@looop-games/cli 0.1.2 → 0.1.3
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/bin/looop.mjs +16 -0
- package/lib/bundle-primitives.mjs +77 -0
- package/lib/bundle-primitives.test.mjs +95 -0
- package/lib/create.mjs +110 -81
- package/lib/create.test.mjs +349 -12
- package/lib/dev.mjs +27 -2
- package/lib/dev.test.mjs +52 -0
- package/lib/engine.mjs +2 -2
- package/lib/feedback.mjs +87 -0
- package/lib/feedback.test.mjs +144 -0
- package/lib/npm.mjs +38 -0
- package/lib/npm.test.mjs +61 -0
- package/lib/publish.mjs +18 -3
- package/lib/publish.test.mjs +44 -0
- package/lib/test-cmd.mjs +135 -0
- package/lib/test-cmd.test.mjs +118 -0
- package/lib/update.mjs +51 -0
- package/lib/update.test.mjs +98 -0
- package/package.json +5 -2
- package/template/.claude/skills/build/SKILL.md +240 -0
- package/template/.claude/skills/engine/SKILL.md +59 -0
- package/template/.claude/skills/feedback/SKILL.md +71 -0
- package/template/.claude/skills/qa/SKILL.md +53 -0
- package/template/.claude/skills/todo/SKILL.md +54 -0
- package/template/.claude/skills/update-handbook/SKILL.md +69 -0
- package/template/AGENTS.md +80 -0
- package/template/CLAUDE.md +4 -0
- package/template/GEMINI.md +4 -0
- package/template/boot.smoke.mjs +21 -0
- package/template/game.js +53 -0
- package/template/gitignore +2 -0
- package/template/handbook/design.md +7 -0
- package/template/handbook/feel.md +8 -0
- package/template/handbook/qa.md +9 -0
- package/template/index.html +26 -0
- package/lib/agent-files.mjs +0 -151
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// `looop feedback` — the client half of the feedback transport (cuqfzo; the
|
|
2
|
+
// send step behind the template's /feedback skill). Against a mock platform,
|
|
3
|
+
// pins the wire contract: every unsent notes/feedback/*.md ships verbatim as
|
|
4
|
+
// a JSON report (title from the H1, slug/engine/cli from the repo) with the
|
|
5
|
+
// creator token as a Bearer header — and a sent file is stamped in its
|
|
6
|
+
// frontmatter so it never ships twice.
|
|
7
|
+
import { test, after } from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import http from 'node:http';
|
|
10
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, realpathSync } from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { sendFeedback } from './feedback.mjs';
|
|
14
|
+
import { setToken, clearToken } from './config.mjs';
|
|
15
|
+
|
|
16
|
+
process.env.LOOOP_HOME = mkdtempSync(join(tmpdir(), 'looop-fb-home-'));
|
|
17
|
+
|
|
18
|
+
// ── mock platform ──
|
|
19
|
+
const received = [];
|
|
20
|
+
let respond = () => ({ status: 200, body: { id: `fb_${'0'.repeat(15)}${received.length}`, createdAt: 1 } });
|
|
21
|
+
const server = http.createServer(async (req, res) => {
|
|
22
|
+
const chunks = [];
|
|
23
|
+
for await (const c of req) chunks.push(c);
|
|
24
|
+
received.push({
|
|
25
|
+
url: req.url,
|
|
26
|
+
auth: req.headers.authorization ?? null,
|
|
27
|
+
report: JSON.parse(Buffer.concat(chunks).toString('utf8')),
|
|
28
|
+
});
|
|
29
|
+
const { status, body } = respond();
|
|
30
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
31
|
+
res.end(JSON.stringify(body));
|
|
32
|
+
});
|
|
33
|
+
await new Promise((r) => server.listen(0, r));
|
|
34
|
+
const apiBase = `http://localhost:${server.address().port}`;
|
|
35
|
+
|
|
36
|
+
// ── fixture standalone game ──
|
|
37
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), 'looop-fb-')));
|
|
38
|
+
const game = join(root, 'my-game');
|
|
39
|
+
mkdirSync(join(game, 'notes/feedback'), { recursive: true });
|
|
40
|
+
writeFileSync(join(game, 'index.html'), '<html></html>');
|
|
41
|
+
writeFileSync(join(game, 'package.json'), JSON.stringify({ name: 'my-game', looop: { engine: '0.1.2' } }));
|
|
42
|
+
|
|
43
|
+
const UNSENT = `---
|
|
44
|
+
created: 2026-07-10
|
|
45
|
+
engine: 0.1.2
|
|
46
|
+
---
|
|
47
|
+
# looop test hangs on smokes
|
|
48
|
+
|
|
49
|
+
**What happened:** …
|
|
50
|
+
|
|
51
|
+
## Transcript
|
|
52
|
+
|
|
53
|
+
> creator: run the tests
|
|
54
|
+
`;
|
|
55
|
+
const ALREADY_SENT = `---
|
|
56
|
+
created: 2026-07-01
|
|
57
|
+
sent: 2026-07-02T10:00:00.000Z
|
|
58
|
+
id: fb_aaaaaaaaaaaaaaaa
|
|
59
|
+
---
|
|
60
|
+
# old report
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
after(() => {
|
|
64
|
+
server.close();
|
|
65
|
+
rmSync(root, { recursive: true, force: true });
|
|
66
|
+
rmSync(process.env.LOOOP_HOME, { recursive: true, force: true });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('sends every unsent report verbatim and stamps it sent', async () => {
|
|
70
|
+
setToken('looop_' + '1'.repeat(64), { apiBase });
|
|
71
|
+
writeFileSync(join(game, 'notes/feedback/test-hangs.md'), UNSENT);
|
|
72
|
+
writeFileSync(join(game, 'notes/feedback/old-report.md'), ALREADY_SENT);
|
|
73
|
+
|
|
74
|
+
const result = await sendFeedback({ cwd: game, apiBase, log: () => {} });
|
|
75
|
+
|
|
76
|
+
// Only the unsent one ships.
|
|
77
|
+
assert.equal(received.length, 1);
|
|
78
|
+
const { url, auth, report } = received[0];
|
|
79
|
+
assert.equal(url, '/api/creator/feedback');
|
|
80
|
+
assert.equal(auth, `Bearer looop_${'1'.repeat(64)}`);
|
|
81
|
+
// The wire report: H1 as title, whole file as body, repo identity attached.
|
|
82
|
+
assert.equal(report.title, 'looop test hangs on smokes');
|
|
83
|
+
assert.equal(report.body, UNSENT); // verbatim — frontmatter, transcript and all
|
|
84
|
+
assert.equal(report.slug, 'my-game');
|
|
85
|
+
assert.equal(report.engine, '0.1.2');
|
|
86
|
+
assert.match(report.cli, /^\d+\.\d+\.\d+/);
|
|
87
|
+
|
|
88
|
+
// The file is stamped with the platform id so it never ships twice.
|
|
89
|
+
assert.equal(result.sent.length, 1);
|
|
90
|
+
assert.match(result.sent[0].id, /^fb_[0-9a-f]{16}$/);
|
|
91
|
+
const stamped = readFileSync(join(game, 'notes/feedback/test-hangs.md'), 'utf8');
|
|
92
|
+
assert.match(stamped, /^sent: /m);
|
|
93
|
+
assert.match(stamped, new RegExp(`^id: ${result.sent[0].id}$`, 'm'));
|
|
94
|
+
|
|
95
|
+
// Second run: nothing left to send.
|
|
96
|
+
const again = await sendFeedback({ cwd: game, apiBase, log: () => {} });
|
|
97
|
+
assert.equal(again.sent.length, 0);
|
|
98
|
+
assert.equal(received.length, 1);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('a report without frontmatter gets a stamp block added', async () => {
|
|
102
|
+
writeFileSync(join(game, 'notes/feedback/bare.md'), '# bare report\n\nbody\n');
|
|
103
|
+
const result = await sendFeedback({ cwd: game, apiBase, log: () => {} });
|
|
104
|
+
assert.equal(result.sent.length, 1);
|
|
105
|
+
const stamped = readFileSync(join(game, 'notes/feedback/bare.md'), 'utf8');
|
|
106
|
+
assert.match(stamped, /^---\nsent: .+\nid: fb_[0-9a-f]{16}\n---\n# bare report/);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('a title falls back to the filename when there is no H1', async () => {
|
|
110
|
+
writeFileSync(join(game, 'notes/feedback/no-heading.md'), 'just prose, no heading\n');
|
|
111
|
+
await sendFeedback({ cwd: game, apiBase, log: () => {} });
|
|
112
|
+
assert.equal(received.at(-1).report.title, 'no-heading');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('not logged in → clear error, nothing sent', async () => {
|
|
116
|
+
const before = received.length;
|
|
117
|
+
writeFileSync(join(game, 'notes/feedback/while-logged-out.md'), '# report\n');
|
|
118
|
+
clearToken();
|
|
119
|
+
await assert.rejects(() => sendFeedback({ cwd: game, apiBase, log: () => {} }), /looop login/);
|
|
120
|
+
assert.equal(received.length, before);
|
|
121
|
+
setToken('looop_' + '1'.repeat(64), { apiBase });
|
|
122
|
+
rmSync(join(game, 'notes/feedback/while-logged-out.md'));
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('a platform reject surfaces the platform message and leaves the file unstamped', async () => {
|
|
126
|
+
writeFileSync(join(game, 'notes/feedback/rejected.md'), '# report\n');
|
|
127
|
+
respond = () => ({ status: 401, body: 'invalid or revoked token — run `looop login` again' });
|
|
128
|
+
await assert.rejects(() => sendFeedback({ cwd: game, apiBase, log: () => {} }), /HTTP 401/);
|
|
129
|
+
respond = () => ({ status: 200, body: { id: `fb_${'e'.repeat(16)}`, createdAt: 1 } });
|
|
130
|
+
const content = readFileSync(join(game, 'notes/feedback/rejected.md'), 'utf8');
|
|
131
|
+
assert.doesNotMatch(content, /^sent:/m); // still unsent — retried next run
|
|
132
|
+
await sendFeedback({ cwd: game, apiBase, log: () => {} }); // clean up: send it
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('no reports at all is a friendly no-op', async () => {
|
|
136
|
+
const bare = join(root, 'quiet-game');
|
|
137
|
+
mkdirSync(bare, { recursive: true });
|
|
138
|
+
writeFileSync(join(bare, 'index.html'), '<html></html>');
|
|
139
|
+
writeFileSync(join(bare, 'package.json'), '{}');
|
|
140
|
+
const logs = [];
|
|
141
|
+
const result = await sendFeedback({ cwd: bare, apiBase, log: (m) => logs.push(m) });
|
|
142
|
+
assert.equal(result.sent.length, 0);
|
|
143
|
+
assert.match(logs.join('\n'), /[Nn]othing to send/);
|
|
144
|
+
});
|
package/lib/npm.mjs
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// The one way the CLI shells out to npm.
|
|
2
|
+
//
|
|
3
|
+
// Why this module exists: on Windows npm is not a binary, it's an `npm.cmd`
|
|
4
|
+
// shim, and Node cannot launch a .bat/.cmd from execFile/spawn. A bare
|
|
5
|
+
// execFileSync('npm', …) dies with ENOENT — which is exactly how `looop
|
|
6
|
+
// create` failed for our first Windows creator (2026-07-11).
|
|
7
|
+
//
|
|
8
|
+
// The obvious fix is a trap: renaming to 'npm.cmd' does NOT work either.
|
|
9
|
+
// Since the CVE-2024-27980 fix (Node >= 18.20.2 / 20.12.2 / 21.7.3) Node
|
|
10
|
+
// refuses to spawn a .bat/.cmd without `shell: true`, so the rename just
|
|
11
|
+
// trades ENOENT for EINVAL. And raw `shell: true` is its own trap: cmd.exe
|
|
12
|
+
// re-parses the argv we hand it, so an unquoted path with a space in it
|
|
13
|
+
// (ensureEngine passes an absolute tarball path, and Windows home dirs are
|
|
14
|
+
// routinely "C:\Users\John Smith\…") silently breaks.
|
|
15
|
+
//
|
|
16
|
+
// cross-spawn does the whole dance: detects the shim, routes it through
|
|
17
|
+
// cmd.exe, escapes the args. It's the standard fix and it's what npm's own
|
|
18
|
+
// tooling uses.
|
|
19
|
+
import spawn from 'cross-spawn';
|
|
20
|
+
|
|
21
|
+
// Callers treat a throw as "npm failed" (create aborts the scaffold,
|
|
22
|
+
// ensureEngine falls back). cross-spawn's sync API mirrors spawnSync — it
|
|
23
|
+
// RETURNS a non-zero status rather than throwing — so re-throw here, or a
|
|
24
|
+
// failed install would sail past every call site as success.
|
|
25
|
+
export function runNpm(args, opts = {}) {
|
|
26
|
+
const res = spawn.sync('npm', args, { stdio: 'pipe', ...opts });
|
|
27
|
+
if (res.error) throw res.error;
|
|
28
|
+
if (res.status !== 0) {
|
|
29
|
+
const err = new Error(
|
|
30
|
+
`npm ${args.join(' ')} failed (exit ${res.status})` +
|
|
31
|
+
(res.stderr?.length ? `\n${res.stderr.toString().trim()}` : ''),
|
|
32
|
+
);
|
|
33
|
+
err.status = res.status;
|
|
34
|
+
err.stderr = res.stderr;
|
|
35
|
+
throw err;
|
|
36
|
+
}
|
|
37
|
+
return res;
|
|
38
|
+
}
|
package/lib/npm.test.mjs
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Every npm invocation in the CLI goes through runNpm. Pins the two things
|
|
2
|
+
// that made `looop create` unusable on Windows (first creator bug report,
|
|
3
|
+
// 2026-07-11: `looop create: spawnSync npm ENOENT`):
|
|
4
|
+
//
|
|
5
|
+
// 1. npm on Windows is `npm.cmd`, and Node's execFile/spawn cannot launch
|
|
6
|
+
// a .bat/.cmd shim. Renaming to 'npm.cmd' does NOT fix it — since the
|
|
7
|
+
// CVE-2024-27980 fix (Node >= 18.20.2) that path throws EINVAL instead
|
|
8
|
+
// of ENOENT. The shim has to go through cmd.exe, which is what
|
|
9
|
+
// cross-spawn does (with the arg escaping the raw `shell: true` route
|
|
10
|
+
// would drop — engine tarball paths live under the user's home, and
|
|
11
|
+
// "C:\Users\John Smith\..." has a space in it).
|
|
12
|
+
// 2. Callers (create, ensureEngine) rely on a FAILED npm install throwing.
|
|
13
|
+
// cross-spawn's sync API returns a status instead, so runNpm re-throws.
|
|
14
|
+
import { test } from 'node:test';
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
import { mkdtempSync, rmSync, readFileSync, readdirSync } from 'node:fs';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { runNpm } from './npm.mjs';
|
|
20
|
+
|
|
21
|
+
const base = mkdtempSync(join(tmpdir(), 'looop-npm-'));
|
|
22
|
+
|
|
23
|
+
test('runNpm reaches the real npm on this platform', () => {
|
|
24
|
+
const out = runNpm(['--version'], { cwd: base, stdio: 'pipe' }).stdout.toString().trim();
|
|
25
|
+
assert.match(out, /^\d+\.\d+\.\d+/, 'npm --version came back, so the binary resolved');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('a failed npm run throws — create/ensureEngine treat a throw as the failure signal', () => {
|
|
29
|
+
assert.throws(
|
|
30
|
+
() => runNpm(['run', 'no-such-script-here'], { cwd: base, stdio: 'pipe' }),
|
|
31
|
+
/npm run no-such-script-here/,
|
|
32
|
+
'non-zero exit surfaces as an Error, not a silent status',
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// The regression guard. The bug was not "one call site got it wrong" — it was
|
|
37
|
+
// that ANY bare 'npm' string reaches Windows broken, and we had two of them
|
|
38
|
+
// (create.mjs and engine.mjs) plus the smokes a Windows CI lane has to run.
|
|
39
|
+
// This test fails the moment someone reintroduces one.
|
|
40
|
+
test('nothing spawns a bare `npm` — every call site goes through runNpm', () => {
|
|
41
|
+
const root = join(import.meta.dirname, '..');
|
|
42
|
+
const files = [
|
|
43
|
+
...readdirSync(join(root, 'lib')).map((f) => join('lib', f)),
|
|
44
|
+
...readdirSync(join(root, 'bin')).map((f) => join('bin', f)),
|
|
45
|
+
...readdirSync(root).filter((f) => f.endsWith('.smoke.mjs')),
|
|
46
|
+
].filter((f) => /\.(mjs|js)$/.test(f) && !f.endsWith('lib/npm.mjs') && !f.endsWith('lib/npm.test.mjs'));
|
|
47
|
+
|
|
48
|
+
const offenders = [];
|
|
49
|
+
for (const f of files) {
|
|
50
|
+
const src = readFileSync(join(root, f), 'utf8');
|
|
51
|
+
src.split('\n').forEach((line, i) => {
|
|
52
|
+
// execFileSync('npm', …) / spawnSync('npm', …) / spawn('npm', …)
|
|
53
|
+
if (/(?:execFileSync|execFile|spawnSync|spawn)\(\s*['"]npm(?:\.cmd)?['"]/.test(line)) {
|
|
54
|
+
offenders.push(`${f}:${i + 1} ${line.trim()}`);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
assert.deepEqual(offenders, [], `bare npm spawn is ENOENT/EINVAL on Windows — use runNpm():\n${offenders.join('\n')}`);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
process.on('exit', () => rmSync(base, { recursive: true, force: true }));
|
package/lib/publish.mjs
CHANGED
|
@@ -16,17 +16,21 @@ import { findProject } from './project.mjs';
|
|
|
16
16
|
import { ensureEngine } from './engine.mjs';
|
|
17
17
|
import { getToken, getApiBase } from './config.mjs';
|
|
18
18
|
import { DEFAULT_API_BASE } from './llm-shim.mjs';
|
|
19
|
+
import { bundlePrimitives, PRIMITIVES_ENTRY } from './bundle-primitives.mjs';
|
|
19
20
|
|
|
20
21
|
export const PLAY_BASE = 'https://play.looop.games';
|
|
21
22
|
|
|
22
|
-
// Never shipped: tooling, VCS, agent workspace, notes
|
|
23
|
-
// repo, not the catalog — same
|
|
24
|
-
|
|
23
|
+
// Never shipped: tooling, VCS, agent workspace, notes + handbook + agent
|
|
24
|
+
// instructions (repo knowledge travels with the repo, not the catalog — same
|
|
25
|
+
// rule as the monorepo publisher; a published game must not expose it on a
|
|
26
|
+
// public URL), tests/smokes.
|
|
27
|
+
const SKIP_DIRS = new Set(['node_modules', 'notes', 'handbook', '.git', '.looop', '.claude', '__pycache__']);
|
|
25
28
|
const SKIP_FILES = [
|
|
26
29
|
/^package(-lock)?\.json$/,
|
|
27
30
|
/\.(test|smoke)\.(js|mjs|ts)$/,
|
|
28
31
|
/^\.DS_Store$/,
|
|
29
32
|
/^gamedev\.toml$/,
|
|
33
|
+
/^(AGENTS|CLAUDE|GEMINI)\.md$/,
|
|
30
34
|
];
|
|
31
35
|
|
|
32
36
|
export function collectGameFiles(dir) {
|
|
@@ -65,6 +69,17 @@ export async function publish({
|
|
|
65
69
|
throw new Error('this game has no index.html at its root — publish needs an entry page.');
|
|
66
70
|
}
|
|
67
71
|
|
|
72
|
+
// Server-backed game (cuqfzo Phase 2 / Option B, Slice 2): its primitives can
|
|
73
|
+
// import npm packages + WASM the platform's bundler-less endpoint can't
|
|
74
|
+
// resolve, so we bundle them HERE (engine marked external) and send the
|
|
75
|
+
// single self-contained module in place of the raw barrel. The endpoint
|
|
76
|
+
// assembles the worker from it + our generated entry + the release runtime.
|
|
77
|
+
if (files.has(PRIMITIVES_ENTRY)) {
|
|
78
|
+
const { source, externals } = await bundlePrimitives(project.dir);
|
|
79
|
+
files.set(PRIMITIVES_ENTRY, Buffer.from(source, 'utf8'));
|
|
80
|
+
log(`Bundled server primitives (${(source.length / 1024).toFixed(0)} KB, engine external${externals.length ? `: ${[...new Set(externals)].length} module(s)` : ''}).`);
|
|
81
|
+
}
|
|
82
|
+
|
|
68
83
|
const meta = { slug: targetSlug, entry: 'index.html', engineVersion: engine.version, files: {} };
|
|
69
84
|
const form = new FormData();
|
|
70
85
|
for (const [path, bytes] of files) {
|
package/lib/publish.test.mjs
CHANGED
|
@@ -51,6 +51,14 @@ writeFileSync(join(game, 'world.js'), 'export const w = 1;');
|
|
|
51
51
|
writeFileSync(join(game, 'assets/s.png'), 'png-bytes');
|
|
52
52
|
writeFileSync(join(game, 'overrides/shared/ui/room/x.js'), 'export const x = 2;');
|
|
53
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');
|
|
54
62
|
writeFileSync(join(game, 'my-game.smoke.mjs'), '// test file');
|
|
55
63
|
writeFileSync(join(game, '.gitignore'), 'node_modules');
|
|
56
64
|
writeFileSync(join(game, 'package.json'), JSON.stringify({ name: 'my-game', dependencies: { '@looop-games/engine': '*' } }));
|
|
@@ -102,3 +110,39 @@ test('a game without index.html is rejected before any upload', async () => {
|
|
|
102
110
|
writeFileSync(join(bare, 'main.js'), '// no entry');
|
|
103
111
|
await assert.rejects(() => publish({ cwd: bare, apiBase, log: () => {} }), /index\.html/);
|
|
104
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
|
+
});
|
package/lib/test-cmd.mjs
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// `looop test` — run the game's own verification gate (cuqfzo creator-harness
|
|
2
|
+
// Slice 1; the successor to the monorepo's `gamedev <name> test`).
|
|
3
|
+
//
|
|
4
|
+
// Discovers the game's test files — `*.test.mjs` (unit, run under
|
|
5
|
+
// `node --test`) and `*.smoke.mjs` (integration, run against a REAL dev
|
|
6
|
+
// stack) — and runs them all. The files ARE the executable QA list: `/qa`
|
|
7
|
+
// merges this gate with handbook/qa.md and the engine's master qa.md.
|
|
8
|
+
//
|
|
9
|
+
// The smoke stack boots on a free shifted port triple so it never seizes a
|
|
10
|
+
// dev server the creator has running on :8000.
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import { join, relative, dirname } from 'node:path';
|
|
15
|
+
import { findProject } from './project.mjs';
|
|
16
|
+
import { dev } from './dev.mjs';
|
|
17
|
+
import { portsFor, portInUse } from './ports.mjs';
|
|
18
|
+
|
|
19
|
+
const SKIP_DIRS = new Set(['node_modules', 'overrides']);
|
|
20
|
+
|
|
21
|
+
export function discoverTestFiles(dir) {
|
|
22
|
+
const unit = [];
|
|
23
|
+
const smokes = [];
|
|
24
|
+
const entries = readdirSync(dir, { withFileTypes: true, recursive: true });
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
if (!entry.isFile()) continue;
|
|
27
|
+
const path = join(entry.parentPath, entry.name);
|
|
28
|
+
const rel = relative(dir, path);
|
|
29
|
+
const parts = rel.split('/');
|
|
30
|
+
if (parts.some((p) => SKIP_DIRS.has(p) || p.startsWith('.'))) continue;
|
|
31
|
+
if (entry.name.endsWith('.test.mjs')) unit.push(path);
|
|
32
|
+
else if (entry.name.endsWith('.smoke.mjs')) smokes.push(path);
|
|
33
|
+
}
|
|
34
|
+
unit.sort();
|
|
35
|
+
smokes.sort();
|
|
36
|
+
return { unit, smokes };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Children run via async spawn, never spawnSync: the smoke dev stack's
|
|
40
|
+
// static + shim servers live in THIS process, and a blocked event loop can't
|
|
41
|
+
// answer a child's requests — spawnSync deadlocks every smoke until its
|
|
42
|
+
// fetches time out (caught live on the first hand-installed repo).
|
|
43
|
+
function run(args, opts) {
|
|
44
|
+
return new Promise((resolve) => {
|
|
45
|
+
const child = spawn(process.execPath, args, { stdio: 'inherit', ...opts });
|
|
46
|
+
child.on('close', (code) => resolve(code ?? 1));
|
|
47
|
+
child.on('error', () => resolve(1));
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function freeTestPort(base = 8100) {
|
|
52
|
+
for (let port = base; port < base + 500; port += 10) {
|
|
53
|
+
const triple = portsFor(port);
|
|
54
|
+
const used = await Promise.all(Object.values(triple).map(portInUse));
|
|
55
|
+
if (!used.some(Boolean)) return port;
|
|
56
|
+
}
|
|
57
|
+
throw new Error('no free port triple found for the test dev stack');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function testCmd({ cwd = process.cwd(), log = console.log, devFn = dev } = {}) {
|
|
61
|
+
const project = findProject(cwd);
|
|
62
|
+
const { unit, smokes } = discoverTestFiles(project.dir);
|
|
63
|
+
|
|
64
|
+
if (unit.length + smokes.length === 0) {
|
|
65
|
+
log(`No tests yet in ${project.dir} — nothing to run.`);
|
|
66
|
+
log('(Unit tests are *.test.mjs; browser smokes are *.smoke.mjs. Anything with an assertion is a regression test — keep it.)');
|
|
67
|
+
return { ok: true, ran: 0 };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let ok = true;
|
|
71
|
+
|
|
72
|
+
// If looop test itself runs under a node --test parent, the inherited
|
|
73
|
+
// NODE_TEST_CONTEXT makes a nested `node --test` exit 0 even on failure —
|
|
74
|
+
// silently green. Strip it for every child.
|
|
75
|
+
const env = { ...process.env };
|
|
76
|
+
delete env.NODE_TEST_CONTEXT;
|
|
77
|
+
|
|
78
|
+
if (unit.length) {
|
|
79
|
+
log(`▶ unit: ${unit.map((f) => relative(project.dir, f)).join(', ')}`);
|
|
80
|
+
if ((await run(['--test', ...unit], { cwd: project.dir, env })) !== 0) ok = false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (smokes.length) {
|
|
84
|
+
// Browser preflight — only when a smoke actually imports playwright (a
|
|
85
|
+
// plain fetch-probe smoke needs none, and test fixtures must stay
|
|
86
|
+
// hermetic). The scaffold ships playwright and `looop create` pre-fetches
|
|
87
|
+
// Chromium; this self-heals older repos and pruned caches so a smoke
|
|
88
|
+
// never dies into a cryptic mid-run download.
|
|
89
|
+
if (smokes.some((f) => /['"]playwright/.test(readFileSync(f, 'utf8')))) {
|
|
90
|
+
let playwrightCli;
|
|
91
|
+
try {
|
|
92
|
+
playwrightCli = join(
|
|
93
|
+
dirname(createRequire(join(project.dir, 'package.json')).resolve('playwright/package.json')),
|
|
94
|
+
'cli.js',
|
|
95
|
+
);
|
|
96
|
+
} catch {
|
|
97
|
+
log('❌ a smoke imports playwright, which is not installed in this game —');
|
|
98
|
+
log(' run: npm i -D playwright (then re-run `looop test`)');
|
|
99
|
+
return { ok: false, ran: unit.length };
|
|
100
|
+
}
|
|
101
|
+
// Instant no-op when the browser is already cached; downloads otherwise.
|
|
102
|
+
if ((await run([playwrightCli, 'install', 'chromium'], { cwd: project.dir, env })) !== 0) {
|
|
103
|
+
log('❌ could not fetch the Chromium test browser (offline?) — smokes need it.');
|
|
104
|
+
return { ok: false, ran: unit.length };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const handle = await devFn({ cwd: project.dir, port: await freeTestPort(), log: () => {} });
|
|
109
|
+
log(`▶ smokes against ${handle.url}`);
|
|
110
|
+
try {
|
|
111
|
+
for (const file of smokes) {
|
|
112
|
+
const rel = relative(project.dir, file);
|
|
113
|
+
const code = await run([file], {
|
|
114
|
+
cwd: project.dir,
|
|
115
|
+
env: {
|
|
116
|
+
...env,
|
|
117
|
+
LOOOP_TEST_GAME_URL: handle.url,
|
|
118
|
+
// The monorepo's smoke convention — exported too, so a game (and
|
|
119
|
+
// its smokes) moving out of looop-games ports without edits.
|
|
120
|
+
GAMEDEV_TEST_GAME_URL: handle.url,
|
|
121
|
+
URL: handle.url,
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
log(` ${code === 0 ? '✅' : '❌'} ${rel}`);
|
|
125
|
+
if (code !== 0) ok = false;
|
|
126
|
+
}
|
|
127
|
+
} finally {
|
|
128
|
+
handle.stop();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
log('');
|
|
133
|
+
log(ok ? `✅ looop test: all green (${unit.length} unit file(s), ${smokes.length} smoke(s))` : '❌ looop test: failures above');
|
|
134
|
+
return { ok, ran: unit.length + smokes.length };
|
|
135
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
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
|
+
});
|
package/lib/update.mjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// `looop update` — move this game to the latest engine release (the CLI verb
|
|
2
|
+
// the demoted /update skill became, cuqfzo Q6). Asks the platform registry
|
|
3
|
+
// for the latest downloadable release, rewrites the `looop.engine` pin, and
|
|
4
|
+
// lets ensureEngine do what it already does on every dev/publish: download
|
|
5
|
+
// (cached), install, re-pin. Slice 2 extends this with the skills/agent-
|
|
6
|
+
// surface reconciliation once those ride the engine artifact.
|
|
7
|
+
import { findProject } from './project.mjs';
|
|
8
|
+
import { ensureEngine, readEnginePin, writeEnginePin } from './engine.mjs';
|
|
9
|
+
import { getToken, getApiBase } from './config.mjs';
|
|
10
|
+
import { login } from './login.mjs';
|
|
11
|
+
import { DEFAULT_API_BASE } from './llm-shim.mjs';
|
|
12
|
+
|
|
13
|
+
export async function update({
|
|
14
|
+
cwd = process.cwd(),
|
|
15
|
+
apiBase = getApiBase(DEFAULT_API_BASE),
|
|
16
|
+
log = console.log,
|
|
17
|
+
fetchImpl = fetch,
|
|
18
|
+
loginFn = login,
|
|
19
|
+
ensure = ensureEngine,
|
|
20
|
+
} = {}) {
|
|
21
|
+
const project = findProject(cwd);
|
|
22
|
+
const from = readEnginePin(project.dir);
|
|
23
|
+
|
|
24
|
+
// Same login gate as every /api/creator endpoint.
|
|
25
|
+
if (!getToken()) {
|
|
26
|
+
log('Checking for engine updates needs your Looop account.');
|
|
27
|
+
await loginFn({ apiBase, log });
|
|
28
|
+
if (!getToken()) throw new Error('login did not produce a token — run `looop login` and retry.');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const res = await fetchImpl(`${apiBase}/api/creator/engine`, {
|
|
32
|
+
headers: { Authorization: `Bearer ${getToken()}` },
|
|
33
|
+
});
|
|
34
|
+
if (res.status === 401) throw new Error('the platform rejected this machine’s token — run `looop login` again.');
|
|
35
|
+
if (!res.ok) throw new Error(`could not list engine releases (HTTP ${res.status})`);
|
|
36
|
+
const { latest } = await res.json();
|
|
37
|
+
if (!latest) throw new Error('the platform has no downloadable engine releases yet.');
|
|
38
|
+
|
|
39
|
+
if (from === latest) {
|
|
40
|
+
log(`✅ Engine ${latest} — already up to date.`);
|
|
41
|
+
return { from, to: latest, updated: false };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Rewrite the pin first; ensureEngine honors it (download → install → pin).
|
|
45
|
+
writeEnginePin(project.dir, latest);
|
|
46
|
+
const engine = await ensure(project.dir, { apiBase, log, fetchImpl });
|
|
47
|
+
log('');
|
|
48
|
+
log(`✅ Engine updated: ${from ?? '(none)'} → ${engine.version}`);
|
|
49
|
+
log(' Republish (`npx looop publish`) when you want the live game on it.');
|
|
50
|
+
return { from, to: engine.version, updated: true };
|
|
51
|
+
}
|