-
Notifications
You must be signed in to change notification settings - Fork 924
feat: Session State Machine #240
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
danew
wants to merge
36
commits into
main
Choose a base branch
from
dane/existentialism
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
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 96608ac
chore: integrate a few more services
danew 9aee386
feat: typestate pattern for state machine (#242)
junhsss 9241bf7
chore: squashed commit of the following:
danew 7c12523
fix: explicitly detach browser listeners instead of removeAllListeners
danew 1f29353
chore: add some tests for session-machine and plugin-adapter
danew 3c1daa9
chore: add test, disconnect doesn't trigger onSessionEnd for plugins
danew 497c275
chore: add test, disconnect with keepAlive=false doesn't cleanup prop…
danew 0b2d6ee
chore: add test, async onBrowserReady failures aren't being caught an…
danew b44a1f5
fix: resolve plugin and browser tests
danew d8aa199
chore: add tests to pre-commit
danew ee1bed5
chore: merge main
danew 292af99
chore: patch browser runtime with latest changes
danew 55973a4
fix: correct type imports
danew 77868d3
chore: merge remote-tracking branch 'origin/main' into dane/existenti…
danew 4c1ccc2
fix: enforce session limitng
danew 8eff72a
feat: xstate
danew 41c096f
fix: harden
danew 57419d0
so far
danew 93942bd
chore: finish integrating the tests
danew fe66270
refactor: intergate the log instrumentation
danew a24d078
refactor: integrate fingerprint injection
danew 1b37e1b
refactor: session state passthrough
danew 9712829
refactor: plugin manager
danew 120ecf5
refactor: remove logging
danew e91f137
feat: add state transition logger
danew 449eca6
feat: add tracing
danew 0d2f2d0
feat: hierarchical states
danew 91c37bb
refactor: mutex and tach scheduling
danew fa395ca
refactor: clean up actors and consolidate browser runtime
danew 88dd847
refactor: clean up some imports
danew ceb9455
test: setup integration test harness
danew a9faf92
test: add good base of integration tests
danew a47af55
refactor: integration tests, remove timout reliance, separate build
danew 26f86c9
refactor: remove previous runtime approach
danew b3d2677
Merge remote-tracking branch 'origin/main' into dane/existentialism
danew File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
100 changes: 100 additions & 0 deletions
100
api/src/browser-runtime/__tests__/disconnect-recovery.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(), | ||
| }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.