@askrjs/cli 0.0.12 → 0.0.14
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 +19 -0
- package/dist/analyze.js +1 -1
- package/dist/cli.js +7 -1
- package/dist/{guardrails-GPM9PJqO.js → guardrails-D6KCKS-H.js} +1 -1
- package/dist/{runner-BRjjPhZY.js → runner-BSs9Ow3t.js} +504 -6
- package/dist/{runner-Ca43qqzG.js → runner-DetGSvGf.js} +1 -1
- package/dist/templates/spa/src/pages/app/admin-home.tsx +136 -133
- package/dist/templates/startkit/src/components/data-table.tsx +33 -33
- package/dist/verify-hydration.d.ts +35 -0
- package/dist/verify-hydration.js +325 -0
- package/package.json +11 -2
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { t as isDirectExecution } from "./is-direct-execution-Cdlr-ZUl.js";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import http from "node:http";
|
|
7
|
+
//#region src/bin/verify-hydration.ts
|
|
8
|
+
const helpText = `
|
|
9
|
+
askr verify-hydration - Verify SSG DOM structure in a real browser
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
askr verify-hydration [--output <dir>] [--route <path> ...]
|
|
13
|
+
|
|
14
|
+
Options:
|
|
15
|
+
--cwd <dir> Project directory (default: current directory)
|
|
16
|
+
--output <dir> Generated SSG output (default: dist)
|
|
17
|
+
--route <path> Route to verify; repeat to select a route set
|
|
18
|
+
--root <selector> Hydrated application root (default: #app)
|
|
19
|
+
--build-script <name> npm script that builds SSG output (default: build)
|
|
20
|
+
--no-build Verify existing output without running a build
|
|
21
|
+
--timeout <ms> Per-route browser timeout (default: 10000)
|
|
22
|
+
--browser-channel <id> Browser channel: chrome, msedge, or playwright
|
|
23
|
+
--help Show this help message
|
|
24
|
+
|
|
25
|
+
When --route is omitted, routes are read from <output>/metadata.json.
|
|
26
|
+
`;
|
|
27
|
+
function parsePositiveInteger(value, option, errors) {
|
|
28
|
+
const parsed = Number(value);
|
|
29
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
|
30
|
+
errors.push(`${option} must be a positive integer`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
return parsed;
|
|
34
|
+
}
|
|
35
|
+
function parseVerifyHydrationArgs(args, defaultCwd = process.cwd()) {
|
|
36
|
+
const parsed = {
|
|
37
|
+
cwd: defaultCwd,
|
|
38
|
+
outputDir: "dist",
|
|
39
|
+
routes: [],
|
|
40
|
+
rootSelector: "#app",
|
|
41
|
+
buildScript: "build",
|
|
42
|
+
build: true,
|
|
43
|
+
timeoutMs: 1e4,
|
|
44
|
+
help: false,
|
|
45
|
+
errors: []
|
|
46
|
+
};
|
|
47
|
+
const takeValue = (index, option) => {
|
|
48
|
+
const value = args[index + 1];
|
|
49
|
+
if (!value || value.startsWith("-")) {
|
|
50
|
+
parsed.errors.push(`Missing value for ${option}`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
};
|
|
55
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
56
|
+
const argument = args[index];
|
|
57
|
+
if (argument === "--cwd" || argument === "--output" || argument === "--route" || argument === "--root" || argument === "--build-script" || argument === "--timeout" || argument === "--browser-channel") {
|
|
58
|
+
const value = takeValue(index, argument);
|
|
59
|
+
if (!value) continue;
|
|
60
|
+
index += 1;
|
|
61
|
+
if (argument === "--cwd") parsed.cwd = value;
|
|
62
|
+
else if (argument === "--output") parsed.outputDir = value;
|
|
63
|
+
else if (argument === "--route") parsed.routes.push(value);
|
|
64
|
+
else if (argument === "--root") parsed.rootSelector = value;
|
|
65
|
+
else if (argument === "--build-script") parsed.buildScript = value;
|
|
66
|
+
else if (argument === "--browser-channel") parsed.browserChannel = value;
|
|
67
|
+
else {
|
|
68
|
+
const timeout = parsePositiveInteger(value, "--timeout", parsed.errors);
|
|
69
|
+
if (timeout) parsed.timeoutMs = timeout;
|
|
70
|
+
}
|
|
71
|
+
} else if (argument === "--no-build") parsed.build = false;
|
|
72
|
+
else if (argument === "--help" || argument === "-h") parsed.help = true;
|
|
73
|
+
else parsed.errors.push(`Unknown option: ${argument}`);
|
|
74
|
+
}
|
|
75
|
+
parsed.cwd = path.resolve(defaultCwd, parsed.cwd);
|
|
76
|
+
parsed.outputDir = path.resolve(parsed.cwd, parsed.outputDir);
|
|
77
|
+
return parsed;
|
|
78
|
+
}
|
|
79
|
+
function normalizeRoute(route) {
|
|
80
|
+
const pathname = new URL(route, "http://askr.local").pathname;
|
|
81
|
+
return pathname === "/" ? pathname : pathname.replace(/\/+$/, "");
|
|
82
|
+
}
|
|
83
|
+
async function readRouteMetadata(outputDir, selectedRoutes) {
|
|
84
|
+
const metadataPath = path.join(outputDir, "metadata.json");
|
|
85
|
+
let metadata;
|
|
86
|
+
try {
|
|
87
|
+
metadata = JSON.parse(await fs.readFile(metadataPath, "utf8"));
|
|
88
|
+
} catch (error) {
|
|
89
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
90
|
+
throw new Error(`Could not read SSG route metadata at ${metadataPath}: ${detail}`);
|
|
91
|
+
}
|
|
92
|
+
const routes = metadata.routes;
|
|
93
|
+
if (!Array.isArray(routes)) throw new Error(`Invalid SSG route metadata at ${metadataPath}: routes must be an array.`);
|
|
94
|
+
const valid = routes.filter((entry) => Boolean(entry && typeof entry === "object" && typeof entry.path === "string" && typeof entry.filePath === "string" && entry.status !== "error" && entry.status !== "removed"));
|
|
95
|
+
const byPath = new Map(valid.map((entry) => [normalizeRoute(entry.path), entry]));
|
|
96
|
+
if (selectedRoutes.length === 0) return [...byPath.values()].sort((left, right) => left.path.localeCompare(right.path));
|
|
97
|
+
return [...new Set(selectedRoutes.map(normalizeRoute))].map((route) => {
|
|
98
|
+
const entry = byPath.get(route);
|
|
99
|
+
if (!entry) throw new Error(`Route ${route} is not present in ${metadataPath}.`);
|
|
100
|
+
return entry;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
function contentType(filePath) {
|
|
104
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
105
|
+
if (extension === ".html") return "text/html; charset=utf-8";
|
|
106
|
+
if (extension === ".js" || extension === ".mjs") return "text/javascript; charset=utf-8";
|
|
107
|
+
if (extension === ".css") return "text/css; charset=utf-8";
|
|
108
|
+
if (extension === ".json") return "application/json; charset=utf-8";
|
|
109
|
+
if (extension === ".svg") return "image/svg+xml";
|
|
110
|
+
if (extension === ".png") return "image/png";
|
|
111
|
+
if (extension === ".jpg" || extension === ".jpeg") return "image/jpeg";
|
|
112
|
+
if (extension === ".webp") return "image/webp";
|
|
113
|
+
return "application/octet-stream";
|
|
114
|
+
}
|
|
115
|
+
function closeServer(server) {
|
|
116
|
+
return new Promise((resolve, reject) => {
|
|
117
|
+
server.close((error) => error ? reject(error) : resolve());
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
async function startStaticOutputServer(outputDir, routes) {
|
|
121
|
+
const outputRoot = path.resolve(outputDir);
|
|
122
|
+
const routeFiles = new Map(routes.map((entry) => [normalizeRoute(entry.path), path.resolve(outputRoot, entry.filePath)]));
|
|
123
|
+
for (const [route, filePath] of routeFiles) {
|
|
124
|
+
const relative = path.relative(outputRoot, filePath);
|
|
125
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Route ${route} resolves outside the SSG output directory.`);
|
|
126
|
+
}
|
|
127
|
+
const server = http.createServer(async (request, response) => {
|
|
128
|
+
try {
|
|
129
|
+
const pathname = normalizeRoute(new URL(request.url ?? "/", "http://askr.local").pathname);
|
|
130
|
+
const candidate = routeFiles.get(pathname) ?? path.resolve(outputRoot, `.${pathname}`);
|
|
131
|
+
const relative = path.relative(outputRoot, candidate);
|
|
132
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
133
|
+
response.writeHead(404).end("Not found");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const filePath = (await fs.stat(candidate).catch(() => null))?.isDirectory() ? path.join(candidate, "index.html") : candidate;
|
|
137
|
+
const content = await fs.readFile(filePath);
|
|
138
|
+
response.writeHead(200, {
|
|
139
|
+
"cache-control": "no-store",
|
|
140
|
+
"content-type": contentType(filePath)
|
|
141
|
+
});
|
|
142
|
+
response.end(content);
|
|
143
|
+
} catch {
|
|
144
|
+
response.writeHead(404).end("Not found");
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
await new Promise((resolve, reject) => {
|
|
148
|
+
server.once("error", reject);
|
|
149
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
150
|
+
});
|
|
151
|
+
const address = server.address();
|
|
152
|
+
if (!address || typeof address === "string") {
|
|
153
|
+
await closeServer(server);
|
|
154
|
+
throw new Error("Hydration verification server did not bind a TCP port.");
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
origin: `http://127.0.0.1:${address.port}`,
|
|
158
|
+
close: () => closeServer(server)
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
async function runNpmBuild(cwd, script) {
|
|
162
|
+
await new Promise((resolve, reject) => {
|
|
163
|
+
const child = spawn(process.platform === "win32" ? "npm.cmd" : "npm", ["run", script], {
|
|
164
|
+
cwd,
|
|
165
|
+
stdio: "inherit"
|
|
166
|
+
});
|
|
167
|
+
const timer = setTimeout(() => {
|
|
168
|
+
child.kill("SIGTERM");
|
|
169
|
+
reject(/* @__PURE__ */ new Error(`npm run ${script} timed out after 300000ms.`));
|
|
170
|
+
}, 3e5);
|
|
171
|
+
child.once("error", (error) => {
|
|
172
|
+
clearTimeout(timer);
|
|
173
|
+
reject(error);
|
|
174
|
+
});
|
|
175
|
+
child.once("exit", (code, signal) => {
|
|
176
|
+
clearTimeout(timer);
|
|
177
|
+
if (code === 0) resolve();
|
|
178
|
+
else reject(/* @__PURE__ */ new Error(`npm run ${script} failed${signal ? ` with signal ${signal}` : ` with exit code ${code}`}.`));
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
async function launchChromium(channel) {
|
|
183
|
+
const { chromium } = await import("playwright-core");
|
|
184
|
+
const requested = channel ?? process.env.ASKR_BROWSER_CHANNEL ?? "chrome";
|
|
185
|
+
try {
|
|
186
|
+
return requested === "playwright" ? await chromium.launch({ headless: true }) : await chromium.launch({
|
|
187
|
+
channel: requested,
|
|
188
|
+
headless: true
|
|
189
|
+
});
|
|
190
|
+
} catch (error) {
|
|
191
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
192
|
+
throw new Error(`Could not launch the ${requested} browser channel. Install Chrome, pass --browser-channel msedge, or run "npx playwright-core install chromium" and pass --browser-channel playwright. ${detail}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
async function snapshotRoot(page, selector) {
|
|
196
|
+
return page.evaluate((rootSelector) => {
|
|
197
|
+
const root = globalThis.document.querySelector(rootSelector);
|
|
198
|
+
if (!root) throw new Error(`Hydration root not found: ${rootSelector}`);
|
|
199
|
+
const lines = [];
|
|
200
|
+
const pending = [{
|
|
201
|
+
node: root,
|
|
202
|
+
path: rootSelector
|
|
203
|
+
}];
|
|
204
|
+
while (pending.length > 0) {
|
|
205
|
+
const current = pending.pop();
|
|
206
|
+
if (!current) break;
|
|
207
|
+
const { node, path: nodePath } = current;
|
|
208
|
+
if (node.nodeType !== 1) continue;
|
|
209
|
+
if (node.matches("script, style, link, meta, noscript, template")) continue;
|
|
210
|
+
lines.push(`${nodePath} <${node.tagName.toLowerCase()}>`);
|
|
211
|
+
const children = [...node.childNodes].filter((child) => child.nodeType === 1 && !child.matches("script, style, link, meta, noscript, template"));
|
|
212
|
+
for (let index = children.length - 1; index >= 0; index -= 1) pending.push({
|
|
213
|
+
node: children[index],
|
|
214
|
+
path: `${nodePath}/${index}`
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
return { lines };
|
|
218
|
+
}, selector);
|
|
219
|
+
}
|
|
220
|
+
function firstDifference(expected, actual) {
|
|
221
|
+
const length = Math.max(expected.lines.length, actual.lines.length);
|
|
222
|
+
for (let index = 0; index < length; index += 1) if (expected.lines[index] !== actual.lines[index]) return {
|
|
223
|
+
index,
|
|
224
|
+
expected: expected.lines[index] ?? "<missing>",
|
|
225
|
+
actual: actual.lines[index] ?? "<missing>"
|
|
226
|
+
};
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
async function loadSnapshot(context, url, selector, timeoutMs, settleHydration) {
|
|
230
|
+
const page = await context.newPage();
|
|
231
|
+
const errors = [];
|
|
232
|
+
page.on("pageerror", (error) => errors.push(error.message));
|
|
233
|
+
page.on("console", (message) => {
|
|
234
|
+
if (message.type() === "error") errors.push(`console: ${message.text()}`);
|
|
235
|
+
});
|
|
236
|
+
try {
|
|
237
|
+
page.setDefaultTimeout(timeoutMs);
|
|
238
|
+
const response = await page.goto(url, {
|
|
239
|
+
waitUntil: "load",
|
|
240
|
+
timeout: timeoutMs
|
|
241
|
+
});
|
|
242
|
+
if (!response?.ok()) throw new Error(`HTTP ${response?.status() ?? "failure"} loading ${url}`);
|
|
243
|
+
if (settleHydration) {
|
|
244
|
+
let timer;
|
|
245
|
+
try {
|
|
246
|
+
await Promise.race([page.evaluate(() => {
|
|
247
|
+
const animationFrame = globalThis.requestAnimationFrame;
|
|
248
|
+
return new Promise((resolve) => animationFrame(() => animationFrame(() => resolve())));
|
|
249
|
+
}), new Promise((_resolve, reject) => {
|
|
250
|
+
timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`Hydration timeout: did not settle within ${timeoutMs}ms.`)), timeoutMs);
|
|
251
|
+
})]);
|
|
252
|
+
} finally {
|
|
253
|
+
if (timer) clearTimeout(timer);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
snapshot: await snapshotRoot(page, selector),
|
|
258
|
+
errors
|
|
259
|
+
};
|
|
260
|
+
} finally {
|
|
261
|
+
await page.close();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
async function verifyHydrationRoutes(browser, origin, routes, rootSelector, timeoutMs) {
|
|
265
|
+
const failures = [];
|
|
266
|
+
const staticContext = await browser.newContext({ javaScriptEnabled: false });
|
|
267
|
+
const hydratedContext = await browser.newContext({ javaScriptEnabled: true });
|
|
268
|
+
try {
|
|
269
|
+
for (const route of routes) {
|
|
270
|
+
const url = `${origin}${normalizeRoute(route.path)}`;
|
|
271
|
+
try {
|
|
272
|
+
const expected = await loadSnapshot(staticContext, url, rootSelector, timeoutMs, false);
|
|
273
|
+
const actual = await loadSnapshot(hydratedContext, url, rootSelector, timeoutMs, true);
|
|
274
|
+
const difference = firstDifference(expected.snapshot, actual.snapshot);
|
|
275
|
+
if (difference) failures.push(`${route.path}: DOM diverged at normalized entry ${difference.index}\n static: ${difference.expected}\n hydrated: ${difference.actual}`);
|
|
276
|
+
for (const error of actual.errors) failures.push(`${route.path}: browser error: ${error}`);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
279
|
+
failures.push(`${route.path}: ${detail}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
} finally {
|
|
283
|
+
await Promise.all([staticContext.close(), hydratedContext.close()]);
|
|
284
|
+
}
|
|
285
|
+
return failures;
|
|
286
|
+
}
|
|
287
|
+
async function runVerifyHydrationCli(args = process.argv.slice(2), deps = {}, io = console) {
|
|
288
|
+
const parsed = parseVerifyHydrationArgs(args);
|
|
289
|
+
if (parsed.help) {
|
|
290
|
+
io.log(helpText);
|
|
291
|
+
return 0;
|
|
292
|
+
}
|
|
293
|
+
if (parsed.errors.length > 0) {
|
|
294
|
+
for (const error of parsed.errors) io.error(`Error: ${error}`);
|
|
295
|
+
return 1;
|
|
296
|
+
}
|
|
297
|
+
let server;
|
|
298
|
+
let browser;
|
|
299
|
+
try {
|
|
300
|
+
if (parsed.build) await (deps.runBuild ?? runNpmBuild)(parsed.cwd, parsed.buildScript);
|
|
301
|
+
const routes = await readRouteMetadata(parsed.outputDir, parsed.routes);
|
|
302
|
+
if (routes.length === 0) throw new Error("SSG metadata contains no successful routes to verify.");
|
|
303
|
+
server = await (deps.startServer ?? startStaticOutputServer)(parsed.outputDir, routes);
|
|
304
|
+
browser = await (deps.launchBrowser ?? launchChromium)(parsed.browserChannel);
|
|
305
|
+
const failures = await verifyHydrationRoutes(browser, server.origin, routes, parsed.rootSelector, parsed.timeoutMs);
|
|
306
|
+
if (failures.length > 0) {
|
|
307
|
+
for (const failure of failures) io.error(`Hydration verification failed: ${failure}`);
|
|
308
|
+
return 1;
|
|
309
|
+
}
|
|
310
|
+
io.log(`Verified hydration DOM for ${routes.length} route(s).`);
|
|
311
|
+
return 0;
|
|
312
|
+
} catch (error) {
|
|
313
|
+
io.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
314
|
+
return 1;
|
|
315
|
+
} finally {
|
|
316
|
+
await browser?.close().catch(() => void 0);
|
|
317
|
+
await server?.close().catch(() => void 0);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
async function main() {
|
|
321
|
+
process.exit(await runVerifyHydrationCli());
|
|
322
|
+
}
|
|
323
|
+
if (isDirectExecution(import.meta.url)) main();
|
|
324
|
+
//#endregion
|
|
325
|
+
export { parseVerifyHydrationArgs, runVerifyHydrationCli, startStaticOutputServer, verifyHydrationRoutes };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askrjs/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.14",
|
|
4
4
|
"description": "Unified CLI for the Askr platform",
|
|
5
5
|
"homepage": "https://github.com/askrjs/askr-cli#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"clean": "npx rimraf dist node_modules",
|
|
37
37
|
"build": "vp pack",
|
|
38
38
|
"dev": "vp pack --watch",
|
|
39
|
+
"analyze": "tsx src/bin/cli.ts analyze --check",
|
|
39
40
|
"test": "vp test run -c vitest.config.ts",
|
|
40
41
|
"test:coverage": "vp test run -c vitest.config.ts --coverage",
|
|
41
42
|
"fmt": "vp fmt .",
|
|
@@ -47,7 +48,7 @@
|
|
|
47
48
|
"bench": "npm run build --silent && npm run bench:analyze && node --import tsx benchmarks/cli.mjs --gate",
|
|
48
49
|
"bench:analyze": "vp test bench --run -c vitest.bench.config.ts",
|
|
49
50
|
"bench:json": "npm run build --silent && node --import tsx benchmarks/cli.mjs --gate --json",
|
|
50
|
-
"check": "npm run lint && npm run typecheck && npm run test:coverage && npm run build && npm run test:publint && npm run pack:check",
|
|
51
|
+
"check": "npm run analyze && npm run lint && npm run typecheck && npm run test:coverage && npm run build && npm run test:publint && npm run pack:check",
|
|
51
52
|
"prepack": "npm run build",
|
|
52
53
|
"prepublishOnly": "npm run check && npm run test:templates"
|
|
53
54
|
},
|
|
@@ -56,6 +57,7 @@
|
|
|
56
57
|
"js-yaml": "^5.2.1",
|
|
57
58
|
"minimatch": "^10.2.5",
|
|
58
59
|
"npm-registry-fetch": "^19.1.1",
|
|
60
|
+
"playwright-core": "^1.62.0",
|
|
59
61
|
"semver": "^7.8.5",
|
|
60
62
|
"tsx": "^4.23.1",
|
|
61
63
|
"typescript": "^6.0.3"
|
|
@@ -86,5 +88,12 @@
|
|
|
86
88
|
},
|
|
87
89
|
"engines": {
|
|
88
90
|
"node": "^20.19.0 || >=22.12.0"
|
|
91
|
+
},
|
|
92
|
+
"askr": {
|
|
93
|
+
"analyze": {
|
|
94
|
+
"exclude": [
|
|
95
|
+
"templates/**"
|
|
96
|
+
]
|
|
97
|
+
}
|
|
89
98
|
}
|
|
90
99
|
}
|