lecodes-cli 0.9.0 → 0.9.2

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.9.0",
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.2",
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.2"
2041
- },
2042
- peerDependencies: {
2043
- "lecodes-design": "^0.3.2",
2044
- "lecodes-renderer": "^0.7.0",
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)}/`);
@@ -5088,6 +5250,10 @@ contract instead of a discarded sketch — but only if the app build keeps the w
5088
5250
  machine-verifiable* — cover the important ones with hand-written \`tests/*.flow.json\` scenarios
5089
5251
  (same step schema as \`lecodes render --script\`; tab-bar navigation needs these too, since the
5090
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.
5091
5257
 
5092
5258
  Names also appear in every headless render (JSON snapshots, the board's live elements), so they
5093
5259
  double as stable selectors for any scripted interaction — naming interactive elements is never
@@ -6003,6 +6169,11 @@ var startDevServer = async (options) => {
6003
6169
  publicUrl: origin,
6004
6170
  makeResourceEntry: (path, absPath) => ({ path, type: "resource", fileSrc: `${base}/assets` + encodeURI(path), absPath })
6005
6171
  });
6172
+ if (!entryOverride) {
6173
+ const ambiguous = ambiguousEntryWarning(entries);
6174
+ if (ambiguous)
6175
+ warnings.push(ambiguous);
6176
+ }
6006
6177
  const js = await compileProject2({ entries, name, publicUrl: origin, entryOverride, header: true, fontsHeader });
6007
6178
  const changed = bundleJs === null || stripVolatile(js) !== stripVolatile(bundleJs);
6008
6179
  if (changed) {
@@ -6494,10 +6665,12 @@ const button = UIButton(
6494
6665
  justifyContent: "center",
6495
6666
  },
6496
6667
  [UIText({ color: "#ffffff", fontSize: 22 }, "Tap me")],
6497
- ).onClick(() => {
6498
- count++
6499
- counter.text = "Taps: " + count
6500
- })
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
+ })
6501
6674
 
6502
6675
  const screen = UIScreen(
6503
6676
  { backgroundColor: "#1e2230", alignItems: "center", justifyContent: "center", padding: 40 },
@@ -6507,7 +6680,7 @@ const screen = UIScreen(
6507
6680
  [UIText({ color: "#ffffff", fontSize: 32 }, "Hello, LeCodes"), button, counter],
6508
6681
  ),
6509
6682
  ],
6510
- )
6683
+ ).style({ name: "home" }) // the screen root's name is what tests' \`expect: { screen: … }\` reads
6511
6684
 
6512
6685
  screen.open()
6513
6686
  `;
@@ -6523,6 +6696,16 @@ lecodes dev # live on your phone: scan the QR with the LeCodes
6523
6696
  lecodes compile -o app.js # bundle to a single self-contained JS file
6524
6697
  \`\`\`
6525
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
+
6526
6709
  ## Go native
6527
6710
 
6528
6711
  \`\`\`
@@ -6533,8 +6716,11 @@ lecodes app open # open it in Xcode, build & run
6533
6716
 
6534
6717
  ## Layout
6535
6718
 
6536
- - \`main.ts\` — the app. Add more top-level .ts files freely; the largest export-free one
6537
- 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)
6538
6724
  - \`assets/\` — images/fonts the app references (created on demand)
6539
6725
  - \`tsconfig.json\` + \`.lecodes/types/\` — generated IDE types (rewritten by the CLI; don't edit)
6540
6726
  - \`.lecodesignore\` — files a future \`lecodes push\` would skip
@@ -6560,6 +6746,8 @@ var init2 = async (args) => {
6560
6746
  };
6561
6747
  write("main.ts", STARTER2);
6562
6748
  write("README.md", readmeTemplate(name));
6749
+ write("CLAUDE.md", appClaudeMd(name));
6750
+ write("tests/counter.flow.json", STARTER_FLOW);
6563
6751
  write(".lecodesignore", DEFAULT_IGNORE_FILE);
6564
6752
  const wroteTypes = materializeTypesLocal(root);
6565
6753
  writeTsconfig(root);
@@ -6571,6 +6759,7 @@ var init2 = async (args) => {
6571
6759
  success(created ? `Local project ${c.bold(name)} ready${dirArg === "." ? "" : ` in ${c.bold(dirArg)}/`}.` : `Project ${c.bold(name)} already initialized.`);
6572
6760
  const cd = dirArg === "." ? "" : `cd ${dirArg} && `;
6573
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)`);
6574
6763
  log(` ${c.bold(cd + "lecodes compile -o app.js")} bundle it for LeCodes Desktop`);
6575
6764
  };
6576
6765
 
@@ -6635,11 +6824,17 @@ var compileHeadlessBundle = async (args) => {
6635
6824
  makeResourceEntry: (path, absPath) => ({ path, type: "resource", fileSrc: encodeURIComponent(absPath), absPath })
6636
6825
  } : { mode: "cdn", root });
6637
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
+ }
6638
6833
  const js = await compileProject2({
6639
6834
  entries: compileEntries,
6640
6835
  name,
6641
6836
  publicUrl,
6642
- entryOverride: flagStr(args, "entry"),
6837
+ entryOverride: entryFlag,
6643
6838
  header: false,
6644
6839
  format: "iife"
6645
6840
  });
@@ -6687,6 +6882,23 @@ var fileScenarioIO = (dir) => ({
6687
6882
  writeFileSync14(abs, data);
6688
6883
  }
6689
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
+ };
6690
6902
  var readScenarioFile = (path) => {
6691
6903
  let raw;
6692
6904
  try {
@@ -6720,10 +6932,21 @@ var render = async (args) => {
6720
6932
  const settleStr = flagStr(args, "settle") ?? (scenario?.settle !== undefined ? String(scenario.settle) : undefined);
6721
6933
  const timeStr = flagStr(args, "time");
6722
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
+ }
6723
6945
  const timing = {
6724
6946
  settleMs: settleStr !== undefined ? Number(settleStr) : undefined,
6725
6947
  waitNetwork: flagBool(args, "wait-network"),
6726
6948
  localAssets: useLocalAssets,
6949
+ fixtures,
6727
6950
  timeMs: timeStr !== undefined ? Number(timeStr) : undefined,
6728
6951
  afterAnimations: !flagBool(args, "no-after-animations"),
6729
6952
  ...resolveSafeAreaChoice(flagStr(args, "safe-area") ?? scenario?.safeArea, flagStr(args, "device") ?? scenario?.device),
@@ -7172,7 +7395,7 @@ var status = async (_args) => {
7172
7395
 
7173
7396
  // src/commands/test.ts
7174
7397
  import { existsSync as existsSync20, readFileSync as readFileSync21, readdirSync as readdirSync9 } from "node:fs";
7175
- import { resolve as resolve8 } from "node:path";
7398
+ import { dirname as dirname14, resolve as resolve8 } from "node:path";
7176
7399
  var deriveDesignFlows = (meta, entryScreen) => {
7177
7400
  const edges = meta.edges ?? [];
7178
7401
  const isState = (id) => id.includes("@");
@@ -7221,7 +7444,8 @@ var deriveDesignFlows = (meta, entryScreen) => {
7221
7444
  name,
7222
7445
  kind: "flow",
7223
7446
  steps: [
7224
- ...path.map((hop) => ({ tap: hop.activator, expect: { screen: hop.to } })),
7447
+ ...path.flatMap((hop) => [{ fill: {} }, { tap: hop.activator, expect: { screen: hop.to } }]),
7448
+ { fill: {} },
7225
7449
  { tap: e.activator, expect: { screen: e.to } }
7226
7450
  ]
7227
7451
  });
@@ -7242,7 +7466,17 @@ var readFlowFile = (abs, displayName) => {
7242
7466
  throw new CliError(`"${displayName}" must be a JSON object with "steps" (or a bare step array).`);
7243
7467
  }
7244
7468
  const file = raw;
7245
- return { name: file.name ?? displayName, kind: "file", steps: file.steps, width: file.width, height: file.height, settle: file.settle, device: file.device, safeArea: file.safeArea };
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
+ };
7246
7480
  };
7247
7481
  var test = async (args) => {
7248
7482
  const { js, warnings, useLocalAssets, root } = await compileHeadlessBundle(args);
@@ -7265,6 +7499,9 @@ var test = async (args) => {
7265
7499
  cases.push(readFlowFile(resolve8(testsDir, f2), f2));
7266
7500
  }
7267
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;
7268
7505
  const flowsOnly = flagBool(args, "flows");
7269
7506
  const wantFlows = !flagBool(args, "no-flows") && args._.length === 0;
7270
7507
  if (flowsOnly)
@@ -7280,14 +7517,14 @@ var test = async (args) => {
7280
7517
  }
7281
7518
  if ((meta.edges ?? []).length > 0) {
7282
7519
  note("Probing the start screen…");
7283
- const probe = await renderer.createSession(js, { raster: false, localAssets: useLocalAssets });
7520
+ const probe = await renderer.createSession(js, { raster: false, localAssets: useLocalAssets, fixtures: flowFixtures });
7284
7521
  entryScreen = probe.screenName();
7285
7522
  probe.dispose();
7286
7523
  if (entryScreen === null) {
7287
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" })).');
7288
7525
  } else {
7289
7526
  const derived = deriveDesignFlows(meta, entryScreen);
7290
- cases.push(...derived.flows);
7527
+ cases.push(...derived.flows.map((f2) => ({ ...f2, fixtures: flowFixtures })));
7291
7528
  skipped = derived.skipped;
7292
7529
  }
7293
7530
  }
@@ -7319,6 +7556,7 @@ var test = async (args) => {
7319
7556
  settleMs: flagSettle !== undefined ? Number(flagSettle) : kase.settle,
7320
7557
  waitNetwork: flagBool(args, "wait-network"),
7321
7558
  localAssets: useLocalAssets,
7559
+ fixtures: kase.fixtures,
7322
7560
  ...resolveSafeAreaChoice(flagStr(args, "safe-area") ?? kase.safeArea, flagStr(args, "device") ?? kase.device),
7323
7561
  onConsole
7324
7562
  });
@@ -7389,13 +7627,13 @@ var test = async (args) => {
7389
7627
  // src/index.ts
7390
7628
  import { existsSync as existsSync22, readFileSync as readFileSync23, realpathSync as realpathSync2 } from "node:fs";
7391
7629
  import { createRequire as createRequire4 } from "node:module";
7392
- import { delimiter as delimiter2, dirname as dirname15, join as join23 } from "node:path";
7630
+ import { delimiter as delimiter2, dirname as dirname16, join as join23 } from "node:path";
7393
7631
 
7394
7632
  // src/commands/install.ts
7395
7633
  import { execFileSync as execFileSync2 } from "node:child_process";
7396
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";
7397
7635
  import { homedir as homedir3 } from "node:os";
7398
- import { basename as basename12, delimiter, dirname as dirname14, join as join22, resolve as resolve9, sep } from "node:path";
7636
+ import { basename as basename12, delimiter, dirname as dirname15, join as join22, resolve as resolve9, sep } from "node:path";
7399
7637
  var isWin = process.platform === "win32";
7400
7638
  var HOME = homedir3();
7401
7639
  var safeRealpath = (p) => {
@@ -7418,7 +7656,7 @@ var onPath = (dir) => {
7418
7656
  });
7419
7657
  };
7420
7658
  var install = async (args) => {
7421
- const bundle = dirname14(process.execPath);
7659
+ const bundle = dirname15(process.execPath);
7422
7660
  const exeName = basename12(process.execPath);
7423
7661
  const chiselName = isWin ? "chisel.exe" : "chisel";
7424
7662
  if (!existsSync21(join22(bundle, "runtime")) || !existsSync21(join22(bundle, chiselName))) {
@@ -7557,7 +7795,7 @@ var addToUserPathWindows = (dir) => {
7557
7795
 
7558
7796
  // src/index.ts
7559
7797
  var STANDALONE = (() => {
7560
- const dir = dirname15(process.execPath);
7798
+ const dir = dirname16(process.execPath);
7561
7799
  const chisel = process.platform === "win32" ? "chisel.exe" : "chisel";
7562
7800
  return existsSync22(join23(dir, "runtime")) && existsSync22(join23(dir, chisel));
7563
7801
  })();
@@ -7594,7 +7832,8 @@ ${c.bold("Usage:")} lecodes <command> [options]
7594
7832
 
7595
7833
  ${c.bold("Commands:")}
7596
7834
  init [dir] Scaffold a new LOCAL project (no login/server): a starter
7597
- 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.
7598
7837
  Run it with 'lecodes dev' or 'lecodes compile'.${INSTALL_HELP}
7599
7838
  login Authorize in the browser and store an access token
7600
7839
  --token <pat> store a token from the web UI instead
@@ -7641,6 +7880,9 @@ ${c.bold("Commands:")}
7641
7880
  --script <file> drive the app through a step list (tap/type/
7642
7881
  scroll/wait/expect/screenshot/json steps)
7643
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
7644
7886
  --scale <n> PNG device-pixel scale (default 2)
7645
7887
  --width/--height viewport size (default 390x844)
7646
7888
  --entry <path> override the entrypoint
@@ -7652,8 +7894,13 @@ ${c.bold("Commands:")}
7652
7894
  — checks the built app against the designed navigation; needs
7653
7895
  named elements + screen roots). Failing steps dump the frame
7654
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.
7655
7900
  --flows run ONLY the design-derived flows
7656
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)
7657
7904
  --json machine-readable report on stdout
7658
7905
  --dir <folder> tests folder (default tests/)
7659
7906
  --design-dir <dir> design folder (default design/)
@@ -7720,7 +7967,7 @@ var OPTIONAL_PEERS = [
7720
7967
  var peerVersion = (name, probe) => {
7721
7968
  try {
7722
7969
  const require2 = createRequire4(import.meta.url);
7723
- let dir = dirname15(require2.resolve(probe));
7970
+ let dir = dirname16(require2.resolve(probe));
7724
7971
  for (let depth = 0;depth < 16; depth++) {
7725
7972
  const manifest = join23(dir, "package.json");
7726
7973
  if (existsSync22(manifest)) {
@@ -7728,7 +7975,7 @@ var peerVersion = (name, probe) => {
7728
7975
  if (parsed.name === name)
7729
7976
  return parsed.version ?? "unknown";
7730
7977
  }
7731
- const parent = dirname15(dir);
7978
+ const parent = dirname16(dir);
7732
7979
  if (parent === dir)
7733
7980
  break;
7734
7981
  dir = parent;