@loadmill/droid-cua 2.0.5 → 2.1.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/src/device/cloud/actions.js +185 -0
- package/build/src/device/cloud/adapter.js +1 -0
- package/build/src/device/cloud/browserstack/adapter.js +334 -0
- package/build/src/device/cloud/cloud-appium-client.js +147 -0
- package/build/src/device/cloud/connection.js +116 -0
- package/build/src/device/cloud/registry.js +8 -0
- package/build/src/device/connection.js +15 -4
- package/build/src/device/factory.js +18 -0
- package/package.json +1 -1
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { logger } from "../../utils/logger.js";
|
|
2
|
+
import { emitDesktopDebug, truncateForDebug } from "../../utils/desktop-debug.js";
|
|
3
|
+
import { getActiveSession, getDevicePixelRatio } from "./connection.js";
|
|
4
|
+
function normalizeMobileKeypress(platform, keys = []) {
|
|
5
|
+
if (!Array.isArray(keys) || keys.length === 0) {
|
|
6
|
+
throw new Error("Keypress action is missing keys");
|
|
7
|
+
}
|
|
8
|
+
if (keys.length > 1) {
|
|
9
|
+
throw new Error(`Unsupported mobile key chord: ${keys.join(", ")}. Use taps and text entry instead.`);
|
|
10
|
+
}
|
|
11
|
+
const key = String(keys[0]).trim().toUpperCase();
|
|
12
|
+
if (platform === "ios") {
|
|
13
|
+
if (key === "ESC" || key === "ESCAPE" || key === "HOME") {
|
|
14
|
+
return { kind: "ios-button", originalKey: keys[0], mapped: "home" };
|
|
15
|
+
}
|
|
16
|
+
if (key === "ENTER" || key === "RETURN") {
|
|
17
|
+
return { kind: "text", originalKey: keys[0], mapped: "\n", label: "Return key" };
|
|
18
|
+
}
|
|
19
|
+
if (key === "BACKSPACE" || key === "DELETE") {
|
|
20
|
+
return { kind: "text", originalKey: keys[0], mapped: "\b", label: "Delete key" };
|
|
21
|
+
}
|
|
22
|
+
if (key === "SPACE") {
|
|
23
|
+
return { kind: "text", originalKey: keys[0], mapped: " ", label: "Space key" };
|
|
24
|
+
}
|
|
25
|
+
throw new Error(`Unsupported mobile keypress: ${keys[0]}. Only single mobile-safe keys are allowed.`);
|
|
26
|
+
}
|
|
27
|
+
const androidKeyMap = {
|
|
28
|
+
ESC: 3,
|
|
29
|
+
ESCAPE: 3,
|
|
30
|
+
HOME: 3,
|
|
31
|
+
BACK: 4,
|
|
32
|
+
ENTER: 66,
|
|
33
|
+
RETURN: 66,
|
|
34
|
+
BACKSPACE: 67,
|
|
35
|
+
DELETE: 67,
|
|
36
|
+
SPACE: 62
|
|
37
|
+
};
|
|
38
|
+
const keycode = androidKeyMap[key];
|
|
39
|
+
if (!keycode) {
|
|
40
|
+
throw new Error(`Unsupported mobile keypress: ${keys[0]}. Only single mobile-safe keys are allowed.`);
|
|
41
|
+
}
|
|
42
|
+
return { kind: "android-keycode", originalKey: keys[0], keycode };
|
|
43
|
+
}
|
|
44
|
+
export async function handleModelAction(deviceId, action, scale = 1.0, context = null) {
|
|
45
|
+
const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
|
|
46
|
+
const session = getActiveSession();
|
|
47
|
+
const platform = session?.platform;
|
|
48
|
+
const meta = (payload = {}) => ({
|
|
49
|
+
eventType: "tool_call",
|
|
50
|
+
actionType: action?.type,
|
|
51
|
+
runId: context?.runId,
|
|
52
|
+
stepId: context?.stepId,
|
|
53
|
+
instructionIndex: context?.instructionIndex,
|
|
54
|
+
payload: {
|
|
55
|
+
platform,
|
|
56
|
+
source: session?.providerId,
|
|
57
|
+
...payload
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
if (!session || !platform) {
|
|
61
|
+
throw new Error("No active cloud session");
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
emitDesktopDebug("device.action.execute", "device", {
|
|
65
|
+
runId: context?.runId,
|
|
66
|
+
sessionId: context?.sessionId,
|
|
67
|
+
stepId: context?.stepId,
|
|
68
|
+
instructionIndex: context?.instructionIndex
|
|
69
|
+
}, {
|
|
70
|
+
platform,
|
|
71
|
+
source: session.providerId,
|
|
72
|
+
deviceId,
|
|
73
|
+
actionType: action?.type,
|
|
74
|
+
scale,
|
|
75
|
+
text: typeof action?.text === "string" ? truncateForDebug(action.text, 300) : undefined,
|
|
76
|
+
keyCount: Array.isArray(action?.keys) ? action.keys.length : 0,
|
|
77
|
+
pathPoints: Array.isArray(action?.path) ? action.path.length : 0
|
|
78
|
+
});
|
|
79
|
+
const dpr = getDevicePixelRatio();
|
|
80
|
+
switch (action.type) {
|
|
81
|
+
case "click":
|
|
82
|
+
case "double_click": {
|
|
83
|
+
const targetX = Math.round((action.x / scale) / dpr);
|
|
84
|
+
const targetY = Math.round((action.y / scale) / dpr);
|
|
85
|
+
addOutput({ type: "action", text: `Tapping at (${targetX}, ${targetY})`, ...meta({ x: targetX, y: targetY }) });
|
|
86
|
+
await session.client.tap(session.sessionId, targetX, targetY);
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
case "type": {
|
|
90
|
+
addOutput({ type: "action", text: `Typing text: ${action.text}`, ...meta({ text: action.text }) });
|
|
91
|
+
await session.client.type(session.sessionId, action.text);
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
case "scroll": {
|
|
95
|
+
const scrollX = Math.round((action.scroll_x / scale) / dpr);
|
|
96
|
+
const scrollY = Math.round((action.scroll_y / scale) / dpr);
|
|
97
|
+
const centerX = 200;
|
|
98
|
+
const centerY = 400;
|
|
99
|
+
const endX = centerX + scrollX;
|
|
100
|
+
const endY = centerY - scrollY;
|
|
101
|
+
addOutput({ type: "action", text: `Scrolling by (${scrollX}, ${scrollY})`, ...meta({ scrollX, scrollY }) });
|
|
102
|
+
await session.client.scroll(session.sessionId, centerX, centerY, endX, endY);
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
case "drag": {
|
|
106
|
+
const { path } = action;
|
|
107
|
+
if (!path || path.length < 2) {
|
|
108
|
+
addOutput({ type: "info", text: `Drag action missing valid path: ${JSON.stringify(action)}` });
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
const start = path[0];
|
|
112
|
+
const end = path[path.length - 1];
|
|
113
|
+
const startX = Math.round((start.x / scale) / dpr);
|
|
114
|
+
const startY = Math.round((start.y / scale) / dpr);
|
|
115
|
+
const endX = Math.round((end.x / scale) / dpr);
|
|
116
|
+
const endY = Math.round((end.y / scale) / dpr);
|
|
117
|
+
addOutput({
|
|
118
|
+
type: "action",
|
|
119
|
+
text: `Dragging from (${startX}, ${startY}) to (${endX}, ${endY})`,
|
|
120
|
+
...meta({ pathStart: { x: startX, y: startY }, pathEnd: { x: endX, y: endY } })
|
|
121
|
+
});
|
|
122
|
+
await session.client.drag(session.sessionId, startX, startY, endX, endY);
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
case "keypress": {
|
|
126
|
+
const normalized = normalizeMobileKeypress(platform, action.keys);
|
|
127
|
+
if (normalized.kind === "ios-button") {
|
|
128
|
+
addOutput({ type: "action", text: "Pressing Home button", ...meta({ keys: [normalized.originalKey], mapped: normalized.mapped }) });
|
|
129
|
+
await session.client.pressIosButton(session.sessionId, normalized.mapped);
|
|
130
|
+
}
|
|
131
|
+
else if (normalized.kind === "android-keycode") {
|
|
132
|
+
addOutput({ type: "action", text: `Pressing keycode ${normalized.keycode}`, ...meta({ keys: [normalized.originalKey], keycode: normalized.keycode }) });
|
|
133
|
+
await session.client.pressAndroidKeyCode(session.sessionId, normalized.keycode);
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
addOutput({ type: "action", text: `Pressing ${normalized.label}`, ...meta({ keys: [normalized.originalKey], mapped: normalized.label }) });
|
|
137
|
+
await session.client.type(session.sessionId, normalized.mapped);
|
|
138
|
+
}
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
case "wait":
|
|
142
|
+
addOutput({ type: "action", text: "Waiting...", ...meta({}) });
|
|
143
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
144
|
+
break;
|
|
145
|
+
default:
|
|
146
|
+
addOutput({ type: "info", text: `Unknown action: ${JSON.stringify(action)}` });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
logger.error("Cloud action execution error", {
|
|
151
|
+
action,
|
|
152
|
+
message: error.message,
|
|
153
|
+
stack: error.stack,
|
|
154
|
+
providerId: session.providerId,
|
|
155
|
+
platform
|
|
156
|
+
});
|
|
157
|
+
emitDesktopDebug("device.error", "device", {
|
|
158
|
+
runId: context?.runId,
|
|
159
|
+
sessionId: context?.sessionId,
|
|
160
|
+
stepId: context?.stepId,
|
|
161
|
+
instructionIndex: context?.instructionIndex
|
|
162
|
+
}, {
|
|
163
|
+
platform,
|
|
164
|
+
source: session.providerId,
|
|
165
|
+
operation: "action.execute",
|
|
166
|
+
actionType: action?.type,
|
|
167
|
+
message: error.message
|
|
168
|
+
});
|
|
169
|
+
addOutput({
|
|
170
|
+
type: "error",
|
|
171
|
+
text: `Error executing action: ${error.message}`,
|
|
172
|
+
eventType: "error",
|
|
173
|
+
actionType: action?.type,
|
|
174
|
+
runId: context?.runId,
|
|
175
|
+
stepId: context?.stepId,
|
|
176
|
+
instructionIndex: context?.instructionIndex,
|
|
177
|
+
payload: {
|
|
178
|
+
message: error.message,
|
|
179
|
+
platform,
|
|
180
|
+
source: session.providerId
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
addOutput({ type: "info", text: "Full error details have been logged to the debug log." });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
const HUB_URL = "https://hub-cloud.browserstack.com/wd/hub";
|
|
4
|
+
const PLAN_URL = "https://api-cloud.browserstack.com/app-automate/plan.json";
|
|
5
|
+
const DEVICES_URL = "https://api-cloud.browserstack.com/app-automate/devices.json";
|
|
6
|
+
const UPLOAD_URL = "https://api-cloud.browserstack.com/app-automate/upload";
|
|
7
|
+
const RECENT_APPS_URL = "https://api-cloud.browserstack.com/app-automate/recent_apps";
|
|
8
|
+
function notImplemented(methodName) {
|
|
9
|
+
throw new Error(`BrowserStack adapter stub: ${methodName} is not implemented yet.`);
|
|
10
|
+
}
|
|
11
|
+
function sanitizeCustomId(value) {
|
|
12
|
+
const sanitized = value.replace(/[^A-Za-z0-9._-]/g, "");
|
|
13
|
+
return sanitized.slice(0, 100) || "droid-cua-app";
|
|
14
|
+
}
|
|
15
|
+
function buildCustomId(localPath) {
|
|
16
|
+
const baseName = path.basename(localPath, path.extname(localPath));
|
|
17
|
+
return sanitizeCustomId(`droid-cua.${baseName}`);
|
|
18
|
+
}
|
|
19
|
+
function normalizePlanLabel(value) {
|
|
20
|
+
if (typeof value !== "string") {
|
|
21
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
22
|
+
return normalizePlanLabel(value.name) ?? normalizePlanLabel(value.label) ?? normalizePlanLabel(value.type);
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
const trimmed = value.trim();
|
|
27
|
+
return trimmed ? trimmed : undefined;
|
|
28
|
+
}
|
|
29
|
+
function readPlanLabel(payload) {
|
|
30
|
+
const candidates = [
|
|
31
|
+
payload.plan,
|
|
32
|
+
payload.plan_name,
|
|
33
|
+
payload.account_plan,
|
|
34
|
+
payload.automate_plan,
|
|
35
|
+
payload.automate_plan_name,
|
|
36
|
+
payload.billing_plan,
|
|
37
|
+
payload.plan_type,
|
|
38
|
+
payload.account_type
|
|
39
|
+
];
|
|
40
|
+
for (const candidate of candidates) {
|
|
41
|
+
const normalized = normalizePlanLabel(candidate);
|
|
42
|
+
if (normalized) {
|
|
43
|
+
return normalized;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
function normalizeParallelLimit(payload) {
|
|
49
|
+
const candidates = [
|
|
50
|
+
payload.parallel_sessions_max_allowed,
|
|
51
|
+
payload.parallel_sessions,
|
|
52
|
+
payload.parallelSessions,
|
|
53
|
+
payload.max_allowed_parallel_sessions
|
|
54
|
+
];
|
|
55
|
+
for (const candidate of candidates) {
|
|
56
|
+
if (typeof candidate === "number" && Number.isFinite(candidate)) {
|
|
57
|
+
return candidate;
|
|
58
|
+
}
|
|
59
|
+
if (typeof candidate === "string" && candidate.trim().length > 0) {
|
|
60
|
+
const parsed = Number(candidate);
|
|
61
|
+
if (Number.isFinite(parsed)) {
|
|
62
|
+
return parsed;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
async function requestPlan(creds) {
|
|
69
|
+
const response = await fetch(PLAN_URL, {
|
|
70
|
+
method: "GET",
|
|
71
|
+
headers: {
|
|
72
|
+
Authorization: browserStackAdapter.getAuthHeader(creds)
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
if (response.status === 401 || response.status === 403) {
|
|
76
|
+
throw new Error("BrowserStack rejected these credentials. Check the username and access key and try again.");
|
|
77
|
+
}
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
throw new Error(`BrowserStack validation failed with status ${response.status}.`);
|
|
80
|
+
}
|
|
81
|
+
const payload = await response.json();
|
|
82
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
|
83
|
+
throw new Error("BrowserStack returned an unexpected validation response.");
|
|
84
|
+
}
|
|
85
|
+
const plan = readPlanLabel(payload);
|
|
86
|
+
const parallelLimit = normalizeParallelLimit(payload);
|
|
87
|
+
const summary = typeof payload.message === "string" && payload.message.trim().length > 0 ? payload.message.trim() : undefined;
|
|
88
|
+
return {
|
|
89
|
+
plan,
|
|
90
|
+
parallelLimit,
|
|
91
|
+
summary
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function normalizePlatform(value) {
|
|
95
|
+
if (typeof value !== "string") {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const normalized = value.trim().toLowerCase();
|
|
99
|
+
if (normalized === "android") {
|
|
100
|
+
return "android";
|
|
101
|
+
}
|
|
102
|
+
if (normalized === "ios") {
|
|
103
|
+
return "ios";
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
function sortOsVersionsDescending(values) {
|
|
108
|
+
return [...values].sort((left, right) => right.localeCompare(left, undefined, { numeric: true, sensitivity: "base" }));
|
|
109
|
+
}
|
|
110
|
+
async function requestDevices(creds) {
|
|
111
|
+
const response = await fetch(DEVICES_URL, {
|
|
112
|
+
method: "GET",
|
|
113
|
+
headers: {
|
|
114
|
+
Authorization: browserStackAdapter.getAuthHeader(creds)
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
if (response.status === 401 || response.status === 403) {
|
|
118
|
+
throw new Error("BrowserStack rejected these credentials. Reconnect BrowserStack and try refreshing devices again.");
|
|
119
|
+
}
|
|
120
|
+
if (!response.ok) {
|
|
121
|
+
throw new Error(`BrowserStack device catalog failed with status ${response.status}.`);
|
|
122
|
+
}
|
|
123
|
+
const payload = await response.json();
|
|
124
|
+
if (!Array.isArray(payload)) {
|
|
125
|
+
throw new Error("BrowserStack returned an unexpected device catalog response.");
|
|
126
|
+
}
|
|
127
|
+
const deduped = new Map();
|
|
128
|
+
for (const item of payload) {
|
|
129
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const platform = normalizePlatform(item.os);
|
|
133
|
+
const deviceName = typeof item.device === "string" && item.device.trim().length > 0
|
|
134
|
+
? item.device.trim()
|
|
135
|
+
: typeof item.deviceName === "string" && item.deviceName.trim().length > 0
|
|
136
|
+
? item.deviceName.trim()
|
|
137
|
+
: null;
|
|
138
|
+
const osVersion = typeof item.os_version === "string" && item.os_version.trim().length > 0
|
|
139
|
+
? item.os_version.trim()
|
|
140
|
+
: typeof item.osVersion === "string" && item.osVersion.trim().length > 0
|
|
141
|
+
? item.osVersion.trim()
|
|
142
|
+
: null;
|
|
143
|
+
if (!platform || !deviceName || !osVersion) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const key = `${platform}::${deviceName}::${osVersion}`;
|
|
147
|
+
if (!deduped.has(key)) {
|
|
148
|
+
deduped.set(key, {
|
|
149
|
+
id: key,
|
|
150
|
+
name: deviceName,
|
|
151
|
+
deviceName,
|
|
152
|
+
platform,
|
|
153
|
+
osVersion
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return [...deduped.values()].sort((left, right) => {
|
|
158
|
+
if (left.platform !== right.platform) {
|
|
159
|
+
return left.platform.localeCompare(right.platform);
|
|
160
|
+
}
|
|
161
|
+
if (left.name !== right.name) {
|
|
162
|
+
return left.name.localeCompare(right.name);
|
|
163
|
+
}
|
|
164
|
+
return sortOsVersionsDescending([left.osVersion ?? "", right.osVersion ?? ""])[0] === (left.osVersion ?? "") ? -1 : 1;
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
function readAppReference(payload) {
|
|
168
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
|
169
|
+
throw new Error("BrowserStack returned an unexpected app upload response.");
|
|
170
|
+
}
|
|
171
|
+
const remotePath = typeof payload.app_url === "string" ? payload.app_url.trim() : "";
|
|
172
|
+
if (!remotePath.startsWith("bs://")) {
|
|
173
|
+
throw new Error("BrowserStack did not return a valid app reference.");
|
|
174
|
+
}
|
|
175
|
+
const id = typeof payload.custom_id === "string" && payload.custom_id.trim().length > 0
|
|
176
|
+
? payload.custom_id.trim()
|
|
177
|
+
: remotePath.slice("bs://".length);
|
|
178
|
+
return { id, remotePath };
|
|
179
|
+
}
|
|
180
|
+
function readAppStatusEntry(payload) {
|
|
181
|
+
if (!Array.isArray(payload)) {
|
|
182
|
+
throw new Error("BrowserStack returned an unexpected uploaded-apps response.");
|
|
183
|
+
}
|
|
184
|
+
const entries = [];
|
|
185
|
+
for (const item of payload) {
|
|
186
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const appUrl = typeof item.app_url === "string" ? item.app_url.trim() : "";
|
|
190
|
+
if (!appUrl.startsWith("bs://")) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const customId = typeof item.custom_id === "string" && item.custom_id.trim().length > 0 ? item.custom_id.trim() : undefined;
|
|
194
|
+
entries.push({ appUrl, customId });
|
|
195
|
+
}
|
|
196
|
+
return entries;
|
|
197
|
+
}
|
|
198
|
+
export const browserStackAdapter = {
|
|
199
|
+
id: "browserstack",
|
|
200
|
+
displayName: "BrowserStack",
|
|
201
|
+
async validateCredentials(creds) {
|
|
202
|
+
try {
|
|
203
|
+
const account = await requestPlan(creds);
|
|
204
|
+
return {
|
|
205
|
+
ok: true,
|
|
206
|
+
message: account.summary ?? "BrowserStack credentials validated successfully.",
|
|
207
|
+
account
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
const message = error instanceof Error ? error.message : "Failed to validate BrowserStack credentials.";
|
|
212
|
+
if (/ENOTFOUND|fetch failed|network|timed out|ECONN/i.test(message)) {
|
|
213
|
+
return {
|
|
214
|
+
ok: false,
|
|
215
|
+
message: "Could not reach BrowserStack. Check your network connection and try again."
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
ok: false,
|
|
220
|
+
message
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
async getAccountInfo(creds) {
|
|
225
|
+
return requestPlan(creds);
|
|
226
|
+
},
|
|
227
|
+
async getAvailableDevices(creds) {
|
|
228
|
+
return requestDevices(creds);
|
|
229
|
+
},
|
|
230
|
+
async uploadApp(creds, localPath) {
|
|
231
|
+
const fileContents = await readFile(localPath);
|
|
232
|
+
const form = new FormData();
|
|
233
|
+
form.append("file", new Blob([new Uint8Array(fileContents)]), path.basename(localPath));
|
|
234
|
+
form.append("custom_id", buildCustomId(localPath));
|
|
235
|
+
const response = await fetch(UPLOAD_URL, {
|
|
236
|
+
method: "POST",
|
|
237
|
+
headers: {
|
|
238
|
+
Authorization: browserStackAdapter.getAuthHeader(creds)
|
|
239
|
+
},
|
|
240
|
+
body: form
|
|
241
|
+
});
|
|
242
|
+
if (response.status === 401 || response.status === 403) {
|
|
243
|
+
throw new Error("BrowserStack rejected these credentials. Reconnect BrowserStack and try again.");
|
|
244
|
+
}
|
|
245
|
+
if (!response.ok) {
|
|
246
|
+
throw new Error(`BrowserStack app upload failed with status ${response.status}.`);
|
|
247
|
+
}
|
|
248
|
+
return readAppReference(await response.json());
|
|
249
|
+
},
|
|
250
|
+
async getAppStatus(creds, ref) {
|
|
251
|
+
const lookupId = sanitizeCustomId(ref.id);
|
|
252
|
+
const response = await fetch(`${RECENT_APPS_URL}/${encodeURIComponent(lookupId)}`, {
|
|
253
|
+
method: "GET",
|
|
254
|
+
headers: {
|
|
255
|
+
Authorization: browserStackAdapter.getAuthHeader(creds)
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
if (response.status === 401 || response.status === 403) {
|
|
259
|
+
throw new Error("BrowserStack rejected these credentials. Reconnect BrowserStack and try again.");
|
|
260
|
+
}
|
|
261
|
+
if (!response.ok) {
|
|
262
|
+
throw new Error(`BrowserStack uploaded-app lookup failed with status ${response.status}.`);
|
|
263
|
+
}
|
|
264
|
+
const apps = readAppStatusEntry(await response.json());
|
|
265
|
+
const matches = apps.some((entry) => entry.appUrl === ref.remotePath);
|
|
266
|
+
return {
|
|
267
|
+
status: matches ? "uploaded" : "missing",
|
|
268
|
+
message: matches
|
|
269
|
+
? "Uploaded app reference is still available on BrowserStack."
|
|
270
|
+
: "Uploaded app reference is missing or has expired on BrowserStack."
|
|
271
|
+
};
|
|
272
|
+
},
|
|
273
|
+
async deleteApp(_creds, _ref) {
|
|
274
|
+
return notImplemented("deleteApp");
|
|
275
|
+
},
|
|
276
|
+
buildCapabilities(opts) {
|
|
277
|
+
const bstackOptions = {
|
|
278
|
+
projectName: opts.projectName,
|
|
279
|
+
buildName: opts.buildName,
|
|
280
|
+
sessionName: opts.sessionName,
|
|
281
|
+
debug: true,
|
|
282
|
+
video: true
|
|
283
|
+
};
|
|
284
|
+
if (typeof opts.username === "string" && opts.username.trim().length > 0) {
|
|
285
|
+
bstackOptions.userName = opts.username.trim();
|
|
286
|
+
}
|
|
287
|
+
if (typeof opts.accessKey === "string" && opts.accessKey.trim().length > 0) {
|
|
288
|
+
bstackOptions.accessKey = opts.accessKey.trim();
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
platformName: opts.platform,
|
|
292
|
+
"appium:deviceName": opts.deviceName,
|
|
293
|
+
"appium:platformVersion": opts.osVersion,
|
|
294
|
+
"appium:app": opts.app,
|
|
295
|
+
"appium:automationName": opts.platform === "ios" ? "XCUITest" : "UiAutomator2",
|
|
296
|
+
"bstack:options": {
|
|
297
|
+
...bstackOptions
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
},
|
|
301
|
+
getHubUrl() {
|
|
302
|
+
return HUB_URL;
|
|
303
|
+
},
|
|
304
|
+
getAuthHeader(creds) {
|
|
305
|
+
const username = typeof creds.username === "string" ? creds.username : "";
|
|
306
|
+
const accessKey = typeof creds.accessKey === "string" ? creds.accessKey : "";
|
|
307
|
+
return `Basic ${Buffer.from(`${username}:${accessKey}`).toString("base64")}`;
|
|
308
|
+
},
|
|
309
|
+
async getSessionArtifacts(creds, sessionId) {
|
|
310
|
+
const response = await fetch(`https://api-cloud.browserstack.com/app-automate/sessions/${encodeURIComponent(sessionId)}.json`, {
|
|
311
|
+
method: "GET",
|
|
312
|
+
headers: {
|
|
313
|
+
Authorization: browserStackAdapter.getAuthHeader(creds)
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
if (response.status === 401 || response.status === 403) {
|
|
317
|
+
throw new Error("BrowserStack rejected these credentials. Reconnect BrowserStack and try again.");
|
|
318
|
+
}
|
|
319
|
+
if (!response.ok) {
|
|
320
|
+
throw new Error(`BrowserStack session lookup failed with status ${response.status}.`);
|
|
321
|
+
}
|
|
322
|
+
const payload = await response.json();
|
|
323
|
+
const session = payload?.automation_session;
|
|
324
|
+
const logsUrl = typeof session?.logs === "string" && session.logs.trim().length > 0 ? session.logs.trim() : undefined;
|
|
325
|
+
const dashboardUrl = logsUrl ? logsUrl.replace(/\/logs\/?$/, "") : undefined;
|
|
326
|
+
return {
|
|
327
|
+
dashboardUrl,
|
|
328
|
+
logsUrl
|
|
329
|
+
};
|
|
330
|
+
},
|
|
331
|
+
async setSessionStatus(_creds, _sessionId, _status) {
|
|
332
|
+
return notImplemented("setSessionStatus");
|
|
333
|
+
}
|
|
334
|
+
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Appium WebDriver client for cloud providers.
|
|
3
|
+
*/
|
|
4
|
+
export class CloudAppiumClient {
|
|
5
|
+
constructor(platform, provider, creds) {
|
|
6
|
+
this.platform = platform;
|
|
7
|
+
this.provider = provider;
|
|
8
|
+
this.creds = creds;
|
|
9
|
+
}
|
|
10
|
+
async request(method, path, body = null) {
|
|
11
|
+
const response = await fetch(`${this.provider.getHubUrl()}${path}`, {
|
|
12
|
+
method,
|
|
13
|
+
headers: {
|
|
14
|
+
Authorization: this.provider.getAuthHeader(this.creds),
|
|
15
|
+
...(body ? { "Content-Type": "application/json" } : {})
|
|
16
|
+
},
|
|
17
|
+
body: body ? JSON.stringify(body) : undefined
|
|
18
|
+
});
|
|
19
|
+
const text = await response.text();
|
|
20
|
+
const payload = text ? JSON.parse(text) : {};
|
|
21
|
+
if (!response.ok) {
|
|
22
|
+
const message = payload?.value?.message ||
|
|
23
|
+
payload?.message ||
|
|
24
|
+
`Cloud Appium request failed with status ${response.status}.`;
|
|
25
|
+
throw new Error(message);
|
|
26
|
+
}
|
|
27
|
+
return payload;
|
|
28
|
+
}
|
|
29
|
+
async createSession(options) {
|
|
30
|
+
const capabilities = {
|
|
31
|
+
...this.provider.buildCapabilities({
|
|
32
|
+
...options,
|
|
33
|
+
platform: this.platform
|
|
34
|
+
})
|
|
35
|
+
};
|
|
36
|
+
const response = await this.request("POST", "/session", {
|
|
37
|
+
capabilities: {
|
|
38
|
+
alwaysMatch: capabilities
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
const sessionId = response.value?.sessionId ?? response.sessionId;
|
|
42
|
+
if (!sessionId) {
|
|
43
|
+
throw new Error("Cloud Appium session was created without a session ID.");
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
sessionId,
|
|
47
|
+
capabilities
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
async deleteSession(sessionId) {
|
|
51
|
+
await this.request("DELETE", `/session/${sessionId}`);
|
|
52
|
+
}
|
|
53
|
+
async getSessionStatus(sessionId) {
|
|
54
|
+
const response = await this.request("GET", `/session/${sessionId}`);
|
|
55
|
+
return response.value;
|
|
56
|
+
}
|
|
57
|
+
async getScreenshot(sessionId) {
|
|
58
|
+
const response = await this.request("GET", `/session/${sessionId}/screenshot`);
|
|
59
|
+
return response.value;
|
|
60
|
+
}
|
|
61
|
+
async getWindowRect(sessionId) {
|
|
62
|
+
const response = await this.request("GET", `/session/${sessionId}/window/rect`);
|
|
63
|
+
return {
|
|
64
|
+
width: Number(response.value?.width),
|
|
65
|
+
height: Number(response.value?.height)
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
async tap(sessionId, x, y) {
|
|
69
|
+
await this.request("POST", `/session/${sessionId}/actions`, {
|
|
70
|
+
actions: [
|
|
71
|
+
{
|
|
72
|
+
type: "pointer",
|
|
73
|
+
id: "finger1",
|
|
74
|
+
parameters: { pointerType: "touch" },
|
|
75
|
+
actions: [
|
|
76
|
+
{ type: "pointerMove", duration: 0, x: Math.round(x), y: Math.round(y) },
|
|
77
|
+
{ type: "pointerDown", button: 0 },
|
|
78
|
+
{ type: "pause", duration: 100 },
|
|
79
|
+
{ type: "pointerUp", button: 0 }
|
|
80
|
+
]
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
async type(sessionId, text) {
|
|
86
|
+
const keyActions = [];
|
|
87
|
+
for (const char of text) {
|
|
88
|
+
keyActions.push({ type: "keyDown", value: char });
|
|
89
|
+
keyActions.push({ type: "keyUp", value: char });
|
|
90
|
+
}
|
|
91
|
+
await this.request("POST", `/session/${sessionId}/actions`, {
|
|
92
|
+
actions: [
|
|
93
|
+
{
|
|
94
|
+
type: "key",
|
|
95
|
+
id: "keyboard",
|
|
96
|
+
actions: keyActions
|
|
97
|
+
}
|
|
98
|
+
]
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
async scroll(sessionId, startX, startY, endX, endY, duration = 500) {
|
|
102
|
+
await this.request("POST", `/session/${sessionId}/actions`, {
|
|
103
|
+
actions: [
|
|
104
|
+
{
|
|
105
|
+
type: "pointer",
|
|
106
|
+
id: "finger1",
|
|
107
|
+
parameters: { pointerType: "touch" },
|
|
108
|
+
actions: [
|
|
109
|
+
{ type: "pointerMove", duration: 0, x: Math.round(startX), y: Math.round(startY) },
|
|
110
|
+
{ type: "pointerDown", button: 0 },
|
|
111
|
+
{ type: "pointerMove", duration, x: Math.round(endX), y: Math.round(endY) },
|
|
112
|
+
{ type: "pointerUp", button: 0 }
|
|
113
|
+
]
|
|
114
|
+
}
|
|
115
|
+
]
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
async drag(sessionId, startX, startY, endX, endY, duration = 500) {
|
|
119
|
+
await this.request("POST", `/session/${sessionId}/actions`, {
|
|
120
|
+
actions: [
|
|
121
|
+
{
|
|
122
|
+
type: "pointer",
|
|
123
|
+
id: "finger1",
|
|
124
|
+
parameters: { pointerType: "touch" },
|
|
125
|
+
actions: [
|
|
126
|
+
{ type: "pointerMove", duration: 0, x: Math.round(startX), y: Math.round(startY) },
|
|
127
|
+
{ type: "pointerDown", button: 0 },
|
|
128
|
+
{ type: "pause", duration: 200 },
|
|
129
|
+
{ type: "pointerMove", duration, x: Math.round(endX), y: Math.round(endY) },
|
|
130
|
+
{ type: "pointerUp", button: 0 }
|
|
131
|
+
]
|
|
132
|
+
}
|
|
133
|
+
]
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
async pressIosButton(sessionId, buttonName) {
|
|
137
|
+
await this.request("POST", `/session/${sessionId}/execute/sync`, {
|
|
138
|
+
script: "mobile: pressButton",
|
|
139
|
+
args: [{ name: buttonName }]
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
async pressAndroidKeyCode(sessionId, keycode) {
|
|
143
|
+
await this.request("POST", `/session/${sessionId}/appium/device/press_keycode`, {
|
|
144
|
+
keycode
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import sharp from "sharp";
|
|
2
|
+
import { getCloudProviderAdapter } from "./registry.js";
|
|
3
|
+
import { CloudAppiumClient } from "./cloud-appium-client.js";
|
|
4
|
+
let activeSession = null;
|
|
5
|
+
function buildCloudCapabilities(options) {
|
|
6
|
+
const { providerId, platform, deviceName, osVersion, appRef, credentials } = options;
|
|
7
|
+
const adapter = getCloudProviderAdapter(providerId);
|
|
8
|
+
if (!adapter) {
|
|
9
|
+
throw new Error(`Cloud provider "${providerId}" is not registered.`);
|
|
10
|
+
}
|
|
11
|
+
return {
|
|
12
|
+
adapter,
|
|
13
|
+
capabilities: {
|
|
14
|
+
platform,
|
|
15
|
+
deviceName,
|
|
16
|
+
osVersion,
|
|
17
|
+
app: appRef,
|
|
18
|
+
username: credentials?.username,
|
|
19
|
+
accessKey: credentials?.accessKey,
|
|
20
|
+
projectName: "droid-cua",
|
|
21
|
+
buildName: `desktop-${new Date().toISOString().slice(0, 10)}`,
|
|
22
|
+
sessionName: `${deviceName} (${platform})`
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export async function connectToDevice(options) {
|
|
27
|
+
if (!options || typeof options !== "object") {
|
|
28
|
+
throw new Error("Cloud device connection requires a provider payload.");
|
|
29
|
+
}
|
|
30
|
+
const { providerId, platform, deviceName, osVersion, appRef, credentials } = options;
|
|
31
|
+
if (!providerId || !platform || !deviceName || !osVersion || !appRef) {
|
|
32
|
+
throw new Error("Cloud device connection is missing required session details.");
|
|
33
|
+
}
|
|
34
|
+
if (!credentials?.username || !credentials?.accessKey) {
|
|
35
|
+
throw new Error("Cloud device connection is missing provider credentials.");
|
|
36
|
+
}
|
|
37
|
+
const { adapter, capabilities } = buildCloudCapabilities({
|
|
38
|
+
providerId,
|
|
39
|
+
platform,
|
|
40
|
+
deviceName,
|
|
41
|
+
osVersion,
|
|
42
|
+
appRef,
|
|
43
|
+
credentials
|
|
44
|
+
});
|
|
45
|
+
const client = new CloudAppiumClient(platform, adapter, credentials);
|
|
46
|
+
console.log(`Creating ${adapter.displayName} session on ${deviceName} (${osVersion})...`);
|
|
47
|
+
const session = await client.createSession(capabilities);
|
|
48
|
+
activeSession = {
|
|
49
|
+
client,
|
|
50
|
+
providerId,
|
|
51
|
+
platform,
|
|
52
|
+
deviceName,
|
|
53
|
+
osVersion,
|
|
54
|
+
sessionId: session.sessionId
|
|
55
|
+
};
|
|
56
|
+
console.log(`Connected to ${adapter.displayName} device "${deviceName}" (${session.sessionId})`);
|
|
57
|
+
return session.sessionId;
|
|
58
|
+
}
|
|
59
|
+
export async function getDeviceInfo(sessionId) {
|
|
60
|
+
if (!activeSession || activeSession.sessionId !== sessionId) {
|
|
61
|
+
throw new Error("No active cloud session. Call connectToDevice first.");
|
|
62
|
+
}
|
|
63
|
+
const windowRect = await activeSession.client.getWindowRect(sessionId);
|
|
64
|
+
const screenshot = await activeSession.client.getScreenshot(sessionId);
|
|
65
|
+
const screenshotBuffer = Buffer.from(screenshot, "base64");
|
|
66
|
+
const metadata = await sharp(screenshotBuffer).metadata();
|
|
67
|
+
const pixelWidth = metadata.width;
|
|
68
|
+
const pixelHeight = metadata.height;
|
|
69
|
+
const devicePixelRatio = windowRect.width > 0 && pixelWidth ? Math.max(1, Math.round(pixelWidth / windowRect.width)) : 1;
|
|
70
|
+
activeSession.devicePixelRatio = devicePixelRatio;
|
|
71
|
+
const targetWidth = 400;
|
|
72
|
+
const scale = pixelWidth > targetWidth ? targetWidth / pixelWidth : 1.0;
|
|
73
|
+
const scaledWidth = Math.round(pixelWidth * scale);
|
|
74
|
+
const scaledHeight = Math.round(pixelHeight * scale);
|
|
75
|
+
return {
|
|
76
|
+
platform: activeSession.platform,
|
|
77
|
+
device_name: activeSession.deviceName,
|
|
78
|
+
model: activeSession.deviceName,
|
|
79
|
+
device_width: pixelWidth,
|
|
80
|
+
device_height: pixelHeight,
|
|
81
|
+
scaled_width: scaledWidth,
|
|
82
|
+
scaled_height: scaledHeight,
|
|
83
|
+
scale
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
export async function getScreenshotAsBase64(sessionId, deviceInfo) {
|
|
87
|
+
if (!activeSession || activeSession.sessionId !== sessionId) {
|
|
88
|
+
throw new Error("No active cloud session. Call connectToDevice first.");
|
|
89
|
+
}
|
|
90
|
+
const rawBase64 = await activeSession.client.getScreenshot(sessionId);
|
|
91
|
+
let buffer = Buffer.from(rawBase64, "base64");
|
|
92
|
+
if (deviceInfo.scale < 1.0) {
|
|
93
|
+
buffer = await sharp(buffer)
|
|
94
|
+
.resize({ width: deviceInfo.scaled_width, height: deviceInfo.scaled_height })
|
|
95
|
+
.png()
|
|
96
|
+
.toBuffer();
|
|
97
|
+
}
|
|
98
|
+
return buffer.toString("base64");
|
|
99
|
+
}
|
|
100
|
+
export function getActiveSession() {
|
|
101
|
+
return activeSession;
|
|
102
|
+
}
|
|
103
|
+
export function getDevicePixelRatio() {
|
|
104
|
+
return activeSession?.devicePixelRatio || 1;
|
|
105
|
+
}
|
|
106
|
+
export async function disconnect() {
|
|
107
|
+
if (!activeSession) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
await activeSession.client.deleteSession(activeSession.sessionId);
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
activeSession = null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { browserStackAdapter } from "./browserstack/adapter.js";
|
|
2
|
+
const availableAdapters = [browserStackAdapter];
|
|
3
|
+
export function listCloudProviderAdapters() {
|
|
4
|
+
return availableAdapters;
|
|
5
|
+
}
|
|
6
|
+
export function getCloudProviderAdapter(providerId) {
|
|
7
|
+
return availableAdapters.find((adapter) => adapter.id === providerId) ?? null;
|
|
8
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Thin wrapper that delegates to the appropriate platform backend.
|
|
5
5
|
* Maintains backwards compatibility with existing code.
|
|
6
6
|
*/
|
|
7
|
-
import { getDeviceBackend, detectPlatform, setCurrentPlatform, getCurrentPlatform } from "./factory.js";
|
|
7
|
+
import { getDeviceBackend, detectPlatform, setCurrentPlatform, setCurrentSource, getCurrentPlatform } from "./factory.js";
|
|
8
8
|
let currentBackend = null;
|
|
9
9
|
/**
|
|
10
10
|
* Connect to a device (Android target ID/serial or iOS simulator name)
|
|
@@ -12,12 +12,22 @@ let currentBackend = null;
|
|
|
12
12
|
* @param {string} platform - Optional platform override ('android' or 'ios')
|
|
13
13
|
* @returns {Promise<string>} Device ID
|
|
14
14
|
*/
|
|
15
|
-
export async function connectToDevice(
|
|
16
|
-
|
|
15
|
+
export async function connectToDevice(deviceNameOrOptions, platform = null) {
|
|
16
|
+
if (deviceNameOrOptions && typeof deviceNameOrOptions === "object" && !Array.isArray(deviceNameOrOptions)) {
|
|
17
|
+
const cloudOptions = deviceNameOrOptions;
|
|
18
|
+
const detectedPlatform = cloudOptions.platform || detectPlatform(cloudOptions.deviceName);
|
|
19
|
+
setCurrentSource(cloudOptions.source || "local");
|
|
20
|
+
setCurrentPlatform(detectedPlatform);
|
|
21
|
+
currentBackend = getDeviceBackend(detectedPlatform);
|
|
22
|
+
console.log(`Platform: ${detectedPlatform}`);
|
|
23
|
+
return currentBackend.connectToDevice(cloudOptions);
|
|
24
|
+
}
|
|
25
|
+
const detectedPlatform = platform || detectPlatform(deviceNameOrOptions);
|
|
26
|
+
setCurrentSource("local");
|
|
17
27
|
setCurrentPlatform(detectedPlatform);
|
|
18
28
|
currentBackend = getDeviceBackend(detectedPlatform);
|
|
19
29
|
console.log(`Platform: ${detectedPlatform}`);
|
|
20
|
-
return currentBackend.connectToDevice(
|
|
30
|
+
return currentBackend.connectToDevice(deviceNameOrOptions);
|
|
21
31
|
}
|
|
22
32
|
/**
|
|
23
33
|
* Get device info (screen dimensions and scale factor)
|
|
@@ -55,4 +65,5 @@ export async function disconnect() {
|
|
|
55
65
|
await currentBackend.disconnect();
|
|
56
66
|
}
|
|
57
67
|
currentBackend = null;
|
|
68
|
+
setCurrentSource("local");
|
|
58
69
|
}
|
|
@@ -5,10 +5,13 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import * as androidConnection from "./android/connection.js";
|
|
7
7
|
import * as androidActions from "./android/actions.js";
|
|
8
|
+
import * as cloudConnection from "./cloud/connection.js";
|
|
9
|
+
import * as cloudActions from "./cloud/actions.js";
|
|
8
10
|
import * as iosConnection from "./ios/connection.js";
|
|
9
11
|
import * as iosActions from "./ios/actions.js";
|
|
10
12
|
// Current platform state
|
|
11
13
|
let currentPlatform = null;
|
|
14
|
+
let currentSource = "local";
|
|
12
15
|
/**
|
|
13
16
|
* Detect platform from device name or environment variable
|
|
14
17
|
* @param {string} deviceName - The device ID/name/simulator name
|
|
@@ -38,6 +41,15 @@ export function detectPlatform(deviceName) {
|
|
|
38
41
|
* @returns {object} Backend with connection and action functions
|
|
39
42
|
*/
|
|
40
43
|
export function getDeviceBackend(platform) {
|
|
44
|
+
if (currentSource !== "local") {
|
|
45
|
+
return {
|
|
46
|
+
connectToDevice: cloudConnection.connectToDevice,
|
|
47
|
+
getDeviceInfo: cloudConnection.getDeviceInfo,
|
|
48
|
+
getScreenshotAsBase64: cloudConnection.getScreenshotAsBase64,
|
|
49
|
+
handleModelAction: cloudActions.handleModelAction,
|
|
50
|
+
disconnect: cloudConnection.disconnect,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
41
53
|
if (platform === "ios") {
|
|
42
54
|
return {
|
|
43
55
|
connectToDevice: iosConnection.connectToDevice,
|
|
@@ -63,6 +75,9 @@ export function getDeviceBackend(platform) {
|
|
|
63
75
|
export function setCurrentPlatform(platform) {
|
|
64
76
|
currentPlatform = platform;
|
|
65
77
|
}
|
|
78
|
+
export function setCurrentSource(source) {
|
|
79
|
+
currentSource = source || "local";
|
|
80
|
+
}
|
|
66
81
|
/**
|
|
67
82
|
* Get the current platform
|
|
68
83
|
* @returns {string|null}
|
|
@@ -70,3 +85,6 @@ export function setCurrentPlatform(platform) {
|
|
|
70
85
|
export function getCurrentPlatform() {
|
|
71
86
|
return currentPlatform;
|
|
72
87
|
}
|
|
88
|
+
export function getCurrentSource() {
|
|
89
|
+
return currentSource;
|
|
90
|
+
}
|