@looop-games/cli 0.1.16 → 0.1.17

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 CHANGED
@@ -14,6 +14,29 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.1.17] - 2026-07-14
18
+
19
+ ### Added
20
+ - **`looop test <pattern>` runs just the tests you name.** While you're iterating
21
+ on one part of your game, running the *whole* suite every time is most of the
22
+ wait. `looop test charge` now runs only the files whose name contains `charge`
23
+ (`charge.smoke.mjs`, `game-charge.test.mjs`) and skips the rest — a focused,
24
+ fast re-run. Name more than one (`looop test charge aim`) to widen it. It's for
25
+ the inner loop, not a replacement for the gate: a pattern that matches nothing
26
+ stops with an error (so a typo can never pass green having run nothing), and you
27
+ still run the full `looop test` before you call the work done — a change can
28
+ break a part you didn't touch, and only the whole suite catches that.
29
+
30
+ ### Fixed
31
+ - **`looop test` no longer pins your whole machine while smokes run.** A headless
32
+ browser has no graphics card, so a 3D game's tests were rendering on the CPU —
33
+ one test could quietly eat 8+ processor cores and freeze a modest laptop solid.
34
+ Tests now render on your actual GPU instead, which is what it's for: the same
35
+ test that hogged 8+ cores now uses a fraction of one, and renders several times
36
+ faster. Nothing in your game changes, and your tests check exactly what they
37
+ did before. On a machine with no usable GPU it simply falls back to the old
38
+ behaviour — never worse. (Set `LOOOP_TEST_NO_GPU=1` to force the old path.)
39
+
17
40
  ## [0.1.16] - 2026-07-13
18
41
 
19
42
  ### 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
- const { ok } = await testCmd();
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': {
@@ -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
- export async function testCmd({ cwd = process.cwd(), log = console.log, devFn = dev, lintFn = lintCmd } = {}) {
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 { unit, smokes } = discoverTestFiles(project.dir);
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
- const lint = await lintFn({ cwd: project.dir, log });
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 run([file], {
154
+ const code = await runFn(['--import', SMOKE_PRELOAD, file], {
121
155
  cwd: project.dir,
122
156
  env: {
123
157
  ...env,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@looop-games/cli",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
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",