@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 +75 -0
- package/dist/cli.js +526 -0
- package/dist/cli.js.map +1 -0
- package/dist/host-bundle.global.js +775 -0
- package/dist/ui/assets/index-D4WUxbBl.css +1 -0
- package/dist/ui/assets/index-iVwcmekY.js +34 -0
- package/dist/ui/index.html +14 -0
- package/package.json +45 -0
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
|