mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-06 00:44:07 +01:00
test: allocate available ports for pairing and watchdog fixtures
This commit is contained in:
1 parent
4ac747558f
commit
eaff1688fb
3 files changed
+125
-65
No files matched your search
@@ -34,36 +34,46 @@ interface DaemonHandle {
|
||||
stateFile: string;
|
||||
tempDir: string;
|
||||
baseUrl: string;
|
||||
output: Promise<[string, string]>;
|
||||
}
|
||||
|
||||
async function waitForReady(baseUrl: string, timeoutMs = 15_000): Promise<void> {
|
||||
async function waitForReady(
|
||||
proc: ReturnType<typeof Bun.spawn>, stateFile: string, timeoutMs = 15_000,
|
||||
): Promise<{ port: number; token: string }> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
let lastError = '';
|
||||
while (Date.now() < deadline && proc.exitCode === null) {
|
||||
try {
|
||||
const resp = await fetch(`${baseUrl}/health`, {
|
||||
// Only this daemon's published state can identify its selected port.
|
||||
const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
||||
if (state.pid !== proc.pid || !Number.isInteger(state.port) || state.port < 1 || state.port > 65535 ||
|
||||
typeof state.token !== 'string' || !state.token) {
|
||||
throw new Error('State does not identify this test daemon');
|
||||
}
|
||||
const resp = await fetch(`http://127.0.0.1:${state.port}/health`, {
|
||||
signal: AbortSignal.timeout(1000),
|
||||
});
|
||||
if (resp.ok) return;
|
||||
} catch {
|
||||
// not ready yet
|
||||
await resp.arrayBuffer();
|
||||
if (resp.ok) return state;
|
||||
lastError = `Health returned HTTP ${resp.status}`;
|
||||
} catch (error) {
|
||||
lastError = String(error);
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
}
|
||||
throw new Error(`Daemon did not become ready within ${timeoutMs}ms`);
|
||||
throw new Error(`Daemon did not become ready within ${timeoutMs}ms (exit=${proc.exitCode}): ${lastError}`);
|
||||
}
|
||||
|
||||
async function spawnDaemon(): Promise<DaemonHandle> {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pair-agent-e2e-'));
|
||||
const stateFile = path.join(tempDir, 'browse.json');
|
||||
// Pick a high ephemeral port
|
||||
const port = 20000 + Math.floor(Math.random() * 20000);
|
||||
|
||||
const proc = Bun.spawn(['bun', 'run', SERVER_ENTRY], {
|
||||
cwd: ROOT,
|
||||
env: {
|
||||
...process.env,
|
||||
BROWSE_HEADLESS_SKIP: '1',
|
||||
BROWSE_PORT: String(port),
|
||||
BROWSE_PORT: '0', // Use the daemon's checked allocator and discover its port from state.
|
||||
BROWSE_STATE_FILE: stateFile,
|
||||
BROWSE_PARENT_PID: '0',
|
||||
BROWSE_IDLE_TIMEOUT: '600000',
|
||||
@@ -71,17 +81,35 @@ async function spawnDaemon(): Promise<DaemonHandle> {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
await waitForReady(baseUrl);
|
||||
|
||||
// Read the token from the state file that the daemon wrote
|
||||
const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
||||
return { proc, port, token: state.token, stateFile, tempDir, baseUrl };
|
||||
const output = Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
||||
try {
|
||||
const state = await waitForReady(proc, stateFile);
|
||||
const port = state.port;
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
return { proc, port, token: state.token, stateFile, tempDir, baseUrl, output };
|
||||
} catch (error) {
|
||||
// beforeAll cannot pass a handle to afterAll when startup fails.
|
||||
try { proc.kill('SIGKILL'); } catch {}
|
||||
try {
|
||||
await proc.exited;
|
||||
const [stdout, stderr] = await output;
|
||||
const errorFile = path.join(tempDir, 'browse-startup-error.log');
|
||||
const startupError = fs.existsSync(errorFile) ? fs.readFileSync(errorFile, 'utf-8') : '';
|
||||
throw new Error(`${error}\n${startupError}\n${stderr}\n${stdout}`);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function killDaemon(handle: DaemonHandle): void {
|
||||
async function killDaemon(handle: DaemonHandle): Promise<void> {
|
||||
try { handle.proc.kill('SIGKILL'); } catch {}
|
||||
try { fs.rmSync(handle.tempDir, { recursive: true, force: true }); } catch {}
|
||||
try {
|
||||
await handle.proc.exited;
|
||||
await handle.output;
|
||||
} finally {
|
||||
fs.rmSync(handle.tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('pair-agent flow end-to-end (HTTP only, no ngrok)', () => {
|
||||
@@ -91,8 +119,8 @@ describe('pair-agent flow end-to-end (HTTP only, no ngrok)', () => {
|
||||
daemon = await spawnDaemon();
|
||||
}, 20_000);
|
||||
|
||||
afterAll(() => {
|
||||
if (daemon) killDaemon(daemon);
|
||||
afterAll(async () => {
|
||||
if (daemon) await killDaemon(daemon);
|
||||
});
|
||||
|
||||
test('GET /health returns daemon status and NEVER includes a token (even for chrome-extension origins)', async () => {
|
||||
|
||||
@@ -39,36 +39,51 @@ interface DaemonHandle {
|
||||
localUrl: string;
|
||||
tunnelUrl: string;
|
||||
attemptsLogPath: string;
|
||||
output: Promise<[string, string]>;
|
||||
}
|
||||
|
||||
async function waitForReady(baseUrl: string, timeoutMs = 20_000): Promise<void> {
|
||||
async function waitForReady(
|
||||
proc: ReturnType<typeof Bun.spawn>, stateFile: string, timeoutMs = 20_000,
|
||||
): Promise<{ port: number; token: string }> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
let lastError = '';
|
||||
while (Date.now() < deadline && proc.exitCode === null) {
|
||||
try {
|
||||
const resp = await fetch(`${baseUrl}/health`, {
|
||||
// Only this daemon's published state identifies its bound listener.
|
||||
const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
||||
if (state.pid !== proc.pid || !Number.isInteger(state.port) || state.port < 1 || state.port > 65535 ||
|
||||
typeof state.token !== 'string' || !state.token) {
|
||||
throw new Error('State does not identify this test daemon');
|
||||
}
|
||||
const resp = await fetch(`http://127.0.0.1:${state.port}/health`, {
|
||||
signal: AbortSignal.timeout(1000),
|
||||
});
|
||||
if (resp.ok) return;
|
||||
} catch {
|
||||
// not ready yet
|
||||
await resp.arrayBuffer();
|
||||
if (resp.ok) return state;
|
||||
lastError = `Health returned HTTP ${resp.status}`;
|
||||
} catch (error) {
|
||||
lastError = String(error);
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
}
|
||||
throw new Error(`Daemon did not become ready within ${timeoutMs}ms at ${baseUrl}`);
|
||||
throw new Error(`Daemon did not become ready within ${timeoutMs}ms (exit=${proc.exitCode}): ${lastError}`);
|
||||
}
|
||||
|
||||
async function waitForTunnelPort(stateFile: string, timeoutMs = 20_000): Promise<number> {
|
||||
async function waitForTunnelPort(
|
||||
proc: ReturnType<typeof Bun.spawn>, stateFile: string, timeoutMs = 20_000,
|
||||
): Promise<number> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
while (Date.now() < deadline && proc.exitCode === null) {
|
||||
try {
|
||||
const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
||||
if (typeof state.tunnelLocalPort === 'number') return state.tunnelLocalPort;
|
||||
if (state.pid === proc.pid && Number.isInteger(state.tunnelLocalPort) &&
|
||||
state.tunnelLocalPort > 0 && state.tunnelLocalPort <= 65535) return state.tunnelLocalPort;
|
||||
} catch {
|
||||
// state file not written yet
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
}
|
||||
throw new Error(`Tunnel local port did not appear in ${stateFile} within ${timeoutMs}ms`);
|
||||
throw new Error(`Tunnel local port did not appear in ${stateFile} within ${timeoutMs}ms (exit=${proc.exitCode})`);
|
||||
}
|
||||
|
||||
async function spawnDaemonWithTunnel(): Promise<DaemonHandle> {
|
||||
@@ -78,7 +93,6 @@ async function spawnDaemonWithTunnel(): Promise<DaemonHandle> {
|
||||
const stateFile = path.join(tempDir, 'browse.json');
|
||||
const fakeHome = path.join(tempDir, 'home');
|
||||
fs.mkdirSync(fakeHome, { recursive: true });
|
||||
const localPort = 30000 + Math.floor(Math.random() * 30000);
|
||||
const attemptsLogPath = path.join(fakeHome, '.gstack', 'security', 'attempts.jsonl');
|
||||
|
||||
const proc = Bun.spawn(['bun', 'run', SERVER_ENTRY], {
|
||||
@@ -88,7 +102,7 @@ async function spawnDaemonWithTunnel(): Promise<DaemonHandle> {
|
||||
HOME: fakeHome,
|
||||
BROWSE_HEADLESS_SKIP: '1',
|
||||
BROWSE_TUNNEL_LOCAL_ONLY: '1',
|
||||
BROWSE_PORT: String(localPort),
|
||||
BROWSE_PORT: '0', // Use the daemon's checked allocator, then discover its actual port.
|
||||
BROWSE_STATE_FILE: stateFile,
|
||||
BROWSE_PARENT_PID: '0',
|
||||
BROWSE_IDLE_TIMEOUT: '600000',
|
||||
@@ -96,37 +110,56 @@ async function spawnDaemonWithTunnel(): Promise<DaemonHandle> {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
const localUrl = `http://127.0.0.1:${localPort}`;
|
||||
await waitForReady(localUrl);
|
||||
const tunnelPort = await waitForTunnelPort(stateFile);
|
||||
const tunnelUrl = `http://127.0.0.1:${tunnelPort}`;
|
||||
const output = Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
||||
try {
|
||||
const state = await waitForReady(proc, stateFile);
|
||||
const localPort = state.port;
|
||||
const localUrl = `http://127.0.0.1:${localPort}`;
|
||||
const tunnelPort = await waitForTunnelPort(proc, stateFile);
|
||||
const tunnelUrl = `http://127.0.0.1:${tunnelPort}`;
|
||||
|
||||
// Read the root token, then exchange it for a scoped token via /pair → /connect.
|
||||
const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
||||
const rootToken = state.token;
|
||||
// Exchange this daemon's root token for a scoped token via /pair → /connect.
|
||||
const rootToken = state.token;
|
||||
const pairResp = await fetch(`${localUrl}/pair`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${rootToken}` },
|
||||
body: JSON.stringify({ clientId: 'tunnel-eval' }),
|
||||
});
|
||||
if (!pairResp.ok) throw new Error(`/pair failed: ${pairResp.status}`);
|
||||
const { setup_key } = await pairResp.json() as any;
|
||||
|
||||
const pairResp = await fetch(`${localUrl}/pair`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${rootToken}` },
|
||||
body: JSON.stringify({ clientId: 'tunnel-eval' }),
|
||||
});
|
||||
if (!pairResp.ok) throw new Error(`/pair failed: ${pairResp.status}`);
|
||||
const { setup_key } = await pairResp.json() as any;
|
||||
const connectResp = await fetch(`${localUrl}/connect`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ setup_key }),
|
||||
});
|
||||
if (!connectResp.ok) throw new Error(`/connect failed: ${connectResp.status}`);
|
||||
const { token: scopedToken } = await connectResp.json() as any;
|
||||
|
||||
const connectResp = await fetch(`${localUrl}/connect`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ setup_key }),
|
||||
});
|
||||
if (!connectResp.ok) throw new Error(`/connect failed: ${connectResp.status}`);
|
||||
const { token: scopedToken } = await connectResp.json() as any;
|
||||
|
||||
return { proc, localPort, tunnelPort, rootToken, scopedToken, stateFile, tempDir, localUrl, tunnelUrl, attemptsLogPath };
|
||||
return { proc, localPort, tunnelPort, rootToken, scopedToken, stateFile, tempDir, localUrl, tunnelUrl, attemptsLogPath, output };
|
||||
} catch (error) {
|
||||
// A failed beforeAll never hands its daemon to afterAll for cleanup.
|
||||
try {
|
||||
try { proc.kill('SIGKILL'); } catch {}
|
||||
await proc.exited;
|
||||
const [stdout, stderr] = await output;
|
||||
const errorFile = path.join(tempDir, 'browse-startup-error.log');
|
||||
const startupError = fs.existsSync(errorFile) ? fs.readFileSync(errorFile, 'utf-8') : '';
|
||||
throw new Error(`${error}\n${startupError}\n${stderr}\n${stdout}`);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function killDaemon(handle: DaemonHandle): void {
|
||||
async function killDaemon(handle: DaemonHandle): Promise<void> {
|
||||
try { handle.proc.kill('SIGKILL'); } catch {}
|
||||
try { fs.rmSync(handle.tempDir, { recursive: true, force: true }); } catch {}
|
||||
try {
|
||||
await handle.proc.exited;
|
||||
await handle.output;
|
||||
} finally {
|
||||
fs.rmSync(handle.tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function postCommand(baseUrl: string, token: string, body: any): Promise<{ status: number; bodyText: string }> {
|
||||
@@ -145,8 +178,8 @@ describe('pair-agent over tunnel surface — gate fires on the right surface onl
|
||||
daemon = await spawnDaemonWithTunnel();
|
||||
}, 30_000);
|
||||
|
||||
afterAll(() => {
|
||||
if (daemon) killDaemon(daemon);
|
||||
afterAll(async () => {
|
||||
if (daemon) await killDaemon(daemon);
|
||||
});
|
||||
|
||||
test('newtab on tunnel surface passes the allowlist gate (not 403 disallowed_command)', async () => {
|
||||
|
||||
@@ -50,13 +50,13 @@ afterEach(async () => {
|
||||
serverProc = null;
|
||||
});
|
||||
|
||||
function spawnServer(env: Record<string, string>, port: number): Subprocess {
|
||||
function spawnServer(env: Record<string, string>): Subprocess {
|
||||
const stateFile = path.join(tmpDir, 'browse-state.json');
|
||||
return spawn(['bun', 'run', SERVER_SCRIPT], {
|
||||
env: {
|
||||
...process.env,
|
||||
BROWSE_STATE_FILE: stateFile,
|
||||
BROWSE_PORT: String(port),
|
||||
BROWSE_PORT: '0', // Use the existing available-port allocator; fixed ports can collide across shards.
|
||||
...env,
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
@@ -101,7 +101,7 @@ async function readStdoutUntil(
|
||||
describe('parent-process watchdog (v0.18.1.0)', () => {
|
||||
test('BROWSE_PARENT_PID=0 disables the watchdog', async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'watchdog-pid0-'));
|
||||
serverProc = spawnServer({ BROWSE_PARENT_PID: '0' }, 34901);
|
||||
serverProc = spawnServer({ BROWSE_PARENT_PID: '0' });
|
||||
|
||||
const out = await readStdoutUntil(
|
||||
serverProc,
|
||||
@@ -121,7 +121,6 @@ describe('parent-process watchdog (v0.18.1.0)', () => {
|
||||
// this PID and eventually fire on the "dead parent."
|
||||
serverProc = spawnServer(
|
||||
{ BROWSE_HEADED: '1', BROWSE_PARENT_PID: '999999' },
|
||||
34902,
|
||||
);
|
||||
|
||||
const out = await readStdoutUntil(
|
||||
@@ -146,7 +145,7 @@ describe('parent-process watchdog (v0.18.1.0)', () => {
|
||||
serverProc = spawnServer({
|
||||
BROWSE_PARENT_PID: String(parentPid),
|
||||
BROWSE_PARENT_WATCHDOG_INTERVAL_MS: '250',
|
||||
}, 34903);
|
||||
});
|
||||
const serverPid = serverProc.pid!;
|
||||
|
||||
// Startup barrier: poll stdout for the listen line instead of a fixed 2s
|
||||
|
||||
Reference in new issue
Block a user