pi-chrome 0.15.48 → 0.15.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  All notable user-facing changes to `pi-chrome`.
4
4
 
5
+ ## 0.15.49 — 2026-09-10
6
+
7
+ - **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.
8
+
9
+ - **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.
10
+ - **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.
11
+ - **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.
12
+ - **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.
13
+ - **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.
14
+
5
15
  ## 0.15.48 — 2026-09-09
6
16
 
7
17
  - **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.
package/README.md CHANGED
@@ -74,6 +74,12 @@ Second doctor run should show all checks passing.
74
74
 
75
75
  Tool parameters and gotchas are documented inline in Pi.
76
76
 
77
+ ### Typing into rich editors
78
+
79
+ `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.
80
+
81
+ 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.
82
+
77
83
  ---
78
84
 
79
85
  ## Safety model
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Pi Chrome Connector",
4
- "version": "0.15.48",
4
+ "version": "0.15.49",
5
5
  "description": "Lets Pi control tabs in Chrome via a local connector at 127.0.0.1.",
6
6
  "permissions": [
7
7
  "tabs",
@@ -681,7 +681,13 @@ function cdpKeyInfo(key, shifted) {
681
681
  };
682
682
  if (SPECIAL[key]) return { key, ...SPECIAL[key] };
683
683
  if (key.length === 1) {
684
- const ch = key;
684
+ // Explicit Shift chords need shifted text as well as a modifier bit. CDP does
685
+ // not derive printable text from code/windowsVirtualKeyCode for us.
686
+ const SHIFTED = {
687
+ "`": "~", "1": "!", "2": "@", "3": "#", "4": "$", "5": "%", "6": "^", "7": "&", "8": "*", "9": "(", "0": ")",
688
+ "-": "_", "=": "+", "[": "{", "]": "}", "\\": "|", ";": ":", "'": "\"", ",": "<", ".": ">", "/": "?",
689
+ };
690
+ const ch = shifted ? (/^[a-z]$/.test(key) ? key.toUpperCase() : SHIFTED[key] || key) : key;
685
691
  const layout = usKeyLayoutForChar(ch);
686
692
  return { key: ch, code: layout.code, windowsVirtualKeyCode: layout.keyCode, text: ch };
687
693
  }
@@ -809,13 +815,14 @@ async function chromeInputKey(params) {
809
815
  await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyDown", key: m.key, code: m.code, windowsVirtualKeyCode: m.vk, modifiers: modBits });
810
816
  await sleep(rng(6, 18));
811
817
  }
812
- const info = cdpKeyInfo(key);
813
- // When modifiers are active, browsers usually emit "rawKeyDown" (no text) so chords like Cmd+V don't insert the literal char.
814
- const downType = modBits ? "rawKeyDown" : "keyDown";
818
+ const info = cdpKeyInfo(key, mods.shiftKey);
819
+ // Ctrl/Meta/Alt chords must not insert literal text (e.g. Cmd+V). Shift alone
820
+ // still types: Shift+a -> A, Shift+1 -> !, and Shift+Enter carries a newline.
821
+ const shortcut = !!(mods.ctrlKey || mods.metaKey || mods.altKey);
815
822
  await cdp(tab.id, "Input.dispatchKeyEvent", {
816
- type: downType, key: info.key, code: info.code,
823
+ type: shortcut ? "rawKeyDown" : "keyDown", key: info.key, code: info.code,
817
824
  windowsVirtualKeyCode: info.windowsVirtualKeyCode, nativeVirtualKeyCode: info.windowsVirtualKeyCode,
818
- text: modBits ? "" : info.text, unmodifiedText: modBits ? "" : info.text, modifiers: modBits,
825
+ text: shortcut ? "" : info.text, unmodifiedText: shortcut ? "" : info.text, modifiers: modBits,
819
826
  });
820
827
  await sleep(rng(25, 90));
821
828
  await cdp(tab.id, "Input.dispatchKeyEvent", {
@@ -829,6 +836,49 @@ async function chromeInputKey(params) {
829
836
  return { input: "chrome", key: info.key, modifiers: mods };
830
837
  }
831
838
 
839
+ // Read the actual focused editor, not a role=textbox lookalike. For fill, select
840
+ // the requested editor's entire contents: triple-click only selects a paragraph.
841
+ // Selection uses the DOM; deletion and insertion still use Chrome's input layer.
842
+ async function contentEditableInTab(tabId, selectAllParams = null) {
843
+ const results = await executeScriptTimed({
844
+ target: { tabId, frameIds: [0] },
845
+ world: "MAIN",
846
+ func: (selector, uid, selectAll) => {
847
+ const active = document.activeElement;
848
+ if (selectAll) {
849
+ const state = window.__PI_CHROME_STATE__;
850
+ const el = uid ? state?.elements?.[uid] : document.querySelector(selector);
851
+ if (uid && (!el || !el.isConnected)) throw new Error(`snapshot uid ${uid} is stale; refresh chrome_snapshot`);
852
+ if (!el?.isContentEditable) return false;
853
+ if (!active?.isContentEditable || !(el === active || el.contains(active) || active.contains(el))) {
854
+ throw new Error("chrome.fill: requested contenteditable is not focused");
855
+ }
856
+ const selection = window.getSelection();
857
+ if (!selection) throw new Error("Could not select contenteditable contents");
858
+ const range = document.createRange();
859
+ range.selectNodeContents(el);
860
+ selection.removeAllRanges();
861
+ selection.addRange(range);
862
+ }
863
+ return active?.isContentEditable === true;
864
+ },
865
+ args: [selectAllParams?.selector ?? null, selectAllParams?.uid ?? null, selectAllParams !== null],
866
+ }, `inspect contenteditable in tab ${tabId}`);
867
+ return results?.[0]?.result === true;
868
+ }
869
+
870
+ async function typeTextInTab(tabId, text, perCharacter) {
871
+ if (!text) return "none";
872
+ if (!perCharacter && await contentEditableInTab(tabId)) {
873
+ // One native edit avoids per-character delays and rich-editor render races.
874
+ // Do not retry as keystrokes if insertion fails: it may already have applied.
875
+ await cdp(tabId, "Input.insertText", { text });
876
+ return "insertText";
877
+ }
878
+ for (const ch of Array.from(text)) await cdpTypeChar(tabId, ch);
879
+ return "keys";
880
+ }
881
+
832
882
  async function chromeInputType(params) {
833
883
  const tab = await getTabByParams(params);
834
884
  await bringToFront(tab, params);
@@ -844,12 +894,9 @@ async function chromeInputType(params) {
844
894
  await sleep(rng(50, 120));
845
895
  }
846
896
  const text = String(params.text || "");
847
- for (const ch of Array.from(text)) await cdpTypeChar(tab.id, ch);
848
- if (params.pressEnter) {
849
- await cdpTypeChar(tab.id, "\r").catch(() => undefined);
850
- await chromeInputKey({ ...params, key: "Enter" });
851
- }
852
- return { input: "chrome", length: text.length };
897
+ const typing = await typeTextInTab(tab.id, text, params.perCharacter);
898
+ if (params.pressEnter) await chromeInputKey({ ...params, targetId: tab.id, key: "Enter" });
899
+ return { input: "chrome", length: text.length, typing };
853
900
  }
854
901
 
855
902
  async function domFillFallback(tabId, params, cause) {
@@ -908,14 +955,15 @@ async function chromeInputFill(params) {
908
955
  await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: i, pointerType: "mouse" });
909
956
  await sleep(rng(20, 60));
910
957
  }
958
+ await contentEditableInTab(tab.id, params);
911
959
  // Delete selection.
912
960
  await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyDown", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
913
961
  await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyUp", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
914
962
  await sleep(rng(20, 60));
915
963
  const text = String(params.text || "");
916
- for (const ch of Array.from(text)) await cdpTypeChar(tab.id, ch);
917
- if (params.submit) await chromeInputKey({ ...params, key: "Enter" });
918
- return { input: "chrome", length: text.length };
964
+ const typing = await typeTextInTab(tab.id, text, params.perCharacter);
965
+ if (params.submit) await chromeInputKey({ ...params, targetId: tab.id, key: "Enter" });
966
+ return { input: "chrome", length: text.length, typing };
919
967
  } catch (error) {
920
968
  if (params.domFallback === false) throw error;
921
969
  return domFillFallback(tab.id, params, error);
@@ -1024,25 +1072,31 @@ async function chromeInputUpload(params) {
1024
1072
  const selector = ${JSON.stringify(params.selector ?? null)};
1025
1073
  const uid = ${JSON.stringify(params.uid ?? null)};
1026
1074
  const state = window.__PI_CHROME_STATE__;
1027
- const el = uid && state && state.elements ? state.elements[uid] : (selector ? document.querySelector(selector) : null);
1075
+ const el = uid ? state?.elements?.[uid] : (selector ? document.querySelector(selector) : null);
1076
+ if (uid && (!el || !el.isConnected)) throw new Error("snapshot uid " + uid + " is stale; refresh chrome_snapshot");
1028
1077
  if (!el || el.tagName !== "INPUT" || el.type !== "file") throw new Error("Target must be <input type=file>");
1029
1078
  el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
1030
1079
  return el;
1031
1080
  })()`;
1032
1081
  const evaluated = await cdp(tab.id, "Runtime.evaluate", { expression, objectGroup: "pi-chrome-upload", includeCommandLineAPI: false, returnByValue: false });
1033
- if (evaluated.exceptionDetails) throw new Error(evaluated.exceptionDetails.text || "Could not resolve file input");
1082
+ if (evaluated.exceptionDetails) throw new Error(cdpExceptionText(evaluated.exceptionDetails) || "Could not resolve file input");
1034
1083
  const objectId = evaluated.result?.objectId;
1035
1084
  if (!objectId) throw new Error("Could not resolve file input object");
1036
- await cdp(tab.id, "DOM.enable", {}).catch(() => undefined);
1037
- const requested = await cdp(tab.id, "DOM.requestNode", { objectId });
1038
- if (!requested.nodeId) throw new Error("Could not resolve file input node");
1039
- await cdp(tab.id, "DOM.setFileInputFiles", { nodeId: requested.nodeId, files: paths });
1040
- await cdp(tab.id, "Runtime.callFunctionOn", {
1041
- objectId,
1042
- functionDeclaration: `function() { this.dispatchEvent(new Event("input", { bubbles: true })); this.dispatchEvent(new Event("change", { bubbles: true })); return this.files ? this.files.length : 0; }`,
1043
- returnByValue: true,
1044
- }).catch(() => undefined);
1045
- await cdp(tab.id, "Runtime.releaseObject", { objectId }).catch(() => undefined);
1085
+ try {
1086
+ await cdp(tab.id, "DOM.enable", {}).catch(() => undefined);
1087
+ // Some DOM agents return nodeId:0 (or reject conversion) for a valid remote
1088
+ // element. CDP accepts that same objectId directly, before any file mutation.
1089
+ const requested = await cdp(tab.id, "DOM.requestNode", { objectId }).catch(() => null);
1090
+ const target = requested?.nodeId ? { nodeId: requested.nodeId } : { objectId };
1091
+ await cdp(tab.id, "DOM.setFileInputFiles", { ...target, files: paths });
1092
+ await cdp(tab.id, "Runtime.callFunctionOn", {
1093
+ objectId,
1094
+ functionDeclaration: `function() { this.dispatchEvent(new Event("input", { bubbles: true })); this.dispatchEvent(new Event("change", { bubbles: true })); return this.files ? this.files.length : 0; }`,
1095
+ returnByValue: true,
1096
+ }).catch(() => undefined);
1097
+ } finally {
1098
+ await cdp(tab.id, "Runtime.releaseObject", { objectId }).catch(() => undefined);
1099
+ }
1046
1100
  return { input: "chrome", uploaded: paths.map((path) => ({ path })) };
1047
1101
  }
1048
1102
  // ===============================================================
@@ -1564,12 +1564,13 @@ Usage rules:
1564
1564
  name: "chrome_type",
1565
1565
  label: "Chrome Type",
1566
1566
  description:
1567
- "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.",
1567
+ "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.",
1568
1568
  promptSnippet: "Type text into Chrome, optionally focusing a snapshot uid or selector first.",
1569
1569
  parameters: Type.Object({
1570
1570
  text: Type.String(),
1571
1571
  uid: Type.Optional(Type.String({ description: "Stable element uid from chrome_snapshot." })),
1572
1572
  selector: Type.Optional(Type.String({ description: "CSS selector to focus before typing." })),
1573
+ 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." })),
1573
1574
  includeSnapshot: Type.Optional(Type.Boolean({ description: "If true, include a fresh chrome_snapshot result after typing." })),
1574
1575
  maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." })),
1575
1576
  pressEnter: Type.Optional(Type.Boolean()),
@@ -1595,12 +1596,13 @@ Usage rules:
1595
1596
  name: "chrome_fill",
1596
1597
  label: "Chrome Fill",
1597
1598
  description:
1598
- "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.",
1599
+ "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.",
1599
1600
  promptSnippet: "Fill a Chrome form field by snapshot uid or selector, optionally returning a fresh snapshot.",
1600
1601
  parameters: Type.Object({
1601
1602
  text: Type.String(),
1602
1603
  uid: Type.Optional(Type.String({ description: "Stable element uid from chrome_snapshot." })),
1603
1604
  selector: Type.Optional(Type.String({ description: "CSS selector to fill if uid is omitted." })),
1605
+ 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." })),
1604
1606
  submit: Type.Optional(Type.Boolean({ description: "If true, press Enter after filling." })),
1605
1607
  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." })),
1606
1608
  includeSnapshot: Type.Optional(Type.Boolean({ description: "If true, include a fresh chrome_snapshot result after filling." })),
@@ -1636,7 +1638,7 @@ Usage rules:
1636
1638
  ctrlKey: Type.Optional(Type.Boolean()),
1637
1639
  altKey: Type.Optional(Type.Boolean()),
1638
1640
  metaKey: Type.Optional(Type.Boolean()),
1639
- }, { description: "Modifier keys to hold while pressing the key (chord)." })),
1641
+ }, { 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." })),
1640
1642
  includeSnapshot: Type.Optional(Type.Boolean({ description: "If true, include a fresh chrome_snapshot result after the keypress." })),
1641
1643
  maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." })),
1642
1644
  targetId: Type.Optional(Type.String()),
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "pi-chrome",
3
- "version": "0.15.48",
3
+ "version": "0.15.49",
4
4
  "scripts": {
5
- "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",
5
+ "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",
6
6
  "version": "node scripts/sync-manifest-version.js",
7
7
  "prepublishOnly": "node scripts/sync-manifest-version.js"
8
8
  },
@@ -177,6 +177,15 @@ The dashboard renders this from `manifest.json`. In brief:
177
177
  41. explicit tab lifecycle
178
178
  42. strict CSP eval/snapshot via CDP (regression guard for the CSP bypass)
179
179
  43. hard background: inactive-tab visibility and trusted input
180
+ 44. input reliability: native rich-editor insertion, full replacement, per-character override, Shift chords, single Enter, and file upload
181
+
182
+ ### Input regressions (16, 21, 31, 44)
183
+
184
+ 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.
185
+
186
+ 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.
187
+
188
+ `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.
180
189
 
181
190
  ### Hard-background regression (43)
182
191
 
@@ -30,9 +30,12 @@ for (const name of ["keydown","keypress","keyup","input"]) {
30
30
  });
31
31
  }
32
32
 
33
- t.addEventListener("input", () => {
34
- if (t.value !== "A") return;
33
+ // input fires before keyup. Grade only when Shift is released so a valid chord
34
+ // cannot fail merely because its release events have not happened yet.
35
+ t.addEventListener("keyup", (event) => {
36
+ if (event.key !== "Shift") return;
35
37
  const bad = [];
38
+ if (t.value !== "A") bad.push(`value=${JSON.stringify(t.value)} (need 'A')`);
36
39
  const downShift = log.find(e => e.name === "keydown" && e.key === "Shift");
37
40
  const downA = log.find(e => e.name === "keydown" && e.code === "KeyA");
38
41
  const upShift = log.find(e => e.name === "keyup" && e.key === "Shift");
@@ -0,0 +1,105 @@
1
+ <!doctype html>
2
+ <meta charset="utf-8">
3
+ <title>44 input reliability</title>
4
+ <link rel="stylesheet" href="../_style.css">
5
+ <script src="../_lib.js"></script>
6
+ <style>
7
+ [contenteditable] { min-height: 48px; max-height: 180px; overflow: auto; padding: 10px; border: 1px solid #777; white-space: pre-wrap; }
8
+ label { display: block; margin-top: 20px; }
9
+ pre { white-space: pre-wrap; }
10
+ </style>
11
+ <body>
12
+ <main>
13
+ <h1>Upload and text-input reliability</h1>
14
+ <p>Read <code>window.__inputFixture.caption</code>. Type it into the empty editor,
15
+ then fill the old multi-paragraph editor with the same text. Leave
16
+ <code>perCharacter</code> off for both. Native text insertion must preserve
17
+ Unicode, newlines, and literal markup without hundreds of key events.</p>
18
+ <details><summary>Exact caption (manual baseline: copy and paste into both editors)</summary><pre id="expected"></pre></details>
19
+ <label for="typed">Empty editor — chrome_type</label>
20
+ <div id="typed" role="textbox" aria-label="Empty editor" contenteditable="true"></div>
21
+ <label for="filled">Old paragraphs — chrome_fill must replace all of them</label>
22
+ <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>
23
+ <label for="keys">Key-event editor — type Ab! with perCharacter:true</label>
24
+ <div id="keys" role="textbox" aria-label="Key-event editor" contenteditable="true"></div>
25
+ <label for="shift">Shift-only chords — Shift+a, Shift+1, Shift+/ must produce A!?</label>
26
+ <input id="shift" aria-label="Shift-only chords" autocomplete="off">
27
+ <form id="onceForm">
28
+ <label for="once">Type done with pressEnter:true — submit exactly once</label>
29
+ <input id="once" aria-label="Single Enter" autocomplete="off">
30
+ <button type="submit">Submit once</button>
31
+ </form>
32
+ <label for="file">Attach test-suite/fixtures/pi-chrome-upload.txt</label>
33
+ <input id="file" type="file">
34
+ <p>Node-ID conversion failure is fault-injected by the Node unit tests; this page
35
+ checks the real file contents and native upload event.</p>
36
+ <button id="verify" type="button">Verify all input</button>
37
+ </main>
38
+ <script>
39
+ Challenge.init({ id: "input-reliability", instructions: "Complete six fields, then Verify all input" });
40
+ window.__inputFixture = {
41
+ caption: Array.from({ length: 8 }, (_, i) => `${i + 1}. Caption: café 中文 👩🏽‍💻 — <b>plain text</b>. Keep spaces.\nSecond paragraph.`).join("\n\n"),
42
+ };
43
+ document.getElementById("expected").textContent = window.__inputFixture.caption;
44
+ const metrics = {};
45
+ const chords = [];
46
+ for (const id of ["typed", "filled", "keys", "shift", "once", "file"]) {
47
+ const el = document.getElementById(id);
48
+ metrics[id] = { beforeinput: 0, input: 0, printableKeys: 0, nativeChange: 0 };
49
+ for (const name of ["keydown", "keyup", "beforeinput", "input", "change"]) {
50
+ el.addEventListener(name, (event) => {
51
+ Challenge.log(name, { id, key: event.key, code: event.code, trusted: event.isTrusted, inputType: event.inputType, dataLength: event.data?.length });
52
+ if (event.isTrusted && name === "beforeinput") metrics[id].beforeinput++;
53
+ if (event.isTrusted && name === "input") metrics[id].input++;
54
+ if (event.isTrusted && name === "change") metrics[id].nativeChange++;
55
+ if (name === "keydown" && event.key.length === 1 && !(event.metaKey || event.ctrlKey || event.altKey)) metrics[id].printableKeys++;
56
+ if (id === "shift" && (name === "keydown" || name === "keyup")) {
57
+ chords.push({ name, key: event.key, code: event.code, shiftKey: event.shiftKey, trusted: event.isTrusted });
58
+ }
59
+ });
60
+ }
61
+ }
62
+ let submits = 0;
63
+ document.getElementById("onceForm").addEventListener("submit", (event) => {
64
+ event.preventDefault();
65
+ submits++;
66
+ Challenge.log("submit", { submits, trusted: event.isTrusted });
67
+ });
68
+
69
+ // innerText reflects contenteditable line breaks; Chrome may preserve repeated
70
+ // spaces using NBSP nodes, which are equivalent here to their rendered spaces.
71
+ function textOf(id) {
72
+ return document.getElementById(id).innerText.replace(/\r\n/g, "\n").replace(/\u00a0/g, " ");
73
+ }
74
+ document.getElementById("verify").addEventListener("click", async (event) => {
75
+ const bad = [];
76
+ if (!event.isTrusted) bad.push("verification click was synthetic");
77
+ for (const id of ["typed", "filled"]) {
78
+ if (textOf(id) !== window.__inputFixture.caption) bad.push(`${id}: text/paragraphs do not match`);
79
+ if (!metrics[id].beforeinput || !metrics[id].input) bad.push(`${id}: missing native beforeinput/input`);
80
+ if (metrics[id].printableKeys) bad.push(`${id}: ${metrics[id].printableKeys} per-character keydowns (expected native bulk insertion)`);
81
+ if (document.getElementById(id).querySelector("b")) bad.push(`${id}: literal markup became HTML`);
82
+ }
83
+ if (textOf("keys") !== "Ab!" || metrics.keys.printableKeys !== 3 || metrics.keys.input < 3) bad.push("perCharacter editor needs Ab! via three native keystrokes");
84
+ if (document.getElementById("shift").value !== "A!?") bad.push("Shift-only chords did not type A!?");
85
+ const expected = [["A", "KeyA"], ["!", "Digit1"], ["?", "Slash"]];
86
+ for (let i = 0; i < expected.length; i++) {
87
+ const [key, code] = expected[i];
88
+ const group = chords.slice(i * 4, i * 4 + 4);
89
+ if (group.length !== 4 || group.some((e) => !e.trusted) ||
90
+ group[0].name !== "keydown" || group[0].key !== "Shift" ||
91
+ group[1].name !== "keydown" || group[1].key !== key || group[1].code !== code || !group[1].shiftKey ||
92
+ group[2].name !== "keyup" || group[2].key !== key ||
93
+ group[3].name !== "keyup" || group[3].key !== "Shift") bad.push(`bad Shift chord for ${key}`);
94
+ }
95
+ if (chords.length !== 12) bad.push(`Shift chord event count=${chords.length}, expected 12`);
96
+ if (document.getElementById("once").value !== "done" || submits !== 1) bad.push(`single Enter: value or submission count wrong (${submits})`);
97
+ const file = document.getElementById("file").files[0];
98
+ 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");
99
+ if (!metrics.file.nativeChange) bad.push("upload lacks native change event");
100
+ Challenge.log("metrics", { metrics, submits, chords });
101
+ if (bad.length) Challenge.fail(...bad);
102
+ else Challenge.pass("native bulk text, full replacement, key-event override, Shift chords, single Enter, and file upload");
103
+ });
104
+ </script>
105
+ </body>
@@ -491,7 +491,7 @@
491
491
  "id": "contenteditable-selection",
492
492
  "file": "challenges/16-contenteditable-selection.html",
493
493
  "category": "editing",
494
- "goal": "Contenteditable typing advances selection/caret like user typing.",
494
+ "goal": "Contenteditable perCharacter typing advances selection/caret for each key (bulk insertion is covered by challenge 44).",
495
495
  "expected": {
496
496
  "synthetic": "CONDITIONAL",
497
497
  "trusted": "PASS",
@@ -502,7 +502,8 @@
502
502
  "tool": "chrome_type",
503
503
  "params": {
504
504
  "selector": "#ed",
505
- "text": "hello"
505
+ "text": "hello",
506
+ "perCharacter": true
506
507
  }
507
508
  }
508
509
  ],
@@ -687,6 +688,10 @@
687
688
  "manual": "PASS"
688
689
  },
689
690
  "recipe": [
691
+ {
692
+ "tool": "chrome_click",
693
+ "params": { "selector": "#t", "domFallback": false }
694
+ },
690
695
  {
691
696
  "tool": "chrome_key",
692
697
  "params": {
@@ -1752,5 +1757,42 @@
1752
1757
  "flakeRisk": "medium",
1753
1758
  "manualBaseline": "unverified",
1754
1759
  "gradeSource": "both"
1760
+ },
1761
+ {
1762
+ "id": "input-reliability",
1763
+ "file": "challenges/44-input-reliability.html",
1764
+ "category": "editing",
1765
+ "difficulty": "L2",
1766
+ "gate": "core",
1767
+ "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.",
1768
+ "expected": { "synthetic": "FAIL", "trusted": "PASS", "manual": "PASS" },
1769
+ "prerequisites": [
1770
+ "Reload Pi and the updated Chrome companion. Snapshot before input; replace descriptive selectors with snapshot UIDs where available.",
1771
+ "Substitute the chrome_evaluate caption result for $CAPTION and expand $PWD to the repository root."
1772
+ ],
1773
+ "recipe": [
1774
+ { "tool": "chrome_evaluate", "params": { "expression": "window.__inputFixture.caption" } },
1775
+ { "tool": "chrome_type", "params": { "selector": "#typed", "text": "$CAPTION", "includeSnapshot": true } },
1776
+ { "tool": "chrome_fill", "params": { "selector": "#filled", "text": "$CAPTION", "domFallback": false, "includeSnapshot": true } },
1777
+ { "tool": "chrome_type", "params": { "selector": "#keys", "text": "Ab!", "perCharacter": true, "includeSnapshot": true } },
1778
+ { "tool": "chrome_click", "params": { "selector": "#shift", "domFallback": false } },
1779
+ { "tool": "chrome_key", "params": { "key": "a", "modifiers": { "shiftKey": true } } },
1780
+ { "tool": "chrome_key", "params": { "key": "1", "modifiers": { "shiftKey": true } } },
1781
+ { "tool": "chrome_key", "params": { "key": "/", "modifiers": { "shiftKey": true }, "includeSnapshot": true } },
1782
+ { "tool": "chrome_type", "params": { "selector": "#once", "text": "done", "pressEnter": true, "includeSnapshot": true } },
1783
+ { "tool": "chrome_upload_file", "params": { "selector": "#file", "paths": ["$PWD/test-suite/fixtures/pi-chrome-upload.txt"] } },
1784
+ { "tool": "chrome_click", "params": { "selector": "#verify", "domFallback": false, "includeSnapshot": true } },
1785
+ { "tool": "chrome_evaluate", "params": { "expression": "JSON.stringify({v:window.__verdict,r:window.__reason,e:window.__events.slice(-20)})" } }
1786
+ ],
1787
+ "requires": { "cdp": true },
1788
+ "tags": ["upload", "contenteditable", "insertText", "keyboard", "unicode", "modifiers"],
1789
+ "notes": [
1790
+ "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.",
1791
+ "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.",
1792
+ "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."
1793
+ ],
1794
+ "flakeRisk": "low",
1795
+ "manualBaseline": "unverified",
1796
+ "gradeSource": "page"
1755
1797
  }
1756
1798
  ]
@@ -119,6 +119,26 @@ test("every registered page tool, tab.new, and chrome_launch(url) use the centra
119
119
  assert.equal(h.writes[0][1].toString(), "test", "screenshot tool still writes decoded bytes");
120
120
  });
121
121
 
122
+ test("typing options and verification pass through real tool registrations without bypassing authorization", async () => {
123
+ const h = piHarness({ send: async () => ({ result: { input: "chrome", typing: "keys" }, snapshot: { url: "https://fixture.test" } }) });
124
+ for (const name of ["chrome_type", "chrome_fill"]) {
125
+ assert.equal(h.tools.get(name).parameters.perCharacter.default, false);
126
+ const result = await h.tool(name, { uid: "el-1", text: "hello", perCharacter: true, includeSnapshot: true, domFallback: false });
127
+ assert.equal(h.calls.at(-1).params.perCharacter, true);
128
+ assert.equal(h.calls.at(-1).params.includeSnapshot, true);
129
+ assert.equal(h.calls.at(-1).params.domFallback, false);
130
+ assert.equal(h.calls.at(-1).params.background, true);
131
+ assert.equal(result.details.result.result.typing, "keys");
132
+ assert.equal(result.details.result.snapshot.url, "https://fixture.test");
133
+ }
134
+ h.authorize(false);
135
+ const count = h.calls.length;
136
+ for (const [name, params] of [["chrome_type", { text: "x" }], ["chrome_fill", { text: "x" }], ["chrome_key", { key: "a" }], ["chrome_upload_file", { paths: ["/fixture.txt"] }]]) {
137
+ await assert.rejects(h.tool(name, params), /Chrome control locked/);
138
+ }
139
+ assert.equal(h.calls.length, count);
140
+ });
141
+
122
142
  test("activation is rejected before dispatch; existing on/off/toggle/status commands suffice", async () => {
123
143
  const h = piHarness();
124
144
  await assert.rejects(h.tool("chrome_tab", { action: "activate", targetId: "2", background: false }), /background mode.*\/chrome background off/);
@@ -0,0 +1,401 @@
1
+ // Exercise the shipped worker against mocked Chrome APIs and a separate page world.
2
+ // This checks command routing and failure cleanup, not browser isTrusted semantics.
3
+ // Live input/selection fidelity is covered by challenges 16, 21, 31, and 44.
4
+ import assert from "node:assert/strict";
5
+ import fs from "node:fs";
6
+ import vm from "node:vm";
7
+ import { test } from "node:test";
8
+
9
+ const workerSource = fs.readFileSync(new URL("../../extensions/chrome-profile-bridge/browser-extension/service_worker.js", import.meta.url), "utf8");
10
+ const clone = (value) => JSON.parse(JSON.stringify(value));
11
+
12
+ function harness({ tag = "DIV", editable = true, initial = "", nodeId = 7 } = {}) {
13
+ const calls = [], selected = [], files = [], tabLookups = [];
14
+ let selectAll = false;
15
+ const element = {
16
+ tagName: tag, type: tag === "INPUT" ? "text" : undefined,
17
+ isContentEditable: editable, isConnected: true, textContent: initial,
18
+ scrollIntoView() {}, getBoundingClientRect: () => ({ left: 10, top: 10, width: 200, height: 60 }),
19
+ contains: (el) => el === element,
20
+ };
21
+ const selection = {
22
+ removeAllRanges() { selectAll = false; },
23
+ addRange(range) { selected.push(range.target); selectAll = range.target === element; },
24
+ };
25
+ const page = vm.createContext({
26
+ document: {
27
+ activeElement: element,
28
+ querySelector: (selector) => selector === "#target" ? element : null,
29
+ createRange: () => ({ selectNodeContents(target) { this.target = target; } }),
30
+ },
31
+ getSelection: () => selection,
32
+ location: { href: "https://fixture.test/" },
33
+ __PI_CHROME_STATE__: { elements: { "el-1": element } },
34
+ });
35
+ page.window = page;
36
+ const listener = { addListener() {}, removeListener() {} };
37
+ const chrome = {
38
+ runtime: { id: "test", getManifest: () => ({ version: "0.0.0" }), onInstalled: listener, onStartup: listener },
39
+ alarms: { create() {}, onAlarm: listener }, action: { onClicked: listener }, webNavigation: { onCommitted: listener },
40
+ debugger: { onDetach: listener },
41
+ scripting: { executeScript: async ({ func, args = [] }) => {
42
+ calls.push({ method: "scripting.executeScript" });
43
+ const fn = vm.runInContext(`(${func.toString()})`, page);
44
+ return [{ result: await fn(...args) }];
45
+ } },
46
+ };
47
+ const worker = {
48
+ chrome, console, setTimeout, clearTimeout, setInterval: () => 0,
49
+ navigator: { userAgent: "unit-test" }, fetch: async () => { throw new Error("no network in unit tests"); },
50
+ };
51
+ worker.self = worker;
52
+ vm.runInNewContext(workerSource, worker);
53
+ worker.sleep = async () => {};
54
+ worker.getTabByParams = async (params) => {
55
+ tabLookups.push(clone(params));
56
+ return { id: Number(params.targetId ?? 2), windowId: 1 };
57
+ };
58
+ worker.bringToFront = async () => {};
59
+ worker.attachDebugger = async () => {};
60
+ worker.cdpMoveTo = async () => {};
61
+ worker.cdp = async (tabId, method, params = {}) => {
62
+ calls.push({ tabId, method, params: clone(params) });
63
+ const error = h.failures.get(method);
64
+ if (error) throw new Error(error);
65
+ if (method === "Runtime.evaluate") {
66
+ try {
67
+ const result = vm.runInContext(params.expression, page);
68
+ if (result === element) return { result: { objectId: "upload-object" } };
69
+ return { result: { value: clone(result) } };
70
+ } catch (error) {
71
+ return { exceptionDetails: { text: error.message, exception: { description: error.message } } };
72
+ }
73
+ }
74
+ if (method === "DOM.requestNode") return h.nodeResult;
75
+ if (method === "DOM.setFileInputFiles") files.push(...params.files);
76
+ if (method === "Input.insertText") element.textContent += params.text;
77
+ if (method === "Input.dispatchKeyEvent" && params.type === "keyDown") {
78
+ if (params.key === "Delete") {
79
+ // Triple-click selects a paragraph, not a whole multi-paragraph editor.
80
+ element.textContent = selectAll ? "" : element.textContent.replace(/[^\n]*$/, "");
81
+ selectAll = false;
82
+ } else if (params.text) element.textContent += params.text;
83
+ }
84
+ return {};
85
+ };
86
+ worker.domFillFallback = async (_tabId, params) => {
87
+ calls.push({ method: "domFillFallback" });
88
+ element.textContent = params.text;
89
+ return { input: "dom-fallback" };
90
+ };
91
+ const h = {
92
+ worker, chrome, page, calls, element, selected, files, tabLookups, failures: new Map(), nodeResult: { nodeId },
93
+ call: (action, params = {}) => worker.dispatch(`page.${action}`, { targetId: "2", background: true, ...params }),
94
+ commands: (method) => calls.filter((call) => call.method === method),
95
+ };
96
+ return h;
97
+ }
98
+
99
+ for (const [name, nodeResult] of [["node ID", { nodeId: 7 }], ["zero node ID", { nodeId: 0 }], ["missing node ID", {}], ["no response", undefined]]) {
100
+ test(`upload accepts ${name} and releases the remote object`, async () => {
101
+ const h = harness({ tag: "INPUT", editable: false });
102
+ h.element.type = "file";
103
+ h.nodeResult = nodeResult;
104
+ const result = await h.call("upload", { uid: "el-1", paths: ["/tmp/a.txt", "/tmp/b.txt"] });
105
+ const target = nodeResult?.nodeId ? { nodeId: 7 } : { objectId: "upload-object" };
106
+ assert.deepEqual(h.commands("DOM.setFileInputFiles").map((c) => c.params), [{ ...target, files: ["/tmp/a.txt", "/tmp/b.txt"] }]);
107
+ assert.deepEqual(h.files, ["/tmp/a.txt", "/tmp/b.txt"]);
108
+ assert.equal(result.input, "chrome");
109
+ assert.deepEqual(clone(result.uploaded), [{ path: "/tmp/a.txt" }, { path: "/tmp/b.txt" }]);
110
+ assert.deepEqual(h.commands("Runtime.releaseObject").map((c) => c.params), [{ objectId: "upload-object" }]);
111
+ assert.equal(h.calls.at(-1).method, "Runtime.releaseObject");
112
+ });
113
+ }
114
+
115
+ test("upload falls back on requestNode failure, not on a failed file attachment", async () => {
116
+ const h = harness({ tag: "INPUT", editable: false });
117
+ h.element.type = "file";
118
+ h.failures.set("DOM.requestNode", "node conversion unavailable");
119
+ await h.call("upload", { selector: "#target", paths: ["/tmp/a.txt"] });
120
+ assert.deepEqual(h.commands("DOM.setFileInputFiles")[0].params, { objectId: "upload-object", files: ["/tmp/a.txt"] });
121
+ h.calls.length = 0;
122
+ h.failures.set("DOM.setFileInputFiles", "attachment denied");
123
+ h.failures.set("Runtime.releaseObject", "target closed during cleanup");
124
+ await assert.rejects(h.call("upload", { selector: "#target", paths: ["/tmp/a.txt"] }), /attachment denied/);
125
+ assert.equal(h.commands("DOM.setFileInputFiles").length, 1, "no object-ID retry after an uncertain file attachment");
126
+ assert.equal(h.commands("Runtime.releaseObject").length, 1, "cleanup still runs on error");
127
+ assert.equal(h.commands("Runtime.callFunctionOn").length, 0, "no notification after failed attachment");
128
+ });
129
+
130
+ test("upload releases valid node-path references on failure and tolerates notification/cleanup failure", async () => {
131
+ const h = harness({ tag: "INPUT", editable: false });
132
+ h.element.type = "file";
133
+ h.failures.set("DOM.setFileInputFiles", "file inaccessible");
134
+ await assert.rejects(h.call("upload", { selector: "#target", paths: ["/tmp/a.txt"] }), /file inaccessible/);
135
+ assert.equal(h.commands("Runtime.releaseObject").length, 1);
136
+ h.failures.delete("DOM.setFileInputFiles");
137
+ for (const method of ["DOM.enable", "Runtime.callFunctionOn", "Runtime.releaseObject"]) h.failures.set(method, "optional step failed");
138
+ const result = await h.call("upload", { selector: "#target", paths: ["/tmp/a.txt"] });
139
+ assert.equal(result.input, "chrome");
140
+ });
141
+
142
+ test("upload rejects non-file elements, missing targets/paths, and stale UIDs without attaching anything", async () => {
143
+ const h = harness();
144
+ await assert.rejects(h.call("upload", { selector: "#target", paths: ["/tmp/a.txt"] }), /Target must be <input type=file>/);
145
+ await assert.rejects(h.call("upload", { paths: ["/tmp/a.txt"] }), /selector or uid required/);
146
+ await assert.rejects(h.call("upload", { selector: "#target", paths: [] }), /no file paths/);
147
+ h.element.tagName = "INPUT";
148
+ h.element.type = "file";
149
+ h.element.isConnected = false;
150
+ await assert.rejects(h.call("upload", { uid: "el-1", selector: "#target", paths: ["/tmp/a.txt"] }), /snapshot uid el-1 is stale/);
151
+ await assert.rejects(h.call("upload", { uid: "missing", selector: "#target", paths: ["/tmp/a.txt"] }), /snapshot uid missing is stale/);
152
+ assert.equal(h.commands("DOM.setFileInputFiles").length, 0);
153
+ });
154
+
155
+ const caption = "Long caption: café, 中文, 👩🏽‍💻. Two spaces.\nSecond paragraph! ".repeat(12);
156
+ for (const target of [{ selector: "#target" }, { uid: "el-1" }, {}]) {
157
+ test(`contenteditable typing uses one native insertText (${Object.keys(target)[0] ?? "already focused"})`, async () => {
158
+ const h = harness();
159
+ const result = await h.call("type", { ...target, text: caption });
160
+ assert.deepEqual(h.commands("Input.insertText").map((c) => c.params), [{ text: caption }]);
161
+ assert.equal(h.commands("Input.dispatchKeyEvent").length, 0);
162
+ assert.equal(h.element.textContent, caption);
163
+ assert.equal(result.input, "chrome");
164
+ assert.equal(result.length, caption.length);
165
+ assert.equal(result.typing, "insertText");
166
+ });
167
+ }
168
+
169
+ for (const perCharacter of [false, true]) {
170
+ test(`contenteditable fill replaces every paragraph (perCharacter=${perCharacter})`, async () => {
171
+ const h = harness({ initial: "First paragraph\nSecond paragraph\nThird paragraph" });
172
+ const text = perCharacter ? "Hello!" : caption;
173
+ const result = await h.call("fill", { uid: "el-1", text, perCharacter, domFallback: false });
174
+ assert.equal(h.selected.length, 1);
175
+ assert.equal(h.selected[0], h.element);
176
+ assert.equal(h.element.textContent, text);
177
+ assert.equal(result.typing, perCharacter ? "keys" : "insertText");
178
+ const deletion = h.calls.findIndex((c) => c.method === "Input.dispatchKeyEvent" && c.params.key === "Delete");
179
+ const insertion = h.calls.findIndex((c) => c.method === "Input.insertText" || (c.method === "Input.dispatchKeyEvent" && c.params.text));
180
+ assert.ok(deletion >= 0 && deletion < insertion);
181
+ assert.equal(h.commands("domFillFallback").length, 0);
182
+ });
183
+ }
184
+
185
+ test("fill refuses to select an unrelated focused editor", async () => {
186
+ const h = harness({ initial: "keep this" });
187
+ h.page.document.activeElement = { isContentEditable: true, contains: () => false };
188
+ await assert.rejects(h.call("fill", { selector: "#target", text: "replace", domFallback: false }), /requested contenteditable is not focused/);
189
+ assert.equal(h.selected.length, 0);
190
+ assert.equal(h.commands("Input.dispatchKeyEvent").length, 0, "no Delete in the wrong editor");
191
+ assert.equal(h.element.textContent, "keep this");
192
+ });
193
+
194
+ test("focused-editor inspection failures do not silently switch to keystrokes", async () => {
195
+ const h = harness();
196
+ h.chrome.scripting.executeScript = async () => { throw new Error("inspection denied"); };
197
+ await assert.rejects(h.call("type", { text: "hello" }), /inspection denied/);
198
+ assert.equal(h.commands("Input.insertText").length, 0);
199
+ assert.equal(h.commands("Input.dispatchKeyEvent").length, 0);
200
+ });
201
+
202
+ test("perCharacter preserves keydown-dependent editors; ordinary inputs/textareas retain key events", async () => {
203
+ for (const options of [{}, { tag: "INPUT", editable: false }, { tag: "TEXTAREA", editable: false }, { tag: "DIV", editable: false }]) {
204
+ const h = harness(options);
205
+ await h.call("type", { text: "Aa.!", ...(options.tag ? {} : { perCharacter: true }) });
206
+ assert.equal(h.commands("Input.insertText").length, 0);
207
+ assert.equal(h.element.textContent, "Aa.!");
208
+ assert.deepEqual(h.commands("Input.dispatchKeyEvent").filter((c) => c.params.text).map((c) => c.params.text), ["A", "a", ".", "!"]);
209
+ }
210
+ });
211
+
212
+ test("empty typing is a no-op; empty fill still deletes the entire editor", async () => {
213
+ const h = harness({ initial: "first\nsecond" });
214
+ await h.call("type", { text: "" });
215
+ assert.equal(h.element.textContent, "first\nsecond");
216
+ assert.equal(h.commands("Input.insertText").length, 0);
217
+ assert.equal(h.commands("Input.dispatchKeyEvent").length, 0);
218
+ await h.call("fill", { selector: "#target", text: "", domFallback: false });
219
+ assert.equal(h.element.textContent, "");
220
+ assert.equal(h.commands("Input.insertText").length, 0);
221
+ });
222
+
223
+ test("insertText failures propagate without silently retrying keystrokes; fill honors domFallback:false", async () => {
224
+ for (const action of ["type", "fill"]) {
225
+ const h = harness();
226
+ h.failures.set("Input.insertText", "insertion failed");
227
+ await assert.rejects(h.call(action, { selector: "#target", text: "hello", domFallback: false }), /insertion failed/);
228
+ assert.equal(h.commands("Input.insertText").length, 1);
229
+ assert.equal(h.commands("Input.dispatchKeyEvent").filter((c) => c.params.text).length, 0);
230
+ assert.equal(h.commands("domFillFallback").length, 0);
231
+ }
232
+ const h = harness();
233
+ h.failures.set("Input.insertText", "insertion failed");
234
+ const result = await h.call("fill", { selector: "#target", text: "hello" });
235
+ assert.equal(result.input, "dom-fallback", "existing opt-out fallback remains available");
236
+ });
237
+
238
+ test("stale typing/fill UIDs never insert text or delete the old selection", async () => {
239
+ for (const action of ["type", "fill"]) {
240
+ const h = harness();
241
+ h.element.isConnected = false;
242
+ await assert.rejects(h.call(action, { uid: "el-1", text: "hello", domFallback: false }), /snapshot uid el-1 is stale/);
243
+ assert.equal(h.commands("Input.insertText").length, 0);
244
+ assert.equal(h.commands("Input.dispatchKeyEvent").length, 0);
245
+ }
246
+ });
247
+
248
+ for (const [key, text, code, vk] of [
249
+ ["a", "A", "KeyA", 65], ["A", "A", "KeyA", 65], ["1", "!", "Digit1", 49],
250
+ ["!", "!", "Digit1", 49], ["2", "@", "Digit2", 50], [".", ">", "Period", 190],
251
+ ["/", "?", "Slash", 191], [" ", " ", "Space", 32],
252
+ ]) {
253
+ test(`Shift+${key} emits ${text} with the correct physical key and text`, async () => {
254
+ const h = harness({ tag: "INPUT", editable: false });
255
+ const result = await h.call("key", { key, modifiers: { shiftKey: true } });
256
+ const events = h.commands("Input.dispatchKeyEvent").map((c) => c.params);
257
+ assert.deepEqual(events.map((e) => [e.type, e.key]), [["keyDown", "Shift"], ["keyDown", text], ["keyUp", text], ["keyUp", "Shift"]]);
258
+ assert.equal(events[1].text, text);
259
+ assert.equal(events[1].unmodifiedText, text);
260
+ assert.equal(events[1].code, code);
261
+ assert.equal(events[1].windowsVirtualKeyCode, vk);
262
+ assert.equal(events[1].modifiers, 8);
263
+ assert.equal(events.at(-1).modifiers, 0);
264
+ assert.equal(h.element.textContent, text);
265
+ assert.equal(result.key, text);
266
+ });
267
+ }
268
+
269
+ test("every US shifted digit/punctuation maps without changing its physical code", () => {
270
+ const { worker } = harness();
271
+ const unshifted = "`1234567890-=[]\\;',./";
272
+ const shifted = '~!@#$%^&*()_+{}|:"<>?';
273
+ assert.equal(unshifted.length, shifted.length);
274
+ for (let i = 0; i < unshifted.length; i++) {
275
+ const base = worker.cdpKeyInfo(unshifted[i]);
276
+ const shift = worker.cdpKeyInfo(unshifted[i], true);
277
+ assert.equal(shift.text, shifted[i]);
278
+ assert.equal(shift.code, base.code);
279
+ assert.equal(shift.windowsVirtualKeyCode, base.windowsVirtualKeyCode);
280
+ }
281
+ });
282
+
283
+ test("Ctrl/Meta/Alt shortcuts never insert their literal character, even with Shift", async () => {
284
+ for (const modifier of ["ctrlKey", "metaKey", "altKey"]) {
285
+ for (const shiftKey of [false, true]) {
286
+ const h = harness();
287
+ await h.call("key", { key: "a", modifiers: { [modifier]: true, shiftKey } });
288
+ const down = h.commands("Input.dispatchKeyEvent").find((c) => c.params.code === "KeyA").params;
289
+ assert.equal(down.type, "rawKeyDown");
290
+ assert.equal(down.text, "");
291
+ assert.equal(down.unmodifiedText, "");
292
+ assert.equal(h.element.textContent, "");
293
+ }
294
+ }
295
+ });
296
+
297
+ test("named keys keep codes and Shift+Enter carries newline text", async () => {
298
+ for (const [key, code, text] of [["ArrowLeft", "ArrowLeft", ""], ["Tab", "Tab", "\t"], ["Enter", "Enter", "\r"], ["Escape", "Escape", ""]]) {
299
+ const h = harness();
300
+ await h.call("key", { key, modifiers: { shiftKey: true } });
301
+ const down = h.commands("Input.dispatchKeyEvent").find((c) => c.params.key === key).params;
302
+ assert.equal(down.code, code);
303
+ assert.equal(down.text, text);
304
+ }
305
+ });
306
+
307
+ test("challenge 21 waits for releases, accepts a complete trusted chord, and rejects missing text/synthetic input", () => {
308
+ const html = fs.readFileSync(new URL("../challenges/21-keyboard-modifiers.html", import.meta.url), "utf8");
309
+ const script = html.match(/<script>([\s\S]*?)<\/script>/)[1];
310
+ for (const mode of ["trusted", "synthetic", "missing-text"]) {
311
+ const listeners = new Map();
312
+ const field = { value: "", addEventListener: (name, fn) => listeners.set(name, [...(listeners.get(name) ?? []), fn]) };
313
+ let verdict = "PENDING";
314
+ const page = {
315
+ document: { getElementById: () => field },
316
+ Challenge: { init() {}, log() {}, pass: () => { verdict = "PASS"; }, fail: () => { verdict = "FAIL"; } },
317
+ };
318
+ vm.runInNewContext(script, page);
319
+ function fire(name, key, code, timeStamp) {
320
+ const event = { key, code, timeStamp, isTrusted: mode !== "synthetic", shiftKey: true, getModifierState: () => true };
321
+ for (const fn of listeners.get(name) ?? []) fn(event);
322
+ }
323
+ fire("keydown", "Shift", "ShiftLeft", 1);
324
+ fire("keydown", "A", "KeyA", 2);
325
+ if (mode !== "missing-text") field.value = "A";
326
+ fire("input", undefined, undefined, 3);
327
+ assert.equal(verdict, "PENDING", "input precedes release events");
328
+ fire("keyup", "A", "KeyA", 4);
329
+ assert.equal(verdict, "PENDING");
330
+ fire("keyup", "Shift", "ShiftLeft", 5);
331
+ assert.equal(verdict, mode === "trusted" ? "PASS" : "FAIL");
332
+ }
333
+ });
334
+
335
+ test("challenge 44 grader accepts native input evidence and rejects broken or synthetic results", async () => {
336
+ const html = fs.readFileSync(new URL("../challenges/44-input-reliability.html", import.meta.url), "utf8");
337
+ const script = html.match(/<script>([\s\S]*?)<\/script>/)[1];
338
+ for (const mode of ["good", "wrong-text", "old-paragraphs", "synthetic", "per-character-auto", "missing-keys", "double-enter", "bad-shift", "wrong-file", "html"]) {
339
+ const nodes = new Map();
340
+ function node(id) {
341
+ if (!nodes.has(id)) nodes.set(id, {
342
+ innerText: "", value: "", files: [], listeners: new Map(),
343
+ querySelector: () => mode === "html" && id === "typed" ? {} : null,
344
+ addEventListener(name, fn) { this.listeners.set(name, [...(this.listeners.get(name) ?? []), fn]); },
345
+ });
346
+ return nodes.get(id);
347
+ }
348
+ let verdict = "PENDING";
349
+ const page = {
350
+ document: { getElementById: node },
351
+ Challenge: { init() {}, log() {}, pass: () => { verdict = "PASS"; }, fail: () => { verdict = "FAIL"; } },
352
+ };
353
+ page.window = page;
354
+ vm.runInNewContext(script, page);
355
+ async function fire(id, name, props = {}) {
356
+ const event = { isTrusted: mode !== "synthetic", preventDefault() {}, ...props };
357
+ for (const fn of node(id).listeners.get(name) ?? []) await fn(event);
358
+ }
359
+ for (const id of ["typed", "filled"]) {
360
+ node(id).innerText = page.__inputFixture.caption;
361
+ await fire(id, "beforeinput");
362
+ await fire(id, "input");
363
+ }
364
+ if (mode === "wrong-text") node("typed").innerText = "wrong";
365
+ if (mode === "old-paragraphs") node("filled").innerText += "OLD paragraph";
366
+ if (mode === "per-character-auto") await fire("typed", "keydown", { key: "a" });
367
+ node("keys").innerText = "Ab!";
368
+ if (mode !== "missing-keys") for (const key of "Ab!") {
369
+ await fire("keys", "keydown", { key });
370
+ await fire("keys", "input");
371
+ }
372
+ node("shift").value = "A!?";
373
+ for (const [key, code] of [["A", "KeyA"], ["!", "Digit1"], ["?", "Slash"]]) {
374
+ await fire("shift", "keydown", { key: "Shift", code: "ShiftLeft", shiftKey: true });
375
+ await fire("shift", "keydown", { key, code, shiftKey: mode !== "bad-shift" });
376
+ await fire("shift", "keyup", { key, code, shiftKey: true });
377
+ await fire("shift", "keyup", { key: "Shift", code: "ShiftLeft", shiftKey: false });
378
+ }
379
+ node("once").value = "done";
380
+ await fire("onceForm", "submit");
381
+ if (mode === "double-enter") await fire("onceForm", "submit");
382
+ node("file").files = [{ name: "pi-chrome-upload.txt", text: async () => mode === "wrong-file" ? "wrong" : "pi-chrome upload fixture\n" }];
383
+ await fire("file", "change");
384
+ await fire("verify", "click");
385
+ assert.equal(verdict, mode === "good" ? "PASS" : "FAIL", mode);
386
+ }
387
+ });
388
+
389
+ test("pressEnter/submit emit one Enter after text and stay pinned to the resolved tab", async () => {
390
+ for (const action of ["type", "fill"]) {
391
+ for (const editable of [true, false]) {
392
+ const h = harness({ editable });
393
+ await h.call(action, { targetId: undefined, urlIncludes: "fixture.test", selector: "#target", text: "ok", pressEnter: true, submit: true, domFallback: false });
394
+ const enters = h.commands("Input.dispatchKeyEvent").filter((c) => c.params.type !== "keyUp" && (c.params.key === "Enter" || c.params.text === "\r"));
395
+ assert.equal(enters.length, 1);
396
+ assert.equal(enters[0].params.key, "Enter");
397
+ assert.equal(h.element.textContent, "ok\r");
398
+ assert.equal(String(h.tabLookups.at(-1).targetId), "2", "Enter must not resolve a different URL/title match");
399
+ }
400
+ }
401
+ });