lecodes-cli 0.8.2 → 0.9.1
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 +745 -164
- package/package.json +36 -36
- package/runtime/sdk/inject.ts +2 -1
- package/runtime/sdk/ui/UI.ts +2 -1
- package/runtime/sdk/ui/UIBottomSheet.ts +149 -0
- package/runtime/sdk/ui/UIInput.ts +24 -1
- package/runtime/sdk/ui/UINode.ts +7 -6
- package/runtime/sdk/ui/UIPager.ts +71 -3
- package/runtime/sdk/ui/UIScreen.ts +7 -57
- package/runtime/sdk/ui/UIScrollable.ts +27 -6
- package/runtime/sdk/ui/UIVirtualizedList.ts +16 -3
- package/runtime/sdk/ui/UIWidget.ts +8 -2
- package/runtime/sdk/ui/presentable.ts +2 -1
- package/runtime/sdk/ui/router.ts +4 -2
- package/runtime/sdk/ui/theme.ts +34 -7
- package/runtime/sdk-types.json +1 -1
package/dist/index.js
CHANGED
|
@@ -190,6 +190,23 @@ var resolveInject = () => {
|
|
|
190
190
|
return bin;
|
|
191
191
|
} catch {}
|
|
192
192
|
throw new Error("chisel binary not found. Install @letary/chisel, or set LECODES_CHISEL_BIN.");
|
|
193
|
+
}, scanModulesChisel = (files) => {
|
|
194
|
+
let bin;
|
|
195
|
+
try {
|
|
196
|
+
bin = resolveChiselBin();
|
|
197
|
+
} catch {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
const input = JSON.stringify({ scan: true, files });
|
|
201
|
+
const r = spawnSync(bin, [], { input, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
202
|
+
if (r.status !== 0 || r.error)
|
|
203
|
+
return null;
|
|
204
|
+
try {
|
|
205
|
+
const parsed = JSON.parse(r.stdout || "{}");
|
|
206
|
+
return parsed.scan ?? null;
|
|
207
|
+
} catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
193
210
|
}, bundleProjectChisel = async (entrypoint, files, format, sourcemap, minifyOverride, editor, libraries) => {
|
|
194
211
|
const entry = normalize(entrypoint);
|
|
195
212
|
const slugs = new Set(Object.keys(libraries ?? {}));
|
|
@@ -262,6 +279,79 @@ var init_bundler = __esm(() => {
|
|
|
262
279
|
sdkFilesCache = new Map;
|
|
263
280
|
});
|
|
264
281
|
|
|
282
|
+
// ../sdk/src/compile/detectEntry.ts
|
|
283
|
+
var ENTRY_CONVENTION = "/main.ts", isCode = (path) => /\.[tj]s$/.test(path) && !path.endsWith(".d.ts"), normalize2 = (path) => {
|
|
284
|
+
const parts = [];
|
|
285
|
+
for (const part of path.split("/")) {
|
|
286
|
+
if (!part || part === ".")
|
|
287
|
+
continue;
|
|
288
|
+
if (part === "..")
|
|
289
|
+
parts.pop();
|
|
290
|
+
else
|
|
291
|
+
parts.push(part);
|
|
292
|
+
}
|
|
293
|
+
return "/" + parts.join("/");
|
|
294
|
+
}, dirOf = (path) => {
|
|
295
|
+
const i = path.lastIndexOf("/");
|
|
296
|
+
return i <= 0 ? "/" : path.slice(0, i);
|
|
297
|
+
}, resolveSpec = (files, importer, spec) => {
|
|
298
|
+
if (!spec.startsWith("./") && !spec.startsWith("../") && !spec.startsWith("/"))
|
|
299
|
+
return null;
|
|
300
|
+
const base = normalize2(spec.startsWith("/") ? spec : dirOf(importer) + "/" + spec);
|
|
301
|
+
for (const cand of [base, base + ".ts", base + ".js", base + "/index.ts"]) {
|
|
302
|
+
if (cand in files)
|
|
303
|
+
return cand;
|
|
304
|
+
}
|
|
305
|
+
return null;
|
|
306
|
+
}, IMPORT_RE, EXPORT_RE, scanModulesFallback = (files) => {
|
|
307
|
+
const out = {};
|
|
308
|
+
for (const [path, text] of Object.entries(files)) {
|
|
309
|
+
const imports = [];
|
|
310
|
+
for (const m of text.matchAll(IMPORT_RE)) {
|
|
311
|
+
const target = resolveSpec(files, path, m[1] ?? m[2] ?? "");
|
|
312
|
+
if (target && isCode(target) && !imports.includes(target))
|
|
313
|
+
imports.push(target);
|
|
314
|
+
}
|
|
315
|
+
out[path] = { imports, hasExports: EXPORT_RE.test(text) };
|
|
316
|
+
}
|
|
317
|
+
return out;
|
|
318
|
+
}, detectEntry = (entries, scanner) => {
|
|
319
|
+
const files = {};
|
|
320
|
+
for (const e of entries) {
|
|
321
|
+
if (e.type !== undefined && e.type !== "text")
|
|
322
|
+
continue;
|
|
323
|
+
if (typeof e.text !== "string")
|
|
324
|
+
continue;
|
|
325
|
+
const path = normalize2(e.path.split("\\").join("/"));
|
|
326
|
+
if (isCode(path))
|
|
327
|
+
files[path] = e.text;
|
|
328
|
+
}
|
|
329
|
+
const candidates = Object.keys(files).filter((p) => !p.endsWith(".editor.ts"));
|
|
330
|
+
if (candidates.length === 0)
|
|
331
|
+
return { entry: "", roots: [] };
|
|
332
|
+
const scan = scanner?.(files) ?? scanModulesFallback(files);
|
|
333
|
+
const imported = new Set;
|
|
334
|
+
for (const [path, mod] of Object.entries(scan)) {
|
|
335
|
+
if (path.endsWith(".editor.ts"))
|
|
336
|
+
continue;
|
|
337
|
+
for (const target of mod.imports)
|
|
338
|
+
imported.add(target);
|
|
339
|
+
}
|
|
340
|
+
let roots = candidates.filter((p) => !imported.has(p));
|
|
341
|
+
if (roots.length === 0)
|
|
342
|
+
roots = candidates;
|
|
343
|
+
if (roots.includes(ENTRY_CONVENTION))
|
|
344
|
+
return { entry: ENTRY_CONVENTION, roots };
|
|
345
|
+
const scripts = roots.filter((p) => !scan[p]?.hasExports);
|
|
346
|
+
const pool = scripts.length > 0 ? scripts : roots;
|
|
347
|
+
const bySize = [...pool].sort((a, b) => files[b].length - files[a].length);
|
|
348
|
+
return { entry: bySize[0], roots };
|
|
349
|
+
};
|
|
350
|
+
var init_detectEntry = __esm(() => {
|
|
351
|
+
IMPORT_RE = /(?:^|[\n;])\s*(?:import|export)\b[^'"`]*?\bfrom\s*['"]([^'"\n]+)['"]|(?:^|[\n;])\s*import\s*['"]([^'"\n]+)['"]/g;
|
|
352
|
+
EXPORT_RE = /(?:^|[\n;])\s*export\b/;
|
|
353
|
+
});
|
|
354
|
+
|
|
265
355
|
// ../sdk/src/compile/sourcemap.ts
|
|
266
356
|
var offsetSourceMap = (mapJson, lines) => {
|
|
267
357
|
if (lines <= 0)
|
|
@@ -322,25 +412,10 @@ var buildHeader = (name, code, opts = {}) => {
|
|
|
322
412
|
};
|
|
323
413
|
|
|
324
414
|
// ../sdk/src/compile/compileProject.ts
|
|
325
|
-
var
|
|
326
|
-
|
|
327
|
-
let max = -1;
|
|
328
|
-
for (const e of entries) {
|
|
329
|
-
const ext2 = e.path.split(".").at(-1)?.toLowerCase();
|
|
330
|
-
if (e.path.endsWith(".editor.ts"))
|
|
331
|
-
continue;
|
|
332
|
-
if (e.type === "text" && e.text !== undefined && !e.text.includes("export") && (ext2 === "ts" || ext2 === "js")) {
|
|
333
|
-
if (e.text.length > max) {
|
|
334
|
-
entry = e.path;
|
|
335
|
-
max = e.text.length;
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
return entry;
|
|
340
|
-
}, compileProject = async (opts) => {
|
|
341
|
-
const entry = opts.entryOverride ?? detectEntry(opts.entries);
|
|
415
|
+
var compileProject = async (opts) => {
|
|
416
|
+
const entry = opts.entryOverride ?? detectEntry(opts.entries, scanModulesChisel).entry;
|
|
342
417
|
if (!entry) {
|
|
343
|
-
throw new Error("Couldn't find an entrypoint (
|
|
418
|
+
throw new Error("Couldn't find an entrypoint (no .ts/.js project file — name yours main.ts). Pass entryOverride.");
|
|
344
419
|
}
|
|
345
420
|
const files = {};
|
|
346
421
|
for (const e of opts.entries) {
|
|
@@ -380,6 +455,7 @@ ${inlineSourceMapComment(offsetSourceMap(map, headerLines))}`;
|
|
|
380
455
|
};
|
|
381
456
|
var init_compileProject = __esm(() => {
|
|
382
457
|
init_bundler();
|
|
458
|
+
init_detectEntry();
|
|
383
459
|
});
|
|
384
460
|
|
|
385
461
|
// ../sdk/src/compile/sceneEditor.ts
|
|
@@ -780,7 +856,7 @@ var FONT_ID, CODE_FILE, DECLARES_FONT, isIdentStart = (c2) => c2 >= "A" && c2 <=
|
|
|
780
856
|
}
|
|
781
857
|
}
|
|
782
858
|
return null;
|
|
783
|
-
}, norm = (p) => p.replace(/\\/g, "/").replace(/^\//, ""),
|
|
859
|
+
}, norm = (p) => p.replace(/\\/g, "/").replace(/^\//, ""), dirOf2 = (p) => {
|
|
784
860
|
const n = norm(p);
|
|
785
861
|
const i = n.lastIndexOf("/");
|
|
786
862
|
return i === -1 ? "" : n.slice(0, i);
|
|
@@ -823,7 +899,7 @@ var FONT_ID, CODE_FILE, DECLARES_FONT, isIdentStart = (c2) => c2 >= "A" && c2 <=
|
|
|
823
899
|
const fileFaceCache = new Map;
|
|
824
900
|
const resolveCall = (entryPath, call) => {
|
|
825
901
|
if (call.kind === "file") {
|
|
826
|
-
const filePath = resolveRel(
|
|
902
|
+
const filePath = resolveRel(dirOf2(entryPath), call.arg);
|
|
827
903
|
const target = filePath && entryByPath.get(filePath);
|
|
828
904
|
if (!target)
|
|
829
905
|
throw new Error(`${entryPath}: font("${call.arg}"): no such file in the project (resolved to "${filePath || call.arg}").`);
|
|
@@ -897,7 +973,7 @@ var FONT_ID, CODE_FILE, DECLARES_FONT, isIdentStart = (c2) => c2 >= "A" && c2 <=
|
|
|
897
973
|
}
|
|
898
974
|
for (const fw of work) {
|
|
899
975
|
const entryPath = norm(fw.entry.path);
|
|
900
|
-
const dir =
|
|
976
|
+
const dir = dirOf2(entryPath);
|
|
901
977
|
const modBase = `.${baseOf(entryPath)}.__fonts.ts`;
|
|
902
978
|
const modPath = dir ? `${dir}/${modBase}` : modBase;
|
|
903
979
|
const idents = new Map;
|
|
@@ -961,6 +1037,8 @@ var init_fontMacro = __esm(() => {
|
|
|
961
1037
|
var exports_compile = {};
|
|
962
1038
|
__export(exports_compile, {
|
|
963
1039
|
sceneEditorEntries: () => sceneEditorEntries,
|
|
1040
|
+
scanModulesFallback: () => scanModulesFallback,
|
|
1041
|
+
scanModulesChisel: () => scanModulesChisel,
|
|
964
1042
|
scanFontCalls: () => scanFontCalls,
|
|
965
1043
|
parseSfnt: () => parseSfnt,
|
|
966
1044
|
offsetSourceMap: () => offsetSourceMap,
|
|
@@ -969,6 +1047,7 @@ __export(exports_compile, {
|
|
|
969
1047
|
fontRegistryBase: () => fontRegistryBase,
|
|
970
1048
|
faceFileName: () => faceFileName,
|
|
971
1049
|
detectProjectKind: () => detectProjectKind,
|
|
1050
|
+
detectEntry: () => detectEntry,
|
|
972
1051
|
compileProject: () => compileProject,
|
|
973
1052
|
bundleProjectWithMap: () => bundleProjectWithMap,
|
|
974
1053
|
bundleProject: () => bundleProject,
|
|
@@ -978,10 +1057,12 @@ __export(exports_compile, {
|
|
|
978
1057
|
SCENE_HARNESS_DIR: () => SCENE_HARNESS_DIR,
|
|
979
1058
|
SCENE_ENTRY_PATH: () => SCENE_ENTRY_PATH,
|
|
980
1059
|
FONT_REGISTRY: () => FONT_REGISTRY,
|
|
1060
|
+
ENTRY_CONVENTION: () => ENTRY_CONVENTION,
|
|
981
1061
|
DEFAULT_FONT_REGISTRY_BASE: () => DEFAULT_FONT_REGISTRY_BASE
|
|
982
1062
|
});
|
|
983
1063
|
var init_compile = __esm(() => {
|
|
984
1064
|
init_bundler();
|
|
1065
|
+
init_detectEntry();
|
|
985
1066
|
init_compileProject();
|
|
986
1067
|
init_sceneEditor();
|
|
987
1068
|
init_fontMacro();
|
|
@@ -2007,43 +2088,39 @@ var require_main = __commonJS((exports, module) => {
|
|
|
2007
2088
|
// package.json
|
|
2008
2089
|
var package_default = {
|
|
2009
2090
|
name: "lecodes-cli",
|
|
2010
|
-
version: "0.
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
"lecodes",
|
|
2016
|
-
"
|
|
2017
|
-
|
|
2091
|
+
version: "0.9.1",
|
|
2092
|
+
dependencies: {
|
|
2093
|
+
"@letary/chisel": "^0.5.1"
|
|
2094
|
+
},
|
|
2095
|
+
devDependencies: {
|
|
2096
|
+
"lecodes-design": "workspace:*",
|
|
2097
|
+
"lecodes-renderer": "workspace:*",
|
|
2098
|
+
"qrcode-terminal": "^0.12.0",
|
|
2099
|
+
"lecodes-3d-editor": "workspace:*",
|
|
2100
|
+
sdk: "workspace:*"
|
|
2101
|
+
},
|
|
2102
|
+
peerDependencies: {
|
|
2103
|
+
"lecodes-design": "^0.3.2",
|
|
2104
|
+
"lecodes-renderer": "^0.7.1",
|
|
2105
|
+
"lecodes-3d-editor": "^0.1.0"
|
|
2106
|
+
},
|
|
2018
2107
|
bin: {
|
|
2019
2108
|
lecodes: "dist/index.js"
|
|
2020
2109
|
},
|
|
2110
|
+
description: "Clone, edit and push LeCodes projects from your machine.",
|
|
2111
|
+
engines: {
|
|
2112
|
+
node: ">=18"
|
|
2113
|
+
},
|
|
2021
2114
|
files: [
|
|
2022
2115
|
"dist",
|
|
2023
2116
|
"runtime",
|
|
2024
2117
|
"README.md"
|
|
2025
2118
|
],
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
"vendor-runtime": "bun scripts/vendor-runtime.ts",
|
|
2032
|
-
"sync-peers": "bun scripts/sync-peer-versions.ts",
|
|
2033
|
-
build: 'bun scripts/vendor-runtime.ts && bun build src/index.ts --target=node --outfile dist/index.js --external @letary/chisel --external lecodes-renderer --external lecodes-design --external lecodes-3d-editor --banner="#!/usr/bin/env node"',
|
|
2034
|
-
"build:bin": "bun scripts/build-bin.ts",
|
|
2035
|
-
typecheck: "tsc --noEmit",
|
|
2036
|
-
dev: "bun run src/index.ts",
|
|
2037
|
-
prepublishOnly: "bun run sync-peers && bun run build"
|
|
2038
|
-
},
|
|
2039
|
-
dependencies: {
|
|
2040
|
-
"@letary/chisel": "^0.4.1"
|
|
2041
|
-
},
|
|
2042
|
-
peerDependencies: {
|
|
2043
|
-
"lecodes-design": "^0.3.2",
|
|
2044
|
-
"lecodes-renderer": "^0.6.2",
|
|
2045
|
-
"lecodes-3d-editor": "^0.1.0"
|
|
2046
|
-
},
|
|
2119
|
+
keywords: [
|
|
2120
|
+
"lecodes",
|
|
2121
|
+
"cli"
|
|
2122
|
+
],
|
|
2123
|
+
license: "MIT",
|
|
2047
2124
|
peerDependenciesMeta: {
|
|
2048
2125
|
"lecodes-design": {
|
|
2049
2126
|
optional: true
|
|
@@ -2055,13 +2132,17 @@ var package_default = {
|
|
|
2055
2132
|
optional: true
|
|
2056
2133
|
}
|
|
2057
2134
|
},
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
"
|
|
2061
|
-
"
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2135
|
+
scripts: {
|
|
2136
|
+
icon: "bun scripts/make-icon.ts",
|
|
2137
|
+
"vendor-runtime": "bun scripts/vendor-runtime.ts",
|
|
2138
|
+
"sync-peers": "bun scripts/sync-peer-versions.ts",
|
|
2139
|
+
build: 'bun scripts/vendor-runtime.ts && bun build src/index.ts --target=node --outfile dist/index.js --external @letary/chisel --external lecodes-renderer --external lecodes-design --external lecodes-3d-editor --banner="#!/usr/bin/env node"',
|
|
2140
|
+
"build:bin": "bun scripts/build-bin.ts",
|
|
2141
|
+
typecheck: "tsc --noEmit",
|
|
2142
|
+
dev: "bun run src/index.ts",
|
|
2143
|
+
prepublishOnly: "bun run sync-peers && bun run build"
|
|
2144
|
+
},
|
|
2145
|
+
type: "module"
|
|
2065
2146
|
};
|
|
2066
2147
|
|
|
2067
2148
|
// src/commands/app.ts
|
|
@@ -2239,10 +2320,11 @@ var requireApiUrl = (config) => {
|
|
|
2239
2320
|
// src/ignore.ts
|
|
2240
2321
|
import { existsSync as existsSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
|
|
2241
2322
|
import { join as join3 } from "node:path";
|
|
2242
|
-
var BUILTIN_PATTERNS = ["node_modules/", ".git/", "_*", "*.log", ".DS_Store"];
|
|
2323
|
+
var BUILTIN_PATTERNS = ["node_modules/", ".git/", "_*", "*.log", ".DS_Store", ".artifacts/"];
|
|
2243
2324
|
var DEFAULT_IGNORE_FILE = `# Files matched here are NOT pushed to LeCodes.
|
|
2244
2325
|
# Only untracked files are affected — assets already in the project stay tracked
|
|
2245
|
-
# even if they match a pattern. Built-in defaults (node_modules, _*, *.log
|
|
2326
|
+
# even if they match a pattern. Built-in defaults (node_modules, _*, *.log, .artifacts)
|
|
2327
|
+
# always apply.
|
|
2246
2328
|
|
|
2247
2329
|
# Render / preview outputs (the default \`lecodes render --png\` target)
|
|
2248
2330
|
screenshot.png
|
|
@@ -2651,6 +2733,12 @@ var resolveChiselBin2 = () => {
|
|
|
2651
2733
|
var chiselBin = resolveChiselBin2();
|
|
2652
2734
|
if (chiselBin)
|
|
2653
2735
|
process.env.LECODES_CHISEL_BIN = chiselBin;
|
|
2736
|
+
var ambiguousEntryWarning = (entries) => {
|
|
2737
|
+
const { entry, roots } = detectEntry(entries, scanModulesChisel);
|
|
2738
|
+
if (!entry || roots.length < 2 || roots.includes(ENTRY_CONVENTION))
|
|
2739
|
+
return null;
|
|
2740
|
+
return `Ambiguous entrypoint: nothing imports ${roots.join(", ")} — picked ${entry}. Rename your entry to main.ts (or pass --entry).`;
|
|
2741
|
+
};
|
|
2654
2742
|
var compileProject2 = (opts) => compileProject({
|
|
2655
2743
|
entries: opts.entries.map((e) => ({ path: e.path, type: e.type, text: e.text, fileSrc: e.fileSrc, remote: e.remote, shaders: e.shaders })),
|
|
2656
2744
|
name: opts.name,
|
|
@@ -2846,11 +2934,17 @@ var compileImpl = async (args, shell) => {
|
|
|
2846
2934
|
makeResourceEntry: (path, absPath) => ({ path, type: "resource", fileSrc: encodeURI(path), absPath })
|
|
2847
2935
|
});
|
|
2848
2936
|
note("Compiling…");
|
|
2937
|
+
const entryFlag = flagStr(args, "entry");
|
|
2938
|
+
if (!entryFlag) {
|
|
2939
|
+
const ambiguous = ambiguousEntryWarning(entries);
|
|
2940
|
+
if (ambiguous)
|
|
2941
|
+
warnErr(ambiguous);
|
|
2942
|
+
}
|
|
2849
2943
|
const js = await compileProject2({
|
|
2850
2944
|
entries,
|
|
2851
2945
|
name,
|
|
2852
2946
|
publicUrl,
|
|
2853
|
-
entryOverride:
|
|
2947
|
+
entryOverride: entryFlag,
|
|
2854
2948
|
header: !flagBool(args, "no-header"),
|
|
2855
2949
|
localAssets,
|
|
2856
2950
|
fontsHeader,
|
|
@@ -4322,6 +4416,73 @@ var clone = async (args) => {
|
|
|
4322
4416
|
// src/commands/create.ts
|
|
4323
4417
|
import { existsSync as existsSync11, mkdirSync as mkdirSync8, readdirSync as readdirSync6, writeFileSync as writeFileSync9 } from "node:fs";
|
|
4324
4418
|
import { join as join13, resolve as resolve4 } from "node:path";
|
|
4419
|
+
|
|
4420
|
+
// src/commands/projectTemplates.ts
|
|
4421
|
+
var appClaudeMd = (name) => `# ${name} — LeCodes app
|
|
4422
|
+
|
|
4423
|
+
Guidance for AI assistants working in this folder. Human docs: README.md. The whole SDK is
|
|
4424
|
+
available as globals (\`UIScreen\`, \`UIText\`, \`Scene2D\`, …) — no SDK imports. \`main.ts\` is
|
|
4425
|
+
the entry point; split code into other \`.ts\` files and import them (relative imports only) —
|
|
4426
|
+
a file no other file imports is treated as an entry candidate, so keep everything reachable
|
|
4427
|
+
from \`main.ts\`.
|
|
4428
|
+
|
|
4429
|
+
## Feedback loop — run the app headless instead of guessing
|
|
4430
|
+
|
|
4431
|
+
\`\`\`
|
|
4432
|
+
lecodes render # semantic JSON of the launched UI (structure, text, names, values)
|
|
4433
|
+
lecodes render --png shot.png # pixels (needs @napi-rs/canvas installed)
|
|
4434
|
+
lecodes render --script <file> # drive the app through a scenario (format below)
|
|
4435
|
+
lecodes test # all tests/*.flow.json + design-derived flows; run before "done"
|
|
4436
|
+
\`\`\`
|
|
4437
|
+
|
|
4438
|
+
Iterate with \`--script\` scenarios and keep the useful ones as \`tests/<name>.flow.json\` — they
|
|
4439
|
+
are the regression suite. A failing step dumps the failing frame (JSON + PNG) under
|
|
4440
|
+
\`tests/.artifacts/\` so a red step explains itself.
|
|
4441
|
+
|
|
4442
|
+
## Scenario format (tests/*.flow.json and --script files)
|
|
4443
|
+
|
|
4444
|
+
One action per step (\`tap\`, \`type\`+\`text\`, \`fill\`, \`scroll\` [+\`by\`], \`wait\`), then an
|
|
4445
|
+
optional \`expect\` (\`screen\`, \`node\`/\`notNode\`, \`text\`/\`notText\`,
|
|
4446
|
+
\`value: { target, equals }\`), then outputs (\`screenshot\`, \`json\`). Targets resolve by
|
|
4447
|
+
\`name\` → exact visible text → substring → input placeholder. Envelope keys: \`name\`,
|
|
4448
|
+
\`width\`/\`height\`, \`settle\`, \`fixtures\`, \`steps\`.
|
|
4449
|
+
|
|
4450
|
+
\`\`\`json
|
|
4451
|
+
{ "steps": [
|
|
4452
|
+
{ "expect": { "screen": "home", "text": "Taps: 0" } },
|
|
4453
|
+
{ "tap": "tap", "expect": { "text": "Taps: 1" }, "screenshot": "after.png" }
|
|
4454
|
+
] }
|
|
4455
|
+
\`\`\`
|
|
4456
|
+
|
|
4457
|
+
## Make the app testable
|
|
4458
|
+
|
|
4459
|
+
- Give interactive elements a \`name\`: \`UIButton(...).style({ name: "login" })\`.
|
|
4460
|
+
- Name every screen's ROOT node with the screen's id — \`expect.screen\` reads it.
|
|
4461
|
+
- If the project has a \`design/\` folder, keep the design's screen ids and activator \`name\`s
|
|
4462
|
+
in the app code: \`lecodes test\` then checks the built app against the designed navigation
|
|
4463
|
+
automatically (conventions in design/CLAUDE.md).
|
|
4464
|
+
- Backend-gated screens: canned responses in \`tests/fixtures.json\` (URL pattern →
|
|
4465
|
+
\`{ status?, json?, text? }\`; \`*\` wildcards full-match, plain strings substring-match, first
|
|
4466
|
+
match wins). \`lecodes test\` auto-loads it for design-derived flows; a scenario file opts in
|
|
4467
|
+
with \`"fixtures": "fixtures.json"\` in its envelope.
|
|
4468
|
+
- \`fill\` fills every empty input — use it before tapping a disabled-until-filled submit.
|
|
4469
|
+
|
|
4470
|
+
## Other commands
|
|
4471
|
+
|
|
4472
|
+
\`lecodes dev\` (run on a device with hot reload + QR), \`lecodes compile -o app.js\`
|
|
4473
|
+
(single-file bundle), \`lecodes design\` (screen-design board — the living spec).
|
|
4474
|
+
`;
|
|
4475
|
+
var STARTER_FLOW = `{
|
|
4476
|
+
"name": "counter",
|
|
4477
|
+
"steps": [
|
|
4478
|
+
{ "expect": { "screen": "home", "text": "Taps: 0" } },
|
|
4479
|
+
{ "tap": "tap", "expect": { "text": "Taps: 1" } },
|
|
4480
|
+
{ "tap": "tap", "expect": { "text": "Taps: 2" } }
|
|
4481
|
+
]
|
|
4482
|
+
}
|
|
4483
|
+
`;
|
|
4484
|
+
|
|
4485
|
+
// src/commands/create.ts
|
|
4325
4486
|
var slug2 = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
|
|
4326
4487
|
var STARTER = `// Welcome to le.codes — no imports needed, the whole SDK is available as globals.
|
|
4327
4488
|
const scene = new Scene2D({ background: '#10131a' })
|
|
@@ -4351,6 +4512,7 @@ var create = async (args) => {
|
|
|
4351
4512
|
writeTsconfig(root);
|
|
4352
4513
|
writeDefaultIgnore(root);
|
|
4353
4514
|
writeFileSync9(join13(root, "main.ts"), STARTER);
|
|
4515
|
+
writeFileSync9(join13(root, "CLAUDE.md"), appClaudeMd(name));
|
|
4354
4516
|
const commits = await getCommits(apiUrl, token, uuid);
|
|
4355
4517
|
writeManifest(root, { uuid, apiUrl, name, baseHeadId: commits.headId, files: {} });
|
|
4356
4518
|
success(`Created ${c.bold(name)} in ${c.bold(dir)}/`);
|
|
@@ -4866,15 +5028,8 @@ var browserLogin = async (webBase, timeoutMs = 5 * 60 * 1000, open = openBrowser
|
|
|
4866
5028
|
}
|
|
4867
5029
|
};
|
|
4868
5030
|
|
|
4869
|
-
// src/
|
|
4870
|
-
var
|
|
4871
|
-
"screens": {
|
|
4872
|
-
"home": { "pos": [0, 0], "desc": "Example screen — replace me", "role": "list" }
|
|
4873
|
-
},
|
|
4874
|
-
"edges": []
|
|
4875
|
-
}
|
|
4876
|
-
`;
|
|
4877
|
-
var SPEC_MD = `# Concept
|
|
5031
|
+
// ../lecodes-design/src/core/concept.ts
|
|
5032
|
+
var SPEC_TEMPLATE = `# Concept
|
|
4878
5033
|
|
|
4879
5034
|
The narrative half of this design. \`meta.json\` + the canvas hold the *visual/flow* half (screens,
|
|
4880
5035
|
positions, edges); this file holds what never renders as a screen — the data model, the actors, and
|
|
@@ -4908,6 +5063,16 @@ which is the screen's *kind* — list, detail, form…)
|
|
|
4908
5063
|
Behaviour that isn't visible in a single screen — validation, permissions, notifications, edge
|
|
4909
5064
|
cases — and the decisions you've made and why, so they aren't re-litigated later.
|
|
4910
5065
|
`;
|
|
5066
|
+
|
|
5067
|
+
// ../lecodes-design/src/templates.ts
|
|
5068
|
+
var SPEC_MD = SPEC_TEMPLATE;
|
|
5069
|
+
var META_JSON = `{
|
|
5070
|
+
"screens": {
|
|
5071
|
+
"home": { "pos": [0, 0], "desc": "Example screen — replace me", "role": "list" }
|
|
5072
|
+
},
|
|
5073
|
+
"edges": []
|
|
5074
|
+
}
|
|
5075
|
+
`;
|
|
4911
5076
|
var TOKENS_TS = `// Design tokens shared by every screen. Colors are hex strings (the engine parses hex only).
|
|
4912
5077
|
// App font: one theme() call here sets the default text font everywhere. font("id") installs a
|
|
4913
5078
|
// registry family (vendored at compile time) and returns its name — no loading code needed.
|
|
@@ -5070,6 +5235,30 @@ positions or descriptions.
|
|
|
5070
5235
|
\`lecodes design check\` lists screens still untagged (never blocking). *(A screen's **states** are
|
|
5071
5236
|
a separate thing — declared in the source, not meta; see "Screen states" below.)*
|
|
5072
5237
|
|
|
5238
|
+
## The design is testable — names carry into the app
|
|
5239
|
+
|
|
5240
|
+
Every edge with an \`activator\` doubles as a **flow test** for the real app: \`lecodes test\` (run
|
|
5241
|
+
at the project root once the app exists) walks the graph from the start screen, taps each edge's
|
|
5242
|
+
activator, and expects to land on a screen named like the edge's target. The design stays a living
|
|
5243
|
+
contract instead of a discarded sketch — but only if the app build keeps the wiring:
|
|
5244
|
+
|
|
5245
|
+
- **Keep the design's \`name\`s** when translating a screen to app code — every element an edge's
|
|
5246
|
+
\`activator\` references keeps its \`name\` (\`.style({ name: "submit" })\`).
|
|
5247
|
+
- **Name each app screen's root with its design screen id** — \`UIScreen([ … ]).style({ name:
|
|
5248
|
+
"login" })\` — that's how the runner knows where a tap landed.
|
|
5249
|
+
- Edges with no activator (backend/system-driven) and \`@state\` edges are reported as *not
|
|
5250
|
+
machine-verifiable* — cover the important ones with hand-written \`tests/*.flow.json\` scenarios
|
|
5251
|
+
(same step schema as \`lecodes render --script\`; tab-bar navigation needs these too, since the
|
|
5252
|
+
bar draws no edges).
|
|
5253
|
+
- Backend-gated transitions (a submit that must succeed to navigate) run headless through
|
|
5254
|
+
\`tests/fixtures.json\` — canned API responses (URL pattern → \`{ status?, json?, text? }\`)
|
|
5255
|
+
that \`lecodes test\` loads automatically for the derived flows. Shape them after the app's
|
|
5256
|
+
API types; the spec.md data model is their first draft.
|
|
5257
|
+
|
|
5258
|
+
Names also appear in every headless render (JSON snapshots, the board's live elements), so they
|
|
5259
|
+
double as stable selectors for any scripted interaction — naming interactive elements is never
|
|
5260
|
+
wasted work.
|
|
5261
|
+
|
|
5073
5262
|
## The concept — spec.md
|
|
5074
5263
|
|
|
5075
5264
|
\`meta.json\` + the canvas are the *visual/flow* half of the design; \`spec.md\` is the *narrative*
|
|
@@ -5291,7 +5480,6 @@ var README_HINT = `# design/
|
|
|
5291
5480
|
LeCodes Design canvas — run \`lecodes design\` here and open the printed URL.
|
|
5292
5481
|
See CLAUDE.md for the conventions (it's written for AI assistants, but it's the spec).
|
|
5293
5482
|
`;
|
|
5294
|
-
|
|
5295
5483
|
// src/commands/design.ts
|
|
5296
5484
|
var DEFAULT_DIR = "design";
|
|
5297
5485
|
var DEFAULT_PORT = 4477;
|
|
@@ -5981,6 +6169,11 @@ var startDevServer = async (options) => {
|
|
|
5981
6169
|
publicUrl: origin,
|
|
5982
6170
|
makeResourceEntry: (path, absPath) => ({ path, type: "resource", fileSrc: `${base}/assets` + encodeURI(path), absPath })
|
|
5983
6171
|
});
|
|
6172
|
+
if (!entryOverride) {
|
|
6173
|
+
const ambiguous = ambiguousEntryWarning(entries);
|
|
6174
|
+
if (ambiguous)
|
|
6175
|
+
warnings.push(ambiguous);
|
|
6176
|
+
}
|
|
5984
6177
|
const js = await compileProject2({ entries, name, publicUrl: origin, entryOverride, header: true, fontsHeader });
|
|
5985
6178
|
const changed = bundleJs === null || stripVolatile(js) !== stripVolatile(bundleJs);
|
|
5986
6179
|
if (changed) {
|
|
@@ -6472,10 +6665,12 @@ const button = UIButton(
|
|
|
6472
6665
|
justifyContent: "center",
|
|
6473
6666
|
},
|
|
6474
6667
|
[UIText({ color: "#ffffff", fontSize: 22 }, "Tap me")],
|
|
6475
|
-
)
|
|
6476
|
-
|
|
6477
|
-
|
|
6478
|
-
|
|
6668
|
+
)
|
|
6669
|
+
.style({ name: "tap" }) // names make elements targetable in tests/*.flow.json (see CLAUDE.md)
|
|
6670
|
+
.onClick(() => {
|
|
6671
|
+
count++
|
|
6672
|
+
counter.text = "Taps: " + count
|
|
6673
|
+
})
|
|
6479
6674
|
|
|
6480
6675
|
const screen = UIScreen(
|
|
6481
6676
|
{ backgroundColor: "#1e2230", alignItems: "center", justifyContent: "center", padding: 40 },
|
|
@@ -6485,7 +6680,7 @@ const screen = UIScreen(
|
|
|
6485
6680
|
[UIText({ color: "#ffffff", fontSize: 32 }, "Hello, LeCodes"), button, counter],
|
|
6486
6681
|
),
|
|
6487
6682
|
],
|
|
6488
|
-
)
|
|
6683
|
+
).style({ name: "home" }) // the screen root's name is what tests' \`expect: { screen: … }\` reads
|
|
6489
6684
|
|
|
6490
6685
|
screen.open()
|
|
6491
6686
|
`;
|
|
@@ -6501,6 +6696,16 @@ lecodes dev # live on your phone: scan the QR with the LeCodes
|
|
|
6501
6696
|
lecodes compile -o app.js # bundle to a single self-contained JS file
|
|
6502
6697
|
\`\`\`
|
|
6503
6698
|
|
|
6699
|
+
## Test it (no device needed)
|
|
6700
|
+
|
|
6701
|
+
\`\`\`
|
|
6702
|
+
lecodes render --png shot.png # headless screenshot of the running app
|
|
6703
|
+
lecodes test # run tests/*.flow.json — the starter ships with a passing one
|
|
6704
|
+
\`\`\`
|
|
6705
|
+
|
|
6706
|
+
Scenario format + conventions: see [CLAUDE.md](./CLAUDE.md) (written for AI assistants, useful
|
|
6707
|
+
for humans too).
|
|
6708
|
+
|
|
6504
6709
|
## Go native
|
|
6505
6710
|
|
|
6506
6711
|
\`\`\`
|
|
@@ -6511,8 +6716,11 @@ lecodes app open # open it in Xcode, build & run
|
|
|
6511
6716
|
|
|
6512
6717
|
## Layout
|
|
6513
6718
|
|
|
6514
|
-
- \`main.ts\` — the app. Add more
|
|
6515
|
-
is
|
|
6719
|
+
- \`main.ts\` — the app's entry point. Add more .ts files freely and import them from
|
|
6720
|
+
\`main.ts\`; a file named \`main.ts\` is always the entry.
|
|
6721
|
+
- \`tests/\` — headless flow tests (\`lecodes test\`); failing steps dump the frame to
|
|
6722
|
+
\`tests/.artifacts/\`
|
|
6723
|
+
- \`CLAUDE.md\` — guidance for AI assistants (the headless feedback loop + testability conventions)
|
|
6516
6724
|
- \`assets/\` — images/fonts the app references (created on demand)
|
|
6517
6725
|
- \`tsconfig.json\` + \`.lecodes/types/\` — generated IDE types (rewritten by the CLI; don't edit)
|
|
6518
6726
|
- \`.lecodesignore\` — files a future \`lecodes push\` would skip
|
|
@@ -6538,6 +6746,8 @@ var init2 = async (args) => {
|
|
|
6538
6746
|
};
|
|
6539
6747
|
write("main.ts", STARTER2);
|
|
6540
6748
|
write("README.md", readmeTemplate(name));
|
|
6749
|
+
write("CLAUDE.md", appClaudeMd(name));
|
|
6750
|
+
write("tests/counter.flow.json", STARTER_FLOW);
|
|
6541
6751
|
write(".lecodesignore", DEFAULT_IGNORE_FILE);
|
|
6542
6752
|
const wroteTypes = materializeTypesLocal(root);
|
|
6543
6753
|
writeTsconfig(root);
|
|
@@ -6549,6 +6759,7 @@ var init2 = async (args) => {
|
|
|
6549
6759
|
success(created ? `Local project ${c.bold(name)} ready${dirArg === "." ? "" : ` in ${c.bold(dirArg)}/`}.` : `Project ${c.bold(name)} already initialized.`);
|
|
6550
6760
|
const cd = dirArg === "." ? "" : `cd ${dirArg} && `;
|
|
6551
6761
|
log(` ${c.bold(cd + "lecodes dev")} run it on a device with hot reload`);
|
|
6762
|
+
log(` ${c.bold(cd + "lecodes test")} run the flow tests headless (no device)`);
|
|
6552
6763
|
log(` ${c.bold(cd + "lecodes compile -o app.js")} bundle it for LeCodes Desktop`);
|
|
6553
6764
|
};
|
|
6554
6765
|
|
|
@@ -6564,50 +6775,47 @@ var login = async (args) => {
|
|
|
6564
6775
|
};
|
|
6565
6776
|
|
|
6566
6777
|
// src/commands/render.ts
|
|
6567
|
-
import { writeFileSync as writeFileSync14 } from "node:fs";
|
|
6568
|
-
import { resolve as resolve6 } from "node:path";
|
|
6778
|
+
import { mkdirSync as mkdirSync13, readFileSync as readFileSync17, writeFileSync as writeFileSync14 } from "node:fs";
|
|
6779
|
+
import { dirname as dirname11, resolve as resolve6 } from "node:path";
|
|
6780
|
+
|
|
6781
|
+
// src/compile/headlessBundle.ts
|
|
6569
6782
|
var LOCAL_ASSET_SCHEME2 = "lecodesfile://";
|
|
6570
|
-
var
|
|
6571
|
-
|
|
6572
|
-
|
|
6573
|
-
|
|
6574
|
-
|
|
6575
|
-
|
|
6576
|
-
|
|
6577
|
-
|
|
6578
|
-
|
|
6579
|
-
if (n.length === 4 && n.every((v) => Number.isFinite(v)))
|
|
6580
|
-
return { safeArea: n, safeAreaBars: [] };
|
|
6581
|
-
throw new CliError(`--safe-area expects "top,right,bottom,left" (4 numbers), got "${explicit}".`);
|
|
6582
|
-
}
|
|
6583
|
-
const device = flagStr(args, "device");
|
|
6584
|
-
if (device) {
|
|
6585
|
-
const preset = DEVICE_SAFE_AREA[device];
|
|
6586
|
-
if (!preset)
|
|
6587
|
-
throw new CliError(`Unknown --device "${device}". Options: ${Object.keys(DEVICE_SAFE_AREA).join(", ")}.`);
|
|
6588
|
-
return { safeArea: preset.insets, safeAreaBars: preset.bars };
|
|
6783
|
+
var compileHeadlessBundle = async (args) => {
|
|
6784
|
+
let root;
|
|
6785
|
+
let manifest;
|
|
6786
|
+
try {
|
|
6787
|
+
root = findProjectRoot(process.cwd());
|
|
6788
|
+
manifest = readManifest(root);
|
|
6789
|
+
} catch {
|
|
6790
|
+
root = process.cwd();
|
|
6791
|
+
manifest = null;
|
|
6589
6792
|
}
|
|
6590
|
-
return;
|
|
6591
|
-
};
|
|
6592
|
-
var render = async (args) => {
|
|
6593
|
-
const timeoutMs = Number(flagStr(args, "timeout")) || 15000;
|
|
6594
|
-
const killer = setTimeout(() => {
|
|
6595
|
-
warnErr(`Render timed out after ${timeoutMs}ms (try --settle / --wait-network, or raise --timeout).`);
|
|
6596
|
-
process.exit(1);
|
|
6597
|
-
}, timeoutMs);
|
|
6598
|
-
killer.unref?.();
|
|
6599
|
-
const root = findProjectRoot(process.cwd());
|
|
6600
|
-
const manifest = readManifest(root);
|
|
6601
|
-
const config = loadConfig();
|
|
6602
|
-
const apiUrl = requireApiUrl(config);
|
|
6603
|
-
const token = requireToken(config);
|
|
6604
|
-
note("Resolving project assets…");
|
|
6605
|
-
const project = await getProject(apiUrl, token, manifest.uuid);
|
|
6606
|
-
const { entries, warnings: collectWarnings } = collectEntries(root, manifest, project);
|
|
6607
6793
|
const publicUrlFlag = flagStr(args, "public-url");
|
|
6608
|
-
const useLocalAssets = !publicUrlFlag && !flagBool(args, "remote-assets");
|
|
6794
|
+
const useLocalAssets = manifest ? !publicUrlFlag && !flagBool(args, "remote-assets") : true;
|
|
6795
|
+
let entries;
|
|
6796
|
+
let warnings;
|
|
6797
|
+
let name;
|
|
6798
|
+
let publicUrl;
|
|
6799
|
+
if (manifest) {
|
|
6800
|
+
const config = loadConfig();
|
|
6801
|
+
const apiUrl = requireApiUrl(config);
|
|
6802
|
+
const token = requireToken(config);
|
|
6803
|
+
note("Resolving project assets…");
|
|
6804
|
+
const project = await getProject(apiUrl, token, manifest.uuid);
|
|
6805
|
+
({ entries, warnings } = collectEntries(root, manifest, project));
|
|
6806
|
+
name = manifest.name;
|
|
6807
|
+
publicUrl = useLocalAssets ? LOCAL_ASSET_SCHEME2 : publicUrlFlag ?? apiUrl;
|
|
6808
|
+
if (useLocalAssets)
|
|
6809
|
+
warnings = warnings.filter((w) => !w.includes("No server URL"));
|
|
6810
|
+
} else {
|
|
6811
|
+
({ entries, warnings } = collectLocalEntries(root, null, ""));
|
|
6812
|
+
name = root.split(/[\\/]/).pop() ?? "project";
|
|
6813
|
+
publicUrl = LOCAL_ASSET_SCHEME2;
|
|
6814
|
+
if (flagBool(args, "remote-assets") || publicUrlFlag) {
|
|
6815
|
+
warnings.push("This is a local project (no server) — assets always load from the working tree.");
|
|
6816
|
+
}
|
|
6817
|
+
}
|
|
6609
6818
|
const compileEntries = useLocalAssets ? entries.map((e) => e.type === "resource" && e.absPath ? { ...e, fileSrc: encodeURIComponent(e.absPath) } : e) : entries;
|
|
6610
|
-
const warnings = useLocalAssets ? collectWarnings.filter((w) => !w.includes("No server URL")) : collectWarnings;
|
|
6611
6819
|
await applyProjectFonts(compileEntries, useLocalAssets ? {
|
|
6612
6820
|
mode: "vendor",
|
|
6613
6821
|
root,
|
|
@@ -6616,11 +6824,17 @@ var render = async (args) => {
|
|
|
6616
6824
|
makeResourceEntry: (path, absPath) => ({ path, type: "resource", fileSrc: encodeURIComponent(absPath), absPath })
|
|
6617
6825
|
} : { mode: "cdn", root });
|
|
6618
6826
|
note("Compiling…");
|
|
6827
|
+
const entryFlag = flagStr(args, "entry");
|
|
6828
|
+
if (!entryFlag) {
|
|
6829
|
+
const ambiguous = ambiguousEntryWarning(compileEntries);
|
|
6830
|
+
if (ambiguous)
|
|
6831
|
+
warnings.push(ambiguous);
|
|
6832
|
+
}
|
|
6619
6833
|
const js = await compileProject2({
|
|
6620
6834
|
entries: compileEntries,
|
|
6621
|
-
name
|
|
6622
|
-
publicUrl
|
|
6623
|
-
entryOverride:
|
|
6835
|
+
name,
|
|
6836
|
+
publicUrl,
|
|
6837
|
+
entryOverride: entryFlag,
|
|
6624
6838
|
header: false,
|
|
6625
6839
|
format: "iife"
|
|
6626
6840
|
});
|
|
@@ -6630,30 +6844,141 @@ var render = async (args) => {
|
|
|
6630
6844
|
if (js.includes("_creator2d") && !useLocalAssets && !flagBool(args, "wait-network")) {
|
|
6631
6845
|
warnings.push("2D scene detected: sprite textures load over the network — add --wait-network to include them (else sprites render as flat placeholders).");
|
|
6632
6846
|
}
|
|
6633
|
-
|
|
6847
|
+
return { js, root, warnings, useLocalAssets };
|
|
6848
|
+
};
|
|
6849
|
+
|
|
6850
|
+
// src/commands/render.ts
|
|
6851
|
+
var DEVICE_SAFE_AREA = {
|
|
6852
|
+
none: { insets: [0, 0, 0, 0], bars: [] },
|
|
6853
|
+
iphone: { insets: [59, 0, 34, 0], bars: [] },
|
|
6854
|
+
android: { insets: [24, 0, 48, 0], bars: ["top", "bottom"] }
|
|
6855
|
+
};
|
|
6856
|
+
var resolveSafeAreaChoice = (explicit, device) => {
|
|
6857
|
+
if (explicit) {
|
|
6858
|
+
const n = explicit.split(",").map((s) => Number(s.trim()));
|
|
6859
|
+
if (n.length === 4 && n.every((v) => Number.isFinite(v)))
|
|
6860
|
+
return { safeArea: n, safeAreaBars: [] };
|
|
6861
|
+
throw new CliError(`--safe-area expects "top,right,bottom,left" (4 numbers), got "${explicit}".`);
|
|
6862
|
+
}
|
|
6863
|
+
if (device) {
|
|
6864
|
+
const preset = DEVICE_SAFE_AREA[device];
|
|
6865
|
+
if (!preset)
|
|
6866
|
+
throw new CliError(`Unknown --device "${device}". Options: ${Object.keys(DEVICE_SAFE_AREA).join(", ")}.`);
|
|
6867
|
+
return { safeArea: preset.insets, safeAreaBars: preset.bars };
|
|
6868
|
+
}
|
|
6869
|
+
return;
|
|
6870
|
+
};
|
|
6871
|
+
var requireRenderer = async () => {
|
|
6634
6872
|
try {
|
|
6635
|
-
|
|
6873
|
+
return await import("lecodes-renderer/headless");
|
|
6636
6874
|
} catch {
|
|
6637
6875
|
throw new CliError("The renderer isn't installed. Run: npm i -D lecodes-renderer");
|
|
6638
6876
|
}
|
|
6639
|
-
|
|
6640
|
-
|
|
6641
|
-
|
|
6877
|
+
};
|
|
6878
|
+
var fileScenarioIO = (dir) => ({
|
|
6879
|
+
write: (relPath, data) => {
|
|
6880
|
+
const abs = resolve6(dir, relPath);
|
|
6881
|
+
mkdirSync13(dirname11(abs), { recursive: true });
|
|
6882
|
+
writeFileSync14(abs, data);
|
|
6883
|
+
}
|
|
6884
|
+
});
|
|
6885
|
+
var resolveFixtures = (value, baseDir) => {
|
|
6886
|
+
if (value === undefined)
|
|
6887
|
+
return;
|
|
6888
|
+
let map = value;
|
|
6889
|
+
if (typeof value === "string") {
|
|
6890
|
+
const abs = resolve6(baseDir, value);
|
|
6891
|
+
try {
|
|
6892
|
+
map = JSON.parse(readFileSync17(abs, "utf8"));
|
|
6893
|
+
} catch (e) {
|
|
6894
|
+
throw new CliError(`Can't read fixtures "${value}": ${e instanceof Error ? e.message : String(e)}`);
|
|
6895
|
+
}
|
|
6896
|
+
}
|
|
6897
|
+
if (typeof map !== "object" || map === null || Array.isArray(map)) {
|
|
6898
|
+
throw new CliError(`Fixtures must be an object mapping URL patterns to { status?, json?, text? }.`);
|
|
6899
|
+
}
|
|
6900
|
+
return map;
|
|
6901
|
+
};
|
|
6902
|
+
var readScenarioFile = (path) => {
|
|
6903
|
+
let raw;
|
|
6904
|
+
try {
|
|
6905
|
+
raw = JSON.parse(readFileSync17(resolve6(path), "utf8"));
|
|
6906
|
+
} catch (e) {
|
|
6907
|
+
throw new CliError(`Can't read scenario "${path}": ${e instanceof Error ? e.message : String(e)}`);
|
|
6908
|
+
}
|
|
6909
|
+
if (Array.isArray(raw))
|
|
6910
|
+
return { steps: raw };
|
|
6911
|
+
if (typeof raw !== "object" || raw === null)
|
|
6912
|
+
throw new CliError(`Scenario "${path}" must be a JSON object with "steps" (or a bare step array).`);
|
|
6913
|
+
const file = raw;
|
|
6914
|
+
if (file.steps === undefined)
|
|
6915
|
+
throw new CliError(`Scenario "${path}" has no "steps".`);
|
|
6916
|
+
return file;
|
|
6917
|
+
};
|
|
6918
|
+
var render = async (args) => {
|
|
6919
|
+
const scriptFlag = flagStr(args, "script");
|
|
6920
|
+
const scenario = scriptFlag !== undefined ? readScenarioFile(scriptFlag) : null;
|
|
6921
|
+
const stepCount = scenario && Array.isArray(scenario.steps) ? scenario.steps.length : 0;
|
|
6922
|
+
const timeoutMs = Number(flagStr(args, "timeout")) || 15000 + stepCount * 3000;
|
|
6923
|
+
const killer = setTimeout(() => {
|
|
6924
|
+
warnErr(`Render timed out after ${timeoutMs}ms (try --settle / --wait-network, or raise --timeout).`);
|
|
6925
|
+
process.exit(1);
|
|
6926
|
+
}, timeoutMs);
|
|
6927
|
+
killer.unref?.();
|
|
6928
|
+
const { js, warnings, useLocalAssets } = await compileHeadlessBundle(args);
|
|
6929
|
+
const renderer = await requireRenderer();
|
|
6930
|
+
const width = Number(flagStr(args, "width")) || scenario?.width || 390;
|
|
6931
|
+
const height = Number(flagStr(args, "height")) || scenario?.height || 844;
|
|
6932
|
+
const settleStr = flagStr(args, "settle") ?? (scenario?.settle !== undefined ? String(scenario.settle) : undefined);
|
|
6642
6933
|
const timeStr = flagStr(args, "time");
|
|
6643
6934
|
const onConsole = flagBool(args, "logs") ? (level, text2) => logErr(`${c.dim(`[${level}]`)} ${text2}`) : undefined;
|
|
6935
|
+
const envelopeFixtures = resolveFixtures(scenario?.fixtures, scriptFlag !== undefined ? dirname11(resolve6(scriptFlag)) : ".");
|
|
6936
|
+
const flagFixtures = resolveFixtures(flagStr(args, "fixtures"), ".");
|
|
6937
|
+
const fixtures = envelopeFixtures || flagFixtures ? { ...envelopeFixtures, ...flagFixtures } : undefined;
|
|
6938
|
+
if (fixtures) {
|
|
6939
|
+
try {
|
|
6940
|
+
renderer.validateFixtures(fixtures);
|
|
6941
|
+
} catch (e) {
|
|
6942
|
+
throw new CliError(e instanceof Error ? e.message : String(e));
|
|
6943
|
+
}
|
|
6944
|
+
}
|
|
6644
6945
|
const timing = {
|
|
6645
6946
|
settleMs: settleStr !== undefined ? Number(settleStr) : undefined,
|
|
6646
6947
|
waitNetwork: flagBool(args, "wait-network"),
|
|
6647
6948
|
localAssets: useLocalAssets,
|
|
6949
|
+
fixtures,
|
|
6648
6950
|
timeMs: timeStr !== undefined ? Number(timeStr) : undefined,
|
|
6649
6951
|
afterAnimations: !flagBool(args, "no-after-animations"),
|
|
6650
|
-
...
|
|
6952
|
+
...resolveSafeAreaChoice(flagStr(args, "safe-area") ?? scenario?.safeArea, flagStr(args, "device") ?? scenario?.device),
|
|
6651
6953
|
onConsole
|
|
6652
6954
|
};
|
|
6653
|
-
const done = (flush) => flush(() => {
|
|
6955
|
+
const done = (flush, code = 0) => flush(() => {
|
|
6654
6956
|
clearTimeout(killer);
|
|
6655
|
-
process.exit(
|
|
6957
|
+
process.exit(code);
|
|
6656
6958
|
});
|
|
6959
|
+
if (scenario) {
|
|
6960
|
+
const steps = renderer.parseScenarioSteps(scenario.steps);
|
|
6961
|
+
const outDir = flagStr(args, "out-dir") ?? ".";
|
|
6962
|
+
const needsRaster = steps.some((s) => s.screenshot !== undefined);
|
|
6963
|
+
note(`Running ${steps.length} step${steps.length === 1 ? "" : "s"}…`);
|
|
6964
|
+
const session = await renderer.createSession(js, { width, height, ...timing, raster: needsRaster ? true : "auto" });
|
|
6965
|
+
try {
|
|
6966
|
+
const result2 = await renderer.runScenario(session, steps, fileScenarioIO(outDir));
|
|
6967
|
+
for (const w of [...warnings, ...session.warnings])
|
|
6968
|
+
warnErr(w);
|
|
6969
|
+
for (const st of result2.steps) {
|
|
6970
|
+
logErr(st.ok ? `${c.green("✓")} ${st.desc}` : `${c.red("✗")} ${st.desc}
|
|
6971
|
+
${c.red(st.error ?? "failed")}`);
|
|
6972
|
+
}
|
|
6973
|
+
if (!result2.ok && result2.failureArtifacts.length > 0) {
|
|
6974
|
+
logErr(c.dim(` failing frame: ${result2.failureArtifacts.map((p) => resolve6(outDir, p)).join(", ")}`));
|
|
6975
|
+
}
|
|
6976
|
+
logErr(result2.ok ? c.green(`✓ ${result2.steps.length}/${steps.length} steps passed`) : c.red(`✗ failed at step ${result2.steps.length} of ${steps.length}`));
|
|
6977
|
+
return done((cb) => cb(), result2.ok ? 0 : 1);
|
|
6978
|
+
} finally {
|
|
6979
|
+
session.dispose();
|
|
6980
|
+
}
|
|
6981
|
+
}
|
|
6657
6982
|
const pngFlag = flagStr(args, "png") ?? (flagBool(args, "png") ? "screenshot.png" : undefined);
|
|
6658
6983
|
if (pngFlag !== undefined) {
|
|
6659
6984
|
note("Rendering PNG…");
|
|
@@ -6694,8 +7019,8 @@ var render = async (args) => {
|
|
|
6694
7019
|
};
|
|
6695
7020
|
|
|
6696
7021
|
// src/commands/pull.ts
|
|
6697
|
-
import { existsSync as existsSync17, mkdirSync as
|
|
6698
|
-
import { dirname as
|
|
7022
|
+
import { existsSync as existsSync17, mkdirSync as mkdirSync14, rmSync as rmSync2, writeFileSync as writeFileSync15 } from "node:fs";
|
|
7023
|
+
import { dirname as dirname12, join as join19 } from "node:path";
|
|
6699
7024
|
var pull = async (args) => {
|
|
6700
7025
|
const root = findProjectRoot(process.cwd());
|
|
6701
7026
|
const manifest = readManifest(root);
|
|
@@ -6755,7 +7080,7 @@ var pullPaths = async (root, manifest, project, paths, apiUrl, token) => {
|
|
|
6755
7080
|
for (const asset of targets) {
|
|
6756
7081
|
const key = pathById.get(asset.id);
|
|
6757
7082
|
const abs = join19(root, ...key.replace(/^\//, "").split("/"));
|
|
6758
|
-
|
|
7083
|
+
mkdirSync14(dirname12(abs), { recursive: true });
|
|
6759
7084
|
let sha;
|
|
6760
7085
|
if (asset.type === "file") {
|
|
6761
7086
|
if (!asset.file?.src)
|
|
@@ -6778,7 +7103,7 @@ var pullPaths = async (root, manifest, project, paths, apiUrl, token) => {
|
|
|
6778
7103
|
|
|
6779
7104
|
// src/commands/push.ts
|
|
6780
7105
|
import { basename as basename10 } from "node:path";
|
|
6781
|
-
import { readFileSync as
|
|
7106
|
+
import { readFileSync as readFileSync18 } from "node:fs";
|
|
6782
7107
|
var push = async (args) => {
|
|
6783
7108
|
const root = findProjectRoot(process.cwd());
|
|
6784
7109
|
const manifest = readManifest(root);
|
|
@@ -6837,9 +7162,9 @@ var push = async (args) => {
|
|
|
6837
7162
|
if (source)
|
|
6838
7163
|
entry.assetId = source.id;
|
|
6839
7164
|
if (isTextKind(kind)) {
|
|
6840
|
-
entry.text =
|
|
7165
|
+
entry.text = readFileSync18(file.absPath, "utf8");
|
|
6841
7166
|
} else if (!source || source.sha !== file.sha) {
|
|
6842
|
-
const blob = await uploadBlob(apiUrl, token, manifest.uuid, basename10(file.path),
|
|
7167
|
+
const blob = await uploadBlob(apiUrl, token, manifest.uuid, basename10(file.path), readFileSync18(file.absPath));
|
|
6843
7168
|
entry.blobFileId = blob.id;
|
|
6844
7169
|
uploaded++;
|
|
6845
7170
|
}
|
|
@@ -6892,11 +7217,11 @@ var reportPublish = (res) => {
|
|
|
6892
7217
|
};
|
|
6893
7218
|
|
|
6894
7219
|
// src/commands/scene.ts
|
|
6895
|
-
import { existsSync as existsSync19, readFileSync as
|
|
6896
|
-
import { basename as basename11, dirname as
|
|
7220
|
+
import { existsSync as existsSync19, readFileSync as readFileSync20, readdirSync as readdirSync8, statSync as statSync4, writeFileSync as writeFileSync16 } from "node:fs";
|
|
7221
|
+
import { basename as basename11, dirname as dirname13, join as join21, resolve as resolve7 } from "node:path";
|
|
6897
7222
|
|
|
6898
7223
|
// src/compile/sceneCompile.ts
|
|
6899
|
-
import { existsSync as existsSync18, readFileSync as
|
|
7224
|
+
import { existsSync as existsSync18, readFileSync as readFileSync19 } from "node:fs";
|
|
6900
7225
|
import { join as join20 } from "node:path";
|
|
6901
7226
|
var TEXT_EXT = /\.(ts|js|json|svg|txt|md)$/i;
|
|
6902
7227
|
var rel = (key) => key.replace(/^\//, "");
|
|
@@ -6907,7 +7232,7 @@ var resolveHarness = (loadSceneHarness2) => {
|
|
|
6907
7232
|
} catch {
|
|
6908
7233
|
const vendored = join20(distRoot, "runtime", "scene-harness.json");
|
|
6909
7234
|
if (existsSync18(vendored))
|
|
6910
|
-
return JSON.parse(
|
|
7235
|
+
return JSON.parse(readFileSync19(vendored, "utf8"));
|
|
6911
7236
|
throw new CliError("scene-editor harness sources not found — reinstall the CLI or set LECODES_SCENE_HARNESS_DIR.");
|
|
6912
7237
|
}
|
|
6913
7238
|
};
|
|
@@ -6919,7 +7244,7 @@ var compileSceneEditor = async (opts) => {
|
|
|
6919
7244
|
for (const file of scan.files) {
|
|
6920
7245
|
const path = "./" + rel(file.path);
|
|
6921
7246
|
if (TEXT_EXT.test(path)) {
|
|
6922
|
-
entries.push({ path, type: "text", text:
|
|
7247
|
+
entries.push({ path, type: "text", text: readFileSync19(file.absPath, "utf8") });
|
|
6923
7248
|
} else {
|
|
6924
7249
|
entries.push({ path, type: "resource", fileSrc: "/project/" + rel(file.path) });
|
|
6925
7250
|
}
|
|
@@ -6963,7 +7288,7 @@ var getContext2 = (args) => {
|
|
|
6963
7288
|
if (existsSync19(abs) && statSync4(abs).isDirectory())
|
|
6964
7289
|
root = abs;
|
|
6965
7290
|
else if (abs.endsWith(".scene.ts") && existsSync19(abs))
|
|
6966
|
-
root =
|
|
7291
|
+
root = dirname13(abs);
|
|
6967
7292
|
else
|
|
6968
7293
|
throw new CliError(`No such folder or .scene.ts: ${pathArg}`);
|
|
6969
7294
|
}
|
|
@@ -6976,7 +7301,7 @@ var writeMcpConfig2 = (root, port) => {
|
|
|
6976
7301
|
let config = {};
|
|
6977
7302
|
if (existsSync19(path)) {
|
|
6978
7303
|
try {
|
|
6979
|
-
config = JSON.parse(
|
|
7304
|
+
config = JSON.parse(readFileSync20(path, "utf8"));
|
|
6980
7305
|
} catch {
|
|
6981
7306
|
warnErr(".mcp.json exists but isn't valid JSON — left it untouched. Add the lecodes-scene server by hand.");
|
|
6982
7307
|
return;
|
|
@@ -7068,23 +7393,254 @@ var status = async (_args) => {
|
|
|
7068
7393
|
info(c.dim(` ${scan.ignored.length} ignored (.lecodesignore)`));
|
|
7069
7394
|
};
|
|
7070
7395
|
|
|
7396
|
+
// src/commands/test.ts
|
|
7397
|
+
import { existsSync as existsSync20, readFileSync as readFileSync21, readdirSync as readdirSync9 } from "node:fs";
|
|
7398
|
+
import { dirname as dirname14, resolve as resolve8 } from "node:path";
|
|
7399
|
+
var deriveDesignFlows = (meta, entryScreen) => {
|
|
7400
|
+
const edges = meta.edges ?? [];
|
|
7401
|
+
const isState = (id) => id.includes("@");
|
|
7402
|
+
const verifiable = edges.filter((e) => e.activator && !isState(e.from) && !isState(e.to));
|
|
7403
|
+
const prev = new Map;
|
|
7404
|
+
const visited = new Set([entryScreen]);
|
|
7405
|
+
const queue = [entryScreen];
|
|
7406
|
+
while (queue.length > 0) {
|
|
7407
|
+
const at = queue.shift();
|
|
7408
|
+
for (const e of verifiable) {
|
|
7409
|
+
if (e.from !== at || visited.has(e.to))
|
|
7410
|
+
continue;
|
|
7411
|
+
visited.add(e.to);
|
|
7412
|
+
prev.set(e.to, e);
|
|
7413
|
+
queue.push(e.to);
|
|
7414
|
+
}
|
|
7415
|
+
}
|
|
7416
|
+
const pathTo = (screen) => {
|
|
7417
|
+
if (screen === entryScreen)
|
|
7418
|
+
return [];
|
|
7419
|
+
if (!visited.has(screen))
|
|
7420
|
+
return null;
|
|
7421
|
+
const hops = [];
|
|
7422
|
+
for (let at = screen;at !== entryScreen; at = hops[0].from)
|
|
7423
|
+
hops.unshift(prev.get(at));
|
|
7424
|
+
return hops;
|
|
7425
|
+
};
|
|
7426
|
+
const flows = [];
|
|
7427
|
+
const skipped = [];
|
|
7428
|
+
for (const e of edges) {
|
|
7429
|
+
const name = `${e.from} —${e.activator ?? "?"}→ ${e.to}`;
|
|
7430
|
+
if (!e.activator) {
|
|
7431
|
+
skipped.push({ name, reason: "no activator (backend/gesture-driven) — cover it with a tests/*.flow.json" });
|
|
7432
|
+
continue;
|
|
7433
|
+
}
|
|
7434
|
+
if (isState(e.from) || isState(e.to)) {
|
|
7435
|
+
skipped.push({ name, reason: "screen-state edge (@state) — states are runtime conditions, not screens" });
|
|
7436
|
+
continue;
|
|
7437
|
+
}
|
|
7438
|
+
const path = pathTo(e.from);
|
|
7439
|
+
if (path === null) {
|
|
7440
|
+
skipped.push({ name, reason: `"${e.from}" has no activator path from "${entryScreen}" (tab-bar navigation?) — cover it with a tests/*.flow.json` });
|
|
7441
|
+
continue;
|
|
7442
|
+
}
|
|
7443
|
+
flows.push({
|
|
7444
|
+
name,
|
|
7445
|
+
kind: "flow",
|
|
7446
|
+
steps: [
|
|
7447
|
+
...path.flatMap((hop) => [{ fill: {} }, { tap: hop.activator, expect: { screen: hop.to } }]),
|
|
7448
|
+
{ fill: {} },
|
|
7449
|
+
{ tap: e.activator, expect: { screen: e.to } }
|
|
7450
|
+
]
|
|
7451
|
+
});
|
|
7452
|
+
}
|
|
7453
|
+
return { flows, skipped };
|
|
7454
|
+
};
|
|
7455
|
+
var slug3 = (s) => s.replace(/[^a-zA-Z0-9-_]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "case";
|
|
7456
|
+
var readFlowFile = (abs, displayName) => {
|
|
7457
|
+
let raw;
|
|
7458
|
+
try {
|
|
7459
|
+
raw = JSON.parse(readFileSync21(abs, "utf8"));
|
|
7460
|
+
} catch (e) {
|
|
7461
|
+
throw new CliError(`Can't read "${displayName}": ${e instanceof Error ? e.message : String(e)}`);
|
|
7462
|
+
}
|
|
7463
|
+
if (Array.isArray(raw))
|
|
7464
|
+
return { name: displayName, kind: "file", steps: raw };
|
|
7465
|
+
if (typeof raw !== "object" || raw === null || raw.steps === undefined) {
|
|
7466
|
+
throw new CliError(`"${displayName}" must be a JSON object with "steps" (or a bare step array).`);
|
|
7467
|
+
}
|
|
7468
|
+
const file = raw;
|
|
7469
|
+
return {
|
|
7470
|
+
name: file.name ?? displayName,
|
|
7471
|
+
kind: "file",
|
|
7472
|
+
steps: file.steps,
|
|
7473
|
+
fixtures: resolveFixtures(file.fixtures, dirname14(abs)),
|
|
7474
|
+
width: file.width,
|
|
7475
|
+
height: file.height,
|
|
7476
|
+
settle: file.settle,
|
|
7477
|
+
device: file.device,
|
|
7478
|
+
safeArea: file.safeArea
|
|
7479
|
+
};
|
|
7480
|
+
};
|
|
7481
|
+
var test = async (args) => {
|
|
7482
|
+
const { js, warnings, useLocalAssets, root } = await compileHeadlessBundle(args);
|
|
7483
|
+
const renderer = await requireRenderer();
|
|
7484
|
+
const testsDir = resolve8(root, flagStr(args, "dir") ?? "tests");
|
|
7485
|
+
const designMetaPath = resolve8(root, flagStr(args, "design-dir") ?? "design", "meta.json");
|
|
7486
|
+
const asJson = flagBool(args, "json");
|
|
7487
|
+
const out = asJson ? logErr : log;
|
|
7488
|
+
const cases = [];
|
|
7489
|
+
if (args._.length > 0) {
|
|
7490
|
+
for (const given of args._) {
|
|
7491
|
+
const candidates = [resolve8(given), resolve8(testsDir, given), resolve8(testsDir, `${given}.flow.json`)];
|
|
7492
|
+
const abs = candidates.find((p) => existsSync20(p));
|
|
7493
|
+
if (!abs)
|
|
7494
|
+
throw new CliError(`No such test: "${given}" (looked in ${testsDir}).`);
|
|
7495
|
+
cases.push(readFlowFile(abs, given));
|
|
7496
|
+
}
|
|
7497
|
+
} else if (existsSync20(testsDir)) {
|
|
7498
|
+
for (const f2 of readdirSync9(testsDir).filter((f3) => f3.endsWith(".flow.json")).sort()) {
|
|
7499
|
+
cases.push(readFlowFile(resolve8(testsDir, f2), f2));
|
|
7500
|
+
}
|
|
7501
|
+
}
|
|
7502
|
+
const fixturesFlag = flagStr(args, "fixtures");
|
|
7503
|
+
const defaultPack = resolve8(testsDir, "fixtures.json");
|
|
7504
|
+
const flowFixtures = fixturesFlag !== undefined ? resolveFixtures(fixturesFlag, ".") : existsSync20(defaultPack) ? resolveFixtures("fixtures.json", testsDir) : undefined;
|
|
7505
|
+
const flowsOnly = flagBool(args, "flows");
|
|
7506
|
+
const wantFlows = !flagBool(args, "no-flows") && args._.length === 0;
|
|
7507
|
+
if (flowsOnly)
|
|
7508
|
+
cases.length = 0;
|
|
7509
|
+
let skipped = [];
|
|
7510
|
+
let entryScreen = null;
|
|
7511
|
+
if ((wantFlows || flowsOnly) && existsSync20(designMetaPath)) {
|
|
7512
|
+
let meta;
|
|
7513
|
+
try {
|
|
7514
|
+
meta = JSON.parse(readFileSync21(designMetaPath, "utf8"));
|
|
7515
|
+
} catch (e) {
|
|
7516
|
+
throw new CliError(`Can't read ${designMetaPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
7517
|
+
}
|
|
7518
|
+
if ((meta.edges ?? []).length > 0) {
|
|
7519
|
+
note("Probing the start screen…");
|
|
7520
|
+
const probe = await renderer.createSession(js, { raster: false, localAssets: useLocalAssets, fixtures: flowFixtures });
|
|
7521
|
+
entryScreen = probe.screenName();
|
|
7522
|
+
probe.dispose();
|
|
7523
|
+
if (entryScreen === null) {
|
|
7524
|
+
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([...], { name: "login" })).');
|
|
7525
|
+
} else {
|
|
7526
|
+
const derived = deriveDesignFlows(meta, entryScreen);
|
|
7527
|
+
cases.push(...derived.flows.map((f2) => ({ ...f2, fixtures: flowFixtures })));
|
|
7528
|
+
skipped = derived.skipped;
|
|
7529
|
+
}
|
|
7530
|
+
}
|
|
7531
|
+
} else if (flowsOnly) {
|
|
7532
|
+
throw new CliError(`--flows needs a design graph (${designMetaPath} not found).`);
|
|
7533
|
+
}
|
|
7534
|
+
if (cases.length === 0) {
|
|
7535
|
+
for (const w of warnings)
|
|
7536
|
+
warnErr(w);
|
|
7537
|
+
throw new CliError(`No tests found. Write tests/*.flow.json scenarios, or draw activator edges in the design board (design/meta.json).`);
|
|
7538
|
+
}
|
|
7539
|
+
const timeoutMs = Number(flagStr(args, "timeout")) || 20000 + cases.length * 1e4;
|
|
7540
|
+
const killer = setTimeout(() => {
|
|
7541
|
+
warnErr(`Test run timed out after ${timeoutMs}ms (raise --timeout or lower --settle).`);
|
|
7542
|
+
process.exit(1);
|
|
7543
|
+
}, timeoutMs);
|
|
7544
|
+
killer.unref?.();
|
|
7545
|
+
const onConsole = flagBool(args, "logs") ? (level, text) => logErr(`${c.dim(`[${level}]`)} ${text}`) : undefined;
|
|
7546
|
+
const artifactsRoot = resolve8(root, flagStr(args, "artifacts") ?? resolve8(testsDir, ".artifacts"));
|
|
7547
|
+
const results = [];
|
|
7548
|
+
for (const kase of cases) {
|
|
7549
|
+
const flagSettle = flagStr(args, "settle");
|
|
7550
|
+
let result;
|
|
7551
|
+
try {
|
|
7552
|
+
const steps = renderer.parseScenarioSteps(kase.steps);
|
|
7553
|
+
const session = await renderer.createSession(js, {
|
|
7554
|
+
width: Number(flagStr(args, "width")) || kase.width || 390,
|
|
7555
|
+
height: Number(flagStr(args, "height")) || kase.height || 844,
|
|
7556
|
+
settleMs: flagSettle !== undefined ? Number(flagSettle) : kase.settle,
|
|
7557
|
+
waitNetwork: flagBool(args, "wait-network"),
|
|
7558
|
+
localAssets: useLocalAssets,
|
|
7559
|
+
fixtures: kase.fixtures,
|
|
7560
|
+
...resolveSafeAreaChoice(flagStr(args, "safe-area") ?? kase.safeArea, flagStr(args, "device") ?? kase.device),
|
|
7561
|
+
onConsole
|
|
7562
|
+
});
|
|
7563
|
+
try {
|
|
7564
|
+
const io = fileScenarioIO(resolve8(artifactsRoot, slug3(kase.name)));
|
|
7565
|
+
const outcome = await renderer.runScenario(session, steps, io);
|
|
7566
|
+
result = {
|
|
7567
|
+
name: kase.name,
|
|
7568
|
+
kind: kase.kind,
|
|
7569
|
+
ok: outcome.ok,
|
|
7570
|
+
steps: outcome.steps.map((s) => ({ desc: s.desc, ok: s.ok, error: s.error })),
|
|
7571
|
+
artifacts: outcome.failureArtifacts.map((p) => resolve8(artifactsRoot, slug3(kase.name), p))
|
|
7572
|
+
};
|
|
7573
|
+
} finally {
|
|
7574
|
+
session.dispose();
|
|
7575
|
+
}
|
|
7576
|
+
} catch (e) {
|
|
7577
|
+
result = { name: kase.name, kind: kase.kind, ok: false, steps: [], error: e instanceof Error ? e.message : String(e), artifacts: [] };
|
|
7578
|
+
}
|
|
7579
|
+
results.push(result);
|
|
7580
|
+
if (result.ok) {
|
|
7581
|
+
out(`${c.green("✓")} ${result.name}`);
|
|
7582
|
+
} else {
|
|
7583
|
+
out(`${c.red("✗")} ${result.name}`);
|
|
7584
|
+
if (result.error)
|
|
7585
|
+
out(` ${c.red(result.error)}`);
|
|
7586
|
+
for (const s of result.steps) {
|
|
7587
|
+
out(s.ok ? c.dim(` ✓ ${s.desc}`) : ` ${c.red(`✗ ${s.desc} — ${s.error}`)}`);
|
|
7588
|
+
}
|
|
7589
|
+
if (result.artifacts.length > 0)
|
|
7590
|
+
out(c.dim(` failing frame: ${result.artifacts.join(", ")}`));
|
|
7591
|
+
}
|
|
7592
|
+
}
|
|
7593
|
+
for (const w of warnings)
|
|
7594
|
+
warnErr(w);
|
|
7595
|
+
const passed = results.filter((r) => r.ok).length;
|
|
7596
|
+
const failed = results.length - passed;
|
|
7597
|
+
if (skipped.length > 0) {
|
|
7598
|
+
out("");
|
|
7599
|
+
out(c.dim(`Not machine-verifiable (${skipped.length}):`));
|
|
7600
|
+
for (const s of skipped)
|
|
7601
|
+
out(c.dim(` - ${s.name}: ${s.reason}`));
|
|
7602
|
+
}
|
|
7603
|
+
out("");
|
|
7604
|
+
const flowCount = results.filter((r) => r.kind === "flow").length;
|
|
7605
|
+
const summary = [
|
|
7606
|
+
`${passed} passed`,
|
|
7607
|
+
failed > 0 ? c.red(`${failed} failed`) : undefined,
|
|
7608
|
+
skipped.length > 0 ? `${skipped.length} unverifiable` : undefined,
|
|
7609
|
+
flowCount > 0 ? c.dim(`(${flowCount} from the design graph${entryScreen ? `, start: ${entryScreen}` : ""})`) : undefined
|
|
7610
|
+
].filter(Boolean).join(", ");
|
|
7611
|
+
out(failed > 0 ? `${c.red("✗")} ${summary}` : `${c.green("✓")} ${summary}`);
|
|
7612
|
+
if (asJson) {
|
|
7613
|
+
process.stdout.write(JSON.stringify({
|
|
7614
|
+
ok: failed === 0,
|
|
7615
|
+
passed,
|
|
7616
|
+
failed,
|
|
7617
|
+
entryScreen,
|
|
7618
|
+
cases: results,
|
|
7619
|
+
unverifiable: skipped
|
|
7620
|
+
}, null, 2) + `
|
|
7621
|
+
`);
|
|
7622
|
+
}
|
|
7623
|
+
clearTimeout(killer);
|
|
7624
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
7625
|
+
};
|
|
7626
|
+
|
|
7071
7627
|
// src/index.ts
|
|
7072
|
-
import { existsSync as
|
|
7628
|
+
import { existsSync as existsSync22, readFileSync as readFileSync23, realpathSync as realpathSync2 } from "node:fs";
|
|
7073
7629
|
import { createRequire as createRequire4 } from "node:module";
|
|
7074
|
-
import { delimiter as delimiter2, dirname as
|
|
7630
|
+
import { delimiter as delimiter2, dirname as dirname16, join as join23 } from "node:path";
|
|
7075
7631
|
|
|
7076
7632
|
// src/commands/install.ts
|
|
7077
7633
|
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
7078
|
-
import { appendFileSync as appendFileSync2, chmodSync, cpSync as cpSync2, existsSync as
|
|
7634
|
+
import { appendFileSync as appendFileSync2, chmodSync, cpSync as cpSync2, existsSync as existsSync21, mkdirSync as mkdirSync15, readFileSync as readFileSync22, realpathSync, rmSync as rmSync3, symlinkSync } from "node:fs";
|
|
7079
7635
|
import { homedir as homedir3 } from "node:os";
|
|
7080
|
-
import { basename as basename12, delimiter, dirname as
|
|
7636
|
+
import { basename as basename12, delimiter, dirname as dirname15, join as join22, resolve as resolve9, sep } from "node:path";
|
|
7081
7637
|
var isWin = process.platform === "win32";
|
|
7082
7638
|
var HOME = homedir3();
|
|
7083
7639
|
var safeRealpath = (p) => {
|
|
7084
7640
|
try {
|
|
7085
7641
|
return realpathSync(p);
|
|
7086
7642
|
} catch {
|
|
7087
|
-
return
|
|
7643
|
+
return resolve9(p);
|
|
7088
7644
|
}
|
|
7089
7645
|
};
|
|
7090
7646
|
var onPath = (dir) => {
|
|
@@ -7100,19 +7656,19 @@ var onPath = (dir) => {
|
|
|
7100
7656
|
});
|
|
7101
7657
|
};
|
|
7102
7658
|
var install = async (args) => {
|
|
7103
|
-
const bundle =
|
|
7659
|
+
const bundle = dirname15(process.execPath);
|
|
7104
7660
|
const exeName = basename12(process.execPath);
|
|
7105
7661
|
const chiselName = isWin ? "chisel.exe" : "chisel";
|
|
7106
|
-
if (!
|
|
7662
|
+
if (!existsSync21(join22(bundle, "runtime")) || !existsSync21(join22(bundle, chiselName))) {
|
|
7107
7663
|
throw new CliError("`lecodes install` only works from the packaged release build (the archive containing lecodes + " + "chisel + runtime/). You appear to be running from source or an npm install — install globally " + "with your package manager there instead.");
|
|
7108
7664
|
}
|
|
7109
|
-
const companions = [exeName, chiselName, "runtime", "README.txt"].filter((f2) =>
|
|
7665
|
+
const companions = [exeName, chiselName, "runtime", "README.txt"].filter((f2) => existsSync21(join22(bundle, f2)));
|
|
7110
7666
|
if (isWin)
|
|
7111
7667
|
return installWindows(args, bundle, companions);
|
|
7112
7668
|
return installUnix(args, bundle, companions);
|
|
7113
7669
|
};
|
|
7114
7670
|
var copyBundle = (bundle, target, companions) => {
|
|
7115
|
-
|
|
7671
|
+
mkdirSync15(target, { recursive: true });
|
|
7116
7672
|
if (safeRealpath(target) === safeRealpath(bundle))
|
|
7117
7673
|
return;
|
|
7118
7674
|
for (const f2 of companions) {
|
|
@@ -7130,8 +7686,8 @@ var permError = (e, dir, system) => {
|
|
|
7130
7686
|
};
|
|
7131
7687
|
var installUnix = (args, bundle, companions) => {
|
|
7132
7688
|
const system = flagBool(args, "system");
|
|
7133
|
-
const libDir =
|
|
7134
|
-
const binDir =
|
|
7689
|
+
const libDir = resolve9(flagStr(args, "dir") ?? (system ? "/usr/local/lib/lecodes-cli" : join22(HOME, ".local", "lib", "lecodes-cli")));
|
|
7690
|
+
const binDir = resolve9(flagStr(args, "bin") ?? (system ? "/usr/local/bin" : join22(HOME, ".local", "bin")));
|
|
7135
7691
|
const link = join22(binDir, "lecodes");
|
|
7136
7692
|
try {
|
|
7137
7693
|
copyBundle(bundle, libDir, companions);
|
|
@@ -7141,7 +7697,7 @@ var installUnix = (args, bundle, companions) => {
|
|
|
7141
7697
|
throw permError(e, libDir, system);
|
|
7142
7698
|
}
|
|
7143
7699
|
try {
|
|
7144
|
-
|
|
7700
|
+
mkdirSync15(binDir, { recursive: true });
|
|
7145
7701
|
rmSync3(link, { force: true });
|
|
7146
7702
|
symlinkSync(join22(libDir, "lecodes"), link);
|
|
7147
7703
|
} catch (e) {
|
|
@@ -7164,7 +7720,7 @@ var appendPathToShellRc = (binDir) => {
|
|
|
7164
7720
|
const { file, name } = shellRc();
|
|
7165
7721
|
const line = `export PATH="${binDir}:$PATH"`;
|
|
7166
7722
|
try {
|
|
7167
|
-
const existing =
|
|
7723
|
+
const existing = existsSync21(file) ? readFileSync22(file, "utf8") : "";
|
|
7168
7724
|
if (existing.includes(line))
|
|
7169
7725
|
return name;
|
|
7170
7726
|
appendFileSync2(file, `${existing && !existing.endsWith(`
|
|
@@ -7202,7 +7758,7 @@ var finishPathUnix = (args, binDir) => {
|
|
|
7202
7758
|
log(` • or install system-wide (no PATH edit): ${c.bold("sudo lecodes install --system")}`);
|
|
7203
7759
|
};
|
|
7204
7760
|
var installWindows = (args, bundle, companions) => {
|
|
7205
|
-
const target =
|
|
7761
|
+
const target = resolve9(flagStr(args, "dir") ?? join22(process.env.LOCALAPPDATA ?? join22(HOME, "AppData", "Local"), "Programs", "lecodes-cli"));
|
|
7206
7762
|
try {
|
|
7207
7763
|
copyBundle(bundle, target, companions);
|
|
7208
7764
|
} catch (e) {
|
|
@@ -7239,9 +7795,9 @@ var addToUserPathWindows = (dir) => {
|
|
|
7239
7795
|
|
|
7240
7796
|
// src/index.ts
|
|
7241
7797
|
var STANDALONE = (() => {
|
|
7242
|
-
const dir =
|
|
7798
|
+
const dir = dirname16(process.execPath);
|
|
7243
7799
|
const chisel = process.platform === "win32" ? "chisel.exe" : "chisel";
|
|
7244
|
-
return
|
|
7800
|
+
return existsSync22(join23(dir, "runtime")) && existsSync22(join23(dir, chisel));
|
|
7245
7801
|
})();
|
|
7246
7802
|
var INSTALLED = STANDALONE && (() => {
|
|
7247
7803
|
const exe = process.platform === "win32" ? "lecodes.exe" : "lecodes";
|
|
@@ -7257,7 +7813,7 @@ var INSTALLED = STANDALONE && (() => {
|
|
|
7257
7813
|
return false;
|
|
7258
7814
|
try {
|
|
7259
7815
|
const cand = join23(d, exe);
|
|
7260
|
-
return
|
|
7816
|
+
return existsSync22(cand) && realpathSync2(cand) === self;
|
|
7261
7817
|
} catch {
|
|
7262
7818
|
return false;
|
|
7263
7819
|
}
|
|
@@ -7276,7 +7832,8 @@ ${c.bold("Usage:")} lecodes <command> [options]
|
|
|
7276
7832
|
|
|
7277
7833
|
${c.bold("Commands:")}
|
|
7278
7834
|
init [dir] Scaffold a new LOCAL project (no login/server): a starter
|
|
7279
|
-
main.ts,
|
|
7835
|
+
main.ts, a CLAUDE.md + passing flow test (the headless
|
|
7836
|
+
render/test loop), IDE types + tsconfig, a .lecodesignore.
|
|
7280
7837
|
Run it with 'lecodes dev' or 'lecodes compile'.${INSTALL_HELP}
|
|
7281
7838
|
login Authorize in the browser and store an access token
|
|
7282
7839
|
--token <pat> store a token from the web UI instead
|
|
@@ -7320,12 +7877,36 @@ ${c.bold("Commands:")}
|
|
|
7320
7877
|
-o, --out <file> write JSON to a file instead of stdout
|
|
7321
7878
|
--png [file] rasterize a PNG (default screenshot.png;
|
|
7322
7879
|
needs @napi-rs/canvas)
|
|
7880
|
+
--script <file> drive the app through a step list (tap/type/
|
|
7881
|
+
scroll/wait/expect/screenshot/json steps)
|
|
7882
|
+
--out-dir <dir> where --script step outputs land (default .)
|
|
7883
|
+
--fixtures <file> canned fetch responses (URL pattern →
|
|
7884
|
+
{ status?, json?, text? }) — make
|
|
7885
|
+
backend-gated screens reachable headless
|
|
7323
7886
|
--scale <n> PNG device-pixel scale (default 2)
|
|
7324
7887
|
--width/--height viewport size (default 390x844)
|
|
7325
7888
|
--entry <path> override the entrypoint
|
|
7326
7889
|
--settle <ms> wait for promises/timers/fetch before snapshot
|
|
7327
7890
|
--wait-network let data fetches hit the real network
|
|
7328
7891
|
--logs forward the app's console output (to stderr)
|
|
7892
|
+
test [name…] Run headless flow tests: tests/*.flow.json scenarios, plus flows
|
|
7893
|
+
derived from the design graph (design/meta.json activator edges
|
|
7894
|
+
— checks the built app against the designed navigation; needs
|
|
7895
|
+
named elements + screen roots). Failing steps dump the frame
|
|
7896
|
+
(JSON + PNG) under tests/.artifacts/<flow>/.
|
|
7897
|
+
Design flows read tests/fixtures.json automatically (canned
|
|
7898
|
+
fetch responses) so backend-gated transitions run headless;
|
|
7899
|
+
file scenarios declare their own via the "fixtures" envelope.
|
|
7900
|
+
--flows run ONLY the design-derived flows
|
|
7901
|
+
--no-flows run ONLY the tests/*.flow.json files
|
|
7902
|
+
--fixtures <file> fixture pack for the design flows (overrides
|
|
7903
|
+
the tests/fixtures.json default)
|
|
7904
|
+
--json machine-readable report on stdout
|
|
7905
|
+
--dir <folder> tests folder (default tests/)
|
|
7906
|
+
--design-dir <dir> design folder (default design/)
|
|
7907
|
+
--artifacts <dir> failure-artifact folder override
|
|
7908
|
+
(+ the render timing flags: --width/--height, --settle,
|
|
7909
|
+
--device, --safe-area, --wait-network, --logs, --entry)
|
|
7329
7910
|
design [serve] LeCodes Design — a live canvas of screens for AI-driven prototyping
|
|
7330
7911
|
(needs the optional 'lecodes-design' package; works inside a
|
|
7331
7912
|
cloned project or standalone in any folder)
|
|
@@ -7374,7 +7955,7 @@ ${c.bold("Commands:")}
|
|
|
7374
7955
|
ASC API key; stored in ~/.lecodes/asc.json
|
|
7375
7956
|
|
|
7376
7957
|
Config lives in ~/.lecodes/config.json (override with LECODES_API / LECODES_TOKEN).`;
|
|
7377
|
-
var commands = { init: init2, login, create, clone, status, diff, pull, push, compile: compile2, preview: previewLegacy, render, design, scene, dev, app };
|
|
7958
|
+
var commands = { init: init2, login, create, clone, status, diff, pull, push, compile: compile2, preview: previewLegacy, render, test, design, scene, dev, app };
|
|
7378
7959
|
if (STANDALONE)
|
|
7379
7960
|
commands.install = install;
|
|
7380
7961
|
var readVersion = () => package_default.version ?? "unknown";
|
|
@@ -7386,15 +7967,15 @@ var OPTIONAL_PEERS = [
|
|
|
7386
7967
|
var peerVersion = (name, probe) => {
|
|
7387
7968
|
try {
|
|
7388
7969
|
const require2 = createRequire4(import.meta.url);
|
|
7389
|
-
let dir =
|
|
7970
|
+
let dir = dirname16(require2.resolve(probe));
|
|
7390
7971
|
for (let depth = 0;depth < 16; depth++) {
|
|
7391
7972
|
const manifest = join23(dir, "package.json");
|
|
7392
|
-
if (
|
|
7393
|
-
const parsed = JSON.parse(
|
|
7973
|
+
if (existsSync22(manifest)) {
|
|
7974
|
+
const parsed = JSON.parse(readFileSync23(manifest, "utf8"));
|
|
7394
7975
|
if (parsed.name === name)
|
|
7395
7976
|
return parsed.version ?? "unknown";
|
|
7396
7977
|
}
|
|
7397
|
-
const parent =
|
|
7978
|
+
const parent = dirname16(dir);
|
|
7398
7979
|
if (parent === dir)
|
|
7399
7980
|
break;
|
|
7400
7981
|
dir = parent;
|