@napplet/conformance-cli 0.1.0

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/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # @napplet/conformance-cli
2
+
3
+ The headless `napplet-conformance` runner. It drives the
4
+ [`@napplet/conformance`](../conformance) engine against a napplet in real headless
5
+ Chromium (via Playwright), so a napplet can prove it conforms to the NAP protocol
6
+ **before** publishing — locally and in CI.
7
+
8
+ ```bash
9
+ # Build your napplet first, then:
10
+ npx napplet-conformance ./dist
11
+ # or point at a directory containing index.html / a dist/ subdir:
12
+ npx napplet-conformance .
13
+ # or a remotely-served napplet:
14
+ npx napplet-conformance --url https://my.napplet.example/
15
+ ```
16
+
17
+ Wire it into any package manager as `test:conformance`:
18
+
19
+ ```jsonc
20
+ {
21
+ "scripts": {
22
+ "test:conformance": "napplet-conformance ./dist",
23
+ "test:conformance:ui": "napplet-conformance --ui . --exec \"vite build --watch\""
24
+ }
25
+ }
26
+ ```
27
+
28
+ ```bash
29
+ pnpm test:conformance # npm / yarn / bun all work — the bin is PM-agnostic
30
+ ```
31
+
32
+ ## UI / watch mode (`--ui`) — like `vitest --ui`
33
+
34
+ ```bash
35
+ napplet-conformance --ui . --exec "vite build --watch"
36
+ ```
37
+
38
+ `--ui` serves the standalone conformance web runtime (bundled with this package) plus
39
+ the napplet, opens your browser, and **re-runs conformance live every time the napplet
40
+ changes**. The optional `--exec` runs your build in watch mode so source edits rebuild
41
+ the served `./dist`; the CLI's file watcher then triggers a fresh run — edit, save, see
42
+ the verdict update. Useful flags: `--port <n>`, `--no-open`. (Headless mode is
43
+ unchanged — `--ui` is purely additive.)
44
+
45
+ ## How it works
46
+
47
+ 1. Serves your built napplet on loopback alongside a host harness page and the
48
+ bundled engine. Every response sends `Access-Control-Allow-Origin: *` so the
49
+ sandboxed napplet's module scripts load across its opaque origin.
50
+ 2. Launches headless Chromium, loads the napplet into a `sandbox="allow-scripts"`
51
+ iframe (no `allow-same-origin`), attaches a reference shell, and records every
52
+ envelope the napplet emits — plus a second no-capability pass to prove graceful
53
+ degradation.
54
+ 3. Assembles the conformance context (manifest + static `window.nostr` scan), runs
55
+ the check catalog, prints the report, and exits non-zero on any error-severity
56
+ failure.
57
+
58
+ ## Options
59
+
60
+ ```
61
+ --url <url> Test a remotely-served napplet instead of a local dir
62
+ --reporter <fmt> pretty | json | junit (default: pretty)
63
+ --out <file> Write the report to a file instead of stdout
64
+ --ready-timeout <ms> Boot timeout waiting for shell.ready (default: 5000)
65
+ --settle <ms> Envelope-collection window after boot (default: 600)
66
+ --no-degraded Skip the graceful-degradation pass
67
+ --allow-same-origin Debug only (a conformant napplet must not need it)
68
+ -h, --help Show help
69
+ ```
70
+
71
+ Exit codes: `0` conformant, `1` non-conformant, `2` usage/runtime error.
72
+
73
+ > Requires Playwright's Chromium. In CI, run `npx playwright install --with-deps chromium`
74
+ > once before invoking the CLI. This package is npm-only (Playwright is not
75
+ > JSR-friendly); the pure engine `@napplet/conformance` is published to both.
package/dist/cli.js ADDED
@@ -0,0 +1,526 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { readFile as readFile4, writeFile, stat as stat4 } from "fs/promises";
5
+ import { join as join4, dirname, resolve as resolve3 } from "path";
6
+ import { fileURLToPath } from "url";
7
+ import { spawn } from "child_process";
8
+ import { chromium } from "playwright";
9
+ import {
10
+ buildContext,
11
+ runConformance,
12
+ report
13
+ } from "@napplet/conformance";
14
+
15
+ // src/server.ts
16
+ import { createServer } from "http";
17
+ import { readFile as readFile2, stat as stat2 } from "fs/promises";
18
+ import { join, normalize, extname as extname2, resolve } from "path";
19
+
20
+ // src/static.ts
21
+ import { readFile, stat } from "fs/promises";
22
+ import { extname } from "path";
23
+ var MIME = {
24
+ ".html": "text/html; charset=utf-8",
25
+ ".js": "text/javascript; charset=utf-8",
26
+ ".mjs": "text/javascript; charset=utf-8",
27
+ ".css": "text/css; charset=utf-8",
28
+ ".json": "application/json; charset=utf-8",
29
+ ".svg": "image/svg+xml",
30
+ ".png": "image/png",
31
+ ".jpg": "image/jpeg",
32
+ ".jpeg": "image/jpeg",
33
+ ".gif": "image/gif",
34
+ ".webp": "image/webp",
35
+ ".ico": "image/x-icon",
36
+ ".wasm": "application/wasm",
37
+ ".woff": "font/woff",
38
+ ".woff2": "font/woff2",
39
+ ".map": "application/json; charset=utf-8",
40
+ ".txt": "text/plain; charset=utf-8"
41
+ };
42
+ function setCors(res) {
43
+ res.setHeader("Access-Control-Allow-Origin", "*");
44
+ }
45
+ async function sendFile(res, absPath) {
46
+ const info = await stat(absPath).catch(() => null);
47
+ if (!info?.isFile()) return false;
48
+ res.writeHead(200, { "Content-Type": MIME[extname(absPath).toLowerCase()] ?? "application/octet-stream" });
49
+ res.end(await readFile(absPath));
50
+ return true;
51
+ }
52
+
53
+ // src/server.ts
54
+ var HOST_HTML_ROUTE = "/__conformance__/host.html";
55
+ var HOST_BUNDLE_ROUTE = "/__conformance__/host-bundle.js";
56
+ function renderHostPage(boot) {
57
+ const cfg = JSON.stringify(boot);
58
+ return `<!doctype html>
59
+ <html lang="en">
60
+ <head><meta charset="utf-8"><title>napplet conformance host</title></head>
61
+ <body>
62
+ <script src="${HOST_BUNDLE_ROUTE}"></script>
63
+ <script>
64
+ (function () {
65
+ var cfg = ${cfg};
66
+ window.NappletConformanceHost.run(cfg).then(function (boot) {
67
+ window.__conformanceBoot__ = boot;
68
+ }).catch(function (err) {
69
+ window.__conformanceError__ = String((err && err.stack) || err);
70
+ });
71
+ })();
72
+ </script>
73
+ </body>
74
+ </html>`;
75
+ }
76
+ async function startHarnessServer(options) {
77
+ const dir = options.nappletDir ? resolve(options.nappletDir) : null;
78
+ const hostHtml = renderHostPage(options.bootConfig);
79
+ const server = createServer((req, res) => {
80
+ void serve(req.url ?? "/", res);
81
+ });
82
+ async function serve(rawUrl, res) {
83
+ res.setHeader("Access-Control-Allow-Origin", "*");
84
+ const path = decodeURIComponent(rawUrl.split("?")[0]);
85
+ if (path === HOST_HTML_ROUTE) {
86
+ res.writeHead(200, { "Content-Type": MIME[".html"] });
87
+ res.end(hostHtml);
88
+ return;
89
+ }
90
+ if (path === HOST_BUNDLE_ROUTE) {
91
+ res.writeHead(200, { "Content-Type": MIME[".js"] });
92
+ res.end(options.hostBundle);
93
+ return;
94
+ }
95
+ if (!dir) {
96
+ res.writeHead(404).end("not found");
97
+ return;
98
+ }
99
+ const relative = normalize(path).replace(/^(\.\.[/\\])+/, "").replace(/^[/\\]+/, "");
100
+ let filePath = join(dir, relative);
101
+ if (!filePath.startsWith(dir)) {
102
+ res.writeHead(403).end("forbidden");
103
+ return;
104
+ }
105
+ try {
106
+ let info = await stat2(filePath).catch(() => null);
107
+ if (info?.isDirectory()) {
108
+ filePath = join(filePath, "index.html");
109
+ info = await stat2(filePath).catch(() => null);
110
+ }
111
+ if (path === "/" && !info) {
112
+ filePath = join(dir, "index.html");
113
+ info = await stat2(filePath).catch(() => null);
114
+ }
115
+ if (!info?.isFile()) {
116
+ res.writeHead(404).end("not found");
117
+ return;
118
+ }
119
+ const body = await readFile2(filePath);
120
+ res.writeHead(200, { "Content-Type": MIME[extname2(filePath).toLowerCase()] ?? "application/octet-stream" });
121
+ res.end(body);
122
+ } catch {
123
+ res.writeHead(500).end("server error");
124
+ }
125
+ }
126
+ await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen));
127
+ const address = server.address();
128
+ const origin = `http://127.0.0.1:${address.port}`;
129
+ return {
130
+ origin,
131
+ hostUrl: `${origin}${HOST_HTML_ROUTE}`,
132
+ close: () => new Promise((res, rej) => server.close((err) => err ? rej(err) : res()))
133
+ };
134
+ }
135
+
136
+ // src/ui-server.ts
137
+ import { createServer as createServer2 } from "http";
138
+ import { watch } from "fs";
139
+ import { join as join2, normalize as normalize2, resolve as resolve2 } from "path";
140
+ var NAPPLET_PREFIX = "/__napplet__/";
141
+ var EVENTS_ROUTE = "/__conformance__/events";
142
+ function safeJoin(dir, urlPath) {
143
+ const relative = normalize2(urlPath).replace(/^(\.\.[/\\])+/, "").replace(/^[/\\]+/, "");
144
+ const full = join2(dir, relative);
145
+ return full.startsWith(dir) ? full : null;
146
+ }
147
+ function handleSse(res, clients) {
148
+ res.writeHead(200, {
149
+ "Content-Type": "text/event-stream",
150
+ "Cache-Control": "no-cache, no-transform",
151
+ Connection: "keep-alive"
152
+ });
153
+ res.write("retry: 1000\n\n");
154
+ clients.add(res);
155
+ res.on("close", () => clients.delete(res));
156
+ }
157
+ async function handleNapplet(res, nappletDir, path) {
158
+ const rel = path.slice(NAPPLET_PREFIX.length) || "index.html";
159
+ const abs = safeJoin(nappletDir, rel);
160
+ if (abs && await sendFile(res, abs)) return;
161
+ res.writeHead(404).end("napplet asset not found");
162
+ }
163
+ async function handleApp(res, appDir, path) {
164
+ const abs = safeJoin(appDir, path === "/" ? "index.html" : path);
165
+ if (abs && await sendFile(res, abs)) return;
166
+ if (await sendFile(res, join2(appDir, "index.html"))) return;
167
+ res.writeHead(404).end("not found");
168
+ }
169
+ function startWatcher(dir, debounceMs, fire) {
170
+ let timer = null;
171
+ const onEvent = () => {
172
+ if (timer) clearTimeout(timer);
173
+ timer = setTimeout(fire, debounceMs);
174
+ };
175
+ let watcher;
176
+ try {
177
+ watcher = watch(dir, { recursive: true }, onEvent);
178
+ } catch {
179
+ watcher = watch(dir, onEvent);
180
+ }
181
+ return {
182
+ close() {
183
+ if (timer) clearTimeout(timer);
184
+ watcher.close();
185
+ }
186
+ };
187
+ }
188
+ async function startUiServer(options) {
189
+ const appDir = resolve2(options.appDir);
190
+ const nappletDir = resolve2(options.nappletDir);
191
+ const clients = /* @__PURE__ */ new Set();
192
+ const broadcast = () => {
193
+ for (const res of clients) res.write(`event: rerun
194
+ data: ${Date.now()}
195
+
196
+ `);
197
+ };
198
+ const server = createServer2((req, res) => {
199
+ setCors(res);
200
+ res.setHeader("Cache-Control", "no-store");
201
+ const path = decodeURIComponent((req.url ?? "/").split("?")[0]);
202
+ if (path === EVENTS_ROUTE) {
203
+ handleSse(res, clients);
204
+ } else if (path === NAPPLET_PREFIX.slice(0, -1) || path.startsWith(NAPPLET_PREFIX)) {
205
+ void handleNapplet(res, nappletDir, path);
206
+ } else {
207
+ void handleApp(res, appDir, path);
208
+ }
209
+ });
210
+ await new Promise((r) => server.listen(options.port ?? 0, "127.0.0.1", r));
211
+ const { port } = server.address();
212
+ const origin = `http://127.0.0.1:${port}`;
213
+ const appUrl = `${origin}/?url=${encodeURIComponent(NAPPLET_PREFIX)}&live=1`;
214
+ const watcher = startWatcher(nappletDir, options.debounceMs ?? 120, broadcast);
215
+ return {
216
+ origin,
217
+ appUrl,
218
+ notify: broadcast,
219
+ close: () => new Promise((res, rej) => {
220
+ watcher.close();
221
+ for (const c of clients) c.end();
222
+ clients.clear();
223
+ server.close((err) => err ? rej(err) : res());
224
+ })
225
+ };
226
+ }
227
+
228
+ // src/scan.ts
229
+ import { readdir, readFile as readFile3, stat as stat3 } from "fs/promises";
230
+ import { join as join3, extname as extname3 } from "path";
231
+ var FORBIDDEN = [
232
+ { label: "window.nostr", re: /\bwindow\s*\.\s*nostr\b/ },
233
+ { label: "globalThis.nostr", re: /\bglobalThis\s*\.\s*nostr\b/ }
234
+ ];
235
+ var SCANNABLE = /* @__PURE__ */ new Set([".js", ".mjs", ".cjs", ".html", ".htm"]);
236
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".turbo", "coverage"]);
237
+ async function collectFiles(dir, acc) {
238
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
239
+ if (entry.isDirectory()) {
240
+ if (SKIP_DIRS.has(entry.name)) continue;
241
+ await collectFiles(join3(dir, entry.name), acc);
242
+ } else if (entry.isFile() && SCANNABLE.has(extname3(entry.name).toLowerCase())) {
243
+ acc.push(join3(dir, entry.name));
244
+ }
245
+ }
246
+ }
247
+ async function scanForbiddenGlobals(dir) {
248
+ const info = await stat3(dir).catch(() => null);
249
+ if (!info?.isDirectory()) return [];
250
+ const files = [];
251
+ await collectFiles(dir, files);
252
+ const found = /* @__PURE__ */ new Set();
253
+ for (const file of files) {
254
+ const text = await readFile3(file, "utf8").catch(() => "");
255
+ for (const { label, re } of FORBIDDEN) {
256
+ if (re.test(text)) found.add(label);
257
+ }
258
+ }
259
+ return [...found];
260
+ }
261
+
262
+ // src/cli.ts
263
+ var HELP = `napplet-conformance \u2014 run NAP protocol conformance against a napplet
264
+
265
+ Usage:
266
+ napplet-conformance [dir] [options] # headless, one-shot, CI exit code
267
+ napplet-conformance --ui [dir] [options] # open the live web runtime (like vitest --ui)
268
+ napplet-conformance --url https://my.napplet/ [options]
269
+
270
+ Arguments:
271
+ dir Built napplet directory (looks for ./index.html or ./dist/index.html)
272
+
273
+ Options:
274
+ --url <url> Test a remotely-served napplet instead of a local dir (headless)
275
+ --reporter <fmt> pretty | json | junit (default: pretty)
276
+ --out <file> Write the report to a file instead of stdout
277
+ --ready-timeout <ms> Boot timeout waiting for shell.ready (default: 5000)
278
+ --settle <ms> Envelope-collection window after boot (default: 600)
279
+ --no-degraded Skip the no-capability graceful-degradation pass
280
+ --allow-same-origin Debug: also grant allow-same-origin (a conformant napplet must not need it)
281
+
282
+ UI / watch mode (--ui):
283
+ --port <n> Port for the UI server (default: random)
284
+ --no-open Do not open a browser automatically
285
+ --exec <cmd> Run a command (e.g. "vite build --watch") so edits rebuild the served dir;
286
+ the watcher then re-runs conformance live on every change
287
+ -h, --help Show this help
288
+
289
+ Exit codes (headless): 0 conformant, 1 non-conformant, 2 usage/runtime error.
290
+ `;
291
+ function parseArgs(argv) {
292
+ const opts = {
293
+ reporter: "pretty",
294
+ readyTimeoutMs: 5e3,
295
+ settleMs: 600,
296
+ runDegraded: true,
297
+ allowSameOrigin: false,
298
+ ui: false,
299
+ open: true,
300
+ help: false
301
+ };
302
+ for (let i = 0; i < argv.length; i += 1) {
303
+ const arg = argv[i];
304
+ const value = () => {
305
+ const v = argv[i + 1];
306
+ if (v === void 0 || v.startsWith("--")) throw new Error(`Missing value for ${arg}`);
307
+ i += 1;
308
+ return v;
309
+ };
310
+ switch (arg) {
311
+ case "-h":
312
+ case "--help":
313
+ opts.help = true;
314
+ break;
315
+ case "--url":
316
+ opts.url = value();
317
+ break;
318
+ case "--reporter": {
319
+ const r = value();
320
+ if (r !== "pretty" && r !== "json" && r !== "junit") throw new Error(`Invalid reporter: ${r}`);
321
+ opts.reporter = r;
322
+ break;
323
+ }
324
+ case "--out":
325
+ opts.out = value();
326
+ break;
327
+ case "--ready-timeout":
328
+ opts.readyTimeoutMs = Number(value());
329
+ break;
330
+ case "--settle":
331
+ opts.settleMs = Number(value());
332
+ break;
333
+ case "--no-degraded":
334
+ opts.runDegraded = false;
335
+ break;
336
+ case "--allow-same-origin":
337
+ opts.allowSameOrigin = true;
338
+ break;
339
+ case "--ui":
340
+ opts.ui = true;
341
+ break;
342
+ case "--port":
343
+ opts.port = Number(value());
344
+ break;
345
+ case "--no-open":
346
+ opts.open = false;
347
+ break;
348
+ case "--exec":
349
+ opts.exec = value();
350
+ break;
351
+ default:
352
+ if (arg.startsWith("--")) throw new Error(`Unknown option: ${arg}`);
353
+ if (opts.target) throw new Error(`Unexpected extra argument: ${arg}`);
354
+ opts.target = arg;
355
+ }
356
+ }
357
+ return opts;
358
+ }
359
+ async function isFile(path) {
360
+ return (await stat4(path).catch(() => null))?.isFile() ?? false;
361
+ }
362
+ async function resolveNappletDir(target) {
363
+ const base = resolve3(target);
364
+ if (await isFile(join4(base, "index.html"))) return { dir: base, indexHtml: join4(base, "index.html") };
365
+ const dist = join4(base, "dist");
366
+ if (await isFile(join4(dist, "index.html"))) return { dir: dist, indexHtml: join4(dist, "index.html") };
367
+ throw new Error(`No index.html found in ${base} or ${dist}. Build the napplet first.`);
368
+ }
369
+ async function collectBoot(hostUrl, allowSameOrigin, totalTimeoutMs) {
370
+ const browser = await chromium.launch({ args: ["--no-sandbox"] });
371
+ try {
372
+ const page = await browser.newPage();
373
+ page.on("pageerror", (e) => process.stderr.write(`[host page error] ${e.message}
374
+ `));
375
+ await page.goto(hostUrl, { waitUntil: "load" });
376
+ await page.waitForFunction(
377
+ () => window.__conformanceBoot__ !== void 0 || window.__conformanceError__ !== void 0,
378
+ void 0,
379
+ { timeout: totalTimeoutMs }
380
+ );
381
+ const error = await page.evaluate(() => window.__conformanceError__);
382
+ if (error) throw new Error(`Host harness failed: ${error}`);
383
+ const boot = await page.evaluate(() => window.__conformanceBoot__);
384
+ if (!boot) throw new Error("Host harness produced no result");
385
+ void allowSameOrigin;
386
+ return boot;
387
+ } finally {
388
+ await browser.close();
389
+ }
390
+ }
391
+ async function isDir(path) {
392
+ return (await stat4(path).catch(() => null))?.isDirectory() ?? false;
393
+ }
394
+ function openBrowser(url) {
395
+ try {
396
+ if (process.platform === "darwin") {
397
+ spawn("open", [url], { stdio: "ignore", detached: true }).unref();
398
+ } else if (process.platform === "win32") {
399
+ spawn("cmd", ["/c", "start", "", url], { stdio: "ignore", detached: true }).unref();
400
+ } else {
401
+ spawn("xdg-open", [url], { stdio: "ignore", detached: true }).unref();
402
+ }
403
+ } catch {
404
+ }
405
+ }
406
+ async function runUiMode(opts) {
407
+ if (opts.url) {
408
+ process.stderr.write("--ui serves a local napplet directory; --url is not supported in UI mode.\n");
409
+ return 2;
410
+ }
411
+ if (!opts.target) {
412
+ process.stderr.write(`Provide a napplet directory.
413
+
414
+ ${HELP}`);
415
+ return 2;
416
+ }
417
+ const resolved = await resolveNappletDir(opts.target);
418
+ const here = dirname(fileURLToPath(import.meta.url));
419
+ const appDir = join4(here, "ui");
420
+ if (!await isDir(appDir)) {
421
+ process.stderr.write(`Conformance UI assets are missing at ${appDir}. Rebuild @napplet/conformance-cli.
422
+ `);
423
+ return 2;
424
+ }
425
+ const server = await startUiServer({ appDir, nappletDir: resolved.dir, port: opts.port });
426
+ const child = opts.exec ? spawn(opts.exec, { cwd: resolve3(opts.target), shell: true, stdio: "inherit" }) : null;
427
+ process.stdout.write(
428
+ `
429
+ napplet conformance UI \u2192 ${server.appUrl}
430
+ watching ${resolved.dir}${opts.exec ? ` (running: ${opts.exec})` : ""}
431
+ Ctrl-C to stop
432
+
433
+ `
434
+ );
435
+ if (opts.open) openBrowser(server.appUrl);
436
+ await new Promise((resolveWait) => {
437
+ let closing = false;
438
+ const shutdown = () => {
439
+ if (closing) return;
440
+ closing = true;
441
+ child?.kill();
442
+ void server.close().finally(resolveWait);
443
+ };
444
+ process.on("SIGINT", shutdown);
445
+ process.on("SIGTERM", shutdown);
446
+ });
447
+ return 0;
448
+ }
449
+ async function main(argv) {
450
+ let opts;
451
+ try {
452
+ opts = parseArgs(argv);
453
+ } catch (err) {
454
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}
455
+
456
+ ${HELP}`);
457
+ return 2;
458
+ }
459
+ if (opts.help) {
460
+ process.stdout.write(HELP);
461
+ return 0;
462
+ }
463
+ if (opts.ui) {
464
+ return runUiMode(opts);
465
+ }
466
+ let nappletDir = null;
467
+ let manifestHtml;
468
+ let forbiddenGlobals = [];
469
+ let bootUrl;
470
+ if (opts.url) {
471
+ bootUrl = opts.url;
472
+ manifestHtml = await (await fetch(opts.url)).text();
473
+ } else {
474
+ if (!opts.target) {
475
+ process.stderr.write(`Provide a napplet directory or --url.
476
+
477
+ ${HELP}`);
478
+ return 2;
479
+ }
480
+ const resolved = await resolveNappletDir(opts.target);
481
+ nappletDir = resolved.dir;
482
+ manifestHtml = await readFile4(resolved.indexHtml, "utf8");
483
+ forbiddenGlobals = await scanForbiddenGlobals(resolved.dir);
484
+ bootUrl = "/";
485
+ }
486
+ const here = dirname(fileURLToPath(import.meta.url));
487
+ const hostBundle = await readFile4(join4(here, "host-bundle.global.js"), "utf8");
488
+ const server = await startHarnessServer({
489
+ nappletDir,
490
+ hostBundle,
491
+ bootConfig: {
492
+ url: bootUrl,
493
+ readyTimeoutMs: opts.readyTimeoutMs,
494
+ settleMs: opts.settleMs,
495
+ runDegraded: opts.runDegraded
496
+ }
497
+ });
498
+ let boot;
499
+ try {
500
+ const totalTimeout = (opts.readyTimeoutMs + opts.settleMs) * 2 + 3e4;
501
+ boot = await collectBoot(server.hostUrl, opts.allowSameOrigin, totalTimeout);
502
+ } finally {
503
+ await server.close();
504
+ }
505
+ const context = buildContext({ manifestHtml, boot, forbiddenGlobals });
506
+ const run = runConformance(context);
507
+ const output = report(run, opts.reporter);
508
+ if (opts.out) {
509
+ await writeFile(opts.out, `${output}
510
+ `, "utf8");
511
+ process.stdout.write(`Report written to ${opts.out}
512
+ `);
513
+ } else {
514
+ process.stdout.write(`${output}
515
+ `);
516
+ }
517
+ return run.ok ? 0 : 1;
518
+ }
519
+ main(process.argv.slice(2)).then((code) => {
520
+ process.exitCode = code;
521
+ }).catch((err) => {
522
+ process.stderr.write(`napplet-conformance: ${err instanceof Error ? err.stack ?? err.message : String(err)}
523
+ `);
524
+ process.exitCode = 2;
525
+ });
526
+ //# sourceMappingURL=cli.js.map