@loadmill/droid-cua 2.28.0 → 2.30.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/README.md +5 -5
- package/build/index.js +142 -10
- package/build/src/browser/cloud-browser-catalog.js +27 -0
- package/build/src/browser/factory.js +33 -10
- package/build/src/browser/gesture-dispatch.js +281 -0
- package/build/src/browser/interface.js +3 -0
- package/build/src/browser/normalize.js +3 -1
- package/build/src/browser/playwright/actions.js +5 -271
- package/build/src/browser/webdriver/actions.js +10 -0
- package/build/src/browser/webdriver/client.js +83 -0
- package/build/src/browser/webdriver/connection.js +300 -0
- package/build/src/browser/webdriver/page-facade.js +123 -0
- package/build/src/browser/webdriver/session-state.js +35 -0
- package/build/src/cli/headless-execution-config.js +1 -1
- package/build/src/cli/headless-runner.js +26 -13
- package/build/src/cli/headless-target.js +87 -16
- package/build/src/cli/headless-test-source-label.js +4 -0
- package/build/src/cli/loadmill-cloud-device-connect-summary.js +6 -0
- package/build/src/cli/loadmill-cloud-session.js +26 -0
- package/build/src/core/context-optimization.js +1 -1
- package/build/src/core/execution-engine.js +9 -0
- package/build/src/core/report/render-html-report.js +106 -12
- package/build/src/core/report/run-report-sections.js +136 -0
- package/build/src/core/runtime-config.js +2 -2
- package/build/src/desktop-shared/connection-predicates.js +52 -0
- package/build/src/device/cloud/loadmill-cloud/adapter.js +17 -12
- package/build/src/device/cloud/loadmill-cloud/artifact-downloader.js +13 -7
- package/build/src/device/cloud/loadmill-cloud/control-plane-client.js +22 -5
- package/build/src/device/cloud/loadmill-cloud/errors.js +25 -0
- package/build/src/device/cloud/loadmill-cloud/run-pool.js +41 -0
- package/build/src/device/cloud/loadmill-cloud/run-status.js +4 -0
- package/package.json +1 -1
- package/skill/droid-cua/SKILL.md +10 -3
- package/skill/droid-cua/agents/openai.yaml +2 -2
- package/skill/droid-cua/references/authoring-tests.md +36 -6
- package/skill/droid-cua/references/iterative-improvement.md +143 -0
- package/skill/droid-cua/references/onboarding-and-troubleshooting.md +19 -0
- package/skill/droid-cua/references/project-and-auth.md +2 -0
- package/skill/droid-cua/references/running-and-healing.md +32 -14
package/README.md
CHANGED
|
@@ -222,7 +222,7 @@ Example headless config:
|
|
|
222
222
|
"appContextEnabled": true,
|
|
223
223
|
"appContextPath": "../tests/context.md",
|
|
224
224
|
"contextOptimizationEnabled": true,
|
|
225
|
-
"contextOptimizationThreshold":
|
|
225
|
+
"contextOptimizationThreshold": 60000
|
|
226
226
|
}
|
|
227
227
|
```
|
|
228
228
|
|
|
@@ -271,11 +271,11 @@ Config and precedence rules:
|
|
|
271
271
|
- CLI flags override config file values.
|
|
272
272
|
- `--context` overrides the config app-context path.
|
|
273
273
|
- `--no-context` disables app context entirely.
|
|
274
|
-
- Context Optimization is enabled by default with a `
|
|
274
|
+
- Context Optimization is enabled by default with a `60000` input-token threshold.
|
|
275
275
|
- `--context-optimization-threshold <tokens>` sets the threshold and enables Context Optimization.
|
|
276
276
|
- `--no-context-optimization` disables Context Optimization.
|
|
277
|
-
-
|
|
278
|
-
- Set `"explainMode":
|
|
277
|
+
- Explain Mode is enabled by default and asks the agent for concise explanations of non-obvious decisions, skipped actions, and completion evidence; explanations appear in terminal output and reports.
|
|
278
|
+
- Set `"explainMode": false` in the JSON config to disable it. The `--explain` flag remains available to explicitly enable it for a run.
|
|
279
279
|
- `--base-prompt` and `--execution-prompt` let you pass prompt customizations inline on the command line.
|
|
280
280
|
- `--base-prompt-file` and `--execution-prompt-file` override the corresponding prompt customizations from config.
|
|
281
281
|
- If both inline and file-based prompt overrides are provided, the inline prompt flags win.
|
|
@@ -291,7 +291,7 @@ HTML report (`--report`):
|
|
|
291
291
|
- Writes a self-contained HTML report of the run — the same Run Details view the desktop app shows, with screenshots inlined — that you can share and open in any browser.
|
|
292
292
|
- With no value it saves `<test>--<date>--<time>.html` (e.g. `login--2026-06-03--16-09-20.html`) in the current directory; pass `--report <file>` to pick a filename or `--report <dir>` to pick a folder.
|
|
293
293
|
- In the desktop app the same report is available from a run's **Share → Download HTML report** button.
|
|
294
|
-
- Runs with captured screenshots also expose **Share → Generate video**. The desktop app renders a square mobile replay or 16:9 browser replay locally, then prompts for a save location using `<test>--<date>--<time>--test-replay.mp4` as the suggested filename.
|
|
294
|
+
- Runs with captured screenshots also expose **Share → Generate video**. The desktop app renders a square mobile replay or 16:9 browser replay locally, then prompts for a save location using `<test>--<date>--<time>--test-replay.mp4` as the suggested filename. While rendering, a compact **Background tasks** card shows progress for **Generating test replay**; it can be minimized to a task-count card or canceled without opening the save dialog.
|
|
295
295
|
- Authenticated headless runs upload their HTML report and screenshots to Loadmill by default. Use `--no-cloud-report` to disable that artifact upload. The basic Droid run row is still reported to Loadmill.
|
|
296
296
|
|
|
297
297
|
```sh
|
package/build/index.js
CHANGED
|
@@ -4,7 +4,8 @@ import dotenv from "dotenv";
|
|
|
4
4
|
import path from "path";
|
|
5
5
|
import { mkdir } from "fs/promises";
|
|
6
6
|
import { connectToDevice, disconnect as disconnectDevice, getDeviceInfo } from "./src/device/connection.js";
|
|
7
|
-
import { connectToBrowser, getBrowserInfo, } from "./src/browser/connection.js";
|
|
7
|
+
import { connectToBrowser, disconnect as disconnectBrowser, getBrowserInfo, } from "./src/browser/connection.js";
|
|
8
|
+
import { setBrowserBackendId } from "./src/browser/factory.js";
|
|
8
9
|
import { normalizeBrowserInfoForSession } from "./src/browser/normalize.js";
|
|
9
10
|
import { Session } from "./src/core/session.js";
|
|
10
11
|
import { ExecutionEngine } from "./src/core/execution-engine.js";
|
|
@@ -18,11 +19,12 @@ import { resolveExecutionTestData } from "./src/core/test-data.js";
|
|
|
18
19
|
import { getDefaultSecretsPath, resolveExecutionSecrets } from "./src/core/secrets.js";
|
|
19
20
|
import { assertRuntimeCredentialsForProvider, createRunContext, resolveRuntimeConfigFromEnv, resolveRuntimeCredentialsFromEnv, } from "./src/core/runtime-config.js";
|
|
20
21
|
import { resolveHeadlessExecutionConfig } from "./src/cli/headless-execution-config.js";
|
|
21
|
-
import { resolveHeadlessTarget, shouldWaitForLoadmillCloudCompletion } from "./src/cli/headless-target.js";
|
|
22
|
+
import { artifactKindsForPool, resolveHeadlessTarget, scopeArtifactKindsToPool, shouldWaitForLoadmillCloudCompletion, } from "./src/cli/headless-target.js";
|
|
22
23
|
import { createHeadlessDebugArtifacts } from "./src/cli/headless-debug.js";
|
|
23
24
|
import { loadHeadlessInstructions } from "./src/cli/headless-instructions.js";
|
|
24
25
|
import { listHeadlessInstructionFiles, resolveHeadlessInstructionsTarget } from "./src/cli/headless-instruction-target.js";
|
|
25
26
|
import { runHeadlessInstructionRuns } from "./src/cli/headless-runner.js";
|
|
27
|
+
import { buildLoadmillCloudDeviceConnectSummaryLines } from "./src/cli/loadmill-cloud-device-connect-summary.js";
|
|
26
28
|
import { planScenarioRuns } from "./src/cli/scenario-planner.js";
|
|
27
29
|
import { resolveLambdaTestCredentials, uploadLambdaTestCliApp } from "./src/cli/lambdatest-app-upload.js";
|
|
28
30
|
import { resolveAwsDeviceFarmCredentials, uploadAwsDeviceFarmCliApp } from "./src/cli/aws-device-farm-app-upload.js";
|
|
@@ -35,9 +37,12 @@ import { droidReportUploadManager } from "./src/integrations/loadmill/report-upl
|
|
|
35
37
|
import { getCloudProviderAdapter } from "./src/device/cloud/registry.js";
|
|
36
38
|
import { setCloudProgressListener } from "./src/device/cloud/progress.js";
|
|
37
39
|
import { runSkillCommand } from "./src/cli/skill-command.js";
|
|
38
|
-
import { isLoadmillCloudUsageLimitError } from "./src/device/cloud/loadmill-cloud/errors.js";
|
|
39
|
-
import { loadmillCloudAdapter } from "./src/device/cloud/loadmill-cloud/adapter.js";
|
|
40
|
+
import { formatLoadmillCloudNotEntitledMessage, isLoadmillCloudNotEntitledError, isLoadmillCloudUsageLimitError, } from "./src/device/cloud/loadmill-cloud/errors.js";
|
|
41
|
+
import { fetchAndDownloadArtifacts, loadmillCloudAdapter } from "./src/device/cloud/loadmill-cloud/adapter.js";
|
|
42
|
+
import { resolveRunPool } from "./src/device/cloud/loadmill-cloud/run-pool.js";
|
|
43
|
+
import { ARTIFACT_READY_STATUSES } from "./src/device/cloud/loadmill-cloud/run-status.js";
|
|
40
44
|
import { formatDuration } from "./src/core/report/run-log-model.js";
|
|
45
|
+
import { createBrowserSessionTeardown, withLoadmillCloudSession } from "./src/cli/loadmill-cloud-session.js";
|
|
41
46
|
dotenv.config();
|
|
42
47
|
process.once("SIGTERM", async () => {
|
|
43
48
|
process.exitCode = 143;
|
|
@@ -45,7 +50,7 @@ process.once("SIGTERM", async () => {
|
|
|
45
50
|
// explicitly; await the /stop because a bare fire-and-forget fetch may not
|
|
46
51
|
// dispatch before exit. Capped so a slow server can't hang the exit.
|
|
47
52
|
await Promise.race([
|
|
48
|
-
loadmillCloudAdapter.releaseActiveSession(),
|
|
53
|
+
Promise.all([disconnectBrowser().catch(() => { }), loadmillCloudAdapter.releaseActiveSession()]),
|
|
49
54
|
new Promise((resolve) => setTimeout(resolve, 1_500)),
|
|
50
55
|
]);
|
|
51
56
|
process.exit(143);
|
|
@@ -243,6 +248,26 @@ async function main() {
|
|
|
243
248
|
});
|
|
244
249
|
try {
|
|
245
250
|
retroactiveStatusLine.start("Waiting for Loadmill Cloud artifacts");
|
|
251
|
+
const { pool, client, state } = await resolveRunPool({
|
|
252
|
+
credentials,
|
|
253
|
+
sessionId: targetConfig.cloudSessionId,
|
|
254
|
+
});
|
|
255
|
+
const kinds = scopeArtifactKindsToPool(targetConfig.artifacts, pool);
|
|
256
|
+
if (kinds.length === 0) {
|
|
257
|
+
throw new Error(`A Loadmill Cloud ${pool} run has none of the requested artifact kinds (${targetConfig.artifacts.join(", ")}); it produces: ${artifactKindsForPool(pool).join(", ")}.`);
|
|
258
|
+
}
|
|
259
|
+
if (pool === "web") {
|
|
260
|
+
if (!ARTIFACT_READY_STATUSES.has(state.status)) {
|
|
261
|
+
retroactiveStatusLine.stop();
|
|
262
|
+
console.error(`Loadmill Cloud session ${targetConfig.cloudSessionId} is not terminal yet; add --instructions to drive it, or wait until it finishes and re-run this same command to fetch artifacts.`);
|
|
263
|
+
process.exit(1);
|
|
264
|
+
}
|
|
265
|
+
await fetchAndDownloadArtifacts({ client, sessionId: targetConfig.cloudSessionId, kinds });
|
|
266
|
+
retroactiveStatusLine.stop();
|
|
267
|
+
console.log("Loadmill Cloud artifact fetch complete.");
|
|
268
|
+
console.log(`Done in ${formatDuration(Date.now() - processStartTime)}.`);
|
|
269
|
+
process.exit(0);
|
|
270
|
+
}
|
|
246
271
|
await connectToDevice({
|
|
247
272
|
source: "loadmill-cloud",
|
|
248
273
|
providerId: "loadmill-cloud",
|
|
@@ -252,7 +277,7 @@ async function main() {
|
|
|
252
277
|
appRef: targetConfig.appPath,
|
|
253
278
|
credentials,
|
|
254
279
|
cloudSessionId: targetConfig.cloudSessionId,
|
|
255
|
-
artifacts:
|
|
280
|
+
artifacts: kinds,
|
|
256
281
|
}, null, { log: () => { } });
|
|
257
282
|
retroactiveStatusLine.stop();
|
|
258
283
|
// A resumable (non-terminal) session reconnected successfully instead of
|
|
@@ -290,6 +315,7 @@ async function main() {
|
|
|
290
315
|
const isLambdaTestTarget = targetConfig.deviceSource === "lambdatest";
|
|
291
316
|
const isAwsDeviceFarmTarget = targetConfig.deviceSource === "aws-device-farm";
|
|
292
317
|
const isLoadmillCloudTarget = targetConfig.deviceSource === "loadmill-cloud";
|
|
318
|
+
const isLoadmillCloudBrowserTarget = isBrowserTarget && isLoadmillCloudTarget;
|
|
293
319
|
const isCloudTarget = isLambdaTestTarget || isAwsDeviceFarmTarget || isLoadmillCloudTarget;
|
|
294
320
|
let lambdaTestSessionConnected = false;
|
|
295
321
|
let lambdaTestSessionUrl = null;
|
|
@@ -297,6 +323,8 @@ async function main() {
|
|
|
297
323
|
let awsSessionConnected = false;
|
|
298
324
|
let awsDeviceFarmStatusLine = null;
|
|
299
325
|
let loadmillCloudSessionConnected = false;
|
|
326
|
+
let loadmillCloudBrowserSessionConnected = false;
|
|
327
|
+
let loadmillCloudBrowserTeardown = null;
|
|
300
328
|
let loadmillCloudStatusLine = null;
|
|
301
329
|
const headlessDebug = createHeadlessDebugArtifacts({
|
|
302
330
|
cwd: process.cwd(),
|
|
@@ -377,7 +405,94 @@ async function main() {
|
|
|
377
405
|
}
|
|
378
406
|
let targetId;
|
|
379
407
|
let deviceInfo;
|
|
380
|
-
if (
|
|
408
|
+
if (isLoadmillCloudBrowserTarget) {
|
|
409
|
+
const loadmillCloudCredentials = resolveLoadmillCloudCredentials(process.env);
|
|
410
|
+
loadmillCloudStatusLine = createStatusLine();
|
|
411
|
+
const statusLine = loadmillCloudStatusLine;
|
|
412
|
+
setCloudProgressListener((event) => statusLine.handleEvent(event));
|
|
413
|
+
setBrowserBackendId("webdriver");
|
|
414
|
+
loadmillCloudBrowserTeardown = createBrowserSessionTeardown({
|
|
415
|
+
disconnect: () => disconnectBrowser(),
|
|
416
|
+
releaseSession: () => loadmillCloudAdapter.releaseSession(loadmillCloudCredentials, {
|
|
417
|
+
waitForCompletion: shouldWaitForLoadmillCloudCompletion(targetConfig),
|
|
418
|
+
}),
|
|
419
|
+
});
|
|
420
|
+
const provisioningAbortController = new AbortController();
|
|
421
|
+
let loadmillCloudBrowserSigintHandled = false;
|
|
422
|
+
let loadmillCloudBrowserInterruptRequested = false;
|
|
423
|
+
process.once("SIGINT", async () => {
|
|
424
|
+
if (loadmillCloudBrowserSigintHandled)
|
|
425
|
+
return;
|
|
426
|
+
loadmillCloudBrowserSigintHandled = true;
|
|
427
|
+
loadmillCloudBrowserInterruptRequested = true;
|
|
428
|
+
console.log("\nStopping Loadmill Cloud session...");
|
|
429
|
+
provisioningAbortController.abort(new Error("Stopped by user"));
|
|
430
|
+
if (!loadmillCloudBrowserSessionConnected) {
|
|
431
|
+
process.exitCode = 130;
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
await loadmillCloudBrowserTeardown();
|
|
435
|
+
process.exit(130);
|
|
436
|
+
});
|
|
437
|
+
try {
|
|
438
|
+
statusLine.start("Starting Loadmill Cloud browser session");
|
|
439
|
+
targetId = await withLoadmillCloudSession({
|
|
440
|
+
credentials: loadmillCloudCredentials,
|
|
441
|
+
connect: () => connectToBrowser({
|
|
442
|
+
source: "loadmill-cloud",
|
|
443
|
+
credentials: loadmillCloudCredentials,
|
|
444
|
+
browserFamily: targetConfig.browserFamily,
|
|
445
|
+
browserVersion: targetConfig.browserVersion,
|
|
446
|
+
cloudSessionId: targetConfig.cloudSessionId,
|
|
447
|
+
artifacts: targetConfig.artifacts,
|
|
448
|
+
signal: provisioningAbortController.signal,
|
|
449
|
+
}),
|
|
450
|
+
teardown: loadmillCloudBrowserTeardown,
|
|
451
|
+
});
|
|
452
|
+
loadmillCloudBrowserSessionConnected = true;
|
|
453
|
+
if (loadmillCloudBrowserInterruptRequested) {
|
|
454
|
+
try {
|
|
455
|
+
await loadmillCloudBrowserTeardown();
|
|
456
|
+
}
|
|
457
|
+
finally {
|
|
458
|
+
statusLine.stop();
|
|
459
|
+
loadmillCloudBrowserSessionConnected = false;
|
|
460
|
+
process.exitCode = 130;
|
|
461
|
+
}
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
deviceInfo = normalizeBrowserInfoForSession(await getBrowserInfo(targetId));
|
|
465
|
+
statusLine.stop();
|
|
466
|
+
}
|
|
467
|
+
catch (error) {
|
|
468
|
+
statusLine.stop();
|
|
469
|
+
if (error?.artifactsOnlyExit) {
|
|
470
|
+
console.log(error.message);
|
|
471
|
+
process.exitCode = 0;
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (loadmillCloudBrowserInterruptRequested) {
|
|
475
|
+
process.exitCode = 130;
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (isLoadmillCloudNotEntitledError(error)) {
|
|
479
|
+
console.error(`\n${formatLoadmillCloudNotEntitledMessage(error)}`);
|
|
480
|
+
process.exitCode = 1;
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (isLoadmillCloudUsageLimitError(error)) {
|
|
484
|
+
const suffix = error.code ? ` (${error.code})` : "";
|
|
485
|
+
console.error(`\n${error.message}${suffix}`);
|
|
486
|
+
process.exitCode = 1;
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
throw error;
|
|
490
|
+
}
|
|
491
|
+
console.log(`Connected to Loadmill Cloud browser "${targetConfig.browserFamily}" (${targetId})`);
|
|
492
|
+
console.log(`Using browser viewport: ${deviceInfo.device_width}x${deviceInfo.device_height}`);
|
|
493
|
+
console.log(`Model sees viewport: ${deviceInfo.scaled_width}x${deviceInfo.scaled_height}`);
|
|
494
|
+
}
|
|
495
|
+
else if (isBrowserTarget) {
|
|
381
496
|
targetId = await connectToBrowser({
|
|
382
497
|
url: startupUrl,
|
|
383
498
|
headless: true,
|
|
@@ -587,6 +702,11 @@ async function main() {
|
|
|
587
702
|
process.exitCode = 130;
|
|
588
703
|
return;
|
|
589
704
|
}
|
|
705
|
+
if (isLoadmillCloudNotEntitledError(error)) {
|
|
706
|
+
console.error(`\n${formatLoadmillCloudNotEntitledMessage(error)}`);
|
|
707
|
+
process.exitCode = 1;
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
590
710
|
if (isLoadmillCloudUsageLimitError(error)) {
|
|
591
711
|
const suffix = error.code ? ` (${error.code})` : "";
|
|
592
712
|
console.error(`\n${error.message}${suffix}`);
|
|
@@ -595,9 +715,9 @@ async function main() {
|
|
|
595
715
|
}
|
|
596
716
|
throw error;
|
|
597
717
|
}
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
718
|
+
for (const line of buildLoadmillCloudDeviceConnectSummaryLines({ deviceInfo })) {
|
|
719
|
+
console.log(line);
|
|
720
|
+
}
|
|
601
721
|
}
|
|
602
722
|
else {
|
|
603
723
|
targetId = await connectToDevice(avdName, platform);
|
|
@@ -668,6 +788,7 @@ async function main() {
|
|
|
668
788
|
screenshotDir,
|
|
669
789
|
isBrowserTarget,
|
|
670
790
|
isCloudTarget,
|
|
791
|
+
runTarget: { isBrowserTarget, isCloudTarget },
|
|
671
792
|
report: reportConfig
|
|
672
793
|
});
|
|
673
794
|
exitCode = result.exitCode;
|
|
@@ -713,6 +834,17 @@ async function main() {
|
|
|
713
834
|
console.log(`Done in ${formatDuration(Date.now() - processStartTime)}.`);
|
|
714
835
|
}
|
|
715
836
|
}
|
|
837
|
+
if (loadmillCloudBrowserSessionConnected) {
|
|
838
|
+
console.log("Ending Loadmill Cloud session...");
|
|
839
|
+
loadmillCloudStatusLine.start("Ending Loadmill Cloud session");
|
|
840
|
+
await loadmillCloudBrowserTeardown();
|
|
841
|
+
loadmillCloudStatusLine.stop();
|
|
842
|
+
loadmillCloudBrowserSessionConnected = false;
|
|
843
|
+
console.log("Loadmill Cloud session ended.");
|
|
844
|
+
if (succeeded) {
|
|
845
|
+
console.log(`Done in ${formatDuration(Date.now() - processStartTime)}.`);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
716
848
|
}
|
|
717
849
|
if (exitCode !== null) {
|
|
718
850
|
process.exit(exitCode);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const LOCAL_BROWSER_FAMILIES = ["chrome", "edge"];
|
|
2
|
+
export const CLOUD_BROWSER_FAMILIES = ["chrome", "firefox"];
|
|
3
|
+
export const CLOUD_BROWSER_VERSION_ALIASES = ["latest", "latest-1", "latest-2"];
|
|
4
|
+
export const DEFAULT_CLOUD_BROWSER_FAMILY = "chrome";
|
|
5
|
+
export const DEFAULT_CLOUD_BROWSER_VERSION = "latest";
|
|
6
|
+
const CLOUD_BROWSER_DISPLAY_NAMES = {
|
|
7
|
+
chrome: "Chrome",
|
|
8
|
+
firefox: "Firefox",
|
|
9
|
+
};
|
|
10
|
+
export function isSupportedCloudBrowserFamily(value) {
|
|
11
|
+
return typeof value === "string" && CLOUD_BROWSER_FAMILIES.includes(value.trim().toLowerCase());
|
|
12
|
+
}
|
|
13
|
+
export function isSupportedCloudBrowserVersion(value) {
|
|
14
|
+
return typeof value === "string" && CLOUD_BROWSER_VERSION_ALIASES.includes(value.trim().toLowerCase());
|
|
15
|
+
}
|
|
16
|
+
export function assertSupportedCloudBrowser(browserFamily, browserVersion) {
|
|
17
|
+
if (!isSupportedCloudBrowserFamily(browserFamily)) {
|
|
18
|
+
throw new Error(`Loadmill Cloud browser must be one of: ${CLOUD_BROWSER_FAMILIES.join(", ")}.`);
|
|
19
|
+
}
|
|
20
|
+
if (!isSupportedCloudBrowserVersion(browserVersion)) {
|
|
21
|
+
throw new Error(`Loadmill Cloud browser version must be one of: ${CLOUD_BROWSER_VERSION_ALIASES.join(", ")}.`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function formatCloudBrowserName(browserFamily, browserVersion) {
|
|
25
|
+
const label = CLOUD_BROWSER_DISPLAY_NAMES[browserFamily] ?? browserFamily;
|
|
26
|
+
return browserVersion ? `${label} (${browserVersion})` : label;
|
|
27
|
+
}
|
|
@@ -1,20 +1,43 @@
|
|
|
1
|
-
import * as
|
|
2
|
-
import * as
|
|
1
|
+
import * as playwrightConnection from "./playwright/connection.js";
|
|
2
|
+
import * as playwrightActions from "./playwright/actions.js";
|
|
3
|
+
import * as webdriverConnection from "./webdriver/connection.js";
|
|
4
|
+
import * as webdriverActions from "./webdriver/actions.js";
|
|
3
5
|
let currentPlatform = null;
|
|
6
|
+
let currentBackendId = "playwright";
|
|
4
7
|
export function detectPlatform() {
|
|
5
8
|
return "browser";
|
|
6
9
|
}
|
|
7
|
-
|
|
10
|
+
function buildPlaywrightBackend() {
|
|
11
|
+
return {
|
|
12
|
+
connectToBrowser: playwrightConnection.connectToBrowser,
|
|
13
|
+
getBrowserInfo: playwrightConnection.getBrowserInfo,
|
|
14
|
+
getScreenshotAsBase64: playwrightConnection.getScreenshotAsBase64,
|
|
15
|
+
navigateToUrl: playwrightConnection.navigateToUrl,
|
|
16
|
+
closeCurrentTab: playwrightConnection.closeCurrentTab,
|
|
17
|
+
handleModelAction: playwrightActions.handleModelAction,
|
|
18
|
+
disconnect: playwrightConnection.disconnect,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function buildWebDriverBackend() {
|
|
8
22
|
return {
|
|
9
|
-
connectToBrowser:
|
|
10
|
-
getBrowserInfo:
|
|
11
|
-
getScreenshotAsBase64:
|
|
12
|
-
navigateToUrl:
|
|
13
|
-
closeCurrentTab:
|
|
14
|
-
handleModelAction:
|
|
15
|
-
disconnect:
|
|
23
|
+
connectToBrowser: webdriverConnection.connectToBrowser,
|
|
24
|
+
getBrowserInfo: webdriverConnection.getBrowserInfo,
|
|
25
|
+
getScreenshotAsBase64: webdriverConnection.getScreenshotAsBase64,
|
|
26
|
+
navigateToUrl: webdriverConnection.navigateToUrl,
|
|
27
|
+
closeCurrentTab: webdriverConnection.closeCurrentTab,
|
|
28
|
+
handleModelAction: webdriverActions.handleModelAction,
|
|
29
|
+
disconnect: webdriverConnection.disconnect,
|
|
16
30
|
};
|
|
17
31
|
}
|
|
32
|
+
export function getBrowserBackend(_platform = "browser") {
|
|
33
|
+
return currentBackendId === "webdriver" ? buildWebDriverBackend() : buildPlaywrightBackend();
|
|
34
|
+
}
|
|
35
|
+
export function setBrowserBackendId(backendId) {
|
|
36
|
+
currentBackendId = backendId === "webdriver" ? "webdriver" : "playwright";
|
|
37
|
+
}
|
|
38
|
+
export function getBrowserBackendId() {
|
|
39
|
+
return currentBackendId;
|
|
40
|
+
}
|
|
18
41
|
export function setCurrentPlatform(platform) {
|
|
19
42
|
currentPlatform = platform;
|
|
20
43
|
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { logger } from "../utils/logger.js";
|
|
2
|
+
import { emitDesktopDebug, truncateForDebug } from "../utils/desktop-debug.js";
|
|
3
|
+
import { getConfiguredStepDelayMs } from "../utils/step-delay.js";
|
|
4
|
+
import { buildActionEventMeta } from "../device/action-event-meta.js";
|
|
5
|
+
import { actionForErrorLog, buildActionErrorOutput, buildDebugTextField, buildSensitiveDebugFields, buildTypeActionPayload, buildTypeActionText, isSensitiveAction, } from "../device/sensitive-action.js";
|
|
6
|
+
import { normalizeAutomationUrl } from "./playwright/url.js";
|
|
7
|
+
const KEY_ALIASES = {
|
|
8
|
+
CTRL: "Control",
|
|
9
|
+
CONTROL: "Control",
|
|
10
|
+
CMD: "Meta",
|
|
11
|
+
COMMAND: "Meta",
|
|
12
|
+
META: "Meta",
|
|
13
|
+
ALT: "Alt",
|
|
14
|
+
OPTION: "Alt",
|
|
15
|
+
SHIFT: "Shift",
|
|
16
|
+
ENTER: "Enter",
|
|
17
|
+
RETURN: "Enter",
|
|
18
|
+
ESC: "Escape",
|
|
19
|
+
ESCAPE: "Escape",
|
|
20
|
+
BACKSPACE: "Backspace",
|
|
21
|
+
DELETE: "Delete",
|
|
22
|
+
TAB: "Tab",
|
|
23
|
+
SPACE: "Space",
|
|
24
|
+
HOME: "Home",
|
|
25
|
+
END: "End",
|
|
26
|
+
PAGEUP: "PageUp",
|
|
27
|
+
PAGEDOWN: "PageDown",
|
|
28
|
+
ARROWUP: "ArrowUp",
|
|
29
|
+
ARROWDOWN: "ArrowDown",
|
|
30
|
+
ARROWLEFT: "ArrowLeft",
|
|
31
|
+
ARROWRIGHT: "ArrowRight",
|
|
32
|
+
};
|
|
33
|
+
function normalizeKeyEntry(key) {
|
|
34
|
+
const raw = String(key ?? "").trim();
|
|
35
|
+
if (!raw) {
|
|
36
|
+
throw new Error("Keypress action is missing keys.");
|
|
37
|
+
}
|
|
38
|
+
const normalized = KEY_ALIASES[raw.toUpperCase()];
|
|
39
|
+
if (normalized) {
|
|
40
|
+
return normalized;
|
|
41
|
+
}
|
|
42
|
+
if (raw.length === 1) {
|
|
43
|
+
return raw;
|
|
44
|
+
}
|
|
45
|
+
return raw[0].toUpperCase() + raw.slice(1);
|
|
46
|
+
}
|
|
47
|
+
function normalizeShortcut(keys = []) {
|
|
48
|
+
if (!Array.isArray(keys) || keys.length === 0) {
|
|
49
|
+
throw new Error("Keypress action is missing keys.");
|
|
50
|
+
}
|
|
51
|
+
return keys.map(normalizeKeyEntry).join("+");
|
|
52
|
+
}
|
|
53
|
+
function buildMeta(context, action, payload = {}) {
|
|
54
|
+
return buildActionEventMeta({
|
|
55
|
+
context,
|
|
56
|
+
action,
|
|
57
|
+
platform: "browser",
|
|
58
|
+
payload,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
function handleNavigationTimeout(error, description) {
|
|
62
|
+
if (!(error instanceof Error) || error.name !== "TimeoutError") {
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
console.warn(`Page load did not finish within the timeout for ${description}. Continuing with the current page state.`);
|
|
66
|
+
}
|
|
67
|
+
async function gotoWithTimeoutTolerance(page, url) {
|
|
68
|
+
try {
|
|
69
|
+
await page.goto(url, { waitUntil: "load" });
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
handleNavigationTimeout(error, url);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function goBackWithTimeoutTolerance(page) {
|
|
76
|
+
try {
|
|
77
|
+
await page.goBack({ waitUntil: "load" });
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
handleNavigationTimeout(error, "browser history back");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function goForwardWithTimeoutTolerance(page) {
|
|
84
|
+
try {
|
|
85
|
+
await page.goForward({ waitUntil: "load" });
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
handleNavigationTimeout(error, "browser history forward");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async function updateSessionUrlFromPage(session, page) {
|
|
92
|
+
const currentUrl = typeof page.url === "function" ? await page.url() : null;
|
|
93
|
+
if (!currentUrl) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
session.pageUrls?.set?.(page, currentUrl);
|
|
97
|
+
session.url = currentUrl;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Builds the shared 11-case model-action dispatcher over a page-shaped facade.
|
|
101
|
+
* `playwright/actions.js` and `webdriver/actions.js` bind their own
|
|
102
|
+
* `{ getSession, getPage }` accessors to this factory.
|
|
103
|
+
*
|
|
104
|
+
* @param {{ getSession: (browserSessionId?: string) => object, getPage: (browserSessionId?: string) => object }} accessors
|
|
105
|
+
* @returns {(browserSessionId: string, action: object, scale?: number, context?: object|null) => Promise<{ success: boolean, error?: string }>}
|
|
106
|
+
*/
|
|
107
|
+
export function createGestureDispatch({ getSession, getPage }) {
|
|
108
|
+
return async function handleModelAction(browserSessionId, action, scale = 1, context = null) {
|
|
109
|
+
const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
|
|
110
|
+
const session = getSession(browserSessionId);
|
|
111
|
+
const page = getPage(browserSessionId);
|
|
112
|
+
try {
|
|
113
|
+
emitDesktopDebug("browser.action.execute", "device", {
|
|
114
|
+
runId: context?.runId,
|
|
115
|
+
sessionId: context?.sessionId,
|
|
116
|
+
stepId: context?.stepId,
|
|
117
|
+
instructionIndex: context?.instructionIndex,
|
|
118
|
+
}, {
|
|
119
|
+
platform: "browser",
|
|
120
|
+
browserSessionId,
|
|
121
|
+
actionType: action?.type,
|
|
122
|
+
scale,
|
|
123
|
+
x: action?.x,
|
|
124
|
+
y: action?.y,
|
|
125
|
+
x1: action?.x1,
|
|
126
|
+
y1: action?.y1,
|
|
127
|
+
x2: action?.x2,
|
|
128
|
+
y2: action?.y2,
|
|
129
|
+
text: buildDebugTextField(action, truncateForDebug),
|
|
130
|
+
...buildSensitiveDebugFields(action),
|
|
131
|
+
keyCount: Array.isArray(action?.keys) ? action.keys.length : 0,
|
|
132
|
+
pathPoints: Array.isArray(action?.path) ? action.path.length : 0,
|
|
133
|
+
});
|
|
134
|
+
switch (action.type) {
|
|
135
|
+
case "click": {
|
|
136
|
+
const x = Math.round(action.x / scale);
|
|
137
|
+
const y = Math.round(action.y / scale);
|
|
138
|
+
addOutput({ type: "action", text: "Click", ...buildMeta(context, action, { x, y, unit: "px" }) });
|
|
139
|
+
await page.mouse.click(x, y);
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
case "double_click": {
|
|
143
|
+
const x = Math.round(action.x / scale);
|
|
144
|
+
const y = Math.round(action.y / scale);
|
|
145
|
+
addOutput({ type: "action", text: `Double-clicking at (${x}, ${y})`, ...buildMeta(context, action, { x, y, unit: "px" }) });
|
|
146
|
+
await page.mouse.dblclick(x, y);
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
case "move": {
|
|
150
|
+
const x = Math.round(action.x / scale);
|
|
151
|
+
const y = Math.round(action.y / scale);
|
|
152
|
+
addOutput({ type: "action", text: `Moving mouse to (${x}, ${y})`, ...buildMeta(context, action, { x, y, unit: "px" }) });
|
|
153
|
+
await page.mouse.move(x, y);
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
case "scroll": {
|
|
157
|
+
const scrollX = Math.round((action.scroll_x ?? 0) / scale);
|
|
158
|
+
const scrollY = Math.round((action.scroll_y ?? 0) / scale);
|
|
159
|
+
addOutput({
|
|
160
|
+
type: "action",
|
|
161
|
+
text: `Scrolling by (${scrollX}, ${scrollY})`,
|
|
162
|
+
...buildMeta(context, action, { scrollX, scrollY, unit: "px" }),
|
|
163
|
+
});
|
|
164
|
+
await page.mouse.wheel(scrollX, scrollY);
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
case "drag": {
|
|
168
|
+
const dragPath = Array.isArray(action.path) ? action.path : [];
|
|
169
|
+
if (dragPath.length < 2) {
|
|
170
|
+
throw new Error("Drag action requires a path with at least two points.");
|
|
171
|
+
}
|
|
172
|
+
const [start] = dragPath;
|
|
173
|
+
const end = dragPath[dragPath.length - 1];
|
|
174
|
+
const scaledStart = { x: Math.round(start.x / scale), y: Math.round(start.y / scale) };
|
|
175
|
+
const scaledEnd = { x: Math.round(end.x / scale), y: Math.round(end.y / scale) };
|
|
176
|
+
addOutput({
|
|
177
|
+
type: "action",
|
|
178
|
+
text: `Dragging from (${scaledStart.x}, ${scaledStart.y}) to (${scaledEnd.x}, ${scaledEnd.y})`,
|
|
179
|
+
...buildMeta(context, action, { pathStart: scaledStart, pathEnd: scaledEnd, unit: "px" }),
|
|
180
|
+
});
|
|
181
|
+
await page.mouse.move(scaledStart.x, scaledStart.y);
|
|
182
|
+
await page.mouse.down();
|
|
183
|
+
for (const point of dragPath.slice(1)) {
|
|
184
|
+
await page.mouse.move(Math.round(point.x / scale), Math.round(point.y / scale));
|
|
185
|
+
}
|
|
186
|
+
await page.mouse.up();
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
case "type": {
|
|
190
|
+
const text = typeof action.text === "string" ? action.text : "";
|
|
191
|
+
addOutput({
|
|
192
|
+
type: "action",
|
|
193
|
+
text: buildTypeActionText(action, text),
|
|
194
|
+
...buildMeta(context, action, buildTypeActionPayload(action, text))
|
|
195
|
+
});
|
|
196
|
+
await page.keyboard.type(text);
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
case "keypress": {
|
|
200
|
+
const shortcut = normalizeShortcut(action.keys);
|
|
201
|
+
addOutput({
|
|
202
|
+
type: "action",
|
|
203
|
+
text: `Pressing key: ${shortcut}`,
|
|
204
|
+
...buildMeta(context, action, { keys: action.keys, shortcut }),
|
|
205
|
+
});
|
|
206
|
+
await page.keyboard.press(shortcut);
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
case "wait": {
|
|
210
|
+
addOutput({ type: "action", text: "Waiting...", ...buildMeta(context, action) });
|
|
211
|
+
const timeoutMs = typeof action.ms === "number" && Number.isFinite(action.ms) && action.ms >= 0
|
|
212
|
+
? Math.round(action.ms)
|
|
213
|
+
: getConfiguredStepDelayMs();
|
|
214
|
+
await page.waitForTimeout(timeoutMs);
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
case "goto": {
|
|
218
|
+
const normalizedUrl = normalizeAutomationUrl(action.url);
|
|
219
|
+
addOutput({
|
|
220
|
+
type: "action",
|
|
221
|
+
text: `Navigating to ${normalizedUrl}`,
|
|
222
|
+
...buildMeta(context, action, { url: normalizedUrl }),
|
|
223
|
+
});
|
|
224
|
+
await gotoWithTimeoutTolerance(page, normalizedUrl);
|
|
225
|
+
session.pageUrls?.set?.(page, normalizedUrl);
|
|
226
|
+
session.url = normalizedUrl;
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
case "back": {
|
|
230
|
+
addOutput({ type: "action", text: "Going back", ...buildMeta(context, action) });
|
|
231
|
+
await goBackWithTimeoutTolerance(page);
|
|
232
|
+
await updateSessionUrlFromPage(session, page);
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
case "forward": {
|
|
236
|
+
addOutput({ type: "action", text: "Going forward", ...buildMeta(context, action) });
|
|
237
|
+
await goForwardWithTimeoutTolerance(page);
|
|
238
|
+
await updateSessionUrlFromPage(session, page);
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
default:
|
|
242
|
+
addOutput({ type: "info", text: `Unknown action: ${JSON.stringify(action)}` });
|
|
243
|
+
}
|
|
244
|
+
return { success: true };
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
const outputError = buildActionErrorOutput(action, error);
|
|
248
|
+
logger.error("Action execution error", {
|
|
249
|
+
action: actionForErrorLog(action),
|
|
250
|
+
message: outputError.message,
|
|
251
|
+
stack: isSensitiveAction(action) ? undefined : error.stack,
|
|
252
|
+
});
|
|
253
|
+
emitDesktopDebug("browser.error", "device", {
|
|
254
|
+
runId: context?.runId,
|
|
255
|
+
sessionId: context?.sessionId,
|
|
256
|
+
stepId: context?.stepId,
|
|
257
|
+
instructionIndex: context?.instructionIndex,
|
|
258
|
+
}, {
|
|
259
|
+
platform: "browser",
|
|
260
|
+
operation: "action.execute",
|
|
261
|
+
actionType: action?.type,
|
|
262
|
+
message: outputError.message,
|
|
263
|
+
});
|
|
264
|
+
addOutput({
|
|
265
|
+
type: "error",
|
|
266
|
+
text: outputError.text,
|
|
267
|
+
eventType: "error",
|
|
268
|
+
actionType: action?.type,
|
|
269
|
+
runId: context?.runId,
|
|
270
|
+
stepId: context?.stepId,
|
|
271
|
+
instructionIndex: context?.instructionIndex,
|
|
272
|
+
payload: {
|
|
273
|
+
message: outputError.message,
|
|
274
|
+
platform: "browser",
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
addOutput({ type: "info", text: "Full error details have been logged to the debug log." });
|
|
278
|
+
return { success: false, error: outputError.message };
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
}
|