all repos — sz-chrome-remote-extension @ master

pi-chrome, patched to drive a signed-in browser on another host through a broker, with an MCP server

test-suite/unit/hidden-input.test.mjs (view raw)

 1
 2
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
// Hidden-tab input guard, full-screen automation targets, input.debug without a target, upload
// targets (label/wrapper/iframe/file-chooser), and screenshot pruning. Chrome and pages are mocked;
// the hidden-page behavior itself (Chrome drops trusted input) was measured live on Chrome/macOS.
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
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) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value)));

function worker({ visibility = ["visible"], tab = { id: 2, windowId: 1, active: false, url: "https://fixture.test/" }, win = { id: 1, state: "normal" } } = {}) {
  const calls = [];
  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, onEvent: listener, getTargets: (cb) => cb([]) },
    storage: { session: { get: async () => ({}), set: async () => {} } },
    scripting: { executeScript: async (options) => { calls.push({ method: "scripting.executeScript", options }); return [{ result: { tag: "BUTTON", url: tab.url } }]; } },
    tabs: {
      get: async (id) => (id === tab.id ? clone(tab) : Promise.reject(new Error(`No tab with id: ${id}`))),
      query: async () => [clone(tab)],
      create: async (props) => { calls.push({ method: "tabs.create", props }); return { id: 77, windowId: 1, url: props.url }; },
    },
    windows: {
      get: async () => clone(win),
      getLastFocused: async () => clone(win),
      create: async (props) => { calls.push({ method: "windows.create", props }); return { id: 9, tabs: [{ id: 90, windowId: 9 }] }; },
    },
  };
  const w = { chrome, console, setTimeout, clearTimeout, setInterval: () => 0, navigator: { userAgent: "unit-test" }, fetch: async () => { throw new Error("offline"); } };
  w.self = w;
  vm.runInNewContext(workerSource, w);
  w.sleep = async () => {};
  w.getTabByParams = async () => clone(tab);
  w.attachDebugger = async () => {};
  w.cdpMoveTo = async () => {};
  w.resolveTargetInTab = async () => ({ found: true, x: 40, y: 50, rect: { left: 10, top: 10, width: 100, height: 20 }, tag: "INPUT" });
  let probes = 0;
  w.cdp = async (_tabId, method, params = {}) => {
    calls.push({ method, params: clone(params) });
    if (method === "Runtime.evaluate" && params.expression === "document.visibilityState") {
      const state = visibility[Math.min(probes++, visibility.length - 1)];
      if (state instanceof Error) throw state;
      return { result: { type: "string", value: state } };
    }
    return {};
  };
  return { w, chrome, calls, inputs: () => calls.filter((c) => c.method.startsWith("Input.")) };
}

// ---- hidden-tab guard ----
test("typing into a hidden tab fails fast with the reason instead of silently dropping keys", async () => {
  const h = worker({ visibility: ["hidden"] });
  await assert.rejects(h.w.dispatch("page.type", { selector: "#q", text: "hi", background: true }), (error) => {
    assert.match(error.message, /chrome\.type: tab 2 is hidden because it is an inactive tab in its window/);
    assert.match(error.message, /background:false/);
    return true;
  });
  assert.equal(h.inputs().length, 0, "no trusted input sent to a hidden page");
});

for (const [action, params] of [["key", { key: "Enter" }], ["hover", { x: 1, y: 1 }], ["scroll", { deltaY: 10 }], ["tap", { x: 1, y: 1 }], ["drag", { fromX: 1, fromY: 1, toX: 2, toY: 2 }]]) {
  test(`${action} refuses hidden tabs before sending input`, async () => {
    const h = worker({ visibility: ["hidden"] });
    await assert.rejects(h.w.dispatch(`page.${action}`, { ...params, background: true }), new RegExp(`chrome\\.${action}: tab 2 is hidden`));
    assert.equal(h.inputs().length, 0);
  });
}

test("click and fill use their DOM fallback immediately for hidden tabs, or reject with domFallback:false", async () => {
  const click = worker({ visibility: ["hidden"] });
  const clicked = await click.w.dispatch("page.click", { selector: "button", background: true });
  assert.equal(clicked.input, "dom-fallback");
  assert.match(clicked.reason, /hidden/);
  assert.equal(click.inputs().length, 0);
  await assert.rejects(worker({ visibility: ["hidden"] }).w.dispatch("page.click", { selector: "button", background: true, domFallback: false }), /is hidden/);

  const fill = worker({ visibility: ["hidden"] });
  fill.w.domFillFallback = async (_tabId, _params, cause) => ({ input: "dom-fallback", reason: String(cause.message) });
  const filled = await fill.w.dispatch("page.fill", { selector: "#q", text: "x", background: true });
  assert.equal(filled.input, "dom-fallback");
  assert.match(filled.reason, /hidden/);
  assert.equal(fill.inputs().length, 0);
});

test("hidden reasons distinguish minimized and macOS full-screen windows", async () => {
  const minimized = worker({ visibility: ["hidden"], win: { id: 1, state: "minimized" } });
  await assert.rejects(minimized.w.dispatch("page.key", { key: "a", background: true }), /its window is minimized/);
  const fullscreen = worker({ visibility: ["hidden"], tab: { id: 2, windowId: 1, active: true, url: "https://fixture.test/" }, win: { id: 1, state: "fullscreen" } });
  await assert.rejects(fullscreen.w.dispatch("page.key", { key: "a", background: true }), (error) => {
    assert.match(error.message, /behind a full-screen window/);
    assert.equal(error.windowState, "fullscreen");
    return true;
  });
});

test("visible pages, failed probes, and foreground tabs that become visible proceed with trusted input", async () => {
  const visible = worker();
  assert.equal((await visible.w.dispatch("page.key", { key: "a", background: true })).input, "chrome");
  const unknown = worker({ visibility: [new Error("probe failed")] });
  assert.equal((await unknown.w.dispatch("page.key", { key: "a", background: true })).input, "chrome");
  const foreground = worker({ visibility: ["hidden", "hidden", "visible"] });
  foreground.w.bringToFront = async (tab) => tab;
  assert.equal((await foreground.w.dispatch("page.key", { key: "a", foreground: true })).input, "chrome");
});

// ---- automation target placement ----
test("automation targets get their own window even while the user's window is full screen", async () => {
  // Chrome disables macOS automatic window tabbing, so a new window is never merged into the user's
  // full-screen window; putting Pi's tab in the user's window would only lose isolation.
  for (const state of ["fullscreen", "maximized"]) {
    const h = worker({ win: { id: 1, state } });
    await h.w.createAutomationTarget(`s-${state}`, undefined);
    assert.deepEqual(clone(h.calls.find((c) => c.method === "windows.create").props), { url: "about:blank", focused: false }, state);
    assert.equal(h.calls.some((c) => c.method === "tabs.create"), false, state);
  }
});

// ---- input.debug ----
test("input.debug without a target does not call tabs.get(-1) and reports the session's own tab", async () => {
  const h = worker();
  h.chrome.tabs.get = async (id) => {
    if (!Number.isInteger(id) || id < 0) throw new Error("Value must be at least 0.");
    return id === 2 ? { id: 2, windowId: 1, url: "https://fixture.test/" } : Promise.reject(new Error("missing"));
  };
  const none = await h.w.dispatch("input.debug", {});
  assert.equal(none.resolvedTab, null);
  h.w.resolveOwnedAutomationTarget = async () => ({ id: 2 });
  const owned = await h.w.dispatch("input.debug", { sessionKey: "s" });
  assert.equal(owned.resolvedTab.id, 2);
});

// ---- upload targets ----
function uploadWorker({ visibility = "visible", chooserMode = "selectMultiple", opensChooser = true } = {}) {
  const h = worker({ visibility: [visibility] });
  const fileInput = { tagName: "INPUT", type: "file", multiple: true, isConnected: true };
  const frameInput = { tagName: "INPUT", type: "file", multiple: false, isConnected: true };
  const button = { tagName: "BUTTON", isConnected: true, querySelectorAll: () => [] };
  const label = { tagName: "LABEL", control: fileInput, isConnected: true, querySelectorAll: () => [] };
  const dropzone = { tagName: "DIV", isConnected: true, querySelectorAll: () => [fileInput] };
  const frameDoc = { querySelector: (s) => (s === "#frame-file" ? frameInput : null), querySelectorAll: () => [] };
  const doc = {
    querySelector: (s) => ({ "#file": fileInput, "#button": button, "#label": label, "#drop": dropzone }[s] ?? null),
    querySelectorAll: (s) => (s === "iframe,frame" ? [{ contentDocument: frameDoc }] : []),
  };
  const page = vm.createContext({ document: doc, __PI_CHROME_STATE__: { elements: {} } });
  page.window = page;
  const objects = new Map();
  let nextObject = 1;
  const base = h.w.cdp;
  h.w.cdp = async (tabId, method, params = {}) => {
    if (method === "Runtime.evaluate" && params.objectGroup === "pi-chrome-upload") {
      h.calls.push({ method, params: {} });
      try {
        const el = vm.runInContext(params.expression, page);
        const objectId = `obj-${nextObject++}`;
        objects.set(objectId, el);
        return { result: { objectId } };
      } catch (error) {
        return { exceptionDetails: { text: error.message } };
      }
    }
    const result = await base(tabId, method, params);
    if (method === "Runtime.callFunctionOn" && /getBoundingClientRect/.test(params.functionDeclaration)) {
      const el = objects.get(params.objectId);
      const isFile = el.tagName === "INPUT" && el.type === "file";
      return { result: { value: { tag: el.tagName, isFile, multiple: isFile ? el.multiple : undefined, inFrame: el === frameInput, x: 50, y: 60, width: 80, height: 20 } } };
    }
    const clicked = (method === "Input.dispatchMouseEvent" && params.type === "mouseReleased") || (method === "Runtime.callFunctionOn" && params.userGesture === true);
    if (clicked && opensChooser) setTimeout(() => h.w.handleDebuggerEvent({ tabId: 2 }, "Page.fileChooserOpened", { backendNodeId: 42, mode: chooserMode }), 0);
    return result;
  };
  h.w.waitForFileChooser = ((original) => (tabId) => original(tabId, 200))(h.w.waitForFileChooser);
  return h;
}
const setFiles = (h) => h.calls.filter((c) => c.method === "DOM.setFileInputFiles").map((c) => c.params);

test("upload resolves labels and single-input wrappers to the file input without clicking", async () => {
  for (const selector of ["#file", "#label", "#drop"]) {
    const h = uploadWorker();
    const result = await h.w.dispatch("page.upload", { selector, paths: ["/a.txt", "/b.txt"], background: true });
    assert.equal(result.mode, "file-input", selector);
    assert.equal(setFiles(h).length, 1);
    assert.equal(h.inputs().length, 0, "no click for a real file input");
  }
});

test("upload finds file inputs inside same-origin iframes and enforces single-file inputs", async () => {
  const h = uploadWorker();
  const result = await h.w.dispatch("page.upload", { selector: "#frame-file", paths: ["/a.txt"], background: true });
  assert.equal(result.mode, "file-input");
  assert.equal(result.inFrame, true);
  await assert.rejects(uploadWorker().w.dispatch("page.upload", { selector: "#frame-file", paths: ["/a.txt", "/b.txt"], background: true }), /accepts one file but 2 paths/);
});

test("upload buttons open an intercepted file chooser with a trusted click on visible pages", async () => {
  const h = uploadWorker();
  const result = await h.w.dispatch("page.upload", { selector: "#button", paths: ["/a.txt"], background: true });
  assert.equal(result.mode, "file-chooser");
  assert.equal(result.trigger, "chrome");
  assert.deepEqual(clone(setFiles(h)), [{ backendNodeId: 42, files: ["/a.txt"] }]);
  assert.deepEqual(h.calls.filter((c) => c.method === "Page.setInterceptFileChooserDialog").map((c) => c.params.enabled), [true, false]);
  assert.ok(h.inputs().some((c) => c.params.type === "mousePressed"));
});

test("hidden pages use a user-gesture element.click() for upload buttons unless domFallback:false", async () => {
  const h = uploadWorker({ visibility: "hidden" });
  const result = await h.w.dispatch("page.upload", { selector: "#button", paths: ["/a.txt"], background: true });
  assert.equal(result.trigger, "activation-click");
  assert.match(result.reason, /hidden/);
  assert.equal(h.inputs().length, 0);
  const strict = uploadWorker({ visibility: "hidden" });
  await assert.rejects(strict.w.dispatch("page.upload", { selector: "#button", paths: ["/a.txt"], background: true, domFallback: false }), /is hidden/);
  assert.equal(setFiles(strict).length, 0);
});

test("chooser failures attach nothing and always disable interception", async () => {
  const single = uploadWorker({ chooserMode: "selectSingle" });
  await assert.rejects(single.w.dispatch("page.upload", { selector: "#button", paths: ["/a.txt", "/b.txt"], background: true }), /accepts one file/);
  assert.equal(setFiles(single).length, 0);
  const none = uploadWorker({ opensChooser: false });
  await assert.rejects(none.w.dispatch("page.upload", { selector: "#button", paths: ["/a.txt"], background: true }), /no file chooser opened/);
  assert.deepEqual(none.calls.filter((c) => c.method === "Page.setInterceptFileChooserDialog").map((c) => c.params.enabled), [true, false]);
  assert.equal(none.calls.at(-1).method, "Runtime.releaseObject");
});

test("file chooser events attached by targetId route to the right tab", async () => {
  const h = worker();
  vm.runInContext('attachedTabs.set(5, { debuggee: { targetId: "T5" } })', h.w);
  const pending = h.w.waitForFileChooser(5, 1000);
  h.w.handleDebuggerEvent({ targetId: "T5" }, "Page.fileChooserOpened", { backendNodeId: 7, mode: "selectSingle" });
  assert.deepEqual(clone(await pending.promise), { backendNodeId: 7, mode: "selectSingle" });
});

// ---- screenshot pruning ----
const pruneSource = indexSource.slice(indexSource.indexOf("const SCREENSHOT_NAME_RE"), indexSource.indexOf("// End screenshot pruning."));
const prune = vm.runInNewContext(`${stripTypeScriptTypes(pruneSource)}; ({ screenshotFilesToPrune, pruneScreenshotDir })`, {
  readdir: fs.promises.readdir, unlink: fs.promises.unlink, join: path.join, Date,
});
const DAY = 24 * 60 * 60 * 1000;
const stamp = (ms) => new Date(ms).toISOString().replace(/[:.]/g, "-");

test("screenshot pruning keeps the newest 20 captures and anything inside the retention window", () => {
  const now = Date.parse("2026-09-26T12:00:00.000Z");
  const names = [];
  for (let i = 0; i < 25; i++) names.push(`${stamp(now - (30 + i) * DAY)}.png`);
  const fullPage = stamp(now - 60 * DAY);
  names.push(`${fullPage}-tile0.png`, `${fullPage}-tile1.png`, `${fullPage}.png.json`);
  names.push(`${stamp(now - DAY)}.jpeg`, "my-notes.png", "2026-01-01.png", "keep.json");
  const pruned = prune.screenshotFilesToPrune(names, now);
  // 25 old singles + 1 full-page capture + 1 recent = 27 captures; the newest 20 survive. The recent
  // capture is newest, so 19 old singles survive and 6 old singles + the full-page capture go.
  assert.equal(pruned.length, 6 + 3);
  for (const suffix of ["-tile0.png", "-tile1.png", ".png.json"]) assert.ok(pruned.includes(`${fullPage}${suffix}`));
  for (const name of ["my-notes.png", "2026-01-01.png", "keep.json", `${stamp(now - DAY)}.jpeg`]) assert.ok(!pruned.includes(name), name);

  const recent = Array.from({ length: 30 }, (_, i) => `${stamp(now - i * 60_000)}.png`);
  assert.equal(prune.screenshotFilesToPrune(recent, now).length, 0, "nothing inside the retention window is removed");
  assert.equal(prune.screenshotFilesToPrune(names, now, 0).length, 0, "retentionDays:0 disables pruning");
  assert.equal(prune.screenshotFilesToPrune(names, now, 55).length, 3, "a longer window keeps more");
});

test("pruneScreenshotDir deletes only eligible files and never throws", async () => {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-chrome-shots-"));
  const now = Date.now();
  for (let i = 0; i < 22; i++) fs.writeFileSync(path.join(dir, `${stamp(now - (10 + i) * DAY)}.png`), "x");
  fs.writeFileSync(path.join(dir, "mine.png"), "x");
  assert.equal(await prune.pruneScreenshotDir(dir), 2);
  assert.equal(fs.readdirSync(dir).length, 21);
  assert.ok(fs.existsSync(path.join(dir, "mine.png")));
  assert.equal(await prune.pruneScreenshotDir(path.join(dir, "missing")), 0);
  fs.rmSync(dir, { recursive: true, force: true });
});

// ---- /chrome doctor visibility report ----
test("automation.status reports whether this session's own tab is visible, without creating one", async () => {
  const h = worker({ tab: { id: 2, windowId: 1, active: false, url: "https://fixture.test/" }, win: { id: 1, state: "normal" } });
  h.chrome.scripting.executeScript = async () => [{ result: "hidden" }];
  const none = await h.w.dispatch("automation.status", { sessionKey: "s", includeVisibility: true });
  assert.deepEqual(clone(none), { windowId: null, tabId: null });
  assert.equal(h.calls.some((c) => c.method === "tabs.create" || c.method === "windows.create"), false);

  vm.runInContext("automationTargets.set('s', { tabId: 2, windowId: undefined })", h.w);
  const hidden = await h.w.dispatch("automation.status", { sessionKey: "s", includeVisibility: true });
  assert.equal(hidden.visibility, "hidden");
  assert.match(hidden.hiddenReason, /inactive tab/);

  h.chrome.scripting.executeScript = async () => [{ result: "visible" }];
  const visible = await h.w.dispatch("automation.status", { sessionKey: "s", includeVisibility: true });
  assert.equal(visible.visibility, "visible");
  assert.equal(visible.hiddenReason, undefined);
  assert.deepEqual(clone(await h.w.dispatch("automation.status", { sessionKey: "s" })), { windowId: null, tabId: 2 }, "plain status is unchanged");
});