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
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
import { rmSync } from "node:fs"
|
|
2
2
|
import { join, relative } from "node:path"
|
|
3
|
-
import { createChannelHub } from "lecodes-sdk/server"
|
|
3
|
+
import { createChannelHub, fileResponse, isMultipart, receiveUploads, UploadError, type Upload } from "lecodes-sdk/server"
|
|
4
4
|
import { compileServer, hasServerFiles, type CompileEntry } from "../compile/projectCompile"
|
|
5
5
|
import { loadPeer } from "../hosts/peerInstall"
|
|
6
6
|
import { LECODES_DIR } from "../project/manifest"
|
|
7
7
|
import { readAppConfigAt } from "../commands/app/shared"
|
|
8
|
-
import { startLocalBackend, type LocalBackend } from "./localBackend"
|
|
8
|
+
import { filesDirOf, startLocalBackend, type LocalBackend } from "./localBackend"
|
|
9
9
|
import { serve, type Served, type Socket, type SocketHandlers } from "./serve"
|
|
10
10
|
|
|
11
11
|
/*
|
|
12
12
|
* The project's backend ON THE WIRE, on this machine: its server bundle run by localBackend.ts, put
|
|
13
13
|
* behind the routes the SDK's client transport speaks (sdk/src/runtime/rpc.ts) —
|
|
14
14
|
*
|
|
15
|
-
* POST <base>/api/<id> an endpoint call, the envelope always HTTP 200 (the client reads `ok`)
|
|
15
|
+
* POST <base>/api/<id> an endpoint call, the envelope always HTTP 200 (the client reads `ok`);
|
|
16
|
+
* multipart when it carries files
|
|
17
|
+
* GET <base>/files/<id>/<name> a stored file (`t.file()`): `.lecodes/files`, beside the database
|
|
16
18
|
* WS <base>/ws the channel socket: the SDK's hub, the one the runner serves on le.codes
|
|
17
19
|
*
|
|
18
20
|
* Two users: the dev server mounts the routes under its token path (`lecodes dev`, `web dev`, the
|
|
@@ -59,6 +61,7 @@ const BODY_LIMIT = 8 * 1024 * 1024
|
|
|
59
61
|
export const createProjectBackend = (options: ProjectBackendOptions): ProjectBackend => {
|
|
60
62
|
const { root, log } = options
|
|
61
63
|
const dbDir = options.dbDir ?? join(root, LECODES_DIR, "db")
|
|
64
|
+
const filesDir = filesDirOf(dbDir)
|
|
62
65
|
let backend: LocalBackend | null = null
|
|
63
66
|
let running: string | null = null
|
|
64
67
|
|
|
@@ -91,6 +94,8 @@ export const createProjectBackend = (options: ProjectBackendOptions): ProjectBac
|
|
|
91
94
|
// the embedded database is a peer, installed the first time a project defines models
|
|
92
95
|
if (Object.keys(bundle.manifest.modules).length && /\bdefineDb\s*\(/.test(bundle.code)) {
|
|
93
96
|
await loadPeer("marcidb-embedded", undefined, { for: `${options.command} (the project's database)` })
|
|
97
|
+
// …and so is the codec of its pictures, the first time a model has a t.image() field
|
|
98
|
+
if (/\bimage\s*\(/.test(bundle.code)) await loadPeer("sharp", undefined, { for: `${options.command} (the project's t.image() fields)` })
|
|
94
99
|
}
|
|
95
100
|
backend ??= start()
|
|
96
101
|
const deployed = await backend.deploy(bundle.code)
|
|
@@ -104,16 +109,32 @@ export const createProjectBackend = (options: ProjectBackendOptions): ProjectBac
|
|
|
104
109
|
},
|
|
105
110
|
handle: async (req, route, ip) => {
|
|
106
111
|
if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS })
|
|
112
|
+
if (route.startsWith("/files/") && (req.method === "GET" || req.method === "HEAD")) {
|
|
113
|
+
return fileResponse(filesDir, route.slice("/files/".length), req, { "access-control-allow-origin": "*" })
|
|
114
|
+
}
|
|
107
115
|
const id = route.startsWith("/api/") ? decodeURIComponent(route.slice("/api/".length)) : ""
|
|
108
116
|
if (!id || req.method !== "POST") return envelope({ ok: false, status: 404, message: "not an endpoint call" })
|
|
109
117
|
if (!backend) return envelope({ ok: false, status: 404, message: "The project has no server files" })
|
|
110
118
|
let args: unknown
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
119
|
+
let uploads: Upload[] = []
|
|
120
|
+
if (isMultipart(req)) {
|
|
121
|
+
try {
|
|
122
|
+
({ args, uploads } = await receiveUploads(req, filesDir))
|
|
123
|
+
} catch (e) {
|
|
124
|
+
if (e instanceof UploadError) return envelope({ ok: false, status: e.status, message: e.message })
|
|
125
|
+
throw e
|
|
126
|
+
}
|
|
127
|
+
} else {
|
|
128
|
+
try {
|
|
129
|
+
const body = await req.text()
|
|
130
|
+
args = body.length > BODY_LIMIT ? null : JSON.parse(body)?.args
|
|
131
|
+
} catch { args = null }
|
|
132
|
+
}
|
|
115
133
|
if (!Array.isArray(args)) return envelope({ ok: false, status: 400, message: "body must be {\"args\":[…]}" })
|
|
116
|
-
|
|
134
|
+
// a stored file's url starts where this very request found the backend (a phone's address of this machine, a tunnel)
|
|
135
|
+
const url = new URL(req.url)
|
|
136
|
+
const filesBase = url.origin + url.pathname.slice(0, url.pathname.length - route.length)
|
|
137
|
+
return envelope(await backend.invoke(id, args, { headers: Object.fromEntries(req.headers), ip, sessionToken: bearerOf(req) ?? undefined, uploads, filesBase }))
|
|
117
138
|
},
|
|
118
139
|
socket: (req, ip) => (conn) => {
|
|
119
140
|
const c = hub.connect(conn, { ip, session: bearerOf(req) })
|
|
@@ -127,6 +148,7 @@ export const createProjectBackend = (options: ProjectBackendOptions): ProjectBac
|
|
|
127
148
|
if (!backend || running === null) return
|
|
128
149
|
await backend.close()
|
|
129
150
|
rmSync(dbDir, { recursive: true, force: true })
|
|
151
|
+
rmSync(filesDir, { recursive: true, force: true })
|
|
130
152
|
backend = start()
|
|
131
153
|
await backend.deploy(running)
|
|
132
154
|
},
|
|
@@ -165,7 +187,7 @@ const stablePort = (root: string): number => {
|
|
|
165
187
|
export const runBackend = (options: { root: string, command: string, log: (msg: string) => void, test?: boolean }): RunBackend => {
|
|
166
188
|
const { root } = options
|
|
167
189
|
const dbDir = join(root, LECODES_DIR, options.test ? "test-db" : "db")
|
|
168
|
-
if (options.test) rmSync(dbDir, { recursive: true, force: true })
|
|
190
|
+
if (options.test) { rmSync(dbDir, { recursive: true, force: true }); rmSync(filesDirOf(dbDir), { recursive: true, force: true }) }
|
|
169
191
|
const backend = createProjectBackend({ root, dbDir, log: options.log, command: options.command })
|
|
170
192
|
let server: Served | null = null
|
|
171
193
|
|
|
@@ -26,6 +26,7 @@ const isPoint = (v: unknown): v is Point =>
|
|
|
26
26
|
typeof v === "object" && v !== null && typeof (v as Point).x === "number" && typeof (v as Point).y === "number"
|
|
27
27
|
|
|
28
28
|
const FRAME_MS = 16
|
|
29
|
+
const SETTLE_FRAMES = 640
|
|
29
30
|
|
|
30
31
|
export type DesktopPlanStep = {
|
|
31
32
|
index: number
|
|
@@ -136,6 +137,14 @@ export const translateScenario = (rawSteps: unknown, opts: { outDir: string, hea
|
|
|
136
137
|
} else throw new CliError(`${at}: "camera" wants { "at": [x,y,z], "lookAt": [x,y,z], "fov"? } or "off".`)
|
|
137
138
|
budgetMs += 2 * FRAME_MS
|
|
138
139
|
}
|
|
140
|
+
if (s.pick !== undefined) {
|
|
141
|
+
// the files of the step's file dialog: armed first, the action that opens it follows
|
|
142
|
+
const files = typeof s.pick === "string" ? [s.pick] : s.pick
|
|
143
|
+
if (!Array.isArray(files) || files.length === 0 || !files.every((f) => typeof f === "string")) throw new CliError(`${at}: "pick" must be a file path or a list of them.`)
|
|
144
|
+
if (opts.sim) throw new CliError(`${at}: "pick" isn't available with --sim (the windowless host has no file dialog) — run this scenario with --desktop or headless.`)
|
|
145
|
+
plan.lines.push(emit(`pick ${(files as string[]).map(wire).join(" ")}`)); budgetMs += FRAME_MS
|
|
146
|
+
parts.push(`pick ${(files as string[]).map((f) => f.split(/[\\/]/).pop()).join(" + ")}`)
|
|
147
|
+
}
|
|
139
148
|
if (s.fov !== undefined && action !== "view") throw new CliError(`${at}: "fov" only goes with "view" (inside "camera" use camera.fov).`)
|
|
140
149
|
if (action === "tap") {
|
|
141
150
|
const p = target(at, "tap", s.tap)
|
|
@@ -316,7 +325,8 @@ export const translateScenario = (rawSteps: unknown, opts: { outDir: string, hea
|
|
|
316
325
|
steps.push(plan)
|
|
317
326
|
})
|
|
318
327
|
|
|
319
|
-
|
|
328
|
+
// + what the host may wait for the requests a command left in flight (script.cpp: 5 s of 8 ms frames)
|
|
329
|
+
const frameCap = Math.max(0, opts.headroomFrames) + Math.ceil(budgetMs / FRAME_MS) + 600 + lines.length * SETTLE_FRAMES
|
|
320
330
|
return { text: lines.join("\n") + "\n", steps, frameCap }
|
|
321
331
|
}
|
|
322
332
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { tmpdir } from "node:os"
|
|
3
|
+
import { join, resolve } from "node:path"
|
|
4
|
+
import { deflateSync } from "node:zlib"
|
|
5
|
+
import { CliError } from "../cli"
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* `pick` of a flow step: the files the step's file dialog is answered with (`openFilePicker`).
|
|
9
|
+
*
|
|
10
|
+
* { "tap": "attach", "pick": "cat.png" } a file beside the flow file
|
|
11
|
+
* { "tap": "attach", "pick": ["cat.png", "notes.txt"] } several (a `multiple` picker)
|
|
12
|
+
* { "tap": "avatar", "pick": { "name": "me.png", "width": 400, "height": 300 } } a picture made for the run
|
|
13
|
+
* { "tap": "attach", "pick": { "name": "notes.txt", "text": "hello" } } a text file made for the run
|
|
14
|
+
*
|
|
15
|
+
* A flow names its files the way it is convenient to write; every host is handed the same thing —
|
|
16
|
+
* absolute paths of files that exist. This module is that step: a path is resolved against the flow
|
|
17
|
+
* file's folder, a described file is written to a folder of this run (removed when the CLI exits).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const MAX_SIDE = 4096
|
|
21
|
+
|
|
22
|
+
const crcTable = (() => {
|
|
23
|
+
const table = new Uint32Array(256)
|
|
24
|
+
for (let n = 0; n < 256; n++) {
|
|
25
|
+
let c = n
|
|
26
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
|
|
27
|
+
table[n] = c >>> 0
|
|
28
|
+
}
|
|
29
|
+
return table
|
|
30
|
+
})()
|
|
31
|
+
const crc32 = (bytes: Uint8Array): number => {
|
|
32
|
+
let c = 0xffffffff
|
|
33
|
+
for (const b of bytes) c = crcTable[(c ^ b) & 0xff]! ^ (c >>> 8)
|
|
34
|
+
return (c ^ 0xffffffff) >>> 0
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A PNG of one colour: what a test of an upload needs of a picture is its type and its size. */
|
|
38
|
+
const png = (width: number, height: number): Uint8Array => {
|
|
39
|
+
const chunk = (type: string, data: Uint8Array): Uint8Array => {
|
|
40
|
+
const out = new Uint8Array(12 + data.length)
|
|
41
|
+
const view = new DataView(out.buffer)
|
|
42
|
+
view.setUint32(0, data.length)
|
|
43
|
+
out.set([...type].map((ch) => ch.charCodeAt(0)), 4)
|
|
44
|
+
out.set(data, 8)
|
|
45
|
+
view.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length)))
|
|
46
|
+
return out
|
|
47
|
+
}
|
|
48
|
+
const header = new Uint8Array(13)
|
|
49
|
+
const view = new DataView(header.buffer)
|
|
50
|
+
view.setUint32(0, width)
|
|
51
|
+
view.setUint32(4, height)
|
|
52
|
+
header.set([8, 2, 0, 0, 0], 8) // 8 bits, RGB
|
|
53
|
+
const row = new Uint8Array(1 + width * 3)
|
|
54
|
+
for (let x = 0; x < width; x++) row.set([0xd4, 0x53, 0x2b], 1 + x * 3)
|
|
55
|
+
const pixels = new Uint8Array(row.length * height)
|
|
56
|
+
for (let y = 0; y < height; y++) pixels.set(row, y * row.length)
|
|
57
|
+
const parts = [Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a), chunk("IHDR", header), chunk("IDAT", deflateSync(pixels)), chunk("IEND", new Uint8Array(0))]
|
|
58
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0))
|
|
59
|
+
let at = 0
|
|
60
|
+
for (const p of parts) { out.set(p, at); at += p.length }
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let madeDir: string | null = null
|
|
65
|
+
let made = 0
|
|
66
|
+
/** A described file, written for this run: a folder of its own, so two of one name do not meet. */
|
|
67
|
+
const write = (name: string, bytes: Uint8Array): string => {
|
|
68
|
+
if (!madeDir) {
|
|
69
|
+
madeDir = mkdtempSync(join(tmpdir(), "lecodes-pick-"))
|
|
70
|
+
const dir = madeDir
|
|
71
|
+
process.on("exit", () => { try { rmSync(dir, { recursive: true, force: true }) } catch { /* the OS clears its temp */ } })
|
|
72
|
+
}
|
|
73
|
+
const dir = join(madeDir, String(made++))
|
|
74
|
+
mkdirSync(dir)
|
|
75
|
+
const path = join(dir, name)
|
|
76
|
+
writeFileSync(path, bytes)
|
|
77
|
+
return path
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const fileOf = (at: string, value: unknown, baseDir: string): string => {
|
|
81
|
+
if (typeof value === "string") {
|
|
82
|
+
const path = resolve(baseDir, value)
|
|
83
|
+
if (!existsSync(path)) throw new CliError(`${at}: "pick": no file "${value}" (looked for ${path}).`)
|
|
84
|
+
return path
|
|
85
|
+
}
|
|
86
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new CliError(`${at}: "pick" wants a file path, { "name", "text" } or { "name", "width", "height" } — or a list of them.`)
|
|
87
|
+
const { name, text, width, height, ...rest } = value as { name?: unknown, text?: unknown, width?: unknown, height?: unknown }
|
|
88
|
+
const unknown = Object.keys(rest)
|
|
89
|
+
if (unknown.length > 0) throw new CliError(`${at}: "pick": unknown key "${unknown[0]}" (a described file is { "name", "text" } or { "name", "width", "height" }).`)
|
|
90
|
+
if (typeof name !== "string" || name === "" || /[\\/]/.test(name)) throw new CliError(`${at}: "pick": a described file needs a "name" (a file name, no folders).`)
|
|
91
|
+
if (text !== undefined) {
|
|
92
|
+
if (typeof text !== "string" || width !== undefined || height !== undefined) throw new CliError(`${at}: "pick": "${name}" is { "name", "text" } or { "name", "width", "height" }.`)
|
|
93
|
+
return write(name, new TextEncoder().encode(text))
|
|
94
|
+
}
|
|
95
|
+
const side = (n: unknown) => typeof n === "number" && Number.isInteger(n) && n >= 1 && n <= MAX_SIDE
|
|
96
|
+
if (!side(width) || !side(height)) throw new CliError(`${at}: "pick": "${name}" needs a "text", or a "width" and a "height" (1…${MAX_SIDE}).`)
|
|
97
|
+
if (!/\.png$/i.test(name)) throw new CliError(`${at}: "pick": a picture made for the run is a PNG — name it "….png" (got "${name}").`)
|
|
98
|
+
return write(name, png(width as number, height as number))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The steps with every `pick` as a list of absolute paths. `baseDir`: the flow file's folder. */
|
|
102
|
+
export const resolvePicks = (steps: unknown, baseDir: string): unknown => {
|
|
103
|
+
if (!Array.isArray(steps)) return steps
|
|
104
|
+
return steps.map((step, i) => {
|
|
105
|
+
if (typeof step !== "object" || step === null || Array.isArray(step) || (step as { pick?: unknown }).pick === undefined) return step
|
|
106
|
+
const pick = (step as { pick: unknown }).pick
|
|
107
|
+
const list = Array.isArray(pick) ? pick : [pick]
|
|
108
|
+
if (list.length === 0) throw new CliError(`steps[${i}]: "pick" is empty — leave it out for a dialog that is cancelled.`)
|
|
109
|
+
return { ...step, pick: list.map((value) => fileOf(`steps[${i}]`, value, baseDir)) }
|
|
110
|
+
})
|
|
111
|
+
}
|
|
@@ -59,6 +59,11 @@ const ops = {
|
|
|
59
59
|
else if (action === "up") await page.keyboard.up(text)
|
|
60
60
|
return null
|
|
61
61
|
},
|
|
62
|
+
/** The page's next file dialog is answered with these files (a picker of one file takes the first). */
|
|
63
|
+
async pick({ files }) {
|
|
64
|
+
page.once("filechooser", (chooser) => { chooser.setFiles(chooser.isMultiple() ? files : files.slice(0, 1)).catch(() => {}) })
|
|
65
|
+
return null
|
|
66
|
+
},
|
|
62
67
|
async screenshot({ path, clip }) { await page.screenshot({ path, ...(clip ? { clip } : {}) }); return null },
|
|
63
68
|
async close() {
|
|
64
69
|
await context?.close().catch(() => {})
|
package/src/hosts/liteRunner.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { dirname, join } from "node:path"
|
|
|
5
5
|
import { createInterface } from "node:readline"
|
|
6
6
|
import { CliError } from "../cli"
|
|
7
7
|
import { startDevServer, type DevServer } from "../dev/devServer"
|
|
8
|
+
import { filesDirOf } from "../dev/localBackend"
|
|
8
9
|
import type { WebRunner } from "../dev/webRunner"
|
|
9
10
|
import { LECODES_DIR, type Manifest } from "../project/manifest"
|
|
10
11
|
import { distRoot } from "./distRoot"
|
|
@@ -41,6 +42,8 @@ type Driver = {
|
|
|
41
42
|
mouse(action: "move" | "down" | "up" | "wheel", x?: number, y?: number): Promise<void>
|
|
42
43
|
keyboard(action: "type" | "down" | "up", text: string): Promise<void>
|
|
43
44
|
screenshot(path: string, clip: Clip | null): Promise<void>
|
|
45
|
+
/** The files the page's next file dialog is answered with. */
|
|
46
|
+
pick(files: string[]): Promise<void>
|
|
44
47
|
close(): Promise<void>
|
|
45
48
|
}
|
|
46
49
|
|
|
@@ -110,6 +113,7 @@ const startDriver = async (): Promise<Driver> => {
|
|
|
110
113
|
mouse: (action, x, y) => call("mouse", { action, x, y }),
|
|
111
114
|
keyboard: (action, text) => call("keyboard", { action, text }),
|
|
112
115
|
screenshot: (path, clip) => call("screenshot", { path, clip }),
|
|
116
|
+
pick: (files) => call("pick", { files }),
|
|
113
117
|
async close() {
|
|
114
118
|
await call("close").catch(() => {})
|
|
115
119
|
child.kill()
|
|
@@ -141,6 +145,7 @@ export const startLiteSession = async (options: { root: string, manifest: Manife
|
|
|
141
145
|
// The project's backend, when it has one: a database of the tests' own, emptied before every case.
|
|
142
146
|
const dbDir = join(options.root, LECODES_DIR, "test-db")
|
|
143
147
|
rmSync(dbDir, { recursive: true, force: true })
|
|
148
|
+
rmSync(filesDirOf(dbDir), { recursive: true, force: true })
|
|
144
149
|
let ran = 0
|
|
145
150
|
const server: DevServer = await startDevServer({
|
|
146
151
|
root: options.root, manifest: options.manifest, name: options.name, lite: options.lite, web: options.web,
|
|
@@ -212,6 +217,17 @@ const ON_SHOW = ".lc-root > .lc:not(.lc-covered)"
|
|
|
212
217
|
|
|
213
218
|
const FRAME = "new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(null))))"
|
|
214
219
|
|
|
220
|
+
// The page's requests in flight, counted from here on: a command that left one waits for its answer
|
|
221
|
+
// (what the headless session and the native host's script do — one flow file, no `wait` for the backend).
|
|
222
|
+
const COUNT_REQUESTS = `(() => {
|
|
223
|
+
if (window.__lecodesPending !== undefined) return
|
|
224
|
+
window.__lecodesPending = 0
|
|
225
|
+
const send = window.fetch
|
|
226
|
+
window.fetch = (...args) => { window.__lecodesPending++; return send(...args).finally(() => { window.__lecodesPending-- }) }
|
|
227
|
+
})()`
|
|
228
|
+
const SETTLE_MAX_MS = 5000
|
|
229
|
+
const SETTLE_QUIET_FRAMES = 2
|
|
230
|
+
|
|
215
231
|
const FILL_VALUE = `(f) => f.type === "email" ? "test@example.com" : f.type === "tel" ? "5550100" : f.inputMode === "numeric" || f.inputMode === "decimal" ? "1" : f.type === "date" ? "2026-01-01" : f.type === "time" ? "12:00" : "test"`
|
|
216
232
|
|
|
217
233
|
const runPlan = async (driver: Driver, url: string, plan: DesktopPlan, options: LiteRunOptions, page: "lite" | "web"): Promise<string> => {
|
|
@@ -256,11 +272,23 @@ const runPlan = async (driver: Driver, url: string, plan: DesktopPlan, options:
|
|
|
256
272
|
}
|
|
257
273
|
}
|
|
258
274
|
|
|
275
|
+
/** After a command: while a request it left is in flight, and two frames past its answer. */
|
|
276
|
+
const settle = async () => {
|
|
277
|
+
const pending = () => driver.evaluate<number>("window.__lecodesPending || 0")
|
|
278
|
+
if (await pending() === 0) return
|
|
279
|
+
const until = Date.now() + SETTLE_MAX_MS
|
|
280
|
+
for (let quiet = 0; quiet <= SETTLE_QUIET_FRAMES && Date.now() < until;) {
|
|
281
|
+
await frame()
|
|
282
|
+
quiet = await pending() > 0 ? 0 : quiet + 1
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
259
286
|
let clip: Clip | null = null
|
|
260
287
|
|
|
261
288
|
const play = async (op: string, rest: string, line: number): Promise<void> => {
|
|
262
289
|
const args = parseArgs(rest)
|
|
263
290
|
switch (op) {
|
|
291
|
+
case "pick": await driver.pick(args.map(String)); return
|
|
264
292
|
case "frames": await waitFor(Math.max(0, Number(args[0])) * FRAME_MS, line); return
|
|
265
293
|
case "wait": await waitFor(Math.max(0, Number(args[0])), line); return
|
|
266
294
|
case "tap": {
|
|
@@ -405,6 +433,7 @@ const runPlan = async (driver: Driver, url: string, plan: DesktopPlan, options:
|
|
|
405
433
|
try {
|
|
406
434
|
await driver.goto(url)
|
|
407
435
|
await driver.waitFor("window.lecodesTest && window.lecodesTest.ready === true", 20000)
|
|
436
|
+
await driver.evaluate<null>(COUNT_REQUESTS)
|
|
408
437
|
say("[script] loaded")
|
|
409
438
|
const lines = plan.text.split("\n")
|
|
410
439
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -415,6 +444,7 @@ const runPlan = async (driver: Driver, url: string, plan: DesktopPlan, options:
|
|
|
415
444
|
const rest = space < 0 ? "" : text.slice(space + 1)
|
|
416
445
|
try {
|
|
417
446
|
await play(op, rest, i + 1)
|
|
447
|
+
await settle()
|
|
418
448
|
say(`[script] #${i + 1} done ${op}`)
|
|
419
449
|
} catch (e) {
|
|
420
450
|
say(`[script] #${i + 1} error ${op}: ${e instanceof Error ? e.message.split("\n")[0] : String(e)}`)
|
package/src/hosts/peerInstall.ts
CHANGED
|
@@ -48,7 +48,11 @@ export const peerSpec = async (name: string): Promise<string> => {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/** Peers that only make sense together: the renderer's PNG output needs canvas next to it. */
|
|
51
|
-
const companions = (name: string): string[] =>
|
|
51
|
+
const companions = (name: string): string[] =>
|
|
52
|
+
name === "lecodes-headless" ? ["lecodes-headless", "@napi-rs/canvas"]
|
|
53
|
+
// the codec of t.image() fields, and the reader of the one format it does not read (an iPhone's HEIC)
|
|
54
|
+
: name === "sharp" ? ["sharp", "heic-decode"]
|
|
55
|
+
: [name]
|
|
52
56
|
|
|
53
57
|
const manualCommand = (dir: string, specs: string[]) => commandLine(ownManager(), installIntoArgv(ownManager(), dir, specs))
|
|
54
58
|
|
package/src/hosts/peers.ts
CHANGED
|
@@ -24,6 +24,8 @@ export const OPTIONAL_PEERS: [name: string, probe: string][] = [
|
|
|
24
24
|
["lecodes-assets", "lecodes-assets/cli"],
|
|
25
25
|
["@napi-rs/canvas", "@napi-rs/canvas"],
|
|
26
26
|
["marcidb-embedded", "marcidb-embedded"],
|
|
27
|
+
["sharp", "sharp"],
|
|
28
|
+
["heic-decode", "heic-decode"],
|
|
27
29
|
["typescript", "typescript"],
|
|
28
30
|
]
|
|
29
31
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{u as D,w as W}from"../lite.js";const $=(m,c)=>{let f=[],l=[];const r=new Map,a=new Map,d=new Set;let e=[0],o=0,n=0,i=0,y=0,w=!0,O=!1,v=!1,p=!1,M=!1,S=!0;const L=t=>a.get(f[t])??l[t],b=t=>{o=Math.min(o,t)},x=()=>{const t=f.length;e.length=t+1,o>t&&(o=t),o===0&&(e[0]=0);for(let s=o;s<t;s++)e[s+1]=e[s]+L(s);o=t},H=()=>(x(),e[f.length]??0),_=t=>{for(let s=t;s<f.length;s++)r.set(f[s],s)},V=()=>m.inverted?Math.max(0,y-H()):0,N=()=>Math.max(0,H()-y),B=()=>{x();const t=f.length;if(t===0||y<=0)return[-1,-2];const s=m.overscan<0?y:m.overscan,u=n-s,h=n+y+s;let T=0,g=t;for(;T<g;){const K=T+g>>1;e[K+1]>u?g=K:T=K+1}const z=Math.min(T,t-1);for(T=0,g=t;T<g;){const K=T+g>>1;e[K]<h?T=K+1:g=K}return[Math.max(z,0),Math.min(T-1,t-1)]},k=()=>{m.endThreshold>0&&(H()-(n+y)<=m.endThreshold?O||(O=!0,c.edge(!0)):O=!1),m.startThreshold>0&&(n<=m.startThreshold?v||(v=!0,c.edge(!1)):v=!1)},I=()=>{if(p){M=!0;return}p=!0;try{for(let t=0;t<4;t++){M=!1;const[s,u]=B(),h=[],T=[];for(const g of d){const z=r.get(g);(z===void 0||z<s||z>u)&&h.push(g)}for(let g=s;g<=u;g++)d.has(f[g])||T.push(f[g]);if(h.length===0&&T.length===0||(c.sync(h,T),!M))break}}finally{p=!1}},E=t=>{n=Math.max(0,Math.min(t,N())),c.adjust(n)},C=(t,s,u)=>{const h=L(s);if(a.set(t,u),Math.abs(u-h)<=.5)return;x();const T=e[s];b(s),T<n?E(n+(u-h)):m.inverted&&w&&E(N()),M=!0};return{setKeys(t,s){f=t.slice(),l=f.map((u,h)=>s[h]??0),r.clear(),_(0),b(0),E(n),I(),k()},insertKeys(t,s,u){if(s.length===0)return;const h=f.length;(t<0||t>h)&&(t=h);const T=h>0,g=t===h;let z=0;s.forEach((R,A)=>{z+=a.get(R)??u[A]??0}),f.splice(t,0,...s),l.splice(t,0,...s.map((R,A)=>u[A]??0)),_(t),b(t);const K=w;x(),T&&!g&&e[t]<=n+.5&&E(n+z),m.inverted&&K&&g&&(E(N()),w=!0),I(),k()},removeKeys(t){let s=0;x();for(const u of t){const h=r.get(u);h!==void 0&&(e[h+1]<=n&&(s+=L(h)),f.splice(h,1),l.splice(h,1),r.delete(u),a.delete(u),_(h),b(h),x())}E(s>0?n-s:n),I(),k()},invalidate(t){if(!(!d.has(t)||p)){p=!0;try{c.sync([t],[t])}finally{p=!1}M&&(M=!1,I())}},mounted(t,s){const u=r.get(t);u!==void 0&&(d.add(t),C(t,u,s))},unmounted(t){d.delete(t)},isMounted:t=>d.has(t),resized(t,s){const u=r.get(t);u===void 0||!d.has(t)||(C(t,u,s),M&&!p&&(M=!1,I()))},scrolled(t){n=Math.max(0,t),w=n+y>=H()-2,I(),k()},viewport(t,s,u){if(!(t>0)||!(s>0))return;const h=Math.abs(t-i)>.5,T=Math.abs(s-y)>.5;if(i=t,y=s,H()<=y&&(w=!0),h){b(0);for(const g of d)a.set(g,u(g))}S?(S=!1,m.inverted&&(E(N()),w=!0)):m.inverted&&w&&T?E(N()):h&&E(n),I()},offsetOf(t){const s=r.get(t);return s===void 0?NaN:(x(),V()+e[s])},mountedTop(){let t=-1;for(const s of d){const u=r.get(s);u!==void 0&&(t<0||u<t)&&(t=u)}return x(),V()+(t<0?0:e[t])},indexOf:t=>r.get(t)??-1,contentHeight:()=>m.inverted?Math.max(H(),y):H(),maxScroll:N,scrollY:()=>n,mountedKeys:()=>[...d]}},j=m=>{const c=new Map,f=new Map,l=e=>{e.content.style.minHeight=`${e.window.contentHeight()}px`,e.content.style.paddingTop=`${e.window.mountedTop()}px`},r=(e,o)=>{const n=e.items.get(o);e.window.unmounted(o),n&&(e.items.delete(o),e.keyOf.delete(n),f.delete(n),e.sizes.unobserve(n.el),n.el.remove(),m.rootOwned(n,!1))},a=(e,o,n)=>{if(Number.isNaN(o))return;l(e);const i=Math.max(0,Math.min(o,e.window.maxScroll()));e.node.el.scrollTo({top:i,behavior:n?"smooth":"instant"})},d=e=>{for(const o of[...e.items.keys()])r(e,o);e.sizes.disconnect(),e.node.el.removeEventListener("scroll",e.onScroll),e.content.remove(),c.delete(e.node)};return{init(e,o,n,i,y){if(c.has(e))return;const w=e.el,O=w.ownerDocument.createElement("div");O.className="lc-vlist-content",w.appendChild(O);const v={node:e,content:O,items:new Map,keyOf:new Map,onScroll:()=>{v.window.scrolled(w.scrollTop),l(v)}};v.window=$({overscan:o,inverted:n,startThreshold:i,endThreshold:y},{sync(p,M){for(const S of p)r(v,S);m.emit(e.id,W,p,M),l(v)},adjust(p){l(v),Math.abs(w.scrollTop-p)>=.5&&(w.scrollTop=p)},edge:p=>{m.emitLater(e.id,D,p)}}),v.sizes=new ResizeObserver(p=>{for(const M of p){const S=M.target;if(S===w){v.window.viewport(w.clientWidth,w.clientHeight,L=>{var b;return((b=v.items.get(L))==null?void 0:b.el.offsetHeight)??0});continue}for(const[L,b]of v.keyOf)if(L.el===S){v.window.resized(b,S.offsetHeight);break}}l(v)}),v.sizes.observe(w),w.addEventListener("scroll",v.onScroll,{passive:!0}),c.set(e,v)},setKeys(e,o,n){const i=c.get(e);i&&(i.window.setKeys(o,Array.from(n)),l(i))},insertKeys(e,o,n,i){const y=c.get(e);y&&(y.window.insertKeys(o,n,Array.from(i)),l(y))},removeKeys(e,o){const n=c.get(e);n&&(n.window.removeKeys(o),l(n))},invalidate(e,o){const n=c.get(e);n&&(n.window.invalidate(o),l(n))},mount(e,o,n){const i=c.get(e);if(!i||!n)return;if(n.parent!==null||n.rooted){m.warn(`vlist: the item for key '${o}' is attached elsewhere`);return}const y=i.window.indexOf(o);if(y<0)return;i.items.has(o)&&r(i,o);let w=null,O=1/0;for(const[v,p]of i.items){const M=i.window.indexOf(v);M>y&&M<O&&(w=p,O=M)}i.items.set(o,n),i.keyOf.set(n,o),f.set(n,i),m.rootOwned(n,!0),i.content.insertBefore(n.el,(w==null?void 0:w.el)??null),m.flush(),i.sizes.observe(n.el),i.window.mounted(o,n.el.offsetHeight)},scrollTo(e,o,n){const i=c.get(e);i&&a(i,o,n)},scrollToKey(e,o,n){const i=c.get(e);i&&a(i,i.window.offsetOf(o),n)},scrollToEnd(e,o){const n=c.get(e);n&&a(n,n.window.maxScroll(),o)},ownerOf:e=>{var o;return((o=f.get(e))==null?void 0:o.node)??null},forget(e){const o=c.get(e);o&&d(o);const n=f.get(e),i=n==null?void 0:n.keyOf.get(e);n&&i!==void 0&&(n.window.unmounted(i),n.items.delete(i),n.keyOf.delete(e),n.sizes.unobserve(e.el),f.delete(e))},destroy(){for(const e of c.values())e.sizes.disconnect(),e.content.remove();c.clear(),f.clear()}}},Y={name:"vlist",install(m){const c=j({emit:m.emit,emitLater:m.emitLater,warn:m.warn,rootOwned:m.rootOwned,flush:m.flush}),f=l=>{const r=m.node(l);return(r==null?void 0:r.type)==="vlist"?r:void 0};return{bridge:{vlistInit(l,r,a,d,e){const o=f(l);o&&c.init(o,r,a,d,e)},vlistSetKeys(l,r,a){const d=f(l);d&&c.setKeys(d,r,a)},vlistInsertKeys(l,r,a,d){const e=f(l);e&&c.insertKeys(e,r,a,d)},vlistRemoveKeys(l,r){const a=f(l);a&&c.removeKeys(a,r)},vlistInvalidate(l,r){const a=f(l);a&&c.invalidate(a,r)},vlistMount(l,r,a){const d=f(l);d&&c.mount(d,r,m.node(a))},vlistScrollTo(l,r,a){const d=f(l);d&&c.scrollTo(d,r,a)},vlistScrollToKey(l,r,a){const d=f(l);d&&c.scrollToKey(d,r,a)},vlistScrollToEnd(l,r){const a=f(l);a&&c.scrollToEnd(a,r)}},forget:c.forget,destroy:c.destroy}}};export{j as createVLists,Y as vlist};
|