@mmerterden/multi-agent-toolkit-mcp 3.0.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/CHANGELOG.md +871 -0
- package/LICENSE +21 -0
- package/README.md +358 -0
- package/README.tr.md +358 -0
- package/index.js +1725 -0
- package/package.json +89 -0
- package/tools/crash-logs/index.js +29 -0
- package/tools/design-check/content-cardinality.js +204 -0
- package/tools/design-check/geometry.js +140 -0
- package/tools/design-check/index.js +219 -0
- package/tools/design-check/mock-detect.js +213 -0
- package/tools/design-check/report.js +596 -0
- package/tools/design-check/scan.js +91 -0
- package/tools/design-check/scenario-inventory.js +598 -0
- package/tools/design-check/visual-compare.js +961 -0
- package/tools/ios-app-store-audit/context.js +181 -0
- package/tools/ios-app-store-audit/data/apple-required-sdks.json +32 -0
- package/tools/ios-app-store-audit/data/debug-tools-blocklist.json +133 -0
- package/tools/ios-app-store-audit/index.js +164 -0
- package/tools/ios-app-store-audit/models.js +57 -0
- package/tools/ios-app-store-audit/rules/asset-validation.js +72 -0
- package/tools/ios-app-store-audit/rules/binary-size.js +70 -0
- package/tools/ios-app-store-audit/rules/code-signing.js +95 -0
- package/tools/ios-app-store-audit/rules/dead-reference.js +131 -0
- package/tools/ios-app-store-audit/rules/debug-tool-leak.js +185 -0
- package/tools/ios-app-store-audit/rules/duplicate-resource.js +130 -0
- package/tools/ios-app-store-audit/rules/embedded-sdk.js +126 -0
- package/tools/ios-app-store-audit/rules/entitlement.js +105 -0
- package/tools/ios-app-store-audit/rules/extension-signing.js +105 -0
- package/tools/ios-app-store-audit/rules/info-plist.js +158 -0
- package/tools/ios-app-store-audit/rules/ipv6-compliance.js +101 -0
- package/tools/ios-app-store-audit/rules/privacy-manifest.js +121 -0
- package/tools/ios-app-store-audit/rules/production-hygiene.js +237 -0
- package/tools/ios-app-store-audit/rules/provisioning-profile.js +127 -0
- package/tools/ios-app-store-audit/rules/required-reason-api.js +123 -0
- package/tools/ios-app-store-audit/rules/sdk-floor.js +104 -0
- package/tools/ios-app-store-audit/rules/swift-abi.js +64 -0
- package/tools/ios-app-store-audit/rules/team-id.js +62 -0
- package/tools/ios-testflight/index.js +489 -0
- package/tools/ui-inspect/index.js +57 -0
- package/ui-tree-dumper.swift +122 -0
package/package.json
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mmerterden/multi-agent-toolkit-mcp",
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"description": "MCP server for iOS Simulator, Android Emulator and headless web control. 83 tools: device automation (tap/swipe/type), accessibility audits, visual diff, crash logs, App Store / Play Store pre-submission compliance. Runs standalone over stdio with any MCP client.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"multi-agent-toolkit-mcp": "index.js",
|
|
9
|
+
"dev-toolkit-mcp": "index.js"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"start": "node index.js",
|
|
13
|
+
"test": "node --test tools/design-check/__tests__/design-check.test.mjs tools/design-check/__tests__/plan-determinism.test.mjs tools/ios-app-store-audit/__tests__/app-store-audit.test.mjs tools/ios-testflight/__tests__/testflight.test.mjs tools/ui-inspect/__tests__/ui-inspect.test.mjs tools/crash-logs/__tests__/crash-logs.test.mjs __tests__/server-tools.test.mjs __tests__/injection.test.mjs",
|
|
14
|
+
"gates": "bash scripts/gates.sh"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"mcp",
|
|
18
|
+
"model-context-protocol",
|
|
19
|
+
"ios",
|
|
20
|
+
"android",
|
|
21
|
+
"simulator",
|
|
22
|
+
"emulator",
|
|
23
|
+
"xcrun",
|
|
24
|
+
"simctl",
|
|
25
|
+
"adb",
|
|
26
|
+
"testing",
|
|
27
|
+
"screenshot",
|
|
28
|
+
"accessibility",
|
|
29
|
+
"ui-testing",
|
|
30
|
+
"mobile",
|
|
31
|
+
"claude",
|
|
32
|
+
"claude-code",
|
|
33
|
+
"copilot",
|
|
34
|
+
"cursor",
|
|
35
|
+
"antigravity",
|
|
36
|
+
"playwright",
|
|
37
|
+
"web-automation",
|
|
38
|
+
"browser-automation",
|
|
39
|
+
"agent-dsl",
|
|
40
|
+
"batch-execution",
|
|
41
|
+
"ios-simulator",
|
|
42
|
+
"android-emulator",
|
|
43
|
+
"appium-alternative",
|
|
44
|
+
"visual-regression",
|
|
45
|
+
"app-store",
|
|
46
|
+
"testflight",
|
|
47
|
+
"device-automation",
|
|
48
|
+
"xcode"
|
|
49
|
+
],
|
|
50
|
+
"author": "Mert Erden",
|
|
51
|
+
"license": "MIT",
|
|
52
|
+
"publishConfig": {
|
|
53
|
+
"registry": "https://registry.npmjs.org",
|
|
54
|
+
"access": "public"
|
|
55
|
+
},
|
|
56
|
+
"repository": {
|
|
57
|
+
"type": "git",
|
|
58
|
+
"url": "git+https://github.com/mmerterden/multi-agent-toolkit-mcp.git"
|
|
59
|
+
},
|
|
60
|
+
"homepage": "https://github.com/mmerterden/multi-agent-toolkit-mcp#readme",
|
|
61
|
+
"bugs": {
|
|
62
|
+
"url": "https://github.com/mmerterden/multi-agent-toolkit-mcp/issues"
|
|
63
|
+
},
|
|
64
|
+
"engines": {
|
|
65
|
+
"node": ">=20.0.0"
|
|
66
|
+
},
|
|
67
|
+
"files": [
|
|
68
|
+
"index.js",
|
|
69
|
+
"ui-tree-dumper.swift",
|
|
70
|
+
"tools/",
|
|
71
|
+
"!tools/**/__tests__",
|
|
72
|
+
"README.md",
|
|
73
|
+
"CHANGELOG.md",
|
|
74
|
+
"LICENSE"
|
|
75
|
+
],
|
|
76
|
+
"dependencies": {
|
|
77
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
78
|
+
"pixelmatch": "^7.2.0",
|
|
79
|
+
"pngjs": "^7.0.0"
|
|
80
|
+
},
|
|
81
|
+
"peerDependencies": {
|
|
82
|
+
"playwright": ">=1.60.0 <2"
|
|
83
|
+
},
|
|
84
|
+
"peerDependenciesMeta": {
|
|
85
|
+
"playwright": {
|
|
86
|
+
"optional": true
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Selection logic for macOS DiagnosticReports crash files.
|
|
3
|
+
*
|
|
4
|
+
* Pure so it can be tested without a filesystem: the tool layer reads the
|
|
5
|
+
* directory and hands the entries here.
|
|
6
|
+
*
|
|
7
|
+
* @module tools/crash-logs
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const CRASH_EXTENSIONS = [".ips", ".crash", ".panic", ".hang", ".diag"];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {Array<{name: string, mtimeMs: number, size: number}>} entries directory listing with stats
|
|
14
|
+
* @param {object} [opts]
|
|
15
|
+
* @param {string} [opts.app] only files whose name contains this substring (case-insensitive)
|
|
16
|
+
* @param {number} [opts.sinceMs] only files modified at or after this epoch ms
|
|
17
|
+
* @param {number} [opts.limit] max results (default 20)
|
|
18
|
+
* @returns {Array<{name: string, mtimeMs: number, size: number}>} newest first
|
|
19
|
+
*/
|
|
20
|
+
export function selectCrashReports(entries, opts = {}) {
|
|
21
|
+
const { app, sinceMs, limit = 20 } = opts;
|
|
22
|
+
const needle = app ? String(app).toLowerCase() : null;
|
|
23
|
+
return entries
|
|
24
|
+
.filter((e) => CRASH_EXTENSIONS.some((ext) => e.name.endsWith(ext)))
|
|
25
|
+
.filter((e) => (needle ? e.name.toLowerCase().includes(needle) : true))
|
|
26
|
+
.filter((e) => (sinceMs !== undefined ? e.mtimeMs >= sinceMs : true))
|
|
27
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
28
|
+
.slice(0, Math.max(0, limit));
|
|
29
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* content-cardinality.js - suppress the geometry noise a fixture-count
|
|
3
|
+
* difference creates, WITHOUT suppressing a real defect.
|
|
4
|
+
*
|
|
5
|
+
* The problem (docs/design-check-gaps.md section 1). A live capture is compared
|
|
6
|
+
* against a screen frame, and a screen frame carries whatever the mock fixture
|
|
7
|
+
* produced. A design showing 5 passenger rows against an app rendering 3 makes
|
|
8
|
+
* every container height and every downstream position differ for a reason that
|
|
9
|
+
* is not a defect. On a real 109-target module that noise dominated the report.
|
|
10
|
+
*
|
|
11
|
+
* Why this is written defensively. The dangerous direction is not noise, it is a
|
|
12
|
+
* hidden defect: demote too eagerly and the engine silently stops reporting real
|
|
13
|
+
* deviations, in a report whose whole purpose is to surface them, and the mistake
|
|
14
|
+
* is invisible precisely because the finding is gone. So the rules below are
|
|
15
|
+
* deliberately narrow, and every one of them is a test in
|
|
16
|
+
* `__tests__/design-check.test.mjs`:
|
|
17
|
+
*
|
|
18
|
+
* 1. Nothing is ever DELETED. A demoted finding stays in the report with
|
|
19
|
+
* `advisory: true` and a `rootCause`, so it renders under the existing
|
|
20
|
+
* "DO NOT CHANGE (advisory / not a defect)" group with its reason attached.
|
|
21
|
+
* `demoted` is returned as a count so a run can state what it demoted.
|
|
22
|
+
* 2. Only GEOMETRY findings are eligible. Copy, colour, typography, font-family
|
|
23
|
+
* and tap-target findings are never touched: a row-count difference cannot
|
|
24
|
+
* make a colour wrong or a string mistranslated, so it can never be the
|
|
25
|
+
* explanation for one.
|
|
26
|
+
* 3. Nothing is demoted unless a mismatch is actually detected, from evidence,
|
|
27
|
+
* in a repeated group. No mismatch -> the findings array is returned
|
|
28
|
+
* unchanged, same objects.
|
|
29
|
+
* 4. Only findings AT OR BELOW the mismatched group are eligible. A count
|
|
30
|
+
* difference shifts what follows it; it cannot explain a deviation above it.
|
|
31
|
+
* 5. A finding already marked advisory is left alone (idempotent), and a
|
|
32
|
+
* `verified` finding is never demoted.
|
|
33
|
+
*
|
|
34
|
+
* FIELD VALIDATION STILL PENDING: the acceptance criterion in the gaps doc ("a
|
|
35
|
+
* screen whose fixture returns a different row count produces zero geometry
|
|
36
|
+
* findings") needs the real module, a live capture and Figma design context to
|
|
37
|
+
* confirm end to end. What is proven here is the safe direction - the rules above
|
|
38
|
+
* hold on synthetic fixtures in both directions. Treat the tuning constants as
|
|
39
|
+
* provisional until a real run is measured.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** Finding categories a cardinality difference can plausibly explain. */
|
|
43
|
+
const GEOMETRY_CATEGORIES = new Set(["spacing", "size", "position", "alignment"]);
|
|
44
|
+
|
|
45
|
+
/** Finding types it can explain. Narrower than the category set on purpose. */
|
|
46
|
+
const GEOMETRY_TYPES = new Set(["spacing", "height", "inset", "gap", "position", "offset"]);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Types that must NEVER be demoted, whatever their category says. Listed
|
|
50
|
+
* explicitly rather than relying on the allow-list above, so a future category
|
|
51
|
+
* rename cannot quietly make one of these eligible.
|
|
52
|
+
*/
|
|
53
|
+
const NEVER_DEMOTE_TYPES = new Set([
|
|
54
|
+
"copy",
|
|
55
|
+
"color",
|
|
56
|
+
"colour",
|
|
57
|
+
"font-size",
|
|
58
|
+
"font-family",
|
|
59
|
+
"typography",
|
|
60
|
+
"tap-target",
|
|
61
|
+
"icon-size",
|
|
62
|
+
"missing",
|
|
63
|
+
"extra",
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Strip the varying tail from an element name so repeated siblings collapse to
|
|
68
|
+
* one group key: `passengerRow_2` / `passenger row 3` / `PassengerRow-4` all
|
|
69
|
+
* become `passengerrow`.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} name
|
|
72
|
+
* @returns {string}
|
|
73
|
+
*/
|
|
74
|
+
export function groupKey(name) {
|
|
75
|
+
return String(name || "")
|
|
76
|
+
.trim()
|
|
77
|
+
.toLowerCase()
|
|
78
|
+
.replace(/[\s_-]+/g, "")
|
|
79
|
+
.replace(/\d+$/, "")
|
|
80
|
+
.replace(/(item|row|cell|card)$/, "$1");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Count repeated siblings per group in a flat element list.
|
|
85
|
+
*
|
|
86
|
+
* @param {Array<object>} elements
|
|
87
|
+
* @returns {Map<string, {count: number, minY: number}>}
|
|
88
|
+
*/
|
|
89
|
+
function groupCounts(elements) {
|
|
90
|
+
const out = new Map();
|
|
91
|
+
for (const el of elements || []) {
|
|
92
|
+
const raw = el?.label ?? el?.text ?? el?.identifier ?? el?.name;
|
|
93
|
+
const key = groupKey(raw);
|
|
94
|
+
if (!key) continue;
|
|
95
|
+
const y = Number.isFinite(el?.y) ? el.y : Infinity;
|
|
96
|
+
const cur = out.get(key);
|
|
97
|
+
if (cur) {
|
|
98
|
+
cur.count += 1;
|
|
99
|
+
cur.minY = Math.min(cur.minY, y);
|
|
100
|
+
} else {
|
|
101
|
+
out.set(key, { count: 1, minY: y });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Detect a repeated-group cardinality difference between design and live.
|
|
109
|
+
*
|
|
110
|
+
* Requires a group to be genuinely REPEATED on at least one side (>= 2), because
|
|
111
|
+
* a 1-vs-0 difference is a missing element - a real defect, and exactly what this
|
|
112
|
+
* must not explain away.
|
|
113
|
+
*
|
|
114
|
+
* @param {Array<object>} figmaSpec
|
|
115
|
+
* @param {Array<object>} liveGeometry
|
|
116
|
+
* @param {{minRepeat?: number}} [opts]
|
|
117
|
+
* @returns {{mismatch: boolean, groups: Array<object>, boundaryY: number|null}}
|
|
118
|
+
*/
|
|
119
|
+
export function detectCardinalityMismatch(figmaSpec, liveGeometry, opts = {}) {
|
|
120
|
+
const minRepeat = opts.minRepeat ?? 2;
|
|
121
|
+
const design = groupCounts(figmaSpec);
|
|
122
|
+
const live = groupCounts(liveGeometry);
|
|
123
|
+
const groups = [];
|
|
124
|
+
|
|
125
|
+
for (const [key, d] of design) {
|
|
126
|
+
const l = live.get(key);
|
|
127
|
+
if (!l) continue; // absent entirely is a missing-element defect, not a count difference
|
|
128
|
+
if (d.count === l.count) continue;
|
|
129
|
+
if (Math.max(d.count, l.count) < minRepeat) continue; // 1 vs 0 is not a repeat
|
|
130
|
+
groups.push({
|
|
131
|
+
key,
|
|
132
|
+
designCount: d.count,
|
|
133
|
+
liveCount: l.count,
|
|
134
|
+
// The topmost occurrence on either side: everything at or below it can be
|
|
135
|
+
// shifted by the difference.
|
|
136
|
+
firstY: Math.min(d.minY, l.minY),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const boundaryY = groups.length
|
|
141
|
+
? Math.min(...groups.map((g) => (Number.isFinite(g.firstY) ? g.firstY : Infinity)))
|
|
142
|
+
: null;
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
mismatch: groups.length > 0,
|
|
146
|
+
groups,
|
|
147
|
+
boundaryY: Number.isFinite(boundaryY) ? boundaryY : null,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Is this finding the kind a cardinality difference could explain? */
|
|
152
|
+
function isGeometryFinding(f) {
|
|
153
|
+
const type = String(f?.type || "").toLowerCase();
|
|
154
|
+
const category = String(f?.category || "").toLowerCase();
|
|
155
|
+
if (NEVER_DEMOTE_TYPES.has(type)) return false;
|
|
156
|
+
return GEOMETRY_TYPES.has(type) || GEOMETRY_CATEGORIES.has(category);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The finding's vertical position, or null when it cannot be established. */
|
|
160
|
+
function findingY(f) {
|
|
161
|
+
const y = f?.region?.y ?? f?.y;
|
|
162
|
+
return Number.isFinite(y) ? y : null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Demote the geometry findings a detected cardinality difference explains.
|
|
167
|
+
*
|
|
168
|
+
* @param {Array<object>} findings
|
|
169
|
+
* @param {{mismatch: boolean, groups: Array<object>, boundaryY: number|null}} detection
|
|
170
|
+
* @returns {{findings: Array<object>, demoted: number, rootCause: string|null}}
|
|
171
|
+
*/
|
|
172
|
+
export function demoteCardinalityNoise(findings, detection) {
|
|
173
|
+
const list = Array.isArray(findings) ? findings : [];
|
|
174
|
+
if (!detection?.mismatch) return { findings: list, demoted: 0, rootCause: null };
|
|
175
|
+
|
|
176
|
+
const summary = detection.groups
|
|
177
|
+
.map((g) => `${g.key}: design ${g.designCount} vs live ${g.liveCount}`)
|
|
178
|
+
.join("; ");
|
|
179
|
+
const rootCause =
|
|
180
|
+
`content count differs between the design fixture and the running app (${summary}). ` +
|
|
181
|
+
`Container heights and everything below the first differing group shift for that reason, ` +
|
|
182
|
+
`not because the layout is wrong. Re-run against a fixture with matching counts to measure these.`;
|
|
183
|
+
|
|
184
|
+
const boundary = detection.boundaryY;
|
|
185
|
+
let demoted = 0;
|
|
186
|
+
|
|
187
|
+
const out = list.map((f) => {
|
|
188
|
+
if (!f || f.advisory || f.status === "verified") return f;
|
|
189
|
+
if (!isGeometryFinding(f)) return f;
|
|
190
|
+
|
|
191
|
+
// Rule 4: only at or below the first differing group. An unknown position
|
|
192
|
+
// is NOT assumed to be below it - unplaceable findings keep full severity,
|
|
193
|
+
// because demoting on a guess is the failure mode this module must not have.
|
|
194
|
+
if (boundary !== null) {
|
|
195
|
+
const y = findingY(f);
|
|
196
|
+
if (y === null || y < boundary) return f;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
demoted += 1;
|
|
200
|
+
return { ...f, advisory: true, rootCause, demotedBy: "content-cardinality" };
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
return { findings: out, demoted, rootCause };
|
|
204
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design_ui_geometry helpers - turn a live UI hierarchy into a flat list of
|
|
3
|
+
* element bounding boxes, so spacing/position deltas against a Figma spec can
|
|
4
|
+
* be measured in pixels. Generic: works off the iOS AX JSON dumper output and
|
|
5
|
+
* the Android uiautomator XML dump.
|
|
6
|
+
*
|
|
7
|
+
* Element shape: { role, label, text, identifier, x, y, w, h }
|
|
8
|
+
* Coordinates are in device points/pixels as reported by the platform.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// iOS: parse the ui-tree-dumper.swift JSON (nested AXNode with frame {x,y,w,h}).
|
|
12
|
+
export function flattenIosAxTree(json) {
|
|
13
|
+
let root;
|
|
14
|
+
try { root = typeof json === "string" ? JSON.parse(json) : json; } catch { return []; }
|
|
15
|
+
const out = [];
|
|
16
|
+
const visit = (node) => {
|
|
17
|
+
if (!node || typeof node !== "object") return;
|
|
18
|
+
const f = node.frame || {};
|
|
19
|
+
if (typeof f.w === "number" && typeof f.h === "number" && f.w > 0 && f.h > 0) {
|
|
20
|
+
out.push({
|
|
21
|
+
role: node.role || "",
|
|
22
|
+
label: node.title || node.description || "",
|
|
23
|
+
text: node.value || "",
|
|
24
|
+
identifier: node.identifier || "",
|
|
25
|
+
x: Math.round(f.x || 0), y: Math.round(f.y || 0),
|
|
26
|
+
w: Math.round(f.w), h: Math.round(f.h),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
(node.children || []).forEach(visit);
|
|
30
|
+
};
|
|
31
|
+
visit(root);
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// iOS via idb: parse `idb ui describe-all --json` output (array of AX elements,
|
|
36
|
+
// each with frame {x,y,width,height} in points). Works headless (no Simulator GUI).
|
|
37
|
+
export function flattenIdbDescribeAll(json) {
|
|
38
|
+
let arr;
|
|
39
|
+
try {
|
|
40
|
+
const raw = typeof json === "string" ? json.trim() : json;
|
|
41
|
+
arr = typeof raw === "string"
|
|
42
|
+
? (raw.startsWith("[") ? JSON.parse(raw) : raw.split("\n").filter(Boolean).map((l) => JSON.parse(l)))
|
|
43
|
+
: raw;
|
|
44
|
+
} catch { return []; }
|
|
45
|
+
if (!Array.isArray(arr)) return [];
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const e of arr) {
|
|
48
|
+
const f = e && e.frame;
|
|
49
|
+
if (!f || typeof f.width !== "number") continue;
|
|
50
|
+
if (f.width <= 0 || f.height <= 0) continue;
|
|
51
|
+
if ((e.type || e.role_description) === "Application") continue;
|
|
52
|
+
out.push({
|
|
53
|
+
role: e.role || e.type || "",
|
|
54
|
+
label: e.AXLabel || "",
|
|
55
|
+
text: e.AXValue || e.title || "",
|
|
56
|
+
identifier: e.AXUniqueId || "",
|
|
57
|
+
x: Math.round(f.x), y: Math.round(f.y),
|
|
58
|
+
w: Math.round(f.width), h: Math.round(f.height),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Android: parse uiautomator XML. bounds="[x1,y1][x2,y2]".
|
|
65
|
+
export function flattenAndroidUiXml(xml) {
|
|
66
|
+
if (!xml || typeof xml !== "string") return [];
|
|
67
|
+
const out = [];
|
|
68
|
+
const nodeRe = /<node\b([^>]*)\/?>/g;
|
|
69
|
+
const attr = (s, name) => {
|
|
70
|
+
const m = new RegExp(`${name}="([^"]*)"`).exec(s);
|
|
71
|
+
return m ? m[1] : "";
|
|
72
|
+
};
|
|
73
|
+
let m;
|
|
74
|
+
while ((m = nodeRe.exec(xml)) !== null) {
|
|
75
|
+
const a = m[1];
|
|
76
|
+
const bounds = attr(a, "bounds");
|
|
77
|
+
const bm = /\[(\d+),(\d+)\]\[(\d+),(\d+)\]/.exec(bounds);
|
|
78
|
+
if (!bm) continue;
|
|
79
|
+
const x1 = +bm[1], y1 = +bm[2], x2 = +bm[3], y2 = +bm[4];
|
|
80
|
+
const w = x2 - x1, h = y2 - y1;
|
|
81
|
+
if (w <= 0 || h <= 0) continue;
|
|
82
|
+
out.push({
|
|
83
|
+
role: attr(a, "class").split(".").pop() || "",
|
|
84
|
+
label: attr(a, "content-desc"),
|
|
85
|
+
text: attr(a, "text"),
|
|
86
|
+
identifier: attr(a, "resource-id"),
|
|
87
|
+
x: x1, y: y1, w, h,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Normalize a Figma node subtree (from get_design_context / metadata) into the
|
|
94
|
+
// same flat element shape, carrying style spec where present.
|
|
95
|
+
// Accepts a node with absoluteBoundingBox / children / style / fills.
|
|
96
|
+
export function flattenFigmaNode(node, originX = 0, originY = 0) {
|
|
97
|
+
const out = [];
|
|
98
|
+
const visit = (n) => {
|
|
99
|
+
if (!n || typeof n !== "object") return;
|
|
100
|
+
const bb = n.absoluteBoundingBox || n.boundingBox;
|
|
101
|
+
if (bb && typeof bb.width === "number") {
|
|
102
|
+
const style = n.style || {};
|
|
103
|
+
const fill = firstSolidColor(n.fills) || (style.fills && firstSolidColor(style.fills));
|
|
104
|
+
out.push({
|
|
105
|
+
role: n.type || "",
|
|
106
|
+
label: n.name || "",
|
|
107
|
+
text: n.characters || "",
|
|
108
|
+
identifier: n.id || "",
|
|
109
|
+
x: Math.round(bb.x - originX), y: Math.round(bb.y - originY),
|
|
110
|
+
w: Math.round(bb.width), h: Math.round(bb.height),
|
|
111
|
+
fontSize: style.fontSize || null,
|
|
112
|
+
fontFamily: style.fontFamily || style.fontPostScriptName || null,
|
|
113
|
+
color: fill || null,
|
|
114
|
+
itemSpacing: typeof n.itemSpacing === "number" ? n.itemSpacing : null,
|
|
115
|
+
paddings: {
|
|
116
|
+
top: n.paddingTop, right: n.paddingRight, bottom: n.paddingBottom, left: n.paddingLeft,
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
(n.children || []).forEach(visit);
|
|
121
|
+
};
|
|
122
|
+
visit(node);
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function firstSolidColor(fills) {
|
|
127
|
+
if (!Array.isArray(fills)) return null;
|
|
128
|
+
for (const f of fills) {
|
|
129
|
+
if (f && f.type === "SOLID" && f.color) {
|
|
130
|
+
const { r, g, b } = f.color;
|
|
131
|
+
return rgbToHex(r * 255, g * 255, b * 255);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function rgbToHex(r, g, b) {
|
|
138
|
+
const h = (v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, "0");
|
|
139
|
+
return `#${h(r)}${h(g)}${h(b)}`.toUpperCase();
|
|
140
|
+
}
|