lecodes-cli 0.8.2 → 0.9.0

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
@@ -2007,7 +2007,7 @@ var require_main = __commonJS((exports, module) => {
2007
2007
  // package.json
2008
2008
  var package_default = {
2009
2009
  name: "lecodes-cli",
2010
- version: "0.8.2",
2010
+ version: "0.9.0",
2011
2011
  description: "Clone, edit and push LeCodes projects from your machine.",
2012
2012
  type: "module",
2013
2013
  license: "MIT",
@@ -2037,11 +2037,11 @@ var package_default = {
2037
2037
  prepublishOnly: "bun run sync-peers && bun run build"
2038
2038
  },
2039
2039
  dependencies: {
2040
- "@letary/chisel": "^0.4.1"
2040
+ "@letary/chisel": "^0.4.2"
2041
2041
  },
2042
2042
  peerDependencies: {
2043
2043
  "lecodes-design": "^0.3.2",
2044
- "lecodes-renderer": "^0.6.2",
2044
+ "lecodes-renderer": "^0.7.0",
2045
2045
  "lecodes-3d-editor": "^0.1.0"
2046
2046
  },
2047
2047
  peerDependenciesMeta: {
@@ -4866,15 +4866,8 @@ var browserLogin = async (webBase, timeoutMs = 5 * 60 * 1000, open = openBrowser
4866
4866
  }
4867
4867
  };
4868
4868
 
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
4869
+ // ../lecodes-design/src/core/concept.ts
4870
+ var SPEC_TEMPLATE = `# Concept
4878
4871
 
4879
4872
  The narrative half of this design. \`meta.json\` + the canvas hold the *visual/flow* half (screens,
4880
4873
  positions, edges); this file holds what never renders as a screen — the data model, the actors, and
@@ -4908,6 +4901,16 @@ which is the screen's *kind* — list, detail, form…)
4908
4901
  Behaviour that isn't visible in a single screen — validation, permissions, notifications, edge
4909
4902
  cases — and the decisions you've made and why, so they aren't re-litigated later.
4910
4903
  `;
4904
+
4905
+ // ../lecodes-design/src/templates.ts
4906
+ var SPEC_MD = SPEC_TEMPLATE;
4907
+ var META_JSON = `{
4908
+ "screens": {
4909
+ "home": { "pos": [0, 0], "desc": "Example screen — replace me", "role": "list" }
4910
+ },
4911
+ "edges": []
4912
+ }
4913
+ `;
4911
4914
  var TOKENS_TS = `// Design tokens shared by every screen. Colors are hex strings (the engine parses hex only).
4912
4915
  // App font: one theme() call here sets the default text font everywhere. font("id") installs a
4913
4916
  // registry family (vendored at compile time) and returns its name — no loading code needed.
@@ -5070,6 +5073,26 @@ positions or descriptions.
5070
5073
  \`lecodes design check\` lists screens still untagged (never blocking). *(A screen's **states** are
5071
5074
  a separate thing — declared in the source, not meta; see "Screen states" below.)*
5072
5075
 
5076
+ ## The design is testable — names carry into the app
5077
+
5078
+ Every edge with an \`activator\` doubles as a **flow test** for the real app: \`lecodes test\` (run
5079
+ at the project root once the app exists) walks the graph from the start screen, taps each edge's
5080
+ activator, and expects to land on a screen named like the edge's target. The design stays a living
5081
+ contract instead of a discarded sketch — but only if the app build keeps the wiring:
5082
+
5083
+ - **Keep the design's \`name\`s** when translating a screen to app code — every element an edge's
5084
+ \`activator\` references keeps its \`name\` (\`.style({ name: "submit" })\`).
5085
+ - **Name each app screen's root with its design screen id** — \`UIScreen([ … ]).style({ name:
5086
+ "login" })\` — that's how the runner knows where a tap landed.
5087
+ - Edges with no activator (backend/system-driven) and \`@state\` edges are reported as *not
5088
+ machine-verifiable* — cover the important ones with hand-written \`tests/*.flow.json\` scenarios
5089
+ (same step schema as \`lecodes render --script\`; tab-bar navigation needs these too, since the
5090
+ bar draws no edges).
5091
+
5092
+ Names also appear in every headless render (JSON snapshots, the board's live elements), so they
5093
+ double as stable selectors for any scripted interaction — naming interactive elements is never
5094
+ wasted work.
5095
+
5073
5096
  ## The concept — spec.md
5074
5097
 
5075
5098
  \`meta.json\` + the canvas are the *visual/flow* half of the design; \`spec.md\` is the *narrative*
@@ -5291,7 +5314,6 @@ var README_HINT = `# design/
5291
5314
  LeCodes Design canvas — run \`lecodes design\` here and open the printed URL.
5292
5315
  See CLAUDE.md for the conventions (it's written for AI assistants, but it's the spec).
5293
5316
  `;
5294
-
5295
5317
  // src/commands/design.ts
5296
5318
  var DEFAULT_DIR = "design";
5297
5319
  var DEFAULT_PORT = 4477;
@@ -6564,50 +6586,47 @@ var login = async (args) => {
6564
6586
  };
6565
6587
 
6566
6588
  // src/commands/render.ts
6567
- import { writeFileSync as writeFileSync14 } from "node:fs";
6568
- import { resolve as resolve6 } from "node:path";
6589
+ import { mkdirSync as mkdirSync13, readFileSync as readFileSync17, writeFileSync as writeFileSync14 } from "node:fs";
6590
+ import { dirname as dirname11, resolve as resolve6 } from "node:path";
6591
+
6592
+ // src/compile/headlessBundle.ts
6569
6593
  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 };
6594
+ var compileHeadlessBundle = async (args) => {
6595
+ let root;
6596
+ let manifest;
6597
+ try {
6598
+ root = findProjectRoot(process.cwd());
6599
+ manifest = readManifest(root);
6600
+ } catch {
6601
+ root = process.cwd();
6602
+ manifest = null;
6589
6603
  }
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
6604
  const publicUrlFlag = flagStr(args, "public-url");
6608
- const useLocalAssets = !publicUrlFlag && !flagBool(args, "remote-assets");
6605
+ const useLocalAssets = manifest ? !publicUrlFlag && !flagBool(args, "remote-assets") : true;
6606
+ let entries;
6607
+ let warnings;
6608
+ let name;
6609
+ let publicUrl;
6610
+ if (manifest) {
6611
+ const config = loadConfig();
6612
+ const apiUrl = requireApiUrl(config);
6613
+ const token = requireToken(config);
6614
+ note("Resolving project assets…");
6615
+ const project = await getProject(apiUrl, token, manifest.uuid);
6616
+ ({ entries, warnings } = collectEntries(root, manifest, project));
6617
+ name = manifest.name;
6618
+ publicUrl = useLocalAssets ? LOCAL_ASSET_SCHEME2 : publicUrlFlag ?? apiUrl;
6619
+ if (useLocalAssets)
6620
+ warnings = warnings.filter((w) => !w.includes("No server URL"));
6621
+ } else {
6622
+ ({ entries, warnings } = collectLocalEntries(root, null, ""));
6623
+ name = root.split(/[\\/]/).pop() ?? "project";
6624
+ publicUrl = LOCAL_ASSET_SCHEME2;
6625
+ if (flagBool(args, "remote-assets") || publicUrlFlag) {
6626
+ warnings.push("This is a local project (no server) — assets always load from the working tree.");
6627
+ }
6628
+ }
6609
6629
  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
6630
  await applyProjectFonts(compileEntries, useLocalAssets ? {
6612
6631
  mode: "vendor",
6613
6632
  root,
@@ -6618,8 +6637,8 @@ var render = async (args) => {
6618
6637
  note("Compiling…");
6619
6638
  const js = await compileProject2({
6620
6639
  entries: compileEntries,
6621
- name: manifest.name,
6622
- publicUrl: useLocalAssets ? LOCAL_ASSET_SCHEME2 : publicUrlFlag ?? apiUrl,
6640
+ name,
6641
+ publicUrl,
6623
6642
  entryOverride: flagStr(args, "entry"),
6624
6643
  header: false,
6625
6644
  format: "iife"
@@ -6630,15 +6649,75 @@ var render = async (args) => {
6630
6649
  if (js.includes("_creator2d") && !useLocalAssets && !flagBool(args, "wait-network")) {
6631
6650
  warnings.push("2D scene detected: sprite textures load over the network — add --wait-network to include them (else sprites render as flat placeholders).");
6632
6651
  }
6633
- let renderer;
6652
+ return { js, root, warnings, useLocalAssets };
6653
+ };
6654
+
6655
+ // src/commands/render.ts
6656
+ var DEVICE_SAFE_AREA = {
6657
+ none: { insets: [0, 0, 0, 0], bars: [] },
6658
+ iphone: { insets: [59, 0, 34, 0], bars: [] },
6659
+ android: { insets: [24, 0, 48, 0], bars: ["top", "bottom"] }
6660
+ };
6661
+ var resolveSafeAreaChoice = (explicit, device) => {
6662
+ if (explicit) {
6663
+ const n = explicit.split(",").map((s) => Number(s.trim()));
6664
+ if (n.length === 4 && n.every((v) => Number.isFinite(v)))
6665
+ return { safeArea: n, safeAreaBars: [] };
6666
+ throw new CliError(`--safe-area expects "top,right,bottom,left" (4 numbers), got "${explicit}".`);
6667
+ }
6668
+ if (device) {
6669
+ const preset = DEVICE_SAFE_AREA[device];
6670
+ if (!preset)
6671
+ throw new CliError(`Unknown --device "${device}". Options: ${Object.keys(DEVICE_SAFE_AREA).join(", ")}.`);
6672
+ return { safeArea: preset.insets, safeAreaBars: preset.bars };
6673
+ }
6674
+ return;
6675
+ };
6676
+ var requireRenderer = async () => {
6634
6677
  try {
6635
- renderer = await import("lecodes-renderer/headless");
6678
+ return await import("lecodes-renderer/headless");
6636
6679
  } catch {
6637
6680
  throw new CliError("The renderer isn't installed. Run: npm i -D lecodes-renderer");
6638
6681
  }
6639
- const width = Number(flagStr(args, "width")) || 390;
6640
- const height = Number(flagStr(args, "height")) || 844;
6641
- const settleStr = flagStr(args, "settle");
6682
+ };
6683
+ var fileScenarioIO = (dir) => ({
6684
+ write: (relPath, data) => {
6685
+ const abs = resolve6(dir, relPath);
6686
+ mkdirSync13(dirname11(abs), { recursive: true });
6687
+ writeFileSync14(abs, data);
6688
+ }
6689
+ });
6690
+ var readScenarioFile = (path) => {
6691
+ let raw;
6692
+ try {
6693
+ raw = JSON.parse(readFileSync17(resolve6(path), "utf8"));
6694
+ } catch (e) {
6695
+ throw new CliError(`Can't read scenario "${path}": ${e instanceof Error ? e.message : String(e)}`);
6696
+ }
6697
+ if (Array.isArray(raw))
6698
+ return { steps: raw };
6699
+ if (typeof raw !== "object" || raw === null)
6700
+ throw new CliError(`Scenario "${path}" must be a JSON object with "steps" (or a bare step array).`);
6701
+ const file = raw;
6702
+ if (file.steps === undefined)
6703
+ throw new CliError(`Scenario "${path}" has no "steps".`);
6704
+ return file;
6705
+ };
6706
+ var render = async (args) => {
6707
+ const scriptFlag = flagStr(args, "script");
6708
+ const scenario = scriptFlag !== undefined ? readScenarioFile(scriptFlag) : null;
6709
+ const stepCount = scenario && Array.isArray(scenario.steps) ? scenario.steps.length : 0;
6710
+ const timeoutMs = Number(flagStr(args, "timeout")) || 15000 + stepCount * 3000;
6711
+ const killer = setTimeout(() => {
6712
+ warnErr(`Render timed out after ${timeoutMs}ms (try --settle / --wait-network, or raise --timeout).`);
6713
+ process.exit(1);
6714
+ }, timeoutMs);
6715
+ killer.unref?.();
6716
+ const { js, warnings, useLocalAssets } = await compileHeadlessBundle(args);
6717
+ const renderer = await requireRenderer();
6718
+ const width = Number(flagStr(args, "width")) || scenario?.width || 390;
6719
+ const height = Number(flagStr(args, "height")) || scenario?.height || 844;
6720
+ const settleStr = flagStr(args, "settle") ?? (scenario?.settle !== undefined ? String(scenario.settle) : undefined);
6642
6721
  const timeStr = flagStr(args, "time");
6643
6722
  const onConsole = flagBool(args, "logs") ? (level, text2) => logErr(`${c.dim(`[${level}]`)} ${text2}`) : undefined;
6644
6723
  const timing = {
@@ -6647,13 +6726,36 @@ var render = async (args) => {
6647
6726
  localAssets: useLocalAssets,
6648
6727
  timeMs: timeStr !== undefined ? Number(timeStr) : undefined,
6649
6728
  afterAnimations: !flagBool(args, "no-after-animations"),
6650
- ...resolveSafeArea(args),
6729
+ ...resolveSafeAreaChoice(flagStr(args, "safe-area") ?? scenario?.safeArea, flagStr(args, "device") ?? scenario?.device),
6651
6730
  onConsole
6652
6731
  };
6653
- const done = (flush) => flush(() => {
6732
+ const done = (flush, code = 0) => flush(() => {
6654
6733
  clearTimeout(killer);
6655
- process.exit(0);
6734
+ process.exit(code);
6656
6735
  });
6736
+ if (scenario) {
6737
+ const steps = renderer.parseScenarioSteps(scenario.steps);
6738
+ const outDir = flagStr(args, "out-dir") ?? ".";
6739
+ const needsRaster = steps.some((s) => s.screenshot !== undefined);
6740
+ note(`Running ${steps.length} step${steps.length === 1 ? "" : "s"}…`);
6741
+ const session = await renderer.createSession(js, { width, height, ...timing, raster: needsRaster ? true : "auto" });
6742
+ try {
6743
+ const result2 = await renderer.runScenario(session, steps, fileScenarioIO(outDir));
6744
+ for (const w of [...warnings, ...session.warnings])
6745
+ warnErr(w);
6746
+ for (const st of result2.steps) {
6747
+ logErr(st.ok ? `${c.green("✓")} ${st.desc}` : `${c.red("✗")} ${st.desc}
6748
+ ${c.red(st.error ?? "failed")}`);
6749
+ }
6750
+ if (!result2.ok && result2.failureArtifacts.length > 0) {
6751
+ logErr(c.dim(` failing frame: ${result2.failureArtifacts.map((p) => resolve6(outDir, p)).join(", ")}`));
6752
+ }
6753
+ logErr(result2.ok ? c.green(`✓ ${result2.steps.length}/${steps.length} steps passed`) : c.red(`✗ failed at step ${result2.steps.length} of ${steps.length}`));
6754
+ return done((cb) => cb(), result2.ok ? 0 : 1);
6755
+ } finally {
6756
+ session.dispose();
6757
+ }
6758
+ }
6657
6759
  const pngFlag = flagStr(args, "png") ?? (flagBool(args, "png") ? "screenshot.png" : undefined);
6658
6760
  if (pngFlag !== undefined) {
6659
6761
  note("Rendering PNG…");
@@ -6694,8 +6796,8 @@ var render = async (args) => {
6694
6796
  };
6695
6797
 
6696
6798
  // 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";
6799
+ import { existsSync as existsSync17, mkdirSync as mkdirSync14, rmSync as rmSync2, writeFileSync as writeFileSync15 } from "node:fs";
6800
+ import { dirname as dirname12, join as join19 } from "node:path";
6699
6801
  var pull = async (args) => {
6700
6802
  const root = findProjectRoot(process.cwd());
6701
6803
  const manifest = readManifest(root);
@@ -6755,7 +6857,7 @@ var pullPaths = async (root, manifest, project, paths, apiUrl, token) => {
6755
6857
  for (const asset of targets) {
6756
6858
  const key = pathById.get(asset.id);
6757
6859
  const abs = join19(root, ...key.replace(/^\//, "").split("/"));
6758
- mkdirSync13(dirname11(abs), { recursive: true });
6860
+ mkdirSync14(dirname12(abs), { recursive: true });
6759
6861
  let sha;
6760
6862
  if (asset.type === "file") {
6761
6863
  if (!asset.file?.src)
@@ -6778,7 +6880,7 @@ var pullPaths = async (root, manifest, project, paths, apiUrl, token) => {
6778
6880
 
6779
6881
  // src/commands/push.ts
6780
6882
  import { basename as basename10 } from "node:path";
6781
- import { readFileSync as readFileSync17 } from "node:fs";
6883
+ import { readFileSync as readFileSync18 } from "node:fs";
6782
6884
  var push = async (args) => {
6783
6885
  const root = findProjectRoot(process.cwd());
6784
6886
  const manifest = readManifest(root);
@@ -6837,9 +6939,9 @@ var push = async (args) => {
6837
6939
  if (source)
6838
6940
  entry.assetId = source.id;
6839
6941
  if (isTextKind(kind)) {
6840
- entry.text = readFileSync17(file.absPath, "utf8");
6942
+ entry.text = readFileSync18(file.absPath, "utf8");
6841
6943
  } else if (!source || source.sha !== file.sha) {
6842
- const blob = await uploadBlob(apiUrl, token, manifest.uuid, basename10(file.path), readFileSync17(file.absPath));
6944
+ const blob = await uploadBlob(apiUrl, token, manifest.uuid, basename10(file.path), readFileSync18(file.absPath));
6843
6945
  entry.blobFileId = blob.id;
6844
6946
  uploaded++;
6845
6947
  }
@@ -6892,11 +6994,11 @@ var reportPublish = (res) => {
6892
6994
  };
6893
6995
 
6894
6996
  // 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";
6997
+ import { existsSync as existsSync19, readFileSync as readFileSync20, readdirSync as readdirSync8, statSync as statSync4, writeFileSync as writeFileSync16 } from "node:fs";
6998
+ import { basename as basename11, dirname as dirname13, join as join21, resolve as resolve7 } from "node:path";
6897
6999
 
6898
7000
  // src/compile/sceneCompile.ts
6899
- import { existsSync as existsSync18, readFileSync as readFileSync18 } from "node:fs";
7001
+ import { existsSync as existsSync18, readFileSync as readFileSync19 } from "node:fs";
6900
7002
  import { join as join20 } from "node:path";
6901
7003
  var TEXT_EXT = /\.(ts|js|json|svg|txt|md)$/i;
6902
7004
  var rel = (key) => key.replace(/^\//, "");
@@ -6907,7 +7009,7 @@ var resolveHarness = (loadSceneHarness2) => {
6907
7009
  } catch {
6908
7010
  const vendored = join20(distRoot, "runtime", "scene-harness.json");
6909
7011
  if (existsSync18(vendored))
6910
- return JSON.parse(readFileSync18(vendored, "utf8"));
7012
+ return JSON.parse(readFileSync19(vendored, "utf8"));
6911
7013
  throw new CliError("scene-editor harness sources not found — reinstall the CLI or set LECODES_SCENE_HARNESS_DIR.");
6912
7014
  }
6913
7015
  };
@@ -6919,7 +7021,7 @@ var compileSceneEditor = async (opts) => {
6919
7021
  for (const file of scan.files) {
6920
7022
  const path = "./" + rel(file.path);
6921
7023
  if (TEXT_EXT.test(path)) {
6922
- entries.push({ path, type: "text", text: readFileSync18(file.absPath, "utf8") });
7024
+ entries.push({ path, type: "text", text: readFileSync19(file.absPath, "utf8") });
6923
7025
  } else {
6924
7026
  entries.push({ path, type: "resource", fileSrc: "/project/" + rel(file.path) });
6925
7027
  }
@@ -6963,7 +7065,7 @@ var getContext2 = (args) => {
6963
7065
  if (existsSync19(abs) && statSync4(abs).isDirectory())
6964
7066
  root = abs;
6965
7067
  else if (abs.endsWith(".scene.ts") && existsSync19(abs))
6966
- root = dirname12(abs);
7068
+ root = dirname13(abs);
6967
7069
  else
6968
7070
  throw new CliError(`No such folder or .scene.ts: ${pathArg}`);
6969
7071
  }
@@ -6976,7 +7078,7 @@ var writeMcpConfig2 = (root, port) => {
6976
7078
  let config = {};
6977
7079
  if (existsSync19(path)) {
6978
7080
  try {
6979
- config = JSON.parse(readFileSync19(path, "utf8"));
7081
+ config = JSON.parse(readFileSync20(path, "utf8"));
6980
7082
  } catch {
6981
7083
  warnErr(".mcp.json exists but isn't valid JSON — left it untouched. Add the lecodes-scene server by hand.");
6982
7084
  return;
@@ -7068,23 +7170,239 @@ var status = async (_args) => {
7068
7170
  info(c.dim(` ${scan.ignored.length} ignored (.lecodesignore)`));
7069
7171
  };
7070
7172
 
7173
+ // src/commands/test.ts
7174
+ import { existsSync as existsSync20, readFileSync as readFileSync21, readdirSync as readdirSync9 } from "node:fs";
7175
+ import { resolve as resolve8 } from "node:path";
7176
+ var deriveDesignFlows = (meta, entryScreen) => {
7177
+ const edges = meta.edges ?? [];
7178
+ const isState = (id) => id.includes("@");
7179
+ const verifiable = edges.filter((e) => e.activator && !isState(e.from) && !isState(e.to));
7180
+ const prev = new Map;
7181
+ const visited = new Set([entryScreen]);
7182
+ const queue = [entryScreen];
7183
+ while (queue.length > 0) {
7184
+ const at = queue.shift();
7185
+ for (const e of verifiable) {
7186
+ if (e.from !== at || visited.has(e.to))
7187
+ continue;
7188
+ visited.add(e.to);
7189
+ prev.set(e.to, e);
7190
+ queue.push(e.to);
7191
+ }
7192
+ }
7193
+ const pathTo = (screen) => {
7194
+ if (screen === entryScreen)
7195
+ return [];
7196
+ if (!visited.has(screen))
7197
+ return null;
7198
+ const hops = [];
7199
+ for (let at = screen;at !== entryScreen; at = hops[0].from)
7200
+ hops.unshift(prev.get(at));
7201
+ return hops;
7202
+ };
7203
+ const flows = [];
7204
+ const skipped = [];
7205
+ for (const e of edges) {
7206
+ const name = `${e.from} —${e.activator ?? "?"}→ ${e.to}`;
7207
+ if (!e.activator) {
7208
+ skipped.push({ name, reason: "no activator (backend/gesture-driven) — cover it with a tests/*.flow.json" });
7209
+ continue;
7210
+ }
7211
+ if (isState(e.from) || isState(e.to)) {
7212
+ skipped.push({ name, reason: "screen-state edge (@state) — states are runtime conditions, not screens" });
7213
+ continue;
7214
+ }
7215
+ const path = pathTo(e.from);
7216
+ if (path === null) {
7217
+ skipped.push({ name, reason: `"${e.from}" has no activator path from "${entryScreen}" (tab-bar navigation?) — cover it with a tests/*.flow.json` });
7218
+ continue;
7219
+ }
7220
+ flows.push({
7221
+ name,
7222
+ kind: "flow",
7223
+ steps: [
7224
+ ...path.map((hop) => ({ tap: hop.activator, expect: { screen: hop.to } })),
7225
+ { tap: e.activator, expect: { screen: e.to } }
7226
+ ]
7227
+ });
7228
+ }
7229
+ return { flows, skipped };
7230
+ };
7231
+ var slug3 = (s) => s.replace(/[^a-zA-Z0-9-_]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "case";
7232
+ var readFlowFile = (abs, displayName) => {
7233
+ let raw;
7234
+ try {
7235
+ raw = JSON.parse(readFileSync21(abs, "utf8"));
7236
+ } catch (e) {
7237
+ throw new CliError(`Can't read "${displayName}": ${e instanceof Error ? e.message : String(e)}`);
7238
+ }
7239
+ if (Array.isArray(raw))
7240
+ return { name: displayName, kind: "file", steps: raw };
7241
+ if (typeof raw !== "object" || raw === null || raw.steps === undefined) {
7242
+ throw new CliError(`"${displayName}" must be a JSON object with "steps" (or a bare step array).`);
7243
+ }
7244
+ 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 };
7246
+ };
7247
+ var test = async (args) => {
7248
+ const { js, warnings, useLocalAssets, root } = await compileHeadlessBundle(args);
7249
+ const renderer = await requireRenderer();
7250
+ const testsDir = resolve8(root, flagStr(args, "dir") ?? "tests");
7251
+ const designMetaPath = resolve8(root, flagStr(args, "design-dir") ?? "design", "meta.json");
7252
+ const asJson = flagBool(args, "json");
7253
+ const out = asJson ? logErr : log;
7254
+ const cases = [];
7255
+ if (args._.length > 0) {
7256
+ for (const given of args._) {
7257
+ const candidates = [resolve8(given), resolve8(testsDir, given), resolve8(testsDir, `${given}.flow.json`)];
7258
+ const abs = candidates.find((p) => existsSync20(p));
7259
+ if (!abs)
7260
+ throw new CliError(`No such test: "${given}" (looked in ${testsDir}).`);
7261
+ cases.push(readFlowFile(abs, given));
7262
+ }
7263
+ } else if (existsSync20(testsDir)) {
7264
+ for (const f2 of readdirSync9(testsDir).filter((f3) => f3.endsWith(".flow.json")).sort()) {
7265
+ cases.push(readFlowFile(resolve8(testsDir, f2), f2));
7266
+ }
7267
+ }
7268
+ const flowsOnly = flagBool(args, "flows");
7269
+ const wantFlows = !flagBool(args, "no-flows") && args._.length === 0;
7270
+ if (flowsOnly)
7271
+ cases.length = 0;
7272
+ let skipped = [];
7273
+ let entryScreen = null;
7274
+ if ((wantFlows || flowsOnly) && existsSync20(designMetaPath)) {
7275
+ let meta;
7276
+ try {
7277
+ meta = JSON.parse(readFileSync21(designMetaPath, "utf8"));
7278
+ } catch (e) {
7279
+ throw new CliError(`Can't read ${designMetaPath}: ${e instanceof Error ? e.message : String(e)}`);
7280
+ }
7281
+ if ((meta.edges ?? []).length > 0) {
7282
+ note("Probing the start screen…");
7283
+ const probe = await renderer.createSession(js, { raster: false, localAssets: useLocalAssets });
7284
+ entryScreen = probe.screenName();
7285
+ probe.dispose();
7286
+ if (entryScreen === null) {
7287
+ 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
+ } else {
7289
+ const derived = deriveDesignFlows(meta, entryScreen);
7290
+ cases.push(...derived.flows);
7291
+ skipped = derived.skipped;
7292
+ }
7293
+ }
7294
+ } else if (flowsOnly) {
7295
+ throw new CliError(`--flows needs a design graph (${designMetaPath} not found).`);
7296
+ }
7297
+ if (cases.length === 0) {
7298
+ for (const w of warnings)
7299
+ warnErr(w);
7300
+ throw new CliError(`No tests found. Write tests/*.flow.json scenarios, or draw activator edges in the design board (design/meta.json).`);
7301
+ }
7302
+ const timeoutMs = Number(flagStr(args, "timeout")) || 20000 + cases.length * 1e4;
7303
+ const killer = setTimeout(() => {
7304
+ warnErr(`Test run timed out after ${timeoutMs}ms (raise --timeout or lower --settle).`);
7305
+ process.exit(1);
7306
+ }, timeoutMs);
7307
+ killer.unref?.();
7308
+ const onConsole = flagBool(args, "logs") ? (level, text) => logErr(`${c.dim(`[${level}]`)} ${text}`) : undefined;
7309
+ const artifactsRoot = resolve8(root, flagStr(args, "artifacts") ?? resolve8(testsDir, ".artifacts"));
7310
+ const results = [];
7311
+ for (const kase of cases) {
7312
+ const flagSettle = flagStr(args, "settle");
7313
+ let result;
7314
+ try {
7315
+ const steps = renderer.parseScenarioSteps(kase.steps);
7316
+ const session = await renderer.createSession(js, {
7317
+ width: Number(flagStr(args, "width")) || kase.width || 390,
7318
+ height: Number(flagStr(args, "height")) || kase.height || 844,
7319
+ settleMs: flagSettle !== undefined ? Number(flagSettle) : kase.settle,
7320
+ waitNetwork: flagBool(args, "wait-network"),
7321
+ localAssets: useLocalAssets,
7322
+ ...resolveSafeAreaChoice(flagStr(args, "safe-area") ?? kase.safeArea, flagStr(args, "device") ?? kase.device),
7323
+ onConsole
7324
+ });
7325
+ try {
7326
+ const io = fileScenarioIO(resolve8(artifactsRoot, slug3(kase.name)));
7327
+ const outcome = await renderer.runScenario(session, steps, io);
7328
+ result = {
7329
+ name: kase.name,
7330
+ kind: kase.kind,
7331
+ ok: outcome.ok,
7332
+ steps: outcome.steps.map((s) => ({ desc: s.desc, ok: s.ok, error: s.error })),
7333
+ artifacts: outcome.failureArtifacts.map((p) => resolve8(artifactsRoot, slug3(kase.name), p))
7334
+ };
7335
+ } finally {
7336
+ session.dispose();
7337
+ }
7338
+ } catch (e) {
7339
+ result = { name: kase.name, kind: kase.kind, ok: false, steps: [], error: e instanceof Error ? e.message : String(e), artifacts: [] };
7340
+ }
7341
+ results.push(result);
7342
+ if (result.ok) {
7343
+ out(`${c.green("✓")} ${result.name}`);
7344
+ } else {
7345
+ out(`${c.red("✗")} ${result.name}`);
7346
+ if (result.error)
7347
+ out(` ${c.red(result.error)}`);
7348
+ for (const s of result.steps) {
7349
+ out(s.ok ? c.dim(` ✓ ${s.desc}`) : ` ${c.red(`✗ ${s.desc} — ${s.error}`)}`);
7350
+ }
7351
+ if (result.artifacts.length > 0)
7352
+ out(c.dim(` failing frame: ${result.artifacts.join(", ")}`));
7353
+ }
7354
+ }
7355
+ for (const w of warnings)
7356
+ warnErr(w);
7357
+ const passed = results.filter((r) => r.ok).length;
7358
+ const failed = results.length - passed;
7359
+ if (skipped.length > 0) {
7360
+ out("");
7361
+ out(c.dim(`Not machine-verifiable (${skipped.length}):`));
7362
+ for (const s of skipped)
7363
+ out(c.dim(` - ${s.name}: ${s.reason}`));
7364
+ }
7365
+ out("");
7366
+ const flowCount = results.filter((r) => r.kind === "flow").length;
7367
+ const summary = [
7368
+ `${passed} passed`,
7369
+ failed > 0 ? c.red(`${failed} failed`) : undefined,
7370
+ skipped.length > 0 ? `${skipped.length} unverifiable` : undefined,
7371
+ flowCount > 0 ? c.dim(`(${flowCount} from the design graph${entryScreen ? `, start: ${entryScreen}` : ""})`) : undefined
7372
+ ].filter(Boolean).join(", ");
7373
+ out(failed > 0 ? `${c.red("✗")} ${summary}` : `${c.green("✓")} ${summary}`);
7374
+ if (asJson) {
7375
+ process.stdout.write(JSON.stringify({
7376
+ ok: failed === 0,
7377
+ passed,
7378
+ failed,
7379
+ entryScreen,
7380
+ cases: results,
7381
+ unverifiable: skipped
7382
+ }, null, 2) + `
7383
+ `);
7384
+ }
7385
+ clearTimeout(killer);
7386
+ process.exit(failed > 0 ? 1 : 0);
7387
+ };
7388
+
7071
7389
  // src/index.ts
7072
- import { existsSync as existsSync21, readFileSync as readFileSync21, realpathSync as realpathSync2 } from "node:fs";
7390
+ import { existsSync as existsSync22, readFileSync as readFileSync23, realpathSync as realpathSync2 } from "node:fs";
7073
7391
  import { createRequire as createRequire4 } from "node:module";
7074
- import { delimiter as delimiter2, dirname as dirname14, join as join23 } from "node:path";
7392
+ import { delimiter as delimiter2, dirname as dirname15, join as join23 } from "node:path";
7075
7393
 
7076
7394
  // src/commands/install.ts
7077
7395
  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";
7396
+ 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
7397
  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";
7398
+ import { basename as basename12, delimiter, dirname as dirname14, join as join22, resolve as resolve9, sep } from "node:path";
7081
7399
  var isWin = process.platform === "win32";
7082
7400
  var HOME = homedir3();
7083
7401
  var safeRealpath = (p) => {
7084
7402
  try {
7085
7403
  return realpathSync(p);
7086
7404
  } catch {
7087
- return resolve8(p);
7405
+ return resolve9(p);
7088
7406
  }
7089
7407
  };
7090
7408
  var onPath = (dir) => {
@@ -7100,19 +7418,19 @@ var onPath = (dir) => {
7100
7418
  });
7101
7419
  };
7102
7420
  var install = async (args) => {
7103
- const bundle = dirname13(process.execPath);
7421
+ const bundle = dirname14(process.execPath);
7104
7422
  const exeName = basename12(process.execPath);
7105
7423
  const chiselName = isWin ? "chisel.exe" : "chisel";
7106
- if (!existsSync20(join22(bundle, "runtime")) || !existsSync20(join22(bundle, chiselName))) {
7424
+ if (!existsSync21(join22(bundle, "runtime")) || !existsSync21(join22(bundle, chiselName))) {
7107
7425
  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
7426
  }
7109
- const companions = [exeName, chiselName, "runtime", "README.txt"].filter((f2) => existsSync20(join22(bundle, f2)));
7427
+ const companions = [exeName, chiselName, "runtime", "README.txt"].filter((f2) => existsSync21(join22(bundle, f2)));
7110
7428
  if (isWin)
7111
7429
  return installWindows(args, bundle, companions);
7112
7430
  return installUnix(args, bundle, companions);
7113
7431
  };
7114
7432
  var copyBundle = (bundle, target, companions) => {
7115
- mkdirSync14(target, { recursive: true });
7433
+ mkdirSync15(target, { recursive: true });
7116
7434
  if (safeRealpath(target) === safeRealpath(bundle))
7117
7435
  return;
7118
7436
  for (const f2 of companions) {
@@ -7130,8 +7448,8 @@ var permError = (e, dir, system) => {
7130
7448
  };
7131
7449
  var installUnix = (args, bundle, companions) => {
7132
7450
  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")));
7451
+ const libDir = resolve9(flagStr(args, "dir") ?? (system ? "/usr/local/lib/lecodes-cli" : join22(HOME, ".local", "lib", "lecodes-cli")));
7452
+ const binDir = resolve9(flagStr(args, "bin") ?? (system ? "/usr/local/bin" : join22(HOME, ".local", "bin")));
7135
7453
  const link = join22(binDir, "lecodes");
7136
7454
  try {
7137
7455
  copyBundle(bundle, libDir, companions);
@@ -7141,7 +7459,7 @@ var installUnix = (args, bundle, companions) => {
7141
7459
  throw permError(e, libDir, system);
7142
7460
  }
7143
7461
  try {
7144
- mkdirSync14(binDir, { recursive: true });
7462
+ mkdirSync15(binDir, { recursive: true });
7145
7463
  rmSync3(link, { force: true });
7146
7464
  symlinkSync(join22(libDir, "lecodes"), link);
7147
7465
  } catch (e) {
@@ -7164,7 +7482,7 @@ var appendPathToShellRc = (binDir) => {
7164
7482
  const { file, name } = shellRc();
7165
7483
  const line = `export PATH="${binDir}:$PATH"`;
7166
7484
  try {
7167
- const existing = existsSync20(file) ? readFileSync20(file, "utf8") : "";
7485
+ const existing = existsSync21(file) ? readFileSync22(file, "utf8") : "";
7168
7486
  if (existing.includes(line))
7169
7487
  return name;
7170
7488
  appendFileSync2(file, `${existing && !existing.endsWith(`
@@ -7202,7 +7520,7 @@ var finishPathUnix = (args, binDir) => {
7202
7520
  log(` • or install system-wide (no PATH edit): ${c.bold("sudo lecodes install --system")}`);
7203
7521
  };
7204
7522
  var installWindows = (args, bundle, companions) => {
7205
- const target = resolve8(flagStr(args, "dir") ?? join22(process.env.LOCALAPPDATA ?? join22(HOME, "AppData", "Local"), "Programs", "lecodes-cli"));
7523
+ const target = resolve9(flagStr(args, "dir") ?? join22(process.env.LOCALAPPDATA ?? join22(HOME, "AppData", "Local"), "Programs", "lecodes-cli"));
7206
7524
  try {
7207
7525
  copyBundle(bundle, target, companions);
7208
7526
  } catch (e) {
@@ -7239,9 +7557,9 @@ var addToUserPathWindows = (dir) => {
7239
7557
 
7240
7558
  // src/index.ts
7241
7559
  var STANDALONE = (() => {
7242
- const dir = dirname14(process.execPath);
7560
+ const dir = dirname15(process.execPath);
7243
7561
  const chisel = process.platform === "win32" ? "chisel.exe" : "chisel";
7244
- return existsSync21(join23(dir, "runtime")) && existsSync21(join23(dir, chisel));
7562
+ return existsSync22(join23(dir, "runtime")) && existsSync22(join23(dir, chisel));
7245
7563
  })();
7246
7564
  var INSTALLED = STANDALONE && (() => {
7247
7565
  const exe = process.platform === "win32" ? "lecodes.exe" : "lecodes";
@@ -7257,7 +7575,7 @@ var INSTALLED = STANDALONE && (() => {
7257
7575
  return false;
7258
7576
  try {
7259
7577
  const cand = join23(d, exe);
7260
- return existsSync21(cand) && realpathSync2(cand) === self;
7578
+ return existsSync22(cand) && realpathSync2(cand) === self;
7261
7579
  } catch {
7262
7580
  return false;
7263
7581
  }
@@ -7320,12 +7638,28 @@ ${c.bold("Commands:")}
7320
7638
  -o, --out <file> write JSON to a file instead of stdout
7321
7639
  --png [file] rasterize a PNG (default screenshot.png;
7322
7640
  needs @napi-rs/canvas)
7641
+ --script <file> drive the app through a step list (tap/type/
7642
+ scroll/wait/expect/screenshot/json steps)
7643
+ --out-dir <dir> where --script step outputs land (default .)
7323
7644
  --scale <n> PNG device-pixel scale (default 2)
7324
7645
  --width/--height viewport size (default 390x844)
7325
7646
  --entry <path> override the entrypoint
7326
7647
  --settle <ms> wait for promises/timers/fetch before snapshot
7327
7648
  --wait-network let data fetches hit the real network
7328
7649
  --logs forward the app's console output (to stderr)
7650
+ test [name…] Run headless flow tests: tests/*.flow.json scenarios, plus flows
7651
+ derived from the design graph (design/meta.json activator edges
7652
+ — checks the built app against the designed navigation; needs
7653
+ named elements + screen roots). Failing steps dump the frame
7654
+ (JSON + PNG) under tests/.artifacts/<flow>/.
7655
+ --flows run ONLY the design-derived flows
7656
+ --no-flows run ONLY the tests/*.flow.json files
7657
+ --json machine-readable report on stdout
7658
+ --dir <folder> tests folder (default tests/)
7659
+ --design-dir <dir> design folder (default design/)
7660
+ --artifacts <dir> failure-artifact folder override
7661
+ (+ the render timing flags: --width/--height, --settle,
7662
+ --device, --safe-area, --wait-network, --logs, --entry)
7329
7663
  design [serve] LeCodes Design — a live canvas of screens for AI-driven prototyping
7330
7664
  (needs the optional 'lecodes-design' package; works inside a
7331
7665
  cloned project or standalone in any folder)
@@ -7374,7 +7708,7 @@ ${c.bold("Commands:")}
7374
7708
  ASC API key; stored in ~/.lecodes/asc.json
7375
7709
 
7376
7710
  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 };
7711
+ var commands = { init: init2, login, create, clone, status, diff, pull, push, compile: compile2, preview: previewLegacy, render, test, design, scene, dev, app };
7378
7712
  if (STANDALONE)
7379
7713
  commands.install = install;
7380
7714
  var readVersion = () => package_default.version ?? "unknown";
@@ -7386,15 +7720,15 @@ var OPTIONAL_PEERS = [
7386
7720
  var peerVersion = (name, probe) => {
7387
7721
  try {
7388
7722
  const require2 = createRequire4(import.meta.url);
7389
- let dir = dirname14(require2.resolve(probe));
7723
+ let dir = dirname15(require2.resolve(probe));
7390
7724
  for (let depth = 0;depth < 16; depth++) {
7391
7725
  const manifest = join23(dir, "package.json");
7392
- if (existsSync21(manifest)) {
7393
- const parsed = JSON.parse(readFileSync21(manifest, "utf8"));
7726
+ if (existsSync22(manifest)) {
7727
+ const parsed = JSON.parse(readFileSync23(manifest, "utf8"));
7394
7728
  if (parsed.name === name)
7395
7729
  return parsed.version ?? "unknown";
7396
7730
  }
7397
- const parent = dirname14(dir);
7731
+ const parent = dirname15(dir);
7398
7732
  if (parent === dir)
7399
7733
  break;
7400
7734
  dir = parent;