dsh-mobilecode 0.11.9 → 0.12.0

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.
@@ -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
+ }