pi-chrome 0.15.47 → 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 +18 -0
- package/README.md +15 -1
- package/SECURITY.md +1 -1
- package/docs/ARCHITECTURE.md +16 -4
- package/docs/COMPARISON.md +1 -1
- package/extensions/chrome-profile-bridge/browser-extension/manifest.json +1 -1
- package/extensions/chrome-profile-bridge/browser-extension/service_worker.js +161 -91
- package/extensions/chrome-profile-bridge/index.ts +97 -106
- package/package.json +2 -2
- package/test-suite/README.md +18 -0
- package/test-suite/challenges/21-keyboard-modifiers.html +5 -2
- package/test-suite/challenges/43-hard-background.html +42 -0
- package/test-suite/challenges/44-input-reliability.html +105 -0
- package/test-suite/manifest.json +119 -2
- package/test-suite/unit/automation-target.test.mjs +1 -1
- package/test-suite/unit/background-policy.test.mjs +489 -0
- package/test-suite/unit/input-reliability.test.mjs +401 -0
|
@@ -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
|
+
});
|