From 27b28b048d7a7c88571c29fa14aaf518d663c734 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 11 Mar 2026 14:23:16 -0700 Subject: [PATCH] test: 40 integration tests with fixture server Tests all commands against local Bun test server serving HTML fixtures. Covers navigation, content extraction, JS/CSS inspection, form interaction, SPA rendering, console/network buffers, tabs, screenshots, responsive, diff, chain, and storage. 40 pass, 0 fail, 2s runtime. Co-Authored-By: Claude Opus 4.6 --- test/commands.test.ts | 409 ++++++++++++++++++++++++++++++++++ test/fixtures/basic.html | 33 +++ test/fixtures/forms.html | 55 +++++ test/fixtures/responsive.html | 49 ++++ test/fixtures/spa.html | 24 ++ test/test-server.ts | 47 ++++ 6 files changed, 617 insertions(+) create mode 100644 test/commands.test.ts create mode 100644 test/fixtures/basic.html create mode 100644 test/fixtures/forms.html create mode 100644 test/fixtures/responsive.html create mode 100644 test/fixtures/spa.html create mode 100644 test/test-server.ts diff --git a/test/commands.test.ts b/test/commands.test.ts new file mode 100644 index 00000000..15612003 --- /dev/null +++ b/test/commands.test.ts @@ -0,0 +1,409 @@ +/** + * Integration tests for all browse commands + * + * Tests run against a local test server serving fixture HTML files. + * A real browse server is started and commands are sent via the CLI HTTP interface. + */ + +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { startTestServer } from './test-server'; +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 { consoleBuffer, networkBuffer } from '../src/buffers'; +import * as fs from 'fs'; + +let testServer: ReturnType; +let bm: BrowserManager; +let baseUrl: string; + +beforeAll(async () => { + testServer = startTestServer(0); + baseUrl = testServer.url; + + bm = new BrowserManager(); + await bm.launch(); +}); + +afterAll(() => { + // Force kill browser instead of graceful close (avoids hang) + try { testServer.server.stop(); } catch {} + // bm.close() can hang — just let process exit handle it + setTimeout(() => process.exit(0), 500); +}); + +// ─── Navigation ───────────────────────────────────────────────── + +describe('Navigation', () => { + test('goto navigates to URL', async () => { + const result = await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + expect(result).toContain('Navigated to'); + expect(result).toContain('200'); + }); + + test('url returns current URL', async () => { + const result = await handleMetaCommand('url', [], bm, async () => {}); + expect(result).toContain('/basic.html'); + }); + + test('back goes back', async () => { + await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm); + const result = await handleWriteCommand('back', [], bm); + expect(result).toContain('Back'); + }); + + test('forward goes forward', async () => { + const result = await handleWriteCommand('forward', [], bm); + expect(result).toContain('Forward'); + }); + + test('reload reloads page', async () => { + const result = await handleWriteCommand('reload', [], bm); + expect(result).toContain('Reloaded'); + }); +}); + +// ─── Content Extraction ───────────────────────────────────────── + +describe('Content extraction', () => { + beforeAll(async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + }); + + test('text returns cleaned page text', async () => { + const result = await handleReadCommand('text', [], bm); + expect(result).toContain('Hello World'); + expect(result).toContain('Item one'); + expect(result).not.toContain('

'); + }); + + test('html returns full page HTML', async () => { + const result = await handleReadCommand('html', [], bm); + expect(result).toContain(''); + expect(result).toContain('

Hello World

'); + }); + + test('html with selector returns element innerHTML', async () => { + const result = await handleReadCommand('html', ['#content'], bm); + expect(result).toContain('Some body text here.'); + expect(result).toContain('
  • Item one
  • '); + }); + + test('links returns all links', async () => { + const result = await handleReadCommand('links', [], bm); + expect(result).toContain('Page 1'); + expect(result).toContain('Page 2'); + expect(result).toContain('External'); + expect(result).toContain('→'); + }); + + test('forms discovers form fields', async () => { + await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm); + const result = await handleReadCommand('forms', [], bm); + const forms = JSON.parse(result); + expect(forms.length).toBe(2); + expect(forms[0].id).toBe('login-form'); + expect(forms[0].method).toBe('post'); + expect(forms[0].fields.length).toBeGreaterThanOrEqual(2); + expect(forms[1].id).toBe('profile-form'); + + // Check field discovery + const emailField = forms[0].fields.find((f: any) => f.name === 'email'); + expect(emailField).toBeDefined(); + expect(emailField.type).toBe('email'); + expect(emailField.required).toBe(true); + }); + + test('accessibility returns ARIA tree', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const result = await handleReadCommand('accessibility', [], bm); + expect(result).toContain('Hello World'); + }); +}); + +// ─── JavaScript / CSS / Attrs ─────────────────────────────────── + +describe('Inspection', () => { + beforeAll(async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + }); + + test('js evaluates expression', async () => { + const result = await handleReadCommand('js', ['document.title'], bm); + expect(result).toBe('Test Page - Basic'); + }); + + test('js returns objects as JSON', async () => { + const result = await handleReadCommand('js', ['({a: 1, b: 2})'], bm); + const obj = JSON.parse(result); + expect(obj.a).toBe(1); + expect(obj.b).toBe(2); + }); + + test('css returns computed property', async () => { + const result = await handleReadCommand('css', ['h1', 'color'], bm); + // Navy color + expect(result).toContain('0, 0, 128'); + }); + + test('css returns font-family', async () => { + const result = await handleReadCommand('css', ['body', 'font-family'], bm); + expect(result).toContain('Helvetica'); + }); + + test('attrs returns element attributes', async () => { + const result = await handleReadCommand('attrs', ['#content'], bm); + const attrs = JSON.parse(result); + expect(attrs.id).toBe('content'); + expect(attrs['data-testid']).toBe('main-content'); + expect(attrs['data-version']).toBe('1.0'); + }); +}); + +// ─── Interaction ──────────────────────────────────────────────── + +describe('Interaction', () => { + test('fill + click works on form', async () => { + await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm); + + let result = await handleWriteCommand('fill', ['#email', 'test@example.com'], bm); + expect(result).toContain('Filled'); + + result = await handleWriteCommand('fill', ['#password', 'secret123'], bm); + expect(result).toContain('Filled'); + + // Verify values were set + const emailVal = await handleReadCommand('js', ['document.querySelector("#email").value'], bm); + expect(emailVal).toBe('test@example.com'); + + result = await handleWriteCommand('click', ['#login-btn'], bm); + expect(result).toContain('Clicked'); + }); + + test('select works on dropdown', async () => { + await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm); + const result = await handleWriteCommand('select', ['#role', 'admin'], bm); + expect(result).toContain('Selected'); + + const val = await handleReadCommand('js', ['document.querySelector("#role").value'], bm); + expect(val).toBe('admin'); + }); + + test('hover works', async () => { + const result = await handleWriteCommand('hover', ['h1'], bm); + expect(result).toContain('Hovered'); + }); + + test('wait finds existing element', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const result = await handleWriteCommand('wait', ['#title'], bm); + expect(result).toContain('appeared'); + }); + + test('scroll works', async () => { + const result = await handleWriteCommand('scroll', ['footer'], bm); + expect(result).toContain('Scrolled'); + }); + + test('viewport changes size', async () => { + const result = await handleWriteCommand('viewport', ['375x812'], bm); + expect(result).toContain('Viewport set'); + + const size = await handleReadCommand('js', ['`${window.innerWidth}x${window.innerHeight}`'], bm); + expect(size).toBe('375x812'); + + // Reset + await handleWriteCommand('viewport', ['1280x720'], bm); + }); + + test('type and press work', async () => { + await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm); + await handleWriteCommand('click', ['#name'], bm); + + const result = await handleWriteCommand('type', ['John Doe'], bm); + expect(result).toContain('Typed'); + + const val = await handleReadCommand('js', ['document.querySelector("#name").value'], bm); + expect(val).toBe('John Doe'); + }); +}); + +// ─── SPA / Console / Network ─────────────────────────────────── + +describe('SPA and buffers', () => { + test('wait handles delayed rendering', async () => { + await handleWriteCommand('goto', [baseUrl + '/spa.html'], bm); + const result = await handleWriteCommand('wait', ['.loaded'], bm); + expect(result).toContain('appeared'); + + const text = await handleReadCommand('text', [], bm); + expect(text).toContain('SPA Content Loaded'); + }); + + test('console captures messages', async () => { + const result = await handleReadCommand('console', [], bm); + expect(result).toContain('[SPA] Starting render'); + expect(result).toContain('[SPA] Render complete'); + }); + + test('console --clear clears buffer', async () => { + const result = await handleReadCommand('console', ['--clear'], bm); + expect(result).toContain('cleared'); + + const after = await handleReadCommand('console', [], bm); + expect(after).toContain('no console messages'); + }); + + test('network captures requests', async () => { + const result = await handleReadCommand('network', [], bm); + expect(result).toContain('GET'); + expect(result).toContain('/spa.html'); + }); + + test('network --clear clears buffer', async () => { + const result = await handleReadCommand('network', ['--clear'], bm); + expect(result).toContain('cleared'); + }); +}); + +// ─── Cookies / Storage ────────────────────────────────────────── + +describe('Cookies and storage', () => { + test('cookies returns array', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const result = await handleReadCommand('cookies', [], bm); + // Test server doesn't set cookies, so empty array + expect(result).toBe('[]'); + }); + + test('storage set and get works', async () => { + await handleReadCommand('storage', ['set', 'testKey', 'testValue'], bm); + const result = await handleReadCommand('storage', [], bm); + const storage = JSON.parse(result); + expect(storage.localStorage.testKey).toBe('testValue'); + }); +}); + +// ─── Performance ──────────────────────────────────────────────── + +describe('Performance', () => { + test('perf returns timing data', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const result = await handleReadCommand('perf', [], bm); + expect(result).toContain('dns'); + expect(result).toContain('ttfb'); + expect(result).toContain('load'); + expect(result).toContain('ms'); + }); +}); + +// ─── Visual ───────────────────────────────────────────────────── + +describe('Visual', () => { + test('screenshot saves file', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const screenshotPath = '/tmp/browse-test-screenshot.png'; + const result = await handleMetaCommand('screenshot', [screenshotPath], bm, async () => {}); + expect(result).toContain('Screenshot saved'); + expect(fs.existsSync(screenshotPath)).toBe(true); + const stat = fs.statSync(screenshotPath); + expect(stat.size).toBeGreaterThan(1000); + fs.unlinkSync(screenshotPath); + }); + + test('responsive saves 3 screenshots', async () => { + await handleWriteCommand('goto', [baseUrl + '/responsive.html'], bm); + const prefix = '/tmp/browse-test-resp'; + const result = await handleMetaCommand('responsive', [prefix], bm, async () => {}); + expect(result).toContain('mobile'); + expect(result).toContain('tablet'); + expect(result).toContain('desktop'); + + expect(fs.existsSync(`${prefix}-mobile.png`)).toBe(true); + expect(fs.existsSync(`${prefix}-tablet.png`)).toBe(true); + expect(fs.existsSync(`${prefix}-desktop.png`)).toBe(true); + + // Cleanup + fs.unlinkSync(`${prefix}-mobile.png`); + fs.unlinkSync(`${prefix}-tablet.png`); + fs.unlinkSync(`${prefix}-desktop.png`); + }); +}); + +// ─── Tabs ─────────────────────────────────────────────────────── + +describe('Tabs', () => { + test('tabs lists all tabs', async () => { + const result = await handleMetaCommand('tabs', [], bm, async () => {}); + expect(result).toContain('['); + expect(result).toContain(']'); + }); + + test('newtab opens new tab', async () => { + const result = await handleMetaCommand('newtab', [baseUrl + '/forms.html'], bm, async () => {}); + expect(result).toContain('Opened tab'); + + const tabCount = bm.getTabCount(); + expect(tabCount).toBeGreaterThanOrEqual(2); + }); + + test('tab switches to specific tab', async () => { + const result = await handleMetaCommand('tab', ['1'], bm, async () => {}); + expect(result).toContain('Switched to tab 1'); + }); + + test('closetab closes a tab', async () => { + const before = bm.getTabCount(); + // Close the last opened tab + const tabs = await bm.getTabListWithTitles(); + const lastTab = tabs[tabs.length - 1]; + const result = await handleMetaCommand('closetab', [String(lastTab.id)], bm, async () => {}); + expect(result).toContain('Closed tab'); + expect(bm.getTabCount()).toBe(before - 1); + }); +}); + +// ─── Diff ─────────────────────────────────────────────────────── + +describe('Diff', () => { + test('diff shows differences between pages', async () => { + const result = await handleMetaCommand( + 'diff', + [baseUrl + '/basic.html', baseUrl + '/forms.html'], + bm, + async () => {} + ); + expect(result).toContain('---'); + expect(result).toContain('+++'); + // basic.html has "Hello World", forms.html has "Form Test Page" + expect(result).toContain('Hello World'); + expect(result).toContain('Form Test Page'); + }); +}); + +// ─── Chain ────────────────────────────────────────────────────── + +describe('Chain', () => { + test('chain executes sequence of commands', async () => { + const commands = JSON.stringify([ + ['goto', baseUrl + '/basic.html'], + ['js', 'document.title'], + ['css', 'h1', 'color'], + ]); + const result = await handleMetaCommand('chain', [commands], bm, async () => {}); + expect(result).toContain('[goto]'); + expect(result).toContain('Test Page - Basic'); + expect(result).toContain('[css]'); + }); +}); + +// ─── Status ───────────────────────────────────────────────────── + +describe('Status', () => { + test('status reports health', async () => { + const result = await handleMetaCommand('status', [], bm, async () => {}); + expect(result).toContain('Status: healthy'); + expect(result).toContain('Tabs:'); + }); +}); diff --git a/test/fixtures/basic.html b/test/fixtures/basic.html new file mode 100644 index 00000000..21904c8b --- /dev/null +++ b/test/fixtures/basic.html @@ -0,0 +1,33 @@ + + + + + Test Page - Basic + + + + +

    Hello World

    +

    This is a highlighted paragraph.

    + +
    +

    Some body text here.

    +
      +
    • Item one
    • +
    • Item two
    • +
    • Item three
    • +
    +
    +
    Footer text
    + + diff --git a/test/fixtures/forms.html b/test/fixtures/forms.html new file mode 100644 index 00000000..8a6b730d --- /dev/null +++ b/test/fixtures/forms.html @@ -0,0 +1,55 @@ + + + + + Test Page - Forms + + + +

    Form Test Page

    + +
    + + + + + +
    + +
    + + + + + + + + +
    + +
    Form submitted!
    + + + + diff --git a/test/fixtures/responsive.html b/test/fixtures/responsive.html new file mode 100644 index 00000000..3c7c89d0 --- /dev/null +++ b/test/fixtures/responsive.html @@ -0,0 +1,49 @@ + + + + + + Test Page - Responsive + + + +
    +

    Responsive Layout Test

    +

    You are on mobile

    +

    You are on desktop

    +
    +
    Card 1
    +
    Card 2
    +
    Card 3
    +
    Card 4
    +
    Card 5
    +
    Card 6
    +
    +
    + + diff --git a/test/fixtures/spa.html b/test/fixtures/spa.html new file mode 100644 index 00000000..2ea176d6 --- /dev/null +++ b/test/fixtures/spa.html @@ -0,0 +1,24 @@ + + + + + Test Page - SPA + + + +
    Loading...
    + + + diff --git a/test/test-server.ts b/test/test-server.ts new file mode 100644 index 00000000..aeb0a5b5 --- /dev/null +++ b/test/test-server.ts @@ -0,0 +1,47 @@ +/** + * Tiny Bun.serve for test fixtures + * Serves HTML files from test/fixtures/ on a random available port + */ + +import * as path from 'path'; +import * as fs from 'fs'; + +const FIXTURES_DIR = path.resolve(import.meta.dir, 'fixtures'); + +export function startTestServer(port: number = 0): { server: ReturnType; url: string } { + const server = Bun.serve({ + port, + hostname: '127.0.0.1', + fetch(req) { + const url = new URL(req.url); + let filePath = url.pathname === '/' ? '/basic.html' : url.pathname; + + // Remove leading slash + filePath = filePath.replace(/^\//, ''); + const fullPath = path.join(FIXTURES_DIR, filePath); + + if (!fs.existsSync(fullPath)) { + return new Response('Not Found', { status: 404 }); + } + + const content = fs.readFileSync(fullPath, 'utf-8'); + const ext = path.extname(fullPath); + const contentType = ext === '.html' ? 'text/html' : 'text/plain'; + + return new Response(content, { + headers: { 'Content-Type': contentType }, + }); + }, + }); + + const url = `http://127.0.0.1:${server.port}`; + return { server, url }; +} + +// If run directly, start and print URL +if (import.meta.main) { + const { server, url } = startTestServer(9450); + console.log(`Test server running at ${url}`); + console.log(`Fixtures: ${FIXTURES_DIR}`); + console.log('Press Ctrl+C to stop'); +}