device-devtools-mcp 0.1.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.
Files changed (66) hide show
  1. package/AGENTS.md +327 -0
  2. package/LICENSE +21 -0
  3. package/README.md +491 -0
  4. package/VERSION +1 -0
  5. package/bin/device-devtools-mcp.js +106 -0
  6. package/bin/devicetools +315 -0
  7. package/config.example.json +63 -0
  8. package/integrations/agent-pointer.sh +48 -0
  9. package/integrations/claude/SKILL.md +8 -0
  10. package/integrations/cursor/devicetools.mdc +8 -0
  11. package/integrations/gemini/GEMINI.md +5 -0
  12. package/integrations/mcp/README.md +184 -0
  13. package/integrations/mcp/mcp.json +10 -0
  14. package/integrations/mcp/reference.sh +119 -0
  15. package/integrations/mcp/selftest.sh +158 -0
  16. package/integrations/mcp/server.sh +343 -0
  17. package/package.json +50 -0
  18. package/scripts/android/app.sh +241 -0
  19. package/scripts/android/back.sh +73 -0
  20. package/scripts/android/controls.sh +56 -0
  21. package/scripts/android/devices.sh +77 -0
  22. package/scripts/android/doctor.sh +253 -0
  23. package/scripts/android/lib.sh +699 -0
  24. package/scripts/android/logs.sh +289 -0
  25. package/scripts/android/permission.sh +119 -0
  26. package/scripts/android/settings.sh +63 -0
  27. package/scripts/android/setup.sh +97 -0
  28. package/scripts/android/tree.awk +166 -0
  29. package/scripts/android/tree.sh +127 -0
  30. package/scripts/android/type.sh +191 -0
  31. package/scripts/common/find.sh +95 -0
  32. package/scripts/common/key.sh +65 -0
  33. package/scripts/common/lib.sh +14 -0
  34. package/scripts/common/measure.sh +170 -0
  35. package/scripts/common/open.sh +131 -0
  36. package/scripts/common/screenshot.sh +102 -0
  37. package/scripts/common/scroll.sh +177 -0
  38. package/scripts/common/snapshot.sh +69 -0
  39. package/scripts/common/swipe.sh +171 -0
  40. package/scripts/common/tap.sh +226 -0
  41. package/scripts/common/wait.sh +212 -0
  42. package/scripts/common/waypoint.sh +141 -0
  43. package/scripts/dispatch.sh +21 -0
  44. package/scripts/flow.sh +266 -0
  45. package/scripts/init.sh +101 -0
  46. package/scripts/ios/app.sh +404 -0
  47. package/scripts/ios/back.sh +95 -0
  48. package/scripts/ios/controls.sh +68 -0
  49. package/scripts/ios/devices.sh +80 -0
  50. package/scripts/ios/doctor.sh +386 -0
  51. package/scripts/ios/lib.sh +864 -0
  52. package/scripts/ios/logs.sh +272 -0
  53. package/scripts/ios/permission.sh +108 -0
  54. package/scripts/ios/settings.sh +76 -0
  55. package/scripts/ios/setup.sh +175 -0
  56. package/scripts/ios/tree.sh +128 -0
  57. package/scripts/ios/type.sh +178 -0
  58. package/scripts/lib.sh +1032 -0
  59. package/scripts/links.tsv +44 -0
  60. package/scripts/relink.sh +121 -0
  61. package/scripts/run.sh +415 -0
  62. package/scripts/selftest.sh +1709 -0
  63. package/scripts/snapshot.awk +362 -0
  64. package/scripts/verify-npm-package.js +133 -0
  65. package/tests/fixtures/ios-contacts-list.expected +52 -0
  66. package/tests/fixtures/ios-contacts-list.rows +140 -0
@@ -0,0 +1,362 @@
1
+ # snapshot.awk — normalised rows in, a screen an agent can read out.
2
+ #
3
+ # awk -v ALL=0 -v GREP="" -v MINHIT=44 [-v MODE=store] -f snapshot.awk < rows
4
+ #
5
+ # Everything here is arithmetic over rectangles and tests over strings. There is
6
+ # no estimate anywhere in this file, and that is the rule it is written to keep:
7
+ # a warning that is wrong one time in five is a warning an agent learns to skip,
8
+ # which is worse than not having it. So "the text does not fit" is not computed
9
+ # from a character count — the only truncation reported is the one the operating
10
+ # system already performed and left an ellipsis to prove.
11
+ #
12
+ # ALL=1 show every element, no filter
13
+ # GREP=s case-insensitive substring of type, id, label or value
14
+ # MINHIT=n the minimum comfortable hit area, in points (44 iOS, 48 Android)
15
+ # MODE "display" (default) or "store", which emits the uid table instead
16
+ #
17
+ # Input, one element per line, twelve '|' separated fields, produced by each
18
+ # adapter's snapshot_rows:
19
+ #
20
+ # WINDOW|<w>|<h>|<orientation>
21
+ # <n>|<depth>|<parent-n>|<type>|<id>|<label>|<value>|<x>|<y>|<w>|<h>|<flags>
22
+ #
23
+ # exit 0 a screen was printed
24
+ # exit 4 the rows were empty, or nothing survived the filter
25
+
26
+ BEGIN {
27
+ FS = "|"
28
+ n = 0
29
+ if (MODE == "") MODE = "display"
30
+
31
+ # HOW MANY POINTS COUNT AS NOTHING.
32
+ #
33
+ # Both drivers report rectangles as floating point and this pipeline floors
34
+ # them, so each edge carries up to a point of error and any pair of edges up
35
+ # to two. A "1pt overlap" between two stacked lines of text is therefore not a
36
+ # layout bug, it is the arithmetic.
37
+ #
38
+ # Measured on a real, dense screen before this existed: a list of six
39
+ # contacts produced thirty-one warnings, of which twenty-three were overlaps
40
+ # of one to two points between text lines that are visibly not touching, and
41
+ # four were labels sitting four points outside a table cell that clips
42
+ # nothing. The eight that mattered — a 24x25 button, and the same button
43
+ # hanging eight points off the right edge — were buried in them.
44
+ #
45
+ # So anything at or below this is not reported. It is a floor on what this
46
+ # file will claim to have seen, not a judgement about what matters.
47
+ if (TOL == "") TOL = 2
48
+ # A byte table, because macOS awk has no ord() and its length()/substr() count
49
+ # bytes rather than characters. Every byte value matters: fold the non-ASCII
50
+ # ones together and two Vietnamese labels differing only in their diacritics
51
+ # would hash the same, which would report two screens as one and take change
52
+ # detection quietly out of service.
53
+ for (i = 1; i < 256; i++) ORD[sprintf("%c", i)] = i
54
+ }
55
+
56
+ $1 == "WINDOW" {
57
+ WW = $2 + 0; WH = $3 + 0; ORIENT = $4
58
+ next
59
+ }
60
+
61
+ $0 == "" { next }
62
+
63
+ {
64
+ n++
65
+ serial[n] = $1 + 0
66
+ depth[n] = $2 + 0
67
+ parent[n] = $3 + 0
68
+ type[n] = $4
69
+ id[n] = $5
70
+ label[n] = $6
71
+ value[n] = $7
72
+ x[n] = $8 + 0; y[n] = $9 + 0; w[n] = $10 + 0; h[n] = $11 + 0
73
+ flags[n] = $12
74
+ byserial[$1 + 0] = n
75
+ }
76
+
77
+ function has(f, s) { return index("," f ",", "," s ",") > 0 }
78
+ function best(i) { return label[i] != "" ? label[i] : (value[i] != "" ? value[i] : id[i]) }
79
+
80
+ # Covers the window, give or take the same two points everything else gets.
81
+ function fullscreen(i) {
82
+ if (WW <= 0 || WH <= 0) return 0
83
+ return (x[i] <= TOL && y[i] <= TOL \
84
+ && w[i] >= WW - TOL && h[i] >= WH - TOL)
85
+ }
86
+
87
+ # Overlap of two intervals, in points. Negative or zero means no overlap.
88
+ function span(a1, a2, b1, b2, lo, hi) {
89
+ lo = (a1 > b1) ? a1 : b1
90
+ hi = (a2 < b2) ? a2 : b2
91
+ return hi - lo
92
+ }
93
+
94
+ # An element is shown when it is visible AND it carries something to identify it
95
+ # by, or is something a person can act on. An invisible element with a label is
96
+ # noise; an unlabelled button is not, because a screen whose only three controls
97
+ # are unlabelled would otherwise read as having nothing to tap.
98
+ #
99
+ # --grep searches the whole tree, including what the filter hides, so it can
100
+ # find something that is present but off screen.
101
+ function shown(i, hay) {
102
+ if (GREP != "") {
103
+ hay = tolower(type[i] " " id[i] " " label[i] " " value[i])
104
+ return index(hay, tolower(GREP)) > 0
105
+ }
106
+ if (ALL == 1) return 1
107
+ if (!has(flags[i], "visible")) return 0
108
+ if (id[i] != "" || label[i] != "" || value[i] != "") return 1
109
+ return has(flags[i], "actionable")
110
+ }
111
+
112
+ # A stable, short identity for the screen: the set of (type,label) pairs of the
113
+ # shown elements, folded into sixteen bits.
114
+ #
115
+ # NOT A CRYPTOGRAPHIC HASH AND NOT CLAIMING TO BE ONE. It answers "is this the
116
+ # same screen as the one before", and a collision costs one unnecessary re-read.
117
+ # XCUITest and UiAutomator publish no view-controller or activity class, so
118
+ # there is no name to use instead; this is the honest substitute and it claims
119
+ # less than a class name would.
120
+ function screenhash( i, s, acc, j) {
121
+ s = ""
122
+ for (i = 1; i <= n; i++) if (shown(i)) s = s type[i] "\037" label[i] "\036"
123
+ acc = 5381
124
+ for (j = 1; j <= length(s); j++)
125
+ acc = (acc * 33 + ORD[substr(s, j, 1)]) % 65536
126
+ return sprintf("%04x", acc)
127
+ }
128
+
129
+ # The screen's name: a navigation bar's label if there is one, otherwise the
130
+ # topmost static text. Both are guesses about which string a person would call
131
+ # the screen, and neither is load-bearing — the hash is what gets compared.
132
+ function screenname( i, bestY, name) {
133
+ for (i = 1; i <= n; i++)
134
+ if (type[i] ~ /NavigationBar|Toolbar|ActionBar/ && best(i) != "") return best(i)
135
+ bestY = 1000000; name = ""
136
+ for (i = 1; i <= n; i++) {
137
+ if (!shown(i) || best(i) == "") continue
138
+ if (type[i] !~ /StaticText|TextView|Label/) continue
139
+ if (y[i] < bestY) { bestY = y[i]; name = best(i) }
140
+ }
141
+ return name
142
+ }
143
+
144
+ END {
145
+ if (n == 0) {
146
+ print "the driver returned no elements" > "/dev/stderr"
147
+ exit 4
148
+ }
149
+
150
+ uid = 0
151
+ for (i = 1; i <= n; i++) {
152
+ if (!shown(i)) continue
153
+ uid++
154
+ uidof[i] = uid
155
+ order[uid] = i
156
+ }
157
+ if (uid == 0) {
158
+ print "nothing matched" > "/dev/stderr"
159
+ exit 4
160
+ }
161
+
162
+ # The uid table, for whoever has to resolve one of these numbers later. Same
163
+ # numbering as the display, by construction rather than by agreement: both
164
+ # come from the loop above.
165
+ #
166
+ # SEPARATED BY '|', NOT BY A TAB, and that is not a style choice. Two of these
167
+ # fields — the identifier and the value — are routinely empty, and bash `read`
168
+ # treats runs of a WHITESPACE delimiter as one separator. A tab is whitespace,
169
+ # so two empty fields in a row collapse and every field after them shifts
170
+ # left: the label ends up holding an x coordinate. A pipe is not whitespace,
171
+ # so empty fields survive, and the adapters have already replaced any pipe
172
+ # inside application copy with a slash.
173
+ if (MODE == "store") {
174
+ for (u = 1; u <= uid; u++) {
175
+ i = order[u]
176
+ printf "%d|%d|%s|%s|%s|%s|%d|%d|%d|%d\n",
177
+ u, serial[i], type[i], id[i], label[i], value[i], x[i], y[i], w[i], h[i]
178
+ }
179
+ exit 0
180
+ }
181
+
182
+ # The status bar's height, from the status bar. Zero when the tree has none,
183
+ # which is the honest answer for a full-screen app.
184
+ SBH = 0
185
+ for (i = 1; i <= n; i++)
186
+ if (type[i] ~ /StatusBar/ && y[i] <= TOL && h[i] > SBH) SBH = h[i]
187
+
188
+ nm = screenname()
189
+ if (nm != "") printf "SCREEN \"%s\" #%s %dx%d %s\n\n", nm, screenhash(), WW, WH, ORIENT
190
+ else printf "SCREEN #%s %dx%d %s\n\n", screenhash(), WW, WH, ORIENT
191
+
192
+ for (u = 1; u <= uid; u++) {
193
+ i = order[u]
194
+
195
+ # THE IDENTIFIER IS PRINTED WHEN THERE IS ONE, AND SEPARATELY FROM THE TEXT.
196
+ #
197
+ # --grep has always searched identifiers, so a screen could be found by an
198
+ # identifier the display never showed — the data was here and the line did
199
+ # not carry it. It is also the one selector that survives a change of copy
200
+ # and a change of language, which makes it the one anybody writing a flow
201
+ # should reach for first. Shown as #id, beside the words rather than instead
202
+ # of them, because a name and a caption answer different questions.
203
+ #
204
+ # best() is left alone deliberately: it feeds the screen hash and the screen
205
+ # name, and changing what those fold over would silently invalidate every
206
+ # waypoint recorded before today.
207
+ line = sprintf("[%d] %s", u, type[i])
208
+ if (id[i] != "") line = line sprintf(" #%s", id[i])
209
+ cap = (label[i] != "" ? label[i] : value[i])
210
+ if (cap != "") line = line sprintf(" \"%s\"", cap)
211
+ line = line sprintf(" (%d,%d) %dx%d", x[i], y[i], w[i], h[i])
212
+ if (has(flags[i], "actionable"))
213
+ line = line (has(flags[i], "enabled") ? " enabled" : " disabled")
214
+ if (type[i] ~ /TextField|EditText|SearchField/ && value[i] == "")
215
+ line = line " empty"
216
+ print line
217
+
218
+ # --- the warnings ---------------------------------------------------------
219
+ #
220
+ # Collected, not printed here. See the note above the LAYOUT block below.
221
+
222
+ # Zero size, but carrying text somebody meant to be read.
223
+ if ((w[i] == 0 || h[i] == 0) && best(i) != "")
224
+ warn(u, "zero size, but it carries text")
225
+
226
+ # Off the window, wholly or partly.
227
+ if (WW > 0 && WH > 0) {
228
+ if (x[i] >= WW || y[i] >= WH || x[i] + w[i] <= 0 || y[i] + h[i] <= 0) {
229
+ warn(u, "off screen")
230
+ } else {
231
+ over = 0
232
+ if (x[i] + w[i] > WW) over = x[i] + w[i] - WW
233
+ if (y[i] + h[i] > WH && y[i] + h[i] - WH > over) over = y[i] + h[i] - WH
234
+ if (x[i] < 0 && -x[i] > over) over = -x[i]
235
+ if (y[i] < 0 && -y[i] > over) over = -y[i]
236
+ if (over > TOL) warn(u, sprintf("partly off screen by %dpt", over))
237
+ }
238
+ }
239
+
240
+ # Outside the parent.
241
+ p = byserial[parent[i]]
242
+ if (p != "" && p > 0 && w[p] > 0 && h[p] > 0) {
243
+ esc = 0
244
+ if (x[i] < x[p]) esc = x[p] - x[i]
245
+ if (x[i] + w[i] > x[p] + w[p] && x[i] + w[i] - (x[p] + w[p]) > esc)
246
+ esc = x[i] + w[i] - (x[p] + w[p])
247
+ if (y[i] < y[p] && y[p] - y[i] > esc) esc = y[p] - y[i]
248
+ if (y[i] + h[i] > y[p] + h[p] && y[i] + h[i] - (y[p] + h[p]) > esc)
249
+ esc = y[i] + h[i] - (y[p] + h[p])
250
+ if (esc > TOL) warn(u, sprintf("outside its parent by %dpt", esc))
251
+ }
252
+
253
+ # Overlapping a shown sibling. Siblings only: a child overlapping its parent
254
+ # is what a child does. The worst overlap is the one reported — which
255
+ # sibling it is comes from `measure`, whose job is exactly that question.
256
+ worst = 0
257
+ for (v = 1; v < u; v++) {
258
+ j = order[v]
259
+ if (parent[j] != parent[i]) continue
260
+ # TWO FULL-SCREEN LAYERS ARE NOT AN OVERLAP, THEY ARE A STACK.
261
+ #
262
+ # --all shows the containers, and a screen is built out of several boxes
263
+ # that each cover the whole window. Reported as "overlaps a sibling by
264
+ # 428pt — [7] [8] [10] and 9 more", which is a true sentence about
265
+ # something no layout has ever got wrong.
266
+ if (fullscreen(i) && fullscreen(j)) continue
267
+ ox = span(x[i], x[i] + w[i], x[j], x[j] + w[j])
268
+ oy = span(y[i], y[i] + h[i], y[j], y[j] + h[j])
269
+ if (ox > TOL && oy > TOL) {
270
+ d = (ox < oy ? ox : oy)
271
+ if (d > worst) worst = d
272
+ }
273
+ }
274
+ if (worst > 0) warn(u, sprintf("overlaps a sibling by %dpt", worst))
275
+
276
+ # THE POINT A TAP LANDS ON, NOT THE BOX IT BELONGS TO.
277
+ #
278
+ # Every tap goes to the centre of the rectangle. Spotlight's search field
279
+ # measures (32,4) 364x48, which looks generous — but its centre is at y=28,
280
+ # underneath a status bar 47pt tall, so the tap lands on the clock and the
281
+ # keyboard never appears. The element is not too small and is not off
282
+ # screen; the one number that matters is simply covered.
283
+ #
284
+ # Only reported when the tree actually carries a StatusBar, because its
285
+ # height is measured rather than assumed.
286
+ if (has(flags[i], "actionable") && SBH > 0 && y[i] + h[i] / 2 < SBH)
287
+ warn(u, sprintf("tappable, but its centre is under the %dpt status bar", SBH))
288
+
289
+ # A hit area smaller than a fingertip. The size itself is already on the
290
+ # element's own line, so the warning does not repeat it.
291
+ if (has(flags[i], "actionable") && w[i] > 0 && h[i] > 0 &&
292
+ (w[i] < MINHIT || h[i] < MINHIT))
293
+ warn(u, sprintf("hit area below the %dpt minimum", MINHIT))
294
+
295
+ # A CONTROL WITH NO IDENTIFIER, WHICH IS A FAULT IN THE APP AND NOT ON THE
296
+ # SCREEN.
297
+ #
298
+ # Every other warning here is about geometry. This one is about whether the
299
+ # thing can be named at all: with no identifier the only way to reach it is
300
+ # its label, and a label changes when marketing rewrites the button or the
301
+ # app is read in another language. Nobody else is standing in the right
302
+ # place to say so — the tree is in front of us and it is not in front of the
303
+ # person who wrote the view.
304
+ #
305
+ # The keyboard is excluded. Its keys are the operating system's and telling
306
+ # an application developer to add identifiers to them is advice they cannot
307
+ # take.
308
+ if (has(flags[i], "actionable") && id[i] == "" && type[i] !~ /^(Key|Keyboard)$/)
309
+ warn(u, "no identifier: a selector has to use the label, which changes with the copy")
310
+
311
+ # The OS already truncated the string, and said so in the string.
312
+ s = best(i)
313
+ if (s ~ /…$/ || s ~ /\.\.\.$/)
314
+ warn(u, "the OS truncated this text with an ellipsis")
315
+ }
316
+
317
+ # --- what is wrong with this screen -----------------------------------------
318
+ #
319
+ # GROUPED BY MESSAGE, NOT PRINTED BESIDE EACH ELEMENT, AND THAT IS ABOUT COST.
320
+ #
321
+ # Measured on a real screen: a contact list of six identical rows produced the
322
+ # same three warnings once per row. Inline, that is forty-nine lines saying
323
+ # eight things, and an agent that reads forty-nine warnings on every screen
324
+ # stops reading warnings. Grouped, it is four lines and the uids are still
325
+ # there to act on.
326
+ #
327
+ # Nothing is dropped silently. When a group has more members than fit, the
328
+ # line says how many more.
329
+ if (nw > 0) {
330
+ printf "\n"
331
+ for (k = 1; k <= nw; k++) {
332
+ list = ""; shown_n = 0
333
+ cnt = split(wuids[k], parts, " ")
334
+ for (q = 1; q <= cnt && q <= 8; q++) {
335
+ list = list sprintf("%s[%s]", (list == "" ? "" : " "), parts[q])
336
+ shown_n++
337
+ }
338
+ if (cnt > shown_n) list = list sprintf(" and %d more", cnt - shown_n)
339
+ printf "⚠ %s — %s\n", wmsg[k], list
340
+ }
341
+ }
342
+ }
343
+
344
+ # warn <uid> <message> — remember it; the END block groups and prints.
345
+ #
346
+ # SILENT UNDER --grep, AND THAT IS THE POINT OF --grep.
347
+ #
348
+ # Without a filter this is an audit of the screen in front of you, and every
349
+ # warning is about something you can see. --grep searches the whole tree
350
+ # including what is off screen, so a home-screen icon sitting on another page
351
+ # comes back "zero size, but it carries text" and "off screen" — both true,
352
+ # neither a layout fault, and both noise in front of the one line the caller
353
+ # asked for. Reported by an agent that grepped for an icon and got two warnings
354
+ # about the phone not currently showing it.
355
+ function warn(u, msg, k) {
356
+ if (GREP != "") return
357
+ for (k = 1; k <= nw; k++)
358
+ if (wmsg[k] == msg) { wuids[k] = wuids[k] " " u; return }
359
+ nw++
360
+ wmsg[nw] = msg
361
+ wuids[nw] = u
362
+ }
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+ // verify-npm-package.js — pack it, install it somewhere else, and use it.
3
+ //
4
+ // npm run verify-npm-package
5
+ //
6
+ // WHAT THIS IS FOR
7
+ //
8
+ // `npm pack` silently dropped all forty-four symlinks in scripts/ — the entire
9
+ // verb surface — and produced a tarball that installs cleanly, starts cleanly,
10
+ // and answers `unknown tool` to every one of the twenty-one tools. Nothing
11
+ // about that looks like a failure until somebody tries to tap something.
12
+ //
13
+ // A published package is the one artefact in this repository that neither
14
+ // self-test covers: they both run against the working tree, where the symlinks
15
+ // are simply there. So this builds the real thing, unpacks it into a directory
16
+ // with no relation to this one, and drives it: the handshake, the tool list,
17
+ // and a verb — through the same `npx`-shaped entry point a user would get.
18
+ //
19
+ // It does not publish anything and needs no network.
20
+ //
21
+ // exit 0 the package works where it lands
22
+ // exit 1 it does not, and the line above says which part
23
+
24
+ 'use strict';
25
+
26
+ const { execFileSync, spawnSync } = require('node:child_process');
27
+ const { mkdtempSync, rmSync, readdirSync, existsSync, writeFileSync, copyFileSync } = require('node:fs');
28
+ const { join } = require('node:path');
29
+ const { tmpdir } = require('node:os');
30
+
31
+ const root = join(__dirname, '..');
32
+ let failures = 0;
33
+
34
+ function ok(what) { console.log(`OK ${what}`); }
35
+ function bad(what, detail) { console.log(`FAIL ${what} — ${detail}`); failures++; }
36
+ function is(what, got, want) { got === want ? ok(what) : bad(what, `expected [${want}], got [${got}]`); }
37
+
38
+ const work = mkdtempSync(join(tmpdir(), 'devicetools-pack-'));
39
+ process.on('exit', () => rmSync(work, { recursive: true, force: true }));
40
+
41
+ // --- build and unpack --------------------------------------------------------
42
+
43
+ execFileSync('npm', ['pack', '--pack-destination', work], { cwd: root, stdio: 'pipe' });
44
+ const tgz = readdirSync(work).find((f) => f.endsWith('.tgz'));
45
+ if (!tgz) { bad('npm pack produces a tarball', 'no .tgz'); process.exit(1); }
46
+ ok(`npm pack produces ${tgz}`);
47
+
48
+ execFileSync('tar', ['xzf', join(work, tgz), '-C', work], { stdio: 'pipe' });
49
+ const pkg = join(work, 'package');
50
+
51
+ // --- the thing that was actually broken --------------------------------------
52
+
53
+ const links = join(pkg, 'scripts', 'links.tsv');
54
+ existsSync(links)
55
+ ? ok('the link manifest is in the tarball')
56
+ : bad('the link manifest is in the tarball', 'scripts/links.tsv is missing');
57
+
58
+ // Before anything runs, the verbs are absent — that is npm, not a mistake, and
59
+ // asserting it here means this check still means something on the day npm
60
+ // starts carrying symlinks.
61
+ existsSync(join(pkg, 'scripts', 'tap.sh'))
62
+ ? ok('npm now carries symlinks (the restore below is redundant, and harmless)')
63
+ : ok('npm dropped the symlinks, as expected — the restore is what fixes it');
64
+
65
+ // --- drive it, the way npx would ---------------------------------------------
66
+ //
67
+ // A config it can find, in a directory that is not this repository, so nothing
68
+ // can quietly fall back to the checkout being verified.
69
+ const project = join(work, 'project');
70
+ execFileSync('mkdir', ['-p', project]);
71
+ writeFileSync(join(project, '.devicetools'), 'devicetools-project 1\n');
72
+ copyFileSync(join(root, 'config.example.json'), join(project, 'config.json'));
73
+
74
+ const entry = join(pkg, 'bin', 'device-devtools-mcp.js');
75
+ const env = { ...process.env, DEVICETOOLS_CONFIG: join(project, 'config.json') };
76
+
77
+ const rpc = [
78
+ '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26"}}',
79
+ '{"jsonrpc":"2.0","id":2,"method":"tools/list"}',
80
+ '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"waypoint","arguments":{"action":"list"}}}',
81
+ ].join('\n') + '\n';
82
+
83
+ const server = spawnSync('node', [entry], { input: rpc, env, encoding: 'utf8' });
84
+ const replies = server.stdout.trim().split('\n').filter(Boolean).map((l) => {
85
+ try { return JSON.parse(l); } catch { return null; }
86
+ }).filter(Boolean);
87
+
88
+ const listed = replies.find((r) => r.id === 2);
89
+ const names = listed?.result?.tools?.map((t) => t.name).sort() ?? [];
90
+
91
+ is('the server answers the handshake',
92
+ replies.find((r) => r.id === 1)?.result?.protocolVersion, '2025-03-26');
93
+ is('every tool is on the list', String(names.length), '21');
94
+
95
+ // THE FAILURE THIS FILE EXISTS FOR. A tool whose script is missing is still
96
+ // advertised by tools/list — the list is built from a table, the dispatch is a
97
+ // file lookup — so calling one is the only way to find out.
98
+ const called = replies.find((r) => r.id === 3);
99
+ if (called?.error) {
100
+ bad('a verb runs from the unpacked package', `${called.error.message} — the verb surface did not survive`);
101
+ } else if (called?.result?.isError) {
102
+ bad('a verb runs from the unpacked package', called.result.content?.[0]?.text?.split('\n')[0] ?? 'error');
103
+ } else {
104
+ ok('a verb runs from the unpacked package');
105
+ }
106
+
107
+ // The command line, through the same entry point.
108
+ const cli = spawnSync('node', [entry, 'flow', 'list'], { env, encoding: 'utf8' });
109
+ cli.status === 0
110
+ ? ok('the CLI runs from the unpacked package')
111
+ : bad('the CLI runs from the unpacked package', (cli.stderr || cli.stdout).trim().split('\n')[0]);
112
+
113
+ // And the restore was not a one-off: the tree it left behind must be complete.
114
+ const check = spawnSync('/bin/bash', [join(pkg, 'scripts', 'relink.sh'), '--check'], { encoding: 'utf8' });
115
+ check.status === 0
116
+ ? ok('the restored tree is complete')
117
+ : bad('the restored tree is complete', check.stderr.trim());
118
+
119
+ // --- nothing private, and nothing this machine's -----------------------------
120
+ //
121
+ // The package is the artefact that goes public. A grep over what is actually in
122
+ // the tarball is cheaper than remembering.
123
+ const listing = execFileSync('tar', ['tzf', join(work, tgz)], { encoding: 'utf8' });
124
+ const forbidden = ['config.json\n', '.state/', '.runs/', '.git/'];
125
+ const leaked = forbidden.filter((f) => listing.includes(`package/${f}`));
126
+ leaked.length === 0
127
+ ? ok('no config, state or run artefacts are in the tarball')
128
+ : bad('no config, state or run artefacts are in the tarball', leaked.join(' '));
129
+
130
+ console.log(failures === 0
131
+ ? 'RESULT PASS npm package'
132
+ : `RESULT FAIL npm package — ${failures} check(s)`);
133
+ process.exit(failures === 0 ? 0 : 1);
@@ -0,0 +1,52 @@
1
+ SCREEN "Liên hệ" #9b41 428x926 portrait
2
+
3
+ [1] Application "DemoApp" (0,0) 428x926
4
+ [2] NavigationBar #Liên hệ (0,47) 428x101
5
+ [3] Button (20,51) 36x36 enabled
6
+ [4] StaticText "Liên hệ" (19,108) 116x31
7
+ [5] StaticText "Liên hệ gần đây" (24,224) 121x36
8
+ [6] StaticText "Liên hệ nhóm" (153,224) 122x36
9
+ [7] StaticText "Liên hệ công việc" (283,224) 121x36
10
+ [8] StaticText "Danh sách đã lưu" (23,303) 126x18
11
+ [9] StaticText "Thêm mới" (315,303) 65x18
12
+ [10] TextField "Tìm kiếm" (72,368) 316x25 enabled
13
+ [11] StaticText "Tất cả" (36,432) 39x19
14
+ [12] StaticText "Yêu thích" (107,432) 60x19
15
+ [13] StaticText "Mã số" (199,432) 82x19
16
+ [14] StaticText "Nhãn" (312,432) 44x19
17
+ [15] Other "Vertical scroll bar, 1 pages" (387,424) 30x35
18
+ [16] StaticText "000111222" (68,520) 356x19
19
+ [17] StaticText "NGUOI DUNG A" (68,500) 336x21
20
+ [18] StaticText "Acme Demo" (68,482) 356x19
21
+ [19] Button "icMore" (412,498) 24x25 enabled
22
+ [20] StaticText "000000******0000" (68,600) 356x18
23
+ [21] StaticText "NGUOI DUNG C DEMO BA XX" (68,579) 336x22
24
+ [22] StaticText "Acme Demo" (68,562) 356x18
25
+ [23] Button "icMore" (412,578) 24x25 enabled
26
+ [24] StaticText "000000******0000" (68,679) 356x19
27
+ [25] StaticText "NGUOI DUNG E DEMO HAI TEST" (68,659) 336x21
28
+ [26] StaticText "Epsilon" (68,641) 356x19
29
+ [27] Button "icMore" (412,657) 24x25 enabled
30
+ [28] StaticText "0000000000000" (68,759) 356x19
31
+ [29] StaticText "NGUOI DUNG B DEMO MOT" (68,739) 356x21
32
+ [30] StaticText "Delta" (68,721) 356x19
33
+ [31] Button "icMore" (412,737) 24x25 enabled
34
+ [32] StaticText "0000000000" (68,839) 356x18
35
+ [33] StaticText "NGUOI DUNG H" (68,818) 356x22
36
+ [34] StaticText "Gamma" (68,801) 356x18
37
+ [35] Button "icMore" (412,817) 24x25 enabled
38
+ [36] StaticText "000000******0000" (68,918) 356x19
39
+ [37] StaticText "NGUOI DUNG D DEMO" (68,898) 356x21
40
+ [38] StaticText "Alpha" (68,880) 356x19
41
+ [39] Button "icMore" (412,896) 24x25 enabled
42
+ [40] Other "Vertical scroll bar, 2 pages" (395,148) 30x730
43
+ [41] Other "Horizontal scroll bar, 1 pages" (47,893) 334x30
44
+
45
+ ⚠ hit area below the 44pt minimum — [3] [10] [19] [23] [27] [31] [35] [39]
46
+ ⚠ no identifier: a selector has to use the label, which changes with the copy — [3] [10] [19] [23] [27] [31] [35] [39]
47
+ ⚠ outside its parent by 4pt — [16] [18] [20] [22] [24] [26] [28] [29] and 7 more
48
+ ⚠ partly off screen by 8pt — [19] [23] [27] [31] [35] [39]
49
+ ⚠ outside its parent by 16pt — [19] [23] [27] [31] [35] [39]
50
+ ⚠ overlaps a sibling by 3pt — [19] [23] [27]
51
+ ⚠ overlaps a sibling by 12pt — [31] [35] [39]
52
+ ⚠ partly off screen by 11pt — [36]