@cevek/screentest 0.2.5 → 0.3.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/README.md +52 -36
- package/dist/global-setup.js +7 -0
- package/dist/index.js +334 -182
- package/dist/runner.js +20 -15
- package/dist/templates/Dockerfile.firefox-server +25 -0
- package/dist/templates/launch-server.mjs +22 -0
- package/dist/web/assets/{index-DMJ0v7v-.css → index-Bi-jx470.css} +1 -1
- package/dist/web/assets/{index-DIlEhyib.js → index-nQ8FCC_F.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +3 -2
- package/dist/templates/Dockerfile.npm.tests +0 -16
- package/dist/templates/Dockerfile.pnpm.tests +0 -27
- package/dist/templates/Dockerfile.yarn.tests +0 -20
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@ var __export = (target, all) => {
|
|
|
7
7
|
|
|
8
8
|
// src/ui.ts
|
|
9
9
|
import { promises as fs3 } from "fs";
|
|
10
|
-
import { dirname as
|
|
10
|
+
import { dirname as dirname4, isAbsolute, resolve as resolve5 } from "path";
|
|
11
11
|
|
|
12
12
|
// ../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/classic/external.js
|
|
13
13
|
var external_exports = {};
|
|
@@ -14554,7 +14554,15 @@ var groupItemSchema = external_exports.lazy(
|
|
|
14554
14554
|
items: external_exports.array(external_exports.union([groupItemSchema, diffItemSchema])).min(0)
|
|
14555
14555
|
})
|
|
14556
14556
|
);
|
|
14557
|
+
var projectInfoSchema = external_exports.object({
|
|
14558
|
+
/** Absolute path to the project root the doc.json was generated from. */
|
|
14559
|
+
path: external_exports.string(),
|
|
14560
|
+
/** Current git branch, or `null` if not a git repo (or `git` not on PATH). */
|
|
14561
|
+
branch: external_exports.string().nullable()
|
|
14562
|
+
});
|
|
14557
14563
|
var docSchema = external_exports.object({
|
|
14564
|
+
/** Optional for backward compatibility with pre-0.3 doc.json files. */
|
|
14565
|
+
project: projectInfoSchema.optional(),
|
|
14558
14566
|
groups: external_exports.array(groupItemSchema)
|
|
14559
14567
|
});
|
|
14560
14568
|
var snapshotTestSchema = external_exports.object({
|
|
@@ -14573,7 +14581,9 @@ var snapshotDocSchema = external_exports.object({
|
|
|
14573
14581
|
var apiDocResponseSchema = external_exports.object({
|
|
14574
14582
|
doc: docSchema,
|
|
14575
14583
|
expectedUrlPattern: external_exports.string(),
|
|
14576
|
-
workerUrl: external_exports.string()
|
|
14584
|
+
workerUrl: external_exports.string(),
|
|
14585
|
+
/** `@cevek/screentest` package version that served this doc.json. */
|
|
14586
|
+
serverVersion: external_exports.string()
|
|
14577
14587
|
});
|
|
14578
14588
|
var apiAcceptRequestSchema = external_exports.object({
|
|
14579
14589
|
testId: external_exports.string().min(1)
|
|
@@ -14645,8 +14655,8 @@ function flatten(doc, docDir) {
|
|
|
14645
14655
|
// src/server.ts
|
|
14646
14656
|
import express from "express";
|
|
14647
14657
|
import { existsSync } from "fs";
|
|
14648
|
-
import { dirname as
|
|
14649
|
-
import { fileURLToPath } from "url";
|
|
14658
|
+
import { dirname as dirname3, join, resolve as resolve4 } from "path";
|
|
14659
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
14650
14660
|
import { createServer } from "http";
|
|
14651
14661
|
|
|
14652
14662
|
// src/routes/doc.ts
|
|
@@ -14677,6 +14687,26 @@ function expectedUrlPattern(workerUrl) {
|
|
|
14677
14687
|
return `${workerUrl}/{hash}.png`;
|
|
14678
14688
|
}
|
|
14679
14689
|
|
|
14690
|
+
// src/version.ts
|
|
14691
|
+
import { readFileSync } from "fs";
|
|
14692
|
+
import { dirname, resolve as resolve2 } from "path";
|
|
14693
|
+
import { fileURLToPath } from "url";
|
|
14694
|
+
function readVersion() {
|
|
14695
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
14696
|
+
const candidates = [resolve2(here, "../package.json"), resolve2(here, "../../package.json")];
|
|
14697
|
+
for (const p of candidates) {
|
|
14698
|
+
try {
|
|
14699
|
+
const pkg = JSON.parse(readFileSync(p, "utf8"));
|
|
14700
|
+
if (pkg.name === "@cevek/screentest" && typeof pkg.version === "string") {
|
|
14701
|
+
return pkg.version;
|
|
14702
|
+
}
|
|
14703
|
+
} catch {
|
|
14704
|
+
}
|
|
14705
|
+
}
|
|
14706
|
+
return "unknown";
|
|
14707
|
+
}
|
|
14708
|
+
var SCREENTEST_VERSION = readVersion();
|
|
14709
|
+
|
|
14680
14710
|
// src/routes/doc.ts
|
|
14681
14711
|
function docRoute(state) {
|
|
14682
14712
|
const r = Router();
|
|
@@ -14684,7 +14714,8 @@ function docRoute(state) {
|
|
|
14684
14714
|
res.json({
|
|
14685
14715
|
doc: state.doc,
|
|
14686
14716
|
expectedUrlPattern: expectedUrlPattern(state.workerUrl),
|
|
14687
|
-
workerUrl: state.workerUrl
|
|
14717
|
+
workerUrl: state.workerUrl,
|
|
14718
|
+
serverVersion: SCREENTEST_VERSION
|
|
14688
14719
|
});
|
|
14689
14720
|
});
|
|
14690
14721
|
return r;
|
|
@@ -14754,7 +14785,7 @@ function sha256Hex(buf) {
|
|
|
14754
14785
|
|
|
14755
14786
|
// src/patcher.ts
|
|
14756
14787
|
import { promises as fs } from "fs";
|
|
14757
|
-
import { dirname, resolve as
|
|
14788
|
+
import { dirname as dirname2, resolve as resolve3 } from "path";
|
|
14758
14789
|
var fileLocks = /* @__PURE__ */ new Map();
|
|
14759
14790
|
async function withFileLock(absPath, fn) {
|
|
14760
14791
|
const prev = fileLocks.get(absPath) ?? Promise.resolve();
|
|
@@ -14804,7 +14835,7 @@ function upsertHash(doc, path, hash2) {
|
|
|
14804
14835
|
async function atomicWriteJson(absPath, data) {
|
|
14805
14836
|
const json2 = JSON.stringify(data, null, 2);
|
|
14806
14837
|
const tmp = `${absPath}.tmp-${process.pid}-${Date.now()}`;
|
|
14807
|
-
await fs.mkdir(
|
|
14838
|
+
await fs.mkdir(dirname2(absPath), { recursive: true });
|
|
14808
14839
|
await fs.writeFile(tmp, json2, "utf8");
|
|
14809
14840
|
await fs.rename(tmp, absPath);
|
|
14810
14841
|
}
|
|
@@ -14839,7 +14870,7 @@ function removeAtPath(doc, path) {
|
|
|
14839
14870
|
return true;
|
|
14840
14871
|
}
|
|
14841
14872
|
async function removeFromSnapshot(args) {
|
|
14842
|
-
const abs =
|
|
14873
|
+
const abs = resolve3(args.docDir, args.patchSnapshotJsonFile);
|
|
14843
14874
|
await withFileLock(abs, async () => {
|
|
14844
14875
|
let doc;
|
|
14845
14876
|
try {
|
|
@@ -14856,7 +14887,7 @@ async function removeFromSnapshot(args) {
|
|
|
14856
14887
|
});
|
|
14857
14888
|
}
|
|
14858
14889
|
async function patchSnapshot(args) {
|
|
14859
|
-
const abs =
|
|
14890
|
+
const abs = resolve3(args.docDir, args.patchSnapshotJsonFile);
|
|
14860
14891
|
await withFileLock(abs, async () => {
|
|
14861
14892
|
let doc;
|
|
14862
14893
|
try {
|
|
@@ -14927,14 +14958,14 @@ function acceptRoute(state) {
|
|
|
14927
14958
|
|
|
14928
14959
|
// src/server.ts
|
|
14929
14960
|
function resolveWebDist() {
|
|
14930
|
-
const here =
|
|
14961
|
+
const here = dirname3(fileURLToPath2(import.meta.url));
|
|
14931
14962
|
const candidates = [
|
|
14932
14963
|
// Published npm package: dist/index.js → ./web
|
|
14933
|
-
|
|
14964
|
+
resolve4(here, "./web"),
|
|
14934
14965
|
// Local monorepo build: apps/server/dist/index.js → ../../web/dist
|
|
14935
|
-
|
|
14966
|
+
resolve4(here, "../../web/dist"),
|
|
14936
14967
|
// Dev via tsx: apps/server/src/server.ts → ../../../apps/web/dist
|
|
14937
|
-
|
|
14968
|
+
resolve4(here, "../../../apps/web/dist")
|
|
14938
14969
|
];
|
|
14939
14970
|
for (const c of candidates) {
|
|
14940
14971
|
if (existsSync(join(c, "index.html"))) return c;
|
|
@@ -15026,8 +15057,8 @@ function parseUIArgs(rawArgs) {
|
|
|
15026
15057
|
};
|
|
15027
15058
|
}
|
|
15028
15059
|
async function runUI(opts) {
|
|
15029
|
-
const docAbs = isAbsolute(opts.docPath) ? opts.docPath :
|
|
15030
|
-
const docDir =
|
|
15060
|
+
const docAbs = isAbsolute(opts.docPath) ? opts.docPath : resolve5(process.cwd(), opts.docPath);
|
|
15061
|
+
const docDir = dirname4(docAbs);
|
|
15031
15062
|
let raw;
|
|
15032
15063
|
try {
|
|
15033
15064
|
raw = await fs3.readFile(docAbs, "utf8");
|
|
@@ -15092,14 +15123,227 @@ async function runUI(opts) {
|
|
|
15092
15123
|
}
|
|
15093
15124
|
|
|
15094
15125
|
// src/orchestrator.ts
|
|
15095
|
-
import { spawn
|
|
15126
|
+
import { spawn as spawn2 } from "child_process";
|
|
15096
15127
|
import { promises as fs5 } from "fs";
|
|
15097
|
-
import {
|
|
15128
|
+
import { dirname as dirname6 } from "path";
|
|
15129
|
+
|
|
15130
|
+
// src/daemon.ts
|
|
15131
|
+
import { spawn, spawnSync } from "child_process";
|
|
15132
|
+
import { existsSync as existsSync2 } from "fs";
|
|
15133
|
+
import { dirname as dirname5, resolve as resolve6 } from "path";
|
|
15134
|
+
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
15135
|
+
import { createConnection } from "net";
|
|
15136
|
+
var IMAGE_NAME = "cevek/screentest-firefox";
|
|
15137
|
+
var IMAGE_TAG = "pw-1.60.0";
|
|
15138
|
+
var FULL_IMAGE = `${IMAGE_NAME}:${IMAGE_TAG}`;
|
|
15139
|
+
var CONTAINER_NAME = "screentest-firefox";
|
|
15140
|
+
var PORT = 5180;
|
|
15141
|
+
var WS_PATH = "screentest-firefox";
|
|
15142
|
+
var WS_ENDPOINT = `ws://localhost:${PORT}/${WS_PATH}`;
|
|
15143
|
+
function run(cmd, argv) {
|
|
15144
|
+
return new Promise((resolveExit) => {
|
|
15145
|
+
const p = spawn(cmd, argv, { stdio: "inherit" });
|
|
15146
|
+
p.on("exit", (code) => resolveExit(code ?? 0));
|
|
15147
|
+
});
|
|
15148
|
+
}
|
|
15149
|
+
function daemonState() {
|
|
15150
|
+
const ps = spawnSync(
|
|
15151
|
+
"docker",
|
|
15152
|
+
["ps", "-a", "-f", `name=^${CONTAINER_NAME}$`, "--format", "{{.Status}} {{.Image}}"],
|
|
15153
|
+
{ stdio: "pipe", encoding: "utf8" }
|
|
15154
|
+
);
|
|
15155
|
+
const line = ps.stdout.trim();
|
|
15156
|
+
if (line.startsWith("Up")) {
|
|
15157
|
+
const tab = line.indexOf(" ");
|
|
15158
|
+
const image = tab === -1 ? "" : line.slice(tab + 1).trim();
|
|
15159
|
+
return { state: "running", image };
|
|
15160
|
+
}
|
|
15161
|
+
const img = spawnSync("docker", ["image", "inspect", FULL_IMAGE], { stdio: "pipe" });
|
|
15162
|
+
if (img.status !== 0) return { state: "missing-image" };
|
|
15163
|
+
return { state: "stopped" };
|
|
15164
|
+
}
|
|
15165
|
+
function resolveTemplatesDir() {
|
|
15166
|
+
const here = dirname5(fileURLToPath3(import.meta.url));
|
|
15167
|
+
const candidates = [
|
|
15168
|
+
resolve6(here, "./templates"),
|
|
15169
|
+
resolve6(here, "../templates"),
|
|
15170
|
+
resolve6(here, "../../server/templates")
|
|
15171
|
+
];
|
|
15172
|
+
for (const c of candidates) {
|
|
15173
|
+
if (existsSync2(c)) return c;
|
|
15174
|
+
}
|
|
15175
|
+
throw new Error("templates/ directory not found relative to daemon.ts");
|
|
15176
|
+
}
|
|
15177
|
+
async function buildImage() {
|
|
15178
|
+
const templates = resolveTemplatesDir();
|
|
15179
|
+
process.stderr.write(`Building Docker image ${FULL_IMAGE} (~2 min first time, cached after).
|
|
15180
|
+
`);
|
|
15181
|
+
const code = await run("docker", [
|
|
15182
|
+
"build",
|
|
15183
|
+
"-f",
|
|
15184
|
+
`${templates}/Dockerfile.firefox-server`,
|
|
15185
|
+
"-t",
|
|
15186
|
+
FULL_IMAGE,
|
|
15187
|
+
templates
|
|
15188
|
+
]);
|
|
15189
|
+
if (code !== 0) {
|
|
15190
|
+
process.stderr.write(`docker build failed (exit ${code})
|
|
15191
|
+
`);
|
|
15192
|
+
return false;
|
|
15193
|
+
}
|
|
15194
|
+
return true;
|
|
15195
|
+
}
|
|
15196
|
+
function probePort() {
|
|
15197
|
+
return new Promise((resolveProbe) => {
|
|
15198
|
+
const s = createConnection({ host: "127.0.0.1", port: PORT });
|
|
15199
|
+
s.once("connect", () => {
|
|
15200
|
+
s.end();
|
|
15201
|
+
resolveProbe(true);
|
|
15202
|
+
});
|
|
15203
|
+
s.once("error", () => resolveProbe(false));
|
|
15204
|
+
});
|
|
15205
|
+
}
|
|
15206
|
+
async function waitForReady(timeoutMs = 1e4) {
|
|
15207
|
+
const start = Date.now();
|
|
15208
|
+
while (Date.now() - start < timeoutMs) {
|
|
15209
|
+
if (await probePort()) return true;
|
|
15210
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
15211
|
+
}
|
|
15212
|
+
return false;
|
|
15213
|
+
}
|
|
15214
|
+
async function startDaemon() {
|
|
15215
|
+
const s = daemonState();
|
|
15216
|
+
if (s.state === "running") {
|
|
15217
|
+
if (s.image !== FULL_IMAGE) {
|
|
15218
|
+
process.stderr.write(
|
|
15219
|
+
`screentest-firefox daemon is running, but on a different image:
|
|
15220
|
+
running: ${s.image}
|
|
15221
|
+
this @cevek/screentest wants: ${FULL_IMAGE}
|
|
15222
|
+
\u2192 stop it first: screentest stop
|
|
15223
|
+
\u2192 then re-run: screentest serve
|
|
15224
|
+
`
|
|
15225
|
+
);
|
|
15226
|
+
return 1;
|
|
15227
|
+
}
|
|
15228
|
+
process.stdout.write(`screentest-firefox daemon already running at ${WS_ENDPOINT}
|
|
15229
|
+
`);
|
|
15230
|
+
return 0;
|
|
15231
|
+
}
|
|
15232
|
+
if (s.state === "missing-image") {
|
|
15233
|
+
if (!await buildImage()) return 1;
|
|
15234
|
+
}
|
|
15235
|
+
spawnSync("docker", ["rm", "-f", CONTAINER_NAME], { stdio: "pipe" });
|
|
15236
|
+
process.stderr.write(`Starting screentest-firefox daemon (port ${PORT})\u2026
|
|
15237
|
+
`);
|
|
15238
|
+
const code = await run("docker", [
|
|
15239
|
+
"run",
|
|
15240
|
+
"-d",
|
|
15241
|
+
"--rm",
|
|
15242
|
+
"--name",
|
|
15243
|
+
CONTAINER_NAME,
|
|
15244
|
+
"--network=host",
|
|
15245
|
+
FULL_IMAGE
|
|
15246
|
+
]);
|
|
15247
|
+
if (code !== 0) {
|
|
15248
|
+
process.stderr.write(`docker run failed (exit ${code})
|
|
15249
|
+
`);
|
|
15250
|
+
return code;
|
|
15251
|
+
}
|
|
15252
|
+
if (!await waitForReady()) {
|
|
15253
|
+
process.stderr.write(`Daemon container started but port ${PORT} never opened. Logs:
|
|
15254
|
+
`);
|
|
15255
|
+
spawnSync("docker", ["logs", CONTAINER_NAME], { stdio: "inherit" });
|
|
15256
|
+
return 1;
|
|
15257
|
+
}
|
|
15258
|
+
process.stdout.write(`screentest-firefox daemon ready at ${WS_ENDPOINT}
|
|
15259
|
+
`);
|
|
15260
|
+
return 0;
|
|
15261
|
+
}
|
|
15262
|
+
async function stopDaemon() {
|
|
15263
|
+
const s = daemonState();
|
|
15264
|
+
if (s.state !== "running") {
|
|
15265
|
+
process.stdout.write("screentest-firefox daemon is not running\n");
|
|
15266
|
+
return 0;
|
|
15267
|
+
}
|
|
15268
|
+
const code = await run("docker", ["stop", CONTAINER_NAME]);
|
|
15269
|
+
if (code === 0) process.stdout.write("screentest-firefox daemon stopped\n");
|
|
15270
|
+
return code;
|
|
15271
|
+
}
|
|
15272
|
+
async function printStatus() {
|
|
15273
|
+
const s = daemonState();
|
|
15274
|
+
if (s.state === "running") {
|
|
15275
|
+
const match = s.image === FULL_IMAGE ? "(matches this @cevek/screentest)" : "(MISMATCH!)";
|
|
15276
|
+
process.stdout.write(
|
|
15277
|
+
`screentest-firefox daemon: running
|
|
15278
|
+
ws endpoint: ${WS_ENDPOINT}
|
|
15279
|
+
image: ${s.image} ${match}
|
|
15280
|
+
expected: ${FULL_IMAGE}
|
|
15281
|
+
`
|
|
15282
|
+
);
|
|
15283
|
+
if (s.image !== FULL_IMAGE) {
|
|
15284
|
+
process.stdout.write(
|
|
15285
|
+
` \u2192 daemon is running an image pinned by a different version of this
|
|
15286
|
+
package. Stop it (screentest stop) and re-serve from the project
|
|
15287
|
+
whose Playwright version you want to use.
|
|
15288
|
+
`
|
|
15289
|
+
);
|
|
15290
|
+
}
|
|
15291
|
+
return 0;
|
|
15292
|
+
}
|
|
15293
|
+
if (s.state === "stopped") {
|
|
15294
|
+
process.stdout.write(
|
|
15295
|
+
`screentest-firefox daemon: stopped (image ${FULL_IMAGE} exists)
|
|
15296
|
+
start it with: screentest serve
|
|
15297
|
+
`
|
|
15298
|
+
);
|
|
15299
|
+
return 0;
|
|
15300
|
+
}
|
|
15301
|
+
process.stdout.write(
|
|
15302
|
+
`screentest-firefox daemon: not installed (image ${FULL_IMAGE} missing)
|
|
15303
|
+
build + start with: screentest serve
|
|
15304
|
+
`
|
|
15305
|
+
);
|
|
15306
|
+
return 0;
|
|
15307
|
+
}
|
|
15308
|
+
async function requireRunningDaemon() {
|
|
15309
|
+
const s = daemonState();
|
|
15310
|
+
if (s.state !== "running") {
|
|
15311
|
+
process.stderr.write(
|
|
15312
|
+
`screentest-firefox daemon is not running.
|
|
15313
|
+
Start it once with: screentest serve
|
|
15314
|
+
It then stays alive across projects until you stop it.
|
|
15315
|
+
`
|
|
15316
|
+
);
|
|
15317
|
+
return false;
|
|
15318
|
+
}
|
|
15319
|
+
if (s.image !== FULL_IMAGE) {
|
|
15320
|
+
process.stderr.write(
|
|
15321
|
+
`screentest-firefox daemon is running with a different Playwright version:
|
|
15322
|
+
running: ${s.image}
|
|
15323
|
+
this @cevek/screentest expects: ${FULL_IMAGE}
|
|
15324
|
+
\u2192 screentest stop && screentest serve
|
|
15325
|
+
(or align package versions across projects sharing the daemon)
|
|
15326
|
+
`
|
|
15327
|
+
);
|
|
15328
|
+
return false;
|
|
15329
|
+
}
|
|
15330
|
+
return true;
|
|
15331
|
+
}
|
|
15098
15332
|
|
|
15099
15333
|
// src/doc-builder.ts
|
|
15334
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
15100
15335
|
import { createHash as createHash2 } from "crypto";
|
|
15101
15336
|
import { promises as fs4 } from "fs";
|
|
15102
15337
|
import { join as join2, relative, sep } from "path";
|
|
15338
|
+
function detectBranch(projectRoot) {
|
|
15339
|
+
const r = spawnSync2("git", ["-C", projectRoot, "rev-parse", "--abbrev-ref", "HEAD"], {
|
|
15340
|
+
stdio: "pipe",
|
|
15341
|
+
encoding: "utf8"
|
|
15342
|
+
});
|
|
15343
|
+
if (r.status !== 0) return null;
|
|
15344
|
+
const branch = r.stdout.trim();
|
|
15345
|
+
return branch || null;
|
|
15346
|
+
}
|
|
15103
15347
|
function isGroup(node) {
|
|
15104
15348
|
return Array.isArray(node.items);
|
|
15105
15349
|
}
|
|
@@ -15163,12 +15407,15 @@ function countLeaves(doc) {
|
|
|
15163
15407
|
for (const g of doc.groups) walk(g.items);
|
|
15164
15408
|
return n;
|
|
15165
15409
|
}
|
|
15166
|
-
async function generateDoc(snapshotFile, actualDir, docFile, cacheDir) {
|
|
15410
|
+
async function generateDoc(projectRoot, snapshotFile, actualDir, docFile, cacheDir) {
|
|
15167
15411
|
const snap = await readSnapshot(snapshotFile);
|
|
15168
15412
|
const expectedByPath = indexSnapshot(snap);
|
|
15169
15413
|
const actuals = await walkActual(actualDir);
|
|
15170
15414
|
const seen = /* @__PURE__ */ new Set();
|
|
15171
|
-
const doc = {
|
|
15415
|
+
const doc = {
|
|
15416
|
+
project: { path: projectRoot, branch: detectBranch(projectRoot) },
|
|
15417
|
+
groups: []
|
|
15418
|
+
};
|
|
15172
15419
|
const summary = { new: 0, change: 0, deleted: 0, unchanged: 0 };
|
|
15173
15420
|
for (const a of actuals) {
|
|
15174
15421
|
const buf = await fs4.readFile(a.absFile);
|
|
@@ -15219,135 +15466,42 @@ async function generateDoc(snapshotFile, actualDir, docFile, cacheDir) {
|
|
|
15219
15466
|
}
|
|
15220
15467
|
|
|
15221
15468
|
// src/runner/paths.ts
|
|
15222
|
-
import {
|
|
15469
|
+
import { existsSync as existsSync3 } from "fs";
|
|
15470
|
+
import { isAbsolute as isAbsolute2, join as join3, resolve as resolve7 } from "path";
|
|
15223
15471
|
function resolveRunnerPaths(cwd = process.cwd()) {
|
|
15224
|
-
const projectRoot =
|
|
15472
|
+
const projectRoot = resolve7(cwd);
|
|
15225
15473
|
const cacheDir = join3(projectRoot, "node_modules", ".cache", "screentest");
|
|
15226
15474
|
return {
|
|
15227
15475
|
projectRoot,
|
|
15228
|
-
snapshotFile:
|
|
15476
|
+
snapshotFile: resolveSnapshotFile(projectRoot),
|
|
15229
15477
|
cacheDir,
|
|
15230
15478
|
actualDir: join3(cacheDir, "actual"),
|
|
15231
15479
|
docFile: join3(cacheDir, "doc.json")
|
|
15232
15480
|
};
|
|
15233
15481
|
}
|
|
15482
|
+
function resolveSnapshotFile(projectRoot) {
|
|
15483
|
+
const envPath = process.env.SCREENTEST_SNAPSHOT_FILE;
|
|
15484
|
+
if (envPath) {
|
|
15485
|
+
return isAbsolute2(envPath) ? envPath : join3(projectRoot, envPath);
|
|
15486
|
+
}
|
|
15487
|
+
const legacyRoot = join3(projectRoot, "snapshot.json");
|
|
15488
|
+
const insideTests = join3(projectRoot, "tests", "snapshot.json");
|
|
15489
|
+
if (existsSync3(legacyRoot) && !existsSync3(insideTests)) return legacyRoot;
|
|
15490
|
+
return insideTests;
|
|
15491
|
+
}
|
|
15234
15492
|
|
|
15235
15493
|
// src/orchestrator.ts
|
|
15236
|
-
|
|
15237
|
-
|
|
15238
|
-
|
|
15239
|
-
|
|
15240
|
-
p.on("exit", (code) => resolve7(code ?? 0));
|
|
15494
|
+
function run2(cmd, argv, env) {
|
|
15495
|
+
return new Promise((resolveExit) => {
|
|
15496
|
+
const p = spawn2(cmd, argv, { stdio: "inherit", env: env ?? process.env });
|
|
15497
|
+
p.on("exit", (code) => resolveExit(code ?? 0));
|
|
15241
15498
|
});
|
|
15242
15499
|
}
|
|
15243
|
-
async function exists(absPath) {
|
|
15244
|
-
try {
|
|
15245
|
-
await fs5.access(absPath);
|
|
15246
|
-
return true;
|
|
15247
|
-
} catch {
|
|
15248
|
-
return false;
|
|
15249
|
-
}
|
|
15250
|
-
}
|
|
15251
|
-
async function ensureDockerImageFresh(projectRoot) {
|
|
15252
|
-
const dockerfile = join4(projectRoot, "Dockerfile.tests");
|
|
15253
|
-
if (!await exists(dockerfile)) {
|
|
15254
|
-
process.stderr.write(
|
|
15255
|
-
`Dockerfile.tests not found at ${dockerfile}
|
|
15256
|
-
Did you run \`screentest init\` in this project?
|
|
15257
|
-
`
|
|
15258
|
-
);
|
|
15259
|
-
return false;
|
|
15260
|
-
}
|
|
15261
|
-
const inspect = spawnSync(
|
|
15262
|
-
"docker",
|
|
15263
|
-
["image", "inspect", "--format", "{{.Created}}", DOCKER_IMAGE],
|
|
15264
|
-
{ stdio: "pipe", encoding: "utf8" }
|
|
15265
|
-
);
|
|
15266
|
-
let reason = null;
|
|
15267
|
-
if (inspect.status !== 0) {
|
|
15268
|
-
reason = `Docker image "${DOCKER_IMAGE}" not found \u2014 building it (first build takes ~2 min).`;
|
|
15269
|
-
} else {
|
|
15270
|
-
const imageCreated = new Date(inspect.stdout.trim()).getTime();
|
|
15271
|
-
const inputs = [
|
|
15272
|
-
"package.json",
|
|
15273
|
-
"pnpm-lock.yaml",
|
|
15274
|
-
"package-lock.json",
|
|
15275
|
-
"yarn.lock",
|
|
15276
|
-
"Dockerfile.tests"
|
|
15277
|
-
];
|
|
15278
|
-
for (const f of inputs) {
|
|
15279
|
-
const abs = join4(projectRoot, f);
|
|
15280
|
-
try {
|
|
15281
|
-
const st = await fs5.stat(abs);
|
|
15282
|
-
if (st.mtimeMs > imageCreated) {
|
|
15283
|
-
reason = `${f} changed since the image was built \u2014 rebuilding.`;
|
|
15284
|
-
break;
|
|
15285
|
-
}
|
|
15286
|
-
} catch {
|
|
15287
|
-
}
|
|
15288
|
-
}
|
|
15289
|
-
}
|
|
15290
|
-
if (!reason) return true;
|
|
15291
|
-
process.stderr.write(`${reason}
|
|
15292
|
-
`);
|
|
15293
|
-
const code = await run("docker", [
|
|
15294
|
-
"build",
|
|
15295
|
-
"-f",
|
|
15296
|
-
"Dockerfile.tests",
|
|
15297
|
-
"-t",
|
|
15298
|
-
DOCKER_IMAGE,
|
|
15299
|
-
projectRoot
|
|
15300
|
-
]);
|
|
15301
|
-
if (code !== 0) {
|
|
15302
|
-
process.stderr.write(`docker build failed (exit ${code})
|
|
15303
|
-
`);
|
|
15304
|
-
return false;
|
|
15305
|
-
}
|
|
15306
|
-
return true;
|
|
15307
|
-
}
|
|
15308
|
-
async function runVitestInDocker(projectRoot, snapshotFile, cacheDir) {
|
|
15309
|
-
const testsDir = join4(projectRoot, "tests");
|
|
15310
|
-
const configFile = join4(projectRoot, "vitest.screentest.config.ts");
|
|
15311
|
-
const tsconfigFile = join4(projectRoot, "tsconfig.json");
|
|
15312
|
-
if (!await exists(testsDir)) {
|
|
15313
|
-
process.stderr.write(
|
|
15314
|
-
`tests/ not found at ${testsDir}
|
|
15315
|
-
Did you run \`screentest init\` in this project?
|
|
15316
|
-
`
|
|
15317
|
-
);
|
|
15318
|
-
return 1;
|
|
15319
|
-
}
|
|
15320
|
-
if (!await exists(configFile)) {
|
|
15321
|
-
process.stderr.write(
|
|
15322
|
-
`vitest.screentest.config.ts not found at ${configFile}
|
|
15323
|
-
Did you run \`screentest init\` in this project?
|
|
15324
|
-
`
|
|
15325
|
-
);
|
|
15326
|
-
return 1;
|
|
15327
|
-
}
|
|
15328
|
-
const dockerArgs = ["run", "--rm", "--init", "--network=host"];
|
|
15329
|
-
dockerArgs.push("-e", `APP_URL=${process.env.APP_URL || "http://localhost:5050"}`);
|
|
15330
|
-
if (process.env.CI) dockerArgs.push("-e", "CI=1");
|
|
15331
|
-
dockerArgs.push(
|
|
15332
|
-
"-v",
|
|
15333
|
-
`${cacheDir}:/work/node_modules/.cache/screentest`,
|
|
15334
|
-
"-v",
|
|
15335
|
-
`${snapshotFile}:/work/snapshot.json`,
|
|
15336
|
-
"-v",
|
|
15337
|
-
`${testsDir}:/work/tests:ro`,
|
|
15338
|
-
"-v",
|
|
15339
|
-
`${configFile}:/work/vitest.screentest.config.ts:ro`
|
|
15340
|
-
);
|
|
15341
|
-
if (await exists(tsconfigFile)) {
|
|
15342
|
-
dockerArgs.push("-v", `${tsconfigFile}:/work/tsconfig.json:ro`);
|
|
15343
|
-
}
|
|
15344
|
-
dockerArgs.push(DOCKER_IMAGE);
|
|
15345
|
-
return run("docker", dockerArgs);
|
|
15346
|
-
}
|
|
15347
15500
|
async function runOrchestrator(opts = {}) {
|
|
15348
15501
|
const paths = resolveRunnerPaths();
|
|
15349
15502
|
if (opts.reviewOnly) {
|
|
15350
15503
|
const total = await generateDoc(
|
|
15504
|
+
paths.projectRoot,
|
|
15351
15505
|
paths.snapshotFile,
|
|
15352
15506
|
paths.actualDir,
|
|
15353
15507
|
paths.docFile,
|
|
@@ -15357,36 +15511,38 @@ async function runOrchestrator(opts = {}) {
|
|
|
15357
15511
|
process.stdout.write("Nothing to review \u2014 all snapshots match.\n");
|
|
15358
15512
|
return 0;
|
|
15359
15513
|
}
|
|
15360
|
-
return await
|
|
15514
|
+
return await run2("node", [process.argv[1], paths.docFile]);
|
|
15361
15515
|
}
|
|
15362
15516
|
await fs5.mkdir(paths.cacheDir, { recursive: true });
|
|
15363
15517
|
try {
|
|
15364
15518
|
await fs5.access(paths.snapshotFile);
|
|
15365
15519
|
} catch {
|
|
15520
|
+
await fs5.mkdir(dirname6(paths.snapshotFile), { recursive: true });
|
|
15366
15521
|
await fs5.writeFile(paths.snapshotFile, '{\n "groups": []\n}\n');
|
|
15367
15522
|
}
|
|
15368
15523
|
await fs5.rm(paths.actualDir, { recursive: true, force: true });
|
|
15369
|
-
|
|
15370
|
-
|
|
15371
|
-
testCode = await run("npx", ["vitest", "--config", "vitest.screentest.config.ts", "run"]);
|
|
15372
|
-
} else {
|
|
15373
|
-
if (opts.requireDockerImage !== false && !await ensureDockerImageFresh(paths.projectRoot)) {
|
|
15374
|
-
return 1;
|
|
15375
|
-
}
|
|
15376
|
-
testCode = await runVitestInDocker(paths.projectRoot, paths.snapshotFile, paths.cacheDir);
|
|
15524
|
+
if (!await requireRunningDaemon()) {
|
|
15525
|
+
return 1;
|
|
15377
15526
|
}
|
|
15527
|
+
const env = { ...process.env, SCREENTEST_FIREFOX_WS: WS_ENDPOINT };
|
|
15528
|
+
const testCode = await run2(
|
|
15529
|
+
"npx",
|
|
15530
|
+
["vitest", "--config", "vitest.screentest.config.ts", "run"],
|
|
15531
|
+
env
|
|
15532
|
+
);
|
|
15378
15533
|
if (testCode !== 0 && !process.env.CI) {
|
|
15379
15534
|
process.stderr.write(
|
|
15380
15535
|
"\n\u2192 Tests failed \u2014 launching screentest for review.\n Close the tool (Ctrl+C) when done; the original failure will still propagate.\n\n"
|
|
15381
15536
|
);
|
|
15382
15537
|
const total = await generateDoc(
|
|
15538
|
+
paths.projectRoot,
|
|
15383
15539
|
paths.snapshotFile,
|
|
15384
15540
|
paths.actualDir,
|
|
15385
15541
|
paths.docFile,
|
|
15386
15542
|
paths.cacheDir
|
|
15387
15543
|
);
|
|
15388
15544
|
if (total > 0) {
|
|
15389
|
-
const reviewCode = await
|
|
15545
|
+
const reviewCode = await run2("node", [process.argv[1], paths.docFile]);
|
|
15390
15546
|
if (reviewCode !== 0 && reviewCode !== 130) {
|
|
15391
15547
|
process.stderr.write(`
|
|
15392
15548
|
(screentest exited with code ${reviewCode})
|
|
@@ -15402,52 +15558,32 @@ async function runOrchestrator(opts = {}) {
|
|
|
15402
15558
|
}
|
|
15403
15559
|
|
|
15404
15560
|
// src/init.ts
|
|
15405
|
-
import { copyFile, mkdir
|
|
15406
|
-
import { existsSync as
|
|
15407
|
-
import { dirname as
|
|
15408
|
-
import { fileURLToPath as
|
|
15409
|
-
async function detectPackageManager(cwd) {
|
|
15410
|
-
if (existsSync2(join5(cwd, "pnpm-lock.yaml"))) return "pnpm";
|
|
15411
|
-
if (existsSync2(join5(cwd, "yarn.lock"))) return "yarn";
|
|
15412
|
-
if (existsSync2(join5(cwd, "package-lock.json"))) return "npm";
|
|
15413
|
-
try {
|
|
15414
|
-
const pkg = JSON.parse(
|
|
15415
|
-
await readFile(join5(cwd, "package.json"), "utf8")
|
|
15416
|
-
);
|
|
15417
|
-
const pm = pkg.packageManager ?? "";
|
|
15418
|
-
if (pm.startsWith("pnpm")) return "pnpm";
|
|
15419
|
-
if (pm.startsWith("yarn")) return "yarn";
|
|
15420
|
-
} catch {
|
|
15421
|
-
}
|
|
15422
|
-
return "npm";
|
|
15423
|
-
}
|
|
15561
|
+
import { copyFile, mkdir } from "fs/promises";
|
|
15562
|
+
import { existsSync as existsSync4 } from "fs";
|
|
15563
|
+
import { dirname as dirname7, join as join4, resolve as resolve8 } from "path";
|
|
15564
|
+
import { fileURLToPath as fileURLToPath4 } from "url";
|
|
15424
15565
|
async function runInit() {
|
|
15425
|
-
const here =
|
|
15426
|
-
const templatesDir = [
|
|
15566
|
+
const here = dirname7(fileURLToPath4(import.meta.url));
|
|
15567
|
+
const templatesDir = [resolve8(here, "./templates"), resolve8(here, "../templates")].find((p) => existsSync4(p)) ?? resolve8(here, "./templates");
|
|
15427
15568
|
const cwd = process.cwd();
|
|
15428
|
-
const pm = await detectPackageManager(cwd);
|
|
15429
|
-
process.stdout.write(`Detected package manager: ${pm}
|
|
15430
|
-
|
|
15431
|
-
`);
|
|
15432
15569
|
const targets = [
|
|
15433
|
-
{ from: `Dockerfile.${pm}.tests`, to: "Dockerfile.tests" },
|
|
15434
15570
|
{ from: "vitest.screentest.config.ts", to: "vitest.screentest.config.ts" },
|
|
15435
15571
|
{ from: "example.test.ts", to: "tests/example.test.ts" }
|
|
15436
15572
|
];
|
|
15437
15573
|
for (const { from, to } of targets) {
|
|
15438
|
-
const src =
|
|
15439
|
-
const dst =
|
|
15440
|
-
if (
|
|
15574
|
+
const src = join4(templatesDir, from);
|
|
15575
|
+
const dst = join4(cwd, to);
|
|
15576
|
+
if (existsSync4(dst)) {
|
|
15441
15577
|
process.stdout.write(` exists ${to}
|
|
15442
15578
|
`);
|
|
15443
15579
|
continue;
|
|
15444
15580
|
}
|
|
15445
|
-
if (!
|
|
15581
|
+
if (!existsSync4(src)) {
|
|
15446
15582
|
process.stdout.write(` missing ${from} (package install incomplete?)
|
|
15447
15583
|
`);
|
|
15448
15584
|
continue;
|
|
15449
15585
|
}
|
|
15450
|
-
await mkdir(
|
|
15586
|
+
await mkdir(dirname7(dst), { recursive: true });
|
|
15451
15587
|
await copyFile(src, dst);
|
|
15452
15588
|
process.stdout.write(` wrote ${to}
|
|
15453
15589
|
`);
|
|
@@ -15455,8 +15591,8 @@ async function runInit() {
|
|
|
15455
15591
|
process.stdout.write(
|
|
15456
15592
|
`
|
|
15457
15593
|
Next steps:
|
|
15458
|
-
1.
|
|
15459
|
-
2. APP_URL=http://localhost:<
|
|
15594
|
+
1. screentest serve # one-time: starts the shared Firefox daemon
|
|
15595
|
+
2. APP_URL=http://localhost:<port> screentest test
|
|
15460
15596
|
`
|
|
15461
15597
|
);
|
|
15462
15598
|
return 0;
|
|
@@ -15470,14 +15606,20 @@ function printHelp() {
|
|
|
15470
15606
|
" screentest <doc-json-path> [--port 5174] [--no-open] [--worker-url URL] [--token TOKEN]",
|
|
15471
15607
|
" open the review UI on an existing doc.json",
|
|
15472
15608
|
"",
|
|
15473
|
-
" screentest test
|
|
15474
|
-
"
|
|
15475
|
-
"
|
|
15609
|
+
" screentest test run vitest on the host, connect to the shared",
|
|
15610
|
+
" Firefox daemon, on failure regenerate doc.json",
|
|
15611
|
+
" and open the review UI",
|
|
15476
15612
|
"",
|
|
15477
15613
|
" screentest review regenerate doc.json from cached actuals + open UI",
|
|
15478
15614
|
"",
|
|
15479
|
-
" screentest
|
|
15480
|
-
"
|
|
15615
|
+
" screentest serve start the shared Firefox browser-server daemon",
|
|
15616
|
+
" (builds the image on first run). One daemon per",
|
|
15617
|
+
" machine \u2014 used by every project",
|
|
15618
|
+
" screentest stop stop the daemon",
|
|
15619
|
+
" screentest status show whether the daemon is running",
|
|
15620
|
+
"",
|
|
15621
|
+
" screentest init scaffold vitest.screentest.config.ts and",
|
|
15622
|
+
" tests/example.test.ts into the current project",
|
|
15481
15623
|
"",
|
|
15482
15624
|
"Env vars (UI mode):",
|
|
15483
15625
|
" CLOUDFLARE_WORKER_URL default https://screentests.x-cevek.workers.dev",
|
|
@@ -15498,9 +15640,7 @@ async function main() {
|
|
|
15498
15640
|
}
|
|
15499
15641
|
const cmd = argv[0];
|
|
15500
15642
|
if (cmd === "test") {
|
|
15501
|
-
const
|
|
15502
|
-
const hostMode = rest.includes("--host");
|
|
15503
|
-
const code = await runOrchestrator({ hostMode });
|
|
15643
|
+
const code = await runOrchestrator();
|
|
15504
15644
|
process.exit(code);
|
|
15505
15645
|
return;
|
|
15506
15646
|
}
|
|
@@ -15509,6 +15649,18 @@ async function main() {
|
|
|
15509
15649
|
process.exit(code);
|
|
15510
15650
|
return;
|
|
15511
15651
|
}
|
|
15652
|
+
if (cmd === "serve") {
|
|
15653
|
+
process.exit(await startDaemon());
|
|
15654
|
+
return;
|
|
15655
|
+
}
|
|
15656
|
+
if (cmd === "stop") {
|
|
15657
|
+
process.exit(await stopDaemon());
|
|
15658
|
+
return;
|
|
15659
|
+
}
|
|
15660
|
+
if (cmd === "status") {
|
|
15661
|
+
process.exit(await printStatus());
|
|
15662
|
+
return;
|
|
15663
|
+
}
|
|
15512
15664
|
if (cmd === "init") {
|
|
15513
15665
|
const code = await runInit();
|
|
15514
15666
|
process.exit(code);
|