dsh-mobilecode 0.11.9 → 0.12.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/README.md +56 -0
- package/lib/client.js +2455 -1610
- package/lib/config-matrix.js +202 -0
- package/lib/index.js +656 -3
- package/lib/openpencil.js +139 -0
- package/lib/png-decode.js +86 -0
- package/lib/preview-gallery.js +309 -0
- package/lib/reference-workspace.js +283 -0
- package/lib/visual-compare.js +196 -0
- package/package.json +2 -2
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-mobilecode — OpenPencil narrow adapter (T7/Feature D).
|
|
3
|
+
*
|
|
4
|
+
* OpenPencil is a design EDITOR whose CLI exports PNG/JPG/WEBP/SVG/JSX/HTML
|
|
5
|
+
* (verified against openpencil.dev/reference/cli, 2026-09-14). Its exports are
|
|
6
|
+
* web-oriented — this adapter NEVER claims native Compose export. Value here:
|
|
7
|
+
* frame → PNG reference into the comparison workspace + design-token
|
|
8
|
+
* inspection (`variables --json`).
|
|
9
|
+
*
|
|
10
|
+
* Safety:
|
|
11
|
+
* - optional: nothing in this module runs at plugin load; absence of the
|
|
12
|
+
* binary degrades to a status report (base plugin unaffected);
|
|
13
|
+
* - every path is confined to an explicitly allowed project root
|
|
14
|
+
* (realpath-based: no traversal through .. or symlinks);
|
|
15
|
+
* - CLI called with argument arrays only; no shell; no network calls;
|
|
16
|
+
* - output goes to a caller-invisible temp location inside the store, never
|
|
17
|
+
* next to the user's design file.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, lstatSync, realpathSync, mkdirSync } from "node:fs"
|
|
21
|
+
import path from "node:path"
|
|
22
|
+
import * as DeviceBuild from "./device-build.js"
|
|
23
|
+
|
|
24
|
+
const CLI = "openpencil"
|
|
25
|
+
const DOC_TIMEOUT_MS = 60_000
|
|
26
|
+
|
|
27
|
+
export function probe() {
|
|
28
|
+
const resolved = DeviceBuild.resolveExecutable(CLI)
|
|
29
|
+
return { installed: resolved !== undefined && resolved !== null && resolved !== "", resolved: resolved ?? undefined }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Run the CLI with an argument array; bounded output + timeout.
|
|
33
|
+
* Uses DeviceBuild.launch (audit P4-2): npm installs resolve to openpencil.cmd,
|
|
34
|
+
* which bare spawn() refuses with EINVAL on Windows. */
|
|
35
|
+
function runCli(args, { timeoutMs = DOC_TIMEOUT_MS } = {}) {
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
const child = DeviceBuild.launch(DeviceBuild.resolveExecutable(CLI) ?? CLI, args, { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] })
|
|
38
|
+
let out = ""
|
|
39
|
+
let err = ""
|
|
40
|
+
let truncated = false
|
|
41
|
+
const grab = (chunk, which) => {
|
|
42
|
+
if (out.length + err.length > 2_000_000) { truncated = true; return }
|
|
43
|
+
if (which === "out") out += chunk; else err += chunk
|
|
44
|
+
}
|
|
45
|
+
child.stdout.on("data", (c) => grab(c, "out"))
|
|
46
|
+
child.stderr.on("data", (c) => grab(c, "err"))
|
|
47
|
+
const timer = setTimeout(() => { try { child.kill() } catch { /* gone */ } }, timeoutMs)
|
|
48
|
+
child.on("error", (e) => { clearTimeout(timer); resolve({ code: -1, out, err: String(e), truncated }) })
|
|
49
|
+
child.on("close", (code) => { clearTimeout(timer); resolve({ code, out, err, truncated }) })
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Confine a caller-supplied file path inside `root` (realpath-based).
|
|
55
|
+
* Returns the resolved absolute path or throws with an actionable reason.
|
|
56
|
+
*/
|
|
57
|
+
export function confinePath(root, target, { mustExist = true } = {}) {
|
|
58
|
+
if (typeof target !== "string" || target.trim() === "") throw new Error("path is required")
|
|
59
|
+
const abs = path.resolve(root, target)
|
|
60
|
+
let realRoot
|
|
61
|
+
let realTarget
|
|
62
|
+
try { realRoot = realpathSync(root) } catch { throw new Error(`allowed root not found: ${root}`) }
|
|
63
|
+
if (mustExist) {
|
|
64
|
+
try { realTarget = realpathSync(abs) } catch { throw new Error(`file not found: ${target}`) }
|
|
65
|
+
} else {
|
|
66
|
+
// for outputs the file may not exist yet: realpath the nearest existing ancestor.
|
|
67
|
+
// audit P4-3: existsSync follows reparse points — a BROKEN junction returns false
|
|
68
|
+
// and its name would be re-joined unresolved, escaping root. Resolve any
|
|
69
|
+
// symlink/junction component through realpath instead of keeping its name.
|
|
70
|
+
let cursor = abs
|
|
71
|
+
const missing = []
|
|
72
|
+
for (;;) {
|
|
73
|
+
let st
|
|
74
|
+
try { st = lstatSync(cursor) } catch { st = undefined }
|
|
75
|
+
if (st !== undefined && st.isSymbolicLink()) {
|
|
76
|
+
// a symlink/junction (incl. broken ones) must resolve inside root or be refused
|
|
77
|
+
try { cursor = realpathSync(cursor); break } catch { throw new Error(`path escapes the allowed project root (broken link): ${target}`) }
|
|
78
|
+
}
|
|
79
|
+
if (st !== undefined) break // real existing entry
|
|
80
|
+
missing.unshift(path.basename(cursor))
|
|
81
|
+
const parent = path.dirname(cursor)
|
|
82
|
+
if (parent === cursor) break
|
|
83
|
+
cursor = parent
|
|
84
|
+
}
|
|
85
|
+
let base
|
|
86
|
+
try { base = realpathSync(cursor) } catch { base = cursor }
|
|
87
|
+
realTarget = path.join(base, ...missing)
|
|
88
|
+
}
|
|
89
|
+
const rel = path.relative(realRoot, realTarget)
|
|
90
|
+
if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
91
|
+
throw new Error(`path escapes the allowed project root: ${target}`)
|
|
92
|
+
}
|
|
93
|
+
return realTarget
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Inspect a .fig document (pages + node tree, depth-bounded). */
|
|
97
|
+
export async function inspect(file, root) {
|
|
98
|
+
const confined = confinePath(root, file)
|
|
99
|
+
if (!/\.(fig)$/i.test(confined)) throw new Error("only .fig documents are inspected")
|
|
100
|
+
const info = await runCli(["info", confined, "--json"])
|
|
101
|
+
const pages = await runCli(["pages", confined, "--json"])
|
|
102
|
+
return {
|
|
103
|
+
status: info.code === 0 ? "passed" : "failed",
|
|
104
|
+
info: safeJson(info.out),
|
|
105
|
+
pages: safeJson(pages.out),
|
|
106
|
+
exit: info.code,
|
|
107
|
+
limitations: ["read-only inspection; the editor owns the document", "no remote/RPC mode used (file mode only)"],
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Export one frame/page of a .fig to PNG inside the store and return provenance. */
|
|
112
|
+
export async function exportFrame({ file, root, node, page, scale = 2, workspace }) {
|
|
113
|
+
const confined = confinePath(root, file)
|
|
114
|
+
if (!/\.(fig)$/i.test(confined)) throw new Error("only .fig documents are exported")
|
|
115
|
+
if (node !== undefined && !/^[A-Za-z0-9:._-]{1,64}$/.test(String(node))) throw new Error("node id has an unexpected shape")
|
|
116
|
+
const outDir = path.join(workspace.root, "openpencil")
|
|
117
|
+
mkdirSync(outDir, { recursive: true })
|
|
118
|
+
const outName = `${Date.now()}-${String(node ?? "page").replaceAll(/[^\w.-]/g, "_")}.png`
|
|
119
|
+
const outPath = confinePath(workspace.root, path.join("openpencil", outName), { mustExist: false })
|
|
120
|
+
const args = ["export", confined, "-f", "png", "-s", String(Math.min(Math.max(Number(scale) || 2, 1), 4)), "-o", outPath]
|
|
121
|
+
if (node) args.push("--node", String(node))
|
|
122
|
+
if (page) args.push("--page", String(page))
|
|
123
|
+
const result = await runCli(args)
|
|
124
|
+
if (result.code !== 0 || !existsSync(outPath)) {
|
|
125
|
+
return { status: "failed", exit: result.code, stderrTail: String(result.err ?? "").split(/\r?\n/).slice(-6).join("\n") }
|
|
126
|
+
}
|
|
127
|
+
return { status: "passed", pngPath: outPath }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Design variables (tokens) from the document — returns them raw; mapping is a human step. */
|
|
131
|
+
export async function variables(file, root) {
|
|
132
|
+
const confined = confinePath(root, file)
|
|
133
|
+
const r = await runCli(["variables", confined, "--json"])
|
|
134
|
+
return { status: r.code === 0 ? "passed" : "failed", variables: safeJson(r.out), exit: r.code }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function safeJson(text) {
|
|
138
|
+
try { return JSON.parse(String(text ?? "").trim()) } catch { return undefined }
|
|
139
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-mobilecode — bounded PNG decoder for native-space regression.
|
|
3
|
+
*
|
|
4
|
+
* Runs ONLY on bytes already inside the store — but store ownership is NOT
|
|
5
|
+
* trust: those bytes originated as user imports. So every input is treated as
|
|
6
|
+
* hostile: per-chunk CRC verification, dimension fences re-enforced at decode
|
|
7
|
+
* time (files can be swapped after import), a decompressed-size budget plus
|
|
8
|
+
* zlib maxOutputLength (deflate bombs die), exact scanline-length validation
|
|
9
|
+
* (truncated IDAT refused), unknown filter bytes refused, and every unsupported
|
|
10
|
+
* format (palette, 16-bit, interlaced, non-PNG) rejected with an explicit
|
|
11
|
+
* reason — NEVER silently approximated, and never falling back to caller
|
|
12
|
+
* pixels while keeping a verified-native label.
|
|
13
|
+
* Scope stays the smallest sufficient subset: 8-bit non-interlaced
|
|
14
|
+
* gray/RGB/RGBA, five scanline filters, stdlib zlib only.
|
|
15
|
+
* ponytail: ceiling = what device screencap + editor exports produce;
|
|
16
|
+
* upgrade path = a real codec dependency IF a workflow needs palette/16-bit.
|
|
17
|
+
*/
|
|
18
|
+
import zlib from "node:zlib"
|
|
19
|
+
|
|
20
|
+
const SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
|
|
21
|
+
const MAX_DIM = 8192 // re-enforced at DECODE time: stored bytes can be swapped
|
|
22
|
+
const MAX_RAW = 64_000_000 // decompressed scanline budget — bombs die here
|
|
23
|
+
|
|
24
|
+
export function decodePngToRgba(buf, { maxPixels = 24_000_000 } = {}) {
|
|
25
|
+
if (buf.length < 33 || !buf.subarray(0, 8).equals(SIG)) throw new Error("not a PNG")
|
|
26
|
+
let p = 8, width = 0, height = 0, bitDepth = 0, colorType = 0, interlace = 0, iend = false
|
|
27
|
+
const idat = []
|
|
28
|
+
while (p + 8 <= buf.length) {
|
|
29
|
+
const len = buf.readUInt32BE(p)
|
|
30
|
+
if (len > buf.length) throw new Error(`truncated chunk (declared ${len} > file ${buf.length})`)
|
|
31
|
+
const type = buf.toString("latin1", p + 4, p + 8)
|
|
32
|
+
const data = buf.subarray(p + 8, p + 8 + len)
|
|
33
|
+
if (p + 12 + len > buf.length) throw new Error(`truncated ${type} chunk body`)
|
|
34
|
+
if ((zlib.crc32(buf.subarray(p + 4, p + 8 + len)) >>> 0) !== (buf.readUInt32BE(p + 8 + len) >>> 0)) throw new Error(`corrupt ${type} chunk (CRC mismatch)`)
|
|
35
|
+
if (type === "IHDR") {
|
|
36
|
+
if (len !== 13) throw new Error(`short/oversized IHDR (${len} bytes, spec says 13)`)
|
|
37
|
+
width = data.readUInt32BE(0); height = data.readUInt32BE(4)
|
|
38
|
+
bitDepth = data[8]; colorType = data[9]; interlace = data[12]
|
|
39
|
+
if (width < 1 || height < 1 || width > MAX_DIM || height > MAX_DIM) throw new Error(`dimension fence violated: ${width}x${height}`)
|
|
40
|
+
if (width * height > maxPixels) throw new Error(`decode guard: ${width}x${height} exceeds ${maxPixels}px`)
|
|
41
|
+
} else if (type === "IDAT") idat.push(Buffer.from(data))
|
|
42
|
+
else if (type === "IEND") { iend = true; break }
|
|
43
|
+
p += 12 + len
|
|
44
|
+
}
|
|
45
|
+
if (!iend) throw new Error("missing IEND — truncated stream")
|
|
46
|
+
if (!width || !height) throw new Error("no IHDR")
|
|
47
|
+
if (interlace !== 0) throw new Error("interlaced PNG not supported (no native claim — refuse, do not approximate)")
|
|
48
|
+
if (bitDepth !== 8) throw new Error(`bit depth ${bitDepth} not supported (8-bit only)`)
|
|
49
|
+
const channels = colorType === 6 ? 4 : colorType === 2 ? 3 : colorType === 0 ? 1 : 0
|
|
50
|
+
if (!channels) throw new Error(`color type ${colorType} not supported (grayscale/RGB/RGBA only — palette refused)`)
|
|
51
|
+
const stride = width * channels
|
|
52
|
+
const rawSize = (stride + 1) * height
|
|
53
|
+
if (rawSize > MAX_RAW) throw new Error(`decompression budget: ${rawSize} raw bytes > ${MAX_RAW}`)
|
|
54
|
+
const raw = zlib.inflateSync(Buffer.concat(idat), { maxOutputLength: MAX_RAW })
|
|
55
|
+
if (raw.length !== rawSize) throw new Error(`truncated IDAT: ${raw.length} decompressed bytes, scanlines need ${rawSize}`)
|
|
56
|
+
const out = Buffer.alloc(width * height * 4)
|
|
57
|
+
const prev = Buffer.alloc(stride)
|
|
58
|
+
let off = 0
|
|
59
|
+
for (let y = 0; y < height; y++) {
|
|
60
|
+
const filter = raw[off++]
|
|
61
|
+
const line = raw.subarray(off, off + stride); off += stride
|
|
62
|
+
const cur = Buffer.alloc(stride)
|
|
63
|
+
for (let x = 0; x < stride; x++) {
|
|
64
|
+
const a = x >= channels ? cur[x - channels] : 0
|
|
65
|
+
const b = prev[x]
|
|
66
|
+
const c = x >= channels ? prev[x - channels] : 0
|
|
67
|
+
let v = line[x]
|
|
68
|
+
if (filter === 1) v += a
|
|
69
|
+
else if (filter === 2) v += b
|
|
70
|
+
else if (filter === 3) v += (a + b) >> 1
|
|
71
|
+
else if (filter === 4) {
|
|
72
|
+
const pa = Math.abs(b - c), pb = Math.abs(a - c), pc = Math.abs(a + b - 2 * c)
|
|
73
|
+
v += (pa <= pb && pa <= pc) ? a : (pb <= pc ? b : c)
|
|
74
|
+
} else if (filter !== 0) throw new Error(`unknown filter ${filter}`)
|
|
75
|
+
cur[x] = v & 0xff
|
|
76
|
+
}
|
|
77
|
+
cur.copy(prev)
|
|
78
|
+
for (let x = 0; x < width; x++) {
|
|
79
|
+
const s = x * channels, d = (y * width + x) * 4
|
|
80
|
+
if (channels === 4) { out[d] = cur[s]; out[d + 1] = cur[s + 1]; out[d + 2] = cur[s + 2]; out[d + 3] = cur[s + 3] }
|
|
81
|
+
else if (channels === 3) { out[d] = cur[s]; out[d + 1] = cur[s + 1]; out[d + 2] = cur[s + 2]; out[d + 3] = 255 }
|
|
82
|
+
else { out[d] = out[d + 1] = out[d + 2] = cur[s]; out[d + 3] = 255 }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { width, height, rgba: out }
|
|
86
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-mobilecode — Compose preview gallery (Feature B/T5, primary path per
|
|
3
|
+
* docs/design/render-adapter-decision.md: official com.android.compose.screenshot
|
|
4
|
+
* 0.0.1-alpha16, host-rendered via layoutlib).
|
|
5
|
+
*
|
|
6
|
+
* Discovery reads the app's screenshotTest source; rendering runs the Gradle
|
|
7
|
+
* task with ARGUMENT ARRAYS through the existing safe launcher; every result
|
|
8
|
+
* carries provenance (renderer, command, exit, timestamps, source-vs-output
|
|
9
|
+
* mtimes). A stale/failed render is never labeled current.
|
|
10
|
+
*
|
|
11
|
+
* Host-rendered previews do NOT prove real-device behavior.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, mkdirSync } from "node:fs"
|
|
15
|
+
import { spawn } from "node:child_process"
|
|
16
|
+
import crypto from "node:crypto"
|
|
17
|
+
import os from "node:os"
|
|
18
|
+
import path from "node:path"
|
|
19
|
+
import * as DeviceBuild from "./device-build.js"
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Kill the whole process tree (audit phase-3 low (b)): child.kill() on the
|
|
23
|
+
* cmd.exe shim would orphan the Gradle JVM; taskkill /T takes the tree.
|
|
24
|
+
*/
|
|
25
|
+
function killTree(child) {
|
|
26
|
+
if (child?.pid === undefined) return
|
|
27
|
+
if (process.platform === "win32") {
|
|
28
|
+
try { spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" }) } catch { /* already gone */ }
|
|
29
|
+
} else {
|
|
30
|
+
try { child.kill("SIGKILL") } catch { /* already gone */ }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const RENDER_TIMEOUT_MS = 10 * 60_000
|
|
35
|
+
export const MAX_RENDER_TIMEOUT_MS = 30 * 60_000
|
|
36
|
+
const MAX_TAIL_LINES = 200
|
|
37
|
+
|
|
38
|
+
/** Adapter availability for a project (drives the capability report). */
|
|
39
|
+
export function previewSupportStatus(appDir) {
|
|
40
|
+
const wrapper = !!DeviceBuild.gradleWrapper(appDir)
|
|
41
|
+
const screenshotTest = wrapper && moduleNames(appDir).some((m) => existsSync(path.join(appDir, m, "src", "screenshotTest")))
|
|
42
|
+
return { wrapper, screenshotTest }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Recursively list .kt files under dir. */
|
|
46
|
+
function ktFiles(dir) {
|
|
47
|
+
if (!existsSync(dir)) return []
|
|
48
|
+
const out = []
|
|
49
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
50
|
+
const p = path.join(dir, entry.name)
|
|
51
|
+
if (entry.isDirectory()) out.push(...ktFiles(p))
|
|
52
|
+
else if (entry.isFile() && entry.name.endsWith(".kt")) out.push(p)
|
|
53
|
+
}
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Discover @PreviewTest entries in one app dir. Returns [] when the module has
|
|
59
|
+
* no screenshotTest source (adapter not set up — caller reports that honestly).
|
|
60
|
+
* Entry: {module, package, className, fqClass, method, previewName, widthDp, sourceFile, sourceMtime}
|
|
61
|
+
*
|
|
62
|
+
* `warnings` (optional array): parse limitations are SURFACED, not hidden —
|
|
63
|
+
* line-based parsing covers the common one-line-annotation form; any
|
|
64
|
+
* @PreviewTest marker that produced no entry (multi-line annotations,
|
|
65
|
+
* `private fun`, annotation on the same line as `fun`, …) adds a warning
|
|
66
|
+
* (audit phase-3 low: single-line assumption must be visible to users/agents).
|
|
67
|
+
*/
|
|
68
|
+
export function discoverPreviewTests(appDir, warnings = []) {
|
|
69
|
+
const entries = []
|
|
70
|
+
for (const moduleName of moduleNames(appDir)) {
|
|
71
|
+
const srcDir = path.join(appDir, moduleName, "src", "screenshotTest")
|
|
72
|
+
for (const file of ktFiles(srcDir)) {
|
|
73
|
+
let text
|
|
74
|
+
try { text = readFileSync(file, "utf8") } catch { continue }
|
|
75
|
+
const pkg = /package\s+([\w.]+)/.exec(text)?.[1] ?? ""
|
|
76
|
+
const lines = text.split(/\r?\n/)
|
|
77
|
+
let currentClass = ""
|
|
78
|
+
let pending = []
|
|
79
|
+
let markerLines = 0
|
|
80
|
+
for (const line of lines) {
|
|
81
|
+
const t = line.trim()
|
|
82
|
+
if (t === "" || t.startsWith("//") || t.startsWith("/*") || t.startsWith("*")) continue
|
|
83
|
+
if (t.startsWith("@")) { pending.push(t); if (t.startsWith("@PreviewTest")) markerLines += 1; continue }
|
|
84
|
+
const cm = /^class\s+(\w+)/.exec(t)
|
|
85
|
+
if (cm) { currentClass = cm[1]; pending = []; continue }
|
|
86
|
+
const fm = /^(?:public\s+|internal\s+)?fun\s+(\w+)\s*[<(]/.exec(t)
|
|
87
|
+
if (fm) {
|
|
88
|
+
if (pending.some((p) => p.startsWith("@PreviewTest"))) {
|
|
89
|
+
// single-line @Preview(...) only; "@Preview(" guard matters —
|
|
90
|
+
// plain startsWith("@Preview") would match @PreviewTest first
|
|
91
|
+
const previewAnn = pending.find((p) => p.startsWith("@Preview("))
|
|
92
|
+
entries.push({
|
|
93
|
+
module: moduleName,
|
|
94
|
+
package: pkg,
|
|
95
|
+
className: currentClass,
|
|
96
|
+
fqClass: pkg ? `${pkg}.${currentClass}` : currentClass,
|
|
97
|
+
method: fm[1],
|
|
98
|
+
previewName: previewAnn ? /name\s*=\s*"([^"]*)"/.exec(previewAnn)?.[1] ?? fm[1] : fm[1],
|
|
99
|
+
widthDp: previewAnn ? Number(/widthDp\s*=\s*([\d.]+)/.exec(previewAnn)?.[1]) || undefined : undefined,
|
|
100
|
+
sourceFile: file,
|
|
101
|
+
sourceMtime: safeMtime(file),
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
pending = []
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
pending = []
|
|
108
|
+
}
|
|
109
|
+
const found = entries.filter((e) => e.sourceFile === file).length
|
|
110
|
+
if (markerLines > found) {
|
|
111
|
+
warnings.push(`${path.relative(appDir, file).replaceAll("\\", "/")}: ${markerLines} @PreviewTest marker(s) but ${found} parsed — unsupported shapes (multi-line @Preview(, annotations on the fun line, private fun) are NOT discovered; keep the common layout: each annotation alone on its line, @Preview(...) on one line`)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return entries
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function safeMtime(p) { try { return statSync(p).mtimeMs } catch { return 0 } }
|
|
119
|
+
|
|
120
|
+
/** Modules declared in settings.gradle(.kts) (fallback: app). */
|
|
121
|
+
function moduleNames(appDir) {
|
|
122
|
+
for (const name of ["settings.gradle", "settings.gradle.kts"]) {
|
|
123
|
+
const file = path.join(appDir, name)
|
|
124
|
+
if (!existsSync(file)) continue
|
|
125
|
+
const includes = [...readFileSync(file, "utf8").matchAll(/include\s*\(?\s*["']:?([\w:.\-]+)["']/g)].map((m) => m[1].replaceAll(/[.:]/g, path.sep))
|
|
126
|
+
if (includes.length > 0) return [...new Set(includes)]
|
|
127
|
+
}
|
|
128
|
+
return ["app"]
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Locate the recorded reference PNG for an entry (engine names <method>_<PreviewName>_<hash>_0.png). */
|
|
132
|
+
export function referencePng(appDir, entry) {
|
|
133
|
+
const pkgPath = entry.package.replaceAll(".", path.sep)
|
|
134
|
+
const dir = path.join(appDir, entry.module, "src", `screenshotTestDebug${path.sep}reference${path.sep}${pkgPath}${path.sep}${entry.className}`)
|
|
135
|
+
if (!existsSync(dir)) return undefined
|
|
136
|
+
try {
|
|
137
|
+
const hit = readdirSync(dir).find((f) => f.startsWith(`${entry.method}_`) && f.endsWith(".png"))
|
|
138
|
+
if (!hit) return undefined
|
|
139
|
+
const p = path.join(dir, hit)
|
|
140
|
+
return { pngPath: p, mtime: safeMtime(p) }
|
|
141
|
+
} catch { return undefined }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Honest render state: PNG existence + manifest-based freshness (never timestamp-only). */
|
|
145
|
+
export function renderState(appDir, entry, { fingerprint = null, manifestDir = defaultManifestDir() } = {}) {
|
|
146
|
+
const found = referencePng(appDir, entry)
|
|
147
|
+
if (!found) return { rendered: false, stale: true, freshness: "never_rendered" }
|
|
148
|
+
const m = readManifest(manifestDir, appDir, entry)
|
|
149
|
+
const f = readFailure(manifestDir, appDir, entry)
|
|
150
|
+
let freshness
|
|
151
|
+
if (m === undefined) freshness = "unknown" // legacy/external render — no manifest, no claim
|
|
152
|
+
else if (f !== undefined && f.at > (m.at ?? 0)) freshness = "last_run_failed"
|
|
153
|
+
else if (fingerprint === null) freshness = "unknown"
|
|
154
|
+
else freshness = fingerprint === m.inputsFingerprint ? "fresh_tracked_inputs" : "stale_inputs_changed"
|
|
155
|
+
return {
|
|
156
|
+
rendered: true, pngPath: found.pngPath, renderedAt: found.mtime,
|
|
157
|
+
stale: freshness !== "fresh_tracked_inputs",
|
|
158
|
+
freshness,
|
|
159
|
+
manifest: m,
|
|
160
|
+
limitations: freshness === "fresh_tracked_inputs"
|
|
161
|
+
? ["fresh only for TRACKED inputs (module sources, build files, version catalog); external deps, JDK/layoutlib and fonts are NOT covered"]
|
|
162
|
+
: [],
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── render manifest (audit §4: timestamp-only freshness is not a currency proof) ──
|
|
167
|
+
|
|
168
|
+
export function defaultManifestDir() {
|
|
169
|
+
return path.join(os.homedir(), ".dsh", "mobilecode", "preview-manifests")
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function keyFor(appDir, entry) {
|
|
173
|
+
return crypto.createHash("sha256").update(String(appDir)).digest("hex").slice(0, 16) + "--" + `${entry.fqClass}.${entry.method}`.replaceAll(/[^A-Za-z0-9_.]/g, "_")
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Fingerprint of the render's relevant inputs: every file under the scanned
|
|
178
|
+
* module(s)' src trees (path+size+mtime), the module build files, settings
|
|
179
|
+
* files, gradle.properties and the version catalog. Shared-token / resource /
|
|
180
|
+
* build-config edits change it even when the preview's own file does not.
|
|
181
|
+
*/
|
|
182
|
+
export function inputsFingerprint(appDir) {
|
|
183
|
+
const hash = crypto.createHash("sha256")
|
|
184
|
+
const add = (file) => {
|
|
185
|
+
try { const st = statSync(file); hash.update(`${path.relative(appDir, file)}|${st.size}|${Math.round(st.mtimeMs)}\n`) } catch { /* missing file contributes nothing */ }
|
|
186
|
+
}
|
|
187
|
+
for (const moduleName of moduleNames(appDir)) {
|
|
188
|
+
const srcRoot = path.join(appDir, moduleName, "src")
|
|
189
|
+
if (existsSync(srcRoot)) for (const f of walkFiles(srcRoot)) add(f)
|
|
190
|
+
add(path.join(appDir, moduleName, "build.gradle.kts"))
|
|
191
|
+
add(path.join(appDir, moduleName, "build.gradle"))
|
|
192
|
+
}
|
|
193
|
+
for (const f of ["settings.gradle.kts", "settings.gradle", "gradle.properties", path.join("gradle", "libs.versions.toml")]) add(path.join(appDir, f))
|
|
194
|
+
return hash.digest("hex")
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function walkFiles(dir) {
|
|
198
|
+
const out = []
|
|
199
|
+
let entries
|
|
200
|
+
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return out }
|
|
201
|
+
for (const e of entries) {
|
|
202
|
+
const p = path.join(dir, e.name)
|
|
203
|
+
if (e.isDirectory()) out.push(...walkFiles(p))
|
|
204
|
+
else if (e.isFile()) out.push(p)
|
|
205
|
+
}
|
|
206
|
+
return out.sort()
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function manifestFile(dir, appDir, entry) { return path.join(dir, keyFor(appDir, entry) + ".json") }
|
|
210
|
+
function failureFile(dir, appDir, entry) { return path.join(dir, keyFor(appDir, entry) + ".failure.json") }
|
|
211
|
+
/** Exported so tools/tests can read or seed manifest state without reimplementing the key. */
|
|
212
|
+
export function manifestPathFor(appDir, entry, dir = defaultManifestDir()) { return manifestFile(dir, appDir, entry) }
|
|
213
|
+
export function failurePathFor(appDir, entry, dir = defaultManifestDir()) { return failureFile(dir, appDir, entry) }
|
|
214
|
+
|
|
215
|
+
function readManifest(dir, appDir, entry) {
|
|
216
|
+
try { return JSON.parse(readFileSync(manifestFile(dir, appDir, entry), "utf8")) } catch { return undefined }
|
|
217
|
+
}
|
|
218
|
+
function readFailure(dir, appDir, entry) {
|
|
219
|
+
try { return JSON.parse(readFileSync(failureFile(dir, appDir, entry), "utf8")) } catch { return undefined }
|
|
220
|
+
}
|
|
221
|
+
function writeJsonFile(dir, appDir, entry, fileFn, payload) {
|
|
222
|
+
try { mkdirSync(dir, { recursive: true }); writeFileSync(fileFn(dir, appDir, entry), JSON.stringify(payload, null, 2) + "\n", "utf8") } catch { /* marker best-effort; missing manifest → unknown freshness */ }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Argument array for one-method render (no shell string anywhere). */
|
|
226
|
+
export function buildRenderArgs(entry) {
|
|
227
|
+
return [`:${entry.module}:updateDebugScreenshotTest`, "--tests", `${entry.fqClass}.${entry.method}`, "--console=plain"]
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function classifyFailure(lines, exitCode) {
|
|
231
|
+
const text = lines.join("\n")
|
|
232
|
+
if (exitCode === 124) return "timeout"
|
|
233
|
+
if (/No tests found for given includes|did not match any tests/i.test(text)) return "discovery"
|
|
234
|
+
if (/Compilation error|e: file:\/\//i.test(text)) return "compilation"
|
|
235
|
+
if (/There were failing tests|FAILED/i.test(text)) return "render"
|
|
236
|
+
return exitCode === 0 ? "none" : "unknown"
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// one live gradle render per directory (a second concurrent invocation would
|
|
240
|
+
// race the same task); ponytail: in-memory only — single-process server by design
|
|
241
|
+
const liveRenders = new Map()
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Render one discovered preview. Resolves an envelope:
|
|
245
|
+
* {status: passed|failed|blocked, failureKind, tail, provenance}
|
|
246
|
+
* Never throws for operational failures; missing prerequisites → "blocked".
|
|
247
|
+
* A successful run records a manifest (inputs fingerprint + renderer + runId);
|
|
248
|
+
* a failed/cancelled run records a failure marker that IMMEDIATELY invalidates
|
|
249
|
+
* the previous success state (audit §4) while the older PNG stays visible,
|
|
250
|
+
* labeled.
|
|
251
|
+
*/
|
|
252
|
+
export async function renderPreviewTest(appDir, entry, { timeoutMs = RENDER_TIMEOUT_MS, onLine, manifestDir = defaultManifestDir() } = {}) {
|
|
253
|
+
const wrapper = DeviceBuild.gradleWrapper(appDir)
|
|
254
|
+
if (!wrapper) return { status: "blocked", reason: "no gradlew wrapper in directory — not a Gradle project", provenance: { directory: appDir } }
|
|
255
|
+
if (!existsSync(path.join(appDir, entry.module, "src", "screenshotTest"))) {
|
|
256
|
+
return { status: "blocked", reason: `module ${entry.module} has no screenshotTest source — apply the adapter first (docs/design/render-adapter-decision.md)`, provenance: { directory: appDir } }
|
|
257
|
+
}
|
|
258
|
+
const capped = Math.min(Math.max(timeoutMs || RENDER_TIMEOUT_MS, 10_000), MAX_RENDER_TIMEOUT_MS)
|
|
259
|
+
const args = buildRenderArgs(entry)
|
|
260
|
+
const startedAt = new Date().toISOString()
|
|
261
|
+
const tail = []
|
|
262
|
+
const push = (line) => { tail.push(line.slice(0, 300)); if (tail.length > MAX_TAIL_LINES) tail.shift(); onLine?.(line) }
|
|
263
|
+
if (liveRenders.has(appDir)) {
|
|
264
|
+
return { status: "blocked", reason: "another preview render is already running for this directory", provenance: { directory: appDir } }
|
|
265
|
+
}
|
|
266
|
+
liveRenders.set(appDir, args.join("\0"))
|
|
267
|
+
try {
|
|
268
|
+
const { child, exit } = DeviceBuild.exec(wrapper, args, { cwd: appDir }, push)
|
|
269
|
+
const exitCode = await new Promise((resolve) => {
|
|
270
|
+
const timer = setTimeout(() => { killTree(child); resolve(124) }, capped)
|
|
271
|
+
exit.then((code) => { clearTimeout(timer); resolve(code) })
|
|
272
|
+
})
|
|
273
|
+
const finishedAt = new Date().toISOString()
|
|
274
|
+
const state = renderState(appDir, entry, { manifestDir })
|
|
275
|
+
const failureKind = classifyFailure(tail, exitCode)
|
|
276
|
+
const passed = exitCode === 0 && state.rendered && !state.stale
|
|
277
|
+
const runId = crypto.randomBytes(8).toString("hex")
|
|
278
|
+
if (passed) {
|
|
279
|
+
writeJsonFile(manifestDir, appDir, entry, manifestFile, {
|
|
280
|
+
runId, at: finishedAt, exitCode,
|
|
281
|
+
inputsFingerprint: inputsFingerprint(appDir),
|
|
282
|
+
renderer: "com.android.compose.screenshot 0.0.1-alpha16 (layoutlib, host-rendered)",
|
|
283
|
+
command: args.join(" "),
|
|
284
|
+
})
|
|
285
|
+
} else {
|
|
286
|
+
// invalidate immediately — even a cancelled/timed-out run marks state untrusted
|
|
287
|
+
writeJsonFile(manifestDir, appDir, entry, failureFile, { at: finishedAt, exitCode, failureKind, runId })
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
status: passed ? "passed" : "failed",
|
|
291
|
+
failureKind: exitCode === 0 ? (state.rendered ? (state.stale ? "stale-after-render" : "none") : "discovery") : failureKind,
|
|
292
|
+
exitCode,
|
|
293
|
+
tail: tail.slice(-40),
|
|
294
|
+
provenance: {
|
|
295
|
+
renderer: "compose-preview-screenshot-testing (layoutlib, host-rendered) — NOT device-verified",
|
|
296
|
+
command: `${path.basename(wrapper)} ${args.join(" ")}`,
|
|
297
|
+
argumentArray: args,
|
|
298
|
+
directory: appDir,
|
|
299
|
+
startedAt, finishedAt, runId: passed ? runId : undefined,
|
|
300
|
+
pngPath: state.pngPath,
|
|
301
|
+
renderedAt: state.renderedAt ? new Date(state.renderedAt).toISOString() : undefined,
|
|
302
|
+
freshness: state.freshness,
|
|
303
|
+
stale: state.stale,
|
|
304
|
+
},
|
|
305
|
+
}
|
|
306
|
+
} finally {
|
|
307
|
+
liveRenders.delete(appDir)
|
|
308
|
+
}
|
|
309
|
+
}
|