lecodes-cli 2.0.4 → 2.0.5
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 +2521 -860
- package/package.json +16 -11
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/VERSION.json +3 -3
- package/runtime/web/assets/creator-2d-CYlV77UR.js +1 -0
- package/runtime/web/assets/creator-2d-nG7rGEmq.wasm +0 -0
- package/runtime/web/assets/{creator-full-D2dSDdDj.wasm → creator-full-Cla1R8N6.wasm} +0 -0
- package/runtime/web/assets/creator-full-DmfoULcB.js +1 -0
- package/runtime/web/assets/creator-full.js-D8SMHuCt.symbols +8419 -0
- package/runtime/web/assets/creator-ui-CbtoaRlz.js +1 -0
- package/runtime/web/assets/creator-ui-DZaoxIpX.wasm +0 -0
- package/runtime/web/player.js +18 -18
- package/runtime/web-lite/assets/__vite-browser-external-BIHI7g3E.js +1 -0
- package/runtime/web-lite/assets/ar-BOdQ-wV4.js +1 -0
- package/runtime/web-lite/assets/basis-C64VHDVD.js +1 -0
- package/runtime/web-lite/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
- package/runtime/web-lite/assets/{canvas-C6G_Pb2Q.js → canvas-WxiusXCB.js} +1 -1
- package/runtime/web-lite/assets/creatorAnim-BOwKr-C1.js +1 -0
- package/runtime/web-lite/assets/creatorParticles-DYlv4g1J.js +1 -0
- package/runtime/web-lite/assets/creatorPhysics-k7wL0kV1.js +1 -0
- package/runtime/web-lite/assets/draco-BiISTFcR.js +118 -0
- package/runtime/web-lite/assets/draco_decoder-DsQ12WqX.wasm +0 -0
- package/runtime/web-lite/assets/index-3CbsYWmo.js +2 -0
- package/runtime/web-lite/assets/neutral_ibl128-D-lzdkLN.ktx +0 -0
- package/runtime/web-lite/assets/{pager-O712dZv9.js → pager-Db5BY7FB.js} +1 -1
- package/runtime/web-lite/assets/plugins-CuEPHbHL.js +1 -0
- package/runtime/web-lite/assets/scene3d-CX7MAqmN.js +866 -0
- package/runtime/web-lite/assets/tween-BnWYhJ7P.js +1 -0
- package/runtime/web-lite/assets/video-BLj61nDx.js +1 -0
- package/runtime/web-lite/assets/{vlist-gEB8Sn0S.js → vlist-DrV2Q7P5.js} +1 -1
- package/runtime/web-lite/assets/worker-Caf-yYEI.js +2 -0
- package/runtime/web-lite/features.json +87 -4
- package/runtime/web-lite/lite.js +39 -10
- package/runtime/web-lite/plugins/camera/index-5XOM8eHl.js +1 -0
- package/runtime/web-lite/plugins/geolocation/index-BjTKN9_P.js +1 -0
- package/runtime/web-lite/plugins/map/_virtual_lecodes-half-CQBO044A.js +1 -0
- package/runtime/web-lite/plugins/map/_virtual_lecodes-half-resources-BYwqQkWQ.js +1 -0
- package/runtime/web-lite/plugins/map/assets/maplibre-gl-worker-DeQFNGa4.js +8 -0
- package/runtime/web-lite/plugins/map/index-BTBHQ264.js +1 -0
- package/runtime/web-lite/plugins/map/mapImpl-C6E1SwCS.js +823 -0
- package/runtime/web-lite/plugins/qr-scanner/index-CK_cYoJR.js +1 -0
- package/runtime/web-lite/plugins.json +8 -0
- package/src/commands/compile.ts +7 -1
- package/src/commands/desktop.ts +7 -4
- package/src/commands/dev.ts +4 -14
- package/src/commands/render.ts +6 -2
- package/src/commands/test.ts +25 -9
- package/src/compile/projectCompile.ts +47 -1
- package/src/dev/androidDev.ts +1 -1
- package/src/dev/backendWorker.ts +138 -0
- package/src/dev/devServer.ts +119 -115
- package/src/dev/localBackend.ts +214 -0
- package/src/dev/projectBackend.ts +199 -0
- package/src/dev/serve.ts +164 -0
- package/src/hosts/cmgenTool.ts +6 -9
- package/src/hosts/desktopRenderer.ts +6 -5
- package/src/hosts/liteRunner.ts +52 -9
- package/src/hosts/matcTool.ts +5 -22
- package/src/hosts/peers.ts +1 -0
- package/src/hosts/releases.ts +6 -0
- package/src/index.ts +5 -1
- package/src/plugin/generate.ts +10 -6
- package/src/web/build.ts +20 -3
- package/runtime/web/assets/creator-2d-D9WKYM6h.js +0 -1
- package/runtime/web/assets/creator-2d-ukvEmNEp.wasm +0 -0
- package/runtime/web/assets/creator-full-BBXzQlhq.js +0 -1
- package/runtime/web/assets/creator-full.js-BYswGsqR.symbols +0 -8124
- package/runtime/web/assets/creator-ui-DIc92ion.wasm +0 -0
- package/runtime/web/assets/creator-ui-_1Z_AX2M.js +0 -1
- package/runtime/web-lite/assets/tween-t_XDy3Nd.js +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const h={present:e=>e!=null,f64:e=>typeof e=="number"&&Number.isFinite(e)?e:void 0,i32:e=>typeof e=="number"&&Number.isInteger(e)&&e>=-2147483648&&e<=2147483647?e:void 0,bool:e=>typeof e=="boolean"?e:void 0,string:e=>typeof e=="string"?e:void 0,json:e=>e,bytes:e=>Object.prototype.toString.call(e)==="[object Uint8Array]"?e.slice():void 0,object:e=>typeof e=="object"&&e!==null&&!Array.isArray(e)?e:void 0,array:(e,r)=>Array.isArray(e)&&(r===void 0||e.length===r)?e:void 0,list:(e,r)=>{if(!Array.isArray(e))return;const n=[];for(const c of e){const o=r(c);if(o===void 0)return;n.push(o)}return n},map:(e,r)=>{if(typeof e!="object"||e===null||Array.isArray(e))return;const n={};for(const[c,o]of Object.entries(e)){const s=r(o);if(s===void 0)return;n[c]=s}return n}},j=e=>!h.present(e)||h.object(e)?{}:void 0,v={name:"qrScanner",version:1},A=(e,r)=>{e.registerView(v.name,(n,c,o)=>{const s={scan:i=>c.emit("scan",i)},a=r(j(n)??{},s,o),t=async(i,p)=>{throw new Error(`Unknown method: ${i}`)};return{el:a.el,call:t,destroy:()=>{var i;return(i=a.destroy)==null?void 0:i.call(a)}}},v.version)},S=150,k=10,T=e=>{var n;const r=globalThis.BarcodeDetector;r&&(typeof navigator>"u"||typeof((n=navigator.mediaDevices)==null?void 0:n.getUserMedia)!="function"||A(e,(c,o,s)=>{const a=s.createElement("div");a.style.cssText="background:#000;overflow:hidden";const t=s.createElement("video");t.autoplay=!0,t.muted=!0,t.playsInline=!0,t.setAttribute("playsinline",""),t.style.cssText="position:absolute;inset:0;width:100%;height:100%;object-fit:cover",a.appendChild(t);const i=new r({formats:["qr_code"]}),p=s.defaultView??globalThis;let u=null,b=null,f=!1,m=!1,y=null,g=0;const w=async()=>{var l;if(!(m||f||t.videoWidth===0)){m=!0;try{const d=((l=(await i.detect(t))[0])==null?void 0:l.rawValue)??null;if(f)return;d!==null?(g=0,d!==y&&(y=d,o.scan({data:d}))):y!==null&&++g>=k&&(y=null,o.scan({data:null}))}catch{}finally{m=!1}}};return navigator.mediaDevices.getUserMedia({video:{facingMode:"environment"},audio:!1}).then(l=>{if(f){for(const d of l.getTracks())d.stop();return}u=l,t.srcObject=l,t.play().catch(()=>{}),b=p.setInterval(()=>{w()},S)},()=>{}),{el:a,destroy(){if(f=!0,b!==null&&p.clearInterval(b),u)for(const l of u.getTracks())l.stop();u=null,t.srcObject=null}}}))};export{T as register};
|
package/src/commands/compile.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { buildGlobMatcher } from "../project/ignore"
|
|
|
13
13
|
import { readAppConfigAt } from "./app/shared"
|
|
14
14
|
import { defineCommand, isNullSink, note, warnErr } from "../cli"
|
|
15
15
|
import { bundleFlags, bundleOptions, outFlag, session, type BundleOptions } from "./shared"
|
|
16
|
+
import type { RunBackend } from "../dev/projectBackend"
|
|
16
17
|
|
|
17
18
|
/*
|
|
18
19
|
* `lecodes compile` — the local project as one JS bundle (the server's pipeline), printed to stdout
|
|
@@ -81,6 +82,10 @@ export type BuildBundleOptions = {
|
|
|
81
82
|
entryOverride?: string
|
|
82
83
|
/** Shell mode (`lecodes app sync`) — see above. */
|
|
83
84
|
shell?: ShellCompile
|
|
85
|
+
/** The bundle is about to RUN on this machine: a project with server files gets its backend
|
|
86
|
+
* started here (dev/projectBackend.ts) and that backend's url in its stubs. Without it the stubs
|
|
87
|
+
* of a `*.server.ts` have no server to call. */
|
|
88
|
+
backend?: RunBackend
|
|
84
89
|
}
|
|
85
90
|
|
|
86
91
|
export default defineCommand({
|
|
@@ -213,8 +218,9 @@ export const buildBundle = async (bundle: BundleOptions, opts: BuildBundleOption
|
|
|
213
218
|
const ambiguous = ambiguousEntryWarning(entries)
|
|
214
219
|
if (ambiguous) warnErr(ambiguous)
|
|
215
220
|
}
|
|
221
|
+
const serverUrl = await opts.backend?.start(entries, (w) => warnings.push(w))
|
|
216
222
|
const js = await compileProject({
|
|
217
|
-
entries, name, publicUrl, entryOverride,
|
|
223
|
+
entries, name, publicUrl, entryOverride, serverUrl,
|
|
218
224
|
header: bundle.header ?? true,
|
|
219
225
|
localAssets, fontsHeader, iconsHeader,
|
|
220
226
|
minify: bundle.minify,
|
package/src/commands/desktop.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { basename } from "node:path"
|
|
2
|
-
import { CliError, bool, c, defineCommand, log, note, num, str } from "../cli"
|
|
2
|
+
import { CliError, bool, c, defineCommand, log, logErr, note, num, str } from "../cli"
|
|
3
3
|
import {
|
|
4
4
|
DESKTOP_RENDERER_VERSION, fetchDesktopManifest, findMacHostExe, installedDesktopVersions, runDesktopApp,
|
|
5
5
|
selectedDesktopVersion, semverNewer, updateDesktop,
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
import { desktopBuild } from "./app/desktop"
|
|
8
8
|
import { desktopPluginLibraries, readAppConfigAt, resolveDesktopRenderer } from "./app/shared"
|
|
9
9
|
import { buildBundle } from "./compile"
|
|
10
|
+
import { runBackend } from "../dev/projectBackend"
|
|
10
11
|
import { bundleFlags, bundleOptions, localProject } from "./shared"
|
|
11
12
|
|
|
12
13
|
/*
|
|
@@ -40,7 +41,7 @@ export const resolveRenderScale = (flag: string | undefined, cfg: number | undef
|
|
|
40
41
|
const run = defineCommand({
|
|
41
42
|
name: "run",
|
|
42
43
|
summary: "Compile the current project and open it in a desktop window",
|
|
43
|
-
description: "Window size / min size / title default from app.json's desktop block; asset() resources load from the working tree. Multiplayer: --server starts the host as a dedicated server, --connect joins one — both only set Net.launch (the app decides).",
|
|
44
|
+
description: "Window size / min size / title default from app.json's desktop block; asset() resources load from the working tree. A project with *.server.ts files gets its backend run on this machine for as long as the window is open (the database of `lecodes dev`: .lecodes/db). Multiplayer: --server starts the host as a dedicated server, --connect joins one — both only set Net.launch (the app decides).",
|
|
44
45
|
flags: {
|
|
45
46
|
width: num("window width (logical px)"),
|
|
46
47
|
height: num("window height (logical px)"),
|
|
@@ -68,7 +69,9 @@ const run = defineCommand({
|
|
|
68
69
|
// ONE renderer decision per run: it picks the host binary AND which .filamat gets staged beside
|
|
69
70
|
// the bundle. Reading the config twice is how a Vulkan exe ends up next to an OpenGL shader.
|
|
70
71
|
const renderer = resolveDesktopRenderer(root, flags.renderer)
|
|
71
|
-
|
|
72
|
+
// The project's backend (its *.server.ts files), when it has one: run here while the window is open.
|
|
73
|
+
const backend = runBackend({ root, command: "lecodes desktop run", log: logErr })
|
|
74
|
+
const { js, resources } = await buildBundle(bundleOptions(flags), { localAssets: true, desktopRenderer: renderer, backend })
|
|
72
75
|
// Native plugins: the project's plugins/ dir + declared plugin libraries, scanned by the host.
|
|
73
76
|
const { dirs: pluginDirs, files: pluginFiles } = desktopPluginLibraries(root)
|
|
74
77
|
if (pluginFiles.length > 0) note(`Native plugins: ${pluginFiles.map((f) => basename(f)).join(", ")}`)
|
|
@@ -101,7 +104,7 @@ const run = defineCommand({
|
|
|
101
104
|
net: flags.server
|
|
102
105
|
? { role: "server", port: flags.port, maxClients: flags["max-clients"] }
|
|
103
106
|
: flags.connect ? { role: "client", address: flags.connect } : undefined,
|
|
104
|
-
})
|
|
107
|
+
}).finally(() => backend.close())
|
|
105
108
|
},
|
|
106
109
|
})
|
|
107
110
|
|
package/src/commands/dev.ts
CHANGED
|
@@ -22,7 +22,8 @@ import { bundleFlags } from "./shared"
|
|
|
22
22
|
* working tree. The device opens the QR URL like any project URL; every save recompiles and
|
|
23
23
|
* hot-swaps the running app (`_creatorApp.run` — fresh context, rollback on error). Device
|
|
24
24
|
* console output and errors stream back to this terminal. Fully local: no login, no le.codes
|
|
25
|
-
* backend
|
|
25
|
+
* backend — a project's own backend (its `*.server.ts` files) runs here too, its database in
|
|
26
|
+
* .lecodes/db (dev/localBackend.ts). Custom shaders compile locally too (matc, downloaded on first use — matcTool.ts);
|
|
26
27
|
* only when matc can't be fetched do they fall back to server-compiled artifacts (push once).
|
|
27
28
|
*
|
|
28
29
|
* `--web` opens the same project in the browser: the dev server also serves, under /web/, the
|
|
@@ -145,16 +146,6 @@ export default defineCommand({
|
|
|
145
146
|
}
|
|
146
147
|
const name = manifest?.name ?? basename(root)
|
|
147
148
|
|
|
148
|
-
// Bun's node:http can't complete a websocket upgrade (writes to the upgraded socket are dropped),
|
|
149
|
-
// so the reload push would silently never reach devices: a phone that scanned a websocket url
|
|
150
|
-
// sat on "Connected — loading the project…" forever. Under Bun every device url — the QR, the
|
|
151
|
-
// printed one, what --ios / --android open — is the POLL form (/dev-poll.js: a path, since the
|
|
152
|
-
// launcher only fetches urls that end in .js). Node is the shipped runtime.
|
|
153
|
-
const underBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined"
|
|
154
|
-
if (underBun) {
|
|
155
|
-
warnErr("Running under Bun: no websocket reload channel here — devices poll instead (the QR points at /dev-poll.js). Node is the shipped runtime.")
|
|
156
|
-
}
|
|
157
|
-
|
|
158
149
|
const port = flags.port || DEFAULT_PORT
|
|
159
150
|
const candidates = lanCandidates()
|
|
160
151
|
const host = flags.host ?? candidates[0]?.address
|
|
@@ -203,15 +194,14 @@ export default defineCommand({
|
|
|
203
194
|
log("")
|
|
204
195
|
log(`${c.bold("LeCodes Dev")} — ${name}`)
|
|
205
196
|
log("")
|
|
206
|
-
|
|
207
|
-
const deviceUrl = underBun ? server.url.replace(/\/dev\.js$/, "/dev-poll.js") : server.url
|
|
197
|
+
const deviceUrl = server.url
|
|
208
198
|
if (!flags["no-qr"]) await printQr(deviceUrl)
|
|
209
199
|
log(`Scan the QR with the LeCodes app, or open: ${c.bold(deviceUrl)}`)
|
|
210
200
|
// The browser runner: localhost is a secure context (camera works there), the LAN address is
|
|
211
201
|
// what a phone's browser can reach.
|
|
212
202
|
const webLocal = server.webUrl ? server.webUrl.replace(`http://${host}:`, "http://localhost:") : null
|
|
213
203
|
if (server.webUrl && webLocal) log(`In the browser: ${c.bold(webLocal)} (LAN: ${server.webUrl})`)
|
|
214
|
-
|
|
204
|
+
note(`Older desktop builds (no websocket bridge): ${server.url}?t=poll`)
|
|
215
205
|
if (alternatives.length > 0) {
|
|
216
206
|
note(`Device can't reach it? This machine also has ${alternatives.map((a) => `${a.address} (${a.iface})`).join(", ")} — retry with --host <ip>.`)
|
|
217
207
|
}
|
package/src/commands/render.ts
CHANGED
|
@@ -4,6 +4,7 @@ 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
8
|
import { desktopDefaultViewport, desktopPluginLibraries, readAppConfigAt, resolveDesktopRenderer } from "./app/shared"
|
|
8
9
|
import { runDesktopRender } from "../hosts/desktopRenderer"
|
|
9
10
|
import { evaluateDesktopRun, translateScenario } from "../hosts/desktopScript"
|
|
@@ -285,8 +286,10 @@ const renderDesktop = async (flags: RenderFlags, file: string | undefined): Prom
|
|
|
285
286
|
const desktopRenderer = resolveDesktopRenderer(root, flags.renderer)
|
|
286
287
|
// Native plugins (project plugins/ dir + declared libraries) — the host scans CREATOR_PLUGIN_DIRS.
|
|
287
288
|
const { dirs: pluginDirs } = desktopPluginLibraries(root)
|
|
289
|
+
// The project's backend, when it has one (the database of `lecodes dev`).
|
|
290
|
+
const backend = runBackend({ root, command: "lecodes render", log: (msg) => { if (logs || msg.includes("[server:error]")) logErr(msg) } })
|
|
288
291
|
const { js, resources } = await buildBundle(bundleOptions(flags), {
|
|
289
|
-
localAssets: true, desktopRenderer,
|
|
292
|
+
localAssets: true, desktopRenderer, backend,
|
|
290
293
|
extraEntries: screen ? [screen.entry] : undefined, entryOverride: screen?.entryPath,
|
|
291
294
|
})
|
|
292
295
|
// The host writes PNGs but never creates folders — make every output dir up front.
|
|
@@ -305,6 +308,7 @@ const renderDesktop = async (flags: RenderFlags, file: string | undefined): Prom
|
|
|
305
308
|
renderer: desktopRenderer, host: sim ? "server" : "desktop", pluginDirs,
|
|
306
309
|
width, height, frames: plan.frameCap, outPng: pngFlag, logs, timeoutMs, script: plan.text, fixedDtMs,
|
|
307
310
|
})
|
|
311
|
+
await backend.close()
|
|
308
312
|
const result = evaluateDesktopRun(plan, output)
|
|
309
313
|
if (!result.loaded) {
|
|
310
314
|
throw new CliError("This desktop host doesn't support scripted input (CREATOR_SCRIPT) — it ran the frames and ignored the steps. Update it: lecodes desktop update (or point LECODES_DESKTOP_EXE at a newer build).")
|
|
@@ -326,7 +330,7 @@ const renderDesktop = async (flags: RenderFlags, file: string | undefined): Prom
|
|
|
326
330
|
await runDesktopRender(js, resources, {
|
|
327
331
|
renderer: desktopRenderer, pluginDirs,
|
|
328
332
|
width, height, frames, outPng: pngFlag, logs, timeoutMs, fixedDtMs, camera, view,
|
|
329
|
-
})
|
|
333
|
+
}).finally(() => backend.close())
|
|
330
334
|
note(`Wrote ${pngFlag} (${width}x${height}, native render${view ? `, ${view.split(";")[0]} view` : camera ? ", custom camera" : ""})`)
|
|
331
335
|
}
|
|
332
336
|
|
package/src/commands/test.ts
CHANGED
|
@@ -4,11 +4,12 @@ import { compileHeadlessBundle } from "../compile/headlessBundle"
|
|
|
4
4
|
import { desktopDefaultViewport, desktopPluginLibraries, resolveDesktopRenderer } from "./app/shared"
|
|
5
5
|
import { desktopRenderFlags, fileScenarioIO, requireRenderer, resolveFixtures, resolveSafeAreaChoice, viewportFlags } from "./render"
|
|
6
6
|
import { buildBundle } from "./compile"
|
|
7
|
+
import { runBackend } from "../dev/projectBackend"
|
|
7
8
|
import { findProjectRoot } from "../project/manifest"
|
|
8
9
|
import { runDesktopRender } from "../hosts/desktopRenderer"
|
|
9
10
|
import { evaluateDesktopRun, translateScenario } from "../hosts/desktopScript"
|
|
10
11
|
import { startLiteSession, type LiteSession } from "../hosts/liteRunner"
|
|
11
|
-
import { resolveLiteRunner } from "../dev/webRunner"
|
|
12
|
+
import { resolveLiteRunner, resolveWebRunner } from "../dev/webRunner"
|
|
12
13
|
import { readManifest, type Manifest } from "../project/manifest"
|
|
13
14
|
import { CliError, bool, c, defineCommand, log, logErr, note, num, str, warnErr, type FlagValues } from "../cli"
|
|
14
15
|
import { bundleFlags, bundleOptions } from "./shared"
|
|
@@ -157,6 +158,7 @@ const testFlags = {
|
|
|
157
158
|
...bundleFlags,
|
|
158
159
|
...desktopRenderFlags,
|
|
159
160
|
lite: bool("run the file scenarios on Web Lite, in a browser (needs Playwright): the same steps, targets and expects; the clock is the browser's, so a step that expects a log line waits for it; design flows stay headless"),
|
|
161
|
+
web: bool("run the file scenarios on the web player, in a browser (needs Playwright): as --lite, on the runtime as wasm; no `fill` step (tap a field and `type`)"),
|
|
160
162
|
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"),
|
|
161
163
|
}
|
|
162
164
|
// (`sim` comes with desktopRenderFlags: the file scenarios through the WINDOWLESS host — no GPU, no
|
|
@@ -168,7 +170,7 @@ export default defineCommand({
|
|
|
168
170
|
name: "test",
|
|
169
171
|
summary: "Run the flow tests (tests/*.flow.json + design-derived flows)",
|
|
170
172
|
usage: "[name…]",
|
|
171
|
-
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
|
|
173
|
+
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>/. On --desktop / --lite / --web 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. The headless renderer has no backend, and --sim does not wait for one: its frames are not tied to real time.\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).",
|
|
172
174
|
flags: testFlags,
|
|
173
175
|
examples: ["lecodes test", "lecodes test hero --logs", "lecodes test --flows --json", "lecodes test --desktop"],
|
|
174
176
|
run: ({ args, flags }) => runTests(args, flags),
|
|
@@ -177,9 +179,12 @@ export default defineCommand({
|
|
|
177
179
|
const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
|
|
178
180
|
// --sim is the native path on the windowless host: everything --desktop does, minus pixels.
|
|
179
181
|
const sim = flags.sim
|
|
180
|
-
|
|
181
|
-
if (lite &&
|
|
182
|
-
|
|
182
|
+
// --lite / --web: a browser run, on Web Lite or on the web player — one runner, two pages.
|
|
183
|
+
if (flags.lite && flags.web) throw new CliError("--lite and --web are two hosts — pick one.")
|
|
184
|
+
const lite = flags.lite || flags.web
|
|
185
|
+
const browserFlag = flags.web ? "--web" : "--lite"
|
|
186
|
+
if (lite && (flags.desktop || sim)) throw new CliError(`${browserFlag} is a host of its own — drop --desktop / --sim.`)
|
|
187
|
+
if (lite && flags.flows) throw new CliError(`--flows needs the headless renderer (design flows read expect.screen and fill inputs) — drop ${browserFlag}.`)
|
|
183
188
|
const desktop = flags.desktop || sim
|
|
184
189
|
if (desktop && flags.flows) throw new CliError(`--flows needs the headless renderer (design flows read expect.screen and fill inputs) — drop --${sim ? "sim" : "desktop"}.`)
|
|
185
190
|
// --desktop: ONE renderer decision (host binary + the staged .filamat variant). The root is
|
|
@@ -187,7 +192,9 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
|
|
|
187
192
|
let desktopRoot: string
|
|
188
193
|
try { desktopRoot = findProjectRoot(process.cwd()) } catch { desktopRoot = process.cwd() }
|
|
189
194
|
const desktopRenderer = desktop ? resolveDesktopRenderer(desktopRoot, flags.renderer) : undefined
|
|
190
|
-
|
|
195
|
+
// The project's backend, when it has one: on a database of its own, emptied before every case.
|
|
196
|
+
const backend = desktop ? runBackend({ root: desktopRoot, command: "lecodes test", test: true, log: (msg) => { if (flags.logs || msg.includes("[server:error]")) logErr(msg) } }) : null
|
|
197
|
+
const native = desktop ? await buildBundle(bundleOptions(flags), { localAssets: true, desktopRenderer, backend: backend! }) : null
|
|
191
198
|
const headless = desktop || lite ? null : await compileHeadlessBundle({ entry: flags.entry, publicUrl: flags["public-url"], remoteAssets: flags["remote-assets"], waitNetwork: flags["wait-network"] })
|
|
192
199
|
const warnings = headless?.warnings ?? []
|
|
193
200
|
const useLocalAssets = headless?.useLocalAssets ?? false
|
|
@@ -338,11 +345,17 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
|
|
|
338
345
|
// Web Lite: one compile, one browser; a page of its own per case.
|
|
339
346
|
let liteSession: LiteSession | null = null
|
|
340
347
|
if (lite) {
|
|
341
|
-
const page = resolveLiteRunner(note)
|
|
342
|
-
if (!page) throw new CliError("No Web Lite page found: this CLI has no runtime/web-lite (a monorepo checkout needs `bun run build` in hosts/web-lite).")
|
|
343
348
|
let manifest: Manifest | null = null
|
|
344
349
|
try { manifest = readManifest(root) } catch { /* a folder standing alone */ }
|
|
345
|
-
|
|
350
|
+
if (flags.web) {
|
|
351
|
+
const web = resolveWebRunner(note)
|
|
352
|
+
if (!web) throw new CliError("No web player found: this CLI has no runtime/web (a monorepo checkout needs `bun run build` in hosts/web-player).")
|
|
353
|
+
liteSession = await startLiteSession({ root, manifest, name: manifest?.name ?? "app", web, entry: flags.entry })
|
|
354
|
+
} else {
|
|
355
|
+
const page = resolveLiteRunner(note)
|
|
356
|
+
if (!page) throw new CliError("No Web Lite page found: this CLI has no runtime/web-lite (a monorepo checkout needs `bun run build` in hosts/web-lite).")
|
|
357
|
+
liteSession = await startLiteSession({ root, manifest, name: manifest?.name ?? "app", lite: page, entry: flags.entry })
|
|
358
|
+
}
|
|
346
359
|
}
|
|
347
360
|
const runLiteCase = async (kase: FlowCase): Promise<CaseResult> => {
|
|
348
361
|
const caseDir = resolve(artifactsRoot, slug(kase.name))
|
|
@@ -364,6 +377,8 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
|
|
|
364
377
|
|
|
365
378
|
for (const kase of cases) {
|
|
366
379
|
let result: CaseResult
|
|
380
|
+
// every case starts on an empty database, like it starts in a fresh host
|
|
381
|
+
if (backend && results.length > 0) await backend.reset()
|
|
367
382
|
if (lite) {
|
|
368
383
|
try {
|
|
369
384
|
result = await runLiteCase(kase)
|
|
@@ -446,6 +461,7 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
|
|
|
446
461
|
}
|
|
447
462
|
|
|
448
463
|
await liteSession?.close()
|
|
464
|
+
await backend?.close()
|
|
449
465
|
clearTimeout(killer)
|
|
450
466
|
process.exit(failed > 0 ? 1 : 0)
|
|
451
467
|
}
|
|
@@ -3,7 +3,10 @@ import { createRequire } from "node:module"
|
|
|
3
3
|
import { dirname, join } from "node:path"
|
|
4
4
|
import type { FileKind } from "../project/manifest"
|
|
5
5
|
import { distRoot } from "../hosts/distRoot"
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
compileProject as sdkCompileProject, compileServerBundle, detectEntry, isServerPath, scanModulesChisel, localAssetNames, ENTRY_CONVENTION,
|
|
8
|
+
type CompileServerOptions,
|
|
9
|
+
} from "lecodes-sdk/compile"
|
|
7
10
|
|
|
8
11
|
// Every project compiles through the SDK's one import-free compiler, which bundles with chisel (the
|
|
9
12
|
// Rust method-granular bundler, @letary/chisel) — the only bundler. The SDK resolves the chisel
|
|
@@ -84,6 +87,9 @@ export type CompileOptions = {
|
|
|
84
87
|
/** What the compile has to say that is not an error (a plugin the app uses and app.json does not
|
|
85
88
|
* list). */
|
|
86
89
|
onWarning?: (message: string) => void
|
|
90
|
+
/** Base URL of the project's backend, baked into the stubs of its `*.server.ts` modules; absent =
|
|
91
|
+
* every call to the server rejects (status 0). */
|
|
92
|
+
serverUrl?: string
|
|
87
93
|
}
|
|
88
94
|
|
|
89
95
|
/** A warning when auto-detection has several import-graph roots and no main.ts to settle it —
|
|
@@ -110,8 +116,48 @@ export const compileProject = (opts: CompileOptions): Promise<string> =>
|
|
|
110
116
|
sourcemap: opts.sourcemap,
|
|
111
117
|
editor: opts.editor,
|
|
112
118
|
onWarning: opts.onWarning,
|
|
119
|
+
serverUrl: opts.serverUrl,
|
|
113
120
|
})
|
|
114
121
|
|
|
122
|
+
/** Does the project have a server side (`*.server.ts` files)? */
|
|
123
|
+
export const hasServerFiles = (entries: CompileEntry[]): boolean =>
|
|
124
|
+
entries.some((e) => e.type === "text" && isServerPath(e.path))
|
|
125
|
+
|
|
126
|
+
/** TypeScript with its lib files on disk — the endpoint validators are derived by its checker. The
|
|
127
|
+
* project's own, else what this CLI's install can see; null when there is none. */
|
|
128
|
+
const findTypescript = (root: string): CompileServerOptions["typescript"] | null => {
|
|
129
|
+
for (const from of [join(root, "package.json"), import.meta.url]) {
|
|
130
|
+
try {
|
|
131
|
+
const mod = createRequire(from)("typescript")
|
|
132
|
+
const ts = mod?.createProgram ? mod : mod?.default
|
|
133
|
+
if (ts?.createProgram) return ts
|
|
134
|
+
} catch { /* the next place */ }
|
|
135
|
+
}
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The project's server bundle (its `*.server.ts` files and what they import), or null when it has
|
|
141
|
+
* none. `app` = app.json's name (the sign-in mail). Without a TypeScript to derive them with the
|
|
142
|
+
* bundle carries no argument validators — said through `onWarning`, since the deployed backend
|
|
143
|
+
* (the le.codes build) does check them.
|
|
144
|
+
*/
|
|
145
|
+
export const compileServer = async (opts: { entries: CompileEntry[], root: string, app?: CompileServerOptions["app"], onWarning?: (message: string) => void }) => {
|
|
146
|
+
const typescript = findTypescript(opts.root)
|
|
147
|
+
if (!typescript) {
|
|
148
|
+
// Sign-in rests on a check only the compiler makes: `db.auth.requireUser()` is a query, and one that is
|
|
149
|
+
// not awaited guards nothing. A server with sign-in is not built without it.
|
|
150
|
+
const signIn = opts.entries.find((e) => e.type === "text" && isServerPath(e.path) && /\.withAuth\s*\(/.test(e.text ?? ""))
|
|
151
|
+
if (signIn) throw new Error(`${signIn.path} uses sign-in (withAuth): its server code is checked with TypeScript, and none was found — npm install --save-dev typescript in the project.`)
|
|
152
|
+
opts.onWarning?.("Endpoint arguments are not checked against their types here: no TypeScript found (npm install --save-dev typescript in the project).")
|
|
153
|
+
}
|
|
154
|
+
return compileServerBundle({
|
|
155
|
+
entries: opts.entries.map((e) => ({ path: e.path, type: e.type as any, text: e.text })),
|
|
156
|
+
app: opts.app,
|
|
157
|
+
...(typescript ? { typescript } : { validate: false }),
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
115
161
|
/** Re-exported so the CALLERS that stage files next to the bundle name them the way the bundle asks
|
|
116
162
|
* for them (see localAssetNames in the SDK compile — two packs shipping SM_Sign_01.glb). */
|
|
117
163
|
export { localAssetNames }
|
package/src/dev/androidDev.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { CliError, note, warnErr } from "../cli"
|
|
|
12
12
|
* app's VIEW filter is pinned to le.codes/qr, and an explicit start bypasses filter matching — with
|
|
13
13
|
* the plain dev.js url: the app's initial.js only fetches a url that ends in ".js", anything else is
|
|
14
14
|
* sent to le.codes/api/code, which answers 400 for a dev bundle (the "Произошла ошибка при открытии
|
|
15
|
-
* проекта" screen).
|
|
15
|
+
* проекта" screen).
|
|
16
16
|
*
|
|
17
17
|
* A device run has no frame cap (unlike the desktop host), so every exit path force-stops the app:
|
|
18
18
|
* a 3D scene left rendering is a sustained GPU load on a charging phone.
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, renameSync, rmSync } from "node:fs"
|
|
2
|
+
import { tmpdir } from "node:os"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { format } from "node:util"
|
|
5
|
+
import {
|
|
6
|
+
createAuthHost, createInvoker, createSubscribeGuard, describeServer, loadServerModules,
|
|
7
|
+
setChannelPublisher, setDbTransport, type AuthHost, type LoadedServer,
|
|
8
|
+
} from "lecodes-sdk/server"
|
|
9
|
+
import { loadPeer } from "../hosts/peerInstall"
|
|
10
|
+
import type { FromWorker, LogLevel, ToWorker } from "./localBackend"
|
|
11
|
+
|
|
12
|
+
/*
|
|
13
|
+
* The process a project's server bundle runs in under `lecodes dev` — the local twin of the runner's
|
|
14
|
+
* worker (tools/runner/src/worker.ts), started by localBackend.ts as this same CLI with
|
|
15
|
+
* BACKEND_WORKER_ARG and spoken to over the IPC channel. One process per loaded bundle: a reload is
|
|
16
|
+
* a new process, so nothing of the old code (its timers, its module state) outlives it.
|
|
17
|
+
*
|
|
18
|
+
* The database is marcidb's embedded engine (the peer marcidb-embedded), opened here, in the
|
|
19
|
+
* directory the parent names. The schema is synced on every load, the way the runner syncs a draft
|
|
20
|
+
* slot: what the models no longer have is dropped; a change the engine can't migrate in place moves
|
|
21
|
+
* the old database aside and starts an empty one.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
type Embedded = {
|
|
25
|
+
exec(op: unknown): Promise<any>
|
|
26
|
+
batch(ops: unknown[]): Promise<any[]>
|
|
27
|
+
$sync(schema: string): Promise<null>
|
|
28
|
+
close(): void
|
|
29
|
+
}
|
|
30
|
+
type EmbeddedModule = { openDatabase(dir: string, options?: { disableFsync?: boolean }): Embedded }
|
|
31
|
+
|
|
32
|
+
const post = (m: FromWorker) => { process.send?.(m) }
|
|
33
|
+
|
|
34
|
+
// ── console → the parent (the dev terminal) ──
|
|
35
|
+
const hook = (level: LogLevel) => (...args: unknown[]) => post({ t: "log", level, text: format(...args) })
|
|
36
|
+
console.log = hook("log"); console.info = hook("info"); console.warn = hook("warn"); console.error = hook("error"); console.debug = hook("debug")
|
|
37
|
+
process.on("unhandledRejection", (e) => post({ t: "log", level: "error", text: `Unhandled rejection: ${(e as Error)?.stack ?? e}` }))
|
|
38
|
+
process.on("uncaughtException", (e) => post({ t: "log", level: "error", text: `Uncaught exception: ${(e as Error)?.stack ?? e}` }))
|
|
39
|
+
|
|
40
|
+
/** The code `db.auth.signInWithCode` takes for any email under `lecodes dev` (no `sendCode` needed). */
|
|
41
|
+
const DEV_CODE = "000000"
|
|
42
|
+
|
|
43
|
+
let db: Embedded | null = null
|
|
44
|
+
let invoke: ReturnType<typeof createInvoker> | null = null
|
|
45
|
+
let subscribe: ReturnType<typeof createSubscribeGuard> | null = null
|
|
46
|
+
let authHost: AuthHost | null = null
|
|
47
|
+
|
|
48
|
+
const quit = () => {
|
|
49
|
+
try { db?.close() } catch { /* already closed */ }
|
|
50
|
+
process.exit(0)
|
|
51
|
+
}
|
|
52
|
+
// the dev server went away (Ctrl+C, a crash): nothing is left to serve
|
|
53
|
+
process.on("disconnect", quit)
|
|
54
|
+
// …and when it was killed outright the channel may never say so: a process left behind keeps the
|
|
55
|
+
// database locked, and the next `lecodes dev` of the project could not open it.
|
|
56
|
+
const parent = process.ppid
|
|
57
|
+
setInterval(() => { try { process.kill(parent, 0) } catch { quit() } }, 1000).unref?.()
|
|
58
|
+
process.on("SIGINT", quit)
|
|
59
|
+
process.on("SIGTERM", quit)
|
|
60
|
+
|
|
61
|
+
/** Open the project's database and bring it to `schema`. Returns what the developer should be told. */
|
|
62
|
+
const openDb = async (dir: string, schema: string): Promise<string[]> => {
|
|
63
|
+
const { openDatabase } = await loadPeer<EmbeddedModule>("marcidb-embedded", undefined, { for: "lecodes dev", install: false })
|
|
64
|
+
?? (() => { throw new Error("marcidb-embedded is not installed") })()
|
|
65
|
+
mkdirSync(dir, { recursive: true })
|
|
66
|
+
try {
|
|
67
|
+
db = openDatabase(dir)
|
|
68
|
+
} catch (e) {
|
|
69
|
+
// one process holds a database: the engine's lock is the sign of a second server on this project
|
|
70
|
+
if (/Locked/i.test((e as Error).message)) throw new Error(`The project's database (${dir}) is in use — this project's backend is already running (another \`lecodes dev\` or \`lecodes desktop run\`). Stop it first.`)
|
|
71
|
+
throw e
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
await db.$sync(schema)
|
|
75
|
+
return []
|
|
76
|
+
} catch (e) {
|
|
77
|
+
if ((e as { kind?: string }).kind !== "bad_request") throw e
|
|
78
|
+
const reason = (e as Error).message
|
|
79
|
+
// Not migratable in place — or not a schema at all. An empty database tells the two apart, so a
|
|
80
|
+
// broken schema never costs the data.
|
|
81
|
+
const probeDir = mkdtempSync(join(tmpdir(), "lecodes-db-"))
|
|
82
|
+
const probe = openDatabase(probeDir, { disableFsync: true })
|
|
83
|
+
try { await probe.$sync(schema) } finally { probe.close(); rmSync(probeDir, { recursive: true, force: true }) }
|
|
84
|
+
db.close()
|
|
85
|
+
const aside = `${dir}-old`
|
|
86
|
+
rmSync(aside, { recursive: true, force: true })
|
|
87
|
+
renameSync(dir, aside)
|
|
88
|
+
mkdirSync(dir, { recursive: true })
|
|
89
|
+
db = openDatabase(dir)
|
|
90
|
+
await db.$sync(schema)
|
|
91
|
+
return [`database started over — the schema change couldn't be migrated (${reason}); the previous data is in ${aside}`]
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const load = async (m: Extract<ToWorker, { t: "load" }>) => {
|
|
96
|
+
const noDb = () => { throw new Error("db: the database is not open yet — query it inside an endpoint, not while the module loads") }
|
|
97
|
+
setDbTransport({ exec: (op) => (db ?? noDb()).exec(op), batch: (ops) => (db ?? noDb()).batch(ops) })
|
|
98
|
+
setChannelPublisher((ch, group, data) => post({ t: "publish", ch, group, data }))
|
|
99
|
+
// The bundle is a plain script (chisel esm output with only side effects): registers globalThis.__lecodesServer
|
|
100
|
+
;(0, eval)(m.code)
|
|
101
|
+
const server: LoadedServer = loadServerModules()
|
|
102
|
+
const describe = describeServer(server)
|
|
103
|
+
const notes = describe.marci ? await openDb(m.dbDir, describe.marci) : []
|
|
104
|
+
// Sessions, passwords and email codes over the project's own db, as on the runner. A code that was
|
|
105
|
+
// asked for is printed here, and DEV_CODE signs any email in without one — the developer is the
|
|
106
|
+
// only user.
|
|
107
|
+
authHost = createAuthHost({
|
|
108
|
+
db: server.dbs.find(d => d.$auth) ?? null,
|
|
109
|
+
appName: server.manifest.app?.name,
|
|
110
|
+
echoCodes: true,
|
|
111
|
+
devCode: DEV_CODE,
|
|
112
|
+
log: (level, text) => post({ t: "log", level, text }),
|
|
113
|
+
})
|
|
114
|
+
authHost.install()
|
|
115
|
+
const auth = authHost.enabled ? authHost : null
|
|
116
|
+
const onError = (id: string, e: unknown) => post({ t: "log", level: "error", text: `${id}: ${(e as Error)?.stack ?? e}` })
|
|
117
|
+
invoke = createInvoker(server, { auth, onError })
|
|
118
|
+
subscribe = createSubscribeGuard(server, { auth, onError })
|
|
119
|
+
post({ t: "loaded", describe, notes })
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
process.on("message", async (m: ToWorker) => {
|
|
123
|
+
switch (m.t) {
|
|
124
|
+
case "load":
|
|
125
|
+
try { await load(m) } catch (e) { post({ t: "loadError", message: (e as Error)?.stack ?? String(e) }) }
|
|
126
|
+
return
|
|
127
|
+
case "invoke": {
|
|
128
|
+
const result = invoke ? await invoke(m.id, m.args, m.ctx) : { ok: false as const, status: 503, message: "server not loaded" }
|
|
129
|
+
return post({ t: "result", req: m.req, result })
|
|
130
|
+
}
|
|
131
|
+
case "subscribe": {
|
|
132
|
+
const result = subscribe ? await subscribe(m.ch, m.args, m.ctx) : { ok: false as const, status: 503, message: "server not loaded" }
|
|
133
|
+
return post({ t: "result", req: m.req, result })
|
|
134
|
+
}
|
|
135
|
+
case "stop":
|
|
136
|
+
return quit()
|
|
137
|
+
}
|
|
138
|
+
})
|