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/lib/index.js CHANGED
@@ -26,8 +26,16 @@ import * as Vision from './vision.js'
26
26
  import { DevicePreviewEngine } from './device-preview.js'
27
27
  import { MeshHub } from './mesh-hub.js'
28
28
  import * as Setup from './setup.js'
29
+ import { ReferenceWorkspace } from './reference-workspace.js'
30
+ import * as PreviewGallery from './preview-gallery.js'
31
+ import * as ConfigMatrix from './config-matrix.js'
32
+ import * as OpenPencil from './openpencil.js'
33
+ import * as VisualCompare from './visual-compare.js'
34
+ import { decodePngToRgba } from './png-decode.js'
29
35
  import { registerMobileSkill } from './skill.js'
30
- import { existsSync, readFileSync } from 'node:fs'
36
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
37
+ import crypto from 'node:crypto'
38
+ import path from 'node:path'
31
39
  import { tmpdir } from 'node:os'
32
40
 
33
41
  export const name = 'mobilecode'
@@ -60,12 +68,12 @@ function writeJson(res, status, body) {
60
68
  res.end(JSON.stringify(body))
61
69
  }
62
70
 
63
- async function readBody(req, res) {
71
+ async function readBody(req, res, limit = 64 * 1024) {
64
72
  const chunks = []
65
73
  let size = 0
66
74
  for await (const chunk of req) {
67
75
  size += chunk.length
68
- if (size > 64 * 1024) { writeJson(res, 413, { error: 'body too large' }); return undefined }
76
+ if (size > limit) { writeJson(res, 413, { error: 'body too large' }); return undefined }
69
77
  chunks.push(chunk)
70
78
  }
71
79
  try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { return {} }
@@ -79,6 +87,7 @@ function resolveDirectory(body, config) {
79
87
  }
80
88
 
81
89
  function makeRoutes(engine, config, stream) {
90
+ const workspaceRef = sharedWorkspaceRef()
82
91
  const guard = (req, res) => {
83
92
  if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return false }
84
93
  return true
@@ -93,6 +102,18 @@ function makeRoutes(engine, config, stream) {
93
102
  if ((req.method ?? 'GET') !== 'POST') { writeJson(res, 405, { error: 'method not allowed' }); return false }
94
103
  return true
95
104
  }
105
+ // Reference-store fence: a request that LOOKS like a browser call (carries
106
+ // Origin / Sec-Fetch-* — which web pages always do for cross-site POSTs) must
107
+ // pass the trusted-origin fence, so a random webpage cannot capture device
108
+ // screenshots or fill the store. Bare loopback HTTP clients (the panel's own
109
+ // same-origin fetches, local tooling, tests) keep the plain loopback guard.
110
+ const refGuard = (req, res) => {
111
+ if (!guard(req, res)) return false
112
+ if (req.headers.origin !== undefined || req.headers['sec-fetch-site'] !== undefined || req.headers['sec-fetch-mode'] !== undefined) {
113
+ return fence(req, res, false)
114
+ }
115
+ return true
116
+ }
96
117
  // Mesh fence: game clients are plain HTTP stacks (OkHttp/Unity/curl) that
97
118
  // never send browser headers — they must only prove a loopback peer plus a
98
119
  // Host the emulator NAT actually presents (10.0.2.2) or loopback. Anything
@@ -817,10 +838,460 @@ function makeRoutes(engine, config, stream) {
817
838
  writeJson(res, 200, { ok: true })
818
839
  },
819
840
  },
841
+
842
+ // ── reference-comparison workspace (Feature A vertical slice) ──────────────
843
+ {
844
+ kind: 'exact',
845
+ path: API_BASE + '/refs',
846
+ handler: async (req, res) => {
847
+ if (!guard(req, res)) return
848
+ const url = new URL(req.url ?? '/', 'http://localhost')
849
+ const project = url.searchParams.get('project') ?? undefined
850
+ const records = workspaceRef.list({ project })
851
+ writeJson(res, 200, { records, active: project === undefined ? undefined : workspaceRef.active(project) })
852
+ },
853
+ },
854
+ {
855
+ kind: 'exact',
856
+ path: API_BASE + '/refs/import',
857
+ handler: async (req, res) => {
858
+ if (!refGuard(req, res)) return
859
+ if (!isPost(req, res)) return
860
+ const body = await readBody(req, res, 40 * 1024 * 1024)
861
+ if (body === undefined) return
862
+ try {
863
+ const record = workspaceRef.import(body, body?.data)
864
+ writeJson(res, 201, { record })
865
+ } catch (error) {
866
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
867
+ }
868
+ },
869
+ },
870
+ {
871
+ kind: 'exact',
872
+ path: API_BASE + '/refs/capture',
873
+ handler: async (req, res) => {
874
+ if (!refGuard(req, res)) return
875
+ if (!isPost(req, res)) return
876
+ const body = await readBody(req, res)
877
+ if (body === undefined) return
878
+ try {
879
+ const serial = await requireAndroidDevice(body?.serial)
880
+ // defense-in-depth: screenCapture names its tmp file after the serial
881
+ if (!/^[A-Za-z0-9._:-]+$/.test(String(serial))) throw new Error('refused: unexpected device serial shape')
882
+ const png = await DeviceBuild.screenCapture(serial, undefined)
883
+ if (!png || typeof png !== 'string') throw new Error('screen capture failed (no screenshot produced)')
884
+ const [size, density] = await Promise.all([
885
+ captureScreenSize(serial).catch(() => undefined),
886
+ DeviceBuild.adbRun(serial, ['shell', 'wm', 'density'])
887
+ .then((text) => String(text ?? '').trim() || undefined)
888
+ .catch(() => undefined),
889
+ ])
890
+ const record = workspaceRef.capture({
891
+ project: body?.project,
892
+ screen: body?.screen,
893
+ stateId: body?.stateId,
894
+ pngPath: png,
895
+ device: { serial, ...(size ?? {}), density },
896
+ })
897
+ writeJson(res, 201, { record })
898
+ } catch (error) {
899
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
900
+ }
901
+ },
902
+ },
903
+ {
904
+ kind: 'exact',
905
+ path: API_BASE + '/refs/active',
906
+ handler: async (req, res) => {
907
+ if (!refGuard(req, res)) return
908
+ if (!isPost(req, res)) return
909
+ const body = await readBody(req, res)
910
+ if (body === undefined) return
911
+ try {
912
+ writeJson(res, 200, { record: workspaceRef.setActive(body?.id) })
913
+ } catch (error) {
914
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
915
+ }
916
+ },
917
+ },
918
+ {
919
+ kind: 'exact',
920
+ path: API_BASE + '/refs/approve',
921
+ handler: async (req, res) => {
922
+ if (!refGuard(req, res)) return
923
+ if (!isPost(req, res)) return
924
+ const body = await readBody(req, res)
925
+ if (body === undefined) return
926
+ try {
927
+ writeJson(res, 200, { record: workspaceRef.setApproved(body?.id, body?.approved !== false) })
928
+ } catch (error) {
929
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
930
+ }
931
+ },
932
+ },
933
+ {
934
+ kind: 'exact',
935
+ path: API_BASE + '/refs/remove',
936
+ handler: async (req, res) => {
937
+ if (!refGuard(req, res)) return
938
+ if (!isPost(req, res)) return
939
+ const body = await readBody(req, res)
940
+ if (body === undefined) return
941
+ writeJson(res, 200, { removed: body?.id ? workspaceRef.remove(body.id) : false })
942
+ },
943
+ },
944
+ {
945
+ kind: 'exact',
946
+ path: API_BASE + '/refs/compare',
947
+ handler: async (req, res) => {
948
+ if (!guard(req, res)) return
949
+ const url = new URL(req.url ?? '/', 'http://localhost')
950
+ const project = url.searchParams.get('project')
951
+ const screen = url.searchParams.get('screen') ?? ''
952
+ const stateId = url.searchParams.get('stateId') ?? ''
953
+ if (!project) { writeJson(res, 400, { error: 'project query parameter is required' }); return }
954
+ writeJson(res, 200, {
955
+ reference: workspaceRef.active(project) ?? workspaceRef.latest(project, screen, stateId, 'reference'),
956
+ capture: workspaceRef.latest(project, screen, stateId, 'capture'),
957
+ })
958
+ },
959
+ },
960
+ {
961
+ kind: 'exact',
962
+ path: API_BASE + '/refs/diff',
963
+ handler: async (req, res) => {
964
+ if (!refGuard(req, res)) return
965
+ if (!isPost(req, res)) return
966
+ const body = await readBody(req, res, 64 * 1024 * 1024) // two native RGBA frames at 1080x2400 ≈ 28 MB base64
967
+ if (body === undefined) return
968
+ // Native regression = server-decoded STORED originals (provenance,
969
+ // audit §3). Caller-posted raw RGBA is only for reduced-resolution
970
+ // TRIAGE (design-reference / non-PNG records); encoded images in the
971
+ // triage path are refused, never pixel-decoded from hostile input.
972
+ const frameOf = (f) => {
973
+ if (!f || typeof f.width !== 'number' || typeof f.height !== 'number' || typeof f.rgba !== 'string') return undefined
974
+ const buf = Buffer.from(f.rgba, 'base64')
975
+ if (buf.length !== f.width * f.height * 4) return undefined
976
+ return { width: f.width, height: f.height, rgba: buf }
977
+ }
978
+ const a = frameOf(body?.a)
979
+ const b = frameOf(body?.b)
980
+ if ((a === undefined || b === undefined) && !(body?.referenceId && body?.captureId)) {
981
+ writeJson(res, 400, {
982
+ error: 'triage comparisons need frames {width, height, rgba:base64} with exact byte counts (browser-canvas decoded); native regression needs referenceId+captureId of stored PNGs',
983
+ note: 'the server decodes only its own stored PNG originals; caller-posted pixels are never trusted for a native verdict',
984
+ })
985
+ return
986
+ }
987
+ const mode = body?.mode === 'regression' ? 'regression' : 'design-reference'
988
+ let masks
989
+ try { masks = VisualCompare.validateMasks(body?.masks) } catch (error) { writeJson(res, 400, { error: String(error instanceof Error ? error.message : error) }); return }
990
+ // BASELINE MODEL (corrected): the REFERENCE must carry a valid explicit
991
+ // approval bound to its current stored bytes + device config; the
992
+ // CANDIDATE never needs approval — a test must be able to detect its
993
+ // regression. Pixel equality can only `passed` when approval binds,
994
+ // dimensions match, and BOTH records carry equal capture configuration
995
+ // (serial/size/density/font-scale metadata). Anything less is capped
996
+ // at needs_review / blocked — never silently auto-approved.
997
+ const recA = body?.referenceId ? workspaceRef.get(String(body.referenceId)) : undefined
998
+ const recB = body?.captureId ? workspaceRef.get(String(body.captureId)) : undefined
999
+ const storedPng = (r) => r !== undefined && r.ext === '.png'
1000
+ let native = null
1001
+ if (recA && recB && storedPng(recA) && storedPng(recB)) {
1002
+ try {
1003
+ const bufA = workspaceRef.readImage(recA)
1004
+ native = { a: decodePngToRgba(bufA), b: decodePngToRgba(workspaceRef.readImage(recB)), sha256A: crypto.createHash('sha256').update(bufA).digest('hex') }
1005
+ } catch (error) {
1006
+ native = { error: `stored original not decodable server-side: ${error instanceof Error ? error.message : error}` }
1007
+ }
1008
+ }
1009
+ if (native && !native.error) {
1010
+ const cfgKey = (r) => (r.device && Object.keys(r.device).length > 0 ? JSON.stringify(r.device, Object.keys(r.device).sort()) : null)
1011
+ const cfgA = cfgKey(recA), cfgB = cfgKey(recB)
1012
+ const dimsMatch = native.a.width === native.b.width && native.a.height === native.b.height
1013
+ const approvalValid = recA.approved === true && recA.approval?.sha256 === native.sha256A
1014
+ const diff = dimsMatch
1015
+ ? VisualCompare.diffFrames(native.a, native.b, { threshold: Number(body?.threshold) || 24, masks })
1016
+ : { status: 'blocked', reason: `stored originals differ in size (${native.a.width}x${native.a.height} vs ${native.b.width}x${native.b.height}) — no native verdict`, changedPixels: 0, totalPixels: 0 }
1017
+ let ceiling = null
1018
+ if (!approvalValid) ceiling = { status: 'blocked', reason: `reference ${recA.id} has no valid approval bound to its current bytes (${recA.approved === true ? 'stored bytes differ from the approved hash' : 'not approved'}) — approve the baseline via /refs/approve; candidates never require approval to be tested` }
1019
+ else if (!dimsMatch) ceiling = { status: 'blocked', reason: 'stored sizes differ — incompatible capture configurations' }
1020
+ else if (cfgA === null || cfgB === null) ceiling = { status: 'needs_review', reason: 'capture configuration not recorded on both records — pixel comparison ran on server-decoded stored bytes, but device/density/font-scale compatibility cannot be certified' }
1021
+ else if (cfgA !== cfgB) ceiling = { status: 'needs_review', reason: `capture configurations differ (${cfgA} vs ${cfgB}) — pixel equality would not be meaningful; observed diff: ${diff.changedPixels} px` }
1022
+ const envelope = VisualCompare.compareEnvelope({
1023
+ runId: Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8),
1024
+ project: String(body?.project ?? recA.project), screen: String(body?.screen ?? recA.screen), stateId: String(body?.stateId ?? ''),
1025
+ mode, diff, comparisonSpace: 'native',
1026
+ observations: [{ source: 'store', finding: `decoded stored ${native.a.width}x${native.a.height} vs ${native.b.width}x${native.b.height}; baseline approval bound=${approvalValid}; config ${cfgA ?? 'unrecorded'} vs ${cfgB ?? 'unrecorded'}` }],
1027
+ evidenceRefs: [recA.id, recB.id],
1028
+ env: {
1029
+ originals: { a: { width: native.a.width, height: native.a.height, id: recA.id }, b: { width: native.b.width, height: native.b.height, id: recB.id } },
1030
+ approval: { referenceApproved: !!recA.approved, approvalBindsBytes: approvalValid, approvedAt: recA.approval?.approvedAt, candidateApprovalRequired: false },
1031
+ configs: { reference: recA.device ?? null, candidate: recB.device ?? null }, masks,
1032
+ },
1033
+ limitations: [
1034
+ 'native space decodes SERVER-STORED PNG bytes only (8-bit grayscale/RGB/RGBA, non-interlaced; CRC-verified); anything else never claims native equality',
1035
+ 'caller-posted frames are ignored on this path — provenance is the store itself',
1036
+ ],
1037
+ })
1038
+ envelope.provenance = 'server-decoded-stored-originals (caller-posted pixels ignored)'
1039
+ if (ceiling !== null && mode === 'regression') {
1040
+ // blocked ceilings invalidate the TEST itself (no valid baseline) even
1041
+ // where pixels differ; needs_review ceilings only cap a would-be PASS —
1042
+ // a detected inequality (changed>0) is never softened.
1043
+ if (ceiling.status === 'blocked') envelope.checkStatus = 'blocked'
1044
+ else if (envelope.checkStatus === 'passed') envelope.checkStatus = 'needs_review'
1045
+ envelope.limitations.unshift(ceiling.reason)
1046
+ }
1047
+ try {
1048
+ const evidenceDir = path.join(workspaceRef.root, 'evidence')
1049
+ mkdirSync(evidenceDir, { recursive: true })
1050
+ writeFileSync(path.join(evidenceDir, envelope.runId + '.json'), JSON.stringify(envelope, null, 2) + '\n', 'utf8')
1051
+ envelope.evidenceFile = 'evidence/' + envelope.runId + '.json'
1052
+ } catch { /* best-effort */ }
1053
+ writeJson(res, 200, envelope)
1054
+ return
1055
+ }
1056
+ // ── non-native path: caller-posted frames, triage only ──
1057
+ if (a === undefined || b === undefined) {
1058
+ writeJson(res, 400, { error: 'no native provenance available (stored originals missing or undecodable) and no valid triage frames were posted', ...(native?.error ? { note: native.error } : {}) })
1059
+ return
1060
+ }
1061
+ const originals = {
1062
+ a: recA ? { width: recA.width, height: recA.height, id: recA.id } : { width: a.width, height: a.height },
1063
+ b: recB ? { width: recB.width, height: recB.height, id: recB.id } : { width: b.width, height: b.height },
1064
+ }
1065
+ const comparisonSpace = 'downsampled'
1066
+ const blockedReason = native?.error ?? (recA && recB ? 'stored originals are not both PNG — server cannot establish pixel provenance; this is a caller-supplied-pixel comparison (triage), never a native pass' : undefined)
1067
+ // Aspect mismatch with NO recorded transform → REJECT the screen-level
1068
+ // comparison (a 1536x1024 board and a 1080x2400 capture are not valid
1069
+ // screen comparators merely because both stretch into the panel).
1070
+ const aspectA = a.width / a.height
1071
+ const aspectB = b.width / b.height
1072
+ const transform = body?.transform && typeof body.transform === 'object' ? body.transform : undefined
1073
+ if (Math.abs(aspectA - aspectB) / Math.max(aspectA, aspectB) > 0.02 && transform === undefined) {
1074
+ writeJson(res, 200, {
1075
+ status: 'blocked', checkStatus: 'blocked', comparisonSpace, mode,
1076
+ reason: `source aspect mismatch (${a.width}x${a.height} vs ${b.width}x${b.height}) — refusing implicit stretch; record an explicit transform or compare screen states captured at the same configuration`,
1077
+ limitations: ['blocked comparisons are never downgraded into passes'],
1078
+ })
1079
+ return
1080
+ }
1081
+ try {
1082
+ const diff = VisualCompare.diffFrames(a, b, { threshold: Number(body?.threshold) || 24, masks })
1083
+ const envelope = VisualCompare.compareEnvelope({
1084
+ runId: Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8),
1085
+ project: String(body?.project ?? 'unknown'), screen: String(body?.screen ?? ''), stateId: String(body?.stateId ?? ''),
1086
+ mode, diff,
1087
+ comparisonSpace,
1088
+ transforms: transform ?? undefined,
1089
+ observations: diff.status === 'ok' ? [{ source: 'diff', finding: `${diff.changedPixels} changed px (${diff.changePct}%) at threshold L1>${diff.threshold}; ${diff.clusterFilter.droppedClusters} sub-minimum cluster(s) counted in the verdict (${diff.clusterFilter.droppedPixels} px)` }] : [],
1090
+ evidenceRefs: [body?.referenceId, body?.captureId].filter(Boolean),
1091
+ env: { originals, postedFrames: { a: { width: a.width, height: a.height }, b: { width: b.width, height: b.height } }, masks },
1092
+ limitations: ['regionsA/regionsB map comparison clusters back to stored-original pixel space', 'caller-supplied pixels — result compares what the client posted, NOT the stored originals; never an approved-baseline native pass',
1093
+ ...(blockedReason ? [blockedReason] : [])],
1094
+ })
1095
+ if (diff.status === 'ok') {
1096
+ envelope.regionsA = diff.clusters.map((c) => VisualCompare.mapRegionToFrame(c, transform?.a ?? {}))
1097
+ envelope.regionsB = diff.clusters.map((c) => VisualCompare.mapRegionToFrame(c, transform?.b ?? {}))
1098
+ }
1099
+ try {
1100
+ const evidenceDir = path.join(workspaceRef.root, 'evidence')
1101
+ mkdirSync(evidenceDir, { recursive: true })
1102
+ writeFileSync(path.join(evidenceDir, envelope.runId + '.json'), JSON.stringify(envelope, null, 2) + '\n', 'utf8')
1103
+ envelope.evidenceFile = 'evidence/' + envelope.runId + '.json'
1104
+ } catch { /* store best-effort; the response still carries the full envelope */ }
1105
+ writeJson(res, 200, envelope)
1106
+ } catch (error) {
1107
+ writeJson(res, 500, { error: String(error instanceof Error ? error.message : error) })
1108
+ }
1109
+ },
1110
+ },
1111
+ {
1112
+ kind: 'exact',
1113
+ path: API_BASE + '/refs/image',
1114
+ handler: async (req, res) => {
1115
+ if (!guard(req, res)) return
1116
+ const url = new URL(req.url ?? '/', 'http://localhost')
1117
+ const record = workspaceRef.get(url.searchParams.get('id') ?? '')
1118
+ if (!record) { writeJson(res, 404, { error: 'not found' }); return }
1119
+ try {
1120
+ const bytes = workspaceRef.readImage(record)
1121
+ const mime = ({ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp' })[record.ext] ?? 'application/octet-stream'
1122
+ res.writeHead(200, { 'content-type': mime, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' })
1123
+ res.end(bytes)
1124
+ } catch { writeJson(res, 500, { error: 'failed to read image' }) }
1125
+ },
1126
+ },
1127
+
1128
+ // ── Compose preview gallery (Feature B/T5) ────────────────────────────────
1129
+ {
1130
+ kind: 'exact',
1131
+ path: API_BASE + '/preview/list',
1132
+ handler: async (req, res) => {
1133
+ if (!guard(req, res)) return
1134
+ const url = new URL(req.url ?? '/', 'http://localhost')
1135
+ const directory = url.searchParams.get('directory') ?? ''
1136
+ const gradle = !!directory && PreviewGallery.previewSupportStatus(directory).wrapper
1137
+ const warnings = []
1138
+ const fingerprint = gradle ? PreviewGallery.inputsFingerprint(directory) : null
1139
+ const entries = gradle ? PreviewGallery.discoverPreviewTests(directory, warnings).map((e) => ({ ...e, ...PreviewGallery.renderState(directory, e, { fingerprint }) })) : []
1140
+ writeJson(res, 200, { directory, gradle, entries, ...(warnings.length ? { warnings } : {}) })
1141
+ },
1142
+ },
1143
+ {
1144
+ kind: 'exact',
1145
+ path: API_BASE + '/preview/render',
1146
+ handler: async (req, res) => {
1147
+ if (!refGuard(req, res)) return
1148
+ if (!isPost(req, res)) return
1149
+ const body = await readBody(req, res)
1150
+ if (body === undefined) return
1151
+ const directory = String(body?.directory ?? '')
1152
+ const className = String(body?.class ?? '')
1153
+ const method = String(body?.method ?? '')
1154
+ if (!directory || !/^[A-Za-z0-9_.]+$/.test(className) || !/^[A-Za-z0-9_]+$/.test(method)) {
1155
+ writeJson(res, 400, { error: 'directory, class and method are required (identifier shapes only)' })
1156
+ return
1157
+ }
1158
+ const entry = PreviewGallery.discoverPreviewTests(directory).find((e) => e.fqClass === className && e.method === method)
1159
+ if (!entry) { writeJson(res, 400, { error: `not a discovered @PreviewTest in this project: ${className}.${method}` }); return }
1160
+ const result = await PreviewGallery.renderPreviewTest(directory, entry, { timeoutMs: Number(body?.timeoutMs) || undefined })
1161
+ writeJson(res, 200, result)
1162
+ },
1163
+ },
1164
+ {
1165
+ kind: 'exact',
1166
+ path: API_BASE + '/preview/image',
1167
+ handler: async (req, res) => {
1168
+ if (!guard(req, res)) return
1169
+ const url = new URL(req.url ?? '/', 'http://localhost')
1170
+ const directory = url.searchParams.get('directory') ?? ''
1171
+ const className = url.searchParams.get('class') ?? ''
1172
+ const method = url.searchParams.get('method') ?? ''
1173
+ if (!directory || !/^[A-Za-z0-9_.]+$/.test(className) || !/^[A-Za-z0-9_]+$/.test(method)) { writeJson(res, 400, { error: 'bad selector' }); return }
1174
+ const entry = PreviewGallery.discoverPreviewTests(directory).find((e) => e.fqClass === className && e.method === method)
1175
+ const state = entry ? PreviewGallery.renderState(directory, entry, { fingerprint: PreviewGallery.inputsFingerprint(directory) }) : undefined
1176
+ if (state?.pngPath === undefined) { writeJson(res, 404, { error: 'no recorded reference for this preview' }); return }
1177
+ try {
1178
+ const bytes = readFileSync(state.pngPath)
1179
+ res.writeHead(200, { 'content-type': 'image/png', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'x-preview-stale': state.stale ? 'true' : 'false', 'x-preview-freshness': state.freshness })
1180
+ res.end(bytes)
1181
+ } catch { writeJson(res, 500, { error: 'failed to read image' }) }
1182
+ },
1183
+ },
1184
+
1185
+ // ── configuration matrix (T7m/Feature F) ─────────────────────────────────
1186
+ {
1187
+ kind: 'exact',
1188
+ path: API_BASE + '/matrix/capabilities',
1189
+ handler: async (req, res) => {
1190
+ if (!guard(req, res)) return
1191
+ const url = new URL(req.url ?? '/', 'http://localhost')
1192
+ const serial = url.searchParams.get('serial') ?? ''
1193
+ try {
1194
+ const actual = await ConfigMatrix.readConfig(serial)
1195
+ writeJson(res, 200, { serial, emulator: ConfigMatrix.isEmulatorSerial(serial), actual, supported: ['widthPx/heightPx (wm size)', 'densityDpi (wm density)', 'fontScale (settings system)'], notSupportedHere: ['light/dark theme (app-owned; Trachtenberg is always dark)', 'orientation (app-owned)', 'insets/keyboard visibility (not settable)'] })
1196
+ } catch (error) {
1197
+ writeJson(res, 200, { serial, emulator: ConfigMatrix.isEmulatorSerial(serial), error: String(error instanceof Error ? error.message : error), actual: {} })
1198
+ }
1199
+ },
1200
+ },
1201
+ {
1202
+ kind: 'exact',
1203
+ path: API_BASE + '/matrix/run',
1204
+ handler: async (req, res) => {
1205
+ if (!refGuard(req, res)) return
1206
+ if (!isPost(req, res)) return
1207
+ const body = await readBody(req, res)
1208
+ if (body === undefined) return
1209
+ const invalid = validateMatrixBody(body)
1210
+ if (invalid !== undefined) { writeJson(res, 400, { error: invalid }); return }
1211
+ if (matrixRunning.has(body.serial)) { writeJson(res, 409, { error: `a matrix is already running on ${body.serial}` }); return }
1212
+ matrixRunning.add(body.serial)
1213
+ try {
1214
+ const result = await ConfigMatrix.runMatrix({ serial: body.serial, workspace: workspaceRef, project: body.project, screen: body.screen, cases: body.cases })
1215
+ writeJson(res, 200, result)
1216
+ } catch (error) {
1217
+ writeJson(res, 500, { error: String(error instanceof Error ? error.message : error), note: 'if the run was interrupted, check device settings were restored: adb shell wm size / wm density' })
1218
+ } finally {
1219
+ matrixRunning.delete(body.serial)
1220
+ }
1221
+ },
1222
+ },
1223
+ // ── OpenPencil narrow adapter (T7/Feature D — optional, degrades honestly) ─
1224
+ {
1225
+ kind: 'exact',
1226
+ path: API_BASE + '/openpencil/status',
1227
+ handler: async (req, res) => {
1228
+ if (!guard(req, res)) return
1229
+ const p = OpenPencil.probe()
1230
+ writeJson(res, 200, {
1231
+ status: p.installed ? 'available' : 'not_installed',
1232
+ binary: p.resolved,
1233
+ // Honesty (2026-09-15): binary FOUND is not CLI RESPONSIVENESS. On this
1234
+ // host the resolved OpenPencil.exe is a GUI editor that did not answer
1235
+ // `--help` within 60 s — treat inspect/export as not_run until a
1236
+ // file-mode CLI demonstrably answers.
1237
+ cliEvidence: p.installed ? 'unverified — resolved binary did not answer a documented CLI invocation within 60 s on this host; presence ≠ usable file-mode CLI' : undefined,
1238
+ hint: p.installed ? 'binary resolved; export/inspect remain not_run until CLI responsiveness is proven — the reference workspace stays fully usable with manual image imports.' : 'install the OpenPencil file-mode CLI (openpencil.dev) to enable frame export; manual image import covers the workflow meanwhile.',
1239
+ boundary: 'OpenPencil exports target PNG/JSX/HTML — no native Compose export is claimed or performed',
1240
+ })
1241
+ },
1242
+ },
1243
+ {
1244
+ kind: 'exact',
1245
+ path: API_BASE + '/openpencil/importFrame',
1246
+ handler: async (req, res) => {
1247
+ if (!refGuard(req, res)) return
1248
+ if (!isPost(req, res)) return
1249
+ const body = await readBody(req, res)
1250
+ if (body === undefined) return
1251
+ if (!OpenPencil.probe().installed) { writeJson(res, 200, { status: 'not_run', reason: 'openpencil CLI not on PATH; see /openpencil/status hint' }); return }
1252
+ try {
1253
+ const exported = await OpenPencil.exportFrame({ file: body.file, root: body.root, node: body.node, page: body.page, scale: body.scale, workspace: workspaceRef })
1254
+ if (exported.status !== 'passed') { writeJson(res, 200, { status: 'failed', reason: 'export failed', ...exported }); return }
1255
+ const record = workspaceRef.import({ project: body.project, screen: body.screen, stateId: body.stateId, filename: path.basename(String(body.file ?? 'frame'), '.fig') + '.png', provenance: `openpencil export: ${body.file}${body.node ? ` node ${body.node}` : ''}`, intendedUse: 'design-target' }, `data:image/png;base64,${readFileSync(exported.pngPath).toString('base64')}`)
1256
+ writeJson(res, 201, { status: 'passed', record })
1257
+ } catch (error) {
1258
+ writeJson(res, 400, { status: 'failed', error: String(error instanceof Error ? error.message : error) })
1259
+ }
1260
+ },
1261
+ },
820
1262
  ]
821
1263
  return routes
822
1264
  }
823
1265
 
1266
+ // one matrix at a time per device (reconfiguring while one runs would race settings)
1267
+ const matrixRunning = new Set()
1268
+
1269
+ // ponytail: single shared store for routes + tools; root overridable for tests.
1270
+ // Per-workspace stores if multi-project isolation is ever needed.
1271
+ let workspaceSingleton
1272
+ function sharedWorkspaceRef() {
1273
+ if (workspaceSingleton === undefined) workspaceSingleton = new ReferenceWorkspace(process.env.DSH_MOBILECODE_REF_ROOT ?? Setup.HOME)
1274
+ return workspaceSingleton
1275
+ }
1276
+
1277
+ /** Shared by the /matrix/run route and the config_matrix tool. Returns an error string or undefined. */
1278
+ function validateMatrixBody(body) {
1279
+ const serial = typeof body?.serial === 'string' ? body.serial.trim() : ''
1280
+ if (serial === '' || !/^[A-Za-z0-9:._-]+$/.test(serial)) return 'serial is required (safe characters only)'
1281
+ if (typeof body?.project !== 'string' || typeof body?.screen !== 'string') return 'project and screen are required strings'
1282
+ const cases = body?.cases
1283
+ if (!Array.isArray(cases) || cases.length === 0 || cases.length > 8) return 'cases must be 1..8 risk-based entries (no uncontrolled products)'
1284
+ for (const c of cases) {
1285
+ if (typeof c?.id !== 'string' || c.id === '' || c.id.length > 80) return 'every case needs a short string id'
1286
+ if (c.dims !== undefined && (!Array.isArray(c.dims) || c.dims.length !== 2 || !c.dims.every((n) => Number.isInteger(n) && n > 100 && n <= 8000))) return `case ${c.id}: dims must be two ints in 101..8000`
1287
+ if (c.density !== undefined && (!Number.isInteger(c.density) || c.density < 80 || c.density > 800)) return `case ${c.id}: density must be an int 80..800`
1288
+ if (c.fontScale !== undefined && (typeof c.fontScale !== 'number' || c.fontScale < 0.5 || c.fontScale > 3)) return `case ${c.id}: fontScale must be a number 0.5..3`
1289
+ if (c.dims === undefined && c.density === undefined && c.fontScale === undefined) return `case ${c.id}: declares no dimensions`
1290
+ }
1291
+ body.serial = serial
1292
+ return undefined
1293
+ }
1294
+
824
1295
  // ── agent tools ───────────────────────────────────────────────────────────────
825
1296
 
826
1297
  const label = (platform) => (platform === 'ios' ? 'iOS' : 'Android')
@@ -2925,6 +3396,185 @@ function deviceStreamTool(host, access) {
2925
3396
  })
2926
3397
  }
2927
3398
 
3399
+ /**
3400
+ * Compose preview gallery (Feature B/T5): discover @PreviewTest composables of
3401
+ * an app, render one through the Gradle adapter (argument arrays, capped
3402
+ * timeout, cancellable kill), return the PNG attachment + provenance.
3403
+ * Host-rendered (layoutlib) — NEVER claimed as device-verified.
3404
+ */
3405
+ function previewGalleryTool(vision) {
3406
+ const envelope = {
3407
+ type: 'object',
3408
+ additionalProperties: true,
3409
+ description: 'Result envelope. status: passed|failed|blocked|not_run. Carries previews/provenance/tail ' +
3410
+ 'depending on action. A stale render is labeled stale; failures never masquerade as current.',
3411
+ properties: {
3412
+ status: { type: 'string', required: true },
3413
+ error: { type: 'string' },
3414
+ setupHint: { type: 'string' },
3415
+ count: { type: 'number' },
3416
+ previews: { type: 'array', items: { type: 'object', additionalProperties: true } },
3417
+ provenance: { type: 'object', additionalProperties: true },
3418
+ image: Vision.IMAGE_REF_SCHEMA,
3419
+ },
3420
+ }
3421
+ return defineTool({
3422
+ name: 'preview_gallery',
3423
+ description: 'Compose preview gallery for an Android Gradle project using official Compose Preview Screenshot ' +
3424
+ 'Testing. action=list discovers @PreviewTest entries with render state (rendered/stale); action=render runs ' +
3425
+ 'ONE Gradle render and returns the PNG as an image attachment plus provenance (command, exit, timestamps, ' +
3426
+ 'stale flag); action=status reports adapter availability only. Host-rendered via layoutlib: NOT device ' +
3427
+ 'behavior (use device_screen for that). Failed renders report tail lines; stale PNGs are never labeled current.',
3428
+ parameters: {
3429
+ directory: { type: 'string', description: 'Gradle project root (contains gradlew + settings.gradle).' },
3430
+ action: { type: 'string', description: 'list | render | status. Default list.' },
3431
+ class: { type: 'string', description: 'render: fully-qualified class of a discovered @PreviewTest.' },
3432
+ method: { type: 'string', description: 'render: the @PreviewTest method name.' },
3433
+ timeoutMs: { type: 'number', description: 'render cap in ms (default 600000, max 1800000).' },
3434
+ },
3435
+ output: { schema: envelope },
3436
+ async execute(args, exec) {
3437
+ const directory = typeof args.directory === 'string' ? args.directory.trim() : ''
3438
+ if (directory === '') return { status: 'failed', error: 'directory is required' }
3439
+ const support = PreviewGallery.previewSupportStatus(directory)
3440
+ if (!support.wrapper) return { status: 'not_run', error: `${directory} has no gradlew wrapper — not a Gradle project`, setupHint: 'point directory at the app project root' }
3441
+ if (!support.screenshotTest) return { status: 'not_run', error: 'no src/screenshotTest source — Compose Preview Screenshot Testing adapter not set up', setupHint: 'see docs/design/render-adapter-decision.md (plugin apply + marker dep + @PreviewTest methods in a *Test class)' }
3442
+ const discoveryWarnings = []
3443
+ const entries = PreviewGallery.discoverPreviewTests(directory, discoveryWarnings)
3444
+ const action = args.action ?? 'list'
3445
+ if (action === 'status') return { status: 'passed', count: entries.length, previews: entries.map((e) => ({ class: e.fqClass, method: e.method })), ...(discoveryWarnings.length ? { warnings: discoveryWarnings } : {}), provenance: { renderer: 'com.android.compose.screenshot 0.0.1-alpha16 (layoutlib, host-rendered)', deviceVerified: false } }
3446
+ if (action === 'render') {
3447
+ const entry = entries.find((e) => e.fqClass === args.class && e.method === args.method)
3448
+ if (!entry) return { status: 'blocked', error: `not a discovered @PreviewTest: ${String(args.class)}.${String(args.method)}`, previews: entries.map((e) => ({ class: e.fqClass, method: e.method })) }
3449
+ const result = await PreviewGallery.renderPreviewTest(directory, entry, { timeoutMs: Number(args.timeoutMs) || undefined })
3450
+ const envelopeOut = { status: result.status, failureKind: result.failureKind, exitCode: result.exitCode, tail: result.tail, provenance: result.provenance, limitations: ['host-rendered (layoutlib) — not device behavior'] }
3451
+ if (result.status === 'passed' && typeof result.provenance?.pngPath === 'string') {
3452
+ const image = await Vision.maybeAttachScreenshot(vision, result.provenance.pngPath, exec)
3453
+ if (image !== undefined) envelopeOut.image = image
3454
+ }
3455
+ return envelopeOut
3456
+ }
3457
+ const previews = entries.map((e) => {
3458
+ const s = PreviewGallery.renderState(directory, e, { fingerprint: PreviewGallery.inputsFingerprint(directory) })
3459
+ return { class: e.fqClass, method: e.method, previewName: e.previewName, widthDp: e.widthDp, sourceFile: path.relative(directory, e.sourceFile).replaceAll('\\', '/'), rendered: s.rendered, stale: s.stale, freshness: s.freshness }
3460
+ })
3461
+ return { status: 'passed', count: previews.length, previews, ...(discoveryWarnings.length ? { warnings: discoveryWarnings } : {}), limitations: ['freshness=unknown means no render manifest exists — the PNG may be current or not; never treat unknown as fresh', 'host-rendered previews are not device verification'] }
3462
+ },
3463
+ })
3464
+ }
3465
+
3466
+ /**
3467
+ * Bounded configuration matrix (T7m): real device reconfiguration through the
3468
+ * existing adb plumbing with snapshot/restore on ALL paths. Evidence lands in
3469
+ * the reference workspace capture store. Physical devices are refused.
3470
+ */
3471
+ function configMatrixTool() {
3472
+ return defineTool({
3473
+ name: 'config_matrix',
3474
+ description: 'Run a bounded, risk-based Android configuration matrix on an EMULATOR (1..8 cases of ' +
3475
+ 'wm size / wm density / font scale). action=capabilities reads back the device actual config and ' +
3476
+ 'lists what is settable vs app-owned; action=run applies each case, CONFIRMS the actual resulting ' +
3477
+ 'configuration by reading it back (requested != actual is reported, not assumed), captures evidence ' +
3478
+ 'through the reference-workspace store, and restores the device to its found state on success, ' +
3479
+ 'failure and cancellation. Physical-device changes are refused. Browser pane scaling is NOT a ' +
3480
+ 'device configuration change and is never represented here.',
3481
+ parameters: {
3482
+ action: { type: 'string', description: 'capabilities | run' },
3483
+ serial: { type: 'string', description: 'Device serial (emulator-* required for run).' },
3484
+ project: { type: 'string', description: 'run: evidence project key (e.g. trachtenberg).' },
3485
+ screen: { type: 'string', description: 'run: evidence screen key (e.g. practice).' },
3486
+ cases: { type: 'string', description: 'run: JSON array of 1..8 cases: [{id, dims?, density?, fontScale?}] — dims=[w,h] px, density dpi, fontScale 0.5..3.' },
3487
+ },
3488
+ output: {
3489
+ schema: {
3490
+ type: 'object',
3491
+ additionalProperties: true,
3492
+ properties: {
3493
+ status: { type: 'string', required: true },
3494
+ reports: { type: 'array', items: { type: 'object', additionalProperties: true } },
3495
+ restore: { type: 'object', additionalProperties: true },
3496
+ snapshotBefore: { type: 'object', additionalProperties: true },
3497
+ warnings: { type: 'array', items: { type: 'string' } },
3498
+ },
3499
+ },
3500
+ },
3501
+ async execute(args) {
3502
+ const action = args.action ?? 'capabilities'
3503
+ if (action === 'capabilities') {
3504
+ const serial = String(args.serial ?? '')
3505
+ const actual = await ConfigMatrix.readConfig(serial).catch((error) => ({ error: String(error instanceof Error ? error.message : error) }))
3506
+ return { status: 'passed', serial, emulator: ConfigMatrix.isEmulatorSerial(serial), actual }
3507
+ }
3508
+ let parsed
3509
+ try { parsed = JSON.parse(String(args.cases ?? '')) } catch { return { status: 'failed', error: 'cases must be a JSON array of {id, dims?, density?, fontScale?}' } }
3510
+ const body = { serial: args.serial, project: args.project, screen: args.screen, cases: parsed }
3511
+ const invalid = validateMatrixBody(body)
3512
+ if (invalid !== undefined) return { status: 'failed', error: invalid }
3513
+ return await ConfigMatrix.runMatrix({ serial: body.serial, workspace: sharedWorkspaceRef(), project: body.project, screen: body.screen, cases: body.cases })
3514
+ },
3515
+ })
3516
+ }
3517
+
3518
+ /**
3519
+ * OpenPencil design bridge (T7): optional local integration with the
3520
+ * OpenPencil CLI for .fig documents — inspect frames, export a frame to PNG
3521
+ * and import it into the reference workspace, read design variables.
3522
+ * NEVER claims native Compose export (OpenPencil exports are PNG/JSX/HTML).
3523
+ */
3524
+ function designBridgeTool() {
3525
+ return defineTool({
3526
+ name: 'design_bridge',
3527
+ description: 'OpenPencil design-document bridge (file mode only — the editor owns edits). ' +
3528
+ 'action=status reports installation + what the adapter can do; action=inspect reads a .fig ' +
3529
+ 'document (info + pages, confined to root); action=variables lists design variables; ' +
3530
+ 'action=importFrame exports one frame/page to PNG via the OpenPencil CLI and registers it in ' +
3531
+ 'the reference-comparison workspace with provenance. OpenPencil has no native Compose export — ' +
3532
+ 'web-oriented outputs (JSX/HTML) are NOT produced here and never presented as Kotlin.',
3533
+ parameters: {
3534
+ action: { type: 'string', description: 'status | inspect | variables | importFrame' },
3535
+ root: { type: 'string', description: 'Allowed project root directory the design file must live in.' },
3536
+ file: { type: 'string', description: 'Path to the .fig document (inside root).' },
3537
+ node: { type: 'string', description: 'importFrame: node id to export (e.g. 1:23). Omit for whole page.' },
3538
+ page: { type: 'string', description: 'inspect/importFrame: page name.' },
3539
+ scale: { type: 'number', description: 'importFrame: export scale 1..4 (default 2).' },
3540
+ project: { type: 'string', description: 'importFrame: evidence project key.' },
3541
+ screen: { type: 'string', description: 'importFrame: screen key.' },
3542
+ stateId: { type: 'string', description: 'importFrame: state key.' },
3543
+ },
3544
+ output: {
3545
+ schema: {
3546
+ type: 'object',
3547
+ additionalProperties: true,
3548
+ properties: {
3549
+ status: { type: 'string', required: true },
3550
+ hint: { type: 'string' },
3551
+ },
3552
+ },
3553
+ },
3554
+ async execute(args) {
3555
+ const action = args.action ?? 'status'
3556
+ const p = OpenPencil.probe()
3557
+ if (!p.installed) {
3558
+ return { status: 'not_run', reason: 'openpencil CLI not on PATH', hint: 'install OpenPencil (openpencil.dev); manual reference imports into the workspace work without it' }
3559
+ }
3560
+ try {
3561
+ if (action === 'status') return { status: 'passed', binary: p.resolved }
3562
+ if (action === 'inspect') return await OpenPencil.inspect(args.file, args.root ?? args.directory ?? '')
3563
+ if (action === 'variables') return await OpenPencil.variables(args.file, args.root ?? '')
3564
+ if (action === 'importFrame') {
3565
+ const exported = await OpenPencil.exportFrame({ file: args.file, root: args.root, node: args.node, page: args.page, scale: args.scale, workspace: sharedWorkspaceRef() })
3566
+ if (exported.status !== 'passed') return { ...exported, status: 'failed' }
3567
+ const record = sharedWorkspaceRef().import({ project: args.project, screen: args.screen, stateId: args.stateId, filename: path.basename(String(args.file ?? 'frame'), '.fig') + '.png', provenance: `openpencil export: ${args.file}${args.node ? ` node ${args.node}` : ''}`, intendedUse: 'design-target' }, `data:image/png;base64,${readFileSync(exported.pngPath).toString('base64')}`)
3568
+ return { status: 'passed', record }
3569
+ }
3570
+ return { status: 'failed', error: `unknown action ${action}` }
3571
+ } catch (error) {
3572
+ return { status: 'failed', error: String(error instanceof Error ? error.message : error) }
3573
+ }
3574
+ },
3575
+ })
3576
+ }
3577
+
2928
3578
  function deviceScreenTool(engine, vision) {
2929
3579
  return defineTool({
2930
3580
  name: 'device_screen',
@@ -3517,6 +4167,9 @@ export function apply(ctx, config) {
3517
4167
  deviceRunTool(engine, config),
3518
4168
  deviceDetectTool(engine, config),
3519
4169
  deviceScreenTool(engine, vision),
4170
+ previewGalleryTool(vision),
4171
+ configMatrixTool(),
4172
+ designBridgeTool(),
3520
4173
  deviceUiTreeTool(),
3521
4174
  deviceTapElementTool(),
3522
4175
  deviceWaitForTool(),