@canaryai/cli 0.1.4 → 0.1.5
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/bin.js +1157 -1091
- package/dist/bin.js.map +1 -1
- package/dist/index.js +1443 -1377
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,430 +1,137 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
2
4
|
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
3
5
|
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
4
6
|
}) : x)(function(x) {
|
|
5
7
|
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
6
8
|
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
9
|
});
|
|
10
|
+
var __esm = (fn, res) => function __init() {
|
|
11
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
12
|
+
};
|
|
13
|
+
var __export = (target, all) => {
|
|
14
|
+
for (var name in all)
|
|
15
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
16
|
+
};
|
|
8
17
|
|
|
9
|
-
// src/
|
|
10
|
-
import
|
|
11
|
-
import
|
|
12
|
-
import
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
// src/runner/common.ts
|
|
16
|
-
import { spawnSync } from "child_process";
|
|
17
|
-
import fs from "fs";
|
|
18
|
-
import path from "path";
|
|
19
|
-
import { createRequire } from "module";
|
|
20
|
-
import { pathToFileURL } from "url";
|
|
21
|
-
function makeRequire() {
|
|
18
|
+
// src/auth.ts
|
|
19
|
+
import fs3 from "fs/promises";
|
|
20
|
+
import os2 from "os";
|
|
21
|
+
import path3 from "path";
|
|
22
|
+
async function readStoredToken() {
|
|
22
23
|
try {
|
|
23
|
-
|
|
24
|
+
const filePath = path3.join(os2.homedir(), ".config", "canary-cli", "auth.json");
|
|
25
|
+
const content = await fs3.readFile(filePath, "utf8");
|
|
26
|
+
const parsed = JSON.parse(content);
|
|
27
|
+
return typeof parsed.token === "string" ? parsed.token : null;
|
|
24
28
|
} catch {
|
|
25
|
-
|
|
26
|
-
return createRequire(process.cwd());
|
|
27
|
-
} catch {
|
|
28
|
-
return typeof __require !== "undefined" ? __require : createRequire(".");
|
|
29
|
-
}
|
|
29
|
+
return null;
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (supportsImport && preloadPath2 && fs.existsSync(preloadPath2)) {
|
|
36
|
-
return { runnerBin: bin, preloadFlag: `--import=${pathToFileURL(preloadPath2).href}` };
|
|
32
|
+
var init_auth = __esm({
|
|
33
|
+
"src/auth.ts"() {
|
|
34
|
+
"use strict";
|
|
37
35
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// src/local-run.ts
|
|
39
|
+
import process2 from "process";
|
|
40
|
+
function getArgValue(argv, key) {
|
|
41
|
+
const index = argv.indexOf(key);
|
|
42
|
+
if (index === -1) return void 0;
|
|
43
|
+
return argv[index + 1];
|
|
42
44
|
}
|
|
43
|
-
function
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
if (!best || version > (best.version ?? 0)) {
|
|
55
|
-
best = current;
|
|
56
|
-
}
|
|
45
|
+
async function runLocalTest(argv) {
|
|
46
|
+
const apiUrl = getArgValue(argv, "--api-url") ?? process2.env.CANARY_API_URL ?? "https://api.trycanary.ai";
|
|
47
|
+
const token = getArgValue(argv, "--token") ?? process2.env.CANARY_API_TOKEN ?? await readStoredToken();
|
|
48
|
+
const title = getArgValue(argv, "--title");
|
|
49
|
+
const featureSpec = getArgValue(argv, "--feature");
|
|
50
|
+
const startUrl = getArgValue(argv, "--start-url");
|
|
51
|
+
const tunnelUrl = getArgValue(argv, "--tunnel-url");
|
|
52
|
+
if (!tunnelUrl && !startUrl) {
|
|
53
|
+
console.error("Missing --tunnel-url or --start-url");
|
|
54
|
+
process2.exit(1);
|
|
57
55
|
}
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
function collectNodeCandidates() {
|
|
63
|
-
const seen = /* @__PURE__ */ new Set();
|
|
64
|
-
const push = (value) => {
|
|
65
|
-
if (!value) return;
|
|
66
|
-
if (seen.has(value)) return;
|
|
67
|
-
seen.add(value);
|
|
68
|
-
};
|
|
69
|
-
const isBun = path.basename(process.execPath).includes("bun");
|
|
70
|
-
push(process.env.CANARY_NODE_BIN);
|
|
71
|
-
push(isBun ? void 0 : process.execPath);
|
|
72
|
-
push("node");
|
|
73
|
-
try {
|
|
74
|
-
const which = spawnSync("which", ["-a", "node"], { encoding: "utf-8" });
|
|
75
|
-
which.stdout?.toString().split("\n").map((line) => line.trim()).forEach((line) => push(line));
|
|
76
|
-
} catch {
|
|
56
|
+
if (!token) {
|
|
57
|
+
console.error("Missing token. Run `canary login` first or set CANARY_API_TOKEN.");
|
|
58
|
+
process2.exit(1);
|
|
77
59
|
}
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
60
|
+
const result = await createLocalRun({
|
|
61
|
+
apiUrl,
|
|
62
|
+
token,
|
|
63
|
+
title,
|
|
64
|
+
featureSpec,
|
|
65
|
+
startUrl,
|
|
66
|
+
tunnelUrl
|
|
67
|
+
});
|
|
68
|
+
console.log(`Local test queued: ${result.runId}`);
|
|
69
|
+
if (result.watchUrl) {
|
|
70
|
+
console.log(`Watch: ${result.watchUrl}`);
|
|
88
71
|
}
|
|
89
|
-
return Array.from(seen);
|
|
90
72
|
}
|
|
91
|
-
function
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
73
|
+
async function createLocalRun(input) {
|
|
74
|
+
const body = {
|
|
75
|
+
title: input.title ?? null,
|
|
76
|
+
featureSpec: input.featureSpec ?? null,
|
|
77
|
+
startUrl: input.startUrl ?? null,
|
|
78
|
+
tunnelPublicUrl: input.tunnelUrl ?? null
|
|
79
|
+
};
|
|
80
|
+
const response = await fetch(`${input.apiUrl}/local-tests/runs`, {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: {
|
|
83
|
+
"content-type": "application/json",
|
|
84
|
+
authorization: `Bearer ${input.token}`
|
|
85
|
+
},
|
|
86
|
+
body: JSON.stringify(body)
|
|
87
|
+
});
|
|
88
|
+
const json = await response.json();
|
|
89
|
+
if (!response.ok || !json.ok || !json.runId) {
|
|
90
|
+
throw new Error(json.error ?? response.statusText);
|
|
98
91
|
}
|
|
99
|
-
return
|
|
92
|
+
return { runId: json.runId, watchUrl: json.watchUrl };
|
|
100
93
|
}
|
|
94
|
+
var init_local_run = __esm({
|
|
95
|
+
"src/local-run.ts"() {
|
|
96
|
+
"use strict";
|
|
97
|
+
init_auth();
|
|
98
|
+
}
|
|
99
|
+
});
|
|
101
100
|
|
|
102
|
-
// src/
|
|
103
|
-
import {
|
|
104
|
-
import
|
|
105
|
-
import
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const stdio = request.stdio ?? "inherit";
|
|
111
|
-
const requireFn2 = makeRequire();
|
|
112
|
-
const playwrightCli = requireFn2.resolve("@playwright/test/cli");
|
|
113
|
-
const baseDir2 = path2.dirname(fileURLToPath(import.meta.url));
|
|
114
|
-
const preloadPath2 = path2.join(baseDir2, "runner", "preload.js");
|
|
115
|
-
const { runnerBin, preloadFlag } = resolveRunner(preloadPath2);
|
|
116
|
-
const { jsonReportPath, eventLogPath, artifactsDir } = prepareArtifactsDir(cwd);
|
|
117
|
-
const reporter = buildReporterArgs(request.reporter, jsonReportPath);
|
|
118
|
-
const args = buildArgs({
|
|
119
|
-
testDir: request.testDir,
|
|
120
|
-
configFile: request.configFile,
|
|
121
|
-
cliArgs: request.cliArgs,
|
|
122
|
-
reporter
|
|
123
|
-
});
|
|
124
|
-
const nodeOptions = process.env.NODE_OPTIONS && preloadFlag ? `${process.env.NODE_OPTIONS} ${preloadFlag}` : preloadFlag ?? process.env.NODE_OPTIONS;
|
|
125
|
-
const env = buildEnv({
|
|
126
|
-
base: process.env,
|
|
127
|
-
overrides: request.env,
|
|
128
|
-
healing: request.healing,
|
|
129
|
-
eventLogPath,
|
|
130
|
-
nodeOptions
|
|
131
|
-
});
|
|
132
|
-
const runResult = await spawnPlaywright({
|
|
133
|
-
bin: request.nodeBin ?? runnerBin,
|
|
134
|
-
args: [playwrightCli, ...args],
|
|
135
|
-
cwd,
|
|
136
|
-
env,
|
|
137
|
-
stdio,
|
|
138
|
-
timeoutMs: request.timeoutMs
|
|
139
|
-
});
|
|
140
|
-
const summary = summarize(jsonReportPath, eventLogPath, runResult.durationMs);
|
|
141
|
-
return {
|
|
142
|
-
ok: runResult.exitCode === 0,
|
|
143
|
-
exitCode: runResult.exitCode,
|
|
144
|
-
summary,
|
|
145
|
-
artifactsDir,
|
|
146
|
-
rawOutput: runResult.output,
|
|
147
|
-
error: runResult.error
|
|
148
|
-
};
|
|
101
|
+
// src/tunnel.ts
|
|
102
|
+
import { createHash } from "crypto";
|
|
103
|
+
import os3 from "os";
|
|
104
|
+
import process3 from "process";
|
|
105
|
+
function getArgValue2(argv, key) {
|
|
106
|
+
const index = argv.indexOf(key);
|
|
107
|
+
if (index === -1) return void 0;
|
|
108
|
+
return argv[index + 1];
|
|
149
109
|
}
|
|
150
|
-
function
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
110
|
+
function toWebSocketUrl(apiUrl) {
|
|
111
|
+
const url = new URL(apiUrl);
|
|
112
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
113
|
+
return url.toString();
|
|
114
|
+
}
|
|
115
|
+
function createFingerprint() {
|
|
116
|
+
const raw = `${os3.hostname()}-${os3.userInfo().username}-${process3.version}`;
|
|
117
|
+
return createHash("sha256").update(raw).digest("hex").slice(0, 16);
|
|
118
|
+
}
|
|
119
|
+
async function runTunnel(argv) {
|
|
120
|
+
const apiUrl = getArgValue2(argv, "--api-url") ?? process3.env.CANARY_API_URL ?? "https://api.trycanary.ai";
|
|
121
|
+
const token = getArgValue2(argv, "--token") ?? process3.env.CANARY_API_TOKEN ?? await readStoredToken();
|
|
122
|
+
const portRaw = getArgValue2(argv, "--port") ?? process3.env.CANARY_LOCAL_PORT;
|
|
123
|
+
if (!portRaw) {
|
|
124
|
+
console.error("Missing --port");
|
|
125
|
+
process3.exit(1);
|
|
155
126
|
}
|
|
156
|
-
|
|
157
|
-
|
|
127
|
+
const port = Number(portRaw);
|
|
128
|
+
if (Number.isNaN(port) || port <= 0) {
|
|
129
|
+
console.error("Invalid --port value");
|
|
130
|
+
process3.exit(1);
|
|
158
131
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
163
|
-
return args;
|
|
164
|
-
}
|
|
165
|
-
function buildReporterArgs(requested, jsonReportPath) {
|
|
166
|
-
if (requested === "json") return `json=${jsonReportPath}`;
|
|
167
|
-
if (requested && requested !== "default") return requested;
|
|
168
|
-
return `list,json=${jsonReportPath}`;
|
|
169
|
-
}
|
|
170
|
-
function prepareArtifactsDir(cwd) {
|
|
171
|
-
const dir = fs2.mkdtempSync(path2.join(os.tmpdir(), "canary-run-"));
|
|
172
|
-
const jsonReportPath = path2.join(dir, "report.json");
|
|
173
|
-
const eventLogPath = path2.join(dir, "events-worker-0.jsonl");
|
|
174
|
-
const artifactsDir = path2.join(cwd, "test-results", "ai-healer");
|
|
175
|
-
return { jsonReportPath, eventLogPath, artifactsDir: dir };
|
|
176
|
-
}
|
|
177
|
-
function buildEnv(params) {
|
|
178
|
-
const healing = params.healing ?? {};
|
|
179
|
-
const env = {
|
|
180
|
-
...params.base,
|
|
181
|
-
CANARY_ENABLED: params.base.CANARY_ENABLED ?? "1",
|
|
182
|
-
CANARY_RUNNER: "canary",
|
|
183
|
-
CANARY_EVENT_LOG: params.eventLogPath,
|
|
184
|
-
...params.nodeOptions ? { NODE_OPTIONS: params.nodeOptions } : {},
|
|
185
|
-
...healing.apiKey ? { AI_API_KEY: healing.apiKey } : {},
|
|
186
|
-
...healing.provider ? { AI_PROVIDER: healing.provider } : {},
|
|
187
|
-
...healing.model ? { AI_MODEL: healing.model } : {},
|
|
188
|
-
...healing.timeoutMs ? { AI_TIMEOUT_MS: String(healing.timeoutMs) } : {},
|
|
189
|
-
...healing.maxActions ? { CANARY_MAX_ACTIONS: String(healing.maxActions) } : {},
|
|
190
|
-
...healing.vision ? { CANARY_VISION: "1" } : {},
|
|
191
|
-
...healing.dryRun ? { CANARY_DRY_RUN: "1" } : {},
|
|
192
|
-
...healing.warnOnly ? { CANARY_WARN_ONLY: "1" } : {},
|
|
193
|
-
...healing.debug ? { CANARY_DEBUG: "1" } : {},
|
|
194
|
-
...healing.readOnly ? { CANARY_READ_ONLY: "1" } : {},
|
|
195
|
-
...healing.allowEvaluate === false ? { CANARY_ALLOW_EVALUATE: "0" } : {},
|
|
196
|
-
...healing.allowRunCode ? { CANARY_ALLOW_RUN_CODE: "1" } : {},
|
|
197
|
-
...healing.maxPayloadBytes ? { CANARY_MAX_PAYLOAD_BYTES: String(healing.maxPayloadBytes) } : {},
|
|
198
|
-
...params.overrides
|
|
199
|
-
};
|
|
200
|
-
return env;
|
|
201
|
-
}
|
|
202
|
-
async function spawnPlaywright(opts) {
|
|
203
|
-
return new Promise((resolve) => {
|
|
204
|
-
const started = Date.now();
|
|
205
|
-
const child = spawn(opts.bin, opts.args, {
|
|
206
|
-
cwd: opts.cwd,
|
|
207
|
-
env: opts.env,
|
|
208
|
-
stdio: opts.stdio === "inherit" ? "inherit" : ["ignore", "pipe", "pipe"]
|
|
209
|
-
});
|
|
210
|
-
let timer;
|
|
211
|
-
let output = "";
|
|
212
|
-
let error;
|
|
213
|
-
if (opts.stdio === "pipe") {
|
|
214
|
-
child.stdout?.on("data", (chunk) => {
|
|
215
|
-
output += chunk.toString();
|
|
216
|
-
});
|
|
217
|
-
child.stderr?.on("data", (chunk) => {
|
|
218
|
-
output += chunk.toString();
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
if (opts.timeoutMs && opts.timeoutMs > 0) {
|
|
222
|
-
timer = setTimeout(() => {
|
|
223
|
-
error = new Error(`canary.run timed out after ${opts.timeoutMs}ms`);
|
|
224
|
-
child.kill("SIGKILL");
|
|
225
|
-
}, opts.timeoutMs);
|
|
226
|
-
}
|
|
227
|
-
child.on("close", (code) => {
|
|
228
|
-
if (timer) clearTimeout(timer);
|
|
229
|
-
resolve({ exitCode: code ?? 1, output: output || void 0, durationMs: Date.now() - started, error });
|
|
230
|
-
});
|
|
231
|
-
});
|
|
232
|
-
}
|
|
233
|
-
function summarize(jsonReportPath, eventLogPath, durationMs) {
|
|
234
|
-
const base = {
|
|
235
|
-
total: 0,
|
|
236
|
-
passed: 0,
|
|
237
|
-
failed: 0,
|
|
238
|
-
flaky: 0,
|
|
239
|
-
skipped: 0,
|
|
240
|
-
durationMs
|
|
241
|
-
};
|
|
242
|
-
const jsonReport = readJsonReport(jsonReportPath);
|
|
243
|
-
if (jsonReport) {
|
|
244
|
-
const counts = countTests(jsonReport);
|
|
245
|
-
base.total = counts.total;
|
|
246
|
-
base.passed = counts.passed;
|
|
247
|
-
base.failed = counts.failed;
|
|
248
|
-
base.flaky = counts.flaky;
|
|
249
|
-
base.skipped = counts.skipped;
|
|
250
|
-
base.durationMs = jsonReport.duration ?? durationMs;
|
|
251
|
-
}
|
|
252
|
-
const healed = countHealed(eventLogPath);
|
|
253
|
-
if (healed) {
|
|
254
|
-
return { ...base, healed };
|
|
255
|
-
}
|
|
256
|
-
return base;
|
|
257
|
-
}
|
|
258
|
-
function readJsonReport(reportPath) {
|
|
259
|
-
try {
|
|
260
|
-
if (fs2.existsSync(reportPath)) {
|
|
261
|
-
const raw = fs2.readFileSync(reportPath, "utf-8");
|
|
262
|
-
return JSON.parse(raw);
|
|
263
|
-
}
|
|
264
|
-
} catch {
|
|
265
|
-
}
|
|
266
|
-
return void 0;
|
|
267
|
-
}
|
|
268
|
-
function countTests(report) {
|
|
269
|
-
let total = 0;
|
|
270
|
-
let passed = 0;
|
|
271
|
-
let failed = 0;
|
|
272
|
-
let flaky = 0;
|
|
273
|
-
let skipped = 0;
|
|
274
|
-
const visitSuite = (suite) => {
|
|
275
|
-
if (!suite) return;
|
|
276
|
-
suite.tests?.forEach((test) => {
|
|
277
|
-
total += 1;
|
|
278
|
-
const statuses = test.results.map((r) => r.status);
|
|
279
|
-
const hasFailed = statuses.includes("failed") || statuses.includes("interrupted");
|
|
280
|
-
const hasPassed = statuses.includes("passed");
|
|
281
|
-
const hasTimedOut = statuses.includes("timedOut");
|
|
282
|
-
const allSkipped = statuses.every((s) => s === "skipped");
|
|
283
|
-
if (allSkipped) {
|
|
284
|
-
skipped += 1;
|
|
285
|
-
} else if ((hasFailed || hasTimedOut) && hasPassed) {
|
|
286
|
-
flaky += 1;
|
|
287
|
-
} else if (hasFailed || hasTimedOut) {
|
|
288
|
-
failed += 1;
|
|
289
|
-
} else if (hasPassed && statuses.length > 1) {
|
|
290
|
-
flaky += 1;
|
|
291
|
-
} else if (hasPassed) {
|
|
292
|
-
passed += 1;
|
|
293
|
-
}
|
|
294
|
-
});
|
|
295
|
-
suite.suites?.forEach(visitSuite);
|
|
296
|
-
};
|
|
297
|
-
report.suites?.forEach(visitSuite);
|
|
298
|
-
return { total, passed, failed, flaky, skipped };
|
|
299
|
-
}
|
|
300
|
-
function countHealed(eventLogPath) {
|
|
301
|
-
try {
|
|
302
|
-
if (!fs2.existsSync(eventLogPath)) return void 0;
|
|
303
|
-
const raw = fs2.readFileSync(eventLogPath, "utf-8").trim();
|
|
304
|
-
if (!raw) return void 0;
|
|
305
|
-
const lines = raw.split("\n");
|
|
306
|
-
let healed = 0;
|
|
307
|
-
for (const line of lines) {
|
|
308
|
-
try {
|
|
309
|
-
const event = JSON.parse(line);
|
|
310
|
-
if (event?.healed === true) healed += 1;
|
|
311
|
-
} catch {
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
return healed;
|
|
315
|
-
} catch {
|
|
316
|
-
return void 0;
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
// src/local-run.ts
|
|
321
|
-
import process2 from "process";
|
|
322
|
-
|
|
323
|
-
// src/auth.ts
|
|
324
|
-
import fs3 from "fs/promises";
|
|
325
|
-
import os2 from "os";
|
|
326
|
-
import path3 from "path";
|
|
327
|
-
async function readStoredToken() {
|
|
328
|
-
try {
|
|
329
|
-
const filePath = path3.join(os2.homedir(), ".config", "canary-cli", "auth.json");
|
|
330
|
-
const content = await fs3.readFile(filePath, "utf8");
|
|
331
|
-
const parsed = JSON.parse(content);
|
|
332
|
-
return typeof parsed.token === "string" ? parsed.token : null;
|
|
333
|
-
} catch {
|
|
334
|
-
return null;
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
// src/local-run.ts
|
|
339
|
-
function getArgValue(argv, key) {
|
|
340
|
-
const index = argv.indexOf(key);
|
|
341
|
-
if (index === -1) return void 0;
|
|
342
|
-
return argv[index + 1];
|
|
343
|
-
}
|
|
344
|
-
async function runLocalTest(argv) {
|
|
345
|
-
const apiUrl = getArgValue(argv, "--api-url") ?? process2.env.CANARY_API_URL ?? "https://api.trycanary.ai";
|
|
346
|
-
const token = getArgValue(argv, "--token") ?? process2.env.CANARY_API_TOKEN ?? await readStoredToken();
|
|
347
|
-
const title = getArgValue(argv, "--title");
|
|
348
|
-
const featureSpec = getArgValue(argv, "--feature");
|
|
349
|
-
const startUrl = getArgValue(argv, "--start-url");
|
|
350
|
-
const tunnelUrl = getArgValue(argv, "--tunnel-url");
|
|
351
|
-
if (!tunnelUrl && !startUrl) {
|
|
352
|
-
console.error("Missing --tunnel-url or --start-url");
|
|
353
|
-
process2.exit(1);
|
|
354
|
-
}
|
|
355
|
-
if (!token) {
|
|
356
|
-
console.error("Missing token. Run `canary login` first or set CANARY_API_TOKEN.");
|
|
357
|
-
process2.exit(1);
|
|
358
|
-
}
|
|
359
|
-
const result = await createLocalRun({
|
|
360
|
-
apiUrl,
|
|
361
|
-
token,
|
|
362
|
-
title,
|
|
363
|
-
featureSpec,
|
|
364
|
-
startUrl,
|
|
365
|
-
tunnelUrl
|
|
366
|
-
});
|
|
367
|
-
console.log(`Local test queued: ${result.runId}`);
|
|
368
|
-
if (result.watchUrl) {
|
|
369
|
-
console.log(`Watch: ${result.watchUrl}`);
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
async function createLocalRun(input) {
|
|
373
|
-
const body = {
|
|
374
|
-
title: input.title ?? null,
|
|
375
|
-
featureSpec: input.featureSpec ?? null,
|
|
376
|
-
startUrl: input.startUrl ?? null,
|
|
377
|
-
tunnelPublicUrl: input.tunnelUrl ?? null
|
|
378
|
-
};
|
|
379
|
-
const response = await fetch(`${input.apiUrl}/local-tests/runs`, {
|
|
380
|
-
method: "POST",
|
|
381
|
-
headers: {
|
|
382
|
-
"content-type": "application/json",
|
|
383
|
-
authorization: `Bearer ${input.token}`
|
|
384
|
-
},
|
|
385
|
-
body: JSON.stringify(body)
|
|
386
|
-
});
|
|
387
|
-
const json = await response.json();
|
|
388
|
-
if (!response.ok || !json.ok || !json.runId) {
|
|
389
|
-
throw new Error(json.error ?? response.statusText);
|
|
390
|
-
}
|
|
391
|
-
return { runId: json.runId, watchUrl: json.watchUrl };
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
// src/tunnel.ts
|
|
395
|
-
import { createHash } from "crypto";
|
|
396
|
-
import os3 from "os";
|
|
397
|
-
import process3 from "process";
|
|
398
|
-
function getArgValue2(argv, key) {
|
|
399
|
-
const index = argv.indexOf(key);
|
|
400
|
-
if (index === -1) return void 0;
|
|
401
|
-
return argv[index + 1];
|
|
402
|
-
}
|
|
403
|
-
function toWebSocketUrl(apiUrl) {
|
|
404
|
-
const url = new URL(apiUrl);
|
|
405
|
-
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
406
|
-
return url.toString();
|
|
407
|
-
}
|
|
408
|
-
function createFingerprint() {
|
|
409
|
-
const raw = `${os3.hostname()}-${os3.userInfo().username}-${process3.version}`;
|
|
410
|
-
return createHash("sha256").update(raw).digest("hex").slice(0, 16);
|
|
411
|
-
}
|
|
412
|
-
async function runTunnel(argv) {
|
|
413
|
-
const apiUrl = getArgValue2(argv, "--api-url") ?? process3.env.CANARY_API_URL ?? "https://api.trycanary.ai";
|
|
414
|
-
const token = getArgValue2(argv, "--token") ?? process3.env.CANARY_API_TOKEN ?? await readStoredToken();
|
|
415
|
-
const portRaw = getArgValue2(argv, "--port") ?? process3.env.CANARY_LOCAL_PORT;
|
|
416
|
-
if (!portRaw) {
|
|
417
|
-
console.error("Missing --port");
|
|
418
|
-
process3.exit(1);
|
|
419
|
-
}
|
|
420
|
-
const port = Number(portRaw);
|
|
421
|
-
if (Number.isNaN(port) || port <= 0) {
|
|
422
|
-
console.error("Invalid --port value");
|
|
423
|
-
process3.exit(1);
|
|
424
|
-
}
|
|
425
|
-
if (!token) {
|
|
426
|
-
console.error("Missing token. Run `canary login` first or set CANARY_API_TOKEN.");
|
|
427
|
-
process3.exit(1);
|
|
132
|
+
if (!token) {
|
|
133
|
+
console.error("Missing token. Run `canary login` first or set CANARY_API_TOKEN.");
|
|
134
|
+
process3.exit(1);
|
|
428
135
|
}
|
|
429
136
|
const maxReconnectAttempts = 10;
|
|
430
137
|
const baseReconnectDelayMs = 1e3;
|
|
@@ -655,852 +362,701 @@ function connectTunnel(input) {
|
|
|
655
362
|
};
|
|
656
363
|
return ws;
|
|
657
364
|
}
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
import { spawn as spawn2 } from "child_process";
|
|
665
|
-
var DEFAULT_APP_URL = "https://app.trycanary.ai";
|
|
666
|
-
function getArgValue3(argv, key) {
|
|
667
|
-
const index = argv.indexOf(key);
|
|
668
|
-
if (index === -1) return void 0;
|
|
669
|
-
return argv[index + 1];
|
|
670
|
-
}
|
|
671
|
-
function shouldOpenBrowser(argv) {
|
|
672
|
-
return !argv.includes("--no-open");
|
|
673
|
-
}
|
|
674
|
-
function openUrl(url) {
|
|
675
|
-
const platform = process4.platform;
|
|
676
|
-
if (platform === "darwin") {
|
|
677
|
-
spawn2("open", [url], { stdio: "ignore" });
|
|
678
|
-
return;
|
|
679
|
-
}
|
|
680
|
-
if (platform === "win32") {
|
|
681
|
-
spawn2("cmd", ["/c", "start", "", url], { stdio: "ignore" });
|
|
682
|
-
return;
|
|
683
|
-
}
|
|
684
|
-
spawn2("xdg-open", [url], { stdio: "ignore" });
|
|
685
|
-
}
|
|
686
|
-
async function writeToken(token) {
|
|
687
|
-
const dir = path4.join(os4.homedir(), ".config", "canary-cli");
|
|
688
|
-
const filePath = path4.join(dir, "auth.json");
|
|
689
|
-
await fs4.mkdir(dir, { recursive: true });
|
|
690
|
-
await fs4.writeFile(filePath, JSON.stringify({ token }, null, 2), "utf8");
|
|
691
|
-
return filePath;
|
|
692
|
-
}
|
|
693
|
-
async function runLogin(argv) {
|
|
694
|
-
const apiUrl = getArgValue3(argv, "--api-url") ?? process4.env.CANARY_API_URL ?? "https://api.trycanary.ai";
|
|
695
|
-
const appUrl = getArgValue3(argv, "--app-url") ?? process4.env.CANARY_APP_URL ?? DEFAULT_APP_URL;
|
|
696
|
-
const startRes = await fetch(`${apiUrl}/cli-login/start`, {
|
|
697
|
-
method: "POST",
|
|
698
|
-
headers: { "content-type": "application/json" },
|
|
699
|
-
body: JSON.stringify({ appUrl })
|
|
700
|
-
});
|
|
701
|
-
const startJson = await startRes.json();
|
|
702
|
-
if (!startRes.ok || !startJson.ok || !startJson.deviceCode || !startJson.userCode) {
|
|
703
|
-
console.error("Login start failed", startJson.error ?? startRes.statusText);
|
|
704
|
-
process4.exit(1);
|
|
705
|
-
}
|
|
706
|
-
console.log("Login required.");
|
|
707
|
-
console.log(`User code: ${startJson.userCode}`);
|
|
708
|
-
if (startJson.verificationUrl) {
|
|
709
|
-
console.log(`Open: ${startJson.verificationUrl}`);
|
|
710
|
-
if (shouldOpenBrowser(argv)) {
|
|
711
|
-
try {
|
|
712
|
-
openUrl(startJson.verificationUrl);
|
|
713
|
-
} catch {
|
|
714
|
-
console.log("Unable to open browser automatically. Please open the URL manually.");
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
const intervalMs = (startJson.intervalSeconds ?? 3) * 1e3;
|
|
719
|
-
const expiresAt = startJson.expiresAt ? new Date(startJson.expiresAt).getTime() : null;
|
|
720
|
-
while (true) {
|
|
721
|
-
if (expiresAt && Date.now() > expiresAt) {
|
|
722
|
-
console.error("Login code expired.");
|
|
723
|
-
process4.exit(1);
|
|
724
|
-
}
|
|
725
|
-
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
726
|
-
const pollRes = await fetch(`${apiUrl}/cli-login/poll`, {
|
|
727
|
-
method: "POST",
|
|
728
|
-
headers: { "content-type": "application/json" },
|
|
729
|
-
body: JSON.stringify({ deviceCode: startJson.deviceCode })
|
|
730
|
-
});
|
|
731
|
-
const pollJson = await pollRes.json();
|
|
732
|
-
if (!pollRes.ok || !pollJson.ok) {
|
|
733
|
-
console.error("Login poll failed", pollJson.error ?? pollRes.statusText);
|
|
734
|
-
process4.exit(1);
|
|
735
|
-
}
|
|
736
|
-
if (pollJson.status === "approved" && pollJson.accessToken) {
|
|
737
|
-
const filePath = await writeToken(pollJson.accessToken);
|
|
738
|
-
console.log(`Login successful. Token saved to ${filePath}`);
|
|
739
|
-
console.log("Set CANARY_API_TOKEN to use the CLI without re-login.");
|
|
740
|
-
return;
|
|
741
|
-
}
|
|
742
|
-
if (pollJson.status === "rejected") {
|
|
743
|
-
console.error("Login rejected.");
|
|
744
|
-
process4.exit(1);
|
|
745
|
-
}
|
|
746
|
-
if (pollJson.status === "expired") {
|
|
747
|
-
console.error("Login expired.");
|
|
748
|
-
process4.exit(1);
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
// src/run-local.ts
|
|
754
|
-
import process5 from "process";
|
|
755
|
-
function getArgValue4(argv, key) {
|
|
756
|
-
const index = argv.indexOf(key);
|
|
757
|
-
if (index === -1) return void 0;
|
|
758
|
-
return argv[index + 1];
|
|
759
|
-
}
|
|
760
|
-
async function runLocalSession(argv) {
|
|
761
|
-
const apiUrl = getArgValue4(argv, "--api-url") ?? process5.env.CANARY_API_URL ?? "https://api.trycanary.ai";
|
|
762
|
-
const token = getArgValue4(argv, "--token") ?? process5.env.CANARY_API_TOKEN ?? await readStoredToken();
|
|
763
|
-
if (!token) {
|
|
764
|
-
console.error("Missing token. Run `canary login` first or set CANARY_API_TOKEN.");
|
|
765
|
-
process5.exit(1);
|
|
766
|
-
}
|
|
767
|
-
const portRaw = getArgValue4(argv, "--port") ?? process5.env.CANARY_LOCAL_PORT;
|
|
768
|
-
const tunnelUrl = getArgValue4(argv, "--tunnel-url");
|
|
769
|
-
const title = getArgValue4(argv, "--title");
|
|
770
|
-
const featureSpec = getArgValue4(argv, "--feature");
|
|
771
|
-
const startUrl = getArgValue4(argv, "--start-url");
|
|
772
|
-
if (!tunnelUrl && !portRaw) {
|
|
773
|
-
console.error("Missing --port or --tunnel-url");
|
|
774
|
-
process5.exit(1);
|
|
775
|
-
}
|
|
776
|
-
let publicUrl = tunnelUrl;
|
|
777
|
-
let ws = null;
|
|
778
|
-
if (!publicUrl && portRaw) {
|
|
779
|
-
const port = Number(portRaw);
|
|
780
|
-
if (Number.isNaN(port) || port <= 0) {
|
|
781
|
-
console.error("Invalid --port value");
|
|
782
|
-
process5.exit(1);
|
|
783
|
-
}
|
|
784
|
-
const tunnel = await createTunnel({ apiUrl, token, port });
|
|
785
|
-
publicUrl = tunnel.publicUrl;
|
|
786
|
-
ws = connectTunnel({
|
|
787
|
-
apiUrl,
|
|
788
|
-
tunnelId: tunnel.tunnelId,
|
|
789
|
-
token: tunnel.token,
|
|
790
|
-
port,
|
|
791
|
-
onReady: () => {
|
|
792
|
-
console.log(`Tunnel connected: ${publicUrl ?? tunnel.tunnelId}`);
|
|
793
|
-
}
|
|
794
|
-
});
|
|
795
|
-
}
|
|
796
|
-
if (!publicUrl) {
|
|
797
|
-
console.error("Failed to resolve tunnel URL");
|
|
798
|
-
process5.exit(1);
|
|
799
|
-
}
|
|
800
|
-
const run2 = await createLocalRun({
|
|
801
|
-
apiUrl,
|
|
802
|
-
token,
|
|
803
|
-
title,
|
|
804
|
-
featureSpec,
|
|
805
|
-
startUrl,
|
|
806
|
-
tunnelUrl: publicUrl
|
|
807
|
-
});
|
|
808
|
-
console.log(`Local test queued: ${run2.runId}`);
|
|
809
|
-
if (run2.watchUrl) {
|
|
810
|
-
console.log(`Watch: ${run2.watchUrl}`);
|
|
811
|
-
}
|
|
812
|
-
if (ws) {
|
|
813
|
-
console.log("Tunnel active. Press Ctrl+C to stop.");
|
|
814
|
-
process5.on("SIGINT", () => {
|
|
815
|
-
ws?.close();
|
|
816
|
-
process5.exit(0);
|
|
817
|
-
});
|
|
818
|
-
await new Promise(() => void 0);
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
// src/mcp.ts
|
|
823
|
-
import process6 from "process";
|
|
824
|
-
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
825
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
826
|
-
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
827
|
-
import { createParser } from "eventsource-parser";
|
|
365
|
+
var init_tunnel = __esm({
|
|
366
|
+
"src/tunnel.ts"() {
|
|
367
|
+
"use strict";
|
|
368
|
+
init_auth();
|
|
369
|
+
}
|
|
370
|
+
});
|
|
828
371
|
|
|
829
372
|
// src/local-browser/host.ts
|
|
830
373
|
import { chromium } from "playwright";
|
|
831
|
-
var HEARTBEAT_INTERVAL_MS
|
|
832
|
-
var
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
} else {
|
|
853
|
-
const fn = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
|
|
854
|
-
fn(`[LocalBrowserHost] ${message}`, data ?? "");
|
|
855
|
-
}
|
|
856
|
-
}
|
|
857
|
-
// =========================================================================
|
|
858
|
-
// Lifecycle
|
|
859
|
-
// =========================================================================
|
|
860
|
-
async start() {
|
|
861
|
-
this.log("info", "Starting local browser host", {
|
|
862
|
-
browserMode: this.options.browserMode,
|
|
863
|
-
sessionId: this.options.sessionId
|
|
864
|
-
});
|
|
865
|
-
await this.connectWebSocket();
|
|
866
|
-
await this.launchBrowser();
|
|
867
|
-
this.sendSessionEvent("browser_ready");
|
|
868
|
-
}
|
|
869
|
-
async stop() {
|
|
870
|
-
this.isShuttingDown = true;
|
|
871
|
-
this.log("info", "Stopping local browser host");
|
|
872
|
-
this.stopHeartbeat();
|
|
873
|
-
if (this.ws) {
|
|
874
|
-
try {
|
|
875
|
-
this.ws.close(1e3, "Shutdown");
|
|
876
|
-
} catch {
|
|
374
|
+
var HEARTBEAT_INTERVAL_MS, RECONNECT_DELAY_MS, MAX_RECONNECT_DELAY_MS, MAX_RECONNECT_ATTEMPTS, LocalBrowserHost;
|
|
375
|
+
var init_host = __esm({
|
|
376
|
+
"src/local-browser/host.ts"() {
|
|
377
|
+
"use strict";
|
|
378
|
+
HEARTBEAT_INTERVAL_MS = 3e4;
|
|
379
|
+
RECONNECT_DELAY_MS = 1e3;
|
|
380
|
+
MAX_RECONNECT_DELAY_MS = 3e4;
|
|
381
|
+
MAX_RECONNECT_ATTEMPTS = 10;
|
|
382
|
+
LocalBrowserHost = class {
|
|
383
|
+
options;
|
|
384
|
+
ws = null;
|
|
385
|
+
browser = null;
|
|
386
|
+
context = null;
|
|
387
|
+
page = null;
|
|
388
|
+
pendingDialogs = [];
|
|
389
|
+
heartbeatTimer = null;
|
|
390
|
+
reconnectAttempts = 0;
|
|
391
|
+
isShuttingDown = false;
|
|
392
|
+
lastSnapshotYaml = "";
|
|
393
|
+
constructor(options) {
|
|
394
|
+
this.options = options;
|
|
877
395
|
}
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
396
|
+
log(level, message, data) {
|
|
397
|
+
if (this.options.onLog) {
|
|
398
|
+
this.options.onLog(level, message, data);
|
|
399
|
+
} else {
|
|
400
|
+
const fn = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
|
|
401
|
+
fn(`[LocalBrowserHost] ${message}`, data ?? "");
|
|
402
|
+
}
|
|
884
403
|
}
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
404
|
+
// =========================================================================
|
|
405
|
+
// Lifecycle
|
|
406
|
+
// =========================================================================
|
|
407
|
+
async start() {
|
|
408
|
+
this.log("info", "Starting local browser host", {
|
|
409
|
+
browserMode: this.options.browserMode,
|
|
410
|
+
sessionId: this.options.sessionId
|
|
411
|
+
});
|
|
412
|
+
await this.connectWebSocket();
|
|
413
|
+
await this.launchBrowser();
|
|
414
|
+
this.sendSessionEvent("browser_ready");
|
|
891
415
|
}
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
this.log("info", "Local browser host stopped");
|
|
896
|
-
}
|
|
897
|
-
// =========================================================================
|
|
898
|
-
// WebSocket Connection
|
|
899
|
-
// =========================================================================
|
|
900
|
-
async connectWebSocket() {
|
|
901
|
-
return new Promise((resolve, reject) => {
|
|
902
|
-
const wsUrl = `${this.options.apiUrl.replace("http", "ws")}/local-browser/sessions/${this.options.sessionId}/connect?token=${this.options.wsToken}`;
|
|
903
|
-
this.log("info", "Connecting to cloud API", { url: wsUrl.replace(/token=.*/, "token=***") });
|
|
904
|
-
const ws = new WebSocket(wsUrl);
|
|
905
|
-
ws.onopen = () => {
|
|
906
|
-
this.log("info", "Connected to cloud API");
|
|
907
|
-
this.ws = ws;
|
|
908
|
-
this.reconnectAttempts = 0;
|
|
909
|
-
this.startHeartbeat();
|
|
910
|
-
resolve();
|
|
911
|
-
};
|
|
912
|
-
ws.onmessage = (event) => {
|
|
913
|
-
this.handleMessage(event.data);
|
|
914
|
-
};
|
|
915
|
-
ws.onerror = (event) => {
|
|
916
|
-
this.log("error", "WebSocket error", event);
|
|
917
|
-
};
|
|
918
|
-
ws.onclose = () => {
|
|
919
|
-
this.log("info", "WebSocket closed");
|
|
416
|
+
async stop() {
|
|
417
|
+
this.isShuttingDown = true;
|
|
418
|
+
this.log("info", "Stopping local browser host");
|
|
920
419
|
this.stopHeartbeat();
|
|
921
|
-
this.ws
|
|
922
|
-
|
|
923
|
-
|
|
420
|
+
if (this.ws) {
|
|
421
|
+
try {
|
|
422
|
+
this.ws.close(1e3, "Shutdown");
|
|
423
|
+
} catch {
|
|
424
|
+
}
|
|
425
|
+
this.ws = null;
|
|
924
426
|
}
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
427
|
+
if (this.context) {
|
|
428
|
+
try {
|
|
429
|
+
await this.context.close();
|
|
430
|
+
} catch {
|
|
431
|
+
}
|
|
432
|
+
this.context = null;
|
|
929
433
|
}
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
this.stop();
|
|
937
|
-
return;
|
|
938
|
-
}
|
|
939
|
-
const delay = Math.min(
|
|
940
|
-
RECONNECT_DELAY_MS * Math.pow(2, this.reconnectAttempts),
|
|
941
|
-
MAX_RECONNECT_DELAY_MS
|
|
942
|
-
);
|
|
943
|
-
this.reconnectAttempts++;
|
|
944
|
-
this.log("info", `Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
|
945
|
-
setTimeout(async () => {
|
|
946
|
-
try {
|
|
947
|
-
await this.connectWebSocket();
|
|
948
|
-
this.sendSessionEvent("connected");
|
|
949
|
-
if (this.page) {
|
|
950
|
-
this.sendSessionEvent("browser_ready");
|
|
434
|
+
if (this.browser) {
|
|
435
|
+
try {
|
|
436
|
+
await this.browser.close();
|
|
437
|
+
} catch {
|
|
438
|
+
}
|
|
439
|
+
this.browser = null;
|
|
951
440
|
}
|
|
952
|
-
|
|
953
|
-
this.log("
|
|
954
|
-
this.scheduleReconnect();
|
|
955
|
-
}
|
|
956
|
-
}, delay);
|
|
957
|
-
}
|
|
958
|
-
// =========================================================================
|
|
959
|
-
// Heartbeat
|
|
960
|
-
// =========================================================================
|
|
961
|
-
startHeartbeat() {
|
|
962
|
-
this.stopHeartbeat();
|
|
963
|
-
this.heartbeatTimer = setInterval(() => {
|
|
964
|
-
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
965
|
-
const ping = {
|
|
966
|
-
type: "heartbeat",
|
|
967
|
-
id: crypto.randomUUID(),
|
|
968
|
-
timestamp: Date.now(),
|
|
969
|
-
direction: "pong"
|
|
970
|
-
};
|
|
971
|
-
this.ws.send(JSON.stringify(ping));
|
|
441
|
+
this.page = null;
|
|
442
|
+
this.log("info", "Local browser host stopped");
|
|
972
443
|
}
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
444
|
+
// =========================================================================
|
|
445
|
+
// WebSocket Connection
|
|
446
|
+
// =========================================================================
|
|
447
|
+
async connectWebSocket() {
|
|
448
|
+
return new Promise((resolve, reject) => {
|
|
449
|
+
const wsUrl = `${this.options.apiUrl.replace("http", "ws")}/local-browser/sessions/${this.options.sessionId}/connect?token=${this.options.wsToken}`;
|
|
450
|
+
this.log("info", "Connecting to cloud API", { url: wsUrl.replace(/token=.*/, "token=***") });
|
|
451
|
+
const ws = new WebSocket(wsUrl);
|
|
452
|
+
ws.onopen = () => {
|
|
453
|
+
this.log("info", "Connected to cloud API");
|
|
454
|
+
this.ws = ws;
|
|
455
|
+
this.reconnectAttempts = 0;
|
|
456
|
+
this.startHeartbeat();
|
|
457
|
+
resolve();
|
|
458
|
+
};
|
|
459
|
+
ws.onmessage = (event) => {
|
|
460
|
+
this.handleMessage(event.data);
|
|
461
|
+
};
|
|
462
|
+
ws.onerror = (event) => {
|
|
463
|
+
this.log("error", "WebSocket error", event);
|
|
464
|
+
};
|
|
465
|
+
ws.onclose = () => {
|
|
466
|
+
this.log("info", "WebSocket closed");
|
|
467
|
+
this.stopHeartbeat();
|
|
468
|
+
this.ws = null;
|
|
469
|
+
if (!this.isShuttingDown) {
|
|
470
|
+
this.scheduleReconnect();
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
setTimeout(() => {
|
|
474
|
+
if (!this.ws) {
|
|
475
|
+
reject(new Error("WebSocket connection timeout"));
|
|
476
|
+
}
|
|
477
|
+
}, 3e4);
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
scheduleReconnect() {
|
|
481
|
+
if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
482
|
+
this.log("error", "Max reconnection attempts reached, giving up");
|
|
483
|
+
this.stop();
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const delay = Math.min(
|
|
487
|
+
RECONNECT_DELAY_MS * Math.pow(2, this.reconnectAttempts),
|
|
488
|
+
MAX_RECONNECT_DELAY_MS
|
|
489
|
+
);
|
|
490
|
+
this.reconnectAttempts++;
|
|
491
|
+
this.log("info", `Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
|
492
|
+
setTimeout(async () => {
|
|
493
|
+
try {
|
|
494
|
+
await this.connectWebSocket();
|
|
495
|
+
this.sendSessionEvent("connected");
|
|
496
|
+
if (this.page) {
|
|
497
|
+
this.sendSessionEvent("browser_ready");
|
|
498
|
+
}
|
|
499
|
+
} catch (error) {
|
|
500
|
+
this.log("error", "Reconnection failed", error);
|
|
501
|
+
this.scheduleReconnect();
|
|
502
|
+
}
|
|
503
|
+
}, delay);
|
|
504
|
+
}
|
|
505
|
+
// =========================================================================
|
|
506
|
+
// Heartbeat
|
|
507
|
+
// =========================================================================
|
|
508
|
+
startHeartbeat() {
|
|
509
|
+
this.stopHeartbeat();
|
|
510
|
+
this.heartbeatTimer = setInterval(() => {
|
|
511
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
512
|
+
const ping = {
|
|
513
|
+
type: "heartbeat",
|
|
514
|
+
id: crypto.randomUUID(),
|
|
515
|
+
timestamp: Date.now(),
|
|
516
|
+
direction: "pong"
|
|
517
|
+
};
|
|
518
|
+
this.ws.send(JSON.stringify(ping));
|
|
519
|
+
}
|
|
520
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
521
|
+
}
|
|
522
|
+
stopHeartbeat() {
|
|
523
|
+
if (this.heartbeatTimer) {
|
|
524
|
+
clearInterval(this.heartbeatTimer);
|
|
525
|
+
this.heartbeatTimer = null;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
// =========================================================================
|
|
529
|
+
// Browser Management
|
|
530
|
+
// =========================================================================
|
|
531
|
+
async launchBrowser() {
|
|
532
|
+
const { browserMode, cdpUrl, headless = true, storageStatePath } = this.options;
|
|
533
|
+
if (browserMode === "cdp" && cdpUrl) {
|
|
534
|
+
this.log("info", "Connecting to existing Chrome via CDP", { cdpUrl });
|
|
535
|
+
this.browser = await chromium.connectOverCDP(cdpUrl);
|
|
536
|
+
const contexts = this.browser.contexts();
|
|
537
|
+
this.context = contexts[0] ?? await this.browser.newContext();
|
|
538
|
+
const pages = this.context.pages();
|
|
539
|
+
this.page = pages[0] ?? await this.context.newPage();
|
|
540
|
+
} else {
|
|
541
|
+
this.log("info", "Launching new Playwright browser", { headless });
|
|
542
|
+
this.browser = await chromium.launch({
|
|
543
|
+
headless,
|
|
544
|
+
args: ["--no-sandbox"]
|
|
545
|
+
});
|
|
546
|
+
const contextOptions = {
|
|
547
|
+
viewport: { width: 1920, height: 1080 }
|
|
548
|
+
};
|
|
549
|
+
if (storageStatePath) {
|
|
550
|
+
try {
|
|
551
|
+
await Bun.file(storageStatePath).exists();
|
|
552
|
+
contextOptions.storageState = storageStatePath;
|
|
553
|
+
this.log("info", "Loading storage state", { storageStatePath });
|
|
554
|
+
} catch {
|
|
555
|
+
this.log("debug", "Storage state file not found, starting fresh");
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
this.context = await this.browser.newContext(contextOptions);
|
|
559
|
+
this.page = await this.context.newPage();
|
|
560
|
+
}
|
|
561
|
+
this.page.on("dialog", (dialog) => {
|
|
562
|
+
this.pendingDialogs.push(dialog);
|
|
563
|
+
});
|
|
564
|
+
this.log("info", "Browser ready");
|
|
565
|
+
}
|
|
566
|
+
// =========================================================================
|
|
567
|
+
// Message Handling
|
|
568
|
+
// =========================================================================
|
|
569
|
+
handleMessage(data) {
|
|
1003
570
|
try {
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
571
|
+
const message = JSON.parse(data);
|
|
572
|
+
if (message.type === "heartbeat" && message.direction === "ping") {
|
|
573
|
+
const pong = {
|
|
574
|
+
type: "heartbeat",
|
|
575
|
+
id: crypto.randomUUID(),
|
|
576
|
+
timestamp: Date.now(),
|
|
577
|
+
direction: "pong"
|
|
578
|
+
};
|
|
579
|
+
this.ws?.send(JSON.stringify(pong));
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
if (message.type === "command") {
|
|
583
|
+
this.handleCommand(message);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
this.log("debug", "Received unknown message type", message);
|
|
587
|
+
} catch (error) {
|
|
588
|
+
this.log("error", "Failed to parse message", { error, data });
|
|
1009
589
|
}
|
|
1010
590
|
}
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
591
|
+
async handleCommand(command) {
|
|
592
|
+
const startTime = Date.now();
|
|
593
|
+
this.log("debug", `Executing command: ${command.method}`, { id: command.id });
|
|
594
|
+
try {
|
|
595
|
+
const result = await this.executeMethod(command.method, command.args);
|
|
596
|
+
const response = {
|
|
597
|
+
type: "response",
|
|
598
|
+
id: crypto.randomUUID(),
|
|
599
|
+
timestamp: Date.now(),
|
|
600
|
+
requestId: command.id,
|
|
601
|
+
success: true,
|
|
602
|
+
result
|
|
603
|
+
};
|
|
604
|
+
this.ws?.send(JSON.stringify(response));
|
|
605
|
+
this.log("debug", `Command completed: ${command.method}`, {
|
|
606
|
+
id: command.id,
|
|
607
|
+
durationMs: Date.now() - startTime
|
|
608
|
+
});
|
|
609
|
+
} catch (error) {
|
|
610
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
611
|
+
const response = {
|
|
612
|
+
type: "response",
|
|
613
|
+
id: crypto.randomUUID(),
|
|
614
|
+
timestamp: Date.now(),
|
|
615
|
+
requestId: command.id,
|
|
616
|
+
success: false,
|
|
617
|
+
error: errorMessage,
|
|
618
|
+
stack: error instanceof Error ? error.stack : void 0
|
|
619
|
+
};
|
|
620
|
+
this.ws?.send(JSON.stringify(response));
|
|
621
|
+
this.log("error", `Command failed: ${command.method}`, {
|
|
622
|
+
id: command.id,
|
|
623
|
+
error: errorMessage
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
sendSessionEvent(event, error) {
|
|
628
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
629
|
+
const message = {
|
|
630
|
+
type: "session",
|
|
1028
631
|
id: crypto.randomUUID(),
|
|
1029
632
|
timestamp: Date.now(),
|
|
1030
|
-
|
|
633
|
+
event,
|
|
634
|
+
browserMode: this.options.browserMode,
|
|
635
|
+
error
|
|
1031
636
|
};
|
|
1032
|
-
this.ws
|
|
1033
|
-
|
|
637
|
+
this.ws.send(JSON.stringify(message));
|
|
638
|
+
}
|
|
639
|
+
// =========================================================================
|
|
640
|
+
// Method Execution
|
|
641
|
+
// =========================================================================
|
|
642
|
+
async executeMethod(method, args) {
|
|
643
|
+
switch (method) {
|
|
644
|
+
// Lifecycle
|
|
645
|
+
case "connect":
|
|
646
|
+
return this.connect(args[0]);
|
|
647
|
+
case "disconnect":
|
|
648
|
+
return this.disconnect();
|
|
649
|
+
// Navigation
|
|
650
|
+
case "navigate":
|
|
651
|
+
return this.navigate(args[0], args[1]);
|
|
652
|
+
case "navigateBack":
|
|
653
|
+
return this.navigateBack(args[0]);
|
|
654
|
+
// Page Inspection
|
|
655
|
+
case "snapshot":
|
|
656
|
+
return this.snapshot(args[0]);
|
|
657
|
+
case "takeScreenshot":
|
|
658
|
+
return this.takeScreenshot(args[0]);
|
|
659
|
+
case "evaluate":
|
|
660
|
+
return this.evaluate(args[0], args[1]);
|
|
661
|
+
case "runCode":
|
|
662
|
+
return this.runCode(args[0], args[1]);
|
|
663
|
+
case "consoleMessages":
|
|
664
|
+
return this.consoleMessages(args[0]);
|
|
665
|
+
case "networkRequests":
|
|
666
|
+
return this.networkRequests(args[0]);
|
|
667
|
+
// Interaction
|
|
668
|
+
case "click":
|
|
669
|
+
return this.click(args[0], args[1], args[2]);
|
|
670
|
+
case "clickAtCoordinates":
|
|
671
|
+
return this.clickAtCoordinates(
|
|
672
|
+
args[0],
|
|
673
|
+
args[1],
|
|
674
|
+
args[2],
|
|
675
|
+
args[3]
|
|
676
|
+
);
|
|
677
|
+
case "moveToCoordinates":
|
|
678
|
+
return this.moveToCoordinates(
|
|
679
|
+
args[0],
|
|
680
|
+
args[1],
|
|
681
|
+
args[2],
|
|
682
|
+
args[3]
|
|
683
|
+
);
|
|
684
|
+
case "dragCoordinates":
|
|
685
|
+
return this.dragCoordinates(
|
|
686
|
+
args[0],
|
|
687
|
+
args[1],
|
|
688
|
+
args[2],
|
|
689
|
+
args[3],
|
|
690
|
+
args[4],
|
|
691
|
+
args[5]
|
|
692
|
+
);
|
|
693
|
+
case "hover":
|
|
694
|
+
return this.hover(args[0], args[1], args[2]);
|
|
695
|
+
case "drag":
|
|
696
|
+
return this.drag(
|
|
697
|
+
args[0],
|
|
698
|
+
args[1],
|
|
699
|
+
args[2],
|
|
700
|
+
args[3],
|
|
701
|
+
args[4]
|
|
702
|
+
);
|
|
703
|
+
case "type":
|
|
704
|
+
return this.type(
|
|
705
|
+
args[0],
|
|
706
|
+
args[1],
|
|
707
|
+
args[2],
|
|
708
|
+
args[3],
|
|
709
|
+
args[4]
|
|
710
|
+
);
|
|
711
|
+
case "pressKey":
|
|
712
|
+
return this.pressKey(args[0], args[1]);
|
|
713
|
+
case "fillForm":
|
|
714
|
+
return this.fillForm(args[0], args[1]);
|
|
715
|
+
case "selectOption":
|
|
716
|
+
return this.selectOption(
|
|
717
|
+
args[0],
|
|
718
|
+
args[1],
|
|
719
|
+
args[2],
|
|
720
|
+
args[3]
|
|
721
|
+
);
|
|
722
|
+
case "fileUpload":
|
|
723
|
+
return this.fileUpload(args[0], args[1]);
|
|
724
|
+
// Dialogs
|
|
725
|
+
case "handleDialog":
|
|
726
|
+
return this.handleDialog(args[0], args[1], args[2]);
|
|
727
|
+
// Waiting
|
|
728
|
+
case "waitFor":
|
|
729
|
+
return this.waitFor(args[0]);
|
|
730
|
+
// Browser Management
|
|
731
|
+
case "close":
|
|
732
|
+
return this.closePage(args[0]);
|
|
733
|
+
case "resize":
|
|
734
|
+
return this.resize(args[0], args[1], args[2]);
|
|
735
|
+
case "tabs":
|
|
736
|
+
return this.tabs(args[0], args[1], args[2]);
|
|
737
|
+
// Storage
|
|
738
|
+
case "getStorageState":
|
|
739
|
+
return this.getStorageState(args[0]);
|
|
740
|
+
case "getCurrentUrl":
|
|
741
|
+
return this.getCurrentUrl(args[0]);
|
|
742
|
+
case "getTitle":
|
|
743
|
+
return this.getTitle(args[0]);
|
|
744
|
+
case "getLinks":
|
|
745
|
+
return this.getLinks(args[0]);
|
|
746
|
+
case "getElementBoundingBox":
|
|
747
|
+
return this.getElementBoundingBox(args[0], args[1]);
|
|
748
|
+
// Tracing
|
|
749
|
+
case "startTracing":
|
|
750
|
+
return this.startTracing(args[0]);
|
|
751
|
+
case "stopTracing":
|
|
752
|
+
return this.stopTracing(args[0]);
|
|
753
|
+
// Video
|
|
754
|
+
case "isVideoRecordingEnabled":
|
|
755
|
+
return false;
|
|
756
|
+
// Video not supported in CLI host currently
|
|
757
|
+
case "saveVideo":
|
|
758
|
+
return null;
|
|
759
|
+
case "getVideoPath":
|
|
760
|
+
return null;
|
|
761
|
+
default:
|
|
762
|
+
throw new Error(`Unknown method: ${method}`);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
// =========================================================================
|
|
766
|
+
// IBrowserClient Method Implementations
|
|
767
|
+
// =========================================================================
|
|
768
|
+
getPage() {
|
|
769
|
+
if (!this.page) throw new Error("No page available");
|
|
770
|
+
return this.page;
|
|
1034
771
|
}
|
|
1035
|
-
|
|
1036
|
-
this.
|
|
772
|
+
resolveRef(ref) {
|
|
773
|
+
return this.getPage().locator(`aria-ref=${ref}`);
|
|
774
|
+
}
|
|
775
|
+
async connect(_options) {
|
|
1037
776
|
return;
|
|
1038
777
|
}
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
return this.runCode(args[0], args[1]);
|
|
1116
|
-
case "consoleMessages":
|
|
1117
|
-
return this.consoleMessages(args[0]);
|
|
1118
|
-
case "networkRequests":
|
|
1119
|
-
return this.networkRequests(args[0]);
|
|
1120
|
-
// Interaction
|
|
1121
|
-
case "click":
|
|
1122
|
-
return this.click(args[0], args[1], args[2]);
|
|
1123
|
-
case "clickAtCoordinates":
|
|
1124
|
-
return this.clickAtCoordinates(
|
|
1125
|
-
args[0],
|
|
1126
|
-
args[1],
|
|
1127
|
-
args[2],
|
|
1128
|
-
args[3]
|
|
1129
|
-
);
|
|
1130
|
-
case "moveToCoordinates":
|
|
1131
|
-
return this.moveToCoordinates(
|
|
1132
|
-
args[0],
|
|
1133
|
-
args[1],
|
|
1134
|
-
args[2],
|
|
1135
|
-
args[3]
|
|
1136
|
-
);
|
|
1137
|
-
case "dragCoordinates":
|
|
1138
|
-
return this.dragCoordinates(
|
|
1139
|
-
args[0],
|
|
1140
|
-
args[1],
|
|
1141
|
-
args[2],
|
|
1142
|
-
args[3],
|
|
1143
|
-
args[4],
|
|
1144
|
-
args[5]
|
|
1145
|
-
);
|
|
1146
|
-
case "hover":
|
|
1147
|
-
return this.hover(args[0], args[1], args[2]);
|
|
1148
|
-
case "drag":
|
|
1149
|
-
return this.drag(
|
|
1150
|
-
args[0],
|
|
1151
|
-
args[1],
|
|
1152
|
-
args[2],
|
|
1153
|
-
args[3],
|
|
1154
|
-
args[4]
|
|
1155
|
-
);
|
|
1156
|
-
case "type":
|
|
1157
|
-
return this.type(
|
|
1158
|
-
args[0],
|
|
1159
|
-
args[1],
|
|
1160
|
-
args[2],
|
|
1161
|
-
args[3],
|
|
1162
|
-
args[4]
|
|
1163
|
-
);
|
|
1164
|
-
case "pressKey":
|
|
1165
|
-
return this.pressKey(args[0], args[1]);
|
|
1166
|
-
case "fillForm":
|
|
1167
|
-
return this.fillForm(args[0], args[1]);
|
|
1168
|
-
case "selectOption":
|
|
1169
|
-
return this.selectOption(
|
|
1170
|
-
args[0],
|
|
1171
|
-
args[1],
|
|
1172
|
-
args[2],
|
|
1173
|
-
args[3]
|
|
1174
|
-
);
|
|
1175
|
-
case "fileUpload":
|
|
1176
|
-
return this.fileUpload(args[0], args[1]);
|
|
1177
|
-
// Dialogs
|
|
1178
|
-
case "handleDialog":
|
|
1179
|
-
return this.handleDialog(args[0], args[1], args[2]);
|
|
1180
|
-
// Waiting
|
|
1181
|
-
case "waitFor":
|
|
1182
|
-
return this.waitFor(args[0]);
|
|
1183
|
-
// Browser Management
|
|
1184
|
-
case "close":
|
|
1185
|
-
return this.closePage(args[0]);
|
|
1186
|
-
case "resize":
|
|
1187
|
-
return this.resize(args[0], args[1], args[2]);
|
|
1188
|
-
case "tabs":
|
|
1189
|
-
return this.tabs(args[0], args[1], args[2]);
|
|
1190
|
-
// Storage
|
|
1191
|
-
case "getStorageState":
|
|
1192
|
-
return this.getStorageState(args[0]);
|
|
1193
|
-
case "getCurrentUrl":
|
|
1194
|
-
return this.getCurrentUrl(args[0]);
|
|
1195
|
-
case "getTitle":
|
|
1196
|
-
return this.getTitle(args[0]);
|
|
1197
|
-
case "getLinks":
|
|
1198
|
-
return this.getLinks(args[0]);
|
|
1199
|
-
case "getElementBoundingBox":
|
|
1200
|
-
return this.getElementBoundingBox(args[0], args[1]);
|
|
1201
|
-
// Tracing
|
|
1202
|
-
case "startTracing":
|
|
1203
|
-
return this.startTracing(args[0]);
|
|
1204
|
-
case "stopTracing":
|
|
1205
|
-
return this.stopTracing(args[0]);
|
|
1206
|
-
// Video
|
|
1207
|
-
case "isVideoRecordingEnabled":
|
|
1208
|
-
return false;
|
|
1209
|
-
// Video not supported in CLI host currently
|
|
1210
|
-
case "saveVideo":
|
|
1211
|
-
return null;
|
|
1212
|
-
case "getVideoPath":
|
|
1213
|
-
return null;
|
|
1214
|
-
default:
|
|
1215
|
-
throw new Error(`Unknown method: ${method}`);
|
|
1216
|
-
}
|
|
1217
|
-
}
|
|
1218
|
-
// =========================================================================
|
|
1219
|
-
// IBrowserClient Method Implementations
|
|
1220
|
-
// =========================================================================
|
|
1221
|
-
getPage() {
|
|
1222
|
-
if (!this.page) throw new Error("No page available");
|
|
1223
|
-
return this.page;
|
|
1224
|
-
}
|
|
1225
|
-
resolveRef(ref) {
|
|
1226
|
-
return this.getPage().locator(`aria-ref=${ref}`);
|
|
1227
|
-
}
|
|
1228
|
-
async connect(_options) {
|
|
1229
|
-
return;
|
|
1230
|
-
}
|
|
1231
|
-
async disconnect() {
|
|
1232
|
-
await this.stop();
|
|
1233
|
-
}
|
|
1234
|
-
async navigate(url, _opts) {
|
|
1235
|
-
const page = this.getPage();
|
|
1236
|
-
await page.goto(url, { waitUntil: "domcontentloaded" });
|
|
1237
|
-
await page.waitForLoadState("load", { timeout: 5e3 }).catch(() => {
|
|
1238
|
-
});
|
|
1239
|
-
return this.captureSnapshot();
|
|
1240
|
-
}
|
|
1241
|
-
async navigateBack(_opts) {
|
|
1242
|
-
await this.getPage().goBack();
|
|
1243
|
-
return this.captureSnapshot();
|
|
1244
|
-
}
|
|
1245
|
-
async snapshot(_opts) {
|
|
1246
|
-
return this.captureSnapshot();
|
|
1247
|
-
}
|
|
1248
|
-
async captureSnapshot() {
|
|
1249
|
-
const page = this.getPage();
|
|
1250
|
-
this.lastSnapshotYaml = await page._snapshotForAI({ mode: "full" });
|
|
1251
|
-
return this.lastSnapshotYaml;
|
|
1252
|
-
}
|
|
1253
|
-
async takeScreenshot(opts) {
|
|
1254
|
-
const page = this.getPage();
|
|
1255
|
-
const buffer = await page.screenshot({
|
|
1256
|
-
type: opts?.type ?? "jpeg",
|
|
1257
|
-
fullPage: opts?.fullPage ?? false
|
|
1258
|
-
});
|
|
1259
|
-
const mime = opts?.type === "png" ? "image/png" : "image/jpeg";
|
|
1260
|
-
return `data:${mime};base64,${buffer.toString("base64")}`;
|
|
1261
|
-
}
|
|
1262
|
-
async evaluate(fn, _opts) {
|
|
1263
|
-
const page = this.getPage();
|
|
1264
|
-
return page.evaluate(new Function(`return (${fn})()`));
|
|
1265
|
-
}
|
|
1266
|
-
async runCode(code, _opts) {
|
|
1267
|
-
const page = this.getPage();
|
|
1268
|
-
const fn = new Function("page", `return (async () => { ${code} })()`);
|
|
1269
|
-
return fn(page);
|
|
1270
|
-
}
|
|
1271
|
-
async consoleMessages(_opts) {
|
|
1272
|
-
return "Console message capture not implemented in CLI host";
|
|
1273
|
-
}
|
|
1274
|
-
async networkRequests(_opts) {
|
|
1275
|
-
return "Network request capture not implemented in CLI host";
|
|
1276
|
-
}
|
|
1277
|
-
async click(ref, _elementDesc, opts) {
|
|
1278
|
-
const locator = this.resolveRef(ref);
|
|
1279
|
-
await locator.scrollIntoViewIfNeeded({ timeout: 5e3 }).catch(() => {
|
|
1280
|
-
});
|
|
1281
|
-
const box = await locator.boundingBox();
|
|
1282
|
-
if (box) {
|
|
1283
|
-
const centerX = box.x + box.width / 2;
|
|
1284
|
-
const centerY = box.y + box.height / 2;
|
|
1285
|
-
const page = this.getPage();
|
|
1286
|
-
if (opts?.modifiers?.length) {
|
|
1287
|
-
for (const mod of opts.modifiers) {
|
|
1288
|
-
await page.keyboard.down(mod);
|
|
778
|
+
async disconnect() {
|
|
779
|
+
await this.stop();
|
|
780
|
+
}
|
|
781
|
+
async navigate(url, _opts) {
|
|
782
|
+
const page = this.getPage();
|
|
783
|
+
await page.goto(url, { waitUntil: "domcontentloaded" });
|
|
784
|
+
await page.waitForLoadState("load", { timeout: 5e3 }).catch(() => {
|
|
785
|
+
});
|
|
786
|
+
return this.captureSnapshot();
|
|
787
|
+
}
|
|
788
|
+
async navigateBack(_opts) {
|
|
789
|
+
await this.getPage().goBack();
|
|
790
|
+
return this.captureSnapshot();
|
|
791
|
+
}
|
|
792
|
+
async snapshot(_opts) {
|
|
793
|
+
return this.captureSnapshot();
|
|
794
|
+
}
|
|
795
|
+
async captureSnapshot() {
|
|
796
|
+
const page = this.getPage();
|
|
797
|
+
this.lastSnapshotYaml = await page._snapshotForAI({ mode: "full" });
|
|
798
|
+
return this.lastSnapshotYaml;
|
|
799
|
+
}
|
|
800
|
+
async takeScreenshot(opts) {
|
|
801
|
+
const page = this.getPage();
|
|
802
|
+
const buffer = await page.screenshot({
|
|
803
|
+
type: opts?.type ?? "jpeg",
|
|
804
|
+
fullPage: opts?.fullPage ?? false
|
|
805
|
+
});
|
|
806
|
+
const mime = opts?.type === "png" ? "image/png" : "image/jpeg";
|
|
807
|
+
return `data:${mime};base64,${buffer.toString("base64")}`;
|
|
808
|
+
}
|
|
809
|
+
async evaluate(fn, _opts) {
|
|
810
|
+
const page = this.getPage();
|
|
811
|
+
return page.evaluate(new Function(`return (${fn})()`));
|
|
812
|
+
}
|
|
813
|
+
async runCode(code, _opts) {
|
|
814
|
+
const page = this.getPage();
|
|
815
|
+
const fn = new Function("page", `return (async () => { ${code} })()`);
|
|
816
|
+
return fn(page);
|
|
817
|
+
}
|
|
818
|
+
async consoleMessages(_opts) {
|
|
819
|
+
return "Console message capture not implemented in CLI host";
|
|
820
|
+
}
|
|
821
|
+
async networkRequests(_opts) {
|
|
822
|
+
return "Network request capture not implemented in CLI host";
|
|
823
|
+
}
|
|
824
|
+
async click(ref, _elementDesc, opts) {
|
|
825
|
+
const locator = this.resolveRef(ref);
|
|
826
|
+
await locator.scrollIntoViewIfNeeded({ timeout: 5e3 }).catch(() => {
|
|
827
|
+
});
|
|
828
|
+
const box = await locator.boundingBox();
|
|
829
|
+
if (box) {
|
|
830
|
+
const centerX = box.x + box.width / 2;
|
|
831
|
+
const centerY = box.y + box.height / 2;
|
|
832
|
+
const page = this.getPage();
|
|
833
|
+
if (opts?.modifiers?.length) {
|
|
834
|
+
for (const mod of opts.modifiers) {
|
|
835
|
+
await page.keyboard.down(mod);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
if (opts?.doubleClick) {
|
|
839
|
+
await page.mouse.dblclick(centerX, centerY);
|
|
840
|
+
} else {
|
|
841
|
+
await page.mouse.click(centerX, centerY);
|
|
842
|
+
}
|
|
843
|
+
if (opts?.modifiers?.length) {
|
|
844
|
+
for (const mod of opts.modifiers) {
|
|
845
|
+
await page.keyboard.up(mod);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
} else {
|
|
849
|
+
if (opts?.doubleClick) {
|
|
850
|
+
await locator.dblclick({ timeout: opts?.timeoutMs ?? 3e4 });
|
|
851
|
+
} else {
|
|
852
|
+
await locator.click({ timeout: opts?.timeoutMs ?? 3e4 });
|
|
853
|
+
}
|
|
1289
854
|
}
|
|
1290
855
|
}
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
856
|
+
async clickAtCoordinates(x, y, _elementDesc, opts) {
|
|
857
|
+
const page = this.getPage();
|
|
858
|
+
if (opts?.doubleClick) {
|
|
859
|
+
await page.mouse.dblclick(x, y);
|
|
860
|
+
} else {
|
|
861
|
+
await page.mouse.click(x, y);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
async moveToCoordinates(x, y, _elementDesc, _opts) {
|
|
865
|
+
await this.getPage().mouse.move(x, y);
|
|
866
|
+
}
|
|
867
|
+
async dragCoordinates(startX, startY, endX, endY, _elementDesc, _opts) {
|
|
868
|
+
const page = this.getPage();
|
|
869
|
+
await page.mouse.move(startX, startY);
|
|
870
|
+
await page.mouse.down();
|
|
871
|
+
await page.mouse.move(endX, endY);
|
|
872
|
+
await page.mouse.up();
|
|
873
|
+
}
|
|
874
|
+
async hover(ref, _elementDesc, opts) {
|
|
875
|
+
await this.resolveRef(ref).hover({ timeout: opts?.timeoutMs ?? 3e4 });
|
|
1295
876
|
}
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
877
|
+
async drag(startRef, _startElement, endRef, _endElement, opts) {
|
|
878
|
+
const startLocator = this.resolveRef(startRef);
|
|
879
|
+
const endLocator = this.resolveRef(endRef);
|
|
880
|
+
await startLocator.dragTo(endLocator, { timeout: opts?.timeoutMs ?? 6e4 });
|
|
881
|
+
}
|
|
882
|
+
async type(ref, text, _elementDesc, submit, opts) {
|
|
883
|
+
const locator = this.resolveRef(ref);
|
|
884
|
+
await locator.clear();
|
|
885
|
+
await locator.pressSequentially(text, {
|
|
886
|
+
delay: opts?.delay ?? 0,
|
|
887
|
+
timeout: opts?.timeoutMs ?? 3e4
|
|
888
|
+
});
|
|
889
|
+
if (submit) {
|
|
890
|
+
await locator.press("Enter");
|
|
1299
891
|
}
|
|
1300
892
|
}
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
await locator.dblclick({ timeout: opts?.timeoutMs ?? 3e4 });
|
|
1304
|
-
} else {
|
|
1305
|
-
await locator.click({ timeout: opts?.timeoutMs ?? 3e4 });
|
|
893
|
+
async pressKey(key, _opts) {
|
|
894
|
+
await this.getPage().keyboard.press(key);
|
|
1306
895
|
}
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
await this.resolveRef(ref).hover({ timeout: opts?.timeoutMs ?? 3e4 });
|
|
1329
|
-
}
|
|
1330
|
-
async drag(startRef, _startElement, endRef, _endElement, opts) {
|
|
1331
|
-
const startLocator = this.resolveRef(startRef);
|
|
1332
|
-
const endLocator = this.resolveRef(endRef);
|
|
1333
|
-
await startLocator.dragTo(endLocator, { timeout: opts?.timeoutMs ?? 6e4 });
|
|
1334
|
-
}
|
|
1335
|
-
async type(ref, text, _elementDesc, submit, opts) {
|
|
1336
|
-
const locator = this.resolveRef(ref);
|
|
1337
|
-
await locator.clear();
|
|
1338
|
-
await locator.pressSequentially(text, {
|
|
1339
|
-
delay: opts?.delay ?? 0,
|
|
1340
|
-
timeout: opts?.timeoutMs ?? 3e4
|
|
1341
|
-
});
|
|
1342
|
-
if (submit) {
|
|
1343
|
-
await locator.press("Enter");
|
|
1344
|
-
}
|
|
1345
|
-
}
|
|
1346
|
-
async pressKey(key, _opts) {
|
|
1347
|
-
await this.getPage().keyboard.press(key);
|
|
1348
|
-
}
|
|
1349
|
-
async fillForm(fields, opts) {
|
|
1350
|
-
for (const field of fields) {
|
|
1351
|
-
const locator = this.resolveRef(field.ref);
|
|
1352
|
-
const fieldType = field.type ?? "textbox";
|
|
1353
|
-
switch (fieldType) {
|
|
1354
|
-
case "checkbox": {
|
|
1355
|
-
const isChecked = await locator.isChecked();
|
|
1356
|
-
const shouldBeChecked = field.value === "true";
|
|
1357
|
-
if (shouldBeChecked !== isChecked) {
|
|
1358
|
-
await locator.click({ timeout: opts?.timeoutMs ?? 3e4 });
|
|
896
|
+
async fillForm(fields, opts) {
|
|
897
|
+
for (const field of fields) {
|
|
898
|
+
const locator = this.resolveRef(field.ref);
|
|
899
|
+
const fieldType = field.type ?? "textbox";
|
|
900
|
+
switch (fieldType) {
|
|
901
|
+
case "checkbox": {
|
|
902
|
+
const isChecked = await locator.isChecked();
|
|
903
|
+
const shouldBeChecked = field.value === "true";
|
|
904
|
+
if (shouldBeChecked !== isChecked) {
|
|
905
|
+
await locator.click({ timeout: opts?.timeoutMs ?? 3e4 });
|
|
906
|
+
}
|
|
907
|
+
break;
|
|
908
|
+
}
|
|
909
|
+
case "radio":
|
|
910
|
+
await locator.check({ timeout: opts?.timeoutMs ?? 3e4 });
|
|
911
|
+
break;
|
|
912
|
+
case "combobox":
|
|
913
|
+
await locator.selectOption(field.value, { timeout: opts?.timeoutMs ?? 3e4 });
|
|
914
|
+
break;
|
|
915
|
+
default:
|
|
916
|
+
await locator.fill(field.value, { timeout: opts?.timeoutMs ?? 3e4 });
|
|
1359
917
|
}
|
|
1360
|
-
break;
|
|
1361
918
|
}
|
|
1362
|
-
case "radio":
|
|
1363
|
-
await locator.check({ timeout: opts?.timeoutMs ?? 3e4 });
|
|
1364
|
-
break;
|
|
1365
|
-
case "combobox":
|
|
1366
|
-
await locator.selectOption(field.value, { timeout: opts?.timeoutMs ?? 3e4 });
|
|
1367
|
-
break;
|
|
1368
|
-
default:
|
|
1369
|
-
await locator.fill(field.value, { timeout: opts?.timeoutMs ?? 3e4 });
|
|
1370
919
|
}
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
async selectOption(ref, value, _elementDesc, opts) {
|
|
1374
|
-
await this.resolveRef(ref).selectOption(value, { timeout: opts?.timeoutMs ?? 3e4 });
|
|
1375
|
-
}
|
|
1376
|
-
async fileUpload(paths, opts) {
|
|
1377
|
-
const fileChooser = await this.getPage().waitForEvent("filechooser", {
|
|
1378
|
-
timeout: opts?.timeoutMs ?? 3e4
|
|
1379
|
-
});
|
|
1380
|
-
await fileChooser.setFiles(paths);
|
|
1381
|
-
}
|
|
1382
|
-
async handleDialog(action, promptText, _opts) {
|
|
1383
|
-
const dialog = this.pendingDialogs.shift();
|
|
1384
|
-
if (dialog) {
|
|
1385
|
-
if (action === "accept") {
|
|
1386
|
-
await dialog.accept(promptText);
|
|
1387
|
-
} else {
|
|
1388
|
-
await dialog.dismiss();
|
|
920
|
+
async selectOption(ref, value, _elementDesc, opts) {
|
|
921
|
+
await this.resolveRef(ref).selectOption(value, { timeout: opts?.timeoutMs ?? 3e4 });
|
|
1389
922
|
}
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
await page.getByText(opts.textGone).first().waitFor({ state: "hidden", timeout });
|
|
1405
|
-
return;
|
|
1406
|
-
}
|
|
1407
|
-
if (opts?.selector) {
|
|
1408
|
-
await page.locator(opts.selector).waitFor({
|
|
1409
|
-
state: opts.state ?? "visible",
|
|
1410
|
-
timeout
|
|
1411
|
-
});
|
|
1412
|
-
}
|
|
1413
|
-
}
|
|
1414
|
-
async closePage(_opts) {
|
|
1415
|
-
await this.getPage().close();
|
|
1416
|
-
this.page = null;
|
|
1417
|
-
}
|
|
1418
|
-
async resize(width, height, _opts) {
|
|
1419
|
-
await this.getPage().setViewportSize({ width, height });
|
|
1420
|
-
}
|
|
1421
|
-
async tabs(action, index, _opts) {
|
|
1422
|
-
if (!this.context) throw new Error("No context available");
|
|
1423
|
-
const pages = this.context.pages();
|
|
1424
|
-
switch (action) {
|
|
1425
|
-
case "list":
|
|
1426
|
-
return Promise.all(
|
|
1427
|
-
pages.map(async (p, i) => ({
|
|
1428
|
-
index: i,
|
|
1429
|
-
url: p.url(),
|
|
1430
|
-
title: await p.title().catch(() => "")
|
|
1431
|
-
}))
|
|
1432
|
-
);
|
|
1433
|
-
case "new": {
|
|
1434
|
-
const newPage = await this.context.newPage();
|
|
1435
|
-
this.page = newPage;
|
|
1436
|
-
newPage.on("dialog", (dialog) => this.pendingDialogs.push(dialog));
|
|
1437
|
-
return { index: pages.length };
|
|
1438
|
-
}
|
|
1439
|
-
case "close":
|
|
1440
|
-
if (index !== void 0 && pages[index]) {
|
|
1441
|
-
await pages[index].close();
|
|
1442
|
-
} else {
|
|
1443
|
-
await this.page?.close();
|
|
923
|
+
async fileUpload(paths, opts) {
|
|
924
|
+
const fileChooser = await this.getPage().waitForEvent("filechooser", {
|
|
925
|
+
timeout: opts?.timeoutMs ?? 3e4
|
|
926
|
+
});
|
|
927
|
+
await fileChooser.setFiles(paths);
|
|
928
|
+
}
|
|
929
|
+
async handleDialog(action, promptText, _opts) {
|
|
930
|
+
const dialog = this.pendingDialogs.shift();
|
|
931
|
+
if (dialog) {
|
|
932
|
+
if (action === "accept") {
|
|
933
|
+
await dialog.accept(promptText);
|
|
934
|
+
} else {
|
|
935
|
+
await dialog.dismiss();
|
|
936
|
+
}
|
|
1444
937
|
}
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
938
|
+
}
|
|
939
|
+
async waitFor(opts) {
|
|
940
|
+
const page = this.getPage();
|
|
941
|
+
const timeout = opts?.timeout ?? opts?.timeoutMs ?? 3e4;
|
|
942
|
+
if (opts?.timeSec) {
|
|
943
|
+
await page.waitForTimeout(opts.timeSec * 1e3);
|
|
944
|
+
return;
|
|
1450
945
|
}
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
946
|
+
if (opts?.text) {
|
|
947
|
+
await page.getByText(opts.text).first().waitFor({ state: "visible", timeout });
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
if (opts?.textGone) {
|
|
951
|
+
await page.getByText(opts.textGone).first().waitFor({ state: "hidden", timeout });
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
if (opts?.selector) {
|
|
955
|
+
await page.locator(opts.selector).waitFor({
|
|
956
|
+
state: opts.state ?? "visible",
|
|
957
|
+
timeout
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
async closePage(_opts) {
|
|
962
|
+
await this.getPage().close();
|
|
963
|
+
this.page = null;
|
|
964
|
+
}
|
|
965
|
+
async resize(width, height, _opts) {
|
|
966
|
+
await this.getPage().setViewportSize({ width, height });
|
|
967
|
+
}
|
|
968
|
+
async tabs(action, index, _opts) {
|
|
969
|
+
if (!this.context) throw new Error("No context available");
|
|
970
|
+
const pages = this.context.pages();
|
|
971
|
+
switch (action) {
|
|
972
|
+
case "list":
|
|
973
|
+
return Promise.all(
|
|
974
|
+
pages.map(async (p, i) => ({
|
|
975
|
+
index: i,
|
|
976
|
+
url: p.url(),
|
|
977
|
+
title: await p.title().catch(() => "")
|
|
978
|
+
}))
|
|
979
|
+
);
|
|
980
|
+
case "new": {
|
|
981
|
+
const newPage = await this.context.newPage();
|
|
982
|
+
this.page = newPage;
|
|
983
|
+
newPage.on("dialog", (dialog) => this.pendingDialogs.push(dialog));
|
|
984
|
+
return { index: pages.length };
|
|
985
|
+
}
|
|
986
|
+
case "close":
|
|
987
|
+
if (index !== void 0 && pages[index]) {
|
|
988
|
+
await pages[index].close();
|
|
989
|
+
} else {
|
|
990
|
+
await this.page?.close();
|
|
991
|
+
}
|
|
992
|
+
this.page = this.context.pages()[0] ?? null;
|
|
993
|
+
break;
|
|
994
|
+
case "select":
|
|
995
|
+
if (index !== void 0 && pages[index]) {
|
|
996
|
+
this.page = pages[index];
|
|
997
|
+
}
|
|
998
|
+
break;
|
|
999
|
+
}
|
|
1000
|
+
return null;
|
|
1001
|
+
}
|
|
1002
|
+
async getStorageState(_opts) {
|
|
1003
|
+
if (!this.context) throw new Error("No context available");
|
|
1004
|
+
return this.context.storageState();
|
|
1005
|
+
}
|
|
1006
|
+
async getCurrentUrl(_opts) {
|
|
1007
|
+
return this.getPage().url();
|
|
1008
|
+
}
|
|
1009
|
+
async getTitle(_opts) {
|
|
1010
|
+
return this.getPage().title();
|
|
1011
|
+
}
|
|
1012
|
+
async getLinks(_opts) {
|
|
1013
|
+
const page = this.getPage();
|
|
1014
|
+
return page.$$eval(
|
|
1015
|
+
"a[href]",
|
|
1016
|
+
(links) => links.map((a) => a.href).filter((h) => !!h && (h.startsWith("http://") || h.startsWith("https://")))
|
|
1017
|
+
);
|
|
1018
|
+
}
|
|
1019
|
+
async getElementBoundingBox(ref, _opts) {
|
|
1020
|
+
const locator = this.resolveRef(ref);
|
|
1021
|
+
const box = await locator.boundingBox();
|
|
1022
|
+
if (!box) return null;
|
|
1023
|
+
return { x: box.x, y: box.y, width: box.width, height: box.height };
|
|
1024
|
+
}
|
|
1025
|
+
async startTracing(_opts) {
|
|
1026
|
+
if (!this.context) throw new Error("No context available");
|
|
1027
|
+
await this.context.tracing.start({ screenshots: true, snapshots: true });
|
|
1028
|
+
}
|
|
1029
|
+
async stopTracing(_opts) {
|
|
1030
|
+
if (!this.context) throw new Error("No context available");
|
|
1031
|
+
const tracePath = `/tmp/trace-${Date.now()}.zip`;
|
|
1032
|
+
await this.context.tracing.stop({ path: tracePath });
|
|
1033
|
+
return {
|
|
1034
|
+
trace: tracePath,
|
|
1035
|
+
network: "",
|
|
1036
|
+
resources: "",
|
|
1037
|
+
directory: null,
|
|
1038
|
+
legend: `Trace saved to ${tracePath}`
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1492
1041
|
};
|
|
1493
1042
|
}
|
|
1494
|
-
};
|
|
1043
|
+
});
|
|
1495
1044
|
|
|
1496
1045
|
// src/mcp.ts
|
|
1497
|
-
var
|
|
1498
|
-
|
|
1046
|
+
var mcp_exports = {};
|
|
1047
|
+
__export(mcp_exports, {
|
|
1048
|
+
runMcp: () => runMcp
|
|
1049
|
+
});
|
|
1050
|
+
import process7 from "process";
|
|
1051
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
1052
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
1053
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
1054
|
+
import { createParser as createParser2 } from "eventsource-parser";
|
|
1499
1055
|
function resolveApiUrl(input) {
|
|
1500
|
-
return input ??
|
|
1056
|
+
return input ?? process7.env.CANARY_API_URL ?? DEFAULT_API_URL;
|
|
1501
1057
|
}
|
|
1502
1058
|
async function resolveToken() {
|
|
1503
|
-
const token =
|
|
1059
|
+
const token = process7.env.CANARY_API_TOKEN ?? await readStoredToken();
|
|
1504
1060
|
if (!token) {
|
|
1505
1061
|
throw new Error("Missing token. Run `canary login` first or set CANARY_API_TOKEN.");
|
|
1506
1062
|
}
|
|
@@ -1813,7 +1369,7 @@ async function streamUntilComplete(input) {
|
|
|
1813
1369
|
if (!response.body) return;
|
|
1814
1370
|
const reader = response.body.getReader();
|
|
1815
1371
|
const decoder = new TextDecoder();
|
|
1816
|
-
const parser =
|
|
1372
|
+
const parser = createParser2({
|
|
1817
1373
|
onEvent: (event) => {
|
|
1818
1374
|
if (event.event === "status") {
|
|
1819
1375
|
try {
|
|
@@ -1828,37 +1384,672 @@ async function streamUntilComplete(input) {
|
|
|
1828
1384
|
reader.cancel().catch(() => void 0);
|
|
1829
1385
|
}
|
|
1830
1386
|
}
|
|
1831
|
-
});
|
|
1387
|
+
});
|
|
1388
|
+
while (true) {
|
|
1389
|
+
const { done, value } = await reader.read();
|
|
1390
|
+
if (done) break;
|
|
1391
|
+
parser.feed(decoder.decode(value, { stream: true }));
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
function formatReport(input) {
|
|
1395
|
+
if (!input.summary) {
|
|
1396
|
+
return {
|
|
1397
|
+
runId: input.run?.id,
|
|
1398
|
+
status: input.run?.status ?? "unknown",
|
|
1399
|
+
summary: "No final report available."
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
const tested = Array.isArray(input.summary.testedItems) ? input.summary.testedItems : [];
|
|
1403
|
+
const status = input.summary.status ?? input.run?.status ?? "unknown";
|
|
1404
|
+
const issues = status === "issues_found" ? input.summary.notes ? [input.summary.notes] : ["Issues reported."] : [];
|
|
1405
|
+
return {
|
|
1406
|
+
runId: input.run?.id,
|
|
1407
|
+
status,
|
|
1408
|
+
summary: input.summary.summary ?? "Run completed.",
|
|
1409
|
+
testedItems: tested,
|
|
1410
|
+
issues,
|
|
1411
|
+
notes: input.summary.notes ?? null
|
|
1412
|
+
};
|
|
1413
|
+
}
|
|
1414
|
+
var browserSessions, DEFAULT_API_URL;
|
|
1415
|
+
var init_mcp = __esm({
|
|
1416
|
+
"src/mcp.ts"() {
|
|
1417
|
+
"use strict";
|
|
1418
|
+
init_auth();
|
|
1419
|
+
init_local_run();
|
|
1420
|
+
init_tunnel();
|
|
1421
|
+
init_host();
|
|
1422
|
+
browserSessions = /* @__PURE__ */ new Map();
|
|
1423
|
+
DEFAULT_API_URL = "https://api.trycanary.ai";
|
|
1424
|
+
}
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
// src/local-browser/index.ts
|
|
1428
|
+
var local_browser_exports = {};
|
|
1429
|
+
__export(local_browser_exports, {
|
|
1430
|
+
runLocalBrowser: () => runLocalBrowser
|
|
1431
|
+
});
|
|
1432
|
+
import process8 from "process";
|
|
1433
|
+
function parseArgs(args) {
|
|
1434
|
+
const options = {
|
|
1435
|
+
mode: "playwright",
|
|
1436
|
+
headless: true,
|
|
1437
|
+
apiUrl: process8.env.CANARY_API_URL ?? DEFAULT_API_URL2
|
|
1438
|
+
};
|
|
1439
|
+
for (let i = 0; i < args.length; i++) {
|
|
1440
|
+
const arg = args[i];
|
|
1441
|
+
const nextArg = args[i + 1];
|
|
1442
|
+
switch (arg) {
|
|
1443
|
+
case "--mode":
|
|
1444
|
+
if (nextArg === "playwright" || nextArg === "cdp") {
|
|
1445
|
+
options.mode = nextArg;
|
|
1446
|
+
i++;
|
|
1447
|
+
}
|
|
1448
|
+
break;
|
|
1449
|
+
case "--cdp-url":
|
|
1450
|
+
options.cdpUrl = nextArg;
|
|
1451
|
+
options.mode = "cdp";
|
|
1452
|
+
i++;
|
|
1453
|
+
break;
|
|
1454
|
+
case "--headless":
|
|
1455
|
+
options.headless = true;
|
|
1456
|
+
break;
|
|
1457
|
+
case "--no-headless":
|
|
1458
|
+
options.headless = false;
|
|
1459
|
+
break;
|
|
1460
|
+
case "--storage-state":
|
|
1461
|
+
options.storageStatePath = nextArg;
|
|
1462
|
+
i++;
|
|
1463
|
+
break;
|
|
1464
|
+
case "--api-url":
|
|
1465
|
+
options.apiUrl = nextArg;
|
|
1466
|
+
i++;
|
|
1467
|
+
break;
|
|
1468
|
+
case "--instructions":
|
|
1469
|
+
options.instructions = nextArg;
|
|
1470
|
+
i++;
|
|
1471
|
+
break;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
return options;
|
|
1475
|
+
}
|
|
1476
|
+
async function resolveToken2() {
|
|
1477
|
+
const token = process8.env.CANARY_API_TOKEN ?? await readStoredToken();
|
|
1478
|
+
if (!token) {
|
|
1479
|
+
throw new Error("Missing token. Run `canary login` first or set CANARY_API_TOKEN.");
|
|
1480
|
+
}
|
|
1481
|
+
return token;
|
|
1482
|
+
}
|
|
1483
|
+
async function createSession(apiUrl, token, options) {
|
|
1484
|
+
const response = await fetch(`${apiUrl}/local-browser/sessions`, {
|
|
1485
|
+
method: "POST",
|
|
1486
|
+
headers: {
|
|
1487
|
+
"Content-Type": "application/json",
|
|
1488
|
+
Authorization: `Bearer ${token}`
|
|
1489
|
+
},
|
|
1490
|
+
body: JSON.stringify({
|
|
1491
|
+
browserMode: options.mode,
|
|
1492
|
+
instructions: options.instructions ?? null
|
|
1493
|
+
})
|
|
1494
|
+
});
|
|
1495
|
+
if (!response.ok) {
|
|
1496
|
+
const text = await response.text();
|
|
1497
|
+
throw new Error(`Failed to create session: ${response.status} ${text}`);
|
|
1498
|
+
}
|
|
1499
|
+
return response.json();
|
|
1500
|
+
}
|
|
1501
|
+
async function runLocalBrowser(args) {
|
|
1502
|
+
const options = parseArgs(args);
|
|
1503
|
+
console.log("Starting local browser...");
|
|
1504
|
+
console.log(` Mode: ${options.mode}`);
|
|
1505
|
+
if (options.cdpUrl) {
|
|
1506
|
+
console.log(` CDP URL: ${options.cdpUrl}`);
|
|
1507
|
+
}
|
|
1508
|
+
console.log(` Headless: ${options.headless}`);
|
|
1509
|
+
console.log(` API URL: ${options.apiUrl}`);
|
|
1510
|
+
console.log();
|
|
1511
|
+
const token = await resolveToken2();
|
|
1512
|
+
console.log("Creating session with cloud API...");
|
|
1513
|
+
const session = await createSession(options.apiUrl, token, options);
|
|
1514
|
+
if (!session.ok) {
|
|
1515
|
+
throw new Error(`Failed to create session: ${session.error}`);
|
|
1516
|
+
}
|
|
1517
|
+
console.log(`Session created: ${session.sessionId}`);
|
|
1518
|
+
console.log(`Expires at: ${session.expiresAt}`);
|
|
1519
|
+
console.log();
|
|
1520
|
+
const host = new LocalBrowserHost({
|
|
1521
|
+
apiUrl: options.apiUrl,
|
|
1522
|
+
wsToken: session.wsToken,
|
|
1523
|
+
sessionId: session.sessionId,
|
|
1524
|
+
browserMode: options.mode,
|
|
1525
|
+
cdpUrl: options.cdpUrl,
|
|
1526
|
+
headless: options.headless,
|
|
1527
|
+
storageStatePath: options.storageStatePath,
|
|
1528
|
+
onLog: (level, message, data) => {
|
|
1529
|
+
const prefix = `[${level.toUpperCase()}]`;
|
|
1530
|
+
if (data) {
|
|
1531
|
+
console.log(prefix, message, data);
|
|
1532
|
+
} else {
|
|
1533
|
+
console.log(prefix, message);
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
});
|
|
1537
|
+
const shutdown = async () => {
|
|
1538
|
+
console.log("\nShutting down...");
|
|
1539
|
+
await host.stop();
|
|
1540
|
+
process8.exit(0);
|
|
1541
|
+
};
|
|
1542
|
+
process8.on("SIGINT", shutdown);
|
|
1543
|
+
process8.on("SIGTERM", shutdown);
|
|
1544
|
+
try {
|
|
1545
|
+
await host.start();
|
|
1546
|
+
console.log();
|
|
1547
|
+
console.log("Local browser is ready and connected to cloud.");
|
|
1548
|
+
console.log("Press Ctrl+C to stop.");
|
|
1549
|
+
console.log();
|
|
1550
|
+
await new Promise(() => {
|
|
1551
|
+
});
|
|
1552
|
+
} catch (error) {
|
|
1553
|
+
console.error("Failed to start local browser:", error);
|
|
1554
|
+
await host.stop();
|
|
1555
|
+
process8.exit(1);
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
var DEFAULT_API_URL2;
|
|
1559
|
+
var init_local_browser = __esm({
|
|
1560
|
+
"src/local-browser/index.ts"() {
|
|
1561
|
+
"use strict";
|
|
1562
|
+
init_auth();
|
|
1563
|
+
init_host();
|
|
1564
|
+
DEFAULT_API_URL2 = "https://api.trycanary.ai";
|
|
1565
|
+
}
|
|
1566
|
+
});
|
|
1567
|
+
|
|
1568
|
+
// src/index.ts
|
|
1569
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
1570
|
+
import process9 from "process";
|
|
1571
|
+
import path5 from "path";
|
|
1572
|
+
import { fileURLToPath as fileURLToPath2, pathToFileURL as pathToFileURL2 } from "url";
|
|
1573
|
+
|
|
1574
|
+
// src/runner/common.ts
|
|
1575
|
+
import { spawnSync } from "child_process";
|
|
1576
|
+
import fs from "fs";
|
|
1577
|
+
import path from "path";
|
|
1578
|
+
import { createRequire } from "module";
|
|
1579
|
+
import { pathToFileURL } from "url";
|
|
1580
|
+
function makeRequire() {
|
|
1581
|
+
try {
|
|
1582
|
+
return createRequire(import.meta.url);
|
|
1583
|
+
} catch {
|
|
1584
|
+
try {
|
|
1585
|
+
return createRequire(process.cwd());
|
|
1586
|
+
} catch {
|
|
1587
|
+
return typeof __require !== "undefined" ? __require : createRequire(".");
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
function resolveRunner(preloadPath2) {
|
|
1592
|
+
const { bin, version } = pickNodeBinary();
|
|
1593
|
+
const supportsImport = typeof version === "number" && version >= 18;
|
|
1594
|
+
if (supportsImport && preloadPath2 && fs.existsSync(preloadPath2)) {
|
|
1595
|
+
return { runnerBin: bin, preloadFlag: `--import=${pathToFileURL(preloadPath2).href}` };
|
|
1596
|
+
}
|
|
1597
|
+
if (preloadPath2) {
|
|
1598
|
+
console.warn("[canary] Warning: no preload module found; instrumentation may be disabled.");
|
|
1599
|
+
}
|
|
1600
|
+
return { runnerBin: bin };
|
|
1601
|
+
}
|
|
1602
|
+
function pickNodeBinary() {
|
|
1603
|
+
const candidates = collectNodeCandidates();
|
|
1604
|
+
let best;
|
|
1605
|
+
let fallback;
|
|
1606
|
+
for (const bin of candidates) {
|
|
1607
|
+
const version = getNodeMajor(bin);
|
|
1608
|
+
if (!version) continue;
|
|
1609
|
+
const current = { bin, version };
|
|
1610
|
+
if (version >= 18 && !fallback) {
|
|
1611
|
+
fallback = current;
|
|
1612
|
+
}
|
|
1613
|
+
if (!best || version > (best.version ?? 0)) {
|
|
1614
|
+
best = current;
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
if (fallback) return fallback;
|
|
1618
|
+
if (best) return best;
|
|
1619
|
+
return { bin: candidates[0] ?? "node" };
|
|
1620
|
+
}
|
|
1621
|
+
function collectNodeCandidates() {
|
|
1622
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1623
|
+
const push = (value) => {
|
|
1624
|
+
if (!value) return;
|
|
1625
|
+
if (seen.has(value)) return;
|
|
1626
|
+
seen.add(value);
|
|
1627
|
+
};
|
|
1628
|
+
const isBun = path.basename(process.execPath).includes("bun");
|
|
1629
|
+
push(process.env.CANARY_NODE_BIN);
|
|
1630
|
+
push(isBun ? void 0 : process.execPath);
|
|
1631
|
+
push("node");
|
|
1632
|
+
try {
|
|
1633
|
+
const which = spawnSync("which", ["-a", "node"], { encoding: "utf-8" });
|
|
1634
|
+
which.stdout?.toString().split("\n").map((line) => line.trim()).forEach((line) => push(line));
|
|
1635
|
+
} catch {
|
|
1636
|
+
}
|
|
1637
|
+
const nvmDir = process.env.NVM_DIR || (process.env.HOME ? path.join(process.env.HOME, ".nvm") : void 0);
|
|
1638
|
+
if (nvmDir) {
|
|
1639
|
+
const versionsDir = path.join(nvmDir, "versions", "node");
|
|
1640
|
+
if (fs.existsSync(versionsDir)) {
|
|
1641
|
+
try {
|
|
1642
|
+
const versions = fs.readdirSync(versionsDir);
|
|
1643
|
+
versions.sort((a, b) => a > b ? -1 : 1).forEach((v) => push(path.join(versionsDir, v, "bin", "node")));
|
|
1644
|
+
} catch {
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
return Array.from(seen);
|
|
1649
|
+
}
|
|
1650
|
+
function getNodeMajor(bin) {
|
|
1651
|
+
try {
|
|
1652
|
+
const result = spawnSync(bin, ["-v"], { encoding: "utf-8" });
|
|
1653
|
+
const output = (result.stdout || result.stderr || "").toString().trim();
|
|
1654
|
+
const match = output.match(/^v(\d+)/);
|
|
1655
|
+
if (match) return Number(match[1]);
|
|
1656
|
+
} catch {
|
|
1657
|
+
}
|
|
1658
|
+
return void 0;
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
// src/run.ts
|
|
1662
|
+
import { spawn } from "child_process";
|
|
1663
|
+
import fs2 from "fs";
|
|
1664
|
+
import os from "os";
|
|
1665
|
+
import path2 from "path";
|
|
1666
|
+
import { fileURLToPath } from "url";
|
|
1667
|
+
async function run(request = {}) {
|
|
1668
|
+
const cwd = request.projectRoot ?? process.cwd();
|
|
1669
|
+
const stdio = request.stdio ?? "inherit";
|
|
1670
|
+
const requireFn2 = makeRequire();
|
|
1671
|
+
const playwrightCli = requireFn2.resolve("@playwright/test/cli");
|
|
1672
|
+
const baseDir2 = path2.dirname(fileURLToPath(import.meta.url));
|
|
1673
|
+
const preloadPath2 = path2.join(baseDir2, "runner", "preload.js");
|
|
1674
|
+
const { runnerBin, preloadFlag } = resolveRunner(preloadPath2);
|
|
1675
|
+
const { jsonReportPath, eventLogPath, artifactsDir } = prepareArtifactsDir(cwd);
|
|
1676
|
+
const reporter = buildReporterArgs(request.reporter, jsonReportPath);
|
|
1677
|
+
const args = buildArgs({
|
|
1678
|
+
testDir: request.testDir,
|
|
1679
|
+
configFile: request.configFile,
|
|
1680
|
+
cliArgs: request.cliArgs,
|
|
1681
|
+
reporter
|
|
1682
|
+
});
|
|
1683
|
+
const nodeOptions = process.env.NODE_OPTIONS && preloadFlag ? `${process.env.NODE_OPTIONS} ${preloadFlag}` : preloadFlag ?? process.env.NODE_OPTIONS;
|
|
1684
|
+
const env = buildEnv({
|
|
1685
|
+
base: process.env,
|
|
1686
|
+
overrides: request.env,
|
|
1687
|
+
healing: request.healing,
|
|
1688
|
+
eventLogPath,
|
|
1689
|
+
nodeOptions
|
|
1690
|
+
});
|
|
1691
|
+
const runResult = await spawnPlaywright({
|
|
1692
|
+
bin: request.nodeBin ?? runnerBin,
|
|
1693
|
+
args: [playwrightCli, ...args],
|
|
1694
|
+
cwd,
|
|
1695
|
+
env,
|
|
1696
|
+
stdio,
|
|
1697
|
+
timeoutMs: request.timeoutMs
|
|
1698
|
+
});
|
|
1699
|
+
const summary = summarize(jsonReportPath, eventLogPath, runResult.durationMs);
|
|
1700
|
+
return {
|
|
1701
|
+
ok: runResult.exitCode === 0,
|
|
1702
|
+
exitCode: runResult.exitCode,
|
|
1703
|
+
summary,
|
|
1704
|
+
artifactsDir,
|
|
1705
|
+
rawOutput: runResult.output,
|
|
1706
|
+
error: runResult.error
|
|
1707
|
+
};
|
|
1708
|
+
}
|
|
1709
|
+
function buildArgs(opts) {
|
|
1710
|
+
const args = ["test"];
|
|
1711
|
+
if (opts.testDir) {
|
|
1712
|
+
const dirs = Array.isArray(opts.testDir) ? opts.testDir : [opts.testDir];
|
|
1713
|
+
args.push(...dirs);
|
|
1714
|
+
}
|
|
1715
|
+
if (opts.configFile) {
|
|
1716
|
+
args.push("--config", opts.configFile);
|
|
1717
|
+
}
|
|
1718
|
+
args.push("--reporter", opts.reporter);
|
|
1719
|
+
if (opts.cliArgs?.length) {
|
|
1720
|
+
args.push(...opts.cliArgs);
|
|
1721
|
+
}
|
|
1722
|
+
return args;
|
|
1723
|
+
}
|
|
1724
|
+
function buildReporterArgs(requested, jsonReportPath) {
|
|
1725
|
+
if (requested === "json") return `json=${jsonReportPath}`;
|
|
1726
|
+
if (requested && requested !== "default") return requested;
|
|
1727
|
+
return `list,json=${jsonReportPath}`;
|
|
1728
|
+
}
|
|
1729
|
+
function prepareArtifactsDir(cwd) {
|
|
1730
|
+
const dir = fs2.mkdtempSync(path2.join(os.tmpdir(), "canary-run-"));
|
|
1731
|
+
const jsonReportPath = path2.join(dir, "report.json");
|
|
1732
|
+
const eventLogPath = path2.join(dir, "events-worker-0.jsonl");
|
|
1733
|
+
const artifactsDir = path2.join(cwd, "test-results", "ai-healer");
|
|
1734
|
+
return { jsonReportPath, eventLogPath, artifactsDir: dir };
|
|
1735
|
+
}
|
|
1736
|
+
function buildEnv(params) {
|
|
1737
|
+
const healing = params.healing ?? {};
|
|
1738
|
+
const env = {
|
|
1739
|
+
...params.base,
|
|
1740
|
+
CANARY_ENABLED: params.base.CANARY_ENABLED ?? "1",
|
|
1741
|
+
CANARY_RUNNER: "canary",
|
|
1742
|
+
CANARY_EVENT_LOG: params.eventLogPath,
|
|
1743
|
+
...params.nodeOptions ? { NODE_OPTIONS: params.nodeOptions } : {},
|
|
1744
|
+
...healing.apiKey ? { AI_API_KEY: healing.apiKey } : {},
|
|
1745
|
+
...healing.provider ? { AI_PROVIDER: healing.provider } : {},
|
|
1746
|
+
...healing.model ? { AI_MODEL: healing.model } : {},
|
|
1747
|
+
...healing.timeoutMs ? { AI_TIMEOUT_MS: String(healing.timeoutMs) } : {},
|
|
1748
|
+
...healing.maxActions ? { CANARY_MAX_ACTIONS: String(healing.maxActions) } : {},
|
|
1749
|
+
...healing.vision ? { CANARY_VISION: "1" } : {},
|
|
1750
|
+
...healing.dryRun ? { CANARY_DRY_RUN: "1" } : {},
|
|
1751
|
+
...healing.warnOnly ? { CANARY_WARN_ONLY: "1" } : {},
|
|
1752
|
+
...healing.debug ? { CANARY_DEBUG: "1" } : {},
|
|
1753
|
+
...healing.readOnly ? { CANARY_READ_ONLY: "1" } : {},
|
|
1754
|
+
...healing.allowEvaluate === false ? { CANARY_ALLOW_EVALUATE: "0" } : {},
|
|
1755
|
+
...healing.allowRunCode ? { CANARY_ALLOW_RUN_CODE: "1" } : {},
|
|
1756
|
+
...healing.maxPayloadBytes ? { CANARY_MAX_PAYLOAD_BYTES: String(healing.maxPayloadBytes) } : {},
|
|
1757
|
+
...params.overrides
|
|
1758
|
+
};
|
|
1759
|
+
return env;
|
|
1760
|
+
}
|
|
1761
|
+
async function spawnPlaywright(opts) {
|
|
1762
|
+
return new Promise((resolve) => {
|
|
1763
|
+
const started = Date.now();
|
|
1764
|
+
const child = spawn(opts.bin, opts.args, {
|
|
1765
|
+
cwd: opts.cwd,
|
|
1766
|
+
env: opts.env,
|
|
1767
|
+
stdio: opts.stdio === "inherit" ? "inherit" : ["ignore", "pipe", "pipe"]
|
|
1768
|
+
});
|
|
1769
|
+
let timer;
|
|
1770
|
+
let output = "";
|
|
1771
|
+
let error;
|
|
1772
|
+
if (opts.stdio === "pipe") {
|
|
1773
|
+
child.stdout?.on("data", (chunk) => {
|
|
1774
|
+
output += chunk.toString();
|
|
1775
|
+
});
|
|
1776
|
+
child.stderr?.on("data", (chunk) => {
|
|
1777
|
+
output += chunk.toString();
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
if (opts.timeoutMs && opts.timeoutMs > 0) {
|
|
1781
|
+
timer = setTimeout(() => {
|
|
1782
|
+
error = new Error(`canary.run timed out after ${opts.timeoutMs}ms`);
|
|
1783
|
+
child.kill("SIGKILL");
|
|
1784
|
+
}, opts.timeoutMs);
|
|
1785
|
+
}
|
|
1786
|
+
child.on("close", (code) => {
|
|
1787
|
+
if (timer) clearTimeout(timer);
|
|
1788
|
+
resolve({ exitCode: code ?? 1, output: output || void 0, durationMs: Date.now() - started, error });
|
|
1789
|
+
});
|
|
1790
|
+
});
|
|
1791
|
+
}
|
|
1792
|
+
function summarize(jsonReportPath, eventLogPath, durationMs) {
|
|
1793
|
+
const base = {
|
|
1794
|
+
total: 0,
|
|
1795
|
+
passed: 0,
|
|
1796
|
+
failed: 0,
|
|
1797
|
+
flaky: 0,
|
|
1798
|
+
skipped: 0,
|
|
1799
|
+
durationMs
|
|
1800
|
+
};
|
|
1801
|
+
const jsonReport = readJsonReport(jsonReportPath);
|
|
1802
|
+
if (jsonReport) {
|
|
1803
|
+
const counts = countTests(jsonReport);
|
|
1804
|
+
base.total = counts.total;
|
|
1805
|
+
base.passed = counts.passed;
|
|
1806
|
+
base.failed = counts.failed;
|
|
1807
|
+
base.flaky = counts.flaky;
|
|
1808
|
+
base.skipped = counts.skipped;
|
|
1809
|
+
base.durationMs = jsonReport.duration ?? durationMs;
|
|
1810
|
+
}
|
|
1811
|
+
const healed = countHealed(eventLogPath);
|
|
1812
|
+
if (healed) {
|
|
1813
|
+
return { ...base, healed };
|
|
1814
|
+
}
|
|
1815
|
+
return base;
|
|
1816
|
+
}
|
|
1817
|
+
function readJsonReport(reportPath) {
|
|
1818
|
+
try {
|
|
1819
|
+
if (fs2.existsSync(reportPath)) {
|
|
1820
|
+
const raw = fs2.readFileSync(reportPath, "utf-8");
|
|
1821
|
+
return JSON.parse(raw);
|
|
1822
|
+
}
|
|
1823
|
+
} catch {
|
|
1824
|
+
}
|
|
1825
|
+
return void 0;
|
|
1826
|
+
}
|
|
1827
|
+
function countTests(report) {
|
|
1828
|
+
let total = 0;
|
|
1829
|
+
let passed = 0;
|
|
1830
|
+
let failed = 0;
|
|
1831
|
+
let flaky = 0;
|
|
1832
|
+
let skipped = 0;
|
|
1833
|
+
const visitSuite = (suite) => {
|
|
1834
|
+
if (!suite) return;
|
|
1835
|
+
suite.tests?.forEach((test) => {
|
|
1836
|
+
total += 1;
|
|
1837
|
+
const statuses = test.results.map((r) => r.status);
|
|
1838
|
+
const hasFailed = statuses.includes("failed") || statuses.includes("interrupted");
|
|
1839
|
+
const hasPassed = statuses.includes("passed");
|
|
1840
|
+
const hasTimedOut = statuses.includes("timedOut");
|
|
1841
|
+
const allSkipped = statuses.every((s) => s === "skipped");
|
|
1842
|
+
if (allSkipped) {
|
|
1843
|
+
skipped += 1;
|
|
1844
|
+
} else if ((hasFailed || hasTimedOut) && hasPassed) {
|
|
1845
|
+
flaky += 1;
|
|
1846
|
+
} else if (hasFailed || hasTimedOut) {
|
|
1847
|
+
failed += 1;
|
|
1848
|
+
} else if (hasPassed && statuses.length > 1) {
|
|
1849
|
+
flaky += 1;
|
|
1850
|
+
} else if (hasPassed) {
|
|
1851
|
+
passed += 1;
|
|
1852
|
+
}
|
|
1853
|
+
});
|
|
1854
|
+
suite.suites?.forEach(visitSuite);
|
|
1855
|
+
};
|
|
1856
|
+
report.suites?.forEach(visitSuite);
|
|
1857
|
+
return { total, passed, failed, flaky, skipped };
|
|
1858
|
+
}
|
|
1859
|
+
function countHealed(eventLogPath) {
|
|
1860
|
+
try {
|
|
1861
|
+
if (!fs2.existsSync(eventLogPath)) return void 0;
|
|
1862
|
+
const raw = fs2.readFileSync(eventLogPath, "utf-8").trim();
|
|
1863
|
+
if (!raw) return void 0;
|
|
1864
|
+
const lines = raw.split("\n");
|
|
1865
|
+
let healed = 0;
|
|
1866
|
+
for (const line of lines) {
|
|
1867
|
+
try {
|
|
1868
|
+
const event = JSON.parse(line);
|
|
1869
|
+
if (event?.healed === true) healed += 1;
|
|
1870
|
+
} catch {
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
return healed;
|
|
1874
|
+
} catch {
|
|
1875
|
+
return void 0;
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
// src/index.ts
|
|
1880
|
+
init_local_run();
|
|
1881
|
+
init_tunnel();
|
|
1882
|
+
|
|
1883
|
+
// src/login.ts
|
|
1884
|
+
import fs4 from "fs/promises";
|
|
1885
|
+
import os4 from "os";
|
|
1886
|
+
import path4 from "path";
|
|
1887
|
+
import process4 from "process";
|
|
1888
|
+
import { spawn as spawn2 } from "child_process";
|
|
1889
|
+
var DEFAULT_APP_URL = "https://app.trycanary.ai";
|
|
1890
|
+
function getArgValue3(argv, key) {
|
|
1891
|
+
const index = argv.indexOf(key);
|
|
1892
|
+
if (index === -1) return void 0;
|
|
1893
|
+
return argv[index + 1];
|
|
1894
|
+
}
|
|
1895
|
+
function shouldOpenBrowser(argv) {
|
|
1896
|
+
return !argv.includes("--no-open");
|
|
1897
|
+
}
|
|
1898
|
+
function openUrl(url) {
|
|
1899
|
+
const platform = process4.platform;
|
|
1900
|
+
if (platform === "darwin") {
|
|
1901
|
+
spawn2("open", [url], { stdio: "ignore" });
|
|
1902
|
+
return;
|
|
1903
|
+
}
|
|
1904
|
+
if (platform === "win32") {
|
|
1905
|
+
spawn2("cmd", ["/c", "start", "", url], { stdio: "ignore" });
|
|
1906
|
+
return;
|
|
1907
|
+
}
|
|
1908
|
+
spawn2("xdg-open", [url], { stdio: "ignore" });
|
|
1909
|
+
}
|
|
1910
|
+
async function writeToken(token) {
|
|
1911
|
+
const dir = path4.join(os4.homedir(), ".config", "canary-cli");
|
|
1912
|
+
const filePath = path4.join(dir, "auth.json");
|
|
1913
|
+
await fs4.mkdir(dir, { recursive: true });
|
|
1914
|
+
await fs4.writeFile(filePath, JSON.stringify({ token }, null, 2), "utf8");
|
|
1915
|
+
return filePath;
|
|
1916
|
+
}
|
|
1917
|
+
async function runLogin(argv) {
|
|
1918
|
+
const apiUrl = getArgValue3(argv, "--api-url") ?? process4.env.CANARY_API_URL ?? "https://api.trycanary.ai";
|
|
1919
|
+
const appUrl = getArgValue3(argv, "--app-url") ?? process4.env.CANARY_APP_URL ?? DEFAULT_APP_URL;
|
|
1920
|
+
const startRes = await fetch(`${apiUrl}/cli-login/start`, {
|
|
1921
|
+
method: "POST",
|
|
1922
|
+
headers: { "content-type": "application/json" },
|
|
1923
|
+
body: JSON.stringify({ appUrl })
|
|
1924
|
+
});
|
|
1925
|
+
const startJson = await startRes.json();
|
|
1926
|
+
if (!startRes.ok || !startJson.ok || !startJson.deviceCode || !startJson.userCode) {
|
|
1927
|
+
console.error("Login start failed", startJson.error ?? startRes.statusText);
|
|
1928
|
+
process4.exit(1);
|
|
1929
|
+
}
|
|
1930
|
+
console.log("Login required.");
|
|
1931
|
+
console.log(`User code: ${startJson.userCode}`);
|
|
1932
|
+
if (startJson.verificationUrl) {
|
|
1933
|
+
console.log(`Open: ${startJson.verificationUrl}`);
|
|
1934
|
+
if (shouldOpenBrowser(argv)) {
|
|
1935
|
+
try {
|
|
1936
|
+
openUrl(startJson.verificationUrl);
|
|
1937
|
+
} catch {
|
|
1938
|
+
console.log("Unable to open browser automatically. Please open the URL manually.");
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
const intervalMs = (startJson.intervalSeconds ?? 3) * 1e3;
|
|
1943
|
+
const expiresAt = startJson.expiresAt ? new Date(startJson.expiresAt).getTime() : null;
|
|
1832
1944
|
while (true) {
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1945
|
+
if (expiresAt && Date.now() > expiresAt) {
|
|
1946
|
+
console.error("Login code expired.");
|
|
1947
|
+
process4.exit(1);
|
|
1948
|
+
}
|
|
1949
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
1950
|
+
const pollRes = await fetch(`${apiUrl}/cli-login/poll`, {
|
|
1951
|
+
method: "POST",
|
|
1952
|
+
headers: { "content-type": "application/json" },
|
|
1953
|
+
body: JSON.stringify({ deviceCode: startJson.deviceCode })
|
|
1954
|
+
});
|
|
1955
|
+
const pollJson = await pollRes.json();
|
|
1956
|
+
if (!pollRes.ok || !pollJson.ok) {
|
|
1957
|
+
console.error("Login poll failed", pollJson.error ?? pollRes.statusText);
|
|
1958
|
+
process4.exit(1);
|
|
1959
|
+
}
|
|
1960
|
+
if (pollJson.status === "approved" && pollJson.accessToken) {
|
|
1961
|
+
const filePath = await writeToken(pollJson.accessToken);
|
|
1962
|
+
console.log(`Login successful. Token saved to ${filePath}`);
|
|
1963
|
+
console.log("Set CANARY_API_TOKEN to use the CLI without re-login.");
|
|
1964
|
+
return;
|
|
1965
|
+
}
|
|
1966
|
+
if (pollJson.status === "rejected") {
|
|
1967
|
+
console.error("Login rejected.");
|
|
1968
|
+
process4.exit(1);
|
|
1969
|
+
}
|
|
1970
|
+
if (pollJson.status === "expired") {
|
|
1971
|
+
console.error("Login expired.");
|
|
1972
|
+
process4.exit(1);
|
|
1973
|
+
}
|
|
1836
1974
|
}
|
|
1837
1975
|
}
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1976
|
+
|
|
1977
|
+
// src/run-local.ts
|
|
1978
|
+
init_auth();
|
|
1979
|
+
init_local_run();
|
|
1980
|
+
init_tunnel();
|
|
1981
|
+
import process5 from "process";
|
|
1982
|
+
function getArgValue4(argv, key) {
|
|
1983
|
+
const index = argv.indexOf(key);
|
|
1984
|
+
if (index === -1) return void 0;
|
|
1985
|
+
return argv[index + 1];
|
|
1986
|
+
}
|
|
1987
|
+
async function runLocalSession(argv) {
|
|
1988
|
+
const apiUrl = getArgValue4(argv, "--api-url") ?? process5.env.CANARY_API_URL ?? "https://api.trycanary.ai";
|
|
1989
|
+
const token = getArgValue4(argv, "--token") ?? process5.env.CANARY_API_TOKEN ?? await readStoredToken();
|
|
1990
|
+
if (!token) {
|
|
1991
|
+
console.error("Missing token. Run `canary login` first or set CANARY_API_TOKEN.");
|
|
1992
|
+
process5.exit(1);
|
|
1993
|
+
}
|
|
1994
|
+
const portRaw = getArgValue4(argv, "--port") ?? process5.env.CANARY_LOCAL_PORT;
|
|
1995
|
+
const tunnelUrl = getArgValue4(argv, "--tunnel-url");
|
|
1996
|
+
const title = getArgValue4(argv, "--title");
|
|
1997
|
+
const featureSpec = getArgValue4(argv, "--feature");
|
|
1998
|
+
const startUrl = getArgValue4(argv, "--start-url");
|
|
1999
|
+
if (!tunnelUrl && !portRaw) {
|
|
2000
|
+
console.error("Missing --port or --tunnel-url");
|
|
2001
|
+
process5.exit(1);
|
|
2002
|
+
}
|
|
2003
|
+
let publicUrl = tunnelUrl;
|
|
2004
|
+
let ws = null;
|
|
2005
|
+
if (!publicUrl && portRaw) {
|
|
2006
|
+
const port = Number(portRaw);
|
|
2007
|
+
if (Number.isNaN(port) || port <= 0) {
|
|
2008
|
+
console.error("Invalid --port value");
|
|
2009
|
+
process5.exit(1);
|
|
2010
|
+
}
|
|
2011
|
+
const tunnel = await createTunnel({ apiUrl, token, port });
|
|
2012
|
+
publicUrl = tunnel.publicUrl;
|
|
2013
|
+
ws = connectTunnel({
|
|
2014
|
+
apiUrl,
|
|
2015
|
+
tunnelId: tunnel.tunnelId,
|
|
2016
|
+
token: tunnel.token,
|
|
2017
|
+
port,
|
|
2018
|
+
onReady: () => {
|
|
2019
|
+
console.log(`Tunnel connected: ${publicUrl ?? tunnel.tunnelId}`);
|
|
2020
|
+
}
|
|
2021
|
+
});
|
|
2022
|
+
}
|
|
2023
|
+
if (!publicUrl) {
|
|
2024
|
+
console.error("Failed to resolve tunnel URL");
|
|
2025
|
+
process5.exit(1);
|
|
2026
|
+
}
|
|
2027
|
+
const run2 = await createLocalRun({
|
|
2028
|
+
apiUrl,
|
|
2029
|
+
token,
|
|
2030
|
+
title,
|
|
2031
|
+
featureSpec,
|
|
2032
|
+
startUrl,
|
|
2033
|
+
tunnelUrl: publicUrl
|
|
2034
|
+
});
|
|
2035
|
+
console.log(`Local test queued: ${run2.runId}`);
|
|
2036
|
+
if (run2.watchUrl) {
|
|
2037
|
+
console.log(`Watch: ${run2.watchUrl}`);
|
|
2038
|
+
}
|
|
2039
|
+
if (ws) {
|
|
2040
|
+
console.log("Tunnel active. Press Ctrl+C to stop.");
|
|
2041
|
+
process5.on("SIGINT", () => {
|
|
2042
|
+
ws?.close();
|
|
2043
|
+
process5.exit(0);
|
|
2044
|
+
});
|
|
2045
|
+
await new Promise(() => void 0);
|
|
1845
2046
|
}
|
|
1846
|
-
const tested = Array.isArray(input.summary.testedItems) ? input.summary.testedItems : [];
|
|
1847
|
-
const status = input.summary.status ?? input.run?.status ?? "unknown";
|
|
1848
|
-
const issues = status === "issues_found" ? input.summary.notes ? [input.summary.notes] : ["Issues reported."] : [];
|
|
1849
|
-
return {
|
|
1850
|
-
runId: input.run?.id,
|
|
1851
|
-
status,
|
|
1852
|
-
summary: input.summary.summary ?? "Run completed.",
|
|
1853
|
-
testedItems: tested,
|
|
1854
|
-
issues,
|
|
1855
|
-
notes: input.summary.notes ?? null
|
|
1856
|
-
};
|
|
1857
2047
|
}
|
|
1858
2048
|
|
|
1859
2049
|
// src/remote-test.ts
|
|
1860
|
-
|
|
1861
|
-
import
|
|
2050
|
+
init_auth();
|
|
2051
|
+
import process6 from "process";
|
|
2052
|
+
import { createParser } from "eventsource-parser";
|
|
1862
2053
|
function getArgValue5(argv, key) {
|
|
1863
2054
|
const index = argv.indexOf(key);
|
|
1864
2055
|
if (index === -1 || index >= argv.length - 1) return void 0;
|
|
@@ -1868,8 +2059,8 @@ function hasFlag(argv, ...flags) {
|
|
|
1868
2059
|
return flags.some((flag) => argv.includes(flag));
|
|
1869
2060
|
}
|
|
1870
2061
|
async function runRemoteTest(argv) {
|
|
1871
|
-
const apiUrl = getArgValue5(argv, "--api-url") ??
|
|
1872
|
-
const token = getArgValue5(argv, "--token") ??
|
|
2062
|
+
const apiUrl = getArgValue5(argv, "--api-url") ?? process6.env.CANARY_API_URL ?? "https://api.trycanary.ai";
|
|
2063
|
+
const token = getArgValue5(argv, "--token") ?? process6.env.CANARY_API_TOKEN ?? await readStoredToken();
|
|
1873
2064
|
const tag = getArgValue5(argv, "--tag");
|
|
1874
2065
|
const namePattern = getArgValue5(argv, "--name-pattern");
|
|
1875
2066
|
const verbose = hasFlag(argv, "--verbose", "-v");
|
|
@@ -1881,7 +2072,7 @@ async function runRemoteTest(argv) {
|
|
|
1881
2072
|
console.error("");
|
|
1882
2073
|
console.error("Or create an API key in Settings > API Keys and pass it:");
|
|
1883
2074
|
console.error(" canary test --remote --token cnry_...");
|
|
1884
|
-
|
|
2075
|
+
process6.exit(1);
|
|
1885
2076
|
}
|
|
1886
2077
|
console.log("Starting remote workflow tests...");
|
|
1887
2078
|
if (tag) console.log(` Filtering by tag: ${tag}`);
|
|
@@ -1902,18 +2093,18 @@ async function runRemoteTest(argv) {
|
|
|
1902
2093
|
});
|
|
1903
2094
|
} catch (err) {
|
|
1904
2095
|
console.error(`Failed to connect to API: ${err}`);
|
|
1905
|
-
|
|
2096
|
+
process6.exit(1);
|
|
1906
2097
|
}
|
|
1907
2098
|
if (!triggerRes.ok) {
|
|
1908
2099
|
const errorText = await triggerRes.text();
|
|
1909
2100
|
console.error(`Failed to start tests: ${triggerRes.status}`);
|
|
1910
2101
|
console.error(errorText);
|
|
1911
|
-
|
|
2102
|
+
process6.exit(1);
|
|
1912
2103
|
}
|
|
1913
2104
|
const triggerData = await triggerRes.json();
|
|
1914
2105
|
if (!triggerData.ok || !triggerData.suiteId) {
|
|
1915
2106
|
console.error(`Failed to start tests: ${triggerData.error ?? "Unknown error"}`);
|
|
1916
|
-
|
|
2107
|
+
process6.exit(1);
|
|
1917
2108
|
}
|
|
1918
2109
|
const { suiteId, jobId } = triggerData;
|
|
1919
2110
|
if (verbose) {
|
|
@@ -1932,11 +2123,11 @@ async function runRemoteTest(argv) {
|
|
|
1932
2123
|
});
|
|
1933
2124
|
} catch (err) {
|
|
1934
2125
|
console.error(`Failed to connect to event stream: ${err}`);
|
|
1935
|
-
|
|
2126
|
+
process6.exit(1);
|
|
1936
2127
|
}
|
|
1937
2128
|
if (!streamRes.ok || !streamRes.body) {
|
|
1938
2129
|
console.error(`Failed to connect to event stream: ${streamRes.status}`);
|
|
1939
|
-
|
|
2130
|
+
process6.exit(1);
|
|
1940
2131
|
}
|
|
1941
2132
|
let exitCode = 0;
|
|
1942
2133
|
let hasCompleted = false;
|
|
@@ -1945,7 +2136,7 @@ async function runRemoteTest(argv) {
|
|
|
1945
2136
|
let completedWorkflows = 0;
|
|
1946
2137
|
let failedWorkflows = 0;
|
|
1947
2138
|
let successfulWorkflows = 0;
|
|
1948
|
-
const parser =
|
|
2139
|
+
const parser = createParser({
|
|
1949
2140
|
onEvent: (event) => {
|
|
1950
2141
|
if (!event.data) return;
|
|
1951
2142
|
try {
|
|
@@ -2010,7 +2201,7 @@ async function runRemoteTest(argv) {
|
|
|
2010
2201
|
console.log("\u2500".repeat(50));
|
|
2011
2202
|
if (totalWorkflows === 0) {
|
|
2012
2203
|
console.log("No workflows found matching the filter criteria.");
|
|
2013
|
-
|
|
2204
|
+
process6.exit(0);
|
|
2014
2205
|
}
|
|
2015
2206
|
const passRate = totalWorkflows > 0 ? Math.round(successfulWorkflows / totalWorkflows * 100) : 0;
|
|
2016
2207
|
if (failedWorkflows > 0) {
|
|
@@ -2023,139 +2214,12 @@ async function runRemoteTest(argv) {
|
|
|
2023
2214
|
if (waitingWorkflows > 0) {
|
|
2024
2215
|
console.log(`Note: ${waitingWorkflows} workflow(s) are still waiting (scheduled for later)`);
|
|
2025
2216
|
}
|
|
2026
|
-
|
|
2027
|
-
}
|
|
2028
|
-
|
|
2029
|
-
// src/local-browser/index.ts
|
|
2030
|
-
import process8 from "process";
|
|
2031
|
-
var DEFAULT_API_URL2 = "https://api.trycanary.ai";
|
|
2032
|
-
function parseArgs(args) {
|
|
2033
|
-
const options = {
|
|
2034
|
-
mode: "playwright",
|
|
2035
|
-
headless: true,
|
|
2036
|
-
apiUrl: process8.env.CANARY_API_URL ?? DEFAULT_API_URL2
|
|
2037
|
-
};
|
|
2038
|
-
for (let i = 0; i < args.length; i++) {
|
|
2039
|
-
const arg = args[i];
|
|
2040
|
-
const nextArg = args[i + 1];
|
|
2041
|
-
switch (arg) {
|
|
2042
|
-
case "--mode":
|
|
2043
|
-
if (nextArg === "playwright" || nextArg === "cdp") {
|
|
2044
|
-
options.mode = nextArg;
|
|
2045
|
-
i++;
|
|
2046
|
-
}
|
|
2047
|
-
break;
|
|
2048
|
-
case "--cdp-url":
|
|
2049
|
-
options.cdpUrl = nextArg;
|
|
2050
|
-
options.mode = "cdp";
|
|
2051
|
-
i++;
|
|
2052
|
-
break;
|
|
2053
|
-
case "--headless":
|
|
2054
|
-
options.headless = true;
|
|
2055
|
-
break;
|
|
2056
|
-
case "--no-headless":
|
|
2057
|
-
options.headless = false;
|
|
2058
|
-
break;
|
|
2059
|
-
case "--storage-state":
|
|
2060
|
-
options.storageStatePath = nextArg;
|
|
2061
|
-
i++;
|
|
2062
|
-
break;
|
|
2063
|
-
case "--api-url":
|
|
2064
|
-
options.apiUrl = nextArg;
|
|
2065
|
-
i++;
|
|
2066
|
-
break;
|
|
2067
|
-
case "--instructions":
|
|
2068
|
-
options.instructions = nextArg;
|
|
2069
|
-
i++;
|
|
2070
|
-
break;
|
|
2071
|
-
}
|
|
2072
|
-
}
|
|
2073
|
-
return options;
|
|
2074
|
-
}
|
|
2075
|
-
async function resolveToken2() {
|
|
2076
|
-
const token = process8.env.CANARY_API_TOKEN ?? await readStoredToken();
|
|
2077
|
-
if (!token) {
|
|
2078
|
-
throw new Error("Missing token. Run `canary login` first or set CANARY_API_TOKEN.");
|
|
2079
|
-
}
|
|
2080
|
-
return token;
|
|
2081
|
-
}
|
|
2082
|
-
async function createSession(apiUrl, token, options) {
|
|
2083
|
-
const response = await fetch(`${apiUrl}/local-browser/sessions`, {
|
|
2084
|
-
method: "POST",
|
|
2085
|
-
headers: {
|
|
2086
|
-
"Content-Type": "application/json",
|
|
2087
|
-
Authorization: `Bearer ${token}`
|
|
2088
|
-
},
|
|
2089
|
-
body: JSON.stringify({
|
|
2090
|
-
browserMode: options.mode,
|
|
2091
|
-
instructions: options.instructions ?? null
|
|
2092
|
-
})
|
|
2093
|
-
});
|
|
2094
|
-
if (!response.ok) {
|
|
2095
|
-
const text = await response.text();
|
|
2096
|
-
throw new Error(`Failed to create session: ${response.status} ${text}`);
|
|
2097
|
-
}
|
|
2098
|
-
return response.json();
|
|
2099
|
-
}
|
|
2100
|
-
async function runLocalBrowser(args) {
|
|
2101
|
-
const options = parseArgs(args);
|
|
2102
|
-
console.log("Starting local browser...");
|
|
2103
|
-
console.log(` Mode: ${options.mode}`);
|
|
2104
|
-
if (options.cdpUrl) {
|
|
2105
|
-
console.log(` CDP URL: ${options.cdpUrl}`);
|
|
2106
|
-
}
|
|
2107
|
-
console.log(` Headless: ${options.headless}`);
|
|
2108
|
-
console.log(` API URL: ${options.apiUrl}`);
|
|
2109
|
-
console.log();
|
|
2110
|
-
const token = await resolveToken2();
|
|
2111
|
-
console.log("Creating session with cloud API...");
|
|
2112
|
-
const session = await createSession(options.apiUrl, token, options);
|
|
2113
|
-
if (!session.ok) {
|
|
2114
|
-
throw new Error(`Failed to create session: ${session.error}`);
|
|
2115
|
-
}
|
|
2116
|
-
console.log(`Session created: ${session.sessionId}`);
|
|
2117
|
-
console.log(`Expires at: ${session.expiresAt}`);
|
|
2118
|
-
console.log();
|
|
2119
|
-
const host = new LocalBrowserHost({
|
|
2120
|
-
apiUrl: options.apiUrl,
|
|
2121
|
-
wsToken: session.wsToken,
|
|
2122
|
-
sessionId: session.sessionId,
|
|
2123
|
-
browserMode: options.mode,
|
|
2124
|
-
cdpUrl: options.cdpUrl,
|
|
2125
|
-
headless: options.headless,
|
|
2126
|
-
storageStatePath: options.storageStatePath,
|
|
2127
|
-
onLog: (level, message, data) => {
|
|
2128
|
-
const prefix = `[${level.toUpperCase()}]`;
|
|
2129
|
-
if (data) {
|
|
2130
|
-
console.log(prefix, message, data);
|
|
2131
|
-
} else {
|
|
2132
|
-
console.log(prefix, message);
|
|
2133
|
-
}
|
|
2134
|
-
}
|
|
2135
|
-
});
|
|
2136
|
-
const shutdown = async () => {
|
|
2137
|
-
console.log("\nShutting down...");
|
|
2138
|
-
await host.stop();
|
|
2139
|
-
process8.exit(0);
|
|
2140
|
-
};
|
|
2141
|
-
process8.on("SIGINT", shutdown);
|
|
2142
|
-
process8.on("SIGTERM", shutdown);
|
|
2143
|
-
try {
|
|
2144
|
-
await host.start();
|
|
2145
|
-
console.log();
|
|
2146
|
-
console.log("Local browser is ready and connected to cloud.");
|
|
2147
|
-
console.log("Press Ctrl+C to stop.");
|
|
2148
|
-
console.log();
|
|
2149
|
-
await new Promise(() => {
|
|
2150
|
-
});
|
|
2151
|
-
} catch (error) {
|
|
2152
|
-
console.error("Failed to start local browser:", error);
|
|
2153
|
-
await host.stop();
|
|
2154
|
-
process8.exit(1);
|
|
2155
|
-
}
|
|
2217
|
+
process6.exit(exitCode);
|
|
2156
2218
|
}
|
|
2157
2219
|
|
|
2158
2220
|
// src/index.ts
|
|
2221
|
+
var loadMcp = () => Promise.resolve().then(() => (init_mcp(), mcp_exports)).then((m) => m.runMcp);
|
|
2222
|
+
var loadLocalBrowser = () => Promise.resolve().then(() => (init_local_browser(), local_browser_exports)).then((m) => m.runLocalBrowser);
|
|
2159
2223
|
var canary = { run };
|
|
2160
2224
|
var baseDir = typeof __dirname !== "undefined" ? __dirname : path5.dirname(fileURLToPath2(import.meta.url));
|
|
2161
2225
|
var preloadPath = path5.join(baseDir, "runner", "preload.js");
|
|
@@ -2244,7 +2308,8 @@ async function main(argv) {
|
|
|
2244
2308
|
return;
|
|
2245
2309
|
}
|
|
2246
2310
|
if (command === "mcp") {
|
|
2247
|
-
await
|
|
2311
|
+
const runMcp2 = await loadMcp();
|
|
2312
|
+
await runMcp2(rest);
|
|
2248
2313
|
return;
|
|
2249
2314
|
}
|
|
2250
2315
|
if (command === "tunnel") {
|
|
@@ -2256,7 +2321,8 @@ async function main(argv) {
|
|
|
2256
2321
|
return;
|
|
2257
2322
|
}
|
|
2258
2323
|
if (command === "browser") {
|
|
2259
|
-
await
|
|
2324
|
+
const runLocalBrowser2 = await loadLocalBrowser();
|
|
2325
|
+
await runLocalBrowser2(rest);
|
|
2260
2326
|
return;
|
|
2261
2327
|
}
|
|
2262
2328
|
console.log(`Unknown command "${command}".`);
|