diff options
| -rw-r--r-- | CHANGELOG.md | 11 | ||||
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | docs/FAQ.md | 2 | ||||
| -rw-r--r-- | extensions/chrome-profile-bridge/browser-extension/manifest.json | 2 | ||||
| -rw-r--r-- | extensions/chrome-profile-bridge/browser-extension/service_worker.js | 274 | ||||
| -rw-r--r-- | extensions/chrome-profile-bridge/index.ts | 83 | ||||
| -rw-r--r-- | package-lock.json | 4 | ||||
| -rw-r--r-- | package.json | 4 | ||||
| -rw-r--r-- | test-suite/unit/background-policy.test.mjs | 3 | ||||
| -rw-r--r-- | test-suite/unit/hidden-input.test.mjs | 279 | ||||
| -rw-r--r-- | test-suite/unit/input-reliability.test.mjs | 45 | ||||
| -rw-r--r-- | test-suite/unit/type-evidence.test.mjs | 25 |
12 files changed, 680 insertions, 54 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index c2ff5ea..d0f7ae3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable user-facing changes to `pi-chrome`. +## 0.15.54 — 2026-09-26 + +- **Hidden tabs report instead of silently dropping input.** Chrome ignores trusted input to hidden pages. A page is hidden when it is an inactive tab, its window is minimized, its window is fully covered by another window, or (on macOS) its window became a hidden window tab behind a full-screen window. In a hidden page, mouse presses and keys were dropped while the tool reported success, and mouse moves could hang until a timeout (the source of `Detached while handling command` in background runs). Input tools now check page visibility first. `chrome_type`, `chrome_key`, `chrome_hover`, `chrome_scroll`, `chrome_tap` and `chrome_drag` fail fast with the reason and the options (`background:false`, or `/chrome background off`). `chrome_click` and `chrome_fill` go straight to their existing DOM fallback and report the reason, or reject with `domFallback:false`. Hard background mode is unchanged: nothing is activated or focused. +- **No hidden automation windows in full screen.** While your Chrome window is full screen, a new automation window would become a hidden macOS window tab or land on another Space. The automation target is now an inactive tab in your window instead. +- **Targeted `chrome_type` appends.** With a uid/selector, `chrome_type` clicked a random point inside the field, which left the caret mid-text and spliced input into existing content. After the focus click it now moves the caret to the end with Chrome's `moveToEndOfDocument` editing command. Untargeted typing still uses the current caret; `replace:true` is unchanged. +- **Upload buttons, labels, wrappers, and iframes.** `chrome_upload_file` accepts a label for a file input, or a wrapper/dropzone containing exactly one, and searches same-origin iframes for selectors. For upload buttons that open the native picker (no persistent `<input type=file>`), it intercepts the file chooser (`Page.setInterceptFileChooserDialog`) so no OS dialog appears. The trigger click uses trusted Chrome input on visible pages. On hidden pages it uses `element.click()` with a CDP user gesture, unless `domFallback:false`. It rejects multiple paths for single-file inputs. Adapted from kkunkunya's fork. +- **No duplicate `change` on upload.** Chrome's `DOM.setFileInputFiles` already fires `input`/`change`, and pi-chrome dispatched a second pair, which could make apps upload twice. It now dispatches only when Chrome did not, and reports `events: native|dispatched`. +- **Bounded screenshot folder.** Default-path `chrome_screenshot` captures prune `.pi/chrome-screenshots` at capture time. Captures older than 7 days are removed, but the newest 20 are always kept. Only files matching the tool's own naming (including full-page tiles and manifests) are eligible, and explicit `path:` captures are never pruned. `retentionDays` overrides the window; `0` disables pruning. Adapted from ardhiqii's fork. +- **`input.debug` without a target.** It no longer fails with `Value must be at least 0`; it describes this session's automation tab if one exists. +- **Validation.** New `hidden-input` unit suite. Live-checked on Chrome/macOS: hidden-tab errors and DOM fallbacks, caret-end typing in inputs and contenteditables, all upload shapes in visible and hidden tabs, single `change` events, and pruning through a real Pi session. + ## 0.15.53 — 2026-09-26 Changes adapted from community forks (ardhiqii, kkunkunya, nihar-oracle, steimerbyte). @@ -85,7 +85,7 @@ Run `/chrome revoke` when finished. Use `/chrome authorize` again whenever you w This is browser automation, not full OS control. Native Chrome/OS dialogs, password-manager prompts, passkeys/security keys/biometrics, CAPTCHA challenges, cross-origin iframe DOM access, rich multitouch/stylus gestures, and arbitrary desktop apps are outside its reliable tool surface. Some workflows need human assistance. -If page inspection or evaluation is blocked, use screenshots and coordinate input where possible. Background pages can throttle rendering or reject focus-gated actions. See the [FAQ](./docs/FAQ.md) for details. +If page inspection or evaluation is blocked, use screenshots and coordinate input where possible. Background pages can throttle rendering or reject focus-gated actions. Chrome ignores real input to hidden pages (inactive tabs, covered or minimized windows, windows behind a macOS full-screen window); input tools report this instead of pretending to succeed. See the [FAQ](./docs/FAQ.md) for details. ## Commands diff --git a/docs/FAQ.md b/docs/FAQ.md index d717694..bf46a51 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -75,7 +75,7 @@ If the page did not change, take a fresh snapshot or screenshot and check for ov ## How do I attach a file to a React file input? -`chrome_upload_file` — uses Chrome DevTools file-input control and fires `input` + `change` events. It does **not** open the native file picker. Works with React/Vue/Angular controlled inputs. +`chrome_upload_file` — uses Chrome DevTools file-input control; the page gets one `input` + `change`. Target the file input, a label or wrapper containing one, or an upload button: for buttons, the native file chooser is intercepted, so no OS dialog appears. Selectors also search same-origin iframes. Works with React/Vue/Angular controlled inputs. ## Can it record videos? diff --git a/extensions/chrome-profile-bridge/browser-extension/manifest.json b/extensions/chrome-profile-bridge/browser-extension/manifest.json index 952c9cc..aed24ef 100644 --- a/extensions/chrome-profile-bridge/browser-extension/manifest.json +++ b/extensions/chrome-profile-bridge/browser-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Pi Chrome Connector", - "version": "0.15.53", + "version": "0.15.54", "description": "Lets Pi control tabs in Chrome via a local connector at 127.0.0.1.", "permissions": [ "tabs", diff --git a/extensions/chrome-profile-bridge/browser-extension/service_worker.js b/extensions/chrome-profile-bridge/browser-extension/service_worker.js index 4bfce34..ead8ae0 100644 --- a/extensions/chrome-profile-bridge/browser-extension/service_worker.js +++ b/extensions/chrome-profile-bridge/browser-extension/service_worker.js @@ -174,6 +174,17 @@ function isPiChromeOwnedTarget(tabId, sessionKey) { return false; } +// A new window created while the user's Chrome window is full screen does not become a visible +// standalone window: macOS either merges it into the full-screen window as a hidden native window +// tab ("Prefer tabs: in full screen") or places it on another Space. Its page is hidden, so trusted +// input cannot reach it. Use a grouped background tab in the user's window instead, which is at least +// visible in the tab strip and does not add a macOS tab bar to the user's full-screen window. +async function userWindowIsFullscreen() { + if (typeof chrome.windows?.getLastFocused !== "function") return false; + const win = await chrome.windows.getLastFocused({ windowTypes: ["normal"] }).catch(() => null); + return win?.state === "fullscreen"; +} + // Create a fresh automation target for `sessionKey`. If this session already has a tab group, // create the tab inside that group's window so one Pi session keeps one Chrome tab group (Chrome // groups cannot span windows). If no group exists yet, prefer an isolated window; fall back to a @@ -187,7 +198,7 @@ async function createAutomationTarget(sessionKey, groupTitle) { await persistAutomationTargets(); return tab; } - if (chrome.windows && typeof chrome.windows.create === "function") { + if (chrome.windows && typeof chrome.windows.create === "function" && !(await userWindowIsFullscreen())) { try { const win = await chrome.windows.create({ url: "about:blank", focused: false }); const created = win && Array.isArray(win.tabs) ? win.tabs[0] : undefined; @@ -391,7 +402,8 @@ async function attachDebugger(tabId) { } async function describeInputTarget(tabId) { - const tab = await chrome.tabs.get(Number(tabId)).catch(() => null); + const id = Number(tabId); + const tab = Number.isInteger(id) && id >= 0 ? await chrome.tabs.get(id).catch(() => null) : null; const active = (await chrome.tabs.query({ active: true, lastFocusedWindow: true }).catch(() => []))[0] || null; let targets = []; try { targets = await new Promise((resolve) => chrome.debugger.getTargets((t) => resolve(t || []))); } catch {} @@ -408,7 +420,13 @@ function targetMetaSuffix(meta) { } async function inputDebug(params) { - const requested = params?.targetId ? await describeInputTarget(Number(params.targetId)) : await describeInputTarget(-1); + // No explicit target: describe this session's automation tab if it exists, without creating one. + let tabId = params?.targetId !== undefined ? Number(params.targetId) : null; + if (tabId === null) { + const owned = await resolveOwnedAutomationTarget(sessionKeyOf(params || {})).catch(() => null); + tabId = owned?.id ?? null; + } + const requested = await describeInputTarget(tabId); return { extensionVersion: chrome.runtime.getManifest().version, extensionId: chrome.runtime.id, @@ -417,6 +435,57 @@ async function inputDebug(params) { }; } +// Chrome drops trusted CDP input to hidden pages: mouse presses and keys are silently ignored and +// mouse moves can hang waiting for a frame. A page is hidden when its tab is inactive, its window is +// minimized, or (macOS) its window became a native window-tab behind a full-screen window. Check +// before sending input so the caller gets an actionable error instead of a false success. +async function pageVisibilityState(tabId) { + try { + const evaluated = await cdp(tabId, "Runtime.evaluate", { expression: "document.visibilityState", returnByValue: true }); + const value = evaluated?.result?.value; + return typeof value === "string" ? value : null; + } catch { + return null; // Unknown: do not block input on a failed probe. + } +} + +function hiddenTabReason(tab, win) { + if (win?.state === "minimized") return "its window is minimized"; + if (tab && tab.active === false) return "it is an inactive tab in its window"; + if (win?.state === "fullscreen") return "its window is behind a full-screen window (on macOS, new windows can become hidden window tabs)"; + return "its window is covered by another window or is on another Space (Chrome treats fully covered windows as hidden)"; +} + +async function assertTabVisibleForInput(tab, params, label) { + let state = await pageVisibilityState(tab.id); + if (state !== "hidden") return; + if (foregroundRequested(params)) { + // bringToFront just ran; give Chrome a moment to report the page visible. + for (let i = 0; i < 6 && state === "hidden"; i++) { + await sleep(100); + state = await pageVisibilityState(tab.id); + } + if (state !== "hidden") return; + } + const fresh = await chrome.tabs.get(tab.id).catch(() => tab); + const win = typeof chrome.windows?.get === "function" ? await chrome.windows.get(fresh?.windowId ?? tab.windowId).catch(() => null) : null; + const error = new Error( + `${label}: tab ${tab.id} is hidden because ${hiddenTabReason(fresh, win)}. Chrome ignores trusted input to hidden pages, so the events would be dropped. ` + + `Pass background:false for this call (or run /chrome background off) to bring the tab forward. chrome_click and chrome_fill can instead use their DOM fallback (untrusted page events).`, + ); + error.hiddenTab = true; + error.windowState = win?.state; + throw error; +} + +// Put the caret at the end of the focused field with Chrome's editing command (platform-neutral, +// works for inputs, textareas, and contenteditables). Used after chrome_type clicks a field to focus +// it, so the random click point never splices text into the middle of existing content. +async function moveCaretToEnd(tabId) { + await cdp(tabId, "Input.dispatchKeyEvent", { type: "rawKeyDown", key: "End", code: "End", windowsVirtualKeyCode: 35, commands: ["moveToEndOfDocument"] }); + await cdp(tabId, "Input.dispatchKeyEvent", { type: "keyUp", key: "End", code: "End", windowsVirtualKeyCode: 35 }); +} + async function detachDebugger(tabId) { const entry = attachedTabs.get(tabId); if (!entry) return; @@ -825,6 +894,7 @@ async function chromeInputClick(params) { await bringToFront(tab, params); try { await attachDebugger(tab.id); + await assertTabVisibleForInput(tab, params, "chrome.click"); const resolved = await resolveTargetInTab(tab.id, params); const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y }; await cdpMoveTo(tab.id, point.x, point.y); @@ -861,6 +931,7 @@ async function chromeInputHover(params) { const tab = await getTabByParams(params); await bringToFront(tab, params); await attachDebugger(tab.id); + await assertTabVisibleForInput(tab, params, "chrome.hover"); const resolved = await resolveTargetInTab(tab.id, params); const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y }; await cdpMoveTo(tab.id, point.x, point.y); @@ -872,6 +943,7 @@ async function chromeInputKey(params) { const tab = await getTabByParams(params); await bringToFront(tab, params); await attachDebugger(tab.id); + await assertTabVisibleForInput(tab, params, "chrome.key"); const key = String(params.key || ""); if (!key) throw new Error("chrome.key: missing key"); const mods = params.modifiers || {}; @@ -954,6 +1026,7 @@ async function chromeInputType(params) { const tab = await getTabByParams(params); await bringToFront(tab, params); await attachDebugger(tab.id); + await assertTabVisibleForInput(tab, params, "chrome.type"); if (params.selector || params.uid) { // Focus target by clicking it first. const resolved = await resolveTargetInTab(tab.id, params); @@ -963,6 +1036,9 @@ async function chromeInputType(params) { await sleep(rng(45, 110)); await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: 1, pointerType: "mouse" }); await sleep(rng(50, 120)); + // The click lands at a random point inside the field, which leaves the caret mid-text. Move it to + // the end so a targeted chrome_type appends; replace=true selects everything below anyway. + if (params.replace !== true) await moveCaretToEnd(tab.id); } const text = String(params.text || ""); // chrome_type inserts at the caret. Read the field before and after so a splice into existing @@ -1144,6 +1220,7 @@ async function chromeInputFill(params) { await bringToFront(tab, params); try { await attachDebugger(tab.id); + await assertTabVisibleForInput(tab, params, "chrome.fill"); if (!(params.selector || params.uid)) throw new Error("chrome.fill: selector or uid required"); const resolved = await resolveTargetInTab(tab.id, params); const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y }; @@ -1174,6 +1251,7 @@ async function chromeInputScroll(params) { const tab = await getTabByParams(params); await bringToFront(tab, params); await attachDebugger(tab.id); + await assertTabVisibleForInput(tab, params, "chrome.scroll"); const resolved = (params.selector || params.uid) ? await resolveTargetInTab(tab.id, params) : { x: 100, y: 100, rect: null }; const x = resolved.rect ? resolved.rect.left + Math.min(resolved.rect.width, 800) / 2 : resolved.x; const y = resolved.rect ? resolved.rect.top + Math.min(resolved.rect.height, 600) / 2 : resolved.y; @@ -1224,6 +1302,7 @@ async function chromeInputTap(params) { const tab = await getTabByParams(params); await bringToFront(tab, params); await attachDebugger(tab.id); + await assertTabVisibleForInput(tab, params, "chrome.tap"); const resolved = (params.selector || params.uid || (typeof params.x === "number" && typeof params.y === "number")) ? await resolveTargetInTab(tab.id, params) : null; @@ -1240,6 +1319,7 @@ async function chromeInputDrag(params) { const tab = await getTabByParams(params); await bringToFront(tab, params); await attachDebugger(tab.id); + await assertTabVisibleForInput(tab, params, "chrome.drag"); const from = await resolveTargetInTab(tab.id, { selector: params.fromSelector ?? null, uid: params.fromUid ?? null, x: params.fromX ?? null, y: params.fromY ?? null }); const to = await resolveTargetInTab(tab.id, { selector: params.toSelector ?? null, uid: params.toUid ?? null, x: params.toX ?? null, y: params.toY ?? null }); const fp = from.rect ? pickInsideRect(from.rect) : { x: from.x, y: from.y }; @@ -1261,6 +1341,100 @@ async function chromeInputDrag(params) { return { input: "chrome", from: fp, to: tp, steps }; } +// Pending native file-chooser intercepts, keyed by tabId. chrome_upload_file uses these when the +// target is an upload trigger (button/dropzone) rather than a persistent <input type=file>: +// Page.setInterceptFileChooserDialog -> click the trigger -> Page.fileChooserOpened -> +// DOM.setFileInputFiles(backendNodeId). No OS dialog is shown. +const pendingFileChoosers = new Map(); +const FILE_CHOOSER_TIMEOUT_MS = 8_000; + +function fileChooserTabIdForSource(source) { + if (typeof source?.tabId === "number") return source.tabId; + if (source?.targetId) { + for (const [tabId, entry] of attachedTabs) if (entry?.debuggee?.targetId === source.targetId) return tabId; + } + return undefined; +} + +function handleDebuggerEvent(source, method, params) { + if (method !== "Page.fileChooserOpened") return; + const tabId = fileChooserTabIdForSource(source); + const entry = tabId === undefined ? undefined : pendingFileChoosers.get(tabId); + if (!entry) return; + pendingFileChoosers.delete(tabId); + entry.resolve({ backendNodeId: params?.backendNodeId, mode: params?.mode }); +} + +if (chrome.debugger && chrome.debugger.onEvent) { + chrome.debugger.onEvent.addListener(handleDebuggerEvent); +} + +function waitForFileChooser(tabId, timeoutMs) { + let entry; + const promise = new Promise((resolve, reject) => { + entry = { resolve, reject }; + entry.timer = setTimeout(() => { + if (pendingFileChoosers.get(tabId) === entry) pendingFileChoosers.delete(tabId); + reject(new Error("chrome.upload: no file chooser opened after clicking the target. Pass the <input type=file> itself, or an element that opens the file picker when clicked.")); + }, timeoutMs); + }); + pendingFileChoosers.set(tabId, entry); + return { promise: promise.finally(() => clearTimeout(entry.timer)), cancel: () => { if (pendingFileChoosers.get(tabId) === entry) pendingFileChoosers.delete(tabId); clearTimeout(entry.timer); } }; +} + +// Resolve the upload target in the page. Selectors also search same-origin iframes (editors often +// host their file input in one). A label for a file input, or a wrapper/dropzone containing exactly +// one file input, resolves to that input so no click is needed. +function uploadTargetExpression(selector, uid) { + return `(() => { + const selector = ${JSON.stringify(selector ?? null)}; + const uid = ${JSON.stringify(uid ?? null)}; + const state = window.__PI_CHROME_STATE__; + const findIn = (doc, depth) => { + let found = null; + try { found = doc.querySelector(selector); } catch (error) { throw new Error("Invalid selector: " + selector); } + if (found || depth >= 3) return found; + for (const frame of doc.querySelectorAll("iframe,frame")) { + let inner = null; + try { inner = frame.contentDocument; } catch {} + if (!inner) continue; + found = findIn(inner, depth + 1); + if (found) return found; + } + return null; + }; + let el = uid ? state?.elements?.[uid] : (selector ? findIn(document, 0) : null); + if (uid && (!el || !el.isConnected)) throw new Error("snapshot uid " + uid + " is stale; refresh chrome_snapshot"); + if (!el) throw new Error("chrome.upload: target not found: " + (uid || selector)); + const isFile = (node) => node && node.tagName === "INPUT" && String(node.type).toLowerCase() === "file"; + if (!isFile(el)) { + const control = el.tagName === "LABEL" && isFile(el.control) ? el.control : null; + const nested = control ? null : Array.from(el.querySelectorAll ? el.querySelectorAll("input[type=file]") : []); + if (control) el = control; + else if (nested && nested.length === 1) el = nested[0]; + } + return el; + })()`; +} + +const UPLOAD_TARGET_INFO_FN = `function() { + const isFile = this.tagName === "INPUT" && String(this.type).toLowerCase() === "file"; + if (!isFile) this.scrollIntoView({ block: "center", inline: "center", behavior: "instant" }); + const r = this.getBoundingClientRect(); + let x = r.left + r.width / 2, y = r.top + r.height / 2; + let win = this.ownerDocument.defaultView; + // Convert iframe-relative coordinates to top-level viewport coordinates (same-origin frames only). + while (win && win.frameElement) { + const frame = win.frameElement; + const fr = frame.getBoundingClientRect(); + const cs = frame.ownerDocument.defaultView.getComputedStyle(frame); + x += fr.left + (parseFloat(cs.borderLeftWidth) || 0) + (parseFloat(cs.paddingLeft) || 0); + y += fr.top + (parseFloat(cs.borderTopWidth) || 0) + (parseFloat(cs.paddingTop) || 0); + win = win.parent === win ? null : win.parent; + } + return { tag: this.tagName, isFile, multiple: isFile ? this.multiple === true : undefined, inFrame: this.ownerDocument !== document, x, y, width: r.width, height: r.height }; +}`; + async function chromeInputUpload(params) { const tab = await getTabByParams(params); await bringToFront(tab, params); @@ -1268,36 +1442,80 @@ async function chromeInputUpload(params) { if (!(params.selector || params.uid)) throw new Error("chrome.upload: selector or uid required"); const paths = Array.isArray(params.paths) ? params.paths.map(String) : []; if (!paths.length) throw new Error("chrome.upload: no file paths provided"); - const expression = `(() => { - const selector = ${JSON.stringify(params.selector ?? null)}; - const uid = ${JSON.stringify(params.uid ?? null)}; - const state = window.__PI_CHROME_STATE__; - const el = uid ? state?.elements?.[uid] : (selector ? document.querySelector(selector) : null); - if (uid && (!el || !el.isConnected)) throw new Error("snapshot uid " + uid + " is stale; refresh chrome_snapshot"); - if (!el || el.tagName !== "INPUT" || el.type !== "file") throw new Error("Target must be <input type=file>"); - el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" }); - return el; - })()`; - const evaluated = await cdp(tab.id, "Runtime.evaluate", { expression, objectGroup: "pi-chrome-upload", includeCommandLineAPI: false, returnByValue: false }); - if (evaluated.exceptionDetails) throw new Error(cdpExceptionText(evaluated.exceptionDetails) || "Could not resolve file input"); + const evaluated = await cdp(tab.id, "Runtime.evaluate", { expression: uploadTargetExpression(params.selector, params.uid), objectGroup: "pi-chrome-upload", includeCommandLineAPI: false, returnByValue: false }); + if (evaluated.exceptionDetails) throw new Error(cdpExceptionText(evaluated.exceptionDetails) || "Could not resolve upload target"); const objectId = evaluated.result?.objectId; - if (!objectId) throw new Error("Could not resolve file input object"); + if (!objectId) throw new Error("Could not resolve upload target object"); try { - await cdp(tab.id, "DOM.enable", {}).catch(() => undefined); - // Some DOM agents return nodeId:0 (or reject conversion) for a valid remote - // element. CDP accepts that same objectId directly, before any file mutation. - const requested = await cdp(tab.id, "DOM.requestNode", { objectId }).catch(() => null); - const target = requested?.nodeId ? { nodeId: requested.nodeId } : { objectId }; - await cdp(tab.id, "DOM.setFileInputFiles", { ...target, files: paths }); - await cdp(tab.id, "Runtime.callFunctionOn", { - objectId, - functionDeclaration: `function() { this.dispatchEvent(new Event("input", { bubbles: true })); this.dispatchEvent(new Event("change", { bubbles: true })); return this.files ? this.files.length : 0; }`, - returnByValue: true, - }).catch(() => undefined); + const infoResult = await cdp(tab.id, "Runtime.callFunctionOn", { objectId, functionDeclaration: UPLOAD_TARGET_INFO_FN, returnByValue: true }); + const info = infoResult?.result?.value || {}; + if (info.isFile) { + if (paths.length > 1 && info.multiple === false) throw new Error(`chrome.upload: the file input accepts one file but ${paths.length} paths were given`); + await cdp(tab.id, "DOM.enable", {}).catch(() => undefined); + // Some DOM agents return nodeId:0 (or reject conversion) for a valid remote + // element. CDP accepts that same objectId directly, before any file mutation. + const requested = await cdp(tab.id, "DOM.requestNode", { objectId }).catch(() => null); + const target = requested?.nodeId ? { nodeId: requested.nodeId } : { objectId }; + // Chrome's setFileInputFiles normally fires input/change itself. Watch for that and only + // dispatch our own events when it did not, so apps never see a duplicate change (double upload). + await cdp(tab.id, "Runtime.callFunctionOn", { + objectId, + functionDeclaration: `function() { const el = this; el.__piChromeChangeSeen = false; el.addEventListener("change", () => { el.__piChromeChangeSeen = true; }, { once: true, capture: true }); }`, + }).catch(() => undefined); + await cdp(tab.id, "DOM.setFileInputFiles", { ...target, files: paths }); + const notified = await cdp(tab.id, "Runtime.callFunctionOn", { + objectId, + functionDeclaration: `function() { const seen = this.__piChromeChangeSeen === true; delete this.__piChromeChangeSeen; if (seen) return "native"; this.dispatchEvent(new Event("input", { bubbles: true })); this.dispatchEvent(new Event("change", { bubbles: true })); return "dispatched"; }`, + returnByValue: true, + }).catch(() => null); + const events = notified?.result?.value; + return { input: "chrome", mode: "file-input", events: typeof events === "string" ? events : undefined, inFrame: info.inFrame || undefined, uploaded: paths.map((path) => ({ path })) }; + } + return await uploadViaFileChooser(tab, params, paths, objectId, info); } finally { await cdp(tab.id, "Runtime.releaseObject", { objectId }).catch(() => undefined); } - return { input: "chrome", uploaded: paths.map((path) => ({ path })) }; +} + +// The target opens the native picker when clicked. Intercept the chooser so no OS dialog appears, +// then attach the files to the input Chrome reports. The click is trusted Chrome input when the page +// is visible; a hidden page drops trusted input, so there we call element.click() with a CDP user +// gesture instead (an untrusted event, like chrome_click's DOM fallback) unless domFallback:false. +async function uploadViaFileChooser(tab, params, paths, objectId, info) { + const hidden = (await pageVisibilityState(tab.id)) === "hidden"; + if (hidden && params.domFallback === false) await assertTabVisibleForInput(tab, params, "chrome.upload"); + await cdp(tab.id, "Page.enable", {}); + await cdp(tab.id, "Page.setInterceptFileChooserDialog", { enabled: true }); + const chooser = waitForFileChooser(tab.id, FILE_CHOOSER_TIMEOUT_MS); + let trigger; + try { + if (!hidden && typeof info.x === "number" && typeof info.y === "number" && info.width > 0 && info.height > 0) { + trigger = "chrome"; + await cdpMoveTo(tab.id, info.x, info.y); + await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mousePressed", x: info.x, y: info.y, button: "left", buttons: 1, clickCount: 1, pointerType: "mouse" }); + await sleep(rng(40, 110)); + await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: info.x, y: info.y, button: "left", buttons: 0, clickCount: 1, pointerType: "mouse" }); + } else { + trigger = "activation-click"; + await cdp(tab.id, "Runtime.callFunctionOn", { objectId, functionDeclaration: "function() { this.click(); }", userGesture: true, awaitPromise: false }); + } + const opened = await chooser.promise; + if (typeof opened.backendNodeId !== "number") throw new Error("chrome.upload: file chooser opened without an input node"); + if (paths.length > 1 && opened.mode === "selectSingle") throw new Error(`chrome.upload: the file chooser accepts one file but ${paths.length} paths were given`); + await cdp(tab.id, "DOM.enable", {}).catch(() => undefined); + await cdp(tab.id, "DOM.setFileInputFiles", { backendNodeId: opened.backendNodeId, files: paths }); + return { + input: "chrome", + mode: "file-chooser", + trigger, + reason: trigger === "activation-click" ? "target tab is hidden; Chrome drops trusted input to hidden pages" : undefined, + inFrame: info.inFrame || undefined, + uploaded: paths.map((path) => ({ path })), + }; + } finally { + chooser.cancel(); + await cdp(tab.id, "Page.setInterceptFileChooserDialog", { enabled: false }).catch(() => undefined); + } } // =============================================================== diff --git a/extensions/chrome-profile-bridge/index.ts b/extensions/chrome-profile-bridge/index.ts index 46be3d9..e9e21e9 100644 --- a/extensions/chrome-profile-bridge/index.ts +++ b/extensions/chrome-profile-bridge/index.ts @@ -1,7 +1,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { existsSync, readFileSync, statSync } from "node:fs"; -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, readdir, unlink, writeFile } from "node:fs/promises"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { dirname, join, resolve } from "node:path"; @@ -236,6 +236,58 @@ function formatCdpResult(method: string, value: unknown): ToolTextResult { return { content: [{ type: "text", text: truncateText(text) }], details: { value } }; } +// chrome_screenshot writes a timestamped file per capture (plus -tileN files and a .json manifest for +// full-page captures) into .pi/chrome-screenshots. Prune that folder at capture time so it stays +// bounded. Only files matching the tool's own naming are eligible; the newest `keep` captures are always +// kept, and nothing inside the retention window is removed. Best-effort: errors never fail a capture. +const SCREENSHOT_NAME_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)(?:-tile\d+)?\.(?:png|jpeg)(?:\.json)?$/; +const SCREENSHOT_KEEP_NEWEST = 20; +const SCREENSHOT_RETENTION_DAYS = 7; + +function screenshotCaptureTime(stamp: string): number { + const iso = stamp.replace(/^(\d{4}-\d{2}-\d{2}T\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/, "$1:$2:$3.$4Z"); + return Date.parse(iso); +} + +function screenshotFilesToPrune(names: string[], now: number, retentionDays = SCREENSHOT_RETENTION_DAYS, keep = SCREENSHOT_KEEP_NEWEST): string[] { + if (!(retentionDays > 0)) return []; + const captures = new Map<string, string[]>(); + for (const name of names) { + const match = SCREENSHOT_NAME_RE.exec(name); + if (!match) continue; + const files = captures.get(match[1]) ?? []; + files.push(name); + captures.set(match[1], files); + } + const cutoff = now - retentionDays * 24 * 60 * 60 * 1000; + const newestFirst = [...captures.keys()].sort().reverse(); + const prune: string[] = []; + for (const stamp of newestFirst.slice(keep)) { + const takenAt = screenshotCaptureTime(stamp); + if (Number.isFinite(takenAt) && takenAt < cutoff) prune.push(...(captures.get(stamp) ?? [])); + } + return prune; +} + +async function pruneScreenshotDir(dir: string, retentionDays?: number): Promise<number> { + try { + const names = await readdir(dir); + let removed = 0; + for (const name of screenshotFilesToPrune(names, Date.now(), retentionDays)) { + try { + await unlink(join(dir, name)); + removed++; + } catch { + // Locked or already gone: keep going. + } + } + return removed; + } catch { + return 0; + } +} +// End screenshot pruning. + function formatChromeInspect(inspect: any): string { if (!inspect || typeof inspect !== "object") return safeJson(inspect); const t = inspect.target || {}; @@ -1066,7 +1118,8 @@ Capability model (important): - Input bypasses page CSP because it is injected at browser input layer, not page JavaScript. Chrome may show the “Pi Chrome Connector started debugging this browser” banner while attached. - \`chrome_evaluate\` and \`chrome_snapshot\` run in MAIN world via **CDP \`Runtime.evaluate\`**, which is not subject to the page's Content-Security-Policy. They work even on strict-CSP pages (e.g. github.com, many bank/SaaS apps) that block \`'unsafe-eval'\`. \`chrome_navigate initScript\` likewise injects at document_start via CDP and bypasses CSP. \`chrome_screenshot\`, \`chrome_tab\`, and Chrome input also work under any CSP. - Input tools return structured details and support \`includeSnapshot=true\` on click/type/fill/key. Use the fresh snapshot to verify state instead of repeating blindly. If the action started a navigation, the snapshot waits (up to 5s) for the new page. -- \`chrome_type\` inserts at the caret and never replaces; check its before/after report. Use \`chrome_fill\` to replace a field. +- \`chrome_type\` never replaces: with a uid/selector it appends at the end of the field; without one it types at the current caret. Check its before/after report. Use \`chrome_fill\` to replace a field. +- Chrome ignores real input to hidden pages (inactive tabs, minimized windows, windows behind a macOS full-screen window). Input tools report this instead of silently doing nothing; retry with background:false only when bringing the tab forward is acceptable. - \`chrome_cdp\` runs any raw CDP method on a tab (emulation, cookies, PDF, accessibility tree, etc.) when no dedicated chrome_* tool fits; \`chrome_cdp_targets\` diagnoses debugger/overlay conflicts. Usage rules: @@ -1630,7 +1683,7 @@ Usage rules: selector: Type.Optional(Type.String({ description: "CSS selector to click. Prefer uid from chrome_snapshot when available." })), x: Type.Optional(Type.Number({ description: "Viewport x coordinate if uid/selector is omitted." })), y: Type.Optional(Type.Number({ description: "Viewport y coordinate if uid/selector is omitted." })), - domFallback: Type.Optional(Type.Boolean({ description: "If true (default), fall back to DOM-dispatched click if Chrome's CDP input path is blocked by another extension overlay or debugger failure." })), + domFallback: Type.Optional(Type.Boolean({ description: "If true (default), fall back to DOM-dispatched click if Chrome's CDP input path is blocked (extension overlay, debugger failure, or a hidden tab that ignores trusted input)." })), includeSnapshot: Type.Optional(Type.Boolean({ description: "If true, include a fresh chrome_snapshot result after the click." })), maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." })), targetId: Type.Optional(Type.String()), @@ -1654,8 +1707,8 @@ Usage rules: name: "chrome_type", label: "Chrome Type", description: - "Focus an optional snapshot uid or CSS selector, then type at the caret using Chrome's real input. It does NOT replace existing text: in a non-empty field the text is inserted wherever the caret lands after the focus click. Use chrome_fill (or replace=true) to replace a field's value. Contenteditables use one native text insertion; other fields use key events. Set perCharacter=true for editors needing individual keydown events. The result reports the field value before/after and insertedAt (empty|caret-end|caret-middle|replaced-selection|replaced-all), with a warning when text was spliced into existing content. Pass includeSnapshot=true to verify after typing; if the action starts a navigation, the snapshot waits (up to 5s) for it to load.", - promptSnippet: "Type text at the caret in Chrome (does not replace existing text; use chrome_fill or replace=true).", + "Type using Chrome's real input. With a snapshot uid or CSS selector, chrome_type clicks the field and moves the caret to the end, so text is appended to existing content; without one it types at the current caret of the focused element. It does NOT replace existing text. Use chrome_fill (or replace=true) to replace a field's value. Contenteditables use one native text insertion; other fields use key events. Set perCharacter=true for editors needing individual keydown events. The result reports the field value before/after and insertedAt (empty|caret-end|caret-middle|replaced-selection|replaced-all), with a warning when text was spliced into existing content. Pass includeSnapshot=true to verify after typing; if the action starts a navigation, the snapshot waits (up to 5s) for it to load.", + promptSnippet: "Type text in Chrome; a uid/selector target appends at the end (does not replace existing text; use chrome_fill or replace=true).", parameters: Type.Object({ text: Type.String(), uid: Type.Optional(Type.String({ description: "Stable element uid from chrome_snapshot." })), @@ -1695,7 +1748,7 @@ Usage rules: selector: Type.Optional(Type.String({ description: "CSS selector to fill if uid is omitted." })), perCharacter: Type.Optional(Type.Boolean({ default: false, description: "Send individual key events even in contenteditables. Default: one native text insertion for contenteditables; key events for other fields." })), submit: Type.Optional(Type.Boolean({ description: "If true, press Enter after filling." })), - domFallback: Type.Optional(Type.Boolean({ description: "If true (default), fall back to DOM value-setting if Chrome's CDP input path is blocked by another extension overlay or debugger failure." })), + domFallback: Type.Optional(Type.Boolean({ description: "If true (default), fall back to DOM value-setting if Chrome's CDP input path is blocked (extension overlay, debugger failure, or a hidden tab that ignores trusted input)." })), includeSnapshot: Type.Optional(Type.Boolean({ description: "If true, include a fresh chrome_snapshot result after filling." })), maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." })), targetId: Type.Optional(Type.String()), @@ -1845,6 +1898,7 @@ Usage rules: format: Type.Optional(StringEnum(imageFormatValues)), quality: Type.Optional(Type.Number({ minimum: 0, maximum: 100, description: "JPEG quality 0-100." })), fullPage: Type.Optional(Type.Boolean({ description: "Capture full-page tiles plus a JSON manifest. Temporarily scrolls the target page; does not activate background tabs." })), + retentionDays: Type.Optional(Type.Number({ minimum: 0, description: "Default-folder captures older than this many days are pruned (the newest 20 are always kept). Default 7; 0 disables pruning. Explicit paths are never pruned." })), targetId: Type.Optional(Type.String()), urlIncludes: Type.Optional(Type.String()), titleIncludes: Type.Optional(Type.String()), @@ -1855,8 +1909,10 @@ Usage rules: async execute(_id, params, signal, _onUpdate, ctx: ExtensionContext): Promise<ToolTextResult> { const format = params.format ?? "png"; const cwd = workspaceCwd(ctx); - const defaultPath = join(cwd, ".pi", "chrome-screenshots", `${new Date().toISOString().replace(/[:.]/g, "-")}.${format}`); + const screenshotDir = join(cwd, ".pi", "chrome-screenshots"); + const defaultPath = join(screenshotDir, `${new Date().toISOString().replace(/[:.]/g, "-")}.${format}`); const outputPath = params.path ? resolve(cwd, params.path) : defaultPath; + const prune = () => (params.path ? Promise.resolve(0) : pruneScreenshotDir(screenshotDir, params.retentionDays)); const result = (await authorizedBridgeSend("page.screenshot", params, params.fullPage ? 120_000 : DEFAULT_TIMEOUT_MS, signal)) as { dataUrl?: string; method?: string; @@ -1880,6 +1936,7 @@ Usage rules: manifest.push({ path: tilePath, y: tile.y }); } await writeFile(outputPath + ".json", JSON.stringify({ width, height, viewportHeight, dpr, tiles: manifest }, null, 2)); + await prune(); return { content: [{ type: "text", text: `Saved ${result.tiles.length} full-page tile(s) for ${width}×${height}px page. Manifest: ${outputPath}.json` }], details: { manifest: outputPath + ".json", tiles: manifest, dimensions: result.dimensions, tab: result.tab, method: result.method } as unknown as Record<string, unknown>, @@ -1888,6 +1945,7 @@ Usage rules: if (!result.dataUrl) throw new Error("Screenshot returned no dataUrl"); const base64 = result.dataUrl.replace(/^data:image\/(?:png|jpeg);base64,/, ""); await writeFile(outputPath, Buffer.from(base64, "base64")); + await prune(); return { content: [{ type: "text", text: `Saved Chrome screenshot to ${outputPath}` }], details: { path: outputPath, format, tab: result.tab, method: result.method } }; }, }); @@ -1987,12 +2045,13 @@ Usage rules: pi.registerTool({ name: "chrome_upload_file", label: "Chrome Upload File", - description: "Attach local files to an <input type=file> element using Chrome DevTools file-input control. Does NOT open the native file picker; works with React/Vue/Angular controlled inputs.", + description: "Attach local files using Chrome DevTools file-input control without opening a native file picker. Target the <input type=file>, a label/wrapper containing one, or an upload button that opens the picker (the chooser is intercepted). Selectors also search same-origin iframes. Works with React/Vue/Angular controlled inputs.", promptSnippet: "Attach local files to a Chrome <input type=file> without opening the native file picker.", parameters: Type.Object({ uid: Type.Optional(Type.String()), selector: Type.Optional(Type.String()), paths: Type.Array(Type.String(), { description: "Local absolute file paths to upload." }), + domFallback: Type.Optional(Type.Boolean({ description: "Upload buttons need a click to open the (intercepted) file chooser. If true (default) and the tab is hidden, use a page-level element.click() with a user gesture instead of trusted input. Set false to require trusted input." })), targetId: Type.Optional(Type.String()), urlIncludes: Type.Optional(Type.String()), titleIncludes: Type.Optional(Type.String()), @@ -2001,8 +2060,12 @@ Usage rules: async execute(_id, params, signal, _onUpdate, ctx): Promise<ToolTextResult> { const cwd = workspaceCwd(ctx); const paths = params.paths.map((p) => resolve(cwd, p)); - const result = await authorizedBridgeSend("page.upload", { ...params, paths }, DEFAULT_TIMEOUT_MS, signal); - return { content: [{ type: "text", text: `Uploaded ${paths.length} file(s) to ${params.uid ?? params.selector}` }], details: { result: result as Json } }; + const result = (await authorizedBridgeSend("page.upload", { ...params, paths }, DEFAULT_TIMEOUT_MS, signal)) as { mode?: string; trigger?: string; inFrame?: boolean } | undefined; + const via = result?.mode === "file-chooser" + ? ` via intercepted file chooser (${result.trigger === "activation-click" ? "page-level click because the tab is hidden" : "trusted click"})` + : ""; + const frame = result?.inFrame ? " inside an iframe" : ""; + return { content: [{ type: "text", text: `Uploaded ${paths.length} file(s) to ${params.uid ?? params.selector}${frame}${via}` }], details: { result: result as Json } }; }, }); diff --git a/package-lock.json b/package-lock.json index a850062..7693387 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-chrome", - "version": "0.15.53", + "version": "0.15.54", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-chrome", - "version": "0.15.53", + "version": "0.15.54", "license": "MIT", "peerDependencies": { "@earendil-works/pi-coding-agent": "*", diff --git a/package.json b/package.json index 3ae9aa3..6767806 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "pi-chrome", - "version": "0.15.53", + "version": "0.15.54", "scripts": { - "test": "node test-suite/unit/csp-eval.test.mjs && node test-suite/unit/automation-target.test.mjs && node test-suite/unit/session-cleanup.test.mjs && node test-suite/unit/background-policy.test.mjs && node test-suite/unit/input-reliability.test.mjs && node test-suite/unit/chrome-command.test.mjs && node test-suite/unit/bridge-resilience.test.mjs && node test-suite/unit/cdp-passthrough.test.mjs && node test-suite/unit/type-evidence.test.mjs", + "test": "node test-suite/unit/csp-eval.test.mjs && node test-suite/unit/automation-target.test.mjs && node test-suite/unit/session-cleanup.test.mjs && node test-suite/unit/background-policy.test.mjs && node test-suite/unit/input-reliability.test.mjs && node test-suite/unit/chrome-command.test.mjs && node test-suite/unit/bridge-resilience.test.mjs && node test-suite/unit/cdp-passthrough.test.mjs && node test-suite/unit/type-evidence.test.mjs && node test-suite/unit/hidden-input.test.mjs", "version": "node scripts/sync-manifest-version.js", "prepublishOnly": "node scripts/sync-manifest-version.js" }, diff --git a/test-suite/unit/background-policy.test.mjs b/test-suite/unit/background-policy.test.mjs index fe71634..2388d78 100644 --- a/test-suite/unit/background-policy.test.mjs +++ b/test-suite/unit/background-policy.test.mjs @@ -50,7 +50,7 @@ function piHarness({ session = "alpha", send } = {}) { formatChromeInspect: JSON.stringify, summarizeActionResult: () => "", formatIncludedSnapshotText: (_r, text) => text, describeTypeEvidence: () => [], formatCdpResult: (_method, value) => ({ content: [{ type: "text", text: JSON.stringify(value) }], details: { value } }), workspaceCwd: () => ctx.cwd, ...path, - mkdir: async () => {}, writeFile: async (...args) => writes.push(args), + mkdir: async () => {}, writeFile: async (...args) => writes.push(args), pruneScreenshotDir: async () => 0, }; const registrations = indexSource.slice(indexSource.indexOf("function registerChromeTools(pi:"), indexSource.lastIndexOf("\n}")); vm.runInNewContext(stripTypeScriptTypes([ @@ -292,6 +292,7 @@ function workerHarness({ withWindows = true } = {}) { w, chrome, calls, tabs, focusedWindow: () => focusedWindow, cdpResult: (method) => method === "Page.captureScreenshot" ? { data: "c2NyZWVuc2hvdA==" } : method === "DOM.requestNode" ? { nodeId: 1 } + : method === "Runtime.callFunctionOn" ? { result: { type: "object", value: { tag: "INPUT", isFile: true } } } : { result: { type: "boolean", value: true, objectId: "file-input" } }, }; return h; diff --git a/test-suite/unit/hidden-input.test.mjs b/test-suite/unit/hidden-input.test.mjs new file mode 100644 index 0000000..376db69 --- /dev/null +++ b/test-suite/unit/hidden-input.test.mjs @@ -0,0 +1,279 @@ +// Hidden-tab input guard, full-screen automation targets, input.debug without a target, upload +// targets (label/wrapper/iframe/file-chooser), and screenshot pruning. Chrome and pages are mocked; +// the hidden-page behavior itself (Chrome drops trusted input) was measured live on Chrome/macOS. +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import vm from "node:vm"; +import { stripTypeScriptTypes } from "node:module"; +import { test } from "node:test"; + +const workerSource = fs.readFileSync(new URL("../../extensions/chrome-profile-bridge/browser-extension/service_worker.js", import.meta.url), "utf8"); +const indexSource = fs.readFileSync(new URL("../../extensions/chrome-profile-bridge/index.ts", import.meta.url), "utf8"); +const clone = (value) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value))); + +function worker({ visibility = ["visible"], tab = { id: 2, windowId: 1, active: false, url: "https://fixture.test/" }, win = { id: 1, state: "normal" } } = {}) { + const calls = []; + const listener = { addListener() {}, removeListener() {} }; + const chrome = { + runtime: { id: "test", getManifest: () => ({ version: "0.0.0" }), onInstalled: listener, onStartup: listener }, + alarms: { create() {}, onAlarm: listener }, action: { onClicked: listener }, webNavigation: { onCommitted: listener }, + debugger: { onDetach: listener, onEvent: listener, getTargets: (cb) => cb([]) }, + storage: { session: { get: async () => ({}), set: async () => {} } }, + scripting: { executeScript: async (options) => { calls.push({ method: "scripting.executeScript", options }); return [{ result: { tag: "BUTTON", url: tab.url } }]; } }, + tabs: { + get: async (id) => (id === tab.id ? clone(tab) : Promise.reject(new Error(`No tab with id: ${id}`))), + query: async () => [clone(tab)], + create: async (props) => { calls.push({ method: "tabs.create", props }); return { id: 77, windowId: 1, url: props.url }; }, + }, + windows: { + get: async () => clone(win), + getLastFocused: async () => clone(win), + create: async (props) => { calls.push({ method: "windows.create", props }); return { id: 9, tabs: [{ id: 90, windowId: 9 }] }; }, + }, + }; + const w = { chrome, console, setTimeout, clearTimeout, setInterval: () => 0, navigator: { userAgent: "unit-test" }, fetch: async () => { throw new Error("offline"); } }; + w.self = w; + vm.runInNewContext(workerSource, w); + w.sleep = async () => {}; + w.getTabByParams = async () => clone(tab); + w.attachDebugger = async () => {}; + w.cdpMoveTo = async () => {}; + w.resolveTargetInTab = async () => ({ found: true, x: 40, y: 50, rect: { left: 10, top: 10, width: 100, height: 20 }, tag: "INPUT" }); + let probes = 0; + w.cdp = async (_tabId, method, params = {}) => { + calls.push({ method, params: clone(params) }); + if (method === "Runtime.evaluate" && params.expression === "document.visibilityState") { + const state = visibility[Math.min(probes++, visibility.length - 1)]; + if (state instanceof Error) throw state; + return { result: { type: "string", value: state } }; + } + return {}; + }; + return { w, chrome, calls, inputs: () => calls.filter((c) => c.method.startsWith("Input.")) }; +} + +// ---- hidden-tab guard ---- +test("typing into a hidden tab fails fast with the reason instead of silently dropping keys", async () => { + const h = worker({ visibility: ["hidden"] }); + await assert.rejects(h.w.dispatch("page.type", { selector: "#q", text: "hi", background: true }), (error) => { + assert.match(error.message, /chrome\.type: tab 2 is hidden because it is an inactive tab in its window/); + assert.match(error.message, /background:false/); + return true; + }); + assert.equal(h.inputs().length, 0, "no trusted input sent to a hidden page"); +}); + +for (const [action, params] of [["key", { key: "Enter" }], ["hover", { x: 1, y: 1 }], ["scroll", { deltaY: 10 }], ["tap", { x: 1, y: 1 }], ["drag", { fromX: 1, fromY: 1, toX: 2, toY: 2 }]]) { + test(`${action} refuses hidden tabs before sending input`, async () => { + const h = worker({ visibility: ["hidden"] }); + await assert.rejects(h.w.dispatch(`page.${action}`, { ...params, background: true }), new RegExp(`chrome\\.${action}: tab 2 is hidden`)); + assert.equal(h.inputs().length, 0); + }); +} + +test("click and fill use their DOM fallback immediately for hidden tabs, or reject with domFallback:false", async () => { + const click = worker({ visibility: ["hidden"] }); + const clicked = await click.w.dispatch("page.click", { selector: "button", background: true }); + assert.equal(clicked.input, "dom-fallback"); + assert.match(clicked.reason, /hidden/); + assert.equal(click.inputs().length, 0); + await assert.rejects(worker({ visibility: ["hidden"] }).w.dispatch("page.click", { selector: "button", background: true, domFallback: false }), /is hidden/); + + const fill = worker({ visibility: ["hidden"] }); + fill.w.domFillFallback = async (_tabId, _params, cause) => ({ input: "dom-fallback", reason: String(cause.message) }); + const filled = await fill.w.dispatch("page.fill", { selector: "#q", text: "x", background: true }); + assert.equal(filled.input, "dom-fallback"); + assert.match(filled.reason, /hidden/); + assert.equal(fill.inputs().length, 0); +}); + +test("hidden reasons distinguish minimized and macOS full-screen windows", async () => { + const minimized = worker({ visibility: ["hidden"], win: { id: 1, state: "minimized" } }); + await assert.rejects(minimized.w.dispatch("page.key", { key: "a", background: true }), /its window is minimized/); + const fullscreen = worker({ visibility: ["hidden"], tab: { id: 2, windowId: 1, active: true, url: "https://fixture.test/" }, win: { id: 1, state: "fullscreen" } }); + await assert.rejects(fullscreen.w.dispatch("page.key", { key: "a", background: true }), (error) => { + assert.match(error.message, /behind a full-screen window/); + assert.equal(error.windowState, "fullscreen"); + return true; + }); +}); + +test("visible pages, failed probes, and foreground tabs that become visible proceed with trusted input", async () => { + const visible = worker(); + assert.equal((await visible.w.dispatch("page.key", { key: "a", background: true })).input, "chrome"); + const unknown = worker({ visibility: [new Error("probe failed")] }); + assert.equal((await unknown.w.dispatch("page.key", { key: "a", background: true })).input, "chrome"); + const foreground = worker({ visibility: ["hidden", "hidden", "visible"] }); + foreground.w.bringToFront = async (tab) => tab; + assert.equal((await foreground.w.dispatch("page.key", { key: "a", foreground: true })).input, "chrome"); +}); + +// ---- automation target placement ---- +test("automation targets use a background tab, not a new window, while the user's window is full screen", async () => { + const full = worker({ win: { id: 1, state: "fullscreen" } }); + await full.w.createAutomationTarget("s1", undefined); + assert.equal(full.calls.some((c) => c.method === "windows.create"), false); + assert.deepEqual(clone(full.calls.find((c) => c.method === "tabs.create").props), { url: "about:blank", active: false }); + + const normal = worker({ win: { id: 1, state: "maximized" } }); + await normal.w.createAutomationTarget("s2", undefined); + assert.deepEqual(clone(normal.calls.find((c) => c.method === "windows.create").props), { url: "about:blank", focused: false }); +}); + +// ---- input.debug ---- +test("input.debug without a target does not call tabs.get(-1) and reports the session's own tab", async () => { + const h = worker(); + h.chrome.tabs.get = async (id) => { + if (!Number.isInteger(id) || id < 0) throw new Error("Value must be at least 0."); + return id === 2 ? { id: 2, windowId: 1, url: "https://fixture.test/" } : Promise.reject(new Error("missing")); + }; + const none = await h.w.dispatch("input.debug", {}); + assert.equal(none.resolvedTab, null); + h.w.resolveOwnedAutomationTarget = async () => ({ id: 2 }); + const owned = await h.w.dispatch("input.debug", { sessionKey: "s" }); + assert.equal(owned.resolvedTab.id, 2); +}); + +// ---- upload targets ---- +function uploadWorker({ visibility = "visible", chooserMode = "selectMultiple", opensChooser = true } = {}) { + const h = worker({ visibility: [visibility] }); + const fileInput = { tagName: "INPUT", type: "file", multiple: true, isConnected: true }; + const frameInput = { tagName: "INPUT", type: "file", multiple: false, isConnected: true }; + const button = { tagName: "BUTTON", isConnected: true, querySelectorAll: () => [] }; + const label = { tagName: "LABEL", control: fileInput, isConnected: true, querySelectorAll: () => [] }; + const dropzone = { tagName: "DIV", isConnected: true, querySelectorAll: () => [fileInput] }; + const frameDoc = { querySelector: (s) => (s === "#frame-file" ? frameInput : null), querySelectorAll: () => [] }; + const doc = { + querySelector: (s) => ({ "#file": fileInput, "#button": button, "#label": label, "#drop": dropzone }[s] ?? null), + querySelectorAll: (s) => (s === "iframe,frame" ? [{ contentDocument: frameDoc }] : []), + }; + const page = vm.createContext({ document: doc, __PI_CHROME_STATE__: { elements: {} } }); + page.window = page; + const objects = new Map(); + let nextObject = 1; + const base = h.w.cdp; + h.w.cdp = async (tabId, method, params = {}) => { + if (method === "Runtime.evaluate" && params.objectGroup === "pi-chrome-upload") { + h.calls.push({ method, params: {} }); + try { + const el = vm.runInContext(params.expression, page); + const objectId = `obj-${nextObject++}`; + objects.set(objectId, el); + return { result: { objectId } }; + } catch (error) { + return { exceptionDetails: { text: error.message } }; + } + } + const result = await base(tabId, method, params); + if (method === "Runtime.callFunctionOn" && /getBoundingClientRect/.test(params.functionDeclaration)) { + const el = objects.get(params.objectId); + const isFile = el.tagName === "INPUT" && el.type === "file"; + return { result: { value: { tag: el.tagName, isFile, multiple: isFile ? el.multiple : undefined, inFrame: el === frameInput, x: 50, y: 60, width: 80, height: 20 } } }; + } + const clicked = (method === "Input.dispatchMouseEvent" && params.type === "mouseReleased") || (method === "Runtime.callFunctionOn" && params.userGesture === true); + if (clicked && opensChooser) setTimeout(() => h.w.handleDebuggerEvent({ tabId: 2 }, "Page.fileChooserOpened", { backendNodeId: 42, mode: chooserMode }), 0); + return result; + }; + h.w.waitForFileChooser = ((original) => (tabId) => original(tabId, 200))(h.w.waitForFileChooser); + return h; +} +const setFiles = (h) => h.calls.filter((c) => c.method === "DOM.setFileInputFiles").map((c) => c.params); + +test("upload resolves labels and single-input wrappers to the file input without clicking", async () => { + for (const selector of ["#file", "#label", "#drop"]) { + const h = uploadWorker(); + const result = await h.w.dispatch("page.upload", { selector, paths: ["/a.txt", "/b.txt"], background: true }); + assert.equal(result.mode, "file-input", selector); + assert.equal(setFiles(h).length, 1); + assert.equal(h.inputs().length, 0, "no click for a real file input"); + } +}); + +test("upload finds file inputs inside same-origin iframes and enforces single-file inputs", async () => { + const h = uploadWorker(); + const result = await h.w.dispatch("page.upload", { selector: "#frame-file", paths: ["/a.txt"], background: true }); + assert.equal(result.mode, "file-input"); + assert.equal(result.inFrame, true); + await assert.rejects(uploadWorker().w.dispatch("page.upload", { selector: "#frame-file", paths: ["/a.txt", "/b.txt"], background: true }), /accepts one file but 2 paths/); +}); + +test("upload buttons open an intercepted file chooser with a trusted click on visible pages", async () => { + const h = uploadWorker(); + const result = await h.w.dispatch("page.upload", { selector: "#button", paths: ["/a.txt"], background: true }); + assert.equal(result.mode, "file-chooser"); + assert.equal(result.trigger, "chrome"); + assert.deepEqual(clone(setFiles(h)), [{ backendNodeId: 42, files: ["/a.txt"] }]); + assert.deepEqual(h.calls.filter((c) => c.method === "Page.setInterceptFileChooserDialog").map((c) => c.params.enabled), [true, false]); + assert.ok(h.inputs().some((c) => c.params.type === "mousePressed")); +}); + +test("hidden pages use a user-gesture element.click() for upload buttons unless domFallback:false", async () => { + const h = uploadWorker({ visibility: "hidden" }); + const result = await h.w.dispatch("page.upload", { selector: "#button", paths: ["/a.txt"], background: true }); + assert.equal(result.trigger, "activation-click"); + assert.match(result.reason, /hidden/); + assert.equal(h.inputs().length, 0); + const strict = uploadWorker({ visibility: "hidden" }); + await assert.rejects(strict.w.dispatch("page.upload", { selector: "#button", paths: ["/a.txt"], background: true, domFallback: false }), /is hidden/); + assert.equal(setFiles(strict).length, 0); +}); + +test("chooser failures attach nothing and always disable interception", async () => { + const single = uploadWorker({ chooserMode: "selectSingle" }); + await assert.rejects(single.w.dispatch("page.upload", { selector: "#button", paths: ["/a.txt", "/b.txt"], background: true }), /accepts one file/); + assert.equal(setFiles(single).length, 0); + const none = uploadWorker({ opensChooser: false }); + await assert.rejects(none.w.dispatch("page.upload", { selector: "#button", paths: ["/a.txt"], background: true }), /no file chooser opened/); + assert.deepEqual(none.calls.filter((c) => c.method === "Page.setInterceptFileChooserDialog").map((c) => c.params.enabled), [true, false]); + assert.equal(none.calls.at(-1).method, "Runtime.releaseObject"); +}); + +test("file chooser events attached by targetId route to the right tab", async () => { + const h = worker(); + vm.runInContext('attachedTabs.set(5, { debuggee: { targetId: "T5" } })', h.w); + const pending = h.w.waitForFileChooser(5, 1000); + h.w.handleDebuggerEvent({ targetId: "T5" }, "Page.fileChooserOpened", { backendNodeId: 7, mode: "selectSingle" }); + assert.deepEqual(clone(await pending.promise), { backendNodeId: 7, mode: "selectSingle" }); +}); + +// ---- screenshot pruning ---- +const pruneSource = indexSource.slice(indexSource.indexOf("const SCREENSHOT_NAME_RE"), indexSource.indexOf("// End screenshot pruning.")); +const prune = vm.runInNewContext(`${stripTypeScriptTypes(pruneSource)}; ({ screenshotFilesToPrune, pruneScreenshotDir })`, { + readdir: fs.promises.readdir, unlink: fs.promises.unlink, join: path.join, Date, +}); +const DAY = 24 * 60 * 60 * 1000; +const stamp = (ms) => new Date(ms).toISOString().replace(/[:.]/g, "-"); + +test("screenshot pruning keeps the newest 20 captures and anything inside the retention window", () => { + const now = Date.parse("2026-09-26T12:00:00.000Z"); + const names = []; + for (let i = 0; i < 25; i++) names.push(`${stamp(now - (30 + i) * DAY)}.png`); + const fullPage = stamp(now - 60 * DAY); + names.push(`${fullPage}-tile0.png`, `${fullPage}-tile1.png`, `${fullPage}.png.json`); + names.push(`${stamp(now - DAY)}.jpeg`, "my-notes.png", "2026-01-01.png", "keep.json"); + const pruned = prune.screenshotFilesToPrune(names, now); + // 25 old singles + 1 full-page capture + 1 recent = 27 captures; the newest 20 survive. The recent + // capture is newest, so 19 old singles survive and 6 old singles + the full-page capture go. + assert.equal(pruned.length, 6 + 3); + for (const suffix of ["-tile0.png", "-tile1.png", ".png.json"]) assert.ok(pruned.includes(`${fullPage}${suffix}`)); + for (const name of ["my-notes.png", "2026-01-01.png", "keep.json", `${stamp(now - DAY)}.jpeg`]) assert.ok(!pruned.includes(name), name); + + const recent = Array.from({ length: 30 }, (_, i) => `${stamp(now - i * 60_000)}.png`); + assert.equal(prune.screenshotFilesToPrune(recent, now).length, 0, "nothing inside the retention window is removed"); + assert.equal(prune.screenshotFilesToPrune(names, now, 0).length, 0, "retentionDays:0 disables pruning"); + assert.equal(prune.screenshotFilesToPrune(names, now, 55).length, 3, "a longer window keeps more"); +}); + +test("pruneScreenshotDir deletes only eligible files and never throws", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-chrome-shots-")); + const now = Date.now(); + for (let i = 0; i < 22; i++) fs.writeFileSync(path.join(dir, `${stamp(now - (10 + i) * DAY)}.png`), "x"); + fs.writeFileSync(path.join(dir, "mine.png"), "x"); + assert.equal(await prune.pruneScreenshotDir(dir), 2); + assert.equal(fs.readdirSync(dir).length, 21); + assert.ok(fs.existsSync(path.join(dir, "mine.png"))); + assert.equal(await prune.pruneScreenshotDir(path.join(dir, "missing")), 0); + fs.rmSync(dir, { recursive: true, force: true }); +}); diff --git a/test-suite/unit/input-reliability.test.mjs b/test-suite/unit/input-reliability.test.mjs index 1329ed7..4a47e9c 100644 --- a/test-suite/unit/input-reliability.test.mjs +++ b/test-suite/unit/input-reliability.test.mjs @@ -17,6 +17,14 @@ function harness({ tag = "DIV", editable = true, initial = "", nodeId = 7 } = {} isContentEditable: editable, isConnected: true, textContent: initial, scrollIntoView() {}, getBoundingClientRect: () => ({ left: 10, top: 10, width: 200, height: 60 }), contains: (el) => el === element, + querySelectorAll: () => [], + listeners: [], events: [], + addEventListener(type, fn) { this.listeners.push({ type, fn }); }, + dispatchEvent(event) { + this.events.push(event.type); + for (const l of this.listeners.splice(0)) if (l.type === event.type) l.fn(event); else this.listeners.push(l); + return true; + }, }; const selection = { removeAllRanges() { selectAll = false; }, @@ -33,6 +41,9 @@ function harness({ tag = "DIV", editable = true, initial = "", nodeId = 7 } = {} __PI_CHROME_STATE__: { elements: { "el-1": element } }, }); page.window = page; + page.document.defaultView = page; + page.document.querySelectorAll = () => []; + element.ownerDocument = page.document; const listener = { addListener() {}, removeListener() {} }; const chrome = { runtime: { id: "test", getManifest: () => ({ version: "0.0.0" }), onInstalled: listener, onStartup: listener }, @@ -71,8 +82,15 @@ function harness({ tag = "DIV", editable = true, initial = "", nodeId = 7 } = {} return { exceptionDetails: { text: error.message, exception: { description: error.message } } }; } } + if (method === "Runtime.callFunctionOn" && params.objectId === "upload-object") { + const fn = vm.runInContext(`(${params.functionDeclaration})`, page); + return { result: { value: clone(fn.call(element) ?? null) } }; + } if (method === "DOM.requestNode") return h.nodeResult; - if (method === "DOM.setFileInputFiles") files.push(...params.files); + if (method === "DOM.setFileInputFiles") { + files.push(...params.files); + if (h.nativeChange) { element.dispatchEvent({ type: "input" }); element.dispatchEvent({ type: "change" }); } + } if (method === "Input.insertText") element.textContent += params.text; if (method === "Input.dispatchKeyEvent" && params.type === "keyDown") { if (params.key === "Delete") { @@ -88,6 +106,7 @@ function harness({ tag = "DIV", editable = true, initial = "", nodeId = 7 } = {} element.textContent = params.text; return { input: "dom-fallback" }; }; + page.Event = class { constructor(type) { this.type = type; } }; const h = { worker, chrome, page, calls, element, selected, files, tabLookups, failures: new Map(), nodeResult: { nodeId }, call: (action, params = {}) => worker.dispatch(`page.${action}`, { targetId: "2", background: true, ...params }), @@ -100,6 +119,7 @@ for (const [name, nodeResult] of [["node ID", { nodeId: 7 }], ["zero node ID", { test(`upload accepts ${name} and releases the remote object`, async () => { const h = harness({ tag: "INPUT", editable: false }); h.element.type = "file"; + h.element.multiple = true; h.nodeResult = nodeResult; const result = await h.call("upload", { uid: "el-1", paths: ["/tmp/a.txt", "/tmp/b.txt"] }); const target = nodeResult?.nodeId ? { nodeId: 7 } : { objectId: "upload-object" }; @@ -112,6 +132,17 @@ for (const [name, nodeResult] of [["node ID", { nodeId: 7 }], ["zero node ID", { }); } +test("upload never duplicates the change event Chrome already fired", async () => { + for (const nativeChange of [true, false]) { + const h = harness({ tag: "INPUT", editable: false }); + h.element.type = "file"; + h.nativeChange = nativeChange; + const result = await h.call("upload", { selector: "#target", paths: ["/tmp/a.txt"] }); + assert.equal(result.events, nativeChange ? "native" : "dispatched"); + assert.deepEqual(h.element.events.filter((t) => t === "change"), ["change"], `exactly one change (native=${nativeChange})`); + } +}); + test("upload falls back on requestNode failure, not on a failed file attachment", async () => { const h = harness({ tag: "INPUT", editable: false }); h.element.type = "file"; @@ -124,7 +155,7 @@ test("upload falls back on requestNode failure, not on a failed file attachment" await assert.rejects(h.call("upload", { selector: "#target", paths: ["/tmp/a.txt"] }), /attachment denied/); assert.equal(h.commands("DOM.setFileInputFiles").length, 1, "no object-ID retry after an uncertain file attachment"); assert.equal(h.commands("Runtime.releaseObject").length, 1, "cleanup still runs on error"); - assert.equal(h.commands("Runtime.callFunctionOn").length, 0, "no notification after failed attachment"); + assert.equal(h.commands("Runtime.callFunctionOn").filter((c) => /dispatchEvent/.test(c.params.functionDeclaration)).length, 0, "no notification after failed attachment"); }); test("upload releases valid node-path references on failure and tolerates notification/cleanup failure", async () => { @@ -134,14 +165,16 @@ test("upload releases valid node-path references on failure and tolerates notifi await assert.rejects(h.call("upload", { selector: "#target", paths: ["/tmp/a.txt"] }), /file inaccessible/); assert.equal(h.commands("Runtime.releaseObject").length, 1); h.failures.delete("DOM.setFileInputFiles"); - for (const method of ["DOM.enable", "Runtime.callFunctionOn", "Runtime.releaseObject"]) h.failures.set(method, "optional step failed"); + for (const method of ["DOM.enable", "Runtime.releaseObject"]) h.failures.set(method, "optional step failed"); const result = await h.call("upload", { selector: "#target", paths: ["/tmp/a.txt"] }); assert.equal(result.input, "chrome"); }); -test("upload rejects non-file elements, missing targets/paths, and stale UIDs without attaching anything", async () => { +test("upload rejects non-trigger elements, missing targets/paths, and stale UIDs without attaching anything", async () => { const h = harness(); - await assert.rejects(h.call("upload", { selector: "#target", paths: ["/tmp/a.txt"] }), /Target must be <input type=file>/); + h.worker.waitForFileChooser = () => ({ promise: Promise.reject(new Error("chrome.upload: no file chooser opened after clicking the target.")), cancel() {} }); + await assert.rejects(h.call("upload", { selector: "#target", paths: ["/tmp/a.txt"] }), /no file chooser opened/); + assert.deepEqual(h.commands("Page.setInterceptFileChooserDialog").map((c) => c.params.enabled), [true, false], "intercept is always disabled again"); await assert.rejects(h.call("upload", { paths: ["/tmp/a.txt"] }), /selector or uid required/); await assert.rejects(h.call("upload", { selector: "#target", paths: [] }), /no file paths/); h.element.tagName = "INPUT"; @@ -158,7 +191,7 @@ for (const target of [{ selector: "#target" }, { uid: "el-1" }, {}]) { const h = harness(); const result = await h.call("type", { ...target, text: caption }); assert.deepEqual(h.commands("Input.insertText").map((c) => c.params), [{ text: caption }]); - assert.equal(h.commands("Input.dispatchKeyEvent").length, 0); + assert.equal(h.commands("Input.dispatchKeyEvent").filter((c) => c.params.key !== "End").length, 0); assert.equal(h.element.textContent, caption); assert.equal(result.input, "chrome"); assert.equal(result.length, caption.length); diff --git a/test-suite/unit/type-evidence.test.mjs b/test-suite/unit/type-evidence.test.mjs index f0a51c1..22393be 100644 --- a/test-suite/unit/type-evidence.test.mjs +++ b/test-suite/unit/type-evidence.test.mjs @@ -59,6 +59,7 @@ function harness(element, { platform = "MacIntel", tabs = {} } = {}) { calls.push({ method, params: clone(params) }); if (method === "Input.dispatchKeyEvent") { if ((params.commands || []).includes("selectAll")) { element.selectionStart = 0; element.selectionEnd = element.value.length; } + else if ((params.commands || []).includes("moveToEndOfDocument") && typeof element.value === "string") { element.selectionStart = element.selectionEnd = element.value.length; } else if (params.type === "keyDown" && params.key === "Delete") { if (element.selectionEnd > element.selectionStart) splice(""); } else if (params.type === "keyDown" && params.text && params.key !== "Enter") splice(params.text); @@ -68,9 +69,29 @@ function harness(element, { platform = "MacIntel", tabs = {} } = {}) { return { worker, calls, element, listeners, fire: (tabId, info) => { for (const fn of [...listeners]) fn(tabId, info); } }; } -test("typing into the middle of a prefilled field reports the splice instead of a bare count", async () => { +test("a targeted chrome_type moves the caret to the end after its focus click, so it appends", async () => { const h = harness(makeInput({ value: "why do flamingos stand", caret: 7 })); - const result = await h.worker.dispatch("page.type", { targetId: "2", selector: "#q", text: "cats " }); + const result = await h.worker.dispatch("page.type", { targetId: "2", selector: "#q", text: " still" }); + assert.equal(h.element.value, "why do flamingos stand still"); + assert.equal(result.insertedAt, "caret-end"); + const moves = h.calls.filter((c) => (c.params.commands || []).includes("moveToEndOfDocument")); + assert.equal(moves.length, 1); + assert.equal(h.calls.indexOf(moves[0]) > h.calls.findIndex((c) => c.method === "Input.dispatchMouseEvent" && c.params.type === "mouseReleased"), true, "caret moves after the focus click"); +}); + +test("replace=true does not move the caret first; untargeted typing keeps the user's caret", async () => { + const replace = harness(makeInput({ value: "old", caret: 1 })); + await replace.worker.dispatch("page.type", { targetId: "2", selector: "#q", text: "new", replace: true }); + assert.equal(replace.calls.some((c) => (c.params.commands || []).includes("moveToEndOfDocument")), false); + const focused = harness(makeInput({ value: "abc", caret: 1 })); + await focused.worker.dispatch("page.type", { targetId: "2", text: "X" }); + assert.equal(focused.calls.some((c) => (c.params.commands || []).includes("moveToEndOfDocument")), false); + assert.equal(focused.element.value, "aXbc"); +}); + +test("typing into the middle of a focused prefilled field reports the splice instead of a bare count", async () => { + const h = harness(makeInput({ value: "why do flamingos stand", caret: 7 })); + const result = await h.worker.dispatch("page.type", { targetId: "2", text: "cats " }); assert.equal(h.element.value, "why do cats flamingos stand"); assert.equal(result.valueBefore, "why do flamingos stand"); assert.equal(result.valueAfter, "why do cats flamingos stand"); |
