diff options
Diffstat (limited to 'test-suite/unit')
| -rw-r--r-- | test-suite/unit/automation-target.test.mjs | 4 | ||||
| -rw-r--r-- | test-suite/unit/background-policy.test.mjs | 14 | ||||
| -rw-r--r-- | test-suite/unit/chrome-command.test.mjs | 3 | ||||
| -rw-r--r-- | test-suite/unit/hidden-input.test.mjs | 98 | ||||
| -rw-r--r-- | test-suite/unit/shared-window.test.mjs | 61 | ||||
| -rw-r--r-- | test-suite/unit/user-window.test.mjs | 119 |
6 files changed, 193 insertions, 106 deletions
diff --git a/test-suite/unit/automation-target.test.mjs b/test-suite/unit/automation-target.test.mjs index 73fbc77..1ac3fd6 100644 --- a/test-suite/unit/automation-target.test.mjs +++ b/test-suite/unit/automation-target.test.mjs @@ -9,6 +9,10 @@ // Like csp-eval.test.mjs we load the *real* worker into a vm sandbox with a stateful chrome.* // mock, then exercise the real helpers and the real dispatch() paths. Chrome state (tabs/windows/ // storage.session) can be shared across two sandbox loads to simulate a service-worker restart. +// +// Its chrome.windows mock has no getLastFocused, so the worker takes the path for a browser with no +// window to share and gives each target a window of its own. Placement in the user's window is +// covered by user-window.test.mjs. import vm from "node:vm"; import fs from "node:fs"; diff --git a/test-suite/unit/background-policy.test.mjs b/test-suite/unit/background-policy.test.mjs index ea08be0..98eaa7c 100644 --- a/test-suite/unit/background-policy.test.mjs +++ b/test-suite/unit/background-policy.test.mjs @@ -71,7 +71,7 @@ function piHarness({ session = "alpha", send } = {}) { } const flagCases = [ - [{}, false], [{ background: false }, false], [{ background: true }, true], + [{}, true], [{ background: false }, false], [{ background: true }, true], [{ foreground: true }, false], [{ foreground: false }, true], [{ background: false, foreground: false }, false], [{ background: true, foreground: true }, true], ]; @@ -96,6 +96,7 @@ test("session background on overrides all per-call/legacy foreground flags; off test("every registered page tool, tab.new, and chrome_launch(url) use the central policy", async () => { const h = piHarness(); + await h.background("on"); const cases = [ ["chrome_launch", { url: "https://example.test" }, "tab.new"], ["chrome_tab", { action: "new", group: false, groupTitle: "other" }, "tab.new"], @@ -143,7 +144,11 @@ test("typing options and verification pass through real tool registrations witho test("activation is rejected before dispatch; existing on/off/toggle/status commands suffice", async () => { const h = piHarness(); - await assert.rejects(h.tool("chrome_tab", { action: "activate", targetId: "2", background: false }), /background mode.*\/chrome background off/); + await h.tool("chrome_tab", { action: "activate", targetId: "2" }); + assert.equal(h.calls.at(-1).params.foreground, true, "activation is allowed by default"); + h.calls.length = 0; + await h.background("on"); + await assert.rejects(h.tool("chrome_tab", { action: "activate", targetId: "2", background: false }), /background mode on/); assert.equal(h.calls.length, 0); await h.background("off"); await h.tool("chrome_tab", { action: "activate", targetId: "2" }); @@ -199,9 +204,9 @@ test("shared-owner sessions retain independent policies; per-call background sti const sharedCalls = []; const send = async (action, params) => { sharedCalls.push({ action, params }); return {}; }; const a = piHarness({ session: "A", send }), b = piHarness({ session: "B", send }); - await b.background("off"); + await a.background("on"); await a.send("tab.new", { background: false }); - await b.send("tab.new", {}); + await b.send("tab.new", { background: false }); assert.equal(sharedCalls[0].params.sessionKey, "session:A"); assert.equal(sharedCalls[0].params.foreground, false); assert.equal(sharedCalls[0].action, "tab.new.background"); @@ -314,6 +319,7 @@ function assertNoFocus(h) { test("Pi-to-worker background aliases preserve grouping, screenshot output, and ownership-aware cleanup", async () => { const h = workerHarness(); const pi = piHarness({ send: (action, params) => h.w.dispatch(action, params) }); + await pi.background("on"); const opened = await pi.tool("chrome_tab", { action: "new", group: false, groupTitle: "wrong", background: false }); assert.equal(opened.details.result.group.title, "Pi Session: alpha"); const launched = await pi.tool("chrome_launch", { url: "https://fixture.test" }); diff --git a/test-suite/unit/chrome-command.test.mjs b/test-suite/unit/chrome-command.test.mjs index dac719a..28bfe8a 100644 --- a/test-suite/unit/chrome-command.test.mjs +++ b/test-suite/unit/chrome-command.test.mjs @@ -174,8 +174,7 @@ test("Doctor warns when this session's Pi tab is hidden, and stays quiet when it await hidden.run("doctor"); const report = hidden.notices.at(-1)[0]; assert.match(report, /⚠ Pi's tab is hidden because its window is covered by another window/); - assert.match(report, /Chrome ignores clicks and typing in hidden tabs/); - assert.match(report, /\/chrome background off/); + assert.match(report, /Clicks, typing and screenshots still work there/); const none = harness({ send: withStatus({ windowId: null, tabId: null }) }); await none.run("doctor"); diff --git a/test-suite/unit/hidden-input.test.mjs b/test-suite/unit/hidden-input.test.mjs index 874b5e5..b49229c 100644 --- a/test-suite/unit/hidden-input.test.mjs +++ b/test-suite/unit/hidden-input.test.mjs @@ -1,6 +1,8 @@ -// 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. +// Hidden-tab input, automation targets beside a full-screen window, input.debug without a target, +// upload targets (label/wrapper/iframe/file-chooser), and screenshot pruning. Chrome and pages are +// mocked. Measured live in Brave on 2026-11-14: an inactive tab takes trusted clicks, keys, text and +// mouse moves and renders screenshots; a mouseWheel there hangs. Minimized windows are untested, so +// input there still fails up front. import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; @@ -54,50 +56,68 @@ function worker({ visibility = ["visible"], tab = { id: 2, windowId: 1, active: 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 () => { +// ---- hidden-tab input ---- +const minimized = { id: 1, state: "minimized" }; + +test("typing into an inactive tab sends trusted input", 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"); + const result = await h.w.dispatch("page.type", { selector: "#q", text: "hi", background: true }); + assert.ok(h.inputs().length > 0, "trusted input sent to the inactive tab"); + assert.notEqual(result.input, "dom-fallback"); }); -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 () => { +for (const [action, params] of [["key", { key: "Enter" }], ["hover", { x: 1, y: 1 }], ["tap", { x: 1, y: 1 }], ["drag", { fromX: 1, fromY: 1, toX: 2, toY: 2 }]]) { + test(`${action} sends trusted input to an inactive tab and refuses a minimized window`, 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); + let moves = 0; + h.w.cdpMoveTo = async () => { moves++; }; // hover is only a mouse move, stubbed by the harness + await h.w.dispatch(`page.${action}`, { ...params, background: true }); + assert.ok(h.inputs().length + moves > 0); + const m = worker({ visibility: ["hidden"], win: minimized }); + await assert.rejects(m.w.dispatch(`page.${action}`, { ...params, background: true }), new RegExp(`chrome\\.${action}: tab 2 is hidden because its window is minimized`)); + assert.equal(m.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"] }); +test("scroll on an inactive tab scrolls by script, since Chrome never answers its wheel events", async () => { + const h = worker({ visibility: ["hidden"] }); + const result = await h.w.dispatch("page.scroll", { deltaY: 300, background: true }); + assert.equal(result.input, "script"); + assert.equal(h.inputs().length, 0, "no mouseWheel sent"); + assert.ok(h.calls.some((c) => c.method === "Runtime.evaluate" && /scrollBy/.test(c.params.expression))); + const visible = worker(); + assert.equal((await visible.w.dispatch("page.scroll", { deltaY: 300, background: true })).input, "chrome"); +}); + +test("click and fill send trusted input to an inactive tab; in a minimized window they use their DOM fallback, or reject with domFallback:false", async () => { + const inactive = worker({ visibility: ["hidden"] }); + assert.equal((await inactive.w.dispatch("page.click", { selector: "button", background: true })).input, "chrome"); + assert.ok(inactive.inputs().length > 0); + + const click = worker({ visibility: ["hidden"], win: minimized }); const clicked = await click.w.dispatch("page.click", { selector: "button", background: true }); assert.equal(clicked.input, "dom-fallback"); - assert.match(clicked.reason, /hidden/); + assert.match(clicked.reason, /minimized/); assert.equal(click.inputs().length, 0); - await assert.rejects(worker({ visibility: ["hidden"] }).w.dispatch("page.click", { selector: "button", background: true, domFallback: false }), /is hidden/); + await assert.rejects(worker({ visibility: ["hidden"], win: minimized }).w.dispatch("page.click", { selector: "button", background: true, domFallback: false }), /is hidden/); - const fill = worker({ visibility: ["hidden"] }); + const fill = worker({ visibility: ["hidden"], win: minimized }); 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.match(filled.reason, /minimized/); 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"); +test("a hidden page in a full-screen or covered window still takes input; only minimized is refused", async () => { + const m = worker({ visibility: ["hidden"], win: minimized }); + await assert.rejects(m.w.dispatch("page.key", { key: "a", background: true }), (error) => { + assert.match(error.message, /its window is minimized/); + assert.equal(error.windowState, "minimized"); return true; }); + const fullscreen = worker({ visibility: ["hidden"], tab: { id: 2, windowId: 1, active: true, url: "https://fixture.test/" }, win: { id: 1, state: "fullscreen" } }); + assert.equal((await fullscreen.w.dispatch("page.key", { key: "a", background: true })).input, "chrome"); }); test("visible pages, failed probes, and foreground tabs that become visible proceed with trusted input", async () => { @@ -105,20 +125,18 @@ test("visible pages, failed probes, and foreground tabs that become visible proc 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"] }); + const foreground = worker({ visibility: ["hidden", "hidden", "visible"], win: minimized }); 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. +test("automation targets go in the user's window, inactive, even while it is full screen", async () => { 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); + assert.deepEqual(clone(h.calls.find((c) => c.method === "tabs.create").props), { url: "about:blank", active: false, windowId: 1 }, state); + assert.equal(h.calls.some((c) => c.method === "windows.create"), false, state); } }); @@ -137,8 +155,8 @@ test("input.debug without a target does not call tabs.get(-1) and reports the se }); // ---- upload targets ---- -function uploadWorker({ visibility = "visible", chooserMode = "selectMultiple", opensChooser = true } = {}) { - const h = worker({ visibility: [visibility] }); +function uploadWorker({ visibility = "visible", chooserMode = "selectMultiple", opensChooser = true, win } = {}) { + const h = worker({ visibility: [visibility], ...(win ? { win } : {}) }); 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: () => [] }; @@ -209,13 +227,15 @@ test("upload buttons open an intercepted file chooser with a trusted click on vi 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" }); +test("upload buttons in a minimized window use a user-gesture element.click() unless domFallback:false", async () => { + const inactive = uploadWorker({ visibility: "hidden" }); + assert.equal((await inactive.w.dispatch("page.upload", { selector: "#button", paths: ["/a.txt"], background: true })).trigger, "chrome", "an inactive tab gets the trusted click"); + const h = uploadWorker({ visibility: "hidden", win: minimized }); 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" }); + const strict = uploadWorker({ visibility: "hidden", win: minimized }); 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); }); diff --git a/test-suite/unit/shared-window.test.mjs b/test-suite/unit/shared-window.test.mjs deleted file mode 100644 index 66041f4..0000000 --- a/test-suite/unit/shared-window.test.mjs +++ /dev/null @@ -1,61 +0,0 @@ -// Each session has its own window: a window shows only one tab, so sessions sharing one hid each -// other's tabs (no real input, blank screenshots). A session's extra tabs go into its own window. -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"); - -function worker({ groups = [], windows = { 5: { id: 5, type: "normal" } } } = {}) { - const calls = []; - const listener = { addListener() {}, removeListener() {} }; - let nextTab = 100; - const w = { - console, setTimeout, clearTimeout, setInterval: () => 0, navigator: { userAgent: "unit-test" }, fetch: async () => { throw new Error("offline"); }, - 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 (v) => { calls.push({ method: 'storage.set', json: JSON.stringify(v) }); } } }, - tabGroups: { query: async () => groups }, - windows: { - get: async (id) => windows[id] ?? Promise.reject(new Error("No window")), - create: async (props) => { calls.push({ method: "windows.create", props }); return { id: 9, tabs: [{ id: 900, windowId: 9 }] }; }, - }, - tabs: { - create: async (props) => { calls.push({ method: "tabs.create", props }); return { id: nextTab++, windowId: props.windowId }; }, - query: async () => [], get: async () => null, onUpdated: listener, - }, - }, - }; - w.self = w; - vm.runInNewContext(workerSource, w); - return { w, calls }; -} - -const piGroup = (windowId, name = "Pi Session: claude-code 1 other") => ({ id: 1, windowId, title: name }); -const ops = (calls) => calls.filter((c) => c.method !== "storage.set").map((c) => c.method); - -test("a new session gets its own window even when another session's window exists", async () => { - const { w, calls } = worker({ groups: [piGroup(5)] }); - await w.createAutomationTarget("claude-code 2 mine", "Pi Session: claude-code 2 mine"); - assert.deepEqual(ops(calls), ["windows.create"]); -}); - -test("a session that already has a group adds its tab to that group's window", async () => { - const { w, calls } = worker({ groups: [piGroup(5, "Pi Session: claude-code 2 mine")] }); - await w.createAutomationTarget("claude-code 2 mine", "Pi Session: claude-code 2 mine"); - assert.deepEqual(ops(calls), ["tabs.create"]); - assert.equal(calls.find((c) => c.method === "tabs.create").props.windowId, 5); -}); - -test("chrome_tab new for a session with no group goes to the session's own window, not the focused one", async () => { - const { w, calls } = worker({ groups: [piGroup(5)] }); - w.groupTab = async (tab) => tab; - w.bringToFront = async () => {}; - await w.dispatch("tab.new", { url: "https://x.test/", groupTitle: "Pi Session: claude-code 2 mine", sessionKey: "claude-code 2 mine" }); - assert.deepEqual(ops(calls), ["windows.create"]); - assert.equal(calls[0].props.url, "https://x.test/"); - assert.equal(calls[0].props.focused, false); -}); diff --git a/test-suite/unit/user-window.test.mjs b/test-suite/unit/user-window.test.mjs new file mode 100644 index 0000000..af4e939 --- /dev/null +++ b/test-suite/unit/user-window.test.mjs @@ -0,0 +1,119 @@ +// Agents' tabs go in the user's window, inactive, in per-session tab groups that start collapsed. +// An inactive tab takes trusted input and renders screenshots (measured 2026-11-14), so there is no +// need for a window per session. A window is created only when Brave has none. +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"); + +function worker({ groups = [], windows = { 5: { id: 5, type: "normal" } }, lastFocused = 5, tabs = {} } = {}) { + const calls = []; + const listener = { addListener() {}, removeListener() {} }; + let nextTab = 100; + const w = { + console, setTimeout, clearTimeout, setInterval: () => 0, navigator: { userAgent: "unit-test" }, fetch: async () => { throw new Error("offline"); }, + 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 (v) => { calls.push({ method: 'storage.set', json: JSON.stringify(v) }); } } }, + tabGroups: { query: async () => groups, update: async (id, props) => { calls.push({ method: "tabGroups.update", id, props }); return { id, ...props }; } }, + windows: { + get: async (id) => windows[id] ?? Promise.reject(new Error("No window")), + getLastFocused: async () => (lastFocused === null ? Promise.reject(new Error("No window")) : windows[lastFocused]), + getAll: async () => Object.values(windows), + update: async (id, props) => { calls.push({ method: "windows.update", id, props }); return { id }; }, + create: async (props) => { calls.push({ method: "windows.create", props }); return { id: 9, tabs: [{ id: 900, windowId: 9 }] }; }, + }, + tabs: { + create: async (props) => { calls.push({ method: "tabs.create", props }); return { id: nextTab++, windowId: props.windowId }; }, + query: async () => [], get: async (id) => tabs[id] ?? null, onUpdated: listener, + group: async () => 77, + update: async (id, props) => { calls.push({ method: "tabs.update", id, props }); return { id, ...props }; }, + }, + }, + }; + w.self = w; + vm.runInNewContext(workerSource, w); + return { w, calls }; +} + +const piGroup = (windowId, name = "Pi Session: claude-code 1 other") => ({ id: 1, windowId, title: name }); +const ops = (calls) => calls.filter((c) => c.method !== "storage.set").map((c) => c.method); +const noWindows = { windows: {}, lastFocused: null }; + +test("a new session's tab goes in the user's window, inactive", async () => { + const { w, calls } = worker({ groups: [piGroup(5)] }); + await w.createAutomationTarget("claude-code 2 mine", "Pi Session: claude-code 2 mine"); + assert.deepEqual(ops(calls), ["tabs.create"]); + const props = calls.find((c) => c.method === "tabs.create").props; + assert.equal(props.windowId, 5); + assert.equal(props.active, false); +}); + +test("a window is created only when Brave has none", async () => { + const { w, calls } = worker(noWindows); + await w.createAutomationTarget("claude-code 2 mine", "Pi Session: claude-code 2 mine"); + assert.deepEqual(ops(calls), ["windows.create"]); + assert.equal(calls[0].props.focused, false); +}); + +test("a session that already has a group adds its tab to that group's window", async () => { + const { w, calls } = worker({ groups: [piGroup(7, "Pi Session: claude-code 2 mine")], windows: { 5: { id: 5 }, 7: { id: 7 } } }); + await w.createAutomationTarget("claude-code 2 mine", "Pi Session: claude-code 2 mine"); + assert.deepEqual(ops(calls), ["tabs.create"]); + assert.equal(calls.find((c) => c.method === "tabs.create").props.windowId, 7); +}); + +test("chrome_tab new for a session with no group goes in the user's window, inactive", async () => { + const { w, calls } = worker({ groups: [piGroup(5)] }); + w.groupTab = async (tab) => tab; + w.bringToFront = async () => {}; + await w.dispatch("tab.new", { url: "https://x.test/", groupTitle: "Pi Session: claude-code 2 mine", sessionKey: "claude-code 2 mine" }); + assert.deepEqual(ops(calls), ["tabs.create"]); + assert.equal(calls[0].props.windowId, 5); + assert.equal(calls[0].props.active, false); +}); + +test("chrome_tab new with no window open creates an unfocused one", async () => { + const { w, calls } = worker(noWindows); + w.groupTab = async (tab) => tab; + w.bringToFront = async () => {}; + await w.dispatch("tab.new", { url: "https://x.test/", groupTitle: "Pi Session: claude-code 2 mine", sessionKey: "claude-code 2 mine" }); + assert.deepEqual(ops(calls), ["windows.create"]); + assert.equal(calls[0].props.focused, false); +}); + +test("a new group starts collapsed; an existing group keeps the user's setting", async () => { + const { w, calls } = worker({ tabs: { 300: { id: 300, windowId: 5, active: false, groupId: -1 }, 301: { id: 301, windowId: 5, active: false, groupId: 77 } } }); + w.groupRecord = async (id) => ({ id }); + w.formatTab = async (t) => t; + await w.groupTab({ id: 300, windowId: 5, groupId: -1 }, "Pi Session: x", "blue"); + await w.groupTab({ id: 301, windowId: 5, groupId: 77 }, "Pi Session: x", "blue"); + const updates = calls.filter((c) => c.method === "tabGroups.update").map((c) => c.props.collapsed); + assert.deepEqual(updates, [true, undefined]); +}); + +test("a new group holding the tab that is showing is not collapsed", async () => { + const { w, calls } = worker({ tabs: { 300: { id: 300, windowId: 5, active: true, groupId: -1 } } }); + w.groupRecord = async (id) => ({ id }); + w.formatTab = async (t) => t; + await w.groupTab({ id: 300, windowId: 5, groupId: -1 }, "Pi Session: x", "blue"); + assert.equal(calls.find((c) => c.method === "tabGroups.update").props.collapsed, false); +}); + +test("bringing a tab forward expands its group", async () => { + const { w, calls } = worker(); + await w.bringToFront({ id: 300, windowId: 5, groupId: 77 }, { foreground: true, background: false }); + assert.deepEqual(ops(calls), ["windows.update", "tabGroups.update", "tabs.update"]); + assert.equal(calls[1].props.collapsed, false); +}); + +test("input is sent to an inactive tab; only a minimized window refuses it", async () => { + const { w } = worker({ windows: { 5: { id: 5, state: "normal" }, 6: { id: 6, state: "minimized" } }, tabs: { 300: { id: 300, windowId: 5, active: false }, 301: { id: 301, windowId: 6, active: true } } }); + w.pageVisibilityState = async () => "hidden"; + await w.assertTabVisibleForInput({ id: 300, windowId: 5 }, {}, "chrome.click"); + await assert.rejects(w.assertTabVisibleForInput({ id: 301, windowId: 6 }, {}, "chrome.click"), /minimized/); +}); |
