lecodes-cli 1.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +6211 -3649
- package/package.json +12 -8
- package/runtime/materials/lightmap.mat +115 -139
- package/runtime/materials/particles-quad.mat +6 -2
- package/runtime/materials/terrain-lightmap.mat +123 -0
- package/runtime/materials/terrain.mat +36 -55
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/VERSION.json +5 -0
- package/runtime/web/assets/camera-5XOM8eHl.js +1 -0
- package/runtime/web/assets/creator-2d-D9WKYM6h.js +1 -0
- package/runtime/web/assets/creator-2d-ukvEmNEp.wasm +0 -0
- package/runtime/web/assets/creator-full-BBXzQlhq.js +1 -0
- package/runtime/web/assets/creator-full-D2dSDdDj.wasm +0 -0
- package/runtime/web/assets/creator-full.js-BYswGsqR.symbols +8124 -0
- package/runtime/web/assets/creator-ui-DIc92ion.wasm +0 -0
- package/runtime/web/assets/creator-ui-_1Z_AX2M.js +1 -0
- package/runtime/web/assets/geolocation-BjTKN9_P.js +1 -0
- package/runtime/web/assets/inspect-C7x78PNs.js +2 -0
- package/runtime/web/assets/map-CyBxOSV0.js +1 -0
- package/runtime/web/assets/map-XzoOFt0E.js +2 -0
- package/runtime/web/assets/mapImpl-bxvQ2h1Z.js +823 -0
- package/runtime/web/assets/maplibre-gl-worker-B7LXXUkR.js +8 -0
- package/runtime/web/assets/materials-uri4FvPE.bin +0 -0
- package/runtime/web/assets/materials-world-CQWt8TAm.bin +0 -0
- package/runtime/web/assets/qr-scanner-CK_cYoJR.js +1 -0
- package/runtime/web/assets/uberarchive-DLH-kBhM.bin +0 -0
- package/runtime/web/index.html +36 -0
- package/runtime/web/player.js +21 -0
- package/runtime/web-page/index.html +41 -0
- package/runtime/web-page/page.css +1 -0
- package/runtime/web-page/page.js +31 -0
- package/src/cli/help.ts +25 -0
- package/src/cli/index.ts +7 -7
- package/src/commands/app/android.ts +3 -1
- package/src/commands/app/desktop.ts +1 -1
- package/src/commands/app/desktopMac.ts +10 -9
- package/src/commands/app/icon.ts +9 -6
- package/src/commands/app/index.ts +3 -1
- package/src/commands/app/shared.ts +13 -5
- package/src/commands/app/templates/android.ts +10 -11
- package/src/commands/app/templates/ios.ts +1 -1
- package/src/commands/assets.ts +1 -1
- package/src/commands/compile.ts +15 -2
- package/src/commands/design/index.ts +4 -4
- package/src/commands/design/snapshot.ts +3 -3
- package/src/commands/desktop.ts +5 -1
- package/src/commands/dev.ts +82 -29
- package/src/commands/index.ts +64 -60
- package/src/commands/init/index.ts +11 -15
- package/src/commands/init/templates.ts +64 -21
- package/src/commands/lightmap.ts +459 -200
- package/src/commands/navmesh.ts +2 -2
- package/src/commands/plugin.ts +46 -0
- package/src/commands/render.ts +22 -16
- package/src/commands/scene.ts +6 -1
- package/src/commands/serve.ts +58 -0
- package/src/commands/shaders/new.ts +1 -1
- package/src/commands/test.ts +402 -398
- package/src/commands/update/index.ts +3 -3
- package/src/compile/designCompile.ts +1 -1
- package/src/compile/headlessBundle.ts +133 -133
- package/src/compile/nativeStack.ts +1 -1
- package/src/compile/projectCompile.ts +10 -2
- package/src/compile/sceneCompile.ts +8 -22
- package/src/declarations/lecodes-3d-editor.d.ts +4 -0
- package/src/declarations/lecodes-assets.d.ts +1 -1
- package/src/declarations/{lecodes-renderer.d.ts → lecodes-headless.d.ts} +3 -3
- package/src/dev/clientTemplates.ts +18 -14
- package/src/dev/devServer.ts +75 -38
- package/src/dev/iosDev.ts +180 -0
- package/src/dev/webRunner.ts +82 -14
- package/src/hosts/cmgenTool.ts +1 -1
- package/src/hosts/desktopRenderer.ts +98 -27
- package/src/hosts/desktopScript.ts +150 -35
- package/src/hosts/distRoot.ts +1 -1
- package/src/hosts/matcTool.ts +1 -1
- package/src/hosts/peerInstall.ts +5 -5
- package/src/hosts/peers.ts +2 -2
- package/src/index.ts +4 -1
- package/src/plugin/contract.ts +438 -0
- package/src/plugin/emitKotlin.ts +357 -0
- package/src/plugin/emitSdk.ts +259 -0
- package/src/plugin/emitSwift.ts +336 -0
- package/src/plugin/emitWeb.ts +383 -0
- package/src/plugin/generate.ts +157 -0
- package/src/plugin/lines.ts +28 -0
- package/src/plugin/lower.ts +231 -0
- package/src/plugin/manifest.ts +192 -0
- package/src/project/ignore.ts +164 -163
- package/runtime/materials/lightmap-baked-lite.mat +0 -180
- package/runtime/materials/lightmap-baked.mat +0 -181
- package/runtime/scene-harness.json +0 -1
- package/runtime/web/assets/__vite-browser-external-BIHI7g3E.js +0 -1
- package/runtime/web/assets/basis-C64VHDVD.js +0 -1
- package/runtime/web/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
- package/runtime/web/assets/createViewerLite-DHqTFhWB.js +0 -866
- package/runtime/web/assets/draco-BiISTFcR.js +0 -118
- package/runtime/web/assets/draco_decoder-DsQ12WqX.wasm +0 -0
- package/runtime/web/assets/index-Q06c6oHx.js +0 -2
- package/runtime/web/assets/mapViewImpl-B2JcES8l.js +0 -810
- package/runtime/web/assets/maplibre-gl-worker-CJfwIrte.js +0 -8
- package/runtime/web/assets/worker-Caf-yYEI.js +0 -2
- package/runtime/web/embed.html +0 -32
- package/runtime/web/embed.js +0 -171
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFileSync, spawn, spawnSync } from "node:child_process"
|
|
2
|
-
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, linkSync } from "node:fs"
|
|
3
3
|
import { homedir, tmpdir } from "node:os"
|
|
4
4
|
import { delimiter, join, resolve } from "node:path"
|
|
5
5
|
import { cachedArtifactSha, downloadReleaseArchive, fetchReleaseProduct, semverNewer, type ManifestVersion } from "./releases"
|
|
@@ -15,7 +15,7 @@ import { makeStackDemangler } from "../compile/nativeStack"
|
|
|
15
15
|
* natively). It downloads on first use from the same S3 release bucket the desktop distribution
|
|
16
16
|
* publishes to (scripts/release/* → releases/versions.json), sha256-verified, into
|
|
17
17
|
* ~/.lecodes/renderer/desktop-<version>/. LECODES_DESKTOP_EXE overrides resolution entirely
|
|
18
|
-
* (local dev: point it at
|
|
18
|
+
* (local dev: point it at hosts/desktop/build/lecodes-desktop.exe).
|
|
19
19
|
*
|
|
20
20
|
* Platforms: windows-x64 + linux (the ubuntu build) + macos-arm64. The macOS host is built and
|
|
21
21
|
* published separately (the Apple repo's LeCodes Desktop.app, via scripts/release/release-macos.sh)
|
|
@@ -58,8 +58,9 @@ const platformKey = (renderer: DesktopRenderer = "gl"): string => {
|
|
|
58
58
|
|
|
59
59
|
const isMac = () => process.platform === "darwin"
|
|
60
60
|
|
|
61
|
-
/** The host binary's file name
|
|
62
|
-
|
|
61
|
+
/** The host binary's file name — the same on every OS; on macOS it sits inside LeCodes Desktop.app
|
|
62
|
+
* (Contents/MacOS/lecodes-desktop, hosts/desktop/package-macos.sh). */
|
|
63
|
+
const exeName = () => (process.platform === "win32" ? "lecodes-desktop.exe" : "lecodes-desktop")
|
|
63
64
|
|
|
64
65
|
/** Recursively locate the host binary inside an extracted archive (the zip nests a
|
|
65
66
|
* `lecodes-desktop/` top-level folder; the tarballs may differ — search, don't assume). */
|
|
@@ -167,7 +168,7 @@ export const findMacHostExe = (): string | null => {
|
|
|
167
168
|
const candidates = [
|
|
168
169
|
...["/Applications", join(homedir(), "Applications")]
|
|
169
170
|
.flatMap((root) => ["LeCodes Desktop.app", "LeCodes.app"].map((app) => join(root, app))),
|
|
170
|
-
].map((app) => join(app, "Contents", "MacOS", "lecodes-
|
|
171
|
+
].map((app) => join(app, "Contents", "MacOS", "lecodes-desktop"))
|
|
171
172
|
return candidates.find((exe) => existsSync(exe)) ?? null
|
|
172
173
|
}
|
|
173
174
|
|
|
@@ -205,7 +206,7 @@ const resolveMacExe = async (selected: string): Promise<string> => {
|
|
|
205
206
|
throw new CliError(
|
|
206
207
|
`${e instanceof Error ? e.message : String(e)}\n` +
|
|
207
208
|
" No LeCodes Desktop.app is installed either — retry online (`lecodes desktop update`), install the DMG into\n" +
|
|
208
|
-
" /Applications, or point LECODES_DESKTOP_EXE at a build (
|
|
209
|
+
" /Applications, or point LECODES_DESKTOP_EXE at a build (hosts/desktop/build-macos/lecodes-desktop, or an .app's Contents/MacOS/lecodes-desktop).",
|
|
209
210
|
)
|
|
210
211
|
}
|
|
211
212
|
}
|
|
@@ -235,6 +236,38 @@ export const resolveDesktopExe = async (renderer: DesktopRenderer = "gl"): Promi
|
|
|
235
236
|
return exe
|
|
236
237
|
}
|
|
237
238
|
|
|
239
|
+
/** Which native host runs a bundle: the desktop (a window, a GPU, pixels) or the windowless server
|
|
240
|
+
* (hosts/desktop lecodes-server: the same engine on Filament's NOOP backend — the dedicated
|
|
241
|
+
* multiplayer server, a headless client, an offline simulation run; no UI hit-test, no pixels). */
|
|
242
|
+
export type HostKind = "desktop" | "server"
|
|
243
|
+
|
|
244
|
+
const serverExeName = () => (process.platform === "win32" ? "lecodes-server.exe" : "lecodes-server")
|
|
245
|
+
|
|
246
|
+
/** The windowless host: LECODES_SERVER_EXE override → the binary beside the resolved desktop host
|
|
247
|
+
* (one product, two executables — a release archive and a repo build both keep them side by side,
|
|
248
|
+
* sharing runtime/). A host older than the server's introduction has none beside it. */
|
|
249
|
+
export const resolveServerExe = async (renderer: DesktopRenderer = "gl"): Promise<string> => {
|
|
250
|
+
const override = process.env.LECODES_SERVER_EXE
|
|
251
|
+
if (override) {
|
|
252
|
+
if (!existsSync(override)) throw new CliError(`LECODES_SERVER_EXE points at "${override}" which doesn't exist.`)
|
|
253
|
+
return resolve(override)
|
|
254
|
+
}
|
|
255
|
+
// macOS: the server ships inside the .app beside the window host (Contents/MacOS/) since the
|
|
256
|
+
// in-repo Metal host (hosts/desktop/build-macos.sh); the "beside the desktop exe" rule holds.
|
|
257
|
+
const desktopExe = await resolveDesktopExe(renderer)
|
|
258
|
+
const exe = join(desktopExe, "..", serverExeName())
|
|
259
|
+
if (!existsSync(exe)) {
|
|
260
|
+
throw new CliError(
|
|
261
|
+
`No ${serverExeName()} beside ${desktopExe} — this desktop host predates the windowless server. ` +
|
|
262
|
+
"Update it (lecodes desktop update), or point LECODES_SERVER_EXE at a build (hosts/desktop/build[-linux|-macos]/lecodes-server).",
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
return exe
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const resolveHostExe = (host: HostKind | undefined, renderer: DesktopRenderer | undefined): Promise<string> =>
|
|
269
|
+
host === "server" ? resolveServerExe(renderer) : resolveDesktopExe(renderer)
|
|
270
|
+
|
|
238
271
|
/** `lecodes desktop update`: move the render host to the newest published release (never below the
|
|
239
272
|
* CLI's pin — a manifest that somehow regressed under it is an error, not a downgrade). Saves the
|
|
240
273
|
* selection so later `render --desktop` runs use it; the download is skipped when already cached. */
|
|
@@ -279,6 +312,8 @@ export type DesktopWindowOpts = {
|
|
|
279
312
|
/** Which host binary to run (see DesktopRenderer). Must match the renderer the bundle's shaders
|
|
280
313
|
* were staged for — resolve it once per command and pass the same value to both. */
|
|
281
314
|
renderer?: DesktopRenderer
|
|
315
|
+
/** The desktop window (default) or the windowless server host (see HostKind). */
|
|
316
|
+
host?: HostKind
|
|
282
317
|
width?: number
|
|
283
318
|
height?: number
|
|
284
319
|
minWidth?: number
|
|
@@ -305,8 +340,16 @@ export type DesktopWindowOpts = {
|
|
|
305
340
|
* the block, forwarded verbatim as CREATOR_VIRTUAL_KEYBOARD JSON. A set env wins, like the mouse knob. */
|
|
306
341
|
virtualKeyboard?: boolean | object
|
|
307
342
|
/** Draw the host's frame counter in the corner (host ≥1.5.0; older hosts ignore the env). Costs
|
|
308
|
-
* nothing to leave off and next to nothing on — the digits are rectangles, no UI behind them.
|
|
343
|
+
* nothing to leave off and next to nothing on — the digits are rectangles, no UI behind them.
|
|
344
|
+
* On macOS too (host 2026-09-09; the CLI sets the same env there). */
|
|
309
345
|
fps?: boolean
|
|
346
|
+
/** The host's profiler (CREATOR_PERF=1, host 2026-09-26): a `[perf]` line a second — GPU frame time from
|
|
347
|
+
* Filament's timer query, the CPU split, visible / total renderables, the view's switches — and the
|
|
348
|
+
* fps overlay's second row. docs/profiling.md in the core repo. */
|
|
349
|
+
perf?: boolean
|
|
350
|
+
/** app.json desktop.frameRate — a CAP on the host's render loop, "display" or a number; forwarded
|
|
351
|
+
* verbatim as CREATOR_FRAME_RATE (a set env wins). macOS host 2026-09-09; Windows ignores it. */
|
|
352
|
+
frameRate?: number | "display"
|
|
310
353
|
/** Multiplayer launch facts for the host (`--server --port N --max-clients N` / `--connect ip:port`);
|
|
311
354
|
* the SDK reads them through `Net.launch` and decides (docs/multiplayer-plan.md). */
|
|
312
355
|
net?: { role: "server" | "client"; address?: string; port?: number; maxClients?: number }
|
|
@@ -340,6 +383,10 @@ const windowEnv = (opts: DesktopWindowOpts): Record<string, string> => {
|
|
|
340
383
|
env.CREATOR_RENDER_MAX = `${Math.round(opts.maxRenderSize[0])}x${Math.round(opts.maxRenderSize[1])}`
|
|
341
384
|
}
|
|
342
385
|
if (opts.fps) env.CREATOR_FPS = "1"
|
|
386
|
+
if (opts.perf) env.CREATOR_PERF = "1"
|
|
387
|
+
if (opts.frameRate !== undefined && process.env.CREATOR_FRAME_RATE === undefined) {
|
|
388
|
+
env.CREATOR_FRAME_RATE = String(opts.frameRate)
|
|
389
|
+
}
|
|
343
390
|
if (opts.mouseEmulateTouch !== undefined && process.env.CREATOR_MOUSE_TOUCH === undefined) {
|
|
344
391
|
env.CREATOR_MOUSE_TOUCH = opts.mouseEmulateTouch ? "1" : "0"
|
|
345
392
|
}
|
|
@@ -367,6 +414,9 @@ export type DesktopRenderOptions = {
|
|
|
367
414
|
/** Which host binary to run (see DesktopRenderer). Must match the renderer the bundle's shaders
|
|
368
415
|
* were staged for — resolve it once per command and pass the same value to both. */
|
|
369
416
|
renderer?: DesktopRenderer
|
|
417
|
+
/** The hidden desktop host (default: pixels, a PNG) or the windowless server host (no pixels — the
|
|
418
|
+
* run is judged by its output alone; `outPng` is ignored). */
|
|
419
|
+
host?: HostKind
|
|
370
420
|
width: number
|
|
371
421
|
height: number
|
|
372
422
|
/** Frames to run before the dump — headroom for async loads (GLB, images, fonts). */
|
|
@@ -376,7 +426,7 @@ export type DesktopRenderOptions = {
|
|
|
376
426
|
/** Forward the host's stdout/stderr (app console.log included) to stderr. */
|
|
377
427
|
logs: boolean
|
|
378
428
|
timeoutMs: number
|
|
379
|
-
/** CREATOR_SCRIPT contents (the line format in
|
|
429
|
+
/** CREATOR_SCRIPT contents (the line format in hosts/desktop/src/main.cpp `scriptStep`): the
|
|
380
430
|
* host drives these commands and self-terminates when done; `frames` is then only a safety cap. */
|
|
381
431
|
script?: string
|
|
382
432
|
/** CREATOR_FIXED_DT: virtual app clock, ms per frame (0/undefined = wall clock). */
|
|
@@ -396,13 +446,29 @@ export type DesktopRenderOutcome = {
|
|
|
396
446
|
|
|
397
447
|
/** Stage a compiled bundle for the host: app.js + the local-asset resources copied next to it
|
|
398
448
|
* (the host's fetchLocal resolves them by basename relative to its cwd). Caller removes the dir. */
|
|
449
|
+
/** The staging dir the host runs from: app.js + every resource the bundle names, next to it (fetchLocal's
|
|
450
|
+
* fallback). A resource is HARD-LINKED when the temp dir shares the project's volume (the usual case:
|
|
451
|
+
* both on C:) and copied otherwise — a yard-sized project names hundreds of MB of models and textures,
|
|
452
|
+
* and copying them was seconds of "the window has not opened yet" on every run. The host only reads
|
|
453
|
+
* them, and the dir is removed after the run, so a link is as safe as a copy. */
|
|
399
454
|
const stageBundle = (js: string, resources: { name: string, absPath?: string }[]): string => {
|
|
455
|
+
const t0 = performance.now()
|
|
400
456
|
const runDir = mkdtempSync(join(tmpdir(), "lecodes-render-"))
|
|
401
457
|
writeFileSync(join(runDir, "app.js"), js)
|
|
458
|
+
let files = 0, bytes = 0, copied = 0
|
|
402
459
|
for (const r of resources) {
|
|
403
|
-
if (r.absPath
|
|
404
|
-
|
|
405
|
-
|
|
460
|
+
if (!r.absPath || !existsSync(r.absPath)) continue
|
|
461
|
+
const st = statSync(r.absPath)
|
|
462
|
+
if (!st.isFile()) continue
|
|
463
|
+
const dst = join(runDir, r.name)
|
|
464
|
+
try { linkSync(r.absPath, dst) }
|
|
465
|
+
catch { copyFileSync(r.absPath, dst); copied++ }
|
|
466
|
+
files++
|
|
467
|
+
bytes += st.size
|
|
468
|
+
}
|
|
469
|
+
const ms = performance.now() - t0
|
|
470
|
+
if (files > 0 && (ms > 250 || copied > 0)) {
|
|
471
|
+
note(`Staged ${files} resources (${(bytes / 1048576).toFixed(0)} MB${copied > 0 ? `, ${copied} copied` : ", linked"}) in ${(ms / 1000).toFixed(1)} s`)
|
|
406
472
|
}
|
|
407
473
|
return runDir
|
|
408
474
|
}
|
|
@@ -429,7 +495,8 @@ export const runDesktopApp = async (
|
|
|
429
495
|
resources: { name: string, absPath?: string }[],
|
|
430
496
|
opts: DesktopWindowOpts = {},
|
|
431
497
|
): Promise<void> => {
|
|
432
|
-
const exe = await
|
|
498
|
+
const exe = await resolveHostExe(opts.host, opts.renderer)
|
|
499
|
+
const what = opts.host === "server" ? "server" : "desktop"
|
|
433
500
|
const runDir = stageBundle(js, resources)
|
|
434
501
|
try {
|
|
435
502
|
const env = { ...process.env, ...windowEnv(opts) }
|
|
@@ -445,10 +512,15 @@ export const runDesktopApp = async (
|
|
|
445
512
|
pipeThroughDemangler(child.stdout!, process.stdout, demangle)
|
|
446
513
|
pipeThroughDemangler(child.stderr!, process.stderr, demangle)
|
|
447
514
|
}
|
|
448
|
-
child
|
|
515
|
+
// Ctrl+C in the terminal: the child gets it too on a console (Windows) or via SIGINT (POSIX),
|
|
516
|
+
// and the server host tears its endpoint down on it; make sure a detached one is not left behind.
|
|
517
|
+
const onSigint = () => { child.kill("SIGINT") }
|
|
518
|
+
process.once("SIGINT", onSigint)
|
|
519
|
+
child.on("error", (e) => { process.off("SIGINT", onSigint); fail(new CliError(`Can't run the ${what} host: ${e.message}`)) })
|
|
449
520
|
child.on("exit", (code) => {
|
|
521
|
+
process.off("SIGINT", onSigint)
|
|
450
522
|
if (code === 0 || code === null) done() // null: killed (e.g. Ctrl+C reached the child)
|
|
451
|
-
else fail(new CliError(`The
|
|
523
|
+
else fail(new CliError(`The ${what} host exited with code ${code}.`))
|
|
452
524
|
})
|
|
453
525
|
})
|
|
454
526
|
} finally {
|
|
@@ -480,30 +552,29 @@ export const runDesktopDev = async (url: string, opts: DesktopWindowOpts = {}):
|
|
|
480
552
|
})
|
|
481
553
|
}
|
|
482
554
|
|
|
483
|
-
/** Run a compiled bundle in the hidden desktop host and dump the final frame as a PNG
|
|
555
|
+
/** Run a compiled bundle in the hidden desktop host and dump the final frame as a PNG — or, with
|
|
556
|
+
* `host: "server"`, in the windowless host, which paints nothing: the outcome is its output. */
|
|
484
557
|
export const runDesktopRender = async (
|
|
485
558
|
js: string,
|
|
486
559
|
resources: { name: string, absPath?: string }[],
|
|
487
560
|
opts: DesktopRenderOptions,
|
|
488
561
|
): Promise<DesktopRenderOutcome> => {
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
}
|
|
495
|
-
const exe = await resolveDesktopExe(opts.renderer)
|
|
562
|
+
const server = opts.host === "server"
|
|
563
|
+
// macOS: the in-repo Metal host (hosts/desktop, phase 11) carries the headless render contract like
|
|
564
|
+
// the other two platforms (CREATOR_HIDDEN / CREATOR_DUMP_PNG / CREATOR_SCRIPT); the old Swift shell
|
|
565
|
+
// did not — a cached one is refused by its missing lecodes-server, not here.
|
|
566
|
+
const exe = await resolveHostExe(opts.host, opts.renderer)
|
|
496
567
|
const runDir = stageBundle(js, resources)
|
|
497
568
|
try {
|
|
498
569
|
const appJs = join(runDir, "app.js")
|
|
499
570
|
const outAbs = resolve(opts.outPng)
|
|
500
571
|
const env: Record<string, string | undefined> = {
|
|
501
572
|
...process.env,
|
|
502
|
-
CREATOR_DUMP_PNG: outAbs,
|
|
503
573
|
CREATOR_MAX_FRAMES: String(opts.frames),
|
|
504
574
|
CREATOR_WINDOW_SIZE: `${opts.width}x${opts.height}`,
|
|
505
|
-
CREATOR_HIDDEN: "1",
|
|
575
|
+
CREATOR_HIDDEN: "1", // a clean storage every run (and, on the desktop host, no window)
|
|
506
576
|
}
|
|
577
|
+
if (!server) env.CREATOR_DUMP_PNG = outAbs
|
|
507
578
|
if (opts.script !== undefined) {
|
|
508
579
|
const scriptPath = join(runDir, "script.txt")
|
|
509
580
|
writeFileSync(scriptPath, opts.script)
|
|
@@ -535,17 +606,17 @@ export const runDesktopRender = async (
|
|
|
535
606
|
child.kill()
|
|
536
607
|
fail(new CliError(`The native render timed out after ${opts.timeoutMs}ms (raise --timeout, or lower --frames).`))
|
|
537
608
|
}, opts.timeoutMs)
|
|
538
|
-
child.on("error", (e) => { clearTimeout(killer); fail(new CliError(`Can't run the desktop
|
|
609
|
+
child.on("error", (e) => { clearTimeout(killer); fail(new CliError(`Can't run the ${server ? "server" : "desktop"} host: ${e.message}`)) })
|
|
539
610
|
child.on("exit", (code) => {
|
|
540
611
|
clearTimeout(killer)
|
|
541
612
|
const output = demangle(chunks.join(""))
|
|
542
613
|
if (stopped) return done({ output, exitCode: 0 })
|
|
543
|
-
if (code === 0 && existsSync(outAbs)) return done({ output, exitCode: code })
|
|
614
|
+
if (code === 0 && (server || existsSync(outAbs))) return done({ output, exitCode: code })
|
|
544
615
|
const tail = demangle(chunks.slice(-80).join("")).trimEnd()
|
|
545
616
|
const detail = tail.length > 0 ? `\n${tail}` : ""
|
|
546
617
|
fail(new CliError(code === 0
|
|
547
618
|
? `The renderer exited cleanly but produced no PNG — did the app open a screen?${detail}`
|
|
548
|
-
: `The renderer exited with code ${code}.${detail}`))
|
|
619
|
+
: `The ${server ? "server host" : "renderer"} exited with code ${code}.${detail}`))
|
|
549
620
|
})
|
|
550
621
|
})
|
|
551
622
|
} finally {
|
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
import { resolve } from "node:path"
|
|
2
2
|
import { CliError } from "../cli"
|
|
3
|
+
// The evaluator lives with the headless runner (one source of truth); a relative source import,
|
|
4
|
+
// since lecodes-headless is an OPTIONAL peer of the CLI and --desktop must not require it.
|
|
5
|
+
import { evaluateDumpExpect, evaluatePaintExpect, parseTreeDump, type DumpSemanticExpect, type PaintExpect } from "../../../../runtime/headless/src/headless/paintDump"
|
|
3
6
|
|
|
4
7
|
/*
|
|
5
8
|
* `lecodes render --desktop --script`: the same JSON scenario a headless run takes, driven through
|
|
6
|
-
* the NATIVE desktop host. The host
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* the NATIVE desktop host. The host takes a line-based command file (CREATOR_SCRIPT, the contract in
|
|
10
|
+
* hosts/desktop/src/host/script.h) and reports `[script] #<line> done <op>` on stdout as each command
|
|
11
|
+
* completes. A pointer target goes over as `X Y` (logical px) or as a quoted "name": the RUNTIME
|
|
12
|
+
* resolves it exactly as the headless session does (name → exact text → text substring, creator-pkg
|
|
13
|
+
* pkgFindNode) and the renderer places it — so a flow written by name runs on the desktop too; `fill`
|
|
14
|
+
* / `scroll` / `clip` are host commands of their own. This module does both halves: translate the
|
|
15
|
+
* scenario into that file and turn the host's stdout back into per-step results — `expect.log` is
|
|
16
|
+
* evaluated here against the console lines seen up to each step's marker; `expect.paint` and the
|
|
17
|
+
* semantic expects (`text` / `node` / `screen` / `value`) against the tree dump the host writes right
|
|
18
|
+
* after the step (the same rules as the headless session, paintDump.ts). Only `expect.audio` (the
|
|
19
|
+
* headless recorder's) and the `json` output (the headless renderer's shape) are reported as
|
|
20
|
+
* skipped, never silently passed. The windowless host (`--sim`) has no UI at all: names, hover,
|
|
21
|
+
* typing, fill, scroll and screenshots are refused there.
|
|
13
22
|
*/
|
|
14
23
|
|
|
15
24
|
type Point = { x: number, y: number }
|
|
@@ -24,9 +33,16 @@ export type DesktopPlanStep = {
|
|
|
24
33
|
/** Script line numbers this step emitted; the LAST one's `done` marker completes the step. */
|
|
25
34
|
lines: number[]
|
|
26
35
|
expectLog?: string
|
|
27
|
-
/**
|
|
36
|
+
/** expect.paint and the semantic expects: the host writes the tree dump to `paintDump` (a `dump`
|
|
37
|
+
* line) right before the step's marker; the assertions are evaluated here against that file. */
|
|
38
|
+
expectPaint?: PaintExpect
|
|
39
|
+
expectSemantic?: DumpSemanticExpect
|
|
40
|
+
paintDump?: string
|
|
41
|
+
/** A `dump` output step: the file the host wrote. */
|
|
42
|
+
dump?: string
|
|
43
|
+
/** Expect keys the native host can't evaluate (`audio`: the headless recorder's) — reported as skipped. */
|
|
28
44
|
skippedExpect: string[]
|
|
29
|
-
/** Outputs the native host can't produce (`json`). */
|
|
45
|
+
/** Outputs the native host can't produce (`json`: the headless renderer's semantic shape — use `dump`). */
|
|
30
46
|
skippedOutputs: string[]
|
|
31
47
|
screenshot?: string
|
|
32
48
|
}
|
|
@@ -39,31 +55,41 @@ export type DesktopPlan = {
|
|
|
39
55
|
frameCap: number
|
|
40
56
|
}
|
|
41
57
|
|
|
42
|
-
const ACTIONS = ["tap", "type", "fill", "scroll", "wait", "frames", "drag", "hold", "key", "keyDown", "keyUp", "look", "mouseTo", "wheel", "stick", "gamepad", "camera", "view"] as const
|
|
43
|
-
const SEMANTIC_EXPECT = ["text", "notText", "node", "notNode", "screen", "value"
|
|
58
|
+
const ACTIONS = ["tap", "type", "fill", "scroll", "wait", "frames", "drag", "hold", "hover", "key", "keyDown", "keyUp", "look", "mouseTo", "wheel", "stick", "gamepad", "camera", "view"] as const
|
|
59
|
+
const SEMANTIC_EXPECT = ["text", "notText", "node", "notNode", "screen", "value"]
|
|
60
|
+
const UNSUPPORTED_EXPECT = ["audio"]
|
|
44
61
|
const VIEW_NAMES = ["iso", "top", "front", "side", "back"]
|
|
45
62
|
const isVec3 = (v: unknown): v is [number, number, number] => Array.isArray(v) && v.length === 3 && v.every((n) => typeof n === "number")
|
|
46
63
|
|
|
47
|
-
|
|
64
|
+
type Target = Point | string
|
|
65
|
+
const pt = (p: Target) => typeof p === "string" ? p : `(${p.x},${p.y})`
|
|
66
|
+
/** A target as the host's script reads it: `X Y`, or a quoted "name" (`"` and `\` escaped). */
|
|
67
|
+
const wire = (p: Target) => typeof p === "string" ? `"${p.replace(/[\\"]/g, (ch) => "\\" + ch)}"` : `${p.x} ${p.y}`
|
|
68
|
+
/** `type` text as the host's `type` line takes it: the rest of the line, `\n` = enter, `\\` = a backslash. */
|
|
69
|
+
const wireText = (s: string) => s.replace(/\\/g, "\\\\").replace(/\r?\n/g, "\\n")
|
|
48
70
|
|
|
49
71
|
/** Translate scenario steps into the host's command file. `headroomFrames` runs first so the app
|
|
50
|
-
* can load (the `--frames` value); screenshots land under `outDir`.
|
|
51
|
-
*
|
|
52
|
-
|
|
72
|
+
* can load (the `--frames` value); screenshots land under `outDir`. `sim` = the windowless host
|
|
73
|
+
* (`--sim`): it has no UI, so names, `hover`, `type`, `fill` and `scroll` are refused there, it
|
|
74
|
+
* paints nothing, so per-step screenshots are refused too, and a tap/drag/hold reaches the SCENE's
|
|
75
|
+
* touch input directly. */
|
|
76
|
+
export const translateScenario = (rawSteps: unknown, opts: { outDir: string, headroomFrames: number, sim?: boolean }): DesktopPlan => {
|
|
53
77
|
if (!Array.isArray(rawSteps)) throw new CliError(`"steps" must be an array of step objects.`)
|
|
54
78
|
const lines: string[] = []
|
|
55
79
|
const steps: DesktopPlanStep[] = []
|
|
56
80
|
let budgetMs = 0
|
|
57
81
|
const emit = (line: string): number => { lines.push(line); return lines.length }
|
|
58
|
-
emit(`# generated by lecodes render --desktop --script`)
|
|
82
|
+
emit(`# generated by lecodes render ${opts.sim ? "--sim" : "--desktop"} --script`)
|
|
59
83
|
emit(`frames ${Math.max(0, opts.headroomFrames)}`)
|
|
60
84
|
|
|
61
|
-
const
|
|
85
|
+
const target = (at: string, key: string, v: unknown): Target => {
|
|
62
86
|
if (isPoint(v)) return v
|
|
63
87
|
if (typeof v === "string") {
|
|
64
|
-
throw new CliError(`${at}: "${key}"
|
|
88
|
+
if (v.length === 0) throw new CliError(`${at}: "${key}" is an empty name.`)
|
|
89
|
+
if (opts.sim) throw new CliError(`${at}: "${key}": "${v}" — the windowless host has no UI to resolve a name in; give --sim scripts { "x", "y" } coordinates (scene input), or run the scenario with --desktop / headless.`)
|
|
90
|
+
return v
|
|
65
91
|
}
|
|
66
|
-
throw new CliError(`${at}: "${key}" must be { "x", "y" }.`)
|
|
92
|
+
throw new CliError(`${at}: "${key}" must be a name or { "x", "y" }.`)
|
|
67
93
|
}
|
|
68
94
|
const num = (at: string, key: string, v: unknown, def?: number): number => {
|
|
69
95
|
if (v === undefined && def !== undefined) return def
|
|
@@ -84,10 +110,10 @@ export const translateScenario = (rawSteps: unknown, opts: { outDir: string, hea
|
|
|
84
110
|
const parts: string[] = []
|
|
85
111
|
const action = actions[0]
|
|
86
112
|
|
|
87
|
-
if (action === "type" || action === "fill" || action === "scroll") {
|
|
88
|
-
throw new CliError(`${at}: "${action}" isn't available with --
|
|
113
|
+
if ((action === "type" || action === "hover" || action === "fill" || action === "scroll") && opts.sim) {
|
|
114
|
+
throw new CliError(`${at}: "${action}" isn't available with --sim (the windowless host has no UI) — run this scenario with --desktop or headless.`)
|
|
89
115
|
}
|
|
90
|
-
if ((action === "tap" || action === "drag" || action === "hold") && inspecting) {
|
|
116
|
+
if ((action === "tap" || action === "drag" || action === "hold" || action === "hover" || action === "type") && inspecting) {
|
|
91
117
|
throw new CliError(`${at}: "${action}" while an inspection camera is active — the app's hit-testing uses its own camera, not the one in the shot. Add { "view": "off" } (or { "camera": "off" }) first.`)
|
|
92
118
|
}
|
|
93
119
|
if (action === "view") {
|
|
@@ -112,19 +138,52 @@ export const translateScenario = (rawSteps: unknown, opts: { outDir: string, hea
|
|
|
112
138
|
}
|
|
113
139
|
if (s.fov !== undefined && action !== "view") throw new CliError(`${at}: "fov" only goes with "view" (inside "camera" use camera.fov).`)
|
|
114
140
|
if (action === "tap") {
|
|
115
|
-
const p =
|
|
116
|
-
plan.lines.push(emit(`tap ${p
|
|
141
|
+
const p = target(at, "tap", s.tap)
|
|
142
|
+
plan.lines.push(emit(`tap ${wire(p)}`)); budgetMs += 4 * FRAME_MS
|
|
117
143
|
parts.push(`tap ${pt(p)}`)
|
|
144
|
+
} else if (action === "type") {
|
|
145
|
+
// The host types into the FOCUSED input: a tap on the target focuses it, then the characters go
|
|
146
|
+
// in one by one (appended — the headless session sets the value; on an empty input they agree).
|
|
147
|
+
const p = target(at, "type", s.type)
|
|
148
|
+
if (typeof s.text !== "string") throw new CliError(`${at}: "type" needs a "text" string.`)
|
|
149
|
+
plan.lines.push(emit(`tap ${wire(p)}`))
|
|
150
|
+
plan.lines.push(emit(`type ${wireText(s.text)}`)); budgetMs += 6 * FRAME_MS
|
|
151
|
+
parts.push(`type ${pt(p)}="${s.text}"`)
|
|
118
152
|
} else if (action === "drag") {
|
|
119
|
-
const a =
|
|
153
|
+
const a = target(at, "drag", s.drag), b = target(at, "to", s.to)
|
|
120
154
|
const ms = num(at, "ms", s.ms, 300)
|
|
121
|
-
plan.lines.push(emit(`drag ${a
|
|
155
|
+
plan.lines.push(emit(`drag ${wire(a)} ${wire(b)} ${Math.round(ms)}`)); budgetMs += ms + 4 * FRAME_MS
|
|
122
156
|
parts.push(`drag ${pt(a)} → ${pt(b)} over ${ms}ms`)
|
|
123
157
|
} else if (action === "hold") {
|
|
124
|
-
const p =
|
|
158
|
+
const p = target(at, "hold", s.hold)
|
|
125
159
|
const ms = num(at, "ms", s.ms)
|
|
126
|
-
plan.lines.push(emit(`hold ${p
|
|
160
|
+
plan.lines.push(emit(`hold ${wire(p)} ${Math.round(ms)}`)); budgetMs += ms + 4 * FRAME_MS
|
|
127
161
|
parts.push(`hold ${pt(p)} ${ms}ms`)
|
|
162
|
+
} else if (action === "hover") {
|
|
163
|
+
// The cursor over a node: Input.mouse.position + the UI hover (`$hovered`, onMouseEnter). The
|
|
164
|
+
// host completes it two frames later, after the enter / leave crossed into JS.
|
|
165
|
+
const p = target(at, "hover", s.hover)
|
|
166
|
+
plan.lines.push(emit(`mouseto ${wire(p)}`)); budgetMs += 3 * FRAME_MS
|
|
167
|
+
parts.push(`hover ${pt(p)}`)
|
|
168
|
+
} else if (action === "fill") {
|
|
169
|
+
const f = s.fill
|
|
170
|
+
if (typeof f !== "object" || f === null || ((f as { value?: unknown }).value !== undefined && typeof (f as { value?: unknown }).value !== "string")) {
|
|
171
|
+
throw new CliError(`${at}: "fill" must be {} or { "value": string }.`)
|
|
172
|
+
}
|
|
173
|
+
const value = (f as { value?: string }).value
|
|
174
|
+
plan.lines.push(emit(value !== undefined ? `fill ${wireText(value)}` : "fill")); budgetMs += 8 * 6 * FRAME_MS // room for a form of inputs
|
|
175
|
+
parts.push(value !== undefined ? `fill inputs ("${value}")` : "fill inputs")
|
|
176
|
+
} else if (action === "scroll") {
|
|
177
|
+
// `{ "scroll": 300 }` = the first scrollable; `{ "scroll": "feed", "by": 300 }` = the named one's.
|
|
178
|
+
if (typeof s.scroll === "number") {
|
|
179
|
+
if (s.by !== undefined) throw new CliError(`${at}: "by" only goes with a named "scroll".`)
|
|
180
|
+
plan.lines.push(emit(`scroll ${s.scroll}`)); parts.push(`scroll ${s.scroll}`)
|
|
181
|
+
} else if (typeof s.scroll === "string") {
|
|
182
|
+
if (typeof s.by !== "number") throw new CliError(`${at}: scrolling a named target needs "by" (px).`)
|
|
183
|
+
if (s.scroll.length === 0) throw new CliError(`${at}: "scroll" is an empty name.`)
|
|
184
|
+
plan.lines.push(emit(`scroll ${s.by} ${wire(s.scroll)}`)); parts.push(`scroll ${s.scroll} by ${s.by}`)
|
|
185
|
+
} else throw new CliError(`${at}: "scroll" must be a number (px) or a name (+ "by").`)
|
|
186
|
+
budgetMs += 2 * FRAME_MS
|
|
128
187
|
} else if (action === "key") {
|
|
129
188
|
if (typeof s.key !== "string") throw new CliError(`${at}: "key" must be a KeyboardEvent.code string.`)
|
|
130
189
|
const ms = num(at, "ms", s.ms)
|
|
@@ -144,7 +203,7 @@ export const translateScenario = (rawSteps: unknown, opts: { outDir: string, hea
|
|
|
144
203
|
} else if (action === "mouseTo") {
|
|
145
204
|
const v = s.mouseTo
|
|
146
205
|
if (!Array.isArray(v) || v.length !== 2 || !v.every((n: unknown) => typeof n === "number")) throw new CliError(`${at}: "mouseTo" must be [x, y] (logical px).`)
|
|
147
|
-
plan.lines.push(emit(`mouseto ${v[0]} ${v[1]}`)); budgetMs += FRAME_MS
|
|
206
|
+
plan.lines.push(emit(`mouseto ${v[0]} ${v[1]}`)); budgetMs += 3 * FRAME_MS
|
|
148
207
|
parts.push(`mouseTo ${v[0]},${v[1]}`)
|
|
149
208
|
} else if (action === "wheel") {
|
|
150
209
|
const v = s.wheel
|
|
@@ -184,29 +243,71 @@ export const translateScenario = (rawSteps: unknown, opts: { outDir: string, hea
|
|
|
184
243
|
if (typeof s.expect !== "object" || s.expect === null) throw new CliError(`${at}: "expect" must be an object.`)
|
|
185
244
|
const e = s.expect as Record<string, unknown>
|
|
186
245
|
const bits: string[] = []
|
|
246
|
+
let needDump = false
|
|
187
247
|
for (const k of Object.keys(e)) {
|
|
188
248
|
if (k === "log") {
|
|
189
249
|
if (typeof e.log !== "string") throw new CliError(`${at}: expect.log must be a string.`)
|
|
190
250
|
plan.expectLog = e.log
|
|
191
251
|
bits.push(`log~"${e.log}"`)
|
|
252
|
+
} else if (k === "paint") {
|
|
253
|
+
if (typeof e.paint !== "object" || e.paint === null) throw new CliError(`${at}: expect.paint must be an object of node names.`)
|
|
254
|
+
plan.expectPaint = e.paint as PaintExpect
|
|
255
|
+
needDump = true
|
|
256
|
+
bits.push(`paint(${Object.keys(e.paint).join(", ")})`)
|
|
192
257
|
} else if (SEMANTIC_EXPECT.includes(k)) {
|
|
258
|
+
if (k === "value") {
|
|
259
|
+
const v = e.value as { target?: unknown, equals?: unknown } | null
|
|
260
|
+
if (typeof v !== "object" || v === null || typeof v.target !== "string" || typeof v.equals !== "string") throw new CliError(`${at}: expect.value needs { "target": string, "equals": string }.`)
|
|
261
|
+
} else if (typeof e[k] !== "string") throw new CliError(`${at}: expect.${k} must be a string.`)
|
|
262
|
+
plan.expectSemantic = { ...(plan.expectSemantic ?? {}), [k]: e[k] }
|
|
263
|
+
needDump = true
|
|
264
|
+
const v = e[k]
|
|
265
|
+
bits.push(k === "screen" ? `screen=${v}` : k === "node" ? `node=${v}` : k === "notNode" ? `!node=${v}` : k === "text" ? `text~"${v}"` : k === "notText" ? `!text~"${v}"` : `${(v as { target: string }).target}="${(v as { equals: string }).equals}"`)
|
|
266
|
+
} else if (UNSUPPORTED_EXPECT.includes(k)) {
|
|
193
267
|
plan.skippedExpect.push(k)
|
|
194
268
|
} else {
|
|
195
269
|
throw new CliError(`${at}: unknown expect key "${k}".`)
|
|
196
270
|
}
|
|
197
271
|
}
|
|
272
|
+
if (needDump) {
|
|
273
|
+
plan.paintDump = resolve(opts.outDir, `.dump-${i}.json`)
|
|
274
|
+
// The tree dump lands one frame after the action settled, like a screenshot.
|
|
275
|
+
plan.lines.push(emit(`frames 1`))
|
|
276
|
+
plan.lines.push(emit(`dump ${plan.paintDump}`)); budgetMs += 2 * FRAME_MS
|
|
277
|
+
}
|
|
198
278
|
if (bits.length > 0) parts.push(`expect ${bits.join(" · ")}`)
|
|
199
279
|
}
|
|
200
|
-
if (s.clip !== undefined) throw new CliError(`${at}: "clip"
|
|
280
|
+
if (s.clip !== undefined && s.screenshot === undefined) throw new CliError(`${at}: "clip" only goes with "screenshot".`)
|
|
201
281
|
if (s.screenshot !== undefined) {
|
|
202
282
|
if (typeof s.screenshot !== "string") throw new CliError(`${at}: "screenshot" must be a path.`)
|
|
283
|
+
if (opts.sim) throw new CliError(`${at}: "screenshot" isn't available with --sim (the windowless host paints nothing) — judge the run by expect.log, or run it with --desktop for pixels.`)
|
|
203
284
|
plan.screenshot = resolve(opts.outDir, s.screenshot)
|
|
204
285
|
// The shot lands AFTER the action settles (two frames), like the headless act → capture order.
|
|
286
|
+
// A clip (a node's box or a logical-px rect) is set for the shot and cleared after it.
|
|
287
|
+
const clip = s.clip
|
|
288
|
+
if (clip !== undefined) {
|
|
289
|
+
if (typeof clip === "string") {
|
|
290
|
+
if (clip.length === 0) throw new CliError(`${at}: "clip" is an empty name.`)
|
|
291
|
+
plan.lines.push(emit(`frames 2`)) // the node's box after the action settled
|
|
292
|
+
plan.lines.push(emit(`clip ${wire(clip)}`))
|
|
293
|
+
} else if (typeof clip === "object" && clip !== null && ["x", "y", "width", "height"].every((k) => typeof (clip as Record<string, unknown>)[k] === "number")) {
|
|
294
|
+
const r = clip as { x: number, y: number, width: number, height: number }
|
|
295
|
+
plan.lines.push(emit(`clip ${r.x} ${r.y} ${r.width} ${r.height}`))
|
|
296
|
+
} else throw new CliError(`${at}: "clip" must be a node name or { "x", "y", "width", "height" }.`)
|
|
297
|
+
}
|
|
205
298
|
plan.lines.push(emit(`frames 2`))
|
|
206
|
-
plan.lines.push(emit(`shot ${plan.screenshot}`)); budgetMs +=
|
|
207
|
-
|
|
299
|
+
plan.lines.push(emit(`shot ${plan.screenshot}`)); budgetMs += 5 * FRAME_MS
|
|
300
|
+
if (clip !== undefined) plan.lines.push(emit(`clip off`))
|
|
301
|
+
parts.push(`→ ${s.screenshot}${clip !== undefined ? ` (clip ${typeof clip === "string" ? clip : "rect"})` : ""}`)
|
|
208
302
|
}
|
|
209
303
|
if (s.json !== undefined) plan.skippedOutputs.push("json")
|
|
304
|
+
if (s.dump !== undefined) {
|
|
305
|
+
if (typeof s.dump !== "string") throw new CliError(`${at}: "dump" must be a path.`)
|
|
306
|
+
plan.dump = resolve(opts.outDir, s.dump)
|
|
307
|
+
plan.lines.push(emit(`frames 1`))
|
|
308
|
+
plan.lines.push(emit(`dump ${plan.dump}`)); budgetMs += 2 * FRAME_MS
|
|
309
|
+
parts.push(`→ ${s.dump} (dump)`)
|
|
310
|
+
}
|
|
210
311
|
if (plan.lines.length === 0) {
|
|
211
312
|
// An expect-only step still needs a marker to anchor its evaluation.
|
|
212
313
|
plan.lines.push(emit(`frames 1`)); budgetMs += FRAME_MS
|
|
@@ -230,7 +331,7 @@ export type DesktopRunResult = {
|
|
|
230
331
|
|
|
231
332
|
/** Turn the host's stdout into per-step results. Console lines are every non-`[script]` line the
|
|
232
333
|
* host printed up to a step's completion marker (the core prints `console.log` as `[DEBUG] …`). */
|
|
233
|
-
export const evaluateDesktopRun = (plan: DesktopPlan, output: string): DesktopRunResult => {
|
|
334
|
+
export const evaluateDesktopRun = (plan: DesktopPlan, output: string, readFile: (path: string) => string | null = () => null): DesktopRunResult => {
|
|
234
335
|
const lines = output.split(/\r?\n/)
|
|
235
336
|
const doneAt = new Map<number, { at: number, error?: string }>()
|
|
236
337
|
const consoleLines: string[] = []
|
|
@@ -251,8 +352,8 @@ export const evaluateDesktopRun = (plan: DesktopPlan, output: string): DesktopRu
|
|
|
251
352
|
let ok = true
|
|
252
353
|
for (const st of plan.steps) {
|
|
253
354
|
const skipped = [
|
|
254
|
-
...st.skippedExpect.map((k) => `expect.${k} (
|
|
255
|
-
...st.skippedOutputs.map((k) => `${k} output (
|
|
355
|
+
...st.skippedExpect.map((k) => `expect.${k} (the headless audio recorder's — not on a native host)`),
|
|
356
|
+
...st.skippedOutputs.map((k) => `${k} output (the headless renderer's shape — use "dump" on a native host)`),
|
|
256
357
|
]
|
|
257
358
|
let error: string | undefined
|
|
258
359
|
let cutoff = consoleLines.length
|
|
@@ -262,6 +363,20 @@ export const evaluateDesktopRun = (plan: DesktopPlan, output: string): DesktopRu
|
|
|
262
363
|
if (d.error) { error = error ?? d.error; break }
|
|
263
364
|
cutoff = d.at
|
|
264
365
|
}
|
|
366
|
+
if (!error && (st.expectPaint !== undefined || st.expectSemantic !== undefined)) {
|
|
367
|
+
const text = st.paintDump ? readFile(st.paintDump) : null
|
|
368
|
+
if (text === null) error = "the host wrote no tree dump for the expect (an older host without the `dump` command?)"
|
|
369
|
+
else {
|
|
370
|
+
try {
|
|
371
|
+
const dump = parseTreeDump(text)
|
|
372
|
+
const failures = [
|
|
373
|
+
...(st.expectSemantic !== undefined ? evaluateDumpExpect(dump, st.expectSemantic) : []),
|
|
374
|
+
...(st.expectPaint !== undefined ? evaluatePaintExpect(dump, st.expectPaint) : []),
|
|
375
|
+
]
|
|
376
|
+
if (failures.length > 0) error = failures.join("; ")
|
|
377
|
+
} catch (e) { error = `expect: ${e instanceof Error ? e.message : String(e)}` }
|
|
378
|
+
}
|
|
379
|
+
}
|
|
265
380
|
if (!error && st.expectLog !== undefined) {
|
|
266
381
|
const seen = consoleLines.slice(0, cutoff)
|
|
267
382
|
if (!seen.some((l) => l.includes(st.expectLog!))) {
|
package/src/hosts/distRoot.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url"
|
|
|
8
8
|
* - npm package: the bundle is dist/index.js, companions at ../runtime.
|
|
9
9
|
* - monorepo dev: this module runs from src/hosts/, so the package root is two levels up. The
|
|
10
10
|
* package.json marker matters here — runtime/ is gitignored and absent until vendor-runtime runs,
|
|
11
|
-
* and callers also resolve siblings from the root (
|
|
11
|
+
* and callers also resolve siblings from the root (../../engines/gl).
|
|
12
12
|
* - standalone binary (`bun build --compile`, see scripts/build-bin.ts): import.meta.url points into
|
|
13
13
|
* the virtual bunfs archive which does NOT exist on disk, so the companions ship in the release
|
|
14
14
|
* archive NEXT TO the executable and are resolved via process.execPath. */
|
package/src/hosts/matcTool.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { CliError, note } from "../cli"
|
|
|
14
14
|
* releases/versions.json — published by scripts/release/release-windows.ps1 / release-ubuntu.sh
|
|
15
15
|
* from the desktop build's in-tree filament host tools, so it always matches the engines the
|
|
16
16
|
* hosts ship). Cache: ~/.lecodes/matc/matc-<version>/. LECODES_MATC overrides resolution entirely
|
|
17
|
-
* (local dev: point it at
|
|
17
|
+
* (local dev: point it at hosts/desktop/build/creator-gl/filament/tools/matc/matc.exe).
|
|
18
18
|
*
|
|
19
19
|
* No version pin: a filamat's compatibility is tied to the vendored Filament, which changes rarely —
|
|
20
20
|
* resolution takes the newest cached copy, else downloads the newest release PUBLISHED FOR THIS
|
package/src/hosts/peerInstall.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { CliError, c, confirm, log, note } from "../cli"
|
|
|
8
8
|
|
|
9
9
|
/*
|
|
10
10
|
* Peers install themselves on first use. `lecodes render` on a machine with only the CLI asks once,
|
|
11
|
-
* then puts lecodes-
|
|
11
|
+
* then puts lecodes-headless (+ @napi-rs/canvas for PNG) into ~/.lecodes/peers and loads it from
|
|
12
12
|
* there — the same pattern as matc and the desktop host (downloaded into ~/.lecodes on first use),
|
|
13
13
|
* applied to npm packages. Why a private prefix and not `npm install -g`: no sudo / system prefix
|
|
14
14
|
* to guess, and the runtime-set peer is installed at EXACTLY this CLI's version — the compiler and the
|
|
@@ -31,9 +31,9 @@ const readJson = (path: string): any => { try { return JSON.parse(readFileSync(p
|
|
|
31
31
|
/** The npm spec a peer installs at: the renderer at exactly this CLI's version (one ABI set), canvas
|
|
32
32
|
* at the range the installed renderer declares, the editors / assets at this CLI's peer ranges. */
|
|
33
33
|
export const peerSpec = (name: string): string => {
|
|
34
|
-
if (name === "lecodes-
|
|
34
|
+
if (name === "lecodes-headless") return `lecodes-headless@${pkg.version}`
|
|
35
35
|
if (name === "@napi-rs/canvas") {
|
|
36
|
-
const renderer = readJson(join(peerDirInPrefix("lecodes-
|
|
36
|
+
const renderer = readJson(join(peerDirInPrefix("lecodes-headless"), "package.json"))
|
|
37
37
|
const range = renderer?.peerDependencies?.["@napi-rs/canvas"] ?? renderer?.optionalDependencies?.["@napi-rs/canvas"] ?? CANVAS_RANGE_FALLBACK
|
|
38
38
|
return `@napi-rs/canvas@${range}`
|
|
39
39
|
}
|
|
@@ -42,7 +42,7 @@ export const peerSpec = (name: string): string => {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/** Peers that only make sense together: the renderer's PNG output needs canvas next to it. */
|
|
45
|
-
const companions = (name: string): string[] => name === "lecodes-
|
|
45
|
+
const companions = (name: string): string[] => name === "lecodes-headless" ? ["lecodes-headless", "@napi-rs/canvas"] : [name]
|
|
46
46
|
|
|
47
47
|
const onPath = (cmd: string): boolean => {
|
|
48
48
|
try { return spawnSync(cmd, ["--version"], { stdio: "ignore", shell: process.platform === "win32" }).status === 0 } catch { return false }
|
|
@@ -66,7 +66,7 @@ export const installPeers = async (names: string[], why: string, opts: { yes?: b
|
|
|
66
66
|
}, null, 2) + "\n")
|
|
67
67
|
}
|
|
68
68
|
// The renderer must be in place before canvas's range can be read off it — install in order.
|
|
69
|
-
const ordered = [...names].sort((a, b) => (a === "lecodes-
|
|
69
|
+
const ordered = [...names].sort((a, b) => (a === "lecodes-headless" ? -1 : b === "lecodes-headless" ? 1 : 0))
|
|
70
70
|
const specsNow = ordered.map(peerSpec)
|
|
71
71
|
|
|
72
72
|
const yes = opts.yes === true || !!process.env.LECODES_YES
|