@joystack/cli 0.0.0-stage → 0.1.43
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/CHANGELOG.md +738 -0
- package/LICENSE +202 -0
- package/README.md +21 -2
- package/bin/joystack.mjs +300 -0
- package/lib/agent-session.mjs +52 -0
- package/lib/agent-surface.mjs +333 -0
- package/lib/assets-json.mjs +205 -0
- package/lib/assets-page.mjs +184 -0
- package/lib/assets-sheet.mjs +365 -0
- package/lib/build-id.mjs +130 -0
- package/lib/bundle-primitives.mjs +123 -0
- package/lib/changelog.mjs +193 -0
- package/lib/config.mjs +56 -0
- package/lib/create.mjs +241 -0
- package/lib/dev.mjs +435 -0
- package/lib/engine.mjs +198 -0
- package/lib/feedback-sync.mjs +231 -0
- package/lib/feedback.mjs +209 -0
- package/lib/freeze.mjs +108 -0
- package/lib/game-files.mjs +99 -0
- package/lib/history.mjs +612 -0
- package/lib/inbox-cmd.mjs +57 -0
- package/lib/inject.mjs +250 -0
- package/lib/joystack-node-doors.mjs +230 -0
- package/lib/lane.mjs +141 -0
- package/lib/legacy-names.mjs +246 -0
- package/lib/lint.mjs +292 -0
- package/lib/login.mjs +134 -0
- package/lib/model-bake-cli.mjs +17 -0
- package/lib/model-bake.mjs +869 -0
- package/lib/npm.mjs +38 -0
- package/lib/ports.mjs +234 -0
- package/lib/primitives.mjs +393 -0
- package/lib/printable.mjs +12 -0
- package/lib/process-tree.mjs +30 -0
- package/lib/project.mjs +84 -0
- package/lib/publish.mjs +179 -0
- package/lib/replay-cmd.mjs +426 -0
- package/lib/replay-export.mjs +1379 -0
- package/lib/replay-list.mjs +308 -0
- package/lib/replay-profile.mjs +467 -0
- package/lib/replay-store.mjs +719 -0
- package/lib/room-reload.mjs +168 -0
- package/lib/room-server.mjs +539 -0
- package/lib/self-update.mjs +162 -0
- package/lib/shadow-resolve.mjs +85 -0
- package/lib/share.mjs +149 -0
- package/lib/smoke-gpu-preload.mjs +91 -0
- package/lib/source-path.mjs +33 -0
- package/lib/static-server.mjs +1238 -0
- package/lib/test-cmd.mjs +697 -0
- package/lib/test-policy.mjs +157 -0
- package/lib/test-reporter.mjs +177 -0
- package/lib/test-selection-cli.mjs +18 -0
- package/lib/update.mjs +299 -0
- package/package.json +27 -4
package/lib/test-cmd.mjs
ADDED
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
// `joystack test` — run the game's own verification gate.
|
|
2
|
+
//
|
|
3
|
+
// Discovers the game's test files — `*.test.mjs` (unit, run under
|
|
4
|
+
// `node --test`) and `*.smoke.mjs` (integration, run against a REAL dev
|
|
5
|
+
// stack) — and selects ordinary plus triggered extended checks. The files ARE the executable QA list: `/qa`
|
|
6
|
+
// merges this gate with handbook/qa.md and the engine's master qa.md.
|
|
7
|
+
//
|
|
8
|
+
// The smoke stack boots on a free shifted port triple so it never seizes a
|
|
9
|
+
// dev server the creator has running on :8000.
|
|
10
|
+
import { selectTests, readTestPolicy, changedTestPaths } from './test-policy.mjs';
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import {
|
|
13
|
+
existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, appendFileSync, copyFileSync, rmSync, statSync,
|
|
14
|
+
openSync, readSync, closeSync,
|
|
15
|
+
} from 'node:fs';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import nodePath, { join, relative, dirname } from 'node:path';
|
|
18
|
+
import { pathToFileURL } from 'node:url';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
21
|
+
import { findProject, resolveEngine } from './project.mjs';
|
|
22
|
+
import { dev } from './dev.mjs';
|
|
23
|
+
import { lintCmd } from './lint.mjs';
|
|
24
|
+
import { portsFor, portInUse } from './ports.mjs';
|
|
25
|
+
import { terminateChildTree } from './process-tree.mjs';
|
|
26
|
+
import { summaryLines } from './test-reporter.mjs';
|
|
27
|
+
import { leaveJoystackNodeDoors } from './joystack-node-doors.mjs';
|
|
28
|
+
import { withoutAgentSession } from './agent-session.mjs';
|
|
29
|
+
import { readEnv, stateDir, withLegacyEnv } from './legacy-names.mjs';
|
|
30
|
+
|
|
31
|
+
const SKIP_DIRS = new Set(['node_modules', 'overrides']);
|
|
32
|
+
|
|
33
|
+
// Whether a game-relative path lies under a directory discovery must ignore:
|
|
34
|
+
// dependencies (the engine ships its own scaffold smokes under node_modules),
|
|
35
|
+
// engine-override copies, and dot-directories. `path.relative()` returns
|
|
36
|
+
// backslash-separated paths on Windows, so the split accepts both separators —
|
|
37
|
+
// splitting on '/' alone yields one giant segment there and skips nothing.
|
|
38
|
+
export function isSkippedPath(rel) {
|
|
39
|
+
return rel.split(/[\\/]/).some((p) => SKIP_DIRS.has(p) || p.startsWith('.'));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The game-relative path with forward slashes on every platform, so a filter
|
|
43
|
+
// the creator types as `sub/depth` matches on Windows too. The path module is
|
|
44
|
+
// injectable so the Windows form can be produced on any host.
|
|
45
|
+
export const posixRelative = (dir, file, p = nodePath) => p.relative(dir, file).split('\\').join('/');
|
|
46
|
+
|
|
47
|
+
// The path module is injectable for the same reason: with `path.win32` the
|
|
48
|
+
// real readdir → join → relative → skip chain runs with Windows semantics
|
|
49
|
+
// against real files, on any host.
|
|
50
|
+
export function discoverTestFiles(dir, { path: p = nodePath } = {}) {
|
|
51
|
+
const unit = [];
|
|
52
|
+
const smokes = [];
|
|
53
|
+
const entries = readdirSync(dir, { withFileTypes: true, recursive: true });
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
if (!entry.isFile()) continue;
|
|
56
|
+
// `parentPath` exists from node 20.12; the 20.11 floor names the same value `path`.
|
|
57
|
+
const path = p.join(entry.parentPath ?? entry.path, entry.name);
|
|
58
|
+
if (isSkippedPath(p.relative(dir, path))) continue;
|
|
59
|
+
if (entry.name.endsWith('.test.mjs')) unit.push(path);
|
|
60
|
+
else if (entry.name.endsWith('.smoke.mjs')) smokes.push(path);
|
|
61
|
+
}
|
|
62
|
+
unit.sort();
|
|
63
|
+
smokes.sort();
|
|
64
|
+
return { unit, smokes };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Children run via async spawn, never spawnSync: the smoke dev stack's
|
|
68
|
+
// static server lives in THIS process, and a blocked event loop can't
|
|
69
|
+
// answer a child's requests — spawnSync deadlocks every smoke until its
|
|
70
|
+
// fetches time out.
|
|
71
|
+
//
|
|
72
|
+
// A child's output is forwarded through `onOutput` rather than inherited, so
|
|
73
|
+
// the runner can write the same bytes to the run's log file. `timeoutMs` is a
|
|
74
|
+
// hang detector: a child still alive after it is killed with its descendants
|
|
75
|
+
// and the run resolves to HUNG.
|
|
76
|
+
//
|
|
77
|
+
// Reaching the descendants is why the child leads its own process group
|
|
78
|
+
// (`detached`) everywhere but Windows, where `terminateChildTree` walks the
|
|
79
|
+
// tree with taskkill instead: a signal sent to a negative pid only lands when
|
|
80
|
+
// that pid is a group leader, and a hung smoke is exactly the one that left a
|
|
81
|
+
// browser or a helper server running. The price of a separate group is that
|
|
82
|
+
// Ctrl-C in the terminal no longer reaches the child by itself, so the live
|
|
83
|
+
// children are tracked and taken down when this process is told to stop.
|
|
84
|
+
//
|
|
85
|
+
// The run resolves when the child EXITS, not when its output pipes close: a
|
|
86
|
+
// process the child started can inherit those pipes and outlive it, and
|
|
87
|
+
// `close` waits for every holder. Output still in flight gets a short grace
|
|
88
|
+
// period, then the pipes are dropped. Each stream is decoded as a stream, so a
|
|
89
|
+
// character whose bytes arrive in two chunks is not turned into two
|
|
90
|
+
// replacement characters.
|
|
91
|
+
const OUTPUT_GRACE_MS = 250;
|
|
92
|
+
export const HUNG = -2;
|
|
93
|
+
|
|
94
|
+
const liveChildren = new Set();
|
|
95
|
+
let stopHandlersInstalled = false;
|
|
96
|
+
function installStopHandlers() {
|
|
97
|
+
if (stopHandlersInstalled) return;
|
|
98
|
+
stopHandlersInstalled = true;
|
|
99
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
100
|
+
process.once(signal, () => {
|
|
101
|
+
for (const child of liveChildren) { try { terminateChildTree(child, 'SIGKILL'); } catch { /* already gone */ } }
|
|
102
|
+
process.exit(signal === 'SIGINT' ? 130 : 143);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function run(args, { onOutput, timeoutMs, ...opts } = {}) {
|
|
108
|
+
installStopHandlers();
|
|
109
|
+
return new Promise((resolve) => {
|
|
110
|
+
const child = spawn(process.execPath, args, { stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32', ...opts });
|
|
111
|
+
liveChildren.add(child);
|
|
112
|
+
let hung = false;
|
|
113
|
+
const timer = timeoutMs
|
|
114
|
+
? setTimeout(() => {
|
|
115
|
+
hung = true;
|
|
116
|
+
try { terminateChildTree(child, 'SIGKILL'); } catch { child.kill('SIGKILL'); }
|
|
117
|
+
}, timeoutMs)
|
|
118
|
+
: null;
|
|
119
|
+
for (const stream of [child.stdout, child.stderr]) {
|
|
120
|
+
if (!stream) continue;
|
|
121
|
+
const decoder = new StringDecoder('utf8');
|
|
122
|
+
stream.on('data', (chunk) => {
|
|
123
|
+
const text = decoder.write(chunk);
|
|
124
|
+
if (text) (onOutput ?? ((t) => process.stdout.write(t)))(text);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
let settled = false;
|
|
128
|
+
const settle = (code) => {
|
|
129
|
+
if (settled) return;
|
|
130
|
+
settled = true;
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
liveChildren.delete(child);
|
|
133
|
+
child.stdout?.destroy();
|
|
134
|
+
child.stderr?.destroy();
|
|
135
|
+
resolve(code);
|
|
136
|
+
};
|
|
137
|
+
child.on('exit', (code) => {
|
|
138
|
+
const result = hung ? HUNG : code ?? 1;
|
|
139
|
+
child.on('close', () => settle(result));
|
|
140
|
+
setTimeout(() => settle(result), OUTPUT_GRACE_MS);
|
|
141
|
+
});
|
|
142
|
+
child.on('error', () => settle(1));
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// A smoke's normal run is seconds to a few minutes even on a slow machine
|
|
147
|
+
// without a GPU, so ten minutes only ever ends a smoke that would not have
|
|
148
|
+
// ended by itself. It is not a performance budget: a tighter value fails
|
|
149
|
+
// different smokes on different machines.
|
|
150
|
+
const DEFAULT_SMOKE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
151
|
+
// What a whole-suite run is measured against when `joystack.tests.json` names no
|
|
152
|
+
// `budgetSeconds`: long enough for a real suite, short enough that a developer
|
|
153
|
+
// still waits for it rather than working around it.
|
|
154
|
+
const DEFAULT_BUDGET_SECONDS = 300;
|
|
155
|
+
|
|
156
|
+
// The node half runs as one process, so its guard covers the whole half: long
|
|
157
|
+
// enough for a large suite on a slow machine, and still an end to a test whose
|
|
158
|
+
// promise never settles.
|
|
159
|
+
const DEFAULT_UNIT_TIMEOUT_MS = 30 * 60 * 1000;
|
|
160
|
+
|
|
161
|
+
// A timeout from the environment, in seconds. A value that is not a positive
|
|
162
|
+
// number falls back to the default AND says so: a knob that silently ignores
|
|
163
|
+
// `30s` leaves its user waiting ten minutes believing they asked for thirty
|
|
164
|
+
// seconds.
|
|
165
|
+
export function timeoutFromEnv(name, fallbackMs, { warn = (line) => console.error(line) } = {}) {
|
|
166
|
+
const raw = readEnv(name);
|
|
167
|
+
if (raw === undefined || raw === '') return fallbackMs;
|
|
168
|
+
const seconds = Number(raw);
|
|
169
|
+
if (Number.isFinite(seconds) && seconds > 0) return seconds * 1000;
|
|
170
|
+
warn(`joystack test: ${name}=${JSON.stringify(raw)} is not a positive number of seconds — using ${fallbackMs / 1000}.`);
|
|
171
|
+
return fallbackMs;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function smokeTimeoutFromEnv() {
|
|
175
|
+
return timeoutFromEnv('JOYSTACK_TEST_SMOKE_TIMEOUT', DEFAULT_SMOKE_TIMEOUT_MS);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// A test file may declare, in a comment near its top, the platforms it cannot
|
|
179
|
+
// run on and why:
|
|
180
|
+
//
|
|
181
|
+
// // skip-on: win32 — WebKit on Windows has no Web Audio
|
|
182
|
+
//
|
|
183
|
+
// Platforms are node's `process.platform` values, comma-separated. The file is
|
|
184
|
+
// then reported as skipped there instead of failing. Returns null when the
|
|
185
|
+
// file declares nothing; an empty `reason` is the caller's to reject.
|
|
186
|
+
//
|
|
187
|
+
// The line counts only inside the comment block that opens the file (`//`
|
|
188
|
+
// lines and blank lines, after an optional `#!` line). The same text further
|
|
189
|
+
// down — inside a template literal, or in a fixture a test writes — is not a
|
|
190
|
+
// declaration. The reason is split off at a dash; a hyphen only counts with
|
|
191
|
+
// whitespace on both sides, so `win32-only` stays one (unknown) platform name
|
|
192
|
+
// instead of reading as platform `win32`, reason `only`.
|
|
193
|
+
const SKIP_LINE = /^\s*\/\/\s*skip-on:\s*(.*)$/;
|
|
194
|
+
const REASON_SPLIT = /\s*[—–]\s*|\s+-\s+/;
|
|
195
|
+
const HEAD_BYTES = 2048;
|
|
196
|
+
|
|
197
|
+
// The values `process.platform` can take. A declaration naming anything else
|
|
198
|
+
// (`windows`, `macos`, `Win32`) can never match, so the file would run
|
|
199
|
+
// everywhere while its author believes it is skipped; that is refused.
|
|
200
|
+
const PLATFORMS = ['aix', 'android', 'darwin', 'freebsd', 'haiku', 'linux', 'openbsd', 'sunos', 'win32', 'cygwin', 'netbsd'];
|
|
201
|
+
|
|
202
|
+
// The `//` lines of the comment block that opens a file: everything before the
|
|
203
|
+
// first line of code, past an optional `#!` line, blank lines and `/* … */`
|
|
204
|
+
// blocks (whose contents are not yielded). Declarations are read from here
|
|
205
|
+
// and nowhere else, so text further down is never mistaken for one.
|
|
206
|
+
export function* openingCommentLines(source) {
|
|
207
|
+
let inBlock = false;
|
|
208
|
+
for (const [index, raw] of source.split(/\r?\n/).entries()) {
|
|
209
|
+
let line = raw.trim();
|
|
210
|
+
if (index === 0 && line.startsWith('#!')) continue;
|
|
211
|
+
if (inBlock) {
|
|
212
|
+
if (!line.includes('*/')) continue;
|
|
213
|
+
inBlock = false;
|
|
214
|
+
line = line.slice(line.indexOf('*/') + 2).trim();
|
|
215
|
+
}
|
|
216
|
+
while (line.startsWith('/*')) {
|
|
217
|
+
const end = line.indexOf('*/', 2);
|
|
218
|
+
if (end === -1) { inBlock = true; line = ''; break; }
|
|
219
|
+
line = line.slice(end + 2).trim();
|
|
220
|
+
}
|
|
221
|
+
if (line === '') continue;
|
|
222
|
+
if (!line.startsWith('//')) return;
|
|
223
|
+
yield line;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function skipDeclaration(source) {
|
|
228
|
+
let declared = null;
|
|
229
|
+
for (const line of openingCommentLines(source.slice(0, HEAD_BYTES))) {
|
|
230
|
+
const match = SKIP_LINE.exec(line);
|
|
231
|
+
if (match) { declared = match[1].trim(); break; }
|
|
232
|
+
}
|
|
233
|
+
if (declared === null) return null;
|
|
234
|
+
const [names, ...rest] = declared.split(REASON_SPLIT);
|
|
235
|
+
const platforms = names.split(',').map((p) => p.trim()).filter(Boolean);
|
|
236
|
+
const unknown = platforms.filter((p) => !PLATFORMS.includes(p));
|
|
237
|
+
if (unknown.length) {
|
|
238
|
+
throw new Error(`\`skip-on: ${names.trim()}\` — "${unknown.join('", "')}" is not a platform. Use node's names: win32 (Windows), darwin (macOS), linux.`);
|
|
239
|
+
}
|
|
240
|
+
return { platforms, reason: rest.join(' — ').trim() };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Why a smoke needs a browser at all, declared at the head of the file:
|
|
244
|
+
//
|
|
245
|
+
// // browser-need: layout
|
|
246
|
+
//
|
|
247
|
+
// The five words are the only things a node test cannot check: `layout` (real
|
|
248
|
+
// CSS and viewport geometry), `input` (real pointer, touch and key events),
|
|
249
|
+
// `boot` (the page loads and its modules resolve), `audio` (what the device's
|
|
250
|
+
// audio graph is doing) and `pixels` (what was actually drawn). `joystack lint`
|
|
251
|
+
// rejects a smoke that declares none or another word; the runner only counts
|
|
252
|
+
// them, so a growing number of browser smokes is visible on the run that added one.
|
|
253
|
+
export const BROWSER_NEEDS = ['layout', 'input', 'boot', 'audio', 'pixels'];
|
|
254
|
+
const BROWSER_NEED = /^\/\/\s*browser-need:\s*(.*?)\s*$/;
|
|
255
|
+
|
|
256
|
+
// Read from the comment block that opens the file, however long it is — the
|
|
257
|
+
// same place `joystack lint` requires it. A word outside the five is no
|
|
258
|
+
// declaration at all: counting `layouts` as a kind of its own would hide the
|
|
259
|
+
// typo on a scoped run, where lint does not run.
|
|
260
|
+
export function browserNeed(source) {
|
|
261
|
+
for (const line of openingCommentLines(source)) {
|
|
262
|
+
const word = BROWSER_NEED.exec(line)?.[1];
|
|
263
|
+
if (word !== undefined) return BROWSER_NEEDS.includes(word) ? word : null;
|
|
264
|
+
}
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// "layout 2 3.4s, audio 1 40.1s, undeclared 1 2.0s" — the five kinds in their
|
|
269
|
+
// fixed order, then the files that declare none of them. Each kind carries its
|
|
270
|
+
// time as well as its count, because the numerous kind is rarely the costly one.
|
|
271
|
+
function needCounts(smokes) {
|
|
272
|
+
const kinds = new Map();
|
|
273
|
+
for (const s of smokes) {
|
|
274
|
+
const kind = kinds.get(s.need ?? 'undeclared') ?? { count: 0, seconds: 0 };
|
|
275
|
+
kinds.set(s.need ?? 'undeclared', { count: kind.count + 1, seconds: kind.seconds + s.seconds });
|
|
276
|
+
}
|
|
277
|
+
return [...BROWSER_NEEDS, 'undeclared'].filter((k) => kinds.has(k))
|
|
278
|
+
.map((k) => `${k} ${kinds.get(k).count} ${kinds.get(k).seconds.toFixed(1)}s`).join(', ');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function readHead(file) {
|
|
282
|
+
const fd = openSync(file, 'r');
|
|
283
|
+
try {
|
|
284
|
+
const buffer = Buffer.alloc(HEAD_BYTES);
|
|
285
|
+
return buffer.toString('utf8', 0, readSync(fd, buffer, 0, HEAD_BYTES, 0));
|
|
286
|
+
} finally {
|
|
287
|
+
closeSync(fd);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Everything a terminal interprets rather than prints: colour and cursor
|
|
292
|
+
// sequences (including the private `?25l` forms a spinner hides the cursor
|
|
293
|
+
// with) and window-title sequences. A carriage return with no line feed is a
|
|
294
|
+
// progress line redrawing itself; in a file each redraw becomes its own line.
|
|
295
|
+
const ANSI = /\u001b(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001b]*(?:\u0007|\u001b\\))/g;
|
|
296
|
+
const plain = (text) => text.replace(ANSI, '').replace(/\r(?!\n)/g, '\n');
|
|
297
|
+
const KEPT_RUNS = 20;
|
|
298
|
+
const SCRATCH_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
299
|
+
|
|
300
|
+
// Each run gets its own pair of files under .joystack/test/ (named by start time
|
|
301
|
+
// and pid, so a background run and a foreground run in one folder never
|
|
302
|
+
// overwrite each other), and `last.json` / `last.log` are copies of the most
|
|
303
|
+
// recently finished one.
|
|
304
|
+
function openRunFiles(projectDir, startedAt) {
|
|
305
|
+
const dir = join(stateDir(projectDir), 'test');
|
|
306
|
+
mkdirSync(dir, { recursive: true });
|
|
307
|
+
const stamp = `${startedAt.toISOString().replace(/[:.]/g, '-')}-${process.pid}`;
|
|
308
|
+
const logPath = join(dir, `${stamp}.log`);
|
|
309
|
+
const verdictPath = join(dir, `${stamp}.json`);
|
|
310
|
+
writeFileSync(logPath, '');
|
|
311
|
+
const names = readdirSync(dir);
|
|
312
|
+
const old = names.filter((f) => /^\d{4}-[^.]*\.(log|json)$/.test(f)).sort();
|
|
313
|
+
for (const name of old.slice(0, Math.max(0, old.length - KEPT_RUNS * 2))) rmSync(join(dir, name), { force: true });
|
|
314
|
+
// A run that was interrupted leaves its `.unit.` scratch pair behind. Only
|
|
315
|
+
// old ones are removed: a recent pair may belong to a run still in progress.
|
|
316
|
+
for (const name of names.filter((f) => /^\d{4}-[^.]*\.unit\.(log|json)$/.test(f))) {
|
|
317
|
+
try { if (Date.now() - statSync(join(dir, name)).mtimeMs > SCRATCH_MAX_AGE_MS) rmSync(join(dir, name), { force: true }); } catch { /* gone already */ }
|
|
318
|
+
}
|
|
319
|
+
return { dir, logPath, verdictPath, unitLogPath: join(dir, `${stamp}.unit.log`), unitReportPath: join(dir, `${stamp}.unit.json`) };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function freeTestPort(base = 8100) {
|
|
323
|
+
for (let port = base; port < base + 500; port += 10) {
|
|
324
|
+
const triple = portsFor(port);
|
|
325
|
+
const used = await Promise.all(Object.values(triple).map(portInUse));
|
|
326
|
+
if (!used.some(Boolean)) return port;
|
|
327
|
+
}
|
|
328
|
+
throw new Error('no free port triple found for the test dev stack');
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Preload that GPU-offloads each smoke's browser (see smoke-gpu-preload.mjs).
|
|
332
|
+
// A file URL so `node --import` resolves it regardless of the smoke's cwd.
|
|
333
|
+
const SMOKE_PRELOAD = new URL('./smoke-gpu-preload.mjs', import.meta.url).href;
|
|
334
|
+
|
|
335
|
+
// The compact node:test reporter (see test-reporter.mjs), as a file URL for the
|
|
336
|
+
// same reason.
|
|
337
|
+
const REPORTER = new URL('./test-reporter.mjs', import.meta.url).href;
|
|
338
|
+
|
|
339
|
+
// The joystack resolver preload the installed engine ships
|
|
340
|
+
// (shared/framework/build/joystack-test-preload.mjs): registering it lets a unit
|
|
341
|
+
// test or smoke import the joystack doors (`import { openWorld } from
|
|
342
|
+
// 'joystack/test'`) at top level, with no per-file bootstrap. Null when the
|
|
343
|
+
// engine is absent (pruned by an npm install — the next dev/publish self-heals
|
|
344
|
+
// it) or too old to ship the preload — the spawn then runs without it, as
|
|
345
|
+
// before.
|
|
346
|
+
function joystackTestPreload(projectDir) {
|
|
347
|
+
try {
|
|
348
|
+
const preload = join(resolveEngine(projectDir).dir, 'shared', 'framework', 'build', 'joystack-test-preload.mjs');
|
|
349
|
+
return existsSync(preload) ? pathToFileURL(preload).href : null;
|
|
350
|
+
} catch {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// How loaded the machine is at the moment a run starts: the share of processor
|
|
356
|
+
// time spent busy over a short window, from the operating system's own counters
|
|
357
|
+
// (`os.loadavg()` is always zero on Windows, so it cannot be used). A suite that
|
|
358
|
+
// takes four times as long on a saturated machine has not become slower, and
|
|
359
|
+
// without this number in the verdict the two cases cannot be told apart later.
|
|
360
|
+
export async function sampleMachine(windowMs = 250) {
|
|
361
|
+
const read = () => os.cpus().reduce((acc, cpu) => {
|
|
362
|
+
const total = Object.values(cpu.times).reduce((a, b) => a + b, 0);
|
|
363
|
+
return { idle: acc.idle + cpu.times.idle, total: acc.total + total };
|
|
364
|
+
}, { idle: 0, total: 0 });
|
|
365
|
+
const before = read();
|
|
366
|
+
await new Promise((resolve) => setTimeout(resolve, windowMs));
|
|
367
|
+
const after = read();
|
|
368
|
+
const total = after.total - before.total;
|
|
369
|
+
const cpuBusy = total > 0 ? Number((1 - (after.idle - before.idle) / total).toFixed(2)) : 0;
|
|
370
|
+
return { cpus: os.cpus().length, cpuBusy, freeMemMB: Math.round(os.freemem() / 1048576) };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Above this share of busy processor time when the run starts, the run says so.
|
|
374
|
+
const BUSY_MACHINE = 0.5;
|
|
375
|
+
|
|
376
|
+
export async function testCmd({
|
|
377
|
+
cwd = process.cwd(), log: logLine = console.log, write: writeOut = (text) => process.stdout.write(text),
|
|
378
|
+
devFn = dev, lintFn = lintCmd, runFn = run, unitRunFn = run, patterns = [], since, all: runAll = false, list = false,
|
|
379
|
+
platform = process.platform, smokeTimeoutMs = smokeTimeoutFromEnv(), machineFn = sampleMachine,
|
|
380
|
+
unitTimeoutMs = timeoutFromEnv('JOYSTACK_TEST_UNIT_TIMEOUT', DEFAULT_UNIT_TIMEOUT_MS),
|
|
381
|
+
} = {}) {
|
|
382
|
+
const project = findProject(cwd);
|
|
383
|
+
const startedAt = new Date();
|
|
384
|
+
let runFiles = null;
|
|
385
|
+
const toLogFile = (text) => { if (runFiles) appendFileSync(runFiles.logPath, plain(text)); };
|
|
386
|
+
const log = (line = '') => { logLine(line); toLogFile(`${line}\n`); };
|
|
387
|
+
const forwardOutput = (text) => { writeOut(text); toLogFile(text); };
|
|
388
|
+
|
|
389
|
+
if ((since && runAll) || (patterns.length && (since || runAll))) throw new Error('Choose patterns, --since, or --all');
|
|
390
|
+
const scoped = patterns.length > 0;
|
|
391
|
+
const posix = (file) => posixRelative(project.dir, file);
|
|
392
|
+
|
|
393
|
+
// `--list` runs nothing, so it leaves no run files. Every other run opens
|
|
394
|
+
// them before its first line, so the log holds the run from the top.
|
|
395
|
+
if (!list) runFiles = openRunFiles(project.dir, startedAt);
|
|
396
|
+
|
|
397
|
+
const verdict = {
|
|
398
|
+
ok: false, startedAt: startedAt.toISOString(), seconds: 0, platform,
|
|
399
|
+
// How the run was launched and on what, so that two verdicts can be compared
|
|
400
|
+
// as two runs of the same thing rather than guessed at.
|
|
401
|
+
command: ['joystack test', ...patterns, ...(since ? ['--since', since] : []), ...(runAll ? ['--all'] : [])].join(' '),
|
|
402
|
+
node: process.version, machine: null, selection: null, lint: null,
|
|
403
|
+
skipped: [], unit: null, smokes: [],
|
|
404
|
+
};
|
|
405
|
+
let lint = { ok: true, skipped: true };
|
|
406
|
+
let selection = null;
|
|
407
|
+
let all, files, policy, unit, smokes;
|
|
408
|
+
|
|
409
|
+
// Every exit of a real run goes through `finish`, so a run always leaves a
|
|
410
|
+
// verdict behind — including the runs that end early. Refreshing `last.*` is
|
|
411
|
+
// best effort: a copy that fails (the file held open by an editor on Windows)
|
|
412
|
+
// must not replace the result of the run with an exception.
|
|
413
|
+
const finish = (result) => {
|
|
414
|
+
verdict.ok = result.ok;
|
|
415
|
+
verdict.seconds = Number(((Date.now() - startedAt.getTime()) / 1000).toFixed(1));
|
|
416
|
+
writeFileSync(runFiles.verdictPath, `${JSON.stringify(verdict, null, 2)}\n`);
|
|
417
|
+
for (const scratch of [runFiles.unitLogPath, runFiles.unitReportPath]) rmSync(scratch, { force: true });
|
|
418
|
+
try {
|
|
419
|
+
copyFileSync(runFiles.verdictPath, join(runFiles.dir, 'last.json'));
|
|
420
|
+
copyFileSync(runFiles.logPath, join(runFiles.dir, 'last.log'));
|
|
421
|
+
} catch (e) {
|
|
422
|
+
logLine(`⚠ could not refresh .joystack/test/last.json and last.log (${e?.message ?? e}) — this run's own files are ${relative(project.dir, runFiles.verdictPath)} and ${relative(project.dir, runFiles.logPath)}`);
|
|
423
|
+
}
|
|
424
|
+
return { ...result, lint, selection, verdictPath: runFiles.verdictPath, logPath: runFiles.logPath };
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
// Anything that throws once the run files exist — an unreadable
|
|
428
|
+
// `joystack.tests.json`, a dev stack that will not boot, no free port — is a
|
|
429
|
+
// failed run with a verdict, not an exception that leaves `last.json`
|
|
430
|
+
// describing the previous run.
|
|
431
|
+
try {
|
|
432
|
+
const listed = await prepare();
|
|
433
|
+
return listed ?? await execute();
|
|
434
|
+
} catch (e) {
|
|
435
|
+
if (!runFiles) throw e;
|
|
436
|
+
const message = String(e?.message ?? e);
|
|
437
|
+
log(`❌ joystack test could not finish: ${message}`);
|
|
438
|
+
verdict.error = message;
|
|
439
|
+
return finish({ ok: false, ran: 0 });
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async function prepare() {
|
|
443
|
+
all = discoverTestFiles(project.dir);
|
|
444
|
+
files = [...all.unit, ...all.smokes];
|
|
445
|
+
policy = readTestPolicy(project.dir, files);
|
|
446
|
+
let changed;
|
|
447
|
+
if (since) {
|
|
448
|
+
try { changed = changedTestPaths(project.dir, since); }
|
|
449
|
+
catch (error) { log(`⚠ Cannot compare with ${since}: ${error.message}. Including every extended test.`); }
|
|
450
|
+
}
|
|
451
|
+
selection = scoped
|
|
452
|
+
? { files: files.filter((f) => patterns.some((p) => posix(f).includes(p))), skipped: [], reason: 'targeted step checks' }
|
|
453
|
+
: selectTests({ dir: project.dir, files, policy, changed, all: runAll });
|
|
454
|
+
const selected = new Set(selection.files);
|
|
455
|
+
unit = all.unit.filter((f) => selected.has(f));
|
|
456
|
+
smokes = all.smokes.filter((f) => selected.has(f));
|
|
457
|
+
verdict.selection = { reason: selection.reason, selected: selected.size, of: files.length };
|
|
458
|
+
log(`▶ ${selection.reason}: ${selected.size} of ${files.length} test files`);
|
|
459
|
+
// A scoped run names its files outright, so which extended tests a broad
|
|
460
|
+
// run would defer is noise there. `--list` is the mode that explains
|
|
461
|
+
// selection, so it gives each extended test its reason; a real run says the
|
|
462
|
+
// same in one line.
|
|
463
|
+
const extended = (scoped ? [] : policy?.extended ?? []).map((entry) => ({
|
|
464
|
+
...entry, included: selection.files.some((f) => posix(f) === entry.test),
|
|
465
|
+
}));
|
|
466
|
+
if (list) {
|
|
467
|
+
for (const entry of extended) log(` ${entry.included ? 'include' : 'defer'} extended ${entry.test} — ${entry.reason}`);
|
|
468
|
+
for (const file of selection.files) log(` ${posix(file)}`);
|
|
469
|
+
return { ok: !scoped || selected.size > 0, ran: 0, selection };
|
|
470
|
+
}
|
|
471
|
+
if (extended.length) {
|
|
472
|
+
const names = (wanted) => extended.filter((e) => e.included === wanted).map((e) => e.test);
|
|
473
|
+
const part = (label, tests) => (tests.length ? `${tests.length} ${label} (${tests.join(', ')})` : `0 ${label}`);
|
|
474
|
+
log(` extended tests: ${part('included', names(true))}, ${part('deferred', names(false))} — \`--list\` gives the reason for each`);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Sampled before lint and model baking, which load the machine themselves.
|
|
478
|
+
verdict.machine = await machineFn();
|
|
479
|
+
if (verdict.machine.cpuBusy >= BUSY_MACHINE) {
|
|
480
|
+
log(`▶ this machine was ${Math.round(verdict.machine.cpuBusy * 100)}% busy when this run started (another test run, a build, a game left open?). The result is still valid; the timings are not comparable with a run on an idle machine.`);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const { ensureBaked } = await import('./model-bake.mjs');
|
|
484
|
+
await ensureBaked({ dir: project.dir, log });
|
|
485
|
+
|
|
486
|
+
// Lint FIRST — it needs no servers and no browser, and the defects it catches
|
|
487
|
+
// (a hardcoded multiplayer host, a smoke pointed at the wrong port) are
|
|
488
|
+
// exactly the ones that make the suite below pass while testing nothing.
|
|
489
|
+
// Failing here does not skip the tests: the creator should see everything
|
|
490
|
+
// that is wrong in one run, not peel it one gate at a time.
|
|
491
|
+
//
|
|
492
|
+
// A SCOPED run skips it: lint runs at the submission gate — a `joystack test <pattern>` is a focused per-step
|
|
493
|
+
// re-run, not the gate.
|
|
494
|
+
// A lint step that throws is a failed step, not the end of the run: the tests
|
|
495
|
+
// below still run and the verdict is still written.
|
|
496
|
+
if (!scoped) {
|
|
497
|
+
try {
|
|
498
|
+
lint = await lintFn({ cwd: project.dir, log });
|
|
499
|
+
} catch (e) {
|
|
500
|
+
log(`❌ joystack lint could not run: ${e?.message ?? e}`);
|
|
501
|
+
lint = { ok: false, error: String(e?.message ?? e) };
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
verdict.lint = { ok: lint.ok, skipped: Boolean(lint.skipped), ...(lint.error ? { error: lint.error } : {}) };
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async function execute() {
|
|
509
|
+
// A pattern that matches nothing FAILS LOUD. "0 tests, all green" is the exact
|
|
510
|
+
// false-pass this whole gate exists to prevent — a mistyped scope must never
|
|
511
|
+
// read as "everything's fine." (A game with genuinely no tests and no pattern
|
|
512
|
+
// is the legitimately-green fresh-game case below.)
|
|
513
|
+
if (patterns.length && unit.length + smokes.length === 0) {
|
|
514
|
+
log(`No tests matched ${patterns.map((p) => `"${p}"`).join(', ')} in ${project.dir}.`);
|
|
515
|
+
log(`(${all.unit.length + all.smokes.length} test file(s) exist; none contain that pattern. Check the spelling, or run \`joystack test\` with no pattern to run the whole suite.)`);
|
|
516
|
+
return finish({ ok: false, ran: 0 });
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (unit.length + smokes.length === 0) {
|
|
520
|
+
log(`No tests yet in ${project.dir} — nothing to run.`);
|
|
521
|
+
log('(Unit tests are *.test.mjs; browser smokes are *.smoke.mjs.)');
|
|
522
|
+
return finish({ ok: lint.ok, ran: 0 });
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (patterns.length) {
|
|
526
|
+
log(`▶ scoped to ${patterns.map((p) => `"${p}"`).join(', ')}: ${unit.length + smokes.length} of ${all.unit.length + all.smokes.length} file(s) — broader coverage runs at submission.`);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
let ok = lint.ok;
|
|
530
|
+
|
|
531
|
+
// Platform skips, declared in the files themselves. A declaration with no
|
|
532
|
+
// reason fails the run: an unexplained skip is indistinguishable from a
|
|
533
|
+
// test somebody switched off because it was red.
|
|
534
|
+
const runsHere = (file) => {
|
|
535
|
+
let declared;
|
|
536
|
+
try {
|
|
537
|
+
declared = skipDeclaration(readHead(file));
|
|
538
|
+
} catch (e) {
|
|
539
|
+
log(` ❌ ${posix(file)} — ${e.message}`);
|
|
540
|
+
ok = false;
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
if (!declared || !declared.platforms.includes(platform)) return true;
|
|
544
|
+
if (!declared.reason) {
|
|
545
|
+
log(` ❌ ${posix(file)} — its \`// skip-on:\` line gives no reason. Write why it cannot run on ${platform}: \`// skip-on: ${platform} — <reason>\``);
|
|
546
|
+
ok = false;
|
|
547
|
+
return false;
|
|
548
|
+
}
|
|
549
|
+
log(` ⏭ ${posix(file)} — skipped on ${platform}: ${declared.reason}`);
|
|
550
|
+
verdict.skipped.push({ file: posix(file), reason: declared.reason });
|
|
551
|
+
return false;
|
|
552
|
+
};
|
|
553
|
+
const unitHere = unit.filter(runsHere);
|
|
554
|
+
const smokesHere = smokes.filter(runsHere);
|
|
555
|
+
|
|
556
|
+
// The joystack resolver preload, if the installed engine ships it (see above).
|
|
557
|
+
const joystackPreload = joystackTestPreload(project.dir);
|
|
558
|
+
const joystackImport = joystackPreload ? ['--import', joystackPreload] : [];
|
|
559
|
+
|
|
560
|
+
// The other half of the same problem: the preload above is what makes `joystack
|
|
561
|
+
// test` able to run these files, and its absence is what makes running one by
|
|
562
|
+
// hand fail with `Cannot find package 'joystack'`. Leave the package that answers
|
|
563
|
+
// that (joystack-node-doors.mjs) while the engine is known — `test` is the
|
|
564
|
+
// command whose files get run by hand, and it does not go through
|
|
565
|
+
// ensureEngine.
|
|
566
|
+
try { leaveJoystackNodeDoors(project.dir, resolveEngine(project.dir)); } catch { /* no engine resolved; nothing to point at */ }
|
|
567
|
+
|
|
568
|
+
// If joystack test itself runs under a node --test parent, the inherited
|
|
569
|
+
// NODE_TEST_CONTEXT makes a nested `node --test` exit 0 even on failure —
|
|
570
|
+
// silently green. Strip it for every child. The agent session's address is
|
|
571
|
+
// stripped too: a test that starts its own `joystack dev` must never post
|
|
572
|
+
// into the conversation of the agent running the tests.
|
|
573
|
+
const env = withoutAgentSession(process.env);
|
|
574
|
+
delete env.NODE_TEST_CONTEXT;
|
|
575
|
+
|
|
576
|
+
if (unitHere.length) {
|
|
577
|
+
log(`▶ node tests: ${unitHere.length} file(s)`);
|
|
578
|
+
// Two reporters on one run: the compact one to the console, node's full
|
|
579
|
+
// `spec` listing to a file that is appended to the run's log afterwards.
|
|
580
|
+
const reporters = [
|
|
581
|
+
'--test-reporter', REPORTER, '--test-reporter-destination', 'stdout',
|
|
582
|
+
'--test-reporter', 'spec', '--test-reporter-destination', runFiles.unitLogPath,
|
|
583
|
+
];
|
|
584
|
+
const unitStarted = performance.now();
|
|
585
|
+
const code = await unitRunFn([...joystackImport, '--test', ...reporters, ...unitHere], {
|
|
586
|
+
cwd: project.dir, env: { ...env, ...withLegacyEnv({ JOYSTACK_TEST_UNIT_REPORT: runFiles.unitReportPath }) }, onOutput: forwardOutput,
|
|
587
|
+
timeoutMs: unitTimeoutMs,
|
|
588
|
+
});
|
|
589
|
+
if (code !== 0) ok = false;
|
|
590
|
+
const unitHung = code === HUNG;
|
|
591
|
+
if (unitHung) log(` ❌ node tests — HUNG: still running after ${Math.round(unitTimeoutMs / 1000)}s, killed. Some test never ends: a promise that never settles, a timer or a server left open. The full log lists the tests that did finish; the one after them is where to look.`);
|
|
592
|
+
if (existsSync(runFiles.unitLogPath)) toLogFile(`\n── node tests, full listing ──\n${readFileSync(runFiles.unitLogPath, 'utf8')}\n`);
|
|
593
|
+
// Wall time is taken here, around the whole child: the reporter only sees
|
|
594
|
+
// the span between its first and last event, which leaves out node's own
|
|
595
|
+
// start-up and the loading of every test file.
|
|
596
|
+
const unitSeconds = Number(((performance.now() - unitStarted) / 1000).toFixed(1));
|
|
597
|
+
if (existsSync(runFiles.unitReportPath)) verdict.unit = { ...JSON.parse(readFileSync(runFiles.unitReportPath, 'utf8')), seconds: unitSeconds };
|
|
598
|
+
// The child died before its reporter could write (a crash in a preload,
|
|
599
|
+
// an out-of-memory kill): the exit code is all that is known.
|
|
600
|
+
else verdict.unit = { tests: 0, pass: 0, fail: 0, skip: 0, todo: 0, failed: [], skipped: [], todos: [], files: [], crashed: !unitHung, exitCode: code, seconds: unitSeconds };
|
|
601
|
+
if (unitHung) verdict.unit.hung = true;
|
|
602
|
+
// node exits 0 for a file that ended itself before reporting a test; the
|
|
603
|
+
// reporter counts that as a failure, and the count is what decides.
|
|
604
|
+
if (verdict.unit.fail > 0) ok = false;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (smokesHere.length) {
|
|
608
|
+
// Browser preflight — only when a smoke actually imports playwright (a
|
|
609
|
+
// plain fetch-probe smoke needs none, and test fixtures must stay
|
|
610
|
+
// hermetic). The scaffold ships playwright and `joystack create` pre-fetches
|
|
611
|
+
// Chromium; this self-heals older repos and pruned caches so a smoke
|
|
612
|
+
// never dies into a cryptic mid-run download.
|
|
613
|
+
if (smokesHere.some((f) => /['"]playwright/.test(readFileSync(f, 'utf8')))) {
|
|
614
|
+
let playwrightCli;
|
|
615
|
+
try {
|
|
616
|
+
playwrightCli = join(
|
|
617
|
+
dirname(createRequire(join(project.dir, 'package.json')).resolve('playwright/package.json')),
|
|
618
|
+
'cli.js',
|
|
619
|
+
);
|
|
620
|
+
} catch {
|
|
621
|
+
log('❌ a smoke imports playwright, which is not installed in this game —');
|
|
622
|
+
log(' run: npm i -D playwright (then re-run `joystack test`)');
|
|
623
|
+
return finish({ ok: false, ran: unitHere.length });
|
|
624
|
+
}
|
|
625
|
+
// Instant no-op when the browser is already cached; downloads otherwise.
|
|
626
|
+
if ((await run([playwrightCli, 'install', 'chromium'], { cwd: project.dir, env, onOutput: forwardOutput })) !== 0) {
|
|
627
|
+
log('❌ could not fetch the Chromium test browser (offline?) — smokes need it.');
|
|
628
|
+
return finish({ ok: false, ran: unitHere.length });
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const handle = await devFn({ cwd: project.dir, port: await freeTestPort(), watch: false, log: () => {} });
|
|
633
|
+
log(`▶ smokes against ${handle.url}`);
|
|
634
|
+
try {
|
|
635
|
+
for (const file of smokesHere) {
|
|
636
|
+
const rel = posix(file);
|
|
637
|
+
const started = performance.now();
|
|
638
|
+
log(` ▶ ${rel}`);
|
|
639
|
+
const code = await runFn(['--import', SMOKE_PRELOAD, ...joystackImport, file], {
|
|
640
|
+
cwd: project.dir,
|
|
641
|
+
onOutput: forwardOutput,
|
|
642
|
+
timeoutMs: smokeTimeoutMs,
|
|
643
|
+
env: {
|
|
644
|
+
...env,
|
|
645
|
+
...withLegacyEnv({ JOYSTACK_TEST_GAME_URL: handle.url }),
|
|
646
|
+
// A legacy alias for the same URL, exported so smokes written
|
|
647
|
+
// against the older convention keep running unedited.
|
|
648
|
+
GAMEDEV_TEST_GAME_URL: handle.url,
|
|
649
|
+
URL: handle.url,
|
|
650
|
+
},
|
|
651
|
+
});
|
|
652
|
+
const seconds = Number(((performance.now() - started) / 1000).toFixed(2));
|
|
653
|
+
const hung = code === HUNG;
|
|
654
|
+
if (hung) log(` ❌ ${rel} — HUNG: still running after ${Math.round(smokeTimeoutMs / 1000)}s, killed. A smoke must end by itself; look for a wait loop whose condition can never become true. (${seconds.toFixed(2)}s)`);
|
|
655
|
+
else log(` ${code === 0 ? '✅' : '❌'} ${rel} (${seconds.toFixed(2)}s)`);
|
|
656
|
+
verdict.smokes.push({ file: rel, ok: code === 0, seconds, need: browserNeed(readFileSync(file, 'utf8')), ...(hung ? { hung: true } : {}) });
|
|
657
|
+
if (code !== 0) ok = false;
|
|
658
|
+
}
|
|
659
|
+
} finally {
|
|
660
|
+
// The smokes have their results by now; a stack that fails to stop is
|
|
661
|
+
// reported, and does not replace them.
|
|
662
|
+
try { await handle.stop(); } catch (e) { log(`⚠ the test dev stack did not stop cleanly: ${e?.message ?? e}`); }
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
log('');
|
|
667
|
+
const smokeSeconds = verdict.smokes.reduce((sum, s) => sum + s.seconds, 0);
|
|
668
|
+
const failedSmokes = verdict.smokes.filter((s) => !s.ok);
|
|
669
|
+
if (verdict.unit && !verdict.unit.crashed) for (const line of summaryLines(verdict.unit, { seconds: verdict.unit.seconds })) log(line);
|
|
670
|
+
if (verdict.smokes.length) log(` smokes: ${verdict.smokes.length} run — ${verdict.smokes.length - failedSmokes.length} passed, ${failedSmokes.length} failed in ${smokeSeconds.toFixed(1)}s (${needCounts(verdict.smokes)})`);
|
|
671
|
+
if (verdict.skipped.length) log(` skipped on ${platform}: ${verdict.skipped.map((s) => s.file).join(', ')}`);
|
|
672
|
+
// The total is measured against a budget on every run that is not scoped to
|
|
673
|
+
// a pattern. Going over never fails the run: the budget exists so that the
|
|
674
|
+
// cost of the suite is in front of whoever is about to add another file to it.
|
|
675
|
+
if (!scoped) {
|
|
676
|
+
const budgetSeconds = policy?.budgetSeconds ?? DEFAULT_BUDGET_SECONDS;
|
|
677
|
+
const total = (Date.now() - startedAt.getTime()) / 1000;
|
|
678
|
+
verdict.budgetSeconds = budgetSeconds;
|
|
679
|
+
verdict.overBudget = total > budgetSeconds;
|
|
680
|
+
log(verdict.overBudget
|
|
681
|
+
? ` total: ${total.toFixed(1)}s of a ${budgetSeconds}s budget — over by ${(total - budgetSeconds).toFixed(0)}s. Before adding a test file: put the check in an existing file, move a smoke's check into a node test, or delete a test that no longer guards anything. To bring the whole suite back down, run /compress-tests — the costs that belong to no single test are only visible from there.`
|
|
682
|
+
: ` total: ${total.toFixed(1)}s of a ${budgetSeconds}s budget`);
|
|
683
|
+
}
|
|
684
|
+
if (!ok) {
|
|
685
|
+
if (verdict.unit?.crashed) log(` ✖ node tests: the test process ended (exit ${verdict.unit.exitCode}) before reporting a single result — the full log has whatever it printed`);
|
|
686
|
+
if (verdict.unit?.hung) log(' ✖ node tests (hung)');
|
|
687
|
+
for (const f of verdict.unit?.failed ?? []) log(` ✖ ${f.file} › ${f.name}`);
|
|
688
|
+
for (const s of failedSmokes) log(` ✖ ${s.file}${s.hung ? ' (hung)' : ''}`);
|
|
689
|
+
if (!lint.ok) log(' ✖ lint');
|
|
690
|
+
}
|
|
691
|
+
log(ok
|
|
692
|
+
? `✅ joystack test: all green (${unitHere.length} node test file(s), ${smokesHere.length} smoke(s))`
|
|
693
|
+
: '❌ joystack test: failed — the list above is complete');
|
|
694
|
+
log(` full log: ${relative(project.dir, runFiles.logPath)} verdict: ${relative(project.dir, runFiles.verdictPath)}`);
|
|
695
|
+
return finish({ ok, ran: unitHere.length + smokesHere.length });
|
|
696
|
+
}
|
|
697
|
+
}
|