@cevek/screentest 0.3.1 → 0.3.2
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 +20 -13
- package/dist/global-setup.js +252 -8
- package/dist/index.js +287 -295
- package/dist/web/assets/{index-nQ8FCC_F.js → index-Ca30omZK.js} +1 -1
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -14956,7 +14956,37 @@ function acceptRoute(state) {
|
|
|
14956
14956
|
return r;
|
|
14957
14957
|
}
|
|
14958
14958
|
|
|
14959
|
+
// src/routes/shutdown.ts
|
|
14960
|
+
import { Router as Router4 } from "express";
|
|
14961
|
+
var EXIT_DELAY_MS = 2e3;
|
|
14962
|
+
var exitTimer = null;
|
|
14963
|
+
function scheduleExit() {
|
|
14964
|
+
if (exitTimer) clearTimeout(exitTimer);
|
|
14965
|
+
exitTimer = setTimeout(() => process.exit(0), EXIT_DELAY_MS);
|
|
14966
|
+
}
|
|
14967
|
+
function cancelPendingExit() {
|
|
14968
|
+
if (exitTimer) {
|
|
14969
|
+
clearTimeout(exitTimer);
|
|
14970
|
+
exitTimer = null;
|
|
14971
|
+
}
|
|
14972
|
+
}
|
|
14973
|
+
var cancelPendingShutdownMiddleware = (req, _res, next) => {
|
|
14974
|
+
if (req.path !== "/shutdown") cancelPendingExit();
|
|
14975
|
+
next();
|
|
14976
|
+
};
|
|
14977
|
+
function shutdownRoute() {
|
|
14978
|
+
const r = Router4();
|
|
14979
|
+
r.post("/shutdown", (_req, res) => {
|
|
14980
|
+
res.status(204).end();
|
|
14981
|
+
scheduleExit();
|
|
14982
|
+
});
|
|
14983
|
+
return r;
|
|
14984
|
+
}
|
|
14985
|
+
|
|
14959
14986
|
// src/server.ts
|
|
14987
|
+
var RANDOM_PORT_MIN = 4e4;
|
|
14988
|
+
var RANDOM_PORT_MAX = 5e4;
|
|
14989
|
+
var RANDOM_PICK_ATTEMPTS = 50;
|
|
14960
14990
|
function resolveWebDist() {
|
|
14961
14991
|
const here = dirname3(fileURLToPath2(import.meta.url));
|
|
14962
14992
|
const candidates = [
|
|
@@ -14972,12 +15002,14 @@ function resolveWebDist() {
|
|
|
14972
15002
|
}
|
|
14973
15003
|
return null;
|
|
14974
15004
|
}
|
|
14975
|
-
async function startServer(state,
|
|
15005
|
+
async function startServer(state, requestedPort) {
|
|
14976
15006
|
const app = express();
|
|
14977
15007
|
app.use(express.json({ limit: "1mb" }));
|
|
15008
|
+
app.use("/api", cancelPendingShutdownMiddleware);
|
|
14978
15009
|
app.use("/api", docRoute(state));
|
|
14979
15010
|
app.use("/api", actualRoute(state));
|
|
14980
15011
|
app.use("/api", acceptRoute(state));
|
|
15012
|
+
app.use("/api", shutdownRoute());
|
|
14981
15013
|
const webDist = resolveWebDist();
|
|
14982
15014
|
if (webDist) {
|
|
14983
15015
|
app.use(express.static(webDist));
|
|
@@ -14985,7 +15017,7 @@ async function startServer(state, startPort) {
|
|
|
14985
15017
|
res.sendFile(join(webDist, "index.html"));
|
|
14986
15018
|
});
|
|
14987
15019
|
}
|
|
14988
|
-
const port = await findFreePort(
|
|
15020
|
+
const port = requestedPort === 0 ? await pickRandomFreePort() : await findFreePort(requestedPort);
|
|
14989
15021
|
const httpServer = createServer(app);
|
|
14990
15022
|
await new Promise((resolveListen, rejectListen) => {
|
|
14991
15023
|
httpServer.once("error", rejectListen);
|
|
@@ -15000,6 +15032,16 @@ async function startServer(state, startPort) {
|
|
|
15000
15032
|
close: () => new Promise((r) => httpServer.close(() => r()))
|
|
15001
15033
|
};
|
|
15002
15034
|
}
|
|
15035
|
+
async function pickRandomFreePort() {
|
|
15036
|
+
const span = RANDOM_PORT_MAX - RANDOM_PORT_MIN;
|
|
15037
|
+
for (let attempt = 0; attempt < RANDOM_PICK_ATTEMPTS; attempt++) {
|
|
15038
|
+
const p = RANDOM_PORT_MIN + Math.floor(Math.random() * span);
|
|
15039
|
+
if (await isFree(p)) return p;
|
|
15040
|
+
}
|
|
15041
|
+
throw new Error(
|
|
15042
|
+
`No free port in [${RANDOM_PORT_MIN}, ${RANDOM_PORT_MAX}) after ${RANDOM_PICK_ATTEMPTS} attempts`
|
|
15043
|
+
);
|
|
15044
|
+
}
|
|
15003
15045
|
async function findFreePort(start) {
|
|
15004
15046
|
for (let p = start; p < start + 100; p++) {
|
|
15005
15047
|
if (await isFree(p)) return p;
|
|
@@ -15019,10 +15061,9 @@ function isFree(port) {
|
|
|
15019
15061
|
// src/ui.ts
|
|
15020
15062
|
var DEFAULT_WORKER_URL = "https://screentests.x-cevek.workers.dev";
|
|
15021
15063
|
var DEFAULT_TOKEN = "SECRET_123";
|
|
15022
|
-
var DEFAULT_PORT = 5174;
|
|
15023
15064
|
function parseUIArgs(rawArgs) {
|
|
15024
15065
|
let docPath = null;
|
|
15025
|
-
let port = Number(process.env.PORT) ||
|
|
15066
|
+
let port = Number(process.env.PORT) || 0;
|
|
15026
15067
|
let doOpen = true;
|
|
15027
15068
|
let workerUrl = process.env.CLOUDFLARE_WORKER_URL || DEFAULT_WORKER_URL;
|
|
15028
15069
|
let token = process.env.CLOUDFLARE_TOKEN || DEFAULT_TOKEN;
|
|
@@ -15122,221 +15163,16 @@ async function runUI(opts) {
|
|
|
15122
15163
|
process.on("SIGTERM", shutdown);
|
|
15123
15164
|
}
|
|
15124
15165
|
|
|
15125
|
-
// src/
|
|
15126
|
-
import { spawn
|
|
15127
|
-
import { promises as fs5 } from "fs";
|
|
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
|
-
}
|
|
15166
|
+
// src/review.ts
|
|
15167
|
+
import { spawn } from "child_process";
|
|
15332
15168
|
|
|
15333
15169
|
// src/doc-builder.ts
|
|
15334
|
-
import { spawnSync
|
|
15170
|
+
import { spawnSync } from "child_process";
|
|
15335
15171
|
import { createHash as createHash2 } from "crypto";
|
|
15336
15172
|
import { promises as fs4 } from "fs";
|
|
15337
15173
|
import { join as join2, relative, sep } from "path";
|
|
15338
15174
|
function detectBranch(projectRoot) {
|
|
15339
|
-
const r =
|
|
15175
|
+
const r = spawnSync("git", ["-C", projectRoot, "rev-parse", "--abbrev-ref", "HEAD"], {
|
|
15340
15176
|
stdio: "pipe",
|
|
15341
15177
|
encoding: "utf8"
|
|
15342
15178
|
});
|
|
@@ -15466,10 +15302,10 @@ async function generateDoc(projectRoot, snapshotFile, actualDir, docFile, cacheD
|
|
|
15466
15302
|
}
|
|
15467
15303
|
|
|
15468
15304
|
// src/runner/paths.ts
|
|
15469
|
-
import { existsSync as
|
|
15470
|
-
import { isAbsolute as isAbsolute2, join as join3, resolve as
|
|
15305
|
+
import { existsSync as existsSync2 } from "fs";
|
|
15306
|
+
import { isAbsolute as isAbsolute2, join as join3, resolve as resolve6 } from "path";
|
|
15471
15307
|
function resolveRunnerPaths(cwd = process.cwd()) {
|
|
15472
|
-
const projectRoot =
|
|
15308
|
+
const projectRoot = resolve6(cwd);
|
|
15473
15309
|
const cacheDir = join3(projectRoot, "node_modules", ".cache", "screentest");
|
|
15474
15310
|
return {
|
|
15475
15311
|
projectRoot,
|
|
@@ -15486,85 +15322,41 @@ function resolveSnapshotFile(projectRoot) {
|
|
|
15486
15322
|
}
|
|
15487
15323
|
const legacyRoot = join3(projectRoot, "snapshot.json");
|
|
15488
15324
|
const insideTests = join3(projectRoot, "tests", "snapshot.json");
|
|
15489
|
-
if (
|
|
15325
|
+
if (existsSync2(legacyRoot) && !existsSync2(insideTests)) return legacyRoot;
|
|
15490
15326
|
return insideTests;
|
|
15491
15327
|
}
|
|
15492
15328
|
|
|
15493
|
-
// src/
|
|
15494
|
-
function
|
|
15329
|
+
// src/review.ts
|
|
15330
|
+
function run(cmd, argv) {
|
|
15495
15331
|
return new Promise((resolveExit) => {
|
|
15496
|
-
const p =
|
|
15332
|
+
const p = spawn(cmd, argv, { stdio: "inherit" });
|
|
15497
15333
|
p.on("exit", (code) => resolveExit(code ?? 0));
|
|
15498
15334
|
});
|
|
15499
15335
|
}
|
|
15500
|
-
async function
|
|
15336
|
+
async function runReview() {
|
|
15501
15337
|
const paths = resolveRunnerPaths();
|
|
15502
|
-
|
|
15503
|
-
|
|
15504
|
-
|
|
15505
|
-
|
|
15506
|
-
|
|
15507
|
-
|
|
15508
|
-
paths.cacheDir
|
|
15509
|
-
);
|
|
15510
|
-
if (total === 0) {
|
|
15511
|
-
process.stdout.write("Nothing to review \u2014 all snapshots match.\n");
|
|
15512
|
-
return 0;
|
|
15513
|
-
}
|
|
15514
|
-
return await run2("node", [process.argv[1], paths.docFile]);
|
|
15515
|
-
}
|
|
15516
|
-
await fs5.mkdir(paths.cacheDir, { recursive: true });
|
|
15517
|
-
try {
|
|
15518
|
-
await fs5.access(paths.snapshotFile);
|
|
15519
|
-
} catch {
|
|
15520
|
-
await fs5.mkdir(dirname6(paths.snapshotFile), { recursive: true });
|
|
15521
|
-
await fs5.writeFile(paths.snapshotFile, '{\n "groups": []\n}\n');
|
|
15522
|
-
}
|
|
15523
|
-
await fs5.rm(paths.actualDir, { recursive: true, force: true });
|
|
15524
|
-
if (!await requireRunningDaemon()) {
|
|
15525
|
-
return 1;
|
|
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
|
|
15338
|
+
const total = await generateDoc(
|
|
15339
|
+
paths.projectRoot,
|
|
15340
|
+
paths.snapshotFile,
|
|
15341
|
+
paths.actualDir,
|
|
15342
|
+
paths.docFile,
|
|
15343
|
+
paths.cacheDir
|
|
15532
15344
|
);
|
|
15533
|
-
if (
|
|
15534
|
-
process.
|
|
15535
|
-
|
|
15536
|
-
);
|
|
15537
|
-
const total = await generateDoc(
|
|
15538
|
-
paths.projectRoot,
|
|
15539
|
-
paths.snapshotFile,
|
|
15540
|
-
paths.actualDir,
|
|
15541
|
-
paths.docFile,
|
|
15542
|
-
paths.cacheDir
|
|
15543
|
-
);
|
|
15544
|
-
if (total > 0) {
|
|
15545
|
-
const reviewCode = await run2("node", [process.argv[1], paths.docFile]);
|
|
15546
|
-
if (reviewCode !== 0 && reviewCode !== 130) {
|
|
15547
|
-
process.stderr.write(`
|
|
15548
|
-
(screentest exited with code ${reviewCode})
|
|
15549
|
-
`);
|
|
15550
|
-
}
|
|
15551
|
-
} else {
|
|
15552
|
-
process.stderr.write(
|
|
15553
|
-
"\n(no diffs to review \u2014 vitest may have failed before any screenshot)\n"
|
|
15554
|
-
);
|
|
15555
|
-
}
|
|
15345
|
+
if (total === 0) {
|
|
15346
|
+
process.stdout.write("Nothing to review \u2014 all snapshots match.\n");
|
|
15347
|
+
return 0;
|
|
15556
15348
|
}
|
|
15557
|
-
return
|
|
15349
|
+
return await run("node", [process.argv[1], paths.docFile]);
|
|
15558
15350
|
}
|
|
15559
15351
|
|
|
15560
15352
|
// src/init.ts
|
|
15561
15353
|
import { copyFile, mkdir } from "fs/promises";
|
|
15562
|
-
import { existsSync as
|
|
15563
|
-
import { dirname as
|
|
15564
|
-
import { fileURLToPath as
|
|
15354
|
+
import { existsSync as existsSync3 } from "fs";
|
|
15355
|
+
import { dirname as dirname5, join as join4, resolve as resolve7 } from "path";
|
|
15356
|
+
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
15565
15357
|
async function runInit() {
|
|
15566
|
-
const here =
|
|
15567
|
-
const templatesDir = [
|
|
15358
|
+
const here = dirname5(fileURLToPath3(import.meta.url));
|
|
15359
|
+
const templatesDir = [resolve7(here, "./templates"), resolve7(here, "../templates")].find((p) => existsSync3(p)) ?? resolve7(here, "./templates");
|
|
15568
15360
|
const cwd = process.cwd();
|
|
15569
15361
|
const targets = [
|
|
15570
15362
|
{ from: "vitest.screentest.config.ts", to: "vitest.screentest.config.ts" },
|
|
@@ -15573,17 +15365,17 @@ async function runInit() {
|
|
|
15573
15365
|
for (const { from, to } of targets) {
|
|
15574
15366
|
const src = join4(templatesDir, from);
|
|
15575
15367
|
const dst = join4(cwd, to);
|
|
15576
|
-
if (
|
|
15368
|
+
if (existsSync3(dst)) {
|
|
15577
15369
|
process.stdout.write(` exists ${to}
|
|
15578
15370
|
`);
|
|
15579
15371
|
continue;
|
|
15580
15372
|
}
|
|
15581
|
-
if (!
|
|
15373
|
+
if (!existsSync3(src)) {
|
|
15582
15374
|
process.stdout.write(` missing ${from} (package install incomplete?)
|
|
15583
15375
|
`);
|
|
15584
15376
|
continue;
|
|
15585
15377
|
}
|
|
15586
|
-
await mkdir(
|
|
15378
|
+
await mkdir(dirname5(dst), { recursive: true });
|
|
15587
15379
|
await copyFile(src, dst);
|
|
15588
15380
|
process.stdout.write(` wrote ${to}
|
|
15589
15381
|
`);
|
|
@@ -15592,7 +15384,190 @@ async function runInit() {
|
|
|
15592
15384
|
`
|
|
15593
15385
|
Next steps:
|
|
15594
15386
|
1. screentest serve # one-time: starts the shared Firefox daemon
|
|
15595
|
-
2. APP_URL=http://localhost:<port>
|
|
15387
|
+
2. APP_URL=http://localhost:<port> \\
|
|
15388
|
+
npx vitest run --config vitest.screentest.config.ts
|
|
15389
|
+
|
|
15390
|
+
Tip: add a script to package.json so you can just \`npm run screentest\`:
|
|
15391
|
+
"screentest": "vitest run --config vitest.screentest.config.ts"
|
|
15392
|
+
`
|
|
15393
|
+
);
|
|
15394
|
+
return 0;
|
|
15395
|
+
}
|
|
15396
|
+
|
|
15397
|
+
// src/daemon.ts
|
|
15398
|
+
import { spawn as spawn2, spawnSync as spawnSync2 } from "child_process";
|
|
15399
|
+
import { existsSync as existsSync4 } from "fs";
|
|
15400
|
+
import { dirname as dirname6, resolve as resolve8 } from "path";
|
|
15401
|
+
import { fileURLToPath as fileURLToPath4 } from "url";
|
|
15402
|
+
import { createConnection } from "net";
|
|
15403
|
+
var IMAGE_NAME = "cevek/screentest-firefox";
|
|
15404
|
+
var IMAGE_TAG = "pw-1.60.0";
|
|
15405
|
+
var FULL_IMAGE = `${IMAGE_NAME}:${IMAGE_TAG}`;
|
|
15406
|
+
var CONTAINER_NAME = "screentest-firefox";
|
|
15407
|
+
var PORT = 5180;
|
|
15408
|
+
var WS_PATH = "screentest-firefox";
|
|
15409
|
+
var WS_ENDPOINT = `ws://localhost:${PORT}/${WS_PATH}`;
|
|
15410
|
+
function run2(cmd, argv) {
|
|
15411
|
+
return new Promise((resolveExit) => {
|
|
15412
|
+
const p = spawn2(cmd, argv, { stdio: "inherit" });
|
|
15413
|
+
p.on("exit", (code) => resolveExit(code ?? 0));
|
|
15414
|
+
});
|
|
15415
|
+
}
|
|
15416
|
+
function daemonState() {
|
|
15417
|
+
const ps = spawnSync2(
|
|
15418
|
+
"docker",
|
|
15419
|
+
["ps", "-a", "-f", `name=^${CONTAINER_NAME}$`, "--format", "{{.Status}} {{.Image}}"],
|
|
15420
|
+
{ stdio: "pipe", encoding: "utf8" }
|
|
15421
|
+
);
|
|
15422
|
+
const line = ps.stdout.trim();
|
|
15423
|
+
if (line.startsWith("Up")) {
|
|
15424
|
+
const tab = line.indexOf(" ");
|
|
15425
|
+
const image = tab === -1 ? "" : line.slice(tab + 1).trim();
|
|
15426
|
+
return { state: "running", image };
|
|
15427
|
+
}
|
|
15428
|
+
const img = spawnSync2("docker", ["image", "inspect", FULL_IMAGE], { stdio: "pipe" });
|
|
15429
|
+
if (img.status !== 0) return { state: "missing-image" };
|
|
15430
|
+
return { state: "stopped" };
|
|
15431
|
+
}
|
|
15432
|
+
function resolveTemplatesDir() {
|
|
15433
|
+
const here = dirname6(fileURLToPath4(import.meta.url));
|
|
15434
|
+
const candidates = [
|
|
15435
|
+
resolve8(here, "./templates"),
|
|
15436
|
+
resolve8(here, "../templates"),
|
|
15437
|
+
resolve8(here, "../../server/templates")
|
|
15438
|
+
];
|
|
15439
|
+
for (const c of candidates) {
|
|
15440
|
+
if (existsSync4(c)) return c;
|
|
15441
|
+
}
|
|
15442
|
+
throw new Error("templates/ directory not found relative to daemon.ts");
|
|
15443
|
+
}
|
|
15444
|
+
async function buildImage() {
|
|
15445
|
+
const templates = resolveTemplatesDir();
|
|
15446
|
+
process.stderr.write(`Building Docker image ${FULL_IMAGE} (~2 min first time, cached after).
|
|
15447
|
+
`);
|
|
15448
|
+
const code = await run2("docker", [
|
|
15449
|
+
"build",
|
|
15450
|
+
"-f",
|
|
15451
|
+
`${templates}/Dockerfile.firefox-server`,
|
|
15452
|
+
"-t",
|
|
15453
|
+
FULL_IMAGE,
|
|
15454
|
+
templates
|
|
15455
|
+
]);
|
|
15456
|
+
if (code !== 0) {
|
|
15457
|
+
process.stderr.write(`docker build failed (exit ${code})
|
|
15458
|
+
`);
|
|
15459
|
+
return false;
|
|
15460
|
+
}
|
|
15461
|
+
return true;
|
|
15462
|
+
}
|
|
15463
|
+
function probePort() {
|
|
15464
|
+
return new Promise((resolveProbe) => {
|
|
15465
|
+
const s = createConnection({ host: "127.0.0.1", port: PORT });
|
|
15466
|
+
s.once("connect", () => {
|
|
15467
|
+
s.end();
|
|
15468
|
+
resolveProbe(true);
|
|
15469
|
+
});
|
|
15470
|
+
s.once("error", () => resolveProbe(false));
|
|
15471
|
+
});
|
|
15472
|
+
}
|
|
15473
|
+
async function waitForReady(timeoutMs = 1e4) {
|
|
15474
|
+
const start = Date.now();
|
|
15475
|
+
while (Date.now() - start < timeoutMs) {
|
|
15476
|
+
if (await probePort()) return true;
|
|
15477
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
15478
|
+
}
|
|
15479
|
+
return false;
|
|
15480
|
+
}
|
|
15481
|
+
async function startDaemon() {
|
|
15482
|
+
const s = daemonState();
|
|
15483
|
+
if (s.state === "running") {
|
|
15484
|
+
if (s.image !== FULL_IMAGE) {
|
|
15485
|
+
process.stderr.write(
|
|
15486
|
+
`screentest-firefox daemon is running, but on a different image:
|
|
15487
|
+
running: ${s.image}
|
|
15488
|
+
this @cevek/screentest wants: ${FULL_IMAGE}
|
|
15489
|
+
\u2192 stop it first: screentest stop
|
|
15490
|
+
\u2192 then re-run: screentest serve
|
|
15491
|
+
`
|
|
15492
|
+
);
|
|
15493
|
+
return 1;
|
|
15494
|
+
}
|
|
15495
|
+
process.stdout.write(`screentest-firefox daemon already running at ${WS_ENDPOINT}
|
|
15496
|
+
`);
|
|
15497
|
+
return 0;
|
|
15498
|
+
}
|
|
15499
|
+
if (s.state === "missing-image") {
|
|
15500
|
+
if (!await buildImage()) return 1;
|
|
15501
|
+
}
|
|
15502
|
+
spawnSync2("docker", ["rm", "-f", CONTAINER_NAME], { stdio: "pipe" });
|
|
15503
|
+
process.stderr.write(`Starting screentest-firefox daemon (port ${PORT})\u2026
|
|
15504
|
+
`);
|
|
15505
|
+
const code = await run2("docker", [
|
|
15506
|
+
"run",
|
|
15507
|
+
"-d",
|
|
15508
|
+
"--rm",
|
|
15509
|
+
"--name",
|
|
15510
|
+
CONTAINER_NAME,
|
|
15511
|
+
"--network=host",
|
|
15512
|
+
FULL_IMAGE
|
|
15513
|
+
]);
|
|
15514
|
+
if (code !== 0) {
|
|
15515
|
+
process.stderr.write(`docker run failed (exit ${code})
|
|
15516
|
+
`);
|
|
15517
|
+
return code;
|
|
15518
|
+
}
|
|
15519
|
+
if (!await waitForReady()) {
|
|
15520
|
+
process.stderr.write(`Daemon container started but port ${PORT} never opened. Logs:
|
|
15521
|
+
`);
|
|
15522
|
+
spawnSync2("docker", ["logs", CONTAINER_NAME], { stdio: "inherit" });
|
|
15523
|
+
return 1;
|
|
15524
|
+
}
|
|
15525
|
+
process.stdout.write(`screentest-firefox daemon ready at ${WS_ENDPOINT}
|
|
15526
|
+
`);
|
|
15527
|
+
return 0;
|
|
15528
|
+
}
|
|
15529
|
+
async function stopDaemon() {
|
|
15530
|
+
const s = daemonState();
|
|
15531
|
+
if (s.state !== "running") {
|
|
15532
|
+
process.stdout.write("screentest-firefox daemon is not running\n");
|
|
15533
|
+
return 0;
|
|
15534
|
+
}
|
|
15535
|
+
const code = await run2("docker", ["stop", CONTAINER_NAME]);
|
|
15536
|
+
if (code === 0) process.stdout.write("screentest-firefox daemon stopped\n");
|
|
15537
|
+
return code;
|
|
15538
|
+
}
|
|
15539
|
+
async function printStatus() {
|
|
15540
|
+
const s = daemonState();
|
|
15541
|
+
if (s.state === "running") {
|
|
15542
|
+
const match = s.image === FULL_IMAGE ? "(matches this @cevek/screentest)" : "(MISMATCH!)";
|
|
15543
|
+
process.stdout.write(
|
|
15544
|
+
`screentest-firefox daemon: running
|
|
15545
|
+
ws endpoint: ${WS_ENDPOINT}
|
|
15546
|
+
image: ${s.image} ${match}
|
|
15547
|
+
expected: ${FULL_IMAGE}
|
|
15548
|
+
`
|
|
15549
|
+
);
|
|
15550
|
+
if (s.image !== FULL_IMAGE) {
|
|
15551
|
+
process.stdout.write(
|
|
15552
|
+
` \u2192 daemon is running an image pinned by a different version of this
|
|
15553
|
+
package. Stop it (screentest stop) and re-serve from the project
|
|
15554
|
+
whose Playwright version you want to use.
|
|
15555
|
+
`
|
|
15556
|
+
);
|
|
15557
|
+
}
|
|
15558
|
+
return 0;
|
|
15559
|
+
}
|
|
15560
|
+
if (s.state === "stopped") {
|
|
15561
|
+
process.stdout.write(
|
|
15562
|
+
`screentest-firefox daemon: stopped (image ${FULL_IMAGE} exists)
|
|
15563
|
+
start it with: screentest serve
|
|
15564
|
+
`
|
|
15565
|
+
);
|
|
15566
|
+
return 0;
|
|
15567
|
+
}
|
|
15568
|
+
process.stdout.write(
|
|
15569
|
+
`screentest-firefox daemon: not installed (image ${FULL_IMAGE} missing)
|
|
15570
|
+
build + start with: screentest serve
|
|
15596
15571
|
`
|
|
15597
15572
|
);
|
|
15598
15573
|
return 0;
|
|
@@ -15603,14 +15578,9 @@ function printHelp() {
|
|
|
15603
15578
|
process.stderr.write(
|
|
15604
15579
|
[
|
|
15605
15580
|
"Usage:",
|
|
15606
|
-
" screentest <doc-json-path> [--port
|
|
15581
|
+
" screentest <doc-json-path> [--port N] [--no-open] [--worker-url URL] [--token TOKEN]",
|
|
15607
15582
|
" open the review UI on an existing doc.json",
|
|
15608
|
-
"",
|
|
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",
|
|
15612
|
-
"",
|
|
15613
|
-
" screentest review regenerate doc.json from cached actuals + open UI",
|
|
15583
|
+
" (this is what `vitest run` auto-launches on failure)",
|
|
15614
15584
|
"",
|
|
15615
15585
|
" screentest serve start the shared Firefox browser-server daemon",
|
|
15616
15586
|
" (builds the image on first run). One daemon per",
|
|
@@ -15618,16 +15588,29 @@ function printHelp() {
|
|
|
15618
15588
|
" screentest stop stop the daemon",
|
|
15619
15589
|
" screentest status show whether the daemon is running",
|
|
15620
15590
|
"",
|
|
15591
|
+
" screentest review regenerate doc.json from cached actuals + open UI",
|
|
15592
|
+
" (useful when you closed the UI without accepting)",
|
|
15593
|
+
"",
|
|
15621
15594
|
" screentest init scaffold vitest.screentest.config.ts and",
|
|
15622
15595
|
" tests/example.test.ts into the current project",
|
|
15623
15596
|
"",
|
|
15597
|
+
"Running screenshot tests:",
|
|
15598
|
+
" Add to your package.json scripts (or run directly):",
|
|
15599
|
+
' "screentest": "vitest run --config vitest.screentest.config.ts"',
|
|
15600
|
+
" The vitest globalSetup we ship handles pre-flight (wipe actualDir, locate",
|
|
15601
|
+
" the daemon) and post-flight (build doc.json, auto-launch the review UI",
|
|
15602
|
+
" if there are diffs).",
|
|
15603
|
+
"",
|
|
15624
15604
|
"Env vars (UI mode):",
|
|
15625
15605
|
" CLOUDFLARE_WORKER_URL default https://screentests.x-cevek.workers.dev",
|
|
15626
15606
|
" CLOUDFLARE_TOKEN default SECRET_123",
|
|
15627
15607
|
"",
|
|
15628
|
-
"Env vars (test
|
|
15608
|
+
"Env vars (test runs):",
|
|
15629
15609
|
" APP_URL default http://localhost:5050",
|
|
15630
15610
|
" CI if set, do not auto-open the review UI on failure",
|
|
15611
|
+
" SCREENTEST_NO_UI if set, never auto-open the review UI",
|
|
15612
|
+
" SCREENTEST_FIREFOX_WS override the daemon WS endpoint",
|
|
15613
|
+
" SCREENTEST_SNAPSHOT_FILE override snapshot.json path (default tests/snapshot.json)",
|
|
15631
15614
|
""
|
|
15632
15615
|
].join("\n")
|
|
15633
15616
|
);
|
|
@@ -15640,12 +15623,21 @@ async function main() {
|
|
|
15640
15623
|
}
|
|
15641
15624
|
const cmd = argv[0];
|
|
15642
15625
|
if (cmd === "test") {
|
|
15643
|
-
|
|
15644
|
-
|
|
15626
|
+
process.stderr.write(
|
|
15627
|
+
`\`screentest test\` was removed in 0.3.2 \u2014 the globalSetup hook now does
|
|
15628
|
+
everything it used to do. Run vitest directly instead:
|
|
15629
|
+
|
|
15630
|
+
npx vitest run --config vitest.screentest.config.ts
|
|
15631
|
+
|
|
15632
|
+
Or add it as a script in package.json:
|
|
15633
|
+
"screentest": "vitest run --config vitest.screentest.config.ts"
|
|
15634
|
+
`
|
|
15635
|
+
);
|
|
15636
|
+
process.exit(2);
|
|
15645
15637
|
return;
|
|
15646
15638
|
}
|
|
15647
15639
|
if (cmd === "review") {
|
|
15648
|
-
const code = await
|
|
15640
|
+
const code = await runReview();
|
|
15649
15641
|
process.exit(code);
|
|
15650
15642
|
return;
|
|
15651
15643
|
}
|