@felipe-software/react-native-gpui 0.0.0-stage → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.reanimated-prebuilt/react-native-reanimated.mjs +29373 -0
- package/LICENSE +21 -0
- package/README.md +657 -2
- package/cli/bin.ts +379 -0
- package/cli/bundle.ts +32 -0
- package/cli/commands/do.ts +213 -0
- package/cli/commands/flow.ts +283 -0
- package/cli/commands/get.ts +584 -0
- package/cli/commands/shot.ts +312 -0
- package/cli/commands/trace.ts +293 -0
- package/cli/host.ts +714 -0
- package/cli/hotReload.ts +210 -0
- package/cli/run.ts +66 -0
- package/cli/selectors.ts +205 -0
- package/dist/Animated.d.ts +120 -0
- package/dist/ChatList.d.ts +28 -0
- package/dist/Dimensions.d.ts +31 -0
- package/dist/StyleSheet.d.ts +18 -0
- package/dist/Window.d.ts +33 -0
- package/dist/apis.d.ts +165 -0
- package/dist/app-state.d.ts +27 -0
- package/dist/chat-list-types.d.ts +136 -0
- package/dist/colors.d.ts +32 -0
- package/dist/commands.d.ts +230 -0
- package/dist/components.d.ts +419 -0
- package/dist/hotModules.d.ts +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.development.js +14876 -0
- package/dist/index.js +12 -0
- package/dist/keyboard.d.ts +140 -0
- package/dist/native-menu.d.ts +7 -0
- package/dist/platform-driver.d.ts +32 -0
- package/dist/platform-services.d.ts +35 -0
- package/dist/portal.d.ts +43 -0
- package/dist/raf.d.ts +4 -0
- package/dist/reanimated/colors-processor-shim.d.ts +10 -0
- package/dist/reanimated/reanimated-host.d.ts +10 -0
- package/dist/reanimated/seam.d.ts +34 -0
- package/dist/reanimated/ui-entry.d.ts +2 -0
- package/dist/reanimated/worklet-channel.d.ts +10 -0
- package/dist/reanimated/worklet-runtime.d.ts +466 -0
- package/dist/reanimated/worklets.d.ts +199 -0
- package/dist/reconciler.d.ts +102 -0
- package/dist/refresh.d.ts +1 -0
- package/dist/render.d.ts +37 -0
- package/dist/runtime.d.ts +235 -0
- package/dist/surfaces.d.ts +51 -0
- package/dist/types.d.ts +276 -0
- package/dist/ui-runtime.js +42276 -0
- package/dist/window-context.d.ts +7 -0
- package/dist/window-host.d.ts +41 -0
- package/dist/wire-delta.d.ts +20 -0
- package/package.json +176 -5
- package/scripts/bundle-app.mjs +88 -0
- package/scripts/conformance-utils.mjs +293 -0
- package/scripts/hot-update-alias-plugin.mjs +88 -0
- package/scripts/native-package-exports-plugin.mjs +235 -0
- package/scripts/pixel.mjs +152 -0
- package/scripts/png.mjs +198 -0
- package/scripts/react-refresh-swc.mjs +35 -0
- package/scripts/reanimated-bun-plugin.mjs +188 -0
- package/scripts/service-binary.d.mts +27 -0
- package/scripts/service-binary.mjs +100 -0
- package/src/reanimated/colors-processor-shim.ts +119 -0
- package/src/reanimated/reanimated-host.ts +69 -0
- package/src/reanimated/rngpui-reanimated-prebuilt.d.ts +7 -0
- package/src/reanimated/seam.ts +566 -0
- package/src/reanimated/ui-entry.ts +80 -0
- package/src/reanimated/worklet-channel.ts +64 -0
- package/src/reanimated/worklet-runtime.ts +2569 -0
- package/src/reanimated/worklets.ts +1334 -0
- package/src/refresh.ts +33 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// `rngpui shot` / `reshot` / `diff` — the fast one-shot iteration loop.
|
|
2
|
+
//
|
|
3
|
+
// shot: launch a bundle/fixture offscreen, wait for a stable frame, write png + tree,
|
|
4
|
+
// then print the png path + key measurements (bounds + sampled color of every
|
|
5
|
+
// --select'd node) in ONE command. No flag archaeology — the output tells you
|
|
6
|
+
// what to look at next.
|
|
7
|
+
// reshot: re-capture an already-running kept session (`rngpui dev` keeps one alive)
|
|
8
|
+
// in sub-second time — no relaunch, no re-bundle.
|
|
9
|
+
// diff: compare two pngs, print the changed-pixel ratio + the bounding box of the
|
|
10
|
+
// change, and (optionally) write a magenta-on-gray highlight png.
|
|
11
|
+
|
|
12
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { dirname, resolve as resolvePath } from "node:path";
|
|
14
|
+
import type { DumpNode, Host, LaunchedHost } from "../host";
|
|
15
|
+
import { attachSession, launchHost, scopeToWindow } from "../host";
|
|
16
|
+
import { isVisible, resolve as resolveSelector, walk } from "../selectors";
|
|
17
|
+
import { averageColor, dominantColor } from "../../scripts/pixel.mjs";
|
|
18
|
+
import { decodePng, encodePng, readPng } from "../../scripts/png.mjs";
|
|
19
|
+
|
|
20
|
+
export type ShotFlags = {
|
|
21
|
+
json: boolean;
|
|
22
|
+
size?: string;
|
|
23
|
+
appearance?: string;
|
|
24
|
+
out?: string;
|
|
25
|
+
select: string[];
|
|
26
|
+
crop?: string;
|
|
27
|
+
fixture: boolean;
|
|
28
|
+
session?: string;
|
|
29
|
+
bundle?: string;
|
|
30
|
+
launch?: string;
|
|
31
|
+
keep: boolean;
|
|
32
|
+
window?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// the team-machine app paints an empty "connecting…" shell without data; the fixture
|
|
36
|
+
// env loads deterministic demo sessions so an offscreen shot shows the real layout.
|
|
37
|
+
// honored by both the team-machine bundle and rngpui examples (which simply ignore it).
|
|
38
|
+
function shotEnv(flags: ShotFlags): Record<string, string> {
|
|
39
|
+
const env: Record<string, string> = {};
|
|
40
|
+
if (flags.fixture) env.TM_FIXTURE_ONLY = "1";
|
|
41
|
+
if (flags.appearance === "light" || flags.appearance === "dark") {
|
|
42
|
+
// native bridge theme (flips tamagui via the appearance event) AND the JS-side
|
|
43
|
+
// capture entries — covers the team-machine bundle and the parity/capture fixtures.
|
|
44
|
+
env.RNGPUI_FORCE_APPEARANCE = flags.appearance;
|
|
45
|
+
env.TM_CAPTURE_APPEARANCE = flags.appearance;
|
|
46
|
+
}
|
|
47
|
+
return env;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function nodeLabel(node: DumpNode): string {
|
|
51
|
+
const a = node.accessibility ?? {};
|
|
52
|
+
return a.testID ?? a.identifier ?? a.nativeID ?? a.label ?? (node.text ? `"${node.text.slice(0, 24)}"` : `#${node.globalId}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function boundsStr(b: DumpNode["bounds"]): string {
|
|
56
|
+
return b ? `${b.x.toFixed(0)},${b.y.toFixed(0)} ${b.width.toFixed(0)}x${b.height.toFixed(0)}` : "—";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function matchNodes(dump: DumpNode, selector: string): DumpNode[] {
|
|
60
|
+
const { best, candidates } = resolveSelector(dump, selector);
|
|
61
|
+
if (!best) return [];
|
|
62
|
+
return candidates.filter((c) => c.matchedValue === best.matchedValue).map((c) => c.node);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type Measurement = {
|
|
66
|
+
selector: string;
|
|
67
|
+
matched: number;
|
|
68
|
+
globalId: number | null;
|
|
69
|
+
type: string | null;
|
|
70
|
+
label: string | null;
|
|
71
|
+
bounds: DumpNode["bounds"] | null;
|
|
72
|
+
visible: boolean;
|
|
73
|
+
dominant: string | null;
|
|
74
|
+
average: string | null;
|
|
75
|
+
coverage: number | null;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// sample a node's color out of the just-captured png (scaled from logical → pixel).
|
|
79
|
+
function sampleNodeFromPng(img: { width: number; height: number }, host: Host, node: DumpNode) {
|
|
80
|
+
const b = node.bounds;
|
|
81
|
+
if (!b || b.width < 1 || b.height < 1) return null;
|
|
82
|
+
const scaleX = img.width / host.window.width;
|
|
83
|
+
const scaleY = img.height / host.window.height;
|
|
84
|
+
const rect = {
|
|
85
|
+
x: Math.round(b.x * scaleX),
|
|
86
|
+
y: Math.round(b.y * scaleY),
|
|
87
|
+
width: Math.max(1, Math.round(b.width * scaleX)),
|
|
88
|
+
height: Math.max(1, Math.round(b.height * scaleY)),
|
|
89
|
+
};
|
|
90
|
+
const dom = dominantColor(img as never, rect);
|
|
91
|
+
const avg = averageColor(img as never, rect);
|
|
92
|
+
return { dominant: dom.hex, average: avg.hex, coverage: dom.coverage };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function measureSelectors(host: Host, dump: DumpNode, png: string, selectors: string[]): Measurement[] {
|
|
96
|
+
if (selectors.length === 0) return [];
|
|
97
|
+
const img = existsSync(png) && statSync(png).size > 0 ? readPng(png) : null;
|
|
98
|
+
return selectors.map((selector) => {
|
|
99
|
+
const nodes = matchNodes(dump, selector);
|
|
100
|
+
const node = nodes[0];
|
|
101
|
+
if (!node) {
|
|
102
|
+
return { selector, matched: 0, globalId: null, type: null, label: null, bounds: null, visible: false, dominant: null, average: null, coverage: null };
|
|
103
|
+
}
|
|
104
|
+
const color = img && isVisible(node) ? sampleNodeFromPng(img, host, node) : null;
|
|
105
|
+
return {
|
|
106
|
+
selector,
|
|
107
|
+
matched: nodes.length,
|
|
108
|
+
globalId: node.globalId,
|
|
109
|
+
type: node.type,
|
|
110
|
+
label: nodeLabel(node),
|
|
111
|
+
bounds: node.bounds ?? null,
|
|
112
|
+
visible: isVisible(node),
|
|
113
|
+
dominant: color?.dominant ?? null,
|
|
114
|
+
average: color?.average ?? null,
|
|
115
|
+
coverage: color?.coverage ?? null,
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// the whole point of shot: one block of output that tells a designer/agent exactly
|
|
121
|
+
// what they have and what to look at next — no second command, no flag hunting.
|
|
122
|
+
function printShot(result: ShotResult, json: boolean) {
|
|
123
|
+
if (json) {
|
|
124
|
+
console.log(JSON.stringify(result, null, 2));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
console.log(` png: ${result.png} (${result.pngSize})${result.appearance ? ` appearance=${result.appearance}` : ""}`);
|
|
128
|
+
console.log(` tree: ${result.tree}`);
|
|
129
|
+
console.log(` nodes=${result.nodes} visible=${result.visible} webviews=${result.webviews} window=${result.window}`);
|
|
130
|
+
if (result.session) console.log(` session: ${result.session} (reshot with: rngpui reshot --session ${result.session})`);
|
|
131
|
+
if (result.measurements.length) {
|
|
132
|
+
console.log(" measurements:");
|
|
133
|
+
for (const m of result.measurements) {
|
|
134
|
+
if (m.matched === 0) {
|
|
135
|
+
console.log(` ${JSON.stringify(m.selector)} — no match`);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const color = m.dominant ? ` dominant=${m.dominant} (${((m.coverage ?? 0) * 100).toFixed(0)}%) avg=${m.average}` : m.visible ? "" : " [not painted]";
|
|
139
|
+
const more = m.matched > 1 ? ` (+${m.matched - 1} more)` : "";
|
|
140
|
+
console.log(` ${JSON.stringify(m.selector)} → ${m.type} ${m.label} #${m.globalId} [${boundsStr(m.bounds)}]${color}${more}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
type ShotResult = {
|
|
146
|
+
png: string;
|
|
147
|
+
pngSize: string;
|
|
148
|
+
tree: string;
|
|
149
|
+
nodes: number;
|
|
150
|
+
visible: number;
|
|
151
|
+
webviews: number;
|
|
152
|
+
window: string;
|
|
153
|
+
appearance: string | null;
|
|
154
|
+
session: string | null;
|
|
155
|
+
measurements: Measurement[];
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// produce the png + tree + measurements for an already-attached/launched host.
|
|
159
|
+
function captureShot(host: Host, flags: ShotFlags, dump: DumpNode, session: string | null): ShotResult {
|
|
160
|
+
const outPng = resolvePath(flags.out ?? "/tmp/rngpui-shot.png");
|
|
161
|
+
const outTree = outPng.replace(/\.png$/i, ".tree.json");
|
|
162
|
+
mkdirSync(dirname(outPng), { recursive: true });
|
|
163
|
+
host.capture(outPng);
|
|
164
|
+
writeFileSync(outTree, JSON.stringify(dump));
|
|
165
|
+
const img = existsSync(outPng) ? readPng(outPng) : { width: 0, height: 0 };
|
|
166
|
+
let nodes = 0;
|
|
167
|
+
let visible = 0;
|
|
168
|
+
let webviews = 0;
|
|
169
|
+
for (const node of walk(dump)) {
|
|
170
|
+
nodes += 1;
|
|
171
|
+
if (isVisible(node)) visible += 1;
|
|
172
|
+
if (node.type === "webview") webviews += 1;
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
png: outPng,
|
|
176
|
+
pngSize: `${img.width}x${img.height}`,
|
|
177
|
+
tree: outTree,
|
|
178
|
+
nodes,
|
|
179
|
+
visible,
|
|
180
|
+
webviews,
|
|
181
|
+
window: `${host.window.width}x${host.window.height}`,
|
|
182
|
+
appearance: flags.appearance === "light" || flags.appearance === "dark" ? flags.appearance : null,
|
|
183
|
+
session,
|
|
184
|
+
measurements: measureSelectors(host, dump, outPng, flags.select),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function runShot(flags: ShotFlags): Promise<number> {
|
|
189
|
+
// reshot path: a kept session is already alive — re-capture in sub-second time.
|
|
190
|
+
if (flags.session) {
|
|
191
|
+
const session = await attachSession(flags.session);
|
|
192
|
+
const host = flags.window ? await scopeToWindow(session, flags.window) : session;
|
|
193
|
+
try {
|
|
194
|
+
const dump = await host.dump();
|
|
195
|
+
printShot(captureShot(host, flags, dump, flags.session), flags.json);
|
|
196
|
+
return 0;
|
|
197
|
+
} finally {
|
|
198
|
+
host.close(); // keepOnClose: true for attached sessions, so this is a no-op for the process
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const target = flags.bundle ?? flags.launch;
|
|
203
|
+
if (!target) {
|
|
204
|
+
console.error(" shot needs --bundle <app.js>, --launch <entry.tsx>, or --session <dir>");
|
|
205
|
+
return 1;
|
|
206
|
+
}
|
|
207
|
+
const env = shotEnv(flags);
|
|
208
|
+
const prevEnv: Record<string, string | undefined> = {};
|
|
209
|
+
for (const [key, value] of Object.entries(env)) {
|
|
210
|
+
prevEnv[key] = process.env[key];
|
|
211
|
+
process.env[key] = value;
|
|
212
|
+
}
|
|
213
|
+
let host: LaunchedHost | null = null;
|
|
214
|
+
try {
|
|
215
|
+
host = await launchHost(flags.launch ?? "", {
|
|
216
|
+
bundle: flags.bundle,
|
|
217
|
+
size: flags.size,
|
|
218
|
+
keep: flags.keep,
|
|
219
|
+
});
|
|
220
|
+
const dump = await host.dump();
|
|
221
|
+
const session = flags.keep ? host.sessionDir : null;
|
|
222
|
+
printShot(captureShot(host, flags, dump, session), flags.json);
|
|
223
|
+
return 0;
|
|
224
|
+
} finally {
|
|
225
|
+
host?.close();
|
|
226
|
+
for (const [key, value] of Object.entries(prevEnv)) {
|
|
227
|
+
if (value === undefined) delete process.env[key];
|
|
228
|
+
else process.env[key] = value;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---- diff ----
|
|
234
|
+
|
|
235
|
+
type DiffResult = {
|
|
236
|
+
before: string;
|
|
237
|
+
after: string;
|
|
238
|
+
width: number;
|
|
239
|
+
height: number;
|
|
240
|
+
changed: number;
|
|
241
|
+
total: number;
|
|
242
|
+
ratio: number;
|
|
243
|
+
maxDelta: number;
|
|
244
|
+
box: { x: number; y: number; width: number; height: number } | null;
|
|
245
|
+
out: string | null;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
export async function runDiff(positional: string[], flags: { json: boolean; out?: string; threshold?: number }): Promise<number> {
|
|
249
|
+
const [beforePath, afterPath] = positional;
|
|
250
|
+
if (!beforePath || !afterPath) {
|
|
251
|
+
console.error(" usage: rngpui diff <before.png> <after.png> [--out highlight.png] [--threshold n]");
|
|
252
|
+
return 1;
|
|
253
|
+
}
|
|
254
|
+
const before = decodePng(readFileSync(beforePath));
|
|
255
|
+
const after = decodePng(readFileSync(afterPath));
|
|
256
|
+
const width = Math.min(before.width, after.width);
|
|
257
|
+
const height = Math.min(before.height, after.height);
|
|
258
|
+
const threshold = flags.threshold ?? 24;
|
|
259
|
+
const diff = Buffer.alloc(width * height * 4);
|
|
260
|
+
let changed = 0;
|
|
261
|
+
let maxDelta = 0;
|
|
262
|
+
let minX = width;
|
|
263
|
+
let minY = height;
|
|
264
|
+
let maxX = -1;
|
|
265
|
+
let maxY = -1;
|
|
266
|
+
for (let y = 0; y < height; y += 1) {
|
|
267
|
+
for (let x = 0; x < width; x += 1) {
|
|
268
|
+
const bi = (y * before.width + x) * 4;
|
|
269
|
+
const ai = (y * after.width + x) * 4;
|
|
270
|
+
const oi = (y * width + x) * 4;
|
|
271
|
+
const dr = Math.abs(before.rgba[bi] - after.rgba[ai]);
|
|
272
|
+
const dg = Math.abs(before.rgba[bi + 1] - after.rgba[ai + 1]);
|
|
273
|
+
const db = Math.abs(before.rgba[bi + 2] - after.rgba[ai + 2]);
|
|
274
|
+
const da = Math.abs(before.rgba[bi + 3] - after.rgba[ai + 3]);
|
|
275
|
+
const md = Math.max(dr, dg, db, da);
|
|
276
|
+
if (md > maxDelta) maxDelta = md;
|
|
277
|
+
if (md > threshold) {
|
|
278
|
+
changed += 1;
|
|
279
|
+
diff[oi] = 255;
|
|
280
|
+
diff[oi + 1] = 0;
|
|
281
|
+
diff[oi + 2] = 180;
|
|
282
|
+
diff[oi + 3] = 255;
|
|
283
|
+
if (x < minX) minX = x;
|
|
284
|
+
if (y < minY) minY = y;
|
|
285
|
+
if (x > maxX) maxX = x;
|
|
286
|
+
if (y > maxY) maxY = y;
|
|
287
|
+
} else {
|
|
288
|
+
const gray = (after.rgba[ai] * 0.299 + after.rgba[ai + 1] * 0.587 + after.rgba[ai + 2] * 0.114) | 0;
|
|
289
|
+
diff[oi] = gray;
|
|
290
|
+
diff[oi + 1] = gray;
|
|
291
|
+
diff[oi + 2] = gray;
|
|
292
|
+
diff[oi + 3] = 255;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const total = width * height;
|
|
297
|
+
const out = flags.out ? resolvePath(flags.out) : null;
|
|
298
|
+
if (out) {
|
|
299
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
300
|
+
writeFileSync(out, encodePng(width, height, diff));
|
|
301
|
+
}
|
|
302
|
+
const box = maxX >= 0 ? { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 } : null;
|
|
303
|
+
const result: DiffResult = { before: beforePath, after: afterPath, width, height, changed, total, ratio: changed / total, maxDelta, box, out };
|
|
304
|
+
if (flags.json) {
|
|
305
|
+
console.log(JSON.stringify(result, null, 2));
|
|
306
|
+
} else {
|
|
307
|
+
console.log(` ${changed}/${total} px changed (${(result.ratio * 100).toFixed(3)}%) maxDelta=${maxDelta} threshold=${threshold}`);
|
|
308
|
+
console.log(` changed region: ${box ? `${box.x},${box.y} ${box.width}x${box.height}` : "(no change above threshold)"}`);
|
|
309
|
+
if (out) console.log(` highlight: ${out} (magenta = changed, gray = unchanged)`);
|
|
310
|
+
}
|
|
311
|
+
return 0;
|
|
312
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// `rngpui trace` — record an animation's actual per-frame values, no screenshots.
|
|
2
|
+
//
|
|
3
|
+
// rngpui trace dialog --action "tap open-button" --ms 1200 --session <dir>
|
|
4
|
+
// rngpui trace --all --keys opacity,transform --ms 800 --attach
|
|
5
|
+
//
|
|
6
|
+
// Arms an in-service trace session (traceStart), optionally fires input actions, waits,
|
|
7
|
+
// then collects (traceStop) every off-thread reanimated style write and NativeLayout
|
|
8
|
+
// tween tick that landed in the window — each tagged with a wall-clock timestamp and the
|
|
9
|
+
// painted-frame counter. The analysis proves the three things a screenshot can't:
|
|
10
|
+
// cadence (per-sample gaps + dropped frames), curve shape (sparkline; spring overshoot
|
|
11
|
+
// is visible), and endpoints (first → last value).
|
|
12
|
+
|
|
13
|
+
import { isDriveableHost, type AttachedHost, type DumpNode, type LaunchedHost } from "../host";
|
|
14
|
+
import { sleep } from "../../scripts/conformance-utils.mjs";
|
|
15
|
+
import { centerOf, parsePoint, resolve } from "../selectors";
|
|
16
|
+
|
|
17
|
+
type Host = LaunchedHost | AttachedHost;
|
|
18
|
+
|
|
19
|
+
export interface TraceOptions {
|
|
20
|
+
json: boolean;
|
|
21
|
+
/// style keys to record (default: all)
|
|
22
|
+
keys?: string[];
|
|
23
|
+
/// observation window after actions fire
|
|
24
|
+
ms: number;
|
|
25
|
+
/// input actions to fire right after arming: "tap <selector>", "key <key>", "type <text>"
|
|
26
|
+
actions: string[];
|
|
27
|
+
/// trace all nodes instead of resolving selectors
|
|
28
|
+
all: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type Sample = { t: number; f: number; id?: number; nativeKey?: string; k: string; v: unknown };
|
|
32
|
+
type StopResponse = {
|
|
33
|
+
ok: boolean;
|
|
34
|
+
error?: string;
|
|
35
|
+
durationMs: number;
|
|
36
|
+
framesPainted: number;
|
|
37
|
+
paintGapsMs: number[];
|
|
38
|
+
truncated: boolean;
|
|
39
|
+
samples: Sample[];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export async function runTrace(host: Host, selectors: string[], opts: TraceOptions): Promise<number> {
|
|
43
|
+
if (!isDriveableHost(host)) {
|
|
44
|
+
console.error(" trace needs a driveable target: --launch, --bundle, --session, or a control-socket attach");
|
|
45
|
+
return 1;
|
|
46
|
+
}
|
|
47
|
+
const driveable = host;
|
|
48
|
+
|
|
49
|
+
// resolve selectors → the matched nodes' whole subtrees. Animated style usually
|
|
50
|
+
// lands on an inner wrapper (Dialog.Content, a presence wrapper), not the node
|
|
51
|
+
// carrying the testID, so the subtree is the useful unit.
|
|
52
|
+
let ids: number[] | undefined;
|
|
53
|
+
let nativeKeys: string[] | undefined;
|
|
54
|
+
const labels: string[] = [];
|
|
55
|
+
if (!opts.all && selectors.length > 0) {
|
|
56
|
+
const dump = await driveable.dump();
|
|
57
|
+
const idSet = new Set<number>();
|
|
58
|
+
const nativeSet = new Set<string>();
|
|
59
|
+
for (const selector of selectors) {
|
|
60
|
+
const { best } = resolve(dump, selector);
|
|
61
|
+
if (!best) {
|
|
62
|
+
console.error(` no node matched "${selector}"`);
|
|
63
|
+
return 1;
|
|
64
|
+
}
|
|
65
|
+
labels.push(`${selector} → #${best.node.globalId}`);
|
|
66
|
+
collectSubtree(best.node, idSet, nativeSet);
|
|
67
|
+
}
|
|
68
|
+
ids = [...idSet];
|
|
69
|
+
nativeKeys = nativeSet.size > 0 ? [...nativeSet] : undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const start = await driveable.request<{ ok: boolean; error?: string }>({
|
|
73
|
+
$cmd: "traceStart",
|
|
74
|
+
...(ids ? { ids } : {}),
|
|
75
|
+
...(opts.keys ? { keys: opts.keys } : {}),
|
|
76
|
+
...(nativeKeys ? { nativeKeys } : {}),
|
|
77
|
+
maxMs: opts.ms + 5_000,
|
|
78
|
+
});
|
|
79
|
+
if (!start.ok) {
|
|
80
|
+
console.error(` traceStart failed: ${start.error || "unknown"}`);
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const action of opts.actions) {
|
|
85
|
+
const error = await fireAction(driveable, action);
|
|
86
|
+
if (error) {
|
|
87
|
+
await driveable.request({ $cmd: "traceStop" });
|
|
88
|
+
console.error(` action "${action}" failed: ${error}`);
|
|
89
|
+
return 1;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
await sleep(opts.ms);
|
|
94
|
+
|
|
95
|
+
const stop = await driveable.request<StopResponse>({ $cmd: "traceStop" });
|
|
96
|
+
if (!stop.ok) {
|
|
97
|
+
console.error(` traceStop failed: ${stop.error || "unknown"}`);
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const series = buildSeries(stop.samples);
|
|
102
|
+
const analysis = series.map(analyzeSeries);
|
|
103
|
+
|
|
104
|
+
if (opts.json) {
|
|
105
|
+
console.log(
|
|
106
|
+
JSON.stringify(
|
|
107
|
+
{
|
|
108
|
+
tracedNodes: labels,
|
|
109
|
+
durationMs: stop.durationMs,
|
|
110
|
+
framesPainted: stop.framesPainted,
|
|
111
|
+
truncated: stop.truncated,
|
|
112
|
+
series: analysis,
|
|
113
|
+
samples: stop.samples,
|
|
114
|
+
},
|
|
115
|
+
null,
|
|
116
|
+
2,
|
|
117
|
+
),
|
|
118
|
+
);
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const paintLine = paintCadence(stop.paintGapsMs);
|
|
123
|
+
console.log(` traced ${Math.round(stop.durationMs)}ms — ${stop.framesPainted} frames painted${paintLine}`);
|
|
124
|
+
for (const label of labels) console.log(` ${label}`);
|
|
125
|
+
if (stop.truncated) console.log(" WARNING: sample buffer overflowed — series are incomplete");
|
|
126
|
+
if (analysis.length === 0) {
|
|
127
|
+
console.log(" no animated values recorded — nothing wrote styles/native-layout frames in the window");
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
for (const s of analysis) {
|
|
131
|
+
if (s.kind === "numeric") {
|
|
132
|
+
const dropped = s.droppedGaps > 0 ? `, ${s.droppedGaps} dropped-frame gaps` : "";
|
|
133
|
+
const overshoot = s.overshoots > 0 ? `, overshoots final ×${s.overshoots} (springy)` : "";
|
|
134
|
+
console.log(` ${s.key} ${s.first} → ${s.last} [${s.min}..${s.max}]`);
|
|
135
|
+
console.log(
|
|
136
|
+
` ${s.spark} ${s.count} samples over ${Math.round(s.spanMs)}ms (~${s.sampleHz}Hz${dropped}${overshoot})`,
|
|
137
|
+
);
|
|
138
|
+
} else {
|
|
139
|
+
console.log(` ${s.key} ${s.count} writes: ${s.first} → ${s.last}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function collectSubtree(node: DumpNode, ids: Set<number>, nativeKeys: Set<string>) {
|
|
146
|
+
ids.add(node.globalId);
|
|
147
|
+
const key = (node as { nativeLayoutKey?: string }).nativeLayoutKey;
|
|
148
|
+
if (key) nativeKeys.add(key);
|
|
149
|
+
for (const child of node.children ?? []) collectSubtree(child, ids, nativeKeys);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function fireAction(
|
|
153
|
+
driveable: { dump(): Promise<DumpNode>; request<T>(cmd: object): Promise<T> },
|
|
154
|
+
action: string,
|
|
155
|
+
): Promise<string | null> {
|
|
156
|
+
const [verb, ...rest] = action.trim().split(/\s+/);
|
|
157
|
+
const arg = rest.join(" ");
|
|
158
|
+
if (verb === "tap") {
|
|
159
|
+
let x: number, y: number;
|
|
160
|
+
const pt = parsePoint(arg);
|
|
161
|
+
if (pt) {
|
|
162
|
+
({ x, y } = pt);
|
|
163
|
+
} else {
|
|
164
|
+
const dump = await driveable.dump();
|
|
165
|
+
const { best } = resolve(dump, arg);
|
|
166
|
+
if (!best) return `no node matched "${arg}"`;
|
|
167
|
+
const center = centerOf(best.node);
|
|
168
|
+
if (!center) return `node #${best.node.globalId} has no bounds`;
|
|
169
|
+
({ x, y } = center);
|
|
170
|
+
}
|
|
171
|
+
const r = await driveable.request<{ ok: boolean; error?: string }>({ $cmd: "tap", x, y });
|
|
172
|
+
return r.ok ? null : r.error || "tap failed";
|
|
173
|
+
}
|
|
174
|
+
if (verb === "key") {
|
|
175
|
+
const r = await driveable.request<{ ok: boolean; error?: string }>({ $cmd: "key", key: arg });
|
|
176
|
+
return r.ok ? null : r.error || "key failed";
|
|
177
|
+
}
|
|
178
|
+
if (verb === "type") {
|
|
179
|
+
const r = await driveable.request<{ ok: boolean; error?: string }>({ $cmd: "type", text: arg });
|
|
180
|
+
return r.ok ? null : r.error || "type failed";
|
|
181
|
+
}
|
|
182
|
+
return `unknown action verb "${verb}" (tap/key/type)`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
type Point = { t: number; f: number; val: number };
|
|
186
|
+
type Series =
|
|
187
|
+
| { kind: "numeric"; key: string; points: Point[] }
|
|
188
|
+
| { kind: "discrete"; key: string; values: { t: number; v: string }[] };
|
|
189
|
+
|
|
190
|
+
/// Flatten raw samples into per-(node,key) series. Numbers chart; transform arrays
|
|
191
|
+
/// explode into per-component numeric series; everything else is a discrete log.
|
|
192
|
+
function buildSeries(samples: Sample[]): Series[] {
|
|
193
|
+
const numeric = new Map<string, Point[]>();
|
|
194
|
+
const discrete = new Map<string, { t: number; v: string }[]>();
|
|
195
|
+
const push = (key: string, t: number, f: number, v: unknown) => {
|
|
196
|
+
if (typeof v === "number") {
|
|
197
|
+
let arr = numeric.get(key);
|
|
198
|
+
if (!arr) numeric.set(key, (arr = []));
|
|
199
|
+
arr.push({ t, f, val: v });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
let arr = discrete.get(key);
|
|
203
|
+
if (!arr) discrete.set(key, (arr = []));
|
|
204
|
+
arr.push({ t, v: JSON.stringify(v) });
|
|
205
|
+
};
|
|
206
|
+
for (const s of samples) {
|
|
207
|
+
const base = s.nativeKey ? `${s.nativeKey}(native)` : `#${s.id}`;
|
|
208
|
+
if (s.k === "transform" && Array.isArray(s.v)) {
|
|
209
|
+
for (const part of s.v) {
|
|
210
|
+
if (part && typeof part === "object") {
|
|
211
|
+
for (const [tk, tv] of Object.entries(part)) push(`${base}.transform.${tk}`, s.t, s.f, tv);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (s.nativeKey && s.v && typeof s.v === "object") {
|
|
217
|
+
for (const [fk, fv] of Object.entries(s.v)) push(`${base}.${fk}`, s.t, s.f, fv);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
push(`${base}.${s.k}`, s.t, s.f, s.v);
|
|
221
|
+
}
|
|
222
|
+
const out: Series[] = [];
|
|
223
|
+
for (const [key, points] of numeric) out.push({ kind: "numeric", key, points });
|
|
224
|
+
for (const [key, values] of discrete) out.push({ kind: "discrete", key, values });
|
|
225
|
+
out.sort((a, b) => a.key.localeCompare(b.key));
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const SPARK = "▁▂▃▄▅▆▇█";
|
|
230
|
+
|
|
231
|
+
function sparkline(values: number[], width = 44): string {
|
|
232
|
+
if (values.length === 0) return "";
|
|
233
|
+
// resample to `width` buckets so long traces still read as one curve
|
|
234
|
+
const sampled: number[] = [];
|
|
235
|
+
for (let i = 0; i < Math.min(width, values.length); i++) {
|
|
236
|
+
sampled.push(values[Math.floor((i * values.length) / Math.min(width, values.length))]);
|
|
237
|
+
}
|
|
238
|
+
const min = Math.min(...sampled);
|
|
239
|
+
const max = Math.max(...sampled);
|
|
240
|
+
const span = max - min || 1;
|
|
241
|
+
return sampled.map((v) => SPARK[Math.min(7, Math.floor(((v - min) / span) * 8))]).join("");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function analyzeSeries(series: Series) {
|
|
245
|
+
if (series.kind === "discrete") {
|
|
246
|
+
const { key, values } = series;
|
|
247
|
+
return {
|
|
248
|
+
kind: "discrete" as const,
|
|
249
|
+
key,
|
|
250
|
+
count: values.length,
|
|
251
|
+
first: values[0]?.v,
|
|
252
|
+
last: values[values.length - 1]?.v,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
const { key, points } = series;
|
|
256
|
+
const vals = points.map((p) => p.val);
|
|
257
|
+
const spanMs = points.length > 1 ? points[points.length - 1].t - points[0].t : 0;
|
|
258
|
+
const gaps: number[] = [];
|
|
259
|
+
for (let i = 1; i < points.length; i++) gaps.push(points[i].t - points[i - 1].t);
|
|
260
|
+
const sortedGaps = [...gaps].sort((a, b) => a - b);
|
|
261
|
+
const medianGap = sortedGaps[Math.floor(sortedGaps.length / 2)] ?? 0;
|
|
262
|
+
const droppedGaps = medianGap > 0 ? gaps.filter((g) => g > medianGap * 2).length : 0;
|
|
263
|
+
// spring fingerprint: how many times the curve crosses its settling value before the end
|
|
264
|
+
const last = vals[vals.length - 1];
|
|
265
|
+
let overshoots = 0;
|
|
266
|
+
for (let i = 1; i < vals.length - 1; i++) {
|
|
267
|
+
if ((vals[i - 1] - last) * (vals[i] - last) < 0) overshoots++;
|
|
268
|
+
}
|
|
269
|
+
const round = (v: number) => Math.round(v * 1000) / 1000;
|
|
270
|
+
return {
|
|
271
|
+
kind: "numeric" as const,
|
|
272
|
+
key,
|
|
273
|
+
count: points.length,
|
|
274
|
+
spanMs,
|
|
275
|
+
sampleHz: spanMs > 0 ? Math.round(((points.length - 1) / spanMs) * 1000) : 0,
|
|
276
|
+
first: round(vals[0]),
|
|
277
|
+
last: round(last),
|
|
278
|
+
min: round(Math.min(...vals)),
|
|
279
|
+
max: round(Math.max(...vals)),
|
|
280
|
+
medianGapMs: round(medianGap),
|
|
281
|
+
droppedGaps,
|
|
282
|
+
overshoots,
|
|
283
|
+
spark: sparkline(vals),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function paintCadence(gaps: number[]): string {
|
|
288
|
+
if (!gaps || gaps.length < 2) return "";
|
|
289
|
+
const sorted = [...gaps].sort((a, b) => a - b);
|
|
290
|
+
const median = sorted[Math.floor(sorted.length / 2)];
|
|
291
|
+
const p95 = sorted[Math.floor(sorted.length * 0.95)];
|
|
292
|
+
return ` (paint gap median ${median.toFixed(1)}ms, p95 ${p95.toFixed(1)}ms)`;
|
|
293
|
+
}
|