lecodes-cli 2.0.2 → 2.0.3
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 +958 -119
- package/package.json +13 -12
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/VERSION.json +3 -3
- package/runtime/web/assets/{inspect-C7x78PNs.js → inspect-DdJ50Kun.js} +2 -2
- package/runtime/web/player.js +17 -17
- package/runtime/web-lite/assets/Roboto-Variable-DHm9jpd-.woff2 +0 -0
- package/runtime/web-lite/assets/creatorTween-PF8B6T8V.wasm +0 -0
- package/runtime/web-lite/index.html +23 -0
- package/runtime/web-lite/lite.js +201 -0
- package/src/commands/design/comments.ts +1 -1
- package/src/commands/design/context.ts +1 -1
- package/src/commands/design/index.ts +1 -1
- package/src/commands/dev.ts +17 -53
- package/src/commands/index.ts +2 -0
- package/src/commands/test.ts +55 -6
- package/src/commands/update/index.ts +6 -4
- package/src/commands/web.ts +171 -0
- package/src/compile/fonts.ts +5 -2
- package/src/dev/devServer.ts +20 -1
- package/src/dev/watchProject.ts +53 -0
- package/src/dev/webRunner.ts +10 -0
- package/src/hosts/archives.ts +32 -0
- package/src/hosts/liteBrowser.mjs +86 -0
- package/src/hosts/liteRunner.ts +386 -0
- package/src/hosts/peerInstall.ts +8 -4
- package/src/web/build.ts +171 -0
- package/src/web/preview.ts +50 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import pkg from "../../package.json"
|
|
2
|
+
|
|
3
|
+
/* The peers that are no packages of npm's: the headless host and the editors go out as ARCHIVES in
|
|
4
|
+
* the releases bucket (the core's scripts/release/archives.ts says why), installed by their url —
|
|
5
|
+
* `npm install <url>` puts the package in place under its own name.
|
|
6
|
+
*
|
|
7
|
+
* A url names one version, and which one is this CLI's to say: the headless host at the CLI's own
|
|
8
|
+
* version (one ABI set), an editor at the version the CLI was published against (its peer range,
|
|
9
|
+
* snapped to it by scripts/sync-peer-versions.ts). */
|
|
10
|
+
|
|
11
|
+
export const ARCHIVED_PEERS: ReadonlySet<string> = new Set(["lecodes-headless", "lecodes-design", "lecodes-3d-editor"])
|
|
12
|
+
|
|
13
|
+
const BASE = "https://storage.yandexcloud.net/le-codes/releases/packages"
|
|
14
|
+
/** LECODES_PACKAGES_URL moves it (a mirror, a bucket of one's own). */
|
|
15
|
+
export const packagesBase = (): string => (process.env.LECODES_PACKAGES_URL || BASE).replace(/\/$/, "")
|
|
16
|
+
|
|
17
|
+
export const archiveUrl = (name: string, version: string): string => `${packagesBase()}/${name}/${name}-${version}.tgz`
|
|
18
|
+
|
|
19
|
+
/** The version of an archived peer this CLI installs; null for a peer that is none. */
|
|
20
|
+
export const archivedVersion = (name: string): string | null => {
|
|
21
|
+
if (!ARCHIVED_PEERS.has(name)) return null
|
|
22
|
+
if (name === "lecodes-headless") return pkg.version
|
|
23
|
+
const range = (pkg.peerDependencies as Record<string, string> | undefined)?.[name]
|
|
24
|
+
const version = range?.replace(/^[\^~>=\s]+/, "")
|
|
25
|
+
return version && /^\d+\.\d+\.\d+/.test(version) ? version : null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** What an archived peer is installed by; null for a peer that is none. */
|
|
29
|
+
export const archiveSpec = (name: string): string | null => {
|
|
30
|
+
const version = archivedVersion(name)
|
|
31
|
+
return version ? archiveUrl(name, version) : null
|
|
32
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// The browser of `lecodes test --lite`, as a process of its own: headless Chromium through
|
|
2
|
+
// Playwright, driven over stdin / stdout in JSON lines (liteRunner.ts is the other end).
|
|
3
|
+
//
|
|
4
|
+
// A process of NODE, whatever runs the CLI: Playwright does not start a browser under Bun on
|
|
5
|
+
// Windows (its launch never returns), and the CLI runs under Bun in a checkout.
|
|
6
|
+
//
|
|
7
|
+
// → { "id": 1, "op": "open", "args": { "width": 390, "height": 844 } }
|
|
8
|
+
// ← { "id": 1, "ok": true, "value": null }
|
|
9
|
+
// ← { "event": "console", "level": "log", "text": "…" } whatever the page says, as it says it
|
|
10
|
+
//
|
|
11
|
+
// One page at a time: `open` closes the one before it.
|
|
12
|
+
import { createRequire } from "node:module"
|
|
13
|
+
import { createInterface } from "node:readline"
|
|
14
|
+
import { pathToFileURL } from "node:url"
|
|
15
|
+
|
|
16
|
+
const send = (message) => process.stdout.write(JSON.stringify(message) + "\n")
|
|
17
|
+
|
|
18
|
+
let browser = null
|
|
19
|
+
let context = null
|
|
20
|
+
let page = null
|
|
21
|
+
|
|
22
|
+
const ops = {
|
|
23
|
+
/** `from`: where Playwright may be, as files to resolve it from. */
|
|
24
|
+
async launch({ from }) {
|
|
25
|
+
let chromium = null
|
|
26
|
+
for (const base of from) {
|
|
27
|
+
try {
|
|
28
|
+
const pw = await import(pathToFileURL(createRequire(base).resolve("playwright")).href)
|
|
29
|
+
chromium = pw.chromium ?? pw.default?.chromium
|
|
30
|
+
if (chromium) break
|
|
31
|
+
} catch { /* not there */ }
|
|
32
|
+
}
|
|
33
|
+
if (!chromium) throw new Error("NO_PLAYWRIGHT")
|
|
34
|
+
browser = await chromium.launch()
|
|
35
|
+
return null
|
|
36
|
+
},
|
|
37
|
+
async open({ width, height }) {
|
|
38
|
+
await context?.close().catch(() => {})
|
|
39
|
+
context = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: 1 })
|
|
40
|
+
page = await context.newPage()
|
|
41
|
+
page.on("console", (message) => send({ event: "console", level: message.type(), text: message.text() }))
|
|
42
|
+
page.on("pageerror", (error) => send({ event: "console", level: "error", text: String(error) }))
|
|
43
|
+
return null
|
|
44
|
+
},
|
|
45
|
+
async goto({ url }) { await page.goto(url); return null },
|
|
46
|
+
async waitFor({ expression, timeout }) { await page.waitForFunction(expression, null, { timeout }); return null },
|
|
47
|
+
evaluate: ({ expression }) => page.evaluate(expression),
|
|
48
|
+
async timeout({ ms }) { await page.waitForTimeout(ms); return null },
|
|
49
|
+
async mouse({ action, x, y }) {
|
|
50
|
+
if (action === "move") await page.mouse.move(x, y)
|
|
51
|
+
else if (action === "down") await page.mouse.down()
|
|
52
|
+
else if (action === "up") await page.mouse.up()
|
|
53
|
+
else if (action === "wheel") await page.mouse.wheel(x, y)
|
|
54
|
+
return null
|
|
55
|
+
},
|
|
56
|
+
async keyboard({ action, text }) {
|
|
57
|
+
if (action === "type") await page.keyboard.type(text)
|
|
58
|
+
else if (action === "down") await page.keyboard.down(text)
|
|
59
|
+
else if (action === "up") await page.keyboard.up(text)
|
|
60
|
+
return null
|
|
61
|
+
},
|
|
62
|
+
async screenshot({ path, clip }) { await page.screenshot({ path, ...(clip ? { clip } : {}) }); return null },
|
|
63
|
+
async close() {
|
|
64
|
+
await context?.close().catch(() => {})
|
|
65
|
+
await browser?.close().catch(() => {})
|
|
66
|
+
setTimeout(() => process.exit(0), 0)
|
|
67
|
+
return null
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// One request at a time, in the order they came: the page is one.
|
|
72
|
+
let chain = Promise.resolve()
|
|
73
|
+
createInterface({ input: process.stdin }).on("line", (line) => {
|
|
74
|
+
let request
|
|
75
|
+
try { request = JSON.parse(line) } catch { return }
|
|
76
|
+
chain = chain.then(async () => {
|
|
77
|
+
try {
|
|
78
|
+
const op = ops[request.op]
|
|
79
|
+
if (!op) throw new Error(`no such op: ${request.op}`)
|
|
80
|
+
send({ id: request.id, ok: true, value: (await op(request.args ?? {})) ?? null })
|
|
81
|
+
} catch (e) {
|
|
82
|
+
send({ id: request.id, ok: false, error: e instanceof Error ? e.message.split("\n")[0] : String(e) })
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
process.stdin.on("end", () => { void ops.close() })
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process"
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { createServer } from "node:net"
|
|
4
|
+
import { dirname, join } from "node:path"
|
|
5
|
+
import { createInterface } from "node:readline"
|
|
6
|
+
import { CliError } from "../cli"
|
|
7
|
+
import { startDevServer, type DevServer } from "../dev/devServer"
|
|
8
|
+
import type { Manifest } from "../project/manifest"
|
|
9
|
+
import { distRoot } from "./distRoot"
|
|
10
|
+
import { peersDir } from "./peers"
|
|
11
|
+
import type { DesktopPlan } from "./desktopScript"
|
|
12
|
+
// (a relative source import, as desktopScript.ts does: lecodes-headless is an optional peer)
|
|
13
|
+
import { findDumpTarget, parseTreeDump } from "../../../../runtime/headless/src/headless/paintDump"
|
|
14
|
+
|
|
15
|
+
/*
|
|
16
|
+
* `lecodes test --lite`: a flow scenario on WEB LITE, in a real browser. The scenario is the one
|
|
17
|
+
* every host takes; it is translated by desktopScript.ts into the native host's command file, and
|
|
18
|
+
* this module is a third reader of those commands — it plays them on the page of Web Lite in
|
|
19
|
+
* headless Chromium (Playwright: the page's own pointer and key events, nothing injected past
|
|
20
|
+
* them) and answers in the native host's words (`[script] #<line> done <op>`), so the verdict is
|
|
21
|
+
* desktopScript.ts's `evaluateDesktopRun`, the same rules as on the desktop.
|
|
22
|
+
*
|
|
23
|
+
* What differs is the CLOCK: a page runs on the browser's time, not on a fixed step. `frames N` /
|
|
24
|
+
* `wait ms` wait that long in real time — and a step that expects a log line is through as soon
|
|
25
|
+
* as the line is there, with room past its frames for a page that is slower than a fixed clock.
|
|
26
|
+
*
|
|
27
|
+
* The browser is a process of its own (liteBrowser.mjs, run by Node): Playwright does not start
|
|
28
|
+
* one under Bun on Windows.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
type Clip = { x: number, y: number, width: number, height: number }
|
|
32
|
+
|
|
33
|
+
/** The browser, as this module drives it: one page at a time. */
|
|
34
|
+
type Driver = {
|
|
35
|
+
open(width: number, height: number, onConsole: (level: string, text: string) => void): Promise<void>
|
|
36
|
+
goto(url: string): Promise<void>
|
|
37
|
+
waitFor(expression: string, timeout: number): Promise<void>
|
|
38
|
+
evaluate<T>(expression: string): Promise<T>
|
|
39
|
+
timeout(ms: number): Promise<void>
|
|
40
|
+
mouse(action: "move" | "down" | "up" | "wheel", x?: number, y?: number): Promise<void>
|
|
41
|
+
keyboard(action: "type" | "down" | "up", text: string): Promise<void>
|
|
42
|
+
screenshot(path: string, clip: Clip | null): Promise<void>
|
|
43
|
+
close(): Promise<void>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const FRAME_MS = 16
|
|
47
|
+
/** How long past its frames a step that expects a log line may wait for it. */
|
|
48
|
+
const LOG_SLACK_MS = 3000
|
|
49
|
+
|
|
50
|
+
const NO_NODE = "--lite needs Node on the PATH: the browser is driven from a Node process."
|
|
51
|
+
const NO_PLAYWRIGHT = "--lite runs the flows in a browser and needs Playwright: `npm install -D playwright` in the project, then `npx playwright install chromium` once."
|
|
52
|
+
|
|
53
|
+
/** Start the browser's process and its browser. */
|
|
54
|
+
const startDriver = async (): Promise<Driver> => {
|
|
55
|
+
const script = join(distRoot, "src", "hosts", "liteBrowser.mjs")
|
|
56
|
+
if (!existsSync(script)) throw new CliError("--lite is not available in this build of the CLI (its browser driver ships with the npm package).")
|
|
57
|
+
const child: ChildProcess = spawn("node", [script], { stdio: ["pipe", "pipe", "inherit"] })
|
|
58
|
+
|
|
59
|
+
const waiting = new Map<number, { resolve: (value: unknown) => void, reject: (error: Error) => void }>()
|
|
60
|
+
let onConsole: (level: string, text: string) => void = () => {}
|
|
61
|
+
let next = 1
|
|
62
|
+
let dead: Error | null = null
|
|
63
|
+
const fail = (error: Error) => {
|
|
64
|
+
dead = dead ?? error
|
|
65
|
+
for (const w of waiting.values()) w.reject(dead)
|
|
66
|
+
waiting.clear()
|
|
67
|
+
}
|
|
68
|
+
child.on("error", () => fail(new CliError(NO_NODE)))
|
|
69
|
+
child.on("exit", () => fail(new Error("the browser's process ended")))
|
|
70
|
+
createInterface({ input: child.stdout! }).on("line", (line) => {
|
|
71
|
+
let message: { id?: number, ok?: boolean, value?: unknown, error?: string, event?: string, level?: string, text?: string }
|
|
72
|
+
try { message = JSON.parse(line) } catch { return }
|
|
73
|
+
if (message.event === "console") { onConsole(message.level ?? "log", message.text ?? ""); return }
|
|
74
|
+
const w = message.id !== undefined ? waiting.get(message.id) : undefined
|
|
75
|
+
if (!w) return
|
|
76
|
+
waiting.delete(message.id!)
|
|
77
|
+
if (message.ok) w.resolve(message.value)
|
|
78
|
+
else w.reject(new Error(message.error ?? "failed"))
|
|
79
|
+
})
|
|
80
|
+
const call = <T = void>(op: string, args: object = {}): Promise<T> => new Promise<T>((resolve, reject) => {
|
|
81
|
+
if (dead) { reject(dead); return }
|
|
82
|
+
const id = next++
|
|
83
|
+
waiting.set(id, { resolve: resolve as (value: unknown) => void, reject })
|
|
84
|
+
child.stdin!.write(JSON.stringify({ id, op, args }) + "\n")
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
// Playwright, from where it may be: the project, the CLI, the CLI's peers, a checkout's gl-lite.
|
|
88
|
+
const from = [
|
|
89
|
+
join(process.cwd(), "package.json"),
|
|
90
|
+
join(distRoot, "package.json"),
|
|
91
|
+
join(peersDir(), "package.json"),
|
|
92
|
+
join(distRoot, "..", "..", "engines", "gl-lite", "package.json"),
|
|
93
|
+
]
|
|
94
|
+
try {
|
|
95
|
+
await call("launch", { from })
|
|
96
|
+
} catch (e) {
|
|
97
|
+
child.kill()
|
|
98
|
+
if (e instanceof CliError) throw e
|
|
99
|
+
const message = e instanceof Error ? e.message : String(e)
|
|
100
|
+
throw new CliError(message === "NO_PLAYWRIGHT" ? NO_PLAYWRIGHT : `Chromium did not start (${message}) — run "npx playwright install chromium" once.`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
open(width, height, listener) { onConsole = listener; return call("open", { width, height }) },
|
|
105
|
+
goto: (url) => call("goto", { url }),
|
|
106
|
+
waitFor: (expression, timeout) => call("waitFor", { expression, timeout }),
|
|
107
|
+
evaluate: <T>(expression: string) => call<T>("evaluate", { expression }),
|
|
108
|
+
timeout: (ms) => call("timeout", { ms }),
|
|
109
|
+
mouse: (action, x, y) => call("mouse", { action, x, y }),
|
|
110
|
+
keyboard: (action, text) => call("keyboard", { action, text }),
|
|
111
|
+
screenshot: (path, clip) => call("screenshot", { path, clip }),
|
|
112
|
+
async close() {
|
|
113
|
+
await call("close").catch(() => {})
|
|
114
|
+
child.kill()
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const freePort = (): Promise<number> => new Promise((resolve, reject) => {
|
|
120
|
+
const probe = createServer()
|
|
121
|
+
probe.once("error", reject)
|
|
122
|
+
probe.listen(0, "127.0.0.1", () => {
|
|
123
|
+
const address = probe.address()
|
|
124
|
+
const port = typeof address === "object" && address ? address.port : 0
|
|
125
|
+
probe.close(() => resolve(port))
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
export type LiteRunOptions = { width: number, height: number, onConsole?: (level: string, text: string) => void }
|
|
130
|
+
|
|
131
|
+
export type LiteSession = {
|
|
132
|
+
/** Play one case; the host's output as `evaluateDesktopRun` reads it. */
|
|
133
|
+
run(plan: DesktopPlan, options: LiteRunOptions): Promise<string>
|
|
134
|
+
close(): Promise<void>
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The project compiled and served with the page of Web Lite, and a browser to open it in. */
|
|
138
|
+
export const startLiteSession = async (options: { root: string, manifest: Manifest | null, name: string, lite: string, entry?: string }): Promise<LiteSession> => {
|
|
139
|
+
const port = await freePort()
|
|
140
|
+
const server: DevServer = await startDevServer({
|
|
141
|
+
root: options.root, manifest: options.manifest, name: options.name, lite: options.lite,
|
|
142
|
+
origin: `http://127.0.0.1:${port}`, bind: "127.0.0.1", port, token: "test", entryOverride: options.entry,
|
|
143
|
+
})
|
|
144
|
+
const compiled = await server.recompile()
|
|
145
|
+
if (!compiled.ok) { server.close(); throw new CliError(`Compile failed:\n${compiled.error}`) }
|
|
146
|
+
|
|
147
|
+
let driver: Driver
|
|
148
|
+
try {
|
|
149
|
+
driver = await startDriver()
|
|
150
|
+
} catch (e) {
|
|
151
|
+
server.close()
|
|
152
|
+
throw e
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
run: (plan, runOptions) => runPlan(driver, `${server.liteUrl}?dev=..&test`, plan, runOptions),
|
|
157
|
+
async close() {
|
|
158
|
+
await driver.close()
|
|
159
|
+
server.close()
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---- the commands ------------------------------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
type Arg = number | string
|
|
167
|
+
|
|
168
|
+
/** The arguments of a command line: numbers and quoted names (`"` and `\` escaped). */
|
|
169
|
+
export const parseArgs = (rest: string): Arg[] => {
|
|
170
|
+
const out: Arg[] = []
|
|
171
|
+
let i = 0
|
|
172
|
+
while (i < rest.length) {
|
|
173
|
+
if (rest[i] === " ") { i++; continue }
|
|
174
|
+
if (rest[i] === '"') {
|
|
175
|
+
let text = ""
|
|
176
|
+
i++
|
|
177
|
+
while (i < rest.length && rest[i] !== '"') {
|
|
178
|
+
if (rest[i] === "\\") i++
|
|
179
|
+
text += rest[i] ?? ""
|
|
180
|
+
i++
|
|
181
|
+
}
|
|
182
|
+
i++
|
|
183
|
+
out.push(text)
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
const end = rest.indexOf(" ", i)
|
|
187
|
+
const token = rest.slice(i, end < 0 ? rest.length : end)
|
|
188
|
+
const n = Number(token)
|
|
189
|
+
out.push(Number.isFinite(n) && token !== "" ? n : token)
|
|
190
|
+
i = end < 0 ? rest.length : end
|
|
191
|
+
}
|
|
192
|
+
return out
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const unwire = (text: string): string => text.replace(/\\(n|\\)/g, (_, c: string) => (c === "n" ? "\n" : "\\"))
|
|
196
|
+
|
|
197
|
+
const LEVEL: Record<string, string> = { log: "DEBUG", debug: "DEBUG", info: "INFO", warning: "WARN", warn: "WARN", error: "ERROR" }
|
|
198
|
+
|
|
199
|
+
// What is on show: the root's children that the router did not cover.
|
|
200
|
+
const ON_SHOW = ".lc-root > .lc:not(.lc-covered)"
|
|
201
|
+
|
|
202
|
+
const FRAME = "new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(null))))"
|
|
203
|
+
|
|
204
|
+
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"`
|
|
205
|
+
|
|
206
|
+
const runPlan = async (driver: Driver, url: string, plan: DesktopPlan, options: LiteRunOptions): Promise<string> => {
|
|
207
|
+
const output: string[] = []
|
|
208
|
+
const seen: string[] = []
|
|
209
|
+
const say = (line: string) => { output.push(line) }
|
|
210
|
+
|
|
211
|
+
await driver.open(options.width, options.height, (level, text) => {
|
|
212
|
+
seen.push(text)
|
|
213
|
+
say(`[${LEVEL[level] ?? "DEBUG"}] ${text}`)
|
|
214
|
+
options.onConsole?.(level, text)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
// Which step a line belongs to: a step that expects a log line waits for it.
|
|
218
|
+
const expectOf = new Map<number, string>()
|
|
219
|
+
for (const step of plan.steps) if (step.expectLog !== undefined) for (const line of step.lines) expectOf.set(line, step.expectLog)
|
|
220
|
+
|
|
221
|
+
const dump = () => driver.evaluate<string>("window.lecodesTest.dump()")
|
|
222
|
+
const rectOf = (id: number) => driver.evaluate<[number, number, number, number] | null>(`window.lecodesTest.rect(${id})`)
|
|
223
|
+
const nodeOf = async (name: string) => {
|
|
224
|
+
const node = findDumpTarget(parseTreeDump(await dump()), name)
|
|
225
|
+
if (!node) throw new Error(`no element "${name}" on the screen`)
|
|
226
|
+
return node
|
|
227
|
+
}
|
|
228
|
+
/** The point of a target: a name resolved in the tree as every host resolves it, or a point. */
|
|
229
|
+
const pointOf = async (args: Arg[], at: number): Promise<{ point: [number, number], next: number }> => {
|
|
230
|
+
const first = args[at]
|
|
231
|
+
if (typeof first === "number") return { point: [first, Number(args[at + 1])], next: at + 2 }
|
|
232
|
+
const rect = await rectOf((await nodeOf(String(first))).id)
|
|
233
|
+
if (!rect || rect[2] === 0 || rect[3] === 0) throw new Error(`"${first}" is not laid out`)
|
|
234
|
+
return { point: [rect[0] + rect[2] / 2, rect[1] + rect[3] / 2], next: at + 1 }
|
|
235
|
+
}
|
|
236
|
+
const frame = () => driver.evaluate<null>(FRAME)
|
|
237
|
+
/** Wait `ms`; a line that expects a log is through when the log is there. */
|
|
238
|
+
const waitFor = async (ms: number, line: number) => {
|
|
239
|
+
const expected = expectOf.get(line)
|
|
240
|
+
if (expected === undefined) { await driver.timeout(ms); return }
|
|
241
|
+
const until = Date.now() + ms + LOG_SLACK_MS
|
|
242
|
+
while (Date.now() < until) {
|
|
243
|
+
if (seen.some((text) => text.includes(expected))) return
|
|
244
|
+
await driver.timeout(FRAME_MS)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
let clip: Clip | null = null
|
|
249
|
+
|
|
250
|
+
const play = async (op: string, rest: string, line: number): Promise<void> => {
|
|
251
|
+
const args = parseArgs(rest)
|
|
252
|
+
switch (op) {
|
|
253
|
+
case "frames": await waitFor(Math.max(0, Number(args[0])) * FRAME_MS, line); return
|
|
254
|
+
case "wait": await waitFor(Math.max(0, Number(args[0])), line); return
|
|
255
|
+
case "tap": {
|
|
256
|
+
const { point } = await pointOf(args, 0)
|
|
257
|
+
await driver.mouse("move", point[0], point[1])
|
|
258
|
+
await driver.mouse("down")
|
|
259
|
+
await driver.mouse("up")
|
|
260
|
+
await frame()
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
case "hold": {
|
|
264
|
+
const { point, next } = await pointOf(args, 0)
|
|
265
|
+
await driver.mouse("move", point[0], point[1])
|
|
266
|
+
await driver.mouse("down")
|
|
267
|
+
await driver.timeout(Number(args[next]) || 0)
|
|
268
|
+
await driver.mouse("up")
|
|
269
|
+
await frame()
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
case "drag": {
|
|
273
|
+
const from = await pointOf(args, 0)
|
|
274
|
+
const to = await pointOf(args, from.next)
|
|
275
|
+
const ms = Number(args[to.next]) || 300
|
|
276
|
+
const steps = Math.max(2, Math.round(ms / FRAME_MS))
|
|
277
|
+
await driver.mouse("move", from.point[0], from.point[1])
|
|
278
|
+
await driver.mouse("down")
|
|
279
|
+
for (let i = 1; i <= steps; i++) {
|
|
280
|
+
const t = i / steps
|
|
281
|
+
await driver.mouse("move", from.point[0] + (to.point[0] - from.point[0]) * t, from.point[1] + (to.point[1] - from.point[1]) * t)
|
|
282
|
+
await driver.timeout(ms / steps)
|
|
283
|
+
}
|
|
284
|
+
await driver.mouse("up")
|
|
285
|
+
await frame()
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
case "mouseto": {
|
|
289
|
+
const { point } = await pointOf(args, 0)
|
|
290
|
+
await driver.mouse("move", point[0], point[1])
|
|
291
|
+
await frame()
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
case "type": await driver.keyboard("type", unwire(rest)); await frame(); return
|
|
295
|
+
case "fill": {
|
|
296
|
+
const value = rest === "" ? undefined : unwire(rest)
|
|
297
|
+
// every empty field on show, in the tree's order: what the native hosts fill
|
|
298
|
+
const count = await driver.evaluate<number>(`(() => {
|
|
299
|
+
window.__lecodesFill = [...document.querySelectorAll("${ON_SHOW} input.lc, ${ON_SHOW} textarea.lc")].filter((f) => f.value === "")
|
|
300
|
+
return window.__lecodesFill.length
|
|
301
|
+
})()`)
|
|
302
|
+
for (let i = 0; i < count; i++) {
|
|
303
|
+
const text = value ?? await driver.evaluate<string>(`(${FILL_VALUE})(window.__lecodesFill[${i}])`)
|
|
304
|
+
await driver.evaluate(`window.__lecodesFill[${i}].focus()`)
|
|
305
|
+
await driver.keyboard("type", text)
|
|
306
|
+
}
|
|
307
|
+
await frame()
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
case "scroll": {
|
|
311
|
+
const by = Number(args[0])
|
|
312
|
+
const name = typeof args[1] === "string" ? args[1] : null
|
|
313
|
+
const target = name === null
|
|
314
|
+
? `document.querySelector("${ON_SHOW} .lc-scrollable, ${ON_SHOW} .lc-vlist")`
|
|
315
|
+
: `document.querySelector('[data-node="${(await nodeOf(name)).id}"]')`
|
|
316
|
+
const found = await driver.evaluate<boolean>(`(() => {
|
|
317
|
+
const el = ${target}
|
|
318
|
+
if (!el) return false
|
|
319
|
+
const style = getComputedStyle(el)
|
|
320
|
+
if (style.overflowX !== "hidden" && style.overflowY === "hidden") el.scrollLeft += ${by}
|
|
321
|
+
else el.scrollTop += ${by}
|
|
322
|
+
return true
|
|
323
|
+
})()`)
|
|
324
|
+
if (!found) throw new Error(name === null ? "no scrollable on the screen" : `"${name}" is not there`)
|
|
325
|
+
await frame()
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
case "key": {
|
|
329
|
+
await driver.keyboard("down", String(args[0]))
|
|
330
|
+
await driver.timeout(Number(args[1]) || 0)
|
|
331
|
+
await driver.keyboard("up", String(args[0]))
|
|
332
|
+
return
|
|
333
|
+
}
|
|
334
|
+
case "keydown": await driver.keyboard("down", String(args[0])); return
|
|
335
|
+
case "keyup": await driver.keyboard("up", String(args[0])); return
|
|
336
|
+
case "wheel": await driver.mouse("wheel", Number(args[0]) * 100, Number(args[1]) * 100); await frame(); return
|
|
337
|
+
case "dump": {
|
|
338
|
+
const file = rest.trim()
|
|
339
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
340
|
+
writeFileSync(file, await dump())
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
case "clip": {
|
|
344
|
+
if (rest.trim() === "off") { clip = null; return }
|
|
345
|
+
if (typeof args[0] === "number") { clip = { x: args[0], y: Number(args[1]), width: Number(args[2]), height: Number(args[3]) }; return }
|
|
346
|
+
const rect = await rectOf((await nodeOf(String(args[0]))).id)
|
|
347
|
+
if (!rect) throw new Error(`"${args[0]}" is not laid out`)
|
|
348
|
+
clip = { x: rect[0], y: rect[1], width: rect[2], height: rect[3] }
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
case "shot": {
|
|
352
|
+
const file = rest.trim()
|
|
353
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
354
|
+
await driver.screenshot(file, clip)
|
|
355
|
+
return
|
|
356
|
+
}
|
|
357
|
+
default:
|
|
358
|
+
throw new Error(`"${op}" is not available on Web Lite (it has no 3D scene, no gamepad)`)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
try {
|
|
363
|
+
await driver.goto(url)
|
|
364
|
+
await driver.waitFor("window.lecodesTest && window.lecodesTest.ready === true", 20000)
|
|
365
|
+
say("[script] loaded")
|
|
366
|
+
const lines = plan.text.split("\n")
|
|
367
|
+
for (let i = 0; i < lines.length; i++) {
|
|
368
|
+
const text = lines[i]!.trim()
|
|
369
|
+
if (text === "" || text.startsWith("#")) continue
|
|
370
|
+
const space = text.indexOf(" ")
|
|
371
|
+
const op = space < 0 ? text : text.slice(0, space)
|
|
372
|
+
const rest = space < 0 ? "" : text.slice(space + 1)
|
|
373
|
+
try {
|
|
374
|
+
await play(op, rest, i + 1)
|
|
375
|
+
say(`[script] #${i + 1} done ${op}`)
|
|
376
|
+
} catch (e) {
|
|
377
|
+
say(`[script] #${i + 1} error ${op}: ${e instanceof Error ? e.message.split("\n")[0] : String(e)}`)
|
|
378
|
+
break
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
say("[script] end")
|
|
382
|
+
} catch (e) {
|
|
383
|
+
say(`[ERROR] ${e instanceof Error ? e.message.split("\n")[0] : String(e)}`)
|
|
384
|
+
}
|
|
385
|
+
return output.join("\n")
|
|
386
|
+
}
|
package/src/hosts/peerInstall.ts
CHANGED
|
@@ -4,13 +4,15 @@ import { join } from "node:path"
|
|
|
4
4
|
import { pathToFileURL } from "node:url"
|
|
5
5
|
import pkg from "../../package.json"
|
|
6
6
|
import { peerDirInPrefix, peersDir, peerVersionGlobal } from "./peers"
|
|
7
|
+
import { archiveSpec } from "./archives"
|
|
7
8
|
import { CliError, c, confirm, log, note } from "../cli"
|
|
8
9
|
|
|
9
10
|
/*
|
|
10
11
|
* Peers install themselves on first use. `lecodes render` on a machine with only the CLI asks once,
|
|
11
12
|
* then puts lecodes-headless (+ @napi-rs/canvas for PNG) into ~/.lecodes/peers and loads it from
|
|
12
13
|
* there — the same pattern as matc and the desktop host (downloaded into ~/.lecodes on first use),
|
|
13
|
-
* applied to
|
|
14
|
+
* applied to packages: the headless host and the editors are ARCHIVES of the releases bucket,
|
|
15
|
+
* installed by their url (./archives.ts); canvas and lecodes-assets are npm's. Why a private prefix and not `npm install -g`: no sudo / system prefix
|
|
14
16
|
* to guess, and the runtime-set peer is installed at EXACTLY this CLI's version — the compiler and the
|
|
15
17
|
* headless host are one ABI set, so a global renderer that drifted behind the CLI is the very failure
|
|
16
18
|
* this removes. A newer CLI re-installs its own version on the next use (peerSpec → `lecodes update`).
|
|
@@ -28,10 +30,12 @@ const CANVAS_RANGE_FALLBACK = "^0.1.65"
|
|
|
28
30
|
|
|
29
31
|
const readJson = (path: string): any => { try { return JSON.parse(readFileSync(path, "utf8")) } catch { return null } }
|
|
30
32
|
|
|
31
|
-
/**
|
|
32
|
-
*
|
|
33
|
+
/** What a peer installs by: the url of its archive — the renderer at exactly this CLI's version (one
|
|
34
|
+
* ABI set), an editor at the version this CLI was published against — or, for npm's, a spec: canvas
|
|
35
|
+
* at the range the installed renderer declares, lecodes-assets at this CLI's peer range. */
|
|
33
36
|
export const peerSpec = (name: string): string => {
|
|
34
|
-
|
|
37
|
+
const archive = archiveSpec(name)
|
|
38
|
+
if (archive) return archive
|
|
35
39
|
if (name === "@napi-rs/canvas") {
|
|
36
40
|
const renderer = readJson(join(peerDirInPrefix("lecodes-headless"), "package.json"))
|
|
37
41
|
const range = renderer?.peerDependencies?.["@napi-rs/canvas"] ?? renderer?.optionalDependencies?.["@napi-rs/canvas"] ?? CANVAS_RANGE_FALLBACK
|