@loadmill/droid-cua 2.24.0 → 2.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -5
- package/build/index.js +206 -2
- package/build/src/cli/cloud-status-line.js +58 -26
- package/build/src/cli/headless-runner.js +2 -1
- package/build/src/cli/headless-target.js +63 -7
- package/build/src/cli/loadmill-cloud-credentials.js +11 -0
- package/build/src/cli/loadmill-cloud-usage.js +18 -0
- package/build/src/commands/help.js +2 -0
- package/build/src/commands/run.js +5 -3
- package/build/src/core/cua-models.js +16 -0
- package/build/src/core/droid-cua-run-usage.js +8 -2
- package/build/src/core/execution-engine.js +8 -1
- package/build/src/core/runtime-config.js +8 -3
- package/build/src/core/session.js +2 -2
- package/build/src/core/tool-handlers/close-keyboard.js +103 -0
- package/build/src/device/actions.js +7 -0
- package/build/src/device/android/actions.js +16 -0
- package/build/src/device/cloud/actions.js +27 -0
- package/build/src/device/cloud/aws-device-farm/upload-cache.js +1 -1
- package/build/src/device/cloud/cloud-appium-client.js +15 -0
- package/build/src/device/cloud/connection.js +18 -6
- package/build/src/device/cloud/loadmill-cloud/adapter.js +537 -0
- package/build/src/device/cloud/loadmill-cloud/artifact-downloader.js +191 -0
- package/build/src/device/cloud/loadmill-cloud/control-plane-client.js +423 -0
- package/build/src/device/cloud/loadmill-cloud/errors.js +16 -0
- package/build/src/device/cloud/loadmill-cloud/format-bytes.js +18 -0
- package/build/src/device/cloud/loadmill-cloud/timing.js +14 -0
- package/build/src/device/cloud/progress.js +29 -0
- package/build/src/device/cloud/registry.js +2 -1
- package/build/src/device/connection.js +8 -2
- package/build/src/device/factory.js +3 -0
- package/build/src/device/interface.js +4 -0
- package/build/src/device/ios/actions.js +13 -0
- package/build/src/device/ios/appium-client.js +16 -0
- package/build/src/device/keyboard.js +119 -0
- package/build/src/device/openai.js +20 -1
- package/build/src/modes/design-mode-ink.js +1 -0
- package/build/src/modes/design-mode.js +1 -0
- package/build/src/modes/execution-mode.js +88 -16
- package/build/src/prompts/base.js +29 -4
- package/build/src/prompts/execution.js +14 -3
- package/package.json +1 -1
- package/skill/droid-cua/SKILL.md +6 -3
- package/skill/droid-cua/agents/openai.yaml +1 -1
- package/skill/droid-cua/references/project-and-auth.md +16 -13
- package/skill/droid-cua/references/running-and-healing.md +45 -5
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { createWriteStream } from "node:fs";
|
|
3
|
+
import { mkdir } from "node:fs/promises";
|
|
4
|
+
import { Readable, Transform } from "node:stream";
|
|
5
|
+
import { pipeline } from "node:stream/promises";
|
|
6
|
+
import { isTimingEnabled, timingLog } from "./timing.js";
|
|
7
|
+
// Mirrors the server-side caps: per-artifact 1 GiB (video/deviceLog/appiumLog),
|
|
8
|
+
// 4 GiB cumulative across a single download batch.
|
|
9
|
+
export const ARTIFACT_KINDS = ["video", "deviceLog", "appiumLog"];
|
|
10
|
+
export const PER_ARTIFACT_CAP_BYTES = 1 * 1024 * 1024 * 1024;
|
|
11
|
+
export const CUMULATIVE_CAP_BYTES = 4 * 1024 * 1024 * 1024;
|
|
12
|
+
export const DOWNLOAD_TIMEOUT_MS = 10 * 60 * 1000;
|
|
13
|
+
const KIND_DEFAULT_FILENAMES = {
|
|
14
|
+
video: "video.mp4",
|
|
15
|
+
deviceLog: "device.log",
|
|
16
|
+
appiumLog: "appium.log",
|
|
17
|
+
};
|
|
18
|
+
function normalizeKinds(kinds) {
|
|
19
|
+
if (kinds == null || kinds === "all") {
|
|
20
|
+
return [...ARTIFACT_KINDS];
|
|
21
|
+
}
|
|
22
|
+
if (Array.isArray(kinds)) {
|
|
23
|
+
return kinds;
|
|
24
|
+
}
|
|
25
|
+
return [kinds];
|
|
26
|
+
}
|
|
27
|
+
function deriveFileName(kind, url) {
|
|
28
|
+
const defaultName = KIND_DEFAULT_FILENAMES[kind] || `${kind}.bin`;
|
|
29
|
+
try {
|
|
30
|
+
const parsed = new URL(url);
|
|
31
|
+
const base = path.basename(parsed.pathname);
|
|
32
|
+
const ext = path.extname(base);
|
|
33
|
+
// Allows extensions like ".logcat" (6 chars) — DF's device-log artifact
|
|
34
|
+
// URLs use that extension and must not be forced to the ".log" default.
|
|
35
|
+
if (/^\.[a-zA-Z0-9]{1,8}$/.test(ext)) {
|
|
36
|
+
const stem = defaultName.includes(".") ? defaultName.slice(0, defaultName.lastIndexOf(".")) : defaultName;
|
|
37
|
+
return `${stem}${ext}`;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Not a parseable URL — fall back to the default name below.
|
|
42
|
+
}
|
|
43
|
+
return defaultName;
|
|
44
|
+
}
|
|
45
|
+
// Truncates the stream in-place at `cap` bytes: chunks past the cap are
|
|
46
|
+
// dropped (not pushed downstream), so the write stream stops growing while
|
|
47
|
+
// upstream reads still drain to completion. Also fires an optional
|
|
48
|
+
// `onProgress(bytesWritten)` callback, throttled to roughly `throttleMs`
|
|
49
|
+
// (always fired on the first chunk and on the final flush), for CLI
|
|
50
|
+
// per-file download progress.
|
|
51
|
+
class CappingTransform extends Transform {
|
|
52
|
+
constructor({ cap, onCapped, onProgress, throttleMs = 100 }) {
|
|
53
|
+
super();
|
|
54
|
+
this.cap = cap;
|
|
55
|
+
this.written = 0;
|
|
56
|
+
this.capped = false;
|
|
57
|
+
this.onCapped = onCapped;
|
|
58
|
+
this.onProgress = onProgress;
|
|
59
|
+
this.throttleMs = throttleMs;
|
|
60
|
+
this.lastReportAt = 0;
|
|
61
|
+
}
|
|
62
|
+
_reportProgress(force = false) {
|
|
63
|
+
if (!this.onProgress) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
if (force || this.lastReportAt === 0 || now - this.lastReportAt >= this.throttleMs) {
|
|
68
|
+
this.lastReportAt = now;
|
|
69
|
+
this.onProgress(this.written);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
_transform(chunk, _encoding, callback) {
|
|
73
|
+
if (this.capped) {
|
|
74
|
+
callback();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const remaining = this.cap - this.written;
|
|
78
|
+
if (chunk.length <= remaining) {
|
|
79
|
+
this.written += chunk.length;
|
|
80
|
+
this._reportProgress();
|
|
81
|
+
callback(null, chunk);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const truncatedChunk = chunk.subarray(0, Math.max(0, remaining));
|
|
85
|
+
this.written += truncatedChunk.length;
|
|
86
|
+
this.capped = true;
|
|
87
|
+
this.onCapped?.();
|
|
88
|
+
this._reportProgress(true);
|
|
89
|
+
callback(null, truncatedChunk);
|
|
90
|
+
}
|
|
91
|
+
_flush(callback) {
|
|
92
|
+
this._reportProgress(true);
|
|
93
|
+
callback();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Streams the requested artifact kinds to destDir. Never buffers a whole
|
|
98
|
+
* file in memory. Returns a summary of what was written.
|
|
99
|
+
*
|
|
100
|
+
* @returns {Promise<Array<{ kind: string, filePath: string, bytesWritten: number, truncated: boolean }>>}
|
|
101
|
+
*/
|
|
102
|
+
export async function downloadArtifacts({ artifacts, kinds, destDir, onNote,
|
|
103
|
+
// Fires (kind, fileName, bytesWritten, totalBytes|null) roughly every 100ms
|
|
104
|
+
// while a file streams to disk, plus once at start (0 bytes) and once at
|
|
105
|
+
// completion. totalBytes is null when the server didn't send Content-Length.
|
|
106
|
+
onProgress,
|
|
107
|
+
// Optional bookkeeping hooks around a single file's download, so a caller
|
|
108
|
+
// (e.g. the CLI status line) can open/close a "step" per file.
|
|
109
|
+
onFileStart, onFileDone,
|
|
110
|
+
// Test-only overrides — production callers rely on the exported defaults.
|
|
111
|
+
perArtifactCapBytes = PER_ARTIFACT_CAP_BYTES, cumulativeCapBytes = CUMULATIVE_CAP_BYTES, downloadTimeoutMs = DOWNLOAD_TIMEOUT_MS, } = {}) {
|
|
112
|
+
const requestedKinds = new Set(normalizeKinds(kinds));
|
|
113
|
+
const list = Array.isArray(artifacts) ? artifacts : [];
|
|
114
|
+
const filtered = list.filter((artifact) => artifact && requestedKinds.has(artifact.kind));
|
|
115
|
+
if (filtered.length === 0) {
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
await mkdir(destDir, { recursive: true });
|
|
119
|
+
const summary = [];
|
|
120
|
+
let cumulativeBytes = 0;
|
|
121
|
+
for (const artifact of filtered) {
|
|
122
|
+
if (cumulativeBytes >= cumulativeCapBytes) {
|
|
123
|
+
onNote?.(`WARNING: ${artifact.kind} skipped — cumulative artifact cap (${cumulativeCapBytes} bytes) reached`);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const perArtifactCap = Math.min(perArtifactCapBytes, cumulativeCapBytes - cumulativeBytes);
|
|
127
|
+
if (typeof artifact.bytes === "number" && artifact.bytes > perArtifactCap) {
|
|
128
|
+
onNote?.(`WARNING: ${artifact.kind} is ${artifact.bytes} bytes, exceeds the ${perArtifactCap} byte cap and will be truncated`);
|
|
129
|
+
}
|
|
130
|
+
const fileName = deriveFileName(artifact.kind, artifact.presignedGetUrl);
|
|
131
|
+
const filePath = path.join(destDir, fileName);
|
|
132
|
+
const downloadStartedAt = Date.now();
|
|
133
|
+
timingLog(`download start: kind=${artifact.kind} url=${artifact.presignedGetUrl}`);
|
|
134
|
+
const controller = new AbortController();
|
|
135
|
+
const deadline = setTimeout(() => controller.abort(new Error(`download of ${artifact.kind} timed out after ${downloadTimeoutMs}ms`)), downloadTimeoutMs);
|
|
136
|
+
let response;
|
|
137
|
+
try {
|
|
138
|
+
response = await fetch(artifact.presignedGetUrl, { signal: controller.signal });
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
clearTimeout(deadline);
|
|
142
|
+
onNote?.(`WARNING: failed to download ${artifact.kind} (${error?.message ?? error})`);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (!response.ok || !response.body) {
|
|
146
|
+
clearTimeout(deadline);
|
|
147
|
+
onNote?.(`WARNING: failed to download ${artifact.kind} (HTTP ${response.status})`);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const lastModified = response.headers?.get("last-modified") ?? null;
|
|
151
|
+
const contentLength = response.headers?.get("content-length") ?? null;
|
|
152
|
+
const totalBytes = contentLength != null && contentLength !== "" ? Number(contentLength) : null;
|
|
153
|
+
timingLog(`download headers: kind=${artifact.kind} Last-Modified=${lastModified ?? "null"} Content-Length=${contentLength ?? "null"}`);
|
|
154
|
+
onFileStart?.(artifact.kind, fileName, totalBytes);
|
|
155
|
+
onProgress?.(artifact.kind, fileName, 0, totalBytes);
|
|
156
|
+
let truncated = false;
|
|
157
|
+
const capper = new CappingTransform({
|
|
158
|
+
cap: perArtifactCap,
|
|
159
|
+
onCapped: () => {
|
|
160
|
+
truncated = true;
|
|
161
|
+
},
|
|
162
|
+
onProgress: onProgress ? (bytesWritten) => onProgress(artifact.kind, fileName, bytesWritten, totalBytes) : undefined,
|
|
163
|
+
});
|
|
164
|
+
try {
|
|
165
|
+
await pipeline(Readable.fromWeb(response.body), capper, createWriteStream(filePath), {
|
|
166
|
+
signal: controller.signal,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
onNote?.(`WARNING: failed to download ${artifact.kind} (${error?.message ?? error})`);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
clearTimeout(deadline);
|
|
175
|
+
}
|
|
176
|
+
onFileDone?.(artifact.kind, fileName, capper.written, Date.now() - downloadStartedAt, truncated);
|
|
177
|
+
if (truncated) {
|
|
178
|
+
onNote?.(`WARNING: ${artifact.kind} truncated at ${capper.written} bytes (cap)`);
|
|
179
|
+
}
|
|
180
|
+
timingLog(`download complete: kind=${artifact.kind} bytes=${capper.written} durationMs=${Date.now() - downloadStartedAt}`);
|
|
181
|
+
cumulativeBytes += capper.written;
|
|
182
|
+
summary.push({ kind: artifact.kind, filePath, bytesWritten: capper.written, truncated, lastModified });
|
|
183
|
+
}
|
|
184
|
+
if (isTimingEnabled() && summary.length > 0) {
|
|
185
|
+
const table = summary
|
|
186
|
+
.map((item) => `${item.kind} → ${item.lastModified ?? "null"} → ${item.bytesWritten}`)
|
|
187
|
+
.join(" | ");
|
|
188
|
+
timingLog(`artifact summary (kind → lastModified → bytes): ${table}`);
|
|
189
|
+
}
|
|
190
|
+
return summary;
|
|
191
|
+
}
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
const DEFAULT_BASE_URL = "https://app.loadmill.com";
|
|
4
|
+
const MOBILE_TESTING_PATH = "/mobile-testing-cloud/v1";
|
|
5
|
+
const LOADMILL_CLIENT_HEADER_VALUE = "cli";
|
|
6
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
7
|
+
// The binary PUT carries the actual app file (tens to low-hundreds of MB) and needs a
|
|
8
|
+
// much larger budget than the small JSON control-plane calls — 30s isn't enough time
|
|
9
|
+
// for a real transfer even on a healthy connection.
|
|
10
|
+
const DEFAULT_UPLOAD_TIMEOUT_MS = 180_000;
|
|
11
|
+
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
12
|
+
const POLL_FALLBACK_INTERVAL_MS = 3_000;
|
|
13
|
+
const SSE_RETRY_INTERVAL_MS = 5_000;
|
|
14
|
+
// Server sends a `: ping` keepalive every 25s; treat total silence this long as a wedged connection.
|
|
15
|
+
const SSE_IDLE_TIMEOUT_MS = 60_000;
|
|
16
|
+
// This control plane mounts at /mobile-testing-cloud/v1, not under /api — do not append /api.
|
|
17
|
+
function normalizeApiBaseUrl(rawBaseUrl) {
|
|
18
|
+
const candidate = (rawBaseUrl || DEFAULT_BASE_URL).trim();
|
|
19
|
+
if (!candidate) {
|
|
20
|
+
return DEFAULT_BASE_URL;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const parsed = new URL(candidate);
|
|
24
|
+
parsed.pathname = parsed.pathname
|
|
25
|
+
.replace(/\/+$/, "")
|
|
26
|
+
.replace(/\/api$/, "")
|
|
27
|
+
.replace(/\/llm\/v1$/, "")
|
|
28
|
+
.replace(/\/llm$/, "");
|
|
29
|
+
parsed.search = "";
|
|
30
|
+
parsed.hash = "";
|
|
31
|
+
return parsed.toString().replace(/\/$/, "");
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return DEFAULT_BASE_URL;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function requestTimeoutError(label, timeoutMs) {
|
|
38
|
+
const error = new Error(`Loadmill Cloud API request timed out after ${timeoutMs}ms: ${label}`);
|
|
39
|
+
error.code = "request_timeout";
|
|
40
|
+
return error;
|
|
41
|
+
}
|
|
42
|
+
function requestAbortedError(label) {
|
|
43
|
+
const error = new Error(`Loadmill Cloud API request aborted: ${label}`);
|
|
44
|
+
error.code = "request_aborted";
|
|
45
|
+
return error;
|
|
46
|
+
}
|
|
47
|
+
async function buildResponseError(response) {
|
|
48
|
+
let payload = null;
|
|
49
|
+
try {
|
|
50
|
+
payload = await response.json();
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// non-JSON error body
|
|
54
|
+
}
|
|
55
|
+
const envelope = (payload?.error && typeof payload.error === "object" && payload.error) ||
|
|
56
|
+
(payload?.payload && typeof payload.payload === "object" && payload.payload) ||
|
|
57
|
+
payload;
|
|
58
|
+
const message = (envelope && typeof envelope.message === "string" && envelope.message) ||
|
|
59
|
+
(payload && typeof payload.err === "string" && payload.err) ||
|
|
60
|
+
`Loadmill Cloud API error (${response.status})`;
|
|
61
|
+
const error = new Error(message);
|
|
62
|
+
error.status = response.status;
|
|
63
|
+
error.code = envelope?.code;
|
|
64
|
+
error.type = envelope?.type;
|
|
65
|
+
error.param = envelope?.param;
|
|
66
|
+
return error;
|
|
67
|
+
}
|
|
68
|
+
export class LoadmillCloudControlPlaneClient {
|
|
69
|
+
/**
|
|
70
|
+
* @param {{ bearerToken: string, baseUrl?: string, requestTimeoutMs?: number, uploadTimeoutMs?: number }} [options]
|
|
71
|
+
*/
|
|
72
|
+
constructor({ bearerToken, baseUrl, requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, uploadTimeoutMs = DEFAULT_UPLOAD_TIMEOUT_MS, } = {}) {
|
|
73
|
+
if (!bearerToken || typeof bearerToken !== "string" || !bearerToken.trim()) {
|
|
74
|
+
throw new Error("LoadmillCloudControlPlaneClient requires a bearerToken.");
|
|
75
|
+
}
|
|
76
|
+
this.bearerToken = bearerToken.trim();
|
|
77
|
+
this.baseUrl = normalizeApiBaseUrl(baseUrl);
|
|
78
|
+
this.requestTimeoutMs = requestTimeoutMs;
|
|
79
|
+
this.uploadTimeoutMs = uploadTimeoutMs;
|
|
80
|
+
this._heartbeatTimer = null;
|
|
81
|
+
}
|
|
82
|
+
_url(pathSuffix) {
|
|
83
|
+
return `${this.baseUrl}${MOBILE_TESTING_PATH}${pathSuffix}`;
|
|
84
|
+
}
|
|
85
|
+
_authHeaders(extra = {}) {
|
|
86
|
+
return {
|
|
87
|
+
Authorization: `Bearer ${this.bearerToken}`,
|
|
88
|
+
"X-Loadmill-Client": LOADMILL_CLIENT_HEADER_VALUE,
|
|
89
|
+
...extra,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
async _request(pathSuffix, { method = "GET", headers = {}, body, signal: callerSignal } = {}) {
|
|
93
|
+
const timeoutSignal = AbortSignal.timeout(this.requestTimeoutMs);
|
|
94
|
+
const signal = callerSignal ? AbortSignal.any([timeoutSignal, callerSignal]) : timeoutSignal;
|
|
95
|
+
let response;
|
|
96
|
+
try {
|
|
97
|
+
response = await fetch(this._url(pathSuffix), {
|
|
98
|
+
method,
|
|
99
|
+
signal,
|
|
100
|
+
headers: {
|
|
101
|
+
...this._authHeaders(headers),
|
|
102
|
+
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
|
103
|
+
},
|
|
104
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
if (timeoutSignal.aborted) {
|
|
109
|
+
throw requestTimeoutError(pathSuffix, this.requestTimeoutMs);
|
|
110
|
+
}
|
|
111
|
+
if (callerSignal?.aborted) {
|
|
112
|
+
throw requestAbortedError(pathSuffix);
|
|
113
|
+
}
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
if (!response.ok) {
|
|
117
|
+
throw await buildResponseError(response);
|
|
118
|
+
}
|
|
119
|
+
if (response.status === 204) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
return response.json();
|
|
123
|
+
}
|
|
124
|
+
async getDevices({ platform } = {}) {
|
|
125
|
+
const query = platform ? `?platform=${encodeURIComponent(platform)}` : "";
|
|
126
|
+
return this._request(`/devices${query}`);
|
|
127
|
+
}
|
|
128
|
+
async getUsage() {
|
|
129
|
+
return this._request("/usage");
|
|
130
|
+
}
|
|
131
|
+
async initRun({ platform, deviceName, osVersion }) {
|
|
132
|
+
return this._request("/runs", {
|
|
133
|
+
method: "POST",
|
|
134
|
+
headers: { "x-loadmill-request-id": randomUUID() },
|
|
135
|
+
body: { platform, deviceName, osVersion },
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
async registerApp(sessionId, { sha256, platform, fileName }) {
|
|
139
|
+
return this._request(`/runs/${encodeURIComponent(sessionId)}/app`, {
|
|
140
|
+
method: "POST",
|
|
141
|
+
body: { sha256, platform, fileName },
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
async uploadBinary(presignedPutUrl, filePath, { signal: callerSignal } = {}) {
|
|
145
|
+
const timeoutSignal = AbortSignal.timeout(this.uploadTimeoutMs);
|
|
146
|
+
const signal = callerSignal ? AbortSignal.any([timeoutSignal, callerSignal]) : timeoutSignal;
|
|
147
|
+
const label = "upload";
|
|
148
|
+
let response;
|
|
149
|
+
try {
|
|
150
|
+
// Buffer the whole file: undici streams bodies as chunked transfer, which S3 presigned PUTs reject.
|
|
151
|
+
// Content-Type must match the value the presigned URL signed, or S3 returns 403 SignatureDoesNotMatch.
|
|
152
|
+
response = await fetch(presignedPutUrl, {
|
|
153
|
+
method: "PUT",
|
|
154
|
+
signal,
|
|
155
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
156
|
+
body: await readFile(filePath),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
if (timeoutSignal.aborted) {
|
|
161
|
+
throw requestTimeoutError(label, this.uploadTimeoutMs);
|
|
162
|
+
}
|
|
163
|
+
if (callerSignal?.aborted) {
|
|
164
|
+
throw requestAbortedError(label);
|
|
165
|
+
}
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
if (!response.ok) {
|
|
169
|
+
const text = await response.text().catch(() => "");
|
|
170
|
+
throw new Error(`Loadmill Cloud app upload failed (HTTP ${response.status}): ${text}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async stopRun(sessionId, { signal } = {}) {
|
|
174
|
+
return this._request(`/runs/${encodeURIComponent(sessionId)}/stop`, { method: "POST", signal });
|
|
175
|
+
}
|
|
176
|
+
async heartbeat(sessionId, { signal } = {}) {
|
|
177
|
+
return this._request(`/runs/${encodeURIComponent(sessionId)}/heartbeat`, { method: "POST", signal });
|
|
178
|
+
}
|
|
179
|
+
async getRunState(sessionId) {
|
|
180
|
+
return this._request(`/runs/${encodeURIComponent(sessionId)}`);
|
|
181
|
+
}
|
|
182
|
+
async getArtifacts(sessionId) {
|
|
183
|
+
return this._request(`/runs/${encodeURIComponent(sessionId)}/artifacts`);
|
|
184
|
+
}
|
|
185
|
+
async reportStatus(sessionId, state, error) {
|
|
186
|
+
return this._request(`/runs/${encodeURIComponent(sessionId)}/status`, {
|
|
187
|
+
method: "POST",
|
|
188
|
+
body: error ? { state, error } : { state },
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
// Server reaps at 90s of missed heartbeats, so individual failures are swallowed.
|
|
192
|
+
startHeartbeat(sessionId) {
|
|
193
|
+
this.stopHeartbeat();
|
|
194
|
+
this._heartbeatTimer = setInterval(() => {
|
|
195
|
+
this.heartbeat(sessionId).catch((error) => {
|
|
196
|
+
console.error(`[loadmill-cloud] heartbeat failed for session ${sessionId}: ${error.message}`);
|
|
197
|
+
});
|
|
198
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
199
|
+
if (typeof this._heartbeatTimer.unref === "function") {
|
|
200
|
+
this._heartbeatTimer.unref();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
stopHeartbeat() {
|
|
204
|
+
if (this._heartbeatTimer) {
|
|
205
|
+
clearInterval(this._heartbeatTimer);
|
|
206
|
+
this._heartbeatTimer = null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
async watchRun(sessionId, onState, { signal } = {}) {
|
|
210
|
+
let stopped = false;
|
|
211
|
+
const onAbort = () => {
|
|
212
|
+
stopped = true;
|
|
213
|
+
};
|
|
214
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
215
|
+
const sseState = { healthy: false, target: new EventTarget() };
|
|
216
|
+
const setSseHealthy = (value) => {
|
|
217
|
+
if (sseState.healthy === value) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
sseState.healthy = value;
|
|
221
|
+
sseState.target.dispatchEvent(new Event("change"));
|
|
222
|
+
};
|
|
223
|
+
const pollLoop = (async () => {
|
|
224
|
+
while (!stopped && !signal?.aborted) {
|
|
225
|
+
if (sseState.healthy) {
|
|
226
|
+
const waiter = this._waitForSseChange(sseState, signal);
|
|
227
|
+
await waiter.promise;
|
|
228
|
+
waiter.cancel();
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
const state = await this.getRunState(sessionId);
|
|
233
|
+
if (stopped || signal?.aborted)
|
|
234
|
+
break;
|
|
235
|
+
onState(state);
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
console.error(`[loadmill-cloud] poll fallback failed for session ${sessionId}: ${error.message}`);
|
|
239
|
+
}
|
|
240
|
+
if (sseState.healthy) {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
// waiter.cancel() is required: the losing side of the race would otherwise leak a listener per poll iteration.
|
|
244
|
+
const waiter = this._waitForSseChange(sseState, signal);
|
|
245
|
+
await Promise.race([this._sleep(POLL_FALLBACK_INTERVAL_MS, signal), waiter.promise]);
|
|
246
|
+
waiter.cancel();
|
|
247
|
+
}
|
|
248
|
+
})();
|
|
249
|
+
const sseLoop = (async () => {
|
|
250
|
+
while (!stopped && !signal?.aborted) {
|
|
251
|
+
try {
|
|
252
|
+
await this._consumeSse(sessionId, onState, signal, setSseHealthy);
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
if (this._isAbortError(error, signal)) {
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
console.error(`[loadmill-cloud] SSE stream failed for session ${sessionId}: ${error.message}`);
|
|
259
|
+
}
|
|
260
|
+
finally {
|
|
261
|
+
setSseHealthy(false);
|
|
262
|
+
}
|
|
263
|
+
if (stopped || signal?.aborted)
|
|
264
|
+
break;
|
|
265
|
+
await this._sleep(SSE_RETRY_INTERVAL_MS, signal);
|
|
266
|
+
}
|
|
267
|
+
})();
|
|
268
|
+
await Promise.all([pollLoop, sseLoop]).catch(() => { });
|
|
269
|
+
}
|
|
270
|
+
_waitForSseChange(sseState, signal) {
|
|
271
|
+
if (signal?.aborted) {
|
|
272
|
+
return { promise: Promise.resolve(), cancel: () => { } };
|
|
273
|
+
}
|
|
274
|
+
let resolvePromise;
|
|
275
|
+
const promise = new Promise((resolve) => {
|
|
276
|
+
resolvePromise = resolve;
|
|
277
|
+
});
|
|
278
|
+
const cleanup = () => {
|
|
279
|
+
sseState.target.removeEventListener("change", onChange);
|
|
280
|
+
signal?.removeEventListener("abort", onAbort);
|
|
281
|
+
};
|
|
282
|
+
const onChange = () => {
|
|
283
|
+
cleanup();
|
|
284
|
+
resolvePromise();
|
|
285
|
+
};
|
|
286
|
+
const onAbort = () => {
|
|
287
|
+
cleanup();
|
|
288
|
+
resolvePromise();
|
|
289
|
+
};
|
|
290
|
+
sseState.target.addEventListener("change", onChange, { once: true });
|
|
291
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
292
|
+
return { promise, cancel: cleanup };
|
|
293
|
+
}
|
|
294
|
+
_isAbortError(error, signal) {
|
|
295
|
+
return Boolean(signal?.aborted ||
|
|
296
|
+
error?.name === "AbortError" ||
|
|
297
|
+
/aborted/i.test(error?.message || ""));
|
|
298
|
+
}
|
|
299
|
+
_sleep(ms, signal) {
|
|
300
|
+
if (signal?.aborted)
|
|
301
|
+
return Promise.resolve();
|
|
302
|
+
return new Promise((resolve) => {
|
|
303
|
+
const timer = setTimeout(() => {
|
|
304
|
+
signal?.removeEventListener("abort", onAbort);
|
|
305
|
+
resolve();
|
|
306
|
+
}, ms);
|
|
307
|
+
const onAbort = () => {
|
|
308
|
+
clearTimeout(timer);
|
|
309
|
+
resolve();
|
|
310
|
+
};
|
|
311
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
async _consumeSse(sessionId, onState, signal, setSseHealthy) {
|
|
315
|
+
const response = await fetch(this._url(`/runs/${encodeURIComponent(sessionId)}/events`), {
|
|
316
|
+
method: "GET",
|
|
317
|
+
signal,
|
|
318
|
+
headers: this._authHeaders({ Accept: "text/event-stream" }),
|
|
319
|
+
});
|
|
320
|
+
if (!response.ok) {
|
|
321
|
+
throw await buildResponseError(response);
|
|
322
|
+
}
|
|
323
|
+
if (!response.body) {
|
|
324
|
+
throw new Error("SSE response had no readable body.");
|
|
325
|
+
}
|
|
326
|
+
setSseHealthy?.(true);
|
|
327
|
+
const idleController = new AbortController();
|
|
328
|
+
const onOuterAbort = () => idleController.abort(signal?.reason);
|
|
329
|
+
if (signal?.aborted) {
|
|
330
|
+
idleController.abort(signal.reason);
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
signal?.addEventListener("abort", onOuterAbort, { once: true });
|
|
334
|
+
}
|
|
335
|
+
let idleTimer = null;
|
|
336
|
+
const armIdleTimer = () => {
|
|
337
|
+
clearTimeout(idleTimer);
|
|
338
|
+
idleTimer = setTimeout(() => {
|
|
339
|
+
setSseHealthy?.(false);
|
|
340
|
+
idleController.abort(new Error(`SSE stream idle for ${SSE_IDLE_TIMEOUT_MS}ms (no bytes received) — reconnecting.`));
|
|
341
|
+
}, SSE_IDLE_TIMEOUT_MS);
|
|
342
|
+
if (typeof idleTimer.unref === "function") {
|
|
343
|
+
idleTimer.unref();
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
armIdleTimer();
|
|
347
|
+
try {
|
|
348
|
+
await parseSseStream(response.body, onState, {
|
|
349
|
+
signal: idleController.signal,
|
|
350
|
+
onActivity: armIdleTimer,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
finally {
|
|
354
|
+
clearTimeout(idleTimer);
|
|
355
|
+
signal?.removeEventListener("abort", onOuterAbort);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
// An abort signal cannot interrupt an in-flight reader.read(), so race the read against it.
|
|
360
|
+
function readOrAbort(reader, signal) {
|
|
361
|
+
if (!signal) {
|
|
362
|
+
return reader.read();
|
|
363
|
+
}
|
|
364
|
+
if (signal.aborted) {
|
|
365
|
+
return Promise.reject(signal.reason ?? new Error("SSE read aborted."));
|
|
366
|
+
}
|
|
367
|
+
return new Promise((resolve, reject) => {
|
|
368
|
+
const onAbort = () => {
|
|
369
|
+
reject(signal.reason ?? new Error("SSE read aborted."));
|
|
370
|
+
};
|
|
371
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
372
|
+
reader.read().then((result) => {
|
|
373
|
+
signal.removeEventListener("abort", onAbort);
|
|
374
|
+
resolve(result);
|
|
375
|
+
}, (error) => {
|
|
376
|
+
signal.removeEventListener("abort", onAbort);
|
|
377
|
+
reject(error);
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
export async function parseSseStream(body, onState, { signal, onActivity } = {}) {
|
|
382
|
+
const reader = body.getReader();
|
|
383
|
+
const decoder = new TextDecoder("utf-8");
|
|
384
|
+
let buffer = "";
|
|
385
|
+
try {
|
|
386
|
+
for (;;) {
|
|
387
|
+
const { done, value } = await readOrAbort(reader, signal);
|
|
388
|
+
if (done)
|
|
389
|
+
break;
|
|
390
|
+
onActivity?.();
|
|
391
|
+
buffer += decoder.decode(value, { stream: true });
|
|
392
|
+
let match;
|
|
393
|
+
while ((match = buffer.match(/\r?\n\r?\n/)) !== null) {
|
|
394
|
+
const rawEvent = buffer.slice(0, match.index);
|
|
395
|
+
buffer = buffer.slice(match.index + match[0].length);
|
|
396
|
+
emitSseEvent(rawEvent, onState);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (buffer.trim()) {
|
|
400
|
+
emitSseEvent(buffer, onState);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
finally {
|
|
404
|
+
await reader.cancel?.().catch(() => { });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
function emitSseEvent(rawEvent, onState) {
|
|
408
|
+
const dataLines = rawEvent
|
|
409
|
+
.split(/\r?\n/)
|
|
410
|
+
.filter((line) => line.startsWith("data:"))
|
|
411
|
+
.map((line) => line.slice("data:".length).trimStart().replace(/\r$/, ""));
|
|
412
|
+
if (dataLines.length === 0) {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const payload = dataLines.join("\n");
|
|
416
|
+
try {
|
|
417
|
+
const state = JSON.parse(payload);
|
|
418
|
+
onState(state);
|
|
419
|
+
}
|
|
420
|
+
catch (error) {
|
|
421
|
+
console.error(`[loadmill-cloud] failed to parse SSE event payload: ${error.message}`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const LOADMILL_CLOUD_USAGE_LIMIT_ERROR_CODES = new Set([
|
|
2
|
+
"mobile_testing_day_minute_quota_exceeded",
|
|
3
|
+
"mobile_testing_week_minute_quota_exceeded",
|
|
4
|
+
"mobile_testing_fixed_budget_minute_quota_exceeded",
|
|
5
|
+
"mobile_testing_concurrency_exceeded",
|
|
6
|
+
"mobile_testing_rate_limit_exceeded",
|
|
7
|
+
]);
|
|
8
|
+
export function isLoadmillCloudUsageLimitError(err) {
|
|
9
|
+
if (!err || typeof err !== "object") {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
if (typeof err.code === "string" && LOADMILL_CLOUD_USAGE_LIMIT_ERROR_CODES.has(err.code)) {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
return err.status === 429 && err.type === "rate_limit_error";
|
|
16
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Human-readable byte sizes for CLI progress/summary output. One decimal,
|
|
2
|
+
// KB/MB/GB — matches the artifact-download progress UX spec.
|
|
3
|
+
export function formatBytes(bytes) {
|
|
4
|
+
const value = Number(bytes) || 0;
|
|
5
|
+
if (value < 1024) {
|
|
6
|
+
return `${value} B`;
|
|
7
|
+
}
|
|
8
|
+
const kb = value / 1024;
|
|
9
|
+
if (kb < 1024) {
|
|
10
|
+
return `${kb.toFixed(1)} KB`;
|
|
11
|
+
}
|
|
12
|
+
const mb = kb / 1024;
|
|
13
|
+
if (mb < 1024) {
|
|
14
|
+
return `${mb.toFixed(1)} MB`;
|
|
15
|
+
}
|
|
16
|
+
const gb = mb / 1024;
|
|
17
|
+
return `${gb.toFixed(1)} GB`;
|
|
18
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Opt-in timing instrumentation for decomposing the post-stop artifact wait.
|
|
2
|
+
// Zero-cost/zero-output unless LOADMILL_CLOUD_TIMING=1 is set in the
|
|
3
|
+
// environment: everything writes to stderr so it never pollutes stdout
|
|
4
|
+
// output the CLI relies on.
|
|
5
|
+
const ENV_VAR = "LOADMILL_CLOUD_TIMING";
|
|
6
|
+
export function isTimingEnabled() {
|
|
7
|
+
return process.env[ENV_VAR] === "1";
|
|
8
|
+
}
|
|
9
|
+
export function timingLog(message) {
|
|
10
|
+
if (!isTimingEnabled()) {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
process.stderr.write(`[loadmill-cloud-timing] ${new Date().toISOString()} ${message}\n`);
|
|
14
|
+
}
|