driftx 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.
- package/dist/bin.js +2983 -0
- package/dist/bin.js.map +1 -0
- package/driftx-plugin/.claude-plugin/plugin.json +10 -0
- package/driftx-plugin/skills/driftx.md +299 -0
- package/package.json +56 -0
package/dist/bin.js
ADDED
|
@@ -0,0 +1,2983 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { createRequire } from "module";
|
|
6
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync8, readdirSync as readdirSync2, symlinkSync, unlinkSync as unlinkSync3, writeFileSync as writeFileSync4 } from "fs";
|
|
7
|
+
import { dirname as dirname4, join as join5, resolve } from "path";
|
|
8
|
+
import { homedir } from "os";
|
|
9
|
+
import { fileURLToPath } from "url";
|
|
10
|
+
|
|
11
|
+
// src/shell.ts
|
|
12
|
+
import { execFile } from "child_process";
|
|
13
|
+
var RealShell = class {
|
|
14
|
+
async exec(cmd, args, options) {
|
|
15
|
+
const controller = new AbortController();
|
|
16
|
+
const timeout = options?.timeout;
|
|
17
|
+
return new Promise((resolve2, reject) => {
|
|
18
|
+
const child = execFile(
|
|
19
|
+
cmd,
|
|
20
|
+
args,
|
|
21
|
+
{
|
|
22
|
+
signal: controller.signal,
|
|
23
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
24
|
+
encoding: "utf-8"
|
|
25
|
+
},
|
|
26
|
+
(error, stdout, stderr) => {
|
|
27
|
+
if (error) {
|
|
28
|
+
reject(error);
|
|
29
|
+
} else {
|
|
30
|
+
resolve2({ stdout, stderr });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
);
|
|
34
|
+
if (timeout) {
|
|
35
|
+
setTimeout(() => {
|
|
36
|
+
controller.abort();
|
|
37
|
+
child.kill();
|
|
38
|
+
}, timeout);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// src/config.ts
|
|
45
|
+
import { z } from "zod";
|
|
46
|
+
import { cosmiconfig } from "cosmiconfig";
|
|
47
|
+
|
|
48
|
+
// src/types.ts
|
|
49
|
+
var DEFAULT_RETRY_POLICY = {
|
|
50
|
+
maxAttempts: 3,
|
|
51
|
+
baseDelayMs: 500,
|
|
52
|
+
maxDelayMs: 5e3,
|
|
53
|
+
backoffMultiplier: 2,
|
|
54
|
+
retryableErrors: ["device busy", "transport error", "ECONNRESET"]
|
|
55
|
+
};
|
|
56
|
+
var DEFAULT_TIMEOUT_CONFIG = {
|
|
57
|
+
deviceDiscoveryMs: 5e3,
|
|
58
|
+
screenshotCaptureMs: 1e4,
|
|
59
|
+
treeInspectionMs: 15e3,
|
|
60
|
+
devToolsConnectMs: 3e3
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// src/config.ts
|
|
64
|
+
var timeoutSchema = z.object({
|
|
65
|
+
deviceDiscoveryMs: z.number().int().positive().optional(),
|
|
66
|
+
screenshotCaptureMs: z.number().int().positive().optional(),
|
|
67
|
+
treeInspectionMs: z.number().int().positive().optional(),
|
|
68
|
+
devToolsConnectMs: z.number().int().positive().optional()
|
|
69
|
+
});
|
|
70
|
+
var retrySchema = z.object({
|
|
71
|
+
maxAttempts: z.number().int().positive().optional(),
|
|
72
|
+
baseDelayMs: z.number().int().positive().optional(),
|
|
73
|
+
maxDelayMs: z.number().int().positive().optional(),
|
|
74
|
+
backoffMultiplier: z.number().positive().optional(),
|
|
75
|
+
retryableErrors: z.array(z.string()).optional()
|
|
76
|
+
});
|
|
77
|
+
var boundingBoxSchema = z.object({
|
|
78
|
+
x: z.number(),
|
|
79
|
+
y: z.number(),
|
|
80
|
+
width: z.number().positive(),
|
|
81
|
+
height: z.number().positive()
|
|
82
|
+
});
|
|
83
|
+
var colorRangeSchema = z.object({
|
|
84
|
+
r: z.tuple([z.number(), z.number()]),
|
|
85
|
+
g: z.tuple([z.number(), z.number()]),
|
|
86
|
+
b: z.tuple([z.number(), z.number()]),
|
|
87
|
+
a: z.tuple([z.number(), z.number()]).optional()
|
|
88
|
+
});
|
|
89
|
+
var ignoreRuleSchema = z.object({
|
|
90
|
+
type: z.enum(["testID", "componentName", "textPattern", "boundingBox", "sourceFile", "colorRange"]),
|
|
91
|
+
value: z.union([z.string(), boundingBoxSchema, colorRangeSchema]),
|
|
92
|
+
reason: z.string().optional()
|
|
93
|
+
});
|
|
94
|
+
var viewportSchema = z.object({
|
|
95
|
+
cropStatusBar: z.boolean().optional(),
|
|
96
|
+
cropNavigationBar: z.boolean().optional(),
|
|
97
|
+
statusBarHeight: z.number().int().nonnegative().optional(),
|
|
98
|
+
navigationBarHeight: z.number().int().nonnegative().optional()
|
|
99
|
+
});
|
|
100
|
+
var analysesSchema = z.object({
|
|
101
|
+
default: z.array(z.string()).optional(),
|
|
102
|
+
disabled: z.array(z.string()).optional(),
|
|
103
|
+
options: z.record(z.string(), z.record(z.string(), z.unknown())).optional()
|
|
104
|
+
}).optional();
|
|
105
|
+
var configSchema = z.object({
|
|
106
|
+
threshold: z.number().min(0).max(1).optional(),
|
|
107
|
+
diffThreshold: z.number().min(0).max(1).optional(),
|
|
108
|
+
settleTimeMs: z.number().int().positive().optional(),
|
|
109
|
+
settleCheckEnabled: z.boolean().optional(),
|
|
110
|
+
settleMaxDelta: z.number().min(0).max(1).optional(),
|
|
111
|
+
primaryDevice: z.string().optional(),
|
|
112
|
+
groups: z.record(z.string(), z.array(z.string())).optional(),
|
|
113
|
+
platform: z.enum(["android", "ios"]).optional(),
|
|
114
|
+
metroPort: z.number().int().positive().optional(),
|
|
115
|
+
devToolsPort: z.number().int().positive().optional(),
|
|
116
|
+
ignoreRules: z.array(ignoreRuleSchema).optional(),
|
|
117
|
+
viewport: viewportSchema.optional(),
|
|
118
|
+
timeouts: timeoutSchema.optional(),
|
|
119
|
+
retry: retrySchema.optional(),
|
|
120
|
+
regionMergeGap: z.number().int().nonnegative().optional(),
|
|
121
|
+
regionMinArea: z.number().int().nonnegative().optional(),
|
|
122
|
+
diffMaskColor: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional(),
|
|
123
|
+
analyses: analysesSchema
|
|
124
|
+
});
|
|
125
|
+
var DEFAULTS = {
|
|
126
|
+
threshold: 0.1,
|
|
127
|
+
diffThreshold: 0.01,
|
|
128
|
+
settleTimeMs: 300,
|
|
129
|
+
settleCheckEnabled: true,
|
|
130
|
+
settleMaxDelta: 1e-3,
|
|
131
|
+
primaryDevice: void 0,
|
|
132
|
+
groups: {},
|
|
133
|
+
platform: "android",
|
|
134
|
+
metroPort: 8081,
|
|
135
|
+
devToolsPort: 8097,
|
|
136
|
+
ignoreRules: [],
|
|
137
|
+
viewport: {
|
|
138
|
+
cropStatusBar: true,
|
|
139
|
+
cropNavigationBar: true,
|
|
140
|
+
statusBarHeight: 24,
|
|
141
|
+
navigationBarHeight: 48
|
|
142
|
+
},
|
|
143
|
+
timeouts: { ...DEFAULT_TIMEOUT_CONFIG },
|
|
144
|
+
retry: { ...DEFAULT_RETRY_POLICY },
|
|
145
|
+
regionMergeGap: 8,
|
|
146
|
+
regionMinArea: 100,
|
|
147
|
+
diffMaskColor: [255, 0, 0, 128],
|
|
148
|
+
analyses: {
|
|
149
|
+
default: [],
|
|
150
|
+
disabled: [],
|
|
151
|
+
options: {}
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
function getDefaultConfig() {
|
|
155
|
+
return structuredClone(DEFAULTS);
|
|
156
|
+
}
|
|
157
|
+
function parseConfig(raw) {
|
|
158
|
+
const parsed = configSchema.parse(raw);
|
|
159
|
+
const defaults = getDefaultConfig();
|
|
160
|
+
return {
|
|
161
|
+
...defaults,
|
|
162
|
+
...parsed,
|
|
163
|
+
viewport: { ...defaults.viewport, ...parsed.viewport },
|
|
164
|
+
timeouts: { ...defaults.timeouts, ...parsed.timeouts },
|
|
165
|
+
retry: { ...defaults.retry, ...parsed.retry },
|
|
166
|
+
analyses: {
|
|
167
|
+
default: parsed.analyses?.default ?? DEFAULTS.analyses.default,
|
|
168
|
+
disabled: parsed.analyses?.disabled ?? DEFAULTS.analyses.disabled,
|
|
169
|
+
options: { ...DEFAULTS.analyses.options, ...parsed.analyses?.options }
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
var explorer = cosmiconfig("driftx");
|
|
174
|
+
async function loadConfig(searchFrom) {
|
|
175
|
+
const result = await explorer.search(searchFrom);
|
|
176
|
+
if (!result || result.isEmpty) {
|
|
177
|
+
return getDefaultConfig();
|
|
178
|
+
}
|
|
179
|
+
return parseConfig(result.config);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// src/logger.ts
|
|
183
|
+
import pino from "pino";
|
|
184
|
+
function createLogger(level = "info") {
|
|
185
|
+
return pino({
|
|
186
|
+
level: level === "silent" ? "silent" : level,
|
|
187
|
+
transport: process.env.NODE_ENV !== "test" ? { target: "pino-pretty", options: { colorize: true } } : void 0
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
var _logger;
|
|
191
|
+
function getLogger() {
|
|
192
|
+
if (!_logger) {
|
|
193
|
+
_logger = createLogger();
|
|
194
|
+
}
|
|
195
|
+
return _logger;
|
|
196
|
+
}
|
|
197
|
+
function setLogger(logger) {
|
|
198
|
+
_logger = logger;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// src/prerequisites.ts
|
|
202
|
+
var CLI_PREREQUISITES = [
|
|
203
|
+
{
|
|
204
|
+
name: "node",
|
|
205
|
+
cmd: "node",
|
|
206
|
+
args: ["--version"],
|
|
207
|
+
required: true,
|
|
208
|
+
versionParser: (stdout) => stdout.trim().replace(/^v/, ""),
|
|
209
|
+
fix: "Install Node.js >= 18 from https://nodejs.org"
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
name: "adb",
|
|
213
|
+
cmd: "adb",
|
|
214
|
+
args: ["--version"],
|
|
215
|
+
required: false,
|
|
216
|
+
versionParser: (stdout) => {
|
|
217
|
+
const match = stdout.match(/(\d+\.\d+\.\d+)/);
|
|
218
|
+
return match?.[1] ?? "unknown";
|
|
219
|
+
},
|
|
220
|
+
fix: "Install Android SDK Platform-Tools: https://developer.android.com/studio"
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
name: "xcrun",
|
|
224
|
+
cmd: "xcrun",
|
|
225
|
+
args: ["--version"],
|
|
226
|
+
required: false,
|
|
227
|
+
versionParser: (stdout) => stdout.trim(),
|
|
228
|
+
fix: "Install Xcode Command Line Tools: xcode-select --install"
|
|
229
|
+
}
|
|
230
|
+
];
|
|
231
|
+
async function checkOne(spec, shell) {
|
|
232
|
+
try {
|
|
233
|
+
const { stdout } = await shell.exec(spec.cmd, spec.args, { timeout: 5e3 });
|
|
234
|
+
return {
|
|
235
|
+
name: spec.name,
|
|
236
|
+
required: spec.required,
|
|
237
|
+
available: true,
|
|
238
|
+
version: spec.versionParser(stdout)
|
|
239
|
+
};
|
|
240
|
+
} catch (err) {
|
|
241
|
+
return {
|
|
242
|
+
name: spec.name,
|
|
243
|
+
required: spec.required,
|
|
244
|
+
available: false,
|
|
245
|
+
error: err instanceof Error ? err.message : String(err),
|
|
246
|
+
fix: spec.fix
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
async function checkMetro(port) {
|
|
251
|
+
try {
|
|
252
|
+
const { default: http2 } = await import("http");
|
|
253
|
+
const status = await new Promise((resolve2, reject) => {
|
|
254
|
+
const req = http2.get(`http://localhost:${port}/status`, { timeout: 2e3 }, (res) => {
|
|
255
|
+
let data = "";
|
|
256
|
+
res.on("data", (chunk) => {
|
|
257
|
+
data += chunk;
|
|
258
|
+
});
|
|
259
|
+
res.on("end", () => resolve2(data));
|
|
260
|
+
});
|
|
261
|
+
req.on("error", reject);
|
|
262
|
+
req.on("timeout", () => {
|
|
263
|
+
req.destroy();
|
|
264
|
+
reject(new Error("timeout"));
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
return {
|
|
268
|
+
name: "metro",
|
|
269
|
+
required: false,
|
|
270
|
+
available: true,
|
|
271
|
+
version: `running on :${port}`
|
|
272
|
+
};
|
|
273
|
+
} catch {
|
|
274
|
+
return {
|
|
275
|
+
name: "metro",
|
|
276
|
+
required: false,
|
|
277
|
+
available: false,
|
|
278
|
+
fix: "Start Metro bundler: npx react-native start"
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
async function checkPrerequisites(shell, metroPort = 8081) {
|
|
283
|
+
const cliChecks = CLI_PREREQUISITES.map((spec) => checkOne(spec, shell));
|
|
284
|
+
const metro = checkMetro(metroPort);
|
|
285
|
+
return Promise.all([...cliChecks, metro]);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// src/exit-codes.ts
|
|
289
|
+
var ExitCode = {
|
|
290
|
+
Success: 0,
|
|
291
|
+
DiffFound: 1,
|
|
292
|
+
ConfigError: 2,
|
|
293
|
+
RuntimeError: 3,
|
|
294
|
+
PrerequisiteMissing: 4
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// src/commands/doctor.ts
|
|
298
|
+
function computeDoctorExitCode(checks) {
|
|
299
|
+
return checks.some((c) => c.required && !c.available) ? ExitCode.PrerequisiteMissing : ExitCode.Success;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// src/commands/init.ts
|
|
303
|
+
function detectFramework(files, packageJson) {
|
|
304
|
+
if (packageJson) {
|
|
305
|
+
const deps = {
|
|
306
|
+
...packageJson.dependencies,
|
|
307
|
+
...packageJson.devDependencies
|
|
308
|
+
};
|
|
309
|
+
if (deps["react-native"]) {
|
|
310
|
+
return "react-native";
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const hasGradle = files.some((f) => f.endsWith(".gradle") || f.endsWith(".gradle.kts"));
|
|
314
|
+
if (hasGradle) return "native-android";
|
|
315
|
+
const hasXcode = files.some((f) => f.endsWith(".xcodeproj") || f.endsWith(".xcworkspace"));
|
|
316
|
+
if (hasXcode) return "native-ios";
|
|
317
|
+
return "unknown";
|
|
318
|
+
}
|
|
319
|
+
function generateConfig(framework) {
|
|
320
|
+
const base = {
|
|
321
|
+
threshold: 0.1,
|
|
322
|
+
diffThreshold: 0.01,
|
|
323
|
+
settleTimeMs: 300,
|
|
324
|
+
viewport: {
|
|
325
|
+
cropStatusBar: true,
|
|
326
|
+
cropNavigationBar: true
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
switch (framework) {
|
|
330
|
+
case "react-native":
|
|
331
|
+
return { ...base, framework, platform: "android", metroPort: 8081 };
|
|
332
|
+
case "native-android":
|
|
333
|
+
return { ...base, framework, platform: "android" };
|
|
334
|
+
case "native-ios":
|
|
335
|
+
return { ...base, framework, platform: "ios" };
|
|
336
|
+
default:
|
|
337
|
+
return { ...base, framework, platform: "android" };
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// src/devices/android-discovery.ts
|
|
342
|
+
function parseAdbDevices(output) {
|
|
343
|
+
const devices = [];
|
|
344
|
+
const lines = output.split("\n");
|
|
345
|
+
for (const line of lines) {
|
|
346
|
+
if (line.startsWith("List of devices") || line.trim() === "") continue;
|
|
347
|
+
const match = line.match(/^(\S+)\s+(device|offline|unauthorized)(.*)$/);
|
|
348
|
+
if (!match) continue;
|
|
349
|
+
const [, id, rawState, rest] = match;
|
|
350
|
+
const modelMatch = rest.match(/model:(\S+)/);
|
|
351
|
+
const transportMatch = rest.match(/transport_id:(\S+)/);
|
|
352
|
+
const state = rawState === "device" ? "booted" : rawState === "offline" ? "offline" : "unauthorized";
|
|
353
|
+
devices.push({
|
|
354
|
+
id,
|
|
355
|
+
name: modelMatch?.[1] ?? id,
|
|
356
|
+
platform: "android",
|
|
357
|
+
osVersion: "",
|
|
358
|
+
state,
|
|
359
|
+
transport: transportMatch?.[1]
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
return devices;
|
|
363
|
+
}
|
|
364
|
+
async function fetchApiLevel(shell, deviceId) {
|
|
365
|
+
try {
|
|
366
|
+
const { stdout } = await shell.exec("adb", ["-s", deviceId, "shell", "getprop", "ro.build.version.sdk"]);
|
|
367
|
+
return stdout.trim();
|
|
368
|
+
} catch {
|
|
369
|
+
return "";
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
async function discoverAndroidDevices(shell) {
|
|
373
|
+
const { stdout } = await shell.exec("adb", ["devices", "-l"]);
|
|
374
|
+
const devices = parseAdbDevices(stdout);
|
|
375
|
+
for (const device of devices) {
|
|
376
|
+
if (device.state === "booted") {
|
|
377
|
+
device.osVersion = await fetchApiLevel(shell, device.id);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return devices;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// src/devices/ios-discovery.ts
|
|
384
|
+
function parseOsVersion(runtime) {
|
|
385
|
+
const match = runtime.match(/iOS-(\d+)-(\d+)/);
|
|
386
|
+
return match ? `${match[1]}.${match[2]}` : "";
|
|
387
|
+
}
|
|
388
|
+
function parseSimctlDevices(output) {
|
|
389
|
+
let parsed;
|
|
390
|
+
try {
|
|
391
|
+
parsed = JSON.parse(output);
|
|
392
|
+
} catch {
|
|
393
|
+
return [];
|
|
394
|
+
}
|
|
395
|
+
const devices = [];
|
|
396
|
+
for (const [runtime, sims] of Object.entries(parsed.devices)) {
|
|
397
|
+
const osVersion = parseOsVersion(runtime);
|
|
398
|
+
for (const sim of sims) {
|
|
399
|
+
if (!sim.isAvailable) continue;
|
|
400
|
+
const state = sim.state === "Booted" ? "booted" : "offline";
|
|
401
|
+
devices.push({
|
|
402
|
+
id: sim.udid,
|
|
403
|
+
name: sim.name,
|
|
404
|
+
platform: "ios",
|
|
405
|
+
osVersion,
|
|
406
|
+
state
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return devices;
|
|
411
|
+
}
|
|
412
|
+
async function discoverIosDevices(shell) {
|
|
413
|
+
const { stdout } = await shell.exec("xcrun", ["simctl", "list", "devices", "--json"]);
|
|
414
|
+
return parseSimctlDevices(stdout);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// src/devices/discovery.ts
|
|
418
|
+
var DeviceDiscovery = class {
|
|
419
|
+
shell;
|
|
420
|
+
cacheTtlMs;
|
|
421
|
+
cache = null;
|
|
422
|
+
cacheTime = 0;
|
|
423
|
+
constructor(shell, options) {
|
|
424
|
+
this.shell = shell;
|
|
425
|
+
this.cacheTtlMs = options?.cacheTtlMs ?? 3e4;
|
|
426
|
+
}
|
|
427
|
+
async list() {
|
|
428
|
+
if (this.cache && Date.now() - this.cacheTime < this.cacheTtlMs) {
|
|
429
|
+
return this.cache;
|
|
430
|
+
}
|
|
431
|
+
const results = [];
|
|
432
|
+
const logger = getLogger();
|
|
433
|
+
try {
|
|
434
|
+
const android = await discoverAndroidDevices(this.shell);
|
|
435
|
+
results.push(...android);
|
|
436
|
+
} catch (err) {
|
|
437
|
+
logger.debug({ err }, "Android discovery failed");
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
const ios = await discoverIosDevices(this.shell);
|
|
441
|
+
results.push(...ios);
|
|
442
|
+
} catch (err) {
|
|
443
|
+
logger.debug({ err }, "iOS discovery failed");
|
|
444
|
+
}
|
|
445
|
+
this.cache = results;
|
|
446
|
+
this.cacheTime = Date.now();
|
|
447
|
+
return results;
|
|
448
|
+
}
|
|
449
|
+
async findById(id) {
|
|
450
|
+
const devices = await this.list();
|
|
451
|
+
return devices.find((d) => d.id === id);
|
|
452
|
+
}
|
|
453
|
+
invalidateCache() {
|
|
454
|
+
this.cache = null;
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
// src/commands/capture.ts
|
|
459
|
+
import * as fs4 from "fs";
|
|
460
|
+
import * as path4 from "path";
|
|
461
|
+
|
|
462
|
+
// src/capture/android-capture.ts
|
|
463
|
+
import * as fs from "fs";
|
|
464
|
+
import * as os from "os";
|
|
465
|
+
import * as path from "path";
|
|
466
|
+
var DEVICE_TMP_PATH = "/sdcard/driftx-tmp.png";
|
|
467
|
+
async function captureAndroidScreenshot(shell, deviceId, timeout) {
|
|
468
|
+
const localTmp = path.join(os.tmpdir(), `driftx-android-${Date.now()}-${deviceId}.png`);
|
|
469
|
+
try {
|
|
470
|
+
await shell.exec(
|
|
471
|
+
"adb",
|
|
472
|
+
["-s", deviceId, "shell", "screencap", "-p", DEVICE_TMP_PATH],
|
|
473
|
+
timeout ? { timeout } : void 0
|
|
474
|
+
);
|
|
475
|
+
await shell.exec("adb", ["-s", deviceId, "pull", DEVICE_TMP_PATH, localTmp]);
|
|
476
|
+
if (!fs.existsSync(localTmp)) {
|
|
477
|
+
throw new Error("Screenshot capture returned empty buffer \u2014 pull did not create local file");
|
|
478
|
+
}
|
|
479
|
+
const buffer = fs.readFileSync(localTmp);
|
|
480
|
+
if (buffer.length === 0) {
|
|
481
|
+
throw new Error("Screenshot capture returned empty buffer");
|
|
482
|
+
}
|
|
483
|
+
return buffer;
|
|
484
|
+
} finally {
|
|
485
|
+
try {
|
|
486
|
+
await shell.exec("adb", ["-s", deviceId, "shell", "rm", DEVICE_TMP_PATH]);
|
|
487
|
+
} catch {
|
|
488
|
+
}
|
|
489
|
+
try {
|
|
490
|
+
if (fs.existsSync(localTmp)) fs.unlinkSync(localTmp);
|
|
491
|
+
} catch {
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// src/capture/ios-capture.ts
|
|
497
|
+
import * as fs2 from "fs";
|
|
498
|
+
import * as os2 from "os";
|
|
499
|
+
import * as path2 from "path";
|
|
500
|
+
async function captureIosScreenshot(shell, deviceId, tmpPath) {
|
|
501
|
+
const screenshotPath = tmpPath ?? path2.join(os2.tmpdir(), `driftx-ios-${Date.now()}.png`);
|
|
502
|
+
await shell.exec("xcrun", ["simctl", "io", deviceId, "screenshot", screenshotPath]);
|
|
503
|
+
if (!fs2.existsSync(screenshotPath)) {
|
|
504
|
+
throw new Error(`iOS screenshot not created at ${screenshotPath}`);
|
|
505
|
+
}
|
|
506
|
+
const buffer = fs2.readFileSync(screenshotPath);
|
|
507
|
+
if (!tmpPath) fs2.unlinkSync(screenshotPath);
|
|
508
|
+
if (buffer.length === 0) throw new Error("iOS screenshot capture returned empty file");
|
|
509
|
+
return buffer;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// src/capture/capture.ts
|
|
513
|
+
import pixelmatch from "pixelmatch";
|
|
514
|
+
import { PNG } from "pngjs";
|
|
515
|
+
function isScreenSettled(buf1, buf2, maxDelta) {
|
|
516
|
+
const img1 = PNG.sync.read(buf1);
|
|
517
|
+
const img2 = PNG.sync.read(buf2);
|
|
518
|
+
if (img1.width !== img2.width || img1.height !== img2.height) return false;
|
|
519
|
+
const diffPixels = pixelmatch(img1.data, img2.data, void 0, img1.width, img1.height, {
|
|
520
|
+
threshold: 0.1
|
|
521
|
+
});
|
|
522
|
+
const totalPixels = img1.width * img1.height;
|
|
523
|
+
return diffPixels / totalPixels <= maxDelta;
|
|
524
|
+
}
|
|
525
|
+
function delay(ms) {
|
|
526
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
527
|
+
}
|
|
528
|
+
async function captureScreenshot(shell, device, options) {
|
|
529
|
+
const logger = getLogger();
|
|
530
|
+
const captureOnce = async () => {
|
|
531
|
+
if (device.platform === "android") {
|
|
532
|
+
return captureAndroidScreenshot(shell, device.id, options?.timeout);
|
|
533
|
+
}
|
|
534
|
+
return captureIosScreenshot(shell, device.id);
|
|
535
|
+
};
|
|
536
|
+
let buffer = await captureOnce();
|
|
537
|
+
if (options?.settleCheck) {
|
|
538
|
+
const maxDelta = options.settleMaxDelta ?? 1e-3;
|
|
539
|
+
const delayMs = options.settleDelayMs ?? 300;
|
|
540
|
+
const maxAttempts = options.settleMaxAttempts ?? 5;
|
|
541
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
542
|
+
await delay(delayMs);
|
|
543
|
+
const next = await captureOnce();
|
|
544
|
+
if (isScreenSettled(buffer, next, maxDelta)) {
|
|
545
|
+
logger.debug(`Screen settled after ${i + 1} check(s)`);
|
|
546
|
+
return next;
|
|
547
|
+
}
|
|
548
|
+
buffer = next;
|
|
549
|
+
}
|
|
550
|
+
logger.warn("Screen did not settle within max attempts, using last capture");
|
|
551
|
+
}
|
|
552
|
+
return buffer;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// src/run-store.ts
|
|
556
|
+
import * as fs3 from "fs";
|
|
557
|
+
import * as path3 from "path";
|
|
558
|
+
import { nanoid } from "nanoid";
|
|
559
|
+
var RunStore = class {
|
|
560
|
+
baseDir;
|
|
561
|
+
constructor(projectRoot) {
|
|
562
|
+
this.baseDir = path3.join(projectRoot, ".driftx", "runs");
|
|
563
|
+
}
|
|
564
|
+
createRun() {
|
|
565
|
+
const runId = nanoid(12);
|
|
566
|
+
const dir = path3.join(this.baseDir, runId);
|
|
567
|
+
fs3.mkdirSync(dir, { recursive: true });
|
|
568
|
+
return { runId, dir };
|
|
569
|
+
}
|
|
570
|
+
getRunDir(runId) {
|
|
571
|
+
return path3.join(this.baseDir, runId);
|
|
572
|
+
}
|
|
573
|
+
async writeMetadata(runId, metadata) {
|
|
574
|
+
const filePath = path3.join(this.getRunDir(runId), "metadata.json");
|
|
575
|
+
fs3.writeFileSync(filePath, JSON.stringify(metadata, null, 2));
|
|
576
|
+
}
|
|
577
|
+
async writeArtifact(runId, relativePath, data) {
|
|
578
|
+
const filePath = path3.join(this.getRunDir(runId), relativePath);
|
|
579
|
+
fs3.mkdirSync(path3.dirname(filePath), { recursive: true });
|
|
580
|
+
fs3.writeFileSync(filePath, data);
|
|
581
|
+
}
|
|
582
|
+
listRuns() {
|
|
583
|
+
if (!fs3.existsSync(this.baseDir)) return [];
|
|
584
|
+
return fs3.readdirSync(this.baseDir).filter((f) => {
|
|
585
|
+
return fs3.statSync(path3.join(this.baseDir, f)).isDirectory();
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
getRunPath(runId, relativePath) {
|
|
589
|
+
const dir = this.getRunDir(runId);
|
|
590
|
+
return relativePath ? path3.join(dir, relativePath) : dir;
|
|
591
|
+
}
|
|
592
|
+
readArtifact(runId, relativePath) {
|
|
593
|
+
const fullPath = path3.join(this.getRunDir(runId), relativePath);
|
|
594
|
+
try {
|
|
595
|
+
return fs3.readFileSync(fullPath);
|
|
596
|
+
} catch {
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
getLatestRun() {
|
|
601
|
+
const runs = this.listRuns();
|
|
602
|
+
if (runs.length === 0) return void 0;
|
|
603
|
+
let latest;
|
|
604
|
+
let latestTime = 0;
|
|
605
|
+
for (const runId of runs) {
|
|
606
|
+
try {
|
|
607
|
+
const stat = fs3.statSync(this.getRunDir(runId));
|
|
608
|
+
if (stat.mtimeMs > latestTime) {
|
|
609
|
+
latestTime = stat.mtimeMs;
|
|
610
|
+
latest = runId;
|
|
611
|
+
}
|
|
612
|
+
} catch {
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return latest;
|
|
617
|
+
}
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
// src/retry.ts
|
|
621
|
+
function isRetryable(error, policy) {
|
|
622
|
+
return policy.retryableErrors.some((pattern) => error.message.includes(pattern));
|
|
623
|
+
}
|
|
624
|
+
function delay2(ms, signal) {
|
|
625
|
+
return new Promise((resolve2, reject) => {
|
|
626
|
+
if (signal?.aborted) {
|
|
627
|
+
reject(new Error("Aborted"));
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
const timer = setTimeout(resolve2, ms);
|
|
631
|
+
signal?.addEventListener("abort", () => {
|
|
632
|
+
clearTimeout(timer);
|
|
633
|
+
reject(new Error("Aborted"));
|
|
634
|
+
}, { once: true });
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
async function withRetry(fn, policy, signal) {
|
|
638
|
+
let lastError;
|
|
639
|
+
for (let attempt = 0; attempt < policy.maxAttempts; attempt++) {
|
|
640
|
+
try {
|
|
641
|
+
return await fn();
|
|
642
|
+
} catch (err) {
|
|
643
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
644
|
+
if (!isRetryable(lastError, policy)) {
|
|
645
|
+
throw lastError;
|
|
646
|
+
}
|
|
647
|
+
if (signal?.aborted) {
|
|
648
|
+
throw lastError;
|
|
649
|
+
}
|
|
650
|
+
if (attempt < policy.maxAttempts - 1) {
|
|
651
|
+
const backoff = Math.min(
|
|
652
|
+
policy.baseDelayMs * Math.pow(policy.backoffMultiplier, attempt),
|
|
653
|
+
policy.maxDelayMs
|
|
654
|
+
);
|
|
655
|
+
try {
|
|
656
|
+
await delay2(backoff, signal);
|
|
657
|
+
} catch {
|
|
658
|
+
throw lastError;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
throw lastError;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// src/commands/device-picker.ts
|
|
667
|
+
import { select } from "@inquirer/prompts";
|
|
668
|
+
async function pickDevice(booted) {
|
|
669
|
+
if (booted.length === 1) return booted[0];
|
|
670
|
+
const selected = await select({
|
|
671
|
+
message: "Select a device",
|
|
672
|
+
choices: booted.map((d) => ({
|
|
673
|
+
name: `${d.name} (${d.platform}, ${d.osVersion || "unknown"})`,
|
|
674
|
+
value: d.id
|
|
675
|
+
}))
|
|
676
|
+
});
|
|
677
|
+
return booted.find((d) => d.id === selected);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// src/commands/capture.ts
|
|
681
|
+
async function runCapture(shell, config, options) {
|
|
682
|
+
const discovery = new DeviceDiscovery(shell);
|
|
683
|
+
const devices = await discovery.list();
|
|
684
|
+
const booted = devices.filter((d) => d.state === "booted");
|
|
685
|
+
if (booted.length === 0) {
|
|
686
|
+
throw new Error("No booted devices found. Start an emulator or boot a simulator.");
|
|
687
|
+
}
|
|
688
|
+
let device;
|
|
689
|
+
if (options.device) {
|
|
690
|
+
const found = booted.find((d) => d.id === options.device || d.name === options.device);
|
|
691
|
+
if (!found) throw new Error(`Device not found: ${options.device}`);
|
|
692
|
+
device = found;
|
|
693
|
+
} else if (config.primaryDevice) {
|
|
694
|
+
const found = booted.find((d) => d.id === config.primaryDevice || d.name === config.primaryDevice);
|
|
695
|
+
if (!found) throw new Error(`Primary device not found: ${config.primaryDevice}`);
|
|
696
|
+
device = found;
|
|
697
|
+
} else {
|
|
698
|
+
device = await pickDevice(booted);
|
|
699
|
+
}
|
|
700
|
+
const buffer = await withRetry(
|
|
701
|
+
() => captureScreenshot(shell, device, {
|
|
702
|
+
settleCheck: options.settleCheck ?? config.settleCheckEnabled,
|
|
703
|
+
settleMaxDelta: config.settleMaxDelta,
|
|
704
|
+
settleDelayMs: config.settleTimeMs
|
|
705
|
+
}),
|
|
706
|
+
config.retry ?? DEFAULT_RETRY_POLICY
|
|
707
|
+
);
|
|
708
|
+
const projectRoot = process.cwd();
|
|
709
|
+
if (options.output) {
|
|
710
|
+
fs4.mkdirSync(path4.dirname(options.output), { recursive: true });
|
|
711
|
+
fs4.writeFileSync(options.output, buffer);
|
|
712
|
+
return { path: options.output, runId: "" };
|
|
713
|
+
}
|
|
714
|
+
const store = new RunStore(projectRoot);
|
|
715
|
+
const run2 = store.createRun();
|
|
716
|
+
await store.writeArtifact(run2.runId, "screenshot.png", buffer);
|
|
717
|
+
await store.writeMetadata(run2.runId, {
|
|
718
|
+
runId: run2.runId,
|
|
719
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
720
|
+
completedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
721
|
+
deviceId: device.id,
|
|
722
|
+
platform: device.platform,
|
|
723
|
+
orientation: "portrait",
|
|
724
|
+
framework: "unknown",
|
|
725
|
+
projectRoot,
|
|
726
|
+
driftxVersion: "0.1.0"
|
|
727
|
+
});
|
|
728
|
+
return { path: store.getRunPath(run2.runId, "screenshot.png"), runId: run2.runId };
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// src/inspect/uiautomator.ts
|
|
732
|
+
function parseBounds(boundsStr) {
|
|
733
|
+
const match = boundsStr.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
|
|
734
|
+
if (!match) return { x: 0, y: 0, width: 0, height: 0 };
|
|
735
|
+
const [, x1, y1, x2, y2] = match.map(Number);
|
|
736
|
+
return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
|
|
737
|
+
}
|
|
738
|
+
function parseNode(nodeStr, idCounter) {
|
|
739
|
+
const attrMatch = nodeStr.match(/^<node\s+([^>]*?)(\/?>)/);
|
|
740
|
+
if (!attrMatch) return null;
|
|
741
|
+
const attrs = attrMatch[1];
|
|
742
|
+
const selfClosing = attrMatch[2] === "/>";
|
|
743
|
+
const afterTag = nodeStr.slice(attrMatch[0].length);
|
|
744
|
+
const get = (name) => {
|
|
745
|
+
const m = attrs.match(new RegExp(`${name}="([^"]*)"`));
|
|
746
|
+
return m ? m[1] : "";
|
|
747
|
+
};
|
|
748
|
+
const id = String(idCounter.n++);
|
|
749
|
+
const resourceId = get("resource-id");
|
|
750
|
+
const className = get("class");
|
|
751
|
+
const node = {
|
|
752
|
+
id,
|
|
753
|
+
name: className || "unknown",
|
|
754
|
+
nativeName: className || void 0,
|
|
755
|
+
testID: resourceId || void 0,
|
|
756
|
+
bounds: parseBounds(get("bounds")),
|
|
757
|
+
text: get("text") || void 0,
|
|
758
|
+
children: [],
|
|
759
|
+
inspectionTier: "basic"
|
|
760
|
+
};
|
|
761
|
+
if (selfClosing) {
|
|
762
|
+
return { node, remaining: afterTag };
|
|
763
|
+
}
|
|
764
|
+
let rest = afterTag;
|
|
765
|
+
while (true) {
|
|
766
|
+
rest = rest.replace(/^\s+/, "");
|
|
767
|
+
if (rest.startsWith("</node>")) {
|
|
768
|
+
rest = rest.slice("</node>".length);
|
|
769
|
+
break;
|
|
770
|
+
}
|
|
771
|
+
const child = parseNode(rest, idCounter);
|
|
772
|
+
if (!child) break;
|
|
773
|
+
node.children.push(child.node);
|
|
774
|
+
rest = child.remaining;
|
|
775
|
+
}
|
|
776
|
+
return { node, remaining: rest };
|
|
777
|
+
}
|
|
778
|
+
function parseUiAutomatorXml(xml) {
|
|
779
|
+
if (!xml.includes("<hierarchy")) {
|
|
780
|
+
throw new Error("Invalid UIAutomator XML: missing <hierarchy> element");
|
|
781
|
+
}
|
|
782
|
+
const hierarchyMatch = xml.match(/<hierarchy[^>]*>([\s\S]*)<\/hierarchy>/);
|
|
783
|
+
if (!hierarchyMatch) return [];
|
|
784
|
+
const content = hierarchyMatch[1].trim();
|
|
785
|
+
if (!content) return [];
|
|
786
|
+
const nodes = [];
|
|
787
|
+
const idCounter = { n: 0 };
|
|
788
|
+
let remaining = content;
|
|
789
|
+
while (remaining.trim()) {
|
|
790
|
+
remaining = remaining.replace(/^\s+/, "");
|
|
791
|
+
if (!remaining.startsWith("<node")) break;
|
|
792
|
+
const result = parseNode(remaining, idCounter);
|
|
793
|
+
if (!result) break;
|
|
794
|
+
nodes.push(result.node);
|
|
795
|
+
remaining = result.remaining;
|
|
796
|
+
}
|
|
797
|
+
return nodes;
|
|
798
|
+
}
|
|
799
|
+
async function dumpUiAutomator(shell, deviceId, timeout) {
|
|
800
|
+
const { stdout } = await shell.exec("adb", ["-s", deviceId, "exec-out", "uiautomator", "dump", "/dev/tty"], timeout ? { timeout } : void 0);
|
|
801
|
+
return parseUiAutomatorXml(stdout);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// src/inspect/ios-accessibility.ts
|
|
805
|
+
function parseElement(el, idCounter) {
|
|
806
|
+
const id = String(idCounter.n++);
|
|
807
|
+
return {
|
|
808
|
+
id,
|
|
809
|
+
name: el.role ?? "unknown",
|
|
810
|
+
nativeName: el.role,
|
|
811
|
+
testID: el.identifier || void 0,
|
|
812
|
+
bounds: el.frame ? { x: el.frame.x, y: el.frame.y, width: el.frame.width, height: el.frame.height } : { x: 0, y: 0, width: 0, height: 0 },
|
|
813
|
+
text: el.label || void 0,
|
|
814
|
+
children: (el.children ?? []).map((c) => parseElement(c, idCounter)),
|
|
815
|
+
inspectionTier: "basic"
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
function parseIosAccessibility(json) {
|
|
819
|
+
const data = JSON.parse(json);
|
|
820
|
+
const elements = data.AXElements ?? [];
|
|
821
|
+
const idCounter = { n: 0 };
|
|
822
|
+
return elements.map((el) => parseElement(el, idCounter));
|
|
823
|
+
}
|
|
824
|
+
async function dumpIosAccessibility(shell, deviceId, timeout) {
|
|
825
|
+
const logger = getLogger();
|
|
826
|
+
const opts = timeout ? { timeout } : void 0;
|
|
827
|
+
try {
|
|
828
|
+
const { stdout } = await shell.exec("idb", ["ui", "describe-all", "--udid", deviceId], opts);
|
|
829
|
+
return parseIosAccessibility(stdout);
|
|
830
|
+
} catch {
|
|
831
|
+
logger.debug("idb not available, skipping iOS accessibility tree");
|
|
832
|
+
}
|
|
833
|
+
throw new Error("iOS accessibility inspection requires idb (brew install idb-companion && pip install fb-idb) or React DevTools");
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// src/inspect/cdp-client.ts
|
|
837
|
+
import WebSocket from "ws";
|
|
838
|
+
import http from "http";
|
|
839
|
+
var FIBER_WALK_SCRIPT = `(function() {
|
|
840
|
+
try {
|
|
841
|
+
var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
842
|
+
if (!hook || !hook.renderers || hook.renderers.size === 0) return JSON.stringify(null);
|
|
843
|
+
|
|
844
|
+
var flat = [];
|
|
845
|
+
hook.renderers.forEach(function(renderer, id) {
|
|
846
|
+
var roots = hook.getFiberRoots(id);
|
|
847
|
+
if (!roots) return;
|
|
848
|
+
roots.forEach(function(root) {
|
|
849
|
+
if (root.current) flattenFiber(root.current, 0);
|
|
850
|
+
});
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
return flat.length > 0 ? JSON.stringify(flat) : JSON.stringify(null);
|
|
854
|
+
} catch(e) {
|
|
855
|
+
return JSON.stringify(null);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function getName(fiber) {
|
|
859
|
+
if (typeof fiber.type === 'string') return fiber.type;
|
|
860
|
+
if (fiber.type && fiber.type.displayName) return fiber.type.displayName;
|
|
861
|
+
if (fiber.type && fiber.type.name) return fiber.type.name;
|
|
862
|
+
if (fiber.tag === 3) return null;
|
|
863
|
+
return null;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function isNoise(name, fiber) {
|
|
867
|
+
if (!name) return true;
|
|
868
|
+
if (fiber.tag === 6) return true;
|
|
869
|
+
var props = fiber.memoizedProps || {};
|
|
870
|
+
var hasSignal = props.testID || props.nativeID || typeof props.children === 'string';
|
|
871
|
+
if (hasSignal) return false;
|
|
872
|
+
if (name === 'Unknown') return true;
|
|
873
|
+
if (fiber.tag === 5) return true;
|
|
874
|
+
if (name.substring(0, 3) === 'RCT' || name.substring(0, 5) === 'RNSVG') return true;
|
|
875
|
+
if (name.indexOf('ViewManagerAdapter_') === 0) return true;
|
|
876
|
+
var len = name.length;
|
|
877
|
+
if (len > 7 && name.indexOf('Context', len - 7) === len - 7) return true;
|
|
878
|
+
if (len > 8 && name.indexOf('Provider', len - 8) === len - 8) return true;
|
|
879
|
+
if (len > 8 && name.indexOf('Consumer', len - 8) === len - 8) return true;
|
|
880
|
+
var c = name.charCodeAt(0);
|
|
881
|
+
if (c >= 97 && c <= 122) return true;
|
|
882
|
+
if (name.indexOf('Animated(') === 0) return true;
|
|
883
|
+
return false;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function flattenFiber(fiber, depth) {
|
|
887
|
+
if (!fiber) return;
|
|
888
|
+
var name = getName(fiber);
|
|
889
|
+
var noise = isNoise(name, fiber);
|
|
890
|
+
|
|
891
|
+
if (!noise) {
|
|
892
|
+
var props = fiber.memoizedProps || {};
|
|
893
|
+
var testID = props.testID || props.nativeID || undefined;
|
|
894
|
+
var text = typeof props.children === 'string' ? props.children : undefined;
|
|
895
|
+
flat.push({ n: name, d: depth, t: testID, x: text });
|
|
896
|
+
var child = fiber.child;
|
|
897
|
+
while (child) { flattenFiber(child, depth + 1); child = child.sibling; }
|
|
898
|
+
} else {
|
|
899
|
+
var child = fiber.child;
|
|
900
|
+
while (child) { flattenFiber(child, depth); child = child.sibling; }
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
})()`;
|
|
904
|
+
async function discoverTargets(metroPort) {
|
|
905
|
+
return new Promise((resolve2, reject) => {
|
|
906
|
+
const req = http.get(`http://localhost:${metroPort}/json/list`, { timeout: 2e3 }, (res) => {
|
|
907
|
+
let data = "";
|
|
908
|
+
res.on("data", (chunk) => {
|
|
909
|
+
data += chunk;
|
|
910
|
+
});
|
|
911
|
+
res.on("end", () => {
|
|
912
|
+
try {
|
|
913
|
+
resolve2(JSON.parse(data));
|
|
914
|
+
} catch {
|
|
915
|
+
resolve2([]);
|
|
916
|
+
}
|
|
917
|
+
});
|
|
918
|
+
});
|
|
919
|
+
req.on("error", () => resolve2([]));
|
|
920
|
+
req.on("timeout", () => {
|
|
921
|
+
req.destroy();
|
|
922
|
+
resolve2([]);
|
|
923
|
+
});
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
function findRuntimeTarget(targets, deviceName) {
|
|
927
|
+
const rnTargets = targets.filter(
|
|
928
|
+
(t) => t.reactNative && t.description.includes("React Native")
|
|
929
|
+
);
|
|
930
|
+
if (deviceName) {
|
|
931
|
+
return rnTargets.find(
|
|
932
|
+
(t) => t.deviceName?.toLowerCase().includes(deviceName.toLowerCase())
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
return rnTargets[0];
|
|
936
|
+
}
|
|
937
|
+
var CdpClient = class {
|
|
938
|
+
ws = null;
|
|
939
|
+
connected = false;
|
|
940
|
+
msgId = 0;
|
|
941
|
+
pending = /* @__PURE__ */ new Map();
|
|
942
|
+
async connectAndGetTree(metroPort, timeoutMs, deviceName) {
|
|
943
|
+
const logger = getLogger();
|
|
944
|
+
const targets = await discoverTargets(metroPort);
|
|
945
|
+
const target = findRuntimeTarget(targets, deviceName);
|
|
946
|
+
if (!target) {
|
|
947
|
+
logger.debug("No React Native debug target found via Metro");
|
|
948
|
+
return [];
|
|
949
|
+
}
|
|
950
|
+
logger.debug(`Found CDP target: ${target.title} (${target.description})`);
|
|
951
|
+
return new Promise((resolve2, reject) => {
|
|
952
|
+
const timer = setTimeout(() => {
|
|
953
|
+
this.cleanup();
|
|
954
|
+
resolve2([]);
|
|
955
|
+
}, timeoutMs);
|
|
956
|
+
try {
|
|
957
|
+
this.ws = new WebSocket(target.webSocketDebuggerUrl);
|
|
958
|
+
} catch {
|
|
959
|
+
clearTimeout(timer);
|
|
960
|
+
resolve2([]);
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
this.ws.on("open", async () => {
|
|
964
|
+
this.connected = true;
|
|
965
|
+
try {
|
|
966
|
+
const result = await this.evaluate(FIBER_WALK_SCRIPT);
|
|
967
|
+
clearTimeout(timer);
|
|
968
|
+
resolve2(this.parseResult(result));
|
|
969
|
+
} catch {
|
|
970
|
+
clearTimeout(timer);
|
|
971
|
+
resolve2([]);
|
|
972
|
+
}
|
|
973
|
+
});
|
|
974
|
+
this.ws.on("message", (data) => {
|
|
975
|
+
try {
|
|
976
|
+
const msg = JSON.parse(data.toString());
|
|
977
|
+
if (msg.id !== void 0) {
|
|
978
|
+
const p = this.pending.get(msg.id);
|
|
979
|
+
if (p) {
|
|
980
|
+
this.pending.delete(msg.id);
|
|
981
|
+
p.resolve(msg);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
} catch {
|
|
985
|
+
}
|
|
986
|
+
});
|
|
987
|
+
this.ws.on("error", () => {
|
|
988
|
+
clearTimeout(timer);
|
|
989
|
+
resolve2([]);
|
|
990
|
+
});
|
|
991
|
+
this.ws.on("close", () => {
|
|
992
|
+
this.connected = false;
|
|
993
|
+
});
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
evaluate(expression) {
|
|
997
|
+
const id = ++this.msgId;
|
|
998
|
+
return new Promise((resolve2, reject) => {
|
|
999
|
+
this.pending.set(id, { resolve: resolve2, reject });
|
|
1000
|
+
this.ws.send(JSON.stringify({
|
|
1001
|
+
id,
|
|
1002
|
+
method: "Runtime.evaluate",
|
|
1003
|
+
params: { expression, returnByValue: true }
|
|
1004
|
+
}));
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
parseResult(response) {
|
|
1008
|
+
const value = response.result?.result?.value;
|
|
1009
|
+
if (!value || typeof value !== "string") return [];
|
|
1010
|
+
const parsed = JSON.parse(value);
|
|
1011
|
+
if (!Array.isArray(parsed)) return [];
|
|
1012
|
+
return this.optimize(this.rebuildTree(parsed));
|
|
1013
|
+
}
|
|
1014
|
+
optimize(roots) {
|
|
1015
|
+
let tree = roots;
|
|
1016
|
+
let prevCount = -1;
|
|
1017
|
+
for (let i = 0; i < 10; i++) {
|
|
1018
|
+
tree = this.dedup(tree);
|
|
1019
|
+
tree = this.pruneLeaves(tree);
|
|
1020
|
+
const count = this.countNodes(tree);
|
|
1021
|
+
if (count === prevCount) break;
|
|
1022
|
+
prevCount = count;
|
|
1023
|
+
}
|
|
1024
|
+
return tree;
|
|
1025
|
+
}
|
|
1026
|
+
countNodes(nodes) {
|
|
1027
|
+
let c = 0;
|
|
1028
|
+
for (const n of nodes) c += 1 + this.countNodes(n.children);
|
|
1029
|
+
return c;
|
|
1030
|
+
}
|
|
1031
|
+
pruneLeaves(nodes) {
|
|
1032
|
+
return nodes.reduce((acc, node) => {
|
|
1033
|
+
node.children = this.pruneLeaves(node.children);
|
|
1034
|
+
if (node.children.length === 0 && !node.testID && !node.text) return acc;
|
|
1035
|
+
acc.push(node);
|
|
1036
|
+
return acc;
|
|
1037
|
+
}, []);
|
|
1038
|
+
}
|
|
1039
|
+
rebuildTree(flat) {
|
|
1040
|
+
const roots = [];
|
|
1041
|
+
const stack = [];
|
|
1042
|
+
let id = 0;
|
|
1043
|
+
for (const entry of flat) {
|
|
1044
|
+
const node = {
|
|
1045
|
+
id: String(id++),
|
|
1046
|
+
name: entry.n,
|
|
1047
|
+
reactName: entry.n,
|
|
1048
|
+
testID: entry.t || void 0,
|
|
1049
|
+
bounds: { x: 0, y: 0, width: 0, height: 0 },
|
|
1050
|
+
text: entry.x || void 0,
|
|
1051
|
+
children: [],
|
|
1052
|
+
inspectionTier: "detailed"
|
|
1053
|
+
};
|
|
1054
|
+
while (stack.length > entry.d) stack.pop();
|
|
1055
|
+
if (stack.length === 0) {
|
|
1056
|
+
roots.push(node);
|
|
1057
|
+
} else {
|
|
1058
|
+
stack[stack.length - 1].children.push(node);
|
|
1059
|
+
}
|
|
1060
|
+
stack.push(node);
|
|
1061
|
+
}
|
|
1062
|
+
return roots;
|
|
1063
|
+
}
|
|
1064
|
+
dedup(nodes) {
|
|
1065
|
+
return nodes.map((node) => this.dedupNode(node)).filter(Boolean);
|
|
1066
|
+
}
|
|
1067
|
+
dedupNode(node) {
|
|
1068
|
+
node.children = this.dedup(node.children);
|
|
1069
|
+
if (node.children.length === 1) {
|
|
1070
|
+
const child = node.children[0];
|
|
1071
|
+
const sameText = node.text && child.text && node.text === child.text;
|
|
1072
|
+
const sameTestID = node.testID && child.testID && node.testID === child.testID;
|
|
1073
|
+
if (sameText || sameTestID) {
|
|
1074
|
+
child.children = [...child.children];
|
|
1075
|
+
return child;
|
|
1076
|
+
}
|
|
1077
|
+
const nodeHasSignal = node.testID || node.text;
|
|
1078
|
+
if (!nodeHasSignal) {
|
|
1079
|
+
child.children = [...child.children];
|
|
1080
|
+
return child;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
return node;
|
|
1084
|
+
}
|
|
1085
|
+
getCapabilities() {
|
|
1086
|
+
return {
|
|
1087
|
+
tree: this.connected ? "detailed" : "none",
|
|
1088
|
+
sourceMapping: this.connected ? "partial" : "none",
|
|
1089
|
+
styles: this.connected ? "partial" : "none",
|
|
1090
|
+
protocol: "cdp"
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
async disconnect() {
|
|
1094
|
+
this.cleanup();
|
|
1095
|
+
}
|
|
1096
|
+
cleanup() {
|
|
1097
|
+
if (this.ws) {
|
|
1098
|
+
try {
|
|
1099
|
+
this.ws.close();
|
|
1100
|
+
} catch {
|
|
1101
|
+
}
|
|
1102
|
+
this.ws = null;
|
|
1103
|
+
}
|
|
1104
|
+
this.connected = false;
|
|
1105
|
+
for (const [, p] of this.pending) {
|
|
1106
|
+
p.reject(new Error("disconnected"));
|
|
1107
|
+
}
|
|
1108
|
+
this.pending.clear();
|
|
1109
|
+
}
|
|
1110
|
+
};
|
|
1111
|
+
|
|
1112
|
+
// src/inspect/strategy-cache.ts
|
|
1113
|
+
import * as fs5 from "fs";
|
|
1114
|
+
import * as path5 from "path";
|
|
1115
|
+
var StrategyCache = class {
|
|
1116
|
+
filePath;
|
|
1117
|
+
ttlMs;
|
|
1118
|
+
entries;
|
|
1119
|
+
constructor(projectRoot, ttlMs = 6e4) {
|
|
1120
|
+
this.filePath = path5.join(projectRoot, ".driftx", "strategy-cache.json");
|
|
1121
|
+
this.ttlMs = ttlMs;
|
|
1122
|
+
this.entries = this.load();
|
|
1123
|
+
}
|
|
1124
|
+
get(deviceId) {
|
|
1125
|
+
const entry = this.entries.get(deviceId);
|
|
1126
|
+
if (!entry) return void 0;
|
|
1127
|
+
if (Date.now() - entry.timestamp > this.ttlMs) {
|
|
1128
|
+
this.entries.delete(deviceId);
|
|
1129
|
+
this.persist();
|
|
1130
|
+
return void 0;
|
|
1131
|
+
}
|
|
1132
|
+
return entry;
|
|
1133
|
+
}
|
|
1134
|
+
set(deviceId, method, reason, appId) {
|
|
1135
|
+
this.entries.set(deviceId, { method, reason, appId, timestamp: Date.now() });
|
|
1136
|
+
this.persist();
|
|
1137
|
+
}
|
|
1138
|
+
delete(deviceId) {
|
|
1139
|
+
this.entries.delete(deviceId);
|
|
1140
|
+
this.persist();
|
|
1141
|
+
}
|
|
1142
|
+
clear() {
|
|
1143
|
+
this.entries.clear();
|
|
1144
|
+
this.persist();
|
|
1145
|
+
}
|
|
1146
|
+
load() {
|
|
1147
|
+
try {
|
|
1148
|
+
const raw = fs5.readFileSync(this.filePath, "utf-8");
|
|
1149
|
+
const data = JSON.parse(raw);
|
|
1150
|
+
return new Map(Object.entries(data));
|
|
1151
|
+
} catch {
|
|
1152
|
+
return /* @__PURE__ */ new Map();
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
persist() {
|
|
1156
|
+
try {
|
|
1157
|
+
const dir = path5.dirname(this.filePath);
|
|
1158
|
+
fs5.mkdirSync(dir, { recursive: true });
|
|
1159
|
+
const obj = Object.fromEntries(this.entries);
|
|
1160
|
+
fs5.writeFileSync(this.filePath, JSON.stringify(obj, null, 2));
|
|
1161
|
+
} catch {
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
};
|
|
1165
|
+
|
|
1166
|
+
// src/inspect/tree-inspector.ts
|
|
1167
|
+
var TreeInspector = class {
|
|
1168
|
+
shell;
|
|
1169
|
+
fileCache;
|
|
1170
|
+
constructor(shell, projectRoot) {
|
|
1171
|
+
this.shell = shell;
|
|
1172
|
+
this.fileCache = projectRoot ? new StrategyCache(projectRoot) : null;
|
|
1173
|
+
}
|
|
1174
|
+
invalidateCache(deviceId) {
|
|
1175
|
+
if (!this.fileCache) return;
|
|
1176
|
+
if (deviceId) {
|
|
1177
|
+
this.fileCache.delete(deviceId);
|
|
1178
|
+
} else {
|
|
1179
|
+
this.fileCache.clear();
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
async resolveStrategy(device, options) {
|
|
1183
|
+
if (options.metroPort > 0) {
|
|
1184
|
+
const targets = await discoverTargets(options.metroPort);
|
|
1185
|
+
const target = findRuntimeTarget(targets, device.name);
|
|
1186
|
+
if (target) {
|
|
1187
|
+
return {
|
|
1188
|
+
method: "cdp",
|
|
1189
|
+
reason: "React Native app connected via Metro",
|
|
1190
|
+
appId: target.appId,
|
|
1191
|
+
cdpTarget: target
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
if (device.platform === "android") {
|
|
1196
|
+
return { method: "uiautomator", reason: "Android native inspection" };
|
|
1197
|
+
}
|
|
1198
|
+
return { method: "idb", reason: "iOS native inspection via idb" };
|
|
1199
|
+
}
|
|
1200
|
+
async inspect(device, options) {
|
|
1201
|
+
const logger = getLogger();
|
|
1202
|
+
const hints = [];
|
|
1203
|
+
let usedCache = false;
|
|
1204
|
+
const cachedEntry = this.fileCache?.get(device.id);
|
|
1205
|
+
let strategy;
|
|
1206
|
+
if (cachedEntry && cachedEntry.method !== "none") {
|
|
1207
|
+
logger.debug(`Using cached strategy for ${device.name}: ${cachedEntry.method}`);
|
|
1208
|
+
usedCache = true;
|
|
1209
|
+
if (cachedEntry.method === "cdp" && options.metroPort > 0) {
|
|
1210
|
+
const targets = await discoverTargets(options.metroPort);
|
|
1211
|
+
const target = findRuntimeTarget(targets, device.name);
|
|
1212
|
+
strategy = target ? { method: "cdp", reason: cachedEntry.reason, appId: target.appId, cdpTarget: target } : await this.resolveStrategy(device, options);
|
|
1213
|
+
} else {
|
|
1214
|
+
strategy = { method: cachedEntry.method, reason: cachedEntry.reason, appId: cachedEntry.appId };
|
|
1215
|
+
}
|
|
1216
|
+
} else {
|
|
1217
|
+
strategy = await this.resolveStrategy(device, options);
|
|
1218
|
+
}
|
|
1219
|
+
this.fileCache?.set(device.id, strategy.method, strategy.reason, strategy.appId);
|
|
1220
|
+
const base = { device: { name: device.name, platform: device.platform }, strategy };
|
|
1221
|
+
if (strategy.method === "cdp" && strategy.cdpTarget) {
|
|
1222
|
+
try {
|
|
1223
|
+
const cdp = new CdpClient();
|
|
1224
|
+
const tree = await cdp.connectAndGetTree(
|
|
1225
|
+
options.metroPort,
|
|
1226
|
+
options.timeoutMs,
|
|
1227
|
+
device.name
|
|
1228
|
+
);
|
|
1229
|
+
const caps = cdp.getCapabilities();
|
|
1230
|
+
await cdp.disconnect();
|
|
1231
|
+
if (tree.length > 0) {
|
|
1232
|
+
logger.debug(`CDP: got ${tree.length} root nodes for ${device.name}`);
|
|
1233
|
+
return { ...base, tree, capabilities: caps, hints };
|
|
1234
|
+
}
|
|
1235
|
+
} catch (err) {
|
|
1236
|
+
logger.debug(`CDP failed: ${err instanceof Error ? err.message : err}`);
|
|
1237
|
+
}
|
|
1238
|
+
logger.debug("CDP strategy resolved but returned no tree, falling back to native");
|
|
1239
|
+
}
|
|
1240
|
+
if (strategy.method === "uiautomator" || strategy.method === "cdp" && device.platform === "android") {
|
|
1241
|
+
try {
|
|
1242
|
+
const tree = await dumpUiAutomator(this.shell, device.id, options.timeoutMs);
|
|
1243
|
+
logger.debug(`UIAutomator: got ${tree.length} root nodes for ${device.name}`);
|
|
1244
|
+
return {
|
|
1245
|
+
...base,
|
|
1246
|
+
strategy: { method: "uiautomator", reason: strategy.method === "cdp" ? "CDP fallback to native" : strategy.reason },
|
|
1247
|
+
tree,
|
|
1248
|
+
capabilities: { tree: "basic", sourceMapping: "none", styles: "none", protocol: "uiautomator" },
|
|
1249
|
+
hints
|
|
1250
|
+
};
|
|
1251
|
+
} catch (err) {
|
|
1252
|
+
logger.debug(`UIAutomator failed: ${err instanceof Error ? err.message : err}`);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
if (strategy.method === "idb" || strategy.method === "cdp" && device.platform === "ios") {
|
|
1256
|
+
try {
|
|
1257
|
+
const tree = await dumpIosAccessibility(this.shell, device.id, options.timeoutMs);
|
|
1258
|
+
logger.debug(`idb: got ${tree.length} root nodes for ${device.name}`);
|
|
1259
|
+
return {
|
|
1260
|
+
...base,
|
|
1261
|
+
strategy: { method: "idb", reason: strategy.method === "cdp" ? "CDP fallback to native" : strategy.reason },
|
|
1262
|
+
tree,
|
|
1263
|
+
capabilities: { tree: "basic", sourceMapping: "none", styles: "none", protocol: "idb" },
|
|
1264
|
+
hints
|
|
1265
|
+
};
|
|
1266
|
+
} catch (err) {
|
|
1267
|
+
logger.debug(`iOS accessibility failed: ${err instanceof Error ? err.message : err}`);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
if (device.platform === "ios") {
|
|
1271
|
+
hints.push("Install idb for native iOS tree inspection: brew install idb-companion && pip install fb-idb");
|
|
1272
|
+
}
|
|
1273
|
+
if (usedCache) {
|
|
1274
|
+
this.fileCache?.delete(device.id);
|
|
1275
|
+
logger.debug(`Invalidated cached strategy for ${device.name} after complete failure`);
|
|
1276
|
+
}
|
|
1277
|
+
return {
|
|
1278
|
+
...base,
|
|
1279
|
+
strategy: { method: "none", reason: "No inspection method available" },
|
|
1280
|
+
tree: [],
|
|
1281
|
+
capabilities: { tree: "none", sourceMapping: "none", styles: "none", protocol: "none" },
|
|
1282
|
+
hints
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
};
|
|
1286
|
+
|
|
1287
|
+
// src/formatters/compare.ts
|
|
1288
|
+
import pc from "picocolors";
|
|
1289
|
+
function confidenceLabel(confidence) {
|
|
1290
|
+
if (confidence >= 0.8) return "high";
|
|
1291
|
+
if (confidence >= 0.5) return "probable";
|
|
1292
|
+
return "approximate";
|
|
1293
|
+
}
|
|
1294
|
+
function severityColor(severity, text) {
|
|
1295
|
+
if (severity === "critical") return pc.red(pc.bold(text));
|
|
1296
|
+
if (severity === "major") return pc.yellow(text);
|
|
1297
|
+
if (severity === "minor") return pc.cyan(text);
|
|
1298
|
+
return pc.dim(text);
|
|
1299
|
+
}
|
|
1300
|
+
function severityCounts(findings) {
|
|
1301
|
+
const counts = {};
|
|
1302
|
+
for (const f of findings) {
|
|
1303
|
+
counts[f.severity] = (counts[f.severity] ?? 0) + 1;
|
|
1304
|
+
}
|
|
1305
|
+
const parts = [];
|
|
1306
|
+
for (const sev of ["critical", "major", "minor", "info"]) {
|
|
1307
|
+
if (counts[sev]) parts.push(`${counts[sev]} ${sev}`);
|
|
1308
|
+
}
|
|
1309
|
+
return parts.join(", ");
|
|
1310
|
+
}
|
|
1311
|
+
function formatTreePlain(nodes, indent = 0) {
|
|
1312
|
+
const lines = [];
|
|
1313
|
+
for (const node of nodes) {
|
|
1314
|
+
const prefix = " ".repeat(indent);
|
|
1315
|
+
const name = node.reactName ?? node.name;
|
|
1316
|
+
const testId = node.testID ? ` [${node.testID}]` : "";
|
|
1317
|
+
const text = node.text ? ` "${node.text}"` : "";
|
|
1318
|
+
const b = node.bounds;
|
|
1319
|
+
const bounds = b.width > 0 ? ` (${b.x},${b.y} ${b.width}x${b.height})` : "";
|
|
1320
|
+
lines.push(`${prefix}${name}${testId}${text}${bounds}`);
|
|
1321
|
+
const childStr = formatTreePlain(node.children, indent + 1);
|
|
1322
|
+
if (childStr) lines.push(childStr);
|
|
1323
|
+
}
|
|
1324
|
+
return lines.join("\n");
|
|
1325
|
+
}
|
|
1326
|
+
function getPixelMeta(analyses) {
|
|
1327
|
+
const pixel = analyses.find((a) => a.analysisName === "pixel");
|
|
1328
|
+
const meta = pixel?.metadata;
|
|
1329
|
+
return {
|
|
1330
|
+
diffPercentage: meta?.diffPercentage ?? 0,
|
|
1331
|
+
diffPixels: meta?.diffPixels ?? 0,
|
|
1332
|
+
totalPixels: meta?.totalPixels ?? 0,
|
|
1333
|
+
regions: meta?.regions ?? []
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
var compareFormatter = {
|
|
1337
|
+
terminal(data) {
|
|
1338
|
+
const { report } = data;
|
|
1339
|
+
const pm = getPixelMeta(report.analyses);
|
|
1340
|
+
const lines = [];
|
|
1341
|
+
lines.push("");
|
|
1342
|
+
lines.push(` Diff: ${pm.diffPercentage.toFixed(2)}% (${pm.diffPixels.toLocaleString()}/${pm.totalPixels.toLocaleString()} pixels)`);
|
|
1343
|
+
if (pm.regions.length > 0) lines.push(` Regions: ${pm.regions.length}`);
|
|
1344
|
+
lines.push(` Duration: ${report.durationMs}ms`);
|
|
1345
|
+
for (const a of report.analyses) {
|
|
1346
|
+
lines.push(` [${a.analysisName}] ${a.summary}`);
|
|
1347
|
+
}
|
|
1348
|
+
if (report.findings.length === 0) {
|
|
1349
|
+
lines.push("");
|
|
1350
|
+
lines.push(pc.green(" No differences found."));
|
|
1351
|
+
lines.push("");
|
|
1352
|
+
lines.push(` Run: ${report.runId}`);
|
|
1353
|
+
lines.push("");
|
|
1354
|
+
return lines.join("\n");
|
|
1355
|
+
}
|
|
1356
|
+
lines.push("");
|
|
1357
|
+
lines.push(" Findings");
|
|
1358
|
+
lines.push(" " + "\u2500".repeat(70));
|
|
1359
|
+
for (const f of report.findings) {
|
|
1360
|
+
const tag = severityColor(f.severity, `[${f.severity.toUpperCase()}]`);
|
|
1361
|
+
const comp = f.component ? `${f.component.name}${f.component.testID ? ` [${f.component.testID}]` : ""}` : pc.dim("(unmatched)");
|
|
1362
|
+
const region = `(${f.region.x},${f.region.y} ${f.region.width}x${f.region.height})`;
|
|
1363
|
+
const conf = `(${confidenceLabel(f.confidence)})`;
|
|
1364
|
+
lines.push(` ${tag} ${f.id} ${comp} ${region} ${conf}`);
|
|
1365
|
+
}
|
|
1366
|
+
lines.push("");
|
|
1367
|
+
lines.push(` Summary: Found ${report.findings.length} differences (${severityCounts(report.findings)})`);
|
|
1368
|
+
lines.push("");
|
|
1369
|
+
lines.push(` Run: ${report.runId}`);
|
|
1370
|
+
lines.push("");
|
|
1371
|
+
return lines.join("\n");
|
|
1372
|
+
},
|
|
1373
|
+
markdown(data) {
|
|
1374
|
+
const { report, device, artifactDir } = data;
|
|
1375
|
+
const pm = getPixelMeta(report.analyses);
|
|
1376
|
+
const lines = ["# Driftx Compare Report", ""];
|
|
1377
|
+
if (device) lines.push(`**Device:** ${device.name} (${device.platform})`);
|
|
1378
|
+
const meta = report.metadata;
|
|
1379
|
+
if (meta.gitCommit || meta.gitBranch) {
|
|
1380
|
+
const git = [meta.gitCommit, meta.gitBranch].filter(Boolean).join(" on ");
|
|
1381
|
+
lines.push(`**Git:** ${git}`);
|
|
1382
|
+
}
|
|
1383
|
+
if (meta.framework && meta.framework !== "unknown") lines.push(`**Framework:** ${meta.framework}`);
|
|
1384
|
+
lines.push(`**Diff:** ${pm.diffPercentage.toFixed(2)}% (${pm.diffPixels.toLocaleString()} / ${pm.totalPixels.toLocaleString()} pixels)`);
|
|
1385
|
+
if (pm.regions.length > 0) lines.push(`**Regions:** ${pm.regions.length}`);
|
|
1386
|
+
lines.push(`**Duration:** ${report.durationMs}ms`);
|
|
1387
|
+
lines.push(`**Run ID:** ${report.runId}`);
|
|
1388
|
+
if (report.analyses.length > 0) {
|
|
1389
|
+
lines.push("", "## Analyses", "");
|
|
1390
|
+
for (const a of report.analyses) {
|
|
1391
|
+
lines.push(`- **${a.analysisName}** (${a.durationMs}ms): ${a.summary}`);
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
if (report.findings.length === 0) {
|
|
1395
|
+
lines.push("", "No differences found.");
|
|
1396
|
+
return lines.join("\n");
|
|
1397
|
+
}
|
|
1398
|
+
lines.push("", "## Artifacts", "");
|
|
1399
|
+
lines.push(`- Screenshot: \`${artifactDir}/screenshot.png\``);
|
|
1400
|
+
lines.push(`- Design: \`${artifactDir}/design.png\``);
|
|
1401
|
+
lines.push(`- Diff mask: \`${artifactDir}/diff-mask.png\``);
|
|
1402
|
+
lines.push("", "## Findings");
|
|
1403
|
+
report.findings.forEach((f, i) => {
|
|
1404
|
+
const compName = f.component?.name ?? "Unmatched region";
|
|
1405
|
+
lines.push("", `### ${i + 1}. [${f.severity.toUpperCase()}] ${compName} (${f.id})`, "");
|
|
1406
|
+
if (f.component) {
|
|
1407
|
+
lines.push(`- **Component:** ${f.component.name}`);
|
|
1408
|
+
if (f.component.testID) lines.push(`- **testID:** ${f.component.testID}`);
|
|
1409
|
+
}
|
|
1410
|
+
lines.push(`- **Category:** ${f.category}`);
|
|
1411
|
+
lines.push(`- **Region:** (${f.region.x}, ${f.region.y}) ${f.region.width}x${f.region.height}`);
|
|
1412
|
+
lines.push(`- **Confidence:** ${confidenceLabel(f.confidence)}`);
|
|
1413
|
+
if (f.evidence.length > 0) {
|
|
1414
|
+
lines.push("- **Evidence:**");
|
|
1415
|
+
for (const e of f.evidence) {
|
|
1416
|
+
lines.push(` - ${e.type}: ${Math.round(e.score * 100)}% score \u2014 "${e.note}"`);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
const regionId = pm.regions[i]?.id;
|
|
1420
|
+
if (regionId) lines.push(`- **Region crop:** \`${artifactDir}/regions/${regionId}.png\``);
|
|
1421
|
+
});
|
|
1422
|
+
if (data.tree && data.tree.length > 0) {
|
|
1423
|
+
lines.push("", "## Component Tree Context", "", "```");
|
|
1424
|
+
lines.push(formatTreePlain(data.tree));
|
|
1425
|
+
lines.push("```");
|
|
1426
|
+
}
|
|
1427
|
+
if (data.inspectHints && data.inspectHints.length > 0) {
|
|
1428
|
+
lines.push("", "## Hints", "");
|
|
1429
|
+
for (const hint of data.inspectHints) {
|
|
1430
|
+
lines.push(`- ${hint}`);
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
return lines.join("\n");
|
|
1434
|
+
},
|
|
1435
|
+
json(data) {
|
|
1436
|
+
return JSON.stringify({
|
|
1437
|
+
report: data.report,
|
|
1438
|
+
device: data.device,
|
|
1439
|
+
artifactDir: data.artifactDir
|
|
1440
|
+
}, null, 2);
|
|
1441
|
+
}
|
|
1442
|
+
};
|
|
1443
|
+
|
|
1444
|
+
// src/analyses/context.ts
|
|
1445
|
+
import sharp from "sharp";
|
|
1446
|
+
import { readFileSync as readFileSync5 } from "fs";
|
|
1447
|
+
async function buildDriftxImage(filePath) {
|
|
1448
|
+
const buffer = readFileSync5(filePath);
|
|
1449
|
+
const image = sharp(buffer);
|
|
1450
|
+
const metadata = await image.metadata();
|
|
1451
|
+
const width = metadata.width;
|
|
1452
|
+
const height = metadata.height;
|
|
1453
|
+
const rawPixels = await image.raw().ensureAlpha().toBuffer();
|
|
1454
|
+
return {
|
|
1455
|
+
buffer,
|
|
1456
|
+
rawPixels,
|
|
1457
|
+
width,
|
|
1458
|
+
height,
|
|
1459
|
+
aspectRatio: width / height,
|
|
1460
|
+
path: filePath
|
|
1461
|
+
};
|
|
1462
|
+
}
|
|
1463
|
+
function buildAnalysisConfig(configAnalyses, withFlag, withoutFlag) {
|
|
1464
|
+
const enabled = withFlag ? withFlag.split(",").map((s) => s.trim()) : [...configAnalyses.default];
|
|
1465
|
+
const disabled = withoutFlag ? withoutFlag.split(",").map((s) => s.trim()) : [...configAnalyses.disabled];
|
|
1466
|
+
return {
|
|
1467
|
+
enabled,
|
|
1468
|
+
disabled,
|
|
1469
|
+
options: configAnalyses.options
|
|
1470
|
+
};
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// src/analyses/registry.ts
|
|
1474
|
+
var AnalysisRegistry = class {
|
|
1475
|
+
plugins = /* @__PURE__ */ new Map();
|
|
1476
|
+
register(plugin) {
|
|
1477
|
+
if (this.plugins.has(plugin.name)) {
|
|
1478
|
+
throw new Error(`Analysis "${plugin.name}" already registered`);
|
|
1479
|
+
}
|
|
1480
|
+
this.plugins.set(plugin.name, plugin);
|
|
1481
|
+
}
|
|
1482
|
+
get(name) {
|
|
1483
|
+
return this.plugins.get(name);
|
|
1484
|
+
}
|
|
1485
|
+
all() {
|
|
1486
|
+
return [...this.plugins.values()];
|
|
1487
|
+
}
|
|
1488
|
+
names() {
|
|
1489
|
+
return [...this.plugins.keys()];
|
|
1490
|
+
}
|
|
1491
|
+
};
|
|
1492
|
+
|
|
1493
|
+
// src/diff/image-loader.ts
|
|
1494
|
+
import sharp2 from "sharp";
|
|
1495
|
+
import * as fs6 from "fs";
|
|
1496
|
+
async function loadImage(filePath) {
|
|
1497
|
+
if (!fs6.existsSync(filePath)) {
|
|
1498
|
+
throw new Error(`Image not found: ${filePath}`);
|
|
1499
|
+
}
|
|
1500
|
+
const input = fs6.readFileSync(filePath);
|
|
1501
|
+
const metadata = await sharp2(input).metadata();
|
|
1502
|
+
if (!metadata.width || !metadata.height) {
|
|
1503
|
+
throw new Error(`Invalid image: could not read dimensions from ${filePath}`);
|
|
1504
|
+
}
|
|
1505
|
+
const rawPixels = await sharp2(input).ensureAlpha().raw().toBuffer();
|
|
1506
|
+
return {
|
|
1507
|
+
buffer: input,
|
|
1508
|
+
width: metadata.width,
|
|
1509
|
+
height: metadata.height,
|
|
1510
|
+
aspectRatio: metadata.width / metadata.height,
|
|
1511
|
+
rawPixels
|
|
1512
|
+
};
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
// src/diff/alignment.ts
|
|
1516
|
+
import sharp3 from "sharp";
|
|
1517
|
+
async function alignImages(design, screenshot) {
|
|
1518
|
+
const targetWidth = screenshot.width;
|
|
1519
|
+
const targetHeight = screenshot.height;
|
|
1520
|
+
const logger = getLogger();
|
|
1521
|
+
const aspectDiff = Math.abs(design.aspectRatio - screenshot.aspectRatio) / screenshot.aspectRatio;
|
|
1522
|
+
const aspectRatioWarning = aspectDiff > 0.05;
|
|
1523
|
+
if (aspectRatioWarning) {
|
|
1524
|
+
logger.warn(
|
|
1525
|
+
`Aspect ratio divergence: design=${design.aspectRatio.toFixed(3)} screenshot=${screenshot.aspectRatio.toFixed(3)} (${(aspectDiff * 100).toFixed(1)}%)`
|
|
1526
|
+
);
|
|
1527
|
+
}
|
|
1528
|
+
let designPixels;
|
|
1529
|
+
if (design.width === targetWidth && design.height === targetHeight) {
|
|
1530
|
+
designPixels = design.rawPixels;
|
|
1531
|
+
} else {
|
|
1532
|
+
designPixels = await sharp3(design.buffer).resize(targetWidth, targetHeight, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } }).ensureAlpha().raw().toBuffer();
|
|
1533
|
+
}
|
|
1534
|
+
return {
|
|
1535
|
+
designPixels,
|
|
1536
|
+
screenshotPixels: screenshot.rawPixels,
|
|
1537
|
+
width: targetWidth,
|
|
1538
|
+
height: targetHeight,
|
|
1539
|
+
aspectRatioWarning
|
|
1540
|
+
};
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
// src/diff/pixel-diff.ts
|
|
1544
|
+
import pixelmatch2 from "pixelmatch";
|
|
1545
|
+
function computePixelDiff(aligned, threshold) {
|
|
1546
|
+
const { width, height, designPixels, screenshotPixels } = aligned;
|
|
1547
|
+
const totalPixels = width * height;
|
|
1548
|
+
const diffMask = Buffer.alloc(width * height * 4);
|
|
1549
|
+
const diffPixels = pixelmatch2(
|
|
1550
|
+
designPixels,
|
|
1551
|
+
screenshotPixels,
|
|
1552
|
+
diffMask,
|
|
1553
|
+
width,
|
|
1554
|
+
height,
|
|
1555
|
+
{ threshold, includeAA: false }
|
|
1556
|
+
);
|
|
1557
|
+
return {
|
|
1558
|
+
diffPixels,
|
|
1559
|
+
totalPixels,
|
|
1560
|
+
diffPercentage: totalPixels > 0 ? diffPixels / totalPixels * 100 : 0,
|
|
1561
|
+
diffMask,
|
|
1562
|
+
width,
|
|
1563
|
+
height
|
|
1564
|
+
};
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
// src/diff/region-extractor.ts
|
|
1568
|
+
function isDiffPixel(mask, idx) {
|
|
1569
|
+
return mask[idx] > 0 || mask[idx + 1] > 0 || mask[idx + 2] > 0;
|
|
1570
|
+
}
|
|
1571
|
+
function floodFill(mask, visited, width, height, startX, startY) {
|
|
1572
|
+
const stack = [[startX, startY]];
|
|
1573
|
+
let minX = startX, maxX = startX, minY = startY, maxY = startY;
|
|
1574
|
+
let count = 0;
|
|
1575
|
+
while (stack.length > 0) {
|
|
1576
|
+
const [x, y] = stack.pop();
|
|
1577
|
+
const idx = y * width + x;
|
|
1578
|
+
if (x < 0 || x >= width || y < 0 || y >= height) continue;
|
|
1579
|
+
if (visited[idx]) continue;
|
|
1580
|
+
if (!isDiffPixel(mask, idx * 4)) continue;
|
|
1581
|
+
visited[idx] = 1;
|
|
1582
|
+
count++;
|
|
1583
|
+
minX = Math.min(minX, x);
|
|
1584
|
+
maxX = Math.max(maxX, x);
|
|
1585
|
+
minY = Math.min(minY, y);
|
|
1586
|
+
maxY = Math.max(maxY, y);
|
|
1587
|
+
stack.push([x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]);
|
|
1588
|
+
}
|
|
1589
|
+
return {
|
|
1590
|
+
bounds: { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 },
|
|
1591
|
+
pixelCount: count
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
function shouldMerge(a, b, gap) {
|
|
1595
|
+
const aRight = a.x + a.width + gap;
|
|
1596
|
+
const aBottom = a.y + a.height + gap;
|
|
1597
|
+
const bRight = b.x + b.width + gap;
|
|
1598
|
+
const bBottom = b.y + b.height + gap;
|
|
1599
|
+
return !(a.x - gap > bRight || b.x - gap > aRight || a.y - gap > bBottom || b.y - gap > aBottom);
|
|
1600
|
+
}
|
|
1601
|
+
function mergeBounds(a, b) {
|
|
1602
|
+
const x = Math.min(a.x, b.x);
|
|
1603
|
+
const y = Math.min(a.y, b.y);
|
|
1604
|
+
return {
|
|
1605
|
+
x,
|
|
1606
|
+
y,
|
|
1607
|
+
width: Math.max(a.x + a.width, b.x + b.width) - x,
|
|
1608
|
+
height: Math.max(a.y + a.height, b.y + b.height) - y
|
|
1609
|
+
};
|
|
1610
|
+
}
|
|
1611
|
+
function extractRegions(diffMask, width, height, options) {
|
|
1612
|
+
const visited = new Uint8Array(width * height);
|
|
1613
|
+
let rawRegions = [];
|
|
1614
|
+
for (let y = 0; y < height; y++) {
|
|
1615
|
+
for (let x = 0; x < width; x++) {
|
|
1616
|
+
const idx = y * width + x;
|
|
1617
|
+
if (visited[idx] || !isDiffPixel(diffMask, idx * 4)) continue;
|
|
1618
|
+
rawRegions.push(floodFill(diffMask, visited, width, height, x, y));
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
let merged = true;
|
|
1622
|
+
while (merged) {
|
|
1623
|
+
merged = false;
|
|
1624
|
+
const next = [];
|
|
1625
|
+
const used = /* @__PURE__ */ new Set();
|
|
1626
|
+
for (let i = 0; i < rawRegions.length; i++) {
|
|
1627
|
+
if (used.has(i)) continue;
|
|
1628
|
+
let current = rawRegions[i];
|
|
1629
|
+
for (let j = i + 1; j < rawRegions.length; j++) {
|
|
1630
|
+
if (used.has(j)) continue;
|
|
1631
|
+
if (shouldMerge(current.bounds, rawRegions[j].bounds, options.mergeGap)) {
|
|
1632
|
+
current = {
|
|
1633
|
+
bounds: mergeBounds(current.bounds, rawRegions[j].bounds),
|
|
1634
|
+
pixelCount: current.pixelCount + rawRegions[j].pixelCount
|
|
1635
|
+
};
|
|
1636
|
+
used.add(j);
|
|
1637
|
+
merged = true;
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
next.push(current);
|
|
1641
|
+
}
|
|
1642
|
+
rawRegions = next;
|
|
1643
|
+
}
|
|
1644
|
+
const totalPixels = width * height;
|
|
1645
|
+
return rawRegions.filter((r) => r.bounds.width * r.bounds.height >= options.minArea).map((r, i) => ({
|
|
1646
|
+
id: `region-${i}`,
|
|
1647
|
+
bounds: r.bounds,
|
|
1648
|
+
pixelCount: r.pixelCount,
|
|
1649
|
+
percentage: totalPixels > 0 ? r.pixelCount / totalPixels * 100 : 0
|
|
1650
|
+
}));
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
// src/diff/ignore-rules.ts
|
|
1654
|
+
function boxesOverlap(a, b) {
|
|
1655
|
+
return !(a.x + a.width <= b.x || b.x + b.width <= a.x || a.y + a.height <= b.y || b.y + b.height <= a.y);
|
|
1656
|
+
}
|
|
1657
|
+
function regionMatchesColorRange(region, range, ctx) {
|
|
1658
|
+
const { bounds } = region;
|
|
1659
|
+
const endX = Math.min(bounds.x + bounds.width, ctx.width);
|
|
1660
|
+
const endY = Math.min(bounds.y + bounds.height, ctx.height);
|
|
1661
|
+
for (let y = bounds.y; y < endY; y++) {
|
|
1662
|
+
for (let x = bounds.x; x < endX; x++) {
|
|
1663
|
+
const idx = (y * ctx.width + x) * 4;
|
|
1664
|
+
const r = ctx.screenshotPixels[idx];
|
|
1665
|
+
const g = ctx.screenshotPixels[idx + 1];
|
|
1666
|
+
const b = ctx.screenshotPixels[idx + 2];
|
|
1667
|
+
if (r < range.r[0] || r > range.r[1]) return false;
|
|
1668
|
+
if (g < range.g[0] || g > range.g[1]) return false;
|
|
1669
|
+
if (b < range.b[0] || b > range.b[1]) return false;
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
return true;
|
|
1673
|
+
}
|
|
1674
|
+
function isIgnored(region, rule, ctx) {
|
|
1675
|
+
switch (rule.type) {
|
|
1676
|
+
case "boundingBox":
|
|
1677
|
+
return boxesOverlap(region.bounds, rule.value);
|
|
1678
|
+
case "colorRange":
|
|
1679
|
+
if (!ctx) return false;
|
|
1680
|
+
return regionMatchesColorRange(region, rule.value, ctx);
|
|
1681
|
+
default:
|
|
1682
|
+
return false;
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
function filterByIgnoreRules(regions, rules, pixelContext) {
|
|
1686
|
+
if (rules.length === 0) return regions;
|
|
1687
|
+
return regions.filter((region) => !rules.some((rule) => isIgnored(region, rule, pixelContext)));
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
// src/diff/mask-generator.ts
|
|
1691
|
+
import sharp4 from "sharp";
|
|
1692
|
+
async function generateDiffMask(screenshotBuffer, diffMask, width, height, color) {
|
|
1693
|
+
const overlay = Buffer.alloc(width * height * 4);
|
|
1694
|
+
const alpha = Math.round(color.a * 255);
|
|
1695
|
+
for (let i = 0; i < width * height; i++) {
|
|
1696
|
+
const idx = i * 4;
|
|
1697
|
+
const hasDiff = diffMask[idx] > 0 || diffMask[idx + 1] > 0 || diffMask[idx + 2] > 0;
|
|
1698
|
+
if (hasDiff) {
|
|
1699
|
+
overlay[idx] = color.r;
|
|
1700
|
+
overlay[idx + 1] = color.g;
|
|
1701
|
+
overlay[idx + 2] = color.b;
|
|
1702
|
+
overlay[idx + 3] = alpha;
|
|
1703
|
+
} else {
|
|
1704
|
+
overlay[idx + 3] = 0;
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
const overlayPng = await sharp4(overlay, { raw: { width, height, channels: 4 } }).png().toBuffer();
|
|
1708
|
+
return sharp4(screenshotBuffer).composite([{ input: overlayPng, blend: "over" }]).png().toBuffer();
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
// src/diff/viewport.ts
|
|
1712
|
+
import sharp5 from "sharp";
|
|
1713
|
+
async function cropViewport(imageBuffer, options) {
|
|
1714
|
+
const metadata = await sharp5(imageBuffer).metadata();
|
|
1715
|
+
const width = metadata.width;
|
|
1716
|
+
let height = metadata.height;
|
|
1717
|
+
let top = 0;
|
|
1718
|
+
if (options.cropStatusBar) {
|
|
1719
|
+
const barHeight = options.platform === "android" ? options.statusBarHeight.android : options.statusBarHeight.ios;
|
|
1720
|
+
top = barHeight;
|
|
1721
|
+
height -= barHeight;
|
|
1722
|
+
}
|
|
1723
|
+
if (top === 0 && height === metadata.height) {
|
|
1724
|
+
return { buffer: imageBuffer, width, height };
|
|
1725
|
+
}
|
|
1726
|
+
const buffer = await sharp5(imageBuffer).extract({ left: 0, top, width, height }).png().toBuffer();
|
|
1727
|
+
return { buffer, width, height };
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
// src/diff/compare.ts
|
|
1731
|
+
import sharp6 from "sharp";
|
|
1732
|
+
function sanitizeDiffMask(mask, width, height) {
|
|
1733
|
+
const clean = Buffer.alloc(width * height * 4);
|
|
1734
|
+
for (let i = 0; i < width * height; i++) {
|
|
1735
|
+
const idx = i * 4;
|
|
1736
|
+
const r = mask[idx];
|
|
1737
|
+
const g = mask[idx + 1];
|
|
1738
|
+
const b = mask[idx + 2];
|
|
1739
|
+
if (r > 0 && g === 0 && b === 0) {
|
|
1740
|
+
clean[idx] = r;
|
|
1741
|
+
clean[idx + 1] = g;
|
|
1742
|
+
clean[idx + 2] = b;
|
|
1743
|
+
clean[idx + 3] = mask[idx + 3];
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
return clean;
|
|
1747
|
+
}
|
|
1748
|
+
async function runComparison(designPath, screenshotPath, options) {
|
|
1749
|
+
const start = Date.now();
|
|
1750
|
+
let design = await loadImage(designPath);
|
|
1751
|
+
let screenshot = await loadImage(screenshotPath);
|
|
1752
|
+
if (options.viewport) {
|
|
1753
|
+
const vpOpts = { ...options.viewport, platform: options.platform };
|
|
1754
|
+
const croppedDesign = await cropViewport(design.buffer, vpOpts);
|
|
1755
|
+
const croppedScreenshot = await cropViewport(screenshot.buffer, vpOpts);
|
|
1756
|
+
design = {
|
|
1757
|
+
buffer: croppedDesign.buffer,
|
|
1758
|
+
width: croppedDesign.width,
|
|
1759
|
+
height: croppedDesign.height,
|
|
1760
|
+
aspectRatio: croppedDesign.width / croppedDesign.height,
|
|
1761
|
+
rawPixels: await sharp6(croppedDesign.buffer).ensureAlpha().raw().toBuffer()
|
|
1762
|
+
};
|
|
1763
|
+
screenshot = {
|
|
1764
|
+
buffer: croppedScreenshot.buffer,
|
|
1765
|
+
width: croppedScreenshot.width,
|
|
1766
|
+
height: croppedScreenshot.height,
|
|
1767
|
+
aspectRatio: croppedScreenshot.width / croppedScreenshot.height,
|
|
1768
|
+
rawPixels: await sharp6(croppedScreenshot.buffer).ensureAlpha().raw().toBuffer()
|
|
1769
|
+
};
|
|
1770
|
+
}
|
|
1771
|
+
const aligned = await alignImages(design, screenshot);
|
|
1772
|
+
const diff = computePixelDiff(aligned, options.threshold);
|
|
1773
|
+
const cleanMask = sanitizeDiffMask(diff.diffMask, diff.width, diff.height);
|
|
1774
|
+
let regions = extractRegions(cleanMask, diff.width, diff.height, {
|
|
1775
|
+
mergeGap: options.regionMergeGap,
|
|
1776
|
+
minArea: options.regionMinArea
|
|
1777
|
+
});
|
|
1778
|
+
regions = filterByIgnoreRules(regions, options.ignoreRules);
|
|
1779
|
+
const diffMaskBuffer = await generateDiffMask(
|
|
1780
|
+
screenshot.buffer,
|
|
1781
|
+
cleanMask,
|
|
1782
|
+
diff.width,
|
|
1783
|
+
diff.height,
|
|
1784
|
+
options.diffMaskColor
|
|
1785
|
+
);
|
|
1786
|
+
const regionCrops = await Promise.all(
|
|
1787
|
+
regions.map(async (r) => {
|
|
1788
|
+
const crop = await sharp6(screenshot.buffer).extract({
|
|
1789
|
+
left: r.bounds.x,
|
|
1790
|
+
top: r.bounds.y,
|
|
1791
|
+
width: Math.min(r.bounds.width, diff.width - r.bounds.x),
|
|
1792
|
+
height: Math.min(r.bounds.height, diff.height - r.bounds.y)
|
|
1793
|
+
}).png().toBuffer();
|
|
1794
|
+
return { id: r.id, buffer: crop };
|
|
1795
|
+
})
|
|
1796
|
+
);
|
|
1797
|
+
return {
|
|
1798
|
+
totalPixels: diff.totalPixels,
|
|
1799
|
+
diffPixels: diff.diffPixels,
|
|
1800
|
+
diffPercentage: diff.diffPercentage,
|
|
1801
|
+
regions,
|
|
1802
|
+
diffMaskBuffer,
|
|
1803
|
+
regionCrops,
|
|
1804
|
+
width: diff.width,
|
|
1805
|
+
height: diff.height,
|
|
1806
|
+
durationMs: Date.now() - start
|
|
1807
|
+
};
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
// src/inspect/component-matcher.ts
|
|
1811
|
+
function boxesOverlap2(a, b) {
|
|
1812
|
+
return !(a.x + a.width <= b.x || b.x + b.width <= a.x || a.y + a.height <= b.y || b.y + b.height <= a.y);
|
|
1813
|
+
}
|
|
1814
|
+
function overlapArea(a, b) {
|
|
1815
|
+
const x1 = Math.max(a.x, b.x);
|
|
1816
|
+
const y1 = Math.max(a.y, b.y);
|
|
1817
|
+
const x2 = Math.min(a.x + a.width, b.x + b.width);
|
|
1818
|
+
const y2 = Math.min(a.y + a.height, b.y + b.height);
|
|
1819
|
+
if (x2 <= x1 || y2 <= y1) return 0;
|
|
1820
|
+
return (x2 - x1) * (y2 - y1);
|
|
1821
|
+
}
|
|
1822
|
+
function findDeepestOverlap(nodes, regionBounds, depth) {
|
|
1823
|
+
let best = null;
|
|
1824
|
+
const regionArea = regionBounds.width * regionBounds.height;
|
|
1825
|
+
for (const node of nodes) {
|
|
1826
|
+
if (!boxesOverlap2(node.bounds, regionBounds)) continue;
|
|
1827
|
+
const overlap = overlapArea(node.bounds, regionBounds);
|
|
1828
|
+
const ratio = regionArea > 0 ? overlap / regionArea : 0;
|
|
1829
|
+
if (ratio > 0.5) {
|
|
1830
|
+
const childMatch = findDeepestOverlap(node.children, regionBounds, depth + 1);
|
|
1831
|
+
if (childMatch && childMatch.overlapRatio > 0.5) {
|
|
1832
|
+
if (!best || childMatch.depth > best.depth) {
|
|
1833
|
+
best = childMatch;
|
|
1834
|
+
}
|
|
1835
|
+
} else {
|
|
1836
|
+
if (!best || depth > best.depth || depth === best.depth && ratio > best.overlapRatio) {
|
|
1837
|
+
best = { node, depth, overlapRatio: ratio };
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
return best;
|
|
1843
|
+
}
|
|
1844
|
+
function computeConfidence(node, overlapRatio) {
|
|
1845
|
+
let base;
|
|
1846
|
+
if (node.inspectionTier === "detailed") {
|
|
1847
|
+
base = 0.6;
|
|
1848
|
+
if (node.source) base = 0.8;
|
|
1849
|
+
} else {
|
|
1850
|
+
base = 0.4;
|
|
1851
|
+
}
|
|
1852
|
+
const overlapBonus = Math.min(overlapRatio * 0.2, 0.2);
|
|
1853
|
+
return Math.min(base + overlapBonus, node.inspectionTier === "detailed" ? 0.95 : 0.6);
|
|
1854
|
+
}
|
|
1855
|
+
function matchRegionsToComponents(regions, tree) {
|
|
1856
|
+
const matches = [];
|
|
1857
|
+
for (const region of regions) {
|
|
1858
|
+
const candidate = findDeepestOverlap(tree, region.bounds, 0);
|
|
1859
|
+
if (!candidate) continue;
|
|
1860
|
+
const { node, depth, overlapRatio } = candidate;
|
|
1861
|
+
const confidence = computeConfidence(node, overlapRatio);
|
|
1862
|
+
matches.push({
|
|
1863
|
+
regionId: region.id,
|
|
1864
|
+
component: {
|
|
1865
|
+
name: node.reactName ?? node.name,
|
|
1866
|
+
testID: node.testID,
|
|
1867
|
+
source: node.source,
|
|
1868
|
+
bounds: node.bounds,
|
|
1869
|
+
depth
|
|
1870
|
+
},
|
|
1871
|
+
confidence,
|
|
1872
|
+
overlapRatio
|
|
1873
|
+
});
|
|
1874
|
+
}
|
|
1875
|
+
return matches;
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
// src/inspect/finding-generator.ts
|
|
1879
|
+
function classifySeverity(pixelPercentage) {
|
|
1880
|
+
if (pixelPercentage >= 10) return "critical";
|
|
1881
|
+
if (pixelPercentage >= 3) return "major";
|
|
1882
|
+
if (pixelPercentage >= 0.5) return "minor";
|
|
1883
|
+
return "info";
|
|
1884
|
+
}
|
|
1885
|
+
function generateFindings(regions, matches, totalPixels) {
|
|
1886
|
+
const matchMap = new Map(matches.map((m) => [m.regionId, m]));
|
|
1887
|
+
return regions.map((region, i) => {
|
|
1888
|
+
const match = matchMap.get(region.id);
|
|
1889
|
+
const pixelPct = totalPixels > 0 ? region.pixelCount / totalPixels * 100 : 0;
|
|
1890
|
+
const evidence = [
|
|
1891
|
+
{ type: "pixel", score: pixelPct / 100, note: `${pixelPct.toFixed(1)}% pixel difference in region` }
|
|
1892
|
+
];
|
|
1893
|
+
if (match) {
|
|
1894
|
+
evidence.push({
|
|
1895
|
+
type: "tree",
|
|
1896
|
+
score: match.confidence,
|
|
1897
|
+
note: `Matched to ${match.component.name} via bounds overlap (${Math.round(match.overlapRatio * 100)}%)`
|
|
1898
|
+
});
|
|
1899
|
+
}
|
|
1900
|
+
return {
|
|
1901
|
+
id: `diff-${i}`,
|
|
1902
|
+
category: "unknown",
|
|
1903
|
+
severity: classifySeverity(pixelPct),
|
|
1904
|
+
confidence: match?.confidence ?? 0.3,
|
|
1905
|
+
region: region.bounds,
|
|
1906
|
+
component: match?.component,
|
|
1907
|
+
evidence
|
|
1908
|
+
};
|
|
1909
|
+
});
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
// src/analyses/plugins/pixel.ts
|
|
1913
|
+
var PixelAnalysis = class {
|
|
1914
|
+
name = "pixel";
|
|
1915
|
+
description = "Pixel-level image comparison between screenshot and design";
|
|
1916
|
+
isAvailable(ctx) {
|
|
1917
|
+
return !!ctx.design;
|
|
1918
|
+
}
|
|
1919
|
+
async run(ctx) {
|
|
1920
|
+
const start = Date.now();
|
|
1921
|
+
const { config } = ctx;
|
|
1922
|
+
const [mr, mg, mb, ma] = config.diffMaskColor;
|
|
1923
|
+
const compareResult = await runComparison(ctx.design.path, ctx.screenshot.path, {
|
|
1924
|
+
threshold: config.threshold,
|
|
1925
|
+
diffThreshold: config.diffThreshold,
|
|
1926
|
+
regionMergeGap: config.regionMergeGap,
|
|
1927
|
+
regionMinArea: config.regionMinArea,
|
|
1928
|
+
ignoreRules: config.ignoreRules,
|
|
1929
|
+
diffMaskColor: { r: mr, g: mg, b: mb, a: ma / 255 },
|
|
1930
|
+
platform: config.platform
|
|
1931
|
+
});
|
|
1932
|
+
await ctx.store.writeArtifact(ctx.runId, "diff-mask.png", compareResult.diffMaskBuffer);
|
|
1933
|
+
for (const crop of compareResult.regionCrops) {
|
|
1934
|
+
await ctx.store.writeArtifact(ctx.runId, `regions/${crop.id}.png`, crop.buffer);
|
|
1935
|
+
}
|
|
1936
|
+
const matches = ctx.tree?.length ? matchRegionsToComponents(compareResult.regions, ctx.tree) : [];
|
|
1937
|
+
const findings = generateFindings(
|
|
1938
|
+
compareResult.regions,
|
|
1939
|
+
matches,
|
|
1940
|
+
compareResult.totalPixels
|
|
1941
|
+
);
|
|
1942
|
+
const passed = compareResult.diffPercentage <= config.diffThreshold;
|
|
1943
|
+
return {
|
|
1944
|
+
analysisName: this.name,
|
|
1945
|
+
findings,
|
|
1946
|
+
summary: passed ? `Pixel diff: ${compareResult.diffPercentage.toFixed(3)}% (pass)` : `Pixel diff: ${compareResult.diffPercentage.toFixed(3)}% \u2014 ${compareResult.regions.length} regions (fail)`,
|
|
1947
|
+
metadata: {
|
|
1948
|
+
totalPixels: compareResult.totalPixels,
|
|
1949
|
+
diffPixels: compareResult.diffPixels,
|
|
1950
|
+
diffPercentage: compareResult.diffPercentage,
|
|
1951
|
+
regions: compareResult.regions,
|
|
1952
|
+
durationMs: compareResult.durationMs,
|
|
1953
|
+
passed
|
|
1954
|
+
},
|
|
1955
|
+
durationMs: Date.now() - start
|
|
1956
|
+
};
|
|
1957
|
+
}
|
|
1958
|
+
};
|
|
1959
|
+
|
|
1960
|
+
// src/analyses/plugins/a11y.ts
|
|
1961
|
+
var INTERACTIVE_NAMES = /* @__PURE__ */ new Set([
|
|
1962
|
+
"Pressable",
|
|
1963
|
+
"TouchableOpacity",
|
|
1964
|
+
"Button",
|
|
1965
|
+
"TouchableHighlight",
|
|
1966
|
+
"TouchableWithoutFeedback"
|
|
1967
|
+
]);
|
|
1968
|
+
function isInteractive(name) {
|
|
1969
|
+
if (INTERACTIVE_NAMES.has(name)) return true;
|
|
1970
|
+
return name.includes("Button") || name.includes("Pressable");
|
|
1971
|
+
}
|
|
1972
|
+
function collectNodes(nodes) {
|
|
1973
|
+
const result = [];
|
|
1974
|
+
const stack = [...nodes];
|
|
1975
|
+
while (stack.length > 0) {
|
|
1976
|
+
const node = stack.pop();
|
|
1977
|
+
result.push(node);
|
|
1978
|
+
for (const child of node.children) {
|
|
1979
|
+
stack.push(child);
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
return result;
|
|
1983
|
+
}
|
|
1984
|
+
var AccessibilityAnalysis = class {
|
|
1985
|
+
name = "a11y";
|
|
1986
|
+
description = "Accessibility audit of component tree";
|
|
1987
|
+
isAvailable(ctx) {
|
|
1988
|
+
return !!ctx.tree?.length;
|
|
1989
|
+
}
|
|
1990
|
+
async run(ctx) {
|
|
1991
|
+
const start = Date.now();
|
|
1992
|
+
const nodes = collectNodes(ctx.tree ?? []);
|
|
1993
|
+
const platform = ctx.config.platform;
|
|
1994
|
+
const tapThreshold = platform === "ios" ? 44 : 48;
|
|
1995
|
+
const findings = [];
|
|
1996
|
+
let labelCount = 0;
|
|
1997
|
+
let tapTargetCount = 0;
|
|
1998
|
+
let imageCount = 0;
|
|
1999
|
+
let emptyTextCount = 0;
|
|
2000
|
+
for (const node of nodes) {
|
|
2001
|
+
if (isInteractive(node.name) && !node.testID) {
|
|
2002
|
+
const styles = node.styles ?? {};
|
|
2003
|
+
const hasLabel = "accessibilityLabel" in styles || "aria-label" in styles;
|
|
2004
|
+
if (!hasLabel) {
|
|
2005
|
+
findings.push({
|
|
2006
|
+
id: `a11y-label-${labelCount++}`,
|
|
2007
|
+
category: "accessibility",
|
|
2008
|
+
severity: "major",
|
|
2009
|
+
confidence: 1,
|
|
2010
|
+
region: node.bounds,
|
|
2011
|
+
component: { name: node.name, testID: node.testID, bounds: node.bounds, depth: 0 },
|
|
2012
|
+
evidence: [{ type: "accessibility", score: 1, note: `Interactive component "${node.name}" is missing an accessibilityLabel` }],
|
|
2013
|
+
description: `Interactive component "${node.name}" is missing an accessibilityLabel`
|
|
2014
|
+
});
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
if (node.bounds.width > 0 && node.bounds.height > 0) {
|
|
2018
|
+
if (node.bounds.width < tapThreshold || node.bounds.height < tapThreshold) {
|
|
2019
|
+
if (isInteractive(node.name)) {
|
|
2020
|
+
findings.push({
|
|
2021
|
+
id: `a11y-tap-${tapTargetCount++}`,
|
|
2022
|
+
category: "accessibility",
|
|
2023
|
+
severity: "minor",
|
|
2024
|
+
confidence: 1,
|
|
2025
|
+
region: node.bounds,
|
|
2026
|
+
component: { name: node.name, testID: node.testID, bounds: node.bounds, depth: 0 },
|
|
2027
|
+
evidence: [{ type: "accessibility", score: 1, note: `Tap target "${node.name}" is ${node.bounds.width}x${node.bounds.height}, smaller than ${tapThreshold}x${tapThreshold}` }],
|
|
2028
|
+
description: `Tap target "${node.name}" is ${node.bounds.width}x${node.bounds.height}, smaller than the recommended ${tapThreshold}x${tapThreshold}`
|
|
2029
|
+
});
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
if (node.name.includes("Image")) {
|
|
2034
|
+
const styles = node.styles ?? {};
|
|
2035
|
+
const hasAlt = "accessibilityLabel" in styles || "aria-label" in styles || "alt" in styles;
|
|
2036
|
+
if (!hasAlt) {
|
|
2037
|
+
findings.push({
|
|
2038
|
+
id: `a11y-image-${imageCount++}`,
|
|
2039
|
+
category: "accessibility",
|
|
2040
|
+
severity: "major",
|
|
2041
|
+
confidence: 1,
|
|
2042
|
+
region: node.bounds,
|
|
2043
|
+
component: { name: node.name, testID: node.testID, bounds: node.bounds, depth: 0 },
|
|
2044
|
+
evidence: [{ type: "accessibility", score: 1, note: `Image component "${node.name}" is missing an accessibilityLabel or alt text` }],
|
|
2045
|
+
description: `Image component "${node.name}" is missing an accessibilityLabel or alt text`
|
|
2046
|
+
});
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
if (node.text === "") {
|
|
2050
|
+
findings.push({
|
|
2051
|
+
id: `a11y-empty-${emptyTextCount++}`,
|
|
2052
|
+
category: "accessibility",
|
|
2053
|
+
severity: "info",
|
|
2054
|
+
confidence: 1,
|
|
2055
|
+
region: node.bounds,
|
|
2056
|
+
component: { name: node.name, testID: node.testID, bounds: node.bounds, depth: 0 },
|
|
2057
|
+
evidence: [{ type: "accessibility", score: 1, note: `Component "${node.name}" has an empty text node` }],
|
|
2058
|
+
description: `Component "${node.name}" has an empty text node`
|
|
2059
|
+
});
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
const totalIssues = findings.length;
|
|
2063
|
+
const summary = totalIssues === 0 ? "No accessibility issues" : `${totalIssues} accessibility issue${totalIssues === 1 ? "" : "s"} found`;
|
|
2064
|
+
return {
|
|
2065
|
+
analysisName: this.name,
|
|
2066
|
+
findings,
|
|
2067
|
+
summary,
|
|
2068
|
+
metadata: {
|
|
2069
|
+
totalChecked: nodes.length,
|
|
2070
|
+
issuesByType: {
|
|
2071
|
+
label: labelCount,
|
|
2072
|
+
tapTarget: tapTargetCount,
|
|
2073
|
+
image: imageCount,
|
|
2074
|
+
emptyText: emptyTextCount
|
|
2075
|
+
}
|
|
2076
|
+
},
|
|
2077
|
+
durationMs: Date.now() - start
|
|
2078
|
+
};
|
|
2079
|
+
}
|
|
2080
|
+
};
|
|
2081
|
+
|
|
2082
|
+
// src/analyses/plugins/regression.ts
|
|
2083
|
+
var RegressionAnalysis = class {
|
|
2084
|
+
name = "regression";
|
|
2085
|
+
description = "Layout regression detection against previous baseline";
|
|
2086
|
+
isAvailable(ctx) {
|
|
2087
|
+
return !!ctx.baseline;
|
|
2088
|
+
}
|
|
2089
|
+
async run(ctx) {
|
|
2090
|
+
const start = Date.now();
|
|
2091
|
+
const { config } = ctx;
|
|
2092
|
+
const [mr, mg, mb, ma] = config.diffMaskColor;
|
|
2093
|
+
const compareResult = await runComparison(ctx.baseline.path, ctx.screenshot.path, {
|
|
2094
|
+
threshold: config.threshold,
|
|
2095
|
+
diffThreshold: config.diffThreshold,
|
|
2096
|
+
regionMergeGap: config.regionMergeGap,
|
|
2097
|
+
regionMinArea: config.regionMinArea,
|
|
2098
|
+
ignoreRules: config.ignoreRules,
|
|
2099
|
+
diffMaskColor: { r: mr, g: mg, b: mb, a: ma / 255 },
|
|
2100
|
+
platform: config.platform
|
|
2101
|
+
});
|
|
2102
|
+
await ctx.store.writeArtifact(ctx.runId, "regression-diff-mask.png", compareResult.diffMaskBuffer);
|
|
2103
|
+
for (const crop of compareResult.regionCrops) {
|
|
2104
|
+
await ctx.store.writeArtifact(ctx.runId, `regression-regions/${crop.id}.png`, crop.buffer);
|
|
2105
|
+
}
|
|
2106
|
+
const regressionThreshold = ctx.analysisConfig.options["regression"]?.regressionThreshold;
|
|
2107
|
+
const passed = compareResult.diffPercentage <= (regressionThreshold ?? config.diffThreshold);
|
|
2108
|
+
const findings = compareResult.regions.map((region, index) => ({
|
|
2109
|
+
id: `regression-${index}`,
|
|
2110
|
+
category: "regression",
|
|
2111
|
+
severity: "major",
|
|
2112
|
+
confidence: 1,
|
|
2113
|
+
region: region.bounds,
|
|
2114
|
+
component: { name: "region", bounds: region.bounds, depth: 0 },
|
|
2115
|
+
evidence: [{ type: "regression", score: region.percentage / 100, note: `${region.percentage.toFixed(3)}% changed` }],
|
|
2116
|
+
description: `Region ${region.id}: ${region.percentage.toFixed(3)}% changed (${region.pixelCount}px)`
|
|
2117
|
+
}));
|
|
2118
|
+
return {
|
|
2119
|
+
analysisName: this.name,
|
|
2120
|
+
findings,
|
|
2121
|
+
summary: passed ? "Regression: no changes detected" : `Regression: ${compareResult.diffPercentage.toFixed(3)}% changed, ${compareResult.regions.length} regions`,
|
|
2122
|
+
metadata: {
|
|
2123
|
+
totalPixels: compareResult.totalPixels,
|
|
2124
|
+
diffPixels: compareResult.diffPixels,
|
|
2125
|
+
diffPercentage: compareResult.diffPercentage,
|
|
2126
|
+
regions: compareResult.regions,
|
|
2127
|
+
passed
|
|
2128
|
+
},
|
|
2129
|
+
durationMs: Date.now() - start
|
|
2130
|
+
};
|
|
2131
|
+
}
|
|
2132
|
+
};
|
|
2133
|
+
|
|
2134
|
+
// src/analyses/default-registry.ts
|
|
2135
|
+
function createDefaultRegistry() {
|
|
2136
|
+
const registry = new AnalysisRegistry();
|
|
2137
|
+
registry.register(new PixelAnalysis());
|
|
2138
|
+
registry.register(new AccessibilityAnalysis());
|
|
2139
|
+
registry.register(new RegressionAnalysis());
|
|
2140
|
+
return registry;
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
// src/analyses/orchestrator.ts
|
|
2144
|
+
var AnalysisOrchestrator = class {
|
|
2145
|
+
constructor(registry) {
|
|
2146
|
+
this.registry = registry;
|
|
2147
|
+
}
|
|
2148
|
+
async run(ctx) {
|
|
2149
|
+
const start = Date.now();
|
|
2150
|
+
const plugins = this.selectPlugins(ctx);
|
|
2151
|
+
const settled = await Promise.allSettled(
|
|
2152
|
+
plugins.map((plugin) => plugin.run(ctx))
|
|
2153
|
+
);
|
|
2154
|
+
const analyses = settled.map((result, i) => {
|
|
2155
|
+
if (result.status === "fulfilled") return result.value;
|
|
2156
|
+
return {
|
|
2157
|
+
analysisName: plugins[i].name,
|
|
2158
|
+
findings: [],
|
|
2159
|
+
summary: `Error: ${result.reason?.message ?? "unknown error"}`,
|
|
2160
|
+
metadata: {},
|
|
2161
|
+
durationMs: 0,
|
|
2162
|
+
error: result.reason?.message ?? "unknown error"
|
|
2163
|
+
};
|
|
2164
|
+
});
|
|
2165
|
+
const findings = analyses.flatMap((a) => a.findings);
|
|
2166
|
+
const summaries = analyses.map((a) => a.summary).join("; ");
|
|
2167
|
+
const durationMs = Date.now() - start;
|
|
2168
|
+
return {
|
|
2169
|
+
runId: ctx.runId,
|
|
2170
|
+
analyses,
|
|
2171
|
+
findings,
|
|
2172
|
+
summary: summaries,
|
|
2173
|
+
metadata: {},
|
|
2174
|
+
durationMs
|
|
2175
|
+
};
|
|
2176
|
+
}
|
|
2177
|
+
selectPlugins(ctx) {
|
|
2178
|
+
let plugins = this.registry.all();
|
|
2179
|
+
const { enabled, disabled } = ctx.analysisConfig;
|
|
2180
|
+
if (enabled.length > 0) {
|
|
2181
|
+
plugins = plugins.filter((p) => enabled.includes(p.name));
|
|
2182
|
+
}
|
|
2183
|
+
if (disabled.length > 0) {
|
|
2184
|
+
plugins = plugins.filter((p) => !disabled.includes(p.name));
|
|
2185
|
+
}
|
|
2186
|
+
return plugins.filter((p) => p.isAvailable(ctx));
|
|
2187
|
+
}
|
|
2188
|
+
};
|
|
2189
|
+
|
|
2190
|
+
// src/commands/compare.ts
|
|
2191
|
+
import * as fs7 from "fs";
|
|
2192
|
+
async function runCompare(shell, config, options) {
|
|
2193
|
+
const store = new RunStore(process.cwd());
|
|
2194
|
+
const run2 = store.createRun();
|
|
2195
|
+
let screenshotPath;
|
|
2196
|
+
let deviceId = "unknown";
|
|
2197
|
+
let platform = config.platform;
|
|
2198
|
+
let deviceInfo;
|
|
2199
|
+
if (options.screenshot) {
|
|
2200
|
+
screenshotPath = options.screenshot;
|
|
2201
|
+
} else {
|
|
2202
|
+
const discovery = new DeviceDiscovery(shell);
|
|
2203
|
+
const devices = await discovery.list();
|
|
2204
|
+
const booted = devices.filter((d) => d.state === "booted");
|
|
2205
|
+
if (booted.length === 0) throw new Error("No booted devices found");
|
|
2206
|
+
let device;
|
|
2207
|
+
if (options.device) {
|
|
2208
|
+
device = booted.find((d) => d.id === options.device || d.name === options.device);
|
|
2209
|
+
if (!device) throw new Error(`Device not found: ${options.device}`);
|
|
2210
|
+
} else {
|
|
2211
|
+
device = await pickDevice(booted);
|
|
2212
|
+
}
|
|
2213
|
+
deviceId = device.id;
|
|
2214
|
+
platform = device.platform;
|
|
2215
|
+
deviceInfo = device;
|
|
2216
|
+
const buffer = await captureScreenshot(shell, device, {
|
|
2217
|
+
settleCheck: config.settleCheckEnabled,
|
|
2218
|
+
settleMaxDelta: config.settleMaxDelta,
|
|
2219
|
+
settleDelayMs: config.settleTimeMs
|
|
2220
|
+
});
|
|
2221
|
+
screenshotPath = store.getRunPath(run2.runId, "screenshot.png");
|
|
2222
|
+
await store.writeArtifact(run2.runId, "screenshot.png", buffer);
|
|
2223
|
+
}
|
|
2224
|
+
const screenshotImage = await buildDriftxImage(screenshotPath);
|
|
2225
|
+
let designImage;
|
|
2226
|
+
if (options.design) {
|
|
2227
|
+
const designBuffer = fs7.readFileSync(options.design);
|
|
2228
|
+
await store.writeArtifact(run2.runId, "design.png", designBuffer);
|
|
2229
|
+
designImage = await buildDriftxImage(options.design);
|
|
2230
|
+
}
|
|
2231
|
+
let baselineImage;
|
|
2232
|
+
if (options.baseline) {
|
|
2233
|
+
const latestRunId = store.getLatestRun();
|
|
2234
|
+
if (latestRunId) {
|
|
2235
|
+
const baselinePath = store.getRunPath(latestRunId, "screenshot.png");
|
|
2236
|
+
if (fs7.existsSync(baselinePath)) {
|
|
2237
|
+
baselineImage = await buildDriftxImage(baselinePath);
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
let inspectResult;
|
|
2242
|
+
let inspectionCapabilities = {
|
|
2243
|
+
tree: "none",
|
|
2244
|
+
sourceMapping: "none",
|
|
2245
|
+
styles: "none",
|
|
2246
|
+
protocol: "none"
|
|
2247
|
+
};
|
|
2248
|
+
if (deviceInfo) {
|
|
2249
|
+
const inspector = new TreeInspector(shell, process.cwd());
|
|
2250
|
+
inspectResult = await inspector.inspect(deviceInfo, {
|
|
2251
|
+
metroPort: config.metroPort,
|
|
2252
|
+
devToolsPort: config.devToolsPort,
|
|
2253
|
+
timeoutMs: config.timeouts.treeInspectionMs
|
|
2254
|
+
});
|
|
2255
|
+
inspectionCapabilities = inspectResult.capabilities;
|
|
2256
|
+
}
|
|
2257
|
+
const analysisConfig = buildAnalysisConfig(config.analyses, options.with, options.without);
|
|
2258
|
+
const ctx = {
|
|
2259
|
+
screenshot: screenshotImage,
|
|
2260
|
+
design: designImage,
|
|
2261
|
+
baseline: baselineImage,
|
|
2262
|
+
tree: inspectResult?.tree,
|
|
2263
|
+
device: deviceInfo,
|
|
2264
|
+
config,
|
|
2265
|
+
analysisConfig,
|
|
2266
|
+
runId: run2.runId,
|
|
2267
|
+
store
|
|
2268
|
+
};
|
|
2269
|
+
const registry = createDefaultRegistry();
|
|
2270
|
+
const orchestrator = new AnalysisOrchestrator(registry);
|
|
2271
|
+
const report = await orchestrator.run(ctx);
|
|
2272
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2273
|
+
const metadata = {
|
|
2274
|
+
runId: run2.runId,
|
|
2275
|
+
startedAt,
|
|
2276
|
+
completedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2277
|
+
projectRoot: process.cwd(),
|
|
2278
|
+
deviceId,
|
|
2279
|
+
platform,
|
|
2280
|
+
orientation: "portrait",
|
|
2281
|
+
framework: "unknown",
|
|
2282
|
+
driftxVersion: "0.1.0",
|
|
2283
|
+
configHash: ""
|
|
2284
|
+
};
|
|
2285
|
+
report.metadata = metadata;
|
|
2286
|
+
await store.writeMetadata(run2.runId, metadata);
|
|
2287
|
+
const resultJson = JSON.stringify(report, null, 2);
|
|
2288
|
+
await store.writeArtifact(run2.runId, "result.json", Buffer.from(resultJson));
|
|
2289
|
+
const anyFailed = report.analyses.some((a) => {
|
|
2290
|
+
const meta = a.metadata;
|
|
2291
|
+
return meta.passed === false;
|
|
2292
|
+
});
|
|
2293
|
+
const exitCode = anyFailed ? ExitCode.DiffFound : ExitCode.Success;
|
|
2294
|
+
const formatData = {
|
|
2295
|
+
report,
|
|
2296
|
+
device: deviceInfo ? { name: deviceInfo.name, platform: deviceInfo.platform } : void 0,
|
|
2297
|
+
artifactDir: store.getRunPath(run2.runId),
|
|
2298
|
+
tree: inspectResult?.tree,
|
|
2299
|
+
inspectHints: inspectResult?.hints
|
|
2300
|
+
};
|
|
2301
|
+
const reportMarkdown = compareFormatter.markdown(formatData);
|
|
2302
|
+
await store.writeArtifact(run2.runId, "report.md", Buffer.from(reportMarkdown));
|
|
2303
|
+
return { report, exitCode, formatData };
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
// src/formatters/clipboard.ts
|
|
2307
|
+
import { exec } from "child_process";
|
|
2308
|
+
function getClipboardCommand(platform) {
|
|
2309
|
+
if (platform === "darwin") return "pbcopy";
|
|
2310
|
+
if (platform === "win32") return "clip";
|
|
2311
|
+
if (platform === "linux") return "xclip -selection clipboard";
|
|
2312
|
+
return void 0;
|
|
2313
|
+
}
|
|
2314
|
+
async function copyToClipboard(text) {
|
|
2315
|
+
const logger = getLogger();
|
|
2316
|
+
const cmd = getClipboardCommand(process.platform);
|
|
2317
|
+
if (!cmd) {
|
|
2318
|
+
logger.debug(`Clipboard not supported on ${process.platform}`);
|
|
2319
|
+
return;
|
|
2320
|
+
}
|
|
2321
|
+
return new Promise((resolve2) => {
|
|
2322
|
+
const proc = exec(cmd, (err) => {
|
|
2323
|
+
if (err) {
|
|
2324
|
+
logger.debug(`Clipboard copy failed: ${err.message}`);
|
|
2325
|
+
}
|
|
2326
|
+
resolve2();
|
|
2327
|
+
});
|
|
2328
|
+
proc.stdin?.write(text);
|
|
2329
|
+
proc.stdin?.end();
|
|
2330
|
+
});
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
// src/formatters/format.ts
|
|
2334
|
+
async function formatOutput(formatter, data, ctx) {
|
|
2335
|
+
const output = formatter[ctx.format](data);
|
|
2336
|
+
if (!ctx.quiet) {
|
|
2337
|
+
console.log(output);
|
|
2338
|
+
}
|
|
2339
|
+
if (ctx.copy) {
|
|
2340
|
+
const clipboardContent = ctx.format === "terminal" ? formatter.markdown(data) : output;
|
|
2341
|
+
await copyToClipboard(clipboardContent);
|
|
2342
|
+
}
|
|
2343
|
+
return output;
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
// src/formatters/devices.ts
|
|
2347
|
+
import pc2 from "picocolors";
|
|
2348
|
+
function stateLabel(state) {
|
|
2349
|
+
if (state === "booted") return pc2.green("\u25CF booted");
|
|
2350
|
+
if (state === "offline") return pc2.yellow("\u25CB offline");
|
|
2351
|
+
return pc2.red("\u2717 unauthorized");
|
|
2352
|
+
}
|
|
2353
|
+
function stateText(state) {
|
|
2354
|
+
if (state === "booted") return "booted";
|
|
2355
|
+
if (state === "offline") return "offline";
|
|
2356
|
+
return "unauthorized";
|
|
2357
|
+
}
|
|
2358
|
+
var devicesFormatter = {
|
|
2359
|
+
terminal(devices) {
|
|
2360
|
+
if (devices.length === 0) {
|
|
2361
|
+
return "No devices found. Start an emulator or connect a device.";
|
|
2362
|
+
}
|
|
2363
|
+
const lines = [];
|
|
2364
|
+
const header = ` ${"ID".padEnd(20)} ${"Name".padEnd(20)} ${"Platform".padEnd(10)} ${"OS".padEnd(10)} ${"State"}`;
|
|
2365
|
+
lines.push("");
|
|
2366
|
+
lines.push(header);
|
|
2367
|
+
lines.push(" " + "-".repeat(70));
|
|
2368
|
+
for (const d of devices) {
|
|
2369
|
+
lines.push(` ${d.id.padEnd(20)} ${d.name.padEnd(20)} ${d.platform.padEnd(10)} ${(d.osVersion || "-").padEnd(10)} ${stateLabel(d.state)}`);
|
|
2370
|
+
}
|
|
2371
|
+
lines.push("");
|
|
2372
|
+
return lines.join("\n");
|
|
2373
|
+
},
|
|
2374
|
+
markdown(devices) {
|
|
2375
|
+
if (devices.length === 0) {
|
|
2376
|
+
return "# Driftx Devices\n\nNo devices found. Start an emulator or connect a device.";
|
|
2377
|
+
}
|
|
2378
|
+
const lines = ["# Driftx Devices", "", "| ID | Name | Platform | OS | State |", "|----|------|----------|-----|-------|"];
|
|
2379
|
+
for (const d of devices) {
|
|
2380
|
+
lines.push(`| ${d.id} | ${d.name} | ${d.platform} | ${d.osVersion || "-"} | ${stateText(d.state)} |`);
|
|
2381
|
+
}
|
|
2382
|
+
return lines.join("\n");
|
|
2383
|
+
},
|
|
2384
|
+
json(devices) {
|
|
2385
|
+
return JSON.stringify(devices, null, 2);
|
|
2386
|
+
}
|
|
2387
|
+
};
|
|
2388
|
+
|
|
2389
|
+
// src/formatters/doctor.ts
|
|
2390
|
+
import pc3 from "picocolors";
|
|
2391
|
+
var doctorFormatter = {
|
|
2392
|
+
terminal(checks) {
|
|
2393
|
+
const lines = [];
|
|
2394
|
+
lines.push("Prerequisite Check");
|
|
2395
|
+
lines.push("\u2500".repeat(60));
|
|
2396
|
+
for (const check of checks) {
|
|
2397
|
+
const icon = check.available ? pc3.green("+") : pc3.red("-");
|
|
2398
|
+
const status = check.available ? "ok" : pc3.red("missing");
|
|
2399
|
+
const version = check.version ?? "";
|
|
2400
|
+
const required = check.required ? "required" : "optional";
|
|
2401
|
+
lines.push(` [${icon}] ${check.name.padEnd(12)} ${String(status).padEnd(10)} ${version.padEnd(16)} (${required})`);
|
|
2402
|
+
if (!check.available && check.fix) {
|
|
2403
|
+
lines.push(` Fix: ${check.fix}`);
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
lines.push("\u2500".repeat(60));
|
|
2407
|
+
return lines.join("\n");
|
|
2408
|
+
},
|
|
2409
|
+
markdown(checks) {
|
|
2410
|
+
const lines = ["# Driftx Doctor", "", "| Tool | Status | Version | Required | Fix |", "|------|--------|---------|----------|-----|"];
|
|
2411
|
+
for (const check of checks) {
|
|
2412
|
+
const status = check.available ? "available" : "unavailable";
|
|
2413
|
+
const version = check.version || "\u2014";
|
|
2414
|
+
const required = check.required ? "yes" : "no";
|
|
2415
|
+
const fix = check.fix || "\u2014";
|
|
2416
|
+
lines.push(`| ${check.name} | ${status} | ${version} | ${required} | ${fix} |`);
|
|
2417
|
+
}
|
|
2418
|
+
return lines.join("\n");
|
|
2419
|
+
},
|
|
2420
|
+
json(checks) {
|
|
2421
|
+
return JSON.stringify(checks, null, 2);
|
|
2422
|
+
}
|
|
2423
|
+
};
|
|
2424
|
+
|
|
2425
|
+
// src/formatters/inspect.ts
|
|
2426
|
+
import pc4 from "picocolors";
|
|
2427
|
+
var STRATEGY_LABELS = {
|
|
2428
|
+
cdp: "CDP via Metro (React DevTools)",
|
|
2429
|
+
uiautomator: "UIAutomator (native Android)",
|
|
2430
|
+
idb: "idb (native iOS)",
|
|
2431
|
+
none: "None"
|
|
2432
|
+
};
|
|
2433
|
+
function formatTreeText(nodes, indent = 0) {
|
|
2434
|
+
const lines = [];
|
|
2435
|
+
for (const node of nodes) {
|
|
2436
|
+
const prefix = " ".repeat(indent);
|
|
2437
|
+
const name = node.reactName ?? node.name;
|
|
2438
|
+
const testId = node.testID ? ` [${node.testID}]` : "";
|
|
2439
|
+
const text = node.text ? ` "${node.text}"` : "";
|
|
2440
|
+
const tier = node.inspectionTier === "detailed" ? " \u269B" : "";
|
|
2441
|
+
const b = node.bounds;
|
|
2442
|
+
const bounds = b.width > 0 ? ` (${b.x},${b.y} ${b.width}x${b.height})` : "";
|
|
2443
|
+
lines.push(`${prefix}${name}${testId}${text}${bounds}${tier}`);
|
|
2444
|
+
const childStr = formatTreeText(node.children, indent + 1);
|
|
2445
|
+
if (childStr) lines.push(childStr);
|
|
2446
|
+
}
|
|
2447
|
+
return lines.join("\n");
|
|
2448
|
+
}
|
|
2449
|
+
function formatStrategySection(result, colored) {
|
|
2450
|
+
const lines = [];
|
|
2451
|
+
const label = STRATEGY_LABELS[result.strategy.method] ?? result.strategy.method;
|
|
2452
|
+
const strategyText = colored ? result.strategy.method === "none" ? pc4.dim(label) : pc4.cyan(label) : label;
|
|
2453
|
+
lines.push("");
|
|
2454
|
+
lines.push(` Device: ${result.device.name} (${result.device.platform})`);
|
|
2455
|
+
lines.push(` Strategy: ${strategyText}`);
|
|
2456
|
+
if (result.strategy.appId) {
|
|
2457
|
+
lines.push(` App: ${result.strategy.appId}`);
|
|
2458
|
+
}
|
|
2459
|
+
lines.push("");
|
|
2460
|
+
return lines.join("\n");
|
|
2461
|
+
}
|
|
2462
|
+
function formatCapsSection(caps) {
|
|
2463
|
+
const lines = [];
|
|
2464
|
+
lines.push(" Capabilities");
|
|
2465
|
+
lines.push(" " + "-".repeat(40));
|
|
2466
|
+
lines.push(` Tree: ${caps.tree}`);
|
|
2467
|
+
lines.push(` Source mapping: ${caps.sourceMapping}`);
|
|
2468
|
+
lines.push(` Styles: ${caps.styles}`);
|
|
2469
|
+
lines.push(` Protocol: ${caps.protocol}`);
|
|
2470
|
+
lines.push("");
|
|
2471
|
+
return lines.join("\n");
|
|
2472
|
+
}
|
|
2473
|
+
function formatHintsSection(hints, colored) {
|
|
2474
|
+
if (hints.length === 0) return "";
|
|
2475
|
+
const lines = ["", " Hints", " " + "-".repeat(40)];
|
|
2476
|
+
for (const hint of hints) {
|
|
2477
|
+
lines.push(colored ? ` ${pc4.yellow(hint)}` : ` ${hint}`);
|
|
2478
|
+
}
|
|
2479
|
+
lines.push("");
|
|
2480
|
+
return lines.join("\n");
|
|
2481
|
+
}
|
|
2482
|
+
var inspectFormatter = {
|
|
2483
|
+
terminal(result) {
|
|
2484
|
+
const parts = [formatStrategySection(result, true)];
|
|
2485
|
+
if (result.tree.length === 0) {
|
|
2486
|
+
parts.push(" No component tree available. Try running with React DevTools enabled.");
|
|
2487
|
+
parts.push(formatHintsSection(result.hints, true));
|
|
2488
|
+
return parts.filter(Boolean).join("\n");
|
|
2489
|
+
}
|
|
2490
|
+
parts.push(formatTreeText(result.tree));
|
|
2491
|
+
parts.push("");
|
|
2492
|
+
parts.push(formatCapsSection(result.capabilities));
|
|
2493
|
+
parts.push(formatHintsSection(result.hints, true));
|
|
2494
|
+
return parts.filter(Boolean).join("\n");
|
|
2495
|
+
},
|
|
2496
|
+
markdown(result) {
|
|
2497
|
+
const lines = [
|
|
2498
|
+
"# Driftx Inspect Report",
|
|
2499
|
+
"",
|
|
2500
|
+
`**Device:** ${result.device.name} (${result.device.platform})`,
|
|
2501
|
+
`**Strategy:** ${STRATEGY_LABELS[result.strategy.method] ?? result.strategy.method}`
|
|
2502
|
+
];
|
|
2503
|
+
if (result.strategy.appId) {
|
|
2504
|
+
lines.push(`**App:** ${result.strategy.appId}`);
|
|
2505
|
+
}
|
|
2506
|
+
if (result.tree.length > 0) {
|
|
2507
|
+
lines.push("", "## Component Tree", "", "```", formatTreeText(result.tree), "```");
|
|
2508
|
+
} else {
|
|
2509
|
+
lines.push("", "No component tree available.");
|
|
2510
|
+
}
|
|
2511
|
+
lines.push("", "## Capabilities", "", "| Capability | Level |", "|------------|-------|");
|
|
2512
|
+
lines.push(`| Tree | ${result.capabilities.tree} |`);
|
|
2513
|
+
lines.push(`| Source mapping | ${result.capabilities.sourceMapping} |`);
|
|
2514
|
+
lines.push(`| Styles | ${result.capabilities.styles} |`);
|
|
2515
|
+
lines.push(`| Protocol | ${result.capabilities.protocol} |`);
|
|
2516
|
+
if (result.hints.length > 0) {
|
|
2517
|
+
lines.push("", "## Hints", "");
|
|
2518
|
+
for (const hint of result.hints) {
|
|
2519
|
+
lines.push(`- ${hint}`);
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
return lines.join("\n");
|
|
2523
|
+
},
|
|
2524
|
+
json(result) {
|
|
2525
|
+
return JSON.stringify({
|
|
2526
|
+
tree: result.tree,
|
|
2527
|
+
capabilities: result.capabilities,
|
|
2528
|
+
strategy: result.strategy,
|
|
2529
|
+
device: result.device,
|
|
2530
|
+
hints: result.hints
|
|
2531
|
+
}, null, 2);
|
|
2532
|
+
}
|
|
2533
|
+
};
|
|
2534
|
+
|
|
2535
|
+
// src/interact/android.ts
|
|
2536
|
+
var AndroidBackend = class {
|
|
2537
|
+
constructor(shell) {
|
|
2538
|
+
this.shell = shell;
|
|
2539
|
+
}
|
|
2540
|
+
async tap(device, point) {
|
|
2541
|
+
await this.adb(device, ["input", "tap", String(point.x), String(point.y)]);
|
|
2542
|
+
}
|
|
2543
|
+
async longPress(device, point, durationMs) {
|
|
2544
|
+
await this.adb(device, ["input", "swipe", String(point.x), String(point.y), String(point.x), String(point.y), String(durationMs)]);
|
|
2545
|
+
}
|
|
2546
|
+
async swipe(device, from, to, durationMs) {
|
|
2547
|
+
await this.adb(device, ["input", "swipe", String(from.x), String(from.y), String(to.x), String(to.y), String(durationMs)]);
|
|
2548
|
+
}
|
|
2549
|
+
async type(device, text) {
|
|
2550
|
+
const escaped = text.replace(/ /g, "%s");
|
|
2551
|
+
await this.adb(device, ["input", "text", escaped]);
|
|
2552
|
+
}
|
|
2553
|
+
async keyEvent(device, key) {
|
|
2554
|
+
await this.adb(device, ["input", "keyevent", key]);
|
|
2555
|
+
}
|
|
2556
|
+
async openUrl(device, url) {
|
|
2557
|
+
await this.shell.exec("adb", ["-s", device.id, "shell", "am", "start", "-a", "android.intent.action.VIEW", "-d", url]);
|
|
2558
|
+
}
|
|
2559
|
+
async adb(device, inputArgs) {
|
|
2560
|
+
await this.shell.exec("adb", ["-s", device.id, "shell", ...inputArgs]);
|
|
2561
|
+
}
|
|
2562
|
+
};
|
|
2563
|
+
|
|
2564
|
+
// src/interact/ios.ts
|
|
2565
|
+
var IosBackend = class {
|
|
2566
|
+
constructor(shell) {
|
|
2567
|
+
this.shell = shell;
|
|
2568
|
+
}
|
|
2569
|
+
async tap(device, point) {
|
|
2570
|
+
await this.simctlIo(device, ["tap", String(point.x), String(point.y)]);
|
|
2571
|
+
}
|
|
2572
|
+
async longPress(device, point, _durationMs) {
|
|
2573
|
+
await this.simctlIo(device, ["longpress", String(point.x), String(point.y)]);
|
|
2574
|
+
}
|
|
2575
|
+
async swipe(device, from, to, _durationMs) {
|
|
2576
|
+
await this.simctlIo(device, ["swipe", String(from.x), String(from.y), String(to.x), String(to.y)]);
|
|
2577
|
+
}
|
|
2578
|
+
async type(device, text) {
|
|
2579
|
+
await this.simctlIo(device, ["type", text]);
|
|
2580
|
+
}
|
|
2581
|
+
async keyEvent(device, key) {
|
|
2582
|
+
await this.simctlIo(device, ["sendkey", key]);
|
|
2583
|
+
}
|
|
2584
|
+
async openUrl(device, url) {
|
|
2585
|
+
await this.shell.exec("xcrun", ["simctl", "openurl", device.id, url]);
|
|
2586
|
+
}
|
|
2587
|
+
async simctlIo(device, args) {
|
|
2588
|
+
await this.shell.exec("xcrun", ["simctl", "io", device.id, ...args]);
|
|
2589
|
+
}
|
|
2590
|
+
};
|
|
2591
|
+
|
|
2592
|
+
// src/interact/backend.ts
|
|
2593
|
+
function createBackend(shell, platform) {
|
|
2594
|
+
return platform === "android" ? new AndroidBackend(shell) : new IosBackend(shell);
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
// src/interact/resolver.ts
|
|
2598
|
+
function resolveTarget(tree, query) {
|
|
2599
|
+
const nodes = flattenTree(tree);
|
|
2600
|
+
const byTestID = nodes.find((n) => n.testID === query && hasSize(n));
|
|
2601
|
+
if (byTestID) return centerOf(byTestID, `testID:${query}`);
|
|
2602
|
+
const byName = nodes.find((n) => n.name === query && hasSize(n));
|
|
2603
|
+
if (byName) return centerOf(byName, `name:${query}`);
|
|
2604
|
+
const byText = nodes.find((n) => n.text === query && hasSize(n));
|
|
2605
|
+
if (byText) return centerOf(byText, `text:${query}`);
|
|
2606
|
+
return null;
|
|
2607
|
+
}
|
|
2608
|
+
function flattenTree(nodes) {
|
|
2609
|
+
const result = [];
|
|
2610
|
+
const stack = [...nodes];
|
|
2611
|
+
while (stack.length > 0) {
|
|
2612
|
+
const node = stack.pop();
|
|
2613
|
+
result.push(node);
|
|
2614
|
+
for (const child of node.children) stack.push(child);
|
|
2615
|
+
}
|
|
2616
|
+
return result;
|
|
2617
|
+
}
|
|
2618
|
+
function hasSize(node) {
|
|
2619
|
+
return node.bounds.width > 0 && node.bounds.height > 0;
|
|
2620
|
+
}
|
|
2621
|
+
function centerOf(node, resolvedFrom) {
|
|
2622
|
+
return {
|
|
2623
|
+
x: Math.round(node.bounds.x + node.bounds.width / 2),
|
|
2624
|
+
y: Math.round(node.bounds.y + node.bounds.height / 2),
|
|
2625
|
+
resolvedFrom
|
|
2626
|
+
};
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
// src/interact/gestures.ts
|
|
2630
|
+
var GestureExecutor = class {
|
|
2631
|
+
constructor(backend) {
|
|
2632
|
+
this.backend = backend;
|
|
2633
|
+
}
|
|
2634
|
+
async tap(device, tree, query) {
|
|
2635
|
+
const start = Date.now();
|
|
2636
|
+
try {
|
|
2637
|
+
const target = resolveTarget(tree, query);
|
|
2638
|
+
if (!target) {
|
|
2639
|
+
return { success: false, action: "tap", durationMs: Date.now() - start, error: `Target not found: ${query}` };
|
|
2640
|
+
}
|
|
2641
|
+
await this.backend.tap(device, target);
|
|
2642
|
+
return { success: true, action: "tap", target, durationMs: Date.now() - start };
|
|
2643
|
+
} catch (e) {
|
|
2644
|
+
return { success: false, action: "tap", durationMs: Date.now() - start, error: String(e) };
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
async tapXY(device, x, y) {
|
|
2648
|
+
const start = Date.now();
|
|
2649
|
+
try {
|
|
2650
|
+
const target = { x, y };
|
|
2651
|
+
await this.backend.tap(device, target);
|
|
2652
|
+
return { success: true, action: "tapXY", target, durationMs: Date.now() - start };
|
|
2653
|
+
} catch (e) {
|
|
2654
|
+
return { success: false, action: "tapXY", durationMs: Date.now() - start, error: String(e) };
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
async longPress(device, tree, query, durationMs = 1e3) {
|
|
2658
|
+
const start = Date.now();
|
|
2659
|
+
try {
|
|
2660
|
+
const target = resolveTarget(tree, query);
|
|
2661
|
+
if (!target) {
|
|
2662
|
+
return { success: false, action: "longPress", durationMs: Date.now() - start, error: `Target not found: ${query}` };
|
|
2663
|
+
}
|
|
2664
|
+
await this.backend.longPress(device, target, durationMs);
|
|
2665
|
+
return { success: true, action: "longPress", target, durationMs: Date.now() - start };
|
|
2666
|
+
} catch (e) {
|
|
2667
|
+
return { success: false, action: "longPress", durationMs: Date.now() - start, error: String(e) };
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2670
|
+
async swipe(device, direction, distance = 600, durationMs = 300) {
|
|
2671
|
+
const start = Date.now();
|
|
2672
|
+
try {
|
|
2673
|
+
const cx = device.screenSize?.width ? Math.round(device.screenSize.width / 2) : 540;
|
|
2674
|
+
const cy = device.screenSize?.height ? Math.round(device.screenSize.height / 2) : 960;
|
|
2675
|
+
const from = { x: cx, y: cy };
|
|
2676
|
+
const half = Math.round(distance / 2);
|
|
2677
|
+
const to = direction === "up" ? { x: cx, y: cy - half } : direction === "down" ? { x: cx, y: cy + half } : direction === "left" ? { x: cx - half, y: cy } : { x: cx + half, y: cy };
|
|
2678
|
+
await this.backend.swipe(device, from, to, durationMs);
|
|
2679
|
+
return { success: true, action: "swipe", durationMs: Date.now() - start };
|
|
2680
|
+
} catch (e) {
|
|
2681
|
+
return { success: false, action: "swipe", durationMs: Date.now() - start, error: String(e) };
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
async typeInto(device, tree, query, text) {
|
|
2685
|
+
const start = Date.now();
|
|
2686
|
+
try {
|
|
2687
|
+
const target = resolveTarget(tree, query);
|
|
2688
|
+
if (!target) {
|
|
2689
|
+
return { success: false, action: "typeInto", durationMs: Date.now() - start, error: `Target not found: ${query}` };
|
|
2690
|
+
}
|
|
2691
|
+
await this.backend.tap(device, target);
|
|
2692
|
+
await this.backend.type(device, text);
|
|
2693
|
+
return { success: true, action: "typeInto", target, durationMs: Date.now() - start };
|
|
2694
|
+
} catch (e) {
|
|
2695
|
+
return { success: false, action: "typeInto", durationMs: Date.now() - start, error: String(e) };
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
async goBack(device) {
|
|
2699
|
+
const start = Date.now();
|
|
2700
|
+
try {
|
|
2701
|
+
const key = device.platform === "android" ? "KEYCODE_BACK" : "home";
|
|
2702
|
+
await this.backend.keyEvent(device, key);
|
|
2703
|
+
return { success: true, action: "goBack", durationMs: Date.now() - start };
|
|
2704
|
+
} catch (e) {
|
|
2705
|
+
return { success: false, action: "goBack", durationMs: Date.now() - start, error: String(e) };
|
|
2706
|
+
}
|
|
2707
|
+
}
|
|
2708
|
+
async openUrl(device, url) {
|
|
2709
|
+
const start = Date.now();
|
|
2710
|
+
try {
|
|
2711
|
+
await this.backend.openUrl(device, url);
|
|
2712
|
+
return { success: true, action: "openUrl", durationMs: Date.now() - start };
|
|
2713
|
+
} catch (e) {
|
|
2714
|
+
return { success: false, action: "openUrl", durationMs: Date.now() - start, error: String(e) };
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
};
|
|
2718
|
+
|
|
2719
|
+
// src/cli.ts
|
|
2720
|
+
var require2 = createRequire(import.meta.url);
|
|
2721
|
+
var pkg = require2("../package.json");
|
|
2722
|
+
function getFormatterContext(opts) {
|
|
2723
|
+
return {
|
|
2724
|
+
format: opts.format ?? "terminal",
|
|
2725
|
+
copy: !!opts.copy,
|
|
2726
|
+
quiet: !!opts.quiet
|
|
2727
|
+
};
|
|
2728
|
+
}
|
|
2729
|
+
function createProgram() {
|
|
2730
|
+
const program = new Command();
|
|
2731
|
+
program.name("driftx").description("Visual diff tool for React Native and Android development").version(pkg.version).option("--verbose", "enable debug logging").option("--quiet", "suppress all output except errors").option("--format <type>", "output format: terminal, markdown, json", "terminal").option("--copy", "copy output to clipboard");
|
|
2732
|
+
program.hook("preAction", (_thisCommand, actionCommand) => {
|
|
2733
|
+
const opts = actionCommand.optsWithGlobals();
|
|
2734
|
+
const level = opts.verbose ? "debug" : opts.quiet ? "silent" : "info";
|
|
2735
|
+
setLogger(createLogger(level));
|
|
2736
|
+
});
|
|
2737
|
+
program.command("doctor").description("Check system prerequisites for driftx").action(async function() {
|
|
2738
|
+
const shell = new RealShell();
|
|
2739
|
+
const config = await loadConfig();
|
|
2740
|
+
const checks = await checkPrerequisites(shell, config.metroPort);
|
|
2741
|
+
const ctx = getFormatterContext(this.optsWithGlobals());
|
|
2742
|
+
await formatOutput(doctorFormatter, checks, ctx);
|
|
2743
|
+
process.exitCode = computeDoctorExitCode(checks);
|
|
2744
|
+
});
|
|
2745
|
+
program.command("init").description("Initialize driftx configuration for this project").action(async () => {
|
|
2746
|
+
const cwd = process.cwd();
|
|
2747
|
+
const files = readdirSync2(cwd);
|
|
2748
|
+
let packageJson;
|
|
2749
|
+
try {
|
|
2750
|
+
packageJson = JSON.parse(readFileSync8(join5(cwd, "package.json"), "utf-8"));
|
|
2751
|
+
} catch {
|
|
2752
|
+
}
|
|
2753
|
+
const framework = detectFramework(files, packageJson);
|
|
2754
|
+
const config = generateConfig(framework);
|
|
2755
|
+
const configPath = join5(cwd, ".driftxrc.json");
|
|
2756
|
+
writeFileSync4(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
2757
|
+
console.log(`Created ${configPath} (framework: ${framework})`);
|
|
2758
|
+
});
|
|
2759
|
+
program.command("devices").description("List connected devices and simulators").action(async function() {
|
|
2760
|
+
const shell = new RealShell();
|
|
2761
|
+
const discovery = new DeviceDiscovery(shell);
|
|
2762
|
+
const devices = await discovery.list();
|
|
2763
|
+
const ctx = getFormatterContext(this.optsWithGlobals());
|
|
2764
|
+
await formatOutput(devicesFormatter, devices, ctx);
|
|
2765
|
+
});
|
|
2766
|
+
program.command("capture").description("Capture a screenshot from a device").option("-d, --device <id>", "device ID or name").option("-o, --output <path>", "output file path").option("--settle", "enable settle-time check").option("--no-settle", "disable settle-time check").action(async (opts) => {
|
|
2767
|
+
const shell = new RealShell();
|
|
2768
|
+
const config = await loadConfig();
|
|
2769
|
+
const result = await runCapture(shell, config, {
|
|
2770
|
+
device: opts.device,
|
|
2771
|
+
output: opts.output,
|
|
2772
|
+
settleCheck: opts.settle
|
|
2773
|
+
});
|
|
2774
|
+
console.log(`Screenshot saved: ${result.path}`);
|
|
2775
|
+
if (result.runId) {
|
|
2776
|
+
console.log(`Run ID: ${result.runId}`);
|
|
2777
|
+
}
|
|
2778
|
+
});
|
|
2779
|
+
program.command("compare").description("Compare a screenshot against a design").option("--design <path>", "path to design image").option("-d, --device <id>", "device ID or name").option("--threshold <n>", "diff percentage threshold", parseFloat).option("--screenshot <path>", "use existing screenshot instead of capturing").option("--with <analyses>", "comma-separated analyses to run").option("--without <analyses>", "exclude specific analyses").option("--baseline", "compare against previous run screenshot").action(async function(opts) {
|
|
2780
|
+
if (!opts.design && !opts.baseline) {
|
|
2781
|
+
throw new Error("Either --design or --baseline must be provided");
|
|
2782
|
+
}
|
|
2783
|
+
const shell = new RealShell();
|
|
2784
|
+
const config = await loadConfig();
|
|
2785
|
+
const { exitCode, formatData } = await runCompare(shell, config, {
|
|
2786
|
+
design: opts.design,
|
|
2787
|
+
device: opts.device,
|
|
2788
|
+
threshold: opts.threshold,
|
|
2789
|
+
screenshot: opts.screenshot,
|
|
2790
|
+
with: opts.with,
|
|
2791
|
+
without: opts.without,
|
|
2792
|
+
baseline: !!opts.baseline
|
|
2793
|
+
});
|
|
2794
|
+
const ctx = getFormatterContext(this.optsWithGlobals());
|
|
2795
|
+
await formatOutput(compareFormatter, formatData, ctx);
|
|
2796
|
+
process.exitCode = exitCode;
|
|
2797
|
+
});
|
|
2798
|
+
program.command("inspect").description("Inspect component tree on device").option("-d, --device <id>", "device ID or name").option("--json", "output as JSON (alias for --format json)").option("--capabilities", "show inspection capabilities only").action(async function(opts) {
|
|
2799
|
+
const shell = new RealShell();
|
|
2800
|
+
const config = await loadConfig();
|
|
2801
|
+
const discovery = new DeviceDiscovery(shell);
|
|
2802
|
+
const devices = await discovery.list();
|
|
2803
|
+
const booted = devices.filter((d) => d.state === "booted");
|
|
2804
|
+
if (booted.length === 0) throw new Error("No booted devices found");
|
|
2805
|
+
let device;
|
|
2806
|
+
if (opts.device) {
|
|
2807
|
+
device = booted.find((d) => d.id === opts.device || d.name === opts.device);
|
|
2808
|
+
if (!device) throw new Error(`Device not found: ${opts.device}`);
|
|
2809
|
+
} else {
|
|
2810
|
+
device = await pickDevice(booted);
|
|
2811
|
+
}
|
|
2812
|
+
const inspector = new TreeInspector(shell, process.cwd());
|
|
2813
|
+
const result = await inspector.inspect(device, {
|
|
2814
|
+
metroPort: config.metroPort,
|
|
2815
|
+
devToolsPort: config.devToolsPort,
|
|
2816
|
+
timeoutMs: config.timeouts.treeInspectionMs
|
|
2817
|
+
});
|
|
2818
|
+
const globalOpts = this.optsWithGlobals();
|
|
2819
|
+
if (opts.json) globalOpts.format = "json";
|
|
2820
|
+
const ctx = getFormatterContext(globalOpts);
|
|
2821
|
+
await formatOutput(inspectFormatter, result, ctx);
|
|
2822
|
+
});
|
|
2823
|
+
program.command("tap <target>").description("Tap a component by testID, name, or text").option("-d, --device <id>", "device ID or name").option("--xy", "treat target as x,y coordinates").action(async (target, opts) => {
|
|
2824
|
+
const shell = new RealShell();
|
|
2825
|
+
const config = await loadConfig();
|
|
2826
|
+
const discovery = new DeviceDiscovery(shell);
|
|
2827
|
+
const devices = await discovery.list();
|
|
2828
|
+
const booted = devices.filter((d) => d.state === "booted");
|
|
2829
|
+
if (booted.length === 0) throw new Error("No booted devices found");
|
|
2830
|
+
let device;
|
|
2831
|
+
if (opts.device) {
|
|
2832
|
+
device = booted.find((d) => d.id === opts.device || d.name === opts.device);
|
|
2833
|
+
if (!device) throw new Error(`Device not found: ${opts.device}`);
|
|
2834
|
+
} else {
|
|
2835
|
+
device = await pickDevice(booted);
|
|
2836
|
+
}
|
|
2837
|
+
const backend = createBackend(shell, device.platform);
|
|
2838
|
+
const executor = new GestureExecutor(backend);
|
|
2839
|
+
let result;
|
|
2840
|
+
if (opts.xy) {
|
|
2841
|
+
const [x, y] = target.split(",").map(Number);
|
|
2842
|
+
result = await executor.tapXY(device, x, y);
|
|
2843
|
+
} else {
|
|
2844
|
+
const inspector = new TreeInspector(shell, process.cwd());
|
|
2845
|
+
const inspectResult = await inspector.inspect(device, {
|
|
2846
|
+
metroPort: config.metroPort,
|
|
2847
|
+
devToolsPort: config.devToolsPort,
|
|
2848
|
+
timeoutMs: config.timeouts.treeInspectionMs
|
|
2849
|
+
});
|
|
2850
|
+
result = await executor.tap(device, inspectResult.tree, target);
|
|
2851
|
+
}
|
|
2852
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2853
|
+
});
|
|
2854
|
+
program.command("type <target> <text>").description("Tap a component then type text into it").option("-d, --device <id>", "device ID or name").action(async (target, text, opts) => {
|
|
2855
|
+
const shell = new RealShell();
|
|
2856
|
+
const config = await loadConfig();
|
|
2857
|
+
const discovery = new DeviceDiscovery(shell);
|
|
2858
|
+
const devices = await discovery.list();
|
|
2859
|
+
const booted = devices.filter((d) => d.state === "booted");
|
|
2860
|
+
if (booted.length === 0) throw new Error("No booted devices found");
|
|
2861
|
+
let device;
|
|
2862
|
+
if (opts.device) {
|
|
2863
|
+
device = booted.find((d) => d.id === opts.device || d.name === opts.device);
|
|
2864
|
+
if (!device) throw new Error(`Device not found: ${opts.device}`);
|
|
2865
|
+
} else {
|
|
2866
|
+
device = await pickDevice(booted);
|
|
2867
|
+
}
|
|
2868
|
+
const inspector = new TreeInspector(shell, process.cwd());
|
|
2869
|
+
const inspectResult = await inspector.inspect(device, {
|
|
2870
|
+
metroPort: config.metroPort,
|
|
2871
|
+
devToolsPort: config.devToolsPort,
|
|
2872
|
+
timeoutMs: config.timeouts.treeInspectionMs
|
|
2873
|
+
});
|
|
2874
|
+
const backend = createBackend(shell, device.platform);
|
|
2875
|
+
const executor = new GestureExecutor(backend);
|
|
2876
|
+
const result = await executor.typeInto(device, inspectResult.tree, target, text);
|
|
2877
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2878
|
+
});
|
|
2879
|
+
program.command("swipe <direction>").description("Swipe up, down, left, or right").option("-d, --device <id>", "device ID or name").action(async (direction, opts) => {
|
|
2880
|
+
const shell = new RealShell();
|
|
2881
|
+
const discovery = new DeviceDiscovery(shell);
|
|
2882
|
+
const devices = await discovery.list();
|
|
2883
|
+
const booted = devices.filter((d) => d.state === "booted");
|
|
2884
|
+
if (booted.length === 0) throw new Error("No booted devices found");
|
|
2885
|
+
let device;
|
|
2886
|
+
if (opts.device) {
|
|
2887
|
+
device = booted.find((d) => d.id === opts.device || d.name === opts.device);
|
|
2888
|
+
if (!device) throw new Error(`Device not found: ${opts.device}`);
|
|
2889
|
+
} else {
|
|
2890
|
+
device = await pickDevice(booted);
|
|
2891
|
+
}
|
|
2892
|
+
const backend = createBackend(shell, device.platform);
|
|
2893
|
+
const executor = new GestureExecutor(backend);
|
|
2894
|
+
const result = await executor.swipe(device, direction);
|
|
2895
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2896
|
+
});
|
|
2897
|
+
program.command("go-back").description("Press the back button").option("-d, --device <id>", "device ID or name").action(async (opts) => {
|
|
2898
|
+
const shell = new RealShell();
|
|
2899
|
+
const discovery = new DeviceDiscovery(shell);
|
|
2900
|
+
const devices = await discovery.list();
|
|
2901
|
+
const booted = devices.filter((d) => d.state === "booted");
|
|
2902
|
+
if (booted.length === 0) throw new Error("No booted devices found");
|
|
2903
|
+
let device;
|
|
2904
|
+
if (opts.device) {
|
|
2905
|
+
device = booted.find((d) => d.id === opts.device || d.name === opts.device);
|
|
2906
|
+
if (!device) throw new Error(`Device not found: ${opts.device}`);
|
|
2907
|
+
} else {
|
|
2908
|
+
device = await pickDevice(booted);
|
|
2909
|
+
}
|
|
2910
|
+
const backend = createBackend(shell, device.platform);
|
|
2911
|
+
const executor = new GestureExecutor(backend);
|
|
2912
|
+
const result = await executor.goBack(device);
|
|
2913
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2914
|
+
});
|
|
2915
|
+
program.command("open-url <url>").description("Open a deep link or URL on the device").option("-d, --device <id>", "device ID or name").action(async (url, opts) => {
|
|
2916
|
+
const shell = new RealShell();
|
|
2917
|
+
const discovery = new DeviceDiscovery(shell);
|
|
2918
|
+
const devices = await discovery.list();
|
|
2919
|
+
const booted = devices.filter((d) => d.state === "booted");
|
|
2920
|
+
if (booted.length === 0) throw new Error("No booted devices found");
|
|
2921
|
+
let device;
|
|
2922
|
+
if (opts.device) {
|
|
2923
|
+
device = booted.find((d) => d.id === opts.device || d.name === opts.device);
|
|
2924
|
+
if (!device) throw new Error(`Device not found: ${opts.device}`);
|
|
2925
|
+
} else {
|
|
2926
|
+
device = await pickDevice(booted);
|
|
2927
|
+
}
|
|
2928
|
+
const backend = createBackend(shell, device.platform);
|
|
2929
|
+
const executor = new GestureExecutor(backend);
|
|
2930
|
+
const result = await executor.openUrl(device, url);
|
|
2931
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2932
|
+
});
|
|
2933
|
+
program.command("setup-claude").description("Register driftx as a Claude Code plugin").action(() => {
|
|
2934
|
+
const claudeDir = join5(homedir(), ".claude");
|
|
2935
|
+
const pluginsDir = join5(claudeDir, "plugins");
|
|
2936
|
+
const driftxPluginDir = join5(pluginsDir, "driftx");
|
|
2937
|
+
const registryPath = join5(pluginsDir, "installed_plugins.json");
|
|
2938
|
+
const packageRoot = resolve(dirname4(fileURLToPath(import.meta.url)), "..");
|
|
2939
|
+
const skillSource = join5(packageRoot, "driftx-plugin");
|
|
2940
|
+
if (!existsSync6(skillSource)) {
|
|
2941
|
+
console.error(`driftx-plugin directory not found at ${skillSource}`);
|
|
2942
|
+
process.exitCode = 1;
|
|
2943
|
+
return;
|
|
2944
|
+
}
|
|
2945
|
+
mkdirSync4(pluginsDir, { recursive: true });
|
|
2946
|
+
if (existsSync6(driftxPluginDir)) {
|
|
2947
|
+
try {
|
|
2948
|
+
unlinkSync3(driftxPluginDir);
|
|
2949
|
+
} catch {
|
|
2950
|
+
console.error(`Could not remove existing ${driftxPluginDir}. Remove it manually and retry.`);
|
|
2951
|
+
process.exitCode = 1;
|
|
2952
|
+
return;
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2955
|
+
symlinkSync(skillSource, driftxPluginDir);
|
|
2956
|
+
let registry = { version: 2, plugins: {} };
|
|
2957
|
+
try {
|
|
2958
|
+
registry = JSON.parse(readFileSync8(registryPath, "utf-8"));
|
|
2959
|
+
} catch {
|
|
2960
|
+
}
|
|
2961
|
+
registry.plugins["driftx@local"] = [{
|
|
2962
|
+
scope: "user",
|
|
2963
|
+
installPath: driftxPluginDir,
|
|
2964
|
+
version: pkg.version,
|
|
2965
|
+
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2966
|
+
lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
|
|
2967
|
+
}];
|
|
2968
|
+
writeFileSync4(registryPath, JSON.stringify(registry, null, 2));
|
|
2969
|
+
console.log("driftx registered as Claude Code plugin.");
|
|
2970
|
+
console.log(` Symlink: ${driftxPluginDir} -> ${skillSource}`);
|
|
2971
|
+
console.log(` Registry: ${registryPath}`);
|
|
2972
|
+
console.log("\nRestart Claude Code to pick up the driftx skill.");
|
|
2973
|
+
});
|
|
2974
|
+
return program;
|
|
2975
|
+
}
|
|
2976
|
+
function run(argv) {
|
|
2977
|
+
const program = createProgram();
|
|
2978
|
+
program.parseAsync(argv);
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2981
|
+
// src/bin.ts
|
|
2982
|
+
run(process.argv);
|
|
2983
|
+
//# sourceMappingURL=bin.js.map
|