@looop-games/cli 0.1.4 → 0.1.6

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.
@@ -1,88 +0,0 @@
1
- // A standalone game project is a folder with an index.html and a package.json
2
- // that depends on @looop-games/engine. The CLI must find the project root from any
3
- // cwd inside it, derive the slug (folder name unless package.json overrides),
4
- // and locate the installed engine bundle.
5
- import { test } from 'node:test';
6
- import assert from 'node:assert/strict';
7
- import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync, realpathSync } from 'node:fs';
8
- import { tmpdir } from 'node:os';
9
- import { join } from 'node:path';
10
- import { findProject, resolveEngine } from './project.mjs';
11
-
12
- function scaffold({ name = 'my-game', looop = undefined } = {}) {
13
- const root = realpathSync(mkdtempSync(join(tmpdir(), 'looop-proj-')));
14
- const game = join(root, name);
15
- mkdirSync(game, { recursive: true });
16
- writeFileSync(join(game, 'index.html'), '<html><head></head><body></body></html>');
17
- writeFileSync(
18
- join(game, 'package.json'),
19
- JSON.stringify({ name, private: true, ...(looop ? { looop } : {}), dependencies: { '@looop-games/engine': '*' } }),
20
- );
21
- return { root, game };
22
- }
23
-
24
- test('finds the project from the game folder and derives slug from folder name', () => {
25
- const { root, game } = scaffold({ name: 'tower-jump' });
26
- try {
27
- const p = findProject(game);
28
- assert.equal(p.dir, game);
29
- assert.equal(p.slug, 'tower-jump');
30
- } finally {
31
- rmSync(root, { recursive: true, force: true });
32
- }
33
- });
34
-
35
- test('finds the project from a subdirectory', () => {
36
- const { root, game } = scaffold();
37
- try {
38
- const sub = join(game, 'assets/sprites');
39
- mkdirSync(sub, { recursive: true });
40
- assert.equal(findProject(sub).dir, game);
41
- } finally {
42
- rmSync(root, { recursive: true, force: true });
43
- }
44
- });
45
-
46
- test('package.json looop.slug overrides the folder name', () => {
47
- const { root, game } = scaffold({ name: 'my-game-repo', looop: { slug: 'basket' } });
48
- try {
49
- assert.equal(findProject(game).slug, 'basket');
50
- } finally {
51
- rmSync(root, { recursive: true, force: true });
52
- }
53
- });
54
-
55
- test('throws a clear error outside any project', () => {
56
- const empty = mkdtempSync(join(tmpdir(), 'looop-empty-'));
57
- try {
58
- assert.throws(() => findProject(empty), /Not inside a Looop game project/);
59
- } finally {
60
- rmSync(empty, { recursive: true, force: true });
61
- }
62
- });
63
-
64
- test('resolveEngine finds the installed @looop-games/engine bundle', () => {
65
- const { root, game } = scaffold();
66
- try {
67
- const engineDir = join(game, 'node_modules/@looop-games/engine');
68
- mkdirSync(join(engineDir, 'shared/platform'), { recursive: true });
69
- mkdirSync(join(engineDir, 'room-server'), { recursive: true });
70
- writeFileSync(join(engineDir, 'package.json'), JSON.stringify({ name: '@looop-games/engine', version: '0.1.0' }));
71
- const e = resolveEngine(game);
72
- assert.equal(e.dir, engineDir);
73
- assert.equal(e.version, '0.1.0');
74
- } finally {
75
- rmSync(root, { recursive: true, force: true });
76
- }
77
- });
78
-
79
- test('resolveEngine gives an actionable error when the engine is not installed', () => {
80
- const { root, game } = scaffold();
81
- try {
82
- // Q4 revision: the fix is running the CLI (which downloads the engine),
83
- // not npm install — the engine is no longer an npm dependency.
84
- assert.throws(() => resolveEngine(game), /engine is not installed.*looop dev/s);
85
- } finally {
86
- rmSync(root, { recursive: true, force: true });
87
- }
88
- });
@@ -1,148 +0,0 @@
1
- // `looop publish` — the client half of server-resolved publish (cuqfzo
2
- // Slice 2, Q1). Against a mock platform capturing the multipart body, pins
3
- // the wire contract: ONLY game files ship (never shared/, node_modules,
4
- // notes/, tests, dotfiles), engineVersion is declared from the installed
5
- // bundle, hashes are real sha256s, overrides ride along as game files, and
6
- // the stored creator token goes out as a Bearer header.
7
- import { test, after } from 'node:test';
8
- import assert from 'node:assert/strict';
9
- import http from 'node:http';
10
- import { createHash } from 'node:crypto';
11
- import { mkdtempSync, mkdirSync, writeFileSync, rmSync, realpathSync } from 'node:fs';
12
- import { tmpdir } from 'node:os';
13
- import { join } from 'node:path';
14
- import { publish } from './publish.mjs';
15
- import { setToken } from './config.mjs';
16
-
17
- process.env.LOOOP_HOME = mkdtempSync(join(tmpdir(), 'looop-pub-home-'));
18
-
19
- // ── mock platform ──
20
- let captured = null;
21
- const server = http.createServer(async (req, res) => {
22
- const chunks = [];
23
- for await (const c of req) chunks.push(c);
24
- const body = Buffer.concat(chunks);
25
- // Parse the multipart with the same machinery the real endpoint uses.
26
- const parsed = await new Request('http://x/api/publish', {
27
- method: 'POST',
28
- headers: { 'content-type': req.headers['content-type'] },
29
- body,
30
- }).formData();
31
- const parts = {};
32
- for (const [name, value] of parsed.entries()) {
33
- parts[name] = typeof value === 'string' ? value : Buffer.from(await value.arrayBuffer()).toString('utf8');
34
- }
35
- captured = { url: req.url, auth: req.headers.authorization ?? null, parts, meta: JSON.parse(parts.meta) };
36
- res.writeHead(200, { 'Content-Type': 'application/json' });
37
- res.end(JSON.stringify({ slug: captured.meta.slug ?? 'minted-abc12', title: 'T', createdAt: 1, updatedAt: 2 }));
38
- });
39
- await new Promise((r) => server.listen(0, r));
40
- const apiBase = `http://localhost:${server.address().port}`;
41
-
42
- // ── fixture standalone game ──
43
- const root = realpathSync(mkdtempSync(join(tmpdir(), 'looop-pub-')));
44
- const game = join(root, 'my-game');
45
- mkdirSync(join(game, 'node_modules/@looop-games/engine'), { recursive: true });
46
- mkdirSync(join(game, 'notes'), { recursive: true });
47
- mkdirSync(join(game, 'overrides/shared/ui/room'), { recursive: true });
48
- mkdirSync(join(game, 'assets'), { recursive: true });
49
- writeFileSync(join(game, 'index.html'), '<html><head><title>My Game</title></head></html>');
50
- writeFileSync(join(game, 'world.js'), 'export const w = 1;');
51
- writeFileSync(join(game, 'assets/s.png'), 'png-bytes');
52
- writeFileSync(join(game, 'overrides/shared/ui/room/x.js'), 'export const x = 2;');
53
- writeFileSync(join(game, 'notes/design.md'), 'secret notes');
54
- // The creator-harness surface (Slice 1): repo knowledge, never catalog bytes.
55
- // Audience rule: handbook/, agent instructions, and skills travel with the
56
- // repo — a published game must not expose them on a public URL.
57
- mkdirSync(join(game, 'handbook'), { recursive: true });
58
- writeFileSync(join(game, 'handbook/qa.md'), 'game checks');
59
- writeFileSync(join(game, 'AGENTS.md'), 'agent instructions');
60
- writeFileSync(join(game, 'CLAUDE.md'), '@AGENTS.md');
61
- writeFileSync(join(game, 'GEMINI.md'), '@AGENTS.md');
62
- writeFileSync(join(game, 'my-game.smoke.mjs'), '// test file');
63
- writeFileSync(join(game, '.gitignore'), 'node_modules');
64
- writeFileSync(join(game, 'package.json'), JSON.stringify({ name: 'my-game', dependencies: { '@looop-games/engine': '*' } }));
65
- writeFileSync(
66
- join(game, 'node_modules/@looop-games/engine/package.json'),
67
- JSON.stringify({ name: '@looop-games/engine', version: '0.1.0' }),
68
- );
69
- writeFileSync(join(game, 'node_modules/@looop-games/engine/junk.js'), 'never ship');
70
-
71
- after(() => {
72
- server.close();
73
- rmSync(root, { recursive: true, force: true });
74
- rmSync(process.env.LOOOP_HOME, { recursive: true, force: true });
75
- });
76
-
77
- test('publishes only the game files, with engineVersion and real hashes', async () => {
78
- setToken('looop_' + '1'.repeat(64), { apiBase });
79
- const result = await publish({ cwd: game, apiBase, log: () => {} });
80
- assert.equal(result.slug, 'my-game');
81
- assert.equal(result.url, 'https://play.looop.games/g/my-game');
82
-
83
- const { url, meta, parts, auth } = captured;
84
- // The creator lane — the one publish route on the public play host. The
85
- // legacy /api/publish stays behind the builder's Access wall.
86
- assert.equal(url, '/api/creator/publish');
87
- assert.equal(meta.engineVersion, '0.1.0');
88
- assert.equal(meta.entry, 'index.html');
89
- assert.equal(meta.slug, 'my-game');
90
- const paths = Object.keys(meta.files).sort();
91
- assert.deepEqual(paths, ['assets/s.png', 'index.html', 'overrides/shared/ui/room/x.js', 'world.js']);
92
- // Real sha256 of the actual bytes, and each file rides as its own part.
93
- const expected = createHash('sha256').update('export const w = 1;').digest('hex');
94
- assert.equal(meta.files['world.js'], expected);
95
- assert.equal(parts['world.js'], 'export const w = 1;');
96
- // The stored creator token authenticates the upload.
97
- assert.equal(auth, `Bearer looop_${'1'.repeat(64)}`);
98
- });
99
-
100
- test('--slug publishes a parallel copy without touching the canonical slug', async () => {
101
- const result = await publish({ cwd: game, apiBase, slug: 'my-game-exp', log: () => {} });
102
- assert.equal(result.slug, 'my-game-exp');
103
- assert.equal(captured.meta.slug, 'my-game-exp');
104
- });
105
-
106
- test('a game without index.html is rejected before any upload', async () => {
107
- const bare = join(root, 'no-entry');
108
- mkdirSync(bare);
109
- writeFileSync(join(bare, 'package.json'), '{}');
110
- writeFileSync(join(bare, 'main.js'), '// no entry');
111
- await assert.rejects(() => publish({ cwd: bare, apiBase, log: () => {} }), /index\.html/);
112
- });
113
-
114
- // cuqfzo Phase 2 / Option B, Slice 2: a server-backed game's primitives are
115
- // BUNDLED (npm + WASM + siblings inlined, engine external) before upload — the
116
- // endpoint gets one self-contained module, not the raw source tree.
117
- test('a server-backed game ships its primitives BUNDLED (engine external), not the raw barrel', async () => {
118
- const srv = join(root, 'srv-game');
119
- mkdirSync(join(srv, '_local/primitives'), { recursive: true });
120
- mkdirSync(join(srv, 'node_modules/@looop-games/engine'), { recursive: true });
121
- writeFileSync(
122
- join(srv, 'node_modules/@looop-games/engine/package.json'),
123
- JSON.stringify({ name: '@looop-games/engine', version: '0.1.0' }),
124
- );
125
- writeFileSync(join(srv, 'package.json'), JSON.stringify({ name: 'srv-game' }));
126
- writeFileSync(join(srv, 'index.html'), '<html><head><title>Srv</title></head></html>');
127
- // a sibling helper the barrel pulls in — must end up INLINED, not shipped raw
128
- writeFileSync(join(srv, '_local/primitives/helper.js'), 'export const HELPER_MARKER = 4242;\n');
129
- writeFileSync(
130
- join(srv, '_local/primitives/index.js'),
131
- [
132
- "import { runEffect } from '/shared/ui/room/primitives/effects.js';",
133
- "import { HELPER_MARKER } from './helper.js';",
134
- 'export const extraPrimitives = { demo: class { constructor() { this.v = HELPER_MARKER; this.e = runEffect; } } };',
135
- ].join('\n'),
136
- );
137
-
138
- await publish({ cwd: srv, apiBase, log: () => {} });
139
- const shipped = captured.parts['_local/primitives/index.js'];
140
- // The shipped barrel is the BUNDLE: the sibling is inlined, the engine import
141
- // is rewritten to the artifact module, the raw /shared/... specifier is gone.
142
- assert.match(shipped, /4242/, 'the sibling helper must be inlined into the bundle');
143
- assert.match(shipped, /\.\/rooms-runtime\.js/, 'the engine import must be rewritten external');
144
- assert.doesNotMatch(shipped, /\/shared\/ui\/room/, 'the raw /shared/... specifier must be gone');
145
- // The manifest hash matches the bundled bytes actually sent (not the source).
146
- const sentHash = createHash('sha256').update(shipped).digest('hex');
147
- assert.equal(captured.meta.files['_local/primitives/index.js'], sentHash);
148
- });
@@ -1,161 +0,0 @@
1
- // End-to-end (real HTTP) tests for the standalone dev static server. Pins the
2
- // serving contract a game depends on: the /games/<slug>/ mount, the /shared/
3
- // alias into the engine bundle, head injection + reload client on HTML, ?v=
4
- // cache busting, directory-index injection (the bare-URL regression from
5
- // dev_server.py), path-traversal containment, and SSE reload on file edits.
6
- import { test, after } from 'node:test';
7
- import assert from 'node:assert/strict';
8
- import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
9
- import { tmpdir } from 'node:os';
10
- import { join } from 'node:path';
11
- import { createStaticServer } from './static-server.mjs';
12
-
13
- const root = mkdtempSync(join(tmpdir(), 'looop-static-'));
14
- const game = join(root, 'pong');
15
- const shared = join(root, 'engine-shared');
16
- mkdirSync(join(game, 'assets'), { recursive: true });
17
- mkdirSync(join(shared, 'platform'), { recursive: true });
18
- mkdirSync(join(shared, 'ui/room'), { recursive: true });
19
- writeFileSync(
20
- join(game, 'index.html'),
21
- '<html><head><title>pong</title></head><body><script type="module" src="main.js"></script></body></html>',
22
- );
23
- writeFileSync(join(game, 'main.js'), "import './world.js';\nwindow.started = true;\n");
24
- writeFileSync(join(game, 'world.js'), 'export const w = 1;\n');
25
- writeFileSync(join(game, 'assets/sprite.png'), 'not-really-a-png');
26
- writeFileSync(join(shared, 'platform/platform.js'), 'export function installPlatform() {}\n');
27
- writeFileSync(join(shared, 'ui/room/client.js'), "import './x.js';\nexport const c = 1;\n");
28
- writeFileSync(join(shared, 'ui/room/x.js'), 'export const x = 1;\n');
29
- writeFileSync(join(root, 'secret.txt'), 'nope');
30
-
31
- const srv = createStaticServer({
32
- slug: 'pong',
33
- mounts: [
34
- { url: '/games/pong/', dir: game },
35
- { url: '/shared/', dir: shared },
36
- ],
37
- watchDirs: [game, shared],
38
- watchIntervalMs: 100,
39
- });
40
- await srv.listen(0);
41
- const base = `http://localhost:${srv.port}`;
42
-
43
- after(() => {
44
- srv.close();
45
- rmSync(root, { recursive: true, force: true });
46
- });
47
-
48
- test('game HTML gets head injection + reload client + versioned scripts', async () => {
49
- const res = await fetch(`${base}/games/pong/index.html`);
50
- assert.equal(res.status, 200);
51
- assert.equal(res.headers.get('cache-control'), 'no-store, must-revalidate');
52
- const html = await res.text();
53
- assert.match(html, /window\.GAME_SLUG = "pong"/);
54
- assert.match(html, /window\.LOOOP_IDENTITY = \{"userId":"dev-local-user"/);
55
- assert.match(html, /installPlatform\(\)/);
56
- assert.match(html, /EventSource\('\/__reload'\)/);
57
- assert.match(html, /src="main\.js\?v=\d+"/);
58
- });
59
-
60
- test('bare directory URL serves index.html THROUGH the injecting path', async () => {
61
- const html = await (await fetch(`${base}/games/pong/`)).text();
62
- assert.match(html, /window\.GAME_SLUG = "pong"/);
63
- });
64
-
65
- test('root redirects to the game entry', async () => {
66
- const res = await fetch(`${base}/`, { redirect: 'manual' });
67
- assert.equal(res.status, 302);
68
- assert.equal(res.headers.get('location'), '/games/pong/index.html');
69
- });
70
-
71
- test('/shared/ serves the engine bundle with versioned imports', async () => {
72
- const res = await fetch(`${base}/shared/ui/room/client.js`);
73
- assert.equal(res.status, 200);
74
- assert.match(res.headers.get('content-type'), /text\/javascript/);
75
- assert.match(await res.text(), /'\.\/x\.js\?v=\d+'/);
76
- });
77
-
78
- test('game JS imports are versioned too', async () => {
79
- const body = await (await fetch(`${base}/games/pong/main.js`)).text();
80
- assert.match(body, /'\.\/world\.js\?v=\d+'/);
81
- });
82
-
83
- test('binary assets pass through with a sensible type', async () => {
84
- const res = await fetch(`${base}/games/pong/assets/sprite.png`);
85
- assert.equal(res.status, 200);
86
- assert.match(res.headers.get('content-type'), /image\/png/);
87
- assert.equal(await res.text(), 'not-really-a-png');
88
- });
89
-
90
- test('path traversal cannot escape a mount', async () => {
91
- for (const p of ['/shared/../secret.txt', '/games/pong/../../secret.txt', '/shared/%2e%2e/secret.txt']) {
92
- const res = await fetch(base + p);
93
- assert.notEqual(res.status, 200, `escaped via ${p}`);
94
- }
95
- });
96
-
97
- test('unknown path 404s', async () => {
98
- assert.equal((await fetch(`${base}/games/pong/nope.js`)).status, 404);
99
- });
100
-
101
- test('?as=<name> injects a distinct dev identity — two-window MP testing (cuqfzo Slice 6 catch)', async () => {
102
- // The room dedups same-account connections even for unverified dev claims,
103
- // so two tabs as the constant dev-local-user evict each other. ?as= lets a
104
- // human be two players locally; without it, identity is the standard one.
105
- const plain = await (await fetch(`${base}/games/pong/index.html`)).text();
106
- assert.match(plain, /"userId":"dev-local-user"/);
107
- const asP2 = await (await fetch(`${base}/games/pong/index.html?as=p2`)).text();
108
- assert.match(asP2, /"userId":"dev-local-p2"/);
109
- assert.match(asP2, /"name":"p2"/);
110
- // Hostile values can't break out of the inline script or the id charset.
111
- const hostile = await (await fetch(`${base}/games/pong/index.html?as=${encodeURIComponent('</script><x>!!')}`)).text();
112
- assert.ok(!hostile.includes('</script><x>'));
113
- assert.match(hostile, /"userId":"dev-local-scriptx"/);
114
- });
115
-
116
- test('overrides mount shadows the engine per-file and falls through otherwise', async () => {
117
- // Slice 2 (cuqfzo Q1): a game's overrides/shared/... wins over the bundle
118
- // for THAT file only; everything else falls through to the next mount.
119
- const overrides = join(root, 'game-overrides');
120
- mkdirSync(join(overrides, 'ui/room'), { recursive: true });
121
- writeFileSync(join(overrides, 'ui/room/x.js'), 'export const x = "OVERRIDDEN";\n');
122
- const srv2 = createStaticServer({
123
- slug: 'pong',
124
- mounts: [
125
- { url: '/games/pong/', dir: game },
126
- { url: '/shared/', dir: overrides },
127
- { url: '/shared/', dir: shared },
128
- ],
129
- watchDirs: [],
130
- });
131
- await srv2.listen(0);
132
- try {
133
- const overridden = await (await fetch(`http://localhost:${srv2.port}/shared/ui/room/x.js`)).text();
134
- assert.match(overridden, /OVERRIDDEN/);
135
- const fallThrough = await fetch(`http://localhost:${srv2.port}/shared/ui/room/client.js`);
136
- assert.equal(fallThrough.status, 200);
137
- assert.match(await fallThrough.text(), /export const c = 1/);
138
- } finally {
139
- srv2.close();
140
- }
141
- });
142
-
143
- test('editing a watched file pushes an SSE reload', async () => {
144
- const res = await fetch(`${base}/__reload`);
145
- assert.match(res.headers.get('content-type'), /text\/event-stream/);
146
- const reader = res.body.getReader();
147
- const decoder = new TextDecoder();
148
- let buf = '';
149
- // First chunk is the :connected flush.
150
- buf += decoder.decode((await reader.read()).value);
151
- assert.match(buf, /:connected/);
152
- writeFileSync(join(game, 'world.js'), 'export const w = 2;\n');
153
- const deadline = Date.now() + 5000;
154
- while (!buf.includes('data: reload') && Date.now() < deadline) {
155
- const { value, done } = await reader.read();
156
- if (done) break;
157
- buf += decoder.decode(value);
158
- }
159
- assert.match(buf, /data: reload/);
160
- await reader.cancel();
161
- });
@@ -1,118 +0,0 @@
1
- // `looop test` — the per-game verification gate (cuqfzo creator-harness
2
- // Slice 1; successor to the monorepo's `gamedev <name> test`). Discovers the
3
- // game's own *.test.mjs (unit) and *.smoke.mjs (browser, needs the dev stack)
4
- // and runs them. The files ARE the QA list `/qa` merges from.
5
- import { test, after } from 'node:test';
6
- import assert from 'node:assert/strict';
7
- import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
8
- import { tmpdir } from 'node:os';
9
- import { join } from 'node:path';
10
- import { discoverTestFiles, testCmd } from './test-cmd.mjs';
11
-
12
- const base = mkdtempSync(join(tmpdir(), 'looop-test-'));
13
- after(() => rmSync(base, { recursive: true, force: true }));
14
-
15
- function gameDir(name, files = {}) {
16
- const dir = join(base, name);
17
- mkdirSync(dir, { recursive: true });
18
- writeFileSync(join(dir, 'index.html'), '<!doctype html>');
19
- writeFileSync(join(dir, 'package.json'), JSON.stringify({ name }));
20
- for (const [rel, content] of Object.entries(files)) {
21
- mkdirSync(join(dir, rel, '..'), { recursive: true });
22
- writeFileSync(join(dir, rel), content);
23
- }
24
- return dir;
25
- }
26
-
27
- test('discovery finds unit tests and smokes, skips node_modules and dot-dirs', () => {
28
- const dir = gameDir('disco', {
29
- 'movement.test.mjs': '',
30
- 'plaza.smoke.mjs': '',
31
- 'sub/depth.smoke.mjs': '',
32
- 'node_modules/dep/oops.test.mjs': '',
33
- '.looop/cache.test.mjs': '',
34
- 'overrides/shared/ui/thing.test.mjs': '', // engine-override copies are not the game's tests
35
- });
36
- const found = discoverTestFiles(dir);
37
- assert.deepEqual(found.unit, [join(dir, 'movement.test.mjs')]);
38
- assert.deepEqual(found.smokes, [join(dir, 'plaza.smoke.mjs'), join(dir, 'sub/depth.smoke.mjs')]);
39
- });
40
-
41
- test('no test files: reports it and succeeds (a fresh game is not broken)', async () => {
42
- const dir = gameDir('fresh');
43
- const lines = [];
44
- const result = await testCmd({ cwd: dir, log: (s) => lines.push(s) });
45
- assert.equal(result.ok, true);
46
- assert.equal(result.ran, 0);
47
- assert.match(lines.join('\n'), /no tests yet/i);
48
- });
49
-
50
- test('unit tests run via node --test; failures fail the gate', async () => {
51
- const dir = gameDir('units', {
52
- 'good.test.mjs': `import { test } from 'node:test'; test('ok', () => {});`,
53
- 'bad.test.mjs': `import { test } from 'node:test'; import assert from 'node:assert'; test('no', () => assert.fail('broken'));`,
54
- });
55
- const result = await testCmd({ cwd: dir, log: () => {} });
56
- assert.equal(result.ok, false);
57
- assert.equal(result.ran, 2);
58
- });
59
-
60
- test('smokes get a real dev stack: URL env exported, dev started once, stopped after', async () => {
61
- const dir = gameDir('smoky', {
62
- // The smoke FETCHES the dev URL — like every real smoke. The dev stack's
63
- // servers run in-process in the looop test parent, so the runner must not
64
- // block its own event loop while a smoke runs (a spawnSync here deadlocks
65
- // every request until the child's fetch times out — caught live in the
66
- // sandbox hand-install, 2026-07-10).
67
- 'sees-url.smoke.mjs': `
68
- if (!process.env.LOOOP_TEST_GAME_URL) { console.error('no url'); process.exit(1); }
69
- if (process.env.LOOOP_TEST_GAME_URL !== process.env.GAMEDEV_TEST_GAME_URL) process.exit(1);
70
- const res = await fetch(process.env.LOOOP_TEST_GAME_URL, { signal: AbortSignal.timeout(3000) });
71
- const body = await res.text();
72
- process.exit(res.ok && body.includes('served-by-parent') ? 0 : 1);`,
73
- });
74
- let started = 0;
75
- let stopped = 0;
76
- const { createServer } = await import('node:http');
77
- const server = createServer((req, res) => res.end('served-by-parent'));
78
- await new Promise((r) => server.listen(0, r));
79
- const fakeDev = async () => {
80
- started += 1;
81
- return {
82
- url: `http://localhost:${server.address().port}/games/smoky/index.html`,
83
- stop: () => {
84
- stopped += 1;
85
- server.close();
86
- },
87
- };
88
- };
89
- const result = await testCmd({ cwd: dir, log: () => {}, devFn: fakeDev });
90
- assert.equal(result.ok, true, 'smoke could reach the in-process server while the runner waited');
91
- assert.equal(started, 1, 'one shared dev stack for all smokes');
92
- assert.equal(stopped, 1, 'dev stack torn down');
93
- });
94
-
95
- test('a failing smoke fails the gate and still tears the stack down', async () => {
96
- const dir = gameDir('smoky-bad', {
97
- 'boom.smoke.mjs': `process.exit(1);`,
98
- });
99
- let stopped = 0;
100
- const fakeDev = async () => ({ url: 'http://x/', stop: () => (stopped += 1) });
101
- const result = await testCmd({ cwd: dir, log: () => {}, devFn: fakeDev });
102
- assert.equal(result.ok, false);
103
- assert.equal(stopped, 1);
104
- });
105
-
106
- test('unit-only games never boot the dev stack', async () => {
107
- const dir = gameDir('unit-only', {
108
- 'pure.test.mjs': `import { test } from 'node:test'; test('ok', () => {});`,
109
- });
110
- let started = 0;
111
- const fakeDev = async () => {
112
- started += 1;
113
- return { url: 'http://x/', stop: () => {} };
114
- };
115
- const result = await testCmd({ cwd: dir, log: () => {}, devFn: fakeDev });
116
- assert.equal(result.ok, true);
117
- assert.equal(started, 0);
118
- });
@@ -1,98 +0,0 @@
1
- // `looop update` — the CLI verb the demoted /update skill became (cuqfzo Q6):
2
- // move the game to the latest engine release and re-pin. Pins the contract:
3
- // - newer release available → pin rewritten to latest, ensureEngine installs
4
- // - already on latest (pin + install match) → no reinstall, says so
5
- // - the latest lookup is login-gated like every creator endpoint
6
- import { test, beforeEach, after } from 'node:test';
7
- import assert from 'node:assert/strict';
8
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
9
- import { tmpdir } from 'node:os';
10
- import { join } from 'node:path';
11
- import { update } from './update.mjs';
12
- import { readEnginePin, writeEnginePin } from './engine.mjs';
13
- import { setToken } from './config.mjs';
14
-
15
- const base = mkdtempSync(join(tmpdir(), 'looop-update-test-'));
16
- after(() => rmSync(base, { recursive: true, force: true }));
17
-
18
- let n = 0;
19
- let projectDir;
20
- beforeEach(() => {
21
- projectDir = join(base, `game-${n++}`);
22
- mkdirSync(projectDir, { recursive: true });
23
- writeFileSync(join(projectDir, 'index.html'), '<!doctype html>');
24
- writeFileSync(join(projectDir, 'package.json'), JSON.stringify({ name: 'game', private: true }, null, 2) + '\n');
25
- process.env.LOOOP_HOME = join(base, `home-${n}`);
26
- setToken('looop_' + 'a'.repeat(64), { apiBase: 'http://registry.test' });
27
- });
28
-
29
- function installFakeEngine(dir, version) {
30
- const engineDir = join(dir, 'node_modules', '@looop-games', 'engine');
31
- mkdirSync(join(engineDir, 'shared'), { recursive: true });
32
- mkdirSync(join(engineDir, 'room-server'), { recursive: true });
33
- writeFileSync(join(engineDir, 'package.json'), JSON.stringify({ name: '@looop-games/engine', version }));
34
- }
35
-
36
- const listResponse = (latest) => ({ ok: true, status: 200, json: async () => ({ latest, versions: [latest] }) });
37
-
38
- test('moves the pin to latest and installs through ensureEngine', async () => {
39
- writeEnginePin(projectDir, '0.1.0');
40
- installFakeEngine(projectDir, '0.1.0');
41
- const ensureCalls = [];
42
- const result = await update({
43
- cwd: projectDir,
44
- apiBase: 'http://registry.test',
45
- log: () => {},
46
- fetchImpl: async (url, opts) => {
47
- assert.equal(url, 'http://registry.test/api/creator/engine');
48
- assert.match(opts.headers.Authorization, /^Bearer looop_/);
49
- return listResponse('0.2.0');
50
- },
51
- ensure: async (dir, opts) => {
52
- ensureCalls.push(dir);
53
- installFakeEngine(dir, '0.2.0'); // what the real ensureEngine would do
54
- return { version: '0.2.0' };
55
- },
56
- });
57
- assert.deepEqual(ensureCalls, [projectDir]);
58
- assert.equal(readEnginePin(projectDir), '0.2.0', 'pin rewritten before ensure so it installs the new version');
59
- assert.deepEqual({ from: result.from, to: result.to }, { from: '0.1.0', to: '0.2.0' });
60
- });
61
-
62
- test('already on latest: no reinstall, reports up to date', async () => {
63
- writeEnginePin(projectDir, '0.2.0');
64
- installFakeEngine(projectDir, '0.2.0');
65
- let ensured = 0;
66
- const lines = [];
67
- const result = await update({
68
- cwd: projectDir,
69
- apiBase: 'http://registry.test',
70
- log: (s) => lines.push(s),
71
- fetchImpl: async () => listResponse('0.2.0'),
72
- ensure: async () => {
73
- ensured += 1;
74
- return { version: '0.2.0' };
75
- },
76
- });
77
- assert.equal(ensured, 0, 'nothing reinstalled');
78
- assert.equal(result.to, '0.2.0');
79
- assert.match(lines.join('\n'), /up to date/i);
80
- });
81
-
82
- test('rejected token: clear login hint, pin untouched', async () => {
83
- writeEnginePin(projectDir, '0.1.0');
84
- await assert.rejects(
85
- () =>
86
- update({
87
- cwd: projectDir,
88
- apiBase: 'http://registry.test',
89
- log: () => {},
90
- fetchImpl: async () => ({ ok: false, status: 401 }),
91
- ensure: async () => {
92
- throw new Error('must not reach ensure');
93
- },
94
- }),
95
- /looop login/,
96
- );
97
- assert.equal(readEnginePin(projectDir), '0.1.0');
98
- });