mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-01 19:25:10 +02:00
d0782c4c4d
* feat(browse): full $B pdf flag contract + tab-scoped load-html/js/pdf
Grow $B pdf from a 2-line wrapper (hard-coded A4) into a real PDF engine
frontend so make-pdf can shell out to it without duplicating Playwright:
- pdf: --format, --width/--height, --margins, --margin-*, --header-template,
--footer-template, --page-numbers, --tagged, --outline, --print-background,
--prefer-css-page-size, --toc. Mutex rules enforced. --from-file <json>
dodges Windows argv limits (8191 char CreateProcess cap).
- load-html: add --from-file <json> mode for large inline HTML. Size + magic
byte checks still apply to the inline content, not the payload file path.
- newtab: add --json returning {"tabId":N,"url":...} for programmatic use.
- cli: extract --tab-id flag and route as body.tabId to the HTTP layer so
parallel callers can target specific tabs without racing on the active
tab (makes make-pdf's per-render tab isolation possible).
- --toc: non-fatal 3s wait for window.__pagedjsAfterFired. Paged.js ships
later; v1 renders TOC statically via the markdown renderer.
Codex round 2 flagged these P0 issues during plan review. All resolved.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(resolvers): add MAKE_PDF_SETUP + makePdfDir host paths
Skill templates can now embed {{MAKE_PDF_SETUP}} to resolve $P to the
make-pdf binary via the same discovery order as $B / $D: env override
(MAKE_PDF_BIN), local skill root, global install, or PATH.
Mirrors the pattern established by generateBrowseSetup() and
generateDesignSetup() in scripts/resolvers/design.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(make-pdf): new /make-pdf skill + orchestrator binary
Turn markdown into publication-quality PDFs. $P generate input.md out.pdf
produces a PDF with 1in margins, intelligent page breaks, page numbers,
running header, CONFIDENTIAL footer, and curly quotes/em dashes — all on
Helvetica so copy-paste extraction works ("S ai li ng" bug avoided).
Architecture (per Codex round 2):
markdown → render.ts (marked + sanitize + smartypants) → orchestrator
→ $B newtab --json → $B load-html --tab-id → $B js (poll Paged.js)
→ $B pdf --tab-id → $B closetab
browseClient.ts shells out to the compiled browse CLI rather than
duplicating Playwright. --tab-id isolation per render means parallel
$P generate calls don't race on the active tab. try/finally tab cleanup
survives Paged.js timeouts, browser crashes, and output-path failures.
Features in v1:
--cover left-aligned cover page (eyebrow + title + hairline rule)
--toc clickable static TOC (Paged.js page numbers deferred)
--watermark <text> diagonal DRAFT/CONFIDENTIAL layer
--no-chapter-breaks opt out of H1-starts-new-page
--page-numbers "N of M" footer (default on)
--tagged --outline accessible PDF + bookmark outline (default on)
--allow-network opt in to external image loading (default off for privacy)
--quiet --verbose stderr control
Design decisions locked from the /plan-design-review pass:
- Helvetica everywhere (Chromium emits single-word Tj operators for
system fonts; bundled webfonts emit per-glyph and break extraction).
- Left-aligned body, flush-left paragraphs, no text-indent, 12pt gap.
- Cover shares 1in margins with body pages; no flexbox-center, no
inset padding.
- The reference HTMLs at .context/designs/*.html are the implementation
source of truth for print-css.ts.
Tests (56 unit + 1 E2E combined-features gate):
- smartypants: code/URL-safe, verified against 10 fixtures
- sanitizer: strips <script>/<iframe>/on*/javascript: URLs
- render: HTML assembly, CJK fallback, cover/TOC/chapter wrap
- print-css: all @page rules, margin variants, watermark
- pdftotext: normalize()+copyPasteGate() cross-OS tolerance
- browseClient: binary resolution + typed error propagation
- combined-features gate (P0): 2-chapter fixture with smartypants +
hyphens + ligatures + bold/italic + inline code + lists + blockquote
passes through PDF → pdftotext → expected.txt diff
Deferred to Phase 4 (future PR): Paged.js vendored for accurate TOC page
numbers, highlight.js for syntax highlighting, drop caps, pull quotes,
two-column, CMYK, watermark visual-diff acceptance.
Plan: .context/ceo-plans/2026-04-19-perfect-pdf-generator.md
References: .context/designs/make-pdf-*.html
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(build): wire make-pdf into build/test/setup/bin + add marked dep
- package.json: compile make-pdf/dist/pdf as part of bun run build; add
"make-pdf" to bin entry; include make-pdf/test/ in the free test pass;
add marked@18.0.2 as a dep (markdown parser, ~40KB).
- setup: add make-pdf/dist/pdf to the Apple Silicon codesign loop.
- .gitignore: add make-pdf/dist/ (matches browse/dist/ and design/dist/).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci(make-pdf): matrix copy-paste gate on Ubuntu + macOS
Runs the combined-features P0 gate on pull requests that touch make-pdf/
or browse's PDF surface. Installs poppler (macOS) / poppler-utils (Ubuntu)
per OS. Windows deferred to tolerant mode (Xpdf / Poppler-Windows
extraction variance not yet calibrated against the normalized comparator —
Codex round 2 #18).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(skills): regenerate SKILL.md for make-pdf addition + browse pdf flags
bun run gen:skill-docs picks up:
- the new /make-pdf skill (make-pdf/SKILL.md)
- updated browse command descriptions for 'pdf', 'load-html', 'newtab'
reflecting the new flag contract and --from-file mode
Source of truth stays the .tmpl files + COMMAND_DESCRIPTIONS;
these are regenerated artifacts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tests): repair stale test expectations + emit _EXPLAIN_LEVEL / _QUESTION_TUNING from preamble
Three pre-existing test failures on main were blocking /ship:
- test/skill-validation.test.ts "Step 3.4 test coverage audit" expected the
literal strings "CODE PATH COVERAGE" and "USER FLOW COVERAGE" which were
removed when the Step 7 coverage diagram was compressed. Updated assertions
to check the stable `Code paths:` / `User flows:` labels that still ship.
- test/skill-validation.test.ts "ship step numbering" allowed-substeps list
didn't include 15.0 (WIP squash) and 15.1 (bisectable commits) which were
added for continuous checkpoint mode. Extended the allowlist.
- test/writing-style-resolver.test.ts and test/plan-tune.test.ts expected
`_EXPLAIN_LEVEL` and `_QUESTION_TUNING` bash variables in the preamble but
generate-preamble-bash.ts had been refactored and those lines were dropped.
Without them, downstream skills can't read `explain_level` or
`question_tuning` config at runtime — terse mode and /plan-tune features
were silently broken.
Added the two bash echo blocks back to generatePreambleBash and refreshed
the golden-file fixtures to match. All three preamble-related golden
baselines (claude/codex/factory) are synchronized with the new output.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: bump version and changelog (v1.4.0.0)
New /make-pdf skill + $P binary.
Turn any markdown file into a publication-quality PDF. Default output is
a 1in-margin Helvetica letter with page numbers in the footer. `--cover`
adds a left-aligned cover page, `--toc` generates a clickable table of
contents, `--watermark DRAFT` overlays a diagonal watermark. Copy-paste
extraction from the PDF produces clean words, not "S a i l i n g"
spaced out letter by letter. CI gate (macOS + Ubuntu) runs a combined-
features fixture through pdftotext on every PR.
make-pdf shells out to browse rather than duplicating Playwright.
$B pdf grew into a real PDF engine with full flag contract (--format,
--margins, --header-template, --footer-template, --page-numbers,
--tagged, --outline, --toc, --tab-id, --from-file). $B load-html and
$B js gained --tab-id. $B newtab --json returns structured output.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(changelog): rewrite v1.4.0.0 headline — positive voice, no VC framing
The original headline led with "a PDF you wouldn't be embarrassed to send
to a VC": double-negative voice and audience-too-narrow. /make-pdf works
for essays, letters, memos, reports, proposals, and briefs. Framing the
whole release around founders-to-investors misses the wider audience.
New headline: "Turn any markdown file into a PDF that looks finished."
New tagline: "This one reads like a real essay or a real letter."
Positive voice. Broader aperture. Same energy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
560 lines
23 KiB
TypeScript
560 lines
23 KiB
TypeScript
/**
|
|
* Tests for sidebar agent queue parsing and inbox writing.
|
|
*
|
|
* sidebar-agent.ts functions are not exported (it's an entry-point script),
|
|
* so we test the same logic inline: JSONL parsing, writeToInbox filesystem
|
|
* behavior, and edge cases.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
|
|
// ─── Helpers: replicate sidebar-agent logic for unit testing ──────
|
|
|
|
/** Parse a single JSONL line — same logic as sidebar-agent poll() */
|
|
function parseQueueLine(line: string): any | null {
|
|
if (!line.trim()) return null;
|
|
try {
|
|
const entry = JSON.parse(line);
|
|
if (!entry.message && !entry.prompt) return null;
|
|
return entry;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Read all valid entries from a JSONL string — same as countLines + readLine loop */
|
|
function parseQueueFile(content: string): any[] {
|
|
const entries: any[] = [];
|
|
const lines = content.split('\n').filter(Boolean);
|
|
for (const line of lines) {
|
|
const entry = parseQueueLine(line);
|
|
if (entry) entries.push(entry);
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
/** Write to inbox — extracted logic from sidebar-agent.ts writeToInbox() */
|
|
function writeToInbox(
|
|
gitRoot: string,
|
|
message: string,
|
|
pageUrl?: string,
|
|
sessionId?: string,
|
|
): string | null {
|
|
if (!gitRoot) return null;
|
|
|
|
const inboxDir = path.join(gitRoot, '.context', 'sidebar-inbox');
|
|
fs.mkdirSync(inboxDir, { recursive: true });
|
|
|
|
const now = new Date();
|
|
const timestamp = now.toISOString().replace(/:/g, '-');
|
|
const filename = `${timestamp}-observation.json`;
|
|
const tmpFile = path.join(inboxDir, `.${filename}.tmp`);
|
|
const finalFile = path.join(inboxDir, filename);
|
|
|
|
const inboxMessage = {
|
|
type: 'observation',
|
|
timestamp: now.toISOString(),
|
|
page: { url: pageUrl || 'unknown', title: '' },
|
|
userMessage: message,
|
|
sidebarSessionId: sessionId || 'unknown',
|
|
};
|
|
|
|
fs.writeFileSync(tmpFile, JSON.stringify(inboxMessage, null, 2));
|
|
fs.renameSync(tmpFile, finalFile);
|
|
return finalFile;
|
|
}
|
|
|
|
/** Shorten paths — same logic as sidebar-agent.ts shorten() */
|
|
function shorten(str: string): string {
|
|
return str
|
|
.replace(/\/Users\/[^/]+/g, '~')
|
|
.replace(/\/conductor\/workspaces\/[^/]+\/[^/]+/g, '')
|
|
.replace(/\.claude\/skills\/gstack\//g, '')
|
|
.replace(/browse\/dist\/browse/g, '$B');
|
|
}
|
|
|
|
/** describeToolCall — replicated from sidebar-agent.ts for unit testing */
|
|
function describeToolCall(tool: string, input: any): string {
|
|
if (!input) return '';
|
|
|
|
if (tool === 'Bash' && input.command) {
|
|
const cmd = input.command;
|
|
const browseMatch = cmd.match(/\$B\s+(\w+)|browse[^\s]*\s+(\w+)/);
|
|
if (browseMatch) {
|
|
const browseCmd = browseMatch[1] || browseMatch[2];
|
|
const args = cmd.split(/\s+/).slice(2).join(' ');
|
|
switch (browseCmd) {
|
|
case 'goto': return `Opening ${args.replace(/['"]/g, '')}`;
|
|
case 'snapshot': return args.includes('-i') ? 'Scanning for interactive elements' : args.includes('-D') ? 'Checking what changed' : 'Taking a snapshot of the page';
|
|
case 'screenshot': return `Saving screenshot${args ? ` to ${shorten(args)}` : ''}`;
|
|
case 'click': return `Clicking ${args}`;
|
|
case 'fill': { const parts = args.split(/\s+/); return `Typing "${parts.slice(1).join(' ')}" into ${parts[0]}`; }
|
|
case 'text': return 'Reading page text';
|
|
case 'html': return args ? `Reading HTML of ${args}` : 'Reading full page HTML';
|
|
case 'links': return 'Finding all links on the page';
|
|
case 'forms': return 'Looking for forms';
|
|
case 'console': return 'Checking browser console for errors';
|
|
case 'network': return 'Checking network requests';
|
|
case 'url': return 'Checking current URL';
|
|
case 'back': return 'Going back';
|
|
case 'forward': return 'Going forward';
|
|
case 'reload': return 'Reloading the page';
|
|
case 'scroll': return args ? `Scrolling to ${args}` : 'Scrolling down';
|
|
case 'wait': return `Waiting for ${args}`;
|
|
case 'inspect': return args ? `Inspecting CSS of ${args}` : 'Getting CSS for last picked element';
|
|
case 'style': return `Changing CSS: ${args}`;
|
|
case 'cleanup': return 'Removing page clutter (ads, popups, banners)';
|
|
case 'prettyscreenshot': return 'Taking a clean screenshot';
|
|
case 'css': return `Checking CSS property: ${args}`;
|
|
case 'is': return `Checking if element is ${args}`;
|
|
case 'diff': return `Comparing ${args}`;
|
|
case 'responsive': return 'Taking screenshots at mobile, tablet, and desktop sizes';
|
|
case 'status': return 'Checking browser status';
|
|
case 'tabs': return 'Listing open tabs';
|
|
case 'focus': return 'Bringing browser to front';
|
|
case 'select': return `Selecting option in ${args}`;
|
|
case 'hover': return `Hovering over ${args}`;
|
|
case 'viewport': return `Setting viewport to ${args}`;
|
|
case 'upload': return `Uploading file to ${args.split(/\s+/)[0]}`;
|
|
default: return `Running browse ${browseCmd} ${args}`.trim();
|
|
}
|
|
}
|
|
if (cmd.includes('git ')) return `Running: ${shorten(cmd)}`;
|
|
let short = shorten(cmd);
|
|
return short.length > 100 ? short.slice(0, 100) + '…' : short;
|
|
}
|
|
|
|
if (tool === 'Read' && input.file_path) return `Reading ${shorten(input.file_path)}`;
|
|
if (tool === 'Edit' && input.file_path) return `Editing ${shorten(input.file_path)}`;
|
|
if (tool === 'Write' && input.file_path) return `Writing ${shorten(input.file_path)}`;
|
|
if (tool === 'Grep' && input.pattern) return `Searching for "${input.pattern}"`;
|
|
if (tool === 'Glob' && input.pattern) return `Finding files matching ${input.pattern}`;
|
|
try { return shorten(JSON.stringify(input)).slice(0, 80); } catch { return ''; }
|
|
}
|
|
|
|
// ─── Test setup ──────────────────────────────────────────────────
|
|
|
|
let tmpDir: string;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sidebar-agent-test-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
// ─── Queue File Parsing ─────────────────────────────────────────
|
|
|
|
describe('queue file parsing', () => {
|
|
test('valid JSONL line parsed correctly', () => {
|
|
const line = JSON.stringify({ message: 'hello', prompt: 'check this', pageUrl: 'https://example.com' });
|
|
const entry = parseQueueLine(line);
|
|
expect(entry).not.toBeNull();
|
|
expect(entry.message).toBe('hello');
|
|
expect(entry.prompt).toBe('check this');
|
|
expect(entry.pageUrl).toBe('https://example.com');
|
|
});
|
|
|
|
test('malformed JSON line skipped without crash', () => {
|
|
const entry = parseQueueLine('this is not json {{{');
|
|
expect(entry).toBeNull();
|
|
});
|
|
|
|
test('valid JSON without message or prompt is skipped', () => {
|
|
const line = JSON.stringify({ foo: 'bar' });
|
|
const entry = parseQueueLine(line);
|
|
expect(entry).toBeNull();
|
|
});
|
|
|
|
test('empty file returns no entries', () => {
|
|
const entries = parseQueueFile('');
|
|
expect(entries).toEqual([]);
|
|
});
|
|
|
|
test('file with blank lines returns no entries', () => {
|
|
const entries = parseQueueFile('\n\n\n');
|
|
expect(entries).toEqual([]);
|
|
});
|
|
|
|
test('mixed valid and invalid lines', () => {
|
|
const content = [
|
|
JSON.stringify({ message: 'first' }),
|
|
'not json',
|
|
JSON.stringify({ unrelated: true }),
|
|
JSON.stringify({ message: 'second', prompt: 'do stuff' }),
|
|
].join('\n');
|
|
|
|
const entries = parseQueueFile(content);
|
|
expect(entries.length).toBe(2);
|
|
expect(entries[0].message).toBe('first');
|
|
expect(entries[1].message).toBe('second');
|
|
});
|
|
});
|
|
|
|
// ─── writeToInbox ────────────────────────────────────────────────
|
|
|
|
describe('writeToInbox', () => {
|
|
test('creates .context/sidebar-inbox/ directory', () => {
|
|
writeToInbox(tmpDir, 'test message');
|
|
const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox');
|
|
expect(fs.existsSync(inboxDir)).toBe(true);
|
|
expect(fs.statSync(inboxDir).isDirectory()).toBe(true);
|
|
});
|
|
|
|
test('writes valid JSON file', () => {
|
|
const filePath = writeToInbox(tmpDir, 'test message', 'https://example.com', 'session-123');
|
|
expect(filePath).not.toBeNull();
|
|
expect(fs.existsSync(filePath!)).toBe(true);
|
|
|
|
const data = JSON.parse(fs.readFileSync(filePath!, 'utf-8'));
|
|
expect(data.type).toBe('observation');
|
|
expect(data.userMessage).toBe('test message');
|
|
expect(data.page.url).toBe('https://example.com');
|
|
expect(data.sidebarSessionId).toBe('session-123');
|
|
expect(data.timestamp).toBeTruthy();
|
|
});
|
|
|
|
test('atomic write — final file exists, no .tmp left', () => {
|
|
const filePath = writeToInbox(tmpDir, 'atomic test');
|
|
expect(filePath).not.toBeNull();
|
|
expect(fs.existsSync(filePath!)).toBe(true);
|
|
|
|
// Check no .tmp files remain in the inbox directory
|
|
const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox');
|
|
const files = fs.readdirSync(inboxDir);
|
|
const tmpFiles = files.filter(f => f.endsWith('.tmp'));
|
|
expect(tmpFiles.length).toBe(0);
|
|
|
|
// Final file should end with -observation.json
|
|
const jsonFiles = files.filter(f => f.endsWith('-observation.json') && !f.startsWith('.'));
|
|
expect(jsonFiles.length).toBe(1);
|
|
});
|
|
|
|
test('handles missing git root gracefully', () => {
|
|
const result = writeToInbox('', 'test');
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
test('defaults pageUrl to unknown when not provided', () => {
|
|
const filePath = writeToInbox(tmpDir, 'no url provided');
|
|
expect(filePath).not.toBeNull();
|
|
const data = JSON.parse(fs.readFileSync(filePath!, 'utf-8'));
|
|
expect(data.page.url).toBe('unknown');
|
|
});
|
|
|
|
test('defaults sessionId to unknown when not provided', () => {
|
|
const filePath = writeToInbox(tmpDir, 'no session');
|
|
expect(filePath).not.toBeNull();
|
|
const data = JSON.parse(fs.readFileSync(filePath!, 'utf-8'));
|
|
expect(data.sidebarSessionId).toBe('unknown');
|
|
});
|
|
|
|
test('multiple writes create separate files', () => {
|
|
writeToInbox(tmpDir, 'message 1');
|
|
// Tiny delay to ensure different timestamps
|
|
const t = Date.now();
|
|
while (Date.now() === t) {} // spin until next ms
|
|
writeToInbox(tmpDir, 'message 2');
|
|
|
|
const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox');
|
|
const files = fs.readdirSync(inboxDir).filter(f => f.endsWith('.json') && !f.startsWith('.'));
|
|
expect(files.length).toBe(2);
|
|
});
|
|
});
|
|
|
|
// ─── describeToolCall (verbose narration) ────────────────────────
|
|
|
|
describe('describeToolCall', () => {
|
|
// Browse navigation commands
|
|
test('goto → plain English with URL', () => {
|
|
const result = describeToolCall('Bash', { command: '$B goto https://example.com' });
|
|
expect(result).toBe('Opening https://example.com');
|
|
});
|
|
|
|
test('goto strips quotes from URL', () => {
|
|
const result = describeToolCall('Bash', { command: '$B goto "https://example.com"' });
|
|
expect(result).toBe('Opening https://example.com');
|
|
});
|
|
|
|
test('url → checking current URL', () => {
|
|
expect(describeToolCall('Bash', { command: '$B url' })).toBe('Checking current URL');
|
|
});
|
|
|
|
test('back/forward/reload → plain English', () => {
|
|
expect(describeToolCall('Bash', { command: '$B back' })).toBe('Going back');
|
|
expect(describeToolCall('Bash', { command: '$B forward' })).toBe('Going forward');
|
|
expect(describeToolCall('Bash', { command: '$B reload' })).toBe('Reloading the page');
|
|
});
|
|
|
|
// Snapshot variants
|
|
test('snapshot -i → scanning for interactive elements', () => {
|
|
expect(describeToolCall('Bash', { command: '$B snapshot -i' })).toBe('Scanning for interactive elements');
|
|
});
|
|
|
|
test('snapshot -D → checking what changed', () => {
|
|
expect(describeToolCall('Bash', { command: '$B snapshot -D' })).toBe('Checking what changed');
|
|
});
|
|
|
|
test('snapshot (plain) → taking a snapshot', () => {
|
|
expect(describeToolCall('Bash', { command: '$B snapshot' })).toBe('Taking a snapshot of the page');
|
|
});
|
|
|
|
// Interaction commands
|
|
test('click → clicking element', () => {
|
|
expect(describeToolCall('Bash', { command: '$B click @e3' })).toBe('Clicking @e3');
|
|
});
|
|
|
|
test('fill → typing into element', () => {
|
|
expect(describeToolCall('Bash', { command: '$B fill @e4 "hello world"' })).toBe('Typing ""hello world"" into @e4');
|
|
});
|
|
|
|
test('scroll with selector → scrolling to element', () => {
|
|
expect(describeToolCall('Bash', { command: '$B scroll .footer' })).toBe('Scrolling to .footer');
|
|
});
|
|
|
|
test('scroll without args → scrolling down', () => {
|
|
expect(describeToolCall('Bash', { command: '$B scroll' })).toBe('Scrolling down');
|
|
});
|
|
|
|
// Reading commands
|
|
test('text → reading page text', () => {
|
|
expect(describeToolCall('Bash', { command: '$B text' })).toBe('Reading page text');
|
|
});
|
|
|
|
test('html with selector → reading HTML of element', () => {
|
|
expect(describeToolCall('Bash', { command: '$B html .header' })).toBe('Reading HTML of .header');
|
|
});
|
|
|
|
test('html without selector → reading full page HTML', () => {
|
|
expect(describeToolCall('Bash', { command: '$B html' })).toBe('Reading full page HTML');
|
|
});
|
|
|
|
test('links → finding all links', () => {
|
|
expect(describeToolCall('Bash', { command: '$B links' })).toBe('Finding all links on the page');
|
|
});
|
|
|
|
test('console → checking console', () => {
|
|
expect(describeToolCall('Bash', { command: '$B console' })).toBe('Checking browser console for errors');
|
|
});
|
|
|
|
// Inspector commands
|
|
test('inspect with selector → inspecting CSS', () => {
|
|
expect(describeToolCall('Bash', { command: '$B inspect .header' })).toBe('Inspecting CSS of .header');
|
|
});
|
|
|
|
test('inspect without args → getting last picked element', () => {
|
|
expect(describeToolCall('Bash', { command: '$B inspect' })).toBe('Getting CSS for last picked element');
|
|
});
|
|
|
|
test('style → changing CSS', () => {
|
|
expect(describeToolCall('Bash', { command: '$B style .header color red' })).toBe('Changing CSS: .header color red');
|
|
});
|
|
|
|
test('cleanup → removing page clutter', () => {
|
|
expect(describeToolCall('Bash', { command: '$B cleanup --all' })).toBe('Removing page clutter (ads, popups, banners)');
|
|
});
|
|
|
|
// Visual commands
|
|
test('screenshot → saving screenshot', () => {
|
|
expect(describeToolCall('Bash', { command: '$B screenshot /tmp/shot.png' })).toBe('Saving screenshot to /tmp/shot.png');
|
|
});
|
|
|
|
test('screenshot without path', () => {
|
|
expect(describeToolCall('Bash', { command: '$B screenshot' })).toBe('Saving screenshot');
|
|
});
|
|
|
|
test('responsive → multi-size screenshots', () => {
|
|
expect(describeToolCall('Bash', { command: '$B responsive' })).toBe('Taking screenshots at mobile, tablet, and desktop sizes');
|
|
});
|
|
|
|
// Non-browse tools
|
|
test('Read tool → reading file', () => {
|
|
expect(describeToolCall('Read', { file_path: '/Users/foo/project/src/app.ts' })).toBe('Reading ~/project/src/app.ts');
|
|
});
|
|
|
|
test('Grep tool → searching for pattern', () => {
|
|
expect(describeToolCall('Grep', { pattern: 'handleClick' })).toBe('Searching for "handleClick"');
|
|
});
|
|
|
|
test('Glob tool → finding files', () => {
|
|
expect(describeToolCall('Glob', { pattern: '**/*.tsx' })).toBe('Finding files matching **/*.tsx');
|
|
});
|
|
|
|
test('Edit tool → editing file', () => {
|
|
expect(describeToolCall('Edit', { file_path: '/Users/foo/src/main.ts' })).toBe('Editing ~/src/main.ts');
|
|
});
|
|
|
|
// Edge cases
|
|
test('null input → empty string', () => {
|
|
expect(describeToolCall('Bash', null)).toBe('');
|
|
});
|
|
|
|
test('unknown browse command → generic description', () => {
|
|
expect(describeToolCall('Bash', { command: '$B newtab https://foo.com' })).toContain('newtab');
|
|
});
|
|
|
|
test('non-browse bash → shortened command', () => {
|
|
expect(describeToolCall('Bash', { command: 'echo hello' })).toBe('echo hello');
|
|
});
|
|
|
|
test('full browse binary path recognized', () => {
|
|
const result = describeToolCall('Bash', { command: '/Users/garrytan/.claude/skills/gstack/browse/dist/browse goto https://example.com' });
|
|
expect(result).toBe('Opening https://example.com');
|
|
});
|
|
|
|
test('tab command → switching tab', () => {
|
|
expect(describeToolCall('Bash', { command: '$B tab 2' })).toContain('tab');
|
|
});
|
|
});
|
|
|
|
// ─── Per-tab agent concurrency (source code validation) ──────────
|
|
|
|
describe('per-tab agent concurrency', () => {
|
|
const serverSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'server.ts'), 'utf-8');
|
|
const agentSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'sidebar-agent.ts'), 'utf-8');
|
|
|
|
test('server has per-tab agent state map', () => {
|
|
expect(serverSrc).toContain('tabAgents');
|
|
expect(serverSrc).toContain('TabAgentState');
|
|
expect(serverSrc).toContain('getTabAgent');
|
|
});
|
|
|
|
test('server returns per-tab agent status in /sidebar-chat', () => {
|
|
expect(serverSrc).toContain('getTabAgentStatus');
|
|
expect(serverSrc).toContain('tabAgentStatus');
|
|
});
|
|
|
|
test('spawnClaude accepts forTabId parameter', () => {
|
|
const spawnFn = serverSrc.slice(
|
|
serverSrc.indexOf('function spawnClaude('),
|
|
serverSrc.indexOf('\nfunction ', serverSrc.indexOf('function spawnClaude(') + 1),
|
|
);
|
|
expect(spawnFn).toContain('forTabId');
|
|
expect(spawnFn).toContain('tabState.status');
|
|
});
|
|
|
|
test('sidebar-command endpoint uses per-tab agent state', () => {
|
|
expect(serverSrc).toContain('msgTabId');
|
|
expect(serverSrc).toContain('tabState.status');
|
|
expect(serverSrc).toContain('tabState.queue');
|
|
});
|
|
|
|
test('agent event handler resets per-tab state', () => {
|
|
expect(serverSrc).toContain('eventTabId');
|
|
expect(serverSrc).toContain('tabState.status = \'idle\'');
|
|
});
|
|
|
|
test('agent event handler processes per-tab queue', () => {
|
|
// After agent_done, should process next message from THIS tab's queue
|
|
expect(serverSrc).toContain('tabState.queue.length > 0');
|
|
expect(serverSrc).toContain('tabState.queue.shift');
|
|
});
|
|
|
|
test('sidebar-agent uses per-tab processing set', () => {
|
|
expect(agentSrc).toContain('processingTabs');
|
|
expect(agentSrc).not.toContain('isProcessing');
|
|
});
|
|
|
|
test('sidebar-agent sends tabId with all events', () => {
|
|
// sendEvent should accept tabId parameter
|
|
expect(agentSrc).toContain('async function sendEvent(event: Record<string, any>, tabId?: number)');
|
|
// askClaude should extract tabId from queue entry
|
|
expect(agentSrc).toContain('const { prompt, args, stateFile, cwd, tabId }');
|
|
});
|
|
|
|
test('sidebar-agent allows concurrent agents across tabs', () => {
|
|
// poll() should not block globally — it should check per-tab
|
|
expect(agentSrc).toContain('processingTabs.has(tid)');
|
|
// askClaude should be fire-and-forget (no await blocking the loop)
|
|
expect(agentSrc).toContain('askClaude(entry).catch');
|
|
});
|
|
|
|
test('queue entries include tabId', () => {
|
|
const spawnFn = serverSrc.slice(
|
|
serverSrc.indexOf('function spawnClaude('),
|
|
serverSrc.indexOf('\nfunction ', serverSrc.indexOf('function spawnClaude(') + 1),
|
|
);
|
|
expect(spawnFn).toContain('tabId: agentTabId');
|
|
});
|
|
|
|
test('health check monitors all per-tab agents', () => {
|
|
expect(serverSrc).toContain('for (const [tid, state] of tabAgents)');
|
|
});
|
|
});
|
|
|
|
describe('BROWSE_TAB tab pinning (cross-tab isolation)', () => {
|
|
const serverSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'server.ts'), 'utf-8');
|
|
const agentSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'sidebar-agent.ts'), 'utf-8');
|
|
const cliSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'cli.ts'), 'utf-8');
|
|
|
|
test('sidebar-agent passes BROWSE_TAB env var to claude process', () => {
|
|
// The env block should include BROWSE_TAB set to the tab ID
|
|
expect(agentSrc).toContain('BROWSE_TAB');
|
|
expect(agentSrc).toContain('String(tid)');
|
|
});
|
|
|
|
test('CLI reads BROWSE_TAB and sends tabId in command body', () => {
|
|
// BROWSE_TAB env var is still honored (sidebar-agent path). After the
|
|
// make-pdf refactor, the CLI layer now also accepts --tab-id <N>, with
|
|
// the CLI flag taking precedence over the env var. Both resolve to the
|
|
// same `tabId` body field.
|
|
expect(cliSrc).toContain('process.env.BROWSE_TAB');
|
|
expect(cliSrc).toContain('parseInt(envTab, 10)');
|
|
});
|
|
|
|
test('handleCommandInternal accepts tabId from request body', () => {
|
|
const handleFn = serverSrc.slice(
|
|
serverSrc.indexOf('async function handleCommandInternal('),
|
|
serverSrc.indexOf('\n/** HTTP wrapper', serverSrc.indexOf('async function handleCommandInternal(') + 1) > 0
|
|
? serverSrc.indexOf('\n/** HTTP wrapper', serverSrc.indexOf('async function handleCommandInternal(') + 1)
|
|
: serverSrc.indexOf('\nasync function ', serverSrc.indexOf('async function handleCommandInternal(') + 200),
|
|
);
|
|
// Should destructure tabId from body
|
|
expect(handleFn).toContain('tabId');
|
|
// Should save and restore the active tab
|
|
expect(handleFn).toContain('savedTabId');
|
|
expect(handleFn).toContain('switchTab(tabId');
|
|
});
|
|
|
|
test('handleCommandInternal restores active tab after command (success path)', () => {
|
|
// On success, should restore savedTabId without stealing focus
|
|
const handleFn = serverSrc.slice(
|
|
serverSrc.indexOf('async function handleCommandInternal('),
|
|
serverSrc.length,
|
|
);
|
|
// Count restore calls — should appear in both success and error paths
|
|
const restoreCount = (handleFn.match(/switchTab\(savedTabId/g) || []).length;
|
|
expect(restoreCount).toBeGreaterThanOrEqual(2); // success + error paths
|
|
});
|
|
|
|
test('handleCommandInternal restores active tab on error path', () => {
|
|
// The catch block should also restore
|
|
const catchBlock = serverSrc.slice(
|
|
serverSrc.indexOf('} catch (err: any) {', serverSrc.indexOf('async function handleCommandInternal(')),
|
|
);
|
|
expect(catchBlock).toContain('switchTab(savedTabId');
|
|
});
|
|
|
|
test('tab pinning only activates when tabId is provided', () => {
|
|
const handleFn = serverSrc.slice(
|
|
serverSrc.indexOf('async function handleCommandInternal('),
|
|
serverSrc.indexOf('try {', serverSrc.indexOf('async function handleCommandInternal(') + 1),
|
|
);
|
|
// Should check tabId is not undefined/null before switching
|
|
expect(handleFn).toContain('tabId !== undefined');
|
|
expect(handleFn).toContain('tabId !== null');
|
|
});
|
|
|
|
test('CLI only sends tabId when it is a valid number', () => {
|
|
// Body should conditionally include tabId. Historically that was keyed off
|
|
// the BROWSE_TAB env var. After the make-pdf refactor, the CLI also honors
|
|
// a --tab-id <N> flag on the CLI itself, so the check is "tabId defined
|
|
// AND not NaN" rather than literally inspecting the env var.
|
|
expect(cliSrc).toContain('tabId !== undefined && !isNaN(tabId)');
|
|
});
|
|
});
|