Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
fee556b
feat: initial implementation for state machine
danew Dec 8, 2025
96608ac
chore: integrate a few more services
danew Dec 8, 2025
9aee386
feat: typestate pattern for state machine (#242)
junhsss Dec 12, 2025
9241bf7
chore: squashed commit of the following:
danew Dec 12, 2025
7c12523
fix: explicitly detach browser listeners instead of removeAllListeners
danew Dec 12, 2025
1f29353
chore: add some tests for session-machine and plugin-adapter
danew Dec 12, 2025
3c1daa9
chore: add test, disconnect doesn't trigger onSessionEnd for plugins
danew Dec 12, 2025
497c275
chore: add test, disconnect with keepAlive=false doesn't cleanup prop…
danew Dec 12, 2025
0b2d6ee
chore: add test, async onBrowserReady failures aren't being caught an…
danew Dec 12, 2025
b44a1f5
fix: resolve plugin and browser tests
danew Dec 12, 2025
d8aa199
chore: add tests to pre-commit
danew Dec 12, 2025
ee1bed5
chore: merge main
danew Dec 12, 2025
292af99
chore: patch browser runtime with latest changes
danew Dec 12, 2025
55973a4
fix: correct type imports
danew Dec 12, 2025
77868d3
chore: merge remote-tracking branch 'origin/main' into dane/existenti…
danew Dec 12, 2025
4c1ccc2
fix: enforce session limitng
danew Dec 19, 2025
8eff72a
feat: xstate
danew Dec 19, 2025
41c096f
fix: harden
danew Dec 19, 2025
57419d0
so far
danew Dec 19, 2025
93942bd
chore: finish integrating the tests
danew Jan 7, 2026
fe66270
refactor: intergate the log instrumentation
danew Jan 8, 2026
a24d078
refactor: integrate fingerprint injection
danew Jan 8, 2026
1b37e1b
refactor: session state passthrough
danew Jan 8, 2026
9712829
refactor: plugin manager
danew Jan 8, 2026
120ecf5
refactor: remove logging
danew Jan 9, 2026
e91f137
feat: add state transition logger
danew Jan 9, 2026
449eca6
feat: add tracing
danew Jan 9, 2026
0d2f2d0
feat: hierarchical states
danew Jan 9, 2026
91c37bb
refactor: mutex and tach scheduling
danew Jan 9, 2026
fa395ca
refactor: clean up actors and consolidate browser runtime
danew Jan 9, 2026
88dd847
refactor: clean up some imports
danew Jan 9, 2026
ceb9455
test: setup integration test harness
danew Jan 9, 2026
a9faf92
test: add good base of integration tests
danew Jan 9, 2026
a47af55
refactor: integration tests, remove timout reliance, separate build
danew Jan 9, 2026
26f86c9
refactor: remove previous runtime approach
danew Jan 9, 2026
b3d2677
Merge remote-tracking branch 'origin/main' into dane/existentialism
danew Feb 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

# API type checking
echo "🎨 Running code formatting..."
npm run typecheck -w api

# Code formatting and linting
echo "🎨 Running code formatting..."
Comment on lines +5 to 9
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The echoed message says “Running code formatting...” but the command is typecheck. Update the message to reflect what’s actually running (and consider avoiding duplicate “formatting” echo lines) to make pre-commit output easier to interpret.

Suggested change
echo "🎨 Running code formatting..."
npm run typecheck -w api
# Code formatting and linting
echo "🎨 Running code formatting..."
echo "🔧 Running API type checking..."
npm run typecheck -w api
# Code formatting and linting
echo "🎨 Running code formatting and linting for API..."

Copilot uses AI. Check for mistakes.
npm run pretty -w api
Expand All @@ -15,4 +19,7 @@ npm run build
# Add formatted files back to staging
git add -u

# Run tests
npm run test -w api

echo "✅ Pre-commit checks passed!"
13 changes: 9 additions & 4 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,18 @@
},
"scripts": {
"start": "node ./build/index.js",
"build": "tsc && npm run copy:templates && npm run copy:fingerprint",
"build": "tsc -p tsconfig.build.json && npm run copy:templates && npm run copy:fingerprint",
"lint": "eslint . --ext ts --report-unused-disable-directives --max-warnings 10",
"copy:templates": "mkdir -p build/templates && cp -r src/templates/* build/templates/",
"copy:fingerprint": "cp src/scripts/fingerprint.js build/scripts/fingerprint.js",
"prepare:recorder": "cd extensions/recorder && npm install && npm run build",
"dev": "npm run prepare:recorder && tsx watch src/index.ts",
"test": "echo \"Error: no test specified\" && exit 1",
"test": "vitest run --project unit --project jsdom",
"test:watch": "vitest --project unit --project jsdom",
"test:integration": "vitest run --project integration",
"pretty": "prettier --write \"src/**/*.ts\"",
"generate:openapi": "tsx ./openapi/generate.ts"
"generate:openapi": "tsx ./openapi/generate.ts",
"typecheck": "tsc --noEmit"
},
"author": "Nasr Mohamed",
"devDependencies": {
Expand Down Expand Up @@ -75,6 +78,7 @@
"@joplin/turndown": "^4.0.80",
"@scalar/fastify-api-reference": "^1.25.116",
"archiver": "^7.0.1",
"async-mutex": "^0.5.0",
"axios": "^1.12.0",
"cheerio": "^1.1.2",
"chokidar": "^4.0.3",
Expand Down Expand Up @@ -102,6 +106,7 @@
"puppeteer-core": "23.6.0",
"socks-proxy-agent": "^8.0.5",
"uuid": "^11.0.5",
"xstate": "^5.25.0",
"zod": "^3.24.2",
"zod-to-json-schema": "^3.24.1"
},
Expand All @@ -121,4 +126,4 @@
"optional": true
}
}
}
}
100 changes: 100 additions & 0 deletions api/src/browser-runtime/__tests__/disconnect-recovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { BrowserRuntime } from "../facade/browser-runtime.js";
import { MockLauncher } from "../drivers/mock-launcher.js";
import { pino } from "pino";

describe("BrowserRuntime Disconnect Recovery", () => {
const mockLogger = pino({ level: "silent" });
let launcher: MockLauncher;
let runtime: BrowserRuntime;

beforeEach(() => {
vi.clearAllMocks();
launcher = new MockLauncher();
});

afterEach(async () => {
if (runtime) {
await runtime.shutdown().catch(() => {});
}
});

it("should NOT auto-recover on browser disconnect by default (if not configured)", async () => {
runtime = new BrowserRuntime({
launcher,
appLogger: mockLogger,
keepAlive: false,
});

const browser = await runtime.launch({ options: { headless: true } } as any);
const browserRef = runtime.getBrowser();
expect(browserRef).toBeDefined();

// Simulate crash
launcher.simulateCrash(browserRef!);

// Wait for machine to reach idle
await new Promise((resolve) => setTimeout(resolve, 500));

expect(runtime.isRunning()).toBe(false);
expect(runtime.getState()).toBe("idle");
});

it("should auto-recover on browser disconnect when keepAlive is true", async () => {
// Note: This test might fail if auto-recovery isn't implemented in the new runtime yet
runtime = new BrowserRuntime({
launcher,
appLogger: mockLogger,
keepAlive: true,
defaultLaunchConfig: { options: { headless: true } } as any,
});

await runtime.launch({ options: { headless: true } } as any);
const browserRef1 = runtime.getBrowser();
expect(browserRef1).toBeDefined();

// Simulate crash
launcher.simulateCrash(browserRef1!);

// Wait for it to become running again
await new Promise((resolve) => {
const check = () => {
if (runtime.isRunning()) {
resolve(true);
} else {
setTimeout(check, 100);
}
};
check();
// Timeout after 5s
setTimeout(() => resolve(false), 5000);
});

expect(runtime.isRunning()).toBe(true);
const browserRef2 = runtime.getBrowser();
expect(browserRef2).toBeDefined();
expect(browserRef2?.id).toBe(browserRef1?.id); // Should use same sessionId if not specified
expect(browserRef2?.launchedAt).toBeGreaterThan(browserRef1?.launchedAt || 0);
});

it("should NOT auto-recover when intentional shutdown is in progress", async () => {
runtime = new BrowserRuntime({
launcher,
appLogger: mockLogger,
keepAlive: true,
});

await runtime.launch({ options: { headless: true } } as any);

const shutdownPromise = runtime.shutdown();

// Simulate crash during shutdown
const browserRef = runtime.getBrowser();
launcher.simulateCrash(browserRef!);

await shutdownPromise;

expect(runtime.isRunning()).toBe(false);
expect(runtime.getState()).toBe("idle");
});
});
77 changes: 77 additions & 0 deletions api/src/browser-runtime/__tests__/fingerprint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { generateFingerprint, injectFingerprint } from "../services/fingerprint.service.js";
import { createMockPage } from "./helpers.js";
import { pino } from "pino";

describe("Fingerprint Service", () => {
const logger = pino({ level: "silent" });

describe("generateFingerprint", () => {
it("should generate a desktop fingerprint by default", () => {
const fingerprint = generateFingerprint({});
expect(fingerprint).toBeDefined();
expect(fingerprint.fingerprint.navigator.userAgent).toContain("Chrome");
expect(fingerprint.fingerprint.screen.width).toBe(1920);
});

it("should respect dimensions", () => {
const fingerprint = generateFingerprint({
dimensions: { width: 1280, height: 720 },
});
expect(fingerprint.fingerprint.screen.width).toBe(1280);
expect(fingerprint.fingerprint.screen.height).toBe(720);
});

it("should generate a mobile fingerprint when requested", () => {
const fingerprint = generateFingerprint({
deviceConfig: { device: "mobile" },
});
expect(fingerprint.fingerprint.navigator.userAgent).toMatch(/phone|android|mobile/i);
});
});

describe("injectFingerprint", () => {
let mockPage: any;
let mockSession: any;

beforeEach(() => {
mockSession = {
send: vi.fn().mockResolvedValue(undefined),
detach: vi.fn().mockResolvedValue(undefined),
};
mockPage = createMockPage();
mockPage.createCDPSession = vi.fn().mockResolvedValue(mockSession);
mockPage.setUserAgent = vi.fn().mockResolvedValue(undefined);
mockPage.setExtraHTTPHeaders = vi.fn().mockResolvedValue(undefined);
});

it("should inject fingerprint into page", async () => {
const fingerprint = generateFingerprint({});
await injectFingerprint(mockPage, fingerprint, logger);

expect(mockPage.setUserAgent).toHaveBeenCalledWith(
fingerprint.fingerprint.navigator.userAgent,
);
expect(mockSession.send).toHaveBeenCalledWith(
"Page.setDeviceMetricsOverride",
expect.any(Object),
);
expect(mockSession.send).toHaveBeenCalledWith(
"Emulation.setUserAgentOverride",
expect.any(Object),
);
expect(mockPage.evaluateOnNewDocument).toHaveBeenCalled();
});

it("should fallback to FingerprintInjector on error", async () => {
const fingerprint = generateFingerprint({});
mockPage.createCDPSession.mockRejectedValue(new Error("CDP error"));

// We don't easily mock FingerprintInjector here as it's a class instantiated inside
// but we can at least check that it doesn't throw and logs an error
const errorSpy = vi.spyOn(logger, "error");
await injectFingerprint(mockPage, fingerprint, logger);
expect(errorSpy).toHaveBeenCalled();
});
});
});
53 changes: 53 additions & 0 deletions api/src/browser-runtime/__tests__/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { vi } from "vitest";

export function createMockPage() {
return {
evaluateOnNewDocument: vi.fn().mockResolvedValue(undefined),
setRequestInterception: vi.fn().mockResolvedValue(undefined),
emulateMediaFeatures: vi.fn().mockResolvedValue(undefined),
setUserAgent: vi.fn().mockResolvedValue(undefined),
setExtraHTTPHeaders: vi.fn().mockResolvedValue(undefined),
on: vi.fn(),
off: vi.fn(),
url: vi.fn().mockReturnValue("about:blank"),
target: vi.fn().mockReturnValue({
_targetId: "test-target-id",
createCDPSession: vi.fn().mockResolvedValue({
send: vi.fn().mockResolvedValue(undefined),
detach: vi.fn().mockResolvedValue(undefined),
}),
}),
browser: vi.fn().mockReturnValue({
version: vi.fn().mockResolvedValue("Chrome/120.0.0.0"),
}),
};
}

export function createMockBrowserInstance() {
const page = createMockPage();
return {
wsEndpoint: vi.fn().mockReturnValue("ws://localhost:9222"),
process: vi.fn().mockReturnValue({ pid: 12345 }),
close: vi.fn().mockResolvedValue(undefined),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
pages: vi.fn().mockResolvedValue([page]),
targets: vi.fn().mockReturnValue([]),
version: vi.fn().mockResolvedValue("Chrome/120.0.0.0"),
userAgent: vi.fn().mockResolvedValue("Mozilla/5.0..."),
};
}

export function createMockBrowserRef(sessionId: string) {
const instance = createMockBrowserInstance();
return {
id: sessionId,
instance: instance as any,
primaryPage: (instance as any).pages()[0] as any,
pid: 12345,
wsEndpoint: instance.wsEndpoint(),
launchedAt: Date.now(),
};
}
72 changes: 72 additions & 0 deletions api/src/browser-runtime/__tests__/load.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, it, expect, vi } from "vitest";
import { BrowserRuntime } from "../facade/browser-runtime.js";
import { SimulatedLauncher } from "../drivers/simulated-launcher.js";
import { pino } from "pino";

describe("Load testing with SimulatedLauncher", () => {
const mockLogger = pino({ level: "silent" });
const mockInstrumentationLogger = { record: vi.fn(), on: vi.fn() };

it("should handle 10 concurrent browser sessions", async () => {
const launcher = new SimulatedLauncher({
avgLaunchTimeMs: 100,
crashProbability: 0.1,
});

const runtimes = Array.from(
{ length: 10 },
() =>
new BrowserRuntime({
launcher,
appLogger: mockLogger,
instrumentationLogger: mockInstrumentationLogger as any,
keepAlive: false,
}),
);

console.log("Starting 10 concurrent sessions...");
const startPromises = runtimes.map((r, i) =>
r.start({ sessionId: `session-${i}`, port: 9000 + i }),
);

const results = await Promise.allSettled(startPromises);
const successful = results.filter((r) => r.status === "fulfilled").length;
console.log(`Successfully started ${successful}/10 sessions`);

expect(successful).toBeGreaterThan(0);

// Get metrics
const metrics = launcher.getMetrics();
console.log("Launcher Metrics:", metrics);

expect(metrics.totalLaunched).toBe(10);

// Stop all
console.log("Stopping all sessions...");
await Promise.all(runtimes.map((r) => r.stop().catch(() => {})));

const finalMetrics = launcher.getMetrics();
console.log("Final Metrics:", finalMetrics);
expect(finalMetrics.currentActive).toBe(0);
});

it("should scale to 20 sequential sessions quickly", async () => {
const launcher = new SimulatedLauncher({ avgLaunchTimeMs: 10 });
const runtime = new BrowserRuntime({
launcher,
appLogger: mockLogger,
instrumentationLogger: mockInstrumentationLogger as any,
keepAlive: false,
});

console.log("Starting 20 sequential sessions...");
for (let i = 0; i < 20; i++) {
await runtime.start({ sessionId: `seq-${i}`, port: 9222 });
await runtime.stop();
}

const metrics = launcher.getMetrics();
console.log("Sequential Metrics:", metrics);
expect(metrics.totalLaunched).toBe(20);
});
});
Loading
Loading