@taqwright/taqwright 0.0.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +108 -0
  3. package/dist/auto-appium.d.ts +12 -0
  4. package/dist/auto-appium.js +77 -0
  5. package/dist/bin/branding.d.ts +6 -0
  6. package/dist/bin/branding.js +22 -0
  7. package/dist/bin/index.d.ts +2 -0
  8. package/dist/bin/index.js +321 -0
  9. package/dist/bin/init.d.ts +26 -0
  10. package/dist/bin/init.js +902 -0
  11. package/dist/bin/inspect.d.ts +9 -0
  12. package/dist/bin/inspect.js +91 -0
  13. package/dist/bin/report-branding.d.ts +2 -0
  14. package/dist/bin/report-branding.js +42 -0
  15. package/dist/branding-assets.d.ts +1 -0
  16. package/dist/branding-assets.js +1 -0
  17. package/dist/capabilities-helpers.d.ts +7 -0
  18. package/dist/capabilities-helpers.js +14 -0
  19. package/dist/capabilities.d.ts +6 -0
  20. package/dist/capabilities.js +86 -0
  21. package/dist/config.d.ts +17 -0
  22. package/dist/config.js +235 -0
  23. package/dist/discovery-setup.d.ts +1 -0
  24. package/dist/discovery-setup.js +61 -0
  25. package/dist/discovery.d.ts +17 -0
  26. package/dist/discovery.js +55 -0
  27. package/dist/docs/configuration.html +376 -0
  28. package/dist/docs/custom-reporters.html +265 -0
  29. package/dist/docs/docker.html +339 -0
  30. package/dist/docs/docs.js +173 -0
  31. package/dist/docs/generating-tests.html +161 -0
  32. package/dist/docs/images/taqwright-html-report.png +0 -0
  33. package/dist/docs/index.html +13 -0
  34. package/dist/docs/installation.html +686 -0
  35. package/dist/docs/parallel.html +271 -0
  36. package/dist/docs/running-tests.html +385 -0
  37. package/dist/docs/styles.css +460 -0
  38. package/dist/docs/writing-tests.html +565 -0
  39. package/dist/doctor.d.ts +33 -0
  40. package/dist/doctor.js +508 -0
  41. package/dist/expect.d.ts +38 -0
  42. package/dist/expect.js +96 -0
  43. package/dist/fixture/artifact-mode.d.ts +2 -0
  44. package/dist/fixture/artifact-mode.js +7 -0
  45. package/dist/fixture/index.d.ts +15 -0
  46. package/dist/fixture/index.js +324 -0
  47. package/dist/images/taqwright-html-report.png +0 -0
  48. package/dist/images/taqwright_favicon.png +0 -0
  49. package/dist/images/taqwright_logo.png +0 -0
  50. package/dist/index.d.ts +9 -0
  51. package/dist/index.js +7 -0
  52. package/dist/inspector/codegen-appium.d.ts +3 -0
  53. package/dist/inspector/codegen-appium.js +228 -0
  54. package/dist/inspector/devices.d.ts +41 -0
  55. package/dist/inspector/devices.js +422 -0
  56. package/dist/inspector/locator-suggester.d.ts +23 -0
  57. package/dist/inspector/locator-suggester.js +539 -0
  58. package/dist/inspector/recorder.d.ts +128 -0
  59. package/dist/inspector/recorder.js +162 -0
  60. package/dist/inspector/server.d.ts +39 -0
  61. package/dist/inspector/server.js +1210 -0
  62. package/dist/inspector/session.d.ts +84 -0
  63. package/dist/inspector/session.js +262 -0
  64. package/dist/inspector/ui.d.ts +1 -0
  65. package/dist/inspector/ui.js +5508 -0
  66. package/dist/keys.d.ts +3 -0
  67. package/dist/keys.js +28 -0
  68. package/dist/locator/index.d.ts +206 -0
  69. package/dist/locator/index.js +1506 -0
  70. package/dist/logger.d.ts +5 -0
  71. package/dist/logger.js +5 -0
  72. package/dist/mobile/index.d.ts +130 -0
  73. package/dist/mobile/index.js +762 -0
  74. package/dist/network/android.d.ts +5 -0
  75. package/dist/network/android.js +87 -0
  76. package/dist/network/ca.d.ts +10 -0
  77. package/dist/network/ca.js +136 -0
  78. package/dist/network/har.d.ts +90 -0
  79. package/dist/network/har.js +101 -0
  80. package/dist/network/host-proxy.d.ts +16 -0
  81. package/dist/network/host-proxy.js +134 -0
  82. package/dist/network/index.d.ts +26 -0
  83. package/dist/network/index.js +105 -0
  84. package/dist/network/ios-sim.d.ts +3 -0
  85. package/dist/network/ios-sim.js +29 -0
  86. package/dist/network/proxy.d.ts +13 -0
  87. package/dist/network/proxy.js +310 -0
  88. package/dist/providers/appium.d.ts +23 -0
  89. package/dist/providers/appium.js +288 -0
  90. package/dist/providers/browserstack/index.d.ts +5 -0
  91. package/dist/providers/browserstack/index.js +77 -0
  92. package/dist/providers/browserstack/utils.d.ts +1 -0
  93. package/dist/providers/browserstack/utils.js +6 -0
  94. package/dist/providers/cloud.d.ts +53 -0
  95. package/dist/providers/cloud.js +117 -0
  96. package/dist/providers/emulator/index.d.ts +8 -0
  97. package/dist/providers/emulator/index.js +47 -0
  98. package/dist/providers/index.d.ts +10 -0
  99. package/dist/providers/index.js +33 -0
  100. package/dist/providers/lambdatest/index.d.ts +28 -0
  101. package/dist/providers/lambdatest/index.js +99 -0
  102. package/dist/providers/lambdatest/utils.d.ts +1 -0
  103. package/dist/providers/lambdatest/utils.js +6 -0
  104. package/dist/providers/local/index.d.ts +9 -0
  105. package/dist/providers/local/index.js +53 -0
  106. package/dist/providers/local-session.d.ts +16 -0
  107. package/dist/providers/local-session.js +55 -0
  108. package/dist/setup/archive.d.ts +2 -0
  109. package/dist/setup/archive.js +43 -0
  110. package/dist/setup/avd.d.ts +12 -0
  111. package/dist/setup/avd.js +103 -0
  112. package/dist/setup/index.d.ts +6 -0
  113. package/dist/setup/index.js +55 -0
  114. package/dist/setup/install-android.d.ts +2 -0
  115. package/dist/setup/install-android.js +70 -0
  116. package/dist/setup/install-appium.d.ts +1 -0
  117. package/dist/setup/install-appium.js +64 -0
  118. package/dist/setup/install-jdk.d.ts +1 -0
  119. package/dist/setup/install-jdk.js +58 -0
  120. package/dist/setup/paths.d.ts +16 -0
  121. package/dist/setup/paths.js +88 -0
  122. package/dist/setup/spawn-tool.d.ts +3 -0
  123. package/dist/setup/spawn-tool.js +11 -0
  124. package/dist/tracer/index.d.ts +34 -0
  125. package/dist/tracer/index.js +687 -0
  126. package/dist/tracer/proxy.d.ts +3 -0
  127. package/dist/tracer/proxy.js +60 -0
  128. package/dist/types/index.d.ts +189 -0
  129. package/dist/types/index.js +6 -0
  130. package/dist/utils.d.ts +2 -0
  131. package/dist/utils.js +37 -0
  132. package/package.json +79 -0
@@ -0,0 +1,1210 @@
1
+ import { createServer } from 'node:http';
2
+ import { readFileSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { dirname, resolve } from 'node:path';
5
+ import { W3C_ELEMENT_KEY, } from '../types/index.js';
6
+ import { buildLocatorFromDescriptor } from '../locator/index.js';
7
+ import { toStepsPython, toStepsJava } from './codegen-appium.js';
8
+ import { runDoctorChecks } from '../doctor.js';
9
+ import { isPortOpen } from '../auto-appium.js';
10
+ import { listDevices, startAndroidEmulator, stopAndroidEmulator, startIosSimulator, stopIosSimulator, } from './devices.js';
11
+ import { generateCandidates, makeNthSuggestion, selectBestPerCategory, pickRecommended, } from './locator-suggester.js';
12
+ import { INSPECTOR_HTML } from './ui.js';
13
+ import { InspectorSession } from './session.js';
14
+ export async function startInspectorServer(opts) {
15
+ const session = new InspectorSession(opts.defaults);
16
+ if (opts.attach) {
17
+ session.attachDriver(opts.attach.driver, opts.attach.platform, opts.attach.capabilities);
18
+ }
19
+ const server = createServer((req, res) => {
20
+ handle(req, res, session).catch((err) => {
21
+ const msg = err.message ?? String(err);
22
+ console.error('inspector: handler error:', msg);
23
+ if (!res.headersSent) {
24
+ res.writeHead(500, { 'content-type': 'application/json' });
25
+ }
26
+ try {
27
+ res.end(JSON.stringify({ error: msg }));
28
+ }
29
+ catch {
30
+ }
31
+ });
32
+ });
33
+ await new Promise((resolveListen, reject) => {
34
+ server.once('error', reject);
35
+ server.listen(opts.port, opts.host, () => {
36
+ server.off('error', reject);
37
+ resolveListen();
38
+ });
39
+ });
40
+ const addr = server.address();
41
+ const boundPort = addr && typeof addr === 'object' ? addr.port : opts.port;
42
+ const url = `http://${opts.host === '0.0.0.0' ? 'localhost' : opts.host}:${boundPort}`;
43
+ return {
44
+ url,
45
+ port: boundPort,
46
+ session,
47
+ close: () => new Promise((resolveClose) => closeServer(server, resolveClose)),
48
+ };
49
+ }
50
+ async function handle(req, res, session) {
51
+ const url = req.url ?? '/';
52
+ const method = req.method ?? 'GET';
53
+ if (method === 'GET' && (url === '/' || url === '/index.html')) {
54
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
55
+ res.end(INSPECTOR_HTML);
56
+ return;
57
+ }
58
+ if (method === 'GET' && url === '/static/logo.png') {
59
+ try {
60
+ const buf = getLogo();
61
+ res.writeHead(200, {
62
+ 'content-type': 'image/png',
63
+ 'cache-control': 'public, max-age=86400',
64
+ });
65
+ res.end(buf);
66
+ }
67
+ catch {
68
+ res.writeHead(404, { 'content-type': 'application/json' });
69
+ res.end(JSON.stringify({ error: 'logo asset not found' }));
70
+ }
71
+ return;
72
+ }
73
+ if (method === 'GET' && url === '/api/status') {
74
+ const appium = session.appium ?? session.defaults.appium;
75
+ const appiumReachable = await isPortOpen(appium.host, appium.port);
76
+ json(res, 200, {
77
+ connected: session.isConnected(),
78
+ attached: session.attached,
79
+ platform: session.platform,
80
+ project: session.defaults.project,
81
+ appium,
82
+ appiumReachable,
83
+ appiumOurs: !!session.appiumProc && !session.appiumProc.killed,
84
+ defaults: session.defaults,
85
+ capabilities: session.lastCapabilities,
86
+ recording: session.recording,
87
+ });
88
+ return;
89
+ }
90
+ if (method === 'GET' && url === '/api/doctor') {
91
+ const checks = await runDoctorChecks();
92
+ json(res, 200, { checks });
93
+ return;
94
+ }
95
+ if (method === 'GET' && url.startsWith('/api/appium/probe')) {
96
+ const u = new URL(url, 'http://x');
97
+ const host = u.searchParams.get('host') ?? session.defaults.appium.host;
98
+ const port = Number(u.searchParams.get('port')) || session.defaults.appium.port;
99
+ const reachable = await isPortOpen(host, port);
100
+ json(res, 200, { reachable, host, port });
101
+ return;
102
+ }
103
+ if (method === 'POST' && url === '/api/appium/start') {
104
+ const body = await readJson(req);
105
+ const opts = {
106
+ host: body.host ?? session.defaults.appium.host,
107
+ port: body.port ?? session.defaults.appium.port,
108
+ path: body.path ?? session.defaults.appium.path,
109
+ };
110
+ try {
111
+ const r = await session.ensureAppium(opts);
112
+ json(res, 200, { ok: true, ...r, appium: opts });
113
+ }
114
+ catch (err) {
115
+ json(res, 500, { ok: false, error: err.message });
116
+ }
117
+ return;
118
+ }
119
+ if (method === 'POST' && url === '/api/appium/restart') {
120
+ const body = await readJson(req);
121
+ const opts = {
122
+ host: body.host ?? session.defaults.appium.host,
123
+ port: body.port ?? session.defaults.appium.port,
124
+ path: body.path ?? session.defaults.appium.path,
125
+ };
126
+ try {
127
+ const r = await session.restartAppium(opts);
128
+ json(res, 200, { ok: true, ...r, appium: opts });
129
+ }
130
+ catch (err) {
131
+ json(res, 500, { ok: false, error: err.message });
132
+ }
133
+ return;
134
+ }
135
+ if (method === 'POST' && url === '/api/connect') {
136
+ const body = await readJson(req);
137
+ const hasLocal = !!(body.appium && body.capabilities);
138
+ const hasCloud = !!body.cloud;
139
+ if (!hasLocal && !hasCloud) {
140
+ json(res, 400, {
141
+ error: 'request must include either { appium, capabilities } or { cloud }',
142
+ });
143
+ return;
144
+ }
145
+ if (session.isConnected()) {
146
+ json(res, 409, { error: 'already connected' });
147
+ return;
148
+ }
149
+ const connectMs = Number(process.env.TAQWRIGHT_CONNECT_TIMEOUT_MS) || 300_000;
150
+ try {
151
+ await withTimeout(session.connect(body), connectMs, 'connect');
152
+ json(res, 200, { ok: true, platform: session.platform });
153
+ }
154
+ catch (err) {
155
+ session.cancelConnect();
156
+ json(res, 502, { ok: false, error: err.message });
157
+ }
158
+ return;
159
+ }
160
+ if (method === 'POST' && url === '/api/connect/cancel') {
161
+ session.cancelConnect();
162
+ json(res, 200, { ok: true });
163
+ return;
164
+ }
165
+ if (method === 'POST' && url === '/api/disconnect') {
166
+ await session.disconnect();
167
+ json(res, 200, { ok: true });
168
+ return;
169
+ }
170
+ if (method === 'POST' && url === '/api/resume') {
171
+ if (!session.attached) {
172
+ json(res, 400, { error: 'resume: not in attached mode' });
173
+ return;
174
+ }
175
+ session.requestResume();
176
+ json(res, 200, { ok: true });
177
+ return;
178
+ }
179
+ if (method === 'GET' && url === '/api/devices') {
180
+ json(res, 200, await listDevices());
181
+ return;
182
+ }
183
+ if (method === 'POST' && url === '/api/devices/start') {
184
+ const body = await readJson(req);
185
+ try {
186
+ if (body.type === 'android') {
187
+ if (!body.avdName)
188
+ throw new Error('avdName is required for Android.');
189
+ await startAndroidEmulator(body.avdName);
190
+ }
191
+ else {
192
+ if (!body.udid)
193
+ throw new Error('udid is required for iOS.');
194
+ await startIosSimulator(body.udid);
195
+ }
196
+ json(res, 200, { ok: true });
197
+ }
198
+ catch (err) {
199
+ json(res, 500, { ok: false, error: err.message });
200
+ }
201
+ return;
202
+ }
203
+ if (method === 'POST' && url === '/api/devices/stop') {
204
+ const body = await readJson(req);
205
+ try {
206
+ if (body.type === 'android')
207
+ await stopAndroidEmulator(body.udid);
208
+ else
209
+ await stopIosSimulator(body.udid);
210
+ json(res, 200, { ok: true });
211
+ }
212
+ catch (err) {
213
+ json(res, 500, { ok: false, error: err.message });
214
+ }
215
+ return;
216
+ }
217
+ if (method === 'POST' && url === '/api/file-picker') {
218
+ try {
219
+ const path = await openNativeFilePicker();
220
+ if (path)
221
+ json(res, 200, { ok: true, path });
222
+ else
223
+ json(res, 200, { ok: false, cancelled: true });
224
+ }
225
+ catch (err) {
226
+ json(res, 400, { ok: false, error: err.message });
227
+ }
228
+ return;
229
+ }
230
+ if (method === 'POST' && url === '/api/file-save-picker') {
231
+ const body = await readJson(req);
232
+ try {
233
+ const path = await openNativeSavePicker({
234
+ defaultName: body.defaultName ?? 'recorded.spec.ts',
235
+ defaultLocation: body.defaultLocation,
236
+ });
237
+ if (path)
238
+ json(res, 200, { ok: true, path });
239
+ else
240
+ json(res, 200, { ok: false, cancelled: true });
241
+ }
242
+ catch (err) {
243
+ json(res, 400, { ok: false, error: err.message });
244
+ }
245
+ return;
246
+ }
247
+ if (method === 'POST' && url === '/api/inspect-app') {
248
+ const body = await readJson(req);
249
+ try {
250
+ const info = await inspectAppFile(body.path);
251
+ json(res, 200, { ok: true, ...info });
252
+ }
253
+ catch (err) {
254
+ json(res, 400, { ok: false, error: err.message });
255
+ }
256
+ return;
257
+ }
258
+ if (method === 'GET' && url === '/api/export-script/info') {
259
+ const { projectRoot, testDir } = session.defaults;
260
+ if (!projectRoot || !testDir) {
261
+ json(res, 200, {
262
+ ok: false,
263
+ error: 'No taqwright.config.ts found — cannot resolve a tests folder. ' +
264
+ `Inspector cwd: ${process.cwd()}. Restart the inspector from inside ` +
265
+ `your project (the dir containing taqwright.config.ts).`,
266
+ debug: {
267
+ projectRoot: projectRoot ?? null,
268
+ testDir: testDir ?? null,
269
+ cwd: process.cwd(),
270
+ },
271
+ });
272
+ return;
273
+ }
274
+ const path = await import('node:path');
275
+ json(res, 200, {
276
+ ok: true,
277
+ projectRoot,
278
+ testDir,
279
+ absoluteDir: path.resolve(projectRoot, testDir),
280
+ });
281
+ return;
282
+ }
283
+ if (method === 'POST' && url === '/api/export-script') {
284
+ const body = await readJson(req);
285
+ try {
286
+ const result = await exportScriptToProject(session, body);
287
+ json(res, 200, { ok: true, ...result });
288
+ }
289
+ catch (err) {
290
+ json(res, 400, { ok: false, error: err.message });
291
+ }
292
+ return;
293
+ }
294
+ if (method === 'GET' && url === '/api/cloud/env') {
295
+ json(res, 200, {
296
+ browserstack: {
297
+ user: process.env.BROWSERSTACK_USERNAME ?? '',
298
+ key: process.env.BROWSERSTACK_ACCESS_KEY ?? '',
299
+ },
300
+ lambdatest: {
301
+ user: process.env.LAMBDATEST_USERNAME ?? '',
302
+ key: process.env.LAMBDATEST_ACCESS_KEY ?? '',
303
+ },
304
+ });
305
+ return;
306
+ }
307
+ if (method === 'POST' && url === '/api/cloud/devices') {
308
+ const body = await readJson(req);
309
+ try {
310
+ const devices = await fetchCloudDevices(body.provider, body.user, body.key);
311
+ json(res, 200, { ok: true, devices });
312
+ }
313
+ catch (err) {
314
+ json(res, 400, { ok: false, error: err.message });
315
+ }
316
+ return;
317
+ }
318
+ if (!session.driver || !session.platform) {
319
+ json(res, 412, { error: 'no active session — POST /api/connect first' });
320
+ return;
321
+ }
322
+ const driver = session.driver;
323
+ const platform = session.platform;
324
+ if (method === 'GET' && url === '/api/snapshot') {
325
+ try {
326
+ const [screenshot, source, rect] = await session.runExclusive(() => Promise.all([
327
+ withTimeout(driver.takeScreenshot(), 15000, 'screenshot'),
328
+ withTimeout(driver.getPageSource(), 15000, 'pageSource'),
329
+ withTimeout(driver.getWindowRect(), 10000, 'windowRect'),
330
+ ]));
331
+ json(res, 200, {
332
+ platform,
333
+ project: session.defaults.project ?? '',
334
+ screenshot,
335
+ source,
336
+ viewport: { w: rect.width, h: rect.height },
337
+ });
338
+ }
339
+ catch (err) {
340
+ json(res, 504, { error: err instanceof Error ? err.message : String(err) });
341
+ }
342
+ return;
343
+ }
344
+ if (method === 'POST' && url === '/api/tap') {
345
+ const { x, y } = await readJson(req);
346
+ await tap(driver, x, y);
347
+ session.recordIf({ kind: 'tap', x, y });
348
+ json(res, 200, { ok: true });
349
+ return;
350
+ }
351
+ if (method === 'POST' && url === '/api/swipe') {
352
+ const { x1, y1, x2, y2, durationMs } = await readJson(req);
353
+ await swipe(driver, x1, y1, x2, y2, durationMs);
354
+ session.recordIf({ kind: 'swipe', x1, y1, x2, y2, durationMs });
355
+ json(res, 200, { ok: true });
356
+ return;
357
+ }
358
+ if (method === 'POST' && url === '/api/suggest') {
359
+ const { attrs, xpath } = await readJson(req);
360
+ const isWeb = /WEBVIEW/i.test(session.currentContext);
361
+ const candidates = generateCandidates(platform, attrs, xpath, isWeb);
362
+ const gen = ++session.suggestGen;
363
+ const out = await session.runExclusive(async () => {
364
+ if (gen !== session.suggestGen)
365
+ return null;
366
+ let targetId = null;
367
+ try {
368
+ const t = await driver.findElement('xpath', xpath);
369
+ if (t && typeof t === 'object') {
370
+ targetId = t[W3C_ELEMENT_KEY] ?? null;
371
+ }
372
+ }
373
+ catch {
374
+ }
375
+ const verified = [];
376
+ for (const c of candidates) {
377
+ if (gen !== session.suggestGen)
378
+ break;
379
+ let refs = [];
380
+ try {
381
+ const r = await driver.findElements(c.using, c.value);
382
+ refs = Array.isArray(r) ? r : [];
383
+ }
384
+ catch {
385
+ }
386
+ const count = refs.length;
387
+ verified.push({ ...c, count, unique: count === 1 });
388
+ if (count > 1 && targetId !== null) {
389
+ const idx = refs.findIndex((r) => r[W3C_ELEMENT_KEY] === targetId);
390
+ if (idx >= 0) {
391
+ verified.push(makeNthSuggestion(c, idx));
392
+ }
393
+ }
394
+ }
395
+ return {
396
+ best: selectBestPerCategory(platform, verified, isWeb),
397
+ recommended: pickRecommended(platform, verified, isWeb) ?? null,
398
+ all: verified,
399
+ };
400
+ });
401
+ if (!out) {
402
+ json(res, 200, { best: [], recommended: null, all: [], superseded: true });
403
+ return;
404
+ }
405
+ json(res, 200, out);
406
+ return;
407
+ }
408
+ if (method === 'POST' && url === '/api/locator-action') {
409
+ const body = await readJson(req);
410
+ const result = await runLocatorAction(driver, platform, session, body);
411
+ json(res, 200, { ok: true, ...(result ?? {}) });
412
+ return;
413
+ }
414
+ if (method === 'POST' && url === '/api/screen-action') {
415
+ const body = await readJson(req);
416
+ await runScreenAction(driver, session, body);
417
+ json(res, 200, { ok: true });
418
+ return;
419
+ }
420
+ if (method === 'POST' && url === '/api/verify-xpath') {
421
+ const { xpath } = await readJson(req);
422
+ let count = 0;
423
+ try {
424
+ const refs = await driver.findElements('xpath', xpath);
425
+ count = Array.isArray(refs) ? refs.length : 0;
426
+ }
427
+ catch {
428
+ }
429
+ json(res, 200, { count, unique: count === 1, xpath });
430
+ return;
431
+ }
432
+ if (method === 'GET' && url === '/api/contexts') {
433
+ json(res, 200, await session.listContexts());
434
+ return;
435
+ }
436
+ if (method === 'POST' && url === '/api/context') {
437
+ const { context } = await readJson(req);
438
+ try {
439
+ await session.switchContext(context);
440
+ json(res, 200, { ok: true, current: session.currentContext });
441
+ }
442
+ catch (err) {
443
+ json(res, 502, { error: err instanceof Error ? err.message : String(err) });
444
+ }
445
+ return;
446
+ }
447
+ if (method === 'GET' && (url === '/api/recording' || url.startsWith('/api/recording?'))) {
448
+ const lang = new URL(url, 'http://x').searchParams.get('lang') ?? 'ts';
449
+ const actions = session.recorder.list();
450
+ const spec = lang === 'python'
451
+ ? toStepsPython(actions)
452
+ : lang === 'java'
453
+ ? toStepsJava(actions)
454
+ : session.recorder.toSpec();
455
+ json(res, 200, { spec, recording: session.recording });
456
+ return;
457
+ }
458
+ if (method === 'POST' && url === '/api/recording/start') {
459
+ session.recorder.clear();
460
+ session.recording = true;
461
+ json(res, 200, { ok: true, recording: true });
462
+ return;
463
+ }
464
+ if (method === 'POST' && url === '/api/recording/stop') {
465
+ session.recording = false;
466
+ json(res, 200, { ok: true, recording: false });
467
+ return;
468
+ }
469
+ if (method === 'POST' && url === '/api/recording/clear') {
470
+ session.recorder.clear();
471
+ json(res, 200, { ok: true });
472
+ return;
473
+ }
474
+ res.writeHead(404, { 'content-type': 'application/json' });
475
+ res.end(JSON.stringify({ error: 'not found', url, method }));
476
+ }
477
+ async function tap(driver, x, y) {
478
+ try {
479
+ await driver.performActions([
480
+ {
481
+ type: 'pointer',
482
+ id: 'finger1',
483
+ parameters: { pointerType: 'touch' },
484
+ actions: [
485
+ { type: 'pointerMove', duration: 0, x, y },
486
+ { type: 'pointerDown', button: 0 },
487
+ { type: 'pause', duration: 50 },
488
+ { type: 'pointerUp', button: 0 },
489
+ ],
490
+ },
491
+ ]);
492
+ }
493
+ finally {
494
+ await driver.releaseActions().catch(() => { });
495
+ }
496
+ }
497
+ async function swipe(driver, x1, y1, x2, y2, duration) {
498
+ try {
499
+ await driver.performActions([
500
+ {
501
+ type: 'pointer',
502
+ id: 'finger1',
503
+ parameters: { pointerType: 'touch' },
504
+ actions: [
505
+ { type: 'pointerMove', duration: 0, x: x1, y: y1 },
506
+ { type: 'pointerDown', button: 0 },
507
+ { type: 'pointerMove', duration, x: x2, y: y2 },
508
+ { type: 'pointerUp', button: 0 },
509
+ ],
510
+ },
511
+ ]);
512
+ }
513
+ finally {
514
+ await driver.releaseActions().catch(() => { });
515
+ }
516
+ }
517
+ export function resolveLocatorDescriptor(body) {
518
+ if (body.descriptor)
519
+ return body.descriptor;
520
+ if (body.using !== undefined && body.value !== undefined) {
521
+ return {
522
+ kind: 'leaf',
523
+ using: body.using,
524
+ value: body.value,
525
+ };
526
+ }
527
+ throw new Error('locator-action: body is missing both `descriptor` and `{using, value}`');
528
+ }
529
+ function recordedLoc(b) {
530
+ return { code: b.code, using: b.using, value: b.value, descriptor: b.descriptor };
531
+ }
532
+ async function runLocatorAction(driver, platform, session, body) {
533
+ const ctx = { driver, platform, defaultTimeout: 30_000 };
534
+ const descriptor = resolveLocatorDescriptor(body);
535
+ const locator = buildLocatorFromDescriptor(ctx, descriptor);
536
+ switch (body.kind) {
537
+ case 'click':
538
+ await locator.click();
539
+ session.recordIf({ kind: 'locatorClick', ...recordedLoc(body) });
540
+ return;
541
+ case 'doubleTap':
542
+ await locator.doubleTap();
543
+ session.recordIf({ kind: 'locatorDoubleTap', ...recordedLoc(body) });
544
+ return;
545
+ case 'longPress':
546
+ await locator.longPress();
547
+ session.recordIf({ kind: 'locatorLongPress', ...recordedLoc(body) });
548
+ return;
549
+ case 'fill':
550
+ await locator.fill(body.text);
551
+ session.recordIf({ kind: 'locatorFill', ...recordedLoc(body), text: body.text });
552
+ return;
553
+ case 'clear':
554
+ await locator.clear();
555
+ session.recordIf({ kind: 'locatorClear', ...recordedLoc(body) });
556
+ return;
557
+ case 'assertion':
558
+ return runAssertion(locator, session, body);
559
+ case 'swipe':
560
+ switch (body.direction) {
561
+ case 'left':
562
+ await locator.swipeLeft();
563
+ break;
564
+ case 'right':
565
+ await locator.swipeRight();
566
+ break;
567
+ case 'up':
568
+ await locator.swipeUp();
569
+ break;
570
+ case 'down':
571
+ await locator.swipeDown();
572
+ break;
573
+ }
574
+ session.recordIf({ kind: 'locatorSwipe', ...recordedLoc(body), direction: body.direction });
575
+ return;
576
+ case 'scrollIntoView':
577
+ await locator.scrollIntoView();
578
+ session.recordIf({ kind: 'locatorScrollIntoView', ...recordedLoc(body) });
579
+ return;
580
+ case 'pinch':
581
+ if (body.direction === 'in')
582
+ await locator.pinchIn();
583
+ else
584
+ await locator.pinchOut();
585
+ session.recordIf({ kind: 'locatorPinch', ...recordedLoc(body), direction: body.direction });
586
+ return;
587
+ case 'dragTo': {
588
+ const targetLoc = buildLocatorFromDescriptor(ctx, resolveLocatorDescriptor(body.target));
589
+ await locator.dragTo(targetLoc);
590
+ session.recordIf({
591
+ kind: 'locatorDragTo',
592
+ ...recordedLoc(body),
593
+ targetCode: body.target.code,
594
+ target: recordedLoc(body.target),
595
+ });
596
+ return;
597
+ }
598
+ case 'check':
599
+ await locator.check();
600
+ session.recordIf({ kind: 'locatorCheck', ...recordedLoc(body) });
601
+ return;
602
+ case 'uncheck':
603
+ await locator.uncheck();
604
+ session.recordIf({ kind: 'locatorUncheck', ...recordedLoc(body) });
605
+ return;
606
+ case 'focus':
607
+ await locator.focus();
608
+ session.recordIf({ kind: 'locatorFocus', ...recordedLoc(body) });
609
+ return;
610
+ case 'blur':
611
+ await locator.blur();
612
+ session.recordIf({ kind: 'locatorBlur', ...recordedLoc(body) });
613
+ return;
614
+ case 'press':
615
+ await locator.press(body.key);
616
+ session.recordIf({ kind: 'locatorPress', ...recordedLoc(body), key: body.key });
617
+ return;
618
+ case 'pressSequentially':
619
+ await locator.pressSequentially(body.text, { delay: body.delay });
620
+ session.recordIf({
621
+ kind: 'locatorPressSequentially',
622
+ ...recordedLoc(body),
623
+ text: body.text,
624
+ ...(body.delay !== undefined ? { delay: body.delay } : {}),
625
+ });
626
+ return;
627
+ case 'selectOption':
628
+ await locator.selectOption(body.value);
629
+ session.recordIf({
630
+ kind: 'locatorSelectOption',
631
+ ...recordedLoc(body),
632
+ value: body.value,
633
+ });
634
+ return;
635
+ }
636
+ }
637
+ const ASSERT_VERIFY_TIMEOUT_MS = 1500;
638
+ async function runAssertion(locator, session, body) {
639
+ const matcher = body.matcher;
640
+ let verified = false;
641
+ let actual;
642
+ if (matcher === 'visible' ||
643
+ matcher === 'hidden' ||
644
+ matcher === 'enabled' ||
645
+ matcher === 'disabled') {
646
+ try {
647
+ await locator.waitFor({ state: matcher, timeout: ASSERT_VERIFY_TIMEOUT_MS });
648
+ verified = true;
649
+ }
650
+ catch {
651
+ verified = false;
652
+ }
653
+ }
654
+ else if (matcher === 'text') {
655
+ try {
656
+ actual = await locator.getText();
657
+ const expected = body.expected ?? '';
658
+ verified = body.mode === 'contains' ? actual.includes(expected) : actual === expected;
659
+ }
660
+ catch {
661
+ verified = false;
662
+ }
663
+ }
664
+ else if (matcher === 'value') {
665
+ try {
666
+ actual = await locator.getValue();
667
+ verified = actual === (body.expected ?? '');
668
+ }
669
+ catch {
670
+ verified = false;
671
+ }
672
+ }
673
+ else if (matcher === 'checked' || matcher === 'unchecked') {
674
+ try {
675
+ const got = await locator.isChecked();
676
+ actual = String(got);
677
+ verified = matcher === 'checked' ? got === true : got === false;
678
+ }
679
+ catch {
680
+ verified = false;
681
+ }
682
+ }
683
+ else if (matcher === 'editable' || matcher === 'readonly') {
684
+ try {
685
+ const got = await locator.isEditable();
686
+ actual = String(got);
687
+ verified = matcher === 'editable' ? got === true : got === false;
688
+ }
689
+ catch {
690
+ verified = false;
691
+ }
692
+ }
693
+ else if (matcher === 'focused') {
694
+ try {
695
+ const got = await locator.isFocused();
696
+ actual = String(got);
697
+ verified = got === true;
698
+ }
699
+ catch {
700
+ verified = false;
701
+ }
702
+ }
703
+ else if (matcher === 'attached') {
704
+ try {
705
+ await locator.waitFor({ state: 'attached', timeout: ASSERT_VERIFY_TIMEOUT_MS });
706
+ verified = true;
707
+ }
708
+ catch {
709
+ verified = false;
710
+ }
711
+ }
712
+ else if (matcher === 'empty') {
713
+ try {
714
+ const got = await locator.isEmpty();
715
+ actual = String(got);
716
+ verified = got === true;
717
+ }
718
+ catch {
719
+ verified = false;
720
+ }
721
+ }
722
+ else if (matcher === 'inViewport') {
723
+ try {
724
+ const got = await locator.isInViewport();
725
+ actual = String(got);
726
+ verified = got === true;
727
+ }
728
+ catch {
729
+ verified = false;
730
+ }
731
+ }
732
+ else if (matcher === 'count') {
733
+ try {
734
+ const got = await locator.count();
735
+ actual = String(got);
736
+ verified = got === (body.expectedCount ?? -1);
737
+ }
738
+ catch {
739
+ verified = false;
740
+ }
741
+ }
742
+ else if (matcher === 'attribute') {
743
+ try {
744
+ const name = body.attrName ?? '';
745
+ actual = (await locator.getAttribute(name)) ?? '';
746
+ verified = actual === (body.expected ?? '');
747
+ }
748
+ catch {
749
+ verified = false;
750
+ }
751
+ }
752
+ let recorded = false;
753
+ if (verified || body.force) {
754
+ switch (matcher) {
755
+ case 'visible':
756
+ session.recordIf({ kind: 'assertVisible', ...recordedLoc(body) });
757
+ break;
758
+ case 'hidden':
759
+ session.recordIf({ kind: 'assertHidden', ...recordedLoc(body) });
760
+ break;
761
+ case 'enabled':
762
+ session.recordIf({ kind: 'assertEnabled', ...recordedLoc(body) });
763
+ break;
764
+ case 'disabled':
765
+ session.recordIf({ kind: 'assertDisabled', ...recordedLoc(body) });
766
+ break;
767
+ case 'text':
768
+ session.recordIf({
769
+ kind: 'assertText',
770
+ ...recordedLoc(body),
771
+ expected: body.expected ?? '',
772
+ mode: body.mode === 'contains' ? 'contains' : 'exact',
773
+ });
774
+ break;
775
+ case 'value':
776
+ session.recordIf({
777
+ kind: 'assertValue',
778
+ ...recordedLoc(body),
779
+ expected: body.expected ?? '',
780
+ });
781
+ break;
782
+ case 'checked':
783
+ session.recordIf({ kind: 'assertChecked', ...recordedLoc(body) });
784
+ break;
785
+ case 'unchecked':
786
+ session.recordIf({ kind: 'assertUnchecked', ...recordedLoc(body) });
787
+ break;
788
+ case 'editable':
789
+ session.recordIf({ kind: 'assertEditable', ...recordedLoc(body) });
790
+ break;
791
+ case 'readonly':
792
+ session.recordIf({ kind: 'assertReadonly', ...recordedLoc(body) });
793
+ break;
794
+ case 'focused':
795
+ session.recordIf({ kind: 'assertFocused', ...recordedLoc(body) });
796
+ break;
797
+ case 'attached':
798
+ session.recordIf({ kind: 'assertAttached', ...recordedLoc(body) });
799
+ break;
800
+ case 'empty':
801
+ session.recordIf({ kind: 'assertEmpty', ...recordedLoc(body) });
802
+ break;
803
+ case 'inViewport':
804
+ session.recordIf({ kind: 'assertInViewport', ...recordedLoc(body) });
805
+ break;
806
+ case 'count':
807
+ session.recordIf({
808
+ kind: 'assertCount',
809
+ ...recordedLoc(body),
810
+ expected: body.expectedCount ?? 0,
811
+ });
812
+ break;
813
+ case 'attribute':
814
+ session.recordIf({
815
+ kind: 'assertAttribute',
816
+ ...recordedLoc(body),
817
+ name: body.attrName ?? '',
818
+ expected: body.expected ?? '',
819
+ });
820
+ break;
821
+ }
822
+ recorded = session.recording;
823
+ }
824
+ return { recorded, verified, actual };
825
+ }
826
+ async function runScreenAction(driver, session, body) {
827
+ switch (body.kind) {
828
+ case 'scroll': {
829
+ const rect = await driver.getWindowRect();
830
+ const dir = body.direction;
831
+ const fingerDir = dir === 'down'
832
+ ? 'up'
833
+ : dir === 'up'
834
+ ? 'down'
835
+ : dir === 'right'
836
+ ? 'left'
837
+ : dir === 'left'
838
+ ? 'right'
839
+ : dir;
840
+ const { fromX, toX, fromY, toY } = body;
841
+ const yFrac = fromY !== undefined || toY !== undefined
842
+ ? [fromY ?? toY ?? 0.4, toY ?? fromY ?? 0.6]
843
+ : [0.4, 0.6];
844
+ const xFrac = fromX !== undefined || toX !== undefined
845
+ ? [fromX ?? toX ?? 0.5, toX ?? fromX ?? 0.5]
846
+ : [0.5, 0.5];
847
+ const yLow = Math.min(yFrac[0], yFrac[1]);
848
+ const yHigh = Math.max(yFrac[0], yFrac[1]);
849
+ const xLow = Math.min(xFrac[0], xFrac[1]);
850
+ const xHigh = Math.max(xFrac[0], xFrac[1]);
851
+ let used = false;
852
+ try {
853
+ await driver.executeScript('mobile: swipeGesture', [
854
+ {
855
+ left: Math.floor(rect.width * xLow),
856
+ top: Math.floor(rect.height * yLow),
857
+ width: Math.max(2, Math.floor(rect.width * (xHigh - xLow))),
858
+ height: Math.max(2, Math.floor(rect.height * (yHigh - yLow))),
859
+ direction: fingerDir,
860
+ percent: 0.75,
861
+ },
862
+ ]);
863
+ used = true;
864
+ }
865
+ catch {
866
+ }
867
+ if (!used) {
868
+ const cx = Math.floor(rect.width / 2);
869
+ const cy = Math.floor(rect.height / 2);
870
+ const span = Math.floor(Math.min(rect.width, rect.height) * 0.4);
871
+ const fX = fromX !== undefined
872
+ ? Math.floor(rect.width * fromX)
873
+ : fingerDir === 'left'
874
+ ? cx + span
875
+ : fingerDir === 'right'
876
+ ? cx - span
877
+ : cx;
878
+ const fY = fromY !== undefined
879
+ ? Math.floor(rect.height * fromY)
880
+ : fingerDir === 'up'
881
+ ? cy + span
882
+ : fingerDir === 'down'
883
+ ? cy - span
884
+ : cy;
885
+ const tX = toX !== undefined
886
+ ? Math.floor(rect.width * toX)
887
+ : fingerDir === 'left'
888
+ ? cx - span
889
+ : fingerDir === 'right'
890
+ ? cx + span
891
+ : cx;
892
+ const tY = toY !== undefined
893
+ ? Math.floor(rect.height * toY)
894
+ : fingerDir === 'up'
895
+ ? cy - span
896
+ : fingerDir === 'down'
897
+ ? cy + span
898
+ : cy;
899
+ await swipe(driver, fX, fY, tX, tY, 300);
900
+ }
901
+ session.recordIf({ kind: 'screenScroll', direction: dir, fromX, toX, fromY, toY });
902
+ return;
903
+ }
904
+ }
905
+ }
906
+ function json(res, status, body) {
907
+ res.writeHead(status, { 'content-type': 'application/json' });
908
+ res.end(JSON.stringify(body));
909
+ }
910
+ export function withTimeout(p, ms, label) {
911
+ return Promise.race([
912
+ p,
913
+ new Promise((_, rej) => setTimeout(() => rej(new Error('device timeout: ' + label)), ms)),
914
+ ]);
915
+ }
916
+ export async function readJson(req) {
917
+ const chunks = [];
918
+ for await (const chunk of req)
919
+ chunks.push(chunk);
920
+ const raw = Buffer.concat(chunks).toString('utf8');
921
+ if (!raw)
922
+ return {};
923
+ return JSON.parse(raw);
924
+ }
925
+ const LOGO_PATH = resolve(dirname(fileURLToPath(import.meta.url)), '../images/taqwright_logo.png');
926
+ let logoBuf;
927
+ function getLogo() {
928
+ if (!logoBuf)
929
+ logoBuf = readFileSync(LOGO_PATH);
930
+ return logoBuf;
931
+ }
932
+ function closeServer(server, done) {
933
+ server.close(() => done());
934
+ if (typeof server.closeAllConnections === 'function') {
935
+ server.closeAllConnections();
936
+ }
937
+ }
938
+ async function openNativeFilePicker() {
939
+ if (process.platform !== 'darwin') {
940
+ throw new Error('Native file picker is only available on macOS. Paste the path manually.');
941
+ }
942
+ const { spawn } = await import('node:child_process');
943
+ return new Promise((resolveStr) => {
944
+ const script = 'try\n' +
945
+ ' POSIX path of (choose file with prompt "Select APK / IPA / .app / .app.zip file" of type {"apk","ipa","app","zip"})\n' +
946
+ 'on error\n' +
947
+ ' ""\n' +
948
+ 'end try';
949
+ const child = spawn('osascript', ['-e', script]);
950
+ let buf = '';
951
+ child.stdout.on('data', (d) => {
952
+ buf += d.toString();
953
+ });
954
+ child.on('close', () => {
955
+ const trimmed = buf.trim();
956
+ resolveStr(trimmed.length > 0 ? trimmed : null);
957
+ });
958
+ child.on('error', () => resolveStr(null));
959
+ });
960
+ }
961
+ async function openNativeSavePicker(opts) {
962
+ if (process.platform !== 'darwin') {
963
+ throw new Error('Native save panel is only available on macOS. Use the filename prompt fallback.');
964
+ }
965
+ const { spawn } = await import('node:child_process');
966
+ const escName = opts.defaultName.replace(/"/g, '\\"');
967
+ const escLoc = opts.defaultLocation?.replace(/"/g, '\\"') ?? '';
968
+ const locationClause = escLoc ? `default location POSIX file "${escLoc}"` : '';
969
+ const script = 'try\n' +
970
+ ` POSIX path of (choose file name with prompt "Save the recorded spec as" default name "${escName}"${locationClause ? ' ' + locationClause : ''})\n` +
971
+ 'on error\n' +
972
+ ' ""\n' +
973
+ 'end try';
974
+ return new Promise((resolveStr) => {
975
+ const child = spawn('osascript', ['-e', script]);
976
+ let buf = '';
977
+ child.stdout.on('data', (d) => {
978
+ buf += d.toString();
979
+ });
980
+ child.on('close', () => {
981
+ const trimmed = buf.trim();
982
+ resolveStr(trimmed.length > 0 ? trimmed : null);
983
+ });
984
+ child.on('error', () => resolveStr(null));
985
+ });
986
+ }
987
+ async function inspectAppFile(filePath) {
988
+ if (!filePath || typeof filePath !== 'string') {
989
+ throw new Error('path is required');
990
+ }
991
+ if (/^(bs|lt|https?):\/\//i.test(filePath)) {
992
+ throw new Error('Cloud / remote URLs (bs://, lt://, http(s)://) are not inspected locally — the cloud session resolves them.');
993
+ }
994
+ const { promises: fs } = await import('node:fs');
995
+ await fs.access(filePath).catch(() => {
996
+ throw new Error(`File not found: ${filePath}`);
997
+ });
998
+ const lower = filePath.toLowerCase();
999
+ if (lower.endsWith('.apk'))
1000
+ return inspectApk(filePath);
1001
+ if (lower.endsWith('.zip'))
1002
+ return inspectIosAppZip(filePath);
1003
+ if (lower.endsWith('.app'))
1004
+ return inspectIosAppDir(filePath);
1005
+ if (lower.endsWith('.ipa'))
1006
+ return inspectIpa(filePath);
1007
+ throw new Error('Unsupported file type — expected .apk, .ipa, .app, or .app.zip');
1008
+ }
1009
+ async function inspectApk(p) {
1010
+ const { execFile } = await import('node:child_process');
1011
+ const { promisify } = await import('node:util');
1012
+ const execP = promisify(execFile);
1013
+ const candidates = ['aapt'];
1014
+ if (process.env.ANDROID_HOME) {
1015
+ try {
1016
+ const fs = await import('node:fs/promises');
1017
+ const { join } = await import('node:path');
1018
+ const buildToolsDir = join(process.env.ANDROID_HOME, 'build-tools');
1019
+ const entries = await fs.readdir(buildToolsDir);
1020
+ const versions = entries
1021
+ .filter((e) => /^\d+\.\d+\.\d+/.test(e))
1022
+ .sort()
1023
+ .reverse();
1024
+ for (const v of versions)
1025
+ candidates.push(join(buildToolsDir, v, 'aapt'));
1026
+ }
1027
+ catch {
1028
+ }
1029
+ }
1030
+ for (const aapt of candidates) {
1031
+ try {
1032
+ const { stdout } = await execP(aapt, ['dump', 'badging', p]);
1033
+ const pkg = stdout.match(/package: name='([^']+)'/);
1034
+ const activity = stdout.match(/launchable-activity: name='([^']+)'/);
1035
+ if (pkg) {
1036
+ return {
1037
+ kind: 'apk',
1038
+ bundleId: pkg[1],
1039
+ appActivity: activity?.[1],
1040
+ };
1041
+ }
1042
+ }
1043
+ catch {
1044
+ }
1045
+ }
1046
+ throw new Error('aapt not on PATH and no usable copy under $ANDROID_HOME/build-tools — install Android command-line tools to inspect APKs.');
1047
+ }
1048
+ async function inspectIosAppDir(p) {
1049
+ const { execFile } = await import('node:child_process');
1050
+ const { promisify } = await import('node:util');
1051
+ const { join } = await import('node:path');
1052
+ const execP = promisify(execFile);
1053
+ const plistPath = join(p, 'Info.plist');
1054
+ try {
1055
+ const { stdout } = await execP('plutil', ['-extract', 'CFBundleIdentifier', 'raw', plistPath]);
1056
+ const id = stdout.trim();
1057
+ if (!id)
1058
+ throw new Error('CFBundleIdentifier not found in Info.plist');
1059
+ return { kind: 'app', bundleId: id };
1060
+ }
1061
+ catch (err) {
1062
+ throw new Error(`Failed to read ${plistPath}: ${err.message}`, { cause: err });
1063
+ }
1064
+ }
1065
+ async function exportScriptToProject(session, body) {
1066
+ const path = await import('node:path');
1067
+ const fs = await import('node:fs/promises');
1068
+ let absPath;
1069
+ if (body.absolutePath) {
1070
+ const ap = String(body.absolutePath).trim();
1071
+ if (!path.isAbsolute(ap))
1072
+ throw new Error('absolutePath must be absolute.');
1073
+ if (!/\.(tsx?|jsx?|py|java)$/i.test(ap)) {
1074
+ throw new Error('Path must end in .ts / .tsx / .js / .jsx / .py / .java.');
1075
+ }
1076
+ absPath = ap;
1077
+ }
1078
+ else {
1079
+ const { projectRoot, testDir } = session.defaults;
1080
+ if (!projectRoot || !testDir) {
1081
+ throw new Error('Inspector started without a taqwright.config.ts — no consuming project to export into.');
1082
+ }
1083
+ const filename = String(body.filename ?? '').trim();
1084
+ if (!filename)
1085
+ throw new Error('filename or absolutePath is required');
1086
+ if (filename.includes('/') || filename.includes('\\') || filename.startsWith('.')) {
1087
+ throw new Error('filename must be a plain file name (no slashes, no leading dot).');
1088
+ }
1089
+ if (!/\.(tsx?|jsx?|py|java)$/i.test(filename)) {
1090
+ throw new Error('filename must end in .ts / .tsx / .js / .jsx / .py / .java.');
1091
+ }
1092
+ const absDir = path.resolve(projectRoot, testDir);
1093
+ absPath = path.join(absDir, filename);
1094
+ if (!absPath.startsWith(absDir + path.sep) && absPath !== absDir) {
1095
+ throw new Error('Refusing to write outside the configured tests folder.');
1096
+ }
1097
+ }
1098
+ const content = typeof body.content === 'string' && body.content.length > 0
1099
+ ? body.content
1100
+ : session.recorder.toSpec();
1101
+ if (!content)
1102
+ throw new Error('Recorded script is empty — record something first.');
1103
+ await fs.mkdir(path.dirname(absPath), { recursive: true });
1104
+ if (!body.overwrite) {
1105
+ const exists = await fs.access(absPath).then(() => true, () => false);
1106
+ if (exists) {
1107
+ throw new Error(`File already exists at ${absPath}. Re-send with overwrite: true to replace it.`);
1108
+ }
1109
+ }
1110
+ await fs.writeFile(absPath, content, 'utf8');
1111
+ return { path: absPath, bytes: Buffer.byteLength(content, 'utf8') };
1112
+ }
1113
+ export function parseLambdatestDevices(raw) {
1114
+ const r = (raw ?? {});
1115
+ const arr = Array.isArray(raw)
1116
+ ? raw
1117
+ : Array.isArray(r.devices)
1118
+ ? r.devices
1119
+ : Array.isArray(r.data)
1120
+ ? r.data
1121
+ : [];
1122
+ const devices = [];
1123
+ for (const item of arr) {
1124
+ const d = item;
1125
+ const name = d.deviceName ?? d.device ?? d.name;
1126
+ if (!name)
1127
+ continue;
1128
+ const os = String(d.platformName ?? d.platform ?? d.os ?? d.osName ?? '');
1129
+ const version = d.osVersion ?? d.version ?? d.os_version ?? d.platformVersion;
1130
+ devices.push({
1131
+ provider: 'lambdatest',
1132
+ platform: os.toLowerCase().includes('ios') ? 'ios' : 'android',
1133
+ deviceName: String(name),
1134
+ osVersion: version != null ? String(version) : '',
1135
+ realDevice: true,
1136
+ });
1137
+ }
1138
+ return devices;
1139
+ }
1140
+ async function fetchCloudDevices(provider, user, key) {
1141
+ if (!user || !key) {
1142
+ throw new Error(`${provider} username + access key are required.`);
1143
+ }
1144
+ const auth = 'Basic ' + Buffer.from(`${user}:${key}`).toString('base64');
1145
+ if (provider === 'browserstack') {
1146
+ const r = await fetch('https://api-cloud.browserstack.com/app-automate/devices.json', {
1147
+ headers: { Authorization: auth },
1148
+ });
1149
+ if (!r.ok) {
1150
+ throw new Error(`BrowserStack devices.json returned ${r.status} — check credentials.`);
1151
+ }
1152
+ const raw = (await r.json());
1153
+ return raw.map((d) => ({
1154
+ provider,
1155
+ platform: d.os.toLowerCase().includes('ios') ? 'ios' : 'android',
1156
+ deviceName: d.device,
1157
+ osVersion: d.os_version,
1158
+ realDevice: d.realMobile === true || d.realMobile === 'true',
1159
+ }));
1160
+ }
1161
+ const r = await fetch('https://mobile-api.lambdatest.com/mobile-automation/api/v1/list?region=us', { headers: { Authorization: auth } });
1162
+ if (!r.ok) {
1163
+ throw new Error(`LambdaTest devices API returned ${r.status} — check credentials.`);
1164
+ }
1165
+ const raw = await r.json();
1166
+ const devices = parseLambdatestDevices(raw);
1167
+ if (devices.length === 0) {
1168
+ const keys = raw && typeof raw === 'object' && !Array.isArray(raw)
1169
+ ? Object.keys(raw).join(', ')
1170
+ : typeof raw;
1171
+ console.error('[taqwright] LambdaTest device list — unrecognized response:', JSON.stringify(raw).slice(0, 800));
1172
+ throw new Error(`LambdaTest returned 200 but no devices could be parsed (top-level: ${keys}). ` +
1173
+ 'If your account region is not "us", the us list can be empty. ' +
1174
+ 'Check the inspector server logs for the raw response.');
1175
+ }
1176
+ return devices;
1177
+ }
1178
+ async function inspectIpa(p) {
1179
+ return bundleIdFromZippedApp(p, 'ipa');
1180
+ }
1181
+ async function inspectIosAppZip(p) {
1182
+ return bundleIdFromZippedApp(p, 'app.zip');
1183
+ }
1184
+ async function bundleIdFromZippedApp(p, kind) {
1185
+ const { execFile } = await import('node:child_process');
1186
+ const { promisify } = await import('node:util');
1187
+ const { join } = await import('node:path');
1188
+ const fs = await import('node:fs/promises');
1189
+ const os = await import('node:os');
1190
+ const execP = promisify(execFile);
1191
+ const label = kind === 'ipa' ? 'IPA' : '.app.zip';
1192
+ const { stdout: listOut } = await execP('unzip', ['-l', p]);
1193
+ const entry = listOut.match(/(\S*[^/\s]+\.app\/Info\.plist)/)?.[1];
1194
+ if (!entry) {
1195
+ throw new Error(`${label} does not contain a *.app/Info.plist entry`);
1196
+ }
1197
+ const tmpDir = await fs.mkdtemp(join(os.tmpdir(), 'taqwright-app-'));
1198
+ try {
1199
+ await execP('unzip', ['-o', '-j', p, entry, '-d', tmpDir]);
1200
+ const plistPath = join(tmpDir, 'Info.plist');
1201
+ const { stdout } = await execP('plutil', ['-extract', 'CFBundleIdentifier', 'raw', plistPath]);
1202
+ const id = stdout.trim();
1203
+ if (!id)
1204
+ throw new Error(`CFBundleIdentifier not found in ${label} Info.plist`);
1205
+ return { kind, bundleId: id };
1206
+ }
1207
+ finally {
1208
+ fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
1209
+ }
1210
+ }