@loadmill/droid-cua 2.7.0 → 2.8.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/build/index.js +48 -14
- package/build/src/browser/actions.js +9 -0
- package/build/src/browser/connection.js +37 -0
- package/build/src/browser/factory.js +22 -0
- package/build/src/browser/interface.js +14 -0
- package/build/src/browser/navigation.js +11 -0
- package/build/src/browser/normalize.js +36 -0
- package/build/src/browser/playwright/actions.js +199 -0
- package/build/src/browser/playwright/connection.js +196 -0
- package/build/src/browser/playwright/installed-browser.js +131 -0
- package/build/src/browser/playwright/session-state.js +19 -0
- package/build/src/browser/playwright/url.js +79 -0
- package/build/src/cli/headless-target.js +64 -0
- package/build/src/commands/help.js +7 -1
- package/build/src/core/capture-sources.js +76 -0
- package/build/src/core/execution-engine.js +41 -11
- package/build/src/core/prompts.js +1 -1
- package/build/src/core/session.js +18 -4
- package/build/src/device/action-event-meta.js +18 -0
- package/build/src/device/android/actions.js +6 -13
- package/build/src/device/cloud/actions.js +9 -14
- package/build/src/device/cloud/connection.js +8 -0
- package/build/src/device/ios/actions.js +6 -13
- package/build/src/device/ios/connection.js +8 -0
- package/build/src/modes/execution-mode.js +113 -21
- package/build/src/prompts/base.js +92 -20
- package/build/src/prompts/design.js +6 -11
- package/build/src/prompts/execution.js +26 -4
- package/build/src/utils/run-screenshot-recorder.js +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { access } from "node:fs/promises";
|
|
4
|
+
import { constants as fsConstants } from "node:fs";
|
|
5
|
+
export const CHROME_INSTALL_URL = "https://www.google.com/chrome/";
|
|
6
|
+
export const EDGE_INSTALL_URL = "https://www.microsoft.com/edge/download";
|
|
7
|
+
function readEnvValue(env, ...keys) {
|
|
8
|
+
for (const key of keys) {
|
|
9
|
+
const value = env?.[key];
|
|
10
|
+
if (typeof value === "string" && value.trim()) {
|
|
11
|
+
return value.trim();
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
function normalizeWindowsBaseDirs(env, homeDir) {
|
|
17
|
+
const programFiles = readEnvValue(env, "PROGRAMFILES", "ProgramFiles") || "C:\\Program Files";
|
|
18
|
+
const programFilesX86 = readEnvValue(env, "PROGRAMFILES(X86)") || "C:\\Program Files (x86)";
|
|
19
|
+
const localAppData = readEnvValue(env, "LOCALAPPDATA")
|
|
20
|
+
|| (homeDir ? path.join(homeDir, "AppData", "Local") : null)
|
|
21
|
+
|| "C:\\Users\\Default\\AppData\\Local";
|
|
22
|
+
return { programFiles, programFilesX86, localAppData };
|
|
23
|
+
}
|
|
24
|
+
function uniquePaths(paths = []) {
|
|
25
|
+
return [...new Set(paths.filter((value) => typeof value === "string" && value.trim().length > 0))];
|
|
26
|
+
}
|
|
27
|
+
export function listInstalledBrowserCandidates({ platform = process.platform, env = process.env, homeDir = os.homedir(), } = {}) {
|
|
28
|
+
if (platform === "darwin") {
|
|
29
|
+
const appRoots = uniquePaths([
|
|
30
|
+
"/Applications",
|
|
31
|
+
homeDir ? path.join(homeDir, "Applications") : null,
|
|
32
|
+
]);
|
|
33
|
+
return [
|
|
34
|
+
{
|
|
35
|
+
browserFamily: "chrome",
|
|
36
|
+
browserName: "Google Chrome",
|
|
37
|
+
candidates: appRoots.map((root) => path.join(root, "Google Chrome.app", "Contents", "MacOS", "Google Chrome")),
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
browserFamily: "edge",
|
|
41
|
+
browserName: "Microsoft Edge",
|
|
42
|
+
candidates: appRoots.map((root) => path.join(root, "Microsoft Edge.app", "Contents", "MacOS", "Microsoft Edge")),
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
}
|
|
46
|
+
if (platform === "win32") {
|
|
47
|
+
const { programFiles, programFilesX86, localAppData } = normalizeWindowsBaseDirs(env, homeDir);
|
|
48
|
+
return [
|
|
49
|
+
{
|
|
50
|
+
browserFamily: "chrome",
|
|
51
|
+
browserName: "Google Chrome",
|
|
52
|
+
candidates: uniquePaths([
|
|
53
|
+
path.win32.join(programFiles, "Google", "Chrome", "Application", "chrome.exe"),
|
|
54
|
+
path.win32.join(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"),
|
|
55
|
+
path.win32.join(localAppData, "Google", "Chrome", "Application", "chrome.exe"),
|
|
56
|
+
]),
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
browserFamily: "edge",
|
|
60
|
+
browserName: "Microsoft Edge",
|
|
61
|
+
candidates: uniquePaths([
|
|
62
|
+
path.win32.join(programFiles, "Microsoft", "Edge", "Application", "msedge.exe"),
|
|
63
|
+
path.win32.join(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe"),
|
|
64
|
+
path.win32.join(localAppData, "Microsoft", "Edge", "Application", "msedge.exe"),
|
|
65
|
+
]),
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
async function pathExistsAndIsRunnable(filePath, platform, accessFn) {
|
|
72
|
+
const accessMode = platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK;
|
|
73
|
+
try {
|
|
74
|
+
await accessFn(filePath, accessMode);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function buildMissingBrowserMessage(platform) {
|
|
82
|
+
if (platform !== "darwin" && platform !== "win32") {
|
|
83
|
+
return "Installed browser detection is currently supported only on macOS and Windows. Install Google Chrome or Microsoft Edge on a supported machine.";
|
|
84
|
+
}
|
|
85
|
+
return `No supported browser was found. Install Google Chrome or Microsoft Edge and try again. Chrome: ${CHROME_INSTALL_URL} Edge: ${EDGE_INSTALL_URL}`;
|
|
86
|
+
}
|
|
87
|
+
export async function resolveInstalledBrowser({ platform = process.platform, env = process.env, homeDir = os.homedir(), accessFn = access, } = {}) {
|
|
88
|
+
const browserGroups = listInstalledBrowserCandidates({ platform, env, homeDir });
|
|
89
|
+
for (const browser of browserGroups) {
|
|
90
|
+
for (const executablePath of browser.candidates) {
|
|
91
|
+
if (await pathExistsAndIsRunnable(executablePath, platform, accessFn)) {
|
|
92
|
+
return {
|
|
93
|
+
browserFamily: browser.browserFamily,
|
|
94
|
+
browserName: browser.browserName,
|
|
95
|
+
executablePath,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
export async function getInstalledBrowserAvailability(options = {}) {
|
|
103
|
+
const resolved = await resolveInstalledBrowser(options);
|
|
104
|
+
if (!resolved) {
|
|
105
|
+
return {
|
|
106
|
+
available: false,
|
|
107
|
+
browserFamily: null,
|
|
108
|
+
browserName: null,
|
|
109
|
+
executablePath: null,
|
|
110
|
+
message: buildMissingBrowserMessage(options.platform ?? process.platform),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
available: true,
|
|
115
|
+
browserFamily: resolved.browserFamily,
|
|
116
|
+
browserName: resolved.browserName,
|
|
117
|
+
executablePath: resolved.executablePath,
|
|
118
|
+
message: null,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
export async function requireInstalledBrowser(options = {}) {
|
|
122
|
+
const availability = await getInstalledBrowserAvailability(options);
|
|
123
|
+
if (!availability.available || !availability.browserName || !availability.executablePath) {
|
|
124
|
+
throw new Error(availability.message || buildMissingBrowserMessage(options.platform ?? process.platform));
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
browserFamily: availability.browserFamily,
|
|
128
|
+
browserName: availability.browserName,
|
|
129
|
+
executablePath: availability.executablePath,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
let activeSession = null;
|
|
2
|
+
export function setActiveBrowserSession(session) {
|
|
3
|
+
activeSession = session;
|
|
4
|
+
}
|
|
5
|
+
export function clearActiveBrowserSession() {
|
|
6
|
+
activeSession = null;
|
|
7
|
+
}
|
|
8
|
+
export function getActiveBrowserSession(browserSessionId = null) {
|
|
9
|
+
if (!activeSession) {
|
|
10
|
+
throw new Error("Not connected to a browser session. Call connectToBrowser first.");
|
|
11
|
+
}
|
|
12
|
+
if (browserSessionId && activeSession.sessionId !== browserSessionId) {
|
|
13
|
+
throw new Error("Requested browser session is no longer active.");
|
|
14
|
+
}
|
|
15
|
+
return activeSession;
|
|
16
|
+
}
|
|
17
|
+
export function getActiveBrowserPage(browserSessionId = null) {
|
|
18
|
+
return getActiveBrowserSession(browserSessionId).page;
|
|
19
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const HTTP_SCHEME_RE = /^https?:\/\//i;
|
|
2
|
+
const SCHEME_RE = /^[a-z][a-z\d+\-.]*:\/\//i;
|
|
3
|
+
const LOCAL_HOST_RE = /^(localhost|127\.0\.0\.1)(:\d+)?$/i;
|
|
4
|
+
const EXPLICIT_PORT_RE = /^[^/]+:\d+$/;
|
|
5
|
+
const URL_ERROR_MESSAGE = "Browser URL must be a valid host or http:// / https:// URL.";
|
|
6
|
+
function hasUnsupportedBareScheme(rawValue) {
|
|
7
|
+
if (rawValue.includes("://")) {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
const authority = rawValue.split(/[/?#]/, 1)[0]?.trim() || "";
|
|
11
|
+
const colonIndex = authority.indexOf(":");
|
|
12
|
+
if (colonIndex <= 0 || LOCAL_HOST_RE.test(authority) || EXPLICIT_PORT_RE.test(authority)) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
const prefix = authority.slice(0, colonIndex);
|
|
16
|
+
const suffix = authority.slice(colonIndex + 1);
|
|
17
|
+
return /^[a-z][a-z\d+\-.]*$/i.test(prefix) && !prefix.includes(".") && !/^\d+$/.test(suffix);
|
|
18
|
+
}
|
|
19
|
+
function inferDefaultScheme(rawValue) {
|
|
20
|
+
const authority = rawValue.split(/[/?#]/, 1)[0]?.trim() || "";
|
|
21
|
+
if (LOCAL_HOST_RE.test(authority) || EXPLICIT_PORT_RE.test(authority)) {
|
|
22
|
+
return "http://";
|
|
23
|
+
}
|
|
24
|
+
return "https://";
|
|
25
|
+
}
|
|
26
|
+
function parseUrlCandidate(rawValue) {
|
|
27
|
+
if (rawValue == null) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
if (typeof rawValue !== "string") {
|
|
31
|
+
throw new Error("Browser URL must be a string.");
|
|
32
|
+
}
|
|
33
|
+
const trimmed = rawValue.trim();
|
|
34
|
+
if (!trimmed) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
if (hasUnsupportedBareScheme(trimmed)) {
|
|
38
|
+
throw new Error("Browser URL must use http:// or https://.");
|
|
39
|
+
}
|
|
40
|
+
if (SCHEME_RE.test(trimmed) && !HTTP_SCHEME_RE.test(trimmed)) {
|
|
41
|
+
throw new Error("Browser URL must use http:// or https://.");
|
|
42
|
+
}
|
|
43
|
+
const candidate = HTTP_SCHEME_RE.test(trimmed)
|
|
44
|
+
? trimmed
|
|
45
|
+
: `${inferDefaultScheme(trimmed)}${trimmed}`;
|
|
46
|
+
let parsedUrl;
|
|
47
|
+
try {
|
|
48
|
+
parsedUrl = new URL(candidate);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw new Error(URL_ERROR_MESSAGE);
|
|
52
|
+
}
|
|
53
|
+
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
|
|
54
|
+
throw new Error("Browser URL must use http:// or https://.");
|
|
55
|
+
}
|
|
56
|
+
if (!parsedUrl.hostname) {
|
|
57
|
+
throw new Error(URL_ERROR_MESSAGE);
|
|
58
|
+
}
|
|
59
|
+
return parsedUrl;
|
|
60
|
+
}
|
|
61
|
+
export function normalizeOptionalAutomationUrl(rawValue) {
|
|
62
|
+
const parsedUrl = parseUrlCandidate(rawValue);
|
|
63
|
+
return parsedUrl ? parsedUrl.toString() : null;
|
|
64
|
+
}
|
|
65
|
+
export function normalizeAutomationUrl(rawValue) {
|
|
66
|
+
const normalizedUrl = normalizeOptionalAutomationUrl(rawValue);
|
|
67
|
+
if (!normalizedUrl) {
|
|
68
|
+
throw new Error("Browser URL is required.");
|
|
69
|
+
}
|
|
70
|
+
return normalizedUrl;
|
|
71
|
+
}
|
|
72
|
+
export function formatAutomationDisplayName(rawUrl) {
|
|
73
|
+
const normalizedUrl = normalizeOptionalAutomationUrl(rawUrl);
|
|
74
|
+
if (!normalizedUrl) {
|
|
75
|
+
return "Browser";
|
|
76
|
+
}
|
|
77
|
+
const parsedUrl = new URL(normalizedUrl);
|
|
78
|
+
return parsedUrl.host || parsedUrl.origin || normalizedUrl;
|
|
79
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
function normalizeTarget(value) {
|
|
2
|
+
if (value == null || value === "") {
|
|
3
|
+
return "mobile";
|
|
4
|
+
}
|
|
5
|
+
if (typeof value !== "string") {
|
|
6
|
+
throw new Error("--target must be one of: mobile, web.");
|
|
7
|
+
}
|
|
8
|
+
const normalized = value.trim().toLowerCase();
|
|
9
|
+
if (normalized === "mobile" || normalized === "web") {
|
|
10
|
+
return normalized;
|
|
11
|
+
}
|
|
12
|
+
throw new Error("--target must be one of: mobile, web.");
|
|
13
|
+
}
|
|
14
|
+
function normalizeSessionMode(value) {
|
|
15
|
+
if (value == null || value === "") {
|
|
16
|
+
return "persistent";
|
|
17
|
+
}
|
|
18
|
+
if (typeof value !== "string") {
|
|
19
|
+
throw new Error("--session-mode must be one of: persistent, fresh.");
|
|
20
|
+
}
|
|
21
|
+
const normalized = value.trim().toLowerCase();
|
|
22
|
+
if (normalized === "persistent" || normalized === "fresh") {
|
|
23
|
+
return normalized;
|
|
24
|
+
}
|
|
25
|
+
throw new Error("--session-mode must be one of: persistent, fresh.");
|
|
26
|
+
}
|
|
27
|
+
function hasNonEmptyString(value) {
|
|
28
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
29
|
+
}
|
|
30
|
+
export function resolveHeadlessTarget(args = {}) {
|
|
31
|
+
const target = normalizeTarget(args.target);
|
|
32
|
+
const sessionMode = normalizeSessionMode(args["session-mode"]);
|
|
33
|
+
const url = hasNonEmptyString(args.url) ? args.url.trim() : null;
|
|
34
|
+
const avdName = hasNonEmptyString(args.avd) ? args.avd.trim() : null;
|
|
35
|
+
const platform = hasNonEmptyString(args.platform) ? args.platform.trim() : null;
|
|
36
|
+
if (target === "web") {
|
|
37
|
+
if (!url) {
|
|
38
|
+
throw new Error("--target web requires --url.");
|
|
39
|
+
}
|
|
40
|
+
if (avdName || platform) {
|
|
41
|
+
throw new Error("--avd and --platform cannot be used with --target web.");
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
target,
|
|
45
|
+
url,
|
|
46
|
+
sessionMode,
|
|
47
|
+
avdName: null,
|
|
48
|
+
platform: null,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
if (hasNonEmptyString(args.url)) {
|
|
52
|
+
throw new Error("--url can only be used with --target web.");
|
|
53
|
+
}
|
|
54
|
+
if (args["session-mode"] != null) {
|
|
55
|
+
throw new Error("--session-mode can only be used with --target web.");
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
target,
|
|
59
|
+
url: null,
|
|
60
|
+
sessionMode: null,
|
|
61
|
+
avdName,
|
|
62
|
+
platform,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -10,15 +10,19 @@
|
|
|
10
10
|
*/
|
|
11
11
|
export async function handleHelp(args, session, context) {
|
|
12
12
|
const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
|
|
13
|
-
addOutput({ type: 'system', text: 'droid-cua - AI-powered mobile
|
|
13
|
+
addOutput({ type: 'system', text: 'droid-cua - AI-powered mobile and web testing CLI' });
|
|
14
14
|
addOutput({ type: 'info', text: '' });
|
|
15
15
|
addOutput({ type: 'info', text: 'Usage:' });
|
|
16
16
|
addOutput({ type: 'info', text: ' droid-cua --avd <device-name> [options]' });
|
|
17
|
+
addOutput({ type: 'info', text: ' droid-cua --target web --url <starting-url> --instructions <file> [options]' });
|
|
17
18
|
addOutput({ type: 'info', text: '' });
|
|
18
19
|
addOutput({ type: 'info', text: 'Options:' });
|
|
20
|
+
addOutput({ type: 'info', text: ' --target <kind> Target kind: mobile (default) or web' });
|
|
19
21
|
addOutput({ type: 'info', text: ' --avd <name> Device name (Android device ID/serial or iOS Simulator)' });
|
|
20
22
|
addOutput({ type: 'info', text: ' --platform <platform> Force platform: android or ios' });
|
|
21
23
|
addOutput({ type: 'info', text: ' --instructions <file> Run test file in headless mode' });
|
|
24
|
+
addOutput({ type: 'info', text: ' --url <value> Required starting URL for --target web headless runs' });
|
|
25
|
+
addOutput({ type: 'info', text: ' --session-mode <mode> Browser session mode for web runs: persistent or fresh' });
|
|
22
26
|
addOutput({ type: 'info', text: ' --config <file> Headless execution JSON config for prompt parity' });
|
|
23
27
|
addOutput({ type: 'info', text: ' --cua-model <model> Headless CUA model override: gpt-5.4, gpt-5.4-mini, or computer-use-preview' });
|
|
24
28
|
addOutput({ type: 'info', text: ' --context <file> Optional app context file used to brief execution runs' });
|
|
@@ -52,6 +56,7 @@ export async function handleHelp(args, session, context) {
|
|
|
52
56
|
addOutput({ type: 'info', text: 'Platform Support:' });
|
|
53
57
|
addOutput({ type: 'info', text: ' Android: Uses ADB to communicate with Android devices (physical or emulator)' });
|
|
54
58
|
addOutput({ type: 'info', text: ' iOS: Uses Appium + XCUITest for iOS Simulator automation' });
|
|
59
|
+
addOutput({ type: 'info', text: ' Web: Uses Playwright with installed Google Chrome or Microsoft Edge for browser execution' });
|
|
55
60
|
addOutput({ type: 'info', text: '' });
|
|
56
61
|
addOutput({ type: 'info', text: 'Platform Detection:' });
|
|
57
62
|
addOutput({ type: 'info', text: ' - Use --platform flag to force a specific platform' });
|
|
@@ -71,6 +76,7 @@ export async function handleHelp(args, session, context) {
|
|
|
71
76
|
addOutput({ type: 'info', text: ' droid-cua --avd "iPhone 16" (iOS Simulator, auto-detected)' });
|
|
72
77
|
addOutput({ type: 'info', text: ' droid-cua --platform ios --avd MySim (Force iOS platform)' });
|
|
73
78
|
addOutput({ type: 'info', text: ' droid-cua --instructions tests/login.dcua --context app/context.md' });
|
|
79
|
+
addOutput({ type: 'info', text: ' droid-cua --target web --url docs.example.com --instructions tests/search.dcua' });
|
|
74
80
|
addOutput({ type: 'info', text: ' /create login-test (design a new test)' });
|
|
75
81
|
addOutput({ type: 'info', text: ' /list (see all tests)' });
|
|
76
82
|
addOutput({ type: 'info', text: ' /view login-test (view test contents)' });
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Single source of truth for screenshot/click-annotation provenance. Producers
|
|
2
|
+
// stamp `captureSource` on every event; consumers (renderer log filter, main
|
|
3
|
+
// result-artifact router, debug recorder) read it through classifyCaptureSource.
|
|
4
|
+
//
|
|
5
|
+
// classifyCaptureSource returns three orthogonal axes — each consumer reads only
|
|
6
|
+
// what it needs:
|
|
7
|
+
// - mediaKind: how the renderer should display the event ('hidden-debug'
|
|
8
|
+
// hides it from the timeline, 'screenshot' / 'click-annotation' put it in
|
|
9
|
+
// the gallery, null leaves it as plain text).
|
|
10
|
+
// - persistsToResults: whether the main process copies the artifact into the
|
|
11
|
+
// run's user-visible result directory (vs. debug-only).
|
|
12
|
+
// - visibility: whether the event represents user-facing action ('user'),
|
|
13
|
+
// internal preview/debug noise ('debug'), or has no associated media
|
|
14
|
+
// ('none'). Distinct from mediaKind because INSTRUCTION_INPUT and
|
|
15
|
+
// DESIGN_INPUT are real user inputs but don't appear in the gallery.
|
|
16
|
+
export const CAPTURE_SOURCES = Object.freeze({
|
|
17
|
+
PREVIEW_CLICK: "preview-click",
|
|
18
|
+
ACTION_CLICK_ANNOTATION: "action-click-annotation",
|
|
19
|
+
CALL_OUTPUT_POST_ACTION: "call-output-post-action",
|
|
20
|
+
CALL_OUTPUT_EXPLICIT_ACTION: "call-output-explicit-action",
|
|
21
|
+
INSTRUCTION_INPUT: "instruction-input",
|
|
22
|
+
INSTRUCTION_INPUT_ASSERTION: "instruction-input-assertion",
|
|
23
|
+
DESIGN_INPUT: "design-input",
|
|
24
|
+
});
|
|
25
|
+
const HIDDEN_DEBUG_MEDIA_KINDS = new Set([
|
|
26
|
+
CAPTURE_SOURCES.PREVIEW_CLICK,
|
|
27
|
+
]);
|
|
28
|
+
const CLICK_ANNOTATION_MEDIA_KINDS = new Set([
|
|
29
|
+
CAPTURE_SOURCES.ACTION_CLICK_ANNOTATION,
|
|
30
|
+
]);
|
|
31
|
+
const SCREENSHOT_MEDIA_KINDS = new Set([
|
|
32
|
+
CAPTURE_SOURCES.CALL_OUTPUT_POST_ACTION,
|
|
33
|
+
CAPTURE_SOURCES.CALL_OUTPUT_EXPLICIT_ACTION,
|
|
34
|
+
CAPTURE_SOURCES.INSTRUCTION_INPUT_ASSERTION,
|
|
35
|
+
]);
|
|
36
|
+
const USER_VISIBLE_RESULT_CAPTURE_SOURCES = new Set([
|
|
37
|
+
CAPTURE_SOURCES.CALL_OUTPUT_POST_ACTION,
|
|
38
|
+
CAPTURE_SOURCES.CALL_OUTPUT_EXPLICIT_ACTION,
|
|
39
|
+
CAPTURE_SOURCES.INSTRUCTION_INPUT_ASSERTION,
|
|
40
|
+
CAPTURE_SOURCES.ACTION_CLICK_ANNOTATION,
|
|
41
|
+
]);
|
|
42
|
+
export function classifyCaptureSource(source) {
|
|
43
|
+
if (typeof source !== "string" || !source.trim()) {
|
|
44
|
+
return {
|
|
45
|
+
mediaKind: null,
|
|
46
|
+
persistsToResults: false,
|
|
47
|
+
visibility: "none",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
if (HIDDEN_DEBUG_MEDIA_KINDS.has(source)) {
|
|
51
|
+
return {
|
|
52
|
+
mediaKind: "hidden-debug",
|
|
53
|
+
persistsToResults: false,
|
|
54
|
+
visibility: "debug",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (CLICK_ANNOTATION_MEDIA_KINDS.has(source)) {
|
|
58
|
+
return {
|
|
59
|
+
mediaKind: "click-annotation",
|
|
60
|
+
persistsToResults: true,
|
|
61
|
+
visibility: "user",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (SCREENSHOT_MEDIA_KINDS.has(source)) {
|
|
65
|
+
return {
|
|
66
|
+
mediaKind: "screenshot",
|
|
67
|
+
persistsToResults: true,
|
|
68
|
+
visibility: "user",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
mediaKind: null,
|
|
73
|
+
persistsToResults: USER_VISIBLE_RESULT_CAPTURE_SOURCES.has(source),
|
|
74
|
+
visibility: source === CAPTURE_SOURCES.INSTRUCTION_INPUT || source === CAPTURE_SOURCES.DESIGN_INPUT ? "user" : "none",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -8,6 +8,7 @@ import { emitDesktopDebug } from "../utils/desktop-debug.js";
|
|
|
8
8
|
import { getConfiguredStepDelayMs } from "../utils/step-delay.js";
|
|
9
9
|
import { printCliOutput } from "../utils/console-output.js";
|
|
10
10
|
import { annotateScreenshotArtifact, createAnnotatedScreenshotFromBase64 } from "../utils/run-screenshot-recorder.js";
|
|
11
|
+
import { CAPTURE_SOURCES } from "./capture-sources.js";
|
|
11
12
|
const VALID_CUA_MODELS = new Set(["gpt-5.4", "gpt-5.4-mini", "computer-use-preview"]);
|
|
12
13
|
function extractActionableOutputItems(items) {
|
|
13
14
|
const entries = [];
|
|
@@ -319,23 +320,51 @@ export class ExecutionEngine {
|
|
|
319
320
|
}));
|
|
320
321
|
}
|
|
321
322
|
const previewEventId = randomUUID();
|
|
322
|
-
await this.recordPreviewScreenshot(previewResult.screenshotBase64, {
|
|
323
|
+
const previewScreenshotResult = await this.recordPreviewScreenshot(previewResult.screenshotBase64, {
|
|
323
324
|
runId: context?.runId,
|
|
324
325
|
sessionId: context?.sessionId,
|
|
325
326
|
stepId: stepContext?.stepId,
|
|
326
327
|
instructionIndex: stepContext?.instructionIndex,
|
|
327
328
|
callId,
|
|
328
329
|
eventId: previewEventId,
|
|
329
|
-
captureSource:
|
|
330
|
+
captureSource: CAPTURE_SOURCES.PREVIEW_CLICK,
|
|
330
331
|
});
|
|
332
|
+
const previewText = buildPreviewOutputText({
|
|
333
|
+
requestedX: previewResult.marker.requestedX,
|
|
334
|
+
requestedY: previewResult.marker.requestedY,
|
|
335
|
+
renderedX: previewResult.marker.x,
|
|
336
|
+
renderedY: previewResult.marker.y,
|
|
337
|
+
wasClamped: previewResult.marker.wasClamped,
|
|
338
|
+
});
|
|
339
|
+
const addOutput = context?.addOutput || null;
|
|
340
|
+
if (addOutput) {
|
|
341
|
+
const previewScreenshotPath = screenshotPathFromRecorderResult(previewScreenshotResult);
|
|
342
|
+
const previewScreenshotDataUrl = context?.includeScreenshotDataUrl
|
|
343
|
+
? `data:image/png;base64,${previewResult.screenshotBase64}`
|
|
344
|
+
: null;
|
|
345
|
+
addOutput({
|
|
346
|
+
type: "action",
|
|
347
|
+
text: "Click",
|
|
348
|
+
eventType: "tool_call",
|
|
349
|
+
runId: context?.runId,
|
|
350
|
+
stepId: stepContext?.stepId,
|
|
351
|
+
instructionIndex: stepContext?.instructionIndex,
|
|
352
|
+
payload: {
|
|
353
|
+
platform: this.session.deviceInfo?.platform,
|
|
354
|
+
toolName: "preview_click",
|
|
355
|
+
captureSource: CAPTURE_SOURCES.PREVIEW_CLICK,
|
|
356
|
+
requestedX: previewResult.marker.requestedX,
|
|
357
|
+
requestedY: previewResult.marker.requestedY,
|
|
358
|
+
renderedX: previewResult.marker.x,
|
|
359
|
+
renderedY: previewResult.marker.y,
|
|
360
|
+
wasClamped: previewResult.marker.wasClamped,
|
|
361
|
+
...(previewScreenshotPath ? { screenshotPath: previewScreenshotPath } : {}),
|
|
362
|
+
...(previewScreenshotDataUrl ? { screenshotDataUrl: previewScreenshotDataUrl } : {})
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
}
|
|
331
366
|
return buildFunctionCallOutputItem(callId, buildPreviewOutputContent({
|
|
332
|
-
text:
|
|
333
|
-
requestedX: previewResult.marker.requestedX,
|
|
334
|
-
requestedY: previewResult.marker.requestedY,
|
|
335
|
-
renderedX: previewResult.marker.x,
|
|
336
|
-
renderedY: previewResult.marker.y,
|
|
337
|
-
wasClamped: previewResult.marker.wasClamped,
|
|
338
|
-
}),
|
|
367
|
+
text: previewText,
|
|
339
368
|
annotatedScreenshotBase64: previewResult.screenshotBase64,
|
|
340
369
|
}));
|
|
341
370
|
}
|
|
@@ -465,7 +494,7 @@ export class ExecutionEngine {
|
|
|
465
494
|
stepId: stepContext?.stepId,
|
|
466
495
|
instructionIndex: stepContext?.instructionIndex,
|
|
467
496
|
eventId: actionEventId,
|
|
468
|
-
captureSource:
|
|
497
|
+
captureSource: CAPTURE_SOURCES.ACTION_CLICK_ANNOTATION
|
|
469
498
|
});
|
|
470
499
|
const annotatedScreenshotPath = screenshotPathFromRecorderResult(annotatedScreenshotResult);
|
|
471
500
|
const annotatedScreenshotDataUrl = screenshotDataUrlFromRecorderResult(annotatedScreenshotResult);
|
|
@@ -475,6 +504,7 @@ export class ExecutionEngine {
|
|
|
475
504
|
stepId: stepContext?.stepId,
|
|
476
505
|
instructionIndex: stepContext?.instructionIndex,
|
|
477
506
|
eventId: actionEventId,
|
|
507
|
+
captureSource: CAPTURE_SOURCES.ACTION_CLICK_ANNOTATION,
|
|
478
508
|
...(annotatedScreenshotPath ? { screenshotPath: annotatedScreenshotPath } : {}),
|
|
479
509
|
...(context?.includeScreenshotDataUrl && annotatedScreenshotDataUrl ? { screenshotDataUrl: annotatedScreenshotDataUrl } : {})
|
|
480
510
|
});
|
|
@@ -505,7 +535,7 @@ export class ExecutionEngine {
|
|
|
505
535
|
base64Length: screenshotBase64.length
|
|
506
536
|
});
|
|
507
537
|
const screenshotEventId = randomUUID();
|
|
508
|
-
const captureSource = sawExplicitScreenshotAction ?
|
|
538
|
+
const captureSource = sawExplicitScreenshotAction ? CAPTURE_SOURCES.CALL_OUTPUT_EXPLICIT_ACTION : CAPTURE_SOURCES.CALL_OUTPUT_POST_ACTION;
|
|
509
539
|
const screenshotResult = await this.recordScreenshot(screenshotBase64, {
|
|
510
540
|
runId: context?.runId,
|
|
511
541
|
sessionId: context?.sessionId,
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { buildBaseSystemPrompt } from "../prompts/base.js";
|
|
2
2
|
export { buildDesignModePrompt, buildDesignRecoveryPrompt } from "../prompts/design.js";
|
|
3
|
-
export { buildAppContextSection, buildAssertionSystemPrompt, buildExecutionModePrompt, buildExecutionRecoveryPrompt, } from "../prompts/execution.js";
|
|
3
|
+
export { buildAppContextSection, buildAssertionSystemPrompt, buildExecutionModePrompt, buildExecutionNavigationRecoveryPrompt, buildExecutionRecoveryPrompt, } from "../prompts/execution.js";
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Session manages the state for a single CLI session
|
|
3
|
-
* Includes
|
|
3
|
+
* Includes target info, message history, transcript, and response chaining
|
|
4
4
|
*/
|
|
5
5
|
export class Session {
|
|
6
|
-
constructor(
|
|
7
|
-
this.deviceId =
|
|
8
|
-
this.deviceInfo =
|
|
6
|
+
constructor(targetSessionId, targetInfo) {
|
|
7
|
+
this.deviceId = targetSessionId;
|
|
8
|
+
this.deviceInfo = targetInfo;
|
|
9
|
+
this.deviceName = targetInfo?.device_name || null;
|
|
10
|
+
this.browserSessionId = targetInfo?.platform === "browser" ? targetSessionId : null;
|
|
11
|
+
this.browserInfo = targetInfo?.platform === "browser" ? targetInfo : null;
|
|
12
|
+
this.browserName = targetInfo?.browserName || null;
|
|
13
|
+
this.currentUrl = targetInfo?.currentUrl || null;
|
|
14
|
+
this.sessionMode = targetInfo?.sessionMode || null;
|
|
15
|
+
this.targetKind = targetInfo?.platform === "browser" ? "browser" : "mobile";
|
|
9
16
|
this.messages = [];
|
|
10
17
|
this.previousResponseId = null;
|
|
11
18
|
this.transcript = [];
|
|
@@ -54,4 +61,11 @@ export class Session {
|
|
|
54
61
|
updateResponseId(responseId) {
|
|
55
62
|
this.previousResponseId = responseId;
|
|
56
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Replace the active system prompt for the next turn and start a fresh response chain.
|
|
66
|
+
*/
|
|
67
|
+
installRecoverySystemPrompt(prompt) {
|
|
68
|
+
this.messages = [{ role: "system", content: prompt }];
|
|
69
|
+
this.updateResponseId(null);
|
|
70
|
+
}
|
|
57
71
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function buildActionEventMeta({ context, action, platform, payload = {}, extraPayload = {} }) {
|
|
2
|
+
return {
|
|
3
|
+
eventType: "tool_call",
|
|
4
|
+
actionType: action?.type,
|
|
5
|
+
runId: context?.runId,
|
|
6
|
+
stepId: context?.stepId,
|
|
7
|
+
instructionIndex: context?.instructionIndex,
|
|
8
|
+
payload: {
|
|
9
|
+
eventId: context?.eventId,
|
|
10
|
+
screenshotPath: context?.screenshotPath,
|
|
11
|
+
screenshotDataUrl: context?.screenshotDataUrl,
|
|
12
|
+
captureSource: context?.captureSource,
|
|
13
|
+
platform,
|
|
14
|
+
...extraPayload,
|
|
15
|
+
...payload,
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { logger } from "../../utils/logger.js";
|
|
2
2
|
import { emitDesktopDebug, truncateForDebug } from "../../utils/desktop-debug.js";
|
|
3
3
|
import { getConfiguredStepDelayMs } from "../../utils/step-delay.js";
|
|
4
|
+
import { buildActionEventMeta } from "../action-event-meta.js";
|
|
4
5
|
import { resolveScrollGesture } from "../scroll-gesture.js";
|
|
5
6
|
import { execAdb } from "./tools.js";
|
|
6
7
|
function adbShell(deviceId, command) {
|
|
@@ -33,19 +34,11 @@ function normalizeMobileKeypress(keys = []) {
|
|
|
33
34
|
}
|
|
34
35
|
export async function handleModelAction(deviceId, action, scale = 1.0, context = null) {
|
|
35
36
|
const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
|
|
36
|
-
const meta = (payload = {}) => ({
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
instructionIndex: context?.instructionIndex,
|
|
42
|
-
payload: {
|
|
43
|
-
eventId: context?.eventId,
|
|
44
|
-
screenshotPath: context?.screenshotPath,
|
|
45
|
-
screenshotDataUrl: context?.screenshotDataUrl,
|
|
46
|
-
platform: 'android',
|
|
47
|
-
...payload
|
|
48
|
-
}
|
|
37
|
+
const meta = (payload = {}) => buildActionEventMeta({
|
|
38
|
+
context,
|
|
39
|
+
action,
|
|
40
|
+
platform: 'android',
|
|
41
|
+
payload
|
|
49
42
|
});
|
|
50
43
|
try {
|
|
51
44
|
const { x, y, x1, y1, x2, y2, text, keys, path } = action;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { logger } from "../../utils/logger.js";
|
|
2
2
|
import { emitDesktopDebug, truncateForDebug } from "../../utils/desktop-debug.js";
|
|
3
3
|
import { getConfiguredStepDelayMs } from "../../utils/step-delay.js";
|
|
4
|
+
import { buildActionEventMeta } from "../action-event-meta.js";
|
|
4
5
|
import { getActiveSession, getDevicePixelRatio } from "./connection.js";
|
|
5
6
|
import { resolveScrollGesture } from "../scroll-gesture.js";
|
|
6
7
|
function normalizeMobileKeypress(platform, keys = []) {
|
|
@@ -47,20 +48,14 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
|
|
|
47
48
|
const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
|
|
48
49
|
const session = getActiveSession();
|
|
49
50
|
const platform = session?.platform;
|
|
50
|
-
const meta = (payload = {}) => ({
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
screenshotPath: context?.screenshotPath,
|
|
59
|
-
screenshotDataUrl: context?.screenshotDataUrl,
|
|
60
|
-
platform,
|
|
61
|
-
source: session?.providerId,
|
|
62
|
-
...payload
|
|
63
|
-
}
|
|
51
|
+
const meta = (payload = {}) => buildActionEventMeta({
|
|
52
|
+
context,
|
|
53
|
+
action,
|
|
54
|
+
platform,
|
|
55
|
+
extraPayload: {
|
|
56
|
+
source: session?.providerId
|
|
57
|
+
},
|
|
58
|
+
payload
|
|
64
59
|
});
|
|
65
60
|
if (!session || !platform) {
|
|
66
61
|
throw new Error("No active cloud session");
|
|
@@ -103,6 +103,14 @@ export function getActiveSession() {
|
|
|
103
103
|
export function getDevicePixelRatio() {
|
|
104
104
|
return activeSession?.devicePixelRatio || 1;
|
|
105
105
|
}
|
|
106
|
+
// Test-only — do not call from production code. Used by unit tests to install
|
|
107
|
+
// a fake session without going through the real cloud-provider connect flow.
|
|
108
|
+
export function setActiveSessionForTests(session) {
|
|
109
|
+
activeSession = session;
|
|
110
|
+
}
|
|
111
|
+
export function clearActiveSessionForTests() {
|
|
112
|
+
activeSession = null;
|
|
113
|
+
}
|
|
106
114
|
export async function disconnect() {
|
|
107
115
|
if (!activeSession) {
|
|
108
116
|
return;
|