diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebf2acb92..647fa0007 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,9 @@ jobs: - name: Lint run: vp exec vp lint --report-unused-disable-directives + - name: Vendored Playwright injected script is current + run: node scripts/extract-playwright-injected.ts --check + - name: Typecheck run: vp exec vp run -r --concurrency-limit 2 typecheck diff --git a/.oxfmtrc.json b/.oxfmtrc.json index ee76b80f9..0a6bf7632 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -12,6 +12,7 @@ "packages/effect-codex-app-server/src/_generated/schema.gen.ts", "apps/web/public/mockServiceWorker.js", "apps/web/src/lib/vendor/qrcodegen.ts", + "apps/desktop/src/preview/vendor/**", "*.icon/**" ], "sortPackageJson": {}, diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 3db474046..5a2f2c874 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -6,15 +6,16 @@ "type": "module", "main": "dist-electron/main.cjs", "scripts": { + "build": "vp pack && node scripts/preview-vendor.mjs", "dev": "node scripts/dev.mjs", - "dev:server-bundle": "vp run --filter @threadlines/server dev:bundle", "dev:bundle": "vp pack --watch", "dev:electron": "node scripts/dev-electron.mjs", - "build": "vp pack", + "dev:server-bundle": "vp run --filter @threadlines/server dev:bundle", + "smoke-test": "node scripts/smoke-test.mjs", "start": "node scripts/start-electron.mjs", - "typecheck": "tsc --noEmit", "test": "vp test run --passWithNoTests", - "smoke-test": "node scripts/smoke-test.mjs" + "typecheck": "tsc --noEmit", + "vendor:playwright": "node ../../scripts/extract-playwright-injected.ts" }, "dependencies": { "@effect/platform-node": "catalog:", @@ -30,6 +31,7 @@ "@effect/vitest": "catalog:", "@types/node": "catalog:", "electron-builder": "^26.15.3", + "playwright-core": "^1.61.1", "typescript": "catalog:", "vite": "catalog:", "vite-plus": "catalog:", diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index e20993823..63e0e8c69 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -3,6 +3,7 @@ import { unwatchFile, watch, watchFile } from "node:fs"; import { join } from "node:path"; import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; +import { copyPreviewVendor } from "./preview-vendor.mjs"; import { waitForResources } from "./wait-for-resources.mjs"; const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); @@ -182,6 +183,11 @@ function startApp() { return; } + // Every launch, not once at startup: `vp pack --watch` clears its output + // directory, and a restart triggered by that rebuild would otherwise start an + // Electron whose vendored assets had just been deleted. + copyPreviewVendor(); + const launchArgs = [ "--threadlines-dev-root=" + devProcessIdentity, ...(desktopUserDataDirectory === null ? [] : ["--user-data-dir=" + desktopUserDataDirectory]), diff --git a/apps/desktop/scripts/preview-vendor.mjs b/apps/desktop/scripts/preview-vendor.mjs new file mode 100644 index 000000000..6cd929e13 --- /dev/null +++ b/apps/desktop/scripts/preview-vendor.mjs @@ -0,0 +1,48 @@ +/** + * Puts the vendored Playwright script next to the bundle that reads it. + * + * `vp pack` bundles code and nothing else, so a static asset under `src/` never + * reaches `dist-electron/`. The bundled main resolves the script relative to + * itself -- correct in a packaged app, where `src/` does not exist -- so without + * this copy the file is only ever where the runtime is not looking. + * + * The symptom is quiet, which is the reason this exists as its own step rather + * than a line in a build script: `browser_snapshot` fails with ENOENT, the model + * shrugs and falls back to screenshots, and the agent still looks like it works. + */ +import { copyFileSync, existsSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + VENDOR_DIRECTORY_NAME, + VENDORED_INJECTED_MANIFEST_FILENAME, + VENDORED_INJECTED_SCRIPT_FILENAME, +} from "@threadlines/shared/previewInjectedScript"; + +const desktopDir = dirname(dirname(fileURLToPath(import.meta.url))); + +/** The manifest travels with the script so a shipped build can say which + * playwright-core it came from. */ +const assets = [VENDORED_INJECTED_SCRIPT_FILENAME, VENDORED_INJECTED_MANIFEST_FILENAME]; + +export function copyPreviewVendor() { + const from = join(desktopDir, "src", "preview", VENDOR_DIRECTORY_NAME); + const to = join(desktopDir, "dist-electron", VENDOR_DIRECTORY_NAME); + + mkdirSync(to, { recursive: true }); + for (const asset of assets) { + const source = join(from, asset); + if (!existsSync(source)) { + throw new Error( + `Missing vendored preview asset ${source}. ` + + `Run 'vp run --filter @threadlines/desktop vendor:playwright' to regenerate it.`, + ); + } + copyFileSync(source, join(to, asset)); + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + copyPreviewVendor(); +} diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index cd4c55180..10603081f 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -16,6 +16,13 @@ export class ElectronWindowCreateError extends Data.TaggedError("ElectronWindowC } export interface ElectronWindowShape { + /** + * The usable area of the primary display, menu bar and dock excluded. + * + * Here rather than read from `Electron.screen` at the call site so window + * sizing stays testable without a display attached. + */ + readonly workAreaSize: Effect.Effect<{ width: number; height: number }>; readonly create: ( options: Electron.BrowserWindowConstructorOptions, ) => Effect.Effect; @@ -65,6 +72,7 @@ const make = Effect.gen(function* () { ); return ElectronWindow.of({ + workAreaSize: Effect.sync(() => Electron.screen.getPrimaryDisplay().workAreaSize), create: (options) => Effect.try({ try: () => new Electron.BrowserWindow(options), diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 0e4e2a819..82e5382d9 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -48,6 +48,32 @@ import { setTheme, showContextMenu, } from "./methods/window.ts"; +import { + previewAttach, + previewClick, + previewDrag, + previewDetach, + previewEvaluate, + previewLocalServers, + previewMove, + previewClearBrowsingData, + previewClearCache, + previewOpenDevTools, + previewScreenshot, + previewCancelPick, + previewPickElement, + previewPress, + previewAwaitDrawing, + previewSetAnnotationMode, + previewRevealElement, + previewScroll, + previewSetColorScheme, + previewSetViewport, + previewSnapshot, + previewStatus, + previewType, + previewWaitFor, +} from "./methods/preview.ts"; export const installDesktopIpcHandlers = Effect.gen(function* () { const ipc = yield* DesktopIpc.DesktopIpc; @@ -72,6 +98,31 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { yield* ipc.handle(issueSshWebSocketToken); yield* ipc.handle(resolveSshPasswordPrompt); + yield* ipc.handle(previewAttach); + yield* ipc.handle(previewDetach); + yield* ipc.handle(previewStatus); + yield* ipc.handle(previewEvaluate); + yield* ipc.handle(previewSnapshot); + yield* ipc.handle(previewClick); + yield* ipc.handle(previewMove); + yield* ipc.handle(previewDrag); + yield* ipc.handle(previewType); + yield* ipc.handle(previewPress); + yield* ipc.handle(previewScroll); + yield* ipc.handle(previewWaitFor); + yield* ipc.handle(previewLocalServers); + yield* ipc.handle(previewScreenshot); + yield* ipc.handle(previewOpenDevTools); + yield* ipc.handle(previewSetColorScheme); + yield* ipc.handle(previewPickElement); + yield* ipc.handle(previewSetAnnotationMode); + yield* ipc.handle(previewAwaitDrawing); + yield* ipc.handle(previewCancelPick); + yield* ipc.handle(previewRevealElement); + yield* ipc.handle(previewSetViewport); + yield* ipc.handle(previewClearBrowsingData); + yield* ipc.handle(previewClearCache); + yield* ipc.handle(getServerExposureState); yield* ipc.handle(setServerExposureMode); yield* ipc.handle(setTailscaleServeEnabled); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index acd3d4b3d..6ccb87454 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -1,6 +1,30 @@ export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; export const CONFIRM_CHANNEL = "desktop:confirm"; export const CAPTURE_SCREENSHOT_CHANNEL = "desktop:capture-screenshot"; +export const PREVIEW_ATTACH_CHANNEL = "desktop:preview-attach"; +export const PREVIEW_DETACH_CHANNEL = "desktop:preview-detach"; +export const PREVIEW_STATUS_CHANNEL = "desktop:preview-status"; +export const PREVIEW_EVALUATE_CHANNEL = "desktop:preview-evaluate"; +export const PREVIEW_SNAPSHOT_CHANNEL = "desktop:preview-snapshot"; +export const PREVIEW_CLICK_CHANNEL = "desktop:preview-click"; +export const PREVIEW_MOVE_CHANNEL = "desktop:preview-move"; +export const PREVIEW_DRAG_CHANNEL = "desktop:preview-drag"; +export const PREVIEW_TYPE_CHANNEL = "desktop:preview-type"; +export const PREVIEW_PRESS_CHANNEL = "desktop:preview-press"; +export const PREVIEW_SCROLL_CHANNEL = "desktop:preview-scroll"; +export const PREVIEW_WAIT_FOR_CHANNEL = "desktop:preview-wait-for"; +export const PREVIEW_LOCAL_SERVERS_CHANNEL = "desktop:preview-local-servers"; +export const PREVIEW_SCREENSHOT_CHANNEL = "desktop:preview-screenshot"; +export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; +export const PREVIEW_SET_COLOR_SCHEME_CHANNEL = "desktop:preview-set-color-scheme"; +export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element"; +export const PREVIEW_CANCEL_PICK_CHANNEL = "desktop:preview-cancel-pick"; +export const PREVIEW_SET_ANNOTATION_MODE_CHANNEL = "desktop:preview-set-annotation-mode"; +export const PREVIEW_AWAIT_DRAWING_CHANNEL = "desktop:preview-await-drawing"; +export const PREVIEW_REVEAL_ELEMENT_CHANNEL = "desktop:preview-reveal-element"; +export const PREVIEW_SET_VIEWPORT_CHANNEL = "desktop:preview-set-viewport"; +export const PREVIEW_CLEAR_BROWSING_DATA_CHANNEL = "desktop:preview-clear-browsing-data"; +export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; export const SET_THEME_CHANNEL = "desktop:set-theme"; export const SET_TASKBAR_STATUS_CHANNEL = "desktop:set-taskbar-status"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts new file mode 100644 index 000000000..2b98a6414 --- /dev/null +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -0,0 +1,275 @@ +import { + DesktopLocalServerSchema, + DesktopPreviewClickInputSchema, + DesktopPreviewColorSchemeInputSchema, + DesktopPreviewDragInputSchema, + DesktopPreviewDragResultSchema, + DesktopPreviewViewportInputSchema, + DesktopPreviewEvaluateInputSchema, + DesktopPreviewMoveInputSchema, + DesktopPreviewScreenshotSchema, + DesktopPreviewSnapshotSchema, + DesktopPreviewTypeInputSchema, + DesktopPreviewStatusSchema, + DesktopPreviewPickedElementSchema, + DesktopPreviewAnnotateInputSchema, + DesktopPreviewDrawingSchema, + DesktopPreviewPointSchema, + DesktopPreviewPickInputSchema, + DesktopPreviewPressInputSchema, + DesktopPreviewRevealInputSchema, + DesktopPreviewScrollInputSchema, + DesktopPreviewTargetSchema, + DesktopPreviewWaitForInputSchema, +} from "@threadlines/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as LocalServers from "../../preview/LocalServers.ts"; +import * as PreviewAutomation from "../../preview/PreviewAutomation.ts"; +import * as PreviewSession from "../../preview/PreviewSession.ts"; +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; + +export const previewAttach = makeIpcMethod({ + channel: IpcChannels.PREVIEW_ATTACH_CHANNEL, + payload: DesktopPreviewTargetSchema, + result: DesktopPreviewStatusSchema, + handler: Effect.fn("desktop.ipc.preview.attach")(function* (input) { + const automation = yield* PreviewAutomation.PreviewAutomation; + return yield* automation.attach(input.webContentsId); + }), +}); + +export const previewDetach = makeIpcMethod({ + channel: IpcChannels.PREVIEW_DETACH_CHANNEL, + payload: DesktopPreviewTargetSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.detach")(function* (input) { + const automation = yield* PreviewAutomation.PreviewAutomation; + yield* automation.detach(input.webContentsId); + }), +}); + +export const previewStatus = makeIpcMethod({ + channel: IpcChannels.PREVIEW_STATUS_CHANNEL, + payload: DesktopPreviewTargetSchema, + result: DesktopPreviewStatusSchema, + handler: Effect.fn("desktop.ipc.preview.status")(function* (input) { + const automation = yield* PreviewAutomation.PreviewAutomation; + return yield* automation.status(input.webContentsId); + }), +}); + +export const previewEvaluate = makeIpcMethod({ + channel: IpcChannels.PREVIEW_EVALUATE_CHANNEL, + payload: DesktopPreviewEvaluateInputSchema, + result: Schema.Unknown, + handler: Effect.fn("desktop.ipc.preview.evaluate")(function* (input) { + const automation = yield* PreviewAutomation.PreviewAutomation; + return yield* automation.evaluate(input); + }), +}); + +export const previewSnapshot = makeIpcMethod({ + channel: IpcChannels.PREVIEW_SNAPSHOT_CHANNEL, + payload: DesktopPreviewTargetSchema, + result: DesktopPreviewSnapshotSchema, + handler: Effect.fn("desktop.ipc.preview.snapshot")(function* (input) { + const automation = yield* PreviewAutomation.PreviewAutomation; + return yield* automation.snapshot(input.webContentsId); + }), +}); + +export const previewClick = makeIpcMethod({ + channel: IpcChannels.PREVIEW_CLICK_CHANNEL, + payload: DesktopPreviewClickInputSchema, + result: DesktopPreviewPointSchema, + handler: Effect.fn("desktop.ipc.preview.click")(function* (input) { + const automation = yield* PreviewAutomation.PreviewAutomation; + return yield* automation.click(input); + }), +}); + +export const previewMove = makeIpcMethod({ + channel: IpcChannels.PREVIEW_MOVE_CHANNEL, + payload: DesktopPreviewMoveInputSchema, + result: DesktopPreviewPointSchema, + handler: Effect.fn("desktop.ipc.preview.move")(function* (input) { + const automation = yield* PreviewAutomation.PreviewAutomation; + return yield* automation.move(input); + }), +}); + +export const previewDrag = makeIpcMethod({ + channel: IpcChannels.PREVIEW_DRAG_CHANNEL, + payload: DesktopPreviewDragInputSchema, + result: DesktopPreviewDragResultSchema, + handler: Effect.fn("desktop.ipc.preview.drag")(function* (input) { + const automation = yield* PreviewAutomation.PreviewAutomation; + return yield* automation.drag(input); + }), +}); + +export const previewType = makeIpcMethod({ + channel: IpcChannels.PREVIEW_TYPE_CHANNEL, + payload: DesktopPreviewTypeInputSchema, + result: DesktopPreviewPointSchema, + handler: Effect.fn("desktop.ipc.preview.type")(function* (input) { + const automation = yield* PreviewAutomation.PreviewAutomation; + return yield* automation.type(input); + }), +}); + +export const previewPress = makeIpcMethod({ + channel: IpcChannels.PREVIEW_PRESS_CHANNEL, + payload: DesktopPreviewPressInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.press")(function* (input) { + const automation = yield* PreviewAutomation.PreviewAutomation; + yield* automation.press(input); + }), +}); + +export const previewScroll = makeIpcMethod({ + channel: IpcChannels.PREVIEW_SCROLL_CHANNEL, + payload: DesktopPreviewScrollInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.scroll")(function* (input) { + const automation = yield* PreviewAutomation.PreviewAutomation; + yield* automation.scroll(input); + }), +}); + +export const previewWaitFor = makeIpcMethod({ + channel: IpcChannels.PREVIEW_WAIT_FOR_CHANNEL, + payload: DesktopPreviewWaitForInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.waitFor")(function* (input) { + const automation = yield* PreviewAutomation.PreviewAutomation; + yield* automation.waitFor(input); + }), +}); + +export const previewLocalServers = makeIpcMethod({ + channel: IpcChannels.PREVIEW_LOCAL_SERVERS_CHANNEL, + payload: Schema.Void, + result: Schema.Array(DesktopLocalServerSchema), + handler: Effect.fn("desktop.ipc.preview.localServers")(function* () { + const servers = yield* LocalServers.LocalServers; + return yield* servers.scan(); + }), +}); + +export const previewScreenshot = makeIpcMethod({ + channel: IpcChannels.PREVIEW_SCREENSHOT_CHANNEL, + payload: DesktopPreviewTargetSchema, + result: DesktopPreviewScreenshotSchema, + handler: Effect.fn("desktop.ipc.preview.screenshot")(function* (input) { + const automation = yield* PreviewAutomation.PreviewAutomation; + return yield* automation.screenshot(input.webContentsId); + }), +}); + +export const previewOpenDevTools = makeIpcMethod({ + channel: IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, + payload: DesktopPreviewTargetSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.openDevTools")(function* (input) { + const automation = yield* PreviewAutomation.PreviewAutomation; + yield* automation.openDevTools(input.webContentsId); + }), +}); + +export const previewClearBrowsingData = makeIpcMethod({ + channel: IpcChannels.PREVIEW_CLEAR_BROWSING_DATA_CHANNEL, + payload: Schema.Void, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.clearBrowsingData")(function* () { + const session = yield* PreviewSession.PreviewSession; + yield* session.clearBrowsingData(); + }), +}); + +export const previewSetColorScheme = makeIpcMethod({ + channel: IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, + payload: DesktopPreviewColorSchemeInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.setColorScheme")(function* (input) { + const automation = yield* PreviewAutomation.PreviewAutomation; + yield* automation.setColorScheme(input.webContentsId, input.colorScheme); + }), +}); + +export const previewPickElement = makeIpcMethod({ + channel: IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, + payload: DesktopPreviewPickInputSchema, + result: Schema.Array(DesktopPreviewPickedElementSchema), + handler: Effect.fn("desktop.ipc.preview.pickElement")(function* (input) { + const automation = yield* PreviewAutomation.PreviewAutomation; + return yield* automation.pickElement(input.webContentsId, input.colorScheme, input.mode); + }), +}); + +export const previewCancelPick = makeIpcMethod({ + channel: IpcChannels.PREVIEW_CANCEL_PICK_CHANNEL, + payload: DesktopPreviewTargetSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.cancelPick")(function* (input) { + const automation = yield* PreviewAutomation.PreviewAutomation; + yield* automation.cancelPick(input.webContentsId); + }), +}); + +export const previewSetAnnotationMode = makeIpcMethod({ + channel: IpcChannels.PREVIEW_SET_ANNOTATION_MODE_CHANNEL, + payload: DesktopPreviewAnnotateInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.setAnnotationMode")(function* (input) { + const automation = yield* PreviewAutomation.PreviewAutomation; + yield* automation.setAnnotationMode(input.webContentsId, input.mode); + }), +}); + +export const previewAwaitDrawing = makeIpcMethod({ + channel: IpcChannels.PREVIEW_AWAIT_DRAWING_CHANNEL, + payload: DesktopPreviewTargetSchema, + result: Schema.NullOr(DesktopPreviewDrawingSchema), + handler: Effect.fn("desktop.ipc.preview.awaitDrawing")(function* (input) { + const automation = yield* PreviewAutomation.PreviewAutomation; + return yield* automation.awaitDrawing(input.webContentsId); + }), +}); + +export const previewRevealElement = makeIpcMethod({ + channel: IpcChannels.PREVIEW_REVEAL_ELEMENT_CHANNEL, + payload: DesktopPreviewRevealInputSchema, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.preview.revealElement")(function* (input) { + const automation = yield* PreviewAutomation.PreviewAutomation; + return yield* automation.revealElement(input.webContentsId, input.selector); + }), +}); + +export const previewClearCache = makeIpcMethod({ + channel: IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, + payload: Schema.Void, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.clearCache")(function* () { + const session = yield* PreviewSession.PreviewSession; + yield* session.clearCache(); + }), +}); + +export const previewSetViewport = makeIpcMethod({ + channel: IpcChannels.PREVIEW_SET_VIEWPORT_CHANNEL, + payload: DesktopPreviewViewportInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.setViewport")(function* (input) { + const automation = yield* PreviewAutomation.PreviewAutomation; + yield* automation.setViewport(input.webContentsId, { + width: input.width, + height: input.height, + }); + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index cf587767b..e81848e16 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -43,6 +43,9 @@ import * as DesktopServerExposure from "./backend/DesktopServerExposure.ts"; import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts"; import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.ts"; import * as DesktopScreenCapture from "./screenCapture/DesktopScreenCapture.ts"; +import * as LocalServers from "./preview/LocalServers.ts"; +import * as PreviewAutomation from "./preview/PreviewAutomation.ts"; +import * as PreviewSession from "./preview/PreviewSession.ts"; import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts"; import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; @@ -173,6 +176,9 @@ const desktopApplicationLayer = Layer.mergeAll( DesktopApplicationMenu.layer, DesktopStatusIndicator.layer, DesktopScreenCapture.layer, + PreviewAutomation.layer, + LocalServers.layer, + PreviewSession.layer, DesktopShellEnvironment.layer, DesktopRelay.layer, desktopSshLayer, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 7c54e8d0a..59a40abd5 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -109,6 +109,38 @@ contextBridge.exposeInMainWorld("desktopBridge", { pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options), confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message), captureScreenshot: (input) => ipcRenderer.invoke(IpcChannels.CAPTURE_SCREENSHOT_CHANNEL, input), + previewAttach: (input) => ipcRenderer.invoke(IpcChannels.PREVIEW_ATTACH_CHANNEL, input), + previewDetach: (input) => ipcRenderer.invoke(IpcChannels.PREVIEW_DETACH_CHANNEL, input), + previewStatus: (input) => ipcRenderer.invoke(IpcChannels.PREVIEW_STATUS_CHANNEL, input), + previewEvaluate: (input) => ipcRenderer.invoke(IpcChannels.PREVIEW_EVALUATE_CHANNEL, input), + previewSnapshot: (input) => ipcRenderer.invoke(IpcChannels.PREVIEW_SNAPSHOT_CHANNEL, input), + previewClick: (input) => ipcRenderer.invoke(IpcChannels.PREVIEW_CLICK_CHANNEL, input), + previewMove: (input) => ipcRenderer.invoke(IpcChannels.PREVIEW_MOVE_CHANNEL, input), + previewDrag: (input) => ipcRenderer.invoke(IpcChannels.PREVIEW_DRAG_CHANNEL, input), + previewType: (input) => ipcRenderer.invoke(IpcChannels.PREVIEW_TYPE_CHANNEL, input), + previewPress: (input) => ipcRenderer.invoke(IpcChannels.PREVIEW_PRESS_CHANNEL, input), + previewScroll: (input) => ipcRenderer.invoke(IpcChannels.PREVIEW_SCROLL_CHANNEL, input), + previewWaitFor: (input) => ipcRenderer.invoke(IpcChannels.PREVIEW_WAIT_FOR_CHANNEL, input), + previewLocalServers: () => ipcRenderer.invoke(IpcChannels.PREVIEW_LOCAL_SERVERS_CHANNEL), + previewScreenshot: (input) => ipcRenderer.invoke(IpcChannels.PREVIEW_SCREENSHOT_CHANNEL, input), + previewOpenDevTools: (input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, input), + previewSetColorScheme: (input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, input), + previewSetViewport: (input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_SET_VIEWPORT_CHANNEL, input), + previewPickElement: (input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, input), + previewCancelPick: (input) => ipcRenderer.invoke(IpcChannels.PREVIEW_CANCEL_PICK_CHANNEL, input), + previewSetAnnotationMode: (input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_SET_ANNOTATION_MODE_CHANNEL, input), + previewAwaitDrawing: (input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_AWAIT_DRAWING_CHANNEL, input), + previewRevealElement: (input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_REVEAL_ELEMENT_CHANNEL, input), + previewClearBrowsingData: () => + ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_BROWSING_DATA_CHANNEL), + previewClearCache: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL), setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme), setTaskbarStatus: (input) => ipcRenderer.invoke(IpcChannels.SET_TASKBAR_STATUS_CHANNEL, input), showContextMenu: (items, position) => diff --git a/apps/desktop/src/preview/LocalServers.ts b/apps/desktop/src/preview/LocalServers.ts new file mode 100644 index 000000000..98e40d861 --- /dev/null +++ b/apps/desktop/src/preview/LocalServers.ts @@ -0,0 +1,87 @@ +/** + * Servers currently listening on this machine. + * + * Discovered rather than configured: the point is to answer "what is running + * right now" without the user knowing a port number. Local by design -- the + * preview is a browser on this machine, so a remote environment's servers + * would not be reachable from it anyway. + */ + +import type { DesktopLocalServer } from "@threadlines/contracts"; +import { execFile } from "node:child_process"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { parseListeningPorts, type ListeningPort } from "./parseListeningPorts.ts"; +import { probeHttpServer } from "./probeHttpServer.ts"; + +const LSOF_TIMEOUT_MS = 5_000; +/** + * How long a probe result stands. Long enough that a stable server is not + * re-fetched on every poll, short enough that restarting one on the same port + * updates its title within a few seconds. + */ +const PROBE_CACHE_MS = 30_000; + +export class LocalServers extends Context.Service< + LocalServers, + { readonly scan: () => Effect.Effect> } +>()("@threadlines/desktop/preview/LocalServers") {} + +export const make = Effect.gen(function* LocalServersMake() { + // Keyed by port and pid together: a different process on the same port is a + // different server, and should not inherit the previous one's answer. + const probeCache = new Map(); + + const probeAll = async (ports: ReadonlyArray) => { + const now = Date.now(); + const results = await Promise.all( + ports.map(async (entry) => { + const key = `${entry.port}:${entry.pid}`; + const cached = probeCache.get(key); + const probe = + cached !== undefined && now - cached.at < PROBE_CACHE_MS + ? cached.result + : await probeHttpServer(entry.port); + probeCache.set(key, { at: now, result: probe }); + return probe === null ? null : { ...entry, title: probe.title }; + }), + ); + for (const [key, value] of probeCache) { + if (now - value.at >= PROBE_CACHE_MS) { + probeCache.delete(key); + } + } + return results.filter((entry) => entry !== null); + }; + + return LocalServers.of({ + scan: Effect.fn("LocalServers.scan")(function* () { + // Never fails the caller: an empty list renders as "nothing running", + // which is the truth whether lsof is missing or genuinely found nothing. + return yield* Effect.promise( + () => + new Promise>((resolve) => { + if (process.platform === "win32") { + resolve([]); + return; + } + execFile( + "lsof", + ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-F", "pcn"], + { timeout: LSOF_TIMEOUT_MS, maxBuffer: 4_000_000 }, + (_error, stdout) => { + // lsof exits non-zero when some descriptors are unreadable + // while still printing the ones it could read, so stdout is + // used even on error rather than discarding a good result. + void probeAll(parseListeningPorts(stdout ?? "")).then(resolve); + }, + ); + }), + ); + }), + }); +}).pipe(Effect.withSpan("LocalServers.make")); + +export const layer = Layer.effect(LocalServers, make); diff --git a/apps/desktop/src/preview/PreviewAutomation.ts b/apps/desktop/src/preview/PreviewAutomation.ts new file mode 100644 index 000000000..bc85bc218 --- /dev/null +++ b/apps/desktop/src/preview/PreviewAutomation.ts @@ -0,0 +1,1411 @@ +/** + * CDP control of preview tabs. + * + * Attaches Chrome DevTools Protocol to a preview 's WebContents. CDP + * rather than `executeJavaScript` because it operates at the browser level + * instead of inside the page's JavaScript sandbox: it is unaffected by + * same-origin policy, and its input events are real ones, so hover, focus and + * blur behave as they would for a person. Synthetic DOM events would not. + * + * Console and failed requests are collected here rather than on demand, + * because by the time anyone asks, the interesting message has already been + * printed. The buffers reset on navigation so they describe the current page. + */ + +import type { + DesktopPreviewAnnotationMode, + DesktopPreviewDrawing, + DesktopPreviewClickInput, + DesktopPreviewDragInput, + DesktopPreviewDragResult, + DesktopPreviewPickedElement, + DesktopPreviewPoint, + DesktopPreviewPickMode, + DesktopPreviewConsoleEntry, + DesktopPreviewEvaluateInput, + DesktopPreviewNetworkFailure, + DesktopPreviewMoveInput, + DesktopPreviewPressInput, + DesktopPreviewScrollInput, + DesktopPreviewSnapshot, + DesktopPreviewScreenshot, + DesktopPreviewStatus, + DesktopPreviewTypeInput, + DesktopPreviewWaitForInput, + PreviewAutomationTarget, +} from "@threadlines/contracts"; +import { webContents, type WebContents } from "electron"; + +import { toCdpKeyDefinition, toCdpModifierBitmask } from "./keyEvents.ts"; +import { + buildAriaSnapshotScript, + buildInjectedRuntimeScript, + buildLocatorQueryScript, + INJECTED_WORLD_NAME, +} from "./injectedRuntime.ts"; +import { isHostInjectedConsoleEntry } from "./previewConsoleNoise.ts"; +import { + buildDrawOverlayScript, + DRAW_OVERLAY_BINDING, + DRAW_OVERLAY_STASH, + DRAW_OVERLAY_TEARDOWN_SCRIPT, +} from "./drawOverlayScript.ts"; +import { buildRevealElementScript } from "./revealElementScript.ts"; +import { + buildPickOverlayScript, + PICK_OVERLAY_BINDING, + PICK_OVERLAY_STASH, + PICK_OVERLAY_TEARDOWN_SCRIPT, +} from "./pickOverlayScript.ts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +/** Enough to explain a failure without letting a chatty page grow unboundedly. */ +const MAX_CONSOLE_ENTRIES = 200; +/** Long enough to choose deliberately, short enough not to strand inspect mode. */ +const PICK_TIMEOUT_MS = 60_000; +/** Drag-and-drop and text selection depend on held movement between press and release. */ +const DRAG_MOVE_STEPS = 10; + +const MAX_NETWORK_FAILURES = 100; + +export class PreviewTargetMissingError extends Schema.TaggedErrorClass()( + "PreviewTargetMissingError", + { webContentsId: Schema.Number }, +) { + override get message(): string { + return `No live preview tab with webContents id ${this.webContentsId}.`; + } +} + +export class PreviewCommandError extends Schema.TaggedErrorClass()( + "PreviewCommandError", + { webContentsId: Schema.Number, method: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + const detail = + this.cause instanceof Error + ? this.cause.message + : typeof this.cause === "string" + ? this.cause + : String(this.cause); + return `Preview command ${this.method} failed for webContents ${this.webContentsId}: ${detail}`; + } +} + +export type PreviewAutomationError = PreviewTargetMissingError | PreviewCommandError; + +interface AttachedTab { + contents: WebContents; + console: DesktopPreviewConsoleEntry[]; + networkFailures: DesktopPreviewNetworkFailure[]; + /** + * requestId -> url, because `Network.loadingFailed` reports only the id. + * Without this a request that fails below HTTP -- DNS, connection refused, + * blocked -- would be reported as an opaque id instead of an address. + */ + requestUrls: Map; + dispose: () => void; +} + +export class PreviewAutomation extends Context.Service< + PreviewAutomation, + { + readonly attach: ( + webContentsId: number, + ) => Effect.Effect; + readonly detach: (webContentsId: number) => Effect.Effect; + readonly status: ( + webContentsId: number, + ) => Effect.Effect; + readonly evaluate: ( + input: DesktopPreviewEvaluateInput, + ) => Effect.Effect; + readonly snapshot: ( + webContentsId: number, + ) => Effect.Effect; + readonly openDevTools: (webContentsId: number) => Effect.Effect; + readonly setViewport: ( + webContentsId: number, + size: { width: number | null; height: number | null }, + ) => Effect.Effect; + readonly pickElement: ( + webContentsId: number, + colorScheme: "light" | "dark", + mode: DesktopPreviewPickMode, + ) => Effect.Effect, PreviewAutomationError>; + readonly cancelPick: (webContentsId: number) => Effect.Effect; + readonly setAnnotationMode: ( + webContentsId: number, + mode: DesktopPreviewAnnotationMode | null, + ) => Effect.Effect; + /** Resolves when the user attaches a drawing, or empty if they discard it + * or the mode is turned off. */ + readonly awaitDrawing: ( + webContentsId: number, + ) => Effect.Effect; + readonly revealElement: ( + webContentsId: number, + selector: string, + ) => Effect.Effect; + readonly setColorScheme: ( + webContentsId: number, + colorScheme: "light" | "dark", + ) => Effect.Effect; + readonly screenshot: ( + webContentsId: number, + ) => Effect.Effect; + readonly click: ( + input: DesktopPreviewClickInput, + ) => Effect.Effect; + readonly move: ( + input: DesktopPreviewMoveInput, + ) => Effect.Effect; + readonly drag: ( + input: DesktopPreviewDragInput, + ) => Effect.Effect; + readonly type: ( + input: DesktopPreviewTypeInput, + ) => Effect.Effect; + readonly press: ( + input: DesktopPreviewPressInput, + ) => Effect.Effect; + readonly scroll: ( + input: DesktopPreviewScrollInput, + ) => Effect.Effect; + readonly waitFor: ( + input: DesktopPreviewWaitForInput, + ) => Effect.Effect; + } +>()("@threadlines/desktop/preview/PreviewAutomation") {} + +/** What the overlay reports: how many elements it left behind, and what the + * user said about them. */ +interface PickResult { + count: number; + note: string | null; + styleChanges: ReadonlyArray<{ property: string; from: string; to: string }>; +} + +export const make = Effect.sync(function PreviewAutomationMake() { + const attached = new Map(); + /** + * The pick a page is currently waiting on, if any. + * + * Only one can be armed at a time, so starting another -- or cancelling -- + * has to settle the previous one now. Left to time out on its own it would + * eventually run its teardown, which by then would be tearing down the + * overlay belonging to whatever replaced it. + */ + const pendingPicks = new Map void }>(); + /** Whoever is waiting for a drawing to be attached, so turning the mode off + * can release them. */ + const drawWaiters = new Map void>(); + + const resolve = (webContentsId: number) => + Effect.suspend(() => { + const contents = webContents.fromId(webContentsId); + return contents === undefined || contents.isDestroyed() + ? Effect.fail(new PreviewTargetMissingError({ webContentsId })) + : Effect.succeed(contents); + }); + + const sendCommand = (contents: WebContents, method: string, params?: Record) => + Effect.tryPromise({ + try: () => contents.debugger.sendCommand(method, params ?? {}), + catch: (cause) => new PreviewCommandError({ webContentsId: contents.id, method, cause }), + }); + + const failCommand = (contents: WebContents, method: string, cause: string) => + Effect.fail(new PreviewCommandError({ webContentsId: contents.id, method, cause })); + + const targetDescription = (target: PreviewAutomationTarget): string => + "ref" in target + ? `ref ${target.ref}` + : "locator" in target + ? `locator ${JSON.stringify(target.locator)}` + : "selector" in target + ? `selector ${JSON.stringify(target.selector)}` + : "text" in target + ? `text ${JSON.stringify(target.text)}` + : `${target.point.x},${target.point.y}`; + + /** + * The isolated world our element engine lives in, created if it is not there. + * + * Asked for on every use rather than tracked: Chrome returns the existing + * world for a name it already knows, and a navigation quietly destroys the + * old one. Tracking it ourselves would mean holding an id that goes stale + * exactly when a page changes, which is the moment this gets used. + */ + const injectedWorld = Effect.fn("PreviewAutomation.injectedWorld")(function* ( + contents: WebContents, + ) { + yield* sendCommand(contents, "Page.enable", {}); + const tree = (yield* sendCommand(contents, "Page.getFrameTree", {})) as { + frameTree?: { frame?: { id?: string } }; + }; + const frameId = tree.frameTree?.frame?.id; + if (frameId === undefined) { + return yield* failCommand(contents, "Page.getFrameTree", "the page has no main frame"); + } + const world = (yield* sendCommand(contents, "Page.createIsolatedWorld", { + frameId, + worldName: INJECTED_WORLD_NAME, + grantUniveralAccess: false, + })) as { executionContextId?: number }; + const contextId = world.executionContextId; + if (contextId === undefined) { + return yield* failCommand( + contents, + "Page.createIsolatedWorld", + "could not create an isolated world for the element engine", + ); + } + yield* sendCommand(contents, "Runtime.evaluate", { + expression: buildInjectedRuntimeScript(), + contextId, + returnByValue: true, + }); + return contextId; + }); + + /** Runs an expression in the engine's world, surfacing a page-side throw. */ + const evaluateInjected = Effect.fn("PreviewAutomation.evaluateInjected")(function* ( + contents: WebContents, + expression: string, + options?: { readonly returnByValue?: boolean }, + ) { + const contextId = yield* injectedWorld(contents); + const result = (yield* sendCommand(contents, "Runtime.evaluate", { + expression, + contextId, + returnByValue: options?.returnByValue ?? false, + awaitPromise: true, + })) as { + result?: { objectId?: string; value?: unknown }; + exceptionDetails?: { exception?: { description?: string; value?: unknown }; text?: string }; + }; + if (result.exceptionDetails !== undefined) { + const exception = result.exceptionDetails.exception; + // The engine's own messages say what to do differently, so they are + // carried through rather than replaced with something generic. + const detail = + (typeof exception?.value === "string" ? exception.value : undefined) ?? + exception?.description?.split("\n")[0] ?? + result.exceptionDetails.text ?? + "the element engine failed"; + return yield* failCommand(contents, "injected.evaluate", detail); + } + return result.result ?? {}; + }); + + const resolveTarget = Effect.fn("PreviewAutomation.resolveTarget")(function* ( + contents: WebContents, + target: PreviewAutomationTarget, + ) { + if ("point" in target) { + // Deliberately not "whatever is at those coordinates". This operation + // needs a specific node, and hit-testing a point would hand it a parent, + // an overlay or the body -- wrong in a way that looks like it worked. + return yield* failCommand( + contents, + "resolveTarget", + "a point names a place, not an element; this needs a ref, locator, selector or text", + ); + } + if ("locator" in target) { + const handle = yield* evaluateInjected(contents, buildLocatorQueryScript(target.locator)); + const objectId = handle.objectId; + if (objectId === undefined) { + return yield* failCommand( + contents, + "resolveTarget", + `the locator ${JSON.stringify(target.locator)} did not resolve to an element`, + ); + } + const described = (yield* sendCommand(contents, "DOM.describeNode", { objectId })) as { + node?: { backendNodeId?: number }; + }; + yield* sendCommand(contents, "Runtime.releaseObject", { objectId }).pipe(Effect.ignore); + const backendNodeId = described.node?.backendNodeId; + if (backendNodeId === undefined) { + return yield* failCommand( + contents, + "resolveTarget", + `the element matching ${JSON.stringify(target.locator)} disappeared; take a new snapshot`, + ); + } + return backendNodeId; + } + + if ("ref" in target) { + // A ref is the `eN` from the aria snapshot, and Playwright's own + // `aria-ref=` engine is what resolves it against the tree that issued it. + // Resolving it as anything else -- a node id, an index -- would work + // right up until the page re-rendered. + const handle = yield* evaluateInjected( + contents, + buildLocatorQueryScript(`aria-ref=${target.ref}`), + ); + const objectId = handle.objectId; + if (objectId === undefined) { + return yield* failCommand( + contents, + "resolveTarget", + `ref ${target.ref} no longer resolves; take a new snapshot and use its refs`, + ); + } + const described = (yield* sendCommand(contents, "DOM.describeNode", { objectId })) as { + node?: { backendNodeId?: number }; + }; + yield* sendCommand(contents, "Runtime.releaseObject", { objectId }).pipe(Effect.ignore); + const backendNodeId = described.node?.backendNodeId; + if (backendNodeId === undefined) { + return yield* failCommand( + contents, + "resolveTarget", + `the element at ref ${target.ref} disappeared; take a new snapshot and try again`, + ); + } + return backendNodeId; + } + + if ("selector" in target) { + const document = (yield* sendCommand(contents, "DOM.getDocument", {})) as { + root?: { nodeId?: number }; + }; + const nodeId = document.root?.nodeId; + if (nodeId === undefined) { + return yield* failCommand( + contents, + "resolveTarget", + `no element matches selector ${JSON.stringify(target.selector)}; take a new snapshot or use a different selector`, + ); + } + + const match = (yield* sendCommand(contents, "DOM.querySelector", { + nodeId, + selector: target.selector, + }).pipe( + Effect.catch(() => + failCommand( + contents, + "resolveTarget", + `no element matches selector ${JSON.stringify(target.selector)}; use a valid selector or take a new snapshot`, + ), + ), + )) as { nodeId?: number }; + if (match.nodeId === undefined || match.nodeId === 0) { + return yield* failCommand( + contents, + "resolveTarget", + `no element matches selector ${JSON.stringify(target.selector)}; take a new snapshot or use a different selector`, + ); + } + + const described = (yield* sendCommand(contents, "DOM.describeNode", { + nodeId: match.nodeId, + }).pipe( + Effect.catch(() => + failCommand( + contents, + "resolveTarget", + `the element matching selector ${JSON.stringify(target.selector)} disappeared; take a new snapshot and try again`, + ), + ), + )) as { node?: { backendNodeId?: number } }; + const backendNodeId = described.node?.backendNodeId; + if (backendNodeId === undefined) { + return yield* failCommand( + contents, + "resolveTarget", + `no element matches selector ${JSON.stringify(target.selector)}; take a new snapshot or use a different selector`, + ); + } + return backendNodeId; + } + + if (target.text.trim() === "") { + return yield* failCommand( + contents, + "resolveTarget", + "an empty text target cannot identify an element; provide visible text, a selector, or a snapshot ref", + ); + } + + const evaluated = (yield* sendCommand(contents, "Runtime.evaluate", { + expression: `(() => { + const wanted = ${JSON.stringify(target.text)}; + const clickableSelector = 'button, a, [role="button"], input, summary'; + const depth = (element) => { + let value = 0; + for (let current = element; current.parentElement; current = current.parentElement) { + value += 1; + } + return value; + }; + const visible = (element) => { + const style = getComputedStyle(element); + if ( + style.display === 'none' || + style.visibility === 'hidden' || + Number(style.opacity) === 0 + ) { + return false; + } + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + const candidates = [...document.querySelectorAll('*')] + .filter(visible) + .map((element) => ({ element, text: (element.innerText || '').trim() })) + .filter((candidate) => candidate.text !== ''); + const rank = (left, right) => { + const clickable = + Number(right.element.matches(clickableSelector)) - + Number(left.element.matches(clickableSelector)); + return clickable !== 0 ? clickable : depth(left.element) - depth(right.element); + }; + const exact = candidates.filter((candidate) => candidate.text === wanted).sort(rank); + if (exact.length > 0) { + return exact[0].element; + } + const containing = candidates + .filter((candidate) => candidate.text.includes(wanted)) + .sort(rank); + return containing.length > 0 ? containing[0].element : null; + })()`, + })) as { + result?: { objectId?: string }; + exceptionDetails?: { text?: string; exception?: { description?: string } }; + }; + const objectId = evaluated.result?.objectId; + if (objectId === undefined) { + return yield* failCommand( + contents, + "resolveTarget", + `no visible element has text ${JSON.stringify(target.text)}; take a new snapshot, use a selector, or try a shorter text`, + ); + } + + const described = (yield* sendCommand(contents, "DOM.describeNode", { objectId }).pipe( + Effect.ensuring( + sendCommand(contents, "Runtime.releaseObject", { objectId }).pipe(Effect.ignore), + ), + Effect.catch(() => + failCommand( + contents, + "resolveTarget", + `the element with text ${JSON.stringify(target.text)} disappeared; take a new snapshot and try again`, + ), + ), + )) as { node?: { backendNodeId?: number } }; + const backendNodeId = described.node?.backendNodeId; + if (backendNodeId === undefined) { + return yield* failCommand( + contents, + "resolveTarget", + `no visible element has text ${JSON.stringify(target.text)}; take a new snapshot or use a selector`, + ); + } + return backendNodeId; + }); + + /** + * Turns a picked node into something that still means something later. + * + * Everything is read in one page-side evaluation so the description matches a + * single moment: reading the tag, then the text, then the box across separate + * round trips would let the page change underneath and produce a description + * that never existed. + */ + const describePickedNode = (send: typeof sendCommand) => + Effect.fn("PreviewAutomation.describePickedNode")(function* ( + contents: WebContents, + backendNodeId: number, + ) { + const resolved = yield* send(contents, "DOM.resolveNode", { backendNodeId }); + const objectId = (resolved as { object?: { objectId?: string } }).object?.objectId; + if (objectId === undefined) { + return null; + } + + const description = yield* send(contents, "Runtime.callFunctionOn", { + objectId, + returnByValue: true, + functionDeclaration: `function () { + const element = this; + const rect = element.getBoundingClientRect(); + // A path that a person could paste into the console: id when it is + // unique, otherwise tag plus nth-of-type up to a sensible depth. + const path = (node) => { + const parts = []; + let current = node; + while (current && current.nodeType === 1 && parts.length < 5) { + if (current.id) { + parts.unshift('#' + CSS.escape(current.id)); + break; + } + const tag = current.tagName.toLowerCase(); + const parent = current.parentElement; + if (!parent) { + parts.unshift(tag); + break; + } + const siblings = [...parent.children].filter((c) => c.tagName === current.tagName); + parts.unshift( + siblings.length > 1 ? tag + ':nth-of-type(' + (siblings.indexOf(current) + 1) + ')' : tag, + ); + current = parent; + } + return parts.join(' > '); + }; + // Spaces collapse but line breaks survive: a heading and the paragraph + // after it are different lines, and squashing them together reads as one + // run-on sentence wherever this is shown. + const text = (element.innerText || element.textContent || '') + .replace(/[^\\S\\n]+/g, ' ') + .replace(/\\n{2,}/g, '\\n') + .trim(); + return { + tagName: element.tagName.toLowerCase(), + selector: path(element), + text: text === '' ? null : text.slice(0, 200), + rect: { + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.round(rect.width), + height: Math.round(rect.height), + }, + url: location.href, + }; + }`, + }); + + yield* send(contents, "Runtime.releaseObject", { objectId }).pipe(Effect.ignore); + + const value = (description as { result?: { value?: Record } }).result?.value; + if (value === undefined) { + return null; + } + + // Role and name come from the accessibility tree, which is how the agent + // identifies elements everywhere else in this feature. + const axNode = yield* send(contents, "Accessibility.getPartialAXTree", { + backendNodeId, + fetchRelatives: false, + }).pipe(Effect.orElseSucceed(() => ({}) as Record)); + const nodes = (axNode as { nodes?: ReadonlyArray> }).nodes ?? []; + const first = nodes[0]; + // An AX property is { type, value } -- the string sits one level down, + // not two. Reading it a level too deep yielded undefined for every + // element, which the fallback below then reported as "no role", silently. + const readValue = (field: unknown): string | null => { + const raw = (field as { value?: unknown } | undefined)?.value; + return typeof raw === "string" && raw.trim() !== "" ? raw.trim() : null; + }; + + return { + // Both come from the user rather than the DOM; the caller attaches them. + note: null, + styleChanges: [], + tagName: String(value.tagName ?? "element"), + role: first === undefined ? null : readValue(first.role), + name: first === undefined ? null : readValue(first.name), + selector: String(value.selector ?? ""), + text: (value.text as string | null) ?? null, + rect: value.rect as { x: number; y: number; width: number; height: number }, + url: String(value.url ?? ""), + } satisfies DesktopPreviewPickedElement; + }); + + /** + * Describes the elements an overlay left behind for us. + * + * Read back by identity rather than by hit-testing a point again: the second + * test can land on a different element than the one the user watched light + * up, and for a region or a circled group no single point names the set at + * all. Must run before the overlay is torn down, since the teardown is what + * drops the handles. + */ + const describeStashed = Effect.fn("PreviewAutomation.describeStashed")(function* ( + contents: WebContents, + stash: string, + count: number, + ) { + const described: DesktopPreviewPickedElement[] = []; + for (let index = 0; index < count; index += 1) { + const handle = (yield* sendCommand(contents, "Runtime.evaluate", { + expression: `window[${JSON.stringify(stash)}][${index}]`, + })) as { result?: { objectId?: string } }; + const objectId = handle.result?.objectId; + if (objectId === undefined) { + continue; + } + const node = (yield* sendCommand(contents, "DOM.describeNode", { objectId })) as { + node?: { backendNodeId?: number }; + }; + yield* sendCommand(contents, "Runtime.releaseObject", { objectId }).pipe(Effect.ignore); + const backendNodeId = node.node?.backendNodeId; + if (backendNodeId === undefined) { + continue; + } + const element = yield* describePickedNode(sendCommand)(contents, backendNodeId); + if (element !== null) { + described.push(element); + } + } + return described; + }); + + const buildStatus = (webContentsId: number, contents: WebContents): DesktopPreviewStatus => { + const tab = attached.get(webContentsId); + return { + webContentsId, + url: contents.getURL(), + title: contents.getTitle(), + loading: contents.isLoading(), + attached: contents.debugger.isAttached(), + console: tab?.console ?? [], + networkFailures: tab?.networkFailures ?? [], + }; + }; + + /** + * Attaches the debugger, or re-attaches one that has gone. + * + * The bookkeeping map is not the question: it records that we set a tab up, + * not that the debugger is still on it. Only one client may attach at a time, + * so opening DevTools on the page takes it away from us, and a crashed or + * reloaded guest drops it too. Asking the map alone meant that once the + * attachment was lost it never came back, and every command after that failed + * with "No target available" -- which reads as the whole browser being broken + * rather than as one thing to redo. + */ + const attach = Effect.fn("PreviewAutomation.attach")(function* (webContentsId: number) { + const contents = yield* resolve(webContentsId); + if (attached.has(webContentsId) && contents.debugger.isAttached()) { + return buildStatus(webContentsId, contents); + } + // Set up once before, but not attached now: the old listeners belong to an + // attachment that no longer exists, and leaving them would double every + // console entry once a new one is made. + const stale = attached.get(webContentsId); + if (stale !== undefined) { + stale.dispose(); + attached.delete(webContentsId); + } + + const tab: AttachedTab = { + contents, + console: [], + networkFailures: [], + requestUrls: new Map(), + dispose: () => {}, + }; + + if (!contents.debugger.isAttached()) { + yield* Effect.try({ + try: () => contents.debugger.attach("1.3"), + catch: (cause) => + new PreviewCommandError({ webContentsId, method: "debugger.attach", cause }), + }); + } + + const pushConsole = (entry: DesktopPreviewConsoleEntry) => { + if (isHostInjectedConsoleEntry(entry)) { + return; + } + tab.console.push(entry); + if (tab.console.length > MAX_CONSOLE_ENTRIES) tab.console.shift(); + }; + + const onMessage = (_event: unknown, method: string, params: Record) => { + if (method === "Runtime.consoleAPICalled") { + const args = (params.args as ReadonlyArray<{ value?: unknown }> | undefined) ?? []; + pushConsole({ + level: String(params.type ?? "log"), + text: args + .map((arg) => (arg.value === undefined ? "" : String(arg.value))) + .join(" ") + .trim(), + at: new Date().toISOString(), + }); + return; + } + if (method === "Runtime.exceptionThrown") { + const details = params.exceptionDetails as { text?: string } | undefined; + pushConsole({ + level: "error", + text: details?.text ?? "Uncaught exception", + at: new Date().toISOString(), + }); + return; + } + if (method === "Network.requestWillBeSent") { + const requestId = String(params.requestId ?? ""); + const request = params.request as { url?: string } | undefined; + if (requestId !== "" && request?.url !== undefined) { + tab.requestUrls.set(requestId, request.url); + } + return; + } + if (method === "Network.loadingFailed") { + const requestId = String(params.requestId ?? ""); + tab.networkFailures.push({ + url: tab.requestUrls.get(requestId) ?? "", + status: null, + errorText: String(params.errorText ?? "request failed"), + at: new Date().toISOString(), + }); + tab.requestUrls.delete(requestId); + if (tab.networkFailures.length > MAX_NETWORK_FAILURES) tab.networkFailures.shift(); + return; + } + if (method === "Log.entryAdded") { + // Browser-level messages the page never printed itself: CSP violations, + // CORS refusals, mixed content. Exactly the failures a developer asks + // an agent about, and invisible to Runtime.consoleAPICalled. + const entry = params.entry as { level?: string; text?: string } | undefined; + pushConsole({ + level: entry?.level ?? "info", + text: entry?.text ?? "", + at: new Date().toISOString(), + }); + return; + } + if (method === "Network.responseReceived") { + const response = params.response as { url?: string; status?: number } | undefined; + if (response?.status !== undefined && response.status >= 400) { + tab.networkFailures.push({ + url: response.url ?? "", + status: response.status, + errorText: null, + at: new Date().toISOString(), + }); + if (tab.networkFailures.length > MAX_NETWORK_FAILURES) tab.networkFailures.shift(); + } + return; + } + if (method === "Page.frameNavigated") { + const frame = params.frame as { parentId?: string } | undefined; + // Only the main frame: an iframe navigating is not a new page, and + // clearing on it would discard the diagnostics being asked about. + if (frame?.parentId === undefined) { + tab.console.length = 0; + tab.networkFailures.length = 0; + tab.requestUrls.clear(); + } + } + }; + + contents.debugger.on("message", onMessage); + const onDestroyed = () => { + attached.delete(webContentsId); + }; + // Electron tells us when the attachment goes -- DevTools opening, the guest + // crashing, someone calling detach. Dropping the record here is what lets + // the next command notice and rebuild rather than fail. + const onDetach = () => { + attached.get(webContentsId)?.dispose(); + attached.delete(webContentsId); + }; + contents.once("destroyed", onDestroyed); + contents.debugger.on("detach", onDetach); + tab.dispose = () => { + contents.debugger.off("message", onMessage); + contents.debugger.off("detach", onDetach); + contents.off("destroyed", onDestroyed); + }; + attached.set(webContentsId, tab); + + for (const method of [ + "Runtime.enable", + "Page.enable", + "Network.enable", + "Log.enable", + "Accessibility.enable", + ]) { + yield* sendCommand(contents, method); + } + + return buildStatus(webContentsId, contents); + }); + + /** + * The tab, with a debugger certainly on it. + * + * Every operation goes through this rather than through `resolve`, because + * the attachment can be taken away at any moment -- DevTools opening on the + * page is enough -- and the alternative is a browser that works until it + * quietly does not. Attaching is cheap when it is already attached. + */ + const resolveAttached = Effect.fn("PreviewAutomation.resolveAttached")(function* ( + webContentsId: number, + ) { + yield* attach(webContentsId); + return yield* resolve(webContentsId); + }); + + const centerOf = Effect.fn("PreviewAutomation.centerOf")(function* ( + contents: WebContents, + backendNodeId: number, + ) { + const box = (yield* sendCommand(contents, "DOM.getBoxModel", { backendNodeId })) as { + model?: { content?: ReadonlyArray }; + }; + const quad = box.model?.content; + if (quad === undefined || quad.length < 8) { + return yield* Effect.fail( + new PreviewCommandError({ + webContentsId: contents.id, + method: "DOM.getBoxModel", + cause: `element ${backendNodeId} has no layout box`, + }), + ); + } + // Centre of the border box: the quad is four corner pairs, clockwise. + return { + x: (quad[0]! + quad[2]! + quad[4]! + quad[6]!) / 4, + y: (quad[1]! + quad[3]! + quad[5]! + quad[7]!) / 4, + }; + }); + + const targetPoint = Effect.fn("PreviewAutomation.targetPoint")(function* ( + contents: WebContents, + target: PreviewAutomationTarget, + ) { + // A point is already the answer. Everything else has to be found first and + // then reduced to its centre. + if ("point" in target) { + return target.point; + } + const backendNodeId = yield* resolveTarget(contents, target); + return yield* centerOf(contents, backendNodeId); + }); + + return PreviewAutomation.of({ + attach, + detach: (webContentsId: number) => + Effect.sync(() => { + const tab = attached.get(webContentsId); + if (tab === undefined) return; + tab.dispose(); + attached.delete(webContentsId); + if (!tab.contents.isDestroyed() && tab.contents.debugger.isAttached()) { + tab.contents.debugger.detach(); + } + }), + status: Effect.fn("PreviewAutomation.status")(function* (webContentsId: number) { + const contents = yield* resolveAttached(webContentsId); + return buildStatus(webContentsId, contents); + }), + snapshot: Effect.fn("PreviewAutomation.snapshot")(function* (webContentsId: number) { + const contents = yield* resolveAttached(webContentsId); + // Playwright's aria snapshot rather than our own walk of the CDP tree. + // Ours kept actionable and landmark nodes that had an accessible name, + // which meant an agent could see every button on a page and not one word + // of what the page said -- asked what a paragraph read, it had to fall + // back to running JavaScript. This carries the text, the structure and a + // ref on everything, in one string that costs less than the array did. + const result = yield* evaluateInjected(contents, buildAriaSnapshotScript(), { + returnByValue: true, + }); + const page = typeof result.value === "string" ? result.value : ""; + return { ...buildStatus(webContentsId, contents), page }; + }), + setViewport: Effect.fn("PreviewAutomation.setViewport")(function* ( + webContentsId: number, + size: { width: number | null; height: number | null }, + ) { + const contents = yield* resolveAttached(webContentsId); + // Resizing the element alone leaves the page believing it is still the + // old size: media queries do not re-evaluate and innerWidth is unchanged, + // so a narrow frame just clips a desktop layout instead of showing the + // mobile one. Overriding the metrics is what device mode actually is. + if (size.width === null || size.height === null) { + yield* sendCommand(contents, "Emulation.clearDeviceMetricsOverride", {}); + return; + } + yield* sendCommand(contents, "Emulation.setDeviceMetricsOverride", { + width: size.width, + height: size.height, + // Zero means "use the host's", which keeps text crisp on a retina + // display rather than rendering the page at 1x. + deviceScaleFactor: 0, + mobile: false, + }); + }), + /** + * Uses DevTools' own element picker rather than injecting a script into the + * page. The guest keeps its preload stripped and context isolation intact, + * and the highlight the user sees while choosing is Chromium's, so it + * behaves exactly as it does in DevTools. + */ + pickElement: Effect.fn("PreviewAutomation.pickElement")(function* ( + webContentsId: number, + colorScheme: "light" | "dark", + mode: DesktopPreviewPickMode, + ) { + const contents = yield* resolveAttached(webContentsId); + let supersede = () => {}; + const token = { supersede: () => supersede() }; + pendingPicks.get(webContentsId)?.supersede(); + pendingPicks.set(webContentsId, token); + + yield* sendCommand(contents, "DOM.enable", {}); + yield* sendCommand(contents, "Runtime.enable", {}); + // The binding is how the injected overlay reports back. Adding it twice + // is harmless, and it must exist before the script that calls it runs. + yield* sendCommand(contents, "Runtime.addBinding", { + name: PICK_OVERLAY_BINDING, + }).pipe(Effect.ignore); + + yield* sendCommand(contents, "Runtime.evaluate", { + expression: buildPickOverlayScript(colorScheme, mode), + }); + + const picked = yield* Effect.tryPromise({ + try: () => + new Promise((resolve) => { + let done = false; + const finish = (value: PickResult | null) => { + if (done) return; + done = true; + contents.debugger.off("message", onMessage); + clearTimeout(timer); + resolve(value); + }; + const onMessage = ( + _event: unknown, + method: string, + params: Record, + ) => { + if (method !== "Runtime.bindingCalled" || params.name !== PICK_OVERLAY_BINDING) { + return; + } + try { + const payload = JSON.parse(String(params.payload ?? "{}")) as { + count?: number; + note?: string | null; + styleChanges?: ReadonlyArray<{ property: string; from: string; to: string }>; + cancelled?: boolean; + }; + finish( + payload.cancelled === true || payload.count === undefined || payload.count < 1 + ? null + : { + count: payload.count, + note: payload.note ?? null, + styleChanges: payload.styleChanges ?? [], + }, + ); + } catch { + finish(null); + } + }; + contents.debugger.on("message", onMessage); + // Reaching for another tool settles this one, rather than leaving + // it waiting on a binding that will never be called. + supersede = () => { + pendingPicks.delete(webContentsId); + finish(null); + }; + // Picking is a deliberate act; if the user wanders off, stop + // waiting rather than leaving the overlay armed forever. + const timer = setTimeout(() => finish(null), PICK_TIMEOUT_MS); + }), + catch: (cause) => + new PreviewCommandError({ webContentsId, method: "Runtime.bindingCalled", cause }), + }); + + // A superseded pick owns nothing on the page any more: its overlay was + // replaced, and tearing down would take its replacement with it. + const current = pendingPicks.get(webContentsId) === token; + if (current) { + pendingPicks.delete(webContentsId); + } + + // Described before the teardown runs, because the teardown is what drops + // the overlay's handles on the elements it chose. + const described: DesktopPreviewPickedElement[] = []; + if (current && picked !== null) { + const elements = yield* describeStashed(contents, PICK_OVERLAY_STASH, picked.count); + for (const element of elements) { + described.push({ + ...element, + note: picked.note, + // Attached to the element they were made on. A region shares one + // note but tweaks only ever apply to a lone element, so this is + // never spread across a set. + styleChanges: picked.styleChanges, + }); + } + } + + if (current) { + yield* sendCommand(contents, "Runtime.evaluate", { + expression: PICK_OVERLAY_TEARDOWN_SCRIPT, + }).pipe(Effect.ignore); + } + + return described; + }), + awaitDrawing: Effect.fn("PreviewAutomation.awaitDrawing")(function* (webContentsId: number) { + const contents = yield* resolveAttached(webContentsId); + yield* sendCommand(contents, "DOM.enable", {}); + yield* sendCommand(contents, "Runtime.enable", {}); + yield* sendCommand(contents, "Runtime.addBinding", { + name: DRAW_OVERLAY_BINDING, + }).pipe(Effect.ignore); + + const attached = yield* Effect.tryPromise({ + try: () => + new Promise<{ note: string | null; count: number } | null>((settle) => { + let done = false; + const finish = (value: { note: string | null; count: number } | null) => { + if (done) return; + done = true; + contents.debugger.off("message", onMessage); + settle(value); + }; + const onMessage = ( + _event: unknown, + method: string, + params: Record, + ) => { + if (method !== "Runtime.bindingCalled" || params.name !== DRAW_OVERLAY_BINDING) { + return; + } + try { + const payload = JSON.parse(String(params.payload ?? "{}")) as { + note?: string | null; + count?: number; + cancelled?: boolean; + }; + finish( + payload.cancelled === true + ? null + : { note: payload.note ?? null, count: payload.count ?? 0 }, + ); + } catch { + finish(null); + } + }; + contents.debugger.on("message", onMessage); + // No timeout: unlike a pick, drawing is not a question waiting on an + // answer. The mode stays armed until the user puts the pen down, + // and turning it off is what cancels this. + drawWaiters.set(webContentsId, () => finish(null)); + }), + catch: (cause) => + new PreviewCommandError({ webContentsId, method: "Runtime.bindingCalled", cause }), + }); + drawWaiters.delete(webContentsId); + if (attached === null) { + return null; + } + + // Described while the ink is still up, because the caller photographs the + // page next and the strokes have to be in the picture. + const elements = yield* describeStashed(contents, DRAW_OVERLAY_STASH, attached.count); + return { + note: attached.note, + elements: elements.map((element) => ({ ...element, note: attached.note })), + } satisfies DesktopPreviewDrawing; + }), + setAnnotationMode: Effect.fn("PreviewAutomation.setAnnotationMode")(function* ( + webContentsId: number, + mode: DesktopPreviewAnnotationMode | null, + ) { + const contents = yield* resolveAttached(webContentsId); + if (mode === null || mode === "idle") { + // Clearing the marks is how a drawing is abandoned and parking them is + // how it is set aside; either way whoever is waiting on one has to stop + // waiting rather than outlive the pen. Coming back to it starts a new + // wait, and the ink is still there to attach. + drawWaiters.get(webContentsId)?.(); + } + // Nothing is read back, so this is a plain evaluation rather than the + // binding round trip picking needs. + yield* sendCommand(contents, "Runtime.evaluate", { + expression: mode === null ? DRAW_OVERLAY_TEARDOWN_SCRIPT : buildDrawOverlayScript(mode), + }); + }), + revealElement: Effect.fn("PreviewAutomation.revealElement")(function* ( + webContentsId: number, + selector: string, + ) { + const contents = yield* resolveAttached(webContentsId); + const result = (yield* sendCommand(contents, "Runtime.evaluate", { + expression: buildRevealElementScript(selector), + returnByValue: true, + })) as { result?: { value?: unknown } }; + return result.result?.value === true; + }), + cancelPick: Effect.fn("PreviewAutomation.cancelPick")(function* (webContentsId: number) { + const contents = yield* resolveAttached(webContentsId); + pendingPicks.get(webContentsId)?.supersede(); + yield* sendCommand(contents, "Runtime.evaluate", { + expression: PICK_OVERLAY_TEARDOWN_SCRIPT, + }).pipe(Effect.ignore); + }), + setColorScheme: Effect.fn("PreviewAutomation.setColorScheme")(function* ( + webContentsId: number, + colorScheme: "light" | "dark", + ) { + const contents = yield* resolveAttached(webContentsId); + // Emulated rather than set on the guest: the page decides what to do with + // prefers-color-scheme, and pages that ignore it are left alone. + yield* sendCommand(contents, "Emulation.setEmulatedMedia", { + features: [{ name: "prefers-color-scheme", value: colorScheme }], + }); + }), + openDevTools: Effect.fn("PreviewAutomation.openDevTools")(function* (webContentsId: number) { + const contents = yield* resolveAttached(webContentsId); + // Undocked: the guest is a small pane inside our layout, and devtools + // docked into it would leave almost nothing of the page visible. + yield* Effect.sync(() => { + contents.openDevTools({ mode: "detach" }); + }); + }), + screenshot: Effect.fn("PreviewAutomation.screenshot")(function* (webContentsId: number) { + const contents = yield* resolveAttached(webContentsId); + // Captured through CDP rather than capturePage so the image is the page + // itself, unaffected by the window being occluded or offscreen. + const shot = (yield* sendCommand(contents, "Page.captureScreenshot", { + format: "png", + captureBeyondViewport: false, + })) as { data?: string }; + const metrics = (yield* sendCommand(contents, "Page.getLayoutMetrics", {})) as { + cssVisualViewport?: { clientWidth?: number; clientHeight?: number }; + }; + return { + dataUrl: `data:image/png;base64,${shot.data ?? ""}`, + width: Math.round(metrics.cssVisualViewport?.clientWidth ?? 0), + height: Math.round(metrics.cssVisualViewport?.clientHeight ?? 0), + }; + }), + click: Effect.fn("PreviewAutomation.click")(function* (input: DesktopPreviewClickInput) { + const contents = yield* resolveAttached(input.webContentsId); + const point = yield* targetPoint(contents, input.target); + // Real input events rather than element.click(): hover, focus and blur + // fire as they would for a person, and a handler that checks isTrusted + // behaves the same way too. + yield* sendCommand(contents, "Input.dispatchMouseEvent", { + type: "mousePressed", + x: point.x, + y: point.y, + button: "left", + clickCount: 1, + }); + yield* sendCommand(contents, "Input.dispatchMouseEvent", { + type: "mouseReleased", + x: point.x, + y: point.y, + button: "left", + clickCount: 1, + }); + return point; + }), + move: Effect.fn("PreviewAutomation.move")(function* (input: DesktopPreviewMoveInput) { + const contents = yield* resolveAttached(input.webContentsId); + const point = yield* targetPoint(contents, input.target); + yield* sendCommand(contents, "Input.dispatchMouseEvent", { + type: "mouseMoved", + x: point.x, + y: point.y, + }); + return point; + }), + drag: Effect.fn("PreviewAutomation.drag")(function* (input: DesktopPreviewDragInput) { + const contents = yield* resolveAttached(input.webContentsId); + const from = yield* targetPoint(contents, input.from); + const to = yield* targetPoint(contents, input.to); + yield* sendCommand(contents, "Input.dispatchMouseEvent", { + type: "mousePressed", + x: from.x, + y: from.y, + button: "left", + clickCount: 1, + }); + for (let step = 1; step <= DRAG_MOVE_STEPS; step += 1) { + const progress = step / DRAG_MOVE_STEPS; + yield* sendCommand(contents, "Input.dispatchMouseEvent", { + type: "mouseMoved", + x: from.x + (to.x - from.x) * progress, + y: from.y + (to.y - from.y) * progress, + button: "left", + buttons: 1, + }); + } + yield* sendCommand(contents, "Input.dispatchMouseEvent", { + type: "mouseReleased", + x: to.x, + y: to.y, + button: "left", + clickCount: 1, + }); + return { from, to }; + }), + type: Effect.fn("PreviewAutomation.type")(function* (input: DesktopPreviewTypeInput) { + const contents = yield* resolveAttached(input.webContentsId); + const point = yield* targetPoint(contents, input.target); + yield* sendCommand(contents, "Input.dispatchMouseEvent", { + type: "mousePressed", + x: point.x, + y: point.y, + button: "left", + clickCount: 1, + }); + yield* sendCommand(contents, "Input.dispatchMouseEvent", { + type: "mouseReleased", + x: point.x, + y: point.y, + button: "left", + clickCount: 1, + }); + if (input.clear === true) { + // `commands` invokes the editing command directly, which is what a + // modifier chord ultimately triggers. Dispatching Meta+A as a raw key + // does not: the browser resolves shortcuts to editing commands above + // the layer CDP injects at, so the keypress arrives and selects + // nothing, and the insert below lands wherever the caret happened to + // be -- mid-word, since a click centres it. + yield* sendCommand(contents, "Input.dispatchKeyEvent", { + type: "keyDown", + key: "a", + code: "KeyA", + windowsVirtualKeyCode: 65, + commands: ["selectAll"], + }); + yield* sendCommand(contents, "Input.dispatchKeyEvent", { + type: "keyUp", + key: "a", + code: "KeyA", + windowsVirtualKeyCode: 65, + }); + } + yield* sendCommand(contents, "Input.insertText", { text: input.text }); + // The caret is where the agent is. Returned so the pointer travels to the + // field it is typing into rather than sitting on the last thing clicked. + return point; + }), + press: Effect.fn("PreviewAutomation.press")(function* (input: DesktopPreviewPressInput) { + const contents = yield* resolveAttached(input.webContentsId); + const definition = toCdpKeyDefinition(input.key); + const modifiers = toCdpModifierBitmask(input.modifiers); + yield* sendCommand(contents, "Input.dispatchKeyEvent", { + type: "keyDown", + ...definition, + modifiers, + }); + const { text: _text, ...releasedDefinition } = definition; + yield* sendCommand(contents, "Input.dispatchKeyEvent", { + type: "keyUp", + ...releasedDefinition, + modifiers, + }); + }), + scroll: Effect.fn("PreviewAutomation.scroll")(function* (input: DesktopPreviewScrollInput) { + const contents = yield* resolveAttached(input.webContentsId); + if (input.target !== undefined) { + const backendNodeId = yield* resolveTarget(contents, input.target); + yield* sendCommand(contents, "DOM.scrollIntoViewIfNeeded", { backendNodeId }); + return; + } + + const metrics = (yield* sendCommand(contents, "Page.getLayoutMetrics", {})) as { + cssVisualViewport?: { clientWidth?: number; clientHeight?: number }; + }; + yield* sendCommand(contents, "Input.dispatchMouseEvent", { + type: "mouseWheel", + x: (metrics.cssVisualViewport?.clientWidth ?? 0) / 2, + y: (metrics.cssVisualViewport?.clientHeight ?? 0) / 2, + deltaX: input.deltaX ?? 0, + deltaY: input.deltaY ?? 0, + }); + }), + evaluate: Effect.fn("PreviewAutomation.evaluate")(function* ( + input: DesktopPreviewEvaluateInput, + ) { + const contents = yield* resolveAttached(input.webContentsId); + const result = (yield* sendCommand(contents, "Runtime.evaluate", { + expression: input.expression, + returnByValue: true, + awaitPromise: true, + })) as { + result?: { value?: unknown }; + exceptionDetails?: { + text?: string; + exception?: { description?: string; value?: unknown }; + }; + }; + if (result.exceptionDetails !== undefined) { + const exception = result.exceptionDetails.exception; + const detail = + exception?.description ?? + (exception?.value === undefined ? undefined : String(exception.value)) ?? + result.exceptionDetails.text ?? + "the expression threw an exception"; + return yield* failCommand(contents, "Runtime.evaluate", detail); + } + return result.result?.value; + }), + waitFor: Effect.fn("PreviewAutomation.waitFor")(function* (input: DesktopPreviewWaitForInput) { + const contents = yield* resolveAttached(input.webContentsId); + const timeoutMs = Math.max(0, input.timeoutMs ?? 10_000); + const startedAt = Date.now(); + let unmet: string[] = []; + + while (true) { + unmet = []; + + if (input.target !== undefined) { + const found = yield* resolveTarget(contents, input.target).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (!found) { + unmet.push(`target ${targetDescription(input.target)}`); + } + } + + if (input.text !== undefined) { + const textResult = (yield* sendCommand(contents, "Runtime.evaluate", { + expression: `Boolean(document.body?.innerText.includes(${JSON.stringify(input.text)}))`, + returnByValue: true, + }).pipe(Effect.orElseSucceed(() => ({ result: { value: false } })))) as { + result?: { value?: unknown }; + }; + if (textResult.result?.value !== true) { + unmet.push(`page text ${JSON.stringify(input.text)}`); + } + } + + if (input.urlContains !== undefined && !contents.getURL().includes(input.urlContains)) { + unmet.push(`URL containing ${JSON.stringify(input.urlContains)}`); + } + + if (unmet.length === 0) { + return; + } + + if (Date.now() - startedAt >= timeoutMs) { + return yield* failCommand( + contents, + "waitFor", + `timed out after ${timeoutMs}ms; these conditions never became true together: ${unmet.join(", ")}`, + ); + } + + yield* Effect.sleep("150 millis"); + } + }), + }); +}).pipe(Effect.withSpan("PreviewAutomation.make")); + +export const layer = Layer.effect(PreviewAutomation, make); diff --git a/apps/desktop/src/preview/PreviewSession.ts b/apps/desktop/src/preview/PreviewSession.ts new file mode 100644 index 000000000..ee9bd5a4d --- /dev/null +++ b/apps/desktop/src/preview/PreviewSession.ts @@ -0,0 +1,115 @@ +/** + * The browser session behind the in-app preview. + * + * One persistent partition, shared by every preview tab, so signing in to the + * app you are building is something you do once rather than once per thread — + * and so the agent driving a tab operates inside that same signed-in session + * rather than a blank one it cannot authenticate. + * + * Kept separate from the app's own session: preview content is untrusted, and + * it has no business reading Threadlines' cookies. + */ + +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import { session, type Session } from "electron"; +import { PREVIEW_PARTITION } from "@threadlines/shared/preview"; + +export { PREVIEW_PARTITION }; + +/** + * Permissions preview content may use. Deliberately short: a page under + * development has no reason to reach the microphone or camera, and the + * default-deny keeps a mistyped URL from prompting for hardware. + * + * `clipboard-sanitized-write` (not `clipboard-write`, which Electron does not + * recognise) is what `navigator.clipboard.writeText()` checks, so framework + * error overlays with a "copy" button keep working. + */ +const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet = new Set([ + "clipboard-read", + "clipboard-sanitized-write", + "notifications", +]); + +export class PreviewSessionCreationError extends Schema.TaggedErrorClass()( + "PreviewSessionCreationError", + { partition: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Failed to create the preview browser session (partition ${this.partition}).`; + } +} + +export class PreviewSession extends Context.Service< + PreviewSession, + { + readonly partition: string; + readonly getSession: () => Effect.Effect; + readonly clearBrowsingData: () => Effect.Effect; + readonly clearCache: () => Effect.Effect; + } +>()("@threadlines/desktop/preview/PreviewSession") {} + +export const make = Effect.gen(function* PreviewSessionMake() { + let cached: Session | null = null; + + const getSession = Effect.fn("PreviewSession.getSession")(function* () { + if (cached !== null) { + return cached; + } + return yield* Effect.try({ + try: () => { + const previewSession = session.fromPartition(PREVIEW_PARTITION); + // Present as an ordinary Chrome build. Sites that sniff for Electron + // serve degraded or blocked experiences, which would make the preview + // disagree with the browser the user checks against. + previewSession.setUserAgent( + previewSession + .getUserAgent() + .replace(/Electron\/[\d.]+ /, "") + .replace(/\s*Threadlines\/[\d.]+/, ""), + ); + previewSession.setPermissionRequestHandler((_contents, permission, callback) => { + callback(ALLOWED_PREVIEW_PERMISSIONS.has(permission)); + }); + // Async clipboard writes consult the *check* handler rather than the + // request handler, so both have to agree or copy buttons fail. + previewSession.setPermissionCheckHandler((_contents, permission) => + ALLOWED_PREVIEW_PERMISSIONS.has(permission), + ); + cached = previewSession; + return previewSession; + }, + catch: (cause) => new PreviewSessionCreationError({ partition: PREVIEW_PARTITION, cause }), + }); + }); + + return PreviewSession.of({ + partition: PREVIEW_PARTITION, + getSession, + clearCache: Effect.fn("PreviewSession.clearCache")(function* () { + // Cache only: a stale bundle is a different complaint from being signed + // in, and clearing both when asked for one loses work. + const previewSession = yield* getSession(); + yield* Effect.tryPromise({ + try: () => previewSession.clearCache(), + catch: (cause) => new PreviewSessionCreationError({ partition: PREVIEW_PARTITION, cause }), + }); + }), + clearBrowsingData: Effect.fn("PreviewSession.clearBrowsingData")(function* () { + const previewSession = yield* getSession(); + yield* Effect.tryPromise({ + try: () => + previewSession.clearStorageData({ + storages: ["cookies", "localstorage", "indexdb", "serviceworkers"], + }), + catch: (cause) => new PreviewSessionCreationError({ partition: PREVIEW_PARTITION, cause }), + }); + }), + }); +}).pipe(Effect.withSpan("PreviewSession.make")); + +export const layer = Layer.effect(PreviewSession, make); diff --git a/apps/desktop/src/preview/drawOverlayScript.ts b/apps/desktop/src/preview/drawOverlayScript.ts new file mode 100644 index 000000000..c7c8e0964 --- /dev/null +++ b/apps/desktop/src/preview/drawOverlayScript.ts @@ -0,0 +1,373 @@ +/** + * Ink drawn over the page, and the eraser that takes it back off. + * + * Unlike picking, this is not a question with an answer -- nothing is reported + * back and nothing is attached. The marks are for the screenshot: they are real + * nodes in the page, so Page.captureScreenshot renders them, and circling the + * broken thing and capturing it is the whole point. Turning the mode off clears + * them, which is also how you start over. + * + * SVG rather than a canvas so each stroke stays a node the eraser can hit-test. + * On a canvas, erasing one line means remembering every stroke and repainting + * the rest, which is a worse version of what the DOM already does. + * + * Positioned in document coordinates rather than the viewport, so a mark stays + * on the thing it was drawn on when the page scrolls. + */ + +import { coveredBoxIndicesSource } from "./regionSelection.ts"; + +const DRAW_OVERLAY_ID = "__threadlines-draw-overlay"; +/** Reported when the user attaches a drawing, mirroring the picker's binding. */ +export const DRAW_OVERLAY_BINDING = "__threadlinesAttachDrawing"; +/** Where the elements the strokes enclose are left, for the caller to describe. */ +export const DRAW_OVERLAY_STASH = "__threadlinesDrawnElements"; +/** How much of an element a stroke has to enclose to count as circled. */ +const DRAW_COVERAGE = 0.7; +/** + * How nearly a stroke has to meet itself to count as going round something. + * + * Only a closed shape encloses anything. An arrow pointing at a button, an + * underline, a sketch of what the thing should look like -- all of those have + * elements inside their bounding box and mean nothing by them, and naming those + * elements would be a confident guess at something the user never said. The gap + * between where a stroke starts and ends, against how big it is, is what tells + * a circle from a line. + */ +const DRAW_CLOSED_GAP = 0.25; +/** A scribble across a page should not attach forty chips. */ +const DRAW_MAX_ELEMENTS = 8; +/** The highlight blue the picker uses, so the whole feature reads as one thing. */ +const INK = "#4c8dff"; +const INK_WIDTH = 3; +/** The invisible twin that catches the click: three pixels of line is not a target. */ +const ERASE_HIT_WIDTH = 18; + +export const DRAW_OVERLAY_TEARDOWN_SCRIPT = ` +document.getElementById(${JSON.stringify(DRAW_OVERLAY_ID)})?.__threadlinesDispose?.(); +document.getElementById(${JSON.stringify(DRAW_OVERLAY_ID)})?.remove(); +`; + +/** + * Arms the overlay, or switches the mode of one already on the page. + * + * Switching keeps the ink. The eraser exists to fix a stroke you just drew, so + * clearing on the way there would leave nothing to fix -- and going idle, which + * is what happens when another tool takes the pointer, has to keep it for the + * same reason: an unattached drawing is work the user has not sent yet. + */ +export function buildDrawOverlayScript(mode: "draw" | "erase" | "idle"): string { + return ` +(() => { + const OVERLAY_ID = ${JSON.stringify(DRAW_OVERLAY_ID)}; + const MODE = ${JSON.stringify(mode)}; + const INK = ${JSON.stringify(INK)}; + const INK_WIDTH = ${INK_WIDTH}; + const HIT_WIDTH = ${ERASE_HIT_WIDTH}; + const SVG_NS = "http://www.w3.org/2000/svg"; + const BINDING = ${JSON.stringify("__threadlinesAttachDrawing")}; + const STASH = ${JSON.stringify("__threadlinesDrawnElements")}; + const COVERAGE = ${DRAW_COVERAGE}; + const MAX_ELEMENTS = ${DRAW_MAX_ELEMENTS}; + const CLOSED_GAP = ${DRAW_CLOSED_GAP}; + + // The same rule the region tool uses, inlined so the page runs exactly what + // the tests cover. + ${coveredBoxIndicesSource()} + + const existing = document.getElementById(OVERLAY_ID); + if (existing) { + existing.__threadlinesSetMode(MODE); + return true; + } + + const host = document.createElement("div"); + host.id = OVERLAY_ID; + // Below the pick overlay, above everything the page has: marks belong on top + // of the content they are about. + host.style.cssText = + "position:absolute;left:0;top:0;z-index:2147483646;pointer-events:auto;"; + const root = host.attachShadow({ mode: "open" }); + root.innerHTML = + "" + + ""; + const svg = root.querySelector("svg"); + + // The overlay has to cover the scrollable page, not just what is on screen, + // or a mark made at the top would be gone by the time you scrolled down. + const resize = () => { + const width = Math.max( + document.documentElement.scrollWidth, + document.body ? document.body.scrollWidth : 0, + ); + const height = Math.max( + document.documentElement.scrollHeight, + document.body ? document.body.scrollHeight : 0, + ); + host.style.width = width + "px"; + host.style.height = height + "px"; + svg.setAttribute("width", String(width)); + svg.setAttribute("height", String(height)); + }; + resize(); + document.documentElement.appendChild(host); + + let mode = MODE; + const setMode = (next) => { + mode = next; + host.setAttribute("data-mode", next); + // Drawing wants the whole surface; erasing wants only the strokes, so the + // page underneath stays scrollable and clickable between them. Idle wants + // nothing: the marks are still there to look at, but another tool has the + // pointer now. + host.style.pointerEvents = next === "draw" ? "auto" : "none"; + host.style.cursor = next === "draw" ? "crosshair" : "default"; + if (next === "idle") { + // The question goes away with the pen. It comes back with it too. + noteField?.remove(); + noteField = null; + return; + } + showNote(); + }; + host.__threadlinesSetMode = setMode; + + let noteField = null; + let stroke = null; + let points = []; + + const pathData = () => { + let data = ""; + for (let index = 0; index < points.length; index += 1) { + const point = points[index]; + data += (index === 0 ? "M" : "L") + point.x + " " + point.y; + } + // A single tap is a dot, which needs a second point to render at all. + if (points.length === 1) { + data += "L" + (points[0].x + 0.01) + " " + points[0].y; + } + return data; + }; + + const onDown = (event) => { + if (mode !== "draw" || event.button !== 0) return; + event.preventDefault(); + points = [{ x: Math.round(event.pageX), y: Math.round(event.pageY) }]; + stroke = document.createElementNS(SVG_NS, "g"); + const hit = document.createElementNS(SVG_NS, "path"); + hit.setAttribute("class", "hit"); + const ink = document.createElementNS(SVG_NS, "path"); + ink.setAttribute("class", "ink"); + stroke.appendChild(hit); + stroke.appendChild(ink); + stroke.dataset.from = points[0].x + "," + points[0].y; + svg.appendChild(stroke); + const data = pathData(); + hit.setAttribute("d", data); + ink.setAttribute("d", data); + host.setPointerCapture?.(event.pointerId); + }; + + const onMove = (event) => { + if (stroke === null) return; + event.preventDefault(); + const point = { x: Math.round(event.pageX), y: Math.round(event.pageY) }; + const last = points[points.length - 1]; + // Skip points the line would not bend at: fewer nodes, same shape. + if (Math.abs(point.x - last.x) < 2 && Math.abs(point.y - last.y) < 2) return; + points.push(point); + const data = pathData(); + for (const path of stroke.children) { + path.setAttribute("d", data); + } + }; + + const onUp = () => { + if (stroke !== null && points.length > 0) { + const last = points[points.length - 1]; + stroke.dataset.to = last.x + "," + last.y; + } + stroke = null; + points = []; + showNote(); + }; + + // Erasing is a click on a stroke, which only reaches us because the hit twin + // is the one element taking pointer events in this mode. + const onErase = (event) => { + if (mode !== "erase") return; + const target = event.target; + if (target && target.parentNode && target.parentNode !== svg.parentNode) { + event.preventDefault(); + event.stopPropagation(); + target.parentNode.remove(); + } + }; + + /** + * What the strokes are drawn around. + * + * Per stroke rather than over all of them together: circling two things in + * different corners means those two things, and a box around the pair would + * sweep up everything between them. + */ + const enclosedElements = () => { + const strokes = []; + for (const group of svg.children) { + const from = (group.dataset.from || "").split(",").map(Number); + const to = (group.dataset.to || "").split(",").map(Number); + if (from.length !== 2 || to.length !== 2) continue; + const box = group.getBoundingClientRect(); + const diagonal = Math.sqrt(box.width * box.width + box.height * box.height); + const gapX = from[0] - to[0]; + const gapY = from[1] - to[1]; + const gap = Math.sqrt(gapX * gapX + gapY * gapY); + // An open stroke went past something rather than round it. + if (diagonal === 0 || gap / diagonal > CLOSED_GAP) continue; + strokes.push({ left: box.left, top: box.top, right: box.right, bottom: box.bottom }); + } + if (strokes.length === 0) { + return []; + } + const candidates = []; + for (const element of document.body.querySelectorAll("*")) { + if (host.contains(element) || element === host) continue; + const tagName = element.tagName; + if (tagName === "SCRIPT" || tagName === "STYLE" || tagName === "BR") continue; + const rect = element.getBoundingClientRect(); + if (rect.width * rect.height < 64) continue; + if (rect.bottom < 0 || rect.top > innerHeight || rect.right < 0 || rect.left > innerWidth) { + continue; + } + const style = getComputedStyle(element); + if (style.visibility === "hidden" || style.display === "none" || style.opacity === "0") { + continue; + } + candidates.push({ element, rect }); + } + const found = []; + for (const strokeBox of strokes) { + const boxes = candidates.map((candidate) => candidate.rect); + for (const index of coveredBoxIndices(boxes, strokeBox, COVERAGE)) { + const element = candidates[index].element; + if (!found.includes(element)) { + found.push(element); + } + } + } + // Outermost only, for the same reason the region tool does it: a circle + // around a card means the card, not the card and its children. + return found + .filter((element) => !found.some((other) => other !== element && other.contains(element))) + .slice(0, MAX_ELEMENTS); + }; + + /** + * Appears once there is something to attach. + * + * Every other tool here ends with the same question, so this one does too -- + * the drawing says which thing, and the note says what about it. + */ + const showNote = () => { + if (noteField !== null || svg.children.length === 0) { + return; + } + noteField = document.createElement("div"); + noteField.className = "note"; + noteField.innerHTML = + "" + + "" + + "" + + "⏎ · esc"; + root.appendChild(noteField); + const input = noteField.querySelector(".input"); + + // Parked in a corner rather than hung off the strokes: the drawing grows + // while you make it, and a bar that chased it would move under the cursor + // exactly when you were reaching for it. + noteField.style.right = "16px"; + noteField.style.bottom = "16px"; + + const attach = () => { + const note = input.value.trim(); + // The elements are read and the bar removed before the caller hears + // anything, because the next thing it does is photograph the page and the + // bar must not be in the picture. + window[STASH] = enclosedElements(); + noteField.remove(); + noteField = null; + window[BINDING]( + JSON.stringify({ note: note === "" ? null : note, count: window[STASH].length }), + ); + }; + const cancel = () => { + window[BINDING](JSON.stringify({ cancelled: true })); + }; + + noteField.querySelector(".attach").addEventListener("click", attach); + noteField.querySelector(".cancel").addEventListener("click", cancel); + input.addEventListener("keydown", (event) => { + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + attach(); + } else if (event.key === "Escape") { + event.preventDefault(); + cancel(); + } + }); + requestAnimationFrame(() => input.focus()); + }; + + // Applied last: setMode reaches for the attach bar, and running it before + // that is declared would throw and take the whole overlay down without a word. + setMode(MODE); + + host.addEventListener("pointerdown", onDown); + host.addEventListener("pointermove", onMove); + host.addEventListener("pointerup", onUp); + host.addEventListener("pointercancel", onUp); + root.addEventListener("pointerdown", onErase, true); + window.addEventListener("resize", resize); + + host.__threadlinesDispose = () => { + window.removeEventListener("resize", resize); + }; + + return true; +})(); +`; +} diff --git a/apps/desktop/src/preview/injectedRuntime.test.ts b/apps/desktop/src/preview/injectedRuntime.test.ts new file mode 100644 index 000000000..074f0906d --- /dev/null +++ b/apps/desktop/src/preview/injectedRuntime.test.ts @@ -0,0 +1,59 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { INJECTED_SCRIPT_REQUIRED_MARKERS } from "@threadlines/shared/previewInjectedScript"; + +import { buildInjectedRuntimeScript } from "./injectedRuntime.ts"; + +/** + * These check the file on disk, so they live with the file. The extractor that + * produced it is tested in @threadlines/shared, where it is shared with the + * build scripts that run it. + */ +describe("the vendored copy", () => { + const vendored = readFileSync( + join(import.meta.dirname, "vendor", "playwrightInjected.js"), + "utf8", + ); + const manifest = JSON.parse( + readFileSync(join(import.meta.dirname, "vendor", "playwrightInjected.json"), "utf8"), + ) as { bytes: number; playwrightCoreVersion: string }; + + it("is the real injected script, with everything we ask of it", () => { + // Cheap insurance that what got checked in is what the extractor thinks it + // extracted -- a truncated or half-written file would pass every unit test + // above and fail only in front of a user. + expect(vendored.length).toBe(manifest.bytes); + for (const marker of INJECTED_SCRIPT_REQUIRED_MARKERS) { + expect(vendored).toContain(marker); + } + }); + + it("is constructed with options this version of the engine reads", () => { + // The gap the hash check cannot cover: an upgrade whose bytes legitimately + // change AND that renames a constructor option. The script would extract + // cleanly, pass every marker check, and then quietly ignore what we passed + // it. Comparing what we send against what its constructor actually reads + // catches that without needing a DOM to run in. + const constructorStart = vendored.indexOf("var InjectedScript = class {"); + const nextMethod = /\n {2}[a-zA-Z_$][\w$]*\(/.exec(vendored.slice(constructorStart + 40)); + const constructorBody = vendored.slice( + constructorStart, + constructorStart + 40 + (nextMethod?.index ?? 0), + ); + const read = new Set([...constructorBody.matchAll(/options\.(\w+)/g)].map((m) => m[1])); + + const wrapper = buildInjectedRuntimeScript("/* engine */"); + const passed = [...wrapper.matchAll(/^\s{4}(\w+):/gm)].map((m) => m[1]); + + expect(passed.length).toBeGreaterThan(0); + expect(passed.filter((name) => !read.has(name))).toEqual([]); + }); + + it("evaluates to a constructible InjectedScript", () => { + // Parsed, not just pattern-matched. A literal that decoded wrongly would + // sail past a substring check and throw the first time a page ran it. + expect(() => new Function(`${vendored}\nreturn typeof pwExport;`)).not.toThrow(); + }); +}); diff --git a/apps/desktop/src/preview/injectedRuntime.ts b/apps/desktop/src/preview/injectedRuntime.ts new file mode 100644 index 000000000..ca3d41a3c --- /dev/null +++ b/apps/desktop/src/preview/injectedRuntime.ts @@ -0,0 +1,134 @@ +/** + * Playwright's element engine, running inside the guest page. + * + * The vendored script is a CommonJS module exporting one class. This is the + * small amount of glue that turns it into something we can call: a wrapper that + * gives it a module to export into, constructs it with the options it wants, + * and parks it somewhere our later calls can find. + * + * It goes into an **isolated world**, not the page's own. Two reasons, and the + * second is the one that matters. An isolated world shares the DOM but not the + * JavaScript context, so nothing we add is reachable by the page: a page cannot + * read our handle, replace `querySelector` underneath us, or notice it is being + * automated by looking for our globals. The competitor we studied assigns to + * `globalThis.__t3PlaywrightInjected` in the main world, which is all three of + * those problems at once. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { + VENDOR_DIRECTORY_NAME, + VENDORED_INJECTED_SCRIPT_FILENAME, +} from "@threadlines/shared/previewInjectedScript"; + +/** + * The vendored script, read once. + * + * From disk rather than bundled in, because 311KB of someone else's generated + * code in the main bundle is 311KB parsed at every start for a feature most + * sessions never touch. + */ +let cachedSource: string | null = null; + +export function injectedScriptSource(): string { + cachedSource ??= readFileSync( + join(import.meta.dirname, VENDOR_DIRECTORY_NAME, VENDORED_INJECTED_SCRIPT_FILENAME), + "utf8", + ); + return cachedSource; +} + +/** The isolated world the engine runs in, named so Chrome hands back the same + * one rather than making another on every call. */ +export const INJECTED_WORLD_NAME = "threadlines-preview"; + +/** Where the constructed engine lives inside the isolated world. */ +export const INJECTED_HANDLE = "__threadlinesInjected"; + +/** + * Builds the engine, or returns the one already built. + * + * Idempotent because an isolated world outlives a single call but not a + * navigation, and the caller should not have to track which of those just + * happened. + */ +export function buildInjectedRuntimeScript(source = injectedScriptSource()): string { + return ` +(() => { + if (globalThis.${INJECTED_HANDLE}) { + return true; + } + const module = { exports: {} }; + ${source} + // The class binding, not module.exports. esbuild assigns the exports object + // at the top of the bundle, before the class exists, and its spread captures + // an undefined that never updates -- so module.exports.InjectedScript is + // permanently not a constructor. + globalThis.${INJECTED_HANDLE} = new InjectedScript(globalThis, { + isUnderTest: false, + sdkLanguage: "javascript", + testIdAttributeName: "data-testid", + stableRafCount: 1, + browserName: "chromium", + customEngines: [], + }); + return true; +})(); +`; +} + +/** + * Resolves a Playwright locator to a single element. + * + * Returns the element itself, so the caller can turn it into a node id the rest + * of our automation already speaks. A locator that matches nothing, or matches + * several things, is an error rather than a guess -- picking the first of four + * "Delete" buttons is exactly the kind of helpfulness nobody wants from a tool + * that clicks things. + */ +export function buildLocatorQueryScript(locator: string): string { + return ` +(() => { + const injected = globalThis.${INJECTED_HANDLE}; + if (!injected) { + throw new Error("the element engine is not loaded in this frame"); + } + const parsed = injected.parseSelector(${JSON.stringify(locator)}); + const matches = injected.querySelectorAll(parsed, document); + if (matches.length === 0) { + throw new Error( + "no element matches " + ${JSON.stringify(JSON.stringify(locator))} + + "; take a new snapshot or loosen the locator", + ); + } + if (matches.length > 1) { + throw new Error( + matches.length + " elements match " + ${JSON.stringify(JSON.stringify(locator))} + + "; narrow it, for example with nth= or by naming an enclosing region", + ); + } + return matches[0]; +})(); +`; +} + +/** + * The page as an accessibility tree, with a ref on everything. + * + * This is what replaces our own tree walk. Ours kept only actionable and + * landmark nodes that had an accessible name, which meant an agent could see + * every button and not a word of what the page said. This carries the text too, + * and the refs in it are what the other tools take. + */ +export function buildAriaSnapshotScript(): string { + return ` +(() => { + const injected = globalThis.${INJECTED_HANDLE}; + if (!injected) { + throw new Error("the element engine is not loaded in this frame"); + } + return injected.ariaSnapshot(document.body, { mode: "ai", refPrefix: "" }); +})(); +`; +} diff --git a/apps/desktop/src/preview/keyEvents.test.ts b/apps/desktop/src/preview/keyEvents.test.ts new file mode 100644 index 000000000..6cb7f4303 --- /dev/null +++ b/apps/desktop/src/preview/keyEvents.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { toCdpKeyDefinition, toCdpModifierBitmask } from "./keyEvents.ts"; + +describe("preview key events", () => { + it("combines CDP modifier flags without counting duplicates twice", () => { + expect(toCdpModifierBitmask(["Alt", "Control", "Meta", "Shift"])).toBe(15); + expect(toCdpModifierBitmask(["Control", "Control", "Shift"])).toBe(10); + }); + + it.each([ + ["Enter", { key: "Enter", code: "Enter", windowsVirtualKeyCode: 13 }], + ["Escape", { key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 }], + ["Tab", { key: "Tab", code: "Tab", windowsVirtualKeyCode: 9 }], + ["Backspace", { key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8 }], + ["ArrowLeft", { key: "ArrowLeft", code: "ArrowLeft", windowsVirtualKeyCode: 37 }], + ["ArrowUp", { key: "ArrowUp", code: "ArrowUp", windowsVirtualKeyCode: 38 }], + ["ArrowRight", { key: "ArrowRight", code: "ArrowRight", windowsVirtualKeyCode: 39 }], + ["ArrowDown", { key: "ArrowDown", code: "ArrowDown", windowsVirtualKeyCode: 40 }], + ])("maps the named %s key to Chromium's keyboard fields", (key, expected) => { + expect(toCdpKeyDefinition(key)).toEqual(expected); + }); + + it("includes text and physical-key fields for printable characters", () => { + expect(toCdpKeyDefinition("a")).toEqual({ + key: "a", + code: "KeyA", + windowsVirtualKeyCode: 65, + text: "a", + }); + expect(toCdpKeyDefinition("?")).toEqual({ + key: "?", + code: "Slash", + windowsVirtualKeyCode: 191, + text: "?", + }); + }); +}); diff --git a/apps/desktop/src/preview/keyEvents.ts b/apps/desktop/src/preview/keyEvents.ts new file mode 100644 index 000000000..77d3a5805 --- /dev/null +++ b/apps/desktop/src/preview/keyEvents.ts @@ -0,0 +1,126 @@ +/** + * A key, as CDP wants to hear about it. + * + * `Input.dispatchKeyEvent` will not infer any of this: send it `key: "a"` alone + * and the page sees a keydown but no character, because insertion comes from + * `text` and shortcut handlers read `code` and `windowsVirtualKeyCode`. Getting + * one of the three wrong produces a key press that half works, which is worse + * to debug than one that does not work at all. + * + * Out here rather than inline because it is pure and entirely table-driven -- + * the sort of thing that is wrong in one entry and right in twenty, which a + * test catches and reading does not. + */ +export type PreviewKeyModifier = "Alt" | "Control" | "Meta" | "Shift"; + +export interface CdpKeyDefinition { + readonly key: string; + readonly code: string; + readonly windowsVirtualKeyCode: number; + readonly text?: string; +} + +const NAMED_KEYS: Readonly> = { + Enter: { key: "Enter", code: "Enter", windowsVirtualKeyCode: 13 }, + Escape: { key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 }, + Tab: { key: "Tab", code: "Tab", windowsVirtualKeyCode: 9 }, + Backspace: { key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8 }, + ArrowLeft: { key: "ArrowLeft", code: "ArrowLeft", windowsVirtualKeyCode: 37 }, + ArrowUp: { key: "ArrowUp", code: "ArrowUp", windowsVirtualKeyCode: 38 }, + ArrowRight: { key: "ArrowRight", code: "ArrowRight", windowsVirtualKeyCode: 39 }, + ArrowDown: { key: "ArrowDown", code: "ArrowDown", windowsVirtualKeyCode: 40 }, +}; + +const PUNCTUATION_KEYS: Readonly> = + { + ";": ["Semicolon", 186], + ":": ["Semicolon", 186], + "=": ["Equal", 187], + "+": ["Equal", 187], + ",": ["Comma", 188], + "<": ["Comma", 188], + "-": ["Minus", 189], + _: ["Minus", 189], + ".": ["Period", 190], + ">": ["Period", 190], + "/": ["Slash", 191], + "?": ["Slash", 191], + "`": ["Backquote", 192], + "~": ["Backquote", 192], + "[": ["BracketLeft", 219], + "{": ["BracketLeft", 219], + "\\": ["Backslash", 220], + "|": ["Backslash", 220], + "]": ["BracketRight", 221], + "}": ["BracketRight", 221], + "'": ["Quote", 222], + '"': ["Quote", 222], + }; + +const SHIFTED_DIGITS: Readonly> = { + "!": "1", + "@": "2", + "#": "3", + $: "4", + "%": "5", + "^": "6", + "&": "7", + "*": "8", + "(": "9", + ")": "0", +}; + +export function toCdpModifierBitmask(modifiers: ReadonlyArray = []): number { + let bitmask = 0; + for (const modifier of modifiers) { + bitmask |= modifier === "Alt" ? 1 : modifier === "Control" ? 2 : modifier === "Meta" ? 4 : 8; + } + return bitmask; +} + +export function toCdpKeyDefinition(key: string): CdpKeyDefinition { + const named = NAMED_KEYS[key]; + if (named !== undefined) { + return named; + } + + if (key.length !== 1) { + return { key, code: key, windowsVirtualKeyCode: 0 }; + } + + if (/^[a-z]$/i.test(key)) { + const upper = key.toUpperCase(); + return { + key, + code: `Key${upper}`, + windowsVirtualKeyCode: upper.charCodeAt(0), + text: key, + }; + } + + const digit = SHIFTED_DIGITS[key] ?? key; + if (/^[0-9]$/.test(digit)) { + return { + key, + code: `Digit${digit}`, + windowsVirtualKeyCode: digit.charCodeAt(0), + text: key, + }; + } + + if (key === " ") { + return { key, code: "Space", windowsVirtualKeyCode: 32, text: key }; + } + + const punctuation = PUNCTUATION_KEYS[key]; + if (punctuation !== undefined) { + return { + key, + code: punctuation[0], + windowsVirtualKeyCode: punctuation[1], + text: key, + }; + } + + return { key, code: "", windowsVirtualKeyCode: key.charCodeAt(0), text: key }; +} diff --git a/apps/desktop/src/preview/parseListeningPorts.test.ts b/apps/desktop/src/preview/parseListeningPorts.test.ts new file mode 100644 index 000000000..c1b18c014 --- /dev/null +++ b/apps/desktop/src/preview/parseListeningPorts.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { parseListeningPorts } from "./parseListeningPorts.ts"; + +describe("parseListeningPorts", () => { + it("pairs each address with the process it was listed under", () => { + const output = ["p123", "cnode", "n*:5173", "p456", "cPython", "n127.0.0.1:8000"].join("\n"); + + // Sorted by port, so the lowest listener is first regardless of lsof order. + expect(parseListeningPorts(output)).toEqual([ + { port: 5173, processName: "node", pid: 123 }, + { port: 8000, processName: "Python", pid: 456 }, + ]); + }); + + it("collapses a port listed once per address family", () => { + const output = ["p1", "cnode", "n*:3000", "n[::1]:3000"].join("\n"); + + expect(parseListeningPorts(output)).toEqual([{ port: 3000, processName: "node", pid: 1 }]); + }); + + it("ignores addresses this machine's browser cannot reach", () => { + const output = ["p1", "cnode", "n192.168.1.50:9999", "n*:3000"].join("\n"); + + expect(parseListeningPorts(output).map((entry) => entry.port)).toEqual([3000]); + }); + + it("ignores established connections, which are not listeners", () => { + const output = ["p1", "cnode", "n127.0.0.1:5173->127.0.0.1:62000"].join("\n"); + + expect(parseListeningPorts(output)).toEqual([]); + }); + + it("does not attribute a port to the previous process when the command is missing", () => { + const output = ["p1", "cnode", "n*:3000", "p2", "n*:4000"].join("\n"); + + expect(parseListeningPorts(output)).toEqual([ + { port: 3000, processName: "node", pid: 1 }, + { port: 4000, processName: "", pid: 2 }, + ]); + }); +}); diff --git a/apps/desktop/src/preview/parseListeningPorts.ts b/apps/desktop/src/preview/parseListeningPorts.ts new file mode 100644 index 000000000..538bb8fd1 --- /dev/null +++ b/apps/desktop/src/preview/parseListeningPorts.ts @@ -0,0 +1,71 @@ +/** + * Parses `lsof -iTCP -sTCP:LISTEN -P -n -F pcn`. + * + * `-F` emits one field per line behind a single-character tag, which is the + * only lsof output worth depending on: the human-readable table changes shape + * between versions and locales. Tags arrive in sets -- `p` (pid) and `c` + * (command) describe the process, and every `n` (address) line after them + * belongs to that process until the next `p`. + */ + +export interface ListeningPort { + port: number; + processName: string; + pid: number; +} + +/** Addresses that a browser on this machine can actually reach. */ +function isLocallyReachable(address: string): boolean { + const host = address.slice(0, address.lastIndexOf(":")); + return ( + host === "*" || + host === "" || + host === "127.0.0.1" || + host === "localhost" || + host === "[::1]" || + host === "::1" || + host === "0.0.0.0" || + host === "[::]" + ); +} + +export function parseListeningPorts(lsofOutput: string): ListeningPort[] { + const byPort = new Map(); + let pid: number | null = null; + let processName = ""; + + for (const line of lsofOutput.split("\n")) { + const tag = line[0]; + const value = line.slice(1); + if (tag === "p") { + const parsed = Number.parseInt(value, 10); + pid = Number.isNaN(parsed) ? null : parsed; + // A new process clears the previous command: pairing an address with the + // wrong name is worse than showing none. + processName = ""; + continue; + } + if (tag === "c") { + processName = value; + continue; + } + if (tag !== "n" || pid === null) { + continue; + } + // IPv6 arrives bracketed, and lsof writes "->" for established + // connections; a listener has no peer. + if (value.includes("->") || !isLocallyReachable(value)) { + continue; + } + const port = Number.parseInt(value.slice(value.lastIndexOf(":") + 1), 10); + if (Number.isNaN(port) || port <= 0) { + continue; + } + // The same port often appears twice, once per address family. One entry. + if (!byPort.has(port)) { + byPort.set(port, { port, processName, pid }); + } + } + + return [...byPort.values()].sort((a, b) => a.port - b.port); +} diff --git a/apps/desktop/src/preview/pickOverlayScript.test.ts b/apps/desktop/src/preview/pickOverlayScript.test.ts new file mode 100644 index 000000000..9ca15d39d --- /dev/null +++ b/apps/desktop/src/preview/pickOverlayScript.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +import { buildDrawOverlayScript, DRAW_OVERLAY_TEARDOWN_SCRIPT } from "./drawOverlayScript.ts"; +import { buildPickOverlayScript, PICK_OVERLAY_TEARDOWN_SCRIPT } from "./pickOverlayScript.ts"; + +/** + * The overlay is a string that only ever runs inside the guest page, where a + * syntax error is invisible: Runtime.evaluate reports it to nobody, the overlay + * never appears, and picking looks like a dead button. That has happened once + * already -- an escape sequence collapsed inside the template literal and left + * an unbalanced group. Parsing it here is what makes that loud. + */ +describe("buildPickOverlayScript", () => { + const modes = ["element", "region"] as const; + const schemes = ["light", "dark"] as const; + + for (const mode of modes) { + for (const scheme of schemes) { + it(`parses as JavaScript for ${mode} picking in ${scheme} mode`, () => { + const script = buildPickOverlayScript(scheme, mode); + expect(() => new Function(script)).not.toThrow(); + }); + } + } + + it("parses the teardown script", () => { + expect(() => new Function(PICK_OVERLAY_TEARDOWN_SCRIPT)).not.toThrow(); + }); + + it("parses the drawing overlay in both of its modes", () => { + expect(() => new Function(buildDrawOverlayScript("draw"))).not.toThrow(); + expect(() => new Function(buildDrawOverlayScript("erase"))).not.toThrow(); + expect(() => new Function(DRAW_OVERLAY_TEARDOWN_SCRIPT)).not.toThrow(); + }); + + it("arms the pointer handlers each mode needs and no others", () => { + const region = buildPickOverlayScript("dark", "region"); + const element = buildPickOverlayScript("dark", "element"); + + // The drag handlers and the hover handler are mutually exclusive: leaving + // hover armed during a region drag would repaint the highlight from under + // the rectangle on every frame. + expect(region).toContain("const REGION_MODE = true"); + expect(element).toContain("const REGION_MODE = false"); + }); +}); diff --git a/apps/desktop/src/preview/pickOverlayScript.ts b/apps/desktop/src/preview/pickOverlayScript.ts new file mode 100644 index 000000000..0cd0066b4 --- /dev/null +++ b/apps/desktop/src/preview/pickOverlayScript.ts @@ -0,0 +1,761 @@ +/** + * The highlight drawn while picking an element. + * + * DevTools' own highlight cannot be styled: with its info card on it is a large + * white panel listing the class chain, and with it off there is no label at all + * -- and its outline is painted per CSS box region, so an element with no + * border gets no outline, which is most of them. This draws the highlight in + * the page instead: one rounded box and a short label. + * + * Injected with Runtime.evaluate rather than a preload. That reaches the page's + * main world directly, so the guest keeps its preload stripped and its context + * isolation on, which the session isolation elsewhere depends on. + * + * Everything lives in a shadow root so the page's own CSS cannot reach it, and + * the overlay never takes pointer events -- the element under the cursor has to + * stay hit-testable for this to work at all. + */ + +/** + * The pointer used while picking. + * + * The page's own cursors leak through otherwise -- an I-beam over text, a hand + * over links -- which says "interact with this" at the moment interaction is + * disabled. One cursor over everything fixes that. + * + * The shape is the arrow from the toolbar's pick icon, so the mode looks like + * the button that started it, and it is drawn small: a cursor is furniture, not + * a graphic. The fill follows the app's theme and the outline is the highlight + * blue, which is what keeps it legible either way. + */ +import { coveredBoxIndicesSource } from "./regionSelection.ts"; + +const ARROW_CURSOR_FILL = { dark: "#16181c", light: "#ffffff" } as const; + +function arrowCursorCss(colorScheme: "light" | "dark"): string { + const svg = [ + '', + // lucide's mouse-pointer, body only -- its tail would read as clutter at + // cursor size. + '', + "", + ].join(""); + const dataUrl = `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`; + // Hotspot on the tip, so what you point at is what gets picked, and `default` + // stays as a fallback in case the image is ever refused. + return `url("${dataUrl}") 2 2, default`; +} + +export const PICK_OVERLAY_BINDING = "__threadlinesPickElement"; +/** + * Where the overlay leaves the elements it chose, for the caller to describe. + * + * The alternative -- reporting coordinates and hit-testing them again from the + * main process -- runs the test twice against a page that may have moved in + * between, so the box the user saw and the element that gets attached are only + * probably the same one. Handing over the elements themselves removes the + * question, and it is the only workable answer for a region, where the choice + * is a set that no single point identifies. + */ +export const PICK_OVERLAY_STASH = "__threadlinesPickedElements"; +const OVERLAY_ID = "__threadlines-pick-overlay"; + +/** Removes any previous overlay, so re-arming never stacks two of them. */ +export const PICK_OVERLAY_TEARDOWN_SCRIPT = ` +(() => { + const existing = document.getElementById(${JSON.stringify(OVERLAY_ID)}); + if (existing) { + existing.__threadlinesDispose?.(); + existing.remove(); + } + document.getElementById(${JSON.stringify(OVERLAY_ID)} + "-cursor")?.remove(); + delete window[${JSON.stringify(PICK_OVERLAY_STASH)}]; +})(); +`; + +/** + * How much of an element the rectangle has to cover to count as selected. + * + * Full containment reads as the honest rule but punishes an imprecise drag: cut + * two pixels off a card and it drops out, which feels broken. Most of the way + * covered is what the gesture means. + */ +const REGION_COVERAGE = 0.8; +/** A sloppy drag across a long page should not attach eighty chips. */ +const REGION_MAX_ELEMENTS = 12; +/** Below this a box is a hairline or a spacer, not something to talk about. */ +const REGION_MIN_AREA = 64; + +export function buildPickOverlayScript( + colorScheme: "light" | "dark", + mode: "element" | "region", +): string { + return ` +(() => { + const OVERLAY_ID = ${JSON.stringify(OVERLAY_ID)}; + const BINDING = ${JSON.stringify(PICK_OVERLAY_BINDING)}; + const STASH = ${JSON.stringify(PICK_OVERLAY_STASH)}; + const REGION_MODE = ${mode === "region"}; + const COVERAGE = ${REGION_COVERAGE}; + const MAX_ELEMENTS = ${REGION_MAX_ELEMENTS}; + const MIN_AREA = ${REGION_MIN_AREA}; + + // The selection rule itself, inlined from regionSelection.ts so the page runs + // exactly what the tests cover. + ${coveredBoxIndicesSource()} + + const previous = document.getElementById(OVERLAY_ID); + if (previous) { + previous.__threadlinesDispose?.(); + previous.remove(); + } + + // The page's own cursors leak through while picking -- an I-beam over text, + // a hand over links -- which reads as "interact with this" at exactly the + // moment interaction is disabled. One cursor over everything fixes that. It + // has to live in the page's own stylesheet: the overlay is in a shadow root + // and its styles cannot reach out. + const cursorStyle = document.createElement("style"); + cursorStyle.id = OVERLAY_ID + "-cursor"; + cursorStyle.textContent = + "*,*::before,*::after{cursor:" + + (REGION_MODE ? "crosshair" : ${JSON.stringify(arrowCursorCss(colorScheme))}) + + " !important}"; + document.documentElement.appendChild(cursorStyle); + + const host = document.createElement("div"); + host.id = OVERLAY_ID; + // Fixed and inert: the overlay must never intercept the pointer, or + // elementFromPoint would only ever return the overlay itself. + host.style.cssText = + "position:fixed;inset:0;pointer-events:none;z-index:2147483647;"; + const root = host.attachShadow({ mode: "open" }); + root.innerHTML = + "" + + ""; + const band = root.querySelector(".band"); + const tag = root.querySelector(".tag"); + document.documentElement.appendChild(host); + + // One box per highlighted element, kept in a pool: element mode uses one, + // a region uses as many as it caught, and reusing them keeps the drag from + // churning nodes on every frame. + const boxes = []; + const paintBoxes = (elements) => { + while (boxes.length < elements.length) { + const box = document.createElement("div"); + box.className = "box" + (REGION_MODE ? " member" : ""); + root.appendChild(box); + boxes.push(box); + } + boxes.forEach((box, index) => { + const element = elements[index]; + if (!element) { + box.hidden = true; + return; + } + const rect = element.getBoundingClientRect(); + box.hidden = false; + box.style.left = rect.left + "px"; + box.style.top = rect.top + "px"; + box.style.width = rect.width + "px"; + box.style.height = rect.height + "px"; + }); + }; + + const describe = (element) => { + const parts = [element.tagName.toLowerCase()]; + if (element.id) parts.push("#" + element.id); + return parts.join(""); + }; + + // Above the anchor by default, tucked below when it would sit off-screen. + const placeTag = (rect) => { + const tagRect = tag.getBoundingClientRect(); + const above = rect.top - tagRect.height - 6; + tag.style.top = + (above < 4 ? Math.min(rect.bottom + 6, innerHeight - tagRect.height - 4) : above) + "px"; + tag.style.left = Math.max(4, Math.min(rect.left, innerWidth - tagRect.width - 4)) + "px"; + }; + + const paint = (element) => { + paintBoxes([element]); + const rect = element.getBoundingClientRect(); + tag.hidden = false; + tag.innerHTML = ""; + tag.querySelector(".name").textContent = describe(element); + tag.querySelector(".dim").textContent = + Math.round(rect.width) + "×" + Math.round(rect.height); + placeTag(rect); + }; + + const hide = () => { + paintBoxes([]); + tag.hidden = true; + current = null; + }; + + let current = null; + + const onMove = (event) => { + const element = document.elementFromPoint(event.clientX, event.clientY); + if (!element || element === host) { + hide(); + return; + } + current = element; + paint(element); + }; + + // The elements chosen so far. One in element mode, however many the + // rectangle caught in region mode; the note field appears once it is filled. + let chosen = []; + + // The properties worth trying on the spot. Enough to describe a visual + // change precisely, not so many that the panel becomes a style editor: the + // agent makes the real change, this only says what to aim for. + const TWEAKS = [ + { key: "font-size", label: "font size", kind: "px" }, + { key: "font-weight", label: "font weight", kind: "number", min: 100, max: 900, step: 50 }, + { key: "color", label: "text colour", kind: "colour" }, + { key: "background-color", label: "background", kind: "colour" }, + { key: "opacity", label: "opacity", kind: "number", min: 0, max: 1, step: 0.05 }, + { key: "border-radius", label: "radius", kind: "px" }, + { key: "border-color", label: "border colour", kind: "colour" }, + { key: "padding", label: "padding", kind: "px" }, + { key: "letter-spacing", label: "letter spacing", kind: "px" }, + { key: "line-height", label: "line height", kind: "px" }, + ]; + + const attach = (note, styleChanges) => { + window[STASH] = chosen; + dispose(); + window[BINDING]( + JSON.stringify({ count: chosen.length, note: note === "" ? null : note, styleChanges }), + ); + }; + + const cancel = () => { + dispose(); + window[BINDING](JSON.stringify({ cancelled: true })); + }; + + /** The box the note field hangs off: the element, or the whole selection. */ + const anchorRect = () => { + const rects = chosen.map((element) => element.getBoundingClientRect()); + return { + left: Math.min(...rects.map((rect) => rect.left)), + right: Math.max(...rects.map((rect) => rect.right)), + top: Math.min(...rects.map((rect) => rect.top)), + bottom: Math.max(...rects.map((rect) => rect.bottom)), + }; + }; + + const showNoteInput = () => { + // Style tweaks are a conversation about one element: applied across a + // selection they would say "make all of these 18px", which is a different + // request and rarely the one being made. + const styleable = chosen.length === 1; + const placeholder = styleable + ? "What about this element?" + : "What about these " + chosen.length + " elements?"; + + const field = document.createElement("div"); + field.className = "note"; + field.innerHTML = + "
" + + "" + + "" + + "
" + + "" + + "
⏎ · esc" + + "" + + "" + + "
"; + root.appendChild(field); + const input = field.querySelector(".input"); + const panel = field.querySelector(".panel"); + const toggle = field.querySelector(".toggle"); + const resetButton = field.querySelector(".reset"); + input.placeholder = placeholder; + toggle.hidden = !styleable; + + const place = () => { + const rect = anchorRect(); + const below = rect.bottom + 8; + field.style.left = + Math.max(8, Math.min(rect.left, innerWidth - field.offsetWidth - 8)) + "px"; + field.style.top = + (below + field.offsetHeight > innerHeight + ? Math.max(8, innerHeight - field.offsetHeight - 8) + : below) + "px"; + }; + + const tweaks = new Map(); + + if (styleable) { + const target = chosen[0]; + + // A computed colour is whatever the page authored: rgb(), but just as + // often oklch() or lab(). Neither string parsing nor canvas normalisation + // handles those -- canvas accepts oklch and hands it straight back in the + // same space -- so the colour is painted onto a single pixel and read back + // as bytes. Whatever the browser can render, this can convert. + const scratch = document.createElement("canvas"); + scratch.width = 1; + scratch.height = 1; + const scratchContext = scratch.getContext("2d", { willReadFrequently: true }); + const toHex = (value) => { + if (!value) return "#000000"; + scratchContext.clearRect(0, 0, 1, 1); + scratchContext.fillStyle = "#000000"; + try { + scratchContext.fillStyle = value; + } catch { + return "#000000"; + } + scratchContext.fillRect(0, 0, 1, 1); + try { + const pixel = scratchContext.getImageData(0, 0, 1, 1).data; + return ( + "#" + + [pixel[0], pixel[1], pixel[2]] + .map((channel) => channel.toString(16).padStart(2, "0")) + .join("") + ); + } catch { + return "#000000"; + } + }; + + const computed = getComputedStyle(target); + const originals = {}; + const recordTweak = (property, from, to) => { + if (from === to) { + tweaks.delete(property); + } else { + tweaks.set(property, { property, from, to }); + } + resetButton.hidden = tweaks.size === 0; + }; + + for (const tweak of TWEAKS) { + const original = computed.getPropertyValue(tweak.key).trim(); + originals[tweak.key] = original; + + const row = document.createElement("label"); + row.className = "prop"; + const name = document.createElement("span"); + name.className = "prop-name"; + name.textContent = tweak.label; + row.appendChild(name); + + const control = document.createElement("input"); + control.className = tweak.kind === "colour" ? "colour" : "num"; + if (tweak.kind === "colour") { + control.type = "color"; + control.value = toHex(original); + } else { + control.type = "number"; + control.value = String(Math.round(parseFloat(original) * 100) / 100 || 0); + if (tweak.min !== undefined) control.min = String(tweak.min); + if (tweak.max !== undefined) control.max = String(tweak.max); + control.step = String(tweak.step ?? 1); + } + control.addEventListener("input", () => { + const next = + tweak.kind === "colour" + ? control.value + : tweak.kind === "px" + ? control.value + "px" + : control.value; + target.style.setProperty(tweak.key, next); + recordTweak(tweak.key, tweak.kind === "colour" ? toHex(original) : original, next); + }); + // Arrow keys and typing belong to the field, not to the annotation. + control.addEventListener("keydown", (event) => event.stopPropagation()); + if (tweak.kind === "colour") { + row.appendChild(control); + } else { + // A stepper of our own: the platform's is a light-mode control that + // cannot be themed, but nudging a value one step at a time is the + // point of a number field. + const stepper = document.createElement("span"); + stepper.className = "stepper"; + const step = (direction) => { + const amount = Number(control.step) || 1; + const next = (Number(control.value) || 0) + direction * amount; + const min = control.min === "" ? -Infinity : Number(control.min); + const max = control.max === "" ? Infinity : Number(control.max); + // Rounded because repeated fractional steps drift (0.05 at a time + // reaches 0.30000000000000004). + control.value = String( + Math.round(Math.max(min, Math.min(max, next)) * 1000) / 1000, + ); + control.dispatchEvent(new Event("input", { bubbles: true })); + }; + const down = document.createElement("button"); + down.type = "button"; + down.className = "step"; + down.textContent = "−"; + down.addEventListener("click", () => step(-1)); + const up = document.createElement("button"); + up.type = "button"; + up.className = "step"; + up.textContent = "+"; + up.addEventListener("click", () => step(1)); + stepper.appendChild(down); + stepper.appendChild(control); + stepper.appendChild(up); + row.appendChild(stepper); + } + if (tweak.kind === "px") { + const unit = document.createElement("span"); + unit.className = "unit"; + unit.textContent = "px"; + row.appendChild(unit); + } + panel.appendChild(row); + } + + toggle.addEventListener("click", () => { + panel.hidden = !panel.hidden; + toggle.classList.toggle("on", !panel.hidden); + place(); + input.focus(); + }); + + resetButton.addEventListener("click", () => { + for (const tweak of TWEAKS) { + target.style.removeProperty(tweak.key); + } + for (const control of panel.querySelectorAll("input")) { + const key = control.parentElement.querySelector(".prop-name").textContent; + const tweak = TWEAKS.find((entry) => entry.label === key); + if (!tweak) continue; + const original = originals[tweak.key]; + control.value = + tweak.kind === "colour" + ? toHex(original) + : String(Math.round(parseFloat(original) * 100) / 100 || 0); + } + tweaks.clear(); + resetButton.hidden = true; + input.focus(); + }); + } + + place(); + + field.querySelector(".attach").addEventListener("click", () => { + attach(input.value.trim(), [...tweaks.values()]); + }); + field.querySelector(".cancel").addEventListener("click", cancel); + input.addEventListener("keydown", (event) => { + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + attach(input.value.trim(), [...tweaks.values()]); + } else if (event.key === "Escape") { + event.preventDefault(); + cancel(); + } + }); + requestAnimationFrame(() => input.focus()); + }; + + const swallow = (event) => { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + }; + + const onClick = (event) => { + // Our own field is exempt. The handler below swallows every click so that + // picking a "Delete" button does not also press it, and that same + // swallowing silently disabled the buttons inside the annotation field -- + // composedPath is what sees through the shadow root to tell them apart. + if (event.composedPath().includes(host)) { + return; + } + // Capture phase and fully swallowed: picking a "Delete" button must not + // also press it. + swallow(event); + if (REGION_MODE || chosen.length > 0) { + return; + } + const element = document.elementFromPoint(event.clientX, event.clientY); + if (!element || element === host) { + return; + } + chosen = [element]; + paint(element); + // Stop following the pointer: the highlight now belongs to the choice. + document.removeEventListener("mousemove", onMove, true); + showNoteInput(); + }; + + // --- Region ------------------------------------------------------------- + + // Rects are measured once when the drag starts rather than every frame: the + // page cannot move under a drag that is holding the mouse down, and reading + // a few thousand boxes per frame would make the rectangle stutter on exactly + // the dense pages worth selecting a region of. + let candidates = []; + let origin = null; + let bandRect = null; + let pending = false; + + const collectCandidates = () => { + const found = []; + for (const element of document.body.querySelectorAll("*")) { + if (host.contains(element) || element === host) continue; + const tagName = element.tagName; + if (tagName === "SCRIPT" || tagName === "STYLE" || tagName === "BR" || + tagName === "NOSCRIPT" || tagName === "TEMPLATE") { + continue; + } + const rect = element.getBoundingClientRect(); + const area = rect.width * rect.height; + if (area < MIN_AREA) continue; + // Off-screen boxes cannot be inside a rectangle drawn on screen, and + // skipping them here keeps the per-frame work to what is visible. + if (rect.bottom < 0 || rect.top > innerHeight || rect.right < 0 || rect.left > innerWidth) { + continue; + } + const style = getComputedStyle(element); + if (style.visibility === "hidden" || style.display === "none" || style.opacity === "0") { + continue; + } + found.push({ element, rect }); + } + return found; + }; + + const matchRegion = (region) => { + const covered = coveredBoxIndices( + candidates.map((candidate) => candidate.rect), + region, + COVERAGE, + ).map((index) => candidates[index].element); + // Only the outermost survive: a rectangle over a card means the card, not + // the card and each of its forty descendants, which is what the agent + // would otherwise be handed. + return covered + .filter((element) => !covered.some((other) => other !== element && other.contains(element))) + .slice(0, MAX_ELEMENTS); + }; + + const paintBand = () => { + pending = false; + if (bandRect === null) return; + band.hidden = false; + band.style.left = bandRect.left + "px"; + band.style.top = bandRect.top + "px"; + band.style.width = (bandRect.right - bandRect.left) + "px"; + band.style.height = (bandRect.bottom - bandRect.top) + "px"; + + const matched = matchRegion(bandRect); + paintBoxes(matched); + tag.hidden = false; + tag.innerHTML = ""; + tag.querySelector(".name").textContent = + matched.length === 1 ? "1 element" : matched.length + " elements"; + tag.querySelector(".dim").textContent = + Math.round(bandRect.right - bandRect.left) + "×" + Math.round(bandRect.bottom - bandRect.top); + placeTag(bandRect); + }; + + // Every pointer event outside our own field is swallowed for as long as the + // mode is armed, including after the rectangle is drawn: the page must not + // receive a stray press while the note is being written. + const onRegionDown = (event) => { + if (event.composedPath().includes(host)) return; + swallow(event); + if (chosen.length > 0) return; + origin = { x: event.clientX, y: event.clientY }; + candidates = collectCandidates(); + }; + + const onRegionMove = (event) => { + if (event.composedPath().includes(host)) return; + swallow(event); + if (origin === null) return; + bandRect = { + left: Math.min(origin.x, event.clientX), + right: Math.max(origin.x, event.clientX), + top: Math.min(origin.y, event.clientY), + bottom: Math.max(origin.y, event.clientY), + }; + // One paint per frame: mousemove fires far faster than the screen updates + // and each paint re-tests every candidate. + if (!pending) { + pending = true; + requestAnimationFrame(paintBand); + } + }; + + const onRegionUp = (event) => { + if (event.composedPath().includes(host)) return; + swallow(event); + if (origin === null) return; + const dragged = bandRect; + origin = null; + bandRect = null; + band.hidden = true; + // A click rather than a drag: too small to have meant a rectangle, and + // treating it as one would select the whole page under the cursor. + if (dragged === null || dragged.right - dragged.left < 6 || dragged.bottom - dragged.top < 6) { + hide(); + return; + } + const matched = matchRegion(dragged); + if (matched.length === 0) { + hide(); + return; + } + chosen = matched; + paintBoxes(matched); + showNoteInput(); + }; + + const onKeyDown = (event) => { + if (event.key === "Escape") { + event.preventDefault(); + cancel(); + } + }; + + const onScroll = () => { + if (chosen.length > 0) { + paintBoxes(chosen); + } else if (current) { + paint(current); + } + }; + + function dispose() { + cursorStyle.remove(); + document.removeEventListener("mousemove", onMove, true); + document.removeEventListener("mousedown", onRegionDown, true); + document.removeEventListener("mousemove", onRegionMove, true); + document.removeEventListener("mouseup", onRegionUp, true); + document.removeEventListener("click", onClick, true); + document.removeEventListener("keydown", onKeyDown, true); + window.removeEventListener("scroll", onScroll, true); + window.removeEventListener("resize", onScroll, true); + host.remove(); + } + host.__threadlinesDispose = dispose; + + if (REGION_MODE) { + document.addEventListener("mousedown", onRegionDown, true); + document.addEventListener("mousemove", onRegionMove, true); + document.addEventListener("mouseup", onRegionUp, true); + } else { + document.addEventListener("mousemove", onMove, true); + } + document.addEventListener("click", onClick, true); + document.addEventListener("keydown", onKeyDown, true); + window.addEventListener("scroll", onScroll, true); + window.addEventListener("resize", onScroll, true); +})(); +`; +} diff --git a/apps/desktop/src/preview/previewConsoleNoise.test.ts b/apps/desktop/src/preview/previewConsoleNoise.test.ts new file mode 100644 index 000000000..deaf0c5f1 --- /dev/null +++ b/apps/desktop/src/preview/previewConsoleNoise.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { isHostInjectedConsoleEntry } from "./previewConsoleNoise.ts"; + +describe("isHostInjectedConsoleEntry", () => { + it("drops Electron's injected security warning", () => { + expect( + isHostInjectedConsoleEntry({ + level: "warning", + text: "%cElectron Security Warning (Insecure Content-Security-Policy) font-weight: bold; This renderer process has either no Content Security Policy set...", + }), + ).toBe(true); + }); + + it("keeps warnings the page itself produced", () => { + expect(isHostInjectedConsoleEntry({ level: "warning", text: "deprecated prop `size`" })).toBe( + false, + ); + }); + + it("never drops errors, even if the page mentions Electron", () => { + // A page debugging its own Electron integration must still see its errors. + expect( + isHostInjectedConsoleEntry({ + level: "error", + text: "Electron Security Warning handling failed", + }), + ).toBe(false); + }); +}); diff --git a/apps/desktop/src/preview/previewConsoleNoise.ts b/apps/desktop/src/preview/previewConsoleNoise.ts new file mode 100644 index 000000000..b78e8efae --- /dev/null +++ b/apps/desktop/src/preview/previewConsoleNoise.ts @@ -0,0 +1,27 @@ +/** + * Console output the host injected into the guest, rather than the page's own. + * + * Electron writes security warnings straight into an unpackaged renderer's + * console. They are about how Electron is hosting the page, not about the page, + * so they are noise in a buffer whose entire purpose is to tell an agent what + * the page under development is doing wrong. + * + * Filtered rather than suppressed with ELECTRON_DISABLE_SECURITY_WARNINGS, + * because that variable is process-wide: it would also silence the warnings for + * Threadlines' own renderer, losing a real safety net on our own code to clean + * up someone else's console. + * + * Matching on the warning's brand string is deliberate. It is stable across + * Electron versions, and if it ever changes the failure mode is that a warning + * reappears in the buffer -- the behaviour we have today -- rather than + * anything breaking. + */ + +const ELECTRON_WARNING_MARKER = "Electron Security Warning"; + +export function isHostInjectedConsoleEntry(entry: { + readonly level: string; + readonly text: string; +}): boolean { + return entry.level === "warning" && entry.text.includes(ELECTRON_WARNING_MARKER); +} diff --git a/apps/desktop/src/preview/probeHttpServer.test.ts b/apps/desktop/src/preview/probeHttpServer.test.ts new file mode 100644 index 000000000..1b6cad256 --- /dev/null +++ b/apps/desktop/src/preview/probeHttpServer.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { extractHtmlTitle, isHtmlContentType } from "./probeHttpServer.ts"; + +describe("extractHtmlTitle", () => { + it("reads a title across attributes and newlines", () => { + expect(extractHtmlTitle('My\n Dev App')).toBe( + "My Dev App", + ); + }); + + it("returns null when there is no usable title", () => { + expect(extractHtmlTitle("")).toBeNull(); + expect(extractHtmlTitle(" ")).toBeNull(); + }); +}); + +describe("isHtmlContentType", () => { + it("accepts html with a charset", () => { + expect(isHtmlContentType("text/html; charset=utf-8")).toBe(true); + }); + + it("rejects everything else, including a missing header", () => { + // AirPlay answers without a content type; an API serves JSON. Neither is a + // page you would open in the preview. + expect(isHtmlContentType(undefined)).toBe(false); + expect(isHtmlContentType("application/json")).toBe(false); + }); +}); diff --git a/apps/desktop/src/preview/probeHttpServer.ts b/apps/desktop/src/preview/probeHttpServer.ts new file mode 100644 index 000000000..591c58725 --- /dev/null +++ b/apps/desktop/src/preview/probeHttpServer.ts @@ -0,0 +1,100 @@ +/** + * Decides whether a listening port is something you could actually open. + * + * A machine has many listeners that are not web servers: Handoff daemons, + * AirPlay receivers, database sockets, an app's own internal ports. Listing + * them all makes the useful entries hard to find, so each candidate is asked + * for a page and kept only if it serves one. + * + * Measured against a real machine: a dev server answers 200 text/html, AirPlay + * answers 403 with no content type, and a Handoff daemon does not speak HTTP at + * all. Serving HTML is the line that separates them. + */ + +import { get, type IncomingMessage } from "node:http"; + +const PROBE_TIMEOUT_MS = 800; +/** Enough for ; a title after this is not worth holding a socket open for. */ +const MAX_TITLE_BYTES = 16_000; +const MAX_REDIRECTS = 2; + +/** + * A page's title, for labelling. Titles are far more useful than process names: + * three dev servers all called "node" are indistinguishable, while their titles + * are exactly what the user is looking for. + */ +export function extractHtmlTitle(html: string): string | null { + const match = /]*>([\s\S]{0,200}?)<\/title>/i.exec(html); + if (match?.[1] === undefined) { + return null; + } + const title = match[1].replace(/\s+/g, " ").trim(); + return title === "" ? null : title; +} + +export function isHtmlContentType(contentType: string | undefined): boolean { + return contentType !== undefined && contentType.toLowerCase().includes("text/html"); +} + +export interface HttpProbeResult { + title: string | null; +} + +/** Resolves null for anything that is not an HTML-serving HTTP endpoint. */ +export function probeHttpServer( + port: number, + redirectsLeft = MAX_REDIRECTS, +): Promise { + return new Promise((resolve) => { + let settled = false; + const finish = (result: HttpProbeResult | null) => { + if (!settled) { + settled = true; + resolve(result); + } + }; + + const request = get( + { host: "127.0.0.1", port, path: "/", timeout: PROBE_TIMEOUT_MS }, + (response: IncomingMessage) => { + const status = response.statusCode ?? 0; + const location = response.headers.location; + if (status >= 300 && status < 400 && location !== undefined && redirectsLeft > 0) { + // A dev server redirecting to its app shell is still a web server; + // the page that matters is the one it points at. + response.resume(); + const target = /^https?:\/\//i.test(location) ? null : location; + if (target === null) { + finish(null); + return; + } + void probeHttpServer(port, redirectsLeft - 1).then(finish); + return; + } + if (status >= 400 || !isHtmlContentType(response.headers["content-type"])) { + response.resume(); + finish(null); + return; + } + + let body = ""; + response.setEncoding("utf8"); + response.on("data", (chunk: string) => { + body += chunk; + if (body.length >= MAX_TITLE_BYTES) { + response.destroy(); + finish({ title: extractHtmlTitle(body) }); + } + }); + response.on("end", () => finish({ title: extractHtmlTitle(body) })); + response.on("error", () => finish(null)); + }, + ); + + request.on("timeout", () => { + request.destroy(); + finish(null); + }); + request.on("error", () => finish(null)); + }); +} diff --git a/apps/desktop/src/preview/regionSelection.test.ts b/apps/desktop/src/preview/regionSelection.test.ts new file mode 100644 index 000000000..d63994fad --- /dev/null +++ b/apps/desktop/src/preview/regionSelection.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; + +import { coveredBoxIndices, coveredBoxIndicesSource } from "./regionSelection.ts"; + +const box = (left: number, top: number, right: number, bottom: number) => ({ + left, + top, + right, + bottom, +}); + +describe("coveredBoxIndices", () => { + const region = box(0, 0, 100, 100); + + it("takes a box the rectangle mostly covers and leaves one it only clips", () => { + const boxes = [ + // Fully inside. + box(10, 10, 50, 50), + // Nine tenths inside: a hurried drag, not a different intent. + box(10, 10, 100, 110), + // A tenth inside: the rectangle brushed past it. + box(90, 90, 190, 190), + // Not touching at all. + box(200, 200, 300, 300), + ]; + + expect(coveredBoxIndices(boxes, region, 0.8)).toEqual([0, 1]); + }); + + it("leaves out the container the rectangle is merely drawn inside", () => { + // The reason coverage is measured against the box rather than the region: + // a rectangle drawn in the middle of a page overlaps every wrapper around + // it, and intersection alone would select the whole document. + expect(coveredBoxIndices([box(-500, -500, 1500, 1500)], region, 0.8)).toEqual([]); + }); + + it("ignores boxes with no area", () => { + expect(coveredBoxIndices([box(10, 10, 10, 50), box(10, 10, 50, 10)], region, 0.8)).toEqual([]); + }); + + it("stays inlinable into the page overlay", () => { + // The overlay evaluates this function's source inside the guest. If a + // bundler ever rewrites it to reach for a helper, the overlay dies silently + // and picking simply stops working. + const source = coveredBoxIndicesSource(); + expect(source).toContain("Math.min"); + expect(source).not.toContain("import"); + }); +}); diff --git a/apps/desktop/src/preview/regionSelection.ts b/apps/desktop/src/preview/regionSelection.ts new file mode 100644 index 000000000..577049b8e --- /dev/null +++ b/apps/desktop/src/preview/regionSelection.ts @@ -0,0 +1,72 @@ +/** + * Which boxes a dragged rectangle has caught. + * + * Lives out here rather than inside the injected overlay so it can be checked + * against known numbers: it is the rule that decides what the user gets, it is + * the thing most likely to be retuned, and buried in a five-hundred-line + * template string it would be neither readable nor testable. The overlay + * inlines this function's own source, so there is still only one copy of it. + * + * Written in plain ES so it survives being stringified and evaluated inside the + * page: no imports, no closure over anything, nothing the bundler has to + * rewrite into a helper that would not exist on the other side. + */ + +export interface RegionBox { + left: number; + top: number; + right: number; + bottom: number; +} + +/** + * The indices of the boxes at least `coverage` of the way inside `region`. + * + * Not full containment: clipping two pixels off a card during a hurried drag + * would drop it, which reads as the selection being broken rather than precise. + * Not mere intersection either, or a rectangle drawn inside a page would catch + * every wrapper it sits within, up to and including the body. + */ +export function coveredBoxIndices( + boxes: ReadonlyArray, + region: RegionBox, + coverage: number, +): number[] { + const indices: number[] = []; + for (let index = 0; index < boxes.length; index += 1) { + const box = boxes[index]; + if (box === undefined) { + continue; + } + const area = (box.right - box.left) * (box.bottom - box.top); + if (area <= 0) { + continue; + } + const width = Math.min(box.right, region.right) - Math.max(box.left, region.left); + const height = Math.min(box.bottom, region.bottom) - Math.max(box.top, region.top); + if (width <= 0 || height <= 0) { + continue; + } + if ((width * height) / area < coverage) { + continue; + } + indices.push(index); + } + return indices; +} + +/** + * The source of `coveredBoxIndices`, for inlining into the injected overlay. + * + * Asserted rather than trusted: if a bundler ever rewrites the function into + * something that closes over a helper, the overlay would fail silently in the + * page -- and a silent overlay failure is the one bug in this feature that has + * already happened once. + */ +export function coveredBoxIndicesSource(): string { + const source = coveredBoxIndices.toString(); + if (!source.startsWith("function coveredBoxIndices(")) { + throw new Error(`coveredBoxIndices is not inlinable: ${source.slice(0, 60)}`); + } + return source; +} diff --git a/apps/desktop/src/preview/revealElementScript.ts b/apps/desktop/src/preview/revealElementScript.ts new file mode 100644 index 000000000..1d077fb58 --- /dev/null +++ b/apps/desktop/src/preview/revealElementScript.ts @@ -0,0 +1,91 @@ +/** + * Shows again, in the page, an element that was picked earlier. + * + * The chip in the composer says what was picked but gives no way back to it, + * and by the time you are writing the message the page may have scrolled + * somewhere else entirely. This scrolls it back into view and flashes the same + * blue box the picker drew, so the thing named in the chip and the thing on + * screen are visibly the same. + * + * Found by selector rather than by node id: the id that identified the element + * during picking died with that document, and the point of this is to work + * after a reload. + */ + +const REVEAL_ID = "__threadlines-reveal-overlay"; +/** Long enough to follow the scroll and register, short enough not to linger. */ +const REVEAL_VISIBLE_MS = 1600; + +export function buildRevealElementScript(selector: string): string { + return ` +(() => { + const SELECTOR = ${JSON.stringify(selector)}; + const OVERLAY_ID = ${JSON.stringify(REVEAL_ID)}; + const VISIBLE_MS = ${REVEAL_VISIBLE_MS}; + + let element = null; + try { + element = document.querySelector(SELECTOR); + } catch { + // A selector that no longer parses is the same as one that finds nothing. + return false; + } + if (!element) { + return false; + } + + document.getElementById(OVERLAY_ID)?.remove(); + + const host = document.createElement("div"); + host.id = OVERLAY_ID; + host.style.cssText = + "position:fixed;inset:0;pointer-events:none;z-index:2147483647;"; + const root = host.attachShadow({ mode: "open" }); + root.innerHTML = + "
"; + const box = root.querySelector(".box"); + document.documentElement.appendChild(host); + + const place = () => { + const rect = element.getBoundingClientRect(); + box.style.left = rect.left + "px"; + box.style.top = rect.top + "px"; + box.style.width = rect.width + "px"; + box.style.height = rect.height + "px"; + }; + place(); + + // Only scrolls when it needs to: an element already on screen should not be + // yanked to the middle just for being named. + const rect = element.getBoundingClientRect(); + if (rect.bottom < 0 || rect.top > innerHeight || rect.right < 0 || rect.left > innerWidth) { + element.scrollIntoView({ block: "center", inline: "center", behavior: "smooth" }); + } + + // Track the element through the scroll so the box does not lag behind it. + const follow = setInterval(place, 60); + setTimeout(() => { + clearInterval(follow); + box.classList.add("fade"); + setTimeout(() => host.remove(), 260); + }, VISIBLE_MS); + + return true; +})(); +`; +} + +export const REVEAL_TEARDOWN_SCRIPT = ` +document.getElementById(${JSON.stringify(REVEAL_ID)})?.remove(); +`; diff --git a/apps/desktop/src/preview/vendor/playwrightInjected.js b/apps/desktop/src/preview/vendor/playwrightInjected.js new file mode 100644 index 000000000..49a79943c --- /dev/null +++ b/apps/desktop/src/preview/vendor/playwrightInjected.js @@ -0,0 +1,8159 @@ + +var __commonJS = obj => { + let required = false; + let result; + return function __require() { + if (!required) { + required = true; + let fn; + for (const name in obj) { fn = obj[name]; break; } + const module = { exports: {} }; + fn(module.exports, module); + result = module.exports; + } + return result; + } +}; +var __export = (target, all) => {for (var name in all) target[name] = all[name];}; +var __toESM = mod => ({ ...mod, 'default': mod }); +var __toCommonJS = mod => ({ ...mod, __esModule: true }); + + +// packages/injected/src/injectedScript.ts +var injectedScript_exports = {}; +__export(injectedScript_exports, { + InjectedScript: () => InjectedScript +}); +module.exports = __toCommonJS(injectedScript_exports); + +// packages/isomorphic/ariaSnapshot.ts +function ariaNodesEqual(a, b) { + if (a.role !== b.role || a.name !== b.name) + return false; + if (!ariaPropsEqual(a, b) || hasPointerCursor(a) !== hasPointerCursor(b)) + return false; + const aKeys = Object.keys(a.props); + const bKeys = Object.keys(b.props); + return aKeys.length === bKeys.length && aKeys.every((k) => a.props[k] === b.props[k]); +} +function hasPointerCursor(ariaNode) { + return ariaNode.box.cursor === "pointer"; +} +function ariaPropsEqual(a, b) { + return a.active === b.active && a.checked === b.checked && a.disabled === b.disabled && a.expanded === b.expanded && a.invalid === b.invalid && a.selected === b.selected && a.level === b.level && a.pressed === b.pressed; +} +function parseAriaSnapshot(yaml, text, options = {}) { + var _a; + const lineCounter = new yaml.LineCounter(); + const parseOptions = { + keepSourceTokens: true, + lineCounter, + ...options + }; + const yamlDoc = yaml.parseDocument(text, parseOptions); + const errors = []; + const convertRange = (range) => { + return [lineCounter.linePos(range[0]), lineCounter.linePos(range[1])]; + }; + const addError = (error) => { + errors.push({ + message: error.message, + range: [lineCounter.linePos(error.pos[0]), lineCounter.linePos(error.pos[1])] + }); + }; + const convertSeq = (container, seq) => { + for (const item of seq.items) { + const itemIsString = item instanceof yaml.Scalar && typeof item.value === "string"; + if (itemIsString) { + const childNode = KeyParser.parse(item, parseOptions, errors); + if (childNode) { + container.children = container.children || []; + container.children.push(childNode); + } + continue; + } + const itemIsMap = item instanceof yaml.YAMLMap; + if (itemIsMap) { + convertMap(container, item); + continue; + } + errors.push({ + message: "Sequence items should be strings or maps", + range: convertRange(item.range || seq.range) + }); + } + }; + const convertMap = (container, map) => { + var _a2; + for (const entry of map.items) { + container.children = container.children || []; + const keyIsString = entry.key instanceof yaml.Scalar && typeof entry.key.value === "string"; + if (!keyIsString) { + errors.push({ + message: "Only string keys are supported", + range: convertRange(entry.key.range || map.range) + }); + continue; + } + const key = entry.key; + const value = entry.value; + if (key.value === "text") { + const valueIsString = value instanceof yaml.Scalar && typeof value.value === "string"; + if (!valueIsString) { + errors.push({ + message: "Text value should be a string", + range: convertRange(entry.value.range || map.range) + }); + continue; + } + container.children.push({ + kind: "text", + text: textValue(value.value) + }); + continue; + } + if (key.value === "/children") { + const valueIsString = value instanceof yaml.Scalar && typeof value.value === "string"; + if (!valueIsString || value.value !== "contain" && value.value !== "equal" && value.value !== "deep-equal") { + errors.push({ + message: 'Strict value should be "contain", "equal" or "deep-equal"', + range: convertRange(entry.value.range || map.range) + }); + continue; + } + container.containerMode = value.value; + continue; + } + if (key.value.startsWith("/")) { + const valueIsString = value instanceof yaml.Scalar && typeof value.value === "string"; + if (!valueIsString) { + errors.push({ + message: "Property value should be a string", + range: convertRange(entry.value.range || map.range) + }); + continue; + } + container.props = (_a2 = container.props) != null ? _a2 : {}; + container.props[key.value.slice(1)] = textValue(value.value); + continue; + } + const childNode = KeyParser.parse(key, parseOptions, errors); + if (!childNode) + continue; + const valueIsScalar = value instanceof yaml.Scalar; + if (valueIsScalar) { + const type = typeof value.value; + if (type !== "string" && type !== "number" && type !== "boolean") { + errors.push({ + message: "Node value should be a string or a sequence", + range: convertRange(entry.value.range || map.range) + }); + continue; + } + container.children.push({ + ...childNode, + children: [{ + kind: "text", + text: textValue(String(value.value)) + }] + }); + continue; + } + const valueIsSequence = value instanceof yaml.YAMLSeq; + if (valueIsSequence) { + container.children.push(childNode); + convertSeq(childNode, value); + continue; + } + errors.push({ + message: "Map values should be strings or sequences", + range: convertRange(entry.value.range || map.range) + }); + } + }; + const fragment = { kind: "role", role: "fragment" }; + yamlDoc.errors.forEach(addError); + if (errors.length) + return { errors, fragment }; + if (!(yamlDoc.contents instanceof yaml.YAMLSeq)) { + errors.push({ + message: 'Aria snapshot must be a YAML sequence, elements starting with " -"', + range: yamlDoc.contents ? convertRange(yamlDoc.contents.range) : [{ line: 0, col: 0 }, { line: 0, col: 0 }] + }); + } + if (errors.length) + return { errors, fragment }; + convertSeq(fragment, yamlDoc.contents); + if (errors.length) + return { errors, fragment: emptyFragment }; + if (((_a = fragment.children) == null ? void 0 : _a.length) === 1 && (!fragment.containerMode || fragment.containerMode === "contain")) + return { fragment: fragment.children[0], errors: [] }; + return { fragment, errors: [] }; +} +var emptyFragment = { kind: "role", role: "fragment" }; +function normalizeWhitespace(text) { + return text.replace(/[\u200b\u00ad]/g, "").replace(/[\r\n\s\t]+/g, " ").trim(); +} +function textValue(value) { + return { + raw: value, + normalized: normalizeWhitespace(value) + }; +} +var KeyParser = class _KeyParser { + static parse(text, options, errors) { + try { + return new _KeyParser(text.value)._parse(); + } catch (e) { + if (e instanceof ParserError) { + const message = options.prettyErrors === false ? e.message : e.message + ":\n\n" + text.value + "\n" + " ".repeat(e.pos) + "^\n"; + errors.push({ + message, + range: [options.lineCounter.linePos(text.range[0]), options.lineCounter.linePos(text.range[0] + e.pos)] + }); + return null; + } + throw e; + } + } + constructor(input) { + this._input = input; + this._pos = 0; + this._length = input.length; + } + _peek() { + return this._input[this._pos] || ""; + } + _next() { + if (this._pos < this._length) + return this._input[this._pos++]; + return null; + } + _eof() { + return this._pos >= this._length; + } + _isWhitespace() { + return !this._eof() && /\s/.test(this._peek()); + } + _skipWhitespace() { + while (this._isWhitespace()) + this._pos++; + } + _readIdentifier(type) { + if (this._eof()) + this._throwError(`Unexpected end of input when expecting ${type}`); + const start = this._pos; + while (!this._eof() && /[a-zA-Z]/.test(this._peek())) + this._pos++; + return this._input.slice(start, this._pos); + } + _readString() { + let result = ""; + let escaped = false; + while (!this._eof()) { + const ch = this._next(); + if (escaped) { + result += ch; + escaped = false; + } else if (ch === "\\") { + escaped = true; + } else if (ch === '"') { + return result; + } else { + result += ch; + } + } + this._throwError("Unterminated string"); + } + _throwError(message, offset = 0) { + throw new ParserError(message, offset || this._pos); + } + _readRegex() { + let result = ""; + let escaped = false; + let insideClass = false; + while (!this._eof()) { + const ch = this._next(); + if (escaped) { + result += ch; + escaped = false; + } else if (ch === "\\") { + escaped = true; + result += ch; + } else if (ch === "/" && !insideClass) { + return { pattern: result }; + } else if (ch === "[") { + insideClass = true; + result += ch; + } else if (ch === "]" && insideClass) { + result += ch; + insideClass = false; + } else { + result += ch; + } + } + this._throwError("Unterminated regex"); + } + _readStringOrRegex() { + const ch = this._peek(); + if (ch === '"') { + this._next(); + return normalizeWhitespace(this._readString()); + } + if (ch === "/") { + this._next(); + return this._readRegex(); + } + return null; + } + _readAttributes(result) { + let errorPos = this._pos; + while (true) { + this._skipWhitespace(); + if (this._peek() === "[") { + this._next(); + this._skipWhitespace(); + errorPos = this._pos; + const flagName = this._readIdentifier("attribute"); + this._skipWhitespace(); + let flagValue = ""; + if (this._peek() === "=") { + this._next(); + this._skipWhitespace(); + errorPos = this._pos; + while (this._peek() !== "]" && !this._isWhitespace() && !this._eof()) + flagValue += this._next(); + } + this._skipWhitespace(); + if (this._peek() !== "]") + this._throwError("Expected ]"); + this._next(); + this._applyAttribute(result, flagName, flagValue || "true", errorPos); + } else { + break; + } + } + } + _parse() { + this._skipWhitespace(); + const role = this._readIdentifier("role"); + this._skipWhitespace(); + const name = this._readStringOrRegex() || ""; + const result = { kind: "role", role, name }; + this._readAttributes(result); + this._skipWhitespace(); + if (!this._eof()) + this._throwError("Unexpected input"); + return result; + } + _applyAttribute(node, key, value, errorPos) { + if (key === "checked") { + this._assert(value === "true" || value === "false" || value === "mixed", 'Value of "checked" attribute must be a boolean or "mixed"', errorPos); + node.checked = value === "true" ? true : value === "false" ? false : "mixed"; + return; + } + if (key === "disabled") { + this._assert(value === "true" || value === "false", 'Value of "disabled" attribute must be a boolean', errorPos); + node.disabled = value === "true"; + return; + } + if (key === "expanded") { + this._assert(value === "true" || value === "false", 'Value of "expanded" attribute must be a boolean', errorPos); + node.expanded = value === "true"; + return; + } + if (key === "active") { + this._assert(value === "true" || value === "false", 'Value of "active" attribute must be a boolean', errorPos); + node.active = value === "true"; + return; + } + if (key === "invalid") { + this._assert(value === "true" || value === "false" || value === "grammar" || value === "spelling", 'Value of "invalid" attribute must be a boolean, "grammar" or "spelling"', errorPos); + node.invalid = value === "true" ? true : value === "false" ? false : value; + return; + } + if (key === "level") { + this._assert(!isNaN(Number(value)), 'Value of "level" attribute must be a number', errorPos); + node.level = Number(value); + return; + } + if (key === "pressed") { + this._assert(value === "true" || value === "false" || value === "mixed", 'Value of "pressed" attribute must be a boolean or "mixed"', errorPos); + node.pressed = value === "true" ? true : value === "false" ? false : "mixed"; + return; + } + if (key === "selected") { + this._assert(value === "true" || value === "false", 'Value of "selected" attribute must be a boolean', errorPos); + node.selected = value === "true"; + return; + } + this._assert(false, `Unsupported attribute [${key}]`, errorPos); + } + _assert(value, message, valuePos) { + if (!value) + this._throwError(message || "Assertion error", valuePos); + } +}; +var ParserError = class extends Error { + constructor(message, pos) { + super(message); + this.pos = pos; + } +}; +function findNewNode(from, to) { + var _a, _b; + function fillMap(root, map, position) { + let size = 1; + let childPosition = position + size; + for (const child of root.children || []) { + if (typeof child === "string") { + size++; + childPosition++; + } else { + size += fillMap(child, map, childPosition); + childPosition += size; + } + } + if (!["none", "presentation", "fragment", "iframe", "generic"].includes(root.role) && root.name) { + let byRole = map.get(root.role); + if (!byRole) { + byRole = /* @__PURE__ */ new Map(); + map.set(root.role, byRole); + } + const existing = byRole.get(root.name); + const sizeAndPosition = size * 100 - position; + if (!existing || existing.sizeAndPosition < sizeAndPosition) + byRole.set(root.name, { node: root, sizeAndPosition }); + } + return size; + } + const fromMap = /* @__PURE__ */ new Map(); + if (from) + fillMap(from, fromMap, 0); + const toMap = /* @__PURE__ */ new Map(); + fillMap(to, toMap, 0); + const result = []; + for (const [role, byRole] of toMap) { + for (const [name, byName] of byRole) { + const inFrom = (_a = fromMap.get(role)) == null ? void 0 : _a.get(name); + if (!inFrom) + result.push(byName); + } + } + result.sort((a, b) => b.sizeAndPosition - a.sizeAndPosition); + return (_b = result[0]) == null ? void 0 : _b.node; +} + +// packages/isomorphic/cssTokenizer.ts +var between = function(num, first, last) { + return num >= first && num <= last; +}; +function digit(code) { + return between(code, 48, 57); +} +function hexdigit(code) { + return digit(code) || between(code, 65, 70) || between(code, 97, 102); +} +function uppercaseletter(code) { + return between(code, 65, 90); +} +function lowercaseletter(code) { + return between(code, 97, 122); +} +function letter(code) { + return uppercaseletter(code) || lowercaseletter(code); +} +function nonascii(code) { + return code >= 128; +} +function namestartchar(code) { + return letter(code) || nonascii(code) || code === 95; +} +function namechar(code) { + return namestartchar(code) || digit(code) || code === 45; +} +function nonprintable(code) { + return between(code, 0, 8) || code === 11 || between(code, 14, 31) || code === 127; +} +function newline(code) { + return code === 10; +} +function whitespace(code) { + return newline(code) || code === 9 || code === 32; +} +var maximumallowedcodepoint = 1114111; +var InvalidCharacterError = class extends Error { + constructor(message) { + super(message); + this.name = "InvalidCharacterError"; + } +}; +function preprocess(str) { + const codepoints = []; + for (let i = 0; i < str.length; i++) { + let code = str.charCodeAt(i); + if (code === 13 && str.charCodeAt(i + 1) === 10) { + code = 10; + i++; + } + if (code === 13 || code === 12) + code = 10; + if (code === 0) + code = 65533; + if (between(code, 55296, 56319) && between(str.charCodeAt(i + 1), 56320, 57343)) { + const lead = code - 55296; + const trail = str.charCodeAt(i + 1) - 56320; + code = Math.pow(2, 16) + lead * Math.pow(2, 10) + trail; + i++; + } + codepoints.push(code); + } + return codepoints; +} +function stringFromCode(code) { + if (code <= 65535) + return String.fromCharCode(code); + code -= Math.pow(2, 16); + const lead = Math.floor(code / Math.pow(2, 10)) + 55296; + const trail = code % Math.pow(2, 10) + 56320; + return String.fromCharCode(lead) + String.fromCharCode(trail); +} +function tokenize(str1) { + const str = preprocess(str1); + let i = -1; + const tokens = []; + let code; + let line = 0; + let column = 0; + let lastLineLength = 0; + const incrLineno = function() { + line += 1; + lastLineLength = column; + column = 0; + }; + const locStart = { line, column }; + const codepoint = function(i2) { + if (i2 >= str.length) + return -1; + return str[i2]; + }; + const next = function(num) { + if (num === void 0) + num = 1; + if (num > 3) + throw "Spec Error: no more than three codepoints of lookahead."; + return codepoint(i + num); + }; + const consume = function(num) { + if (num === void 0) + num = 1; + i += num; + code = codepoint(i); + if (newline(code)) + incrLineno(); + else + column += num; + return true; + }; + const reconsume = function() { + i -= 1; + if (newline(code)) { + line -= 1; + column = lastLineLength; + } else { + column -= 1; + } + locStart.line = line; + locStart.column = column; + return true; + }; + const eof = function(codepoint2) { + if (codepoint2 === void 0) + codepoint2 = code; + return codepoint2 === -1; + }; + const donothing = function() { + }; + const parseerror = function() { + }; + const consumeAToken = function() { + consumeComments(); + consume(); + if (whitespace(code)) { + while (whitespace(next())) + consume(); + return new WhitespaceToken(); + } else if (code === 34) { + return consumeAStringToken(); + } else if (code === 35) { + if (namechar(next()) || areAValidEscape(next(1), next(2))) { + const token = new HashToken(""); + if (wouldStartAnIdentifier(next(1), next(2), next(3))) + token.type = "id"; + token.value = consumeAName(); + return token; + } else { + return new DelimToken(code); + } + } else if (code === 36) { + if (next() === 61) { + consume(); + return new SuffixMatchToken(); + } else { + return new DelimToken(code); + } + } else if (code === 39) { + return consumeAStringToken(); + } else if (code === 40) { + return new OpenParenToken(); + } else if (code === 41) { + return new CloseParenToken(); + } else if (code === 42) { + if (next() === 61) { + consume(); + return new SubstringMatchToken(); + } else { + return new DelimToken(code); + } + } else if (code === 43) { + if (startsWithANumber()) { + reconsume(); + return consumeANumericToken(); + } else { + return new DelimToken(code); + } + } else if (code === 44) { + return new CommaToken(); + } else if (code === 45) { + if (startsWithANumber()) { + reconsume(); + return consumeANumericToken(); + } else if (next(1) === 45 && next(2) === 62) { + consume(2); + return new CDCToken(); + } else if (startsWithAnIdentifier()) { + reconsume(); + return consumeAnIdentlikeToken(); + } else { + return new DelimToken(code); + } + } else if (code === 46) { + if (startsWithANumber()) { + reconsume(); + return consumeANumericToken(); + } else { + return new DelimToken(code); + } + } else if (code === 58) { + return new ColonToken(); + } else if (code === 59) { + return new SemicolonToken(); + } else if (code === 60) { + if (next(1) === 33 && next(2) === 45 && next(3) === 45) { + consume(3); + return new CDOToken(); + } else { + return new DelimToken(code); + } + } else if (code === 64) { + if (wouldStartAnIdentifier(next(1), next(2), next(3))) + return new AtKeywordToken(consumeAName()); + else + return new DelimToken(code); + } else if (code === 91) { + return new OpenSquareToken(); + } else if (code === 92) { + if (startsWithAValidEscape()) { + reconsume(); + return consumeAnIdentlikeToken(); + } else { + parseerror(); + return new DelimToken(code); + } + } else if (code === 93) { + return new CloseSquareToken(); + } else if (code === 94) { + if (next() === 61) { + consume(); + return new PrefixMatchToken(); + } else { + return new DelimToken(code); + } + } else if (code === 123) { + return new OpenCurlyToken(); + } else if (code === 124) { + if (next() === 61) { + consume(); + return new DashMatchToken(); + } else if (next() === 124) { + consume(); + return new ColumnToken(); + } else { + return new DelimToken(code); + } + } else if (code === 125) { + return new CloseCurlyToken(); + } else if (code === 126) { + if (next() === 61) { + consume(); + return new IncludeMatchToken(); + } else { + return new DelimToken(code); + } + } else if (digit(code)) { + reconsume(); + return consumeANumericToken(); + } else if (namestartchar(code)) { + reconsume(); + return consumeAnIdentlikeToken(); + } else if (eof()) { + return new EOFToken(); + } else { + return new DelimToken(code); + } + }; + const consumeComments = function() { + while (next(1) === 47 && next(2) === 42) { + consume(2); + while (true) { + consume(); + if (code === 42 && next() === 47) { + consume(); + break; + } else if (eof()) { + parseerror(); + return; + } + } + } + }; + const consumeANumericToken = function() { + const num = consumeANumber(); + if (wouldStartAnIdentifier(next(1), next(2), next(3))) { + const token = new DimensionToken(); + token.value = num.value; + token.repr = num.repr; + token.type = num.type; + token.unit = consumeAName(); + return token; + } else if (next() === 37) { + consume(); + const token = new PercentageToken(); + token.value = num.value; + token.repr = num.repr; + return token; + } else { + const token = new NumberToken(); + token.value = num.value; + token.repr = num.repr; + token.type = num.type; + return token; + } + }; + const consumeAnIdentlikeToken = function() { + const str2 = consumeAName(); + if (str2.toLowerCase() === "url" && next() === 40) { + consume(); + while (whitespace(next(1)) && whitespace(next(2))) + consume(); + if (next() === 34 || next() === 39) + return new FunctionToken(str2); + else if (whitespace(next()) && (next(2) === 34 || next(2) === 39)) + return new FunctionToken(str2); + else + return consumeAURLToken(); + } else if (next() === 40) { + consume(); + return new FunctionToken(str2); + } else { + return new IdentToken(str2); + } + }; + const consumeAStringToken = function(endingCodePoint) { + if (endingCodePoint === void 0) + endingCodePoint = code; + let string = ""; + while (consume()) { + if (code === endingCodePoint || eof()) { + return new StringToken(string); + } else if (newline(code)) { + parseerror(); + reconsume(); + return new BadStringToken(); + } else if (code === 92) { + if (eof(next())) + donothing(); + else if (newline(next())) + consume(); + else + string += stringFromCode(consumeEscape()); + } else { + string += stringFromCode(code); + } + } + throw new Error("Internal error"); + }; + const consumeAURLToken = function() { + const token = new URLToken(""); + while (whitespace(next())) + consume(); + if (eof(next())) + return token; + while (consume()) { + if (code === 41 || eof()) { + return token; + } else if (whitespace(code)) { + while (whitespace(next())) + consume(); + if (next() === 41 || eof(next())) { + consume(); + return token; + } else { + consumeTheRemnantsOfABadURL(); + return new BadURLToken(); + } + } else if (code === 34 || code === 39 || code === 40 || nonprintable(code)) { + parseerror(); + consumeTheRemnantsOfABadURL(); + return new BadURLToken(); + } else if (code === 92) { + if (startsWithAValidEscape()) { + token.value += stringFromCode(consumeEscape()); + } else { + parseerror(); + consumeTheRemnantsOfABadURL(); + return new BadURLToken(); + } + } else { + token.value += stringFromCode(code); + } + } + throw new Error("Internal error"); + }; + const consumeEscape = function() { + consume(); + if (hexdigit(code)) { + const digits = [code]; + for (let total = 0; total < 5; total++) { + if (hexdigit(next())) { + consume(); + digits.push(code); + } else { + break; + } + } + if (whitespace(next())) + consume(); + let value = parseInt(digits.map(function(x) { + return String.fromCharCode(x); + }).join(""), 16); + if (value > maximumallowedcodepoint) + value = 65533; + return value; + } else if (eof()) { + return 65533; + } else { + return code; + } + }; + const areAValidEscape = function(c1, c2) { + if (c1 !== 92) + return false; + if (newline(c2)) + return false; + return true; + }; + const startsWithAValidEscape = function() { + return areAValidEscape(code, next()); + }; + const wouldStartAnIdentifier = function(c1, c2, c3) { + if (c1 === 45) + return namestartchar(c2) || c2 === 45 || areAValidEscape(c2, c3); + else if (namestartchar(c1)) + return true; + else if (c1 === 92) + return areAValidEscape(c1, c2); + else + return false; + }; + const startsWithAnIdentifier = function() { + return wouldStartAnIdentifier(code, next(1), next(2)); + }; + const wouldStartANumber = function(c1, c2, c3) { + if (c1 === 43 || c1 === 45) { + if (digit(c2)) + return true; + if (c2 === 46 && digit(c3)) + return true; + return false; + } else if (c1 === 46) { + if (digit(c2)) + return true; + return false; + } else if (digit(c1)) { + return true; + } else { + return false; + } + }; + const startsWithANumber = function() { + return wouldStartANumber(code, next(1), next(2)); + }; + const consumeAName = function() { + let result = ""; + while (consume()) { + if (namechar(code)) { + result += stringFromCode(code); + } else if (startsWithAValidEscape()) { + result += stringFromCode(consumeEscape()); + } else { + reconsume(); + return result; + } + } + throw new Error("Internal parse error"); + }; + const consumeANumber = function() { + let repr = ""; + let type = "integer"; + if (next() === 43 || next() === 45) { + consume(); + repr += stringFromCode(code); + } + while (digit(next())) { + consume(); + repr += stringFromCode(code); + } + if (next(1) === 46 && digit(next(2))) { + consume(); + repr += stringFromCode(code); + consume(); + repr += stringFromCode(code); + type = "number"; + while (digit(next())) { + consume(); + repr += stringFromCode(code); + } + } + const c1 = next(1); + const c2 = next(2); + const c3 = next(3); + if ((c1 === 69 || c1 === 101) && digit(c2)) { + consume(); + repr += stringFromCode(code); + consume(); + repr += stringFromCode(code); + type = "number"; + while (digit(next())) { + consume(); + repr += stringFromCode(code); + } + } else if ((c1 === 69 || c1 === 101) && (c2 === 43 || c2 === 45) && digit(c3)) { + consume(); + repr += stringFromCode(code); + consume(); + repr += stringFromCode(code); + consume(); + repr += stringFromCode(code); + type = "number"; + while (digit(next())) { + consume(); + repr += stringFromCode(code); + } + } + const value = convertAStringToANumber(repr); + return { type, value, repr }; + }; + const convertAStringToANumber = function(string) { + return +string; + }; + const consumeTheRemnantsOfABadURL = function() { + while (consume()) { + if (code === 41 || eof()) { + return; + } else if (startsWithAValidEscape()) { + consumeEscape(); + donothing(); + } else { + donothing(); + } + } + }; + let iterationCount = 0; + while (!eof(next())) { + tokens.push(consumeAToken()); + iterationCount++; + if (iterationCount > str.length * 2) + throw new Error("I'm infinite-looping!"); + } + return tokens; +} +var CSSParserToken = class { + constructor() { + this.tokenType = ""; + } + toJSON() { + return { token: this.tokenType }; + } + toString() { + return this.tokenType; + } + toSource() { + return "" + this; + } +}; +var BadStringToken = class extends CSSParserToken { + constructor() { + super(...arguments); + this.tokenType = "BADSTRING"; + } +}; +var BadURLToken = class extends CSSParserToken { + constructor() { + super(...arguments); + this.tokenType = "BADURL"; + } +}; +var WhitespaceToken = class extends CSSParserToken { + constructor() { + super(...arguments); + this.tokenType = "WHITESPACE"; + } + toString() { + return "WS"; + } + toSource() { + return " "; + } +}; +var CDOToken = class extends CSSParserToken { + constructor() { + super(...arguments); + this.tokenType = "CDO"; + } + toSource() { + return ""; + } +}; +var ColonToken = class extends CSSParserToken { + constructor() { + super(...arguments); + this.tokenType = ":"; + } +}; +var SemicolonToken = class extends CSSParserToken { + constructor() { + super(...arguments); + this.tokenType = ";"; + } +}; +var CommaToken = class extends CSSParserToken { + constructor() { + super(...arguments); + this.tokenType = ","; + } +}; +var GroupingToken = class extends CSSParserToken { + constructor() { + super(...arguments); + this.value = ""; + this.mirror = ""; + } +}; +var OpenCurlyToken = class extends GroupingToken { + constructor() { + super(); + this.tokenType = "{"; + this.value = "{"; + this.mirror = "}"; + } +}; +var CloseCurlyToken = class extends GroupingToken { + constructor() { + super(); + this.tokenType = "}"; + this.value = "}"; + this.mirror = "{"; + } +}; +var OpenSquareToken = class extends GroupingToken { + constructor() { + super(); + this.tokenType = "["; + this.value = "["; + this.mirror = "]"; + } +}; +var CloseSquareToken = class extends GroupingToken { + constructor() { + super(); + this.tokenType = "]"; + this.value = "]"; + this.mirror = "["; + } +}; +var OpenParenToken = class extends GroupingToken { + constructor() { + super(); + this.tokenType = "("; + this.value = "("; + this.mirror = ")"; + } +}; +var CloseParenToken = class extends GroupingToken { + constructor() { + super(); + this.tokenType = ")"; + this.value = ")"; + this.mirror = "("; + } +}; +var IncludeMatchToken = class extends CSSParserToken { + constructor() { + super(...arguments); + this.tokenType = "~="; + } +}; +var DashMatchToken = class extends CSSParserToken { + constructor() { + super(...arguments); + this.tokenType = "|="; + } +}; +var PrefixMatchToken = class extends CSSParserToken { + constructor() { + super(...arguments); + this.tokenType = "^="; + } +}; +var SuffixMatchToken = class extends CSSParserToken { + constructor() { + super(...arguments); + this.tokenType = "$="; + } +}; +var SubstringMatchToken = class extends CSSParserToken { + constructor() { + super(...arguments); + this.tokenType = "*="; + } +}; +var ColumnToken = class extends CSSParserToken { + constructor() { + super(...arguments); + this.tokenType = "||"; + } +}; +var EOFToken = class extends CSSParserToken { + constructor() { + super(...arguments); + this.tokenType = "EOF"; + } + toSource() { + return ""; + } +}; +var DelimToken = class extends CSSParserToken { + constructor(code) { + super(); + this.tokenType = "DELIM"; + this.value = ""; + this.value = stringFromCode(code); + } + toString() { + return "DELIM(" + this.value + ")"; + } + toJSON() { + const json = this.constructor.prototype.constructor.prototype.toJSON.call(this); + json.value = this.value; + return json; + } + toSource() { + if (this.value === "\\") + return "\\\n"; + else + return this.value; + } +}; +var StringValuedToken = class extends CSSParserToken { + constructor() { + super(...arguments); + this.value = ""; + } + ASCIIMatch(str) { + return this.value.toLowerCase() === str.toLowerCase(); + } + toJSON() { + const json = this.constructor.prototype.constructor.prototype.toJSON.call(this); + json.value = this.value; + return json; + } +}; +var IdentToken = class extends StringValuedToken { + constructor(val) { + super(); + this.tokenType = "IDENT"; + this.value = val; + } + toString() { + return "IDENT(" + this.value + ")"; + } + toSource() { + return escapeIdent(this.value); + } +}; +var FunctionToken = class extends StringValuedToken { + constructor(val) { + super(); + this.tokenType = "FUNCTION"; + this.value = val; + this.mirror = ")"; + } + toString() { + return "FUNCTION(" + this.value + ")"; + } + toSource() { + return escapeIdent(this.value) + "("; + } +}; +var AtKeywordToken = class extends StringValuedToken { + constructor(val) { + super(); + this.tokenType = "AT-KEYWORD"; + this.value = val; + } + toString() { + return "AT(" + this.value + ")"; + } + toSource() { + return "@" + escapeIdent(this.value); + } +}; +var HashToken = class extends StringValuedToken { + constructor(val) { + super(); + this.tokenType = "HASH"; + this.value = val; + this.type = "unrestricted"; + } + toString() { + return "HASH(" + this.value + ")"; + } + toJSON() { + const json = this.constructor.prototype.constructor.prototype.toJSON.call(this); + json.value = this.value; + json.type = this.type; + return json; + } + toSource() { + if (this.type === "id") + return "#" + escapeIdent(this.value); + else + return "#" + escapeHash(this.value); + } +}; +var StringToken = class extends StringValuedToken { + constructor(val) { + super(); + this.tokenType = "STRING"; + this.value = val; + } + toString() { + return '"' + escapeString(this.value) + '"'; + } +}; +var URLToken = class extends StringValuedToken { + constructor(val) { + super(); + this.tokenType = "URL"; + this.value = val; + } + toString() { + return "URL(" + this.value + ")"; + } + toSource() { + return 'url("' + escapeString(this.value) + '")'; + } +}; +var NumberToken = class extends CSSParserToken { + constructor() { + super(); + this.tokenType = "NUMBER"; + this.type = "integer"; + this.repr = ""; + } + toString() { + if (this.type === "integer") + return "INT(" + this.value + ")"; + return "NUMBER(" + this.value + ")"; + } + toJSON() { + const json = super.toJSON(); + json.value = this.value; + json.type = this.type; + json.repr = this.repr; + return json; + } + toSource() { + return this.repr; + } +}; +var PercentageToken = class extends CSSParserToken { + constructor() { + super(); + this.tokenType = "PERCENTAGE"; + this.repr = ""; + } + toString() { + return "PERCENTAGE(" + this.value + ")"; + } + toJSON() { + const json = this.constructor.prototype.constructor.prototype.toJSON.call(this); + json.value = this.value; + json.repr = this.repr; + return json; + } + toSource() { + return this.repr + "%"; + } +}; +var DimensionToken = class extends CSSParserToken { + constructor() { + super(); + this.tokenType = "DIMENSION"; + this.type = "integer"; + this.repr = ""; + this.unit = ""; + } + toString() { + return "DIM(" + this.value + "," + this.unit + ")"; + } + toJSON() { + const json = this.constructor.prototype.constructor.prototype.toJSON.call(this); + json.value = this.value; + json.type = this.type; + json.repr = this.repr; + json.unit = this.unit; + return json; + } + toSource() { + const source = this.repr; + let unit = escapeIdent(this.unit); + if (unit[0].toLowerCase() === "e" && (unit[1] === "-" || between(unit.charCodeAt(1), 48, 57))) { + unit = "\\65 " + unit.slice(1, unit.length); + } + return source + unit; + } +}; +function escapeIdent(string) { + string = "" + string; + let result = ""; + const firstcode = string.charCodeAt(0); + for (let i = 0; i < string.length; i++) { + const code = string.charCodeAt(i); + if (code === 0) + throw new InvalidCharacterError("Invalid character: the input contains U+0000."); + if (between(code, 1, 31) || code === 127 || i === 0 && between(code, 48, 57) || i === 1 && between(code, 48, 57) && firstcode === 45) + result += "\\" + code.toString(16) + " "; + else if (code >= 128 || code === 45 || code === 95 || between(code, 48, 57) || between(code, 65, 90) || between(code, 97, 122)) + result += string[i]; + else + result += "\\" + string[i]; + } + return result; +} +function escapeHash(string) { + string = "" + string; + let result = ""; + for (let i = 0; i < string.length; i++) { + const code = string.charCodeAt(i); + if (code === 0) + throw new InvalidCharacterError("Invalid character: the input contains U+0000."); + if (code >= 128 || code === 45 || code === 95 || between(code, 48, 57) || between(code, 65, 90) || between(code, 97, 122)) + result += string[i]; + else + result += "\\" + code.toString(16) + " "; + } + return result; +} +function escapeString(string) { + string = "" + string; + let result = ""; + for (let i = 0; i < string.length; i++) { + const code = string.charCodeAt(i); + if (code === 0) + throw new InvalidCharacterError("Invalid character: the input contains U+0000."); + if (between(code, 1, 31) || code === 127) + result += "\\" + code.toString(16) + " "; + else if (code === 34 || code === 92) + result += "\\" + string[i]; + else + result += string[i]; + } + return result; +} + +// packages/isomorphic/cssParser.ts +var InvalidSelectorError = class extends Error { +}; +function parseCSS(selector, customNames) { + let tokens; + try { + tokens = tokenize(selector); + if (!(tokens[tokens.length - 1] instanceof EOFToken)) + tokens.push(new EOFToken()); + } catch (e) { + const newMessage = e.message + ` while parsing css selector "${selector}". Did you mean to CSS.escape it?`; + const index = (e.stack || "").indexOf(e.message); + if (index !== -1) + e.stack = e.stack.substring(0, index) + newMessage + e.stack.substring(index + e.message.length); + e.message = newMessage; + throw e; + } + const unsupportedToken = tokens.find((token) => { + return token instanceof AtKeywordToken || token instanceof BadStringToken || token instanceof BadURLToken || token instanceof ColumnToken || token instanceof CDOToken || token instanceof CDCToken || token instanceof SemicolonToken || // TODO: Consider using these for something, e.g. to escape complex strings. + // For example :xpath{ (//div/bar[@attr="foo"])[2]/baz } + // Or this way :xpath( {complex-xpath-goes-here("hello")} ) + token instanceof OpenCurlyToken || token instanceof CloseCurlyToken || // TODO: Consider treating these as strings? + token instanceof URLToken || token instanceof PercentageToken; + }); + if (unsupportedToken) + throw new InvalidSelectorError(`Unsupported token "${unsupportedToken.toSource()}" while parsing css selector "${selector}". Did you mean to CSS.escape it?`); + let pos = 0; + const names = /* @__PURE__ */ new Set(); + function unexpected() { + return new InvalidSelectorError(`Unexpected token "${tokens[pos].toSource()}" while parsing css selector "${selector}". Did you mean to CSS.escape it?`); + } + function skipWhitespace() { + while (tokens[pos] instanceof WhitespaceToken) + pos++; + } + function isIdent(p = pos) { + return tokens[p] instanceof IdentToken; + } + function isString(p = pos) { + return tokens[p] instanceof StringToken; + } + function isNumber(p = pos) { + return tokens[p] instanceof NumberToken; + } + function isComma(p = pos) { + return tokens[p] instanceof CommaToken; + } + function isOpenParen(p = pos) { + return tokens[p] instanceof OpenParenToken; + } + function isCloseParen(p = pos) { + return tokens[p] instanceof CloseParenToken; + } + function isFunction(p = pos) { + return tokens[p] instanceof FunctionToken; + } + function isStar(p = pos) { + return tokens[p] instanceof DelimToken && tokens[p].value === "*"; + } + function isEOF(p = pos) { + return tokens[p] instanceof EOFToken; + } + function isClauseCombinator(p = pos) { + return tokens[p] instanceof DelimToken && [">", "+", "~"].includes(tokens[p].value); + } + function isSelectorClauseEnd(p = pos) { + return isComma(p) || isCloseParen(p) || isEOF(p) || isClauseCombinator(p) || tokens[p] instanceof WhitespaceToken; + } + function consumeFunctionArguments() { + const result2 = [consumeArgument()]; + while (true) { + skipWhitespace(); + if (!isComma()) + break; + pos++; + result2.push(consumeArgument()); + } + return result2; + } + function consumeArgument() { + skipWhitespace(); + if (isNumber()) + return tokens[pos++].value; + if (isString()) + return tokens[pos++].value; + return consumeComplexSelector(); + } + function consumeComplexSelector() { + const result2 = { simples: [] }; + skipWhitespace(); + if (isClauseCombinator()) { + result2.simples.push({ selector: { functions: [{ name: "scope", args: [] }] }, combinator: "" }); + } else { + result2.simples.push({ selector: consumeSimpleSelector(), combinator: "" }); + } + while (true) { + skipWhitespace(); + if (isClauseCombinator()) { + result2.simples[result2.simples.length - 1].combinator = tokens[pos++].value; + skipWhitespace(); + } else if (isSelectorClauseEnd()) { + break; + } + result2.simples.push({ combinator: "", selector: consumeSimpleSelector() }); + } + return result2; + } + function consumeSimpleSelector() { + let rawCSSString = ""; + const functions = []; + while (!isSelectorClauseEnd()) { + if (isIdent() || isStar()) { + rawCSSString += tokens[pos++].toSource(); + } else if (tokens[pos] instanceof HashToken) { + rawCSSString += tokens[pos++].toSource(); + } else if (tokens[pos] instanceof DelimToken && tokens[pos].value === ".") { + pos++; + if (isIdent()) + rawCSSString += "." + tokens[pos++].toSource(); + else + throw unexpected(); + } else if (tokens[pos] instanceof ColonToken) { + pos++; + if (isIdent()) { + if (!customNames.has(tokens[pos].value.toLowerCase())) { + rawCSSString += ":" + tokens[pos++].toSource(); + } else { + const name = tokens[pos++].value.toLowerCase(); + functions.push({ name, args: [] }); + names.add(name); + } + } else if (isFunction()) { + const name = tokens[pos++].value.toLowerCase(); + if (!customNames.has(name)) { + rawCSSString += `:${name}(${consumeBuiltinFunctionArguments()})`; + } else { + functions.push({ name, args: consumeFunctionArguments() }); + names.add(name); + } + skipWhitespace(); + if (!isCloseParen()) + throw unexpected(); + pos++; + } else { + throw unexpected(); + } + } else if (tokens[pos] instanceof OpenSquareToken) { + rawCSSString += "["; + pos++; + while (!(tokens[pos] instanceof CloseSquareToken) && !isEOF()) + rawCSSString += tokens[pos++].toSource(); + if (!(tokens[pos] instanceof CloseSquareToken)) + throw unexpected(); + rawCSSString += "]"; + pos++; + } else { + throw unexpected(); + } + } + if (!rawCSSString && !functions.length) + throw unexpected(); + return { css: rawCSSString || void 0, functions }; + } + function consumeBuiltinFunctionArguments() { + let s = ""; + let balance = 1; + while (!isEOF()) { + if (isOpenParen() || isFunction()) + balance++; + if (isCloseParen()) + balance--; + if (!balance) + break; + s += tokens[pos++].toSource(); + } + return s; + } + const result = consumeFunctionArguments(); + if (!isEOF()) + throw unexpected(); + if (result.some((arg) => typeof arg !== "object" || !("simples" in arg))) + throw new InvalidSelectorError(`Error while parsing css selector "${selector}". Did you mean to CSS.escape it?`); + return { selector: result, names: Array.from(names) }; +} + +// packages/isomorphic/selectorParser.ts +var kNestedSelectorNames = /* @__PURE__ */ new Set(["internal:has", "internal:has-not", "internal:and", "internal:or", "internal:chain", "left-of", "right-of", "above", "below", "near"]); +var kNestedSelectorNamesWithDistance = /* @__PURE__ */ new Set(["left-of", "right-of", "above", "below", "near"]); +var customCSSNames = /* @__PURE__ */ new Set(["not", "is", "where", "has", "scope", "light", "visible", "text", "text-matches", "text-is", "has-text", "above", "below", "right-of", "left-of", "near", "nth-match"]); +function parseSelector(selector) { + const parsedStrings = parseSelectorString(selector); + const parts = []; + for (const part of parsedStrings.parts) { + if (part.name === "css" || part.name === "css:light") { + if (part.name === "css:light") + part.body = ":light(" + part.body + ")"; + const parsedCSS = parseCSS(part.body, customCSSNames); + parts.push({ + name: "css", + body: parsedCSS.selector, + source: part.body + }); + continue; + } + if (kNestedSelectorNames.has(part.name)) { + let innerSelector; + let distance; + try { + const unescaped = JSON.parse("[" + part.body + "]"); + if (!Array.isArray(unescaped) || unescaped.length < 1 || unescaped.length > 2 || typeof unescaped[0] !== "string") + throw new InvalidSelectorError(`Malformed selector: ${part.name}=` + part.body); + innerSelector = unescaped[0]; + if (unescaped.length === 2) { + if (typeof unescaped[1] !== "number" || !kNestedSelectorNamesWithDistance.has(part.name)) + throw new InvalidSelectorError(`Malformed selector: ${part.name}=` + part.body); + distance = unescaped[1]; + } + } catch (e) { + throw new InvalidSelectorError(`Malformed selector: ${part.name}=` + part.body); + } + const nested = { name: part.name, source: part.body, body: { parsed: parseSelector(innerSelector), distance } }; + const lastFrame = [...nested.body.parsed.parts].reverse().find((part2) => part2.name === "internal:control" && part2.body === "enter-frame"); + const lastFrameIndex = lastFrame ? nested.body.parsed.parts.indexOf(lastFrame) : -1; + if (lastFrameIndex !== -1 && selectorPartsEqual(nested.body.parsed.parts.slice(0, lastFrameIndex + 1), parts.slice(0, lastFrameIndex + 1))) + nested.body.parsed.parts.splice(0, lastFrameIndex + 1); + parts.push(nested); + continue; + } + parts.push({ ...part, source: part.body }); + } + if (kNestedSelectorNames.has(parts[0].name)) + throw new InvalidSelectorError(`"${parts[0].name}" selector cannot be first`); + return { + capture: parsedStrings.capture, + parts + }; +} +function selectorPartsEqual(list1, list2) { + return stringifySelector({ parts: list1 }) === stringifySelector({ parts: list2 }); +} +function stringifySelector(selector, forceEngineName) { + if (typeof selector === "string") + return selector; + return selector.parts.map((p, i) => { + let includeEngine = true; + if (!forceEngineName && i !== selector.capture) { + if (p.name === "css") + includeEngine = false; + else if (p.name === "xpath" && p.source.startsWith("//") || p.source.startsWith("..")) + includeEngine = false; + } + const prefix = includeEngine ? p.name + "=" : ""; + return `${i === selector.capture ? "*" : ""}${prefix}${p.source}`; + }).join(" >> "); +} +function visitAllSelectorParts(selector, visitor) { + const visit = (selector2, nested) => { + for (const part of selector2.parts) { + visitor(part, nested); + if (kNestedSelectorNames.has(part.name)) + visit(part.body.parsed, true); + } + }; + visit(selector, false); +} +function parseSelectorString(selector) { + let index = 0; + let quote; + let start = 0; + const result = { parts: [] }; + const append = () => { + const part = selector.substring(start, index).trim(); + const eqIndex = part.indexOf("="); + let name; + let body; + if (eqIndex !== -1 && part.substring(0, eqIndex).trim().match(/^[a-zA-Z_0-9-+:*]+$/)) { + name = part.substring(0, eqIndex).trim(); + body = part.substring(eqIndex + 1); + } else if (part.length > 1 && part[0] === '"' && part[part.length - 1] === '"') { + name = "text"; + body = part; + } else if (part.length > 1 && part[0] === "'" && part[part.length - 1] === "'") { + name = "text"; + body = part; + } else if (/^\(*\/\//.test(part) || part.startsWith("..")) { + name = "xpath"; + body = part; + } else { + name = "css"; + body = part; + } + let capture = false; + if (name[0] === "*") { + capture = true; + name = name.substring(1); + } + result.parts.push({ name, body }); + if (capture) { + if (result.capture !== void 0) + throw new InvalidSelectorError(`Only one of the selectors can capture using * modifier`); + result.capture = result.parts.length - 1; + } + }; + if (!selector.includes(">>")) { + index = selector.length; + append(); + return result; + } + const shouldIgnoreTextSelectorQuote = () => { + const prefix = selector.substring(start, index); + const match = prefix.match(/^\s*text\s*=(.*)$/); + return !!match && !!match[1]; + }; + while (index < selector.length) { + const c = selector[index]; + if (c === "\\" && index + 1 < selector.length) { + index += 2; + } else if (c === quote) { + quote = void 0; + index++; + } else if (!quote && (c === '"' || c === "'" || c === "`") && !shouldIgnoreTextSelectorQuote()) { + quote = c; + index++; + } else if (!quote && c === ">" && selector[index + 1] === ">") { + append(); + index += 2; + start = index; + } else { + index++; + } + } + append(); + return result; +} +function parseAttributeSelector(selector, allowUnquotedStrings) { + let wp = 0; + let EOL = selector.length === 0; + const next = () => selector[wp] || ""; + const eat1 = () => { + const result2 = next(); + ++wp; + EOL = wp >= selector.length; + return result2; + }; + const syntaxError = (stage) => { + if (EOL) + throw new InvalidSelectorError(`Unexpected end of selector while parsing selector \`${selector}\``); + throw new InvalidSelectorError(`Error while parsing selector \`${selector}\` - unexpected symbol "${next()}" at position ${wp}` + (stage ? " during " + stage : "")); + }; + function skipSpaces() { + while (!EOL && /\s/.test(next())) + eat1(); + } + function isCSSNameChar(char) { + return char >= "\x80" || char >= "0" && char <= "9" || char >= "A" && char <= "Z" || char >= "a" && char <= "z" || char >= "0" && char <= "9" || char === "_" || char === "-"; + } + function readIdentifier() { + let result2 = ""; + skipSpaces(); + while (!EOL && isCSSNameChar(next())) + result2 += eat1(); + return result2; + } + function readQuotedString(quote) { + let result2 = eat1(); + if (result2 !== quote) + syntaxError("parsing quoted string"); + while (!EOL && next() !== quote) { + if (next() === "\\") + eat1(); + result2 += eat1(); + } + if (next() !== quote) + syntaxError("parsing quoted string"); + result2 += eat1(); + return result2; + } + function readRegularExpression() { + if (eat1() !== "/") + syntaxError("parsing regular expression"); + let source = ""; + let inClass = false; + while (!EOL) { + if (next() === "\\") { + source += eat1(); + if (EOL) + syntaxError("parsing regular expression"); + } else if (inClass && next() === "]") { + inClass = false; + } else if (!inClass && next() === "[") { + inClass = true; + } else if (!inClass && next() === "/") { + break; + } + source += eat1(); + } + if (eat1() !== "/") + syntaxError("parsing regular expression"); + let flags = ""; + while (!EOL && next().match(/[dgimsuy]/)) + flags += eat1(); + try { + return new RegExp(source, flags); + } catch (e) { + throw new InvalidSelectorError(`Error while parsing selector \`${selector}\`: ${e.message}`); + } + } + function readAttributeToken() { + let token = ""; + skipSpaces(); + if (next() === `'` || next() === `"`) + token = readQuotedString(next()).slice(1, -1); + else + token = readIdentifier(); + if (!token) + syntaxError("parsing property path"); + return token; + } + function readOperator() { + skipSpaces(); + let op = ""; + if (!EOL) + op += eat1(); + if (!EOL && op !== "=") + op += eat1(); + if (!["=", "*=", "^=", "$=", "|=", "~="].includes(op)) + syntaxError("parsing operator"); + return op; + } + function readAttribute() { + eat1(); + const jsonPath = []; + jsonPath.push(readAttributeToken()); + skipSpaces(); + while (next() === ".") { + eat1(); + jsonPath.push(readAttributeToken()); + skipSpaces(); + } + if (next() === "]") { + eat1(); + return { name: jsonPath.join("."), jsonPath, op: "", value: null, caseSensitive: false }; + } + const operator = readOperator(); + let value = void 0; + let caseSensitive = true; + skipSpaces(); + if (next() === "/") { + if (operator !== "=") + throw new InvalidSelectorError(`Error while parsing selector \`${selector}\` - cannot use ${operator} in attribute with regular expression`); + value = readRegularExpression(); + } else if (next() === `'` || next() === `"`) { + value = readQuotedString(next()).slice(1, -1); + skipSpaces(); + if (next() === "i" || next() === "I") { + caseSensitive = false; + eat1(); + } else if (next() === "s" || next() === "S") { + caseSensitive = true; + eat1(); + } + } else { + value = ""; + while (!EOL && (isCSSNameChar(next()) || next() === "+" || next() === ".")) + value += eat1(); + if (value === "true") { + value = true; + } else if (value === "false") { + value = false; + } else { + if (!allowUnquotedStrings) { + value = +value; + if (Number.isNaN(value)) + syntaxError("parsing attribute value"); + } + } + } + skipSpaces(); + if (next() !== "]") + syntaxError("parsing attribute value"); + eat1(); + if (operator !== "=" && typeof value !== "string") + throw new InvalidSelectorError(`Error while parsing selector \`${selector}\` - cannot use ${operator} in attribute with non-string matching value - ${value}`); + return { name: jsonPath.join("."), jsonPath, op: operator, value, caseSensitive }; + } + const result = { + name: "", + attributes: [] + }; + result.name = readIdentifier(); + skipSpaces(); + while (next() === "[") { + result.attributes.push(readAttribute()); + skipSpaces(); + } + if (!EOL) + syntaxError(void 0); + if (!result.name && !result.attributes.length) + throw new InvalidSelectorError(`Error while parsing selector \`${selector}\` - selector cannot be empty`); + return result; +} + +// packages/isomorphic/stringUtils.ts +function escapeWithQuotes(text, char = "'") { + const stringified = JSON.stringify(text); + const escapedText = stringified.substring(1, stringified.length - 1).replace(/\\"/g, '"'); + if (char === "'") + return char + escapedText.replace(/[']/g, "\\'") + char; + if (char === '"') + return char + escapedText.replace(/["]/g, '\\"') + char; + if (char === "`") + return char + escapedText.replace(/[`]/g, "\\`") + char; + throw new Error("Invalid escape char"); +} +function toTitleCase(name) { + return name.charAt(0).toUpperCase() + name.substring(1); +} +function toSnakeCase(name) { + return name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/([A-Z])([A-Z][a-z])/g, "$1_$2").toLowerCase(); +} +function quoteCSSAttributeValue(text) { + return `"${text.replace(/["\\]/g, (char) => "\\" + char)}"`; +} +var normalizedWhitespaceCache; +function cacheNormalizedWhitespaces() { + normalizedWhitespaceCache = /* @__PURE__ */ new Map(); +} +function normalizeWhiteSpace(text) { + let result = normalizedWhitespaceCache == null ? void 0 : normalizedWhitespaceCache.get(text); + if (result === void 0) { + result = text.replace(/[\u200b\u00ad]/g, "").trim().replace(/\s+/g, " "); + normalizedWhitespaceCache == null ? void 0 : normalizedWhitespaceCache.set(text, result); + } + return result; +} +function normalizeEscapedRegexQuotes(source) { + return source.replace(/(^|[^\\])(\\\\)*\\(['"`])/g, "$1$2$3"); +} +function escapeRegexForSelector(re) { + if (re.unicode || re.unicodeSets) + return String(re); + return String(re).replace(/(^|[^\\])(\\\\)*(["'`])/g, "$1$2\\$3").replace(/>>/g, "\\>\\>"); +} +function escapeForTextSelector(text, exact) { + if (typeof text !== "string") + return escapeRegexForSelector(text); + return `${JSON.stringify(text)}${exact ? "s" : "i"}`; +} +function escapeForAttributeSelector(value, exact) { + if (typeof value !== "string") + return escapeRegexForSelector(value); + return `"${value.replace(/\\/g, "\\\\").replace(/["]/g, '\\"')}"${exact ? "s" : "i"}`; +} +function trimString(input, cap, suffix = "") { + if (input.length <= cap) + return input; + const chars = [...input]; + if (chars.length > cap) + return chars.slice(0, cap - suffix.length).join("") + suffix; + return chars.join(""); +} +function trimStringWithEllipsis(input, cap) { + return trimString(input, cap, "\u2026"); +} +function escapeRegExp(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} +function longestCommonSubstring(s1, s2) { + const n = s1.length; + const m = s2.length; + let maxLen = 0; + let endingIndex = 0; + const dp = Array(n + 1).fill(null).map(() => Array(m + 1).fill(0)); + for (let i = 1; i <= n; i++) { + for (let j = 1; j <= m; j++) { + if (s1[i - 1] === s2[j - 1]) { + dp[i][j] = dp[i - 1][j - 1] + 1; + if (dp[i][j] > maxLen) { + maxLen = dp[i][j]; + endingIndex = i; + } + } + } + } + return s1.slice(endingIndex - maxLen, endingIndex); +} +var ansiRegex = new RegExp("([\\u001B\\u009B][[\\]()#?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{0,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~])))", "g"); + +// packages/isomorphic/locatorGenerators.ts +function asLocator(lang, selector, isFrameLocator = false) { + return asLocators(lang, selector, isFrameLocator, 1)[0]; +} +function asLocators(lang, selector, isFrameLocator = false, maxOutputSize = 20, preferredQuote) { + try { + return innerAsLocators(new generators[lang](preferredQuote), parseSelector(selector), isFrameLocator, maxOutputSize); + } catch (e) { + return [selector]; + } +} +function innerAsLocators(factory, parsed, isFrameLocator = false, maxOutputSize = 20) { + const parts = [...parsed.parts]; + const tokens = []; + let nextBase = isFrameLocator ? "frame-locator" : "page"; + for (let index = 0; index < parts.length; index++) { + const part = parts[index]; + const base = nextBase; + nextBase = "locator"; + if (part.name === "internal:describe") + continue; + if (part.name === "nth") { + if (part.body === "0") + tokens.push([factory.generateLocator(base, "first", ""), factory.generateLocator(base, "nth", "0")]); + else if (part.body === "-1") + tokens.push([factory.generateLocator(base, "last", ""), factory.generateLocator(base, "nth", "-1")]); + else + tokens.push([factory.generateLocator(base, "nth", part.body)]); + continue; + } + if (part.name === "visible") { + tokens.push([factory.generateLocator(base, "visible", part.body), factory.generateLocator(base, "default", `visible=${part.body}`)]); + continue; + } + if (part.name === "internal:text") { + const { exact, text } = detectExact(part.body); + tokens.push([factory.generateLocator(base, "text", text, { exact })]); + continue; + } + if (part.name === "internal:has-text") { + const { exact, text } = detectExact(part.body); + if (!exact) { + tokens.push([factory.generateLocator(base, "has-text", text, { exact })]); + continue; + } + } + if (part.name === "internal:has-not-text") { + const { exact, text } = detectExact(part.body); + if (!exact) { + tokens.push([factory.generateLocator(base, "has-not-text", text, { exact })]); + continue; + } + } + if (part.name === "internal:has") { + const inners = innerAsLocators(factory, part.body.parsed, false, maxOutputSize); + tokens.push(inners.map((inner) => factory.generateLocator(base, "has", inner))); + continue; + } + if (part.name === "internal:has-not") { + const inners = innerAsLocators(factory, part.body.parsed, false, maxOutputSize); + tokens.push(inners.map((inner) => factory.generateLocator(base, "hasNot", inner))); + continue; + } + if (part.name === "internal:and") { + const inners = innerAsLocators(factory, part.body.parsed, false, maxOutputSize); + tokens.push(inners.map((inner) => factory.generateLocator(base, "and", inner))); + continue; + } + if (part.name === "internal:or") { + const inners = innerAsLocators(factory, part.body.parsed, false, maxOutputSize); + tokens.push(inners.map((inner) => factory.generateLocator(base, "or", inner))); + continue; + } + if (part.name === "internal:chain") { + const inners = innerAsLocators(factory, part.body.parsed, false, maxOutputSize); + tokens.push(inners.map((inner) => factory.generateLocator(base, "chain", inner))); + continue; + } + if (part.name === "internal:label") { + const { exact, text } = detectExact(part.body); + tokens.push([factory.generateLocator(base, "label", text, { exact })]); + continue; + } + if (part.name === "internal:role") { + const attrSelector = parseAttributeSelector(part.body, true); + const options = { attrs: [] }; + for (const attr of attrSelector.attributes) { + if (attr.name === "name") { + if (options.exact !== void 0 && options.exact !== attr.caseSensitive) + throw new Error(`Conflicting exactness in internal:role selector: ${stringifySelector({ parts: [part] })}`); + options.exact = attr.caseSensitive; + options.name = attr.value; + } else if (attr.name === "description") { + if (options.exact !== void 0 && options.exact !== attr.caseSensitive) + throw new Error(`Conflicting exactness in internal:role selector: ${stringifySelector({ parts: [part] })}`); + options.exact = attr.caseSensitive; + options.description = attr.value; + } else { + if (attr.name === "level" && typeof attr.value === "string") + attr.value = +attr.value; + options.attrs.push({ name: attr.name === "include-hidden" ? "includeHidden" : attr.name, value: attr.value }); + } + } + tokens.push([factory.generateLocator(base, "role", attrSelector.name, options)]); + continue; + } + if (part.name === "internal:testid") { + const attrSelector = parseAttributeSelector(part.body, true); + const { value } = attrSelector.attributes[0]; + tokens.push([factory.generateLocator(base, "test-id", value)]); + continue; + } + if (part.name === "internal:attr") { + const attrSelector = parseAttributeSelector(part.body, true); + const { name, value, caseSensitive } = attrSelector.attributes[0]; + const text = value; + const exact = !!caseSensitive; + if (name === "placeholder") { + tokens.push([factory.generateLocator(base, "placeholder", text, { exact })]); + continue; + } + if (name === "alt") { + tokens.push([factory.generateLocator(base, "alt", text, { exact })]); + continue; + } + if (name === "title") { + tokens.push([factory.generateLocator(base, "title", text, { exact })]); + continue; + } + } + if (part.name === "internal:control" && part.body === "enter-frame") { + const lastTokens = tokens[tokens.length - 1]; + const lastPart = parts[index - 1]; + const transformed = lastTokens.map((token) => factory.chainLocators([token, factory.generateLocator(base, "frame", "")])); + if (["xpath", "css"].includes(lastPart.name)) { + transformed.push( + factory.generateLocator(base, "frame-locator", stringifySelector({ parts: [lastPart] })), + factory.generateLocator(base, "frame-locator", stringifySelector({ parts: [lastPart] }, true)) + ); + } + lastTokens.splice(0, lastTokens.length, ...transformed); + nextBase = "frame-locator"; + continue; + } + const nextPart = parts[index + 1]; + const selectorPart = stringifySelector({ parts: [part] }); + const locatorPart = factory.generateLocator(base, "default", selectorPart); + if (nextPart && ["internal:has-text", "internal:has-not-text"].includes(nextPart.name)) { + const { exact, text } = detectExact(nextPart.body); + if (!exact) { + const nextLocatorPart = factory.generateLocator("locator", nextPart.name === "internal:has-text" ? "has-text" : "has-not-text", text, { exact }); + const options = {}; + if (nextPart.name === "internal:has-text") + options.hasText = text; + else + options.hasNotText = text; + const combinedPart = factory.generateLocator(base, "default", selectorPart, options); + tokens.push([factory.chainLocators([locatorPart, nextLocatorPart]), combinedPart]); + index++; + continue; + } + } + let locatorPartWithEngine; + if (["xpath", "css"].includes(part.name)) { + const selectorPart2 = stringifySelector( + { parts: [part] }, + /* forceEngineName */ + true + ); + locatorPartWithEngine = factory.generateLocator(base, "default", selectorPart2); + } + tokens.push([locatorPart, locatorPartWithEngine].filter(Boolean)); + } + return combineTokens(factory, tokens, maxOutputSize); +} +function combineTokens(factory, tokens, maxOutputSize) { + const currentTokens = tokens.map(() => ""); + const result = []; + const visit = (index) => { + if (index === tokens.length) { + result.push(factory.chainLocators(currentTokens)); + return result.length < maxOutputSize; + } + for (const taken of tokens[index]) { + currentTokens[index] = taken; + if (!visit(index + 1)) + return false; + } + return true; + }; + visit(0); + return result; +} +function detectExact(text) { + let exact = false; + const match = text.match(/^\/(.*)\/([igm]*)$/); + if (match) + return { text: new RegExp(match[1], match[2]) }; + if (text.endsWith('"')) { + text = JSON.parse(text); + exact = true; + } else if (text.endsWith('"s')) { + text = JSON.parse(text.substring(0, text.length - 1)); + exact = true; + } else if (text.endsWith('"i')) { + text = JSON.parse(text.substring(0, text.length - 1)); + exact = false; + } + return { exact, text }; +} +var JavaScriptLocatorFactory = class { + constructor(preferredQuote) { + this.preferredQuote = preferredQuote; + } + generateLocator(base, kind, body, options = {}) { + switch (kind) { + case "default": + if (options.hasText !== void 0) + return `locator(${this.quote(body)}, { hasText: ${this.toHasText(options.hasText)} })`; + if (options.hasNotText !== void 0) + return `locator(${this.quote(body)}, { hasNotText: ${this.toHasText(options.hasNotText)} })`; + return `locator(${this.quote(body)})`; + case "frame-locator": + return `frameLocator(${this.quote(body)})`; + case "frame": + return `contentFrame()`; + case "nth": + return `nth(${body})`; + case "first": + return `first()`; + case "last": + return `last()`; + case "visible": + return `filter({ visible: ${body === "true" ? "true" : "false"} })`; + case "role": + const attrs = []; + if (isRegExp(options.name)) + attrs.push(`name: ${this.regexToSourceString(options.name)}`); + else if (typeof options.name === "string") + attrs.push(`name: ${this.quote(options.name)}`); + if (isRegExp(options.description)) + attrs.push(`description: ${this.regexToSourceString(options.description)}`); + else if (typeof options.description === "string") + attrs.push(`description: ${this.quote(options.description)}`); + if (options.exact && (typeof options.name === "string" || typeof options.description === "string")) + attrs.push(`exact: true`); + for (const { name, value } of options.attrs) + attrs.push(`${name}: ${typeof value === "string" ? this.quote(value) : value}`); + const attrString = attrs.length ? `, { ${attrs.join(", ")} }` : ""; + return `getByRole(${this.quote(body)}${attrString})`; + case "has-text": + return `filter({ hasText: ${this.toHasText(body)} })`; + case "has-not-text": + return `filter({ hasNotText: ${this.toHasText(body)} })`; + case "has": + return `filter({ has: ${body} })`; + case "hasNot": + return `filter({ hasNot: ${body} })`; + case "and": + return `and(${body})`; + case "or": + return `or(${body})`; + case "chain": + return `locator(${body})`; + case "test-id": + return `getByTestId(${this.toTestIdValue(body)})`; + case "text": + return this.toCallWithExact("getByText", body, !!options.exact); + case "alt": + return this.toCallWithExact("getByAltText", body, !!options.exact); + case "placeholder": + return this.toCallWithExact("getByPlaceholder", body, !!options.exact); + case "label": + return this.toCallWithExact("getByLabel", body, !!options.exact); + case "title": + return this.toCallWithExact("getByTitle", body, !!options.exact); + default: + throw new Error("Unknown selector kind " + kind); + } + } + chainLocators(locators) { + return locators.join("."); + } + regexToSourceString(re) { + return normalizeEscapedRegexQuotes(String(re)); + } + toCallWithExact(method, body, exact) { + if (isRegExp(body)) + return `${method}(${this.regexToSourceString(body)})`; + return exact ? `${method}(${this.quote(body)}, { exact: true })` : `${method}(${this.quote(body)})`; + } + toHasText(body) { + if (isRegExp(body)) + return this.regexToSourceString(body); + return this.quote(body); + } + toTestIdValue(value) { + if (isRegExp(value)) + return this.regexToSourceString(value); + return this.quote(value); + } + quote(text) { + var _a; + return escapeWithQuotes(text, (_a = this.preferredQuote) != null ? _a : "'"); + } +}; +var PythonLocatorFactory = class { + generateLocator(base, kind, body, options = {}) { + switch (kind) { + case "default": + if (options.hasText !== void 0) + return `locator(${this.quote(body)}, has_text=${this.toHasText(options.hasText)})`; + if (options.hasNotText !== void 0) + return `locator(${this.quote(body)}, has_not_text=${this.toHasText(options.hasNotText)})`; + return `locator(${this.quote(body)})`; + case "frame-locator": + return `frame_locator(${this.quote(body)})`; + case "frame": + return `content_frame`; + case "nth": + return `nth(${body})`; + case "first": + return `first`; + case "last": + return `last`; + case "visible": + return `filter(visible=${body === "true" ? "True" : "False"})`; + case "role": + const attrs = []; + if (isRegExp(options.name)) + attrs.push(`name=${this.regexToString(options.name)}`); + else if (typeof options.name === "string") + attrs.push(`name=${this.quote(options.name)}`); + if (isRegExp(options.description)) + attrs.push(`description=${this.regexToString(options.description)}`); + else if (typeof options.description === "string") + attrs.push(`description=${this.quote(options.description)}`); + if (options.exact && (typeof options.name === "string" || typeof options.description === "string")) + attrs.push(`exact=True`); + for (const { name, value } of options.attrs) { + let valueString = typeof value === "string" ? this.quote(value) : value; + if (typeof value === "boolean") + valueString = value ? "True" : "False"; + attrs.push(`${toSnakeCase(name)}=${valueString}`); + } + const attrString = attrs.length ? `, ${attrs.join(", ")}` : ""; + return `get_by_role(${this.quote(body)}${attrString})`; + case "has-text": + return `filter(has_text=${this.toHasText(body)})`; + case "has-not-text": + return `filter(has_not_text=${this.toHasText(body)})`; + case "has": + return `filter(has=${body})`; + case "hasNot": + return `filter(has_not=${body})`; + case "and": + return `and_(${body})`; + case "or": + return `or_(${body})`; + case "chain": + return `locator(${body})`; + case "test-id": + return `get_by_test_id(${this.toTestIdValue(body)})`; + case "text": + return this.toCallWithExact("get_by_text", body, !!options.exact); + case "alt": + return this.toCallWithExact("get_by_alt_text", body, !!options.exact); + case "placeholder": + return this.toCallWithExact("get_by_placeholder", body, !!options.exact); + case "label": + return this.toCallWithExact("get_by_label", body, !!options.exact); + case "title": + return this.toCallWithExact("get_by_title", body, !!options.exact); + default: + throw new Error("Unknown selector kind " + kind); + } + } + chainLocators(locators) { + return locators.join("."); + } + regexToString(body) { + const suffix = body.flags.includes("i") ? ", re.IGNORECASE" : ""; + return `re.compile(r"${normalizeEscapedRegexQuotes(body.source).replace(/\\\//, "/").replace(/"/g, '\\"')}"${suffix})`; + } + toCallWithExact(method, body, exact) { + if (isRegExp(body)) + return `${method}(${this.regexToString(body)})`; + if (exact) + return `${method}(${this.quote(body)}, exact=True)`; + return `${method}(${this.quote(body)})`; + } + toHasText(body) { + if (isRegExp(body)) + return this.regexToString(body); + return `${this.quote(body)}`; + } + toTestIdValue(value) { + if (isRegExp(value)) + return this.regexToString(value); + return this.quote(value); + } + quote(text) { + return escapeWithQuotes(text, '"'); + } +}; +var JavaLocatorFactory = class { + generateLocator(base, kind, body, options = {}) { + let clazz; + switch (base) { + case "page": + clazz = "Page"; + break; + case "frame-locator": + clazz = "FrameLocator"; + break; + case "locator": + clazz = "Locator"; + break; + } + switch (kind) { + case "default": + if (options.hasText !== void 0) + return `locator(${this.quote(body)}, new ${clazz}.LocatorOptions().setHasText(${this.toHasText(options.hasText)}))`; + if (options.hasNotText !== void 0) + return `locator(${this.quote(body)}, new ${clazz}.LocatorOptions().setHasNotText(${this.toHasText(options.hasNotText)}))`; + return `locator(${this.quote(body)})`; + case "frame-locator": + return `frameLocator(${this.quote(body)})`; + case "frame": + return `contentFrame()`; + case "nth": + return `nth(${body})`; + case "first": + return `first()`; + case "last": + return `last()`; + case "visible": + return `filter(new ${clazz}.FilterOptions().setVisible(${body === "true" ? "true" : "false"}))`; + case "role": + const attrs = []; + if (isRegExp(options.name)) + attrs.push(`.setName(${this.regexToString(options.name)})`); + else if (typeof options.name === "string") + attrs.push(`.setName(${this.quote(options.name)})`); + if (isRegExp(options.description)) + attrs.push(`.setDescription(${this.regexToString(options.description)})`); + else if (typeof options.description === "string") + attrs.push(`.setDescription(${this.quote(options.description)})`); + if (options.exact && (typeof options.name === "string" || typeof options.description === "string")) + attrs.push(`.setExact(true)`); + for (const { name, value } of options.attrs) + attrs.push(`.set${toTitleCase(name)}(${typeof value === "string" ? this.quote(value) : value})`); + const attrString = attrs.length ? `, new ${clazz}.GetByRoleOptions()${attrs.join("")}` : ""; + return `getByRole(AriaRole.${toSnakeCase(body).toUpperCase()}${attrString})`; + case "has-text": + return `filter(new ${clazz}.FilterOptions().setHasText(${this.toHasText(body)}))`; + case "has-not-text": + return `filter(new ${clazz}.FilterOptions().setHasNotText(${this.toHasText(body)}))`; + case "has": + return `filter(new ${clazz}.FilterOptions().setHas(${body}))`; + case "hasNot": + return `filter(new ${clazz}.FilterOptions().setHasNot(${body}))`; + case "and": + return `and(${body})`; + case "or": + return `or(${body})`; + case "chain": + return `locator(${body})`; + case "test-id": + return `getByTestId(${this.toTestIdValue(body)})`; + case "text": + return this.toCallWithExact(clazz, "getByText", body, !!options.exact); + case "alt": + return this.toCallWithExact(clazz, "getByAltText", body, !!options.exact); + case "placeholder": + return this.toCallWithExact(clazz, "getByPlaceholder", body, !!options.exact); + case "label": + return this.toCallWithExact(clazz, "getByLabel", body, !!options.exact); + case "title": + return this.toCallWithExact(clazz, "getByTitle", body, !!options.exact); + default: + throw new Error("Unknown selector kind " + kind); + } + } + chainLocators(locators) { + return locators.join("."); + } + regexToString(body) { + const suffix = body.flags.includes("i") ? ", Pattern.CASE_INSENSITIVE" : ""; + return `Pattern.compile(${this.quote(normalizeEscapedRegexQuotes(body.source))}${suffix})`; + } + toCallWithExact(clazz, method, body, exact) { + if (isRegExp(body)) + return `${method}(${this.regexToString(body)})`; + if (exact) + return `${method}(${this.quote(body)}, new ${clazz}.${toTitleCase(method)}Options().setExact(true))`; + return `${method}(${this.quote(body)})`; + } + toHasText(body) { + if (isRegExp(body)) + return this.regexToString(body); + return this.quote(body); + } + toTestIdValue(value) { + if (isRegExp(value)) + return this.regexToString(value); + return this.quote(value); + } + quote(text) { + return escapeWithQuotes(text, '"'); + } +}; +var CSharpLocatorFactory = class { + generateLocator(base, kind, body, options = {}) { + switch (kind) { + case "default": + if (options.hasText !== void 0) + return `Locator(${this.quote(body)}, new() { ${this.toHasText(options.hasText)} })`; + if (options.hasNotText !== void 0) + return `Locator(${this.quote(body)}, new() { ${this.toHasNotText(options.hasNotText)} })`; + return `Locator(${this.quote(body)})`; + case "frame-locator": + return `FrameLocator(${this.quote(body)})`; + case "frame": + return `ContentFrame`; + case "nth": + return `Nth(${body})`; + case "first": + return `First`; + case "last": + return `Last`; + case "visible": + return `Filter(new() { Visible = ${body === "true" ? "true" : "false"} })`; + case "role": + const attrs = []; + if (isRegExp(options.name)) + attrs.push(`NameRegex = ${this.regexToString(options.name)}`); + else if (typeof options.name === "string") + attrs.push(`Name = ${this.quote(options.name)}`); + if (isRegExp(options.description)) + attrs.push(`DescriptionRegex = ${this.regexToString(options.description)}`); + else if (typeof options.description === "string") + attrs.push(`Description = ${this.quote(options.description)}`); + if (options.exact && (typeof options.name === "string" || typeof options.description === "string")) + attrs.push(`Exact = true`); + for (const { name, value } of options.attrs) + attrs.push(`${toTitleCase(name)} = ${typeof value === "string" ? this.quote(value) : value}`); + const attrString = attrs.length ? `, new() { ${attrs.join(", ")} }` : ""; + return `GetByRole(AriaRole.${toTitleCase(body)}${attrString})`; + case "has-text": + return `Filter(new() { ${this.toHasText(body)} })`; + case "has-not-text": + return `Filter(new() { ${this.toHasNotText(body)} })`; + case "has": + return `Filter(new() { Has = ${body} })`; + case "hasNot": + return `Filter(new() { HasNot = ${body} })`; + case "and": + return `And(${body})`; + case "or": + return `Or(${body})`; + case "chain": + return `Locator(${body})`; + case "test-id": + return `GetByTestId(${this.toTestIdValue(body)})`; + case "text": + return this.toCallWithExact("GetByText", body, !!options.exact); + case "alt": + return this.toCallWithExact("GetByAltText", body, !!options.exact); + case "placeholder": + return this.toCallWithExact("GetByPlaceholder", body, !!options.exact); + case "label": + return this.toCallWithExact("GetByLabel", body, !!options.exact); + case "title": + return this.toCallWithExact("GetByTitle", body, !!options.exact); + default: + throw new Error("Unknown selector kind " + kind); + } + } + chainLocators(locators) { + return locators.join("."); + } + regexToString(body) { + const suffix = body.flags.includes("i") ? ", RegexOptions.IgnoreCase" : ""; + return `new Regex(${this.quote(normalizeEscapedRegexQuotes(body.source))}${suffix})`; + } + toCallWithExact(method, body, exact) { + if (isRegExp(body)) + return `${method}(${this.regexToString(body)})`; + if (exact) + return `${method}(${this.quote(body)}, new() { Exact = true })`; + return `${method}(${this.quote(body)})`; + } + toHasText(body) { + if (isRegExp(body)) + return `HasTextRegex = ${this.regexToString(body)}`; + return `HasText = ${this.quote(body)}`; + } + toTestIdValue(value) { + if (isRegExp(value)) + return this.regexToString(value); + return this.quote(value); + } + toHasNotText(body) { + if (isRegExp(body)) + return `HasNotTextRegex = ${this.regexToString(body)}`; + return `HasNotText = ${this.quote(body)}`; + } + quote(text) { + return escapeWithQuotes(text, '"'); + } +}; +var JsonlLocatorFactory = class { + generateLocator(base, kind, body, options = {}) { + return JSON.stringify({ + kind, + body, + options + }); + } + chainLocators(locators) { + const objects = locators.map((l) => JSON.parse(l)); + for (let i = 0; i < objects.length - 1; ++i) + objects[i].next = objects[i + 1]; + return JSON.stringify(objects[0]); + } +}; +var generators = { + javascript: JavaScriptLocatorFactory, + python: PythonLocatorFactory, + java: JavaLocatorFactory, + csharp: CSharpLocatorFactory, + jsonl: JsonlLocatorFactory +}; +function isRegExp(obj) { + return obj instanceof RegExp; +} + +// packages/isomorphic/locatorUtils.ts +function getByAttributeTextSelector(attrName, text, options) { + return `internal:attr=[${attrName}=${escapeForAttributeSelector(text, (options == null ? void 0 : options.exact) || false)}]`; +} +function splitTestIdAttributeNames(testIdAttributeName) { + return testIdAttributeName.split(","); +} +function encodeTestIdAttributeName(testIdAttributeName) { + return testIdAttributeName.includes(",") ? JSON.stringify(testIdAttributeName) : testIdAttributeName; +} +function getByTestIdSelector(testIdAttributeName, testId) { + return `internal:testid=[${encodeTestIdAttributeName(testIdAttributeName)}=${escapeForAttributeSelector(testId, true)}]`; +} +function getByLabelSelector(text, options) { + return "internal:label=" + escapeForTextSelector(text, !!(options == null ? void 0 : options.exact)); +} +function getByAltTextSelector(text, options) { + return getByAttributeTextSelector("alt", text, options); +} +function getByTitleSelector(text, options) { + return getByAttributeTextSelector("title", text, options); +} +function getByPlaceholderSelector(text, options) { + return getByAttributeTextSelector("placeholder", text, options); +} +function getByTextSelector(text, options) { + return "internal:text=" + escapeForTextSelector(text, !!(options == null ? void 0 : options.exact)); +} +function getByRoleSelector(role, options = {}) { + const props = []; + if (options.checked !== void 0) + props.push(["checked", String(options.checked)]); + if (options.disabled !== void 0) + props.push(["disabled", String(options.disabled)]); + if (options.selected !== void 0) + props.push(["selected", String(options.selected)]); + if (options.expanded !== void 0) + props.push(["expanded", String(options.expanded)]); + if (options.includeHidden !== void 0) + props.push(["include-hidden", String(options.includeHidden)]); + if (options.level !== void 0) + props.push(["level", String(options.level)]); + if (options.name !== void 0) + props.push(["name", escapeForAttributeSelector(options.name, !!options.exact)]); + if (options.description !== void 0) + props.push(["description", escapeForAttributeSelector(options.description, !!options.exact)]); + if (options.pressed !== void 0) + props.push(["pressed", String(options.pressed)]); + return `internal:role=${role}${props.map(([n, v]) => `[${n}=${v}]`).join("")}`; +} + +// packages/isomorphic/yaml.ts +function yamlEscapeKeyIfNeeded(str) { + if (!yamlStringNeedsQuotes(str)) + return str; + return `'` + str.replace(/'/g, `''`) + `'`; +} +function yamlEscapeValueIfNeeded(str) { + if (!yamlStringNeedsQuotes(str)) + return str; + return '"' + str.replace(/[\\"\x00-\x1f\x7f-\x9f]/g, (c) => { + switch (c) { + case "\\": + return "\\\\"; + case '"': + return '\\"'; + case "\b": + return "\\b"; + case "\f": + return "\\f"; + case "\n": + return "\\n"; + case "\r": + return "\\r"; + case " ": + return "\\t"; + default: + const code = c.charCodeAt(0); + return "\\x" + code.toString(16).padStart(2, "0"); + } + }) + '"'; +} +function yamlStringNeedsQuotes(str) { + if (str.length === 0) + return true; + if (/^\s|\s$/.test(str)) + return true; + if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/.test(str)) + return true; + if (/^-/.test(str)) + return true; + if (/[\n:](\s|$)/.test(str)) + return true; + if (/\s#/.test(str)) + return true; + if (/[\n\r]/.test(str)) + return true; + if (/^[&*\],?!>|@"'#%]/.test(str)) + return true; + if (/[{}`]/.test(str)) + return true; + if (/^\[/.test(str)) + return true; + if (!isNaN(Number(str)) || ["y", "n", "yes", "no", "true", "false", "on", "off", "null"].includes(str.toLowerCase())) + return true; + return false; +} + +// packages/injected/src/domUtils.ts +var globalOptions = {}; +function setGlobalOptions(options) { + globalOptions = options; +} +function isInsideScope(scope, element) { + while (element) { + if (scope.contains(element)) + return true; + element = enclosingShadowHost(element); + } + return false; +} +function parentElementOrShadowHost(element) { + if (element.parentElement) + return element.parentElement; + if (!element.parentNode) + return; + if (element.parentNode.nodeType === 11 && element.parentNode.host) + return element.parentNode.host; +} +function enclosingShadowRootOrDocument(element) { + let node = element; + while (node.parentNode) + node = node.parentNode; + if (node.nodeType === 11 || node.nodeType === 9) + return node; +} +function enclosingShadowHost(element) { + while (element.parentElement) + element = element.parentElement; + return parentElementOrShadowHost(element); +} +function closestCrossShadow(element, css, scope) { + while (element) { + const closest = element.closest(css); + if (scope && closest !== scope && (closest == null ? void 0 : closest.contains(scope))) + return; + if (closest) + return closest; + element = enclosingShadowHost(element); + } +} +function getElementComputedStyle(element, pseudo) { + const cache = pseudo === "::before" ? cacheStyleBefore : pseudo === "::after" ? cacheStyleAfter : cacheStyle; + if (cache && cache.has(element)) + return cache.get(element); + const style = element.ownerDocument && element.ownerDocument.defaultView ? element.ownerDocument.defaultView.getComputedStyle(element, pseudo) : void 0; + cache == null ? void 0 : cache.set(element, style); + return style; +} +function isElementStyleVisibilityVisible(element, style) { + style = style != null ? style : getElementComputedStyle(element); + if (!style) + return true; + if (Element.prototype.checkVisibility && globalOptions.browserNameForWorkarounds !== "webkit") { + if (!element.checkVisibility()) + return false; + } else { + const detailsOrSummary = element.closest("details,summary"); + if (detailsOrSummary !== element && (detailsOrSummary == null ? void 0 : detailsOrSummary.nodeName) === "DETAILS" && !detailsOrSummary.open) + return false; + } + if (style.visibility !== "visible") + return false; + return true; +} +function computeBox(element) { + const style = getElementComputedStyle(element); + if (!style) + return { visible: true, inline: false }; + const cursor = style.cursor; + if (style.display === "contents") { + for (let child = element.firstChild; child; child = child.nextSibling) { + if (child.nodeType === 1 && isElementVisible(child)) + return { visible: true, inline: false, cursor }; + if (child.nodeType === 3 && isVisibleTextNode(child)) + return { visible: true, inline: true, cursor }; + } + return { visible: false, inline: false, cursor }; + } + if (!isElementStyleVisibilityVisible(element, style)) + return { cursor, visible: false, inline: false }; + const rect = element.getBoundingClientRect(); + return { cursor, visible: rect.width > 0 && rect.height > 0, inline: style.display === "inline" }; +} +function isElementVisible(element) { + return computeBox(element).visible; +} +function isVisibleTextNode(node) { + const range = node.ownerDocument.createRange(); + range.selectNode(node); + const rect = range.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; +} +function elementSafeTagName(element) { + const tagName = element.tagName; + if (typeof tagName === "string") + return tagName.toUpperCase(); + if (element instanceof HTMLFormElement) + return "FORM"; + return element.tagName.toUpperCase(); +} +var cacheStyle; +var cacheStyleBefore; +var cacheStyleAfter; +var cachesCounter = 0; +function beginDOMCaches() { + ++cachesCounter; + cacheStyle != null ? cacheStyle : cacheStyle = /* @__PURE__ */ new Map(); + cacheStyleBefore != null ? cacheStyleBefore : cacheStyleBefore = /* @__PURE__ */ new Map(); + cacheStyleAfter != null ? cacheStyleAfter : cacheStyleAfter = /* @__PURE__ */ new Map(); +} +function endDOMCaches() { + if (!--cachesCounter) { + cacheStyle = void 0; + cacheStyleBefore = void 0; + cacheStyleAfter = void 0; + } +} + +// packages/injected/src/roleUtils.ts +function hasExplicitAccessibleName(e) { + return e.hasAttribute("aria-label") || e.hasAttribute("aria-labelledby"); +} +var kAncestorPreventingLandmark = "article:not([role]), aside:not([role]), main:not([role]), nav:not([role]), section:not([role]), [role=article], [role=complementary], [role=main], [role=navigation], [role=region]"; +var kGlobalAriaAttributes = [ + ["aria-atomic", void 0], + ["aria-busy", void 0], + ["aria-controls", void 0], + ["aria-current", void 0], + ["aria-describedby", void 0], + ["aria-details", void 0], + // Global use deprecated in ARIA 1.2 + // ['aria-disabled', undefined], + ["aria-dropeffect", void 0], + // Global use deprecated in ARIA 1.2 + // ['aria-errormessage', undefined], + ["aria-flowto", void 0], + ["aria-grabbed", void 0], + // Global use deprecated in ARIA 1.2 + // ['aria-haspopup', undefined], + ["aria-hidden", void 0], + // Global use deprecated in ARIA 1.2 + // ['aria-invalid', undefined], + ["aria-keyshortcuts", void 0], + ["aria-label", ["caption", "code", "deletion", "emphasis", "generic", "insertion", "paragraph", "presentation", "strong", "subscript", "superscript"]], + ["aria-labelledby", ["caption", "code", "deletion", "emphasis", "generic", "insertion", "paragraph", "presentation", "strong", "subscript", "superscript"]], + ["aria-live", void 0], + ["aria-owns", void 0], + ["aria-relevant", void 0], + ["aria-roledescription", ["generic"]] +]; +function hasGlobalAriaAttribute(element, forRole) { + return kGlobalAriaAttributes.some(([attr, prohibited]) => { + return !(prohibited == null ? void 0 : prohibited.includes(forRole || "")) && element.hasAttribute(attr); + }); +} +function hasTabIndex(element) { + return !Number.isNaN(Number(String(element.getAttribute("tabindex")))); +} +function isFocusable(element) { + return !isNativelyDisabled(element) && (isNativelyFocusable(element) || hasTabIndex(element)); +} +function isNativelyFocusable(element) { + const tagName = elementSafeTagName(element); + if (["BUTTON", "DETAILS", "SELECT", "TEXTAREA"].includes(tagName)) + return true; + if (tagName === "A" || tagName === "AREA") + return element.hasAttribute("href"); + if (tagName === "INPUT") + return !element.hidden; + return false; +} +var kImplicitRoleByTagName = { + "A": (e) => { + return e.hasAttribute("href") ? "link" : null; + }, + "AREA": (e) => { + return e.hasAttribute("href") ? "link" : null; + }, + "ARTICLE": () => "article", + "ASIDE": () => "complementary", + "BLOCKQUOTE": () => "blockquote", + "BUTTON": () => "button", + "CAPTION": () => "caption", + "CODE": () => "code", + "DATALIST": () => "listbox", + "DD": () => "definition", + "DEL": () => "deletion", + "DETAILS": () => "group", + "DFN": () => "term", + "DIALOG": () => "dialog", + "DT": () => "term", + "EM": () => "emphasis", + "FIELDSET": () => "group", + "FIGURE": () => "figure", + "FOOTER": (e) => closestCrossShadow(e, kAncestorPreventingLandmark) ? null : "contentinfo", + "FORM": (e) => hasExplicitAccessibleName(e) ? "form" : null, + "H1": () => "heading", + "H2": () => "heading", + "H3": () => "heading", + "H4": () => "heading", + "H5": () => "heading", + "H6": () => "heading", + "HEADER": (e) => closestCrossShadow(e, kAncestorPreventingLandmark) ? null : "banner", + "HR": () => "separator", + "HTML": () => "document", + "IMG": (e) => e.getAttribute("alt") === "" && !e.getAttribute("title") && !hasGlobalAriaAttribute(e) && !hasTabIndex(e) ? "presentation" : "img", + "INPUT": (e) => { + const type = e.type.toLowerCase(); + if (type === "search") + return e.hasAttribute("list") ? "combobox" : "searchbox"; + if (["email", "tel", "text", "url", ""].includes(type)) { + const list = getIdRefs(e, e.getAttribute("list"))[0]; + return list && elementSafeTagName(list) === "DATALIST" ? "combobox" : "textbox"; + } + if (type === "hidden") + return null; + if (type === "file") + return "button"; + return inputTypeToRole[type] || "textbox"; + }, + "INS": () => "insertion", + "LI": () => "listitem", + "MAIN": () => "main", + "MARK": () => "mark", + "MATH": () => "math", + "MENU": () => "list", + "METER": () => "meter", + "NAV": () => "navigation", + "OL": () => "list", + "OPTGROUP": () => "group", + "OPTION": () => "option", + "OUTPUT": () => "status", + "P": () => "paragraph", + "PROGRESS": () => "progressbar", + "SEARCH": () => "search", + "SECTION": (e) => hasExplicitAccessibleName(e) ? "region" : null, + "SELECT": (e) => e.hasAttribute("multiple") || e.size > 1 ? "listbox" : "combobox", + "STRONG": () => "strong", + "SUB": () => "subscript", + "SUP": () => "superscript", + // For we default to Chrome behavior: + // - Chrome reports 'img'. + // - Firefox reports 'diagram' that is not in official ARIA spec yet. + // - Safari reports 'no role', but still computes accessible name. + "SVG": () => "img", + "TABLE": () => "table", + "TBODY": () => "rowgroup", + "TD": (e) => { + const table = closestCrossShadow(e, "table"); + const role = table ? getExplicitAriaRole(table) : ""; + return role === "grid" || role === "treegrid" ? "gridcell" : "cell"; + }, + "TEXTAREA": () => "textbox", + "TFOOT": () => "rowgroup", + "TH": (e) => { + const scope = e.getAttribute("scope"); + if (scope === "col" || scope === "colgroup") + return "columnheader"; + if (scope === "row" || scope === "rowgroup") + return "rowheader"; + const nextSibling = e.nextElementSibling; + const prevSibling = e.previousElementSibling; + const row = !!e.parentElement && elementSafeTagName(e.parentElement) === "TR" ? e.parentElement : void 0; + if (!nextSibling && !prevSibling) { + if (row) { + const table = closestCrossShadow(row, "table"); + if (table && table.rows.length <= 1) + return null; + } + return "columnheader"; + } + if (isHeaderCell(nextSibling) && isHeaderCell(prevSibling)) + return "columnheader"; + if (isNonEmptyDataCell(nextSibling) || isNonEmptyDataCell(prevSibling)) + return "rowheader"; + return "columnheader"; + }, + "THEAD": () => "rowgroup", + "TIME": () => "time", + "TR": () => "row", + "UL": () => "list" +}; +function isHeaderCell(element) { + return !!element && elementSafeTagName(element) === "TH"; +} +function isNonEmptyDataCell(element) { + var _a; + if (!element || elementSafeTagName(element) !== "TD") + return false; + return !!(((_a = element.textContent) == null ? void 0 : _a.trim()) || element.children.length > 0); +} +var kPresentationInheritanceParents = { + "DD": ["DL", "DIV"], + "DIV": ["DL"], + "DT": ["DL", "DIV"], + "LI": ["OL", "UL"], + "TBODY": ["TABLE"], + "TD": ["TR"], + "TFOOT": ["TABLE"], + "TH": ["TR"], + "THEAD": ["TABLE"], + "TR": ["THEAD", "TBODY", "TFOOT", "TABLE"] +}; +function getImplicitAriaRole(element) { + var _a; + const implicitRole = ((_a = kImplicitRoleByTagName[elementSafeTagName(element)]) == null ? void 0 : _a.call(kImplicitRoleByTagName, element)) || ""; + if (!implicitRole) + return null; + let ancestor = element; + while (ancestor) { + const parent = parentElementOrShadowHost(ancestor); + const parents = kPresentationInheritanceParents[elementSafeTagName(ancestor)]; + if (!parents || !parent || !parents.includes(elementSafeTagName(parent))) + break; + const parentExplicitRole = getExplicitAriaRole(parent); + if ((parentExplicitRole === "none" || parentExplicitRole === "presentation") && !hasPresentationConflictResolution(parent, parentExplicitRole)) + return parentExplicitRole; + ancestor = parent; + } + return implicitRole; +} +var validRoles = [ + "alert", + "alertdialog", + "application", + "article", + "banner", + "blockquote", + "button", + "caption", + "cell", + "checkbox", + "code", + "columnheader", + "combobox", + "complementary", + "contentinfo", + "definition", + "deletion", + "dialog", + "directory", + "document", + "emphasis", + "feed", + "figure", + "form", + "generic", + "grid", + "gridcell", + "group", + "heading", + "img", + "insertion", + "link", + "list", + "listbox", + "listitem", + "log", + "main", + "mark", + "marquee", + "math", + "meter", + "menu", + "menubar", + "menuitem", + "menuitemcheckbox", + "menuitemradio", + "navigation", + "none", + "note", + "option", + "paragraph", + "presentation", + "progressbar", + "radio", + "radiogroup", + "region", + "row", + "rowgroup", + "rowheader", + "scrollbar", + "search", + "searchbox", + "separator", + "slider", + "spinbutton", + "status", + "strong", + "subscript", + "superscript", + "switch", + "tab", + "table", + "tablist", + "tabpanel", + "term", + "textbox", + "time", + "timer", + "toolbar", + "tooltip", + "tree", + "treegrid", + "treeitem" +]; +function getExplicitAriaRole(element) { + const roles = (element.getAttribute("role") || "").split(" ").map((role) => role.trim()); + return roles.find((role) => validRoles.includes(role)) || null; +} +function hasPresentationConflictResolution(element, role) { + return hasGlobalAriaAttribute(element, role) || isFocusable(element); +} +function getAriaRole(element) { + const explicitRole = getExplicitAriaRole(element); + if (!explicitRole) + return getImplicitAriaRole(element); + if (explicitRole === "none" || explicitRole === "presentation") { + const implicitRole = getImplicitAriaRole(element); + if (hasPresentationConflictResolution(element, implicitRole)) + return implicitRole; + } + return explicitRole; +} +function getAriaBoolean(attr) { + return attr === null ? void 0 : attr.toLowerCase() === "true"; +} +function isElementIgnoredForAria(element) { + return ["STYLE", "SCRIPT", "NOSCRIPT", "TEMPLATE"].includes(elementSafeTagName(element)); +} +function isElementHiddenForAria(element) { + if (isElementIgnoredForAria(element)) + return true; + const style = getElementComputedStyle(element); + const isSlot = element.nodeName === "SLOT"; + if ((style == null ? void 0 : style.display) === "contents" && !isSlot) { + for (let child = element.firstChild; child; child = child.nextSibling) { + if (child.nodeType === 1 && !isElementHiddenForAria(child)) + return false; + if (child.nodeType === 3 && isVisibleTextNode(child)) + return false; + } + return true; + } + const isOptionInsideSelect = element.nodeName === "OPTION" && !!element.closest("select"); + if (!isOptionInsideSelect && !isSlot && !isElementStyleVisibilityVisible(element, style)) + return true; + return belongsToDisplayNoneOrAriaHiddenOrNonSlotted(element); +} +function belongsToDisplayNoneOrAriaHiddenOrNonSlotted(element) { + let hidden = cacheIsHidden == null ? void 0 : cacheIsHidden.get(element); + if (hidden === void 0) { + hidden = false; + if (element.parentElement && element.parentElement.shadowRoot && !element.assignedSlot) + hidden = true; + if (!hidden) { + const style = getElementComputedStyle(element); + hidden = !style || style.display === "none" || getAriaBoolean(element.getAttribute("aria-hidden")) === true; + } + if (!hidden) { + const parent = parentElementOrShadowHost(element); + if (parent) + hidden = belongsToDisplayNoneOrAriaHiddenOrNonSlotted(parent); + } + cacheIsHidden == null ? void 0 : cacheIsHidden.set(element, hidden); + } + return hidden; +} +function getIdRefs(element, ref) { + if (!ref) + return []; + const root = enclosingShadowRootOrDocument(element); + if (!root) + return []; + try { + const ids = ref.split(" ").filter((id) => !!id); + const result = []; + for (const id of ids) { + const firstElement = root.querySelector("#" + CSS.escape(id)); + if (firstElement && !result.includes(firstElement)) + result.push(firstElement); + } + return result; + } catch (e) { + return []; + } +} +function trimFlatString(s) { + return s.trim(); +} +function asFlatString(s) { + return s.split("\xA0").map((chunk) => chunk.replace(/\r\n/g, "\n").replace(/[\u200b\u00ad]/g, "").replace(/\s\s*/g, " ")).join("\xA0").trim(); +} +function queryInAriaOwned(element, selector) { + const result = [...element.querySelectorAll(selector)]; + for (const owned of getIdRefs(element, element.getAttribute("aria-owns"))) { + if (owned.matches(selector)) + result.push(owned); + result.push(...owned.querySelectorAll(selector)); + } + return result; +} +function getCSSContent(element, pseudo) { + const cache = pseudo === "::before" ? cachePseudoContentBefore : pseudo === "::after" ? cachePseudoContentAfter : cachePseudoContent; + if (cache == null ? void 0 : cache.has(element)) + return cache == null ? void 0 : cache.get(element); + const style = getElementComputedStyle(element, pseudo); + let content; + if (style) { + const contentValue = style.content; + if (contentValue && contentValue !== "none" && contentValue !== "normal") { + if (style.display !== "none" && style.visibility !== "hidden") { + content = parseCSSContentPropertyAsString(element, contentValue, !!pseudo); + } + } + } + if (pseudo && content !== void 0) { + const display = (style == null ? void 0 : style.display) || "inline"; + if (display !== "inline") + content = " " + content + " "; + } + if (cache) + cache.set(element, content); + return content; +} +function parseCSSContentPropertyAsString(element, content, isPseudo) { + if (!content || content === "none" || content === "normal") { + return; + } + try { + let tokens = tokenize(content).filter((token) => !(token instanceof WhitespaceToken)); + const delimIndex = tokens.findIndex((token) => token instanceof DelimToken && token.value === "/"); + if (delimIndex !== -1) { + tokens = tokens.slice(delimIndex + 1); + } else if (!isPseudo) { + return; + } + const accumulated = []; + let index = 0; + while (index < tokens.length) { + if (tokens[index] instanceof StringToken) { + accumulated.push(tokens[index].value); + index++; + } else if (index + 2 < tokens.length && tokens[index] instanceof FunctionToken && tokens[index].value === "attr" && tokens[index + 1] instanceof IdentToken && tokens[index + 2] instanceof CloseParenToken) { + const attrName = tokens[index + 1].value; + accumulated.push(element.getAttribute(attrName) || ""); + index += 3; + } else { + return; + } + } + return accumulated.join(""); + } catch { + } +} +function getAriaLabelledByElements(element) { + const ref = element.getAttribute("aria-labelledby"); + if (ref === null) + return null; + const refs = getIdRefs(element, ref); + return refs.length ? refs : null; +} +function allowsNameFromContent(role, targetDescendant) { + const alwaysAllowsNameFromContent = ["button", "cell", "checkbox", "columnheader", "gridcell", "heading", "link", "menuitem", "menuitemcheckbox", "menuitemradio", "option", "radio", "row", "rowheader", "switch", "tab", "tooltip", "treeitem"].includes(role); + const descendantAllowsNameFromContent = targetDescendant && ["", "caption", "code", "contentinfo", "definition", "deletion", "emphasis", "insertion", "list", "listitem", "mark", "none", "paragraph", "presentation", "region", "row", "rowgroup", "section", "strong", "subscript", "superscript", "table", "term", "time"].includes(role); + return alwaysAllowsNameFromContent || descendantAllowsNameFromContent; +} +function getElementAccessibleName(element, includeHidden) { + const cache = includeHidden ? cacheAccessibleNameHidden : cacheAccessibleName; + let accessibleName = cache == null ? void 0 : cache.get(element); + if (accessibleName === void 0) { + accessibleName = ""; + const elementProhibitsNaming = ["caption", "code", "definition", "deletion", "emphasis", "generic", "insertion", "mark", "paragraph", "presentation", "strong", "subscript", "suggestion", "superscript", "term", "time"].includes(getAriaRole(element) || ""); + if (!elementProhibitsNaming) { + accessibleName = asFlatString(getTextAlternativeInternal(element, { + includeHidden, + visitedElements: /* @__PURE__ */ new Set(), + embeddedInTargetElement: "self" + })); + } + cache == null ? void 0 : cache.set(element, accessibleName); + } + return accessibleName; +} +function getElementAccessibleDescription(element, includeHidden) { + const cache = includeHidden ? cacheAccessibleDescriptionHidden : cacheAccessibleDescription; + let accessibleDescription = cache == null ? void 0 : cache.get(element); + if (accessibleDescription === void 0) { + accessibleDescription = ""; + if (element.hasAttribute("aria-describedby")) { + const describedBy = getIdRefs(element, element.getAttribute("aria-describedby")); + accessibleDescription = asFlatString(describedBy.map((ref) => getTextAlternativeInternal(ref, { + includeHidden, + visitedElements: /* @__PURE__ */ new Set(), + embeddedInDescribedBy: { element: ref, hidden: isElementHiddenForAria(ref) } + })).join(" ")); + } else if (element.hasAttribute("aria-description")) { + accessibleDescription = asFlatString(element.getAttribute("aria-description") || ""); + } else { + accessibleDescription = asFlatString(element.getAttribute("title") || ""); + } + cache == null ? void 0 : cache.set(element, accessibleDescription); + } + return accessibleDescription; +} +var kAriaInvalidRoles = [ + "application", + "checkbox", + "columnheader", + "combobox", + "gridcell", + "listbox", + "radiogroup", + "rowheader", + "searchbox", + "slider", + "spinbutton", + "switch", + "textbox", + "tree" +]; +function getAriaInvalid(element) { + const ariaInvalid = element.getAttribute("aria-invalid"); + if (!ariaInvalid || ariaInvalid.trim() === "" || ariaInvalid.toLocaleLowerCase() === "false") + return "false"; + if (ariaInvalid === "true" || ariaInvalid === "grammar" || ariaInvalid === "spelling") + return ariaInvalid; + return "true"; +} +function getValidityInvalid(element) { + if ("validity" in element) { + const validity = element.validity; + return (validity == null ? void 0 : validity.valid) === false; + } + return false; +} +function getElementAccessibleErrorMessage(element) { + const cache = cacheAccessibleErrorMessage; + let accessibleErrorMessage = cacheAccessibleErrorMessage == null ? void 0 : cacheAccessibleErrorMessage.get(element); + if (accessibleErrorMessage === void 0) { + accessibleErrorMessage = ""; + const isAriaInvalid = getAriaInvalid(element) !== "false"; + const isValidityInvalid = getValidityInvalid(element); + if (isAriaInvalid || isValidityInvalid) { + const errorMessageId = element.getAttribute("aria-errormessage"); + const errorMessages = getIdRefs(element, errorMessageId); + const parts = errorMessages.map((errorMessage) => asFlatString( + getTextAlternativeInternal(errorMessage, { + visitedElements: /* @__PURE__ */ new Set(), + embeddedInDescribedBy: { element: errorMessage, hidden: isElementHiddenForAria(errorMessage) } + }) + )); + accessibleErrorMessage = parts.join(" ").trim(); + } + cache == null ? void 0 : cache.set(element, accessibleErrorMessage); + } + return accessibleErrorMessage; +} +function getTextAlternativeInternal(element, options) { + var _a, _b, _c, _d; + if (options.visitedElements.has(element)) + return ""; + const childOptions = { + ...options, + embeddedInTargetElement: options.embeddedInTargetElement === "self" ? "descendant" : options.embeddedInTargetElement + }; + if (!options.includeHidden) { + const isEmbeddedInHiddenReferenceTraversal = !!((_a = options.embeddedInLabelledBy) == null ? void 0 : _a.hidden) || !!((_b = options.embeddedInDescribedBy) == null ? void 0 : _b.hidden) || !!((_c = options.embeddedInNativeTextAlternative) == null ? void 0 : _c.hidden) || !!((_d = options.embeddedInLabel) == null ? void 0 : _d.hidden); + if (isElementIgnoredForAria(element) || !isEmbeddedInHiddenReferenceTraversal && isElementHiddenForAria(element)) { + options.visitedElements.add(element); + return ""; + } + } + const labelledBy = getAriaLabelledByElements(element); + if (!options.embeddedInLabelledBy) { + const accessibleName = (labelledBy || []).map((ref) => getTextAlternativeInternal(ref, { + ...options, + embeddedInLabelledBy: { element: ref, hidden: isElementHiddenForAria(ref) }, + embeddedInDescribedBy: void 0, + embeddedInTargetElement: void 0, + embeddedInLabel: void 0, + embeddedInNativeTextAlternative: void 0 + })).join(" "); + if (accessibleName) + return accessibleName; + } + const role = getAriaRole(element) || ""; + const tagName = elementSafeTagName(element); + if (!!options.embeddedInLabel || !!options.embeddedInLabelledBy || options.embeddedInTargetElement === "descendant") { + const isOwnLabel = [...element.labels || []].includes(element); + const isOwnLabelledBy = (labelledBy || []).includes(element); + if (!isOwnLabel && !isOwnLabelledBy) { + if (role === "textbox") { + options.visitedElements.add(element); + if (tagName === "INPUT" || tagName === "TEXTAREA") + return element.value; + return element.textContent || ""; + } + if (["combobox", "listbox"].includes(role)) { + options.visitedElements.add(element); + let selectedOptions; + if (tagName === "SELECT") { + selectedOptions = [...element.selectedOptions]; + if (!selectedOptions.length && element.options.length) + selectedOptions.push(element.options[0]); + } else { + const listbox = role === "combobox" ? queryInAriaOwned(element, "*").find((e) => getAriaRole(e) === "listbox") : element; + selectedOptions = listbox ? queryInAriaOwned(listbox, '[aria-selected="true"]').filter((e) => getAriaRole(e) === "option") : []; + } + if (!selectedOptions.length && tagName === "INPUT") { + return element.value; + } + return selectedOptions.map((option) => getTextAlternativeInternal(option, childOptions)).join(" "); + } + if (["progressbar", "scrollbar", "slider", "spinbutton", "meter"].includes(role)) { + options.visitedElements.add(element); + if (element.hasAttribute("aria-valuetext")) + return element.getAttribute("aria-valuetext") || ""; + if (element.hasAttribute("aria-valuenow")) + return element.getAttribute("aria-valuenow") || ""; + return element.getAttribute("value") || ""; + } + if (["menu"].includes(role)) { + options.visitedElements.add(element); + return ""; + } + } + } + const ariaLabel = element.getAttribute("aria-label") || ""; + if (trimFlatString(ariaLabel)) { + options.visitedElements.add(element); + return ariaLabel; + } + if (!["presentation", "none"].includes(role)) { + if (tagName === "INPUT" && ["button", "submit", "reset"].includes(element.type)) { + options.visitedElements.add(element); + const value = element.value || ""; + if (trimFlatString(value)) + return value; + if (element.type === "submit") + return "Submit"; + if (element.type === "reset") + return "Reset"; + const title = element.getAttribute("title") || ""; + return title; + } + if (tagName === "INPUT" && element.type === "file") { + options.visitedElements.add(element); + const labels = element.labels || []; + if (labels.length && !options.embeddedInLabelledBy) + return getAccessibleNameFromAssociatedLabels(labels, options); + return "Choose File"; + } + if (tagName === "INPUT" && element.type === "image") { + options.visitedElements.add(element); + const labels = element.labels || []; + if (labels.length && !options.embeddedInLabelledBy) + return getAccessibleNameFromAssociatedLabels(labels, options); + const alt = element.getAttribute("alt") || ""; + if (trimFlatString(alt)) + return alt; + const title = element.getAttribute("title") || ""; + if (trimFlatString(title)) + return title; + return "Submit"; + } + if (!labelledBy && tagName === "BUTTON") { + options.visitedElements.add(element); + const labels = element.labels || []; + if (labels.length) + return getAccessibleNameFromAssociatedLabels(labels, options); + } + if (!labelledBy && tagName === "OUTPUT") { + options.visitedElements.add(element); + const labels = element.labels || []; + if (labels.length) + return getAccessibleNameFromAssociatedLabels(labels, options); + return element.getAttribute("title") || ""; + } + if (!labelledBy && (tagName === "TEXTAREA" || tagName === "SELECT" || tagName === "INPUT")) { + options.visitedElements.add(element); + const labels = element.labels || []; + if (labels.length) + return getAccessibleNameFromAssociatedLabels(labels, options); + const usePlaceholder = tagName === "INPUT" && ["text", "password", "search", "tel", "email", "url"].includes(element.type) || tagName === "TEXTAREA"; + const placeholder = element.getAttribute("placeholder") || ""; + const title = element.getAttribute("title") || ""; + if (!usePlaceholder || title) + return title; + return placeholder; + } + if (!labelledBy && tagName === "FIELDSET") { + options.visitedElements.add(element); + for (let child = element.firstElementChild; child; child = child.nextElementSibling) { + if (elementSafeTagName(child) === "LEGEND") { + return getTextAlternativeInternal(child, { + ...childOptions, + embeddedInNativeTextAlternative: { element: child, hidden: isElementHiddenForAria(child) } + }); + } + } + const title = element.getAttribute("title") || ""; + return title; + } + if (!labelledBy && tagName === "FIGURE") { + options.visitedElements.add(element); + for (let child = element.firstElementChild; child; child = child.nextElementSibling) { + if (elementSafeTagName(child) === "FIGCAPTION") { + return getTextAlternativeInternal(child, { + ...childOptions, + embeddedInNativeTextAlternative: { element: child, hidden: isElementHiddenForAria(child) } + }); + } + } + const title = element.getAttribute("title") || ""; + return title; + } + if (tagName === "IMG") { + options.visitedElements.add(element); + const alt = element.getAttribute("alt") || ""; + if (trimFlatString(alt)) + return alt; + const title = element.getAttribute("title") || ""; + return title; + } + if (tagName === "TABLE") { + options.visitedElements.add(element); + for (let child = element.firstElementChild; child; child = child.nextElementSibling) { + if (elementSafeTagName(child) === "CAPTION") { + return getTextAlternativeInternal(child, { + ...childOptions, + embeddedInNativeTextAlternative: { element: child, hidden: isElementHiddenForAria(child) } + }); + } + } + const summary = element.getAttribute("summary") || ""; + if (summary) + return summary; + } + if (tagName === "AREA") { + options.visitedElements.add(element); + const alt = element.getAttribute("alt") || ""; + if (trimFlatString(alt)) + return alt; + const title = element.getAttribute("title") || ""; + return title; + } + if (tagName === "SVG" || element.ownerSVGElement) { + options.visitedElements.add(element); + for (let child = element.firstElementChild; child; child = child.nextElementSibling) { + if (elementSafeTagName(child) === "TITLE" && child.ownerSVGElement) { + return getTextAlternativeInternal(child, { + ...childOptions, + embeddedInLabelledBy: { element: child, hidden: isElementHiddenForAria(child) } + }); + } + } + } + if (element.ownerSVGElement && tagName === "A") { + const title = element.getAttribute("xlink:title") || ""; + if (trimFlatString(title)) { + options.visitedElements.add(element); + return title; + } + } + } + const shouldNameFromContentForSummary = tagName === "SUMMARY" && !["presentation", "none"].includes(role); + if (allowsNameFromContent(role, options.embeddedInTargetElement === "descendant") || shouldNameFromContentForSummary || !!options.embeddedInLabelledBy || !!options.embeddedInDescribedBy || !!options.embeddedInLabel || !!options.embeddedInNativeTextAlternative) { + options.visitedElements.add(element); + const accessibleName = innerAccumulatedElementText(element, childOptions); + const maybeTrimmedAccessibleName = options.embeddedInTargetElement === "self" ? trimFlatString(accessibleName) : accessibleName; + if (maybeTrimmedAccessibleName) + return accessibleName; + } + if (!["presentation", "none"].includes(role) || tagName === "IFRAME") { + options.visitedElements.add(element); + const title = element.getAttribute("title") || ""; + if (trimFlatString(title)) + return title; + } + options.visitedElements.add(element); + return ""; +} +function innerAccumulatedElementText(element, options) { + const tokens = []; + const visit = (node, skipSlotted) => { + var _a; + if (skipSlotted && node.assignedSlot) + return; + if (node.nodeType === 1) { + const display = ((_a = getElementComputedStyle(node)) == null ? void 0 : _a.display) || "inline"; + let token = getTextAlternativeInternal(node, options); + if (display !== "inline" || node.nodeName === "BR") + token = " " + token + " "; + tokens.push(token); + } else if (node.nodeType === 3) { + tokens.push(node.textContent || ""); + } + }; + tokens.push(getCSSContent(element, "::before") || ""); + const content = getCSSContent(element); + if (content !== void 0) { + tokens.push(content); + } else { + const assignedNodes = element.nodeName === "SLOT" ? element.assignedNodes() : []; + if (assignedNodes.length) { + for (const child of assignedNodes) + visit(child, false); + } else { + for (let child = element.firstChild; child; child = child.nextSibling) + visit(child, true); + if (element.shadowRoot) { + for (let child = element.shadowRoot.firstChild; child; child = child.nextSibling) + visit(child, true); + } + for (const owned of getIdRefs(element, element.getAttribute("aria-owns"))) + visit(owned, true); + } + } + tokens.push(getCSSContent(element, "::after") || ""); + return tokens.join(""); +} +var kAriaSelectedRoles = ["gridcell", "option", "row", "tab", "rowheader", "columnheader", "treeitem"]; +function getAriaSelected(element) { + if (elementSafeTagName(element) === "OPTION") + return element.selected; + if (kAriaSelectedRoles.includes(getAriaRole(element) || "")) + return getAriaBoolean(element.getAttribute("aria-selected")) === true; + return false; +} +var kAriaCheckedRoles = ["checkbox", "menuitemcheckbox", "option", "radio", "switch", "menuitemradio", "treeitem"]; +function getAriaChecked(element) { + const result = getChecked(element, true); + return result === "error" ? false : result; +} +function getCheckedAllowMixed(element) { + return getChecked(element, true); +} +function getCheckedWithoutMixed(element) { + const result = getChecked(element, false); + return result; +} +function getChecked(element, allowMixed) { + const tagName = elementSafeTagName(element); + if (allowMixed && tagName === "INPUT" && element.indeterminate) + return "mixed"; + if (tagName === "INPUT" && ["checkbox", "radio"].includes(element.type)) + return element.checked; + if (kAriaCheckedRoles.includes(getAriaRole(element) || "")) { + const checked = element.getAttribute("aria-checked"); + if (checked === "true") + return true; + if (allowMixed && checked === "mixed") + return "mixed"; + return false; + } + return "error"; +} +var kAriaReadonlyRoles = ["checkbox", "combobox", "grid", "gridcell", "listbox", "radiogroup", "slider", "spinbutton", "textbox", "columnheader", "rowheader", "searchbox", "switch", "treegrid"]; +function getReadonly(element) { + const tagName = elementSafeTagName(element); + if (["INPUT", "TEXTAREA", "SELECT"].includes(tagName)) + return element.hasAttribute("readonly"); + if (kAriaReadonlyRoles.includes(getAriaRole(element) || "")) + return element.getAttribute("aria-readonly") === "true"; + if (element.isContentEditable) + return false; + return "error"; +} +var kAriaPressedRoles = ["button"]; +function getAriaPressed(element) { + if (kAriaPressedRoles.includes(getAriaRole(element) || "")) { + const pressed = element.getAttribute("aria-pressed"); + if (pressed === "true") + return true; + if (pressed === "mixed") + return "mixed"; + } + return false; +} +var kAriaExpandedRoles = ["application", "button", "checkbox", "combobox", "gridcell", "link", "listbox", "menuitem", "row", "rowheader", "tab", "treeitem", "columnheader", "menuitemcheckbox", "menuitemradio", "rowheader", "switch"]; +function getAriaExpanded(element) { + if (elementSafeTagName(element) === "DETAILS") + return element.open; + if (kAriaExpandedRoles.includes(getAriaRole(element) || "")) { + const expanded = element.getAttribute("aria-expanded"); + if (expanded === null) + return void 0; + if (expanded === "true") + return true; + return false; + } + return void 0; +} +var kAriaLevelRoles = ["heading", "listitem", "row", "treeitem"]; +function getAriaLevel(element) { + const native = { "H1": 1, "H2": 2, "H3": 3, "H4": 4, "H5": 5, "H6": 6 }[elementSafeTagName(element)]; + if (native) + return native; + if (kAriaLevelRoles.includes(getAriaRole(element) || "")) { + const attr = element.getAttribute("aria-level"); + const value = attr === null ? Number.NaN : Number(attr); + if (Number.isInteger(value) && value >= 1) + return value; + } + return 0; +} +var kAriaDisabledRoles = ["application", "button", "composite", "gridcell", "group", "input", "link", "menuitem", "scrollbar", "separator", "tab", "checkbox", "columnheader", "combobox", "grid", "listbox", "menu", "menubar", "menuitemcheckbox", "menuitemradio", "option", "radio", "radiogroup", "row", "rowheader", "searchbox", "select", "slider", "spinbutton", "switch", "tablist", "textbox", "toolbar", "tree", "treegrid", "treeitem"]; +function getAriaDisabled(element) { + return isNativelyDisabled(element) || hasExplicitAriaDisabled(element); +} +function isNativelyDisabled(element) { + const isNativeFormControl = ["BUTTON", "INPUT", "SELECT", "TEXTAREA", "OPTION", "OPTGROUP"].includes(elementSafeTagName(element)); + return isNativeFormControl && (element.hasAttribute("disabled") || belongsToDisabledOptGroup(element) || belongsToDisabledFieldSet(element)); +} +function belongsToDisabledOptGroup(element) { + return elementSafeTagName(element) === "OPTION" && !!element.closest("OPTGROUP[DISABLED]"); +} +function belongsToDisabledFieldSet(element) { + const fieldSetElement = element == null ? void 0 : element.closest("FIELDSET[DISABLED]"); + if (!fieldSetElement) + return false; + const legendElement = fieldSetElement.querySelector(":scope > LEGEND"); + return !legendElement || !legendElement.contains(element); +} +function hasExplicitAriaDisabled(element, isAncestor = false) { + if (!element) + return false; + if (isAncestor || kAriaDisabledRoles.includes(getAriaRole(element) || "")) { + const attribute = (element.getAttribute("aria-disabled") || "").toLowerCase(); + if (attribute === "true") + return true; + if (attribute === "false") + return false; + return hasExplicitAriaDisabled(parentElementOrShadowHost(element), true); + } + return false; +} +function getAccessibleNameFromAssociatedLabels(labels, options) { + return [...labels].map((label) => getTextAlternativeInternal(label, { + ...options, + embeddedInLabel: { element: label, hidden: isElementHiddenForAria(label) }, + embeddedInNativeTextAlternative: void 0, + embeddedInLabelledBy: void 0, + embeddedInDescribedBy: void 0, + embeddedInTargetElement: void 0 + })).filter((accessibleName) => !!accessibleName).join(" "); +} +function receivesPointerEvents(element) { + const cache = cachePointerEvents; + let e = element; + let result; + const parents = []; + for (; e; e = parentElementOrShadowHost(e)) { + const cached = cache.get(e); + if (cached !== void 0) { + result = cached; + break; + } + parents.push(e); + const style = getElementComputedStyle(e); + if (!style) { + result = true; + break; + } + const value = style.pointerEvents; + if (value) { + result = value !== "none"; + break; + } + } + if (result === void 0) + result = true; + for (const parent of parents) + cache.set(parent, result); + return result; +} +var cacheAccessibleName; +var cacheAccessibleNameHidden; +var cacheAccessibleDescription; +var cacheAccessibleDescriptionHidden; +var cacheAccessibleErrorMessage; +var cacheIsHidden; +var cachePseudoContent; +var cachePseudoContentBefore; +var cachePseudoContentAfter; +var cachePointerEvents; +var cachesCounter2 = 0; +function beginAriaCaches() { + beginDOMCaches(); + ++cachesCounter2; + cacheAccessibleName != null ? cacheAccessibleName : cacheAccessibleName = /* @__PURE__ */ new Map(); + cacheAccessibleNameHidden != null ? cacheAccessibleNameHidden : cacheAccessibleNameHidden = /* @__PURE__ */ new Map(); + cacheAccessibleDescription != null ? cacheAccessibleDescription : cacheAccessibleDescription = /* @__PURE__ */ new Map(); + cacheAccessibleDescriptionHidden != null ? cacheAccessibleDescriptionHidden : cacheAccessibleDescriptionHidden = /* @__PURE__ */ new Map(); + cacheAccessibleErrorMessage != null ? cacheAccessibleErrorMessage : cacheAccessibleErrorMessage = /* @__PURE__ */ new Map(); + cacheIsHidden != null ? cacheIsHidden : cacheIsHidden = /* @__PURE__ */ new Map(); + cachePseudoContent != null ? cachePseudoContent : cachePseudoContent = /* @__PURE__ */ new Map(); + cachePseudoContentBefore != null ? cachePseudoContentBefore : cachePseudoContentBefore = /* @__PURE__ */ new Map(); + cachePseudoContentAfter != null ? cachePseudoContentAfter : cachePseudoContentAfter = /* @__PURE__ */ new Map(); + cachePointerEvents != null ? cachePointerEvents : cachePointerEvents = /* @__PURE__ */ new Map(); +} +function endAriaCaches() { + if (!--cachesCounter2) { + cacheAccessibleName = void 0; + cacheAccessibleNameHidden = void 0; + cacheAccessibleDescription = void 0; + cacheAccessibleDescriptionHidden = void 0; + cacheAccessibleErrorMessage = void 0; + cacheIsHidden = void 0; + cachePseudoContent = void 0; + cachePseudoContentBefore = void 0; + cachePseudoContentAfter = void 0; + cachePointerEvents = void 0; + } + endDOMCaches(); +} +var inputTypeToRole = { + "button": "button", + "checkbox": "checkbox", + "image": "button", + "number": "spinbutton", + "radio": "radio", + "range": "slider", + "reset": "button", + "submit": "button" +}; + +// packages/injected/src/ariaSnapshot.ts +var lastRef = 0; +function toInternalOptions(options) { + const renderBoxes = options.boxes; + if (options.mode === "ai") { + return { + visibility: "ariaOrVisible", + refs: "interactable", + refPrefix: options.refPrefix, + includeGenericRole: true, + renderActive: !options.doNotRenderActive, + renderCursorPointer: true, + renderBoxes + }; + } + if (options.mode === "autoexpect") { + return { visibility: "ariaAndVisible", refs: "none", renderBoxes }; + } + if (options.mode === "codegen") { + return { visibility: "aria", refs: "none", renderStringsAsRegex: true, renderBoxes }; + } + return { visibility: "aria", refs: "none", renderBoxes }; +} +function generateAriaTree(rootElement, publicOptions) { + const options = toInternalOptions(publicOptions); + const visited = /* @__PURE__ */ new Set(); + const snapshot = { + root: { role: "fragment", name: "", children: [], props: {}, box: computeBox(rootElement), receivesPointerEvents: true }, + elements: /* @__PURE__ */ new Map(), + refs: /* @__PURE__ */ new Map(), + iframeRefs: [] + }; + setAriaNodeElement(snapshot.root, rootElement); + const visit = (ariaNode, node, parentElementVisible) => { + if (visited.has(node)) + return; + visited.add(node); + if (node.nodeType === Node.TEXT_NODE && node.nodeValue) { + if (!parentElementVisible) + return; + const text = node.nodeValue; + if (ariaNode.role !== "textbox" && text) + ariaNode.children.push(node.nodeValue || ""); + return; + } + if (node.nodeType !== Node.ELEMENT_NODE) + return; + const element = node; + const isElementVisibleForAria = !isElementHiddenForAria(element); + let visible = isElementVisibleForAria; + if (options.visibility === "ariaOrVisible") + visible = isElementVisibleForAria || isElementVisible(element); + if (options.visibility === "ariaAndVisible") + visible = isElementVisibleForAria && isElementVisible(element); + if (options.visibility === "aria" && !visible) + return; + const ariaChildren = []; + if (element.hasAttribute("aria-owns")) { + const ids = element.getAttribute("aria-owns").split(/\s+/); + for (const id of ids) { + const ownedElement = rootElement.ownerDocument.getElementById(id); + if (ownedElement) + ariaChildren.push(ownedElement); + } + } + const childAriaNode = visible ? toAriaNode(element, options) : null; + if (childAriaNode) { + if (childAriaNode.ref) { + snapshot.elements.set(childAriaNode.ref, element); + snapshot.refs.set(element, childAriaNode.ref); + if (childAriaNode.role === "iframe") + snapshot.iframeRefs.push(childAriaNode.ref); + } + ariaNode.children.push(childAriaNode); + } + processElement(childAriaNode || ariaNode, element, ariaChildren, visible); + }; + function processElement(ariaNode, element, ariaChildren, parentElementVisible) { + var _a; + const display = ((_a = getElementComputedStyle(element)) == null ? void 0 : _a.display) || "inline"; + const treatAsBlock = display !== "inline" || element.nodeName === "BR" ? " " : ""; + if (treatAsBlock) + ariaNode.children.push(treatAsBlock); + ariaNode.children.push(getCSSContent(element, "::before") || ""); + const assignedNodes = element.nodeName === "SLOT" ? element.assignedNodes() : []; + if (assignedNodes.length) { + for (const child of assignedNodes) + visit(ariaNode, child, parentElementVisible); + } else { + for (let child = element.firstChild; child; child = child.nextSibling) { + if (!child.assignedSlot) + visit(ariaNode, child, parentElementVisible); + } + if (element.shadowRoot) { + for (let child = element.shadowRoot.firstChild; child; child = child.nextSibling) + visit(ariaNode, child, parentElementVisible); + } + } + for (const child of ariaChildren) + visit(ariaNode, child, parentElementVisible); + ariaNode.children.push(getCSSContent(element, "::after") || ""); + if (treatAsBlock) + ariaNode.children.push(treatAsBlock); + if (ariaNode.children.length === 1 && ariaNode.name === ariaNode.children[0]) + ariaNode.children = []; + if (ariaNode.role === "link" && element.hasAttribute("href")) { + const href = element.getAttribute("href"); + ariaNode.props["url"] = href; + } + if (ariaNode.role === "textbox" && element.hasAttribute("placeholder") && element.getAttribute("placeholder") !== ariaNode.name) { + const placeholder = element.getAttribute("placeholder"); + ariaNode.props["placeholder"] = placeholder; + } + } + beginAriaCaches(); + try { + visit(snapshot.root, rootElement, true); + } finally { + endAriaCaches(); + } + normalizeStringChildren(snapshot.root); + normalizeGenericRoles(snapshot.root); + return snapshot; +} +function computeAriaRef(ariaNode, options) { + var _a; + if (options.refs === "none") + return; + if (options.refs === "interactable" && (!ariaNode.box.visible || !ariaNode.receivesPointerEvents)) + return; + const element = ariaNodeElement(ariaNode); + let ariaRef = element._ariaRef; + if (!ariaRef || ariaRef.role !== ariaNode.role || ariaRef.name !== ariaNode.name) { + ariaRef = { role: ariaNode.role, name: ariaNode.name, ref: ((_a = options.refPrefix) != null ? _a : "") + "e" + ++lastRef }; + element._ariaRef = ariaRef; + } + ariaNode.ref = ariaRef.ref; +} +function toAriaNode(element, options) { + var _a; + const active = element.ownerDocument.activeElement === element; + if (element.nodeName === "IFRAME") { + const ariaNode = { + role: "iframe", + name: "", + children: [], + props: {}, + box: computeBox(element), + receivesPointerEvents: true, + active + }; + setAriaNodeElement(ariaNode, element); + computeAriaRef(ariaNode, options); + return ariaNode; + } + const defaultRole = options.includeGenericRole ? "generic" : null; + const role = (_a = getAriaRole(element)) != null ? _a : defaultRole; + if (!role || role === "presentation" || role === "none") + return null; + const name = normalizeWhiteSpace(getElementAccessibleName(element, false) || ""); + const receivesPointerEvents2 = receivesPointerEvents(element); + const box = computeBox(element); + if (role === "generic" && box.inline && element.childNodes.length === 1 && element.childNodes[0].nodeType === Node.TEXT_NODE) + return null; + const result = { + role, + name, + children: [], + props: {}, + box, + receivesPointerEvents: receivesPointerEvents2, + active + }; + setAriaNodeElement(result, element); + computeAriaRef(result, options); + if (kAriaCheckedRoles.includes(role)) + result.checked = getAriaChecked(element); + if (kAriaDisabledRoles.includes(role)) + result.disabled = getAriaDisabled(element); + if (kAriaExpandedRoles.includes(role)) + result.expanded = getAriaExpanded(element); + if (kAriaInvalidRoles.includes(role)) { + const invalid = getAriaInvalid(element); + result.invalid = invalid === "false" ? false : invalid === "true" ? true : invalid; + } + if (kAriaLevelRoles.includes(role)) + result.level = getAriaLevel(element); + if (kAriaPressedRoles.includes(role)) + result.pressed = getAriaPressed(element); + if (kAriaSelectedRoles.includes(role)) + result.selected = getAriaSelected(element); + if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) { + if (element.type !== "checkbox" && element.type !== "radio" && element.type !== "file") + result.children = [element.value]; + } + return result; +} +function normalizeGenericRoles(node) { + const normalizeChildren = (node2) => { + const result = []; + for (const child of node2.children || []) { + if (typeof child === "string") { + result.push(child); + continue; + } + const normalized = normalizeChildren(child); + result.push(...normalized); + } + const removeSelf = node2.role === "generic" && !node2.name && result.length <= 1 && result.every((c) => typeof c !== "string" && !!c.ref); + if (removeSelf) + return result; + node2.children = result; + return [node2]; + }; + normalizeChildren(node); +} +function normalizeStringChildren(rootA11yNode) { + const flushChildren = (buffer, normalizedChildren) => { + if (!buffer.length) + return; + const text = normalizeWhiteSpace(buffer.join("")); + if (text) + normalizedChildren.push(text); + buffer.length = 0; + }; + const visit = (ariaNode) => { + const normalizedChildren = []; + const buffer = []; + for (const child of ariaNode.children || []) { + if (typeof child === "string") { + buffer.push(child); + } else { + flushChildren(buffer, normalizedChildren); + visit(child); + normalizedChildren.push(child); + } + } + flushChildren(buffer, normalizedChildren); + ariaNode.children = normalizedChildren.length ? normalizedChildren : []; + if (ariaNode.children.length === 1 && ariaNode.children[0] === ariaNode.name) + ariaNode.children = []; + }; + visit(rootA11yNode); +} +function matchesStringOrRegex(text, template) { + if (!template) + return true; + if (!text) + return false; + if (typeof template === "string") + return text === template; + return !!text.match(new RegExp(template.pattern)); +} +function matchesTextValue(text, template) { + if (!(template == null ? void 0 : template.normalized)) + return true; + if (!text) + return false; + if (text === template.normalized) + return true; + if (text === template.raw) + return true; + const regex = cachedRegex(template); + if (regex) + return !!text.match(regex); + return false; +} +var cachedRegexSymbol = Symbol("cachedRegex"); +function cachedRegex(template) { + if (template[cachedRegexSymbol] !== void 0) + return template[cachedRegexSymbol]; + const { raw } = template; + const canBeRegex = raw.startsWith("/") && raw.endsWith("/") && raw.length > 1; + let regex; + try { + regex = canBeRegex ? new RegExp(raw.slice(1, -1)) : null; + } catch (e) { + regex = null; + } + template[cachedRegexSymbol] = regex; + return regex; +} +function matchesExpectAriaTemplate(rootElement, template) { + const snapshot = generateAriaTree(rootElement, { mode: "default" }); + const matches = matchesNodeDeep(snapshot.root, template, false, false); + return { + matches, + received: { + raw: renderAriaTree(snapshot, { mode: "default" }).text, + regex: renderAriaTree(snapshot, { mode: "codegen" }).text + } + }; +} +function getAllElementsMatchingExpectAriaTemplate(rootElement, template) { + const root = generateAriaTree(rootElement, { mode: "default" }).root; + const matches = matchesNodeDeep(root, template, true, false); + return matches.map((n) => ariaNodeElement(n)); +} +function matchesNode(node, template, isDeepEqual) { + var _a; + if (typeof node === "string" && template.kind === "text") + return matchesTextValue(node, template.text); + if (node === null || typeof node !== "object" || template.kind !== "role") + return false; + if (template.role !== "fragment" && template.role !== node.role) + return false; + if (template.checked !== void 0 && template.checked !== node.checked) + return false; + if (template.disabled !== void 0 && template.disabled !== node.disabled) + return false; + if (template.expanded !== void 0 && template.expanded !== node.expanded) + return false; + if (template.invalid !== void 0 && template.invalid !== node.invalid) + return false; + if (template.level !== void 0 && template.level !== node.level) + return false; + if (template.pressed !== void 0 && template.pressed !== node.pressed) + return false; + if (template.selected !== void 0 && template.selected !== node.selected) + return false; + if (!matchesStringOrRegex(node.name, template.name)) + return false; + if (!matchesTextValue(node.props.url, (_a = template.props) == null ? void 0 : _a.url)) + return false; + if (template.containerMode === "contain") + return containsList(node.children || [], template.children || []); + if (template.containerMode === "equal") + return listEqual(node.children || [], template.children || [], false); + if (template.containerMode === "deep-equal" || isDeepEqual) + return listEqual(node.children || [], template.children || [], true); + return containsList(node.children || [], template.children || []); +} +function listEqual(children, template, isDeepEqual) { + if (template.length !== children.length) + return false; + for (let i = 0; i < template.length; ++i) { + if (!matchesNode(children[i], template[i], isDeepEqual)) + return false; + } + return true; +} +function containsList(children, template) { + if (template.length > children.length) + return false; + const cc = children.slice(); + const tt = template.slice(); + for (const t of tt) { + let c = cc.shift(); + while (c) { + if (matchesNode(c, t, false)) + break; + c = cc.shift(); + } + if (!c) + return false; + } + return true; +} +function matchesNodeDeep(root, template, collectAll, isDeepEqual) { + const results = []; + const visit = (node, parent) => { + if (matchesNode(node, template, isDeepEqual)) { + const result = typeof node === "string" ? parent : node; + if (result) + results.push(result); + return !collectAll; + } + if (typeof node === "string") + return false; + for (const child of node.children || []) { + if (visit(child, node)) + return true; + } + return false; + }; + visit(root, null); + return results; +} +function buildByRefMap(root, map = /* @__PURE__ */ new Map()) { + if (root == null ? void 0 : root.ref) + map.set(root.ref, root); + for (const child of (root == null ? void 0 : root.children) || []) { + if (typeof child !== "string") + buildByRefMap(child, map); + } + return map; +} +function compareSnapshots(ariaSnapshot, previousSnapshot) { + var _a; + const previousByRef = buildByRefMap(previousSnapshot == null ? void 0 : previousSnapshot.root); + const result = /* @__PURE__ */ new Map(); + const visit = (ariaNode, previousNode) => { + let same = ariaNode.children.length === (previousNode == null ? void 0 : previousNode.children.length) && ariaNodesEqual(ariaNode, previousNode); + let canBeSkipped = same; + for (let childIndex = 0; childIndex < ariaNode.children.length; childIndex++) { + const child = ariaNode.children[childIndex]; + const previousChild = previousNode == null ? void 0 : previousNode.children[childIndex]; + if (typeof child === "string") { + same && (same = child === previousChild); + canBeSkipped && (canBeSkipped = child === previousChild); + } else { + let previous = typeof previousChild !== "string" ? previousChild : void 0; + if (child.ref) + previous = previousByRef.get(child.ref); + const sameChild = visit(child, previous); + if (!previous || !sameChild && !child.ref || previous !== previousChild) + canBeSkipped = false; + same && (same = sameChild && previous === previousChild); + } + } + result.set(ariaNode, same ? "same" : canBeSkipped ? "skip" : "changed"); + return same; + }; + visit(ariaSnapshot.root, previousByRef.get((_a = previousSnapshot == null ? void 0 : previousSnapshot.root) == null ? void 0 : _a.ref)); + return result; +} +function filterSnapshotDiff(nodes, statusMap) { + const result = []; + const visit = (ariaNode) => { + const status = statusMap.get(ariaNode); + if (status === "same") { + } else if (status === "skip") { + for (const child of ariaNode.children) { + if (typeof child !== "string") + visit(child); + } + } else { + result.push(ariaNode); + } + }; + for (const node of nodes) { + if (typeof node === "string") + result.push(node); + else + visit(node); + } + return result; +} +function indent(depth) { + return " ".repeat(depth); +} +function renderAriaTree(ariaSnapshot, publicOptions, previousSnapshot) { + const options = toInternalOptions(publicOptions); + const lines = []; + const iframeDepths = {}; + const includeText = options.renderStringsAsRegex ? textContributesInfo : () => true; + const renderString = options.renderStringsAsRegex ? convertToBestGuessRegex : (str) => str; + let nodesToRender = ariaSnapshot.root.role === "fragment" ? ariaSnapshot.root.children : [ariaSnapshot.root]; + const statusMap = compareSnapshots(ariaSnapshot, previousSnapshot); + if (previousSnapshot) + nodesToRender = filterSnapshotDiff(nodesToRender, statusMap); + const visitText = (text, depth) => { + if (publicOptions.depth && depth > publicOptions.depth) + return; + const escaped = yamlEscapeValueIfNeeded(renderString(text)); + if (escaped) + lines.push(indent(depth) + "- text: " + escaped); + }; + const createKey = (ariaNode, renderCursorPointer) => { + let key = ariaNode.role; + if (ariaNode.name && ariaNode.name.length <= 900) { + const name = renderString(ariaNode.name); + if (name) { + const stringifiedName = name.startsWith("/") && name.endsWith("/") ? name : JSON.stringify(name); + key += " " + stringifiedName; + } + } + if (ariaNode.checked === "mixed") + key += ` [checked=mixed]`; + if (ariaNode.checked === true) + key += ` [checked]`; + if (ariaNode.disabled) + key += ` [disabled]`; + if (ariaNode.expanded) + key += ` [expanded]`; + if (ariaNode.active && options.renderActive) + key += ` [active]`; + if (ariaNode.invalid === "grammar" || ariaNode.invalid === "spelling") + key += ` [invalid=${ariaNode.invalid}]`; + if (ariaNode.invalid === true) + key += ` [invalid]`; + if (ariaNode.level) + key += ` [level=${ariaNode.level}]`; + if (ariaNode.pressed === "mixed") + key += ` [pressed=mixed]`; + if (ariaNode.pressed === true) + key += ` [pressed]`; + if (ariaNode.selected === true) + key += ` [selected]`; + if (ariaNode.ref) { + key += ` [ref=${ariaNode.ref}]`; + if (renderCursorPointer && hasPointerCursor(ariaNode)) + key += " [cursor=pointer]"; + } + if (options.renderBoxes) { + const element = ariaNodeElement(ariaNode); + if (element) { + const r = element.getBoundingClientRect(); + key += ` [box=${Math.round(r.x)},${Math.round(r.y)},${Math.round(r.width)},${Math.round(r.height)}]`; + } + } + return key; + }; + const getSingleInlinedTextChild = (ariaNode) => { + return (ariaNode == null ? void 0 : ariaNode.children.length) === 1 && typeof ariaNode.children[0] === "string" && !Object.keys(ariaNode.props).length ? ariaNode.children[0] : void 0; + }; + const visit = (ariaNode, depth, renderCursorPointer) => { + if (publicOptions.depth && depth > publicOptions.depth) + return; + if (ariaNode.role === "iframe" && ariaNode.ref) + iframeDepths[ariaNode.ref] = depth; + if (statusMap.get(ariaNode) === "same" && ariaNode.ref) { + lines.push(indent(depth) + `- ref=${ariaNode.ref} [unchanged]`); + return; + } + const isDiffRoot = !!previousSnapshot && !depth; + const escapedKey = indent(depth) + "- " + (isDiffRoot ? " " : "") + yamlEscapeKeyIfNeeded(createKey(ariaNode, renderCursorPointer)); + const singleInlinedTextChild = getSingleInlinedTextChild(ariaNode); + const isAtDepthLimit = !!publicOptions.depth && depth === publicOptions.depth; + const hasNoChildren = !singleInlinedTextChild && (!ariaNode.children.length || isAtDepthLimit); + if (hasNoChildren && !Object.keys(ariaNode.props).length) { + lines.push(escapedKey); + } else if (singleInlinedTextChild !== void 0) { + const shouldInclude = includeText(ariaNode, singleInlinedTextChild); + if (shouldInclude) + lines.push(escapedKey + ": " + yamlEscapeValueIfNeeded(renderString(singleInlinedTextChild))); + else + lines.push(escapedKey); + } else { + lines.push(escapedKey + ":"); + for (const [name, value] of Object.entries(ariaNode.props)) + lines.push(indent(depth + 1) + "- /" + name + ": " + yamlEscapeValueIfNeeded(value)); + const inCursorPointer = !!ariaNode.ref && renderCursorPointer && hasPointerCursor(ariaNode); + for (const child of ariaNode.children) { + if (typeof child === "string") + visitText(includeText(ariaNode, child) ? child : "", depth + 1); + else + visit(child, depth + 1, renderCursorPointer && !inCursorPointer); + } + } + }; + for (const nodeToRender of nodesToRender) { + if (typeof nodeToRender === "string") + visitText(nodeToRender, 0); + else + visit(nodeToRender, 0, !!options.renderCursorPointer); + } + return { text: lines.join("\n"), iframeDepths }; +} +function convertToBestGuessRegex(text) { + const dynamicContent = [ + // 550e8400-e29b-41d4-a716-446655440000 + { regex: /\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/, replacement: "[0-9a-fA-F-]+" }, + // 2mb + { regex: /\b[\d,.]+[bkmBKM]+\b/, replacement: "[\\d,.]+[bkmBKM]+" }, + // 2ms, 20s + { regex: /\b\d+[hmsp]+\b/, replacement: "\\d+[hmsp]+" }, + { regex: /\b[\d,.]+[hmsp]+\b/, replacement: "[\\d,.]+[hmsp]+" }, + // Do not replace single digits with regex by default. + // 2+ digits: [Issue 22, 22.3, 2.33, 2,333] + { regex: /\b\d+,\d+\b/, replacement: "\\d+,\\d+" }, + { regex: /\b\d+\.\d{2,}\b/, replacement: "\\d+\\.\\d+" }, + { regex: /\b\d{2,}\.\d+\b/, replacement: "\\d+\\.\\d+" }, + { regex: /\b\d{2,}\b/, replacement: "\\d+" } + ]; + let pattern = ""; + let lastIndex = 0; + const combinedRegex = new RegExp(dynamicContent.map((r) => "(" + r.regex.source + ")").join("|"), "g"); + text.replace(combinedRegex, (match, ...args) => { + const offset = args[args.length - 2]; + const groups = args.slice(0, -2); + pattern += escapeRegExp(text.slice(lastIndex, offset)); + for (let i = 0; i < groups.length; i++) { + if (groups[i]) { + const { replacement } = dynamicContent[i]; + pattern += replacement; + break; + } + } + lastIndex = offset + match.length; + return match; + }); + if (!pattern) + return text; + pattern += escapeRegExp(text.slice(lastIndex)); + return String(new RegExp(pattern)); +} +function textContributesInfo(node, text) { + if (!text.length) + return false; + if (!node.name) + return true; + const substr = text.length <= 200 && node.name.length <= 200 ? longestCommonSubstring(text, node.name) : ""; + let filtered = text; + while (substr && filtered.includes(substr)) + filtered = filtered.replace(substr, ""); + return filtered.trim().length / text.length > 0.1; +} +var elementSymbol = Symbol("element"); +function ariaNodeElement(ariaNode) { + return ariaNode[elementSymbol]; +} +function setAriaNodeElement(ariaNode, element) { + ariaNode[elementSymbol] = element; +} +function findNewElement(from, to) { + const node = findNewNode(from, to); + return node ? ariaNodeElement(node) : void 0; +} + +// packages/injected/src/highlight.css?inline +var highlight_default = ":host{font-size:13px;font-family:system-ui,Ubuntu,Droid Sans,sans-serif;color:#333;color-scheme:light}svg{position:absolute;height:0}x-pw-tooltip{backdrop-filter:blur(5px);background-color:#fff;border-radius:6px;box-shadow:0 .5rem 1.2rem #0000004d;display:none;font-size:12.8px;font-weight:400;left:0;line-height:1.5;max-width:600px;position:absolute;top:0;padding:0;flex-direction:column;overflow:hidden}x-pw-tooltip-line{display:flex;max-width:600px;padding:6px;user-select:none;cursor:pointer}x-pw-tooltip-footer{display:flex;max-width:600px;padding:6px;user-select:none;color:#777}x-pw-dialog{background-color:#fff;pointer-events:auto;border-radius:6px;box-shadow:0 .5rem 1.2rem #0000004d;display:flex;flex-direction:column;position:absolute;z-index:10;font-size:13px}x-pw-dialog:not(.autosize){width:400px;height:150px}x-pw-dialog-body{display:flex;flex-direction:column;flex:auto}x-pw-dialog-body label{margin:5px 8px;display:flex;flex-direction:row;align-items:center}x-pw-highlight{position:absolute;top:0;left:0;width:0;height:0}x-pw-action-point{position:absolute;width:20px;height:20px;background:red;border-radius:10px;margin:-10px 0 0 -10px;z-index:2}x-pw-action-cursor{position:absolute;width:18px;height:22px;pointer-events:none;z-index:4;filter:drop-shadow(0 1px 2px rgba(0,0,0,.4))}x-pw-action-cursor svg{width:100%;height:100%;position:static}x-pw-title{position:absolute;backdrop-filter:blur(5px);background-color:#00000080;color:#fff;border-radius:6px;padding:6px;font-size:24px;line-height:1.4;white-space:nowrap;user-select:none;z-index:3}x-pw-user-overlays,x-pw-user-overlay{position:absolute;inset:0}@keyframes pw-fade-out{0%{opacity:1}to{opacity:0}}x-pw-separator{height:1px;margin:6px 9px;background:#949494e5}x-pw-tool-gripper{height:28px;width:24px;margin:2px 0;cursor:grab}x-pw-tool-gripper:active{cursor:grabbing}x-pw-tool-gripper>x-div{width:16px;height:16px;margin:6px 4px;clip-path:url(#icon-gripper);background-color:#555}x-pw-tools-list>label{display:flex;align-items:center;margin:0 10px;user-select:none}x-pw-tools-list{display:flex;width:100%;border-bottom:1px solid #dddddd}x-pw-tool-item{pointer-events:auto;height:28px;width:28px;border-radius:3px}x-pw-tool-item:not(.disabled){cursor:pointer}x-pw-tool-item:not(.disabled):hover{background-color:#dbdbdb}x-pw-tool-item.toggled{background-color:#8acae480}x-pw-tool-item.toggled:not(.disabled):hover{background-color:#8acae4c4}x-pw-tool-item>x-div{width:16px;height:16px;margin:6px;background-color:#3a3a3a}x-pw-tool-item.disabled>x-div{background-color:#61616180;cursor:default}x-pw-tool-item.record.toggled{background-color:transparent}x-pw-tool-item.record.toggled:not(.disabled):hover{background-color:#dbdbdb}x-pw-tool-item.record.toggled>x-div{background-color:#a1260d}x-pw-tool-item.record.disabled.toggled>x-div{opacity:.8}x-pw-tool-item.accept>x-div{background-color:#388a34}x-pw-tool-item.record>x-div{clip-path:url(#icon-circle-large-filled)}x-pw-tool-item.record.toggled>x-div{clip-path:url(#icon-stop-circle)}x-pw-tool-item.pick-locator>x-div{clip-path:url(#icon-inspect)}x-pw-tool-item.text>x-div{clip-path:url(#icon-whole-word)}x-pw-tool-item.visibility>x-div{clip-path:url(#icon-eye)}x-pw-tool-item.value>x-div{clip-path:url(#icon-symbol-constant)}x-pw-tool-item.snapshot>x-div{clip-path:url(#icon-gist)}x-pw-tool-item.accept>x-div{clip-path:url(#icon-check)}x-pw-tool-item.cancel>x-div{clip-path:url(#icon-close)}x-pw-tool-item.succeeded>x-div{clip-path:url(#icon-pass);background-color:#388a34!important}x-pw-overlay{position:absolute;top:0;max-width:min-content;z-index:2147483647;background:transparent;pointer-events:auto}x-pw-overlay x-pw-tools-list{background-color:#fffd;box-shadow:#0000001a 0 5px 5px;border-radius:3px;border-bottom:none}x-pw-overlay x-pw-tool-item{margin:2px}textarea.text-editor{font-family:system-ui,Ubuntu,Droid Sans,sans-serif;flex:auto;border:none;margin:6px 10px;color:#333;outline:1px solid transparent!important;resize:none;padding:0;font-size:13px}textarea.text-editor.does-not-match{outline:1px solid red!important}x-div{display:block}x-spacer{flex:auto}*{box-sizing:border-box}*[hidden]{display:none!important}x-locator-editor{flex:none;width:100%;height:60px;padding:4px;border-bottom:1px solid #dddddd;outline:1px solid transparent}x-locator-editor.does-not-match{outline:1px solid red}.CodeMirror{width:100%!important;height:100%!important}x-pw-action-list{flex:auto;display:flex;flex-direction:column;user-select:none}x-pw-action-item{padding:6px 10px;cursor:pointer;overflow:hidden}x-pw-action-item:hover{background-color:#f2f2f2}x-pw-action-item:last-child{border-bottom-left-radius:6px;border-bottom-right-radius:6px}\n"; + +// packages/injected/src/highlight.ts +var Highlight = class { + constructor(injectedScript) { + this._renderedEntries = []; + this._userOverlays = /* @__PURE__ */ new Map(); + this._userOverlayHidden = false; + this._language = "javascript"; + this._elementHighlightSelectors = /* @__PURE__ */ new Map(); + this._injectedScript = injectedScript; + const document = injectedScript.document; + this._isUnderTest = injectedScript.isUnderTest; + this._glassPaneElement = document.createElement("x-pw-glass"); + this._glassPaneElement.setAttribute("popover", "manual"); + this._glassPaneElement.style.inset = "0"; + this._glassPaneElement.style.width = "100%"; + this._glassPaneElement.style.height = "100%"; + this._glassPaneElement.style.maxWidth = "none"; + this._glassPaneElement.style.maxHeight = "none"; + this._glassPaneElement.style.padding = "0"; + this._glassPaneElement.style.margin = "0"; + this._glassPaneElement.style.border = "none"; + this._glassPaneElement.style.overflow = "visible"; + this._glassPaneElement.style.pointerEvents = "none"; + this._glassPaneElement.style.display = "flex"; + this._glassPaneElement.style.backgroundColor = "transparent"; + this._actionPointElement = document.createElement("x-pw-action-point"); + this._actionPointElement.setAttribute("hidden", "true"); + this._actionCursorElement = document.createElement("x-pw-action-cursor"); + this._actionCursorElement.style.visibility = "hidden"; + this._actionCursorElement.appendChild(this._createCursorSvg(document)); + this._titleElement = document.createElement("x-pw-title"); + this._titleElement.setAttribute("hidden", "true"); + this._userOverlayContainer = document.createElement("x-pw-user-overlays"); + this._userOverlayContainer.setAttribute("hidden", "true"); + this._glassPaneShadow = this._glassPaneElement.attachShadow({ mode: this._isUnderTest ? "open" : "closed" }); + if (typeof this._glassPaneShadow.adoptedStyleSheets.push === "function") { + const sheet = new this._injectedScript.window.CSSStyleSheet(); + sheet.replaceSync(highlight_default); + this._glassPaneShadow.adoptedStyleSheets.push(sheet); + } else { + const styleElement = this._injectedScript.document.createElement("style"); + styleElement.textContent = highlight_default; + this._glassPaneShadow.appendChild(styleElement); + } + this._glassPaneShadow.appendChild(this._actionPointElement); + this._glassPaneShadow.appendChild(this._actionCursorElement); + this._glassPaneShadow.appendChild(this._titleElement); + this._glassPaneShadow.appendChild(this._userOverlayContainer); + } + install() { + if (!this._injectedScript.document.documentElement) + return; + if (!this._injectedScript.document.documentElement.contains(this._glassPaneElement) || this._glassPaneElement.nextElementSibling) + this._injectedScript.document.documentElement.appendChild(this._glassPaneElement); + this._bringToFront(); + } + _bringToFront() { + this._glassPaneElement.hidePopover(); + this._glassPaneElement.showPopover(); + } + setLanguage(language) { + this._language = language; + } + addElementHighlight(selector, cssStyle) { + const key = stringifySelector(selector); + this._elementHighlightSelectors.set(key, { selector, cssStyle }); + this._ensureElementHighlightRaf(); + } + removeElementHighlight(selector) { + const key = stringifySelector(selector); + if (!this._elementHighlightSelectors.delete(key)) + return; + if (this._elementHighlightSelectors.size === 0) { + if (this._rafRequest) { + this._injectedScript.utils.builtins.cancelAnimationFrame(this._rafRequest); + this._rafRequest = void 0; + } + this.clearHighlight(); + } + } + _ensureElementHighlightRaf() { + if (this._rafRequest) + return; + const tick = () => { + const entries = []; + for (const { selector, cssStyle } of this._elementHighlightSelectors.values()) { + const elements = this._injectedScript.querySelectorAll(selector, this._injectedScript.document.documentElement); + const locator = asLocator(this._language, stringifySelector(selector)); + const color = elements.length > 1 ? "#f6b26b7f" : "#6fa8dc7f"; + for (let i = 0; i < elements.length; ++i) { + const suffix = elements.length > 1 ? ` [${i + 1} of ${elements.length}]` : ""; + entries.push({ element: elements[i], color, tooltipText: locator + suffix, cssStyle }); + } + } + this.updateHighlight(entries); + this._rafRequest = this._injectedScript.utils.builtins.requestAnimationFrame(tick); + }; + this._rafRequest = this._injectedScript.utils.builtins.requestAnimationFrame(tick); + } + uninstall() { + if (this._rafRequest) { + this._injectedScript.utils.builtins.cancelAnimationFrame(this._rafRequest); + this._rafRequest = void 0; + } + this._elementHighlightSelectors.clear(); + this._glassPaneElement.remove(); + } + showActionPoint(x, y, fadeDuration) { + this._actionPointElement.style.top = y + "px"; + this._actionPointElement.style.left = x + "px"; + this._actionPointElement.hidden = false; + if (fadeDuration) + this._actionPointElement.style.animation = `pw-fade-out ${fadeDuration}ms ease-out forwards`; + else + this._actionPointElement.style.animation = ""; + } + hideActionPoint() { + this._actionPointElement.hidden = true; + } + moveActionCursor(x, y, fadeDuration) { + const moveDuration = fadeDuration ? Math.max(80, Math.min(fadeDuration * 0.6, 400)) : 0; + this._actionCursorElement.style.transition = `top ${moveDuration}ms ease, left ${moveDuration}ms ease`; + this._actionCursorElement.style.left = x + "px"; + this._actionCursorElement.style.top = y + "px"; + this._actionCursorElement.style.visibility = "visible"; + } + hideActionCursor() { + this._actionCursorElement.style.visibility = "hidden"; + } + _createCursorSvg(document) { + const svgNs = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(svgNs, "svg"); + svg.setAttribute("viewBox", "0 0 18 22"); + const path = document.createElementNS(svgNs, "path"); + path.setAttribute("d", "M1 1 L1 17 L5.5 13 L8 20.5 L11 19.5 L8.5 12 L15 12 Z"); + path.setAttribute("fill", "white"); + path.setAttribute("stroke", "black"); + path.setAttribute("stroke-width", "1.5"); + path.setAttribute("stroke-linejoin", "round"); + svg.appendChild(path); + return svg; + } + showActionTitle(text, fadeDuration, position, fontSize) { + this._titleElement.textContent = text; + this._titleElement.hidden = false; + if (fadeDuration) { + const fadeTime = fadeDuration / 4; + this._titleElement.style.animation = `pw-fade-out ${fadeTime}ms ease-out ${fadeDuration - fadeTime}ms forwards`; + } else { + this._titleElement.style.animation = ""; + } + this._titleElement.style.top = ""; + this._titleElement.style.bottom = ""; + this._titleElement.style.left = ""; + this._titleElement.style.right = ""; + this._titleElement.style.transform = ""; + switch (position) { + case "top-left": + this._titleElement.style.top = "6px"; + this._titleElement.style.left = "6px"; + break; + case "top": + this._titleElement.style.top = "6px"; + this._titleElement.style.left = "50%"; + this._titleElement.style.transform = "translateX(-50%)"; + break; + case "bottom-left": + this._titleElement.style.bottom = "6px"; + this._titleElement.style.left = "6px"; + break; + case "bottom": + this._titleElement.style.bottom = "6px"; + this._titleElement.style.left = "50%"; + this._titleElement.style.transform = "translateX(-50%)"; + break; + case "bottom-right": + this._titleElement.style.bottom = "6px"; + this._titleElement.style.right = "6px"; + break; + case "top-right": + default: + this._titleElement.style.top = "6px"; + this._titleElement.style.right = "6px"; + break; + } + if (fontSize) + this._titleElement.style.fontSize = fontSize + "px"; + } + hideActionTitle() { + this._titleElement.hidden = true; + } + addUserOverlay(id, html) { + const element = this._injectedScript.document.createElement("div"); + element.className = "x-pw-user-overlay"; + element.innerHTML = html; + for (const script of element.querySelectorAll("script")) + script.remove(); + for (const el of element.querySelectorAll("*")) { + for (const attr of [...el.attributes]) { + if (attr.name.startsWith("on")) + el.removeAttribute(attr.name); + } + } + this._userOverlays.set(id, element); + this._userOverlayContainer.appendChild(element); + this._userOverlayContainer.hidden = this._userOverlayHidden; + return id; + } + getUserOverlay(id) { + return this._userOverlays.get(id); + } + removeUserOverlay(id) { + const element = this._userOverlays.get(id); + if (element) { + element.remove(); + this._userOverlays.delete(id); + } + if (this._userOverlays.size === 0) + this._userOverlayContainer.hidden = true; + } + setUserOverlaysVisible(visible) { + this._userOverlayHidden = !visible; + this._userOverlayContainer.hidden = !visible || this._userOverlays.size === 0; + } + clearHighlight() { + var _a, _b; + for (const entry of this._renderedEntries) { + (_a = entry.highlightElement) == null ? void 0 : _a.remove(); + (_b = entry.tooltipElement) == null ? void 0 : _b.remove(); + } + this._renderedEntries = []; + } + maskElements(elements, color) { + this.updateHighlight(elements.map((element) => ({ element, color }))); + } + updateHighlight(entries) { + if (this._highlightIsUpToDate(entries)) + return; + this.clearHighlight(); + for (const entry of entries) { + const highlightElement = this._createHighlightElement(); + this._glassPaneShadow.appendChild(highlightElement); + let tooltipElement; + if (entry.tooltipText) { + tooltipElement = this._injectedScript.document.createElement("x-pw-tooltip"); + this._glassPaneShadow.appendChild(tooltipElement); + tooltipElement.style.top = "0"; + tooltipElement.style.left = "0"; + tooltipElement.style.display = "flex"; + const lineElement = this._injectedScript.document.createElement("x-pw-tooltip-line"); + lineElement.textContent = entry.tooltipText; + tooltipElement.appendChild(lineElement); + } + this._renderedEntries.push({ targetElement: entry.element, box: toDOMRect(entry.box), color: entry.color, borderColor: entry.borderColor, fadeDuration: entry.fadeDuration, cssStyle: entry.cssStyle, tooltipElement, highlightElement }); + } + for (const entry of this._renderedEntries) { + if (!entry.box && !entry.targetElement) + continue; + entry.box = entry.box || entry.targetElement.getBoundingClientRect(); + if (!entry.tooltipElement) + continue; + const { anchorLeft, anchorTop } = this.tooltipPosition(entry.box, entry.tooltipElement); + entry.tooltipTop = anchorTop; + entry.tooltipLeft = anchorLeft; + } + for (const entry of this._renderedEntries) { + if (entry.tooltipElement) { + entry.tooltipElement.style.top = entry.tooltipTop + "px"; + entry.tooltipElement.style.left = entry.tooltipLeft + "px"; + } + const box = entry.box; + entry.highlightElement.style.backgroundColor = entry.color; + entry.highlightElement.style.left = box.x + "px"; + entry.highlightElement.style.top = box.y + "px"; + entry.highlightElement.style.width = box.width + "px"; + entry.highlightElement.style.height = box.height + "px"; + entry.highlightElement.style.display = "block"; + if (entry.borderColor) + entry.highlightElement.style.border = "2px solid " + entry.borderColor; + if (entry.fadeDuration) + entry.highlightElement.style.animation = `pw-fade-out ${entry.fadeDuration}ms ease-out forwards`; + if (entry.cssStyle) + entry.highlightElement.style.cssText += ";" + entry.cssStyle; + if (this._isUnderTest) + console.error("Highlight box for test: " + JSON.stringify({ x: box.x, y: box.y, width: box.width, height: box.height })); + } + } + firstBox() { + var _a; + return (_a = this._renderedEntries[0]) == null ? void 0 : _a.box; + } + firstTooltipBox() { + const entry = this._renderedEntries[0]; + if (!entry || !entry.tooltipElement || entry.tooltipLeft === void 0 || entry.tooltipTop === void 0) + return; + return { + x: entry.tooltipLeft, + y: entry.tooltipTop, + left: entry.tooltipLeft, + top: entry.tooltipTop, + width: entry.tooltipElement.offsetWidth, + height: entry.tooltipElement.offsetHeight, + bottom: entry.tooltipTop + entry.tooltipElement.offsetHeight, + right: entry.tooltipLeft + entry.tooltipElement.offsetWidth, + toJSON: () => { + } + }; + } + // Note: there is a copy of this method in dialog.tsx. Please fix bugs in both places. + tooltipPosition(box, tooltipElement) { + const tooltipWidth = tooltipElement.offsetWidth; + const tooltipHeight = tooltipElement.offsetHeight; + const totalWidth = this._glassPaneElement.offsetWidth; + const totalHeight = this._glassPaneElement.offsetHeight; + let anchorLeft = Math.max(5, box.left); + if (anchorLeft + tooltipWidth > totalWidth - 5) + anchorLeft = totalWidth - tooltipWidth - 5; + let anchorTop = Math.max(0, box.bottom) + 5; + if (anchorTop + tooltipHeight > totalHeight - 5) { + if (Math.max(0, box.top) > tooltipHeight + 5) { + anchorTop = Math.max(0, box.top) - tooltipHeight - 5; + } else { + anchorTop = totalHeight - 5 - tooltipHeight; + } + } + return { anchorLeft, anchorTop }; + } + _highlightIsUpToDate(entries) { + if (entries.length !== this._renderedEntries.length) + return false; + for (let i = 0; i < this._renderedEntries.length; ++i) { + if (entries[i].element !== this._renderedEntries[i].targetElement) + return false; + if (entries[i].color !== this._renderedEntries[i].color) + return false; + if (entries[i].cssStyle !== this._renderedEntries[i].cssStyle) + return false; + const oldBox = this._renderedEntries[i].box; + if (!oldBox) + return false; + const box = entries[i].box ? toDOMRect(entries[i].box) : entries[i].element.getBoundingClientRect(); + if (box.top !== oldBox.top || box.right !== oldBox.right || box.bottom !== oldBox.bottom || box.left !== oldBox.left) + return false; + } + return true; + } + _createHighlightElement() { + return this._injectedScript.document.createElement("x-pw-highlight"); + } + appendChild(element) { + this._glassPaneShadow.appendChild(element); + } + onGlassPaneClick(handler) { + this._glassPaneElement.style.pointerEvents = "auto"; + this._glassPaneElement.style.backgroundColor = "rgba(0, 0, 0, 0.3)"; + this._glassPaneElement.addEventListener("click", handler); + } + offGlassPaneClick(handler) { + this._glassPaneElement.style.pointerEvents = "none"; + this._glassPaneElement.style.backgroundColor = "transparent"; + this._glassPaneElement.removeEventListener("click", handler); + } +}; +function toDOMRect(box) { + if (!box) + return void 0; + return new DOMRect(box.x, box.y, box.width, box.height); +} + +// packages/injected/src/layoutSelectorUtils.ts +function boxRightOf(box1, box2, maxDistance) { + const distance = box1.left - box2.right; + if (distance < 0 || maxDistance !== void 0 && distance > maxDistance) + return; + return distance + Math.max(box2.bottom - box1.bottom, 0) + Math.max(box1.top - box2.top, 0); +} +function boxLeftOf(box1, box2, maxDistance) { + const distance = box2.left - box1.right; + if (distance < 0 || maxDistance !== void 0 && distance > maxDistance) + return; + return distance + Math.max(box2.bottom - box1.bottom, 0) + Math.max(box1.top - box2.top, 0); +} +function boxAbove(box1, box2, maxDistance) { + const distance = box2.top - box1.bottom; + if (distance < 0 || maxDistance !== void 0 && distance > maxDistance) + return; + return distance + Math.max(box1.left - box2.left, 0) + Math.max(box2.right - box1.right, 0); +} +function boxBelow(box1, box2, maxDistance) { + const distance = box1.top - box2.bottom; + if (distance < 0 || maxDistance !== void 0 && distance > maxDistance) + return; + return distance + Math.max(box1.left - box2.left, 0) + Math.max(box2.right - box1.right, 0); +} +function boxNear(box1, box2, maxDistance) { + const kThreshold = maxDistance === void 0 ? 50 : maxDistance; + let score = 0; + if (box1.left - box2.right >= 0) + score += box1.left - box2.right; + if (box2.left - box1.right >= 0) + score += box2.left - box1.right; + if (box2.top - box1.bottom >= 0) + score += box2.top - box1.bottom; + if (box1.top - box2.bottom >= 0) + score += box1.top - box2.bottom; + return score > kThreshold ? void 0 : score; +} +var kLayoutSelectorNames = ["left-of", "right-of", "above", "below", "near"]; +function layoutSelectorScore(name, element, inner, maxDistance) { + const box = element.getBoundingClientRect(); + const scorer = { "left-of": boxLeftOf, "right-of": boxRightOf, "above": boxAbove, "below": boxBelow, "near": boxNear }[name]; + let bestScore; + for (const e of inner) { + if (e === element) + continue; + const score = scorer(box, e.getBoundingClientRect(), maxDistance); + if (score === void 0) + continue; + if (bestScore === void 0 || score < bestScore) + bestScore = score; + } + return bestScore; +} + +// packages/injected/src/selectorUtils.ts +function matchesAttributePart(value, attr) { + const objValue = typeof value === "string" && !attr.caseSensitive ? value.toUpperCase() : value; + const attrValue = typeof attr.value === "string" && !attr.caseSensitive ? attr.value.toUpperCase() : attr.value; + if (attr.op === "") + return !!objValue; + if (attr.op === "=") { + if (attrValue instanceof RegExp) + return typeof objValue === "string" && !!objValue.match(attrValue); + return objValue === attrValue; + } + if (typeof objValue !== "string" || typeof attrValue !== "string") + return false; + if (attr.op === "*=") + return objValue.includes(attrValue); + if (attr.op === "^=") + return objValue.startsWith(attrValue); + if (attr.op === "$=") + return objValue.endsWith(attrValue); + if (attr.op === "|=") + return objValue === attrValue || objValue.startsWith(attrValue + "-"); + if (attr.op === "~=") + return objValue.split(" ").includes(attrValue); + return false; +} +function shouldSkipForTextMatching(element) { + const document = element.ownerDocument; + return element.nodeName === "SCRIPT" || element.nodeName === "NOSCRIPT" || element.nodeName === "STYLE" || document.head && document.head.contains(element); +} +function elementText(cache, root) { + let value = cache.get(root); + if (value === void 0) { + value = { full: "", normalized: "", immediate: [] }; + if (!shouldSkipForTextMatching(root)) { + let currentImmediate = ""; + if (root instanceof HTMLInputElement && (root.type === "submit" || root.type === "button")) { + value = { full: root.value, normalized: normalizeWhiteSpace(root.value), immediate: [root.value] }; + } else { + for (let child = root.firstChild; child; child = child.nextSibling) { + if (child.nodeType === Node.TEXT_NODE) { + value.full += child.nodeValue || ""; + currentImmediate += child.nodeValue || ""; + } else if (child.nodeType === Node.COMMENT_NODE) { + continue; + } else { + if (currentImmediate) + value.immediate.push(currentImmediate); + currentImmediate = ""; + if (child.nodeType === Node.ELEMENT_NODE) + value.full += elementText(cache, child).full; + } + } + if (currentImmediate) + value.immediate.push(currentImmediate); + if (root.shadowRoot) + value.full += elementText(cache, root.shadowRoot).full; + if (value.full) + value.normalized = normalizeWhiteSpace(value.full); + } + } + cache.set(root, value); + } + return value; +} +function elementMatchesText(cache, element, matcher) { + if (shouldSkipForTextMatching(element)) + return "none"; + if (!matcher(elementText(cache, element))) + return "none"; + for (let child = element.firstChild; child; child = child.nextSibling) { + if (child.nodeType === Node.ELEMENT_NODE && matcher(elementText(cache, child))) + return "selfAndChildren"; + } + if (element.shadowRoot && matcher(elementText(cache, element.shadowRoot))) + return "selfAndChildren"; + return "self"; +} +function getElementLabels(textCache, element) { + const labels = getAriaLabelledByElements(element); + if (labels) + return labels.map((label) => elementText(textCache, label)); + const ariaLabel = element.getAttribute("aria-label"); + if (ariaLabel !== null && !!ariaLabel.trim()) + return [{ full: ariaLabel, normalized: normalizeWhiteSpace(ariaLabel), immediate: [ariaLabel] }]; + const isNonHiddenInput = element.nodeName === "INPUT" && element.type !== "hidden"; + if (["BUTTON", "METER", "OUTPUT", "PROGRESS", "SELECT", "TEXTAREA"].includes(element.nodeName) || isNonHiddenInput) { + const labels2 = element.labels; + if (labels2) + return [...labels2].map((label) => elementText(textCache, label)); + } + return []; +} + +// packages/injected/src/roleSelectorEngine.ts +var kSupportedAttributes = ["selected", "checked", "pressed", "expanded", "level", "disabled", "name", "description", "include-hidden"]; +kSupportedAttributes.sort(); +function validateSupportedRole(attr, roles, role) { + if (!roles.includes(role)) + throw new Error(`"${attr}" attribute is only supported for roles: ${roles.slice().sort().map((role2) => `"${role2}"`).join(", ")}`); +} +function validateSupportedValues(attr, values) { + if (attr.op !== "" && !values.includes(attr.value)) + throw new Error(`"${attr.name}" must be one of ${values.map((v) => JSON.stringify(v)).join(", ")}`); +} +function validateSupportedOp(attr, ops) { + if (!ops.includes(attr.op)) + throw new Error(`"${attr.name}" does not support "${attr.op}" matcher`); +} +function validateAttributes(attrs, role) { + const options = { role }; + for (const attr of attrs) { + switch (attr.name) { + case "checked": { + validateSupportedRole(attr.name, kAriaCheckedRoles, role); + validateSupportedValues(attr, [true, false, "mixed"]); + validateSupportedOp(attr, ["", "="]); + options.checked = attr.op === "" ? true : attr.value; + break; + } + case "pressed": { + validateSupportedRole(attr.name, kAriaPressedRoles, role); + validateSupportedValues(attr, [true, false, "mixed"]); + validateSupportedOp(attr, ["", "="]); + options.pressed = attr.op === "" ? true : attr.value; + break; + } + case "selected": { + validateSupportedRole(attr.name, kAriaSelectedRoles, role); + validateSupportedValues(attr, [true, false]); + validateSupportedOp(attr, ["", "="]); + options.selected = attr.op === "" ? true : attr.value; + break; + } + case "expanded": { + validateSupportedRole(attr.name, kAriaExpandedRoles, role); + validateSupportedValues(attr, [true, false]); + validateSupportedOp(attr, ["", "="]); + options.expanded = attr.op === "" ? true : attr.value; + break; + } + case "level": { + validateSupportedRole(attr.name, kAriaLevelRoles, role); + if (typeof attr.value === "string") + attr.value = +attr.value; + if (attr.op !== "=" || typeof attr.value !== "number" || Number.isNaN(attr.value)) + throw new Error(`"level" attribute must be compared to a number`); + options.level = attr.value; + break; + } + case "disabled": { + validateSupportedValues(attr, [true, false]); + validateSupportedOp(attr, ["", "="]); + options.disabled = attr.op === "" ? true : attr.value; + break; + } + case "name": { + if (attr.op === "") + throw new Error(`"name" attribute must have a value`); + if (typeof attr.value !== "string" && !(attr.value instanceof RegExp)) + throw new Error(`"name" attribute must be a string or a regular expression`); + options.name = attr.value; + options.nameOp = attr.op; + options.nameExact = attr.caseSensitive; + break; + } + case "description": { + if (attr.op === "") + throw new Error(`"description" attribute must have a value`); + if (typeof attr.value !== "string" && !(attr.value instanceof RegExp)) + throw new Error(`"description" attribute must be a string or a regular expression`); + options.description = attr.value; + options.descriptionOp = attr.op; + options.descriptionExact = attr.caseSensitive; + break; + } + case "include-hidden": { + validateSupportedValues(attr, [true, false]); + validateSupportedOp(attr, ["", "="]); + options.includeHidden = attr.op === "" ? true : attr.value; + break; + } + default: { + throw new Error(`Unknown attribute "${attr.name}", must be one of ${kSupportedAttributes.map((a) => `"${a}"`).join(", ")}.`); + } + } + } + return options; +} +function queryRole(scope, options, internal) { + const result = []; + const match = (element) => { + if (getAriaRole(element) !== options.role) + return; + if (options.selected !== void 0 && getAriaSelected(element) !== options.selected) + return; + if (options.checked !== void 0 && getAriaChecked(element) !== options.checked) + return; + if (options.pressed !== void 0 && getAriaPressed(element) !== options.pressed) + return; + if (options.expanded !== void 0 && getAriaExpanded(element) !== options.expanded) + return; + if (options.level !== void 0 && getAriaLevel(element) !== options.level) + return; + if (options.disabled !== void 0 && getAriaDisabled(element) !== options.disabled) + return; + if (!options.includeHidden) { + const isHidden = isElementHiddenForAria(element); + if (isHidden) + return; + } + if (options.name !== void 0) { + const accessibleName = normalizeWhiteSpace(getElementAccessibleName(element, !!options.includeHidden)); + if (typeof options.name === "string") + options.name = normalizeWhiteSpace(options.name); + if (internal && !options.nameExact && options.nameOp === "=") + options.nameOp = "*="; + if (!matchesAttributePart(accessibleName, { name: "", jsonPath: [], op: options.nameOp || "=", value: options.name, caseSensitive: !!options.nameExact })) + return; + } + if (options.description !== void 0) { + const accessibleDescription = normalizeWhiteSpace(getElementAccessibleDescription(element, !!options.includeHidden)); + if (typeof options.description === "string") + options.description = normalizeWhiteSpace(options.description); + if (internal && !options.descriptionExact && options.descriptionOp === "=") + options.descriptionOp = "*="; + if (!matchesAttributePart(accessibleDescription, { name: "", jsonPath: [], op: options.descriptionOp || "=", value: options.description, caseSensitive: !!options.descriptionExact })) + return; + } + result.push(element); + }; + const query = (root) => { + const shadows = []; + if (root.shadowRoot) + shadows.push(root.shadowRoot); + for (const element of root.querySelectorAll("*")) { + match(element); + if (element.shadowRoot) + shadows.push(element.shadowRoot); + } + shadows.forEach(query); + }; + query(scope); + return result; +} +function createRoleEngine(internal) { + return { + queryAll: (scope, selector) => { + const parsed = parseAttributeSelector(selector, true); + const role = parsed.name.toLowerCase(); + if (!role) + throw new Error(`Role must not be empty`); + const options = validateAttributes(parsed.attributes, role); + beginAriaCaches(); + try { + return queryRole(scope, options, internal); + } finally { + endAriaCaches(); + } + } + }; +} + +// packages/injected/src/selectorEvaluator.ts +var SelectorEvaluatorImpl = class { + constructor() { + this._retainCacheCounter = 0; + this._cacheText = /* @__PURE__ */ new Map(); + this._cacheQueryCSS = /* @__PURE__ */ new Map(); + this._cacheMatches = /* @__PURE__ */ new Map(); + this._cacheQuery = /* @__PURE__ */ new Map(); + this._cacheMatchesSimple = /* @__PURE__ */ new Map(); + this._cacheMatchesParents = /* @__PURE__ */ new Map(); + this._cacheCallMatches = /* @__PURE__ */ new Map(); + this._cacheCallQuery = /* @__PURE__ */ new Map(); + this._cacheQuerySimple = /* @__PURE__ */ new Map(); + this._engines = /* @__PURE__ */ new Map(); + this._engines.set("not", notEngine); + this._engines.set("is", isEngine); + this._engines.set("where", isEngine); + this._engines.set("has", hasEngine); + this._engines.set("scope", scopeEngine); + this._engines.set("light", lightEngine); + this._engines.set("visible", visibleEngine); + this._engines.set("text", textEngine); + this._engines.set("text-is", textIsEngine); + this._engines.set("text-matches", textMatchesEngine); + this._engines.set("has-text", hasTextEngine); + this._engines.set("right-of", createLayoutEngine("right-of")); + this._engines.set("left-of", createLayoutEngine("left-of")); + this._engines.set("above", createLayoutEngine("above")); + this._engines.set("below", createLayoutEngine("below")); + this._engines.set("near", createLayoutEngine("near")); + this._engines.set("nth-match", nthMatchEngine); + const allNames = [...this._engines.keys()]; + allNames.sort(); + const parserNames = [...customCSSNames]; + parserNames.sort(); + if (allNames.join("|") !== parserNames.join("|")) + throw new Error(`Please keep customCSSNames in sync with evaluator engines: ${allNames.join("|")} vs ${parserNames.join("|")}`); + } + begin() { + ++this._retainCacheCounter; + } + end() { + --this._retainCacheCounter; + if (!this._retainCacheCounter) { + this._cacheQueryCSS.clear(); + this._cacheMatches.clear(); + this._cacheQuery.clear(); + this._cacheMatchesSimple.clear(); + this._cacheMatchesParents.clear(); + this._cacheCallMatches.clear(); + this._cacheCallQuery.clear(); + this._cacheQuerySimple.clear(); + this._cacheText.clear(); + } + } + _cached(cache, main, rest, cb) { + if (!cache.has(main)) + cache.set(main, []); + const entries = cache.get(main); + const entry = entries.find((e) => rest.every((value, index) => e.rest[index] === value)); + if (entry) + return entry.result; + const result = cb(); + entries.push({ rest, result }); + return result; + } + _checkSelector(s) { + const wellFormed = typeof s === "object" && s && (Array.isArray(s) || "simples" in s && s.simples.length); + if (!wellFormed) + throw new Error(`Malformed selector "${s}"`); + return s; + } + matches(element, s, context) { + const selector = this._checkSelector(s); + this.begin(); + try { + return this._cached(this._cacheMatches, element, [selector, context.scope, context.pierceShadow, context.originalScope], () => { + if (Array.isArray(selector)) + return this._matchesEngine(isEngine, element, selector, context); + if (this._hasScopeClause(selector)) + context = this._expandContextForScopeMatching(context); + if (!this._matchesSimple(element, selector.simples[selector.simples.length - 1].selector, context)) + return false; + return this._matchesParents(element, selector, selector.simples.length - 2, context); + }); + } finally { + this.end(); + } + } + query(context, s) { + const selector = this._checkSelector(s); + this.begin(); + try { + return this._cached(this._cacheQuery, selector, [context.scope, context.pierceShadow, context.originalScope], () => { + if (Array.isArray(selector)) + return this._queryEngine(isEngine, context, selector); + if (this._hasScopeClause(selector)) + context = this._expandContextForScopeMatching(context); + const previousScoreMap = this._scoreMap; + this._scoreMap = /* @__PURE__ */ new Map(); + let elements = this._querySimple(context, selector.simples[selector.simples.length - 1].selector); + elements = elements.filter((element) => this._matchesParents(element, selector, selector.simples.length - 2, context)); + if (this._scoreMap.size) { + elements.sort((a, b) => { + const aScore = this._scoreMap.get(a); + const bScore = this._scoreMap.get(b); + if (aScore === bScore) + return 0; + if (aScore === void 0) + return 1; + if (bScore === void 0) + return -1; + return aScore - bScore; + }); + } + this._scoreMap = previousScoreMap; + return elements; + }); + } finally { + this.end(); + } + } + _markScore(element, score) { + if (this._scoreMap) + this._scoreMap.set(element, score); + } + _hasScopeClause(selector) { + return selector.simples.some((simple) => simple.selector.functions.some((f) => f.name === "scope")); + } + _expandContextForScopeMatching(context) { + if (context.scope.nodeType !== 1) + return context; + const scope = parentElementOrShadowHost(context.scope); + if (!scope) + return context; + return { ...context, scope, originalScope: context.originalScope || context.scope }; + } + _matchesSimple(element, simple, context) { + return this._cached(this._cacheMatchesSimple, element, [simple, context.scope, context.pierceShadow, context.originalScope], () => { + if (element === context.scope) + return false; + if (simple.css && !this._matchesCSS(element, simple.css)) + return false; + for (const func of simple.functions) { + if (!this._matchesEngine(this._getEngine(func.name), element, func.args, context)) + return false; + } + return true; + }); + } + _querySimple(context, simple) { + if (!simple.functions.length) + return this._queryCSS(context, simple.css || "*"); + return this._cached(this._cacheQuerySimple, simple, [context.scope, context.pierceShadow, context.originalScope], () => { + let css = simple.css; + const funcs = simple.functions; + if (css === "*" && funcs.length) + css = void 0; + let elements; + let firstIndex = -1; + if (css !== void 0) { + elements = this._queryCSS(context, css); + } else { + firstIndex = funcs.findIndex((func) => this._getEngine(func.name).query !== void 0); + if (firstIndex === -1) + firstIndex = 0; + elements = this._queryEngine(this._getEngine(funcs[firstIndex].name), context, funcs[firstIndex].args); + } + for (let i = 0; i < funcs.length; i++) { + if (i === firstIndex) + continue; + const engine = this._getEngine(funcs[i].name); + if (engine.matches !== void 0) + elements = elements.filter((e) => this._matchesEngine(engine, e, funcs[i].args, context)); + } + for (let i = 0; i < funcs.length; i++) { + if (i === firstIndex) + continue; + const engine = this._getEngine(funcs[i].name); + if (engine.matches === void 0) + elements = elements.filter((e) => this._matchesEngine(engine, e, funcs[i].args, context)); + } + return elements; + }); + } + _matchesParents(element, complex, index, context) { + if (index < 0) + return true; + return this._cached(this._cacheMatchesParents, element, [complex, index, context.scope, context.pierceShadow, context.originalScope], () => { + const { selector: simple, combinator } = complex.simples[index]; + if (combinator === ">") { + const parent = parentElementOrShadowHostInContext(element, context); + if (!parent || !this._matchesSimple(parent, simple, context)) + return false; + return this._matchesParents(parent, complex, index - 1, context); + } + if (combinator === "+") { + const previousSibling = previousSiblingInContext(element, context); + if (!previousSibling || !this._matchesSimple(previousSibling, simple, context)) + return false; + return this._matchesParents(previousSibling, complex, index - 1, context); + } + if (combinator === "") { + let parent = parentElementOrShadowHostInContext(element, context); + while (parent) { + if (this._matchesSimple(parent, simple, context)) { + if (this._matchesParents(parent, complex, index - 1, context)) + return true; + if (complex.simples[index - 1].combinator === "") + break; + } + parent = parentElementOrShadowHostInContext(parent, context); + } + return false; + } + if (combinator === "~") { + let previousSibling = previousSiblingInContext(element, context); + while (previousSibling) { + if (this._matchesSimple(previousSibling, simple, context)) { + if (this._matchesParents(previousSibling, complex, index - 1, context)) + return true; + if (complex.simples[index - 1].combinator === "~") + break; + } + previousSibling = previousSiblingInContext(previousSibling, context); + } + return false; + } + if (combinator === ">=") { + let parent = element; + while (parent) { + if (this._matchesSimple(parent, simple, context)) { + if (this._matchesParents(parent, complex, index - 1, context)) + return true; + if (complex.simples[index - 1].combinator === "") + break; + } + parent = parentElementOrShadowHostInContext(parent, context); + } + return false; + } + throw new Error(`Unsupported combinator "${combinator}"`); + }); + } + _matchesEngine(engine, element, args, context) { + if (engine.matches) + return this._callMatches(engine, element, args, context); + if (engine.query) + return this._callQuery(engine, args, context).includes(element); + throw new Error(`Selector engine should implement "matches" or "query"`); + } + _queryEngine(engine, context, args) { + if (engine.query) + return this._callQuery(engine, args, context); + if (engine.matches) + return this._queryCSS(context, "*").filter((element) => this._callMatches(engine, element, args, context)); + throw new Error(`Selector engine should implement "matches" or "query"`); + } + _callMatches(engine, element, args, context) { + return this._cached(this._cacheCallMatches, element, [engine, context.scope, context.pierceShadow, context.originalScope, ...args], () => { + return engine.matches(element, args, context, this); + }); + } + _callQuery(engine, args, context) { + return this._cached(this._cacheCallQuery, engine, [context.scope, context.pierceShadow, context.originalScope, ...args], () => { + return engine.query(context, args, this); + }); + } + _matchesCSS(element, css) { + return element.matches(css); + } + _queryCSS(context, css) { + return this._cached(this._cacheQueryCSS, css, [context.scope, context.pierceShadow, context.originalScope], () => { + let result = []; + function query(root) { + result = result.concat([...root.querySelectorAll(css)]); + if (!context.pierceShadow) + return; + if (root.shadowRoot) + query(root.shadowRoot); + for (const element of root.querySelectorAll("*")) { + if (element.shadowRoot) + query(element.shadowRoot); + } + } + query(context.scope); + return result; + }); + } + _getEngine(name) { + const engine = this._engines.get(name); + if (!engine) + throw new Error(`Unknown selector engine "${name}"`); + return engine; + } +}; +var isEngine = { + matches(element, args, context, evaluator) { + if (args.length === 0) + throw new Error(`"is" engine expects non-empty selector list`); + return args.some((selector) => evaluator.matches(element, selector, context)); + }, + query(context, args, evaluator) { + if (args.length === 0) + throw new Error(`"is" engine expects non-empty selector list`); + let elements = []; + for (const arg of args) + elements = elements.concat(evaluator.query(context, arg)); + return args.length === 1 ? elements : sortInDOMOrder(elements); + } +}; +var hasEngine = { + matches(element, args, context, evaluator) { + if (args.length === 0) + throw new Error(`"has" engine expects non-empty selector list`); + return evaluator.query({ ...context, scope: element }, args).length > 0; + } + // TODO: we can implement efficient "query" by matching "args" and returning + // all parents/descendants, just have to be careful with the ":scope" matching. +}; +var scopeEngine = { + matches(element, args, context, evaluator) { + if (args.length !== 0) + throw new Error(`"scope" engine expects no arguments`); + const actualScope = context.originalScope || context.scope; + if (actualScope.nodeType === 9) + return element === actualScope.documentElement; + return element === actualScope; + }, + query(context, args, evaluator) { + if (args.length !== 0) + throw new Error(`"scope" engine expects no arguments`); + const actualScope = context.originalScope || context.scope; + if (actualScope.nodeType === 9) { + const root = actualScope.documentElement; + return root ? [root] : []; + } + if (actualScope.nodeType === 1) + return [actualScope]; + return []; + } +}; +var notEngine = { + matches(element, args, context, evaluator) { + if (args.length === 0) + throw new Error(`"not" engine expects non-empty selector list`); + return !evaluator.matches(element, args, context); + } +}; +var lightEngine = { + query(context, args, evaluator) { + return evaluator.query({ ...context, pierceShadow: false }, args); + }, + matches(element, args, context, evaluator) { + return evaluator.matches(element, args, { ...context, pierceShadow: false }); + } +}; +var visibleEngine = { + matches(element, args, context, evaluator) { + if (args.length) + throw new Error(`"visible" engine expects no arguments`); + return isElementVisible(element); + } +}; +var textEngine = { + matches(element, args, context, evaluator) { + if (args.length !== 1 || typeof args[0] !== "string") + throw new Error(`"text" engine expects a single string`); + const text = normalizeWhiteSpace(args[0]).toLowerCase(); + const matcher = (elementText2) => elementText2.normalized.toLowerCase().includes(text); + return elementMatchesText(evaluator._cacheText, element, matcher) === "self"; + } +}; +var textIsEngine = { + matches(element, args, context, evaluator) { + if (args.length !== 1 || typeof args[0] !== "string") + throw new Error(`"text-is" engine expects a single string`); + const text = normalizeWhiteSpace(args[0]); + const matcher = (elementText2) => { + if (!text && !elementText2.immediate.length) + return true; + return elementText2.immediate.some((s) => normalizeWhiteSpace(s) === text); + }; + return elementMatchesText(evaluator._cacheText, element, matcher) !== "none"; + } +}; +var textMatchesEngine = { + matches(element, args, context, evaluator) { + if (args.length === 0 || typeof args[0] !== "string" || args.length > 2 || args.length === 2 && typeof args[1] !== "string") + throw new Error(`"text-matches" engine expects a regexp body and optional regexp flags`); + const re = new RegExp(args[0], args.length === 2 ? args[1] : void 0); + const matcher = (elementText2) => re.test(elementText2.full); + return elementMatchesText(evaluator._cacheText, element, matcher) === "self"; + } +}; +var hasTextEngine = { + matches(element, args, context, evaluator) { + if (args.length !== 1 || typeof args[0] !== "string") + throw new Error(`"has-text" engine expects a single string`); + if (shouldSkipForTextMatching(element)) + return false; + const text = normalizeWhiteSpace(args[0]).toLowerCase(); + const matcher = (elementText2) => elementText2.normalized.toLowerCase().includes(text); + return matcher(elementText(evaluator._cacheText, element)); + } +}; +function createLayoutEngine(name) { + return { + matches(element, args, context, evaluator) { + const maxDistance = args.length && typeof args[args.length - 1] === "number" ? args[args.length - 1] : void 0; + const queryArgs = maxDistance === void 0 ? args : args.slice(0, args.length - 1); + if (args.length < 1 + (maxDistance === void 0 ? 0 : 1)) + throw new Error(`"${name}" engine expects a selector list and optional maximum distance in pixels`); + const inner = evaluator.query(context, queryArgs); + const score = layoutSelectorScore(name, element, inner, maxDistance); + if (score === void 0) + return false; + evaluator._markScore(element, score); + return true; + } + }; +} +var nthMatchEngine = { + query(context, args, evaluator) { + let index = args[args.length - 1]; + if (args.length < 2) + throw new Error(`"nth-match" engine expects non-empty selector list and an index argument`); + if (typeof index !== "number" || index < 1) + throw new Error(`"nth-match" engine expects a one-based index as the last argument`); + const elements = isEngine.query(context, args.slice(0, args.length - 1), evaluator); + index--; + return index < elements.length ? [elements[index]] : []; + } +}; +function parentElementOrShadowHostInContext(element, context) { + if (element === context.scope) + return; + if (!context.pierceShadow) + return element.parentElement || void 0; + return parentElementOrShadowHost(element); +} +function previousSiblingInContext(element, context) { + if (element === context.scope) + return; + return element.previousElementSibling || void 0; +} +function sortInDOMOrder(elements) { + const elementToEntry = /* @__PURE__ */ new Map(); + const roots = []; + const result = []; + function append(element) { + let entry = elementToEntry.get(element); + if (entry) + return entry; + const parent = parentElementOrShadowHost(element); + if (parent) { + const parentEntry = append(parent); + parentEntry.children.push(element); + } else { + roots.push(element); + } + entry = { children: [], taken: false }; + elementToEntry.set(element, entry); + return entry; + } + for (const e of elements) + append(e).taken = true; + function visit(element) { + const entry = elementToEntry.get(element); + if (entry.taken) + result.push(element); + if (entry.children.length > 1) { + const set = new Set(entry.children); + entry.children = []; + let child = element.firstElementChild; + while (child && entry.children.length < set.size) { + if (set.has(child)) + entry.children.push(child); + child = child.nextElementSibling; + } + child = element.shadowRoot ? element.shadowRoot.firstElementChild : null; + while (child && entry.children.length < set.size) { + if (set.has(child)) + entry.children.push(child); + child = child.nextElementSibling; + } + } + entry.children.forEach(visit); + } + roots.forEach(visit); + return result; +} + +// packages/injected/src/selectorGenerator.ts +var kTextScoreRange = 10; +var kExactPenalty = kTextScoreRange / 2; +var kTestIdScore = 1; +var kOtherTestIdScore = 2; +var kIframeByAttributeScore = 10; +var kBeginPenalizedScore = 50; +var kRoleWithNameScore = 100; +var kPlaceholderScore = 120; +var kLabelScore = 140; +var kAltTextScore = 160; +var kTextScore = 180; +var kTitleScore = 200; +var kTextScoreRegex = 250; +var kPlaceholderScoreExact = kPlaceholderScore + kExactPenalty; +var kLabelScoreExact = kLabelScore + kExactPenalty; +var kRoleWithNameScoreExact = kRoleWithNameScore + kExactPenalty; +var kAltTextScoreExact = kAltTextScore + kExactPenalty; +var kTextScoreExact = kTextScore + kExactPenalty; +var kTitleScoreExact = kTitleScore + kExactPenalty; +var kEndPenalizedScore = 300; +var kCSSIdScore = 500; +var kRoleWithoutNameScore = 510; +var kCSSInputTypeNameScore = 520; +var kCSSTagNameScore = 530; +var kNthScore = 1e4; +var kCSSFallbackScore = 1e7; +var kScoreThresholdForTextExpect = 1e3; +function generateSelector(injectedScript, targetElement, options) { + var _a; + injectedScript._evaluator.begin(); + const cache = { allowText: /* @__PURE__ */ new Map(), disallowText: /* @__PURE__ */ new Map() }; + beginAriaCaches(); + beginDOMCaches(); + try { + let selectors = []; + if (options.forTextExpect) { + let targetTokens = cssFallback(injectedScript, targetElement.ownerDocument.documentElement, options); + for (let element = targetElement; element; element = parentElementOrShadowHost(element)) { + const tokens = generateSelectorFor(cache, injectedScript, element, { ...options, noText: true }); + if (!tokens) + continue; + const score = combineScores(tokens); + if (score <= kScoreThresholdForTextExpect) { + targetTokens = tokens; + break; + } + } + selectors = [joinTokens(targetTokens)]; + } else { + if (!targetElement.matches("input,textarea,select") && !targetElement.isContentEditable) { + const interactiveParent = closestCrossShadow(targetElement, "button,select,input,[role=button],[role=checkbox],[role=radio],a,[role=link]", options.root); + if (interactiveParent && isElementVisible(interactiveParent)) + targetElement = interactiveParent; + } + if (options.multiple) { + const withText = generateSelectorFor(cache, injectedScript, targetElement, options); + const withoutText = generateSelectorFor(cache, injectedScript, targetElement, { ...options, noText: true }); + let tokens = [withText, withoutText]; + cache.allowText.clear(); + cache.disallowText.clear(); + if (withText && hasCSSIdToken(withText)) + tokens.push(generateSelectorFor(cache, injectedScript, targetElement, { ...options, noCSSId: true })); + if (withoutText && hasCSSIdToken(withoutText)) + tokens.push(generateSelectorFor(cache, injectedScript, targetElement, { ...options, noText: true, noCSSId: true })); + tokens = tokens.filter(Boolean); + if (!tokens.length) { + const css = cssFallback(injectedScript, targetElement, options); + tokens.push(css); + if (hasCSSIdToken(css)) + tokens.push(cssFallback(injectedScript, targetElement, { ...options, noCSSId: true })); + } + selectors = [...new Set(tokens.map((t) => joinTokens(t)))]; + } else { + const targetTokens = generateSelectorFor(cache, injectedScript, targetElement, options) || cssFallback(injectedScript, targetElement, options); + selectors = [joinTokens(targetTokens)]; + } + } + const selector = selectors[0]; + const parsedSelector = injectedScript.parseSelector(selector); + return { + selector, + selectors, + elements: injectedScript.querySelectorAll(parsedSelector, (_a = options.root) != null ? _a : targetElement.ownerDocument) + }; + } finally { + endDOMCaches(); + endAriaCaches(); + injectedScript._evaluator.end(); + } +} +function generateSelectorFor(cache, injectedScript, targetElement, options) { + var _a; + if (options.root && !isInsideScope(options.root, targetElement)) + throw new Error(`Target element must belong to the root's subtree`); + if (targetElement === options.root) + return [{ engine: "css", selector: ":scope", score: 1 }]; + if (targetElement.ownerDocument.documentElement === targetElement) + return [{ engine: "css", selector: "html", score: 1 }]; + let result = null; + const updateResult = (candidate) => { + if (!result || combineScores(candidate) < combineScores(result)) + result = candidate; + }; + const candidates = []; + if (!options.noText) { + for (const candidate of buildTextCandidates(injectedScript, targetElement, !options.isRecursive)) + candidates.push({ candidate, isTextCandidate: true }); + } + for (const token of buildNoTextCandidates(injectedScript, targetElement, options)) { + if (options.omitInternalEngines && token.engine.startsWith("internal:")) + continue; + candidates.push({ candidate: [token], isTextCandidate: false }); + } + candidates.sort((a, b) => combineScores(a.candidate) - combineScores(b.candidate)); + for (const { candidate, isTextCandidate } of candidates) { + const elements = injectedScript.querySelectorAll(injectedScript.parseSelector(joinTokens(candidate)), (_a = options.root) != null ? _a : targetElement.ownerDocument); + if (!elements.includes(targetElement)) { + continue; + } + if (elements.length === 1) { + updateResult(candidate); + break; + } + const index = elements.indexOf(targetElement); + if (index > 5) { + continue; + } + updateResult([...candidate, { engine: "nth", selector: String(index), score: kNthScore }]); + if (options.isRecursive) { + continue; + } + for (let parent = parentElementOrShadowHost(targetElement); parent && parent !== options.root; parent = parentElementOrShadowHost(parent)) { + const filtered = elements.filter((e) => isInsideScope(parent, e) && e !== parent); + const newIndex = filtered.indexOf(targetElement); + if (filtered.length > 5 || newIndex === -1 || newIndex === index && filtered.length > 1) { + continue; + } + const inParent = filtered.length === 1 ? candidate : [...candidate, { engine: "nth", selector: String(newIndex), score: kNthScore }]; + const idealSelectorForParent = { engine: "", selector: "", score: 1 }; + if (result && combineScores([idealSelectorForParent, ...inParent]) >= combineScores(result)) { + continue; + } + const noText = !!options.noText || isTextCandidate; + const cacheMap = noText ? cache.disallowText : cache.allowText; + let parentTokens = cacheMap.get(parent); + if (parentTokens === void 0) { + parentTokens = generateSelectorFor(cache, injectedScript, parent, { ...options, isRecursive: true, noText }) || cssFallback(injectedScript, parent, options); + cacheMap.set(parent, parentTokens); + } + if (!parentTokens) + continue; + updateResult([...parentTokens, ...inParent]); + } + } + return result; +} +function buildNoTextCandidates(injectedScript, element, options) { + const candidates = []; + const testIdAttributeNames = splitTestIdAttributeNames(options.testIdAttributeName); + { + for (const attr of ["data-testid", "data-test-id", "data-test"]) { + if (!testIdAttributeNames.includes(attr) && element.getAttribute(attr)) + candidates.push({ engine: "css", selector: `[${attr}=${quoteCSSAttributeValue(element.getAttribute(attr))}]`, score: kOtherTestIdScore }); + } + if (!options.noCSSId) { + const idAttr = element.getAttribute("id"); + if (idAttr && !isGuidLike(idAttr)) + candidates.push({ engine: "css", selector: makeSelectorForId(idAttr), score: kCSSIdScore }); + } + candidates.push({ engine: "css", selector: escapeNodeName(element), score: kCSSTagNameScore }); + } + if (element.nodeName === "IFRAME") { + for (const attribute of ["name", "title"]) { + if (element.getAttribute(attribute)) + candidates.push({ engine: "css", selector: `${escapeNodeName(element)}[${attribute}=${quoteCSSAttributeValue(element.getAttribute(attribute))}]`, score: kIframeByAttributeScore }); + } + for (const testIdAttr of testIdAttributeNames) { + if (element.getAttribute(testIdAttr)) + candidates.push({ engine: "css", selector: `[${testIdAttr}=${quoteCSSAttributeValue(element.getAttribute(testIdAttr))}]`, score: kTestIdScore }); + } + penalizeScoreForLength([candidates]); + return candidates; + } + for (const testIdAttr of testIdAttributeNames) { + if (element.getAttribute(testIdAttr)) + candidates.push({ engine: "internal:testid", selector: `[${testIdAttr}=${escapeForAttributeSelector(element.getAttribute(testIdAttr), true)}]`, score: kTestIdScore }); + } + if (element.nodeName === "INPUT" || element.nodeName === "TEXTAREA") { + const input = element; + if (input.placeholder) { + candidates.push({ engine: "internal:attr", selector: `[placeholder=${escapeForAttributeSelector(input.placeholder, true)}]`, score: kPlaceholderScoreExact }); + for (const alternative of suitableTextAlternatives(input.placeholder)) + candidates.push({ engine: "internal:attr", selector: `[placeholder=${escapeForAttributeSelector(alternative.text, false)}]`, score: kPlaceholderScore - alternative.scoreBonus }); + } + } + const labels = getElementLabels(injectedScript._evaluator._cacheText, element); + for (const label of labels) { + const labelText = label.normalized; + candidates.push({ engine: "internal:label", selector: escapeForTextSelector(labelText, true), score: kLabelScoreExact }); + for (const alternative of suitableTextAlternatives(labelText)) + candidates.push({ engine: "internal:label", selector: escapeForTextSelector(alternative.text, false), score: kLabelScore - alternative.scoreBonus }); + } + const ariaRole = getAriaRole(element); + if (ariaRole && !["none", "presentation"].includes(ariaRole)) + candidates.push({ engine: "internal:role", selector: ariaRole, score: kRoleWithoutNameScore }); + if (element.getAttribute("name") && ["BUTTON", "FORM", "FIELDSET", "FRAME", "IFRAME", "INPUT", "KEYGEN", "OBJECT", "OUTPUT", "SELECT", "TEXTAREA", "MAP", "META", "PARAM"].includes(element.nodeName)) + candidates.push({ engine: "css", selector: `${escapeNodeName(element)}[name=${quoteCSSAttributeValue(element.getAttribute("name"))}]`, score: kCSSInputTypeNameScore }); + if (["INPUT", "TEXTAREA"].includes(element.nodeName) && element.getAttribute("type") !== "hidden") { + if (element.getAttribute("type")) + candidates.push({ engine: "css", selector: `${escapeNodeName(element)}[type=${quoteCSSAttributeValue(element.getAttribute("type"))}]`, score: kCSSInputTypeNameScore }); + } + if (["INPUT", "TEXTAREA", "SELECT"].includes(element.nodeName) && element.getAttribute("type") !== "hidden") + candidates.push({ engine: "css", selector: escapeNodeName(element), score: kCSSInputTypeNameScore + 1 }); + penalizeScoreForLength([candidates]); + return candidates; +} +function buildTextCandidates(injectedScript, element, isTargetNode) { + if (element.nodeName === "SELECT") + return []; + const candidates = []; + const title = element.getAttribute("title"); + if (title) { + candidates.push([{ engine: "internal:attr", selector: `[title=${escapeForAttributeSelector(title, true)}]`, score: kTitleScoreExact }]); + for (const alternative of suitableTextAlternatives(title)) + candidates.push([{ engine: "internal:attr", selector: `[title=${escapeForAttributeSelector(alternative.text, false)}]`, score: kTitleScore - alternative.scoreBonus }]); + } + const alt = element.getAttribute("alt"); + if (alt && ["APPLET", "AREA", "IMG", "INPUT"].includes(element.nodeName)) { + candidates.push([{ engine: "internal:attr", selector: `[alt=${escapeForAttributeSelector(alt, true)}]`, score: kAltTextScoreExact }]); + for (const alternative of suitableTextAlternatives(alt)) + candidates.push([{ engine: "internal:attr", selector: `[alt=${escapeForAttributeSelector(alternative.text, false)}]`, score: kAltTextScore - alternative.scoreBonus }]); + } + const text = elementText(injectedScript._evaluator._cacheText, element).normalized; + const textAlternatives = text ? suitableTextAlternatives(text) : []; + if (text) { + if (isTargetNode) { + if (text.length <= 80) + candidates.push([{ engine: "internal:text", selector: escapeForTextSelector(text, true), score: kTextScoreExact }]); + for (const alternative of textAlternatives) + candidates.push([{ engine: "internal:text", selector: escapeForTextSelector(alternative.text, false), score: kTextScore - alternative.scoreBonus }]); + } + const cssToken = { engine: "css", selector: escapeNodeName(element), score: kCSSTagNameScore }; + for (const alternative of textAlternatives) + candidates.push([cssToken, { engine: "internal:has-text", selector: escapeForTextSelector(alternative.text, false), score: kTextScore - alternative.scoreBonus }]); + if (isTargetNode && text.length <= 80) { + const re = new RegExp("^" + escapeRegExp(text) + "$"); + candidates.push([cssToken, { engine: "internal:has-text", selector: escapeForTextSelector(re, false), score: kTextScoreRegex }]); + } + } + const ariaRole = getAriaRole(element); + if (ariaRole && !["none", "presentation"].includes(ariaRole)) { + const ariaName = getElementAccessibleName(element, false); + if (ariaName && !ariaName.match(/^\p{Co}+$/u)) { + const roleToken = { engine: "internal:role", selector: `${ariaRole}[name=${escapeForAttributeSelector(ariaName, true)}]`, score: kRoleWithNameScoreExact }; + candidates.push([roleToken]); + for (const alternative of suitableTextAlternatives(ariaName)) + candidates.push([{ engine: "internal:role", selector: `${ariaRole}[name=${escapeForAttributeSelector(alternative.text, false)}]`, score: kRoleWithNameScore - alternative.scoreBonus }]); + const ariaDescription = getElementAccessibleDescription(element, false); + if (ariaDescription) { + candidates.push([{ engine: "internal:role", selector: `${ariaRole}[name=${escapeForAttributeSelector(ariaName, true)}][description=${escapeForAttributeSelector(ariaDescription, true)}]`, score: kRoleWithNameScoreExact + 1 }]); + for (const alternative of suitableTextAlternatives(ariaName)) + candidates.push([{ engine: "internal:role", selector: `${ariaRole}[name=${escapeForAttributeSelector(alternative.text, false)}][description=${escapeForAttributeSelector(ariaDescription, false)}]`, score: kRoleWithNameScore - alternative.scoreBonus + 1 }]); + } + } else { + const roleToken = { engine: "internal:role", selector: `${ariaRole}`, score: kRoleWithoutNameScore }; + const ariaDescription = getElementAccessibleDescription(element, false); + if (ariaDescription) + candidates.push([{ engine: "internal:role", selector: `${ariaRole}[description=${escapeForAttributeSelector(ariaDescription, true)}]`, score: kRoleWithoutNameScore + 1 }]); + for (const alternative of textAlternatives) + candidates.push([roleToken, { engine: "internal:has-text", selector: escapeForTextSelector(alternative.text, false), score: kTextScore - alternative.scoreBonus }]); + if (isTargetNode && text.length <= 80) { + const re = new RegExp("^" + escapeRegExp(text) + "$"); + candidates.push([roleToken, { engine: "internal:has-text", selector: escapeForTextSelector(re, false), score: kTextScoreRegex }]); + } + } + } + penalizeScoreForLength(candidates); + return candidates; +} +function makeSelectorForId(id) { + return /^[a-zA-Z][a-zA-Z0-9\-\_]+$/.test(id) ? "#" + id : `[id=${quoteCSSAttributeValue(id)}]`; +} +function hasCSSIdToken(tokens) { + return tokens.some((token) => token.engine === "css" && (token.selector.startsWith("#") || token.selector.startsWith('[id="'))); +} +function cssFallback(injectedScript, targetElement, options) { + var _a; + const root = (_a = options.root) != null ? _a : targetElement.ownerDocument; + const tokens = []; + function uniqueCSSSelector(prefix) { + const path = tokens.slice(); + if (prefix) + path.unshift(prefix); + const selector = path.join(" > "); + const parsedSelector = injectedScript.parseSelector(selector); + const node = injectedScript.querySelector(parsedSelector, root, false); + return node === targetElement ? selector : void 0; + } + function makeStrict(selector) { + const token = { engine: "css", selector, score: kCSSFallbackScore }; + const parsedSelector = injectedScript.parseSelector(selector); + const elements = injectedScript.querySelectorAll(parsedSelector, root); + if (elements.length === 1) + return [token]; + const nth = { engine: "nth", selector: String(elements.indexOf(targetElement)), score: kNthScore }; + return [token, nth]; + } + for (let element = targetElement; element && element !== root; element = parentElementOrShadowHost(element)) { + let bestTokenForLevel = ""; + if (element.id && !options.noCSSId) { + const token = makeSelectorForId(element.id); + const selector = uniqueCSSSelector(token); + if (selector) + return makeStrict(selector); + bestTokenForLevel = token; + } + const parent = element.parentNode; + const classes = [...element.classList].map(escapeClassName); + for (let i = 0; i < classes.length; ++i) { + const token = "." + classes.slice(0, i + 1).join("."); + const selector = uniqueCSSSelector(token); + if (selector) + return makeStrict(selector); + if (!bestTokenForLevel && parent) { + const sameClassSiblings = parent.querySelectorAll(token); + if (sameClassSiblings.length === 1) + bestTokenForLevel = token; + } + } + if (parent) { + const siblings = [...parent.children]; + const nodeName = element.nodeName; + const sameTagSiblings = siblings.filter((sibling) => sibling.nodeName === nodeName); + const token = sameTagSiblings.indexOf(element) === 0 ? escapeNodeName(element) : `${escapeNodeName(element)}:nth-child(${1 + siblings.indexOf(element)})`; + const selector = uniqueCSSSelector(token); + if (selector) + return makeStrict(selector); + if (!bestTokenForLevel) + bestTokenForLevel = token; + } else if (!bestTokenForLevel) { + bestTokenForLevel = escapeNodeName(element); + } + tokens.unshift(bestTokenForLevel); + } + return makeStrict(uniqueCSSSelector()); +} +function penalizeScoreForLength(groups) { + for (const group of groups) { + for (const token of group) { + if (token.score > kBeginPenalizedScore && token.score < kEndPenalizedScore) + token.score += Math.min(kTextScoreRange, token.selector.length / 10 | 0); + } + } +} +function joinTokens(tokens) { + const parts = []; + let lastEngine = ""; + for (const { engine, selector } of tokens) { + if (parts.length && (lastEngine !== "css" || engine !== "css" || selector.startsWith(":nth-match("))) + parts.push(">>"); + lastEngine = engine; + if (engine === "css") + parts.push(selector); + else + parts.push(`${engine}=${selector}`); + } + return parts.join(" "); +} +function combineScores(tokens) { + let score = 0; + for (let i = 0; i < tokens.length; i++) + score += tokens[i].score * (tokens.length - i); + return score; +} +function isGuidLike(id) { + let lastCharacterType; + let transitionCount = 0; + for (let i = 0; i < id.length; ++i) { + const c = id[i]; + let characterType; + if (c === "-" || c === "_") + continue; + if (c >= "a" && c <= "z") + characterType = "lower"; + else if (c >= "A" && c <= "Z") + characterType = "upper"; + else if (c >= "0" && c <= "9") + characterType = "digit"; + else + characterType = "other"; + if (characterType === "lower" && lastCharacterType === "upper") { + lastCharacterType = characterType; + continue; + } + if (lastCharacterType && lastCharacterType !== characterType) + ++transitionCount; + lastCharacterType = characterType; + } + return transitionCount >= id.length / 4; +} +function trimWordBoundary(text, maxLength) { + if (text.length <= maxLength) + return text; + text = text.substring(0, maxLength); + const match = text.match(/^(.*)\b(.+?)$/); + if (!match) + return ""; + return match[1].trimEnd(); +} +function suitableTextAlternatives(text) { + let result = []; + { + const match = text.match(/^([\d.,]+)[^.,\w]/); + const leadingNumberLength = match ? match[1].length : 0; + if (leadingNumberLength) { + const alt = trimWordBoundary(text.substring(leadingNumberLength).trimStart(), 80); + result.push({ text: alt, scoreBonus: alt.length <= 30 ? 2 : 1 }); + } + } + { + const match = text.match(/[^.,\w]([\d.,]+)$/); + const trailingNumberLength = match ? match[1].length : 0; + if (trailingNumberLength) { + const alt = trimWordBoundary(text.substring(0, text.length - trailingNumberLength).trimEnd(), 80); + result.push({ text: alt, scoreBonus: alt.length <= 30 ? 2 : 1 }); + } + } + if (text.length <= 30) { + result.push({ text, scoreBonus: 0 }); + } else { + result.push({ text: trimWordBoundary(text, 80), scoreBonus: 0 }); + result.push({ text: trimWordBoundary(text, 30), scoreBonus: 1 }); + } + result = result.filter((r) => r.text); + if (!result.length) + result.push({ text: text.substring(0, 80), scoreBonus: 0 }); + return result; +} +function escapeNodeName(node) { + return node.nodeName.toLocaleLowerCase().replace(/[:\.]/g, (char) => "\\" + char); +} +function escapeClassName(className) { + let result = ""; + for (let i = 0; i < className.length; i++) + result += cssEscapeCharacter(className, i); + return result; +} +function cssEscapeCharacter(s, i) { + const c = s.charCodeAt(i); + if (c === 0) + return "\uFFFD"; + if (c >= 1 && c <= 31 || c >= 48 && c <= 57 && (i === 0 || i === 1 && s.charCodeAt(0) === 45)) + return "\\" + c.toString(16) + " "; + if (i === 0 && c === 45 && s.length === 1) + return "\\" + s.charAt(i); + if (c >= 128 || c === 45 || c === 95 || c >= 48 && c <= 57 || c >= 65 && c <= 90 || c >= 97 && c <= 122) + return s.charAt(i); + return "\\" + s.charAt(i); +} + +// packages/injected/src/xpathSelectorEngine.ts +var XPathEngine = { + queryAll(root, selector) { + if (selector.startsWith("/") && root.nodeType !== Node.DOCUMENT_NODE) + selector = "." + selector; + const result = []; + const document = root.ownerDocument || root; + if (!document) + return result; + const it = document.evaluate(selector, root, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE); + for (let node = it.iterateNext(); node; node = it.iterateNext()) { + if (node.nodeType === Node.ELEMENT_NODE) + result.push(node); + } + return result; + } +}; + +// packages/injected/src/consoleApi.ts +var selectorSymbol = Symbol("selector"); +selectorSymbol; +var _Locator = class _Locator { + constructor(injectedScript, selector, options) { + if (options == null ? void 0 : options.hasText) + selector += ` >> internal:has-text=${escapeForTextSelector(options.hasText, false)}`; + if (options == null ? void 0 : options.hasNotText) + selector += ` >> internal:has-not-text=${escapeForTextSelector(options.hasNotText, false)}`; + if (options == null ? void 0 : options.has) + selector += ` >> internal:has=` + JSON.stringify(options.has[selectorSymbol]); + if (options == null ? void 0 : options.hasNot) + selector += ` >> internal:has-not=` + JSON.stringify(options.hasNot[selectorSymbol]); + if ((options == null ? void 0 : options.visible) !== void 0) + selector += ` >> visible=${options.visible ? "true" : "false"}`; + this[selectorSymbol] = selector; + if (selector) { + const parsed = injectedScript.parseSelector(selector); + this.element = injectedScript.querySelector(parsed, injectedScript.document, false); + this.elements = injectedScript.querySelectorAll(parsed, injectedScript.document); + } + const selectorBase = selector; + const self = this; + self.locator = (selector2, options2) => { + return new _Locator(injectedScript, selectorBase ? selectorBase + " >> " + selector2 : selector2, options2); + }; + self.getByTestId = (testId) => self.locator(getByTestIdSelector(injectedScript.testIdAttributeNameForStrictErrorAndConsoleCodegen(), testId)); + self.getByAltText = (text, options2) => self.locator(getByAltTextSelector(text, options2)); + self.getByLabel = (text, options2) => self.locator(getByLabelSelector(text, options2)); + self.getByPlaceholder = (text, options2) => self.locator(getByPlaceholderSelector(text, options2)); + self.getByText = (text, options2) => self.locator(getByTextSelector(text, options2)); + self.getByTitle = (text, options2) => self.locator(getByTitleSelector(text, options2)); + self.getByRole = (role, options2 = {}) => self.locator(getByRoleSelector(role, options2)); + self.filter = (options2) => new _Locator(injectedScript, selector, options2); + self.first = () => self.locator("nth=0"); + self.last = () => self.locator("nth=-1"); + self.nth = (index) => self.locator(`nth=${index}`); + self.and = (locator) => new _Locator(injectedScript, selectorBase + ` >> internal:and=` + JSON.stringify(locator[selectorSymbol])); + self.or = (locator) => new _Locator(injectedScript, selectorBase + ` >> internal:or=` + JSON.stringify(locator[selectorSymbol])); + } +}; +var Locator = _Locator; +var ConsoleAPI = class { + constructor(injectedScript) { + this._injectedScript = injectedScript; + } + install() { + if (this._injectedScript.window.playwright) + return; + this._injectedScript.window.playwright = { + $: (selector, strict) => this._querySelector(selector, !!strict), + $$: (selector) => this._querySelectorAll(selector), + inspect: (selector) => this._inspect(selector), + selector: (element) => this._selector(element), + generateLocator: (element, language) => this._generateLocator(element, language), + ariaSnapshot: (element, options) => { + return this._injectedScript.ariaSnapshot(element || this._injectedScript.document.body, options || { mode: "default" }); + }, + resume: () => this._resume(), + ...new Locator(this._injectedScript, "") + }; + delete this._injectedScript.window.playwright.filter; + delete this._injectedScript.window.playwright.first; + delete this._injectedScript.window.playwright.last; + delete this._injectedScript.window.playwright.nth; + delete this._injectedScript.window.playwright.and; + delete this._injectedScript.window.playwright.or; + } + _querySelector(selector, strict) { + if (typeof selector !== "string") + throw new Error(`Usage: playwright.query('Playwright >> selector').`); + const parsed = this._injectedScript.parseSelector(selector); + return this._injectedScript.querySelector(parsed, this._injectedScript.document, strict); + } + _querySelectorAll(selector) { + if (typeof selector !== "string") + throw new Error(`Usage: playwright.$$('Playwright >> selector').`); + const parsed = this._injectedScript.parseSelector(selector); + return this._injectedScript.querySelectorAll(parsed, this._injectedScript.document); + } + _inspect(selector) { + if (typeof selector !== "string") + throw new Error(`Usage: playwright.inspect('Playwright >> selector').`); + this._injectedScript.window.inspect(this._querySelector(selector, false)); + } + _selector(element) { + if (!(element instanceof Element)) + throw new Error(`Usage: playwright.selector(element).`); + return this._injectedScript.generateSelectorSimple(element); + } + _generateLocator(element, language) { + if (!(element instanceof Element)) + throw new Error(`Usage: playwright.locator(element).`); + const selector = this._injectedScript.generateSelectorSimple(element); + return asLocator(language || "javascript", selector); + } + _resume() { + if (!this._injectedScript.window.__pw_resume) + return false; + this._injectedScript.window.__pw_resume().catch(() => { + }); + } +}; + +// packages/isomorphic/utilityScriptSerializers.ts +function isRegExp2(obj) { + try { + return obj instanceof RegExp || Object.prototype.toString.call(obj) === "[object RegExp]"; + } catch (error) { + return false; + } +} +function isDate(obj) { + try { + return obj instanceof Date || Object.prototype.toString.call(obj) === "[object Date]"; + } catch (error) { + return false; + } +} +function isURL(obj) { + try { + return obj instanceof URL || Object.prototype.toString.call(obj) === "[object URL]"; + } catch (error) { + return false; + } +} +function isError(obj) { + var _a; + try { + return obj instanceof Error || obj && ((_a = Object.getPrototypeOf(obj)) == null ? void 0 : _a.name) === "Error"; + } catch (error) { + return false; + } +} +function isTypedArray(obj, constructor) { + try { + return obj instanceof constructor || Object.prototype.toString.call(obj) === `[object ${constructor.name}]`; + } catch (error) { + return false; + } +} +function isArrayBuffer(obj) { + try { + return obj instanceof ArrayBuffer || Object.prototype.toString.call(obj) === "[object ArrayBuffer]"; + } catch (error) { + return false; + } +} +var typedArrayConstructors = { + i8: Int8Array, + ui8: Uint8Array, + ui8c: Uint8ClampedArray, + i16: Int16Array, + ui16: Uint16Array, + i32: Int32Array, + ui32: Uint32Array, + // TODO: add Float16Array once it's in baseline + f32: Float32Array, + f64: Float64Array, + bi64: BigInt64Array, + bui64: BigUint64Array +}; +function typedArrayToBase64(array) { + if ("toBase64" in array) + return array.toBase64(); + const binary = Array.from(new Uint8Array(array.buffer, array.byteOffset, array.byteLength)).map((b) => String.fromCharCode(b)).join(""); + return btoa(binary); +} +function base64ToTypedArray(base64, TypedArrayConstructor) { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) + bytes[i] = binary.charCodeAt(i); + return new TypedArrayConstructor(bytes.buffer); +} +function parseEvaluationResultValue(value, handles = [], refs = /* @__PURE__ */ new Map()) { + if (Object.is(value, void 0)) + return void 0; + if (typeof value === "object" && value) { + if ("ref" in value) + return refs.get(value.ref); + if ("v" in value) { + if (value.v === "undefined") + return void 0; + if (value.v === "null") + return null; + if (value.v === "NaN") + return NaN; + if (value.v === "Infinity") + return Infinity; + if (value.v === "-Infinity") + return -Infinity; + if (value.v === "-0") + return -0; + return void 0; + } + if ("d" in value) { + return new Date(value.d); + } + if ("u" in value) + return new URL(value.u); + if ("bi" in value) + return BigInt(value.bi); + if ("e" in value) { + const error = new Error(value.e.m); + error.name = value.e.n; + error.stack = value.e.s; + return error; + } + if ("r" in value) + return new RegExp(value.r.p, value.r.f); + if ("a" in value) { + const result = []; + refs.set(value.id, result); + for (const a of value.a) + result.push(parseEvaluationResultValue(a, handles, refs)); + return result; + } + if ("o" in value) { + const result = {}; + refs.set(value.id, result); + for (const { k, v } of value.o) { + if (k === "__proto__") + continue; + result[k] = parseEvaluationResultValue(v, handles, refs); + } + return result; + } + if ("h" in value) + return handles[value.h]; + if ("ta" in value) + return base64ToTypedArray(value.ta.b, typedArrayConstructors[value.ta.k]); + if ("ab" in value) + return base64ToTypedArray(value.ab.b, Uint8Array).buffer; + } + return value; +} +function serializeAsCallArgument(value, handleSerializer) { + return serialize(value, handleSerializer, { visited: /* @__PURE__ */ new Map(), lastId: 0 }); +} +function serialize(value, handleSerializer, visitorInfo) { + if (value && typeof value === "object") { + if (typeof globalThis.Window === "function" && value instanceof globalThis.Window) + return "ref: "; + if (typeof globalThis.Document === "function" && value instanceof globalThis.Document) + return "ref: "; + if (typeof globalThis.Node === "function" && value instanceof globalThis.Node) + return "ref: "; + } + return innerSerialize(value, handleSerializer, visitorInfo); +} +function innerSerialize(value, handleSerializer, visitorInfo) { + var _a; + const result = handleSerializer(value); + if ("fallThrough" in result) + value = result.fallThrough; + else + return result; + if (typeof value === "symbol") + return { v: "undefined" }; + if (Object.is(value, void 0)) + return { v: "undefined" }; + if (Object.is(value, null)) + return { v: "null" }; + if (Object.is(value, NaN)) + return { v: "NaN" }; + if (Object.is(value, Infinity)) + return { v: "Infinity" }; + if (Object.is(value, -Infinity)) + return { v: "-Infinity" }; + if (Object.is(value, -0)) + return { v: "-0" }; + if (typeof value === "boolean") + return value; + if (typeof value === "number") + return value; + if (typeof value === "string") + return value; + if (typeof value === "bigint") + return { bi: value.toString() }; + if (isError(value)) { + let stack; + if ((_a = value.stack) == null ? void 0 : _a.startsWith(value.name + ": " + value.message)) { + stack = value.stack; + } else { + stack = `${value.name}: ${value.message} +${value.stack}`; + } + return { e: { n: value.name, m: value.message, s: stack } }; + } + if (isDate(value)) + return { d: value.toJSON() }; + if (isURL(value)) + return { u: value.toJSON() }; + if (isRegExp2(value)) + return { r: { p: value.source, f: value.flags } }; + for (const [k, ctor] of Object.entries(typedArrayConstructors)) { + if (isTypedArray(value, ctor)) + return { ta: { b: typedArrayToBase64(value), k } }; + } + if (isArrayBuffer(value)) + return { ab: { b: typedArrayToBase64(new Uint8Array(value)) } }; + const id = visitorInfo.visited.get(value); + if (id) + return { ref: id }; + if (Array.isArray(value)) { + const a = []; + const id2 = ++visitorInfo.lastId; + visitorInfo.visited.set(value, id2); + for (let i = 0; i < value.length; ++i) + a.push(serialize(value[i], handleSerializer, visitorInfo)); + return { a, id: id2 }; + } + if (typeof value === "object") { + const o = []; + const id2 = ++visitorInfo.lastId; + visitorInfo.visited.set(value, id2); + for (const name of Object.keys(value)) { + let item; + try { + item = value[name]; + } catch (e) { + continue; + } + if (name === "toJSON" && typeof item === "function") + o.push({ k: name, v: { o: [], id: 0 } }); + else + o.push({ k: name, v: serialize(item, handleSerializer, visitorInfo) }); + } + let jsonWrapper; + try { + if (o.length === 0 && value.toJSON && typeof value.toJSON === "function") + jsonWrapper = { value: value.toJSON() }; + } catch (e) { + } + if (jsonWrapper) + return innerSerialize(jsonWrapper.value, handleSerializer, visitorInfo); + return { o, id: id2 }; + } +} + +// packages/injected/src/utilityScript.ts +var UtilityScript = class { + constructor(global, isUnderTest) { + var _a, _b, _c, _d, _e, _f, _g, _h; + this.global = global; + this.isUnderTest = isUnderTest; + if (global.__pwClock) { + this.builtins = global.__pwClock.builtins; + } else { + this.builtins = { + setTimeout: (_a = global.setTimeout) == null ? void 0 : _a.bind(global), + clearTimeout: (_b = global.clearTimeout) == null ? void 0 : _b.bind(global), + setInterval: (_c = global.setInterval) == null ? void 0 : _c.bind(global), + clearInterval: (_d = global.clearInterval) == null ? void 0 : _d.bind(global), + requestAnimationFrame: (_e = global.requestAnimationFrame) == null ? void 0 : _e.bind(global), + cancelAnimationFrame: (_f = global.cancelAnimationFrame) == null ? void 0 : _f.bind(global), + requestIdleCallback: (_g = global.requestIdleCallback) == null ? void 0 : _g.bind(global), + cancelIdleCallback: (_h = global.cancelIdleCallback) == null ? void 0 : _h.bind(global), + performance: global.performance, + Intl: global.Intl, + Date: global.Date, + AbortSignal: global.AbortSignal + }; + } + if (this.isUnderTest) + global.builtins = this.builtins; + } + evaluate(isFunction, returnByValue, expression, argCount, ...argsAndHandles) { + const args = argsAndHandles.slice(0, argCount); + const handles = argsAndHandles.slice(argCount); + const parameters = []; + for (let i = 0; i < args.length; i++) + parameters[i] = parseEvaluationResultValue(args[i], handles); + let result = this.global.eval(expression); + if (isFunction === true) { + result = result(...parameters); + } else if (isFunction === false) { + result = result; + } else { + if (typeof result === "function") + result = result(...parameters); + } + return returnByValue ? this._promiseAwareJsonValueNoThrow(result) : result; + } + jsonValue(returnByValue, value) { + if (value === void 0) + return void 0; + return serializeAsCallArgument(value, (value2) => ({ fallThrough: value2 })); + } + _promiseAwareJsonValueNoThrow(value) { + const safeJson = (value2) => { + try { + return this.jsonValue(true, value2); + } catch (e) { + return void 0; + } + }; + if (value && typeof value === "object" && typeof value.then === "function") { + return (async () => { + const promiseValue = await value; + return safeJson(promiseValue); + })(); + } + return safeJson(value); + } +}; + +// packages/injected/src/injectedScript.ts +var InjectedScript = class { + constructor(window, options) { + this._testIdAttributeNameForStrictErrorAndConsoleCodegen = "data-testid"; + this._lastAriaSnapshotForTrack = /* @__PURE__ */ new Map(); + // Recorder must use any external dependencies through InjectedScript. + // Otherwise it will end up with a copy of all modules it uses, and any + // module-level globals will be duplicated, which leads to subtle bugs. + this.utils = { + asLocator, + cacheNormalizedWhitespaces, + elementText, + getAriaRole, + getElementAccessibleDescription, + getElementAccessibleName, + isElementVisible, + isInsideScope, + normalizeWhiteSpace, + parseAriaSnapshot, + generateAriaTree, + findNewElement, + // Builtins protect injected code from clock emulation. + builtins: null + }; + this.window = window; + this.document = window.document; + this.isUnderTest = options.isUnderTest; + this.utils.builtins = new UtilityScript(window, options.isUnderTest).builtins; + this._sdkLanguage = options.sdkLanguage; + this._testIdAttributeNameForStrictErrorAndConsoleCodegen = options.testIdAttributeName; + this._evaluator = new SelectorEvaluatorImpl(); + this.consoleApi = new ConsoleAPI(this); + this.onGlobalListenersRemoved = /* @__PURE__ */ new Set(); + this._autoClosingTags = /* @__PURE__ */ new Set(["AREA", "BASE", "BR", "COL", "COMMAND", "EMBED", "HR", "IMG", "INPUT", "KEYGEN", "LINK", "MENUITEM", "META", "PARAM", "SOURCE", "TRACK", "WBR"]); + this._booleanAttributes = /* @__PURE__ */ new Set(["checked", "selected", "disabled", "readonly", "multiple"]); + this._eventTypes = /* @__PURE__ */ new Map([ + ["auxclick", "mouse"], + ["click", "mouse"], + ["dblclick", "mouse"], + ["mousedown", "mouse"], + ["mouseeenter", "mouse"], + ["mouseleave", "mouse"], + ["mousemove", "mouse"], + ["mouseout", "mouse"], + ["mouseover", "mouse"], + ["mouseup", "mouse"], + ["mouseleave", "mouse"], + ["mousewheel", "mouse"], + ["keydown", "keyboard"], + ["keyup", "keyboard"], + ["keypress", "keyboard"], + ["textInput", "keyboard"], + ["touchstart", "touch"], + ["touchmove", "touch"], + ["touchend", "touch"], + ["touchcancel", "touch"], + ["pointerover", "pointer"], + ["pointerout", "pointer"], + ["pointerenter", "pointer"], + ["pointerleave", "pointer"], + ["pointerdown", "pointer"], + ["pointerup", "pointer"], + ["pointermove", "pointer"], + ["pointercancel", "pointer"], + ["gotpointercapture", "pointer"], + ["lostpointercapture", "pointer"], + ["focus", "focus"], + ["blur", "focus"], + ["drag", "drag"], + ["dragstart", "drag"], + ["dragend", "drag"], + ["dragover", "drag"], + ["dragenter", "drag"], + ["dragleave", "drag"], + ["dragexit", "drag"], + ["drop", "drag"], + ["wheel", "wheel"], + ["deviceorientation", "deviceorientation"], + ["deviceorientationabsolute", "deviceorientation"], + ["devicemotion", "devicemotion"] + ]); + this._hoverHitTargetInterceptorEvents = /* @__PURE__ */ new Set(["mousemove"]); + this._tapHitTargetInterceptorEvents = /* @__PURE__ */ new Set(["pointerdown", "pointerup", "touchstart", "touchend", "touchcancel"]); + this._mouseHitTargetInterceptorEvents = /* @__PURE__ */ new Set(["mousedown", "mouseup", "pointerdown", "pointerup", "click", "auxclick", "dblclick", "contextmenu"]); + this._allHitTargetInterceptorEvents = /* @__PURE__ */ new Set([...this._hoverHitTargetInterceptorEvents, ...this._tapHitTargetInterceptorEvents, ...this._mouseHitTargetInterceptorEvents]); + this._engines = /* @__PURE__ */ new Map(); + this._engines.set("xpath", XPathEngine); + this._engines.set("xpath:light", XPathEngine); + this._engines.set("role", createRoleEngine(false)); + this._engines.set("text", this._createTextEngine(true, false)); + this._engines.set("text:light", this._createTextEngine(false, false)); + this._engines.set("id", this._createAttributeEngine("id", true)); + this._engines.set("id:light", this._createAttributeEngine("id", false)); + this._engines.set("data-testid", this._createAttributeEngine("data-testid", true)); + this._engines.set("data-testid:light", this._createAttributeEngine("data-testid", false)); + this._engines.set("data-test-id", this._createAttributeEngine("data-test-id", true)); + this._engines.set("data-test-id:light", this._createAttributeEngine("data-test-id", false)); + this._engines.set("data-test", this._createAttributeEngine("data-test", true)); + this._engines.set("data-test:light", this._createAttributeEngine("data-test", false)); + this._engines.set("css", this._createCSSEngine()); + this._engines.set("nth", { queryAll: () => [] }); + this._engines.set("visible", this._createVisibleEngine()); + this._engines.set("internal:control", this._createControlEngine()); + this._engines.set("internal:has", this._createHasEngine()); + this._engines.set("internal:has-not", this._createHasNotEngine()); + this._engines.set("internal:and", { queryAll: () => [] }); + this._engines.set("internal:or", { queryAll: () => [] }); + this._engines.set("internal:chain", this._createInternalChainEngine()); + this._engines.set("internal:label", this._createInternalLabelEngine()); + this._engines.set("internal:text", this._createTextEngine(true, true)); + this._engines.set("internal:has-text", this._createInternalHasTextEngine()); + this._engines.set("internal:has-not-text", this._createInternalHasNotTextEngine()); + this._engines.set("internal:attr", this._createNamedAttributeEngine()); + this._engines.set("internal:testid", this._createTestIdEngine()); + this._engines.set("internal:role", createRoleEngine(true)); + this._engines.set("internal:describe", this._createDescribeEngine()); + this._engines.set("aria-ref", this._createAriaRefEngine()); + for (const { name, source } of options.customEngines) + this._engines.set(name, this.eval(source)); + this._stableRafCount = options.stableRafCount; + this._browserName = options.browserName; + this._shouldPrependErrorPrefix = !!options.shouldPrependErrorPrefix; + this._isUtilityWorld = !!options.isUtilityWorld; + setGlobalOptions({ browserNameForWorkarounds: options.browserName }); + this._setupGlobalListenersRemovalDetection(); + this._setupHitTargetInterceptors(); + if (this.isUnderTest) + this.window.__injectedScript = this; + } + eval(expression) { + return this.window.eval(expression); + } + testIdAttributeNameForStrictErrorAndConsoleCodegen() { + return this._testIdAttributeNameForStrictErrorAndConsoleCodegen; + } + parseSelector(selector) { + const result = parseSelector(selector); + visitAllSelectorParts(result, (part) => { + if (!this._engines.has(part.name)) + throw this.createStacklessError(`Unknown engine "${part.name}" while parsing selector ${selector}`); + }); + return result; + } + generateSelector(targetElement, options) { + return generateSelector(this, targetElement, options); + } + generateSelectorSimple(targetElement, options) { + return generateSelector(this, targetElement, { ...options, testIdAttributeName: this._testIdAttributeNameForStrictErrorAndConsoleCodegen }).selector; + } + querySelector(selector, root, strict) { + const result = this.querySelectorAll(selector, root); + if (strict && result.length > 1) + throw this.strictModeViolationError(selector, result); + this.checkDeprecatedSelectorUsage(selector, result); + return result[0]; + } + _queryNth(elements, part) { + const list = [...elements]; + let nth = +part.body; + if (nth === -1) + nth = list.length - 1; + return new Set(list.slice(nth, nth + 1)); + } + _queryLayoutSelector(elements, part, originalRoot) { + const name = part.name; + const body = part.body; + const result = []; + const inner = this.querySelectorAll(body.parsed, originalRoot); + for (const element of elements) { + const score = layoutSelectorScore(name, element, inner, body.distance); + if (score !== void 0) + result.push({ element, score }); + } + result.sort((a, b) => a.score - b.score); + return new Set(result.map((r) => r.element)); + } + ariaSnapshot(node, options) { + return this.incrementalAriaSnapshot(node, options).full; + } + incrementalAriaSnapshot(node, options) { + if (node.nodeType !== Node.ELEMENT_NODE) + throw this.createStacklessError("Can only capture aria snapshot of Element nodes."); + const ariaSnapshot = generateAriaTree(node, options); + const rendered = renderAriaTree(ariaSnapshot, options); + let incremental; + if (options.track) { + const previousSnapshot = this._lastAriaSnapshotForTrack.get(options.track); + if (previousSnapshot) + incremental = renderAriaTree(ariaSnapshot, options, previousSnapshot).text; + this._lastAriaSnapshotForTrack.set(options.track, ariaSnapshot); + } + this._lastAriaSnapshotForQuery = ariaSnapshot; + return { full: rendered.text, incremental, iframeRefs: ariaSnapshot.iframeRefs, iframeDepths: rendered.iframeDepths }; + } + ariaSnapshotForRecorder() { + const tree = generateAriaTree(this.document.body, { mode: "ai" }); + const { text: ariaSnapshot } = renderAriaTree(tree, { mode: "ai" }); + return { ariaSnapshot, refs: tree.refs }; + } + getAllElementsMatchingExpectAriaTemplate(document, template) { + return getAllElementsMatchingExpectAriaTemplate(document.documentElement, template); + } + querySelectorAll(selector, root) { + if (selector.capture !== void 0) { + if (selector.parts.some((part) => part.name === "nth")) + throw this.createStacklessError(`Can't query n-th element in a request with the capture.`); + const withHas = { parts: selector.parts.slice(0, selector.capture + 1) }; + if (selector.capture < selector.parts.length - 1) { + const parsed = { parts: selector.parts.slice(selector.capture + 1) }; + const has = { name: "internal:has", body: { parsed }, source: stringifySelector(parsed) }; + withHas.parts.push(has); + } + return this.querySelectorAll(withHas, root); + } + if (!root["querySelectorAll"]) + throw this.createStacklessError("Node is not queryable."); + if (selector.capture !== void 0) { + throw this.createStacklessError("Internal error: there should not be a capture in the selector."); + } + if (root.nodeType === 11 && selector.parts.length === 1 && selector.parts[0].name === "css" && selector.parts[0].source === ":scope") + return [root]; + this._evaluator.begin(); + try { + let roots = /* @__PURE__ */ new Set([root]); + for (const part of selector.parts) { + if (part.name === "nth") { + roots = this._queryNth(roots, part); + } else if (part.name === "internal:and") { + const andElements = this.querySelectorAll(part.body.parsed, root); + roots = new Set(andElements.filter((e) => roots.has(e))); + } else if (part.name === "internal:or") { + const orElements = this.querySelectorAll(part.body.parsed, root); + roots = new Set(sortInDOMOrder(/* @__PURE__ */ new Set([...roots, ...orElements]))); + } else if (kLayoutSelectorNames.includes(part.name)) { + roots = this._queryLayoutSelector(roots, part, root); + } else { + const next = /* @__PURE__ */ new Set(); + for (const root2 of roots) { + const all = this._queryEngineAll(part, root2); + for (const one of all) + next.add(one); + } + roots = next; + } + } + return [...roots]; + } finally { + this._evaluator.end(); + } + } + _queryEngineAll(part, root) { + const result = this._engines.get(part.name).queryAll(root, part.body); + for (const element of result) { + if (!("nodeName" in element)) + throw this.createStacklessError(`Expected a Node but got ${Object.prototype.toString.call(element)}`); + } + return result; + } + _createAttributeEngine(attribute, shadow) { + const toCSS = (selector) => { + const css = `[${attribute}=${JSON.stringify(selector)}]`; + return [{ simples: [{ selector: { css, functions: [] }, combinator: "" }] }]; + }; + return { + queryAll: (root, selector) => { + return this._evaluator.query({ scope: root, pierceShadow: shadow }, toCSS(selector)); + } + }; + } + _createCSSEngine() { + return { + queryAll: (root, body) => { + return this._evaluator.query({ scope: root, pierceShadow: true }, body); + } + }; + } + _createTextEngine(shadow, internal) { + const queryAll = (root, selector) => { + const { matcher, kind } = createTextMatcher(selector, internal); + const result = []; + let lastDidNotMatchSelf = null; + const appendElement = (element) => { + if (kind === "lax" && lastDidNotMatchSelf && lastDidNotMatchSelf.contains(element)) + return false; + const matches = elementMatchesText(this._evaluator._cacheText, element, matcher); + if (matches === "none") + lastDidNotMatchSelf = element; + if (matches === "self" || matches === "selfAndChildren" && kind === "strict" && !internal) + result.push(element); + }; + if (root.nodeType === Node.ELEMENT_NODE) + appendElement(root); + const elements = this._evaluator._queryCSS({ scope: root, pierceShadow: shadow }, "*"); + for (const element of elements) + appendElement(element); + return result; + }; + return { queryAll }; + } + _createInternalHasTextEngine() { + return { + queryAll: (root, selector) => { + if (root.nodeType !== 1) + return []; + const element = root; + const text = elementText(this._evaluator._cacheText, element); + const { matcher } = createTextMatcher(selector, true); + return matcher(text) ? [element] : []; + } + }; + } + _createInternalHasNotTextEngine() { + return { + queryAll: (root, selector) => { + if (root.nodeType !== 1) + return []; + const element = root; + const text = elementText(this._evaluator._cacheText, element); + const { matcher } = createTextMatcher(selector, true); + return matcher(text) ? [] : [element]; + } + }; + } + _createInternalLabelEngine() { + return { + queryAll: (root, selector) => { + const { matcher } = createTextMatcher(selector, true); + const allElements = this._evaluator._queryCSS({ scope: root, pierceShadow: true }, "*"); + return allElements.filter((element) => { + return getElementLabels(this._evaluator._cacheText, element).some((label) => matcher(label)); + }); + } + }; + } + _createNamedAttributeEngine() { + const queryAll = (root, selector) => { + const parsed = parseAttributeSelector(selector, true); + if (parsed.name || parsed.attributes.length !== 1) + throw new Error("Malformed attribute selector: " + selector); + const { name } = parsed.attributes[0]; + const matcher = createAttributeMatcher(parsed.attributes[0]); + const elements = this._evaluator._queryCSS({ scope: root, pierceShadow: true }, `[${name}]`); + return elements.filter((e) => matcher(e.getAttribute(name))); + }; + return { queryAll }; + } + _createTestIdEngine() { + const queryAll = (root, selector) => { + const parsed = parseAttributeSelector(selector, true); + if (parsed.name || parsed.attributes.length !== 1) + throw new Error("Malformed test id selector: " + selector); + const names = splitTestIdAttributeNames(parsed.attributes[0].name); + const matcher = createAttributeMatcher(parsed.attributes[0]); + const cssQuery = names.map((n) => `[${n}]`).join(","); + const elements = this._evaluator._queryCSS({ scope: root, pierceShadow: true }, cssQuery); + return elements.filter((e) => names.some((n) => { + const actual = e.getAttribute(n); + return actual !== null && matcher(actual); + })); + }; + return { queryAll }; + } + _createDescribeEngine() { + const queryAll = (root) => { + if (root.nodeType !== 1) + return []; + return [root]; + }; + return { queryAll }; + } + _createControlEngine() { + return { + queryAll(root, body) { + if (body === "enter-frame") + return []; + if (body === "return-empty") + return []; + if (body === "component") { + if (root.nodeType !== 1) + return []; + return [root.childElementCount === 1 ? root.firstElementChild : root]; + } + throw new Error(`Internal error, unknown internal:control selector ${body}`); + } + }; + } + _createHasEngine() { + const queryAll = (root, body) => { + if (root.nodeType !== 1) + return []; + const has = !!this.querySelector(body.parsed, root, false); + return has ? [root] : []; + }; + return { queryAll }; + } + _createHasNotEngine() { + const queryAll = (root, body) => { + if (root.nodeType !== 1) + return []; + const has = !!this.querySelector(body.parsed, root, false); + return has ? [] : [root]; + }; + return { queryAll }; + } + _createVisibleEngine() { + const queryAll = (root, body) => { + if (root.nodeType !== 1) + return []; + const visible = body === "true"; + return isElementVisible(root) === visible ? [root] : []; + }; + return { queryAll }; + } + _createInternalChainEngine() { + const queryAll = (root, body) => { + return this.querySelectorAll(body.parsed, root); + }; + return { queryAll }; + } + extend(source, params) { + const constrFunction = this.window.eval(` + (() => { + const module = {}; + ${source} + return module.exports.default(); + })()`); + return new constrFunction(this, params); + } + async viewportRatio(element) { + return await new Promise((resolve) => { + const observer = new IntersectionObserver((entries) => { + resolve(entries[0].intersectionRatio); + observer.disconnect(); + }); + observer.observe(element); + this.utils.builtins.requestAnimationFrame(() => { + }); + }); + } + getElementBorderWidth(node) { + if (node.nodeType !== Node.ELEMENT_NODE || !node.ownerDocument || !node.ownerDocument.defaultView) + return { left: 0, top: 0 }; + const style = node.ownerDocument.defaultView.getComputedStyle(node); + return { left: parseInt(style.borderLeftWidth || "", 10), top: parseInt(style.borderTopWidth || "", 10) }; + } + describeIFrameStyle(iframe) { + if (!iframe.ownerDocument || !iframe.ownerDocument.defaultView) + return "error:notconnected"; + const defaultView = iframe.ownerDocument.defaultView; + for (let e = iframe; e; e = parentElementOrShadowHost(e)) { + if (defaultView.getComputedStyle(e).transform !== "none") + return "transformed"; + } + const iframeStyle = defaultView.getComputedStyle(iframe); + return { + left: parseInt(iframeStyle.borderLeftWidth || "", 10) + parseInt(iframeStyle.paddingLeft || "", 10), + top: parseInt(iframeStyle.borderTopWidth || "", 10) + parseInt(iframeStyle.paddingTop || "", 10) + }; + } + retarget(node, behavior) { + let element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement; + if (!element) + return null; + if (behavior === "none") + return element; + if (!element.matches("input, textarea, select") && !element.isContentEditable) { + if (behavior === "button-link") + element = element.closest("button, [role=button], a, [role=link]") || element; + else + element = element.closest("button, [role=button], [role=checkbox], [role=radio]") || element; + } + if (behavior === "follow-label") { + if (!element.matches("a, input, textarea, button, select, [role=link], [role=button], [role=checkbox], [role=radio]") && !element.isContentEditable) { + const enclosingLabel = element.closest("label"); + if (enclosingLabel && enclosingLabel.control) + element = enclosingLabel.control; + } + } + return element; + } + async checkElementStates(node, states) { + if (states.includes("stable")) { + const stableResult = await this._checkElementIsStable(node); + if (stableResult === false) + return { missingState: "stable" }; + if (stableResult === "error:notconnected") + return "error:notconnected"; + } + for (const state of states) { + if (state !== "stable") { + const result = this.elementState(node, state); + if (result.received === "error:notconnected") + return "error:notconnected"; + if (!result.matches) + return { missingState: state }; + } + } + } + async _checkElementIsStable(node) { + const continuePolling = Symbol("continuePolling"); + let lastRect; + let stableRafCounter = 0; + let lastTime = 0; + const check = () => { + const element = this.retarget(node, "no-follow-label"); + if (!element) + return "error:notconnected"; + const time = this.utils.builtins.performance.now(); + if (this._stableRafCount > 1 && time - lastTime < 15) + return continuePolling; + lastTime = time; + const clientRect = element.getBoundingClientRect(); + const rect = { x: clientRect.top, y: clientRect.left, width: clientRect.width, height: clientRect.height }; + if (lastRect) { + const samePosition = rect.x === lastRect.x && rect.y === lastRect.y && rect.width === lastRect.width && rect.height === lastRect.height; + if (!samePosition) + return false; + if (++stableRafCounter >= this._stableRafCount) + return true; + } + lastRect = rect; + return continuePolling; + }; + let fulfill; + let reject; + const result = new Promise((f, r) => { + fulfill = f; + reject = r; + }); + const raf = () => { + try { + const success = check(); + if (success !== continuePolling) + fulfill(success); + else + this.utils.builtins.requestAnimationFrame(raf); + } catch (e) { + reject(e); + } + }; + this.utils.builtins.requestAnimationFrame(raf); + return result; + } + _createAriaRefEngine() { + const queryAll = (root, selector) => { + var _a, _b; + const result = (_b = (_a = this._lastAriaSnapshotForQuery) == null ? void 0 : _a.elements) == null ? void 0 : _b.get(selector); + return result && result.isConnected ? [result] : []; + }; + return { queryAll }; + } + elementState(node, state) { + const element = this.retarget(node, ["visible", "hidden"].includes(state) ? "none" : "follow-label"); + if (!element || !element.isConnected) { + if (state === "hidden") + return { matches: true, received: "hidden" }; + return { matches: false, received: "error:notconnected" }; + } + if (state === "visible" || state === "hidden") { + const visible = isElementVisible(element); + return { + matches: state === "visible" ? visible : !visible, + received: visible ? "visible" : "hidden" + }; + } + if (state === "disabled" || state === "enabled") { + const disabled = getAriaDisabled(element); + return { + matches: state === "disabled" ? disabled : !disabled, + received: disabled ? "disabled" : "enabled" + }; + } + if (state === "editable") { + const disabled = getAriaDisabled(element); + const readonly = getReadonly(element); + if (readonly === "error") + throw this.createStacklessError("Element is not an
{/* Chat column */} -
+
{/* Messages Wrapper */}
{/* Messages — LegendList handles virtualization and scrolling internally */} @@ -6241,6 +6357,8 @@ export default function ChatView(props: ChatViewProps) { composerTerminalContextsRef={composerTerminalContextsRef} composerTranscriptHighlightContextsRef={composerTranscriptHighlightContextsRef} composerFileSelectionContextsRef={composerFileSelectionContextsRef} + composerPickedElementContextsRef={composerPickedElementContextsRef} + onRevealPickedElement={isElectron ? revealPickedElement : undefined} shouldAutoScrollRef={isAtEndRef} scheduleStickToBottom={scheduleTimelineStickToBottom} onSend={onSend} @@ -6381,6 +6499,26 @@ export default function ChatView(props: ChatViewProps) { ) : null}
{/* end chat column */} + {browserOpen && routeThreadRef !== null ? ( + <> + {browserExpanded ? null : ( + + )} + composerRef.current?.addScreenshotAttachment(shot)} + onDrawing={(drawing) => composerRef.current?.addDrawingContext(drawing)} + pendingReveal={pendingElementReveal} + onRevealHandled={() => setPendingElementReveal(null)} + /> + + ) : null}
{/* end horizontal flex container */} diff --git a/apps/web/src/components/ChatsDestinationView.tsx b/apps/web/src/components/ChatsDestinationView.tsx new file mode 100644 index 000000000..bfa8780f9 --- /dev/null +++ b/apps/web/src/components/ChatsDestinationView.tsx @@ -0,0 +1,140 @@ +import { scopeThreadRef } from "@threadlines/client-runtime"; +import { useNavigate } from "@tanstack/react-router"; +import { MessageCirclePlusIcon } from "lucide-react"; +import { useMemo } from "react"; +import { useShallow } from "zustand/react/shallow"; + +import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { useSettings } from "../hooks/useSettings"; +import { startNewGeneralChatThread } from "../lib/chatThreadActions"; +import { resolveGeneralChatsProjectRef } from "../lib/generalChats"; +import { sortThreads } from "../lib/threadSort"; +import { usePrimaryEnvironmentId } from "../environments/primary"; +import { + selectGeneralChatsProjectAcrossEnvironments, + selectSidebarThreadsAcrossEnvironments, + useStore, +} from "../store"; +import { buildThreadRouteParams } from "../threadRoutes"; +import { formatRelativeTimeLabel } from "../timestampFormat"; +import { ThreadRowLeadingStatus } from "./ThreadStatusIndicators"; +import { resolveThreadStatusPill } from "./Sidebar.logic"; +import { ThreadHoverCard } from "./sidebar/ThreadHoverCard"; +import { SidebarHoverCardGroup } from "./sidebar/hoverCard"; + +/** + * The Chats destination: general chats are threads with no project, so they get + * a place of their own rather than a project-shaped group in the sidebar tree. + */ +export function ChatsDestinationView() { + const navigate = useNavigate(); + const { handleNewThread } = useHandleNewThread(); + const threads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); + const generalChatsProject = useStore(selectGeneralChatsProjectAcrossEnvironments); + const activeEnvironmentId = useStore((state) => state.activeEnvironmentId); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const threadSortOrder = useSettings((settings) => settings.sidebarThreadSortOrder); + + const generalChatsProjectRef = useMemo( + () => + resolveGeneralChatsProjectRef({ + generalChatsProject, + activeEnvironmentId, + primaryEnvironmentId, + }), + [activeEnvironmentId, generalChatsProject, primaryEnvironmentId], + ); + + const chats = useMemo(() => { + if (generalChatsProject === null) { + return []; + } + return sortThreads( + threads.filter( + (thread) => + thread.archivedAt === null && + thread.environmentId === generalChatsProject.environmentId && + thread.projectId === generalChatsProject.id, + ), + threadSortOrder, + ); + }, [generalChatsProject, threadSortOrder, threads]); + + const startChat = () => { + if (generalChatsProjectRef) { + void startNewGeneralChatThread(handleNewThread, generalChatsProjectRef); + } + }; + + return ( +
+
+

General chats

+ +
+

+ Conversations that aren't tied to a project. +

+ + {chats.length === 0 ? ( +

+ No chats yet. +

+ ) : ( + + {/* The rows repeat the page title's shape closely enough that a rule + alone read as more list. A label gives the list a head of its own. */} +
+ {chats.length} {chats.length === 1 ? "chat" : "chats"} +
+
+ {chats.map((thread) => ( + + + + ))} +
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 7d7400cc8..5c500a741 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -60,6 +60,35 @@ export const ThreadlinesGlyph: Icon = (props) => { ); }; +/** + * Device rotation: an upright screen with arrows turning around it. + * + * Drawn rather than taken from the icon set, which offers a square with a + * refresh arrow (reads as "reload") and a pair of devices (reads as + * "responsive"). Neither says "turn this on its side". + */ +export const RotateDeviceIcon: Icon = ({ className, ...props }) => ( + + {/* The device, upright, so the arrows read as turning it. */} + + {/* Turning down the left... */} + + + {/* ...and back up the right. */} + + + +); + export const SourceControlIcon: Icon = ({ className, ...props }) => ( selectActiveAndRecentThreads(threads, limit), - [limit, threads], - ); + const recentThreads = useMemo(() => { + const isGeneralChat = (thread: (typeof threads)[number]) => + generalChatsProject !== null && + thread.environmentId === generalChatsProject.environmentId && + thread.projectId === generalChatsProject.id; + // Scoped here, ordered there: which threads belong in this list is this + // component's business, and putting the ones still working at the top is a + // decision the shared selector owns. It drops archived and applies the + // limit too, so neither is repeated here. + return selectActiveAndRecentThreads( + threads.filter((thread) => { + if (scope === "chats") return isGeneralChat(thread); + if (scope === "projects") return !isGeneralChat(thread); + return true; + }), + limit, + ); + }, [generalChatsProject, limit, scope, threads]); const projectByScopedKey = useMemo( () => new Map( @@ -49,7 +73,7 @@ export function RecentThreadsList({ limit = 3, testId, className }: RecentThread return (
- Active and recent + {scope === "chats" ? "Active and recent chats" : "Active and recent"}
{recentThreads.map((thread) => { @@ -77,7 +101,7 @@ export function RecentThreadsList({ limit = 3, testId, className }: RecentThread {thread.title} - {isGeneralChat ? ( + {isGeneralChat && scope !== "chats" ? ( General diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 3cee1f773..63aff431f 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -2,8 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { ProviderDriverKind } from "@threadlines/contracts"; import { + buildOnDeckSyncInput, + buildProjectHoverSummary, + countThreadsNeedingUser, createThreadJumpHintVisibilityController, + excludeOnDeckThreads, getSidebarThreadIdsToPrewarm, + isOnDeckDismissible, getVisibleSidebarThreadIds, resolveAdjacentThreadId, getFallbackThreadIdAfterDelete, @@ -33,6 +38,7 @@ import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Project, + type SidebarThreadSummary, type Thread, } from "../types"; @@ -1238,3 +1244,139 @@ describe("sortProjectsForSidebar", () => { expect(timestamp).toBe(Date.parse("2026-03-09T10:10:00.000Z")); }); }); + +describe("on deck classification", () => { + const pill = (label: import("./Sidebar.logic").ThreadStatusPill["label"]) => ({ + label, + colorClass: "", + dotClass: "", + pulse: false, + }); + + it("buildOnDeckSyncInput separates live work from unseen completions", () => { + expect( + buildOnDeckSyncInput({ threadKey: "t-1", pinnedAt: null, status: pill("Working") }), + ).toEqual({ key: "t-1", pinned: false, live: true, unseen: false }); + expect( + buildOnDeckSyncInput({ threadKey: "t-2", pinnedAt: null, status: pill("Completed") }), + ).toEqual({ key: "t-2", pinned: false, live: false, unseen: true }); + expect( + buildOnDeckSyncInput({ + threadKey: "t-3", + pinnedAt: "2026-03-09T10:00:00.000Z", + status: null, + }), + ).toEqual({ key: "t-3", pinned: true, live: false, unseen: false }); + }); + + it("isOnDeckDismissible allows dismissing settled rows only", () => { + expect(isOnDeckDismissible(null)).toBe(true); + expect(isOnDeckDismissible(pill("Completed"))).toBe(true); + expect(isOnDeckDismissible(pill("Working"))).toBe(false); + expect(isOnDeckDismissible(pill("Pending Approval"))).toBe(false); + }); + + it("excludeOnDeckThreads drops deck rows from the tree and keeps the rest in order", () => { + const threads = [{ key: "t-1" }, { key: "t-2" }, { key: "t-3" }]; + const getThreadKey = (thread: { key: string }) => thread.key; + + expect( + excludeOnDeckThreads({ + threads, + onDeckThreadKeys: new Set(["t-2"]), + getThreadKey, + }), + ).toEqual([{ key: "t-1" }, { key: "t-3" }]); + + expect(excludeOnDeckThreads({ threads, onDeckThreadKeys: new Set(), getThreadKey })).toEqual( + threads, + ); + + expect( + excludeOnDeckThreads({ + threads, + onDeckThreadKeys: new Set(["t-1", "t-2", "t-3"]), + getThreadKey, + }), + ).toEqual([]); + }); + + it("countThreadsNeedingUser counts only threads blocked on the user", () => { + expect( + countThreadsNeedingUser([ + pill("Pending Approval"), + pill("Awaiting Input"), + // Live but unblocked, so the rail badge must ignore these. + pill("Working"), + pill("Starting"), + pill("Plan Ready"), + pill("Completed"), + null, + ]), + ).toBe(2); + expect(countThreadsNeedingUser([])).toBe(0); + }); +}); + +describe("buildProjectHoverSummary", () => { + const thread = (id: string, overrides: Partial = {}) => + ({ + id: ThreadId.make(id), + environmentId: localEnvironmentId, + projectId: ProjectId.make("project-badcode"), + title: id, + interactionMode: DEFAULT_INTERACTION_MODE, + session: null, + createdAt: "2026-07-20T00:00:00.000Z", + archivedAt: null, + pinnedAt: null, + latestTurn: null, + branch: null, + worktreePath: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, + }) as SidebarThreadSummary; + + const summaryFor = (threads: readonly SidebarThreadSummary[]) => + buildProjectHoverSummary({ + name: "badcode", + cwd: "/repo/badcode", + environmentId: localEnvironmentId, + threads, + getLastVisitedAt: () => undefined, + }); + + it("counts only threads the provider is still live on", () => { + const summary = summaryFor([ + thread("waiting", { hasPendingApprovals: true }), + thread("idle"), + thread("also-idle"), + ]); + + expect(summary.threadCount).toBe(3); + expect(summary.activeCount).toBe(1); + expect(summary.status?.label).toBe("Pending Approval"); + }); + + it("reports the most recent activity across the project", () => { + const summary = summaryFor([ + thread("older", { latestUserMessageAt: "2026-07-21T00:00:00.000Z" }), + thread("newest", { latestUserMessageAt: "2026-07-24T00:00:00.000Z" }), + thread("middle", { latestUserMessageAt: "2026-07-22T00:00:00.000Z" }), + ]); + + expect(summary.lastActivityAt).toBe("2026-07-24T00:00:00.000Z"); + }); + + it("describes an empty project without inventing activity", () => { + const summary = summaryFor([]); + + expect(summary.threadCount).toBe(0); + expect(summary.activeCount).toBe(0); + expect(summary.status).toBeNull(); + expect(summary.lastActivityAt).toBeNull(); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index a127db7f7..744168d55 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -10,7 +10,10 @@ import { toSortableTimestamp, type ThreadSortInput, } from "../lib/threadSort"; +import type { EnvironmentId } from "@threadlines/contracts"; +import { scopedThreadKey, scopeThreadRef } from "@threadlines/client-runtime"; import type { SidebarThreadSummary, Thread } from "../types"; +import type { OnDeckSyncInput } from "../uiStateStore"; import { cn } from "../lib/utils"; import { isLatestTurnSettled } from "../session-logic"; @@ -421,6 +424,84 @@ export function resolveThreadStatusPill(input: { return null; } +/** Statuses that mean the provider is working or waiting on the user right now. */ +const ON_DECK_LIVE_STATUSES: ReadonlySet = new Set([ + "Pending Approval", + "Awaiting Input", + "Working", + "Starting", + "Plan Ready", + "Background", +]); + +/** Maps a thread's status pill and pin state onto the deck's entry signals. */ +export function buildOnDeckSyncInput(input: { + threadKey: string; + pinnedAt: string | null; + status: ThreadStatusPill | null; +}): OnDeckSyncInput { + return { + key: input.threadKey, + pinned: input.pinnedAt !== null, + live: input.status !== null && ON_DECK_LIVE_STATUSES.has(input.status.label), + unseen: input.status?.label === "Completed", + }; +} + +/** Only settled rows offer the dismiss affordance; live work can't be waved away. */ +export function isOnDeckDismissible(status: ThreadStatusPill | null): boolean { + return status === null || !ON_DECK_LIVE_STATUSES.has(status.label); +} + +/** + * Drops threads that already have a deck row, so a thread on deck appears once + * in the sidebar rather than twice. Must be applied *before* the preview window + * is computed: a deck thread should not consume a preview slot and then vanish, + * which would leave "Show more" counts disagreeing with the rendered rows. + */ +export function excludeOnDeckThreads(input: { + threads: readonly TThread[]; + onDeckThreadKeys: ReadonlySet; + getThreadKey: (thread: TThread) => string; +}): TThread[] { + if (input.onDeckThreadKeys.size === 0) { + return [...input.threads]; + } + return input.threads.filter((thread) => !input.onDeckThreadKeys.has(input.getThreadKey(thread))); +} + +/** + * Statuses where the agent has stopped and cannot continue without the user. + * Narrower than {@link ON_DECK_LIVE_STATUSES}: a working thread is live but + * needs nothing, and a ready plan is an invitation rather than a block. + */ +const NEEDS_USER_STATUSES: ReadonlySet = new Set([ + "Pending Approval", + "Awaiting Input", +]); + +/** True while the provider is working on the thread or waiting on the user. */ +export function isLiveThreadStatus(status: ThreadStatusPill | null): boolean { + return status !== null && ON_DECK_LIVE_STATUSES.has(status.label); +} + +/** True when the thread is blocked waiting on the user. */ +export function isNeedsUserStatus(status: ThreadStatusPill | null): boolean { + return status !== null && NEEDS_USER_STATUSES.has(status.label); +} + +/** + * How many threads are blocked on the user. The collapsed sidebar rail drops + * per-thread titles, so this aggregate is the only attention signal left. + */ +export function countThreadsNeedingUser(statuses: ReadonlyArray): number { + let count = 0; + for (const status of statuses) { + if (isNeedsUserStatus(status)) count += 1; + } + return count; +} + export function resolveProjectStatusIndicator( statuses: ReadonlyArray, ): ThreadStatusPill | null { @@ -617,3 +698,51 @@ export function sortScopedProjectsByActivity< return project ? [project] : []; }); } + +export interface ProjectHoverSummary { + name: string; + cwd: string; + environmentId: EnvironmentId; + status: ThreadStatusPill | null; + threadCount: number; + activeCount: number; + lastActivityAt: string | null; +} + +/** + * Builds a project's hover summary from its threads. Shared so the expanded row + * and the collapsed rail glyph describe a project identically. + */ +export function buildProjectHoverSummary(input: { + name: string; + cwd: string; + environmentId: EnvironmentId; + threads: readonly SidebarThreadSummary[]; + getLastVisitedAt: (threadKey: string) => string | null | undefined; +}): ProjectHoverSummary { + const getThreadKey = (thread: SidebarThreadSummary) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const statuses = input.threads.map((thread) => { + const lastVisitedAt = input.getLastVisitedAt(getThreadKey(thread)); + return resolveThreadStatusPill({ + thread: { + ...thread, + ...(lastVisitedAt !== undefined && lastVisitedAt !== null ? { lastVisitedAt } : {}), + }, + }); + }); + const lastActivityAt = input.threads.reduce((latest, thread) => { + const at = thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt; + return latest === null || at > latest ? at : latest; + }, null); + + return { + name: input.name, + cwd: input.cwd, + environmentId: input.environmentId, + status: resolveProjectStatusIndicator(statuses), + threadCount: input.threads.length, + activeCount: statuses.filter(isLiveThreadStatus).length, + lastActivityAt, + }; +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ae474e530..aa76c24b1 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -5,7 +5,6 @@ import { ChevronUpIcon, CloudIcon, FolderPlusIcon, - MessageCirclePlusIcon, MessagesSquareIcon, PinIcon, PinOffIcon, @@ -46,7 +45,6 @@ import { CSS } from "@dnd-kit/utilities"; import { type ContextMenuItem, ProjectId, - type ScopedProjectRef, type ScopedThreadRef, type SidebarProjectGroupingMode, type ThreadEnvMode, @@ -69,14 +67,14 @@ import { } from "@threadlines/contracts/settings"; import { resolveThreadWorkingCwd } from "@threadlines/shared/threadCwd"; import { usePrimaryEnvironmentId } from "../environments/primary"; +import { resolveGeneralChatsProjectRef } from "../lib/generalChats"; import { isElectron } from "../env"; import { APP_BASE_NAME, APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isTerminalFocused } from "../lib/terminalFocus"; import { cn, isMacPlatform, newCommandId } from "../lib/utils"; -import { resolveGeneralChatsProjectRef } from "../lib/generalChats"; import { - selectGeneralChatsProjectAcrossEnvironments, selectProjectByRef, + selectGeneralChatsProjectAcrossEnvironments, selectProjectsAcrossEnvironments, selectSidebarThreadsForProjectRefs, selectSidebarThreadsAcrossEnvironments, @@ -98,7 +96,7 @@ import { useShortcutModifierState } from "../shortcutModifierState"; import { useGitStatus } from "../lib/gitStatusState"; import { readLocalApi } from "../localApi"; import { useComposerDraftStore } from "../composerDraftStore"; -import { useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { useHandleNewThread, useNewThreadHandler } from "../hooks/useHandleNewThread"; import { retainThreadDetailSubscription } from "../environments/runtime/service"; import { useThreadActions } from "../hooks/useThreadActions"; @@ -158,8 +156,12 @@ import { import { useThreadSelectionStore } from "../threadSelectionStore"; import { useCommandPaletteStore } from "../commandPaletteStore"; import { + buildOnDeckSyncInput, + buildProjectHoverSummary, + excludeOnDeckThreads, getSidebarThreadIdsToPrewarm, getSidebarThreadWindow, + isOnDeckDismissible, resolveAdjacentThreadId, isContextMenuPointerDown, resolveProjectStatusIndicator, @@ -173,7 +175,16 @@ import { useThreadJumpHintVisibility, ThreadStatusPill, } from "./Sidebar.logic"; -import { startNewGeneralChatThread } from "../lib/chatThreadActions"; +import { OnDeckSection, type OnDeckEntry } from "./sidebar/OnDeckSection"; +import { DeckRail, type DeckRailProject } from "./sidebar/DeckRail"; +import { ProjectHoverCard } from "./sidebar/ProjectHoverCard"; +import { SidebarHoverCardGroup } from "./sidebar/hoverCard"; +import { + DESTINATION_ICONS, + DestinationBand, + type SidebarDestination, +} from "./sidebar/DestinationBand"; +import { startNewGeneralChatThread, startNewThreadFromContext } from "../lib/chatThreadActions"; import { sortThreads } from "../lib/threadSort"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { SidebarVersionTag } from "./sidebar/SidebarVersionTag"; @@ -1203,6 +1214,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const sidebarThreadByKeyRef = useRef(sidebarThreadByKey); sidebarThreadByKeyRef.current = sidebarThreadByKey; const projectThreads = sidebarThreads; + // Threads with a deck row are rendered there instead of here, so the same + // thread never appears twice in the sidebar. + const onDeckThreadKeys = useUiStateStore(useShallow((state) => state.onDeckThreadKeys)); + const onDeckThreadKeySet = useMemo(() => new Set(onDeckThreadKeys), [onDeckThreadKeys]); const projectExpanded = useUiStateStore( (state) => state.projectExpandedById[project.projectKey] ?? true, ); @@ -1257,39 +1272,35 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return counts; }, [memberProjectByScopedKey, project.memberProjects, projectThreads]); - const { projectStatus, visibleProjectThreads, orderedProjectThreadKeys } = useMemo(() => { - const lastVisitedAtByThreadKey = new Map( - projectThreads.map((thread, index) => [ - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - threadLastVisitedAts[index] ?? null, - ]), - ); - const resolveProjectThreadStatus = (thread: SidebarThreadSummary) => { - const lastVisitedAt = lastVisitedAtByThreadKey.get( - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + const { hoverSummary, projectStatus, visibleProjectThreads, orderedProjectThreadKeys } = + useMemo(() => { + const lastVisitedAtByThreadKey = new Map( + projectThreads.map((thread, index) => [ + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + threadLastVisitedAts[index] ?? null, + ]), ); - return resolveThreadStatusPill({ - thread: { - ...thread, - ...(lastVisitedAt !== null && lastVisitedAt !== undefined ? { lastVisitedAt } : {}), - }, + const visibleProjectThreads = sortThreads( + projectThreads.filter((thread) => thread.archivedAt === null), + threadSortOrder, + ); + const hoverSummary = buildProjectHoverSummary({ + name: project.displayName, + cwd: project.cwd, + environmentId: project.environmentId, + threads: visibleProjectThreads, + getLastVisitedAt: (threadKey) => lastVisitedAtByThreadKey.get(threadKey), }); - }; - const visibleProjectThreads = sortThreads( - projectThreads.filter((thread) => thread.archivedAt === null), - threadSortOrder, - ); - const projectStatus = resolveProjectStatusIndicator( - visibleProjectThreads.map((thread) => resolveProjectThreadStatus(thread)), - ); - return { - orderedProjectThreadKeys: visibleProjectThreads.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ), - projectStatus, - visibleProjectThreads, - }; - }, [projectThreads, threadLastVisitedAts, threadSortOrder]); + const projectStatus = hoverSummary.status; + return { + orderedProjectThreadKeys: visibleProjectThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + hoverSummary, + projectStatus, + visibleProjectThreads, + }; + }, [projectThreads, threadLastVisitedAts, threadSortOrder]); const { hiddenThreadStatus, @@ -1317,8 +1328,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }, }); }; - const threadWindow = getSidebarThreadWindow({ + const treeThreads = excludeOnDeckThreads({ threads: visibleProjectThreads, + onDeckThreadKeys: onDeckThreadKeySet, + getThreadKey: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + }); + const threadWindow = getSidebarThreadWindow({ + threads: treeThreads, getThreadKey: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), activeThreadKey: activeRouteThreadKey, previewLimit: sidebarThreadPreviewCount, @@ -1332,11 +1348,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec nextRevealCount: threadWindow.nextRevealCount, searchHandoffThreadCount: threadWindow.searchHandoffThreadCount, isThreadListRevealed: threadWindow.isRevealed, + // Deliberately the unfiltered list: a project whose only threads are on + // deck still has threads, so "no threads yet" would be a lie. It simply + // renders no rows of its own. showEmptyThreadState: projectExpanded && visibleProjectThreads.length === 0, shouldShowThreadPanel: projectExpanded, }; }, [ activeRouteThreadKey, + onDeckThreadKeySet, projectExpanded, projectThreads, revealedThreadCount, @@ -2189,121 +2209,123 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec {/* Sticky: stays pinned to the sidebar viewport top while this project's threads scroll, so context and the collapse toggle are always in reach. The next project header pushes it off naturally. */} -
- - {!projectExpanded && projectStatus ? ( -
+
+ ); + // The rail is too narrow for the wordmark, and its top-left is already the + // collapse trigger's fixed home — so the collapsed header is a bare spacer + // that keeps the titlebar height (and, on Electron, the drag region). + if (collapsed) { + return ( + + ); + } + return isElectron ? ( {electronWordmarkLayout.spacerClassName ? ( @@ -2758,9 +2796,10 @@ interface SidebarProjectsContentProps { threadPreviewCount: SidebarThreadPreviewCount; updateSettings: ReturnType["updateSettings"]; openAddProject: () => void; - generalChatsProjectRef: ScopedProjectRef | null; + /** The On Deck working-set group, rendered between search and the tree. */ + onDeckSection: React.ReactNode; + destinationBand: React.ReactNode; /** Pinned above the Projects section; null while there are no general chats. */ - generalChatsProject: SidebarProjectSnapshot | null; isManualProjectSorting: boolean; projectDnDSensors: ReturnType; projectCollisionDetection: CollisionDetection; @@ -2799,8 +2838,8 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( threadPreviewCount, updateSettings, openAddProject, - generalChatsProjectRef, - generalChatsProject, + onDeckSection, + destinationBand, isManualProjectSorting, projectDnDSensors, projectCollisionDetection, @@ -2880,58 +2919,9 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( - {/* The slot below search is always the General Chats home: a quiet - new-chat action row until the first chat exists, then the group - itself (whose hover button takes over starting new chats). */} - {!generalChatsProject && generalChatsProjectRef ? ( - - - - { - void startNewGeneralChatThread(handleNewThread, generalChatsProjectRef); - }} - > - - New general chat - - - - - ) : null} - {generalChatsProject ? ( - - - - - - ) : null} + {destinationBand} + + {onDeckSection}
Projects @@ -3056,9 +3046,11 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( export default function Sidebar() { const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); + const primaryEnvironmentId = usePrimaryEnvironmentId(); const generalChatsProject = useStore(selectGeneralChatsProjectAcrossEnvironments); const activeEnvironmentId = useStore((state) => state.activeEnvironmentId); - const primaryEnvironmentId = usePrimaryEnvironmentId(); + // Needed for the destination row's new-chat action: the helper falls back to + // the active environment when no general-chat project exists yet. const generalChatsProjectRef = useMemo( () => resolveGeneralChatsProjectRef({ @@ -3075,6 +3067,10 @@ export default function Sidebar() { const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); const projectOrder = useUiStateStore((store) => store.projectOrder); const reorderProjects = useUiStateStore((store) => store.reorderProjects); + const threadLastVisitedAtById = useUiStateStore((store) => store.threadLastVisitedAtById); + const onDeckThreadKeys = useUiStateStore((store) => store.onDeckThreadKeys); + const syncOnDeck = useUiStateStore((store) => store.syncOnDeck); + const dismissFromOnDeck = useUiStateStore((store) => store.dismissFromOnDeck); const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); const isOnSettings = pathname.startsWith("/settings"); @@ -3084,9 +3080,16 @@ export default function Sidebar() { const projectGroupingSettings = useSettings(selectProjectGroupingSettings); const sidebarThreadPreviewCount = useSettings((s) => s.sidebarThreadPreviewCount); const { updateSettings } = useUpdateSettings(); - const { handleNewThread } = useNewThreadHandler(); + // The full hook (rather than `useNewThreadHandler`) because the deck rail's + // new-thread button has to resolve the same default project and draft context + // the app menu's "New thread" action uses. + const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = + useHandleNewThread(); + const defaultThreadEnvMode = useSettings( + (settings) => settings.defaultThreadEnvMode, + ); const { archiveThread, deleteThread, pinThread, unpinThread } = useThreadActions(); - const { isMobile, setOpenMobile } = useSidebar(); + const { isMobile, setOpen, setOpenMobile, state: sidebarState } = useSidebar(); const routeThreadRef = useParams({ strict: false, select: (params) => resolveThreadRouteRef(params), @@ -3094,6 +3097,7 @@ export default function Sidebar() { const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; const keybindings = useServerKeybindings(); const openAddProjectCommandPalette = useCommandPaletteStore((store) => store.openAddProject); + const toggleCommandPalette = useCommandPaletteStore((store) => store.toggleOpen); // Extra thread rows revealed beyond the preview, per project ("Show more"). const [revealedThreadCountsByProject, setRevealedThreadCountsByProject] = useState< ReadonlyMap @@ -3248,6 +3252,34 @@ export default function Sidebar() { [clearSelection, isMobile, navigate, setOpenMobile, setSelectionAnchor], ); + const expandSidebar = useCallback(() => { + void setOpen(true); + }, [setOpen]); + const setProjectExpanded = useUiStateStore((store) => store.setProjectExpanded); + // The rail has nowhere to show a thread list, so a glyph opens the pane with + // that project already unfolded rather than navigating somewhere unasked. + const revealProject = useCallback( + (projectKey: string) => { + setProjectExpanded(projectKey, true); + void setOpen(true); + }, + [setOpen, setProjectExpanded], + ); + const openSettings = useCallback(() => { + void navigate({ to: "/settings" }); + }, [navigate]); + const startNewThreadFromRail = useCallback(() => { + void startNewThreadFromContext({ + activeDraftThread, + activeThread, + defaultProjectRef, + defaultThreadEnvMode: resolveSidebarNewThreadEnvMode({ + defaultEnvMode: defaultThreadEnvMode, + }), + handleNewThread, + }); + }, [activeDraftThread, activeThread, defaultProjectRef, defaultThreadEnvMode, handleNewThread]); + const projectDnDSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 }, @@ -3320,6 +3352,28 @@ export default function Sidebar() { () => sidebarThreads.filter((thread) => thread.archivedAt === null), [sidebarThreads], ); + const generalChatProjectKeys = useMemo( + () => + new Set( + projects + .filter((project) => project.kind === "general-chat") + .map((project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id))), + ), + [projects], + ); + // On Deck is the working set for project work. General chats are + // conversations that belong to no project, so they never join the deck -- + // they live behind the Chats destination and nowhere else. + const deckEligibleThreads = useMemo( + () => + visibleThreads.filter( + (thread) => + !generalChatProjectKeys.has( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ), + ), + [generalChatProjectKeys, visibleThreads], + ); const sortedProjects = useMemo(() => { const sortableProjects = sidebarProjects.map((project) => ({ ...project, @@ -3358,58 +3412,145 @@ export default function Sidebar() { ]); // Pinned above the Projects section, hidden until it has chats; discovery // happens through the header's new-general-chat button. - const generalChatsSnapshot = useMemo(() => { - const snapshot = sidebarProjects.find((project) => project.kind === "general-chat"); - if (!snapshot) { - return null; + const isManualProjectSorting = sidebarProjectSortOrder === "manual"; + + // On Deck: the cross-project working set pinned above the project browser. + // Status pills are computed once per visible thread and shared between the + // deck reconcile (store) and the rendered deck rows. + const onDeckStatusByThreadKey = useMemo(() => { + const statuses = new Map(); + for (const thread of visibleThreads) { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const lastVisitedAt = threadLastVisitedAtById[threadKey]; + statuses.set( + threadKey, + resolveThreadStatusPill({ + thread: { + ...thread, + ...(lastVisitedAt !== undefined ? { lastVisitedAt } : {}), + }, + }), + ); } - const hasVisibleThreads = (threadsByProjectKey.get(snapshot.projectKey) ?? []).some( - (thread) => thread.archivedAt === null, + return statuses; + }, [threadLastVisitedAtById, visibleThreads]); + + useEffect(() => { + syncOnDeck( + deckEligibleThreads.map((thread) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + return buildOnDeckSyncInput({ + threadKey, + pinnedAt: thread.pinnedAt, + status: onDeckStatusByThreadKey.get(threadKey) ?? null, + }); + }), + routeThreadKey, ); - return hasVisibleThreads ? snapshot : null; - }, [sidebarProjects, threadsByProjectKey]); - const isManualProjectSorting = sidebarProjectSortOrder === "manual"; - const visibleSidebarThreadKeys = useMemo( + }, [deckEligibleThreads, onDeckStatusByThreadKey, routeThreadKey, syncOnDeck]); + + // The rail drops the project tree, so each project keeps a glyph carrying the + // same aggregate status its expanded row shows. + const railProjects = useMemo( () => - [...(generalChatsSnapshot ? [generalChatsSnapshot] : []), ...sortedProjects].flatMap( - (project) => { - const projectExpanded = projectExpandedById[project.projectKey] ?? true; - if (!projectExpanded) { - return []; - } - const projectThreads = sortThreads( - (threadsByProjectKey.get(project.projectKey) ?? []).filter( - (thread) => thread.archivedAt === null, - ), - sidebarThreadSortOrder, - ); - // Mirrors SidebarProjectItem's rendering window so jump labels and - // thread traversal line up with the rows that are actually visible. - const { visibleThreads } = getSidebarThreadWindow({ - threads: projectThreads, - getThreadKey: (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - activeThreadKey: project.projectKey === activeRouteProjectKey ? routeThreadKey : null, - previewLimit: sidebarThreadPreviewCount, - revealedCount: revealedThreadCountsByProject.get(project.projectKey) ?? 0, - }); - return visibleThreads.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); + sortedProjects.map((project) => ({ + projectKey: project.projectKey, + summary: buildProjectHoverSummary({ + name: project.displayName, + cwd: project.cwd, + environmentId: project.environmentId, + threads: (threadsByProjectKey.get(project.projectKey) ?? []).filter( + (thread) => thread.archivedAt === null, + ), + getLastVisitedAt: (threadKey) => threadLastVisitedAtById[threadKey], + }), + })), + [sortedProjects, threadLastVisitedAtById, threadsByProjectKey], + ); + + const onDeckEntries = useMemo(() => { + return onDeckThreadKeys.flatMap((threadKey) => { + const thread = sidebarThreadByKey.get(threadKey); + if (!thread || thread.archivedAt !== null) { + return []; + } + const status = onDeckStatusByThreadKey.get(threadKey) ?? null; + const physicalKey = + projectPhysicalKeyByScopedRef.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + const project = sidebarProjectByKey.get(physicalToLogicalKey.get(physicalKey) ?? physicalKey); + return [ + { + thread, + status, + projectLabel: project?.kind === "general-chat" ? "chats" : (project?.displayName ?? null), + projectCwd: project?.kind === "general-chat" ? null : (project?.cwd ?? null), + dismissible: isOnDeckDismissible(status), }, + ]; + }); + }, [ + onDeckStatusByThreadKey, + onDeckThreadKeys, + physicalToLogicalKey, + projectPhysicalKeyByScopedRef, + sidebarProjectByKey, + sidebarThreadByKey, + ]); + const onDeckVisibleThreadKeys = useMemo( + () => + onDeckEntries.map((entry) => + scopedThreadKey(scopeThreadRef(entry.thread.environmentId, entry.thread.id)), ), - [ - activeRouteProjectKey, - generalChatsSnapshot, - routeThreadKey, - sidebarThreadSortOrder, - sidebarThreadPreviewCount, - revealedThreadCountsByProject, - projectExpandedById, - sortedProjects, - threadsByProjectKey, - ], + [onDeckEntries], ); + + const visibleSidebarThreadKeys = useMemo(() => { + const onDeckKeySet = new Set(onDeckVisibleThreadKeys); + const projectThreadKeys = sortedProjects.flatMap((project) => { + const projectExpanded = projectExpandedById[project.projectKey] ?? true; + if (!projectExpanded) { + return []; + } + const projectThreads = excludeOnDeckThreads({ + threads: sortThreads( + (threadsByProjectKey.get(project.projectKey) ?? []).filter( + (thread) => thread.archivedAt === null, + ), + sidebarThreadSortOrder, + ), + onDeckThreadKeys: onDeckKeySet, + getThreadKey: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + }); + // Mirrors SidebarProjectItem's rendering window so jump labels and + // thread traversal line up with the rows that are actually visible. + const { visibleThreads } = getSidebarThreadWindow({ + threads: projectThreads, + getThreadKey: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + activeThreadKey: project.projectKey === activeRouteProjectKey ? routeThreadKey : null, + previewLimit: sidebarThreadPreviewCount, + revealedCount: revealedThreadCountsByProject.get(project.projectKey) ?? 0, + }); + return visibleThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ); + }); + // Deck rows render above the tree, so they lead the visual order that jump + // keys and thread traversal follow. The tree lists were already filtered + // above, so a deck thread has exactly one row and one jump index. + return [...onDeckVisibleThreadKeys, ...projectThreadKeys]; + }, [ + activeRouteProjectKey, + onDeckVisibleThreadKeys, + routeThreadKey, + sidebarThreadSortOrder, + sidebarThreadPreviewCount, + revealedThreadCountsByProject, + projectExpandedById, + sortedProjects, + threadsByProjectKey, + ]); const threadJumpCommandByKey = useMemo(() => { const mapping = new Map>>(); for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) { @@ -3460,6 +3601,77 @@ export default function Sidebar() { const visibleThreadJumpLabelByKey = showThreadJumpHints ? threadJumpLabelByKey : EMPTY_THREAD_JUMP_LABELS; + // One list, rendered as rows in the pane and as icons in the rail, so both + // densities offer the same destinations in the same order. + const chatsStatus = useMemo(() => { + const chatThreads = visibleThreads.filter((thread) => + generalChatProjectKeys.has( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ), + ); + if (chatThreads.length === 0) { + return null; + } + return resolveProjectStatusIndicator( + chatThreads.map((thread) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const lastVisitedAt = threadLastVisitedAtById[threadKey]; + return resolveThreadStatusPill({ + thread: { + ...thread, + ...(lastVisitedAt !== undefined ? { lastVisitedAt } : {}), + }, + }); + }), + ); + }, [generalChatProjectKeys, threadLastVisitedAtById, visibleThreads]); + const destinations = useMemo( + () => [ + { + id: "chats", + label: "General chats", + icon: DESTINATION_ICONS.chats, + active: pathname.startsWith("/chats"), + status: chatsStatus, + action: { + label: "New chat", + icon: DESTINATION_ICONS.newChat, + onSelect: () => { + if (generalChatsProjectRef) { + void startNewGeneralChatThread(handleNewThread, generalChatsProjectRef); + } + }, + }, + onSelect: () => { + void navigate({ to: "/chats" }); + }, + }, + ], + [chatsStatus, generalChatsProjectRef, handleNewThread, navigate, pathname], + ); + const destinationBand = useMemo( + () => , + [destinations], + ); + const onDeckSection = useMemo( + () => + onDeckEntries.length > 0 ? ( + + ) : null, + [ + dismissFromOnDeck, + navigateToThread, + onDeckEntries, + routeThreadKey, + visibleThreadJumpLabelByKey, + ], + ); const orderedSidebarThreadKeys = visibleSidebarThreadKeys; const prewarmedSidebarThreadKeys = useMemo( () => getSidebarThreadIdsToPrewarm(visibleSidebarThreadKeys), @@ -3611,6 +3823,29 @@ export default function Sidebar() { }); }, [activeRouteProjectKey]); + // Collapsed desktop density: the deck rail replaces the pane entirely rather + // than sitting beside it, so nothing is shown twice. Mobile keeps using the + // off-canvas sheet, which has no collapsed state to render. + if (!isMobile && sidebarState === "collapsed") { + return ( + <> + + + + ); + } + return ( <> @@ -3621,42 +3856,44 @@ export default function Sidebar() { ) : ( <> - + + + diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index e6b5e60dc..5d87cb850 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -9,6 +9,7 @@ import { useSavedEnvironmentRuntimeStore, } from "../environments/runtime"; import { useGitStatus } from "../lib/gitStatusState"; +import { cn } from "../lib/utils"; import { type AppState, selectProjectByRef, useStore } from "../store"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; @@ -95,6 +96,32 @@ export function terminalStatusFromRunningIds( }; } +/** + * The bare status dot, without a tooltip or label. Callers that supply their + * own tooltip (the collapsed sidebar rail names the thread rather than the + * status) use this instead of {@link ThreadStatusLabel}. + */ +export function ThreadStatusDot({ + status, + className, +}: { + status: ThreadStatusPill | null; + className?: string; +}) { + if (status === null) { + return ; + } + return status.pulse ? ( + + ) : ( + + ); +} + export function ThreadStatusLabel({ status, compact = false, diff --git a/apps/web/src/components/browser/AgentActivityLine.browser.tsx b/apps/web/src/components/browser/AgentActivityLine.browser.tsx new file mode 100644 index 000000000..84411e55c --- /dev/null +++ b/apps/web/src/components/browser/AgentActivityLine.browser.tsx @@ -0,0 +1,64 @@ +import { page } from "vite-plus/test/browser"; +import { describe, expect, it } from "vite-plus/test"; +import { render } from "vitest-browser-react"; + +import { AgentActivityLine } from "./BrowserPanel"; +import type { AgentActivity } from "./previewAutomationHost"; + +const activity = (overrides: Partial = {}): AgentActivity => ({ + phase: "running", + verb: "clicked", + detail: '"Sign in"', + sequence: 1, + ...overrides, +}); + +/** + * The line is the answer to "is the agent doing something, or is this page + * broken?" -- so what it says, and whether it offers a way to go and look, are + * the whole of its job. + */ +describe("AgentActivityLine", () => { + it("says what the agent did, in words rather than tool names", async () => { + render( {}} />); + + await expect.element(page.getByTestId("agent-activity-line")).toBeVisible(); + expect((await page.getByTestId("agent-activity-line").element()).textContent).toContain( + 'clicked "Sign in"', + ); + }); + + it("is inert while you are already on the agent's tab", async () => { + // Nowhere to go, and a button that does nothing is worse than no button. + render( {}} />); + + await expect.element(page.getByTestId("agent-activity-line")).toBeVisible(); + expect((await page.getByTestId("agent-activity-line").element()).tagName).toBe("DIV"); + }); + + it("offers a way over when you are looking at another tab", async () => { + render( + {}} />, + ); + + await expect.element(page.getByTestId("agent-activity-line")).toBeVisible(); + const line = await page.getByTestId("agent-activity-line").element(); + expect(line.tagName).toBe("BUTTON"); + expect(line.textContent).toContain("show"); + }); + + it("reads without a subject when the action had none", async () => { + render( + {}} + />, + ); + + await expect.element(page.getByTestId("agent-activity-line")).toBeVisible(); + const text = (await page.getByTestId("agent-activity-line").element()).textContent ?? ""; + expect(text).toContain("read the page"); + expect(text).not.toContain("null"); + }); +}); diff --git a/apps/web/src/components/browser/AgentPointer.test.ts b/apps/web/src/components/browser/AgentPointer.test.ts new file mode 100644 index 000000000..94effd7e7 --- /dev/null +++ b/apps/web/src/components/browser/AgentPointer.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { travelDurationMs } from "./AgentPointer"; + +/** + * The glide is the whole point of moving rather than reappearing, and both ends + * of it are wrong in a way that looks like a bug rather than a bad number. + */ +describe("travelDurationMs", () => { + it("arrives instantly the first time, instead of flying in from the corner", () => { + // Before the first action the pointer has no position, and animating from + // the origin would send it across the page on every fresh turn. + expect(travelDurationMs(null, { x: 400, y: 300 })).toBe(0); + }); + + it("keeps a short hop long enough to read as movement", () => { + expect(travelDurationMs({ x: 100, y: 100 }, { x: 104, y: 100 })).toBe(220); + }); + + it("caps a long haul so the page does not change mid-flight", () => { + // A click at the far corner would otherwise still be travelling while the + // page it landed on has already navigated. + expect(travelDurationMs({ x: 0, y: 0 }, { x: 1600, y: 1200 })).toBe(560); + }); + + it("scales with distance between those bounds", () => { + const near = travelDurationMs({ x: 0, y: 0 }, { x: 300, y: 0 }); + const far = travelDurationMs({ x: 0, y: 0 }, { x: 500, y: 0 }); + + expect(near).toBeGreaterThan(220); + expect(far).toBeGreaterThan(near); + expect(far).toBeLessThanOrEqual(560); + }); +}); diff --git a/apps/web/src/components/browser/AgentPointer.tsx b/apps/web/src/components/browser/AgentPointer.tsx new file mode 100644 index 000000000..c718d2e69 --- /dev/null +++ b/apps/web/src/components/browser/AgentPointer.tsx @@ -0,0 +1,259 @@ +import { useEffect, useRef, useState } from "react"; + +import { cn } from "~/lib/utils"; + +/** + * Where the agent is on the page. + * + * An agent driving a browser the user is watching should be visible doing it. + * Without this, a page changes under you with no way to tell whether the agent + * did it, the page did it, or nothing happened at all -- and "did the click + * land?" is the question every one of these turns ends on. + * + * It stays where it last acted rather than fading out. A mark that vanishes + * answers that question only if you happened to be looking; one that rests + * answers it whenever you look up. It travels between actions instead of + * teleporting, because a jump reads as two separate marks appearing and a glide + * reads as one thing moving, which is what actually happened. + * + * Drawn over the webview rather than inside the page: it is a thing our UI is + * saying about the page, not a thing on the page. That also keeps it out of + * screenshots, which should show what the user would see, not our annotations. + */ + +/** How long after the last action the pointer settles to its resting state. */ +const POINTER_SETTLE_MS = 900; + +/** + * How long the click ring runs. + * + * Deliberately finite. Tailwind's `animate-ping` loops forever, and a + * perpetually animating SVG over a live webview is the exact shape of the idle + * churn that cost this app a quarter of a core per window once already. + */ +const POINTER_PING_MS = 620; + +/** + * How long the mark takes to leave once the page it referred to has gone. + * + * Matches the opacity transition below, so the element is still mounted for + * the whole fade. + */ +export const POINTER_RETIRE_MS = 420; + +/** One frame at the start of a drag, so there is somewhere to travel from. */ +const HOLD_SETTLE_MS = 60; + +/** + * Bounds on the glide. + * + * Below the floor it reads as a jump. Above the ceiling the mark is still in + * flight well after the action it is reporting, which matters because the point + * arrives only once the click has already been dispatched -- a click that + * navigates has the page changing underneath a pointer still on its way there. + * + * Paced for watching rather than for speed. A hand does not cross a page in a + * sixth of a second, and the mark is there to be followed, not to keep up. + */ +const POINTER_TRAVEL_MIN_MS = 220; +const POINTER_TRAVEL_MAX_MS = 560; +const POINTER_TRAVEL_MS_PER_PX = 0.9; + +/** + * Eases in as well as out. + * + * A pointer that starts at full speed and merely slows down reads as something + * being dragged. Gathering pace and then settling is how a hand moves, and it + * is the difference between smooth and merely animated. + */ +const POINTER_TRAVEL_EASING = "cubic-bezier(0.32, 0.04, 0.22, 1)"; + +export interface AgentPointerPosition { + /** Viewport coordinates, as the agent's input was dispatched. */ + readonly x: number; + readonly y: number; + /** Distinguishes two actions in the same spot, which should read as two. */ + readonly sequence: number; + /** + * Where a drag began, when this point is the end of one. + * + * The gesture is over by the time we hear about it, so this is a replay + * rather than a live feed: the mark presses at one end, travels, releases at + * the other. Showing only where it finished would lose the part worth seeing. + */ + readonly from?: { readonly x: number; readonly y: number } | undefined; +} + +export function travelDurationMs( + from: { readonly x: number; readonly y: number } | null, + to: { readonly x: number; readonly y: number }, +): number { + // The first appearance has nowhere to travel from, so it arrives rather than + // flying in from the corner. + if (from === null) { + return 0; + } + const distance = Math.hypot(to.x - from.x, to.y - from.y); + return Math.min( + POINTER_TRAVEL_MAX_MS, + Math.max(POINTER_TRAVEL_MIN_MS, distance * POINTER_TRAVEL_MS_PER_PX), + ); +} + +export function AgentPointer({ + position, + scale, + retiring = false, + className, +}: { + position: AgentPointerPosition | null; + /** Page pixels to panel pixels, so the mark lands where the user sees it. */ + scale: number; + /** The page it was pointing at has gone, so it is on its way out. */ + retiring?: boolean; + className?: string; +}) { + const [settled, setSettled] = useState(false); + const previous = useRef<{ x: number; y: number } | null>(null); + const gesture = useDragReplay(position, scale); + + const sequence = position?.sequence ?? null; + useEffect(() => { + if (sequence === null) { + return; + } + // Every action resets the clock: the pointer only rests once the agent has + // stopped moving it. + setSettled(false); + const timer = window.setTimeout(() => setSettled(true), POINTER_SETTLE_MS); + return () => window.clearTimeout(timer); + }, [sequence]); + + if (position === null) { + previous.current = null; + return null; + } + + const point = gesture.point ?? { x: position.x * scale, y: position.y * scale }; + const duration = travelDurationMs(previous.current, point); + previous.current = point; + + return ( + + ); +} + +/** + * Replays a drag: pressed at one end, travelling, released at the other. + * + * Two renders, because a single state change would batch into one and the mark + * would simply appear at the destination -- there has to be a frame where it is + * at the start for there to be anything to travel from. + */ +function useDragReplay( + position: AgentPointerPosition | null, + scale: number, +): { point: { x: number; y: number } | null; held: boolean } { + const [state, setState] = useState<{ + point: { x: number; y: number } | null; + held: boolean; + }>({ point: null, held: false }); + + const sequence = position?.sequence ?? null; + const from = position?.from; + const x = position?.x ?? 0; + const y = position?.y ?? 0; + + useEffect(() => { + if (sequence === null || from === undefined) { + setState({ point: null, held: false }); + return; + } + const start = { x: from.x * scale, y: from.y * scale }; + const end = { x: x * scale, y: y * scale }; + setState({ point: start, held: true }); + + const travel = window.setTimeout(() => setState({ point: end, held: true }), HOLD_SETTLE_MS); + // Let go once it has arrived, so the ring expands where the drag ended. + const release = window.setTimeout( + () => setState({ point: end, held: false }), + HOLD_SETTLE_MS + travelDurationMs(start, end), + ); + return () => { + window.clearTimeout(travel); + window.clearTimeout(release); + }; + }, [sequence, from, x, y, scale]); + + return state; +} + +/** + * The button, held down. + * + * Still rather than animating: a pulsing ring during travel reads as repeated + * clicking, and one continuous gesture is what actually happened. The stillness + * is the signal, and it costs nothing to render. + */ +function HeldRing() { + return ( + + ); +} + +/** The action itself, as a ring that expands once from where it landed. */ +function ClickRing({ sequence }: { sequence: number }) { + const [running, setRunning] = useState(true); + + useEffect(() => { + setRunning(true); + const timer = window.setTimeout(() => setRunning(false), POINTER_PING_MS); + return () => window.clearTimeout(timer); + }, [sequence]); + + if (!running) { + return null; + } + return ( + + ); +} diff --git a/apps/web/src/components/browser/BrowserPanel.tsx b/apps/web/src/components/browser/BrowserPanel.tsx new file mode 100644 index 000000000..867e6dc88 --- /dev/null +++ b/apps/web/src/components/browser/BrowserPanel.tsx @@ -0,0 +1,1773 @@ +import type { DrawingContext } from "../../lib/drawingContext"; +import { + pickedElementFromPreview, + type PickedElementContextDraft, +} from "../../lib/pickedElementContext"; +import type { + DesktopLocalServer, + DesktopPreviewAnnotationMode, + DesktopPreviewPickedElement, + DesktopPreviewPickMode, + ScopedThreadRef, +} from "@threadlines/contracts"; +import { PREVIEW_PARTITION } from "@threadlines/shared/preview"; +import { + ArrowLeftIcon, + ArrowRightIcon, + CameraIcon, + ExternalLinkIcon, + GlobeIcon, + MaximizeIcon, + MinimizeIcon, + ChevronDownIcon, + EraserIcon, + MousePointerClickIcon, + PencilIcon, + SquareDashedMousePointerIcon, + MoreVerticalIcon, + PlusIcon, + RadioTowerIcon, + RotateCwIcon, + XIcon, +} from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { + BROWSER_VIEWPORT_PRESETS, + RESPONSIVE_VIEWPORT, + selectActiveTab, + selectThreadBrowserState, + steppedZoom, + useBrowserPanelStore, + type BrowserAppearance, + type BrowserTab, + type BrowserViewport, +} from "../../browserPanelStore"; +import { isElectron } from "../../env"; +import { useTheme } from "../../hooks/useTheme"; +import { cn } from "../../lib/utils"; +import { + Menu, + MenuGroupLabel, + MenuItem, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator, + MenuTrigger, +} from "../ui/menu"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { RotateDeviceIcon } from "../Icons"; +import { resolveBrowserViewportLayout } from "./browserViewportLayout"; +import { AgentPointer, POINTER_RETIRE_MS, type AgentPointerPosition } from "./AgentPointer"; +import { completeUrl } from "./urlCompletion"; +import { usePreviewAutomationHost, type AgentActivity } from "./previewAutomationHost"; +import { normalizePreviewUrl } from "./previewUrl"; + +/** + * Electron's is a custom element, so React needs to be told it exists. + * It is deliberately the renderer's own element rather than a native view over + * the window: that keeps it inside normal CSS layout, so dialogs, popovers and + * the source control sheet stack above it without any bounds bookkeeping. + */ +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace JSX { + interface IntrinsicElements { + webview: React.DetailedHTMLProps, HTMLElement> & { + src?: string; + partition?: string; + }; + } + } +} + +/** + * A rejects imperative calls until its guest has attached and fired + * dom-ready. Navigation still happens: `src` is bound to the tab's url, so a + * tab created and immediately pointed somewhere loads from the attribute once + * it is ready. These helpers keep that race from surfacing as an error. + */ +function callWhenReady(action: () => T): T | null { + try { + return action(); + } catch { + return null; + } +} + +export interface PreviewWebview extends HTMLElement { + getWebContentsId: () => number; + getURL: () => string; + getTitle: () => string; + canGoBack: () => boolean; + canGoForward: () => boolean; + goBack: () => void; + goForward: () => void; + reload: () => void; + reloadIgnoringCache: () => void; + setZoomFactor: (factor: number) => void; + loadURL: (url: string) => Promise; +} + +interface NavState { + canGoBack: boolean; + canGoForward: boolean; + loading: boolean; +} + +const IDLE_NAV_STATE: NavState = { canGoBack: false, canGoForward: false, loading: false }; + +export function BrowserPanel({ + threadRef, + flexGrow, + onClose, + onPickElement, + onScreenshot, + onDrawing, + pendingReveal, + onRevealHandled, +}: { + threadRef: ScopedThreadRef; + /** Complement of the chat column's share, so the two honour one ratio. */ + flexGrow: number; + onClose: () => void; + /** Hands a picked element to the composer as context for the next message. */ + onPickElement?: ((element: DesktopPreviewPickedElement) => void) | undefined; + /** Attaches a captured screenshot to the message being written. */ + onScreenshot?: ((input: { dataUrl: string; name: string }) => void) | undefined; + /** Attaches a drawing made on the page: one chip carrying its picture. */ + onDrawing?: ((context: DrawingContext) => void) | undefined; + /** An element to show again, requested from a composer chip. */ + pendingReveal?: PickedElementContextDraft | null | undefined; + onRevealHandled?: (() => void) | undefined; +}) { + const browserState = useBrowserPanelStore((store) => + selectThreadBrowserState(store.browserStateByThreadKey, threadRef), + ); + const setTabUrl = useBrowserPanelStore((store) => store.setTabUrl); + const setTabViewport = useBrowserPanelStore((store) => store.setTabViewport); + const openTab = useBrowserPanelStore((store) => store.openTab); + const closeTab = useBrowserPanelStore((store) => store.closeTab); + const selectTab = useBrowserPanelStore((store) => store.selectTab); + const expanded = useBrowserPanelStore((store) => store.expanded); + const toggleExpanded = useBrowserPanelStore((store) => store.toggleExpanded); + const deviceToolbarOpen = useBrowserPanelStore((store) => store.deviceToolbarOpen); + const toggleDeviceToolbar = useBrowserPanelStore((store) => store.toggleDeviceToolbar); + const appearance = useBrowserPanelStore((store) => store.appearance); + const setAppearance = useBrowserPanelStore((store) => store.setAppearance); + const setTabZoom = useBrowserPanelStore((store) => store.setTabZoom); + const { resolvedTheme } = useTheme(); + const guestColorScheme = appearance === "system" ? resolvedTheme : appearance; + + const activeTab = selectActiveTab(browserState); + const activeTabId = activeTab?.id ?? ""; + + // One element per tab, so switching tabs keeps every page alive -- along with + // the CDP attachment collecting its console and network diagnostics. + const webviewsRef = useRef(new Map()); + const [navState, setNavState] = useState(IDLE_NAV_STATE); + const [addressDraft, setAddressDraft] = useState(activeTab?.url ?? ""); + const visitedUrls = useBrowserPanelStore((store) => store.visitedUrls); + const activeUrl = activeTab?.url ?? null; + + // The address bar is an input the user types in, so it is only reset when the + // page moves or the tab changes -- never on every keystroke. + useEffect(() => { + setAddressDraft(activeUrl ?? ""); + setNavState(IDLE_NAV_STATE); + }, [activeUrl, activeTabId]); + + // The agent's last touch on the page, shown over the webview so it is + // visible working rather than silently changing things. + const [agentPoint, setAgentPoint] = useState(null); + + const activeWebview = () => webviewsRef.current.get(activeTabId) ?? null; + + // Host rather than hostname: on a dev box every page is localhost and the + // port is the only thing telling two captures apart. + const screenshotName = useCallback(() => { + try { + return new URL(activeUrl ?? "").host.replace(":", "-") || "page"; + } catch { + return "page"; + } + }, [activeUrl]); + + /** + * The tab the agent works in. + * + * Pinned on its first action and kept, rather than resolved fresh each time. + * Resolving each time meant two things went wrong at once: the agent's next + * click landed on whatever tab the user had just switched to, and a + * navigation moved the page out from under someone reading it. The pin only + * lets go when the tab does. + */ + const [agentTabId, setAgentTabId] = useState(null); + const agentTabIdRef = useRef(null); + agentTabIdRef.current = agentTabId; + + /** What the agent last did, shown while it is working. */ + const [agentActivity, setAgentActivity] = useState(null); + + /* + * The agent keeps its tab. Opening a tab, or switching to one, is yours to do + * and does not move it. + * + * This was briefly the opposite -- an idle agent handed its tab over to + * whichever one you were looking at -- and that was wrong twice over. "Idle" + * was measured per operation rather than per turn, so opening a tab in the + * gap between two of the agent's own actions stole the page mid-task. And it + * was solving a problem that no longer exists: the agent could not see other + * tabs when I wrote it, so following you was the only way it could ever end + * up somewhere useful. It can see them now, and move itself, and say so when + * it does. + * + * Which leaves the arrangement people expect from tabs. Yours are yours to + * browse; its tab is marked in the strip and one click away from the activity + * line, and it goes only where it decides to go. + */ + + useEffect(() => { + // The agent's tab has been closed. Letting go here is what makes its next + // action pick up the tab the user is actually looking at. + if (agentTabId !== null && !browserState.tabs.some((tab) => tab.id === agentTabId)) { + setAgentTabId(null); + setAgentActivity(null); + setAgentPoint(null); + } + }, [agentTabId, browserState.tabs]); + + /** Set while the mark is fading out, after the page it referred to has gone. */ + const [agentPointRetiring, setAgentPointRetiring] = useState(false); + const agentPointRef = useRef(null); + agentPointRef.current = agentPoint; + + useEffect(() => { + // The mark is in viewport coordinates, so it stops meaning anything the + // moment the page under it changes: resting somewhere stale would point + // confidently at whatever now occupies that spot. + // + // It fades rather than blinking out, though. Clicking a link is the most + // common way to navigate, so cutting the mark on the same frame as the + // click means the one action you most want confirmed is the one that + // leaves nothing behind. Fading says "that happened, and then the page + // changed", which is what did happen. + if (agentPointRef.current === null) { + return; + } + setAgentPointRetiring(true); + const timer = window.setTimeout(() => { + setAgentPoint(null); + setAgentPointRetiring(false); + }, POINTER_RETIRE_MS); + return () => window.clearTimeout(timer); + }, [activeUrl]); + + // Offers this panel as the page the agent acts on, for as long as it is here. + usePreviewAutomationHost({ + threadRef, + enabled: isElectron, + resolveTarget: () => { + const pinned = agentTabIdRef.current; + const tabId = pinned !== null && webviewsRef.current.has(pinned) ? pinned : activeTabId; + if (tabId !== pinned) { + agentTabIdRef.current = tabId; + setAgentTabId(tabId); + } + const webview = webviewsRef.current.get(tabId) ?? null; + return { + webContentsId: webview === null ? null : webview.getWebContentsId(), + onAgentPoint: (point) => { + setAgentPointRetiring(false); + setAgentPoint((previous) => ({ + ...point, + sequence: (previous?.sequence ?? 0) + 1, + })); + }, + onAgentActivity: (activity) => setAgentActivity(activity), + selectTab: (index) => { + const chosen = browserState.tabs[index]; + if (chosen === undefined) { + return; + } + // Move the pin with the view. Without this the agent would keep + // acting on the tab it was pinned to while showing you another. + agentTabIdRef.current = chosen.id; + setAgentTabId(chosen.id); + selectTab(threadRef, chosen.id); + }, + tabs: () => + browserState.tabs.map((entry) => ({ + title: entry.title ?? "", + url: entry.url ?? "", + active: entry.id === activeTabId, + agent: entry.id === tabId, + })), + viewport: () => { + const rect = webview?.getBoundingClientRect(); + return { + width: Math.round(rect?.width ?? 0), + height: Math.round(rect?.height ?? 0), + }; + }, + // The address belongs to the element, so this is the one operation the + // main process cannot do on the agent's behalf. Note that it never + // touches which tab is selected: the agent moves its page, not the view. + navigate: async (url) => { + const normalized = normalizePreviewUrl(url); + // Refused rather than silently doing nothing: the agent gave an + // address, and it needs to know if that address was not one. + if (normalized === null) { + throw new Error(`${JSON.stringify(url)} is not a URL this browser can open.`); + } + setTabUrl(threadRef, tabId, normalized); + await webview?.loadURL(normalized); + }, + }; + }, + }); + + // Opening the device row on a responsive tab seeds concrete dimensions from + // whatever the page currently occupies. Without them there is nothing to drag + // and nothing to type over, so the row would open showing "auto" and do + // nothing until a preset was chosen. + const viewportAreaRef = useRef(null); + const measurePanelViewport = useCallback((): BrowserViewport | null => { + const area = viewportAreaRef.current; + if (area === null) { + return null; + } + const width = Math.max(160, Math.round(area.clientWidth - 24)); + const height = Math.max(160, Math.round(area.clientHeight - 24)); + return { width, height }; + }, []); + + useEffect(() => { + if (!deviceToolbarOpen || activeTab === null || activeTab.viewport.width !== null) { + return; + } + const measured = measurePanelViewport(); + if (measured !== null) { + setTabViewport(threadRef, activeTab.id, measured); + } + }, [activeTab, deviceToolbarOpen, measurePanelViewport, setTabViewport, threadRef]); + + const addressRef = useRef(null); + const deletingRef = useRef(false); + + const completeAddress = useCallback( + (typed: string) => { + setAddressDraft(typed); + if (deletingRef.current) { + return; + } + const completion = completeUrl(typed, visitedUrls); + if (completion === null) { + return; + } + // The completion goes in the field with the part you did not type + // selected, so carrying on typing overwrites it and Enter accepts it. + // This is what every address bar does, and the reason it never feels + // like the field is arguing with you. + setAddressDraft(completion); + requestAnimationFrame(() => { + addressRef.current?.setSelectionRange(typed.length, completion.length); + }); + }, + [visitedUrls], + ); + + const submitAddress = useCallback( + (event: React.FormEvent) => { + event.preventDefault(); + const normalized = normalizePreviewUrl(addressDraft); + if (normalized === null || activeTabId === "") { + return; + } + setTabUrl(threadRef, activeTabId, normalized); + const webview = webviewsRef.current.get(activeTabId); + callWhenReady(() => void webview?.loadURL(normalized)); + }, + [activeTabId, addressDraft, setTabUrl, threadRef], + ); + + /** + * Which page tool is armed, or null when the pointer belongs to the page. + * + * One piece of state for all four: they are the same act of pointing at the + * page, they all want the pointer, and only one can be live. Kept here rather + * than in the store because what they act on -- the guest document, and any + * ink on it -- dies with the page, so a remembered mode would light the + * control up over nothing. + */ + const [armedTool, setArmedTool] = useState(null); + // Read inside the pick round trip, which outlives the render that started it. + const armedToolRef = useRef(null); + const [selectedTool, setSelectedTool] = useState("element"); + + const armTool = useCallback( + async (requested: PageTool | null, options?: { readonly toggle?: boolean }) => { + const webview = webviewsRef.current.get(activeTabId); + if (webview === null || webview === undefined || !isElectron) { + return; + } + const webContentsId = webview.getWebContentsId(); + const previous = armedToolRef.current; + // Pressing the lit control puts the tool away, which for ink is also how + // you clear it: putting the pen down and being done are the same moment. + // Reaching into the menu does not toggle, though -- choosing a tool from + // a list means use this one, and having it turn the tool off because it + // happened to already be on reads as the menu being broken. + const next = options?.toggle !== false && previous === requested ? null : requested; + + if (isInkTool(previous) && !isInkTool(next)) { + // Parked, not binned. A drawing you have not attached yet is unsent + // work, and reaching for the picker to name something inside what you + // just circled should not throw the circle away. Pressing the lit pen + // is how you clear it, and that comes through here as next === null. + await window.desktopBridge?.previewSetAnnotationMode?.({ + webContentsId, + mode: next === null ? null : "idle", + }); + } else if (previous !== null && !isInkTool(previous)) { + await window.desktopBridge?.previewCancelPick?.({ webContentsId }); + } + + armedToolRef.current = next; + setArmedTool(next); + if (next === null) { + return; + } + if (isInkTool(next)) { + // Straight to the new mode when moving between the pen and the eraser: + // the eraser exists to fix the stroke you just drew, and clearing on + // the way there would leave nothing to fix. + await window.desktopBridge?.previewSetAnnotationMode?.({ webContentsId, mode: next }); + // One wait covers a stretch with the pen, including trips to the + // eraser. Coming back after using another tool starts a fresh one, + // because parking the ink settled the last. + if (isInkTool(previous)) { + return; + } + const drawing = await window.desktopBridge?.previewAwaitDrawing?.({ webContentsId }); + if (drawing === null || drawing === undefined) { + return; + } + // Photographed before the marks come down, because the ink is half of + // what the drawing says. + const shot = await window.desktopBridge?.previewScreenshot?.({ webContentsId }); + if (shot !== undefined && shot.dataUrl.slice(shot.dataUrl.indexOf(",") + 1) !== "") { + // One thing, because it was one act. The picture and whatever the + // closed strokes went round travel together behind a single chip + // rather than scattering across the composer. + onDrawing?.({ + note: drawing.note, + imageDataUrl: shot.dataUrl, + url: activeUrl ?? "", + elements: drawing.elements.map(pickedElementFromPreview), + }); + } + await window.desktopBridge?.previewSetAnnotationMode?.({ webContentsId, mode: null }); + armedToolRef.current = null; + setArmedTool(null); + return; + } + try { + const elements = await window.desktopBridge?.previewPickElement?.({ + webContentsId, + colorScheme: guestColorScheme, + mode: next, + }); + for (const element of elements ?? []) { + onPickElement?.(element); + } + } finally { + // Only if nothing else took the pointer while this was waiting: arming + // another tool cancels this pick, and its resolution must not then + // disarm the tool that replaced it. + if (armedToolRef.current === next) { + armedToolRef.current = null; + setArmedTool(null); + } + } + }, + [activeTabId, activeUrl, guestColorScheme, onDrawing, onPickElement], + ); + + // Everything these tools act on lives in the guest document, which a new + // address replaces. Switching tabs is the same story: the tool was armed on + // the other one. + const activeTabUrl = activeTab?.url ?? null; + useEffect(() => { + armedToolRef.current = null; + setArmedTool(null); + }, [activeTabUrl, activeTabId]); + + // A reveal request arrives from the composer, which cannot know which tab is + // showing the page it came from -- or whether the panel was even open. The + // panel resolves that here: it prefers a tab already on that URL, falls back + // to the active one, and reports when the element has since gone. + const [revealMissing, setRevealMissing] = useState(null); + useEffect(() => { + if (pendingReveal === null || pendingReveal === undefined || !isElectron) { + return; + } + let cancelled = false; + const run = async () => { + const onSameUrl = browserState.tabs.find((tab) => tab.url === pendingReveal.url); + if (onSameUrl !== undefined && onSameUrl.id !== activeTabId) { + selectTab(threadRef, onSameUrl.id); + // Let the newly active tab mount before asking it for anything. + await new Promise((resolve) => setTimeout(resolve, 120)); + } + if (cancelled) return; + const targetId = onSameUrl?.id ?? activeTabId; + const webview = webviewsRef.current.get(targetId); + if (webview === null || webview === undefined) { + onRevealHandled?.(); + return; + } + const found = await window.desktopBridge?.previewRevealElement?.({ + webContentsId: webview.getWebContentsId(), + selector: pendingReveal.selector, + }); + if (cancelled) return; + setRevealMissing(found === true ? null : pendingReveal.id); + onRevealHandled?.(); + }; + void run(); + return () => { + cancelled = true; + }; + }, [activeTabId, browserState.tabs, onRevealHandled, pendingReveal, selectTab, threadRef]); + + // The notice clears itself: it answers a question the user just asked and + // has no meaning a moment later. + useEffect(() => { + if (revealMissing === null) { + return; + } + const timer = setTimeout(() => setRevealMissing(null), 4000); + return () => clearTimeout(timer); + }, [revealMissing]); + + /** + * Closing the last tab closes the panel rather than leaving a blank one. + * Clicking close and being handed a fresh tab reads as nothing having + * happened, and reopening the panel provides one anyway. + */ + const closeBrowserTab = useCallback( + (tabId: string) => { + if (browserState.tabs.length <= 1) { + onClose(); + return; + } + closeTab(threadRef, tabId); + }, + [browserState.tabs.length, closeTab, onClose, threadRef], + ); + + /** + * Straight into the composer rather than onto the clipboard. + * + * It used to write the data URL to the clipboard as text, which looked like + * nothing happening and pasted a wall of base64. A screenshot is evidence for + * the message being written, and everything else picked from the page already + * attaches itself -- making this one a copy-then-paste errand would be the + * odd one out. + */ + const captureScreenshot = useCallback(() => { + const webview = webviewsRef.current.get(activeTabId); + if (webview === undefined || !isElectron) { + return; + } + void window.desktopBridge + ?.previewScreenshot?.({ webContentsId: webview.getWebContentsId() }) + .then((shot) => { + // A payload-less data URL is still a non-empty string, so the emptiness + // check has to look past the header. + if (shot.dataUrl.slice(shot.dataUrl.indexOf(",") + 1) === "") { + return; + } + onScreenshot?.({ dataUrl: shot.dataUrl, name: `${screenshotName()}.png` }); + }); + }, [activeTabId, onScreenshot, screenshotName]); + + return ( +
+
+ {browserState.tabs.map((tab) => ( + selectTab(threadRef, tab.id)} + onClose={() => closeBrowserTab(tab.id)} + /> + ))} + + + {/* Panel-level controls only: where the browser sits and whether it is + open. What acts on the page lives with the address it acts on. */} +
+ + {expanded ? ( + + ) : ( + + )} + + + + +
+
+ +
+ activeWebview()?.goBack()} + > + + + activeWebview()?.goForward()} + > + + + activeWebview()?.reload()}> + + + +
+ completeAddress(event.target.value)} + onKeyDown={(event) => { + // Deleting is a correction. Completing over it would put the + // guess straight back and make the key look broken. + deletingRef.current = event.key === "Backspace" || event.key === "Delete"; + }} + /> + {/* Inside the field, where it reads as "this address, elsewhere" + rather than as another page control. */} + + { + if (activeUrl !== null) { + void window.desktopBridge?.openExternal?.(activeUrl); + } + }} + /> + } + > + + + Open in default browser + +
+ + {/* Beside the address they act on, which is also where both reference + browsers put them. */} + void armTool(tool)} + onSelect={(tool) => { + setSelectedTool(tool); + void armTool(tool, { toggle: false }); + }} + /> + + + + + + } + > + + + + { + const webview = webviewsRef.current.get(activeTabId); + callWhenReady(() => webview?.reloadIgnoringCache()); + }} + > + Hard reload + + + {deviceToolbarOpen ? "Hide device toolbar" : "Show device toolbar"} + + + setAppearance(value as BrowserAppearance)} + > + {/* The label belongs to the group: Base UI reads its context, and + outside one it throws and takes the whole menu with it. */} + Appearance + {(["system", "light", "dark"] as const).map((value) => ( + + {value === "system" ? "Follow app" : value === "light" ? "Light" : "Dark"} + + ))} + + + { + if (activeUrl !== null) { + void window.desktopBridge?.openExternal?.(activeUrl); + } + }} + > + Open in default browser + + { + if (activeUrl !== null) { + void navigator.clipboard.writeText(activeUrl).catch(() => {}); + } + }} + > + Copy address + + { + const webview = webviewsRef.current.get(activeTabId); + const id = + webview === undefined ? null : callWhenReady(() => webview.getWebContentsId()); + if (id !== null) { + void window.desktopBridge?.previewOpenDevTools?.({ webContentsId: id }); + } + }} + > + Open developer tools + + + { + void window.desktopBridge?.previewClearCache?.().then(() => { + callWhenReady(() => webviewsRef.current.get(activeTabId)?.reloadIgnoringCache()); + }); + }} + > + Clear cache + + { + // Signing out of the preview is the point, so reload after: the + // page on screen would otherwise still look signed in. + void window.desktopBridge?.previewClearBrowsingData?.().then(() => { + callWhenReady(() => webviewsRef.current.get(activeTabId)?.reload()); + }); + }} + > + Clear cookies and storage + + + +
+ + {agentTabId !== null && agentActivity !== null ? ( + selectTab(threadRef, agentTabId)} + /> + ) : null} + + {deviceToolbarOpen && activeTab !== null ? ( + setTabViewport(threadRef, activeTab.id, viewport)} + onZoomChange={(factor) => setTabZoom(threadRef, activeTab.id, factor)} + onClose={toggleDeviceToolbar} + /> + ) : null} + + {revealMissing === null ? null : ( +
+ That element is not on this page any more. +
+ )} +
+ {!isElectron ? ( + + ) : ( + <> + {activeTab !== null && activeTab.url === null ? ( +
+ { + const url = `http://localhost:${port}`; + setTabUrl(threadRef, activeTab.id, url); + const webview = webviewsRef.current.get(activeTab.id); + callWhenReady(() => void webview?.loadURL(url)); + }} + /> +
+ ) : null} + {browserState.tabs.map((tab) => ( + setTabViewport(threadRef, tab.id, next) + : undefined + } + onNavState={setNavState} + agentPoint={tab.id === agentTabId ? agentPoint : null} + agentPointRetiring={agentPointRetiring} + register={(element) => { + if (element === null) { + webviewsRef.current.delete(tab.id); + } else { + webviewsRef.current.set(tab.id, element); + } + }} + /> + ))} + + )} +
+
+ ); +} + +function TabStripItem({ + tab, + isActive, + closable, + agentState, + onSelect, + onClose, +}: { + tab: BrowserTab; + isActive: boolean; + closable: boolean; + /** Whether the agent is working in this tab, and whether right now. */ + agentState: "none" | "pinned" | "working"; + onSelect: () => void; + onClose: () => void; +}) { + const label = tab.title ?? (tab.url === null ? "New tab" : tab.url.replace(/^https?:\/\//, "")); + return ( +
+ + {closable ? ( + + ) : null} +
+ ); +} + +/** + * One tab's guest page. Kept mounted while inactive and hidden with + * `visibility` rather than `display`, so the guest keeps its layout: a page + * collapsed to zero size would report a meaningless viewport to a screenshot + * or to an agent measuring an element. + */ +function PreviewTabFrame({ + tab, + threadRef, + isActive, + viewport, + zoomFactor, + colorScheme, + onResize, + onNavState, + register, + agentPoint, + agentPointRetiring, +}: { + tab: BrowserTab; + threadRef: ScopedThreadRef; + isActive: boolean; + viewport: BrowserViewport; + zoomFactor: number; + colorScheme: "light" | "dark"; + onResize?: ((viewport: BrowserViewport) => void) | undefined; + onNavState: (state: NavState) => void; + register: (element: PreviewWebview | null) => void; + /** The agent's last touch, drawn over this tab when it is the visible one. */ + agentPoint: AgentPointerPosition | null; + /** Whether that touch is fading out, its page having been navigated away. */ + agentPointRetiring: boolean; +}) { + const elementRef = useRef(null); + const setTabUrl = useBrowserPanelStore((store) => store.setTabUrl); + const setTabTitle = useBrowserPanelStore((store) => store.setTabTitle); + const isActiveRef = useRef(isActive); + isActiveRef.current = isActive; + const colorSchemeRef = useRef(colorScheme); + colorSchemeRef.current = colorScheme; + const zoomFactorRef = useRef(zoomFactor); + zoomFactorRef.current = zoomFactor; + + useEffect(() => { + const webview = elementRef.current; + if (webview === null || !isElectron) { + return; + } + const id = callWhenReady(() => webview.getWebContentsId()); + if (id !== null) { + void window.desktopBridge?.previewSetColorScheme?.({ webContentsId: id, colorScheme }); + } + }, [colorScheme]); + + useEffect(() => { + const webview = elementRef.current; + if (webview === null || !isElectron) { + return; + } + callWhenReady(() => webview.setZoomFactor(zoomFactor)); + }, [zoomFactor]); + + useEffect(() => { + const webview = elementRef.current; + if (webview === null || !isElectron) { + return; + } + const id = callWhenReady(() => webview.getWebContentsId()); + if (id !== null) { + void window.desktopBridge?.previewSetViewport?.({ + webContentsId: id, + width: viewport.width, + height: viewport.height, + }); + } + }, [viewport.width, viewport.height]); + + useEffect(() => { + const webview = elementRef.current; + if (webview === null || !isElectron) { + return; + } + let attachedId: number | null = null; + const onAttached = () => { + attachedId = webview.getWebContentsId(); + callWhenReady(() => webview.setZoomFactor(zoomFactorRef.current)); + void window.desktopBridge?.previewAttach?.({ webContentsId: attachedId }).then(() => { + // A page that respects prefers-color-scheme should follow the app + // rather than the OS: a dark app hosting a stubbornly light page is + // the jarring part, and it is the app the page is embedded in. + void window.desktopBridge?.previewSetColorScheme?.({ + webContentsId: attachedId as number, + colorScheme: colorSchemeRef.current, + }); + }); + }; + const publishNav = (loading: boolean) => { + // Only the visible tab drives the toolbar; a background tab finishing a + // load must not repaint controls that describe a different page. + if (isActiveRef.current) { + onNavState({ + canGoBack: callWhenReady(() => webview.canGoBack()) ?? false, + canGoForward: callWhenReady(() => webview.canGoForward()) ?? false, + loading, + }); + } + }; + const onNavigated = () => { + // Electron remembers zoom per origin inside the session, so a page + // visited at 125% comes back at 125% while our control still reads + // 100%. Reasserting on every navigation keeps the two from drifting. + callWhenReady(() => webview.setZoomFactor(zoomFactorRef.current)); + const url = callWhenReady(() => webview.getURL()); + if (url !== null && url !== "") { + setTabUrl(threadRef, tab.id, url); + } + publishNav(false); + }; + const onTitle = () => setTabTitle(threadRef, tab.id, webview.getTitle()); + const onStart = () => publishNav(true); + const onStop = () => publishNav(false); + + webview.addEventListener("did-attach", onAttached); + webview.addEventListener("did-navigate", onNavigated); + webview.addEventListener("did-navigate-in-page", onNavigated); + webview.addEventListener("page-title-updated", onTitle); + webview.addEventListener("did-start-loading", onStart); + webview.addEventListener("did-stop-loading", onStop); + return () => { + webview.removeEventListener("did-attach", onAttached); + webview.removeEventListener("did-navigate", onNavigated); + webview.removeEventListener("did-navigate-in-page", onNavigated); + webview.removeEventListener("page-title-updated", onTitle); + webview.removeEventListener("did-start-loading", onStart); + webview.removeEventListener("did-stop-loading", onStop); + if (attachedId !== null) { + void window.desktopBridge?.previewDetach?.({ webContentsId: attachedId }); + } + }; + }, [onNavState, setTabTitle, setTabUrl, tab.id, threadRef]); + + // The container is measured so the frame can be placed at computed + // coordinates: Electron positions the guest's surface from the element's own + // box, and a flex-centred element sits where its unscaled size says while + // painting somewhere else. + const canvasRef = useRef(null); + const [container, setContainer] = useState({ width: 0, height: 0 }); + useEffect(() => { + const element = canvasRef.current; + if (element === null) { + return; + } + const observer = new ResizeObserver(([entry]) => { + if (entry !== undefined) { + setContainer({ width: entry.contentRect.width, height: entry.contentRect.height }); + } + }); + observer.observe(element); + return () => observer.disconnect(); + }, []); + + const layout = resolveBrowserViewportLayout({ container, viewport, zoomFactor }); + + return ( +
+
+ { + elementRef.current = element as PreviewWebview | null; + register(element as PreviewWebview | null); + }} + {...(isActive ? { "data-testid": "browser-panel-webview" } : {})} + // `flex` is deliberate: Electron's webview uses its own display value + // to size the guest, and replacing it breaks painting. + className={cn("absolute flex bg-background", !layout.fills && "ring-1 ring-border")} + style={{ + left: `${layout.x}px`, + top: `${layout.y}px`, + // Laid out unscaled and shrunk by a transform on the element, so + // the guest keeps the CSS viewport it was asked for. + width: `${layout.width / layout.scale}px`, + height: `${layout.height / layout.scale}px`, + ...(layout.scale < 1 + ? { transform: `scale(${layout.scale})`, transformOrigin: "top left" } + : {}), + }} + partition={PREVIEW_PARTITION} + {...(tab.url ? { src: tab.url } : {})} + /> + {isActive ? ( + // Positioned in the same frame as the guest, so a page pixel and a + // panel pixel mean the same thing to it. +
+ +
+ ) : null} + {!layout.fills && isActive && onResize !== undefined ? ( +
+ + + +
+ ) : null} +
+
+ ); +} + +/** + * Drag handles on the page's own edges. + * + * Resizing the device by dragging is the natural gesture, and it means finding + * a breakpoint does not require resizing the whole app around it. A shield is + * raised while dragging because the pointer crosses the guest, which is a + * separate process and would otherwise swallow the events. + */ +function ViewportResizeHandle({ + edge, + viewport, + scale, + onResize, +}: { + edge: "right" | "bottom" | "corner"; + viewport: BrowserViewport; + /** Pointer travel is in screen pixels; the device is measured in its own. */ + scale: number; + onResize: (viewport: BrowserViewport) => void; +}) { + const [dragging, setDragging] = useState(false); + const originRef = useRef({ x: 0, y: 0, width: 0, height: 0 }); + + const onPointerDown = (event: React.PointerEvent) => { + event.preventDefault(); + originRef.current = { + x: event.clientX, + y: event.clientY, + width: viewport.width ?? 0, + height: viewport.height ?? 0, + }; + setDragging(true); + event.currentTarget.setPointerCapture(event.pointerId); + }; + + const onPointerMove = (event: React.PointerEvent) => { + if (!dragging) { + return; + } + const origin = originRef.current; + const ratio = scale > 0 ? scale : 1; + const width = + edge === "bottom" + ? origin.width + : Math.max(160, origin.width + (event.clientX - origin.x) / ratio); + const height = + edge === "right" + ? origin.height + : Math.max(160, origin.height + (event.clientY - origin.y) / ratio); + onResize({ width: Math.round(width), height: Math.round(height) }); + }; + + const endDrag = (event: React.PointerEvent) => { + setDragging(false); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + }; + + const position = + edge === "right" + ? "-right-1.5 inset-y-0 w-3 cursor-col-resize" + : edge === "bottom" + ? "-bottom-1.5 inset-x-0 h-3 cursor-row-resize" + : "-bottom-1.5 -right-1.5 size-3 cursor-nwse-resize"; + + return ( + <> + {dragging ?
: null} +
+ +
+ + ); +} + +/** + * The four ways of pointing at the page, behind one control. + * + * Four buttons in a row read as four unrelated things and crowd the address + * out of a panel that is often narrow. They are one gesture aimed differently, + * so they get one slot: the tool you last chose, and a caret to change it. + * Choosing from the menu arms it too -- reaching for a tool is the same act as + * picking it up. + */ +// One word each. The menu is a list of four things that differ only in what +// they point at, and a sentence apiece made the card wider than the panel it +// hangs off without saying more than the icon already does. +const PAGE_TOOLS = [ + { tool: "element", label: "Annotate", Icon: MousePointerClickIcon }, + { tool: "region", label: "Region", Icon: SquareDashedMousePointerIcon }, + { tool: "draw", label: "Draw", Icon: PencilIcon }, + { tool: "erase", label: "Erase", Icon: EraserIcon }, +] as const satisfies ReadonlyArray<{ tool: PageTool; label: string; Icon: typeof PencilIcon }>; + +type PageTool = DesktopPreviewPickMode | DesktopPreviewAnnotationMode; + +/** Ink is armed and cleared differently from a pick, and survives the swap. */ +function isInkTool(tool: PageTool | null): tool is DesktopPreviewAnnotationMode { + return tool === "draw" || tool === "erase"; +} + +function PageToolControl({ + armed, + selected, + onArm, + onSelect, +}: { + armed: PageTool | null; + selected: PageTool; + onArm: (tool: PageTool) => void; + onSelect: (tool: PageTool) => void; +}) { + // The armed tool wins over the selected one: while something is live the + // control has to show what the pointer is actually doing. + const shown = PAGE_TOOLS.find((entry) => entry.tool === (armed ?? selected)) ?? PAGE_TOOLS[0]; + const isArmed = armed !== null; + const label = isArmed ? (isInkTool(armed) ? "Clear drawing" : "Cancel") : shown.label; + + return ( + + + onArm(shown.tool)} + className={cn( + "inline-flex size-6 items-center justify-center rounded-l-md hover:bg-accent hover:text-foreground", + isArmed ? "text-foreground" : "text-muted-foreground/70", + )} + /> + } + > + + + {label} + + + + + } + > + + + + onSelect(value as PageTool)}> + {PAGE_TOOLS.map((entry) => ( + + {/* Flexed because the reset makes an svg a block, which would + otherwise drop the label onto its own line. */} + + + {entry.label} + + + ))} + + + + + ); +} + +/** + * What the agent is doing, in one line under the toolbar. + * + * The tab dot says where; this says what. Without it the only evidence an + * agent is working is the page changing on its own, which reads the same as + * the page being broken. + * + * Clicking it goes to the agent's tab, which is the whole follow affordance -- + * a mode that moved your selection for you would be the yanking this was built + * to stop. + */ +export function AgentActivityLine({ + activity, + isOnAgentTab, + onGoToAgentTab, +}: { + activity: AgentActivity; + isOnAgentTab: boolean; + onGoToAgentTab: () => void; +}) { + const body = ( + <> + + Agent + + {activity.verb} + {activity.detail === null ? "" : ` ${activity.detail}`} + + + ); + const className = + "flex h-[22px] w-full shrink-0 items-center gap-1.5 border-b border-border px-2 font-mono text-[11px] text-muted-foreground"; + + if (isOnAgentTab) { + return ( +
+ {body} +
+ ); + } + return ( + + ); +} + +function NavButton({ + label, + disabled, + active, + onClick, + testId, + children, +}: { + label: string; + disabled?: boolean; + active?: boolean; + onClick: () => void; + testId?: string; + children: React.ReactNode; +}) { + return ( + + + } + > + {children} + + {label} + + ); +} + +/** + * What is listening right now, offered as destinations. + * + * A blank address bar is a worse starting point than it looks: the port a dev + * server picked is exactly the thing nobody remembers. Rows rather than tiles, + * separated by rules, so it reads as a list of facts. + */ +function LocalServerPicker({ onSelect }: { onSelect: (port: number) => void }) { + const [servers, setServers] = useState>([]); + const [scanned, setScanned] = useState(false); + + useEffect(() => { + let cancelled = false; + const scan = () => { + void window.desktopBridge?.previewLocalServers?.().then((found) => { + if (!cancelled) { + setServers(found); + setScanned(true); + } + }); + }; + scan(); + // Servers start and stop while the panel is open; a stale list would send + // the user to a port that has since died. + const interval = window.setInterval(scan, 4000); + return () => { + cancelled = true; + window.clearInterval(interval); + }; + }, []); + + return ( +
+
+ + Local servers +
+ {servers.length === 0 ? ( +

+ {scanned ? "Nothing is listening right now." : "Looking for local servers…"} +

+ ) : ( +
+ {servers.map((server) => ( + + ))} +
+ )} +

+ Select a listening port to open it here. +

+
+ ); +} + +/** + * The preview is a Chromium , which only exists in the desktop app. + * Rather than degrade to an iframe -- which cannot be driven, inspected, or + * navigated cross-origin -- the web build says so plainly. + */ +function BrowserUnavailableNotice() { + return ( +
+ +

The browser preview needs the desktop app.

+

+ It runs a real Chromium tab so pages can be inspected and driven, which a browser tab cannot + host. +

+
+ ); +} + +/** + * Size and zoom for the page under test. + * + * Hidden until asked for, because sizing is an occasional task and a permanent + * control costs toolbar width on every page you ever look at. Width and height + * are editable rather than a fixed menu of devices: the presets fill them in, + * and any other size is reachable by typing it. + */ +function DeviceToolbar({ + viewport, + zoomFactor, + onViewportChange, + onZoomChange, + onClose, +}: { + viewport: BrowserViewport; + zoomFactor: number; + onViewportChange: (viewport: BrowserViewport) => void; + onZoomChange: (factor: number) => void; + onClose: () => void; +}) { + // "Responsive" is the label for a size nobody named, which is what dragging + // produces; a matching preset takes precedence over it. + const matchingPreset = BROWSER_VIEWPORT_PRESETS.find( + (entry) => + entry.width !== null && entry.width === viewport.width && entry.height === viewport.height, + ); + + return ( +
+ + + + onViewportChange( + width === null ? RESPONSIVE_VIEWPORT : { width, height: viewport.height ?? 800 }, + ) + } + /> + × + + onViewportChange( + height === null ? RESPONSIVE_VIEWPORT : { width: viewport.width ?? 1280, height }, + ) + } + /> + + + +
+ + + +
+ + +
+ ); +} + +/** Empty means "let it fill", which is how you get back to responsive by typing. */ +function DimensionInput({ + label, + value, + onCommit, +}: { + label: string; + value: number | null; + onCommit: (value: number | null) => void; +}) { + const [draft, setDraft] = useState(value === null ? "" : String(value)); + useEffect(() => { + setDraft(value === null ? "" : String(value)); + }, [value]); + + const commit = () => { + const trimmed = draft.trim(); + if (trimmed === "") { + onCommit(null); + return; + } + const parsed = Number.parseInt(trimmed, 10); + onCommit(Number.isNaN(parsed) || parsed < 1 ? value : Math.min(parsed, 9999)); + }; + + return ( + setDraft(event.target.value)} + onBlur={commit} + onKeyDown={(event) => { + if (event.key === "Enter") { + commit(); + } + }} + /> + ); +} diff --git a/apps/web/src/components/browser/BrowserSplitHandle.tsx b/apps/web/src/components/browser/BrowserSplitHandle.tsx new file mode 100644 index 000000000..b0ec13ac0 --- /dev/null +++ b/apps/web/src/components/browser/BrowserSplitHandle.tsx @@ -0,0 +1,97 @@ +import { useCallback, useRef } from "react"; + +import { clampBrowserSplitFraction } from "../../browserPanelStore"; +import { cn } from "../../lib/utils"; + +/** + * Drag handle between the chat column and the browser panel. + * + * Reports a fraction rather than a width so the split survives window resizing: + * a stored pixel width would leave the chat column the wrong size on a + * different display. The store clamps, so a hard drag cannot collapse a pane. + */ +export function BrowserSplitHandle({ + chatFraction, + onChange, + orientation = "vertical", +}: { + chatFraction: number; + onChange: (fraction: number) => void; + /** "vertical" splits left/right; "horizontal" splits top/bottom. */ + orientation?: "vertical" | "horizontal"; +}) { + const draggingRef = useRef(false); + + const handlePointerDown = useCallback((event: React.PointerEvent) => { + draggingRef.current = true; + event.currentTarget.setPointerCapture(event.pointerId); + }, []); + + const handlePointerMove = useCallback( + (event: React.PointerEvent) => { + if (!draggingRef.current) { + return; + } + const row = event.currentTarget.parentElement; + if (row === null) { + return; + } + const bounds = row.getBoundingClientRect(); + const extent = orientation === "vertical" ? bounds.width : bounds.height; + if (extent === 0) { + return; + } + const offset = + orientation === "vertical" ? event.clientX - bounds.left : event.clientY - bounds.top; + onChange(clampBrowserSplitFraction(offset / extent)); + }, + [onChange, orientation], + ); + + const endDrag = useCallback((event: React.PointerEvent) => { + draggingRef.current = false; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + }, []); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + const step = event.shiftKey ? 0.1 : 0.02; + const decrease = orientation === "vertical" ? "ArrowLeft" : "ArrowUp"; + const increase = orientation === "vertical" ? "ArrowRight" : "ArrowDown"; + if (event.key === decrease) { + event.preventDefault(); + onChange(clampBrowserSplitFraction(chatFraction - step)); + } else if (event.key === increase) { + event.preventDefault(); + onChange(clampBrowserSplitFraction(chatFraction + step)); + } + }, + [chatFraction, onChange, orientation], + ); + + return ( +
+ ); +} diff --git a/apps/web/src/components/browser/browserViewportLayout.test.ts b/apps/web/src/components/browser/browserViewportLayout.test.ts new file mode 100644 index 000000000..d728d571f --- /dev/null +++ b/apps/web/src/components/browser/browserViewportLayout.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveBrowserViewportLayout } from "./browserViewportLayout"; + +const container = { width: 400, height: 600 }; + +describe("resolveBrowserViewportLayout", () => { + it("fills the panel when no size is set", () => { + const layout = resolveBrowserViewportLayout({ + container, + viewport: { width: null, height: null }, + }); + + expect(layout).toMatchObject({ x: 0, y: 0, width: 400, height: 600, scale: 1, fills: true }); + }); + + it("centres a device that already fits, at its own size", () => { + const layout = resolveBrowserViewportLayout({ + container, + viewport: { width: 200, height: 400 }, + }); + + // Never scaled up: a phone blown up to fill a wide panel misrepresents it. + expect(layout.scale).toBe(1); + expect(layout).toMatchObject({ width: 200, height: 400, x: 100, y: 100 }); + }); + + it("scales a device down to fit, keeping its proportions", () => { + const layout = resolveBrowserViewportLayout({ + container, + viewport: { width: 400, height: 1200 }, + }); + + expect(layout.scale).toBeCloseTo(0.5, 5); + expect(layout.width).toBeCloseTo(200, 5); + expect(layout.height).toBeCloseTo(600, 5); + expect(layout.y).toBe(0); + }); + + it("fits against the zoomed size, since zoom is what is drawn", () => { + const unzoomed = resolveBrowserViewportLayout({ + container, + viewport: { width: 400, height: 600 }, + }); + const zoomed = resolveBrowserViewportLayout({ + container, + viewport: { width: 400, height: 600 }, + zoomFactor: 2, + }); + + expect(unzoomed.scale).toBe(1); + expect(zoomed.scale).toBeCloseTo(0.5, 5); + // Same footprint on screen: zoom doubled it, fitting halved it back. + expect(zoomed.width).toBeCloseTo(unzoomed.width, 5); + }); + + it("treats a nonsense zoom as no zoom rather than collapsing the frame", () => { + const layout = resolveBrowserViewportLayout({ + container, + viewport: { width: 200, height: 300 }, + zoomFactor: 0, + }); + + expect(layout.scale).toBe(1); + expect(layout.width).toBe(200); + }); +}); diff --git a/apps/web/src/components/browser/browserViewportLayout.ts b/apps/web/src/components/browser/browserViewportLayout.ts new file mode 100644 index 000000000..26f739210 --- /dev/null +++ b/apps/web/src/components/browser/browserViewportLayout.ts @@ -0,0 +1,84 @@ +/** + * Geometry for the preview surface. + * + * The webview is placed at computed coordinates rather than centred by flex, + * and scaled by a transform on the element itself. Both matter: Electron + * positions the guest's surface from the element's own box, so a transform on + * an ancestor moves the element without moving what the guest paints, and a + * flex-centred element sits where its *unscaled* size says it should while + * drawing somewhere else. Either one leaves the page visibly offset inside its + * own frame. + * + * Keeping the arithmetic here rather than in the component means the placement + * can be checked without a browser. + */ + +export interface BrowserViewport { + /** null means the page fills the panel and reflows with it. */ + width: number | null; + height: number | null; +} + +export interface BrowserViewportLayout { + /** The surface the frame is placed within; always the full container. */ + canvasWidth: number; + canvasHeight: number; + /** Top-left of the frame's visible footprint, within the canvas. */ + x: number; + y: number; + /** What the frame occupies on screen, after fitting. */ + width: number; + height: number; + /** + * Presentation only: the guest keeps the CSS viewport it was asked for, so a + * scaled-down phone still reports a phone's width to the page. + */ + scale: number; + fills: boolean; +} + +function positiveOrOne(value: number): number { + return Number.isFinite(value) && value > 0 ? value : 1; +} + +export function resolveBrowserViewportLayout(input: { + container: { width: number; height: number }; + viewport: BrowserViewport; + zoomFactor?: number; +}): BrowserViewportLayout { + const canvasWidth = Math.max(1, Math.round(input.container.width)); + const canvasHeight = Math.max(1, Math.round(input.container.height)); + + if (input.viewport.width === null || input.viewport.height === null) { + return { + canvasWidth, + canvasHeight, + x: 0, + y: 0, + width: canvasWidth, + height: canvasHeight, + scale: 1, + fills: true, + }; + } + + const zoomFactor = positiveOrOne(input.zoomFactor ?? 1); + const renderedWidth = input.viewport.width * zoomFactor; + const renderedHeight = input.viewport.height * zoomFactor; + // Only ever scales down. A device smaller than the panel is shown at its own + // size, because blowing a phone up to fill a wide panel would misrepresent it. + const scale = Math.min(1, canvasWidth / renderedWidth, canvasHeight / renderedHeight); + const width = renderedWidth * scale; + const height = renderedHeight * scale; + + return { + canvasWidth, + canvasHeight, + x: Math.max(0, Math.round((canvasWidth - width) / 2)), + y: Math.max(0, Math.round((canvasHeight - height) / 2)), + width, + height, + scale, + fills: false, + }; +} diff --git a/apps/web/src/components/browser/previewAutomationHost.test.ts b/apps/web/src/components/browser/previewAutomationHost.test.ts new file mode 100644 index 000000000..8d592bfc8 --- /dev/null +++ b/apps/web/src/components/browser/previewAutomationHost.test.ts @@ -0,0 +1,281 @@ +import type { DesktopBridge, PreviewAutomationRequest } from "@threadlines/contracts"; +import { describe, expect, it } from "vitest"; + +import { createPreviewAutomationHandler, type AgentActivity } from "./previewAutomationHost"; + +const request = ( + operation: PreviewAutomationRequest["operation"], + input: PreviewAutomationRequest["input"] = {}, +): PreviewAutomationRequest => ({ requestId: "r1", operation, input }); + +const handlerFor = ( + bridge: Partial, + webContentsId: number | null = 42, + navigate: (url: string) => Promise = () => Promise.resolve(), +) => + createPreviewAutomationHandler(bridge as DesktopBridge, () => ({ + webContentsId, + navigate, + viewport: () => ({ width: 800, height: 600 }), + onAgentPoint: () => {}, + tabs: () => [], + selectTab: () => {}, + onAgentActivity: () => {}, + })); + +describe("createPreviewAutomationHandler", () => { + it("sends the operation's input to the bridge along with the tab it acts on", async () => { + const seen: unknown[] = []; + const handle = handlerFor({ + previewClick: (input) => { + seen.push(input); + return Promise.resolve({ x: 10, y: 20 }); + }, + previewStatus: () => + Promise.resolve({ url: "http://x/", title: "X", loading: false }) as never, + }); + + const response = await handle(request("click", { target: { ref: 7 } })); + + // The tab is the host's business, not the agent's: it never names one. + expect(seen).toEqual([{ webContentsId: 42, target: { ref: 7 } }]); + // An action answers with where the page ended up. Returning nothing failed + // MCP validation, which showed a successful click to the agent as an error + // and invited a retry -- and a retried click clicks twice. + expect(response.error).toBeUndefined(); + expect(response.result).toMatchObject({ + url: "http://x/", + title: "X", + loading: false, + width: 800, + height: 600, + }); + }); + + it("reports where a click landed so the panel can show it happening", async () => { + // Without this an agent changes the page with nothing to say it was the + // agent, and "did the click land?" has no answer but another snapshot. + const points: unknown[] = []; + const handle = createPreviewAutomationHandler( + { + previewClick: () => Promise.resolve({ x: 120, y: 48 }), + previewStatus: () => Promise.resolve({ url: "http://x/", title: "X", loading: false }), + } as unknown as DesktopBridge, + () => ({ + webContentsId: 42, + navigate: () => Promise.resolve(), + viewport: () => ({ width: 800, height: 600 }), + onAgentPoint: (point) => points.push(point), + tabs: () => [], + selectTab: () => {}, + onAgentActivity: () => {}, + }), + ); + + await handle(request("click", { target: { ref: 1 } })); + + expect(points).toEqual([{ x: 120, y: 48 }]); + }); + + it("reports both ends of a drag, so the pointer can replay the gesture", async () => { + // Only the destination would show where the drag finished and lose the + // drag, which is the part a watching user cannot otherwise see. + const points: unknown[] = []; + const activity: Array = []; + const handle = createPreviewAutomationHandler( + { + previewDrag: () => Promise.resolve({ from: { x: 10, y: 20 }, to: { x: 90, y: 20 } }), + previewStatus: () => Promise.resolve({ url: "http://x/", title: "X", loading: false }), + } as unknown as DesktopBridge, + () => ({ + webContentsId: 42, + navigate: () => Promise.resolve(), + viewport: () => ({ width: 800, height: 600 }), + onAgentPoint: (point) => points.push(point), + tabs: () => [], + selectTab: () => {}, + onAgentActivity: (entry) => activity.push(entry.detail), + }), + ); + + await handle(request("drag", { from: { text: "Sign in" }, to: { text: "fixture" } })); + + expect(points).toEqual([{ x: 90, y: 20, from: { x: 10, y: 20 } }]); + // "dragged" on its own says nothing about what moved. + expect(activity.at(-1)).toBe('"Sign in" \u2192 "fixture"'); + }); + + it("names a coordinate target by its coordinates", async () => { + // A drag between two points has no element to name, and an activity line + // reading "dragged" with nothing after it is the same as no line at all. + const seen: AgentActivity[] = []; + const handle = createPreviewAutomationHandler( + { + previewDrag: () => Promise.resolve({ x: 300, y: 120 }), + previewStatus: () => Promise.resolve({ url: "http://x/", title: "X", loading: false }), + } as unknown as DesktopBridge, + () => ({ + webContentsId: 42, + navigate: () => Promise.resolve(), + viewport: () => ({ width: 800, height: 600 }), + onAgentPoint: () => {}, + onAgentActivity: (activity) => seen.push(activity), + tabs: () => [], + selectTab: () => {}, + }), + ); + + await handle( + request("drag", { from: { point: { x: 120.4, y: 60 } }, to: { point: { x: 300, y: 60 } } }), + ); + + expect(seen[0]?.detail).toBe("120, 60 \u2192 300, 60"); + }); + + it("reports every tab, marking the user's and its own", async () => { + // The agent pins itself to a tab. Without a way to see the others it + // reports on the one it is pinned to and states that nothing else is open, + // which is what it did when a second tab was the one being asked about. + const handle = createPreviewAutomationHandler( + { + previewStatus: () => Promise.resolve({ url: "http://x/", title: "X", loading: false }), + } as unknown as DesktopBridge, + () => ({ + webContentsId: 42, + navigate: () => Promise.resolve(), + viewport: () => ({ width: 800, height: 600 }), + onAgentPoint: () => {}, + onAgentActivity: () => {}, + selectTab: () => {}, + tabs: () => [ + { title: "Fixture", url: "http://localhost:18821/", active: false, agent: true }, + { title: "Manuals", url: "https://example.com/", active: true, agent: false }, + ], + }), + ); + + const response = await handle(request("tabs", {})); + + expect(response.result).toEqual({ + tabs: [ + { title: "Fixture", url: "http://localhost:18821/", active: false, agent: true }, + { title: "Manuals", url: "https://example.com/", active: true, agent: false }, + ], + }); + }); + + it("wraps an evaluate result so an array is not a validation failure", async () => { + // The expression has already run by the time the result is checked, so a + // bare array used to \"fail\" after mutating the page. + const handle = handlerFor({ previewEvaluate: () => Promise.resolve([1, 2, 3]) as never }); + + const response = await handle(request("evaluate", { expression: "[1,2,3]" })); + + expect(response.result).toEqual({ result: [1, 2, 3] }); + }); + + it("says what it is doing in words, before and after", async () => { + // The line under the toolbar is the only thing that distinguishes an agent + // working from a page misbehaving on its own. + const seen: AgentActivity[] = []; + const handle = createPreviewAutomationHandler( + { + previewClick: () => Promise.resolve({ x: 1, y: 2 }), + previewStatus: () => Promise.resolve({ url: "http://x/", title: "X", loading: false }), + } as unknown as DesktopBridge, + () => ({ + webContentsId: 42, + navigate: () => Promise.resolve(), + viewport: () => ({ width: 800, height: 600 }), + onAgentPoint: () => {}, + tabs: () => [], + selectTab: () => {}, + onAgentActivity: (activity) => seen.push(activity), + }), + ); + + await handle(request("click", { target: { text: "Sign in" } })); + + // A verb and the thing's own words, not a tool name and a selector. + expect(seen.map((a) => `${a.phase}: ${a.verb} ${a.detail}`)).toEqual([ + 'running: clicked "Sign in"', + 'done: clicked "Sign in"', + ]); + }); + + it("stops saying it is working when the action fails", async () => { + // A line left mid-sentence would claim an action never finished. + const seen: AgentActivity[] = []; + const handle = createPreviewAutomationHandler( + { + previewClick: () => Promise.reject(new Error("no element matched")), + } as unknown as DesktopBridge, + () => ({ + webContentsId: 42, + navigate: () => Promise.resolve(), + viewport: () => ({ width: 800, height: 600 }), + onAgentPoint: () => {}, + tabs: () => [], + selectTab: () => {}, + onAgentActivity: (activity) => seen.push(activity), + }), + ); + + await handle(request("click", { target: { ref: "e3" } })); + + expect(seen.at(-1)?.phase).toBe("done"); + }); + + it("answers with the failure instead of rejecting", async () => { + // A rejection would leave the broker waiting out its whole timeout for + // something already known, and the agent needs the reason to pick its next + // move -- a bad selector means re-snapshot, not give up. + const handle = handlerFor({ + previewClick: () => Promise.reject(new Error('no element matches selector ".gone"')), + }); + + const response = await handle(request("click", { target: { selector: ".gone" } })); + + expect(response.error).toBe('no element matches selector ".gone"'); + expect(response.requestId).toBe("r1"); + }); + + it("strips Electron's wrapper off a main-process failure", async () => { + // Electron prefixes anything thrown across IPC, which buries the sentence + // the agent actually needs under something that reads like a broken tool. + const handle = handlerFor({ + previewType: () => + Promise.reject( + new Error( + "Error invoking remote method 'desktop:preview-type': Error: target is not editable", + ), + ), + }); + + const response = await handle(request("type", { target: { ref: 1 }, text: "hi" })); + + expect(response.error).toBe("target is not editable"); + }); + + it("says so when the panel is open but has no page", async () => { + const handle = handlerFor( + { previewSnapshot: () => Promise.reject(new Error("unreachable")) }, + null, + ); + + const response = await handle(request("snapshot")); + + expect(response.error).toContain("no page loaded"); + }); + + it("reports an operation this build cannot perform rather than hanging", async () => { + // The bridge and the advertised operation list are built from the same + // contract, so a gap here means they drifted -- which should be a loud + // answer, not a twenty second silence. + const handle = handlerFor({}); + + const response = await handle(request("press", { key: "Enter" })); + + expect(response.error).toContain("cannot perform press"); + }); +}); diff --git a/apps/web/src/components/browser/previewAutomationHost.ts b/apps/web/src/components/browser/previewAutomationHost.ts new file mode 100644 index 000000000..c7960751d --- /dev/null +++ b/apps/web/src/components/browser/previewAutomationHost.ts @@ -0,0 +1,398 @@ +import type { + DesktopBridge, + PreviewAutomationOperation, + PreviewAutomationRequest, + PreviewAutomationResponse, + ScopedThreadRef, +} from "@threadlines/contracts"; +import { useEffect, useRef } from "react"; + +import { ensureEnvironmentApi } from "../../environmentApi"; + +/** + * The end of the wire that can actually touch the page. + * + * The broker in the server holds the agent's call; this turns it into a call on + * the desktop bridge and sends the answer back. It lives in the renderer + * because that is where the `` is -- the server has no route to a page + * at all, and the main process has no idea which tab the user is looking at. + * + * Kept apart from the React that drives it so the mapping can be tested without + * an Electron window: every interesting thing here is which bridge call an + * operation becomes and what happens when it throws. + */ + +/** + * What this build can service. + * + * Sent to the broker on connect and checked there before anything is + * dispatched, so an older client is told it cannot do something rather than + * being handed a command it would silently drop. + */ +export const PREVIEW_AUTOMATION_HOST_OPERATIONS = [ + "status", + "tabs", + "selectTab", + "snapshot", + "navigate", + "click", + "move", + "drag", + "type", + "press", + "scroll", + "evaluate", + "waitFor", + "screenshot", + "resize", + "setAppearance", +] as const satisfies ReadonlyArray; + +export interface PreviewAutomationHostTarget { + /** The tab the agent acts on: the one the user is looking at. Null when the + * panel is open but has no live tab yet. */ + readonly webContentsId: number | null; + /** + * Navigating is the one operation the main process cannot do for us: the + * address belongs to the `` element, which only the renderer holds. + * Everything else is a CDP command and goes over the bridge. + */ + readonly navigate: (url: string) => Promise; + /** How big the page is right now. The panel is the only one that knows: the + * main process cannot see the element and this module should not reach for + * it. A question about layout is a question about this. */ + readonly viewport: () => { width: number; height: number }; + /** Where the agent just acted, so the panel can show it happening. */ + readonly onAgentPoint: (point: { + x: number; + y: number; + /** Where a drag began, when this point is the end of one. */ + from?: { x: number; y: number }; + }) => void; + /** Every page the panel has open. Renderer state, like the viewport: the + * main process cannot see the tab strip. */ + readonly tabs: () => ReadonlyArray<{ + title: string; + url: string; + active: boolean; + agent: boolean; + }>; + /** Bring a tab to the front, which is also how the agent moves to it. */ + readonly selectTab: (index: number) => void; + /** What the agent is doing, in words, for the line under the toolbar. */ + readonly onAgentActivity: (activity: AgentActivity) => void; +} + +/** + * One line of what the agent just did. + * + * Emitted here because this is the only place that knows both the operation and + * what it was aimed at. Without it the only evidence an agent is working is the + * page changing by itself, which is indistinguishable from the page being + * broken. + */ +export interface AgentActivity { + /** Present tense while it runs, past tense once it has answered. */ + readonly phase: "running" | "done"; + /** "clicked", "typed into", "went to" -- a verb, not a tool name. */ + readonly verb: string; + /** What it acted on, when that is nameable. */ + readonly detail: string | null; + /** Distinguishes two identical actions in a row. */ + readonly sequence: number; +} + +/** The agent's own vocabulary, turned into a person's. */ +const ACTIVITY_VERBS: Record = { + status: "checked", + tabs: "looked at the tabs", + selectTab: "switched to", + snapshot: "read the page", + navigate: "went to", + click: "clicked", + move: "moved to", + drag: "dragged", + type: "typed into", + press: "pressed", + scroll: "scrolled", + evaluate: "ran script on", + waitFor: "waited on", + screenshot: "looked at the page", + resize: "resized", + setAppearance: "restyled", +}; + +/** What the action was aimed at, said the way the user would say it. */ +function describeSubject(operation: PreviewAutomationOperation, input: unknown): string | null { + const value = (input ?? {}) as Record; + if (operation === "navigate") { + return typeof value.url === "string" ? value.url : null; + } + if (operation === "press") { + return typeof value.key === "string" ? value.key : null; + } + if (operation === "selectTab") { + return typeof value.index === "number" ? `tab ${value.index + 1}` : null; + } + if (operation === "drag") { + // Both ends, because "dragged" on its own says nothing about what moved. + const from = nameTarget(value.from); + const to = nameTarget(value.to); + if (from === null || to === null) { + return from ?? to; + } + return `${from} \u2192 ${to}`; + } + return nameTarget(value.target); +} + +function nameTarget(candidate: unknown): string | null { + const target = candidate as Record | undefined; + if (target === undefined) { + return null; + } + // A locator or a selector is machinery; the text on a thing is its name. + if (typeof target.text === "string") return `"${target.text}"`; + if (typeof target.ref === "string") return target.ref; + if (typeof target.selector === "string") return target.selector; + if (typeof target.locator === "string") return target.locator; + // A place rather than a thing, so there is no name to give: the coordinates + // are all there is, and they are what the pointer is about to do. + const point = target.point as { x?: unknown; y?: unknown } | undefined; + if (typeof point?.x === "number" && typeof point.y === "number") { + return `${Math.round(point.x)}, ${Math.round(point.y)}`; + } + return null; +} + +/** + * Turns one request into one response. + * + * Never rejects. A failure here is an answer -- the agent needs to be told the + * selector matched nothing so it can re-snapshot and try again, and a rejected + * promise would instead leave the broker waiting out its timeout for something + * we already know. + */ +export function createPreviewAutomationHandler( + bridge: DesktopBridge, + resolveTarget: () => PreviewAutomationHostTarget, +): (request: PreviewAutomationRequest) => Promise { + let sequence = 0; + return async (request: PreviewAutomationRequest): Promise => { + const target = resolveTarget(); + sequence += 1; + const verb = ACTIVITY_VERBS[request.operation]; + const detail = describeSubject(request.operation, request.input); + target.onAgentActivity({ phase: "running", verb, detail, sequence }); + const settle = (response: T): T => { + target.onAgentActivity({ phase: "done", verb, detail, sequence }); + return response; + }; + if (target.webContentsId === null) { + return { + requestId: request.requestId, + error: "The browser panel is open but has no page loaded yet.", + }; + } + try { + const result = await dispatch(bridge, target, target.webContentsId, request); + // The key is omitted rather than set to undefined: an operation with + // nothing to report should send nothing, not a hole. + return settle( + result === undefined + ? { requestId: request.requestId } + : { + requestId: request.requestId, + result: result as Exclude, + }, + ); + } catch (cause) { + // Settled on the way out too: an action that failed has still stopped, + // and a line left saying "clicking" would claim it never did. + return settle({ requestId: request.requestId, error: describe(cause) }); + } + }; +} + +async function dispatch( + bridge: DesktopBridge, + target: PreviewAutomationHostTarget, + webContentsId: number, + request: PreviewAutomationRequest, +): Promise { + const input = (request.input ?? {}) as Record; + const call = ( + method: ((...args: never[]) => Promise) | undefined, + args: unknown, + ): Promise => { + if (method === undefined) { + // The bridge is built from the same contract as the operation list above, + // so this means the two drifted rather than that the user did anything. + throw new Error(`This build cannot perform ${request.operation}.`); + } + return (method as (arg: unknown) => Promise)({ webContentsId, ...(args as object) }); + }; + + switch (request.operation) { + case "status": + return toStatus(await call(bridge.previewStatus, {}), target.viewport()); + case "snapshot": { + const snapshot = await call(bridge.previewSnapshot, {}); + return { + ...toStatus(snapshot, target.viewport()), + page: snapshot.page, + console: snapshot.console.map((entry) => ({ level: entry.level, text: entry.text })), + networkFailures: snapshot.networkFailures.map((failure) => ({ + url: failure.url, + detail: failure.errorText ?? `HTTP ${failure.status ?? "error"}`, + })), + }; + } + case "navigate": + await target.navigate(String((input as { url?: unknown }).url ?? "")); + return toStatus(await call(bridge.previewStatus, {}), target.viewport()); + // Every action answers with where the page ended up. An action that + // returned nothing failed MCP validation and was shown to the agent as an + // error, which invited a retry -- and a retried click clicks twice. + case "click": { + const point = await call(bridge.previewClick, input); + // Shown before the page is asked what changed, so the mark lands while + // the click is still the most recent thing that happened. + target.onAgentPoint(point); + return toStatus(await call(bridge.previewStatus, {}), target.viewport()); + } + case "move": { + const point = await call(bridge.previewMove, input); + target.onAgentPoint(point); + return toStatus(await call(bridge.previewStatus, {}), target.viewport()); + } + case "drag": { + const gesture = await call(bridge.previewDrag, input); + // The whole drag has already happened by the time we hear about it, so + // the pointer replays it: pressed at one end, travelling, released at the + // other. Sending only the result would show the destination and lose the + // gesture, which is the part worth seeing. + target.onAgentPoint({ ...gesture.to, from: gesture.from }); + return toStatus(await call(bridge.previewStatus, {}), target.viewport()); + } + case "tabs": + return { tabs: target.tabs() }; + case "selectTab": { + target.selectTab((input as unknown as { index: number }).index); + return toStatus(await call(bridge.previewStatus, {}), target.viewport()); + } + case "type": { + const point = await call(bridge.previewType, input); + target.onAgentPoint(point); + return toStatus(await call(bridge.previewStatus, {}), target.viewport()); + } + case "press": + await call(bridge.previewPress, input); + return toStatus(await call(bridge.previewStatus, {}), target.viewport()); + case "scroll": + await call(bridge.previewScroll, input); + return toStatus(await call(bridge.previewStatus, {}), target.viewport()); + case "waitFor": + await call(bridge.previewWaitFor, input); + return toStatus(await call(bridge.previewStatus, {}), target.viewport()); + case "evaluate": + // Wrapped, because an expression that returns an array or a number is a + // perfectly good answer and used to fail validation *after* running. + return { result: await call(bridge.previewEvaluate, input) }; + case "screenshot": { + const shot = await call(bridge.previewScreenshot, {}); + // Base64 without the data-url header: the tool hands this to the model as + // an image block, which wants the bytes rather than a URL. + return { + data: shot.dataUrl.slice(shot.dataUrl.indexOf(",") + 1), + width: shot.width, + height: shot.height, + }; + } + case "resize": + await call(bridge.previewSetViewport, input); + return toStatus(await call(bridge.previewStatus, {}), target.viewport()); + case "setAppearance": + return await call(bridge.previewSetColorScheme, input); + } +} + +/** + * The desktop's status, in the shape the agent was promised. + * + * These are two different vocabularies and the host is where they meet: the + * bridge speaks of webContents and attachment, the contract speaks of a page + * and its size. Passing one through as the other is what made every snapshot + * fail on a missing key. + */ +function toStatus( + status: { url: string; title: string; loading: boolean }, + size: { width: number; height: number }, +): { url: string; title: string; loading: boolean; width: number; height: number } { + // The size comes from the panel rather than from here: it is the one part of + // a page's state that neither the main process nor this module can see, and + // a question about layout is a question about it. + return { url: status.url, title: status.title, loading: status.loading, ...size }; +} + +/** + * Whatever came back, as a sentence the agent can act on. + * + * Electron wraps a main-process throw in its own prefix, which turns a useful + * "no element matches ..." into something that reads like the tool is broken. + * The original message is the part worth keeping. + */ +function describe(cause: unknown): string { + const message = + cause instanceof Error ? cause.message : typeof cause === "string" ? cause : String(cause); + const marker = "Error invoking remote method"; + if (!message.startsWith(marker)) { + return message; + } + const lastColon = message.lastIndexOf(": "); + return lastColon < 0 ? message : message.slice(lastColon + 2); +} + +/** + * Keeps this client registered as the browser for a thread while the panel is + * showing it. + * + * Registration is the subscription: while it is open the agent's calls land + * here, and when it closes -- panel dismissed, thread switched, socket dropped + * -- the broker forgets the host and tells anything still waiting that the + * browser went away. That is the whole lifecycle, and it is why there is no + * unregister call to forget to make. + */ +export function usePreviewAutomationHost(input: { + readonly threadRef: ScopedThreadRef; + readonly enabled: boolean; + readonly resolveTarget: () => PreviewAutomationHostTarget; +}): void { + const { threadRef, enabled } = input; + // Read through a ref so a re-render that changes which tab is active does not + // tear the subscription down and put it back up. + const target = useRef(input.resolveTarget); + target.current = input.resolveTarget; + + useEffect(() => { + const bridge = window.desktopBridge; + if (!enabled || bridge === undefined) { + return; + } + const handle = createPreviewAutomationHandler(bridge, () => target.current()); + + const api = ensureEnvironmentApi(threadRef.environmentId); + return api.previewAutomation.connect( + { + threadId: threadRef.threadId, + // Identifies this connection to the broker, so a reconnect displaces + // its own earlier registration rather than racing it. + hostId: `${threadRef.threadId}:${Math.random().toString(36).slice(2)}`, + operations: PREVIEW_AUTOMATION_HOST_OPERATIONS, + }, + (request) => { + void handle(request).then((response) => api.previewAutomation.respond(response)); + }, + ); + }, [enabled, threadRef.environmentId, threadRef.threadId]); +} diff --git a/apps/web/src/components/browser/previewUrl.test.ts b/apps/web/src/components/browser/previewUrl.test.ts new file mode 100644 index 000000000..35a7fcacd --- /dev/null +++ b/apps/web/src/components/browser/previewUrl.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { normalizePreviewUrl } from "./previewUrl"; + +describe("normalizePreviewUrl", () => { + it("assumes http for a bare dev-server address", () => { + // https would fail on every dev server without a certificate. + expect(normalizePreviewUrl("localhost:5173")).toBe("http://localhost:5173/"); + expect(normalizePreviewUrl("127.0.0.1:3000/pricing")).toBe("http://127.0.0.1:3000/pricing"); + }); + + it("keeps an explicit scheme", () => { + expect(normalizePreviewUrl("https://example.com/a")).toBe("https://example.com/a"); + }); + + it("rejects entries that would navigate somewhere surprising", () => { + expect(normalizePreviewUrl("")).toBeNull(); + expect(normalizePreviewUrl(" ")).toBeNull(); + expect(normalizePreviewUrl("javascript:alert(1)")).toBeNull(); + expect(normalizePreviewUrl("file:///etc/passwd")).toBeNull(); + }); +}); diff --git a/apps/web/src/components/browser/previewUrl.ts b/apps/web/src/components/browser/previewUrl.ts new file mode 100644 index 000000000..ef0b09d4d --- /dev/null +++ b/apps/web/src/components/browser/previewUrl.ts @@ -0,0 +1,33 @@ +/** + * Turns whatever was typed in the address bar into something loadable. + * + * Deliberately permissive about scheme and strict about everything else: the + * common case is a dev server typed as `localhost:5173`, which is not a valid + * URL until it has a scheme, while a genuinely malformed entry should fail + * quietly rather than navigate somewhere surprising. + */ +export function normalizePreviewUrl(input: string): string | null { + const trimmed = input.trim(); + if (trimmed === "") { + return null; + } + + const candidate = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) + ? trimmed + : // A bare host:port or path is http, not https: dev servers rarely have a + // certificate, and defaulting to https would fail on every one of them. + `http://${trimmed.replace(/^\/+/, "")}`; + + try { + const url = new URL(candidate); + if (url.protocol !== "http:" && url.protocol !== "https:") { + return null; + } + if (url.hostname === "") { + return null; + } + return url.toString(); + } catch { + return null; + } +} diff --git a/apps/web/src/components/browser/urlCompletion.test.ts b/apps/web/src/components/browser/urlCompletion.test.ts new file mode 100644 index 000000000..6600ede49 --- /dev/null +++ b/apps/web/src/components/browser/urlCompletion.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; + +import { completeUrl, rememberVisit, type VisitedUrl } from "./urlCompletion"; + +const visited = (url: string, visits: number, lastVisitedAt: number): VisitedUrl => ({ + url, + visits, + lastVisitedAt, +}); + +describe("completeUrl", () => { + const history = [ + visited("https://facpmanuals.com/", 12, 500), + visited("https://fastmail.com/", 2, 900), + visited("http://localhost:18821/", 3, 100), + ]; + + it("finishes a site from the few letters people actually type", () => { + expect(completeUrl("facp", history)).toBe("https://facpmanuals.com/"); + }); + + it("ignores the scheme and www, which nobody types", () => { + expect(completeUrl("localhost:1", history)).toBe("http://localhost:18821/"); + expect(completeUrl("www.facp", [visited("https://www.facpmanuals.com/", 4, 10)])).toBe( + "https://www.facpmanuals.com/", + ); + }); + + it("prefers the site you go to often over the one you saw recently", () => { + // fastmail was visited more recently; facpmanuals is where this person + // actually lives, and "fa" should not become a coin toss. + expect(completeUrl("fa", history)).toBe("https://facpmanuals.com/"); + }); + + it("stays quiet until there is enough to go on", () => { + // A guess that changes on every keystroke reads as the field being + // possessed rather than helpful. + expect(completeUrl("f", history)).toBeNull(); + }); + + it("offers nothing when nothing matches", () => { + expect(completeUrl("example", history)).toBeNull(); + }); +}); + +describe("rememberVisit", () => { + it("counts repeat visits rather than listing them twice", () => { + const once = rememberVisit([], "https://a.com/", 1); + const twice = rememberVisit(once, "https://a.com/", 2); + + expect(twice).toHaveLength(1); + expect(twice[0]).toEqual({ url: "https://a.com/", visits: 2, lastVisitedAt: 2 }); + }); + + it("keeps a well-worn site over a one-off when the list is full", () => { + // Dropping by age would lose the site you use daily after one busy morning + // somewhere else. + const daily = visited("https://daily.com/", 40, 1); + const list = Array.from({ length: 199 }, (_, index) => + visited(`https://one-off-${index}.com/`, 1, 100 + index), + ); + + const full = rememberVisit([daily, ...list], "https://new.com/", 999); + + expect(full).toHaveLength(200); + expect(full.some((entry) => entry.url === "https://daily.com/")).toBe(true); + }); + + it("does not record a blank tab as a place you have been", () => { + expect(rememberVisit([], "", 1)).toEqual([]); + expect(rememberVisit([], " ", 1)).toEqual([]); + }); +}); diff --git a/apps/web/src/components/browser/urlCompletion.ts b/apps/web/src/components/browser/urlCompletion.ts new file mode 100644 index 000000000..2285d136c --- /dev/null +++ b/apps/web/src/components/browser/urlCompletion.ts @@ -0,0 +1,97 @@ +/** + * Finishing a URL you have typed before. + * + * Not history in the browser sense -- no list to browse, nothing to manage. + * Just the part of history people actually use: type three letters of a site + * you visit constantly and have the rest already there. + * + * Ranked by visits first and recency second, which is the ordering people + * expect without being able to say so: the site you go to daily should win + * over the one you opened once an hour ago, but among sites you visit equally + * the recent one is the one you mean. + */ + +/** One page this browser has been to. */ +export interface VisitedUrl { + readonly url: string; + readonly visits: number; + readonly lastVisitedAt: number; +} + +/** + * Enough to cover the handful of sites anyone actually revisits, small enough + * that ranking it on every keystroke costs nothing worth measuring. + */ +export const MAX_VISITED_URLS = 200; + +/** + * What the user typed, reduced to the part they are aiming at. + * + * Nobody types the scheme, and `www.` is noise on the way to the name. Matching + * on what remains is what lets three letters find a site. + */ +export function completionKey(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/^[a-z][a-z0-9+.-]*:\/\//, "") + .replace(/^www\./, ""); +} + +function rank(a: VisitedUrl, b: VisitedUrl): number { + return b.visits - a.visits || b.lastVisitedAt - a.lastVisitedAt; +} + +/** + * Records a visit, keeping the list capped and the counts honest. + * + * Returns a new list; the caller owns where it lives. + */ +export function rememberVisit( + visited: ReadonlyArray, + url: string, + now: number, +): ReadonlyArray { + const trimmed = url.trim(); + // A blank tab and an unparseable address are not places you have been. + if (trimmed === "" || completionKey(trimmed) === "") { + return visited; + } + const existing = visited.find((entry) => entry.url === trimmed); + const rest = visited.filter((entry) => entry.url !== trimmed); + const updated: VisitedUrl = { + url: trimmed, + visits: (existing?.visits ?? 0) + 1, + lastVisitedAt: now, + }; + // Dropping the lowest-ranked rather than the oldest: a site you have been to + // fifty times should not fall off because you spent a morning elsewhere. + return [updated, ...rest].sort(rank).slice(0, MAX_VISITED_URLS); +} + +/** + * The best completion for what has been typed so far, or null. + * + * Returns the whole URL, not the remainder, because the caller replaces the + * field's value and selects the part the user did not type -- so carrying on + * typing overwrites the guess instead of fighting it. + */ +export function completeUrl(typed: string, visited: ReadonlyArray): string | null { + const key = completionKey(typed); + // One character matches half the internet and the guess would change under + // every keystroke, which reads as the field being possessed. + if (key.length < 2) { + return null; + } + const match = [...visited] + .sort(rank) + .find((entry) => completionKey(entry.url).startsWith(key) && entry.url !== typed); + if (match === undefined) { + return null; + } + // Only offer it when the typed text is a genuine prefix of what we would put + // in the field. Otherwise accepting the completion would silently rewrite + // what is already there -- typing `facpmanuals.com/x` should not become the + // homepage just because that is the ranked hit. + return match.url.toLowerCase().includes(key) ? match.url : null; +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 8ef74b522..f68207d08 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -36,6 +36,12 @@ import { useQuery } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { serializeComposerMentionPath } from "~/composerMentionPath"; import type { FileSelectionContextDraft } from "~/lib/fileSelectionContext"; +import type { DrawingContext, DrawingContextDraft } from "~/lib/drawingContext"; +import { + pickedElementContextDedupKey, + type PickedElementContext, + type PickedElementContextDraft, +} from "~/lib/pickedElementContext"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { providerSkillsQueryOptions } from "~/lib/providerSkillsReactQuery"; import { ComposerPendingFileSelectionContexts } from "./ComposerPendingFileSelectionContexts"; @@ -85,6 +91,8 @@ import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; import { ComposerGoalBar, type ComposerGoalSetInput } from "./ComposerGoalBar"; import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; +import { ComposerPendingDrawingContexts } from "./ComposerPendingDrawingContexts"; +import { ComposerPendingPickedElementContexts } from "./ComposerPendingPickedElementContexts"; import { ComposerPendingTranscriptHighlightContexts } from "./ComposerPendingTranscriptHighlightContexts"; import { ComposerPendingTerminalContexts } from "./ComposerPendingTerminalContexts"; import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; @@ -410,6 +418,13 @@ export interface ChatComposerHandle { addTerminalContext: (selection: TerminalContextSelection) => void; /** Add a note attached to selected transcript text. */ addTranscriptHighlightContext: (selection: TranscriptHighlightContextSelection) => void; + /** Attach an element picked in the browser preview. */ + addPickedElementContext: (context: PickedElementContext) => void; + /** Attach a screenshot captured from the browser preview. */ + addScreenshotAttachment: (input: { dataUrl: string; name: string }) => void; + /** Attach a drawing made on the page: one chip carrying the picture, the + * note and whatever the closed strokes went round. */ + addDrawingContext: (context: DrawingContext) => void; /** Get the current prompt/effort/model state for use in send. */ getSendContext: () => { prompt: string; @@ -417,6 +432,8 @@ export interface ChatComposerHandle { terminalContexts: TerminalContextDraft[]; transcriptHighlightContexts: TranscriptHighlightContextDraft[]; fileSelectionContexts: FileSelectionContextDraft[]; + pickedElementContexts: PickedElementContextDraft[]; + drawingContexts: DrawingContextDraft[]; selectedPromptEffort: string | null; selectedModelOptionsForDispatch: unknown; selectedModelSelection: ModelSelection; @@ -505,6 +522,9 @@ export interface ChatComposerProps { composerTerminalContextsRef: React.RefObject; composerTranscriptHighlightContextsRef: React.RefObject; composerFileSelectionContextsRef: React.RefObject; + composerPickedElementContextsRef: React.RefObject; + /** Shows a picked element again in the preview; absent outside the desktop app. */ + onRevealPickedElement?: ((context: PickedElementContextDraft) => void) | undefined; composerRef: React.RefObject; // Scroll @@ -605,6 +625,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerTerminalContextsRef, composerTranscriptHighlightContextsRef, composerFileSelectionContextsRef, + composerPickedElementContextsRef, + onRevealPickedElement, shouldAutoScrollRef, scheduleStickToBottom, onSend, @@ -643,6 +665,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerTerminalContexts = composerDraft.terminalContexts; const composerTranscriptHighlightContexts = composerDraft.transcriptHighlightContexts; const composerFileSelectionContexts = composerDraft.fileSelectionContexts; + const composerPickedElementContexts = composerDraft.pickedElementContexts; + const composerDrawingContexts = composerDraft.drawingContexts; const nonPersistedComposerImageIds = composerDraft.nonPersistedAttachmentIds; const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); @@ -664,6 +688,73 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const addComposerDraftTranscriptHighlightContext = useComposerDraftStore( (store) => store.addTranscriptHighlightContext, ); + const setComposerDraftPickedElementContexts = useComposerDraftStore( + (store) => store.setPickedElementContexts, + ); + /** + * Writes the list to the store and through to the ref that reads it back. + * + * The ref is refreshed by an effect, so it still holds the pre-call value + * until the next render. Two of these in one tick would each build on that + * same stale list and the second would overwrite the first -- which is + * exactly what a region pick does, arriving as several elements at once. + */ + const commitPickedElementContexts = useCallback( + (next: PickedElementContextDraft[]) => { + composerPickedElementContextsRef.current = next; + setComposerDraftPickedElementContexts(composerDraftTarget, next); + }, + [composerDraftTarget, composerPickedElementContextsRef, setComposerDraftPickedElementContexts], + ); + const setComposerDraftDrawingContexts = useComposerDraftStore( + (store) => store.setDrawingContexts, + ); + const drawingContextsRef = useRef(composerDrawingContexts); + drawingContextsRef.current = composerDrawingContexts; + const commitDrawingContexts = useCallback( + (next: DrawingContextDraft[]) => { + drawingContextsRef.current = next; + setComposerDraftDrawingContexts(composerDraftTarget, next); + }, + [composerDraftTarget, setComposerDraftDrawingContexts], + ); + const updateDrawingNote = useCallback( + (contextId: string, note: string) => { + commitDrawingContexts( + drawingContextsRef.current.map((context) => + context.id === contextId ? { ...context, note: note === "" ? null : note } : context, + ), + ); + }, + [commitDrawingContexts], + ); + const removeDrawingContext = useCallback( + (contextId: string) => { + commitDrawingContexts( + drawingContextsRef.current.filter((context) => context.id !== contextId), + ); + }, + [commitDrawingContexts], + ); + + const updatePickedElementNote = useCallback( + (contextId: string, note: string) => { + commitPickedElementContexts( + composerPickedElementContextsRef.current.map((context) => + context.id === contextId ? { ...context, note: note === "" ? null : note } : context, + ), + ); + }, + [commitPickedElementContexts, composerPickedElementContextsRef], + ); + const removePickedElementContext = useCallback( + (contextId: string) => { + commitPickedElementContexts( + composerPickedElementContextsRef.current.filter((context) => context.id !== contextId), + ); + }, + [commitPickedElementContexts, composerPickedElementContextsRef], + ); const removeComposerDraftTranscriptHighlightContext = useComposerDraftStore( (store) => store.removeTranscriptHighlightContext, ); @@ -1442,6 +1533,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerFileSelectionContextsRef.current = composerFileSelectionContexts; }, [composerFileSelectionContexts, composerFileSelectionContextsRef]); + useEffect(() => { + composerPickedElementContextsRef.current = composerPickedElementContexts; + }, [composerPickedElementContexts, composerPickedElementContextsRef]); + // ------------------------------------------------------------------ // Composer menu highlight sync // ------------------------------------------------------------------ @@ -2354,12 +2449,67 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerEditorRef.current?.focusAtEnd(); }); }, + addPickedElementContext: (context: PickedElementContext) => { + if (!activeThread) return; + const existing = composerPickedElementContextsRef.current; + // Picking the same element twice is one context, not two: the second + // pick is usually a miss-click or a re-check, and duplicate chips would + // send the same block twice. + const key = pickedElementContextDedupKey(context); + if (existing.some((entry) => pickedElementContextDedupKey(entry) === key)) { + return; + } + commitPickedElementContexts([ + ...existing, + { + ...context, + id: randomUUID(), + threadId: activeThread.id, + createdAt: new Date().toISOString(), + }, + ]); + window.requestAnimationFrame(() => { + composerEditorRef.current?.focusAtEnd(); + }); + }, + addDrawingContext: (context: DrawingContext) => { + if (!activeThread) return; + commitDrawingContexts([ + ...drawingContextsRef.current, + { + ...context, + id: randomUUID(), + threadId: activeThread.id, + createdAt: new Date().toISOString(), + }, + ]); + focusComposer(); + }, + addScreenshotAttachment: ({ dataUrl, name }) => { + // The same two steps the desktop screen capture takes, for the same + // reasons: the converter rejects anything that is not a PNG data URL, + // and addComposerFiles applies the model-accepts-images check, the size + // limits and the dedupe that this would otherwise have to restate. + const file = desktopCapturedScreenshotToFile({ dataUrl, name, mimeType: "image/png" }); + if (file === null) { + toastManager.add({ + type: "error", + title: "Screenshot capture failed.", + description: "The captured image could not be read.", + }); + return; + } + addComposerFiles([file]); + focusComposer(); + }, getSendContext: () => ({ prompt: promptRef.current, images: composerAttachmentsRef.current, terminalContexts: composerTerminalContextsRef.current, transcriptHighlightContexts: composerTranscriptHighlightContextsRef.current, fileSelectionContexts: composerFileSelectionContextsRef.current, + pickedElementContexts: composerPickedElementContextsRef.current, + drawingContexts: drawingContextsRef.current, selectedPromptEffort, selectedModelOptionsForDispatch, selectedModelSelection, @@ -2776,6 +2926,31 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) /> )} + {!isComposerCollapsedMobile && + !isComposerApprovalState && + pendingUserInputs.length === 0 && + composerPickedElementContexts.length > 0 && ( + + )} + + {!isComposerCollapsedMobile && + !isComposerApprovalState && + pendingUserInputs.length === 0 && + composerDrawingContexts.length > 0 && ( + + )} + {!isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0 && diff --git a/apps/web/src/components/chat/ChatHeader.render.test.tsx b/apps/web/src/components/chat/ChatHeader.render.test.tsx index 6f1bc7c5b..8867e1adf 100644 --- a/apps/web/src/components/chat/ChatHeader.render.test.tsx +++ b/apps/web/src/components/chat/ChatHeader.render.test.tsx @@ -26,6 +26,9 @@ function renderChatHeader(overrides: Partial> sourceControlToggleShortcutLabel: null, sourceControlOpen: false, sourceControlAvailable: false, + browserAvailable: true, + browserOpen: false, + workingTreeDiffStat: null, fileBrowserAvailable: false, taskProgress: null, subagentProgress: null, @@ -40,6 +43,7 @@ function renderChatHeader(overrides: Partial> onOpenForkSourceThread: vi.fn(), onToggleTerminal: vi.fn(), onToggleSourceControl: vi.fn(), + onToggleBrowser: vi.fn(), ...overrides, } satisfies ComponentProps; @@ -71,4 +75,26 @@ describe("ChatHeader", () => { expect(markup).toContain('data-disabled="true"'); expect(markup).toContain("cursor-default"); }); + + it("shows the working-tree diffstat on the closed source control toggle", () => { + const markup = renderChatHeader({ + sourceControlAvailable: true, + sourceControlOpen: false, + workingTreeDiffStat: { insertions: 38, deletions: 12 }, + }); + + expect(markup).toContain("+38"); + expect(markup).toContain("−12"); + }); + + it("drops the diffstat once the panel is open and shows its own counts", () => { + const markup = renderChatHeader({ + sourceControlAvailable: true, + sourceControlOpen: true, + workingTreeDiffStat: { insertions: 38, deletions: 12 }, + }); + + expect(markup).not.toContain("+38"); + expect(markup).not.toContain("−12"); + }); }); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 099b41728..469d0263b 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -6,7 +6,13 @@ import { type ResolvedKeybindingsConfig, } from "@threadlines/contracts"; import { memo } from "react"; -import { FolderInputIcon, FolderOpenIcon, GitForkIcon, TerminalSquareIcon } from "lucide-react"; +import { + FolderInputIcon, + FolderOpenIcon, + GitForkIcon, + GlobeIcon, + TerminalSquareIcon, +} from "lucide-react"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Group } from "../ui/group"; @@ -49,6 +55,15 @@ interface ChatHeaderProps { sourceControlOpen: boolean; /** False for capability-gated threads (General Chats) even when a project name exists. */ sourceControlAvailable: boolean; + /** False where there is no project to preview, e.g. a general chat. */ + browserAvailable: boolean; + browserOpen: boolean; + /** + * Working-tree diffstat, surfaced on the closed source control toggle so the + * size of the pending change is legible without opening the panel. Null when + * the tree is clean or the status has not loaded. + */ + workingTreeDiffStat: { readonly insertions: number; readonly deletions: number } | null; /** False for General Chats: their scratch workspace has no files worth browsing. */ fileBrowserAvailable: boolean; taskProgress: ThreadTaskProgressState | null; @@ -67,6 +82,7 @@ interface ChatHeaderProps { onOpenForkSourceThread: (threadId: ThreadId) => void; onToggleTerminal: () => void; onToggleSourceControl: () => void; + onToggleBrowser: () => void; /** Present only for General Chat threads that can continue into a project. */ onContinueInProject?: ((event: React.MouseEvent) => void) | undefined; continueInProjectDisabledReason?: string | null; @@ -112,6 +128,10 @@ export const ChatHeader = memo(function ChatHeader({ sourceControlToggleShortcutLabel, sourceControlOpen, sourceControlAvailable, + browserAvailable, + browserOpen, + onToggleBrowser, + workingTreeDiffStat, fileBrowserAvailable, taskProgress, subagentProgress, @@ -301,12 +321,38 @@ export const ChatHeader = memo(function ChatHeader({ : "Toggle terminal drawer"} - {sourceControlAvailable || sourceControlOpen ? ( + {browserAvailable ? ( + + + } + /> + Toggle browser preview + + ) : null} + {sourceControlAvailable || sourceControlOpen ? ( + + + {/* Only while closed: once the panel is open it shows the + per-file counts, and repeating the total is noise. */} + {!sourceControlOpen && workingTreeDiffStat ? ( + + +{workingTreeDiffStat.insertions} + + −{workingTreeDiffStat.deletions} + + + ) : null} } /> diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index eccff2ccc..c9d55dbde 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -76,6 +76,8 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str terminalContexts: [], transcriptHighlightContexts: [], fileSelectionContexts: [], + pickedElementContexts: [], + drawingContexts: [], modelSelectionByProvider: { [instanceId]: createModelSelection(instanceId, model, props?.modelSelection?.options), }, diff --git a/apps/web/src/components/chat/ComposerPendingDrawingContexts.tsx b/apps/web/src/components/chat/ComposerPendingDrawingContexts.tsx new file mode 100644 index 000000000..3a61760ae --- /dev/null +++ b/apps/web/src/components/chat/ComposerPendingDrawingContexts.tsx @@ -0,0 +1,208 @@ +import { PencilIcon, XIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import { cn } from "~/lib/utils"; +import { formatDrawingDescriptor, type DrawingContextDraft } from "~/lib/drawingContext"; +import { formatPickedElementDescriptor } from "~/lib/pickedElementContext"; +import { COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME } from "../composerInlineChip"; +import { Button } from "../ui/button"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; + +interface ComposerPendingDrawingContextsProps { + contexts: ReadonlyArray; + onRemove: (contextId: string) => void; + onUpdateNote: (contextId: string, note: string) => void; + className?: string; +} + +const CHIP_CONTAINER_CLASS_NAME = + "inline-flex max-w-56 items-center gap-0.5 rounded-md border border-border/70 bg-accent/40 py-1 pr-1 pl-2 transition-colors hover:bg-accent/60"; + +const CHIP_TRIGGER_CLASS_NAME = + "inline-flex min-w-0 cursor-pointer items-center gap-1.5 rounded-sm text-left text-[12px] font-medium leading-tight text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring"; + +/** + * A drawing, as one chip. + * + * It is one act, so it reads as one thing. Sending the picture and a chip per + * circled element instead would put four items in the composer for a gesture + * that took a second and had a single point to make, and would bury the note + * under them. + * + * The picture lives behind the click, next to the note, because the chip only + * needs to say that a drawing is attached -- what it shows is the reason to + * open it. + */ +export function ComposerPendingDrawingContexts({ + contexts, + onRemove, + onUpdateNote, + className, +}: ComposerPendingDrawingContextsProps) { + if (contexts.length === 0) { + return null; + } + + return ( +
+ {contexts.map((context) => ( + + ))} +
+ ); +} + +function DrawingChip({ + context, + onRemove, + onUpdateNote, +}: { + context: DrawingContextDraft; + onRemove: (contextId: string) => void; + onUpdateNote: (contextId: string, note: string) => void; +}) { + const noteInputRef = useRef(null); + const [open, setOpen] = useState(false); + const [noteDraft, setNoteDraft] = useState(context.note ?? ""); + const descriptor = formatDrawingDescriptor(context); + + useEffect(() => { + setNoteDraft(context.note ?? ""); + }, [context.note]); + + useEffect(() => { + if (!open) { + return; + } + const frameId = window.requestAnimationFrame(() => { + const input = noteInputRef.current; + if (input === null) { + return; + } + input.focus(); + input.setSelectionRange(input.value.length, input.value.length); + }); + return () => window.cancelAnimationFrame(frameId); + }, [open]); + + const trimmed = noteDraft.trim(); + const changed = trimmed !== (context.note ?? "").trim(); + + const save = () => { + if (changed) { + onUpdateNote(context.id, trimmed); + } + setOpen(false); + }; + + return ( + { + if (next) { + setNoteDraft(context.note ?? ""); + } + setOpen(next); + }} + > + + + + {descriptor} + {context.note === null ? null : ( + + )} + + } + /> + + + + + {/* The drawing itself, small: enough to recognise which one this is, + not a second copy of the browser panel. */} + The page with your drawing on it + {context.elements.length === 0 ? null : ( +
    + {context.elements.map((element) => ( +
  • + {formatPickedElementDescriptor(element)} +
  • + ))} +
+ )} +