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 +10 -0
- package/README.md +6 -0
- package/extensions/chrome-profile-bridge/browser-extension/manifest.json +1 -1
- package/extensions/chrome-profile-bridge/browser-extension/service_worker.js +81 -27
- package/extensions/chrome-profile-bridge/index.ts +5 -3
- package/package.json +2 -2
- package/test-suite/README.md +9 -0
- package/test-suite/challenges/21-keyboard-modifiers.html +5 -2
- package/test-suite/challenges/44-input-reliability.html +105 -0
- package/test-suite/manifest.json +44 -2
- package/test-suite/unit/background-policy.test.mjs +20 -0
- package/test-suite/unit/input-reliability.test.mjs +401 -0
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
|
|
@@ -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
|
-
|
|
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
|
-
//
|
|
814
|
-
|
|
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:
|
|
823
|
+
type: shortcut ? "rawKeyDown" : "keyDown", key: info.key, code: info.code,
|
|
817
824
|
windowsVirtualKeyCode: info.windowsVirtualKeyCode, nativeVirtualKeyCode: info.windowsVirtualKeyCode,
|
|
818
|
-
text:
|
|
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
|
-
|
|
848
|
-
if (params.pressEnter) {
|
|
849
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
objectId
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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.
|
|
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
|
},
|
package/test-suite/README.md
CHANGED
|
@@ -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
|
-
|
|
34
|
-
|
|
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>
|
package/test-suite/manifest.json
CHANGED
|
@@ -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
|
|
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
|
+
});
|