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.
@@ -0,0 +1,202 @@
1
+ /**
2
+ * dsh-mobilecode — bounded configuration matrix runner (T7m/Feature F).
3
+ *
4
+ * Drives REAL Android configuration (wm size/density overrides + font scale)
5
+ * through the existing adb plumbing, captures evidence through the
6
+ * reference-workspace capture store, and RESTORES the device to the state it
7
+ * was FOUND in — including pre-existing overrides — on success, failure and
8
+ * cancellation paths.
9
+ *
10
+ * Honesty rules:
11
+ * - requested config ≠ actual config: every case queries the device
12
+ * (wm size / wm density / settings get font_scale) and reports what it
13
+ * ACTUALLY ended up with;
14
+ * - scaling the browser pane is NOT an Android configuration change and is
15
+ * never represented here;
16
+ * - physical devices are refused without an explicit approval flag;
17
+ * - font behavior is read back from the platform — no linear px↔sp formula.
18
+ */
19
+
20
+ import { tmpdir } from "node:os"
21
+ import * as DeviceBuild from "./device-build.js"
22
+
23
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
24
+
25
+ /** emulator-* serials are safe to reconfigure; physical hardware is not, without approval. */
26
+ export function isEmulatorSerial(serial) {
27
+ return typeof serial === "string" && serial.startsWith("emulator-")
28
+ }
29
+
30
+ /** Parse `wm size`/`wm density` output into effective value + whether an override was in place.
31
+ * Keys are prefixed per-kind (sizeOverridden/densityOverridden) so spreading the two parses
32
+ * cannot clobber each other's override flag (audit P4-1: both previously used `overridden`). */
33
+ export function parseDisplaySetting(out, kind) {
34
+ const text = String(out ?? "")
35
+ const flag = kind === "size" ? "sizeOverridden" : "densityOverridden"
36
+ const override = kind === "size"
37
+ ? /Override size:\s*(\d+)x(\d+)/.exec(text)
38
+ : /Override density:\s*(\d+)/.exec(text)
39
+ if (override) {
40
+ return kind === "size"
41
+ ? { [flag]: true, widthPx: Number(override[1]), heightPx: Number(override[2]) }
42
+ : { [flag]: true, densityDpi: Number(override[1]) }
43
+ }
44
+ const physical = kind === "size"
45
+ ? /Physical size:\s*(\d+)x(\d+)/.exec(text)
46
+ : /Physical density:\s*(\d+)/.exec(text)
47
+ if (!physical) return {}
48
+ return kind === "size"
49
+ ? { [flag]: false, widthPx: Number(physical[1]), heightPx: Number(physical[2]) }
50
+ : { [flag]: false, densityDpi: Number(physical[1]) }
51
+ }
52
+
53
+ export async function readConfig(serial) {
54
+ const [sizeOut, densityOut, fontOut] = await Promise.all([
55
+ DeviceBuild.adbRun(serial, ["shell", "wm", "size"]).catch(() => ""),
56
+ DeviceBuild.adbRun(serial, ["shell", "wm", "density"]).catch(() => ""),
57
+ DeviceBuild.adbRun(serial, ["shell", "settings", "get", "system", "font_scale"]).catch(() => ""),
58
+ ])
59
+ const fontRaw = String(fontOut ?? "").trim()
60
+ const fontScale = fontRaw === "" || fontRaw === "null" ? 1 : Number(fontRaw)
61
+ return {
62
+ ...parseDisplaySetting(sizeOut, "size"),
63
+ ...parseDisplaySetting(densityOut, "density"),
64
+ fontScale: Number.isFinite(fontScale) ? fontScale : 1,
65
+ }
66
+ }
67
+
68
+ /** Put one device setting and VERIFY it took effect by reading back (bounded retries). */
69
+ async function setVerified(serial, apply, read, expect, approx = false) {
70
+ for (let attempt = 0; attempt < 3; attempt++) {
71
+ await apply()
72
+ await sleep(approx ? 600 : 300)
73
+ const actual = await read()
74
+ const ok = approx ? Math.abs(actual - expect) < 0.01 : actual === expect
75
+ if (ok) return { ok: true, actual }
76
+ }
77
+ return { ok: false, actual: await read() }
78
+ }
79
+
80
+ async function applyCase(serial, c) {
81
+ if (c.dims) {
82
+ const r = await setVerified(serial,
83
+ () => DeviceBuild.adbRun(serial, ["shell", "wm", "size", `${c.dims[0]}x${c.dims[1]}`]),
84
+ async () => { const s = await readConfig(serial); return s.widthPx },
85
+ c.dims[0])
86
+ if (!r.ok) return { ok: false, step: "size", ...r }
87
+ }
88
+ if (c.density) {
89
+ const r = await setVerified(serial,
90
+ () => DeviceBuild.adbRun(serial, ["shell", "wm", "density", String(c.density)]),
91
+ async () => { const s = await readConfig(serial); return s.densityDpi },
92
+ c.density)
93
+ if (!r.ok) return { ok: false, step: "density", ...r }
94
+ }
95
+ if (c.fontScale !== undefined) {
96
+ const r = await setVerified(serial,
97
+ () => DeviceBuild.adbRun(serial, ["shell", "settings", "put", "system", "font_scale", String(c.fontScale)]),
98
+ async () => { const s = await readConfig(serial); return s.fontScale },
99
+ c.fontScale, true)
100
+ if (!r.ok) return { ok: false, step: "font_scale", ...r }
101
+ }
102
+ await sleep(700) // reflow settle before capture
103
+ return { ok: true }
104
+ }
105
+
106
+ /** Restore to the FOUND state: re-apply pre-existing overrides, else reset. */
107
+ export async function restoreConfig(serial, snapshot) {
108
+ const problems = []
109
+ if (snapshot.widthPx !== undefined) {
110
+ try {
111
+ if (snapshot.sizeOverridden) await DeviceBuild.adbRun(serial, ["shell", "wm", "size", `${snapshot.widthPx}x${snapshot.heightPx}`])
112
+ else await DeviceBuild.adbRun(serial, ["shell", "wm", "size", "reset"])
113
+ } catch (e) { problems.push(`size restore failed: ${e}`) }
114
+ }
115
+ if (snapshot.densityDpi !== undefined) {
116
+ try {
117
+ if (snapshot.densityOverridden) await DeviceBuild.adbRun(serial, ["shell", "wm", "density", String(snapshot.densityDpi)])
118
+ else await DeviceBuild.adbRun(serial, ["shell", "wm", "density", "reset"])
119
+ } catch (e) { problems.push(`density restore failed: ${e}`) }
120
+ }
121
+ try { await DeviceBuild.adbRun(serial, ["shell", "settings", "put", "system", "font_scale", String(snapshot.fontScale ?? 1)]) } catch (e) { problems.push(`font_scale restore failed: ${e}`) }
122
+ await sleep(700)
123
+ const after = await readConfig(serial)
124
+ const ok = problems.length === 0 &&
125
+ (snapshot.widthPx === undefined || (after.widthPx === snapshot.widthPx && after.heightPx === snapshot.heightPx)) &&
126
+ (snapshot.densityDpi === undefined || after.densityDpi === snapshot.densityDpi) &&
127
+ Math.abs((after.fontScale ?? 1) - (snapshot.fontScale ?? 1)) < 0.01
128
+ return { ok, problems, actualAfterRestore: after, snapshotWas: snapshot }
129
+ }
130
+
131
+ /**
132
+ * Run a bounded matrix (1..8 cases — explicit expansion is the user's call,
133
+ * no uncontrolled Cartesian product). Evidence goes through the capture store.
134
+ */
135
+ export async function runMatrix({ serial, workspace, project, screen, cases, signal }) {
136
+ if (!isEmulatorSerial(serial)) {
137
+ return { status: "blocked", reason: "physical-device reconfiguration requires explicit approval; refusing", serial, reports: [] }
138
+ }
139
+ if (!Array.isArray(cases) || cases.length === 0 || cases.length > 8) {
140
+ return { status: "blocked", reason: "matrix must declare 1..8 risk-based cases", reports: [] }
141
+ }
142
+ const before = await readConfig(serial)
143
+ // parse keys are already per-kind (sizeOverridden/densityOverridden) — no re-derivation
144
+ const snapshot = { ...before }
145
+ const reports = []
146
+ const warnings = []
147
+ let restoreResult = { ok: false, problems: ["not run"], actualAfterRestore: {} }
148
+ try {
149
+ for (const c of cases) {
150
+ if (signal?.aborted) { reports.push({ id: c.id, status: "not_run", reason: "cancelled before case" }); continue }
151
+ try {
152
+ const applied = await applyCase(serial, c)
153
+ if (!applied.ok) {
154
+ reports.push({ id: c.id, status: "needs_review", step: applied.step, requested: c, actual: await readConfig(serial), warning: "setting did not verify — device reports a different actual configuration" })
155
+ continue
156
+ }
157
+ const actual = await readConfig(serial)
158
+ const png = await DeviceBuild.screenCapture(serial, tmpdir())
159
+ const evidence = png ? workspace.capture({ project, screen, stateId: c.id, pngPath: png, device: { serial, ...actual } }) : undefined
160
+ reports.push({
161
+ id: c.id,
162
+ requested: { dims: c.dims, density: c.density, fontScale: c.fontScale },
163
+ actual,
164
+ evidenceId: evidence?.id,
165
+ evidenceFile: evidence?.file,
166
+ status: evidence ? "captured" : "failed",
167
+ ...(evidence ? {} : { error: "no screenshot produced" }),
168
+ })
169
+ } catch (error) {
170
+ reports.push({ id: c.id, status: "failed", error: String(error instanceof Error ? error.message : error) })
171
+ }
172
+ }
173
+ } finally {
174
+ restoreResult = await restoreConfig(serial, snapshot)
175
+ if (!restoreResult.ok) warnings.push(`device settings NOT fully restored — actual after restore: ${JSON.stringify(restoreResult.actualAfterRestore)} problems: ${restoreResult.problems.join("; ")}`)
176
+ }
177
+ return {
178
+ status: signal?.aborted ? "blocked" : "completed",
179
+ serial,
180
+ snapshotBefore: snapshot,
181
+ reports,
182
+ // audit P4-4: restore travels as a real envelope field, not an array property
183
+ // (reports.restore was dropped by JSON serialization)
184
+ restore: restoreResult,
185
+ warnings,
186
+ limitations: ["theme light/dark and orientation rows are app-owned capabilities — this runner covers size/density/font-scale only", "font scale read back from the platform; no px↔sp formula applied"],
187
+ }
188
+ }
189
+
190
+ /** Risk-based starter matrix for a phone app from the device's actual state. */
191
+ export function starterCases(actual) {
192
+ const density = actual.densityDpi ?? 420
193
+ const d = density / 160
194
+ const heightPx = actual.heightPx ?? 2400
195
+ const widthPx = actual.widthPx ?? Math.round(411 * d)
196
+ const narrowW = Math.round(320 * d)
197
+ const narrowH = Math.round((heightPx * narrowW) / widthPx)
198
+ return [
199
+ { id: "narrow-320dp-fs1.0", dims: [narrowW, narrowH], density, fontScale: 1 },
200
+ { id: "base-fs2.0", dims: [widthPx, heightPx], density, fontScale: 2 },
201
+ ]
202
+ }