dsh-mobilecode 0.11.8 → 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.
- package/README.md +52 -0
- package/lib/client.js +251 -1
- 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/skill.js +1 -1
- package/lib/visual-compare.js +196 -0
- package/package.json +1 -1
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-mobilecode — reference-comparison workspace (Feature A vertical slice).
|
|
3
|
+
*
|
|
4
|
+
* A project-scoped store of imported design references and device captures,
|
|
5
|
+
* persisted under ~/.dsh/mobilecode/reference/ (Setup.HOME), surviving GUI
|
|
6
|
+
* reloads. The store never trusts caller-supplied paths: images are written
|
|
7
|
+
* under generated ids, records carry metadata only, and association happens
|
|
8
|
+
* through the project/screen/stateId keys.
|
|
9
|
+
*
|
|
10
|
+
* Originals are preserved verbatim; alignment/crop transforms are recorded as
|
|
11
|
+
* metadata on the record (applied at compare time), never baked into the image.
|
|
12
|
+
*
|
|
13
|
+
* Image imports are guarded before any decode: header-only dimension parsing
|
|
14
|
+
* (PNG IHDR, JPEG SOF, WebP chunks) enforces a decompression limit — no pixel
|
|
15
|
+
* buffer is ever materialized by this module.
|
|
16
|
+
*
|
|
17
|
+
* Compare rendering (side-by-side / opacity overlay / swipe slider) is a
|
|
18
|
+
* client concern; this module serves the pair + metadata.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, rmSync, renameSync } from "node:fs"
|
|
22
|
+
import path from "node:path"
|
|
23
|
+
import crypto from "node:crypto"
|
|
24
|
+
|
|
25
|
+
export const MAX_IMPORT_BYTES = 25 * 1024 * 1024 // 25 MB raw body cap (base64 inside JSON)
|
|
26
|
+
export const MAX_IMAGE_DIMENSION = 8192 // header-only decompression fence
|
|
27
|
+
const ALLOWED_EXT = new Set([".png", ".jpg", ".jpeg", ".webp"])
|
|
28
|
+
const META = "meta.json"
|
|
29
|
+
const IMAGES = "images"
|
|
30
|
+
|
|
31
|
+
/** Header-only dimension read. Returns {width,height} or null when undetectable. */
|
|
32
|
+
export function imageDimensions(buf) {
|
|
33
|
+
if (!Buffer.isBuffer(buf) || buf.length < 12) return null
|
|
34
|
+
// PNG: 8-byte signature, IHDR length+type, then width/height big-endian.
|
|
35
|
+
if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
|
|
36
|
+
if (buf.length < 24 || buf.toString("latin1", 12, 16) !== "IHDR") return null
|
|
37
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) }
|
|
38
|
+
}
|
|
39
|
+
// JPEG: scan markers for SOF0..SOF15 (skip SOF1-style 0xC0..0xCF except DHT C4).
|
|
40
|
+
if (buf[0] === 0xff && buf[1] === 0xd8) {
|
|
41
|
+
for (let i = 2; i + 9 < buf.length;) {
|
|
42
|
+
if (buf[i] !== 0xff) { i += 1; continue }
|
|
43
|
+
const marker = buf[i + 1]
|
|
44
|
+
if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) { i += 2; continue }
|
|
45
|
+
const len = buf.readUInt16BE(i + 2)
|
|
46
|
+
if (len < 2 || i + 2 + len > buf.length) break
|
|
47
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
48
|
+
return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) }
|
|
49
|
+
}
|
|
50
|
+
i += 2 + len
|
|
51
|
+
}
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
// WebP: RIFF....WEBP + chunk header.
|
|
55
|
+
if (buf.toString("latin1", 0, 4) === "RIFF" && buf.toString("latin1", 8, 12) === "WEBP") {
|
|
56
|
+
const four = buf.toString("latin1", 12, 16)
|
|
57
|
+
if (four === "VP8X" && buf.length >= 30) {
|
|
58
|
+
const w = 1 + buf.readUIntLE(24, 3)
|
|
59
|
+
const h = 1 + buf.readUIntLE(27, 3)
|
|
60
|
+
return { width: w, height: h }
|
|
61
|
+
}
|
|
62
|
+
if (four === "VP8L" && buf.length >= 25) {
|
|
63
|
+
const bits = buf.readUInt32LE(21)
|
|
64
|
+
return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 }
|
|
65
|
+
}
|
|
66
|
+
if (four === "VP8 " && buf.length >= 30) {
|
|
67
|
+
return { width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function nowIso() { return new Date().toISOString() }
|
|
74
|
+
|
|
75
|
+
/** Validate + normalize the caller-declared import metadata. Throws on bad shape. */
|
|
76
|
+
export function validateImportMeta({ project, screen, stateId, filename, provenance, intendedUse } = {}) {
|
|
77
|
+
const clean = (value, label, max) => {
|
|
78
|
+
if (typeof value !== "string" || value.trim() === "" || value.length > max) {
|
|
79
|
+
throw new Error(`${label} is required and must be a non-empty string <= ${max} chars`)
|
|
80
|
+
}
|
|
81
|
+
return value.trim()
|
|
82
|
+
}
|
|
83
|
+
const opt = (value, max) => (typeof value === "string" && value.trim() !== "" ? value.trim().slice(0, max) : undefined)
|
|
84
|
+
return {
|
|
85
|
+
project: clean(project, "project", 120),
|
|
86
|
+
screen: clean(screen, "screen", 120),
|
|
87
|
+
stateId: clean(stateId, "stateId", 120),
|
|
88
|
+
filename: opt(filename, 200),
|
|
89
|
+
provenance: opt(provenance, 1000),
|
|
90
|
+
intendedUse: opt(intendedUse, 500),
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export class ReferenceWorkspace {
|
|
95
|
+
constructor(home) {
|
|
96
|
+
this.root = path.join(home, "reference")
|
|
97
|
+
this.imagesDir = path.join(this.root, IMAGES)
|
|
98
|
+
this.metaFile = path.join(this.root, META)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
ensure() {
|
|
102
|
+
if (!existsSync(this.imagesDir)) mkdirSync(this.imagesDir, { recursive: true })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Tolerant read: malformed meta is quarantined (original preserved), never crashes the store. */
|
|
106
|
+
readMeta() {
|
|
107
|
+
this.ensure()
|
|
108
|
+
if (!existsSync(this.metaFile)) return []
|
|
109
|
+
let raw
|
|
110
|
+
try { raw = readFileSync(this.metaFile, "utf8") } catch { return [] }
|
|
111
|
+
let parsed
|
|
112
|
+
try { parsed = JSON.parse(raw) } catch {
|
|
113
|
+
const corrupt = `${this.metaFile}.corrupt-${Date.now()}`
|
|
114
|
+
try { copyFileSync(this.metaFile, corrupt); rmSync(this.metaFile) } catch { /* keep serving empty */ }
|
|
115
|
+
return []
|
|
116
|
+
}
|
|
117
|
+
if (!Array.isArray(parsed)) return []
|
|
118
|
+
// Drop records that no longer satisfy the shape (tolerate unknowns, keep knowns).
|
|
119
|
+
return parsed.filter((r) => r && typeof r === "object" && typeof r.id === "string" && typeof r.project === "string")
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
writeMeta(records) {
|
|
123
|
+
this.ensure()
|
|
124
|
+
// atomic (audit F2): a torn meta.json would quarantine the whole store
|
|
125
|
+
const tmp = this.metaFile + ".tmp"
|
|
126
|
+
writeFileSync(tmp, JSON.stringify(records, null, 2) + "\n", "utf8")
|
|
127
|
+
renameSync(tmp, this.metaFile)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#nextId(records) {
|
|
131
|
+
let id
|
|
132
|
+
do { id = crypto.randomBytes(8).toString("hex") } while (records.some((r) => r.id === id))
|
|
133
|
+
return id
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#resolveFile(record) {
|
|
137
|
+
// Never accept caller input as a path: id + stored extension only.
|
|
138
|
+
const ext = typeof record.ext === "string" && ALLOWED_EXT.has(record.ext.toLowerCase()) ? record.ext.toLowerCase() : ".png"
|
|
139
|
+
return path.join(this.imagesDir, `${record.id}${ext}`)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
list({ project } = {}) {
|
|
143
|
+
const records = this.readMeta()
|
|
144
|
+
const scoped = project === undefined ? records : records.filter((r) => r.project === project)
|
|
145
|
+
return scoped.map((r) => ({ ...r, file: this.#resolveFile(r) }))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
get(id) {
|
|
149
|
+
const record = this.readMeta().find((r) => r.id === id)
|
|
150
|
+
return record === undefined ? undefined : { ...record, file: this.#resolveFile(record) }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
latest(project, screen, stateId, kind) {
|
|
154
|
+
const records = this.readMeta()
|
|
155
|
+
const matches = records.filter((r) =>
|
|
156
|
+
r.project === project && r.screen === screen && r.stateId === stateId && r.kind === kind)
|
|
157
|
+
if (matches.length === 0) return undefined
|
|
158
|
+
matches.sort((a, b) => String(b.importedAt ?? "").localeCompare(String(a.importedAt ?? "")))
|
|
159
|
+
const record = matches[0]
|
|
160
|
+
return { ...record, file: this.#resolveFile(record) }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
active(project) {
|
|
164
|
+
const record = this.readMeta().find((r) => r.project === project && r.active === true)
|
|
165
|
+
return record === undefined ? undefined : { ...record, file: this.#resolveFile(record) }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
setActive(id) {
|
|
169
|
+
const records = this.readMeta()
|
|
170
|
+
const target = records.find((r) => r.id === id)
|
|
171
|
+
if (target === undefined) throw new Error(`unknown reference id: ${id}`)
|
|
172
|
+
for (const record of records) record.active = record.id === id
|
|
173
|
+
this.writeMeta(records)
|
|
174
|
+
return { ...target, active: true, file: this.#resolveFile(target) }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Baseline approval — bound to the stored BYTES and recorded capture config at
|
|
178
|
+
* approval time. A later byte change or config change silently invalidates it
|
|
179
|
+
* (the diff route re-verifies the binding); candidates never need approval. */
|
|
180
|
+
setApproved(id, approved = true) {
|
|
181
|
+
const records = this.readMeta()
|
|
182
|
+
const target = records.find((r) => r.id === id)
|
|
183
|
+
if (target === undefined) throw new Error(`unknown reference id: ${id}`)
|
|
184
|
+
if (approved) {
|
|
185
|
+
target.approved = true
|
|
186
|
+
target.approval = {
|
|
187
|
+
sha256: crypto.createHash("sha256").update(this.readImage(target)).digest("hex"),
|
|
188
|
+
config: target.device ?? null,
|
|
189
|
+
approvedAt: nowIso(),
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
target.approved = false
|
|
193
|
+
delete target.approval
|
|
194
|
+
}
|
|
195
|
+
this.writeMeta(records)
|
|
196
|
+
return { ...target, file: this.#resolveFile(target) }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Import a design reference from a base64 data URL (data:image/png;base64,..)
|
|
201
|
+
* or a raw base64 string. Decode-free validation: extension whitelist, byte
|
|
202
|
+
* cap, header-only dimension fence. Original bytes are stored verbatim.
|
|
203
|
+
*/
|
|
204
|
+
import(meta, data) {
|
|
205
|
+
const validated = validateImportMeta(meta)
|
|
206
|
+
const match = /^data:image\/(png|jpeg|jpg|webp);base64,([A-Za-z0-9+/=]+)$/.exec(String(data ?? ""))
|
|
207
|
+
const b64 = match ? match[2] : (() => {
|
|
208
|
+
const cleaned = String(data ?? "").replace(/^data:image\/[a-z]+;base64,/, "")
|
|
209
|
+
return /^[A-Za-z0-9+/=]+$/.test(cleaned) ? cleaned : ""
|
|
210
|
+
})()
|
|
211
|
+
if (b64 === "") throw new Error("image data must be a base64 data URL (data:image/png;base64,...)")
|
|
212
|
+
const buf = Buffer.from(b64, "base64")
|
|
213
|
+
if (buf.length === 0) throw new Error("image data is empty")
|
|
214
|
+
if (buf.length > MAX_IMPORT_BYTES) throw new Error(`image exceeds ${MAX_IMPORT_BYTES} bytes`)
|
|
215
|
+
const ext = match ? `.${match[1]}` : path.extname(validated.filename ?? "ref.png").toLowerCase()
|
|
216
|
+
// Note: path.extname(".png") returns "" (dotfile semantics), so the matched
|
|
217
|
+
// data-URL extension bypasses extname entirely.
|
|
218
|
+
if (!ALLOWED_EXT.has(ext)) throw new Error(`unsupported image type "${ext}" (png/jpg/webp only)`)
|
|
219
|
+
const dims = imageDimensions(buf)
|
|
220
|
+
if (dims === null) throw new Error("unable to read image dimensions from header")
|
|
221
|
+
if (dims.width > MAX_IMAGE_DIMENSION || dims.height > MAX_IMAGE_DIMENSION) {
|
|
222
|
+
throw new Error(`image is ${dims.width}x${dims.height} — exceeds ${MAX_IMAGE_DIMENSION}px fence`)
|
|
223
|
+
}
|
|
224
|
+
const records = this.readMeta()
|
|
225
|
+
const id = this.#nextId(records)
|
|
226
|
+
const record = {
|
|
227
|
+
id, kind: "reference", project: validated.project, screen: validated.screen, stateId: validated.stateId,
|
|
228
|
+
ext, width: dims.width, height: dims.height,
|
|
229
|
+
source: validated.filename, provenance: validated.provenance, intendedUse: validated.intendedUse,
|
|
230
|
+
importedAt: nowIso(), approved: false, active: false, transform: null,
|
|
231
|
+
}
|
|
232
|
+
this.writeFile(id, ext, buf)
|
|
233
|
+
records.push(record)
|
|
234
|
+
this.writeMeta(records)
|
|
235
|
+
return { ...record, file: this.#resolveFile(record) }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Associate an existing device capture (PNG path from DeviceBuild.screenCapture)
|
|
240
|
+
* with a project/screen/state. The original capture file is copied into the
|
|
241
|
+
* store verbatim; device configuration is recorded as metadata, never edited.
|
|
242
|
+
*/
|
|
243
|
+
capture({ project, screen, stateId, pngPath, device }) {
|
|
244
|
+
const validated = validateImportMeta({ project, screen, stateId })
|
|
245
|
+
if (typeof pngPath !== "string" || !existsSync(pngPath)) throw new Error(`capture file not found: ${pngPath}`)
|
|
246
|
+
const buf = readFileSync(pngPath)
|
|
247
|
+
if (buf.length === 0) throw new Error("capture file is empty")
|
|
248
|
+
const dims = imageDimensions(buf)
|
|
249
|
+
if (dims === null) throw new Error("capture is not a readable PNG/JPEG/WebP")
|
|
250
|
+
const records = this.readMeta()
|
|
251
|
+
const id = this.#nextId(records)
|
|
252
|
+
const record = {
|
|
253
|
+
id, kind: "capture", project: validated.project, screen: validated.screen, stateId: validated.stateId,
|
|
254
|
+
ext: ".png", width: dims.width, height: dims.height,
|
|
255
|
+
source: path.basename(pngPath), provenance: "device-capture", intendedUse: "baseline-evidence",
|
|
256
|
+
importedAt: nowIso(), approved: false, active: false, transform: null,
|
|
257
|
+
device: device ?? {},
|
|
258
|
+
}
|
|
259
|
+
copyFileSync(pngPath, this.#resolveFile(record))
|
|
260
|
+
records.push(record)
|
|
261
|
+
this.writeMeta(records)
|
|
262
|
+
return { ...record, file: this.#resolveFile(record) }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
writeFile(id, ext, buf) {
|
|
266
|
+
this.ensure()
|
|
267
|
+
writeFileSync(path.join(this.imagesDir, `${id}${ext}`), buf)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
readImage(record) {
|
|
271
|
+
return readFileSync(this.#resolveFile(record))
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
remove(id) {
|
|
275
|
+
const records = this.readMeta()
|
|
276
|
+
const target = records.find((r) => r.id === id)
|
|
277
|
+
if (target === undefined) return false
|
|
278
|
+
const file = this.#resolveFile(target)
|
|
279
|
+
try { rmSync(file, { force: true }) } catch { /* best-effort file removal */ }
|
|
280
|
+
this.writeMeta(records.filter((r) => r.id !== id))
|
|
281
|
+
return true
|
|
282
|
+
}
|
|
283
|
+
}
|
package/lib/skill.js
CHANGED
|
@@ -47,7 +47,7 @@ The loop is **observe once → act with an assertion → observe again only if t
|
|
|
47
47
|
- Prefer \`device_tap_element\` (resource-id / text / content-desc). Raw pixel coordinates through \`device_input action=tap\` are the last resort — they break on the next layout change.
|
|
48
48
|
- When you must tap pixels, take the center of a box from \`device_ui_tree\`/\`device_screen\`: x=(x1+x2)/2, y=(y1+y2)/2. Coordinates are ABSOLUTE pixels of the current display.
|
|
49
49
|
- \`device_input action=swipe\` scrolls; \`action=key\` with "back" is a first-class verb on Android — use it instead of hunting for an on-screen back arrow.
|
|
50
|
-
- Typing: ASCII goes through
|
|
50
|
+
- Typing: ASCII goes through \`input text\` directly. Non-ASCII (Chinese, emoji) needs the ADBKeyboard IME — \`device_input\` uses it automatically when installed, and refuses with the install hint otherwise (github.com/senzhk/ADBKeyBoard); do not retry a refused non-ASCII type.
|
|
51
51
|
|
|
52
52
|
## Never guess a package name or a control
|
|
53
53
|
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-mobilecode — visual comparison core (pure, dependency-free).
|
|
3
|
+
*
|
|
4
|
+
* Operates on decoded RGBA frames ({width, height, rgba:Uint8ClampedArray|Buffer}).
|
|
5
|
+
* Decoding is the CALLER's boundary: the browser canvas for the GUI, test
|
|
6
|
+
* fixtures for headless checks. The plugin server never pixel-decodes, so no
|
|
7
|
+
* image library is added to the base plugin.
|
|
8
|
+
*
|
|
9
|
+
* Two modes, kept SEPARATE by design (they answer different questions and
|
|
10
|
+
* must never share a single pass/fail threshold):
|
|
11
|
+
* - regression: same environment, approved native baseline vs current.
|
|
12
|
+
* Absolute pixel diff is meaningful here.
|
|
13
|
+
* - design-reference: implementation vs design target image. Pixel equality
|
|
14
|
+
* is NOT expected (different renderer, font stack,
|
|
15
|
+
* typography metrics) — this reports structure and
|
|
16
|
+
* regions, it does not grade a score.
|
|
17
|
+
*
|
|
18
|
+
* Findings are concrete (regions that changed, where, by how much); no
|
|
19
|
+
* "UI quality 98/100" anywhere. OCR corroboration is passed IN as boxes by
|
|
20
|
+
* the caller — disagreement between observers is reported, never averaged.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** RGBA L1 distance > per-channel threshold counts as a changed pixel. */
|
|
24
|
+
export function diffFrames(a, b, { threshold = 24, masks = [], skip = [] } = {}) {
|
|
25
|
+
if (a.width !== b.width || a.height !== b.height) {
|
|
26
|
+
// Frames of different sizes cannot be diffed as-is: the caller must align
|
|
27
|
+
// (record explicit transform) or this is honestly blocked, not approximated.
|
|
28
|
+
return { status: "blocked", reason: `frame size mismatch ${a.width}x${a.height} vs ${b.width}x${b.height} — align first`, changedPixels: 0, totalPixels: 0 }
|
|
29
|
+
}
|
|
30
|
+
const w = a.width, h = a.height
|
|
31
|
+
const skipAll = [...masks, ...skip]
|
|
32
|
+
let changed = 0
|
|
33
|
+
const marks = new Uint8Array(w * h)
|
|
34
|
+
for (let i = 0; i < w * h; i++) {
|
|
35
|
+
const x = i % w, y = (i / w) | 0
|
|
36
|
+
if (skipAll.some((r) => x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height)) continue
|
|
37
|
+
const p = i * 4
|
|
38
|
+
const d = Math.abs(a.rgba[p] - b.rgba[p]) + Math.abs(a.rgba[p + 1] - b.rgba[p + 1]) + Math.abs(a.rgba[p + 2] - b.rgba[p + 2]) + Math.abs(a.rgba[p + 3] - b.rgba[p + 3])
|
|
39
|
+
if (d > threshold) { changed += 1; marks[i] = 1 }
|
|
40
|
+
}
|
|
41
|
+
const total = w * h - skipAll.reduce((acc, r) => acc + r.width * r.height, 0)
|
|
42
|
+
const filterStats = { droppedClusters: 0, droppedPixels: 0 }
|
|
43
|
+
const clusters = clusterMarks(marks, w, h, 40, filterStats)
|
|
44
|
+
return {
|
|
45
|
+
status: "ok",
|
|
46
|
+
changedPixels: changed,
|
|
47
|
+
totalPixels: Math.max(total, 1),
|
|
48
|
+
changePct: Number(((changed / Math.max(total, 1)) * 100).toFixed(3)),
|
|
49
|
+
threshold,
|
|
50
|
+
clusters, // [{x,y,width,height,pixels}] — VISUALIZATION only
|
|
51
|
+
// The verdict (changedPixels) is INDEPENDENT of cluster filtering: small
|
|
52
|
+
// clusters dropped below minPixels never hide a change from regression.
|
|
53
|
+
clusterFilter: { minPixels: 40, ...filterStats },
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Connected components (4-neighbour flood fill) over changed-pixel marks. */
|
|
58
|
+
export function clusterMarks(marks, w, h, minPixels = 40, stats = null) {
|
|
59
|
+
const seen = new Uint8Array(w * h)
|
|
60
|
+
const clusters = []
|
|
61
|
+
const stack = new Int32Array(w * h)
|
|
62
|
+
for (let start = 0; start < w * h; start++) {
|
|
63
|
+
if (!marks[start] || seen[start]) continue
|
|
64
|
+
let top = 0
|
|
65
|
+
stack[top++] = start
|
|
66
|
+
seen[start] = 1
|
|
67
|
+
let minX = w, minY = h, maxX = 0, maxY = 0, pixels = 0
|
|
68
|
+
while (top > 0) {
|
|
69
|
+
const i = stack[--top]
|
|
70
|
+
const x = i % w, y = (i / w) | 0
|
|
71
|
+
pixels += 1
|
|
72
|
+
if (x < minX) minX = x; if (x > maxX) maxX = x
|
|
73
|
+
if (y < minY) minY = y; if (y > maxY) maxY = y
|
|
74
|
+
if (x > 0 && marks[i - 1] && !seen[i - 1]) { seen[i - 1] = 1; stack[top++] = i - 1 }
|
|
75
|
+
if (x < w - 1 && marks[i + 1] && !seen[i + 1]) { seen[i + 1] = 1; stack[top++] = i + 1 }
|
|
76
|
+
if (y > 0 && marks[i - w] && !seen[i - w]) { seen[i - w] = 1; stack[top++] = i - w }
|
|
77
|
+
if (y < h - 1 && marks[i + w] && !seen[i + w]) { seen[i + w] = 1; stack[top++] = i + w }
|
|
78
|
+
}
|
|
79
|
+
if (pixels >= minPixels) clusters.push({ x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1, pixels })
|
|
80
|
+
else if (stats) { stats.droppedClusters += 1; stats.droppedPixels += pixels }
|
|
81
|
+
}
|
|
82
|
+
clusters.sort((p, q) => q.pixels - p.pixels)
|
|
83
|
+
return clusters.slice(0, 50)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Explicit alignment transform (recorded, never baked into stored images).
|
|
88
|
+
* Output-space contract: out(x,y) samples in((x-dx)/scaleX,(y-dy)/scaleY).
|
|
89
|
+
* dx/dy are output-space offsets — THE SAME transform the panel must render
|
|
90
|
+
* (audit §3: displayed overlay and computed diff share one contract).
|
|
91
|
+
*/
|
|
92
|
+
export function applyAlign(frame, { scaleX = 1, scaleY = 1, dx = 0, dy = 0 } = {}) {
|
|
93
|
+
if (scaleX === 1 && scaleY === 1 && dx === 0 && dy === 0) return frame
|
|
94
|
+
const w = Math.max(1, Math.round(frame.width * scaleX) + Math.abs(dx))
|
|
95
|
+
const h = Math.max(1, Math.round(frame.height * scaleY) + Math.abs(dy))
|
|
96
|
+
const out = new Uint8ClampedArray(w * h * 4)
|
|
97
|
+
for (let y = 0; y < h; y++) {
|
|
98
|
+
const sy = Math.floor((y - dy) / scaleY)
|
|
99
|
+
if (sy < 0 || sy >= frame.height) continue
|
|
100
|
+
for (let x = 0; x < w; x++) {
|
|
101
|
+
const sx = Math.floor((x - dx) / scaleX)
|
|
102
|
+
if (sx < 0 || sx >= frame.width) continue
|
|
103
|
+
const t = (y * w + x) * 4
|
|
104
|
+
const s = (sy * frame.width + sx) * 4
|
|
105
|
+
out[t] = frame.rgba[s]; out[t + 1] = frame.rgba[s + 1]; out[t + 2] = frame.rgba[s + 2]; out[t + 3] = frame.rgba[s + 3]
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { width: w, height: h, rgba: out, align: { scaleX, scaleY, dx, dy } }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Map a region from ALIGNED comparison space back to the ORIGINAL frame's
|
|
113
|
+
* pixel space, so reported findings are reviewable on stored originals.
|
|
114
|
+
*/
|
|
115
|
+
export function mapRegionToFrame(region, { scaleX = 1, scaleY = 1, dx = 0, dy = 0 } = {}) {
|
|
116
|
+
const r = (v) => Math.max(0, Math.round(v))
|
|
117
|
+
return {
|
|
118
|
+
x: r((region.x - dx) / scaleX), y: r((region.y - dy) / scaleY),
|
|
119
|
+
width: Math.max(1, r(region.width / scaleX)), height: Math.max(1, r(region.height / scaleY)),
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Integer box-average downsample — simulates a canvas reduction; used by tests to PROVE reduced-resolution diffs can lose real changes. */
|
|
124
|
+
export function boxAverage(frame, factor) {
|
|
125
|
+
const f = Math.max(1, Math.round(factor))
|
|
126
|
+
const w = Math.max(1, Math.floor(frame.width / f))
|
|
127
|
+
const h = Math.max(1, Math.floor(frame.height / f))
|
|
128
|
+
const out = new Uint8ClampedArray(w * h * 4)
|
|
129
|
+
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
|
|
130
|
+
let r = 0, g = 0, b = 0, a = 0, n = 0
|
|
131
|
+
for (let yy = y * f; yy < Math.min((y + 1) * f, frame.height); yy++) for (let xx = x * f; xx < Math.min((x + 1) * f, frame.width); xx++, n++) {
|
|
132
|
+
const p = (yy * frame.width + xx) * 4
|
|
133
|
+
r += frame.rgba[p]; g += frame.rgba[p + 1]; b += frame.rgba[p + 2]; a += frame.rgba[p + 3]
|
|
134
|
+
}
|
|
135
|
+
const t = (y * w + x) * 4
|
|
136
|
+
out[t] = r / n; out[t + 1] = g / n; out[t + 2] = b / n; out[t + 3] = a / n
|
|
137
|
+
}
|
|
138
|
+
return { width: w, height: h, rgba: out }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Masks are opt-in exclusions with a REQUIRED documented reason — no silent masking (audit §2). */
|
|
142
|
+
export function validateMasks(masks) {
|
|
143
|
+
return (masks ?? []).map((m, i) => {
|
|
144
|
+
if (!m || typeof m !== "object") throw new Error(`mask ${i}: must be an object`)
|
|
145
|
+
if (typeof m.reason !== "string" || m.reason.trim() === "") throw new Error(`mask ${i}: a documented reason is required (masks hide defects — never adjust them to make a comparison pass)`)
|
|
146
|
+
for (const k of ["x", "y", "width", "height"]) if (typeof m[k] !== "number" || !Number.isFinite(m[k]) || m[k] < 0) throw new Error(`mask ${i}: ${k} must be a non-negative number`)
|
|
147
|
+
return { x: m.x | 0, y: m.y | 0, width: m.width | 0, height: m.height | 0, reason: m.reason.trim().slice(0, 500) }
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Corroborate clusters against OCR text boxes passed in by the caller.
|
|
153
|
+
* Reports DISAGREEMENT explicitly: changed pixels with no text, text with no
|
|
154
|
+
* change — evidence to look at, not averaged truth.
|
|
155
|
+
*/
|
|
156
|
+
export function ocrCorroboration(clusters, ocrBoxes = []) {
|
|
157
|
+
const inCluster = (box) => clusters.some((c) =>
|
|
158
|
+
box.x < c.x + c.width && box.x + box.width > c.x && box.y < c.y + c.height && box.y + box.height > c.y)
|
|
159
|
+
return {
|
|
160
|
+
textInChangedRegions: ocrBoxes.filter(inCluster).map((b) => b.text ?? "(unlabeled)"),
|
|
161
|
+
changedRegionsWithoutText: clusters.filter((c) => !ocrBoxes.some((b) => inCluster([{ ...b }]) &&
|
|
162
|
+
b.x < c.x + c.width && b.x + b.width > c.x && b.y < c.y + c.height && b.y + b.height > c.y)).length,
|
|
163
|
+
note: "disagreements listed; never averaged into a single score",
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Unified result envelope — status is about the OPERATION, never a verdict on UI quality. */
|
|
168
|
+
export function compareEnvelope({ runId, project, screen, stateId, mode, diff, observations = [], evidenceRefs = [], env = {}, comparisonSpace = "native", transforms = null, limitations = [] }) {
|
|
169
|
+
if (mode !== "regression" && mode !== "design-reference") throw new Error('mode must be "regression" | "design-reference"')
|
|
170
|
+
if (comparisonSpace !== "native" && comparisonSpace !== "downsampled") throw new Error('comparisonSpace must be "native" | "downsampled"')
|
|
171
|
+
let checkStatus
|
|
172
|
+
if (diff.status === "blocked") checkStatus = "blocked"
|
|
173
|
+
else if (mode === "regression") {
|
|
174
|
+
// A REDUCED-resolution comparison can never prove native pixel equality
|
|
175
|
+
// (downsampling can erase small changes) — it is triage, needs_review at best.
|
|
176
|
+
if (diff.changedPixels > 0) checkStatus = "failed"
|
|
177
|
+
else checkStatus = comparisonSpace === "native" ? "passed" : "needs_review"
|
|
178
|
+
} else checkStatus = "needs_review"
|
|
179
|
+
return {
|
|
180
|
+
runId, project, screen, stateId, mode,
|
|
181
|
+
operation: diff.status === "blocked" ? "blocked" : "completed",
|
|
182
|
+
checkStatus,
|
|
183
|
+
comparisonSpace,
|
|
184
|
+
transforms: transforms ?? undefined,
|
|
185
|
+
diff,
|
|
186
|
+
observations, // [{source, finding}] — concrete observations only
|
|
187
|
+
evidenceRefs,
|
|
188
|
+
env,
|
|
189
|
+
limitations: [
|
|
190
|
+
...(mode === "design-reference" ? ["design-reference mode compares structure, not pixels; renderer and typography differ by nature"] : []),
|
|
191
|
+
...(comparisonSpace === "downsampled" ? ["reduced-resolution comparison is triage only — it CANNOT establish native pixel equality (small changes may be erased); run full-resolution regression for a passed verdict"] : []),
|
|
192
|
+
...limitations,
|
|
193
|
+
],
|
|
194
|
+
note: "tool ran; this is NOT a visual pass. regression (native): any changed pixel fails; masks require documented reasons and are shown in review, never adjusted to hide defects. design-reference: human/critic review required.",
|
|
195
|
+
}
|
|
196
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobilecode",
|
|
3
3
|
"description": "MobileCode for the dsh web GUI: detect iOS/Android projects, run preview servers, and drive the simulator/emulator from the session — 37 agent tools (device_run, device_screen, device_ui_tree, device_ui_rows, device_tap_row, device_tap_element, device_wait_for, device_scroll_to, device_input, device_batch, device_intent, device_connect, device_pair_qr, device_perf, device_meminfo, device_backtrace, device_display, device_avd_create, device_pair_capture, device_app_info, device_install, device_uninstall, device_reboot, device_log, live screen stream, multimodal screenshots) plus a host-mediated co-op mesh hub (random callsigns, JSON pub/sub, tunable latency/jitter/drop/dup/throttle) so two virtual devices — and the AI watching them — can talk in real time. One classified adb boundary and a Wi-Fi connect/pair QR flow in the Connection settings tab. Hot-pluggable — mounted via the profile bundle list + cordis.patch.yml, no dsh source changes.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.12.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@11.22.0",
|
|
7
7
|
"engines": {
|