@cardstack/boxel-cli 0.2.0-unstable.298 → 0.2.0-unstable.425
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.
- package/dist/index.js +156 -97
- package/package.json +5 -3
- package/src/build-program.ts +6 -0
- package/src/commands/lint.ts +285 -0
- package/src/commands/parse.ts +741 -0
- package/src/commands/realm/index.ts +4 -0
- package/src/commands/realm/publish.ts +291 -0
- package/src/commands/realm/unpublish.ts +150 -0
- package/src/commands/test.ts +728 -0
- package/src/lib/find-package-root.ts +34 -0
- package/src/lib/realm-relative-path.ts +46 -0
|
@@ -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
|
+
}
|