mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-10 16:20:35 +02:00
Phase 2: Enhanced browser — dialog handling, upload, state checks, snapshots
- CircularBuffer O(1) ring buffer for console/network/dialog (was O(n) array+shift) - Async buffer flush with Bun.write() (was appendFileSync) - Dialog auto-accept/dismiss with buffer + prompt text support - File upload command (upload <sel> <file...>) - Element state checks (is visible/hidden/enabled/disabled/checked/editable/focused) - Annotated screenshots with ref labels overlaid (-a flag) - Snapshot diffing against previous snapshot (-D flag) - Cursor-interactive element scan for non-ARIA clickables (-C flag) - Snapshot scoping depth limit (-d N flag) - Health check with page.evaluate + 2s timeout - Playwright error wrapping — actionable messages for AI agents - Fix useragent — context recreation preserves cookies/storage/URLs - wait --networkidle / --load / --domcontentloaded flags - console --errors filter (error + warning only) - cookie-import <json-file> with auto-fill domain from page URL - 166 integration tests (was ~63) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
3d901066cd
commit
f3ebd0adbf
@@ -11,6 +11,7 @@ import { BrowserManager } from '../src/browser-manager';
|
||||
import { handleReadCommand } from '../src/read-commands';
|
||||
import { handleWriteCommand } from '../src/write-commands';
|
||||
import { handleMetaCommand } from '../src/meta-commands';
|
||||
import * as fs from 'fs';
|
||||
|
||||
let testServer: ReturnType<typeof startTestServer>;
|
||||
let bm: BrowserManager;
|
||||
@@ -199,3 +200,219 @@ describe('Ref invalidation', () => {
|
||||
expect(bm.getRefCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Snapshot Diffing ──────────────────────────────────────────
|
||||
|
||||
describe('Snapshot diff', () => {
|
||||
test('first snapshot -D stores baseline', async () => {
|
||||
// Clear any previous snapshot
|
||||
bm.setLastSnapshot(null);
|
||||
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
|
||||
const result = await handleMetaCommand('snapshot', ['-D'], bm, shutdown);
|
||||
expect(result).toContain('no previous snapshot');
|
||||
expect(result).toContain('baseline');
|
||||
});
|
||||
|
||||
test('snapshot -D shows diff after change', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
|
||||
// Take first snapshot
|
||||
await handleMetaCommand('snapshot', [], bm, shutdown);
|
||||
// Modify DOM
|
||||
await handleReadCommand('js', ['document.querySelector("h1").textContent = "Changed Title"'], bm);
|
||||
// Take diff
|
||||
const diff = await handleMetaCommand('snapshot', ['-D'], bm, shutdown);
|
||||
expect(diff).toContain('---');
|
||||
expect(diff).toContain('+++');
|
||||
expect(diff).toContain('previous snapshot');
|
||||
expect(diff).toContain('current snapshot');
|
||||
});
|
||||
|
||||
test('snapshot -D with identical page shows no changes', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
||||
await handleMetaCommand('snapshot', [], bm, shutdown);
|
||||
const diff = await handleMetaCommand('snapshot', ['-D'], bm, shutdown);
|
||||
// All lines should be unchanged (prefixed with space)
|
||||
const lines = diff.split('\n').filter(l => l.startsWith('+') || l.startsWith('-'));
|
||||
// Header lines start with --- and +++ so filter those
|
||||
const contentChanges = lines.filter(l => !l.startsWith('---') && !l.startsWith('+++'));
|
||||
expect(contentChanges.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Annotated Screenshots ─────────────────────────────────────
|
||||
|
||||
describe('Annotated screenshots', () => {
|
||||
test('snapshot -a creates annotated screenshot', async () => {
|
||||
const screenshotPath = '/tmp/browse-test-annotated.png';
|
||||
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
|
||||
const result = await handleMetaCommand('snapshot', ['-a', '-o', screenshotPath], bm, shutdown);
|
||||
expect(result).toContain('annotated screenshot');
|
||||
expect(result).toContain(screenshotPath);
|
||||
expect(fs.existsSync(screenshotPath)).toBe(true);
|
||||
const stat = fs.statSync(screenshotPath);
|
||||
expect(stat.size).toBeGreaterThan(1000);
|
||||
fs.unlinkSync(screenshotPath);
|
||||
});
|
||||
|
||||
test('snapshot -a uses default path', async () => {
|
||||
const defaultPath = '/tmp/browse-annotated.png';
|
||||
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
|
||||
const result = await handleMetaCommand('snapshot', ['-a'], bm, shutdown);
|
||||
expect(result).toContain('annotated screenshot');
|
||||
expect(fs.existsSync(defaultPath)).toBe(true);
|
||||
fs.unlinkSync(defaultPath);
|
||||
});
|
||||
|
||||
test('snapshot -a -i only annotates interactive', async () => {
|
||||
const screenshotPath = '/tmp/browse-test-annotated-i.png';
|
||||
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
|
||||
const result = await handleMetaCommand('snapshot', ['-i', '-a', '-o', screenshotPath], bm, shutdown);
|
||||
expect(result).toContain('[button]');
|
||||
expect(result).toContain('[link]');
|
||||
expect(result).toContain('annotated screenshot');
|
||||
if (fs.existsSync(screenshotPath)) fs.unlinkSync(screenshotPath);
|
||||
});
|
||||
|
||||
test('annotation overlays are cleaned up', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
|
||||
await handleMetaCommand('snapshot', ['-a'], bm, shutdown);
|
||||
// Check that overlays are removed
|
||||
const overlays = await handleReadCommand('js', ['document.querySelectorAll(".__browse_annotation__").length'], bm);
|
||||
expect(overlays).toBe('0');
|
||||
// Clean up default file
|
||||
try { fs.unlinkSync('/tmp/browse-annotated.png'); } catch {}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cursor-Interactive ────────────────────────────────────────
|
||||
|
||||
describe('Cursor-interactive', () => {
|
||||
test('snapshot -C finds cursor:pointer elements', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/cursor-interactive.html'], bm);
|
||||
const result = await handleMetaCommand('snapshot', ['-C'], bm, shutdown);
|
||||
expect(result).toContain('cursor-interactive');
|
||||
expect(result).toContain('@c');
|
||||
expect(result).toContain('cursor:pointer');
|
||||
});
|
||||
|
||||
test('snapshot -C includes onclick elements', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/cursor-interactive.html'], bm);
|
||||
const result = await handleMetaCommand('snapshot', ['-C'], bm, shutdown);
|
||||
expect(result).toContain('onclick');
|
||||
});
|
||||
|
||||
test('snapshot -C includes tabindex elements', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/cursor-interactive.html'], bm);
|
||||
const result = await handleMetaCommand('snapshot', ['-C'], bm, shutdown);
|
||||
expect(result).toContain('tabindex');
|
||||
});
|
||||
|
||||
test('@c ref is clickable', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/cursor-interactive.html'], bm);
|
||||
const snap = await handleMetaCommand('snapshot', ['-C'], bm, shutdown);
|
||||
// Find a @c ref
|
||||
const cLine = snap.split('\n').find(l => l.includes('@c'));
|
||||
if (cLine) {
|
||||
const refMatch = cLine.match(/@(c\d+)/);
|
||||
if (refMatch) {
|
||||
const result = await handleWriteCommand('click', [`@${refMatch[1]}`], bm);
|
||||
expect(result).toContain('Clicked');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('snapshot -C on page with no cursor elements', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/empty.html'], bm);
|
||||
const result = await handleMetaCommand('snapshot', ['-C'], bm, shutdown);
|
||||
// Should not contain cursor-interactive section
|
||||
expect(result).not.toContain('cursor-interactive');
|
||||
});
|
||||
|
||||
test('snapshot -i -C combines both modes', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/cursor-interactive.html'], bm);
|
||||
const result = await handleMetaCommand('snapshot', ['-i', '-C'], bm, shutdown);
|
||||
// Should have interactive elements (button, link)
|
||||
expect(result).toContain('[button]');
|
||||
expect(result).toContain('[link]');
|
||||
// And cursor-interactive section
|
||||
expect(result).toContain('cursor-interactive');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Snapshot Error Paths ───────────────────────────────────────
|
||||
|
||||
describe('Snapshot errors', () => {
|
||||
test('unknown flag throws', async () => {
|
||||
try {
|
||||
await handleMetaCommand('snapshot', ['--bogus'], bm, shutdown);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Unknown snapshot flag');
|
||||
}
|
||||
});
|
||||
|
||||
test('-d without number throws', async () => {
|
||||
try {
|
||||
await handleMetaCommand('snapshot', ['-d'], bm, shutdown);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Usage');
|
||||
}
|
||||
});
|
||||
|
||||
test('-s without selector throws', async () => {
|
||||
try {
|
||||
await handleMetaCommand('snapshot', ['-s'], bm, shutdown);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Usage');
|
||||
}
|
||||
});
|
||||
|
||||
test('-s with nonexistent selector throws', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
||||
try {
|
||||
await handleMetaCommand('snapshot', ['-s', '#nonexistent-element-12345'], bm, shutdown);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Selector not found');
|
||||
}
|
||||
});
|
||||
|
||||
test('-o without path throws', async () => {
|
||||
try {
|
||||
await handleMetaCommand('snapshot', ['-o'], bm, shutdown);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Usage');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Combined Flags ─────────────────────────────────────────────
|
||||
|
||||
describe('Snapshot combined flags', () => {
|
||||
test('-i -c -d 2 combines all filters', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
|
||||
const result = await handleMetaCommand('snapshot', ['-i', '-c', '-d', '2'], bm, shutdown);
|
||||
// Should be filtered to interactive, compact, shallow
|
||||
expect(result).toContain('[button]');
|
||||
expect(result).toContain('[link]');
|
||||
// Should NOT contain deep nested non-interactive elements
|
||||
expect(result).not.toContain('[heading]');
|
||||
});
|
||||
|
||||
test('closetab last tab auto-creates new', async () => {
|
||||
// Get down to 1 tab
|
||||
const tabs = await bm.getTabListWithTitles();
|
||||
for (let i = 1; i < tabs.length; i++) {
|
||||
await bm.closeTab(tabs[i].id);
|
||||
}
|
||||
expect(bm.getTabCount()).toBe(1);
|
||||
// Close the last tab
|
||||
const lastTab = (await bm.getTabListWithTitles())[0];
|
||||
await bm.closeTab(lastTab.id);
|
||||
// Should have auto-created a new tab
|
||||
expect(bm.getTabCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user