testdriverai 7.2.55 → 7.2.57
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/.env.example +2 -0
- package/.github/workflows/acceptance-windows-scheduled.yaml +29 -28
- package/.github/workflows/acceptance.yaml +54 -52
- package/.github/workflows/testdriver.yml +157 -156
- package/.github/workflows/windows-self-hosted.yaml +60 -46
- package/docs/docs.json +1 -0
- package/docs/v7/captcha.mdx +160 -0
- package/examples/captcha-api.test.mjs +50 -0
- package/examples/hover-text-with-description.test.mjs +9 -6
- package/interfaces/cli/commands/init.js +48 -21
- package/lib/captcha/solver.js +296 -0
- package/lib/core/Dashcam.js +135 -95
- package/lib/vitest/hooks.mjs +175 -126
- package/lib/vitest/setup-aws.mjs +69 -46
- package/package.json +1 -1
- package/sdk.d.ts +67 -20
- package/sdk.js +733 -402
- package/test/captcha-solver.test.mjs +70 -0
- package/test/chrome-remote-debugging.test.mjs +66 -0
- package/vitest.config.mjs +10 -6
package/sdk.js
CHANGED
|
@@ -19,13 +19,15 @@ function getCallerFilePath() {
|
|
|
19
19
|
// Look for the first file that's not sdk.js, hooks.mjs, or node internals
|
|
20
20
|
for (const callSite of stack) {
|
|
21
21
|
const fileName = callSite.getFileName();
|
|
22
|
-
if (
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
22
|
+
if (
|
|
23
|
+
fileName &&
|
|
24
|
+
!fileName.includes("sdk.js") &&
|
|
25
|
+
!fileName.includes("hooks.mjs") &&
|
|
26
|
+
!fileName.includes("hooks.js") &&
|
|
27
|
+
!fileName.includes("node_modules") &&
|
|
28
|
+
!fileName.includes("node:internal") &&
|
|
29
|
+
fileName !== "evalmachine.<anonymous>"
|
|
30
|
+
) {
|
|
29
31
|
return fileName;
|
|
30
32
|
}
|
|
31
33
|
}
|
|
@@ -50,12 +52,12 @@ function getCallerFileHash() {
|
|
|
50
52
|
try {
|
|
51
53
|
// Handle file:// URLs by converting to file system path
|
|
52
54
|
let fsPath = filePath;
|
|
53
|
-
if (filePath.startsWith(
|
|
54
|
-
fsPath = filePath.replace(
|
|
55
|
+
if (filePath.startsWith("file://")) {
|
|
56
|
+
fsPath = filePath.replace("file://", "");
|
|
55
57
|
}
|
|
56
|
-
|
|
57
|
-
const fileContent = fs.readFileSync(fsPath,
|
|
58
|
-
const hash = crypto.createHash(
|
|
58
|
+
|
|
59
|
+
const fileContent = fs.readFileSync(fsPath, "utf-8");
|
|
60
|
+
const hash = crypto.createHash("sha256").update(fileContent).digest("hex");
|
|
59
61
|
// Return first 16 chars of hash for brevity
|
|
60
62
|
return hash.substring(0, 16);
|
|
61
63
|
} catch (error) {
|
|
@@ -298,7 +300,7 @@ class AIError extends Error {
|
|
|
298
300
|
this.message += `\nTries: ${this.tries}/${this.maxTries}`;
|
|
299
301
|
this.message += `\nDuration: ${this.duration}ms`;
|
|
300
302
|
this.message += `\nTimestamp: ${this.timestamp}`;
|
|
301
|
-
|
|
303
|
+
|
|
302
304
|
if (this.cause) {
|
|
303
305
|
this.message += `\nUnderlying error: ${this.cause.message}`;
|
|
304
306
|
}
|
|
@@ -349,17 +351,21 @@ class Element {
|
|
|
349
351
|
// Include response metadata if available
|
|
350
352
|
if (this._response) {
|
|
351
353
|
result.cache = {
|
|
352
|
-
hit:
|
|
354
|
+
hit:
|
|
355
|
+
this._response.cacheHit ||
|
|
356
|
+
this._response.cache_hit ||
|
|
357
|
+
this._response.cached ||
|
|
358
|
+
false,
|
|
353
359
|
strategy: this._response.cacheStrategy,
|
|
354
360
|
createdAt: this._response.cacheCreatedAt,
|
|
355
361
|
diffPercent: this._response.cacheDiffPercent,
|
|
356
362
|
imageUrl: this._response.cachedImageUrl,
|
|
357
363
|
};
|
|
358
|
-
|
|
364
|
+
|
|
359
365
|
result.similarity = this._response.similarity;
|
|
360
366
|
result.confidence = this._response.confidence;
|
|
361
367
|
result.selector = this._response.selector;
|
|
362
|
-
|
|
368
|
+
|
|
363
369
|
// Include AI response text if available
|
|
364
370
|
if (this._response.response?.content?.[0]?.text) {
|
|
365
371
|
result.aiResponse = this._response.response.content[0].text;
|
|
@@ -378,7 +384,7 @@ class Element {
|
|
|
378
384
|
*/
|
|
379
385
|
async find(newDescription, options) {
|
|
380
386
|
// Handle timeout/polling option
|
|
381
|
-
const timeout = typeof options ===
|
|
387
|
+
const timeout = typeof options === "object" ? options?.timeout : null;
|
|
382
388
|
if (timeout && timeout > 0) {
|
|
383
389
|
return this._findWithTimeout(newDescription, options, timeout);
|
|
384
390
|
}
|
|
@@ -414,11 +420,11 @@ class Element {
|
|
|
414
420
|
let cacheKey = null;
|
|
415
421
|
let cacheThreshold = null;
|
|
416
422
|
let zoom = false; // Default to disabled, enable with zoom: true
|
|
417
|
-
|
|
418
|
-
if (typeof options ===
|
|
423
|
+
|
|
424
|
+
if (typeof options === "number") {
|
|
419
425
|
// Legacy: options is just a number threshold
|
|
420
426
|
cacheThreshold = options;
|
|
421
|
-
} else if (typeof options ===
|
|
427
|
+
} else if (typeof options === "object" && options !== null) {
|
|
422
428
|
// New: options is an object with cacheKey and/or cacheThreshold
|
|
423
429
|
cacheKey = options.cacheKey || null;
|
|
424
430
|
cacheThreshold = options.cacheThreshold ?? null;
|
|
@@ -428,11 +434,15 @@ class Element {
|
|
|
428
434
|
|
|
429
435
|
// Use default cacheKey from SDK constructor if not provided in find() options
|
|
430
436
|
// BUT only if cache is not explicitly disabled via cache: false option
|
|
431
|
-
if (
|
|
437
|
+
if (
|
|
438
|
+
!cacheKey &&
|
|
439
|
+
this.sdk.options?.cacheKey &&
|
|
440
|
+
!this.sdk._cacheExplicitlyDisabled
|
|
441
|
+
) {
|
|
432
442
|
cacheKey = this.sdk.options.cacheKey;
|
|
433
443
|
}
|
|
434
444
|
|
|
435
|
-
// Determine threshold:
|
|
445
|
+
// Determine threshold:
|
|
436
446
|
// - If cache is explicitly disabled, don't use cache even with cacheKey
|
|
437
447
|
// - If cacheKey is provided, enable cache with threshold
|
|
438
448
|
// - If no cacheKey, disable cache
|
|
@@ -458,9 +468,11 @@ class Element {
|
|
|
458
468
|
// Debug log threshold
|
|
459
469
|
if (debugMode) {
|
|
460
470
|
const { events } = require("./agent/events.js");
|
|
461
|
-
const autoGenMsg =
|
|
462
|
-
|
|
463
|
-
|
|
471
|
+
const autoGenMsg =
|
|
472
|
+
this.sdk._autoGeneratedCacheKey &&
|
|
473
|
+
cacheKey === this.sdk.options.cacheKey
|
|
474
|
+
? " (auto-generated from file hash)"
|
|
475
|
+
: "";
|
|
464
476
|
this.sdk.emitter.emit(
|
|
465
477
|
events.log.debug,
|
|
466
478
|
`🔍 find() threshold: ${threshold} (cache ${threshold < 0 ? "DISABLED" : "ENABLED"}${cacheKey ? `, cacheKey: ${cacheKey}${autoGenMsg}` : ""})`,
|
|
@@ -492,7 +504,7 @@ class Element {
|
|
|
492
504
|
this._response = this._sanitizeResponse(response);
|
|
493
505
|
this._found = false;
|
|
494
506
|
findError = "Element not found";
|
|
495
|
-
|
|
507
|
+
|
|
496
508
|
// Log not found
|
|
497
509
|
const duration = Date.now() - startTime;
|
|
498
510
|
const { events } = require("./agent/events.js");
|
|
@@ -508,7 +520,7 @@ class Element {
|
|
|
508
520
|
this._found = false;
|
|
509
521
|
findError = error.message;
|
|
510
522
|
response = error.response;
|
|
511
|
-
|
|
523
|
+
|
|
512
524
|
// Log not found with error
|
|
513
525
|
const duration = Date.now() - startTime;
|
|
514
526
|
const { events } = require("./agent/events.js");
|
|
@@ -517,27 +529,33 @@ class Element {
|
|
|
517
529
|
error: error.message,
|
|
518
530
|
});
|
|
519
531
|
this.sdk.emitter.emit(events.log.log, notFoundMessage);
|
|
520
|
-
|
|
532
|
+
|
|
521
533
|
console.error("Error during find():", error);
|
|
522
534
|
}
|
|
523
535
|
|
|
524
536
|
// Track find interaction once at the end (fire-and-forget, don't block)
|
|
525
537
|
const sessionId = this.sdk.getSessionId();
|
|
526
538
|
if (sessionId && this.sdk.sandbox?.send) {
|
|
527
|
-
await this.sdk.sandbox
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
539
|
+
await this.sdk.sandbox
|
|
540
|
+
.send({
|
|
541
|
+
type: "trackInteraction",
|
|
542
|
+
interactionType: "find",
|
|
543
|
+
session: sessionId,
|
|
544
|
+
prompt: description,
|
|
545
|
+
timestamp: absoluteTimestamp, // Absolute epoch timestamp - frontend calculates relative using clientStartDate
|
|
546
|
+
success: this._found,
|
|
547
|
+
error: findError,
|
|
548
|
+
cacheHit:
|
|
549
|
+
response?.cacheHit ||
|
|
550
|
+
response?.cache_hit ||
|
|
551
|
+
response?.cached ||
|
|
552
|
+
false,
|
|
553
|
+
selector: response?.selector,
|
|
554
|
+
selectorUsed: !!response?.selector,
|
|
555
|
+
})
|
|
556
|
+
.catch((err) => {
|
|
557
|
+
console.warn("Failed to track find interaction:", err.message);
|
|
558
|
+
});
|
|
541
559
|
}
|
|
542
560
|
|
|
543
561
|
return this;
|
|
@@ -555,46 +573,58 @@ class Element {
|
|
|
555
573
|
const POLL_INTERVAL = 5000; // 5 seconds between attempts
|
|
556
574
|
const startTime = Date.now();
|
|
557
575
|
const description = newDescription || this.description;
|
|
558
|
-
|
|
576
|
+
|
|
559
577
|
// Log that we're starting a polling find
|
|
560
578
|
const { events } = require("./agent/events.js");
|
|
561
|
-
this.sdk.emitter.emit(
|
|
562
|
-
|
|
579
|
+
this.sdk.emitter.emit(
|
|
580
|
+
events.log.log,
|
|
581
|
+
`🔄 Polling for "${description}" (timeout: ${timeout}ms)`,
|
|
582
|
+
);
|
|
583
|
+
|
|
563
584
|
// Create options without timeout to avoid infinite recursion
|
|
564
|
-
const findOptions = typeof options ===
|
|
585
|
+
const findOptions = typeof options === "object" ? { ...options } : {};
|
|
565
586
|
delete findOptions.timeout;
|
|
566
|
-
|
|
587
|
+
|
|
567
588
|
let attempts = 0;
|
|
568
589
|
while (Date.now() - startTime < timeout) {
|
|
569
590
|
attempts++;
|
|
570
|
-
|
|
591
|
+
|
|
571
592
|
// Call the regular find (without timeout option)
|
|
572
593
|
await this.find(newDescription, findOptions);
|
|
573
|
-
|
|
594
|
+
|
|
574
595
|
if (this._found) {
|
|
575
|
-
this.sdk.emitter.emit(
|
|
596
|
+
this.sdk.emitter.emit(
|
|
597
|
+
events.log.log,
|
|
598
|
+
`✅ Found "${description}" after ${attempts} attempt(s)`,
|
|
599
|
+
);
|
|
576
600
|
return this;
|
|
577
601
|
}
|
|
578
|
-
|
|
602
|
+
|
|
579
603
|
const elapsed = Date.now() - startTime;
|
|
580
604
|
const remaining = timeout - elapsed;
|
|
581
|
-
|
|
605
|
+
|
|
582
606
|
if (remaining > POLL_INTERVAL) {
|
|
583
|
-
this.sdk.emitter.emit(
|
|
584
|
-
|
|
607
|
+
this.sdk.emitter.emit(
|
|
608
|
+
events.log.log,
|
|
609
|
+
`⏳ Element not found, retrying in 5s... (${Math.round(remaining / 1000)}s remaining)`,
|
|
610
|
+
);
|
|
611
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
|
|
585
612
|
} else if (remaining > 0) {
|
|
586
613
|
// Less than 5s remaining, wait the remaining time and try once more
|
|
587
|
-
await new Promise(resolve => setTimeout(resolve, remaining));
|
|
614
|
+
await new Promise((resolve) => setTimeout(resolve, remaining));
|
|
588
615
|
}
|
|
589
616
|
}
|
|
590
|
-
|
|
617
|
+
|
|
591
618
|
// Final attempt after timeout
|
|
592
619
|
await this.find(newDescription, findOptions);
|
|
593
|
-
|
|
620
|
+
|
|
594
621
|
if (!this._found) {
|
|
595
|
-
this.sdk.emitter.emit(
|
|
622
|
+
this.sdk.emitter.emit(
|
|
623
|
+
events.log.log,
|
|
624
|
+
`❌ Element "${description}" not found after ${timeout}ms (${attempts} attempts)`,
|
|
625
|
+
);
|
|
596
626
|
}
|
|
597
|
-
|
|
627
|
+
|
|
598
628
|
return this;
|
|
599
629
|
}
|
|
600
630
|
|
|
@@ -881,13 +911,22 @@ class Element {
|
|
|
881
911
|
edgeDetectedImageUrl: this._response?.edgeSavedImagePath || null,
|
|
882
912
|
cacheHit: this._response?.cacheHit,
|
|
883
913
|
selectorUsed: !!this._response?.selector,
|
|
884
|
-
selector: this._response?.selector
|
|
914
|
+
selector: this._response?.selector,
|
|
885
915
|
};
|
|
886
916
|
|
|
887
917
|
if (action === "hover") {
|
|
888
|
-
await this.commands.hover(
|
|
918
|
+
await this.commands.hover(
|
|
919
|
+
this.coordinates.x,
|
|
920
|
+
this.coordinates.y,
|
|
921
|
+
elementData,
|
|
922
|
+
);
|
|
889
923
|
} else {
|
|
890
|
-
await this.commands.click(
|
|
924
|
+
await this.commands.click(
|
|
925
|
+
this.coordinates.x,
|
|
926
|
+
this.coordinates.y,
|
|
927
|
+
action,
|
|
928
|
+
elementData,
|
|
929
|
+
);
|
|
891
930
|
}
|
|
892
931
|
}
|
|
893
932
|
|
|
@@ -921,10 +960,14 @@ class Element {
|
|
|
921
960
|
edgeDetectedImageUrl: this._response?.edgeSavedImagePath || null,
|
|
922
961
|
cacheHit: this._response?.cacheHit,
|
|
923
962
|
selectorUsed: !!this._response?.selector,
|
|
924
|
-
selector: this._response?.selector
|
|
963
|
+
selector: this._response?.selector,
|
|
925
964
|
};
|
|
926
965
|
|
|
927
|
-
await this.commands.hover(
|
|
966
|
+
await this.commands.hover(
|
|
967
|
+
this.coordinates.x,
|
|
968
|
+
this.coordinates.y,
|
|
969
|
+
elementData,
|
|
970
|
+
);
|
|
928
971
|
}
|
|
929
972
|
|
|
930
973
|
/**
|
|
@@ -1119,54 +1162,69 @@ class Element {
|
|
|
1119
1162
|
/**
|
|
1120
1163
|
* Creates a chainable promise that allows method chaining on find() results
|
|
1121
1164
|
* This enables syntax like: await testdriver.find("button").click()
|
|
1122
|
-
*
|
|
1165
|
+
*
|
|
1123
1166
|
* @param {Promise<Element>} promise - The promise that resolves to an Element
|
|
1124
1167
|
* @returns {Promise<Element> & ChainableElement} A promise with chainable element methods
|
|
1125
1168
|
*/
|
|
1126
1169
|
function createChainablePromise(promise) {
|
|
1127
1170
|
// Define the chainable methods that should be available
|
|
1128
|
-
const chainableMethods = [
|
|
1129
|
-
|
|
1171
|
+
const chainableMethods = [
|
|
1172
|
+
"click",
|
|
1173
|
+
"hover",
|
|
1174
|
+
"doubleClick",
|
|
1175
|
+
"rightClick",
|
|
1176
|
+
"mouseDown",
|
|
1177
|
+
"mouseUp",
|
|
1178
|
+
];
|
|
1179
|
+
|
|
1130
1180
|
// Create a new promise that wraps the original
|
|
1131
|
-
const chainablePromise = promise.then(element => element);
|
|
1132
|
-
|
|
1181
|
+
const chainablePromise = promise.then((element) => element);
|
|
1182
|
+
|
|
1133
1183
|
// Add chainable methods to the promise
|
|
1134
1184
|
for (const method of chainableMethods) {
|
|
1135
|
-
chainablePromise[method] = function(...args) {
|
|
1185
|
+
chainablePromise[method] = function (...args) {
|
|
1136
1186
|
// Return a promise that waits for the element, then calls the method
|
|
1137
|
-
return promise.then(element => element[method](...args));
|
|
1187
|
+
return promise.then((element) => element[method](...args));
|
|
1138
1188
|
};
|
|
1139
1189
|
}
|
|
1140
|
-
|
|
1190
|
+
|
|
1141
1191
|
// Add getters for element properties (these return promises)
|
|
1142
|
-
Object.defineProperty(chainablePromise,
|
|
1143
|
-
get() {
|
|
1192
|
+
Object.defineProperty(chainablePromise, "x", {
|
|
1193
|
+
get() {
|
|
1194
|
+
return promise.then((el) => el.x);
|
|
1195
|
+
},
|
|
1144
1196
|
});
|
|
1145
|
-
Object.defineProperty(chainablePromise,
|
|
1146
|
-
get() {
|
|
1197
|
+
Object.defineProperty(chainablePromise, "y", {
|
|
1198
|
+
get() {
|
|
1199
|
+
return promise.then((el) => el.y);
|
|
1200
|
+
},
|
|
1147
1201
|
});
|
|
1148
|
-
Object.defineProperty(chainablePromise,
|
|
1149
|
-
get() {
|
|
1202
|
+
Object.defineProperty(chainablePromise, "centerX", {
|
|
1203
|
+
get() {
|
|
1204
|
+
return promise.then((el) => el.centerX);
|
|
1205
|
+
},
|
|
1150
1206
|
});
|
|
1151
|
-
Object.defineProperty(chainablePromise,
|
|
1152
|
-
get() {
|
|
1207
|
+
Object.defineProperty(chainablePromise, "centerY", {
|
|
1208
|
+
get() {
|
|
1209
|
+
return promise.then((el) => el.centerY);
|
|
1210
|
+
},
|
|
1153
1211
|
});
|
|
1154
|
-
|
|
1212
|
+
|
|
1155
1213
|
// Add found() method
|
|
1156
|
-
chainablePromise.found = function() {
|
|
1157
|
-
return promise.then(el => el.found());
|
|
1214
|
+
chainablePromise.found = function () {
|
|
1215
|
+
return promise.then((el) => el.found());
|
|
1158
1216
|
};
|
|
1159
|
-
|
|
1217
|
+
|
|
1160
1218
|
// Add getCoordinates() method
|
|
1161
|
-
chainablePromise.getCoordinates = function() {
|
|
1162
|
-
return promise.then(el => el.getCoordinates());
|
|
1219
|
+
chainablePromise.getCoordinates = function () {
|
|
1220
|
+
return promise.then((el) => el.getCoordinates());
|
|
1163
1221
|
};
|
|
1164
|
-
|
|
1222
|
+
|
|
1165
1223
|
// Add getResponse() method
|
|
1166
|
-
chainablePromise.getResponse = function() {
|
|
1167
|
-
return promise.then(el => el.getResponse());
|
|
1224
|
+
chainablePromise.getResponse = function () {
|
|
1225
|
+
return promise.then((el) => el.getResponse());
|
|
1168
1226
|
};
|
|
1169
|
-
|
|
1227
|
+
|
|
1170
1228
|
return chainablePromise;
|
|
1171
1229
|
}
|
|
1172
1230
|
|
|
@@ -1256,7 +1314,8 @@ class TestDriverSDK {
|
|
|
1256
1314
|
this.sandboxInstance = options.sandboxInstance || null;
|
|
1257
1315
|
|
|
1258
1316
|
// Store reconnect preference from options
|
|
1259
|
-
this.reconnect =
|
|
1317
|
+
this.reconnect =
|
|
1318
|
+
options.reconnect !== undefined ? options.reconnect : false;
|
|
1260
1319
|
|
|
1261
1320
|
// Store dashcam preference (default: true)
|
|
1262
1321
|
this.dashcamEnabled = options.dashcam !== false;
|
|
@@ -1281,7 +1340,7 @@ class TestDriverSDK {
|
|
|
1281
1340
|
} else {
|
|
1282
1341
|
// Cache enabled by default when cacheKey is provided
|
|
1283
1342
|
this.cacheThresholds = {
|
|
1284
|
-
find: options.cacheThreshold?.find ?? 0.01,
|
|
1343
|
+
find: options.cacheThreshold?.find ?? 0.01, // Default: 1% threshold
|
|
1285
1344
|
findAll: options.cacheThreshold?.findAll ?? 0.01,
|
|
1286
1345
|
};
|
|
1287
1346
|
}
|
|
@@ -1293,14 +1352,16 @@ class TestDriverSDK {
|
|
|
1293
1352
|
// The `redraw` option takes precedence and matches the per-command API
|
|
1294
1353
|
if (options.redraw !== undefined) {
|
|
1295
1354
|
// New unified API: redraw object (matches per-command options)
|
|
1296
|
-
this.redrawOptions =
|
|
1297
|
-
|
|
1298
|
-
|
|
1355
|
+
this.redrawOptions =
|
|
1356
|
+
typeof options.redraw === "object"
|
|
1357
|
+
? options.redraw
|
|
1358
|
+
: { enabled: options.redraw }; // Support redraw: false as shorthand
|
|
1299
1359
|
} else if (options.redrawThreshold !== undefined) {
|
|
1300
1360
|
// Legacy API: redrawThreshold number or object
|
|
1301
|
-
this.redrawOptions =
|
|
1302
|
-
|
|
1303
|
-
|
|
1361
|
+
this.redrawOptions =
|
|
1362
|
+
typeof options.redrawThreshold === "object"
|
|
1363
|
+
? options.redrawThreshold
|
|
1364
|
+
: { diffThreshold: options.redrawThreshold };
|
|
1304
1365
|
} else {
|
|
1305
1366
|
// Default: enabled (as of v7.2)
|
|
1306
1367
|
this.redrawOptions = { enabled: true };
|
|
@@ -1354,7 +1415,7 @@ class TestDriverSDK {
|
|
|
1354
1415
|
await this.__connectionPromise;
|
|
1355
1416
|
}
|
|
1356
1417
|
if (!this.connected) {
|
|
1357
|
-
throw new Error(
|
|
1418
|
+
throw new Error("Not connected to sandbox. Call connect() first.");
|
|
1358
1419
|
}
|
|
1359
1420
|
}
|
|
1360
1421
|
|
|
@@ -1411,18 +1472,18 @@ class TestDriverSDK {
|
|
|
1411
1472
|
* @private
|
|
1412
1473
|
*/
|
|
1413
1474
|
async _getDashcamChromeExtensionPath() {
|
|
1414
|
-
if (this.os !==
|
|
1415
|
-
return
|
|
1475
|
+
if (this.os !== "windows") {
|
|
1476
|
+
return "/usr/lib/node_modules/dashcam-chrome/build";
|
|
1416
1477
|
}
|
|
1417
1478
|
|
|
1418
1479
|
// dashcam-chrome is preinstalled on Windows at C:\Program Files\nodejs\node_modules\dashcam-chrome\build
|
|
1419
1480
|
// Use the actual long path - we'll handle quoting in the chrome launch
|
|
1420
|
-
return
|
|
1481
|
+
return "C:\\PROGRA~1\\nodejs\\node_modules\\dashcam-chrome\\build";
|
|
1421
1482
|
}
|
|
1422
1483
|
|
|
1423
1484
|
_createProvisionAPI() {
|
|
1424
1485
|
const self = this;
|
|
1425
|
-
|
|
1486
|
+
|
|
1426
1487
|
const provisionMethods = {
|
|
1427
1488
|
/**
|
|
1428
1489
|
* Launch Chrome browser
|
|
@@ -1434,7 +1495,7 @@ class TestDriverSDK {
|
|
|
1434
1495
|
*/
|
|
1435
1496
|
chrome: async (options = {}) => {
|
|
1436
1497
|
const {
|
|
1437
|
-
url =
|
|
1498
|
+
url = "http://testdriver-sandbox.vercel.app/",
|
|
1438
1499
|
maximized = true,
|
|
1439
1500
|
guest = false,
|
|
1440
1501
|
} = options;
|
|
@@ -1442,69 +1503,84 @@ class TestDriverSDK {
|
|
|
1442
1503
|
// If dashcam is available, add web logs for all websites
|
|
1443
1504
|
// Note: File log and dashcam.start() are handled by the connection promise in hooks.mjs
|
|
1444
1505
|
if (this._dashcam) {
|
|
1445
|
-
await this._dashcam.addWebLog(
|
|
1506
|
+
await this._dashcam.addWebLog("**", "Web Logs");
|
|
1446
1507
|
}
|
|
1447
1508
|
|
|
1448
1509
|
// Set up Chrome profile with preferences
|
|
1449
|
-
const shell = this.os ===
|
|
1450
|
-
const userDataDir =
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1510
|
+
const shell = this.os === "windows" ? "pwsh" : "sh";
|
|
1511
|
+
const userDataDir =
|
|
1512
|
+
this.os === "windows"
|
|
1513
|
+
? "C:\\Users\\testdriver\\AppData\\Local\\TestDriver\\Chrome"
|
|
1514
|
+
: "/tmp/testdriver-chrome-profile";
|
|
1515
|
+
|
|
1454
1516
|
// Create user data directory and Default profile directory
|
|
1455
|
-
const defaultProfileDir =
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1517
|
+
const defaultProfileDir =
|
|
1518
|
+
this.os === "windows"
|
|
1519
|
+
? `${userDataDir}\\Default`
|
|
1520
|
+
: `${userDataDir}/Default`;
|
|
1521
|
+
|
|
1522
|
+
const createDirCmd =
|
|
1523
|
+
this.os === "windows"
|
|
1524
|
+
? `New-Item -ItemType Directory -Path "${defaultProfileDir}" -Force | Out-Null`
|
|
1525
|
+
: `mkdir -p "${defaultProfileDir}"`;
|
|
1526
|
+
|
|
1463
1527
|
await this.exec(shell, createDirCmd, 60000, true);
|
|
1464
|
-
|
|
1528
|
+
|
|
1465
1529
|
// Write Chrome preferences
|
|
1466
1530
|
const chromePrefs = {
|
|
1467
1531
|
credentials_enable_service: false,
|
|
1468
1532
|
profile: {
|
|
1469
1533
|
password_manager_enabled: false,
|
|
1470
|
-
default_content_setting_values: {}
|
|
1534
|
+
default_content_setting_values: {},
|
|
1471
1535
|
},
|
|
1472
1536
|
signin: {
|
|
1473
|
-
allowed: false
|
|
1537
|
+
allowed: false,
|
|
1474
1538
|
},
|
|
1475
1539
|
sync: {
|
|
1476
1540
|
requested: false,
|
|
1477
1541
|
first_setup_complete: true,
|
|
1478
|
-
sync_all_os_types: false
|
|
1542
|
+
sync_all_os_types: false,
|
|
1479
1543
|
},
|
|
1480
1544
|
autofill: {
|
|
1481
|
-
enabled: false
|
|
1545
|
+
enabled: false,
|
|
1482
1546
|
},
|
|
1483
1547
|
local_state: {
|
|
1484
1548
|
browser: {
|
|
1485
|
-
has_seen_welcome_page: true
|
|
1486
|
-
}
|
|
1487
|
-
}
|
|
1549
|
+
has_seen_welcome_page: true,
|
|
1550
|
+
},
|
|
1551
|
+
},
|
|
1488
1552
|
};
|
|
1489
|
-
|
|
1490
|
-
const prefsPath =
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1553
|
+
|
|
1554
|
+
const prefsPath =
|
|
1555
|
+
this.os === "windows"
|
|
1556
|
+
? `${defaultProfileDir}\\Preferences`
|
|
1557
|
+
: `${defaultProfileDir}/Preferences`;
|
|
1558
|
+
|
|
1494
1559
|
const prefsJson = JSON.stringify(chromePrefs, null, 2);
|
|
1495
|
-
const writePrefCmd =
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1560
|
+
const writePrefCmd =
|
|
1561
|
+
this.os === "windows"
|
|
1562
|
+
? // Use compact JSON and [System.IO.File]::WriteAllText to avoid Set-Content hanging issues
|
|
1563
|
+
`[System.IO.File]::WriteAllText("${prefsPath}", '${JSON.stringify(chromePrefs).replace(/'/g, "''")}')`
|
|
1564
|
+
: `cat > "${prefsPath}" << 'EOF'\n${prefsJson}\nEOF`;
|
|
1565
|
+
|
|
1500
1566
|
await this.exec(shell, writePrefCmd, 60000, true);
|
|
1501
1567
|
|
|
1502
1568
|
// Build Chrome launch command
|
|
1503
1569
|
const chromeArgs = [];
|
|
1504
|
-
if (maximized) chromeArgs.push(
|
|
1505
|
-
if (guest) chromeArgs.push(
|
|
1506
|
-
chromeArgs.push(
|
|
1507
|
-
|
|
1570
|
+
if (maximized) chromeArgs.push("--start-maximized");
|
|
1571
|
+
if (guest) chromeArgs.push("--guest");
|
|
1572
|
+
chromeArgs.push(
|
|
1573
|
+
"--disable-fre",
|
|
1574
|
+
"--no-default-browser-check",
|
|
1575
|
+
"--no-first-run",
|
|
1576
|
+
"--no-experiments",
|
|
1577
|
+
"--disable-infobars",
|
|
1578
|
+
`--user-data-dir=${userDataDir}`,
|
|
1579
|
+
);
|
|
1580
|
+
|
|
1581
|
+
// Add remote debugging port for captcha solving support
|
|
1582
|
+
chromeArgs.push("--remote-debugging-port=9222");
|
|
1583
|
+
|
|
1508
1584
|
// Add dashcam-chrome extension
|
|
1509
1585
|
const dashcamChromePath = await this._getDashcamChromeExtensionPath();
|
|
1510
1586
|
if (dashcamChromePath) {
|
|
@@ -1512,44 +1588,47 @@ class TestDriverSDK {
|
|
|
1512
1588
|
}
|
|
1513
1589
|
|
|
1514
1590
|
// Launch Chrome
|
|
1515
|
-
|
|
1516
|
-
if (this.os ===
|
|
1517
|
-
const argsString = chromeArgs.map(arg => `"${arg}"`).join(
|
|
1591
|
+
|
|
1592
|
+
if (this.os === "windows") {
|
|
1593
|
+
const argsString = chromeArgs.map((arg) => `"${arg}"`).join(", ");
|
|
1518
1594
|
await this.exec(
|
|
1519
1595
|
shell,
|
|
1520
1596
|
`Start-Process "C:\\ChromeForTesting\\chrome-win64\\chrome.exe" -ArgumentList ${argsString}, "${url}"`,
|
|
1521
|
-
30000
|
|
1597
|
+
30000,
|
|
1522
1598
|
);
|
|
1523
1599
|
} else {
|
|
1524
|
-
const argsString = chromeArgs.join(
|
|
1600
|
+
const argsString = chromeArgs.join(" ");
|
|
1525
1601
|
await this.exec(
|
|
1526
1602
|
shell,
|
|
1527
1603
|
`chrome-for-testing ${argsString} "${url}" >/dev/null 2>&1 &`,
|
|
1528
|
-
30000
|
|
1604
|
+
30000,
|
|
1529
1605
|
);
|
|
1530
1606
|
}
|
|
1531
1607
|
|
|
1532
1608
|
// Wait for Chrome to be ready
|
|
1533
|
-
await this.focusApplication(
|
|
1609
|
+
await this.focusApplication("Google Chrome");
|
|
1534
1610
|
|
|
1535
1611
|
// Wait for URL to load
|
|
1536
1612
|
try {
|
|
1537
1613
|
const urlObj = new URL(url);
|
|
1538
1614
|
const domain = urlObj.hostname;
|
|
1539
|
-
|
|
1615
|
+
|
|
1540
1616
|
for (let attempt = 0; attempt < 30; attempt++) {
|
|
1541
1617
|
const result = await this.find(`${domain}`);
|
|
1542
1618
|
|
|
1543
1619
|
if (result.found()) {
|
|
1544
1620
|
break;
|
|
1545
1621
|
} else {
|
|
1546
|
-
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
1622
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
1547
1623
|
}
|
|
1548
1624
|
}
|
|
1549
|
-
|
|
1550
|
-
await this.focusApplication(
|
|
1625
|
+
|
|
1626
|
+
await this.focusApplication("Google Chrome");
|
|
1551
1627
|
} catch (e) {
|
|
1552
|
-
console.warn(
|
|
1628
|
+
console.warn(
|
|
1629
|
+
`[provision.chrome] ⚠️ Could not parse URL "${url}":`,
|
|
1630
|
+
e.message,
|
|
1631
|
+
);
|
|
1553
1632
|
}
|
|
1554
1633
|
},
|
|
1555
1634
|
|
|
@@ -1566,7 +1645,7 @@ class TestDriverSDK {
|
|
|
1566
1645
|
* await testdriver.provision.chromeExtension({
|
|
1567
1646
|
* extensionPath: '/tmp/extension'
|
|
1568
1647
|
* });
|
|
1569
|
-
*
|
|
1648
|
+
*
|
|
1570
1649
|
* @example
|
|
1571
1650
|
* // Load extension by Chrome Web Store ID
|
|
1572
1651
|
* await testdriver.provision.chromeExtension({
|
|
@@ -1581,55 +1660,62 @@ class TestDriverSDK {
|
|
|
1581
1660
|
} = options;
|
|
1582
1661
|
|
|
1583
1662
|
if (!providedExtensionPath && !extensionId) {
|
|
1584
|
-
throw new Error(
|
|
1663
|
+
throw new Error(
|
|
1664
|
+
"[provision.chromeExtension] Either extensionPath or extensionId is required",
|
|
1665
|
+
);
|
|
1585
1666
|
}
|
|
1586
1667
|
|
|
1587
1668
|
let extensionPath = providedExtensionPath;
|
|
1588
|
-
const shell = this.os ===
|
|
1669
|
+
const shell = this.os === "windows" ? "pwsh" : "sh";
|
|
1589
1670
|
|
|
1590
1671
|
// If extensionId is provided, download and extract the extension from Chrome Web Store
|
|
1591
1672
|
if (extensionId && !extensionPath) {
|
|
1592
|
-
console.log(
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1673
|
+
console.log(
|
|
1674
|
+
`[provision.chromeExtension] Downloading extension ${extensionId} from Chrome Web Store...`,
|
|
1675
|
+
);
|
|
1676
|
+
|
|
1677
|
+
const extensionDir =
|
|
1678
|
+
this.os === "windows"
|
|
1679
|
+
? `C:\\Users\\testdriver\\AppData\\Local\\TestDriver\\Extensions\\${extensionId}`
|
|
1680
|
+
: `/tmp/testdriver-extensions/${extensionId}`;
|
|
1681
|
+
|
|
1598
1682
|
// Create extension directory
|
|
1599
|
-
const mkdirCmd =
|
|
1600
|
-
|
|
1601
|
-
|
|
1683
|
+
const mkdirCmd =
|
|
1684
|
+
this.os === "windows"
|
|
1685
|
+
? `New-Item -ItemType Directory -Path "${extensionDir}" -Force | Out-Null`
|
|
1686
|
+
: `mkdir -p "${extensionDir}"`;
|
|
1602
1687
|
await this.exec(shell, mkdirCmd, 60000, true);
|
|
1603
|
-
|
|
1688
|
+
|
|
1604
1689
|
// Download CRX from Chrome Web Store
|
|
1605
1690
|
// The CRX download URL format for Chrome Web Store
|
|
1606
1691
|
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`;
|
|
1607
|
-
const crxPath =
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1692
|
+
const crxPath =
|
|
1693
|
+
this.os === "windows"
|
|
1694
|
+
? `${extensionDir}\\extension.crx`
|
|
1695
|
+
: `${extensionDir}/extension.crx`;
|
|
1696
|
+
|
|
1697
|
+
if (this.os === "windows") {
|
|
1612
1698
|
await this.exec(
|
|
1613
|
-
|
|
1699
|
+
"pwsh",
|
|
1614
1700
|
`Invoke-WebRequest -Uri "${crxUrl}" -OutFile "${crxPath}"`,
|
|
1615
1701
|
60000,
|
|
1616
|
-
true
|
|
1702
|
+
true,
|
|
1617
1703
|
);
|
|
1618
1704
|
} else {
|
|
1619
1705
|
await this.exec(
|
|
1620
|
-
|
|
1706
|
+
"sh",
|
|
1621
1707
|
`curl -L -o "${crxPath}" "${crxUrl}"`,
|
|
1622
1708
|
60000,
|
|
1623
|
-
true
|
|
1709
|
+
true,
|
|
1624
1710
|
);
|
|
1625
1711
|
}
|
|
1626
|
-
|
|
1712
|
+
|
|
1627
1713
|
// Extract the CRX file (CRX is a ZIP with a header)
|
|
1628
1714
|
// Skip the CRX header and extract as ZIP
|
|
1629
|
-
if (this.os ===
|
|
1715
|
+
if (this.os === "windows") {
|
|
1630
1716
|
// PowerShell: Read CRX, skip header, extract ZIP
|
|
1631
1717
|
await this.exec(
|
|
1632
|
-
|
|
1718
|
+
"pwsh",
|
|
1633
1719
|
`
|
|
1634
1720
|
$crxBytes = [System.IO.File]::ReadAllBytes("${crxPath}")
|
|
1635
1721
|
# CRX3 header: 4 bytes magic + 4 bytes version + 4 bytes header length + header
|
|
@@ -1647,13 +1733,13 @@ $zipPath = "${extensionDir}\\extension.zip"
|
|
|
1647
1733
|
Expand-Archive -Path $zipPath -DestinationPath "${extensionDir}\\unpacked" -Force
|
|
1648
1734
|
`,
|
|
1649
1735
|
30000,
|
|
1650
|
-
true
|
|
1736
|
+
true,
|
|
1651
1737
|
);
|
|
1652
1738
|
extensionPath = `${extensionDir}\\unpacked`;
|
|
1653
1739
|
} else {
|
|
1654
1740
|
// Linux: Use unzip with offset or python to extract
|
|
1655
1741
|
await this.exec(
|
|
1656
|
-
|
|
1742
|
+
"sh",
|
|
1657
1743
|
`
|
|
1658
1744
|
cd "${extensionDir}"
|
|
1659
1745
|
# Extract CRX (skip header and unzip)
|
|
@@ -1686,120 +1772,140 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
1686
1772
|
"
|
|
1687
1773
|
`,
|
|
1688
1774
|
30000,
|
|
1689
|
-
true
|
|
1775
|
+
true,
|
|
1690
1776
|
);
|
|
1691
1777
|
extensionPath = `${extensionDir}/unpacked`;
|
|
1692
1778
|
}
|
|
1693
|
-
|
|
1694
|
-
console.log(
|
|
1779
|
+
|
|
1780
|
+
console.log(
|
|
1781
|
+
`[provision.chromeExtension] Extension ${extensionId} extracted to ${extensionPath}`,
|
|
1782
|
+
);
|
|
1695
1783
|
}
|
|
1696
1784
|
|
|
1697
1785
|
// If dashcam is available, add web logs for all websites
|
|
1698
1786
|
// Note: File log and dashcam.start() are handled by the connection promise in hooks.mjs
|
|
1699
1787
|
if (this._dashcam) {
|
|
1700
|
-
await this._dashcam.addWebLog(
|
|
1788
|
+
await this._dashcam.addWebLog("**", "Web Logs");
|
|
1701
1789
|
}
|
|
1702
1790
|
|
|
1703
1791
|
// Set up Chrome profile with preferences
|
|
1704
|
-
const userDataDir =
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1792
|
+
const userDataDir =
|
|
1793
|
+
this.os === "windows"
|
|
1794
|
+
? "C:\\Users\\testdriver\\AppData\\Local\\TestDriver\\Chrome"
|
|
1795
|
+
: "/tmp/testdriver-chrome-profile";
|
|
1796
|
+
|
|
1708
1797
|
// Create user data directory and Default profile directory
|
|
1709
|
-
const defaultProfileDir =
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1798
|
+
const defaultProfileDir =
|
|
1799
|
+
this.os === "windows"
|
|
1800
|
+
? `${userDataDir}\\Default`
|
|
1801
|
+
: `${userDataDir}/Default`;
|
|
1802
|
+
|
|
1803
|
+
const createDirCmd =
|
|
1804
|
+
this.os === "windows"
|
|
1805
|
+
? `New-Item -ItemType Directory -Path "${defaultProfileDir}" -Force | Out-Null`
|
|
1806
|
+
: `mkdir -p "${defaultProfileDir}"`;
|
|
1807
|
+
|
|
1717
1808
|
await this.exec(shell, createDirCmd, 60000, true);
|
|
1718
|
-
|
|
1809
|
+
|
|
1719
1810
|
// Write Chrome preferences
|
|
1720
1811
|
const chromePrefs = {
|
|
1721
1812
|
credentials_enable_service: false,
|
|
1722
1813
|
profile: {
|
|
1723
1814
|
password_manager_enabled: false,
|
|
1724
|
-
default_content_setting_values: {}
|
|
1815
|
+
default_content_setting_values: {},
|
|
1725
1816
|
},
|
|
1726
1817
|
signin: {
|
|
1727
|
-
allowed: false
|
|
1818
|
+
allowed: false,
|
|
1728
1819
|
},
|
|
1729
1820
|
sync: {
|
|
1730
1821
|
requested: false,
|
|
1731
1822
|
first_setup_complete: true,
|
|
1732
|
-
sync_all_os_types: false
|
|
1823
|
+
sync_all_os_types: false,
|
|
1733
1824
|
},
|
|
1734
1825
|
autofill: {
|
|
1735
|
-
enabled: false
|
|
1826
|
+
enabled: false,
|
|
1736
1827
|
},
|
|
1737
1828
|
local_state: {
|
|
1738
1829
|
browser: {
|
|
1739
|
-
has_seen_welcome_page: true
|
|
1740
|
-
}
|
|
1741
|
-
}
|
|
1830
|
+
has_seen_welcome_page: true,
|
|
1831
|
+
},
|
|
1832
|
+
},
|
|
1742
1833
|
};
|
|
1743
|
-
|
|
1744
|
-
const prefsPath =
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1834
|
+
|
|
1835
|
+
const prefsPath =
|
|
1836
|
+
this.os === "windows"
|
|
1837
|
+
? `${defaultProfileDir}\\Preferences`
|
|
1838
|
+
: `${defaultProfileDir}/Preferences`;
|
|
1839
|
+
|
|
1748
1840
|
const prefsJson = JSON.stringify(chromePrefs, null, 2);
|
|
1749
|
-
const writePrefCmd =
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1841
|
+
const writePrefCmd =
|
|
1842
|
+
this.os === "windows"
|
|
1843
|
+
? // Use compact JSON and [System.IO.File]::WriteAllText to avoid Set-Content hanging issues
|
|
1844
|
+
`[System.IO.File]::WriteAllText("${prefsPath}", '${JSON.stringify(chromePrefs).replace(/'/g, "''")}')`
|
|
1845
|
+
: `cat > "${prefsPath}" << 'EOF'\n${prefsJson}\nEOF`;
|
|
1846
|
+
|
|
1754
1847
|
await this.exec(shell, writePrefCmd, 60000, true);
|
|
1755
1848
|
|
|
1756
1849
|
// Build Chrome launch command
|
|
1757
1850
|
const chromeArgs = [];
|
|
1758
|
-
if (maximized) chromeArgs.push(
|
|
1759
|
-
chromeArgs.push(
|
|
1760
|
-
|
|
1851
|
+
if (maximized) chromeArgs.push("--start-maximized");
|
|
1852
|
+
chromeArgs.push(
|
|
1853
|
+
"--disable-fre",
|
|
1854
|
+
"--no-default-browser-check",
|
|
1855
|
+
"--no-first-run",
|
|
1856
|
+
"--no-experiments",
|
|
1857
|
+
"--disable-infobars",
|
|
1858
|
+
"--disable-features=ChromeLabs",
|
|
1859
|
+
`--user-data-dir=${userDataDir}`,
|
|
1860
|
+
);
|
|
1861
|
+
|
|
1862
|
+
// Add remote debugging port for captcha solving support
|
|
1863
|
+
chromeArgs.push("--remote-debugging-port=9222");
|
|
1864
|
+
|
|
1761
1865
|
// Add user extension and dashcam-chrome extension
|
|
1762
1866
|
const dashcamChromePath = await this._getDashcamChromeExtensionPath();
|
|
1763
1867
|
if (dashcamChromePath) {
|
|
1764
1868
|
// Load both user extension and dashcam-chrome for web log capture
|
|
1765
|
-
chromeArgs.push(
|
|
1869
|
+
chromeArgs.push(
|
|
1870
|
+
`--load-extension=${extensionPath},${dashcamChromePath}`,
|
|
1871
|
+
);
|
|
1766
1872
|
} else {
|
|
1767
1873
|
// If dashcam-chrome unavailable, just load user extension
|
|
1768
1874
|
chromeArgs.push(`--load-extension=${extensionPath}`);
|
|
1769
1875
|
}
|
|
1770
1876
|
|
|
1771
1877
|
// Launch Chrome (opens to New Tab by default)
|
|
1772
|
-
if (this.os ===
|
|
1773
|
-
const argsString = chromeArgs.map(arg => `"${arg}"`).join(
|
|
1878
|
+
if (this.os === "windows") {
|
|
1879
|
+
const argsString = chromeArgs.map((arg) => `"${arg}"`).join(", ");
|
|
1774
1880
|
await this.exec(
|
|
1775
1881
|
shell,
|
|
1776
1882
|
`Start-Process "C:\\ChromeForTesting\\chrome-win64\\chrome.exe" -ArgumentList ${argsString}`,
|
|
1777
|
-
30000
|
|
1883
|
+
30000,
|
|
1778
1884
|
);
|
|
1779
1885
|
} else {
|
|
1780
|
-
const argsString = chromeArgs.join(
|
|
1886
|
+
const argsString = chromeArgs.join(" ");
|
|
1781
1887
|
await this.exec(
|
|
1782
1888
|
shell,
|
|
1783
1889
|
`chrome-for-testing ${argsString} >/dev/null 2>&1 &`,
|
|
1784
|
-
30000
|
|
1890
|
+
30000,
|
|
1785
1891
|
);
|
|
1786
1892
|
}
|
|
1787
1893
|
|
|
1788
1894
|
// Wait for Chrome to be ready
|
|
1789
|
-
await this.focusApplication(
|
|
1895
|
+
await this.focusApplication("Google Chrome");
|
|
1790
1896
|
|
|
1791
1897
|
// Wait for New Tab to appear
|
|
1792
1898
|
for (let attempt = 0; attempt < 30; attempt++) {
|
|
1793
|
-
const result = await this.find(
|
|
1899
|
+
const result = await this.find("New Tab");
|
|
1794
1900
|
|
|
1795
1901
|
if (result.found()) {
|
|
1796
1902
|
break;
|
|
1797
1903
|
} else {
|
|
1798
|
-
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
1904
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
1799
1905
|
}
|
|
1800
1906
|
}
|
|
1801
|
-
|
|
1802
|
-
await this.focusApplication(
|
|
1907
|
+
|
|
1908
|
+
await this.focusApplication("Google Chrome");
|
|
1803
1909
|
},
|
|
1804
1910
|
|
|
1805
1911
|
/**
|
|
@@ -1810,17 +1916,14 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
1810
1916
|
* @returns {Promise<void>}
|
|
1811
1917
|
*/
|
|
1812
1918
|
vscode: async (options = {}) => {
|
|
1813
|
-
const {
|
|
1814
|
-
workspace = null,
|
|
1815
|
-
extensions = [],
|
|
1816
|
-
} = options;
|
|
1919
|
+
const { workspace = null, extensions = [] } = options;
|
|
1817
1920
|
|
|
1818
|
-
const shell = this.os ===
|
|
1921
|
+
const shell = this.os === "windows" ? "pwsh" : "sh";
|
|
1819
1922
|
|
|
1820
1923
|
// If dashcam is available, add web logs for all websites
|
|
1821
1924
|
// Note: File log and dashcam.start() are handled by the connection promise in hooks.mjs
|
|
1822
1925
|
if (this._dashcam) {
|
|
1823
|
-
await this._dashcam.addWebLog(
|
|
1926
|
+
await this._dashcam.addWebLog("**", "Web Logs");
|
|
1824
1927
|
}
|
|
1825
1928
|
|
|
1826
1929
|
// Install extensions if provided
|
|
@@ -1830,33 +1933,35 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
1830
1933
|
shell,
|
|
1831
1934
|
`code --install-extension ${extension} --force`,
|
|
1832
1935
|
120000,
|
|
1833
|
-
true
|
|
1936
|
+
true,
|
|
1937
|
+
);
|
|
1938
|
+
console.log(
|
|
1939
|
+
`[provision.vscode] ✅ Extension installed: ${extension}`,
|
|
1834
1940
|
);
|
|
1835
|
-
console.log(`[provision.vscode] ✅ Extension installed: ${extension}`);
|
|
1836
1941
|
}
|
|
1837
1942
|
|
|
1838
1943
|
// Launch VS Code
|
|
1839
|
-
const workspaceArg = workspace ? `"${workspace}"` :
|
|
1840
|
-
|
|
1841
|
-
if (this.os ===
|
|
1944
|
+
const workspaceArg = workspace ? `"${workspace}"` : "";
|
|
1945
|
+
|
|
1946
|
+
if (this.os === "windows") {
|
|
1842
1947
|
await this.exec(
|
|
1843
1948
|
shell,
|
|
1844
1949
|
`Start-Process code -ArgumentList ${workspaceArg}`,
|
|
1845
|
-
30000
|
|
1950
|
+
30000,
|
|
1846
1951
|
);
|
|
1847
1952
|
} else {
|
|
1848
1953
|
await this.exec(
|
|
1849
1954
|
shell,
|
|
1850
1955
|
`code ${workspaceArg} >/dev/null 2>&1 &`,
|
|
1851
|
-
30000
|
|
1956
|
+
30000,
|
|
1852
1957
|
);
|
|
1853
1958
|
}
|
|
1854
1959
|
|
|
1855
1960
|
// Wait for VS Code to start up
|
|
1856
|
-
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
1961
|
+
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
1857
1962
|
|
|
1858
1963
|
// Wait for VS Code to be ready
|
|
1859
|
-
await this.focusApplication(
|
|
1964
|
+
await this.focusApplication("Visual Studio Code");
|
|
1860
1965
|
},
|
|
1861
1966
|
|
|
1862
1967
|
/**
|
|
@@ -1873,7 +1978,7 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
1873
1978
|
* url: 'https://example.com/app.deb',
|
|
1874
1979
|
* appName: 'MyApp'
|
|
1875
1980
|
* });
|
|
1876
|
-
*
|
|
1981
|
+
*
|
|
1877
1982
|
* @example
|
|
1878
1983
|
* // Download and run custom commands
|
|
1879
1984
|
* const filePath = await testdriver.provision.installer({
|
|
@@ -1883,39 +1988,33 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
1883
1988
|
* await testdriver.exec('sh', `chmod +x "${filePath}" && "${filePath}" &`, 10000);
|
|
1884
1989
|
*/
|
|
1885
1990
|
installer: async (options = {}) => {
|
|
1886
|
-
const {
|
|
1887
|
-
url,
|
|
1888
|
-
filename,
|
|
1889
|
-
appName,
|
|
1890
|
-
launch = true,
|
|
1891
|
-
} = options;
|
|
1991
|
+
const { url, filename, appName, launch = true } = options;
|
|
1892
1992
|
|
|
1893
1993
|
if (!url) {
|
|
1894
|
-
throw new Error(
|
|
1994
|
+
throw new Error("[provision.installer] url is required");
|
|
1895
1995
|
}
|
|
1896
1996
|
|
|
1897
|
-
const shell = this.os ===
|
|
1997
|
+
const shell = this.os === "windows" ? "pwsh" : "sh";
|
|
1898
1998
|
|
|
1899
1999
|
// If dashcam is available, add web logs for all websites
|
|
1900
2000
|
// Note: File log and dashcam.start() are handled by the connection promise in hooks.mjs
|
|
1901
2001
|
if (this._dashcam) {
|
|
1902
|
-
await this._dashcam.addWebLog(
|
|
2002
|
+
await this._dashcam.addWebLog("**", "Web Logs");
|
|
1903
2003
|
}
|
|
1904
2004
|
|
|
1905
2005
|
// Determine download directory
|
|
1906
|
-
const downloadDir =
|
|
1907
|
-
?
|
|
1908
|
-
: '/tmp';
|
|
2006
|
+
const downloadDir =
|
|
2007
|
+
this.os === "windows" ? "C:\\Users\\testdriver\\Downloads" : "/tmp";
|
|
1909
2008
|
|
|
1910
2009
|
console.log(`[provision.installer] Downloading ${url}...`);
|
|
1911
2010
|
|
|
1912
2011
|
let actualFilePath;
|
|
1913
2012
|
|
|
1914
2013
|
// Download the file and get the actual filename (handles redirects)
|
|
1915
|
-
if (this.os ===
|
|
2014
|
+
if (this.os === "windows") {
|
|
1916
2015
|
// Simple approach: download first, then get the actual filename from the response
|
|
1917
2016
|
const tempFile = `${downloadDir}\\installer_temp_${Date.now()}`;
|
|
1918
|
-
|
|
2017
|
+
|
|
1919
2018
|
const downloadScript = `
|
|
1920
2019
|
$ProgressPreference = 'SilentlyContinue'
|
|
1921
2020
|
$response = Invoke-WebRequest -Uri "${url}" -OutFile "${tempFile}" -PassThru -UseBasicParsing
|
|
@@ -1942,12 +2041,12 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
1942
2041
|
Move-Item -Path "${tempFile}" -Destination $finalPath -Force
|
|
1943
2042
|
Write-Output $finalPath
|
|
1944
2043
|
`;
|
|
1945
|
-
|
|
2044
|
+
|
|
1946
2045
|
const result = await this.exec(shell, downloadScript, 300000, true);
|
|
1947
2046
|
actualFilePath = result ? result.trim() : null;
|
|
1948
|
-
|
|
2047
|
+
|
|
1949
2048
|
if (!actualFilePath) {
|
|
1950
|
-
throw new Error(
|
|
2049
|
+
throw new Error("[provision.installer] Failed to download file");
|
|
1951
2050
|
}
|
|
1952
2051
|
} else {
|
|
1953
2052
|
// Use curl with options to get the final filename
|
|
@@ -1956,21 +2055,21 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
1956
2055
|
cd "${downloadDir}"
|
|
1957
2056
|
curl -L -J -O -w "%{filename_effective}" "${url}" 2>/dev/null || echo "${tempMarker}"
|
|
1958
2057
|
`;
|
|
1959
|
-
|
|
2058
|
+
|
|
1960
2059
|
const result = await this.exec(shell, downloadScript, 300000, true);
|
|
1961
2060
|
const downloadedFile = result ? result.trim() : null;
|
|
1962
|
-
|
|
2061
|
+
|
|
1963
2062
|
if (downloadedFile && downloadedFile !== tempMarker) {
|
|
1964
2063
|
actualFilePath = `${downloadDir}/${downloadedFile}`;
|
|
1965
2064
|
} else {
|
|
1966
2065
|
// Fallback: use curl without -J and specify output file
|
|
1967
|
-
const fallbackFilename = filename ||
|
|
2066
|
+
const fallbackFilename = filename || "installer";
|
|
1968
2067
|
actualFilePath = `${downloadDir}/${fallbackFilename}`;
|
|
1969
2068
|
await this.exec(
|
|
1970
2069
|
shell,
|
|
1971
2070
|
`curl -L -o "${actualFilePath}" "${url}"`,
|
|
1972
2071
|
300000,
|
|
1973
|
-
true
|
|
2072
|
+
true,
|
|
1974
2073
|
);
|
|
1975
2074
|
}
|
|
1976
2075
|
}
|
|
@@ -1978,30 +2077,30 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
1978
2077
|
console.log(`[provision.installer] ✅ Downloaded to ${actualFilePath}`);
|
|
1979
2078
|
|
|
1980
2079
|
// Auto-detect install command based on file extension (use actualFilePath for extension detection)
|
|
1981
|
-
const actualFilename = actualFilePath.split(/[/\\]/).pop() ||
|
|
1982
|
-
const ext = actualFilename.split(
|
|
2080
|
+
const actualFilename = actualFilePath.split(/[/\\]/).pop() || "";
|
|
2081
|
+
const ext = actualFilename.split(".").pop()?.toLowerCase();
|
|
1983
2082
|
let installCommand = null;
|
|
1984
|
-
|
|
1985
|
-
if (this.os ===
|
|
1986
|
-
if (ext ===
|
|
2083
|
+
|
|
2084
|
+
if (this.os === "windows") {
|
|
2085
|
+
if (ext === "msi") {
|
|
1987
2086
|
installCommand = `Start-Process msiexec -ArgumentList '/i', '"${actualFilePath}"', '/quiet', '/norestart' -Wait`;
|
|
1988
|
-
} else if (ext ===
|
|
2087
|
+
} else if (ext === "exe") {
|
|
1989
2088
|
installCommand = `Start-Process "${actualFilePath}" -ArgumentList '/S' -Wait`;
|
|
1990
2089
|
}
|
|
1991
|
-
} else if (this.os ===
|
|
1992
|
-
if (ext ===
|
|
2090
|
+
} else if (this.os === "linux") {
|
|
2091
|
+
if (ext === "deb") {
|
|
1993
2092
|
installCommand = `sudo dpkg -i "${actualFilePath}" && sudo apt-get install -f -y`;
|
|
1994
|
-
} else if (ext ===
|
|
2093
|
+
} else if (ext === "rpm") {
|
|
1995
2094
|
installCommand = `sudo rpm -i "${actualFilePath}"`;
|
|
1996
|
-
} else if (ext ===
|
|
2095
|
+
} else if (ext === "appimage") {
|
|
1997
2096
|
installCommand = `chmod +x "${actualFilePath}"`;
|
|
1998
|
-
} else if (ext ===
|
|
2097
|
+
} else if (ext === "sh") {
|
|
1999
2098
|
installCommand = `chmod +x "${actualFilePath}" && "${actualFilePath}"`;
|
|
2000
2099
|
}
|
|
2001
|
-
} else if (this.os ===
|
|
2002
|
-
if (ext ===
|
|
2100
|
+
} else if (this.os === "darwin") {
|
|
2101
|
+
if (ext === "dmg") {
|
|
2003
2102
|
installCommand = `hdiutil attach "${actualFilePath}" -mountpoint /Volumes/installer && cp -R /Volumes/installer/*.app /Applications/ && hdiutil detach /Volumes/installer`;
|
|
2004
|
-
} else if (ext ===
|
|
2103
|
+
} else if (ext === "pkg") {
|
|
2005
2104
|
installCommand = `sudo installer -pkg "${actualFilePath}" -target /`;
|
|
2006
2105
|
}
|
|
2007
2106
|
}
|
|
@@ -2014,7 +2113,7 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2014
2113
|
|
|
2015
2114
|
// Launch and focus the app if appName is provided and launch is true
|
|
2016
2115
|
if (appName && launch) {
|
|
2017
|
-
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
2116
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
2018
2117
|
await this.focusApplication(appName);
|
|
2019
2118
|
}
|
|
2020
2119
|
|
|
@@ -2030,36 +2129,36 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2030
2129
|
*/
|
|
2031
2130
|
electron: async (options = {}) => {
|
|
2032
2131
|
const { appPath, args = [] } = options;
|
|
2033
|
-
|
|
2132
|
+
|
|
2034
2133
|
if (!appPath) {
|
|
2035
|
-
throw new Error(
|
|
2134
|
+
throw new Error("provision.electron requires appPath option");
|
|
2036
2135
|
}
|
|
2037
2136
|
|
|
2038
|
-
const shell = this.os ===
|
|
2137
|
+
const shell = this.os === "windows" ? "pwsh" : "sh";
|
|
2039
2138
|
|
|
2040
2139
|
// If dashcam is available, add web logs for all websites
|
|
2041
2140
|
// Note: File log and dashcam.start() are handled by the connection promise in hooks.mjs
|
|
2042
2141
|
if (this._dashcam) {
|
|
2043
|
-
await this._dashcam.addWebLog(
|
|
2142
|
+
await this._dashcam.addWebLog("**", "Web Logs");
|
|
2044
2143
|
}
|
|
2045
2144
|
|
|
2046
|
-
const argsString = args.join(
|
|
2047
|
-
|
|
2048
|
-
if (this.os ===
|
|
2145
|
+
const argsString = args.join(" ");
|
|
2146
|
+
|
|
2147
|
+
if (this.os === "windows") {
|
|
2049
2148
|
await this.exec(
|
|
2050
2149
|
shell,
|
|
2051
2150
|
`Start-Process electron -ArgumentList "${appPath}", ${argsString}`,
|
|
2052
|
-
30000
|
|
2151
|
+
30000,
|
|
2053
2152
|
);
|
|
2054
2153
|
} else {
|
|
2055
2154
|
await this.exec(
|
|
2056
2155
|
shell,
|
|
2057
2156
|
`electron "${appPath}" ${argsString} >/dev/null 2>&1 &`,
|
|
2058
|
-
30000
|
|
2157
|
+
30000,
|
|
2059
2158
|
);
|
|
2060
2159
|
}
|
|
2061
2160
|
|
|
2062
|
-
await this.focusApplication(
|
|
2161
|
+
await this.focusApplication("Electron");
|
|
2063
2162
|
},
|
|
2064
2163
|
};
|
|
2065
2164
|
|
|
@@ -2067,19 +2166,204 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2067
2166
|
return new Proxy(provisionMethods, {
|
|
2068
2167
|
get(target, prop) {
|
|
2069
2168
|
const method = target[prop];
|
|
2070
|
-
if (typeof method ===
|
|
2169
|
+
if (typeof method === "function") {
|
|
2071
2170
|
return async (...args) => {
|
|
2072
2171
|
// Skip provisioning if reconnecting to existing sandbox
|
|
2073
2172
|
if (self.reconnect) {
|
|
2074
|
-
console.log(
|
|
2173
|
+
console.log(
|
|
2174
|
+
`[provision.${prop}] Skipping provisioning (reconnect mode)`,
|
|
2175
|
+
);
|
|
2075
2176
|
return;
|
|
2076
2177
|
}
|
|
2077
2178
|
return method(...args);
|
|
2078
2179
|
};
|
|
2079
2180
|
}
|
|
2080
2181
|
return method;
|
|
2081
|
-
}
|
|
2182
|
+
},
|
|
2183
|
+
});
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
/**
|
|
2187
|
+
* Solve a captcha on the current page using 2captcha service
|
|
2188
|
+
* Requires Chrome to be launched with remote debugging (--remote-debugging-port=9222)
|
|
2189
|
+
*
|
|
2190
|
+
* @param {Object} options - Captcha solving options
|
|
2191
|
+
* @param {string} options.apiKey - 2captcha API key (required)
|
|
2192
|
+
* @param {string} [options.sitekey] - Captcha sitekey (auto-detected if not provided)
|
|
2193
|
+
* @param {string} [options.type='recaptcha_v3'] - Captcha type: 'recaptcha_v2', 'recaptcha_v3', 'hcaptcha', 'turnstile'
|
|
2194
|
+
* @param {string} [options.action='verify'] - Action parameter for reCAPTCHA v3
|
|
2195
|
+
* @param {boolean} [options.autoSubmit=true] - Automatically click submit button after solving
|
|
2196
|
+
* @param {number} [options.pollInterval=5000] - Polling interval in ms for 2captcha
|
|
2197
|
+
* @param {number} [options.timeout=120000] - Timeout in ms for solving
|
|
2198
|
+
* @returns {Promise<{success: boolean, message: string, token?: string}>}
|
|
2199
|
+
*
|
|
2200
|
+
* @example
|
|
2201
|
+
* // Auto-detect and solve captcha
|
|
2202
|
+
* await testdriver.captcha({
|
|
2203
|
+
* apiKey: 'your-2captcha-api-key'
|
|
2204
|
+
* });
|
|
2205
|
+
*
|
|
2206
|
+
* @example
|
|
2207
|
+
* // Solve with known sitekey
|
|
2208
|
+
* await testdriver.captcha({
|
|
2209
|
+
* apiKey: 'your-2captcha-api-key',
|
|
2210
|
+
* sitekey: '6LfB5_IbAAAAAMCtsjEHEHKqcB9iQocwwxTiihJu',
|
|
2211
|
+
* action: 'demo_action'
|
|
2212
|
+
* });
|
|
2213
|
+
*/
|
|
2214
|
+
async captcha(options = {}) {
|
|
2215
|
+
const {
|
|
2216
|
+
apiKey,
|
|
2217
|
+
sitekey,
|
|
2218
|
+
type = "recaptcha_v3",
|
|
2219
|
+
action = "verify",
|
|
2220
|
+
autoSubmit = true,
|
|
2221
|
+
pollInterval = 5000,
|
|
2222
|
+
timeout = 120000,
|
|
2223
|
+
} = options;
|
|
2224
|
+
|
|
2225
|
+
if (!apiKey) {
|
|
2226
|
+
throw new Error(
|
|
2227
|
+
"[captcha] apiKey is required. Get your API key at https://2captcha.com",
|
|
2228
|
+
);
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
const shell = this.os === "windows" ? "pwsh" : "sh";
|
|
2232
|
+
const isWindows = this.os === "windows";
|
|
2233
|
+
|
|
2234
|
+
// Paths for config and solver script
|
|
2235
|
+
const configPath = isWindows
|
|
2236
|
+
? "C:\\Users\\testdriver\\AppData\\Local\\Temp\\td-captcha-config.json"
|
|
2237
|
+
: "/tmp/td-captcha-config.json";
|
|
2238
|
+
const solverPath = isWindows
|
|
2239
|
+
? "C:\\Users\\testdriver\\AppData\\Local\\Temp\\td-captcha-solver.js"
|
|
2240
|
+
: "/tmp/td-captcha-solver.js";
|
|
2241
|
+
|
|
2242
|
+
// Ensure chrome-remote-interface is installed
|
|
2243
|
+
if (isWindows) {
|
|
2244
|
+
await this.exec(
|
|
2245
|
+
shell,
|
|
2246
|
+
"npm install -g chrome-remote-interface 2>$null; $true",
|
|
2247
|
+
60000,
|
|
2248
|
+
true,
|
|
2249
|
+
);
|
|
2250
|
+
} else {
|
|
2251
|
+
await this.exec(
|
|
2252
|
+
shell,
|
|
2253
|
+
"sudo npm install -g chrome-remote-interface 2>/dev/null || npm install -g chrome-remote-interface",
|
|
2254
|
+
60000,
|
|
2255
|
+
true,
|
|
2256
|
+
);
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
// Build config JSON for the solver
|
|
2260
|
+
const config = JSON.stringify({
|
|
2261
|
+
apiKey,
|
|
2262
|
+
sitekey: sitekey || null,
|
|
2263
|
+
type,
|
|
2264
|
+
action,
|
|
2265
|
+
autoSubmit,
|
|
2266
|
+
pollInterval,
|
|
2267
|
+
timeout,
|
|
2082
2268
|
});
|
|
2269
|
+
|
|
2270
|
+
// Write config file
|
|
2271
|
+
if (isWindows) {
|
|
2272
|
+
// Use PowerShell's Set-Content with escaped JSON
|
|
2273
|
+
const escapedConfig = config.replace(/'/g, "''");
|
|
2274
|
+
await this.exec(
|
|
2275
|
+
shell,
|
|
2276
|
+
`[System.IO.File]::WriteAllText('${configPath}', '${escapedConfig}')`,
|
|
2277
|
+
5000,
|
|
2278
|
+
true,
|
|
2279
|
+
);
|
|
2280
|
+
} else {
|
|
2281
|
+
// Use heredoc for Linux
|
|
2282
|
+
await this.exec(
|
|
2283
|
+
shell,
|
|
2284
|
+
`cat > ${configPath} << 'CONFIGEOF'
|
|
2285
|
+
${config}
|
|
2286
|
+
CONFIGEOF`,
|
|
2287
|
+
5000,
|
|
2288
|
+
true,
|
|
2289
|
+
);
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
// Load the solver script from file (avoids escaping issues with string concatenation)
|
|
2293
|
+
const solverScriptPath = path.join(
|
|
2294
|
+
__dirname,
|
|
2295
|
+
"lib",
|
|
2296
|
+
"captcha",
|
|
2297
|
+
"solver.js",
|
|
2298
|
+
);
|
|
2299
|
+
const solverScript = fs.readFileSync(solverScriptPath, "utf8");
|
|
2300
|
+
|
|
2301
|
+
// Write the solver script to sandbox
|
|
2302
|
+
if (isWindows) {
|
|
2303
|
+
// For Windows, write the script using base64 encoding to avoid escaping issues
|
|
2304
|
+
const base64Script = Buffer.from(solverScript).toString("base64");
|
|
2305
|
+
await this.exec(
|
|
2306
|
+
shell,
|
|
2307
|
+
`[System.IO.File]::WriteAllBytes('${solverPath}', [System.Convert]::FromBase64String('${base64Script}'))`,
|
|
2308
|
+
10000,
|
|
2309
|
+
true,
|
|
2310
|
+
);
|
|
2311
|
+
} else {
|
|
2312
|
+
// Use heredoc for Linux
|
|
2313
|
+
await this.exec(
|
|
2314
|
+
shell,
|
|
2315
|
+
`cat > ${solverPath} << 'CAPTCHA_SOLVER_EOF'
|
|
2316
|
+
${solverScript}
|
|
2317
|
+
CAPTCHA_SOLVER_EOF`,
|
|
2318
|
+
10000,
|
|
2319
|
+
true,
|
|
2320
|
+
);
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
// Run the solver (capture output even on failure)
|
|
2324
|
+
let result;
|
|
2325
|
+
try {
|
|
2326
|
+
if (isWindows) {
|
|
2327
|
+
// Set environment variable and run node on Windows
|
|
2328
|
+
result = await this.exec(
|
|
2329
|
+
shell,
|
|
2330
|
+
`$env:NODE_PATH = (npm root -g).Trim(); $env:TD_CAPTCHA_CONFIG_PATH='${configPath}'; node '${solverPath}' 2>&1 | Out-String; Write-Output "EXIT_CODE:$LASTEXITCODE"`,
|
|
2331
|
+
timeout + 30000,
|
|
2332
|
+
);
|
|
2333
|
+
} else {
|
|
2334
|
+
result = await this.exec(
|
|
2335
|
+
shell,
|
|
2336
|
+
`NODE_PATH=/usr/lib/node_modules node ${solverPath} 2>&1; echo "EXIT_CODE:$?"`,
|
|
2337
|
+
timeout + 30000,
|
|
2338
|
+
);
|
|
2339
|
+
}
|
|
2340
|
+
} catch (err) {
|
|
2341
|
+
// If exec throws, try to get output from the error
|
|
2342
|
+
result = err.message || err.toString();
|
|
2343
|
+
if (err.responseData && err.responseData.stdout) {
|
|
2344
|
+
result = err.responseData.stdout;
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
const tokenMatch = result.match(/TOKEN:\s*(\S+)/);
|
|
2349
|
+
const success = result.includes('"success":true');
|
|
2350
|
+
const hasError = result.includes("ERROR:");
|
|
2351
|
+
|
|
2352
|
+
if (hasError && !success) {
|
|
2353
|
+
const errorMatch = result.match(/ERROR:\s*(.+)/);
|
|
2354
|
+
throw new Error(
|
|
2355
|
+
`[captcha] ${errorMatch ? errorMatch[1] : "Unknown error"}\nOutput: ${result}`,
|
|
2356
|
+
);
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
return {
|
|
2360
|
+
success,
|
|
2361
|
+
message: success
|
|
2362
|
+
? "Captcha solved successfully"
|
|
2363
|
+
: "Captcha solving failed",
|
|
2364
|
+
token: tokenMatch ? tokenMatch[1] : null,
|
|
2365
|
+
output: result,
|
|
2366
|
+
};
|
|
2083
2367
|
}
|
|
2084
2368
|
|
|
2085
2369
|
/**
|
|
@@ -2117,8 +2401,16 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2117
2401
|
|
|
2118
2402
|
// Clean up screenshots folder for this test file before running
|
|
2119
2403
|
if (this.testFile) {
|
|
2120
|
-
const testFileName = path.basename(
|
|
2121
|
-
|
|
2404
|
+
const testFileName = path.basename(
|
|
2405
|
+
this.testFile,
|
|
2406
|
+
path.extname(this.testFile),
|
|
2407
|
+
);
|
|
2408
|
+
const screenshotsDir = path.join(
|
|
2409
|
+
process.cwd(),
|
|
2410
|
+
".testdriver",
|
|
2411
|
+
"screenshots",
|
|
2412
|
+
testFileName,
|
|
2413
|
+
);
|
|
2122
2414
|
if (fs.existsSync(screenshotsDir)) {
|
|
2123
2415
|
fs.rmSync(screenshotsDir, { recursive: true, force: true });
|
|
2124
2416
|
}
|
|
@@ -2149,23 +2441,24 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2149
2441
|
|
|
2150
2442
|
// Handle reconnect option - use last sandbox file
|
|
2151
2443
|
// Check both connectOptions and constructor options
|
|
2152
|
-
const shouldReconnect =
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2444
|
+
const shouldReconnect =
|
|
2445
|
+
connectOptions.reconnect !== undefined
|
|
2446
|
+
? connectOptions.reconnect
|
|
2447
|
+
: this.reconnect;
|
|
2448
|
+
|
|
2156
2449
|
// Skip reconnect if IP is supplied - directly connect to the provided IP
|
|
2157
2450
|
const hasIp = Boolean(connectOptions.ip || this.ip);
|
|
2158
|
-
|
|
2451
|
+
|
|
2159
2452
|
if (shouldReconnect && !hasIp) {
|
|
2160
2453
|
const lastSandbox = this.agent.getLastSandboxId();
|
|
2161
2454
|
if (!lastSandbox || !lastSandbox.sandboxId) {
|
|
2162
2455
|
throw new Error(
|
|
2163
|
-
"Cannot reconnect: No previous sandbox found. Run a test first to create a sandbox, or remove the reconnect option."
|
|
2456
|
+
"Cannot reconnect: No previous sandbox found. Run a test first to create a sandbox, or remove the reconnect option.",
|
|
2164
2457
|
);
|
|
2165
2458
|
}
|
|
2166
2459
|
this.agent.sandboxId = lastSandbox.sandboxId;
|
|
2167
2460
|
buildEnvOptions.new = false;
|
|
2168
|
-
|
|
2461
|
+
|
|
2169
2462
|
// Use OS from last sandbox if not explicitly specified
|
|
2170
2463
|
if (!connectOptions.os && lastSandbox.os) {
|
|
2171
2464
|
this.agent.sandboxOs = lastSandbox.os;
|
|
@@ -2226,7 +2519,7 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2226
2519
|
if (this.agent.sandboxOs) {
|
|
2227
2520
|
this.os = this.agent.sandboxOs;
|
|
2228
2521
|
}
|
|
2229
|
-
|
|
2522
|
+
|
|
2230
2523
|
// Also ensure sandbox.os is set for consistency
|
|
2231
2524
|
if (this.agent.sandbox && this.os) {
|
|
2232
2525
|
this.agent.sandbox.os = this.os;
|
|
@@ -2276,7 +2569,7 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2276
2569
|
|
|
2277
2570
|
// Always close the sandbox WebSocket connection to clean up resources
|
|
2278
2571
|
// This ensures we don't leave orphaned connections even if connect() failed
|
|
2279
|
-
if (this.sandbox && typeof this.sandbox.close ===
|
|
2572
|
+
if (this.sandbox && typeof this.sandbox.close === "function") {
|
|
2280
2573
|
this.sandbox.close();
|
|
2281
2574
|
}
|
|
2282
2575
|
|
|
@@ -2353,23 +2646,28 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2353
2646
|
if (this._lastCommandName && !this._lastPromiseSettled) {
|
|
2354
2647
|
console.warn(
|
|
2355
2648
|
`⚠️ Warning: Previous ${this._lastCommandName}() may not have been awaited.\n` +
|
|
2356
|
-
|
|
2357
|
-
|
|
2649
|
+
` Add "await" before the call: await testdriver.${this._lastCommandName}(...)\n` +
|
|
2650
|
+
` Unawaited promises can cause race conditions and flaky tests.`,
|
|
2358
2651
|
);
|
|
2359
2652
|
}
|
|
2360
2653
|
|
|
2361
2654
|
this._ensureConnected();
|
|
2362
2655
|
|
|
2363
2656
|
// Track this promise for unawaited detection
|
|
2364
|
-
this._lastCommandName =
|
|
2657
|
+
this._lastCommandName = "find";
|
|
2365
2658
|
this._lastPromiseSettled = false;
|
|
2366
2659
|
|
|
2367
|
-
const element = new Element(
|
|
2660
|
+
const element = new Element(
|
|
2661
|
+
description,
|
|
2662
|
+
this,
|
|
2663
|
+
this.system,
|
|
2664
|
+
this.commands,
|
|
2665
|
+
);
|
|
2368
2666
|
const result = await element.find(null, options);
|
|
2369
2667
|
this._lastPromiseSettled = true;
|
|
2370
2668
|
return result;
|
|
2371
2669
|
})();
|
|
2372
|
-
|
|
2670
|
+
|
|
2373
2671
|
// Create a chainable promise that allows direct method chaining
|
|
2374
2672
|
// e.g., await testdriver.find("button").click()
|
|
2375
2673
|
return createChainablePromise(findPromise);
|
|
@@ -2407,15 +2705,15 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2407
2705
|
if (this._lastCommandName && !this._lastPromiseSettled) {
|
|
2408
2706
|
console.warn(
|
|
2409
2707
|
`⚠️ Warning: Previous ${this._lastCommandName}() may not have been awaited.\n` +
|
|
2410
|
-
|
|
2411
|
-
|
|
2708
|
+
` Add "await" before the call: await testdriver.${this._lastCommandName}(...)\n` +
|
|
2709
|
+
` Unawaited promises can cause race conditions and flaky tests.`,
|
|
2412
2710
|
);
|
|
2413
2711
|
}
|
|
2414
2712
|
|
|
2415
2713
|
this._ensureConnected();
|
|
2416
2714
|
|
|
2417
2715
|
// Track this promise for unawaited detection
|
|
2418
|
-
this._lastCommandName =
|
|
2716
|
+
this._lastCommandName = "findAll";
|
|
2419
2717
|
this._lastPromiseSettled = false;
|
|
2420
2718
|
|
|
2421
2719
|
// Capture absolute timestamp at the very start of the command
|
|
@@ -2431,11 +2729,11 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2431
2729
|
// Handle options - can be a number (cacheThreshold) or object with cacheKey/cacheThreshold
|
|
2432
2730
|
let cacheKey = null;
|
|
2433
2731
|
let cacheThreshold = null;
|
|
2434
|
-
|
|
2435
|
-
if (typeof options ===
|
|
2732
|
+
|
|
2733
|
+
if (typeof options === "number") {
|
|
2436
2734
|
// Legacy: options is just a number threshold
|
|
2437
2735
|
cacheThreshold = options;
|
|
2438
|
-
} else if (typeof options ===
|
|
2736
|
+
} else if (typeof options === "object" && options !== null) {
|
|
2439
2737
|
// New: options is an object with cacheKey and/or cacheThreshold
|
|
2440
2738
|
cacheKey = options.cacheKey || null;
|
|
2441
2739
|
cacheThreshold = options.cacheThreshold ?? null;
|
|
@@ -2443,11 +2741,15 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2443
2741
|
|
|
2444
2742
|
// Use default cacheKey from SDK constructor if not provided in findAll() options
|
|
2445
2743
|
// BUT only if cache is not explicitly disabled via cache: false option
|
|
2446
|
-
if (
|
|
2744
|
+
if (
|
|
2745
|
+
!cacheKey &&
|
|
2746
|
+
this.options?.cacheKey &&
|
|
2747
|
+
!this._cacheExplicitlyDisabled
|
|
2748
|
+
) {
|
|
2447
2749
|
cacheKey = this.options.cacheKey;
|
|
2448
2750
|
}
|
|
2449
2751
|
|
|
2450
|
-
// Determine threshold:
|
|
2752
|
+
// Determine threshold:
|
|
2451
2753
|
// - If cache is explicitly disabled, don't use cache even with cacheKey
|
|
2452
2754
|
// - If cacheKey is provided, enable cache with threshold
|
|
2453
2755
|
// - If no cacheKey, disable cache
|
|
@@ -2468,11 +2770,13 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2468
2770
|
}
|
|
2469
2771
|
|
|
2470
2772
|
// Debug log threshold
|
|
2471
|
-
const debugMode =
|
|
2773
|
+
const debugMode =
|
|
2774
|
+
process.env.VERBOSE || process.env.DEBUG || process.env.TD_DEBUG;
|
|
2472
2775
|
if (debugMode) {
|
|
2473
|
-
const autoGenMsg =
|
|
2474
|
-
|
|
2475
|
-
|
|
2776
|
+
const autoGenMsg =
|
|
2777
|
+
this._autoGeneratedCacheKey && cacheKey === this.options.cacheKey
|
|
2778
|
+
? " (auto-generated from file hash)"
|
|
2779
|
+
: "";
|
|
2476
2780
|
this.emitter.emit(
|
|
2477
2781
|
events.log.debug,
|
|
2478
2782
|
`🔍 findAll() threshold: ${threshold} (cache ${threshold < 0 ? "DISABLED" : "ENABLED"}${cacheKey ? `, cacheKey: ${cacheKey}${autoGenMsg}` : ""})`,
|
|
@@ -2536,20 +2840,22 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2536
2840
|
// Track successful findAll interaction (fire-and-forget, don't block)
|
|
2537
2841
|
const sessionId = this.getSessionId();
|
|
2538
2842
|
if (sessionId && this.sandbox?.send) {
|
|
2539
|
-
this.sandbox
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2843
|
+
this.sandbox
|
|
2844
|
+
.send({
|
|
2845
|
+
type: "trackInteraction",
|
|
2846
|
+
interactionType: "findAll",
|
|
2847
|
+
session: sessionId,
|
|
2848
|
+
prompt: description,
|
|
2849
|
+
timestamp: absoluteTimestamp, // Absolute epoch timestamp - frontend calculates relative using clientStartDate
|
|
2850
|
+
success: true,
|
|
2851
|
+
input: { count: elements.length },
|
|
2852
|
+
cacheHit: response.cached || false,
|
|
2853
|
+
selector: response.selector,
|
|
2854
|
+
selectorUsed: !!response.selector,
|
|
2855
|
+
})
|
|
2856
|
+
.catch((err) => {
|
|
2857
|
+
console.warn("Failed to track findAll interaction:", err.message);
|
|
2858
|
+
});
|
|
2553
2859
|
}
|
|
2554
2860
|
|
|
2555
2861
|
// Log debug information when elements are found
|
|
@@ -2569,7 +2875,7 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2569
2875
|
return elements;
|
|
2570
2876
|
} else {
|
|
2571
2877
|
const duration = Date.now() - startTime;
|
|
2572
|
-
|
|
2878
|
+
|
|
2573
2879
|
// Single log at the end - no elements found
|
|
2574
2880
|
const formattedMessage = formatter.formatFindAllSingleLine(
|
|
2575
2881
|
description,
|
|
@@ -2584,21 +2890,23 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2584
2890
|
// No elements found - track interaction (fire-and-forget, don't block)
|
|
2585
2891
|
const sessionId = this.getSessionId();
|
|
2586
2892
|
if (sessionId && this.sandbox?.send) {
|
|
2587
|
-
this.sandbox
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2893
|
+
this.sandbox
|
|
2894
|
+
.send({
|
|
2895
|
+
type: "trackInteraction",
|
|
2896
|
+
interactionType: "findAll",
|
|
2897
|
+
session: sessionId,
|
|
2898
|
+
prompt: description,
|
|
2899
|
+
timestamp: absoluteTimestamp, // Absolute epoch timestamp - frontend calculates relative using clientStartDate
|
|
2900
|
+
success: false,
|
|
2901
|
+
error: "No elements found",
|
|
2902
|
+
input: { count: 0 },
|
|
2903
|
+
cacheHit: response?.cached || false,
|
|
2904
|
+
selector: response?.selector,
|
|
2905
|
+
selectorUsed: !!response?.selector,
|
|
2906
|
+
})
|
|
2907
|
+
.catch((err) => {
|
|
2908
|
+
console.warn("Failed to track findAll interaction:", err.message);
|
|
2909
|
+
});
|
|
2602
2910
|
}
|
|
2603
2911
|
|
|
2604
2912
|
// No elements found - return empty array
|
|
@@ -2607,7 +2915,7 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2607
2915
|
}
|
|
2608
2916
|
} catch (error) {
|
|
2609
2917
|
const duration = Date.now() - startTime;
|
|
2610
|
-
|
|
2918
|
+
|
|
2611
2919
|
// Single log at the end - error
|
|
2612
2920
|
const formattedMessage = formatter.formatFindAllSingleLine(
|
|
2613
2921
|
description,
|
|
@@ -2621,18 +2929,20 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2621
2929
|
// Track findAll error interaction (fire-and-forget, don't block)
|
|
2622
2930
|
const sessionId = this.getSessionId();
|
|
2623
2931
|
if (sessionId && this.sandbox?.send) {
|
|
2624
|
-
this.sandbox
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2932
|
+
this.sandbox
|
|
2933
|
+
.send({
|
|
2934
|
+
type: "trackInteraction",
|
|
2935
|
+
interactionType: "findAll",
|
|
2936
|
+
session: sessionId,
|
|
2937
|
+
prompt: description,
|
|
2938
|
+
timestamp: absoluteTimestamp, // Absolute epoch timestamp - frontend calculates relative using clientStartDate
|
|
2939
|
+
success: false,
|
|
2940
|
+
error: error.message,
|
|
2941
|
+
input: { count: 0 },
|
|
2942
|
+
})
|
|
2943
|
+
.catch((err) => {
|
|
2944
|
+
console.warn("Failed to track findAll interaction:", err.message);
|
|
2945
|
+
});
|
|
2636
2946
|
}
|
|
2637
2947
|
|
|
2638
2948
|
this._lastPromiseSettled = true;
|
|
@@ -2690,20 +3000,20 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2690
3000
|
"hover-text": "hoverText",
|
|
2691
3001
|
"hover-image": "hoverImage",
|
|
2692
3002
|
"match-image": "matchImage",
|
|
2693
|
-
|
|
3003
|
+
type: "type",
|
|
2694
3004
|
"press-keys": "pressKeys",
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
3005
|
+
click: "click",
|
|
3006
|
+
hover: "hover",
|
|
3007
|
+
scroll: "scroll",
|
|
3008
|
+
wait: "wait",
|
|
2699
3009
|
"wait-for-text": "waitForText",
|
|
2700
3010
|
"wait-for-image": "waitForImage",
|
|
2701
3011
|
"scroll-until-text": "scrollUntilText",
|
|
2702
3012
|
"scroll-until-image": "scrollUntilImage",
|
|
2703
3013
|
"focus-application": "focusApplication",
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
3014
|
+
extract: "extract",
|
|
3015
|
+
assert: "assert",
|
|
3016
|
+
exec: "exec",
|
|
2707
3017
|
};
|
|
2708
3018
|
|
|
2709
3019
|
// Create SDK methods that lazy-await connection then forward to this.commands
|
|
@@ -2718,8 +3028,8 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2718
3028
|
if (this._lastCommandName && !this._lastPromiseSettled) {
|
|
2719
3029
|
console.warn(
|
|
2720
3030
|
`⚠️ Warning: Previous ${this._lastCommandName}() may not have been awaited.\n` +
|
|
2721
|
-
|
|
2722
|
-
|
|
3031
|
+
` Add "await" before the call: await testdriver.${this._lastCommandName}(...)\n` +
|
|
3032
|
+
` Unawaited promises can cause race conditions and flaky tests.`,
|
|
2723
3033
|
);
|
|
2724
3034
|
}
|
|
2725
3035
|
|
|
@@ -2787,33 +3097,38 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2787
3097
|
*/
|
|
2788
3098
|
async screenshot(filename) {
|
|
2789
3099
|
this._ensureConnected();
|
|
2790
|
-
|
|
2791
|
-
const finalFilename = filename
|
|
2792
|
-
?
|
|
3100
|
+
|
|
3101
|
+
const finalFilename = filename
|
|
3102
|
+
? filename.endsWith(".png")
|
|
3103
|
+
? filename
|
|
3104
|
+
: `${filename}.png`
|
|
2793
3105
|
: `screenshot-${Date.now()}.png`;
|
|
2794
|
-
|
|
3106
|
+
|
|
2795
3107
|
const base64Data = await this.system.captureScreenBase64(1, false, false);
|
|
2796
|
-
|
|
3108
|
+
|
|
2797
3109
|
// Save to .testdriver/screenshots/<test-file-name> directory
|
|
2798
3110
|
let screenshotsDir = path.join(process.cwd(), ".testdriver", "screenshots");
|
|
2799
3111
|
if (this.testFile) {
|
|
2800
|
-
const testFileName = path.basename(
|
|
3112
|
+
const testFileName = path.basename(
|
|
3113
|
+
this.testFile,
|
|
3114
|
+
path.extname(this.testFile),
|
|
3115
|
+
);
|
|
2801
3116
|
screenshotsDir = path.join(screenshotsDir, testFileName);
|
|
2802
3117
|
}
|
|
2803
3118
|
if (!fs.existsSync(screenshotsDir)) {
|
|
2804
3119
|
fs.mkdirSync(screenshotsDir, { recursive: true });
|
|
2805
3120
|
}
|
|
2806
|
-
|
|
3121
|
+
|
|
2807
3122
|
const filePath = path.join(screenshotsDir, finalFilename);
|
|
2808
|
-
|
|
3123
|
+
|
|
2809
3124
|
// Remove data:image/png;base64, prefix if present
|
|
2810
3125
|
const cleanBase64 = base64Data.replace(/^data:image\/\w+;base64,/, "");
|
|
2811
3126
|
const buffer = Buffer.from(cleanBase64, "base64");
|
|
2812
|
-
|
|
3127
|
+
|
|
2813
3128
|
fs.writeFileSync(filePath, buffer);
|
|
2814
|
-
|
|
3129
|
+
|
|
2815
3130
|
this.emitter.emit("log:info", `📸 Screenshot saved to: ${filePath}`);
|
|
2816
|
-
|
|
3131
|
+
|
|
2817
3132
|
return filePath;
|
|
2818
3133
|
}
|
|
2819
3134
|
|
|
@@ -2872,13 +3187,14 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2872
3187
|
_setupLogging() {
|
|
2873
3188
|
// Track the last fatal error message to throw on exit
|
|
2874
3189
|
let lastFatalError = null;
|
|
2875
|
-
const debugMode =
|
|
3190
|
+
const debugMode =
|
|
3191
|
+
process.env.VERBOSE || process.env.DEBUG || process.env.TD_DEBUG;
|
|
2876
3192
|
|
|
2877
3193
|
// Set up markdown logger
|
|
2878
3194
|
createMarkdownLogger(this.emitter);
|
|
2879
3195
|
|
|
2880
3196
|
// Set up basic event logging
|
|
2881
|
-
// Note: We only console.log here - the console spy in vitest/hooks.mjs
|
|
3197
|
+
// Note: We only console.log here - the console spy in vitest/hooks.mjs
|
|
2882
3198
|
// handles forwarding to sandbox. This prevents duplicate output to server.
|
|
2883
3199
|
this.emitter.on("log:**", (message) => {
|
|
2884
3200
|
const event = this.emitter.event;
|
|
@@ -2900,7 +3216,7 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2900
3216
|
if (this.loggingEnabled) {
|
|
2901
3217
|
const event = this.emitter.event;
|
|
2902
3218
|
console.error(event, ":", data);
|
|
2903
|
-
|
|
3219
|
+
|
|
2904
3220
|
// Capture fatal errors
|
|
2905
3221
|
if (event === events.error.fatal) {
|
|
2906
3222
|
lastFatalError = data;
|
|
@@ -2919,9 +3235,9 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2919
3235
|
this.emitter.on(events.exit, (exitCode) => {
|
|
2920
3236
|
if (exitCode !== 0) {
|
|
2921
3237
|
// Create an error with the fatal error message if available
|
|
2922
|
-
const errorMessage = lastFatalError ||
|
|
3238
|
+
const errorMessage = lastFatalError || "TestDriver fatal error";
|
|
2923
3239
|
const error = new Error(errorMessage);
|
|
2924
|
-
error.name =
|
|
3240
|
+
error.name = "TestDriverFatalError";
|
|
2925
3241
|
error.exitCode = exitCode;
|
|
2926
3242
|
throw error;
|
|
2927
3243
|
}
|
|
@@ -2946,7 +3262,6 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2946
3262
|
console.log(url);
|
|
2947
3263
|
await this._openBrowser(url);
|
|
2948
3264
|
}
|
|
2949
|
-
|
|
2950
3265
|
}
|
|
2951
3266
|
});
|
|
2952
3267
|
}
|
|
@@ -3037,7 +3352,12 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
3037
3352
|
|
|
3038
3353
|
// Auto-detect sandbox ID from the active sandbox if not provided
|
|
3039
3354
|
// For E2B (Linux), the instance has sandboxId; for AWS (Windows), it has instanceId
|
|
3040
|
-
const sandboxId =
|
|
3355
|
+
const sandboxId =
|
|
3356
|
+
options.sandboxId ||
|
|
3357
|
+
this.instance?.sandboxId ||
|
|
3358
|
+
this.instance?.instanceId ||
|
|
3359
|
+
this.agent?.sandboxId ||
|
|
3360
|
+
null;
|
|
3041
3361
|
|
|
3042
3362
|
// Get or create session ID using the agent's newSession method
|
|
3043
3363
|
let sessionId = this.agent?.sessionInstance?.get() || null;
|
|
@@ -3208,7 +3528,7 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
3208
3528
|
// Store original checkLimit and set custom one if provided
|
|
3209
3529
|
const originalCheckLimit = this.agent.checkLimit;
|
|
3210
3530
|
this.agent.checkLimit = tries;
|
|
3211
|
-
|
|
3531
|
+
|
|
3212
3532
|
// Reset check count for this act() call
|
|
3213
3533
|
const originalCheckCount = this.agent.checkCount;
|
|
3214
3534
|
this.agent.checkCount = 0;
|
|
@@ -3218,17 +3538,25 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
3218
3538
|
|
|
3219
3539
|
try {
|
|
3220
3540
|
// Use the agent's exploratoryLoop method directly
|
|
3221
|
-
const response = await this.agent.exploratoryLoop(
|
|
3222
|
-
|
|
3541
|
+
const response = await this.agent.exploratoryLoop(
|
|
3542
|
+
task,
|
|
3543
|
+
false,
|
|
3544
|
+
true,
|
|
3545
|
+
false,
|
|
3546
|
+
);
|
|
3547
|
+
|
|
3223
3548
|
const duration = Date.now() - startTime;
|
|
3224
3549
|
const triesUsed = this.agent.checkCount;
|
|
3225
|
-
|
|
3226
|
-
this.emitter.emit(
|
|
3227
|
-
|
|
3550
|
+
|
|
3551
|
+
this.emitter.emit(
|
|
3552
|
+
events.log.log,
|
|
3553
|
+
formatter.formatAIComplete(duration, true),
|
|
3554
|
+
);
|
|
3555
|
+
|
|
3228
3556
|
// Restore original checkLimit
|
|
3229
3557
|
this.agent.checkLimit = originalCheckLimit;
|
|
3230
3558
|
this.agent.checkCount = originalCheckCount;
|
|
3231
|
-
|
|
3559
|
+
|
|
3232
3560
|
return {
|
|
3233
3561
|
success: true,
|
|
3234
3562
|
task,
|
|
@@ -3240,13 +3568,16 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
3240
3568
|
} catch (error) {
|
|
3241
3569
|
const duration = Date.now() - startTime;
|
|
3242
3570
|
const triesUsed = this.agent.checkCount;
|
|
3243
|
-
|
|
3244
|
-
this.emitter.emit(
|
|
3245
|
-
|
|
3571
|
+
|
|
3572
|
+
this.emitter.emit(
|
|
3573
|
+
events.log.log,
|
|
3574
|
+
formatter.formatAIComplete(duration, false, error.message),
|
|
3575
|
+
);
|
|
3576
|
+
|
|
3246
3577
|
// Restore original checkLimit
|
|
3247
3578
|
this.agent.checkLimit = originalCheckLimit;
|
|
3248
3579
|
this.agent.checkCount = originalCheckCount;
|
|
3249
|
-
|
|
3580
|
+
|
|
3250
3581
|
// Create an enhanced error with additional context using AIError class
|
|
3251
3582
|
throw new AIError(`AI failed: ${error.message}`, {
|
|
3252
3583
|
task,
|