lecodes-cli 0.8.2 → 0.9.1

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/dist/index.js CHANGED
@@ -190,6 +190,23 @@ var resolveInject = () => {
190
190
  return bin;
191
191
  } catch {}
192
192
  throw new Error("chisel binary not found. Install @letary/chisel, or set LECODES_CHISEL_BIN.");
193
+ }, scanModulesChisel = (files) => {
194
+ let bin;
195
+ try {
196
+ bin = resolveChiselBin();
197
+ } catch {
198
+ return null;
199
+ }
200
+ const input = JSON.stringify({ scan: true, files });
201
+ const r = spawnSync(bin, [], { input, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
202
+ if (r.status !== 0 || r.error)
203
+ return null;
204
+ try {
205
+ const parsed = JSON.parse(r.stdout || "{}");
206
+ return parsed.scan ?? null;
207
+ } catch {
208
+ return null;
209
+ }
193
210
  }, bundleProjectChisel = async (entrypoint, files, format, sourcemap, minifyOverride, editor, libraries) => {
194
211
  const entry = normalize(entrypoint);
195
212
  const slugs = new Set(Object.keys(libraries ?? {}));
@@ -262,6 +279,79 @@ var init_bundler = __esm(() => {
262
279
  sdkFilesCache = new Map;
263
280
  });
264
281
 
282
+ // ../sdk/src/compile/detectEntry.ts
283
+ var ENTRY_CONVENTION = "/main.ts", isCode = (path) => /\.[tj]s$/.test(path) && !path.endsWith(".d.ts"), normalize2 = (path) => {
284
+ const parts = [];
285
+ for (const part of path.split("/")) {
286
+ if (!part || part === ".")
287
+ continue;
288
+ if (part === "..")
289
+ parts.pop();
290
+ else
291
+ parts.push(part);
292
+ }
293
+ return "/" + parts.join("/");
294
+ }, dirOf = (path) => {
295
+ const i = path.lastIndexOf("/");
296
+ return i <= 0 ? "/" : path.slice(0, i);
297
+ }, resolveSpec = (files, importer, spec) => {
298
+ if (!spec.startsWith("./") && !spec.startsWith("../") && !spec.startsWith("/"))
299
+ return null;
300
+ const base = normalize2(spec.startsWith("/") ? spec : dirOf(importer) + "/" + spec);
301
+ for (const cand of [base, base + ".ts", base + ".js", base + "/index.ts"]) {
302
+ if (cand in files)
303
+ return cand;
304
+ }
305
+ return null;
306
+ }, IMPORT_RE, EXPORT_RE, scanModulesFallback = (files) => {
307
+ const out = {};
308
+ for (const [path, text] of Object.entries(files)) {
309
+ const imports = [];
310
+ for (const m of text.matchAll(IMPORT_RE)) {
311
+ const target = resolveSpec(files, path, m[1] ?? m[2] ?? "");
312
+ if (target && isCode(target) && !imports.includes(target))
313
+ imports.push(target);
314
+ }
315
+ out[path] = { imports, hasExports: EXPORT_RE.test(text) };
316
+ }
317
+ return out;
318
+ }, detectEntry = (entries, scanner) => {
319
+ const files = {};
320
+ for (const e of entries) {
321
+ if (e.type !== undefined && e.type !== "text")
322
+ continue;
323
+ if (typeof e.text !== "string")
324
+ continue;
325
+ const path = normalize2(e.path.split("\\").join("/"));
326
+ if (isCode(path))
327
+ files[path] = e.text;
328
+ }
329
+ const candidates = Object.keys(files).filter((p) => !p.endsWith(".editor.ts"));
330
+ if (candidates.length === 0)
331
+ return { entry: "", roots: [] };
332
+ const scan = scanner?.(files) ?? scanModulesFallback(files);
333
+ const imported = new Set;
334
+ for (const [path, mod] of Object.entries(scan)) {
335
+ if (path.endsWith(".editor.ts"))
336
+ continue;
337
+ for (const target of mod.imports)
338
+ imported.add(target);
339
+ }
340
+ let roots = candidates.filter((p) => !imported.has(p));
341
+ if (roots.length === 0)
342
+ roots = candidates;
343
+ if (roots.includes(ENTRY_CONVENTION))
344
+ return { entry: ENTRY_CONVENTION, roots };
345
+ const scripts = roots.filter((p) => !scan[p]?.hasExports);
346
+ const pool = scripts.length > 0 ? scripts : roots;
347
+ const bySize = [...pool].sort((a, b) => files[b].length - files[a].length);
348
+ return { entry: bySize[0], roots };
349
+ };
350
+ var init_detectEntry = __esm(() => {
351
+ IMPORT_RE = /(?:^|[\n;])\s*(?:import|export)\b[^'"`]*?\bfrom\s*['"]([^'"\n]+)['"]|(?:^|[\n;])\s*import\s*['"]([^'"\n]+)['"]/g;
352
+ EXPORT_RE = /(?:^|[\n;])\s*export\b/;
353
+ });
354
+
265
355
  // ../sdk/src/compile/sourcemap.ts
266
356
  var offsetSourceMap = (mapJson, lines) => {
267
357
  if (lines <= 0)
@@ -322,25 +412,10 @@ var buildHeader = (name, code, opts = {}) => {
322
412
  };
323
413
 
324
414
  // ../sdk/src/compile/compileProject.ts
325
- var detectEntry = (entries) => {
326
- let entry = "";
327
- let max = -1;
328
- for (const e of entries) {
329
- const ext2 = e.path.split(".").at(-1)?.toLowerCase();
330
- if (e.path.endsWith(".editor.ts"))
331
- continue;
332
- if (e.type === "text" && e.text !== undefined && !e.text.includes("export") && (ext2 === "ts" || ext2 === "js")) {
333
- if (e.text.length > max) {
334
- entry = e.path;
335
- max = e.text.length;
336
- }
337
- }
338
- }
339
- return entry;
340
- }, compileProject = async (opts) => {
341
- const entry = opts.entryOverride ?? detectEntry(opts.entries);
415
+ var compileProject = async (opts) => {
416
+ const entry = opts.entryOverride ?? detectEntry(opts.entries, scanModulesChisel).entry;
342
417
  if (!entry) {
343
- throw new Error("Couldn't find an entrypoint (a top-level .ts/.js file with no `export`). Pass entryOverride.");
418
+ throw new Error("Couldn't find an entrypoint (no .ts/.js project file — name yours main.ts). Pass entryOverride.");
344
419
  }
345
420
  const files = {};
346
421
  for (const e of opts.entries) {
@@ -380,6 +455,7 @@ ${inlineSourceMapComment(offsetSourceMap(map, headerLines))}`;
380
455
  };
381
456
  var init_compileProject = __esm(() => {
382
457
  init_bundler();
458
+ init_detectEntry();
383
459
  });
384
460
 
385
461
  // ../sdk/src/compile/sceneEditor.ts
@@ -780,7 +856,7 @@ var FONT_ID, CODE_FILE, DECLARES_FONT, isIdentStart = (c2) => c2 >= "A" && c2 <=
780
856
  }
781
857
  }
782
858
  return null;
783
- }, norm = (p) => p.replace(/\\/g, "/").replace(/^\//, ""), dirOf = (p) => {
859
+ }, norm = (p) => p.replace(/\\/g, "/").replace(/^\//, ""), dirOf2 = (p) => {
784
860
  const n = norm(p);
785
861
  const i = n.lastIndexOf("/");
786
862
  return i === -1 ? "" : n.slice(0, i);
@@ -823,7 +899,7 @@ var FONT_ID, CODE_FILE, DECLARES_FONT, isIdentStart = (c2) => c2 >= "A" && c2 <=
823
899
  const fileFaceCache = new Map;
824
900
  const resolveCall = (entryPath, call) => {
825
901
  if (call.kind === "file") {
826
- const filePath = resolveRel(dirOf(entryPath), call.arg);
902
+ const filePath = resolveRel(dirOf2(entryPath), call.arg);
827
903
  const target = filePath && entryByPath.get(filePath);
828
904
  if (!target)
829
905
  throw new Error(`${entryPath}: font("${call.arg}"): no such file in the project (resolved to "${filePath || call.arg}").`);
@@ -897,7 +973,7 @@ var FONT_ID, CODE_FILE, DECLARES_FONT, isIdentStart = (c2) => c2 >= "A" && c2 <=
897
973
  }
898
974
  for (const fw of work) {
899
975
  const entryPath = norm(fw.entry.path);
900
- const dir = dirOf(entryPath);
976
+ const dir = dirOf2(entryPath);
901
977
  const modBase = `.${baseOf(entryPath)}.__fonts.ts`;
902
978
  const modPath = dir ? `${dir}/${modBase}` : modBase;
903
979
  const idents = new Map;
@@ -961,6 +1037,8 @@ var init_fontMacro = __esm(() => {
961
1037
  var exports_compile = {};
962
1038
  __export(exports_compile, {
963
1039
  sceneEditorEntries: () => sceneEditorEntries,
1040
+ scanModulesFallback: () => scanModulesFallback,
1041
+ scanModulesChisel: () => scanModulesChisel,
964
1042
  scanFontCalls: () => scanFontCalls,
965
1043
  parseSfnt: () => parseSfnt,
966
1044
  offsetSourceMap: () => offsetSourceMap,
@@ -969,6 +1047,7 @@ __export(exports_compile, {
969
1047
  fontRegistryBase: () => fontRegistryBase,
970
1048
  faceFileName: () => faceFileName,
971
1049
  detectProjectKind: () => detectProjectKind,
1050
+ detectEntry: () => detectEntry,
972
1051
  compileProject: () => compileProject,
973
1052
  bundleProjectWithMap: () => bundleProjectWithMap,
974
1053
  bundleProject: () => bundleProject,
@@ -978,10 +1057,12 @@ __export(exports_compile, {
978
1057
  SCENE_HARNESS_DIR: () => SCENE_HARNESS_DIR,
979
1058
  SCENE_ENTRY_PATH: () => SCENE_ENTRY_PATH,
980
1059
  FONT_REGISTRY: () => FONT_REGISTRY,
1060
+ ENTRY_CONVENTION: () => ENTRY_CONVENTION,
981
1061
  DEFAULT_FONT_REGISTRY_BASE: () => DEFAULT_FONT_REGISTRY_BASE
982
1062
  });
983
1063
  var init_compile = __esm(() => {
984
1064
  init_bundler();
1065
+ init_detectEntry();
985
1066
  init_compileProject();
986
1067
  init_sceneEditor();
987
1068
  init_fontMacro();
@@ -2007,43 +2088,39 @@ var require_main = __commonJS((exports, module) => {
2007
2088
  // package.json
2008
2089
  var package_default = {
2009
2090
  name: "lecodes-cli",
2010
- version: "0.8.2",
2011
- description: "Clone, edit and push LeCodes projects from your machine.",
2012
- type: "module",
2013
- license: "MIT",
2014
- keywords: [
2015
- "lecodes",
2016
- "cli"
2017
- ],
2091
+ version: "0.9.1",
2092
+ dependencies: {
2093
+ "@letary/chisel": "^0.5.1"
2094
+ },
2095
+ devDependencies: {
2096
+ "lecodes-design": "workspace:*",
2097
+ "lecodes-renderer": "workspace:*",
2098
+ "qrcode-terminal": "^0.12.0",
2099
+ "lecodes-3d-editor": "workspace:*",
2100
+ sdk: "workspace:*"
2101
+ },
2102
+ peerDependencies: {
2103
+ "lecodes-design": "^0.3.2",
2104
+ "lecodes-renderer": "^0.7.1",
2105
+ "lecodes-3d-editor": "^0.1.0"
2106
+ },
2018
2107
  bin: {
2019
2108
  lecodes: "dist/index.js"
2020
2109
  },
2110
+ description: "Clone, edit and push LeCodes projects from your machine.",
2111
+ engines: {
2112
+ node: ">=18"
2113
+ },
2021
2114
  files: [
2022
2115
  "dist",
2023
2116
  "runtime",
2024
2117
  "README.md"
2025
2118
  ],
2026
- engines: {
2027
- node: ">=18"
2028
- },
2029
- scripts: {
2030
- icon: "bun scripts/make-icon.ts",
2031
- "vendor-runtime": "bun scripts/vendor-runtime.ts",
2032
- "sync-peers": "bun scripts/sync-peer-versions.ts",
2033
- build: 'bun scripts/vendor-runtime.ts && bun build src/index.ts --target=node --outfile dist/index.js --external @letary/chisel --external lecodes-renderer --external lecodes-design --external lecodes-3d-editor --banner="#!/usr/bin/env node"',
2034
- "build:bin": "bun scripts/build-bin.ts",
2035
- typecheck: "tsc --noEmit",
2036
- dev: "bun run src/index.ts",
2037
- prepublishOnly: "bun run sync-peers && bun run build"
2038
- },
2039
- dependencies: {
2040
- "@letary/chisel": "^0.4.1"
2041
- },
2042
- peerDependencies: {
2043
- "lecodes-design": "^0.3.2",
2044
- "lecodes-renderer": "^0.6.2",
2045
- "lecodes-3d-editor": "^0.1.0"
2046
- },
2119
+ keywords: [
2120
+ "lecodes",
2121
+ "cli"
2122
+ ],
2123
+ license: "MIT",
2047
2124
  peerDependenciesMeta: {
2048
2125
  "lecodes-design": {
2049
2126
  optional: true
@@ -2055,13 +2132,17 @@ var package_default = {
2055
2132
  optional: true
2056
2133
  }
2057
2134
  },
2058
- devDependencies: {
2059
- "lecodes-design": "workspace:*",
2060
- "lecodes-renderer": "workspace:*",
2061
- "qrcode-terminal": "^0.12.0",
2062
- "lecodes-3d-editor": "workspace:*",
2063
- sdk: "workspace:*"
2064
- }
2135
+ scripts: {
2136
+ icon: "bun scripts/make-icon.ts",
2137
+ "vendor-runtime": "bun scripts/vendor-runtime.ts",
2138
+ "sync-peers": "bun scripts/sync-peer-versions.ts",
2139
+ build: 'bun scripts/vendor-runtime.ts && bun build src/index.ts --target=node --outfile dist/index.js --external @letary/chisel --external lecodes-renderer --external lecodes-design --external lecodes-3d-editor --banner="#!/usr/bin/env node"',
2140
+ "build:bin": "bun scripts/build-bin.ts",
2141
+ typecheck: "tsc --noEmit",
2142
+ dev: "bun run src/index.ts",
2143
+ prepublishOnly: "bun run sync-peers && bun run build"
2144
+ },
2145
+ type: "module"
2065
2146
  };
2066
2147
 
2067
2148
  // src/commands/app.ts
@@ -2239,10 +2320,11 @@ var requireApiUrl = (config) => {
2239
2320
  // src/ignore.ts
2240
2321
  import { existsSync as existsSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
2241
2322
  import { join as join3 } from "node:path";
2242
- var BUILTIN_PATTERNS = ["node_modules/", ".git/", "_*", "*.log", ".DS_Store"];
2323
+ var BUILTIN_PATTERNS = ["node_modules/", ".git/", "_*", "*.log", ".DS_Store", ".artifacts/"];
2243
2324
  var DEFAULT_IGNORE_FILE = `# Files matched here are NOT pushed to LeCodes.
2244
2325
  # Only untracked files are affected — assets already in the project stay tracked
2245
- # even if they match a pattern. Built-in defaults (node_modules, _*, *.log) always apply.
2326
+ # even if they match a pattern. Built-in defaults (node_modules, _*, *.log, .artifacts)
2327
+ # always apply.
2246
2328
 
2247
2329
  # Render / preview outputs (the default \`lecodes render --png\` target)
2248
2330
  screenshot.png
@@ -2651,6 +2733,12 @@ var resolveChiselBin2 = () => {
2651
2733
  var chiselBin = resolveChiselBin2();
2652
2734
  if (chiselBin)
2653
2735
  process.env.LECODES_CHISEL_BIN = chiselBin;
2736
+ var ambiguousEntryWarning = (entries) => {
2737
+ const { entry, roots } = detectEntry(entries, scanModulesChisel);
2738
+ if (!entry || roots.length < 2 || roots.includes(ENTRY_CONVENTION))
2739
+ return null;
2740
+ return `Ambiguous entrypoint: nothing imports ${roots.join(", ")} — picked ${entry}. Rename your entry to main.ts (or pass --entry).`;
2741
+ };
2654
2742
  var compileProject2 = (opts) => compileProject({
2655
2743
  entries: opts.entries.map((e) => ({ path: e.path, type: e.type, text: e.text, fileSrc: e.fileSrc, remote: e.remote, shaders: e.shaders })),
2656
2744
  name: opts.name,
@@ -2846,11 +2934,17 @@ var compileImpl = async (args, shell) => {
2846
2934
  makeResourceEntry: (path, absPath) => ({ path, type: "resource", fileSrc: encodeURI(path), absPath })
2847
2935
  });
2848
2936
  note("Compiling…");
2937
+ const entryFlag = flagStr(args, "entry");
2938
+ if (!entryFlag) {
2939
+ const ambiguous = ambiguousEntryWarning(entries);
2940
+ if (ambiguous)
2941
+ warnErr(ambiguous);
2942
+ }
2849
2943
  const js = await compileProject2({
2850
2944
  entries,
2851
2945
  name,
2852
2946
  publicUrl,
2853
- entryOverride: flagStr(args, "entry"),
2947
+ entryOverride: entryFlag,
2854
2948
  header: !flagBool(args, "no-header"),
2855
2949
  localAssets,
2856
2950
  fontsHeader,
@@ -4322,6 +4416,73 @@ var clone = async (args) => {
4322
4416
  // src/commands/create.ts
4323
4417
  import { existsSync as existsSync11, mkdirSync as mkdirSync8, readdirSync as readdirSync6, writeFileSync as writeFileSync9 } from "node:fs";
4324
4418
  import { join as join13, resolve as resolve4 } from "node:path";
4419
+
4420
+ // src/commands/projectTemplates.ts
4421
+ var appClaudeMd = (name) => `# ${name} — LeCodes app
4422
+
4423
+ Guidance for AI assistants working in this folder. Human docs: README.md. The whole SDK is
4424
+ available as globals (\`UIScreen\`, \`UIText\`, \`Scene2D\`, …) — no SDK imports. \`main.ts\` is
4425
+ the entry point; split code into other \`.ts\` files and import them (relative imports only) —
4426
+ a file no other file imports is treated as an entry candidate, so keep everything reachable
4427
+ from \`main.ts\`.
4428
+
4429
+ ## Feedback loop — run the app headless instead of guessing
4430
+
4431
+ \`\`\`
4432
+ lecodes render # semantic JSON of the launched UI (structure, text, names, values)
4433
+ lecodes render --png shot.png # pixels (needs @napi-rs/canvas installed)
4434
+ lecodes render --script <file> # drive the app through a scenario (format below)
4435
+ lecodes test # all tests/*.flow.json + design-derived flows; run before "done"
4436
+ \`\`\`
4437
+
4438
+ Iterate with \`--script\` scenarios and keep the useful ones as \`tests/<name>.flow.json\` — they
4439
+ are the regression suite. A failing step dumps the failing frame (JSON + PNG) under
4440
+ \`tests/.artifacts/\` so a red step explains itself.
4441
+
4442
+ ## Scenario format (tests/*.flow.json and --script files)
4443
+
4444
+ One action per step (\`tap\`, \`type\`+\`text\`, \`fill\`, \`scroll\` [+\`by\`], \`wait\`), then an
4445
+ optional \`expect\` (\`screen\`, \`node\`/\`notNode\`, \`text\`/\`notText\`,
4446
+ \`value: { target, equals }\`), then outputs (\`screenshot\`, \`json\`). Targets resolve by
4447
+ \`name\` → exact visible text → substring → input placeholder. Envelope keys: \`name\`,
4448
+ \`width\`/\`height\`, \`settle\`, \`fixtures\`, \`steps\`.
4449
+
4450
+ \`\`\`json
4451
+ { "steps": [
4452
+ { "expect": { "screen": "home", "text": "Taps: 0" } },
4453
+ { "tap": "tap", "expect": { "text": "Taps: 1" }, "screenshot": "after.png" }
4454
+ ] }
4455
+ \`\`\`
4456
+
4457
+ ## Make the app testable
4458
+
4459
+ - Give interactive elements a \`name\`: \`UIButton(...).style({ name: "login" })\`.
4460
+ - Name every screen's ROOT node with the screen's id — \`expect.screen\` reads it.
4461
+ - If the project has a \`design/\` folder, keep the design's screen ids and activator \`name\`s
4462
+ in the app code: \`lecodes test\` then checks the built app against the designed navigation
4463
+ automatically (conventions in design/CLAUDE.md).
4464
+ - Backend-gated screens: canned responses in \`tests/fixtures.json\` (URL pattern →
4465
+ \`{ status?, json?, text? }\`; \`*\` wildcards full-match, plain strings substring-match, first
4466
+ match wins). \`lecodes test\` auto-loads it for design-derived flows; a scenario file opts in
4467
+ with \`"fixtures": "fixtures.json"\` in its envelope.
4468
+ - \`fill\` fills every empty input — use it before tapping a disabled-until-filled submit.
4469
+
4470
+ ## Other commands
4471
+
4472
+ \`lecodes dev\` (run on a device with hot reload + QR), \`lecodes compile -o app.js\`
4473
+ (single-file bundle), \`lecodes design\` (screen-design board — the living spec).
4474
+ `;
4475
+ var STARTER_FLOW = `{
4476
+ "name": "counter",
4477
+ "steps": [
4478
+ { "expect": { "screen": "home", "text": "Taps: 0" } },
4479
+ { "tap": "tap", "expect": { "text": "Taps: 1" } },
4480
+ { "tap": "tap", "expect": { "text": "Taps: 2" } }
4481
+ ]
4482
+ }
4483
+ `;
4484
+
4485
+ // src/commands/create.ts
4325
4486
  var slug2 = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
4326
4487
  var STARTER = `// Welcome to le.codes — no imports needed, the whole SDK is available as globals.
4327
4488
  const scene = new Scene2D({ background: '#10131a' })
@@ -4351,6 +4512,7 @@ var create = async (args) => {
4351
4512
  writeTsconfig(root);
4352
4513
  writeDefaultIgnore(root);
4353
4514
  writeFileSync9(join13(root, "main.ts"), STARTER);
4515
+ writeFileSync9(join13(root, "CLAUDE.md"), appClaudeMd(name));
4354
4516
  const commits = await getCommits(apiUrl, token, uuid);
4355
4517
  writeManifest(root, { uuid, apiUrl, name, baseHeadId: commits.headId, files: {} });
4356
4518
  success(`Created ${c.bold(name)} in ${c.bold(dir)}/`);
@@ -4866,15 +5028,8 @@ var browserLogin = async (webBase, timeoutMs = 5 * 60 * 1000, open = openBrowser
4866
5028
  }
4867
5029
  };
4868
5030
 
4869
- // src/commands/designTemplates.ts
4870
- var META_JSON = `{
4871
- "screens": {
4872
- "home": { "pos": [0, 0], "desc": "Example screen — replace me", "role": "list" }
4873
- },
4874
- "edges": []
4875
- }
4876
- `;
4877
- var SPEC_MD = `# Concept
5031
+ // ../lecodes-design/src/core/concept.ts
5032
+ var SPEC_TEMPLATE = `# Concept
4878
5033
 
4879
5034
  The narrative half of this design. \`meta.json\` + the canvas hold the *visual/flow* half (screens,
4880
5035
  positions, edges); this file holds what never renders as a screen — the data model, the actors, and
@@ -4908,6 +5063,16 @@ which is the screen's *kind* — list, detail, form…)
4908
5063
  Behaviour that isn't visible in a single screen — validation, permissions, notifications, edge
4909
5064
  cases — and the decisions you've made and why, so they aren't re-litigated later.
4910
5065
  `;
5066
+
5067
+ // ../lecodes-design/src/templates.ts
5068
+ var SPEC_MD = SPEC_TEMPLATE;
5069
+ var META_JSON = `{
5070
+ "screens": {
5071
+ "home": { "pos": [0, 0], "desc": "Example screen — replace me", "role": "list" }
5072
+ },
5073
+ "edges": []
5074
+ }
5075
+ `;
4911
5076
  var TOKENS_TS = `// Design tokens shared by every screen. Colors are hex strings (the engine parses hex only).
4912
5077
  // App font: one theme() call here sets the default text font everywhere. font("id") installs a
4913
5078
  // registry family (vendored at compile time) and returns its name — no loading code needed.
@@ -5070,6 +5235,30 @@ positions or descriptions.
5070
5235
  \`lecodes design check\` lists screens still untagged (never blocking). *(A screen's **states** are
5071
5236
  a separate thing — declared in the source, not meta; see "Screen states" below.)*
5072
5237
 
5238
+ ## The design is testable — names carry into the app
5239
+
5240
+ Every edge with an \`activator\` doubles as a **flow test** for the real app: \`lecodes test\` (run
5241
+ at the project root once the app exists) walks the graph from the start screen, taps each edge's
5242
+ activator, and expects to land on a screen named like the edge's target. The design stays a living
5243
+ contract instead of a discarded sketch — but only if the app build keeps the wiring:
5244
+
5245
+ - **Keep the design's \`name\`s** when translating a screen to app code — every element an edge's
5246
+ \`activator\` references keeps its \`name\` (\`.style({ name: "submit" })\`).
5247
+ - **Name each app screen's root with its design screen id** — \`UIScreen([ … ]).style({ name:
5248
+ "login" })\` — that's how the runner knows where a tap landed.
5249
+ - Edges with no activator (backend/system-driven) and \`@state\` edges are reported as *not
5250
+ machine-verifiable* — cover the important ones with hand-written \`tests/*.flow.json\` scenarios
5251
+ (same step schema as \`lecodes render --script\`; tab-bar navigation needs these too, since the
5252
+ bar draws no edges).
5253
+ - Backend-gated transitions (a submit that must succeed to navigate) run headless through
5254
+ \`tests/fixtures.json\` — canned API responses (URL pattern → \`{ status?, json?, text? }\`)
5255
+ that \`lecodes test\` loads automatically for the derived flows. Shape them after the app's
5256
+ API types; the spec.md data model is their first draft.
5257
+
5258
+ Names also appear in every headless render (JSON snapshots, the board's live elements), so they
5259
+ double as stable selectors for any scripted interaction — naming interactive elements is never
5260
+ wasted work.
5261
+
5073
5262
  ## The concept — spec.md
5074
5263
 
5075
5264
  \`meta.json\` + the canvas are the *visual/flow* half of the design; \`spec.md\` is the *narrative*
@@ -5291,7 +5480,6 @@ var README_HINT = `# design/
5291
5480
  LeCodes Design canvas — run \`lecodes design\` here and open the printed URL.
5292
5481
  See CLAUDE.md for the conventions (it's written for AI assistants, but it's the spec).
5293
5482
  `;
5294
-
5295
5483
  // src/commands/design.ts
5296
5484
  var DEFAULT_DIR = "design";
5297
5485
  var DEFAULT_PORT = 4477;
@@ -5981,6 +6169,11 @@ var startDevServer = async (options) => {
5981
6169
  publicUrl: origin,
5982
6170
  makeResourceEntry: (path, absPath) => ({ path, type: "resource", fileSrc: `${base}/assets` + encodeURI(path), absPath })
5983
6171
  });
6172
+ if (!entryOverride) {
6173
+ const ambiguous = ambiguousEntryWarning(entries);
6174
+ if (ambiguous)
6175
+ warnings.push(ambiguous);
6176
+ }
5984
6177
  const js = await compileProject2({ entries, name, publicUrl: origin, entryOverride, header: true, fontsHeader });
5985
6178
  const changed = bundleJs === null || stripVolatile(js) !== stripVolatile(bundleJs);
5986
6179
  if (changed) {
@@ -6472,10 +6665,12 @@ const button = UIButton(
6472
6665
  justifyContent: "center",
6473
6666
  },
6474
6667
  [UIText({ color: "#ffffff", fontSize: 22 }, "Tap me")],
6475
- ).onClick(() => {
6476
- count++
6477
- counter.text = "Taps: " + count
6478
- })
6668
+ )
6669
+ .style({ name: "tap" }) // names make elements targetable in tests/*.flow.json (see CLAUDE.md)
6670
+ .onClick(() => {
6671
+ count++
6672
+ counter.text = "Taps: " + count
6673
+ })
6479
6674
 
6480
6675
  const screen = UIScreen(
6481
6676
  { backgroundColor: "#1e2230", alignItems: "center", justifyContent: "center", padding: 40 },
@@ -6485,7 +6680,7 @@ const screen = UIScreen(
6485
6680
  [UIText({ color: "#ffffff", fontSize: 32 }, "Hello, LeCodes"), button, counter],
6486
6681
  ),
6487
6682
  ],
6488
- )
6683
+ ).style({ name: "home" }) // the screen root's name is what tests' \`expect: { screen: … }\` reads
6489
6684
 
6490
6685
  screen.open()
6491
6686
  `;
@@ -6501,6 +6696,16 @@ lecodes dev # live on your phone: scan the QR with the LeCodes
6501
6696
  lecodes compile -o app.js # bundle to a single self-contained JS file
6502
6697
  \`\`\`
6503
6698
 
6699
+ ## Test it (no device needed)
6700
+
6701
+ \`\`\`
6702
+ lecodes render --png shot.png # headless screenshot of the running app
6703
+ lecodes test # run tests/*.flow.json — the starter ships with a passing one
6704
+ \`\`\`
6705
+
6706
+ Scenario format + conventions: see [CLAUDE.md](./CLAUDE.md) (written for AI assistants, useful
6707
+ for humans too).
6708
+
6504
6709
  ## Go native
6505
6710
 
6506
6711
  \`\`\`
@@ -6511,8 +6716,11 @@ lecodes app open # open it in Xcode, build & run
6511
6716
 
6512
6717
  ## Layout
6513
6718
 
6514
- - \`main.ts\` — the app. Add more top-level .ts files freely; the largest export-free one
6515
- is picked as the entry point.
6719
+ - \`main.ts\` — the app's entry point. Add more .ts files freely and import them from
6720
+ \`main.ts\`; a file named \`main.ts\` is always the entry.
6721
+ - \`tests/\` — headless flow tests (\`lecodes test\`); failing steps dump the frame to
6722
+ \`tests/.artifacts/\`
6723
+ - \`CLAUDE.md\` — guidance for AI assistants (the headless feedback loop + testability conventions)
6516
6724
  - \`assets/\` — images/fonts the app references (created on demand)
6517
6725
  - \`tsconfig.json\` + \`.lecodes/types/\` — generated IDE types (rewritten by the CLI; don't edit)
6518
6726
  - \`.lecodesignore\` — files a future \`lecodes push\` would skip
@@ -6538,6 +6746,8 @@ var init2 = async (args) => {
6538
6746
  };
6539
6747
  write("main.ts", STARTER2);
6540
6748
  write("README.md", readmeTemplate(name));
6749
+ write("CLAUDE.md", appClaudeMd(name));
6750
+ write("tests/counter.flow.json", STARTER_FLOW);
6541
6751
  write(".lecodesignore", DEFAULT_IGNORE_FILE);
6542
6752
  const wroteTypes = materializeTypesLocal(root);
6543
6753
  writeTsconfig(root);
@@ -6549,6 +6759,7 @@ var init2 = async (args) => {
6549
6759
  success(created ? `Local project ${c.bold(name)} ready${dirArg === "." ? "" : ` in ${c.bold(dirArg)}/`}.` : `Project ${c.bold(name)} already initialized.`);
6550
6760
  const cd = dirArg === "." ? "" : `cd ${dirArg} && `;
6551
6761
  log(` ${c.bold(cd + "lecodes dev")} run it on a device with hot reload`);
6762
+ log(` ${c.bold(cd + "lecodes test")} run the flow tests headless (no device)`);
6552
6763
  log(` ${c.bold(cd + "lecodes compile -o app.js")} bundle it for LeCodes Desktop`);
6553
6764
  };
6554
6765
 
@@ -6564,50 +6775,47 @@ var login = async (args) => {
6564
6775
  };
6565
6776
 
6566
6777
  // src/commands/render.ts
6567
- import { writeFileSync as writeFileSync14 } from "node:fs";
6568
- import { resolve as resolve6 } from "node:path";
6778
+ import { mkdirSync as mkdirSync13, readFileSync as readFileSync17, writeFileSync as writeFileSync14 } from "node:fs";
6779
+ import { dirname as dirname11, resolve as resolve6 } from "node:path";
6780
+
6781
+ // src/compile/headlessBundle.ts
6569
6782
  var LOCAL_ASSET_SCHEME2 = "lecodesfile://";
6570
- var DEVICE_SAFE_AREA = {
6571
- none: { insets: [0, 0, 0, 0], bars: [] },
6572
- iphone: { insets: [59, 0, 34, 0], bars: [] },
6573
- android: { insets: [24, 0, 48, 0], bars: ["top", "bottom"] }
6574
- };
6575
- var resolveSafeArea = (args) => {
6576
- const explicit = flagStr(args, "safe-area");
6577
- if (explicit) {
6578
- const n = explicit.split(",").map((s) => Number(s.trim()));
6579
- if (n.length === 4 && n.every((v) => Number.isFinite(v)))
6580
- return { safeArea: n, safeAreaBars: [] };
6581
- throw new CliError(`--safe-area expects "top,right,bottom,left" (4 numbers), got "${explicit}".`);
6582
- }
6583
- const device = flagStr(args, "device");
6584
- if (device) {
6585
- const preset = DEVICE_SAFE_AREA[device];
6586
- if (!preset)
6587
- throw new CliError(`Unknown --device "${device}". Options: ${Object.keys(DEVICE_SAFE_AREA).join(", ")}.`);
6588
- return { safeArea: preset.insets, safeAreaBars: preset.bars };
6783
+ var compileHeadlessBundle = async (args) => {
6784
+ let root;
6785
+ let manifest;
6786
+ try {
6787
+ root = findProjectRoot(process.cwd());
6788
+ manifest = readManifest(root);
6789
+ } catch {
6790
+ root = process.cwd();
6791
+ manifest = null;
6589
6792
  }
6590
- return;
6591
- };
6592
- var render = async (args) => {
6593
- const timeoutMs = Number(flagStr(args, "timeout")) || 15000;
6594
- const killer = setTimeout(() => {
6595
- warnErr(`Render timed out after ${timeoutMs}ms (try --settle / --wait-network, or raise --timeout).`);
6596
- process.exit(1);
6597
- }, timeoutMs);
6598
- killer.unref?.();
6599
- const root = findProjectRoot(process.cwd());
6600
- const manifest = readManifest(root);
6601
- const config = loadConfig();
6602
- const apiUrl = requireApiUrl(config);
6603
- const token = requireToken(config);
6604
- note("Resolving project assets…");
6605
- const project = await getProject(apiUrl, token, manifest.uuid);
6606
- const { entries, warnings: collectWarnings } = collectEntries(root, manifest, project);
6607
6793
  const publicUrlFlag = flagStr(args, "public-url");
6608
- const useLocalAssets = !publicUrlFlag && !flagBool(args, "remote-assets");
6794
+ const useLocalAssets = manifest ? !publicUrlFlag && !flagBool(args, "remote-assets") : true;
6795
+ let entries;
6796
+ let warnings;
6797
+ let name;
6798
+ let publicUrl;
6799
+ if (manifest) {
6800
+ const config = loadConfig();
6801
+ const apiUrl = requireApiUrl(config);
6802
+ const token = requireToken(config);
6803
+ note("Resolving project assets…");
6804
+ const project = await getProject(apiUrl, token, manifest.uuid);
6805
+ ({ entries, warnings } = collectEntries(root, manifest, project));
6806
+ name = manifest.name;
6807
+ publicUrl = useLocalAssets ? LOCAL_ASSET_SCHEME2 : publicUrlFlag ?? apiUrl;
6808
+ if (useLocalAssets)
6809
+ warnings = warnings.filter((w) => !w.includes("No server URL"));
6810
+ } else {
6811
+ ({ entries, warnings } = collectLocalEntries(root, null, ""));
6812
+ name = root.split(/[\\/]/).pop() ?? "project";
6813
+ publicUrl = LOCAL_ASSET_SCHEME2;
6814
+ if (flagBool(args, "remote-assets") || publicUrlFlag) {
6815
+ warnings.push("This is a local project (no server) — assets always load from the working tree.");
6816
+ }
6817
+ }
6609
6818
  const compileEntries = useLocalAssets ? entries.map((e) => e.type === "resource" && e.absPath ? { ...e, fileSrc: encodeURIComponent(e.absPath) } : e) : entries;
6610
- const warnings = useLocalAssets ? collectWarnings.filter((w) => !w.includes("No server URL")) : collectWarnings;
6611
6819
  await applyProjectFonts(compileEntries, useLocalAssets ? {
6612
6820
  mode: "vendor",
6613
6821
  root,
@@ -6616,11 +6824,17 @@ var render = async (args) => {
6616
6824
  makeResourceEntry: (path, absPath) => ({ path, type: "resource", fileSrc: encodeURIComponent(absPath), absPath })
6617
6825
  } : { mode: "cdn", root });
6618
6826
  note("Compiling…");
6827
+ const entryFlag = flagStr(args, "entry");
6828
+ if (!entryFlag) {
6829
+ const ambiguous = ambiguousEntryWarning(compileEntries);
6830
+ if (ambiguous)
6831
+ warnings.push(ambiguous);
6832
+ }
6619
6833
  const js = await compileProject2({
6620
6834
  entries: compileEntries,
6621
- name: manifest.name,
6622
- publicUrl: useLocalAssets ? LOCAL_ASSET_SCHEME2 : publicUrlFlag ?? apiUrl,
6623
- entryOverride: flagStr(args, "entry"),
6835
+ name,
6836
+ publicUrl,
6837
+ entryOverride: entryFlag,
6624
6838
  header: false,
6625
6839
  format: "iife"
6626
6840
  });
@@ -6630,30 +6844,141 @@ var render = async (args) => {
6630
6844
  if (js.includes("_creator2d") && !useLocalAssets && !flagBool(args, "wait-network")) {
6631
6845
  warnings.push("2D scene detected: sprite textures load over the network — add --wait-network to include them (else sprites render as flat placeholders).");
6632
6846
  }
6633
- let renderer;
6847
+ return { js, root, warnings, useLocalAssets };
6848
+ };
6849
+
6850
+ // src/commands/render.ts
6851
+ var DEVICE_SAFE_AREA = {
6852
+ none: { insets: [0, 0, 0, 0], bars: [] },
6853
+ iphone: { insets: [59, 0, 34, 0], bars: [] },
6854
+ android: { insets: [24, 0, 48, 0], bars: ["top", "bottom"] }
6855
+ };
6856
+ var resolveSafeAreaChoice = (explicit, device) => {
6857
+ if (explicit) {
6858
+ const n = explicit.split(",").map((s) => Number(s.trim()));
6859
+ if (n.length === 4 && n.every((v) => Number.isFinite(v)))
6860
+ return { safeArea: n, safeAreaBars: [] };
6861
+ throw new CliError(`--safe-area expects "top,right,bottom,left" (4 numbers), got "${explicit}".`);
6862
+ }
6863
+ if (device) {
6864
+ const preset = DEVICE_SAFE_AREA[device];
6865
+ if (!preset)
6866
+ throw new CliError(`Unknown --device "${device}". Options: ${Object.keys(DEVICE_SAFE_AREA).join(", ")}.`);
6867
+ return { safeArea: preset.insets, safeAreaBars: preset.bars };
6868
+ }
6869
+ return;
6870
+ };
6871
+ var requireRenderer = async () => {
6634
6872
  try {
6635
- renderer = await import("lecodes-renderer/headless");
6873
+ return await import("lecodes-renderer/headless");
6636
6874
  } catch {
6637
6875
  throw new CliError("The renderer isn't installed. Run: npm i -D lecodes-renderer");
6638
6876
  }
6639
- const width = Number(flagStr(args, "width")) || 390;
6640
- const height = Number(flagStr(args, "height")) || 844;
6641
- const settleStr = flagStr(args, "settle");
6877
+ };
6878
+ var fileScenarioIO = (dir) => ({
6879
+ write: (relPath, data) => {
6880
+ const abs = resolve6(dir, relPath);
6881
+ mkdirSync13(dirname11(abs), { recursive: true });
6882
+ writeFileSync14(abs, data);
6883
+ }
6884
+ });
6885
+ var resolveFixtures = (value, baseDir) => {
6886
+ if (value === undefined)
6887
+ return;
6888
+ let map = value;
6889
+ if (typeof value === "string") {
6890
+ const abs = resolve6(baseDir, value);
6891
+ try {
6892
+ map = JSON.parse(readFileSync17(abs, "utf8"));
6893
+ } catch (e) {
6894
+ throw new CliError(`Can't read fixtures "${value}": ${e instanceof Error ? e.message : String(e)}`);
6895
+ }
6896
+ }
6897
+ if (typeof map !== "object" || map === null || Array.isArray(map)) {
6898
+ throw new CliError(`Fixtures must be an object mapping URL patterns to { status?, json?, text? }.`);
6899
+ }
6900
+ return map;
6901
+ };
6902
+ var readScenarioFile = (path) => {
6903
+ let raw;
6904
+ try {
6905
+ raw = JSON.parse(readFileSync17(resolve6(path), "utf8"));
6906
+ } catch (e) {
6907
+ throw new CliError(`Can't read scenario "${path}": ${e instanceof Error ? e.message : String(e)}`);
6908
+ }
6909
+ if (Array.isArray(raw))
6910
+ return { steps: raw };
6911
+ if (typeof raw !== "object" || raw === null)
6912
+ throw new CliError(`Scenario "${path}" must be a JSON object with "steps" (or a bare step array).`);
6913
+ const file = raw;
6914
+ if (file.steps === undefined)
6915
+ throw new CliError(`Scenario "${path}" has no "steps".`);
6916
+ return file;
6917
+ };
6918
+ var render = async (args) => {
6919
+ const scriptFlag = flagStr(args, "script");
6920
+ const scenario = scriptFlag !== undefined ? readScenarioFile(scriptFlag) : null;
6921
+ const stepCount = scenario && Array.isArray(scenario.steps) ? scenario.steps.length : 0;
6922
+ const timeoutMs = Number(flagStr(args, "timeout")) || 15000 + stepCount * 3000;
6923
+ const killer = setTimeout(() => {
6924
+ warnErr(`Render timed out after ${timeoutMs}ms (try --settle / --wait-network, or raise --timeout).`);
6925
+ process.exit(1);
6926
+ }, timeoutMs);
6927
+ killer.unref?.();
6928
+ const { js, warnings, useLocalAssets } = await compileHeadlessBundle(args);
6929
+ const renderer = await requireRenderer();
6930
+ const width = Number(flagStr(args, "width")) || scenario?.width || 390;
6931
+ const height = Number(flagStr(args, "height")) || scenario?.height || 844;
6932
+ const settleStr = flagStr(args, "settle") ?? (scenario?.settle !== undefined ? String(scenario.settle) : undefined);
6642
6933
  const timeStr = flagStr(args, "time");
6643
6934
  const onConsole = flagBool(args, "logs") ? (level, text2) => logErr(`${c.dim(`[${level}]`)} ${text2}`) : undefined;
6935
+ const envelopeFixtures = resolveFixtures(scenario?.fixtures, scriptFlag !== undefined ? dirname11(resolve6(scriptFlag)) : ".");
6936
+ const flagFixtures = resolveFixtures(flagStr(args, "fixtures"), ".");
6937
+ const fixtures = envelopeFixtures || flagFixtures ? { ...envelopeFixtures, ...flagFixtures } : undefined;
6938
+ if (fixtures) {
6939
+ try {
6940
+ renderer.validateFixtures(fixtures);
6941
+ } catch (e) {
6942
+ throw new CliError(e instanceof Error ? e.message : String(e));
6943
+ }
6944
+ }
6644
6945
  const timing = {
6645
6946
  settleMs: settleStr !== undefined ? Number(settleStr) : undefined,
6646
6947
  waitNetwork: flagBool(args, "wait-network"),
6647
6948
  localAssets: useLocalAssets,
6949
+ fixtures,
6648
6950
  timeMs: timeStr !== undefined ? Number(timeStr) : undefined,
6649
6951
  afterAnimations: !flagBool(args, "no-after-animations"),
6650
- ...resolveSafeArea(args),
6952
+ ...resolveSafeAreaChoice(flagStr(args, "safe-area") ?? scenario?.safeArea, flagStr(args, "device") ?? scenario?.device),
6651
6953
  onConsole
6652
6954
  };
6653
- const done = (flush) => flush(() => {
6955
+ const done = (flush, code = 0) => flush(() => {
6654
6956
  clearTimeout(killer);
6655
- process.exit(0);
6957
+ process.exit(code);
6656
6958
  });
6959
+ if (scenario) {
6960
+ const steps = renderer.parseScenarioSteps(scenario.steps);
6961
+ const outDir = flagStr(args, "out-dir") ?? ".";
6962
+ const needsRaster = steps.some((s) => s.screenshot !== undefined);
6963
+ note(`Running ${steps.length} step${steps.length === 1 ? "" : "s"}…`);
6964
+ const session = await renderer.createSession(js, { width, height, ...timing, raster: needsRaster ? true : "auto" });
6965
+ try {
6966
+ const result2 = await renderer.runScenario(session, steps, fileScenarioIO(outDir));
6967
+ for (const w of [...warnings, ...session.warnings])
6968
+ warnErr(w);
6969
+ for (const st of result2.steps) {
6970
+ logErr(st.ok ? `${c.green("✓")} ${st.desc}` : `${c.red("✗")} ${st.desc}
6971
+ ${c.red(st.error ?? "failed")}`);
6972
+ }
6973
+ if (!result2.ok && result2.failureArtifacts.length > 0) {
6974
+ logErr(c.dim(` failing frame: ${result2.failureArtifacts.map((p) => resolve6(outDir, p)).join(", ")}`));
6975
+ }
6976
+ logErr(result2.ok ? c.green(`✓ ${result2.steps.length}/${steps.length} steps passed`) : c.red(`✗ failed at step ${result2.steps.length} of ${steps.length}`));
6977
+ return done((cb) => cb(), result2.ok ? 0 : 1);
6978
+ } finally {
6979
+ session.dispose();
6980
+ }
6981
+ }
6657
6982
  const pngFlag = flagStr(args, "png") ?? (flagBool(args, "png") ? "screenshot.png" : undefined);
6658
6983
  if (pngFlag !== undefined) {
6659
6984
  note("Rendering PNG…");
@@ -6694,8 +7019,8 @@ var render = async (args) => {
6694
7019
  };
6695
7020
 
6696
7021
  // src/commands/pull.ts
6697
- import { existsSync as existsSync17, mkdirSync as mkdirSync13, rmSync as rmSync2, writeFileSync as writeFileSync15 } from "node:fs";
6698
- import { dirname as dirname11, join as join19 } from "node:path";
7022
+ import { existsSync as existsSync17, mkdirSync as mkdirSync14, rmSync as rmSync2, writeFileSync as writeFileSync15 } from "node:fs";
7023
+ import { dirname as dirname12, join as join19 } from "node:path";
6699
7024
  var pull = async (args) => {
6700
7025
  const root = findProjectRoot(process.cwd());
6701
7026
  const manifest = readManifest(root);
@@ -6755,7 +7080,7 @@ var pullPaths = async (root, manifest, project, paths, apiUrl, token) => {
6755
7080
  for (const asset of targets) {
6756
7081
  const key = pathById.get(asset.id);
6757
7082
  const abs = join19(root, ...key.replace(/^\//, "").split("/"));
6758
- mkdirSync13(dirname11(abs), { recursive: true });
7083
+ mkdirSync14(dirname12(abs), { recursive: true });
6759
7084
  let sha;
6760
7085
  if (asset.type === "file") {
6761
7086
  if (!asset.file?.src)
@@ -6778,7 +7103,7 @@ var pullPaths = async (root, manifest, project, paths, apiUrl, token) => {
6778
7103
 
6779
7104
  // src/commands/push.ts
6780
7105
  import { basename as basename10 } from "node:path";
6781
- import { readFileSync as readFileSync17 } from "node:fs";
7106
+ import { readFileSync as readFileSync18 } from "node:fs";
6782
7107
  var push = async (args) => {
6783
7108
  const root = findProjectRoot(process.cwd());
6784
7109
  const manifest = readManifest(root);
@@ -6837,9 +7162,9 @@ var push = async (args) => {
6837
7162
  if (source)
6838
7163
  entry.assetId = source.id;
6839
7164
  if (isTextKind(kind)) {
6840
- entry.text = readFileSync17(file.absPath, "utf8");
7165
+ entry.text = readFileSync18(file.absPath, "utf8");
6841
7166
  } else if (!source || source.sha !== file.sha) {
6842
- const blob = await uploadBlob(apiUrl, token, manifest.uuid, basename10(file.path), readFileSync17(file.absPath));
7167
+ const blob = await uploadBlob(apiUrl, token, manifest.uuid, basename10(file.path), readFileSync18(file.absPath));
6843
7168
  entry.blobFileId = blob.id;
6844
7169
  uploaded++;
6845
7170
  }
@@ -6892,11 +7217,11 @@ var reportPublish = (res) => {
6892
7217
  };
6893
7218
 
6894
7219
  // src/commands/scene.ts
6895
- import { existsSync as existsSync19, readFileSync as readFileSync19, readdirSync as readdirSync8, statSync as statSync4, writeFileSync as writeFileSync16 } from "node:fs";
6896
- import { basename as basename11, dirname as dirname12, join as join21, resolve as resolve7 } from "node:path";
7220
+ import { existsSync as existsSync19, readFileSync as readFileSync20, readdirSync as readdirSync8, statSync as statSync4, writeFileSync as writeFileSync16 } from "node:fs";
7221
+ import { basename as basename11, dirname as dirname13, join as join21, resolve as resolve7 } from "node:path";
6897
7222
 
6898
7223
  // src/compile/sceneCompile.ts
6899
- import { existsSync as existsSync18, readFileSync as readFileSync18 } from "node:fs";
7224
+ import { existsSync as existsSync18, readFileSync as readFileSync19 } from "node:fs";
6900
7225
  import { join as join20 } from "node:path";
6901
7226
  var TEXT_EXT = /\.(ts|js|json|svg|txt|md)$/i;
6902
7227
  var rel = (key) => key.replace(/^\//, "");
@@ -6907,7 +7232,7 @@ var resolveHarness = (loadSceneHarness2) => {
6907
7232
  } catch {
6908
7233
  const vendored = join20(distRoot, "runtime", "scene-harness.json");
6909
7234
  if (existsSync18(vendored))
6910
- return JSON.parse(readFileSync18(vendored, "utf8"));
7235
+ return JSON.parse(readFileSync19(vendored, "utf8"));
6911
7236
  throw new CliError("scene-editor harness sources not found — reinstall the CLI or set LECODES_SCENE_HARNESS_DIR.");
6912
7237
  }
6913
7238
  };
@@ -6919,7 +7244,7 @@ var compileSceneEditor = async (opts) => {
6919
7244
  for (const file of scan.files) {
6920
7245
  const path = "./" + rel(file.path);
6921
7246
  if (TEXT_EXT.test(path)) {
6922
- entries.push({ path, type: "text", text: readFileSync18(file.absPath, "utf8") });
7247
+ entries.push({ path, type: "text", text: readFileSync19(file.absPath, "utf8") });
6923
7248
  } else {
6924
7249
  entries.push({ path, type: "resource", fileSrc: "/project/" + rel(file.path) });
6925
7250
  }
@@ -6963,7 +7288,7 @@ var getContext2 = (args) => {
6963
7288
  if (existsSync19(abs) && statSync4(abs).isDirectory())
6964
7289
  root = abs;
6965
7290
  else if (abs.endsWith(".scene.ts") && existsSync19(abs))
6966
- root = dirname12(abs);
7291
+ root = dirname13(abs);
6967
7292
  else
6968
7293
  throw new CliError(`No such folder or .scene.ts: ${pathArg}`);
6969
7294
  }
@@ -6976,7 +7301,7 @@ var writeMcpConfig2 = (root, port) => {
6976
7301
  let config = {};
6977
7302
  if (existsSync19(path)) {
6978
7303
  try {
6979
- config = JSON.parse(readFileSync19(path, "utf8"));
7304
+ config = JSON.parse(readFileSync20(path, "utf8"));
6980
7305
  } catch {
6981
7306
  warnErr(".mcp.json exists but isn't valid JSON — left it untouched. Add the lecodes-scene server by hand.");
6982
7307
  return;
@@ -7068,23 +7393,254 @@ var status = async (_args) => {
7068
7393
  info(c.dim(` ${scan.ignored.length} ignored (.lecodesignore)`));
7069
7394
  };
7070
7395
 
7396
+ // src/commands/test.ts
7397
+ import { existsSync as existsSync20, readFileSync as readFileSync21, readdirSync as readdirSync9 } from "node:fs";
7398
+ import { dirname as dirname14, resolve as resolve8 } from "node:path";
7399
+ var deriveDesignFlows = (meta, entryScreen) => {
7400
+ const edges = meta.edges ?? [];
7401
+ const isState = (id) => id.includes("@");
7402
+ const verifiable = edges.filter((e) => e.activator && !isState(e.from) && !isState(e.to));
7403
+ const prev = new Map;
7404
+ const visited = new Set([entryScreen]);
7405
+ const queue = [entryScreen];
7406
+ while (queue.length > 0) {
7407
+ const at = queue.shift();
7408
+ for (const e of verifiable) {
7409
+ if (e.from !== at || visited.has(e.to))
7410
+ continue;
7411
+ visited.add(e.to);
7412
+ prev.set(e.to, e);
7413
+ queue.push(e.to);
7414
+ }
7415
+ }
7416
+ const pathTo = (screen) => {
7417
+ if (screen === entryScreen)
7418
+ return [];
7419
+ if (!visited.has(screen))
7420
+ return null;
7421
+ const hops = [];
7422
+ for (let at = screen;at !== entryScreen; at = hops[0].from)
7423
+ hops.unshift(prev.get(at));
7424
+ return hops;
7425
+ };
7426
+ const flows = [];
7427
+ const skipped = [];
7428
+ for (const e of edges) {
7429
+ const name = `${e.from} —${e.activator ?? "?"}→ ${e.to}`;
7430
+ if (!e.activator) {
7431
+ skipped.push({ name, reason: "no activator (backend/gesture-driven) — cover it with a tests/*.flow.json" });
7432
+ continue;
7433
+ }
7434
+ if (isState(e.from) || isState(e.to)) {
7435
+ skipped.push({ name, reason: "screen-state edge (@state) — states are runtime conditions, not screens" });
7436
+ continue;
7437
+ }
7438
+ const path = pathTo(e.from);
7439
+ if (path === null) {
7440
+ skipped.push({ name, reason: `"${e.from}" has no activator path from "${entryScreen}" (tab-bar navigation?) — cover it with a tests/*.flow.json` });
7441
+ continue;
7442
+ }
7443
+ flows.push({
7444
+ name,
7445
+ kind: "flow",
7446
+ steps: [
7447
+ ...path.flatMap((hop) => [{ fill: {} }, { tap: hop.activator, expect: { screen: hop.to } }]),
7448
+ { fill: {} },
7449
+ { tap: e.activator, expect: { screen: e.to } }
7450
+ ]
7451
+ });
7452
+ }
7453
+ return { flows, skipped };
7454
+ };
7455
+ var slug3 = (s) => s.replace(/[^a-zA-Z0-9-_]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "case";
7456
+ var readFlowFile = (abs, displayName) => {
7457
+ let raw;
7458
+ try {
7459
+ raw = JSON.parse(readFileSync21(abs, "utf8"));
7460
+ } catch (e) {
7461
+ throw new CliError(`Can't read "${displayName}": ${e instanceof Error ? e.message : String(e)}`);
7462
+ }
7463
+ if (Array.isArray(raw))
7464
+ return { name: displayName, kind: "file", steps: raw };
7465
+ if (typeof raw !== "object" || raw === null || raw.steps === undefined) {
7466
+ throw new CliError(`"${displayName}" must be a JSON object with "steps" (or a bare step array).`);
7467
+ }
7468
+ const file = raw;
7469
+ return {
7470
+ name: file.name ?? displayName,
7471
+ kind: "file",
7472
+ steps: file.steps,
7473
+ fixtures: resolveFixtures(file.fixtures, dirname14(abs)),
7474
+ width: file.width,
7475
+ height: file.height,
7476
+ settle: file.settle,
7477
+ device: file.device,
7478
+ safeArea: file.safeArea
7479
+ };
7480
+ };
7481
+ var test = async (args) => {
7482
+ const { js, warnings, useLocalAssets, root } = await compileHeadlessBundle(args);
7483
+ const renderer = await requireRenderer();
7484
+ const testsDir = resolve8(root, flagStr(args, "dir") ?? "tests");
7485
+ const designMetaPath = resolve8(root, flagStr(args, "design-dir") ?? "design", "meta.json");
7486
+ const asJson = flagBool(args, "json");
7487
+ const out = asJson ? logErr : log;
7488
+ const cases = [];
7489
+ if (args._.length > 0) {
7490
+ for (const given of args._) {
7491
+ const candidates = [resolve8(given), resolve8(testsDir, given), resolve8(testsDir, `${given}.flow.json`)];
7492
+ const abs = candidates.find((p) => existsSync20(p));
7493
+ if (!abs)
7494
+ throw new CliError(`No such test: "${given}" (looked in ${testsDir}).`);
7495
+ cases.push(readFlowFile(abs, given));
7496
+ }
7497
+ } else if (existsSync20(testsDir)) {
7498
+ for (const f2 of readdirSync9(testsDir).filter((f3) => f3.endsWith(".flow.json")).sort()) {
7499
+ cases.push(readFlowFile(resolve8(testsDir, f2), f2));
7500
+ }
7501
+ }
7502
+ const fixturesFlag = flagStr(args, "fixtures");
7503
+ const defaultPack = resolve8(testsDir, "fixtures.json");
7504
+ const flowFixtures = fixturesFlag !== undefined ? resolveFixtures(fixturesFlag, ".") : existsSync20(defaultPack) ? resolveFixtures("fixtures.json", testsDir) : undefined;
7505
+ const flowsOnly = flagBool(args, "flows");
7506
+ const wantFlows = !flagBool(args, "no-flows") && args._.length === 0;
7507
+ if (flowsOnly)
7508
+ cases.length = 0;
7509
+ let skipped = [];
7510
+ let entryScreen = null;
7511
+ if ((wantFlows || flowsOnly) && existsSync20(designMetaPath)) {
7512
+ let meta;
7513
+ try {
7514
+ meta = JSON.parse(readFileSync21(designMetaPath, "utf8"));
7515
+ } catch (e) {
7516
+ throw new CliError(`Can't read ${designMetaPath}: ${e instanceof Error ? e.message : String(e)}`);
7517
+ }
7518
+ if ((meta.edges ?? []).length > 0) {
7519
+ note("Probing the start screen…");
7520
+ const probe = await renderer.createSession(js, { raster: false, localAssets: useLocalAssets, fixtures: flowFixtures });
7521
+ entryScreen = probe.screenName();
7522
+ probe.dispose();
7523
+ if (entryScreen === null) {
7524
+ warnErr('Design flows skipped: the app\'s start screen root has no `name`. Name each screen root with its design screen id (e.g. UIScreen([...], { name: "login" })).');
7525
+ } else {
7526
+ const derived = deriveDesignFlows(meta, entryScreen);
7527
+ cases.push(...derived.flows.map((f2) => ({ ...f2, fixtures: flowFixtures })));
7528
+ skipped = derived.skipped;
7529
+ }
7530
+ }
7531
+ } else if (flowsOnly) {
7532
+ throw new CliError(`--flows needs a design graph (${designMetaPath} not found).`);
7533
+ }
7534
+ if (cases.length === 0) {
7535
+ for (const w of warnings)
7536
+ warnErr(w);
7537
+ throw new CliError(`No tests found. Write tests/*.flow.json scenarios, or draw activator edges in the design board (design/meta.json).`);
7538
+ }
7539
+ const timeoutMs = Number(flagStr(args, "timeout")) || 20000 + cases.length * 1e4;
7540
+ const killer = setTimeout(() => {
7541
+ warnErr(`Test run timed out after ${timeoutMs}ms (raise --timeout or lower --settle).`);
7542
+ process.exit(1);
7543
+ }, timeoutMs);
7544
+ killer.unref?.();
7545
+ const onConsole = flagBool(args, "logs") ? (level, text) => logErr(`${c.dim(`[${level}]`)} ${text}`) : undefined;
7546
+ const artifactsRoot = resolve8(root, flagStr(args, "artifacts") ?? resolve8(testsDir, ".artifacts"));
7547
+ const results = [];
7548
+ for (const kase of cases) {
7549
+ const flagSettle = flagStr(args, "settle");
7550
+ let result;
7551
+ try {
7552
+ const steps = renderer.parseScenarioSteps(kase.steps);
7553
+ const session = await renderer.createSession(js, {
7554
+ width: Number(flagStr(args, "width")) || kase.width || 390,
7555
+ height: Number(flagStr(args, "height")) || kase.height || 844,
7556
+ settleMs: flagSettle !== undefined ? Number(flagSettle) : kase.settle,
7557
+ waitNetwork: flagBool(args, "wait-network"),
7558
+ localAssets: useLocalAssets,
7559
+ fixtures: kase.fixtures,
7560
+ ...resolveSafeAreaChoice(flagStr(args, "safe-area") ?? kase.safeArea, flagStr(args, "device") ?? kase.device),
7561
+ onConsole
7562
+ });
7563
+ try {
7564
+ const io = fileScenarioIO(resolve8(artifactsRoot, slug3(kase.name)));
7565
+ const outcome = await renderer.runScenario(session, steps, io);
7566
+ result = {
7567
+ name: kase.name,
7568
+ kind: kase.kind,
7569
+ ok: outcome.ok,
7570
+ steps: outcome.steps.map((s) => ({ desc: s.desc, ok: s.ok, error: s.error })),
7571
+ artifacts: outcome.failureArtifacts.map((p) => resolve8(artifactsRoot, slug3(kase.name), p))
7572
+ };
7573
+ } finally {
7574
+ session.dispose();
7575
+ }
7576
+ } catch (e) {
7577
+ result = { name: kase.name, kind: kase.kind, ok: false, steps: [], error: e instanceof Error ? e.message : String(e), artifacts: [] };
7578
+ }
7579
+ results.push(result);
7580
+ if (result.ok) {
7581
+ out(`${c.green("✓")} ${result.name}`);
7582
+ } else {
7583
+ out(`${c.red("✗")} ${result.name}`);
7584
+ if (result.error)
7585
+ out(` ${c.red(result.error)}`);
7586
+ for (const s of result.steps) {
7587
+ out(s.ok ? c.dim(` ✓ ${s.desc}`) : ` ${c.red(`✗ ${s.desc} — ${s.error}`)}`);
7588
+ }
7589
+ if (result.artifacts.length > 0)
7590
+ out(c.dim(` failing frame: ${result.artifacts.join(", ")}`));
7591
+ }
7592
+ }
7593
+ for (const w of warnings)
7594
+ warnErr(w);
7595
+ const passed = results.filter((r) => r.ok).length;
7596
+ const failed = results.length - passed;
7597
+ if (skipped.length > 0) {
7598
+ out("");
7599
+ out(c.dim(`Not machine-verifiable (${skipped.length}):`));
7600
+ for (const s of skipped)
7601
+ out(c.dim(` - ${s.name}: ${s.reason}`));
7602
+ }
7603
+ out("");
7604
+ const flowCount = results.filter((r) => r.kind === "flow").length;
7605
+ const summary = [
7606
+ `${passed} passed`,
7607
+ failed > 0 ? c.red(`${failed} failed`) : undefined,
7608
+ skipped.length > 0 ? `${skipped.length} unverifiable` : undefined,
7609
+ flowCount > 0 ? c.dim(`(${flowCount} from the design graph${entryScreen ? `, start: ${entryScreen}` : ""})`) : undefined
7610
+ ].filter(Boolean).join(", ");
7611
+ out(failed > 0 ? `${c.red("✗")} ${summary}` : `${c.green("✓")} ${summary}`);
7612
+ if (asJson) {
7613
+ process.stdout.write(JSON.stringify({
7614
+ ok: failed === 0,
7615
+ passed,
7616
+ failed,
7617
+ entryScreen,
7618
+ cases: results,
7619
+ unverifiable: skipped
7620
+ }, null, 2) + `
7621
+ `);
7622
+ }
7623
+ clearTimeout(killer);
7624
+ process.exit(failed > 0 ? 1 : 0);
7625
+ };
7626
+
7071
7627
  // src/index.ts
7072
- import { existsSync as existsSync21, readFileSync as readFileSync21, realpathSync as realpathSync2 } from "node:fs";
7628
+ import { existsSync as existsSync22, readFileSync as readFileSync23, realpathSync as realpathSync2 } from "node:fs";
7073
7629
  import { createRequire as createRequire4 } from "node:module";
7074
- import { delimiter as delimiter2, dirname as dirname14, join as join23 } from "node:path";
7630
+ import { delimiter as delimiter2, dirname as dirname16, join as join23 } from "node:path";
7075
7631
 
7076
7632
  // src/commands/install.ts
7077
7633
  import { execFileSync as execFileSync2 } from "node:child_process";
7078
- import { appendFileSync as appendFileSync2, chmodSync, cpSync as cpSync2, existsSync as existsSync20, mkdirSync as mkdirSync14, readFileSync as readFileSync20, realpathSync, rmSync as rmSync3, symlinkSync } from "node:fs";
7634
+ import { appendFileSync as appendFileSync2, chmodSync, cpSync as cpSync2, existsSync as existsSync21, mkdirSync as mkdirSync15, readFileSync as readFileSync22, realpathSync, rmSync as rmSync3, symlinkSync } from "node:fs";
7079
7635
  import { homedir as homedir3 } from "node:os";
7080
- import { basename as basename12, delimiter, dirname as dirname13, join as join22, resolve as resolve8, sep } from "node:path";
7636
+ import { basename as basename12, delimiter, dirname as dirname15, join as join22, resolve as resolve9, sep } from "node:path";
7081
7637
  var isWin = process.platform === "win32";
7082
7638
  var HOME = homedir3();
7083
7639
  var safeRealpath = (p) => {
7084
7640
  try {
7085
7641
  return realpathSync(p);
7086
7642
  } catch {
7087
- return resolve8(p);
7643
+ return resolve9(p);
7088
7644
  }
7089
7645
  };
7090
7646
  var onPath = (dir) => {
@@ -7100,19 +7656,19 @@ var onPath = (dir) => {
7100
7656
  });
7101
7657
  };
7102
7658
  var install = async (args) => {
7103
- const bundle = dirname13(process.execPath);
7659
+ const bundle = dirname15(process.execPath);
7104
7660
  const exeName = basename12(process.execPath);
7105
7661
  const chiselName = isWin ? "chisel.exe" : "chisel";
7106
- if (!existsSync20(join22(bundle, "runtime")) || !existsSync20(join22(bundle, chiselName))) {
7662
+ if (!existsSync21(join22(bundle, "runtime")) || !existsSync21(join22(bundle, chiselName))) {
7107
7663
  throw new CliError("`lecodes install` only works from the packaged release build (the archive containing lecodes + " + "chisel + runtime/). You appear to be running from source or an npm install — install globally " + "with your package manager there instead.");
7108
7664
  }
7109
- const companions = [exeName, chiselName, "runtime", "README.txt"].filter((f2) => existsSync20(join22(bundle, f2)));
7665
+ const companions = [exeName, chiselName, "runtime", "README.txt"].filter((f2) => existsSync21(join22(bundle, f2)));
7110
7666
  if (isWin)
7111
7667
  return installWindows(args, bundle, companions);
7112
7668
  return installUnix(args, bundle, companions);
7113
7669
  };
7114
7670
  var copyBundle = (bundle, target, companions) => {
7115
- mkdirSync14(target, { recursive: true });
7671
+ mkdirSync15(target, { recursive: true });
7116
7672
  if (safeRealpath(target) === safeRealpath(bundle))
7117
7673
  return;
7118
7674
  for (const f2 of companions) {
@@ -7130,8 +7686,8 @@ var permError = (e, dir, system) => {
7130
7686
  };
7131
7687
  var installUnix = (args, bundle, companions) => {
7132
7688
  const system = flagBool(args, "system");
7133
- const libDir = resolve8(flagStr(args, "dir") ?? (system ? "/usr/local/lib/lecodes-cli" : join22(HOME, ".local", "lib", "lecodes-cli")));
7134
- const binDir = resolve8(flagStr(args, "bin") ?? (system ? "/usr/local/bin" : join22(HOME, ".local", "bin")));
7689
+ const libDir = resolve9(flagStr(args, "dir") ?? (system ? "/usr/local/lib/lecodes-cli" : join22(HOME, ".local", "lib", "lecodes-cli")));
7690
+ const binDir = resolve9(flagStr(args, "bin") ?? (system ? "/usr/local/bin" : join22(HOME, ".local", "bin")));
7135
7691
  const link = join22(binDir, "lecodes");
7136
7692
  try {
7137
7693
  copyBundle(bundle, libDir, companions);
@@ -7141,7 +7697,7 @@ var installUnix = (args, bundle, companions) => {
7141
7697
  throw permError(e, libDir, system);
7142
7698
  }
7143
7699
  try {
7144
- mkdirSync14(binDir, { recursive: true });
7700
+ mkdirSync15(binDir, { recursive: true });
7145
7701
  rmSync3(link, { force: true });
7146
7702
  symlinkSync(join22(libDir, "lecodes"), link);
7147
7703
  } catch (e) {
@@ -7164,7 +7720,7 @@ var appendPathToShellRc = (binDir) => {
7164
7720
  const { file, name } = shellRc();
7165
7721
  const line = `export PATH="${binDir}:$PATH"`;
7166
7722
  try {
7167
- const existing = existsSync20(file) ? readFileSync20(file, "utf8") : "";
7723
+ const existing = existsSync21(file) ? readFileSync22(file, "utf8") : "";
7168
7724
  if (existing.includes(line))
7169
7725
  return name;
7170
7726
  appendFileSync2(file, `${existing && !existing.endsWith(`
@@ -7202,7 +7758,7 @@ var finishPathUnix = (args, binDir) => {
7202
7758
  log(` • or install system-wide (no PATH edit): ${c.bold("sudo lecodes install --system")}`);
7203
7759
  };
7204
7760
  var installWindows = (args, bundle, companions) => {
7205
- const target = resolve8(flagStr(args, "dir") ?? join22(process.env.LOCALAPPDATA ?? join22(HOME, "AppData", "Local"), "Programs", "lecodes-cli"));
7761
+ const target = resolve9(flagStr(args, "dir") ?? join22(process.env.LOCALAPPDATA ?? join22(HOME, "AppData", "Local"), "Programs", "lecodes-cli"));
7206
7762
  try {
7207
7763
  copyBundle(bundle, target, companions);
7208
7764
  } catch (e) {
@@ -7239,9 +7795,9 @@ var addToUserPathWindows = (dir) => {
7239
7795
 
7240
7796
  // src/index.ts
7241
7797
  var STANDALONE = (() => {
7242
- const dir = dirname14(process.execPath);
7798
+ const dir = dirname16(process.execPath);
7243
7799
  const chisel = process.platform === "win32" ? "chisel.exe" : "chisel";
7244
- return existsSync21(join23(dir, "runtime")) && existsSync21(join23(dir, chisel));
7800
+ return existsSync22(join23(dir, "runtime")) && existsSync22(join23(dir, chisel));
7245
7801
  })();
7246
7802
  var INSTALLED = STANDALONE && (() => {
7247
7803
  const exe = process.platform === "win32" ? "lecodes.exe" : "lecodes";
@@ -7257,7 +7813,7 @@ var INSTALLED = STANDALONE && (() => {
7257
7813
  return false;
7258
7814
  try {
7259
7815
  const cand = join23(d, exe);
7260
- return existsSync21(cand) && realpathSync2(cand) === self;
7816
+ return existsSync22(cand) && realpathSync2(cand) === self;
7261
7817
  } catch {
7262
7818
  return false;
7263
7819
  }
@@ -7276,7 +7832,8 @@ ${c.bold("Usage:")} lecodes <command> [options]
7276
7832
 
7277
7833
  ${c.bold("Commands:")}
7278
7834
  init [dir] Scaffold a new LOCAL project (no login/server): a starter
7279
- main.ts, IDE types + tsconfig, and a .lecodesignore.
7835
+ main.ts, a CLAUDE.md + passing flow test (the headless
7836
+ render/test loop), IDE types + tsconfig, a .lecodesignore.
7280
7837
  Run it with 'lecodes dev' or 'lecodes compile'.${INSTALL_HELP}
7281
7838
  login Authorize in the browser and store an access token
7282
7839
  --token <pat> store a token from the web UI instead
@@ -7320,12 +7877,36 @@ ${c.bold("Commands:")}
7320
7877
  -o, --out <file> write JSON to a file instead of stdout
7321
7878
  --png [file] rasterize a PNG (default screenshot.png;
7322
7879
  needs @napi-rs/canvas)
7880
+ --script <file> drive the app through a step list (tap/type/
7881
+ scroll/wait/expect/screenshot/json steps)
7882
+ --out-dir <dir> where --script step outputs land (default .)
7883
+ --fixtures <file> canned fetch responses (URL pattern →
7884
+ { status?, json?, text? }) — make
7885
+ backend-gated screens reachable headless
7323
7886
  --scale <n> PNG device-pixel scale (default 2)
7324
7887
  --width/--height viewport size (default 390x844)
7325
7888
  --entry <path> override the entrypoint
7326
7889
  --settle <ms> wait for promises/timers/fetch before snapshot
7327
7890
  --wait-network let data fetches hit the real network
7328
7891
  --logs forward the app's console output (to stderr)
7892
+ test [name…] Run headless flow tests: tests/*.flow.json scenarios, plus flows
7893
+ derived from the design graph (design/meta.json activator edges
7894
+ — checks the built app against the designed navigation; needs
7895
+ named elements + screen roots). Failing steps dump the frame
7896
+ (JSON + PNG) under tests/.artifacts/<flow>/.
7897
+ Design flows read tests/fixtures.json automatically (canned
7898
+ fetch responses) so backend-gated transitions run headless;
7899
+ file scenarios declare their own via the "fixtures" envelope.
7900
+ --flows run ONLY the design-derived flows
7901
+ --no-flows run ONLY the tests/*.flow.json files
7902
+ --fixtures <file> fixture pack for the design flows (overrides
7903
+ the tests/fixtures.json default)
7904
+ --json machine-readable report on stdout
7905
+ --dir <folder> tests folder (default tests/)
7906
+ --design-dir <dir> design folder (default design/)
7907
+ --artifacts <dir> failure-artifact folder override
7908
+ (+ the render timing flags: --width/--height, --settle,
7909
+ --device, --safe-area, --wait-network, --logs, --entry)
7329
7910
  design [serve] LeCodes Design — a live canvas of screens for AI-driven prototyping
7330
7911
  (needs the optional 'lecodes-design' package; works inside a
7331
7912
  cloned project or standalone in any folder)
@@ -7374,7 +7955,7 @@ ${c.bold("Commands:")}
7374
7955
  ASC API key; stored in ~/.lecodes/asc.json
7375
7956
 
7376
7957
  Config lives in ~/.lecodes/config.json (override with LECODES_API / LECODES_TOKEN).`;
7377
- var commands = { init: init2, login, create, clone, status, diff, pull, push, compile: compile2, preview: previewLegacy, render, design, scene, dev, app };
7958
+ var commands = { init: init2, login, create, clone, status, diff, pull, push, compile: compile2, preview: previewLegacy, render, test, design, scene, dev, app };
7378
7959
  if (STANDALONE)
7379
7960
  commands.install = install;
7380
7961
  var readVersion = () => package_default.version ?? "unknown";
@@ -7386,15 +7967,15 @@ var OPTIONAL_PEERS = [
7386
7967
  var peerVersion = (name, probe) => {
7387
7968
  try {
7388
7969
  const require2 = createRequire4(import.meta.url);
7389
- let dir = dirname14(require2.resolve(probe));
7970
+ let dir = dirname16(require2.resolve(probe));
7390
7971
  for (let depth = 0;depth < 16; depth++) {
7391
7972
  const manifest = join23(dir, "package.json");
7392
- if (existsSync21(manifest)) {
7393
- const parsed = JSON.parse(readFileSync21(manifest, "utf8"));
7973
+ if (existsSync22(manifest)) {
7974
+ const parsed = JSON.parse(readFileSync23(manifest, "utf8"));
7394
7975
  if (parsed.name === name)
7395
7976
  return parsed.version ?? "unknown";
7396
7977
  }
7397
- const parent = dirname14(dir);
7978
+ const parent = dirname16(dir);
7398
7979
  if (parent === dir)
7399
7980
  break;
7400
7981
  dir = parent;