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.
- package/dist/index.js +20616 -19049
- package/package.json +15 -18
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/VERSION.json +3 -3
- package/runtime/web/player.js +16 -16
- package/runtime/web-lite/lite.js +1 -1
- package/src/commands/call.ts +96 -0
- package/src/commands/compile.ts +8 -4
- package/src/commands/desktop.ts +5 -4
- package/src/commands/dev.ts +42 -3
- package/src/commands/index.ts +2 -0
- package/src/commands/init/templates.ts +67 -3
- package/src/commands/plugin.ts +1 -1
- package/src/commands/render.ts +31 -22
- package/src/commands/test.ts +90 -24
- package/src/commands/web.ts +13 -4
- package/src/compile/headlessBundle.ts +5 -6
- package/src/compile/projectCompile.ts +35 -5
- package/src/declarations/lecodes-headless.d.ts +51 -2
- package/src/dev/backendCall.ts +44 -0
- package/src/dev/backendWorker.ts +60 -7
- package/src/dev/bots.ts +133 -0
- package/src/dev/devLink.ts +136 -0
- package/src/dev/devServer.ts +19 -6
- package/src/dev/localBackend.ts +57 -9
- package/src/dev/projectBackend.ts +227 -49
- package/src/hosts/liteRunner.ts +1 -0
- package/src/plugin/generate.ts +5 -3
package/src/commands/render.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
305
|
-
|
|
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
|
|
373
|
-
//
|
|
374
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
|
package/src/commands/test.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
408
|
-
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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.
|
package/src/commands/web.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|