@astur-mobile/cli 0.1.0-beta.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/assets/brand/Astur_A_balck.png +0 -0
- package/assets/brand/Astur_A_white.png +0 -0
- package/assets/brand/astur-logo-dark.png +0 -0
- package/assets/brand/astur-logo-light.png +0 -0
- package/assets/brand/astur-mark-light.png +0 -0
- package/assets/brand/astur-mark-transparent.png +0 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +917 -0
- package/dist/index.js.map +1 -0
- package/dist/inspectorServer.d.ts +194 -0
- package/dist/inspectorServer.d.ts.map +1 -0
- package/dist/inspectorServer.js +3487 -0
- package/dist/inspectorServer.js.map +1 -0
- package/dist/inspectorUi.d.ts +17 -0
- package/dist/inspectorUi.d.ts.map +1 -0
- package/dist/inspectorUi.js +1181 -0
- package/dist/inspectorUi.js.map +1 -0
- package/dist/scaffold.d.ts +67 -0
- package/dist/scaffold.d.ts.map +1 -0
- package/dist/scaffold.js +470 -0
- package/dist/scaffold.js.map +1 -0
- package/package.json +46 -0
|
@@ -0,0 +1,3487 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { constants, readFileSync } from 'node:fs';
|
|
3
|
+
import { access, chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { createServer } from 'node:http';
|
|
5
|
+
import { basename, dirname, join, normalize, resolve, sep } from 'node:path';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { WebSocketServer } from 'ws';
|
|
9
|
+
const INSPECTOR_VERSION = readInspectorVersion();
|
|
10
|
+
// ─── Main export ─────────────────────────────────────────────────────────────
|
|
11
|
+
export function startInspectorServer(inspector, device, options) {
|
|
12
|
+
return new Promise((resolveHandle, rejectHandle) => {
|
|
13
|
+
const baseFrameIntervalMs = options.frameIntervalMs ?? 750;
|
|
14
|
+
const baseTreeIntervalMs = options.treeIntervalMs ?? 1200;
|
|
15
|
+
// When the screen/tree stays unchanged we back off polling up to this cap to
|
|
16
|
+
// reduce device load (slow adb dumps, simulator screenshots) while idle. The
|
|
17
|
+
// interval snaps back to the base value as soon as something changes or an
|
|
18
|
+
// action runs, so responsiveness is preserved during active inspection.
|
|
19
|
+
const maxIdleIntervalMs = 4_000;
|
|
20
|
+
// ── State ──────────────────────────────────────────────────────────────
|
|
21
|
+
let activeInspector = inspector;
|
|
22
|
+
let frameIdleStreak = 0;
|
|
23
|
+
let treeIdleStreak = 0;
|
|
24
|
+
let lastTreeSignature;
|
|
25
|
+
let lastFrameBuffer;
|
|
26
|
+
let activeDevice = device;
|
|
27
|
+
let currentNodes = [];
|
|
28
|
+
let currentViewport = { width: 1, height: 1 };
|
|
29
|
+
let revision = 0;
|
|
30
|
+
let recording = false;
|
|
31
|
+
let selectedUid;
|
|
32
|
+
const steps = [];
|
|
33
|
+
let logoDataUri;
|
|
34
|
+
let initialSuggestions = [];
|
|
35
|
+
let gestureCommandInFlight = false;
|
|
36
|
+
let lastGestureCommandAt = 0;
|
|
37
|
+
const bundleUploads = new Map();
|
|
38
|
+
// ── HTTP server ────────────────────────────────────────────────────────
|
|
39
|
+
const server = createServer((req, res) => {
|
|
40
|
+
const url = req.url ?? '/';
|
|
41
|
+
if (url === '/' || url === '/index.html') {
|
|
42
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
43
|
+
res.end(buildInspectorHtml(activeDevice));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (url === '/api/bootstrap') {
|
|
47
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
48
|
+
res.end(JSON.stringify({
|
|
49
|
+
device: {
|
|
50
|
+
id: activeDevice.id,
|
|
51
|
+
name: activeDevice.name,
|
|
52
|
+
platform: activeDevice.platform,
|
|
53
|
+
kind: activeDevice.kind,
|
|
54
|
+
},
|
|
55
|
+
nodes: currentNodes,
|
|
56
|
+
viewport: currentViewport,
|
|
57
|
+
logoDataUri,
|
|
58
|
+
initialUid: pickInitialUid(currentNodes),
|
|
59
|
+
suggestions: initialSuggestions,
|
|
60
|
+
}));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (url.startsWith('/api/upload-app-file')) {
|
|
64
|
+
handleAppBundleFileUpload(req, res).catch((error) => {
|
|
65
|
+
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
66
|
+
res.end(formatActionError(error));
|
|
67
|
+
});
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (url.startsWith('/api/upload-app-bundle')) {
|
|
71
|
+
handleAppBundleFinalize(req, res).catch((error) => {
|
|
72
|
+
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
73
|
+
res.end(formatActionError(error));
|
|
74
|
+
});
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (url.startsWith('/api/upload-app')) {
|
|
78
|
+
handleAppUpload(req, res).catch((error) => {
|
|
79
|
+
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
80
|
+
res.end(formatActionError(error));
|
|
81
|
+
});
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (url.startsWith('/api/export')) {
|
|
85
|
+
const params = new URLSearchParams(url.split('?')[1] ?? '');
|
|
86
|
+
const lang = params.get('lang') === 'javascript' ? 'javascript' : 'typescript';
|
|
87
|
+
const code = generateTestCode(steps, lang);
|
|
88
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
89
|
+
res.end(code);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
res.writeHead(404);
|
|
93
|
+
res.end('Not found');
|
|
94
|
+
});
|
|
95
|
+
async function handleAppUpload(req, res) {
|
|
96
|
+
if (req.method !== 'POST') {
|
|
97
|
+
res.writeHead(405);
|
|
98
|
+
res.end('Method not allowed');
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (!options.installApp) {
|
|
102
|
+
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
103
|
+
res.end('App install is unavailable in this inspector session.');
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const params = new URLSearchParams((req.url ?? '').split('?')[1] ?? '');
|
|
107
|
+
const name = basename(params.get('filename') || 'astur-uploaded-app');
|
|
108
|
+
const dir = await mkdtemp(join(tmpdir(), 'astur-inspector-upload-'));
|
|
109
|
+
const path = join(dir, name);
|
|
110
|
+
try {
|
|
111
|
+
const payload = await readRequestBuffer(req);
|
|
112
|
+
await writeFile(path, payload);
|
|
113
|
+
await options.installApp(path);
|
|
114
|
+
await syncInspectorState();
|
|
115
|
+
broadcast({ type: 'status', message: `Action OK: Installed ${name}` });
|
|
116
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
117
|
+
res.end(JSON.stringify({ ok: true, path }));
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
async function handleAppBundleFileUpload(req, res) {
|
|
124
|
+
if (req.method !== 'POST') {
|
|
125
|
+
res.writeHead(405);
|
|
126
|
+
res.end('Method not allowed');
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (!options.installApp) {
|
|
130
|
+
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
131
|
+
res.end('App install is unavailable in this inspector session.');
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const params = new URLSearchParams((req.url ?? '').split('?')[1] ?? '');
|
|
135
|
+
const uploadId = normalizeUploadId(params.get('uploadId'));
|
|
136
|
+
const relativePath = normalizeClientUploadPath(params.get('relativePath'));
|
|
137
|
+
if (!uploadId || !relativePath) {
|
|
138
|
+
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
139
|
+
res.end('uploadId and relativePath are required for bundle uploads.');
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const dir = await getBundleUploadDir(uploadId);
|
|
143
|
+
const targetPath = resolveBundleUploadPath(dir, relativePath);
|
|
144
|
+
const payload = await readRequestBuffer(req);
|
|
145
|
+
await mkdir(dirname(targetPath), { recursive: true });
|
|
146
|
+
await writeFile(targetPath, payload);
|
|
147
|
+
if (looksLikeExecutableBinary(payload)) {
|
|
148
|
+
await chmod(targetPath, 0o755).catch(() => undefined);
|
|
149
|
+
}
|
|
150
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
151
|
+
res.end(JSON.stringify({ ok: true, path: relativePath }));
|
|
152
|
+
}
|
|
153
|
+
async function handleAppBundleFinalize(req, res) {
|
|
154
|
+
if (req.method !== 'POST') {
|
|
155
|
+
res.writeHead(405);
|
|
156
|
+
res.end('Method not allowed');
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (!options.installApp) {
|
|
160
|
+
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
161
|
+
res.end('App install is unavailable in this inspector session.');
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const params = new URLSearchParams((req.url ?? '').split('?')[1] ?? '');
|
|
165
|
+
const uploadId = normalizeUploadId(params.get('uploadId'));
|
|
166
|
+
const rootName = basename(params.get('rootName') || '').trim();
|
|
167
|
+
if (!uploadId || !rootName) {
|
|
168
|
+
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
169
|
+
res.end('uploadId and rootName are required to finalize a bundle upload.');
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (!rootName.toLowerCase().endsWith('.app')) {
|
|
173
|
+
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
174
|
+
res.end('Bundle uploads must finalize to a .app root.');
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const dir = bundleUploads.get(uploadId);
|
|
178
|
+
if (!dir) {
|
|
179
|
+
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
180
|
+
res.end('No bundle upload exists for this uploadId.');
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const installPath = resolveBundleUploadPath(dir, rootName);
|
|
184
|
+
try {
|
|
185
|
+
await options.installApp(installPath);
|
|
186
|
+
await syncInspectorState();
|
|
187
|
+
broadcast({ type: 'status', message: `Action OK: Installed ${rootName}` });
|
|
188
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
189
|
+
res.end(JSON.stringify({ ok: true, path: installPath }));
|
|
190
|
+
}
|
|
191
|
+
finally {
|
|
192
|
+
bundleUploads.delete(uploadId);
|
|
193
|
+
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
async function getBundleUploadDir(uploadId) {
|
|
197
|
+
const existing = bundleUploads.get(uploadId);
|
|
198
|
+
if (existing) {
|
|
199
|
+
return existing;
|
|
200
|
+
}
|
|
201
|
+
const dir = await mkdtemp(join(tmpdir(), `astur-inspector-bundle-${uploadId}-`));
|
|
202
|
+
bundleUploads.set(uploadId, dir);
|
|
203
|
+
return dir;
|
|
204
|
+
}
|
|
205
|
+
async function readRequestBuffer(req) {
|
|
206
|
+
const chunks = [];
|
|
207
|
+
for await (const chunk of req) {
|
|
208
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
209
|
+
}
|
|
210
|
+
return Buffer.concat(chunks);
|
|
211
|
+
}
|
|
212
|
+
function normalizeUploadId(value) {
|
|
213
|
+
const normalized = String(value ?? '').trim().replace(/[^a-zA-Z0-9._-]+/g, '-').slice(0, 80);
|
|
214
|
+
return normalized || undefined;
|
|
215
|
+
}
|
|
216
|
+
function normalizeClientUploadPath(value) {
|
|
217
|
+
const normalized = normalize(String(value ?? '').replace(/\\/g, '/')).replace(/^\/+/, '');
|
|
218
|
+
if (!normalized || normalized === '.' || normalized.startsWith('..')) {
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
return normalized.replace(/\\/g, '/');
|
|
222
|
+
}
|
|
223
|
+
function resolveBundleUploadPath(rootDir, relativePath) {
|
|
224
|
+
const target = resolve(rootDir, relativePath);
|
|
225
|
+
const root = resolve(rootDir);
|
|
226
|
+
if (target !== root && !target.startsWith(root + sep)) {
|
|
227
|
+
throw new Error(`Invalid upload path: ${relativePath}`);
|
|
228
|
+
}
|
|
229
|
+
return target;
|
|
230
|
+
}
|
|
231
|
+
function looksLikeExecutableBinary(payload) {
|
|
232
|
+
if (payload.length < 4) {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
const magic = payload.subarray(0, 4).toString('hex');
|
|
236
|
+
return [
|
|
237
|
+
'feedface',
|
|
238
|
+
'feedfacf',
|
|
239
|
+
'cefaedfe',
|
|
240
|
+
'cffaedfe',
|
|
241
|
+
'cafebabe',
|
|
242
|
+
'bebafeca',
|
|
243
|
+
'cafebabf',
|
|
244
|
+
'bfbabeca'
|
|
245
|
+
].includes(magic);
|
|
246
|
+
}
|
|
247
|
+
// ── WebSocket server ───────────────────────────────────────────────────
|
|
248
|
+
const wss = new WebSocketServer({ server });
|
|
249
|
+
const clients = new Set();
|
|
250
|
+
let lastFrameDataUri;
|
|
251
|
+
let lastFrameTimestamp = 0;
|
|
252
|
+
function broadcast(event) {
|
|
253
|
+
const data = JSON.stringify(event);
|
|
254
|
+
for (const client of clients) {
|
|
255
|
+
if (client.readyState === 1 /* OPEN */) {
|
|
256
|
+
client.send(data, () => { });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
wss.on('connection', (ws) => {
|
|
261
|
+
clients.add(ws);
|
|
262
|
+
ws.on('error', () => { });
|
|
263
|
+
// Send full bootstrap on connect
|
|
264
|
+
const bootstrapEvent = {
|
|
265
|
+
type: 'bootstrap',
|
|
266
|
+
device: toBootstrapDevice(activeDevice),
|
|
267
|
+
viewport: currentViewport,
|
|
268
|
+
nodes: currentNodes,
|
|
269
|
+
suggestions: initialSuggestions,
|
|
270
|
+
initialUid: pickInitialUid(currentNodes),
|
|
271
|
+
logoDataUri,
|
|
272
|
+
};
|
|
273
|
+
ws.send(JSON.stringify(bootstrapEvent), () => { });
|
|
274
|
+
if (lastFrameDataUri) {
|
|
275
|
+
ws.send(JSON.stringify({
|
|
276
|
+
type: 'frame',
|
|
277
|
+
dataUri: lastFrameDataUri,
|
|
278
|
+
timestamp: lastFrameTimestamp,
|
|
279
|
+
}), () => { });
|
|
280
|
+
}
|
|
281
|
+
ws.on('close', () => clients.delete(ws));
|
|
282
|
+
ws.on('message', (raw) => {
|
|
283
|
+
let event;
|
|
284
|
+
try {
|
|
285
|
+
event = JSON.parse(raw.toString());
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
handleClientEvent(event, ws).catch(() => undefined);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
async function handleClientEvent(event, ws) {
|
|
294
|
+
switch (event.type) {
|
|
295
|
+
case 'click': {
|
|
296
|
+
let uid;
|
|
297
|
+
let node;
|
|
298
|
+
let suggestions = [];
|
|
299
|
+
const shouldRecordTap = recording && event.record !== false;
|
|
300
|
+
const shouldPerformTap = event.perform === true && !shouldRecordTap;
|
|
301
|
+
const localNode = findUiNodeAtPoint(currentNodes, { x: event.x, y: event.y }, {
|
|
302
|
+
preferActionable: shouldRecordTap
|
|
303
|
+
});
|
|
304
|
+
if (localNode) {
|
|
305
|
+
uid = localNode.uid;
|
|
306
|
+
node = localNode;
|
|
307
|
+
suggestions = suggestLocatorsForNode(localNode, currentNodes);
|
|
308
|
+
}
|
|
309
|
+
if (!node && currentNodes.length > 0 && !shouldRecordTap && !shouldPerformTap) {
|
|
310
|
+
const hit = await activeInspector.hitTest({ x: event.x, y: event.y });
|
|
311
|
+
if (!hit) {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
uid = nodeUid(hit, currentNodes);
|
|
315
|
+
node = uid
|
|
316
|
+
? currentNodes.find((candidate) => candidate.uid === uid)
|
|
317
|
+
: flattenNode(hit, 0, '0');
|
|
318
|
+
node = node ? resolveInspectableNode(node, currentNodes, {
|
|
319
|
+
preferActionable: shouldRecordTap
|
|
320
|
+
}) : undefined;
|
|
321
|
+
uid = node?.uid ?? uid;
|
|
322
|
+
suggestions = node ? suggestLocatorsForNode(node, currentNodes) : [];
|
|
323
|
+
}
|
|
324
|
+
if (!node || !uid) {
|
|
325
|
+
if (shouldPerformTap) {
|
|
326
|
+
await performInspectorCoordinateTap({ x: event.x, y: event.y }, ws);
|
|
327
|
+
}
|
|
328
|
+
else if (shouldRecordTap) {
|
|
329
|
+
await recordInspectorCoordinateTap({ x: event.x, y: event.y }, ws);
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
ws.send(JSON.stringify({
|
|
333
|
+
type: 'status',
|
|
334
|
+
message: 'Action Pending: UI tree is still loading. Use Interact mode to tap by coordinate until elements are inspectable.'
|
|
335
|
+
}));
|
|
336
|
+
}
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
selectedUid = uid;
|
|
340
|
+
const selectionEvent = { type: 'selection', uid, node, suggestions };
|
|
341
|
+
ws.send(JSON.stringify(selectionEvent));
|
|
342
|
+
if (shouldPerformTap) {
|
|
343
|
+
await performInspectorCoordinateTap({ x: event.x, y: event.y }, ws);
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
if (shouldRecordTap && !suggestions[0]) {
|
|
347
|
+
await recordInspectorCoordinateTap({ x: event.x, y: event.y }, ws);
|
|
348
|
+
}
|
|
349
|
+
if (shouldRecordTap && suggestions[0]) {
|
|
350
|
+
try {
|
|
351
|
+
if (options.performTap) {
|
|
352
|
+
await options.performTap({ x: event.x, y: event.y });
|
|
353
|
+
}
|
|
354
|
+
else {
|
|
355
|
+
await activeInspector.executeAction({
|
|
356
|
+
kind: 'tap',
|
|
357
|
+
selector: suggestions[0].selector,
|
|
358
|
+
options: { timeout: 2_000 }
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
ws.send(JSON.stringify({
|
|
364
|
+
type: 'status',
|
|
365
|
+
message: `Action Error: Tap failed: ${formatActionError(error)}`
|
|
366
|
+
}));
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const step = {
|
|
370
|
+
index: steps.length,
|
|
371
|
+
action: 'tap',
|
|
372
|
+
locator: normalizeRecordingLocator(suggestions[0].code),
|
|
373
|
+
};
|
|
374
|
+
steps.push(step);
|
|
375
|
+
broadcast({ type: 'step', ...step });
|
|
376
|
+
await syncInspectorState();
|
|
377
|
+
broadcast({ type: 'status', message: 'Action OK: Tap recorded' });
|
|
378
|
+
}
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
case 'direct_action': {
|
|
382
|
+
try {
|
|
383
|
+
if (event.action === 'fill') {
|
|
384
|
+
await activeInspector.executeAction({
|
|
385
|
+
kind: 'fill',
|
|
386
|
+
selector: event.selector,
|
|
387
|
+
value: event.value ?? '',
|
|
388
|
+
options: { timeout: 2_000 }
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
await activeInspector.executeAction({
|
|
393
|
+
kind: 'tap',
|
|
394
|
+
selector: event.selector,
|
|
395
|
+
options: { timeout: 2_000 }
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
await syncInspectorState();
|
|
399
|
+
broadcast({ type: 'status', message: `Action OK: ${event.action === 'fill' ? 'Filled' : 'Tapped'}` });
|
|
400
|
+
}
|
|
401
|
+
catch (error) {
|
|
402
|
+
ws.send(JSON.stringify({
|
|
403
|
+
type: 'status',
|
|
404
|
+
message: `Action Error: ${event.action === 'fill' ? 'Fill' : 'Tap'} failed: ${formatActionError(error)}`
|
|
405
|
+
}));
|
|
406
|
+
}
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
case 'select': {
|
|
410
|
+
const rawNode = currentNodes.find((n) => n.uid === event.uid);
|
|
411
|
+
const node = rawNode ? resolveInspectableNode(rawNode, currentNodes) : undefined;
|
|
412
|
+
if (!node) {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const suggestions = suggestLocatorsForNode(node, currentNodes);
|
|
416
|
+
selectedUid = node.uid;
|
|
417
|
+
const selectionEvent = { type: 'selection', uid: node.uid, node, suggestions };
|
|
418
|
+
ws.send(JSON.stringify(selectionEvent));
|
|
419
|
+
break;
|
|
420
|
+
}
|
|
421
|
+
case 'list_devices': {
|
|
422
|
+
if (!options.listDevices) {
|
|
423
|
+
ws.send(JSON.stringify({ type: 'devices', devices: [toBootstrapDevice(activeDevice)] }));
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
426
|
+
const devices = await options.listDevices();
|
|
427
|
+
ws.send(JSON.stringify({ type: 'devices', devices: devices.map(toBootstrapDevice) }));
|
|
428
|
+
break;
|
|
429
|
+
}
|
|
430
|
+
case 'switch_device': {
|
|
431
|
+
if (!options.switchDevice) {
|
|
432
|
+
ws.send(JSON.stringify({ type: 'status', message: 'Action Error: Device switching is unavailable in this session.' }));
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
try {
|
|
436
|
+
broadcast({ type: 'status', message: 'Action Pending: Switching device...' });
|
|
437
|
+
const binding = await options.switchDevice(event.deviceId);
|
|
438
|
+
activeDevice = binding.device;
|
|
439
|
+
activeInspector = binding.inspector;
|
|
440
|
+
currentNodes = [];
|
|
441
|
+
currentViewport = { width: 1, height: 1 };
|
|
442
|
+
selectedUid = undefined;
|
|
443
|
+
initialSuggestions = [];
|
|
444
|
+
revision += 1;
|
|
445
|
+
broadcast({
|
|
446
|
+
type: 'bootstrap',
|
|
447
|
+
device: toBootstrapDevice(activeDevice),
|
|
448
|
+
viewport: currentViewport,
|
|
449
|
+
nodes: currentNodes,
|
|
450
|
+
suggestions: [],
|
|
451
|
+
logoDataUri
|
|
452
|
+
});
|
|
453
|
+
await syncInspectorState();
|
|
454
|
+
broadcast({ type: 'status', message: `Action OK: Switched to ${activeDevice.name}` });
|
|
455
|
+
}
|
|
456
|
+
catch (error) {
|
|
457
|
+
ws.send(JSON.stringify({
|
|
458
|
+
type: 'status',
|
|
459
|
+
message: `Action Error: Device switch failed: ${formatActionError(error)}`
|
|
460
|
+
}));
|
|
461
|
+
}
|
|
462
|
+
break;
|
|
463
|
+
}
|
|
464
|
+
case 'app_action': {
|
|
465
|
+
if (!options.performAppAction) {
|
|
466
|
+
ws.send(JSON.stringify({ type: 'status', message: 'Action Error: App actions are unavailable in this session.' }));
|
|
467
|
+
break;
|
|
468
|
+
}
|
|
469
|
+
try {
|
|
470
|
+
const binding = await options.performAppAction(event.action, {
|
|
471
|
+
identifier: event.identifier,
|
|
472
|
+
permission: event.permission
|
|
473
|
+
});
|
|
474
|
+
if (binding) {
|
|
475
|
+
activeDevice = binding.device;
|
|
476
|
+
activeInspector = binding.inspector;
|
|
477
|
+
currentNodes = [];
|
|
478
|
+
currentViewport = { width: 1, height: 1 };
|
|
479
|
+
selectedUid = undefined;
|
|
480
|
+
initialSuggestions = [];
|
|
481
|
+
revision += 1;
|
|
482
|
+
broadcast({
|
|
483
|
+
type: 'bootstrap',
|
|
484
|
+
device: toBootstrapDevice(activeDevice),
|
|
485
|
+
viewport: currentViewport,
|
|
486
|
+
nodes: currentNodes,
|
|
487
|
+
suggestions: [],
|
|
488
|
+
logoDataUri
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
await syncInspectorState();
|
|
492
|
+
broadcast({ type: 'status', message: `Action OK: ${inspectorAppActionLabel(event.action)}` });
|
|
493
|
+
}
|
|
494
|
+
catch (error) {
|
|
495
|
+
ws.send(JSON.stringify({
|
|
496
|
+
type: 'status',
|
|
497
|
+
message: `Action Error: ${inspectorAppActionLabel(event.action)} failed: ${formatActionError(error)}`
|
|
498
|
+
}));
|
|
499
|
+
}
|
|
500
|
+
break;
|
|
501
|
+
}
|
|
502
|
+
case 'swipe': {
|
|
503
|
+
if (!options.performSwipe) {
|
|
504
|
+
ws.send(JSON.stringify({ type: 'status', message: 'Action Error: Swipe is unavailable in this session.' }));
|
|
505
|
+
break;
|
|
506
|
+
}
|
|
507
|
+
const now = Date.now();
|
|
508
|
+
if (gestureCommandInFlight || now - lastGestureCommandAt < 350) {
|
|
509
|
+
ws.send(JSON.stringify({ type: 'gesture_ack' }));
|
|
510
|
+
break;
|
|
511
|
+
}
|
|
512
|
+
gestureCommandInFlight = true;
|
|
513
|
+
lastGestureCommandAt = now;
|
|
514
|
+
try {
|
|
515
|
+
await options.performSwipe(event.gesture);
|
|
516
|
+
if (recording && event.record !== false) {
|
|
517
|
+
const step = {
|
|
518
|
+
index: steps.length,
|
|
519
|
+
action: 'swipe',
|
|
520
|
+
locator: '',
|
|
521
|
+
gesture: event.gesture
|
|
522
|
+
};
|
|
523
|
+
steps.push(step);
|
|
524
|
+
broadcast({ type: 'step', ...step });
|
|
525
|
+
}
|
|
526
|
+
await syncInspectorState();
|
|
527
|
+
broadcast({ type: 'status', message: recording ? 'Action OK: Swipe recorded' : 'Action OK: Swiped' });
|
|
528
|
+
}
|
|
529
|
+
catch (error) {
|
|
530
|
+
ws.send(JSON.stringify({
|
|
531
|
+
type: 'status',
|
|
532
|
+
message: `Action Error: Swipe failed: ${formatActionError(error)}`
|
|
533
|
+
}));
|
|
534
|
+
}
|
|
535
|
+
finally {
|
|
536
|
+
gestureCommandInFlight = false;
|
|
537
|
+
}
|
|
538
|
+
break;
|
|
539
|
+
}
|
|
540
|
+
case 'record_toggle': {
|
|
541
|
+
recording = !recording;
|
|
542
|
+
broadcast({ type: 'status', message: recording ? 'Recording ON' : 'Recording OFF' });
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
case 'add_step': {
|
|
546
|
+
const step = {
|
|
547
|
+
index: steps.length,
|
|
548
|
+
action: event.action,
|
|
549
|
+
locator: normalizeRecordingLocator(event.locator),
|
|
550
|
+
value: event.value,
|
|
551
|
+
assertion: event.assertion,
|
|
552
|
+
};
|
|
553
|
+
steps.push(step);
|
|
554
|
+
broadcast({ type: 'step', ...step });
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
557
|
+
case 'device_action': {
|
|
558
|
+
if (event.action === 'refresh') {
|
|
559
|
+
const result = await pushFrame();
|
|
560
|
+
if (result === 'updated') {
|
|
561
|
+
broadcast({ type: 'status', message: 'Action OK: Screen refreshed' });
|
|
562
|
+
}
|
|
563
|
+
else if (result === 'busy') {
|
|
564
|
+
broadcast({ type: 'status', message: 'Action Pending: Screen refresh already running' });
|
|
565
|
+
}
|
|
566
|
+
else {
|
|
567
|
+
broadcast({ type: 'status', message: 'Action Error: Screen refresh failed' });
|
|
568
|
+
}
|
|
569
|
+
break;
|
|
570
|
+
}
|
|
571
|
+
if (event.action === 'tree.refresh') {
|
|
572
|
+
const result = await pushTree({ reportFirstError: true });
|
|
573
|
+
if (result === 'updated') {
|
|
574
|
+
broadcast({ type: 'status', message: 'Action OK: UI tree refreshed' });
|
|
575
|
+
}
|
|
576
|
+
else if (result === 'busy') {
|
|
577
|
+
broadcast({ type: 'status', message: 'Action Pending: UI tree refresh already running' });
|
|
578
|
+
}
|
|
579
|
+
else if (result === 'failed') {
|
|
580
|
+
broadcast({ type: 'status', message: 'Action Error: UI tree refresh did not return an update' });
|
|
581
|
+
}
|
|
582
|
+
break;
|
|
583
|
+
}
|
|
584
|
+
if (!options.performDeviceAction) {
|
|
585
|
+
ws.send(JSON.stringify({ type: 'status', message: 'Action Error: Device actions are unavailable in this session.' }));
|
|
586
|
+
break;
|
|
587
|
+
}
|
|
588
|
+
try {
|
|
589
|
+
await options.performDeviceAction(event.action);
|
|
590
|
+
await syncInspectorState();
|
|
591
|
+
broadcast({ type: 'status', message: `Action OK: ${inspectorDeviceActionLabel(event.action)}` });
|
|
592
|
+
}
|
|
593
|
+
catch (error) {
|
|
594
|
+
ws.send(JSON.stringify({
|
|
595
|
+
type: 'status',
|
|
596
|
+
message: `Action Error: ${inspectorDeviceActionLabel(event.action)} failed: ${formatActionError(error)}`
|
|
597
|
+
}));
|
|
598
|
+
}
|
|
599
|
+
break;
|
|
600
|
+
}
|
|
601
|
+
case 'clear_steps': {
|
|
602
|
+
steps.length = 0;
|
|
603
|
+
broadcast({ type: 'steps', steps: [] });
|
|
604
|
+
break;
|
|
605
|
+
}
|
|
606
|
+
case 'export': {
|
|
607
|
+
const code = generateTestCode(steps, event.lang);
|
|
608
|
+
ws.send(JSON.stringify({ type: 'status', message: `Export:\n${code}` }));
|
|
609
|
+
break;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
async function performInspectorCoordinateTap(point, ws) {
|
|
614
|
+
if (!options.performTap) {
|
|
615
|
+
ws.send(JSON.stringify({ type: 'status', message: 'Action Error: Tap is unavailable in this session.' }));
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
try {
|
|
619
|
+
await options.performTap(point);
|
|
620
|
+
await syncInspectorState();
|
|
621
|
+
broadcast({ type: 'status', message: 'Action OK: Tapped' });
|
|
622
|
+
}
|
|
623
|
+
catch (error) {
|
|
624
|
+
ws.send(JSON.stringify({
|
|
625
|
+
type: 'status',
|
|
626
|
+
message: `Action Error: Tap failed: ${formatActionError(error)}`
|
|
627
|
+
}));
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
async function recordInspectorCoordinateTap(point, ws) {
|
|
631
|
+
if (!options.performTap) {
|
|
632
|
+
ws.send(JSON.stringify({
|
|
633
|
+
type: 'status',
|
|
634
|
+
message: 'Action Error: Tap could not be recorded because coordinate tapping is unavailable in this session.'
|
|
635
|
+
}));
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
try {
|
|
639
|
+
await options.performTap(point);
|
|
640
|
+
const step = {
|
|
641
|
+
index: steps.length,
|
|
642
|
+
action: 'tapPoint',
|
|
643
|
+
locator: '',
|
|
644
|
+
point
|
|
645
|
+
};
|
|
646
|
+
steps.push(step);
|
|
647
|
+
broadcast({ type: 'step', ...step });
|
|
648
|
+
await syncInspectorState();
|
|
649
|
+
broadcast({ type: 'status', message: 'Action OK: Coordinate tap recorded' });
|
|
650
|
+
}
|
|
651
|
+
catch (error) {
|
|
652
|
+
ws.send(JSON.stringify({
|
|
653
|
+
type: 'status',
|
|
654
|
+
message: `Action Error: Tap failed: ${formatActionError(error)}`
|
|
655
|
+
}));
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
// ── Frame streaming loop ────────────────────────────────────────────────
|
|
659
|
+
let frameTimer;
|
|
660
|
+
let frameInFlight = false;
|
|
661
|
+
async function pushFrame() {
|
|
662
|
+
if (clients.size === 0 || frameInFlight) {
|
|
663
|
+
return 'busy';
|
|
664
|
+
}
|
|
665
|
+
frameInFlight = true;
|
|
666
|
+
try {
|
|
667
|
+
const buf = await options.captureScreenshot();
|
|
668
|
+
if (buf && buf.length > 0) {
|
|
669
|
+
// Skip re-encoding and re-broadcasting identical frames (common on
|
|
670
|
+
// static screens such as forms); this saves base64 work and socket
|
|
671
|
+
// bandwidth and lets the poll loop back off while the screen is idle.
|
|
672
|
+
if (lastFrameBuffer && buf.equals(lastFrameBuffer)) {
|
|
673
|
+
frameIdleStreak += 1;
|
|
674
|
+
return 'updated';
|
|
675
|
+
}
|
|
676
|
+
lastFrameBuffer = buf;
|
|
677
|
+
lastFrameDataUri = `data:image/png;base64,${buf.toString('base64')}`;
|
|
678
|
+
lastFrameTimestamp = Date.now();
|
|
679
|
+
frameIdleStreak = 0;
|
|
680
|
+
broadcast({
|
|
681
|
+
type: 'frame',
|
|
682
|
+
dataUri: lastFrameDataUri,
|
|
683
|
+
timestamp: lastFrameTimestamp,
|
|
684
|
+
});
|
|
685
|
+
return 'updated';
|
|
686
|
+
}
|
|
687
|
+
return 'failed';
|
|
688
|
+
}
|
|
689
|
+
catch {
|
|
690
|
+
// device may be busy
|
|
691
|
+
return 'failed';
|
|
692
|
+
}
|
|
693
|
+
finally {
|
|
694
|
+
frameInFlight = false;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
function scheduleFrame() {
|
|
698
|
+
const interval = clients.size === 0
|
|
699
|
+
? maxIdleIntervalMs
|
|
700
|
+
: nextPollInterval(baseFrameIntervalMs, frameIdleStreak, maxIdleIntervalMs);
|
|
701
|
+
frameTimer = setTimeout(async () => {
|
|
702
|
+
await pushFrame();
|
|
703
|
+
scheduleFrame();
|
|
704
|
+
}, interval);
|
|
705
|
+
}
|
|
706
|
+
// ── Tree polling loop ──────────────────────────────────────────────────
|
|
707
|
+
let treeTimer;
|
|
708
|
+
let treeInFlight = false;
|
|
709
|
+
let lastTreeErrorMessage;
|
|
710
|
+
let lastTreeErrorAt = 0;
|
|
711
|
+
let consecutiveTreeErrors = 0;
|
|
712
|
+
async function pushTree(options = {}) {
|
|
713
|
+
if (treeInFlight) {
|
|
714
|
+
return 'busy';
|
|
715
|
+
}
|
|
716
|
+
// Avoid expensive UI-tree reads (e.g. adb `uiautomator dump`, broad XCUI
|
|
717
|
+
// queries) when nobody is watching. Bootstrap/manual refreshes pass
|
|
718
|
+
// reportFirstError and are always honoured.
|
|
719
|
+
if (clients.size === 0 && !options.reportFirstError) {
|
|
720
|
+
return 'busy';
|
|
721
|
+
}
|
|
722
|
+
treeInFlight = true;
|
|
723
|
+
try {
|
|
724
|
+
for await (const update of activeInspector.subscribeTree({ maxUpdates: 1 })) {
|
|
725
|
+
const nodes = flattenSnapshot(update.root);
|
|
726
|
+
const viewport = estimateViewport(nodes);
|
|
727
|
+
currentNodes = nodes;
|
|
728
|
+
currentViewport = viewport;
|
|
729
|
+
consecutiveTreeErrors = 0;
|
|
730
|
+
lastTreeErrorMessage = undefined;
|
|
731
|
+
const signature = computeTreeSignature(nodes);
|
|
732
|
+
if (signature === lastTreeSignature && !options.reportFirstError) {
|
|
733
|
+
// Tree is unchanged: keep the freshly captured nodes for hit-testing
|
|
734
|
+
// but skip the broadcast and suggestion recompute, and let the poll
|
|
735
|
+
// loop back off.
|
|
736
|
+
treeIdleStreak += 1;
|
|
737
|
+
return 'updated';
|
|
738
|
+
}
|
|
739
|
+
lastTreeSignature = signature;
|
|
740
|
+
treeIdleStreak = 0;
|
|
741
|
+
revision += 1;
|
|
742
|
+
const initialUid = selectedUid ?? pickInitialUid(currentNodes);
|
|
743
|
+
const initialNode = initialUid
|
|
744
|
+
? currentNodes.find((node) => node.uid === initialUid)
|
|
745
|
+
: undefined;
|
|
746
|
+
initialSuggestions = initialNode ? suggestLocatorsForNode(initialNode, currentNodes) : [];
|
|
747
|
+
broadcast({ type: 'tree', nodes, viewport, revision });
|
|
748
|
+
return 'updated';
|
|
749
|
+
}
|
|
750
|
+
return 'failed';
|
|
751
|
+
}
|
|
752
|
+
catch (error) {
|
|
753
|
+
consecutiveTreeErrors += 1;
|
|
754
|
+
const message = formatActionError(error);
|
|
755
|
+
const now = Date.now();
|
|
756
|
+
if (!options.reportFirstError && consecutiveTreeErrors < 2 && currentNodes.length > 0) {
|
|
757
|
+
return 'failed';
|
|
758
|
+
}
|
|
759
|
+
if (options.reportFirstError || message !== lastTreeErrorMessage || now - lastTreeErrorAt > 8_000) {
|
|
760
|
+
lastTreeErrorMessage = message;
|
|
761
|
+
lastTreeErrorAt = now;
|
|
762
|
+
const prefix = currentNodes.length > 0 ? 'Action Pending' : 'Action Error';
|
|
763
|
+
const label = currentNodes.length > 0 ? 'UI tree refresh delayed' : 'UI tree unavailable';
|
|
764
|
+
broadcast({
|
|
765
|
+
type: 'status',
|
|
766
|
+
message: `${prefix}: ${label}: ${message}`
|
|
767
|
+
});
|
|
768
|
+
return 'reported';
|
|
769
|
+
}
|
|
770
|
+
return 'failed';
|
|
771
|
+
}
|
|
772
|
+
finally {
|
|
773
|
+
treeInFlight = false;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
async function syncInspectorState() {
|
|
777
|
+
// An action just ran: snap polling back to the responsive base interval so
|
|
778
|
+
// the resulting screen/tree change surfaces immediately.
|
|
779
|
+
frameIdleStreak = 0;
|
|
780
|
+
treeIdleStreak = 0;
|
|
781
|
+
const frame = await pushFrame();
|
|
782
|
+
void pushTree({ reportFirstError: true }).catch(() => undefined);
|
|
783
|
+
return { frame, tree: 'busy' };
|
|
784
|
+
}
|
|
785
|
+
function scheduleTree() {
|
|
786
|
+
const interval = clients.size === 0
|
|
787
|
+
? maxIdleIntervalMs
|
|
788
|
+
: nextPollInterval(baseTreeIntervalMs, treeIdleStreak, maxIdleIntervalMs);
|
|
789
|
+
treeTimer = setTimeout(async () => {
|
|
790
|
+
await pushTree();
|
|
791
|
+
scheduleTree();
|
|
792
|
+
}, interval);
|
|
793
|
+
}
|
|
794
|
+
// ── Startup ────────────────────────────────────────────────────────────
|
|
795
|
+
// Bind to loopback only: the inspector grants full device control (tap,
|
|
796
|
+
// fill, swipe, app install) over an unauthenticated socket and must never be
|
|
797
|
+
// reachable from other hosts on the network.
|
|
798
|
+
server.listen(options.port ?? 0, '127.0.0.1', () => {
|
|
799
|
+
const addr = server.address();
|
|
800
|
+
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
|
801
|
+
// Resolve the handle immediately — browser opens now, data streams in
|
|
802
|
+
scheduleFrame();
|
|
803
|
+
scheduleTree();
|
|
804
|
+
void syncInspectorState();
|
|
805
|
+
options.onListen?.(port);
|
|
806
|
+
resolveHandle({
|
|
807
|
+
port,
|
|
808
|
+
close() {
|
|
809
|
+
clearTimeout(frameTimer);
|
|
810
|
+
clearTimeout(treeTimer);
|
|
811
|
+
for (const dir of bundleUploads.values()) {
|
|
812
|
+
void rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
|
813
|
+
}
|
|
814
|
+
bundleUploads.clear();
|
|
815
|
+
wss.close();
|
|
816
|
+
server.close();
|
|
817
|
+
},
|
|
818
|
+
});
|
|
819
|
+
// Load logo asynchronously and push to any connected clients
|
|
820
|
+
readAsturLogoDataUri().then((uri) => {
|
|
821
|
+
logoDataUri = uri;
|
|
822
|
+
}).catch(() => undefined);
|
|
823
|
+
});
|
|
824
|
+
server.on('error', rejectHandle);
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
828
|
+
/**
|
|
829
|
+
* Back off a polling interval the longer the device stays idle, capped at
|
|
830
|
+
* maxMs. The interval returns to the base value (streak 0) the moment a change
|
|
831
|
+
* or action is observed, keeping active inspection responsive while idle
|
|
832
|
+
* sessions stop hammering slow device transports.
|
|
833
|
+
*/
|
|
834
|
+
function nextPollInterval(baseMs, idleStreak, maxMs) {
|
|
835
|
+
if (idleStreak <= 1) {
|
|
836
|
+
return baseMs;
|
|
837
|
+
}
|
|
838
|
+
const factor = 1 + Math.min(3, (idleStreak - 1) * 0.5);
|
|
839
|
+
return Math.min(maxMs, Math.round(baseMs * factor));
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* Compact, order-sensitive fingerprint of the visible UI tree used to detect
|
|
843
|
+
* whether anything changed between polls. Hashing keeps the stored value small
|
|
844
|
+
* and the comparison O(1) regardless of tree size.
|
|
845
|
+
*/
|
|
846
|
+
function computeTreeSignature(nodes) {
|
|
847
|
+
const hash = createHash('sha1');
|
|
848
|
+
for (const node of nodes) {
|
|
849
|
+
hash.update(node.uid);
|
|
850
|
+
hash.update('|');
|
|
851
|
+
hash.update(node.type);
|
|
852
|
+
hash.update('|');
|
|
853
|
+
hash.update(`${node.bounds.x},${node.bounds.y},${node.bounds.width},${node.bounds.height}`);
|
|
854
|
+
hash.update('|');
|
|
855
|
+
hash.update(node.visible ? '1' : '0');
|
|
856
|
+
hash.update(node.enabled ? '1' : '0');
|
|
857
|
+
hash.update('|');
|
|
858
|
+
hash.update(node.id ?? '');
|
|
859
|
+
hash.update('|');
|
|
860
|
+
hash.update(node.label ?? '');
|
|
861
|
+
hash.update('|');
|
|
862
|
+
hash.update(node.text ?? '');
|
|
863
|
+
hash.update('|');
|
|
864
|
+
hash.update(node.value ?? '');
|
|
865
|
+
hash.update('\n');
|
|
866
|
+
}
|
|
867
|
+
return hash.digest('hex');
|
|
868
|
+
}
|
|
869
|
+
function flattenSnapshot(root) {
|
|
870
|
+
const nodes = [];
|
|
871
|
+
const visit = (node, depth, uid, parentUid) => {
|
|
872
|
+
nodes.push(flattenNode(node, depth, uid, parentUid));
|
|
873
|
+
for (const [i, child] of node.children.entries()) {
|
|
874
|
+
visit(child, depth + 1, `${uid}.${i}`, uid);
|
|
875
|
+
}
|
|
876
|
+
};
|
|
877
|
+
visit(root, 0, '0');
|
|
878
|
+
return nodes;
|
|
879
|
+
}
|
|
880
|
+
function flattenNode(node, depth, uid, parentUid) {
|
|
881
|
+
return {
|
|
882
|
+
uid,
|
|
883
|
+
parentUid,
|
|
884
|
+
depth,
|
|
885
|
+
title: titleForNode({
|
|
886
|
+
id: node.id,
|
|
887
|
+
label: node.label,
|
|
888
|
+
text: node.text,
|
|
889
|
+
value: node.value,
|
|
890
|
+
type: node.type,
|
|
891
|
+
}),
|
|
892
|
+
type: node.type,
|
|
893
|
+
id: node.id,
|
|
894
|
+
label: node.label,
|
|
895
|
+
text: node.text,
|
|
896
|
+
value: node.value,
|
|
897
|
+
visible: node.visible,
|
|
898
|
+
enabled: node.enabled,
|
|
899
|
+
bounds: node.bounds,
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
function estimateViewport(nodes) {
|
|
903
|
+
const root = nodes[0];
|
|
904
|
+
if (root?.bounds && root.bounds.width > 0 && root.bounds.height > 0) {
|
|
905
|
+
return {
|
|
906
|
+
width: Math.max(1, root.bounds.x + root.bounds.width),
|
|
907
|
+
height: Math.max(1, root.bounds.y + root.bounds.height)
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
const visible = nodes.filter((n) => n.visible && n.bounds.width > 0 && n.bounds.height > 0);
|
|
911
|
+
const right = Math.max(1, ...visible.map((n) => n.bounds.x + n.bounds.width));
|
|
912
|
+
const bottom = Math.max(1, ...visible.map((n) => n.bounds.y + n.bounds.height));
|
|
913
|
+
return { width: right, height: bottom };
|
|
914
|
+
}
|
|
915
|
+
function pickInitialUid(nodes) {
|
|
916
|
+
return [...nodes]
|
|
917
|
+
.filter((node) => node.visible && node.enabled && !isRootNode(node))
|
|
918
|
+
.sort((left, right) => scoreInspectableNode(right, { preferActionable: true })
|
|
919
|
+
- scoreInspectableNode(left, { preferActionable: true }))[0]?.uid ?? nodes[0]?.uid;
|
|
920
|
+
}
|
|
921
|
+
function findUiNodeAtPoint(nodes, point, options = {}) {
|
|
922
|
+
let best;
|
|
923
|
+
let bestScore = Number.NEGATIVE_INFINITY;
|
|
924
|
+
for (const node of nodes) {
|
|
925
|
+
if (!node.visible || node.bounds.width <= 0 || node.bounds.height <= 0) {
|
|
926
|
+
continue;
|
|
927
|
+
}
|
|
928
|
+
if (!containsPoint(node.bounds, point)) {
|
|
929
|
+
continue;
|
|
930
|
+
}
|
|
931
|
+
const score = scoreInspectableNode(node, options);
|
|
932
|
+
if (!best || score > bestScore) {
|
|
933
|
+
best = node;
|
|
934
|
+
bestScore = score;
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
const bestArea = best.bounds.width * best.bounds.height;
|
|
938
|
+
const nodeArea = node.bounds.width * node.bounds.height;
|
|
939
|
+
if (score === bestScore && (node.depth > best.depth || (node.depth === best.depth && nodeArea <= bestArea))) {
|
|
940
|
+
best = node;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
return best ? resolveInspectableNode(best, nodes, options) : undefined;
|
|
944
|
+
}
|
|
945
|
+
function nodeUid(hit, nodes) {
|
|
946
|
+
// Try to find by bounds + type match in the flattened tree
|
|
947
|
+
return nodes.find((n) => n.type === hit.type &&
|
|
948
|
+
n.bounds.x === hit.bounds.x &&
|
|
949
|
+
n.bounds.y === hit.bounds.y &&
|
|
950
|
+
n.bounds.width === hit.bounds.width &&
|
|
951
|
+
n.bounds.height === hit.bounds.height)?.uid;
|
|
952
|
+
}
|
|
953
|
+
const INSPECTOR_ROLES = [
|
|
954
|
+
'button',
|
|
955
|
+
'checkbox',
|
|
956
|
+
'image',
|
|
957
|
+
'img',
|
|
958
|
+
'link',
|
|
959
|
+
'menuitem',
|
|
960
|
+
'radio',
|
|
961
|
+
'slider',
|
|
962
|
+
'switch',
|
|
963
|
+
'tab',
|
|
964
|
+
'text',
|
|
965
|
+
'textbox'
|
|
966
|
+
];
|
|
967
|
+
function suggestLocatorsForNode(node, nodes) {
|
|
968
|
+
const target = resolveInspectableNode(node, nodes);
|
|
969
|
+
const candidates = buildLocalLocatorCandidates(target);
|
|
970
|
+
const suggestions = [];
|
|
971
|
+
const seen = new Set();
|
|
972
|
+
for (const candidate of candidates) {
|
|
973
|
+
const code = normalizeRecordingLocator(candidate.code);
|
|
974
|
+
if (seen.has(code)) {
|
|
975
|
+
continue;
|
|
976
|
+
}
|
|
977
|
+
seen.add(code);
|
|
978
|
+
const matched = nodes.filter((candidateNode) => uiNodeMatchesSelector(candidateNode, candidate.selector));
|
|
979
|
+
const uniqueness = matched.length > 0 ? 1 / matched.length : 0;
|
|
980
|
+
const stability = clampScore(candidate.stabilityHint);
|
|
981
|
+
const readable = code.length <= 88;
|
|
982
|
+
const score = scoreLocatorCandidate(candidate.baseScore, uniqueness, stability, readable);
|
|
983
|
+
suggestions.push({
|
|
984
|
+
code,
|
|
985
|
+
selector: candidate.selector,
|
|
986
|
+
score,
|
|
987
|
+
uniqueness: roundScore(uniqueness),
|
|
988
|
+
stability: roundScore(stability),
|
|
989
|
+
readable,
|
|
990
|
+
crossPlatform: candidate.crossPlatform,
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
return suggestions
|
|
994
|
+
.sort((left, right) => {
|
|
995
|
+
if (right.score !== left.score) {
|
|
996
|
+
return right.score - left.score;
|
|
997
|
+
}
|
|
998
|
+
return left.code.length - right.code.length;
|
|
999
|
+
})
|
|
1000
|
+
.slice(0, 8);
|
|
1001
|
+
}
|
|
1002
|
+
function buildLocalLocatorCandidates(node) {
|
|
1003
|
+
const candidates = [];
|
|
1004
|
+
const id = normalizeLocatorToken(node.id);
|
|
1005
|
+
const label = normalizeLocatorToken(node.label);
|
|
1006
|
+
const text = normalizeLocatorToken(node.text);
|
|
1007
|
+
const value = normalizeLocatorToken(node.value);
|
|
1008
|
+
const name = label ?? text ?? value;
|
|
1009
|
+
const role = inferUiRole(node, name);
|
|
1010
|
+
if (id) {
|
|
1011
|
+
candidates.push({
|
|
1012
|
+
selector: { strategy: 'id', value: id, exact: true },
|
|
1013
|
+
code: `getByTestId('${escapeSingleQuotes(id)}')`,
|
|
1014
|
+
baseScore: 0.99,
|
|
1015
|
+
crossPlatform: true,
|
|
1016
|
+
stabilityHint: scoreStableToken(id, 'id')
|
|
1017
|
+
});
|
|
1018
|
+
candidates.push({
|
|
1019
|
+
selector: { strategy: 'id', value: id, exact: true },
|
|
1020
|
+
code: `getById('${escapeSingleQuotes(id)}')`,
|
|
1021
|
+
baseScore: 0.96,
|
|
1022
|
+
crossPlatform: true,
|
|
1023
|
+
stabilityHint: scoreStableToken(id, 'id')
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
if (role && name) {
|
|
1027
|
+
candidates.push({
|
|
1028
|
+
selector: { strategy: 'role', value: role, name, exact: true },
|
|
1029
|
+
code: `getByRole('${escapeSingleQuotes(role)}', { name: '${escapeSingleQuotes(name)}' })`,
|
|
1030
|
+
baseScore: 0.92,
|
|
1031
|
+
crossPlatform: true,
|
|
1032
|
+
stabilityHint: scoreStableToken(name, 'text')
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
if (label) {
|
|
1036
|
+
candidates.push({
|
|
1037
|
+
selector: { strategy: 'accessibility', value: label, exact: true },
|
|
1038
|
+
code: `getByLabel('${escapeSingleQuotes(label)}')`,
|
|
1039
|
+
baseScore: 0.89,
|
|
1040
|
+
crossPlatform: true,
|
|
1041
|
+
stabilityHint: scoreStableToken(label, 'accessibility')
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
if (text) {
|
|
1045
|
+
candidates.push({
|
|
1046
|
+
selector: { strategy: 'text', value: text, exact: true },
|
|
1047
|
+
code: `getByText('${escapeSingleQuotes(text)}')`,
|
|
1048
|
+
baseScore: 0.84,
|
|
1049
|
+
crossPlatform: true,
|
|
1050
|
+
stabilityHint: scoreStableToken(text, 'text')
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
const type = normalizeLocatorToken(node.type);
|
|
1054
|
+
if (type && candidates.length === 0) {
|
|
1055
|
+
candidates.push({
|
|
1056
|
+
selector: { strategy: 'type', value: type, exact: true },
|
|
1057
|
+
code: `getByType('${escapeSingleQuotes(type)}')`,
|
|
1058
|
+
baseScore: 0.45,
|
|
1059
|
+
crossPlatform: false,
|
|
1060
|
+
stabilityHint: 0.45
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
return candidates;
|
|
1064
|
+
}
|
|
1065
|
+
function resolveInspectableNode(node, nodes, options = {}) {
|
|
1066
|
+
const targetIsUsable = hasUsableLocator(node) && !isDecorativeNode(node);
|
|
1067
|
+
const targetIsActionable = targetIsUsable && isActionableNode(node);
|
|
1068
|
+
if (!options.preferActionable && targetIsUsable) {
|
|
1069
|
+
return node;
|
|
1070
|
+
}
|
|
1071
|
+
if (options.preferActionable && targetIsActionable) {
|
|
1072
|
+
return node;
|
|
1073
|
+
}
|
|
1074
|
+
for (const ancestor of ancestorsOf(node, nodes)) {
|
|
1075
|
+
if (!hasUsableLocator(ancestor) || isDecorativeNode(ancestor)) {
|
|
1076
|
+
continue;
|
|
1077
|
+
}
|
|
1078
|
+
if (!options.preferActionable || isActionableNode(ancestor) || !targetIsUsable) {
|
|
1079
|
+
return ancestor;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
return targetIsUsable ? node : nearestUsableDescendant(node, nodes) ?? node;
|
|
1083
|
+
}
|
|
1084
|
+
function ancestorsOf(node, nodes) {
|
|
1085
|
+
const ancestors = [];
|
|
1086
|
+
let parentUid = node.parentUid;
|
|
1087
|
+
while (parentUid) {
|
|
1088
|
+
const parent = nodes.find((candidate) => candidate.uid === parentUid);
|
|
1089
|
+
if (!parent) {
|
|
1090
|
+
break;
|
|
1091
|
+
}
|
|
1092
|
+
ancestors.push(parent);
|
|
1093
|
+
parentUid = parent.parentUid;
|
|
1094
|
+
}
|
|
1095
|
+
return ancestors;
|
|
1096
|
+
}
|
|
1097
|
+
function nearestUsableDescendant(node, nodes) {
|
|
1098
|
+
return nodes
|
|
1099
|
+
.filter((candidate) => candidate.uid.startsWith(`${node.uid}.`) && hasUsableLocator(candidate))
|
|
1100
|
+
.sort((left, right) => left.depth - right.depth)[0];
|
|
1101
|
+
}
|
|
1102
|
+
function scoreInspectableNode(node, options = {}) {
|
|
1103
|
+
if (!node.visible || node.bounds.width <= 0 || node.bounds.height <= 0) {
|
|
1104
|
+
return Number.NEGATIVE_INFINITY;
|
|
1105
|
+
}
|
|
1106
|
+
let score = 0;
|
|
1107
|
+
if (normalizeLocatorToken(node.id))
|
|
1108
|
+
score += 80;
|
|
1109
|
+
if (normalizeLocatorToken(node.label))
|
|
1110
|
+
score += 70;
|
|
1111
|
+
if (normalizeLocatorToken(node.text))
|
|
1112
|
+
score += 42;
|
|
1113
|
+
if (normalizeLocatorToken(node.value))
|
|
1114
|
+
score += 28;
|
|
1115
|
+
if (node.enabled)
|
|
1116
|
+
score += 12;
|
|
1117
|
+
if (isActionableNode(node))
|
|
1118
|
+
score += options.preferActionable ? 72 : 24;
|
|
1119
|
+
if (isFillableNode(node))
|
|
1120
|
+
score += 24;
|
|
1121
|
+
if (isRootNode(node))
|
|
1122
|
+
score -= 160;
|
|
1123
|
+
if (isGenericContainer(node) && !hasUsableLocator(node))
|
|
1124
|
+
score -= 50;
|
|
1125
|
+
if (isDecorativeNode(node))
|
|
1126
|
+
score -= 90;
|
|
1127
|
+
const area = node.bounds.width * node.bounds.height;
|
|
1128
|
+
if (area > 0) {
|
|
1129
|
+
score -= Math.min(24, Math.log10(area) * 3);
|
|
1130
|
+
}
|
|
1131
|
+
return score + Math.min(node.depth, 14);
|
|
1132
|
+
}
|
|
1133
|
+
function uiNodeMatchesSelector(node, selector) {
|
|
1134
|
+
switch (selector.strategy) {
|
|
1135
|
+
case 'accessibility':
|
|
1136
|
+
return matchSelectorValue(node.label, selector) || matchSelectorValue(node.id, selector);
|
|
1137
|
+
case 'id':
|
|
1138
|
+
return matchSelectorValue(node.id, selector);
|
|
1139
|
+
case 'role':
|
|
1140
|
+
return rolesForNode(node).includes(normalizeRole(selector.value)) && matchAccessibleName(node, selector);
|
|
1141
|
+
case 'text':
|
|
1142
|
+
return matchSelectorValue(node.text, selector) || matchSelectorValue(node.label, selector);
|
|
1143
|
+
case 'type':
|
|
1144
|
+
return selector.value.trim().toLowerCase() === 'any' || matchSelectorValue(node.type, selector);
|
|
1145
|
+
case 'coordinates':
|
|
1146
|
+
case 'xpath':
|
|
1147
|
+
return false;
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
function matchAccessibleName(node, selector) {
|
|
1151
|
+
if (selector.name === undefined) {
|
|
1152
|
+
return true;
|
|
1153
|
+
}
|
|
1154
|
+
return [node.label, node.text, node.value, node.id]
|
|
1155
|
+
.some((value) => matchExpected(value, selector.name, selector.exact));
|
|
1156
|
+
}
|
|
1157
|
+
function matchSelectorValue(actual, selector) {
|
|
1158
|
+
return matchExpected(actual, selector.value, selector.exact);
|
|
1159
|
+
}
|
|
1160
|
+
function matchExpected(actual, expected, exact = true) {
|
|
1161
|
+
if (!actual) {
|
|
1162
|
+
return false;
|
|
1163
|
+
}
|
|
1164
|
+
if (expected instanceof RegExp) {
|
|
1165
|
+
expected.lastIndex = 0;
|
|
1166
|
+
const result = expected.test(actual);
|
|
1167
|
+
expected.lastIndex = 0;
|
|
1168
|
+
return result;
|
|
1169
|
+
}
|
|
1170
|
+
return exact === false ? actual.includes(expected) : actual === expected;
|
|
1171
|
+
}
|
|
1172
|
+
function inferUiRole(node, name) {
|
|
1173
|
+
return INSPECTOR_ROLES.find((role) => {
|
|
1174
|
+
if (!rolesForNode(node).includes(role)) {
|
|
1175
|
+
return false;
|
|
1176
|
+
}
|
|
1177
|
+
return name ? matchAccessibleName(node, { strategy: 'role', value: role, name, exact: true }) : true;
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
function rolesForNode(node) {
|
|
1181
|
+
const type = node.type.toLowerCase();
|
|
1182
|
+
const roles = new Set();
|
|
1183
|
+
if (type.includes('button'))
|
|
1184
|
+
roles.add('button');
|
|
1185
|
+
if (type.includes('checkbox'))
|
|
1186
|
+
roles.add('checkbox');
|
|
1187
|
+
if (type.includes('image')) {
|
|
1188
|
+
roles.add('image');
|
|
1189
|
+
roles.add('img');
|
|
1190
|
+
}
|
|
1191
|
+
if (type.includes('link'))
|
|
1192
|
+
roles.add('link');
|
|
1193
|
+
if (type.includes('menuitem'))
|
|
1194
|
+
roles.add('menuitem');
|
|
1195
|
+
if (type.includes('radiobutton') || type.endsWith('.radio') || type.includes('radio'))
|
|
1196
|
+
roles.add('radio');
|
|
1197
|
+
if (type.includes('seekbar') || type.includes('slider'))
|
|
1198
|
+
roles.add('slider');
|
|
1199
|
+
if (type.includes('switch'))
|
|
1200
|
+
roles.add('switch');
|
|
1201
|
+
if (type.includes('tab'))
|
|
1202
|
+
roles.add('tab');
|
|
1203
|
+
if (type.includes('edittext') || type.includes('textfield') || type.includes('securetextfield') || type.includes('searchfield') || type.includes('textinput'))
|
|
1204
|
+
roles.add('textbox');
|
|
1205
|
+
if (type.includes('textview') || type.includes('statictext') || type.includes('label'))
|
|
1206
|
+
roles.add('text');
|
|
1207
|
+
return [...roles];
|
|
1208
|
+
}
|
|
1209
|
+
function isActionableNode(node) {
|
|
1210
|
+
const roles = rolesForNode(node);
|
|
1211
|
+
return roles.some((role) => role !== 'text' && role !== 'image' && role !== 'img')
|
|
1212
|
+
|| node.type.toLowerCase().includes('button');
|
|
1213
|
+
}
|
|
1214
|
+
function isFillableNode(node) {
|
|
1215
|
+
return rolesForNode(node).includes('textbox');
|
|
1216
|
+
}
|
|
1217
|
+
function isRootNode(node) {
|
|
1218
|
+
return node.type.endsWith('.root') || node.type === 'root' || node.uid === '0';
|
|
1219
|
+
}
|
|
1220
|
+
function isGenericContainer(node) {
|
|
1221
|
+
const type = node.type.toLowerCase();
|
|
1222
|
+
return type.includes('viewgroup')
|
|
1223
|
+
|| type.includes('framelayout')
|
|
1224
|
+
|| type.includes('linearlayout')
|
|
1225
|
+
|| type.includes('scrollview')
|
|
1226
|
+
|| type.includes('recyclerview')
|
|
1227
|
+
|| type.endsWith('.view');
|
|
1228
|
+
}
|
|
1229
|
+
function hasUsableLocator(node) {
|
|
1230
|
+
return Boolean(normalizeLocatorToken(node.id)
|
|
1231
|
+
|| normalizeLocatorToken(node.label)
|
|
1232
|
+
|| normalizeLocatorToken(node.text)
|
|
1233
|
+
|| normalizeLocatorToken(node.value));
|
|
1234
|
+
}
|
|
1235
|
+
function isDecorativeNode(node) {
|
|
1236
|
+
if (normalizeLocatorToken(node.id)
|
|
1237
|
+
|| normalizeLocatorToken(node.label)
|
|
1238
|
+
|| normalizeLocatorToken(node.text)
|
|
1239
|
+
|| normalizeLocatorToken(node.value)) {
|
|
1240
|
+
return false;
|
|
1241
|
+
}
|
|
1242
|
+
return Boolean(isDecorativeToken(node.text)
|
|
1243
|
+
|| isDecorativeToken(node.label)
|
|
1244
|
+
|| isDecorativeToken(node.value));
|
|
1245
|
+
}
|
|
1246
|
+
function titleForNode(node) {
|
|
1247
|
+
return normalizeLocatorToken(node.label)
|
|
1248
|
+
?? normalizeLocatorToken(node.text)
|
|
1249
|
+
?? normalizeLocatorToken(node.value)
|
|
1250
|
+
?? shortId(node.id)
|
|
1251
|
+
?? node.type;
|
|
1252
|
+
}
|
|
1253
|
+
function shortId(value) {
|
|
1254
|
+
const id = normalizeLocatorToken(value);
|
|
1255
|
+
if (!id) {
|
|
1256
|
+
return undefined;
|
|
1257
|
+
}
|
|
1258
|
+
return id.split('/').pop() ?? id;
|
|
1259
|
+
}
|
|
1260
|
+
function normalizeLocatorToken(value) {
|
|
1261
|
+
const token = value
|
|
1262
|
+
?.replace(/&#x[0-9a-f]+;?/gi, '')
|
|
1263
|
+
.replace(/&#\d+;?/g, '')
|
|
1264
|
+
.replace(/&/g, '&')
|
|
1265
|
+
.replace(/</g, '<')
|
|
1266
|
+
.replace(/>/g, '>')
|
|
1267
|
+
.replace(/"/g, '"')
|
|
1268
|
+
.trim();
|
|
1269
|
+
if (!token || isDecorativeToken(token)) {
|
|
1270
|
+
return undefined;
|
|
1271
|
+
}
|
|
1272
|
+
return token;
|
|
1273
|
+
}
|
|
1274
|
+
function isDecorativeToken(value) {
|
|
1275
|
+
const token = value?.trim();
|
|
1276
|
+
if (!token) {
|
|
1277
|
+
return false;
|
|
1278
|
+
}
|
|
1279
|
+
return /^&#(?:x[0-9a-f]+|\d+);?$/i.test(token)
|
|
1280
|
+
|| (token.length <= 2 && !/[A-Za-z0-9]/.test(token));
|
|
1281
|
+
}
|
|
1282
|
+
function scoreLocatorCandidate(baseScore, uniqueness, stability, readable) {
|
|
1283
|
+
const uniquenessWeight = 0.45 + clampScore(uniqueness) * 0.55;
|
|
1284
|
+
const stabilityWeight = 0.6 + clampScore(stability) * 0.4;
|
|
1285
|
+
const readabilityWeight = readable ? 1 : 0.92;
|
|
1286
|
+
return roundScore(clampScore(baseScore * uniquenessWeight * stabilityWeight * readabilityWeight));
|
|
1287
|
+
}
|
|
1288
|
+
function scoreStableToken(value, strategy) {
|
|
1289
|
+
let score = 1;
|
|
1290
|
+
if (value.length > 72)
|
|
1291
|
+
score -= 0.1;
|
|
1292
|
+
if (/\b(tmp|temp|debug|sample|placeholder)\b/i.test(value))
|
|
1293
|
+
score -= 0.25;
|
|
1294
|
+
if (/\d{3,}/.test(value))
|
|
1295
|
+
score -= strategy === 'id' ? 0.08 : 0.2;
|
|
1296
|
+
if (/[a-f0-9]{8,}/i.test(value))
|
|
1297
|
+
score -= 0.2;
|
|
1298
|
+
if (strategy === 'text' && value.length <= 2)
|
|
1299
|
+
score -= 0.2;
|
|
1300
|
+
if (strategy === 'type')
|
|
1301
|
+
score = Math.min(score, 0.45);
|
|
1302
|
+
return clampScore(score);
|
|
1303
|
+
}
|
|
1304
|
+
function normalizeRole(role) {
|
|
1305
|
+
return (role === 'img' ? 'img' : role.trim().toLowerCase());
|
|
1306
|
+
}
|
|
1307
|
+
function normalizeRecordingLocator(locator) {
|
|
1308
|
+
return locator.trim().replace(/^device\./, '');
|
|
1309
|
+
}
|
|
1310
|
+
function escapeSingleQuotes(value) {
|
|
1311
|
+
return value.replaceAll('\\', '\\\\').replaceAll("'", "\\'");
|
|
1312
|
+
}
|
|
1313
|
+
function clampScore(value) {
|
|
1314
|
+
if (!Number.isFinite(value)) {
|
|
1315
|
+
return 0;
|
|
1316
|
+
}
|
|
1317
|
+
return Math.max(0, Math.min(1, value));
|
|
1318
|
+
}
|
|
1319
|
+
function roundScore(value) {
|
|
1320
|
+
return Math.round(value * 100) / 100;
|
|
1321
|
+
}
|
|
1322
|
+
function containsPoint(bounds, point) {
|
|
1323
|
+
return point.x >= bounds.x
|
|
1324
|
+
&& point.y >= bounds.y
|
|
1325
|
+
&& point.x <= bounds.x + bounds.width
|
|
1326
|
+
&& point.y <= bounds.y + bounds.height;
|
|
1327
|
+
}
|
|
1328
|
+
async function readAsturLogoDataUri() {
|
|
1329
|
+
const candidates = [
|
|
1330
|
+
fileURLToPath(new URL('../assets/brand/astur-logo-dark.png', import.meta.url)),
|
|
1331
|
+
fileURLToPath(new URL('../assets/brand/astur-logo-light.png', import.meta.url)),
|
|
1332
|
+
resolve(process.cwd(), 'packages/cli/assets/brand/astur-logo-dark.png'),
|
|
1333
|
+
resolve(process.cwd(), 'packages/cli/assets/brand/astur-logo-light.png'),
|
|
1334
|
+
];
|
|
1335
|
+
for (const p of candidates) {
|
|
1336
|
+
try {
|
|
1337
|
+
await access(p, constants.F_OK);
|
|
1338
|
+
const buf = await readFile(p);
|
|
1339
|
+
return `data:image/png;base64,${buf.toString('base64')}`;
|
|
1340
|
+
}
|
|
1341
|
+
catch {
|
|
1342
|
+
// try next
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
return undefined;
|
|
1346
|
+
}
|
|
1347
|
+
function readInspectorVersion() {
|
|
1348
|
+
try {
|
|
1349
|
+
const packageJsonPath = fileURLToPath(new URL('../package.json', import.meta.url));
|
|
1350
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
|
1351
|
+
return packageJson.version ?? 'dev';
|
|
1352
|
+
}
|
|
1353
|
+
catch {
|
|
1354
|
+
return 'dev';
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
function generateTestCode(steps, lang) {
|
|
1358
|
+
if (!steps.length) {
|
|
1359
|
+
return '// No steps recorded yet';
|
|
1360
|
+
}
|
|
1361
|
+
const importLine = lang === 'typescript'
|
|
1362
|
+
? `import { test, expect } from '@astur-mobile/test';`
|
|
1363
|
+
: `const { test, expect } = require('@astur-mobile/test');`;
|
|
1364
|
+
const lines = steps.map((s) => generateRecordedStepCode(s));
|
|
1365
|
+
return `${importLine}\n\ntest('recorded flow', async ({ device }) => {\n${lines.join('\n')}\n});\n`;
|
|
1366
|
+
}
|
|
1367
|
+
function generateRecordedStepCode(step) {
|
|
1368
|
+
const locator = normalizeRecordingLocator(step.locator);
|
|
1369
|
+
if (step.action === 'swipe' && step.gesture) {
|
|
1370
|
+
return ` await device.swipe(${JSON.stringify(step.gesture)});`;
|
|
1371
|
+
}
|
|
1372
|
+
if (step.action === 'tapPoint' && step.point) {
|
|
1373
|
+
return ` await device.tap(${JSON.stringify(step.point)});`;
|
|
1374
|
+
}
|
|
1375
|
+
if (step.action === 'fill') {
|
|
1376
|
+
return ` await device.${locator}.fill(${JSON.stringify(step.value ?? '')});`;
|
|
1377
|
+
}
|
|
1378
|
+
if (step.action === 'expect') {
|
|
1379
|
+
const actual = `device.${locator}`;
|
|
1380
|
+
switch (step.assertion ?? 'visible') {
|
|
1381
|
+
case 'text':
|
|
1382
|
+
return ` await expect(${actual}).toHaveText(${JSON.stringify(step.value ?? '')});`;
|
|
1383
|
+
case 'containsText':
|
|
1384
|
+
return ` await expect(${actual}).toContainText(${JSON.stringify(step.value ?? '')});`;
|
|
1385
|
+
case 'value':
|
|
1386
|
+
return ` await expect(${actual}).toHaveValue(${JSON.stringify(step.value ?? '')});`;
|
|
1387
|
+
case 'label':
|
|
1388
|
+
return ` await expect(${actual}).toHaveLabel(${JSON.stringify(step.value ?? '')});`;
|
|
1389
|
+
case 'type':
|
|
1390
|
+
return ` await expect(${actual}).toHaveType(${JSON.stringify(step.value ?? '')});`;
|
|
1391
|
+
case 'visible':
|
|
1392
|
+
default:
|
|
1393
|
+
return ` await expect(${actual}).toBeVisible();`;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
return ` await device.${locator}.tap();`;
|
|
1397
|
+
}
|
|
1398
|
+
const BASE_INSPECTOR_DEVICE_ACTIONS = [
|
|
1399
|
+
{ id: 'refresh', label: 'Refresh Screen', group: 'device' },
|
|
1400
|
+
{ id: 'tree.refresh', label: 'Refresh UI Tree', group: 'device' },
|
|
1401
|
+
{ id: 'orientation.portrait', label: 'Portrait', group: 'device' },
|
|
1402
|
+
{ id: 'orientation.landscape', label: 'Landscape', group: 'device' },
|
|
1403
|
+
{ id: 'keyboard.dismiss', label: 'Dismiss Keyboard', group: 'device' },
|
|
1404
|
+
];
|
|
1405
|
+
const LOCK_INSPECTOR_DEVICE_ACTIONS = [
|
|
1406
|
+
{ id: 'device.lock', label: 'Lock', group: 'device' },
|
|
1407
|
+
{ id: 'device.unlock', label: 'Unlock', group: 'device' },
|
|
1408
|
+
];
|
|
1409
|
+
const ANDROID_NAVIGATION_ACTIONS = [
|
|
1410
|
+
{ id: 'navigation.back', label: 'Back', group: 'navigation' },
|
|
1411
|
+
{ id: 'navigation.home', label: 'Home', group: 'navigation' },
|
|
1412
|
+
{ id: 'navigation.recents', label: 'Recents', group: 'navigation' },
|
|
1413
|
+
];
|
|
1414
|
+
const ALL_INSPECTOR_DEVICE_ACTIONS = [
|
|
1415
|
+
...BASE_INSPECTOR_DEVICE_ACTIONS,
|
|
1416
|
+
...LOCK_INSPECTOR_DEVICE_ACTIONS,
|
|
1417
|
+
...ANDROID_NAVIGATION_ACTIONS,
|
|
1418
|
+
];
|
|
1419
|
+
function getInspectorDeviceActionDefinitions(device) {
|
|
1420
|
+
const actions = [...BASE_INSPECTOR_DEVICE_ACTIONS];
|
|
1421
|
+
if (device.platform === 'android' || (device.platform === 'ios' && device.kind === 'simulator')) {
|
|
1422
|
+
actions.push(...LOCK_INSPECTOR_DEVICE_ACTIONS);
|
|
1423
|
+
}
|
|
1424
|
+
if (device.platform === 'android') {
|
|
1425
|
+
actions.push(...ANDROID_NAVIGATION_ACTIONS);
|
|
1426
|
+
}
|
|
1427
|
+
return actions;
|
|
1428
|
+
}
|
|
1429
|
+
function inspectorDeviceActionLabel(action) {
|
|
1430
|
+
return ALL_INSPECTOR_DEVICE_ACTIONS.find((candidate) => candidate.id === action)?.label ?? action;
|
|
1431
|
+
}
|
|
1432
|
+
function formatActionError(error) {
|
|
1433
|
+
return error instanceof Error ? error.message : String(error);
|
|
1434
|
+
}
|
|
1435
|
+
function renderInspectorDeviceActionMenu(device) {
|
|
1436
|
+
const actions = getInspectorDeviceActionDefinitions(device);
|
|
1437
|
+
const groups = [
|
|
1438
|
+
{ id: 'device', label: 'Device' },
|
|
1439
|
+
{ id: 'navigation', label: 'Navigation' },
|
|
1440
|
+
];
|
|
1441
|
+
return groups.map((group) => {
|
|
1442
|
+
const groupActions = actions.filter((action) => action.group === group.id);
|
|
1443
|
+
if (!groupActions.length) {
|
|
1444
|
+
return '';
|
|
1445
|
+
}
|
|
1446
|
+
const items = groupActions.map((action) => (`<button type="button" class="device-action-btn icon" data-action="${action.id}" data-label="${escHtml(action.label)}" title="${escHtml(action.label)}" aria-label="${escHtml(action.label)}">${inspectorDeviceActionIcon(action.id)}</button>`)).join('');
|
|
1447
|
+
return `<div class="device-menu-section"><div class="device-menu-label">${group.label}</div><div class="device-menu-actions">${items}</div></div>`;
|
|
1448
|
+
}).join('');
|
|
1449
|
+
}
|
|
1450
|
+
function inspectorDeviceActionIcon(action) {
|
|
1451
|
+
switch (action) {
|
|
1452
|
+
case 'refresh':
|
|
1453
|
+
return iconSvg('<path d="M21 12a9 9 0 0 1-9 9 8.7 8.7 0 0 1-6.2-2.6"/><path d="M3 12a9 9 0 0 1 15.2-6.5"/><path d="M18 2v4h-4"/><path d="M6 22v-4h4"/>');
|
|
1454
|
+
case 'tree.refresh':
|
|
1455
|
+
return iconSvg('<path d="M21 12a9 9 0 0 1-9 9 8.7 8.7 0 0 1-6.2-2.6"/><path d="M3 12a9 9 0 0 1 15.2-6.5"/><path d="M18 2v4h-4"/><path d="M6 22v-4h4"/><path d="M12 7v4"/><path d="M9 11h6"/><path d="M8 16h8"/>');
|
|
1456
|
+
case 'orientation.portrait':
|
|
1457
|
+
return iconSvg('<rect x="7" y="2" width="10" height="20" rx="2"/><path d="M11 18h2"/>');
|
|
1458
|
+
case 'orientation.landscape':
|
|
1459
|
+
return iconSvg('<rect x="2" y="7" width="20" height="10" rx="2"/><path d="M18 11v2"/>');
|
|
1460
|
+
case 'keyboard.dismiss':
|
|
1461
|
+
return iconSvg('<rect x="3" y="5" width="18" height="12" rx="2"/><path d="M7 9h.01M11 9h.01M15 9h.01M19 9h.01M7 13h10"/><path d="m8 21 4-4 4 4"/>');
|
|
1462
|
+
case 'device.lock':
|
|
1463
|
+
return iconSvg('<rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/>');
|
|
1464
|
+
case 'device.unlock':
|
|
1465
|
+
return iconSvg('<rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 7.7-1.5"/>');
|
|
1466
|
+
case 'navigation.back':
|
|
1467
|
+
return iconSvg('<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>');
|
|
1468
|
+
case 'navigation.home':
|
|
1469
|
+
return iconSvg('<path d="m3 10 9-7 9 7"/><path d="M5 10v10h14V10"/><path d="M9 20v-6h6v6"/>');
|
|
1470
|
+
case 'navigation.recents':
|
|
1471
|
+
return iconSvg('<rect x="4" y="5" width="14" height="14" rx="2"/><path d="M8 3h12v12"/>');
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
function iconSvg(paths) {
|
|
1475
|
+
return `<svg viewBox="0 0 24 24" aria-hidden="true">${paths}</svg>`;
|
|
1476
|
+
}
|
|
1477
|
+
function toBootstrapDevice(device) {
|
|
1478
|
+
return {
|
|
1479
|
+
id: device.id,
|
|
1480
|
+
name: device.name,
|
|
1481
|
+
platform: device.platform,
|
|
1482
|
+
kind: device.kind
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1485
|
+
function inspectorAppActionLabel(action) {
|
|
1486
|
+
switch (action) {
|
|
1487
|
+
case 'launch':
|
|
1488
|
+
return 'Launch app';
|
|
1489
|
+
case 'clearData':
|
|
1490
|
+
return 'Clear app data';
|
|
1491
|
+
case 'clearCache':
|
|
1492
|
+
return 'Clear app cache';
|
|
1493
|
+
case 'grantPermission':
|
|
1494
|
+
return 'Grant permission';
|
|
1495
|
+
case 'revokePermission':
|
|
1496
|
+
return 'Revoke permission';
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
export const __testing = {
|
|
1500
|
+
buildInspectorHtml,
|
|
1501
|
+
generateTestCode,
|
|
1502
|
+
generateRecordedStepCode,
|
|
1503
|
+
getInspectorDeviceActionDefinitions,
|
|
1504
|
+
inspectorDeviceActionLabel,
|
|
1505
|
+
findUiNodeAtPoint,
|
|
1506
|
+
normalizeRecordingLocator,
|
|
1507
|
+
suggestLocatorsForNode,
|
|
1508
|
+
};
|
|
1509
|
+
// ─── Inspector HTML app ───────────────────────────────────────────────────────
|
|
1510
|
+
function buildInspectorHtml(device) {
|
|
1511
|
+
const title = `Astur Inspector — ${device.name}`;
|
|
1512
|
+
return `<!DOCTYPE html>
|
|
1513
|
+
<html lang="en">
|
|
1514
|
+
<head>
|
|
1515
|
+
<meta charset="UTF-8"/>
|
|
1516
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
1517
|
+
<title>${escHtml(title)}</title>
|
|
1518
|
+
<style>
|
|
1519
|
+
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
|
1520
|
+
:root{
|
|
1521
|
+
--bg:#0d1117;--surface:#161b22;--surface2:#21262d;--border:#30363d;
|
|
1522
|
+
--text:#e6edf3;--text-dim:#8b949e;--text-muted:#484f58;
|
|
1523
|
+
--accent:#1f6feb;--accent-hover:#388bfd;--green:#3fb950;--red:#f85149;
|
|
1524
|
+
--yellow:#d29922;--purple:#8b5cf6;--radius:6px;--font:system-ui,sans-serif;
|
|
1525
|
+
--mono:"SFMono-Regular",Consolas,monospace;
|
|
1526
|
+
}
|
|
1527
|
+
html,body{height:100%;overflow:hidden;background:var(--bg);color:var(--text);font:13px/1.5 var(--font)}
|
|
1528
|
+
/* Layout */
|
|
1529
|
+
#app{display:grid;grid-template-rows:48px 1fr;height:100vh}
|
|
1530
|
+
#topbar{display:flex;align-items:center;gap:10px;padding:0 14px;border-bottom:1px solid var(--border);background:var(--surface);flex-shrink:0}
|
|
1531
|
+
#main{display:grid;grid-template-columns:300px 10px minmax(0,1fr) 10px 340px;overflow:hidden}
|
|
1532
|
+
/* Top bar */
|
|
1533
|
+
#logo{height:22px;object-fit:contain;flex-shrink:0}
|
|
1534
|
+
#logo-text{font-weight:700;font-size:14px;color:var(--text);margin-right:4px}
|
|
1535
|
+
#version-chip{padding:2px 8px;border:1px solid var(--border);border-radius:999px;font-size:11px;line-height:1;color:var(--text-muted);background:rgba(255,255,255,.03)}
|
|
1536
|
+
.tb-sep{width:1px;height:24px;background:var(--border);margin:0 4px}
|
|
1537
|
+
#device-switcher{position:relative;display:flex;align-items:center}
|
|
1538
|
+
#device-chip{display:flex;align-items:center;gap:6px;padding:4px 10px;background:var(--surface2);border-radius:var(--radius);font-size:12px;color:var(--text-dim)}
|
|
1539
|
+
#device-chip{cursor:pointer;border:1px solid transparent}
|
|
1540
|
+
#device-chip:hover,#device-switcher.open #device-chip{border-color:var(--accent);color:var(--accent-hover)}
|
|
1541
|
+
#device-list-menu{display:none;position:absolute;top:calc(100% + 8px);left:0;width:min(380px,calc(100vw - 28px));max-height:min(420px,calc(100vh - 70px));overflow:auto;padding:10px;border:1px solid var(--border);border-radius:10px;background:var(--surface);box-shadow:0 18px 40px rgba(0,0,0,.35);z-index:35}
|
|
1542
|
+
#device-switcher.open #device-list-menu{display:flex;flex-direction:column;gap:8px}
|
|
1543
|
+
#live-badge{padding:3px 8px;border-radius:10px;font-size:11px;font-weight:600;background:#1a3d1a;color:var(--green);border:1px solid #2e6b2e;display:flex;align-items:center;gap:4px}
|
|
1544
|
+
#live-badge.connecting{background:#3a2a10;color:var(--yellow);border-color:#6a4e20}
|
|
1545
|
+
#live-badge::before{content:'';width:7px;height:7px;border-radius:50%;background:currentColor;flex-shrink:0}
|
|
1546
|
+
#device-controls{position:relative;display:flex;align-items:center;gap:8px}
|
|
1547
|
+
#device-menu-btn{padding:6px 12px;border-radius:var(--radius);border:1px solid var(--border);font-size:12px;font-weight:600;cursor:pointer;background:var(--surface2);color:var(--text);transition:all .15s}
|
|
1548
|
+
#device-menu-btn:hover,#device-controls.open #device-menu-btn{border-color:var(--accent);color:var(--accent-hover)}
|
|
1549
|
+
#device-status{flex:1;min-width:0;font-size:11px;color:var(--text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:center;padding:0 12px}
|
|
1550
|
+
#device-status[data-tone="pending"]{color:var(--yellow)}
|
|
1551
|
+
#device-status[data-tone="success"]{color:var(--green)}
|
|
1552
|
+
#device-status[data-tone="error"]{color:var(--red)}
|
|
1553
|
+
#device-menu{display:none;position:absolute;top:calc(100% + 8px);right:0;width:min(440px,calc(100vw - 28px));max-height:min(620px,calc(100vh - 70px));overflow:auto;padding:10px;border:1px solid var(--border);border-radius:10px;background:var(--surface);box-shadow:0 18px 40px rgba(0,0,0,.35);z-index:30}
|
|
1554
|
+
#device-controls.open #device-menu{display:flex;flex-direction:column;gap:10px}
|
|
1555
|
+
.device-menu-section{display:flex;flex-direction:column;gap:6px}
|
|
1556
|
+
.device-menu-label{font-size:10px;font-weight:700;letter-spacing:.06em;color:var(--text-muted);text-transform:uppercase}
|
|
1557
|
+
.device-menu-actions{display:flex;flex-wrap:wrap;gap:6px}
|
|
1558
|
+
.device-action-btn{padding:5px 10px;border-radius:var(--radius);border:1px solid var(--border);font-size:11px;font-weight:600;cursor:pointer;background:var(--surface2);color:var(--text)}
|
|
1559
|
+
.device-action-btn:hover{border-color:var(--accent);color:var(--accent-hover)}
|
|
1560
|
+
.device-action-btn.icon{width:32px;height:30px;padding:0;display:inline-flex;align-items:center;justify-content:center}
|
|
1561
|
+
.device-action-btn.icon svg{width:15px;height:15px;stroke:currentColor;stroke-width:2;fill:none;stroke-linecap:round;stroke-linejoin:round}
|
|
1562
|
+
.device-row{display:flex;gap:6px;align-items:center}
|
|
1563
|
+
.device-row.upload-zone.drag-active .device-input,.device-row.upload-zone.drag-active .device-action-btn{border-color:var(--accent);background:rgba(31,111,235,.08)}
|
|
1564
|
+
.device-help{font-size:10px;line-height:1.45;color:var(--text-muted);padding:0 2px}
|
|
1565
|
+
#app-upload-selection{min-height:14px;color:var(--text-dim)}
|
|
1566
|
+
.device-input{min-width:0;flex:1;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius);padding:6px 8px;font:11px var(--font);color:var(--text);outline:none}
|
|
1567
|
+
.device-input:focus{border-color:var(--accent)}
|
|
1568
|
+
.device-list{display:flex;flex-direction:column;gap:4px;max-height:150px;overflow:auto}
|
|
1569
|
+
.device-choice{display:flex;justify-content:space-between;gap:8px;width:100%;text-align:left;padding:7px 8px;border-radius:var(--radius);border:1px solid var(--border);background:var(--surface2);color:var(--text);cursor:pointer;font-size:11px}
|
|
1570
|
+
.device-choice.active{border-color:var(--accent);color:var(--accent-hover)}
|
|
1571
|
+
.device-choice small{color:var(--text-muted)}
|
|
1572
|
+
#record-btn{padding:6px 14px;border-radius:var(--radius);border:none;font-size:12px;font-weight:600;cursor:pointer;background:var(--surface2);color:var(--text);border:1px solid var(--border);display:flex;align-items:center;gap:6px;transition:all .15s}
|
|
1573
|
+
#record-btn.active{background:#3a0a0a;color:var(--red);border-color:#6e1515}
|
|
1574
|
+
#record-btn::before{content:'';width:8px;height:8px;border-radius:50%;background:currentColor;flex-shrink:0}
|
|
1575
|
+
#export-btn{padding:6px 14px;border-radius:var(--radius);border:1px solid var(--accent);font-size:12px;font-weight:600;cursor:pointer;background:var(--accent);color:#fff;transition:all .15s}
|
|
1576
|
+
#export-btn:hover{background:var(--accent-hover)}
|
|
1577
|
+
/* Left panel */
|
|
1578
|
+
#left-panel{display:flex;flex-direction:column;overflow:hidden;background:var(--surface);min-width:0}
|
|
1579
|
+
.panel-header{padding:10px 14px;font-size:11px;font-weight:700;letter-spacing:.05em;color:var(--text-dim);text-transform:uppercase;border-bottom:1px solid var(--border);flex-shrink:0}
|
|
1580
|
+
#inspector-section{display:flex;flex-direction:column;overflow:hidden;flex:1}
|
|
1581
|
+
#inspector-hint{padding:12px 14px;font-size:12px;color:var(--text-dim);border-bottom:1px solid var(--border);flex-shrink:0}
|
|
1582
|
+
#action-section{display:flex;flex-direction:column;gap:8px;padding:10px 14px;border-bottom:1px solid var(--border);flex-shrink:0}
|
|
1583
|
+
.action-mode-row{display:grid;grid-template-columns:1fr 1fr;gap:6px}
|
|
1584
|
+
.mode-btn,.element-action-btn{min-height:30px;padding:5px 10px;border-radius:var(--radius);border:1px solid var(--border);font-size:11px;font-weight:700;cursor:pointer;background:var(--surface2);color:var(--text);transition:border-color .15s,color .15s,background .15s}
|
|
1585
|
+
.mode-btn:hover,.element-action-btn:hover{border-color:var(--accent);color:var(--accent-hover)}
|
|
1586
|
+
.mode-btn.active{border-color:var(--accent);color:var(--accent-hover);background:rgba(31,111,235,.12)}
|
|
1587
|
+
.element-action-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:6px;align-items:center}
|
|
1588
|
+
.element-action-input{min-width:0;height:30px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius);padding:5px 8px;font:11px var(--font);color:var(--text);outline:none}
|
|
1589
|
+
.element-action-input:focus{border-color:var(--accent)}
|
|
1590
|
+
.mode-btn:disabled,.element-action-btn:disabled,.element-action-input:disabled{opacity:.45;cursor:not-allowed;color:var(--text-muted)}
|
|
1591
|
+
.mode-btn:disabled:hover,.element-action-btn:disabled:hover{border-color:var(--border);color:var(--text-muted)}
|
|
1592
|
+
#locator-section{padding:12px 14px;flex-shrink:0}
|
|
1593
|
+
#best-locator-label{font-size:10px;font-weight:700;letter-spacing:.06em;color:var(--text-muted);text-transform:uppercase;margin-bottom:6px}
|
|
1594
|
+
#best-locator-code{display:flex;align-items:flex-start;gap:8px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius);padding:8px 10px;font:12px/1.4 var(--mono);color:var(--accent-hover);word-break:break-all}
|
|
1595
|
+
#alternatives-label{font-size:10px;font-weight:700;letter-spacing:.06em;color:var(--text-muted);text-transform:uppercase;margin:10px 0 6px}
|
|
1596
|
+
#alternatives-list{display:flex;flex-direction:column;gap:3px}
|
|
1597
|
+
.alt-item{display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:var(--radius);cursor:pointer;border:1px solid transparent}
|
|
1598
|
+
.alt-item:hover{background:var(--surface2);border-color:var(--border)}
|
|
1599
|
+
.locator-code{flex:1;min-width:0}
|
|
1600
|
+
.alt-code{flex:1;font:11px/1.3 var(--mono);color:var(--text-dim);word-break:break-all}
|
|
1601
|
+
.alt-score{font-size:10px;padding:1px 5px;border-radius:3px;font-weight:700;background:var(--surface2);color:var(--green);flex-shrink:0}
|
|
1602
|
+
.copy-btn{position:relative;flex-shrink:0;width:28px;height:28px;border:1px solid var(--border);border-radius:var(--radius);background:transparent;color:var(--text-muted);cursor:pointer;transition:border-color .15s,color .15s,background .15s}
|
|
1603
|
+
.copy-btn:hover{border-color:var(--accent);color:var(--accent-hover);background:rgba(31,111,235,.08)}
|
|
1604
|
+
.copy-btn.copied{border-color:var(--green);color:var(--green);background:rgba(63,185,80,.08)}
|
|
1605
|
+
.copy-btn::before,.copy-btn::after{content:'';position:absolute;border:1.5px solid currentColor;border-radius:2px}
|
|
1606
|
+
.copy-btn::before{top:7px;left:9px;width:10px;height:12px;background:var(--surface)}
|
|
1607
|
+
.copy-btn::after{top:10px;left:6px;width:10px;height:12px}
|
|
1608
|
+
#details-section{flex:1;overflow-y:auto;border-top:1px solid var(--border)}
|
|
1609
|
+
#details-table{width:100%;border-collapse:collapse;font-size:12px}
|
|
1610
|
+
#details-table td{padding:5px 14px;border-bottom:1px solid var(--border)}
|
|
1611
|
+
#details-table td:first-child{color:var(--text-muted);width:40%;font-size:11px}
|
|
1612
|
+
#details-table td:last-child{color:var(--text);font:11px/1.4 var(--mono);word-break:break-all}
|
|
1613
|
+
#inspector-footer{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;padding:10px 14px;border-top:1px solid var(--border);flex-shrink:0}
|
|
1614
|
+
#legal-note{font-size:10px;color:var(--text-muted);line-height:1.4}
|
|
1615
|
+
/* Center mirror */
|
|
1616
|
+
#center-panel{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:12px 18px 16px;overflow:hidden;background:var(--bg)}
|
|
1617
|
+
#phone-shell{position:relative;background:transparent;border:none;padding:0;box-shadow:none;flex-shrink:0}
|
|
1618
|
+
#phone-notch{display:none}
|
|
1619
|
+
#mirror-stage{position:relative;cursor:crosshair;overflow:hidden;border-radius:28px;background:#0a0a0f;box-shadow:0 24px 64px rgba(0,0,0,.48),0 0 0 1px rgba(255,255,255,.08);width:360px;height:720px;max-width:calc(100vw - 420px);max-height:calc(100vh - 96px)}
|
|
1620
|
+
#mirror-stage[data-mode="interact"]{cursor:pointer}
|
|
1621
|
+
#mirror-stage.dragging{cursor:grabbing}
|
|
1622
|
+
#mirror-img{display:block;max-width:100%;user-select:none;pointer-events:none;border-radius:inherit}
|
|
1623
|
+
#mirror-img.placeholder{opacity:.15}
|
|
1624
|
+
#highlight-overlay{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none}
|
|
1625
|
+
.el-highlight{position:absolute;border:2px solid var(--purple);background:rgba(139,92,246,.12);border-radius:2px;transition:all .1s}
|
|
1626
|
+
.el-label{position:absolute;top:-18px;left:0;background:var(--purple);color:#fff;font:10px/18px var(--mono);padding:0 5px;border-radius:3px;white-space:nowrap}
|
|
1627
|
+
#mirror-status{margin-top:12px;font-size:11px;color:var(--text-muted);text-align:center}
|
|
1628
|
+
#busy-overlay{position:absolute;inset:0;display:none;align-items:center;justify-content:center;flex-direction:column;gap:10px;background:rgba(13,17,23,.72);z-index:5;text-align:center;padding:24px}
|
|
1629
|
+
#busy-overlay.active{display:flex}
|
|
1630
|
+
.spinner{width:34px;height:34px;border-radius:50%;border:3px solid rgba(255,255,255,.18);border-top-color:var(--accent-hover);animation:spin .8s linear infinite}
|
|
1631
|
+
.busy-label{font-size:12px;font-weight:600;color:var(--text)}
|
|
1632
|
+
.busy-subtitle{font-size:11px;color:var(--text-muted);max-width:220px}
|
|
1633
|
+
@keyframes spin{to{transform:rotate(360deg)}}
|
|
1634
|
+
#left-column-splitter,#right-column-splitter{position:relative;cursor:col-resize;background:var(--surface);border-left:1px solid var(--border);border-right:1px solid var(--border)}
|
|
1635
|
+
#left-column-splitter::before,#right-column-splitter::before{content:'';position:absolute;top:50%;left:50%;width:4px;height:44px;border-radius:999px;background:var(--border);transform:translate(-50%,-50%)}
|
|
1636
|
+
/* Right panel */
|
|
1637
|
+
#right-panel{display:grid;grid-template-rows:minmax(240px,1.2fr) 10px minmax(180px,.8fr);overflow:hidden;background:var(--surface);min-width:0}
|
|
1638
|
+
#right-splitter{position:relative;cursor:row-resize;background:var(--surface);border-top:1px solid var(--border);border-bottom:1px solid var(--border)}
|
|
1639
|
+
#right-splitter::before{content:'';position:absolute;top:50%;left:50%;width:44px;height:4px;border-radius:999px;background:var(--border);transform:translate(-50%,-50%)}
|
|
1640
|
+
/* Tree panel */
|
|
1641
|
+
#tree-panel{display:flex;flex-direction:column;overflow:hidden;min-height:0}
|
|
1642
|
+
#tree-search-row{padding:8px 10px;border-bottom:1px solid var(--border);flex-shrink:0;display:flex;gap:6px}
|
|
1643
|
+
#tree-search{flex:1;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius);padding:5px 8px;font:12px var(--font);color:var(--text);outline:none}
|
|
1644
|
+
#tree-search:focus{border-color:var(--accent)}
|
|
1645
|
+
#tree-list{flex:1;overflow:auto;padding:4px 0}
|
|
1646
|
+
.tree-empty{padding:16px 12px;font-size:12px;color:var(--text-muted);text-align:center}
|
|
1647
|
+
.tree-node{display:flex;align-items:center;gap:4px;padding:2px 8px;cursor:pointer;border-left:2px solid transparent;transition:background .1s;min-width:100%;width:max-content}
|
|
1648
|
+
.tree-node:hover{background:var(--surface2)}
|
|
1649
|
+
.tree-node.selected{background:rgba(31,111,235,.15);border-left-color:var(--accent)}
|
|
1650
|
+
.tree-node.hidden{opacity:.35}
|
|
1651
|
+
.tree-expander{width:14px;flex-shrink:0;font-size:10px;color:var(--text-muted);cursor:pointer;user-select:none}
|
|
1652
|
+
.tree-type{font:10px/1 var(--mono);color:var(--text-muted);flex-shrink:0;white-space:nowrap}
|
|
1653
|
+
.tree-title{font-size:11px;color:var(--text-dim);flex:0 0 auto;white-space:nowrap}
|
|
1654
|
+
/* Code panel */
|
|
1655
|
+
#code-panel{display:flex;flex-direction:column;overflow:hidden;min-height:0}
|
|
1656
|
+
#code-tabs{display:flex;border-bottom:1px solid var(--border);flex-shrink:0}
|
|
1657
|
+
.code-tab{padding:7px 14px;font-size:12px;font-weight:600;cursor:pointer;color:var(--text-muted);border-bottom:2px solid transparent;transition:all .1s}
|
|
1658
|
+
.code-tab:hover{color:var(--text)}
|
|
1659
|
+
.code-tab.active{color:var(--accent-hover);border-bottom-color:var(--accent-hover)}
|
|
1660
|
+
#code-view{flex:1;display:flex;flex-direction:column;overflow:hidden}
|
|
1661
|
+
#code-lang-tabs{display:flex;gap:0;border-bottom:1px solid var(--border);flex-shrink:0;align-items:center;padding-right:8px}
|
|
1662
|
+
#code-script-copy-btn{margin-left:auto}
|
|
1663
|
+
#code-block{flex:1;overflow:auto;padding:10px 12px;font:11px/1.6 var(--mono);color:#c9d1d9;background:var(--bg);white-space:pre;tab-size:2}
|
|
1664
|
+
#steps-view{flex:1;display:flex;flex-direction:column;overflow:hidden}
|
|
1665
|
+
#steps-toolbar{display:flex;gap:6px;padding:8px 10px;border-bottom:1px solid var(--border);flex-shrink:0}
|
|
1666
|
+
.step-btn{padding:4px 10px;font-size:11px;font-weight:600;border-radius:var(--radius);border:1px solid var(--border);background:var(--surface2);color:var(--text);cursor:pointer}
|
|
1667
|
+
.step-btn:hover{border-color:var(--accent);color:var(--accent-hover)}
|
|
1668
|
+
.step-btn:disabled{opacity:.45;cursor:not-allowed;color:var(--text-muted)}
|
|
1669
|
+
.step-btn:disabled:hover{border-color:var(--border);color:var(--text-muted)}
|
|
1670
|
+
#clear-btn{margin-left:auto;color:var(--red);border-color:#4a1414}
|
|
1671
|
+
#steps-table-wrap{flex:1;overflow-y:auto}
|
|
1672
|
+
#steps-table{width:100%;border-collapse:collapse;font-size:11px}
|
|
1673
|
+
#steps-table th{padding:5px 10px;text-align:left;color:var(--text-muted);font-size:10px;text-transform:uppercase;letter-spacing:.05em;border-bottom:1px solid var(--border);background:var(--surface)}
|
|
1674
|
+
#steps-table td{padding:5px 10px;border-bottom:1px solid var(--border);font:11px/1.4 var(--mono)}
|
|
1675
|
+
#steps-table td:first-child{color:var(--text-muted);width:30px}
|
|
1676
|
+
#steps-table td:nth-child(2){color:var(--green)}
|
|
1677
|
+
#steps-table td:nth-child(3){color:var(--text-dim);word-break:break-all}
|
|
1678
|
+
#steps-table td:last-child{color:var(--yellow)}
|
|
1679
|
+
#step-composer{display:none;padding:10px;border-bottom:1px solid var(--border);background:var(--surface)}
|
|
1680
|
+
#step-composer.active{display:block}
|
|
1681
|
+
.composer-grid{display:grid;grid-template-columns:1fr;gap:8px}
|
|
1682
|
+
.composer-row{display:flex;gap:6px}
|
|
1683
|
+
.composer-input,.composer-select{min-width:0;flex:1;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius);padding:6px 8px;font:11px var(--font);color:var(--text)}
|
|
1684
|
+
.composer-actions{display:flex;justify-content:flex-end;gap:6px}
|
|
1685
|
+
/* Scrollbars */
|
|
1686
|
+
::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
|
|
1687
|
+
</style>
|
|
1688
|
+
</head>
|
|
1689
|
+
<body>
|
|
1690
|
+
<div id="app">
|
|
1691
|
+
<!-- Top bar -->
|
|
1692
|
+
<header id="topbar">
|
|
1693
|
+
<img id="logo" src="" alt="" onerror="this.style.display='none'"/>
|
|
1694
|
+
<span id="logo-text">Inspector</span>
|
|
1695
|
+
<div class="tb-sep"></div>
|
|
1696
|
+
<div id="device-switcher">
|
|
1697
|
+
<button id="device-chip" type="button" title="Switch device" aria-haspopup="true" aria-expanded="false">
|
|
1698
|
+
<span id="platform-icon">📱</span>
|
|
1699
|
+
<span id="device-name">${escHtml(device.name)}</span>
|
|
1700
|
+
</button>
|
|
1701
|
+
<div id="device-list-menu">
|
|
1702
|
+
<div class="device-menu-label">Devices</div>
|
|
1703
|
+
<div id="device-list" class="device-list"></div>
|
|
1704
|
+
</div>
|
|
1705
|
+
</div>
|
|
1706
|
+
<div id="live-badge" class="connecting">Connecting…</div>
|
|
1707
|
+
<span id="device-status" aria-live="polite"></span>
|
|
1708
|
+
<div id="device-controls">
|
|
1709
|
+
<button id="device-menu-btn" type="button" aria-haspopup="true" aria-expanded="false">Controls</button>
|
|
1710
|
+
<div id="device-menu">
|
|
1711
|
+
<div class="device-menu-section">
|
|
1712
|
+
<div class="device-menu-label">App</div>
|
|
1713
|
+
<div class="device-row">
|
|
1714
|
+
<input id="app-identifier-input" class="device-input" placeholder="package or bundle id"/>
|
|
1715
|
+
<button type="button" class="device-action-btn" id="launch-app-btn">Launch</button>
|
|
1716
|
+
</div>
|
|
1717
|
+
<div id="app-upload-row" class="device-row upload-zone">
|
|
1718
|
+
<input id="app-upload-input" class="device-input" type="file" accept=".apk,.ipa,.app"/>
|
|
1719
|
+
<button type="button" class="device-action-btn" id="install-app-btn">Install</button>
|
|
1720
|
+
</div>
|
|
1721
|
+
<div id="app-upload-hint" class="device-help">Android installs use .apk. iOS Simulator uses a simulator-built .app from Xcode. Real iPhone/iPad installs use a signed .ipa.</div>
|
|
1722
|
+
<div id="app-upload-selection" class="device-help"></div>
|
|
1723
|
+
<div class="device-row">
|
|
1724
|
+
<input id="permission-input" class="device-input" placeholder="permission, e.g. camera"/>
|
|
1725
|
+
<button type="button" class="device-action-btn" id="grant-permission-btn">Grant</button>
|
|
1726
|
+
<button type="button" class="device-action-btn" id="revoke-permission-btn">Revoke</button>
|
|
1727
|
+
</div>
|
|
1728
|
+
<div class="device-row">
|
|
1729
|
+
<button type="button" class="device-action-btn" id="clear-data-btn">Clear Data</button>
|
|
1730
|
+
<button type="button" class="device-action-btn" id="clear-cache-btn">Clear Cache</button>
|
|
1731
|
+
</div>
|
|
1732
|
+
</div>
|
|
1733
|
+
${renderInspectorDeviceActionMenu(device)}
|
|
1734
|
+
</div>
|
|
1735
|
+
</div>
|
|
1736
|
+
<button id="record-btn" title="Toggle recording">Record</button>
|
|
1737
|
+
<button id="export-btn" title="Export test code (Ctrl/Cmd+S)">Export Code</button>
|
|
1738
|
+
</header>
|
|
1739
|
+
|
|
1740
|
+
<!-- Main 3-column layout -->
|
|
1741
|
+
<div id="main">
|
|
1742
|
+
<!-- Left: Inspector panel -->
|
|
1743
|
+
<div id="left-panel">
|
|
1744
|
+
<div class="panel-header">Inspector</div>
|
|
1745
|
+
<div id="inspector-section">
|
|
1746
|
+
<div id="inspector-hint">Tap on the screen or select an element in the tree to generate locators.</div>
|
|
1747
|
+
<div id="action-section">
|
|
1748
|
+
<div class="action-mode-row" role="group" aria-label="Mirror click mode">
|
|
1749
|
+
<button type="button" id="inspect-mode-btn" class="mode-btn active" title="Select elements in the mirror">Inspect</button>
|
|
1750
|
+
<button type="button" id="interact-mode-btn" class="mode-btn" title="Tap the device without recording">Interact</button>
|
|
1751
|
+
</div>
|
|
1752
|
+
<div class="element-action-row">
|
|
1753
|
+
<button type="button" id="element-tap-btn" class="element-action-btn" disabled>Tap</button>
|
|
1754
|
+
<input id="element-fill-input" class="element-action-input" placeholder="text" disabled/>
|
|
1755
|
+
<button type="button" id="element-fill-btn" class="element-action-btn" disabled>Fill</button>
|
|
1756
|
+
</div>
|
|
1757
|
+
</div>
|
|
1758
|
+
<div id="locator-section">
|
|
1759
|
+
<div id="best-locator-label">Best Locator</div>
|
|
1760
|
+
<div id="best-locator-code">—</div>
|
|
1761
|
+
<div id="alternatives-label">Alternatives</div>
|
|
1762
|
+
<div id="alternatives-list"></div>
|
|
1763
|
+
</div>
|
|
1764
|
+
<div id="details-section">
|
|
1765
|
+
<table id="details-table">
|
|
1766
|
+
<tbody id="details-body"></tbody>
|
|
1767
|
+
</table>
|
|
1768
|
+
</div>
|
|
1769
|
+
<div id="inspector-footer">
|
|
1770
|
+
<span id="legal-note">© ${new Date().getFullYear()} Astur · Open source, Apache-2.0</span>
|
|
1771
|
+
<span id="version-chip">v${escHtml(INSPECTOR_VERSION)}</span>
|
|
1772
|
+
</div>
|
|
1773
|
+
</div>
|
|
1774
|
+
</div>
|
|
1775
|
+
|
|
1776
|
+
<div id="left-column-splitter" title="Drag to resize the Inspector panel"></div>
|
|
1777
|
+
|
|
1778
|
+
<!-- Center: Device mirror -->
|
|
1779
|
+
<div id="center-panel">
|
|
1780
|
+
<div id="phone-shell">
|
|
1781
|
+
<div id="phone-notch"></div>
|
|
1782
|
+
<div id="mirror-stage">
|
|
1783
|
+
<img id="mirror-img" class="placeholder" src="" alt="Device mirror" draggable="false"/>
|
|
1784
|
+
<div id="highlight-overlay"></div>
|
|
1785
|
+
<div id="busy-overlay" class="active" aria-live="polite" aria-busy="true">
|
|
1786
|
+
<div class="spinner"></div>
|
|
1787
|
+
<div id="busy-label" class="busy-label">Inspector is not ready yet</div>
|
|
1788
|
+
<div id="busy-subtitle" class="busy-subtitle">Astur is preparing the device, screen stream, and UI tree. This can take a few minutes on first real-device runs.</div>
|
|
1789
|
+
</div>
|
|
1790
|
+
</div>
|
|
1791
|
+
</div>
|
|
1792
|
+
<div id="mirror-status">Waiting for device…</div>
|
|
1793
|
+
</div>
|
|
1794
|
+
|
|
1795
|
+
<div id="right-column-splitter" title="Drag to resize the UI Tree panel"></div>
|
|
1796
|
+
|
|
1797
|
+
<!-- Right: Tree + Code -->
|
|
1798
|
+
<div id="right-panel">
|
|
1799
|
+
<!-- Tree -->
|
|
1800
|
+
<div id="tree-panel">
|
|
1801
|
+
<div class="panel-header">UI Tree <span id="tree-badge" style="font-weight:400;color:var(--text-muted)"></span></div>
|
|
1802
|
+
<div id="tree-search-row">
|
|
1803
|
+
<input id="tree-search" type="search" placeholder="Search element…"/>
|
|
1804
|
+
</div>
|
|
1805
|
+
<div id="tree-list"></div>
|
|
1806
|
+
</div>
|
|
1807
|
+
<div id="right-splitter" title="Drag to resize the UI tree"></div>
|
|
1808
|
+
<!-- Code / Steps -->
|
|
1809
|
+
<div id="code-panel">
|
|
1810
|
+
<div id="code-tabs">
|
|
1811
|
+
<div class="code-tab active" data-tab="code">Code</div>
|
|
1812
|
+
<div class="code-tab" data-tab="steps">Recording Steps</div>
|
|
1813
|
+
</div>
|
|
1814
|
+
<div id="code-view">
|
|
1815
|
+
<div id="code-lang-tabs">
|
|
1816
|
+
<button class="code-tab active" data-lang="typescript">TypeScript</button>
|
|
1817
|
+
<button class="code-tab" data-lang="javascript">JavaScript</button>
|
|
1818
|
+
<button id="code-script-copy-btn" class="copy-btn" type="button" title="Copy script" aria-label="Copy script"></button>
|
|
1819
|
+
</div>
|
|
1820
|
+
<pre id="code-block">// No steps recorded yet</pre>
|
|
1821
|
+
</div>
|
|
1822
|
+
<div id="steps-view" style="display:none">
|
|
1823
|
+
<div id="steps-toolbar">
|
|
1824
|
+
<button class="step-btn" id="add-tap-btn">+ Tap</button>
|
|
1825
|
+
<button class="step-btn" id="add-fill-btn">+ Fill</button>
|
|
1826
|
+
<button class="step-btn" id="add-expect-btn">+ Expect</button>
|
|
1827
|
+
<button class="step-btn" id="clear-btn">Clear</button>
|
|
1828
|
+
</div>
|
|
1829
|
+
<div id="step-composer">
|
|
1830
|
+
<div class="composer-grid">
|
|
1831
|
+
<div class="composer-row">
|
|
1832
|
+
<select id="composer-assertion" class="composer-select">
|
|
1833
|
+
<option value="visible">visible</option>
|
|
1834
|
+
<option value="text">text equals</option>
|
|
1835
|
+
<option value="containsText">text contains</option>
|
|
1836
|
+
<option value="value">value equals</option>
|
|
1837
|
+
<option value="label">label equals</option>
|
|
1838
|
+
<option value="type">type equals</option>
|
|
1839
|
+
</select>
|
|
1840
|
+
<input id="composer-value" class="composer-input" placeholder="value"/>
|
|
1841
|
+
</div>
|
|
1842
|
+
<div class="composer-actions">
|
|
1843
|
+
<button class="step-btn" id="composer-cancel-btn">Cancel</button>
|
|
1844
|
+
<button class="step-btn" id="composer-add-btn">Add</button>
|
|
1845
|
+
</div>
|
|
1846
|
+
</div>
|
|
1847
|
+
</div>
|
|
1848
|
+
<div id="steps-table-wrap">
|
|
1849
|
+
<table id="steps-table">
|
|
1850
|
+
<thead><tr><th>#</th><th>Action</th><th>Locator</th><th>Value</th></tr></thead>
|
|
1851
|
+
<tbody id="steps-body"></tbody>
|
|
1852
|
+
</table>
|
|
1853
|
+
</div>
|
|
1854
|
+
</div>
|
|
1855
|
+
</div>
|
|
1856
|
+
</div>
|
|
1857
|
+
</div>
|
|
1858
|
+
</div>
|
|
1859
|
+
<script>
|
|
1860
|
+
(function(){
|
|
1861
|
+
'use strict';
|
|
1862
|
+
|
|
1863
|
+
// ── State ──────────────────────────────────────────────────────────────────
|
|
1864
|
+
let nodes = [];
|
|
1865
|
+
let viewport = { width: 1, height: 1 };
|
|
1866
|
+
let selectedUid = null;
|
|
1867
|
+
let currentSuggestions = [];
|
|
1868
|
+
let activeLocator = '';
|
|
1869
|
+
let activeSelector = null;
|
|
1870
|
+
let recording = false;
|
|
1871
|
+
let mirrorMode = 'inspect';
|
|
1872
|
+
let steps = [];
|
|
1873
|
+
let codeLang = 'typescript';
|
|
1874
|
+
let activeTab = 'code';
|
|
1875
|
+
let currentDevice = ${JSON.stringify(toBootstrapDevice(device))};
|
|
1876
|
+
let devices = [currentDevice];
|
|
1877
|
+
let busyCount = 0;
|
|
1878
|
+
let socketConnected = false;
|
|
1879
|
+
let hasFrame = false;
|
|
1880
|
+
let hasTree = false;
|
|
1881
|
+
let composerMode = null;
|
|
1882
|
+
let dragStart = null;
|
|
1883
|
+
let suppressNextClick = false;
|
|
1884
|
+
let gestureInFlight = false;
|
|
1885
|
+
let gestureReleaseTimer = null;
|
|
1886
|
+
let nextGestureAllowedAt = 0;
|
|
1887
|
+
|
|
1888
|
+
// ── DOM refs ───────────────────────────────────────────────────────────────
|
|
1889
|
+
const $ = id => document.getElementById(id);
|
|
1890
|
+
const logo = $('logo');
|
|
1891
|
+
const liveBadge = $('live-badge');
|
|
1892
|
+
const deviceSwitcher = $('device-switcher');
|
|
1893
|
+
const deviceControls = $('device-controls');
|
|
1894
|
+
const deviceChip = $('device-chip');
|
|
1895
|
+
const deviceListMenu = $('device-list-menu');
|
|
1896
|
+
const deviceMenu = $('device-menu');
|
|
1897
|
+
const deviceMenuBtn = $('device-menu-btn');
|
|
1898
|
+
const deviceName = $('device-name');
|
|
1899
|
+
const deviceList = $('device-list');
|
|
1900
|
+
const deviceStatus = $('device-status');
|
|
1901
|
+
const recordBtn = $('record-btn');
|
|
1902
|
+
const exportBtn = $('export-btn');
|
|
1903
|
+
const mirrorImg = $('mirror-img');
|
|
1904
|
+
const mirrorStage = $('mirror-stage');
|
|
1905
|
+
const busyOverlay = $('busy-overlay');
|
|
1906
|
+
const busyLabel = $('busy-label');
|
|
1907
|
+
const busySubtitle = $('busy-subtitle');
|
|
1908
|
+
const centerPanel = $('center-panel');
|
|
1909
|
+
const highlightOverlay = $('highlight-overlay');
|
|
1910
|
+
const inspectorHint = $('inspector-hint');
|
|
1911
|
+
const inspectModeBtn = $('inspect-mode-btn');
|
|
1912
|
+
const interactModeBtn = $('interact-mode-btn');
|
|
1913
|
+
const elementTapBtn = $('element-tap-btn');
|
|
1914
|
+
const elementFillInput = $('element-fill-input');
|
|
1915
|
+
const elementFillBtn = $('element-fill-btn');
|
|
1916
|
+
const bestLocatorCode = $('best-locator-code');
|
|
1917
|
+
const alternativesList = $('alternatives-list');
|
|
1918
|
+
const detailsBody = $('details-body');
|
|
1919
|
+
const treeList = $('tree-list');
|
|
1920
|
+
const treeSearch = $('tree-search');
|
|
1921
|
+
const treeBadge = $('tree-badge');
|
|
1922
|
+
const codeBlock = $('code-block');
|
|
1923
|
+
const codeScriptCopyBtn = $('code-script-copy-btn');
|
|
1924
|
+
const stepsBody = $('steps-body');
|
|
1925
|
+
const codeView = $('code-view');
|
|
1926
|
+
const stepsView = $('steps-view');
|
|
1927
|
+
const addTapBtn = $('add-tap-btn');
|
|
1928
|
+
const addFillBtn = $('add-fill-btn');
|
|
1929
|
+
const addExpectBtn = $('add-expect-btn');
|
|
1930
|
+
const clearBtn = $('clear-btn');
|
|
1931
|
+
const stepComposer = $('step-composer');
|
|
1932
|
+
const composerAssertion = $('composer-assertion');
|
|
1933
|
+
const composerValue = $('composer-value');
|
|
1934
|
+
const composerCancelBtn = $('composer-cancel-btn');
|
|
1935
|
+
const composerAddBtn = $('composer-add-btn');
|
|
1936
|
+
const appIdentifierInput = $('app-identifier-input');
|
|
1937
|
+
const appUploadRow = $('app-upload-row');
|
|
1938
|
+
const appUploadInput = $('app-upload-input');
|
|
1939
|
+
const appUploadHint = $('app-upload-hint');
|
|
1940
|
+
const appUploadSelection = $('app-upload-selection');
|
|
1941
|
+
const permissionInput = $('permission-input');
|
|
1942
|
+
const launchAppBtn = $('launch-app-btn');
|
|
1943
|
+
const installAppBtn = $('install-app-btn');
|
|
1944
|
+
const grantPermissionBtn = $('grant-permission-btn');
|
|
1945
|
+
const revokePermissionBtn = $('revoke-permission-btn');
|
|
1946
|
+
const clearDataBtn = $('clear-data-btn');
|
|
1947
|
+
const clearCacheBtn = $('clear-cache-btn');
|
|
1948
|
+
const main = $('main');
|
|
1949
|
+
const leftColumnSplitter = $('left-column-splitter');
|
|
1950
|
+
const rightColumnSplitter = $('right-column-splitter');
|
|
1951
|
+
const rightPanel = $('right-panel');
|
|
1952
|
+
const rightSplitter = $('right-splitter');
|
|
1953
|
+
let deviceStatusTimer;
|
|
1954
|
+
let pendingInstallSelection = null;
|
|
1955
|
+
|
|
1956
|
+
// ── WebSocket ──────────────────────────────────────────────────────────────
|
|
1957
|
+
let ws;
|
|
1958
|
+
function connectWs() {
|
|
1959
|
+
ws = new WebSocket('ws://' + location.host);
|
|
1960
|
+
ws.onopen = () => {
|
|
1961
|
+
socketConnected = true;
|
|
1962
|
+
updateDeviceReadiness();
|
|
1963
|
+
};
|
|
1964
|
+
ws.onclose = () => {
|
|
1965
|
+
socketConnected = false;
|
|
1966
|
+
liveBadge.textContent = 'Disconnected';
|
|
1967
|
+
liveBadge.className = 'connecting';
|
|
1968
|
+
updateDeviceReadiness('Connection lost. Reconnecting…');
|
|
1969
|
+
setTimeout(connectWs, 2000);
|
|
1970
|
+
};
|
|
1971
|
+
ws.onerror = () => ws.close();
|
|
1972
|
+
ws.onmessage = e => {
|
|
1973
|
+
let ev;
|
|
1974
|
+
try { ev = JSON.parse(e.data); } catch { return; }
|
|
1975
|
+
handleServerEvent(ev);
|
|
1976
|
+
};
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
function send(obj) {
|
|
1980
|
+
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
// ── Server event handler ───────────────────────────────────────────────────
|
|
1984
|
+
function handleServerEvent(ev) {
|
|
1985
|
+
switch (ev.type) {
|
|
1986
|
+
case 'bootstrap':
|
|
1987
|
+
resetDeviceReadiness('Inspector is not ready yet');
|
|
1988
|
+
currentDevice = ev.device || currentDevice;
|
|
1989
|
+
devices = mergeDevices(devices, currentDevice);
|
|
1990
|
+
renderDeviceHeader();
|
|
1991
|
+
renderDeviceList();
|
|
1992
|
+
if (ev.logoDataUri) { logo.src = ev.logoDataUri; logo.style.display = ''; }
|
|
1993
|
+
nodes = ev.nodes || [];
|
|
1994
|
+
viewport = ev.viewport || { width: 1, height: 1 };
|
|
1995
|
+
hasTree = nodes.length > 0;
|
|
1996
|
+
if (ev.suggestions) currentSuggestions = ev.suggestions;
|
|
1997
|
+
renderTree();
|
|
1998
|
+
if (ev.initialUid) selectUid(ev.initialUid, ev.suggestions || [], false);
|
|
1999
|
+
updateDeviceReadiness();
|
|
2000
|
+
break;
|
|
2001
|
+
|
|
2002
|
+
case 'devices':
|
|
2003
|
+
devices = ev.devices || [];
|
|
2004
|
+
renderDeviceList();
|
|
2005
|
+
break;
|
|
2006
|
+
|
|
2007
|
+
case 'frame':
|
|
2008
|
+
hasFrame = true;
|
|
2009
|
+
mirrorImg.src = ev.dataUri;
|
|
2010
|
+
mirrorImg.classList.remove('placeholder');
|
|
2011
|
+
$('mirror-status').textContent = '';
|
|
2012
|
+
sizeMirror();
|
|
2013
|
+
updateDeviceReadiness();
|
|
2014
|
+
break;
|
|
2015
|
+
|
|
2016
|
+
case 'tree':
|
|
2017
|
+
nodes = ev.nodes || [];
|
|
2018
|
+
viewport = ev.viewport || viewport;
|
|
2019
|
+
sizeMirror();
|
|
2020
|
+
hasTree = nodes.length > 0;
|
|
2021
|
+
renderTree();
|
|
2022
|
+
if (deviceStatus.textContent.startsWith('UI tree unavailable') || deviceStatus.textContent.startsWith('UI tree refresh delayed')) {
|
|
2023
|
+
showDeviceStatus('', '');
|
|
2024
|
+
}
|
|
2025
|
+
if (selectedUid && nodes.some(n => n.uid === selectedUid)) {
|
|
2026
|
+
updateHighlight(selectedUid);
|
|
2027
|
+
} else {
|
|
2028
|
+
selectedUid = null;
|
|
2029
|
+
updateStepControls();
|
|
2030
|
+
// Auto-select first interesting node on first tree arrival
|
|
2031
|
+
const auto = nodes.find(n => n.visible && n.enabled && !n.type.endsWith('.root') && (n.id || n.label || n.text));
|
|
2032
|
+
if (auto) send({ type: 'select', uid: auto.uid });
|
|
2033
|
+
}
|
|
2034
|
+
updateDeviceReadiness();
|
|
2035
|
+
break;
|
|
2036
|
+
|
|
2037
|
+
case 'selection':
|
|
2038
|
+
nodes = updateNodeInList(nodes, ev.uid, ev.node);
|
|
2039
|
+
selectUid(ev.uid, ev.suggestions || [], true);
|
|
2040
|
+
break;
|
|
2041
|
+
|
|
2042
|
+
case 'step':
|
|
2043
|
+
steps.push(ev);
|
|
2044
|
+
renderStep(ev);
|
|
2045
|
+
updateCodeBlock();
|
|
2046
|
+
break;
|
|
2047
|
+
|
|
2048
|
+
case 'steps':
|
|
2049
|
+
steps = ev.steps || [];
|
|
2050
|
+
renderAllSteps();
|
|
2051
|
+
updateCodeBlock();
|
|
2052
|
+
break;
|
|
2053
|
+
|
|
2054
|
+
case 'status':
|
|
2055
|
+
if (ev.message.startsWith('Action OK: ') || ev.message.startsWith('Action Error: ')) {
|
|
2056
|
+
releaseGestureLock();
|
|
2057
|
+
}
|
|
2058
|
+
if (ev.message.startsWith('Recording ON')) {
|
|
2059
|
+
recording = true;
|
|
2060
|
+
recordBtn.classList.add('active');
|
|
2061
|
+
recordBtn.textContent = 'Recording';
|
|
2062
|
+
showDeviceStatus('Click the mirror to tap and record', 'pending');
|
|
2063
|
+
}
|
|
2064
|
+
else if (ev.message.startsWith('Recording OFF')) {
|
|
2065
|
+
recording = false;
|
|
2066
|
+
recordBtn.classList.remove('active');
|
|
2067
|
+
recordBtn.textContent = 'Record';
|
|
2068
|
+
showDeviceStatus('Recording paused', 'success');
|
|
2069
|
+
}
|
|
2070
|
+
else if (ev.message.startsWith('Action Pending: ')) {
|
|
2071
|
+
showDeviceStatus(ev.message.slice(16), 'pending');
|
|
2072
|
+
break;
|
|
2073
|
+
}
|
|
2074
|
+
else if (ev.message.startsWith('Action OK: ')) { showDeviceStatus(ev.message.slice(11), 'success'); }
|
|
2075
|
+
else if (ev.message.startsWith('Action Error: ')) { showDeviceStatus(ev.message.slice(14), 'error'); }
|
|
2076
|
+
setBusy(false);
|
|
2077
|
+
break;
|
|
2078
|
+
|
|
2079
|
+
case 'gesture_ack':
|
|
2080
|
+
releaseGestureLock();
|
|
2081
|
+
setBusy(false);
|
|
2082
|
+
break;
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
function showDeviceStatus(message, tone) {
|
|
2087
|
+
deviceStatus.textContent = message || '';
|
|
2088
|
+
deviceStatus.dataset.tone = tone || '';
|
|
2089
|
+
clearTimeout(deviceStatusTimer);
|
|
2090
|
+
if (!message) {
|
|
2091
|
+
return;
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
deviceStatusTimer = setTimeout(() => {
|
|
2095
|
+
deviceStatus.textContent = '';
|
|
2096
|
+
deviceStatus.dataset.tone = '';
|
|
2097
|
+
}, tone === 'error' ? 5000 : 2600);
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
function closeDeviceMenu() {
|
|
2101
|
+
deviceControls.classList.remove('open');
|
|
2102
|
+
deviceMenuBtn.setAttribute('aria-expanded', 'false');
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
function toggleDeviceMenu() {
|
|
2106
|
+
const nextOpen = !deviceControls.classList.contains('open');
|
|
2107
|
+
closeDeviceSwitcher();
|
|
2108
|
+
deviceControls.classList.toggle('open', nextOpen);
|
|
2109
|
+
deviceMenuBtn.setAttribute('aria-expanded', String(nextOpen));
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
function closeDeviceSwitcher() {
|
|
2113
|
+
deviceSwitcher.classList.remove('open');
|
|
2114
|
+
deviceChip.setAttribute('aria-expanded', 'false');
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
function toggleDeviceSwitcher() {
|
|
2118
|
+
const nextOpen = !deviceSwitcher.classList.contains('open');
|
|
2119
|
+
closeDeviceMenu();
|
|
2120
|
+
deviceSwitcher.classList.toggle('open', nextOpen);
|
|
2121
|
+
deviceChip.setAttribute('aria-expanded', String(nextOpen));
|
|
2122
|
+
if (nextOpen) {
|
|
2123
|
+
send({ type: 'list_devices' });
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
function setBusy(active) {
|
|
2128
|
+
busyCount = Math.max(0, busyCount + (active ? 1 : -1));
|
|
2129
|
+
updateDeviceReadiness();
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
function resetDeviceReadiness(message) {
|
|
2133
|
+
hasFrame = false;
|
|
2134
|
+
hasTree = false;
|
|
2135
|
+
selectedUid = null;
|
|
2136
|
+
currentSuggestions = [];
|
|
2137
|
+
activeLocator = '';
|
|
2138
|
+
activeSelector = null;
|
|
2139
|
+
mirrorImg.src = '';
|
|
2140
|
+
mirrorImg.classList.add('placeholder');
|
|
2141
|
+
highlightOverlay.innerHTML = '';
|
|
2142
|
+
renderLocators([]);
|
|
2143
|
+
detailsBody.innerHTML = '';
|
|
2144
|
+
updateDeviceReadiness(message);
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
function updateDeviceReadiness(message) {
|
|
2148
|
+
const screenReady = socketConnected && hasFrame;
|
|
2149
|
+
const overlayActive = busyCount > 0 || !screenReady;
|
|
2150
|
+
busyOverlay.classList.toggle('active', overlayActive);
|
|
2151
|
+
busyOverlay.setAttribute('aria-busy', overlayActive ? 'true' : 'false');
|
|
2152
|
+
|
|
2153
|
+
if (!socketConnected) {
|
|
2154
|
+
liveBadge.textContent = 'Connecting…';
|
|
2155
|
+
liveBadge.className = 'connecting';
|
|
2156
|
+
busyLabel.textContent = message || 'Connecting to Inspector…';
|
|
2157
|
+
busySubtitle.textContent = 'Waiting for the local Inspector session. The device is not ready yet.';
|
|
2158
|
+
$('mirror-status').textContent = message || 'Connecting to Inspector…';
|
|
2159
|
+
return;
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
if (!hasFrame) {
|
|
2163
|
+
liveBadge.textContent = 'Preparing';
|
|
2164
|
+
liveBadge.className = 'connecting';
|
|
2165
|
+
busyLabel.textContent = message || 'Preparing device…';
|
|
2166
|
+
busySubtitle.textContent = 'Astur is waiting for the first screen frame. If this is the first real-device run, Xcode/agent setup may take a few minutes.';
|
|
2167
|
+
$('mirror-status').textContent = message || 'Waiting for device…';
|
|
2168
|
+
return;
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
if (!hasTree) {
|
|
2172
|
+
liveBadge.textContent = 'Live';
|
|
2173
|
+
liveBadge.className = '';
|
|
2174
|
+
busyLabel.textContent = busyCount > 0 ? 'Running action…' : 'Screen ready';
|
|
2175
|
+
busySubtitle.textContent = busyCount > 0
|
|
2176
|
+
? 'Waiting for device response.'
|
|
2177
|
+
: 'The screen is visible. The UI tree is still loading, so inspection and locator ranking may lag behind the mirror.';
|
|
2178
|
+
if (busyCount === 0 && !$('device-status').textContent) {
|
|
2179
|
+
$('mirror-status').textContent = 'UI tree still loading…';
|
|
2180
|
+
}
|
|
2181
|
+
return;
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
liveBadge.textContent = 'Live';
|
|
2185
|
+
liveBadge.className = '';
|
|
2186
|
+
busyLabel.textContent = busyCount > 0 ? 'Running action…' : 'Ready';
|
|
2187
|
+
busySubtitle.textContent = busyCount > 0 ? 'Waiting for device response.' : '';
|
|
2188
|
+
if (busyCount === 0) {
|
|
2189
|
+
$('mirror-status').textContent = '';
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
function releaseGestureLock() {
|
|
2194
|
+
gestureInFlight = false;
|
|
2195
|
+
if (gestureReleaseTimer) {
|
|
2196
|
+
clearTimeout(gestureReleaseTimer);
|
|
2197
|
+
gestureReleaseTimer = null;
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
function renderDeviceHeader() {
|
|
2202
|
+
deviceName.textContent = currentDevice ? currentDevice.name : 'Device';
|
|
2203
|
+
applyDeviceInstallSpec(currentDevice);
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
function deviceInstallSpec(device) {
|
|
2207
|
+
if (device && device.platform === 'android') {
|
|
2208
|
+
return {
|
|
2209
|
+
installKind: 'file',
|
|
2210
|
+
accept: '.apk',
|
|
2211
|
+
extensions: ['.apk'],
|
|
2212
|
+
identifierPlaceholder: 'package id, e.g. com.example.app',
|
|
2213
|
+
hint: 'Android installs use .apk files. You can choose a file or drag one into this area.',
|
|
2214
|
+
emptyMessage: 'Choose an .apk to install on Android.',
|
|
2215
|
+
invalidMessage: 'Android installs require an .apk file.'
|
|
2216
|
+
};
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
if (device && device.platform === 'ios' && device.kind === 'simulator') {
|
|
2220
|
+
return {
|
|
2221
|
+
installKind: 'bundle',
|
|
2222
|
+
accept: '.app',
|
|
2223
|
+
extensions: ['.app'],
|
|
2224
|
+
identifierPlaceholder: 'bundle id, e.g. com.example.app',
|
|
2225
|
+
hint: 'iOS Simulator installs use a simulator-built .app bundle from Xcode. Choose the .app bundle directory or drag it into this area.',
|
|
2226
|
+
emptyMessage: 'Choose a simulator-built .app bundle for iOS Simulator.',
|
|
2227
|
+
invalidMessage: 'iOS Simulator installs require a simulator-built .app bundle from Xcode.'
|
|
2228
|
+
};
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
if (device && device.platform === 'ios') {
|
|
2232
|
+
return {
|
|
2233
|
+
installKind: 'file',
|
|
2234
|
+
accept: '.ipa',
|
|
2235
|
+
extensions: ['.ipa'],
|
|
2236
|
+
identifierPlaceholder: 'bundle id, e.g. com.example.app',
|
|
2237
|
+
hint: 'Real iPhone and iPad installs use a signed .ipa with a valid provisioning profile trusted on the device. You can choose a file or drag one into this area.',
|
|
2238
|
+
emptyMessage: 'Choose a signed .ipa to install on iPhone or iPad.',
|
|
2239
|
+
invalidMessage: 'Real iPhone and iPad installs require a signed .ipa.'
|
|
2240
|
+
};
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
return {
|
|
2244
|
+
installKind: 'file',
|
|
2245
|
+
accept: '.apk,.app,.ipa',
|
|
2246
|
+
extensions: ['.apk', '.app', '.ipa'],
|
|
2247
|
+
identifierPlaceholder: 'package or bundle id',
|
|
2248
|
+
hint: 'Android installs use .apk. iOS Simulator uses a simulator-built .app from Xcode. Real iPhone and iPad installs use a signed .ipa.',
|
|
2249
|
+
emptyMessage: 'Choose an install artifact.',
|
|
2250
|
+
invalidMessage: 'Choose a valid install artifact for the current device.'
|
|
2251
|
+
};
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
function applyDeviceInstallSpec(device) {
|
|
2255
|
+
const spec = deviceInstallSpec(device);
|
|
2256
|
+
appIdentifierInput.placeholder = spec.identifierPlaceholder;
|
|
2257
|
+
if (spec.installKind === 'bundle') {
|
|
2258
|
+
appUploadInput.removeAttribute('accept');
|
|
2259
|
+
appUploadInput.setAttribute('webkitdirectory', '');
|
|
2260
|
+
appUploadInput.setAttribute('directory', '');
|
|
2261
|
+
appUploadInput.multiple = true;
|
|
2262
|
+
} else {
|
|
2263
|
+
appUploadInput.accept = spec.accept;
|
|
2264
|
+
appUploadInput.removeAttribute('webkitdirectory');
|
|
2265
|
+
appUploadInput.removeAttribute('directory');
|
|
2266
|
+
appUploadInput.multiple = false;
|
|
2267
|
+
}
|
|
2268
|
+
appUploadInput.title = '';
|
|
2269
|
+
const specKey = spec.installKind + ':' + spec.accept;
|
|
2270
|
+
if (appUploadInput.dataset.acceptSpec !== specKey) {
|
|
2271
|
+
appUploadInput.value = '';
|
|
2272
|
+
appUploadInput.dataset.acceptSpec = specKey;
|
|
2273
|
+
pendingInstallSelection = null;
|
|
2274
|
+
}
|
|
2275
|
+
if (appUploadHint) {
|
|
2276
|
+
appUploadHint.textContent = spec.hint;
|
|
2277
|
+
}
|
|
2278
|
+
renderInstallSelection();
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
function isAllowedInstallArtifact(file, spec) {
|
|
2282
|
+
const name = String(file && file.name || '').toLowerCase();
|
|
2283
|
+
return spec.extensions.some((extension) => name.endsWith(extension));
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
function renderInstallSelection() {
|
|
2287
|
+
if (!appUploadSelection) return;
|
|
2288
|
+
if (!pendingInstallSelection) {
|
|
2289
|
+
appUploadSelection.textContent = '';
|
|
2290
|
+
return;
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
if (pendingInstallSelection.kind === 'bundle') {
|
|
2294
|
+
appUploadSelection.textContent = 'Selected bundle: ' + pendingInstallSelection.rootName + ' (' + pendingInstallSelection.files.length + ' files)';
|
|
2295
|
+
return;
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
appUploadSelection.textContent = 'Selected file: ' + pendingInstallSelection.file.name;
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
function setPendingInstallSelection(selection) {
|
|
2302
|
+
pendingInstallSelection = selection;
|
|
2303
|
+
renderInstallSelection();
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
function clearPendingInstallSelection() {
|
|
2307
|
+
pendingInstallSelection = null;
|
|
2308
|
+
renderInstallSelection();
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
function normalizeBundleEntries(files) {
|
|
2312
|
+
return Array.from(files || []).map((file) => ({
|
|
2313
|
+
file,
|
|
2314
|
+
relativePath: String(file.webkitRelativePath || file.name).replace(/\\\\/g, '/')
|
|
2315
|
+
}));
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
function inferBundleRootName(entries) {
|
|
2319
|
+
const roots = [...new Set(entries.map((entry) => String(entry.relativePath || '').split('/')[0]).filter(Boolean))];
|
|
2320
|
+
return roots.length === 1 ? roots[0] : undefined;
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
function createInstallSelectionFromFiles(files, spec) {
|
|
2324
|
+
const list = Array.from(files || []);
|
|
2325
|
+
if (!list.length) {
|
|
2326
|
+
return null;
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
if (spec.installKind === 'bundle') {
|
|
2330
|
+
const entries = normalizeBundleEntries(list);
|
|
2331
|
+
const rootName = inferBundleRootName(entries);
|
|
2332
|
+
if (!rootName || !rootName.toLowerCase().endsWith('.app')) {
|
|
2333
|
+
throw new Error(spec.invalidMessage);
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
return {
|
|
2337
|
+
kind: 'bundle',
|
|
2338
|
+
rootName,
|
|
2339
|
+
files: entries
|
|
2340
|
+
};
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
const file = list[0];
|
|
2344
|
+
if (!isAllowedInstallArtifact(file, spec)) {
|
|
2345
|
+
throw new Error(spec.invalidMessage);
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
return {
|
|
2349
|
+
kind: 'file',
|
|
2350
|
+
file
|
|
2351
|
+
};
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
function readDirectoryEntries(reader) {
|
|
2355
|
+
return new Promise((resolve) => {
|
|
2356
|
+
const entries = [];
|
|
2357
|
+
const readBatch = () => {
|
|
2358
|
+
reader.readEntries((batch) => {
|
|
2359
|
+
if (!batch.length) {
|
|
2360
|
+
resolve(entries);
|
|
2361
|
+
return;
|
|
2362
|
+
}
|
|
2363
|
+
entries.push(...batch);
|
|
2364
|
+
readBatch();
|
|
2365
|
+
}, () => resolve(entries));
|
|
2366
|
+
};
|
|
2367
|
+
readBatch();
|
|
2368
|
+
});
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
async function readDroppedEntry(entry, prefix) {
|
|
2372
|
+
if (entry.isFile) {
|
|
2373
|
+
return new Promise((resolve) => {
|
|
2374
|
+
entry.file((file) => {
|
|
2375
|
+
resolve([{ file, relativePath: prefix + file.name }]);
|
|
2376
|
+
}, () => resolve([]));
|
|
2377
|
+
});
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
if (!entry.isDirectory) {
|
|
2381
|
+
return [];
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
const nextPrefix = prefix + entry.name + '/';
|
|
2385
|
+
const children = await readDirectoryEntries(entry.createReader());
|
|
2386
|
+
const nested = await Promise.all(children.map((child) => readDroppedEntry(child, nextPrefix)));
|
|
2387
|
+
return nested.flat();
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
async function createInstallSelectionFromDrop(dataTransfer, spec) {
|
|
2391
|
+
if (spec.installKind !== 'bundle') {
|
|
2392
|
+
return createInstallSelectionFromFiles(dataTransfer.files, spec);
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
const items = Array.from(dataTransfer.items || []);
|
|
2396
|
+
const nested = await Promise.all(items.map(async (item) => {
|
|
2397
|
+
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
|
|
2398
|
+
if (entry) {
|
|
2399
|
+
return readDroppedEntry(entry, '');
|
|
2400
|
+
}
|
|
2401
|
+
const file = item.getAsFile ? item.getAsFile() : null;
|
|
2402
|
+
return file ? [{ file, relativePath: file.name }] : [];
|
|
2403
|
+
}));
|
|
2404
|
+
const entries = nested.flat();
|
|
2405
|
+
|
|
2406
|
+
if (entries.length) {
|
|
2407
|
+
const rootName = inferBundleRootName(entries);
|
|
2408
|
+
if (!rootName || !rootName.toLowerCase().endsWith('.app')) {
|
|
2409
|
+
throw new Error(spec.invalidMessage);
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
return {
|
|
2413
|
+
kind: 'bundle',
|
|
2414
|
+
rootName,
|
|
2415
|
+
files: entries
|
|
2416
|
+
};
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
return createInstallSelectionFromFiles(dataTransfer.files, spec);
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
async function uploadInstallSelection(selection) {
|
|
2423
|
+
if (!selection) {
|
|
2424
|
+
return;
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
if (selection.kind === 'bundle') {
|
|
2428
|
+
const uploadId = self.crypto && self.crypto.randomUUID
|
|
2429
|
+
? self.crypto.randomUUID()
|
|
2430
|
+
: String(Date.now()) + Math.random().toString(16).slice(2);
|
|
2431
|
+
|
|
2432
|
+
for (const entry of selection.files) {
|
|
2433
|
+
const response = await fetch('/api/upload-app-file?uploadId=' + encodeURIComponent(uploadId) + '&relativePath=' + encodeURIComponent(entry.relativePath), {
|
|
2434
|
+
method: 'POST',
|
|
2435
|
+
body: entry.file
|
|
2436
|
+
});
|
|
2437
|
+
if (!response.ok) {
|
|
2438
|
+
throw new Error(await response.text());
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
const finalize = await fetch('/api/upload-app-bundle?uploadId=' + encodeURIComponent(uploadId) + '&rootName=' + encodeURIComponent(selection.rootName), {
|
|
2443
|
+
method: 'POST'
|
|
2444
|
+
});
|
|
2445
|
+
if (!finalize.ok) {
|
|
2446
|
+
throw new Error(await finalize.text());
|
|
2447
|
+
}
|
|
2448
|
+
return;
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
const response = await fetch('/api/upload-app?filename=' + encodeURIComponent(selection.file.name), {
|
|
2452
|
+
method: 'POST',
|
|
2453
|
+
body: selection.file
|
|
2454
|
+
});
|
|
2455
|
+
if (!response.ok) {
|
|
2456
|
+
throw new Error(await response.text());
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
function mergeDevices(list, device) {
|
|
2461
|
+
if (!device) return list;
|
|
2462
|
+
const without = list.filter(d => d.id !== device.id);
|
|
2463
|
+
return [device, ...without];
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
function renderDeviceList() {
|
|
2467
|
+
deviceList.innerHTML = '';
|
|
2468
|
+
if (!devices.length) {
|
|
2469
|
+
const empty = document.createElement('div');
|
|
2470
|
+
empty.className = 'tree-title';
|
|
2471
|
+
empty.textContent = 'No devices found';
|
|
2472
|
+
deviceList.appendChild(empty);
|
|
2473
|
+
return;
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
for (const device of devices) {
|
|
2477
|
+
const button = document.createElement('button');
|
|
2478
|
+
button.type = 'button';
|
|
2479
|
+
button.className = 'device-choice' + (currentDevice && device.id === currentDevice.id ? ' active' : '');
|
|
2480
|
+
button.innerHTML = '<span>' + escHtml(device.name || device.id) + '</span><small>' + escHtml(device.platform + ' ' + device.kind) + '</small>';
|
|
2481
|
+
button.addEventListener('click', () => {
|
|
2482
|
+
if (currentDevice && device.id === currentDevice.id) return;
|
|
2483
|
+
setBusy(true);
|
|
2484
|
+
closeDeviceSwitcher();
|
|
2485
|
+
send({ type: 'switch_device', deviceId: device.id });
|
|
2486
|
+
});
|
|
2487
|
+
deviceList.appendChild(button);
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
function updateNodeInList(list, uid, node) {
|
|
2492
|
+
const idx = list.findIndex(n => n.uid === uid);
|
|
2493
|
+
if (idx >= 0) { const updated = [...list]; updated[idx] = node; return updated; }
|
|
2494
|
+
return list;
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
// ── Tree rendering ─────────────────────────────────────────────────────────
|
|
2498
|
+
function renderTree() {
|
|
2499
|
+
const query = treeSearch.value.toLowerCase();
|
|
2500
|
+
treeBadge.textContent = nodes.length ? '(' + nodes.length + ')' : '';
|
|
2501
|
+
const frag = document.createDocumentFragment();
|
|
2502
|
+
if (!nodes.length) {
|
|
2503
|
+
const empty = document.createElement('div');
|
|
2504
|
+
empty.className = 'tree-empty';
|
|
2505
|
+
empty.textContent = socketConnected ? 'Reading UI tree…' : 'Connecting to Inspector…';
|
|
2506
|
+
treeList.innerHTML = '';
|
|
2507
|
+
treeList.appendChild(empty);
|
|
2508
|
+
return;
|
|
2509
|
+
}
|
|
2510
|
+
for (const node of nodes) {
|
|
2511
|
+
const haystack = [node.title, node.type, node.id, node.label, node.text, node.value]
|
|
2512
|
+
.filter(Boolean)
|
|
2513
|
+
.join(' ')
|
|
2514
|
+
.toLowerCase();
|
|
2515
|
+
if (query && !haystack.includes(query)) continue;
|
|
2516
|
+
const el = document.createElement('div');
|
|
2517
|
+
el.className = 'tree-node' + (!node.visible ? ' hidden' : '') + (node.uid === selectedUid ? ' selected' : '');
|
|
2518
|
+
el.style.paddingLeft = (8 + node.depth * 12) + 'px';
|
|
2519
|
+
el.dataset.uid = node.uid;
|
|
2520
|
+
|
|
2521
|
+
const expander = document.createElement('span');
|
|
2522
|
+
expander.className = 'tree-expander';
|
|
2523
|
+
expander.textContent = nodes.some(n => n.parentUid === node.uid) ? '▾' : ' ';
|
|
2524
|
+
|
|
2525
|
+
const type = document.createElement('span');
|
|
2526
|
+
type.className = 'tree-type';
|
|
2527
|
+
type.textContent = node.type.split('.').pop() || node.type;
|
|
2528
|
+
|
|
2529
|
+
const title = document.createElement('span');
|
|
2530
|
+
title.className = 'tree-title';
|
|
2531
|
+
title.textContent = node.title !== node.type ? node.title : '';
|
|
2532
|
+
|
|
2533
|
+
el.append(expander, type, title);
|
|
2534
|
+
el.addEventListener('click', () => {
|
|
2535
|
+
send({ type: 'select', uid: node.uid });
|
|
2536
|
+
});
|
|
2537
|
+
frag.appendChild(el);
|
|
2538
|
+
}
|
|
2539
|
+
treeList.innerHTML = '';
|
|
2540
|
+
treeList.appendChild(frag);
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
// ── Selection ──────────────────────────────────────────────────────────────
|
|
2544
|
+
function selectUid(uid, suggestions, scroll) {
|
|
2545
|
+
selectedUid = uid;
|
|
2546
|
+
currentSuggestions = suggestions;
|
|
2547
|
+
renderTree();
|
|
2548
|
+
updateHighlight(uid);
|
|
2549
|
+
renderLocators(suggestions);
|
|
2550
|
+
const node = nodes.find(n => n.uid === uid);
|
|
2551
|
+
if (node) renderDetails(node);
|
|
2552
|
+
updateStepControls();
|
|
2553
|
+
if (scroll !== false) {
|
|
2554
|
+
const el = treeList.querySelector('[data-uid="' + uid + '"]');
|
|
2555
|
+
if (el) el.scrollIntoView({ block: 'nearest' });
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
function updateHighlight(uid) {
|
|
2560
|
+
highlightOverlay.innerHTML = '';
|
|
2561
|
+
const node = nodes.find(n => n.uid === uid);
|
|
2562
|
+
if (!node || !node.visible) return;
|
|
2563
|
+
const imgW = viewport.width || mirrorImg.naturalWidth || 1;
|
|
2564
|
+
const imgH = viewport.height || mirrorImg.naturalHeight || 1;
|
|
2565
|
+
const dispW = mirrorImg.clientWidth || 1;
|
|
2566
|
+
const dispH = mirrorImg.clientHeight || 1;
|
|
2567
|
+
const sx = dispW / imgW;
|
|
2568
|
+
const sy = dispH / imgH;
|
|
2569
|
+
const div = document.createElement('div');
|
|
2570
|
+
div.className = 'el-highlight';
|
|
2571
|
+
div.style.left = (node.bounds.x * sx) + 'px';
|
|
2572
|
+
div.style.top = (node.bounds.y * sy) + 'px';
|
|
2573
|
+
div.style.width = (node.bounds.width * sx) + 'px';
|
|
2574
|
+
div.style.height = (node.bounds.height * sy) + 'px';
|
|
2575
|
+
const lbl = document.createElement('div');
|
|
2576
|
+
lbl.className = 'el-label';
|
|
2577
|
+
lbl.textContent = node.type.split('.').pop() || node.type;
|
|
2578
|
+
div.appendChild(lbl);
|
|
2579
|
+
highlightOverlay.appendChild(div);
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
// ── Locators ──────────────────────────────────────────────────────────────
|
|
2583
|
+
function renderLocators(suggestions) {
|
|
2584
|
+
const list = suggestions || [];
|
|
2585
|
+
currentSuggestions = list;
|
|
2586
|
+
const best = list[0];
|
|
2587
|
+
activeLocator = best ? best.code : '';
|
|
2588
|
+
activeSelector = best ? best.selector : null;
|
|
2589
|
+
renderBestLocator();
|
|
2590
|
+
alternativesList.innerHTML = '';
|
|
2591
|
+
for (const s of list.slice(1, 5)) {
|
|
2592
|
+
const div = document.createElement('div');
|
|
2593
|
+
div.className = 'alt-item';
|
|
2594
|
+
const code = document.createElement('span');
|
|
2595
|
+
code.className = 'alt-code';
|
|
2596
|
+
code.textContent = s.code;
|
|
2597
|
+
const score = document.createElement('span');
|
|
2598
|
+
score.className = 'alt-score';
|
|
2599
|
+
score.textContent = Math.round(s.score * 100) + '';
|
|
2600
|
+
const copy = createCopyButton(() => s.code, 'Copy alternative locator');
|
|
2601
|
+
div.append(code, score, copy);
|
|
2602
|
+
div.addEventListener('click', () => {
|
|
2603
|
+
activeLocator = s.code;
|
|
2604
|
+
activeSelector = s.selector;
|
|
2605
|
+
renderBestLocator();
|
|
2606
|
+
});
|
|
2607
|
+
alternativesList.appendChild(div);
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
function renderBestLocator() {
|
|
2612
|
+
bestLocatorCode.innerHTML = '';
|
|
2613
|
+
bestLocatorCode.dataset.locator = activeLocator;
|
|
2614
|
+
if (!activeLocator) {
|
|
2615
|
+
bestLocatorCode.textContent = '—';
|
|
2616
|
+
activeSelector = null;
|
|
2617
|
+
updateStepControls();
|
|
2618
|
+
return;
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
const code = document.createElement('span');
|
|
2622
|
+
code.className = 'locator-code';
|
|
2623
|
+
code.textContent = activeLocator;
|
|
2624
|
+
const copy = createCopyButton(() => activeLocator, 'Copy locator');
|
|
2625
|
+
bestLocatorCode.append(code, copy);
|
|
2626
|
+
updateStepControls();
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
function createCopyButton(getText, label) {
|
|
2630
|
+
const button = document.createElement('button');
|
|
2631
|
+
button.type = 'button';
|
|
2632
|
+
button.className = 'copy-btn';
|
|
2633
|
+
button.title = label;
|
|
2634
|
+
button.setAttribute('aria-label', label);
|
|
2635
|
+
button.addEventListener('click', async (event) => {
|
|
2636
|
+
event.stopPropagation();
|
|
2637
|
+
const text = getText();
|
|
2638
|
+
if (!text || text === '—') return;
|
|
2639
|
+
const copied = await copyText(text);
|
|
2640
|
+
if (!copied) return;
|
|
2641
|
+
button.classList.add('copied');
|
|
2642
|
+
button.title = 'Copied';
|
|
2643
|
+
clearTimeout(button._copyResetTimer);
|
|
2644
|
+
button._copyResetTimer = setTimeout(() => {
|
|
2645
|
+
button.classList.remove('copied');
|
|
2646
|
+
button.title = label;
|
|
2647
|
+
}, 1200);
|
|
2648
|
+
});
|
|
2649
|
+
return button;
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2652
|
+
async function copyText(text) {
|
|
2653
|
+
try {
|
|
2654
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
2655
|
+
await navigator.clipboard.writeText(text);
|
|
2656
|
+
return true;
|
|
2657
|
+
}
|
|
2658
|
+
} catch {
|
|
2659
|
+
// fall through to execCommand fallback
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
const textarea = document.createElement('textarea');
|
|
2663
|
+
textarea.value = text;
|
|
2664
|
+
textarea.setAttribute('readonly', 'true');
|
|
2665
|
+
textarea.style.position = 'fixed';
|
|
2666
|
+
textarea.style.opacity = '0';
|
|
2667
|
+
document.body.appendChild(textarea);
|
|
2668
|
+
textarea.select();
|
|
2669
|
+
textarea.setSelectionRange(0, textarea.value.length);
|
|
2670
|
+
const copied = document.execCommand('copy');
|
|
2671
|
+
document.body.removeChild(textarea);
|
|
2672
|
+
return copied;
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
function getSelectedNode() {
|
|
2676
|
+
return selectedUid ? nodes.find(n => n.uid === selectedUid) : undefined;
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
function nodeRoles(node) {
|
|
2680
|
+
if (!node || !node.type) return [];
|
|
2681
|
+
const type = node.type.toLowerCase();
|
|
2682
|
+
const roles = [];
|
|
2683
|
+
if (type.includes('button')) roles.push('button');
|
|
2684
|
+
if (type.includes('checkbox')) roles.push('checkbox');
|
|
2685
|
+
if (type.includes('radiobutton') || type.includes('radio')) roles.push('radio');
|
|
2686
|
+
if (type.includes('seekbar') || type.includes('slider')) roles.push('slider');
|
|
2687
|
+
if (type.includes('switch')) roles.push('switch');
|
|
2688
|
+
if (type.includes('tab')) roles.push('tab');
|
|
2689
|
+
if (type.includes('edittext') || type.includes('textfield') || type.includes('securetextfield') || type.includes('searchfield') || type.includes('textinput')) roles.push('textbox');
|
|
2690
|
+
return roles;
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
function isFillableSelectedNode() {
|
|
2694
|
+
return nodeRoles(getSelectedNode()).includes('textbox');
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
function updateStepControls() {
|
|
2698
|
+
const hasLocator = !!activeLocator && activeLocator !== '—';
|
|
2699
|
+
const canRunAction = hasLocator && !!activeSelector;
|
|
2700
|
+
const canFill = canRunAction && isFillableSelectedNode();
|
|
2701
|
+
addTapBtn.disabled = !hasLocator;
|
|
2702
|
+
addExpectBtn.disabled = !hasLocator;
|
|
2703
|
+
addFillBtn.disabled = !hasLocator || !isFillableSelectedNode();
|
|
2704
|
+
addFillBtn.title = addFillBtn.disabled && hasLocator
|
|
2705
|
+
? 'Fill is only available for text input elements'
|
|
2706
|
+
: '';
|
|
2707
|
+
elementTapBtn.disabled = !canRunAction;
|
|
2708
|
+
elementFillInput.disabled = !canFill;
|
|
2709
|
+
elementFillBtn.disabled = !canFill;
|
|
2710
|
+
elementFillBtn.title = !canFill && canRunAction
|
|
2711
|
+
? 'Fill is only available for text input elements'
|
|
2712
|
+
: '';
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
function setMirrorMode(mode) {
|
|
2716
|
+
mirrorMode = mode;
|
|
2717
|
+
const interacting = mode === 'interact';
|
|
2718
|
+
inspectModeBtn.classList.toggle('active', !interacting);
|
|
2719
|
+
interactModeBtn.classList.toggle('active', interacting);
|
|
2720
|
+
mirrorStage.dataset.mode = mode;
|
|
2721
|
+
inspectorHint.textContent = interacting
|
|
2722
|
+
? 'Tap the screen to interact with the device without recording.'
|
|
2723
|
+
: 'Tap on the screen or select an element in the tree to generate locators.';
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
// ── Details ────────────────────────────────────────────────────────────────
|
|
2727
|
+
function renderDetails(node) {
|
|
2728
|
+
const fields = [
|
|
2729
|
+
['Type', node.type],
|
|
2730
|
+
['Text', node.text],
|
|
2731
|
+
['Label', node.label],
|
|
2732
|
+
['Resource-id', node.id],
|
|
2733
|
+
['Value', node.value],
|
|
2734
|
+
['Enabled', node.enabled ? 'true' : 'false'],
|
|
2735
|
+
['Visible', node.visible ? 'true' : 'false'],
|
|
2736
|
+
['Bounds', node.bounds ? '[' + node.bounds.x + ', ' + node.bounds.y + '][' + (node.bounds.x+node.bounds.width) + ', ' + (node.bounds.y+node.bounds.height) + ']' : ''],
|
|
2737
|
+
];
|
|
2738
|
+
detailsBody.innerHTML = '';
|
|
2739
|
+
for (const [k, v] of fields) {
|
|
2740
|
+
if (!v && v !== 'false') continue;
|
|
2741
|
+
const tr = document.createElement('tr');
|
|
2742
|
+
tr.innerHTML = '<td>' + escHtml(k) + '</td><td>' + escHtml(String(v)) + '</td>';
|
|
2743
|
+
detailsBody.appendChild(tr);
|
|
2744
|
+
}
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
// ── Mirror click ───────────────────────────────────────────────────────────
|
|
2748
|
+
function resolveMirrorSourceSize() {
|
|
2749
|
+
const viewportWidth = Number(viewport.width || 0);
|
|
2750
|
+
const viewportHeight = Number(viewport.height || 0);
|
|
2751
|
+
if (viewportWidth > 1 && viewportHeight > 1) {
|
|
2752
|
+
return { width: viewportWidth, height: viewportHeight };
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
const naturalWidth = Number(mirrorImg.naturalWidth || 0);
|
|
2756
|
+
const naturalHeight = Number(mirrorImg.naturalHeight || 0);
|
|
2757
|
+
if (naturalWidth > 1 && naturalHeight > 1) {
|
|
2758
|
+
return { width: naturalWidth, height: naturalHeight };
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2761
|
+
return { width: 1, height: 1 };
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
function sizeMirror() {
|
|
2765
|
+
const source = resolveMirrorSourceSize();
|
|
2766
|
+
const sourceW = source.width;
|
|
2767
|
+
const sourceH = source.height;
|
|
2768
|
+
const ratio = sourceH / sourceW;
|
|
2769
|
+
const availableW = Math.max(240, centerPanel.clientWidth - 44);
|
|
2770
|
+
const availableH = Math.max(320, centerPanel.clientHeight - 40);
|
|
2771
|
+
const maxW = Math.min(availableW, 520);
|
|
2772
|
+
const maxH = Math.min(availableH, window.innerHeight - 88);
|
|
2773
|
+
let w = maxW;
|
|
2774
|
+
let h = w * ratio;
|
|
2775
|
+
if (h > maxH) { h = maxH; w = h / ratio; }
|
|
2776
|
+
mirrorStage.style.width = w + 'px';
|
|
2777
|
+
mirrorStage.style.height = h + 'px';
|
|
2778
|
+
mirrorImg.style.width = w + 'px';
|
|
2779
|
+
mirrorImg.style.height = h + 'px';
|
|
2780
|
+
if (selectedUid) updateHighlight(selectedUid);
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
mirrorImg.addEventListener('load', () => {
|
|
2784
|
+
sizeMirror();
|
|
2785
|
+
if (selectedUid) updateHighlight(selectedUid);
|
|
2786
|
+
});
|
|
2787
|
+
|
|
2788
|
+
mirrorStage.addEventListener('click', e => {
|
|
2789
|
+
if (suppressNextClick) {
|
|
2790
|
+
suppressNextClick = false;
|
|
2791
|
+
return;
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
const point = mirrorEventPoint(e);
|
|
2795
|
+
const performTap = mirrorMode === 'interact' && !recording;
|
|
2796
|
+
if (recording || performTap) setBusy(true);
|
|
2797
|
+
send({ type: 'click', x: point.x, y: point.y, record: recording, perform: performTap });
|
|
2798
|
+
});
|
|
2799
|
+
|
|
2800
|
+
mirrorStage.addEventListener('wheel', e => {
|
|
2801
|
+
e.preventDefault();
|
|
2802
|
+
sendGesture(wheelSwipeGesture(e));
|
|
2803
|
+
}, { passive: false });
|
|
2804
|
+
|
|
2805
|
+
mirrorStage.addEventListener('pointerdown', e => {
|
|
2806
|
+
if (e.button !== 0) return;
|
|
2807
|
+
dragStart = {
|
|
2808
|
+
pointerId: e.pointerId,
|
|
2809
|
+
clientX: e.clientX,
|
|
2810
|
+
clientY: e.clientY,
|
|
2811
|
+
point: mirrorEventPoint(e)
|
|
2812
|
+
};
|
|
2813
|
+
mirrorStage.classList.add('dragging');
|
|
2814
|
+
mirrorStage.setPointerCapture?.(e.pointerId);
|
|
2815
|
+
});
|
|
2816
|
+
|
|
2817
|
+
mirrorStage.addEventListener('pointerup', e => {
|
|
2818
|
+
if (!dragStart || dragStart.pointerId !== e.pointerId) return;
|
|
2819
|
+
const start = dragStart;
|
|
2820
|
+
dragStart = null;
|
|
2821
|
+
mirrorStage.classList.remove('dragging');
|
|
2822
|
+
mirrorStage.releasePointerCapture?.(e.pointerId);
|
|
2823
|
+
|
|
2824
|
+
const moved = Math.hypot(e.clientX - start.clientX, e.clientY - start.clientY);
|
|
2825
|
+
if (moved < 10) return;
|
|
2826
|
+
|
|
2827
|
+
suppressNextClick = true;
|
|
2828
|
+
const end = mirrorEventPoint(e);
|
|
2829
|
+
sendGesture({
|
|
2830
|
+
start: start.point,
|
|
2831
|
+
end,
|
|
2832
|
+
durationMs: 350
|
|
2833
|
+
});
|
|
2834
|
+
});
|
|
2835
|
+
|
|
2836
|
+
mirrorStage.addEventListener('pointercancel', e => {
|
|
2837
|
+
if (!dragStart || dragStart.pointerId !== e.pointerId) return;
|
|
2838
|
+
dragStart = null;
|
|
2839
|
+
mirrorStage.classList.remove('dragging');
|
|
2840
|
+
});
|
|
2841
|
+
|
|
2842
|
+
function mirrorEventPoint(e) {
|
|
2843
|
+
const rect = mirrorStage.getBoundingClientRect();
|
|
2844
|
+
const px = Math.max(0, Math.min(rect.width, e.clientX - rect.left));
|
|
2845
|
+
const py = Math.max(0, Math.min(rect.height, e.clientY - rect.top));
|
|
2846
|
+
const dx = Math.round((px / Math.max(1, rect.width)) * (viewport.width || 1));
|
|
2847
|
+
const dy = Math.round((py / Math.max(1, rect.height)) * (viewport.height || 1));
|
|
2848
|
+
return {
|
|
2849
|
+
x: Math.max(0, Math.min(Math.max(0, viewport.width - 1), dx)),
|
|
2850
|
+
y: Math.max(0, Math.min(Math.max(0, viewport.height - 1), dy))
|
|
2851
|
+
};
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2854
|
+
function sendGesture(gesture) {
|
|
2855
|
+
const now = Date.now();
|
|
2856
|
+
if (gestureInFlight || now < nextGestureAllowedAt) {
|
|
2857
|
+
return false;
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
gestureInFlight = true;
|
|
2861
|
+
nextGestureAllowedAt = now + 450;
|
|
2862
|
+
setBusy(true);
|
|
2863
|
+
send({
|
|
2864
|
+
type: 'swipe',
|
|
2865
|
+
record: recording,
|
|
2866
|
+
gesture
|
|
2867
|
+
});
|
|
2868
|
+
|
|
2869
|
+
if (gestureReleaseTimer) {
|
|
2870
|
+
clearTimeout(gestureReleaseTimer);
|
|
2871
|
+
}
|
|
2872
|
+
gestureReleaseTimer = setTimeout(() => {
|
|
2873
|
+
gestureInFlight = false;
|
|
2874
|
+
gestureReleaseTimer = null;
|
|
2875
|
+
setBusy(false);
|
|
2876
|
+
}, 1800);
|
|
2877
|
+
|
|
2878
|
+
return true;
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
function wheelSwipeGesture(e) {
|
|
2882
|
+
const horizontal = Math.abs(e.deltaX) > Math.abs(e.deltaY);
|
|
2883
|
+
const width = viewport.width || 1;
|
|
2884
|
+
const height = viewport.height || 1;
|
|
2885
|
+
const cx = Math.round(width / 2);
|
|
2886
|
+
const cy = Math.round(height / 2);
|
|
2887
|
+
|
|
2888
|
+
if (horizontal) {
|
|
2889
|
+
const direction = e.deltaX >= 0 ? 1 : -1;
|
|
2890
|
+
const distance = Math.round(width * 0.38) * direction;
|
|
2891
|
+
const startX = Math.round(width * (direction > 0 ? 0.72 : 0.28));
|
|
2892
|
+
const endX = Math.max(10, Math.min(width - 10, startX - distance));
|
|
2893
|
+
return {
|
|
2894
|
+
start: { x: startX, y: cy },
|
|
2895
|
+
end: { x: endX, y: cy },
|
|
2896
|
+
durationMs: 350
|
|
2897
|
+
};
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
const direction = e.deltaY >= 0 ? 1 : -1;
|
|
2901
|
+
const distance = Math.round(height * 0.38) * direction;
|
|
2902
|
+
const startY = Math.round(height * (direction > 0 ? 0.72 : 0.32));
|
|
2903
|
+
const endY = Math.max(10, Math.min(height - 10, startY - distance));
|
|
2904
|
+
return {
|
|
2905
|
+
start: { x: cx, y: startY },
|
|
2906
|
+
end: { x: cx, y: endY },
|
|
2907
|
+
durationMs: 350
|
|
2908
|
+
};
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
function clampTreePanelHeight(nextHeight) {
|
|
2912
|
+
const splitterHeight = 10;
|
|
2913
|
+
const minTreeHeight = 220;
|
|
2914
|
+
const minCodeHeight = 160;
|
|
2915
|
+
const maxTreeHeight = Math.max(minTreeHeight, rightPanel.clientHeight - minCodeHeight - splitterHeight);
|
|
2916
|
+
return Math.min(Math.max(nextHeight, minTreeHeight), maxTreeHeight);
|
|
2917
|
+
}
|
|
2918
|
+
|
|
2919
|
+
function setTreePanelHeight(nextHeight) {
|
|
2920
|
+
const clamped = clampTreePanelHeight(nextHeight);
|
|
2921
|
+
rightPanel.style.gridTemplateRows = clamped + 'px 10px minmax(160px, 1fr)';
|
|
2922
|
+
}
|
|
2923
|
+
|
|
2924
|
+
function syncTreePanelHeight() {
|
|
2925
|
+
const current = rightPanel.style.gridTemplateRows;
|
|
2926
|
+
if (!current) {
|
|
2927
|
+
return;
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2930
|
+
const height = Number.parseFloat(current);
|
|
2931
|
+
if (Number.isFinite(height)) {
|
|
2932
|
+
setTreePanelHeight(height);
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
function readMainColumnWidths() {
|
|
2937
|
+
const columns = getComputedStyle(main).gridTemplateColumns.split(' ');
|
|
2938
|
+
return {
|
|
2939
|
+
left: Number.parseFloat(columns[0]) || 300,
|
|
2940
|
+
right: Number.parseFloat(columns[4]) || 340,
|
|
2941
|
+
};
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2944
|
+
function clampPanelWidths(leftWidth, rightWidth) {
|
|
2945
|
+
const splitterWidth = 20;
|
|
2946
|
+
const minLeft = 220;
|
|
2947
|
+
const minRight = 280;
|
|
2948
|
+
const minCenter = 360;
|
|
2949
|
+
const total = main.clientWidth;
|
|
2950
|
+
const maxLeft = Math.max(minLeft, total - minCenter - minRight - splitterWidth);
|
|
2951
|
+
const clampedLeft = Math.min(Math.max(leftWidth, minLeft), maxLeft);
|
|
2952
|
+
const maxRight = Math.max(minRight, total - minCenter - clampedLeft - splitterWidth);
|
|
2953
|
+
const clampedRight = Math.min(Math.max(rightWidth, minRight), maxRight);
|
|
2954
|
+
return { left: clampedLeft, right: clampedRight };
|
|
2955
|
+
}
|
|
2956
|
+
|
|
2957
|
+
function setMainColumnWidths(leftWidth, rightWidth) {
|
|
2958
|
+
const clamped = clampPanelWidths(leftWidth, rightWidth);
|
|
2959
|
+
main.style.gridTemplateColumns = clamped.left + 'px 10px minmax(0,1fr) 10px ' + clamped.right + 'px';
|
|
2960
|
+
}
|
|
2961
|
+
|
|
2962
|
+
function syncMainColumnWidths() {
|
|
2963
|
+
if (!main.style.gridTemplateColumns) {
|
|
2964
|
+
return;
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
const widths = readMainColumnWidths();
|
|
2968
|
+
setMainColumnWidths(widths.left, widths.right);
|
|
2969
|
+
}
|
|
2970
|
+
|
|
2971
|
+
function installColumnSplitter(splitter, side) {
|
|
2972
|
+
splitter.addEventListener('pointerdown', e => {
|
|
2973
|
+
e.preventDefault();
|
|
2974
|
+
const pointerId = e.pointerId;
|
|
2975
|
+
splitter.setPointerCapture(pointerId);
|
|
2976
|
+
document.body.style.cursor = 'col-resize';
|
|
2977
|
+
document.body.style.userSelect = 'none';
|
|
2978
|
+
|
|
2979
|
+
const startingWidths = readMainColumnWidths();
|
|
2980
|
+
const move = event => {
|
|
2981
|
+
const rect = main.getBoundingClientRect();
|
|
2982
|
+
if (side === 'left') {
|
|
2983
|
+
setMainColumnWidths(event.clientX - rect.left, startingWidths.right);
|
|
2984
|
+
} else {
|
|
2985
|
+
setMainColumnWidths(startingWidths.left, rect.right - event.clientX);
|
|
2986
|
+
}
|
|
2987
|
+
};
|
|
2988
|
+
|
|
2989
|
+
const finish = () => {
|
|
2990
|
+
window.removeEventListener('pointermove', move);
|
|
2991
|
+
window.removeEventListener('pointerup', finish);
|
|
2992
|
+
window.removeEventListener('pointercancel', finish);
|
|
2993
|
+
document.body.style.cursor = '';
|
|
2994
|
+
document.body.style.userSelect = '';
|
|
2995
|
+
if (splitter.hasPointerCapture(pointerId)) {
|
|
2996
|
+
splitter.releasePointerCapture(pointerId);
|
|
2997
|
+
}
|
|
2998
|
+
};
|
|
2999
|
+
|
|
3000
|
+
window.addEventListener('pointermove', move);
|
|
3001
|
+
window.addEventListener('pointerup', finish);
|
|
3002
|
+
window.addEventListener('pointercancel', finish);
|
|
3003
|
+
});
|
|
3004
|
+
|
|
3005
|
+
splitter.addEventListener('dblclick', () => {
|
|
3006
|
+
const defaults = side === 'left'
|
|
3007
|
+
? { left: 300, right: readMainColumnWidths().right }
|
|
3008
|
+
: { left: readMainColumnWidths().left, right: 340 };
|
|
3009
|
+
setMainColumnWidths(defaults.left, defaults.right);
|
|
3010
|
+
});
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
installColumnSplitter(leftColumnSplitter, 'left');
|
|
3014
|
+
installColumnSplitter(rightColumnSplitter, 'right');
|
|
3015
|
+
|
|
3016
|
+
rightSplitter.addEventListener('pointerdown', e => {
|
|
3017
|
+
e.preventDefault();
|
|
3018
|
+
const pointerId = e.pointerId;
|
|
3019
|
+
rightSplitter.setPointerCapture(pointerId);
|
|
3020
|
+
document.body.style.cursor = 'row-resize';
|
|
3021
|
+
document.body.style.userSelect = 'none';
|
|
3022
|
+
|
|
3023
|
+
const move = event => {
|
|
3024
|
+
const rect = rightPanel.getBoundingClientRect();
|
|
3025
|
+
setTreePanelHeight(event.clientY - rect.top);
|
|
3026
|
+
};
|
|
3027
|
+
|
|
3028
|
+
const finish = () => {
|
|
3029
|
+
window.removeEventListener('pointermove', move);
|
|
3030
|
+
window.removeEventListener('pointerup', finish);
|
|
3031
|
+
window.removeEventListener('pointercancel', finish);
|
|
3032
|
+
document.body.style.cursor = '';
|
|
3033
|
+
document.body.style.userSelect = '';
|
|
3034
|
+
if (rightSplitter.hasPointerCapture(pointerId)) {
|
|
3035
|
+
rightSplitter.releasePointerCapture(pointerId);
|
|
3036
|
+
}
|
|
3037
|
+
};
|
|
3038
|
+
|
|
3039
|
+
window.addEventListener('pointermove', move);
|
|
3040
|
+
window.addEventListener('pointerup', finish);
|
|
3041
|
+
window.addEventListener('pointercancel', finish);
|
|
3042
|
+
});
|
|
3043
|
+
|
|
3044
|
+
rightSplitter.addEventListener('dblclick', () => {
|
|
3045
|
+
rightPanel.style.gridTemplateRows = '';
|
|
3046
|
+
});
|
|
3047
|
+
|
|
3048
|
+
window.addEventListener('resize', () => {
|
|
3049
|
+
sizeMirror();
|
|
3050
|
+
syncTreePanelHeight();
|
|
3051
|
+
syncMainColumnWidths();
|
|
3052
|
+
});
|
|
3053
|
+
|
|
3054
|
+
// ── Tabs ───────────────────────────────────────────────────────────────────
|
|
3055
|
+
document.querySelectorAll('#code-tabs .code-tab').forEach(tab => {
|
|
3056
|
+
tab.addEventListener('click', () => {
|
|
3057
|
+
document.querySelectorAll('#code-tabs .code-tab').forEach(t => t.classList.remove('active'));
|
|
3058
|
+
tab.classList.add('active');
|
|
3059
|
+
activeTab = tab.dataset.tab;
|
|
3060
|
+
codeView.style.display = activeTab === 'code' ? 'flex' : 'none';
|
|
3061
|
+
stepsView.style.display = activeTab === 'steps' ? 'flex' : 'none';
|
|
3062
|
+
});
|
|
3063
|
+
});
|
|
3064
|
+
|
|
3065
|
+
document.querySelectorAll('#code-lang-tabs .code-tab').forEach(btn => {
|
|
3066
|
+
btn.addEventListener('click', () => {
|
|
3067
|
+
document.querySelectorAll('#code-lang-tabs .code-tab').forEach(b => b.classList.remove('active'));
|
|
3068
|
+
btn.classList.add('active');
|
|
3069
|
+
codeLang = btn.dataset.lang;
|
|
3070
|
+
updateCodeBlock();
|
|
3071
|
+
});
|
|
3072
|
+
});
|
|
3073
|
+
|
|
3074
|
+
if (codeScriptCopyBtn) {
|
|
3075
|
+
codeScriptCopyBtn.addEventListener('click', async (event) => {
|
|
3076
|
+
event.stopPropagation();
|
|
3077
|
+
const text = codeBlock.textContent || '';
|
|
3078
|
+
const copied = await copyText(text);
|
|
3079
|
+
if (!copied) return;
|
|
3080
|
+
codeScriptCopyBtn.classList.add('copied');
|
|
3081
|
+
codeScriptCopyBtn.title = 'Copied';
|
|
3082
|
+
clearTimeout(codeScriptCopyBtn._copyResetTimer);
|
|
3083
|
+
codeScriptCopyBtn._copyResetTimer = setTimeout(() => {
|
|
3084
|
+
codeScriptCopyBtn.classList.remove('copied');
|
|
3085
|
+
codeScriptCopyBtn.title = 'Copy script';
|
|
3086
|
+
}, 1200);
|
|
3087
|
+
});
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3090
|
+
// ── Code block ────────────────────────────────────────────────────────────
|
|
3091
|
+
function updateCodeBlock() {
|
|
3092
|
+
// Generate code client-side from steps for display; actual export goes server-side
|
|
3093
|
+
if (!steps.length) { codeBlock.textContent = '// No steps recorded yet'; return; }
|
|
3094
|
+
const lines = steps.map(s => {
|
|
3095
|
+
const locator = normalizeLocatorCode(s.locator);
|
|
3096
|
+
if (s.action === 'swipe' && s.gesture) return ' await device.swipe(' + JSON.stringify(s.gesture) + ');';
|
|
3097
|
+
if (s.action === 'tapPoint' && s.point) return ' await device.tap(' + JSON.stringify(s.point) + ');';
|
|
3098
|
+
if (s.action === 'fill') return ' await device.' + locator + '.fill(' + JSON.stringify(s.value || '') + ');';
|
|
3099
|
+
if (s.action === 'expect') {
|
|
3100
|
+
const actual = 'device.' + locator;
|
|
3101
|
+
switch (s.assertion || 'visible') {
|
|
3102
|
+
case 'text':
|
|
3103
|
+
return ' await expect(' + actual + ').toHaveText(' + JSON.stringify(s.value || '') + ');';
|
|
3104
|
+
case 'containsText':
|
|
3105
|
+
return ' await expect(' + actual + ').toContainText(' + JSON.stringify(s.value || '') + ');';
|
|
3106
|
+
case 'value':
|
|
3107
|
+
return ' await expect(' + actual + ').toHaveValue(' + JSON.stringify(s.value || '') + ');';
|
|
3108
|
+
case 'label':
|
|
3109
|
+
return ' await expect(' + actual + ').toHaveLabel(' + JSON.stringify(s.value || '') + ');';
|
|
3110
|
+
case 'type':
|
|
3111
|
+
return ' await expect(' + actual + ').toHaveType(' + JSON.stringify(s.value || '') + ');';
|
|
3112
|
+
default:
|
|
3113
|
+
return ' await expect(' + actual + ').toBeVisible();';
|
|
3114
|
+
}
|
|
3115
|
+
}
|
|
3116
|
+
return ' await device.' + locator + '.tap();';
|
|
3117
|
+
});
|
|
3118
|
+
const imp = codeLang === 'typescript'
|
|
3119
|
+
? "import { test, expect } from '@astur-mobile/test';"
|
|
3120
|
+
: "const { test, expect } = require('@astur-mobile/test');";
|
|
3121
|
+
const body = imp + "\\n\\ntest('recorded flow', async ({ device }) => {\\n" + lines.join('\\n') + "\\n});\\n";
|
|
3122
|
+
codeBlock.textContent = body;
|
|
3123
|
+
}
|
|
3124
|
+
|
|
3125
|
+
// ── Steps ─────────────────────────────────────────────────────────────────
|
|
3126
|
+
function renderStep(step) {
|
|
3127
|
+
const tr = document.createElement('tr');
|
|
3128
|
+
const locator = step.action === 'swipe' && step.gesture
|
|
3129
|
+
? formatGesture(step.gesture)
|
|
3130
|
+
: step.action === 'tapPoint' && step.point
|
|
3131
|
+
? formatPoint(step.point)
|
|
3132
|
+
: step.locator;
|
|
3133
|
+
tr.innerHTML = '<td>' + (step.index+1) + '</td><td>' + escHtml(formatStepAction(step)) + '</td><td>' + escHtml(locator) + '</td><td>' + escHtml(step.value || '') + '</td>';
|
|
3134
|
+
stepsBody.appendChild(tr);
|
|
3135
|
+
}
|
|
3136
|
+
|
|
3137
|
+
function formatStepAction(step) {
|
|
3138
|
+
if (step.action === 'swipe') return 'swipe';
|
|
3139
|
+
if (step.action === 'tapPoint') return 'tap.point';
|
|
3140
|
+
if (step.action !== 'expect') return step.action;
|
|
3141
|
+
switch (step.assertion || 'visible') {
|
|
3142
|
+
case 'text':
|
|
3143
|
+
return 'expect.text';
|
|
3144
|
+
case 'containsText':
|
|
3145
|
+
return 'expect.containsText';
|
|
3146
|
+
case 'value':
|
|
3147
|
+
return 'expect.value';
|
|
3148
|
+
case 'label':
|
|
3149
|
+
return 'expect.label';
|
|
3150
|
+
case 'type':
|
|
3151
|
+
return 'expect.type';
|
|
3152
|
+
default:
|
|
3153
|
+
return 'expect.visible';
|
|
3154
|
+
}
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
function formatGesture(gesture) {
|
|
3158
|
+
return '(' + gesture.start.x + ',' + gesture.start.y + ') -> (' + gesture.end.x + ',' + gesture.end.y + ')';
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
function formatPoint(point) {
|
|
3162
|
+
return '(' + point.x + ',' + point.y + ')';
|
|
3163
|
+
}
|
|
3164
|
+
|
|
3165
|
+
function renderAllSteps() {
|
|
3166
|
+
stepsBody.innerHTML = '';
|
|
3167
|
+
for (const s of steps) renderStep(s);
|
|
3168
|
+
}
|
|
3169
|
+
|
|
3170
|
+
addTapBtn.addEventListener('click', () => {
|
|
3171
|
+
const locator = activeLocator;
|
|
3172
|
+
if (!locator || locator === '—') return;
|
|
3173
|
+
send({ type: 'add_step', action: 'tap', locator });
|
|
3174
|
+
});
|
|
3175
|
+
|
|
3176
|
+
addFillBtn.addEventListener('click', () => {
|
|
3177
|
+
const locator = activeLocator;
|
|
3178
|
+
if (!locator || locator === '—' || !isFillableSelectedNode()) return;
|
|
3179
|
+
openStepComposer('fill');
|
|
3180
|
+
});
|
|
3181
|
+
|
|
3182
|
+
addExpectBtn.addEventListener('click', () => {
|
|
3183
|
+
const locator = activeLocator;
|
|
3184
|
+
if (!locator || locator === '—') return;
|
|
3185
|
+
openStepComposer('expect');
|
|
3186
|
+
});
|
|
3187
|
+
|
|
3188
|
+
clearBtn.addEventListener('click', () => send({ type: 'clear_steps' }));
|
|
3189
|
+
|
|
3190
|
+
function openStepComposer(mode) {
|
|
3191
|
+
composerMode = mode;
|
|
3192
|
+
const node = getSelectedNode();
|
|
3193
|
+
stepComposer.classList.add('active');
|
|
3194
|
+
composerAssertion.style.display = mode === 'expect' ? '' : 'none';
|
|
3195
|
+
composerValue.style.display = mode === 'expect' && composerAssertion.value === 'visible' ? 'none' : '';
|
|
3196
|
+
|
|
3197
|
+
if (mode === 'fill') {
|
|
3198
|
+
composerValue.placeholder = 'value to fill';
|
|
3199
|
+
composerValue.value = '';
|
|
3200
|
+
} else {
|
|
3201
|
+
const defaultAssertion = node && node.text ? 'text' : node && node.value ? 'value' : node && node.label ? 'label' : 'visible';
|
|
3202
|
+
composerAssertion.value = defaultAssertion;
|
|
3203
|
+
syncComposerValue();
|
|
3204
|
+
}
|
|
3205
|
+
|
|
3206
|
+
composerValue.focus();
|
|
3207
|
+
}
|
|
3208
|
+
|
|
3209
|
+
function closeStepComposer() {
|
|
3210
|
+
composerMode = null;
|
|
3211
|
+
stepComposer.classList.remove('active');
|
|
3212
|
+
}
|
|
3213
|
+
|
|
3214
|
+
function syncComposerValue() {
|
|
3215
|
+
const node = getSelectedNode();
|
|
3216
|
+
const assertion = composerAssertion.value;
|
|
3217
|
+
const needsValue = assertion !== 'visible';
|
|
3218
|
+
composerValue.style.display = needsValue ? '' : 'none';
|
|
3219
|
+
composerValue.placeholder = assertion === 'containsText' ? 'expected substring' : 'expected value';
|
|
3220
|
+
composerValue.value = needsValue
|
|
3221
|
+
? assertion === 'type'
|
|
3222
|
+
? (node && node.type) || ''
|
|
3223
|
+
: assertion === 'label'
|
|
3224
|
+
? ((node && node.label) || (node && node.text) || '')
|
|
3225
|
+
: assertion === 'value'
|
|
3226
|
+
? ((node && node.value) || (node && node.text) || '')
|
|
3227
|
+
: ((node && node.text) || (node && node.value) || (node && node.label) || '')
|
|
3228
|
+
: '';
|
|
3229
|
+
}
|
|
3230
|
+
|
|
3231
|
+
composerAssertion.addEventListener('change', syncComposerValue);
|
|
3232
|
+
composerCancelBtn.addEventListener('click', closeStepComposer);
|
|
3233
|
+
composerAddBtn.addEventListener('click', () => {
|
|
3234
|
+
const locator = activeLocator;
|
|
3235
|
+
if (!locator || locator === '—' || !composerMode) return;
|
|
3236
|
+
if (composerMode === 'fill') {
|
|
3237
|
+
send({ type: 'add_step', action: 'fill', locator, value: composerValue.value });
|
|
3238
|
+
} else {
|
|
3239
|
+
const assertion = composerAssertion.value;
|
|
3240
|
+
send({
|
|
3241
|
+
type: 'add_step',
|
|
3242
|
+
action: 'expect',
|
|
3243
|
+
locator,
|
|
3244
|
+
assertion,
|
|
3245
|
+
value: assertion === 'visible' ? undefined : composerValue.value
|
|
3246
|
+
});
|
|
3247
|
+
}
|
|
3248
|
+
closeStepComposer();
|
|
3249
|
+
});
|
|
3250
|
+
|
|
3251
|
+
// ── Direct Actions ────────────────────────────────────────────────────────
|
|
3252
|
+
function runSelectedElementTap() {
|
|
3253
|
+
if (!activeSelector) return;
|
|
3254
|
+
showDeviceStatus('Tapping selected element...', 'pending');
|
|
3255
|
+
setBusy(true);
|
|
3256
|
+
send({ type: 'direct_action', action: 'tap', selector: activeSelector });
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
function runSelectedElementFill() {
|
|
3260
|
+
if (!activeSelector || !isFillableSelectedNode()) return;
|
|
3261
|
+
showDeviceStatus('Filling selected element...', 'pending');
|
|
3262
|
+
setBusy(true);
|
|
3263
|
+
send({
|
|
3264
|
+
type: 'direct_action',
|
|
3265
|
+
action: 'fill',
|
|
3266
|
+
selector: activeSelector,
|
|
3267
|
+
value: elementFillInput.value
|
|
3268
|
+
});
|
|
3269
|
+
}
|
|
3270
|
+
|
|
3271
|
+
inspectModeBtn.addEventListener('click', () => setMirrorMode('inspect'));
|
|
3272
|
+
interactModeBtn.addEventListener('click', () => setMirrorMode('interact'));
|
|
3273
|
+
elementTapBtn.addEventListener('click', runSelectedElementTap);
|
|
3274
|
+
elementFillBtn.addEventListener('click', runSelectedElementFill);
|
|
3275
|
+
elementFillInput.addEventListener('keydown', event => {
|
|
3276
|
+
if (event.key !== 'Enter' || elementFillBtn.disabled) return;
|
|
3277
|
+
event.preventDefault();
|
|
3278
|
+
runSelectedElementFill();
|
|
3279
|
+
});
|
|
3280
|
+
setMirrorMode('inspect');
|
|
3281
|
+
|
|
3282
|
+
// ── Record & Export ────────────────────────────────────────────────────────
|
|
3283
|
+
recordBtn.addEventListener('click', () => send({ type: 'record_toggle' }));
|
|
3284
|
+
|
|
3285
|
+
exportBtn.addEventListener('click', () => exportCode());
|
|
3286
|
+
document.addEventListener('keydown', e => {
|
|
3287
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); exportCode(); }
|
|
3288
|
+
});
|
|
3289
|
+
|
|
3290
|
+
function exportCode() {
|
|
3291
|
+
send({ type: 'export', lang: codeLang });
|
|
3292
|
+
// Also trigger download
|
|
3293
|
+
const content = codeBlock.textContent;
|
|
3294
|
+
const ext = codeLang === 'javascript' ? 'js' : 'ts';
|
|
3295
|
+
const blob = new Blob([content], { type: 'text/plain' });
|
|
3296
|
+
const a = document.createElement('a');
|
|
3297
|
+
a.href = URL.createObjectURL(blob);
|
|
3298
|
+
a.download = 'astur-test.' + ext;
|
|
3299
|
+
a.click();
|
|
3300
|
+
URL.revokeObjectURL(a.href);
|
|
3301
|
+
}
|
|
3302
|
+
|
|
3303
|
+
deviceControls.addEventListener('click', event => {
|
|
3304
|
+
event.stopPropagation();
|
|
3305
|
+
});
|
|
3306
|
+
|
|
3307
|
+
deviceSwitcher.addEventListener('click', event => {
|
|
3308
|
+
event.stopPropagation();
|
|
3309
|
+
});
|
|
3310
|
+
|
|
3311
|
+
deviceChip.addEventListener('click', event => {
|
|
3312
|
+
event.preventDefault();
|
|
3313
|
+
event.stopPropagation();
|
|
3314
|
+
toggleDeviceSwitcher();
|
|
3315
|
+
});
|
|
3316
|
+
|
|
3317
|
+
deviceMenuBtn.addEventListener('click', event => {
|
|
3318
|
+
event.preventDefault();
|
|
3319
|
+
event.stopPropagation();
|
|
3320
|
+
toggleDeviceMenu();
|
|
3321
|
+
});
|
|
3322
|
+
|
|
3323
|
+
deviceMenu.addEventListener('click', event => {
|
|
3324
|
+
const button = event.target.closest('.device-action-btn');
|
|
3325
|
+
if (!button) return;
|
|
3326
|
+
const action = button.dataset.action;
|
|
3327
|
+
const label = button.dataset.label || 'Action';
|
|
3328
|
+
if (!action) return;
|
|
3329
|
+
closeDeviceMenu();
|
|
3330
|
+
showDeviceStatus('Running ' + label + '...', 'pending');
|
|
3331
|
+
send({ type: 'device_action', action });
|
|
3332
|
+
});
|
|
3333
|
+
|
|
3334
|
+
function appIdentifier() {
|
|
3335
|
+
return appIdentifierInput.value.trim();
|
|
3336
|
+
}
|
|
3337
|
+
|
|
3338
|
+
launchAppBtn.addEventListener('click', () => {
|
|
3339
|
+
const identifier = appIdentifier();
|
|
3340
|
+
if (!identifier) return showDeviceStatus('Enter package or bundle id', 'error');
|
|
3341
|
+
closeDeviceMenu();
|
|
3342
|
+
setBusy(true);
|
|
3343
|
+
send({ type: 'app_action', action: 'launch', identifier });
|
|
3344
|
+
});
|
|
3345
|
+
|
|
3346
|
+
installAppBtn.addEventListener('click', async () => {
|
|
3347
|
+
const spec = deviceInstallSpec(currentDevice);
|
|
3348
|
+
let selection;
|
|
3349
|
+
try {
|
|
3350
|
+
selection = pendingInstallSelection || createInstallSelectionFromFiles(appUploadInput.files, spec);
|
|
3351
|
+
} catch (error) {
|
|
3352
|
+
return showDeviceStatus((error && error.message) || spec.invalidMessage, 'error');
|
|
3353
|
+
}
|
|
3354
|
+
if (!selection) return showDeviceStatus(spec.emptyMessage, 'error');
|
|
3355
|
+
closeDeviceMenu();
|
|
3356
|
+
setBusy(true);
|
|
3357
|
+
try {
|
|
3358
|
+
await uploadInstallSelection(selection);
|
|
3359
|
+
appUploadInput.value = '';
|
|
3360
|
+
clearPendingInstallSelection();
|
|
3361
|
+
} catch (error) {
|
|
3362
|
+
setBusy(false);
|
|
3363
|
+
showDeviceStatus((error && error.message) || String(error), 'error');
|
|
3364
|
+
}
|
|
3365
|
+
});
|
|
3366
|
+
|
|
3367
|
+
appUploadInput.addEventListener('change', () => {
|
|
3368
|
+
const spec = deviceInstallSpec(currentDevice);
|
|
3369
|
+
try {
|
|
3370
|
+
const selection = createInstallSelectionFromFiles(appUploadInput.files, spec);
|
|
3371
|
+
if (!selection) {
|
|
3372
|
+
clearPendingInstallSelection();
|
|
3373
|
+
return;
|
|
3374
|
+
}
|
|
3375
|
+
setPendingInstallSelection(selection);
|
|
3376
|
+
appUploadInput.value = '';
|
|
3377
|
+
} catch (error) {
|
|
3378
|
+
appUploadInput.value = '';
|
|
3379
|
+
clearPendingInstallSelection();
|
|
3380
|
+
showDeviceStatus((error && error.message) || spec.invalidMessage, 'error');
|
|
3381
|
+
}
|
|
3382
|
+
});
|
|
3383
|
+
|
|
3384
|
+
['dragenter', 'dragover'].forEach((eventName) => {
|
|
3385
|
+
appUploadRow.addEventListener(eventName, (event) => {
|
|
3386
|
+
event.preventDefault();
|
|
3387
|
+
event.stopPropagation();
|
|
3388
|
+
appUploadRow.classList.add('drag-active');
|
|
3389
|
+
if (event.dataTransfer) {
|
|
3390
|
+
event.dataTransfer.dropEffect = 'copy';
|
|
3391
|
+
}
|
|
3392
|
+
});
|
|
3393
|
+
});
|
|
3394
|
+
|
|
3395
|
+
['dragleave', 'dragend'].forEach((eventName) => {
|
|
3396
|
+
appUploadRow.addEventListener(eventName, (event) => {
|
|
3397
|
+
event.preventDefault();
|
|
3398
|
+
event.stopPropagation();
|
|
3399
|
+
appUploadRow.classList.remove('drag-active');
|
|
3400
|
+
});
|
|
3401
|
+
});
|
|
3402
|
+
|
|
3403
|
+
appUploadRow.addEventListener('drop', async (event) => {
|
|
3404
|
+
event.preventDefault();
|
|
3405
|
+
event.stopPropagation();
|
|
3406
|
+
appUploadRow.classList.remove('drag-active');
|
|
3407
|
+
const spec = deviceInstallSpec(currentDevice);
|
|
3408
|
+
try {
|
|
3409
|
+
const selection = await createInstallSelectionFromDrop(event.dataTransfer, spec);
|
|
3410
|
+
if (!selection) {
|
|
3411
|
+
throw new Error(spec.emptyMessage);
|
|
3412
|
+
}
|
|
3413
|
+
appUploadInput.value = '';
|
|
3414
|
+
setPendingInstallSelection(selection);
|
|
3415
|
+
} catch (error) {
|
|
3416
|
+
clearPendingInstallSelection();
|
|
3417
|
+
showDeviceStatus((error && error.message) || spec.invalidMessage, 'error');
|
|
3418
|
+
}
|
|
3419
|
+
});
|
|
3420
|
+
|
|
3421
|
+
clearDataBtn.addEventListener('click', () => {
|
|
3422
|
+
const identifier = appIdentifier();
|
|
3423
|
+
if (!identifier) return showDeviceStatus('Enter package or bundle id', 'error');
|
|
3424
|
+
closeDeviceMenu();
|
|
3425
|
+
setBusy(true);
|
|
3426
|
+
send({ type: 'app_action', action: 'clearData', identifier });
|
|
3427
|
+
});
|
|
3428
|
+
|
|
3429
|
+
clearCacheBtn.addEventListener('click', () => {
|
|
3430
|
+
const identifier = appIdentifier();
|
|
3431
|
+
if (!identifier) return showDeviceStatus('Enter package or bundle id', 'error');
|
|
3432
|
+
closeDeviceMenu();
|
|
3433
|
+
setBusy(true);
|
|
3434
|
+
send({ type: 'app_action', action: 'clearCache', identifier });
|
|
3435
|
+
});
|
|
3436
|
+
|
|
3437
|
+
grantPermissionBtn.addEventListener('click', () => {
|
|
3438
|
+
const identifier = appIdentifier();
|
|
3439
|
+
const permission = permissionInput.value.trim();
|
|
3440
|
+
if (!identifier || !permission) return showDeviceStatus('Enter app id and permission', 'error');
|
|
3441
|
+
closeDeviceMenu();
|
|
3442
|
+
setBusy(true);
|
|
3443
|
+
send({ type: 'app_action', action: 'grantPermission', identifier, permission });
|
|
3444
|
+
});
|
|
3445
|
+
|
|
3446
|
+
revokePermissionBtn.addEventListener('click', () => {
|
|
3447
|
+
const identifier = appIdentifier();
|
|
3448
|
+
const permission = permissionInput.value.trim();
|
|
3449
|
+
if (!identifier || !permission) return showDeviceStatus('Enter app id and permission', 'error');
|
|
3450
|
+
closeDeviceMenu();
|
|
3451
|
+
setBusy(true);
|
|
3452
|
+
send({ type: 'app_action', action: 'revokePermission', identifier, permission });
|
|
3453
|
+
});
|
|
3454
|
+
|
|
3455
|
+
document.addEventListener('click', () => {
|
|
3456
|
+
closeDeviceMenu();
|
|
3457
|
+
closeDeviceSwitcher();
|
|
3458
|
+
});
|
|
3459
|
+
|
|
3460
|
+
// ── Tree search ────────────────────────────────────────────────────────────
|
|
3461
|
+
treeSearch.addEventListener('input', renderTree);
|
|
3462
|
+
|
|
3463
|
+
// ── Utilities ─────────────────────────────────────────────────────────────
|
|
3464
|
+
function escHtml(s) {
|
|
3465
|
+
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
3466
|
+
}
|
|
3467
|
+
|
|
3468
|
+
function normalizeLocatorCode(locator) {
|
|
3469
|
+
return String(locator || '').trim().replace(/^device\\./, '');
|
|
3470
|
+
}
|
|
3471
|
+
|
|
3472
|
+
// ── Init ───────────────────────────────────────────────────────────────────
|
|
3473
|
+
sizeMirror();
|
|
3474
|
+
updateStepControls();
|
|
3475
|
+
renderDeviceHeader();
|
|
3476
|
+
renderDeviceList();
|
|
3477
|
+
connectWs();
|
|
3478
|
+
|
|
3479
|
+
})();
|
|
3480
|
+
</script>
|
|
3481
|
+
</body>
|
|
3482
|
+
</html>`;
|
|
3483
|
+
}
|
|
3484
|
+
function escHtml(s) {
|
|
3485
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
3486
|
+
}
|
|
3487
|
+
//# sourceMappingURL=inspectorServer.js.map
|