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.
- package/README.md +52 -0
- package/lib/client.js +249 -0
- package/lib/config-matrix.js +202 -0
- package/lib/index.js +656 -3
- package/lib/openpencil.js +139 -0
- package/lib/png-decode.js +86 -0
- package/lib/preview-gallery.js +309 -0
- package/lib/reference-workspace.js +283 -0
- package/lib/visual-compare.js +196 -0
- package/package.json +1 -1
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 >
|
|
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(),
|