lecodes-cli 2.0.6 → 2.0.8
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 +1813 -1121
- package/package.json +21 -11
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/VERSION.json +3 -3
- package/runtime/web/player.js +8 -8
- package/runtime/web-lite/assets/vlist-CwSYpLz-.js +1 -0
- package/runtime/web-lite/features.json +1 -1
- package/runtime/web-lite/lite.js +2 -2
- package/src/commands/app/android.ts +7 -7
- package/src/commands/app/index.ts +58 -84
- package/src/commands/app/shared.ts +53 -74
- package/src/commands/app/templates/ios.ts +75 -78
- package/src/commands/compile.ts +1 -1
- package/src/commands/init/templates.ts +7 -0
- package/src/commands/render.ts +17 -4
- package/src/commands/test.ts +10 -6
- package/src/compile/collect.ts +12 -3
- package/src/compile/headlessBundle.ts +10 -3
- package/src/compile/projectCompile.ts +11 -7
- package/src/declarations/lecodes-headless.d.ts +3 -0
- package/src/dev/backendWorker.ts +17 -6
- package/src/dev/localBackend.ts +8 -2
- package/src/dev/projectBackend.ts +31 -9
- package/src/hosts/desktopScript.ts +11 -1
- package/src/hosts/flowPick.ts +111 -0
- package/src/hosts/liteBrowser.mjs +5 -0
- package/src/hosts/liteRunner.ts +30 -0
- package/src/hosts/peerInstall.ts +5 -1
- package/src/hosts/peers.ts +2 -0
- package/runtime/web-lite/assets/vlist-DrV2Q7P5.js +0 -1
|
@@ -482,48 +482,19 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|
|
482
482
|
|
|
483
483
|
export const VIEW_CONTROLLER = `//
|
|
484
484
|
// ViewController.swift
|
|
485
|
-
// Generated by lecodes app init — yours to edit.
|
|
485
|
+
// Generated by lecodes app init — yours to edit; \`lecodes app sync\` never touches it.
|
|
486
486
|
//
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
//
|
|
487
|
+
// The shell's controller: LeCodesAppViewController (LeCodesRuntime, CLI-owned) boots the engine
|
|
488
|
+
// with the bundled app.js — offline, the plugins of app.json registered, newer bundles taken over
|
|
489
|
+
// the air (app.json "update"). Override \`configure(_:)\` to register your own native code
|
|
490
|
+
// (\`engine.registerService\`, \`engine.registerView\`) before the bundle runs, or any UIViewController
|
|
491
|
+
// hook to put native UI around the app's root view.
|
|
490
492
|
//
|
|
491
493
|
|
|
492
494
|
import UIKit
|
|
493
495
|
import LeCodesRuntime
|
|
494
496
|
|
|
495
|
-
class ViewController:
|
|
496
|
-
|
|
497
|
-
override func viewDidLoad() {
|
|
498
|
-
super.viewDidLoad()
|
|
499
|
-
|
|
500
|
-
let lang = Locale.preferredLanguages.first.map { String($0.prefix(2)) } ?? "en"
|
|
501
|
-
engine.setLanguage(lang)
|
|
502
|
-
|
|
503
|
-
// \`// preload:\` assets (images, fonts) resolve out of the app bundle.
|
|
504
|
-
engine.setFetchLocal { path in
|
|
505
|
-
let ns = path as NSString
|
|
506
|
-
let ext = ns.pathExtension
|
|
507
|
-
let name = ns.deletingPathExtension
|
|
508
|
-
guard let url = Bundle.main.url(forResource: name, withExtension: ext.isEmpty ? nil : ext) else {
|
|
509
|
-
return nil
|
|
510
|
-
}
|
|
511
|
-
return try? Data(contentsOf: url)
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
registerBundledPlugins(in: engine)
|
|
515
|
-
|
|
516
|
-
// OTA (native, no JS involved): start the check FIRST — even a bundle that crashes
|
|
517
|
-
// on boot can never block its own fix — then run the newest local bundle (a
|
|
518
|
-
// downloaded update, or the embedded app.js). New code applies on the next launch.
|
|
519
|
-
// Configured via app.json "update" (→ Info.plist LeCodesUpdateURL); offline: no-op.
|
|
520
|
-
LeCodesUpdater.checkForUpdate()
|
|
521
|
-
guard let code = LeCodesUpdater.activeBundle() else {
|
|
522
|
-
print("LeCodes: bundled app.js is missing — run \`lecodes app sync\`")
|
|
523
|
-
return
|
|
524
|
-
}
|
|
525
|
-
engine.run(code)
|
|
526
|
-
}
|
|
497
|
+
class ViewController: LeCodesAppViewController {
|
|
527
498
|
}
|
|
528
499
|
`
|
|
529
500
|
|
|
@@ -702,19 +673,32 @@ export interface RuntimePlugin {
|
|
|
702
673
|
packages?: IosPackage[]
|
|
703
674
|
}
|
|
704
675
|
|
|
705
|
-
/** The SDK
|
|
706
|
-
* 2d =
|
|
676
|
+
/** The SDK's engine variants — one tag of lecodes-ios-sdk per variant (`<version>` = full,
|
|
677
|
+
* `<version>-3d`, `-2d`, `-core`): core = UI only, 2d = + the 2D engine, 3d = + Filament with Jolt
|
|
678
|
+
* (AR included), full = everything. The Swift sources are the same; the engine binary differs. */
|
|
707
679
|
export type SdkVariant = "core" | "2d" | "3d" | "full"
|
|
708
680
|
|
|
709
|
-
/**
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
*
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
681
|
+
/** The tag of lecodes-ios-sdk a shell pins for an SDK version + variant. */
|
|
682
|
+
export const sdkTag = (sdkVersion: string, variant: SdkVariant): string => variant === "full" ? sdkVersion : `${sdkVersion}-${variant}`
|
|
683
|
+
|
|
684
|
+
/** The SDK products a shell links: `LeCodes` always; the 3D variants also AR (ARKit behind the
|
|
685
|
+
* scenes) and the binaural filters (`engine.useHrtf()`), as the viewer does. */
|
|
686
|
+
export const sdkProducts = (variant: SdkVariant): string[] =>
|
|
687
|
+
variant === "full" || variant === "3d" ? ["LeCodes", "LeCodesAR", "LeCodesHRTF"] : ["LeCodes"]
|
|
688
|
+
|
|
689
|
+
/** LeCodesRuntime/Package.swift — the CLI-owned dependency graph: the SDK (one tag per
|
|
690
|
+
* variant, pinned with `.exact(…)`: the variant is the engine binary the tag's manifest names,
|
|
691
|
+
* so SwiftPM must never drift on its own — moving the version is the CLI's job, `lecodes app
|
|
692
|
+
* update` or app.json ios.sdk) + one vendored target per installed plugin. The SDK's assets
|
|
693
|
+
* (the 3D variants' shader archives) ride inside the package. */
|
|
694
|
+
export const runtimePackageTemplate = (sdkVersion: string, variant: SdkVariant, plugins: RuntimePlugin[], sdkPath?: string): string => {
|
|
695
|
+
const sdkDep = (name: string) => `.product(name: "${name}", package: "lecodes-ios-sdk")`
|
|
696
|
+
// LECODES_IOS_SDK_PATH: the SDK-development loop — a package tree on disk (release-ios-sdk.sh
|
|
697
|
+
// --check assembles one per variant) in place of the published tag; `name:` keeps the products'
|
|
698
|
+
// package identity so nothing else in this manifest changes.
|
|
699
|
+
const sdkPackage = sdkPath
|
|
700
|
+
? `.package(name: "lecodes-ios-sdk", path: "${sdkPath}"), // LECODES_IOS_SDK_PATH — not a release`
|
|
701
|
+
: `.package(url: "https://github.com/letary/lecodes-ios-sdk.git", exact: "${sdkTag(sdkVersion, variant)}"),`
|
|
718
702
|
// Third-party Swift packages (manifest ios.packages), one `.package(…)` per url. Two plugins
|
|
719
703
|
// naming the same url must agree on the pin — SwiftPM has one resolution per package.
|
|
720
704
|
const packages = new Map<string, IosPackage>()
|
|
@@ -731,7 +715,7 @@ export const runtimePackageTemplate = (sdkVersion: string, variant: SdkVariant,
|
|
|
731
715
|
`\n .package(url: "${pkg.url}", ${pkg.exact ? `exact: "${pkg.exact}"` : `from: "${pkg.from}"`}),`).join("")
|
|
732
716
|
const pluginTargets = plugins.map(p => {
|
|
733
717
|
const deps = [
|
|
734
|
-
|
|
718
|
+
sdkDep("LeCodes"),
|
|
735
719
|
...(p.packages ?? []).flatMap(pkg => pkg.products.map(name => `.product(name: "${name}", package: "${swiftPackageName(pkg.url)}")`)),
|
|
736
720
|
]
|
|
737
721
|
return `
|
|
@@ -742,14 +726,14 @@ export const runtimePackageTemplate = (sdkVersion: string, variant: SdkVariant,
|
|
|
742
726
|
path: "Sources/${p.module}"
|
|
743
727
|
),`
|
|
744
728
|
}).join("")
|
|
745
|
-
const runtimeDeps = [
|
|
729
|
+
const runtimeDeps = [...sdkProducts(variant).map(sdkDep), ...plugins.map(p => `"${p.module}"`)]
|
|
746
730
|
return `// swift-tools-version:5.9
|
|
747
731
|
//
|
|
748
732
|
// GENERATED by lecodes — regenerated by \`lecodes app sync\`; do not edit.
|
|
749
|
-
// Owns the app's native dependency graph: the
|
|
750
|
-
//
|
|
751
|
-
//
|
|
752
|
-
//
|
|
733
|
+
// Owns the app's native dependency graph: the SDK's tag (the release of app.json ios.sdk — move
|
|
734
|
+
// it with \`lecodes app update\` — and the engine variant, auto-picked from what the compiled
|
|
735
|
+
// bundle uses or forced via app.json ios.variant) and one vendored target per installed plugin
|
|
736
|
+
// (see plugins.lock.json).
|
|
753
737
|
|
|
754
738
|
import PackageDescription
|
|
755
739
|
|
|
@@ -760,7 +744,7 @@ let package = Package(
|
|
|
760
744
|
.library(name: "LeCodesRuntime", targets: ["LeCodesRuntime"]),
|
|
761
745
|
],
|
|
762
746
|
dependencies: [
|
|
763
|
-
|
|
747
|
+
${sdkPackage}${packageDecls}
|
|
764
748
|
],
|
|
765
749
|
targets: [
|
|
766
750
|
.target(
|
|
@@ -775,31 +759,43 @@ let package = Package(
|
|
|
775
759
|
`
|
|
776
760
|
}
|
|
777
761
|
|
|
778
|
-
/** Sources/LeCodesRuntime/LeCodesRuntime.swift — SDK re-
|
|
779
|
-
*
|
|
780
|
-
*
|
|
781
|
-
*
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
762
|
+
/** Sources/LeCodesRuntime/LeCodesRuntime.swift — the SDK re-exported (app code needs only
|
|
763
|
+
* \`import LeCodesRuntime\`) and the shell's controller: LeCodesAppViewController, a
|
|
764
|
+
* LeCodesShellViewController (the SDK's — the boot: language, the OTA check, the newest local
|
|
765
|
+
* bundle) whose \`configure\` registers every plugin of app.json, the AR kinds of a 3D variant and
|
|
766
|
+
* (server-backed projects) the baked project identity — all BEFORE the bundle runs, in a
|
|
767
|
+
* CLI-owned file: a plain \`lecodes app sync\` moves every shell, the user-owned ViewController
|
|
768
|
+
* (a subclass of this one) is never edited. */
|
|
769
|
+
export const runtimeSwiftTemplate = (plugins: RuntimePlugin[], projectUuid: string | undefined, variant: SdkVariant): string => {
|
|
770
|
+
const is3d = variant === "full" || variant === "3d"
|
|
771
|
+
const imports = [...(is3d ? ["import LeCodesAR\n", "import LeCodesHRTF\n"] : []), ...plugins.map(p => `import ${p.module}\n`)].join("")
|
|
772
|
+
const identity = !projectUuid ? "" : ` // World identity: this shell IS the project — the engine attributes the boot world so
|
|
773
|
+
// identity-gated services (push registration) see the app's uuid from its first
|
|
774
|
+
// synchronous statement. Baked from .lecodes/manifest.json by \`lecodes app sync\`.
|
|
775
|
+
engine.bootProjectUuid = "${projectUuid}"
|
|
788
776
|
`
|
|
789
|
-
const
|
|
790
|
-
|
|
777
|
+
const ar = !is3d ? "" : ` // The AR controller kinds of the 3D engine (ARKit), and the binaural audio filters.
|
|
778
|
+
for mode in ["default", "world", "arcore", "detached"] { engine.registerARController(mode) { ARKitController(.world) } }
|
|
779
|
+
engine.registerARController("markers") { ARKitController(.markers) }
|
|
780
|
+
engine.useHrtf()
|
|
781
|
+
`
|
|
782
|
+
const calls = plugins.map(p => ` ${p.register}.register(in: engine)\n`).join("")
|
|
783
|
+
const body = identity + ar + calls
|
|
791
784
|
return `//
|
|
792
785
|
// LeCodesRuntime.swift
|
|
793
786
|
// GENERATED by lecodes — regenerated by \`lecodes app sync\`; do not edit.
|
|
794
787
|
//
|
|
795
|
-
// Re-exports the SDK (so app code needs only \`import LeCodesRuntime\`) and
|
|
796
|
-
//
|
|
788
|
+
// Re-exports the SDK (so app code needs only \`import LeCodesRuntime\`) and gives the app its
|
|
789
|
+
// controller: the SDK's shell controller with this project's plugins, AR kinds and identity
|
|
790
|
+
// registered before the bundle runs. App/ViewController.swift subclasses it.
|
|
797
791
|
//
|
|
798
792
|
|
|
799
|
-
@_exported import
|
|
793
|
+
@_exported import LeCodes
|
|
800
794
|
${imports}
|
|
801
|
-
|
|
802
|
-
|
|
795
|
+
open class LeCodesAppViewController: LeCodesShellViewController {
|
|
796
|
+
open override func configure(_ engine: LeCodesEngine) {
|
|
797
|
+
${body === "" ? " // no plugins installed (app.json \"plugins\")\n" : body} }
|
|
798
|
+
}
|
|
803
799
|
`
|
|
804
800
|
}
|
|
805
801
|
|
|
@@ -859,18 +855,19 @@ CLI-owned — regenerated on every sync, hand edits are overwritten:
|
|
|
859
855
|
assets (videos, big models) back into streaming from the server instead.
|
|
860
856
|
- \`App/Info.plist\` — template + the installed plugins' usage keys
|
|
861
857
|
- \`App/App.entitlements\` — the capabilities the installed plugins declare (e.g. push)
|
|
862
|
-
- \`LeCodesRuntime/\` — local Swift package: the
|
|
863
|
-
sources (Sources/<Module>/), and
|
|
858
|
+
- \`LeCodesRuntime/\` — local Swift package: the lecodes-ios-sdk dependency (one tag per engine
|
|
859
|
+
variant), vendored plugin sources (Sources/<Module>/), and LeCodesAppViewController — the SDK's
|
|
860
|
+
shell controller with this project's plugins registered (App/ViewController.swift subclasses it)
|
|
864
861
|
- the version values inside \`App.xcodeproj/project.pbxproj\` (from app.json)
|
|
865
862
|
- this README
|
|
866
863
|
|
|
867
864
|
## SDK variant
|
|
868
865
|
|
|
869
|
-
Sync
|
|
870
|
-
\`core\` (UI only), \`2d\` (+ 2D engine with physics), \`3d\` (+ 3D/AR engine with
|
|
871
|
-
~
|
|
872
|
-
|
|
873
|
-
to pin one instead.
|
|
866
|
+
Sync pins the SDK tag of the smallest engine variant that covers what the compiled bundle actually
|
|
867
|
+
uses: \`core\` (UI only), \`2d\` (+ the 2D engine with physics), \`3d\` (+ the 3D/AR engine with
|
|
868
|
+
physics, ~10 MB heavier), or \`full\` (everything) — \`<version>-core\`, \`-2d\`, \`-3d\`, and the
|
|
869
|
+
bare \`<version>\` for full. Adding your first 3D scene or 2D game to the project switches it
|
|
870
|
+
automatically on the next sync; set \`ios.variant\` in \`../app.json\` to pin one instead.
|
|
874
871
|
|
|
875
872
|
## Over-the-air updates
|
|
876
873
|
|
package/src/commands/compile.ts
CHANGED
|
@@ -143,7 +143,7 @@ export const buildBundle = async (bundle: BundleOptions, opts: BuildBundleOption
|
|
|
143
143
|
const project = await getProject(apiUrl, token, manifest.uuid)
|
|
144
144
|
// Local-asset compiles use URL-less resources as-is instead of degrading, so skip that warning
|
|
145
145
|
// there (an unpushed resources.remote match still warns below).
|
|
146
|
-
;({ entries, warnings } = collectEntries(root, manifest, project, { warnMissingUrl: !localAssets }))
|
|
146
|
+
;({ entries, warnings } = collectEntries(root, manifest, project, { warnMissingUrl: !localAssets, localShaders: localAssets && (opts.localShaders ?? !shell) }))
|
|
147
147
|
name = manifest.name
|
|
148
148
|
publicUrl = bundle.publicUrl ?? apiUrl
|
|
149
149
|
note(`Resolved ${entries.length} entries in ${((performance.now() - tResolve) / 1000).toFixed(1)} s`)
|
|
@@ -118,6 +118,13 @@ assertion channel for game state that has no UI.
|
|
|
118
118
|
\`{ status?, json?, text? }\`; \`*\` wildcards full-match, plain strings substring-match, first
|
|
119
119
|
match wins). \`lecodes test\` auto-loads it for design-derived flows; a scenario file opts in
|
|
120
120
|
with \`"fixtures": "fixtures.json"\` in its envelope.
|
|
121
|
+
- A project with \`*.server.ts\` files: \`lecodes test\` runs its backend for the run, on a database
|
|
122
|
+
of its own that is EMPTY before every case (a flow signs up, it never finds yesterday's data). A
|
|
123
|
+
step waits for the server calls it started — no \`wait\` after a tap that saves.
|
|
124
|
+
- A file dialog (\`openFilePicker\`) is answered by \`pick\`, on the step whose action opens it:
|
|
125
|
+
\`{ "tap": "attach", "pick": "cat.png" }\` — a file beside the flow file, a list of them, or a
|
|
126
|
+
file made for the run: \`{ "name": "me.png", "width": 400, "height": 300 }\`,
|
|
127
|
+
\`{ "name": "notes.txt", "text": "hello" }\`. Without \`pick\` the dialog is cancelled.
|
|
121
128
|
- \`fill\` fills every empty input — use it before tapping a disabled-until-filled submit.
|
|
122
129
|
- Visual state is a \`$class\` in the style (\`$pressed\`, \`$hovered\`, \`$focused\`, your own
|
|
123
130
|
\`$selected\` set with \`el.class.selected = true\` or bound with \`.class({ selected: () => … })\`);
|
package/src/commands/render.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { screenTargetFrom } from "../compile/screenEntry"
|
|
|
5
5
|
import { findProjectRoot } from "../project/manifest"
|
|
6
6
|
import { buildBundle } from "./compile"
|
|
7
7
|
import { runBackend } from "../dev/projectBackend"
|
|
8
|
+
import { resolvePicks } from "../hosts/flowPick"
|
|
8
9
|
import { desktopDefaultViewport, desktopPluginLibraries, readAppConfigAt, resolveDesktopRenderer } from "./app/shared"
|
|
9
10
|
import { runDesktopRender } from "../hosts/desktopRenderer"
|
|
10
11
|
import { evaluateDesktopRun, translateScenario } from "../hosts/desktopScript"
|
|
@@ -156,11 +157,11 @@ const readScenarioFile = (path: string): ScenarioFile => {
|
|
|
156
157
|
} catch (e) {
|
|
157
158
|
throw new CliError(`Can't read scenario "${path}": ${e instanceof Error ? e.message : String(e)}`)
|
|
158
159
|
}
|
|
159
|
-
if (Array.isArray(raw)) return { steps: raw } // a bare step array is a valid scenario file
|
|
160
|
+
if (Array.isArray(raw)) return { steps: resolvePicks(raw, dirname(resolve(path))) } // a bare step array is a valid scenario file
|
|
160
161
|
if (typeof raw !== "object" || raw === null) throw new CliError(`Scenario "${path}" must be a JSON object with "steps" (or a bare step array).`)
|
|
161
162
|
const file = raw as ScenarioFile
|
|
162
163
|
if (file.steps === undefined) throw new CliError(`Scenario "${path}" has no "steps".`)
|
|
163
|
-
return file
|
|
164
|
+
return { ...file, steps: resolvePicks(file.steps, dirname(resolve(path))) }
|
|
164
165
|
}
|
|
165
166
|
|
|
166
167
|
/** `--clip <name|x,y,w,h>`: a node name (its layout rect) or an explicit logical-px rect. */
|
|
@@ -354,7 +355,18 @@ const renderHeadless = async (flags: RenderFlags, file: string | undefined): Pro
|
|
|
354
355
|
}, timeoutMs)
|
|
355
356
|
killer.unref?.()
|
|
356
357
|
|
|
357
|
-
|
|
358
|
+
// The project's backend, when it has one (the database of `lecodes dev`). Beside a running
|
|
359
|
+
// `lecodes dev`, which holds that database, the picture is still made — without the backend.
|
|
360
|
+
const run = runBackend({ root: projectRoot, command: "lecodes render", log: (msg) => { if (flags.logs || msg.includes("[server:error]")) logErr(msg) } })
|
|
361
|
+
const backend: typeof run = {
|
|
362
|
+
...run,
|
|
363
|
+
start: (entries, onWarning) => run.start(entries, onWarning).catch((e: Error) => {
|
|
364
|
+
if (!/is in use/.test(e.message)) throw e
|
|
365
|
+
warnErr(`${e.message} Rendering without the backend: every call to the server fails.`)
|
|
366
|
+
return undefined
|
|
367
|
+
}),
|
|
368
|
+
}
|
|
369
|
+
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 })
|
|
358
370
|
const renderer = await requireRenderer()
|
|
359
371
|
|
|
360
372
|
// Flags override the scenario file's envelope; both fall back to the project default (a
|
|
@@ -385,6 +397,7 @@ const renderHeadless = async (flags: RenderFlags, file: string | undefined): Pro
|
|
|
385
397
|
const timing = {
|
|
386
398
|
settleMs: flags.settle ?? scenario?.settle,
|
|
387
399
|
waitNetwork: flags["wait-network"],
|
|
400
|
+
backend: serverUrl,
|
|
388
401
|
localAssets: useLocalAssets,
|
|
389
402
|
fixtures,
|
|
390
403
|
timeMs: flags.time,
|
|
@@ -395,7 +408,7 @@ const renderHeadless = async (flags: RenderFlags, file: string | undefined): Pro
|
|
|
395
408
|
|
|
396
409
|
// Clean process exit: clear the timeout guard, flush the given output, then exit. Without this the
|
|
397
410
|
// process could linger (e.g. on stdout pipe back-pressure) even though the render is done.
|
|
398
|
-
const done = (flush: (cb: () => void) => void, code = 0) => flush(() => { clearTimeout(killer); process.exit(code) })
|
|
411
|
+
const done = (flush: (cb: () => void) => void, code = 0) => flush(() => { clearTimeout(killer); void backend.close().finally(() => process.exit(code)) })
|
|
399
412
|
|
|
400
413
|
// --script: drive the compiled app through the scenario steps against a live session.
|
|
401
414
|
if (scenario) {
|
package/src/commands/test.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { desktopDefaultViewport, desktopPluginLibraries, resolveDesktopRenderer
|
|
|
5
5
|
import { desktopRenderFlags, fileScenarioIO, requireRenderer, resolveFixtures, resolveSafeAreaChoice, viewportFlags } from "./render"
|
|
6
6
|
import { buildBundle } from "./compile"
|
|
7
7
|
import { runBackend } from "../dev/projectBackend"
|
|
8
|
+
import { resolvePicks } from "../hosts/flowPick"
|
|
8
9
|
import { findProjectRoot } from "../project/manifest"
|
|
9
10
|
import { runDesktopRender } from "../hosts/desktopRenderer"
|
|
10
11
|
import { evaluateDesktopRun, translateScenario } from "../hosts/desktopScript"
|
|
@@ -133,13 +134,13 @@ const readFlowFile = (abs: string, displayName: string): FlowCase => {
|
|
|
133
134
|
} catch (e) {
|
|
134
135
|
throw new CliError(`Can't read "${displayName}": ${e instanceof Error ? e.message : String(e)}`)
|
|
135
136
|
}
|
|
136
|
-
if (Array.isArray(raw)) return { name: displayName, kind: "file", steps: raw }
|
|
137
|
+
if (Array.isArray(raw)) return { name: displayName, kind: "file", steps: resolvePicks(raw, dirname(abs)) }
|
|
137
138
|
if (typeof raw !== "object" || raw === null || (raw as { steps?: unknown }).steps === undefined) {
|
|
138
139
|
throw new CliError(`"${displayName}" must be a JSON object with "steps" (or a bare step array).`)
|
|
139
140
|
}
|
|
140
141
|
const file = raw as { name?: string, width?: number, height?: number, settle?: number, device?: string, safeArea?: string, fixtures?: Record<string, unknown> | string, steps: unknown }
|
|
141
142
|
return {
|
|
142
|
-
name: file.name ?? displayName, kind: "file", steps: file.steps,
|
|
143
|
+
name: file.name ?? displayName, kind: "file", steps: resolvePicks(file.steps, dirname(abs)),
|
|
143
144
|
fixtures: resolveFixtures(file.fixtures, dirname(abs)),
|
|
144
145
|
width: file.width, height: file.height, settle: file.settle, device: file.device, safeArea: file.safeArea,
|
|
145
146
|
}
|
|
@@ -170,7 +171,7 @@ export default defineCommand({
|
|
|
170
171
|
name: "test",
|
|
171
172
|
summary: "Run the flow tests (tests/*.flow.json + design-derived flows)",
|
|
172
173
|
usage: "[name…]",
|
|
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>/.
|
|
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).",
|
|
174
175
|
flags: testFlags,
|
|
175
176
|
examples: ["lecodes test", "lecodes test hero --logs", "lecodes test --flows --json", "lecodes test --desktop"],
|
|
176
177
|
run: ({ args, flags }) => runTests(args, flags),
|
|
@@ -193,9 +194,10 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
|
|
|
193
194
|
try { desktopRoot = findProjectRoot(process.cwd()) } catch { desktopRoot = process.cwd() }
|
|
194
195
|
const desktopRenderer = desktop ? resolveDesktopRenderer(desktopRoot, flags.renderer) : undefined
|
|
195
196
|
// The project's backend, when it has one: on a database of its own, emptied before every case.
|
|
196
|
-
|
|
197
|
+
// (a browser run starts its own, in its session)
|
|
198
|
+
const backend = lite ? null : runBackend({ root: desktopRoot, command: "lecodes test", test: true, log: (msg) => { if (flags.logs || msg.includes("[server:error]")) logErr(msg) } })
|
|
197
199
|
const native = desktop ? await buildBundle(bundleOptions(flags), { localAssets: true, desktopRenderer, backend: backend! }) : null
|
|
198
|
-
const headless = desktop || lite ? null : await compileHeadlessBundle({ entry: flags.entry, publicUrl: flags["public-url"], remoteAssets: flags["remote-assets"], waitNetwork: flags["wait-network"] })
|
|
200
|
+
const headless = desktop || lite ? null : await compileHeadlessBundle({ entry: flags.entry, publicUrl: flags["public-url"], remoteAssets: flags["remote-assets"], waitNetwork: flags["wait-network"], backend: backend! })
|
|
199
201
|
const warnings = headless?.warnings ?? []
|
|
200
202
|
const useLocalAssets = headless?.useLocalAssets ?? false
|
|
201
203
|
const js = headless?.js ?? ""
|
|
@@ -257,9 +259,10 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
|
|
|
257
259
|
if ((meta.edges ?? []).length > 0) {
|
|
258
260
|
// Probe the app for its start screen — the BFS origin of every derived flow.
|
|
259
261
|
note("Probing the start screen…")
|
|
260
|
-
const probe = await renderer!.createSession(js, { raster: false, localAssets: useLocalAssets, fixtures: flowFixtures })
|
|
262
|
+
const probe = await renderer!.createSession(js, { raster: false, localAssets: useLocalAssets, fixtures: flowFixtures, backend: headless!.serverUrl })
|
|
261
263
|
entryScreen = probe.screenName()
|
|
262
264
|
probe.dispose()
|
|
265
|
+
await backend?.reset()
|
|
263
266
|
if (entryScreen === null) {
|
|
264
267
|
warnErr("Design flows skipped: the app's start screen root has no `name`. Name each screen root with its design screen id (e.g. UIScreen(...).named(\"login\")).")
|
|
265
268
|
} else {
|
|
@@ -408,6 +411,7 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
|
|
|
408
411
|
height: flags.height || kase.height || projectViewport?.height || 844,
|
|
409
412
|
settleMs: flags.settle ?? kase.settle,
|
|
410
413
|
waitNetwork: flags["wait-network"],
|
|
414
|
+
backend: headless!.serverUrl,
|
|
411
415
|
localAssets: useLocalAssets,
|
|
412
416
|
fixtures: kase.fixtures,
|
|
413
417
|
...resolveSafeAreaChoice(flags["safe-area"] ?? kase.safeArea, flags.device ?? kase.device),
|
package/src/compile/collect.ts
CHANGED
|
@@ -16,7 +16,13 @@ import type { CompileEntry } from "./projectCompile"
|
|
|
16
16
|
*/
|
|
17
17
|
export const collectEntries = (
|
|
18
18
|
root: string, manifest: Manifest, project: Project,
|
|
19
|
-
opts: {
|
|
19
|
+
opts: {
|
|
20
|
+
/** Warn about resources with no server URL yet (default true; shell compiles bundle them instead). */
|
|
21
|
+
warnMissingUrl?: boolean
|
|
22
|
+
/** The caller compiles the .mat files itself (applyLocalShaders in `local` mode — the desktop
|
|
23
|
+
* host paths): skip the "push it to compile the shader" notice, which would be wrong there. */
|
|
24
|
+
localShaders?: boolean
|
|
25
|
+
} = {},
|
|
20
26
|
): { entries: CompileEntry[], warnings: string[] } => {
|
|
21
27
|
const pathById = buildPaths(project.assets)
|
|
22
28
|
const serverByPath = new Map<string, { fileSrc?: string, shaders?: { platform: string, src: string }[] }>()
|
|
@@ -36,8 +42,11 @@ export const collectEntries = (
|
|
|
36
42
|
entries.push({ path: file.path, type, fileSrc: src, absPath: file.absPath })
|
|
37
43
|
} else if (type === "shader") {
|
|
38
44
|
const shaders = serverByPath.get(file.path)?.shaders
|
|
39
|
-
if (!shaders?.length) warnings.push(`No compiled shader for ${file.path} — push it to compile the shader.`)
|
|
40
|
-
|
|
45
|
+
if (!shaders?.length && !opts.localShaders) warnings.push(`No compiled shader for ${file.path} — push it to compile the shader.`)
|
|
46
|
+
// absPath: the local shader compile (compile/shaders.ts) reads the .mat source through it and
|
|
47
|
+
// selects the entries that carry it — without it a server-backed project's shaders were never
|
|
48
|
+
// compiled locally, and `desktop run` shipped only what the server had (nothing until a push).
|
|
49
|
+
entries.push({ path: file.path, type, shaders, absPath: file.absPath })
|
|
41
50
|
} else {
|
|
42
51
|
entries.push({ path: file.path, type, text: readFileSync(file.absPath, "utf8") })
|
|
43
52
|
}
|
|
@@ -8,6 +8,7 @@ import { applyProjectFonts } from "./fonts"
|
|
|
8
8
|
import { applyAppAssetIcons } from "./assetIcons"
|
|
9
9
|
import type { ScreenTarget } from "./screenEntry"
|
|
10
10
|
import { note } from "../cli"
|
|
11
|
+
import type { RunBackend } from "../dev/projectBackend"
|
|
11
12
|
|
|
12
13
|
/*
|
|
13
14
|
* Compile the project into the iife bundle the headless renderer runs — shared by `lecodes render`
|
|
@@ -34,11 +35,15 @@ export type HeadlessBundle = {
|
|
|
34
35
|
warnings: string[]
|
|
35
36
|
/** Whether asset URLs resolve from the local disk (`lecodesfile://`). */
|
|
36
37
|
useLocalAssets: boolean
|
|
38
|
+
/** The url of the backend started for this run (`backend`), when the project has server files —
|
|
39
|
+
* what the renderer is told to let through. */
|
|
40
|
+
serverUrl?: string
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
/** What a headless compile honours: `--entry` / `--public-url` / `--remote-assets` / `--wait-network`,
|
|
40
|
-
* and the screen module a `render <file>` named.
|
|
41
|
-
|
|
44
|
+
* and the screen module a `render <file>` named. `backend`: the run's backend — started here when the
|
|
45
|
+
* project has server files, its url put in the app's stubs (without one they have no server to call). */
|
|
46
|
+
export type HeadlessOptions = { entry?: string, publicUrl?: string, remoteAssets?: boolean, waitNetwork?: boolean, screen?: ScreenTarget, backend?: RunBackend }
|
|
42
47
|
|
|
43
48
|
/** Compile the local project for a headless run. */
|
|
44
49
|
export const compileHeadlessBundle = async (opts: HeadlessOptions = {}): Promise<HeadlessBundle> => {
|
|
@@ -112,10 +117,12 @@ export const compileHeadlessBundle = async (opts: HeadlessOptions = {}): Promise
|
|
|
112
117
|
const ambiguous = ambiguousEntryWarning(compileEntries)
|
|
113
118
|
if (ambiguous) warnings.push(ambiguous)
|
|
114
119
|
}
|
|
120
|
+
const serverUrl = await opts.backend?.start(compileEntries, (w) => warnings.push(w))
|
|
115
121
|
const js = await compileProject({
|
|
116
122
|
entries: compileEntries,
|
|
117
123
|
name,
|
|
118
124
|
publicUrl,
|
|
125
|
+
serverUrl,
|
|
119
126
|
entryOverride: entryFlag,
|
|
120
127
|
header: false,
|
|
121
128
|
format: "iife",
|
|
@@ -129,5 +136,5 @@ export const compileHeadlessBundle = async (opts: HeadlessOptions = {}): Promise
|
|
|
129
136
|
warnings.push("2D scene detected: sprite textures load over the network — add --wait-network to include them (else sprites render as flat placeholders).")
|
|
130
137
|
}
|
|
131
138
|
|
|
132
|
-
return { js, root, warnings, useLocalAssets }
|
|
139
|
+
return { js, root, warnings, useLocalAssets, serverUrl }
|
|
133
140
|
}
|
|
@@ -133,17 +133,21 @@ type Typescript = NonNullable<CompileServerOptions["typescript"]>
|
|
|
133
133
|
* over: TypeScript 7 — what `install typescript` brings today — is the native compiler, with no
|
|
134
134
|
* `createProgram` to call. */
|
|
135
135
|
const findTypescript = async (root: string): Promise<Typescript> => {
|
|
136
|
+
const api = (mod: any): Typescript | null => mod?.createProgram ? mod : mod?.default?.createProgram ? mod.default : null
|
|
136
137
|
const from = (at: string): Typescript | null => {
|
|
137
|
-
try {
|
|
138
|
-
const mod = createRequire(at)("typescript")
|
|
139
|
-
return mod?.createProgram ? mod : mod?.default?.createProgram ? mod.default : null
|
|
140
|
-
} catch { return null }
|
|
138
|
+
try { return api(createRequire(at)("typescript")) } catch { return null }
|
|
141
139
|
}
|
|
142
|
-
|
|
143
|
-
|
|
140
|
+
// The peer is loaded by the PATH of its entry, and only once it is on disk: a lookup by name that
|
|
141
|
+
// missed before the install is remembered by the runtime, and would miss again right after it.
|
|
142
|
+
const dir = peerDirInPrefix("typescript")
|
|
143
|
+
const peer = (): Typescript | null => {
|
|
144
|
+
if (!existsSync(join(dir, "package.json"))) return null
|
|
145
|
+
try { return api(createRequire(import.meta.url)(join(dir, "lib", "typescript.js"))) } catch { return null }
|
|
146
|
+
}
|
|
147
|
+
const found = from(join(root, "package.json")) ?? from(import.meta.url) ?? peer()
|
|
144
148
|
if (found) return found
|
|
145
149
|
await installPeers(["typescript"], "The project's server code (*.server.ts)")
|
|
146
|
-
return
|
|
150
|
+
return peer() ?? (() => { throw new Error(`The typescript installed in ${dir} has no compiler API`) })()
|
|
147
151
|
}
|
|
148
152
|
|
|
149
153
|
/**
|
|
@@ -10,6 +10,9 @@ declare module "lecodes-headless/headless" {
|
|
|
10
10
|
settleMs?: number
|
|
11
11
|
/** Let data fetches hit the real network (off by default). */
|
|
12
12
|
waitNetwork?: boolean
|
|
13
|
+
/** The url of the project's backend, run for this run: the app's calls to it are made and
|
|
14
|
+
* waited for, its channel socket is real. */
|
|
15
|
+
backend?: string
|
|
13
16
|
/** Resolve `lecodesfile://<abs-path>` resource URLs from the local filesystem (the `render`
|
|
14
17
|
* command emits these for a local project's assets). */
|
|
15
18
|
localAssets?: boolean
|
package/src/dev/backendWorker.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { mkdirSync, mkdtempSync, renameSync, rmSync } from "node:fs"
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync } from "node:fs"
|
|
2
2
|
import { tmpdir } from "node:os"
|
|
3
3
|
import { join } from "node:path"
|
|
4
4
|
import { format } from "node:util"
|
|
5
5
|
import {
|
|
6
|
-
createAuthHost, createInvoker, createSubscribeGuard, describeServer, loadServerModules,
|
|
7
|
-
setChannelPublisher, setDbTransport, type AuthHost, type LoadedServer,
|
|
6
|
+
createAuthHost, createFilesHost, createInvoker, createSubscribeGuard, describeServer, loadServerModules,
|
|
7
|
+
setChannelPublisher, setDbTransport, type AuthHost, type Heic, type LoadedServer, type Sharp,
|
|
8
8
|
} from "lecodes-sdk/server"
|
|
9
9
|
import { loadPeer } from "../hosts/peerInstall"
|
|
10
10
|
import type { FromWorker, LogLevel, ToWorker } from "./localBackend"
|
|
@@ -59,7 +59,7 @@ process.on("SIGINT", quit)
|
|
|
59
59
|
process.on("SIGTERM", quit)
|
|
60
60
|
|
|
61
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[]> => {
|
|
62
|
+
const openDb = async (dir: string, filesDir: string, schema: string): Promise<string[]> => {
|
|
63
63
|
const { openDatabase } = await loadPeer<EmbeddedModule>("marcidb-embedded", undefined, { for: "lecodes dev", install: false })
|
|
64
64
|
?? (() => { throw new Error("marcidb-embedded is not installed") })()
|
|
65
65
|
mkdirSync(dir, { recursive: true })
|
|
@@ -85,6 +85,9 @@ const openDb = async (dir: string, schema: string): Promise<string[]> => {
|
|
|
85
85
|
const aside = `${dir}-old`
|
|
86
86
|
rmSync(aside, { recursive: true, force: true })
|
|
87
87
|
renameSync(dir, aside)
|
|
88
|
+
// the stored files are that database's: they go aside with it
|
|
89
|
+
rmSync(`${filesDir}-old`, { recursive: true, force: true })
|
|
90
|
+
if (existsSync(filesDir)) renameSync(filesDir, `${filesDir}-old`)
|
|
88
91
|
mkdirSync(dir, { recursive: true })
|
|
89
92
|
db = openDatabase(dir)
|
|
90
93
|
await db.$sync(schema)
|
|
@@ -100,7 +103,7 @@ const load = async (m: Extract<ToWorker, { t: "load" }>) => {
|
|
|
100
103
|
;(0, eval)(m.code)
|
|
101
104
|
const server: LoadedServer = loadServerModules()
|
|
102
105
|
const describe = describeServer(server)
|
|
103
|
-
const notes = describe.marci ? await openDb(m.dbDir, describe.marci) : []
|
|
106
|
+
const notes = describe.marci ? await openDb(m.dbDir, m.filesDir, describe.marci) : []
|
|
104
107
|
// Sessions, passwords and email codes over the project's own db, as on the runner. A code that was
|
|
105
108
|
// asked for is printed here, and DEV_CODE signs any email in without one — the developer is the
|
|
106
109
|
// only user.
|
|
@@ -114,7 +117,15 @@ const load = async (m: Extract<ToWorker, { t: "load" }>) => {
|
|
|
114
117
|
authHost.install()
|
|
115
118
|
const auth = authHost.enabled ? authHost : null
|
|
116
119
|
const onError = (id: string, e: unknown) => post({ t: "log", level: "error", text: `${id}: ${(e as Error)?.stack ?? e}` })
|
|
117
|
-
|
|
120
|
+
// Stored files (`t.file()`): rows in the database, bytes in the folder beside it
|
|
121
|
+
const files = createFilesHost({
|
|
122
|
+
db: server.dbs[0] ?? null, dir: m.filesDir, log: (level, text) => post({ t: "log", level, text }),
|
|
123
|
+
// the codec of t.image() fields: a peer, installed by the parent when the bundle has one (projectBackend.ts)
|
|
124
|
+
sharp: async () => (await loadPeer<{ default: Sharp }>("sharp", undefined, { for: "lecodes dev", install: false }))?.default ?? null,
|
|
125
|
+
heic: async () => (await loadPeer<{ default: Heic }>("heic-decode", undefined, { for: "lecodes dev", install: false }))?.default ?? null,
|
|
126
|
+
})
|
|
127
|
+
files.install()
|
|
128
|
+
invoke = createInvoker(server, { auth, files, onError })
|
|
118
129
|
subscribe = createSubscribeGuard(server, { auth, onError })
|
|
119
130
|
post({ t: "loaded", describe, notes })
|
|
120
131
|
}
|
package/src/dev/localBackend.ts
CHANGED
|
@@ -25,7 +25,7 @@ export type LogLevel = "log" | "info" | "warn" | "error" | "debug"
|
|
|
25
25
|
type Ctx = Omit<RequestContext, "id">
|
|
26
26
|
|
|
27
27
|
export type ToWorker =
|
|
28
|
-
| { t: "load", code: string, dbDir: string }
|
|
28
|
+
| { t: "load", code: string, dbDir: string, filesDir: string }
|
|
29
29
|
| { t: "invoke", req: number, id: string, args: unknown[], ctx: Ctx }
|
|
30
30
|
| { t: "subscribe", req: number, ch: string, args: unknown[], ctx: Ctx }
|
|
31
31
|
| { t: "stop" }
|
|
@@ -41,6 +41,8 @@ export type LocalBackendOptions = {
|
|
|
41
41
|
root: string
|
|
42
42
|
/** The database's folder (default `.lecodes/db`). */
|
|
43
43
|
dbDir?: string
|
|
44
|
+
/** The stored files' folder (`t.file()`; default: beside the database — `.lecodes/files`). */
|
|
45
|
+
filesDir?: string
|
|
44
46
|
log: (msg: string) => void
|
|
45
47
|
onPublish: (ch: string, group: ChannelGroup | null, data: unknown) => void
|
|
46
48
|
invokeTimeoutMs?: number
|
|
@@ -74,11 +76,15 @@ const selfCommand = (): string[] => {
|
|
|
74
76
|
return [process.execPath, ...process.execArgv, entry]
|
|
75
77
|
}
|
|
76
78
|
|
|
79
|
+
/** Where a database's stored files are: beside it, named after it (`.lecodes/db` → `.lecodes/files`, `test-db` → `test-files`). */
|
|
80
|
+
export const filesDirOf = (dbDir: string) => join(dirname(dbDir), basename(dbDir).replace(/db$/, "") + "files")
|
|
81
|
+
|
|
77
82
|
export const startLocalBackend = (options: LocalBackendOptions): LocalBackend => {
|
|
78
83
|
const { log } = options
|
|
79
84
|
const invokeTimeoutMs = options.invokeTimeoutMs ?? 30_000
|
|
80
85
|
const loadTimeoutMs = options.loadTimeoutMs ?? 15_000
|
|
81
86
|
const dbDir = options.dbDir ?? join(options.root, LECODES_DIR, "db")
|
|
87
|
+
const filesDir = options.filesDir ?? filesDirOf(dbDir)
|
|
82
88
|
|
|
83
89
|
let live: Live | null = null
|
|
84
90
|
/** The bundle that is deployed — what a crashed or timed-out process is started from again. */
|
|
@@ -141,7 +147,7 @@ export const startLocalBackend = (options: LocalBackendOptions): LocalBackend =>
|
|
|
141
147
|
if (!closed) log(`[server:error] the server process exited (${status ?? "killed"}) — it starts again with the next call`)
|
|
142
148
|
}
|
|
143
149
|
})
|
|
144
|
-
child.send({ t: "load", code: bundle, dbDir } satisfies ToWorker)
|
|
150
|
+
child.send({ t: "load", code: bundle, dbDir, filesDir } satisfies ToWorker)
|
|
145
151
|
})
|
|
146
152
|
|
|
147
153
|
const stop = async (l: Live) => {
|