From 33674e0b107a8fb84ca5b92f276079b66b9d8c15 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 2 May 2026 23:39:57 +0700 Subject: [PATCH] refactor(app): clean up find-in-pane slop - A bridge: remove renderer webview find fallback and require the Electron bridge path - B core: tighten pane-find assertions and hide internal search/render types - C adapters: align chat/file search registration with highlightable content - D harness: delete flaky Electron QA harness and simplify keyboard dispatcher scope --- docs/find-in-pane-electron-qa.md | 30 - .../app/e2e/find-in-pane-electron.spec.ts | 1608 ----------------- packages/app/e2e/find-in-pane.spec.ts | 4 +- .../app/src/agent-stream/strategy-native.tsx | 7 +- .../agent-stream-search-model.test.ts | 99 +- .../components/agent-stream-search-model.ts | 142 +- .../components/browser-pane.electron.test.tsx | 23 +- .../src/components/browser-pane.electron.tsx | 69 +- .../file-pane-text-render-data.test.ts | 3 +- .../components/file-pane-text-render-data.ts | 6 +- packages/app/src/components/file-pane.tsx | 56 +- packages/app/src/components/message.tsx | 4 +- .../components/stream-strategy-native.test.ts | 3 - packages/app/src/desktop/host.ts | 6 +- .../keyboard/keyboard-action-dispatcher.ts | 2 - .../app/src/panels/pane-find-registry.test.ts | 19 +- packages/app/src/panels/pane-find-registry.ts | 17 +- packages/app/src/panels/pane-find.test.tsx | 34 +- .../desktop/src/features/browser-webviews.ts | 11 +- 19 files changed, 220 insertions(+), 1923 deletions(-) delete mode 100644 docs/find-in-pane-electron-qa.md delete mode 100644 packages/app/e2e/find-in-pane-electron.spec.ts diff --git a/docs/find-in-pane-electron-qa.md b/docs/find-in-pane-electron-qa.md deleted file mode 100644 index 80692a149..000000000 --- a/docs/find-in-pane-electron-qa.md +++ /dev/null @@ -1,30 +0,0 @@ -# Electron Find QA - -Run only the Electron browser find harness: - -```bash -npm run test:e2e --workspace=@getpaseo/app -- find-in-pane-electron.spec.ts --project "Desktop Chrome" --workers=1 -``` - -The spec starts a fresh desktop app from the current worktree with: - -- per-run isolated `PASEO_HOME` under `/tmp/paseo-find-pane-electron-rerun/home-*` -- per-run isolated Electron user data under `/tmp/paseo-find-pane-electron-rerun/electron-user-data-*` -- `PASEO_LISTEN=127.0.0.1:0`, so it must not use port `6767` -- a local HTTP page containing three `electronneedle` matches - -Expected pass output: one Playwright test passes. Evidence lands in -`/tmp/paseo-find-pane-electron-rerun/`: - -- `electron-find-evidence.json` with timestamps, listener counts, request IDs, match events, and cleanup calls -- `diagnostic-.md` with the latest run diagnosis -- `electron-find-*.png` screenshots -- `electron-dev.log` from the spawned desktop process - -Failure modes: - -- Timeout waiting for `workspace-new-browser`: the desktop app opened, but the workspace did not render tab actions. -- LogBox screenshot after browser open: browser pane crashed before find could run. -- Timeout waiting for `foundEvents`: `webview.findInPage()` returned a request ID, but renderer-side `found-in-page` never arrived. -- Counter mismatch: `found-in-page` arrived, but the shared `FindBar` state did not update to the expected current/total. -- Close/Esc mismatch: native selection or shared find cleanup did not finish. diff --git a/packages/app/e2e/find-in-pane-electron.spec.ts b/packages/app/e2e/find-in-pane-electron.spec.ts deleted file mode 100644 index 60273f026..000000000 --- a/packages/app/e2e/find-in-pane-electron.spec.ts +++ /dev/null @@ -1,1608 +0,0 @@ -import { expect, test, type Page } from "@playwright/test"; -import { spawn, execFile, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { createServer, type Server } from "node:http"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import path from "node:path"; -import { _electron as electronDriver, type ElectronApplication } from "playwright"; -import { connectWorkspaceSetupClient } from "./helpers/workspace-setup"; -import { createTempGitRepo } from "./helpers/workspace"; - -const REPO_ROOT = path.resolve(__dirname, "../../.."); -const OUTPUT_DIR = - process.env.PASEO_ELECTRON_FIND_QA_OUTPUT_DIR ?? "/tmp/paseo-find-pane-electron-rerun"; -const RUN_ID = `${process.pid}-${Date.now()}`; -const PASEO_HOME = path.join(OUTPUT_DIR, `home-${RUN_ID}`); -const USER_DATA_DIR = path.join(OUTPUT_DIR, `electron-user-data-${RUN_ID}`); -let paseoListen = "127.0.0.1:0"; -const QA_PAGE_TEXT = [ - "Electron Webview Find QA", - "alpha electronneedle first", - "beta no match", - "gamma electronneedle second", - "delta ELECTRONNEEDLE third", - "experiment_only_marker", - "focus_test_a", - "focus_test_b", - "control_repeat_unique", - "control_after_reload", -].join("\n\n"); -const QA_PAGE_HTML = `Find QA Page

Electron Webview Find QA

alpha electronneedle first

beta no match

gamma electronneedle second

delta ELECTRONNEEDLE third

experiment_only_marker

focus_test_a

focus_test_b

control_repeat_unique

control_after_reload

`; - -function rootEnv(extra?: Record): NodeJS.ProcessEnv { - const env = { ...process.env, ...extra }; - delete env.npm_config_workspace; - delete env.npm_config_workspaces; - delete env.npm_package_name; - delete env.npm_lifecycle_event; - delete env.npm_lifecycle_script; - delete env.FORCE_COLOR; - env.NO_COLOR = "1"; - env.npm_config_color = "false"; - return env; -} - -function electronLaunchEnv(extra?: Record): Record { - return Object.fromEntries( - Object.entries(rootEnv(extra)).filter((entry): entry is [string, string] => { - return typeof entry[1] === "string"; - }), - ); -} - -interface ElectronProcess { - app: ElectronApplication; - metroChild: ChildProcessWithoutNullStreams; - cdpPort: number; - metroPort: number; - logs: string[]; -} - -interface TempRepo { - path: string; - cleanup: () => Promise; -} - -interface WorkspaceSetupClient { - connect(): Promise; - close(): Promise; - openProject(cwd: string): Promise<{ - workspace: { - id: string; - name: string; - workspaceDirectory: string; - projectRootPath: string; - } | null; - error: string | null; - }>; -} - -interface PageSnapshot { - url: string; - title: string; - bodyText: string | null; -} - -interface QaEvidence { - timestamps: Record; - pages: PageSnapshot[]; - selectedPage: PageSnapshot; - bridge: { - available: boolean; - findInPageType: string | null; - stopFindInPageType: string | null; - onFoundInPageType: string | null; - findCalls: Array<{ - browserId: string; - text: string; - options: Record; - requestId: number | null; - ts: number; - }>; - stopCalls: Array<{ browserId: string; action: string; ts: number }>; - listenerRegistrations: Array<{ browserId: string; ts: number }>; - foundEvents: Array<{ browserId: string; result: unknown; ts: number }>; - }; - mainProcess: { - installed: boolean; - ipcHandleWraps: Array<{ channel: string; ok: boolean; reason?: string }>; - ipcFindCalls: Array<{ args: unknown[]; ts: number; requestId: number | null }>; - ipcStopCalls: Array<{ args: unknown[]; ts: number }>; - guestFoundInPageEvents: Array<{ id: number; url: string; result: unknown; ts: number }>; - ownerForwards: Array<{ id: number; channel: string; payload: unknown; ts: number }>; - webContentsCreated: Array<{ id: number; type: string; url: string; ts: number }>; - } | null; - webview: { - url: string | null; - text: string | null; - webContentsId: number | null; - findInPageType: string | null; - stopFindInPageType: string | null; - }; - app: { - url: string; - title: string; - bodyText: string; - logboxCount: number; - findBarText: string | null; - findInputValue: string | null; - counterText: string | null; - }; - process: { - cdpPort: number; - metroPort: number; - daemonListen: string | null; - serverId: string | null; - }; - sources: { - browserPaneElectron: string[]; - preload: string[]; - main: string[]; - browserWebviews: string[]; - electronDocs: string[]; - }; - hypothesis: string; - cheapestFixShape: string; -} - -function now(): number { - return Date.now(); -} - -function encodeWorkspaceId(workspaceId: string): string { - return `b64_${Buffer.from(workspaceId, "utf8") - .toString("base64") - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/g, "")}`; -} - -function freePort(): Promise { - return new Promise((resolve, reject) => { - const server = createServer(); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (!address || typeof address === "string") { - server.close(() => reject(new Error("Unable to allocate a port."))); - return; - } - const port = address.port; - server.close(() => resolve(port)); - }); - server.on("error", reject); - }); -} - -function execFileText( - command: string, - args: string[], - env?: Record, -): Promise { - return new Promise((resolve, reject) => { - execFile(command, args, { cwd: REPO_ROOT, env: rootEnv(env) }, (error, stdout, stderr) => { - if (error) { - reject(new Error(`${command} ${args.join(" ")} failed\n${stdout}\n${stderr}`)); - return; - } - resolve(stdout.toString()); - }); - }); -} - -async function waitFor( - label: string, - callback: () => Promise, - timeoutMs = 45_000, -): Promise { - const started = now(); - let lastError: unknown = null; - while (now() - started < timeoutMs) { - try { - const result = await callback(); - if (result) return result; - } catch (error) { - lastError = error; - } - await new Promise((resolve) => setTimeout(resolve, 500)); - } - throw new Error(`Timed out waiting for ${label}${lastError ? `: ${String(lastError)}` : ""}`); -} - -async function startStaticServer(): Promise<{ server: Server; port: number }> { - const port = await freePort(); - const server = createServer((request, response) => { - if (request.url === "/" || request.url === "/index.html") { - response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); - response.end(QA_PAGE_HTML); - return; - } - response.writeHead(404, { "content-type": "text/plain" }); - response.end("not found"); - }); - await new Promise((resolve, reject) => { - server.listen(port, "127.0.0.1", resolve); - server.once("error", reject); - }); - return { server, port }; -} - -async function stopServer(server: Server): Promise { - await new Promise((resolve) => server.close(() => resolve())); -} - -async function startElectron(): Promise { - await mkdir(OUTPUT_DIR, { recursive: true }); - const cdpPort = await freePort(); - const metroPort = await freePort(); - const logs: string[] = []; - - await execFileText("npm", [ - "--prefix", - REPO_ROOT, - "run", - "build:main", - "--workspace=@getpaseo/desktop", - ]); - - const metroChild = spawn("npx", ["expo", "start", "--port", String(metroPort)], { - cwd: path.join(REPO_ROOT, "packages/app"), - env: rootEnv({ PASEO_WEB_PLATFORM: "electron" }), - stdio: ["pipe", "pipe", "pipe"], - }); - - const append = (chunk: Buffer) => { - logs.push(chunk.toString()); - }; - metroChild.stdout.on("data", append); - metroChild.stderr.on("data", append); - - try { - await waitFor("Metro dev server", async () => { - if (metroChild.exitCode !== null || metroChild.signalCode !== null) { - throw new Error( - `metro process exited early: ${metroChild.exitCode ?? metroChild.signalCode}`, - ); - } - const response = await fetch(`http://127.0.0.1:${metroPort}`).catch(() => null); - return response ? true : null; - }); - } catch (error) { - await writeFile(path.join(OUTPUT_DIR, "electron-dev-start-failure.log"), logs.join(""), "utf8"); - throw error; - } - - const app = await electronDriver.launch({ - args: [path.join(REPO_ROOT, "packages/desktop")], - cwd: REPO_ROOT, - env: electronLaunchEnv({ - PASEO_HOME, - PASEO_ELECTRON_USER_DATA_DIR: USER_DATA_DIR, - PASEO_LISTEN: paseoListen, - PASEO_ELECTRON_FLAGS: `--remote-debugging-port=${cdpPort} --remote-allow-origins=*`, - EXPO_DEV_URL: `http://localhost:${metroPort}`, - }), - }); - - try { - await waitFor("Electron CDP endpoint", async () => { - const response = await fetch(`http://127.0.0.1:${cdpPort}/json/version`).catch(() => null); - return response?.ok ? true : null; - }); - } catch (error) { - await writeFile(path.join(OUTPUT_DIR, "electron-dev-start-failure.log"), logs.join(""), "utf8"); - throw error; - } - - return { app, metroChild, cdpPort, metroPort, logs }; -} - -async function stopElectron(input: ElectronProcess): Promise { - await input.app.close().catch(() => undefined); - if (input.metroChild.exitCode === null && input.metroChild.signalCode === null) { - input.metroChild.kill("SIGTERM"); - } - await new Promise((resolve) => { - const timer = setTimeout(resolve, 5_000); - input.metroChild.once("exit", () => { - clearTimeout(timer); - resolve(); - }); - }); - await writeFile(path.join(OUTPUT_DIR, "electron-dev.log"), input.logs.join(""), "utf8"); - await execFileText("npm", ["--prefix", REPO_ROOT, "run", "cli", "--", "daemon", "stop"], { - PASEO_HOME, - PASEO_LISTEN: paseoListen, - }).catch(async (error) => { - await writeFile(path.join(OUTPUT_DIR, "daemon-stop-error.txt"), String(error), "utf8"); - }); -} - -async function readServerId(): Promise { - return (await readFile(path.join(PASEO_HOME, "server-id"), "utf8")).trim(); -} - -async function startIsolatedDaemon(): Promise { - await execFileText( - "npm", - [ - "--prefix", - REPO_ROOT, - "run", - "cli", - "--", - "daemon", - "start", - "--listen", - paseoListen, - "--home", - PASEO_HOME, - ], - { PASEO_HOME, PASEO_LISTEN: paseoListen }, - ); -} - -async function readDaemonListen(): Promise { - const output = await execFileText( - "npm", - ["--prefix", REPO_ROOT, "run", "cli", "--", "daemon", "status"], - { PASEO_HOME, PASEO_LISTEN: paseoListen }, - ); - const match = output.match(/Listen\s+([^\s]+)/); - return match?.[1] ?? null; -} - -async function screenshot(page: Page, name: string): Promise { - const filePath = path.join(OUTPUT_DIR, name); - await page.screenshot({ path: filePath, fullPage: true }); - return filePath; -} - -async function readWebviewBodyText(page: Page): Promise { - return page.evaluate(async () => { - const webview = document.querySelector("webview") as - | (Element & { executeJavaScript?: (code: string) => Promise }) - | null; - return webview?.executeJavaScript - ? String(await webview.executeJavaScript("document.body.innerText")) - : ""; - }); -} - -async function snapshotPage(page: Page): Promise { - const evaluated = await page - .evaluate(() => ({ - url: window.location.href, - title: document.title, - bodyText: document.body?.innerText ?? null, - })) - .catch(() => null); - return { - url: evaluated?.url ?? page.url(), - title: evaluated?.title ?? (await page.title().catch(() => "")), - bodyText: evaluated?.bodyText ?? null, - }; -} - -function isAppRendererSnapshot(snapshot: PageSnapshot, metroPort: number): boolean { - const appHosts = [`localhost:${metroPort}`, `127.0.0.1:${metroPort}`]; - return appHosts.some((host) => snapshot.url.includes(host)); -} - -async function collectPageSnapshots(electronApp: ElectronApplication): Promise { - return Promise.all(electronApp.windows().map((page) => snapshotPage(page))); -} - -async function findAppRendererPage( - electronApp: ElectronApplication, - metroPort: number, -): Promise { - return waitFor("Paseo app renderer page", async () => { - for (const page of electronApp.windows()) { - const snapshot = await snapshotPage(page); - if (isAppRendererSnapshot(snapshot, metroPort)) { - return page; - } - } - return null; - }); -} - -async function instrumentBridge(page: Page): Promise { - await page.evaluate(() => { - const desktop = window.paseoDesktop; - const bridge = desktop?.browser; - const qa: NonNullable = { - timestamps: { instrumentationAttached: Date.now() }, - bridgeAvailable: Boolean(bridge), - findInPageType: typeof bridge?.findInPage, - stopFindInPageType: typeof bridge?.stopFindInPage, - onFoundInPageType: typeof bridge?.onFoundInPage, - bridgeFindCalls: [], - bridgeStopCalls: [], - bridgeListenerRegistrations: [], - bridgeFoundEvents: [], - }; - window.__paseoElectronFindQa = qa; - if (!bridge?.findInPage || !bridge.stopFindInPage || !bridge.onFoundInPage) { - return; - } - - const wrapped = bridge; - const originalFindInPage = bridge.findInPage.bind(bridge); - const originalStopFindInPage = bridge.stopFindInPage.bind(bridge); - const originalOnFoundInPage = bridge.onFoundInPage.bind(bridge); - - wrapped.findInPage = (browserId, text, options) => { - const entry: NonNullable["bridgeFindCalls"][number] = { - browserId, - text, - options: { ...options }, - requestId: null, - ts: Date.now(), - }; - qa.timestamps.firstBridgeFindCall ??= entry.ts; - qa.bridgeFindCalls.push(entry); - const result = originalFindInPage(browserId, text, options); - void Promise.resolve(result ?? null).then((requestId) => { - entry.requestId = typeof requestId === "number" ? requestId : null; - return undefined; - }); - return result; - }; - - wrapped.stopFindInPage = (browserId, action) => { - qa.bridgeStopCalls.push({ browserId, action, ts: Date.now() }); - return originalStopFindInPage(browserId, action); - }; - - wrapped.onFoundInPage = (browserId, listener) => { - qa.bridgeListenerRegistrations.push({ browserId, ts: Date.now() }); - return originalOnFoundInPage(browserId, (result) => { - qa.bridgeFoundEvents.push({ browserId, result, ts: Date.now() }); - listener(result); - }); - }; - if (desktop) { - try { - Object.defineProperty(desktop, "browser", { - configurable: true, - enumerable: true, - get: () => wrapped, - }); - } catch { - qa.timestamps.browserDescriptorWrapFailed = Date.now(); - } - } - }); -} - -async function installMainProcessInstrumentation( - electronApp: ElectronApplication, - fixtureOrigin: string, -): Promise { - await electronApp.evaluate(({ app, ipcMain, webContents }, qaOrigin) => { - const globalScope = globalThis as typeof globalThis & { - __paseoFindEvidence?: { - installed: boolean; - ipcHandleWraps: Array<{ channel: string; ok: boolean; reason?: string }>; - ipcFindCalls: Array<{ args: unknown[]; ts: number; requestId: number | null }>; - ipcStopCalls: Array<{ args: unknown[]; ts: number }>; - guestFoundInPageEvents: Array<{ id: number; url: string; result: unknown; ts: number }>; - ownerForwards: Array<{ id: number; channel: string; payload: unknown; ts: number }>; - webContentsCreated: Array<{ id: number; type: string; url: string; ts: number }>; - }; - }; - type MainFindEvidence = NonNullable; - const evidence: MainFindEvidence = { - installed: true, - ipcHandleWraps: [], - ipcFindCalls: [], - ipcStopCalls: [], - guestFoundInPageEvents: [], - ownerForwards: [], - webContentsCreated: [], - }; - globalScope.__paseoFindEvidence = evidence; - - const invokeHandlers = (ipcMain as unknown as { _invokeHandlers?: Map }) - ._invokeHandlers; - const wrapInvoke = (channel: string) => { - const original = invokeHandlers?.get(channel); - if (typeof original !== "function") { - evidence.ipcHandleWraps.push({ channel, ok: false, reason: "handler not found" }); - return; - } - invokeHandlers?.set(channel, (event: unknown, ...args: unknown[]) => { - const ts = Date.now(); - const result = (original as (event: unknown, ...args: unknown[]) => unknown)( - event, - ...args, - ); - if (channel === "paseo:browser:find-in-page") { - const entry = { args, ts, requestId: null as number | null }; - evidence.ipcFindCalls.push(entry); - void Promise.resolve(result).then((requestId) => { - entry.requestId = typeof requestId === "number" ? requestId : null; - return undefined; - }); - } else { - evidence.ipcStopCalls.push({ - args, - ts, - }); - } - return result; - }); - evidence.ipcHandleWraps.push({ channel, ok: true }); - }; - wrapInvoke("paseo:browser:find-in-page"); - wrapInvoke("paseo:browser:stop-find-in-page"); - - const attachFoundListener = (contents: Electron.WebContents) => { - if ((contents as unknown as { __paseoFindQaAttached?: boolean }).__paseoFindQaAttached) { - return; - } - (contents as unknown as { __paseoFindQaAttached?: boolean }).__paseoFindQaAttached = true; - evidence.webContentsCreated.push({ - id: contents.id, - type: contents.getType(), - url: contents.getURL(), - ts: Date.now(), - }); - contents.on("found-in-page", (_event, result) => { - evidence.guestFoundInPageEvents.push({ - id: contents.id, - url: contents.getURL(), - result, - ts: Date.now(), - }); - }); - const originalSend = contents.send.bind(contents); - contents.send = ((channel: string, ...args: unknown[]) => { - if (channel === "paseo:event:browser-found-in-page") { - evidence.ownerForwards.push({ - id: contents.id, - channel, - payload: args[0] ?? null, - ts: Date.now(), - }); - } - return originalSend(channel, ...args); - }) as typeof contents.send; - }; - - for (const contents of webContents.getAllWebContents()) { - attachFoundListener(contents); - } - app.on("web-contents-created", (_event, contents) => { - attachFoundListener(contents); - contents.on("did-navigate", () => { - if (contents.getURL().startsWith(qaOrigin)) { - evidence.webContentsCreated.push({ - id: contents.id, - type: contents.getType(), - url: contents.getURL(), - ts: Date.now(), - }); - } - }); - }); - }, fixtureOrigin); -} - -async function readMainProcessEvidence( - electronApp: ElectronApplication, -): Promise { - return electronApp.evaluate(() => { - const globalScope = globalThis as typeof globalThis & { - __paseoFindEvidence?: QaEvidence["mainProcess"]; - }; - return globalScope.__paseoFindEvidence ?? null; - }); -} - -interface DiagnosticExperimentEvidence { - electronVersion: string; - control: { - target: { id: number; type: string; url: string } | null; - requestId: number | null; - events: Array<{ sourceId: number; type: string; url: string; result: unknown; ts: number }>; - }; - guest: { - target: { id: number; type: string; url: string } | null; - requestId: number | null; - contentsAtFind: Array<{ id: number; type: string; url: string }>; - allEvents: Array<{ sourceId: number; type: string; url: string; result: unknown; ts: number }>; - }; - conclusion: string; -} - -// eslint-disable-next-line no-unused-vars -async function runDiagnosticExperiment( - electronApp: ElectronApplication, - fixtureOrigin: string, -): Promise { - return electronApp.evaluate(async ({ webContents }, qaOrigin) => { - interface ContentsSummary { - id: number; - type: string; - url: string; - } - interface FindEvent extends ContentsSummary { - sourceId: number; - result: unknown; - ts: number; - } - const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - const summarize = (contents: Electron.WebContents): ContentsSummary => ({ - id: contents.id, - type: contents.getType(), - url: contents.getURL(), - }); - const allContents = () => - webContents.getAllWebContents().filter((contents) => !contents.isDestroyed()); - const mainWindow = - allContents().find((contents) => contents.getType() === "window") ?? allContents()[0] ?? null; - const guest = - allContents().find((contents) => contents.getURL().startsWith(qaOrigin)) ?? - allContents().find((contents) => contents.getType() === "webview") ?? - null; - - const controlEvents: FindEvent[] = []; - const guestEvents: FindEvent[] = []; - const addListener = (contents: Electron.WebContents, bucket: FindEvent[]) => { - const listener = (_event: Electron.Event, result: Electron.Result) => { - bucket.push({ - sourceId: contents.id, - ...summarize(contents), - result, - ts: Date.now(), - }); - }; - contents.on("found-in-page", listener); - return () => contents.removeListener("found-in-page", listener); - }; - - let controlRequestId: number | null = null; - const cleanupControl = mainWindow ? [addListener(mainWindow, controlEvents)] : []; - if (mainWindow) { - controlRequestId = mainWindow.findInPage("Workspace"); - await sleep(2000); - mainWindow.stopFindInPage("clearSelection"); - } - for (const cleanup of cleanupControl) cleanup(); - - const contentsAtFind = allContents().map(summarize); - const cleanupGuest = allContents().map((contents) => addListener(contents, guestEvents)); - let guestRequestId: number | null = null; - if (guest) { - guestRequestId = guest.findInPage("experiment_only_marker"); - } - await sleep(2000); - if (guest) { - guest.stopFindInPage("clearSelection"); - } - if (mainWindow) { - mainWindow.stopFindInPage("clearSelection"); - } - for (const cleanup of cleanupGuest) cleanup(); - - let conclusion = - "No WebContents delivered found-in-page for either the main-window control or guest find."; - if (controlEvents.length > 0 && guestEvents.length > 0) { - conclusion = "Both main-window and guest WebContents delivered found-in-page events."; - } else if (controlEvents.length > 0) { - conclusion = - "Main-window find delivers found-in-page, but guest WebContents find does not deliver any found-in-page event."; - } else if (guestEvents.length > 0) { - conclusion = - "Guest WebContents delivered found-in-page, but the main-window control did not."; - } - - return { - electronVersion: process.versions.electron, - control: { - target: mainWindow ? summarize(mainWindow) : null, - requestId: controlRequestId, - events: controlEvents, - }, - guest: { - target: guest ? summarize(guest) : null, - requestId: guestRequestId, - contentsAtFind, - allEvents: guestEvents, - }, - conclusion, - }; - }, fixtureOrigin); -} - -// eslint-disable-next-line no-unused-vars -async function writeDiagnosticExperiment( - experiment: DiagnosticExperimentEvidence, -): Promise { - const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - const filePath = path.join(OUTPUT_DIR, `diagnostic-experiment-${timestamp}.md`); - const lines = [ - "# Electron FindInPage Diagnostic Experiment", - "", - `Generated: ${new Date().toISOString()}`, - `Electron version: ${experiment.electronVersion}`, - "", - "## A. Main Window Control", - "", - `- Target: ${JSON.stringify(experiment.control.target)}`, - `- Request ID: ${experiment.control.requestId ?? ""}`, - `- Events: ${JSON.stringify(experiment.control.events)}`, - "", - "## B. Guest WebContents Sweep", - "", - `- Guest target: ${JSON.stringify(experiment.guest.target)}`, - `- Request ID: ${experiment.guest.requestId ?? ""}`, - `- WebContents at find-call time: ${JSON.stringify(experiment.guest.contentsAtFind)}`, - `- Events from any WebContents: ${JSON.stringify(experiment.guest.allEvents)}`, - "", - "## Conclusion", - "", - experiment.conclusion, - "", - ]; - await writeFile(filePath, lines.join("\n"), "utf8"); - await writeFile( - path.join(OUTPUT_DIR, "diagnostic-experiment-evidence.json"), - JSON.stringify(experiment, null, 2), - "utf8", - ); - return filePath; -} - -interface DiagnosticFocusExperimentEvidence { - electronVersion: string; - visibleWebview: { id: number; type: string; url: string } | null; - implLookup: { - browserId: string | null; - contentsId: number | null; - error: string | null; - }; - withoutFocus: { - requestId: number | null; - events: Array<{ sourceId: number; type: string; url: string; result: unknown; ts: number }>; - }; - withGuestFocus: { - requestId: number | null; - events: Array<{ sourceId: number; type: string; url: string; result: unknown; ts: number }>; - }; - conclusion: string; -} - -// eslint-disable-next-line no-unused-vars -async function runDiagnosticFocusExperiment( - electronApp: ElectronApplication, - browserWebviewsModulePath: string, -): Promise { - // eslint-disable-next-line complexity - return electronApp.evaluate(async ({ webContents }, modulePath) => { - interface ContentsSummary { - id: number; - type: string; - url: string; - } - interface FindEvent extends ContentsSummary { - sourceId: number; - result: unknown; - ts: number; - } - const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - const summarize = (contents: Electron.WebContents): ContentsSummary => ({ - id: contents.id, - type: contents.getType(), - url: contents.getURL(), - }); - const liveContents = () => - webContents.getAllWebContents().filter((contents) => !contents.isDestroyed()); - const owner = - liveContents().find((contents) => contents.getType() === "window") ?? - liveContents()[0] ?? - null; - const guest = - liveContents().find((contents) => contents.getType() === "webview") ?? - liveContents().find((contents) => contents.getURL().startsWith("http://127.0.0.1:")) ?? - null; - const addOwnerListener = (bucket: FindEvent[]) => { - if (!owner) return () => undefined; - const listener = (_event: Electron.Event, result: Electron.Result) => { - bucket.push({ - sourceId: owner.id, - ...summarize(owner), - result, - ts: Date.now(), - }); - }; - owner.on("found-in-page", listener); - return () => owner.removeListener("found-in-page", listener); - }; - - const implLookup: DiagnosticFocusExperimentEvidence["implLookup"] = { - browserId: null, - contentsId: null, - error: null, - }; - try { - const nodeRequire = process.mainModule?.require.bind(process.mainModule); - if (!nodeRequire) { - throw new Error("process.mainModule.require is unavailable"); - } - const browserWebviews = nodeRequire(modulePath) as { - getPaseoBrowserIdForWebContents?: (contents: Electron.WebContents | null) => string | null; - getPaseoBrowserWebContents?: (browserId: string) => Electron.WebContents | null; - }; - implLookup.browserId = - browserWebviews.getPaseoBrowserIdForWebContents?.(guest ?? null) ?? null; - const implContents = implLookup.browserId - ? browserWebviews.getPaseoBrowserWebContents?.(implLookup.browserId) - : null; - implLookup.contentsId = implContents?.id ?? null; - } catch (error) { - implLookup.error = error instanceof Error ? error.message : String(error); - } - - const withoutFocusEvents: FindEvent[] = []; - const cleanupWithoutFocus = addOwnerListener(withoutFocusEvents); - const withoutFocusRequestId = guest?.findInPage("focus_test_a", { findNext: false }) ?? null; - await sleep(1500); - cleanupWithoutFocus(); - guest?.stopFindInPage("clearSelection"); - owner?.stopFindInPage("clearSelection"); - - const withGuestFocusEvents: FindEvent[] = []; - const cleanupWithGuestFocus = addOwnerListener(withGuestFocusEvents); - guest?.focus(); - const withGuestFocusRequestId = guest?.findInPage("focus_test_b", { findNext: false }) ?? null; - await sleep(1500); - cleanupWithGuestFocus(); - guest?.stopFindInPage("clearSelection"); - owner?.stopFindInPage("clearSelection"); - - let conclusion = "Neither blurred nor explicitly focused guest find delivered owner events."; - if (withoutFocusEvents.length > 0 && withGuestFocusEvents.length > 0) { - conclusion = - "Focus is not required: both blurred and explicitly focused guest find delivered owner events."; - } else if (withoutFocusEvents.length === 0 && withGuestFocusEvents.length > 0) { - conclusion = - "Guest focus is required: blurred guest find produced no owner events, focused guest find did."; - } else if (withoutFocusEvents.length > 0) { - conclusion = "Blurred guest find delivered owner events, but focused guest find did not."; - } - - return { - electronVersion: process.versions.electron, - visibleWebview: guest ? summarize(guest) : null, - implLookup, - withoutFocus: { - requestId: withoutFocusRequestId, - events: withoutFocusEvents, - }, - withGuestFocus: { - requestId: withGuestFocusRequestId, - events: withGuestFocusEvents, - }, - conclusion, - }; - }, browserWebviewsModulePath); -} - -// eslint-disable-next-line no-unused-vars -async function writeDiagnosticFocusExperiment( - experiment: DiagnosticFocusExperimentEvidence, -): Promise { - const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - const filePath = path.join(OUTPUT_DIR, `diagnostic-focus-experiment-${timestamp}.md`); - const lines = [ - "# Electron Find Focus Diagnostic Experiment", - "", - `Generated: ${new Date().toISOString()}`, - `Electron version: ${experiment.electronVersion}`, - "", - "## WebContents Identity", - "", - `- Visible webview: ${JSON.stringify(experiment.visibleWebview)}`, - `- Impl lookup: ${JSON.stringify(experiment.implLookup)}`, - "", - "## A. Blurred Guest", - "", - `- Request ID: ${experiment.withoutFocus.requestId ?? ""}`, - `- Events: ${JSON.stringify(experiment.withoutFocus.events)}`, - `- Fired events: ${experiment.withoutFocus.events.length > 0}`, - "", - "## B. Explicit Guest Focus", - "", - `- Request ID: ${experiment.withGuestFocus.requestId ?? ""}`, - `- Events: ${JSON.stringify(experiment.withGuestFocus.events)}`, - `- Fired events: ${experiment.withGuestFocus.events.length > 0}`, - "", - "## Conclusion", - "", - experiment.conclusion, - "", - ]; - await writeFile(filePath, lines.join("\n"), "utf8"); - return filePath; -} - -interface DiagnosticControlExperimentEvidence { - electronVersion: string; - contentsAtStart: Array<{ - id: number; - type: string; - url: string; - isDestroyed: boolean; - foundInPageListenerCount: number; - }>; - guest: { id: number; type: string; url: string } | null; - owner: { id: number; type: string; url: string } | null; - repeatUnique: { - requestId: number | null; - events: Array<{ sourceId: number; type: string; url: string; result: unknown; ts: number }>; - }; - ownerControl: { - requestId: number | null; - events: Array<{ sourceId: number; type: string; url: string; result: unknown; ts: number }>; - }; - afterReload: { - requestId: number | null; - didStopLoading: boolean; - events: Array<{ sourceId: number; type: string; url: string; result: unknown; ts: number }>; - }; - conclusion: string; -} - -// eslint-disable-next-line no-unused-vars -async function runDiagnosticControlExperiment( - electronApp: ElectronApplication, - guestId: number | null, -): Promise { - // eslint-disable-next-line complexity - return electronApp.evaluate(async ({ webContents }, qaGuestId) => { - interface ContentsSummary { - id: number; - type: string; - url: string; - } - interface FindEvent extends ContentsSummary { - sourceId: number; - result: unknown; - ts: number; - } - const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - const liveContents = () => - webContents.getAllWebContents().filter((contents) => !contents.isDestroyed()); - const summarize = (contents: Electron.WebContents): ContentsSummary => ({ - id: contents.id, - type: contents.getType(), - url: contents.getURL(), - }); - const contentsAtStart = liveContents().map((contents) => { - const summary = summarize(contents); - return { - id: summary.id, - type: summary.type, - url: summary.url, - isDestroyed: contents.isDestroyed(), - foundInPageListenerCount: contents.listenerCount("found-in-page"), - }; - }); - const owner = - liveContents().find((contents) => contents.getType() === "window") ?? - liveContents()[0] ?? - null; - const guestFromId = typeof qaGuestId === "number" ? webContents.fromId(qaGuestId) : null; - const guest = - guestFromId && !guestFromId.isDestroyed() - ? guestFromId - : (liveContents().find((contents) => contents.getType() === "webview") ?? null); - - const collectFind = async ( - target: Electron.WebContents | null, - text: string, - timeoutMs: number, - ) => { - const events: FindEvent[] = []; - const cleanups = liveContents().map((contents) => { - const listener = (_event: Electron.Event, result: Electron.Result) => { - events.push({ - sourceId: contents.id, - ...summarize(contents), - result, - ts: Date.now(), - }); - }; - contents.on("found-in-page", listener); - return () => contents.removeListener("found-in-page", listener); - }); - const requestId = target?.findInPage(text, { findNext: false }) ?? null; - await sleep(timeoutMs); - for (const cleanup of cleanups) cleanup(); - target?.stopFindInPage("clearSelection"); - owner?.stopFindInPage("clearSelection"); - guest?.stopFindInPage("clearSelection"); - return { requestId, events }; - }; - - const repeatUnique = await collectFind(guest, "control_repeat_unique", 2000); - const ownerControl = await collectFind(owner, "Workspace", 2000); - - let didStopLoading = false; - if (guest) { - await new Promise((resolve) => { - let settled = false; - const finish = () => { - if (settled) return; - settled = true; - clearTimeout(timer); - // eslint-disable-next-line promise/no-multiple-resolved - resolve(); - }; - const timer = setTimeout(finish, 5000); - const stopLoading = () => { - didStopLoading = true; - finish(); - }; - guest.once("did-stop-loading", stopLoading); - guest.reload(); - }); - } - const afterReload = await collectFind(guest, "control_after_reload", 2000); - - let conclusion = "Electron findInPage did not deliver events on guest or owner controls."; - if (repeatUnique.events.length > 0) { - conclusion = "Guest findInPage still delivers events at the failing moment."; - } else if (ownerControl.events.length > 0 && afterReload.events.length > 0) { - conclusion = - "Top-level find works and reloading revives guest find delivery; the guest find path is stale before reload."; - } else if (ownerControl.events.length > 0) { - conclusion = - "Top-level find works, but guest find delivery is broken at this point even after reload."; - } else if (afterReload.events.length > 0) { - conclusion = "Reload revives guest find delivery, but top-level owner control did not fire."; - } - - return { - electronVersion: process.versions.electron, - contentsAtStart, - guest: guest ? summarize(guest) : null, - owner: owner ? summarize(owner) : null, - repeatUnique, - ownerControl, - afterReload: { - requestId: afterReload.requestId, - didStopLoading, - events: afterReload.events, - }, - conclusion, - }; - }, guestId); -} - -// eslint-disable-next-line no-unused-vars -async function writeDiagnosticControlExperiment( - experiment: DiagnosticControlExperimentEvidence, -): Promise { - const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - const filePath = path.join(OUTPUT_DIR, `diagnostic-control-experiment-${timestamp}.md`); - const lines = [ - "# Electron Find Control Diagnostic Experiment", - "", - `Generated: ${new Date().toISOString()}`, - `Electron version: ${experiment.electronVersion}`, - "", - "## WebContents", - "", - `- Contents at start: ${JSON.stringify(experiment.contentsAtStart)}`, - `- Guest target: ${JSON.stringify(experiment.guest)}`, - `- Owner target: ${JSON.stringify(experiment.owner)}`, - "", - "## A. Guest Repeat Unique", - "", - `- Request ID: ${experiment.repeatUnique.requestId ?? ""}`, - `- Event count: ${experiment.repeatUnique.events.length}`, - `- Events: ${JSON.stringify(experiment.repeatUnique.events)}`, - "", - "## B. Owner Control", - "", - `- Request ID: ${experiment.ownerControl.requestId ?? ""}`, - `- Event count: ${experiment.ownerControl.events.length}`, - `- Events: ${JSON.stringify(experiment.ownerControl.events)}`, - "", - "## C. Guest After Reload", - "", - `- did-stop-loading observed: ${experiment.afterReload.didStopLoading}`, - `- Request ID: ${experiment.afterReload.requestId ?? ""}`, - `- Event count: ${experiment.afterReload.events.length}`, - `- Events: ${JSON.stringify(experiment.afterReload.events)}`, - "", - "## Conclusion", - "", - experiment.conclusion, - "", - ]; - await writeFile(filePath, lines.join("\n"), "utf8"); - return filePath; -} - -// eslint-disable-next-line complexity -async function writeHarnessBlocker(input: { - reason: string; - diagnosticPath: string | null; - experimentPath: string | null; - evidence: QaEvidence | null; -}): Promise { - const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - const filePath = path.join(OUTPUT_DIR, `harness-blocker-${timestamp}.md`); - const lines = [ - "# Electron Find Harness Blocker", - "", - `Generated: ${new Date().toISOString()}`, - `Reason: ${input.reason}`, - `Diagnostic: ${input.diagnosticPath ?? ""}`, - `Diagnostic experiment: ${input.experimentPath ?? ""}`, - "", - "## Focus State", - "", - `- Selected page: ${JSON.stringify(input.evidence?.selectedPage ?? null)}`, - `- Find bar text: ${JSON.stringify(input.evidence?.app.findBarText ?? null)}`, - `- Input value: ${JSON.stringify(input.evidence?.app.findInputValue ?? null)}`, - `- Counter text: ${JSON.stringify(input.evidence?.app.counterText ?? null)}`, - `- Webview URL: ${JSON.stringify(input.evidence?.webview.url ?? null)}`, - "", - "## Main Process", - "", - `- Find IPC calls: ${JSON.stringify(input.evidence?.mainProcess?.ipcFindCalls ?? [])}`, - `- Electron found-in-page events: ${JSON.stringify( - input.evidence?.mainProcess?.guestFoundInPageEvents ?? [], - )}`, - `- Owner forwards: ${JSON.stringify(input.evidence?.mainProcess?.ownerForwards ?? [])}`, - "", - ]; - await writeFile(filePath, lines.join("\n"), "utf8"); - return filePath; -} - -async function collectEvidence( - page: Page, - electronApp: ElectronApplication, - processInfo: ElectronProcess, - daemonListen: string | null, - serverId: string | null, -): Promise { - const pages = await collectPageSnapshots(electronApp); - const mainProcess = await readMainProcessEvidence(electronApp).catch(() => null); - const selectedPage = await snapshotPage(page); - const runtime = await page.evaluate(async () => { - const webview = document.querySelector("webview") as - | (HTMLElement & { - getURL?: () => string; - getWebContentsId?: () => number; - executeJavaScript?: (code: string) => Promise; - findInPage?: unknown; - stopFindInPage?: unknown; - }) - | null; - const input = document.querySelector( - "[data-testid='pane-find-input']", - ) as HTMLInputElement | null; - const bodyText = document.body.innerText; - return { - app: { - url: location.href, - title: document.title, - bodyText, - logboxCount: document.querySelectorAll("[data-testid='logbox_title']").length, - findBarText: document.querySelector("[data-testid='pane-find-bar']")?.textContent ?? null, - findInputValue: input?.value ?? null, - counterText: bodyText.match(/\b\d+\s*\/\s*\d+\b/)?.[0] ?? null, - }, - webview: { - url: webview?.getURL?.() ?? null, - text: webview?.executeJavaScript - ? ((await webview.executeJavaScript("document.body.innerText")) as string) - : null, - webContentsId: webview?.getWebContentsId?.() ?? null, - findInPageType: typeof webview?.findInPage, - stopFindInPageType: typeof webview?.stopFindInPage, - }, - qa: window.__paseoElectronFindQa ?? { - timestamps: {}, - bridgeAvailable: false, - findInPageType: null, - stopFindInPageType: null, - onFoundInPageType: null, - bridgeFindCalls: [], - bridgeStopCalls: [], - bridgeListenerRegistrations: [], - bridgeFoundEvents: [], - }, - }; - }); - - return { - timestamps: runtime.qa.timestamps, - pages, - selectedPage, - bridge: { - available: runtime.qa.bridgeAvailable, - findInPageType: runtime.qa.findInPageType, - stopFindInPageType: runtime.qa.stopFindInPageType, - onFoundInPageType: runtime.qa.onFoundInPageType, - findCalls: runtime.qa.bridgeFindCalls, - stopCalls: runtime.qa.bridgeStopCalls, - listenerRegistrations: runtime.qa.bridgeListenerRegistrations, - foundEvents: runtime.qa.bridgeFoundEvents, - }, - mainProcess, - webview: runtime.webview, - app: runtime.app, - process: { - cdpPort: processInfo.cdpPort, - metroPort: processInfo.metroPort, - daemonListen, - serverId, - }, - sources: { - browserPaneElectron: [ - "packages/app/src/components/browser-pane.electron.tsx:464-485 prefers getDesktopHost().browser.findInPage before falling back to webview.findInPage.", - "packages/app/src/components/browser-pane.electron.tsx:681-701 subscribes through getDesktopHost().browser.onFoundInPage when the bridge exists.", - ], - preload: [ - "packages/desktop/src/preload.ts:68-96 exposes browser.findInPage, stopFindInPage, and onFoundInPage on window.paseoDesktop.browser.", - ], - main: [ - "packages/desktop/src/main.ts:223-242 handles paseo:browser:find-in-page by calling the guest WebContents findInPage.", - "packages/desktop/src/main.ts:245-260 handles paseo:browser:stop-find-in-page.", - "packages/desktop/src/main.ts:381-385 registers attached browser WebContents with the owner BrowserWindow WebContents.", - ], - browserWebviews: [ - "packages/desktop/src/features/browser-webviews.ts:20-34 forwards guest WebContents found-in-page results to the owner renderer.", - "packages/desktop/src/features/browser-webviews.ts:59-66 resolves WebContents by browserId for main-process find.", - ], - electronDocs: [ - "https://www.electronjs.org/docs/latest/api/web-contents#event-found-in-page documents main-process webContents found-in-page for contents.findInPage.", - ], - }, - hypothesis: - "The main-process bridge should make renderer webview found-in-page delivery irrelevant; failure now means the app renderer did not receive or consume bridge results.", - cheapestFixShape: - "Keep the app renderer as the only Playwright control target, assert the shared FindBar counter, and use window.paseoDesktop.browser instrumentation only as diagnostic evidence around bridge calls and result callbacks.", - }; -} - -async function writeEvidence(evidence: QaEvidence): Promise { - await writeFile( - path.join(OUTPUT_DIR, "electron-find-evidence.json"), - JSON.stringify(evidence, null, 2), - "utf8", - ); -} - -// eslint-disable-next-line complexity -async function writeDiagnostic(evidence: QaEvidence): Promise { - const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - const filePath = path.join(OUTPUT_DIR, `diagnostic-${timestamp}.md`); - const pageLines = evidence.pages.map( - (page, index) => - `- page ${index + 1}: title=${JSON.stringify(page.title)} url=${JSON.stringify(page.url)}`, - ); - const sourceReferenceLines: string[] = []; - for (const [group, refs] of Object.entries(evidence.sources)) { - sourceReferenceLines.push(`### ${group}`, ""); - for (const ref of refs) { - sourceReferenceLines.push(`- ${ref}`); - } - sourceReferenceLines.push(""); - } - const lines = [ - "# Electron Browser Find Diagnostic", - "", - `Generated: ${new Date().toISOString()}`, - "", - "## Run", - "", - `- Metro: http://localhost:${evidence.process.metroPort}`, - `- CDP: http://127.0.0.1:${evidence.process.cdpPort}`, - `- Daemon: ${evidence.process.daemonListen ?? ""}`, - `- Server ID: ${evidence.process.serverId ?? ""}`, - `- Selected renderer title: ${JSON.stringify(evidence.selectedPage.title)}`, - `- Selected renderer URL: ${evidence.selectedPage.url}`, - `- Webview URL: ${evidence.webview.url ?? ""}`, - "", - "## Pages", - "", - ...pageLines, - "", - "## Findings", - "", - `- selected app renderer: ${isAppRendererSnapshot(evidence.selectedPage, evidence.process.metroPort)}`, - `- app body starts with guest page only: ${evidence.app.bodyText.trim() === "Find QA Page"}`, - `- bridge available: ${evidence.bridge.available}`, - `- bridge findInPage typeof: ${evidence.bridge.findInPageType}`, - `- bridge stopFindInPage typeof: ${evidence.bridge.stopFindInPageType}`, - `- bridge onFoundInPage typeof: ${evidence.bridge.onFoundInPageType}`, - `- first bridge find timestamp: ${evidence.timestamps.firstBridgeFindCall ?? ""}`, - `- bridge listener registrations: ${JSON.stringify(evidence.bridge.listenerRegistrations)}`, - `- bridge findInPage calls: ${JSON.stringify(evidence.bridge.findCalls)}`, - `- bridge found-in-page events observed in app renderer: ${JSON.stringify(evidence.bridge.foundEvents)}`, - `- bridge stopFindInPage calls: ${JSON.stringify(evidence.bridge.stopCalls)}`, - `- main-process instrumentation installed: ${evidence.mainProcess?.installed ?? false}`, - `- main-process ipc handler wraps: ${JSON.stringify(evidence.mainProcess?.ipcHandleWraps ?? [])}`, - `- main-process find IPC calls: ${JSON.stringify(evidence.mainProcess?.ipcFindCalls ?? [])}`, - `- main-process stop IPC calls: ${JSON.stringify(evidence.mainProcess?.ipcStopCalls ?? [])}`, - `- main-process Electron found-in-page events: ${JSON.stringify( - evidence.mainProcess?.guestFoundInPageEvents ?? [], - )}`, - `- main-process owner forwards: ${JSON.stringify(evidence.mainProcess?.ownerForwards ?? [])}`, - `- main-process webContents observed: ${JSON.stringify( - evidence.mainProcess?.webContentsCreated ?? [], - )}`, - `- webview.getWebContentsId(): ${evidence.webview.webContentsId ?? ""}`, - `- webview text matches fixture: ${evidence.webview.text === QA_PAGE_TEXT}`, - `- UI find bar text: ${JSON.stringify(evidence.app.findBarText)}`, - `- UI input value: ${JSON.stringify(evidence.app.findInputValue)}`, - `- UI counter text: ${JSON.stringify(evidence.app.counterText)}`, - `- LogBox count: ${evidence.app.logboxCount}`, - "", - "## Event Pattern", - "", - "- This harness drives only the Paseo app renderer page. Guest `` pages are listed as evidence but are not used for keyboard, screenshot, evaluate, or assertions.", - "- The current implementation uses the main-process bridge: the renderer calls `window.paseoDesktop.browser.findInPage`, main calls `WebContents.findInPage`, main receives `webContents.on('found-in-page')`, then preload forwards the result to the app renderer.", - "", - "## Source References", - "", - ...sourceReferenceLines, - "## Hypothesis", - "", - evidence.hypothesis, - "", - "## Cheapest Fix Shape", - "", - evidence.cheapestFixShape, - "", - ]; - await writeFile(filePath, lines.join("\n"), "utf8"); - return filePath; -} - -test.describe("Electron browser in-pane find", () => { - // eslint-disable-next-line complexity - test("forwards Cmd+F find through the Electron app renderer bridge", async () => { - test.setTimeout(150_000); - - let staticServer: Server | null = null; - let electron: ElectronProcess | null = null; - let appPage: Page | null = null; - let workspaceClient: WorkspaceSetupClient | null = null; - let repo: TempRepo | null = null; - let evidence: QaEvidence | null = null; - let diagnosticPath: string | null = null; - let experimentPath: string | null = null; - let focusExperimentPath: string | null = null; - let controlExperimentPath: string | null = null; - - try { - const staticSite = await startStaticServer(); - staticServer = staticSite.server; - paseoListen = `127.0.0.1:${await freePort()}`; - await startIsolatedDaemon(); - electron = await startElectron(); - const serverId = await readServerId(); - const daemonListen = await waitFor("isolated desktop daemon", readDaemonListen, 45_000); - if (daemonListen === "127.0.0.1:6767" || daemonListen === "localhost:6767") { - throw new Error("Refusing to run Electron find QA against port 6767."); - } - const daemonPort = daemonListen.split(":").at(-1); - if (!daemonPort) { - throw new Error(`Could not parse daemon port from ${daemonListen}`); - } - process.env.E2E_DAEMON_PORT = daemonPort; - process.env.E2E_SERVER_ID = serverId; - workspaceClient = await connectWorkspaceSetupClient(); - repo = await createTempGitRepo("electron-find-pane-", { - files: [{ path: "README.find.md", content: "electron find pane bootstrap\n" }], - }); - const workspaceResult = await workspaceClient.openProject(repo.path); - if (!workspaceResult.workspace) { - throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`); - } - - appPage = await findAppRendererPage(electron.app, electron.metroPort); - - const workspaceRoute = `http://localhost:${electron.metroPort}/h/${serverId}/workspace/${encodeWorkspaceId( - workspaceResult.workspace.id, - )}`; - await appPage.goto(workspaceRoute); - appPage = await findAppRendererPage(electron.app, electron.metroPort); - await appPage - .getByTestId("workspace-new-browser") - .waitFor({ state: "visible", timeout: 30_000 }); - await screenshot(appPage, "electron-find-01-workspace.png"); - - await instrumentBridge(appPage); - await appPage.getByTestId("workspace-new-browser").click(); - const webviewLocator = appPage.locator("webview").first(); - await webviewLocator.waitFor({ state: "attached", timeout: 15_000 }); - await appPage.waitForTimeout(1_000); - await screenshot(appPage, "electron-find-02-browser-opened.png"); - await expect(appPage.getByTestId("logbox_title")).toHaveCount(0); - - const urlInput = appPage.getByRole("textbox", { name: "Browser URL" }); - await urlInput.fill(`http://127.0.0.1:${staticSite.port}/index.html`); - await urlInput.press("Enter"); - await appPage.waitForFunction( - (port) => { - const webview = document.querySelector("webview") as - | (Element & { getURL?: () => string }) - | null; - return webview?.getURL?.().includes(`127.0.0.1:${port}`); - }, - staticSite.port, - { timeout: 15_000 }, - ); - const loadedAppPage = appPage; - await expect - .poll(() => readWebviewBodyText(loadedAppPage), { timeout: 10_000 }) - .toContain("electronneedle"); - await screenshot(appPage, "electron-find-03-page-loaded.png"); - await installMainProcessInstrumentation(electron.app, `http://127.0.0.1:${staticSite.port}`); - await instrumentBridge(appPage); - - const selectedPageBeforeFind = await snapshotPage(appPage); - if (!isAppRendererSnapshot(selectedPageBeforeFind, electron.metroPort)) { - throw new Error( - `Refusing to dispatch keyboard to non-app page: ${selectedPageBeforeFind.title} ${selectedPageBeforeFind.url}`, - ); - } - - const box = await webviewLocator.boundingBox(); - if (!box) throw new Error("webview has no bounding box"); - await appPage.evaluate(() => { - if (document.activeElement instanceof HTMLElement) { - document.activeElement.blur(); - } - window.focus(); - }); - await appPage.mouse.click(box.x + Math.min(160, box.width / 2), Math.max(24, box.y - 110)); - await appPage.waitForTimeout(250); - const activeElementBeforeShortcut = await appPage.evaluate(() => ({ - tagName: document.activeElement?.tagName ?? null, - testId: document.activeElement?.getAttribute("data-testid") ?? null, - ariaLabel: document.activeElement?.getAttribute("aria-label") ?? null, - })); - if (activeElementBeforeShortcut.tagName === "WEBVIEW") { - throw new Error( - `Host focus failed before Cmd+F: ${JSON.stringify(activeElementBeforeShortcut)}`, - ); - } - await screenshot(appPage, "electron-find-04-before-keypress.png"); - const syntheticOpenResult = await appPage.evaluate(() => { - const event = new KeyboardEvent("keydown", { - key: "f", - code: "KeyF", - metaKey: navigator.platform.toLowerCase().includes("mac"), - ctrlKey: !navigator.platform.toLowerCase().includes("mac"), - bubbles: true, - cancelable: true, - }); - return window.dispatchEvent(event); - }); - await appPage.getByTestId("pane-find-bar").waitFor({ state: "visible", timeout: 10_000 }); - await appPage.evaluate((value) => { - const input = document.querySelector( - "[data-testid='pane-find-input']", - ) as HTMLInputElement | null; - if (!input) { - throw new Error("pane-find-input not found"); - } - const valueSetter = Object.getOwnPropertyDescriptor( - HTMLInputElement.prototype, - "value", - )?.set; - valueSetter?.call(input, value); - input.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText" })); - input.dispatchEvent(new Event("change", { bubbles: true })); - }, "electronneedle"); - await appPage.evaluate((opened) => { - window.__paseoElectronFindQa = window.__paseoElectronFindQa ?? { - timestamps: {}, - bridgeAvailable: false, - findInPageType: null, - stopFindInPageType: null, - onFoundInPageType: null, - bridgeFindCalls: [], - bridgeStopCalls: [], - bridgeListenerRegistrations: [], - bridgeFoundEvents: [], - }; - window.__paseoElectronFindQa.timestamps.syntheticOpenDispatchReturned = opened ? 1 : 0; - }, syntheticOpenResult); - await expect(appPage.getByText("1 / 3")).toBeVisible({ timeout: 10_000 }); - await screenshot(appPage, "electron-find-04-query.png"); - - await appPage.getByTestId("pane-find-next").click(); - await expect(appPage.getByText("2 / 3")).toBeVisible({ timeout: 10_000 }); - await appPage.getByTestId("pane-find-prev").click(); - await expect(appPage.getByText("1 / 3")).toBeVisible({ timeout: 10_000 }); - await appPage.getByTestId("pane-find-close").click(); - await expect(appPage.getByTestId("pane-find-bar")).toHaveCount(0); - - evidence = await collectEvidence(appPage, electron.app, electron, daemonListen, serverId); - await writeEvidence(evidence); - diagnosticPath = await writeDiagnostic(evidence); - await screenshot(appPage, "electron-find-05-closed.png"); - expect(evidence.bridge.foundEvents.length).toBeGreaterThan(0); - expect(evidence.mainProcess?.ipcFindCalls.length ?? 0).toBeGreaterThan(0); - expect(evidence.mainProcess?.ownerForwards.length ?? 0).toBeGreaterThan(0); - } catch (error) { - if (electron) { - appPage = - appPage ?? - (await findAppRendererPage(electron.app, electron.metroPort).catch(() => null)); - if (appPage) { - const daemonListen = await readDaemonListen().catch(() => null); - const serverId = await readServerId().catch(() => null); - await screenshot(appPage, "electron-find-failure-state.png").catch(() => undefined); - evidence = await collectEvidence(appPage, electron.app, electron, daemonListen, serverId); - await writeEvidence(evidence); - diagnosticPath = await writeDiagnostic(evidence); - if ( - error instanceof Error && - (error.message.includes("pane-find-bar") || - error.message.includes("getByText('1 / 3')")) - ) { - await writeHarnessBlocker({ - reason: error.message, - diagnosticPath, - experimentPath, - evidence, - }); - } - } - } - throw new Error( - `${error instanceof Error ? error.message : String(error)}\nDiagnostic: ${ - diagnosticPath ?? "" - }\nDiagnostic experiment: ${experimentPath ?? ""}\nFocus experiment: ${ - focusExperimentPath ?? "" - }\nControl experiment: ${controlExperimentPath ?? ""}`, - { cause: error }, - ); - } finally { - await workspaceClient?.close().catch(() => undefined); - await repo?.cleanup().catch(() => undefined); - if (electron) await stopElectron(electron); - if (staticServer) await stopServer(staticServer); - } - - expect(evidence?.selectedPage.title).not.toBe("Find QA Page"); - expect(evidence?.webview.text).toBe(QA_PAGE_TEXT); - expect(evidence?.bridge.foundEvents.length).toBeGreaterThan(0); - }); -}); - -declare global { - interface Window { - __paseoElectronFindQa?: { - timestamps: Record; - bridgeAvailable: boolean; - findInPageType: string | null; - stopFindInPageType: string | null; - onFoundInPageType: string | null; - bridgeFindCalls: Array<{ - browserId: string; - text: string; - options: Record; - requestId: number | null; - ts: number; - }>; - bridgeStopCalls: Array<{ browserId: string; action: string; ts: number }>; - bridgeListenerRegistrations: Array<{ browserId: string; ts: number }>; - bridgeFoundEvents: Array<{ browserId: string; result: unknown; ts: number }>; - }; - } -} diff --git a/packages/app/e2e/find-in-pane.spec.ts b/packages/app/e2e/find-in-pane.spec.ts index 12d8e1c0e..33ab99f1e 100644 --- a/packages/app/e2e/find-in-pane.spec.ts +++ b/packages/app/e2e/find-in-pane.spec.ts @@ -44,7 +44,7 @@ async function typeFindQuery(page: Page, query: string): Promise { await input.fill(query); } -test.describe("in-pane find manual QA", () => { +test.describe("in-pane find", () => { test("walks chat, file, terminal, split-pane, and browser-web find flows in the running app", async ({ page, }, testInfo) => { @@ -120,7 +120,7 @@ test.describe("in-pane find manual QA", () => { await waitForTerminalContent(page, (text) => text.includes("split needle two"), 10_000); await openFind(page); await typeFindQuery(page, "needle"); - await expect(page.getByText(/1 \/ 2|2 \/ 2|Searching\.\.\./)).toBeVisible({ + await expect(page.getByText(/1 \/ 2|2 \/ 2/)).toBeVisible({ timeout: 10_000, }); await page.getByTestId("workspace-file-pane").click(); diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index 0575c4f13..3834836d7 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -40,11 +40,16 @@ interface NativeScrollToIndexFailedInfo { averageItemLength: number; } +interface NativeScrollIndexFallbackInput { + index: number; + averageItemLength: number; +} + function keyExtractor(item: { id: string }): string { return item.id; } -export function getNativeScrollToIndexFallbackOffset(input: NativeScrollToIndexFailedInfo) { +export function getNativeScrollToIndexFallbackOffset(input: NativeScrollIndexFallbackInput) { if (!Number.isFinite(input.averageItemLength) || input.averageItemLength <= 0) { return 0; } diff --git a/packages/app/src/components/agent-stream-search-model.test.ts b/packages/app/src/components/agent-stream-search-model.test.ts index 3722c88fa..183858d7d 100644 --- a/packages/app/src/components/agent-stream-search-model.test.ts +++ b/packages/app/src/components/agent-stream-search-model.test.ts @@ -1,10 +1,8 @@ import { describe, expect, it } from "vitest"; import type { StreamItem } from "@/types/stream"; -import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; import { buildAgentStreamSearchModel, findAgentStreamSearchMatches, - getAgentStreamItemSearchableText, } from "./agent-stream-search-model"; function timestamp(seed: number): Date { @@ -63,82 +61,53 @@ function todoList(id: string, seed = 1): StreamItem { }; } -function agentToolCall(id: string, detail: ToolCallDetail, seed = 1): StreamItem { - return { - kind: "tool_call", - id, - timestamp: timestamp(seed), - payload: { - source: "agent", - data: { - provider: "codex", - callId: `call-${id}`, - name: "exec_command", - status: "completed", - error: null, - detail, - }, - }, - }; +function getSearchableText(item: StreamItem): string { + const model = buildAgentStreamSearchModel({ + streamItems: [item], + streamHead: [], + platform: "web", + isMobileBreakpoint: true, + }); + return model.entries[0]?.text ?? ""; } -describe("getAgentStreamItemSearchableText", () => { - it("extracts user, assistant, thought, activity, and todo text", () => { - expect(getAgentStreamItemSearchableText(userMessage("u1", "user text"))).toBe("user text"); - expect(getAgentStreamItemSearchableText(assistantMessage("a1", "assistant text"))).toBe( - "assistant text", - ); - expect(getAgentStreamItemSearchableText(thought("t1", "thought text"))).toBe("thought text"); - expect(getAgentStreamItemSearchableText(activityLog("l1", "activity text"))).toBe( - "activity text", - ); - expect(getAgentStreamItemSearchableText(todoList("todo"))).toBe( - "Write the red test\nMake search green", - ); +describe("buildAgentStreamSearchModel", () => { + it("indexes user and assistant message text", () => { + expect(getSearchableText(userMessage("u1", "user text"))).toBe("user text"); + expect(getSearchableText(assistantMessage("a1", "assistant text"))).toBe("assistant text"); }); - it("searches minimal visible tool-call text and skips raw hidden payloads", () => { - const shell = agentToolCall("shell", { - type: "shell", - command: "npm run typecheck", - output: "internal output should stay out", - }); - - expect(getAgentStreamItemSearchableText(shell)).toBe("Shell\nnpm run typecheck"); + it("excludes non-message rows that do not render find highlights", () => { + expect(getSearchableText(thought("t1", "thought text"))).toBe(""); + expect(getSearchableText(activityLog("l1", "activity text"))).toBe(""); + expect(getSearchableText(todoList("todo"))).toBe(""); }); - it("includes special tool-call content branches that render as messages or cards", () => { - const speak: StreamItem = { + it("excludes tool call rows from the search index", () => { + const toolCall: StreamItem = { kind: "tool_call", - id: "speak", + id: "shell", timestamp: timestamp(1), payload: { source: "agent", data: { provider: "codex", - callId: "call-speak", - name: "speak", + callId: "call-shell", + name: "exec_command", status: "completed", error: null, detail: { - type: "unknown", - input: "spoken message", - output: null, + type: "shell", + command: "npm run typecheck", + output: "internal output should stay out", }, }, }, }; - const plan = agentToolCall("plan", { - type: "plan", - text: "phase checklist", - }); - expect(getAgentStreamItemSearchableText(speak)).toBe("spoken message"); - expect(getAgentStreamItemSearchableText(plan)).toBe("Plan\nphase checklist"); + expect(getSearchableText(toolCall)).toBe(""); }); -}); -describe("buildAgentStreamSearchModel", () => { it("orders virtualized history, mounted history, live head, and optimistic items deterministically", () => { const committed: StreamItem[] = []; for (let index = 0; index < 64; index += 1) { @@ -185,7 +154,7 @@ describe("findAgentStreamSearchMatches", () => { it("returns stable match ids from item identity and local occurrence data", () => { const model = buildAgentStreamSearchModel({ streamItems: [assistantMessage("a1", "Alpha alpha beta")], - streamHead: [thought("h1", "alpha live")], + streamHead: [assistantMessage("h1", "alpha live")], platform: "web", isMobileBreakpoint: true, }); @@ -202,4 +171,20 @@ describe("findAgentStreamSearchMatches", () => { ]); expect(matches.map((match) => match.entry.item.id)).toEqual(["a1", "a1", "h1"]); }); + + it("skips fenced code blocks while preserving message offsets for highlights", () => { + const model = buildAgentStreamSearchModel({ + streamItems: [assistantMessage("a1", "before alpha\n```\nalpha\n```\nafter alpha")], + streamHead: [], + platform: "web", + isMobileBreakpoint: true, + }); + + const matches = findAgentStreamSearchMatches({ + model, + query: "alpha", + }); + + expect(matches.map((match) => match.id)).toEqual(["a1:text:0:7:12", "a1:text:0:33:38"]); + }); }); diff --git a/packages/app/src/components/agent-stream-search-model.ts b/packages/app/src/components/agent-stream-search-model.ts index 12987b5dc..9585bcec3 100644 --- a/packages/app/src/components/agent-stream-search-model.ts +++ b/packages/app/src/components/agent-stream-search-model.ts @@ -1,19 +1,19 @@ -import type { StreamItem, ToolCallItem } from "@/types/stream"; -import { buildToolCallDisplayModel } from "@/utils/tool-call-display"; +import type { StreamItem } from "@/types/stream"; import { findMountedWindowStart, getWebMountedRecentStreamItems, getWebPartialVirtualizationThreshold, } from "./agent-stream-web-virtualization"; -export type AgentStreamSearchSource = "historyVirtualized" | "historyMounted" | "liveHead"; +type AgentStreamSearchSource = "historyVirtualized" | "historyMounted" | "liveHead"; -export interface AgentStreamSearchTextSegment { +interface AgentStreamSearchTextSegment { key: string; text: string; + startOffset: number; } -export interface AgentStreamSearchEntry { +interface AgentStreamSearchEntry { item: StreamItem; source: AgentStreamSearchSource; index: number; @@ -30,7 +30,7 @@ export interface AgentStreamSearchMatch { end: number; } -export interface AgentStreamSearchModel { +interface AgentStreamSearchModel { entries: AgentStreamSearchEntry[]; segments: { historyVirtualized: AgentStreamSearchEntry[]; @@ -39,7 +39,7 @@ export interface AgentStreamSearchModel { }; } -export interface BuildAgentStreamSearchModelInput { +interface BuildAgentStreamSearchModelInput { platform: "web" | "native"; isMobileBreakpoint: boolean; streamItems: StreamItem[]; @@ -48,99 +48,79 @@ export interface BuildAgentStreamSearchModelInput { cwd?: string; } -export interface FindAgentStreamSearchMatchesInput { +interface FindAgentStreamSearchMatchesInput { model: AgentStreamSearchModel; query: string; } -function compactText(parts: Array): string { - return parts - .map((part) => part?.trim()) - .filter((part): part is string => Boolean(part)) - .join("\n"); +function getFenceDelimiter(line: string): string | null { + const match = /^( {0,3})(`{3,}|~{3,})/.exec(line); + return match?.[2] ?? null; } -function getToolCallSearchableSegments( - item: ToolCallItem, - cwd: string | undefined, -): AgentStreamSearchTextSegment[] { - if (item.payload.source === "agent") { - const { data } = item.payload; - if ( - data.name === "speak" && - data.detail.type === "unknown" && - typeof data.detail.input === "string" && - data.detail.input.trim() - ) { - return [{ key: "text", text: data.detail.input }]; +function getMessageSearchableSegments(text: string): AgentStreamSearchTextSegment[] { + const segments: AgentStreamSearchTextSegment[] = []; + let activeFenceCharacter: "`" | "~" | null = null; + let activeFenceLength = 0; + let currentText = ""; + let currentStartOffset = 0; + let offset = 0; + + const flush = () => { + if (currentText.length > 0) { + segments.push({ key: "text", text: currentText, startOffset: currentStartOffset }); + currentText = ""; + } + }; + + for (const line of text.split("\n")) { + const lineWithBreak = offset + line.length < text.length ? `${line}\n` : line; + const fenceDelimiter = getFenceDelimiter(line); + const isClosingFence = + activeFenceCharacter && + fenceDelimiter?.[0] === activeFenceCharacter && + fenceDelimiter.length >= activeFenceLength; + const isOpeningFence = !activeFenceCharacter && fenceDelimiter; + const isIndentedCode = !activeFenceCharacter && (/^( {4,}|\t)/.test(line) || line === " "); + + if (isOpeningFence || activeFenceCharacter || isIndentedCode) { + flush(); + } else { + if (currentText.length === 0) { + currentStartOffset = offset; + } + currentText += lineWithBreak; } - const display = buildToolCallDisplayModel({ - name: data.name, - status: data.status, - error: data.error, - detail: data.detail, - metadata: data.metadata, - cwd, - }); - const visibleText = compactText([ - display.displayName, - display.summary, - data.detail.type === "plan" ? data.detail.text : undefined, - display.errorText, - ]); - return visibleText ? [{ key: "tool", text: visibleText }] : []; + if (isOpeningFence) { + activeFenceCharacter = fenceDelimiter[0] as "`" | "~"; + activeFenceLength = fenceDelimiter.length; + } else if (isClosingFence) { + activeFenceCharacter = null; + activeFenceLength = 0; + } + + offset += lineWithBreak.length; } - const { data } = item.payload; - const display = buildToolCallDisplayModel({ - name: data.toolName, - status: data.status === "executing" ? "running" : data.status, - error: data.error, - detail: { - type: "unknown", - input: data.arguments, - output: data.result ?? null, - }, - cwd, - }); - const visibleText = compactText([display.displayName, display.summary, display.errorText]); - return visibleText ? [{ key: "tool", text: visibleText }] : []; + flush(); + return segments; } -export function getAgentStreamItemSearchableSegments( - item: StreamItem, - options: { cwd?: string } = {}, -): AgentStreamSearchTextSegment[] { +function getAgentStreamItemSearchableSegments(item: StreamItem): AgentStreamSearchTextSegment[] { switch (item.kind) { case "user_message": case "assistant_message": + return item.text ? getMessageSearchableSegments(item.text) : []; case "thought": - return item.text ? [{ key: "text", text: item.text }] : []; case "activity_log": - return item.message ? [{ key: "text", text: item.message }] : []; case "todo_list": - return item.items.map((todo, index) => ({ - key: `todo:${index}`, - text: todo.text, - })); case "tool_call": - return getToolCallSearchableSegments(item, options.cwd); case "compaction": return []; } } -export function getAgentStreamItemSearchableText( - item: StreamItem, - options: { cwd?: string } = {}, -): string { - return getAgentStreamItemSearchableSegments(item, options) - .map((segment) => segment.text) - .filter((text) => text.length > 0) - .join("\n"); -} - function mergeOptimisticItems(input: { streamItems: StreamItem[]; optimisticItems: StreamItem[] | undefined; @@ -163,7 +143,7 @@ function buildEntries(input: { cwd: string | undefined; }): AgentStreamSearchEntry[] { return input.items.map((item, offset) => { - const segments = getAgentStreamItemSearchableSegments(item, { cwd: input.cwd }); + const segments = getAgentStreamItemSearchableSegments(item); return { item, source: input.source, @@ -277,13 +257,15 @@ export function findAgentStreamSearchMatches( break; } const end = start + input.query.length; + const absoluteStart = segment.startOffset + start; + const absoluteEnd = segment.startOffset + end; matches.push({ - id: `${entry.item.id}:${segment.key}:${occurrenceIndex}:${start}:${end}`, + id: `${entry.item.id}:${segment.key}:${occurrenceIndex}:${absoluteStart}:${absoluteEnd}`, entry, segmentKey: segment.key, occurrenceIndex, - start, - end, + start: absoluteStart, + end: absoluteEnd, }); occurrenceIndex += 1; fromIndex = end; diff --git a/packages/app/src/components/browser-pane.electron.test.tsx b/packages/app/src/components/browser-pane.electron.test.tsx index a5b0dd317..114f6673a 100644 --- a/packages/app/src/components/browser-pane.electron.test.tsx +++ b/packages/app/src/components/browser-pane.electron.test.tsx @@ -207,8 +207,6 @@ vi.mock("@/stores/browser-store", () => ({ })); type FakeWebview = HTMLDivElement & { - findInPage: ReturnType number>>; - stopFindInPage: ReturnType void>>; getURL: ReturnType string>>; canGoBack: ReturnType boolean>>; canGoForward: ReturnType boolean>>; @@ -248,8 +246,6 @@ function installWebviewElementFactory(): void { return originalCreateElement(tagName, options); } const element = originalCreateElement("div") as FakeWebview; - element.findInPage = vi.fn(() => nextRequestId++); - element.stopFindInPage = vi.fn(); element.getURL = vi.fn(() => "https://example.com"); element.canGoBack = vi.fn(() => false); element.canGoForward = vi.fn(() => false); @@ -423,23 +419,6 @@ describe("BrowserPane Electron find", () => { expect(container?.querySelector('[data-testid="pane-find-input"]')).toBeNull(); }); - it("ignores requestId-less found-in-page events while a newer requestId search is active", () => { - renderBrowserPane(); - markWebviewDomReady(); - openFind(); - - changeInput("needle"); - dispatchFoundInPage({ requestId: 1, activeMatchOrdinal: 1, matches: 2 }); - expect(container?.textContent).toContain("1 / 2"); - - pressKey("Enter"); - dispatchFoundInPage({ activeMatchOrdinal: 2, matches: 9 }); - expect(container?.textContent).toContain("Searching..."); - - dispatchFoundInPage({ requestId: 2, activeMatchOrdinal: 2, matches: 2 }); - expect(container?.textContent).toContain("2 / 2"); - }); - it("cleans browser find selection on empty query, navigation, blur, and unmount", () => { renderBrowserPane(); markWebviewDomReady(); @@ -480,7 +459,7 @@ describe("BrowserPane Electron find", () => { expect(desktopBridge.foundInPageListeners.size).toBe(0); }); - it("does not call webview find methods before dom-ready", () => { + it("does not call the browser find bridge before dom-ready", () => { renderBrowserPane(); openFind(); diff --git a/packages/app/src/components/browser-pane.electron.tsx b/packages/app/src/components/browser-pane.electron.tsx index 7875a11fe..f04d6bce8 100644 --- a/packages/app/src/components/browser-pane.electron.tsx +++ b/packages/app/src/components/browser-pane.electron.tsx @@ -28,12 +28,6 @@ import { isDev } from "@/constants/platform"; import { FindBar, usePaneFind, type PaneFindMatchState } from "@/panels/pane-find"; import { useBrowserStore, normalizeWorkspaceBrowserUrl } from "@/stores/browser-store"; -interface ElectronFindOptions { - forward?: boolean; - findNext?: boolean; - matchCase?: boolean; -} - type ElectronWebview = HTMLElement & { canGoBack?: () => boolean; canGoForward?: () => boolean; @@ -44,20 +38,11 @@ type ElectronWebview = HTMLElement & { loadURL?: (url: string) => Promise; getURL?: () => string; executeJavaScript?: (code: string) => Promise; - findInPage?: (text: string, options?: ElectronFindOptions) => number; - stopFindInPage?: (action: "clearSelection" | "keepSelection" | "activateSelection") => void; focus?: () => void; addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => void; removeEventListener: (type: string, listener: EventListenerOrEventListenerObject) => void; }; -interface ElectronFoundInPageResult extends DesktopBrowserFoundInPageResult { - requestId?: number; - activeMatchOrdinal?: number; - matches?: number; - finalUpdate?: boolean; -} - type WebTextInput = TextInput & { getNativeRef?: () => unknown; }; @@ -261,25 +246,16 @@ function isDesktopBrowserShortcutEvent(payload: unknown): payload is DesktopBrow return event.action === "focus-url"; } -function getFoundInPageResult(event: Event): ElectronFoundInPageResult | null { - const result = (event as Event & { result?: unknown }).result; - if (!result || typeof result !== "object") { - return null; - } - return result as ElectronFoundInPageResult; -} - function stopBrowserFindInPage(input: { browserId: string; - webview: ElectronWebview | null; action: DesktopBrowserFindAction; }): void { const bridge = getDesktopHost()?.browser; - if (bridge?.stopFindInPage) { - void bridge.stopFindInPage(input.browserId, input.action); + if (!bridge?.stopFindInPage) { + console.warn("Electron browser find bridge is unavailable; cannot stop find-in-page."); return; } - input.webview?.stopFindInPage?.(input.action); + void bridge.stopFindInPage(input.browserId, input.action); } function startSelectorResultPolling(input: { @@ -436,7 +412,6 @@ export function BrowserPane({ if (domReadyRef.current) { stopBrowserFindInPage({ browserId: browserIdRef.current, - webview: webviewRef.current, action: "clearSelection", }); } @@ -450,13 +425,13 @@ export function BrowserPane({ return; } - const webview = webviewRef.current; if (!domReadyRef.current) { setBrowserFindMatchState(PENDING_FIND_MATCH_STATE); return; } const bridgeFindInPage = getDesktopHost()?.browser?.findInPage; - if (!bridgeFindInPage && !webview?.findInPage) { + if (!bridgeFindInPage) { + console.warn("Electron browser find bridge is unavailable; cannot start find-in-page."); setBrowserFindMatchState(NO_FIND_MATCH_STATE); return; } @@ -474,9 +449,7 @@ export function BrowserPane({ findNext: !input?.reset, matchCase: false, }; - const requestIdResult = bridgeFindInPage - ? bridgeFindInPage(browserIdRef.current, query, options) - : webview?.findInPage?.(query, options); + const requestIdResult = bridgeFindInPage(browserIdRef.current, query, options); if (typeof requestIdResult === "number") { activeBrowserFindRef.current = { generation, @@ -504,7 +477,7 @@ export function BrowserPane({ [clearBrowserFindSelection], ); - const handleFoundInPageResult = useCallback((result: ElectronFoundInPageResult) => { + const handleFoundInPageResult = useCallback((result: DesktopBrowserFoundInPageResult) => { const activeFind = activeBrowserFindRef.current; if ( !activeFind || @@ -513,12 +486,11 @@ export function BrowserPane({ ) { return; } - const eventRequestId = typeof result.requestId === "number" ? result.requestId : null; - if (typeof activeFind.requestId === "number") { - if (eventRequestId !== activeFind.requestId) { - return; - } - } else if (eventRequestId !== null) { + const requestId = result.requestId; + if (typeof requestId !== "number") { + return; + } + if (typeof activeFind.requestId !== "number" || requestId !== activeFind.requestId) { return; } @@ -539,17 +511,6 @@ export function BrowserPane({ }); }, []); - const handleFoundInPage = useCallback( - (event: Event) => { - const result = getFoundInPageResult(event); - if (!result) { - return; - } - handleFoundInPageResult(result); - }, - [handleFoundInPageResult], - ); - useEffect(() => { if (!isElectronRuntime()) { return; @@ -691,7 +652,7 @@ export function BrowserPane({ }); } } else { - webview.addEventListener("found-in-page", handleFoundInPage); + console.warn("Electron browser find bridge is unavailable; found-in-page events disabled."); } host.appendChild(webview); @@ -714,15 +675,11 @@ export function BrowserPane({ webview.removeEventListener("dom-ready", handleDomReady); didCleanupFoundInPage = true; unsubscribeFoundInPage?.(); - if (!foundInPageBridge) { - webview.removeEventListener("found-in-page", handleFoundInPage); - } webview.removeEventListener("focus", handleWebviewFocus); webview.removeEventListener("mousedown", handleWebviewFocus); if (domReadyRef.current) { stopBrowserFindInPage({ browserId: browserIdRef.current, - webview, action: "clearSelection", }); } diff --git a/packages/app/src/components/file-pane-text-render-data.test.ts b/packages/app/src/components/file-pane-text-render-data.test.ts index 529e7a1ac..969fa904f 100644 --- a/packages/app/src/components/file-pane-text-render-data.test.ts +++ b/packages/app/src/components/file-pane-text-render-data.test.ts @@ -5,7 +5,8 @@ import { createFilePaneTextRenderData, findFilePaneTextMatches, } from "@/components/file-pane-text-render-data"; -import type { FilePaneTextLineRenderData } from "@/components/file-pane-text-render-data"; + +type FilePaneTextLineRenderData = ReturnType["lines"][number]; function tokenText(line: FilePaneTextLineRenderData): string { return line.tokens.map(({ text }) => text).join(""); diff --git a/packages/app/src/components/file-pane-text-render-data.ts b/packages/app/src/components/file-pane-text-render-data.ts index 2472f11d9..4ba47ee42 100644 --- a/packages/app/src/components/file-pane-text-render-data.ts +++ b/packages/app/src/components/file-pane-text-render-data.ts @@ -1,17 +1,17 @@ import { highlightCode, type HighlightToken } from "@getpaseo/highlight"; -export interface FilePaneTextLineRenderData { +interface FilePaneTextLineRenderData { lineNumber: number; text: string; tokens: HighlightToken[]; } -export interface FilePaneTextRenderData { +interface FilePaneTextRenderData { lines: FilePaneTextLineRenderData[]; searchableText: string; } -export interface FilePaneFindLineSpan { +interface FilePaneFindLineSpan { lineNumber: number; startColumn: number; endColumn: number; diff --git a/packages/app/src/components/file-pane.tsx b/packages/app/src/components/file-pane.tsx index 10a5a8f3c..5390bd526 100644 --- a/packages/app/src/components/file-pane.tsx +++ b/packages/app/src/components/file-pane.tsx @@ -93,6 +93,13 @@ interface FilePaneTextPreviewProps { webScrollbarStyle: object; } +interface FilePaneSearchableTextPreviewProps extends Omit< + FilePaneTextPreviewProps, + "findHighlightsByLine" | "textRenderData" +> { + textRenderData: ReturnType; +} + interface FilePaneImagePreviewProps { imagePreviewUri: string | null; imageSource: { uri: string } | null; @@ -598,6 +605,20 @@ function FilePaneTextPreview({ ); } +function FilePaneSearchableTextPreview(props: FilePaneSearchableTextPreviewProps) { + const { findHighlightsByLine, paneFind } = useFilePaneFindAdapter({ + textRenderData: props.textRenderData, + textScrollRefs: props.textScrollRefs, + }); + + return ( + <> + + + + ); +} + function FilePaneImagePreview({ imagePreviewUri, imageSource, @@ -676,11 +697,6 @@ function FilePreviewBody({ const scrollbar = useWebScrollViewScrollbar(previewScrollRef, { enabled: showDesktopWebScrollbar, }); - const { findHighlightsByLine, paneFind } = useFilePaneFindAdapter({ - textRenderData, - textScrollRefs, - }); - const gutterWidth = useMemo(() => { if (!textRenderData) return 0; return lineNumberGutterWidth(textRenderData.lines.length, theme.fontSize.code); @@ -729,11 +745,32 @@ function FilePreviewBody({ No preview available ); + } else if (preview.kind === "text" && textRenderData) { + content = ( + + ); } else if (preview.kind === "text") { content = ( - - {content} - - ); + return {content}; } export function FilePane({ diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 88245ec22..152ca3e66 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -241,7 +241,7 @@ function normalizeFindHighlights( .sort((left, right) => left.start - right.start || left.end - right.end); } -export function createMessageFindTextSegments( +function createMessageFindTextSegments( text: string, highlights: MessageFindHighlight[] | undefined, ): MessageFindTextSegment[] { @@ -283,7 +283,7 @@ interface MarkdownBlockWithOffset { startOffset: number; } -export function createMarkdownBlocksWithOffsets(message: string): MarkdownBlockWithOffset[] { +function createMarkdownBlocksWithOffsets(message: string): MarkdownBlockWithOffset[] { let cursor = 0; return splitMarkdownBlocks(message).map((block, index) => { const startOffset = Math.max(cursor, message.indexOf(block, cursor)); diff --git a/packages/app/src/components/stream-strategy-native.test.ts b/packages/app/src/components/stream-strategy-native.test.ts index b52f5f6ad..9f62b9920 100644 --- a/packages/app/src/components/stream-strategy-native.test.ts +++ b/packages/app/src/components/stream-strategy-native.test.ts @@ -6,7 +6,6 @@ describe("getNativeScrollToIndexFallbackOffset", () => { expect( getNativeScrollToIndexFallbackOffset({ index: 25, - highestMeasuredFrameIndex: 4, averageItemLength: 72, }), ).toBe(1800); @@ -16,14 +15,12 @@ describe("getNativeScrollToIndexFallbackOffset", () => { expect( getNativeScrollToIndexFallbackOffset({ index: 25, - highestMeasuredFrameIndex: 4, averageItemLength: 0, }), ).toBe(0); expect( getNativeScrollToIndexFallbackOffset({ index: 25, - highestMeasuredFrameIndex: 4, averageItemLength: Number.NaN, }), ).toBe(0); diff --git a/packages/app/src/desktop/host.ts b/packages/app/src/desktop/host.ts index 9836a16ac..b786ffab0 100644 --- a/packages/app/src/desktop/host.ts +++ b/packages/app/src/desktop/host.ts @@ -111,13 +111,13 @@ export interface DesktopBrowserBridge { setWorkspaceActiveBrowser?: (browserId: string | null) => Promise; openDevTools?: (browserId: string) => Promise; clearPartition?: (browserId: string) => Promise; - findInPage?: ( + findInPage: ( browserId: string, text: string, options?: DesktopBrowserFindOptions, ) => Promise | number | null; - stopFindInPage?: (browserId: string, action: DesktopBrowserFindAction) => Promise | void; - onFoundInPage?: ( + stopFindInPage: (browserId: string, action: DesktopBrowserFindAction) => Promise | void; + onFoundInPage: ( browserId: string, listener: (result: DesktopBrowserFoundInPageResult) => void, ) => Promise<() => void> | (() => void); diff --git a/packages/app/src/keyboard/keyboard-action-dispatcher.ts b/packages/app/src/keyboard/keyboard-action-dispatcher.ts index aa43d41fc..0adc733e4 100644 --- a/packages/app/src/keyboard/keyboard-action-dispatcher.ts +++ b/packages/app/src/keyboard/keyboard-action-dispatcher.ts @@ -11,7 +11,6 @@ export type KeyboardActionId = | "message-input.voice-mute-toggle" | "workspace.tab.new" | "workspace.tab.close-current" - | "workspace.find.open" | "workspace.tab.navigate-index" | "workspace.tab.navigate-relative" | "workspace.pane.split.right" @@ -41,7 +40,6 @@ export type KeyboardActionDefinition = | { id: "message-input.voice-mute-toggle"; scope: KeyboardActionScope } | { id: "workspace.tab.new"; scope: KeyboardActionScope } | { id: "workspace.tab.close-current"; scope: KeyboardActionScope } - | { id: "workspace.find.open"; scope: KeyboardActionScope } | { id: "workspace.tab.navigate-index"; scope: KeyboardActionScope; index: number } | { id: "workspace.tab.navigate-relative"; scope: KeyboardActionScope; delta: 1 | -1 } | { id: "workspace.pane.split.right"; scope: KeyboardActionScope } diff --git a/packages/app/src/panels/pane-find-registry.test.ts b/packages/app/src/panels/pane-find-registry.test.ts index 69d70f857..2536b0936 100644 --- a/packages/app/src/panels/pane-find-registry.test.ts +++ b/packages/app/src/panels/pane-find-registry.test.ts @@ -9,9 +9,9 @@ import { type PaneFindController, } from "@/panels/pane-find-registry"; -function createController(): PaneFindController { +function createController(input?: { openResult?: boolean }): PaneFindController { return { - openFind: vi.fn(() => true), + openFind: vi.fn(() => input?.openResult ?? true), closeFind: vi.fn(() => true), }; } @@ -22,8 +22,8 @@ describe("pane find registry", () => { const registry = createPaneFindRegistry({ getActivePaneId: () => activePaneId.current, }); - const left = createController(); - const right = createController(); + const left = createController({ openResult: false }); + const right = createController({ openResult: true }); registry.register({ paneId: "server:workspace:left", @@ -34,9 +34,7 @@ describe("pane find registry", () => { controller: right, }); - expect(registry.openFindInActivePane()).toBe(true); - expect(left.openFind).toHaveBeenCalledTimes(1); - expect(right.openFind).not.toHaveBeenCalled(); + expect(registry.openFindInActivePane()).toBe(false); }); it("stops routing to a pane after it unregisters", () => { @@ -52,7 +50,6 @@ describe("pane find registry", () => { unregister(); expect(registry.openFindInActivePane()).toBe(false); - expect(controller.openFind).not.toHaveBeenCalled(); expect(controller.closeFind).toHaveBeenCalledTimes(1); }); @@ -61,8 +58,8 @@ describe("pane find registry", () => { const registry = createPaneFindRegistry({ getActivePaneId: () => activePaneId.current, }); - const left = createController(); - const right = createController(); + const left = createController({ openResult: false }); + const right = createController({ openResult: true }); registry.register({ paneId: "server:workspace:left", @@ -74,8 +71,6 @@ describe("pane find registry", () => { }); expect(registry.openFindInActivePane()).toBe(true); - expect(left.openFind).not.toHaveBeenCalled(); - expect(right.openFind).toHaveBeenCalledTimes(1); }); it("handles the keyboard find action through the active pane", () => { diff --git a/packages/app/src/panels/pane-find-registry.ts b/packages/app/src/panels/pane-find-registry.ts index 3703d5c9f..e27cdd00d 100644 --- a/packages/app/src/panels/pane-find-registry.ts +++ b/packages/app/src/panels/pane-find-registry.ts @@ -1,16 +1,19 @@ -import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; - export interface PaneFindController { openFind(): boolean; closeFind(): boolean; } -export interface RegisterPaneFindInput { +interface PaneFindKeyboardAction { + id: "workspace.find.open"; + scope: "workspace"; +} + +interface RegisterPaneFindInput { paneId: string; controller: PaneFindController; } -export interface PaneFindRegistry { +interface PaneFindRegistry { register(input: RegisterPaneFindInput): () => void; openFindInActivePane(): boolean; closeFindInPane(paneId: string): boolean; @@ -73,9 +76,7 @@ export function clearActivePaneFindPaneId(paneId: string) { } } -export function handlePaneFindKeyboardAction(action: KeyboardActionDefinition): boolean { - if (action.id !== "workspace.find.open") { - return false; - } +export function handlePaneFindKeyboardAction(action: PaneFindKeyboardAction): boolean { + void action; return paneFindRegistry.openFindInActivePane(); } diff --git a/packages/app/src/panels/pane-find.test.tsx b/packages/app/src/panels/pane-find.test.tsx index 56e2bf210..a60ddf11a 100644 --- a/packages/app/src/panels/pane-find.test.tsx +++ b/packages/app/src/panels/pane-find.test.tsx @@ -145,13 +145,14 @@ interface FakeSearchController { const controllers = new Map(); -function createController(): FakeSearchController { +function createController(input?: { total?: number }): FakeSearchController { + const total = input?.total ?? 3; return { query: vi.fn((query: string) => - query === "missing" ? { status: "no-match" } : { status: "matched", current: 1, total: 3 }, + query === "missing" ? { status: "no-match" } : { status: "matched", current: 1, total }, ), - next: vi.fn(() => ({ status: "matched", current: 2, total: 3 })), - prev: vi.fn(() => ({ status: "matched", current: 3, total: 3 })), + next: vi.fn(() => ({ status: "matched", current: 2, total })), + prev: vi.fn(() => ({ status: "matched", current: 3, total })), close: vi.fn(), }; } @@ -159,11 +160,14 @@ function createController(): FakeSearchController { function FakeFindPanel() { const paneContext = usePaneContext(); const controller = controllers.get(paneContext.paneInstanceId ?? ""); + if (!controller) { + throw new Error(`Missing fake find controller for pane ${paneContext.paneInstanceId}`); + } const paneFind = usePaneFind({ - onQuery: controller?.query ?? createController().query, - onNext: controller?.next ?? createController().next, - onPrev: controller?.prev ?? createController().prev, - onClose: controller?.close ?? createController().close, + onQuery: controller.query, + onNext: controller.next, + onPrev: controller.prev, + onClose: controller.close, }); return ( @@ -350,12 +354,12 @@ describe("FindBar", () => { act(() => { button("pane-find-next").dispatchEvent(new MouseEvent("click", { bubbles: true })); }); - expect(controller.next).toHaveBeenCalledTimes(2); + expect(container?.textContent).toContain("2 / 3"); act(() => { button("pane-find-prev").dispatchEvent(new MouseEvent("click", { bubbles: true })); }); - expect(controller.prev).toHaveBeenCalledTimes(2); + expect(container?.textContent).toContain("3 / 3"); pressKey("Escape"); expect(controller.close).toHaveBeenCalledTimes(1); @@ -367,7 +371,7 @@ describe("FindBar", () => { act(() => { button("pane-find-close").dispatchEvent(new MouseEvent("click", { bubbles: true })); }); - expect(controller.close).toHaveBeenCalledTimes(2); + expect(container?.querySelector('[data-testid="pane-find-input"]')).toBeNull(); }); it("cleans up the active find adapter on pane deactivation and unmount", () => { @@ -436,8 +440,8 @@ describe("FindBar", () => { }); it("routes open find through the focused workspace pane without replacing pane focus", () => { - const left = createController(); - const right = createController(); + const left = createController({ total: 7 }); + const right = createController({ total: 5 }); const leftContent = buildWorkspacePaneContentModel({ tab, paneId: "left", @@ -488,8 +492,8 @@ describe("FindBar", () => { }); changeInput("abc"); - expect(left.query).not.toHaveBeenCalled(); - expect(right.query).toHaveBeenCalledWith("abc"); + expect(container?.textContent).toContain("1 / 5"); + expect(container?.textContent).not.toContain("1 / 7"); expect(focusLeft).not.toHaveBeenCalled(); expect(focusRight).not.toHaveBeenCalled(); }); diff --git a/packages/desktop/src/features/browser-webviews.ts b/packages/desktop/src/features/browser-webviews.ts index 849312d4e..0c2db2631 100644 --- a/packages/desktop/src/features/browser-webviews.ts +++ b/packages/desktop/src/features/browser-webviews.ts @@ -45,11 +45,11 @@ function ensureOwnerFoundInPageListener(ownerContents: WebContents): void { export function registerPaseoBrowserWebContents( contents: WebContents, browserId: string, - ownerContents?: WebContents, + ownerContents: WebContents, ): void { browserIdsByWebContentsId.set(contents.id, browserId); webContentsIdsByBrowserId.set(browserId, contents.id); - if (ownerContents && !ownerContents.isDestroyed()) { + if (!ownerContents.isDestroyed()) { ownerWebContentsIdsByBrowserId.set(browserId, ownerContents.id); ensureOwnerFoundInPageListener(ownerContents); } @@ -92,18 +92,17 @@ export function getPaseoBrowserWebContents(browserId: string): WebContents | nul return contents && !contents.isDestroyed() ? contents : null; } -export function setActivePaseoBrowserFind(browserId: string): boolean { +export function setActivePaseoBrowserFind(browserId: string): void { const ownerContentsId = ownerWebContentsIdsByBrowserId.get(browserId); if (!ownerContentsId) { - return false; + return; } const ownerContents = allWebContents.fromId(ownerContentsId); if (!ownerContents || ownerContents.isDestroyed()) { - return false; + return; } ensureOwnerFoundInPageListener(ownerContents); activeFindBrowserIdsByOwnerWebContentsId.set(ownerContents.id, browserId); - return true; } export function clearActivePaseoBrowserFind(browserId: string): void {