@looop-games/cli 0.1.4 → 0.1.5
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 +1 -1
- package/lib/bundle-primitives.mjs +2 -2
- package/lib/create.mjs +1 -1
- package/lib/dev.mjs +3 -5
- package/lib/feedback.mjs +1 -1
- package/lib/inject.mjs +1 -2
- package/lib/llm-shim.mjs +1 -1
- package/lib/login.mjs +1 -2
- package/lib/ports.mjs +1 -1
- package/lib/publish.mjs +6 -7
- package/lib/static-server.mjs +2 -3
- package/lib/test-cmd.mjs +3 -4
- package/lib/update.mjs +2 -2
- package/package.json +3 -2
- package/lib/bundle-primitives.test.mjs +0 -95
- package/lib/config.test.mjs +0 -37
- package/lib/create.test.mjs +0 -433
- package/lib/dev.test.mjs +0 -52
- package/lib/engine.test.mjs +0 -176
- package/lib/feedback.test.mjs +0 -144
- package/lib/inject.test.mjs +0 -87
- package/lib/llm-shim.test.mjs +0 -90
- package/lib/login.test.mjs +0 -89
- package/lib/npm.test.mjs +0 -61
- package/lib/project.test.mjs +0 -88
- package/lib/publish.test.mjs +0 -148
- package/lib/static-server.test.mjs +0 -161
- package/lib/test-cmd.test.mjs +0 -118
- package/lib/update.test.mjs +0 -98
package/lib/create.test.mjs
DELETED
|
@@ -1,433 +0,0 @@
|
|
|
1
|
-
// `looop create <name>` — the create-next-app gesture (cuqfzo Slice 4): one
|
|
2
|
-
// command produces a standalone game folder where `dev` immediately serves a
|
|
3
|
-
// working multiplayer game. Pins the scaffold contract: a real MP template
|
|
4
|
-
// (imports the room client), a package.json depending on @looop-games/cli
|
|
5
|
-
// (the engine is NOT an npm dependency — Q4 revision: `looop dev` installs it
|
|
6
|
-
// from the platform's login-gated registry via ensureEngine), agent
|
|
7
|
-
// instructions, and a slug-safe name. The full install-and-boot path is
|
|
8
|
-
// covered by packed-install.smoke.mjs.
|
|
9
|
-
import { test, after } from 'node:test';
|
|
10
|
-
import assert from 'node:assert/strict';
|
|
11
|
-
import { execFileSync } from 'node:child_process';
|
|
12
|
-
import { mkdtempSync, rmSync, readFileSync, existsSync, readdirSync, lstatSync } from 'node:fs';
|
|
13
|
-
import { tmpdir } from 'node:os';
|
|
14
|
-
import { join, relative } from 'node:path';
|
|
15
|
-
import { create, TEMPLATE_DIR } from './create.mjs';
|
|
16
|
-
|
|
17
|
-
const base = mkdtempSync(join(tmpdir(), 'looop-create-'));
|
|
18
|
-
after(() => rmSync(base, { recursive: true, force: true }));
|
|
19
|
-
|
|
20
|
-
// Hermetic: create() now resolves the engine pin from the platform — point
|
|
21
|
-
// every test at a dead port (instant refusal → the warn-fallback path).
|
|
22
|
-
// Tests that exercise the pin itself pass an explicit mock apiBase.
|
|
23
|
-
process.env.LOOOP_API_BASE = 'http://127.0.0.1:1';
|
|
24
|
-
|
|
25
|
-
// The scaffold is an on-disk template FOLDER (cuqfzo creator-harness Slice 1),
|
|
26
|
-
// not template strings in code — Fran edits it as normal files. `create`
|
|
27
|
-
// copies it with a {{name}} substitution pass; `gitignore` is stored dot-less
|
|
28
|
-
// (npm pack silently strips .gitignore files) and renamed on copy.
|
|
29
|
-
test('the scaffold is a faithful copy of the on-disk template folder', async () => {
|
|
30
|
-
const { dir } = await create({ name: 'from-template', cwd: base, install: false, log: () => {} });
|
|
31
|
-
|
|
32
|
-
const walk = (d) =>
|
|
33
|
-
readdirSync(d, { withFileTypes: true, recursive: true })
|
|
34
|
-
.filter((e) => e.isFile())
|
|
35
|
-
.map((e) => relative(d, join(e.parentPath, e.name)));
|
|
36
|
-
|
|
37
|
-
for (const file of walk(TEMPLATE_DIR)) {
|
|
38
|
-
const dest = file === 'gitignore' ? '.gitignore' : file;
|
|
39
|
-
assert.ok(existsSync(join(dir, dest)), `template file ${file} lands in the scaffold as ${dest}`);
|
|
40
|
-
const rendered = readFileSync(join(dir, dest), 'utf8');
|
|
41
|
-
assert.ok(!rendered.includes('{{name}}'), `${dest} has no unsubstituted {{name}} placeholders`);
|
|
42
|
-
const source = readFileSync(join(TEMPLATE_DIR, file), 'utf8');
|
|
43
|
-
assert.equal(rendered, source.replaceAll('{{name}}', 'from-template'), `${dest} is the template modulo substitution`);
|
|
44
|
-
}
|
|
45
|
-
// No stray dot-less gitignore in the scaffold.
|
|
46
|
-
assert.ok(!existsSync(join(dir, 'gitignore')));
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test('the template folder ships in the npm package (files list)', () => {
|
|
50
|
-
const pkg = JSON.parse(readFileSync(join(TEMPLATE_DIR, '..', 'package.json'), 'utf8'));
|
|
51
|
-
assert.ok(pkg.files.includes('template'), 'npm pack must carry template/ or the published CLI scaffolds nothing');
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test('scaffolds a complete standalone game folder', async () => {
|
|
55
|
-
const { dir } = await create({ name: 'tower-jump', cwd: base, install: false, log: () => {} });
|
|
56
|
-
assert.equal(dir, join(base, 'tower-jump'));
|
|
57
|
-
|
|
58
|
-
const html = readFileSync(join(dir, 'index.html'), 'utf8');
|
|
59
|
-
assert.match(html, /<title>tower-jump<\/title>/);
|
|
60
|
-
|
|
61
|
-
// The mobile baseline (architecture.md "Mobile baseline (every game)") is
|
|
62
|
-
// REQUIRED boilerplate the template must carry — without it every phone
|
|
63
|
-
// build double-tap-zooms and finger-drag-selects the HUD. All four items:
|
|
64
|
-
assert.match(html, /maximum-scale=1\.0, user-scalable=no, viewport-fit=cover/, '1: locked viewport');
|
|
65
|
-
assert.match(html, /touch-action: manipulation/, '2: no double-tap zoom on the chrome');
|
|
66
|
-
assert.match(html, /gesturestart/, '3: iOS gesture-zoom blocked in JS');
|
|
67
|
-
assert.match(html, /\*\s*\{[^}]*user-select: none/, '4: text selection killed on every element');
|
|
68
|
-
assert.match(html, /input, textarea[^}]*user-select: text/, '4b: typeable fields stay selectable');
|
|
69
|
-
const js = readFileSync(join(dir, 'game.js'), 'utf8');
|
|
70
|
-
assert.match(js, /from '\/shared\/ui\/room\/client\.js'/); // real multiplayer from minute one
|
|
71
|
-
|
|
72
|
-
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
73
|
-
assert.equal(pkg.name, 'tower-jump');
|
|
74
|
-
assert.equal(pkg.private, true);
|
|
75
|
-
// Q4 revision: the engine is not an npm package — no dependency on it.
|
|
76
|
-
// ensureEngine() installs it at first `looop dev` (login-gated) and writes
|
|
77
|
-
// the `looop.engine` pin then.
|
|
78
|
-
assert.equal(pkg.dependencies, undefined);
|
|
79
|
-
assert.ok(pkg.devDependencies['@looop-games/cli']);
|
|
80
|
-
// Smokes need a real browser: playwright ships with every scaffold and
|
|
81
|
-
// create pre-fetches Chromium — never a surprise mid-build install
|
|
82
|
-
// (caught in Fran's manual e2e, 2026-07-10).
|
|
83
|
-
assert.ok(pkg.devDependencies.playwright, 'playwright is a scaffold devDependency');
|
|
84
|
-
assert.match(pkg.scripts.dev, /looop dev/);
|
|
85
|
-
assert.match(pkg.scripts.publish, /looop publish/);
|
|
86
|
-
|
|
87
|
-
// npm is moving install scripts to opt-in (npm 11 warns, npm 12 SKIPS them
|
|
88
|
-
// by default). esbuild and workerd download their native binaries in a
|
|
89
|
-
// postinstall — skip those and `looop dev` cannot start, with no obvious
|
|
90
|
-
// cause. The scaffold ships the approval so a stranger's first install is
|
|
91
|
-
// clean on any npm. Name-only (unpinned) entries: partykit moves its
|
|
92
|
-
// esbuild/workerd pins between releases, and a version-pinned approval
|
|
93
|
-
// would silently stop covering them. Reported by a Windows creator with a
|
|
94
|
-
// strict npm, 2026-07-11.
|
|
95
|
-
assert.equal(pkg.allowScripts.esbuild, true, 'esbuild postinstall pre-approved (native binary)');
|
|
96
|
-
assert.equal(pkg.allowScripts.workerd, true, 'workerd postinstall pre-approved (native binary)');
|
|
97
|
-
// macOS-only optional dep of the watchers; unused on Windows. Verified
|
|
98
|
-
// against a real npm 11 install of the published scaffold: with these three
|
|
99
|
-
// the install is warning-free on every platform.
|
|
100
|
-
assert.equal(pkg.allowScripts.fsevents, true, 'fsevents postinstall pre-approved (mac watcher)');
|
|
101
|
-
|
|
102
|
-
// The agent-facing surface: instructions + a pointer at the practices docs
|
|
103
|
-
// that ship inside the installed engine bundle.
|
|
104
|
-
const agents = readFileSync(join(dir, 'AGENTS.md'), 'utf8');
|
|
105
|
-
assert.match(agents, /looop dev/);
|
|
106
|
-
assert.match(agents, /overrides\/shared\//);
|
|
107
|
-
assert.match(agents, /node_modules\/@looop-games\/engine\/shared\/practices/);
|
|
108
|
-
|
|
109
|
-
assert.match(readFileSync(join(dir, '.gitignore'), 'utf8'), /node_modules/);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
test('scaffolds the agent surface for every vendor (no plugin needed — Q4 addendum)', async () => {
|
|
113
|
-
const { dir } = await create({ name: 'multi-agent', cwd: base, install: false, log: () => {} });
|
|
114
|
-
|
|
115
|
-
// AGENTS.md is canonical; CLAUDE.md and GEMINI.md are thin pointers that
|
|
116
|
-
// inline it (@import) with a plain-text fallback line for tools without
|
|
117
|
-
// import support.
|
|
118
|
-
for (const pointer of ['CLAUDE.md', 'GEMINI.md']) {
|
|
119
|
-
const text = readFileSync(join(dir, pointer), 'utf8');
|
|
120
|
-
assert.match(text, /^@AGENTS\.md$/m, `${pointer} imports AGENTS.md`);
|
|
121
|
-
assert.match(text, /read .*AGENTS\.md/i, `${pointer} has the fallback instruction`);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// The scaffold's .gitignore must NOT swallow the agent files.
|
|
125
|
-
const ignore = readFileSync(join(dir, '.gitignore'), 'utf8');
|
|
126
|
-
assert.ok(!/\.claude/.test(ignore), '.claude/skills travels with the repo');
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
// The creator harness (cuqfzo creator-harness Slice 1, decisions Q2–Q7):
|
|
130
|
-
// Layer 0 AGENTS.md with managed-section markers + routing table, the SIX
|
|
131
|
-
// skills, and the handbook/ seed. This is the product surface a creator's
|
|
132
|
-
// agent runs on — the contract below is what the Slice-2 updater and the
|
|
133
|
-
// friends' cutover depend on.
|
|
134
|
-
test('Layer 0: AGENTS.md has managed-section markers and the routing table', async () => {
|
|
135
|
-
const { dir } = await create({ name: 'layer-zero', cwd: base, install: false, log: () => {} });
|
|
136
|
-
const agents = readFileSync(join(dir, 'AGENTS.md'), 'utf8');
|
|
137
|
-
|
|
138
|
-
// The exact marker tokens the Slice-2 updater rewrites between — a
|
|
139
|
-
// creator's own additions outside them must never be clobbered.
|
|
140
|
-
assert.ok(agents.includes('<!-- looop:managed:start -->'), 'managed-block start marker');
|
|
141
|
-
assert.ok(agents.includes('<!-- looop:managed:end -->'), 'managed-block end marker');
|
|
142
|
-
assert.ok(
|
|
143
|
-
agents.indexOf('<!-- looop:managed:start -->') < agents.indexOf('<!-- looop:managed:end -->'),
|
|
144
|
-
'markers in order',
|
|
145
|
-
);
|
|
146
|
-
|
|
147
|
-
// Routing lines for the demoted skills (Q6): save, update, feel/tweaks,
|
|
148
|
-
// overrides — capabilities without a skill MUST be reachable from Layer 0.
|
|
149
|
-
assert.match(agents, /save/i, 'save routing line');
|
|
150
|
-
assert.match(agents, /looop\.engine/, 'engine-update routing mentions the version pin');
|
|
151
|
-
assert.match(agents, /tweaks/, 'feel-tuning routes at the tweaks library');
|
|
152
|
-
assert.match(agents, /overrides\/shared\//, 'override mechanism reachable from Layer 0');
|
|
153
|
-
|
|
154
|
-
// Layer 0 must NOT re-list the skills' triggers — every major agent CLI has
|
|
155
|
-
// a native skill system and the frontmatter `description` IS the routing
|
|
156
|
-
// (a second copy in AGENTS.md drifts). One orientation mention of the
|
|
157
|
-
// skills directory is all that's allowed.
|
|
158
|
-
assert.ok(!agents.includes('skills/build/SKILL.md'), 'no per-skill routing rows');
|
|
159
|
-
assert.match(agents, /\.claude\/skills/, 'orientation line names the skills directory');
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
test('.agents/skills aliases .claude/skills for non-Claude agent CLIs', async () => {
|
|
163
|
-
// Codex, Gemini/Antigravity, Cursor, and opencode discover project skills
|
|
164
|
-
// at .agents/skills/ — the scaffold aliases it to the same SKILL.md files
|
|
165
|
-
// (created at scaffold time; npm pack cannot ship symlinks).
|
|
166
|
-
const { dir } = await create({ name: 'vendor-skills', cwd: base, install: false, log: () => {} });
|
|
167
|
-
const link = join(dir, '.agents', 'skills');
|
|
168
|
-
assert.ok(lstatSync(link).isSymbolicLink(), '.agents/skills is a symlink');
|
|
169
|
-
assert.deepEqual(
|
|
170
|
-
readdirSync(link).sort(),
|
|
171
|
-
readdirSync(join(dir, '.claude', 'skills')).sort(),
|
|
172
|
-
'both paths expose the same skills',
|
|
173
|
-
);
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
test('the six skills ship, correctly named, no more no fewer (Q6 roster)', async () => {
|
|
177
|
-
const { dir } = await create({ name: 'six-skills', cwd: base, install: false, log: () => {} });
|
|
178
|
-
const skillsDir = join(dir, '.claude', 'skills');
|
|
179
|
-
const roster = ['build', 'engine', 'feedback', 'qa', 'todo', 'update-handbook'];
|
|
180
|
-
assert.deepEqual(readdirSync(skillsDir).sort(), roster, 'exactly the six-skill roster');
|
|
181
|
-
for (const name of roster) {
|
|
182
|
-
const text = readFileSync(join(skillsDir, name, 'SKILL.md'), 'utf8');
|
|
183
|
-
assert.match(text, new RegExp(`^---\\nname: ${name}\\n`), `${name} frontmatter name matches its folder`);
|
|
184
|
-
assert.match(text, /\ndescription: .+/, `${name} has a trigger description`);
|
|
185
|
-
}
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
test('the build loop encodes the locked mechanics (Q3/Q5/Q8)', async () => {
|
|
189
|
-
const { dir } = await create({ name: 'build-loop', cwd: base, install: false, log: () => {} });
|
|
190
|
-
const build = readFileSync(join(dir, '.claude', 'skills', 'build', 'SKILL.md'), 'utf8');
|
|
191
|
-
|
|
192
|
-
assert.match(build, /plan/i, 'note-anchored: the plan is the spine');
|
|
193
|
-
assert.match(build, /milestone/i, 'milestone vocabulary');
|
|
194
|
-
assert.match(build, /step/i, 'step vocabulary');
|
|
195
|
-
// Save-on-accept / revert-to-last-save — and NO invented git ceremony: the
|
|
196
|
-
// pre-milestone "checkpoint" commit was struck by Fran (2026-07-10).
|
|
197
|
-
assert.match(build, /last save/i, 'reject reverts to the last save');
|
|
198
|
-
assert.ok(!/checkpoint/i.test(build), 'no checkpoint mechanic');
|
|
199
|
-
assert.match(build, /never publish/i, 'publish is offered, never automatic');
|
|
200
|
-
assert.match(build, /worktree/i, 'parallel lanes section (Q8)');
|
|
201
|
-
assert.match(build, /consequence/i, 'PM-register discussion (Q5)');
|
|
202
|
-
// The discussion is a HARD gate on plan creation, ported from the studio
|
|
203
|
-
// loop's design-then-execute split (design settles decisions one at a time
|
|
204
|
-
// with a stated lean and visible lock-in; execution runs only from a
|
|
205
|
-
// settled plan). Caught in Fran's manual e2e 2026-07-10: a whole plan +
|
|
206
|
-
// code landed with zero discussion.
|
|
207
|
-
assert.match(build, /only from a plan whose decisions are settled/i, 'building requires a settled plan');
|
|
208
|
-
assert.match(build, /one decision per turn/i, 'decisions are posed one at a time');
|
|
209
|
-
assert.match(build, /problem before the options/i, 'problem explained before options');
|
|
210
|
-
assert.match(build, /your lean/i, 'a lean is stated with the options');
|
|
211
|
-
assert.match(build, /locking in/i, 'decisions are locked in visibly');
|
|
212
|
-
assert.match(build, /never applies when there is no plan yet/i, 'ceremony collapse cannot skip plan creation');
|
|
213
|
-
// Parity-audit fixes (2026-07-10): the remaining /discuss reflexes + the
|
|
214
|
-
// scope/lifecycle/lanes gaps the independent audit found.
|
|
215
|
-
assert.match(build, /re-explain the\s+underlying problem/i, '"I don\'t understand" → back up, not louder');
|
|
216
|
-
assert.match(build, /case for the other option/i, 'the lean is easy to override');
|
|
217
|
-
assert.match(build, /## Out of scope/m, 'plan template has a durable Out-of-scope home');
|
|
218
|
-
assert.match(build, /paused \| abandoned/, 'a plan can be abandoned, distinct from done');
|
|
219
|
-
assert.match(build, /never auto-resolve/i, 'lane merge conflicts stop for the creator');
|
|
220
|
-
|
|
221
|
-
const todo = readFileSync(join(dir, '.claude', 'skills', 'todo', 'SKILL.md'), 'utf8');
|
|
222
|
-
assert.match(todo, /promoted \| abandoned/, 'todos distinguish won\'t-do from did-it');
|
|
223
|
-
assert.match(todo, /one-line why/i, 'abandoning requires the why');
|
|
224
|
-
|
|
225
|
-
// Review cadence (Fran, 2026-07-10): AUTOMATED steps — including the
|
|
226
|
-
// review sub-agents — run after EVERY step; only the playtest is
|
|
227
|
-
// per-milestone. "Milestone-sized reviews" was drift, not a decision.
|
|
228
|
-
assert.match(build, /step's diff/, 'review sub-agents run on each step\'s diff');
|
|
229
|
-
assert.match(build, /only the playtest is per-milestone/i, 'the manual gate is the only per-milestone check');
|
|
230
|
-
|
|
231
|
-
// Seamless-UX handback (Fran, 2026-07-10): the AGENT runs the dev server;
|
|
232
|
-
// the creator gets a link to click, never a command to paste.
|
|
233
|
-
assert.match(build, /you run the server,\s+never the creator/i, 'agent starts/reuses the dev stack itself');
|
|
234
|
-
assert.match(build, /link to click/i, 'the creator receives a URL, not a command');
|
|
235
|
-
const qaSkill = readFileSync(join(dir, '.claude', 'skills', 'qa', 'SKILL.md'), 'utf8');
|
|
236
|
-
assert.match(qaSkill, /already\s+running/i, '/qa manual items run against a live game the agent started');
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
// The harness core that makes the monorepo loop effective must survive the
|
|
240
|
-
// port IN FULL — the parity gate is the whole point of the workstream. The
|
|
241
|
-
// framework itself (automated-vs-manual, the ratchet, the master steps list
|
|
242
|
-
// incl. the review rows and the generative verdict-mix row) has ONE source of
|
|
243
|
-
// truth: the engine's shared/practices/qa.md, shipped inside the artifact.
|
|
244
|
-
// The skills REFERENCE it — they never restate it (that scatter is exactly
|
|
245
|
-
// what qa.md was created to end). These rows pin the reference shape plus the
|
|
246
|
-
// point-of-use pointers that were once silently dropped.
|
|
247
|
-
test('the harness core survives the port: single source referenced, pointers intact', async () => {
|
|
248
|
-
const { dir } = await create({ name: 'harness-core', cwd: base, install: false, log: () => {} });
|
|
249
|
-
const build = readFileSync(join(dir, '.claude', 'skills', 'build', 'SKILL.md'), 'utf8');
|
|
250
|
-
const qa = readFileSync(join(dir, '.claude', 'skills', 'qa', 'SKILL.md'), 'utf8');
|
|
251
|
-
|
|
252
|
-
// Both loop skills anchor on the ONE master doc inside the engine artifact.
|
|
253
|
-
for (const [name, text] of [['build', build], ['qa', qa]]) {
|
|
254
|
-
assert.match(text, /engine\/shared\/practices\/qa\.md/, `${name} points at the master QA doc`);
|
|
255
|
-
}
|
|
256
|
-
assert.match(qa, /steps live there|never restate/i, '/qa declares the single-source rule');
|
|
257
|
-
assert.match(qa, /`npx looop test`/, '/qa names the real test gate');
|
|
258
|
-
|
|
259
|
-
// Point-of-use pointers in the loop (the rows themselves live in qa.md):
|
|
260
|
-
// independent review sub-agents + red→green findings + verdict mix.
|
|
261
|
-
assert.match(build, /sub-agent/i, 'milestone close spawns reviewers');
|
|
262
|
-
assert.match(build, /code.quality/i, 'names the code-quality review row');
|
|
263
|
-
assert.match(build, /coverage|test.opportunit|test.thoroughness/i, 'names the test-coverage review row');
|
|
264
|
-
assert.match(build, /red.*green|failing test.*(first|before)/i, 'findings fixed red→green');
|
|
265
|
-
assert.match(build, /verdict/i, 'generative verdict-mix pointer at milestone close');
|
|
266
|
-
assert.match(build, /diagnose/i, 'diagnose before second-guessing a failed fix');
|
|
267
|
-
assert.match(qa, /sub-agent/i, '/qa maps the review skills to sub-agents');
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
test('handbook/ seeds ship and /qa + /update-handbook wire the three QA sources (Q7)', async () => {
|
|
271
|
-
const { dir } = await create({ name: 'handbook-game', cwd: base, install: false, log: () => {} });
|
|
272
|
-
for (const f of ['qa.md', 'feel.md', 'design.md']) {
|
|
273
|
-
assert.ok(existsSync(join(dir, 'handbook', f)), `handbook/${f} seeded`);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
const qa = readFileSync(join(dir, '.claude', 'skills', 'qa', 'SKILL.md'), 'utf8');
|
|
277
|
-
assert.match(qa, /looop test/, 'source 1: executable files via looop test');
|
|
278
|
-
assert.match(qa, /handbook\/qa\.md/, 'source 2: the game handbook');
|
|
279
|
-
assert.match(qa, /engine\/shared\/practices\/qa\.md/, 'source 3: the engine master list');
|
|
280
|
-
|
|
281
|
-
const ratchet = readFileSync(join(dir, '.claude', 'skills', 'update-handbook', 'SKILL.md'), 'utf8');
|
|
282
|
-
assert.match(ratchet, /handbook\/qa\.md/, 'ratchet writes game QA steps');
|
|
283
|
-
assert.match(ratchet, /feedback/i, 'generic holes route upstream via /feedback');
|
|
284
|
-
// The handbook is the CREATOR's truth — writes are proposed and approved,
|
|
285
|
-
// never slipped in as a side effect (Fran, 2026-07-10).
|
|
286
|
-
assert.match(ratchet, /approval first/i, 'handbook writes gated on creator approval');
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
// /feedback has a REAL transport (`looop feedback` → /api/creator/feedback),
|
|
290
|
-
// and the report is agent-authored: maximal detail, verbatim output, and the
|
|
291
|
-
// conversation transcript — the Looop team can't ask the session follow-ups.
|
|
292
|
-
test('the feedback skill sends via looop feedback and demands the full evidence', async () => {
|
|
293
|
-
const { dir } = await create({ name: 'feedback-game', cwd: base, install: false, log: () => {} });
|
|
294
|
-
const fb = readFileSync(join(dir, '.claude', 'skills', 'feedback', 'SKILL.md'), 'utf8');
|
|
295
|
-
|
|
296
|
-
assert.match(fb, /npx looop feedback/, 'sends through the real CLI transport');
|
|
297
|
-
assert.ok(!/however they normally reach them/.test(fb), 'the interim manual transport is gone');
|
|
298
|
-
assert.match(fb, /## Transcript/m, 'the report carries the conversation transcript');
|
|
299
|
-
assert.match(fb, /verbatim/i, 'output is pasted verbatim, never summarized');
|
|
300
|
-
assert.match(fb, /sent:/, 'documents the sent-stamp so reports never ship twice');
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
// Every notes/ file is datetime-prefixed so the tree lists in creation order
|
|
304
|
-
// and "the latest plan" is findable at a glance (Fran, 2026-07-10 — a flat
|
|
305
|
-
// slug like iso-view.md is unsortable once notes accumulate).
|
|
306
|
-
test('note-writing skills mandate the datetime filename prefix', async () => {
|
|
307
|
-
const { dir } = await create({ name: 'ordered-notes', cwd: base, install: false, log: () => {} });
|
|
308
|
-
for (const [skill, tree] of [['build', 'plans'], ['todo', 'todos'], ['feedback', 'feedback']]) {
|
|
309
|
-
const text = readFileSync(join(dir, '.claude', 'skills', skill, 'SKILL.md'), 'utf8');
|
|
310
|
-
assert.match(
|
|
311
|
-
text,
|
|
312
|
-
new RegExp(`notes/${tree}/<YYYY-MM-DD-HHMM>-`),
|
|
313
|
-
`${skill} names its notes/${tree} files datetime-first`,
|
|
314
|
-
);
|
|
315
|
-
}
|
|
316
|
-
});
|
|
317
|
-
|
|
318
|
-
// The engine pin is resolved AT CREATE TIME from the public version-list
|
|
319
|
-
// endpoint and lands INSIDE the initial commit — the milestone-1 revert
|
|
320
|
-
// baseline. Without this, first dev wrote the pin after the baseline: fresh
|
|
321
|
-
// repos started dirty, and a milestone-1 reject deleted the pin so the next
|
|
322
|
-
// dev could silently resolve a NEWER engine (caught in Fran's manual e2e,
|
|
323
|
-
// 2026-07-10).
|
|
324
|
-
test('create resolves the engine pin into the initial commit; reverts keep it', async (t) => {
|
|
325
|
-
const http = await import('node:http');
|
|
326
|
-
const server = http.createServer((req, res) => {
|
|
327
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
328
|
-
res.end(JSON.stringify(req.url === '/api/creator/engine' ? { versions: ['0.7.3'], latest: '0.7.3' } : {}));
|
|
329
|
-
});
|
|
330
|
-
await new Promise((r) => server.listen(0, r));
|
|
331
|
-
t.after(() => server.close());
|
|
332
|
-
|
|
333
|
-
const { dir } = await create({
|
|
334
|
-
name: 'pinned-game',
|
|
335
|
-
cwd: base,
|
|
336
|
-
install: false,
|
|
337
|
-
apiBase: `http://localhost:${server.address().port}`,
|
|
338
|
-
log: () => {},
|
|
339
|
-
});
|
|
340
|
-
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
341
|
-
assert.equal(pkg.looop.engine, '0.7.3', 'pin written at create');
|
|
342
|
-
|
|
343
|
-
// The pin is IN the baseline commit, so reset --hard can never drop it.
|
|
344
|
-
const shown = execFileSync('git', ['show', 'HEAD:package.json'], { cwd: dir, encoding: 'utf8' });
|
|
345
|
-
assert.match(shown, /"engine": "0\.7\.3"/, 'initial commit carries the pin');
|
|
346
|
-
execFileSync('git', ['reset', '--hard', 'HEAD'], { cwd: dir });
|
|
347
|
-
const after = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
348
|
-
assert.equal(after.looop.engine, '0.7.3', 'revert-to-baseline preserves the pin');
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
test('an unreachable platform degrades to the old pin-at-first-dev behavior', async () => {
|
|
352
|
-
const logs = [];
|
|
353
|
-
const { dir } = await create({ name: 'offline-game', cwd: base, install: false, log: (m) => logs.push(m) });
|
|
354
|
-
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
355
|
-
assert.equal(pkg.looop, undefined, 'no pin when the lookup fails');
|
|
356
|
-
assert.match(logs.join('\n'), /Could not resolve the engine version/, 'the fallback is loud');
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
test('the CLI dependency spec is overridable for tarball installs (the smoke path)', async () => {
|
|
360
|
-
const { dir } = await create({
|
|
361
|
-
name: 'spec-game',
|
|
362
|
-
cwd: base,
|
|
363
|
-
install: false,
|
|
364
|
-
cliSpec: 'file:../cli.tgz',
|
|
365
|
-
log: () => {},
|
|
366
|
-
});
|
|
367
|
-
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
368
|
-
assert.equal(pkg.devDependencies['@looop-games/cli'], 'file:../cli.tgz');
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
// The initial commit is the revert baseline: reject-milestone-1 resets to it,
|
|
372
|
-
// and `git worktree add` (parallel lanes) needs a commit to exist — on a
|
|
373
|
-
// never-committed repo it silently creates an empty orphan lane.
|
|
374
|
-
test('create leaves the scaffold fully committed (the first revert baseline)', async () => {
|
|
375
|
-
const { dir } = await create({ name: 'baseline', cwd: base, install: false, log: () => {} });
|
|
376
|
-
const log = execFileSync('git', ['log', '--oneline'], { cwd: dir }).toString().trim().split('\n');
|
|
377
|
-
assert.equal(log.length, 1, 'exactly one commit');
|
|
378
|
-
const status = execFileSync('git', ['status', '--porcelain'], { cwd: dir }).toString().trim();
|
|
379
|
-
assert.equal(status, '', 'nothing left uncommitted');
|
|
380
|
-
});
|
|
381
|
-
|
|
382
|
-
// A stranger who runs `npx looop create` gets the template AND the practices
|
|
383
|
-
// docs inside the engine artifact. They have never heard of the studio, its
|
|
384
|
-
// monorepo, its CLI, its games, or its people — any mention is a leak (Fran,
|
|
385
|
-
// 2026-07-10: "if they read about the monorepo, it's an issue"). Studio-side
|
|
386
|
-
// content lives in shared/notes/guides/ (never shipped), not here.
|
|
387
|
-
test('no studio leaks in any surface a stranger receives', () => {
|
|
388
|
-
const engineRoot = join(TEMPLATE_DIR, '..', '..', '..');
|
|
389
|
-
const shipped = [];
|
|
390
|
-
const collect = (d) => {
|
|
391
|
-
for (const e of readdirSync(d, { withFileTypes: true, recursive: true })) {
|
|
392
|
-
if (e.isFile()) shipped.push(join(e.parentPath, e.name));
|
|
393
|
-
}
|
|
394
|
-
};
|
|
395
|
-
collect(TEMPLATE_DIR);
|
|
396
|
-
collect(join(engineRoot, 'shared', 'practices'));
|
|
397
|
-
shipped.push(join(engineRoot, 'shared', 'ui', 'INDEX.md'));
|
|
398
|
-
|
|
399
|
-
const FORBIDDEN = [
|
|
400
|
-
/`gamedev|gamedev </i, // the studio's internal CLI ("gamedev literature" prose is fine)
|
|
401
|
-
/\bFran\b/, // the studio's human
|
|
402
|
-
/monorepo/i, // the workspace the reader must never learn exists
|
|
403
|
-
/studio/i, // "the studio" / "studio-only" dual-world framing
|
|
404
|
-
/harness-improvement/, // studio-only skills
|
|
405
|
-
/import-game/,
|
|
406
|
-
/`\/primitives`/,
|
|
407
|
-
/looop-ugc|looop-boxeo/, // studio partykit deploy names
|
|
408
|
-
/looop-core|looop-games\/games\//, // sibling-repo paths (@looop-games/* npm scope is fine)
|
|
409
|
-
/new_game\.py|catalog_publish\.py/, // studio tool paths
|
|
410
|
-
/shared\/notes\//, // the studio notes tree (not in the artifact)
|
|
411
|
-
];
|
|
412
|
-
// Studio game names: banned in the practices docs (there they were
|
|
413
|
-
// references the reader can't open); allowed as plain DATA elsewhere —
|
|
414
|
-
// INDEX.md's used_by column, and template game.js's generic "a tiny
|
|
415
|
-
// multiplayer plaza".
|
|
416
|
-
const PRACTICES_ONLY = [/mp-lab|escaleras|piso-20|booox|wordsmith|survivors|amnesia|\bagora\b|\bplaza\b/];
|
|
417
|
-
|
|
418
|
-
for (const file of shipped) {
|
|
419
|
-
const text = readFileSync(file, 'utf8');
|
|
420
|
-
const extra = file.includes(`${join('shared', 'practices')}`) ? PRACTICES_ONLY : [];
|
|
421
|
-
for (const re of [...FORBIDDEN, ...extra]) {
|
|
422
|
-
const m = text.match(re);
|
|
423
|
-
assert.ok(!m, `${relative(engineRoot, file)} leaks "${m?.[0]}" (${re})`);
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
});
|
|
427
|
-
|
|
428
|
-
test('rejects slug-unsafe names and existing folders', async () => {
|
|
429
|
-
await assert.rejects(() => create({ name: 'My Game!', cwd: base, install: false, log: () => {} }), /letters/);
|
|
430
|
-
await create({ name: 'dupe', cwd: base, install: false, log: () => {} });
|
|
431
|
-
await assert.rejects(() => create({ name: 'dupe', cwd: base, install: false, log: () => {} }), /exists/);
|
|
432
|
-
assert.ok(existsSync(join(base, 'dupe')));
|
|
433
|
-
});
|
package/lib/dev.test.mjs
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
// Per-game server parity (cuqfzo Phase 4, dev half): a game that ships its
|
|
2
|
-
// own `partykit.json` gets its OWN server in `looop dev` — partykit runs with
|
|
3
|
-
// the game folder as cwd (resolving the game's `main` entry, which imports
|
|
4
|
-
// the room server from the installed engine). A game without one rides the
|
|
5
|
-
// bundle's shared room server, exactly as before. This mirrors the monorepo's
|
|
6
|
-
// dev.sh detection, so a vendor-flipped game behaves identically standalone.
|
|
7
|
-
|
|
8
|
-
import { test } from 'node:test';
|
|
9
|
-
import assert from 'node:assert/strict';
|
|
10
|
-
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
11
|
-
import { join } from 'node:path';
|
|
12
|
-
import { tmpdir } from 'node:os';
|
|
13
|
-
import { partykitCwdFor } from './dev.mjs';
|
|
14
|
-
|
|
15
|
-
function tempGameDir(withPartykit) {
|
|
16
|
-
const dir = mkdtempSync(join(tmpdir(), 'looop-devtest-'));
|
|
17
|
-
writeFileSync(join(dir, 'index.html'), '<canvas></canvas>');
|
|
18
|
-
if (withPartykit) {
|
|
19
|
-
writeFileSync(join(dir, 'partykit.json'), JSON.stringify({ name: 'mygame', main: 'server.js' }));
|
|
20
|
-
writeFileSync(join(dir, 'server.js'), 'export { default } from "@looop-games/engine/shared/ui/room/server.js";');
|
|
21
|
-
}
|
|
22
|
-
return dir;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
test('game WITHOUT partykit.json → partykit runs on the bundle room server', () => {
|
|
26
|
-
const dir = tempGameDir(false);
|
|
27
|
-
try {
|
|
28
|
-
const cwd = partykitCwdFor({ dir }, { roomServerDir: '/engine/room' });
|
|
29
|
-
assert.equal(cwd, '/engine/room');
|
|
30
|
-
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
test('game WITH partykit.json → partykit runs on the GAME folder (its own server)', () => {
|
|
34
|
-
const dir = tempGameDir(true);
|
|
35
|
-
try {
|
|
36
|
-
const cwd = partykitCwdFor({ dir }, { roomServerDir: '/engine/room' });
|
|
37
|
-
assert.equal(cwd, dir);
|
|
38
|
-
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
test('a partykit.json without a resolvable main is IGNORED (falls back to the bundle, loudly)', () => {
|
|
42
|
-
// A dead config must not silently break dev — the room server the game
|
|
43
|
-
// actually reaches should still exist.
|
|
44
|
-
const dir = tempGameDir(false);
|
|
45
|
-
writeFileSync(join(dir, 'partykit.json'), JSON.stringify({ name: 'mygame', main: 'missing.js' }));
|
|
46
|
-
try {
|
|
47
|
-
const warnings = [];
|
|
48
|
-
const cwd = partykitCwdFor({ dir }, { roomServerDir: '/engine/room' }, (m) => warnings.push(m));
|
|
49
|
-
assert.equal(cwd, '/engine/room');
|
|
50
|
-
assert.ok(warnings.some((w) => /missing\.js/.test(w)), 'warns about the dead entry');
|
|
51
|
-
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
52
|
-
});
|