diff options
| -rw-r--r-- | CHANGELOG.md | 1 | ||||
| -rw-r--r-- | extensions/chrome-profile-bridge/browser-extension/service_worker.js | 19 | ||||
| -rw-r--r-- | extensions/chrome-profile-bridge/index.ts | 19 | ||||
| -rw-r--r-- | test-suite/unit/chrome-command.test.mjs | 30 | ||||
| -rw-r--r-- | test-suite/unit/hidden-input.test.mjs | 20 |
5 files changed, 86 insertions, 3 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 627193f..c7bf6df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable user-facing changes to `pi-chrome`. ## Unreleased +- **`/chrome doctor` flags a hidden Pi tab.** Chrome ignores clicks and typing in hidden pages. Doctor now checks this session's own tab and says whether it is visible. If it's hidden, doctor gives the reason (inactive tab, minimized window, covered by another window) and the fix, instead of the first click or type failing. It never creates or activates a tab to check. - **Pi keeps track of its tabs across extension reloads.** Ownership of Pi's automation window and session tabs lived only in `chrome.storage.session`, which Chrome wipes when the extension reloads. The extension reloads itself on every pi-chrome update, so afterwards Pi could not close or reuse the windows and tabs it had opened, and they were left behind. Ownership is now also kept in `chrome.storage.local` and restored after a reload. A restored tab is trusted only if it still exists in the window it was recorded in (adopted tabs must also still be in Pi's group). The copy is dropped on browser startup, since tab ids do not carry over between browser runs. ## 0.15.56 — 2026-09-28 diff --git a/extensions/chrome-profile-bridge/browser-extension/service_worker.js b/extensions/chrome-profile-bridge/browser-extension/service_worker.js index ac48579..64ed8a9 100644 --- a/extensions/chrome-profile-bridge/browser-extension/service_worker.js +++ b/extensions/chrome-profile-bridge/browser-extension/service_worker.js @@ -1985,7 +1985,24 @@ async function dispatch(action, params) { // Report this session's owned automation target (ids only). Used for diagnostics/tests. await hydrateAutomationTargets(); const t = automationTargets.get(sessionKeyOf(params)); - return { windowId: t?.windowId ?? null, tabId: t?.tabId ?? null }; + const status = { windowId: t?.windowId ?? null, tabId: t?.tabId ?? null }; + if (!params.includeVisibility || !t) return status; + // For /chrome doctor: is this session's own tab visible? Never creates or activates anything. + const tab = await chrome.tabs.get(t.tabId).catch(() => null); + if (!tab) return { ...status, tabId: null }; + const results = await executeScriptWithFallback({ + target: { tabId: tab.id, frameIds: [0] }, + world: "MAIN", + func: () => document.visibilityState, + }, `read visibility of tab ${tab.id}`).catch(() => null); + const visibility = typeof results?.[0]?.result === "string" ? results[0].result : "unknown"; + const win = typeof chrome.windows?.get === "function" ? await chrome.windows.get(tab.windowId).catch(() => null) : null; + return { + ...status, + visibility, + url: tab.url, + hiddenReason: visibility === "hidden" ? hiddenTabReason(tab, win) : undefined, + }; } case "automation.cleanup": // Close recorded creations, and only ungroup user tabs still in their adopted group. diff --git a/extensions/chrome-profile-bridge/index.ts b/extensions/chrome-profile-bridge/index.ts index adfe6cf..fd12dac 100644 --- a/extensions/chrome-profile-bridge/index.ts +++ b/extensions/chrome-profile-bridge/index.ts @@ -1219,6 +1219,25 @@ Usage rules: } catch (error) { lines.push(`⚠ Couldn't inspect the active tab: ${(error as Error).message}`); } + + // Pi's own tab for this session: hidden pages ignore clicks and typing, so say so up front. + try { + const own = (await bridge.send("automation.status", { sessionKey: sessionKeyFor(ctx), includeVisibility: true }, 10_000)) as + | { tabId?: number | null; visibility?: string; hiddenReason?: string } + | undefined; + if (own?.tabId === null || own?.tabId === undefined) { + lines.push(`• Pi hasn't opened its own tab in this session yet.`); + } else if (own.visibility === "hidden") { + lines.push( + `⚠ Pi's tab is hidden because ${own.hiddenReason ?? "its window is not visible"}. Chrome ignores clicks and typing in hidden tabs, so those tools will fail until it's visible.`, + ` Fix: bring Pi's window forward or uncover it, or run /chrome background off to let Pi bring it forward.`, + ); + } else if (own.visibility === "visible") { + lines.push(`✓ Pi's tab is visible, so clicks and typing will reach it.`); + } + } catch { + // Older extensions or a closed tab: nothing useful to add. + } } else if (versionMismatch) { lines.push(`… Skipped the remaining checks until you reload the Chrome extension.`); } diff --git a/test-suite/unit/chrome-command.test.mjs b/test-suite/unit/chrome-command.test.mjs index 3fb4462..dac719a 100644 --- a/test-suite/unit/chrome-command.test.mjs +++ b/test-suite/unit/chrome-command.test.mjs @@ -24,6 +24,7 @@ function healthyResponse(action) { case "tab.version": return { extensionVersion: version }; case "page.evaluate": return 2; case "page.probe": return { arithmetic: 2, location: "https://fixture.test/", webdriver: false }; + case "automation.status": return { windowId: 5, tabId: 6, visibility: "visible" }; default: throw new Error(`Unexpected bridge action: ${action}`); } } @@ -44,6 +45,7 @@ function harness({ until, background = true, mode = "server", choices = [], send Date: { now: () => now }, PI_CHROME_VERSION: version, chromeAuthorizedUntil: until, backgroundEnabled: background, hostnameOf: (url) => new URL(url).hostname, + sessionKeyFor: () => "session:alpha", bridge: { status: () => ({ mode }), async send(action, params, timeout) { @@ -128,8 +130,10 @@ test("Doctor includes locked, timed, indefinite, and expired authorization plus assert.match(report, /can run code/); assert.match(report, /fixture\.test/); assert.deepEqual(h.calls.map(({ action, timeout }) => [action, timeout]), [ - ["tab.version", 35_000], ["page.evaluate", 10_000], ["page.probe", 10_000], + ["tab.version", 35_000], ["page.evaluate", 10_000], ["page.probe", 10_000], ["automation.status", 10_000], ]); + assert.deepEqual(h.calls.at(-1).params, { sessionKey: "session:alpha", includeVisibility: true }); + assert.match(report, /Pi's tab is visible/); assert.ok(h.calls.filter((call) => call.action.startsWith("page.")).every((call) => call.params.foreground === false)); assert.equal(h.sandbox.chromeAuthorizedUntil, until, "diagnostics do not grant or change authorization"); assert.equal(h.sandbox.backgroundEnabled, background); @@ -157,7 +161,29 @@ test("Doctor retains local state and repair hints when connection/version checks test("choosing Doctor explicitly from the dashboard runs full diagnostics", async () => { const h = harness({ choices: ["Doctor / troubleshoot"] }); await h.run(); - assert.deepEqual(h.calls.map((call) => call.action), ["tab.version", "tab.version", "page.evaluate", "page.probe"]); + assert.deepEqual(h.calls.map((call) => call.action), ["tab.version", "tab.version", "page.evaluate", "page.probe", "automation.status"]); assert.match(h.notices.at(-1)[0], /Authorization: locked/); assert.match(h.notices.at(-1)[0], /Background: on \(hard\)/); }); + +test("Doctor warns when this session's Pi tab is hidden, and stays quiet when it cannot tell", async () => { + const withStatus = (status) => (action, ...rest) => action === "automation.status" + ? (typeof status === "function" ? status() : status) + : healthyResponse(action, ...rest); + const hidden = harness({ send: withStatus({ tabId: 6, visibility: "hidden", hiddenReason: "its window is covered by another window" }) }); + 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/); + + const none = harness({ send: withStatus({ windowId: null, tabId: null }) }); + await none.run("doctor"); + assert.match(none.notices.at(-1)[0], /hasn't opened its own tab in this session yet/); + + for (const status of [{ windowId: 5, tabId: 6 }, () => { throw new Error("old extension"); }]) { + const quiet = harness({ send: withStatus(status) }); + await quiet.run("doctor"); + assert.doesNotMatch(quiet.notices.at(-1)[0], /Pi's tab|own tab/); + } +}); diff --git a/test-suite/unit/hidden-input.test.mjs b/test-suite/unit/hidden-input.test.mjs index 376db69..4b45a49 100644 --- a/test-suite/unit/hidden-input.test.mjs +++ b/test-suite/unit/hidden-input.test.mjs @@ -277,3 +277,23 @@ test("pruneScreenshotDir deletes only eligible files and never throws", async () 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"); +}); |
