@askrjs/cli 0.0.13 → 0.0.15
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/cli.js +6 -0
- package/dist/verify-hydration.d.ts +35 -0
- package/dist/verify-hydration.js +325 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -49,6 +49,7 @@ unless you opt out with `--no-skills`.
|
|
|
49
49
|
- `askr skills install [--cwd <dir>] [--force]`
|
|
50
50
|
- `askr skills sync [--cwd <dir>]`
|
|
51
51
|
- `askr ssg --config <path> --output <dir> [--incremental]`
|
|
52
|
+
- `askr verify-hydration [--output ./dist] [--route <path>]...`
|
|
52
53
|
- `askr openapi [--entry ./src/api.ts] [--output ./openapi.yml] [--check]`
|
|
53
54
|
- `askr outdated [packages...] [--workspace <glob>] [--tag <tag>] [--json]`
|
|
54
55
|
- `askr update [packages...] [--workspace <glob>] [--tag <tag>] [--json]`
|
|
@@ -140,6 +141,24 @@ owned files so stale chunks and previous output paths are removed. Full and
|
|
|
140
141
|
incremental builds publish through a sibling staging directory, so route output,
|
|
141
142
|
metadata, assets, and sitemap artifacts change together or not at all.
|
|
142
143
|
|
|
144
|
+
## Hydration verification
|
|
145
|
+
|
|
146
|
+
`askr verify-hydration` builds SSG output, serves the generated route set, and
|
|
147
|
+
loads every successful metadata route in a real headless browser both with and
|
|
148
|
+
without JavaScript. It compares normalized tag-and-child topology under `#app`
|
|
149
|
+
after hydration, so text, classes, and mutable ARIA state do not create noise
|
|
150
|
+
while nodes migrating into the wrong sibling container fail with an actionable
|
|
151
|
+
static-versus-hydrated path diff.
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
askr verify-hydration
|
|
155
|
+
askr verify-hydration --route / --route /docs
|
|
156
|
+
askr verify-hydration --no-build --output ./dist
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
See the [hydration verification reference](./docs/verify-hydration.md) for
|
|
160
|
+
browser installation, timeout, route, and root-selector options.
|
|
161
|
+
|
|
143
162
|
## OpenAPI artifacts
|
|
144
163
|
|
|
145
164
|
`askr openapi` loads a TypeScript module whose default export exposes
|
package/dist/cli.js
CHANGED
|
@@ -41,6 +41,7 @@ function printHelp(io = console) {
|
|
|
41
41
|
io.log(" openapi Generate or check an OpenAPI YAML artifact");
|
|
42
42
|
io.log(" skills Install or sync Askr agent skills");
|
|
43
43
|
io.log(" ssg Run static-site generation");
|
|
44
|
+
io.log(" verify-hydration Verify SSG DOM structure in a real browser");
|
|
44
45
|
io.log(" outdated List available dependency updates");
|
|
45
46
|
io.log(" repair Apply safe fixes and identify remaining semantic work");
|
|
46
47
|
io.log(" update Apply safe dependency updates");
|
|
@@ -63,6 +64,7 @@ function printHelp(io = console) {
|
|
|
63
64
|
io.log(" askr openapi --check");
|
|
64
65
|
io.log(" askr skills review foundation --cwd ./candidate-app");
|
|
65
66
|
io.log(" askr ssg --config ./ssg.config.ts --output ./dist/static");
|
|
67
|
+
io.log(" askr verify-hydration --output ./dist --route /");
|
|
66
68
|
io.log(" askr outdated");
|
|
67
69
|
io.log(" askr update");
|
|
68
70
|
io.log(" askr upgrade");
|
|
@@ -101,6 +103,10 @@ async function runCli(args = process.argv.slice(2), io = console) {
|
|
|
101
103
|
const { runSsgCli } = await import("./ssg.js");
|
|
102
104
|
return runSsgCli(args.slice(1), void 0, io);
|
|
103
105
|
}
|
|
106
|
+
if (command === "verify-hydration") {
|
|
107
|
+
const { runVerifyHydrationCli } = await import("./verify-hydration.js");
|
|
108
|
+
return runVerifyHydrationCli(args.slice(1), void 0, io);
|
|
109
|
+
}
|
|
104
110
|
if (command === "openapi") {
|
|
105
111
|
const { runOpenApiCli } = await import("./openapi.js");
|
|
106
112
|
return runOpenApiCli(args.slice(1), io);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Browser } from "playwright-core";
|
|
2
|
+
//#region src/bin/verify-hydration.d.ts
|
|
3
|
+
type CliIo = Pick<Console, "error" | "log">;
|
|
4
|
+
interface ParsedVerifyHydrationArgs {
|
|
5
|
+
cwd: string;
|
|
6
|
+
outputDir: string;
|
|
7
|
+
routes: string[];
|
|
8
|
+
rootSelector: string;
|
|
9
|
+
buildScript: string;
|
|
10
|
+
build: boolean;
|
|
11
|
+
timeoutMs: number;
|
|
12
|
+
browserChannel?: string;
|
|
13
|
+
help: boolean;
|
|
14
|
+
errors: string[];
|
|
15
|
+
}
|
|
16
|
+
interface RouteMetadata {
|
|
17
|
+
path: string;
|
|
18
|
+
filePath: string;
|
|
19
|
+
status?: string;
|
|
20
|
+
}
|
|
21
|
+
interface StaticOutputServer {
|
|
22
|
+
origin: string;
|
|
23
|
+
close(): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
interface VerifyHydrationDeps {
|
|
26
|
+
runBuild?: (cwd: string, script: string) => Promise<void>;
|
|
27
|
+
launchBrowser?: (channel?: string) => Promise<Browser>;
|
|
28
|
+
startServer?: (outputDir: string, routes: readonly RouteMetadata[]) => Promise<StaticOutputServer>;
|
|
29
|
+
}
|
|
30
|
+
declare function parseVerifyHydrationArgs(args: string[], defaultCwd?: string): ParsedVerifyHydrationArgs;
|
|
31
|
+
declare function startStaticOutputServer(outputDir: string, routes: readonly RouteMetadata[]): Promise<StaticOutputServer>;
|
|
32
|
+
declare function verifyHydrationRoutes(browser: Browser, origin: string, routes: readonly RouteMetadata[], rootSelector: string, timeoutMs: number): Promise<string[]>;
|
|
33
|
+
declare function runVerifyHydrationCli(args?: string[], deps?: VerifyHydrationDeps, io?: CliIo): Promise<number>;
|
|
34
|
+
//#endregion
|
|
35
|
+
export { parseVerifyHydrationArgs, runVerifyHydrationCli, startStaticOutputServer, verifyHydrationRoutes };
|
|
@@ -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.15",
|
|
4
4
|
"description": "Unified CLI for the Askr platform",
|
|
5
5
|
"homepage": "https://github.com/askrjs/askr-cli#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"js-yaml": "^5.2.1",
|
|
58
58
|
"minimatch": "^10.2.5",
|
|
59
59
|
"npm-registry-fetch": "^19.1.1",
|
|
60
|
+
"playwright-core": "^1.62.0",
|
|
60
61
|
"semver": "^7.8.5",
|
|
61
62
|
"tsx": "^4.23.1",
|
|
62
63
|
"typescript": "^6.0.3"
|