@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/dist/server.cjs
CHANGED
|
@@ -35,15 +35,16 @@ __export(server_exports, {
|
|
|
35
35
|
module.exports = __toCommonJS(server_exports);
|
|
36
36
|
|
|
37
37
|
// src/server/app.ts
|
|
38
|
-
var
|
|
38
|
+
var import_path8 = require("path");
|
|
39
39
|
var import_url = require("url");
|
|
40
40
|
var import_p_limit = __toESM(require("p-limit"), 1);
|
|
41
41
|
|
|
42
42
|
// src/offline-reports.ts
|
|
43
43
|
var import_promises = require("fs/promises");
|
|
44
|
-
var
|
|
44
|
+
var import_path2 = require("path");
|
|
45
45
|
|
|
46
46
|
// src/report-utils.ts
|
|
47
|
+
var import_path = require("path");
|
|
47
48
|
function normalizeScreenshotsBaseUrl(baseUrl) {
|
|
48
49
|
return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
|
49
50
|
}
|
|
@@ -88,7 +89,7 @@ function attachmentsToImages(attachments, screenshotsBaseUrl = "/screenshots/")
|
|
|
88
89
|
const role = match[2];
|
|
89
90
|
if (baseName === null || baseName === void 0 || role === null || role === void 0) continue;
|
|
90
91
|
images[baseName] ??= {};
|
|
91
|
-
const url = `${baseUrl}${attachment.path}`;
|
|
92
|
+
const url = (0, import_path.isAbsolute)(attachment.path) ? `/file/${encodeURIComponent(attachment.path)}` : `${baseUrl}${attachment.path}`;
|
|
92
93
|
const img = images[baseName];
|
|
93
94
|
if (img !== null && img !== void 0) {
|
|
94
95
|
if (role === "actual") img.actual = url;
|
|
@@ -300,10 +301,29 @@ var CrvyRprtrSuiteSchema = import_zod.z.lazy(
|
|
|
300
301
|
children: import_zod.z.record(import_zod.z.string(), import_zod.z.union([CrvyRprtrSuiteSchema, CrvyRprtrTestSchema])).optional()
|
|
301
302
|
})
|
|
302
303
|
);
|
|
303
|
-
var
|
|
304
|
+
var IncomingWebSocketMessageSchema = import_zod.z.object({
|
|
304
305
|
type: import_zod.z.enum(["test-begin", "test-end", "run-end", "approve", "sync"]),
|
|
305
306
|
data: import_zod.z.unknown()
|
|
306
307
|
});
|
|
308
|
+
var WebSocketMessageSchema = import_zod.z.discriminatedUnion("type", [
|
|
309
|
+
import_zod.z.object({ type: import_zod.z.literal("test-begin"), data: TestDataSchema }),
|
|
310
|
+
import_zod.z.object({ type: import_zod.z.literal("test-update"), data: TestDataSchema }),
|
|
311
|
+
import_zod.z.object({
|
|
312
|
+
type: import_zod.z.literal("run-end"),
|
|
313
|
+
data: import_zod.z.object({
|
|
314
|
+
status: import_zod.z.enum(["passed", "failed", "skipped"]),
|
|
315
|
+
removedTestIds: import_zod.z.array(import_zod.z.string())
|
|
316
|
+
})
|
|
317
|
+
}),
|
|
318
|
+
import_zod.z.object({
|
|
319
|
+
type: import_zod.z.literal("sync"),
|
|
320
|
+
data: import_zod.z.object({
|
|
321
|
+
tests: import_zod.z.record(import_zod.z.string(), TestDataSchema),
|
|
322
|
+
isUpdateMode: import_zod.z.boolean().optional()
|
|
323
|
+
})
|
|
324
|
+
}),
|
|
325
|
+
import_zod.z.object({ type: import_zod.z.literal("approve"), data: import_zod.z.unknown() })
|
|
326
|
+
]);
|
|
307
327
|
var TestBeginDataSchema = import_zod.z.object({
|
|
308
328
|
id: import_zod.z.string(),
|
|
309
329
|
title: import_zod.z.string(),
|
|
@@ -323,6 +343,9 @@ var TestEndDataSchema = import_zod.z.object({
|
|
|
323
343
|
error: import_zod.z.string().optional(),
|
|
324
344
|
duration: import_zod.z.number().optional()
|
|
325
345
|
});
|
|
346
|
+
var RunEndDataSchema = import_zod.z.object({
|
|
347
|
+
status: import_zod.z.enum(["passed", "failed", "skipped"])
|
|
348
|
+
});
|
|
326
349
|
var ReportDataSchema = import_zod.z.object({
|
|
327
350
|
isRunning: import_zod.z.boolean(),
|
|
328
351
|
tests: import_zod.z.record(import_zod.z.string(), TestDataSchema),
|
|
@@ -377,7 +400,7 @@ var OFFLINE_REPORT_FILE_PATTERN = /^crvy-rprtr(?:-\d+)?\.json$/;
|
|
|
377
400
|
async function findOfflineReportPaths(searchDir) {
|
|
378
401
|
try {
|
|
379
402
|
const entries = await (0, import_promises.readdir)(searchDir);
|
|
380
|
-
return entries.filter((entry) => OFFLINE_REPORT_FILE_PATTERN.test(entry)).sort((left, right) => left.localeCompare(right)).map((entry) => (0,
|
|
403
|
+
return entries.filter((entry) => OFFLINE_REPORT_FILE_PATTERN.test(entry)).sort((left, right) => left.localeCompare(right)).map((entry) => (0, import_path2.join)(searchDir, entry));
|
|
381
404
|
} catch (error) {
|
|
382
405
|
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
383
406
|
return [];
|
|
@@ -429,7 +452,7 @@ function mergeOfflineReportsIntoTests(existingTests, offlineReports, options = {
|
|
|
429
452
|
|
|
430
453
|
// src/server/file-utils.ts
|
|
431
454
|
var import_promises2 = require("fs/promises");
|
|
432
|
-
var
|
|
455
|
+
var import_path3 = require("path");
|
|
433
456
|
function isFileNotFound(error) {
|
|
434
457
|
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
435
458
|
}
|
|
@@ -470,16 +493,16 @@ async function readJsonFile(filePath) {
|
|
|
470
493
|
}
|
|
471
494
|
}
|
|
472
495
|
async function writeJsonFile(filePath, value) {
|
|
473
|
-
await (0, import_promises2.mkdir)((0,
|
|
496
|
+
await (0, import_promises2.mkdir)((0, import_path3.dirname)(filePath), { recursive: true });
|
|
474
497
|
await (0, import_promises2.writeFile)(filePath, `${JSON.stringify(value, null, 2)}
|
|
475
498
|
`);
|
|
476
499
|
}
|
|
477
500
|
async function copyFilePortable(sourcePath, destinationPath) {
|
|
478
|
-
await (0, import_promises2.mkdir)((0,
|
|
501
|
+
await (0, import_promises2.mkdir)((0, import_path3.dirname)(destinationPath), { recursive: true });
|
|
479
502
|
await (0, import_promises2.copyFile)(sourcePath, destinationPath);
|
|
480
503
|
}
|
|
481
504
|
function inferContentType(filePath) {
|
|
482
|
-
switch ((0,
|
|
505
|
+
switch ((0, import_path3.extname)(filePath).toLowerCase()) {
|
|
483
506
|
case ".css":
|
|
484
507
|
return "text/css";
|
|
485
508
|
case ".gif":
|
|
@@ -509,7 +532,10 @@ async function respondWithFile(filePath, contentType) {
|
|
|
509
532
|
try {
|
|
510
533
|
const file = await (0, import_promises2.readFile)(filePath);
|
|
511
534
|
const resolvedContentType = contentType ?? inferContentType(filePath);
|
|
512
|
-
const headers =
|
|
535
|
+
const headers = { "X-Content-Type-Options": "nosniff" };
|
|
536
|
+
if (resolvedContentType !== void 0) {
|
|
537
|
+
headers["Content-Type"] = resolvedContentType;
|
|
538
|
+
}
|
|
513
539
|
return new Response(file, { headers });
|
|
514
540
|
} catch (error) {
|
|
515
541
|
if (isFileNotFound(error)) {
|
|
@@ -519,160 +545,13 @@ async function respondWithFile(filePath, contentType) {
|
|
|
519
545
|
}
|
|
520
546
|
}
|
|
521
547
|
|
|
522
|
-
// src/server/utils.ts
|
|
523
|
-
function broadcastToBrowsers(wsClients, msg) {
|
|
524
|
-
const payload = JSON.stringify(msg);
|
|
525
|
-
wsClients.forEach((ws) => {
|
|
526
|
-
ws.send(payload);
|
|
527
|
-
});
|
|
528
|
-
}
|
|
529
|
-
|
|
530
548
|
// src/server/handlers.ts
|
|
531
|
-
|
|
532
|
-
const test = applyTestBeginEvent(ctx, data);
|
|
533
|
-
ctx.reportData.isRunning = true;
|
|
534
|
-
console.log(` \u25B6 [${test.browser ?? "?"}] ${test.title}`);
|
|
535
|
-
broadcastToBrowsers(ctx.wsClients, { type: "test-begin", data });
|
|
536
|
-
}
|
|
537
|
-
function handleTestEnd(ctx, data) {
|
|
538
|
-
const result = applyTestEndEvent(ctx, data);
|
|
539
|
-
if (result !== null) {
|
|
540
|
-
const { test, diffCount } = result;
|
|
541
|
-
const icon = data.status === "passed" ? "\u2713" : data.status === "skipped" ? "\u2013" : "\u2717";
|
|
542
|
-
const dur = data.duration === null || data.duration === void 0 ? "" : ` (${data.duration}ms)`;
|
|
543
|
-
const diffNote = diffCount > 0 ? ` [${diffCount} diff(s)]` : "";
|
|
544
|
-
const errNote = data.error !== null && data.error !== void 0 ? `
|
|
545
|
-
Error: ${data.error}` : "";
|
|
546
|
-
console.log(` ${icon} [${test.browser}] ${test.title}${dur}${diffNote}${errNote}`);
|
|
547
|
-
}
|
|
548
|
-
broadcastToBrowsers(ctx.wsClients, { type: "test-update", data });
|
|
549
|
-
}
|
|
550
|
-
async function handleRunEnd(ctx, data) {
|
|
551
|
-
const { passed, failed, pending } = finalizeRunEvent(ctx);
|
|
552
|
-
await ctx.saveReport();
|
|
553
|
-
console.log(`
|
|
554
|
-
Run complete \u2014 ${passed} passed, ${failed} failed, ${pending} skipped`);
|
|
555
|
-
broadcastToBrowsers(ctx.wsClients, { type: "run-end", data });
|
|
556
|
-
}
|
|
557
|
-
function handleApprove() {
|
|
558
|
-
console.log("[Server] Received approve message via WebSocket (handled via HTTP API)");
|
|
559
|
-
}
|
|
560
|
-
function handleSync(ctx) {
|
|
561
|
-
console.log("[Server] Received sync message");
|
|
562
|
-
broadcastToBrowsers(ctx.wsClients, { type: "sync", data: ctx.reportData });
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
// src/server/report-watch.ts
|
|
566
|
-
var import_promises3 = require("fs/promises");
|
|
567
|
-
var import_path3 = require("path");
|
|
568
|
-
var OFFLINE_REPORT_FILE_PATTERN2 = /^crvy-rprtr(?:-\d+)?\.json$/;
|
|
569
|
-
function createDebouncedRefresh(reload, delayMs = 50) {
|
|
570
|
-
let timer = null;
|
|
571
|
-
return () => {
|
|
572
|
-
if (timer !== null) {
|
|
573
|
-
clearTimeout(timer);
|
|
574
|
-
}
|
|
575
|
-
timer = setTimeout(() => {
|
|
576
|
-
timer = null;
|
|
577
|
-
void reload();
|
|
578
|
-
}, delayMs);
|
|
579
|
-
};
|
|
580
|
-
}
|
|
581
|
-
function isFileNotFound2(error) {
|
|
582
|
-
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
583
|
-
}
|
|
584
|
-
async function describeFile(filePath, label) {
|
|
585
|
-
try {
|
|
586
|
-
const fileStats = await (0, import_promises3.stat)(filePath);
|
|
587
|
-
return `${label}:${fileStats.size}:${fileStats.mtimeMs}`;
|
|
588
|
-
} catch (error) {
|
|
589
|
-
if (isFileNotFound2(error)) {
|
|
590
|
-
return null;
|
|
591
|
-
}
|
|
592
|
-
throw error;
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
async function createOfflineFingerprint(offlineReportDir) {
|
|
596
|
-
if (!await isDirectory(offlineReportDir)) {
|
|
597
|
-
return "offline:missing";
|
|
598
|
-
}
|
|
599
|
-
try {
|
|
600
|
-
const entries = await (0, import_promises3.readdir)(offlineReportDir, { withFileTypes: true });
|
|
601
|
-
const relevantEntries = entries.filter(
|
|
602
|
-
(entry) => entry.isFile() && (entry.name === "report.json" || OFFLINE_REPORT_FILE_PATTERN2.test(entry.name))
|
|
603
|
-
).sort((left, right) => left.name.localeCompare(right.name));
|
|
604
|
-
const parts = await Promise.all(
|
|
605
|
-
relevantEntries.map((entry) => describeFile((0, import_path3.join)(offlineReportDir, entry.name), entry.name))
|
|
606
|
-
);
|
|
607
|
-
return `offline:${parts.filter((part) => part !== null).join("|")}`;
|
|
608
|
-
} catch (error) {
|
|
609
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
610
|
-
console.warn(`[Server] Unable to scan ${offlineReportDir}: ${errorMsg}`);
|
|
611
|
-
return "offline:error";
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
async function createScreenshotFingerprint(screenshotDir, relativePath = "") {
|
|
615
|
-
const directoryPath = relativePath === "" ? screenshotDir : (0, import_path3.join)(screenshotDir, relativePath);
|
|
616
|
-
if (!await isDirectory(directoryPath)) {
|
|
617
|
-
return relativePath === "" ? ["screenshots:missing"] : [];
|
|
618
|
-
}
|
|
619
|
-
try {
|
|
620
|
-
const entries = await (0, import_promises3.readdir)(directoryPath, { withFileTypes: true });
|
|
621
|
-
const parts = await Promise.all(
|
|
622
|
-
entries.sort((left, right) => left.name.localeCompare(right.name)).map((entry) => {
|
|
623
|
-
const entryRelativePath = relativePath === "" ? entry.name : (0, import_path3.join)(relativePath, entry.name);
|
|
624
|
-
if (entry.isDirectory()) {
|
|
625
|
-
return createScreenshotFingerprint(screenshotDir, entryRelativePath);
|
|
626
|
-
}
|
|
627
|
-
return describeFile((0, import_path3.join)(screenshotDir, entryRelativePath), entryRelativePath);
|
|
628
|
-
})
|
|
629
|
-
);
|
|
630
|
-
return parts.flat().filter((part) => part !== null);
|
|
631
|
-
} catch (error) {
|
|
632
|
-
if (isFileNotFound2(error)) {
|
|
633
|
-
return relativePath === "" ? ["screenshots:missing"] : [];
|
|
634
|
-
}
|
|
635
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
636
|
-
console.warn(`[Server] Unable to scan ${directoryPath}: ${errorMsg}`);
|
|
637
|
-
return relativePath === "" ? ["screenshots:error"] : [];
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
async function createArtifactsFingerprint(options) {
|
|
641
|
-
const [offlineFingerprint, screenshotFingerprint] = await Promise.all([
|
|
642
|
-
createOfflineFingerprint(options.offlineReportDir),
|
|
643
|
-
createScreenshotFingerprint(options.screenshotDir)
|
|
644
|
-
]);
|
|
645
|
-
return `${offlineFingerprint}::${screenshotFingerprint.join("|")}`;
|
|
646
|
-
}
|
|
647
|
-
async function watchReportArtifacts(options) {
|
|
648
|
-
let fingerprint = await createArtifactsFingerprint(options);
|
|
649
|
-
let isPolling = false;
|
|
650
|
-
const interval = setInterval(() => {
|
|
651
|
-
if (isPolling) {
|
|
652
|
-
return;
|
|
653
|
-
}
|
|
654
|
-
isPolling = true;
|
|
655
|
-
void createArtifactsFingerprint(options).then((nextFingerprint) => {
|
|
656
|
-
if (nextFingerprint === fingerprint) {
|
|
657
|
-
return;
|
|
658
|
-
}
|
|
659
|
-
fingerprint = nextFingerprint;
|
|
660
|
-
options.scheduleRefresh();
|
|
661
|
-
}).catch((error) => {
|
|
662
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
663
|
-
console.warn(`[Server] Artifact refresh poll failed: ${errorMsg}`);
|
|
664
|
-
}).finally(() => {
|
|
665
|
-
isPolling = false;
|
|
666
|
-
});
|
|
667
|
-
}, 100);
|
|
668
|
-
return () => {
|
|
669
|
-
clearInterval(interval);
|
|
670
|
-
};
|
|
671
|
-
}
|
|
549
|
+
var import_fs2 = require("fs");
|
|
672
550
|
|
|
673
|
-
// src/server/routes.ts
|
|
551
|
+
// src/server/artifact-routes.ts
|
|
674
552
|
var import_fs = require("fs");
|
|
675
|
-
var
|
|
553
|
+
var import_promises3 = require("fs/promises");
|
|
554
|
+
var import_path6 = require("path");
|
|
676
555
|
|
|
677
556
|
// src/snapshot-path-resolver.ts
|
|
678
557
|
var import_crypto = require("crypto");
|
|
@@ -807,9 +686,11 @@ function resolveNamedTarget(input, declaration) {
|
|
|
807
686
|
function reporterTitlesWithoutProjectAndFile(reporterTitlePath2) {
|
|
808
687
|
return reporterTitlePath2.slice(3).filter((part) => part !== "");
|
|
809
688
|
}
|
|
689
|
+
function anonymousNameFromTitles(titles, occurrenceIndex) {
|
|
690
|
+
return sanitizeFilePathBeforeExtension(trimLongString(`${titles.join(" ")} ${occurrenceIndex}.png`), ".png");
|
|
691
|
+
}
|
|
810
692
|
function anonymousName(reporterTitlePath2, occurrenceIndex) {
|
|
811
|
-
|
|
812
|
-
return sanitizeFilePathBeforeExtension(trimLongString(rawAnonymousName), ".png");
|
|
693
|
+
return anonymousNameFromTitles(reporterTitlesWithoutProjectAndFile(reporterTitlePath2), occurrenceIndex);
|
|
813
694
|
}
|
|
814
695
|
function resolveTarget(input, declaration) {
|
|
815
696
|
switch (declaration.kind) {
|
|
@@ -830,41 +711,67 @@ function resolveBaselineTargets(input) {
|
|
|
830
711
|
});
|
|
831
712
|
}
|
|
832
713
|
|
|
833
|
-
// src/server/
|
|
714
|
+
// src/server/utils.ts
|
|
715
|
+
var import_path5 = require("path");
|
|
834
716
|
var LIVE_UPDATES_WEBSOCKET_PATH = "/";
|
|
717
|
+
function broadcastToBrowsers(wsClients, msg) {
|
|
718
|
+
const payload = JSON.stringify(msg);
|
|
719
|
+
wsClients.forEach((ws) => {
|
|
720
|
+
ws.send(payload);
|
|
721
|
+
});
|
|
722
|
+
}
|
|
835
723
|
function isWebSocketUpgradeRequest(req) {
|
|
836
724
|
return new URL(req.url).pathname === LIVE_UPDATES_WEBSOCKET_PATH && req.headers.get("upgrade")?.toLowerCase() === "websocket";
|
|
837
725
|
}
|
|
838
|
-
|
|
839
|
-
const
|
|
840
|
-
return
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
return css ?? new Response("Not Found", { status: 404 });
|
|
845
|
-
}
|
|
846
|
-
async function handleSrcFiles(req) {
|
|
847
|
-
const path = new URL(req.url).pathname.slice("/src/".length);
|
|
848
|
-
const filePath = `./src/${path}`;
|
|
849
|
-
const contentType = filePath.endsWith(".ts") || filePath.endsWith(".tsx") ? "application/javascript" : filePath.endsWith(".css") ? "text/css" : "text/plain";
|
|
850
|
-
const file = await respondWithFile(filePath, contentType);
|
|
851
|
-
return file ?? new Response("Not Found", { status: 404 });
|
|
726
|
+
function isPathWithinRoots(target, roots) {
|
|
727
|
+
const resolvedTarget = (0, import_path5.resolve)(target);
|
|
728
|
+
return roots.some((root) => {
|
|
729
|
+
const rel = (0, import_path5.relative)((0, import_path5.resolve)(root), resolvedTarget);
|
|
730
|
+
return rel === "" || !rel.startsWith(`..${import_path5.sep}`) && rel !== ".." && !(0, import_path5.isAbsolute)(rel);
|
|
731
|
+
});
|
|
852
732
|
}
|
|
853
|
-
|
|
854
|
-
|
|
733
|
+
|
|
734
|
+
// src/server/artifact-routes.ts
|
|
735
|
+
async function realpathOrNull(path) {
|
|
736
|
+
try {
|
|
737
|
+
return await (0, import_promises3.realpath)(path);
|
|
738
|
+
} catch {
|
|
739
|
+
return null;
|
|
740
|
+
}
|
|
855
741
|
}
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
742
|
+
async function handleFile(ctx, req) {
|
|
743
|
+
const notFound = () => new Response("Not Found", { status: 404 });
|
|
744
|
+
let decodedPath;
|
|
745
|
+
try {
|
|
746
|
+
decodedPath = (0, import_path6.resolve)(decodeURIComponent(new URL(req.url).pathname.slice("/file/".length)));
|
|
747
|
+
} catch {
|
|
748
|
+
return notFound();
|
|
749
|
+
}
|
|
750
|
+
const realTarget = await realpathOrNull(decodedPath);
|
|
751
|
+
if (realTarget === null) {
|
|
752
|
+
return notFound();
|
|
753
|
+
}
|
|
754
|
+
const realRoots = (await Promise.all((ctx.artifactRoots ?? []).map((root) => realpathOrNull((0, import_path6.resolve)(root))))).filter(
|
|
755
|
+
(root) => root !== null
|
|
756
|
+
);
|
|
757
|
+
if (!isPathWithinRoots(realTarget, realRoots)) {
|
|
758
|
+
return notFound();
|
|
759
|
+
}
|
|
760
|
+
try {
|
|
761
|
+
const file = await respondWithFile(realTarget);
|
|
762
|
+
return file ?? notFound();
|
|
763
|
+
} catch {
|
|
764
|
+
return notFound();
|
|
765
|
+
}
|
|
859
766
|
}
|
|
860
767
|
function reporterTitlePath(test) {
|
|
861
768
|
const testFile = test.location?.file;
|
|
862
769
|
return ["", test.browser, testFile ?? "", ...test.titlePath, test.title];
|
|
863
770
|
}
|
|
864
|
-
function
|
|
771
|
+
function resolveBaselineSnapshotPath(routing, test, retry, imageName) {
|
|
865
772
|
const testFile = test.location?.file;
|
|
866
773
|
const declaration = test.results?.[retry]?.visualDeclarations?.find((candidate) => candidate.visualName === imageName);
|
|
867
|
-
if (
|
|
774
|
+
if (routing === void 0 || testFile === void 0 || declaration === void 0) {
|
|
868
775
|
return null;
|
|
869
776
|
}
|
|
870
777
|
const targets = resolveBaselineTargets({
|
|
@@ -872,18 +779,162 @@ function resolveApprovalTarget(ctx, test, retry, imageName) {
|
|
|
872
779
|
reporterTitlePath: reporterTitlePath(test),
|
|
873
780
|
declarations: [declaration],
|
|
874
781
|
config: {
|
|
875
|
-
configDir:
|
|
876
|
-
testDir:
|
|
877
|
-
snapshotDir:
|
|
782
|
+
configDir: routing.configDir,
|
|
783
|
+
testDir: routing.playwrightTestDir ?? (0, import_path6.dirname)(testFile),
|
|
784
|
+
snapshotDir: routing.playwrightSnapshotDir ?? (0, import_path6.dirname)(testFile),
|
|
878
785
|
projectName: test.browser,
|
|
879
786
|
snapshotSuffix: process.platform,
|
|
880
|
-
snapshotPathTemplate:
|
|
881
|
-
toHaveScreenshotPathTemplate:
|
|
787
|
+
snapshotPathTemplate: routing.playwrightSnapshotPathTemplate,
|
|
788
|
+
toHaveScreenshotPathTemplate: routing.playwrightToHaveScreenshotPathTemplate
|
|
882
789
|
},
|
|
883
790
|
snapshotPathExists: import_fs.existsSync
|
|
884
791
|
});
|
|
885
792
|
return targets.length === 1 ? targets[0]?.snapshotPath ?? null : null;
|
|
886
793
|
}
|
|
794
|
+
async function handleBaseline(ctx, req) {
|
|
795
|
+
const notFound = () => new Response("Not Found", { status: 404 });
|
|
796
|
+
let testId;
|
|
797
|
+
let retry;
|
|
798
|
+
let visualName;
|
|
799
|
+
try {
|
|
800
|
+
const segments = new URL(req.url).pathname.slice("/baseline/".length).split("/");
|
|
801
|
+
const [rawTestId, retryRaw, ...visualNameParts] = segments;
|
|
802
|
+
if (rawTestId === void 0 || retryRaw === void 0 || !/^\d+$/.test(retryRaw) || visualNameParts.length === 0) {
|
|
803
|
+
return notFound();
|
|
804
|
+
}
|
|
805
|
+
testId = decodeURIComponent(rawTestId);
|
|
806
|
+
retry = Number(retryRaw);
|
|
807
|
+
visualName = decodeURIComponent(visualNameParts.join("/"));
|
|
808
|
+
} catch {
|
|
809
|
+
return notFound();
|
|
810
|
+
}
|
|
811
|
+
const test = ctx.reportData.tests[testId];
|
|
812
|
+
if (test === void 0) {
|
|
813
|
+
return notFound();
|
|
814
|
+
}
|
|
815
|
+
const snapshotPath = resolveBaselineSnapshotPath(ctx.approvalRouting, test, retry, visualName);
|
|
816
|
+
if (snapshotPath === null) {
|
|
817
|
+
return notFound();
|
|
818
|
+
}
|
|
819
|
+
try {
|
|
820
|
+
const file = await respondWithFile(snapshotPath);
|
|
821
|
+
return file ?? notFound();
|
|
822
|
+
} catch {
|
|
823
|
+
return notFound();
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
function handleArtifactRoute(ctx, req, pathname) {
|
|
827
|
+
if (pathname.startsWith("/file/")) {
|
|
828
|
+
return handleFile(ctx, req);
|
|
829
|
+
}
|
|
830
|
+
if (pathname.startsWith("/baseline/")) {
|
|
831
|
+
return handleBaseline(ctx, req);
|
|
832
|
+
}
|
|
833
|
+
return null;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// src/server/handlers.ts
|
|
837
|
+
function handleTestBegin(ctx, data) {
|
|
838
|
+
const test = applyTestBeginEvent(ctx, data);
|
|
839
|
+
ctx.reportData.isRunning = true;
|
|
840
|
+
console.log(` \u25B6 [${test.browser ?? "?"}] ${test.title}`);
|
|
841
|
+
const message = { type: "test-begin", data: test };
|
|
842
|
+
broadcastToBrowsers(ctx.wsClients, message);
|
|
843
|
+
}
|
|
844
|
+
function enrichDeclaredBaselines(ctx, test) {
|
|
845
|
+
const retry = (test.results?.length ?? 0) - 1;
|
|
846
|
+
const images = test.results?.[retry]?.images;
|
|
847
|
+
if (retry < 0 || images === void 0) {
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
for (const [visualName, image] of Object.entries(images)) {
|
|
851
|
+
if (image === void 0 || image.source !== "declared-only") {
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
const snapshotPath = resolveBaselineSnapshotPath(ctx.approvalRouting, test, retry, visualName);
|
|
855
|
+
if (snapshotPath === null || !(0, import_fs2.existsSync)(snapshotPath)) {
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
image.expect = `/baseline/${encodeURIComponent(test.id)}/${retry}/${encodeURIComponent(visualName)}`;
|
|
859
|
+
image.source = "baseline-only";
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
function handleTestEnd(ctx, data) {
|
|
863
|
+
const result = applyTestEndEvent(ctx, data);
|
|
864
|
+
if (result === null) {
|
|
865
|
+
console.error("[Server] test-end for unknown test id:", data.id);
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
const { test, diffCount } = result;
|
|
869
|
+
if (data.status === "passed") {
|
|
870
|
+
enrichDeclaredBaselines(ctx, test);
|
|
871
|
+
}
|
|
872
|
+
const icon = data.status === "passed" ? "\u2713" : data.status === "skipped" ? "\u2013" : "\u2717";
|
|
873
|
+
const dur = data.duration === null || data.duration === void 0 ? "" : ` (${data.duration}ms)`;
|
|
874
|
+
const diffNote = diffCount > 0 ? ` [${diffCount} diff(s)]` : "";
|
|
875
|
+
const errNote = data.error !== null && data.error !== void 0 ? `
|
|
876
|
+
Error: ${data.error}` : "";
|
|
877
|
+
console.log(` ${icon} [${test.browser}] ${test.title}${dur}${diffNote}${errNote}`);
|
|
878
|
+
const message = { type: "test-update", data: test };
|
|
879
|
+
broadcastToBrowsers(ctx.wsClients, message);
|
|
880
|
+
}
|
|
881
|
+
async function handleRunEnd(ctx, data) {
|
|
882
|
+
const removedTestIds = Object.keys(ctx.reportData.tests).filter((id) => !ctx.currentRunIds.has(id));
|
|
883
|
+
const { passed, failed, pending } = finalizeRunEvent(ctx);
|
|
884
|
+
await ctx.saveReport();
|
|
885
|
+
console.log(`
|
|
886
|
+
Run complete \u2014 ${passed} passed, ${failed} failed, ${pending} skipped`);
|
|
887
|
+
const message = {
|
|
888
|
+
type: "run-end",
|
|
889
|
+
data: { status: data.status, removedTestIds }
|
|
890
|
+
};
|
|
891
|
+
broadcastToBrowsers(ctx.wsClients, message);
|
|
892
|
+
}
|
|
893
|
+
function handleApprove() {
|
|
894
|
+
console.log("[Server] Received approve message via WebSocket (handled via HTTP API)");
|
|
895
|
+
}
|
|
896
|
+
function handleSync(ctx) {
|
|
897
|
+
console.log("[Server] Received sync message");
|
|
898
|
+
const message = {
|
|
899
|
+
type: "sync",
|
|
900
|
+
data: {
|
|
901
|
+
tests: ctx.reportData.tests,
|
|
902
|
+
isUpdateMode: ctx.reportData.isUpdateMode
|
|
903
|
+
}
|
|
904
|
+
};
|
|
905
|
+
broadcastToBrowsers(ctx.wsClients, message);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// src/server/routes.ts
|
|
909
|
+
var import_path7 = require("path");
|
|
910
|
+
async function handleRoot(ctx) {
|
|
911
|
+
const html = await respondWithFile((0, import_path7.join)(ctx.staticDir, "index.html"), "text/html");
|
|
912
|
+
return html ?? new Response("Not Found", { status: 404 });
|
|
913
|
+
}
|
|
914
|
+
async function handleAppCss() {
|
|
915
|
+
const css = await respondWithFile("./src/client/app.css", "text/css");
|
|
916
|
+
return css ?? new Response("Not Found", { status: 404 });
|
|
917
|
+
}
|
|
918
|
+
async function handleSrcFiles(req) {
|
|
919
|
+
const path = new URL(req.url).pathname.slice("/src/".length);
|
|
920
|
+
const filePath = `./src/${path}`;
|
|
921
|
+
const contentType = filePath.endsWith(".ts") || filePath.endsWith(".tsx") ? "application/javascript" : filePath.endsWith(".css") ? "text/css" : "text/plain";
|
|
922
|
+
const file = await respondWithFile(filePath, contentType);
|
|
923
|
+
return file ?? new Response("Not Found", { status: 404 });
|
|
924
|
+
}
|
|
925
|
+
function handleApiReport(ctx) {
|
|
926
|
+
return Response.json(ctx.reportData);
|
|
927
|
+
}
|
|
928
|
+
var APPROVAL_TARGET_ERROR = "Could not resolve approval target";
|
|
929
|
+
function actualPathFromUrl(ctx, actualUrl) {
|
|
930
|
+
if (actualUrl.startsWith("/screenshots/")) {
|
|
931
|
+
return (0, import_path7.join)(ctx.reportData.screenshotDir, actualUrl.slice("/screenshots/".length));
|
|
932
|
+
}
|
|
933
|
+
if (actualUrl.startsWith("/file/")) {
|
|
934
|
+
return decodeURIComponent(actualUrl.slice("/file/".length));
|
|
935
|
+
}
|
|
936
|
+
return actualUrl;
|
|
937
|
+
}
|
|
887
938
|
async function handleApiApprove(ctx, req) {
|
|
888
939
|
try {
|
|
889
940
|
const rawBody = await req.json();
|
|
@@ -901,7 +952,7 @@ async function handleApiApprove(ctx, req) {
|
|
|
901
952
|
if (actualUrl === void 0) {
|
|
902
953
|
return Response.json({ success: false, error: "Actual image not found" }, { status: 409 });
|
|
903
954
|
}
|
|
904
|
-
const snapshotPath =
|
|
955
|
+
const snapshotPath = resolveBaselineSnapshotPath(ctx.approvalRouting, test, retry, image);
|
|
905
956
|
if (snapshotPath === null) {
|
|
906
957
|
return Response.json({ success: false, error: APPROVAL_TARGET_ERROR }, { status: 409 });
|
|
907
958
|
}
|
|
@@ -936,7 +987,7 @@ function createBulkApprovalUpdates(ctx) {
|
|
|
936
987
|
if (actualUrl === void 0) {
|
|
937
988
|
return [Promise.resolve({ kind: "unresolved" })];
|
|
938
989
|
}
|
|
939
|
-
const snapshotPath =
|
|
990
|
+
const snapshotPath = resolveBaselineSnapshotPath(ctx.approvalRouting, test, lastRetry, imageName);
|
|
940
991
|
if (snapshotPath === null) {
|
|
941
992
|
return [Promise.resolve({ kind: "unresolved" })];
|
|
942
993
|
}
|
|
@@ -999,7 +1050,7 @@ async function handleScreenshots(ctx, req) {
|
|
|
999
1050
|
}
|
|
1000
1051
|
async function handleDist(ctx, req) {
|
|
1001
1052
|
const path = new URL(req.url).pathname.slice("/dist/".length);
|
|
1002
|
-
const filePath = (0,
|
|
1053
|
+
const filePath = (0, import_path7.join)(ctx.staticDir, path);
|
|
1003
1054
|
const contentType = filePath.endsWith(".css") ? "text/css" : filePath.endsWith(".js") ? "application/javascript" : filePath.endsWith(".svelte") ? "text/plain" : "application/octet-stream";
|
|
1004
1055
|
const file = await respondWithFile(filePath, contentType);
|
|
1005
1056
|
return file ?? new Response("Not Found", { status: 404 });
|
|
@@ -1027,6 +1078,10 @@ function handleHttpRequest(ctx, req) {
|
|
|
1027
1078
|
if (pathname.startsWith("/api/images/")) {
|
|
1028
1079
|
return handleApiImages(req);
|
|
1029
1080
|
}
|
|
1081
|
+
const artifactResponse = handleArtifactRoute(ctx, req, pathname);
|
|
1082
|
+
if (artifactResponse !== null) {
|
|
1083
|
+
return artifactResponse;
|
|
1084
|
+
}
|
|
1030
1085
|
if (pathname.startsWith("/screenshots/")) {
|
|
1031
1086
|
return handleScreenshots(ctx, req);
|
|
1032
1087
|
}
|
|
@@ -1048,12 +1103,13 @@ function createReportData(options) {
|
|
|
1048
1103
|
screenshotDir: options.screenshotDir ?? "./screenshots"
|
|
1049
1104
|
};
|
|
1050
1105
|
}
|
|
1051
|
-
function createHandlerContext(reportData, wsClients, currentRunIds, saveReport) {
|
|
1106
|
+
function createHandlerContext(reportData, wsClients, currentRunIds, saveReport, approvalRouting) {
|
|
1052
1107
|
return {
|
|
1053
1108
|
reportData,
|
|
1054
1109
|
wsClients,
|
|
1055
1110
|
currentRunIds,
|
|
1056
|
-
saveReport
|
|
1111
|
+
saveReport,
|
|
1112
|
+
approvalRouting
|
|
1057
1113
|
};
|
|
1058
1114
|
}
|
|
1059
1115
|
async function loadReport(reportPath, reportData) {
|
|
@@ -1103,10 +1159,6 @@ async function loadOfflineReports(offlineReportDir, reportData) {
|
|
|
1103
1159
|
screenshotsBaseUrl: "/screenshots/"
|
|
1104
1160
|
});
|
|
1105
1161
|
}
|
|
1106
|
-
function resetReloadableReportData(reportData) {
|
|
1107
|
-
reportData.tests = {};
|
|
1108
|
-
reportData.isUpdateMode = false;
|
|
1109
|
-
}
|
|
1110
1162
|
async function handleParsedWebSocketMessage(ctx, msg) {
|
|
1111
1163
|
switch (msg.type) {
|
|
1112
1164
|
case "test-begin": {
|
|
@@ -1127,9 +1179,15 @@ async function handleParsedWebSocketMessage(ctx, msg) {
|
|
|
1127
1179
|
handleTestEnd(ctx, parsed);
|
|
1128
1180
|
break;
|
|
1129
1181
|
}
|
|
1130
|
-
case "run-end":
|
|
1131
|
-
|
|
1182
|
+
case "run-end": {
|
|
1183
|
+
const parsed = safeParse(RunEndDataSchema, msg.data);
|
|
1184
|
+
if (parsed === null) {
|
|
1185
|
+
console.error("Invalid run-end message data", msg.data);
|
|
1186
|
+
break;
|
|
1187
|
+
}
|
|
1188
|
+
await handleRunEnd(ctx, parsed);
|
|
1132
1189
|
break;
|
|
1190
|
+
}
|
|
1133
1191
|
case "approve":
|
|
1134
1192
|
handleApprove();
|
|
1135
1193
|
break;
|
|
@@ -1142,7 +1200,7 @@ function createWebSocketMessageHandler(getHandlerContext) {
|
|
|
1142
1200
|
return async function handleWebSocketMessage(message) {
|
|
1143
1201
|
try {
|
|
1144
1202
|
const parsed = JSON.parse(message);
|
|
1145
|
-
const wsMessage = safeParse(
|
|
1203
|
+
const wsMessage = safeParse(IncomingWebSocketMessageSchema, parsed);
|
|
1146
1204
|
if (wsMessage === null) {
|
|
1147
1205
|
console.error("Invalid WebSocket message: missing or invalid type", parsed);
|
|
1148
1206
|
return;
|
|
@@ -1155,19 +1213,19 @@ function createWebSocketMessageHandler(getHandlerContext) {
|
|
|
1155
1213
|
};
|
|
1156
1214
|
}
|
|
1157
1215
|
async function resolveStaticDir(staticDir) {
|
|
1158
|
-
const currentDir = (0,
|
|
1216
|
+
const currentDir = (0, import_path8.dirname)((0, import_url.fileURLToPath)(import_meta.url));
|
|
1159
1217
|
const candidates = staticDir === void 0 ? [
|
|
1160
1218
|
currentDir,
|
|
1161
|
-
(0,
|
|
1162
|
-
(0,
|
|
1163
|
-
(0,
|
|
1164
|
-
(0,
|
|
1165
|
-
(0,
|
|
1166
|
-
] : [staticDir, (0,
|
|
1219
|
+
(0, import_path8.join)(currentDir, "dist"),
|
|
1220
|
+
(0, import_path8.join)(currentDir, "..", "dist"),
|
|
1221
|
+
(0, import_path8.join)(currentDir, "..", "..", "dist"),
|
|
1222
|
+
(0, import_path8.join)(currentDir, ".."),
|
|
1223
|
+
(0, import_path8.join)(currentDir, "..", "..")
|
|
1224
|
+
] : [staticDir, (0, import_path8.join)(staticDir, "dist")];
|
|
1167
1225
|
const resolvedCandidates = await Promise.all(
|
|
1168
1226
|
candidates.map(async (candidate) => ({
|
|
1169
1227
|
candidate,
|
|
1170
|
-
exists: await fileExists((0,
|
|
1228
|
+
exists: await fileExists((0, import_path8.join)(candidate, "index.html"))
|
|
1171
1229
|
}))
|
|
1172
1230
|
);
|
|
1173
1231
|
const resolved = resolvedCandidates.find(({ exists }) => exists);
|
|
@@ -1178,15 +1236,19 @@ async function resolveStaticDir(staticDir) {
|
|
|
1178
1236
|
}
|
|
1179
1237
|
async function resolveReportPath(reportPath) {
|
|
1180
1238
|
if (await isDirectory(reportPath)) {
|
|
1181
|
-
return { reportFile: (0,
|
|
1239
|
+
return { reportFile: (0, import_path8.join)(reportPath, "report.json"), offlineReportDir: reportPath };
|
|
1182
1240
|
}
|
|
1183
|
-
return { reportFile: reportPath, offlineReportDir: (0,
|
|
1241
|
+
return { reportFile: reportPath, offlineReportDir: (0, import_path8.dirname)(reportPath) };
|
|
1184
1242
|
}
|
|
1185
1243
|
function createRoutesContext(reportData, staticDir, saveReport, options) {
|
|
1244
|
+
const artifactRoots = [reportData.screenshotDir, options.outputDir, options.playwrightSnapshotDir].filter(
|
|
1245
|
+
(root) => root !== void 0 && root !== ""
|
|
1246
|
+
);
|
|
1186
1247
|
return {
|
|
1187
1248
|
reportData,
|
|
1188
1249
|
staticDir,
|
|
1189
1250
|
saveReport,
|
|
1251
|
+
artifactRoots,
|
|
1190
1252
|
approvalRouting: {
|
|
1191
1253
|
configDir: options.configDir ?? process.cwd(),
|
|
1192
1254
|
playwrightTestDir: options.playwrightTestDir,
|
|
@@ -1208,25 +1270,16 @@ async function createServerApp(options = {}) {
|
|
|
1208
1270
|
await writeJsonFile(reportFile, reportData);
|
|
1209
1271
|
}
|
|
1210
1272
|
const routesContext = createRoutesContext(reportData, staticDir, saveReport, options);
|
|
1211
|
-
const getHandlerContext = () => createHandlerContext(reportData, wsClients, currentRunIds, saveReport);
|
|
1273
|
+
const getHandlerContext = () => createHandlerContext(reportData, wsClients, currentRunIds, saveReport, routesContext.approvalRouting);
|
|
1212
1274
|
const handleRequest = (req) => handleHttpRequest(routesContext, req);
|
|
1213
1275
|
const handleWebSocketMessage = createWebSocketMessageHandler(getHandlerContext);
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
await loadReport(reportFile, reportData);
|
|
1217
|
-
await loadOfflineReports(offlineReportDir, reportData);
|
|
1218
|
-
broadcastToBrowsers(wsClients, { type: "sync", data: reportData });
|
|
1219
|
-
};
|
|
1220
|
-
await reloadFromDisk();
|
|
1221
|
-
const close = await watchReportArtifacts({
|
|
1222
|
-
offlineReportDir,
|
|
1223
|
-
screenshotDir: reportData.screenshotDir,
|
|
1224
|
-
scheduleRefresh: createDebouncedRefresh(reloadFromDisk)
|
|
1225
|
-
});
|
|
1276
|
+
await loadReport(reportFile, reportData);
|
|
1277
|
+
await loadOfflineReports(offlineReportDir, reportData);
|
|
1226
1278
|
return {
|
|
1227
1279
|
port,
|
|
1228
1280
|
wsClients,
|
|
1229
|
-
close
|
|
1281
|
+
close: () => {
|
|
1282
|
+
},
|
|
1230
1283
|
handleRequest,
|
|
1231
1284
|
handleWebSocketMessage
|
|
1232
1285
|
};
|
|
@@ -1407,7 +1460,7 @@ function attachWebSocketServer(server, app) {
|
|
|
1407
1460
|
});
|
|
1408
1461
|
}
|
|
1409
1462
|
async function listen(server, port) {
|
|
1410
|
-
await new Promise((
|
|
1463
|
+
await new Promise((resolve4, reject) => {
|
|
1411
1464
|
const onError = (error) => {
|
|
1412
1465
|
server.off("error", onError);
|
|
1413
1466
|
reject(error);
|
|
@@ -1415,7 +1468,7 @@ async function listen(server, port) {
|
|
|
1415
1468
|
server.on("error", onError);
|
|
1416
1469
|
server.listen(port, () => {
|
|
1417
1470
|
server.off("error", onError);
|
|
1418
|
-
|
|
1471
|
+
resolve4();
|
|
1419
1472
|
});
|
|
1420
1473
|
});
|
|
1421
1474
|
}
|