Skip to content

Commit ab04e2f

Browse files
ralyodioclaude
andcommitted
test(m3.1): integration tests for the tron-session engine
Regression coverage for the running shell implementation (previously only verified ad-hoc). Drives the real `tron-session` CLI against a Node CDP mock via child_process: launch + live descriptor (incl. webSocketDebuggerUrl), tabs/current, open-as-new-tab, use, already-running guard, headless→ephemeral profile cleanup, close, and the rc-3 no-session `open` fallback signal. Skips gracefully when curl/python3 are unavailable. 8 cases, wired into the existing `pnpm -r test` / vitest suite. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2d600f6 commit ab04e2f

3 files changed

Lines changed: 209 additions & 0 deletions

File tree

apps/desktop/src/session.test.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// Integration tests for the managed-session engine (apps/desktop/launcher/
2+
// tron-session), driven through its real CLI against a Node CDP mock. This is
3+
// the regression coverage for the *running* implementation; the pure schema /
4+
// tab-mapping contract it mirrors is unit-tested in @tronbrowser/browser-core.
5+
import { execFileSync, spawnSync } from 'node:child_process';
6+
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
7+
import { tmpdir } from 'node:os';
8+
import { join } from 'node:path';
9+
import { fileURLToPath } from 'node:url';
10+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
11+
12+
const SESSION = fileURLToPath(new URL('../launcher/tron-session', import.meta.url));
13+
const SHIM = fileURLToPath(new URL('../test/fixtures/fake-shim.sh', import.meta.url));
14+
15+
// The engine shells out to curl + python3 (already launcher dependencies). Skip
16+
// the suite gracefully where they are unavailable rather than fail spuriously.
17+
function has(bin: string): boolean {
18+
return spawnSync('sh', ['-c', `command -v ${bin}`], { encoding: 'utf8' }).status === 0;
19+
}
20+
const ready = has('curl') && (has('python3') || has('python'));
21+
22+
let dataDir: string;
23+
24+
function baseEnv(): NodeJS.ProcessEnv {
25+
return { ...process.env, TRONBROWSER_DATA: dataDir, TRONBROWSER_SHIM: SHIM };
26+
}
27+
28+
/** Run a tron-session command, returning stdout (throws on nonzero exit). */
29+
function tron(...args: string[]): string {
30+
return execFileSync(SESSION, args, { env: baseEnv(), encoding: 'utf8', timeout: 30_000 });
31+
}
32+
33+
/** Run and capture status + stdout without throwing (for exit-code assertions). */
34+
function tronStatus(...args: string[]): { status: number | null; stdout: string } {
35+
const r = spawnSync(SESSION, args, { env: baseEnv(), encoding: 'utf8', timeout: 30_000 });
36+
return { status: r.status, stdout: r.stdout ?? '' };
37+
}
38+
39+
describe.skipIf(!ready)('tron-session managed sessions', () => {
40+
beforeEach(() => {
41+
dataDir = mkdtempSync(join(tmpdir(), 'tron-session-test-'));
42+
});
43+
44+
afterEach(() => {
45+
try {
46+
tron('browser', 'close');
47+
} catch {
48+
// ignore — individual tests close their own session
49+
}
50+
rmSync(dataDir, { recursive: true, force: true });
51+
});
52+
53+
it('launches a session and writes a live descriptor', () => {
54+
const out = tron('browser', 'launch');
55+
expect(out).toMatch(/managed session ready on 127\.0\.0\.1:\d+/);
56+
57+
const desc = JSON.parse(tron('browser', 'status', '--json'));
58+
expect(desc.state).toBe('running');
59+
expect(desc.version).toBe(1);
60+
expect(desc.host).toBe('127.0.0.1');
61+
expect(desc.port).toBeGreaterThan(0);
62+
expect(desc.profileName).toBe('agent');
63+
expect(desc.headless).toBe(false);
64+
// The M3.2 attach point must be captured.
65+
expect(desc.webSocketDebuggerUrl).toMatch(/^ws:\/\/127\.0\.0\.1:\d+\/devtools\/browser\//);
66+
67+
expect(tron('browser', 'status')).toMatch(/running/);
68+
});
69+
70+
it('lists the initial tab and marks it current', () => {
71+
tron('browser', 'launch');
72+
const tabs = JSON.parse(tron('browser', 'tabs', '--json'));
73+
expect(tabs).toHaveLength(1);
74+
expect(tabs[0].current).toBe(true);
75+
expect(tabs[0].url).toBe('chrome://newtab/');
76+
});
77+
78+
it('opens a URL as a new current tab', () => {
79+
tron('browser', 'launch');
80+
const out = tron('open', 'http://example.com/contact');
81+
expect(out).toMatch(/opened http:\/\/example\.com\/contact/);
82+
83+
const tabs = JSON.parse(tron('browser', 'tabs', '--json'));
84+
expect(tabs).toHaveLength(2);
85+
const current = tabs.find((t: { current: boolean }) => t.current);
86+
expect(current.url).toBe('http://example.com/contact');
87+
});
88+
89+
it('switches the current tab with use, reflected by current', () => {
90+
tron('browser', 'launch');
91+
tron('open', 'http://example.org');
92+
const tabs = JSON.parse(tron('browser', 'tabs', '--json'));
93+
const first = tabs[0].id as string;
94+
95+
tron('browser', 'use', first);
96+
const after = JSON.parse(tron('browser', 'tabs', '--json'));
97+
expect(after.find((t: { current: boolean }) => t.current).id).toBe(first);
98+
expect(tron('browser', 'current')).toContain(first);
99+
});
100+
101+
it('rejects a launch while one is already running', () => {
102+
tron('browser', 'launch');
103+
expect(tron('browser', 'launch')).toMatch(/already running/);
104+
});
105+
106+
it('uses an ephemeral temp profile for headless and removes it on close', () => {
107+
tron('browser', 'launch', '--headless');
108+
const desc = JSON.parse(tron('browser', 'status', '--json'));
109+
expect(desc.headless).toBe(true);
110+
expect(desc.ephemeral).toBe(true);
111+
expect(desc.profileName).toBe('ephemeral');
112+
expect(desc.profileDir.startsWith(tmpdir())).toBe(true);
113+
expect(existsSync(desc.profileDir)).toBe(true);
114+
115+
tron('browser', 'close');
116+
expect(existsSync(desc.profileDir)).toBe(false);
117+
expect(tron('browser', 'status')).toMatch(/no managed session/);
118+
});
119+
120+
it('closes cleanly and reports no session afterwards', () => {
121+
tron('browser', 'launch');
122+
expect(tron('browser', 'close')).toMatch(/closed managed session/);
123+
expect(tron('browser', 'status')).toMatch(/no managed session/);
124+
});
125+
126+
it('exits 3 from `open` when no session is running (legacy-launch signal)', () => {
127+
const r = tronStatus('open', 'http://fallback.test');
128+
expect(r.status).toBe(3);
129+
});
130+
});
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Minimal CDP DevTools HTTP endpoint mock, standing in for Chromium so the
2+
// tron-session shell engine can be integration-tested without a browser.
3+
// The fake shim exec-replaces into this, so tron-session tracks its pid exactly
4+
// like Chromium on Linux. Behavior mirrors packages/browser-core/src/automation.
5+
import { createServer } from 'node:http';
6+
import { mkdirSync, writeFileSync } from 'node:fs';
7+
import { dirname } from 'node:path';
8+
9+
const dataDir = process.env.TRONBROWSER_DATA;
10+
const reqPort = Number(process.env.TRON_AUTOMATION_PORT ?? '0');
11+
12+
let counter = 0;
13+
const targets = [];
14+
let PORT = 0;
15+
16+
function newTarget(url) {
17+
counter += 1;
18+
const id = `TAB${String(counter).padStart(4, '0')}`;
19+
const t = {
20+
id,
21+
type: 'page',
22+
title: url,
23+
url,
24+
webSocketDebuggerUrl: `ws://127.0.0.1:${PORT}/devtools/page/${id}`,
25+
};
26+
targets.push(t);
27+
return t;
28+
}
29+
30+
function send(res, code, obj) {
31+
const body = JSON.stringify(obj);
32+
res.writeHead(code, { 'Content-Type': 'application/json' });
33+
res.end(body);
34+
}
35+
36+
const server = createServer((req, res) => {
37+
const path = req.url ?? '';
38+
if (path === '/json/version') {
39+
return send(res, 200, {
40+
Browser: 'MockChrome/1.0',
41+
webSocketDebuggerUrl: `ws://127.0.0.1:${PORT}/devtools/browser/mock`,
42+
});
43+
}
44+
if (path === '/json' || path === '/json/list') {
45+
return send(res, 200, targets);
46+
}
47+
if (path.startsWith('/json/new')) {
48+
const q = path.indexOf('?');
49+
const url = q >= 0 ? path.slice(q + 1) : 'about:blank';
50+
return send(res, 200, newTarget(url));
51+
}
52+
if (path.startsWith('/json/close/')) {
53+
const id = path.slice('/json/close/'.length);
54+
const before = targets.length;
55+
for (let i = targets.length - 1; i >= 0; i -= 1) {
56+
if (targets[i].id === id) targets.splice(i, 1);
57+
}
58+
return send(res, targets.length < before ? 200 : 404, { closed: id });
59+
}
60+
if (path.startsWith('/json/activate/')) {
61+
const id = path.slice('/json/activate/'.length);
62+
const ok = targets.some((t) => t.id === id);
63+
return send(res, ok ? 200 : 404, { activated: id });
64+
}
65+
send(res, 404, { error: 'not found' });
66+
});
67+
68+
server.listen(reqPort, '127.0.0.1', () => {
69+
PORT = server.address().port;
70+
newTarget('chrome://newtab/'); // a session always opens with one page
71+
const apf = `${dataDir}/DevToolsActivePort`;
72+
mkdirSync(dirname(apf), { recursive: true });
73+
writeFileSync(apf, `${PORT}\n/devtools/browser/mock\n`);
74+
process.stderr.write(`mock cdp on 127.0.0.1:${PORT}\n`);
75+
});
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
#!/bin/sh
2+
# Test double for the `tronbrowser` shim: exec-replaces into the Node CDP mock so
3+
# tron-session tracks the mock's pid exactly like Chromium does on Linux.
4+
exec node "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/cdp-mock-server.mjs"

0 commit comments

Comments
 (0)