mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-27 23:21:53 +02:00
* fix(memory-ingest): --scan-secrets scans the rendered page and fails closed --scan-secrets ran gitleaks on the raw transcript .jsonl, then imported a page rendered from it. gitleaks' assignment rules don't match across a JSON-escaped quote (KEY=\"v\" on disk), so a secret the rendered page shows as KEY="v" was imported unflagged. And the gate skipped a file only on scanner "gitleaks" with findings, so a scan that errored (non-zero exit, 16MB maxBuffer overflow on a file with many findings, unparseable report) or could not run (gitleaks missing, slow-probe cooldown) imported the file unscanned. Scan the rendered page body, the exact bytes writeStaged() writes, via a new secretScanText() helper, and skip the file whenever the scan did not complete. Skipped files stay out of the state file, so the next run retries them. Reword the helper warnings and setup-gbrain/memory.md, which described the fail-open as intended. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(test): reconcile Bun failure markers and footer counts * fix(sync-gbrain): verify source-scoped reads without mutation * fix(test): recognize grounded TTHW target choices structurally * fix(aside): make the readiness probe work under zsh and report why it failed The probe built its deadline into `_T` and expanded it unquoted, so `$_T aside repl …` only worked in a shell that word-splits. zsh does not: it looked for a command literally named "gtimeout 30", the probe answered ASIDE_NOT_RUNNING with Aside installed and ready, and every browsing skill fell back to the bundled Chromium in silence. zsh is the macOS default and Aside is macOS-only, so on a stock Mac the probe could never report READY. The deadline becomes a function, `_gs_d`. It receives the command as "$@", already split, so sh, bash and zsh all behave the same, and the gtimeout → timeout → perl alarm chain is unchanged. A 4th arm runs the call unbounded when none of the three is present, which is what the empty `_T` did before. Not `eval`: it re-parses the string, so the parens and `;` of the perl arm become syntax and that arm dies in bash *and* zsh — on a stock Mac, the arm that actually runs. On failure the probe now prints the CLI's reason after ASIDE_NOT_RUNNING:, the shape gstack-render already uses: the first line that starts with a capital letter, i.e. the CLI's own sentence or Node's `Error:` line below its loader frame. "Not running" covers states with different fixes — no window open for the profile, a NODE_OPTIONS preload that kills the CLI — and a bare verdict sent all of them to "open the Aside app". The BROWSER SETUP prose quotes that reason before asking the user to open the app. The text pin asserted the broken invocation verbatim, so it now pins the function and asserts neither `$_T aside repl` nor an eval form comes back. A second test executes the rendered probe in sh, bash and zsh on each of the four deadline arms with stubbed binaries on a narrowed PATH, plus two failing CLIs: one that prints its own sentence, one that crashes like Node with the useful line below the frame. The deadline function costs zero bytes against the lines it replaces; the reason costs 53 per copy of the probe (44 where the reworded BROWSER SETUP line gives 9 back). That moves four guards by the measured amount: plan-devex-review's skeleton cap to 68,550 (measured 68,544), plan-ceo-review's skeleton cap to 80,150 (measured 80,111) and union ratio to 1.081 (measured 1.0803), and plan-eng-review's union ratio to 1.151 (measured 1.1504). Fixes #2842, #2941. * Clarify engineering review startup and decision flow * Fix Windows readiness fixture PATH and command shim * fix(test): recognize grounded TTHW target choices structurally * Clarify engineering review startup and decision flow * fix(test): restrict QA-only fixture tools to its no-Edit contract * v1.90.0.0 fix(sync-gbrain): guard readiness verdicts and refresh metadata * fix(browse): validate canonical upload targets * fix(gbrain): classify structured PGLite busy response * fix(browse): preserve native extension runtime APIs * Fix displayless browser handoff ownership * Accept unique installed autoplan methodology aliases * fix(skills): preserve positional literals during installation * fix(browse): checksum installer contents through stdin * fix(test): normalize Windows checksum fixture paths * test: emulate unavailable shasum in Windows checksum fixture * fix(investigate): preserve owned freeze lifecycle * fix(review): preserve N+1 retry and Red Team completion * fix: bound Aside readiness and preserve safe fallback * test: exercise setup and Chromium on native ARM * fix: preserve install ownership and ARM browser selection * Fix gbrain ingest scan boundaries and seed observation * Refresh managed ship hooks and supervise expanded paid census * Reject resumed gbrain pages excluded by current policy * Recover zombie agent locks safely and enable CI Python venv * Repair paid actor declarations and Aside pitch assertions * Bump consolidated wave to next free minor release * Clarify CEO review admin choices and option tradeoffs * Preserve CEO mode handoff anchors in clarified workflow * Make Windows portability fixtures use shell-native paths * Restore ARM Bun alias and clarify ship review gates * Refresh ship workflow golden snapshots * Fix Windows DX documentation controls without piped stdin * Decode Codex child pipes without Bun's encoded-stream stall * Bound DX pre-review audit before product questions * Clarify trusted review-start read in paid revalidation * Bump consolidated wave to next free minor release * Clarify CEO review admin choices and option tradeoffs * Preserve CEO mode handoff anchors in clarified workflow * Make Windows portability fixtures use shell-native paths * Restore ARM Bun alias and clarify ship review gates * Refresh ship workflow golden snapshots * Fix Windows DX documentation controls without piped stdin * Decode Codex child pipes without Bun's encoded-stream stall * Bound DX pre-review audit before product questions * Clarify trusted review-start read in paid revalidation * Reconcile new main planning flow and paid judge census * fix: reconcile rebased planning and source-bound validation * test: pin cookie workflow judge to scored Sonnet model * fix: keep terminal agent boot out of module imports * fix: preserve pending-question uncertainty in engineering review * fix: stabilize Windows reliability-wave fixtures * fix: clarify design consultation research workflow * fix: preserve independent design consultation inputs * fix: resolve design taste scope and browser research guidance * fix: make consultation opt-in preflight unambiguous * test: await native Edge owner readiness or terminal result --------- Co-authored-by: Bruce Krysiak <brucek@alum.mit.edu> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Antonio Vitalic <antoninte99@gmail.com>
1047 lines
44 KiB
TypeScript
1047 lines
44 KiB
TypeScript
/**
|
|
* Structural tests for the sidebar's surviving UX surfaces:
|
|
* - Quick-action toolbar (cleanup via PTY injection, screenshot, cookies)
|
|
* - CSP fallback basic picker (content.js) + inspector allowlist
|
|
* - Deterministic cleanup heuristics (write-commands.ts)
|
|
* - Welcome page + sidebar auto-open + arrow hint signal chain
|
|
* - Connection/auth race prevention + startup health check
|
|
* - browser-manager tab tracking + no-focus-steal invariants
|
|
* - Server shutdown teardown of the terminal-agent
|
|
*
|
|
* History: this file used to also pin the chat-queue architecture
|
|
* (sidebar-agent.ts, /sidebar-command, /sidebar-chat, /sidebar-tabs,
|
|
* per-tab chat context, stop button, chat polling, processAgentEvent,
|
|
* pickSidebarModel). That entire path was deliberately ripped in PR #1216
|
|
* (v1.14.0.0) when the interactive claude PTY (terminal-agent.ts) proved
|
|
* strictly more capable — see docs/designs/SIDEBAR_MESSAGE_FLOW.md. The
|
|
* stale blocks kept "passing" only because a teardown bug made `bun test`
|
|
* exit 0 before reporting; once that was fixed (PR #2172) they surfaced as
|
|
* failures and were removed. The rip itself is pinned as absence tests in
|
|
* browse/test/sidebar-tabs.test.ts.
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { EventEmitter } from 'node:events';
|
|
import { BrowserManager } from '../src/browser-manager';
|
|
|
|
const ROOT = path.resolve(__dirname, '..');
|
|
|
|
describe('browser tab bar (sidepanel.html)', () => {
|
|
const html = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.html'), 'utf-8');
|
|
|
|
test('browser-tabs container exists', () => {
|
|
expect(html).toContain('id="browser-tabs"');
|
|
});
|
|
|
|
test('browser-tabs hidden by default', () => {
|
|
const match = html.match(/id="browser-tabs"[^>]*/);
|
|
expect(match).not.toBeNull();
|
|
expect(match![0]).toContain('display:none');
|
|
});
|
|
});
|
|
|
|
// ─── Bidirectional tab sync ──────────────────────────────────────
|
|
|
|
describe('sidebar→browser tab switch', () => {
|
|
const bmSrc = fs.readFileSync(path.join(ROOT, 'src', 'browser-manager.ts'), 'utf-8');
|
|
|
|
test('switchTab supports bringToFront option', () => {
|
|
expect(bmSrc).toContain('switchTab(id: number, opts?');
|
|
expect(bmSrc).toContain('bringToFront');
|
|
// Default behavior still brings to front (opt-out, not opt-in)
|
|
expect(bmSrc).toContain('bringToFront !== false');
|
|
});
|
|
});
|
|
|
|
describe('browser→sidebar tab sync', () => {
|
|
const bmSrc = fs.readFileSync(path.join(ROOT, 'src', 'browser-manager.ts'), 'utf-8');
|
|
|
|
test('syncActiveTabByUrl method exists on BrowserManager', () => {
|
|
expect(bmSrc).toContain('syncActiveTabByUrl(activeUrl: string)');
|
|
});
|
|
|
|
test('syncActiveTabByUrl updates activeTabId when URL matches a different tab', () => {
|
|
const fn = bmSrc.slice(
|
|
bmSrc.indexOf('syncActiveTabByUrl('),
|
|
bmSrc.indexOf('syncActiveTabByUrl(') + 1200,
|
|
);
|
|
expect(fn).toContain('this.activeTabId = id');
|
|
// Exact match
|
|
expect(fn).toContain('pageUrl === activeUrl');
|
|
// Fuzzy match (origin+pathname)
|
|
expect(fn).toContain('activeOriginPath');
|
|
expect(fn).toContain('fuzzyId');
|
|
});
|
|
|
|
test('context.on("page") tracks user-created tabs', () => {
|
|
expect(bmSrc).toContain("context.on('page'");
|
|
expect(bmSrc).toContain('this.pages.set(id, page)');
|
|
// Should log when new tab detected
|
|
expect(bmSrc).toContain('New tab detected');
|
|
});
|
|
|
|
test('page close handler removes tab from pages map', () => {
|
|
const manager = new BrowserManager() as any;
|
|
const closed = new EventEmitter();
|
|
const remaining = new EventEmitter();
|
|
manager.pages = new Map([[1, closed], [2, remaining]]);
|
|
manager.tabSessions = new Map([[1, { page: closed }], [2, { page: remaining }]]);
|
|
manager.activeTabId = 1;
|
|
manager.wirePageEvents(closed);
|
|
|
|
closed.emit('close');
|
|
|
|
expect(manager.pages.has(1)).toBe(false);
|
|
expect(manager.tabSessions.has(1)).toBe(false);
|
|
expect(manager.pages.get(2)).toBe(remaining);
|
|
expect(manager.tabSessions.get(2).page).toBe(remaining);
|
|
expect(manager.activeTabId).toBe(2);
|
|
});
|
|
|
|
test('old page close during handoff preserves the replacement browser tabs', () => {
|
|
const manager = new BrowserManager() as any;
|
|
const closed = new EventEmitter();
|
|
const remaining = new EventEmitter();
|
|
const replacement = new EventEmitter();
|
|
manager.pages = new Map([[1, closed], [2, remaining]]);
|
|
manager.tabSessions = new Map([[1, { page: closed }], [2, { page: remaining }]]);
|
|
manager.activeTabId = 1;
|
|
manager.wirePageEvents(closed);
|
|
manager.wirePageEvents(remaining);
|
|
const previous = { pages: manager.pages, tabSessions: manager.tabSessions, activeTabId: 1 };
|
|
manager.handoffPrevious = previous;
|
|
manager.pages = new Map([[1, replacement]]);
|
|
manager.tabSessions = new Map([[1, { page: replacement }]]);
|
|
|
|
closed.emit('close');
|
|
|
|
expect(previous.pages.has(1)).toBe(false);
|
|
expect(previous.tabSessions.has(1)).toBe(false);
|
|
expect(previous.pages.get(2)).toBe(remaining);
|
|
expect(previous.activeTabId).toBe(2);
|
|
expect(manager.pages.get(1)).toBe(replacement);
|
|
expect(manager.tabSessions.get(1).page).toBe(replacement);
|
|
expect(manager.activeTabId).toBe(1);
|
|
|
|
manager.handoffPrevious = null;
|
|
remaining.emit('close');
|
|
|
|
expect(previous.pages.size).toBe(0);
|
|
expect(previous.tabSessions.size).toBe(0);
|
|
expect(manager.pages.get(1)).toBe(replacement);
|
|
expect(manager.tabSessions.get(1).page).toBe(replacement);
|
|
expect(manager.activeTabId).toBe(1);
|
|
});
|
|
|
|
test('syncActiveTabByUrl skips when only 1 tab (no ambiguity)', () => {
|
|
const fn = bmSrc.slice(
|
|
bmSrc.indexOf('syncActiveTabByUrl('),
|
|
bmSrc.indexOf('syncActiveTabByUrl(') + 600,
|
|
);
|
|
expect(fn).toContain('this.pages.size <= 1');
|
|
});
|
|
|
|
// NOTE: the /sidebar-tabs + /sidebar-command server consumers of
|
|
// syncActiveTabByUrl and the sidepanel chat-tab handlers were removed
|
|
// with the chat-queue rip (PR #1216). The BrowserManager primitives above
|
|
// survive (tab tracking feeds active-tab.json for the PTY claude).
|
|
|
|
test('background.js listens for chrome.tabs.onActivated', () => {
|
|
const bgSrc = fs.readFileSync(path.join(ROOT, '..', 'extension', 'background.js'), 'utf-8');
|
|
expect(bgSrc).toContain('chrome.tabs.onActivated.addListener');
|
|
expect(bgSrc).toContain('browserTabActivated');
|
|
});
|
|
});
|
|
|
|
describe('browser tab bar (sidepanel.css)', () => {
|
|
const css = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.css'), 'utf-8');
|
|
|
|
test('browser-tabs styles exist', () => {
|
|
expect(css).toContain('.browser-tabs');
|
|
expect(css).toContain('.browser-tab');
|
|
expect(css).toContain('.browser-tab.active');
|
|
});
|
|
|
|
test('tab bar is horizontally scrollable', () => {
|
|
const barStyle = css.slice(
|
|
css.indexOf('.browser-tabs {'),
|
|
css.indexOf('}', css.indexOf('.browser-tabs {')) + 1,
|
|
);
|
|
expect(barStyle).toContain('overflow-x: auto');
|
|
});
|
|
|
|
test('active tab is visually distinct', () => {
|
|
const activeStyle = css.slice(
|
|
css.indexOf('.browser-tab.active {'),
|
|
css.indexOf('}', css.indexOf('.browser-tab.active {')) + 1,
|
|
);
|
|
expect(activeStyle).toContain('--bg-surface');
|
|
expect(activeStyle).toContain('--text-body');
|
|
});
|
|
});
|
|
|
|
// ─── Sidebar CSS tests ──────────────────────────────────────────
|
|
|
|
describe('sidebar CSS (sidepanel.css)', () => {
|
|
const css = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.css'), 'utf-8');
|
|
|
|
test('stop button style exists', () => {
|
|
expect(css).toContain('.stop-btn');
|
|
});
|
|
|
|
test('stop button uses error color', () => {
|
|
const stopBtnSection = css.slice(
|
|
css.indexOf('.stop-btn {'),
|
|
css.indexOf('}', css.indexOf('.stop-btn {')) + 1,
|
|
);
|
|
expect(stopBtnSection).toContain('--error');
|
|
});
|
|
|
|
test('experimental-banner no longer uses amber warning colors', () => {
|
|
const bannerSection = css.slice(
|
|
css.indexOf('.experimental-banner {'),
|
|
css.indexOf('}', css.indexOf('.experimental-banner {')) + 1,
|
|
);
|
|
// Should not be amber/warning anymore
|
|
expect(bannerSection).not.toContain('245, 158, 11, 0.15');
|
|
expect(bannerSection).not.toContain('#F59E0B');
|
|
});
|
|
|
|
test('tool description uses system font not mono', () => {
|
|
const toolSection = css.slice(
|
|
css.indexOf('.agent-tool {'),
|
|
css.indexOf('}', css.indexOf('.agent-tool {')) + 1,
|
|
);
|
|
expect(toolSection).toContain('font-system');
|
|
expect(toolSection).not.toContain('font-mono');
|
|
});
|
|
});
|
|
|
|
// ─── Inspector message allowlist fix ────────────────────────────
|
|
|
|
describe('inspector message allowlist fix', () => {
|
|
const bgSrc = fs.readFileSync(path.join(ROOT, '..', 'extension', 'background.js'), 'utf-8');
|
|
|
|
test('ALLOWED_TYPES includes inspector message types', () => {
|
|
const allowListSection = bgSrc.slice(
|
|
bgSrc.indexOf('const ALLOWED_TYPES'),
|
|
bgSrc.indexOf(']);', bgSrc.indexOf('const ALLOWED_TYPES')) + 3,
|
|
);
|
|
expect(allowListSection).toContain('startInspector');
|
|
expect(allowListSection).toContain('stopInspector');
|
|
expect(allowListSection).toContain('elementPicked');
|
|
expect(allowListSection).toContain('pickerCancelled');
|
|
expect(allowListSection).toContain('applyStyle');
|
|
expect(allowListSection).toContain('inspectResult');
|
|
});
|
|
});
|
|
|
|
// ─── CSP fallback basic picker ──────────────────────────────────
|
|
|
|
describe('CSP fallback basic picker', () => {
|
|
const contentSrc = fs.readFileSync(path.join(ROOT, '..', 'extension', 'content.js'), 'utf-8');
|
|
const bgSrc = fs.readFileSync(path.join(ROOT, '..', 'extension', 'background.js'), 'utf-8');
|
|
|
|
test('content.js contains startBasicPicker message handler', () => {
|
|
expect(contentSrc).toContain("msg.type === 'startBasicPicker'");
|
|
expect(contentSrc).toContain('startBasicPicker()');
|
|
});
|
|
|
|
test('content.js contains captureBasicData function with getComputedStyle', () => {
|
|
expect(contentSrc).toContain('function captureBasicData(');
|
|
expect(contentSrc).toContain('getComputedStyle(');
|
|
expect(contentSrc).toContain('getBoundingClientRect()');
|
|
});
|
|
|
|
test('content.js contains CSSOM iteration guarded against cross-origin sheets', () => {
|
|
expect(contentSrc).toContain('document.styleSheets');
|
|
expect(contentSrc).toContain('cssRules');
|
|
// Cross-origin stylesheets throw DOMException on cssRules access. The
|
|
// iteration must swallow exactly that (typed catch, not a bare catch {}
|
|
// — see the slop-scan philosophy in CLAUDE.md).
|
|
expect(contentSrc).toContain('(same-origin only)');
|
|
expect(contentSrc).toMatch(/catch \(e\) \{ if \(!\(e instanceof DOMException\)\) throw e; \}/);
|
|
});
|
|
|
|
test('content.js saves and restores outline on elements', () => {
|
|
expect(contentSrc).toContain('basicPickerSavedOutline');
|
|
// Outline is restored in cleanup and highlight functions
|
|
expect(contentSrc).toContain('.style.outline = basicPickerSavedOutline');
|
|
});
|
|
|
|
test('content.js basic picker sends inspectResult with mode basic', () => {
|
|
expect(contentSrc).toContain("mode: 'basic'");
|
|
expect(contentSrc).toContain("type: 'inspectResult'");
|
|
});
|
|
|
|
test('content.js basic picker cleans up on Escape', () => {
|
|
expect(contentSrc).toContain('onBasicKeydown');
|
|
expect(contentSrc).toContain("e.key === 'Escape'");
|
|
expect(contentSrc).toContain('basicPickerCleanup');
|
|
});
|
|
|
|
test('background.js injectInspector has separate try blocks for executeScript and insertCSS', () => {
|
|
const injectFn = bgSrc.slice(
|
|
bgSrc.indexOf('async function injectInspector('),
|
|
bgSrc.indexOf('\n}', bgSrc.indexOf('async function injectInspector(') + 1) + 2,
|
|
);
|
|
// executeScript and insertCSS should be in separate try blocks
|
|
expect(injectFn).toContain('executeScript');
|
|
expect(injectFn).toContain('insertCSS');
|
|
// Fallback sends startBasicPicker
|
|
expect(injectFn).toContain("type: 'startBasicPicker'");
|
|
expect(injectFn).toContain("mode: 'basic'");
|
|
});
|
|
|
|
test('background.js stores inspectorMode for routing', () => {
|
|
expect(bgSrc).toContain('inspectorMode');
|
|
});
|
|
});
|
|
|
|
// ─── Cleanup and screenshot buttons ─────────────────────────────
|
|
|
|
describe('cleanup and screenshot buttons', () => {
|
|
const html = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.html'), 'utf-8');
|
|
const js = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8');
|
|
const css = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.css'), 'utf-8');
|
|
|
|
test('sidepanel.html contains cleanup and screenshot buttons in inspector', () => {
|
|
expect(html).toContain('inspector-cleanup-btn');
|
|
expect(html).toContain('inspector-screenshot-btn');
|
|
expect(html).toContain('inspector-action-btn');
|
|
});
|
|
|
|
test('sidepanel.html contains cleanup and screenshot buttons in chat toolbar', () => {
|
|
expect(html).toContain('chat-cleanup-btn');
|
|
expect(html).toContain('chat-screenshot-btn');
|
|
expect(html).toContain('quick-actions');
|
|
});
|
|
|
|
test('cleanup button injects smart prompt into the live PTY (not just deterministic selectors)', () => {
|
|
// Cleanup pipes a prompt into the running claude PTY via
|
|
// gstackInjectToTerminal (the chat-queue POST to /sidebar-command was
|
|
// ripped in PR #1216 — the live REPL is the only execution surface).
|
|
const cleanupFn = js.slice(
|
|
js.indexOf('async function runCleanup('),
|
|
js.indexOf('async function runScreenshot('),
|
|
);
|
|
expect(cleanupFn).toContain('gstackInjectToTerminal');
|
|
expect(cleanupFn).toContain('cleanupPrompt');
|
|
// Should include both deterministic first pass AND agent snapshot analysis
|
|
expect(cleanupFn).toContain('cleanup --all');
|
|
expect(cleanupFn).toContain('snapshot -i');
|
|
// Should instruct claude to keep site branding
|
|
expect(cleanupFn).toContain('Keep the site');
|
|
expect(cleanupFn).toContain('header/masthead');
|
|
});
|
|
|
|
test('sidepanel.js screenshot handler POSTs to /command with screenshot', () => {
|
|
expect(js).toContain("command: 'screenshot'");
|
|
});
|
|
|
|
test('sidepanel.css contains inspector-action-btn styles', () => {
|
|
expect(css).toContain('.inspector-action-btn');
|
|
expect(css).toContain('.inspector-action-btn.loading');
|
|
});
|
|
|
|
test('sidepanel.css contains quick-action-btn styles for chat toolbar', () => {
|
|
expect(css).toContain('.quick-action-btn');
|
|
expect(css).toContain('.quick-action-btn.loading');
|
|
expect(css).toContain('.quick-actions');
|
|
});
|
|
|
|
test('cleanup and screenshot use shared helper functions', () => {
|
|
expect(js).toContain('async function runCleanup(');
|
|
expect(js).toContain('async function runScreenshot(');
|
|
// Both inspector and chat buttons are wired
|
|
expect(js).toContain('chatCleanupBtn');
|
|
expect(js).toContain('chatScreenshotBtn');
|
|
});
|
|
|
|
test('sidepanel.css contains chat-notification styles', () => {
|
|
expect(css).toContain('.chat-notification');
|
|
});
|
|
});
|
|
|
|
describe('cleanup heuristics (write-commands.ts)', () => {
|
|
const wcSrc = fs.readFileSync(path.join(ROOT, 'src', 'write-commands.ts'), 'utf-8');
|
|
|
|
test('cleanup defaults to --all when no args provided', () => {
|
|
// Should not throw on empty args, should default to doAll
|
|
expect(wcSrc).toContain('if (args.length === 0)');
|
|
expect(wcSrc).toContain('doAll = true');
|
|
});
|
|
|
|
test('CLEANUP_SELECTORS has overlays category', () => {
|
|
expect(wcSrc).toContain('overlays: [');
|
|
expect(wcSrc).toContain('paywall');
|
|
expect(wcSrc).toContain('newsletter');
|
|
expect(wcSrc).toContain('interstitial');
|
|
expect(wcSrc).toContain('push-notification');
|
|
expect(wcSrc).toContain('app-banner');
|
|
});
|
|
|
|
test('CLEANUP_SELECTORS ads has major ad networks', () => {
|
|
expect(wcSrc).toContain('doubleclick');
|
|
expect(wcSrc).toContain('googlesyndication');
|
|
expect(wcSrc).toContain('amazon-adsystem');
|
|
expect(wcSrc).toContain('outbrain');
|
|
expect(wcSrc).toContain('taboola');
|
|
expect(wcSrc).toContain('criteo');
|
|
});
|
|
|
|
test('CLEANUP_SELECTORS cookies has major consent frameworks', () => {
|
|
expect(wcSrc).toContain('onetrust');
|
|
expect(wcSrc).toContain('CybotCookiebot');
|
|
expect(wcSrc).toContain('truste');
|
|
expect(wcSrc).toContain('qc-cmp2');
|
|
expect(wcSrc).toContain('Quantcast');
|
|
});
|
|
|
|
test('cleanup uses !important to override inline styles', () => {
|
|
// Elements with inline style="display:block" need !important to hide
|
|
expect(wcSrc).toContain("setProperty('display', 'none', 'important')");
|
|
});
|
|
|
|
test('cleanup unlocks scroll (body overflow:hidden)', () => {
|
|
expect(wcSrc).toContain("overflow === 'hidden'");
|
|
expect(wcSrc).toContain("setProperty('overflow', 'auto', 'important')");
|
|
});
|
|
|
|
test('cleanup removes blur effects (paywall blur)', () => {
|
|
expect(wcSrc).toContain("filter?.includes('blur')");
|
|
expect(wcSrc).toContain("setProperty('filter', 'none', 'important')");
|
|
});
|
|
|
|
test('cleanup removes article truncation (max-height)', () => {
|
|
expect(wcSrc).toContain('truncat');
|
|
expect(wcSrc).toContain("setProperty('max-height', 'none', 'important')");
|
|
});
|
|
|
|
test('cleanup collapses empty ad placeholder whitespace', () => {
|
|
expect(wcSrc).toContain('empty placeholders');
|
|
// Should check text content length before collapsing
|
|
expect(wcSrc).toContain('text.length < 20');
|
|
});
|
|
|
|
test('sticky cleanup skips gstack control indicator', () => {
|
|
expect(wcSrc).toContain("gstack-ctrl");
|
|
});
|
|
|
|
test('CLEANUP_SELECTORS has clutter category', () => {
|
|
expect(wcSrc).toContain('clutter: [');
|
|
expect(wcSrc).toContain('audio-player');
|
|
expect(wcSrc).toContain('podcast-player');
|
|
expect(wcSrc).toContain('puzzle');
|
|
expect(wcSrc).toContain('recirculation');
|
|
expect(wcSrc).toContain('everlit');
|
|
});
|
|
|
|
test('cleanup removes "ADVERTISEMENT" text labels', () => {
|
|
expect(wcSrc).toContain('adTextPatterns');
|
|
expect(wcSrc).toContain('/^advertisement$/i');
|
|
expect(wcSrc).toContain('/article continues/i');
|
|
expect(wcSrc).toContain('ad labels');
|
|
});
|
|
|
|
test('sticky cleanup preserves topmost full-width nav bar', () => {
|
|
// Should preserve the first full-width element near the top
|
|
expect(wcSrc).toContain('preservedTopNav');
|
|
expect(wcSrc).toContain('viewportWidth * 0.8');
|
|
// Should sort sticky elements by vertical position
|
|
expect(wcSrc).toContain('sort((a, b) => a.top - b.top)');
|
|
});
|
|
});
|
|
|
|
describe('chat toolbar buttons disabled state', () => {
|
|
const js = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8');
|
|
const css = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.css'), 'utf-8');
|
|
|
|
test('setActionButtonsEnabled function exists', () => {
|
|
expect(js).toContain('function setActionButtonsEnabled(enabled)');
|
|
});
|
|
|
|
test('buttons are disabled when disconnected', () => {
|
|
// updateConnection should call setActionButtonsEnabled(false) when no URL
|
|
expect(js).toContain('setActionButtonsEnabled(false)');
|
|
expect(js).toContain('setActionButtonsEnabled(true)');
|
|
});
|
|
|
|
test('runCleanup silently returns when disconnected (no error spam)', () => {
|
|
// Should NOT show "Not connected" notification, just return silently
|
|
const cleanupFn = js.slice(
|
|
js.indexOf('async function runCleanup('),
|
|
js.indexOf('\n}', js.indexOf('async function runCleanup(') + 1) + 2,
|
|
);
|
|
expect(cleanupFn).not.toContain('Not connected to browse server');
|
|
});
|
|
|
|
test('CSS has disabled style for action buttons', () => {
|
|
expect(css).toContain('.quick-action-btn.disabled');
|
|
expect(css).toContain('.inspector-action-btn.disabled');
|
|
expect(css).toContain('pointer-events: none');
|
|
});
|
|
});
|
|
|
|
// ─── Focus stealing prevention ──────────────────────────────────
|
|
|
|
describe('tab switching does not steal focus', () => {
|
|
const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8');
|
|
const bmSrc = fs.readFileSync(path.join(ROOT, 'src', 'browser-manager.ts'), 'utf-8');
|
|
|
|
test('switchTab has bringToFront option', () => {
|
|
expect(bmSrc).toContain('bringToFront?: boolean');
|
|
expect(bmSrc).toContain('bringToFront !== false');
|
|
});
|
|
|
|
test('handleCommand tab pinning does NOT steal focus', () => {
|
|
// All switchTab calls in handleCommand should use bringToFront: false
|
|
const handleFn = serverSrc.slice(
|
|
serverSrc.indexOf('async function handleCommand('),
|
|
serverSrc.indexOf('\n// ', serverSrc.indexOf('async function handleCommand(') + 200),
|
|
);
|
|
const switchCalls = handleFn.match(/switchTab\([^)]+\)/g) || [];
|
|
for (const call of switchCalls) {
|
|
expect(call).toContain('bringToFront: false');
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── LLM-based cleanup architecture ─────────────────────────────
|
|
|
|
describe('LLM-based cleanup (smart agent cleanup)', () => {
|
|
const js = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8');
|
|
const wcSrc = fs.readFileSync(path.join(ROOT, 'src', 'write-commands.ts'), 'utf-8');
|
|
|
|
test('cleanup button does not bypass the agent with a direct /command POST', () => {
|
|
const cleanupFn = js.slice(
|
|
js.indexOf('async function runCleanup('),
|
|
js.indexOf('async function runScreenshot('),
|
|
);
|
|
// The smart cleanup goes through the claude PTY, never a raw
|
|
// deterministic /command fetch. (The PTY-injection wiring itself is
|
|
// pinned in sidebar-tabs.test.ts.)
|
|
expect(cleanupFn).not.toMatch(/fetch.*\/command['"]/);
|
|
});
|
|
|
|
test('cleanup prompt includes deterministic first pass', () => {
|
|
const cleanupFn = js.slice(
|
|
js.indexOf('async function runCleanup('),
|
|
js.indexOf('async function runScreenshot('),
|
|
);
|
|
// First run the deterministic sweep
|
|
expect(cleanupFn).toContain('cleanup --all');
|
|
});
|
|
|
|
test('cleanup prompt instructs agent to snapshot and analyze', () => {
|
|
const cleanupFn = js.slice(
|
|
js.indexOf('async function runCleanup('),
|
|
js.indexOf('async function runScreenshot('),
|
|
);
|
|
// Agent should take a snapshot to see what deterministic pass missed
|
|
expect(cleanupFn).toContain('snapshot -i');
|
|
// Agent should analyze what remains
|
|
expect(cleanupFn).toContain('identify any remaining');
|
|
});
|
|
|
|
test('cleanup prompt lists specific clutter categories for agent', () => {
|
|
const cleanupFn = js.slice(
|
|
js.indexOf('async function runCleanup('),
|
|
js.indexOf('async function runScreenshot('),
|
|
);
|
|
// Should guide the agent on what to look for
|
|
expect(cleanupFn).toContain('cookie/consent banners');
|
|
expect(cleanupFn).toContain('newsletter popups');
|
|
expect(cleanupFn).toContain('login walls');
|
|
expect(cleanupFn).toContain('video autoplay');
|
|
expect(cleanupFn).toContain('sidebar');
|
|
expect(cleanupFn).toContain('share');
|
|
expect(cleanupFn).toContain('floating chat');
|
|
});
|
|
|
|
test('cleanup prompt instructs agent to preserve site identity', () => {
|
|
const cleanupFn = js.slice(
|
|
js.indexOf('async function runCleanup('),
|
|
js.indexOf('async function runScreenshot('),
|
|
);
|
|
// Must keep the site looking like itself
|
|
expect(cleanupFn).toContain('Keep the site');
|
|
expect(cleanupFn).toContain('header/masthead');
|
|
expect(cleanupFn).toContain('headline');
|
|
expect(cleanupFn).toContain('article body');
|
|
expect(cleanupFn).toContain('byline');
|
|
});
|
|
|
|
test('cleanup prompt instructs agent to unlock scrolling', () => {
|
|
const cleanupFn = js.slice(
|
|
js.indexOf('async function runCleanup('),
|
|
js.indexOf('async function runScreenshot('),
|
|
);
|
|
expect(cleanupFn).toContain('unlock scrolling');
|
|
expect(cleanupFn).toContain('scroll-locked');
|
|
});
|
|
|
|
test('cleanup prompt instructs agent to use $B eval for removal', () => {
|
|
const cleanupFn = js.slice(
|
|
js.indexOf('async function runCleanup('),
|
|
js.indexOf('async function runScreenshot('),
|
|
);
|
|
// Agent should use $B eval to hide elements via JavaScript
|
|
expect(cleanupFn).toContain('$B eval');
|
|
expect(cleanupFn).toContain('hide each');
|
|
});
|
|
|
|
test('cleanup removes loading state after short delay (agent is async)', () => {
|
|
const cleanupFn = js.slice(
|
|
js.indexOf('async function runCleanup('),
|
|
js.indexOf('async function runScreenshot('),
|
|
);
|
|
// Should use setTimeout since agent runs asynchronously
|
|
expect(cleanupFn).toContain('setTimeout');
|
|
expect(cleanupFn).toContain("classList.remove('loading')");
|
|
});
|
|
|
|
test('deterministic cleanup still has comprehensive selectors as first pass', () => {
|
|
// The deterministic $B cleanup --all still needs good selectors for the quick pass
|
|
expect(wcSrc).toContain('ads: [');
|
|
expect(wcSrc).toContain('cookies: [');
|
|
expect(wcSrc).toContain('social: [');
|
|
expect(wcSrc).toContain('overlays: [');
|
|
expect(wcSrc).toContain('clutter: [');
|
|
});
|
|
|
|
test('deterministic cleanup clutter covers audio/podcast widgets', () => {
|
|
expect(wcSrc).toContain('audio-player');
|
|
expect(wcSrc).toContain('podcast-player');
|
|
expect(wcSrc).toContain('listen-widget');
|
|
expect(wcSrc).toContain('everlit');
|
|
expect(wcSrc).toContain("'audio'"); // bare audio elements
|
|
});
|
|
|
|
test('deterministic cleanup clutter covers sidebar recirculation', () => {
|
|
expect(wcSrc).toContain('most-popular');
|
|
expect(wcSrc).toContain('most-read');
|
|
expect(wcSrc).toContain('recommended');
|
|
expect(wcSrc).toContain('taboola');
|
|
expect(wcSrc).toContain('outbrain');
|
|
expect(wcSrc).toContain('nativo');
|
|
});
|
|
|
|
test('deterministic cleanup clutter covers games/puzzles', () => {
|
|
expect(wcSrc).toContain('puzzle');
|
|
expect(wcSrc).toContain('daily-game');
|
|
expect(wcSrc).toContain('crossword-promo');
|
|
});
|
|
|
|
test('ad label text detection catches common patterns', () => {
|
|
expect(wcSrc).toContain('/^advertisement$/i');
|
|
expect(wcSrc).toContain('/^sponsored$/i');
|
|
expect(wcSrc).toContain('/^promoted$/i');
|
|
expect(wcSrc).toContain('/article continues/i');
|
|
expect(wcSrc).toContain('/continues below/i');
|
|
expect(wcSrc).toContain('/^paid content$/i');
|
|
expect(wcSrc).toContain('/^partner content$/i');
|
|
});
|
|
|
|
test('ad label detection skips elements with too much text (not a label)', () => {
|
|
// Should skip elements with >50 chars (probably real content)
|
|
expect(wcSrc).toContain('text.length > 50');
|
|
});
|
|
|
|
test('ad label detection hides parent wrapper when small enough', () => {
|
|
// If parent has little content, hide the whole wrapper
|
|
expect(wcSrc).toContain('parent.textContent');
|
|
expect(wcSrc).toContain('trim().length < 80');
|
|
});
|
|
|
|
test('sticky removal sorts by vertical position (topmost first)', () => {
|
|
expect(wcSrc).toContain('sort((a, b) => a.top - b.top)');
|
|
});
|
|
|
|
test('sticky removal preserves first full-width element near top', () => {
|
|
expect(wcSrc).toContain('preservedTopNav');
|
|
// Should check element spans most of viewport
|
|
expect(wcSrc).toContain('viewportWidth * 0.8');
|
|
// Should only preserve the first one
|
|
expect(wcSrc).toContain('!preservedTopNav');
|
|
// Should check it's near the top
|
|
expect(wcSrc).toContain('top <= 50');
|
|
// Should check it's not too tall (it's a nav, not a hero)
|
|
expect(wcSrc).toContain('height < 120');
|
|
});
|
|
|
|
test('sticky removal still skips semantic nav/header elements', () => {
|
|
expect(wcSrc).toContain("tag === 'nav'");
|
|
expect(wcSrc).toContain("tag === 'header'");
|
|
expect(wcSrc).toContain("role') === 'navigation'");
|
|
});
|
|
});
|
|
|
|
// ─── Welcome page + sidebar auto-open ────────────────────────────
|
|
|
|
describe('welcome page', () => {
|
|
const welcomePath = path.join(ROOT, 'src', 'welcome.html');
|
|
const welcomeExists = fs.existsSync(welcomePath);
|
|
const welcomeSrc = welcomeExists ? fs.readFileSync(welcomePath, 'utf-8') : '';
|
|
|
|
test('welcome.html exists in browse/src/', () => {
|
|
expect(welcomeExists).toBe(true);
|
|
});
|
|
|
|
test('welcome page has GStack Browser branding', () => {
|
|
expect(welcomeSrc).toContain('GStack Browser');
|
|
});
|
|
|
|
test('welcome page has extension-ready listener to hide prompt', () => {
|
|
expect(welcomeSrc).toContain('gstack-extension-ready');
|
|
expect(welcomeSrc).toContain('sidebar-prompt');
|
|
});
|
|
|
|
test('welcome page points RIGHT toward sidebar (not UP at toolbar)', () => {
|
|
// Up arrow can never align with browser chrome. Right arrow always
|
|
// points toward the sidebar area regardless of window size.
|
|
expect(welcomeSrc).not.toContain('arrow-up');
|
|
expect(welcomeSrc).toContain('arrow-right');
|
|
});
|
|
|
|
test('welcome page has left-aligned text (no center-align on headings)', () => {
|
|
// User preference: always left-align, never center
|
|
expect(welcomeSrc).not.toMatch(/text-align:\s*center/);
|
|
});
|
|
|
|
test('welcome page uses dark theme', () => {
|
|
expect(welcomeSrc).toContain('#0C0C0C'); // --base (near-black)
|
|
expect(welcomeSrc).toContain('#141414'); // --surface (card bg)
|
|
});
|
|
});
|
|
|
|
describe('server /welcome endpoint', () => {
|
|
const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8');
|
|
|
|
test('/welcome endpoint exists in server.ts', () => {
|
|
expect(serverSrc).toContain("url.pathname === '/welcome'");
|
|
});
|
|
|
|
test('/welcome serves HTML content type', () => {
|
|
const welcomeSection = serverSrc.slice(
|
|
serverSrc.indexOf("url.pathname === '/welcome'"),
|
|
serverSrc.indexOf("url.pathname === '/health'"),
|
|
);
|
|
expect(welcomeSection).toContain("'Content-Type': 'text/html");
|
|
});
|
|
|
|
test('/welcome serves fallback HTML if no welcome file found', () => {
|
|
const welcomeSection = serverSrc.slice(
|
|
serverSrc.indexOf("url.pathname === '/welcome'"),
|
|
serverSrc.indexOf("url.pathname === '/health'"),
|
|
);
|
|
// Changed from 302 redirect to about:blank (ERR_UNSAFE_REDIRECT on Windows)
|
|
// to inline HTML fallback page (PR #822)
|
|
expect(welcomeSection).toContain('GStack Browser ready');
|
|
expect(welcomeSection).toContain('status: 200');
|
|
});
|
|
});
|
|
|
|
describe('headed launch navigates to welcome page', () => {
|
|
const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8');
|
|
|
|
test('server navigates to /welcome after startup in headed mode', () => {
|
|
// Navigation must happen AFTER Bun.serve() starts (not during launchHeaded)
|
|
// because the HTTP server needs to be listening before the browser requests /welcome
|
|
const afterServe = serverSrc.slice(serverSrc.indexOf('Bun.serve('));
|
|
expect(afterServe).toContain('/welcome');
|
|
expect(afterServe).toContain("getConnectionMode() === 'headed'");
|
|
});
|
|
|
|
test('welcome navigation does NOT happen in browser-manager (too early)', () => {
|
|
const bmSrc = fs.readFileSync(path.join(ROOT, 'src', 'browser-manager.ts'), 'utf-8');
|
|
// browser-manager.ts should NOT navigate to /welcome because the server
|
|
// isn't listening yet when launchHeaded() runs
|
|
const launchHeadedSection = bmSrc.slice(
|
|
bmSrc.indexOf('async launchHeaded('),
|
|
bmSrc.indexOf('// Browser disconnect handler'),
|
|
);
|
|
expect(launchHeadedSection).not.toContain('/welcome');
|
|
});
|
|
});
|
|
|
|
describe('sidebar auto-open (background.js)', () => {
|
|
const bgSrc = fs.readFileSync(path.join(ROOT, '..', 'extension', 'background.js'), 'utf-8');
|
|
|
|
test('autoOpenSidePanel function exists with retry logic', () => {
|
|
expect(bgSrc).toContain('async function autoOpenSidePanel');
|
|
expect(bgSrc).toContain('attempt < 5');
|
|
});
|
|
|
|
test('auto-open fires on install AND on every service worker startup', () => {
|
|
// onInstalled fires on first install / extension update
|
|
expect(bgSrc).toContain('chrome.runtime.onInstalled.addListener');
|
|
expect(bgSrc).toContain('autoOpenSidePanel()');
|
|
// Top-level call fires on every service worker startup
|
|
const topLevelCalls = bgSrc.match(/^autoOpenSidePanel\(\)/gm);
|
|
expect(topLevelCalls).not.toBeNull();
|
|
expect(topLevelCalls!.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
test('retry uses backoff delays (not fixed interval)', () => {
|
|
expect(bgSrc).toContain('500');
|
|
expect(bgSrc).toContain('1000');
|
|
expect(bgSrc).toContain('2000');
|
|
expect(bgSrc).toContain('3000');
|
|
expect(bgSrc).toContain('5000');
|
|
});
|
|
|
|
test('auto-open uses chrome.sidePanel.open with windowId', () => {
|
|
expect(bgSrc).toContain('chrome.sidePanel.open');
|
|
expect(bgSrc).toContain('windowId');
|
|
});
|
|
|
|
test('auto-open logs success and failure for debugging', () => {
|
|
expect(bgSrc).toContain('Side panel opened on attempt');
|
|
expect(bgSrc).toContain('Side panel auto-open failed');
|
|
});
|
|
});
|
|
|
|
describe('sidebar arrow hint hide flow (4-step signal chain)', () => {
|
|
// The arrow hint on the welcome page should ONLY hide when the sidebar
|
|
// is actually opened, not when the extension content script loads.
|
|
//
|
|
// Signal flow:
|
|
// 1. sidepanel.js connects → sends { type: 'sidebarOpened' } to background
|
|
// 2. background.js receives → relays to active tab's content script
|
|
// 3. content.js receives 'sidebarOpened' → dispatches 'gstack-extension-ready'
|
|
// 4. welcome.html listens for 'gstack-extension-ready' → hides arrow
|
|
//
|
|
const contentSrc = fs.readFileSync(path.join(ROOT, '..', 'extension', 'content.js'), 'utf-8');
|
|
const bgSrc = fs.readFileSync(path.join(ROOT, '..', 'extension', 'background.js'), 'utf-8');
|
|
const spSrc = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8');
|
|
const welcomeSrc = fs.readFileSync(path.join(ROOT, 'src', 'welcome.html'), 'utf-8');
|
|
|
|
// Step 1: sidepanel sends sidebarOpened when connected
|
|
test('step 1: sidepanel sends sidebarOpened message on connect', () => {
|
|
expect(spSrc).toContain("{ type: 'sidebarOpened' }");
|
|
// Should be in updateConnection, after setConnState('connected').
|
|
// Window is generous: updateConnection also exposes the PTY bootstrap
|
|
// globals (gstackServerPort/gstackAuthToken) before the connected branch.
|
|
const connectFn = spSrc.slice(
|
|
spSrc.indexOf('function updateConnection('),
|
|
spSrc.indexOf('function updateConnection(') + 2500,
|
|
);
|
|
const connectedIdx = connectFn.indexOf("setConnState('connected')");
|
|
const openedIdx = connectFn.indexOf('sidebarOpened');
|
|
expect(connectedIdx).toBeGreaterThan(0);
|
|
expect(openedIdx).toBeGreaterThan(connectedIdx);
|
|
});
|
|
|
|
// Step 2: background.js accepts and relays sidebarOpened
|
|
test('step 2: background.js allows sidebarOpened message type', () => {
|
|
expect(bgSrc).toContain("'sidebarOpened'");
|
|
// Must be in ALLOWED_TYPES
|
|
const allowedBlock = bgSrc.slice(
|
|
bgSrc.indexOf('ALLOWED_TYPES'),
|
|
bgSrc.indexOf('ALLOWED_TYPES') + 300,
|
|
);
|
|
expect(allowedBlock).toContain('sidebarOpened');
|
|
});
|
|
|
|
test('step 2: background.js relays sidebarOpened to active tab content script', () => {
|
|
expect(bgSrc).toContain("msg.type === 'sidebarOpened'");
|
|
// Should send to active tab via chrome.tabs.sendMessage
|
|
const handler = bgSrc.slice(
|
|
bgSrc.indexOf("msg.type === 'sidebarOpened'"),
|
|
bgSrc.indexOf("msg.type === 'sidebarOpened'") + 400,
|
|
);
|
|
expect(handler).toContain('chrome.tabs.sendMessage');
|
|
expect(handler).toContain("{ type: 'sidebarOpened' }");
|
|
});
|
|
|
|
// Step 3: content.js fires gstack-extension-ready ONLY on sidebarOpened
|
|
test('step 3: content.js dispatches extension-ready on sidebarOpened message', () => {
|
|
expect(contentSrc).toContain("msg.type === 'sidebarOpened'");
|
|
expect(contentSrc).toContain("new CustomEvent('gstack-extension-ready')");
|
|
});
|
|
|
|
test('step 3: content.js does NOT auto-fire extension-ready on load', () => {
|
|
// The old pattern was: fire immediately when content script loads.
|
|
// Now it should only fire when sidebarOpened message arrives.
|
|
// Check there's no top-level dispatchEvent outside the message handler.
|
|
const beforeListener = contentSrc.slice(0, contentSrc.indexOf('chrome.runtime.onMessage'));
|
|
expect(beforeListener).not.toContain("dispatchEvent(new CustomEvent('gstack-extension-ready'))");
|
|
});
|
|
|
|
// Step 4: welcome page hides arrow on gstack-extension-ready
|
|
test('step 4: welcome page hides arrow on gstack-extension-ready event', () => {
|
|
expect(welcomeSrc).toContain("'gstack-extension-ready'");
|
|
expect(welcomeSrc).toContain("classList.add('hidden')");
|
|
});
|
|
|
|
test('step 4: welcome page does NOT auto-hide via status pill polling', () => {
|
|
// The old fallback (checkPill/gstack-status-pill) would hide the arrow
|
|
// as soon as the content script injected the pill, even without sidebar open.
|
|
expect(welcomeSrc).not.toContain('checkPill');
|
|
expect(welcomeSrc).not.toContain('gstack-status-pill');
|
|
});
|
|
});
|
|
|
|
describe('sidebar auth race prevention', () => {
|
|
const bgSrc = fs.readFileSync(path.join(ROOT, '..', 'extension', 'background.js'), 'utf-8');
|
|
const spSrc = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8');
|
|
|
|
test('getPort response includes authToken (not just port + connected)', () => {
|
|
// The auth race: sidepanel calls getPort, gets {port, connected} but no token.
|
|
// All subsequent requests fail 401. Token must be in the getPort response.
|
|
const getPortHandler = bgSrc.slice(
|
|
bgSrc.indexOf("msg.type === 'getPort'"),
|
|
bgSrc.indexOf("msg.type === 'setPort'"),
|
|
);
|
|
expect(getPortHandler).toContain('token: authToken');
|
|
});
|
|
|
|
test('tryConnect uses token from getPort response', () => {
|
|
// Sidepanel must pass resp.token to updateConnection, not null
|
|
const start = spSrc.indexOf('function tryConnect()');
|
|
const end = spSrc.indexOf('\ntryConnect();', start); // top-level call after the function
|
|
const tryConnectFn = spSrc.slice(start, end);
|
|
expect(tryConnectFn).toContain('resp.token');
|
|
expect(tryConnectFn).not.toContain('updateConnection(url, null)');
|
|
});
|
|
});
|
|
|
|
describe('startup health check fast-retry', () => {
|
|
const bgSrc = fs.readFileSync(path.join(ROOT, '..', 'extension', 'background.js'), 'utf-8');
|
|
|
|
test('initial health check retries every 1s (not 10s)', () => {
|
|
// The server may not be listening when the extension starts because
|
|
// Chromium launches before Bun.serve(). A 10s gap means the user
|
|
// stares at "Connecting..." for 10 seconds. 1s retry fixes this.
|
|
expect(bgSrc).toContain('startupAttempts');
|
|
expect(bgSrc).toContain('setInterval(async ()');
|
|
// Fast retry uses 1000ms, not the 10000ms slow poll
|
|
expect(bgSrc).toContain('}, 1000);');
|
|
});
|
|
|
|
test('startup retry stops after connection or max attempts', () => {
|
|
expect(bgSrc).toContain('isConnected || startupAttempts >= 15');
|
|
expect(bgSrc).toContain('clearInterval(startupCheck)');
|
|
});
|
|
|
|
test('slow 10s polling only starts after startup phase completes', () => {
|
|
expect(bgSrc).toContain('if (!healthInterval)');
|
|
expect(bgSrc).toContain('setInterval(checkHealth, 10000)');
|
|
});
|
|
});
|
|
|
|
describe('sidebar debug visibility when stuck', () => {
|
|
const spSrc = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8');
|
|
|
|
test('connection state machine has a dead state with user-visible message', () => {
|
|
expect(spSrc).toContain("'dead'");
|
|
expect(spSrc).toContain('MAX_RECONNECT_ATTEMPTS');
|
|
});
|
|
|
|
test('reconnect attempt counter is visible in the UI', () => {
|
|
// The banner should show attempt count so user knows something is happening
|
|
expect(spSrc).toContain('reconnectAttempts');
|
|
});
|
|
});
|
|
|
|
describe('BROWSE_NO_AUTOSTART (sidebar headless prevention)', () => {
|
|
const cliSrc = fs.readFileSync(path.join(ROOT, 'src', 'cli.ts'), 'utf-8');
|
|
const termAgentSrc = fs.readFileSync(path.join(ROOT, 'src', 'terminal-agent.ts'), 'utf-8');
|
|
|
|
test('cli.ts checks BROWSE_NO_AUTOSTART before starting a new server', () => {
|
|
// ensureServer must check this env var BEFORE spawning a server.
|
|
// (Anchor on the open paren — both functions grew parameters.)
|
|
const ensureStart = cliSrc.indexOf('async function ensureServer(');
|
|
const ensureEnd = cliSrc.indexOf('\nasync function ', ensureStart + 1);
|
|
const ensureServerFn = cliSrc.slice(
|
|
ensureStart,
|
|
ensureEnd > ensureStart ? ensureEnd : undefined,
|
|
);
|
|
expect(ensureServerFn).toContain('BROWSE_NO_AUTOSTART');
|
|
expect(ensureServerFn).toContain('process.exit(1)');
|
|
});
|
|
|
|
test('cli.ts shows actionable error message when BROWSE_NO_AUTOSTART blocks', () => {
|
|
expect(cliSrc).toContain('/open-gstack-browser');
|
|
expect(cliSrc).toContain('BROWSE_NO_AUTOSTART is set');
|
|
});
|
|
|
|
test('terminal-agent.ts sets BROWSE_NO_AUTOSTART=1 for the claude PTY', () => {
|
|
// The PTY claude must reuse THIS headed server, never race to spawn
|
|
// its own. (sidebar-agent.ts, the original setter, was ripped in
|
|
// PR #1216 — the PTY agent inherited the same env contract.)
|
|
expect(termAgentSrc).toContain("BROWSE_NO_AUTOSTART: '1'");
|
|
});
|
|
|
|
test('terminal-agent.ts sets BROWSE_PORT for headed server reuse', () => {
|
|
expect(termAgentSrc).toContain('BROWSE_PORT');
|
|
});
|
|
|
|
test('BROWSE_NO_AUTOSTART check happens before lock acquisition', () => {
|
|
// The guard must be BEFORE the lock acquisition. If it's after,
|
|
// we'd acquire a lock and then exit, leaving a stale lock file.
|
|
const ensureServerStart = cliSrc.indexOf('async function ensureServer(');
|
|
const noAutoStart = cliSrc.indexOf('BROWSE_NO_AUTOSTART', ensureServerStart);
|
|
const lockAcquisition = cliSrc.indexOf('Acquire lock', ensureServerStart);
|
|
expect(noAutoStart).toBeGreaterThan(0);
|
|
expect(lockAcquisition).toBeGreaterThan(0);
|
|
expect(noAutoStart).toBeLessThan(lockAcquisition);
|
|
});
|
|
});
|
|
|
|
// ─── Idle timeout disabled in headed mode (server.ts) ───────────
|
|
//
|
|
// The original 'idle check skips in headed mode' string-grep test was deleted
|
|
// in v1.42.3.0 — it would have passed even with the dual-instance bug present
|
|
// because it only grepped for "=== 'headed'" + 'return' in the same window.
|
|
// Behavioral coverage lives in browse/test/server-factory.test.ts under the
|
|
// 'idle timer + onDisconnect dual-instance fix' describe block, which
|
|
// exercises the headed/headless/tunnel branches of idleCheckTick directly.
|
|
// The companion '/sidebar-command resets idle timer' test went with the
|
|
// chat-queue rip (PR #1216) — /command and /batch reset the timer and are
|
|
// covered by that factory suite.
|
|
|
|
// ─── Shutdown kills the terminal-agent (server.ts) ──────────────
|
|
|
|
describe('shutdown cleanup (server.ts)', () => {
|
|
const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8');
|
|
|
|
test('shutdown kills the terminal-agent via identity-based kill (no pkill)', () => {
|
|
// v1.44+ identity-based teardown: only the PID recorded by THIS
|
|
// daemon's agent is signaled. The pre-v1.44 `pkill -f terminal-agent`
|
|
// regex killed sibling gstack sessions on the same host (also pinned
|
|
// by browse/test/terminal-agent-pid-identity.test.ts).
|
|
const shutdownFn = serverSrc.slice(
|
|
serverSrc.indexOf('async function shutdown('),
|
|
serverSrc.indexOf('try { detachSession()', serverSrc.indexOf('async function shutdown(')),
|
|
);
|
|
expect(shutdownFn).toContain('stopAgentByRecord');
|
|
expect(shutdownFn).toContain('isOurAgent(record, process.pid)');
|
|
expect(shutdownFn).toContain('readAgentRecord');
|
|
// No pkill CALL — the word may appear in the explanatory comment, so
|
|
// match invocation shapes only. The repo-wide reintroduction tripwire
|
|
// is browse/test/terminal-agent-pid-identity.test.ts.
|
|
expect(shutdownFn).not.toMatch(/(?:spawnSync|execSync|\$)\(\s*['"`]pkill/);
|
|
});
|
|
});
|
|
|
|
// ─── Cookie button in sidebar footer ────────────────────────────
|
|
|
|
describe('cookie import button (sidebar)', () => {
|
|
const html = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.html'), 'utf-8');
|
|
const js = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8');
|
|
|
|
test('quick actions toolbar has cookies button', () => {
|
|
expect(html).toContain('id="chat-cookies-btn"');
|
|
expect(html).toContain('Cookies');
|
|
});
|
|
|
|
test('cookies button navigates to cookie-picker', () => {
|
|
expect(js).toContain("'chat-cookies-btn'");
|
|
expect(js).toContain('cookie-picker');
|
|
});
|
|
});
|