diff options
Diffstat (limited to 'test-suite/unit/type-evidence.test.mjs')
| -rw-r--r-- | test-suite/unit/type-evidence.test.mjs | 208 |
1 files changed, 208 insertions, 0 deletions
diff --git a/test-suite/unit/type-evidence.test.mjs b/test-suite/unit/type-evidence.test.mjs new file mode 100644 index 0000000..f0a51c1 --- /dev/null +++ b/test-suite/unit/type-evidence.test.mjs @@ -0,0 +1,208 @@ +// chrome_type before/after evidence, replace=true, navigation-aware includeSnapshot, and tab.new load +// settling. Chrome and the page are mocked; live caret behavior is covered by challenge 44. +import assert from "node:assert/strict"; +import fs from "node:fs"; +import vm from "node:vm"; +import { stripTypeScriptTypes } from "node:module"; +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 indexSource = fs.readFileSync(new URL("../../extensions/chrome-profile-bridge/index.ts", import.meta.url), "utf8"); +const clone = (value) => JSON.parse(JSON.stringify(value)); + +function makeInput({ value = "", caret = value.length, type = "text", name = "q" } = {}) { + return { + tagName: "INPUT", type, name, id: name, isConnected: true, isContentEditable: false, + value, selectionStart: caret, selectionEnd: caret, + getAttribute(attr) { return attr === "type" ? this.type : attr === "name" ? this.name : null; }, + scrollIntoView() {}, getBoundingClientRect: () => ({ left: 10, top: 10, width: 300, height: 30 }), + focus() {}, + }; +} + +function harness(element, { platform = "MacIntel", tabs = {} } = {}) { + const calls = []; + const listeners = new Set(); + const page = vm.createContext({ + document: { activeElement: element, querySelector: (s) => (s === "#q" ? element : null), createRange: () => ({}) }, + getSelection: () => null, + 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.1" }), onInstalled: listener, onStartup: listener }, + alarms: { create() {}, onAlarm: listener }, action: { onClicked: listener }, webNavigation: { onCommitted: listener }, + debugger: { onDetach: listener }, + scripting: { executeScript: async ({ func, args = [] }) => [{ result: await vm.runInContext(`(${func.toString()})`, page)(...args) }] }, + tabs: { + onUpdated: { addListener: (fn) => listeners.add(fn), removeListener: (fn) => listeners.delete(fn) }, + get: async (id) => clone(tabs[id] ?? { id, windowId: 1, url: "https://fixture.test/", status: "complete" }), + }, + }; + const worker = { chrome, console, AbortController, setTimeout, clearTimeout, setInterval: () => 0, navigator: { userAgent: "unit-test", platform }, fetch: async () => { throw new Error("offline"); } }; + worker.self = worker; + vm.runInNewContext(workerSource, worker); + worker.sleep = async () => {}; + worker.getTabByParams = async (params) => clone(tabs[Number(params.targetId ?? 2)] ?? { id: Number(params.targetId ?? 2), windowId: 1, url: "https://fixture.test/", status: "complete" }); + worker.bringToFront = async (tab) => tab; + worker.attachDebugger = async () => {}; + worker.cdpMoveTo = async () => {}; + const splice = (text) => { + if (typeof element.value !== "string") return; + const { value, selectionStart: s, selectionEnd: e } = element; + element.value = value.slice(0, s) + text + value.slice(e); + element.selectionStart = element.selectionEnd = s + text.length; + }; + worker.cdp = async (_tabId, method, params = {}) => { + calls.push({ method, params: clone(params) }); + if (method === "Input.dispatchKeyEvent") { + if ((params.commands || []).includes("selectAll")) { element.selectionStart = 0; element.selectionEnd = element.value.length; } + else if (params.type === "keyDown" && params.key === "Delete") { + if (element.selectionEnd > element.selectionStart) splice(""); + } else if (params.type === "keyDown" && params.text && params.key !== "Enter") splice(params.text); + } + return {}; + }; + return { worker, calls, element, listeners, fire: (tabId, info) => { for (const fn of [...listeners]) fn(tabId, info); } }; +} + +test("typing into the middle of a prefilled field reports the splice instead of a bare count", async () => { + const h = harness(makeInput({ value: "why do flamingos stand", caret: 7 })); + const result = await h.worker.dispatch("page.type", { targetId: "2", selector: "#q", text: "cats " }); + assert.equal(h.element.value, "why do cats flamingos stand"); + assert.equal(result.valueBefore, "why do flamingos stand"); + assert.equal(result.valueAfter, "why do cats flamingos stand"); + assert.equal(result.existingTextLengthBefore, 22); + assert.equal(result.insertedAt, "caret-middle"); + assert.equal(result.replaced, undefined); +}); + +test("empty and caret-end targets are classified without warnings", async () => { + const empty = harness(makeInput()); + assert.equal((await empty.worker.dispatch("page.type", { targetId: "2", uid: "el-1", text: "hi" })).insertedAt, "empty"); + const end = harness(makeInput({ value: "hello" })); + const result = await end.worker.dispatch("page.type", { targetId: "2", uid: "el-1", text: "!" }); + assert.equal(result.insertedAt, "caret-end"); + assert.equal(result.valueAfter, "hello!"); +}); + +for (const [platform, modifiers] of [["MacIntel", 4], ["Win32", 2]]) { + test(`replace=true selects all with the platform shortcut + selectAll command, then deletes (${platform})`, async () => { + const h = harness(makeInput({ value: "old query text", caret: 3 }), { platform }); + const result = await h.worker.dispatch("page.type", { targetId: "2", selector: "#q", text: "new", replace: true }); + assert.equal(h.element.value, "new"); + assert.equal(result.replaced, true); + assert.equal(result.insertedAt, "replaced-all"); + assert.equal(result.valueBefore, "old query text", "before is read prior to select-all/delete"); + const selectAll = h.calls.find((c) => (c.params.commands || []).includes("selectAll")); + assert.equal(selectAll.params.modifiers, modifiers); + }); +} + +test("sensitive fields never expose contents in type evidence", async () => { + const h = harness(makeInput({ value: "hunter2", type: "password", name: "password" })); + const result = await h.worker.dispatch("page.type", { targetId: "2", selector: "#q", text: "x" }); + assert.equal(result.valueRedacted, true); + assert.equal(result.valueBefore, undefined); + assert.equal(result.valueAfter, undefined); + assert.ok(!JSON.stringify(result).includes("hunter2")); + assert.equal(result.existingTextLengthBefore, 7); +}); + +test("a non-text focus target reports no value evidence rather than a false empty field", async () => { + const h = harness({ tagName: "DIV", isConnected: true, isContentEditable: false, scrollIntoView() {}, getBoundingClientRect: () => ({ left: 0, top: 0, width: 10, height: 10 }) }); + const result = await h.worker.dispatch("page.type", { targetId: "2", selector: "#q", text: "x" }); + assert.equal(result.valueBefore, undefined); + assert.equal(result.insertedAt, undefined); +}); + +test("includeSnapshot waits for a navigation the action started and snapshots the new page", async () => { + const tabs = { 2: { id: 2, windowId: 1, url: "https://fixture.test/", status: "complete" } }; + const h = harness(makeInput(), { tabs }); + h.worker.chromeInputKey = async () => { + tabs[2] = { ...tabs[2], url: "https://fixture.test/search?q=x", status: "loading" }; + setTimeout(() => { tabs[2].status = "complete"; h.fire(2, { status: "complete" }); }, 20); + return { input: "chrome", key: "Enter" }; + }; + h.worker.snapshotInTab = async (params) => ({ url: tabs[Number(params.targetId)].url, status: tabs[Number(params.targetId)].status }); + const out = await h.worker.dispatch("page.key", { targetId: "2", key: "Enter", includeSnapshot: true }); + assert.equal(out.navigation.from, "https://fixture.test/"); + assert.equal(out.navigation.to, "https://fixture.test/search?q=x"); + assert.equal(out.navigation.settled, true); + assert.deepEqual(clone(out.snapshot), { url: "https://fixture.test/search?q=x", status: "complete" }); + assert.equal(h.listeners.size, 0, "listener removed"); +}); + +test("a non-navigating action snapshots immediately; an already-loading page is not waited on", async () => { + const tabs = { 2: { id: 2, windowId: 1, url: "https://fixture.test/", status: "complete" } }; + const h = harness(makeInput(), { tabs }); + h.worker.chromeInputClick = async () => ({ input: "chrome" }); + h.worker.snapshotInTab = async () => ({ ok: true }); + const plain = await h.worker.dispatch("page.click", { targetId: "2", selector: "#q", includeSnapshot: true }); + assert.equal(plain.navigation, undefined); + + tabs[2].status = "loading"; // e.g. a hanging subresource, same URL + const started = Date.now(); + const busy = await h.worker.dispatch("page.click", { targetId: "2", selector: "#q", includeSnapshot: true }); + assert.ok(Date.now() - started < 1_000, "no bounded wait for a load the action did not start"); + assert.equal(busy.navigation.settled, false); + assert.equal(busy.navigation.waitedMs < 1_000, true); +}); + +test("tab.new reports the settled tab and loadStatus instead of the t=0 loading stub", async () => { + const tabs = {}; + const h = harness(makeInput(), { tabs }); + h.worker.findGroupRecordByTitle = async () => null; + h.worker.trackSessionTab = async () => {}; + h.worker.groupTab = async (tab) => ({ tab: { id: tab.id, url: tab.url, status: tab.status }, group: { title: "Pi Session: t" } }); + h.worker.formatTab = async (tab) => ({ id: tab.id, url: tab.url, status: tab.status, title: tab.title }); + h.worker.chrome.tabs.create = async ({ url }) => { + tabs[9] = { id: 9, windowId: 1, url: "", title: "", status: "loading" }; + setTimeout(() => { tabs[9] = { ...tabs[9], url, title: "Loaded", status: "complete" }; h.fire(9, { status: "complete" }); }, 20); + return clone(tabs[9]); + }; + const out = await h.worker.dispatch("tab.new", { url: "https://fixture.test/page", background: true }); + assert.equal(out.loadStatus, "complete"); + assert.deepEqual(clone(out.tab), { id: 9, url: "https://fixture.test/page", status: "complete", title: "Loaded" }); + assert.equal(out.group.title, "Pi Session: t"); +}); + +// ---- Pi-side text ---- +function section(start, end) { + const from = indexSource.indexOf(start); + const to = indexSource.indexOf(end, from); + assert.ok(from >= 0 && to > from, `Missing source section: ${start}`); + return indexSource.slice(from, to); +} +const pi = vm.runInNewContext(`${stripTypeScriptTypes(section("function formatIncludedSnapshotText(", "\n// Keep raw CDP"))}; ({ formatIncludedSnapshotText, describeTypeEvidence })`, { + formatChromeSnapshot: (s) => `SNAPSHOT ${s.url}`, +}); + +test("chrome_type text warns on splices, mentions spliced submits, and redacts secrets", () => { + const spliced = pi.describeTypeEvidence({ valueBefore: "abc", valueAfter: "aXbc", insertedAt: "caret-middle" }, 1, true).join("\n"); + assert.match(spliced, /Field went from "abc" to "aXbc"/); + assert.match(spliced, /spliced into the middle/); + assert.match(spliced, /submitted the spliced value/); + const appended = pi.describeTypeEvidence({ valueBefore: "abc", valueAfter: "abcX", insertedAt: "caret-end" }, 1, false).join("\n"); + assert.match(appended, /appended to existing content/); + assert.doesNotMatch(appended, /⚠/); + const secret = pi.describeTypeEvidence({ valueRedacted: true, existingTextLengthBefore: 7, valueLengthAfter: 8, insertedAt: "caret-end" }, 1, false).join("\n"); + assert.match(secret, /\[redacted\] \(7 → 8 chars/); + assert.equal(pi.describeTypeEvidence({ input: "chrome" }, 1, false).length, 0); + const lost = pi.describeTypeEvidence({ valueBefore: "abc", valueAfter: "abc", existingTextLengthBefore: 3, valueLengthAfter: 3, insertedAt: "caret-middle" }, 5, true).join("\n"); + assert.match(lost, /did not change/); + assert.doesNotMatch(lost, /spliced/, "an unchanged field is not reported as a splice"); + const lostSecret = pi.describeTypeEvidence({ valueRedacted: true, existingTextLengthBefore: 3, valueLengthAfter: 3, insertedAt: "caret-end" }, 2, false).join("\n"); + assert.match(lostSecret, /did not change/); +}); + +test("included snapshots state whether a navigation settled", () => { + const settled = pi.formatIncludedSnapshotText({ snapshot: { url: "https://b.test/" }, navigation: { from: "https://a.test/", to: "https://b.test/", settled: true, waitedMs: 120 } }, "Pressed Enter."); + assert.match(settled, /Navigated https:\/\/a\.test\/ → https:\/\/b\.test\/ \(waited 120ms/); + assert.match(settled, /SNAPSHOT https:\/\/b\.test\//); + const stale = pi.formatIncludedSnapshotText({ snapshot: { url: "https://a.test/" }, navigation: { from: "https://a.test/", to: "https://b.test/", settled: false, waitedMs: 5000 } }, "Pressed Enter."); + assert.match(stale, /⚠ The action started a navigation that had not finished after 5000ms/); + assert.equal(pi.formatIncludedSnapshotText({ result: {} }, "Clicked."), "Clicked."); +}); |
