@crvy/rprtr 0.0.8 → 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/CHANGELOG.md +55 -0
- package/dist/{chunk-XU5VQ3FZ.js → chunk-35CZLYSW.js} +223 -192
- package/dist/{chunk-EJRLZZ22.js → chunk-43JLQN36.js} +45 -5
- package/dist/ci.d.ts +2 -0
- package/dist/ci.d.ts.map +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +7 -4
- package/dist/index.js +338 -46
- package/dist/report-utils.d.ts.map +1 -1
- package/dist/reporter-artifact-ops.d.ts +1 -3
- package/dist/reporter-artifact-ops.d.ts.map +1 -1
- package/dist/reporter.cjs +285 -198
- package/dist/reporter.d.ts +5 -3
- package/dist/reporter.d.ts.map +1 -1
- package/dist/reporter.js +81 -31
- package/dist/schemas.d.ts +210 -1
- package/dist/schemas.d.ts.map +1 -1
- package/dist/server/app.d.ts +6 -0
- package/dist/server/app.d.ts.map +1 -1
- package/dist/server/artifact-routes.d.ts +8 -0
- package/dist/server/artifact-routes.d.ts.map +1 -0
- package/dist/server/file-utils.d.ts.map +1 -1
- package/dist/server/handlers.d.ts +6 -2
- package/dist/server/handlers.d.ts.map +1 -1
- package/dist/server/routes.d.ts +2 -2
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/utils.d.ts +3 -0
- package/dist/server/utils.d.ts.map +1 -1
- package/dist/server.cjs +281 -228
- package/dist/server.js +2 -2
- package/dist/snapshot-path-resolver.d.ts +2 -0
- package/dist/snapshot-path-resolver.d.ts.map +1 -1
- package/dist/types.d.ts +24 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/dist/server/report-watch.d.ts +0 -9
- package/dist/server/report-watch.d.ts.map +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,61 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.1.0] - 2026-06-08
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **resolver:** Expose playwrightAnonymousVisualName
|
|
13
|
+
- **resolver:** Add withResolvedVisualNames
|
|
14
|
+
- **server:** Add isPathWithinRoots allowlist helper
|
|
15
|
+
- **server:** Add allowlisted /file artifact route
|
|
16
|
+
- **server:** Add /baseline route, consolidate baseline resolution
|
|
17
|
+
- **server:** Wire artifactRoots and outputDir option
|
|
18
|
+
- Add isCI helper
|
|
19
|
+
- **report-utils:** Route absolute attachment paths to /file
|
|
20
|
+
- **server:** Enrich declared-only images with /baseline urls
|
|
21
|
+
- **reporter:** Add rewriteTestEndAttachments helper
|
|
22
|
+
- **reporter:** Gate screenshot copying and artifacts on CI
|
|
23
|
+
- **cli:** Add --output-dir for live native artifact serving
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- **resolver:** Compute anonymous title list once
|
|
28
|
+
|
|
29
|
+
### Documentation
|
|
30
|
+
|
|
31
|
+
- **spec:** CI-gated screenshot artifact handling design
|
|
32
|
+
- **plans:** Naming fix, live-mode serving, CI-gated copying
|
|
33
|
+
- **server:** Clarify outputDir is resolved against CWD
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- Apply web socket payloads directly to the client tree
|
|
38
|
+
- **reporter:** Use Playwright auto-name for unnamed screenshots
|
|
39
|
+
- **server:** Realpath-guard /file route and set nosniff header
|
|
40
|
+
- **server:** Guard /baseline decode, retry parsing, and read errors
|
|
41
|
+
- **server:** Exclude test source dir from /file allowlist
|
|
42
|
+
- **server:** Guard /baseline url parsing, decode testId, test nosniff
|
|
43
|
+
- **server:** Decode /file URLs in approval path
|
|
44
|
+
- **server:** Only enrich declared-only baselines for passed tests
|
|
45
|
+
- **server:** Enrich baselines only when the snapshot file exists
|
|
46
|
+
- **reporter:** Bound onEnd concurrency, fix retry attachments, stop CI queue growth
|
|
47
|
+
|
|
48
|
+
### Styling
|
|
49
|
+
|
|
50
|
+
- **client:** Center-align image comparison views
|
|
51
|
+
|
|
52
|
+
### Testing
|
|
53
|
+
|
|
54
|
+
- **report-state:** Assert no phantom entry for finalized unnamed screenshot
|
|
55
|
+
- **report-state:** Make phantom-entry guard falsifiable
|
|
56
|
+
- Document phantom fixture divergence and cover multi-occurrence rewrite
|
|
57
|
+
- **server:** Cover raw-traversal and empty-roots cases for isPathWithinRoots
|
|
58
|
+
## [0.0.9] - 2026-06-05
|
|
59
|
+
|
|
60
|
+
### Fixed
|
|
61
|
+
|
|
62
|
+
- Remove file watcher that interfered with live updates
|
|
8
63
|
## [0.0.8] - 2026-06-05
|
|
9
64
|
|
|
10
65
|
### Fixed
|
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ApproveRequestBodySchema,
|
|
3
|
+
IncomingWebSocketMessageSchema,
|
|
3
4
|
LoadedReportDataSchema,
|
|
4
5
|
OfflineReportSchema,
|
|
6
|
+
RunEndDataSchema,
|
|
5
7
|
TestBeginDataSchema,
|
|
6
8
|
TestEndDataSchema,
|
|
7
|
-
WebSocketMessageSchema,
|
|
8
9
|
applyTestBeginEvent,
|
|
9
10
|
applyTestEndEvent,
|
|
10
11
|
createMutableReportState,
|
|
11
12
|
finalizeRunEvent,
|
|
12
13
|
resolveBaselineTargets,
|
|
13
14
|
safeParse
|
|
14
|
-
} from "./chunk-
|
|
15
|
+
} from "./chunk-43JLQN36.js";
|
|
15
16
|
|
|
16
17
|
// src/server/app.ts
|
|
17
|
-
import { dirname as dirname3, join as
|
|
18
|
+
import { dirname as dirname3, join as join3 } from "path";
|
|
18
19
|
import { fileURLToPath } from "url";
|
|
19
20
|
import pLimit from "p-limit";
|
|
20
21
|
|
|
@@ -157,7 +158,10 @@ async function respondWithFile(filePath, contentType) {
|
|
|
157
158
|
try {
|
|
158
159
|
const file = await readFile(filePath);
|
|
159
160
|
const resolvedContentType = contentType ?? inferContentType(filePath);
|
|
160
|
-
const headers =
|
|
161
|
+
const headers = { "X-Content-Type-Options": "nosniff" };
|
|
162
|
+
if (resolvedContentType !== void 0) {
|
|
163
|
+
headers["Content-Type"] = resolvedContentType;
|
|
164
|
+
}
|
|
161
165
|
return new Response(file, { headers });
|
|
162
166
|
} catch (error) {
|
|
163
167
|
if (isFileNotFound(error)) {
|
|
@@ -167,166 +171,212 @@ async function respondWithFile(filePath, contentType) {
|
|
|
167
171
|
}
|
|
168
172
|
}
|
|
169
173
|
|
|
174
|
+
// src/server/handlers.ts
|
|
175
|
+
import { existsSync as existsSync2 } from "fs";
|
|
176
|
+
|
|
177
|
+
// src/server/artifact-routes.ts
|
|
178
|
+
import { existsSync } from "fs";
|
|
179
|
+
import { realpath } from "fs/promises";
|
|
180
|
+
import { dirname as dirname2, resolve as resolve2 } from "path";
|
|
181
|
+
|
|
170
182
|
// src/server/utils.ts
|
|
183
|
+
import { isAbsolute, relative, resolve, sep } from "path";
|
|
184
|
+
var LIVE_UPDATES_WEBSOCKET_PATH = "/";
|
|
171
185
|
function broadcastToBrowsers(wsClients, msg) {
|
|
172
186
|
const payload = JSON.stringify(msg);
|
|
173
187
|
wsClients.forEach((ws) => {
|
|
174
188
|
ws.send(payload);
|
|
175
189
|
});
|
|
176
190
|
}
|
|
191
|
+
function isWebSocketUpgradeRequest(req) {
|
|
192
|
+
return new URL(req.url).pathname === LIVE_UPDATES_WEBSOCKET_PATH && req.headers.get("upgrade")?.toLowerCase() === "websocket";
|
|
193
|
+
}
|
|
194
|
+
function isPathWithinRoots(target, roots) {
|
|
195
|
+
const resolvedTarget = resolve(target);
|
|
196
|
+
return roots.some((root) => {
|
|
197
|
+
const rel = relative(resolve(root), resolvedTarget);
|
|
198
|
+
return rel === "" || !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// src/server/artifact-routes.ts
|
|
203
|
+
async function realpathOrNull(path) {
|
|
204
|
+
try {
|
|
205
|
+
return await realpath(path);
|
|
206
|
+
} catch {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
async function handleFile(ctx, req) {
|
|
211
|
+
const notFound = () => new Response("Not Found", { status: 404 });
|
|
212
|
+
let decodedPath;
|
|
213
|
+
try {
|
|
214
|
+
decodedPath = resolve2(decodeURIComponent(new URL(req.url).pathname.slice("/file/".length)));
|
|
215
|
+
} catch {
|
|
216
|
+
return notFound();
|
|
217
|
+
}
|
|
218
|
+
const realTarget = await realpathOrNull(decodedPath);
|
|
219
|
+
if (realTarget === null) {
|
|
220
|
+
return notFound();
|
|
221
|
+
}
|
|
222
|
+
const realRoots = (await Promise.all((ctx.artifactRoots ?? []).map((root) => realpathOrNull(resolve2(root))))).filter(
|
|
223
|
+
(root) => root !== null
|
|
224
|
+
);
|
|
225
|
+
if (!isPathWithinRoots(realTarget, realRoots)) {
|
|
226
|
+
return notFound();
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
const file = await respondWithFile(realTarget);
|
|
230
|
+
return file ?? notFound();
|
|
231
|
+
} catch {
|
|
232
|
+
return notFound();
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function reporterTitlePath(test) {
|
|
236
|
+
const testFile = test.location?.file;
|
|
237
|
+
return ["", test.browser, testFile ?? "", ...test.titlePath, test.title];
|
|
238
|
+
}
|
|
239
|
+
function resolveBaselineSnapshotPath(routing, test, retry, imageName) {
|
|
240
|
+
const testFile = test.location?.file;
|
|
241
|
+
const declaration = test.results?.[retry]?.visualDeclarations?.find((candidate) => candidate.visualName === imageName);
|
|
242
|
+
if (routing === void 0 || testFile === void 0 || declaration === void 0) {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
const targets = resolveBaselineTargets({
|
|
246
|
+
testFile,
|
|
247
|
+
reporterTitlePath: reporterTitlePath(test),
|
|
248
|
+
declarations: [declaration],
|
|
249
|
+
config: {
|
|
250
|
+
configDir: routing.configDir,
|
|
251
|
+
testDir: routing.playwrightTestDir ?? dirname2(testFile),
|
|
252
|
+
snapshotDir: routing.playwrightSnapshotDir ?? dirname2(testFile),
|
|
253
|
+
projectName: test.browser,
|
|
254
|
+
snapshotSuffix: process.platform,
|
|
255
|
+
snapshotPathTemplate: routing.playwrightSnapshotPathTemplate,
|
|
256
|
+
toHaveScreenshotPathTemplate: routing.playwrightToHaveScreenshotPathTemplate
|
|
257
|
+
},
|
|
258
|
+
snapshotPathExists: existsSync
|
|
259
|
+
});
|
|
260
|
+
return targets.length === 1 ? targets[0]?.snapshotPath ?? null : null;
|
|
261
|
+
}
|
|
262
|
+
async function handleBaseline(ctx, req) {
|
|
263
|
+
const notFound = () => new Response("Not Found", { status: 404 });
|
|
264
|
+
let testId;
|
|
265
|
+
let retry;
|
|
266
|
+
let visualName;
|
|
267
|
+
try {
|
|
268
|
+
const segments = new URL(req.url).pathname.slice("/baseline/".length).split("/");
|
|
269
|
+
const [rawTestId, retryRaw, ...visualNameParts] = segments;
|
|
270
|
+
if (rawTestId === void 0 || retryRaw === void 0 || !/^\d+$/.test(retryRaw) || visualNameParts.length === 0) {
|
|
271
|
+
return notFound();
|
|
272
|
+
}
|
|
273
|
+
testId = decodeURIComponent(rawTestId);
|
|
274
|
+
retry = Number(retryRaw);
|
|
275
|
+
visualName = decodeURIComponent(visualNameParts.join("/"));
|
|
276
|
+
} catch {
|
|
277
|
+
return notFound();
|
|
278
|
+
}
|
|
279
|
+
const test = ctx.reportData.tests[testId];
|
|
280
|
+
if (test === void 0) {
|
|
281
|
+
return notFound();
|
|
282
|
+
}
|
|
283
|
+
const snapshotPath = resolveBaselineSnapshotPath(ctx.approvalRouting, test, retry, visualName);
|
|
284
|
+
if (snapshotPath === null) {
|
|
285
|
+
return notFound();
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
const file = await respondWithFile(snapshotPath);
|
|
289
|
+
return file ?? notFound();
|
|
290
|
+
} catch {
|
|
291
|
+
return notFound();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
function handleArtifactRoute(ctx, req, pathname) {
|
|
295
|
+
if (pathname.startsWith("/file/")) {
|
|
296
|
+
return handleFile(ctx, req);
|
|
297
|
+
}
|
|
298
|
+
if (pathname.startsWith("/baseline/")) {
|
|
299
|
+
return handleBaseline(ctx, req);
|
|
300
|
+
}
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
177
303
|
|
|
178
304
|
// src/server/handlers.ts
|
|
179
305
|
function handleTestBegin(ctx, data) {
|
|
180
306
|
const test = applyTestBeginEvent(ctx, data);
|
|
181
307
|
ctx.reportData.isRunning = true;
|
|
182
308
|
console.log(` \u25B6 [${test.browser ?? "?"}] ${test.title}`);
|
|
183
|
-
|
|
309
|
+
const message = { type: "test-begin", data: test };
|
|
310
|
+
broadcastToBrowsers(ctx.wsClients, message);
|
|
311
|
+
}
|
|
312
|
+
function enrichDeclaredBaselines(ctx, test) {
|
|
313
|
+
const retry = (test.results?.length ?? 0) - 1;
|
|
314
|
+
const images = test.results?.[retry]?.images;
|
|
315
|
+
if (retry < 0 || images === void 0) {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
for (const [visualName, image] of Object.entries(images)) {
|
|
319
|
+
if (image === void 0 || image.source !== "declared-only") {
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
const snapshotPath = resolveBaselineSnapshotPath(ctx.approvalRouting, test, retry, visualName);
|
|
323
|
+
if (snapshotPath === null || !existsSync2(snapshotPath)) {
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
image.expect = `/baseline/${encodeURIComponent(test.id)}/${retry}/${encodeURIComponent(visualName)}`;
|
|
327
|
+
image.source = "baseline-only";
|
|
328
|
+
}
|
|
184
329
|
}
|
|
185
330
|
function handleTestEnd(ctx, data) {
|
|
186
331
|
const result = applyTestEndEvent(ctx, data);
|
|
187
|
-
if (result
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const dur = data.duration === null || data.duration === void 0 ? "" : ` (${data.duration}ms)`;
|
|
191
|
-
const diffNote = diffCount > 0 ? ` [${diffCount} diff(s)]` : "";
|
|
192
|
-
const errNote = data.error !== null && data.error !== void 0 ? `
|
|
193
|
-
Error: ${data.error}` : "";
|
|
194
|
-
console.log(` ${icon} [${test.browser}] ${test.title}${dur}${diffNote}${errNote}`);
|
|
332
|
+
if (result === null) {
|
|
333
|
+
console.error("[Server] test-end for unknown test id:", data.id);
|
|
334
|
+
return;
|
|
195
335
|
}
|
|
196
|
-
|
|
336
|
+
const { test, diffCount } = result;
|
|
337
|
+
if (data.status === "passed") {
|
|
338
|
+
enrichDeclaredBaselines(ctx, test);
|
|
339
|
+
}
|
|
340
|
+
const icon = data.status === "passed" ? "\u2713" : data.status === "skipped" ? "\u2013" : "\u2717";
|
|
341
|
+
const dur = data.duration === null || data.duration === void 0 ? "" : ` (${data.duration}ms)`;
|
|
342
|
+
const diffNote = diffCount > 0 ? ` [${diffCount} diff(s)]` : "";
|
|
343
|
+
const errNote = data.error !== null && data.error !== void 0 ? `
|
|
344
|
+
Error: ${data.error}` : "";
|
|
345
|
+
console.log(` ${icon} [${test.browser}] ${test.title}${dur}${diffNote}${errNote}`);
|
|
346
|
+
const message = { type: "test-update", data: test };
|
|
347
|
+
broadcastToBrowsers(ctx.wsClients, message);
|
|
197
348
|
}
|
|
198
349
|
async function handleRunEnd(ctx, data) {
|
|
350
|
+
const removedTestIds = Object.keys(ctx.reportData.tests).filter((id) => !ctx.currentRunIds.has(id));
|
|
199
351
|
const { passed, failed, pending } = finalizeRunEvent(ctx);
|
|
200
352
|
await ctx.saveReport();
|
|
201
353
|
console.log(`
|
|
202
354
|
Run complete \u2014 ${passed} passed, ${failed} failed, ${pending} skipped`);
|
|
203
|
-
|
|
355
|
+
const message = {
|
|
356
|
+
type: "run-end",
|
|
357
|
+
data: { status: data.status, removedTestIds }
|
|
358
|
+
};
|
|
359
|
+
broadcastToBrowsers(ctx.wsClients, message);
|
|
204
360
|
}
|
|
205
361
|
function handleApprove() {
|
|
206
362
|
console.log("[Server] Received approve message via WebSocket (handled via HTTP API)");
|
|
207
363
|
}
|
|
208
364
|
function handleSync(ctx) {
|
|
209
365
|
console.log("[Server] Received sync message");
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
import { join as join2 } from "path";
|
|
216
|
-
var OFFLINE_REPORT_FILE_PATTERN2 = /^crvy-rprtr(?:-\d+)?\.json$/;
|
|
217
|
-
function createDebouncedRefresh(reload, delayMs = 50) {
|
|
218
|
-
let timer = null;
|
|
219
|
-
return () => {
|
|
220
|
-
if (timer !== null) {
|
|
221
|
-
clearTimeout(timer);
|
|
222
|
-
}
|
|
223
|
-
timer = setTimeout(() => {
|
|
224
|
-
timer = null;
|
|
225
|
-
void reload();
|
|
226
|
-
}, delayMs);
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
|
-
function isFileNotFound2(error) {
|
|
230
|
-
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
231
|
-
}
|
|
232
|
-
async function describeFile(filePath, label) {
|
|
233
|
-
try {
|
|
234
|
-
const fileStats = await stat2(filePath);
|
|
235
|
-
return `${label}:${fileStats.size}:${fileStats.mtimeMs}`;
|
|
236
|
-
} catch (error) {
|
|
237
|
-
if (isFileNotFound2(error)) {
|
|
238
|
-
return null;
|
|
366
|
+
const message = {
|
|
367
|
+
type: "sync",
|
|
368
|
+
data: {
|
|
369
|
+
tests: ctx.reportData.tests,
|
|
370
|
+
isUpdateMode: ctx.reportData.isUpdateMode
|
|
239
371
|
}
|
|
240
|
-
throw error;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
async function createOfflineFingerprint(offlineReportDir) {
|
|
244
|
-
if (!await isDirectory(offlineReportDir)) {
|
|
245
|
-
return "offline:missing";
|
|
246
|
-
}
|
|
247
|
-
try {
|
|
248
|
-
const entries = await readdir2(offlineReportDir, { withFileTypes: true });
|
|
249
|
-
const relevantEntries = entries.filter(
|
|
250
|
-
(entry) => entry.isFile() && (entry.name === "report.json" || OFFLINE_REPORT_FILE_PATTERN2.test(entry.name))
|
|
251
|
-
).sort((left, right) => left.name.localeCompare(right.name));
|
|
252
|
-
const parts = await Promise.all(
|
|
253
|
-
relevantEntries.map((entry) => describeFile(join2(offlineReportDir, entry.name), entry.name))
|
|
254
|
-
);
|
|
255
|
-
return `offline:${parts.filter((part) => part !== null).join("|")}`;
|
|
256
|
-
} catch (error) {
|
|
257
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
258
|
-
console.warn(`[Server] Unable to scan ${offlineReportDir}: ${errorMsg}`);
|
|
259
|
-
return "offline:error";
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
async function createScreenshotFingerprint(screenshotDir, relativePath = "") {
|
|
263
|
-
const directoryPath = relativePath === "" ? screenshotDir : join2(screenshotDir, relativePath);
|
|
264
|
-
if (!await isDirectory(directoryPath)) {
|
|
265
|
-
return relativePath === "" ? ["screenshots:missing"] : [];
|
|
266
|
-
}
|
|
267
|
-
try {
|
|
268
|
-
const entries = await readdir2(directoryPath, { withFileTypes: true });
|
|
269
|
-
const parts = await Promise.all(
|
|
270
|
-
entries.sort((left, right) => left.name.localeCompare(right.name)).map((entry) => {
|
|
271
|
-
const entryRelativePath = relativePath === "" ? entry.name : join2(relativePath, entry.name);
|
|
272
|
-
if (entry.isDirectory()) {
|
|
273
|
-
return createScreenshotFingerprint(screenshotDir, entryRelativePath);
|
|
274
|
-
}
|
|
275
|
-
return describeFile(join2(screenshotDir, entryRelativePath), entryRelativePath);
|
|
276
|
-
})
|
|
277
|
-
);
|
|
278
|
-
return parts.flat().filter((part) => part !== null);
|
|
279
|
-
} catch (error) {
|
|
280
|
-
if (isFileNotFound2(error)) {
|
|
281
|
-
return relativePath === "" ? ["screenshots:missing"] : [];
|
|
282
|
-
}
|
|
283
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
284
|
-
console.warn(`[Server] Unable to scan ${directoryPath}: ${errorMsg}`);
|
|
285
|
-
return relativePath === "" ? ["screenshots:error"] : [];
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
async function createArtifactsFingerprint(options) {
|
|
289
|
-
const [offlineFingerprint, screenshotFingerprint] = await Promise.all([
|
|
290
|
-
createOfflineFingerprint(options.offlineReportDir),
|
|
291
|
-
createScreenshotFingerprint(options.screenshotDir)
|
|
292
|
-
]);
|
|
293
|
-
return `${offlineFingerprint}::${screenshotFingerprint.join("|")}`;
|
|
294
|
-
}
|
|
295
|
-
async function watchReportArtifacts(options) {
|
|
296
|
-
let fingerprint = await createArtifactsFingerprint(options);
|
|
297
|
-
let isPolling = false;
|
|
298
|
-
const interval = setInterval(() => {
|
|
299
|
-
if (isPolling) {
|
|
300
|
-
return;
|
|
301
|
-
}
|
|
302
|
-
isPolling = true;
|
|
303
|
-
void createArtifactsFingerprint(options).then((nextFingerprint) => {
|
|
304
|
-
if (nextFingerprint === fingerprint) {
|
|
305
|
-
return;
|
|
306
|
-
}
|
|
307
|
-
fingerprint = nextFingerprint;
|
|
308
|
-
options.scheduleRefresh();
|
|
309
|
-
}).catch((error) => {
|
|
310
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
311
|
-
console.warn(`[Server] Artifact refresh poll failed: ${errorMsg}`);
|
|
312
|
-
}).finally(() => {
|
|
313
|
-
isPolling = false;
|
|
314
|
-
});
|
|
315
|
-
}, 100);
|
|
316
|
-
return () => {
|
|
317
|
-
clearInterval(interval);
|
|
318
372
|
};
|
|
373
|
+
broadcastToBrowsers(ctx.wsClients, message);
|
|
319
374
|
}
|
|
320
375
|
|
|
321
376
|
// src/server/routes.ts
|
|
322
|
-
import {
|
|
323
|
-
import { dirname as dirname2, join as join3 } from "path";
|
|
324
|
-
var LIVE_UPDATES_WEBSOCKET_PATH = "/";
|
|
325
|
-
function isWebSocketUpgradeRequest(req) {
|
|
326
|
-
return new URL(req.url).pathname === LIVE_UPDATES_WEBSOCKET_PATH && req.headers.get("upgrade")?.toLowerCase() === "websocket";
|
|
327
|
-
}
|
|
377
|
+
import { join as join2 } from "path";
|
|
328
378
|
async function handleRoot(ctx) {
|
|
329
|
-
const html = await respondWithFile(
|
|
379
|
+
const html = await respondWithFile(join2(ctx.staticDir, "index.html"), "text/html");
|
|
330
380
|
return html ?? new Response("Not Found", { status: 404 });
|
|
331
381
|
}
|
|
332
382
|
async function handleAppCss() {
|
|
@@ -345,34 +395,13 @@ function handleApiReport(ctx) {
|
|
|
345
395
|
}
|
|
346
396
|
var APPROVAL_TARGET_ERROR = "Could not resolve approval target";
|
|
347
397
|
function actualPathFromUrl(ctx, actualUrl) {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
function reporterTitlePath(test) {
|
|
351
|
-
const testFile = test.location?.file;
|
|
352
|
-
return ["", test.browser, testFile ?? "", ...test.titlePath, test.title];
|
|
353
|
-
}
|
|
354
|
-
function resolveApprovalTarget(ctx, test, retry, imageName) {
|
|
355
|
-
const testFile = test.location?.file;
|
|
356
|
-
const declaration = test.results?.[retry]?.visualDeclarations?.find((candidate) => candidate.visualName === imageName);
|
|
357
|
-
if (ctx.approvalRouting === void 0 || testFile === void 0 || declaration === void 0) {
|
|
358
|
-
return null;
|
|
398
|
+
if (actualUrl.startsWith("/screenshots/")) {
|
|
399
|
+
return join2(ctx.reportData.screenshotDir, actualUrl.slice("/screenshots/".length));
|
|
359
400
|
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
config: {
|
|
365
|
-
configDir: ctx.approvalRouting.configDir,
|
|
366
|
-
testDir: ctx.approvalRouting.playwrightTestDir ?? dirname2(testFile),
|
|
367
|
-
snapshotDir: ctx.approvalRouting.playwrightSnapshotDir ?? dirname2(testFile),
|
|
368
|
-
projectName: test.browser,
|
|
369
|
-
snapshotSuffix: process.platform,
|
|
370
|
-
snapshotPathTemplate: ctx.approvalRouting.playwrightSnapshotPathTemplate,
|
|
371
|
-
toHaveScreenshotPathTemplate: ctx.approvalRouting.playwrightToHaveScreenshotPathTemplate
|
|
372
|
-
},
|
|
373
|
-
snapshotPathExists: existsSync
|
|
374
|
-
});
|
|
375
|
-
return targets.length === 1 ? targets[0]?.snapshotPath ?? null : null;
|
|
401
|
+
if (actualUrl.startsWith("/file/")) {
|
|
402
|
+
return decodeURIComponent(actualUrl.slice("/file/".length));
|
|
403
|
+
}
|
|
404
|
+
return actualUrl;
|
|
376
405
|
}
|
|
377
406
|
async function handleApiApprove(ctx, req) {
|
|
378
407
|
try {
|
|
@@ -391,7 +420,7 @@ async function handleApiApprove(ctx, req) {
|
|
|
391
420
|
if (actualUrl === void 0) {
|
|
392
421
|
return Response.json({ success: false, error: "Actual image not found" }, { status: 409 });
|
|
393
422
|
}
|
|
394
|
-
const snapshotPath =
|
|
423
|
+
const snapshotPath = resolveBaselineSnapshotPath(ctx.approvalRouting, test, retry, image);
|
|
395
424
|
if (snapshotPath === null) {
|
|
396
425
|
return Response.json({ success: false, error: APPROVAL_TARGET_ERROR }, { status: 409 });
|
|
397
426
|
}
|
|
@@ -426,7 +455,7 @@ function createBulkApprovalUpdates(ctx) {
|
|
|
426
455
|
if (actualUrl === void 0) {
|
|
427
456
|
return [Promise.resolve({ kind: "unresolved" })];
|
|
428
457
|
}
|
|
429
|
-
const snapshotPath =
|
|
458
|
+
const snapshotPath = resolveBaselineSnapshotPath(ctx.approvalRouting, test, lastRetry, imageName);
|
|
430
459
|
if (snapshotPath === null) {
|
|
431
460
|
return [Promise.resolve({ kind: "unresolved" })];
|
|
432
461
|
}
|
|
@@ -489,7 +518,7 @@ async function handleScreenshots(ctx, req) {
|
|
|
489
518
|
}
|
|
490
519
|
async function handleDist(ctx, req) {
|
|
491
520
|
const path = new URL(req.url).pathname.slice("/dist/".length);
|
|
492
|
-
const filePath =
|
|
521
|
+
const filePath = join2(ctx.staticDir, path);
|
|
493
522
|
const contentType = filePath.endsWith(".css") ? "text/css" : filePath.endsWith(".js") ? "application/javascript" : filePath.endsWith(".svelte") ? "text/plain" : "application/octet-stream";
|
|
494
523
|
const file = await respondWithFile(filePath, contentType);
|
|
495
524
|
return file ?? new Response("Not Found", { status: 404 });
|
|
@@ -517,6 +546,10 @@ function handleHttpRequest(ctx, req) {
|
|
|
517
546
|
if (pathname.startsWith("/api/images/")) {
|
|
518
547
|
return handleApiImages(req);
|
|
519
548
|
}
|
|
549
|
+
const artifactResponse = handleArtifactRoute(ctx, req, pathname);
|
|
550
|
+
if (artifactResponse !== null) {
|
|
551
|
+
return artifactResponse;
|
|
552
|
+
}
|
|
520
553
|
if (pathname.startsWith("/screenshots/")) {
|
|
521
554
|
return handleScreenshots(ctx, req);
|
|
522
555
|
}
|
|
@@ -537,12 +570,13 @@ function createReportData(options) {
|
|
|
537
570
|
screenshotDir: options.screenshotDir ?? "./screenshots"
|
|
538
571
|
};
|
|
539
572
|
}
|
|
540
|
-
function createHandlerContext(reportData, wsClients, currentRunIds, saveReport) {
|
|
573
|
+
function createHandlerContext(reportData, wsClients, currentRunIds, saveReport, approvalRouting) {
|
|
541
574
|
return {
|
|
542
575
|
reportData,
|
|
543
576
|
wsClients,
|
|
544
577
|
currentRunIds,
|
|
545
|
-
saveReport
|
|
578
|
+
saveReport,
|
|
579
|
+
approvalRouting
|
|
546
580
|
};
|
|
547
581
|
}
|
|
548
582
|
async function loadReport(reportPath, reportData) {
|
|
@@ -592,10 +626,6 @@ async function loadOfflineReports(offlineReportDir, reportData) {
|
|
|
592
626
|
screenshotsBaseUrl: "/screenshots/"
|
|
593
627
|
});
|
|
594
628
|
}
|
|
595
|
-
function resetReloadableReportData(reportData) {
|
|
596
|
-
reportData.tests = {};
|
|
597
|
-
reportData.isUpdateMode = false;
|
|
598
|
-
}
|
|
599
629
|
async function handleParsedWebSocketMessage(ctx, msg) {
|
|
600
630
|
switch (msg.type) {
|
|
601
631
|
case "test-begin": {
|
|
@@ -616,9 +646,15 @@ async function handleParsedWebSocketMessage(ctx, msg) {
|
|
|
616
646
|
handleTestEnd(ctx, parsed);
|
|
617
647
|
break;
|
|
618
648
|
}
|
|
619
|
-
case "run-end":
|
|
620
|
-
|
|
649
|
+
case "run-end": {
|
|
650
|
+
const parsed = safeParse(RunEndDataSchema, msg.data);
|
|
651
|
+
if (parsed === null) {
|
|
652
|
+
console.error("Invalid run-end message data", msg.data);
|
|
653
|
+
break;
|
|
654
|
+
}
|
|
655
|
+
await handleRunEnd(ctx, parsed);
|
|
621
656
|
break;
|
|
657
|
+
}
|
|
622
658
|
case "approve":
|
|
623
659
|
handleApprove();
|
|
624
660
|
break;
|
|
@@ -631,7 +667,7 @@ function createWebSocketMessageHandler(getHandlerContext) {
|
|
|
631
667
|
return async function handleWebSocketMessage(message) {
|
|
632
668
|
try {
|
|
633
669
|
const parsed = JSON.parse(message);
|
|
634
|
-
const wsMessage = safeParse(
|
|
670
|
+
const wsMessage = safeParse(IncomingWebSocketMessageSchema, parsed);
|
|
635
671
|
if (wsMessage === null) {
|
|
636
672
|
console.error("Invalid WebSocket message: missing or invalid type", parsed);
|
|
637
673
|
return;
|
|
@@ -647,16 +683,16 @@ async function resolveStaticDir(staticDir) {
|
|
|
647
683
|
const currentDir = dirname3(fileURLToPath(import.meta.url));
|
|
648
684
|
const candidates = staticDir === void 0 ? [
|
|
649
685
|
currentDir,
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
] : [staticDir,
|
|
686
|
+
join3(currentDir, "dist"),
|
|
687
|
+
join3(currentDir, "..", "dist"),
|
|
688
|
+
join3(currentDir, "..", "..", "dist"),
|
|
689
|
+
join3(currentDir, ".."),
|
|
690
|
+
join3(currentDir, "..", "..")
|
|
691
|
+
] : [staticDir, join3(staticDir, "dist")];
|
|
656
692
|
const resolvedCandidates = await Promise.all(
|
|
657
693
|
candidates.map(async (candidate) => ({
|
|
658
694
|
candidate,
|
|
659
|
-
exists: await fileExists(
|
|
695
|
+
exists: await fileExists(join3(candidate, "index.html"))
|
|
660
696
|
}))
|
|
661
697
|
);
|
|
662
698
|
const resolved = resolvedCandidates.find(({ exists }) => exists);
|
|
@@ -667,15 +703,19 @@ async function resolveStaticDir(staticDir) {
|
|
|
667
703
|
}
|
|
668
704
|
async function resolveReportPath(reportPath) {
|
|
669
705
|
if (await isDirectory(reportPath)) {
|
|
670
|
-
return { reportFile:
|
|
706
|
+
return { reportFile: join3(reportPath, "report.json"), offlineReportDir: reportPath };
|
|
671
707
|
}
|
|
672
708
|
return { reportFile: reportPath, offlineReportDir: dirname3(reportPath) };
|
|
673
709
|
}
|
|
674
710
|
function createRoutesContext(reportData, staticDir, saveReport, options) {
|
|
711
|
+
const artifactRoots = [reportData.screenshotDir, options.outputDir, options.playwrightSnapshotDir].filter(
|
|
712
|
+
(root) => root !== void 0 && root !== ""
|
|
713
|
+
);
|
|
675
714
|
return {
|
|
676
715
|
reportData,
|
|
677
716
|
staticDir,
|
|
678
717
|
saveReport,
|
|
718
|
+
artifactRoots,
|
|
679
719
|
approvalRouting: {
|
|
680
720
|
configDir: options.configDir ?? process.cwd(),
|
|
681
721
|
playwrightTestDir: options.playwrightTestDir,
|
|
@@ -697,25 +737,16 @@ async function createServerApp(options = {}) {
|
|
|
697
737
|
await writeJsonFile(reportFile, reportData);
|
|
698
738
|
}
|
|
699
739
|
const routesContext = createRoutesContext(reportData, staticDir, saveReport, options);
|
|
700
|
-
const getHandlerContext = () => createHandlerContext(reportData, wsClients, currentRunIds, saveReport);
|
|
740
|
+
const getHandlerContext = () => createHandlerContext(reportData, wsClients, currentRunIds, saveReport, routesContext.approvalRouting);
|
|
701
741
|
const handleRequest = (req) => handleHttpRequest(routesContext, req);
|
|
702
742
|
const handleWebSocketMessage = createWebSocketMessageHandler(getHandlerContext);
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
await loadReport(reportFile, reportData);
|
|
706
|
-
await loadOfflineReports(offlineReportDir, reportData);
|
|
707
|
-
broadcastToBrowsers(wsClients, { type: "sync", data: reportData });
|
|
708
|
-
};
|
|
709
|
-
await reloadFromDisk();
|
|
710
|
-
const close = await watchReportArtifacts({
|
|
711
|
-
offlineReportDir,
|
|
712
|
-
screenshotDir: reportData.screenshotDir,
|
|
713
|
-
scheduleRefresh: createDebouncedRefresh(reloadFromDisk)
|
|
714
|
-
});
|
|
743
|
+
await loadReport(reportFile, reportData);
|
|
744
|
+
await loadOfflineReports(offlineReportDir, reportData);
|
|
715
745
|
return {
|
|
716
746
|
port,
|
|
717
747
|
wsClients,
|
|
718
|
-
close
|
|
748
|
+
close: () => {
|
|
749
|
+
},
|
|
719
750
|
handleRequest,
|
|
720
751
|
handleWebSocketMessage
|
|
721
752
|
};
|
|
@@ -896,7 +927,7 @@ function attachWebSocketServer(server, app) {
|
|
|
896
927
|
});
|
|
897
928
|
}
|
|
898
929
|
async function listen(server, port) {
|
|
899
|
-
await new Promise((
|
|
930
|
+
await new Promise((resolve3, reject) => {
|
|
900
931
|
const onError = (error) => {
|
|
901
932
|
server.off("error", onError);
|
|
902
933
|
reject(error);
|
|
@@ -904,7 +935,7 @@ async function listen(server, port) {
|
|
|
904
935
|
server.on("error", onError);
|
|
905
936
|
server.listen(port, () => {
|
|
906
937
|
server.off("error", onError);
|
|
907
|
-
|
|
938
|
+
resolve3();
|
|
908
939
|
});
|
|
909
940
|
});
|
|
910
941
|
}
|