browser-pilot-cli 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,698 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import { spawn } from 'node:child_process';
4
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
5
- import { basename, dirname, isAbsolute, resolve } from 'node:path';
6
- import { fileURLToPath } from 'node:url';
7
-
8
- const REPORT_SCHEMA_VERSION = 1;
9
- const SUITE_VERSION = '1.0.0';
10
- const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
11
- const MAX_REQUEST_TIMEOUT_MS = 300_000;
12
- const MAX_STDOUT_LINE_BYTES = 16 * 1024 * 1024;
13
- const MAX_STDERR_BYTES = 8 * 1024;
14
- const MAX_REPORT_STRING = 500;
15
- const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
16
- const REQUIRED_CAPABILITIES = [
17
- 'browser.control',
18
- 'workspace.manage',
19
- 'observation.read',
20
- 'artifact.read',
21
- 'event.read',
22
- ];
23
- const REQUIRED_TOOLS = [
24
- 'browser.connect',
25
- 'browser.open',
26
- 'browser.observe',
27
- 'browser.capture',
28
- 'browser.tabs.list',
29
- 'browser.tabs.close',
30
- ];
31
- const REQUIRED_TOOL_CONTEXTS = new Map([
32
- ['browser.connect', 'workspace'],
33
- ['browser.open', 'workspace'],
34
- ['browser.observe', 'target'],
35
- ['browser.capture', 'target'],
36
- ['browser.tabs.list', 'workspace'],
37
- ['browser.tabs.close', 'target'],
38
- ]);
39
-
40
- class ConformanceError extends Error {
41
- constructor(code, message, options = {}) {
42
- super(message, options);
43
- this.name = 'ConformanceError';
44
- this.code = code;
45
- this.rpcCode = options.rpcCode;
46
- }
47
- }
48
-
49
- function bounded(value, limit = MAX_REPORT_STRING) {
50
- return String(value).replace(/[\r\n]+/g, ' ').slice(0, limit);
51
- }
52
-
53
- function asRecord(value, label) {
54
- if (value === null || typeof value !== 'object' || Array.isArray(value)) {
55
- throw new ConformanceError('invalid_contract', `${label} must be an object`);
56
- }
57
- return value;
58
- }
59
-
60
- function nonEmptyString(value, label) {
61
- if (typeof value !== 'string' || value.length === 0 || value.length > 16_384) {
62
- throw new ConformanceError('invalid_contract', `${label} must be a non-empty bounded string`);
63
- }
64
- return value;
65
- }
66
-
67
- function assertContract(condition, message) {
68
- if (!condition) throw new ConformanceError('invalid_contract', message);
69
- }
70
-
71
- function parsePositiveInteger(raw, flag) {
72
- const value = Number(raw);
73
- if (!Number.isSafeInteger(value) || value < 1 || value > MAX_REQUEST_TIMEOUT_MS) {
74
- throw new ConformanceError('invalid_arguments', `${flag} must be an integer from 1 through ${MAX_REQUEST_TIMEOUT_MS}`);
75
- }
76
- return value;
77
- }
78
-
79
- function parseArguments(argv) {
80
- let reportPath;
81
- let timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS;
82
- let command;
83
- for (let index = 0; index < argv.length; index += 1) {
84
- const argument = argv[index];
85
- if (argument === '--') {
86
- command = argv.slice(index + 1);
87
- break;
88
- }
89
- if (argument === '--report') {
90
- const value = argv[++index];
91
- if (!value) throw new ConformanceError('invalid_arguments', '--report requires a path');
92
- reportPath = resolve(value);
93
- continue;
94
- }
95
- if (argument === '--timeout-ms') {
96
- const value = argv[++index];
97
- if (!value) throw new ConformanceError('invalid_arguments', '--timeout-ms requires a value');
98
- timeoutMs = parsePositiveInteger(value, '--timeout-ms');
99
- continue;
100
- }
101
- if (argument === '--help' || argument === '-h') {
102
- return { help: true };
103
- }
104
- throw new ConformanceError('invalid_arguments', `Unknown argument: ${bounded(argument, 128)}`);
105
- }
106
-
107
- const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
108
- const selected = command ?? [process.execPath, resolve(root, 'dist/cli.js'), 'bridge', '--stdio'];
109
- if (selected.length === 0 || !selected[0]) {
110
- throw new ConformanceError('invalid_arguments', 'A command is required after --');
111
- }
112
- if (selected.length > 64 || selected.some(value => value.length > 16_384)) {
113
- throw new ConformanceError('invalid_arguments', 'The command is too large');
114
- }
115
- return { command: selected, reportPath, timeoutMs, cwd: process.cwd() };
116
- }
117
-
118
- function helpText() {
119
- return `Usage: node scripts/run-stdio-conformance.mjs [options] [-- executable arg ...]\n\n` +
120
- `Options:\n` +
121
- ` --report <path> Also write the JSON report to this path\n` +
122
- ` --timeout-ms <value> Timeout for each protocol operation (default: 30000)\n` +
123
- ` -h, --help Show this help\n\n` +
124
- `The default command is: node dist/cli.js bridge --stdio\n`;
125
- }
126
-
127
- class NdjsonRpcPeer {
128
- constructor(command, options) {
129
- this.timeoutMs = options.timeoutMs;
130
- this.pending = new Map();
131
- this.retiredIds = new Set();
132
- this.nextId = 1;
133
- this.stdoutBuffer = Buffer.alloc(0);
134
- this.stderrBytes = 0;
135
- this.notifications = 0;
136
- this.failure = undefined;
137
- this.exited = false;
138
-
139
- this.child = spawn(command[0], command.slice(1), {
140
- cwd: options.cwd,
141
- env: process.env,
142
- shell: false,
143
- stdio: ['pipe', 'pipe', 'pipe'],
144
- });
145
- this.exitPromise = new Promise(resolveExit => {
146
- this.child.once('exit', (code, signal) => {
147
- this.exited = true;
148
- const error = this.failure ?? new ConformanceError(
149
- 'bridge_exited',
150
- `Bridge exited before completing the suite (code ${code ?? 'null'}, signal ${signal ?? 'none'})`,
151
- );
152
- this.rejectPending(error);
153
- resolveExit({ code, signal });
154
- });
155
- });
156
- this.child.once('error', error => {
157
- this.fail(new ConformanceError('bridge_spawn_failed', `Unable to start bridge: ${bounded(error.message)}`));
158
- });
159
- this.child.stdout.on('data', chunk => this.onStdout(chunk));
160
- this.child.stdout.once('end', () => {
161
- if (this.stdoutBuffer.length > 0 && !this.failure) {
162
- this.fail(new ConformanceError('invalid_framing', 'Bridge stdout ended with an incomplete NDJSON line'));
163
- }
164
- });
165
- this.child.stderr.on('data', chunk => {
166
- this.stderrBytes = Math.min(MAX_STDERR_BYTES, this.stderrBytes + chunk.length);
167
- });
168
- this.child.stdin.on('error', error => {
169
- if (!this.exited) this.fail(new ConformanceError('transport_error', `Bridge stdin failed: ${bounded(error.message)}`));
170
- });
171
- }
172
-
173
- onStdout(chunk) {
174
- if (this.failure) return;
175
- this.stdoutBuffer = Buffer.concat([this.stdoutBuffer, chunk]);
176
- if (this.stdoutBuffer.length > MAX_STDOUT_LINE_BYTES && !this.stdoutBuffer.includes(0x0a)) {
177
- this.fail(new ConformanceError('invalid_framing', 'Bridge emitted an oversized NDJSON line'));
178
- return;
179
- }
180
- while (true) {
181
- const newline = this.stdoutBuffer.indexOf(0x0a);
182
- if (newline < 0) break;
183
- let line = this.stdoutBuffer.subarray(0, newline);
184
- this.stdoutBuffer = this.stdoutBuffer.subarray(newline + 1);
185
- if (line.at(-1) === 0x0d) line = line.subarray(0, -1);
186
- if (line.length === 0) {
187
- this.fail(new ConformanceError('invalid_framing', 'Bridge emitted an empty stdout line'));
188
- return;
189
- }
190
- if (line.length > MAX_STDOUT_LINE_BYTES) {
191
- this.fail(new ConformanceError('invalid_framing', 'Bridge emitted an oversized NDJSON line'));
192
- return;
193
- }
194
- try {
195
- this.onMessage(line);
196
- } catch (error) {
197
- this.fail(error instanceof ConformanceError
198
- ? error
199
- : new ConformanceError('invalid_jsonrpc', 'Bridge emitted an invalid JSON-RPC message'));
200
- }
201
- if (this.failure) return;
202
- }
203
- }
204
-
205
- onMessage(line) {
206
- let message;
207
- try {
208
- message = JSON.parse(utf8Decoder.decode(line));
209
- } catch {
210
- this.fail(new ConformanceError('invalid_json', 'Bridge stdout contained invalid UTF-8 or JSON'));
211
- return;
212
- }
213
- if (message === null || typeof message !== 'object' || Array.isArray(message) || message.jsonrpc !== '2.0') {
214
- this.fail(new ConformanceError('invalid_jsonrpc', 'Bridge emitted an invalid JSON-RPC envelope'));
215
- return;
216
- }
217
- if (typeof message.method === 'string' && !Object.hasOwn(message, 'id')) {
218
- this.notifications += 1;
219
- return;
220
- }
221
- if (!Object.hasOwn(message, 'id') || (Object.hasOwn(message, 'result') === Object.hasOwn(message, 'error'))) {
222
- this.fail(new ConformanceError('invalid_jsonrpc', 'Bridge stdout must contain responses or notifications only'));
223
- return;
224
- }
225
- const pending = this.pending.get(message.id);
226
- if (!pending) {
227
- if (this.retiredIds.delete(message.id)) return;
228
- this.fail(new ConformanceError('unexpected_response', 'Bridge returned an unknown JSON-RPC response id'));
229
- return;
230
- }
231
- this.pending.delete(message.id);
232
- clearTimeout(pending.timer);
233
- if (message.error !== undefined) {
234
- const error = asRecord(message.error, 'JSON-RPC error');
235
- const code = typeof error.data?.code === 'string' ? error.data.code : 'rpc_error';
236
- pending.reject(new ConformanceError(code, bounded(error.message ?? 'Bridge request failed'), {
237
- rpcCode: Number.isSafeInteger(error.code) ? error.code : undefined,
238
- }));
239
- return;
240
- }
241
- pending.resolve(message.result);
242
- }
243
-
244
- call(method, params = {}, timeoutMs = this.timeoutMs) {
245
- if (this.failure) return Promise.reject(this.failure);
246
- if (this.exited) return Promise.reject(new ConformanceError('bridge_exited', 'Bridge is not running'));
247
- const id = `conformance:${this.nextId++}`;
248
- return new Promise((resolveCall, rejectCall) => {
249
- const timer = setTimeout(() => {
250
- this.pending.delete(id);
251
- this.retiredIds.add(id);
252
- rejectCall(new ConformanceError('request_timeout', `${method} did not respond within ${timeoutMs} ms`));
253
- }, timeoutMs);
254
- this.pending.set(id, { resolve: resolveCall, reject: rejectCall, timer });
255
- const payload = `${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`;
256
- this.child.stdin.write(payload, error => {
257
- if (!error) return;
258
- const pending = this.pending.get(id);
259
- if (!pending) return;
260
- this.pending.delete(id);
261
- clearTimeout(timer);
262
- pending.reject(new ConformanceError('transport_error', `Unable to write ${method} request`));
263
- });
264
- });
265
- }
266
-
267
- fail(error) {
268
- if (this.failure) return;
269
- this.failure = error;
270
- this.rejectPending(error);
271
- }
272
-
273
- rejectPending(error) {
274
- for (const pending of this.pending.values()) {
275
- clearTimeout(pending.timer);
276
- pending.reject(error);
277
- }
278
- this.pending.clear();
279
- }
280
-
281
- async waitForExit(timeoutMs) {
282
- if (this.exited) return this.exitPromise;
283
- return new Promise(resolveExit => {
284
- const timer = setTimeout(() => resolveExit(undefined), timeoutMs);
285
- void this.exitPromise.then(result => {
286
- clearTimeout(timer);
287
- resolveExit(result);
288
- });
289
- });
290
- }
291
-
292
- async stop() {
293
- if (this.exited) return this.exitPromise;
294
- this.child.stdin.end();
295
- const exited = await this.waitForExit(1_000);
296
- if (exited) return exited;
297
- this.child.kill('SIGTERM');
298
- const terminated = await this.waitForExit(1_000);
299
- if (terminated) return terminated;
300
- this.child.kill('SIGKILL');
301
- return this.exitPromise;
302
- }
303
- }
304
-
305
- function validateCommandOutcome(value, method) {
306
- const outcome = asRecord(value, `${method} response`);
307
- const command = asRecord(outcome.command, `${method} command`);
308
- assertContract(command.method === method, `${method} returned a mismatched command method`);
309
- assertContract(command.status === 'completed', `${method} did not complete synchronously`);
310
- return asRecord(outcome.result, `${method} result`);
311
- }
312
-
313
- function commandParams(name, args, context, serial) {
314
- return {
315
- name,
316
- arguments: args,
317
- workspaceId: context.workspaceId,
318
- leaseId: context.leaseId,
319
- ...(context.targetId ? { targetId: context.targetId } : {}),
320
- commandId: `command:conformance-${serial}`,
321
- idempotencyKey: `conformance:${serial}`,
322
- deadlineMs: 30_000,
323
- };
324
- }
325
-
326
- function reportError(error, checkId) {
327
- return {
328
- checkId,
329
- code: bounded(error?.code ?? 'unexpected_error', 128),
330
- message: bounded(error instanceof Error ? error.message : error),
331
- ...(Number.isSafeInteger(error?.rpcCode) ? { rpcCode: error.rpcCode } : {}),
332
- };
333
- }
334
-
335
- async function runSuite(options) {
336
- const startedAt = Date.now();
337
- const checks = [];
338
- const state = {
339
- initialized: false,
340
- browserId: undefined,
341
- workspaceId: undefined,
342
- leaseId: undefined,
343
- targetId: undefined,
344
- artifactId: undefined,
345
- shutdown: false,
346
- };
347
- let currentCheck = 'spawn';
348
- let failure;
349
- let peer;
350
- let commandSerial = 1;
351
-
352
- const check = async (id, operation) => {
353
- currentCheck = id;
354
- const began = Date.now();
355
- try {
356
- const detail = await operation();
357
- checks.push({ id, status: 'passed', durationMs: Date.now() - began, ...(detail ? { detail } : {}) });
358
- return detail;
359
- } catch (error) {
360
- checks.push({ id, status: 'failed', durationMs: Date.now() - began });
361
- throw error;
362
- }
363
- };
364
- const tool = async (name, args, context) => validateCommandOutcome(
365
- await peer.call('tools/call', commandParams(name, args, context, commandSerial++)),
366
- name,
367
- );
368
- const cleanup = async (id, operation) => {
369
- if (!peer || peer.exited) return;
370
- const began = Date.now();
371
- try {
372
- await operation();
373
- checks.push({ id, status: 'passed', durationMs: Date.now() - began });
374
- } catch (error) {
375
- checks.push({ id, status: 'failed', durationMs: Date.now() - began });
376
- failure ??= reportError(error, id);
377
- }
378
- };
379
-
380
- try {
381
- peer = new NdjsonRpcPeer(options.command, { timeoutMs: options.timeoutMs, cwd: options.cwd });
382
-
383
- await check('initialize', async () => {
384
- const initialized = asRecord(await peer.call('initialize', {
385
- client: {
386
- id: 'org.browser-pilot.conformance',
387
- name: 'Browser Pilot Stdio Conformance',
388
- version: SUITE_VERSION,
389
- instanceId: `run:${process.pid}-${startedAt}`,
390
- },
391
- protocol: { min: { major: 1, minor: 0 }, max: { major: 1, minor: 1 } },
392
- requestedCapabilities: REQUIRED_CAPABILITIES,
393
- launchMode: 'embedded',
394
- }), 'initialize result');
395
- const protocol = asRecord(initialized.protocol, 'initialize protocol');
396
- assertContract(protocol.major === 1 && Number.isSafeInteger(protocol.minor), 'Bridge selected an unsupported protocol');
397
- nonEmptyString(initialized.serviceVersion, 'serviceVersion');
398
- nonEmptyString(initialized.executableVersion, 'executableVersion');
399
- nonEmptyString(initialized.brokerProcessIdentity, 'brokerProcessIdentity');
400
- nonEmptyString(initialized.connectionId, 'connectionId');
401
- const capabilities = asRecord(initialized.capabilities, 'capability negotiation');
402
- const limits = asRecord(initialized.limits, 'initialize limits');
403
- assertContract(Array.isArray(capabilities.granted), 'initialize must return granted capabilities');
404
- for (const capability of REQUIRED_CAPABILITIES) {
405
- assertContract(capabilities.granted.includes(capability), `Required capability was not granted: ${capability}`);
406
- }
407
- assertContract(Array.isArray(initialized.browsers) && initialized.browsers.length > 0,
408
- 'No browser candidate was advertised');
409
- state.browserId = nonEmptyString(initialized.browsers[0]?.id, 'browser candidate id');
410
- assertContract(
411
- Number.isSafeInteger(limits.maxMessageBytes) && limits.maxMessageBytes > 0 &&
412
- Number.isSafeInteger(limits.maxResultBytes) && limits.maxResultBytes > 0 &&
413
- Number.isSafeInteger(limits.maxArtifactBytes) && limits.maxArtifactBytes > 0 &&
414
- Number.isSafeInteger(limits.eventJournalSize) && limits.eventJournalSize > 0,
415
- 'initialize returned invalid protocol limits',
416
- );
417
- state.initialized = true;
418
- return {
419
- protocol: `${protocol.major}.${protocol.minor}`,
420
- serviceVersion: bounded(initialized.serviceVersion, 64),
421
- executableVersion: bounded(initialized.executableVersion, 64),
422
- };
423
- });
424
-
425
- await check('tool_manifest', async () => {
426
- const manifest = asRecord(await peer.call('tools/list', {}), 'tool manifest');
427
- assertContract(manifest.schemaVersion === 1, 'Unsupported tool manifest schemaVersion');
428
- assertContract(Array.isArray(manifest.tools), 'Tool manifest must contain tools');
429
- const tools = new Map(manifest.tools.map(entry => [entry?.name, entry]));
430
- for (const name of REQUIRED_TOOLS) {
431
- const entry = asRecord(tools.get(name), `${name} manifest entry`);
432
- assertContract(entry.context === REQUIRED_TOOL_CONTEXTS.get(name), `${name} has an invalid context`);
433
- asRecord(entry.inputSchema, `${name} inputSchema`);
434
- asRecord(entry.outputSchema, `${name} outputSchema`);
435
- }
436
- return { toolCount: manifest.tools.length };
437
- });
438
-
439
- let eventCursor;
440
- await check('workspace_create', async () => {
441
- const created = asRecord(await peer.call('workspaces/create', {
442
- browserId: state.browserId,
443
- }), 'Workspace result');
444
- const workspace = asRecord(created.workspace, 'Workspace');
445
- state.workspaceId = nonEmptyString(workspace.id, 'Workspace id');
446
- assertContract(workspace.state === 'active', 'Created Workspace is not active');
447
- eventCursor = nonEmptyString(created.eventCursor, 'eventCursor');
448
- const tabSet = asRecord(created.managedTabSet, 'ManagedTabSet');
449
- assertContract(tabSet.workspaceId === state.workspaceId, 'ManagedTabSet belongs to another Workspace');
450
- });
451
-
452
- await check('lease_create_and_heartbeat', async () => {
453
- const created = asRecord(await peer.call('leases/create', {
454
- workspaceId: state.workspaceId,
455
- ttlMs: 60_000,
456
- }), 'Lease result');
457
- const lease = asRecord(created.lease, 'Lease');
458
- state.leaseId = nonEmptyString(lease.id, 'Lease id');
459
- assertContract(lease.workspaceId === state.workspaceId && lease.state === 'active', 'Lease is not active for the Workspace');
460
- const heartbeat = asRecord(await peer.call('leases/heartbeat', {
461
- leaseId: state.leaseId,
462
- ttlMs: 60_000,
463
- }), 'heartbeat result');
464
- const renewed = asRecord(heartbeat.lease, 'renewed Lease');
465
- assertContract(renewed.id === state.leaseId && renewed.state === 'active', 'Heartbeat did not renew the Lease');
466
- });
467
-
468
- await check('browser_connect', async () => {
469
- const connected = await tool('browser.connect', { browserId: state.browserId }, state);
470
- assertContract(connected.state === 'connected', 'browser.connect did not establish the browser connection');
471
- assertContract(Number.isSafeInteger(connected.connectionGeneration) && connected.connectionGeneration > 0,
472
- 'browser.connect returned an invalid connection generation');
473
- });
474
-
475
- await check('managed_target_open', async () => {
476
- const opened = await tool('browser.open', {
477
- url: 'about:blank',
478
- newTarget: true,
479
- observationLimit: 10,
480
- }, state);
481
- state.targetId = nonEmptyString(opened.targetId, 'browser.open targetId');
482
- nonEmptyString(opened.observationId, 'browser.open observationId');
483
- assertContract(opened.workspaceId === state.workspaceId && opened.leaseId === state.leaseId, 'browser.open returned foreign context');
484
- assertContract(opened.url === 'about:blank', 'Managed target did not open about:blank');
485
-
486
- const listed = await tool('browser.tabs.list', { scope: 'managed_only' }, state);
487
- assertContract(Array.isArray(listed.targets), 'browser.tabs.list must return targets');
488
- assertContract(listed.targets.length === 1, 'A new Workspace must list exactly its one conformance managed target');
489
- assertContract(listed.targets.every(candidate => candidate?.origin === 'managed'), 'managed_only inventory returned a non-managed target');
490
- const target = listed.targets.find(candidate => candidate?.targetId === state.targetId);
491
- assertContract(target?.origin === 'managed', 'Created target is not present as a managed target');
492
- return { targetOrigin: 'managed' };
493
- });
494
-
495
- await check('observation', async () => {
496
- const observed = await tool('browser.observe', { limit: 10 }, state);
497
- nonEmptyString(observed.observationId, 'Observation id');
498
- assertContract(observed.targetId === state.targetId, 'Observation belongs to another target');
499
- assertContract(Array.isArray(observed.elements), 'Observation elements must be an array');
500
- assertContract(typeof observed.truncated === 'boolean', 'Observation must report truncation state');
501
- return { elementCount: observed.elements.length, truncated: observed.truncated };
502
- });
503
-
504
- let artifactPath;
505
- await check('screenshot_artifact', async () => {
506
- const captured = await tool('browser.capture', { fullPage: false }, state);
507
- const artifact = asRecord(captured.artifact, 'screenshot Artifact');
508
- state.artifactId = nonEmptyString(artifact.id, 'Artifact id');
509
- assertContract(artifact.kind === 'screenshot' || artifact.kind === 'screenshot_preview', 'Capture returned a non-screenshot Artifact');
510
- assertContract(artifact.mimeType === 'image/png', 'Screenshot Artifact must use image/png');
511
- assertContract(artifact.workspaceId === state.workspaceId, 'Screenshot Artifact belongs to another Workspace');
512
- assertContract(Number.isSafeInteger(artifact.byteSize) && artifact.byteSize > 0, 'Screenshot Artifact has an invalid byteSize');
513
-
514
- const accessed = asRecord(await peer.call('artifacts/get', {
515
- workspaceId: state.workspaceId,
516
- leaseId: state.leaseId,
517
- artifactId: state.artifactId,
518
- }), 'Artifact access result');
519
- const accessedArtifact = asRecord(accessed.artifact, 'accessed Artifact');
520
- assertContract(
521
- accessedArtifact.id === state.artifactId &&
522
- accessedArtifact.workspaceId === state.workspaceId &&
523
- accessedArtifact.mimeType === 'image/png' &&
524
- accessedArtifact.byteSize === artifact.byteSize,
525
- 'Artifact access descriptor does not match the captured Artifact',
526
- );
527
- artifactPath = nonEmptyString(accessed.path, 'Artifact path');
528
- assertContract(isAbsolute(artifactPath), 'Artifact access path must be absolute');
529
- const bytes = await readFile(artifactPath);
530
- assertContract(bytes.length === artifact.byteSize, 'Artifact bytes do not match descriptor byteSize');
531
- assertContract(bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])), 'Artifact is not a PNG file');
532
- return { byteSize: bytes.length, mimeType: artifact.mimeType };
533
- });
534
-
535
- await check('artifact_release', async () => {
536
- const released = asRecord(await peer.call('artifacts/release', {
537
- workspaceId: state.workspaceId,
538
- leaseId: state.leaseId,
539
- artifactId: state.artifactId,
540
- }), 'Artifact release result');
541
- assertContract(released.released === true && released.artifactId === state.artifactId, 'Artifact release was not confirmed');
542
- try {
543
- await peer.call('artifacts/get', {
544
- workspaceId: state.workspaceId,
545
- leaseId: state.leaseId,
546
- artifactId: state.artifactId,
547
- });
548
- throw new ConformanceError('invalid_contract', 'Released Artifact remained accessible');
549
- } catch (error) {
550
- if (error?.code !== 'artifact_not_found') throw error;
551
- }
552
- try {
553
- await readFile(artifactPath);
554
- throw new ConformanceError('invalid_contract', 'Released Artifact bytes remained on disk');
555
- } catch (error) {
556
- if (error?.code !== 'ENOENT') throw error;
557
- }
558
- state.artifactId = undefined;
559
- });
560
-
561
- await check('event_replay', async () => {
562
- const polled = asRecord(await peer.call('events/poll', {
563
- workspaceId: state.workspaceId,
564
- cursor: eventCursor,
565
- limit: 100,
566
- }), 'events/poll result');
567
- assertContract(Array.isArray(polled.events), 'events/poll must return events');
568
- nonEmptyString(polled.nextCursor, 'nextCursor');
569
- assertContract(typeof polled.hasMore === 'boolean', 'events/poll must return hasMore');
570
- assertContract(polled.events.length > 0, 'No command events were replayed');
571
- let previousSequence = 0;
572
- for (const value of polled.events) {
573
- const event = asRecord(value, 'BrowserEvent');
574
- assertContract(event.workspaceId === state.workspaceId, 'Event belongs to another Workspace');
575
- assertContract(Number.isSafeInteger(event.sequence) && event.sequence > previousSequence, 'Events are not strictly ordered');
576
- previousSequence = event.sequence;
577
- }
578
- return { eventCount: polled.events.length, hasMore: polled.hasMore };
579
- });
580
-
581
- await check('managed_target_close', async () => {
582
- const closed = await tool('browser.tabs.close', {}, state);
583
- assertContract(closed.closedTargetId === state.targetId, 'The managed target was not closed');
584
- state.targetId = undefined;
585
- });
586
-
587
- await check('lease_and_workspace_release', async () => {
588
- const lease = asRecord(await peer.call('leases/release', { leaseId: state.leaseId }), 'Lease release result');
589
- assertContract(lease.released === true && lease.leaseId === state.leaseId, 'Lease release was not confirmed');
590
- state.leaseId = undefined;
591
- const workspace = asRecord(await peer.call('workspaces/release', { workspaceId: state.workspaceId }), 'Workspace release result');
592
- assertContract(workspace.released === true && workspace.workspaceId === state.workspaceId, 'Workspace release was not confirmed');
593
- state.workspaceId = undefined;
594
- });
595
-
596
- await check('shutdown', async () => {
597
- const shutdown = asRecord(await peer.call('shutdown', {}), 'shutdown result');
598
- assertContract(shutdown.ok === true, 'Bridge did not acknowledge shutdown');
599
- state.shutdown = true;
600
- const exited = await peer.waitForExit(Math.min(options.timeoutMs, 5_000));
601
- assertContract(exited !== undefined, 'Bridge did not exit after shutdown');
602
- assertContract(exited.code === 0 && exited.signal === null, 'Bridge exited unsuccessfully after shutdown');
603
- });
604
- } catch (error) {
605
- failure = reportError(error, currentCheck);
606
- } finally {
607
- if (state.artifactId && state.workspaceId && state.leaseId) {
608
- await cleanup('cleanup_artifact', async () => {
609
- await peer.call('artifacts/release', {
610
- workspaceId: state.workspaceId,
611
- leaseId: state.leaseId,
612
- artifactId: state.artifactId,
613
- });
614
- state.artifactId = undefined;
615
- });
616
- }
617
- if (state.targetId && state.workspaceId && state.leaseId) {
618
- await cleanup('cleanup_managed_target', async () => {
619
- await tool('browser.tabs.close', {}, state);
620
- state.targetId = undefined;
621
- });
622
- }
623
- if (state.leaseId) {
624
- await cleanup('cleanup_lease', async () => {
625
- await peer.call('leases/release', { leaseId: state.leaseId });
626
- state.leaseId = undefined;
627
- });
628
- }
629
- if (state.workspaceId) {
630
- await cleanup('cleanup_workspace', async () => {
631
- await peer.call('workspaces/release', { workspaceId: state.workspaceId });
632
- state.workspaceId = undefined;
633
- });
634
- }
635
- if (peer && state.initialized && !state.shutdown && !peer.exited) {
636
- await cleanup('cleanup_shutdown', async () => {
637
- await peer.call('shutdown', {});
638
- state.shutdown = true;
639
- });
640
- }
641
- if (peer) await peer.stop();
642
- }
643
-
644
- return {
645
- schemaVersion: REPORT_SCHEMA_VERSION,
646
- suite: 'browser-pilot-stdio-conformance',
647
- suiteVersion: SUITE_VERSION,
648
- outcome: failure ? 'failed' : 'passed',
649
- command: {
650
- executable: bounded(basename(options.command[0]), 128),
651
- argumentCount: options.command.length - 1,
652
- },
653
- checks,
654
- transport: {
655
- notificationsReceived: peer?.notifications ?? 0,
656
- stderrBytesObserved: peer?.stderrBytes ?? 0,
657
- },
658
- durationMs: Date.now() - startedAt,
659
- ...(failure ? { failure } : {}),
660
- };
661
- }
662
-
663
- async function writeReport(report, reportPath) {
664
- const serialized = `${JSON.stringify(report, null, 2)}\n`;
665
- if (reportPath) {
666
- await mkdir(dirname(reportPath), { recursive: true });
667
- await writeFile(reportPath, serialized, { encoding: 'utf8', mode: 0o600 });
668
- }
669
- process.stdout.write(serialized);
670
- }
671
-
672
- let options;
673
- try {
674
- options = parseArguments(process.argv.slice(2));
675
- if (options.help) {
676
- process.stdout.write(helpText());
677
- } else {
678
- const report = await runSuite(options);
679
- await writeReport(report, options.reportPath);
680
- process.exitCode = report.outcome === 'passed' ? 0 : 1;
681
- }
682
- } catch (error) {
683
- const report = {
684
- schemaVersion: REPORT_SCHEMA_VERSION,
685
- suite: 'browser-pilot-stdio-conformance',
686
- suiteVersion: SUITE_VERSION,
687
- outcome: 'failed',
688
- checks: [],
689
- durationMs: 0,
690
- failure: reportError(error, options ? 'report_write' : 'runner_arguments'),
691
- };
692
- try {
693
- await writeReport(report, options?.reportPath);
694
- } catch {
695
- process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
696
- }
697
- process.exitCode = 2;
698
- }