@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.
Files changed (39) hide show
  1. package/README.md +5 -5
  2. package/build/index.js +142 -10
  3. package/build/src/browser/cloud-browser-catalog.js +27 -0
  4. package/build/src/browser/factory.js +33 -10
  5. package/build/src/browser/gesture-dispatch.js +281 -0
  6. package/build/src/browser/interface.js +3 -0
  7. package/build/src/browser/normalize.js +3 -1
  8. package/build/src/browser/playwright/actions.js +5 -271
  9. package/build/src/browser/webdriver/actions.js +10 -0
  10. package/build/src/browser/webdriver/client.js +83 -0
  11. package/build/src/browser/webdriver/connection.js +300 -0
  12. package/build/src/browser/webdriver/page-facade.js +123 -0
  13. package/build/src/browser/webdriver/session-state.js +35 -0
  14. package/build/src/cli/headless-execution-config.js +1 -1
  15. package/build/src/cli/headless-runner.js +26 -13
  16. package/build/src/cli/headless-target.js +87 -16
  17. package/build/src/cli/headless-test-source-label.js +4 -0
  18. package/build/src/cli/loadmill-cloud-device-connect-summary.js +6 -0
  19. package/build/src/cli/loadmill-cloud-session.js +26 -0
  20. package/build/src/core/context-optimization.js +1 -1
  21. package/build/src/core/execution-engine.js +9 -0
  22. package/build/src/core/report/render-html-report.js +106 -12
  23. package/build/src/core/report/run-report-sections.js +136 -0
  24. package/build/src/core/runtime-config.js +2 -2
  25. package/build/src/desktop-shared/connection-predicates.js +52 -0
  26. package/build/src/device/cloud/loadmill-cloud/adapter.js +17 -12
  27. package/build/src/device/cloud/loadmill-cloud/artifact-downloader.js +13 -7
  28. package/build/src/device/cloud/loadmill-cloud/control-plane-client.js +22 -5
  29. package/build/src/device/cloud/loadmill-cloud/errors.js +25 -0
  30. package/build/src/device/cloud/loadmill-cloud/run-pool.js +41 -0
  31. package/build/src/device/cloud/loadmill-cloud/run-status.js +4 -0
  32. package/package.json +1 -1
  33. package/skill/droid-cua/SKILL.md +10 -3
  34. package/skill/droid-cua/agents/openai.yaml +2 -2
  35. package/skill/droid-cua/references/authoring-tests.md +36 -6
  36. package/skill/droid-cua/references/iterative-improvement.md +143 -0
  37. package/skill/droid-cua/references/onboarding-and-troubleshooting.md +19 -0
  38. package/skill/droid-cua/references/project-and-auth.md +2 -0
  39. 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": 30000
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 `30000` input-token threshold.
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
- - `--explain` enables Explain Mode for the run. Explain Mode 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": true` in the JSON config to enable the same behavior without a flag. Explain Mode is off by default, and `--explain` enables it when the config leaves it off.
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: targetConfig.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 (isBrowserTarget) {
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
- console.log(`Connected to Loadmill Cloud device "${targetConfig.deviceName}" (${targetId})`);
599
- console.log(`Using real resolution: ${deviceInfo.device_width}x${deviceInfo.device_height}`);
600
- console.log(`Model sees resolution: ${deviceInfo.scaled_width}x${deviceInfo.scaled_height}`);
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 browserConnection from "./playwright/connection.js";
2
- import * as browserActions from "./playwright/actions.js";
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
- export function getBrowserBackend(_platform = "browser") {
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: browserConnection.connectToBrowser,
10
- getBrowserInfo: browserConnection.getBrowserInfo,
11
- getScreenshotAsBase64: browserConnection.getScreenshotAsBase64,
12
- navigateToUrl: browserConnection.navigateToUrl,
13
- closeCurrentTab: browserConnection.closeCurrentTab,
14
- handleModelAction: browserActions.handleModelAction,
15
- disconnect: browserConnection.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
+ }
@@ -11,4 +11,7 @@ export const SUPPORTED_ACTIONS = [
11
11
  "keypress",
12
12
  "wait",
13
13
  "screenshot",
14
+ "goto",
15
+ "back",
16
+ "forward",
14
17
  ];