@looop-games/cli 0.1.16 → 0.1.18
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 +38 -0
- package/bin/looop.mjs +3 -1
- package/lib/bundle-primitives.mjs +10 -7
- package/lib/lint.mjs +29 -3
- package/lib/primitives.mjs +12 -1
- package/lib/publish.mjs +15 -5
- package/lib/room-server.mjs +75 -8
- package/lib/smoke-gpu-preload.mjs +75 -0
- package/lib/test-cmd.mjs +38 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,44 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
14
14
|
|
|
15
15
|
## [Unreleased]
|
|
16
16
|
|
|
17
|
+
## [0.1.18] - 2026-07-15
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **`looop dev`, `looop test`, and `looop publish` understand entity
|
|
22
|
+
components.** A game that declares behavior in `components/` folders (the
|
|
23
|
+
entity system — see `shared/practices/entities.md` in your installed engine)
|
|
24
|
+
now works across the whole command surface: `dev` bundles your components
|
|
25
|
+
into the local room server, `test` runs the entity graph lint out of the
|
|
26
|
+
installed engine before anything else (it runs even when eslint isn't
|
|
27
|
+
installed in the game), and `publish` bundles them into your game's own
|
|
28
|
+
multiplayer server — a component game is server-backed automatically,
|
|
29
|
+
exactly like a primitives game. Requires engine 0.1.27 or newer; run
|
|
30
|
+
`looop update` if your game pins an older one.
|
|
31
|
+
|
|
32
|
+
## [0.1.17] - 2026-07-14
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
- **`looop test <pattern>` runs just the tests you name.** While you're iterating
|
|
36
|
+
on one part of your game, running the *whole* suite every time is most of the
|
|
37
|
+
wait. `looop test charge` now runs only the files whose name contains `charge`
|
|
38
|
+
(`charge.smoke.mjs`, `game-charge.test.mjs`) and skips the rest — a focused,
|
|
39
|
+
fast re-run. Name more than one (`looop test charge aim`) to widen it. It's for
|
|
40
|
+
the inner loop, not a replacement for the gate: a pattern that matches nothing
|
|
41
|
+
stops with an error (so a typo can never pass green having run nothing), and you
|
|
42
|
+
still run the full `looop test` before you call the work done — a change can
|
|
43
|
+
break a part you didn't touch, and only the whole suite catches that.
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
- **`looop test` no longer pins your whole machine while smokes run.** A headless
|
|
47
|
+
browser has no graphics card, so a 3D game's tests were rendering on the CPU —
|
|
48
|
+
one test could quietly eat 8+ processor cores and freeze a modest laptop solid.
|
|
49
|
+
Tests now render on your actual GPU instead, which is what it's for: the same
|
|
50
|
+
test that hogged 8+ cores now uses a fraction of one, and renders several times
|
|
51
|
+
faster. Nothing in your game changes, and your tests check exactly what they
|
|
52
|
+
did before. On a machine with no usable GPU it simply falls back to the old
|
|
53
|
+
behaviour — never worse. (Set `LOOOP_TEST_NO_GPU=1` to force the old path.)
|
|
54
|
+
|
|
17
55
|
## [0.1.16] - 2026-07-13
|
|
18
56
|
|
|
19
57
|
### Added
|
package/bin/looop.mjs
CHANGED
|
@@ -69,7 +69,9 @@ try {
|
|
|
69
69
|
await create({ name: rest.find((a) => !a.startsWith('--')) });
|
|
70
70
|
break;
|
|
71
71
|
case 'test': {
|
|
72
|
-
|
|
72
|
+
// Positional args scope the run: `looop test charge` runs only files whose
|
|
73
|
+
// path contains "charge". Flags are left for future options (e.g. --changed).
|
|
74
|
+
const { ok } = await testCmd({ patterns: rest.filter((a) => !a.startsWith('--')) });
|
|
73
75
|
process.exit(ok ? 0 : 1);
|
|
74
76
|
}
|
|
75
77
|
case 'lint': {
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// class past the setInterval/park cost boundary. Keeping it external means the
|
|
18
18
|
// worker's room code is always ours.
|
|
19
19
|
import { assertNoOverrideCycle, PRIMITIVES_DIR, SERVER_BARREL, renderBarrel, scanPrimitives } from './primitives.mjs';
|
|
20
|
+
import { scanEntityComponents, renderEntityComponents } from './room-server.mjs';
|
|
20
21
|
import { shadowResolvePlugin } from './shadow-resolve.mjs';
|
|
21
22
|
import { join } from 'node:path';
|
|
22
23
|
|
|
@@ -29,17 +30,19 @@ export const PRIMITIVES_ENTRY = SERVER_BARREL;
|
|
|
29
30
|
// engine import in the primitives bundle is rewritten to this.
|
|
30
31
|
export const ENGINE_ARTIFACT_MODULE = './rooms-runtime.js';
|
|
31
32
|
|
|
32
|
-
export async function bundlePrimitives(projectDir, { esbuildImpl } = {}) {
|
|
33
|
+
export async function bundlePrimitives(projectDir, { esbuildImpl, engineSharedDir } = {}) {
|
|
33
34
|
// The barrel is GENERATED, not authored. A creator adds a primitive by adding
|
|
34
|
-
// a file to `overrides/shared/ui/room/primitives/` —
|
|
35
|
-
//
|
|
35
|
+
// a file to `overrides/shared/ui/room/primitives/` — and an entity component
|
|
36
|
+
// by adding a folder under components/ — there is no list to keep in sync,
|
|
37
|
+
// and therefore no way for the list and the folder to disagree.
|
|
36
38
|
const primitives = scanPrimitives(projectDir); // throws on a reserved index.js
|
|
37
|
-
|
|
39
|
+
const engineShared = engineSharedDir ?? join(projectDir, 'node_modules/@looop-games/engine/shared');
|
|
40
|
+
const entityComponents = await scanEntityComponents(projectDir, engineShared); // throws on a malformed component
|
|
41
|
+
if (!primitives.length && !entityComponents.length) {
|
|
38
42
|
throw new Error(
|
|
39
|
-
`
|
|
43
|
+
`nothing to bundle — ${PRIMITIVES_DIR}/ and components/ are both empty (this is not a server-backed game)`,
|
|
40
44
|
);
|
|
41
45
|
}
|
|
42
|
-
const engineShared = join(projectDir, 'node_modules/@looop-games/engine/shared');
|
|
43
46
|
assertNoOverrideCycle(primitives, projectDir, engineShared);
|
|
44
47
|
|
|
45
48
|
const esbuild = esbuildImpl ?? (await import('esbuild'));
|
|
@@ -68,7 +71,7 @@ export async function bundlePrimitives(projectDir, { esbuildImpl } = {}) {
|
|
|
68
71
|
// against the game root so `./overrides/...` means what it says. Same shape
|
|
69
72
|
// the dev room server uses (lib/room-server.mjs) — one contract, two builds.
|
|
70
73
|
stdin: {
|
|
71
|
-
contents: renderBarrel(primitives)
|
|
74
|
+
contents: `${renderBarrel(primitives)}\n${renderEntityComponents(entityComponents)}\n`,
|
|
72
75
|
resolveDir: projectDir,
|
|
73
76
|
sourcefile: 'looop-primitives.js',
|
|
74
77
|
loader: 'js',
|
package/lib/lint.mjs
CHANGED
|
@@ -57,21 +57,46 @@ async function loadRulesFromEngine(projectDir) {
|
|
|
57
57
|
return { configs: mod.configs ?? mod.default?.configs ?? null, why: 'ok' };
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// The entity graph lint — the SECOND shared step, alongside the eslint rules.
|
|
61
|
+
// Like the rules, the implementation lives in the ENGINE (shared/ui/room/
|
|
62
|
+
// entity/lint-run.mjs), so every gate that checks a game runs the identical
|
|
63
|
+
// pass — there is no second copy to drift. An engine too old to carry it
|
|
64
|
+
// skips — same degradation contract as the rules.
|
|
65
|
+
async function loadEntityLintFromEngine(projectDir) {
|
|
66
|
+
let engineDir;
|
|
67
|
+
try {
|
|
68
|
+
({ dir: engineDir } = resolveEngine(projectDir));
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const entry = join(engineDir, 'shared', 'ui', 'room', 'entity', 'lint-run.mjs');
|
|
73
|
+
if (!existsSync(entry)) return null;
|
|
74
|
+
const mod = await import(pathToFileURL(entry).href);
|
|
75
|
+
return mod.runEntityLint ?? null;
|
|
76
|
+
}
|
|
77
|
+
|
|
60
78
|
export async function lintCmd({
|
|
61
79
|
cwd = process.cwd(),
|
|
62
80
|
fix = false,
|
|
63
81
|
log = console.log,
|
|
64
82
|
loadEslint = loadFromProject,
|
|
65
83
|
loadRules = loadRulesFromEngine,
|
|
84
|
+
loadEntityLint = loadEntityLintFromEngine,
|
|
66
85
|
} = {}) {
|
|
67
86
|
const project = findProject(cwd);
|
|
68
87
|
|
|
88
|
+
// The entity graph check runs regardless of the eslint tooling below — a
|
|
89
|
+
// missing dev tool must never switch off a structural guarantee. It skips
|
|
90
|
+
// itself silently for games with no entity folders.
|
|
91
|
+
const runEntity = await loadEntityLint(project.dir);
|
|
92
|
+
const entity = runEntity ? await runEntity(project.dir, { log }) : { ok: true, skipped: true };
|
|
93
|
+
|
|
69
94
|
const ESLint = await loadEslint(project.dir);
|
|
70
95
|
if (!ESLint) {
|
|
71
96
|
log('⚠️ looop lint: eslint is not installed in this game — skipping the rule checks.');
|
|
72
97
|
log(' To turn them on (recommended — they catch bugs that silently pass your tests):');
|
|
73
98
|
log(' npm i -D eslint');
|
|
74
|
-
return { ok:
|
|
99
|
+
return { ok: entity.ok, skipped: true, errorCount: 0, warningCount: 0, entity };
|
|
75
100
|
}
|
|
76
101
|
|
|
77
102
|
const { configs, why } = await loadRules(project.dir);
|
|
@@ -87,7 +112,7 @@ export async function lintCmd({
|
|
|
87
112
|
log('⚠️ looop lint: your engine is older than the Looop rules — skipping.');
|
|
88
113
|
log(' Get an engine that carries them: looop update');
|
|
89
114
|
}
|
|
90
|
-
return { ok:
|
|
115
|
+
return { ok: entity.ok, skipped: true, errorCount: 0, warningCount: 0, entity };
|
|
91
116
|
}
|
|
92
117
|
|
|
93
118
|
// A game that ships its own eslint.config.js owns its rules — we do not
|
|
@@ -131,10 +156,11 @@ export async function lintCmd({
|
|
|
131
156
|
}
|
|
132
157
|
|
|
133
158
|
return {
|
|
134
|
-
ok: errorCount === 0,
|
|
159
|
+
ok: errorCount === 0 && entity.ok,
|
|
135
160
|
skipped: false,
|
|
136
161
|
errorCount,
|
|
137
162
|
warningCount,
|
|
163
|
+
entity,
|
|
138
164
|
files: results.map((r) => relative(project.dir, r.filePath)),
|
|
139
165
|
};
|
|
140
166
|
}
|
package/lib/primitives.mjs
CHANGED
|
@@ -127,8 +127,19 @@ export function scanPrimitives(projectDir) {
|
|
|
127
127
|
// other. (They used to disagree: publish looked for `_local/primitives/index.js`
|
|
128
128
|
// while dev looked for a `partykit.json` that nothing ever created, so a
|
|
129
129
|
// creator's primitive ran in production and silently did not run in dev.)
|
|
130
|
+
//
|
|
131
|
+
// TWO trees make a game server-backed: vendored primitives (overrides/) and
|
|
132
|
+
// entity components (components/, or entity-local ones under entities/). The
|
|
133
|
+
// entity half is a cheap folder check, not a scan — a malformed component
|
|
134
|
+
// still makes the game server-backed; the build is where it fails loudly.
|
|
130
135
|
export function isServerBacked(projectDir) {
|
|
131
|
-
|
|
136
|
+
if (scanPrimitives(projectDir).length > 0) return true;
|
|
137
|
+
if (existsSync(join(projectDir, 'components'))) return true;
|
|
138
|
+
const entitiesDir = join(projectDir, 'entities');
|
|
139
|
+
if (!existsSync(entitiesDir)) return false;
|
|
140
|
+
return readdirSync(entitiesDir).some((name) => {
|
|
141
|
+
try { return existsSync(join(entitiesDir, name, 'components')); } catch { return false; }
|
|
142
|
+
});
|
|
132
143
|
}
|
|
133
144
|
|
|
134
145
|
// The one cycle shadowing makes possible, caught in the creator's own words.
|
package/lib/publish.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import { ensureEngine } from './engine.mjs';
|
|
|
18
18
|
import { getToken, getApiBase } from './config.mjs';
|
|
19
19
|
import { DEFAULT_API_BASE } from './llm-shim.mjs';
|
|
20
20
|
import { bundlePrimitives } from './bundle-primitives.mjs';
|
|
21
|
+
import { scanEntityComponents } from './room-server.mjs';
|
|
21
22
|
import {
|
|
22
23
|
BROWSER_REGISTRY,
|
|
23
24
|
SERVER_BARREL,
|
|
@@ -119,13 +120,22 @@ export async function publish({
|
|
|
119
120
|
// are killing is an override that uploads, serves, and silently never runs.
|
|
120
121
|
assertNoInertOverride({ projectDir: project.dir, engine });
|
|
121
122
|
const serverPrimitives = scanPrimitives(project.dir); // throws on a reserved index.js
|
|
122
|
-
|
|
123
|
-
|
|
123
|
+
const entityComponents = await scanEntityComponents(project.dir, engine.sharedDir); // throws on a malformed component
|
|
124
|
+
if (serverPrimitives.length || entityComponents.length) {
|
|
125
|
+
const { source, externals } = await bundlePrimitives(project.dir, { engineSharedDir: engine.sharedDir });
|
|
124
126
|
files.set(SERVER_BARREL, Buffer.from(source, 'utf8'));
|
|
125
|
-
|
|
126
|
-
|
|
127
|
+
// The browser prediction registry exists for PRIMITIVES only — entity
|
|
128
|
+
// components' client/shared files ship as ordinary game files, and the
|
|
129
|
+
// client imports its own manifests directly.
|
|
130
|
+
if (serverPrimitives.length) {
|
|
131
|
+
files.set(BROWSER_REGISTRY, Buffer.from(renderBrowserRegistry(serverPrimitives), 'utf8'));
|
|
132
|
+
}
|
|
133
|
+
const names = [
|
|
134
|
+
...serverPrimitives.map((p) => p.type),
|
|
135
|
+
...entityComponents.map((c) => `${c.name} (component)`),
|
|
136
|
+
].join(', ');
|
|
127
137
|
log(
|
|
128
|
-
`Bundled server
|
|
138
|
+
`Bundled server code — ${names} (${(source.length / 1024).toFixed(0)} KB, engine external${externals.length ? `: ${[...new Set(externals)].length} module(s)` : ''}).`,
|
|
129
139
|
);
|
|
130
140
|
}
|
|
131
141
|
|
package/lib/room-server.mjs
CHANGED
|
@@ -23,8 +23,9 @@
|
|
|
23
23
|
// imports EXTERNAL and rewrites them to the release's pre-bundled
|
|
24
24
|
// `rooms-runtime.js`, because the publish endpoint is a Worker with no bundler.
|
|
25
25
|
// Same seam, two deliveries.)
|
|
26
|
-
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
27
|
-
import { join } from 'node:path';
|
|
26
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
27
|
+
import { dirname, join, relative } from 'node:path';
|
|
28
|
+
import { pathToFileURL } from 'node:url';
|
|
28
29
|
import {
|
|
29
30
|
assertNoOverrideCycle,
|
|
30
31
|
isServerBacked,
|
|
@@ -65,17 +66,76 @@ function engineAliasPlugin(sharedDir) {
|
|
|
65
66
|
};
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
// Entity components take the same trip as vendored primitives: scanned from
|
|
70
|
+
// the game tree, bundled, handed to the host — through the sibling
|
|
71
|
+
// `extraEntityComponents()` seam. The room bundle carries each component's
|
|
72
|
+
// MANIFEST (so channel gates are trusted — a world spec arrives from a
|
|
73
|
+
// client and may not define them) and its SIM entry: `server/sim.js` when
|
|
74
|
+
// the component has one, else `shared/sim.js` (predicted sims live in
|
|
75
|
+
// shared/ so the browser can run them too; server/ is for genuinely
|
|
76
|
+
// server-only adjudication and may import shared/ itself).
|
|
77
|
+
//
|
|
78
|
+
// The scan implementation lives in the ENGINE (shared/ui/room/entity/) at
|
|
79
|
+
// the same relative path in the repo and the built artifact. An engine too
|
|
80
|
+
// old to carry it means no entity components exist for that game — skip.
|
|
81
|
+
export async function scanEntityComponents(projectDir, sharedDir) {
|
|
82
|
+
const scanPath = join(sharedDir, 'ui', 'room', 'entity', 'scan.js');
|
|
83
|
+
if (!existsSync(scanPath)) return [];
|
|
84
|
+
const { scanGame } = await import(pathToFileURL(scanPath).href);
|
|
85
|
+
const { components, errors } = scanGame(projectDir);
|
|
86
|
+
if (errors.length) {
|
|
87
|
+
throw new Error(`this game's entity components cannot build a room server:\n - ${errors.join('\n - ')}`);
|
|
88
|
+
}
|
|
89
|
+
const posix = (p) => relative(projectDir, p).split('\\').join('/');
|
|
90
|
+
return [...components.values()].map((c) => {
|
|
91
|
+
const serverSim = join(c.dir, 'server', 'sim.js');
|
|
92
|
+
const sharedSim = join(c.dir, 'shared', 'sim.js');
|
|
93
|
+
const code = existsSync(serverSim) ? serverSim : existsSync(sharedSim) ? sharedSim : null;
|
|
94
|
+
return {
|
|
95
|
+
name: c.name,
|
|
96
|
+
manifestRel: posix(c.manifestPath),
|
|
97
|
+
codeRel: code ? posix(code) : null,
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function renderEntityComponents(components) {
|
|
103
|
+
if (!components.length) return 'export const extraEntityComponents = null;';
|
|
104
|
+
const lines = [];
|
|
105
|
+
const manifests = [];
|
|
106
|
+
const code = [];
|
|
107
|
+
components.forEach((c, i) => {
|
|
108
|
+
lines.push(`import entityManifest${i} from './${c.manifestRel}';`);
|
|
109
|
+
manifests.push(`entityManifest${i}`);
|
|
110
|
+
if (c.codeRel) {
|
|
111
|
+
lines.push(`import * as entityCode${i} from './${c.codeRel}';`);
|
|
112
|
+
code.push(` '${c.name}': entityCode${i},`);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
return [
|
|
116
|
+
...lines,
|
|
117
|
+
'export const extraEntityComponents = {',
|
|
118
|
+
` manifests: [${manifests.join(', ')}],`,
|
|
119
|
+
' code: {',
|
|
120
|
+
...code,
|
|
121
|
+
' },',
|
|
122
|
+
'};',
|
|
123
|
+
].join('\n');
|
|
124
|
+
}
|
|
125
|
+
|
|
68
126
|
// The room the game actually gets: the engine's room class, subclassed to hand
|
|
69
127
|
// the host the creator's primitives through the `extraPrimitives()` seam the
|
|
70
128
|
// engine already reads (server.js). Identical in shape to what the publish
|
|
71
129
|
// endpoint generates for the per-game Worker — one contract, two builders.
|
|
72
|
-
function renderEntry(primitives) {
|
|
130
|
+
function renderEntry(primitives, entityComponents) {
|
|
73
131
|
return [
|
|
74
132
|
renderBarrel(primitives),
|
|
133
|
+
renderEntityComponents(entityComponents),
|
|
75
134
|
"import LooopRoom from '/shared/ui/room/server.js';",
|
|
76
135
|
'',
|
|
77
136
|
'export default class GameRoom extends LooopRoom {',
|
|
78
137
|
' extraPrimitives() { return extraPrimitives; }',
|
|
138
|
+
' extraEntityComponents() { return extraEntityComponents; }',
|
|
79
139
|
'}',
|
|
80
140
|
'',
|
|
81
141
|
].join('\n');
|
|
@@ -88,7 +148,8 @@ function renderEntry(primitives) {
|
|
|
88
148
|
// dev must not quietly serve a room that is missing the creator's code.
|
|
89
149
|
export async function buildDevRoomServer({ projectDir, engine, esbuildImpl }) {
|
|
90
150
|
const primitives = scanPrimitives(projectDir); // throws on a reserved index.js
|
|
91
|
-
|
|
151
|
+
const entityComponents = await scanEntityComponents(projectDir, engine.sharedDir); // throws on a malformed component
|
|
152
|
+
if (!primitives.length && !entityComponents.length) {
|
|
92
153
|
// Not server-backed (any more). A PREVIOUS run may have generated a room
|
|
93
154
|
// server and a registry shadow; both must go, or dev keeps mounting a stale
|
|
94
155
|
// `.looop/overrides/shared/.../index.js` that imports a primitive the creator
|
|
@@ -109,7 +170,7 @@ export async function buildDevRoomServer({ projectDir, engine, esbuildImpl }) {
|
|
|
109
170
|
// against the game root, so `./overrides/...` means what it says and no
|
|
110
171
|
// machine-specific absolute path can leak into the output.
|
|
111
172
|
stdin: {
|
|
112
|
-
contents: renderEntry(primitives),
|
|
173
|
+
contents: renderEntry(primitives, entityComponents),
|
|
113
174
|
resolveDir: projectDir,
|
|
114
175
|
sourcefile: 'looop-room-server.js',
|
|
115
176
|
loader: 'js',
|
|
@@ -156,9 +217,15 @@ export async function buildDevRoomServer({ projectDir, engine, esbuildImpl }) {
|
|
|
156
217
|
// same in dev (static mount fall-through) and in production (the import map):
|
|
157
218
|
// /shared/ui/room/primitives/engine.js → not shadowed → the engine's list
|
|
158
219
|
// /shared/ui/room/primitives/beacon.js → shadowed → the game's primitive
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
220
|
+
if (primitives.length) {
|
|
221
|
+
const registryDir = join(projectDir, DEV_OVERRIDES_DIR, 'ui/room/primitives');
|
|
222
|
+
mkdirSync(registryDir, { recursive: true });
|
|
223
|
+
writeFileSync(join(registryDir, 'index.js'), renderBrowserRegistry(primitives));
|
|
224
|
+
} else {
|
|
225
|
+
// Entity components alone need no primitive-registry shadow — and a stale
|
|
226
|
+
// one from a removed primitive must not stay mounted.
|
|
227
|
+
rmSync(join(projectDir, DEV_OVERRIDES_DIR), { recursive: true, force: true });
|
|
228
|
+
}
|
|
162
229
|
|
|
163
230
|
return outDir;
|
|
164
231
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Preloaded before every browser smoke via `node --import`. Its whole job is to
|
|
2
|
+
// make headless Chromium render on the real GPU instead of SwiftShader.
|
|
3
|
+
//
|
|
4
|
+
// Why it matters: a headless browser has no GPU, so WebGL falls back to
|
|
5
|
+
// SwiftShader — software rasterization spread across a thread pool sized to the
|
|
6
|
+
// machine's core count. A single 3D-game smoke can pin 8+ cores that way, which
|
|
7
|
+
// on a modest laptop means a frozen machine, not just a slow test. Rendering on
|
|
8
|
+
// the actual GPU (Metal / D3D / GL, chosen by ANGLE per platform) moves that work
|
|
9
|
+
// off the CPU entirely: measured ~8.5 cores → ~0.1, with pixel-identical output.
|
|
10
|
+
//
|
|
11
|
+
// It runs as a preload so an UNMODIFIED smoke needs no change: it patches the
|
|
12
|
+
// `chromium.launch` of the GAME's own playwright (resolved from cwd, the same
|
|
13
|
+
// instance the smoke imports) to append the flags. Fetch-only smokes that never
|
|
14
|
+
// import playwright, and machines with no usable GPU, both fall through
|
|
15
|
+
// untouched — ANGLE simply falls back to software, so this is never worse than
|
|
16
|
+
// before. Opt out with LOOOP_TEST_NO_GPU=1.
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
import { pathToFileURL } from 'node:url';
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
import { join, dirname } from 'node:path';
|
|
21
|
+
|
|
22
|
+
export function gpuArgs() {
|
|
23
|
+
if (process.env.LOOOP_TEST_NO_GPU === '1') return [];
|
|
24
|
+
return ['--enable-gpu', '--ignore-gpu-blocklist'];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Append `extra` to whatever args a caller already passed to chromium.launch(),
|
|
28
|
+
// preserving theirs. Idempotent enough for our use: called once per smoke process.
|
|
29
|
+
export function patchLaunch(chromium, extra = gpuArgs()) {
|
|
30
|
+
if (!chromium || typeof chromium.launch !== 'function' || extra.length === 0) return false;
|
|
31
|
+
const orig = chromium.launch.bind(chromium);
|
|
32
|
+
chromium.launch = (opts = {}) => orig({ ...opts, args: [...(opts.args || []), ...extra] });
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Resolve the SAME playwright module object a smoke's `import 'playwright'` gets.
|
|
37
|
+
// The catch: a bare ESM import resolves via the package's "import" export
|
|
38
|
+
// (index.mjs), while createRequire().resolve() returns the "require" main
|
|
39
|
+
// (index.js) — a DIFFERENT module instance. Patching that one would miss, and the
|
|
40
|
+
// smoke would silently fall back to software with no error. So resolve the
|
|
41
|
+
// package dir, then import its ESM entry explicitly.
|
|
42
|
+
//
|
|
43
|
+
// Resolve from the SMOKE FILE's directory first (process.argv[1] under
|
|
44
|
+
// `node --import <preload> <smoke>`), then cwd. A smoke normally runs from its
|
|
45
|
+
// own game folder (cwd == the smoke's dir), but a runner may invoke it from a
|
|
46
|
+
// different working directory — resolving off the smoke's own location makes the
|
|
47
|
+
// same playwright install resolve either way.
|
|
48
|
+
async function loadGamePlaywright() {
|
|
49
|
+
const bases = [];
|
|
50
|
+
const smoke = process.argv[1];
|
|
51
|
+
if (smoke) bases.push(join(dirname(smoke), 'package.json'));
|
|
52
|
+
bases.push(join(process.cwd(), 'package.json'));
|
|
53
|
+
let lastErr;
|
|
54
|
+
for (const base of bases) {
|
|
55
|
+
try {
|
|
56
|
+
const require = createRequire(base);
|
|
57
|
+
const pkgDir = dirname(require.resolve('playwright')); // .../node_modules/playwright
|
|
58
|
+
const pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'));
|
|
59
|
+
const entry = pkg.exports?.['.']?.import ?? pkg.module ?? pkg.main ?? 'index.js';
|
|
60
|
+
return import(pathToFileURL(join(pkgDir, entry)).href);
|
|
61
|
+
} catch (e) { lastErr = e; }
|
|
62
|
+
}
|
|
63
|
+
throw lastErr;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Self-execute on preload. Kept in try/catch so a smoke without playwright (a
|
|
67
|
+
// fetch-only boot smoke) or a missing install never turns a preload into a crash.
|
|
68
|
+
if (gpuArgs().length > 0) {
|
|
69
|
+
try {
|
|
70
|
+
const pw = await loadGamePlaywright();
|
|
71
|
+
patchLaunch(pw.chromium ?? pw.default?.chromium);
|
|
72
|
+
} catch {
|
|
73
|
+
// No playwright here, or it couldn't be patched — leave the smoke unchanged.
|
|
74
|
+
}
|
|
75
|
+
}
|
package/lib/test-cmd.mjs
CHANGED
|
@@ -57,16 +57,46 @@ async function freeTestPort(base = 8100) {
|
|
|
57
57
|
throw new Error('no free port triple found for the test dev stack');
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
// Preload that GPU-offloads each smoke's browser (see smoke-gpu-preload.mjs).
|
|
61
|
+
// A file URL so `node --import` resolves it regardless of the smoke's cwd.
|
|
62
|
+
const SMOKE_PRELOAD = new URL('./smoke-gpu-preload.mjs', import.meta.url).href;
|
|
63
|
+
|
|
64
|
+
export async function testCmd({ cwd = process.cwd(), log = console.log, devFn = dev, lintFn = lintCmd, runFn = run, patterns = [] } = {}) {
|
|
61
65
|
const project = findProject(cwd);
|
|
62
|
-
const
|
|
66
|
+
const all = discoverTestFiles(project.dir);
|
|
67
|
+
|
|
68
|
+
// Scoped run (`looop test <pattern>…`): keep only files whose path contains a
|
|
69
|
+
// pattern. The file-naming convention already encodes the aspect
|
|
70
|
+
// (`charge.smoke.mjs`, `riven-charge.test.mjs`), so a substring on the path
|
|
71
|
+
// reaches both a unit test and its smoke without any source→test mapping.
|
|
72
|
+
// This is a PER-STEP speed lever, not the gate — the full suite still runs at
|
|
73
|
+
// milestone close and before publish (qa.md T3). With no pattern, everything
|
|
74
|
+
// runs, exactly as before.
|
|
75
|
+
const scoped = patterns.length > 0;
|
|
76
|
+
const matches = (f) => patterns.some((p) => relative(project.dir, f).includes(p));
|
|
77
|
+
const unit = scoped ? all.unit.filter(matches) : all.unit;
|
|
78
|
+
const smokes = scoped ? all.smokes.filter(matches) : all.smokes;
|
|
63
79
|
|
|
64
80
|
// Lint FIRST — it needs no servers and no browser, and the defects it catches
|
|
65
81
|
// (a hardcoded multiplayer host, a smoke pointed at the wrong port) are
|
|
66
82
|
// exactly the ones that make the suite below pass while testing nothing.
|
|
67
83
|
// Failing here does not skip the tests: the creator should see everything
|
|
68
84
|
// that is wrong in one run, not peel it one gate at a time.
|
|
69
|
-
|
|
85
|
+
//
|
|
86
|
+
// A SCOPED run skips it: lint is the full gate's job and runs at milestone
|
|
87
|
+
// close with the whole suite — a `looop test <pattern>` is a focused per-step
|
|
88
|
+
// re-run, not the gate.
|
|
89
|
+
const lint = scoped ? { ok: true, skipped: true } : await lintFn({ cwd: project.dir, log });
|
|
90
|
+
|
|
91
|
+
// A pattern that matches nothing FAILS LOUD. "0 tests, all green" is the exact
|
|
92
|
+
// false-pass this whole gate exists to prevent — a mistyped scope must never
|
|
93
|
+
// read as "everything's fine." (A game with genuinely no tests and no pattern
|
|
94
|
+
// is the legitimately-green fresh-game case below.)
|
|
95
|
+
if (patterns.length && unit.length + smokes.length === 0) {
|
|
96
|
+
log(`No tests matched ${patterns.map((p) => `"${p}"`).join(', ')} in ${project.dir}.`);
|
|
97
|
+
log(`(${all.unit.length + all.smokes.length} test file(s) exist; none contain that pattern. Check the spelling, or run \`looop test\` with no pattern to run the whole suite.)`);
|
|
98
|
+
return { ok: false, ran: 0, lint };
|
|
99
|
+
}
|
|
70
100
|
|
|
71
101
|
if (unit.length + smokes.length === 0) {
|
|
72
102
|
log(`No tests yet in ${project.dir} — nothing to run.`);
|
|
@@ -74,6 +104,10 @@ export async function testCmd({ cwd = process.cwd(), log = console.log, devFn =
|
|
|
74
104
|
return { ok: lint.ok, ran: 0 };
|
|
75
105
|
}
|
|
76
106
|
|
|
107
|
+
if (patterns.length) {
|
|
108
|
+
log(`▶ scoped to ${patterns.map((p) => `"${p}"`).join(', ')}: ${unit.length + smokes.length} of ${all.unit.length + all.smokes.length} file(s) — full suite still runs at milestone close.`);
|
|
109
|
+
}
|
|
110
|
+
|
|
77
111
|
let ok = lint.ok;
|
|
78
112
|
|
|
79
113
|
// If looop test itself runs under a node --test parent, the inherited
|
|
@@ -117,7 +151,7 @@ export async function testCmd({ cwd = process.cwd(), log = console.log, devFn =
|
|
|
117
151
|
try {
|
|
118
152
|
for (const file of smokes) {
|
|
119
153
|
const rel = relative(project.dir, file);
|
|
120
|
-
const code = await
|
|
154
|
+
const code = await runFn(['--import', SMOKE_PRELOAD, file], {
|
|
121
155
|
cwd: project.dir,
|
|
122
156
|
env: {
|
|
123
157
|
...env,
|