testdriverai 7.1.3 → 7.2.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/.github/workflows/acceptance.yaml +81 -0
- package/.github/workflows/publish.yaml +44 -0
- package/.github/workflows/test-init.yml +145 -0
- package/agent/index.js +18 -19
- package/agent/lib/commander.js +2 -2
- package/agent/lib/commands.js +324 -124
- package/agent/lib/redraw.js +99 -39
- package/agent/lib/sandbox.js +98 -6
- package/agent/lib/sdk.js +25 -0
- package/agent/lib/system.js +2 -1
- package/agent/lib/validation.js +6 -6
- package/docs/docs.json +211 -101
- package/docs/snippets/tests/type-repeated-replay.mdx +1 -1
- package/docs/v7/_drafts/caching-selectors.mdx +24 -0
- package/docs/v7/_drafts/migration.mdx +3 -3
- package/docs/v7/api/act.mdx +2 -2
- package/docs/v7/api/assert.mdx +2 -2
- package/docs/v7/api/assertions.mdx +21 -21
- package/docs/v7/api/elements.mdx +78 -0
- package/docs/v7/api/find.mdx +38 -0
- package/docs/v7/api/focusApplication.mdx +2 -2
- package/docs/v7/api/hover.mdx +2 -2
- package/docs/v7/features/ai-native.mdx +57 -71
- package/docs/v7/features/application-logs.mdx +353 -0
- package/docs/v7/features/browser-logs.mdx +414 -0
- package/docs/v7/features/cache-management.mdx +402 -0
- package/docs/v7/features/continuous-testing.mdx +346 -0
- package/docs/v7/features/coverage.mdx +508 -0
- package/docs/v7/features/data-driven-testing.mdx +441 -0
- package/docs/v7/features/easy-to-write.mdx +2 -73
- package/docs/v7/features/enterprise.mdx +155 -39
- package/docs/v7/features/fast.mdx +63 -81
- package/docs/v7/features/managed-sandboxes.mdx +384 -0
- package/docs/v7/features/network-monitoring.mdx +568 -0
- package/docs/v7/features/observable.mdx +3 -22
- package/docs/v7/features/parallel-execution.mdx +381 -0
- package/docs/v7/features/powerful.mdx +1 -1
- package/docs/v7/features/reports.mdx +414 -0
- package/docs/v7/features/sandbox-customization.mdx +229 -0
- package/docs/v7/features/scalable.mdx +217 -2
- package/docs/v7/features/stable.mdx +106 -147
- package/docs/v7/features/system-performance.mdx +616 -0
- package/docs/v7/features/test-analytics.mdx +373 -0
- package/docs/v7/features/test-cases.mdx +393 -0
- package/docs/v7/features/test-replays.mdx +408 -0
- package/docs/v7/features/test-reports.mdx +308 -0
- package/docs/v7/getting-started/{running-and-debugging.mdx → debugging-tests.mdx} +12 -142
- package/docs/v7/getting-started/quickstart.mdx +22 -305
- package/docs/v7/getting-started/running-tests.mdx +173 -0
- package/docs/v7/overview/readme.mdx +1 -1
- package/docs/v7/overview/what-is-testdriver.mdx +2 -14
- package/docs/v7/presets/chrome-extension.mdx +147 -122
- package/interfaces/cli/commands/init.js +78 -20
- package/interfaces/cli/lib/base.js +3 -2
- package/interfaces/logger.js +0 -2
- package/interfaces/shared-test-state.mjs +0 -5
- package/interfaces/vitest-plugin.mjs +69 -42
- package/lib/core/Dashcam.js +65 -66
- package/lib/vitest/hooks.mjs +42 -50
- package/manual/test-init-command.js +223 -0
- package/package.json +2 -2
- package/schema.json +5 -5
- package/sdk-log-formatter.js +351 -176
- package/sdk.d.ts +8 -8
- package/sdk.js +436 -121
- package/setup/aws/cloudformation.yaml +2 -2
- package/setup/aws/self-hosted.yml +1 -1
- package/test/testdriver/chrome-extension.test.mjs +55 -72
- package/test/testdriver/element-not-found.test.mjs +2 -1
- package/test/testdriver/hover-image.test.mjs +1 -1
- package/test/testdriver/hover-text-with-description.test.mjs +0 -3
- package/test/testdriver/scroll-until-text.test.mjs +10 -6
- package/test/testdriver/setup/lifecycleHelpers.mjs +19 -24
- package/test/testdriver/setup/testHelpers.mjs +18 -23
- package/vitest.config.mjs +3 -3
- package/.github/workflows/linux-tests.yml +0 -28
- package/docs/v7/getting-started/generating-tests.mdx +0 -525
- package/test/testdriver/auto-cache-key-demo.test.mjs +0 -56
package/sdk.js
CHANGED
|
@@ -289,6 +289,44 @@ class Element {
|
|
|
289
289
|
return this._found;
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
+
/**
|
|
293
|
+
* Serialize element to JSON safely (removes circular references)
|
|
294
|
+
* This is automatically called by JSON.stringify()
|
|
295
|
+
* @returns {Object} Serializable representation of the element
|
|
296
|
+
*/
|
|
297
|
+
toJSON() {
|
|
298
|
+
const result = {
|
|
299
|
+
description: this.description,
|
|
300
|
+
coordinates: this.coordinates,
|
|
301
|
+
found: this._found,
|
|
302
|
+
threshold: this._threshold,
|
|
303
|
+
x: this.coordinates?.x,
|
|
304
|
+
y: this.coordinates?.y,
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
// Include response metadata if available
|
|
308
|
+
if (this._response) {
|
|
309
|
+
result.cache = {
|
|
310
|
+
hit: this._response.cacheHit || this._response.cache_hit || this._response.cached || false,
|
|
311
|
+
strategy: this._response.cacheStrategy,
|
|
312
|
+
createdAt: this._response.cacheCreatedAt,
|
|
313
|
+
diffPercent: this._response.cacheDiffPercent,
|
|
314
|
+
imageUrl: this._response.cachedImageUrl,
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
result.similarity = this._response.similarity;
|
|
318
|
+
result.confidence = this._response.confidence;
|
|
319
|
+
result.selector = this._response.selector;
|
|
320
|
+
|
|
321
|
+
// Include AI response text if available
|
|
322
|
+
if (this._response.response?.content?.[0]?.text) {
|
|
323
|
+
result.aiResponse = this._response.response.content[0].text;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return result;
|
|
328
|
+
}
|
|
329
|
+
|
|
292
330
|
/**
|
|
293
331
|
* Find the element on screen
|
|
294
332
|
* @param {string} [newDescription] - Optional new description to search for
|
|
@@ -301,7 +339,10 @@ class Element {
|
|
|
301
339
|
this.description = newDescription;
|
|
302
340
|
}
|
|
303
341
|
|
|
304
|
-
|
|
342
|
+
// Capture absolute timestamp at the very start of the command
|
|
343
|
+
// Frontend will calculate relative time using: timestamp - replay.clientStartDate
|
|
344
|
+
const absoluteTimestamp = Date.now();
|
|
345
|
+
const startTime = absoluteTimestamp;
|
|
305
346
|
let response = null;
|
|
306
347
|
let findError = null;
|
|
307
348
|
|
|
@@ -380,8 +421,6 @@ class Element {
|
|
|
380
421
|
|
|
381
422
|
const duration = Date.now() - startTime;
|
|
382
423
|
|
|
383
|
-
console.log("AI Response Text:", response?.response.content[0]?.text);
|
|
384
|
-
|
|
385
424
|
if (response && response.coordinates) {
|
|
386
425
|
// Store response but clear large base64 data to prevent memory leaks
|
|
387
426
|
this._response = this._sanitizeResponse(response);
|
|
@@ -394,6 +433,14 @@ class Element {
|
|
|
394
433
|
this._response = this._sanitizeResponse(response);
|
|
395
434
|
this._found = false;
|
|
396
435
|
findError = "Element not found";
|
|
436
|
+
|
|
437
|
+
// Log not found
|
|
438
|
+
const duration = Date.now() - startTime;
|
|
439
|
+
const { events } = require("./agent/events.js");
|
|
440
|
+
const notFoundMessage = formatter.formatElementNotFound(description, {
|
|
441
|
+
duration: `${duration}ms`,
|
|
442
|
+
});
|
|
443
|
+
this.sdk.emitter.emit(events.log.log, notFoundMessage);
|
|
397
444
|
}
|
|
398
445
|
} catch (error) {
|
|
399
446
|
this._response = error.response
|
|
@@ -402,27 +449,36 @@ class Element {
|
|
|
402
449
|
this._found = false;
|
|
403
450
|
findError = error.message;
|
|
404
451
|
response = error.response;
|
|
452
|
+
|
|
453
|
+
// Log not found with error
|
|
454
|
+
const duration = Date.now() - startTime;
|
|
455
|
+
const { events } = require("./agent/events.js");
|
|
456
|
+
const notFoundMessage = formatter.formatElementNotFound(description, {
|
|
457
|
+
duration: `${duration}ms`,
|
|
458
|
+
error: error.message,
|
|
459
|
+
});
|
|
460
|
+
this.sdk.emitter.emit(events.log.log, notFoundMessage);
|
|
461
|
+
|
|
462
|
+
console.error("Error during find():", error);
|
|
405
463
|
}
|
|
406
464
|
|
|
407
|
-
// Track find interaction once at the end
|
|
465
|
+
// Track find interaction once at the end (fire-and-forget, don't block)
|
|
408
466
|
const sessionId = this.sdk.getSessionId();
|
|
409
467
|
if (sessionId && this.sdk.sandbox?.send) {
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
});
|
|
423
|
-
} catch (err) {
|
|
468
|
+
await this.sdk.sandbox.send({
|
|
469
|
+
type: "trackInteraction",
|
|
470
|
+
interactionType: "find",
|
|
471
|
+
session: sessionId,
|
|
472
|
+
prompt: description,
|
|
473
|
+
timestamp: absoluteTimestamp, // Absolute epoch timestamp - frontend calculates relative using clientStartDate
|
|
474
|
+
success: this._found,
|
|
475
|
+
error: findError,
|
|
476
|
+
cacheHit: response?.cacheHit || response?.cache_hit || response?.cached || false,
|
|
477
|
+
selector: response?.selector,
|
|
478
|
+
selectorUsed: !!response?.selector,
|
|
479
|
+
}).catch(err => {
|
|
424
480
|
console.warn("Failed to track find interaction:", err.message);
|
|
425
|
-
}
|
|
481
|
+
});
|
|
426
482
|
}
|
|
427
483
|
|
|
428
484
|
return this;
|
|
@@ -470,11 +526,15 @@ class Element {
|
|
|
470
526
|
|
|
471
527
|
// Emit element found as log:log event
|
|
472
528
|
const { events } = require("./agent/events.js");
|
|
529
|
+
const Dashcam = require("./lib/core/Dashcam");
|
|
530
|
+
const consoleUrl = Dashcam.getConsoleUrl(this.sdk.config?.TD_API_ROOT);
|
|
473
531
|
const formattedMessage = formatter.formatElementFound(this.description, {
|
|
474
532
|
x: this.coordinates.x,
|
|
475
533
|
y: this.coordinates.y,
|
|
476
534
|
duration: debugInfo.duration,
|
|
477
535
|
cacheHit: debugInfo.cacheHit,
|
|
536
|
+
selectorId: this._response?.selector,
|
|
537
|
+
consoleUrl: consoleUrl,
|
|
478
538
|
});
|
|
479
539
|
this.sdk.emitter.emit(events.log.log, formattedMessage);
|
|
480
540
|
|
|
@@ -1079,7 +1139,6 @@ class TestDriverSDK {
|
|
|
1079
1139
|
|
|
1080
1140
|
// Store sandbox configuration options
|
|
1081
1141
|
this.sandboxAmi = options.sandboxAmi || null;
|
|
1082
|
-
this.sandboxOs = options.sandboxOs || null;
|
|
1083
1142
|
this.sandboxInstance = options.sandboxInstance || null;
|
|
1084
1143
|
|
|
1085
1144
|
// Cache threshold configuration
|
|
@@ -1233,26 +1292,18 @@ class TestDriverSDK {
|
|
|
1233
1292
|
|
|
1234
1293
|
await this.exec(shell, createLogCmd, 10000, true);
|
|
1235
1294
|
|
|
1236
|
-
console.log('[provision.chrome] Adding web logs to dashcam...');
|
|
1237
|
-
try {
|
|
1238
1295
|
const urlObj = new URL(url);
|
|
1239
1296
|
const domain = urlObj.hostname;
|
|
1240
1297
|
const pattern = `*${domain}*`;
|
|
1241
1298
|
await this._dashcam.addWebLog(pattern, 'Web Logs');
|
|
1242
|
-
console.log(`[provision.chrome] ✅ Web logs added to dashcam (pattern: ${pattern})`);
|
|
1243
1299
|
|
|
1244
1300
|
await this._dashcam.addFileLog(logPath, "TestDriver Log");
|
|
1245
1301
|
|
|
1246
|
-
} catch (error) {
|
|
1247
|
-
console.warn('[provision.chrome] ⚠️ Failed to add web logs:', error.message);
|
|
1248
|
-
}
|
|
1249
1302
|
}
|
|
1250
1303
|
|
|
1251
1304
|
// Automatically start dashcam if not already recording
|
|
1252
1305
|
if (!this._dashcam || !this._dashcam.recording) {
|
|
1253
|
-
console.log('[provision.chrome] Starting dashcam...');
|
|
1254
1306
|
await this.dashcam.start();
|
|
1255
|
-
console.log('[provision.chrome] ✅ Dashcam started');
|
|
1256
1307
|
}
|
|
1257
1308
|
|
|
1258
1309
|
// Set up Chrome profile with preferences
|
|
@@ -1303,17 +1354,17 @@ class TestDriverSDK {
|
|
|
1303
1354
|
|
|
1304
1355
|
const prefsJson = JSON.stringify(chromePrefs, null, 2);
|
|
1305
1356
|
const writePrefCmd = this.os === 'windows'
|
|
1306
|
-
|
|
1357
|
+
// Use compact JSON and [System.IO.File]::WriteAllText to avoid Set-Content hanging issues
|
|
1358
|
+
? `[System.IO.File]::WriteAllText("${prefsPath}", '${JSON.stringify(chromePrefs).replace(/'/g, "''")}')`
|
|
1307
1359
|
: `cat > "${prefsPath}" << 'EOF'\n${prefsJson}\nEOF`;
|
|
1308
1360
|
|
|
1309
1361
|
await this.exec(shell, writePrefCmd, 10000, true);
|
|
1310
|
-
console.log('[provision.chrome] ✅ Chrome preferences configured');
|
|
1311
1362
|
|
|
1312
1363
|
// Build Chrome launch command
|
|
1313
1364
|
const chromeArgs = [];
|
|
1314
1365
|
if (maximized) chromeArgs.push('--start-maximized');
|
|
1315
1366
|
if (guest) chromeArgs.push('--guest');
|
|
1316
|
-
chromeArgs.push('--disable-fre', '--no-default-browser-check', '--no-first-run', '--disable-infobars', `--user-data-dir=${userDataDir}`);
|
|
1367
|
+
chromeArgs.push('--disable-fre', '--no-default-browser-check', '--no-first-run', '--no-experiments', '--disable-infobars', `--user-data-dir=${userDataDir}`);
|
|
1317
1368
|
|
|
1318
1369
|
// Add dashcam-chrome extension on Linux
|
|
1319
1370
|
if (this.os === 'linux') {
|
|
@@ -1341,30 +1392,305 @@ class TestDriverSDK {
|
|
|
1341
1392
|
// Wait for Chrome to be ready
|
|
1342
1393
|
await this.focusApplication('Google Chrome');
|
|
1343
1394
|
|
|
1344
|
-
|
|
1345
1395
|
// Wait for URL to load
|
|
1346
1396
|
try {
|
|
1347
1397
|
const urlObj = new URL(url);
|
|
1348
1398
|
const domain = urlObj.hostname;
|
|
1399
|
+
|
|
1400
|
+
for (let attempt = 0; attempt < 30; attempt++) {
|
|
1401
|
+
const result = await this.find(`${domain}`);
|
|
1402
|
+
|
|
1403
|
+
if (result.found()) {
|
|
1404
|
+
break;
|
|
1405
|
+
} else {
|
|
1406
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1349
1409
|
|
|
1350
|
-
|
|
1410
|
+
await this.focusApplication('Google Chrome');
|
|
1411
|
+
} catch (e) {
|
|
1412
|
+
console.warn(`[provision.chrome] ⚠️ Could not parse URL "${url}":`, e.message);
|
|
1413
|
+
}
|
|
1414
|
+
},
|
|
1415
|
+
|
|
1416
|
+
/**
|
|
1417
|
+
* Launch Chrome browser with a custom extension loaded
|
|
1418
|
+
* @param {Object} options - Chrome extension launch options
|
|
1419
|
+
* @param {string} [options.extensionPath] - Local filesystem path to the unpacked extension directory
|
|
1420
|
+
* @param {string} [options.extensionId] - Chrome Web Store extension ID (e.g., "cjpalhdlnbpafiamejdnhcphjbkeiagm" for uBlock Origin)
|
|
1421
|
+
* @param {string} [options.url='http://testdriver-sandbox.vercel.app/'] - URL to navigate to
|
|
1422
|
+
* @param {boolean} [options.maximized=true] - Start maximized
|
|
1423
|
+
* @returns {Promise<void>}
|
|
1424
|
+
* @example
|
|
1425
|
+
* // Load extension from local path
|
|
1426
|
+
* await testdriver.exec('sh', 'git clone https://github.com/user/extension.git /tmp/extension');
|
|
1427
|
+
* await testdriver.provision.chromeExtension({
|
|
1428
|
+
* extensionPath: '/tmp/extension',
|
|
1429
|
+
* url: 'https://example.com'
|
|
1430
|
+
* });
|
|
1431
|
+
*
|
|
1432
|
+
* @example
|
|
1433
|
+
* // Load extension by Chrome Web Store ID
|
|
1434
|
+
* await testdriver.provision.chromeExtension({
|
|
1435
|
+
* extensionId: 'cjpalhdlnbpafiamejdnhcphjbkeiagm', // uBlock Origin
|
|
1436
|
+
* url: 'https://example.com'
|
|
1437
|
+
* });
|
|
1438
|
+
*/
|
|
1439
|
+
chromeExtension: async (options = {}) => {
|
|
1440
|
+
// Automatically wait for connection to be ready
|
|
1441
|
+
await this.ready();
|
|
1442
|
+
|
|
1443
|
+
const {
|
|
1444
|
+
extensionPath: providedExtensionPath,
|
|
1445
|
+
extensionId,
|
|
1446
|
+
url = 'http://testdriver-sandbox.vercel.app/',
|
|
1447
|
+
maximized = true,
|
|
1448
|
+
} = options;
|
|
1449
|
+
|
|
1450
|
+
if (!providedExtensionPath && !extensionId) {
|
|
1451
|
+
throw new Error('[provision.chromeExtension] Either extensionPath or extensionId is required');
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
let extensionPath = providedExtensionPath;
|
|
1455
|
+
const shell = this.os === 'windows' ? 'pwsh' : 'sh';
|
|
1456
|
+
|
|
1457
|
+
// If extensionId is provided, download and extract the extension from Chrome Web Store
|
|
1458
|
+
if (extensionId && !extensionPath) {
|
|
1459
|
+
console.log(`[provision.chromeExtension] Downloading extension ${extensionId} from Chrome Web Store...`);
|
|
1460
|
+
|
|
1461
|
+
const extensionDir = this.os === 'windows'
|
|
1462
|
+
? `C:\\Users\\testdriver\\AppData\\Local\\TestDriver\\Extensions\\${extensionId}`
|
|
1463
|
+
: `/tmp/testdriver-extensions/${extensionId}`;
|
|
1464
|
+
|
|
1465
|
+
// Create extension directory
|
|
1466
|
+
const mkdirCmd = this.os === 'windows'
|
|
1467
|
+
? `New-Item -ItemType Directory -Path "${extensionDir}" -Force | Out-Null`
|
|
1468
|
+
: `mkdir -p "${extensionDir}"`;
|
|
1469
|
+
await this.exec(shell, mkdirCmd, 10000, true);
|
|
1470
|
+
|
|
1471
|
+
// Download CRX from Chrome Web Store
|
|
1472
|
+
// The CRX download URL format for Chrome Web Store
|
|
1473
|
+
const crxUrl = `https://clients2.google.com/service/update2/crx?response=redirect&prodversion=131.0.0.0&acceptformat=crx2,crx3&x=id%3D${extensionId}%26installsource%3Dondemand%26uc`;
|
|
1474
|
+
const crxPath = this.os === 'windows'
|
|
1475
|
+
? `${extensionDir}\\extension.crx`
|
|
1476
|
+
: `${extensionDir}/extension.crx`;
|
|
1477
|
+
|
|
1478
|
+
if (this.os === 'windows') {
|
|
1479
|
+
await this.exec(
|
|
1480
|
+
'pwsh',
|
|
1481
|
+
`Invoke-WebRequest -Uri "${crxUrl}" -OutFile "${crxPath}"`,
|
|
1482
|
+
60000,
|
|
1483
|
+
true
|
|
1484
|
+
);
|
|
1485
|
+
} else {
|
|
1486
|
+
await this.exec(
|
|
1487
|
+
'sh',
|
|
1488
|
+
`curl -L -o "${crxPath}" "${crxUrl}"`,
|
|
1489
|
+
60000,
|
|
1490
|
+
true
|
|
1491
|
+
);
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
// Extract the CRX file (CRX is a ZIP with a header)
|
|
1495
|
+
// Skip the CRX header and extract as ZIP
|
|
1496
|
+
if (this.os === 'windows') {
|
|
1497
|
+
// PowerShell: Read CRX, skip header, extract ZIP
|
|
1498
|
+
await this.exec(
|
|
1499
|
+
'pwsh',
|
|
1500
|
+
`
|
|
1501
|
+
$crxBytes = [System.IO.File]::ReadAllBytes("${crxPath}")
|
|
1502
|
+
# CRX3 header: 4 bytes magic + 4 bytes version + 4 bytes header length + header
|
|
1503
|
+
$magic = [System.Text.Encoding]::ASCII.GetString($crxBytes[0..3])
|
|
1504
|
+
if ($magic -eq "Cr24") {
|
|
1505
|
+
$headerLen = [BitConverter]::ToUInt32($crxBytes, 8)
|
|
1506
|
+
$zipStart = 12 + $headerLen
|
|
1507
|
+
} else {
|
|
1508
|
+
# CRX2 format
|
|
1509
|
+
$zipStart = 16 + [BitConverter]::ToUInt32($crxBytes, 8) + [BitConverter]::ToUInt32($crxBytes, 12)
|
|
1510
|
+
}
|
|
1511
|
+
$zipBytes = $crxBytes[$zipStart..($crxBytes.Length - 1)]
|
|
1512
|
+
$zipPath = "${extensionDir}\\extension.zip"
|
|
1513
|
+
[System.IO.File]::WriteAllBytes($zipPath, $zipBytes)
|
|
1514
|
+
Expand-Archive -Path $zipPath -DestinationPath "${extensionDir}\\unpacked" -Force
|
|
1515
|
+
`,
|
|
1516
|
+
30000,
|
|
1517
|
+
true
|
|
1518
|
+
);
|
|
1519
|
+
extensionPath = `${extensionDir}\\unpacked`;
|
|
1520
|
+
} else {
|
|
1521
|
+
// Linux: Use unzip with offset or python to extract
|
|
1522
|
+
await this.exec(
|
|
1523
|
+
'sh',
|
|
1524
|
+
`
|
|
1525
|
+
cd "${extensionDir}"
|
|
1526
|
+
# Extract CRX (skip header and unzip)
|
|
1527
|
+
# CRX3 format: magic(4) + version(4) + header_length(4) + header + zip
|
|
1528
|
+
python3 -c "
|
|
1529
|
+
import struct
|
|
1530
|
+
import zipfile
|
|
1531
|
+
import io
|
|
1532
|
+
import os
|
|
1533
|
+
|
|
1534
|
+
with open('extension.crx', 'rb') as f:
|
|
1535
|
+
data = f.read()
|
|
1536
|
+
|
|
1537
|
+
# Check magic number
|
|
1538
|
+
magic = data[:4]
|
|
1539
|
+
if magic == b'Cr24':
|
|
1540
|
+
# CRX3 format
|
|
1541
|
+
header_len = struct.unpack('<I', data[8:12])[0]
|
|
1542
|
+
zip_start = 12 + header_len
|
|
1543
|
+
else:
|
|
1544
|
+
# CRX2 format
|
|
1545
|
+
pub_key_len = struct.unpack('<I', data[8:12])[0]
|
|
1546
|
+
sig_len = struct.unpack('<I', data[12:16])[0]
|
|
1547
|
+
zip_start = 16 + pub_key_len + sig_len
|
|
1548
|
+
|
|
1549
|
+
zip_data = data[zip_start:]
|
|
1550
|
+
os.makedirs('unpacked', exist_ok=True)
|
|
1551
|
+
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
1552
|
+
zf.extractall('unpacked')
|
|
1553
|
+
"
|
|
1554
|
+
`,
|
|
1555
|
+
30000,
|
|
1556
|
+
true
|
|
1557
|
+
);
|
|
1558
|
+
extensionPath = `${extensionDir}/unpacked`;
|
|
1559
|
+
}
|
|
1351
1560
|
|
|
1561
|
+
console.log(`[provision.chromeExtension] Extension ${extensionId} extracted to ${extensionPath}`);
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
// If dashcam is available and recording, add web logs for this domain
|
|
1565
|
+
if (this._dashcam) {
|
|
1566
|
+
// Create the log file on the remote machine
|
|
1567
|
+
const logPath = this.os === "windows"
|
|
1568
|
+
? "C:\\Users\\testdriver\\Documents\\testdriver.log"
|
|
1569
|
+
: "/tmp/testdriver.log";
|
|
1570
|
+
|
|
1571
|
+
const createLogCmd = this.os === "windows"
|
|
1572
|
+
? `New-Item -ItemType File -Path "${logPath}" -Force | Out-Null`
|
|
1573
|
+
: `touch ${logPath}`;
|
|
1574
|
+
|
|
1575
|
+
await this.exec(shell, createLogCmd, 10000, true);
|
|
1576
|
+
|
|
1577
|
+
const urlObj = new URL(url);
|
|
1578
|
+
const domain = urlObj.hostname;
|
|
1579
|
+
const pattern = `*${domain}*`;
|
|
1580
|
+
await this._dashcam.addWebLog(pattern, 'Web Logs');
|
|
1581
|
+
await this._dashcam.addFileLog(logPath, "TestDriver Log");
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
// Automatically start dashcam if not already recording
|
|
1585
|
+
if (!this._dashcam || !this._dashcam.recording) {
|
|
1586
|
+
await this.dashcam.start();
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
// Set up Chrome profile with preferences
|
|
1590
|
+
const userDataDir = this.os === 'windows'
|
|
1591
|
+
? 'C:\\Users\\testdriver\\AppData\\Local\\TestDriver\\Chrome'
|
|
1592
|
+
: '/tmp/testdriver-chrome-profile';
|
|
1593
|
+
|
|
1594
|
+
// Create user data directory and Default profile directory
|
|
1595
|
+
const defaultProfileDir = this.os === 'windows'
|
|
1596
|
+
? `${userDataDir}\\Default`
|
|
1597
|
+
: `${userDataDir}/Default`;
|
|
1598
|
+
|
|
1599
|
+
const createDirCmd = this.os === 'windows'
|
|
1600
|
+
? `New-Item -ItemType Directory -Path "${defaultProfileDir}" -Force | Out-Null`
|
|
1601
|
+
: `mkdir -p "${defaultProfileDir}"`;
|
|
1602
|
+
|
|
1603
|
+
await this.exec(shell, createDirCmd, 10000, true);
|
|
1604
|
+
|
|
1605
|
+
// Write Chrome preferences
|
|
1606
|
+
const chromePrefs = {
|
|
1607
|
+
credentials_enable_service: false,
|
|
1608
|
+
profile: {
|
|
1609
|
+
password_manager_enabled: false,
|
|
1610
|
+
default_content_setting_values: {}
|
|
1611
|
+
},
|
|
1612
|
+
signin: {
|
|
1613
|
+
allowed: false
|
|
1614
|
+
},
|
|
1615
|
+
sync: {
|
|
1616
|
+
requested: false,
|
|
1617
|
+
first_setup_complete: true,
|
|
1618
|
+
sync_all_os_types: false
|
|
1619
|
+
},
|
|
1620
|
+
autofill: {
|
|
1621
|
+
enabled: false
|
|
1622
|
+
},
|
|
1623
|
+
local_state: {
|
|
1624
|
+
browser: {
|
|
1625
|
+
has_seen_welcome_page: true
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
};
|
|
1629
|
+
|
|
1630
|
+
const prefsPath = this.os === 'windows'
|
|
1631
|
+
? `${defaultProfileDir}\\Preferences`
|
|
1632
|
+
: `${defaultProfileDir}/Preferences`;
|
|
1633
|
+
|
|
1634
|
+
const prefsJson = JSON.stringify(chromePrefs, null, 2);
|
|
1635
|
+
const writePrefCmd = this.os === 'windows'
|
|
1636
|
+
// Use compact JSON and [System.IO.File]::WriteAllText to avoid Set-Content hanging issues
|
|
1637
|
+
? `[System.IO.File]::WriteAllText("${prefsPath}", '${JSON.stringify(chromePrefs).replace(/'/g, "''")}')`
|
|
1638
|
+
: `cat > "${prefsPath}" << 'EOF'\n${prefsJson}\nEOF`;
|
|
1639
|
+
|
|
1640
|
+
await this.exec(shell, writePrefCmd, 10000, true);
|
|
1641
|
+
|
|
1642
|
+
// Build Chrome launch command
|
|
1643
|
+
const chromeArgs = [];
|
|
1644
|
+
if (maximized) chromeArgs.push('--start-maximized');
|
|
1645
|
+
chromeArgs.push('--disable-fre', '--no-default-browser-check', '--no-first-run', '--no-experiments', '--disable-infobars', '--disable-features=ChromeLabs', `--user-data-dir=${userDataDir}`);
|
|
1646
|
+
|
|
1647
|
+
// Add user extension and dashcam-chrome extension
|
|
1648
|
+
if (this.os === 'linux') {
|
|
1649
|
+
// Load both user extension and dashcam-chrome for web log capture
|
|
1650
|
+
chromeArgs.push(`--load-extension=${extensionPath},/usr/lib/node_modules/dashcam-chrome/build`);
|
|
1651
|
+
} else if (this.os === 'windows') {
|
|
1652
|
+
// On Windows, just load the user extension (dashcam-chrome not available)
|
|
1653
|
+
chromeArgs.push(`--load-extension=${extensionPath}`);
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
// Launch Chrome
|
|
1657
|
+
if (this.os === 'windows') {
|
|
1658
|
+
const argsString = chromeArgs.map(arg => `"${arg}"`).join(', ');
|
|
1659
|
+
await this.exec(
|
|
1660
|
+
shell,
|
|
1661
|
+
`Start-Process "C:/Program Files/Google/Chrome/Application/chrome.exe" -ArgumentList ${argsString}, "${url}"`,
|
|
1662
|
+
30000
|
|
1663
|
+
);
|
|
1664
|
+
} else {
|
|
1665
|
+
const argsString = chromeArgs.join(' ');
|
|
1666
|
+
await this.exec(
|
|
1667
|
+
shell,
|
|
1668
|
+
`chrome-for-testing ${argsString} "${url}" >/dev/null 2>&1 &`,
|
|
1669
|
+
30000
|
|
1670
|
+
);
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
// Wait for Chrome to be ready
|
|
1674
|
+
await this.focusApplication('Google Chrome');
|
|
1675
|
+
|
|
1676
|
+
// Wait for URL to load
|
|
1677
|
+
try {
|
|
1678
|
+
const urlObj = new URL(url);
|
|
1679
|
+
const domain = urlObj.hostname;
|
|
1680
|
+
|
|
1352
1681
|
for (let attempt = 0; attempt < 30; attempt++) {
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
} catch (e) {
|
|
1360
|
-
// Not found yet, continue polling
|
|
1682
|
+
const result = await this.find(`${domain}`);
|
|
1683
|
+
|
|
1684
|
+
if (result.found()) {
|
|
1685
|
+
break;
|
|
1686
|
+
} else {
|
|
1687
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
1361
1688
|
}
|
|
1362
|
-
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
1363
1689
|
}
|
|
1364
1690
|
|
|
1365
1691
|
await this.focusApplication('Google Chrome');
|
|
1366
1692
|
} catch (e) {
|
|
1367
|
-
console.warn(`[provision.
|
|
1693
|
+
console.warn(`[provision.chromeExtension] ⚠️ Could not parse URL "${url}":`, e.message);
|
|
1368
1694
|
}
|
|
1369
1695
|
},
|
|
1370
1696
|
|
|
@@ -1414,7 +1740,6 @@ class TestDriverSDK {
|
|
|
1414
1740
|
|
|
1415
1741
|
// Wait for VS Code to be ready
|
|
1416
1742
|
await this.focusApplication('Visual Studio Code');
|
|
1417
|
-
console.log('[provision.vscode] ✅ VS Code ready');
|
|
1418
1743
|
},
|
|
1419
1744
|
|
|
1420
1745
|
/**
|
|
@@ -1451,7 +1776,6 @@ class TestDriverSDK {
|
|
|
1451
1776
|
}
|
|
1452
1777
|
|
|
1453
1778
|
await this.focusApplication('Electron');
|
|
1454
|
-
console.log('[provision.electron] ✅ Electron app ready');
|
|
1455
1779
|
},
|
|
1456
1780
|
};
|
|
1457
1781
|
}
|
|
@@ -1534,13 +1858,11 @@ class TestDriverSDK {
|
|
|
1534
1858
|
} else if (this.sandboxInstance) {
|
|
1535
1859
|
this.agent.sandboxInstance = this.sandboxInstance;
|
|
1536
1860
|
}
|
|
1537
|
-
// Use os from connectOptions if provided, otherwise fall back to
|
|
1861
|
+
// Use os from connectOptions if provided, otherwise fall back to this.os
|
|
1538
1862
|
if (connectOptions.os !== undefined) {
|
|
1539
1863
|
this.agent.sandboxOs = connectOptions.os;
|
|
1540
|
-
|
|
1541
|
-
this.agent.sandboxOs = this.sandboxOs;
|
|
1864
|
+
this.os = connectOptions.os; // Update this.os to match
|
|
1542
1865
|
} else {
|
|
1543
|
-
// Fall back to this.os (which defaults to "linux")
|
|
1544
1866
|
this.agent.sandboxOs = this.os;
|
|
1545
1867
|
}
|
|
1546
1868
|
|
|
@@ -1553,6 +1875,17 @@ class TestDriverSDK {
|
|
|
1553
1875
|
// Get the instance from the agent
|
|
1554
1876
|
this.instance = this.agent.instance;
|
|
1555
1877
|
|
|
1878
|
+
// Ensure this.os reflects the actual sandbox OS (important for vitest reporter)
|
|
1879
|
+
// After buildEnv, agent.sandboxOs should contain the correct OS value
|
|
1880
|
+
if (this.agent.sandboxOs) {
|
|
1881
|
+
this.os = this.agent.sandboxOs;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
// Also ensure sandbox.os is set for consistency
|
|
1885
|
+
if (this.agent.sandbox && this.os) {
|
|
1886
|
+
this.agent.sandbox.os = this.os;
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1556
1889
|
// Expose the agent's commands, parser, and commander
|
|
1557
1890
|
this.commands = this.agent.commands;
|
|
1558
1891
|
|
|
@@ -1689,7 +2022,10 @@ class TestDriverSDK {
|
|
|
1689
2022
|
async findAll(description, options) {
|
|
1690
2023
|
this._ensureConnected();
|
|
1691
2024
|
|
|
1692
|
-
|
|
2025
|
+
// Capture absolute timestamp at the very start of the command
|
|
2026
|
+
// Frontend will calculate relative time using: timestamp - replay.clientStartDate
|
|
2027
|
+
const absoluteTimestamp = Date.now();
|
|
2028
|
+
const startTime = absoluteTimestamp;
|
|
1693
2029
|
|
|
1694
2030
|
// Log finding all action
|
|
1695
2031
|
const { events } = require("./agent/events.js");
|
|
@@ -1798,25 +2134,23 @@ class TestDriverSDK {
|
|
|
1798
2134
|
return element;
|
|
1799
2135
|
});
|
|
1800
2136
|
|
|
1801
|
-
// Track successful findAll interaction
|
|
2137
|
+
// Track successful findAll interaction (fire-and-forget, don't block)
|
|
1802
2138
|
const sessionId = this.getSessionId();
|
|
1803
2139
|
if (sessionId && this.sandbox?.send) {
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
});
|
|
1817
|
-
} catch (err) {
|
|
2140
|
+
this.sandbox.send({
|
|
2141
|
+
type: "trackInteraction",
|
|
2142
|
+
interactionType: "findAll",
|
|
2143
|
+
session: sessionId,
|
|
2144
|
+
prompt: description,
|
|
2145
|
+
timestamp: absoluteTimestamp, // Absolute epoch timestamp - frontend calculates relative using clientStartDate
|
|
2146
|
+
success: true,
|
|
2147
|
+
input: { count: elements.length },
|
|
2148
|
+
cacheHit: response.cached || false,
|
|
2149
|
+
selector: response.selector,
|
|
2150
|
+
selectorUsed: !!response.selector,
|
|
2151
|
+
}).catch(err => {
|
|
1818
2152
|
console.warn("Failed to track findAll interaction:", err.message);
|
|
1819
|
-
}
|
|
2153
|
+
});
|
|
1820
2154
|
}
|
|
1821
2155
|
|
|
1822
2156
|
// Log debug information when elements are found
|
|
@@ -1835,49 +2169,45 @@ class TestDriverSDK {
|
|
|
1835
2169
|
|
|
1836
2170
|
return elements;
|
|
1837
2171
|
} else {
|
|
1838
|
-
// No elements found - track interaction
|
|
2172
|
+
// No elements found - track interaction (fire-and-forget, don't block)
|
|
1839
2173
|
const sessionId = this.getSessionId();
|
|
1840
2174
|
if (sessionId && this.sandbox?.send) {
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
});
|
|
1855
|
-
} catch (err) {
|
|
2175
|
+
this.sandbox.send({
|
|
2176
|
+
type: "trackInteraction",
|
|
2177
|
+
interactionType: "findAll",
|
|
2178
|
+
session: sessionId,
|
|
2179
|
+
prompt: description,
|
|
2180
|
+
timestamp: absoluteTimestamp, // Absolute epoch timestamp - frontend calculates relative using clientStartDate
|
|
2181
|
+
success: false,
|
|
2182
|
+
error: "No elements found",
|
|
2183
|
+
input: { count: 0 },
|
|
2184
|
+
cacheHit: response?.cached || false,
|
|
2185
|
+
selector: response?.selector,
|
|
2186
|
+
selectorUsed: !!response?.selector,
|
|
2187
|
+
}).catch(err => {
|
|
1856
2188
|
console.warn("Failed to track findAll interaction:", err.message);
|
|
1857
|
-
}
|
|
2189
|
+
});
|
|
1858
2190
|
}
|
|
1859
2191
|
|
|
1860
2192
|
// No elements found - return empty array
|
|
1861
2193
|
return [];
|
|
1862
2194
|
}
|
|
1863
2195
|
} catch (error) {
|
|
1864
|
-
// Track findAll error interaction
|
|
2196
|
+
// Track findAll error interaction (fire-and-forget, don't block)
|
|
1865
2197
|
const sessionId = this.getSessionId();
|
|
1866
2198
|
if (sessionId && this.sandbox?.send) {
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
});
|
|
1878
|
-
} catch (err) {
|
|
2199
|
+
this.sandbox.send({
|
|
2200
|
+
type: "trackInteraction",
|
|
2201
|
+
interactionType: "findAll",
|
|
2202
|
+
session: sessionId,
|
|
2203
|
+
prompt: description,
|
|
2204
|
+
timestamp: absoluteTimestamp, // Absolute epoch timestamp - frontend calculates relative using clientStartDate
|
|
2205
|
+
success: false,
|
|
2206
|
+
error: error.message,
|
|
2207
|
+
input: { count: 0 },
|
|
2208
|
+
}).catch(err => {
|
|
1879
2209
|
console.warn("Failed to track findAll interaction:", err.message);
|
|
1880
|
-
}
|
|
2210
|
+
});
|
|
1881
2211
|
}
|
|
1882
2212
|
|
|
1883
2213
|
const { events } = require("./agent/events.js");
|
|
@@ -2101,15 +2431,15 @@ class TestDriverSDK {
|
|
|
2101
2431
|
*/
|
|
2102
2432
|
doc: "Focus an application by name",
|
|
2103
2433
|
},
|
|
2104
|
-
|
|
2105
|
-
name: "
|
|
2434
|
+
extract: {
|
|
2435
|
+
name: "extract",
|
|
2106
2436
|
/**
|
|
2107
|
-
* Extract
|
|
2437
|
+
* Extract information from the screen using AI
|
|
2108
2438
|
* @param {Object|string} options - Options object or description (legacy positional)
|
|
2109
|
-
* @param {string} options.description - What to
|
|
2439
|
+
* @param {string} options.description - What to extract
|
|
2110
2440
|
* @returns {Promise<string>}
|
|
2111
2441
|
*/
|
|
2112
|
-
doc: "Extract
|
|
2442
|
+
doc: "Extract information from the screen",
|
|
2113
2443
|
},
|
|
2114
2444
|
assert: {
|
|
2115
2445
|
name: "assert",
|
|
@@ -2270,6 +2600,7 @@ class TestDriverSDK {
|
|
|
2270
2600
|
_setupLogging() {
|
|
2271
2601
|
// Track the last fatal error message to throw on exit
|
|
2272
2602
|
let lastFatalError = null;
|
|
2603
|
+
const debugMode = process.env.VERBOSE || process.env.DEBUG || process.env.TD_DEBUG;
|
|
2273
2604
|
|
|
2274
2605
|
// Set up markdown logger
|
|
2275
2606
|
createMarkdownLogger(this.emitter);
|
|
@@ -2277,7 +2608,7 @@ class TestDriverSDK {
|
|
|
2277
2608
|
// Set up basic event logging
|
|
2278
2609
|
this.emitter.on("log:**", (message) => {
|
|
2279
2610
|
const event = this.emitter.event;
|
|
2280
|
-
if (event === events.log.debug) return;
|
|
2611
|
+
if (event === events.log.debug && !debugMode) return;
|
|
2281
2612
|
if (this.loggingEnabled && message) {
|
|
2282
2613
|
const prefixedMessage = this.testContext
|
|
2283
2614
|
? `[${this.testContext}] ${message}`
|
|
@@ -2307,23 +2638,6 @@ class TestDriverSDK {
|
|
|
2307
2638
|
}
|
|
2308
2639
|
});
|
|
2309
2640
|
|
|
2310
|
-
// Handle redraw status for debugging scroll and other async operations
|
|
2311
|
-
this.emitter.on("redraw:status", (status) => {
|
|
2312
|
-
if (this.loggingEnabled) {
|
|
2313
|
-
console.log(
|
|
2314
|
-
`[redraw] screen:${status.redraw.text} network:${status.network.text} timeout:${status.timeout.text}`,
|
|
2315
|
-
);
|
|
2316
|
-
}
|
|
2317
|
-
});
|
|
2318
|
-
|
|
2319
|
-
this.emitter.on("redraw:complete", (info) => {
|
|
2320
|
-
if (this.loggingEnabled) {
|
|
2321
|
-
console.log(
|
|
2322
|
-
`[redraw complete] screen:${info.screenHasRedrawn} network:${info.networkSettled} timeout:${info.isTimeout} elapsed:${info.timeElapsed}ms`,
|
|
2323
|
-
);
|
|
2324
|
-
}
|
|
2325
|
-
});
|
|
2326
|
-
|
|
2327
2641
|
// Handle exit events - throw error with meaningful message instead of calling process.exit
|
|
2328
2642
|
// This allows test frameworks like Vitest to properly catch and display the error
|
|
2329
2643
|
this.emitter.on(events.exit, (exitCode) => {
|
|
@@ -2341,7 +2655,7 @@ class TestDriverSDK {
|
|
|
2341
2655
|
this.emitter.on("show-window", async (url) => {
|
|
2342
2656
|
if (this.loggingEnabled) {
|
|
2343
2657
|
console.log("");
|
|
2344
|
-
console.log("Live test execution:");
|
|
2658
|
+
console.log("🔗 Live test execution:");
|
|
2345
2659
|
if (this.config.CI) {
|
|
2346
2660
|
// In CI mode, just print the view-only URL
|
|
2347
2661
|
const u = new URL(url);
|
|
@@ -2356,6 +2670,7 @@ class TestDriverSDK {
|
|
|
2356
2670
|
console.log(url);
|
|
2357
2671
|
await this._openBrowser(url);
|
|
2358
2672
|
}
|
|
2673
|
+
|
|
2359
2674
|
}
|
|
2360
2675
|
});
|
|
2361
2676
|
}
|