@cardstack/boxel-cli 0.2.0-unstable.298 → 0.2.0-unstable.327

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.
@@ -0,0 +1,728 @@
1
+ import type { Command } from 'commander';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { readFileSync, statSync } from 'node:fs';
4
+ import { createServer, type Server } from 'node:http';
5
+ import { dirname, join, normalize, resolve } from 'node:path';
6
+
7
+ import { ensureTrailingSlash } from '@cardstack/runtime-common/paths';
8
+
9
+ import {
10
+ getProfileManager,
11
+ NO_ACTIVE_PROFILE_ERROR,
12
+ type ProfileManager,
13
+ } from '../lib/profile-manager';
14
+ import { FG_RED, FG_GREEN, DIM, RESET } from '../lib/colors';
15
+ import { cliLog } from '../lib/cli-log';
16
+ import { findBoxelCliRoot } from '../lib/find-package-root';
17
+ import { listFiles } from './file/list';
18
+
19
+ // `@playwright/test` is a devDependency and external in our esbuild
20
+ // config, so it's not present in a published-from-npm install. Anything
21
+ // loaded at the top of this module would crash `boxel --help` for end
22
+ // users who never run `boxel test`. Resolved lazily inside the runner
23
+ // instead.
24
+ type ChromiumApi = (typeof import('@playwright/test'))['chromium'];
25
+
26
+ async function loadChromium(): Promise<ChromiumApi> {
27
+ try {
28
+ let mod = (await import('@playwright/test')) as {
29
+ chromium: ChromiumApi;
30
+ };
31
+ return mod.chromium;
32
+ } catch (err) {
33
+ let message = err instanceof Error ? err.message : String(err);
34
+ throw new Error(
35
+ `Could not load @playwright/test (${message}). \`boxel test\` ` +
36
+ 'is monorepo-only — install Playwright in the boxel-cli package ' +
37
+ 'via `pnpm --filter @cardstack/boxel-cli install` and run ' +
38
+ '`npx playwright install chromium` once.',
39
+ );
40
+ }
41
+ }
42
+
43
+ /**
44
+ * `boxel test` runs the realm's QUnit test suite by driving a
45
+ * headless Chromium instance against the host app's compiled test
46
+ * bundle. Lifted from
47
+ * `packages/software-factory/src/test-run-execution.ts` (the
48
+ * `runTestsInMemory` path) during CS-11149 so the same engine is
49
+ * reachable from a subscription-billed Claude Code session via Bash.
50
+ *
51
+ * Like `boxel parse`, this is a monorepo-only command — it locates
52
+ * the host app's `dist/` (test bundles + assets) via either
53
+ * `TEST_HARNESS_HOST_DIST_PACKAGE_DIR`, the sibling `packages/host`
54
+ * directory, or the root repo's `packages/host` directory when run
55
+ * from a git worktree. It does not work in the published CLI.
56
+ *
57
+ * Unlike the factory's `executeTestRunFromRealm`, this command does
58
+ * NOT create or update a TestRun card — it returns in-memory results
59
+ * only. Card persistence is the agent's job in the new Phase 1 flow.
60
+ */
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Types
64
+ // ---------------------------------------------------------------------------
65
+
66
+ interface QunitTestResult {
67
+ name: string;
68
+ module: string;
69
+ status: 'passed' | 'failed' | 'skipped' | 'todo';
70
+ runtime: number;
71
+ errors: { message: string; stack?: string }[];
72
+ }
73
+
74
+ interface QunitRunSummary {
75
+ status: 'passed' | 'failed';
76
+ testCounts: {
77
+ passed: number;
78
+ failed: number;
79
+ skipped: number;
80
+ todo: number;
81
+ total: number;
82
+ };
83
+ runtime: number;
84
+ }
85
+
86
+ interface QunitResults {
87
+ tests: QunitTestResult[];
88
+ runEnd: QunitRunSummary | null;
89
+ }
90
+
91
+ export interface TestFailure {
92
+ testName: string;
93
+ module: string;
94
+ message: string;
95
+ stackTrace?: string;
96
+ }
97
+
98
+ export interface RunTestsResult {
99
+ status: 'passed' | 'failed' | 'error';
100
+ passedCount: number;
101
+ failedCount: number;
102
+ skippedCount: number;
103
+ durationMs: number;
104
+ /** Realm-relative `.test.gts` paths discovered before the run. */
105
+ testFiles: string[];
106
+ failures: TestFailure[];
107
+ /** Set only when `status === 'error'`. */
108
+ errorMessage?: string;
109
+ }
110
+
111
+ export interface RunTestsOptions {
112
+ /**
113
+ * URL of the host app served by the realm-server compat proxy.
114
+ * Defaults to the realm server URL from the active profile, which
115
+ * is what the dev `mise run dev-all` stack exposes.
116
+ */
117
+ hostAppUrl?: string;
118
+ /** Path to the host app's dist directory; auto-discovered otherwise. */
119
+ hostDistDir?: string;
120
+ /** Stream browser console output to stderr for debugging. */
121
+ debug?: boolean;
122
+ profileManager?: ProfileManager;
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Public entry point
127
+ // ---------------------------------------------------------------------------
128
+
129
+ export async function runTestsForRealm(
130
+ realmUrl: string,
131
+ options?: RunTestsOptions,
132
+ ): Promise<RunTestsResult> {
133
+ let pm = options?.profileManager ?? getProfileManager();
134
+ let active = pm.getActiveProfile();
135
+ if (!active) {
136
+ return emptyErrorResult(NO_ACTIVE_PROFILE_ERROR);
137
+ }
138
+
139
+ let normalizedRealmUrl = ensureTrailingSlash(realmUrl);
140
+ let hostAppUrl = ensureTrailingSlash(
141
+ options?.hostAppUrl ?? active.profile.realmServerUrl,
142
+ );
143
+
144
+ let testFiles: string[];
145
+ try {
146
+ let listing = await listFiles(normalizedRealmUrl, { profileManager: pm });
147
+ if (listing.error) {
148
+ return emptyErrorResult(
149
+ `Failed to discover test files: ${listing.error}`,
150
+ );
151
+ }
152
+ testFiles = listing.filenames.filter((f) => f.endsWith('.test.gts'));
153
+ } catch (err) {
154
+ return emptyErrorResult(
155
+ `Failed to discover test files: ${err instanceof Error ? err.message : String(err)}`,
156
+ );
157
+ }
158
+
159
+ if (testFiles.length === 0) {
160
+ // A realm with no `*.test.gts` files is treated as a validator
161
+ // failure: factory Issues are supposed to ship with tests, and a
162
+ // silent "passed" would let an agent mark an Issue done without
163
+ // ever writing one.
164
+ return {
165
+ status: 'failed',
166
+ passedCount: 0,
167
+ failedCount: 0,
168
+ skippedCount: 0,
169
+ durationMs: 0,
170
+ testFiles: [],
171
+ failures: [],
172
+ errorMessage:
173
+ 'No `*.test.gts` files found in the realm. ' +
174
+ 'Every implementation Issue must ship with at least one test file.',
175
+ };
176
+ }
177
+
178
+ try {
179
+ let { qunitResults, durationMs } = await runQunitInBrowser({
180
+ pm,
181
+ targetRealm: normalizedRealmUrl,
182
+ hostAppUrl,
183
+ hostDistDir: options?.hostDistDir,
184
+ debug: options?.debug,
185
+ });
186
+
187
+ let summary = summarizeQunitResults(qunitResults);
188
+ return {
189
+ ...summary,
190
+ durationMs,
191
+ testFiles,
192
+ };
193
+ } catch (err) {
194
+ let errorMessage = err instanceof Error ? err.message : String(err);
195
+ return {
196
+ status: 'error',
197
+ passedCount: 0,
198
+ failedCount: 0,
199
+ skippedCount: 0,
200
+ durationMs: 0,
201
+ testFiles,
202
+ failures: [],
203
+ errorMessage,
204
+ };
205
+ }
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // QUnit Runner
210
+ // ---------------------------------------------------------------------------
211
+
212
+ interface QunitRunnerOptions {
213
+ pm: ProfileManager;
214
+ targetRealm: string;
215
+ hostAppUrl: string;
216
+ hostDistDir?: string;
217
+ debug?: boolean;
218
+ }
219
+
220
+ async function runQunitInBrowser(options: QunitRunnerOptions): Promise<{
221
+ qunitResults: QunitResults;
222
+ durationMs: number;
223
+ }> {
224
+ let start = Date.now();
225
+ let browser;
226
+ let testPageServer: Server | undefined;
227
+
228
+ try {
229
+ let hostDistDir =
230
+ options.hostDistDir ??
231
+ join(
232
+ findHostDistPackageDir() ??
233
+ join(resolve(findBoxelCliRoot(__dirname), '..'), 'host'),
234
+ 'dist',
235
+ );
236
+
237
+ if (!fileExists(join(hostDistDir, 'tests', 'index.html'))) {
238
+ throw new Error(
239
+ `Host app dist not found at ${hostDistDir}. Build the host app (e.g., \`pnpm --filter @cardstack/host build\`) or set TEST_HARNESS_HOST_DIST_PACKAGE_DIR.`,
240
+ );
241
+ }
242
+
243
+ let {
244
+ url: testPageUrl,
245
+ server,
246
+ setHtml,
247
+ } = await startTestPageServer(hostDistDir);
248
+ testPageServer = server;
249
+
250
+ let html = buildQunitTestPageHtml({
251
+ assetServerUrl: testPageUrl,
252
+ hostDistDir,
253
+ realmProxyUrl: options.hostAppUrl,
254
+ });
255
+ setHtml(html);
256
+
257
+ let chromium = await loadChromium();
258
+ browser = await chromium.launch({ headless: true });
259
+ let page = await browser.newPage();
260
+
261
+ if (options.debug) {
262
+ page.on('console', (msg) => {
263
+ process.stderr.write(`[browser ${msg.type()}] ${msg.text()}\n`);
264
+ });
265
+ page.on('pageerror', (err) => {
266
+ process.stderr.write(`[browser pageerror] ${err.message}\n`);
267
+ });
268
+ }
269
+
270
+ let realmToken = options.pm.getRealmToken(options.targetRealm);
271
+ if (realmToken) {
272
+ let realmOrigin = new URL(options.targetRealm).origin;
273
+ await page.route(`${realmOrigin}/**`, (route) => {
274
+ let headers = {
275
+ ...route.request().headers(),
276
+ Authorization: realmToken!,
277
+ };
278
+ route.continue({ headers });
279
+ });
280
+ }
281
+
282
+ let realmParam = encodeURIComponent(options.targetRealm);
283
+ let pageUrl = `${testPageUrl}?liveTest=true&realmURL=${realmParam}&hidepassed`;
284
+
285
+ await page.goto(pageUrl, { waitUntil: 'domcontentloaded' });
286
+ await page.waitForFunction(
287
+ () =>
288
+ (window as unknown as { __qunitResults?: { runEnd: unknown } })
289
+ .__qunitResults?.runEnd !== null,
290
+ null,
291
+ { timeout: 300_000 },
292
+ );
293
+
294
+ let qunitResults = (await page.evaluate(
295
+ () =>
296
+ (window as unknown as { __qunitResults: QunitResults }).__qunitResults,
297
+ )) as QunitResults;
298
+
299
+ return { qunitResults, durationMs: Date.now() - start };
300
+ } finally {
301
+ if (browser) {
302
+ await browser.close().catch(() => {});
303
+ }
304
+ if (testPageServer) {
305
+ testPageServer.close();
306
+ }
307
+ }
308
+ }
309
+
310
+ // ---------------------------------------------------------------------------
311
+ // Test page HTML + asset server
312
+ // ---------------------------------------------------------------------------
313
+
314
+ function buildQunitTestPageHtml(opts: {
315
+ assetServerUrl: string;
316
+ hostDistDir: string;
317
+ realmProxyUrl: string;
318
+ }): string {
319
+ let host = opts.assetServerUrl.replace(/\/$/, '');
320
+ let browserOrigin = opts.realmProxyUrl.replace(/\/$/, '');
321
+
322
+ let testIndexPath = resolve(opts.hostDistDir, 'tests', 'index.html');
323
+ let testIndexHtml: string;
324
+ try {
325
+ testIndexHtml = readFileSync(testIndexPath, 'utf8');
326
+ } catch {
327
+ throw new Error(
328
+ `Could not read host test page at ${testIndexPath}. Build the host app with test support.`,
329
+ );
330
+ }
331
+
332
+ let metaTags = (testIndexHtml.match(/<meta[^>]+>/g) ?? [])
333
+ .filter((tag) => !tag.includes('charset') && !tag.includes('viewport'))
334
+ .map((tag) => {
335
+ if (!tag.includes('config/environment')) return tag;
336
+ let match = tag.match(/content="([^"]+)"/);
337
+ if (!match) return tag;
338
+ try {
339
+ let config = JSON.parse(decodeURIComponent(match[1]));
340
+ if (config.resolvedBaseRealmURL) {
341
+ config.resolvedBaseRealmURL = `${browserOrigin}/base/`;
342
+ }
343
+ if (config.resolvedSkillsRealmURL) {
344
+ config.resolvedSkillsRealmURL = `${browserOrigin}/skills/`;
345
+ }
346
+ if (config.resolvedOpenRouterRealmURL) {
347
+ config.resolvedOpenRouterRealmURL = `${browserOrigin}/openrouter/`;
348
+ }
349
+ if (config.realmServerURL) {
350
+ config.realmServerURL = `${browserOrigin}/`;
351
+ }
352
+ let encoded = encodeURIComponent(JSON.stringify(config));
353
+ return tag.replace(/content="[^"]+"/, `content="${encoded}"`);
354
+ } catch {
355
+ return tag;
356
+ }
357
+ });
358
+
359
+ let scriptTags = (
360
+ testIndexHtml.match(/<script[^>]*src="[^"]*"[^>]*><\/script>/g) ?? []
361
+ )
362
+ .filter(
363
+ (tag) =>
364
+ !tag.includes('testem.js') && !tag.includes('ember-cli-live-reload'),
365
+ )
366
+ .map((tag) => tag.replace(/src="\/([^"]*)"/g, `src="${host}/$1"`));
367
+
368
+ let linkTags = (
369
+ testIndexHtml.match(/<link[^>]*rel="stylesheet"[^>]*>/g) ?? []
370
+ ).map((tag) => tag.replace(/href="\/([^"]*)"/g, `href="${host}/$1"`));
371
+
372
+ let moduleScripts = (
373
+ testIndexHtml.match(/<script type="module">[^]*?<\/script>/g) ?? []
374
+ ).map((tag) => tag.replace(/from '\/([^']*)'/g, `from '${host}/$1'`));
375
+
376
+ return `<!DOCTYPE html>
377
+ <html>
378
+ <head>
379
+ <meta charset="utf-8">
380
+ ${metaTags.join('\n ')}
381
+ <title>Boxel realm tests</title>
382
+ ${linkTags.join('\n ')}
383
+ </head>
384
+ <body>
385
+ <div id="qunit"></div>
386
+ <div id="qunit-fixture">
387
+ <div id="ember-testing-container">
388
+ <div id="ember-testing"></div>
389
+ </div>
390
+ </div>
391
+
392
+ <script>
393
+ globalThis.process = { env: {}, version: '', cwd() { return '/'; } };
394
+ globalThis.global = globalThis;
395
+
396
+ window.__qunitResults = { tests: [], runEnd: null };
397
+ (function attachQUnitHooks() {
398
+ if (typeof QUnit !== 'undefined') {
399
+ QUnit.on('testEnd', function(d) {
400
+ window.__qunitResults.tests.push({
401
+ name: d.name, module: d.module, status: d.status,
402
+ runtime: d.runtime,
403
+ errors: (d.errors || []).map(function(e) {
404
+ return { message: e.message, stack: e.stack };
405
+ }),
406
+ });
407
+ });
408
+ QUnit.on('runEnd', function(d) {
409
+ window.__qunitResults.runEnd = d;
410
+ });
411
+ } else {
412
+ setTimeout(attachQUnitHooks, 10);
413
+ }
414
+ })();
415
+ </script>
416
+
417
+ ${moduleScripts.join('\n ')}
418
+ ${scriptTags.join('\n ')}
419
+ </body>
420
+ </html>`;
421
+ }
422
+
423
+ async function startTestPageServer(hostDistDir: string): Promise<{
424
+ url: string;
425
+ server: Server;
426
+ setHtml: (h: string) => void;
427
+ }> {
428
+ let mimeTypes: Record<string, string> = {
429
+ '.js': 'application/javascript',
430
+ '.css': 'text/css',
431
+ '.map': 'application/json',
432
+ '.html': 'text/html',
433
+ '.wasm': 'application/wasm',
434
+ '.svg': 'image/svg+xml',
435
+ '.png': 'image/png',
436
+ '.woff2': 'font/woff2',
437
+ '.woff': 'font/woff',
438
+ '.ttf': 'font/ttf',
439
+ };
440
+
441
+ let html = '';
442
+ let setHtml = (h: string) => {
443
+ html = h;
444
+ };
445
+
446
+ return new Promise((res, rej) => {
447
+ let server = createServer((req, reply) => {
448
+ let url = (req.url ?? '/').split('?')[0];
449
+
450
+ if (url !== '/') {
451
+ let normalized = normalize(url.slice(1));
452
+ if (normalized.startsWith('..') || normalized.startsWith('/')) {
453
+ reply.writeHead(403);
454
+ reply.end('Forbidden');
455
+ return;
456
+ }
457
+ let filePath = resolve(hostDistDir, normalized);
458
+ if (!filePath.startsWith(resolve(hostDistDir))) {
459
+ reply.writeHead(403);
460
+ reply.end('Forbidden');
461
+ return;
462
+ }
463
+ try {
464
+ let content = readFileSync(filePath);
465
+ let ext = filePath.match(/\.[^.]+$/)?.[0] ?? '';
466
+ let contentType = mimeTypes[ext] ?? 'application/octet-stream';
467
+ reply.writeHead(200, {
468
+ 'Content-Type': contentType,
469
+ 'Access-Control-Allow-Origin': '*',
470
+ });
471
+ reply.end(content);
472
+ } catch {
473
+ reply.writeHead(404);
474
+ reply.end('Not found');
475
+ }
476
+ return;
477
+ }
478
+
479
+ reply.writeHead(200, {
480
+ 'Content-Type': 'text/html',
481
+ 'Access-Control-Allow-Origin': '*',
482
+ });
483
+ reply.end(html);
484
+ });
485
+ server.on('error', rej);
486
+ server.listen(0, '127.0.0.1', () => {
487
+ let addr = server.address();
488
+ if (!addr || typeof addr === 'string') {
489
+ rej(new Error('Failed to start test page server'));
490
+ return;
491
+ }
492
+ res({ url: `http://127.0.0.1:${addr.port}`, server, setHtml });
493
+ });
494
+ });
495
+ }
496
+
497
+ // ---------------------------------------------------------------------------
498
+ // Host dist discovery — inlined from @cardstack/realm-test-harness
499
+ // ---------------------------------------------------------------------------
500
+
501
+ function fileExists(path: string): boolean {
502
+ try {
503
+ return statSync(path).isFile();
504
+ } catch {
505
+ return false;
506
+ }
507
+ }
508
+
509
+ function findHostDistPackageDir(): string | undefined {
510
+ let packageRoot = findBoxelCliRoot(__dirname);
511
+ let packagesDir = resolve(packageRoot, '..');
512
+ let workspaceRoot = resolve(packagesDir, '..');
513
+ let hostDir = join(packagesDir, 'host');
514
+
515
+ let rootRepoCheckoutDir = findRootRepoCheckoutDir(workspaceRoot);
516
+ let rootRepoHostDir =
517
+ rootRepoCheckoutDir && rootRepoCheckoutDir !== workspaceRoot
518
+ ? resolve(rootRepoCheckoutDir, 'packages', 'host')
519
+ : undefined;
520
+
521
+ let candidates = [
522
+ process.env.TEST_HARNESS_HOST_DIST_PACKAGE_DIR,
523
+ hostDir,
524
+ rootRepoHostDir,
525
+ ]
526
+ .filter((value): value is string => Boolean(value))
527
+ .map((value) => resolve(value));
528
+
529
+ let seen = new Set<string>();
530
+ for (let candidate of candidates) {
531
+ if (seen.has(candidate)) continue;
532
+ seen.add(candidate);
533
+ if (fileExists(join(candidate, 'dist', 'index.html'))) {
534
+ return candidate;
535
+ }
536
+ }
537
+ return undefined;
538
+ }
539
+
540
+ function findRootRepoCheckoutDir(workspaceRoot: string): string | undefined {
541
+ let result = spawnSync(
542
+ 'git',
543
+ ['rev-parse', '--path-format=absolute', '--git-common-dir'],
544
+ {
545
+ cwd: workspaceRoot,
546
+ encoding: 'utf8',
547
+ stdio: ['ignore', 'pipe', 'ignore'],
548
+ },
549
+ );
550
+ if (result.status !== 0) return undefined;
551
+ let commonDir = result.stdout.trim();
552
+ if (!commonDir.endsWith(`${join('.git')}`)) return undefined;
553
+ return dirname(commonDir);
554
+ }
555
+
556
+ // ---------------------------------------------------------------------------
557
+ // QUnit result summarization
558
+ // ---------------------------------------------------------------------------
559
+
560
+ interface QunitSummary {
561
+ status: 'passed' | 'failed' | 'error';
562
+ passedCount: number;
563
+ failedCount: number;
564
+ skippedCount: number;
565
+ failures: TestFailure[];
566
+ errorMessage?: string;
567
+ }
568
+
569
+ function summarizeQunitResults(results: QunitResults): QunitSummary {
570
+ if (!results.runEnd) {
571
+ return {
572
+ status: 'error',
573
+ passedCount: 0,
574
+ failedCount: 0,
575
+ skippedCount: 0,
576
+ failures: [],
577
+ errorMessage: 'QUnit did not complete — runEnd event was not received',
578
+ };
579
+ }
580
+
581
+ let passedCount = 0;
582
+ let failedCount = 0;
583
+ let skippedCount = 0;
584
+ let failures: TestFailure[] = [];
585
+
586
+ for (let test of results.tests) {
587
+ if (test.status === 'failed') {
588
+ failedCount += 1;
589
+ let firstError = test.errors[0];
590
+ failures.push({
591
+ testName: test.name,
592
+ module: test.module || 'default',
593
+ message: firstError?.message ?? 'Test failed',
594
+ ...(firstError?.stack
595
+ ? { stackTrace: firstError.stack.slice(0, 500) }
596
+ : {}),
597
+ });
598
+ } else if (test.status === 'skipped' || test.status === 'todo') {
599
+ skippedCount += 1;
600
+ } else {
601
+ passedCount += 1;
602
+ }
603
+ }
604
+
605
+ let status: QunitSummary['status'];
606
+ if (results.tests.length === 0) {
607
+ status = 'error';
608
+ } else if (failedCount > 0) {
609
+ status = 'failed';
610
+ } else if (passedCount === 0 && skippedCount > 0) {
611
+ status = 'failed';
612
+ } else {
613
+ status = 'passed';
614
+ }
615
+
616
+ return { status, passedCount, failedCount, skippedCount, failures };
617
+ }
618
+
619
+ // ---------------------------------------------------------------------------
620
+ // Helpers
621
+ // ---------------------------------------------------------------------------
622
+
623
+ function emptyErrorResult(message: string): RunTestsResult {
624
+ return {
625
+ status: 'error',
626
+ passedCount: 0,
627
+ failedCount: 0,
628
+ skippedCount: 0,
629
+ durationMs: 0,
630
+ testFiles: [],
631
+ failures: [],
632
+ errorMessage: message,
633
+ };
634
+ }
635
+
636
+ // ---------------------------------------------------------------------------
637
+ // CLI surface
638
+ // ---------------------------------------------------------------------------
639
+
640
+ interface TestCliOptions {
641
+ realm: string;
642
+ hostAppUrl?: string;
643
+ hostDistDir?: string;
644
+ debug?: boolean;
645
+ json?: boolean;
646
+ }
647
+
648
+ export function registerTestCommand(program: Command): void {
649
+ program
650
+ .command('test')
651
+ .description(
652
+ "Run the realm's QUnit test suite (every `*.test.gts` file) in a headless Chromium driven against the host app. Monorepo-only: relies on the host app's compiled `dist/` being reachable from this CLI's location (or via TEST_HARNESS_HOST_DIST_PACKAGE_DIR).",
653
+ )
654
+ .requiredOption('--realm <realm-url>', 'The realm URL to test')
655
+ .option(
656
+ '--host-app-url <url>',
657
+ "Host app URL (compat proxy). Defaults to the active profile's realm-server URL.",
658
+ )
659
+ .option(
660
+ '--host-dist-dir <path>',
661
+ 'Override the host app dist directory used to build the test page.',
662
+ )
663
+ .option('--debug', 'Stream browser console output to stderr')
664
+ .option('--json', 'Output structured JSON result')
665
+ .action(async (opts: TestCliOptions) => {
666
+ let result: RunTestsResult;
667
+ try {
668
+ result = await runTestsForRealm(opts.realm, {
669
+ ...(opts.hostAppUrl ? { hostAppUrl: opts.hostAppUrl } : {}),
670
+ ...(opts.hostDistDir ? { hostDistDir: opts.hostDistDir } : {}),
671
+ ...(opts.debug ? { debug: true } : {}),
672
+ });
673
+ } catch (err) {
674
+ console.error(
675
+ `${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`,
676
+ );
677
+ process.exit(1);
678
+ }
679
+
680
+ if (opts.json) {
681
+ cliLog.output(JSON.stringify(result, null, 2));
682
+ if (result.status !== 'passed') {
683
+ process.exit(1);
684
+ }
685
+ return;
686
+ }
687
+
688
+ if (result.errorMessage) {
689
+ console.error(`${FG_RED}Error:${RESET} ${result.errorMessage}`);
690
+ }
691
+
692
+ if (result.testFiles.length === 0) {
693
+ console.log(`${DIM}No .test.gts files found in the realm.${RESET}`);
694
+ if (result.status !== 'passed') {
695
+ process.exit(1);
696
+ }
697
+ return;
698
+ }
699
+
700
+ if (result.failures.length > 0) {
701
+ for (let f of result.failures) {
702
+ console.log(
703
+ `\n${FG_RED}FAIL${RESET} ${DIM}${f.module}${RESET} › ${f.testName}`,
704
+ );
705
+ console.log(` ${f.message}`);
706
+ if (f.stackTrace) {
707
+ console.log(
708
+ ` ${DIM}${f.stackTrace.split('\n').slice(0, 3).join('\n ')}${RESET}`,
709
+ );
710
+ }
711
+ }
712
+ }
713
+
714
+ let statusColor =
715
+ result.status === 'passed'
716
+ ? FG_GREEN
717
+ : result.status === 'failed'
718
+ ? FG_RED
719
+ : FG_RED;
720
+ console.log(
721
+ `\n${statusColor}${result.status}${RESET} ${DIM}—${RESET} ${result.passedCount} passed, ${result.failedCount} failed${result.skippedCount > 0 ? `, ${result.skippedCount} skipped` : ''} ${DIM}(${result.durationMs}ms across ${result.testFiles.length} file(s))${RESET}`,
722
+ );
723
+
724
+ if (result.status !== 'passed') {
725
+ process.exit(1);
726
+ }
727
+ });
728
+ }