@looop-games/cli 0.1.36 → 0.1.37
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 +8 -0
- package/bin/looop.mjs +5 -4
- package/lib/dev.mjs +22 -32
- package/lib/inject.mjs +36 -23
- package/lib/process-tree.mjs +30 -0
- package/lib/replay-cmd.mjs +2 -2
- package/lib/test-cmd.mjs +35 -26
- package/lib/test-policy.mjs +100 -0
- package/lib/test-selection-cli.mjs +18 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,14 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
14
14
|
|
|
15
15
|
## [Unreleased]
|
|
16
16
|
|
|
17
|
+
## [0.1.37] - 2026-09-06
|
|
18
|
+
|
|
19
|
+
- Windows: stopping a development or smoke stack terminates its worker descendants, releasing their ports and temporary folders.
|
|
20
|
+
|
|
21
|
+
- Windows: selective test runs recognize short and long names for the same project folder, and replay export accepts destinations inside the game folder.
|
|
22
|
+
|
|
23
|
+
- Test submissions with `looop test --since <base>`: ordinary checks plus explicitly triggered extended tests in `looop.tests.json`; `--list` explains selection and `--all` runs an explicit audit. Smoke stacks disable reload watchers, and module serving avoids repeated filesystem lookups within each dependency walk.
|
|
24
|
+
|
|
17
25
|
## [0.1.36] - 2026-09-04
|
|
18
26
|
|
|
19
27
|
### Added
|
package/bin/looop.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { lane } from '../lib/lane.mjs';
|
|
|
8
8
|
import { login, whoami } from '../lib/login.mjs';
|
|
9
9
|
import { publish } from '../lib/publish.mjs';
|
|
10
10
|
import { create } from '../lib/create.mjs';
|
|
11
|
+
import { parseTestArgs } from '../lib/test-policy.mjs';
|
|
11
12
|
import { testCmd } from '../lib/test-cmd.mjs';
|
|
12
13
|
import { lintCmd } from '../lib/lint.mjs';
|
|
13
14
|
import { update } from '../lib/update.mjs';
|
|
@@ -30,7 +31,9 @@ Usage:
|
|
|
30
31
|
looop dev [--port <n>] Run the full dev stack (game + multiplayer + services)
|
|
31
32
|
looop assets [<id>] Browse and drive your models, sounds and assets on their own
|
|
32
33
|
looop lane <name> Open an isolated copy of the game to experiment in, safely
|
|
33
|
-
looop test
|
|
34
|
+
looop test [aspect] Run targeted checks (no aspect: conservative full run)
|
|
35
|
+
looop test --since <base> Submission: ordinary + triggered extended checks
|
|
36
|
+
looop test --all Explicit full audit; add --list to preview selection
|
|
34
37
|
looop lint [--fix] Check the game against the Looop rules (runs inside 'looop test')
|
|
35
38
|
looop changelog [<v>] What changed in the engine (default: everything newer than your pin)
|
|
36
39
|
looop update [--rc <v>] Move this game to the latest engine release (--rc <v> takes a specific pre-release)
|
|
@@ -90,9 +93,7 @@ try {
|
|
|
90
93
|
await create({ name: rest.find((a) => !a.startsWith('--')) });
|
|
91
94
|
break;
|
|
92
95
|
case 'test': {
|
|
93
|
-
|
|
94
|
-
// path contains "charge". Flags are left for future options (e.g. --changed).
|
|
95
|
-
const { ok } = await testCmd({ patterns: rest.filter((a) => !a.startsWith('--')) });
|
|
96
|
+
const { ok } = await testCmd(parseTestArgs(rest));
|
|
96
97
|
process.exit(ok ? 0 : 1);
|
|
97
98
|
}
|
|
98
99
|
case 'lint': {
|
package/lib/dev.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// Multiplayer runs from minute one — the platform is not optional (the Next
|
|
9
9
|
// analogy deliberately breaks there). NO_MP=1 opts out for purely-SP work.
|
|
10
10
|
import { spawn } from 'node:child_process';
|
|
11
|
+
import { terminateChildTree } from './process-tree.mjs';
|
|
11
12
|
import { existsSync, readFileSync } from 'node:fs';
|
|
12
13
|
import { tmpdir } from 'node:os';
|
|
13
14
|
import { join, dirname, basename } from 'node:path';
|
|
@@ -50,7 +51,7 @@ export function partykitBin() {
|
|
|
50
51
|
// and publish disagreed and a creator's primitive ran in production but not on
|
|
51
52
|
// their own machine. One question, one answer, both commands.
|
|
52
53
|
|
|
53
|
-
export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP === '1', log = console.log } = {}) {
|
|
54
|
+
export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP === '1', watch = true, log = console.log } = {}) {
|
|
54
55
|
const project = findProject(cwd);
|
|
55
56
|
// Q4 revision: the engine is not an npm dependency — install/refresh it from
|
|
56
57
|
// the platform's login-gated registry (no-op when the pinned version is in).
|
|
@@ -112,19 +113,8 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
|
|
|
112
113
|
const ownServer = roomServerCwd !== engine.roomServerDir;
|
|
113
114
|
|
|
114
115
|
const stop = () => {
|
|
115
|
-
for (const c of children) {
|
|
116
|
-
// partykit is npx-style: node wrapper forks workerd. Kill the tree.
|
|
117
|
-
try {
|
|
118
|
-
process.kill(-c.pid, 'SIGTERM');
|
|
119
|
-
} catch {
|
|
120
|
-
try {
|
|
121
|
-
c.kill('SIGTERM');
|
|
122
|
-
} catch {
|
|
123
|
-
/* gone */
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
116
|
for (const s of servers) s.close();
|
|
117
|
+
for (const c of children) terminateChildTree(c);
|
|
128
118
|
};
|
|
129
119
|
|
|
130
120
|
// ─── static ───
|
|
@@ -159,14 +149,15 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
|
|
|
159
149
|
{ url: '/shared/', dir: overridesDir },
|
|
160
150
|
{ url: '/shared/', dir: engine.sharedDir },
|
|
161
151
|
],
|
|
162
|
-
watchDirs: [project.dir, engine.sharedDir],
|
|
152
|
+
watchDirs: watch ? [project.dir, engine.sharedDir] : [],
|
|
153
|
+
injectReload: watch,
|
|
163
154
|
// Present only for a framework-v2 game — injects the looop import map + the
|
|
164
155
|
// lowered skeleton into the entry HTML so startGame() boots in the browser.
|
|
165
156
|
skeleton: roomSkeleton,
|
|
166
157
|
});
|
|
167
158
|
await staticServer.listen(ports.static);
|
|
168
159
|
servers.push(staticServer);
|
|
169
|
-
log(`→ static :${ports.static} (serving ${project.dir}, engine ${engine.version}, auto-reload on)`);
|
|
160
|
+
log(`→ static :${ports.static} (serving ${project.dir}, engine ${engine.version}, auto-reload ${watch ? 'on' : 'off'})`);
|
|
170
161
|
|
|
171
162
|
// ─── multiplayer (partykit on the bundle's room code) ───
|
|
172
163
|
// A single-player framework-v2 game (no config({ multiplayer: true })) runs its
|
|
@@ -187,7 +178,7 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
|
|
|
187
178
|
// partykit reloader below does this as a side effect; single-player needs a
|
|
188
179
|
// stripped-down version with no child to respawn. (NO_MP on a real multiplayer
|
|
189
180
|
// game has no client to serve a skeleton to, so it needs none.)
|
|
190
|
-
if (singlePlayer) {
|
|
181
|
+
if (singlePlayer && watch) {
|
|
191
182
|
const skelWatcher = createFileWatcher({
|
|
192
183
|
files: roomInputs,
|
|
193
184
|
dirs: ['entities', 'components', 'overrides/shared/ui/room'].map((d) => join(project.dir, d)),
|
|
@@ -226,28 +217,27 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
|
|
|
226
217
|
return pk;
|
|
227
218
|
};
|
|
228
219
|
const killMp = async (pk) => {
|
|
220
|
+
if (pk.pid == null) return;
|
|
221
|
+
// Keep ownership when termination fails so stop() can retry the tree.
|
|
222
|
+
// Child exit callbacks cannot run between these synchronous statements.
|
|
223
|
+
terminateChildTree(pk);
|
|
229
224
|
const idx = children.indexOf(pk);
|
|
230
225
|
if (idx !== -1) children.splice(idx, 1);
|
|
231
|
-
if (pk.pid == null) return; // never started — nothing to reap, and 'exit' will never fire
|
|
232
226
|
const exited = new Promise((resolveExit) => {
|
|
233
227
|
if (pk.exitCode !== null || pk.signalCode !== null) return resolveExit();
|
|
234
|
-
|
|
235
|
-
|
|
228
|
+
const onExit = () => {
|
|
229
|
+
pk.removeListener('exit', onExit);
|
|
230
|
+
pk.removeListener('error', onExit);
|
|
231
|
+
resolveExit();
|
|
232
|
+
};
|
|
233
|
+
pk.once('exit', onExit);
|
|
234
|
+
pk.once('error', onExit);
|
|
236
235
|
});
|
|
237
|
-
try {
|
|
238
|
-
process.kill(-pk.pid, 'SIGTERM');
|
|
239
|
-
} catch {
|
|
240
|
-
try {
|
|
241
|
-
pk.kill('SIGTERM');
|
|
242
|
-
} catch {
|
|
243
|
-
/* gone */
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
236
|
// partykit is a node wrapper forking workerd; if the tree ignores TERM,
|
|
247
237
|
// escalate — the port must be free before the replacement binds it.
|
|
248
238
|
const hardKill = setTimeout(() => {
|
|
249
239
|
try {
|
|
250
|
-
|
|
240
|
+
terminateChildTree(pk, 'SIGKILL');
|
|
251
241
|
} catch {
|
|
252
242
|
/* gone */
|
|
253
243
|
}
|
|
@@ -315,18 +305,18 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
|
|
|
315
305
|
},
|
|
316
306
|
});
|
|
317
307
|
reloader.attach(roomServerCwd, pk);
|
|
318
|
-
roomWatcher = createFileWatcher({
|
|
308
|
+
roomWatcher = watch ? createFileWatcher({
|
|
319
309
|
files: roomInputs,
|
|
320
310
|
dirs: ['entities', 'components', 'overrides/shared/ui/room'].map((d) => join(project.dir, d)),
|
|
321
311
|
onChange: reloader.onChange,
|
|
322
|
-
});
|
|
312
|
+
}) : null;
|
|
323
313
|
// stop() (e.g. `looop test` tearing down its stack) must also stop the
|
|
324
314
|
// reloader: a rebuild in flight would otherwise spawn a replacement room
|
|
325
315
|
// child AFTER the children list was killed — a leaked detached process.
|
|
326
316
|
servers.push({
|
|
327
317
|
close: () => {
|
|
328
318
|
reloader.stop();
|
|
329
|
-
roomWatcher
|
|
319
|
+
roomWatcher?.close();
|
|
330
320
|
},
|
|
331
321
|
});
|
|
332
322
|
}
|
package/lib/inject.mjs
CHANGED
|
@@ -137,7 +137,7 @@ function fileInfo(path) {
|
|
|
137
137
|
return null;
|
|
138
138
|
}
|
|
139
139
|
const hit = parseCache.get(path);
|
|
140
|
-
if (hit && hit.mtimeMs === st.mtimeMs) return hit;
|
|
140
|
+
if (hit && hit.mtimeMs === st.mtimeMs && hit.ctimeMs === st.ctimeMs && hit.size === st.size) return hit;
|
|
141
141
|
const specs = [];
|
|
142
142
|
if (/\.(js|mjs)$/.test(path)) {
|
|
143
143
|
let text = null;
|
|
@@ -152,35 +152,48 @@ function fileInfo(path) {
|
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
|
-
const info = { mtimeMs: st.mtimeMs, specs };
|
|
155
|
+
const info = { mtimeMs: st.mtimeMs, ctimeMs: st.ctimeMs, size: st.size, specs };
|
|
156
156
|
parseCache.set(path, info);
|
|
157
157
|
return info;
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
160
|
+
// A rewrite shares resolved nodes across all import closures. Each URL is
|
|
161
|
+
// resolved/stat'ed once even when several roots reach it. The cache belongs to
|
|
162
|
+
// this synchronous rewrite only: the next request observes edits, deletions and
|
|
163
|
+
// newly-created overrides immediately, without waiting for the reload watcher.
|
|
164
|
+
function rewriteContext(resolveUrl) {
|
|
165
|
+
const nodes = new Map();
|
|
166
|
+
return {
|
|
167
|
+
stamps: new Map(),
|
|
168
|
+
node(url) {
|
|
169
|
+
if (!nodes.has(url)) {
|
|
170
|
+
const path = resolveUrl(url);
|
|
171
|
+
nodes.set(url, path ? fileInfo(path) : null);
|
|
172
|
+
}
|
|
173
|
+
return nodes.get(url);
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function closureStampUrl(urlPath, context) {
|
|
179
|
+
if (context.stamps.has(urlPath)) return context.stamps.get(urlPath);
|
|
165
180
|
let max = 0;
|
|
166
181
|
const seen = new Set();
|
|
167
182
|
const stack = [urlPath];
|
|
168
183
|
while (stack.length) {
|
|
169
|
-
const
|
|
170
|
-
if (seen.has(
|
|
171
|
-
seen.add(
|
|
172
|
-
const
|
|
173
|
-
if (!fs) continue;
|
|
174
|
-
const info = fileInfo(fs);
|
|
184
|
+
const url = stack.pop();
|
|
185
|
+
if (seen.has(url)) continue;
|
|
186
|
+
seen.add(url);
|
|
187
|
+
const info = context.node(url);
|
|
175
188
|
if (!info) continue;
|
|
176
|
-
|
|
189
|
+
max = Math.max(max, info.mtimeMs);
|
|
177
190
|
for (const spec of info.specs) {
|
|
178
|
-
const child = resolveSpecToUrl(spec,
|
|
191
|
+
const child = resolveSpecToUrl(spec, url);
|
|
179
192
|
if (child) stack.push(child);
|
|
180
193
|
}
|
|
181
194
|
}
|
|
182
195
|
const stamp = max ? Math.floor(max) : null;
|
|
183
|
-
|
|
196
|
+
context.stamps.set(urlPath, stamp);
|
|
184
197
|
return stamp;
|
|
185
198
|
}
|
|
186
199
|
|
|
@@ -192,28 +205,28 @@ function closureStampUrl(urlPath, resolveUrl, memo) {
|
|
|
192
205
|
// disk-space fallback. Resolving a relative spec beside the importer on disk
|
|
193
206
|
// stamps the wrong file whenever a mount shadows it (the overrides mechanism),
|
|
194
207
|
// which silently re-opens the staleness hole. No serving context → no stamp.
|
|
195
|
-
function versionedPath(relPath, { baseUrl, resolveUrl, allowBareRelative = false,
|
|
208
|
+
function versionedPath(relPath, { baseUrl, resolveUrl, allowBareRelative = false, context }) {
|
|
196
209
|
if (!/\.(js|mjs|json)$/.test(relPath)) return null;
|
|
197
210
|
if (baseUrl == null || !resolveUrl) return null;
|
|
198
211
|
const urlTarget = resolveSpecToUrl(relPath, baseUrl, { allowBareRelative });
|
|
199
212
|
if (!urlTarget) return null;
|
|
200
|
-
if (!
|
|
201
|
-
const stamp = closureStampUrl(urlTarget,
|
|
213
|
+
if (!context.node(urlTarget)) return null;
|
|
214
|
+
const stamp = closureStampUrl(urlTarget, context);
|
|
202
215
|
return stamp === null ? null : `${relPath}?v=${stamp}`;
|
|
203
216
|
}
|
|
204
217
|
|
|
205
218
|
export function rewriteJsImports(text, { baseUrl, resolveUrl }) {
|
|
206
|
-
const
|
|
219
|
+
const context = rewriteContext(resolveUrl);
|
|
207
220
|
return text.replace(JS_IMPORT_RE, (whole, prefix, quote, path) => {
|
|
208
|
-
const v = versionedPath(path, { baseUrl, resolveUrl,
|
|
221
|
+
const v = versionedPath(path, { baseUrl, resolveUrl, context });
|
|
209
222
|
return `${prefix}${quote}${v ?? path}${quote}`;
|
|
210
223
|
});
|
|
211
224
|
}
|
|
212
225
|
|
|
213
226
|
export function rewriteHtmlScripts(html, { baseUrl, resolveUrl }) {
|
|
214
|
-
const
|
|
227
|
+
const context = rewriteContext(resolveUrl);
|
|
215
228
|
return html.replace(HTML_SCRIPT_RE, (whole, prefix, path, suffix) => {
|
|
216
|
-
const v = versionedPath(path, { baseUrl, resolveUrl, allowBareRelative: true,
|
|
229
|
+
const v = versionedPath(path, { baseUrl, resolveUrl, allowBareRelative: true, context });
|
|
217
230
|
return `${prefix}${v ?? path}${suffix}`;
|
|
218
231
|
});
|
|
219
232
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { win32 } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// PartyKit owns worker descendants. Windows has no Unix process groups, so
|
|
5
|
+
// killing its wrapper alone can leave a worker holding ports and directories.
|
|
6
|
+
export function terminateChildTree(child, signal = 'SIGTERM', {
|
|
7
|
+
platform = process.platform, exec = execFileSync, kill = process.kill,
|
|
8
|
+
} = {}) {
|
|
9
|
+
if (child.pid == null) return;
|
|
10
|
+
if (platform === 'win32') {
|
|
11
|
+
if (child.exitCode != null || child.signalCode != null) return;
|
|
12
|
+
const taskkill = win32.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'taskkill.exe');
|
|
13
|
+
try {
|
|
14
|
+
exec(taskkill, ['/PID', String(child.pid), '/T', '/F'], {
|
|
15
|
+
stdio: 'pipe', timeout: 5000, windowsHide: true,
|
|
16
|
+
});
|
|
17
|
+
} catch (error) {
|
|
18
|
+
// An already-exited child is harmless; a live tree that could not be
|
|
19
|
+
// terminated must fail shutdown rather than silently leak workers.
|
|
20
|
+
try { kill(child.pid, 0); } catch (probe) {
|
|
21
|
+
if (probe.code === 'ESRCH') return;
|
|
22
|
+
}
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
try { kill(-child.pid, signal); } catch {
|
|
28
|
+
child.kill(signal);
|
|
29
|
+
}
|
|
30
|
+
}
|
package/lib/replay-cmd.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// hand both to the exporter, and then say enough about what came out that the
|
|
5
5
|
// next step is obvious. The table's shape and every decision about it live in
|
|
6
6
|
// replay-export.mjs; nothing here knows what a column is.
|
|
7
|
-
import { join, relative, resolve } from 'node:path';
|
|
7
|
+
import { join, relative, resolve, sep } from 'node:path';
|
|
8
8
|
import { statSync } from 'node:fs';
|
|
9
9
|
import { findProject } from './project.mjs';
|
|
10
10
|
import { streamDir } from './replay-store.mjs';
|
|
@@ -82,7 +82,7 @@ export async function replayExport({
|
|
|
82
82
|
const outDir = out ? resolve(project.dir, out) : join(project.dir, ...EXPORTS_DIR);
|
|
83
83
|
// The tables are tens of megabytes and this creates directories to hold them.
|
|
84
84
|
// A path that climbs out of the game folder is a typo, not an intention.
|
|
85
|
-
if (outDir !== project.dir && !outDir.startsWith(`${resolve(project.dir)}
|
|
85
|
+
if (outDir !== project.dir && !outDir.startsWith(`${resolve(project.dir)}${sep}`)) {
|
|
86
86
|
throw new Error(`--out must be inside the game folder, and ${out} is not`);
|
|
87
87
|
}
|
|
88
88
|
const engine = await ensureEngineImpl(project.dir, { log });
|
package/lib/test-cmd.mjs
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Discovers the game's test files — `*.test.mjs` (unit, run under
|
|
4
4
|
// `node --test`) and `*.smoke.mjs` (integration, run against a REAL dev
|
|
5
|
-
// stack) — and
|
|
5
|
+
// stack) — and selects ordinary plus triggered extended checks. The files ARE the executable QA list: `/qa`
|
|
6
6
|
// merges this gate with handbook/qa.md and the engine's master qa.md.
|
|
7
7
|
//
|
|
8
8
|
// The smoke stack boots on a free shifted port triple so it never seizes a
|
|
9
9
|
// dev server the creator has running on :8000.
|
|
10
|
+
import { selectTests, readTestPolicy, changedTestPaths } from './test-policy.mjs';
|
|
10
11
|
import { spawn } from 'node:child_process';
|
|
11
12
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
12
13
|
import { createRequire } from 'node:module';
|
|
@@ -93,29 +94,36 @@ function looopTestPreload(projectDir) {
|
|
|
93
94
|
}
|
|
94
95
|
}
|
|
95
96
|
|
|
96
|
-
export async function testCmd({ cwd = process.cwd(), log = console.log, devFn = dev, lintFn = lintCmd, runFn = run, patterns = [] } = {}) {
|
|
97
|
+
export async function testCmd({ cwd = process.cwd(), log = console.log, devFn = dev, lintFn = lintCmd, runFn = run, patterns = [], since, all: runAll = false, list = false } = {}) {
|
|
97
98
|
const project = findProject(cwd);
|
|
98
99
|
|
|
99
|
-
|
|
100
|
-
// changed, BEFORE anything runs — a stale artifact means every hit test in
|
|
101
|
-
// the suite exercises a surface the player no longer sees. A bake failure
|
|
102
|
-
// fails the gate with the model named.
|
|
103
|
-
const { ensureBaked } = await import('./model-bake.mjs');
|
|
104
|
-
await ensureBaked({ dir: project.dir, log });
|
|
105
|
-
|
|
100
|
+
if ((since && runAll) || (patterns.length && (since || runAll))) throw new Error('Choose patterns, --since, or --all');
|
|
106
101
|
const all = discoverTestFiles(project.dir);
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
// pattern. The file-naming convention already encodes the aspect
|
|
110
|
-
// (`charge.smoke.mjs`, `riven-charge.test.mjs`), so a substring on the path
|
|
111
|
-
// reaches both a unit test and its smoke without any source→test mapping.
|
|
112
|
-
// This is a PER-STEP speed lever, not the gate — the full suite still runs at
|
|
113
|
-
// milestone close and before publish (qa.md T3). With no pattern, everything
|
|
114
|
-
// runs, exactly as before.
|
|
102
|
+
const files = [...all.unit, ...all.smokes];
|
|
103
|
+
const policy = readTestPolicy(project.dir, files);
|
|
115
104
|
const scoped = patterns.length > 0;
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
105
|
+
let changed;
|
|
106
|
+
if (since) {
|
|
107
|
+
try { changed = changedTestPaths(project.dir, since); }
|
|
108
|
+
catch (error) { log(`⚠ Cannot compare with ${since}: ${error.message}. Including every extended test.`); }
|
|
109
|
+
}
|
|
110
|
+
const selection = scoped
|
|
111
|
+
? { files: files.filter((f) => patterns.some((p) => posixRelative(project.dir, f).includes(p))), skipped: [], reason: 'targeted step checks' }
|
|
112
|
+
: selectTests({ dir: project.dir, files, policy, changed, all: runAll });
|
|
113
|
+
const selected = new Set(selection.files);
|
|
114
|
+
const unit = all.unit.filter((f) => selected.has(f));
|
|
115
|
+
const smokes = all.smokes.filter((f) => selected.has(f));
|
|
116
|
+
log(`▶ ${selection.reason}: ${selected.size} of ${files.length} test files`);
|
|
117
|
+
for (const entry of policy?.extended ?? []) {
|
|
118
|
+
const included = selection.files.some((f) => posixRelative(project.dir, f) === entry.test);
|
|
119
|
+
log(` ${included ? 'include' : 'defer'} extended ${entry.test} — ${entry.reason}`);
|
|
120
|
+
}
|
|
121
|
+
if (list) {
|
|
122
|
+
for (const file of selection.files) log(` ${posixRelative(project.dir, file)}`);
|
|
123
|
+
return { ok: !scoped || selected.size > 0, ran: 0, selection };
|
|
124
|
+
}
|
|
125
|
+
const { ensureBaked } = await import('./model-bake.mjs');
|
|
126
|
+
await ensureBaked({ dir: project.dir, log });
|
|
119
127
|
|
|
120
128
|
// Lint FIRST — it needs no servers and no browser, and the defects it catches
|
|
121
129
|
// (a hardcoded multiplayer host, a smoke pointed at the wrong port) are
|
|
@@ -123,8 +131,7 @@ export async function testCmd({ cwd = process.cwd(), log = console.log, devFn =
|
|
|
123
131
|
// Failing here does not skip the tests: the creator should see everything
|
|
124
132
|
// that is wrong in one run, not peel it one gate at a time.
|
|
125
133
|
//
|
|
126
|
-
// A SCOPED run skips it: lint
|
|
127
|
-
// close with the whole suite — a `looop test <pattern>` is a focused per-step
|
|
134
|
+
// A SCOPED run skips it: lint runs at the submission gate — a `looop test <pattern>` is a focused per-step
|
|
128
135
|
// re-run, not the gate.
|
|
129
136
|
const lint = scoped ? { ok: true, skipped: true } : await lintFn({ cwd: project.dir, log });
|
|
130
137
|
|
|
@@ -145,7 +152,7 @@ export async function testCmd({ cwd = process.cwd(), log = console.log, devFn =
|
|
|
145
152
|
}
|
|
146
153
|
|
|
147
154
|
if (patterns.length) {
|
|
148
|
-
log(`▶ scoped to ${patterns.map((p) => `"${p}"`).join(', ')}: ${unit.length + smokes.length} of ${all.unit.length + all.smokes.length} file(s) —
|
|
155
|
+
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.`);
|
|
149
156
|
}
|
|
150
157
|
|
|
151
158
|
let ok = lint.ok;
|
|
@@ -190,11 +197,13 @@ export async function testCmd({ cwd = process.cwd(), log = console.log, devFn =
|
|
|
190
197
|
}
|
|
191
198
|
}
|
|
192
199
|
|
|
193
|
-
const handle = await devFn({ cwd: project.dir, port: await freeTestPort(), log: () => {} });
|
|
200
|
+
const handle = await devFn({ cwd: project.dir, port: await freeTestPort(), watch: false, log: () => {} });
|
|
194
201
|
log(`▶ smokes against ${handle.url}`);
|
|
195
202
|
try {
|
|
196
203
|
for (const file of smokes) {
|
|
197
204
|
const rel = relative(project.dir, file);
|
|
205
|
+
const started = performance.now();
|
|
206
|
+
log(` ▶ ${rel}`);
|
|
198
207
|
const code = await runFn(['--import', SMOKE_PRELOAD, ...looopImport, file], {
|
|
199
208
|
cwd: project.dir,
|
|
200
209
|
env: {
|
|
@@ -206,7 +215,7 @@ export async function testCmd({ cwd = process.cwd(), log = console.log, devFn =
|
|
|
206
215
|
URL: handle.url,
|
|
207
216
|
},
|
|
208
217
|
});
|
|
209
|
-
log(` ${code === 0 ? '✅' : '❌'} ${rel}`);
|
|
218
|
+
log(` ${code === 0 ? '✅' : '❌'} ${rel} (${((performance.now() - started) / 1000).toFixed(2)}s)`);
|
|
210
219
|
if (code !== 0) ok = false;
|
|
211
220
|
}
|
|
212
221
|
} finally {
|
|
@@ -216,5 +225,5 @@ export async function testCmd({ cwd = process.cwd(), log = console.log, devFn =
|
|
|
216
225
|
|
|
217
226
|
log('');
|
|
218
227
|
log(ok ? `✅ looop test: all green (${unit.length} unit file(s), ${smokes.length} smoke(s))` : '❌ looop test: failures above');
|
|
219
|
-
return { ok, ran: unit.length + smokes.length, lint };
|
|
228
|
+
return { ok, ran: unit.length + smokes.length, lint, selection };
|
|
220
229
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
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
|
+
|
|
6
|
+
const POLICY = 'looop.tests.json';
|
|
7
|
+
const posix = (s) => s.replaceAll('\\', '/');
|
|
8
|
+
|
|
9
|
+
function matcher(pattern) {
|
|
10
|
+
if (typeof pattern !== 'string' || !pattern || pattern.startsWith('/') || pattern.includes('\\') || pattern.split('/').includes('..') || /[\[\]{}]/.test(pattern)) {
|
|
11
|
+
throw new Error(`${POLICY}: patterns must be relative paths using /, *, ** or ?`);
|
|
12
|
+
}
|
|
13
|
+
let source = '^';
|
|
14
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
15
|
+
const c = pattern[i];
|
|
16
|
+
if (c === '*' && pattern[i + 1] === '*') {
|
|
17
|
+
i++;
|
|
18
|
+
if (pattern[i + 1] === '/') { i++; source += '(?:.*/)?'; }
|
|
19
|
+
else source += '.*';
|
|
20
|
+
} else if (c === '*') source += '[^/]*';
|
|
21
|
+
else if (c === '?') source += '[^/]';
|
|
22
|
+
else source += c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
23
|
+
}
|
|
24
|
+
return new RegExp(source + '$');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function patterns(value, label, { nonempty = false } = {}) {
|
|
28
|
+
if (!Array.isArray(value) || (nonempty && !value.length)) throw new Error(`${POLICY}: ${label} must be ${nonempty ? 'a nonempty' : 'an'} array`);
|
|
29
|
+
return value.map(matcher);
|
|
30
|
+
}
|
|
31
|
+
const matches = (path, rules) => rules.some((rule) => rule.test(path));
|
|
32
|
+
|
|
33
|
+
export function readTestPolicy(dir, files) {
|
|
34
|
+
const path = join(dir, POLICY);
|
|
35
|
+
if (!existsSync(path)) return null;
|
|
36
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
37
|
+
if (raw.version !== 1 || !Array.isArray(raw.extended)) throw new Error(`${POLICY}: expected version 1 and an extended array`);
|
|
38
|
+
const allowed = ['version', 'extended', 'ordinaryChanges', 'always'];
|
|
39
|
+
if (Object.keys(raw).some((k) => !allowed.includes(k))) throw new Error(`${POLICY}: unknown top-level field`);
|
|
40
|
+
const known = new Set(files.map((f) => posix(relative(dir, f))));
|
|
41
|
+
const seen = new Set();
|
|
42
|
+
const extended = raw.extended.map((entry) => {
|
|
43
|
+
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}`);
|
|
44
|
+
if (Object.keys(entry).some((k) => !['test', 'when', 'reason'].includes(k))) throw new Error(`${POLICY}: unknown field for ${entry.test}`);
|
|
45
|
+
if (typeof entry.reason !== 'string' || !entry.reason.trim()) throw new Error(`${POLICY}: ${entry.test} needs a reason`);
|
|
46
|
+
seen.add(entry.test);
|
|
47
|
+
return { ...entry, rules: patterns(entry.when, `${entry.test}.when`, { nonempty: true }) };
|
|
48
|
+
});
|
|
49
|
+
return {
|
|
50
|
+
extended,
|
|
51
|
+
ordinary: patterns(raw.ordinaryChanges ?? [], 'ordinaryChanges'),
|
|
52
|
+
always: patterns(raw.always ?? [], 'always'),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Name-only without rename detection returns both sides of moves. NUL delimiters
|
|
57
|
+
// preserve spaces/newlines, and -- separates paths from command-line options.
|
|
58
|
+
export function changedTestPaths(dir, since) {
|
|
59
|
+
const git = (args) => execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
60
|
+
if (!since || since.startsWith('-')) throw new Error('test --since needs an integration base, not an option');
|
|
61
|
+
const root = realpathSync.native(git(['rev-parse', '--show-toplevel']).trim());
|
|
62
|
+
const projectDir = realpathSync.native(dir);
|
|
63
|
+
const base = git(['merge-base', 'HEAD', since]).trim();
|
|
64
|
+
const changed = git(['-C', root, 'diff', '--no-relative', '--name-only', '--no-renames', '-z', base, '--']);
|
|
65
|
+
const untracked = git(['-C', root, 'ls-files', '--others', '--exclude-standard', '-z']);
|
|
66
|
+
return [...new Set((changed + untracked).split('\0').filter(Boolean).map((p) => posix(relative(projectDir, resolve(root, p)))))];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function selectTests({ dir, files, policy, changed, all = false }) {
|
|
70
|
+
if (!policy || all) return { files, skipped: [], reason: all ? 'explicit --all audit' : 'all tests are ordinary (no policy)' };
|
|
71
|
+
if (!changed) return { files, skipped: [], reason: 'no integration base; including all extended tests (use --since <base> at submission)' };
|
|
72
|
+
const extended = new Map(policy.extended.map((entry) => [entry.test, entry]));
|
|
73
|
+
const testPaths = new Set(files.map((f) => posix(relative(dir, f))));
|
|
74
|
+
const broad = changed.find((p) => p === POLICY || matches(p, policy.always) || (!testPaths.has(p) && !matches(p, policy.ordinary) && !policy.extended.some((e) => matches(p, e.rules))));
|
|
75
|
+
if (broad) return { files, skipped: [], reason: `broad coverage: ${broad} is a foundation, policy, or unmapped change` };
|
|
76
|
+
const selected = files.filter((file) => {
|
|
77
|
+
const path = posix(relative(dir, file));
|
|
78
|
+
const entry = extended.get(path);
|
|
79
|
+
return !entry || changed.some((p) => p === path || matches(p, entry.rules));
|
|
80
|
+
});
|
|
81
|
+
const selectedSet = new Set(selected);
|
|
82
|
+
return { files: selected, skipped: files.filter((f) => !selectedSet.has(f)), reason: 'ordinary regressions plus extended tests triggered by changes' };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function parseTestArgs(args) {
|
|
86
|
+
const options = { patterns: [], all: false, list: false };
|
|
87
|
+
for (let i = 0; i < args.length; i++) {
|
|
88
|
+
const arg = args[i];
|
|
89
|
+
if (arg === '--all') options.all = true;
|
|
90
|
+
else if (arg === '--list') options.list = true;
|
|
91
|
+
else if (arg === '--since') {
|
|
92
|
+
const value = args[++i];
|
|
93
|
+
if (!value || value.startsWith('-')) throw new Error('test --since requires a git integration base');
|
|
94
|
+
options.since = value;
|
|
95
|
+
} else if (arg.startsWith('-')) throw new Error(`Unknown test option: ${arg}`);
|
|
96
|
+
else options.patterns.push(arg);
|
|
97
|
+
}
|
|
98
|
+
if ((options.all && options.since) || (options.patterns.length && (options.all || options.since))) throw new Error('Choose test <pattern>, test --since <base>, or test --all');
|
|
99
|
+
return options;
|
|
100
|
+
}
|
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@looop-games/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.37",
|
|
4
4
|
"description": "Looop game development CLI — dev server, login, and publishing for standalone Looop games.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"node": ">=20.11"
|
|
18
18
|
},
|
|
19
19
|
"scripts": {
|
|
20
|
-
"test": "node
|
|
20
|
+
"test": "node scripts/run-tests.mjs"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"cross-spawn": "^7.0.6",
|