@pickforge/picklab 0.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/dist/chunk-SGSRIPSM.js +3813 -0
- package/dist/picklab-mcp.js +7 -0
- package/dist/picklab.js +3164 -0
- package/package.json +32 -0
|
@@ -0,0 +1,3813 @@
|
|
|
1
|
+
// src/commands/mcp.ts
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
|
|
4
|
+
// ../mcp-server/dist/index.js
|
|
5
|
+
import { createRequire } from "module";
|
|
6
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
+
import fs8 from "fs";
|
|
8
|
+
import path9 from "path";
|
|
9
|
+
|
|
10
|
+
// ../core/dist/index.js
|
|
11
|
+
import fs from "fs";
|
|
12
|
+
import os from "os";
|
|
13
|
+
import path from "path";
|
|
14
|
+
import fs2 from "fs";
|
|
15
|
+
import path2 from "path";
|
|
16
|
+
import fs3 from "fs";
|
|
17
|
+
import path3 from "path";
|
|
18
|
+
import { spawn } from "child_process";
|
|
19
|
+
import fs4 from "fs";
|
|
20
|
+
import path4 from "path";
|
|
21
|
+
import { lstat, realpath } from "fs/promises";
|
|
22
|
+
import path6 from "path";
|
|
23
|
+
import { randomBytes } from "crypto";
|
|
24
|
+
import fs5 from "fs";
|
|
25
|
+
import path5 from "path";
|
|
26
|
+
function picklabHome(env = process.env) {
|
|
27
|
+
const fromEnv = env.PICKLAB_HOME;
|
|
28
|
+
if (fromEnv !== void 0 && fromEnv !== "") {
|
|
29
|
+
return fromEnv;
|
|
30
|
+
}
|
|
31
|
+
return path.join(os.homedir(), ".picklab");
|
|
32
|
+
}
|
|
33
|
+
function sessionsDir(env = process.env) {
|
|
34
|
+
return path.join(picklabHome(env), "sessions");
|
|
35
|
+
}
|
|
36
|
+
function agentsDir(env = process.env) {
|
|
37
|
+
return path.join(picklabHome(env), "agents");
|
|
38
|
+
}
|
|
39
|
+
function projectConfigPath(projectDir) {
|
|
40
|
+
return path.join(projectDir, ".picklab", "config.json");
|
|
41
|
+
}
|
|
42
|
+
function globalConfigPath(env = process.env) {
|
|
43
|
+
return path.join(picklabHome(env), "config.json");
|
|
44
|
+
}
|
|
45
|
+
function runsDir(projectDir) {
|
|
46
|
+
return path.join(projectDir, ".picklab", "runs");
|
|
47
|
+
}
|
|
48
|
+
async function ensureDir(dir) {
|
|
49
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
50
|
+
return dir;
|
|
51
|
+
}
|
|
52
|
+
var resolvedDefaults = {
|
|
53
|
+
android: { avdName: "picklab-avd" },
|
|
54
|
+
labUser: { name: "picklab-lab", home: "/var/lib/picklab/lab-home" }
|
|
55
|
+
};
|
|
56
|
+
function isPlainObject(value) {
|
|
57
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
58
|
+
}
|
|
59
|
+
function deepMerge(base, overlay) {
|
|
60
|
+
const result = { ...base };
|
|
61
|
+
for (const [key, value] of Object.entries(overlay)) {
|
|
62
|
+
if (value === void 0) continue;
|
|
63
|
+
const existing = result[key];
|
|
64
|
+
if (isPlainObject(existing) && isPlainObject(value)) {
|
|
65
|
+
result[key] = deepMerge(existing, value);
|
|
66
|
+
} else {
|
|
67
|
+
result[key] = isPlainObject(value) ? deepMerge({}, value) : value;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
async function readConfigFile(filePath) {
|
|
73
|
+
let raw;
|
|
74
|
+
try {
|
|
75
|
+
raw = await fs2.promises.readFile(filePath, "utf8");
|
|
76
|
+
} catch (error) {
|
|
77
|
+
const code = error.code;
|
|
78
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const parsed = JSON.parse(raw);
|
|
85
|
+
if (!isPlainObject(parsed)) {
|
|
86
|
+
throw new Error("expected a JSON object");
|
|
87
|
+
}
|
|
88
|
+
return parsed;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`Invalid PickLab config at ${filePath}: ${error.message}`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async function loadConfig(projectDir, env = process.env) {
|
|
96
|
+
const global = await readConfigFile(globalConfigPath(env));
|
|
97
|
+
const project = await readConfigFile(projectConfigPath(projectDir));
|
|
98
|
+
return deepMerge(
|
|
99
|
+
deepMerge(deepMerge({}, resolvedDefaults), global),
|
|
100
|
+
project
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
var tmpCounter = 0;
|
|
104
|
+
async function writeConfigFile(filePath, config) {
|
|
105
|
+
const dir = await ensureDir(path2.dirname(filePath));
|
|
106
|
+
tmpCounter += 1;
|
|
107
|
+
const tmp = path2.join(
|
|
108
|
+
dir,
|
|
109
|
+
`.${path2.basename(filePath)}.tmp-${process.pid}-${tmpCounter}`
|
|
110
|
+
);
|
|
111
|
+
await fs2.promises.writeFile(
|
|
112
|
+
tmp,
|
|
113
|
+
`${JSON.stringify(config, null, 2)}
|
|
114
|
+
`,
|
|
115
|
+
"utf8"
|
|
116
|
+
);
|
|
117
|
+
await fs2.promises.rename(tmp, filePath);
|
|
118
|
+
}
|
|
119
|
+
async function saveProjectConfig(projectDir, config) {
|
|
120
|
+
await writeConfigFile(projectConfigPath(projectDir), config);
|
|
121
|
+
}
|
|
122
|
+
async function saveGlobalConfig(config, env = process.env) {
|
|
123
|
+
await writeConfigFile(globalConfigPath(env), config);
|
|
124
|
+
}
|
|
125
|
+
var SLUG_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
126
|
+
var tmpCounter2 = 0;
|
|
127
|
+
function assertValidSlug(slug) {
|
|
128
|
+
if (!SLUG_PATTERN.test(slug) || slug.includes("..")) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`Invalid run slug "${slug}": must start with a letter or digit and contain only letters, digits, ".", "_", or "-" (no path separators or "..")`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function formatTimestamp(date) {
|
|
135
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
136
|
+
return `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}-${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}`;
|
|
137
|
+
}
|
|
138
|
+
async function writeManifest(runDir, manifest) {
|
|
139
|
+
const target = path3.join(runDir, "manifest.json");
|
|
140
|
+
tmpCounter2 += 1;
|
|
141
|
+
const tmp = path3.join(
|
|
142
|
+
runDir,
|
|
143
|
+
`.manifest.json.tmp-${process.pid}-${tmpCounter2}`
|
|
144
|
+
);
|
|
145
|
+
await fs3.promises.writeFile(
|
|
146
|
+
tmp,
|
|
147
|
+
`${JSON.stringify(manifest, null, 2)}
|
|
148
|
+
`,
|
|
149
|
+
"utf8"
|
|
150
|
+
);
|
|
151
|
+
await fs3.promises.rename(tmp, target);
|
|
152
|
+
}
|
|
153
|
+
var RunHandle = class {
|
|
154
|
+
dir;
|
|
155
|
+
manifest;
|
|
156
|
+
constructor(dir, manifest) {
|
|
157
|
+
this.dir = dir;
|
|
158
|
+
this.manifest = manifest;
|
|
159
|
+
}
|
|
160
|
+
get runId() {
|
|
161
|
+
return this.manifest.runId;
|
|
162
|
+
}
|
|
163
|
+
async addArtifact(type, name, artifactPath) {
|
|
164
|
+
const relative = path3.isAbsolute(artifactPath) ? path3.relative(this.dir, artifactPath) : artifactPath;
|
|
165
|
+
const artifact = {
|
|
166
|
+
type,
|
|
167
|
+
name,
|
|
168
|
+
path: relative,
|
|
169
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
170
|
+
};
|
|
171
|
+
this.manifest.artifacts.push(artifact);
|
|
172
|
+
await writeManifest(this.dir, this.manifest);
|
|
173
|
+
return artifact;
|
|
174
|
+
}
|
|
175
|
+
async setStatus(status) {
|
|
176
|
+
this.manifest.status = status;
|
|
177
|
+
await writeManifest(this.dir, this.manifest);
|
|
178
|
+
}
|
|
179
|
+
async finish(status = "completed") {
|
|
180
|
+
await this.setStatus(status);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
async function createRun(projectDir, slug, opts = {}) {
|
|
184
|
+
assertValidSlug(slug);
|
|
185
|
+
const now = opts.now ?? /* @__PURE__ */ new Date();
|
|
186
|
+
const baseName = `${formatTimestamp(now)}-${slug}`;
|
|
187
|
+
const parent = runsDir(projectDir);
|
|
188
|
+
await ensureDir(parent);
|
|
189
|
+
let runDir;
|
|
190
|
+
let runId = baseName;
|
|
191
|
+
for (let attempt = 1; runDir === void 0; attempt += 1) {
|
|
192
|
+
runId = attempt === 1 ? baseName : `${baseName}-${attempt}`;
|
|
193
|
+
const candidate = path3.join(parent, runId);
|
|
194
|
+
try {
|
|
195
|
+
await fs3.promises.mkdir(candidate);
|
|
196
|
+
runDir = candidate;
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (error.code !== "EEXIST") {
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
await ensureDir(path3.join(runDir, "screenshots"));
|
|
204
|
+
await ensureDir(path3.join(runDir, "logs"));
|
|
205
|
+
const manifest = {
|
|
206
|
+
runId,
|
|
207
|
+
slug,
|
|
208
|
+
createdAt: now.toISOString(),
|
|
209
|
+
status: "running",
|
|
210
|
+
artifacts: []
|
|
211
|
+
};
|
|
212
|
+
if (opts.sessionId !== void 0) manifest.sessionId = opts.sessionId;
|
|
213
|
+
if (opts.meta !== void 0) manifest.meta = opts.meta;
|
|
214
|
+
await writeManifest(runDir, manifest);
|
|
215
|
+
return new RunHandle(runDir, manifest);
|
|
216
|
+
}
|
|
217
|
+
async function listRuns(projectDir) {
|
|
218
|
+
const parent = runsDir(projectDir);
|
|
219
|
+
try {
|
|
220
|
+
const realProject = await fs3.promises.realpath(projectDir);
|
|
221
|
+
const realParent = await fs3.promises.realpath(parent);
|
|
222
|
+
if (realParent !== path3.join(realProject, ".picklab", "runs")) {
|
|
223
|
+
return [];
|
|
224
|
+
}
|
|
225
|
+
} catch (error) {
|
|
226
|
+
if (error.code === "ENOENT") {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
let entries;
|
|
232
|
+
try {
|
|
233
|
+
entries = await fs3.promises.readdir(parent, { withFileTypes: true });
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (error.code === "ENOENT") {
|
|
236
|
+
return [];
|
|
237
|
+
}
|
|
238
|
+
throw error;
|
|
239
|
+
}
|
|
240
|
+
const manifests = [];
|
|
241
|
+
for (const entry of entries) {
|
|
242
|
+
if (entry.isSymbolicLink() || !entry.isDirectory()) continue;
|
|
243
|
+
const manifestPath = path3.join(parent, entry.name, "manifest.json");
|
|
244
|
+
try {
|
|
245
|
+
const manifestStat = await fs3.promises.lstat(manifestPath);
|
|
246
|
+
if (manifestStat.isSymbolicLink()) continue;
|
|
247
|
+
const raw = await fs3.promises.readFile(manifestPath, "utf8");
|
|
248
|
+
const parsed = JSON.parse(raw);
|
|
249
|
+
if (typeof parsed === "object" && parsed !== null && typeof parsed.runId === "string" && typeof parsed.createdAt === "string" && Array.isArray(parsed.artifacts)) {
|
|
250
|
+
manifests.push(parsed);
|
|
251
|
+
}
|
|
252
|
+
} catch {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
manifests.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
257
|
+
return manifests;
|
|
258
|
+
}
|
|
259
|
+
var REPLACEMENT = "[REDACTED]";
|
|
260
|
+
var JSON_FIELD_RE = /("[^"\r\n]*(?:token|secret|password|passwd|api[_-]?key|authorization|bearer|credential)[^"\r\n]*"\s*:\s*)"(?:[^"\\]|\\.)*"/gi;
|
|
261
|
+
var ASSIGNMENT_RE = /(\b[A-Za-z0-9_.-]*(?:token|secret|password|passwd|api[_-]?key|authorization|bearer|credential)[A-Za-z0-9_.-]*\s*[=:]\s*)("(?:[^"\\\r\n]|\\.)*"|'(?:[^'\\\r\n]|\\.)*'|(?:Bearer\s+)?(?:<[^<>\r\n]*>|[^\s\r\n"'<>]+(?:<[^<>\r\n]*>[^\s\r\n"'<>]*)*))/gi;
|
|
262
|
+
var BEARER_VALUE_RE = /(\bBearer\s+)(?:<[^<>\r\n]*>|[^\s\r\n"'<>]+)/gi;
|
|
263
|
+
var LITERAL_RES = [
|
|
264
|
+
/ghp_[A-Za-z0-9]{36}/g,
|
|
265
|
+
/\bsk-[A-Za-z0-9_-]{20,}\b/g,
|
|
266
|
+
/\bAKIA[0-9A-Z]{16}\b/g
|
|
267
|
+
];
|
|
268
|
+
function redactSecrets(text) {
|
|
269
|
+
let result = text.replace(JSON_FIELD_RE, `$1"${REPLACEMENT}"`);
|
|
270
|
+
result = result.replace(ASSIGNMENT_RE, (_match, prefix, value) => {
|
|
271
|
+
const quote = value[0];
|
|
272
|
+
if (quote === '"' || quote === "'") {
|
|
273
|
+
return `${prefix}${quote}${REPLACEMENT}${quote}`;
|
|
274
|
+
}
|
|
275
|
+
return `${prefix}${REPLACEMENT}`;
|
|
276
|
+
});
|
|
277
|
+
result = result.replace(BEARER_VALUE_RE, `$1${REPLACEMENT}`);
|
|
278
|
+
for (const re of LITERAL_RES) {
|
|
279
|
+
result = result.replace(re, REPLACEMENT);
|
|
280
|
+
}
|
|
281
|
+
return result;
|
|
282
|
+
}
|
|
283
|
+
var DEFAULT_MAX_OUTPUT_BYTES = 10 * 1024 * 1024;
|
|
284
|
+
var DEFAULT_KILL_GRACE_MS = 2e3;
|
|
285
|
+
var POLL_INTERVAL_MS = 50;
|
|
286
|
+
var CommandError = class extends Error {
|
|
287
|
+
result;
|
|
288
|
+
constructor(cmd, args, result) {
|
|
289
|
+
const reason = result.code !== null ? `exited with code ${result.code}` : `killed with signal ${result.signal ?? "unknown"}`;
|
|
290
|
+
super(
|
|
291
|
+
`Command failed (${reason}${result.timedOut ? ", timed out" : ""}): ${cmd} ${args.join(" ")}`
|
|
292
|
+
);
|
|
293
|
+
this.name = "CommandError";
|
|
294
|
+
this.result = result;
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
function resolveEnv(opts) {
|
|
298
|
+
if (opts.cleanEnv) {
|
|
299
|
+
return opts.env ?? {};
|
|
300
|
+
}
|
|
301
|
+
return { ...process.env, ...opts.env };
|
|
302
|
+
}
|
|
303
|
+
function runCommand(cmd, args, opts = {}) {
|
|
304
|
+
return new Promise((resolve, reject) => {
|
|
305
|
+
const maxBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
306
|
+
const killGraceMs = opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
|
|
307
|
+
const child = spawn(cmd, args, {
|
|
308
|
+
cwd: opts.cwd,
|
|
309
|
+
env: resolveEnv(opts),
|
|
310
|
+
shell: false,
|
|
311
|
+
detached: true,
|
|
312
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
313
|
+
});
|
|
314
|
+
const stdoutChunks = [];
|
|
315
|
+
const stderrChunks = [];
|
|
316
|
+
let stdoutBytes = 0;
|
|
317
|
+
let stderrBytes = 0;
|
|
318
|
+
let timedOut = false;
|
|
319
|
+
let settled = false;
|
|
320
|
+
let exited = false;
|
|
321
|
+
let exitCode = null;
|
|
322
|
+
let exitSignal = null;
|
|
323
|
+
const timers = [];
|
|
324
|
+
const collect = (chunks, counted, chunk) => {
|
|
325
|
+
if (counted >= maxBytes) return counted + chunk.length;
|
|
326
|
+
const remaining = maxBytes - counted;
|
|
327
|
+
chunks.push(chunk.length > remaining ? chunk.subarray(0, remaining) : chunk);
|
|
328
|
+
return counted + chunk.length;
|
|
329
|
+
};
|
|
330
|
+
child.stdout.on("data", (chunk) => {
|
|
331
|
+
stdoutBytes = collect(stdoutChunks, stdoutBytes, chunk);
|
|
332
|
+
});
|
|
333
|
+
child.stderr.on("data", (chunk) => {
|
|
334
|
+
stderrBytes = collect(stderrChunks, stderrBytes, chunk);
|
|
335
|
+
});
|
|
336
|
+
const buildResult = (code, signal) => {
|
|
337
|
+
const stdoutBuffer = Buffer.concat(stdoutChunks);
|
|
338
|
+
const result = {
|
|
339
|
+
ok: code === 0 && !timedOut,
|
|
340
|
+
code,
|
|
341
|
+
signal,
|
|
342
|
+
stdout: opts.binary ? "" : stdoutBuffer.toString("utf8"),
|
|
343
|
+
stderr: Buffer.concat(stderrChunks).toString("utf8"),
|
|
344
|
+
timedOut,
|
|
345
|
+
stdoutTruncated: stdoutBytes > maxBytes,
|
|
346
|
+
stderrTruncated: stderrBytes > maxBytes
|
|
347
|
+
};
|
|
348
|
+
if (opts.binary) {
|
|
349
|
+
result.stdoutBuffer = stdoutBuffer;
|
|
350
|
+
}
|
|
351
|
+
return result;
|
|
352
|
+
};
|
|
353
|
+
const settle = (result) => {
|
|
354
|
+
if (settled) return;
|
|
355
|
+
settled = true;
|
|
356
|
+
for (const timer of timers) clearTimeout(timer);
|
|
357
|
+
if (opts.check && !result.ok) {
|
|
358
|
+
reject(new CommandError(cmd, args, result));
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
resolve(result);
|
|
362
|
+
};
|
|
363
|
+
const killTree = (signal) => {
|
|
364
|
+
if (child.pid !== void 0) {
|
|
365
|
+
try {
|
|
366
|
+
process.kill(-child.pid, signal);
|
|
367
|
+
return;
|
|
368
|
+
} catch {
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
try {
|
|
372
|
+
child.kill(signal);
|
|
373
|
+
} catch {
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
if (opts.timeoutMs !== void 0) {
|
|
377
|
+
timers.push(
|
|
378
|
+
setTimeout(() => {
|
|
379
|
+
timedOut = true;
|
|
380
|
+
killTree("SIGTERM");
|
|
381
|
+
timers.push(
|
|
382
|
+
setTimeout(() => {
|
|
383
|
+
killTree("SIGKILL");
|
|
384
|
+
timers.push(
|
|
385
|
+
setTimeout(() => {
|
|
386
|
+
child.stdout.destroy();
|
|
387
|
+
child.stderr.destroy();
|
|
388
|
+
child.stdin.destroy();
|
|
389
|
+
settle(
|
|
390
|
+
buildResult(exitCode, exited ? exitSignal : "SIGKILL")
|
|
391
|
+
);
|
|
392
|
+
}, killGraceMs)
|
|
393
|
+
);
|
|
394
|
+
}, killGraceMs)
|
|
395
|
+
);
|
|
396
|
+
}, opts.timeoutMs)
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
child.on("error", (error) => {
|
|
400
|
+
if (settled) return;
|
|
401
|
+
settled = true;
|
|
402
|
+
for (const timer of timers) clearTimeout(timer);
|
|
403
|
+
reject(error);
|
|
404
|
+
});
|
|
405
|
+
child.on("exit", (code, signal) => {
|
|
406
|
+
exited = true;
|
|
407
|
+
exitCode = code;
|
|
408
|
+
exitSignal = signal;
|
|
409
|
+
});
|
|
410
|
+
child.on("close", (code, signal) => {
|
|
411
|
+
settle(buildResult(code, signal));
|
|
412
|
+
});
|
|
413
|
+
child.stdin.on("error", () => {
|
|
414
|
+
});
|
|
415
|
+
if (opts.input !== void 0) {
|
|
416
|
+
child.stdin.write(opts.input);
|
|
417
|
+
}
|
|
418
|
+
child.stdin.end();
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
async function startDaemon(cmd, args, opts) {
|
|
422
|
+
await fs4.promises.mkdir(opts.logDir, { recursive: true });
|
|
423
|
+
const name = opts.name ?? path4.basename(cmd);
|
|
424
|
+
const logPath = path4.join(opts.logDir, `${name}.log`);
|
|
425
|
+
const fd = fs4.openSync(logPath, "a");
|
|
426
|
+
let child;
|
|
427
|
+
try {
|
|
428
|
+
child = spawn(cmd, args, {
|
|
429
|
+
cwd: opts.cwd,
|
|
430
|
+
env: resolveEnv(opts),
|
|
431
|
+
shell: false,
|
|
432
|
+
detached: true,
|
|
433
|
+
stdio: ["ignore", fd, fd]
|
|
434
|
+
});
|
|
435
|
+
} catch (error) {
|
|
436
|
+
fs4.closeSync(fd);
|
|
437
|
+
throw error;
|
|
438
|
+
}
|
|
439
|
+
return new Promise((resolve, reject) => {
|
|
440
|
+
const onSpawn = () => {
|
|
441
|
+
cleanup();
|
|
442
|
+
if (child.pid === void 0) {
|
|
443
|
+
reject(new Error(`Failed to start daemon: ${cmd}`));
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
child.unref();
|
|
447
|
+
resolve({ pid: child.pid, logPath });
|
|
448
|
+
};
|
|
449
|
+
const onError = (error) => {
|
|
450
|
+
cleanup();
|
|
451
|
+
reject(error);
|
|
452
|
+
};
|
|
453
|
+
const cleanup = () => {
|
|
454
|
+
child.off("spawn", onSpawn);
|
|
455
|
+
child.off("error", onError);
|
|
456
|
+
fs4.closeSync(fd);
|
|
457
|
+
};
|
|
458
|
+
child.once("spawn", onSpawn);
|
|
459
|
+
child.once("error", onError);
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
function isPidAlive(pid) {
|
|
463
|
+
try {
|
|
464
|
+
process.kill(pid, 0);
|
|
465
|
+
return true;
|
|
466
|
+
} catch (error) {
|
|
467
|
+
return error.code === "EPERM";
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
function sleep(ms) {
|
|
471
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
472
|
+
}
|
|
473
|
+
function signalPid(pid, signal) {
|
|
474
|
+
try {
|
|
475
|
+
process.kill(pid, signal);
|
|
476
|
+
} catch (error) {
|
|
477
|
+
if (error.code !== "ESRCH") {
|
|
478
|
+
throw error;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
async function stopPid(pid, opts = {}) {
|
|
483
|
+
const timeoutMs = opts.timeoutMs ?? 5e3;
|
|
484
|
+
if (!isPidAlive(pid)) return true;
|
|
485
|
+
signalPid(pid, "SIGTERM");
|
|
486
|
+
const deadline = Date.now() + timeoutMs;
|
|
487
|
+
while (Date.now() < deadline) {
|
|
488
|
+
if (!isPidAlive(pid)) return true;
|
|
489
|
+
await sleep(POLL_INTERVAL_MS);
|
|
490
|
+
}
|
|
491
|
+
signalPid(pid, "SIGKILL");
|
|
492
|
+
const killDeadline = Date.now() + 1e3;
|
|
493
|
+
while (Date.now() < killDeadline) {
|
|
494
|
+
if (!isPidAlive(pid)) return true;
|
|
495
|
+
await sleep(POLL_INTERVAL_MS);
|
|
496
|
+
}
|
|
497
|
+
return !isPidAlive(pid);
|
|
498
|
+
}
|
|
499
|
+
var ID_PREFIXES = {
|
|
500
|
+
desktop: "desk",
|
|
501
|
+
android: "andr",
|
|
502
|
+
"desktop+android": "duo"
|
|
503
|
+
};
|
|
504
|
+
var SESSION_ID_PATTERN = /^(desk|andr|duo)-[0-9a-f]{6,}$/;
|
|
505
|
+
var MAX_ID_ATTEMPTS = 5;
|
|
506
|
+
var tmpCounter3 = 0;
|
|
507
|
+
function isValidSessionId(id) {
|
|
508
|
+
return SESSION_ID_PATTERN.test(id);
|
|
509
|
+
}
|
|
510
|
+
function invalidSessionIdError(id) {
|
|
511
|
+
return new Error(
|
|
512
|
+
`Invalid session id "${id}": must match ${SESSION_ID_PATTERN}`
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
function newSessionId(type) {
|
|
516
|
+
return `${ID_PREFIXES[type]}-${randomBytes(4).toString("hex")}`;
|
|
517
|
+
}
|
|
518
|
+
function sessionPath(id, env) {
|
|
519
|
+
return path5.join(sessionsDir(env), `${id}.json`);
|
|
520
|
+
}
|
|
521
|
+
function serialize(record) {
|
|
522
|
+
return `${JSON.stringify(record, null, 2)}
|
|
523
|
+
`;
|
|
524
|
+
}
|
|
525
|
+
async function writeSession(record, env) {
|
|
526
|
+
const dir = await ensureDir(sessionsDir(env));
|
|
527
|
+
const target = path5.join(dir, `${record.id}.json`);
|
|
528
|
+
tmpCounter3 += 1;
|
|
529
|
+
const tmp = path5.join(
|
|
530
|
+
dir,
|
|
531
|
+
`.${record.id}.json.tmp-${process.pid}-${tmpCounter3}`
|
|
532
|
+
);
|
|
533
|
+
await fs5.promises.writeFile(tmp, serialize(record), "utf8");
|
|
534
|
+
await fs5.promises.rename(tmp, target);
|
|
535
|
+
}
|
|
536
|
+
async function createSession(input, env = process.env) {
|
|
537
|
+
await ensureDir(sessionsDir(env));
|
|
538
|
+
for (let attempt = 0; attempt < MAX_ID_ATTEMPTS; attempt += 1) {
|
|
539
|
+
const record = {
|
|
540
|
+
id: newSessionId(input.type),
|
|
541
|
+
type: input.type,
|
|
542
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
543
|
+
status: input.status ?? "starting",
|
|
544
|
+
projectDir: input.projectDir
|
|
545
|
+
};
|
|
546
|
+
if (input.desktop !== void 0) record.desktop = input.desktop;
|
|
547
|
+
if (input.android !== void 0) record.android = input.android;
|
|
548
|
+
if (input.meta !== void 0) record.meta = input.meta;
|
|
549
|
+
try {
|
|
550
|
+
await fs5.promises.writeFile(sessionPath(record.id, env), serialize(record), {
|
|
551
|
+
encoding: "utf8",
|
|
552
|
+
flag: "wx"
|
|
553
|
+
});
|
|
554
|
+
return record;
|
|
555
|
+
} catch (error) {
|
|
556
|
+
if (error.code !== "EEXIST") {
|
|
557
|
+
throw error;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
throw new Error(
|
|
562
|
+
`Failed to allocate a unique session id after ${MAX_ID_ATTEMPTS} attempts`
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
async function getSession(id, env = process.env) {
|
|
566
|
+
if (!isValidSessionId(id)) {
|
|
567
|
+
return void 0;
|
|
568
|
+
}
|
|
569
|
+
const filePath = sessionPath(id, env);
|
|
570
|
+
let raw;
|
|
571
|
+
try {
|
|
572
|
+
raw = await fs5.promises.readFile(filePath, "utf8");
|
|
573
|
+
} catch (error) {
|
|
574
|
+
const code = error.code;
|
|
575
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
576
|
+
return void 0;
|
|
577
|
+
}
|
|
578
|
+
throw error;
|
|
579
|
+
}
|
|
580
|
+
try {
|
|
581
|
+
return JSON.parse(raw);
|
|
582
|
+
} catch (error) {
|
|
583
|
+
throw new Error(
|
|
584
|
+
`Invalid session record at ${filePath}: ${error.message}`
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
async function listSessions(env = process.env) {
|
|
589
|
+
let entries;
|
|
590
|
+
try {
|
|
591
|
+
entries = await fs5.promises.readdir(sessionsDir(env));
|
|
592
|
+
} catch (error) {
|
|
593
|
+
if (error.code === "ENOENT") {
|
|
594
|
+
return [];
|
|
595
|
+
}
|
|
596
|
+
throw error;
|
|
597
|
+
}
|
|
598
|
+
const records = [];
|
|
599
|
+
for (const entry of entries) {
|
|
600
|
+
if (!entry.endsWith(".json") || entry.startsWith(".")) continue;
|
|
601
|
+
let record;
|
|
602
|
+
try {
|
|
603
|
+
record = await getSession(entry.slice(0, -".json".length), env);
|
|
604
|
+
} catch {
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
if (record !== void 0) records.push(record);
|
|
608
|
+
}
|
|
609
|
+
records.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
610
|
+
return records;
|
|
611
|
+
}
|
|
612
|
+
async function updateSession(id, patch, env = process.env) {
|
|
613
|
+
if (!isValidSessionId(id)) {
|
|
614
|
+
throw invalidSessionIdError(id);
|
|
615
|
+
}
|
|
616
|
+
const existing = await getSession(id, env);
|
|
617
|
+
if (existing === void 0) {
|
|
618
|
+
throw new Error(`Session not found: ${id}`);
|
|
619
|
+
}
|
|
620
|
+
const updated = {
|
|
621
|
+
...existing,
|
|
622
|
+
...patch,
|
|
623
|
+
id: existing.id,
|
|
624
|
+
type: existing.type,
|
|
625
|
+
createdAt: existing.createdAt
|
|
626
|
+
};
|
|
627
|
+
await writeSession(updated, env);
|
|
628
|
+
return updated;
|
|
629
|
+
}
|
|
630
|
+
async function destroySessionRecord(id, env = process.env) {
|
|
631
|
+
if (!isValidSessionId(id)) {
|
|
632
|
+
throw invalidSessionIdError(id);
|
|
633
|
+
}
|
|
634
|
+
await fs5.promises.rm(sessionPath(id, env), { force: true });
|
|
635
|
+
}
|
|
636
|
+
async function resolveRunnableSession(type, id, opts) {
|
|
637
|
+
const env = opts.env ?? process.env;
|
|
638
|
+
if (id !== void 0) {
|
|
639
|
+
const record = await getSession(id, env);
|
|
640
|
+
if (record === void 0) {
|
|
641
|
+
throw new Error(`Session not found: ${id}`);
|
|
642
|
+
}
|
|
643
|
+
if (record.type !== type) {
|
|
644
|
+
throw new Error(
|
|
645
|
+
`Session ${id} is of type "${record.type}", but this ${opts.consumerLabel} needs a ${type} session`
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
return record;
|
|
649
|
+
}
|
|
650
|
+
let candidates = (await listSessions(env)).filter(
|
|
651
|
+
(record) => record.type === type && record.status === "running"
|
|
652
|
+
);
|
|
653
|
+
let scopeLabel = "found";
|
|
654
|
+
if (opts.projectDir !== void 0) {
|
|
655
|
+
const projectDir = path6.resolve(opts.projectDir);
|
|
656
|
+
candidates = candidates.filter(
|
|
657
|
+
(record) => record.projectDir === projectDir
|
|
658
|
+
);
|
|
659
|
+
scopeLabel = "for this project";
|
|
660
|
+
}
|
|
661
|
+
if (candidates.length === 0) {
|
|
662
|
+
throw new Error(
|
|
663
|
+
`No running ${type} session ${scopeLabel}; ${opts.createHint}`
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
if (candidates.length > 1) {
|
|
667
|
+
throw new Error(
|
|
668
|
+
`Multiple running ${type} sessions ${scopeLabel} (${candidates.map((record) => record.id).join(", ")}); ` + opts.selectHint
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
return candidates[0];
|
|
672
|
+
}
|
|
673
|
+
function requireDisplay(record) {
|
|
674
|
+
const display = record.desktop?.display;
|
|
675
|
+
if (display === void 0) {
|
|
676
|
+
throw new Error(`Session ${record.id} has no display recorded`);
|
|
677
|
+
}
|
|
678
|
+
return display;
|
|
679
|
+
}
|
|
680
|
+
async function realpathNearest(target) {
|
|
681
|
+
let probe = target;
|
|
682
|
+
while (true) {
|
|
683
|
+
try {
|
|
684
|
+
const real = await realpath(probe);
|
|
685
|
+
if (probe === target) {
|
|
686
|
+
return real;
|
|
687
|
+
}
|
|
688
|
+
return path6.join(real, path6.relative(probe, target));
|
|
689
|
+
} catch {
|
|
690
|
+
const parent = path6.dirname(probe);
|
|
691
|
+
if (parent === probe) {
|
|
692
|
+
return target;
|
|
693
|
+
}
|
|
694
|
+
probe = parent;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
async function resolveScreenshotTarget(opts) {
|
|
699
|
+
if (opts.out !== void 0 && opts.runSlug !== void 0) {
|
|
700
|
+
throw new Error(opts.conflictError);
|
|
701
|
+
}
|
|
702
|
+
if (opts.out !== void 0) {
|
|
703
|
+
if (opts.outBaseDir === void 0) {
|
|
704
|
+
return { outPath: path6.resolve(opts.out) };
|
|
705
|
+
}
|
|
706
|
+
const base = path6.resolve(opts.outBaseDir);
|
|
707
|
+
const outPath = path6.resolve(base, opts.out);
|
|
708
|
+
const relative = path6.relative(base, outPath);
|
|
709
|
+
if (relative === "" || relative.startsWith("..") || path6.isAbsolute(relative)) {
|
|
710
|
+
throw new Error(
|
|
711
|
+
`Refusing to write screenshot outside the project directory: ${opts.out}`
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
try {
|
|
715
|
+
const outStat = await lstat(outPath);
|
|
716
|
+
if (outStat.isSymbolicLink()) {
|
|
717
|
+
throw new Error(
|
|
718
|
+
`Refusing to write screenshot outside the project directory: ${opts.out}`
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
} catch (error) {
|
|
722
|
+
if (error.code !== "ENOENT") {
|
|
723
|
+
throw error;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
const realBase = await realpathNearest(base);
|
|
727
|
+
const realProbe = await realpathNearest(outPath);
|
|
728
|
+
const realRelative = path6.relative(realBase, realProbe);
|
|
729
|
+
if (realProbe !== realBase && (realRelative.startsWith("..") || path6.isAbsolute(realRelative))) {
|
|
730
|
+
throw new Error(
|
|
731
|
+
`Refusing to write screenshot outside the project directory: ${opts.out}`
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
return { outPath };
|
|
735
|
+
}
|
|
736
|
+
const run = await createRun(
|
|
737
|
+
opts.projectDir,
|
|
738
|
+
opts.runSlug ?? opts.defaultSlug,
|
|
739
|
+
opts.sessionId === void 0 ? {} : { sessionId: opts.sessionId }
|
|
740
|
+
);
|
|
741
|
+
return {
|
|
742
|
+
outPath: path6.join(run.dir, "screenshots", "screenshot.png"),
|
|
743
|
+
run
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
async function captureToTarget(target, capture) {
|
|
747
|
+
try {
|
|
748
|
+
await capture();
|
|
749
|
+
} catch (error) {
|
|
750
|
+
if (target.run !== void 0) {
|
|
751
|
+
await target.run.finish("failed").catch(() => {
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
throw error;
|
|
755
|
+
}
|
|
756
|
+
const data = { path: target.outPath };
|
|
757
|
+
if (target.run !== void 0) {
|
|
758
|
+
try {
|
|
759
|
+
await target.run.addArtifact(
|
|
760
|
+
"screenshot",
|
|
761
|
+
path6.basename(target.outPath),
|
|
762
|
+
target.outPath
|
|
763
|
+
);
|
|
764
|
+
await target.run.finish("completed");
|
|
765
|
+
} catch (error) {
|
|
766
|
+
await target.run.finish("failed").catch(() => {
|
|
767
|
+
});
|
|
768
|
+
throw error;
|
|
769
|
+
}
|
|
770
|
+
data.runId = target.run.runId;
|
|
771
|
+
data.runDir = target.run.dir;
|
|
772
|
+
}
|
|
773
|
+
return data;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// ../mcp-server/dist/index.js
|
|
777
|
+
import { z } from "zod";
|
|
778
|
+
import fs24 from "fs";
|
|
779
|
+
import path34 from "path";
|
|
780
|
+
import {
|
|
781
|
+
ResourceTemplate
|
|
782
|
+
} from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
783
|
+
import path24 from "path";
|
|
784
|
+
import { z as z2 } from "zod";
|
|
785
|
+
import { z as z3 } from "zod";
|
|
786
|
+
|
|
787
|
+
// ../android/dist/index.js
|
|
788
|
+
import fs22 from "fs";
|
|
789
|
+
import os2 from "os";
|
|
790
|
+
import path22 from "path";
|
|
791
|
+
import fs6 from "fs";
|
|
792
|
+
import path7 from "path";
|
|
793
|
+
import fs32 from "fs";
|
|
794
|
+
import os22 from "os";
|
|
795
|
+
import path32 from "path";
|
|
796
|
+
import fs52 from "fs";
|
|
797
|
+
import path52 from "path";
|
|
798
|
+
import fs42 from "fs";
|
|
799
|
+
import path42 from "path";
|
|
800
|
+
import path62 from "path";
|
|
801
|
+
function sleep2(ms) {
|
|
802
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
803
|
+
}
|
|
804
|
+
function findOnPath(name, env = process.env) {
|
|
805
|
+
const dirs = (env.PATH ?? "").split(path7.delimiter).filter((d) => d !== "");
|
|
806
|
+
for (const dir of dirs) {
|
|
807
|
+
const candidate = path7.join(dir, name);
|
|
808
|
+
if (isExecutableFile(candidate)) {
|
|
809
|
+
return candidate;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
return null;
|
|
813
|
+
}
|
|
814
|
+
function isExecutableFile(filePath) {
|
|
815
|
+
try {
|
|
816
|
+
fs6.accessSync(filePath, fs6.constants.X_OK);
|
|
817
|
+
return fs6.statSync(filePath).isFile();
|
|
818
|
+
} catch {
|
|
819
|
+
return false;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
var SYSTEM_IMAGE_ID_PATTERN = /^system-images;[^;\s]+;[^;\s]+;[^;\s]+$/;
|
|
823
|
+
var CMDLINE_TOOLS_VERSION_PATTERN = /^\d+(\.\d+)*$/;
|
|
824
|
+
function commonSdkPaths(homeDir = os2.homedir()) {
|
|
825
|
+
return [
|
|
826
|
+
path22.join(homeDir, "Android", "Sdk"),
|
|
827
|
+
path22.join(homeDir, "Library", "Android", "sdk"),
|
|
828
|
+
path22.join("/opt", "android-sdk")
|
|
829
|
+
];
|
|
830
|
+
}
|
|
831
|
+
function isDirectory(dir) {
|
|
832
|
+
try {
|
|
833
|
+
return fs22.statSync(dir).isDirectory();
|
|
834
|
+
} catch {
|
|
835
|
+
return false;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
function detectSdkRoot(opts = {}) {
|
|
839
|
+
const env = opts.env ?? process.env;
|
|
840
|
+
for (const key of ["ANDROID_HOME", "ANDROID_SDK_ROOT"]) {
|
|
841
|
+
const candidate = env[key];
|
|
842
|
+
if (candidate !== void 0 && candidate !== "" && isDirectory(candidate)) {
|
|
843
|
+
return candidate;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
const candidates = opts.commonPaths ?? commonSdkPaths(opts.homeDir);
|
|
847
|
+
for (const candidate of candidates) {
|
|
848
|
+
if (isDirectory(candidate)) {
|
|
849
|
+
return candidate;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
return null;
|
|
853
|
+
}
|
|
854
|
+
function missingSdkMessage() {
|
|
855
|
+
return `Android SDK not found. Set ANDROID_HOME (or ANDROID_SDK_ROOT) to your SDK directory, or install it to one of: ${commonSdkPaths().join(", ")}. See https://developer.android.com/studio#command-line for command-line tools.`;
|
|
856
|
+
}
|
|
857
|
+
function resolveSdkRoot(sdk, env = process.env) {
|
|
858
|
+
return sdk === void 0 ? detectSdkRoot({ env }) : sdk;
|
|
859
|
+
}
|
|
860
|
+
function compareVersionsDesc(a, b) {
|
|
861
|
+
const left = a.split(".").map(Number);
|
|
862
|
+
const right = b.split(".").map(Number);
|
|
863
|
+
const length = Math.max(left.length, right.length);
|
|
864
|
+
for (let i = 0; i < length; i += 1) {
|
|
865
|
+
const diff = (right[i] ?? 0) - (left[i] ?? 0);
|
|
866
|
+
if (diff !== 0) {
|
|
867
|
+
return diff;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
return 0;
|
|
871
|
+
}
|
|
872
|
+
function cmdlineToolsBinDirs(sdk) {
|
|
873
|
+
const root = path22.join(sdk, "cmdline-tools");
|
|
874
|
+
const dirs = [path22.join(root, "latest", "bin")];
|
|
875
|
+
const versioned = listSubdirs(root).filter((name) => CMDLINE_TOOLS_VERSION_PATTERN.test(name)).sort(compareVersionsDesc);
|
|
876
|
+
for (const version2 of versioned) {
|
|
877
|
+
dirs.push(path22.join(root, version2, "bin"));
|
|
878
|
+
}
|
|
879
|
+
dirs.push(path22.join(root, "bin"));
|
|
880
|
+
return dirs;
|
|
881
|
+
}
|
|
882
|
+
function toolCandidateDirs(sdk, tool) {
|
|
883
|
+
if (tool === "emulator") {
|
|
884
|
+
return [path22.join(sdk, "emulator")];
|
|
885
|
+
}
|
|
886
|
+
return [...cmdlineToolsBinDirs(sdk), path22.join(sdk, "tools", "bin")];
|
|
887
|
+
}
|
|
888
|
+
function findSdkTool(sdk, tool, env = process.env) {
|
|
889
|
+
const root = resolveSdkRoot(sdk, env);
|
|
890
|
+
if (tool === "adb") {
|
|
891
|
+
if (root !== null) {
|
|
892
|
+
const candidate = path22.join(root, "platform-tools", "adb");
|
|
893
|
+
if (isExecutableFile(candidate)) {
|
|
894
|
+
return candidate;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
return findOnPath("adb", env);
|
|
898
|
+
}
|
|
899
|
+
if (root === null) {
|
|
900
|
+
return findOnPath(tool, env);
|
|
901
|
+
}
|
|
902
|
+
for (const dir of toolCandidateDirs(root, tool)) {
|
|
903
|
+
const candidate = path22.join(dir, tool);
|
|
904
|
+
if (isExecutableFile(candidate)) {
|
|
905
|
+
return candidate;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
return findOnPath(tool, env);
|
|
909
|
+
}
|
|
910
|
+
function detectSdkTools(opts = {}) {
|
|
911
|
+
const env = opts.env ?? process.env;
|
|
912
|
+
const sdk = resolveSdkRoot(opts.sdk, env);
|
|
913
|
+
return {
|
|
914
|
+
sdkmanager: findSdkTool(sdk, "sdkmanager", env),
|
|
915
|
+
avdmanager: findSdkTool(sdk, "avdmanager", env),
|
|
916
|
+
emulator: findSdkTool(sdk, "emulator", env),
|
|
917
|
+
adb: findSdkTool(sdk, "adb", env)
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
function listSubdirs(dir) {
|
|
921
|
+
let entries;
|
|
922
|
+
try {
|
|
923
|
+
entries = fs22.readdirSync(dir, { withFileTypes: true });
|
|
924
|
+
} catch {
|
|
925
|
+
return [];
|
|
926
|
+
}
|
|
927
|
+
const names = [];
|
|
928
|
+
for (const entry of entries) {
|
|
929
|
+
if (entry.isDirectory()) {
|
|
930
|
+
names.push(entry.name);
|
|
931
|
+
} else if (entry.isSymbolicLink() && isDirectory(path22.join(dir, entry.name))) {
|
|
932
|
+
names.push(entry.name);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
return names;
|
|
936
|
+
}
|
|
937
|
+
function listSystemImages(sdk) {
|
|
938
|
+
const root = path22.join(sdk, "system-images");
|
|
939
|
+
const images = [];
|
|
940
|
+
for (const api of listSubdirs(root)) {
|
|
941
|
+
for (const tag of listSubdirs(path22.join(root, api))) {
|
|
942
|
+
for (const abi of listSubdirs(path22.join(root, api, tag))) {
|
|
943
|
+
images.push({
|
|
944
|
+
packageId: `system-images;${api};${tag};${abi}`,
|
|
945
|
+
api,
|
|
946
|
+
tag,
|
|
947
|
+
abi,
|
|
948
|
+
path: path22.join(root, api, tag, abi)
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
images.sort((a, b) => a.packageId.localeCompare(b.packageId));
|
|
954
|
+
return images;
|
|
955
|
+
}
|
|
956
|
+
function isValidSystemImageId(packageId) {
|
|
957
|
+
return SYSTEM_IMAGE_ID_PATTERN.test(packageId);
|
|
958
|
+
}
|
|
959
|
+
function assertSystemImageId(packageId) {
|
|
960
|
+
if (!isValidSystemImageId(packageId)) {
|
|
961
|
+
throw new Error(
|
|
962
|
+
`Invalid system image "${packageId}": expected the form "system-images;android-<api>;<tag>;<abi>"`
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
function sdkmanagerInstallCommand(packageId) {
|
|
967
|
+
assertSystemImageId(packageId);
|
|
968
|
+
return `sdkmanager "${packageId}"`;
|
|
969
|
+
}
|
|
970
|
+
function detectKvm(kvmPath = "/dev/kvm") {
|
|
971
|
+
const exists = fs22.existsSync(kvmPath);
|
|
972
|
+
let readable = false;
|
|
973
|
+
let writable = false;
|
|
974
|
+
if (exists) {
|
|
975
|
+
try {
|
|
976
|
+
fs22.accessSync(kvmPath, fs22.constants.R_OK);
|
|
977
|
+
readable = true;
|
|
978
|
+
} catch {
|
|
979
|
+
readable = false;
|
|
980
|
+
}
|
|
981
|
+
try {
|
|
982
|
+
fs22.accessSync(kvmPath, fs22.constants.W_OK);
|
|
983
|
+
writable = true;
|
|
984
|
+
} catch {
|
|
985
|
+
writable = false;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
return { exists, readable, writable, supported: exists && readable && writable };
|
|
989
|
+
}
|
|
990
|
+
function detectAndroidEnvironment(opts = {}) {
|
|
991
|
+
const sdkRoot = detectSdkRoot(opts);
|
|
992
|
+
return {
|
|
993
|
+
sdkRoot,
|
|
994
|
+
tools: detectSdkTools({ sdk: sdkRoot, env: opts.env }),
|
|
995
|
+
systemImages: sdkRoot === null ? [] : listSystemImages(sdkRoot),
|
|
996
|
+
kvm: detectKvm(opts.kvmPath)
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
var DEFAULT_AVD_NAME = "picklab-avd";
|
|
1000
|
+
var AVD_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
1001
|
+
var LIST_AVDS_TIMEOUT_MS = 3e4;
|
|
1002
|
+
function assertAvdName(name) {
|
|
1003
|
+
if (!AVD_NAME_PATTERN.test(name)) {
|
|
1004
|
+
throw new Error(
|
|
1005
|
+
`Invalid AVD name "${name}": expected only letters, digits, dots, underscores, and hyphens`
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
function assertDeviceProfile(device) {
|
|
1010
|
+
if (device === "" || device.startsWith("-") || /[\x00-\x1f\x7f]/.test(device)) {
|
|
1011
|
+
throw new Error(
|
|
1012
|
+
`Invalid device profile "${device}": expected a non-empty avdmanager device id or name`
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
function buildCreateAvdArgs(opts) {
|
|
1017
|
+
assertAvdName(opts.name);
|
|
1018
|
+
assertSystemImageId(opts.systemImage);
|
|
1019
|
+
const args = ["create", "avd", "-n", opts.name, "-k", opts.systemImage];
|
|
1020
|
+
if (opts.device !== void 0) {
|
|
1021
|
+
assertDeviceProfile(opts.device);
|
|
1022
|
+
args.push("--device", opts.device);
|
|
1023
|
+
}
|
|
1024
|
+
return args;
|
|
1025
|
+
}
|
|
1026
|
+
function avdHomeDir(env = process.env) {
|
|
1027
|
+
const fromEnv = env.ANDROID_AVD_HOME;
|
|
1028
|
+
if (fromEnv !== void 0 && fromEnv !== "") {
|
|
1029
|
+
return fromEnv;
|
|
1030
|
+
}
|
|
1031
|
+
const home2 = env.HOME !== void 0 && env.HOME !== "" ? env.HOME : os22.homedir();
|
|
1032
|
+
return path32.join(home2, ".android", "avd");
|
|
1033
|
+
}
|
|
1034
|
+
function parseEmulatorListAvds(output) {
|
|
1035
|
+
return output.split("\n").map((line) => line.trim()).filter((line) => AVD_NAME_PATTERN.test(line));
|
|
1036
|
+
}
|
|
1037
|
+
function scanAvdHome(env = process.env) {
|
|
1038
|
+
let entries;
|
|
1039
|
+
try {
|
|
1040
|
+
entries = fs32.readdirSync(avdHomeDir(env));
|
|
1041
|
+
} catch {
|
|
1042
|
+
return [];
|
|
1043
|
+
}
|
|
1044
|
+
return entries.filter((entry) => entry.endsWith(".ini")).map((entry) => entry.slice(0, -".ini".length)).filter((name) => AVD_NAME_PATTERN.test(name)).sort();
|
|
1045
|
+
}
|
|
1046
|
+
async function listAvds(opts = {}) {
|
|
1047
|
+
const env = opts.env ?? process.env;
|
|
1048
|
+
const emulator = findSdkTool(opts.sdk, "emulator", env);
|
|
1049
|
+
if (emulator !== null) {
|
|
1050
|
+
try {
|
|
1051
|
+
const result = await runCommand(emulator, ["-list-avds"], {
|
|
1052
|
+
env: opts.env,
|
|
1053
|
+
timeoutMs: LIST_AVDS_TIMEOUT_MS
|
|
1054
|
+
});
|
|
1055
|
+
if (result.ok) {
|
|
1056
|
+
return parseEmulatorListAvds(result.stdout);
|
|
1057
|
+
}
|
|
1058
|
+
} catch {
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
return scanAvdHome(env);
|
|
1062
|
+
}
|
|
1063
|
+
var KEYCODE_HOME = "KEYCODE_HOME";
|
|
1064
|
+
var KEYCODE_BACK = "KEYCODE_BACK";
|
|
1065
|
+
var UI_DUMP_REMOTE_PATH = "/sdcard/picklab-ui.xml";
|
|
1066
|
+
var SERIAL_PATTERN = /^[A-Za-z0-9._:-]+$/;
|
|
1067
|
+
var PACKAGE_PATTERN = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/;
|
|
1068
|
+
var ACTIVITY_PATTERN = /^\.?[A-Za-z_$][A-Za-z0-9_$]*(\.[A-Za-z_$][A-Za-z0-9_$]*)*$/;
|
|
1069
|
+
var KEYCODE_PATTERN = /^(KEYCODE_[A-Z0-9_]+|\d+)$/;
|
|
1070
|
+
var PNG_MAGIC = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
1071
|
+
var ADB_TIMEOUT_MS = 3e4;
|
|
1072
|
+
var INSTALL_TIMEOUT_MS = 3e5;
|
|
1073
|
+
var SCREENSHOT_TIMEOUT_MS = 6e4;
|
|
1074
|
+
var SCREENSHOT_MAX_BYTES = 64 * 1024 * 1024;
|
|
1075
|
+
var DEFAULT_LOGCAT_LINES = 500;
|
|
1076
|
+
var DEFAULT_UI_DUMP_ATTEMPTS = 15;
|
|
1077
|
+
var DEFAULT_UI_DUMP_RETRY_DELAY_MS = 2e3;
|
|
1078
|
+
function assertSerial(serial) {
|
|
1079
|
+
if (!SERIAL_PATTERN.test(serial)) {
|
|
1080
|
+
throw new Error(
|
|
1081
|
+
`Invalid device serial "${serial}": expected only letters, digits, dots, colons, underscores, and hyphens`
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
function assertPackageName(packageName2) {
|
|
1086
|
+
if (!PACKAGE_PATTERN.test(packageName2)) {
|
|
1087
|
+
throw new Error(
|
|
1088
|
+
`Invalid package name "${packageName2}": expected a Java package like "com.example.app"`
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
function assertActivity(activity) {
|
|
1093
|
+
if (!ACTIVITY_PATTERN.test(activity)) {
|
|
1094
|
+
throw new Error(
|
|
1095
|
+
`Invalid activity "${activity}": expected a class name like ".MainActivity" or "com.example.app.MainActivity"`
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
function assertCoordinate(value, label) {
|
|
1100
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
1101
|
+
throw new Error(
|
|
1102
|
+
`Invalid ${label} coordinate ${value}: expected a non-negative integer`
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
function resolveAdb(opts = {}) {
|
|
1107
|
+
const adb = findSdkTool(opts.sdk, "adb", opts.env ?? process.env);
|
|
1108
|
+
if (adb === null) {
|
|
1109
|
+
throw new Error(
|
|
1110
|
+
`adb not found in <sdk>/platform-tools or on PATH; install it with: sdkmanager "platform-tools" (or your distro's android-tools package)`
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
return adb;
|
|
1114
|
+
}
|
|
1115
|
+
function escapeInputText(text) {
|
|
1116
|
+
return text.replace(/[\\()<>|;&*~"'`$]/g, (c) => `\\${c}`).replace(/ /g, "%s");
|
|
1117
|
+
}
|
|
1118
|
+
function splitInputText(text) {
|
|
1119
|
+
return text.split(/(?<=%)(?=s)/);
|
|
1120
|
+
}
|
|
1121
|
+
function buildInstallApkArgs(serial, apkPath) {
|
|
1122
|
+
assertSerial(serial);
|
|
1123
|
+
if (apkPath === "") {
|
|
1124
|
+
throw new Error("Invalid apkPath: expected a non-empty path");
|
|
1125
|
+
}
|
|
1126
|
+
return ["-s", serial, "install", "-r", apkPath];
|
|
1127
|
+
}
|
|
1128
|
+
function buildLaunchAppArgs(serial, packageName2, activity) {
|
|
1129
|
+
assertSerial(serial);
|
|
1130
|
+
assertPackageName(packageName2);
|
|
1131
|
+
if (activity !== void 0) {
|
|
1132
|
+
assertActivity(activity);
|
|
1133
|
+
return [
|
|
1134
|
+
"-s",
|
|
1135
|
+
serial,
|
|
1136
|
+
"shell",
|
|
1137
|
+
"am",
|
|
1138
|
+
"start",
|
|
1139
|
+
"-n",
|
|
1140
|
+
`${packageName2}/${activity}`
|
|
1141
|
+
];
|
|
1142
|
+
}
|
|
1143
|
+
return [
|
|
1144
|
+
"-s",
|
|
1145
|
+
serial,
|
|
1146
|
+
"shell",
|
|
1147
|
+
"monkey",
|
|
1148
|
+
"-p",
|
|
1149
|
+
packageName2,
|
|
1150
|
+
"-c",
|
|
1151
|
+
"android.intent.category.LAUNCHER",
|
|
1152
|
+
"1"
|
|
1153
|
+
];
|
|
1154
|
+
}
|
|
1155
|
+
function buildScreenshotArgs(serial) {
|
|
1156
|
+
assertSerial(serial);
|
|
1157
|
+
return ["-s", serial, "exec-out", "screencap", "-p"];
|
|
1158
|
+
}
|
|
1159
|
+
function buildTapArgs(serial, x, y) {
|
|
1160
|
+
assertSerial(serial);
|
|
1161
|
+
assertCoordinate(x, "x");
|
|
1162
|
+
assertCoordinate(y, "y");
|
|
1163
|
+
return ["-s", serial, "shell", "input", "tap", String(x), String(y)];
|
|
1164
|
+
}
|
|
1165
|
+
function buildTypeTextArgs(serial, text) {
|
|
1166
|
+
assertSerial(serial);
|
|
1167
|
+
if (text === "") {
|
|
1168
|
+
throw new Error("Invalid text: expected a non-empty string");
|
|
1169
|
+
}
|
|
1170
|
+
if (/[\x00-\x1f\x7f]/.test(text)) {
|
|
1171
|
+
throw new Error(
|
|
1172
|
+
"Invalid text: control characters (including newlines) are not supported by android input text"
|
|
1173
|
+
);
|
|
1174
|
+
}
|
|
1175
|
+
if (/[^\x20-\x7e]/.test(text)) {
|
|
1176
|
+
throw new Error(
|
|
1177
|
+
'Invalid text: non-ASCII characters cannot be typed with android "input text"; use ASCII text, or set the field content through the app itself (clipboard or deep link)'
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
if (text.includes("%s")) {
|
|
1181
|
+
throw new Error(
|
|
1182
|
+
'Invalid text: the device input tool turns "%s" into a space; use typeText, which splits such text into safe chunks'
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
return ["-s", serial, "shell", "input", "text", escapeInputText(text)];
|
|
1186
|
+
}
|
|
1187
|
+
function buildKeyeventArgs(serial, key) {
|
|
1188
|
+
assertSerial(serial);
|
|
1189
|
+
if (!KEYCODE_PATTERN.test(key)) {
|
|
1190
|
+
throw new Error(
|
|
1191
|
+
`Invalid key "${key}": expected a KEYCODE_* name or a numeric keycode`
|
|
1192
|
+
);
|
|
1193
|
+
}
|
|
1194
|
+
return ["-s", serial, "shell", "input", "keyevent", key];
|
|
1195
|
+
}
|
|
1196
|
+
function buildUiDumpArgs(serial) {
|
|
1197
|
+
assertSerial(serial);
|
|
1198
|
+
return ["-s", serial, "shell", "uiautomator", "dump", UI_DUMP_REMOTE_PATH];
|
|
1199
|
+
}
|
|
1200
|
+
function buildUiCatArgs(serial) {
|
|
1201
|
+
assertSerial(serial);
|
|
1202
|
+
return ["-s", serial, "exec-out", "cat", UI_DUMP_REMOTE_PATH];
|
|
1203
|
+
}
|
|
1204
|
+
function buildUiCleanupArgs(serial) {
|
|
1205
|
+
assertSerial(serial);
|
|
1206
|
+
return ["-s", serial, "shell", "rm", "-f", UI_DUMP_REMOTE_PATH];
|
|
1207
|
+
}
|
|
1208
|
+
function buildLogcatArgs(serial, opts = {}) {
|
|
1209
|
+
assertSerial(serial);
|
|
1210
|
+
const lines = opts.lines ?? DEFAULT_LOGCAT_LINES;
|
|
1211
|
+
if (!Number.isInteger(lines) || lines <= 0) {
|
|
1212
|
+
throw new Error(`Invalid lines ${lines}: expected a positive integer`);
|
|
1213
|
+
}
|
|
1214
|
+
const args = ["-s", serial, "logcat", "-d", "-t", String(lines)];
|
|
1215
|
+
if (opts.filter !== void 0 && opts.filter.trim() !== "") {
|
|
1216
|
+
args.push(...opts.filter.trim().split(/\s+/));
|
|
1217
|
+
}
|
|
1218
|
+
return args;
|
|
1219
|
+
}
|
|
1220
|
+
function buildClearLogcatArgs(serial) {
|
|
1221
|
+
assertSerial(serial);
|
|
1222
|
+
return ["-s", serial, "logcat", "-c"];
|
|
1223
|
+
}
|
|
1224
|
+
function parseAdbDevices(output) {
|
|
1225
|
+
const devices = [];
|
|
1226
|
+
for (const line of output.split("\n")) {
|
|
1227
|
+
const trimmed = line.trim();
|
|
1228
|
+
if (trimmed === "" || trimmed.startsWith("List of devices") || trimmed.startsWith("*")) {
|
|
1229
|
+
continue;
|
|
1230
|
+
}
|
|
1231
|
+
const [serial, state] = trimmed.split(/\s+/);
|
|
1232
|
+
if (serial !== void 0 && state !== void 0 && SERIAL_PATTERN.test(serial)) {
|
|
1233
|
+
devices.push({ serial, state });
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
return devices;
|
|
1237
|
+
}
|
|
1238
|
+
async function execAdb(opts, args, runOpts = {}) {
|
|
1239
|
+
const adb = resolveAdb(opts);
|
|
1240
|
+
const baseOpts = {
|
|
1241
|
+
env: opts.env,
|
|
1242
|
+
timeoutMs: runOpts.timeoutMs ?? ADB_TIMEOUT_MS,
|
|
1243
|
+
maxOutputBytes: runOpts.maxOutputBytes
|
|
1244
|
+
};
|
|
1245
|
+
if (runOpts.binary === true) {
|
|
1246
|
+
return runCommand(adb, args, { ...baseOpts, binary: true });
|
|
1247
|
+
}
|
|
1248
|
+
return runCommand(adb, args, baseOpts);
|
|
1249
|
+
}
|
|
1250
|
+
function commandFailure(what, args, result) {
|
|
1251
|
+
const detail = result.stderr.trim() || result.stdout.trim() || (result.timedOut ? "timed out" : `exit code ${result.code}`);
|
|
1252
|
+
return new Error(`${what} failed (adb ${args.join(" ")}): ${detail}`);
|
|
1253
|
+
}
|
|
1254
|
+
async function listDevices(opts = {}) {
|
|
1255
|
+
const result = await execAdb(opts, ["devices"]);
|
|
1256
|
+
if (!result.ok) {
|
|
1257
|
+
throw commandFailure("adb devices", ["devices"], result);
|
|
1258
|
+
}
|
|
1259
|
+
return parseAdbDevices(result.stdout);
|
|
1260
|
+
}
|
|
1261
|
+
async function installApk(opts) {
|
|
1262
|
+
const args = buildInstallApkArgs(opts.serial, opts.apkPath);
|
|
1263
|
+
const result = await execAdb(opts, args, { timeoutMs: INSTALL_TIMEOUT_MS });
|
|
1264
|
+
if (!result.ok || /Failure/.test(result.stdout)) {
|
|
1265
|
+
throw commandFailure(`apk install of ${opts.apkPath}`, args, result);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
async function launchApp(opts) {
|
|
1269
|
+
const args = buildLaunchAppArgs(opts.serial, opts.packageName, opts.activity);
|
|
1270
|
+
const result = await execAdb(opts, args);
|
|
1271
|
+
if (!result.ok || /^Error/m.test(result.stdout) || /monkey aborted/i.test(result.stdout) || /monkey aborted/i.test(result.stderr)) {
|
|
1272
|
+
throw commandFailure(`launch of ${opts.packageName}`, args, result);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
async function screenshot(opts) {
|
|
1276
|
+
const args = buildScreenshotArgs(opts.serial);
|
|
1277
|
+
const result = await execAdb(opts, args, {
|
|
1278
|
+
timeoutMs: SCREENSHOT_TIMEOUT_MS,
|
|
1279
|
+
binary: true,
|
|
1280
|
+
maxOutputBytes: SCREENSHOT_MAX_BYTES
|
|
1281
|
+
});
|
|
1282
|
+
if (!result.ok) {
|
|
1283
|
+
throw commandFailure("screenshot", args, result);
|
|
1284
|
+
}
|
|
1285
|
+
if (result.stdoutTruncated) {
|
|
1286
|
+
throw new Error(
|
|
1287
|
+
`screenshot output exceeded ${SCREENSHOT_MAX_BYTES} bytes and was truncated`
|
|
1288
|
+
);
|
|
1289
|
+
}
|
|
1290
|
+
const data = result.stdoutBuffer;
|
|
1291
|
+
if (data.length < PNG_MAGIC.length || !data.subarray(0, PNG_MAGIC.length).equals(PNG_MAGIC)) {
|
|
1292
|
+
throw new Error(
|
|
1293
|
+
`screencap on ${opts.serial} did not produce a PNG image (got ${data.length} bytes)`
|
|
1294
|
+
);
|
|
1295
|
+
}
|
|
1296
|
+
await fs42.promises.mkdir(path42.dirname(opts.outPath), { recursive: true });
|
|
1297
|
+
await fs42.promises.writeFile(opts.outPath, data);
|
|
1298
|
+
return { path: opts.outPath };
|
|
1299
|
+
}
|
|
1300
|
+
async function tap(opts) {
|
|
1301
|
+
const args = buildTapArgs(opts.serial, opts.x, opts.y);
|
|
1302
|
+
const result = await execAdb(opts, args);
|
|
1303
|
+
if (!result.ok) {
|
|
1304
|
+
throw commandFailure(`tap at (${opts.x}, ${opts.y})`, args, result);
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
async function typeText(opts) {
|
|
1308
|
+
for (const chunk of splitInputText(opts.text)) {
|
|
1309
|
+
const args = buildTypeTextArgs(opts.serial, chunk);
|
|
1310
|
+
const result = await execAdb(opts, args);
|
|
1311
|
+
if (!result.ok) {
|
|
1312
|
+
throw commandFailure("text input", args, result);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
async function pressKey(opts) {
|
|
1317
|
+
const args = buildKeyeventArgs(opts.serial, opts.key);
|
|
1318
|
+
const result = await execAdb(opts, args);
|
|
1319
|
+
if (!result.ok) {
|
|
1320
|
+
throw commandFailure(`keyevent ${opts.key}`, args, result);
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
async function back(opts) {
|
|
1324
|
+
await pressKey({ ...opts, key: KEYCODE_BACK });
|
|
1325
|
+
}
|
|
1326
|
+
async function home(opts) {
|
|
1327
|
+
await pressKey({ ...opts, key: KEYCODE_HOME });
|
|
1328
|
+
}
|
|
1329
|
+
async function dumpUiTreeOnce(opts) {
|
|
1330
|
+
const dumpArgs = buildUiDumpArgs(opts.serial);
|
|
1331
|
+
const dumpResult = await execAdb(opts, dumpArgs);
|
|
1332
|
+
if (!dumpResult.ok || /ERROR/i.test(dumpResult.stderr) || /ERROR/.test(dumpResult.stdout)) {
|
|
1333
|
+
throw commandFailure("uiautomator dump", dumpArgs, dumpResult);
|
|
1334
|
+
}
|
|
1335
|
+
try {
|
|
1336
|
+
const catArgs = buildUiCatArgs(opts.serial);
|
|
1337
|
+
const catResult = await execAdb(opts, catArgs);
|
|
1338
|
+
if (!catResult.ok) {
|
|
1339
|
+
throw commandFailure("ui tree read", catArgs, catResult);
|
|
1340
|
+
}
|
|
1341
|
+
const xml = catResult.stdout.trim();
|
|
1342
|
+
if (!xml.startsWith("<?xml") && !xml.startsWith("<hierarchy")) {
|
|
1343
|
+
throw new Error(
|
|
1344
|
+
`uiautomator dump on ${opts.serial} did not return XML: ${xml.slice(0, 120)}`
|
|
1345
|
+
);
|
|
1346
|
+
}
|
|
1347
|
+
return xml;
|
|
1348
|
+
} finally {
|
|
1349
|
+
await execAdb(opts, buildUiCleanupArgs(opts.serial)).catch(() => {
|
|
1350
|
+
});
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
async function getUiTree(opts) {
|
|
1354
|
+
const attempts = opts.attempts ?? DEFAULT_UI_DUMP_ATTEMPTS;
|
|
1355
|
+
if (!Number.isInteger(attempts) || attempts <= 0) {
|
|
1356
|
+
throw new Error(`Invalid attempts ${attempts}: expected a positive integer`);
|
|
1357
|
+
}
|
|
1358
|
+
const retryDelayMs = opts.retryDelayMs ?? DEFAULT_UI_DUMP_RETRY_DELAY_MS;
|
|
1359
|
+
let lastError;
|
|
1360
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
1361
|
+
try {
|
|
1362
|
+
return await dumpUiTreeOnce(opts);
|
|
1363
|
+
} catch (error) {
|
|
1364
|
+
lastError = error;
|
|
1365
|
+
if (attempt < attempts - 1) {
|
|
1366
|
+
await sleep2(retryDelayMs);
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
throw lastError;
|
|
1371
|
+
}
|
|
1372
|
+
async function logcat(opts) {
|
|
1373
|
+
const args = buildLogcatArgs(opts.serial, {
|
|
1374
|
+
lines: opts.lines,
|
|
1375
|
+
filter: opts.filter
|
|
1376
|
+
});
|
|
1377
|
+
const result = await execAdb(opts, args);
|
|
1378
|
+
if (!result.ok) {
|
|
1379
|
+
throw commandFailure("logcat", args, result);
|
|
1380
|
+
}
|
|
1381
|
+
return result.stdout;
|
|
1382
|
+
}
|
|
1383
|
+
async function clearLogcat(opts) {
|
|
1384
|
+
const args = buildClearLogcatArgs(opts.serial);
|
|
1385
|
+
const result = await execAdb(opts, args);
|
|
1386
|
+
if (!result.ok) {
|
|
1387
|
+
throw commandFailure("logcat clear", args, result);
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
async function runAdb(opts) {
|
|
1391
|
+
const args = [...opts.args];
|
|
1392
|
+
if (opts.serial !== void 0) {
|
|
1393
|
+
assertSerial(opts.serial);
|
|
1394
|
+
args.unshift("-s", opts.serial);
|
|
1395
|
+
}
|
|
1396
|
+
return execAdb(opts, args, { timeoutMs: opts.timeoutMs });
|
|
1397
|
+
}
|
|
1398
|
+
var MIN_CONSOLE_PORT = 5554;
|
|
1399
|
+
var MAX_CONSOLE_PORT = 5682;
|
|
1400
|
+
var DEFAULT_BOOT_TIMEOUT_MS = 18e4;
|
|
1401
|
+
var DEFAULT_BOOT_POLL_INTERVAL_MS = 2e3;
|
|
1402
|
+
var GETPROP_TIMEOUT_MS = 1e4;
|
|
1403
|
+
var EMU_KILL_TIMEOUT_MS = 1e4;
|
|
1404
|
+
var EMU_KILL_POLL_INTERVAL_MS = 200;
|
|
1405
|
+
function assertConsolePort(port) {
|
|
1406
|
+
if (!Number.isInteger(port) || port < MIN_CONSOLE_PORT || port > MAX_CONSOLE_PORT || port % 2 !== 0) {
|
|
1407
|
+
throw new Error(
|
|
1408
|
+
`Invalid console port ${port}: expected an even integer between ${MIN_CONSOLE_PORT} and ${MAX_CONSOLE_PORT}`
|
|
1409
|
+
);
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
function emulatorSerial(consolePort) {
|
|
1413
|
+
assertConsolePort(consolePort);
|
|
1414
|
+
return `emulator-${consolePort}`;
|
|
1415
|
+
}
|
|
1416
|
+
function buildEmulatorArgs(opts) {
|
|
1417
|
+
assertAvdName(opts.avdName);
|
|
1418
|
+
const port = opts.port ?? MIN_CONSOLE_PORT;
|
|
1419
|
+
assertConsolePort(port);
|
|
1420
|
+
const args = ["-avd", opts.avdName];
|
|
1421
|
+
if (opts.headless !== false) {
|
|
1422
|
+
args.push("-no-window");
|
|
1423
|
+
}
|
|
1424
|
+
args.push("-no-audio", "-no-boot-anim", "-port", String(port));
|
|
1425
|
+
return args;
|
|
1426
|
+
}
|
|
1427
|
+
async function waitForBoot(opts) {
|
|
1428
|
+
assertSerial(opts.serial);
|
|
1429
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_BOOT_TIMEOUT_MS;
|
|
1430
|
+
const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_BOOT_POLL_INTERVAL_MS;
|
|
1431
|
+
const logHint = opts.logPath !== void 0 ? `; check the log at ${opts.logPath}` : "";
|
|
1432
|
+
const startedAt = Date.now();
|
|
1433
|
+
const deadline = startedAt + timeoutMs;
|
|
1434
|
+
for (; ; ) {
|
|
1435
|
+
if (opts.signal?.aborted === true) {
|
|
1436
|
+
throw new Error(
|
|
1437
|
+
`Aborted while waiting for emulator ${opts.serial} to boot${logHint}`
|
|
1438
|
+
);
|
|
1439
|
+
}
|
|
1440
|
+
if (opts.isEmulatorAlive !== void 0 && !opts.isEmulatorAlive()) {
|
|
1441
|
+
throw new Error(
|
|
1442
|
+
`Emulator for ${opts.serial} exited before finishing boot${logHint}`
|
|
1443
|
+
);
|
|
1444
|
+
}
|
|
1445
|
+
opts.onProgress?.(
|
|
1446
|
+
`waiting for emulator ${opts.serial} to boot (${Math.round((Date.now() - startedAt) / 1e3)}s elapsed)`
|
|
1447
|
+
);
|
|
1448
|
+
const remainingMs = deadline - Date.now();
|
|
1449
|
+
if (remainingMs <= 0) {
|
|
1450
|
+
throw new Error(
|
|
1451
|
+
`Emulator ${opts.serial} did not finish booting within ${timeoutMs}ms${logHint}`
|
|
1452
|
+
);
|
|
1453
|
+
}
|
|
1454
|
+
const result = await runCommand(
|
|
1455
|
+
opts.adbPath,
|
|
1456
|
+
["-s", opts.serial, "shell", "getprop", "sys.boot_completed"],
|
|
1457
|
+
{ env: opts.env, timeoutMs: Math.min(GETPROP_TIMEOUT_MS, remainingMs) }
|
|
1458
|
+
);
|
|
1459
|
+
if (result.ok && result.stdout.trim() === "1") {
|
|
1460
|
+
if (opts.isEmulatorAlive !== void 0 && !opts.isEmulatorAlive()) {
|
|
1461
|
+
throw new Error(
|
|
1462
|
+
`Emulator for ${opts.serial} exited before finishing boot${logHint}`
|
|
1463
|
+
);
|
|
1464
|
+
}
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
if (Date.now() + pollIntervalMs > deadline) {
|
|
1468
|
+
throw new Error(
|
|
1469
|
+
`Emulator ${opts.serial} did not finish booting within ${timeoutMs}ms${logHint}`
|
|
1470
|
+
);
|
|
1471
|
+
}
|
|
1472
|
+
await sleep2(pollIntervalMs);
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
function consolePortLockPath(port, registryEnv = process.env) {
|
|
1476
|
+
return path52.join(picklabHome(registryEnv), "ports", `emulator-${port}.lock`);
|
|
1477
|
+
}
|
|
1478
|
+
function readLockOwnerPid(lockPath) {
|
|
1479
|
+
try {
|
|
1480
|
+
const pid = Number(fs52.readFileSync(lockPath, "utf8").trim());
|
|
1481
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
1482
|
+
} catch {
|
|
1483
|
+
return null;
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
function tryReserveConsolePort(port, registryEnv = process.env, ownerPid = process.pid) {
|
|
1487
|
+
assertConsolePort(port);
|
|
1488
|
+
const lockPath = consolePortLockPath(port, registryEnv);
|
|
1489
|
+
fs52.mkdirSync(path52.dirname(lockPath), { recursive: true });
|
|
1490
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1491
|
+
try {
|
|
1492
|
+
fs52.writeFileSync(lockPath, `${ownerPid}
|
|
1493
|
+
`, { flag: "wx" });
|
|
1494
|
+
return true;
|
|
1495
|
+
} catch (error) {
|
|
1496
|
+
if (error.code !== "EEXIST") {
|
|
1497
|
+
throw error;
|
|
1498
|
+
}
|
|
1499
|
+
const owner = readLockOwnerPid(lockPath);
|
|
1500
|
+
if (owner !== null && isPidAlive(owner)) {
|
|
1501
|
+
return false;
|
|
1502
|
+
}
|
|
1503
|
+
fs52.rmSync(lockPath, { force: true });
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
return false;
|
|
1507
|
+
}
|
|
1508
|
+
function releaseConsolePort(port, registryEnv = process.env) {
|
|
1509
|
+
try {
|
|
1510
|
+
fs52.rmSync(consolePortLockPath(port, registryEnv), { force: true });
|
|
1511
|
+
} catch {
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
function claimConsolePort(port, ownerPid, registryEnv) {
|
|
1515
|
+
try {
|
|
1516
|
+
fs52.writeFileSync(consolePortLockPath(port, registryEnv), `${ownerPid}
|
|
1517
|
+
`);
|
|
1518
|
+
} catch {
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
async function allocateConsolePort(opts) {
|
|
1522
|
+
let devices;
|
|
1523
|
+
try {
|
|
1524
|
+
devices = await listDevices(opts);
|
|
1525
|
+
} catch (error) {
|
|
1526
|
+
throw new Error(
|
|
1527
|
+
"Failed to list adb devices while allocating an emulator console port",
|
|
1528
|
+
{ cause: error }
|
|
1529
|
+
);
|
|
1530
|
+
}
|
|
1531
|
+
const used = /* @__PURE__ */ new Set();
|
|
1532
|
+
for (const device of devices) {
|
|
1533
|
+
const match = /^emulator-(\d+)$/.exec(device.serial);
|
|
1534
|
+
if (match !== null) {
|
|
1535
|
+
used.add(Number(match[1]));
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
const registryEnv = opts.registryEnv ?? process.env;
|
|
1539
|
+
for (let port = MIN_CONSOLE_PORT; port <= MAX_CONSOLE_PORT; port += 2) {
|
|
1540
|
+
if (used.has(port)) {
|
|
1541
|
+
continue;
|
|
1542
|
+
}
|
|
1543
|
+
if (tryReserveConsolePort(port, registryEnv)) {
|
|
1544
|
+
return port;
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
throw new Error(
|
|
1548
|
+
`No free emulator console port between ${MIN_CONSOLE_PORT} and ${MAX_CONSOLE_PORT}`
|
|
1549
|
+
);
|
|
1550
|
+
}
|
|
1551
|
+
async function startEmulator(opts) {
|
|
1552
|
+
const avdName = opts.avdName ?? DEFAULT_AVD_NAME;
|
|
1553
|
+
const env = opts.env ?? process.env;
|
|
1554
|
+
const registryEnv = opts.registryEnv ?? process.env;
|
|
1555
|
+
const sdk = resolveSdkRoot(opts.sdk, env);
|
|
1556
|
+
const emulator = findSdkTool(sdk, "emulator", env);
|
|
1557
|
+
if (emulator === null) {
|
|
1558
|
+
throw new Error(
|
|
1559
|
+
'Android emulator binary not found (<sdk>/emulator/emulator or PATH); install it with: sdkmanager "emulator", or set ANDROID_HOME'
|
|
1560
|
+
);
|
|
1561
|
+
}
|
|
1562
|
+
const adbPath = resolveAdb({ sdk, env: opts.env });
|
|
1563
|
+
let port;
|
|
1564
|
+
if (opts.port !== void 0) {
|
|
1565
|
+
assertConsolePort(opts.port);
|
|
1566
|
+
if (!tryReserveConsolePort(opts.port, registryEnv)) {
|
|
1567
|
+
throw new Error(
|
|
1568
|
+
`Console port ${opts.port} is already reserved by another PickLab emulator (${consolePortLockPath(opts.port, registryEnv)})`
|
|
1569
|
+
);
|
|
1570
|
+
}
|
|
1571
|
+
port = opts.port;
|
|
1572
|
+
} else {
|
|
1573
|
+
port = await allocateConsolePort({ sdk, env: opts.env, registryEnv });
|
|
1574
|
+
}
|
|
1575
|
+
try {
|
|
1576
|
+
const args = buildEmulatorArgs({
|
|
1577
|
+
avdName,
|
|
1578
|
+
headless: opts.headless,
|
|
1579
|
+
port
|
|
1580
|
+
});
|
|
1581
|
+
const serial = emulatorSerial(port);
|
|
1582
|
+
const sdkEnv = sdk !== null ? { ANDROID_HOME: sdk, ANDROID_SDK_ROOT: sdk } : {};
|
|
1583
|
+
if (opts.signal?.aborted === true) {
|
|
1584
|
+
throw new Error(
|
|
1585
|
+
`Aborted before starting the emulator for AVD ${avdName}`
|
|
1586
|
+
);
|
|
1587
|
+
}
|
|
1588
|
+
opts.onProgress?.(`starting emulator for AVD ${avdName} (${serial})`);
|
|
1589
|
+
const daemon = await startDaemon(emulator, args, {
|
|
1590
|
+
logDir: opts.logDir,
|
|
1591
|
+
name: "emulator",
|
|
1592
|
+
env: { ...sdkEnv, ...opts.env }
|
|
1593
|
+
});
|
|
1594
|
+
claimConsolePort(port, daemon.pid, registryEnv);
|
|
1595
|
+
try {
|
|
1596
|
+
await waitForBoot({
|
|
1597
|
+
serial,
|
|
1598
|
+
adbPath,
|
|
1599
|
+
env: opts.env,
|
|
1600
|
+
timeoutMs: opts.bootTimeoutMs,
|
|
1601
|
+
pollIntervalMs: opts.bootPollIntervalMs,
|
|
1602
|
+
isEmulatorAlive: () => isPidAlive(daemon.pid),
|
|
1603
|
+
logPath: daemon.logPath,
|
|
1604
|
+
onProgress: opts.onProgress,
|
|
1605
|
+
signal: opts.signal
|
|
1606
|
+
});
|
|
1607
|
+
} catch (error) {
|
|
1608
|
+
await stopPid(daemon.pid).catch(() => {
|
|
1609
|
+
});
|
|
1610
|
+
throw error;
|
|
1611
|
+
}
|
|
1612
|
+
return {
|
|
1613
|
+
pid: daemon.pid,
|
|
1614
|
+
serial,
|
|
1615
|
+
consolePort: port,
|
|
1616
|
+
logPath: daemon.logPath
|
|
1617
|
+
};
|
|
1618
|
+
} catch (error) {
|
|
1619
|
+
releaseConsolePort(port, registryEnv);
|
|
1620
|
+
throw error;
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
async function stopEmulator(opts) {
|
|
1624
|
+
const stopped = await stopEmulatorProcess(opts);
|
|
1625
|
+
if (stopped && opts.serial !== void 0) {
|
|
1626
|
+
const match = /^emulator-(\d+)$/.exec(opts.serial);
|
|
1627
|
+
if (match !== null) {
|
|
1628
|
+
releaseConsolePort(Number(match[1]), opts.registryEnv ?? process.env);
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
return stopped;
|
|
1632
|
+
}
|
|
1633
|
+
async function stopEmulatorProcess(opts) {
|
|
1634
|
+
const timeoutMs = opts.timeoutMs ?? EMU_KILL_TIMEOUT_MS;
|
|
1635
|
+
let adbPath = null;
|
|
1636
|
+
try {
|
|
1637
|
+
adbPath = resolveAdb(opts);
|
|
1638
|
+
} catch {
|
|
1639
|
+
adbPath = null;
|
|
1640
|
+
}
|
|
1641
|
+
let sentEmuKill = false;
|
|
1642
|
+
if (opts.serial !== void 0 && adbPath !== null) {
|
|
1643
|
+
assertSerial(opts.serial);
|
|
1644
|
+
const killResult = await runCommand(
|
|
1645
|
+
adbPath,
|
|
1646
|
+
["-s", opts.serial, "emu", "kill"],
|
|
1647
|
+
{ env: opts.env, timeoutMs: 5e3 }
|
|
1648
|
+
).catch(() => null);
|
|
1649
|
+
sentEmuKill = killResult !== null && killResult.ok;
|
|
1650
|
+
}
|
|
1651
|
+
if (opts.pid !== void 0) {
|
|
1652
|
+
if (sentEmuKill) {
|
|
1653
|
+
const deadline = Date.now() + timeoutMs;
|
|
1654
|
+
while (Date.now() < deadline && isPidAlive(opts.pid)) {
|
|
1655
|
+
await sleep2(EMU_KILL_POLL_INTERVAL_MS);
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
if (isPidAlive(opts.pid)) {
|
|
1659
|
+
return stopPid(opts.pid);
|
|
1660
|
+
}
|
|
1661
|
+
return true;
|
|
1662
|
+
}
|
|
1663
|
+
if (opts.serial !== void 0 && adbPath !== null) {
|
|
1664
|
+
const deadline = Date.now() + timeoutMs;
|
|
1665
|
+
while (Date.now() < deadline) {
|
|
1666
|
+
try {
|
|
1667
|
+
const devices = await listDevices(opts);
|
|
1668
|
+
if (!devices.some((device) => device.serial === opts.serial)) {
|
|
1669
|
+
return true;
|
|
1670
|
+
}
|
|
1671
|
+
} catch {
|
|
1672
|
+
return false;
|
|
1673
|
+
}
|
|
1674
|
+
await sleep2(EMU_KILL_POLL_INTERVAL_MS);
|
|
1675
|
+
}
|
|
1676
|
+
return false;
|
|
1677
|
+
}
|
|
1678
|
+
return true;
|
|
1679
|
+
}
|
|
1680
|
+
function androidSessionLogDir(id, registryEnv = process.env) {
|
|
1681
|
+
return path62.join(sessionsDir(registryEnv), id);
|
|
1682
|
+
}
|
|
1683
|
+
async function createAndroidSession(opts) {
|
|
1684
|
+
const registryEnv = opts.registryEnv ?? process.env;
|
|
1685
|
+
const avdName = opts.avdName ?? DEFAULT_AVD_NAME;
|
|
1686
|
+
const record = await createSession(
|
|
1687
|
+
{ type: "android", projectDir: opts.projectDir, android: { avdName } },
|
|
1688
|
+
registryEnv
|
|
1689
|
+
);
|
|
1690
|
+
const logDir = androidSessionLogDir(record.id, registryEnv);
|
|
1691
|
+
let emulator;
|
|
1692
|
+
try {
|
|
1693
|
+
emulator = await startEmulator({
|
|
1694
|
+
avdName,
|
|
1695
|
+
sdk: opts.sdk,
|
|
1696
|
+
headless: opts.headless,
|
|
1697
|
+
port: opts.port,
|
|
1698
|
+
logDir,
|
|
1699
|
+
env: opts.env,
|
|
1700
|
+
registryEnv,
|
|
1701
|
+
bootTimeoutMs: opts.bootTimeoutMs,
|
|
1702
|
+
bootPollIntervalMs: opts.bootPollIntervalMs,
|
|
1703
|
+
onProgress: opts.onProgress,
|
|
1704
|
+
signal: opts.signal
|
|
1705
|
+
});
|
|
1706
|
+
const android = {
|
|
1707
|
+
avdName,
|
|
1708
|
+
serial: emulator.serial,
|
|
1709
|
+
emulatorPid: emulator.pid,
|
|
1710
|
+
consolePort: emulator.consolePort
|
|
1711
|
+
};
|
|
1712
|
+
await updateSession(record.id, { status: "running", android }, registryEnv);
|
|
1713
|
+
return {
|
|
1714
|
+
id: record.id,
|
|
1715
|
+
avdName,
|
|
1716
|
+
serial: emulator.serial,
|
|
1717
|
+
consolePort: emulator.consolePort,
|
|
1718
|
+
emulatorPid: emulator.pid,
|
|
1719
|
+
logPath: emulator.logPath,
|
|
1720
|
+
logDir
|
|
1721
|
+
};
|
|
1722
|
+
} catch (error) {
|
|
1723
|
+
if (emulator !== void 0) {
|
|
1724
|
+
await stopEmulator({
|
|
1725
|
+
serial: emulator.serial,
|
|
1726
|
+
pid: emulator.pid,
|
|
1727
|
+
sdk: opts.sdk,
|
|
1728
|
+
env: opts.env,
|
|
1729
|
+
registryEnv
|
|
1730
|
+
}).catch(() => {
|
|
1731
|
+
});
|
|
1732
|
+
}
|
|
1733
|
+
await updateSession(record.id, { status: "error" }, registryEnv).catch(
|
|
1734
|
+
() => {
|
|
1735
|
+
}
|
|
1736
|
+
);
|
|
1737
|
+
throw error;
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
async function destroyAndroidSession(id, registryEnv = process.env, opts = {}) {
|
|
1741
|
+
const record = await getSession(id, registryEnv);
|
|
1742
|
+
if (record === void 0) {
|
|
1743
|
+
throw new Error(`Android session not found: ${id}`);
|
|
1744
|
+
}
|
|
1745
|
+
const android = record.android;
|
|
1746
|
+
if (android?.emulatorPid !== void 0 || android?.serial !== void 0) {
|
|
1747
|
+
let stopped;
|
|
1748
|
+
let failure;
|
|
1749
|
+
try {
|
|
1750
|
+
stopped = await stopEmulator({
|
|
1751
|
+
serial: android.serial,
|
|
1752
|
+
pid: android.emulatorPid,
|
|
1753
|
+
sdk: opts.sdk,
|
|
1754
|
+
env: opts.env,
|
|
1755
|
+
registryEnv,
|
|
1756
|
+
timeoutMs: opts.timeoutMs
|
|
1757
|
+
});
|
|
1758
|
+
} catch (error) {
|
|
1759
|
+
stopped = false;
|
|
1760
|
+
failure = error instanceof Error ? error : new Error(String(error));
|
|
1761
|
+
}
|
|
1762
|
+
if (!stopped) {
|
|
1763
|
+
await updateSession(id, { status: "error" }, registryEnv).catch(() => {
|
|
1764
|
+
});
|
|
1765
|
+
throw new Error(
|
|
1766
|
+
`Failed to stop emulator of android session ${id} (serial ${android.serial ?? "unknown"}, pid ${android.emulatorPid ?? "unknown"})` + (failure !== void 0 ? `: ${failure.message}` : "")
|
|
1767
|
+
);
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
await destroySessionRecord(id, registryEnv);
|
|
1771
|
+
}
|
|
1772
|
+
async function getAndroidSessionStatus(id, registryEnv = process.env, opts = {}) {
|
|
1773
|
+
const record = await getSession(id, registryEnv);
|
|
1774
|
+
if (record === void 0) {
|
|
1775
|
+
throw new Error(`Android session not found: ${id}`);
|
|
1776
|
+
}
|
|
1777
|
+
const android = record.android;
|
|
1778
|
+
const emulatorAlive = android?.emulatorPid !== void 0 && isPidAlive(android.emulatorPid);
|
|
1779
|
+
let deviceState = null;
|
|
1780
|
+
if (android?.serial !== void 0) {
|
|
1781
|
+
try {
|
|
1782
|
+
const devices = await listDevices(opts);
|
|
1783
|
+
deviceState = devices.find((device) => device.serial === android.serial)?.state ?? null;
|
|
1784
|
+
} catch {
|
|
1785
|
+
deviceState = null;
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
return { record, emulatorAlive, deviceState };
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
// ../desktop-linux/dist/index.js
|
|
1792
|
+
import fs23 from "fs";
|
|
1793
|
+
import fs7 from "fs";
|
|
1794
|
+
import path8 from "path";
|
|
1795
|
+
import net from "net";
|
|
1796
|
+
import crypto from "crypto";
|
|
1797
|
+
import fs33 from "fs";
|
|
1798
|
+
import path23 from "path";
|
|
1799
|
+
import path33 from "path";
|
|
1800
|
+
function sleep3(ms) {
|
|
1801
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1802
|
+
}
|
|
1803
|
+
function findOnPath2(name, env = process.env) {
|
|
1804
|
+
const dirs = (env.PATH ?? "").split(path8.delimiter).filter((d) => d !== "");
|
|
1805
|
+
for (const dir of dirs) {
|
|
1806
|
+
const candidate = path8.join(dir, name);
|
|
1807
|
+
try {
|
|
1808
|
+
fs7.accessSync(candidate, fs7.constants.X_OK);
|
|
1809
|
+
if (fs7.statSync(candidate).isFile()) {
|
|
1810
|
+
return candidate;
|
|
1811
|
+
}
|
|
1812
|
+
} catch {
|
|
1813
|
+
continue;
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
return null;
|
|
1817
|
+
}
|
|
1818
|
+
var DISPLAY_PATTERN = /^:\d+$/;
|
|
1819
|
+
var DEFAULT_WIDTH = 1280;
|
|
1820
|
+
var DEFAULT_HEIGHT = 800;
|
|
1821
|
+
var DEFAULT_DEPTH = 24;
|
|
1822
|
+
var DEFAULT_START_DISPLAY = 90;
|
|
1823
|
+
var DEFAULT_MAX_ATTEMPTS = 200;
|
|
1824
|
+
var SOCKET_POLL_INTERVAL_MS = 100;
|
|
1825
|
+
var DEFAULT_WAIT_TIMEOUT_MS = 1e4;
|
|
1826
|
+
var ALLOCATION_RETRY_LIMIT = 5;
|
|
1827
|
+
function parseDisplayNumber(display) {
|
|
1828
|
+
if (!DISPLAY_PATTERN.test(display)) {
|
|
1829
|
+
throw new Error(
|
|
1830
|
+
`Invalid display "${display}": expected the form ":<number>"`
|
|
1831
|
+
);
|
|
1832
|
+
}
|
|
1833
|
+
return Number.parseInt(display.slice(1), 10);
|
|
1834
|
+
}
|
|
1835
|
+
function displaySocketPath(displayNumber) {
|
|
1836
|
+
return `/tmp/.X11-unix/X${displayNumber}`;
|
|
1837
|
+
}
|
|
1838
|
+
function displayLockPath(displayNumber) {
|
|
1839
|
+
return `/tmp/.X${displayNumber}-lock`;
|
|
1840
|
+
}
|
|
1841
|
+
function readLockPid(displayNumber) {
|
|
1842
|
+
let raw;
|
|
1843
|
+
try {
|
|
1844
|
+
raw = fs23.readFileSync(displayLockPath(displayNumber), "utf8");
|
|
1845
|
+
} catch {
|
|
1846
|
+
return null;
|
|
1847
|
+
}
|
|
1848
|
+
const pid = Number.parseInt(raw.trim(), 10);
|
|
1849
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
1850
|
+
}
|
|
1851
|
+
function assertPositiveInteger(value, label) {
|
|
1852
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
1853
|
+
throw new Error(`Invalid ${label} ${value}: expected a positive integer`);
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
function buildXvfbArgs(opts) {
|
|
1857
|
+
parseDisplayNumber(opts.display);
|
|
1858
|
+
const width = opts.width ?? DEFAULT_WIDTH;
|
|
1859
|
+
const height = opts.height ?? DEFAULT_HEIGHT;
|
|
1860
|
+
const depth = opts.depth ?? DEFAULT_DEPTH;
|
|
1861
|
+
assertPositiveInteger(width, "width");
|
|
1862
|
+
assertPositiveInteger(height, "height");
|
|
1863
|
+
assertPositiveInteger(depth, "depth");
|
|
1864
|
+
return [
|
|
1865
|
+
opts.display,
|
|
1866
|
+
"-screen",
|
|
1867
|
+
"0",
|
|
1868
|
+
`${width}x${height}x${depth}`,
|
|
1869
|
+
"-nolisten",
|
|
1870
|
+
"tcp"
|
|
1871
|
+
];
|
|
1872
|
+
}
|
|
1873
|
+
function allocateDisplay(opts = {}) {
|
|
1874
|
+
const start = opts.start ?? DEFAULT_START_DISPLAY;
|
|
1875
|
+
const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
1876
|
+
for (let n = start; n < start + maxAttempts; n += 1) {
|
|
1877
|
+
if (!fs23.existsSync(displayLockPath(n)) && !fs23.existsSync(displaySocketPath(n))) {
|
|
1878
|
+
return `:${n}`;
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
throw new Error(
|
|
1882
|
+
`No free X display found between :${start} and :${start + maxAttempts - 1}`
|
|
1883
|
+
);
|
|
1884
|
+
}
|
|
1885
|
+
function isDisplayAlive(display) {
|
|
1886
|
+
return fs23.existsSync(displaySocketPath(parseDisplayNumber(display)));
|
|
1887
|
+
}
|
|
1888
|
+
async function attemptStartXvfb(display, opts) {
|
|
1889
|
+
const displayNumber = parseDisplayNumber(display);
|
|
1890
|
+
const args = buildXvfbArgs({
|
|
1891
|
+
display,
|
|
1892
|
+
width: opts.width,
|
|
1893
|
+
height: opts.height,
|
|
1894
|
+
depth: opts.depth
|
|
1895
|
+
});
|
|
1896
|
+
const daemon = await startDaemon("Xvfb", args, {
|
|
1897
|
+
logDir: opts.logDir,
|
|
1898
|
+
name: "xvfb",
|
|
1899
|
+
env: opts.env
|
|
1900
|
+
});
|
|
1901
|
+
const timeoutMs = opts.waitTimeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS;
|
|
1902
|
+
const deadline = Date.now() + timeoutMs;
|
|
1903
|
+
while (Date.now() < deadline) {
|
|
1904
|
+
const alive = isPidAlive(daemon.pid);
|
|
1905
|
+
if (fs23.existsSync(displaySocketPath(displayNumber))) {
|
|
1906
|
+
const lockPid = readLockPid(displayNumber);
|
|
1907
|
+
if (lockPid !== null && lockPid !== daemon.pid && isPidAlive(lockPid)) {
|
|
1908
|
+
await stopPid(daemon.pid);
|
|
1909
|
+
return { outcome: "lost-race", logPath: daemon.logPath };
|
|
1910
|
+
}
|
|
1911
|
+
if (alive && (lockPid === null || lockPid === daemon.pid)) {
|
|
1912
|
+
return {
|
|
1913
|
+
outcome: "ready",
|
|
1914
|
+
handle: { display, pid: daemon.pid, logPath: daemon.logPath }
|
|
1915
|
+
};
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
if (!alive) {
|
|
1919
|
+
return { outcome: "exited", logPath: daemon.logPath };
|
|
1920
|
+
}
|
|
1921
|
+
await sleep3(SOCKET_POLL_INTERVAL_MS);
|
|
1922
|
+
}
|
|
1923
|
+
await stopPid(daemon.pid);
|
|
1924
|
+
return { outcome: "timeout", logPath: daemon.logPath };
|
|
1925
|
+
}
|
|
1926
|
+
function describeXvfbFailure(attempt, display, timeoutMs) {
|
|
1927
|
+
switch (attempt.outcome) {
|
|
1928
|
+
case "exited":
|
|
1929
|
+
return `Xvfb exited during startup on ${display}; check the log at ${attempt.logPath}`;
|
|
1930
|
+
case "lost-race":
|
|
1931
|
+
return `Xvfb could not claim ${display}: another X server owns it; check the log at ${attempt.logPath}`;
|
|
1932
|
+
case "timeout":
|
|
1933
|
+
return `Xvfb did not come up on ${display} within ${timeoutMs}ms; check the log at ${attempt.logPath}`;
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
async function startXvfb(opts) {
|
|
1937
|
+
const timeoutMs = opts.waitTimeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS;
|
|
1938
|
+
if (opts.display !== void 0) {
|
|
1939
|
+
const attempt = await attemptStartXvfb(opts.display, opts);
|
|
1940
|
+
if (attempt.outcome === "ready") {
|
|
1941
|
+
return attempt.handle;
|
|
1942
|
+
}
|
|
1943
|
+
throw new Error(describeXvfbFailure(attempt, opts.display, timeoutMs));
|
|
1944
|
+
}
|
|
1945
|
+
let searchFrom = DEFAULT_START_DISPLAY;
|
|
1946
|
+
let lastFailureMessage = "";
|
|
1947
|
+
for (let retry = 0; retry < ALLOCATION_RETRY_LIMIT; retry += 1) {
|
|
1948
|
+
const display = allocateDisplay({ start: searchFrom });
|
|
1949
|
+
const attempt = await attemptStartXvfb(display, opts);
|
|
1950
|
+
if (attempt.outcome === "ready") {
|
|
1951
|
+
return attempt.handle;
|
|
1952
|
+
}
|
|
1953
|
+
lastFailureMessage = describeXvfbFailure(attempt, display, timeoutMs);
|
|
1954
|
+
if (attempt.outcome === "timeout") {
|
|
1955
|
+
throw new Error(lastFailureMessage);
|
|
1956
|
+
}
|
|
1957
|
+
searchFrom = parseDisplayNumber(display) + 1;
|
|
1958
|
+
}
|
|
1959
|
+
throw new Error(
|
|
1960
|
+
`Xvfb failed to claim a free display after ${ALLOCATION_RETRY_LIMIT} attempts; last failure: ${lastFailureMessage}`
|
|
1961
|
+
);
|
|
1962
|
+
}
|
|
1963
|
+
var VNC_BASE_PORT = 5900;
|
|
1964
|
+
var STARTUP_TIMEOUT_MS = 5e3;
|
|
1965
|
+
var STARTUP_POLL_INTERVAL_MS = 100;
|
|
1966
|
+
function assertValidPort(port) {
|
|
1967
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
1968
|
+
throw new Error(`Invalid port ${port}: expected an integer in 1-65535`);
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
function buildVncArgs(opts) {
|
|
1972
|
+
parseDisplayNumber(opts.display);
|
|
1973
|
+
assertValidPort(opts.port);
|
|
1974
|
+
return [
|
|
1975
|
+
"-display",
|
|
1976
|
+
opts.display,
|
|
1977
|
+
"-rfbport",
|
|
1978
|
+
String(opts.port),
|
|
1979
|
+
"-localhost",
|
|
1980
|
+
"-forever",
|
|
1981
|
+
"-shared",
|
|
1982
|
+
"-nopw",
|
|
1983
|
+
"-quiet"
|
|
1984
|
+
];
|
|
1985
|
+
}
|
|
1986
|
+
function detectVncBinary(env = process.env) {
|
|
1987
|
+
return findOnPath2("x11vnc", env);
|
|
1988
|
+
}
|
|
1989
|
+
function isPortListening(port) {
|
|
1990
|
+
return new Promise((resolve) => {
|
|
1991
|
+
const socket = net.connect({ host: "127.0.0.1", port });
|
|
1992
|
+
socket.once("connect", () => {
|
|
1993
|
+
socket.destroy();
|
|
1994
|
+
resolve(true);
|
|
1995
|
+
});
|
|
1996
|
+
socket.once("error", () => {
|
|
1997
|
+
socket.destroy();
|
|
1998
|
+
resolve(false);
|
|
1999
|
+
});
|
|
2000
|
+
});
|
|
2001
|
+
}
|
|
2002
|
+
async function startVnc(opts) {
|
|
2003
|
+
const port = opts.port ?? VNC_BASE_PORT + parseDisplayNumber(opts.display);
|
|
2004
|
+
const args = buildVncArgs({ display: opts.display, port });
|
|
2005
|
+
const binary = detectVncBinary({ ...process.env, ...opts.env });
|
|
2006
|
+
if (binary === null) {
|
|
2007
|
+
throw new Error(
|
|
2008
|
+
"x11vnc was not found on PATH; install x11vnc to enable VNC"
|
|
2009
|
+
);
|
|
2010
|
+
}
|
|
2011
|
+
const daemon = await startDaemon(binary, args, {
|
|
2012
|
+
logDir: opts.logDir,
|
|
2013
|
+
name: "x11vnc",
|
|
2014
|
+
env: opts.env
|
|
2015
|
+
});
|
|
2016
|
+
const deadline = Date.now() + STARTUP_TIMEOUT_MS;
|
|
2017
|
+
while (Date.now() < deadline) {
|
|
2018
|
+
if (!isPidAlive(daemon.pid)) {
|
|
2019
|
+
throw new Error(
|
|
2020
|
+
`x11vnc exited during startup on ${opts.display}; check the log at ${daemon.logPath}`
|
|
2021
|
+
);
|
|
2022
|
+
}
|
|
2023
|
+
if (await isPortListening(port)) {
|
|
2024
|
+
return { pid: daemon.pid, port, logPath: daemon.logPath };
|
|
2025
|
+
}
|
|
2026
|
+
await sleep3(STARTUP_POLL_INTERVAL_MS);
|
|
2027
|
+
}
|
|
2028
|
+
await stopPid(daemon.pid);
|
|
2029
|
+
throw new Error(
|
|
2030
|
+
`x11vnc did not start listening on 127.0.0.1:${port} within ${STARTUP_TIMEOUT_MS}ms; check the log at ${daemon.logPath}`
|
|
2031
|
+
);
|
|
2032
|
+
}
|
|
2033
|
+
var XDOTOOL_TIMEOUT_MS = 5e3;
|
|
2034
|
+
var WINDOW_POLL_INTERVAL_MS = 100;
|
|
2035
|
+
var DEFAULT_WAIT_TIMEOUT_MS2 = 1e4;
|
|
2036
|
+
var LAUNCH_GRACE_MS = 300;
|
|
2037
|
+
var LAUNCH_POLL_INTERVAL_MS = 50;
|
|
2038
|
+
async function launchApp2(opts) {
|
|
2039
|
+
parseDisplayNumber(opts.display);
|
|
2040
|
+
const daemon = await startDaemon(opts.command, opts.args ?? [], {
|
|
2041
|
+
logDir: opts.logDir,
|
|
2042
|
+
cwd: opts.cwd,
|
|
2043
|
+
env: { ...opts.env, DISPLAY: opts.display }
|
|
2044
|
+
});
|
|
2045
|
+
const graceDeadline = Date.now() + LAUNCH_GRACE_MS;
|
|
2046
|
+
while (Date.now() < graceDeadline) {
|
|
2047
|
+
if (!isPidAlive(daemon.pid)) {
|
|
2048
|
+
throw new Error(
|
|
2049
|
+
`${opts.command} exited immediately after launch on ${opts.display}; check the log at ${daemon.logPath}`
|
|
2050
|
+
);
|
|
2051
|
+
}
|
|
2052
|
+
await sleep3(LAUNCH_POLL_INTERVAL_MS);
|
|
2053
|
+
}
|
|
2054
|
+
return { pid: daemon.pid, logPath: daemon.logPath };
|
|
2055
|
+
}
|
|
2056
|
+
async function runXdotoolQuery(display, args, env) {
|
|
2057
|
+
try {
|
|
2058
|
+
return await runCommand("xdotool", args, {
|
|
2059
|
+
env: { ...env, DISPLAY: display },
|
|
2060
|
+
timeoutMs: XDOTOOL_TIMEOUT_MS
|
|
2061
|
+
});
|
|
2062
|
+
} catch (error) {
|
|
2063
|
+
if (error.code === "ENOENT") {
|
|
2064
|
+
throw new Error(
|
|
2065
|
+
"xdotool was not found on PATH; install xdotool to manage windows"
|
|
2066
|
+
);
|
|
2067
|
+
}
|
|
2068
|
+
throw error;
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
async function listWindows(display, env) {
|
|
2072
|
+
parseDisplayNumber(display);
|
|
2073
|
+
const search = await runXdotoolQuery(
|
|
2074
|
+
display,
|
|
2075
|
+
["search", "--onlyvisible", "--name", "."],
|
|
2076
|
+
env
|
|
2077
|
+
);
|
|
2078
|
+
if (!search.ok) {
|
|
2079
|
+
if (search.code === 1 && search.stderr.trim() === "") {
|
|
2080
|
+
return [];
|
|
2081
|
+
}
|
|
2082
|
+
const detail = search.stderr.trim() || `exit code ${search.code}`;
|
|
2083
|
+
throw new Error(`xdotool search failed on ${display}: ${detail}`);
|
|
2084
|
+
}
|
|
2085
|
+
const ids = search.stdout.split("\n").map((line) => line.trim()).filter((line) => /^\d+$/.test(line));
|
|
2086
|
+
const windows = [];
|
|
2087
|
+
for (const id of ids) {
|
|
2088
|
+
const nameResult = await runXdotoolQuery(
|
|
2089
|
+
display,
|
|
2090
|
+
["getwindowname", id],
|
|
2091
|
+
env
|
|
2092
|
+
);
|
|
2093
|
+
windows.push({
|
|
2094
|
+
id,
|
|
2095
|
+
name: nameResult.ok ? nameResult.stdout.replace(/\n$/, "") : ""
|
|
2096
|
+
});
|
|
2097
|
+
}
|
|
2098
|
+
return windows;
|
|
2099
|
+
}
|
|
2100
|
+
async function waitForWindow(display, namePattern, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS2) {
|
|
2101
|
+
const matches = typeof namePattern === "string" ? (name) => name.includes(namePattern) : (name) => namePattern.test(name);
|
|
2102
|
+
const description = typeof namePattern === "string" ? JSON.stringify(namePattern) : String(namePattern);
|
|
2103
|
+
const deadline = Date.now() + timeoutMs;
|
|
2104
|
+
let lastSeen = [];
|
|
2105
|
+
for (; ; ) {
|
|
2106
|
+
lastSeen = await listWindows(display);
|
|
2107
|
+
const match = lastSeen.find((win) => matches(win.name));
|
|
2108
|
+
if (match !== void 0) {
|
|
2109
|
+
return match;
|
|
2110
|
+
}
|
|
2111
|
+
if (Date.now() >= deadline) {
|
|
2112
|
+
break;
|
|
2113
|
+
}
|
|
2114
|
+
await sleep3(WINDOW_POLL_INTERVAL_MS);
|
|
2115
|
+
}
|
|
2116
|
+
const seen = lastSeen.map((win) => JSON.stringify(win.name)).join(", ");
|
|
2117
|
+
throw new Error(
|
|
2118
|
+
`No window matching ${description} appeared on ${display} within ${timeoutMs}ms` + (seen === "" ? "" : `; visible windows: ${seen}`)
|
|
2119
|
+
);
|
|
2120
|
+
}
|
|
2121
|
+
var SCREENSHOT_TIMEOUT_MS2 = 2e4;
|
|
2122
|
+
var PNG_MAGIC2 = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
2123
|
+
function detectScreenshotTool(env = process.env) {
|
|
2124
|
+
if (findOnPath2("import", env) !== null) {
|
|
2125
|
+
return "import";
|
|
2126
|
+
}
|
|
2127
|
+
if (findOnPath2("xwd", env) !== null && findOnPath2("convert", env) !== null) {
|
|
2128
|
+
return "xwd";
|
|
2129
|
+
}
|
|
2130
|
+
if (findOnPath2("scrot", env) !== null) {
|
|
2131
|
+
return "scrot";
|
|
2132
|
+
}
|
|
2133
|
+
return null;
|
|
2134
|
+
}
|
|
2135
|
+
function buildScreenshotCommand(tool, display, outPath, xwdDumpPath) {
|
|
2136
|
+
parseDisplayNumber(display);
|
|
2137
|
+
switch (tool) {
|
|
2138
|
+
case "import":
|
|
2139
|
+
return [
|
|
2140
|
+
{
|
|
2141
|
+
cmd: "import",
|
|
2142
|
+
args: ["-display", display, "-window", "root", outPath]
|
|
2143
|
+
}
|
|
2144
|
+
];
|
|
2145
|
+
case "xwd": {
|
|
2146
|
+
const dumpPath = xwdDumpPath ?? `${outPath}.xwd`;
|
|
2147
|
+
return [
|
|
2148
|
+
{
|
|
2149
|
+
cmd: "xwd",
|
|
2150
|
+
args: ["-root", "-silent", "-display", display, "-out", dumpPath]
|
|
2151
|
+
},
|
|
2152
|
+
{
|
|
2153
|
+
cmd: "convert",
|
|
2154
|
+
args: [`xwd:${dumpPath}`, `png:${outPath}`]
|
|
2155
|
+
}
|
|
2156
|
+
];
|
|
2157
|
+
}
|
|
2158
|
+
case "scrot":
|
|
2159
|
+
return [
|
|
2160
|
+
{
|
|
2161
|
+
cmd: "scrot",
|
|
2162
|
+
args: ["--overwrite", outPath],
|
|
2163
|
+
requiresDisplayEnv: true
|
|
2164
|
+
}
|
|
2165
|
+
];
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
async function assertPngFile(outPath, tool) {
|
|
2169
|
+
let stat;
|
|
2170
|
+
try {
|
|
2171
|
+
stat = await fs33.promises.stat(outPath);
|
|
2172
|
+
} catch {
|
|
2173
|
+
throw new Error(
|
|
2174
|
+
`Screenshot command (${tool}) succeeded but produced no file at ${outPath}`
|
|
2175
|
+
);
|
|
2176
|
+
}
|
|
2177
|
+
if (stat.size === 0) {
|
|
2178
|
+
throw new Error(
|
|
2179
|
+
`Screenshot command (${tool}) produced an empty file at ${outPath}`
|
|
2180
|
+
);
|
|
2181
|
+
}
|
|
2182
|
+
const header = Buffer.alloc(PNG_MAGIC2.length);
|
|
2183
|
+
const handle = await fs33.promises.open(outPath, "r");
|
|
2184
|
+
try {
|
|
2185
|
+
await handle.read(header, 0, PNG_MAGIC2.length, 0);
|
|
2186
|
+
} finally {
|
|
2187
|
+
await handle.close();
|
|
2188
|
+
}
|
|
2189
|
+
if (!header.equals(PNG_MAGIC2)) {
|
|
2190
|
+
throw new Error(
|
|
2191
|
+
`Screenshot command (${tool}) produced a file without a PNG signature at ${outPath}`
|
|
2192
|
+
);
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
async function screenshot2(opts) {
|
|
2196
|
+
parseDisplayNumber(opts.display);
|
|
2197
|
+
const env = opts.env ?? process.env;
|
|
2198
|
+
const tool = opts.tool ?? detectScreenshotTool(env);
|
|
2199
|
+
if (tool === null) {
|
|
2200
|
+
throw new Error(
|
|
2201
|
+
"No screenshot tool found on PATH. Install one of: imagemagick (provides `import` and `convert`), xorg-xwd (`xwd`, combined with imagemagick `convert`), or scrot."
|
|
2202
|
+
);
|
|
2203
|
+
}
|
|
2204
|
+
await fs33.promises.mkdir(path23.dirname(opts.outPath), { recursive: true });
|
|
2205
|
+
const xwdDumpPath = tool === "xwd" ? `${opts.outPath}.${process.pid}-${crypto.randomBytes(4).toString("hex")}.xwd` : void 0;
|
|
2206
|
+
const steps = buildScreenshotCommand(
|
|
2207
|
+
tool,
|
|
2208
|
+
opts.display,
|
|
2209
|
+
opts.outPath,
|
|
2210
|
+
xwdDumpPath
|
|
2211
|
+
);
|
|
2212
|
+
try {
|
|
2213
|
+
for (const step of steps) {
|
|
2214
|
+
const result = await runCommand(step.cmd, step.args, {
|
|
2215
|
+
env: { ...opts.env, DISPLAY: opts.display },
|
|
2216
|
+
timeoutMs: SCREENSHOT_TIMEOUT_MS2
|
|
2217
|
+
});
|
|
2218
|
+
if (!result.ok) {
|
|
2219
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
2220
|
+
throw new Error(
|
|
2221
|
+
`Screenshot command failed (${step.cmd} ${step.args.join(" ")}): ${detail}`
|
|
2222
|
+
);
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
} finally {
|
|
2226
|
+
if (xwdDumpPath !== void 0) {
|
|
2227
|
+
await fs33.promises.rm(xwdDumpPath, { force: true });
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
await assertPngFile(opts.outPath, tool);
|
|
2231
|
+
return { path: opts.outPath, tool };
|
|
2232
|
+
}
|
|
2233
|
+
var TYPE_DELAY_MS = 50;
|
|
2234
|
+
var INPUT_TIMEOUT_MS = 1e4;
|
|
2235
|
+
var TYPE_TIMEOUT_MS = 6e4;
|
|
2236
|
+
function assertCoordinate2(value, label) {
|
|
2237
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
2238
|
+
throw new Error(
|
|
2239
|
+
`Invalid ${label} coordinate ${value}: expected a non-negative integer`
|
|
2240
|
+
);
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
function buildClickArgs(opts) {
|
|
2244
|
+
assertCoordinate2(opts.x, "x");
|
|
2245
|
+
assertCoordinate2(opts.y, "y");
|
|
2246
|
+
const button = opts.button ?? 1;
|
|
2247
|
+
if (!Number.isInteger(button) || button < 1 || button > 9) {
|
|
2248
|
+
throw new Error(`Invalid button ${button}: expected an integer in 1-9`);
|
|
2249
|
+
}
|
|
2250
|
+
return [
|
|
2251
|
+
"mousemove",
|
|
2252
|
+
"--sync",
|
|
2253
|
+
String(opts.x),
|
|
2254
|
+
String(opts.y),
|
|
2255
|
+
"click",
|
|
2256
|
+
String(button)
|
|
2257
|
+
];
|
|
2258
|
+
}
|
|
2259
|
+
function buildTypeArgs(text) {
|
|
2260
|
+
if (text === "") {
|
|
2261
|
+
throw new Error("Invalid text: expected a non-empty string");
|
|
2262
|
+
}
|
|
2263
|
+
return ["type", "--delay", String(TYPE_DELAY_MS), "--", text];
|
|
2264
|
+
}
|
|
2265
|
+
function buildKeyArgs(key) {
|
|
2266
|
+
if (key === "") {
|
|
2267
|
+
throw new Error("Invalid key: expected a non-empty string");
|
|
2268
|
+
}
|
|
2269
|
+
return ["key", "--", key];
|
|
2270
|
+
}
|
|
2271
|
+
async function runXdotool(display, args, timeoutMs) {
|
|
2272
|
+
parseDisplayNumber(display);
|
|
2273
|
+
await runCommand("xdotool", args, {
|
|
2274
|
+
env: { DISPLAY: display },
|
|
2275
|
+
timeoutMs,
|
|
2276
|
+
check: true
|
|
2277
|
+
});
|
|
2278
|
+
}
|
|
2279
|
+
async function click(opts) {
|
|
2280
|
+
await runXdotool(
|
|
2281
|
+
opts.display,
|
|
2282
|
+
buildClickArgs({ x: opts.x, y: opts.y, button: opts.button }),
|
|
2283
|
+
INPUT_TIMEOUT_MS
|
|
2284
|
+
);
|
|
2285
|
+
}
|
|
2286
|
+
async function typeText2(opts) {
|
|
2287
|
+
await runXdotool(opts.display, buildTypeArgs(opts.text), TYPE_TIMEOUT_MS);
|
|
2288
|
+
}
|
|
2289
|
+
async function pressKey2(opts) {
|
|
2290
|
+
await runXdotool(opts.display, buildKeyArgs(opts.key), INPUT_TIMEOUT_MS);
|
|
2291
|
+
}
|
|
2292
|
+
function desktopSessionLogDir(id, registryEnv = process.env) {
|
|
2293
|
+
return path33.join(sessionsDir(registryEnv), id);
|
|
2294
|
+
}
|
|
2295
|
+
async function createDesktopSession(opts) {
|
|
2296
|
+
const registryEnv = opts.registryEnv ?? process.env;
|
|
2297
|
+
if (opts.vnc === true && detectVncBinary({ ...process.env, ...opts.env }) === null) {
|
|
2298
|
+
throw new Error(
|
|
2299
|
+
"VNC was requested but x11vnc was not found on PATH; install x11vnc to enable it"
|
|
2300
|
+
);
|
|
2301
|
+
}
|
|
2302
|
+
const record = await createSession(
|
|
2303
|
+
{ type: "desktop", projectDir: opts.projectDir },
|
|
2304
|
+
registryEnv
|
|
2305
|
+
);
|
|
2306
|
+
const logDir = desktopSessionLogDir(record.id, registryEnv);
|
|
2307
|
+
let xvfb;
|
|
2308
|
+
let vnc;
|
|
2309
|
+
try {
|
|
2310
|
+
xvfb = await startXvfb({
|
|
2311
|
+
width: opts.width,
|
|
2312
|
+
height: opts.height,
|
|
2313
|
+
logDir,
|
|
2314
|
+
env: opts.env
|
|
2315
|
+
});
|
|
2316
|
+
if (opts.vnc === true) {
|
|
2317
|
+
vnc = await startVnc({ display: xvfb.display, logDir, env: opts.env });
|
|
2318
|
+
}
|
|
2319
|
+
const desktop = {
|
|
2320
|
+
display: xvfb.display,
|
|
2321
|
+
xvfbPid: xvfb.pid
|
|
2322
|
+
};
|
|
2323
|
+
if (vnc !== void 0) {
|
|
2324
|
+
desktop.vncPid = vnc.pid;
|
|
2325
|
+
desktop.vncPort = vnc.port;
|
|
2326
|
+
}
|
|
2327
|
+
await updateSession(record.id, { status: "running", desktop }, registryEnv);
|
|
2328
|
+
const handle = {
|
|
2329
|
+
id: record.id,
|
|
2330
|
+
display: xvfb.display,
|
|
2331
|
+
xvfbPid: xvfb.pid,
|
|
2332
|
+
logDir
|
|
2333
|
+
};
|
|
2334
|
+
if (vnc !== void 0) {
|
|
2335
|
+
handle.vncPid = vnc.pid;
|
|
2336
|
+
handle.vncPort = vnc.port;
|
|
2337
|
+
}
|
|
2338
|
+
return handle;
|
|
2339
|
+
} catch (error) {
|
|
2340
|
+
if (vnc !== void 0) {
|
|
2341
|
+
await stopPid(vnc.pid).catch(() => {
|
|
2342
|
+
});
|
|
2343
|
+
}
|
|
2344
|
+
if (xvfb !== void 0) {
|
|
2345
|
+
await stopPid(xvfb.pid).catch(() => {
|
|
2346
|
+
});
|
|
2347
|
+
}
|
|
2348
|
+
await updateSession(record.id, { status: "error" }, registryEnv).catch(
|
|
2349
|
+
() => {
|
|
2350
|
+
}
|
|
2351
|
+
);
|
|
2352
|
+
throw error;
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
async function destroyDesktopSession(id, registryEnv = process.env) {
|
|
2356
|
+
const record = await getSession(id, registryEnv);
|
|
2357
|
+
if (record === void 0) {
|
|
2358
|
+
throw new Error(`Desktop session not found: ${id}`);
|
|
2359
|
+
}
|
|
2360
|
+
const desktop = record.desktop;
|
|
2361
|
+
const failures = [];
|
|
2362
|
+
const stops = [];
|
|
2363
|
+
if (desktop?.vncPid !== void 0) {
|
|
2364
|
+
stops.push(["x11vnc", desktop.vncPid]);
|
|
2365
|
+
}
|
|
2366
|
+
if (desktop?.xvfbPid !== void 0) {
|
|
2367
|
+
stops.push(["Xvfb", desktop.xvfbPid]);
|
|
2368
|
+
}
|
|
2369
|
+
for (const [label, pid] of stops) {
|
|
2370
|
+
try {
|
|
2371
|
+
const stopped = await stopPid(pid);
|
|
2372
|
+
if (!stopped) {
|
|
2373
|
+
failures.push(
|
|
2374
|
+
new Error(`${label} (pid ${pid}) survived SIGTERM and SIGKILL`)
|
|
2375
|
+
);
|
|
2376
|
+
}
|
|
2377
|
+
} catch (error) {
|
|
2378
|
+
failures.push(
|
|
2379
|
+
error instanceof Error ? error : new Error(`Failed to stop ${label} (pid ${pid}): ${String(error)}`)
|
|
2380
|
+
);
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
if (failures.length > 0) {
|
|
2384
|
+
await updateSession(id, { status: "error" }, registryEnv).catch(() => {
|
|
2385
|
+
});
|
|
2386
|
+
throw new AggregateError(
|
|
2387
|
+
failures,
|
|
2388
|
+
`Failed to stop ${failures.length} process(es) of desktop session ${id}`
|
|
2389
|
+
);
|
|
2390
|
+
}
|
|
2391
|
+
await destroySessionRecord(id, registryEnv);
|
|
2392
|
+
}
|
|
2393
|
+
async function getDesktopSessionStatus(id, registryEnv = process.env) {
|
|
2394
|
+
const record = await getSession(id, registryEnv);
|
|
2395
|
+
if (record === void 0) {
|
|
2396
|
+
throw new Error(`Desktop session not found: ${id}`);
|
|
2397
|
+
}
|
|
2398
|
+
const desktop = record.desktop;
|
|
2399
|
+
return {
|
|
2400
|
+
record,
|
|
2401
|
+
xvfbAlive: desktop?.xvfbPid !== void 0 && isPidAlive(desktop.xvfbPid),
|
|
2402
|
+
vncAlive: desktop?.vncPid !== void 0 && isPidAlive(desktop.vncPid),
|
|
2403
|
+
displayAlive: desktop !== void 0 && isDisplayAlive(desktop.display)
|
|
2404
|
+
};
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
// ../mcp-server/dist/index.js
|
|
2408
|
+
import path43 from "path";
|
|
2409
|
+
import { z as z4 } from "zod";
|
|
2410
|
+
import path53 from "path";
|
|
2411
|
+
import { z as z5 } from "zod";
|
|
2412
|
+
import { z as z6 } from "zod";
|
|
2413
|
+
var MAX_INLINE_IMAGE_BYTES = 2 * 1024 * 1024;
|
|
2414
|
+
function resolveContext(opts = {}) {
|
|
2415
|
+
const env = opts.env ?? process.env;
|
|
2416
|
+
const projectDir = path9.resolve(
|
|
2417
|
+
opts.projectDir ?? env.PICKLAB_PROJECT_DIR ?? process.cwd()
|
|
2418
|
+
);
|
|
2419
|
+
return { projectDir, env };
|
|
2420
|
+
}
|
|
2421
|
+
function reportResult(report) {
|
|
2422
|
+
const errors = report.errors ?? [];
|
|
2423
|
+
const body = { ok: errors.length === 0 };
|
|
2424
|
+
for (const [key, value] of Object.entries(report.data ?? {})) {
|
|
2425
|
+
if (key !== "ok" && key !== "errors") {
|
|
2426
|
+
body[key] = value;
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
body.errors = errors;
|
|
2430
|
+
const content = [
|
|
2431
|
+
{ type: "text", text: JSON.stringify(body, null, 2) },
|
|
2432
|
+
...report.extraContent ?? []
|
|
2433
|
+
];
|
|
2434
|
+
return errors.length === 0 ? { content } : { content, isError: true };
|
|
2435
|
+
}
|
|
2436
|
+
async function runTool(fn) {
|
|
2437
|
+
try {
|
|
2438
|
+
return reportResult(await fn());
|
|
2439
|
+
} catch (error) {
|
|
2440
|
+
return reportResult({
|
|
2441
|
+
errors: [error instanceof Error ? error.message : String(error)]
|
|
2442
|
+
});
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
async function imageContent(filePath) {
|
|
2446
|
+
let stat;
|
|
2447
|
+
try {
|
|
2448
|
+
stat = await fs8.promises.stat(filePath);
|
|
2449
|
+
} catch {
|
|
2450
|
+
return {
|
|
2451
|
+
content: [],
|
|
2452
|
+
meta: {
|
|
2453
|
+
inlineImage: false,
|
|
2454
|
+
inlineImageReason: `image file not readable: ${filePath}`
|
|
2455
|
+
}
|
|
2456
|
+
};
|
|
2457
|
+
}
|
|
2458
|
+
if (stat.size > MAX_INLINE_IMAGE_BYTES) {
|
|
2459
|
+
return {
|
|
2460
|
+
content: [],
|
|
2461
|
+
meta: {
|
|
2462
|
+
inlineImage: false,
|
|
2463
|
+
inlineImageReason: `image is ${stat.size} bytes, over the ${MAX_INLINE_IMAGE_BYTES} byte inline limit; read it from ${filePath}`
|
|
2464
|
+
}
|
|
2465
|
+
};
|
|
2466
|
+
}
|
|
2467
|
+
const data = await fs8.promises.readFile(filePath);
|
|
2468
|
+
return {
|
|
2469
|
+
content: [
|
|
2470
|
+
{ type: "image", data: data.toString("base64"), mimeType: "image/png" }
|
|
2471
|
+
],
|
|
2472
|
+
meta: { inlineImage: true }
|
|
2473
|
+
};
|
|
2474
|
+
}
|
|
2475
|
+
async function resolveSessionRecord(ctx, type, id) {
|
|
2476
|
+
return resolveRunnableSession(type, id, {
|
|
2477
|
+
env: ctx.env,
|
|
2478
|
+
projectDir: ctx.projectDir,
|
|
2479
|
+
consumerLabel: "tool",
|
|
2480
|
+
createHint: `create one with the session_create tool (type "${type}")`,
|
|
2481
|
+
selectHint: 'pick one with the "session" argument'
|
|
2482
|
+
});
|
|
2483
|
+
}
|
|
2484
|
+
async function resolveScreenshotTarget2(ctx, args, defaultSlug, sessionId) {
|
|
2485
|
+
return resolveScreenshotTarget({
|
|
2486
|
+
projectDir: ctx.projectDir,
|
|
2487
|
+
out: args.out,
|
|
2488
|
+
outBaseDir: ctx.projectDir,
|
|
2489
|
+
runSlug: args.runSlug,
|
|
2490
|
+
defaultSlug,
|
|
2491
|
+
sessionId,
|
|
2492
|
+
conflictError: 'Use either "out" or "runSlug", not both'
|
|
2493
|
+
});
|
|
2494
|
+
}
|
|
2495
|
+
function userMessage(text) {
|
|
2496
|
+
return { messages: [{ role: "user", content: { type: "text", text } }] };
|
|
2497
|
+
}
|
|
2498
|
+
var HUMAN_BLOCKER_GUIDELINE = "If you become blocked on anything that requires a human \u2014 credentials, license keys, 2FA, a judgment call, or a click you cannot perform \u2014 use the `request_user_input` tool (or ask in your conversation) and WAIT for the answer. Never guess credentials and never abandon the session; report what you need.";
|
|
2499
|
+
function registerPrompts(server) {
|
|
2500
|
+
server.registerPrompt(
|
|
2501
|
+
"test-flutter-desktop-visually",
|
|
2502
|
+
{
|
|
2503
|
+
title: "Test a desktop app visually",
|
|
2504
|
+
description: "Build a Flutter (or any Linux) desktop app, run it in an isolated PickLab desktop session, and verify it visually with screenshots.",
|
|
2505
|
+
argsSchema: {
|
|
2506
|
+
appCommand: z.string().optional().describe(
|
|
2507
|
+
"Command that starts the app (default: the project's release binary)"
|
|
2508
|
+
),
|
|
2509
|
+
windowTitle: z.string().optional().describe("Window title (or fragment) to wait for after launch")
|
|
2510
|
+
}
|
|
2511
|
+
},
|
|
2512
|
+
({ appCommand, windowTitle }) => userMessage(
|
|
2513
|
+
[
|
|
2514
|
+
"Visually test the desktop app in an isolated PickLab session:",
|
|
2515
|
+
"",
|
|
2516
|
+
"1. Build the app first (for Flutter: `flutter build linux`). Fix any build errors before continuing.",
|
|
2517
|
+
'2. Create an isolated display with the `session_create` tool (type "desktop"). Note the returned session id.',
|
|
2518
|
+
`3. Launch the app with \`desktop_launch\` using command ${appCommand === void 0 ? "set to the built app binary (for Flutter: build/linux/x64/release/bundle/<app>)" : `\`${appCommand}\``} and arguments as an array.${windowTitle === void 0 ? " Use waitWindow with the app's window title so the launch blocks until the UI is up." : ` Use waitWindow \`${windowTitle}\` so the launch blocks until the UI is up.`}`,
|
|
2519
|
+
"4. Capture the screen with `desktop_screenshot` and inspect the returned image. Check that the UI matches what the code should render: layout, labels, colors, missing assets.",
|
|
2520
|
+
"5. Drive the app like a user: `desktop_click` at widget coordinates, `desktop_type` to fill fields, and `desktop_key` for keys/chords (Return, Tab, ctrl+s). Take a screenshot after each meaningful interaction to confirm the result.",
|
|
2521
|
+
"6. If something looks wrong, fix the code, rebuild, relaunch inside the same session, and re-verify with new screenshots.",
|
|
2522
|
+
"7. When finished, destroy the session with `session_destroy` and summarize what you verified. Use `artifact_report` to reference the captured screenshots.",
|
|
2523
|
+
"",
|
|
2524
|
+
"Never run the app on the user's real display; always work inside the PickLab session.",
|
|
2525
|
+
HUMAN_BLOCKER_GUIDELINE
|
|
2526
|
+
].join("\n")
|
|
2527
|
+
)
|
|
2528
|
+
);
|
|
2529
|
+
server.registerPrompt(
|
|
2530
|
+
"debug-android-apk",
|
|
2531
|
+
{
|
|
2532
|
+
title: "Debug an Android APK",
|
|
2533
|
+
description: "Install an APK in the PickLab Android emulator, drive its UI, and debug it with logcat and UI-tree dumps.",
|
|
2534
|
+
argsSchema: {
|
|
2535
|
+
apkPath: z.string().describe("Path to the APK to debug"),
|
|
2536
|
+
packageName: z.string().optional().describe('Application package name, e.g. "com.example.app"')
|
|
2537
|
+
}
|
|
2538
|
+
},
|
|
2539
|
+
({ apkPath, packageName: packageName2 }) => userMessage(
|
|
2540
|
+
[
|
|
2541
|
+
"Debug the Android app inside the PickLab emulator lab:",
|
|
2542
|
+
"",
|
|
2543
|
+
'1. Start an emulator session with the `android_start` tool (or `session_create` with type "android"). Wait for it to report a device serial.',
|
|
2544
|
+
`2. Install the APK with \`android_install_apk\` using apkPath \`${apkPath}\`.`,
|
|
2545
|
+
"3. Clear old logs with `android_logcat` (clear=true) so later output only shows this debugging session.",
|
|
2546
|
+
`4. Launch the app with \`android_launch_app\`${packageName2 === void 0 ? " using its package name" : ` using packageName \`${packageName2}\``}.`,
|
|
2547
|
+
"5. Capture the screen with `android_screenshot` and dump the widget hierarchy with `android_get_ui_tree`. Use the XML bounds to compute tap coordinates.",
|
|
2548
|
+
"6. Reproduce the issue: `android_tap` on widgets, `android_type` for text fields, `android_back`/`android_home` for navigation. Screenshot after each step.",
|
|
2549
|
+
"7. Read `android_logcat` output (it is secret-redacted) and look for exceptions, ANRs, or suspicious log lines from the app process.",
|
|
2550
|
+
"8. For anything else (e.g. `pm list packages`, `dumpsys`), use `android_run_adb` with an argument array.",
|
|
2551
|
+
"9. Fix the code, rebuild the APK, reinstall with `android_install_apk`, and verify the fix the same way.",
|
|
2552
|
+
"10. Destroy the session with `session_destroy` when done and summarize the root cause and fix.",
|
|
2553
|
+
"",
|
|
2554
|
+
HUMAN_BLOCKER_GUIDELINE
|
|
2555
|
+
].join("\n")
|
|
2556
|
+
)
|
|
2557
|
+
);
|
|
2558
|
+
server.registerPrompt(
|
|
2559
|
+
"run-visual-regression-check",
|
|
2560
|
+
{
|
|
2561
|
+
title: "Run a visual regression check",
|
|
2562
|
+
description: "Capture fresh screenshots of the app in a PickLab session and compare them against a baseline directory.",
|
|
2563
|
+
argsSchema: {
|
|
2564
|
+
baselineDir: z.string().describe("Directory holding the baseline screenshots"),
|
|
2565
|
+
appCommand: z.string().optional().describe("Command that starts the app")
|
|
2566
|
+
}
|
|
2567
|
+
},
|
|
2568
|
+
({ baselineDir, appCommand }) => userMessage(
|
|
2569
|
+
[
|
|
2570
|
+
"Run a visual regression check against the baseline screenshots:",
|
|
2571
|
+
"",
|
|
2572
|
+
`1. List the baseline images in \`${baselineDir}\` to learn which screens are covered and their file names.`,
|
|
2573
|
+
'2. Create an isolated display with `session_create` (type "desktop") sized to match the baselines.',
|
|
2574
|
+
`3. Launch the app with \`desktop_launch\`${appCommand === void 0 ? "" : ` using command \`${appCommand}\``} and wait for its window.`,
|
|
2575
|
+
"4. For each baseline screen: navigate to the same state (`desktop_click`, `desktop_type`, `desktop_key`), then capture it with `desktop_screenshot` using a runSlug matching the baseline name.",
|
|
2576
|
+
"5. Compare each captured screenshot with its baseline. Prefer a pixel diff (e.g. ImageMagick `compare -metric AE`) when available; otherwise inspect both images and describe differences in layout, text, color, and spacing.",
|
|
2577
|
+
"6. Collect the verdict per screen: unchanged, intentionally changed, or regression. For regressions, include the run id and the differing region.",
|
|
2578
|
+
"7. Destroy the session with `session_destroy`, then report results with `artifact_report` so every captured screenshot is referenced.",
|
|
2579
|
+
`8. Only update files in \`${baselineDir}\` if the user confirms the new rendering is intended.`,
|
|
2580
|
+
"",
|
|
2581
|
+
HUMAN_BLOCKER_GUIDELINE
|
|
2582
|
+
].join("\n")
|
|
2583
|
+
)
|
|
2584
|
+
);
|
|
2585
|
+
}
|
|
2586
|
+
var RUN_ID_PATTERN = /^[A-Za-z0-9._-]+$/;
|
|
2587
|
+
function isSafeRunId(runId) {
|
|
2588
|
+
return RUN_ID_PATTERN.test(runId) && runId !== "." && runId !== ".." && !runId.includes("..");
|
|
2589
|
+
}
|
|
2590
|
+
async function findRun(projectDir, runId) {
|
|
2591
|
+
const manifests = (await listRuns(projectDir)).filter(
|
|
2592
|
+
(candidate) => isSafeRunId(candidate.runId)
|
|
2593
|
+
);
|
|
2594
|
+
let manifest;
|
|
2595
|
+
if (runId === void 0) {
|
|
2596
|
+
manifest = manifests[0];
|
|
2597
|
+
if (manifest === void 0) {
|
|
2598
|
+
throw new Error(`No runs found under ${runsDir(projectDir)}`);
|
|
2599
|
+
}
|
|
2600
|
+
} else {
|
|
2601
|
+
manifest = manifests.find((candidate) => candidate.runId === runId);
|
|
2602
|
+
if (manifest === void 0) {
|
|
2603
|
+
throw new Error(
|
|
2604
|
+
`Run not found: ${runId} (see the artifact_list tool)`
|
|
2605
|
+
);
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2608
|
+
return { manifest, dir: path24.join(runsDir(projectDir), manifest.runId) };
|
|
2609
|
+
}
|
|
2610
|
+
function renderRunReport(manifest, dir) {
|
|
2611
|
+
const lines = [
|
|
2612
|
+
`# PickLab run ${manifest.runId}`,
|
|
2613
|
+
"",
|
|
2614
|
+
`- Slug: ${manifest.slug}`,
|
|
2615
|
+
`- Status: ${manifest.status}`,
|
|
2616
|
+
`- Created: ${manifest.createdAt}`
|
|
2617
|
+
];
|
|
2618
|
+
if (manifest.sessionId !== void 0) {
|
|
2619
|
+
lines.push(`- Session: ${manifest.sessionId}`);
|
|
2620
|
+
}
|
|
2621
|
+
lines.push(
|
|
2622
|
+
`- Directory: ${dir}`,
|
|
2623
|
+
"",
|
|
2624
|
+
`## Artifacts (${manifest.artifacts.length})`,
|
|
2625
|
+
""
|
|
2626
|
+
);
|
|
2627
|
+
if (manifest.artifacts.length === 0) {
|
|
2628
|
+
lines.push("(none)");
|
|
2629
|
+
}
|
|
2630
|
+
for (const artifact of manifest.artifacts) {
|
|
2631
|
+
lines.push(
|
|
2632
|
+
`- [${artifact.type}] ${artifact.name} \u2014 ${artifact.path} (${artifact.createdAt})`
|
|
2633
|
+
);
|
|
2634
|
+
}
|
|
2635
|
+
return lines.join("\n");
|
|
2636
|
+
}
|
|
2637
|
+
function registerArtifactTools(server, ctx) {
|
|
2638
|
+
server.registerTool(
|
|
2639
|
+
"artifact_list",
|
|
2640
|
+
{
|
|
2641
|
+
title: "List runs",
|
|
2642
|
+
description: "List recorded runs (screenshots, logs, reports) under .picklab/runs in the project directory.",
|
|
2643
|
+
inputSchema: {}
|
|
2644
|
+
},
|
|
2645
|
+
() => runTool(async () => {
|
|
2646
|
+
const manifests = await listRuns(ctx.projectDir);
|
|
2647
|
+
const runs = manifests.map((manifest) => ({
|
|
2648
|
+
runId: manifest.runId,
|
|
2649
|
+
slug: manifest.slug,
|
|
2650
|
+
createdAt: manifest.createdAt,
|
|
2651
|
+
status: manifest.status,
|
|
2652
|
+
artifacts: manifest.artifacts.length
|
|
2653
|
+
}));
|
|
2654
|
+
return { data: { projectDir: ctx.projectDir, runs } };
|
|
2655
|
+
})
|
|
2656
|
+
);
|
|
2657
|
+
server.registerTool(
|
|
2658
|
+
"artifact_report",
|
|
2659
|
+
{
|
|
2660
|
+
title: "Run report",
|
|
2661
|
+
description: "Render a report for one run (default: the most recent run), including its artifact inventory.",
|
|
2662
|
+
inputSchema: {
|
|
2663
|
+
runId: z2.string().min(1).optional().describe("Run id")
|
|
2664
|
+
}
|
|
2665
|
+
},
|
|
2666
|
+
(args) => runTool(async () => {
|
|
2667
|
+
const { manifest, dir } = await findRun(ctx.projectDir, args.runId);
|
|
2668
|
+
return {
|
|
2669
|
+
data: {
|
|
2670
|
+
runId: manifest.runId,
|
|
2671
|
+
dir,
|
|
2672
|
+
manifest,
|
|
2673
|
+
report: renderRunReport(manifest, dir)
|
|
2674
|
+
}
|
|
2675
|
+
};
|
|
2676
|
+
})
|
|
2677
|
+
);
|
|
2678
|
+
}
|
|
2679
|
+
function progressReporter(extra) {
|
|
2680
|
+
const progressToken = extra._meta?.progressToken;
|
|
2681
|
+
if (progressToken === void 0) {
|
|
2682
|
+
return void 0;
|
|
2683
|
+
}
|
|
2684
|
+
let progress = 0;
|
|
2685
|
+
return (message) => {
|
|
2686
|
+
progress += 1;
|
|
2687
|
+
void extra.sendNotification({
|
|
2688
|
+
method: "notifications/progress",
|
|
2689
|
+
params: { progressToken, progress, message }
|
|
2690
|
+
}).catch(() => {
|
|
2691
|
+
});
|
|
2692
|
+
};
|
|
2693
|
+
}
|
|
2694
|
+
async function createDesktopLeg(ctx, args) {
|
|
2695
|
+
const handle = await createDesktopSession({
|
|
2696
|
+
projectDir: ctx.projectDir,
|
|
2697
|
+
registryEnv: ctx.env,
|
|
2698
|
+
env: ctx.env,
|
|
2699
|
+
width: args.width,
|
|
2700
|
+
height: args.height,
|
|
2701
|
+
vnc: args.vnc
|
|
2702
|
+
});
|
|
2703
|
+
const summary = {
|
|
2704
|
+
id: handle.id,
|
|
2705
|
+
type: "desktop",
|
|
2706
|
+
display: handle.display,
|
|
2707
|
+
logDir: handle.logDir
|
|
2708
|
+
};
|
|
2709
|
+
if (handle.vncPort !== void 0) {
|
|
2710
|
+
summary.vncPort = handle.vncPort;
|
|
2711
|
+
}
|
|
2712
|
+
return summary;
|
|
2713
|
+
}
|
|
2714
|
+
async function createAndroidLeg(ctx, args, lifecycle) {
|
|
2715
|
+
const config = await loadConfig(ctx.projectDir, ctx.env);
|
|
2716
|
+
const avdName = args.avdName ?? config.android?.avdName;
|
|
2717
|
+
const handle = await createAndroidSession({
|
|
2718
|
+
projectDir: ctx.projectDir,
|
|
2719
|
+
registryEnv: ctx.env,
|
|
2720
|
+
env: ctx.env,
|
|
2721
|
+
onProgress: lifecycle.onProgress,
|
|
2722
|
+
signal: lifecycle.signal,
|
|
2723
|
+
...avdName === void 0 ? {} : { avdName }
|
|
2724
|
+
});
|
|
2725
|
+
return {
|
|
2726
|
+
id: handle.id,
|
|
2727
|
+
type: "android",
|
|
2728
|
+
avdName: handle.avdName,
|
|
2729
|
+
serial: handle.serial,
|
|
2730
|
+
consolePort: handle.consolePort,
|
|
2731
|
+
logDir: handle.logDir
|
|
2732
|
+
};
|
|
2733
|
+
}
|
|
2734
|
+
async function createSessions(ctx, args, lifecycle = {}) {
|
|
2735
|
+
const type = args.type ?? "desktop";
|
|
2736
|
+
const sessions = [];
|
|
2737
|
+
if (type === "desktop" || type === "desktop+android") {
|
|
2738
|
+
sessions.push(await createDesktopLeg(ctx, args));
|
|
2739
|
+
}
|
|
2740
|
+
if (type === "android" || type === "desktop+android") {
|
|
2741
|
+
try {
|
|
2742
|
+
if (lifecycle.signal?.aborted === true) {
|
|
2743
|
+
throw new Error("Session creation aborted by the client");
|
|
2744
|
+
}
|
|
2745
|
+
sessions.push(await createAndroidLeg(ctx, args, lifecycle));
|
|
2746
|
+
} catch (error) {
|
|
2747
|
+
const desktop = sessions.find((session) => session.type === "desktop");
|
|
2748
|
+
if (desktop !== void 0) {
|
|
2749
|
+
await destroyDesktopSession(desktop.id, ctx.env).catch(() => {
|
|
2750
|
+
});
|
|
2751
|
+
}
|
|
2752
|
+
throw error;
|
|
2753
|
+
}
|
|
2754
|
+
}
|
|
2755
|
+
return sessions;
|
|
2756
|
+
}
|
|
2757
|
+
async function sessionStatusEntry(ctx, record) {
|
|
2758
|
+
const entry = {
|
|
2759
|
+
id: record.id,
|
|
2760
|
+
type: record.type,
|
|
2761
|
+
status: record.status,
|
|
2762
|
+
createdAt: record.createdAt,
|
|
2763
|
+
projectDir: record.projectDir
|
|
2764
|
+
};
|
|
2765
|
+
if (record.type === "desktop") {
|
|
2766
|
+
const status = await getDesktopSessionStatus(record.id, ctx.env);
|
|
2767
|
+
entry.desktop = {
|
|
2768
|
+
...record.desktop,
|
|
2769
|
+
xvfbAlive: status.xvfbAlive,
|
|
2770
|
+
vncAlive: status.vncAlive,
|
|
2771
|
+
displayAlive: status.displayAlive
|
|
2772
|
+
};
|
|
2773
|
+
} else if (record.type === "android") {
|
|
2774
|
+
const status = await getAndroidSessionStatus(record.id, ctx.env, {
|
|
2775
|
+
env: ctx.env
|
|
2776
|
+
});
|
|
2777
|
+
entry.android = {
|
|
2778
|
+
...record.android,
|
|
2779
|
+
emulatorAlive: status.emulatorAlive,
|
|
2780
|
+
deviceState: status.deviceState
|
|
2781
|
+
};
|
|
2782
|
+
}
|
|
2783
|
+
return entry;
|
|
2784
|
+
}
|
|
2785
|
+
async function destroyRecord(ctx, record) {
|
|
2786
|
+
if (record.type === "desktop") {
|
|
2787
|
+
await destroyDesktopSession(record.id, ctx.env);
|
|
2788
|
+
} else if (record.type === "android") {
|
|
2789
|
+
await destroyAndroidSession(record.id, ctx.env, { env: ctx.env });
|
|
2790
|
+
} else {
|
|
2791
|
+
throw new Error(
|
|
2792
|
+
`Cannot destroy session ${record.id} of type "${record.type}"`
|
|
2793
|
+
);
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
function registerSessionTools(server, ctx) {
|
|
2797
|
+
server.registerTool(
|
|
2798
|
+
"session_create",
|
|
2799
|
+
{
|
|
2800
|
+
title: "Create lab session",
|
|
2801
|
+
description: 'Create an isolated lab session: a virtual desktop display (Xvfb) and/or an Android emulator. Defaults to type "desktop".',
|
|
2802
|
+
inputSchema: {
|
|
2803
|
+
type: z3.enum(["desktop", "android", "desktop+android"]).optional().describe('Session type (default "desktop")'),
|
|
2804
|
+
width: z3.number().int().positive().optional().describe("Desktop display width in pixels"),
|
|
2805
|
+
height: z3.number().int().positive().optional().describe("Desktop display height in pixels"),
|
|
2806
|
+
vnc: z3.boolean().optional().describe("Expose the desktop display over VNC"),
|
|
2807
|
+
avdName: z3.string().min(1).optional().describe("Android AVD name")
|
|
2808
|
+
}
|
|
2809
|
+
},
|
|
2810
|
+
(args, extra) => runTool(async () => ({
|
|
2811
|
+
data: {
|
|
2812
|
+
sessions: await createSessions(ctx, args, {
|
|
2813
|
+
onProgress: progressReporter(extra),
|
|
2814
|
+
signal: extra.signal
|
|
2815
|
+
})
|
|
2816
|
+
}
|
|
2817
|
+
}))
|
|
2818
|
+
);
|
|
2819
|
+
server.registerTool(
|
|
2820
|
+
"session_status",
|
|
2821
|
+
{
|
|
2822
|
+
title: "Session status",
|
|
2823
|
+
description: "Show liveness for one session (by id) or for all known sessions.",
|
|
2824
|
+
inputSchema: {
|
|
2825
|
+
sessionId: z3.string().min(1).optional().describe("Session id")
|
|
2826
|
+
}
|
|
2827
|
+
},
|
|
2828
|
+
(args) => runTool(async () => {
|
|
2829
|
+
let records;
|
|
2830
|
+
if (args.sessionId !== void 0) {
|
|
2831
|
+
const record = await getSession(args.sessionId, ctx.env);
|
|
2832
|
+
if (record === void 0) {
|
|
2833
|
+
throw new Error(`Session not found: ${args.sessionId}`);
|
|
2834
|
+
}
|
|
2835
|
+
records = [record];
|
|
2836
|
+
} else {
|
|
2837
|
+
records = await listSessions(ctx.env);
|
|
2838
|
+
}
|
|
2839
|
+
const sessions = [];
|
|
2840
|
+
for (const record of records) {
|
|
2841
|
+
sessions.push(await sessionStatusEntry(ctx, record));
|
|
2842
|
+
}
|
|
2843
|
+
return { data: { sessions } };
|
|
2844
|
+
})
|
|
2845
|
+
);
|
|
2846
|
+
server.registerTool(
|
|
2847
|
+
"session_destroy",
|
|
2848
|
+
{
|
|
2849
|
+
title: "Destroy lab session",
|
|
2850
|
+
description: "Destroy a session and stop its processes. Pass a session id, or all=true to destroy every session.",
|
|
2851
|
+
inputSchema: {
|
|
2852
|
+
sessionId: z3.string().min(1).optional().describe("Session id"),
|
|
2853
|
+
all: z3.boolean().optional().describe("Destroy all sessions")
|
|
2854
|
+
}
|
|
2855
|
+
},
|
|
2856
|
+
(args) => runTool(async () => {
|
|
2857
|
+
if (args.sessionId !== void 0 && args.all === true) {
|
|
2858
|
+
throw new Error('Pass either "sessionId" or "all", not both');
|
|
2859
|
+
}
|
|
2860
|
+
if (args.sessionId === void 0 && args.all !== true) {
|
|
2861
|
+
throw new Error('Pass a "sessionId" or set "all" to true');
|
|
2862
|
+
}
|
|
2863
|
+
const records = [];
|
|
2864
|
+
if (args.sessionId !== void 0) {
|
|
2865
|
+
const record = await getSession(args.sessionId, ctx.env);
|
|
2866
|
+
if (record === void 0) {
|
|
2867
|
+
throw new Error(`Session not found: ${args.sessionId}`);
|
|
2868
|
+
}
|
|
2869
|
+
records.push(record);
|
|
2870
|
+
} else {
|
|
2871
|
+
records.push(...await listSessions(ctx.env));
|
|
2872
|
+
}
|
|
2873
|
+
const destroyed = [];
|
|
2874
|
+
const errors = [];
|
|
2875
|
+
for (const record of records) {
|
|
2876
|
+
try {
|
|
2877
|
+
await destroyRecord(ctx, record);
|
|
2878
|
+
destroyed.push(record.id);
|
|
2879
|
+
} catch (error) {
|
|
2880
|
+
errors.push(
|
|
2881
|
+
`${record.id}: ${error instanceof Error ? error.message : String(error)}`
|
|
2882
|
+
);
|
|
2883
|
+
}
|
|
2884
|
+
}
|
|
2885
|
+
return { data: { destroyed }, errors };
|
|
2886
|
+
})
|
|
2887
|
+
);
|
|
2888
|
+
}
|
|
2889
|
+
var SAFE_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
|
|
2890
|
+
var MAX_BLOB_BYTES = 8 * 1024 * 1024;
|
|
2891
|
+
function decodeVariable(variables, label) {
|
|
2892
|
+
const raw = variables[label];
|
|
2893
|
+
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
2894
|
+
if (value === void 0) {
|
|
2895
|
+
throw new Error(`Missing "${label}" in resource URI`);
|
|
2896
|
+
}
|
|
2897
|
+
let decoded;
|
|
2898
|
+
try {
|
|
2899
|
+
decoded = decodeURIComponent(value);
|
|
2900
|
+
} catch {
|
|
2901
|
+
throw new Error(`Invalid "${label}" in resource URI`);
|
|
2902
|
+
}
|
|
2903
|
+
if (!SAFE_NAME_PATTERN.test(decoded) || decoded === "." || decoded.includes("..")) {
|
|
2904
|
+
throw new Error(`Invalid "${label}" in resource URI: ${decoded}`);
|
|
2905
|
+
}
|
|
2906
|
+
return decoded;
|
|
2907
|
+
}
|
|
2908
|
+
function runFilePath(ctx, runId, subdir, name) {
|
|
2909
|
+
const base = path34.join(runsDir(ctx.projectDir), runId, subdir);
|
|
2910
|
+
const resolved = path34.resolve(base, name);
|
|
2911
|
+
if (resolved !== path34.join(base, name)) {
|
|
2912
|
+
throw new Error(`Invalid resource path: ${name}`);
|
|
2913
|
+
}
|
|
2914
|
+
return resolved;
|
|
2915
|
+
}
|
|
2916
|
+
async function isRunDirSafe(ctx, runId) {
|
|
2917
|
+
const root = runsDir(ctx.projectDir);
|
|
2918
|
+
const runDir = path34.join(root, runId);
|
|
2919
|
+
try {
|
|
2920
|
+
const realProject = await fs24.promises.realpath(ctx.projectDir);
|
|
2921
|
+
const realRoot = await fs24.promises.realpath(root);
|
|
2922
|
+
if (realRoot !== path34.join(realProject, ".picklab", "runs")) {
|
|
2923
|
+
return false;
|
|
2924
|
+
}
|
|
2925
|
+
const realRunDir = await fs24.promises.realpath(runDir);
|
|
2926
|
+
return realRunDir === path34.join(realRoot, runId);
|
|
2927
|
+
} catch {
|
|
2928
|
+
return false;
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
async function assertWithinSubdir(ctx, runId, subdir, filePath, notFound) {
|
|
2932
|
+
if (!await isRunDirSafe(ctx, runId)) {
|
|
2933
|
+
throw notFound();
|
|
2934
|
+
}
|
|
2935
|
+
try {
|
|
2936
|
+
const lst = await fs24.promises.lstat(filePath);
|
|
2937
|
+
if (lst.isSymbolicLink()) {
|
|
2938
|
+
throw notFound();
|
|
2939
|
+
}
|
|
2940
|
+
} catch (err) {
|
|
2941
|
+
if (err.code === void 0) {
|
|
2942
|
+
throw err;
|
|
2943
|
+
}
|
|
2944
|
+
return;
|
|
2945
|
+
}
|
|
2946
|
+
const runDir = path34.join(runsDir(ctx.projectDir), runId);
|
|
2947
|
+
const base = path34.join(runDir, subdir);
|
|
2948
|
+
let realRunDir;
|
|
2949
|
+
let realBase;
|
|
2950
|
+
let realFile;
|
|
2951
|
+
try {
|
|
2952
|
+
realRunDir = await fs24.promises.realpath(runDir);
|
|
2953
|
+
realBase = await fs24.promises.realpath(base);
|
|
2954
|
+
realFile = await fs24.promises.realpath(filePath);
|
|
2955
|
+
} catch {
|
|
2956
|
+
return;
|
|
2957
|
+
}
|
|
2958
|
+
const expectedBase = path34.join(realRunDir, subdir);
|
|
2959
|
+
if (realBase !== expectedBase) {
|
|
2960
|
+
throw notFound();
|
|
2961
|
+
}
|
|
2962
|
+
if (realFile !== realBase && !realFile.startsWith(realBase + path34.sep)) {
|
|
2963
|
+
throw notFound();
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
async function assertManifestWithinRun(ctx, runId, manifestPath, notFound) {
|
|
2967
|
+
if (!await isRunDirSafe(ctx, runId)) {
|
|
2968
|
+
throw notFound();
|
|
2969
|
+
}
|
|
2970
|
+
const runDir = path34.join(runsDir(ctx.projectDir), runId);
|
|
2971
|
+
let realRunDir;
|
|
2972
|
+
let realFile;
|
|
2973
|
+
try {
|
|
2974
|
+
realRunDir = await fs24.promises.realpath(runDir);
|
|
2975
|
+
realFile = await fs24.promises.realpath(manifestPath);
|
|
2976
|
+
} catch {
|
|
2977
|
+
return;
|
|
2978
|
+
}
|
|
2979
|
+
if (realFile !== path34.join(realRunDir, "manifest.json")) {
|
|
2980
|
+
throw notFound();
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2983
|
+
async function isManifestSafe(ctx, runId) {
|
|
2984
|
+
if (!await isRunDirSafe(ctx, runId)) return false;
|
|
2985
|
+
const runDir = path34.join(runsDir(ctx.projectDir), runId);
|
|
2986
|
+
const manifestPath = path34.join(runDir, "manifest.json");
|
|
2987
|
+
try {
|
|
2988
|
+
const realRunDir = await fs24.promises.realpath(runDir);
|
|
2989
|
+
const realFile = await fs24.promises.realpath(manifestPath);
|
|
2990
|
+
return realFile === path34.join(realRunDir, "manifest.json");
|
|
2991
|
+
} catch {
|
|
2992
|
+
return false;
|
|
2993
|
+
}
|
|
2994
|
+
}
|
|
2995
|
+
async function listSafeRuns(ctx) {
|
|
2996
|
+
const safe = [];
|
|
2997
|
+
for (const manifest of await listRuns(ctx.projectDir)) {
|
|
2998
|
+
if (!isSafeRunId(manifest.runId)) continue;
|
|
2999
|
+
if (!await isManifestSafe(ctx, manifest.runId)) continue;
|
|
3000
|
+
safe.push(manifest);
|
|
3001
|
+
}
|
|
3002
|
+
return safe;
|
|
3003
|
+
}
|
|
3004
|
+
async function listRunFiles(ctx, subdir) {
|
|
3005
|
+
const entries = [];
|
|
3006
|
+
for (const manifest of await listSafeRuns(ctx)) {
|
|
3007
|
+
const runDir = path34.join(runsDir(ctx.projectDir), manifest.runId);
|
|
3008
|
+
const dir = path34.join(runDir, subdir);
|
|
3009
|
+
try {
|
|
3010
|
+
const realRunDir = await fs24.promises.realpath(runDir);
|
|
3011
|
+
const realDir = await fs24.promises.realpath(dir);
|
|
3012
|
+
if (realDir !== path34.join(realRunDir, subdir)) continue;
|
|
3013
|
+
} catch {
|
|
3014
|
+
continue;
|
|
3015
|
+
}
|
|
3016
|
+
let names;
|
|
3017
|
+
try {
|
|
3018
|
+
names = await fs24.promises.readdir(dir);
|
|
3019
|
+
} catch {
|
|
3020
|
+
continue;
|
|
3021
|
+
}
|
|
3022
|
+
for (const name of names) {
|
|
3023
|
+
if (!SAFE_NAME_PATTERN.test(name) || name.includes("..")) continue;
|
|
3024
|
+
let entry;
|
|
3025
|
+
try {
|
|
3026
|
+
entry = await fs24.promises.lstat(path34.join(dir, name));
|
|
3027
|
+
} catch {
|
|
3028
|
+
continue;
|
|
3029
|
+
}
|
|
3030
|
+
if (entry.isSymbolicLink()) continue;
|
|
3031
|
+
entries.push({ runId: manifest.runId, name });
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
return entries;
|
|
3035
|
+
}
|
|
3036
|
+
function registerResources(server, ctx) {
|
|
3037
|
+
server.registerResource(
|
|
3038
|
+
"runs",
|
|
3039
|
+
"picklab://runs",
|
|
3040
|
+
{
|
|
3041
|
+
title: "PickLab runs",
|
|
3042
|
+
description: "Index of recorded runs under .picklab/runs",
|
|
3043
|
+
mimeType: "application/json"
|
|
3044
|
+
},
|
|
3045
|
+
async (uri) => {
|
|
3046
|
+
const runs = (await listSafeRuns(ctx)).map((manifest) => ({
|
|
3047
|
+
runId: manifest.runId,
|
|
3048
|
+
slug: manifest.slug,
|
|
3049
|
+
createdAt: manifest.createdAt,
|
|
3050
|
+
status: manifest.status,
|
|
3051
|
+
artifacts: manifest.artifacts.length
|
|
3052
|
+
}));
|
|
3053
|
+
return {
|
|
3054
|
+
contents: [
|
|
3055
|
+
{
|
|
3056
|
+
uri: uri.href,
|
|
3057
|
+
mimeType: "application/json",
|
|
3058
|
+
text: JSON.stringify(runs, null, 2)
|
|
3059
|
+
}
|
|
3060
|
+
]
|
|
3061
|
+
};
|
|
3062
|
+
}
|
|
3063
|
+
);
|
|
3064
|
+
server.registerResource(
|
|
3065
|
+
"run-manifest",
|
|
3066
|
+
new ResourceTemplate("picklab://runs/{runId}/manifest", {
|
|
3067
|
+
list: async () => ({
|
|
3068
|
+
resources: (await listSafeRuns(ctx)).map((manifest) => ({
|
|
3069
|
+
uri: `picklab://runs/${manifest.runId}/manifest`,
|
|
3070
|
+
name: `Run ${manifest.runId} manifest`,
|
|
3071
|
+
mimeType: "application/json"
|
|
3072
|
+
}))
|
|
3073
|
+
})
|
|
3074
|
+
}),
|
|
3075
|
+
{
|
|
3076
|
+
title: "Run manifest",
|
|
3077
|
+
description: "Manifest (status and artifacts) of a recorded run",
|
|
3078
|
+
mimeType: "application/json"
|
|
3079
|
+
},
|
|
3080
|
+
async (uri, variables) => {
|
|
3081
|
+
const runId = decodeVariable(variables, "runId");
|
|
3082
|
+
const manifestPath = path34.join(
|
|
3083
|
+
runsDir(ctx.projectDir),
|
|
3084
|
+
runId,
|
|
3085
|
+
"manifest.json"
|
|
3086
|
+
);
|
|
3087
|
+
await assertManifestWithinRun(
|
|
3088
|
+
ctx,
|
|
3089
|
+
runId,
|
|
3090
|
+
manifestPath,
|
|
3091
|
+
() => new Error(`Run not found: ${runId}`)
|
|
3092
|
+
);
|
|
3093
|
+
let raw;
|
|
3094
|
+
try {
|
|
3095
|
+
raw = await fs24.promises.readFile(manifestPath, "utf8");
|
|
3096
|
+
} catch {
|
|
3097
|
+
throw new Error(`Run not found: ${runId}`);
|
|
3098
|
+
}
|
|
3099
|
+
return {
|
|
3100
|
+
contents: [
|
|
3101
|
+
{ uri: uri.href, mimeType: "application/json", text: raw }
|
|
3102
|
+
]
|
|
3103
|
+
};
|
|
3104
|
+
}
|
|
3105
|
+
);
|
|
3106
|
+
server.registerResource(
|
|
3107
|
+
"run-screenshot",
|
|
3108
|
+
new ResourceTemplate("picklab://runs/{runId}/screenshots/{name}", {
|
|
3109
|
+
list: async () => ({
|
|
3110
|
+
resources: (await listRunFiles(ctx, "screenshots")).map((entry) => ({
|
|
3111
|
+
uri: `picklab://runs/${entry.runId}/screenshots/${entry.name}`,
|
|
3112
|
+
name: `Run ${entry.runId} screenshot ${entry.name}`,
|
|
3113
|
+
mimeType: "image/png"
|
|
3114
|
+
}))
|
|
3115
|
+
})
|
|
3116
|
+
}),
|
|
3117
|
+
{
|
|
3118
|
+
title: "Run screenshot",
|
|
3119
|
+
description: "PNG screenshot captured during a run",
|
|
3120
|
+
mimeType: "image/png"
|
|
3121
|
+
},
|
|
3122
|
+
async (uri, variables) => {
|
|
3123
|
+
const runId = decodeVariable(variables, "runId");
|
|
3124
|
+
const name = decodeVariable(variables, "name");
|
|
3125
|
+
if (!name.endsWith(".png")) {
|
|
3126
|
+
throw new Error(`Not a PNG screenshot: ${name}`);
|
|
3127
|
+
}
|
|
3128
|
+
const filePath = runFilePath(ctx, runId, "screenshots", name);
|
|
3129
|
+
await assertWithinSubdir(
|
|
3130
|
+
ctx,
|
|
3131
|
+
runId,
|
|
3132
|
+
"screenshots",
|
|
3133
|
+
filePath,
|
|
3134
|
+
() => new Error(`Screenshot not found: ${runId}/${name}`)
|
|
3135
|
+
);
|
|
3136
|
+
let stat;
|
|
3137
|
+
try {
|
|
3138
|
+
stat = await fs24.promises.stat(filePath);
|
|
3139
|
+
} catch {
|
|
3140
|
+
throw new Error(`Screenshot not found: ${runId}/${name}`);
|
|
3141
|
+
}
|
|
3142
|
+
if (stat.size > MAX_BLOB_BYTES) {
|
|
3143
|
+
return {
|
|
3144
|
+
contents: [
|
|
3145
|
+
{
|
|
3146
|
+
uri: uri.href,
|
|
3147
|
+
mimeType: "text/plain",
|
|
3148
|
+
text: `Screenshot ${runId}/${name} is ${stat.size} bytes, over the ${MAX_BLOB_BYTES} byte inline limit; read the file directly at ${filePath}`
|
|
3149
|
+
}
|
|
3150
|
+
]
|
|
3151
|
+
};
|
|
3152
|
+
}
|
|
3153
|
+
let data;
|
|
3154
|
+
try {
|
|
3155
|
+
data = await fs24.promises.readFile(filePath);
|
|
3156
|
+
} catch {
|
|
3157
|
+
throw new Error(`Screenshot not found: ${runId}/${name}`);
|
|
3158
|
+
}
|
|
3159
|
+
return {
|
|
3160
|
+
contents: [
|
|
3161
|
+
{
|
|
3162
|
+
uri: uri.href,
|
|
3163
|
+
mimeType: "image/png",
|
|
3164
|
+
blob: data.toString("base64")
|
|
3165
|
+
}
|
|
3166
|
+
]
|
|
3167
|
+
};
|
|
3168
|
+
}
|
|
3169
|
+
);
|
|
3170
|
+
server.registerResource(
|
|
3171
|
+
"run-log",
|
|
3172
|
+
new ResourceTemplate("picklab://runs/{runId}/logs/{name}", {
|
|
3173
|
+
list: async () => ({
|
|
3174
|
+
resources: (await listRunFiles(ctx, "logs")).map((entry) => ({
|
|
3175
|
+
uri: `picklab://runs/${entry.runId}/logs/${entry.name}`,
|
|
3176
|
+
name: `Run ${entry.runId} log ${entry.name}`,
|
|
3177
|
+
mimeType: "text/plain"
|
|
3178
|
+
}))
|
|
3179
|
+
})
|
|
3180
|
+
}),
|
|
3181
|
+
{
|
|
3182
|
+
title: "Run log",
|
|
3183
|
+
description: "Log captured during a run (secrets redacted)",
|
|
3184
|
+
mimeType: "text/plain"
|
|
3185
|
+
},
|
|
3186
|
+
async (uri, variables) => {
|
|
3187
|
+
const runId = decodeVariable(variables, "runId");
|
|
3188
|
+
const name = decodeVariable(variables, "name");
|
|
3189
|
+
const filePath = runFilePath(ctx, runId, "logs", name);
|
|
3190
|
+
await assertWithinSubdir(
|
|
3191
|
+
ctx,
|
|
3192
|
+
runId,
|
|
3193
|
+
"logs",
|
|
3194
|
+
filePath,
|
|
3195
|
+
() => new Error(`Log not found: ${runId}/${name}`)
|
|
3196
|
+
);
|
|
3197
|
+
let raw;
|
|
3198
|
+
try {
|
|
3199
|
+
raw = await fs24.promises.readFile(filePath, "utf8");
|
|
3200
|
+
} catch {
|
|
3201
|
+
throw new Error(`Log not found: ${runId}/${name}`);
|
|
3202
|
+
}
|
|
3203
|
+
return {
|
|
3204
|
+
contents: [
|
|
3205
|
+
{
|
|
3206
|
+
uri: uri.href,
|
|
3207
|
+
mimeType: "text/plain",
|
|
3208
|
+
text: redactSecrets(raw)
|
|
3209
|
+
}
|
|
3210
|
+
]
|
|
3211
|
+
};
|
|
3212
|
+
}
|
|
3213
|
+
);
|
|
3214
|
+
server.registerResource(
|
|
3215
|
+
"session-status",
|
|
3216
|
+
new ResourceTemplate("picklab://sessions/{sessionId}/status", {
|
|
3217
|
+
list: async () => ({
|
|
3218
|
+
resources: (await listSessions(ctx.env)).map((record) => ({
|
|
3219
|
+
uri: `picklab://sessions/${record.id}/status`,
|
|
3220
|
+
name: `Session ${record.id} status`,
|
|
3221
|
+
mimeType: "application/json"
|
|
3222
|
+
}))
|
|
3223
|
+
})
|
|
3224
|
+
}),
|
|
3225
|
+
{
|
|
3226
|
+
title: "Session status",
|
|
3227
|
+
description: "Liveness and details of a lab session",
|
|
3228
|
+
mimeType: "application/json"
|
|
3229
|
+
},
|
|
3230
|
+
async (uri, variables) => {
|
|
3231
|
+
const sessionId = decodeVariable(variables, "sessionId");
|
|
3232
|
+
const record = await getSession(sessionId, ctx.env);
|
|
3233
|
+
if (record === void 0) {
|
|
3234
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
3235
|
+
}
|
|
3236
|
+
const entry = await sessionStatusEntry(ctx, record);
|
|
3237
|
+
return {
|
|
3238
|
+
contents: [
|
|
3239
|
+
{
|
|
3240
|
+
uri: uri.href,
|
|
3241
|
+
mimeType: "application/json",
|
|
3242
|
+
text: JSON.stringify(entry, null, 2)
|
|
3243
|
+
}
|
|
3244
|
+
]
|
|
3245
|
+
};
|
|
3246
|
+
}
|
|
3247
|
+
);
|
|
3248
|
+
}
|
|
3249
|
+
var targetArgs = {
|
|
3250
|
+
session: z4.string().min(1).optional().describe("Android session id (default: the single running session)"),
|
|
3251
|
+
serial: z4.string().min(1).optional().describe("Explicit adb device serial instead of a session")
|
|
3252
|
+
};
|
|
3253
|
+
async function resolveAndroidTarget(ctx, args) {
|
|
3254
|
+
if (args.serial !== void 0 && args.session !== void 0) {
|
|
3255
|
+
throw new Error('Pass either "session" or "serial", not both');
|
|
3256
|
+
}
|
|
3257
|
+
if (args.serial !== void 0) {
|
|
3258
|
+
return { serial: args.serial };
|
|
3259
|
+
}
|
|
3260
|
+
const record = await resolveSessionRecord(ctx, "android", args.session);
|
|
3261
|
+
const serial = record.android?.serial;
|
|
3262
|
+
if (serial === void 0) {
|
|
3263
|
+
throw new Error(`Session ${record.id} has no device serial recorded`);
|
|
3264
|
+
}
|
|
3265
|
+
return { serial, sessionId: record.id };
|
|
3266
|
+
}
|
|
3267
|
+
function targetData(target) {
|
|
3268
|
+
const data = { serial: target.serial };
|
|
3269
|
+
if (target.sessionId !== void 0) {
|
|
3270
|
+
data.sessionId = target.sessionId;
|
|
3271
|
+
}
|
|
3272
|
+
return data;
|
|
3273
|
+
}
|
|
3274
|
+
function registerAndroidTools(server, ctx) {
|
|
3275
|
+
server.registerTool(
|
|
3276
|
+
"android_start",
|
|
3277
|
+
{
|
|
3278
|
+
title: "Start Android session",
|
|
3279
|
+
description: "Start an Android emulator session (boots the dedicated PickLab AVD).",
|
|
3280
|
+
inputSchema: {
|
|
3281
|
+
avdName: z4.string().min(1).optional().describe("Android AVD name")
|
|
3282
|
+
}
|
|
3283
|
+
},
|
|
3284
|
+
(args, extra) => runTool(async () => ({
|
|
3285
|
+
data: {
|
|
3286
|
+
sessions: await createSessions(
|
|
3287
|
+
ctx,
|
|
3288
|
+
{ type: "android", avdName: args.avdName },
|
|
3289
|
+
{ onProgress: progressReporter(extra), signal: extra.signal }
|
|
3290
|
+
)
|
|
3291
|
+
}
|
|
3292
|
+
}))
|
|
3293
|
+
);
|
|
3294
|
+
server.registerTool(
|
|
3295
|
+
"android_install_apk",
|
|
3296
|
+
{
|
|
3297
|
+
title: "Install APK",
|
|
3298
|
+
description: "Install an APK on the device (path relative to the project dir).",
|
|
3299
|
+
inputSchema: {
|
|
3300
|
+
...targetArgs,
|
|
3301
|
+
apkPath: z4.string().min(1).describe("Path to the APK")
|
|
3302
|
+
}
|
|
3303
|
+
},
|
|
3304
|
+
(args) => runTool(async () => {
|
|
3305
|
+
const target = await resolveAndroidTarget(ctx, args);
|
|
3306
|
+
const apkPath = path43.resolve(ctx.projectDir, args.apkPath);
|
|
3307
|
+
await installApk({ serial: target.serial, apkPath, env: ctx.env });
|
|
3308
|
+
return { data: { ...targetData(target), apkPath } };
|
|
3309
|
+
})
|
|
3310
|
+
);
|
|
3311
|
+
server.registerTool(
|
|
3312
|
+
"android_launch_app",
|
|
3313
|
+
{
|
|
3314
|
+
title: "Launch Android app",
|
|
3315
|
+
description: "Launch an installed app by package name.",
|
|
3316
|
+
inputSchema: {
|
|
3317
|
+
...targetArgs,
|
|
3318
|
+
packageName: z4.string().min(1).describe('Application package name, e.g. "com.example.app"'),
|
|
3319
|
+
activity: z4.string().min(1).optional().describe('Activity to start, e.g. ".MainActivity"')
|
|
3320
|
+
}
|
|
3321
|
+
},
|
|
3322
|
+
(args) => runTool(async () => {
|
|
3323
|
+
const target = await resolveAndroidTarget(ctx, args);
|
|
3324
|
+
await launchApp({
|
|
3325
|
+
serial: target.serial,
|
|
3326
|
+
packageName: args.packageName,
|
|
3327
|
+
activity: args.activity,
|
|
3328
|
+
env: ctx.env
|
|
3329
|
+
});
|
|
3330
|
+
return {
|
|
3331
|
+
data: { ...targetData(target), packageName: args.packageName }
|
|
3332
|
+
};
|
|
3333
|
+
})
|
|
3334
|
+
);
|
|
3335
|
+
server.registerTool(
|
|
3336
|
+
"android_screenshot",
|
|
3337
|
+
{
|
|
3338
|
+
title: "Android screenshot",
|
|
3339
|
+
description: "Capture the device screen as PNG. By default the image is recorded as an artifact of a new run under .picklab/runs and returned inline when small enough.",
|
|
3340
|
+
inputSchema: {
|
|
3341
|
+
...targetArgs,
|
|
3342
|
+
out: z4.string().min(1).optional().describe("Explicit output path instead of a run artifact"),
|
|
3343
|
+
runSlug: z4.string().min(1).optional().describe('Run slug (default "android")')
|
|
3344
|
+
}
|
|
3345
|
+
},
|
|
3346
|
+
(args) => runTool(async () => {
|
|
3347
|
+
const target = await resolveAndroidTarget(ctx, args);
|
|
3348
|
+
const destination = await resolveScreenshotTarget2(
|
|
3349
|
+
ctx,
|
|
3350
|
+
args,
|
|
3351
|
+
"android",
|
|
3352
|
+
target.sessionId
|
|
3353
|
+
);
|
|
3354
|
+
const data = await captureToTarget(destination, async () => {
|
|
3355
|
+
await screenshot({
|
|
3356
|
+
serial: target.serial,
|
|
3357
|
+
outPath: destination.outPath,
|
|
3358
|
+
env: ctx.env
|
|
3359
|
+
});
|
|
3360
|
+
});
|
|
3361
|
+
Object.assign(data, targetData(target));
|
|
3362
|
+
const image = await imageContent(destination.outPath);
|
|
3363
|
+
Object.assign(data, image.meta);
|
|
3364
|
+
return { data, extraContent: image.content };
|
|
3365
|
+
})
|
|
3366
|
+
);
|
|
3367
|
+
server.registerTool(
|
|
3368
|
+
"android_tap",
|
|
3369
|
+
{
|
|
3370
|
+
title: "Android tap",
|
|
3371
|
+
description: "Tap at the given screen coordinates.",
|
|
3372
|
+
inputSchema: {
|
|
3373
|
+
...targetArgs,
|
|
3374
|
+
x: z4.number().int().nonnegative().describe("X coordinate"),
|
|
3375
|
+
y: z4.number().int().nonnegative().describe("Y coordinate")
|
|
3376
|
+
}
|
|
3377
|
+
},
|
|
3378
|
+
(args) => runTool(async () => {
|
|
3379
|
+
const target = await resolveAndroidTarget(ctx, args);
|
|
3380
|
+
await tap({ serial: target.serial, x: args.x, y: args.y, env: ctx.env });
|
|
3381
|
+
return { data: { ...targetData(target), x: args.x, y: args.y } };
|
|
3382
|
+
})
|
|
3383
|
+
);
|
|
3384
|
+
server.registerTool(
|
|
3385
|
+
"android_type",
|
|
3386
|
+
{
|
|
3387
|
+
title: "Android type",
|
|
3388
|
+
description: "Type ASCII text into the focused field.",
|
|
3389
|
+
inputSchema: {
|
|
3390
|
+
...targetArgs,
|
|
3391
|
+
text: z4.string().min(1).describe("Text to type")
|
|
3392
|
+
}
|
|
3393
|
+
},
|
|
3394
|
+
(args) => runTool(async () => {
|
|
3395
|
+
const target = await resolveAndroidTarget(ctx, args);
|
|
3396
|
+
await typeText({ serial: target.serial, text: args.text, env: ctx.env });
|
|
3397
|
+
return { data: { ...targetData(target), length: args.text.length } };
|
|
3398
|
+
})
|
|
3399
|
+
);
|
|
3400
|
+
server.registerTool(
|
|
3401
|
+
"android_back",
|
|
3402
|
+
{
|
|
3403
|
+
title: "Android back",
|
|
3404
|
+
description: "Press the back button.",
|
|
3405
|
+
inputSchema: { ...targetArgs }
|
|
3406
|
+
},
|
|
3407
|
+
(args) => runTool(async () => {
|
|
3408
|
+
const target = await resolveAndroidTarget(ctx, args);
|
|
3409
|
+
await back({ serial: target.serial, env: ctx.env });
|
|
3410
|
+
return { data: targetData(target) };
|
|
3411
|
+
})
|
|
3412
|
+
);
|
|
3413
|
+
server.registerTool(
|
|
3414
|
+
"android_home",
|
|
3415
|
+
{
|
|
3416
|
+
title: "Android home",
|
|
3417
|
+
description: "Press the home button.",
|
|
3418
|
+
inputSchema: { ...targetArgs }
|
|
3419
|
+
},
|
|
3420
|
+
(args) => runTool(async () => {
|
|
3421
|
+
const target = await resolveAndroidTarget(ctx, args);
|
|
3422
|
+
await home({ serial: target.serial, env: ctx.env });
|
|
3423
|
+
return { data: targetData(target) };
|
|
3424
|
+
})
|
|
3425
|
+
);
|
|
3426
|
+
server.registerTool(
|
|
3427
|
+
"android_get_ui_tree",
|
|
3428
|
+
{
|
|
3429
|
+
title: "Android UI tree",
|
|
3430
|
+
description: "Dump the current UI hierarchy as XML (uiautomator dump). Use it to find widget bounds before tapping.",
|
|
3431
|
+
inputSchema: { ...targetArgs }
|
|
3432
|
+
},
|
|
3433
|
+
(args) => runTool(async () => {
|
|
3434
|
+
const target = await resolveAndroidTarget(ctx, args);
|
|
3435
|
+
const xml = redactSecrets(
|
|
3436
|
+
await getUiTree({ serial: target.serial, env: ctx.env })
|
|
3437
|
+
);
|
|
3438
|
+
return { data: { ...targetData(target), xml } };
|
|
3439
|
+
})
|
|
3440
|
+
);
|
|
3441
|
+
server.registerTool(
|
|
3442
|
+
"android_logcat",
|
|
3443
|
+
{
|
|
3444
|
+
title: "Android logcat",
|
|
3445
|
+
description: "Dump recent device log lines with secrets redacted, or clear the log buffer with clear=true.",
|
|
3446
|
+
inputSchema: {
|
|
3447
|
+
...targetArgs,
|
|
3448
|
+
lines: z4.number().int().positive().optional().describe("Number of recent lines (default 500)"),
|
|
3449
|
+
filter: z4.string().min(1).optional().describe('Logcat filter spec, e.g. "ActivityManager:I *:S"'),
|
|
3450
|
+
clear: z4.boolean().optional().describe("Clear the log buffer instead of dumping it")
|
|
3451
|
+
}
|
|
3452
|
+
},
|
|
3453
|
+
(args) => runTool(async () => {
|
|
3454
|
+
const target = await resolveAndroidTarget(ctx, args);
|
|
3455
|
+
if (args.clear === true) {
|
|
3456
|
+
await clearLogcat({ serial: target.serial, env: ctx.env });
|
|
3457
|
+
return { data: { ...targetData(target), cleared: true } };
|
|
3458
|
+
}
|
|
3459
|
+
const output = redactSecrets(
|
|
3460
|
+
await logcat({
|
|
3461
|
+
serial: target.serial,
|
|
3462
|
+
lines: args.lines,
|
|
3463
|
+
filter: args.filter,
|
|
3464
|
+
env: ctx.env
|
|
3465
|
+
})
|
|
3466
|
+
);
|
|
3467
|
+
return { data: { ...targetData(target), output } };
|
|
3468
|
+
})
|
|
3469
|
+
);
|
|
3470
|
+
server.registerTool(
|
|
3471
|
+
"android_run_adb",
|
|
3472
|
+
{
|
|
3473
|
+
title: "Run adb command",
|
|
3474
|
+
description: "Run a raw adb command as an argument array. Output is redacted; use the picklab CLI for unredacted adb access.",
|
|
3475
|
+
inputSchema: {
|
|
3476
|
+
...targetArgs,
|
|
3477
|
+
args: z4.array(z4.string()).min(1).describe('adb arguments, e.g. ["shell", "pm", "list", "packages"]'),
|
|
3478
|
+
timeoutMs: z4.number().int().positive().optional().describe("Command timeout in milliseconds")
|
|
3479
|
+
}
|
|
3480
|
+
},
|
|
3481
|
+
(args) => runTool(async () => {
|
|
3482
|
+
let target;
|
|
3483
|
+
if (args.serial !== void 0 || args.session !== void 0) {
|
|
3484
|
+
target = await resolveAndroidTarget(ctx, args);
|
|
3485
|
+
} else {
|
|
3486
|
+
try {
|
|
3487
|
+
target = await resolveAndroidTarget(ctx, args);
|
|
3488
|
+
} catch (error) {
|
|
3489
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3490
|
+
if (!message.startsWith("No running android session")) {
|
|
3491
|
+
throw error;
|
|
3492
|
+
}
|
|
3493
|
+
target = void 0;
|
|
3494
|
+
}
|
|
3495
|
+
}
|
|
3496
|
+
const result = await runAdb({
|
|
3497
|
+
args: args.args,
|
|
3498
|
+
serial: target?.serial,
|
|
3499
|
+
env: ctx.env,
|
|
3500
|
+
timeoutMs: args.timeoutMs
|
|
3501
|
+
});
|
|
3502
|
+
const data = {
|
|
3503
|
+
...target === void 0 ? {} : targetData(target),
|
|
3504
|
+
code: result.code,
|
|
3505
|
+
stdout: redactSecrets(result.stdout),
|
|
3506
|
+
stderr: redactSecrets(result.stderr)
|
|
3507
|
+
};
|
|
3508
|
+
return {
|
|
3509
|
+
data,
|
|
3510
|
+
errors: result.ok ? [] : [`adb exited with code ${result.code}`]
|
|
3511
|
+
};
|
|
3512
|
+
})
|
|
3513
|
+
);
|
|
3514
|
+
}
|
|
3515
|
+
var sessionArg = {
|
|
3516
|
+
session: z5.string().min(1).optional().describe("Desktop session id (default: the single running session)")
|
|
3517
|
+
};
|
|
3518
|
+
async function resolveDesktop(ctx, session) {
|
|
3519
|
+
const record = await resolveSessionRecord(ctx, "desktop", session);
|
|
3520
|
+
return { id: record.id, display: requireDisplay(record) };
|
|
3521
|
+
}
|
|
3522
|
+
function registerDesktopTools(server, ctx) {
|
|
3523
|
+
server.registerTool(
|
|
3524
|
+
"desktop_launch",
|
|
3525
|
+
{
|
|
3526
|
+
title: "Launch desktop app",
|
|
3527
|
+
description: "Launch an application inside the desktop session (argument array, no shell). Optionally wait for a window whose name contains a pattern.",
|
|
3528
|
+
inputSchema: {
|
|
3529
|
+
...sessionArg,
|
|
3530
|
+
command: z5.string().min(1).describe("Executable to launch"),
|
|
3531
|
+
args: z5.array(z5.string()).optional().describe("Arguments for the executable"),
|
|
3532
|
+
cwd: z5.string().min(1).optional().describe("Working directory (relative to the project dir)"),
|
|
3533
|
+
waitWindow: z5.string().min(1).optional().describe("Wait for a window whose name contains this pattern")
|
|
3534
|
+
}
|
|
3535
|
+
},
|
|
3536
|
+
(args) => runTool(async () => {
|
|
3537
|
+
const { id, display } = await resolveDesktop(ctx, args.session);
|
|
3538
|
+
const app = await launchApp2({
|
|
3539
|
+
display,
|
|
3540
|
+
command: args.command,
|
|
3541
|
+
args: args.args ?? [],
|
|
3542
|
+
env: ctx.env,
|
|
3543
|
+
logDir: desktopSessionLogDir(id, ctx.env),
|
|
3544
|
+
cwd: args.cwd === void 0 ? void 0 : path53.resolve(ctx.projectDir, args.cwd)
|
|
3545
|
+
});
|
|
3546
|
+
const data = {
|
|
3547
|
+
sessionId: id,
|
|
3548
|
+
display,
|
|
3549
|
+
pid: app.pid,
|
|
3550
|
+
logPath: app.logPath
|
|
3551
|
+
};
|
|
3552
|
+
if (args.waitWindow !== void 0) {
|
|
3553
|
+
data.window = await waitForWindow(display, args.waitWindow);
|
|
3554
|
+
}
|
|
3555
|
+
return { data };
|
|
3556
|
+
})
|
|
3557
|
+
);
|
|
3558
|
+
server.registerTool(
|
|
3559
|
+
"desktop_screenshot",
|
|
3560
|
+
{
|
|
3561
|
+
title: "Desktop screenshot",
|
|
3562
|
+
description: "Capture the desktop display as PNG. By default the image is recorded as an artifact of a new run under .picklab/runs and returned inline when small enough.",
|
|
3563
|
+
inputSchema: {
|
|
3564
|
+
...sessionArg,
|
|
3565
|
+
out: z5.string().min(1).optional().describe("Explicit output path instead of a run artifact"),
|
|
3566
|
+
runSlug: z5.string().min(1).optional().describe('Run slug (default "desktop")')
|
|
3567
|
+
}
|
|
3568
|
+
},
|
|
3569
|
+
(args) => runTool(async () => {
|
|
3570
|
+
const { id, display } = await resolveDesktop(ctx, args.session);
|
|
3571
|
+
const target = await resolveScreenshotTarget2(ctx, args, "desktop", id);
|
|
3572
|
+
let tool;
|
|
3573
|
+
const data = await captureToTarget(target, async () => {
|
|
3574
|
+
const result = await screenshot2({
|
|
3575
|
+
display,
|
|
3576
|
+
outPath: target.outPath,
|
|
3577
|
+
env: ctx.env
|
|
3578
|
+
});
|
|
3579
|
+
tool = result.tool;
|
|
3580
|
+
});
|
|
3581
|
+
data.sessionId = id;
|
|
3582
|
+
data.display = display;
|
|
3583
|
+
data.tool = tool;
|
|
3584
|
+
const image = await imageContent(target.outPath);
|
|
3585
|
+
Object.assign(data, image.meta);
|
|
3586
|
+
return { data, extraContent: image.content };
|
|
3587
|
+
})
|
|
3588
|
+
);
|
|
3589
|
+
server.registerTool(
|
|
3590
|
+
"desktop_click",
|
|
3591
|
+
{
|
|
3592
|
+
title: "Desktop click",
|
|
3593
|
+
description: "Click at the given desktop coordinates.",
|
|
3594
|
+
inputSchema: {
|
|
3595
|
+
...sessionArg,
|
|
3596
|
+
x: z5.number().int().nonnegative().describe("X coordinate"),
|
|
3597
|
+
y: z5.number().int().nonnegative().describe("Y coordinate"),
|
|
3598
|
+
button: z5.number().int().min(1).max(9).optional().describe("Mouse button (1-9, default 1)")
|
|
3599
|
+
}
|
|
3600
|
+
},
|
|
3601
|
+
(args) => runTool(async () => {
|
|
3602
|
+
const { id, display } = await resolveDesktop(ctx, args.session);
|
|
3603
|
+
await click({ display, x: args.x, y: args.y, button: args.button });
|
|
3604
|
+
return {
|
|
3605
|
+
data: {
|
|
3606
|
+
sessionId: id,
|
|
3607
|
+
display,
|
|
3608
|
+
x: args.x,
|
|
3609
|
+
y: args.y,
|
|
3610
|
+
button: args.button ?? 1
|
|
3611
|
+
}
|
|
3612
|
+
};
|
|
3613
|
+
})
|
|
3614
|
+
);
|
|
3615
|
+
server.registerTool(
|
|
3616
|
+
"desktop_type",
|
|
3617
|
+
{
|
|
3618
|
+
title: "Desktop type",
|
|
3619
|
+
description: "Type text into the focused desktop window.",
|
|
3620
|
+
inputSchema: {
|
|
3621
|
+
...sessionArg,
|
|
3622
|
+
text: z5.string().min(1).describe("Text to type")
|
|
3623
|
+
}
|
|
3624
|
+
},
|
|
3625
|
+
(args) => runTool(async () => {
|
|
3626
|
+
const { id, display } = await resolveDesktop(ctx, args.session);
|
|
3627
|
+
await typeText2({ display, text: args.text });
|
|
3628
|
+
return {
|
|
3629
|
+
data: { sessionId: id, display, length: args.text.length }
|
|
3630
|
+
};
|
|
3631
|
+
})
|
|
3632
|
+
);
|
|
3633
|
+
server.registerTool(
|
|
3634
|
+
"desktop_key",
|
|
3635
|
+
{
|
|
3636
|
+
title: "Desktop key press",
|
|
3637
|
+
description: 'Press a key or chord (e.g. "Return", "Tab", "ctrl+s") in the desktop session.',
|
|
3638
|
+
inputSchema: {
|
|
3639
|
+
...sessionArg,
|
|
3640
|
+
key: z5.string().min(1).describe("Key or chord to press")
|
|
3641
|
+
}
|
|
3642
|
+
},
|
|
3643
|
+
(args) => runTool(async () => {
|
|
3644
|
+
const { id, display } = await resolveDesktop(ctx, args.session);
|
|
3645
|
+
await pressKey2({ display, key: args.key });
|
|
3646
|
+
return { data: { sessionId: id, display, key: args.key } };
|
|
3647
|
+
})
|
|
3648
|
+
);
|
|
3649
|
+
}
|
|
3650
|
+
var SECRET_QUESTION_PATTERN = /password|api[_ -]?key|token|secret|2fa|otp|credential/i;
|
|
3651
|
+
var SECRET_GUIDANCE = `This looks like a request for a secret (password, API key, token, 2FA code, or other credential). Never collect secrets through this tool. Ask the user to enter the secret directly into the lab app (e.g. via VNC) or into the environment, then confirm out-of-band with kind "confirm" (e.g. "I've entered the password, continue?").`;
|
|
3652
|
+
var NO_ELICITATION_GUIDANCE = "This client does not support elicitation. Relay the question to the user in your conversation and wait for their answer before continuing.";
|
|
3653
|
+
function registerUserTools(server) {
|
|
3654
|
+
server.registerTool(
|
|
3655
|
+
"request_user_input",
|
|
3656
|
+
{
|
|
3657
|
+
title: "Ask the user",
|
|
3658
|
+
description: 'Ask the human user a question and wait for the answer. Use this when you are blocked on something only a human can provide: a judgment call, a license acceptance, a click you cannot perform, or confirmation that an out-of-band step is done. SECURITY: never request passwords, API keys, or tokens through this tool \u2014 ask the user to enter them directly into the lab app (e.g. via VNC) or environment instead, then confirm completion with kind "confirm".',
|
|
3659
|
+
inputSchema: {
|
|
3660
|
+
question: z6.string().min(1).describe("The question to put to the user"),
|
|
3661
|
+
kind: z6.enum(["text", "confirm"]).optional().describe(
|
|
3662
|
+
'Answer kind: "text" for a free-form answer, "confirm" for a yes/no decision (default "text")'
|
|
3663
|
+
),
|
|
3664
|
+
context: z6.string().min(1).optional().describe("Why this input is needed, shown alongside the question")
|
|
3665
|
+
}
|
|
3666
|
+
},
|
|
3667
|
+
(args) => runTool(async () => {
|
|
3668
|
+
const kind = args.kind ?? "text";
|
|
3669
|
+
if (kind === "text" && SECRET_QUESTION_PATTERN.test(args.question)) {
|
|
3670
|
+
return { errors: [SECRET_GUIDANCE] };
|
|
3671
|
+
}
|
|
3672
|
+
if (server.server.getClientCapabilities()?.elicitation === void 0) {
|
|
3673
|
+
return { errors: [NO_ELICITATION_GUIDANCE] };
|
|
3674
|
+
}
|
|
3675
|
+
const message = args.context === void 0 ? args.question : `${args.question}
|
|
3676
|
+
|
|
3677
|
+
Context: ${args.context}`;
|
|
3678
|
+
const fieldName = kind === "confirm" ? "confirmed" : "answer";
|
|
3679
|
+
const requestedSchema = {
|
|
3680
|
+
type: "object",
|
|
3681
|
+
properties: {
|
|
3682
|
+
[fieldName]: kind === "confirm" ? {
|
|
3683
|
+
type: "boolean",
|
|
3684
|
+
title: "Confirm",
|
|
3685
|
+
description: args.question
|
|
3686
|
+
} : {
|
|
3687
|
+
type: "string",
|
|
3688
|
+
title: "Answer",
|
|
3689
|
+
description: args.question
|
|
3690
|
+
}
|
|
3691
|
+
},
|
|
3692
|
+
required: [fieldName]
|
|
3693
|
+
};
|
|
3694
|
+
const result = await server.server.elicitInput({
|
|
3695
|
+
message,
|
|
3696
|
+
requestedSchema
|
|
3697
|
+
});
|
|
3698
|
+
if (result.action === "accept") {
|
|
3699
|
+
const value = kind === "confirm" ? result.content?.confirmed : result.content?.answer;
|
|
3700
|
+
return { data: { action: "accept", value } };
|
|
3701
|
+
}
|
|
3702
|
+
if (result.action === "decline") {
|
|
3703
|
+
return {
|
|
3704
|
+
data: { action: "decline" },
|
|
3705
|
+
errors: [
|
|
3706
|
+
"The user declined to answer. Do not ask again through this tool; continue without this input or ask in your conversation."
|
|
3707
|
+
]
|
|
3708
|
+
};
|
|
3709
|
+
}
|
|
3710
|
+
return {
|
|
3711
|
+
data: { action: "cancel" },
|
|
3712
|
+
errors: [
|
|
3713
|
+
"The user dismissed the prompt without answering. Relay the question in your conversation, or retry later."
|
|
3714
|
+
]
|
|
3715
|
+
};
|
|
3716
|
+
})
|
|
3717
|
+
);
|
|
3718
|
+
}
|
|
3719
|
+
var require2 = createRequire(import.meta.url);
|
|
3720
|
+
var { version } = require2("../package.json");
|
|
3721
|
+
function createMcpServer(opts = {}) {
|
|
3722
|
+
const ctx = resolveContext(opts);
|
|
3723
|
+
const server = new McpServer({ name: "picklab", version });
|
|
3724
|
+
registerSessionTools(server, ctx);
|
|
3725
|
+
registerDesktopTools(server, ctx);
|
|
3726
|
+
registerAndroidTools(server, ctx);
|
|
3727
|
+
registerArtifactTools(server, ctx);
|
|
3728
|
+
registerUserTools(server);
|
|
3729
|
+
registerResources(server, ctx);
|
|
3730
|
+
registerPrompts(server);
|
|
3731
|
+
return server;
|
|
3732
|
+
}
|
|
3733
|
+
|
|
3734
|
+
// src/commands/mcp.ts
|
|
3735
|
+
async function runMcpServe() {
|
|
3736
|
+
const server = createMcpServer();
|
|
3737
|
+
const transport = new StdioServerTransport();
|
|
3738
|
+
await server.connect(transport);
|
|
3739
|
+
console.error("picklab mcp server: listening on stdio");
|
|
3740
|
+
return new Promise((resolve) => {
|
|
3741
|
+
let settled = false;
|
|
3742
|
+
const finish = () => {
|
|
3743
|
+
if (settled) {
|
|
3744
|
+
return;
|
|
3745
|
+
}
|
|
3746
|
+
settled = true;
|
|
3747
|
+
void server.close().catch(() => {
|
|
3748
|
+
}).then(() => resolve(0));
|
|
3749
|
+
};
|
|
3750
|
+
server.server.onclose = finish;
|
|
3751
|
+
process.stdin.on("end", finish);
|
|
3752
|
+
process.stdin.on("close", finish);
|
|
3753
|
+
});
|
|
3754
|
+
}
|
|
3755
|
+
|
|
3756
|
+
export {
|
|
3757
|
+
picklabHome,
|
|
3758
|
+
agentsDir,
|
|
3759
|
+
projectConfigPath,
|
|
3760
|
+
globalConfigPath,
|
|
3761
|
+
runsDir,
|
|
3762
|
+
ensureDir,
|
|
3763
|
+
resolvedDefaults,
|
|
3764
|
+
deepMerge,
|
|
3765
|
+
readConfigFile,
|
|
3766
|
+
loadConfig,
|
|
3767
|
+
saveProjectConfig,
|
|
3768
|
+
saveGlobalConfig,
|
|
3769
|
+
listRuns,
|
|
3770
|
+
redactSecrets,
|
|
3771
|
+
runCommand,
|
|
3772
|
+
getSession,
|
|
3773
|
+
listSessions,
|
|
3774
|
+
resolveRunnableSession,
|
|
3775
|
+
requireDisplay,
|
|
3776
|
+
resolveScreenshotTarget,
|
|
3777
|
+
captureToTarget,
|
|
3778
|
+
findOnPath,
|
|
3779
|
+
missingSdkMessage,
|
|
3780
|
+
isValidSystemImageId,
|
|
3781
|
+
sdkmanagerInstallCommand,
|
|
3782
|
+
detectAndroidEnvironment,
|
|
3783
|
+
buildCreateAvdArgs,
|
|
3784
|
+
listAvds,
|
|
3785
|
+
installApk,
|
|
3786
|
+
launchApp,
|
|
3787
|
+
screenshot,
|
|
3788
|
+
tap,
|
|
3789
|
+
typeText,
|
|
3790
|
+
back,
|
|
3791
|
+
home,
|
|
3792
|
+
getUiTree,
|
|
3793
|
+
logcat,
|
|
3794
|
+
clearLogcat,
|
|
3795
|
+
runAdb,
|
|
3796
|
+
createAndroidSession,
|
|
3797
|
+
destroyAndroidSession,
|
|
3798
|
+
getAndroidSessionStatus,
|
|
3799
|
+
findOnPath2,
|
|
3800
|
+
detectVncBinary,
|
|
3801
|
+
launchApp2,
|
|
3802
|
+
waitForWindow,
|
|
3803
|
+
detectScreenshotTool,
|
|
3804
|
+
screenshot2,
|
|
3805
|
+
click,
|
|
3806
|
+
typeText2,
|
|
3807
|
+
pressKey2 as pressKey,
|
|
3808
|
+
desktopSessionLogDir,
|
|
3809
|
+
createDesktopSession,
|
|
3810
|
+
destroyDesktopSession,
|
|
3811
|
+
getDesktopSessionStatus,
|
|
3812
|
+
runMcpServe
|
|
3813
|
+
};
|