@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
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Extended tests are opt-in declarations; every undeclared test stays ordinary.
|
|
2
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { join, relative, resolve } from 'node:path';
|
|
5
|
+
import { STATE_DIR_NAMES, TEST_POLICY_FILE, testPolicyFile } from './legacy-names.mjs';
|
|
6
|
+
|
|
7
|
+
// The policy file's name, as the game being read spells it (testPolicyFile);
|
|
8
|
+
// set by readTestPolicy so every error names the file the creator has.
|
|
9
|
+
let POLICY = TEST_POLICY_FILE;
|
|
10
|
+
const posix = (s) => s.replaceAll('\\', '/');
|
|
11
|
+
|
|
12
|
+
function matcher(pattern) {
|
|
13
|
+
if (typeof pattern !== 'string' || !pattern || pattern.startsWith('/') || pattern.includes('\\') || pattern.split('/').includes('..') || /[\[\]{}]/.test(pattern)) {
|
|
14
|
+
throw new Error(`${POLICY}: patterns must be relative paths using /, *, ** or ?`);
|
|
15
|
+
}
|
|
16
|
+
let source = '^';
|
|
17
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
18
|
+
const c = pattern[i];
|
|
19
|
+
if (c === '*' && pattern[i + 1] === '*') {
|
|
20
|
+
i++;
|
|
21
|
+
if (pattern[i + 1] === '/') { i++; source += '(?:.*/)?'; }
|
|
22
|
+
else source += '.*';
|
|
23
|
+
} else if (c === '*') source += '[^/]*';
|
|
24
|
+
else if (c === '?') source += '[^/]';
|
|
25
|
+
else source += c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
26
|
+
}
|
|
27
|
+
return new RegExp(source + '$');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function patterns(value, label, { nonempty = false } = {}) {
|
|
31
|
+
if (!Array.isArray(value) || (nonempty && !value.length)) throw new Error(`${POLICY}: ${label} must be ${nonempty ? 'a nonempty' : 'an'} array`);
|
|
32
|
+
return value.map(matcher);
|
|
33
|
+
}
|
|
34
|
+
const matches = (path, rules) => rules.some((rule) => rule.test(path));
|
|
35
|
+
|
|
36
|
+
export function readTestPolicy(dir, files) {
|
|
37
|
+
POLICY = testPolicyFile(dir);
|
|
38
|
+
const path = join(dir, POLICY);
|
|
39
|
+
if (!existsSync(path)) return null;
|
|
40
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
41
|
+
if (raw.version !== 1 || !Array.isArray(raw.extended)) throw new Error(`${POLICY}: expected version 1 and an extended array`);
|
|
42
|
+
const allowed = ['version', 'extended', 'ordinaryChanges', 'always', 'budgetSeconds'];
|
|
43
|
+
if (Object.keys(raw).some((k) => !allowed.includes(k))) throw new Error(`${POLICY}: unknown top-level field`);
|
|
44
|
+
if ('budgetSeconds' in raw && !(typeof raw.budgetSeconds === 'number' && raw.budgetSeconds > 0)) throw new Error(`${POLICY}: budgetSeconds must be a positive number of seconds`);
|
|
45
|
+
const known = new Set(files.map((f) => posix(relative(dir, f))));
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
const extended = raw.extended.map((entry) => {
|
|
48
|
+
if (!entry || typeof entry.test !== 'string' || !known.has(entry.test) || seen.has(entry.test)) throw new Error(`${POLICY}: extended test is missing or duplicated: ${entry?.test}`);
|
|
49
|
+
if (Object.keys(entry).some((k) => !['test', 'when', 'reason'].includes(k))) throw new Error(`${POLICY}: unknown field for ${entry.test}`);
|
|
50
|
+
if (typeof entry.reason !== 'string' || !entry.reason.trim()) throw new Error(`${POLICY}: ${entry.test} needs a reason`);
|
|
51
|
+
seen.add(entry.test);
|
|
52
|
+
return { ...entry, rules: patterns(entry.when, `${entry.test}.when`, { nonempty: true }) };
|
|
53
|
+
});
|
|
54
|
+
return {
|
|
55
|
+
extended,
|
|
56
|
+
ordinary: patterns(raw.ordinaryChanges ?? [], 'ordinaryChanges'),
|
|
57
|
+
always: patterns(raw.always ?? [], 'always'),
|
|
58
|
+
budgetSeconds: raw.budgetSeconds,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Name-only without rename detection returns both sides of moves. NUL delimiters
|
|
63
|
+
// preserve spaces/newlines, and -- separates paths from command-line options.
|
|
64
|
+
export function changedTestPaths(dir, since) {
|
|
65
|
+
const git = (args) => execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
66
|
+
if (!since || since.startsWith('-')) throw new Error('test --since needs an integration base, not an option');
|
|
67
|
+
const root = realpathSync.native(git(['rev-parse', '--show-toplevel']).trim());
|
|
68
|
+
const projectDir = realpathSync.native(dir);
|
|
69
|
+
const base = git(['merge-base', 'HEAD', since]).trim();
|
|
70
|
+
const changed = git(['-C', root, 'diff', '--no-relative', '--name-only', '--no-renames', '-z', base, '--']);
|
|
71
|
+
const untracked = git(['-C', root, 'ls-files', '--others', '--exclude-standard', '-z']);
|
|
72
|
+
// The runner's own output under `.joystack/` is never a change to the game. A
|
|
73
|
+
// game whose .gitignore does not list it would otherwise see this run's log
|
|
74
|
+
// as an unmapped change and widen every run to the whole suite.
|
|
75
|
+
return [...new Set((changed + untracked).split('\0').filter(Boolean).map((p) => posix(relative(projectDir, resolve(root, p)))))]
|
|
76
|
+
.filter((p) => !STATE_DIR_NAMES.some((d) => p.startsWith(`${d}/`)));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function selectTests({ dir, files, policy, changed, all = false }) {
|
|
80
|
+
if (!policy || all) return { files, skipped: [], reason: all ? 'explicit --all audit' : 'all tests are ordinary (no policy)' };
|
|
81
|
+
if (!changed) return { files, skipped: [], reason: 'no integration base; including all extended tests (use --since <base> at submission)' };
|
|
82
|
+
const extended = new Map(policy.extended.map((entry) => [entry.test, entry]));
|
|
83
|
+
const testPaths = new Set(files.map((f) => posix(relative(dir, f))));
|
|
84
|
+
// A changed test file is never an unmapped change, whether or not it still
|
|
85
|
+
// exists: a deleted or renamed test is absent from `files`, so it is
|
|
86
|
+
// recognised by name. Nothing imports a test file, so removing one cannot
|
|
87
|
+
// affect any other test.
|
|
88
|
+
const isTest = (p) => /\.(test|smoke)\.mjs$/.test(p);
|
|
89
|
+
const policyFile = testPolicyFile(dir);
|
|
90
|
+
const broad = changed.find((p) => p === policyFile || matches(p, policy.always) || (!isTest(p) && !matches(p, policy.ordinary) && !policy.extended.some((e) => matches(p, e.rules))));
|
|
91
|
+
if (broad) return { files, skipped: [], reason: `broad coverage: ${broad} is a foundation, policy, or unmapped change` };
|
|
92
|
+
const selected = files.filter((file) => {
|
|
93
|
+
const path = posix(relative(dir, file));
|
|
94
|
+
const entry = extended.get(path);
|
|
95
|
+
return !entry || changed.some((p) => p === path || matches(p, entry.rules));
|
|
96
|
+
});
|
|
97
|
+
const selectedSet = new Set(selected);
|
|
98
|
+
return { files: selected, skipped: files.filter((f) => !selectedSet.has(f)), reason: 'ordinary regressions plus extended tests triggered by changes' };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const TEST_HELP = `joystack test — run this game's checks
|
|
102
|
+
|
|
103
|
+
Usage:
|
|
104
|
+
joystack test <aspect> [<aspect>…] Run the test files whose path contains <aspect> (e.g. \`joystack test boot\`
|
|
105
|
+
runs boot.test.mjs and boot.smoke.mjs). The way to check one thing while
|
|
106
|
+
you work; skips lint.
|
|
107
|
+
joystack test --since <base> Submission: ordinary tests plus the extended ones your changes since
|
|
108
|
+
<base> trigger (joystack.tests.json), and lint.
|
|
109
|
+
joystack test Everything not deferred by joystack.tests.json, and lint.
|
|
110
|
+
joystack test --all Every test file, whatever joystack.tests.json says.
|
|
111
|
+
joystack test … --list Print what would run, and stop.
|
|
112
|
+
|
|
113
|
+
Test files:
|
|
114
|
+
*.test.mjs node tests (node:test). Run together, reported per file.
|
|
115
|
+
*.smoke.mjs browser smokes. Run one at a time against a dev stack this command starts and stops.
|
|
116
|
+
Both can import the joystack doors (\`import { openWorld } from 'joystack/test'\`). That import only resolves
|
|
117
|
+
when the file is run by this command — \`node --test x.test.mjs\` fails with "Cannot find package 'joystack'".
|
|
118
|
+
|
|
119
|
+
Reading the result:
|
|
120
|
+
The exit code is 0 only when everything passed. The console shows failures in full and passing tests
|
|
121
|
+
as counts. Every run also writes .joystack/test/last.json (the verdict: what failed, what was skipped,
|
|
122
|
+
seconds per file) and .joystack/test/last.log (the complete output). Read those instead of piping this
|
|
123
|
+
command through \`tail\` or \`grep\`: a pipe replaces the exit code with its own and cuts the diagnosis.
|
|
124
|
+
|
|
125
|
+
A whole-suite run ends with its total against a budget: 300 seconds, or "budgetSeconds" in joystack.tests.json.
|
|
126
|
+
Going over never fails the run; it recommends /compress-tests, the pass over the whole suite.
|
|
127
|
+
|
|
128
|
+
A test that cannot run on some platform says so at the top of its file, and is reported as skipped there:
|
|
129
|
+
// skip-on: win32 — WebKit on Windows has no Web Audio
|
|
130
|
+
|
|
131
|
+
Environment:
|
|
132
|
+
JOYSTACK_TEST_SMOKE_TIMEOUT=<seconds> Kill a smoke still running after this long (default 600). A hang
|
|
133
|
+
detector, not a speed limit.
|
|
134
|
+
JOYSTACK_TEST_UNIT_TIMEOUT=<seconds> The same for the node tests as a whole (default 1800).
|
|
135
|
+
JOYSTACK_TEST_NO_GPU=1 Run smoke browsers on software rendering.
|
|
136
|
+
JOYSTACK_TEST_ANGLE=<backend> Graphics backend for smoke browsers (Windows defaults to d3d11;
|
|
137
|
+
\`default\` lets the browser choose).
|
|
138
|
+
`;
|
|
139
|
+
|
|
140
|
+
export function parseTestArgs(args) {
|
|
141
|
+
const options = { patterns: [], all: false, list: false };
|
|
142
|
+
// Asking for help is never an error, whatever else is on the line.
|
|
143
|
+
if (args.includes('--help') || args.includes('-h')) return { ...options, help: true };
|
|
144
|
+
for (let i = 0; i < args.length; i++) {
|
|
145
|
+
const arg = args[i];
|
|
146
|
+
if (arg === '--all') options.all = true;
|
|
147
|
+
else if (arg === '--list') options.list = true;
|
|
148
|
+
else if (arg === '--since') {
|
|
149
|
+
const value = args[++i];
|
|
150
|
+
if (!value || value.startsWith('-')) throw new Error('test --since requires a git integration base');
|
|
151
|
+
options.since = value;
|
|
152
|
+
} else if (arg.startsWith('-')) throw new Error(`Unknown test option: ${arg}`);
|
|
153
|
+
else options.patterns.push(arg);
|
|
154
|
+
}
|
|
155
|
+
if ((options.all && options.since) || (options.patterns.length && (options.all || options.since))) throw new Error('Choose test <pattern>, test --since <base>, or test --all');
|
|
156
|
+
return options;
|
|
157
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// The node:test reporter `joystack test` runs its unit half with.
|
|
2
|
+
//
|
|
3
|
+
// It prints failures in full and everything else as counts, so the output of a
|
|
4
|
+
// two-thousand-test suite fits on a screen and nobody needs to pipe it through
|
|
5
|
+
// `tail` to read it. The complete per-test listing still exists: the runner
|
|
6
|
+
// attaches node's own `spec` reporter to a file alongside this one.
|
|
7
|
+
//
|
|
8
|
+
// It also records what the default reporters do not: which FILE each result
|
|
9
|
+
// belongs to and how long each file took. The record is written as JSON to the
|
|
10
|
+
// path in JOYSTACK_TEST_UNIT_REPORT when the run ends; the runner folds it into
|
|
11
|
+
// the verdict file.
|
|
12
|
+
import { writeFileSync, realpathSync } from 'node:fs';
|
|
13
|
+
import { relative, isAbsolute } from 'node:path';
|
|
14
|
+
import { readEnv } from './legacy-names.mjs';
|
|
15
|
+
|
|
16
|
+
const MAX_ERROR_LINES = 24;
|
|
17
|
+
const SLOWEST_SHOWN = 5;
|
|
18
|
+
|
|
19
|
+
// Events for one file can name it through different spellings of the same
|
|
20
|
+
// path (macOS reports /var/… in one event and /private/var/… in another), so
|
|
21
|
+
// both sides are resolved before the relative path is taken.
|
|
22
|
+
const real = (path) => { try { return realpathSync.native(path); } catch { return path; } };
|
|
23
|
+
|
|
24
|
+
// node names a whole-file result after the path the file was given by on the
|
|
25
|
+
// command line, which may be relative or absolute.
|
|
26
|
+
const isWholeFile = (data) => Boolean(data.file) && data.nesting === 0
|
|
27
|
+
&& (data.name === data.file || real(data.name) === real(data.file) || data.file.endsWith(`/${data.name}`) || data.file.endsWith(`\\${data.name}`));
|
|
28
|
+
|
|
29
|
+
function rel(file, root) {
|
|
30
|
+
if (!file) return '(unknown file)';
|
|
31
|
+
const path = isAbsolute(file) ? relative(real(root), real(file)) : file;
|
|
32
|
+
return path.split('\\').join('/');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// node wraps an assertion thrown inside a test in an ERR_TEST_FAILURE error
|
|
36
|
+
// whose `cause` is the original; the original carries the message a reader wants.
|
|
37
|
+
function describeError(error) {
|
|
38
|
+
const cause = error?.cause ?? error;
|
|
39
|
+
const message = String(cause?.message ?? cause ?? 'test failed');
|
|
40
|
+
const stack = typeof cause?.stack === 'string' ? cause.stack : '';
|
|
41
|
+
// Frames inside node itself say nothing about the game and push the frames
|
|
42
|
+
// that do off the screen; the full log keeps them.
|
|
43
|
+
const lines = (stack.includes(message) ? stack : `${message}\n${stack}`).split('\n')
|
|
44
|
+
.filter((line) => !/^\s*at .*\(?node:/.test(line));
|
|
45
|
+
const shown = lines.slice(0, MAX_ERROR_LINES);
|
|
46
|
+
if (lines.length > shown.length) shown.push(`… ${lines.length - shown.length} more line(s) in the full log`);
|
|
47
|
+
return { message, text: shown.join('\n') };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createCollector(root = process.cwd()) {
|
|
51
|
+
const files = new Map();
|
|
52
|
+
const failed = [];
|
|
53
|
+
const skipped = [];
|
|
54
|
+
const todos = [];
|
|
55
|
+
// Counts are singular (`fail`, `skip`); the lists of what failed and what was
|
|
56
|
+
// skipped take the past participle, so the two never share a key.
|
|
57
|
+
const totals = { tests: 0, pass: 0, fail: 0, skip: 0, todo: 0 };
|
|
58
|
+
|
|
59
|
+
const fileEntry = (file) => {
|
|
60
|
+
const key = rel(file, root);
|
|
61
|
+
if (!files.has(key)) files.set(key, { file: key, ms: 0, tests: 0, fail: 0, measured: false });
|
|
62
|
+
return files.get(key);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function record(type, data) {
|
|
66
|
+
// A `describe` block reports as an event of its own; counting it would
|
|
67
|
+
// count its tests twice.
|
|
68
|
+
if (data.details?.type === 'suite') return null;
|
|
69
|
+
const entry = fileEntry(data.file);
|
|
70
|
+
const duration = Number(data.details?.duration_ms) || 0;
|
|
71
|
+
// Top-level durations already include their subtests. A file's time is the
|
|
72
|
+
// sum of its top-level tests unless node reports a per-file figure (below).
|
|
73
|
+
if (data.nesting === 0 && !entry.measured) entry.ms += duration;
|
|
74
|
+
totals.tests += 1;
|
|
75
|
+
entry.tests += 1;
|
|
76
|
+
if (data.skip !== undefined && data.skip !== false) {
|
|
77
|
+
totals.skip += 1;
|
|
78
|
+
const item = { file: entry.file, name: data.name, reason: typeof data.skip === 'string' ? data.skip : '' };
|
|
79
|
+
skipped.push(item);
|
|
80
|
+
return { kind: 'skip', item };
|
|
81
|
+
}
|
|
82
|
+
if (data.todo !== undefined && data.todo !== false) {
|
|
83
|
+
totals.todo += 1;
|
|
84
|
+
const item = { file: entry.file, name: data.name, reason: typeof data.todo === 'string' ? data.todo : '' };
|
|
85
|
+
todos.push(item);
|
|
86
|
+
return { kind: 'todo', item };
|
|
87
|
+
}
|
|
88
|
+
// node reports a FILE as one passing test named after its own path only
|
|
89
|
+
// when the process ended cleanly without reporting any test of its own —
|
|
90
|
+
// which is what `process.exit(0)` at module level, or a helper that exits
|
|
91
|
+
// on import, produces. The tests in that file never ran, so it is a failure.
|
|
92
|
+
if (type === 'test:pass' && isWholeFile(data)) {
|
|
93
|
+
totals.fail += 1;
|
|
94
|
+
entry.fail += 1;
|
|
95
|
+
const message = 'this file exited before reporting any test — a `process.exit()` at module level (its own, or in something it imports) ended it, so none of its tests ran';
|
|
96
|
+
const item = { file: entry.file, name: '(the whole file)', message };
|
|
97
|
+
failed.push(item);
|
|
98
|
+
return { kind: 'fail', item, text: message };
|
|
99
|
+
}
|
|
100
|
+
if (type === 'test:pass') { totals.pass += 1; return null; }
|
|
101
|
+
totals.fail += 1;
|
|
102
|
+
entry.fail += 1;
|
|
103
|
+
let { message, text } = describeError(data.details?.error);
|
|
104
|
+
// A file that could not be loaded fails as a whole, and node's error for it
|
|
105
|
+
// says only "test failed"; what went wrong was written to the file's stderr.
|
|
106
|
+
const output = (stderrByFile.get(data.file) ?? '').trim();
|
|
107
|
+
if (isWholeFile(data) && output) {
|
|
108
|
+
const lines = output.split('\n').filter((line) => !/^\s*at .*\(?node:/.test(line));
|
|
109
|
+
message = lines.find((line) => /\w*Error\b/.test(line)) ?? lines[0];
|
|
110
|
+
text = lines.slice(0, MAX_ERROR_LINES).join('\n');
|
|
111
|
+
}
|
|
112
|
+
const item = { file: entry.file, name: isWholeFile(data) ? '(the whole file)' : data.name, message };
|
|
113
|
+
failed.push(item);
|
|
114
|
+
return { kind: 'fail', item, text };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// node ≥ 22.12 emits a summary per file with its wall time, which includes
|
|
118
|
+
// module load and top-level awaits that per-test durations leave out.
|
|
119
|
+
function summary(data) {
|
|
120
|
+
if (!data?.file || typeof data.duration_ms !== 'number') return;
|
|
121
|
+
const entry = fileEntry(data.file);
|
|
122
|
+
entry.ms = data.duration_ms;
|
|
123
|
+
entry.measured = true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// What each test file wrote to stderr, kept only to explain a file-level failure.
|
|
127
|
+
const stderrByFile = new Map();
|
|
128
|
+
function stderr(data) {
|
|
129
|
+
if (!data?.file || typeof data.message !== 'string') return;
|
|
130
|
+
const so_far = stderrByFile.get(data.file) ?? '';
|
|
131
|
+
if (so_far.length < 16384) stderrByFile.set(data.file, so_far + data.message);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function result() {
|
|
135
|
+
const list = [...files.values()].map(({ measured, ...f }) => ({ ...f, ms: Math.round(f.ms) }));
|
|
136
|
+
list.sort((a, b) => b.ms - a.ms);
|
|
137
|
+
return { ...totals, failed, skipped, todos, files: list };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { record, summary, stderr, result };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function summaryLines(result, { seconds } = {}) {
|
|
144
|
+
const lines = [];
|
|
145
|
+
const took = seconds === undefined ? '' : ` in ${seconds.toFixed(1)}s`;
|
|
146
|
+
lines.push(` node tests: ${result.tests} tests — ${result.pass} passed, ${result.fail} failed, ${result.skip} skipped${result.todo ? `, ${result.todo} todo` : ''}${took}`);
|
|
147
|
+
if (result.files.length > SLOWEST_SHOWN) {
|
|
148
|
+
lines.push(` slowest node files: ${result.files.slice(0, SLOWEST_SHOWN).map((f) => `${f.file} ${(f.ms / 1000).toFixed(1)}s`).join(', ')}`);
|
|
149
|
+
} else if (result.files.length > 1) {
|
|
150
|
+
lines.push(` node files: ${result.files.map((f) => `${f.file} ${(f.ms / 1000).toFixed(1)}s`).join(', ')}`);
|
|
151
|
+
}
|
|
152
|
+
return lines;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export default async function* joystackReporter(source) {
|
|
156
|
+
const collector = createCollector(process.cwd());
|
|
157
|
+
const started = Date.now();
|
|
158
|
+
for await (const event of source) {
|
|
159
|
+
if (event.type === 'test:pass' || event.type === 'test:fail') {
|
|
160
|
+
const outcome = collector.record(event.type, event.data);
|
|
161
|
+
if (outcome?.kind === 'fail') yield ` ✖ ${outcome.item.file} › ${outcome.item.name}\n${outcome.text.split('\n').map((l) => ` ${l}`).join('\n')}\n`;
|
|
162
|
+
if (outcome?.kind === 'skip') yield ` ⏭ ${outcome.item.file} › ${outcome.item.name} — skipped: ${outcome.item.reason || '(no reason given)'}\n`;
|
|
163
|
+
if (outcome?.kind === 'todo') yield ` ☐ ${outcome.item.file} › ${outcome.item.name} — todo, does not run${outcome.item.reason ? `: ${outcome.item.reason}` : ''}\n`;
|
|
164
|
+
} else if (event.type === 'test:summary') {
|
|
165
|
+
collector.summary(event.data);
|
|
166
|
+
} else if (event.type === 'test:stderr') {
|
|
167
|
+
collector.stderr(event.data);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const result = collector.result();
|
|
171
|
+
const seconds = (Date.now() - started) / 1000;
|
|
172
|
+
if (readEnv('JOYSTACK_TEST_UNIT_REPORT')) {
|
|
173
|
+
writeFileSync(readEnv('JOYSTACK_TEST_UNIT_REPORT'), JSON.stringify({ ...result, seconds }));
|
|
174
|
+
}
|
|
175
|
+
// No summary from here: the runner prints it once at the end of the whole
|
|
176
|
+
// run, with a wall time that includes node's start-up and module loading.
|
|
177
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Adapter for runners with their own discovery rules. Stdout is NUL-delimited
|
|
2
|
+
// selected paths; explanations go to stderr so filenames remain lossless.
|
|
3
|
+
import { readTestPolicy, changedTestPaths, selectTests } from './test-policy.mjs';
|
|
4
|
+
const [dir, since, ...files] = process.argv.slice(2);
|
|
5
|
+
try {
|
|
6
|
+
const policy = readTestPolicy(dir, files);
|
|
7
|
+
let changed;
|
|
8
|
+
if (since !== '--all') {
|
|
9
|
+
try { changed = changedTestPaths(dir, since); }
|
|
10
|
+
catch (error) { console.error(`Cannot compare with ${since}: ${error.message}. Including all tests.`); }
|
|
11
|
+
}
|
|
12
|
+
const selection = selectTests({ dir, files, policy, changed, all: since === '--all' });
|
|
13
|
+
console.error(`Test selection: ${selection.reason}; ${selection.files.length} selected, ${selection.skipped.length} deferred.`);
|
|
14
|
+
process.stdout.write(selection.files.map((file) => file + '\0').join(''));
|
|
15
|
+
} catch (error) {
|
|
16
|
+
console.error(error.message);
|
|
17
|
+
process.exitCode = 1;
|
|
18
|
+
}
|
package/lib/update.mjs
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// `joystack update` — move this game to the latest Joystack: the engine bundle AND
|
|
2
|
+
// the agent surface (skills + the Layer-0 managed block) that rides it.
|
|
3
|
+
//
|
|
4
|
+
// This is the ONLY command that writes to `.claude/skills/` or `AGENTS.md`.
|
|
5
|
+
// `dev` and `publish` never touch them: an update is something a creator asks
|
|
6
|
+
// for, not something that happens to them while they were doing something else.
|
|
7
|
+
//
|
|
8
|
+
// Because it writes into files the creator has in git, it OWES them a full
|
|
9
|
+
// account of what it changed — every path, and whose it was. The `/update-joystack`
|
|
10
|
+
// skill wraps this to walk them through the diff and offer the commit.
|
|
11
|
+
import { findProject, resolveEngine } from './project.mjs';
|
|
12
|
+
import { ensureEngine, readEnginePin, writeEnginePin } from './engine.mjs';
|
|
13
|
+
import { reconcileAgentSurface } from './agent-surface.mjs';
|
|
14
|
+
import { DEFAULT_API_BASE, getApiBase, getToken } from './config.mjs';
|
|
15
|
+
import { login } from './login.mjs';
|
|
16
|
+
import { compareVersions, renderReleases } from './changelog.mjs';
|
|
17
|
+
import { syncCli, reportCli } from './self-update.mjs';
|
|
18
|
+
import { fetchStatus, landedIn } from './feedback-sync.mjs';
|
|
19
|
+
|
|
20
|
+
// The report is the point. A creator reading this must be able to answer, with
|
|
21
|
+
// no further digging: what changed, was any of it mine, and what do I do now.
|
|
22
|
+
function report(log, engineVersion, surface) {
|
|
23
|
+
const { placed = [], removed = [], lossy = [], backups = [], backupDir, adopted } = surface;
|
|
24
|
+
if (!placed.length && !removed.length && !lossy.length) {
|
|
25
|
+
log(' Your skills and instructions were already current.');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
log('');
|
|
30
|
+
log(`Joystack also updated this game's skills and instructions to match engine ${engineVersion}:`);
|
|
31
|
+
for (const f of placed) {
|
|
32
|
+
if (lossy.includes(f)) log(` OVERWROTE ${f} (it had local changes — see below)`);
|
|
33
|
+
else log(` updated ${f}`);
|
|
34
|
+
}
|
|
35
|
+
for (const f of removed) {
|
|
36
|
+
if (lossy.includes(f)) log(` removed ${f} (retired by Joystack — it had local changes, see below)`);
|
|
37
|
+
else log(` removed ${f} (retired by Joystack)`);
|
|
38
|
+
}
|
|
39
|
+
for (const f of lossy) {
|
|
40
|
+
if (!placed.includes(f) && !removed.includes(f)) log(` OVERWROTE ${f} (it had local changes — see below)`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (lossy.length) {
|
|
44
|
+
// The overwrite is the contract (Joystack's files always land — keeping a
|
|
45
|
+
// local edit would freeze the file out of every future update), but the content is
|
|
46
|
+
// never gone: name both recovery routes and the one place local guidance
|
|
47
|
+
// is safe from updates.
|
|
48
|
+
log('');
|
|
49
|
+
log(' Files marked OVERWROTE had local changes that Joystack replaced. Nothing is');
|
|
50
|
+
log(` lost: recover them with \`git diff\`${backupDir ? `, or from ${backupDir}` : ''}.`);
|
|
51
|
+
log(' Guidance you want to keep belongs in AGENTS.md, below the managed block —');
|
|
52
|
+
log(' updates never touch anything there.');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (adopted && backups.length) {
|
|
56
|
+
// On adoption we cannot tell an untouched old platform file from one the
|
|
57
|
+
// creator edited — there is no ownership record to compare against, which
|
|
58
|
+
// is precisely why we're adopting. Most of these are just the previous
|
|
59
|
+
// version's files. Claiming "you edited these" would be a lie that alarms;
|
|
60
|
+
// saying nothing would risk a silent overwrite. State only what is true.
|
|
61
|
+
log('');
|
|
62
|
+
log(` This game predates Joystack's skill updates, so those files had no`);
|
|
63
|
+
log(` ownership record. Their previous contents are saved in ${backupDir}`);
|
|
64
|
+
log(` — if you had edited any of them, your version is in there.`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
log('');
|
|
68
|
+
log(" These are Joystack's files inside your repo, so they show up in git as");
|
|
69
|
+
log(" changes you didn't make. Review them and save them with your next commit.");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function update({
|
|
73
|
+
cwd = process.cwd(),
|
|
74
|
+
apiBase = getApiBase(DEFAULT_API_BASE),
|
|
75
|
+
log = console.log,
|
|
76
|
+
fetchImpl = fetch,
|
|
77
|
+
loginFn = login,
|
|
78
|
+
ensure = ensureEngine,
|
|
79
|
+
reconcile = reconcileAgentSurface,
|
|
80
|
+
syncCliFn = syncCli,
|
|
81
|
+
// `joystack update --rc <version>` — take an EXACT release candidate by reference.
|
|
82
|
+
target = null,
|
|
83
|
+
} = {}) {
|
|
84
|
+
const project = findProject(cwd);
|
|
85
|
+
const from = readEnginePin(project.dir);
|
|
86
|
+
|
|
87
|
+
// Same login gate as every /api/creator endpoint.
|
|
88
|
+
if (!getToken()) {
|
|
89
|
+
log('Checking for engine updates needs your Joystack account.');
|
|
90
|
+
await loginFn({ apiBase, log });
|
|
91
|
+
if (!getToken()) throw new Error('login did not produce a token — run `joystack login` and retry.');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Release-candidate lane ────────────────────────────────────────────────
|
|
95
|
+
// A candidate (`--rc 0.2.0-rc.1`) is UNLISTED: it never appears in `latest` or
|
|
96
|
+
// `joystack changelog`, so this lane bypasses the newest-stable comparison and
|
|
97
|
+
// installs the named version directly. Its changelog and any migration notes
|
|
98
|
+
// ship INSIDE the engine tarball (the docs the agent reads), not through the
|
|
99
|
+
// version-crossing callout the stable lane prints. syncCli runs FIRST for the
|
|
100
|
+
// same reason it does below — its npm install would prune the engine if it ran
|
|
101
|
+
// after the engine landed.
|
|
102
|
+
if (target) {
|
|
103
|
+
// Validate the version shape BEFORE anything mutates the repo. syncCli's npm
|
|
104
|
+
// install below prunes the engine, so a typo caught only after that point
|
|
105
|
+
// would leave the game engine-less on a bad pin. This mirrors the platform's
|
|
106
|
+
// ENGINE_VERSION_WITH_PRERELEASE_OK; a well-formed but unknown/removed
|
|
107
|
+
// candidate is still caught below by restoring the pin on a failed download.
|
|
108
|
+
if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z][0-9A-Za-z.-]*)?$/.test(target)) {
|
|
109
|
+
throw new Error(`\`--rc\` needs a valid engine version like 0.2.0-rc.1 — got "${target}".`);
|
|
110
|
+
}
|
|
111
|
+
let cli;
|
|
112
|
+
try {
|
|
113
|
+
cli = await syncCliFn({ projectDir: project.dir, log });
|
|
114
|
+
} catch (err) {
|
|
115
|
+
cli = { updated: false, error: err };
|
|
116
|
+
}
|
|
117
|
+
writeEnginePin(project.dir, target);
|
|
118
|
+
let engine;
|
|
119
|
+
try {
|
|
120
|
+
engine = await ensure(project.dir, { apiBase, log, fetchImpl });
|
|
121
|
+
} catch (err) {
|
|
122
|
+
// The candidate didn't download (a typo that still parsed, or one that was
|
|
123
|
+
// since removed). Restore the previous pin so the game isn't left pointing
|
|
124
|
+
// at a version that doesn't exist — the engine may have been pruned by the
|
|
125
|
+
// CLI install above, but is recoverable by a normal `joystack dev`/`update` on
|
|
126
|
+
// the restored pin. A game with no prior pin keeps none.
|
|
127
|
+
if (from) writeEnginePin(project.dir, from);
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
const engineDir = engine.dir ?? null;
|
|
131
|
+
log('');
|
|
132
|
+
log(`✅ Engine candidate installed: ${from ?? '(none)'} → ${engine.version}`);
|
|
133
|
+
|
|
134
|
+
const surface = engineDir ? reconcile(project.dir, engineDir, { log }) : { skipped: true };
|
|
135
|
+
if (!surface.skipped) report(log, surface.engineVersion ?? target, surface);
|
|
136
|
+
|
|
137
|
+
if (cli?.error) {
|
|
138
|
+
log('');
|
|
139
|
+
log(` The joystack command could not be updated (${cli.error.message}).`);
|
|
140
|
+
log(' Retry with: npm update @joystack/cli');
|
|
141
|
+
} else {
|
|
142
|
+
reportCli(log, cli);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
log('');
|
|
146
|
+
log(' This is a pre-release candidate — its changelog and any migration');
|
|
147
|
+
log(' notes ship inside the engine (read the handbook / engine docs), not');
|
|
148
|
+
log(' via `joystack changelog`. Republish (`npx joystack publish`) when ready.');
|
|
149
|
+
return { from, to: engine.version, updated: from !== engine.version, surface, crossed: null, cli, candidate: true };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const res = await fetchImpl(`${apiBase}/api/creator/engine`, {
|
|
153
|
+
headers: { Authorization: `Bearer ${getToken()}` },
|
|
154
|
+
});
|
|
155
|
+
if (res.status === 401) throw new Error('the platform rejected this machine’s token — run `joystack login` again.');
|
|
156
|
+
if (!res.ok) throw new Error(`could not list engine releases (HTTP ${res.status})`);
|
|
157
|
+
const { latest, releases } = await res.json();
|
|
158
|
+
if (!latest) throw new Error('the platform has no downloadable engine releases yet.');
|
|
159
|
+
|
|
160
|
+
// What the update is about to change UNDER the game.
|
|
161
|
+
// Reporting the skill files we rewrote and nothing about the engine was the
|
|
162
|
+
// whole gap: an agent crossed two versions of the shared library blind.
|
|
163
|
+
//
|
|
164
|
+
// A platform too old to serve `releases` must NOT block the update — it just
|
|
165
|
+
// has to be honest that it cannot say what changed. (`joystack changelog`, whose
|
|
166
|
+
// only job is to answer that question, refuses outright instead.)
|
|
167
|
+
// Newest first, to match `joystack changelog` and — more importantly — to match
|
|
168
|
+
// what renderReleases' own header SAYS. The endpoint returns them ascending;
|
|
169
|
+
// passing that straight through would print 0.1.13 above 0.1.14 under a
|
|
170
|
+
// heading that reads "newest first".
|
|
171
|
+
const crossed = Array.isArray(releases)
|
|
172
|
+
? releases
|
|
173
|
+
.filter(
|
|
174
|
+
(r) => compareVersions(r.version, latest) <= 0 && (!from || compareVersions(r.version, from) > 0),
|
|
175
|
+
)
|
|
176
|
+
.sort((a, b) => compareVersions(b.version, a.version))
|
|
177
|
+
: null;
|
|
178
|
+
|
|
179
|
+
// ── The `joystack` command FIRST, before the engine lands ─────────────────────
|
|
180
|
+
//
|
|
181
|
+
// Order is load-bearing, and getting it wrong deleted the engine. The engine is
|
|
182
|
+
// not an npm package — it installs with `npm install --no-save`, so npm has no
|
|
183
|
+
// record of it and treats it as extraneous. ANY later `npm install` in the game
|
|
184
|
+
// prunes it. syncCli runs exactly such an install (`--save-dev` the new CLI), so
|
|
185
|
+
// running it AFTER the engine install wiped the engine every single time: the
|
|
186
|
+
// update reported success and left node_modules/@joystack/engine gone. The
|
|
187
|
+
// next `joystack lint` then found no engine at all (caught on a real game).
|
|
188
|
+
//
|
|
189
|
+
// So: do every npm install that this command is going to do BEFORE the engine
|
|
190
|
+
// is put on disk, and let the engine be the last thing to land.
|
|
191
|
+
//
|
|
192
|
+
// Wrapped: syncCli is already fail-soft, and a bug in it must not stop the
|
|
193
|
+
// engine update that is the point of this command.
|
|
194
|
+
let cli;
|
|
195
|
+
try {
|
|
196
|
+
cli = await syncCliFn({ projectDir: project.dir, log });
|
|
197
|
+
} catch (err) {
|
|
198
|
+
cli = { updated: false, error: err };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let engineDir = null;
|
|
202
|
+
let updated = false;
|
|
203
|
+
|
|
204
|
+
// Forward-only: `joystack update` moves a game to `latest` only when `latest` is
|
|
205
|
+
// actually NEWER than the pin. It never moves a game that is already on latest
|
|
206
|
+
// OR ahead of it — the latter is real now that release candidates exist: a
|
|
207
|
+
// testbed pinned to `0.2.0-rc.3` while the newest stable is `0.1.10` must not
|
|
208
|
+
// be silently DOWNGRADED to 0.1.10 (which would break every 0.2.0 API it uses)
|
|
209
|
+
// just because the plain `update` was run out of habit. `--rc` is the lane that
|
|
210
|
+
// moves onto a candidate; the stable lane only ever moves forward.
|
|
211
|
+
const cmp = from ? compareVersions(from, latest) : -1;
|
|
212
|
+
if (cmp >= 0) {
|
|
213
|
+
// On latest, or ahead of it on a pre-release. Reconcile the surface anyway —
|
|
214
|
+
// a repo can sit on the right engine with a STILL out-of-date surface (one
|
|
215
|
+
// scaffolded before this mechanism existed never had its skills adopted) —
|
|
216
|
+
// but never rewrite the pin.
|
|
217
|
+
const label = from ?? latest;
|
|
218
|
+
const ahead = cmp > 0;
|
|
219
|
+
try {
|
|
220
|
+
engineDir = resolveEngine(project.dir).dir;
|
|
221
|
+
log(
|
|
222
|
+
ahead
|
|
223
|
+
? `✅ Engine ${label} — a pre-release ahead of the latest release (${latest}); not downgrading.`
|
|
224
|
+
: `✅ Engine ${label} — already up to date.`,
|
|
225
|
+
);
|
|
226
|
+
} catch {
|
|
227
|
+
// Pinned to this version, but NOT on disk. The pin is a claim about what
|
|
228
|
+
// this game runs; node_modules is the truth, and they disagree — because a
|
|
229
|
+
// plain `npm install` (the creator's own, or ours above) prunes the engine,
|
|
230
|
+
// which npm never recorded. "Already up to date" while the engine is missing
|
|
231
|
+
// is a lie that leaves every engine-reading command broken, and re-running
|
|
232
|
+
// update could never fix it. Put it back — from the local cache, so this is
|
|
233
|
+
// fast and works offline.
|
|
234
|
+
log(`Engine ${label} is pinned but missing from node_modules — reinstalling it.`);
|
|
235
|
+
const engine = await ensure(project.dir, { apiBase, log, fetchImpl });
|
|
236
|
+
engineDir = engine.dir ?? null;
|
|
237
|
+
log('');
|
|
238
|
+
log(`✅ Engine ${label} — restored.`);
|
|
239
|
+
}
|
|
240
|
+
} else {
|
|
241
|
+
// Rewrite the pin first; ensureEngine honors it (download → install → pin).
|
|
242
|
+
writeEnginePin(project.dir, latest);
|
|
243
|
+
const engine = await ensure(project.dir, { apiBase, log, fetchImpl });
|
|
244
|
+
engineDir = engine.dir ?? null;
|
|
245
|
+
updated = true;
|
|
246
|
+
log('');
|
|
247
|
+
log(`✅ Engine updated: ${from ?? '(none)'} → ${engine.version}`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const surface = engineDir ? reconcile(project.dir, engineDir, { log }) : { skipped: true };
|
|
251
|
+
if (!surface.skipped) report(log, surface.engineVersion ?? latest, surface);
|
|
252
|
+
|
|
253
|
+
// The third lane: the `joystack` command itself (see self-update.mjs). The skills
|
|
254
|
+
// we reconciled ship WITH the engine and can name any command they like — a real
|
|
255
|
+
// game ended up on an engine whose skills say `joystack changelog` while its CLI was
|
|
256
|
+
// four versions too old to have it. So update moves this too.
|
|
257
|
+
//
|
|
258
|
+
// It already RAN, at the top: its npm install has to happen before the engine
|
|
259
|
+
// lands or it prunes it. This is only the report.
|
|
260
|
+
if (cli?.error) {
|
|
261
|
+
log('');
|
|
262
|
+
log(` The joystack command could not be updated (${cli.error.message}).`);
|
|
263
|
+
log(' Your engine and skills are up to date. Retry with: npm update @joystack/cli');
|
|
264
|
+
} else {
|
|
265
|
+
reportCli(log, cli);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Feedback this repo filed that a crossed release resolved. Best-effort on
|
|
269
|
+
// purpose: the back-channel must never block or fail an engine update, so
|
|
270
|
+
// any error here (an older platform without the endpoint, offline after the
|
|
271
|
+
// download, a bad response) reduces to "no landings to report".
|
|
272
|
+
let landed = [];
|
|
273
|
+
if (updated) {
|
|
274
|
+
try {
|
|
275
|
+
landed = landedIn(await fetchStatus({ apiBase, fetchImpl }), { from, to: latest });
|
|
276
|
+
} catch {
|
|
277
|
+
landed = [];
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (updated) {
|
|
282
|
+
log('');
|
|
283
|
+
if (crossed === null) {
|
|
284
|
+
log(' This Joystack platform could not tell us what changed in the engine — no changelog');
|
|
285
|
+
log(' is available for the versions you just crossed.');
|
|
286
|
+
} else if (crossed.length) {
|
|
287
|
+
log(renderReleases(crossed, { pinned: from, latest }));
|
|
288
|
+
}
|
|
289
|
+
if (landed.length) {
|
|
290
|
+
log('');
|
|
291
|
+
log(' Feedback you filed landed in this update:');
|
|
292
|
+
for (const r of landed) log(` • "${r.title}" — resolved in ${r.resolved_in}`);
|
|
293
|
+
log(' Run `npx joystack feedback` to pull the details into notes/feedback/.');
|
|
294
|
+
}
|
|
295
|
+
log('');
|
|
296
|
+
log(' Republish (`npx joystack publish`) when you want the live game on it.');
|
|
297
|
+
}
|
|
298
|
+
return { from, to: latest, updated, surface, crossed, cli, landed };
|
|
299
|
+
}
|