// Exercise the shipped worker against mocked Chrome APIs and a separate page world. // This checks command routing and failure cleanup, not browser isTrusted semantics. // Live input/selection fidelity is covered by challenges 16, 21, 31, and 44. import assert from "node:assert/strict"; import fs from "node:fs"; import vm from "node:vm"; import { test } from "node:test"; const workerSource = fs.readFileSync(new URL("../../extensions/chrome-profile-bridge/browser-extension/service_worker.js", import.meta.url), "utf8"); const clone = (value) => JSON.parse(JSON.stringify(value)); function harness({ tag = "DIV", editable = true, initial = "", nodeId = 7 } = {}) { const calls = [], selected = [], files = [], tabLookups = []; let selectAll = false; const element = { tagName: tag, type: tag === "INPUT" ? "text" : undefined, isContentEditable: editable, isConnected: true, textContent: initial, scrollIntoView() {}, getBoundingClientRect: () => ({ left: 10, top: 10, width: 200, height: 60 }), contains: (el) => el === element, }; const selection = { removeAllRanges() { selectAll = false; }, addRange(range) { selected.push(range.target); selectAll = range.target === element; }, }; const page = vm.createContext({ document: { activeElement: element, querySelector: (selector) => selector === "#target" ? element : null, createRange: () => ({ selectNodeContents(target) { this.target = target; } }), }, getSelection: () => selection, location: { href: "https://fixture.test/" }, __PI_CHROME_STATE__: { elements: { "el-1": element } }, }); page.window = page; const listener = { addListener() {}, removeListener() {} }; const chrome = { runtime: { id: "test", getManifest: () => ({ version: "0.0.0" }), onInstalled: listener, onStartup: listener }, alarms: { create() {}, onAlarm: listener }, action: { onClicked: listener }, webNavigation: { onCommitted: listener }, debugger: { onDetach: listener }, scripting: { executeScript: async ({ func, args = [] }) => { calls.push({ method: "scripting.executeScript" }); const fn = vm.runInContext(`(${func.toString()})`, page); return [{ result: await fn(...args) }]; } }, }; const worker = { chrome, console, setTimeout, clearTimeout, setInterval: () => 0, navigator: { userAgent: "unit-test" }, fetch: async () => { throw new Error("no network in unit tests"); }, }; worker.self = worker; vm.runInNewContext(workerSource, worker); worker.sleep = async () => {}; worker.getTabByParams = async (params) => { tabLookups.push(clone(params)); return { id: Number(params.targetId ?? 2), windowId: 1 }; }; worker.bringToFront = async () => {}; worker.attachDebugger = async () => {}; worker.cdpMoveTo = async () => {}; worker.cdp = async (tabId, method, params = {}) => { calls.push({ tabId, method, params: clone(params) }); const error = h.failures.get(method); if (error) throw new Error(error); if (method === "Runtime.evaluate") { try { const result = vm.runInContext(params.expression, page); if (result === element) return { result: { objectId: "upload-object" } }; return { result: { value: clone(result) } }; } catch (error) { return { exceptionDetails: { text: error.message, exception: { description: error.message } } }; } } if (method === "DOM.requestNode") return h.nodeResult; if (method === "DOM.setFileInputFiles") files.push(...params.files); if (method === "Input.insertText") element.textContent += params.text; if (method === "Input.dispatchKeyEvent" && params.type === "keyDown") { if (params.key === "Delete") { // Triple-click selects a paragraph, not a whole multi-paragraph editor. element.textContent = selectAll ? "" : element.textContent.replace(/[^\n]*$/, ""); selectAll = false; } else if (params.text) element.textContent += params.text; } return {}; }; worker.domFillFallback = async (_tabId, params) => { calls.push({ method: "domFillFallback" }); element.textContent = params.text; return { input: "dom-fallback" }; }; const h = { worker, chrome, page, calls, element, selected, files, tabLookups, failures: new Map(), nodeResult: { nodeId }, call: (action, params = {}) => worker.dispatch(`page.${action}`, { targetId: "2", background: true, ...params }), commands: (method) => calls.filter((call) => call.method === method), }; return h; } for (const [name, nodeResult] of [["node ID", { nodeId: 7 }], ["zero node ID", { nodeId: 0 }], ["missing node ID", {}], ["no response", undefined]]) { test(`upload accepts ${name} and releases the remote object`, async () => { const h = harness({ tag: "INPUT", editable: false }); h.element.type = "file"; h.nodeResult = nodeResult; const result = await h.call("upload", { uid: "el-1", paths: ["/tmp/a.txt", "/tmp/b.txt"] }); const target = nodeResult?.nodeId ? { nodeId: 7 } : { objectId: "upload-object" }; assert.deepEqual(h.commands("DOM.setFileInputFiles").map((c) => c.params), [{ ...target, files: ["/tmp/a.txt", "/tmp/b.txt"] }]); assert.deepEqual(h.files, ["/tmp/a.txt", "/tmp/b.txt"]); assert.equal(result.input, "chrome"); assert.deepEqual(clone(result.uploaded), [{ path: "/tmp/a.txt" }, { path: "/tmp/b.txt" }]); assert.deepEqual(h.commands("Runtime.releaseObject").map((c) => c.params), [{ objectId: "upload-object" }]); assert.equal(h.calls.at(-1).method, "Runtime.releaseObject"); }); } test("upload falls back on requestNode failure, not on a failed file attachment", async () => { const h = harness({ tag: "INPUT", editable: false }); h.element.type = "file"; h.failures.set("DOM.requestNode", "node conversion unavailable"); await h.call("upload", { selector: "#target", paths: ["/tmp/a.txt"] }); assert.deepEqual(h.commands("DOM.setFileInputFiles")[0].params, { objectId: "upload-object", files: ["/tmp/a.txt"] }); h.calls.length = 0; h.failures.set("DOM.setFileInputFiles", "attachment denied"); h.failures.set("Runtime.releaseObject", "target closed during cleanup"); await assert.rejects(h.call("upload", { selector: "#target", paths: ["/tmp/a.txt"] }), /attachment denied/); assert.equal(h.commands("DOM.setFileInputFiles").length, 1, "no object-ID retry after an uncertain file attachment"); assert.equal(h.commands("Runtime.releaseObject").length, 1, "cleanup still runs on error"); assert.equal(h.commands("Runtime.callFunctionOn").length, 0, "no notification after failed attachment"); }); test("upload releases valid node-path references on failure and tolerates notification/cleanup failure", async () => { const h = harness({ tag: "INPUT", editable: false }); h.element.type = "file"; h.failures.set("DOM.setFileInputFiles", "file inaccessible"); await assert.rejects(h.call("upload", { selector: "#target", paths: ["/tmp/a.txt"] }), /file inaccessible/); assert.equal(h.commands("Runtime.releaseObject").length, 1); h.failures.delete("DOM.setFileInputFiles"); for (const method of ["DOM.enable", "Runtime.callFunctionOn", "Runtime.releaseObject"]) h.failures.set(method, "optional step failed"); const result = await h.call("upload", { selector: "#target", paths: ["/tmp/a.txt"] }); assert.equal(result.input, "chrome"); }); test("upload rejects non-file elements, missing targets/paths, and stale UIDs without attaching anything", async () => { const h = harness(); await assert.rejects(h.call("upload", { selector: "#target", paths: ["/tmp/a.txt"] }), /Target must be /); await assert.rejects(h.call("upload", { paths: ["/tmp/a.txt"] }), /selector or uid required/); await assert.rejects(h.call("upload", { selector: "#target", paths: [] }), /no file paths/); h.element.tagName = "INPUT"; h.element.type = "file"; h.element.isConnected = false; await assert.rejects(h.call("upload", { uid: "el-1", selector: "#target", paths: ["/tmp/a.txt"] }), /snapshot uid el-1 is stale/); await assert.rejects(h.call("upload", { uid: "missing", selector: "#target", paths: ["/tmp/a.txt"] }), /snapshot uid missing is stale/); assert.equal(h.commands("DOM.setFileInputFiles").length, 0); }); const caption = "Long caption: café, 中文, 👩🏽‍💻. Two spaces.\nSecond paragraph! ".repeat(12); for (const target of [{ selector: "#target" }, { uid: "el-1" }, {}]) { test(`contenteditable typing uses one native insertText (${Object.keys(target)[0] ?? "already focused"})`, async () => { const h = harness(); const result = await h.call("type", { ...target, text: caption }); assert.deepEqual(h.commands("Input.insertText").map((c) => c.params), [{ text: caption }]); assert.equal(h.commands("Input.dispatchKeyEvent").length, 0); assert.equal(h.element.textContent, caption); assert.equal(result.input, "chrome"); assert.equal(result.length, caption.length); assert.equal(result.typing, "insertText"); }); } for (const perCharacter of [false, true]) { test(`contenteditable fill replaces every paragraph (perCharacter=${perCharacter})`, async () => { const h = harness({ initial: "First paragraph\nSecond paragraph\nThird paragraph" }); const text = perCharacter ? "Hello!" : caption; const result = await h.call("fill", { uid: "el-1", text, perCharacter, domFallback: false }); assert.equal(h.selected.length, 1); assert.equal(h.selected[0], h.element); assert.equal(h.element.textContent, text); assert.equal(result.typing, perCharacter ? "keys" : "insertText"); const deletion = h.calls.findIndex((c) => c.method === "Input.dispatchKeyEvent" && c.params.key === "Delete"); const insertion = h.calls.findIndex((c) => c.method === "Input.insertText" || (c.method === "Input.dispatchKeyEvent" && c.params.text)); assert.ok(deletion >= 0 && deletion < insertion); assert.equal(h.commands("domFillFallback").length, 0); }); } test("fill refuses to select an unrelated focused editor", async () => { const h = harness({ initial: "keep this" }); h.page.document.activeElement = { isContentEditable: true, contains: () => false }; await assert.rejects(h.call("fill", { selector: "#target", text: "replace", domFallback: false }), /requested contenteditable is not focused/); assert.equal(h.selected.length, 0); assert.equal(h.commands("Input.dispatchKeyEvent").length, 0, "no Delete in the wrong editor"); assert.equal(h.element.textContent, "keep this"); }); test("focused-editor inspection failures do not silently switch to keystrokes", async () => { const h = harness(); h.chrome.scripting.executeScript = async () => { throw new Error("inspection denied"); }; await assert.rejects(h.call("type", { text: "hello" }), /inspection denied/); assert.equal(h.commands("Input.insertText").length, 0); assert.equal(h.commands("Input.dispatchKeyEvent").length, 0); }); test("perCharacter preserves keydown-dependent editors; ordinary inputs/textareas retain key events", async () => { for (const options of [{}, { tag: "INPUT", editable: false }, { tag: "TEXTAREA", editable: false }, { tag: "DIV", editable: false }]) { const h = harness(options); await h.call("type", { text: "Aa.!", ...(options.tag ? {} : { perCharacter: true }) }); assert.equal(h.commands("Input.insertText").length, 0); assert.equal(h.element.textContent, "Aa.!"); assert.deepEqual(h.commands("Input.dispatchKeyEvent").filter((c) => c.params.text).map((c) => c.params.text), ["A", "a", ".", "!"]); } }); test("empty typing is a no-op; empty fill still deletes the entire editor", async () => { const h = harness({ initial: "first\nsecond" }); await h.call("type", { text: "" }); assert.equal(h.element.textContent, "first\nsecond"); assert.equal(h.commands("Input.insertText").length, 0); assert.equal(h.commands("Input.dispatchKeyEvent").length, 0); await h.call("fill", { selector: "#target", text: "", domFallback: false }); assert.equal(h.element.textContent, ""); assert.equal(h.commands("Input.insertText").length, 0); }); test("insertText failures propagate without silently retrying keystrokes; fill honors domFallback:false", async () => { for (const action of ["type", "fill"]) { const h = harness(); h.failures.set("Input.insertText", "insertion failed"); await assert.rejects(h.call(action, { selector: "#target", text: "hello", domFallback: false }), /insertion failed/); assert.equal(h.commands("Input.insertText").length, 1); assert.equal(h.commands("Input.dispatchKeyEvent").filter((c) => c.params.text).length, 0); assert.equal(h.commands("domFillFallback").length, 0); } const h = harness(); h.failures.set("Input.insertText", "insertion failed"); const result = await h.call("fill", { selector: "#target", text: "hello" }); assert.equal(result.input, "dom-fallback", "existing opt-out fallback remains available"); }); test("stale typing/fill UIDs never insert text or delete the old selection", async () => { for (const action of ["type", "fill"]) { const h = harness(); h.element.isConnected = false; await assert.rejects(h.call(action, { uid: "el-1", text: "hello", domFallback: false }), /snapshot uid el-1 is stale/); assert.equal(h.commands("Input.insertText").length, 0); assert.equal(h.commands("Input.dispatchKeyEvent").length, 0); } }); for (const [key, text, code, vk] of [ ["a", "A", "KeyA", 65], ["A", "A", "KeyA", 65], ["1", "!", "Digit1", 49], ["!", "!", "Digit1", 49], ["2", "@", "Digit2", 50], [".", ">", "Period", 190], ["/", "?", "Slash", 191], [" ", " ", "Space", 32], ]) { test(`Shift+${key} emits ${text} with the correct physical key and text`, async () => { const h = harness({ tag: "INPUT", editable: false }); const result = await h.call("key", { key, modifiers: { shiftKey: true } }); const events = h.commands("Input.dispatchKeyEvent").map((c) => c.params); assert.deepEqual(events.map((e) => [e.type, e.key]), [["keyDown", "Shift"], ["keyDown", text], ["keyUp", text], ["keyUp", "Shift"]]); assert.equal(events[1].text, text); assert.equal(events[1].unmodifiedText, text); assert.equal(events[1].code, code); assert.equal(events[1].windowsVirtualKeyCode, vk); assert.equal(events[1].modifiers, 8); assert.equal(events.at(-1).modifiers, 0); assert.equal(h.element.textContent, text); assert.equal(result.key, text); }); } test("every US shifted digit/punctuation maps without changing its physical code", () => { const { worker } = harness(); const unshifted = "`1234567890-=[]\\;',./"; const shifted = '~!@#$%^&*()_+{}|:"<>?'; assert.equal(unshifted.length, shifted.length); for (let i = 0; i < unshifted.length; i++) { const base = worker.cdpKeyInfo(unshifted[i]); const shift = worker.cdpKeyInfo(unshifted[i], true); assert.equal(shift.text, shifted[i]); assert.equal(shift.code, base.code); assert.equal(shift.windowsVirtualKeyCode, base.windowsVirtualKeyCode); } }); test("Ctrl/Meta/Alt shortcuts never insert their literal character, even with Shift", async () => { for (const modifier of ["ctrlKey", "metaKey", "altKey"]) { for (const shiftKey of [false, true]) { const h = harness(); await h.call("key", { key: "a", modifiers: { [modifier]: true, shiftKey } }); const down = h.commands("Input.dispatchKeyEvent").find((c) => c.params.code === "KeyA").params; assert.equal(down.type, "rawKeyDown"); assert.equal(down.text, ""); assert.equal(down.unmodifiedText, ""); assert.equal(h.element.textContent, ""); } } }); test("named keys keep codes and Shift+Enter carries newline text", async () => { for (const [key, code, text] of [["ArrowLeft", "ArrowLeft", ""], ["Tab", "Tab", "\t"], ["Enter", "Enter", "\r"], ["Escape", "Escape", ""]]) { const h = harness(); await h.call("key", { key, modifiers: { shiftKey: true } }); const down = h.commands("Input.dispatchKeyEvent").find((c) => c.params.key === key).params; assert.equal(down.code, code); assert.equal(down.text, text); } }); test("challenge 21 waits for releases, accepts a complete trusted chord, and rejects missing text/synthetic input", () => { const html = fs.readFileSync(new URL("../challenges/21-keyboard-modifiers.html", import.meta.url), "utf8"); const script = html.match(/