lecodes-cli 1.0.0 → 2.0.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.
Files changed (104) hide show
  1. package/dist/index.js +6211 -3649
  2. package/package.json +12 -8
  3. package/runtime/materials/lightmap.mat +115 -139
  4. package/runtime/materials/particles-quad.mat +6 -2
  5. package/runtime/materials/terrain-lightmap.mat +123 -0
  6. package/runtime/materials/terrain.mat +36 -55
  7. package/runtime/sdk-types.json +1 -1
  8. package/runtime/web/VERSION.json +5 -0
  9. package/runtime/web/assets/camera-5XOM8eHl.js +1 -0
  10. package/runtime/web/assets/creator-2d-D9WKYM6h.js +1 -0
  11. package/runtime/web/assets/creator-2d-ukvEmNEp.wasm +0 -0
  12. package/runtime/web/assets/creator-full-BBXzQlhq.js +1 -0
  13. package/runtime/web/assets/creator-full-D2dSDdDj.wasm +0 -0
  14. package/runtime/web/assets/creator-full.js-BYswGsqR.symbols +8124 -0
  15. package/runtime/web/assets/creator-ui-DIc92ion.wasm +0 -0
  16. package/runtime/web/assets/creator-ui-_1Z_AX2M.js +1 -0
  17. package/runtime/web/assets/geolocation-BjTKN9_P.js +1 -0
  18. package/runtime/web/assets/inspect-C7x78PNs.js +2 -0
  19. package/runtime/web/assets/map-CyBxOSV0.js +1 -0
  20. package/runtime/web/assets/map-XzoOFt0E.js +2 -0
  21. package/runtime/web/assets/mapImpl-bxvQ2h1Z.js +823 -0
  22. package/runtime/web/assets/maplibre-gl-worker-B7LXXUkR.js +8 -0
  23. package/runtime/web/assets/materials-uri4FvPE.bin +0 -0
  24. package/runtime/web/assets/materials-world-CQWt8TAm.bin +0 -0
  25. package/runtime/web/assets/qr-scanner-CK_cYoJR.js +1 -0
  26. package/runtime/web/assets/uberarchive-DLH-kBhM.bin +0 -0
  27. package/runtime/web/index.html +36 -0
  28. package/runtime/web/player.js +21 -0
  29. package/runtime/web-page/index.html +41 -0
  30. package/runtime/web-page/page.css +1 -0
  31. package/runtime/web-page/page.js +31 -0
  32. package/src/cli/help.ts +25 -0
  33. package/src/cli/index.ts +7 -7
  34. package/src/commands/app/android.ts +3 -1
  35. package/src/commands/app/desktop.ts +1 -1
  36. package/src/commands/app/desktopMac.ts +10 -9
  37. package/src/commands/app/icon.ts +9 -6
  38. package/src/commands/app/index.ts +3 -1
  39. package/src/commands/app/shared.ts +13 -5
  40. package/src/commands/app/templates/android.ts +10 -11
  41. package/src/commands/app/templates/ios.ts +1 -1
  42. package/src/commands/assets.ts +1 -1
  43. package/src/commands/compile.ts +15 -2
  44. package/src/commands/design/index.ts +4 -4
  45. package/src/commands/design/snapshot.ts +3 -3
  46. package/src/commands/desktop.ts +5 -1
  47. package/src/commands/dev.ts +82 -29
  48. package/src/commands/index.ts +64 -60
  49. package/src/commands/init/index.ts +11 -15
  50. package/src/commands/init/templates.ts +64 -21
  51. package/src/commands/lightmap.ts +459 -200
  52. package/src/commands/navmesh.ts +2 -2
  53. package/src/commands/plugin.ts +46 -0
  54. package/src/commands/render.ts +22 -16
  55. package/src/commands/scene.ts +6 -1
  56. package/src/commands/serve.ts +58 -0
  57. package/src/commands/shaders/new.ts +1 -1
  58. package/src/commands/test.ts +402 -398
  59. package/src/commands/update/index.ts +3 -3
  60. package/src/compile/designCompile.ts +1 -1
  61. package/src/compile/headlessBundle.ts +133 -133
  62. package/src/compile/nativeStack.ts +1 -1
  63. package/src/compile/projectCompile.ts +10 -2
  64. package/src/compile/sceneCompile.ts +8 -22
  65. package/src/declarations/lecodes-3d-editor.d.ts +4 -0
  66. package/src/declarations/lecodes-assets.d.ts +1 -1
  67. package/src/declarations/{lecodes-renderer.d.ts → lecodes-headless.d.ts} +3 -3
  68. package/src/dev/clientTemplates.ts +18 -14
  69. package/src/dev/devServer.ts +75 -38
  70. package/src/dev/iosDev.ts +180 -0
  71. package/src/dev/webRunner.ts +82 -14
  72. package/src/hosts/cmgenTool.ts +1 -1
  73. package/src/hosts/desktopRenderer.ts +98 -27
  74. package/src/hosts/desktopScript.ts +150 -35
  75. package/src/hosts/distRoot.ts +1 -1
  76. package/src/hosts/matcTool.ts +1 -1
  77. package/src/hosts/peerInstall.ts +5 -5
  78. package/src/hosts/peers.ts +2 -2
  79. package/src/index.ts +4 -1
  80. package/src/plugin/contract.ts +438 -0
  81. package/src/plugin/emitKotlin.ts +357 -0
  82. package/src/plugin/emitSdk.ts +259 -0
  83. package/src/plugin/emitSwift.ts +336 -0
  84. package/src/plugin/emitWeb.ts +383 -0
  85. package/src/plugin/generate.ts +157 -0
  86. package/src/plugin/lines.ts +28 -0
  87. package/src/plugin/lower.ts +231 -0
  88. package/src/plugin/manifest.ts +192 -0
  89. package/src/project/ignore.ts +164 -163
  90. package/runtime/materials/lightmap-baked-lite.mat +0 -180
  91. package/runtime/materials/lightmap-baked.mat +0 -181
  92. package/runtime/scene-harness.json +0 -1
  93. package/runtime/web/assets/__vite-browser-external-BIHI7g3E.js +0 -1
  94. package/runtime/web/assets/basis-C64VHDVD.js +0 -1
  95. package/runtime/web/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  96. package/runtime/web/assets/createViewerLite-DHqTFhWB.js +0 -866
  97. package/runtime/web/assets/draco-BiISTFcR.js +0 -118
  98. package/runtime/web/assets/draco_decoder-DsQ12WqX.wasm +0 -0
  99. package/runtime/web/assets/index-Q06c6oHx.js +0 -2
  100. package/runtime/web/assets/mapViewImpl-B2JcES8l.js +0 -810
  101. package/runtime/web/assets/maplibre-gl-worker-CJfwIrte.js +0 -8
  102. package/runtime/web/assets/worker-Caf-yYEI.js +0 -2
  103. package/runtime/web/embed.html +0 -32
  104. package/runtime/web/embed.js +0 -171
@@ -1,398 +1,402 @@
1
- import { existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs"
2
- import { dirname, resolve } from "node:path"
3
- import { compileHeadlessBundle } from "../compile/headlessBundle"
4
- import { desktopDefaultViewport, desktopPluginLibraries, resolveDesktopRenderer } from "./app/shared"
5
- import { desktopRenderFlags, fileScenarioIO, requireRenderer, resolveFixtures, resolveSafeAreaChoice, viewportFlags } from "./render"
6
- import { buildBundle } from "./compile"
7
- import { findProjectRoot } from "../project/manifest"
8
- import { runDesktopRender } from "../hosts/desktopRenderer"
9
- import { evaluateDesktopRun, translateScenario } from "../hosts/desktopScript"
10
- import { CliError, bool, c, defineCommand, log, logErr, note, num, str, warnErr, type FlagValues } from "../cli"
11
- import { bundleFlags, bundleOptions } from "./shared"
12
-
13
- /*
14
- * `lecodes test` — run the project's headless flow tests (needs the optional `lecodes-renderer`
15
- * package). Two sources, both executed as scenario step lists against a fresh session each:
16
- *
17
- * 1. `tests/*.flow.json` — hand-written scenarios (same schema as `lecodes render --script`:
18
- * tap / type / scroll / wait / expect / screenshot / json steps).
19
- * 2. The design graph: when `design/meta.json` exists, every edge with an `activator` between two
20
- * real screens becomes a flow test — walk to the edge's `from` screen along activator edges
21
- * from the start screen, tap the activator, expect a screen named `to`. This checks the BUILT
22
- * app against the DESIGNED navigation — it requires the app to keep the design's `name`s on
23
- * interactive elements and to name each screen root with its design screen id.
24
- * Edges without an activator (backend-driven) or targeting an `@state` are reported as
25
- * "unverifiable"; edges whose `from` has no activator path (e.g. tab-bar navigation, which is
26
- * a surface, not edges) are "unreachable" — cover those with a hand-written flow file.
27
- *
28
- * A failing step stops its flow and dumps the failing frame (JSON + PNG when @napi-rs/canvas is
29
- * installed) under tests/.artifacts/<flow>/ so a red test explains itself without a rerun.
30
- *
31
- * `--desktop` runs the file scenarios through the NATIVE host (desktopScript.ts — coordinates only,
32
- * expect.log checked, semantic expects reported skipped). Design-derived flows tap by activator
33
- * NAME, which the host can't resolve, so they stay headless-only. The viewport / timing and
34
- * --desktop flags are `lecodes render`'s own groups.
35
- */
36
-
37
- type DesignEdge = { from: string, to: string, label?: string, activator?: string }
38
- type DesignMeta = { screens?: Record<string, unknown>, edges?: DesignEdge[] }
39
-
40
- type FlowCase = {
41
- name: string
42
- kind: "file" | "flow"
43
- steps: unknown
44
- /** Canned fetch responses for this case's session. File scenarios: their envelope `fixtures`
45
- * (inline or a path). Design flows: the project pack (tests/fixtures.json / --fixtures). */
46
- fixtures?: import("lecodes-renderer/headless").Fixtures
47
- /** Per-file envelope overrides (file scenarios only). */
48
- width?: number
49
- height?: number
50
- settle?: number
51
- device?: string
52
- safeArea?: string
53
- }
54
-
55
- type SkippedFlow = { name: string, reason: string }
56
-
57
- /** Derive flow cases from the design graph. Returns the runnable flows + the ones that can't be
58
- * machine-verified (documented, not failed). */
59
- export const deriveDesignFlows = (meta: DesignMeta, entryScreen: string): { flows: FlowCase[], skipped: SkippedFlow[] } => {
60
- const edges = meta.edges ?? []
61
- const isState = (id: string) => id.includes("@")
62
- const verifiable = edges.filter((e) => e.activator && !isState(e.from) && !isState(e.to))
63
-
64
- // BFS shortest activator-path from the entry screen (over verifiable edges only).
65
- const prev = new Map<string, DesignEdge>()
66
- const visited = new Set<string>([entryScreen])
67
- const queue = [entryScreen]
68
- while (queue.length > 0) {
69
- const at = queue.shift()!
70
- for (const e of verifiable) {
71
- if (e.from !== at || visited.has(e.to)) continue
72
- visited.add(e.to)
73
- prev.set(e.to, e)
74
- queue.push(e.to)
75
- }
76
- }
77
- const pathTo = (screen: string): DesignEdge[] | null => {
78
- if (screen === entryScreen) return []
79
- if (!visited.has(screen)) return null
80
- const hops: DesignEdge[] = []
81
- for (let at = screen; at !== entryScreen; at = hops[0].from) hops.unshift(prev.get(at)!)
82
- return hops
83
- }
84
-
85
- const flows: FlowCase[] = []
86
- const skipped: SkippedFlow[] = []
87
- for (const e of edges) {
88
- const name = `${e.from} —${e.activator ?? "?"}→ ${e.to}`
89
- if (!e.activator) {
90
- skipped.push({ name, reason: "no activator (backend/gesture-driven) — cover it with a tests/*.flow.json" })
91
- continue
92
- }
93
- if (isState(e.from) || isState(e.to)) {
94
- skipped.push({ name, reason: "screen-state edge (@state) — states are runtime conditions, not screens" })
95
- continue
96
- }
97
- const path = pathTo(e.from)
98
- if (path === null) {
99
- skipped.push({ name, reason: `"${e.from}" has no activator path from "${entryScreen}" (tab-bar navigation?) — cover it with a tests/*.flow.json` })
100
- continue
101
- }
102
- // A `fill` precedes every activator tap: submits are commonly disabled until their form is
103
- // filled, and the graph tests NAVIGATION — dummy input satisfies local gates, the fixture pack
104
- // answers what the submit then fetches. A no-op on screens without inputs.
105
- flows.push({
106
- name,
107
- kind: "flow",
108
- steps: [
109
- ...path.flatMap((hop) => [{ fill: {} }, { tap: hop.activator!, expect: { screen: hop.to } }]),
110
- { fill: {} },
111
- { tap: e.activator, expect: { screen: e.to } },
112
- ],
113
- })
114
- }
115
- return { flows, skipped }
116
- }
117
-
118
- const slug = (s: string): string => s.replace(/[^a-zA-Z0-9-_]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "case"
119
-
120
- /** Read one tests/*.flow.json (envelope + steps, or a bare step array). */
121
- const readFlowFile = (abs: string, displayName: string): FlowCase => {
122
- let raw: unknown
123
- try {
124
- raw = JSON.parse(readFileSync(abs, "utf8"))
125
- } catch (e) {
126
- throw new CliError(`Can't read "${displayName}": ${e instanceof Error ? e.message : String(e)}`)
127
- }
128
- if (Array.isArray(raw)) return { name: displayName, kind: "file", steps: raw }
129
- if (typeof raw !== "object" || raw === null || (raw as { steps?: unknown }).steps === undefined) {
130
- throw new CliError(`"${displayName}" must be a JSON object with "steps" (or a bare step array).`)
131
- }
132
- const file = raw as { name?: string, width?: number, height?: number, settle?: number, device?: string, safeArea?: string, fixtures?: Record<string, unknown> | string, steps: unknown }
133
- return {
134
- name: file.name ?? displayName, kind: "file", steps: file.steps,
135
- fixtures: resolveFixtures(file.fixtures, dirname(abs)),
136
- width: file.width, height: file.height, settle: file.settle, device: file.device, safeArea: file.safeArea,
137
- }
138
- }
139
-
140
- const testFlags = {
141
- flows: bool("run ONLY the design-derived flows"),
142
- "no-flows": bool("run ONLY the tests/*.flow.json files"),
143
- fixtures: str("fixture pack for the design flows (overrides the tests/fixtures.json default)", { value: "<file>" }),
144
- json: bool("machine-readable report on stdout"),
145
- dir: str("tests folder", { value: "<folder>", default: "tests" }),
146
- "design-dir": str("design folder", { value: "<dir>", default: "design" }),
147
- artifacts: str("failure-artifact folder override (default <tests>/.artifacts)", { value: "<dir>" }),
148
- timeout: num("hard cap on the whole run (default 20000 + 10000 per case; --desktop 60000 + 60000 per case)", { value: "<ms>" }),
149
- ...viewportFlags,
150
- ...bundleFlags,
151
- ...desktopRenderFlags,
152
- desktop: bool("run the file scenarios through the NATIVE host (coordinates only; expect.log checked, text/node/screen/value skipped; design flows stay headless). --frames / --fixed-dt as in render --desktop"),
153
- }
154
-
155
- type TestFlags = FlagValues<typeof testFlags>
156
-
157
- export default defineCommand({
158
- name: "test",
159
- summary: "Run the flow tests (tests/*.flow.json + design-derived flows)",
160
- usage: "[name…]",
161
- description: "Headless flow tests: tests/*.flow.json scenarios, plus flows derived from the design graph (design/meta.json activator edges — checks the built app against the designed navigation; needs named elements + screen roots). Failing steps dump the frame (JSON + PNG) under tests/.artifacts/<flow>/.\n\nDesign flows read tests/fixtures.json automatically (canned fetch responses) so backend-gated transitions run headless; file scenarios declare their own via the \"fixtures\" envelope. [name…] runs only those files (a path, or a name under the tests folder).",
162
- flags: testFlags,
163
- examples: ["lecodes test", "lecodes test hero --logs", "lecodes test --flows --json", "lecodes test --desktop"],
164
- run: ({ args, flags }) => runTests(args, flags),
165
- })
166
-
167
- const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
168
- const desktop = flags.desktop
169
- if (desktop && flags.flows) throw new CliError("--flows needs the headless renderer (design flows tap by name) — drop --desktop.")
170
- // --desktop: ONE renderer decision (host binary + the staged .filamat variant). The root is
171
- // recomputed here because `root` below comes from the headless bundle, which --desktop skips.
172
- let desktopRoot: string
173
- try { desktopRoot = findProjectRoot(process.cwd()) } catch { desktopRoot = process.cwd() }
174
- const desktopRenderer = desktop ? resolveDesktopRenderer(desktopRoot, flags.renderer) : undefined
175
- const native = desktop ? await buildBundle(bundleOptions(flags), { localAssets: true, desktopRenderer }) : null
176
- const headless = desktop ? null : await compileHeadlessBundle({ entry: flags.entry, publicUrl: flags["public-url"], remoteAssets: flags["remote-assets"], waitNetwork: flags["wait-network"] })
177
- const warnings = headless?.warnings ?? []
178
- const useLocalAssets = headless?.useLocalAssets ?? false
179
- const js = headless?.js ?? ""
180
- let root: string
181
- if (headless) root = headless.root
182
- else { try { root = findProjectRoot(process.cwd()) } catch { root = process.cwd() } }
183
- const renderer = desktop ? null : await requireRenderer()
184
- const nativeFrames = flags.frames || 60
185
- const nativeFixedDt = flags["fixed-dt"] ?? 16
186
- if (nativeFixedDt < 0 || nativeFixedDt > 1000) throw new CliError(`--fixed-dt wants a number of ms (0 = wall clock, max 1000), got "${nativeFixedDt}".`)
187
- // Desktop-only projects (app.json desktop block, no ios/android) run flows desktop-sized.
188
- const projectViewport = desktopDefaultViewport(root)
189
-
190
- const testsDir = resolve(root, flags.dir)
191
- const designMetaPath = resolve(root, flags["design-dir"], "meta.json")
192
- const asJson = flags.json
193
- const out = asJson ? logErr : log // --json owns stdout; the human report moves to stderr
194
-
195
- // 1. File scenarios: the given paths, or every tests/*.flow.json.
196
- const cases: FlowCase[] = []
197
- if (args.length > 0) {
198
- for (const given of args) {
199
- const candidates = [resolve(given), resolve(testsDir, given), resolve(testsDir, `${given}.flow.json`)]
200
- const abs = candidates.find((p) => existsSync(p))
201
- if (!abs) throw new CliError(`No such test: "${given}" (looked in ${testsDir}).`)
202
- cases.push(readFlowFile(abs, given))
203
- }
204
- } else if (existsSync(testsDir)) {
205
- for (const f of readdirSync(testsDir).filter((f) => f.endsWith(".flow.json")).sort()) {
206
- cases.push(readFlowFile(resolve(testsDir, f), f))
207
- }
208
- }
209
-
210
- // The project fixture pack: backing data for the design-derived flows (whose success paths are
211
- // usually backend-gated). `tests/fixtures.json` is picked up automatically; --fixtures overrides
212
- // the path. File scenarios do NOT inherit it — they declare their own via the envelope, so a
213
- // deliberately-offline scenario (error states) stays offline.
214
- const defaultPack = resolve(testsDir, "fixtures.json")
215
- const flowFixtures = flags.fixtures !== undefined
216
- ? resolveFixtures(flags.fixtures, ".")
217
- : existsSync(defaultPack) ? resolveFixtures("fixtures.json", testsDir) : undefined
218
-
219
- // 2. Design-graph flows (opt out with --no-flows; --flows runs ONLY these).
220
- const flowsOnly = flags.flows
221
- const wantFlows = !flags["no-flows"] && args.length === 0
222
- if (flowsOnly) cases.length = 0
223
- let skipped: SkippedFlow[] = []
224
- let entryScreen: string | null = null
225
- if (desktop && wantFlows && existsSync(designMetaPath)) {
226
- note("Design-derived flows skipped with --desktop (they tap by name; run `lecodes test` headless for them).")
227
- }
228
- if (!desktop && (wantFlows || flowsOnly) && existsSync(designMetaPath)) {
229
- let meta: DesignMeta
230
- try {
231
- meta = JSON.parse(readFileSync(designMetaPath, "utf8")) as DesignMeta
232
- } catch (e) {
233
- throw new CliError(`Can't read ${designMetaPath}: ${e instanceof Error ? e.message : String(e)}`)
234
- }
235
- if ((meta.edges ?? []).length > 0) {
236
- // Probe the app for its start screen — the BFS origin of every derived flow.
237
- note("Probing the start screen…")
238
- const probe = await renderer!.createSession(js, { raster: false, localAssets: useLocalAssets, fixtures: flowFixtures })
239
- entryScreen = probe.screenName()
240
- probe.dispose()
241
- if (entryScreen === null) {
242
- 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\" })).")
243
- } else {
244
- const derived = deriveDesignFlows(meta, entryScreen)
245
- cases.push(...derived.flows.map((f) => ({ ...f, fixtures: flowFixtures })))
246
- skipped = derived.skipped
247
- }
248
- }
249
- } else if (flowsOnly) {
250
- throw new CliError(`--flows needs a design graph (${designMetaPath} not found).`)
251
- }
252
-
253
- if (cases.length === 0) {
254
- for (const w of warnings) warnErr(w)
255
- throw new CliError(`No tests found. Write tests/*.flow.json scenarios, or draw activator edges in the design board (design/meta.json).`)
256
- }
257
-
258
- // Hard timeout across the whole run (each case is bounded by settles, but a pathological app can
259
- // still spin) — generous per-case allowance, overridable.
260
- const timeoutMs = flags.timeout || (desktop ? 60000 + cases.length * 60000 : 20000 + cases.length * 10000)
261
- const killer = setTimeout(() => {
262
- warnErr(`Test run timed out after ${timeoutMs}ms (raise --timeout or lower --settle).`)
263
- process.exit(1)
264
- }, timeoutMs)
265
- killer.unref?.()
266
-
267
- const onConsole = flags.logs
268
- ? (level: string, text: string) => logErr(`${c.dim(`[${level}]`)} ${text}`)
269
- : undefined
270
- const artifactsRoot = resolve(root, flags.artifacts ?? resolve(testsDir, ".artifacts"))
271
-
272
- type CaseResult = {
273
- name: string
274
- kind: FlowCase["kind"]
275
- ok: boolean
276
- steps: { desc: string, ok: boolean, error?: string, skipped?: string[] }[]
277
- error?: string
278
- artifacts: string[]
279
- }
280
- const results: CaseResult[] = []
281
-
282
- const reportCase = (result: CaseResult): void => {
283
- const skippedCount = result.steps.reduce((n, s) => n + (s.skipped?.length ?? 0), 0)
284
- if (result.ok) {
285
- out(`${c.green("✓")} ${result.name}${skippedCount > 0 ? c.dim(` (${skippedCount} semantic check${skippedCount === 1 ? "" : "s"} skipped on --desktop)`) : ""}`)
286
- } else {
287
- out(`${c.red("✗")} ${result.name}`)
288
- if (result.error) out(` ${c.red(result.error)}`)
289
- for (const s of result.steps) {
290
- out(s.ok ? c.dim(` ✓ ${s.desc}`) : ` ${c.red(`✗ ${s.desc} — ${s.error}`)}`)
291
- for (const sk of s.skipped ?? []) out(c.dim(` – skipped: ${sk}`))
292
- }
293
- if (result.artifacts.length > 0) out(c.dim(` failing frame: ${result.artifacts.join(", ")}`))
294
- }
295
- }
296
-
297
- // Native case runner: each case is its own host process (fresh state, like a fresh session);
298
- // per-step screenshots + the final frame land under the case's artifacts folder.
299
- const runNativeCase = async (kase: FlowCase): Promise<CaseResult> => {
300
- const caseDir = resolve(artifactsRoot, slug(kase.name))
301
- const plan = translateScenario(kase.steps, { outDir: caseDir, headroomFrames: nativeFrames })
302
- for (const st of plan.steps) if (st.screenshot) mkdirSync(dirname(st.screenshot), { recursive: true })
303
- mkdirSync(caseDir, { recursive: true })
304
- const finalPng = resolve(caseDir, "final.png")
305
- const { output } = await runDesktopRender(native!.js, native!.resources, {
306
- renderer: desktopRenderer,
307
- pluginDirs: desktopPluginLibraries(root).dirs,
308
- width: flags.width || kase.width || projectViewport?.width || 390,
309
- height: flags.height || kase.height || projectViewport?.height || 844,
310
- frames: plan.frameCap, outPng: finalPng, logs: flags.logs,
311
- timeoutMs: 60000 + plan.steps.length * 5000, script: plan.text, fixedDtMs: nativeFixedDt,
312
- })
313
- const r = evaluateDesktopRun(plan, output)
314
- if (!r.loaded) throw new CliError("This desktop host doesn't support scripted input (CREATOR_SCRIPT). Update it: lecodes desktop update (or point LECODES_DESKTOP_EXE at a newer build).")
315
- return {
316
- name: kase.name, kind: kase.kind, ok: r.ok,
317
- steps: r.steps.map((s) => ({ desc: s.desc, ok: s.ok, error: s.error, skipped: s.skipped.length > 0 ? s.skipped : undefined })),
318
- error: r.ended ? undefined : "the host stopped before the script finished (frame cap / crash)",
319
- artifacts: r.ok ? [] : [finalPng],
320
- }
321
- }
322
-
323
- for (const kase of cases) {
324
- let result: CaseResult
325
- if (desktop) {
326
- try {
327
- result = await runNativeCase(kase)
328
- } catch (e) {
329
- if (e instanceof CliError && /doesn't support scripted input/.test(e.message)) throw e
330
- result = { name: kase.name, kind: kase.kind, ok: false, steps: [], error: e instanceof Error ? e.message : String(e), artifacts: [] }
331
- }
332
- results.push(result)
333
- reportCase(result)
334
- continue
335
- }
336
- try {
337
- const steps = renderer!.parseScenarioSteps(kase.steps)
338
- // Every case runs in a FRESH session — no state bleeds between flows.
339
- const session = await renderer!.createSession(js, {
340
- width: flags.width || kase.width || projectViewport?.width || 390,
341
- height: flags.height || kase.height || projectViewport?.height || 844,
342
- settleMs: flags.settle ?? kase.settle,
343
- waitNetwork: flags["wait-network"],
344
- localAssets: useLocalAssets,
345
- fixtures: kase.fixtures,
346
- ...resolveSafeAreaChoice(flags["safe-area"] ?? kase.safeArea, flags.device ?? kase.device),
347
- onConsole,
348
- })
349
- try {
350
- const io = fileScenarioIO(resolve(artifactsRoot, slug(kase.name)))
351
- const outcome = await renderer!.runScenario(session, steps, io)
352
- result = {
353
- name: kase.name, kind: kase.kind, ok: outcome.ok,
354
- steps: outcome.steps.map((s) => ({ desc: s.desc, ok: s.ok, error: s.error })),
355
- artifacts: outcome.failureArtifacts.map((p) => resolve(artifactsRoot, slug(kase.name), p)),
356
- }
357
- } finally {
358
- session.dispose()
359
- }
360
- } catch (e) {
361
- // A malformed scenario / session crash is a failed case, not a dead run.
362
- result = { name: kase.name, kind: kase.kind, ok: false, steps: [], error: e instanceof Error ? e.message : String(e), artifacts: [] }
363
- }
364
- results.push(result)
365
- reportCase(result)
366
- }
367
-
368
- for (const w of warnings) warnErr(w)
369
- const passed = results.filter((r) => r.ok).length
370
- const failed = results.length - passed
371
- if (skipped.length > 0) {
372
- out("")
373
- out(c.dim(`Not machine-verifiable (${skipped.length}):`))
374
- for (const s of skipped) out(c.dim(` - ${s.name}: ${s.reason}`))
375
- }
376
- out("")
377
- const flowCount = results.filter((r) => r.kind === "flow").length
378
- const summary = [
379
- `${passed} passed`,
380
- failed > 0 ? c.red(`${failed} failed`) : undefined,
381
- skipped.length > 0 ? `${skipped.length} unverifiable` : undefined,
382
- flowCount > 0 ? c.dim(`(${flowCount} from the design graph${entryScreen ? `, start: ${entryScreen}` : ""})`) : undefined,
383
- ].filter(Boolean).join(", ")
384
- out(failed > 0 ? `${c.red("✗")} ${summary}` : `${c.green("✓")} ${summary}`)
385
-
386
- if (asJson) {
387
- process.stdout.write(JSON.stringify({
388
- ok: failed === 0,
389
- passed, failed,
390
- entryScreen,
391
- cases: results,
392
- unverifiable: skipped,
393
- }, null, 2) + "\n")
394
- }
395
-
396
- clearTimeout(killer)
397
- process.exit(failed > 0 ? 1 : 0)
398
- }
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs"
2
+ import { dirname, resolve } from "node:path"
3
+ import { compileHeadlessBundle } from "../compile/headlessBundle"
4
+ import { desktopDefaultViewport, desktopPluginLibraries, resolveDesktopRenderer } from "./app/shared"
5
+ import { desktopRenderFlags, fileScenarioIO, requireRenderer, resolveFixtures, resolveSafeAreaChoice, viewportFlags } from "./render"
6
+ import { buildBundle } from "./compile"
7
+ import { findProjectRoot } from "../project/manifest"
8
+ import { runDesktopRender } from "../hosts/desktopRenderer"
9
+ import { evaluateDesktopRun, translateScenario } from "../hosts/desktopScript"
10
+ import { CliError, bool, c, defineCommand, log, logErr, note, num, str, warnErr, type FlagValues } from "../cli"
11
+ import { bundleFlags, bundleOptions } from "./shared"
12
+
13
+ /*
14
+ * `lecodes test` — run the project's headless flow tests (needs the optional `lecodes-headless`
15
+ * package). Two sources, both executed as scenario step lists against a fresh session each:
16
+ *
17
+ * 1. `tests/*.flow.json` — hand-written scenarios (same schema as `lecodes render --script`:
18
+ * tap / type / scroll / wait / expect / screenshot / json steps).
19
+ * 2. The design graph: when `design/meta.json` exists, every edge with an `activator` between two
20
+ * real screens becomes a flow test — walk to the edge's `from` screen along activator edges
21
+ * from the start screen, tap the activator, expect a screen named `to`. This checks the BUILT
22
+ * app against the DESIGNED navigation — it requires the app to keep the design's `name`s on
23
+ * interactive elements and to name each screen root with its design screen id.
24
+ * Edges without an activator (backend-driven) or targeting an `@state` are reported as
25
+ * "unverifiable"; edges whose `from` has no activator path (e.g. tab-bar navigation, which is
26
+ * a surface, not edges) are "unreachable" — cover those with a hand-written flow file.
27
+ *
28
+ * A failing step stops its flow and dumps the failing frame (JSON + PNG when @napi-rs/canvas is
29
+ * installed) under tests/.artifacts/<flow>/ so a red test explains itself without a rerun.
30
+ *
31
+ * `--desktop` runs the file scenarios through the NATIVE host (desktopScript.ts — targets by name or
32
+ * coordinates, expect.log + expect.paint checked, the semantic expects reported skipped). The
33
+ * design-derived flows need `expect.screen` and `fill`, which the host can't answer, so they stay
34
+ * headless-only. The viewport / timing and --desktop flags are `lecodes render`'s own groups.
35
+ */
36
+
37
+ type DesignEdge = { from: string, to: string, label?: string, activator?: string }
38
+ type DesignMeta = { screens?: Record<string, unknown>, edges?: DesignEdge[] }
39
+
40
+ type FlowCase = {
41
+ name: string
42
+ kind: "file" | "flow"
43
+ steps: unknown
44
+ /** Canned fetch responses for this case's session. File scenarios: their envelope `fixtures`
45
+ * (inline or a path). Design flows: the project pack (tests/fixtures.json / --fixtures). */
46
+ fixtures?: import("lecodes-headless/headless").Fixtures
47
+ /** Per-file envelope overrides (file scenarios only). */
48
+ width?: number
49
+ height?: number
50
+ settle?: number
51
+ device?: string
52
+ safeArea?: string
53
+ }
54
+
55
+ type SkippedFlow = { name: string, reason: string }
56
+
57
+ /** Derive flow cases from the design graph. Returns the runnable flows + the ones that can't be
58
+ * machine-verified (documented, not failed). */
59
+ export const deriveDesignFlows = (meta: DesignMeta, entryScreen: string): { flows: FlowCase[], skipped: SkippedFlow[] } => {
60
+ const edges = meta.edges ?? []
61
+ const isState = (id: string) => id.includes("@")
62
+ const verifiable = edges.filter((e) => e.activator && !isState(e.from) && !isState(e.to))
63
+
64
+ // BFS shortest activator-path from the entry screen (over verifiable edges only).
65
+ const prev = new Map<string, DesignEdge>()
66
+ const visited = new Set<string>([entryScreen])
67
+ const queue = [entryScreen]
68
+ while (queue.length > 0) {
69
+ const at = queue.shift()!
70
+ for (const e of verifiable) {
71
+ if (e.from !== at || visited.has(e.to)) continue
72
+ visited.add(e.to)
73
+ prev.set(e.to, e)
74
+ queue.push(e.to)
75
+ }
76
+ }
77
+ const pathTo = (screen: string): DesignEdge[] | null => {
78
+ if (screen === entryScreen) return []
79
+ if (!visited.has(screen)) return null
80
+ const hops: DesignEdge[] = []
81
+ for (let at = screen; at !== entryScreen; at = hops[0].from) hops.unshift(prev.get(at)!)
82
+ return hops
83
+ }
84
+
85
+ const flows: FlowCase[] = []
86
+ const skipped: SkippedFlow[] = []
87
+ for (const e of edges) {
88
+ const name = `${e.from} —${e.activator ?? "?"}→ ${e.to}`
89
+ if (!e.activator) {
90
+ skipped.push({ name, reason: "no activator (backend/gesture-driven) — cover it with a tests/*.flow.json" })
91
+ continue
92
+ }
93
+ if (isState(e.from) || isState(e.to)) {
94
+ skipped.push({ name, reason: "screen-state edge (@state) — states are runtime conditions, not screens" })
95
+ continue
96
+ }
97
+ const path = pathTo(e.from)
98
+ if (path === null) {
99
+ skipped.push({ name, reason: `"${e.from}" has no activator path from "${entryScreen}" (tab-bar navigation?) — cover it with a tests/*.flow.json` })
100
+ continue
101
+ }
102
+ // A `fill` precedes every activator tap: submits are commonly disabled until their form is
103
+ // filled, and the graph tests NAVIGATION — dummy input satisfies local gates, the fixture pack
104
+ // answers what the submit then fetches. A no-op on screens without inputs.
105
+ flows.push({
106
+ name,
107
+ kind: "flow",
108
+ steps: [
109
+ ...path.flatMap((hop) => [{ fill: {} }, { tap: hop.activator!, expect: { screen: hop.to } }]),
110
+ { fill: {} },
111
+ { tap: e.activator, expect: { screen: e.to } },
112
+ ],
113
+ })
114
+ }
115
+ return { flows, skipped }
116
+ }
117
+
118
+ const slug = (s: string): string => s.replace(/[^a-zA-Z0-9-_]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "case"
119
+
120
+ /** Read one tests/*.flow.json (envelope + steps, or a bare step array). */
121
+ const readFlowFile = (abs: string, displayName: string): FlowCase => {
122
+ let raw: unknown
123
+ try {
124
+ raw = JSON.parse(readFileSync(abs, "utf8"))
125
+ } catch (e) {
126
+ throw new CliError(`Can't read "${displayName}": ${e instanceof Error ? e.message : String(e)}`)
127
+ }
128
+ if (Array.isArray(raw)) return { name: displayName, kind: "file", steps: raw }
129
+ if (typeof raw !== "object" || raw === null || (raw as { steps?: unknown }).steps === undefined) {
130
+ throw new CliError(`"${displayName}" must be a JSON object with "steps" (or a bare step array).`)
131
+ }
132
+ const file = raw as { name?: string, width?: number, height?: number, settle?: number, device?: string, safeArea?: string, fixtures?: Record<string, unknown> | string, steps: unknown }
133
+ return {
134
+ name: file.name ?? displayName, kind: "file", steps: file.steps,
135
+ fixtures: resolveFixtures(file.fixtures, dirname(abs)),
136
+ width: file.width, height: file.height, settle: file.settle, device: file.device, safeArea: file.safeArea,
137
+ }
138
+ }
139
+
140
+ const testFlags = {
141
+ flows: bool("run ONLY the design-derived flows"),
142
+ "no-flows": bool("run ONLY the tests/*.flow.json files"),
143
+ fixtures: str("fixture pack for the design flows (overrides the tests/fixtures.json default)", { value: "<file>" }),
144
+ json: bool("machine-readable report on stdout"),
145
+ dir: str("tests folder", { value: "<folder>", default: "tests" }),
146
+ "design-dir": str("design folder", { value: "<dir>", default: "design" }),
147
+ artifacts: str("failure-artifact folder override (default <tests>/.artifacts)", { value: "<dir>" }),
148
+ timeout: num("hard cap on the whole run (default 20000 + 10000 per case; --desktop 60000 + 60000 per case)", { value: "<ms>" }),
149
+ ...viewportFlags,
150
+ ...bundleFlags,
151
+ ...desktopRenderFlags,
152
+ desktop: bool("run the file scenarios through the NATIVE host: the same steps, targets and expects as headless (expect.audio and the json output excepted); design flows stay headless. --frames / --fixed-dt as in render --desktop"),
153
+ }
154
+ // (`sim` comes with desktopRenderFlags: the file scenarios through the WINDOWLESS host — no GPU, no
155
+ // pixels, expect.log only; the same coordinate-only contract as --desktop minus screenshots.)
156
+
157
+ type TestFlags = FlagValues<typeof testFlags>
158
+
159
+ export default defineCommand({
160
+ name: "test",
161
+ summary: "Run the flow tests (tests/*.flow.json + design-derived flows)",
162
+ usage: "[name…]",
163
+ description: "Headless flow tests: tests/*.flow.json scenarios, plus flows derived from the design graph (design/meta.json activator edges — checks the built app against the designed navigation; needs named elements + screen roots). Failing steps dump the frame (JSON + PNG) under tests/.artifacts/<flow>/.\n\nDesign flows read tests/fixtures.json automatically (canned fetch responses) so backend-gated transitions run headless; file scenarios declare their own via the \"fixtures\" envelope. [name…] runs only those files (a path, or a name under the tests folder).",
164
+ flags: testFlags,
165
+ examples: ["lecodes test", "lecodes test hero --logs", "lecodes test --flows --json", "lecodes test --desktop"],
166
+ run: ({ args, flags }) => runTests(args, flags),
167
+ })
168
+
169
+ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
170
+ // --sim is the native path on the windowless host: everything --desktop does, minus pixels.
171
+ const sim = flags.sim
172
+ const desktop = flags.desktop || sim
173
+ if (desktop && flags.flows) throw new CliError(`--flows needs the headless renderer (design flows read expect.screen and fill inputs) — drop --${sim ? "sim" : "desktop"}.`)
174
+ // --desktop: ONE renderer decision (host binary + the staged .filamat variant). The root is
175
+ // recomputed here because `root` below comes from the headless bundle, which --desktop skips.
176
+ let desktopRoot: string
177
+ try { desktopRoot = findProjectRoot(process.cwd()) } catch { desktopRoot = process.cwd() }
178
+ const desktopRenderer = desktop ? resolveDesktopRenderer(desktopRoot, flags.renderer) : undefined
179
+ const native = desktop ? await buildBundle(bundleOptions(flags), { localAssets: true, desktopRenderer }) : null
180
+ const headless = desktop ? null : await compileHeadlessBundle({ entry: flags.entry, publicUrl: flags["public-url"], remoteAssets: flags["remote-assets"], waitNetwork: flags["wait-network"] })
181
+ const warnings = headless?.warnings ?? []
182
+ const useLocalAssets = headless?.useLocalAssets ?? false
183
+ const js = headless?.js ?? ""
184
+ let root: string
185
+ if (headless) root = headless.root
186
+ else { try { root = findProjectRoot(process.cwd()) } catch { root = process.cwd() } }
187
+ const renderer = desktop ? null : await requireRenderer()
188
+ const nativeFrames = flags.frames || 60
189
+ const nativeFixedDt = flags["fixed-dt"] ?? 16
190
+ if (nativeFixedDt < 0 || nativeFixedDt > 1000) throw new CliError(`--fixed-dt wants a number of ms (0 = wall clock, max 1000), got "${nativeFixedDt}".`)
191
+ // Desktop-only projects (app.json desktop block, no ios/android) run flows desktop-sized.
192
+ const projectViewport = desktopDefaultViewport(root)
193
+
194
+ const testsDir = resolve(root, flags.dir)
195
+ const designMetaPath = resolve(root, flags["design-dir"], "meta.json")
196
+ const asJson = flags.json
197
+ const out = asJson ? logErr : log // --json owns stdout; the human report moves to stderr
198
+
199
+ // 1. File scenarios: the given paths, or every tests/*.flow.json.
200
+ const cases: FlowCase[] = []
201
+ if (args.length > 0) {
202
+ for (const given of args) {
203
+ const candidates = [resolve(given), resolve(testsDir, given), resolve(testsDir, `${given}.flow.json`)]
204
+ const abs = candidates.find((p) => existsSync(p))
205
+ if (!abs) throw new CliError(`No such test: "${given}" (looked in ${testsDir}).`)
206
+ cases.push(readFlowFile(abs, given))
207
+ }
208
+ } else if (existsSync(testsDir)) {
209
+ for (const f of readdirSync(testsDir).filter((f) => f.endsWith(".flow.json")).sort()) {
210
+ cases.push(readFlowFile(resolve(testsDir, f), f))
211
+ }
212
+ }
213
+
214
+ // The project fixture pack: backing data for the design-derived flows (whose success paths are
215
+ // usually backend-gated). `tests/fixtures.json` is picked up automatically; --fixtures overrides
216
+ // the path. File scenarios do NOT inherit it — they declare their own via the envelope, so a
217
+ // deliberately-offline scenario (error states) stays offline.
218
+ const defaultPack = resolve(testsDir, "fixtures.json")
219
+ const flowFixtures = flags.fixtures !== undefined
220
+ ? resolveFixtures(flags.fixtures, ".")
221
+ : existsSync(defaultPack) ? resolveFixtures("fixtures.json", testsDir) : undefined
222
+
223
+ // 2. Design-graph flows (opt out with --no-flows; --flows runs ONLY these).
224
+ const flowsOnly = flags.flows
225
+ const wantFlows = !flags["no-flows"] && args.length === 0
226
+ if (flowsOnly) cases.length = 0
227
+ let skipped: SkippedFlow[] = []
228
+ let entryScreen: string | null = null
229
+ if (desktop && wantFlows && existsSync(designMetaPath)) {
230
+ note("Design-derived flows skipped with --desktop (they read expect.screen and fill inputs; run `lecodes test` headless for them).")
231
+ }
232
+ if (!desktop && (wantFlows || flowsOnly) && existsSync(designMetaPath)) {
233
+ let meta: DesignMeta
234
+ try {
235
+ meta = JSON.parse(readFileSync(designMetaPath, "utf8")) as DesignMeta
236
+ } catch (e) {
237
+ throw new CliError(`Can't read ${designMetaPath}: ${e instanceof Error ? e.message : String(e)}`)
238
+ }
239
+ if ((meta.edges ?? []).length > 0) {
240
+ // Probe the app for its start screen — the BFS origin of every derived flow.
241
+ note("Probing the start screen…")
242
+ const probe = await renderer!.createSession(js, { raster: false, localAssets: useLocalAssets, fixtures: flowFixtures })
243
+ entryScreen = probe.screenName()
244
+ probe.dispose()
245
+ if (entryScreen === null) {
246
+ 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(...).named(\"login\")).")
247
+ } else {
248
+ const derived = deriveDesignFlows(meta, entryScreen)
249
+ cases.push(...derived.flows.map((f) => ({ ...f, fixtures: flowFixtures })))
250
+ skipped = derived.skipped
251
+ }
252
+ }
253
+ } else if (flowsOnly) {
254
+ throw new CliError(`--flows needs a design graph (${designMetaPath} not found).`)
255
+ }
256
+
257
+ if (cases.length === 0) {
258
+ for (const w of warnings) warnErr(w)
259
+ throw new CliError(`No tests found. Write tests/*.flow.json scenarios, or draw activator edges in the design board (design/meta.json).`)
260
+ }
261
+
262
+ // Hard timeout across the whole run (each case is bounded by settles, but a pathological app can
263
+ // still spin) — generous per-case allowance, overridable.
264
+ const timeoutMs = flags.timeout || (desktop ? 60000 + cases.length * 60000 : 20000 + cases.length * 10000)
265
+ const killer = setTimeout(() => {
266
+ warnErr(`Test run timed out after ${timeoutMs}ms (raise --timeout or lower --settle).`)
267
+ process.exit(1)
268
+ }, timeoutMs)
269
+ killer.unref?.()
270
+
271
+ const onConsole = flags.logs
272
+ ? (level: string, text: string) => logErr(`${c.dim(`[${level}]`)} ${text}`)
273
+ : undefined
274
+ const artifactsRoot = resolve(root, flags.artifacts ?? resolve(testsDir, ".artifacts"))
275
+
276
+ type CaseResult = {
277
+ name: string
278
+ kind: FlowCase["kind"]
279
+ ok: boolean
280
+ steps: { desc: string, ok: boolean, error?: string, skipped?: string[] }[]
281
+ error?: string
282
+ artifacts: string[]
283
+ }
284
+ const results: CaseResult[] = []
285
+
286
+ const reportCase = (result: CaseResult): void => {
287
+ const skippedCount = result.steps.reduce((n, s) => n + (s.skipped?.length ?? 0), 0)
288
+ if (result.ok) {
289
+ out(`${c.green("✓")} ${result.name}${skippedCount > 0 ? c.dim(` (${skippedCount} semantic check${skippedCount === 1 ? "" : "s"} skipped on --desktop)`) : ""}`)
290
+ } else {
291
+ out(`${c.red("✗")} ${result.name}`)
292
+ if (result.error) out(` ${c.red(result.error)}`)
293
+ for (const s of result.steps) {
294
+ out(s.ok ? c.dim(` ✓ ${s.desc}`) : ` ${c.red(`✗ ${s.desc} — ${s.error}`)}`)
295
+ for (const sk of s.skipped ?? []) out(c.dim(` – skipped: ${sk}`))
296
+ }
297
+ if (result.artifacts.length > 0) out(c.dim(` failing frame: ${result.artifacts.join(", ")}`))
298
+ }
299
+ }
300
+
301
+ // Native case runner: each case is its own host process (fresh state, like a fresh session);
302
+ // per-step screenshots + the final frame land under the case's artifacts folder.
303
+ const runNativeCase = async (kase: FlowCase): Promise<CaseResult> => {
304
+ const caseDir = resolve(artifactsRoot, slug(kase.name))
305
+ const plan = translateScenario(kase.steps, { outDir: caseDir, headroomFrames: nativeFrames, sim })
306
+ for (const st of plan.steps) if (st.screenshot) mkdirSync(dirname(st.screenshot), { recursive: true })
307
+ mkdirSync(caseDir, { recursive: true })
308
+ const finalPng = resolve(caseDir, "final.png")
309
+ const { output } = await runDesktopRender(native!.js, native!.resources, {
310
+ renderer: desktopRenderer, host: sim ? "server" : "desktop",
311
+ pluginDirs: desktopPluginLibraries(root).dirs,
312
+ width: flags.width || kase.width || projectViewport?.width || 390,
313
+ height: flags.height || kase.height || projectViewport?.height || 844,
314
+ frames: plan.frameCap, outPng: finalPng, logs: flags.logs,
315
+ timeoutMs: 60000 + plan.steps.length * 5000, script: plan.text, fixedDtMs: nativeFixedDt,
316
+ })
317
+ const r = evaluateDesktopRun(plan, output, (path) => existsSync(path) ? readFileSync(path, "utf8") : null)
318
+ if (!r.loaded) throw new CliError("This desktop host doesn't support scripted input (CREATOR_SCRIPT). Update it: lecodes desktop update (or point LECODES_DESKTOP_EXE at a newer build).")
319
+ return {
320
+ name: kase.name, kind: kase.kind, ok: r.ok,
321
+ steps: r.steps.map((s) => ({ desc: s.desc, ok: s.ok, error: s.error, skipped: s.skipped.length > 0 ? s.skipped : undefined })),
322
+ error: r.ended ? undefined : "the host stopped before the script finished (frame cap / crash)",
323
+ artifacts: r.ok ? [] : [finalPng],
324
+ }
325
+ }
326
+
327
+ for (const kase of cases) {
328
+ let result: CaseResult
329
+ if (desktop) {
330
+ try {
331
+ result = await runNativeCase(kase)
332
+ } catch (e) {
333
+ if (e instanceof CliError && /doesn't support scripted input/.test(e.message)) throw e
334
+ result = { name: kase.name, kind: kase.kind, ok: false, steps: [], error: e instanceof Error ? e.message : String(e), artifacts: [] }
335
+ }
336
+ results.push(result)
337
+ reportCase(result)
338
+ continue
339
+ }
340
+ try {
341
+ const steps = renderer!.parseScenarioSteps(kase.steps)
342
+ // Every case runs in a FRESH session — no state bleeds between flows.
343
+ const session = await renderer!.createSession(js, {
344
+ width: flags.width || kase.width || projectViewport?.width || 390,
345
+ height: flags.height || kase.height || projectViewport?.height || 844,
346
+ settleMs: flags.settle ?? kase.settle,
347
+ waitNetwork: flags["wait-network"],
348
+ localAssets: useLocalAssets,
349
+ fixtures: kase.fixtures,
350
+ ...resolveSafeAreaChoice(flags["safe-area"] ?? kase.safeArea, flags.device ?? kase.device),
351
+ onConsole,
352
+ })
353
+ try {
354
+ const io = fileScenarioIO(resolve(artifactsRoot, slug(kase.name)))
355
+ const outcome = await renderer!.runScenario(session, steps, io)
356
+ result = {
357
+ name: kase.name, kind: kase.kind, ok: outcome.ok,
358
+ steps: outcome.steps.map((s) => ({ desc: s.desc, ok: s.ok, error: s.error })),
359
+ artifacts: outcome.failureArtifacts.map((p) => resolve(artifactsRoot, slug(kase.name), p)),
360
+ }
361
+ } finally {
362
+ session.dispose()
363
+ }
364
+ } catch (e) {
365
+ // A malformed scenario / session crash is a failed case, not a dead run.
366
+ result = { name: kase.name, kind: kase.kind, ok: false, steps: [], error: e instanceof Error ? e.message : String(e), artifacts: [] }
367
+ }
368
+ results.push(result)
369
+ reportCase(result)
370
+ }
371
+
372
+ for (const w of warnings) warnErr(w)
373
+ const passed = results.filter((r) => r.ok).length
374
+ const failed = results.length - passed
375
+ if (skipped.length > 0) {
376
+ out("")
377
+ out(c.dim(`Not machine-verifiable (${skipped.length}):`))
378
+ for (const s of skipped) out(c.dim(` - ${s.name}: ${s.reason}`))
379
+ }
380
+ out("")
381
+ const flowCount = results.filter((r) => r.kind === "flow").length
382
+ const summary = [
383
+ `${passed} passed`,
384
+ failed > 0 ? c.red(`${failed} failed`) : undefined,
385
+ skipped.length > 0 ? `${skipped.length} unverifiable` : undefined,
386
+ flowCount > 0 ? c.dim(`(${flowCount} from the design graph${entryScreen ? `, start: ${entryScreen}` : ""})`) : undefined,
387
+ ].filter(Boolean).join(", ")
388
+ out(failed > 0 ? `${c.red("✗")} ${summary}` : `${c.green("✓")} ${summary}`)
389
+
390
+ if (asJson) {
391
+ process.stdout.write(JSON.stringify({
392
+ ok: failed === 0,
393
+ passed, failed,
394
+ entryScreen,
395
+ cases: results,
396
+ unverifiable: skipped,
397
+ }, null, 2) + "\n")
398
+ }
399
+
400
+ clearTimeout(killer)
401
+ process.exit(failed > 0 ? 1 : 0)
402
+ }