@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +738 -0
  2. package/LICENSE +202 -0
  3. package/README.md +21 -2
  4. package/bin/joystack.mjs +300 -0
  5. package/lib/agent-session.mjs +52 -0
  6. package/lib/agent-surface.mjs +333 -0
  7. package/lib/assets-json.mjs +205 -0
  8. package/lib/assets-page.mjs +184 -0
  9. package/lib/assets-sheet.mjs +365 -0
  10. package/lib/build-id.mjs +130 -0
  11. package/lib/bundle-primitives.mjs +123 -0
  12. package/lib/changelog.mjs +193 -0
  13. package/lib/config.mjs +56 -0
  14. package/lib/create.mjs +241 -0
  15. package/lib/dev.mjs +435 -0
  16. package/lib/engine.mjs +198 -0
  17. package/lib/feedback-sync.mjs +231 -0
  18. package/lib/feedback.mjs +209 -0
  19. package/lib/freeze.mjs +108 -0
  20. package/lib/game-files.mjs +99 -0
  21. package/lib/history.mjs +612 -0
  22. package/lib/inbox-cmd.mjs +57 -0
  23. package/lib/inject.mjs +250 -0
  24. package/lib/joystack-node-doors.mjs +230 -0
  25. package/lib/lane.mjs +141 -0
  26. package/lib/legacy-names.mjs +246 -0
  27. package/lib/lint.mjs +292 -0
  28. package/lib/login.mjs +134 -0
  29. package/lib/model-bake-cli.mjs +17 -0
  30. package/lib/model-bake.mjs +869 -0
  31. package/lib/npm.mjs +38 -0
  32. package/lib/ports.mjs +234 -0
  33. package/lib/primitives.mjs +393 -0
  34. package/lib/printable.mjs +12 -0
  35. package/lib/process-tree.mjs +30 -0
  36. package/lib/project.mjs +84 -0
  37. package/lib/publish.mjs +179 -0
  38. package/lib/replay-cmd.mjs +426 -0
  39. package/lib/replay-export.mjs +1379 -0
  40. package/lib/replay-list.mjs +308 -0
  41. package/lib/replay-profile.mjs +467 -0
  42. package/lib/replay-store.mjs +719 -0
  43. package/lib/room-reload.mjs +168 -0
  44. package/lib/room-server.mjs +539 -0
  45. package/lib/self-update.mjs +162 -0
  46. package/lib/shadow-resolve.mjs +85 -0
  47. package/lib/share.mjs +149 -0
  48. package/lib/smoke-gpu-preload.mjs +91 -0
  49. package/lib/source-path.mjs +33 -0
  50. package/lib/static-server.mjs +1238 -0
  51. package/lib/test-cmd.mjs +697 -0
  52. package/lib/test-policy.mjs +157 -0
  53. package/lib/test-reporter.mjs +177 -0
  54. package/lib/test-selection-cli.mjs +18 -0
  55. package/lib/update.mjs +299 -0
  56. package/package.json +27 -4
@@ -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
+ }