@cevek/screentest 0.3.1 → 0.3.3

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/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, startPort) {
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(startPort);
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) || DEFAULT_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/orchestrator.ts
15126
- import { spawn as spawn2 } from "child_process";
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 as spawnSync2 } from "child_process";
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 = spawnSync2("git", ["-C", projectRoot, "rev-parse", "--abbrev-ref", "HEAD"], {
15175
+ const r = spawnSync("git", ["-C", projectRoot, "rev-parse", "--abbrev-ref", "HEAD"], {
15340
15176
  stdio: "pipe",
15341
15177
  encoding: "utf8"
15342
15178
  });
@@ -15407,7 +15243,8 @@ function countLeaves(doc) {
15407
15243
  for (const g of doc.groups) walk(g.items);
15408
15244
  return n;
15409
15245
  }
15410
- async function generateDoc(projectRoot, snapshotFile, actualDir, docFile, cacheDir) {
15246
+ async function generateDoc(projectRoot, snapshotFile, actualDir, docFile, cacheDir, opts = {}) {
15247
+ const detectDeletions = opts.detectDeletions ?? true;
15411
15248
  const snap = await readSnapshot(snapshotFile);
15412
15249
  const expectedByPath = indexSnapshot(snap);
15413
15250
  const actuals = await walkActual(actualDir);
@@ -15445,31 +15282,34 @@ async function generateDoc(projectRoot, snapshotFile, actualDir, docFile, cacheD
15445
15282
  });
15446
15283
  summary.change++;
15447
15284
  }
15448
- for (const [key, hash2] of expectedByPath) {
15449
- if (seen.has(key) || !hash2) continue;
15450
- insertLeaf(doc, key.split("/"), {
15451
- type: "deleted",
15452
- expectedHash: hash2,
15453
- patchSnapshotJsonFile: snapshotFile
15454
- });
15455
- summary.deleted++;
15285
+ if (detectDeletions) {
15286
+ for (const [key, hash2] of expectedByPath) {
15287
+ if (seen.has(key) || !hash2) continue;
15288
+ insertLeaf(doc, key.split("/"), {
15289
+ type: "deleted",
15290
+ expectedHash: hash2,
15291
+ patchSnapshotJsonFile: snapshotFile
15292
+ });
15293
+ summary.deleted++;
15294
+ }
15456
15295
  }
15457
15296
  await fs4.mkdir(cacheDir, { recursive: true });
15458
15297
  await fs4.writeFile(docFile, JSON.stringify(doc, null, 2));
15459
15298
  const total = countLeaves(doc);
15299
+ const deletedSummary = detectDeletions ? `deleted: ${summary.deleted}, ` : "";
15460
15300
  process.stdout.write(
15461
15301
  `Wrote ${docFile}
15462
- ${total} diff${total === 1 ? "" : "s"} to review (new: ${summary.new}, change: ${summary.change}, deleted: ${summary.deleted}, unchanged: ${summary.unchanged})
15302
+ ${total} diff${total === 1 ? "" : "s"} to review (new: ${summary.new}, change: ${summary.change}, ${deletedSummary}unchanged: ${summary.unchanged}${detectDeletions ? "" : "; deletion detection disabled \u2014 subset run"})
15463
15303
  `
15464
15304
  );
15465
15305
  return total;
15466
15306
  }
15467
15307
 
15468
15308
  // src/runner/paths.ts
15469
- import { existsSync as existsSync3 } from "fs";
15470
- import { isAbsolute as isAbsolute2, join as join3, resolve as resolve7 } from "path";
15309
+ import { existsSync as existsSync2 } from "fs";
15310
+ import { isAbsolute as isAbsolute2, join as join3, resolve as resolve6 } from "path";
15471
15311
  function resolveRunnerPaths(cwd = process.cwd()) {
15472
- const projectRoot = resolve7(cwd);
15312
+ const projectRoot = resolve6(cwd);
15473
15313
  const cacheDir = join3(projectRoot, "node_modules", ".cache", "screentest");
15474
15314
  return {
15475
15315
  projectRoot,
@@ -15486,85 +15326,41 @@ function resolveSnapshotFile(projectRoot) {
15486
15326
  }
15487
15327
  const legacyRoot = join3(projectRoot, "snapshot.json");
15488
15328
  const insideTests = join3(projectRoot, "tests", "snapshot.json");
15489
- if (existsSync3(legacyRoot) && !existsSync3(insideTests)) return legacyRoot;
15329
+ if (existsSync2(legacyRoot) && !existsSync2(insideTests)) return legacyRoot;
15490
15330
  return insideTests;
15491
15331
  }
15492
15332
 
15493
- // src/orchestrator.ts
15494
- function run2(cmd, argv, env) {
15333
+ // src/review.ts
15334
+ function run(cmd, argv) {
15495
15335
  return new Promise((resolveExit) => {
15496
- const p = spawn2(cmd, argv, { stdio: "inherit", env: env ?? process.env });
15336
+ const p = spawn(cmd, argv, { stdio: "inherit" });
15497
15337
  p.on("exit", (code) => resolveExit(code ?? 0));
15498
15338
  });
15499
15339
  }
15500
- async function runOrchestrator(opts = {}) {
15340
+ async function runReview() {
15501
15341
  const paths = resolveRunnerPaths();
15502
- if (opts.reviewOnly) {
15503
- const total = await generateDoc(
15504
- paths.projectRoot,
15505
- paths.snapshotFile,
15506
- paths.actualDir,
15507
- paths.docFile,
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
15342
+ const total = await generateDoc(
15343
+ paths.projectRoot,
15344
+ paths.snapshotFile,
15345
+ paths.actualDir,
15346
+ paths.docFile,
15347
+ paths.cacheDir
15532
15348
  );
15533
- if (testCode !== 0 && !process.env.CI) {
15534
- process.stderr.write(
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"
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
- }
15349
+ if (total === 0) {
15350
+ process.stdout.write("Nothing to review \u2014 all snapshots match.\n");
15351
+ return 0;
15556
15352
  }
15557
- return testCode;
15353
+ return await run("node", [process.argv[1], paths.docFile]);
15558
15354
  }
15559
15355
 
15560
15356
  // src/init.ts
15561
15357
  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";
15358
+ import { existsSync as existsSync3 } from "fs";
15359
+ import { dirname as dirname5, join as join4, resolve as resolve7 } from "path";
15360
+ import { fileURLToPath as fileURLToPath3 } from "url";
15565
15361
  async function runInit() {
15566
- const here = dirname7(fileURLToPath4(import.meta.url));
15567
- const templatesDir = [resolve8(here, "./templates"), resolve8(here, "../templates")].find((p) => existsSync4(p)) ?? resolve8(here, "./templates");
15362
+ const here = dirname5(fileURLToPath3(import.meta.url));
15363
+ const templatesDir = [resolve7(here, "./templates"), resolve7(here, "../templates")].find((p) => existsSync3(p)) ?? resolve7(here, "./templates");
15568
15364
  const cwd = process.cwd();
15569
15365
  const targets = [
15570
15366
  { from: "vitest.screentest.config.ts", to: "vitest.screentest.config.ts" },
@@ -15573,17 +15369,17 @@ async function runInit() {
15573
15369
  for (const { from, to } of targets) {
15574
15370
  const src = join4(templatesDir, from);
15575
15371
  const dst = join4(cwd, to);
15576
- if (existsSync4(dst)) {
15372
+ if (existsSync3(dst)) {
15577
15373
  process.stdout.write(` exists ${to}
15578
15374
  `);
15579
15375
  continue;
15580
15376
  }
15581
- if (!existsSync4(src)) {
15377
+ if (!existsSync3(src)) {
15582
15378
  process.stdout.write(` missing ${from} (package install incomplete?)
15583
15379
  `);
15584
15380
  continue;
15585
15381
  }
15586
- await mkdir(dirname7(dst), { recursive: true });
15382
+ await mkdir(dirname5(dst), { recursive: true });
15587
15383
  await copyFile(src, dst);
15588
15384
  process.stdout.write(` wrote ${to}
15589
15385
  `);
@@ -15592,7 +15388,190 @@ async function runInit() {
15592
15388
  `
15593
15389
  Next steps:
15594
15390
  1. screentest serve # one-time: starts the shared Firefox daemon
15595
- 2. APP_URL=http://localhost:<port> screentest test
15391
+ 2. APP_URL=http://localhost:<port> \\
15392
+ npx vitest run --config vitest.screentest.config.ts
15393
+
15394
+ Tip: add a script to package.json so you can just \`npm run screentest\`:
15395
+ "screentest": "vitest run --config vitest.screentest.config.ts"
15396
+ `
15397
+ );
15398
+ return 0;
15399
+ }
15400
+
15401
+ // src/daemon.ts
15402
+ import { spawn as spawn2, spawnSync as spawnSync2 } from "child_process";
15403
+ import { existsSync as existsSync4 } from "fs";
15404
+ import { dirname as dirname6, resolve as resolve8 } from "path";
15405
+ import { fileURLToPath as fileURLToPath4 } from "url";
15406
+ import { createConnection } from "net";
15407
+ var IMAGE_NAME = "cevek/screentest-firefox";
15408
+ var IMAGE_TAG = "pw-1.60.0";
15409
+ var FULL_IMAGE = `${IMAGE_NAME}:${IMAGE_TAG}`;
15410
+ var CONTAINER_NAME = "screentest-firefox";
15411
+ var PORT = 5180;
15412
+ var WS_PATH = "screentest-firefox";
15413
+ var WS_ENDPOINT = `ws://localhost:${PORT}/${WS_PATH}`;
15414
+ function run2(cmd, argv) {
15415
+ return new Promise((resolveExit) => {
15416
+ const p = spawn2(cmd, argv, { stdio: "inherit" });
15417
+ p.on("exit", (code) => resolveExit(code ?? 0));
15418
+ });
15419
+ }
15420
+ function daemonState() {
15421
+ const ps = spawnSync2(
15422
+ "docker",
15423
+ ["ps", "-a", "-f", `name=^${CONTAINER_NAME}$`, "--format", "{{.Status}} {{.Image}}"],
15424
+ { stdio: "pipe", encoding: "utf8" }
15425
+ );
15426
+ const line = ps.stdout.trim();
15427
+ if (line.startsWith("Up")) {
15428
+ const tab = line.indexOf(" ");
15429
+ const image = tab === -1 ? "" : line.slice(tab + 1).trim();
15430
+ return { state: "running", image };
15431
+ }
15432
+ const img = spawnSync2("docker", ["image", "inspect", FULL_IMAGE], { stdio: "pipe" });
15433
+ if (img.status !== 0) return { state: "missing-image" };
15434
+ return { state: "stopped" };
15435
+ }
15436
+ function resolveTemplatesDir() {
15437
+ const here = dirname6(fileURLToPath4(import.meta.url));
15438
+ const candidates = [
15439
+ resolve8(here, "./templates"),
15440
+ resolve8(here, "../templates"),
15441
+ resolve8(here, "../../server/templates")
15442
+ ];
15443
+ for (const c of candidates) {
15444
+ if (existsSync4(c)) return c;
15445
+ }
15446
+ throw new Error("templates/ directory not found relative to daemon.ts");
15447
+ }
15448
+ async function buildImage() {
15449
+ const templates = resolveTemplatesDir();
15450
+ process.stderr.write(`Building Docker image ${FULL_IMAGE} (~2 min first time, cached after).
15451
+ `);
15452
+ const code = await run2("docker", [
15453
+ "build",
15454
+ "-f",
15455
+ `${templates}/Dockerfile.firefox-server`,
15456
+ "-t",
15457
+ FULL_IMAGE,
15458
+ templates
15459
+ ]);
15460
+ if (code !== 0) {
15461
+ process.stderr.write(`docker build failed (exit ${code})
15462
+ `);
15463
+ return false;
15464
+ }
15465
+ return true;
15466
+ }
15467
+ function probePort() {
15468
+ return new Promise((resolveProbe) => {
15469
+ const s = createConnection({ host: "127.0.0.1", port: PORT });
15470
+ s.once("connect", () => {
15471
+ s.end();
15472
+ resolveProbe(true);
15473
+ });
15474
+ s.once("error", () => resolveProbe(false));
15475
+ });
15476
+ }
15477
+ async function waitForReady(timeoutMs = 1e4) {
15478
+ const start = Date.now();
15479
+ while (Date.now() - start < timeoutMs) {
15480
+ if (await probePort()) return true;
15481
+ await new Promise((r) => setTimeout(r, 150));
15482
+ }
15483
+ return false;
15484
+ }
15485
+ async function startDaemon() {
15486
+ const s = daemonState();
15487
+ if (s.state === "running") {
15488
+ if (s.image !== FULL_IMAGE) {
15489
+ process.stderr.write(
15490
+ `screentest-firefox daemon is running, but on a different image:
15491
+ running: ${s.image}
15492
+ this @cevek/screentest wants: ${FULL_IMAGE}
15493
+ \u2192 stop it first: screentest stop
15494
+ \u2192 then re-run: screentest serve
15495
+ `
15496
+ );
15497
+ return 1;
15498
+ }
15499
+ process.stdout.write(`screentest-firefox daemon already running at ${WS_ENDPOINT}
15500
+ `);
15501
+ return 0;
15502
+ }
15503
+ if (s.state === "missing-image") {
15504
+ if (!await buildImage()) return 1;
15505
+ }
15506
+ spawnSync2("docker", ["rm", "-f", CONTAINER_NAME], { stdio: "pipe" });
15507
+ process.stderr.write(`Starting screentest-firefox daemon (port ${PORT})\u2026
15508
+ `);
15509
+ const code = await run2("docker", [
15510
+ "run",
15511
+ "-d",
15512
+ "--rm",
15513
+ "--name",
15514
+ CONTAINER_NAME,
15515
+ "--network=host",
15516
+ FULL_IMAGE
15517
+ ]);
15518
+ if (code !== 0) {
15519
+ process.stderr.write(`docker run failed (exit ${code})
15520
+ `);
15521
+ return code;
15522
+ }
15523
+ if (!await waitForReady()) {
15524
+ process.stderr.write(`Daemon container started but port ${PORT} never opened. Logs:
15525
+ `);
15526
+ spawnSync2("docker", ["logs", CONTAINER_NAME], { stdio: "inherit" });
15527
+ return 1;
15528
+ }
15529
+ process.stdout.write(`screentest-firefox daemon ready at ${WS_ENDPOINT}
15530
+ `);
15531
+ return 0;
15532
+ }
15533
+ async function stopDaemon() {
15534
+ const s = daemonState();
15535
+ if (s.state !== "running") {
15536
+ process.stdout.write("screentest-firefox daemon is not running\n");
15537
+ return 0;
15538
+ }
15539
+ const code = await run2("docker", ["stop", CONTAINER_NAME]);
15540
+ if (code === 0) process.stdout.write("screentest-firefox daemon stopped\n");
15541
+ return code;
15542
+ }
15543
+ async function printStatus() {
15544
+ const s = daemonState();
15545
+ if (s.state === "running") {
15546
+ const match = s.image === FULL_IMAGE ? "(matches this @cevek/screentest)" : "(MISMATCH!)";
15547
+ process.stdout.write(
15548
+ `screentest-firefox daemon: running
15549
+ ws endpoint: ${WS_ENDPOINT}
15550
+ image: ${s.image} ${match}
15551
+ expected: ${FULL_IMAGE}
15552
+ `
15553
+ );
15554
+ if (s.image !== FULL_IMAGE) {
15555
+ process.stdout.write(
15556
+ ` \u2192 daemon is running an image pinned by a different version of this
15557
+ package. Stop it (screentest stop) and re-serve from the project
15558
+ whose Playwright version you want to use.
15559
+ `
15560
+ );
15561
+ }
15562
+ return 0;
15563
+ }
15564
+ if (s.state === "stopped") {
15565
+ process.stdout.write(
15566
+ `screentest-firefox daemon: stopped (image ${FULL_IMAGE} exists)
15567
+ start it with: screentest serve
15568
+ `
15569
+ );
15570
+ return 0;
15571
+ }
15572
+ process.stdout.write(
15573
+ `screentest-firefox daemon: not installed (image ${FULL_IMAGE} missing)
15574
+ build + start with: screentest serve
15596
15575
  `
15597
15576
  );
15598
15577
  return 0;
@@ -15603,14 +15582,9 @@ function printHelp() {
15603
15582
  process.stderr.write(
15604
15583
  [
15605
15584
  "Usage:",
15606
- " screentest <doc-json-path> [--port 5174] [--no-open] [--worker-url URL] [--token TOKEN]",
15585
+ " screentest <doc-json-path> [--port N] [--no-open] [--worker-url URL] [--token TOKEN]",
15607
15586
  " 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",
15587
+ " (this is what `vitest run` auto-launches on failure)",
15614
15588
  "",
15615
15589
  " screentest serve start the shared Firefox browser-server daemon",
15616
15590
  " (builds the image on first run). One daemon per",
@@ -15618,16 +15592,29 @@ function printHelp() {
15618
15592
  " screentest stop stop the daemon",
15619
15593
  " screentest status show whether the daemon is running",
15620
15594
  "",
15595
+ " screentest review regenerate doc.json from cached actuals + open UI",
15596
+ " (useful when you closed the UI without accepting)",
15597
+ "",
15621
15598
  " screentest init scaffold vitest.screentest.config.ts and",
15622
15599
  " tests/example.test.ts into the current project",
15623
15600
  "",
15601
+ "Running screenshot tests:",
15602
+ " Add to your package.json scripts (or run directly):",
15603
+ ' "screentest": "vitest run --config vitest.screentest.config.ts"',
15604
+ " The vitest globalSetup we ship handles pre-flight (wipe actualDir, locate",
15605
+ " the daemon) and post-flight (build doc.json, auto-launch the review UI",
15606
+ " if there are diffs).",
15607
+ "",
15624
15608
  "Env vars (UI mode):",
15625
15609
  " CLOUDFLARE_WORKER_URL default https://screentests.x-cevek.workers.dev",
15626
15610
  " CLOUDFLARE_TOKEN default SECRET_123",
15627
15611
  "",
15628
- "Env vars (test mode):",
15612
+ "Env vars (test runs):",
15629
15613
  " APP_URL default http://localhost:5050",
15630
15614
  " CI if set, do not auto-open the review UI on failure",
15615
+ " SCREENTEST_NO_UI if set, never auto-open the review UI",
15616
+ " SCREENTEST_FIREFOX_WS override the daemon WS endpoint",
15617
+ " SCREENTEST_SNAPSHOT_FILE override snapshot.json path (default tests/snapshot.json)",
15631
15618
  ""
15632
15619
  ].join("\n")
15633
15620
  );
@@ -15640,12 +15627,21 @@ async function main() {
15640
15627
  }
15641
15628
  const cmd = argv[0];
15642
15629
  if (cmd === "test") {
15643
- const code = await runOrchestrator();
15644
- process.exit(code);
15630
+ process.stderr.write(
15631
+ `\`screentest test\` was removed in 0.3.2 \u2014 the globalSetup hook now does
15632
+ everything it used to do. Run vitest directly instead:
15633
+
15634
+ npx vitest run --config vitest.screentest.config.ts
15635
+
15636
+ Or add it as a script in package.json:
15637
+ "screentest": "vitest run --config vitest.screentest.config.ts"
15638
+ `
15639
+ );
15640
+ process.exit(2);
15645
15641
  return;
15646
15642
  }
15647
15643
  if (cmd === "review") {
15648
- const code = await runOrchestrator({ reviewOnly: true });
15644
+ const code = await runReview();
15649
15645
  process.exit(code);
15650
15646
  return;
15651
15647
  }