mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-15 20:17:24 +02:00
feat: Phase 3.5 — cookie import, QA testing, team retro (v0.3.1) (#29)
* 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> * Phase 2: Rewrite SKILL.md as QA playbook + command reference Reorient SKILL.md files from raw command reference to QA-first playbook with 10 workflow patterns (test user flows, verify deployments, dogfood features, responsive layouts, file upload, forms, dialogs, compare pages). Compact command reference tables at the bottom. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Phase 3: /qa skill — systematic QA testing with health scores New /qa skill for systematic web app QA testing. Three modes: - full: 5-10 documented issues with screenshots and repro steps - quick: 30-second smoke test with health score - regression: compare against saved baseline Includes issue taxonomy (7 categories, 4 severity levels), structured report template, health score rubric (weighted across 7 categories), framework detection guidance (Next.js, Rails, WordPress, SPA). Also adds browse/bin/find-browse (DRY binary discovery using git rev-parse), .gstack/ to .gitignore, and updated TODO roadmap. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Bump to v0.3.0 — Phase 2 + Phase 3 changelog Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: cookie-import-browser — Chromium cookie decryption module + tests Pure logic module for reading and decrypting cookies from macOS Chromium browsers (Comet, Chrome, Arc, Brave, Edge). Supports v10 AES-128-CBC encryption with macOS Keychain access, PBKDF2 key derivation, and per-browser key caching. 18 unit tests with encrypted cookie fixtures. * feat: cookie picker web UI + route handler Two-panel dark-theme picker served from the browse server. Left panel shows source browser domains with search and import buttons. Right panel shows imported domains with trash buttons. No cookie values exposed. 6 API endpoints, importedDomains Set tracking, inline clearCookies. * feat: wire cookie-import-browser into browse server Add cookie-picker route dispatch (no auth, localhost-only), add cookie-import-browser to WRITE_COMMANDS and CHAIN_WRITE, add serverPort property to BrowserManager, add write command with two modes (picker UI vs --domain direct import), update CLI help text. * chore: /setup-browser-cookies skill + docs (Phase 3.5) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: bump version and changelog (v0.3.1) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * security: redact sensitive values from command output (PR #21) type no longer echoes text (reports character count), cookie redacts value with ****, header redacts Authorization/Cookie/X-API-Key/X-Auth-Token, storage set drops value, forms redacts password fields. Prevents secrets from persisting in LLM transcripts. 7 new tests. Credit: fredluz (PR #21) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * security: path traversal prevention for screenshot/pdf/eval (PR #26) Add validateOutputPath() for screenshot/pdf/responsive (restricts to /tmp and cwd) and validateReadPath() for eval (blocks .. sequences and absolute paths outside safe dirs). 7 new tests. Credit: Jah-yee (PR #26) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: auto-install Playwright Chromium in setup (PR #22) Setup now verifies Playwright can launch Chromium, and auto-installs it via `bunx playwright install chromium` if missing. Exits non-zero if build or Chromium launch fails. Credit: AkbarDevop (PR #22) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * security: fix path validation bypass, CORS restriction, cookie-import path check - startsWith('/tmp') matched '/tmpevil' — now requires trailing slash - CORS Access-Control-Allow-Origin changed from * to http://127.0.0.1:<port> - cookie-import now validates file paths (was missing validateReadPath) - 3 new tests for prefix collision and cookie-import path traversal Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address review informational issues + add regression tests - Add cookie-import to CHAIN_WRITE set for chain command routing - Add path validation to snapshot -a -o output path - Fix package.json version to match 0.3.1 - Use crypto.randomUUID() for temp DB paths (unpredictable filenames) - Add regression tests for chain cookie-import and snapshot path validation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add /qa, /setup-browser-cookies to README + update BROWSER.md - Add /qa and /setup-browser-cookies to skills table, install/update/uninstall blurbs - Add dedicated README sections for both new skills with usage examples - Update demo workflow to show cookie import → QA → browse flow - Update BROWSER.md: cookie import commands, new source files, test count (203) - Update skill count from 6 to 8 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: team-aware /retro v2.0 — per-person praise and growth opportunities - Identify current user via git config, orient narrative as "you" vs teammates - Add per-author metrics: commits, LOC, focus areas, commit type mix, sessions - New "Your Week" section with personal deep-dive for whoever runs the command - New "Team Breakdown" with per-person praise and growth opportunities - Track AI-assisted commits via Co-Authored-By trailers - Personal + team shipping streaks - Tone: praise like a 1:1, growth like investment advice, never compare negatively Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add Conductor parallel sessions section to README Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+1093
-20
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* Unit tests for cookie-import-browser.ts
|
||||
*
|
||||
* Uses a fixture SQLite database with cookies encrypted using a known test key.
|
||||
* Mocks Keychain access to return the test password.
|
||||
*
|
||||
* Test key derivation (matches real Chromium pipeline):
|
||||
* password = "test-keychain-password"
|
||||
* key = PBKDF2(password, "saltysalt", 1003, 16, sha1)
|
||||
*
|
||||
* Encryption: AES-128-CBC with IV = 16 × 0x20, prefix "v10"
|
||||
* First 32 bytes of plaintext = HMAC-SHA256 tag (random for tests)
|
||||
* Remaining bytes = actual cookie value
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll, mock } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import * as crypto from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
// ─── Test Constants ─────────────────────────────────────────────
|
||||
|
||||
const TEST_PASSWORD = 'test-keychain-password';
|
||||
const TEST_KEY = crypto.pbkdf2Sync(TEST_PASSWORD, 'saltysalt', 1003, 16, 'sha1');
|
||||
const IV = Buffer.alloc(16, 0x20);
|
||||
const CHROMIUM_EPOCH_OFFSET = 11644473600000000n;
|
||||
|
||||
// Fixture DB path
|
||||
const FIXTURE_DIR = path.join(import.meta.dir, 'fixtures');
|
||||
const FIXTURE_DB = path.join(FIXTURE_DIR, 'test-cookies.db');
|
||||
|
||||
// ─── Encryption Helper ──────────────────────────────────────────
|
||||
|
||||
function encryptCookieValue(value: string): Buffer {
|
||||
// 32-byte HMAC tag (random for test) + actual value
|
||||
const hmacTag = crypto.randomBytes(32);
|
||||
const plaintext = Buffer.concat([hmacTag, Buffer.from(value, 'utf-8')]);
|
||||
|
||||
// PKCS7 pad to AES block size (16 bytes)
|
||||
const blockSize = 16;
|
||||
const padLen = blockSize - (plaintext.length % blockSize);
|
||||
const padded = Buffer.concat([plaintext, Buffer.alloc(padLen, padLen)]);
|
||||
|
||||
const cipher = crypto.createCipheriv('aes-128-cbc', TEST_KEY, IV);
|
||||
cipher.setAutoPadding(false); // We padded manually
|
||||
const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
|
||||
|
||||
// Prefix with "v10"
|
||||
return Buffer.concat([Buffer.from('v10'), encrypted]);
|
||||
}
|
||||
|
||||
function chromiumEpoch(unixSeconds: number): bigint {
|
||||
return BigInt(unixSeconds) * 1000000n + CHROMIUM_EPOCH_OFFSET;
|
||||
}
|
||||
|
||||
// ─── Create Fixture Database ────────────────────────────────────
|
||||
|
||||
function createFixtureDb() {
|
||||
fs.mkdirSync(FIXTURE_DIR, { recursive: true });
|
||||
if (fs.existsSync(FIXTURE_DB)) fs.unlinkSync(FIXTURE_DB);
|
||||
|
||||
const db = new Database(FIXTURE_DB);
|
||||
db.run(`CREATE TABLE cookies (
|
||||
host_key TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
encrypted_value BLOB NOT NULL DEFAULT x'',
|
||||
path TEXT NOT NULL DEFAULT '/',
|
||||
expires_utc INTEGER NOT NULL DEFAULT 0,
|
||||
is_secure INTEGER NOT NULL DEFAULT 0,
|
||||
is_httponly INTEGER NOT NULL DEFAULT 0,
|
||||
has_expires INTEGER NOT NULL DEFAULT 0,
|
||||
samesite INTEGER NOT NULL DEFAULT 1
|
||||
)`);
|
||||
|
||||
const insert = db.prepare(`INSERT INTO cookies
|
||||
(host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, has_expires, samesite)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
||||
|
||||
const futureExpiry = Number(chromiumEpoch(Math.floor(Date.now() / 1000) + 86400 * 365));
|
||||
const pastExpiry = Number(chromiumEpoch(Math.floor(Date.now() / 1000) - 86400));
|
||||
|
||||
// Domain 1: .github.com — 3 encrypted cookies
|
||||
insert.run('.github.com', 'session_id', '', encryptCookieValue('abc123'), '/', futureExpiry, 1, 1, 1, 1);
|
||||
insert.run('.github.com', 'user_token', '', encryptCookieValue('token-xyz'), '/', futureExpiry, 1, 0, 1, 0);
|
||||
insert.run('.github.com', 'theme', '', encryptCookieValue('dark'), '/', futureExpiry, 0, 0, 1, 2);
|
||||
|
||||
// Domain 2: .google.com — 2 cookies
|
||||
insert.run('.google.com', 'NID', '', encryptCookieValue('google-nid-value'), '/', futureExpiry, 1, 1, 1, 0);
|
||||
insert.run('.google.com', 'SID', '', encryptCookieValue('google-sid-value'), '/', futureExpiry, 1, 1, 1, 1);
|
||||
|
||||
// Domain 3: .example.com — 1 unencrypted cookie (value field set, no encrypted_value)
|
||||
insert.run('.example.com', 'plain_cookie', 'hello-world', Buffer.alloc(0), '/', futureExpiry, 0, 0, 1, 1);
|
||||
|
||||
// Domain 4: .expired.com — 1 expired cookie (should be filtered out)
|
||||
insert.run('.expired.com', 'old', '', encryptCookieValue('expired-value'), '/', pastExpiry, 0, 0, 1, 1);
|
||||
|
||||
// Domain 5: .session.com — session cookie (has_expires=0)
|
||||
insert.run('.session.com', 'sess', '', encryptCookieValue('session-value'), '/', 0, 1, 1, 0, 1);
|
||||
|
||||
// Domain 6: .corrupt.com — cookie with garbage encrypted_value
|
||||
insert.run('.corrupt.com', 'bad', '', Buffer.from('v10' + 'not-valid-ciphertext-at-all'), '/', futureExpiry, 0, 0, 1, 1);
|
||||
|
||||
// Domain 7: .mixed.com — one good, one corrupt
|
||||
insert.run('.mixed.com', 'good', '', encryptCookieValue('mixed-good'), '/', futureExpiry, 0, 0, 1, 1);
|
||||
insert.run('.mixed.com', 'bad', '', Buffer.from('v10' + 'garbage-data-here!!!'), '/', futureExpiry, 0, 0, 1, 1);
|
||||
|
||||
db.close();
|
||||
}
|
||||
|
||||
// ─── Mock Setup ─────────────────────────────────────────────────
|
||||
// We need to mock:
|
||||
// 1. The Keychain access (getKeychainPassword) to return TEST_PASSWORD
|
||||
// 2. The cookie DB path resolution to use our fixture DB
|
||||
|
||||
// We'll import the module after setting up the mocks
|
||||
let findInstalledBrowsers: any;
|
||||
let listDomains: any;
|
||||
let importCookies: any;
|
||||
let CookieImportError: any;
|
||||
|
||||
beforeAll(async () => {
|
||||
createFixtureDb();
|
||||
|
||||
// Mock Bun.spawn to return test password for keychain access
|
||||
const origSpawn = Bun.spawn;
|
||||
// @ts-ignore - monkey-patching for test
|
||||
Bun.spawn = function(cmd: any, opts: any) {
|
||||
// Intercept security find-generic-password calls
|
||||
if (Array.isArray(cmd) && cmd[0] === 'security' && cmd[1] === 'find-generic-password') {
|
||||
const service = cmd[3]; // -s <service>
|
||||
// Return test password for any known test service
|
||||
return {
|
||||
stdout: new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(TEST_PASSWORD + '\n'));
|
||||
controller.close();
|
||||
}
|
||||
}),
|
||||
stderr: new ReadableStream({
|
||||
start(controller) { controller.close(); }
|
||||
}),
|
||||
exited: Promise.resolve(0),
|
||||
kill: () => {},
|
||||
};
|
||||
}
|
||||
// Pass through other spawn calls
|
||||
return origSpawn(cmd, opts);
|
||||
};
|
||||
|
||||
// Import the module (uses our mocked Bun.spawn)
|
||||
const mod = await import('../src/cookie-import-browser');
|
||||
findInstalledBrowsers = mod.findInstalledBrowsers;
|
||||
listDomains = mod.listDomains;
|
||||
importCookies = mod.importCookies;
|
||||
CookieImportError = mod.CookieImportError;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Clean up fixture DB
|
||||
try { fs.unlinkSync(FIXTURE_DB); } catch {}
|
||||
try { fs.rmdirSync(FIXTURE_DIR); } catch {}
|
||||
});
|
||||
|
||||
// ─── Helper: Override DB path for tests ─────────────────────────
|
||||
// The real code resolves paths via ~/Library/Application Support/<browser>/Default/Cookies
|
||||
// We need to test against our fixture DB directly. We'll test the pure decryption functions
|
||||
// by calling importCookies with a browser that points to our fixture.
|
||||
// Since the module uses a hardcoded registry, we test the decryption logic via a different approach:
|
||||
// We'll directly call the internal decryption by setting up the DB in the expected location.
|
||||
|
||||
// For the unit tests below, we test the decryption pipeline by:
|
||||
// 1. Creating encrypted cookies with known values
|
||||
// 2. Decrypting them with the module's decryption logic
|
||||
// The actual DB path resolution is tested separately.
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────
|
||||
|
||||
describe('Cookie Import Browser', () => {
|
||||
|
||||
describe('Decryption Pipeline', () => {
|
||||
test('encrypts and decrypts round-trip correctly', () => {
|
||||
// Verify our test helper produces valid ciphertext
|
||||
const encrypted = encryptCookieValue('hello-world');
|
||||
expect(encrypted.slice(0, 3).toString()).toBe('v10');
|
||||
|
||||
// Decrypt manually to verify
|
||||
const ciphertext = encrypted.slice(3);
|
||||
const decipher = crypto.createDecipheriv('aes-128-cbc', TEST_KEY, IV);
|
||||
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
// Skip 32-byte HMAC tag
|
||||
const value = plaintext.slice(32).toString('utf-8');
|
||||
expect(value).toBe('hello-world');
|
||||
});
|
||||
|
||||
test('handles empty encrypted_value', () => {
|
||||
const encrypted = encryptCookieValue('');
|
||||
const ciphertext = encrypted.slice(3);
|
||||
const decipher = crypto.createDecipheriv('aes-128-cbc', TEST_KEY, IV);
|
||||
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
// 32-byte tag + empty value → slice(32) = empty
|
||||
expect(plaintext.length).toBe(32); // just the HMAC tag, padded to block boundary? Actually 32 + 0 padded = 48
|
||||
// With PKCS7 padding: 32 bytes + 16 bytes of padding = 48 bytes padded → decrypts to 32 bytes + padding removed = 32 bytes
|
||||
});
|
||||
|
||||
test('handles special characters in cookie values', () => {
|
||||
const specialValue = 'a=b&c=d; path=/; expires=Thu, 01 Jan 2099';
|
||||
const encrypted = encryptCookieValue(specialValue);
|
||||
const ciphertext = encrypted.slice(3);
|
||||
const decipher = crypto.createDecipheriv('aes-128-cbc', TEST_KEY, IV);
|
||||
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
expect(plaintext.slice(32).toString('utf-8')).toBe(specialValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fixture DB Structure', () => {
|
||||
test('fixture DB has correct domain counts', () => {
|
||||
const db = new Database(FIXTURE_DB, { readonly: true });
|
||||
const rows = db.query(
|
||||
`SELECT host_key, COUNT(*) as count FROM cookies GROUP BY host_key ORDER BY count DESC`
|
||||
).all() as any[];
|
||||
db.close();
|
||||
|
||||
const counts = Object.fromEntries(rows.map((r: any) => [r.host_key, r.count]));
|
||||
expect(counts['.github.com']).toBe(3);
|
||||
expect(counts['.google.com']).toBe(2);
|
||||
expect(counts['.example.com']).toBe(1);
|
||||
expect(counts['.expired.com']).toBe(1);
|
||||
expect(counts['.session.com']).toBe(1);
|
||||
expect(counts['.corrupt.com']).toBe(1);
|
||||
expect(counts['.mixed.com']).toBe(2);
|
||||
});
|
||||
|
||||
test('encrypted cookies in fixture have v10 prefix', () => {
|
||||
const db = new Database(FIXTURE_DB, { readonly: true });
|
||||
const rows = db.query(
|
||||
`SELECT name, encrypted_value FROM cookies WHERE host_key = '.github.com'`
|
||||
).all() as any[];
|
||||
db.close();
|
||||
|
||||
for (const row of rows) {
|
||||
const ev = Buffer.from(row.encrypted_value);
|
||||
expect(ev.slice(0, 3).toString()).toBe('v10');
|
||||
}
|
||||
});
|
||||
|
||||
test('decrypts all github.com cookies from fixture DB', () => {
|
||||
const db = new Database(FIXTURE_DB, { readonly: true });
|
||||
const rows = db.query(
|
||||
`SELECT name, value, encrypted_value FROM cookies WHERE host_key = '.github.com'`
|
||||
).all() as any[];
|
||||
db.close();
|
||||
|
||||
const expected: Record<string, string> = {
|
||||
'session_id': 'abc123',
|
||||
'user_token': 'token-xyz',
|
||||
'theme': 'dark',
|
||||
};
|
||||
|
||||
for (const row of rows) {
|
||||
const ev = Buffer.from(row.encrypted_value);
|
||||
if (ev.length === 0) continue;
|
||||
const ciphertext = ev.slice(3);
|
||||
const decipher = crypto.createDecipheriv('aes-128-cbc', TEST_KEY, IV);
|
||||
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
const value = plaintext.slice(32).toString('utf-8');
|
||||
expect(value).toBe(expected[row.name]);
|
||||
}
|
||||
});
|
||||
|
||||
test('unencrypted cookie uses value field directly', () => {
|
||||
const db = new Database(FIXTURE_DB, { readonly: true });
|
||||
const row = db.query(
|
||||
`SELECT value, encrypted_value FROM cookies WHERE host_key = '.example.com'`
|
||||
).get() as any;
|
||||
db.close();
|
||||
|
||||
expect(row.value).toBe('hello-world');
|
||||
expect(Buffer.from(row.encrypted_value).length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sameSite Mapping', () => {
|
||||
test('maps sameSite values correctly', () => {
|
||||
// Read from fixture DB and verify mapping
|
||||
const db = new Database(FIXTURE_DB, { readonly: true });
|
||||
|
||||
// samesite=0 → None
|
||||
const none = db.query(`SELECT samesite FROM cookies WHERE name = 'user_token'`).get() as any;
|
||||
expect(none.samesite).toBe(0);
|
||||
|
||||
// samesite=1 → Lax
|
||||
const lax = db.query(`SELECT samesite FROM cookies WHERE name = 'session_id'`).get() as any;
|
||||
expect(lax.samesite).toBe(1);
|
||||
|
||||
// samesite=2 → Strict
|
||||
const strict = db.query(`SELECT samesite FROM cookies WHERE name = 'theme'`).get() as any;
|
||||
expect(strict.samesite).toBe(2);
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Chromium Epoch Conversion', () => {
|
||||
test('converts Chromium epoch to Unix timestamp correctly', () => {
|
||||
// Round-trip: pick a known Unix timestamp, convert to Chromium, convert back
|
||||
const knownUnix = 1704067200; // 2024-01-01T00:00:00Z
|
||||
const chromiumTs = BigInt(knownUnix) * 1000000n + CHROMIUM_EPOCH_OFFSET;
|
||||
const unixTs = Number((chromiumTs - CHROMIUM_EPOCH_OFFSET) / 1000000n);
|
||||
expect(unixTs).toBe(knownUnix);
|
||||
});
|
||||
|
||||
test('session cookies (has_expires=0) get expires=-1', () => {
|
||||
const db = new Database(FIXTURE_DB, { readonly: true });
|
||||
const row = db.query(
|
||||
`SELECT has_expires, expires_utc FROM cookies WHERE host_key = '.session.com'`
|
||||
).get() as any;
|
||||
db.close();
|
||||
expect(row.has_expires).toBe(0);
|
||||
// When has_expires=0, the module should return expires=-1
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
test('CookieImportError has correct properties', () => {
|
||||
const err = new CookieImportError('test message', 'test_code', 'retry');
|
||||
expect(err.message).toBe('test message');
|
||||
expect(err.code).toBe('test_code');
|
||||
expect(err.action).toBe('retry');
|
||||
expect(err.name).toBe('CookieImportError');
|
||||
expect(err instanceof Error).toBe(true);
|
||||
});
|
||||
|
||||
test('CookieImportError without action', () => {
|
||||
const err = new CookieImportError('no action', 'some_code');
|
||||
expect(err.action).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Browser Registry', () => {
|
||||
test('findInstalledBrowsers returns array', () => {
|
||||
const browsers = findInstalledBrowsers();
|
||||
expect(Array.isArray(browsers)).toBe(true);
|
||||
// Each entry should have the right shape
|
||||
for (const b of browsers) {
|
||||
expect(b).toHaveProperty('name');
|
||||
expect(b).toHaveProperty('dataDir');
|
||||
expect(b).toHaveProperty('keychainService');
|
||||
expect(b).toHaveProperty('aliases');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Corrupt Data Handling', () => {
|
||||
test('garbage ciphertext produces decryption error', () => {
|
||||
const garbage = Buffer.from('v10' + 'this-is-not-valid-ciphertext!!');
|
||||
const ciphertext = garbage.slice(3);
|
||||
expect(() => {
|
||||
const decipher = crypto.createDecipheriv('aes-128-cbc', TEST_KEY, IV);
|
||||
Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Profile Validation', () => {
|
||||
test('rejects path traversal in profile names', () => {
|
||||
// The validateProfile function should reject profiles with / or ..
|
||||
// We can't call it directly (internal), but we can test via listDomains
|
||||
// which calls validateProfile
|
||||
expect(() => listDomains('chrome', '../etc')).toThrow(/Invalid profile/);
|
||||
expect(() => listDomains('chrome', 'Default/../../etc')).toThrow(/Invalid profile/);
|
||||
});
|
||||
|
||||
test('rejects control characters in profile names', () => {
|
||||
expect(() => listDomains('chrome', 'Default\x00evil')).toThrow(/Invalid profile/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unknown Browser', () => {
|
||||
test('throws for unknown browser name', () => {
|
||||
expect(() => listDomains('firefox')).toThrow(/Unknown browser.*firefox/i);
|
||||
});
|
||||
|
||||
test('error includes list of supported browsers', () => {
|
||||
try {
|
||||
listDomains('firefox');
|
||||
throw new Error('Should have thrown');
|
||||
} catch (err: any) {
|
||||
expect(err.code).toBe('unknown_browser');
|
||||
expect(err.message).toContain('comet');
|
||||
expect(err.message).toContain('chrome');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Test Page - Cursor Interactive</title>
|
||||
<style>
|
||||
.clickable-div { cursor: pointer; padding: 10px; border: 1px solid #ccc; }
|
||||
.hover-card { cursor: pointer; padding: 20px; background: #f0f0f0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Cursor Interactive Test</h1>
|
||||
<!-- These are NOT standard interactive elements but have cursor:pointer -->
|
||||
<div class="clickable-div" id="click-div" onclick="this.textContent = 'clicked!'">Click me (div)</div>
|
||||
<span class="hover-card" id="hover-span">Hover card (span)</span>
|
||||
<div tabindex="0" id="focusable-div">Focusable div</div>
|
||||
<div onclick="alert('hi')" id="onclick-div">Onclick div</div>
|
||||
<!-- Standard interactive element (should NOT appear in -C output) -->
|
||||
<button id="normal-btn">Normal Button</button>
|
||||
<a href="/test">Normal Link</a>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Test Page - Dialog</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Dialog Test</h1>
|
||||
<button id="alert-btn" onclick="alert('Hello from alert')">Alert</button>
|
||||
<button id="confirm-btn" onclick="document.getElementById('confirm-result').textContent = confirm('Are you sure?') ? 'confirmed' : 'cancelled'">Confirm</button>
|
||||
<button id="prompt-btn" onclick="document.getElementById('prompt-result').textContent = prompt('Enter name:', 'default') || 'null'">Prompt</button>
|
||||
<p id="confirm-result"></p>
|
||||
<p id="prompt-result"></p>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
<!DOCTYPE html>
|
||||
<html><body></body></html>
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Test Page - Element States</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Element States Test</h1>
|
||||
<input type="text" id="enabled-input" value="enabled" />
|
||||
<input type="text" id="disabled-input" value="disabled" disabled />
|
||||
<input type="checkbox" id="checked-box" checked />
|
||||
<input type="checkbox" id="unchecked-box" />
|
||||
<div id="visible-div">Visible</div>
|
||||
<div id="hidden-div" style="display: none;">Hidden</div>
|
||||
<input type="text" id="readonly-input" readonly value="readonly" />
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+25
@@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Test Page - Upload</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Upload Test</h1>
|
||||
<input type="file" id="file-input" />
|
||||
<input type="file" id="multi-input" multiple />
|
||||
<p id="upload-result"></p>
|
||||
<script>
|
||||
document.getElementById('file-input').addEventListener('change', function(e) {
|
||||
const files = e.target.files;
|
||||
const names = Array.from(files).map(f => f.name).join(', ');
|
||||
document.getElementById('upload-result').textContent = 'Uploaded: ' + names;
|
||||
});
|
||||
document.getElementById('multi-input').addEventListener('change', function(e) {
|
||||
const files = e.target.files;
|
||||
const names = Array.from(files).map(f => f.name).join(', ');
|
||||
document.getElementById('upload-result').textContent = 'Multi: ' + names;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,16 @@ export function startTestServer(port: number = 0): { server: ReturnType<typeof B
|
||||
hostname: '127.0.0.1',
|
||||
fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
// Echo endpoint — returns request headers as JSON
|
||||
if (url.pathname === '/echo') {
|
||||
const headers: Record<string, string> = {};
|
||||
req.headers.forEach((value, key) => { headers[key] = value; });
|
||||
return new Response(JSON.stringify(headers, null, 2), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
let filePath = url.pathname === '/' ? '/basic.html' : url.pathname;
|
||||
|
||||
// Remove leading slash
|
||||
|
||||
Reference in New Issue
Block a user