import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { mkdtemp, rm } from "node:fs/promises"; // Browser regression test against the real app with a deterministic local API fixture. // npm run build && npm run preview -- --host 127.0.0.1 --port 5198 // WEB_TEST_URL=http://127.0.0.1:5198/ node tests/search-focus.browser.mjs const targetUrl = process.env.WEB_TEST_URL || "http://127.0.0.1:5198/"; assert.ok(new URL(targetUrl).hostname === "127.0.0.1" || new URL(targetUrl).hostname === "localhost"); const browserBinary = process.env.CHROME_EXECUTABLE || ["/usr/bin/chromium", "/usr/bin/google-chrome", "/usr/bin/chromium-browser", `${homedir()}/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome`].find(existsSync) || "chromium"; const profile = await mkdtemp(`${tmpdir()}/sybil-search-focus-browser-`); const browser = spawn(browserBinary, ["--headless", "--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu", "--remote-debugging-port=0", "--remote-allow-origins=*", `--user-data-dir=${profile}`, "--no-first-run"]); let diagnostics = ""; browser.stderr.setEncoding("utf8").on("data", chunk => { diagnostics += chunk; }); let socket; let commandId = 0; const pending = new Map(); const calls = []; const date = "2026-11-04T12:00:00.000Z"; let searchNumber = 0; let streamWasRequested = false; let latestSearch = null; const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); async function waitForDebugPort() { const deadline = Date.now() + 12000; while (Date.now() < deadline) { const match = diagnostics.match(/DevTools listening on ws:\/\/127\.0\.0\.1:(\d+)\//); if (match) return Number(match[1]); await delay(50); } throw new Error(`Chrome did not start: ${diagnostics.slice(-1000)}`); } async function connect() { const port = await waitForDebugPort(); const response = await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: "PUT" }); const tab = await response.json(); socket = new WebSocket(tab.webSocketDebuggerUrl); await new Promise((resolve, reject) => { socket.addEventListener("open", resolve, { once: true }); socket.addEventListener("error", reject, { once: true }); }); socket.addEventListener("message", event => { const message = JSON.parse(event.data); if (message.method === "Fetch.requestPaused") void handlePausedRequest(message.params); if (message.id) { const request = pending.get(message.id); if (request) { pending.delete(message.id); message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result); } } }); await call("Page.enable"); await call("Runtime.enable"); await call("Fetch.enable", { patterns: [{ urlPattern: "*" }] }); await call("Page.addScriptToEvaluateOnNewDocument", { source: 'localStorage.setItem("sybil_admin_token", "search-focus-fixture-token");' }); await call("Page.navigate", { url: targetUrl }); } function call(method, params = {}) { return new Promise((resolve, reject) => { const id = ++commandId; pending.set(id, { resolve, reject }); socket.send(JSON.stringify({ id, method, params })); }); } async function evaluate(expression) { const response = await call("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true }); assert.equal(response.exceptionDetails, undefined, response.exceptionDetails?.text); return response.result?.value; } async function waitFor(expression, message) { const deadline = Date.now() + 12000; while (Date.now() < deadline) { if (await evaluate(expression)) return; await delay(40); } const body = await evaluate('document.body?.innerText.slice(0, 1200) || "no body"').catch(() => "evaluation failed"); throw new Error(`Timed out waiting for ${message}; body=${JSON.stringify(body)}; calls=${JSON.stringify(calls)}; chrome=${diagnostics.slice(-500)}`); } async function fulfill(id, value, contentType = "application/json") { const body = Buffer.from(typeof value === "string" ? value : JSON.stringify(value)).toString("base64"); await call("Fetch.fulfillRequest", { requestId: id, responseCode: 200, responseHeaders: [{ name: "Content-Type", value: contentType }], body }); } async function handlePausedRequest(request) { try { const url = new URL(request.request.url); if (url.origin !== new URL(targetUrl).origin || !url.pathname.startsWith("/api/")) { await call("Fetch.continueRequest", { requestId: request.requestId }); return; } const path = url.pathname; const method = request.request.method; calls.push({ path, method }); if (path.endsWith("/auth/session")) return fulfill(request.requestId, { mode: "token", authenticated: true }); if (path.endsWith("/workspace-items")) return fulfill(request.requestId, { items: latestSearch ? [{ type: "search", ...latestSearch }] : [] }); if (path.endsWith("/models")) return fulfill(request.requestId, { providers: {} }); if (path.endsWith("/chat-tools") || path.endsWith("/agent/tools")) return fulfill(request.requestId, { tools: [] }); if (path.endsWith("/active-runs")) return fulfill(request.requestId, { chats: [], searches: [] }); if (path.endsWith("/agent")) return fulfill(request.requestId, { id: "fixture", title: "Sybil Agent", activity: { state: "idle", isThinking: false, queued: 0, backgroundWorkers: 0, tools: [], queuedMessages: [] }, protocol: "pi-room.v1", websocket: "/v1/agent/ws" }); if (path.endsWith("/agent/messages")) return fulfill(request.requestId, { conversationId: "fixture", messages: [], cursor: "0", beforeCursor: null, hasMore: false }); if (path.endsWith("/push/config")) return fulfill(request.requestId, { version: 1, enabled: false, webPushAvailable: false, apnsAvailable: false, sourceId: "fixture", quarantined: false, policy: { presenceHeartbeatMs: 15000, presenceLeaseMs: 60000 } }); if (path.includes("/presence/")) return fulfill(request.requestId, { expiresAt: date }); if (path.endsWith("/searches") && method === "POST") { const number = ++searchNumber; const body = JSON.parse(request.request.postData || "{}"); const search = { id: `search-${number}`, title: body.query, query: body.query, createdAt: date, updatedAt: date, starred: false, starredAt: null, requestId: null, latencyMs: null, error: null, answerText: null, answerRequestId: null, answerCitations: null, answerError: null, results: [] }; latestSearch = search; return fulfill(request.requestId, { search, reused: false, cacheHit: false }); } if (path.includes("/searches/") && method === "GET") return fulfill(request.requestId, { search: latestSearch }); if (path.includes("/searches/") && path.endsWith("/run/stream")) { streamWasRequested = true; const body = JSON.parse(request.request.postData || "{}"); const result = { id: `result-${searchNumber}`, createdAt: date, rank: 1, title: `Fixture result for ${body.query}`, url: "https://fixture.test/result", publishedDate: null, author: null, text: "Synthetic result; no provider called.", highlights: null, highlightScores: null, score: 1, favicon: null, image: null }; const search = { id: `search-${searchNumber}`, title: body.query, query: body.query, createdAt: date, updatedAt: date, starred: false, starredAt: null, requestId: "fixture-request", latencyMs: 1, error: null, answerText: null, answerRequestId: null, answerCitations: null, answerError: null, results: [result] }; latestSearch = search; await delay(900); const events = `event: search_results\ndata: ${JSON.stringify({ requestId: "fixture-request", results: [result] })}\n\nevent: done\ndata: ${JSON.stringify({ search })}\n\n`; return fulfill(request.requestId, events, "text/event-stream"); } return fulfill(request.requestId, {}); } catch (error) { console.error("API fixture failed", error); await call("Fetch.failRequest", { requestId: request.requestId, errorReason: "Failed" }).catch(() => undefined); } } try { await connect(); await waitFor('document.querySelector("#composer-input") && document.querySelector("#composer-input").isConnected', "app composer"); await evaluate('document.querySelector("button").blur()'); await evaluate('Array.from(document.querySelectorAll("button")).find(button => button.textContent.includes("New search")).click()'); await waitFor('document.activeElement?.id === "composer-input"', "New Search focus"); assert.equal(await evaluate('document.activeElement?.id'), "composer-input", "New Search focuses the search input"); await evaluate('Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value").set.call(document.querySelector("#composer-input"), "first fixture query"); document.querySelector("#composer-input").dispatchEvent(new Event("input", { bubbles: true }))'); await evaluate('Array.from(document.querySelectorAll("button")).find(button => button.getAttribute("aria-label") === "Search").click()'); await waitFor('document.querySelector("#composer-input")?.disabled', "running fixture search"); const streamDeadline = Date.now() + 8000; while (!streamWasRequested && Date.now() < streamDeadline) await delay(25); assert.equal(streamWasRequested, true, "the synthetic stream is pending before focus is moved"); assert.notEqual(await evaluate('document.activeElement?.id'), "composer-input", "starting search does not return focus to the input"); await evaluate('Array.from(document.querySelectorAll("button")).find(button => button.textContent.includes("New chat")).focus()'); assert.equal(await evaluate('document.activeElement?.textContent.includes("New chat")'), true, "search execution does not steal focus from another control"); await waitFor('document.body.innerText.includes("Fixture result for first fixture query")', "fixture result"); assert.equal(await evaluate('document.activeElement?.textContent.includes("New chat")'), true, "search completion preserves the user's focused control"); assert.ok(streamWasRequested, "fixture search stream was requested"); assert.equal(searchNumber, 1); await evaluate('Array.from(document.querySelectorAll("button")).find(button => button.textContent.includes("New search")).click()'); await waitFor('document.activeElement?.id === "composer-input"', "repeated New Search focus"); assert.equal(await evaluate('document.activeElement?.id'), "composer-input", "repeated New Search focuses the input"); assert.equal(calls.filter(call => call.path.endsWith("/run/stream")).length, 1, "exactly one synthetic search ran"); console.log(JSON.stringify({ focusOnNewSearch: true, executionPreservedFocus: true, pendingFocusPreservedAtCompletion: true, repeatedNewSearchFocus: true, normalSearchResultRendered: "Fixture result for first fixture query", fixtureOnly: true, apiRequests: calls.length })); } finally { if (socket) socket.close(); if (browser.exitCode === null) { browser.kill("SIGTERM"); await Promise.race([new Promise(resolve => browser.once("close", resolve)), delay(2000)]); } await rm(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); }