@crvy/rprtr 0.0.9 → 0.1.1
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-TS64TROX.js → chunk-35CZLYSW.js} +210 -58
- 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 +340 -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 +289 -115
- 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/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.1] - 2026-06-08
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Preserve all tests when applying single WebSocket update
|
|
13
|
+
## [0.1.0] - 2026-06-08
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **resolver:** Expose playwrightAnonymousVisualName
|
|
18
|
+
- **resolver:** Add withResolvedVisualNames
|
|
19
|
+
- **server:** Add isPathWithinRoots allowlist helper
|
|
20
|
+
- **server:** Add allowlisted /file artifact route
|
|
21
|
+
- **server:** Add /baseline route, consolidate baseline resolution
|
|
22
|
+
- **server:** Wire artifactRoots and outputDir option
|
|
23
|
+
- Add isCI helper
|
|
24
|
+
- **report-utils:** Route absolute attachment paths to /file
|
|
25
|
+
- **server:** Enrich declared-only images with /baseline urls
|
|
26
|
+
- **reporter:** Add rewriteTestEndAttachments helper
|
|
27
|
+
- **reporter:** Gate screenshot copying and artifacts on CI
|
|
28
|
+
- **cli:** Add --output-dir for live native artifact serving
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- **resolver:** Compute anonymous title list once
|
|
33
|
+
|
|
34
|
+
### Documentation
|
|
35
|
+
|
|
36
|
+
- **spec:** CI-gated screenshot artifact handling design
|
|
37
|
+
- **plans:** Naming fix, live-mode serving, CI-gated copying
|
|
38
|
+
- **server:** Clarify outputDir is resolved against CWD
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- Apply web socket payloads directly to the client tree
|
|
43
|
+
- **reporter:** Use Playwright auto-name for unnamed screenshots
|
|
44
|
+
- **server:** Realpath-guard /file route and set nosniff header
|
|
45
|
+
- **server:** Guard /baseline decode, retry parsing, and read errors
|
|
46
|
+
- **server:** Exclude test source dir from /file allowlist
|
|
47
|
+
- **server:** Guard /baseline url parsing, decode testId, test nosniff
|
|
48
|
+
- **server:** Decode /file URLs in approval path
|
|
49
|
+
- **server:** Only enrich declared-only baselines for passed tests
|
|
50
|
+
- **server:** Enrich baselines only when the snapshot file exists
|
|
51
|
+
- **reporter:** Bound onEnd concurrency, fix retry attachments, stop CI queue growth
|
|
52
|
+
|
|
53
|
+
### Styling
|
|
54
|
+
|
|
55
|
+
- **client:** Center-align image comparison views
|
|
56
|
+
|
|
57
|
+
### Testing
|
|
58
|
+
|
|
59
|
+
- **report-state:** Assert no phantom entry for finalized unnamed screenshot
|
|
60
|
+
- **report-state:** Make phantom-entry guard falsifiable
|
|
61
|
+
- Document phantom fixture divergence and cover multi-occurrence rewrite
|
|
62
|
+
- **server:** Cover raw-traversal and empty-roots cases for isPathWithinRoots
|
|
8
63
|
## [0.0.9] - 2026-06-05
|
|
9
64
|
|
|
10
65
|
### Fixed
|
|
@@ -1,17 +1,18 @@
|
|
|
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
18
|
import { dirname as dirname3, join as join3 } from "path";
|
|
@@ -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,56 +171,210 @@ 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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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;
|
|
335
|
+
}
|
|
336
|
+
const { test, diffCount } = result;
|
|
337
|
+
if (data.status === "passed") {
|
|
338
|
+
enrichDeclaredBaselines(ctx, test);
|
|
195
339
|
}
|
|
196
|
-
|
|
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
|
-
|
|
366
|
+
const message = {
|
|
367
|
+
type: "sync",
|
|
368
|
+
data: {
|
|
369
|
+
tests: ctx.reportData.tests,
|
|
370
|
+
isUpdateMode: ctx.reportData.isUpdateMode
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
broadcastToBrowsers(ctx.wsClients, message);
|
|
211
374
|
}
|
|
212
375
|
|
|
213
376
|
// src/server/routes.ts
|
|
214
|
-
import {
|
|
215
|
-
import { dirname as dirname2, join as join2 } from "path";
|
|
216
|
-
var LIVE_UPDATES_WEBSOCKET_PATH = "/";
|
|
217
|
-
function isWebSocketUpgradeRequest(req) {
|
|
218
|
-
return new URL(req.url).pathname === LIVE_UPDATES_WEBSOCKET_PATH && req.headers.get("upgrade")?.toLowerCase() === "websocket";
|
|
219
|
-
}
|
|
377
|
+
import { join as join2 } from "path";
|
|
220
378
|
async function handleRoot(ctx) {
|
|
221
379
|
const html = await respondWithFile(join2(ctx.staticDir, "index.html"), "text/html");
|
|
222
380
|
return html ?? new Response("Not Found", { status: 404 });
|
|
@@ -237,34 +395,13 @@ function handleApiReport(ctx) {
|
|
|
237
395
|
}
|
|
238
396
|
var APPROVAL_TARGET_ERROR = "Could not resolve approval target";
|
|
239
397
|
function actualPathFromUrl(ctx, actualUrl) {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
function reporterTitlePath(test) {
|
|
243
|
-
const testFile = test.location?.file;
|
|
244
|
-
return ["", test.browser, testFile ?? "", ...test.titlePath, test.title];
|
|
245
|
-
}
|
|
246
|
-
function resolveApprovalTarget(ctx, test, retry, imageName) {
|
|
247
|
-
const testFile = test.location?.file;
|
|
248
|
-
const declaration = test.results?.[retry]?.visualDeclarations?.find((candidate) => candidate.visualName === imageName);
|
|
249
|
-
if (ctx.approvalRouting === void 0 || testFile === void 0 || declaration === void 0) {
|
|
250
|
-
return null;
|
|
398
|
+
if (actualUrl.startsWith("/screenshots/")) {
|
|
399
|
+
return join2(ctx.reportData.screenshotDir, actualUrl.slice("/screenshots/".length));
|
|
251
400
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
config: {
|
|
257
|
-
configDir: ctx.approvalRouting.configDir,
|
|
258
|
-
testDir: ctx.approvalRouting.playwrightTestDir ?? dirname2(testFile),
|
|
259
|
-
snapshotDir: ctx.approvalRouting.playwrightSnapshotDir ?? dirname2(testFile),
|
|
260
|
-
projectName: test.browser,
|
|
261
|
-
snapshotSuffix: process.platform,
|
|
262
|
-
snapshotPathTemplate: ctx.approvalRouting.playwrightSnapshotPathTemplate,
|
|
263
|
-
toHaveScreenshotPathTemplate: ctx.approvalRouting.playwrightToHaveScreenshotPathTemplate
|
|
264
|
-
},
|
|
265
|
-
snapshotPathExists: existsSync
|
|
266
|
-
});
|
|
267
|
-
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;
|
|
268
405
|
}
|
|
269
406
|
async function handleApiApprove(ctx, req) {
|
|
270
407
|
try {
|
|
@@ -283,7 +420,7 @@ async function handleApiApprove(ctx, req) {
|
|
|
283
420
|
if (actualUrl === void 0) {
|
|
284
421
|
return Response.json({ success: false, error: "Actual image not found" }, { status: 409 });
|
|
285
422
|
}
|
|
286
|
-
const snapshotPath =
|
|
423
|
+
const snapshotPath = resolveBaselineSnapshotPath(ctx.approvalRouting, test, retry, image);
|
|
287
424
|
if (snapshotPath === null) {
|
|
288
425
|
return Response.json({ success: false, error: APPROVAL_TARGET_ERROR }, { status: 409 });
|
|
289
426
|
}
|
|
@@ -318,7 +455,7 @@ function createBulkApprovalUpdates(ctx) {
|
|
|
318
455
|
if (actualUrl === void 0) {
|
|
319
456
|
return [Promise.resolve({ kind: "unresolved" })];
|
|
320
457
|
}
|
|
321
|
-
const snapshotPath =
|
|
458
|
+
const snapshotPath = resolveBaselineSnapshotPath(ctx.approvalRouting, test, lastRetry, imageName);
|
|
322
459
|
if (snapshotPath === null) {
|
|
323
460
|
return [Promise.resolve({ kind: "unresolved" })];
|
|
324
461
|
}
|
|
@@ -409,6 +546,10 @@ function handleHttpRequest(ctx, req) {
|
|
|
409
546
|
if (pathname.startsWith("/api/images/")) {
|
|
410
547
|
return handleApiImages(req);
|
|
411
548
|
}
|
|
549
|
+
const artifactResponse = handleArtifactRoute(ctx, req, pathname);
|
|
550
|
+
if (artifactResponse !== null) {
|
|
551
|
+
return artifactResponse;
|
|
552
|
+
}
|
|
412
553
|
if (pathname.startsWith("/screenshots/")) {
|
|
413
554
|
return handleScreenshots(ctx, req);
|
|
414
555
|
}
|
|
@@ -429,12 +570,13 @@ function createReportData(options) {
|
|
|
429
570
|
screenshotDir: options.screenshotDir ?? "./screenshots"
|
|
430
571
|
};
|
|
431
572
|
}
|
|
432
|
-
function createHandlerContext(reportData, wsClients, currentRunIds, saveReport) {
|
|
573
|
+
function createHandlerContext(reportData, wsClients, currentRunIds, saveReport, approvalRouting) {
|
|
433
574
|
return {
|
|
434
575
|
reportData,
|
|
435
576
|
wsClients,
|
|
436
577
|
currentRunIds,
|
|
437
|
-
saveReport
|
|
578
|
+
saveReport,
|
|
579
|
+
approvalRouting
|
|
438
580
|
};
|
|
439
581
|
}
|
|
440
582
|
async function loadReport(reportPath, reportData) {
|
|
@@ -504,9 +646,15 @@ async function handleParsedWebSocketMessage(ctx, msg) {
|
|
|
504
646
|
handleTestEnd(ctx, parsed);
|
|
505
647
|
break;
|
|
506
648
|
}
|
|
507
|
-
case "run-end":
|
|
508
|
-
|
|
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);
|
|
509
656
|
break;
|
|
657
|
+
}
|
|
510
658
|
case "approve":
|
|
511
659
|
handleApprove();
|
|
512
660
|
break;
|
|
@@ -519,7 +667,7 @@ function createWebSocketMessageHandler(getHandlerContext) {
|
|
|
519
667
|
return async function handleWebSocketMessage(message) {
|
|
520
668
|
try {
|
|
521
669
|
const parsed = JSON.parse(message);
|
|
522
|
-
const wsMessage = safeParse(
|
|
670
|
+
const wsMessage = safeParse(IncomingWebSocketMessageSchema, parsed);
|
|
523
671
|
if (wsMessage === null) {
|
|
524
672
|
console.error("Invalid WebSocket message: missing or invalid type", parsed);
|
|
525
673
|
return;
|
|
@@ -560,10 +708,14 @@ async function resolveReportPath(reportPath) {
|
|
|
560
708
|
return { reportFile: reportPath, offlineReportDir: dirname3(reportPath) };
|
|
561
709
|
}
|
|
562
710
|
function createRoutesContext(reportData, staticDir, saveReport, options) {
|
|
711
|
+
const artifactRoots = [reportData.screenshotDir, options.outputDir, options.playwrightSnapshotDir].filter(
|
|
712
|
+
(root) => root !== void 0 && root !== ""
|
|
713
|
+
);
|
|
563
714
|
return {
|
|
564
715
|
reportData,
|
|
565
716
|
staticDir,
|
|
566
717
|
saveReport,
|
|
718
|
+
artifactRoots,
|
|
567
719
|
approvalRouting: {
|
|
568
720
|
configDir: options.configDir ?? process.cwd(),
|
|
569
721
|
playwrightTestDir: options.playwrightTestDir,
|
|
@@ -585,7 +737,7 @@ async function createServerApp(options = {}) {
|
|
|
585
737
|
await writeJsonFile(reportFile, reportData);
|
|
586
738
|
}
|
|
587
739
|
const routesContext = createRoutesContext(reportData, staticDir, saveReport, options);
|
|
588
|
-
const getHandlerContext = () => createHandlerContext(reportData, wsClients, currentRunIds, saveReport);
|
|
740
|
+
const getHandlerContext = () => createHandlerContext(reportData, wsClients, currentRunIds, saveReport, routesContext.approvalRouting);
|
|
589
741
|
const handleRequest = (req) => handleHttpRequest(routesContext, req);
|
|
590
742
|
const handleWebSocketMessage = createWebSocketMessageHandler(getHandlerContext);
|
|
591
743
|
await loadReport(reportFile, reportData);
|
|
@@ -775,7 +927,7 @@ function attachWebSocketServer(server, app) {
|
|
|
775
927
|
});
|
|
776
928
|
}
|
|
777
929
|
async function listen(server, port) {
|
|
778
|
-
await new Promise((
|
|
930
|
+
await new Promise((resolve3, reject) => {
|
|
779
931
|
const onError = (error) => {
|
|
780
932
|
server.off("error", onError);
|
|
781
933
|
reject(error);
|
|
@@ -783,7 +935,7 @@ async function listen(server, port) {
|
|
|
783
935
|
server.on("error", onError);
|
|
784
936
|
server.listen(port, () => {
|
|
785
937
|
server.off("error", onError);
|
|
786
|
-
|
|
938
|
+
resolve3();
|
|
787
939
|
});
|
|
788
940
|
});
|
|
789
941
|
}
|
|
@@ -131,9 +131,15 @@ function resolveNamedTarget(input, declaration) {
|
|
|
131
131
|
function reporterTitlesWithoutProjectAndFile(reporterTitlePath) {
|
|
132
132
|
return reporterTitlePath.slice(3).filter((part) => part !== "");
|
|
133
133
|
}
|
|
134
|
+
function anonymousNameFromTitles(titles, occurrenceIndex) {
|
|
135
|
+
return sanitizeFilePathBeforeExtension(trimLongString(`${titles.join(" ")} ${occurrenceIndex}.png`), ".png");
|
|
136
|
+
}
|
|
134
137
|
function anonymousName(reporterTitlePath, occurrenceIndex) {
|
|
135
|
-
|
|
136
|
-
|
|
138
|
+
return anonymousNameFromTitles(reporterTitlesWithoutProjectAndFile(reporterTitlePath), occurrenceIndex);
|
|
139
|
+
}
|
|
140
|
+
function playwrightAnonymousVisualName(reporterTitlePath, occurrenceIndex) {
|
|
141
|
+
const titles = reporterTitlesWithoutProjectAndFile(reporterTitlePath);
|
|
142
|
+
return titles.length === 0 ? null : removeExtension(anonymousNameFromTitles(titles, occurrenceIndex), ".png");
|
|
137
143
|
}
|
|
138
144
|
function resolveTarget(input, declaration) {
|
|
139
145
|
switch (declaration.kind) {
|
|
@@ -147,6 +153,15 @@ function resolveTarget(input, declaration) {
|
|
|
147
153
|
}
|
|
148
154
|
}
|
|
149
155
|
}
|
|
156
|
+
function withResolvedVisualNames(declarations, reporterTitlePath) {
|
|
157
|
+
return declarations.map((declaration) => {
|
|
158
|
+
if (declaration.kind !== "unnamed") {
|
|
159
|
+
return declaration;
|
|
160
|
+
}
|
|
161
|
+
const resolvedName = playwrightAnonymousVisualName(reporterTitlePath, declaration.occurrenceIndex);
|
|
162
|
+
return resolvedName === null ? declaration : { ...declaration, visualName: resolvedName };
|
|
163
|
+
});
|
|
164
|
+
}
|
|
150
165
|
function resolveBaselineTargets(input) {
|
|
151
166
|
return input.declarations.flatMap((declaration) => {
|
|
152
167
|
const resolvedTarget = resolveTarget(input, declaration);
|
|
@@ -224,10 +239,29 @@ var CrvyRprtrSuiteSchema = z.lazy(
|
|
|
224
239
|
children: z.record(z.string(), z.union([CrvyRprtrSuiteSchema, CrvyRprtrTestSchema])).optional()
|
|
225
240
|
})
|
|
226
241
|
);
|
|
227
|
-
var
|
|
242
|
+
var IncomingWebSocketMessageSchema = z.object({
|
|
228
243
|
type: z.enum(["test-begin", "test-end", "run-end", "approve", "sync"]),
|
|
229
244
|
data: z.unknown()
|
|
230
245
|
});
|
|
246
|
+
var WebSocketMessageSchema = z.discriminatedUnion("type", [
|
|
247
|
+
z.object({ type: z.literal("test-begin"), data: TestDataSchema }),
|
|
248
|
+
z.object({ type: z.literal("test-update"), data: TestDataSchema }),
|
|
249
|
+
z.object({
|
|
250
|
+
type: z.literal("run-end"),
|
|
251
|
+
data: z.object({
|
|
252
|
+
status: z.enum(["passed", "failed", "skipped"]),
|
|
253
|
+
removedTestIds: z.array(z.string())
|
|
254
|
+
})
|
|
255
|
+
}),
|
|
256
|
+
z.object({
|
|
257
|
+
type: z.literal("sync"),
|
|
258
|
+
data: z.object({
|
|
259
|
+
tests: z.record(z.string(), TestDataSchema),
|
|
260
|
+
isUpdateMode: z.boolean().optional()
|
|
261
|
+
})
|
|
262
|
+
}),
|
|
263
|
+
z.object({ type: z.literal("approve"), data: z.unknown() })
|
|
264
|
+
]);
|
|
231
265
|
var TestBeginDataSchema = z.object({
|
|
232
266
|
id: z.string(),
|
|
233
267
|
title: z.string(),
|
|
@@ -247,6 +281,9 @@ var TestEndDataSchema = z.object({
|
|
|
247
281
|
error: z.string().optional(),
|
|
248
282
|
duration: z.number().optional()
|
|
249
283
|
});
|
|
284
|
+
var RunEndDataSchema = z.object({
|
|
285
|
+
status: z.enum(["passed", "failed", "skipped"])
|
|
286
|
+
});
|
|
250
287
|
var ReportDataSchema = z.object({
|
|
251
288
|
isRunning: z.boolean(),
|
|
252
289
|
tests: z.record(z.string(), TestDataSchema),
|
|
@@ -297,6 +334,7 @@ function safeParse(schema, data) {
|
|
|
297
334
|
}
|
|
298
335
|
|
|
299
336
|
// src/report-utils.ts
|
|
337
|
+
import { isAbsolute } from "path";
|
|
300
338
|
function normalizeScreenshotsBaseUrl(baseUrl) {
|
|
301
339
|
return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
|
302
340
|
}
|
|
@@ -341,7 +379,7 @@ function attachmentsToImages(attachments, screenshotsBaseUrl = "/screenshots/")
|
|
|
341
379
|
const role = match[2];
|
|
342
380
|
if (baseName === null || baseName === void 0 || role === null || role === void 0) continue;
|
|
343
381
|
images[baseName] ??= {};
|
|
344
|
-
const url = `${baseUrl}${attachment.path}`;
|
|
382
|
+
const url = isAbsolute(attachment.path) ? `/file/${encodeURIComponent(attachment.path)}` : `${baseUrl}${attachment.path}`;
|
|
345
383
|
const img = images[baseName];
|
|
346
384
|
if (img !== null && img !== void 0) {
|
|
347
385
|
if (role === "actual") img.actual = url;
|
|
@@ -488,13 +526,15 @@ export {
|
|
|
488
526
|
applyTestBeginEvent,
|
|
489
527
|
applyTestEndEvent,
|
|
490
528
|
finalizeRunEvent,
|
|
491
|
-
|
|
529
|
+
IncomingWebSocketMessageSchema,
|
|
492
530
|
TestBeginDataSchema,
|
|
493
531
|
TestEndDataSchema,
|
|
532
|
+
RunEndDataSchema,
|
|
494
533
|
LoadedReportDataSchema,
|
|
495
534
|
OfflineReportSchema,
|
|
496
535
|
ApproveRequestBodySchema,
|
|
497
536
|
ClientBootstrapDataSchema,
|
|
498
537
|
safeParse,
|
|
538
|
+
withResolvedVisualNames,
|
|
499
539
|
resolveBaselineTargets
|
|
500
540
|
};
|
package/dist/ci.d.ts
ADDED
package/dist/ci.d.ts.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ci.d.ts","sourceRoot":"","sources":["../src/ci.ts"],"names":[],"mappings":"AAAA,wBAAgB,IAAI,CAAC,GAAG,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAe,GAAG,OAAO,CAGnF"}
|
package/dist/cli.d.ts
CHANGED
package/dist/cli.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAKA,OAAO,EAAe,KAAK,aAAa,EAAE,MAAM,aAAa,CAAA;
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAKA,OAAO,EAAe,KAAK,aAAa,EAAE,MAAM,aAAa,CAAA;AAO7D,UAAU,kBAAmB,SAAQ,aAAa;IAChD,IAAI,EAAE,MAAM,CAAA;IACZ,aAAa,EAAE,MAAM,CAAA;IACrB,UAAU,EAAE,MAAM,CAAA;IAClB,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,kBAAkB,CA4BpE"}
|
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
startServer
|
|
4
|
-
} from "./chunk-
|
|
5
|
-
import "./chunk-
|
|
4
|
+
} from "./chunk-35CZLYSW.js";
|
|
5
|
+
import "./chunk-43JLQN36.js";
|
|
6
6
|
|
|
7
7
|
// src/cli.ts
|
|
8
8
|
import { join } from "path";
|
|
@@ -31,6 +31,7 @@ function isDirectExecution(moduleUrl) {
|
|
|
31
31
|
var DEFAULT_PORT = 3e3;
|
|
32
32
|
var DEFAULT_SCREENSHOT_DIR = "./screenshots";
|
|
33
33
|
var DEFAULT_REPORT_PATH = "./report.json";
|
|
34
|
+
var DEFAULT_OUTPUT_DIR = "./test-results";
|
|
34
35
|
function resolveCliOptions(args) {
|
|
35
36
|
const { values, positionals } = parseArgs({
|
|
36
37
|
args,
|
|
@@ -38,7 +39,8 @@ function resolveCliOptions(args) {
|
|
|
38
39
|
options: {
|
|
39
40
|
port: { type: "string", short: "p", default: `${DEFAULT_PORT}` },
|
|
40
41
|
"screenshot-dir": { type: "string", short: "s" },
|
|
41
|
-
"report-path": { type: "string", short: "r" }
|
|
42
|
+
"report-path": { type: "string", short: "r" },
|
|
43
|
+
"output-dir": { type: "string", short: "o" }
|
|
42
44
|
}
|
|
43
45
|
});
|
|
44
46
|
if (positionals.length > 1) {
|
|
@@ -50,7 +52,8 @@ function resolveCliOptions(args) {
|
|
|
50
52
|
return {
|
|
51
53
|
port: parseInt(values.port ?? `${DEFAULT_PORT}`, 10),
|
|
52
54
|
screenshotDir,
|
|
53
|
-
reportPath
|
|
55
|
+
reportPath,
|
|
56
|
+
outputDir: values["output-dir"] ?? DEFAULT_OUTPUT_DIR
|
|
54
57
|
};
|
|
55
58
|
}
|
|
56
59
|
if (isDirectExecution(import.meta.url)) {
|