@jskit-ai/jskit-catalog 0.2.32 → 0.2.33
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/catalog/packages.json +251 -242
- package/package.json +2 -1
- package/scripts/browser-test-build.mjs +153 -0
- package/scripts/browser-test-servers.mjs +132 -0
- package/scripts/browser-test-times.mjs +33 -0
- package/scripts/browser-tests.mjs +166 -0
- package/scripts/jskit.mjs +7 -2
- package/scripts/test-command.mjs +126 -19
- package/scripts/test-databases.mjs +50 -0
- package/scripts/test-file-stop.mjs +32 -0
- package/scripts/test-runs.mjs +56 -1
- package/scripts/test-time-report.mjs +150 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/jskit-catalog",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.33",
|
|
4
4
|
"description": "Published metadata catalog for JSKIT package metadata.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"./packages": "./catalog/packages.json",
|
|
16
16
|
"./project-packages": "./scripts/project-packages.mjs",
|
|
17
17
|
"./test-budget": "./scripts/test-budget.mjs",
|
|
18
|
+
"./browser-tests": "./scripts/browser-tests.mjs",
|
|
18
19
|
"./package.json": "./package.json"
|
|
19
20
|
},
|
|
20
21
|
"scripts": {
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// The production build of the frontend that every browser-test server serves, as a deployed server does: a page then
|
|
2
|
+
// loads a few bundled files instead of every source module from a development server. The build goes where the app
|
|
3
|
+
// server serves it from (dist/), never a folder of its own: the server tells every page which build it serves from
|
|
4
|
+
// dist/index.html, and a page from any other build offers a reload over the screen. It is reused while its inputs' contents
|
|
5
|
+
// are unchanged and dist/index.html is still the one it made, so a repeated focused run does not rebuild, even though a
|
|
6
|
+
// build rewrites files it generates among its inputs (vue-router's src/typed-router.d.ts). Two runs in one tree build one
|
|
7
|
+
// at a time: the second waits, then reuses the first one's build.
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import process from "node:process";
|
|
13
|
+
|
|
14
|
+
// What the last browser-test build was made from, and the dist/index.html it made.
|
|
15
|
+
const FINGERPRINT_FILE = path.join("node_modules", ".cache", "jskit-browser-test-build.json");
|
|
16
|
+
// Held, with its holder's process id, while a run builds.
|
|
17
|
+
const BUILD_LOCK = path.join("node_modules", ".cache", "jskit-browser-test-build.lock");
|
|
18
|
+
const BUILD_WAIT_MS = 10 * 60_000;
|
|
19
|
+
const SERVED_INDEX = path.join("dist", "index.html");
|
|
20
|
+
// The app's folders a frontend build reads, and the installed packages it bundles. Every file at the app's root is an
|
|
21
|
+
// input too (index.html, vite.config.*, favicon.svg, package.json, the .env files Vite reads), except other dotfiles,
|
|
22
|
+
// such as caches, and notes.
|
|
23
|
+
const BUILD_DIRECTORIES = Object.freeze(["src", "packages", "shared", "config", "public", "node_modules/@jskit-ai"]);
|
|
24
|
+
const INSTALLED_PACKAGES = "node_modules/.package-lock.json";
|
|
25
|
+
const NOT_AN_INPUT = /^\.(?!env(?:\.|$))|\.(?:md|log|txt)$/u;
|
|
26
|
+
|
|
27
|
+
const seconds = (since) => `${((performance.now() - since) / 1000).toFixed(1)}s`;
|
|
28
|
+
const digest = (file) => (existsSync(file) ? createHash("sha256").update(readFileSync(file)).digest("hex") : "");
|
|
29
|
+
|
|
30
|
+
/** Every build input's path and contents, the build command and the VITE_ settings, hashed. */
|
|
31
|
+
function buildFingerprint(appRoot, build, env = process.env) {
|
|
32
|
+
const hash = createHash("sha256").update(`${build}\n`);
|
|
33
|
+
const visit = (relative) => {
|
|
34
|
+
const absolute = path.join(appRoot, relative);
|
|
35
|
+
if (!existsSync(absolute)) return;
|
|
36
|
+
const stats = statSync(absolute);
|
|
37
|
+
if (!stats.isDirectory()) {
|
|
38
|
+
hash.update(`${relative}\0${stats.size}\0`).update(readFileSync(absolute));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
for (const entry of readdirSync(absolute).sort()) {
|
|
42
|
+
if (entry !== "node_modules" && !entry.startsWith(".")) visit(path.join(relative, entry));
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
for (const entry of readdirSync(appRoot, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
|
|
46
|
+
if (!entry.isDirectory() && !NOT_AN_INPUT.test(entry.name)) visit(entry.name);
|
|
47
|
+
}
|
|
48
|
+
[INSTALLED_PACKAGES, ...BUILD_DIRECTORIES].forEach(visit);
|
|
49
|
+
for (const [name, value] of Object.entries(env).filter(([key]) => key.startsWith("VITE_")).sort()) {
|
|
50
|
+
hash.update(`${name}=${value}\n`);
|
|
51
|
+
}
|
|
52
|
+
return hash.digest("hex");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function runToCompletion(command, appRoot) {
|
|
56
|
+
const [program, ...args] = String(command).trim().split(/\s+/u);
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
// The build a deployment serves, whatever NODE_ENV the launcher was given.
|
|
59
|
+
const child = spawn(program, args, {
|
|
60
|
+
cwd: appRoot,
|
|
61
|
+
env: { ...process.env, NODE_ENV: "production" },
|
|
62
|
+
stdio: ["ignore", "ignore", "inherit"]
|
|
63
|
+
});
|
|
64
|
+
child.once("error", reject);
|
|
65
|
+
child.once("exit", (code, signal) => (code === 0 ? resolve() : reject(new Error(`${command} failed (${signal || `exit ${code}`}).`))));
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function lastBuild(appRoot) {
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(readFileSync(path.join(appRoot, FINGERPRINT_FILE), "utf8"));
|
|
72
|
+
} catch {
|
|
73
|
+
return {};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isRunning(pid) {
|
|
78
|
+
try {
|
|
79
|
+
process.kill(pid, 0);
|
|
80
|
+
return true;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
return error.code === "EPERM";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Takes the tree's build lock, waiting while another live run holds it; a dead holder's lock is taken over. */
|
|
87
|
+
async function lockBuild(appRoot) {
|
|
88
|
+
const lock = path.join(appRoot, BUILD_LOCK);
|
|
89
|
+
mkdirSync(path.dirname(lock), { recursive: true });
|
|
90
|
+
const deadline = Date.now() + BUILD_WAIT_MS;
|
|
91
|
+
let reported = false;
|
|
92
|
+
while (true) {
|
|
93
|
+
try {
|
|
94
|
+
const descriptor = openSync(lock, "wx");
|
|
95
|
+
writeSync(descriptor, String(process.pid));
|
|
96
|
+
closeSync(descriptor);
|
|
97
|
+
return () => rmSync(lock, { force: true });
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (error.code !== "EEXIST") throw error;
|
|
100
|
+
}
|
|
101
|
+
let holder = 0;
|
|
102
|
+
try {
|
|
103
|
+
holder = Number.parseInt(readFileSync(lock, "utf8"), 10);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error.code !== "ENOENT") throw error;
|
|
106
|
+
}
|
|
107
|
+
if (holder && !isRunning(holder)) {
|
|
108
|
+
rmSync(lock, { force: true });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (Date.now() > deadline) throw new Error(`Another run (process ${holder}) has been building the frontend for 10 minutes.`);
|
|
112
|
+
if (!reported) console.log(`[browser-tests] Waiting for another run's frontend build (process ${holder}).`);
|
|
113
|
+
reported = true;
|
|
114
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isCurrent(appRoot, inputs) {
|
|
119
|
+
const last = lastBuild(appRoot);
|
|
120
|
+
return last.inputs === inputs && Boolean(last.index) && last.index === digest(path.join(appRoot, SERVED_INDEX));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Builds the frontend with `build` unless dist/ already holds the build of the current inputs.
|
|
125
|
+
* @param {{ appRoot?: string, build?: string, run?: (command: string, appRoot: string) => Promise<void> }} [options]
|
|
126
|
+
* @returns {Promise<{ built: boolean }>}
|
|
127
|
+
*/
|
|
128
|
+
async function buildFrontend({ appRoot = process.cwd(), build = "npm run build", run = runToCompletion } = {}) {
|
|
129
|
+
const started = performance.now();
|
|
130
|
+
const inputs = buildFingerprint(appRoot, build);
|
|
131
|
+
if (isCurrent(appRoot, inputs)) {
|
|
132
|
+
console.log(`[browser-tests] Frontend build unchanged, reused (${seconds(started)}).`);
|
|
133
|
+
return { built: false };
|
|
134
|
+
}
|
|
135
|
+
const unlock = await lockBuild(appRoot);
|
|
136
|
+
try {
|
|
137
|
+
// The run that held the lock may have just built these inputs.
|
|
138
|
+
if (isCurrent(appRoot, inputs)) {
|
|
139
|
+
console.log(`[browser-tests] Frontend built by another run, reused (${seconds(started)}).`);
|
|
140
|
+
return { built: false };
|
|
141
|
+
}
|
|
142
|
+
await run(build, appRoot);
|
|
143
|
+
const index = digest(path.join(appRoot, SERVED_INDEX));
|
|
144
|
+
if (!index) throw new Error(`${build} made no ${SERVED_INDEX}, which the app server serves.`);
|
|
145
|
+
writeFileSync(path.join(appRoot, FINGERPRINT_FILE), JSON.stringify({ inputs, index }));
|
|
146
|
+
console.log(`[browser-tests] Frontend built in ${seconds(started)}.`);
|
|
147
|
+
return { built: true };
|
|
148
|
+
} finally {
|
|
149
|
+
unlock();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export { buildFrontend };
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// The webServer of a local browser run (browser-tests.mjs): builds the production frontend unless its build is current
|
|
2
|
+
// (browser-test-build.mjs), runs the app's `prepare` module once for every worker's environment (its own database), then
|
|
3
|
+
// starts one app server per Playwright worker, each on its own port, and prints the ready line Playwright waits for.
|
|
4
|
+
// Each worker's server environment is also left, readable only by this user, where the worker's own process takes it
|
|
5
|
+
// (browser-tests.mjs). With the app's `signIn` module, each server is then signed in once, and its storage state left
|
|
6
|
+
// for its worker. Each server's output goes to test-results/browser-test-servers/worker-<n>.log; a server that stops
|
|
7
|
+
// ends the run.
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { createWriteStream, mkdirSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { createRequire } from "node:module";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import process from "node:process";
|
|
13
|
+
import { pathToFileURL } from "node:url";
|
|
14
|
+
import { buildFrontend } from "./browser-test-build.mjs";
|
|
15
|
+
|
|
16
|
+
const SETTINGS_VARIABLE = "JSKIT_BROWSER_TESTS";
|
|
17
|
+
const LOG_DIRECTORY = path.join("test-results", "browser-test-servers");
|
|
18
|
+
const READY_TIMEOUT_MS = 60_000;
|
|
19
|
+
|
|
20
|
+
const seconds = (since) => `${((performance.now() - since) / 1000).toFixed(1)}s`;
|
|
21
|
+
const words = (command) => String(command).trim().split(/\s+/u).filter(Boolean);
|
|
22
|
+
|
|
23
|
+
/** Each worker's server environment from the app's prepare module, given each worker's base URL, or none. */
|
|
24
|
+
async function prepareWorkers(appRoot, prepare, workers, baseUrls) {
|
|
25
|
+
if (!prepare) return Array.from({ length: workers }, () => ({}));
|
|
26
|
+
const { default: prepareWorkerEnvironments } = await import(pathToFileURL(path.resolve(appRoot, prepare)).href);
|
|
27
|
+
const environments = await prepareWorkerEnvironments({ workers, baseUrls });
|
|
28
|
+
if (!Array.isArray(environments) || environments.length < workers) {
|
|
29
|
+
throw new Error(`${prepare} prepared ${environments?.length ?? 0} worker environment(s) for ${workers} worker(s); ` +
|
|
30
|
+
"let the database user create the copies, or set PLAYWRIGHT_WORKERS lower.");
|
|
31
|
+
}
|
|
32
|
+
return environments;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Signs each worker's server in once through the app's signIn module, and leaves its storage state for that worker. */
|
|
36
|
+
async function signInWorkers(appRoot, signIn, baseUrls, environments, signedInStates) {
|
|
37
|
+
const started = performance.now();
|
|
38
|
+
const { default: signInWorker } = await import(pathToFileURL(path.resolve(appRoot, signIn)).href);
|
|
39
|
+
// The app's own Playwright, which its tests run on.
|
|
40
|
+
const { request } = createRequire(path.join(appRoot, "package.json"))("@playwright/test");
|
|
41
|
+
await Promise.all(baseUrls.map(async (baseURL, index) => {
|
|
42
|
+
const context = await request.newContext({ baseURL });
|
|
43
|
+
try {
|
|
44
|
+
await signInWorker({ request: context, baseURL, workerIndex: index, env: environments[index] });
|
|
45
|
+
// It holds a session's cookies: only this user may read it.
|
|
46
|
+
writeFileSync(`${signedInStates}-${index}.json`, JSON.stringify(await context.storageState()), { mode: 0o600 });
|
|
47
|
+
} finally {
|
|
48
|
+
await context.dispose();
|
|
49
|
+
}
|
|
50
|
+
}));
|
|
51
|
+
console.log(`[browser-tests] ${baseUrls.length} worker(s) signed in in ${seconds(started)}.`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function waitUntilReady(url, server, name) {
|
|
55
|
+
const started = performance.now();
|
|
56
|
+
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
57
|
+
while (Date.now() < deadline) {
|
|
58
|
+
if (server.exitCode !== null || server.signalCode !== null) throw new Error(`${name} stopped while starting.`);
|
|
59
|
+
try {
|
|
60
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(2_000) });
|
|
61
|
+
if (response.ok) {
|
|
62
|
+
console.log(`[browser-tests] ${name} started in ${seconds(started)}.`);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
// Not listening yet.
|
|
67
|
+
}
|
|
68
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
69
|
+
}
|
|
70
|
+
throw new Error(`${name} was not ready at ${url} within ${READY_TIMEOUT_MS / 1000}s.`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const servers = [];
|
|
74
|
+
let stopping = false;
|
|
75
|
+
function stopServers() {
|
|
76
|
+
stopping = true;
|
|
77
|
+
for (const server of servers) server.kill("SIGTERM");
|
|
78
|
+
}
|
|
79
|
+
process.once("SIGINT", () => { stopServers(); process.exit(130); });
|
|
80
|
+
process.once("SIGTERM", () => { stopServers(); process.exit(143); });
|
|
81
|
+
process.once("exit", stopServers);
|
|
82
|
+
|
|
83
|
+
const started = performance.now();
|
|
84
|
+
const appRoot = process.cwd();
|
|
85
|
+
const {
|
|
86
|
+
workers, host, basePort, server, build, prepare, env, health, workerEnvironments: workerEnvironmentsFile, signIn,
|
|
87
|
+
signedInStates
|
|
88
|
+
} = JSON.parse(process.env[SETTINGS_VARIABLE] || "{}");
|
|
89
|
+
const baseEnvironment = { ...process.env };
|
|
90
|
+
delete baseEnvironment[SETTINGS_VARIABLE];
|
|
91
|
+
const baseUrls = Array.from({ length: workers }, (_, index) => `http://${host}:${basePort + index}`);
|
|
92
|
+
try {
|
|
93
|
+
const [, workerEnvironments] = await Promise.all([
|
|
94
|
+
buildFrontend({ appRoot, build }),
|
|
95
|
+
prepareWorkers(appRoot, prepare, workers, baseUrls)
|
|
96
|
+
]);
|
|
97
|
+
mkdirSync(path.join(appRoot, LOG_DIRECTORY), { recursive: true });
|
|
98
|
+
mkdirSync(path.dirname(workerEnvironmentsFile), { recursive: true });
|
|
99
|
+
writeFileSync(workerEnvironmentsFile, JSON.stringify(workerEnvironments.slice(0, workers)), { mode: 0o600 });
|
|
100
|
+
const [program, ...args] = words(server);
|
|
101
|
+
const serverEnvironments = workerEnvironments.slice(0, workers).map((workerEnvironment, index) => ({
|
|
102
|
+
...baseEnvironment,
|
|
103
|
+
...env,
|
|
104
|
+
...workerEnvironment,
|
|
105
|
+
HOST: host,
|
|
106
|
+
PORT: String(basePort + index),
|
|
107
|
+
TEST_PARALLEL_INDEX: String(index)
|
|
108
|
+
}));
|
|
109
|
+
await Promise.all(serverEnvironments.map((serverEnvironment, index) => {
|
|
110
|
+
const baseUrl = baseUrls[index];
|
|
111
|
+
const name = `The browser test server of worker ${index} (${baseUrl})`;
|
|
112
|
+
const log = path.join(LOG_DIRECTORY, `worker-${index}.log`);
|
|
113
|
+
const child = spawn(program, args, { cwd: appRoot, env: serverEnvironment, stdio: ["ignore", "pipe", "pipe"] });
|
|
114
|
+
const output = createWriteStream(path.join(appRoot, log));
|
|
115
|
+
child.stdout.pipe(output);
|
|
116
|
+
child.stderr.pipe(output);
|
|
117
|
+
child.once("exit", (code, signal) => {
|
|
118
|
+
if (stopping) return;
|
|
119
|
+
console.error(`[browser-tests] ${name} stopped (${signal || `exit ${code}`}); log: ${log}`);
|
|
120
|
+
stopServers();
|
|
121
|
+
process.exit(1);
|
|
122
|
+
});
|
|
123
|
+
servers.push(child);
|
|
124
|
+
return waitUntilReady(new URL(health, baseUrl), child, name);
|
|
125
|
+
}));
|
|
126
|
+
if (signIn) await signInWorkers(appRoot, signIn, baseUrls, serverEnvironments, signedInStates);
|
|
127
|
+
console.log(`jskit browser tests: ${workers} server(s) ready in ${seconds(started)}; logs in ${LOG_DIRECTORY}.`);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
console.error(`[browser-tests] ${error.message}`);
|
|
130
|
+
stopServers();
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
|
|
4
|
+
const SLOWEST_REPORTED = 10;
|
|
5
|
+
|
|
6
|
+
// A Playwright reporter (browser-tests.mjs adds it beside "list"): every run ends with its slowest tests and their
|
|
7
|
+
// times, so a test that got slow shows instead of hiding inside a longer run.
|
|
8
|
+
class SlowestTests {
|
|
9
|
+
constructor() {
|
|
10
|
+
this.times = [];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
onTestEnd(test, result) {
|
|
14
|
+
const file = path.relative(process.cwd(), test.location.file);
|
|
15
|
+
this.times.push({ name: `${file}:${test.location.line} ${test.title}`, duration: result.duration, status: result.status });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
onEnd() {
|
|
19
|
+
const slowest = [...this.times].sort((left, right) => right.duration - left.duration).slice(0, SLOWEST_REPORTED);
|
|
20
|
+
if (slowest.length < 1) return;
|
|
21
|
+
const lines = [`[test-time] Slowest ${slowest.length} of ${this.times.length} tests:`];
|
|
22
|
+
for (const { name, duration, status } of slowest) {
|
|
23
|
+
lines.push(`[test-time] ${(duration / 1000).toFixed(1)} s ${name}${status === "passed" ? "" : ` (${status})`}`);
|
|
24
|
+
}
|
|
25
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
printsToStdio() {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default SlowestTests;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { testConcurrency } from "./test-budget.mjs";
|
|
6
|
+
|
|
7
|
+
// An application's Playwright configuration: `defineConfig(browserTestConfig({ ... }))`. A local run builds the
|
|
8
|
+
// production frontend once (cached by its sources) and starts one app server per Playwright worker, each on its own port
|
|
9
|
+
// and, through the app's `prepare` module, its own database, so workers run in parallel without sharing data
|
|
10
|
+
// (browser-test-servers.mjs). With the app's `signIn` module, each worker's server is signed in once and every test of
|
|
11
|
+
// that worker starts from its signed-in state. With PLAYWRIGHT_BASE_URL set, the tests use that managed target instead,
|
|
12
|
+
// as its host serves it and with the login state its host supplies, one worker at a time unless the runner sets
|
|
13
|
+
// PLAYWRIGHT_WORKERS.
|
|
14
|
+
|
|
15
|
+
const SERVERS = fileURLToPath(new URL("./browser-test-servers.mjs", import.meta.url));
|
|
16
|
+
const SLOWEST_TESTS = fileURLToPath(new URL("./browser-test-times.mjs", import.meta.url));
|
|
17
|
+
const SETTINGS_VARIABLE = "JSKIT_BROWSER_TESTS";
|
|
18
|
+
const READY = /^jskit browser tests: \d+ server\(s\) ready/mu;
|
|
19
|
+
const HOST = "127.0.0.1";
|
|
20
|
+
const DEFAULT_PORT = 4173;
|
|
21
|
+
// A whole run's default bound: a suite that would take hours stops instead, and says so. A suite measured to need
|
|
22
|
+
// longer passes its own globalTimeout, with the measurement beside it.
|
|
23
|
+
const GLOBAL_TIMEOUT_MS = 20 * 60_000;
|
|
24
|
+
// Building the frontend when its sources changed, preparing the databases and starting every server.
|
|
25
|
+
const SERVERS_TIMEOUT_MS = 180_000;
|
|
26
|
+
// Where the servers' launcher leaves each worker's server environment, for that worker's own process.
|
|
27
|
+
const WORKER_ENVIRONMENTS = path.join("node_modules", ".cache", "jskit-browser-tests");
|
|
28
|
+
|
|
29
|
+
const positiveInteger = (value) => (/^[1-9]\d*$/u.test(String(value ?? "").trim()) ? Number(String(value).trim()) : 0);
|
|
30
|
+
|
|
31
|
+
function managedBaseUrl(env) {
|
|
32
|
+
return String(env.PLAYWRIGHT_BASE_URL || "").trim().replace(/\/+$/u, "");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The `--workers`/`-j` count given on the Playwright command line, or 0. A percentage is refused: the servers are
|
|
37
|
+
* started before Playwright resolves it.
|
|
38
|
+
* @param {ReadonlyArray<string>} argv
|
|
39
|
+
*/
|
|
40
|
+
function commandLineWorkers(argv) {
|
|
41
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
42
|
+
const match = /^(?:--workers|-j)(?:=(.*))?$/u.exec(argv[index]);
|
|
43
|
+
if (!match) continue;
|
|
44
|
+
const value = String(match[1] ?? argv[index + 1] ?? "").trim();
|
|
45
|
+
const count = positiveInteger(value);
|
|
46
|
+
if (!count) throw new Error(`Give --workers a whole number of workers, not ${value || "nothing"}.`);
|
|
47
|
+
return count;
|
|
48
|
+
}
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* How many workers a browser run uses: the command line's `--workers`, else PLAYWRIGHT_WORKERS, else half the machine's
|
|
54
|
+
* test budget for a local run and 1 for a managed target (one server). A local worker drives a browser and its own app
|
|
55
|
+
* server, about two threads' work: with a budget of 4, the shell foundation's 9 tests took 11.5 s on 4 workers and
|
|
56
|
+
* 7.9 s on 2.
|
|
57
|
+
* @param {{ env?: Record<string, string | undefined>, argv?: ReadonlyArray<string> }} [options]
|
|
58
|
+
*/
|
|
59
|
+
function browserTestWorkers({ env = process.env, argv = process.argv } = {}) {
|
|
60
|
+
return commandLineWorkers(argv) || positiveInteger(env.PLAYWRIGHT_WORKERS) ||
|
|
61
|
+
(managedBaseUrl(env) ? 1 : Math.max(1, Math.floor(testConcurrency({ env }) / 2)));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The environment the launcher gave worker `index`'s server, from the app's prepare module, or none. */
|
|
65
|
+
function preparedWorkerEnvironment(file, index) {
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(readFileSync(file, "utf8"))[index] ?? {};
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error.code === "ENOENT") return {};
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The Playwright configuration of an application's browser tests.
|
|
76
|
+
* @param {object} [options]
|
|
77
|
+
* @param {string} [options.testDir] Where the specs are.
|
|
78
|
+
* @param {number} [options.port] The first worker's port; worker n uses port + n. PLAYWRIGHT_PORT overrides it.
|
|
79
|
+
* @param {string} [options.server] The command that starts the app server, reading PORT and HOST.
|
|
80
|
+
* @param {string} [options.build] The command that builds the production frontend into the folder the server serves.
|
|
81
|
+
* @param {string} [options.prepare] An app module, run once before the servers start, whose default export
|
|
82
|
+
* `({ workers, baseUrls })` returns each worker's server environment, such as its own DB_NAME. The worker's own
|
|
83
|
+
* process gets it too, so a test that opens the database itself reaches its own server's copy.
|
|
84
|
+
* @param {string} [options.signIn] An app module whose default export `({ request, baseURL, workerIndex, env })` signs
|
|
85
|
+
* `request`, a Playwright APIRequestContext on one worker's server (`env` is that server's environment), in once,
|
|
86
|
+
* such as with `loginRequestAsExistingUser()` from @jskit-ai/auth-web/test/playwright. Each of that worker's tests
|
|
87
|
+
* starts from the signed-in state, which its process also finds in PLAYWRIGHT_STORAGE_STATE, as on a managed target.
|
|
88
|
+
* A test that must start signed out uses `test.use({ storageState: { cookies: [], origins: [] } })`; one that signs
|
|
89
|
+
* out or ends sessions signs in on its own first, so the worker's shared session lives on.
|
|
90
|
+
* @param {Record<string, string>} [options.env] Environment every server gets, such as its test-only drivers.
|
|
91
|
+
* @param {string} [options.health] The path that answers 200 once a server is ready.
|
|
92
|
+
* @param {number} [options.globalTimeout] The whole run's bound in milliseconds (20 minutes); never 0.
|
|
93
|
+
* @param {Record<string, string | undefined>} [options.environment] Read, and given a worker's prepared environment,
|
|
94
|
+
* instead of process.env (tests).
|
|
95
|
+
* @param {string} [options.cwd] The directory Playwright runs in (tests).
|
|
96
|
+
*/
|
|
97
|
+
function browserTestConfig({
|
|
98
|
+
testDir = "./tests/e2e",
|
|
99
|
+
port = DEFAULT_PORT,
|
|
100
|
+
server = "node ./bin/server.js",
|
|
101
|
+
build = "npm run build",
|
|
102
|
+
prepare = "",
|
|
103
|
+
signIn = "",
|
|
104
|
+
env = {},
|
|
105
|
+
health = "/api/health",
|
|
106
|
+
globalTimeout = GLOBAL_TIMEOUT_MS,
|
|
107
|
+
environment = process.env,
|
|
108
|
+
cwd = process.cwd()
|
|
109
|
+
} = {}) {
|
|
110
|
+
if (!Number.isInteger(globalTimeout) || globalTimeout < 1) {
|
|
111
|
+
throw new Error("browserTestConfig() needs a globalTimeout of at least 1 ms; a run is always bounded.");
|
|
112
|
+
}
|
|
113
|
+
const managed = managedBaseUrl(environment);
|
|
114
|
+
const workers = browserTestWorkers({ env: environment });
|
|
115
|
+
const basePort = positiveInteger(environment.PLAYWRIGHT_PORT) || port;
|
|
116
|
+
// A Playwright worker loads this config again, with its own TEST_PARALLEL_INDEX, and reaches its own server; its
|
|
117
|
+
// process takes the environment prepare gave that server, such as its DB_NAME, before any test file loads.
|
|
118
|
+
const workerIndex = managed ? 0 : Number.parseInt(String(environment.TEST_PARALLEL_INDEX || "0"), 10) || 0;
|
|
119
|
+
const workerEnvironments = path.resolve(cwd, WORKER_ENVIRONMENTS, `workers-${basePort}.json`);
|
|
120
|
+
// A login state the host supplies is used as it is; the servers sign in only when there is none.
|
|
121
|
+
const signedInStates = signIn && !managed && !String(environment.PLAYWRIGHT_STORAGE_STATE || "").trim()
|
|
122
|
+
? path.resolve(cwd, WORKER_ENVIRONMENTS, `signed-in-${basePort}`)
|
|
123
|
+
: "";
|
|
124
|
+
if (!managed && environment.TEST_PARALLEL_INDEX !== undefined) {
|
|
125
|
+
Object.assign(environment, preparedWorkerEnvironment(workerEnvironments, workerIndex));
|
|
126
|
+
if (signedInStates) environment.PLAYWRIGHT_STORAGE_STATE = `${signedInStates}-${workerIndex}.json`;
|
|
127
|
+
}
|
|
128
|
+
const storageState = String(environment.PLAYWRIGHT_STORAGE_STATE || "").trim();
|
|
129
|
+
const settings = {
|
|
130
|
+
workers, host: HOST, basePort, server, build, prepare, env, health, workerEnvironments,
|
|
131
|
+
...(signedInStates ? { signIn, signedInStates } : {})
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
testDir,
|
|
136
|
+
workers,
|
|
137
|
+
globalTimeout,
|
|
138
|
+
timeout: 60_000,
|
|
139
|
+
expect: { timeout: 10_000 },
|
|
140
|
+
// Every run ends with its 10 slowest tests and their times.
|
|
141
|
+
reporter: [["list"], [SLOWEST_TESTS]],
|
|
142
|
+
use: {
|
|
143
|
+
baseURL: managed || `http://${HOST}:${basePort + workerIndex}`,
|
|
144
|
+
headless: true,
|
|
145
|
+
// A click, fill or navigation waiting on something that never comes fails in seconds, with its own error.
|
|
146
|
+
actionTimeout: 15_000,
|
|
147
|
+
navigationTimeout: 30_000,
|
|
148
|
+
// A failed test keeps its trace to read; no screencast is recorded while tests run.
|
|
149
|
+
trace: { mode: "retain-on-failure", screenshots: false, snapshots: true, sources: true },
|
|
150
|
+
...(storageState ? { storageState } : {})
|
|
151
|
+
},
|
|
152
|
+
...(managed ? {} : {
|
|
153
|
+
webServer: {
|
|
154
|
+
command: `node ${JSON.stringify(SERVERS)}`,
|
|
155
|
+
env: { [SETTINGS_VARIABLE]: JSON.stringify(settings) },
|
|
156
|
+
wait: { stdout: READY },
|
|
157
|
+
timeout: SERVERS_TIMEOUT_MS,
|
|
158
|
+
stdout: "pipe",
|
|
159
|
+
stderr: "pipe",
|
|
160
|
+
gracefulShutdown: { signal: "SIGTERM", timeout: 10_000 }
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export { SETTINGS_VARIABLE, browserTestConfig, browserTestWorkers };
|
package/scripts/jskit.mjs
CHANGED
|
@@ -42,7 +42,10 @@ function usage() {
|
|
|
42
42
|
" page without a declared parent, or a main-navigation page that declares one. It also fails when",
|
|
43
43
|
" a package script runs tests outside the machine's test budget (test/*): node --test instead of",
|
|
44
44
|
" jskit test, a fixed test concurrency or worker count, or a Playwright or Vitest config whose",
|
|
45
|
-
" workers do not come from testConcurrency() of @jskit-ai/jskit-catalog/test-budget.",
|
|
45
|
+
" workers do not come from testConcurrency() of @jskit-ai/jskit-catalog/test-budget. A Playwright",
|
|
46
|
+
" config with a webServer uses browserTestConfig() of @jskit-ai/jskit-catalog/browser-tests (one",
|
|
47
|
+
" production-build server and database per worker), never a development server, and every one",
|
|
48
|
+
" has a globalTimeout.",
|
|
46
49
|
" check m3 List application UI source that departs from the JSKIT Material 3 guide, each finding with its",
|
|
47
50
|
" guide section: the whole app, or only the given files and directories (a section, or a task's",
|
|
48
51
|
" changed files). Its visual rules (m3/*) are a report; its nav/* rules are plain jskit check's.",
|
|
@@ -53,7 +56,9 @@ function usage() {
|
|
|
53
56
|
" test Run node --test with these arguments, test files in parallel within the machine's test budget:",
|
|
54
57
|
" V64_TEST_CONCURRENCY when it is a positive whole number, otherwise half of the machine's CPUs",
|
|
55
58
|
" (at least 1). A file whose first line is `// jskit-test: alone - <reason>` runs after the others,",
|
|
56
|
-
" on its own.",
|
|
59
|
+
" on its own. With --databases, each file running at once gets its own copy of TEST_DB_NAME,",
|
|
60
|
+
" restored from a schema snapshot (the app's knexfile.js and @jskit-ai/database-runtime); with no",
|
|
61
|
+
" usable TEST_DB_NAME, the files run one at a time on the connected database.",
|
|
57
62
|
"",
|
|
58
63
|
"<locale> is a BCP 47 language tag, such as fr or pt-BR."
|
|
59
64
|
].join("\n");
|