@cevek/screentest 0.3.0 → 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 +322 -307
- package/dist/runner.js +7 -13
- package/dist/web/assets/{index-DMJ0v7v-.css → index-Bi-jx470.css} +1 -1
- package/dist/web/assets/{index-DIlEhyib.js → index-Ca30omZK.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +3 -2
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 = {};
|
|
@@ -14581,7 +14581,9 @@ var snapshotDocSchema = external_exports.object({
|
|
|
14581
14581
|
var apiDocResponseSchema = external_exports.object({
|
|
14582
14582
|
doc: docSchema,
|
|
14583
14583
|
expectedUrlPattern: external_exports.string(),
|
|
14584
|
-
workerUrl: external_exports.string()
|
|
14584
|
+
workerUrl: external_exports.string(),
|
|
14585
|
+
/** `@cevek/screentest` package version that served this doc.json. */
|
|
14586
|
+
serverVersion: external_exports.string()
|
|
14585
14587
|
});
|
|
14586
14588
|
var apiAcceptRequestSchema = external_exports.object({
|
|
14587
14589
|
testId: external_exports.string().min(1)
|
|
@@ -14653,8 +14655,8 @@ function flatten(doc, docDir) {
|
|
|
14653
14655
|
// src/server.ts
|
|
14654
14656
|
import express from "express";
|
|
14655
14657
|
import { existsSync } from "fs";
|
|
14656
|
-
import { dirname as
|
|
14657
|
-
import { fileURLToPath } from "url";
|
|
14658
|
+
import { dirname as dirname3, join, resolve as resolve4 } from "path";
|
|
14659
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
14658
14660
|
import { createServer } from "http";
|
|
14659
14661
|
|
|
14660
14662
|
// src/routes/doc.ts
|
|
@@ -14685,6 +14687,26 @@ function expectedUrlPattern(workerUrl) {
|
|
|
14685
14687
|
return `${workerUrl}/{hash}.png`;
|
|
14686
14688
|
}
|
|
14687
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
|
+
|
|
14688
14710
|
// src/routes/doc.ts
|
|
14689
14711
|
function docRoute(state) {
|
|
14690
14712
|
const r = Router();
|
|
@@ -14692,7 +14714,8 @@ function docRoute(state) {
|
|
|
14692
14714
|
res.json({
|
|
14693
14715
|
doc: state.doc,
|
|
14694
14716
|
expectedUrlPattern: expectedUrlPattern(state.workerUrl),
|
|
14695
|
-
workerUrl: state.workerUrl
|
|
14717
|
+
workerUrl: state.workerUrl,
|
|
14718
|
+
serverVersion: SCREENTEST_VERSION
|
|
14696
14719
|
});
|
|
14697
14720
|
});
|
|
14698
14721
|
return r;
|
|
@@ -14762,7 +14785,7 @@ function sha256Hex(buf) {
|
|
|
14762
14785
|
|
|
14763
14786
|
// src/patcher.ts
|
|
14764
14787
|
import { promises as fs } from "fs";
|
|
14765
|
-
import { dirname, resolve as
|
|
14788
|
+
import { dirname as dirname2, resolve as resolve3 } from "path";
|
|
14766
14789
|
var fileLocks = /* @__PURE__ */ new Map();
|
|
14767
14790
|
async function withFileLock(absPath, fn) {
|
|
14768
14791
|
const prev = fileLocks.get(absPath) ?? Promise.resolve();
|
|
@@ -14812,7 +14835,7 @@ function upsertHash(doc, path, hash2) {
|
|
|
14812
14835
|
async function atomicWriteJson(absPath, data) {
|
|
14813
14836
|
const json2 = JSON.stringify(data, null, 2);
|
|
14814
14837
|
const tmp = `${absPath}.tmp-${process.pid}-${Date.now()}`;
|
|
14815
|
-
await fs.mkdir(
|
|
14838
|
+
await fs.mkdir(dirname2(absPath), { recursive: true });
|
|
14816
14839
|
await fs.writeFile(tmp, json2, "utf8");
|
|
14817
14840
|
await fs.rename(tmp, absPath);
|
|
14818
14841
|
}
|
|
@@ -14847,7 +14870,7 @@ function removeAtPath(doc, path) {
|
|
|
14847
14870
|
return true;
|
|
14848
14871
|
}
|
|
14849
14872
|
async function removeFromSnapshot(args) {
|
|
14850
|
-
const abs =
|
|
14873
|
+
const abs = resolve3(args.docDir, args.patchSnapshotJsonFile);
|
|
14851
14874
|
await withFileLock(abs, async () => {
|
|
14852
14875
|
let doc;
|
|
14853
14876
|
try {
|
|
@@ -14864,7 +14887,7 @@ async function removeFromSnapshot(args) {
|
|
|
14864
14887
|
});
|
|
14865
14888
|
}
|
|
14866
14889
|
async function patchSnapshot(args) {
|
|
14867
|
-
const abs =
|
|
14890
|
+
const abs = resolve3(args.docDir, args.patchSnapshotJsonFile);
|
|
14868
14891
|
await withFileLock(abs, async () => {
|
|
14869
14892
|
let doc;
|
|
14870
14893
|
try {
|
|
@@ -14933,28 +14956,60 @@ function acceptRoute(state) {
|
|
|
14933
14956
|
return r;
|
|
14934
14957
|
}
|
|
14935
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
|
+
|
|
14936
14986
|
// src/server.ts
|
|
14987
|
+
var RANDOM_PORT_MIN = 4e4;
|
|
14988
|
+
var RANDOM_PORT_MAX = 5e4;
|
|
14989
|
+
var RANDOM_PICK_ATTEMPTS = 50;
|
|
14937
14990
|
function resolveWebDist() {
|
|
14938
|
-
const here =
|
|
14991
|
+
const here = dirname3(fileURLToPath2(import.meta.url));
|
|
14939
14992
|
const candidates = [
|
|
14940
14993
|
// Published npm package: dist/index.js → ./web
|
|
14941
|
-
|
|
14994
|
+
resolve4(here, "./web"),
|
|
14942
14995
|
// Local monorepo build: apps/server/dist/index.js → ../../web/dist
|
|
14943
|
-
|
|
14996
|
+
resolve4(here, "../../web/dist"),
|
|
14944
14997
|
// Dev via tsx: apps/server/src/server.ts → ../../../apps/web/dist
|
|
14945
|
-
|
|
14998
|
+
resolve4(here, "../../../apps/web/dist")
|
|
14946
14999
|
];
|
|
14947
15000
|
for (const c of candidates) {
|
|
14948
15001
|
if (existsSync(join(c, "index.html"))) return c;
|
|
14949
15002
|
}
|
|
14950
15003
|
return null;
|
|
14951
15004
|
}
|
|
14952
|
-
async function startServer(state,
|
|
15005
|
+
async function startServer(state, requestedPort) {
|
|
14953
15006
|
const app = express();
|
|
14954
15007
|
app.use(express.json({ limit: "1mb" }));
|
|
15008
|
+
app.use("/api", cancelPendingShutdownMiddleware);
|
|
14955
15009
|
app.use("/api", docRoute(state));
|
|
14956
15010
|
app.use("/api", actualRoute(state));
|
|
14957
15011
|
app.use("/api", acceptRoute(state));
|
|
15012
|
+
app.use("/api", shutdownRoute());
|
|
14958
15013
|
const webDist = resolveWebDist();
|
|
14959
15014
|
if (webDist) {
|
|
14960
15015
|
app.use(express.static(webDist));
|
|
@@ -14962,7 +15017,7 @@ async function startServer(state, startPort) {
|
|
|
14962
15017
|
res.sendFile(join(webDist, "index.html"));
|
|
14963
15018
|
});
|
|
14964
15019
|
}
|
|
14965
|
-
const port = await findFreePort(
|
|
15020
|
+
const port = requestedPort === 0 ? await pickRandomFreePort() : await findFreePort(requestedPort);
|
|
14966
15021
|
const httpServer = createServer(app);
|
|
14967
15022
|
await new Promise((resolveListen, rejectListen) => {
|
|
14968
15023
|
httpServer.once("error", rejectListen);
|
|
@@ -14977,6 +15032,16 @@ async function startServer(state, startPort) {
|
|
|
14977
15032
|
close: () => new Promise((r) => httpServer.close(() => r()))
|
|
14978
15033
|
};
|
|
14979
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
|
+
}
|
|
14980
15045
|
async function findFreePort(start) {
|
|
14981
15046
|
for (let p = start; p < start + 100; p++) {
|
|
14982
15047
|
if (await isFree(p)) return p;
|
|
@@ -14996,10 +15061,9 @@ function isFree(port) {
|
|
|
14996
15061
|
// src/ui.ts
|
|
14997
15062
|
var DEFAULT_WORKER_URL = "https://screentests.x-cevek.workers.dev";
|
|
14998
15063
|
var DEFAULT_TOKEN = "SECRET_123";
|
|
14999
|
-
var DEFAULT_PORT = 5174;
|
|
15000
15064
|
function parseUIArgs(rawArgs) {
|
|
15001
15065
|
let docPath = null;
|
|
15002
|
-
let port = Number(process.env.PORT) ||
|
|
15066
|
+
let port = Number(process.env.PORT) || 0;
|
|
15003
15067
|
let doOpen = true;
|
|
15004
15068
|
let workerUrl = process.env.CLOUDFLARE_WORKER_URL || DEFAULT_WORKER_URL;
|
|
15005
15069
|
let token = process.env.CLOUDFLARE_TOKEN || DEFAULT_TOKEN;
|
|
@@ -15034,8 +15098,8 @@ function parseUIArgs(rawArgs) {
|
|
|
15034
15098
|
};
|
|
15035
15099
|
}
|
|
15036
15100
|
async function runUI(opts) {
|
|
15037
|
-
const docAbs = isAbsolute(opts.docPath) ? opts.docPath :
|
|
15038
|
-
const docDir =
|
|
15101
|
+
const docAbs = isAbsolute(opts.docPath) ? opts.docPath : resolve5(process.cwd(), opts.docPath);
|
|
15102
|
+
const docDir = dirname4(docAbs);
|
|
15039
15103
|
let raw;
|
|
15040
15104
|
try {
|
|
15041
15105
|
raw = await fs3.readFile(docAbs, "utf8");
|
|
@@ -15099,221 +15163,16 @@ async function runUI(opts) {
|
|
|
15099
15163
|
process.on("SIGTERM", shutdown);
|
|
15100
15164
|
}
|
|
15101
15165
|
|
|
15102
|
-
// src/
|
|
15103
|
-
import { spawn
|
|
15104
|
-
import { promises as fs5 } from "fs";
|
|
15105
|
-
import { dirname as dirname5 } from "path";
|
|
15106
|
-
|
|
15107
|
-
// src/daemon.ts
|
|
15108
|
-
import { spawn, spawnSync } from "child_process";
|
|
15109
|
-
import { existsSync as existsSync2 } from "fs";
|
|
15110
|
-
import { dirname as dirname4, resolve as resolve5 } from "path";
|
|
15111
|
-
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
15112
|
-
import { createConnection } from "net";
|
|
15113
|
-
var IMAGE_NAME = "cevek/screentest-firefox";
|
|
15114
|
-
var IMAGE_TAG = "pw-1.60.0";
|
|
15115
|
-
var FULL_IMAGE = `${IMAGE_NAME}:${IMAGE_TAG}`;
|
|
15116
|
-
var CONTAINER_NAME = "screentest-firefox";
|
|
15117
|
-
var PORT = 5180;
|
|
15118
|
-
var WS_PATH = "screentest-firefox";
|
|
15119
|
-
var WS_ENDPOINT = `ws://localhost:${PORT}/${WS_PATH}`;
|
|
15120
|
-
function run(cmd, argv) {
|
|
15121
|
-
return new Promise((resolveExit) => {
|
|
15122
|
-
const p = spawn(cmd, argv, { stdio: "inherit" });
|
|
15123
|
-
p.on("exit", (code) => resolveExit(code ?? 0));
|
|
15124
|
-
});
|
|
15125
|
-
}
|
|
15126
|
-
function daemonState() {
|
|
15127
|
-
const ps = spawnSync(
|
|
15128
|
-
"docker",
|
|
15129
|
-
["ps", "-a", "-f", `name=^${CONTAINER_NAME}$`, "--format", "{{.Status}} {{.Image}}"],
|
|
15130
|
-
{ stdio: "pipe", encoding: "utf8" }
|
|
15131
|
-
);
|
|
15132
|
-
const line = ps.stdout.trim();
|
|
15133
|
-
if (line.startsWith("Up")) {
|
|
15134
|
-
const tab = line.indexOf(" ");
|
|
15135
|
-
const image = tab === -1 ? "" : line.slice(tab + 1).trim();
|
|
15136
|
-
return { state: "running", image };
|
|
15137
|
-
}
|
|
15138
|
-
const img = spawnSync("docker", ["image", "inspect", FULL_IMAGE], { stdio: "pipe" });
|
|
15139
|
-
if (img.status !== 0) return { state: "missing-image" };
|
|
15140
|
-
return { state: "stopped" };
|
|
15141
|
-
}
|
|
15142
|
-
function resolveTemplatesDir() {
|
|
15143
|
-
const here = dirname4(fileURLToPath2(import.meta.url));
|
|
15144
|
-
const candidates = [
|
|
15145
|
-
resolve5(here, "./templates"),
|
|
15146
|
-
resolve5(here, "../templates"),
|
|
15147
|
-
resolve5(here, "../../server/templates")
|
|
15148
|
-
];
|
|
15149
|
-
for (const c of candidates) {
|
|
15150
|
-
if (existsSync2(c)) return c;
|
|
15151
|
-
}
|
|
15152
|
-
throw new Error("templates/ directory not found relative to daemon.ts");
|
|
15153
|
-
}
|
|
15154
|
-
async function buildImage() {
|
|
15155
|
-
const templates = resolveTemplatesDir();
|
|
15156
|
-
process.stderr.write(`Building Docker image ${FULL_IMAGE} (~2 min first time, cached after).
|
|
15157
|
-
`);
|
|
15158
|
-
const code = await run("docker", [
|
|
15159
|
-
"build",
|
|
15160
|
-
"-f",
|
|
15161
|
-
`${templates}/Dockerfile.firefox-server`,
|
|
15162
|
-
"-t",
|
|
15163
|
-
FULL_IMAGE,
|
|
15164
|
-
templates
|
|
15165
|
-
]);
|
|
15166
|
-
if (code !== 0) {
|
|
15167
|
-
process.stderr.write(`docker build failed (exit ${code})
|
|
15168
|
-
`);
|
|
15169
|
-
return false;
|
|
15170
|
-
}
|
|
15171
|
-
return true;
|
|
15172
|
-
}
|
|
15173
|
-
function probePort() {
|
|
15174
|
-
return new Promise((resolveProbe) => {
|
|
15175
|
-
const s = createConnection({ host: "127.0.0.1", port: PORT });
|
|
15176
|
-
s.once("connect", () => {
|
|
15177
|
-
s.end();
|
|
15178
|
-
resolveProbe(true);
|
|
15179
|
-
});
|
|
15180
|
-
s.once("error", () => resolveProbe(false));
|
|
15181
|
-
});
|
|
15182
|
-
}
|
|
15183
|
-
async function waitForReady(timeoutMs = 1e4) {
|
|
15184
|
-
const start = Date.now();
|
|
15185
|
-
while (Date.now() - start < timeoutMs) {
|
|
15186
|
-
if (await probePort()) return true;
|
|
15187
|
-
await new Promise((r) => setTimeout(r, 150));
|
|
15188
|
-
}
|
|
15189
|
-
return false;
|
|
15190
|
-
}
|
|
15191
|
-
async function startDaemon() {
|
|
15192
|
-
const s = daemonState();
|
|
15193
|
-
if (s.state === "running") {
|
|
15194
|
-
if (s.image !== FULL_IMAGE) {
|
|
15195
|
-
process.stderr.write(
|
|
15196
|
-
`screentest-firefox daemon is running, but on a different image:
|
|
15197
|
-
running: ${s.image}
|
|
15198
|
-
this @cevek/screentest wants: ${FULL_IMAGE}
|
|
15199
|
-
\u2192 stop it first: screentest stop
|
|
15200
|
-
\u2192 then re-run: screentest serve
|
|
15201
|
-
`
|
|
15202
|
-
);
|
|
15203
|
-
return 1;
|
|
15204
|
-
}
|
|
15205
|
-
process.stdout.write(`screentest-firefox daemon already running at ${WS_ENDPOINT}
|
|
15206
|
-
`);
|
|
15207
|
-
return 0;
|
|
15208
|
-
}
|
|
15209
|
-
if (s.state === "missing-image") {
|
|
15210
|
-
if (!await buildImage()) return 1;
|
|
15211
|
-
}
|
|
15212
|
-
spawnSync("docker", ["rm", "-f", CONTAINER_NAME], { stdio: "pipe" });
|
|
15213
|
-
process.stderr.write(`Starting screentest-firefox daemon (port ${PORT})\u2026
|
|
15214
|
-
`);
|
|
15215
|
-
const code = await run("docker", [
|
|
15216
|
-
"run",
|
|
15217
|
-
"-d",
|
|
15218
|
-
"--rm",
|
|
15219
|
-
"--name",
|
|
15220
|
-
CONTAINER_NAME,
|
|
15221
|
-
"--network=host",
|
|
15222
|
-
FULL_IMAGE
|
|
15223
|
-
]);
|
|
15224
|
-
if (code !== 0) {
|
|
15225
|
-
process.stderr.write(`docker run failed (exit ${code})
|
|
15226
|
-
`);
|
|
15227
|
-
return code;
|
|
15228
|
-
}
|
|
15229
|
-
if (!await waitForReady()) {
|
|
15230
|
-
process.stderr.write(`Daemon container started but port ${PORT} never opened. Logs:
|
|
15231
|
-
`);
|
|
15232
|
-
spawnSync("docker", ["logs", CONTAINER_NAME], { stdio: "inherit" });
|
|
15233
|
-
return 1;
|
|
15234
|
-
}
|
|
15235
|
-
process.stdout.write(`screentest-firefox daemon ready at ${WS_ENDPOINT}
|
|
15236
|
-
`);
|
|
15237
|
-
return 0;
|
|
15238
|
-
}
|
|
15239
|
-
async function stopDaemon() {
|
|
15240
|
-
const s = daemonState();
|
|
15241
|
-
if (s.state !== "running") {
|
|
15242
|
-
process.stdout.write("screentest-firefox daemon is not running\n");
|
|
15243
|
-
return 0;
|
|
15244
|
-
}
|
|
15245
|
-
const code = await run("docker", ["stop", CONTAINER_NAME]);
|
|
15246
|
-
if (code === 0) process.stdout.write("screentest-firefox daemon stopped\n");
|
|
15247
|
-
return code;
|
|
15248
|
-
}
|
|
15249
|
-
async function printStatus() {
|
|
15250
|
-
const s = daemonState();
|
|
15251
|
-
if (s.state === "running") {
|
|
15252
|
-
const match = s.image === FULL_IMAGE ? "(matches this @cevek/screentest)" : "(MISMATCH!)";
|
|
15253
|
-
process.stdout.write(
|
|
15254
|
-
`screentest-firefox daemon: running
|
|
15255
|
-
ws endpoint: ${WS_ENDPOINT}
|
|
15256
|
-
image: ${s.image} ${match}
|
|
15257
|
-
expected: ${FULL_IMAGE}
|
|
15258
|
-
`
|
|
15259
|
-
);
|
|
15260
|
-
if (s.image !== FULL_IMAGE) {
|
|
15261
|
-
process.stdout.write(
|
|
15262
|
-
` \u2192 daemon is running an image pinned by a different version of this
|
|
15263
|
-
package. Stop it (screentest stop) and re-serve from the project
|
|
15264
|
-
whose Playwright version you want to use.
|
|
15265
|
-
`
|
|
15266
|
-
);
|
|
15267
|
-
}
|
|
15268
|
-
return 0;
|
|
15269
|
-
}
|
|
15270
|
-
if (s.state === "stopped") {
|
|
15271
|
-
process.stdout.write(
|
|
15272
|
-
`screentest-firefox daemon: stopped (image ${FULL_IMAGE} exists)
|
|
15273
|
-
start it with: screentest serve
|
|
15274
|
-
`
|
|
15275
|
-
);
|
|
15276
|
-
return 0;
|
|
15277
|
-
}
|
|
15278
|
-
process.stdout.write(
|
|
15279
|
-
`screentest-firefox daemon: not installed (image ${FULL_IMAGE} missing)
|
|
15280
|
-
build + start with: screentest serve
|
|
15281
|
-
`
|
|
15282
|
-
);
|
|
15283
|
-
return 0;
|
|
15284
|
-
}
|
|
15285
|
-
async function requireRunningDaemon() {
|
|
15286
|
-
const s = daemonState();
|
|
15287
|
-
if (s.state !== "running") {
|
|
15288
|
-
process.stderr.write(
|
|
15289
|
-
`screentest-firefox daemon is not running.
|
|
15290
|
-
Start it once with: screentest serve
|
|
15291
|
-
It then stays alive across projects until you stop it.
|
|
15292
|
-
`
|
|
15293
|
-
);
|
|
15294
|
-
return false;
|
|
15295
|
-
}
|
|
15296
|
-
if (s.image !== FULL_IMAGE) {
|
|
15297
|
-
process.stderr.write(
|
|
15298
|
-
`screentest-firefox daemon is running with a different Playwright version:
|
|
15299
|
-
running: ${s.image}
|
|
15300
|
-
this @cevek/screentest expects: ${FULL_IMAGE}
|
|
15301
|
-
\u2192 screentest stop && screentest serve
|
|
15302
|
-
(or align package versions across projects sharing the daemon)
|
|
15303
|
-
`
|
|
15304
|
-
);
|
|
15305
|
-
return false;
|
|
15306
|
-
}
|
|
15307
|
-
return true;
|
|
15308
|
-
}
|
|
15166
|
+
// src/review.ts
|
|
15167
|
+
import { spawn } from "child_process";
|
|
15309
15168
|
|
|
15310
15169
|
// src/doc-builder.ts
|
|
15311
|
-
import { spawnSync
|
|
15170
|
+
import { spawnSync } from "child_process";
|
|
15312
15171
|
import { createHash as createHash2 } from "crypto";
|
|
15313
15172
|
import { promises as fs4 } from "fs";
|
|
15314
15173
|
import { join as join2, relative, sep } from "path";
|
|
15315
15174
|
function detectBranch(projectRoot) {
|
|
15316
|
-
const r =
|
|
15175
|
+
const r = spawnSync("git", ["-C", projectRoot, "rev-parse", "--abbrev-ref", "HEAD"], {
|
|
15317
15176
|
stdio: "pipe",
|
|
15318
15177
|
encoding: "utf8"
|
|
15319
15178
|
});
|
|
@@ -15443,7 +15302,7 @@ async function generateDoc(projectRoot, snapshotFile, actualDir, docFile, cacheD
|
|
|
15443
15302
|
}
|
|
15444
15303
|
|
|
15445
15304
|
// src/runner/paths.ts
|
|
15446
|
-
import { existsSync as
|
|
15305
|
+
import { existsSync as existsSync2 } from "fs";
|
|
15447
15306
|
import { isAbsolute as isAbsolute2, join as join3, resolve as resolve6 } from "path";
|
|
15448
15307
|
function resolveRunnerPaths(cwd = process.cwd()) {
|
|
15449
15308
|
const projectRoot = resolve6(cwd);
|
|
@@ -15463,85 +15322,41 @@ function resolveSnapshotFile(projectRoot) {
|
|
|
15463
15322
|
}
|
|
15464
15323
|
const legacyRoot = join3(projectRoot, "snapshot.json");
|
|
15465
15324
|
const insideTests = join3(projectRoot, "tests", "snapshot.json");
|
|
15466
|
-
if (
|
|
15325
|
+
if (existsSync2(legacyRoot) && !existsSync2(insideTests)) return legacyRoot;
|
|
15467
15326
|
return insideTests;
|
|
15468
15327
|
}
|
|
15469
15328
|
|
|
15470
|
-
// src/
|
|
15471
|
-
function
|
|
15329
|
+
// src/review.ts
|
|
15330
|
+
function run(cmd, argv) {
|
|
15472
15331
|
return new Promise((resolveExit) => {
|
|
15473
|
-
const p =
|
|
15332
|
+
const p = spawn(cmd, argv, { stdio: "inherit" });
|
|
15474
15333
|
p.on("exit", (code) => resolveExit(code ?? 0));
|
|
15475
15334
|
});
|
|
15476
15335
|
}
|
|
15477
|
-
async function
|
|
15336
|
+
async function runReview() {
|
|
15478
15337
|
const paths = resolveRunnerPaths();
|
|
15479
|
-
|
|
15480
|
-
|
|
15481
|
-
|
|
15482
|
-
|
|
15483
|
-
|
|
15484
|
-
|
|
15485
|
-
paths.cacheDir
|
|
15486
|
-
);
|
|
15487
|
-
if (total === 0) {
|
|
15488
|
-
process.stdout.write("Nothing to review \u2014 all snapshots match.\n");
|
|
15489
|
-
return 0;
|
|
15490
|
-
}
|
|
15491
|
-
return await run2("node", [process.argv[1], paths.docFile]);
|
|
15492
|
-
}
|
|
15493
|
-
await fs5.mkdir(paths.cacheDir, { recursive: true });
|
|
15494
|
-
try {
|
|
15495
|
-
await fs5.access(paths.snapshotFile);
|
|
15496
|
-
} catch {
|
|
15497
|
-
await fs5.mkdir(dirname5(paths.snapshotFile), { recursive: true });
|
|
15498
|
-
await fs5.writeFile(paths.snapshotFile, '{\n "groups": []\n}\n');
|
|
15499
|
-
}
|
|
15500
|
-
await fs5.rm(paths.actualDir, { recursive: true, force: true });
|
|
15501
|
-
if (!await requireRunningDaemon()) {
|
|
15502
|
-
return 1;
|
|
15503
|
-
}
|
|
15504
|
-
const env = { ...process.env, SCREENTEST_FIREFOX_WS: WS_ENDPOINT };
|
|
15505
|
-
const testCode = await run2(
|
|
15506
|
-
"npx",
|
|
15507
|
-
["vitest", "--config", "vitest.screentest.config.ts", "run"],
|
|
15508
|
-
env
|
|
15338
|
+
const total = await generateDoc(
|
|
15339
|
+
paths.projectRoot,
|
|
15340
|
+
paths.snapshotFile,
|
|
15341
|
+
paths.actualDir,
|
|
15342
|
+
paths.docFile,
|
|
15343
|
+
paths.cacheDir
|
|
15509
15344
|
);
|
|
15510
|
-
if (
|
|
15511
|
-
process.
|
|
15512
|
-
|
|
15513
|
-
);
|
|
15514
|
-
const total = await generateDoc(
|
|
15515
|
-
paths.projectRoot,
|
|
15516
|
-
paths.snapshotFile,
|
|
15517
|
-
paths.actualDir,
|
|
15518
|
-
paths.docFile,
|
|
15519
|
-
paths.cacheDir
|
|
15520
|
-
);
|
|
15521
|
-
if (total > 0) {
|
|
15522
|
-
const reviewCode = await run2("node", [process.argv[1], paths.docFile]);
|
|
15523
|
-
if (reviewCode !== 0 && reviewCode !== 130) {
|
|
15524
|
-
process.stderr.write(`
|
|
15525
|
-
(screentest exited with code ${reviewCode})
|
|
15526
|
-
`);
|
|
15527
|
-
}
|
|
15528
|
-
} else {
|
|
15529
|
-
process.stderr.write(
|
|
15530
|
-
"\n(no diffs to review \u2014 vitest may have failed before any screenshot)\n"
|
|
15531
|
-
);
|
|
15532
|
-
}
|
|
15345
|
+
if (total === 0) {
|
|
15346
|
+
process.stdout.write("Nothing to review \u2014 all snapshots match.\n");
|
|
15347
|
+
return 0;
|
|
15533
15348
|
}
|
|
15534
|
-
return
|
|
15349
|
+
return await run("node", [process.argv[1], paths.docFile]);
|
|
15535
15350
|
}
|
|
15536
15351
|
|
|
15537
15352
|
// src/init.ts
|
|
15538
15353
|
import { copyFile, mkdir } from "fs/promises";
|
|
15539
|
-
import { existsSync as
|
|
15540
|
-
import { dirname as
|
|
15354
|
+
import { existsSync as existsSync3 } from "fs";
|
|
15355
|
+
import { dirname as dirname5, join as join4, resolve as resolve7 } from "path";
|
|
15541
15356
|
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
15542
15357
|
async function runInit() {
|
|
15543
|
-
const here =
|
|
15544
|
-
const templatesDir = [resolve7(here, "./templates"), resolve7(here, "../templates")].find((p) =>
|
|
15358
|
+
const here = dirname5(fileURLToPath3(import.meta.url));
|
|
15359
|
+
const templatesDir = [resolve7(here, "./templates"), resolve7(here, "../templates")].find((p) => existsSync3(p)) ?? resolve7(here, "./templates");
|
|
15545
15360
|
const cwd = process.cwd();
|
|
15546
15361
|
const targets = [
|
|
15547
15362
|
{ from: "vitest.screentest.config.ts", to: "vitest.screentest.config.ts" },
|
|
@@ -15550,17 +15365,17 @@ async function runInit() {
|
|
|
15550
15365
|
for (const { from, to } of targets) {
|
|
15551
15366
|
const src = join4(templatesDir, from);
|
|
15552
15367
|
const dst = join4(cwd, to);
|
|
15553
|
-
if (
|
|
15368
|
+
if (existsSync3(dst)) {
|
|
15554
15369
|
process.stdout.write(` exists ${to}
|
|
15555
15370
|
`);
|
|
15556
15371
|
continue;
|
|
15557
15372
|
}
|
|
15558
|
-
if (!
|
|
15373
|
+
if (!existsSync3(src)) {
|
|
15559
15374
|
process.stdout.write(` missing ${from} (package install incomplete?)
|
|
15560
15375
|
`);
|
|
15561
15376
|
continue;
|
|
15562
15377
|
}
|
|
15563
|
-
await mkdir(
|
|
15378
|
+
await mkdir(dirname5(dst), { recursive: true });
|
|
15564
15379
|
await copyFile(src, dst);
|
|
15565
15380
|
process.stdout.write(` wrote ${to}
|
|
15566
15381
|
`);
|
|
@@ -15569,7 +15384,190 @@ async function runInit() {
|
|
|
15569
15384
|
`
|
|
15570
15385
|
Next steps:
|
|
15571
15386
|
1. screentest serve # one-time: starts the shared Firefox daemon
|
|
15572
|
-
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
|
|
15573
15571
|
`
|
|
15574
15572
|
);
|
|
15575
15573
|
return 0;
|
|
@@ -15580,14 +15578,9 @@ function printHelp() {
|
|
|
15580
15578
|
process.stderr.write(
|
|
15581
15579
|
[
|
|
15582
15580
|
"Usage:",
|
|
15583
|
-
" screentest <doc-json-path> [--port
|
|
15581
|
+
" screentest <doc-json-path> [--port N] [--no-open] [--worker-url URL] [--token TOKEN]",
|
|
15584
15582
|
" open the review UI on an existing doc.json",
|
|
15585
|
-
"",
|
|
15586
|
-
" screentest test run vitest on the host, connect to the shared",
|
|
15587
|
-
" Firefox daemon, on failure regenerate doc.json",
|
|
15588
|
-
" and open the review UI",
|
|
15589
|
-
"",
|
|
15590
|
-
" screentest review regenerate doc.json from cached actuals + open UI",
|
|
15583
|
+
" (this is what `vitest run` auto-launches on failure)",
|
|
15591
15584
|
"",
|
|
15592
15585
|
" screentest serve start the shared Firefox browser-server daemon",
|
|
15593
15586
|
" (builds the image on first run). One daemon per",
|
|
@@ -15595,16 +15588,29 @@ function printHelp() {
|
|
|
15595
15588
|
" screentest stop stop the daemon",
|
|
15596
15589
|
" screentest status show whether the daemon is running",
|
|
15597
15590
|
"",
|
|
15591
|
+
" screentest review regenerate doc.json from cached actuals + open UI",
|
|
15592
|
+
" (useful when you closed the UI without accepting)",
|
|
15593
|
+
"",
|
|
15598
15594
|
" screentest init scaffold vitest.screentest.config.ts and",
|
|
15599
15595
|
" tests/example.test.ts into the current project",
|
|
15600
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
|
+
"",
|
|
15601
15604
|
"Env vars (UI mode):",
|
|
15602
15605
|
" CLOUDFLARE_WORKER_URL default https://screentests.x-cevek.workers.dev",
|
|
15603
15606
|
" CLOUDFLARE_TOKEN default SECRET_123",
|
|
15604
15607
|
"",
|
|
15605
|
-
"Env vars (test
|
|
15608
|
+
"Env vars (test runs):",
|
|
15606
15609
|
" APP_URL default http://localhost:5050",
|
|
15607
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)",
|
|
15608
15614
|
""
|
|
15609
15615
|
].join("\n")
|
|
15610
15616
|
);
|
|
@@ -15617,12 +15623,21 @@ async function main() {
|
|
|
15617
15623
|
}
|
|
15618
15624
|
const cmd = argv[0];
|
|
15619
15625
|
if (cmd === "test") {
|
|
15620
|
-
|
|
15621
|
-
|
|
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);
|
|
15622
15637
|
return;
|
|
15623
15638
|
}
|
|
15624
15639
|
if (cmd === "review") {
|
|
15625
|
-
const code = await
|
|
15640
|
+
const code = await runReview();
|
|
15626
15641
|
process.exit(code);
|
|
15627
15642
|
return;
|
|
15628
15643
|
}
|