lecodes-cli 2.6.0 → 2.8.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.
@@ -4,7 +4,8 @@ import { compileHeadlessBundle } from "../compile/headlessBundle"
4
4
  import { screenTargetFrom } from "../compile/screenEntry"
5
5
  import { findProjectRoot } from "../project/manifest"
6
6
  import { buildBundle } from "./compile"
7
- import { runBackend } from "../dev/projectBackend"
7
+ import { projectRunBackend } from "../dev/devLink"
8
+ import { callEndpoint } from "../dev/backendCall"
8
9
  import { resolvePicks } from "../hosts/flowPick"
9
10
  import { desktopDefaultViewport, desktopPluginLibraries, readAppConfigAt, resolveDesktopRenderer } from "./app/shared"
10
11
  import { runDesktopRender } from "../hosts/desktopRenderer"
@@ -139,6 +140,8 @@ type ScenarioFile = {
139
140
  device?: string
140
141
  safeArea?: string
141
142
  fixtures?: Record<string, unknown> | string
143
+ /** How long an expect waits by default, ms. */
144
+ timeout?: number
142
145
  steps: unknown
143
146
  }
144
147
 
@@ -175,6 +178,7 @@ const readScenarioFile = (path: string): ScenarioFile => {
175
178
  if (typeof raw !== "object" || raw === null) throw new CliError(`Scenario "${path}" must be a JSON object with "steps" (or a bare step array).`)
176
179
  const file = raw as ScenarioFile
177
180
  if (file.steps === undefined) throw new CliError(`Scenario "${path}" has no "steps".`)
181
+ if ((raw as { clients?: unknown }).clients !== undefined) throw new CliError(`"clients" (several clients in one case) runs under \`lecodes test\`: \`lecodes test ${path}\`.`)
178
182
  return { ...file, steps: resolvePicks(file.steps, dirname(resolve(path))) }
179
183
  }
180
184
 
@@ -209,6 +213,7 @@ const renderFlags = {
209
213
  "out-dir": str("where --script step outputs land", { value: "<dir>", default: "." }),
210
214
  clip: str("(--png) crop to one node's rect or a logical-px rect — a component / region render", { value: "<name|x,y,w,h>" }),
211
215
  fixtures: str("canned fetch responses (URL pattern → { status?, json?, text? }) — make backend-gated screens reachable headless; merged over the scenario file's own", { value: "<file>" }),
216
+ fresh: bool("start on an EMPTY database: the project's backend (a running lecodes dev's, else .lecodes/db) is emptied first — by default its data stays"),
212
217
  scale: num("PNG device-pixel scale", { default: 2 }),
213
218
  time: num("render at this animation time (0 = the initial frame-0 state)", { value: "<ms>" }),
214
219
  "no-after-animations": bool("keep frame 0 instead of advancing animations to their resting state"),
@@ -301,12 +306,14 @@ const renderDesktop = async (flags: RenderFlags, file: string | undefined): Prom
301
306
  const desktopRenderer = resolveDesktopRenderer(root, flags.renderer)
302
307
  // Native plugins (project plugins/ dir + declared libraries) — the host scans CREATOR_PLUGIN_DIRS.
303
308
  const { dirs: pluginDirs } = desktopPluginLibraries(root)
304
- // The project's backend, when it has one (the database of `lecodes dev`).
305
- const backend = runBackend({ root, command: "lecodes render", log: (msg) => { if (logs || msg.includes("[server:error]")) logErr(msg) } })
309
+ // The project's backend, when it has one: a running `lecodes dev`'s (it holds the database), else one
310
+ // started for the run on its database (.lecodes/db). --fresh empties it first.
311
+ const backend = await projectRunBackend({ root, command: "lecodes render", note, log: (msg) => { if (logs || msg.includes("[server:error]")) logErr(msg) } })
306
312
  const { js, resources } = await buildBundle(bundleOptions(flags), {
307
- localAssets: true, desktopRenderer, backend,
313
+ localAssets: true, desktopRenderer, backend, ambiguousEntry: "error",
308
314
  extraEntries: screen ? [screen.entry] : undefined, entryOverride: screen?.entryPath,
309
315
  })
316
+ if (flags.fresh) await backend.reset()
310
317
  // The host writes PNGs but never creates folders — make every output dir up front.
311
318
  if (!sim) mkdirSync(dirname(resolve(pngFlag)), { recursive: true })
312
319
 
@@ -369,18 +376,11 @@ const renderHeadless = async (flags: RenderFlags, file: string | undefined): Pro
369
376
  }, timeoutMs)
370
377
  killer.unref?.()
371
378
 
372
- // The project's backend, when it has one (the database of `lecodes dev`). Beside a running
373
- // `lecodes dev`, which holds that database, the picture is still made — without the backend.
374
- const run = runBackend({ root: projectRoot, command: "lecodes render", log: (msg) => { if (flags.logs || msg.includes("[server:error]")) logErr(msg) } })
375
- const backend: typeof run = {
376
- ...run,
377
- start: (entries, onWarning) => run.start(entries, onWarning).catch((e: Error) => {
378
- if (!/is in use/.test(e.message)) throw e
379
- warnErr(`${e.message} Rendering without the backend: every call to the server fails.`)
380
- return undefined
381
- }),
382
- }
379
+ // The project's backend, when it has one: a running `lecodes dev`'s (it holds the database), else one
380
+ // started for the run on its database (.lecodes/db). --fresh empties it first.
381
+ const backend = await projectRunBackend({ root: projectRoot, command: "lecodes render", note, log: (msg) => { if (flags.logs || msg.includes("[server:error]")) logErr(msg) } })
383
382
  const { js, warnings, useLocalAssets, root, serverUrl } = await compileHeadlessBundle({ screen, entry: flags.entry, publicUrl: flags["public-url"], remoteAssets: flags["remote-assets"], waitNetwork: flags["wait-network"], backend })
383
+ if (flags.fresh && serverUrl) await backend.reset()
384
384
  const renderer = await requireRenderer()
385
385
 
386
386
  // Flags override the scenario file's envelope; both fall back to the project default (a
@@ -427,16 +427,27 @@ const renderHeadless = async (flags: RenderFlags, file: string | undefined): Pro
427
427
 
428
428
  // --script: drive the compiled app through the scenario steps against a live session.
429
429
  if (scenario) {
430
- const steps = renderer.parseScenarioSteps(scenario.steps)
430
+ // A running lecodes dev's backend keeps its devices' clock: the run is on real time then (no `clock`
431
+ // step; a `wait` waits), as `dev --bots` is.
432
+ const live = !!(serverUrl && backend.dev)
433
+ const steps = (() => {
434
+ try { return renderer.parseScenarioSteps(scenario.steps, live ? { realTime: true } : {}) }
435
+ catch (e) { throw new CliError(`${flags.script}: ${(e as Error).message}`) }
436
+ })()
431
437
  const outDir = flags["out-dir"]
432
438
  // Screenshots require pixels; otherwise rasterize opportunistically (napi installed → failure
433
439
  // PNGs and real Skia text metrics, absent → deterministic HeadlessHost metrics).
434
440
  const needsRaster = steps.some((s) => s.screenshot !== undefined)
435
441
  note(`Running ${steps.length} step${steps.length === 1 ? "" : "s"}…`)
436
- const session = await renderer.createSession(js, { width, height, ...timing, raster: needsRaster ? true : "auto" })
437
- try {
438
- const result = await renderer.runScenario(session, steps, fileScenarioIO(outDir))
439
- for (const w of [ ...warnings, ...session.warnings ]) warnErr(w)
442
+ // one client, the case runner's: its clock is the backend's too (not a dev's), and `call` steps reach the backend as it
443
+ let session: Awaited<ReturnType<typeof renderer.createSession>> | null = null
444
+ {
445
+ const result = await renderer.runCase({
446
+ clients: [{ name: "app", start: async (env) => (session = await renderer.createSession(js, { width, height, ...timing, raster: needsRaster ? true : "auto", ...env })) }],
447
+ single: true, steps, io: fileScenarioIO(outDir), timeoutMs: scenario.timeout, realTime: live,
448
+ backend: serverUrl ? { url: serverUrl, call: (id, args, device) => callEndpoint(serverUrl, id, args, device), ...(live ? {} : { setClock: (ms: number) => backend.setClock(ms) }) } : undefined,
449
+ })
450
+ for (const w of [ ...warnings, ...((session as { warnings: string[] } | null)?.warnings ?? []) ]) warnErr(w)
440
451
  for (const st of result.steps) {
441
452
  logErr(st.ok ? `${c.green("✓")} ${st.desc}` : `${c.red("✗")} ${st.desc}\n ${c.red(st.error ?? "failed")}`)
442
453
  }
@@ -445,8 +456,6 @@ const renderHeadless = async (flags: RenderFlags, file: string | undefined): Pro
445
456
  }
446
457
  logErr(result.ok ? c.green(`✓ ${result.steps.length}/${steps.length} steps passed`) : c.red(`✗ failed at step ${result.steps.length} of ${steps.length}`))
447
458
  return done((cb) => cb(), result.ok ? 0 : 1)
448
- } finally {
449
- session.dispose()
450
459
  }
451
460
  }
452
461
 
@@ -5,6 +5,7 @@ import { desktopDefaultViewport, desktopPluginLibraries, resolveDesktopRenderer
5
5
  import { desktopRenderFlags, fileScenarioIO, parseNativeViews, requireRenderer, resolveFixtures, resolveSafeAreaChoice, viewportFlags } from "./render"
6
6
  import { buildBundle } from "./compile"
7
7
  import { runBackend } from "../dev/projectBackend"
8
+ import { callEndpoint } from "../dev/backendCall"
8
9
  import { resolvePicks } from "../hosts/flowPick"
9
10
  import { findProjectRoot } from "../project/manifest"
10
11
  import { runDesktopRender } from "../hosts/desktopRenderer"
@@ -33,6 +34,13 @@ import { bundleFlags, bundleOptions } from "./shared"
33
34
  * A failing step stops its flow and dumps the failing frame (JSON + PNG when @napi-rs/canvas is
34
35
  * installed) under tests/.artifacts/<flow>/ so a red test explains itself without a rerun.
35
36
  *
37
+ * A file case may run several clients (`"clients": { "ann": {}, "bob": { "height": 640 } }` in its
38
+ * envelope): the app started once per client, each a headless session of its own in this process (its
39
+ * own storage, clock reader, fetch and socket), all against the case's backend; a step names its clients
40
+ * and the case keeps one clock (runCase in lecodes-headless — the backend's clock follows it over the dev
41
+ * backend's IPC, `setClock`). A case with only `call` / `clock` steps and no `clients` starts no app: a
42
+ * server test. Artifacts of a multi-client case go per client: tests/.artifacts/<flow>/<client>/.
43
+ *
36
44
  * `--desktop` runs the file scenarios through the NATIVE host (desktopScript.ts — targets by name or
37
45
  * coordinates, expect.log + expect.paint checked, the semantic expects reported skipped). The
38
46
  * design-derived flows need `expect.screen` and `fill`, which the host can't answer, so they stay
@@ -46,7 +54,7 @@ import { bundleFlags, bundleOptions } from "./shared"
46
54
  type DesignEdge = { from: string, to: string, label?: string, activator?: string }
47
55
  type DesignMeta = { screens?: Record<string, unknown>, edges?: DesignEdge[] }
48
56
 
49
- type FlowCase = {
57
+ export type FlowCase = {
50
58
  name: string
51
59
  kind: "file" | "flow"
52
60
  steps: unknown
@@ -59,6 +67,37 @@ type FlowCase = {
59
67
  settle?: number
60
68
  device?: string
61
69
  safeArea?: string
70
+ /** Several clients: name → its own viewport / device (file scenarios only). */
71
+ clients?: Record<string, ClientEnvelope>
72
+ /** How long an expect waits by default in this case, ms. */
73
+ timeout?: number
74
+ /** The `${name}`s the run must give (`"vars": ["code"]`): a flow for `lecodes dev --bots … --var code=4821`,
75
+ * played beside a human — `lecodes test` does not run it. */
76
+ vars?: string[]
77
+ }
78
+
79
+ /** What a client of a multi-client case may set for itself (else the case's own). */
80
+ export type ClientEnvelope = { width?: number, height?: number, device?: string, safeArea?: string }
81
+ const CLIENT_KEYS = new Set(["width", "height", "device", "safeArea"])
82
+
83
+ /** The step kinds of the headless case runner (runCase) the native and browser runners don't take yet. */
84
+ const CASE_STEP_KEYS = ["call", "clock", "save", "offline", "restart", "close"]
85
+
86
+ /** A case with no `clients` whose steps are all `call` / `clock` (or `wait`): a server test — no app is started. */
87
+ export const isServerCase = (kase: FlowCase): boolean =>
88
+ kase.kind === "file" && (kase.clients ? Object.keys(kase.clients).length === 0
89
+ : Array.isArray(kase.steps) && kase.steps.length > 0 && kase.steps.every((s) => typeof s === "object" && s !== null && ((s as Record<string, unknown>).call !== undefined || (s as Record<string, unknown>).clock !== undefined)))
90
+
91
+ /** The envelope's `clients`: names (they are folder names too) → per-client overrides. */
92
+ const readClients = (raw: unknown, displayName: string): Record<string, ClientEnvelope> | undefined => {
93
+ if (raw === undefined) return undefined
94
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new CliError(`"${displayName}": "clients" maps names to their settings: { "ann": {}, "bob": { "height": 640 } }.`)
95
+ for (const [name, o] of Object.entries(raw)) {
96
+ if (!/^[A-Za-z0-9_-]+$/.test(name)) throw new CliError(`"${displayName}": client name "${name}" — letters, digits, _ and - only (it names the client's artifact folder).`)
97
+ if (typeof o !== "object" || o === null || Array.isArray(o)) throw new CliError(`"${displayName}": clients.${name} must be an object ({} for the case's own settings).`)
98
+ for (const k of Object.keys(o)) if (!CLIENT_KEYS.has(k)) throw new CliError(`"${displayName}": clients.${name}.${k} — a client sets ${[...CLIENT_KEYS].join(", ")}.`)
99
+ }
100
+ return raw as Record<string, ClientEnvelope>
62
101
  }
63
102
 
64
103
  type SkippedFlow = { name: string, reason: string }
@@ -124,10 +163,10 @@ export const deriveDesignFlows = (meta: DesignMeta, entryScreen: string): { flow
124
163
  return { flows, skipped }
125
164
  }
126
165
 
127
- const slug = (s: string): string => s.replace(/[^a-zA-Z0-9-_]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "case"
166
+ export const slug = (s: string): string => s.replace(/[^a-zA-Z0-9-_]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "case"
128
167
 
129
168
  /** Read one tests/*.flow.json (envelope + steps, or a bare step array). */
130
- const readFlowFile = (abs: string, displayName: string): FlowCase => {
169
+ export const readFlowFile = (abs: string, displayName: string): FlowCase => {
131
170
  let raw: unknown
132
171
  try {
133
172
  raw = JSON.parse(readFileSync(abs, "utf8"))
@@ -138,11 +177,15 @@ const readFlowFile = (abs: string, displayName: string): FlowCase => {
138
177
  if (typeof raw !== "object" || raw === null || (raw as { steps?: unknown }).steps === undefined) {
139
178
  throw new CliError(`"${displayName}" must be a JSON object with "steps" (or a bare step array).`)
140
179
  }
141
- const file = raw as { name?: string, width?: number, height?: number, settle?: number, device?: string, safeArea?: string, fixtures?: Record<string, unknown> | string, steps: unknown }
180
+ const file = raw as { name?: string, width?: number, height?: number, settle?: number, device?: string, safeArea?: string, fixtures?: Record<string, unknown> | string, clients?: unknown, timeout?: unknown, vars?: unknown, steps: unknown }
181
+ if (file.timeout !== undefined && (typeof file.timeout !== "number" || file.timeout < 0)) throw new CliError(`"${displayName}": "timeout" is how long an expect waits, ms.`)
182
+ if (file.vars !== undefined && !(Array.isArray(file.vars) && file.vars.every((v) => typeof v === "string" && v !== ""))) throw new CliError(`"${displayName}": "vars" lists the names a run gives (--var name=value): ["code"].`)
142
183
  return {
143
184
  name: file.name ?? displayName, kind: "file", steps: resolvePicks(file.steps, dirname(abs)),
144
185
  fixtures: resolveFixtures(file.fixtures, dirname(abs)),
145
186
  width: file.width, height: file.height, settle: file.settle, device: file.device, safeArea: file.safeArea,
187
+ clients: readClients(file.clients, displayName), timeout: file.timeout as number | undefined,
188
+ vars: file.vars as string[] | undefined,
146
189
  }
147
190
  }
148
191
 
@@ -171,7 +214,7 @@ export default defineCommand({
171
214
  name: "test",
172
215
  summary: "Run the flow tests (tests/*.flow.json + design-derived flows)",
173
216
  usage: "[name…]",
174
- 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>/. A project with *.server.ts files gets its backend run for the tests, on a database of its own (.lecodes/test-db) that is emptied before every case; a step waits for the calls it started.\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).",
217
+ 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>/. A project with *.server.ts files gets its backend run for the tests, on a database of its own (.lecodes/test-db) that is emptied before every case; a step waits for the calls it started.\n\nA flow file may run several clients against that backend (\"clients\": { \"ann\": {}, \"bob\": {} } in its envelope; every step then names its \"client\" / \"clients\"), call server functions directly (\"call\" + \"as\"), keep values for later steps (\"save\", then ${name}), jump the case's one clock — the apps' and the server's (\"clock\": \"+92s\"), take a client offline, restart or close it (\"offline\", \"restart\", \"close\"). A file whose envelope lists \"vars\" (the ${name}s a run gives: a flow for `lecodes dev --bots … --var`) is not run. An expect waits until it holds (\"timeout\", default 5000 ms). A file of only call / clock steps starts no app: a server test. These run headless only.\n\nThe backend of a test is always its own, never a running `lecodes dev`'s: a test does not see or change the data the developer plays with (the other commands talk to a running dev — `lecodes dev --bots` runs a flow there).\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).",
175
218
  flags: testFlags,
176
219
  examples: ["lecodes test", "lecodes test hero --logs", "lecodes test --flows --json", "lecodes test --desktop"],
177
220
  run: ({ args, flags }) => runTests(args, flags),
@@ -196,7 +239,7 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
196
239
  // The project's backend, when it has one: on a database of its own, emptied before every case.
197
240
  // (a browser run starts its own, in its session)
198
241
  const backend = lite ? null : runBackend({ root: desktopRoot, command: "lecodes test", test: true, log: (msg) => { if (flags.logs || msg.includes("[server:error]")) logErr(msg) } })
199
- const native = desktop ? await buildBundle(bundleOptions(flags), { localAssets: true, desktopRenderer, backend: backend! }) : null
242
+ const native = desktop ? await buildBundle(bundleOptions(flags), { localAssets: true, desktopRenderer, backend: backend!, ambiguousEntry: "error" }) : null
200
243
  const headless = desktop || lite ? null : await compileHeadlessBundle({ entry: flags.entry, publicUrl: flags["public-url"], remoteAssets: flags["remote-assets"], waitNetwork: flags["wait-network"], backend: backend! })
201
244
  const warnings = headless?.warnings ?? []
202
245
  const useLocalAssets = headless?.useLocalAssets ?? false
@@ -275,6 +318,12 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
275
318
  throw new CliError(`--flows needs a design graph (${designMetaPath} not found).`)
276
319
  }
277
320
 
321
+ // a flow that takes values from its run (`"vars"`) is played beside a human (lecodes dev --bots), not a test
322
+ for (const k of cases.filter((k) => k.kind === "file" && k.vars?.length)) {
323
+ note(`${k.name}: a flow for lecodes dev --bots (it takes --var ${k.vars!.join(", ")}) — not run by lecodes test.`)
324
+ }
325
+ for (let i = cases.length - 1; i >= 0; i--) if (cases[i].kind === "file" && cases[i].vars?.length) cases.splice(i, 1)
326
+
278
327
  if (cases.length === 0) {
279
328
  for (const w of warnings) warnErr(w)
280
329
  throw new CliError(`No tests found. Write tests/*.flow.json scenarios, or draw activator edges in the design board (design/meta.json).`)
@@ -282,7 +331,9 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
282
331
 
283
332
  // Hard timeout across the whole run (each case is bounded by settles, but a pathological app can
284
333
  // still spin) — generous per-case allowance, overridable.
285
- const timeoutMs = flags.timeout || (desktop ? 60000 + cases.length * 60000 : 20000 + cases.length * 10000)
334
+ // (an expect waits in real time, on every client of its case: a case's share grows with both)
335
+ const caseShare = (k: FlowCase) => 10000 + (Array.isArray(k.steps) ? k.steps.length : 0) * 1000 * Math.max(1, Object.keys(k.clients ?? {}).length)
336
+ const timeoutMs = flags.timeout || (desktop ? 60000 + cases.length * 60000 : 20000 + cases.reduce((n, k) => n + caseShare(k), 0))
286
337
  const killer = setTimeout(() => {
287
338
  warnErr(`Test run timed out after ${timeoutMs}ms (raise --timeout or lower --settle).`)
288
339
  process.exit(1)
@@ -382,6 +433,14 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
382
433
  let result: CaseResult
383
434
  // every case starts on an empty database, like it starts in a fresh host
384
435
  if (backend && results.length > 0) await backend.reset()
436
+ const caseOnly = (desktop || lite) && Array.isArray(kase.steps) ? CASE_STEP_KEYS.find((k) => (kase.steps as Record<string, unknown>[]).some((st) => st !== null && typeof st === "object" && k in st)) : undefined
437
+ if ((desktop || lite) && (kase.clients || isServerCase(kase) || caseOnly)) {
438
+ const what = kase.clients ? "several clients" : isServerCase(kase) ? "a server test" : `"${caseOnly}" steps`
439
+ result = { name: kase.name, kind: kase.kind, ok: false, steps: [], error: `${what} — headless only for now: run it with \`lecodes test\` (no --desktop / --lite / --web)`, artifacts: [] }
440
+ results.push(result)
441
+ reportCase(result)
442
+ continue
443
+ }
385
444
  if (lite) {
386
445
  try {
387
446
  result = await runLiteCase(kase)
@@ -404,30 +463,37 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
404
463
  continue
405
464
  }
406
465
  try {
407
- const steps = renderer!.parseScenarioSteps(kase.steps)
408
- // Every case runs in a FRESH session — no state bleeds between flows.
409
- const session = await renderer!.createSession(js, {
410
- width: flags.width || kase.width || projectViewport?.width || 390,
411
- height: flags.height || kase.height || projectViewport?.height || 844,
466
+ const server = isServerCase(kase)
467
+ const names = kase.clients ? Object.keys(kase.clients) : undefined
468
+ const steps = renderer!.parseScenarioSteps(kase.steps, server ? { server: true } : { clients: names })
469
+ // Every case runs in FRESH sessions — no state bleeds between flows. Several clients: one each,
470
+ // with what the envelope says of each; the app's console is told under the client's name.
471
+ const sessionOptions = (name: string | undefined, own: ClientEnvelope = {}) => ({
472
+ width: flags.width || own.width || kase.width || projectViewport?.width || 390,
473
+ height: flags.height || own.height || kase.height || projectViewport?.height || 844,
412
474
  settleMs: flags.settle ?? kase.settle,
413
475
  waitNetwork: flags["wait-network"],
414
476
  backend: headless!.serverUrl,
415
477
  localAssets: useLocalAssets,
416
478
  fixtures: kase.fixtures,
417
- ...resolveSafeAreaChoice(flags["safe-area"] ?? kase.safeArea, flags.device ?? kase.device),
479
+ ...resolveSafeAreaChoice(flags["safe-area"] ?? own.safeArea ?? kase.safeArea, flags.device ?? own.device ?? kase.device),
418
480
  nativeViews: parseNativeViews(flags["native-view"]),
419
- onConsole,
481
+ onConsole: onConsole && name ? (level: string, text: string) => onConsole(level, `${c.dim(`[${name}]`)} ${text}`) : onConsole,
420
482
  })
421
- try {
422
- const io = fileScenarioIO(resolve(artifactsRoot, slug(kase.name)))
423
- const outcome = await renderer!.runScenario(session, steps, io)
424
- result = {
425
- name: kase.name, kind: kase.kind, ok: outcome.ok,
426
- steps: outcome.steps.map((s) => ({ desc: s.desc, ok: s.ok, error: s.error })),
427
- artifacts: outcome.failureArtifacts.map((p) => resolve(artifactsRoot, slug(kase.name), p)),
428
- }
429
- } finally {
430
- session.dispose()
483
+ const clients = server ? []
484
+ : kase.clients ? Object.entries(kase.clients).map(([name, own]) => ({ name, start: (env: import("lecodes-headless/headless").ClientStart) => renderer!.createSession(js, { ...sessionOptions(name, own), ...env }) }))
485
+ : [{ name: "app", start: (env: import("lecodes-headless/headless").ClientStart) => renderer!.createSession(js, { ...sessionOptions(undefined), ...env }) }]
486
+ const serverUrl = headless!.serverUrl
487
+ const io = fileScenarioIO(resolve(artifactsRoot, slug(kase.name)))
488
+ const outcome = await renderer!.runCase({
489
+ clients, single: !kase.clients, steps, io, timeoutMs: kase.timeout,
490
+ // the case's backend: `call` steps reach it as a device would, and its clock is the case's
491
+ backend: serverUrl && backend ? { url: serverUrl, call: (id, args, device) => callEndpoint(serverUrl, id, args, device), setClock: (ms) => backend.setClock(ms) } : undefined,
492
+ })
493
+ result = {
494
+ name: kase.name, kind: kase.kind, ok: outcome.ok,
495
+ steps: outcome.steps.map((s) => ({ desc: s.desc, ok: s.ok, error: s.error })),
496
+ artifacts: outcome.failureArtifacts.map((p) => resolve(artifactsRoot, slug(kase.name), p)),
431
497
  }
432
498
  } catch (e) {
433
499
  // A malformed scenario / session crash is a failed case, not a dead run.
@@ -10,6 +10,7 @@ import { startPreview } from "../web/preview"
10
10
  import { bundleFlags } from "./shared"
11
11
  import { lanCandidates, loadDevToken } from "./dev"
12
12
  import { devCertificate } from "../dev/https"
13
+ import { findRunningDev, recordDevPort } from "../dev/devLink"
13
14
 
14
15
  /*
15
16
  * `lecodes web` — the project as a WEB PAGE (Web Lite: DOM and CSS, the browser's layout, text and
@@ -21,7 +22,9 @@ import { devCertificate } from "../dev/https"
21
22
  * build a folder any static hosting serves (web/build.ts)
22
23
  * preview serve a built folder, as a hosting would
23
24
  *
24
- * Fully local: no login, no le.codes backend.
25
+ * Fully local: no login, no le.codes backend. The project's backend is the one of a running
26
+ * `lecodes dev` of it when there is one (dev/devLink.ts: the page's /server routes are forwarded there);
27
+ * else `web dev` runs it, and is the one the other commands find.
25
28
  */
26
29
 
27
30
  const DEFAULT_PORT = 4488
@@ -51,7 +54,7 @@ const kb = (bytes: number): string => bytes < 1024 ? `${bytes} B` : bytes < 1024
51
54
  const dev = defineCommand({
52
55
  name: "dev",
53
56
  summary: "Open the project as a web page, reloaded on every save",
54
- description: "Serves the project's bundle and assets with the page of Web Lite around them; a save recompiles and runs the app again, the page's console output and errors stream back here. The address's hash is the route the app is opened with (#/product/42 → app.launchUrl). A web half of the project's own (app.json `\"ar\": { \"web\": \"./plugins/ar-8thwall\" }`) is built with every compile and served beside the page, its `web.static` folder from the project.\n\n`--https`: the page over https, for a phone's camera and motion sensors (a browser gives them to a secure page only) — a self-signed certificate for localhost and this machine's LAN addresses, made once and kept in ~/.lecodes/dev-https/. The phone's browser warns about it: accept it once.",
57
+ description: "Serves the project's bundle and assets with the page of Web Lite around them; a save recompiles and runs the app again, the page's console output and errors stream back here. The project's backend is a running `lecodes dev`'s when there is one (its database and data; the page's calls are forwarded there), else it runs here. The address's hash is the route the app is opened with (#/product/42 → app.launchUrl). A web half of the project's own (app.json `\"ar\": { \"web\": \"./plugins/ar-8thwall\" }`) is built with every compile and served beside the page, its `web.static` folder from the project.\n\n`--https`: the page over https, for a phone's camera and motion sensors (a browser gives them to a secure page only) — a self-signed certificate for localhost and this machine's LAN addresses, made once and kept in ~/.lecodes/dev-https/. The phone's browser warns about it: accept it once.",
55
58
  flags: {
56
59
  port: num("server port", { default: DEFAULT_PORT }),
57
60
  entry: bundleFlags.entry,
@@ -70,17 +73,23 @@ const dev = defineCommand({
70
73
  const scheme = flags.https ? "https" : "http"
71
74
  const origin = `${scheme}://localhost:${port}`
72
75
  const tls = flags.https ? await devCertificate(lans) : undefined
76
+ const token = loadDevToken(root)
77
+ // one backend per project on this machine: a running `lecodes dev` of it runs it — this page's calls go there
78
+ const owner = await findRunningDev(root)
73
79
  const server = await startDevServer({
74
- root, manifest, name, origin, port,
75
- token: loadDevToken(root),
80
+ root, manifest, name, origin, port, token,
76
81
  entryOverride: flags.entry,
77
82
  lite,
78
83
  tls,
79
84
  log: note,
85
+ backendUrl: owner?.url,
80
86
  }).catch((e: unknown) => {
81
87
  const msg = e instanceof Error ? e.message : String(e)
82
88
  throw new CliError(msg.includes("EADDRINUSE") ? `Port ${port} is already in use — pass --port <n>.` : msg)
83
89
  })
90
+ if (owner) note(`The project's backend: the running lecodes dev's (port ${owner.port}) — its database, its data.`)
91
+ // the backend runs here: the other commands find it (not over https: they speak plain http to this machine)
92
+ else if (!tls) recordDevPort(root, server.port)
84
93
 
85
94
  note("Compiling…")
86
95
  const first = await server.recompile()
@@ -4,7 +4,7 @@ import { loadConfig, requireApiUrl, requireToken } from "../platform/config"
4
4
  import { findProjectRoot, readManifest, type Manifest } from "../project/manifest"
5
5
  import { collectEntries } from "./collect"
6
6
  import { collectLocalEntries } from "./collectLocal"
7
- import { ambiguousEntryWarning, compileProject } from "./projectCompile"
7
+ import { compileProject, requireUnambiguousEntry } from "./projectCompile"
8
8
  import { applyProjectFonts } from "./fonts"
9
9
  import { applyAppAssetIcons } from "./assetIcons"
10
10
  import type { ScreenTarget } from "./screenEntry"
@@ -112,12 +112,11 @@ export const compileHeadlessBundle = async (opts: HeadlessOptions = {}): Promise
112
112
  // headless renders stay deterministic and offline after the first fetch (like local assets).
113
113
  await applyAppAssetIcons(compileEntries)
114
114
 
115
- note("Compiling…")
115
+ // A guessed entry fails here, before the compile: render / test would otherwise run the wrong file
116
+ // and report only its symptoms ("Nothing was rendered", "got an unnamed screen").
116
117
  const entryFlag = opts.screen?.entryPath ?? opts.entry
117
- if (!entryFlag) {
118
- const ambiguous = ambiguousEntryWarning(compileEntries)
119
- if (ambiguous) warnings.push(ambiguous)
120
- }
118
+ requireUnambiguousEntry(compileEntries, entryFlag)
119
+ note("Compiling…")
121
120
  const serverUrl = await opts.backend?.start(compileEntries, (w) => warnings.push(w))
122
121
  const js = await compileProject({
123
122
  entries: compileEntries,
@@ -5,6 +5,7 @@ import type { FileKind } from "../project/manifest"
5
5
  import { distRoot } from "../hosts/distRoot"
6
6
  import { installPeers } from "../hosts/peerInstall"
7
7
  import { peerDirInPrefix } from "../hosts/peers"
8
+ import { CliError } from "../cli"
8
9
  import {
9
10
  compileProjectWithMap as sdkCompileProjectWithMap, compileServerBundle, detectEntry, inlineSourceMapComment, isServerPath, scanModulesChisel,
10
11
  localAssetNames, ENTRY_CONVENTION, findEnvLeaks, EnvLeakError, type BundleBake, type CompileServerOptions,
@@ -101,15 +102,44 @@ export type CompileOptions = {
101
102
  env?: Record<string, string>
102
103
  }
103
104
 
104
- /** A warning when auto-detection has several import-graph roots and no main.ts to settle it —
105
- * the pick still works, but the developer should make the entry explicit. Null when unambiguous.
106
- * Callers skip this when an explicit --entry / entryOverride is in play. */
107
- export const ambiguousEntryWarning = (entries: { path: string, type?: string, text?: string }[]): string | null => {
105
+ type EntryFiles = { path: string, type?: string, text?: string }[]
106
+
107
+ /** An auto-detected entry that is a guess: several import-graph roots and no main.ts among them to
108
+ * settle it, so the pick goes by size. Null when the detection is not a guess. */
109
+ const guessedEntry = (entries: EntryFiles): { picked: string, roots: string[] } | null => {
108
110
  const { entry, roots } = detectEntry(entries, scanModulesChisel)
109
111
  if (!entry || roots.length < 2 || roots.includes(ENTRY_CONVENTION)) return null
110
- return `Ambiguous entrypoint: nothing imports ${roots.join(", ")} — picked ${entry}. Rename your entry to main.ts (or pass --entry).`
112
+ return { picked: entry, roots }
113
+ }
114
+
115
+ /** `compile` / `dev` / `web`: a guessed entry is a warning — the pick still works, but the developer
116
+ * should make the entry explicit. Null when unambiguous. Callers skip this when an explicit
117
+ * --entry / entryOverride is in play. */
118
+ export const ambiguousEntryWarning = (entries: EntryFiles): string | null => {
119
+ const guess = guessedEntry(entries)
120
+ if (!guess) return null
121
+ return `Ambiguous entrypoint: nothing imports ${guess.roots.join(", ")} — picked ${guess.picked}. Rename your entry to main.ts (or pass --entry).`
111
122
  }
112
123
 
124
+ /** `render` / `test`: a guessed entry is an error, thrown before the compile. A stray unimported file
125
+ * picked as the app otherwise surfaces as "Nothing was rendered" / every case "got an unnamed
126
+ * screen", with the cause printed last. An explicit entry (`--entry`, a screen's generated entry)
127
+ * settles it: the detection is not consulted. */
128
+ export const requireUnambiguousEntry = (entries: EntryFiles, explicitEntry: string | undefined): void => {
129
+ if (explicitEntry) return
130
+ const guess = guessedEntry(entries)
131
+ if (!guess) return
132
+ throw new CliError(
133
+ `Ambiguous entrypoint: nothing imports ${guess.roots.join(", ")}. ` +
134
+ `The guess would be ${guess.picked}. ` +
135
+ `Name the app's entry main.ts, or pass --entry <path>. ` +
136
+ `An unimported helper is an entry candidate: import it, or move it out of the project.`,
137
+ )
138
+ }
139
+
140
+ /** How a builder treats a guessed entry (see the two helpers above). Default `warn`. */
141
+ export type AmbiguousEntryPolicy = "warn" | "error"
142
+
113
143
  /** A file of the compile by its root path (`/assets/lightmap/level.bake`) — how `bake()` reads the manifest it names:
114
144
  * the entries' own files on disk. */
115
145
  const resourceReader = (entries: CompileEntry[]) => {
@@ -58,6 +58,12 @@ declare module "lecodes-headless/headless" {
58
58
  raster?: boolean | "auto"
59
59
  /** Device-pixel scale for png() (default 2). */
60
60
  scale?: number
61
+ /** The app's storage — a map the caller keeps survives the session (a flow's `restart`). */
62
+ storage?: Map<string, string>
63
+ /** What the app's `Date.now()` reads at clock 0 (default: the wall clock at launch). */
64
+ epochMs?: number
65
+ /** Before every step of the virtual clock: the time it steps to (ms since launch). */
66
+ onTime?: (clockMs: number) => void
61
67
  }
62
68
  export type Session = {
63
69
  warnings: string[]
@@ -99,6 +105,12 @@ declare module "lecodes-headless/headless" {
99
105
  }
100
106
  export type ScenarioStep = {
101
107
  name?: string
108
+ client?: string
109
+ clients?: string[] | "*"
110
+ /** How long this step's expect (and target) waits, ms. */
111
+ timeout?: number
112
+ /** End the step's clients (`restart` opens them again). */
113
+ close?: true
102
114
  tap?: SessionTarget
103
115
  type?: string
104
116
  text?: string
@@ -128,9 +140,46 @@ declare module "lecodes-headless/headless" {
128
140
  export type ScenarioIO = { write: (relPath: string, data: string | Uint8Array) => void }
129
141
  export type StepOutcome = { index: number, desc: string, ok: boolean, error?: string }
130
142
  export type ScenarioOutcome = { ok: boolean, steps: StepOutcome[], failureArtifacts: string[] }
131
- export function parseScenarioSteps(raw: unknown): ScenarioStep[]
143
+ /** What a step list is parsed for: a case's clients (absent: one), or a server test (no UI client); the
144
+ * variables a run is given (`vars`), and a case on real time (`realTime`: a `clock` step is an error). */
145
+ export type ScenarioShape = { clients?: string[], server?: boolean, vars?: string[], realTime?: boolean }
146
+ export function parseScenarioSteps(raw: unknown, shape?: ScenarioShape): ScenarioStep[]
132
147
  export function describeStep(step: ScenarioStep): string
148
+ /** `"+92s"` / `"+1500ms"` / `"+2m"` → ms (undefined: not a jump forward). */
149
+ export function parseClockJump(v: unknown): number | undefined
150
+
151
+ // ---- a case: several clients, one clock, a backend (lecodes test) ----
152
+ export type ClientStart = { storage: Map<string, string>, epochMs: number, onTime: (clockMs: number) => void }
153
+ export type CaseClient = { name: string, start: (env: ClientStart) => Promise<Session> }
154
+ export type CallOutcome = { ok: true, result: unknown } | { ok: false, status: number, message: string }
155
+ export type CallDevice = { token: () => string | null, store: (token: string | null) => void }
156
+ export type CaseBackend = {
157
+ url: string
158
+ call?: (id: string, args: unknown[], device: CallDevice) => Promise<CallOutcome>
159
+ setClock?: (offsetMs: number) => void
160
+ }
161
+ export type CaseOptions = {
162
+ clients: CaseClient[]
163
+ single?: boolean
164
+ steps: ScenarioStep[]
165
+ io: ScenarioIO
166
+ backend?: CaseBackend
167
+ timeoutMs?: number
168
+ artifactPrefix?: string
169
+ keepOpen?: boolean
170
+ /** The case runs on the machine's clock (a backend other devices use): clients follow real time, `wait` waits, `clock` fails, `setClock` is never called. */
171
+ realTime?: boolean
172
+ /** A failing step does not end the case. */
173
+ keepGoing?: boolean
174
+ /** `${name}` values known from the start. */
175
+ vars?: Record<string, unknown>
176
+ onStepStart?: (index: number, desc: string) => void
177
+ onStep?: (outcome: StepOutcome) => void
178
+ /** (realTime) After the steps (their outcome, the clients still open): those stay, on real time, until what this returns settles. */
179
+ linger?: (outcome: ScenarioOutcome, open: string[]) => Promise<unknown>
180
+ }
181
+ export function runCase(opts: CaseOptions): Promise<ScenarioOutcome>
133
182
  export function runScenario(
134
- session: Session, steps: ScenarioStep[], io: ScenarioIO, opts?: { artifactPrefix?: string },
183
+ session: Session, steps: ScenarioStep[], io: ScenarioIO, opts?: { artifactPrefix?: string, backend?: CaseBackend, timeoutMs?: number },
135
184
  ): Promise<ScenarioOutcome>
136
185
  }
@@ -0,0 +1,44 @@
1
+ import { firstCallGate, type FirstCallGate } from "lecodes-sdk/server"
2
+
3
+ /*
4
+ * An endpoint call made from the CLI as one device would make it — a flow's `call` step (`lecodes test`)
5
+ * and `lecodes call`. The wire is the client transport's (sdk/src/runtime/rpc.ts): `POST <url>/api/<id>`
6
+ * with `{"args":[…]}` and the device's token as the bearer, the envelope always HTTP 200; a `session` in
7
+ * the envelope is the device's token from now on. And its first-call rule (sdk/src/runtime/firstCall.ts):
8
+ * a device with no token sends its first call alone, so a backend that mints one on first contact
9
+ * (`request.session`, a guest session) mints ONE for the device however many calls it starts at once.
10
+ */
11
+
12
+ /** Who calls: the token it holds (null = none yet) and where a new one goes. */
13
+ export type CallDevice = { token: () => string | null, store: (token: string | null) => void }
14
+
15
+ /** What the call answered: the result, or the failure the app would see as an `RpcError`
16
+ * (status 0 = the backend was not reached). */
17
+ export type CallOutcome = { ok: true, result: unknown } | { ok: false, status: number, message: string }
18
+
19
+ const gates = new WeakMap<CallDevice, FirstCallGate>()
20
+
21
+ /** Call endpoint `id` (`<file>#<export>`) of the backend at `url` as `device`. Never throws. */
22
+ export const callEndpoint = (url: string, id: string, args: unknown[], device: CallDevice): Promise<CallOutcome> => {
23
+ let gate = gates.get(device)
24
+ if (!gate) { gate = firstCallGate(); gates.set(device, gate) }
25
+ return gate(() => !!device.token(), () => post(url, id, args, device))
26
+ }
27
+
28
+ const post = async (url: string, id: string, args: unknown[], device: CallDevice): Promise<CallOutcome> => {
29
+ const headers: Record<string, string> = { "content-type": "application/json" }
30
+ const token = device.token()
31
+ if (token) headers.authorization = `Bearer ${token}`
32
+ let res: Response
33
+ try {
34
+ res = await fetch(`${url.replace(/\/+$/, "")}/api/${encodeURIComponent(id)}`, { method: "POST", headers, body: JSON.stringify({ args }) })
35
+ } catch (e) {
36
+ return { ok: false, status: 0, message: `Endpoint ${id}: request failed (${(e as Error)?.message ?? e})` }
37
+ }
38
+ let body: any = null
39
+ try { body = await res.json() } catch { /* not an envelope */ }
40
+ if (body && typeof body === "object" && "session" in body) device.store(body.session ?? null)
41
+ if (body?.ok === true) return { ok: true, result: body.result ?? null }
42
+ if (body?.ok === false) return { ok: false, status: body.status ?? res.status, message: body.message ?? "" }
43
+ return { ok: false, status: res.status, message: `Endpoint ${id}: unexpected response (HTTP ${res.status})` }
44
+ }