aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md10
-rw-r--r--README.md6
-rw-r--r--extensions/chrome-profile-bridge/browser-extension/manifest.json2
-rw-r--r--extensions/chrome-profile-bridge/browser-extension/service_worker.js108
-rw-r--r--extensions/chrome-profile-bridge/index.ts8
-rw-r--r--package-lock.json4
-rw-r--r--package.json4
-rw-r--r--test-suite/README.md9
-rw-r--r--test-suite/challenges/21-keyboard-modifiers.html7
-rw-r--r--test-suite/challenges/44-input-reliability.html105
-rw-r--r--test-suite/manifest.json46
-rw-r--r--test-suite/unit/background-policy.test.mjs20
-rw-r--r--test-suite/unit/input-reliability.test.mjs401
13 files changed, 691 insertions, 39 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a5b8675..c40b76e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,16 @@
All notable user-facing changes to `pi-chrome`.
+## 0.15.49 — 2026-09-10
+
+- **Validation scope.** Node regression suites passed. Live browser validation remains incomplete: an input attempt encountered `Input.dispatchMouseEvent: Detached while handling command.`; a subsequent retest was blocked by a disconnected companion. No live-browser pass is claimed for these changes.
+
+- **Upload node fallback.** When Chrome cannot convert a file input's remote object to a DOM node ID, use that same `objectId` directly. Release the remote object after success or failure and reject stale snapshot UIDs. No native file picker or new permissions.
+- **Native rich-editor insertion.** `chrome_type` and `chrome_fill` use one CDP `Input.insertText` for focused contenteditables. Inputs/textareas keep individual key events; `perCharacter:true` preserves that path for rich editors needing `keydown` events. Results report `typing` as `insertText`, `keys`, or `none`. Existing DOM-fallback controls and background policy remain intact.
+- **Complete rich-editor fill and single Enter.** Select all requested contenteditable contents before deletion, not only one paragraph. `pressEnter` sends one Enter instead of two; Enter after type/fill stays pinned to the resolved tab.
+- **Shift-only typing.** Shift+a, Shift+1, and other US-layout printable chords now carry shifted text. Ctrl/Meta/Alt shortcuts still suppress literal insertion.
+- **Input regressions.** Added worker fault-injection tests and challenge 44. Challenge 16 explicitly tests per-character typing; challenge 21 waits for Shift release before grading.
+
## 0.15.48 — 2026-09-09
- **Existing background mode is now hard background.** `/chrome background on` (still the default) overrides per-call foreground requests, keeps new tabs inactive, and blocks `chrome_tab activate`. Use the existing `/chrome background off` for foreground/watch mode; no new command or lock state.
diff --git a/README.md b/README.md
index 094dfe6..a6774bc 100644
--- a/README.md
+++ b/README.md
@@ -74,6 +74,12 @@ Second doctor run should show all checks passing.
Tool parameters and gotchas are documented inline in Pi.
+### Typing into rich editors
+
+`chrome_type` and `chrome_fill` use one native CDP `Input.insertText` operation for focused contenteditables. This avoids per-character delays for long text and preserves Unicode/newlines. Ordinary inputs and textareas retain individual key events.
+
+For an editor that needs a `keydown` for every character, pass `perCharacter:true`. Bulk insertion still uses Chrome's input system, but does not emit per-character key events or a clipboard `paste` event. Use `includeSnapshot:true` to verify the result; `chrome_fill` still honors `domFallback:false` when synthetic fallback is unwanted.
+
---
## Safety model
diff --git a/extensions/chrome-profile-bridge/browser-extension/manifest.json b/extensions/chrome-profile-bridge/browser-extension/manifest.json
index 38d1b7f..2fd4915 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.48",
+ "version": "0.15.49",
"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 fd6c452..8ba0e2d 100644
--- a/extensions/chrome-profile-bridge/browser-extension/service_worker.js
+++ b/extensions/chrome-profile-bridge/browser-extension/service_worker.js
@@ -681,7 +681,13 @@ function cdpKeyInfo(key, shifted) {
};
if (SPECIAL[key]) return { key, ...SPECIAL[key] };
if (key.length === 1) {
- const ch = key;
+ // Explicit Shift chords need shifted text as well as a modifier bit. CDP does
+ // not derive printable text from code/windowsVirtualKeyCode for us.
+ const SHIFTED = {
+ "`": "~", "1": "!", "2": "@", "3": "#", "4": "$", "5": "%", "6": "^", "7": "&", "8": "*", "9": "(", "0": ")",
+ "-": "_", "=": "+", "[": "{", "]": "}", "\\": "|", ";": ":", "'": "\"", ",": "<", ".": ">", "/": "?",
+ };
+ const ch = shifted ? (/^[a-z]$/.test(key) ? key.toUpperCase() : SHIFTED[key] || key) : key;
const layout = usKeyLayoutForChar(ch);
return { key: ch, code: layout.code, windowsVirtualKeyCode: layout.keyCode, text: ch };
}
@@ -809,13 +815,14 @@ async function chromeInputKey(params) {
await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyDown", key: m.key, code: m.code, windowsVirtualKeyCode: m.vk, modifiers: modBits });
await sleep(rng(6, 18));
}
- const info = cdpKeyInfo(key);
- // When modifiers are active, browsers usually emit "rawKeyDown" (no text) so chords like Cmd+V don't insert the literal char.
- const downType = modBits ? "rawKeyDown" : "keyDown";
+ const info = cdpKeyInfo(key, mods.shiftKey);
+ // Ctrl/Meta/Alt chords must not insert literal text (e.g. Cmd+V). Shift alone
+ // still types: Shift+a -> A, Shift+1 -> !, and Shift+Enter carries a newline.
+ const shortcut = !!(mods.ctrlKey || mods.metaKey || mods.altKey);
await cdp(tab.id, "Input.dispatchKeyEvent", {
- type: downType, key: info.key, code: info.code,
+ type: shortcut ? "rawKeyDown" : "keyDown", key: info.key, code: info.code,
windowsVirtualKeyCode: info.windowsVirtualKeyCode, nativeVirtualKeyCode: info.windowsVirtualKeyCode,
- text: modBits ? "" : info.text, unmodifiedText: modBits ? "" : info.text, modifiers: modBits,
+ text: shortcut ? "" : info.text, unmodifiedText: shortcut ? "" : info.text, modifiers: modBits,
});
await sleep(rng(25, 90));
await cdp(tab.id, "Input.dispatchKeyEvent", {
@@ -829,6 +836,49 @@ async function chromeInputKey(params) {
return { input: "chrome", key: info.key, modifiers: mods };
}
+// Read the actual focused editor, not a role=textbox lookalike. For fill, select
+// the requested editor's entire contents: triple-click only selects a paragraph.
+// Selection uses the DOM; deletion and insertion still use Chrome's input layer.
+async function contentEditableInTab(tabId, selectAllParams = null) {
+ const results = await executeScriptTimed({
+ target: { tabId, frameIds: [0] },
+ world: "MAIN",
+ func: (selector, uid, selectAll) => {
+ const active = document.activeElement;
+ if (selectAll) {
+ const state = window.__PI_CHROME_STATE__;
+ const el = uid ? state?.elements?.[uid] : document.querySelector(selector);
+ if (uid && (!el || !el.isConnected)) throw new Error(`snapshot uid ${uid} is stale; refresh chrome_snapshot`);
+ if (!el?.isContentEditable) return false;
+ if (!active?.isContentEditable || !(el === active || el.contains(active) || active.contains(el))) {
+ throw new Error("chrome.fill: requested contenteditable is not focused");
+ }
+ const selection = window.getSelection();
+ if (!selection) throw new Error("Could not select contenteditable contents");
+ const range = document.createRange();
+ range.selectNodeContents(el);
+ selection.removeAllRanges();
+ selection.addRange(range);
+ }
+ return active?.isContentEditable === true;
+ },
+ args: [selectAllParams?.selector ?? null, selectAllParams?.uid ?? null, selectAllParams !== null],
+ }, `inspect contenteditable in tab ${tabId}`);
+ return results?.[0]?.result === true;
+}
+
+async function typeTextInTab(tabId, text, perCharacter) {
+ if (!text) return "none";
+ if (!perCharacter && await contentEditableInTab(tabId)) {
+ // One native edit avoids per-character delays and rich-editor render races.
+ // Do not retry as keystrokes if insertion fails: it may already have applied.
+ await cdp(tabId, "Input.insertText", { text });
+ return "insertText";
+ }
+ for (const ch of Array.from(text)) await cdpTypeChar(tabId, ch);
+ return "keys";
+}
+
async function chromeInputType(params) {
const tab = await getTabByParams(params);
await bringToFront(tab, params);
@@ -844,12 +894,9 @@ async function chromeInputType(params) {
await sleep(rng(50, 120));
}
const text = String(params.text || "");
- for (const ch of Array.from(text)) await cdpTypeChar(tab.id, ch);
- if (params.pressEnter) {
- await cdpTypeChar(tab.id, "\r").catch(() => undefined);
- await chromeInputKey({ ...params, key: "Enter" });
- }
- return { input: "chrome", length: text.length };
+ const typing = await typeTextInTab(tab.id, text, params.perCharacter);
+ if (params.pressEnter) await chromeInputKey({ ...params, targetId: tab.id, key: "Enter" });
+ return { input: "chrome", length: text.length, typing };
}
async function domFillFallback(tabId, params, cause) {
@@ -908,14 +955,15 @@ async function chromeInputFill(params) {
await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: i, pointerType: "mouse" });
await sleep(rng(20, 60));
}
+ await contentEditableInTab(tab.id, params);
// Delete selection.
await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyDown", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyUp", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
await sleep(rng(20, 60));
const text = String(params.text || "");
- for (const ch of Array.from(text)) await cdpTypeChar(tab.id, ch);
- if (params.submit) await chromeInputKey({ ...params, key: "Enter" });
- return { input: "chrome", length: text.length };
+ const typing = await typeTextInTab(tab.id, text, params.perCharacter);
+ if (params.submit) await chromeInputKey({ ...params, targetId: tab.id, key: "Enter" });
+ return { input: "chrome", length: text.length, typing };
} catch (error) {
if (params.domFallback === false) throw error;
return domFillFallback(tab.id, params, error);
@@ -1024,25 +1072,31 @@ async function chromeInputUpload(params) {
const selector = ${JSON.stringify(params.selector ?? null)};
const uid = ${JSON.stringify(params.uid ?? null)};
const state = window.__PI_CHROME_STATE__;
- const el = uid && state && state.elements ? state.elements[uid] : (selector ? document.querySelector(selector) : null);
+ 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(evaluated.exceptionDetails.text || "Could not resolve file input");
+ if (evaluated.exceptionDetails) throw new Error(cdpExceptionText(evaluated.exceptionDetails) || "Could not resolve file input");
const objectId = evaluated.result?.objectId;
if (!objectId) throw new Error("Could not resolve file input object");
- await cdp(tab.id, "DOM.enable", {}).catch(() => undefined);
- const requested = await cdp(tab.id, "DOM.requestNode", { objectId });
- if (!requested.nodeId) throw new Error("Could not resolve file input node");
- await cdp(tab.id, "DOM.setFileInputFiles", { nodeId: requested.nodeId, 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);
- await cdp(tab.id, "Runtime.releaseObject", { objectId }).catch(() => undefined);
+ 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);
+ } finally {
+ await cdp(tab.id, "Runtime.releaseObject", { objectId }).catch(() => undefined);
+ }
return { input: "chrome", uploaded: paths.map((path) => ({ path })) };
}
// ===============================================================
diff --git a/extensions/chrome-profile-bridge/index.ts b/extensions/chrome-profile-bridge/index.ts
index 9546b9c..40a353a 100644
--- a/extensions/chrome-profile-bridge/index.ts
+++ b/extensions/chrome-profile-bridge/index.ts
@@ -1564,12 +1564,13 @@ Usage rules:
name: "chrome_type",
label: "Chrome Type",
description:
- "Focus an optional snapshot uid or CSS selector, then type text using Chrome's real keyboard input. Pass includeSnapshot=true to return a fresh snapshot after typing.",
+ "Focus an optional snapshot uid or CSS selector, then type using Chrome's real input. Contenteditables use one native text insertion; other fields use key events. Set perCharacter=true for editors needing individual keydown events. Pass includeSnapshot=true to verify after typing.",
promptSnippet: "Type text into Chrome, optionally focusing a snapshot uid or selector first.",
parameters: Type.Object({
text: Type.String(),
uid: Type.Optional(Type.String({ description: "Stable element uid from chrome_snapshot." })),
selector: Type.Optional(Type.String({ description: "CSS selector to focus before typing." })),
+ 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." })),
includeSnapshot: Type.Optional(Type.Boolean({ description: "If true, include a fresh chrome_snapshot result after typing." })),
maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." })),
pressEnter: Type.Optional(Type.Boolean()),
@@ -1595,12 +1596,13 @@ Usage rules:
name: "chrome_fill",
label: "Chrome Fill",
description:
- "Set the full value of a text input, textarea, or contenteditable element using Chrome click/select/delete/type input. Accepts a snapshot uid or CSS selector. Pass includeSnapshot=true to verify after filling.",
+ "Set the full value of a text input, textarea, or contenteditable using Chrome click/select/delete/type input. Contenteditables use one native text insertion; perCharacter=true retains individual keydown events. Accepts a snapshot uid or CSS selector. Pass includeSnapshot=true to verify after filling.",
promptSnippet: "Fill a Chrome form field by snapshot uid or selector, optionally returning a fresh snapshot.",
parameters: Type.Object({
text: Type.String(),
uid: Type.Optional(Type.String({ description: "Stable element uid from chrome_snapshot." })),
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." })),
includeSnapshot: Type.Optional(Type.Boolean({ description: "If true, include a fresh chrome_snapshot result after filling." })),
@@ -1636,7 +1638,7 @@ Usage rules:
ctrlKey: Type.Optional(Type.Boolean()),
altKey: Type.Optional(Type.Boolean()),
metaKey: Type.Optional(Type.Boolean()),
- }, { description: "Modifier keys to hold while pressing the key (chord)." })),
+ }, { description: "Modifier keys to hold while pressing the key. Shift alone types the shifted US-layout character (a → A, 1 → !); Ctrl/Meta/Alt chords do not insert literal text." })),
includeSnapshot: Type.Optional(Type.Boolean({ description: "If true, include a fresh chrome_snapshot result after the keypress." })),
maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." })),
targetId: Type.Optional(Type.String()),
diff --git a/package-lock.json b/package-lock.json
index 1e05f8e..f7edadd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "pi-chrome",
- "version": "0.15.48",
+ "version": "0.15.49",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-chrome",
- "version": "0.15.48",
+ "version": "0.15.49",
"license": "MIT",
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*",
diff --git a/package.json b/package.json
index c1f3b84..0a705bb 100644
--- a/package.json
+++ b/package.json
@@ -1,8 +1,8 @@
{
"name": "pi-chrome",
- "version": "0.15.48",
+ "version": "0.15.49",
"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",
+ "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",
"version": "node scripts/sync-manifest-version.js",
"prepublishOnly": "node scripts/sync-manifest-version.js"
},
diff --git a/test-suite/README.md b/test-suite/README.md
index 59d2388..c7321a1 100644
--- a/test-suite/README.md
+++ b/test-suite/README.md
@@ -177,6 +177,15 @@ The dashboard renders this from `manifest.json`. In brief:
41. explicit tab lifecycle
42. strict CSP eval/snapshot via CDP (regression guard for the CSP bypass)
43. hard background: inactive-tab visibility and trusted input
+44. input reliability: native rich-editor insertion, full replacement, per-character override, Shift chords, single Enter, and file upload
+
+### Input regressions (16, 21, 31, 44)
+
+Challenge 44 exercises `chrome_type`/`chrome_fill` bulk insertion into contenteditables, including long Unicode text and multiple paragraphs. It also checks `perCharacter:true`, Shift-only printable keys, one Enter per `pressEnter`, and real file contents. Read `window.__inputFixture.caption` and follow its manifest recipe. Use `domFallback:false` to ensure failures are not hidden by synthetic events.
+
+Challenge 16 intentionally requests `perCharacter:true`: its grader requires a caret update for each keystroke. Challenge 21 focuses its field before sending Shift+a and grades after Shift release, not during the earlier input event. Challenge 31 retains standalone upload coverage.
+
+`npm test` also fault-injects zero/missing/rejected upload node IDs, attachment/cleanup failures, stale UIDs, insertion failures, shortcut suppression, and authorization/option forwarding. These mocked tests cannot prove `isTrusted` or real browser selection behavior; run the browser challenges too.
### Hard-background regression (43)
diff --git a/test-suite/challenges/21-keyboard-modifiers.html b/test-suite/challenges/21-keyboard-modifiers.html
index 870bef6..d18d484 100644
--- a/test-suite/challenges/21-keyboard-modifiers.html
+++ b/test-suite/challenges/21-keyboard-modifiers.html
@@ -30,9 +30,12 @@ for (const name of ["keydown","keypress","keyup","input"]) {
});
}
-t.addEventListener("input", () => {
- if (t.value !== "A") return;
+// input fires before keyup. Grade only when Shift is released so a valid chord
+// cannot fail merely because its release events have not happened yet.
+t.addEventListener("keyup", (event) => {
+ if (event.key !== "Shift") return;
const bad = [];
+ if (t.value !== "A") bad.push(`value=${JSON.stringify(t.value)} (need 'A')`);
const downShift = log.find(e => e.name === "keydown" && e.key === "Shift");
const downA = log.find(e => e.name === "keydown" && e.code === "KeyA");
const upShift = log.find(e => e.name === "keyup" && e.key === "Shift");
diff --git a/test-suite/challenges/44-input-reliability.html b/test-suite/challenges/44-input-reliability.html
new file mode 100644
index 0000000..7576376
--- /dev/null
+++ b/test-suite/challenges/44-input-reliability.html
@@ -0,0 +1,105 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>44 input reliability</title>
+<link rel="stylesheet" href="../_style.css">
+<script src="../_lib.js"></script>
+<style>
+ [contenteditable] { min-height: 48px; max-height: 180px; overflow: auto; padding: 10px; border: 1px solid #777; white-space: pre-wrap; }
+ label { display: block; margin-top: 20px; }
+ pre { white-space: pre-wrap; }
+</style>
+<body>
+<main>
+ <h1>Upload and text-input reliability</h1>
+ <p>Read <code>window.__inputFixture.caption</code>. Type it into the empty editor,
+ then fill the old multi-paragraph editor with the same text. Leave
+ <code>perCharacter</code> off for both. Native text insertion must preserve
+ Unicode, newlines, and literal markup without hundreds of key events.</p>
+ <details><summary>Exact caption (manual baseline: copy and paste into both editors)</summary><pre id="expected"></pre></details>
+ <label for="typed">Empty editor — chrome_type</label>
+ <div id="typed" role="textbox" aria-label="Empty editor" contenteditable="true"></div>
+ <label for="filled">Old paragraphs — chrome_fill must replace all of them</label>
+ <div id="filled" role="textbox" aria-label="Old paragraphs" contenteditable="true"><div>OLD first paragraph</div><div>OLD second paragraph</div><div>OLD third paragraph</div></div>
+ <label for="keys">Key-event editor — type Ab! with perCharacter:true</label>
+ <div id="keys" role="textbox" aria-label="Key-event editor" contenteditable="true"></div>
+ <label for="shift">Shift-only chords — Shift+a, Shift+1, Shift+/ must produce A!?</label>
+ <input id="shift" aria-label="Shift-only chords" autocomplete="off">
+ <form id="onceForm">
+ <label for="once">Type done with pressEnter:true — submit exactly once</label>
+ <input id="once" aria-label="Single Enter" autocomplete="off">
+ <button type="submit">Submit once</button>
+ </form>
+ <label for="file">Attach test-suite/fixtures/pi-chrome-upload.txt</label>
+ <input id="file" type="file">
+ <p>Node-ID conversion failure is fault-injected by the Node unit tests; this page
+ checks the real file contents and native upload event.</p>
+ <button id="verify" type="button">Verify all input</button>
+</main>
+<script>
+Challenge.init({ id: "input-reliability", instructions: "Complete six fields, then Verify all input" });
+window.__inputFixture = {
+ caption: Array.from({ length: 8 }, (_, i) => `${i + 1}. Caption: café 中文 👩🏽‍💻 — <b>plain text</b>. Keep spaces.\nSecond paragraph.`).join("\n\n"),
+};
+document.getElementById("expected").textContent = window.__inputFixture.caption;
+const metrics = {};
+const chords = [];
+for (const id of ["typed", "filled", "keys", "shift", "once", "file"]) {
+ const el = document.getElementById(id);
+ metrics[id] = { beforeinput: 0, input: 0, printableKeys: 0, nativeChange: 0 };
+ for (const name of ["keydown", "keyup", "beforeinput", "input", "change"]) {
+ el.addEventListener(name, (event) => {
+ Challenge.log(name, { id, key: event.key, code: event.code, trusted: event.isTrusted, inputType: event.inputType, dataLength: event.data?.length });
+ if (event.isTrusted && name === "beforeinput") metrics[id].beforeinput++;
+ if (event.isTrusted && name === "input") metrics[id].input++;
+ if (event.isTrusted && name === "change") metrics[id].nativeChange++;
+ if (name === "keydown" && event.key.length === 1 && !(event.metaKey || event.ctrlKey || event.altKey)) metrics[id].printableKeys++;
+ if (id === "shift" && (name === "keydown" || name === "keyup")) {
+ chords.push({ name, key: event.key, code: event.code, shiftKey: event.shiftKey, trusted: event.isTrusted });
+ }
+ });
+ }
+}
+let submits = 0;
+document.getElementById("onceForm").addEventListener("submit", (event) => {
+ event.preventDefault();
+ submits++;
+ Challenge.log("submit", { submits, trusted: event.isTrusted });
+});
+
+// innerText reflects contenteditable line breaks; Chrome may preserve repeated
+// spaces using NBSP nodes, which are equivalent here to their rendered spaces.
+function textOf(id) {
+ return document.getElementById(id).innerText.replace(/\r\n/g, "\n").replace(/\u00a0/g, " ");
+}
+document.getElementById("verify").addEventListener("click", async (event) => {
+ const bad = [];
+ if (!event.isTrusted) bad.push("verification click was synthetic");
+ for (const id of ["typed", "filled"]) {
+ if (textOf(id) !== window.__inputFixture.caption) bad.push(`${id}: text/paragraphs do not match`);
+ if (!metrics[id].beforeinput || !metrics[id].input) bad.push(`${id}: missing native beforeinput/input`);
+ if (metrics[id].printableKeys) bad.push(`${id}: ${metrics[id].printableKeys} per-character keydowns (expected native bulk insertion)`);
+ if (document.getElementById(id).querySelector("b")) bad.push(`${id}: literal markup became HTML`);
+ }
+ if (textOf("keys") !== "Ab!" || metrics.keys.printableKeys !== 3 || metrics.keys.input < 3) bad.push("perCharacter editor needs Ab! via three native keystrokes");
+ if (document.getElementById("shift").value !== "A!?") bad.push("Shift-only chords did not type A!?");
+ const expected = [["A", "KeyA"], ["!", "Digit1"], ["?", "Slash"]];
+ for (let i = 0; i < expected.length; i++) {
+ const [key, code] = expected[i];
+ const group = chords.slice(i * 4, i * 4 + 4);
+ if (group.length !== 4 || group.some((e) => !e.trusted) ||
+ group[0].name !== "keydown" || group[0].key !== "Shift" ||
+ group[1].name !== "keydown" || group[1].key !== key || group[1].code !== code || !group[1].shiftKey ||
+ group[2].name !== "keyup" || group[2].key !== key ||
+ group[3].name !== "keyup" || group[3].key !== "Shift") bad.push(`bad Shift chord for ${key}`);
+ }
+ if (chords.length !== 12) bad.push(`Shift chord event count=${chords.length}, expected 12`);
+ if (document.getElementById("once").value !== "done" || submits !== 1) bad.push(`single Enter: value or submission count wrong (${submits})`);
+ const file = document.getElementById("file").files[0];
+ if (!file || file.name !== "pi-chrome-upload.txt" || await file.text() !== "pi-chrome upload fixture\n") bad.push("uploaded file contents do not match fixture");
+ if (!metrics.file.nativeChange) bad.push("upload lacks native change event");
+ Challenge.log("metrics", { metrics, submits, chords });
+ if (bad.length) Challenge.fail(...bad);
+ else Challenge.pass("native bulk text, full replacement, key-event override, Shift chords, single Enter, and file upload");
+});
+</script>
+</body>
diff --git a/test-suite/manifest.json b/test-suite/manifest.json
index 4f72c85..cd6d34e 100644
--- a/test-suite/manifest.json
+++ b/test-suite/manifest.json
@@ -491,7 +491,7 @@
"id": "contenteditable-selection",
"file": "challenges/16-contenteditable-selection.html",
"category": "editing",
- "goal": "Contenteditable typing advances selection/caret like user typing.",
+ "goal": "Contenteditable perCharacter typing advances selection/caret for each key (bulk insertion is covered by challenge 44).",
"expected": {
"synthetic": "CONDITIONAL",
"trusted": "PASS",
@@ -502,7 +502,8 @@
"tool": "chrome_type",
"params": {
"selector": "#ed",
- "text": "hello"
+ "text": "hello",
+ "perCharacter": true
}
}
],
@@ -688,6 +689,10 @@
},
"recipe": [
{
+ "tool": "chrome_click",
+ "params": { "selector": "#t", "domFallback": false }
+ },
+ {
"tool": "chrome_key",
"params": {
"key": "a",
@@ -1752,5 +1757,42 @@
"flakeRisk": "medium",
"manualBaseline": "unverified",
"gradeSource": "both"
+ },
+ {
+ "id": "input-reliability",
+ "file": "challenges/44-input-reliability.html",
+ "category": "editing",
+ "difficulty": "L2",
+ "gate": "core",
+ "goal": "Upload a fixture, enter long Unicode rich-editor text, replace all old paragraphs, retain per-character typing, type Shift-only chords, and submit exactly once.",
+ "expected": { "synthetic": "FAIL", "trusted": "PASS", "manual": "PASS" },
+ "prerequisites": [
+ "Reload Pi and the updated Chrome companion. Snapshot before input; replace descriptive selectors with snapshot UIDs where available.",
+ "Substitute the chrome_evaluate caption result for $CAPTION and expand $PWD to the repository root."
+ ],
+ "recipe": [
+ { "tool": "chrome_evaluate", "params": { "expression": "window.__inputFixture.caption" } },
+ { "tool": "chrome_type", "params": { "selector": "#typed", "text": "$CAPTION", "includeSnapshot": true } },
+ { "tool": "chrome_fill", "params": { "selector": "#filled", "text": "$CAPTION", "domFallback": false, "includeSnapshot": true } },
+ { "tool": "chrome_type", "params": { "selector": "#keys", "text": "Ab!", "perCharacter": true, "includeSnapshot": true } },
+ { "tool": "chrome_click", "params": { "selector": "#shift", "domFallback": false } },
+ { "tool": "chrome_key", "params": { "key": "a", "modifiers": { "shiftKey": true } } },
+ { "tool": "chrome_key", "params": { "key": "1", "modifiers": { "shiftKey": true } } },
+ { "tool": "chrome_key", "params": { "key": "/", "modifiers": { "shiftKey": true }, "includeSnapshot": true } },
+ { "tool": "chrome_type", "params": { "selector": "#once", "text": "done", "pressEnter": true, "includeSnapshot": true } },
+ { "tool": "chrome_upload_file", "params": { "selector": "#file", "paths": ["$PWD/test-suite/fixtures/pi-chrome-upload.txt"] } },
+ { "tool": "chrome_click", "params": { "selector": "#verify", "domFallback": false, "includeSnapshot": true } },
+ { "tool": "chrome_evaluate", "params": { "expression": "JSON.stringify({v:window.__verdict,r:window.__reason,e:window.__events.slice(-20)})" } }
+ ],
+ "requires": { "cdp": true },
+ "tags": ["upload", "contenteditable", "insertText", "keyboard", "unicode", "modifiers"],
+ "notes": [
+ "Bulk insertion emits native editing events, not a keydown per character or a clipboard paste event. Per-character behavior remains covered by challenge 16 and the explicit keys editor here.",
+ "Manual baseline: paste the exact caption into both editors (select all old paragraphs first), type Ab!, release Shift between each Shift chord, type done then Enter once, and choose the fixture file.",
+ "The page checks actual file contents/native change. Node-ID conversion failure and cleanup are covered by fault-injected worker unit tests, not forced by the page."
+ ],
+ "flakeRisk": "low",
+ "manualBaseline": "unverified",
+ "gradeSource": "page"
}
]
diff --git a/test-suite/unit/background-policy.test.mjs b/test-suite/unit/background-policy.test.mjs
index e20d0ab..e78fa49 100644
--- a/test-suite/unit/background-policy.test.mjs
+++ b/test-suite/unit/background-policy.test.mjs
@@ -119,6 +119,26 @@ test("every registered page tool, tab.new, and chrome_launch(url) use the centra
assert.equal(h.writes[0][1].toString(), "test", "screenshot tool still writes decoded bytes");
});
+test("typing options and verification pass through real tool registrations without bypassing authorization", async () => {
+ const h = piHarness({ send: async () => ({ result: { input: "chrome", typing: "keys" }, snapshot: { url: "https://fixture.test" } }) });
+ for (const name of ["chrome_type", "chrome_fill"]) {
+ assert.equal(h.tools.get(name).parameters.perCharacter.default, false);
+ const result = await h.tool(name, { uid: "el-1", text: "hello", perCharacter: true, includeSnapshot: true, domFallback: false });
+ assert.equal(h.calls.at(-1).params.perCharacter, true);
+ assert.equal(h.calls.at(-1).params.includeSnapshot, true);
+ assert.equal(h.calls.at(-1).params.domFallback, false);
+ assert.equal(h.calls.at(-1).params.background, true);
+ assert.equal(result.details.result.result.typing, "keys");
+ assert.equal(result.details.result.snapshot.url, "https://fixture.test");
+ }
+ h.authorize(false);
+ const count = h.calls.length;
+ for (const [name, params] of [["chrome_type", { text: "x" }], ["chrome_fill", { text: "x" }], ["chrome_key", { key: "a" }], ["chrome_upload_file", { paths: ["/fixture.txt"] }]]) {
+ await assert.rejects(h.tool(name, params), /Chrome control locked/);
+ }
+ assert.equal(h.calls.length, count);
+});
+
test("activation is rejected before dispatch; existing on/off/toggle/status commands suffice", async () => {
const h = piHarness();
await assert.rejects(h.tool("chrome_tab", { action: "activate", targetId: "2", background: false }), /background mode.*\/chrome background off/);
diff --git a/test-suite/unit/input-reliability.test.mjs b/test-suite/unit/input-reliability.test.mjs
new file mode 100644
index 0000000..1329ed7
--- /dev/null
+++ b/test-suite/unit/input-reliability.test.mjs
@@ -0,0 +1,401 @@
+// Exercise the shipped worker against mocked Chrome APIs and a separate page world.
+// This checks command routing and failure cleanup, not browser isTrusted semantics.
+// Live input/selection fidelity is covered by challenges 16, 21, 31, and 44.
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import vm from "node:vm";
+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 clone = (value) => JSON.parse(JSON.stringify(value));
+
+function harness({ tag = "DIV", editable = true, initial = "", nodeId = 7 } = {}) {
+ const calls = [], selected = [], files = [], tabLookups = [];
+ let selectAll = false;
+ const element = {
+ tagName: tag, type: tag === "INPUT" ? "text" : undefined,
+ isContentEditable: editable, isConnected: true, textContent: initial,
+ scrollIntoView() {}, getBoundingClientRect: () => ({ left: 10, top: 10, width: 200, height: 60 }),
+ contains: (el) => el === element,
+ };
+ const selection = {
+ removeAllRanges() { selectAll = false; },
+ addRange(range) { selected.push(range.target); selectAll = range.target === element; },
+ };
+ const page = vm.createContext({
+ document: {
+ activeElement: element,
+ querySelector: (selector) => selector === "#target" ? element : null,
+ createRange: () => ({ selectNodeContents(target) { this.target = target; } }),
+ },
+ getSelection: () => selection,
+ location: { href: "https://fixture.test/" },
+ __PI_CHROME_STATE__: { elements: { "el-1": element } },
+ });
+ page.window = page;
+ 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 },
+ scripting: { executeScript: async ({ func, args = [] }) => {
+ calls.push({ method: "scripting.executeScript" });
+ const fn = vm.runInContext(`(${func.toString()})`, page);
+ return [{ result: await fn(...args) }];
+ } },
+ };
+ const worker = {
+ chrome, console, setTimeout, clearTimeout, setInterval: () => 0,
+ navigator: { userAgent: "unit-test" }, fetch: async () => { throw new Error("no network in unit tests"); },
+ };
+ worker.self = worker;
+ vm.runInNewContext(workerSource, worker);
+ worker.sleep = async () => {};
+ worker.getTabByParams = async (params) => {
+ tabLookups.push(clone(params));
+ return { id: Number(params.targetId ?? 2), windowId: 1 };
+ };
+ worker.bringToFront = async () => {};
+ worker.attachDebugger = async () => {};
+ worker.cdpMoveTo = async () => {};
+ worker.cdp = async (tabId, method, params = {}) => {
+ calls.push({ tabId, method, params: clone(params) });
+ const error = h.failures.get(method);
+ if (error) throw new Error(error);
+ if (method === "Runtime.evaluate") {
+ try {
+ const result = vm.runInContext(params.expression, page);
+ if (result === element) return { result: { objectId: "upload-object" } };
+ return { result: { value: clone(result) } };
+ } catch (error) {
+ return { exceptionDetails: { text: error.message, exception: { description: error.message } } };
+ }
+ }
+ if (method === "DOM.requestNode") return h.nodeResult;
+ if (method === "DOM.setFileInputFiles") files.push(...params.files);
+ if (method === "Input.insertText") element.textContent += params.text;
+ if (method === "Input.dispatchKeyEvent" && params.type === "keyDown") {
+ if (params.key === "Delete") {
+ // Triple-click selects a paragraph, not a whole multi-paragraph editor.
+ element.textContent = selectAll ? "" : element.textContent.replace(/[^\n]*$/, "");
+ selectAll = false;
+ } else if (params.text) element.textContent += params.text;
+ }
+ return {};
+ };
+ worker.domFillFallback = async (_tabId, params) => {
+ calls.push({ method: "domFillFallback" });
+ element.textContent = params.text;
+ return { input: "dom-fallback" };
+ };
+ 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 }),
+ commands: (method) => calls.filter((call) => call.method === method),
+ };
+ return h;
+}
+
+for (const [name, nodeResult] of [["node ID", { nodeId: 7 }], ["zero node ID", { nodeId: 0 }], ["missing node ID", {}], ["no response", undefined]]) {
+ test(`upload accepts ${name} and releases the remote object`, async () => {
+ const h = harness({ tag: "INPUT", editable: false });
+ h.element.type = "file";
+ 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" };
+ assert.deepEqual(h.commands("DOM.setFileInputFiles").map((c) => c.params), [{ ...target, files: ["/tmp/a.txt", "/tmp/b.txt"] }]);
+ assert.deepEqual(h.files, ["/tmp/a.txt", "/tmp/b.txt"]);
+ assert.equal(result.input, "chrome");
+ assert.deepEqual(clone(result.uploaded), [{ path: "/tmp/a.txt" }, { path: "/tmp/b.txt" }]);
+ assert.deepEqual(h.commands("Runtime.releaseObject").map((c) => c.params), [{ objectId: "upload-object" }]);
+ assert.equal(h.calls.at(-1).method, "Runtime.releaseObject");
+ });
+}
+
+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";
+ h.failures.set("DOM.requestNode", "node conversion unavailable");
+ await h.call("upload", { selector: "#target", paths: ["/tmp/a.txt"] });
+ assert.deepEqual(h.commands("DOM.setFileInputFiles")[0].params, { objectId: "upload-object", files: ["/tmp/a.txt"] });
+ h.calls.length = 0;
+ h.failures.set("DOM.setFileInputFiles", "attachment denied");
+ h.failures.set("Runtime.releaseObject", "target closed during cleanup");
+ 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");
+});
+
+test("upload releases valid node-path references on failure and tolerates notification/cleanup failure", async () => {
+ const h = harness({ tag: "INPUT", editable: false });
+ h.element.type = "file";
+ h.failures.set("DOM.setFileInputFiles", "file inaccessible");
+ 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");
+ 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 () => {
+ const h = harness();
+ await assert.rejects(h.call("upload", { selector: "#target", paths: ["/tmp/a.txt"] }), /Target must be <input type=file>/);
+ 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";
+ h.element.type = "file";
+ h.element.isConnected = false;
+ await assert.rejects(h.call("upload", { uid: "el-1", selector: "#target", paths: ["/tmp/a.txt"] }), /snapshot uid el-1 is stale/);
+ await assert.rejects(h.call("upload", { uid: "missing", selector: "#target", paths: ["/tmp/a.txt"] }), /snapshot uid missing is stale/);
+ assert.equal(h.commands("DOM.setFileInputFiles").length, 0);
+});
+
+const caption = "Long caption: café, 中文, 👩🏽‍💻. Two spaces.\nSecond paragraph! ".repeat(12);
+for (const target of [{ selector: "#target" }, { uid: "el-1" }, {}]) {
+ test(`contenteditable typing uses one native insertText (${Object.keys(target)[0] ?? "already focused"})`, async () => {
+ 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.element.textContent, caption);
+ assert.equal(result.input, "chrome");
+ assert.equal(result.length, caption.length);
+ assert.equal(result.typing, "insertText");
+ });
+}
+
+for (const perCharacter of [false, true]) {
+ test(`contenteditable fill replaces every paragraph (perCharacter=${perCharacter})`, async () => {
+ const h = harness({ initial: "First paragraph\nSecond paragraph\nThird paragraph" });
+ const text = perCharacter ? "Hello!" : caption;
+ const result = await h.call("fill", { uid: "el-1", text, perCharacter, domFallback: false });
+ assert.equal(h.selected.length, 1);
+ assert.equal(h.selected[0], h.element);
+ assert.equal(h.element.textContent, text);
+ assert.equal(result.typing, perCharacter ? "keys" : "insertText");
+ const deletion = h.calls.findIndex((c) => c.method === "Input.dispatchKeyEvent" && c.params.key === "Delete");
+ const insertion = h.calls.findIndex((c) => c.method === "Input.insertText" || (c.method === "Input.dispatchKeyEvent" && c.params.text));
+ assert.ok(deletion >= 0 && deletion < insertion);
+ assert.equal(h.commands("domFillFallback").length, 0);
+ });
+}
+
+test("fill refuses to select an unrelated focused editor", async () => {
+ const h = harness({ initial: "keep this" });
+ h.page.document.activeElement = { isContentEditable: true, contains: () => false };
+ await assert.rejects(h.call("fill", { selector: "#target", text: "replace", domFallback: false }), /requested contenteditable is not focused/);
+ assert.equal(h.selected.length, 0);
+ assert.equal(h.commands("Input.dispatchKeyEvent").length, 0, "no Delete in the wrong editor");
+ assert.equal(h.element.textContent, "keep this");
+});
+
+test("focused-editor inspection failures do not silently switch to keystrokes", async () => {
+ const h = harness();
+ h.chrome.scripting.executeScript = async () => { throw new Error("inspection denied"); };
+ await assert.rejects(h.call("type", { text: "hello" }), /inspection denied/);
+ assert.equal(h.commands("Input.insertText").length, 0);
+ assert.equal(h.commands("Input.dispatchKeyEvent").length, 0);
+});
+
+test("perCharacter preserves keydown-dependent editors; ordinary inputs/textareas retain key events", async () => {
+ for (const options of [{}, { tag: "INPUT", editable: false }, { tag: "TEXTAREA", editable: false }, { tag: "DIV", editable: false }]) {
+ const h = harness(options);
+ await h.call("type", { text: "Aa.!", ...(options.tag ? {} : { perCharacter: true }) });
+ assert.equal(h.commands("Input.insertText").length, 0);
+ assert.equal(h.element.textContent, "Aa.!");
+ assert.deepEqual(h.commands("Input.dispatchKeyEvent").filter((c) => c.params.text).map((c) => c.params.text), ["A", "a", ".", "!"]);
+ }
+});
+
+test("empty typing is a no-op; empty fill still deletes the entire editor", async () => {
+ const h = harness({ initial: "first\nsecond" });
+ await h.call("type", { text: "" });
+ assert.equal(h.element.textContent, "first\nsecond");
+ assert.equal(h.commands("Input.insertText").length, 0);
+ assert.equal(h.commands("Input.dispatchKeyEvent").length, 0);
+ await h.call("fill", { selector: "#target", text: "", domFallback: false });
+ assert.equal(h.element.textContent, "");
+ assert.equal(h.commands("Input.insertText").length, 0);
+});
+
+test("insertText failures propagate without silently retrying keystrokes; fill honors domFallback:false", async () => {
+ for (const action of ["type", "fill"]) {
+ const h = harness();
+ h.failures.set("Input.insertText", "insertion failed");
+ await assert.rejects(h.call(action, { selector: "#target", text: "hello", domFallback: false }), /insertion failed/);
+ assert.equal(h.commands("Input.insertText").length, 1);
+ assert.equal(h.commands("Input.dispatchKeyEvent").filter((c) => c.params.text).length, 0);
+ assert.equal(h.commands("domFillFallback").length, 0);
+ }
+ const h = harness();
+ h.failures.set("Input.insertText", "insertion failed");
+ const result = await h.call("fill", { selector: "#target", text: "hello" });
+ assert.equal(result.input, "dom-fallback", "existing opt-out fallback remains available");
+});
+
+test("stale typing/fill UIDs never insert text or delete the old selection", async () => {
+ for (const action of ["type", "fill"]) {
+ const h = harness();
+ h.element.isConnected = false;
+ await assert.rejects(h.call(action, { uid: "el-1", text: "hello", domFallback: false }), /snapshot uid el-1 is stale/);
+ assert.equal(h.commands("Input.insertText").length, 0);
+ assert.equal(h.commands("Input.dispatchKeyEvent").length, 0);
+ }
+});
+
+for (const [key, text, code, vk] of [
+ ["a", "A", "KeyA", 65], ["A", "A", "KeyA", 65], ["1", "!", "Digit1", 49],
+ ["!", "!", "Digit1", 49], ["2", "@", "Digit2", 50], [".", ">", "Period", 190],
+ ["/", "?", "Slash", 191], [" ", " ", "Space", 32],
+]) {
+ test(`Shift+${key} emits ${text} with the correct physical key and text`, async () => {
+ const h = harness({ tag: "INPUT", editable: false });
+ const result = await h.call("key", { key, modifiers: { shiftKey: true } });
+ const events = h.commands("Input.dispatchKeyEvent").map((c) => c.params);
+ assert.deepEqual(events.map((e) => [e.type, e.key]), [["keyDown", "Shift"], ["keyDown", text], ["keyUp", text], ["keyUp", "Shift"]]);
+ assert.equal(events[1].text, text);
+ assert.equal(events[1].unmodifiedText, text);
+ assert.equal(events[1].code, code);
+ assert.equal(events[1].windowsVirtualKeyCode, vk);
+ assert.equal(events[1].modifiers, 8);
+ assert.equal(events.at(-1).modifiers, 0);
+ assert.equal(h.element.textContent, text);
+ assert.equal(result.key, text);
+ });
+}
+
+test("every US shifted digit/punctuation maps without changing its physical code", () => {
+ const { worker } = harness();
+ const unshifted = "`1234567890-=[]\\;',./";
+ const shifted = '~!@#$%^&*()_+{}|:"<>?';
+ assert.equal(unshifted.length, shifted.length);
+ for (let i = 0; i < unshifted.length; i++) {
+ const base = worker.cdpKeyInfo(unshifted[i]);
+ const shift = worker.cdpKeyInfo(unshifted[i], true);
+ assert.equal(shift.text, shifted[i]);
+ assert.equal(shift.code, base.code);
+ assert.equal(shift.windowsVirtualKeyCode, base.windowsVirtualKeyCode);
+ }
+});
+
+test("Ctrl/Meta/Alt shortcuts never insert their literal character, even with Shift", async () => {
+ for (const modifier of ["ctrlKey", "metaKey", "altKey"]) {
+ for (const shiftKey of [false, true]) {
+ const h = harness();
+ await h.call("key", { key: "a", modifiers: { [modifier]: true, shiftKey } });
+ const down = h.commands("Input.dispatchKeyEvent").find((c) => c.params.code === "KeyA").params;
+ assert.equal(down.type, "rawKeyDown");
+ assert.equal(down.text, "");
+ assert.equal(down.unmodifiedText, "");
+ assert.equal(h.element.textContent, "");
+ }
+ }
+});
+
+test("named keys keep codes and Shift+Enter carries newline text", async () => {
+ for (const [key, code, text] of [["ArrowLeft", "ArrowLeft", ""], ["Tab", "Tab", "\t"], ["Enter", "Enter", "\r"], ["Escape", "Escape", ""]]) {
+ const h = harness();
+ await h.call("key", { key, modifiers: { shiftKey: true } });
+ const down = h.commands("Input.dispatchKeyEvent").find((c) => c.params.key === key).params;
+ assert.equal(down.code, code);
+ assert.equal(down.text, text);
+ }
+});
+
+test("challenge 21 waits for releases, accepts a complete trusted chord, and rejects missing text/synthetic input", () => {
+ const html = fs.readFileSync(new URL("../challenges/21-keyboard-modifiers.html", import.meta.url), "utf8");
+ const script = html.match(/<script>([\s\S]*?)<\/script>/)[1];
+ for (const mode of ["trusted", "synthetic", "missing-text"]) {
+ const listeners = new Map();
+ const field = { value: "", addEventListener: (name, fn) => listeners.set(name, [...(listeners.get(name) ?? []), fn]) };
+ let verdict = "PENDING";
+ const page = {
+ document: { getElementById: () => field },
+ Challenge: { init() {}, log() {}, pass: () => { verdict = "PASS"; }, fail: () => { verdict = "FAIL"; } },
+ };
+ vm.runInNewContext(script, page);
+ function fire(name, key, code, timeStamp) {
+ const event = { key, code, timeStamp, isTrusted: mode !== "synthetic", shiftKey: true, getModifierState: () => true };
+ for (const fn of listeners.get(name) ?? []) fn(event);
+ }
+ fire("keydown", "Shift", "ShiftLeft", 1);
+ fire("keydown", "A", "KeyA", 2);
+ if (mode !== "missing-text") field.value = "A";
+ fire("input", undefined, undefined, 3);
+ assert.equal(verdict, "PENDING", "input precedes release events");
+ fire("keyup", "A", "KeyA", 4);
+ assert.equal(verdict, "PENDING");
+ fire("keyup", "Shift", "ShiftLeft", 5);
+ assert.equal(verdict, mode === "trusted" ? "PASS" : "FAIL");
+ }
+});
+
+test("challenge 44 grader accepts native input evidence and rejects broken or synthetic results", async () => {
+ const html = fs.readFileSync(new URL("../challenges/44-input-reliability.html", import.meta.url), "utf8");
+ const script = html.match(/<script>([\s\S]*?)<\/script>/)[1];
+ for (const mode of ["good", "wrong-text", "old-paragraphs", "synthetic", "per-character-auto", "missing-keys", "double-enter", "bad-shift", "wrong-file", "html"]) {
+ const nodes = new Map();
+ function node(id) {
+ if (!nodes.has(id)) nodes.set(id, {
+ innerText: "", value: "", files: [], listeners: new Map(),
+ querySelector: () => mode === "html" && id === "typed" ? {} : null,
+ addEventListener(name, fn) { this.listeners.set(name, [...(this.listeners.get(name) ?? []), fn]); },
+ });
+ return nodes.get(id);
+ }
+ let verdict = "PENDING";
+ const page = {
+ document: { getElementById: node },
+ Challenge: { init() {}, log() {}, pass: () => { verdict = "PASS"; }, fail: () => { verdict = "FAIL"; } },
+ };
+ page.window = page;
+ vm.runInNewContext(script, page);
+ async function fire(id, name, props = {}) {
+ const event = { isTrusted: mode !== "synthetic", preventDefault() {}, ...props };
+ for (const fn of node(id).listeners.get(name) ?? []) await fn(event);
+ }
+ for (const id of ["typed", "filled"]) {
+ node(id).innerText = page.__inputFixture.caption;
+ await fire(id, "beforeinput");
+ await fire(id, "input");
+ }
+ if (mode === "wrong-text") node("typed").innerText = "wrong";
+ if (mode === "old-paragraphs") node("filled").innerText += "OLD paragraph";
+ if (mode === "per-character-auto") await fire("typed", "keydown", { key: "a" });
+ node("keys").innerText = "Ab!";
+ if (mode !== "missing-keys") for (const key of "Ab!") {
+ await fire("keys", "keydown", { key });
+ await fire("keys", "input");
+ }
+ node("shift").value = "A!?";
+ for (const [key, code] of [["A", "KeyA"], ["!", "Digit1"], ["?", "Slash"]]) {
+ await fire("shift", "keydown", { key: "Shift", code: "ShiftLeft", shiftKey: true });
+ await fire("shift", "keydown", { key, code, shiftKey: mode !== "bad-shift" });
+ await fire("shift", "keyup", { key, code, shiftKey: true });
+ await fire("shift", "keyup", { key: "Shift", code: "ShiftLeft", shiftKey: false });
+ }
+ node("once").value = "done";
+ await fire("onceForm", "submit");
+ if (mode === "double-enter") await fire("onceForm", "submit");
+ node("file").files = [{ name: "pi-chrome-upload.txt", text: async () => mode === "wrong-file" ? "wrong" : "pi-chrome upload fixture\n" }];
+ await fire("file", "change");
+ await fire("verify", "click");
+ assert.equal(verdict, mode === "good" ? "PASS" : "FAIL", mode);
+ }
+});
+
+test("pressEnter/submit emit one Enter after text and stay pinned to the resolved tab", async () => {
+ for (const action of ["type", "fill"]) {
+ for (const editable of [true, false]) {
+ const h = harness({ editable });
+ await h.call(action, { targetId: undefined, urlIncludes: "fixture.test", selector: "#target", text: "ok", pressEnter: true, submit: true, domFallback: false });
+ const enters = h.commands("Input.dispatchKeyEvent").filter((c) => c.params.type !== "keyUp" && (c.params.key === "Enter" || c.params.text === "\r"));
+ assert.equal(enters.length, 1);
+ assert.equal(enters[0].params.key, "Enter");
+ assert.equal(h.element.textContent, "ok\r");
+ assert.equal(String(h.tabLookups.at(-1).targetId), "2", "Enter must not resolve a different URL/title match");
+ }
+ }
+});