@canaryai/cli 0.1.5 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -1
- package/dist/bin.js +1 -2121
- package/dist/bin.js.map +1 -1
- package/dist/chunk-DGUM43GV.js +11 -0
- package/dist/chunk-DGUM43GV.js.map +1 -0
- package/dist/chunk-G2X3H7AM.js +671 -0
- package/dist/chunk-G2X3H7AM.js.map +1 -0
- package/dist/chunk-NRMZHITS.js +334 -0
- package/dist/chunk-NRMZHITS.js.map +1 -0
- package/dist/chunk-ROTCL5WO.js +1081 -0
- package/dist/chunk-ROTCL5WO.js.map +1 -0
- package/dist/chunk-SGNA6N2N.js +36 -0
- package/dist/chunk-SGNA6N2N.js.map +1 -0
- package/dist/feature-flag-43WAHIUZ.js +213 -0
- package/dist/feature-flag-43WAHIUZ.js.map +1 -0
- package/dist/index.js +544 -1674
- package/dist/index.js.map +1 -1
- package/dist/local-browser-REU2RIYX.js +140 -0
- package/dist/local-browser-REU2RIYX.js.map +1 -0
- package/dist/mcp-5N5Z343W.js +384 -0
- package/dist/mcp-5N5Z343W.js.map +1 -0
- package/dist/psql-7AEFGJWI.js +123 -0
- package/dist/psql-7AEFGJWI.js.map +1 -0
- package/dist/redis-BXYEPX4T.js +129 -0
- package/dist/redis-BXYEPX4T.js.map +1 -0
- package/dist/runner/preload.js +13 -1079
- package/dist/runner/preload.js.map +1 -1
- package/dist/test.js +14 -1078
- package/dist/test.js.map +1 -1
- package/package.json +3 -4
- package/dist/bin.d.ts +0 -2
package/dist/index.js
CHANGED
|
@@ -1,1574 +1,25 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
// src/auth.ts
|
|
19
|
-
import fs3 from "fs/promises";
|
|
20
|
-
import os2 from "os";
|
|
21
|
-
import path3 from "path";
|
|
22
|
-
async function readStoredToken() {
|
|
23
|
-
try {
|
|
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;
|
|
28
|
-
} catch {
|
|
29
|
-
return null;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
var init_auth = __esm({
|
|
33
|
-
"src/auth.ts"() {
|
|
34
|
-
"use strict";
|
|
35
|
-
}
|
|
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];
|
|
44
|
-
}
|
|
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);
|
|
55
|
-
}
|
|
56
|
-
if (!token) {
|
|
57
|
-
console.error("Missing token. Run `canary login` first or set CANARY_API_TOKEN.");
|
|
58
|
-
process2.exit(1);
|
|
59
|
-
}
|
|
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}`);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
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);
|
|
91
|
-
}
|
|
92
|
-
return { runId: json.runId, watchUrl: json.watchUrl };
|
|
93
|
-
}
|
|
94
|
-
var init_local_run = __esm({
|
|
95
|
-
"src/local-run.ts"() {
|
|
96
|
-
"use strict";
|
|
97
|
-
init_auth();
|
|
98
|
-
}
|
|
99
|
-
});
|
|
100
|
-
|
|
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];
|
|
109
|
-
}
|
|
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);
|
|
126
|
-
}
|
|
127
|
-
const port = Number(portRaw);
|
|
128
|
-
if (Number.isNaN(port) || port <= 0) {
|
|
129
|
-
console.error("Invalid --port value");
|
|
130
|
-
process3.exit(1);
|
|
131
|
-
}
|
|
132
|
-
if (!token) {
|
|
133
|
-
console.error("Missing token. Run `canary login` first or set CANARY_API_TOKEN.");
|
|
134
|
-
process3.exit(1);
|
|
135
|
-
}
|
|
136
|
-
const maxReconnectAttempts = 10;
|
|
137
|
-
const baseReconnectDelayMs = 1e3;
|
|
138
|
-
let reconnectAttempts = 0;
|
|
139
|
-
const connect = async () => {
|
|
140
|
-
try {
|
|
141
|
-
const data = await createTunnel({
|
|
142
|
-
apiUrl,
|
|
143
|
-
token,
|
|
144
|
-
port
|
|
145
|
-
});
|
|
146
|
-
console.log(`Tunnel connected: ${data.publicUrl ?? data.tunnelId}`);
|
|
147
|
-
if (data.publicUrl) {
|
|
148
|
-
console.log(`Public URL: ${data.publicUrl}`);
|
|
149
|
-
console.log("");
|
|
150
|
-
console.log("To use this tunnel for sandbox agent callbacks, add to apps/api/.env:");
|
|
151
|
-
console.log(` SANDBOX_AGENT_API_URL=${data.publicUrl}`);
|
|
152
|
-
console.log("");
|
|
153
|
-
}
|
|
154
|
-
const ws = connectTunnel({
|
|
155
|
-
apiUrl,
|
|
156
|
-
tunnelId: data.tunnelId,
|
|
157
|
-
token: data.token,
|
|
158
|
-
port,
|
|
159
|
-
onReady: () => {
|
|
160
|
-
reconnectAttempts = 0;
|
|
161
|
-
}
|
|
162
|
-
});
|
|
163
|
-
return new Promise((resolve, reject) => {
|
|
164
|
-
ws.onclose = (event) => {
|
|
165
|
-
console.log(`Tunnel closed (code: ${event.code})`);
|
|
166
|
-
if (reconnectAttempts < maxReconnectAttempts) {
|
|
167
|
-
const delay = Math.min(baseReconnectDelayMs * Math.pow(2, reconnectAttempts), 3e4);
|
|
168
|
-
reconnectAttempts++;
|
|
169
|
-
console.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempts}/${maxReconnectAttempts})...`);
|
|
170
|
-
setTimeout(() => {
|
|
171
|
-
connect().then(resolve).catch(reject);
|
|
172
|
-
}, delay);
|
|
173
|
-
} else {
|
|
174
|
-
console.error("Max reconnection attempts reached. Exiting.");
|
|
175
|
-
process3.exit(1);
|
|
176
|
-
}
|
|
177
|
-
};
|
|
178
|
-
ws.onerror = (event) => {
|
|
179
|
-
console.error("Tunnel error:", event);
|
|
180
|
-
};
|
|
181
|
-
});
|
|
182
|
-
} catch (error) {
|
|
183
|
-
if (reconnectAttempts < maxReconnectAttempts) {
|
|
184
|
-
const delay = Math.min(baseReconnectDelayMs * Math.pow(2, reconnectAttempts), 3e4);
|
|
185
|
-
reconnectAttempts++;
|
|
186
|
-
console.error(`Failed to create tunnel: ${error}`);
|
|
187
|
-
console.log(`Retrying in ${delay}ms (attempt ${reconnectAttempts}/${maxReconnectAttempts})...`);
|
|
188
|
-
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
189
|
-
return connect();
|
|
190
|
-
} else {
|
|
191
|
-
console.error("Max reconnection attempts reached. Exiting.");
|
|
192
|
-
process3.exit(1);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
};
|
|
196
|
-
await connect();
|
|
197
|
-
}
|
|
198
|
-
async function createTunnel(input) {
|
|
199
|
-
const response = await fetch(`${input.apiUrl}/local-tests/tunnels`, {
|
|
200
|
-
method: "POST",
|
|
201
|
-
headers: {
|
|
202
|
-
"content-type": "application/json",
|
|
203
|
-
authorization: `Bearer ${input.token}`
|
|
204
|
-
},
|
|
205
|
-
body: JSON.stringify({
|
|
206
|
-
requestedPort: input.port,
|
|
207
|
-
clientFingerprint: createFingerprint()
|
|
208
|
-
})
|
|
209
|
-
});
|
|
210
|
-
const data = await response.json();
|
|
211
|
-
if (!response.ok || !data.ok || !data.tunnelId || !data.token) {
|
|
212
|
-
throw new Error(data.error ?? response.statusText);
|
|
213
|
-
}
|
|
214
|
-
return { tunnelId: data.tunnelId, publicUrl: data.publicUrl, token: data.token };
|
|
215
|
-
}
|
|
216
|
-
function connectTunnel(input) {
|
|
217
|
-
const wsUrl = toWebSocketUrl(
|
|
218
|
-
`${input.apiUrl}/local-tests/tunnels/${input.tunnelId}/connect?token=${input.token}`
|
|
219
|
-
);
|
|
220
|
-
const ws = new WebSocket(wsUrl);
|
|
221
|
-
const wsConnections = /* @__PURE__ */ new Map();
|
|
222
|
-
const wsQueues = /* @__PURE__ */ new Map();
|
|
223
|
-
ws.onopen = () => {
|
|
224
|
-
input.onReady?.();
|
|
225
|
-
};
|
|
226
|
-
ws.onerror = (event) => {
|
|
227
|
-
console.error("Tunnel error", event);
|
|
228
|
-
};
|
|
229
|
-
ws.onclose = () => {
|
|
230
|
-
console.log("Tunnel closed");
|
|
231
|
-
};
|
|
232
|
-
ws.onmessage = async (event) => {
|
|
233
|
-
try {
|
|
234
|
-
const raw = typeof event.data === "string" ? event.data : Buffer.from(event.data).toString();
|
|
235
|
-
const payload = JSON.parse(raw);
|
|
236
|
-
if (payload.type === "http_request") {
|
|
237
|
-
const request = payload;
|
|
238
|
-
const targetUrl = `http://localhost:${input.port}${request.path.startsWith("/") ? request.path : `/${request.path}`}`;
|
|
239
|
-
const body = request.bodyBase64 ? Buffer.from(request.bodyBase64, "base64") : void 0;
|
|
240
|
-
const headers = { ...request.headers };
|
|
241
|
-
delete headers.host;
|
|
242
|
-
delete headers["content-length"];
|
|
243
|
-
try {
|
|
244
|
-
const res = await fetch(targetUrl, {
|
|
245
|
-
method: request.method,
|
|
246
|
-
headers,
|
|
247
|
-
body: body ?? void 0
|
|
248
|
-
});
|
|
249
|
-
const resBody = await res.arrayBuffer();
|
|
250
|
-
const resHeaders = Object.fromEntries(res.headers.entries());
|
|
251
|
-
delete resHeaders["set-cookie"];
|
|
252
|
-
const getSetCookie = res.headers.getSetCookie;
|
|
253
|
-
const setCookieValues = typeof getSetCookie === "function" ? getSetCookie.call(res.headers) : [];
|
|
254
|
-
const fallbackSetCookie = res.headers.get("set-cookie");
|
|
255
|
-
if (setCookieValues.length === 0 && fallbackSetCookie) {
|
|
256
|
-
setCookieValues.push(fallbackSetCookie);
|
|
257
|
-
}
|
|
258
|
-
if (setCookieValues.length > 0) {
|
|
259
|
-
resHeaders["set-cookie"] = setCookieValues;
|
|
260
|
-
}
|
|
261
|
-
const responsePayload = {
|
|
262
|
-
type: "http_response",
|
|
263
|
-
id: request.id,
|
|
264
|
-
status: res.status,
|
|
265
|
-
headers: resHeaders,
|
|
266
|
-
bodyBase64: resBody.byteLength ? Buffer.from(resBody).toString("base64") : null
|
|
267
|
-
};
|
|
268
|
-
ws.send(JSON.stringify(responsePayload));
|
|
269
|
-
} catch (error) {
|
|
270
|
-
const responsePayload = {
|
|
271
|
-
type: "http_response",
|
|
272
|
-
id: request.id,
|
|
273
|
-
status: 502,
|
|
274
|
-
headers: { "content-type": "text/plain" },
|
|
275
|
-
bodyBase64: Buffer.from(`Tunnel error: ${String(error)}`).toString("base64")
|
|
276
|
-
};
|
|
277
|
-
ws.send(JSON.stringify(responsePayload));
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
if (payload.type === "ws_open") {
|
|
281
|
-
const request = payload;
|
|
282
|
-
const targetUrl = `ws://localhost:${input.port}${request.path.startsWith("/") ? request.path : `/${request.path}`}`;
|
|
283
|
-
const protocolsHeader = request.headers["sec-websocket-protocol"] ?? request.headers["Sec-WebSocket-Protocol"];
|
|
284
|
-
const protocols = protocolsHeader ? protocolsHeader.split(",").map((value) => value.trim()).filter(Boolean) : void 0;
|
|
285
|
-
const localWs = new WebSocket(targetUrl, protocols);
|
|
286
|
-
wsConnections.set(request.id, localWs);
|
|
287
|
-
localWs.onopen = () => {
|
|
288
|
-
ws.send(JSON.stringify({ type: "ws_ready", id: request.id }));
|
|
289
|
-
const queued = wsQueues.get(request.id);
|
|
290
|
-
if (queued) {
|
|
291
|
-
for (const message of queued) {
|
|
292
|
-
ws.send(JSON.stringify(message));
|
|
293
|
-
}
|
|
294
|
-
wsQueues.delete(request.id);
|
|
295
|
-
}
|
|
296
|
-
};
|
|
297
|
-
localWs.onmessage = (event2) => {
|
|
298
|
-
const data = typeof event2.data === "string" ? Buffer.from(event2.data) : Buffer.from(event2.data);
|
|
299
|
-
const response = {
|
|
300
|
-
type: "ws_message",
|
|
301
|
-
id: request.id,
|
|
302
|
-
dataBase64: data.toString("base64"),
|
|
303
|
-
isBinary: typeof event2.data !== "string"
|
|
304
|
-
};
|
|
305
|
-
ws.send(JSON.stringify(response));
|
|
306
|
-
};
|
|
307
|
-
localWs.onclose = (event2) => {
|
|
308
|
-
wsConnections.delete(request.id);
|
|
309
|
-
const response = {
|
|
310
|
-
type: "ws_close",
|
|
311
|
-
id: request.id,
|
|
312
|
-
code: event2.code,
|
|
313
|
-
reason: event2.reason
|
|
314
|
-
};
|
|
315
|
-
ws.send(JSON.stringify(response));
|
|
316
|
-
};
|
|
317
|
-
localWs.onerror = () => {
|
|
318
|
-
wsConnections.delete(request.id);
|
|
319
|
-
const response = {
|
|
320
|
-
type: "ws_close",
|
|
321
|
-
id: request.id,
|
|
322
|
-
code: 1011,
|
|
323
|
-
reason: "local_ws_error"
|
|
324
|
-
};
|
|
325
|
-
ws.send(JSON.stringify(response));
|
|
326
|
-
};
|
|
327
|
-
}
|
|
328
|
-
if (payload.type === "ws_message") {
|
|
329
|
-
const message = payload;
|
|
330
|
-
const localWs = wsConnections.get(message.id);
|
|
331
|
-
const data = Buffer.from(message.dataBase64, "base64");
|
|
332
|
-
if (!localWs || localWs.readyState !== WebSocket.OPEN) {
|
|
333
|
-
const queued = wsQueues.get(message.id) ?? [];
|
|
334
|
-
queued.push(message);
|
|
335
|
-
wsQueues.set(message.id, queued);
|
|
336
|
-
return;
|
|
337
|
-
}
|
|
338
|
-
if (message.isBinary) {
|
|
339
|
-
localWs.send(data);
|
|
340
|
-
} else {
|
|
341
|
-
localWs.send(data.toString());
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
if (payload.type === "ws_close") {
|
|
345
|
-
const message = payload;
|
|
346
|
-
const localWs = wsConnections.get(message.id);
|
|
347
|
-
if (!localWs) {
|
|
348
|
-
const queued = wsQueues.get(message.id) ?? [];
|
|
349
|
-
queued.push(message);
|
|
350
|
-
wsQueues.set(message.id, queued);
|
|
351
|
-
return;
|
|
352
|
-
}
|
|
353
|
-
localWs.close(message.code ?? 1e3, message.reason ?? "");
|
|
354
|
-
wsConnections.delete(message.id);
|
|
355
|
-
}
|
|
356
|
-
if (payload.type === "health_ping") {
|
|
357
|
-
ws.send(JSON.stringify({ type: "health_pong" }));
|
|
358
|
-
}
|
|
359
|
-
} catch (error) {
|
|
360
|
-
console.error("Tunnel message error", error);
|
|
361
|
-
}
|
|
362
|
-
};
|
|
363
|
-
return ws;
|
|
364
|
-
}
|
|
365
|
-
var init_tunnel = __esm({
|
|
366
|
-
"src/tunnel.ts"() {
|
|
367
|
-
"use strict";
|
|
368
|
-
init_auth();
|
|
369
|
-
}
|
|
370
|
-
});
|
|
371
|
-
|
|
372
|
-
// src/local-browser/host.ts
|
|
373
|
-
import { chromium } from "playwright";
|
|
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;
|
|
395
|
-
}
|
|
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
|
-
}
|
|
403
|
-
}
|
|
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");
|
|
415
|
-
}
|
|
416
|
-
async stop() {
|
|
417
|
-
this.isShuttingDown = true;
|
|
418
|
-
this.log("info", "Stopping local browser host");
|
|
419
|
-
this.stopHeartbeat();
|
|
420
|
-
if (this.ws) {
|
|
421
|
-
try {
|
|
422
|
-
this.ws.close(1e3, "Shutdown");
|
|
423
|
-
} catch {
|
|
424
|
-
}
|
|
425
|
-
this.ws = null;
|
|
426
|
-
}
|
|
427
|
-
if (this.context) {
|
|
428
|
-
try {
|
|
429
|
-
await this.context.close();
|
|
430
|
-
} catch {
|
|
431
|
-
}
|
|
432
|
-
this.context = null;
|
|
433
|
-
}
|
|
434
|
-
if (this.browser) {
|
|
435
|
-
try {
|
|
436
|
-
await this.browser.close();
|
|
437
|
-
} catch {
|
|
438
|
-
}
|
|
439
|
-
this.browser = null;
|
|
440
|
-
}
|
|
441
|
-
this.page = null;
|
|
442
|
-
this.log("info", "Local browser host stopped");
|
|
443
|
-
}
|
|
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) {
|
|
570
|
-
try {
|
|
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 });
|
|
589
|
-
}
|
|
590
|
-
}
|
|
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",
|
|
631
|
-
id: crypto.randomUUID(),
|
|
632
|
-
timestamp: Date.now(),
|
|
633
|
-
event,
|
|
634
|
-
browserMode: this.options.browserMode,
|
|
635
|
-
error
|
|
636
|
-
};
|
|
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;
|
|
771
|
-
}
|
|
772
|
-
resolveRef(ref) {
|
|
773
|
-
return this.getPage().locator(`aria-ref=${ref}`);
|
|
774
|
-
}
|
|
775
|
-
async connect(_options) {
|
|
776
|
-
return;
|
|
777
|
-
}
|
|
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
|
-
}
|
|
854
|
-
}
|
|
855
|
-
}
|
|
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 });
|
|
876
|
-
}
|
|
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");
|
|
891
|
-
}
|
|
892
|
-
}
|
|
893
|
-
async pressKey(key, _opts) {
|
|
894
|
-
await this.getPage().keyboard.press(key);
|
|
895
|
-
}
|
|
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 });
|
|
917
|
-
}
|
|
918
|
-
}
|
|
919
|
-
}
|
|
920
|
-
async selectOption(ref, value, _elementDesc, opts) {
|
|
921
|
-
await this.resolveRef(ref).selectOption(value, { timeout: opts?.timeoutMs ?? 3e4 });
|
|
922
|
-
}
|
|
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
|
-
}
|
|
937
|
-
}
|
|
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;
|
|
945
|
-
}
|
|
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
|
-
}
|
|
1041
|
-
};
|
|
1042
|
-
}
|
|
1043
|
-
});
|
|
1044
|
-
|
|
1045
|
-
// src/mcp.ts
|
|
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";
|
|
1055
|
-
function resolveApiUrl(input) {
|
|
1056
|
-
return input ?? process7.env.CANARY_API_URL ?? DEFAULT_API_URL;
|
|
1057
|
-
}
|
|
1058
|
-
async function resolveToken() {
|
|
1059
|
-
const token = process7.env.CANARY_API_TOKEN ?? await readStoredToken();
|
|
1060
|
-
if (!token) {
|
|
1061
|
-
throw new Error("Missing token. Run `canary login` first or set CANARY_API_TOKEN.");
|
|
1062
|
-
}
|
|
1063
|
-
return token;
|
|
1064
|
-
}
|
|
1065
|
-
function toolText(text) {
|
|
1066
|
-
return { content: [{ type: "text", text }] };
|
|
1067
|
-
}
|
|
1068
|
-
function toolJson(data) {
|
|
1069
|
-
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
1070
|
-
}
|
|
1071
|
-
async function runMcp(argv) {
|
|
1072
|
-
const server = new Server(
|
|
1073
|
-
{ name: "canary-cli", version: "0.1.0" },
|
|
1074
|
-
{ capabilities: { tools: {} } }
|
|
1075
|
-
);
|
|
1076
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
1077
|
-
tools: [
|
|
1078
|
-
{
|
|
1079
|
-
name: "local_run_tests",
|
|
1080
|
-
description: "Start an async local test run. A tunnel is opened automatically. Returns runId and watchUrl.",
|
|
1081
|
-
inputSchema: {
|
|
1082
|
-
type: "object",
|
|
1083
|
-
properties: {
|
|
1084
|
-
port: { type: "number" },
|
|
1085
|
-
instructions: { type: "string" },
|
|
1086
|
-
title: { type: "string" }
|
|
1087
|
-
},
|
|
1088
|
-
required: ["port", "instructions"]
|
|
1089
|
-
}
|
|
1090
|
-
},
|
|
1091
|
-
{
|
|
1092
|
-
name: "local_wait_for_results",
|
|
1093
|
-
description: "Wait for a local test run to complete. Streams until completion and returns a compact report.",
|
|
1094
|
-
inputSchema: {
|
|
1095
|
-
type: "object",
|
|
1096
|
-
properties: {
|
|
1097
|
-
runId: { type: "string" }
|
|
1098
|
-
},
|
|
1099
|
-
required: ["runId"]
|
|
1100
|
-
}
|
|
1101
|
-
},
|
|
1102
|
-
{
|
|
1103
|
-
name: "local_browser_start",
|
|
1104
|
-
description: "Start a local browser session that connects to the cloud agent. The cloud agent can then control this browser to test local applications. Returns sessionId for tracking.",
|
|
1105
|
-
inputSchema: {
|
|
1106
|
-
type: "object",
|
|
1107
|
-
properties: {
|
|
1108
|
-
mode: {
|
|
1109
|
-
type: "string",
|
|
1110
|
-
enum: ["playwright", "cdp"],
|
|
1111
|
-
description: "Browser mode: 'playwright' for fresh browser, 'cdp' to connect to existing Chrome"
|
|
1112
|
-
},
|
|
1113
|
-
cdpUrl: {
|
|
1114
|
-
type: "string",
|
|
1115
|
-
description: "CDP endpoint URL when mode is 'cdp' (e.g. http://localhost:9222)"
|
|
1116
|
-
},
|
|
1117
|
-
headless: {
|
|
1118
|
-
type: "boolean",
|
|
1119
|
-
description: "Run browser headless (default: true for playwright mode)"
|
|
1120
|
-
},
|
|
1121
|
-
storageStatePath: {
|
|
1122
|
-
type: "string",
|
|
1123
|
-
description: "Path to Playwright storage state JSON for pre-authenticated sessions"
|
|
1124
|
-
},
|
|
1125
|
-
instructions: {
|
|
1126
|
-
type: "string",
|
|
1127
|
-
description: "Instructions for the cloud agent on what to test"
|
|
1128
|
-
}
|
|
1129
|
-
}
|
|
1130
|
-
}
|
|
1131
|
-
},
|
|
1132
|
-
{
|
|
1133
|
-
name: "local_browser_status",
|
|
1134
|
-
description: "Check the status of a local browser session.",
|
|
1135
|
-
inputSchema: {
|
|
1136
|
-
type: "object",
|
|
1137
|
-
properties: {
|
|
1138
|
-
sessionId: { type: "string" }
|
|
1139
|
-
},
|
|
1140
|
-
required: ["sessionId"]
|
|
1141
|
-
}
|
|
1142
|
-
},
|
|
1143
|
-
{
|
|
1144
|
-
name: "local_browser_stop",
|
|
1145
|
-
description: "Stop a local browser session and close the browser.",
|
|
1146
|
-
inputSchema: {
|
|
1147
|
-
type: "object",
|
|
1148
|
-
properties: {
|
|
1149
|
-
sessionId: { type: "string" }
|
|
1150
|
-
},
|
|
1151
|
-
required: ["sessionId"]
|
|
1152
|
-
}
|
|
1153
|
-
},
|
|
1154
|
-
{
|
|
1155
|
-
name: "local_browser_list",
|
|
1156
|
-
description: "List all active local browser sessions.",
|
|
1157
|
-
inputSchema: {
|
|
1158
|
-
type: "object",
|
|
1159
|
-
properties: {}
|
|
1160
|
-
}
|
|
1161
|
-
},
|
|
1162
|
-
{
|
|
1163
|
-
name: "local_browser_run",
|
|
1164
|
-
description: "Start a test run on an active local browser session. The cloud agent will control the local browser according to the instructions.",
|
|
1165
|
-
inputSchema: {
|
|
1166
|
-
type: "object",
|
|
1167
|
-
properties: {
|
|
1168
|
-
sessionId: {
|
|
1169
|
-
type: "string",
|
|
1170
|
-
description: "The session ID from local_browser_start"
|
|
1171
|
-
},
|
|
1172
|
-
instructions: {
|
|
1173
|
-
type: "string",
|
|
1174
|
-
description: "Instructions for the cloud agent on what to test"
|
|
1175
|
-
},
|
|
1176
|
-
startUrl: {
|
|
1177
|
-
type: "string",
|
|
1178
|
-
description: "Optional URL to navigate to before starting"
|
|
1179
|
-
}
|
|
1180
|
-
},
|
|
1181
|
-
required: ["sessionId", "instructions"]
|
|
1182
|
-
}
|
|
1183
|
-
}
|
|
1184
|
-
]
|
|
1185
|
-
}));
|
|
1186
|
-
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
1187
|
-
const token = await resolveToken();
|
|
1188
|
-
const tool = req.params.name;
|
|
1189
|
-
if (tool === "local_run_tests") {
|
|
1190
|
-
const input = req.params.arguments;
|
|
1191
|
-
const apiUrl = resolveApiUrl();
|
|
1192
|
-
const tunnel = await createTunnel({ apiUrl, token, port: input.port });
|
|
1193
|
-
connectTunnel({
|
|
1194
|
-
apiUrl,
|
|
1195
|
-
tunnelId: tunnel.tunnelId,
|
|
1196
|
-
token: tunnel.token,
|
|
1197
|
-
port: input.port
|
|
1198
|
-
});
|
|
1199
|
-
const tunnelUrl = tunnel.publicUrl;
|
|
1200
|
-
const run2 = await createLocalRun({
|
|
1201
|
-
apiUrl,
|
|
1202
|
-
token,
|
|
1203
|
-
title: input.title ?? "Local MCP run",
|
|
1204
|
-
featureSpec: input.instructions,
|
|
1205
|
-
startUrl: void 0,
|
|
1206
|
-
tunnelUrl
|
|
1207
|
-
});
|
|
1208
|
-
return toolJson({
|
|
1209
|
-
runId: run2.runId,
|
|
1210
|
-
watchUrl: run2.watchUrl,
|
|
1211
|
-
tunnelUrl,
|
|
1212
|
-
note: "Testing is asynchronous. Use local_wait_for_results with the runId to wait for completion."
|
|
1213
|
-
});
|
|
1214
|
-
}
|
|
1215
|
-
if (tool === "local_wait_for_results") {
|
|
1216
|
-
const input = req.params.arguments;
|
|
1217
|
-
const apiUrl = resolveApiUrl();
|
|
1218
|
-
const report = await waitForResult({ apiUrl, token, runId: input.runId });
|
|
1219
|
-
return toolJson(report);
|
|
1220
|
-
}
|
|
1221
|
-
if (tool === "local_browser_start") {
|
|
1222
|
-
const input = req.params.arguments;
|
|
1223
|
-
const apiUrl = resolveApiUrl();
|
|
1224
|
-
const mode = input.mode ?? "playwright";
|
|
1225
|
-
const sessionResponse = await fetch(`${apiUrl}/local-browser/sessions`, {
|
|
1226
|
-
method: "POST",
|
|
1227
|
-
headers: {
|
|
1228
|
-
"Content-Type": "application/json",
|
|
1229
|
-
Authorization: `Bearer ${token}`
|
|
1230
|
-
},
|
|
1231
|
-
body: JSON.stringify({
|
|
1232
|
-
browserMode: mode,
|
|
1233
|
-
instructions: input.instructions ?? null
|
|
1234
|
-
})
|
|
1235
|
-
});
|
|
1236
|
-
if (!sessionResponse.ok) {
|
|
1237
|
-
const text = await sessionResponse.text();
|
|
1238
|
-
return toolJson({ ok: false, error: `Failed to create session: ${text}` });
|
|
1239
|
-
}
|
|
1240
|
-
const session = await sessionResponse.json();
|
|
1241
|
-
const host = new LocalBrowserHost({
|
|
1242
|
-
apiUrl,
|
|
1243
|
-
wsToken: session.wsToken,
|
|
1244
|
-
sessionId: session.sessionId,
|
|
1245
|
-
browserMode: mode,
|
|
1246
|
-
cdpUrl: input.cdpUrl,
|
|
1247
|
-
headless: input.headless ?? true,
|
|
1248
|
-
storageStatePath: input.storageStatePath,
|
|
1249
|
-
onLog: (level, message) => {
|
|
1250
|
-
if (level === "error") {
|
|
1251
|
-
console.error(`[LocalBrowser] ${message}`);
|
|
1252
|
-
}
|
|
1253
|
-
}
|
|
1254
|
-
});
|
|
1255
|
-
host.start().catch((err) => {
|
|
1256
|
-
console.error("Failed to start local browser:", err);
|
|
1257
|
-
browserSessions.delete(session.sessionId);
|
|
1258
|
-
});
|
|
1259
|
-
browserSessions.set(session.sessionId, {
|
|
1260
|
-
sessionId: session.sessionId,
|
|
1261
|
-
host,
|
|
1262
|
-
startedAt: Date.now(),
|
|
1263
|
-
mode
|
|
1264
|
-
});
|
|
1265
|
-
return toolJson({
|
|
1266
|
-
ok: true,
|
|
1267
|
-
sessionId: session.sessionId,
|
|
1268
|
-
mode,
|
|
1269
|
-
expiresAt: session.expiresAt,
|
|
1270
|
-
note: "Browser session started. The cloud agent can now control this browser. Use local_browser_stop to end the session."
|
|
1271
|
-
});
|
|
1272
|
-
}
|
|
1273
|
-
if (tool === "local_browser_status") {
|
|
1274
|
-
const input = req.params.arguments;
|
|
1275
|
-
const session = browserSessions.get(input.sessionId);
|
|
1276
|
-
if (!session) {
|
|
1277
|
-
return toolJson({ ok: false, error: "Session not found", sessionId: input.sessionId });
|
|
1278
|
-
}
|
|
1279
|
-
return toolJson({
|
|
1280
|
-
ok: true,
|
|
1281
|
-
sessionId: session.sessionId,
|
|
1282
|
-
mode: session.mode,
|
|
1283
|
-
startedAt: new Date(session.startedAt).toISOString(),
|
|
1284
|
-
uptimeMs: Date.now() - session.startedAt
|
|
1285
|
-
});
|
|
1286
|
-
}
|
|
1287
|
-
if (tool === "local_browser_stop") {
|
|
1288
|
-
const input = req.params.arguments;
|
|
1289
|
-
const session = browserSessions.get(input.sessionId);
|
|
1290
|
-
if (!session) {
|
|
1291
|
-
return toolJson({ ok: false, error: "Session not found", sessionId: input.sessionId });
|
|
1292
|
-
}
|
|
1293
|
-
await session.host.stop();
|
|
1294
|
-
browserSessions.delete(input.sessionId);
|
|
1295
|
-
return toolJson({
|
|
1296
|
-
ok: true,
|
|
1297
|
-
sessionId: input.sessionId,
|
|
1298
|
-
note: "Browser session stopped and browser closed."
|
|
1299
|
-
});
|
|
1300
|
-
}
|
|
1301
|
-
if (tool === "local_browser_list") {
|
|
1302
|
-
const sessions = Array.from(browserSessions.values()).map((s) => ({
|
|
1303
|
-
sessionId: s.sessionId,
|
|
1304
|
-
mode: s.mode,
|
|
1305
|
-
startedAt: new Date(s.startedAt).toISOString(),
|
|
1306
|
-
uptimeMs: Date.now() - s.startedAt
|
|
1307
|
-
}));
|
|
1308
|
-
return toolJson({
|
|
1309
|
-
ok: true,
|
|
1310
|
-
count: sessions.length,
|
|
1311
|
-
sessions
|
|
1312
|
-
});
|
|
1313
|
-
}
|
|
1314
|
-
if (tool === "local_browser_run") {
|
|
1315
|
-
const input = req.params.arguments;
|
|
1316
|
-
const apiUrl = resolveApiUrl();
|
|
1317
|
-
const session = browserSessions.get(input.sessionId);
|
|
1318
|
-
if (!session) {
|
|
1319
|
-
return toolJson({
|
|
1320
|
-
ok: false,
|
|
1321
|
-
error: "Session not found locally. Make sure you started it with local_browser_start.",
|
|
1322
|
-
sessionId: input.sessionId
|
|
1323
|
-
});
|
|
1324
|
-
}
|
|
1325
|
-
const response = await fetch(`${apiUrl}/local-browser/sessions/${input.sessionId}/run`, {
|
|
1326
|
-
method: "POST",
|
|
1327
|
-
headers: {
|
|
1328
|
-
"Content-Type": "application/json",
|
|
1329
|
-
Authorization: `Bearer ${token}`
|
|
1330
|
-
},
|
|
1331
|
-
body: JSON.stringify({
|
|
1332
|
-
instructions: input.instructions,
|
|
1333
|
-
startUrl: input.startUrl ?? null
|
|
1334
|
-
})
|
|
1335
|
-
});
|
|
1336
|
-
if (!response.ok) {
|
|
1337
|
-
const text = await response.text();
|
|
1338
|
-
return toolJson({ ok: false, error: `Failed to start run: ${text}` });
|
|
1339
|
-
}
|
|
1340
|
-
const result = await response.json();
|
|
1341
|
-
return toolJson({
|
|
1342
|
-
ok: true,
|
|
1343
|
-
jobId: result.jobId,
|
|
1344
|
-
sessionId: result.sessionId,
|
|
1345
|
-
note: "Test run started. The cloud agent is now controlling your local browser. You can watch the browser to see the test in action."
|
|
1346
|
-
});
|
|
1347
|
-
}
|
|
1348
|
-
return toolText(`Unknown tool: ${tool}`);
|
|
1349
|
-
});
|
|
1350
|
-
const transport = new StdioServerTransport();
|
|
1351
|
-
await server.connect(transport);
|
|
1352
|
-
return new Promise(() => void 0);
|
|
1353
|
-
}
|
|
1354
|
-
async function waitForResult(input) {
|
|
1355
|
-
await streamUntilComplete(input);
|
|
1356
|
-
const response = await fetch(`${input.apiUrl}/local-tests/runs/${input.runId}`, {
|
|
1357
|
-
credentials: "include",
|
|
1358
|
-
headers: { authorization: `Bearer ${input.token}` }
|
|
1359
|
-
});
|
|
1360
|
-
const data = await response.json();
|
|
1361
|
-
const run2 = data?.data?.run ?? data?.run ?? data?.data;
|
|
1362
|
-
const summary = run2?.summaryJson;
|
|
1363
|
-
return formatReport({ run: run2, summary });
|
|
1364
|
-
}
|
|
1365
|
-
async function streamUntilComplete(input) {
|
|
1366
|
-
const response = await fetch(`${input.apiUrl}/local-tests/runs/${input.runId}/stream`, {
|
|
1367
|
-
headers: { authorization: `Bearer ${input.token}` }
|
|
1368
|
-
});
|
|
1369
|
-
if (!response.body) return;
|
|
1370
|
-
const reader = response.body.getReader();
|
|
1371
|
-
const decoder = new TextDecoder();
|
|
1372
|
-
const parser = createParser2({
|
|
1373
|
-
onEvent: (event) => {
|
|
1374
|
-
if (event.event === "status") {
|
|
1375
|
-
try {
|
|
1376
|
-
const payload = JSON.parse(event.data);
|
|
1377
|
-
if (payload?.status === "completed" || payload?.status === "failed") {
|
|
1378
|
-
reader.cancel().catch(() => void 0);
|
|
1379
|
-
}
|
|
1380
|
-
} catch {
|
|
1381
|
-
}
|
|
1382
|
-
}
|
|
1383
|
-
if (event.event === "complete" || event.event === "error") {
|
|
1384
|
-
reader.cancel().catch(() => void 0);
|
|
1385
|
-
}
|
|
1386
|
-
}
|
|
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
|
-
});
|
|
1
|
+
import {
|
|
2
|
+
connectTunnel,
|
|
3
|
+
createLocalRun,
|
|
4
|
+
createTunnel,
|
|
5
|
+
runLocalTest,
|
|
6
|
+
runTunnel
|
|
7
|
+
} from "./chunk-NRMZHITS.js";
|
|
8
|
+
import {
|
|
9
|
+
readStoredApiUrl,
|
|
10
|
+
readStoredAuth,
|
|
11
|
+
readStoredToken,
|
|
12
|
+
saveAuth
|
|
13
|
+
} from "./chunk-SGNA6N2N.js";
|
|
14
|
+
import {
|
|
15
|
+
__require
|
|
16
|
+
} from "./chunk-DGUM43GV.js";
|
|
1567
17
|
|
|
1568
18
|
// src/index.ts
|
|
1569
19
|
import { spawnSync as spawnSync2 } from "child_process";
|
|
1570
|
-
import
|
|
1571
|
-
import
|
|
20
|
+
import { createRequire as createRequire2 } from "module";
|
|
21
|
+
import process7 from "process";
|
|
22
|
+
import path4 from "path";
|
|
1572
23
|
import { fileURLToPath as fileURLToPath2, pathToFileURL as pathToFileURL2 } from "url";
|
|
1573
24
|
|
|
1574
25
|
// src/runner/common.ts
|
|
@@ -1876,18 +327,29 @@ function countHealed(eventLogPath) {
|
|
|
1876
327
|
}
|
|
1877
328
|
}
|
|
1878
329
|
|
|
1879
|
-
// src/index.ts
|
|
1880
|
-
init_local_run();
|
|
1881
|
-
init_tunnel();
|
|
1882
|
-
|
|
1883
330
|
// src/login.ts
|
|
1884
|
-
import
|
|
1885
|
-
import
|
|
1886
|
-
import path4 from "path";
|
|
1887
|
-
import process4 from "process";
|
|
331
|
+
import process2 from "process";
|
|
332
|
+
import readline from "readline";
|
|
1888
333
|
import { spawn as spawn2 } from "child_process";
|
|
1889
|
-
var
|
|
1890
|
-
|
|
334
|
+
var ENV_URLS = {
|
|
335
|
+
prod: {
|
|
336
|
+
api: "https://api.trycanary.ai",
|
|
337
|
+
app: "https://app.trycanary.ai"
|
|
338
|
+
},
|
|
339
|
+
production: {
|
|
340
|
+
api: "https://api.trycanary.ai",
|
|
341
|
+
app: "https://app.trycanary.ai"
|
|
342
|
+
},
|
|
343
|
+
dev: {
|
|
344
|
+
api: "https://api.dev.trycanary.ai",
|
|
345
|
+
app: "https://app.dev.trycanary.ai"
|
|
346
|
+
},
|
|
347
|
+
local: {
|
|
348
|
+
api: "http://localhost:3000",
|
|
349
|
+
app: "http://localhost:5173"
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
function getArgValue(argv, key) {
|
|
1891
353
|
const index = argv.indexOf(key);
|
|
1892
354
|
if (index === -1) return void 0;
|
|
1893
355
|
return argv[index + 1];
|
|
@@ -1896,7 +358,7 @@ function shouldOpenBrowser(argv) {
|
|
|
1896
358
|
return !argv.includes("--no-open");
|
|
1897
359
|
}
|
|
1898
360
|
function openUrl(url) {
|
|
1899
|
-
const platform =
|
|
361
|
+
const platform = process2.platform;
|
|
1900
362
|
if (platform === "darwin") {
|
|
1901
363
|
spawn2("open", [url], { stdio: "ignore" });
|
|
1902
364
|
return;
|
|
@@ -1907,16 +369,48 @@ function openUrl(url) {
|
|
|
1907
369
|
}
|
|
1908
370
|
spawn2("xdg-open", [url], { stdio: "ignore" });
|
|
1909
371
|
}
|
|
1910
|
-
|
|
1911
|
-
const
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
372
|
+
function promptChoice(question) {
|
|
373
|
+
const rl = readline.createInterface({ input: process2.stdin, output: process2.stdout });
|
|
374
|
+
return new Promise((resolve) => {
|
|
375
|
+
rl.question(question, (answer) => {
|
|
376
|
+
rl.close();
|
|
377
|
+
resolve(answer.trim());
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
async function fetchOrgs(apiUrl, token) {
|
|
382
|
+
try {
|
|
383
|
+
const res = await fetch(`${apiUrl}/cli-login/orgs`, {
|
|
384
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
385
|
+
});
|
|
386
|
+
if (!res.ok) return null;
|
|
387
|
+
return await res.json();
|
|
388
|
+
} catch {
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
async function switchOrg(apiUrl, token, orgId) {
|
|
393
|
+
const res = await fetch(`${apiUrl}/cli-login/switch-org`, {
|
|
394
|
+
method: "POST",
|
|
395
|
+
headers: {
|
|
396
|
+
Authorization: `Bearer ${token}`,
|
|
397
|
+
"Content-Type": "application/json"
|
|
398
|
+
},
|
|
399
|
+
body: JSON.stringify({ orgId })
|
|
400
|
+
});
|
|
401
|
+
return await res.json();
|
|
1916
402
|
}
|
|
1917
403
|
async function runLogin(argv) {
|
|
1918
|
-
const
|
|
1919
|
-
const
|
|
404
|
+
const env = getArgValue(argv, "--env");
|
|
405
|
+
const envUrls = env ? ENV_URLS[env] : void 0;
|
|
406
|
+
if (env && !envUrls) {
|
|
407
|
+
console.error(`Unknown environment: ${env}`);
|
|
408
|
+
console.error("Valid environments: prod, dev, local");
|
|
409
|
+
process2.exit(1);
|
|
410
|
+
}
|
|
411
|
+
const apiUrl = getArgValue(argv, "--api-url") ?? envUrls?.api ?? process2.env.CANARY_API_URL ?? "https://api.trycanary.ai";
|
|
412
|
+
const appUrl = getArgValue(argv, "--app-url") ?? envUrls?.app ?? process2.env.CANARY_APP_URL ?? "https://app.trycanary.ai";
|
|
413
|
+
const orgFlag = getArgValue(argv, "--org");
|
|
1920
414
|
const startRes = await fetch(`${apiUrl}/cli-login/start`, {
|
|
1921
415
|
method: "POST",
|
|
1922
416
|
headers: { "content-type": "application/json" },
|
|
@@ -1925,7 +419,7 @@ async function runLogin(argv) {
|
|
|
1925
419
|
const startJson = await startRes.json();
|
|
1926
420
|
if (!startRes.ok || !startJson.ok || !startJson.deviceCode || !startJson.userCode) {
|
|
1927
421
|
console.error("Login start failed", startJson.error ?? startRes.statusText);
|
|
1928
|
-
|
|
422
|
+
process2.exit(1);
|
|
1929
423
|
}
|
|
1930
424
|
console.log("Login required.");
|
|
1931
425
|
console.log(`User code: ${startJson.userCode}`);
|
|
@@ -1941,10 +435,12 @@ async function runLogin(argv) {
|
|
|
1941
435
|
}
|
|
1942
436
|
const intervalMs = (startJson.intervalSeconds ?? 3) * 1e3;
|
|
1943
437
|
const expiresAt = startJson.expiresAt ? new Date(startJson.expiresAt).getTime() : null;
|
|
438
|
+
let token;
|
|
439
|
+
let initialOrgId;
|
|
1944
440
|
while (true) {
|
|
1945
441
|
if (expiresAt && Date.now() > expiresAt) {
|
|
1946
442
|
console.error("Login code expired.");
|
|
1947
|
-
|
|
443
|
+
process2.exit(1);
|
|
1948
444
|
}
|
|
1949
445
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
1950
446
|
const pollRes = await fetch(`${apiUrl}/cli-login/poll`, {
|
|
@@ -1955,50 +451,137 @@ async function runLogin(argv) {
|
|
|
1955
451
|
const pollJson = await pollRes.json();
|
|
1956
452
|
if (!pollRes.ok || !pollJson.ok) {
|
|
1957
453
|
console.error("Login poll failed", pollJson.error ?? pollRes.statusText);
|
|
1958
|
-
|
|
454
|
+
process2.exit(1);
|
|
1959
455
|
}
|
|
1960
456
|
if (pollJson.status === "approved" && pollJson.accessToken) {
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
return;
|
|
457
|
+
token = pollJson.accessToken;
|
|
458
|
+
initialOrgId = pollJson.orgId;
|
|
459
|
+
break;
|
|
1965
460
|
}
|
|
1966
461
|
if (pollJson.status === "rejected") {
|
|
1967
462
|
console.error("Login rejected.");
|
|
1968
|
-
|
|
463
|
+
process2.exit(1);
|
|
1969
464
|
}
|
|
1970
465
|
if (pollJson.status === "expired") {
|
|
1971
466
|
console.error("Login expired.");
|
|
1972
|
-
|
|
467
|
+
process2.exit(1);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
const orgsData = await fetchOrgs(apiUrl, token);
|
|
471
|
+
const orgs = orgsData?.organizations ?? [];
|
|
472
|
+
let finalToken = token;
|
|
473
|
+
let finalOrgId = initialOrgId;
|
|
474
|
+
let finalOrgName;
|
|
475
|
+
if (orgs.length <= 1) {
|
|
476
|
+
finalOrgName = orgs[0]?.name;
|
|
477
|
+
} else {
|
|
478
|
+
let selectedOrg;
|
|
479
|
+
if (orgFlag) {
|
|
480
|
+
selectedOrg = orgs.find(
|
|
481
|
+
(o) => o.name.toLowerCase() === orgFlag.toLowerCase() || o.id === orgFlag
|
|
482
|
+
);
|
|
483
|
+
if (!selectedOrg) {
|
|
484
|
+
console.error(`Organization "${orgFlag}" not found. Available orgs:`);
|
|
485
|
+
for (const o of orgs) {
|
|
486
|
+
console.error(` - ${o.name} (${o.id})`);
|
|
487
|
+
}
|
|
488
|
+
process2.exit(1);
|
|
489
|
+
}
|
|
490
|
+
} else if (process2.stdin.isTTY) {
|
|
491
|
+
console.log("\nYou belong to multiple organizations. Select one:");
|
|
492
|
+
for (let i = 0; i < orgs.length; i++) {
|
|
493
|
+
const marker = orgs[i].id === initialOrgId ? " (current)" : "";
|
|
494
|
+
console.log(` ${i + 1}. ${orgs[i].name}${marker}`);
|
|
495
|
+
}
|
|
496
|
+
const answer = await promptChoice(`
|
|
497
|
+
Choice [1-${orgs.length}]: `);
|
|
498
|
+
const idx = parseInt(answer, 10) - 1;
|
|
499
|
+
if (isNaN(idx) || idx < 0 || idx >= orgs.length) {
|
|
500
|
+
console.error("Invalid selection.");
|
|
501
|
+
process2.exit(1);
|
|
502
|
+
}
|
|
503
|
+
selectedOrg = orgs[idx];
|
|
504
|
+
} else {
|
|
505
|
+
const defaultOrg = orgs.find((o) => o.id === initialOrgId);
|
|
506
|
+
console.log(
|
|
507
|
+
`Warning: Multiple organizations available but running non-interactively. Using "${defaultOrg?.name ?? initialOrgId}".`
|
|
508
|
+
);
|
|
509
|
+
console.log("Tip: Use --org <name> to select a specific organization.");
|
|
510
|
+
selectedOrg = defaultOrg;
|
|
1973
511
|
}
|
|
512
|
+
if (selectedOrg && selectedOrg.id !== initialOrgId) {
|
|
513
|
+
const switchRes = await switchOrg(apiUrl, token, selectedOrg.id);
|
|
514
|
+
if (!switchRes.ok || !switchRes.accessToken) {
|
|
515
|
+
console.error("Failed to switch organization:", switchRes.error ?? "Unknown error");
|
|
516
|
+
process2.exit(1);
|
|
517
|
+
}
|
|
518
|
+
finalToken = switchRes.accessToken;
|
|
519
|
+
finalOrgId = switchRes.orgId;
|
|
520
|
+
finalOrgName = switchRes.orgName;
|
|
521
|
+
} else if (selectedOrg) {
|
|
522
|
+
finalOrgName = selectedOrg.name;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
const filePath = await saveAuth({ token: finalToken, apiUrl, orgId: finalOrgId, orgName: finalOrgName });
|
|
526
|
+
const displayName = finalOrgName ? ` to ${finalOrgName}` : "";
|
|
527
|
+
console.log(`Login successful${displayName}. Token saved to ${filePath}`);
|
|
528
|
+
console.log("Set CANARY_API_TOKEN to use the CLI without re-login.");
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// src/orgs.ts
|
|
532
|
+
import process3 from "process";
|
|
533
|
+
async function runOrgs(argv) {
|
|
534
|
+
const token = process3.env.CANARY_API_TOKEN ?? await readStoredToken();
|
|
535
|
+
if (!token) {
|
|
536
|
+
console.error("Not logged in. Run: canary login");
|
|
537
|
+
process3.exit(1);
|
|
1974
538
|
}
|
|
539
|
+
const auth = await readStoredAuth();
|
|
540
|
+
const apiUrl = process3.env.CANARY_API_URL ?? auth?.apiUrl ?? "https://api.trycanary.ai";
|
|
541
|
+
const res = await fetch(`${apiUrl}/cli-login/orgs`, {
|
|
542
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
543
|
+
});
|
|
544
|
+
if (!res.ok) {
|
|
545
|
+
console.error("Failed to fetch organizations. You may need to re-login: canary login");
|
|
546
|
+
process3.exit(1);
|
|
547
|
+
}
|
|
548
|
+
const data = await res.json();
|
|
549
|
+
if (!data.ok || !data.organizations) {
|
|
550
|
+
console.error("Failed to fetch organizations:", data.error ?? "Unknown error");
|
|
551
|
+
process3.exit(1);
|
|
552
|
+
}
|
|
553
|
+
const currentOrgId = auth?.orgId ?? data.currentOrgId;
|
|
554
|
+
console.log("Organizations:\n");
|
|
555
|
+
for (const org of data.organizations) {
|
|
556
|
+
const marker = org.id === currentOrgId ? " *" : "";
|
|
557
|
+
console.log(` ${org.name} (${org.role})${marker}`);
|
|
558
|
+
}
|
|
559
|
+
console.log("\n* = current organization");
|
|
560
|
+
console.log("To switch: canary login --org <name>");
|
|
1975
561
|
}
|
|
1976
562
|
|
|
1977
563
|
// src/run-local.ts
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
init_tunnel();
|
|
1981
|
-
import process5 from "process";
|
|
1982
|
-
function getArgValue4(argv, key) {
|
|
564
|
+
import process4 from "process";
|
|
565
|
+
function getArgValue2(argv, key) {
|
|
1983
566
|
const index = argv.indexOf(key);
|
|
1984
567
|
if (index === -1) return void 0;
|
|
1985
568
|
return argv[index + 1];
|
|
1986
569
|
}
|
|
1987
570
|
async function runLocalSession(argv) {
|
|
1988
|
-
const apiUrl =
|
|
1989
|
-
const token =
|
|
571
|
+
const apiUrl = getArgValue2(argv, "--api-url") ?? process4.env.CANARY_API_URL ?? "https://api.trycanary.ai";
|
|
572
|
+
const token = getArgValue2(argv, "--token") ?? process4.env.CANARY_API_TOKEN ?? await readStoredToken();
|
|
1990
573
|
if (!token) {
|
|
1991
574
|
console.error("Missing token. Run `canary login` first or set CANARY_API_TOKEN.");
|
|
1992
|
-
|
|
575
|
+
process4.exit(1);
|
|
1993
576
|
}
|
|
1994
|
-
const portRaw =
|
|
1995
|
-
const tunnelUrl =
|
|
1996
|
-
const title =
|
|
1997
|
-
const featureSpec =
|
|
1998
|
-
const startUrl =
|
|
577
|
+
const portRaw = getArgValue2(argv, "--port") ?? process4.env.CANARY_LOCAL_PORT;
|
|
578
|
+
const tunnelUrl = getArgValue2(argv, "--tunnel-url");
|
|
579
|
+
const title = getArgValue2(argv, "--title");
|
|
580
|
+
const featureSpec = getArgValue2(argv, "--feature");
|
|
581
|
+
const startUrl = getArgValue2(argv, "--start-url");
|
|
1999
582
|
if (!tunnelUrl && !portRaw) {
|
|
2000
583
|
console.error("Missing --port or --tunnel-url");
|
|
2001
|
-
|
|
584
|
+
process4.exit(1);
|
|
2002
585
|
}
|
|
2003
586
|
let publicUrl = tunnelUrl;
|
|
2004
587
|
let ws = null;
|
|
@@ -2006,7 +589,7 @@ async function runLocalSession(argv) {
|
|
|
2006
589
|
const port = Number(portRaw);
|
|
2007
590
|
if (Number.isNaN(port) || port <= 0) {
|
|
2008
591
|
console.error("Invalid --port value");
|
|
2009
|
-
|
|
592
|
+
process4.exit(1);
|
|
2010
593
|
}
|
|
2011
594
|
const tunnel = await createTunnel({ apiUrl, token, port });
|
|
2012
595
|
publicUrl = tunnel.publicUrl;
|
|
@@ -2022,7 +605,7 @@ async function runLocalSession(argv) {
|
|
|
2022
605
|
}
|
|
2023
606
|
if (!publicUrl) {
|
|
2024
607
|
console.error("Failed to resolve tunnel URL");
|
|
2025
|
-
|
|
608
|
+
process4.exit(1);
|
|
2026
609
|
}
|
|
2027
610
|
const run2 = await createLocalRun({
|
|
2028
611
|
apiUrl,
|
|
@@ -2038,19 +621,18 @@ async function runLocalSession(argv) {
|
|
|
2038
621
|
}
|
|
2039
622
|
if (ws) {
|
|
2040
623
|
console.log("Tunnel active. Press Ctrl+C to stop.");
|
|
2041
|
-
|
|
624
|
+
process4.on("SIGINT", () => {
|
|
2042
625
|
ws?.close();
|
|
2043
|
-
|
|
626
|
+
process4.exit(0);
|
|
2044
627
|
});
|
|
2045
628
|
await new Promise(() => void 0);
|
|
2046
629
|
}
|
|
2047
630
|
}
|
|
2048
631
|
|
|
2049
632
|
// src/remote-test.ts
|
|
2050
|
-
|
|
2051
|
-
import process6 from "process";
|
|
633
|
+
import process5 from "process";
|
|
2052
634
|
import { createParser } from "eventsource-parser";
|
|
2053
|
-
function
|
|
635
|
+
function getArgValue3(argv, key) {
|
|
2054
636
|
const index = argv.indexOf(key);
|
|
2055
637
|
if (index === -1 || index >= argv.length - 1) return void 0;
|
|
2056
638
|
return argv[index + 1];
|
|
@@ -2058,11 +640,20 @@ function getArgValue5(argv, key) {
|
|
|
2058
640
|
function hasFlag(argv, ...flags) {
|
|
2059
641
|
return flags.some((flag) => argv.includes(flag));
|
|
2060
642
|
}
|
|
643
|
+
function formatFailedTests(failedTests, appUrl) {
|
|
644
|
+
if (failedTests.length === 0 || !appUrl) return null;
|
|
645
|
+
const lines = ["", "Failed tests:"];
|
|
646
|
+
for (const t of failedTests) {
|
|
647
|
+
lines.push(` \u2717 ${t.name}`);
|
|
648
|
+
lines.push(` ${appUrl}/runs/tests/${t.testRunId}`);
|
|
649
|
+
}
|
|
650
|
+
return lines.join("\n");
|
|
651
|
+
}
|
|
2061
652
|
async function runRemoteTest(argv) {
|
|
2062
|
-
const apiUrl =
|
|
2063
|
-
const token =
|
|
2064
|
-
const tag =
|
|
2065
|
-
const namePattern =
|
|
653
|
+
const apiUrl = getArgValue3(argv, "--api-url") ?? process5.env.CANARY_API_URL ?? "https://api.trycanary.ai";
|
|
654
|
+
const token = getArgValue3(argv, "--token") ?? process5.env.CANARY_API_TOKEN ?? await readStoredToken();
|
|
655
|
+
const tag = getArgValue3(argv, "--tag");
|
|
656
|
+
const namePattern = getArgValue3(argv, "--name-pattern");
|
|
2066
657
|
const verbose = hasFlag(argv, "--verbose", "-v");
|
|
2067
658
|
if (!token) {
|
|
2068
659
|
console.error("Error: No API token found.");
|
|
@@ -2072,7 +663,7 @@ async function runRemoteTest(argv) {
|
|
|
2072
663
|
console.error("");
|
|
2073
664
|
console.error("Or create an API key in Settings > API Keys and pass it:");
|
|
2074
665
|
console.error(" canary test --remote --token cnry_...");
|
|
2075
|
-
|
|
666
|
+
process5.exit(1);
|
|
2076
667
|
}
|
|
2077
668
|
console.log("Starting remote workflow tests...");
|
|
2078
669
|
if (tag) console.log(` Filtering by tag: ${tag}`);
|
|
@@ -2093,20 +684,20 @@ async function runRemoteTest(argv) {
|
|
|
2093
684
|
});
|
|
2094
685
|
} catch (err) {
|
|
2095
686
|
console.error(`Failed to connect to API: ${err}`);
|
|
2096
|
-
|
|
687
|
+
process5.exit(1);
|
|
2097
688
|
}
|
|
2098
689
|
if (!triggerRes.ok) {
|
|
2099
690
|
const errorText = await triggerRes.text();
|
|
2100
691
|
console.error(`Failed to start tests: ${triggerRes.status}`);
|
|
2101
692
|
console.error(errorText);
|
|
2102
|
-
|
|
693
|
+
process5.exit(1);
|
|
2103
694
|
}
|
|
2104
695
|
const triggerData = await triggerRes.json();
|
|
2105
696
|
if (!triggerData.ok || !triggerData.suiteId) {
|
|
2106
697
|
console.error(`Failed to start tests: ${triggerData.error ?? "Unknown error"}`);
|
|
2107
|
-
|
|
698
|
+
process5.exit(1);
|
|
2108
699
|
}
|
|
2109
|
-
const { suiteId, jobId } = triggerData;
|
|
700
|
+
const { suiteId, jobId, appUrl } = triggerData;
|
|
2110
701
|
if (verbose) {
|
|
2111
702
|
console.log(`Suite ID: ${suiteId}`);
|
|
2112
703
|
console.log(`Job ID: ${jobId}`);
|
|
@@ -2123,15 +714,16 @@ async function runRemoteTest(argv) {
|
|
|
2123
714
|
});
|
|
2124
715
|
} catch (err) {
|
|
2125
716
|
console.error(`Failed to connect to event stream: ${err}`);
|
|
2126
|
-
|
|
717
|
+
process5.exit(1);
|
|
2127
718
|
}
|
|
2128
719
|
if (!streamRes.ok || !streamRes.body) {
|
|
2129
720
|
console.error(`Failed to connect to event stream: ${streamRes.status}`);
|
|
2130
|
-
|
|
721
|
+
process5.exit(1);
|
|
2131
722
|
}
|
|
2132
723
|
let exitCode = 0;
|
|
2133
724
|
let hasCompleted = false;
|
|
2134
725
|
const workflowNames = /* @__PURE__ */ new Map();
|
|
726
|
+
const failedTests = [];
|
|
2135
727
|
let totalWorkflows = 0;
|
|
2136
728
|
let completedWorkflows = 0;
|
|
2137
729
|
let failedWorkflows = 0;
|
|
@@ -2160,6 +752,7 @@ async function runRemoteTest(argv) {
|
|
|
2160
752
|
if (errorMessage) {
|
|
2161
753
|
console.log(` Error: ${errorMessage.slice(0, 200)}`);
|
|
2162
754
|
}
|
|
755
|
+
failedTests.push({ name, testRunId: testEvent.testRunId });
|
|
2163
756
|
exitCode = 1;
|
|
2164
757
|
} else if (status === "running") {
|
|
2165
758
|
} else if (status === "waiting") {
|
|
@@ -2201,7 +794,7 @@ async function runRemoteTest(argv) {
|
|
|
2201
794
|
console.log("\u2500".repeat(50));
|
|
2202
795
|
if (totalWorkflows === 0) {
|
|
2203
796
|
console.log("No workflows found matching the filter criteria.");
|
|
2204
|
-
|
|
797
|
+
process5.exit(0);
|
|
2205
798
|
}
|
|
2206
799
|
const passRate = totalWorkflows > 0 ? Math.round(successfulWorkflows / totalWorkflows * 100) : 0;
|
|
2207
800
|
if (failedWorkflows > 0) {
|
|
@@ -2214,83 +807,336 @@ async function runRemoteTest(argv) {
|
|
|
2214
807
|
if (waitingWorkflows > 0) {
|
|
2215
808
|
console.log(`Note: ${waitingWorkflows} workflow(s) are still waiting (scheduled for later)`);
|
|
2216
809
|
}
|
|
2217
|
-
|
|
810
|
+
const failedSection = formatFailedTests(failedTests, appUrl);
|
|
811
|
+
if (failedSection) {
|
|
812
|
+
console.log(failedSection);
|
|
813
|
+
}
|
|
814
|
+
process5.exit(exitCode);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// src/debug-session.ts
|
|
818
|
+
import fs3 from "fs/promises";
|
|
819
|
+
import os2 from "os";
|
|
820
|
+
import path3 from "path";
|
|
821
|
+
import process6 from "process";
|
|
822
|
+
var ENV_URLS2 = {
|
|
823
|
+
prod: {
|
|
824
|
+
api: "https://api.trycanary.ai",
|
|
825
|
+
app: "https://app.trycanary.ai"
|
|
826
|
+
},
|
|
827
|
+
production: {
|
|
828
|
+
api: "https://api.trycanary.ai",
|
|
829
|
+
app: "https://app.trycanary.ai"
|
|
830
|
+
},
|
|
831
|
+
dev: {
|
|
832
|
+
api: "https://api.dev.trycanary.ai",
|
|
833
|
+
app: "https://app.dev.trycanary.ai"
|
|
834
|
+
},
|
|
835
|
+
local: {
|
|
836
|
+
api: "http://localhost:3000",
|
|
837
|
+
app: "http://localhost:5173"
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
function getArgValue4(argv, key) {
|
|
841
|
+
const index = argv.indexOf(key);
|
|
842
|
+
if (index === -1 || index >= argv.length - 1) return void 0;
|
|
843
|
+
return argv[index + 1];
|
|
844
|
+
}
|
|
845
|
+
function hasFlag2(argv, ...flags) {
|
|
846
|
+
return flags.some((flag) => argv.includes(flag));
|
|
847
|
+
}
|
|
848
|
+
async function writeDebugSession(loginUrl, expiresAt, apiUrl) {
|
|
849
|
+
const dir = path3.join(os2.homedir(), ".config", "canary-cli");
|
|
850
|
+
const filePath = path3.join(dir, "debug-session.json");
|
|
851
|
+
await fs3.mkdir(dir, { recursive: true, mode: 448 });
|
|
852
|
+
await fs3.writeFile(
|
|
853
|
+
filePath,
|
|
854
|
+
JSON.stringify({ loginUrl, expiresAt, apiUrl, createdAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2),
|
|
855
|
+
{ encoding: "utf8", mode: 384 }
|
|
856
|
+
);
|
|
857
|
+
return filePath;
|
|
858
|
+
}
|
|
859
|
+
async function runDebugSession(argv) {
|
|
860
|
+
const env = getArgValue4(argv, "--env");
|
|
861
|
+
const envUrls = env ? ENV_URLS2[env] : void 0;
|
|
862
|
+
if (env && !envUrls) {
|
|
863
|
+
console.error(`Unknown environment: ${env}`);
|
|
864
|
+
console.error("Valid environments: prod, dev, local");
|
|
865
|
+
process6.exit(1);
|
|
866
|
+
}
|
|
867
|
+
const storedApiUrl = await readStoredApiUrl();
|
|
868
|
+
const apiUrl = getArgValue4(argv, "--api-url") ?? envUrls?.api ?? process6.env.CANARY_API_URL ?? storedApiUrl ?? "https://api.trycanary.ai";
|
|
869
|
+
const token = getArgValue4(argv, "--token") ?? process6.env.CANARY_API_TOKEN ?? await readStoredToken();
|
|
870
|
+
const jsonOutput = hasFlag2(argv, "--json");
|
|
871
|
+
if (!token) {
|
|
872
|
+
console.error("Error: No API token found.");
|
|
873
|
+
console.error("Run: canary login [--env <env>]");
|
|
874
|
+
process6.exit(1);
|
|
875
|
+
}
|
|
876
|
+
try {
|
|
877
|
+
const res = await fetch(`${apiUrl}/auth/debug-session/create`, {
|
|
878
|
+
method: "POST",
|
|
879
|
+
headers: {
|
|
880
|
+
Authorization: `Bearer ${token}`,
|
|
881
|
+
"Content-Type": "application/json"
|
|
882
|
+
}
|
|
883
|
+
});
|
|
884
|
+
if (!res.ok) {
|
|
885
|
+
const text = await res.text();
|
|
886
|
+
if (res.status === 401) {
|
|
887
|
+
console.error("Error: Unauthorized. Your session may have expired.");
|
|
888
|
+
console.error("Run: canary login");
|
|
889
|
+
process6.exit(1);
|
|
890
|
+
}
|
|
891
|
+
if (res.status === 403) {
|
|
892
|
+
console.error("Error: Forbidden. Debug session creation requires superadmin access.");
|
|
893
|
+
process6.exit(1);
|
|
894
|
+
}
|
|
895
|
+
if (res.status === 404) {
|
|
896
|
+
console.error(
|
|
897
|
+
"Error: Endpoint not found. The debug-session feature may not be deployed to this environment."
|
|
898
|
+
);
|
|
899
|
+
process6.exit(1);
|
|
900
|
+
}
|
|
901
|
+
try {
|
|
902
|
+
const errorJson = JSON.parse(text);
|
|
903
|
+
console.error(`Error: ${errorJson.message ?? errorJson.error ?? text}`);
|
|
904
|
+
} catch {
|
|
905
|
+
console.error(`Error (${res.status}): ${text || res.statusText}`);
|
|
906
|
+
}
|
|
907
|
+
process6.exit(1);
|
|
908
|
+
}
|
|
909
|
+
const json = await res.json();
|
|
910
|
+
if (!json.ok || !json.loginUrl) {
|
|
911
|
+
console.error(`Error: ${json.message ?? json.error ?? "Failed to create debug session"}`);
|
|
912
|
+
process6.exit(1);
|
|
913
|
+
}
|
|
914
|
+
const filePath = await writeDebugSession(json.loginUrl, json.expiresAt ?? "", apiUrl);
|
|
915
|
+
if (jsonOutput) {
|
|
916
|
+
console.log(
|
|
917
|
+
JSON.stringify(
|
|
918
|
+
{
|
|
919
|
+
loginUrl: json.loginUrl,
|
|
920
|
+
expiresAt: json.expiresAt,
|
|
921
|
+
sessionFile: filePath
|
|
922
|
+
},
|
|
923
|
+
null,
|
|
924
|
+
2
|
|
925
|
+
)
|
|
926
|
+
);
|
|
927
|
+
} else {
|
|
928
|
+
console.log("Debug session created successfully.");
|
|
929
|
+
console.log("");
|
|
930
|
+
console.log(`Login URL: ${json.loginUrl}`);
|
|
931
|
+
console.log(`Expires: ${json.expiresAt}`);
|
|
932
|
+
console.log(`Session saved to: ${filePath}`);
|
|
933
|
+
console.log("");
|
|
934
|
+
console.log("Use this URL with Playwright to authenticate as the debug agent.");
|
|
935
|
+
console.log("The token is single-use and expires in 5 minutes.");
|
|
936
|
+
}
|
|
937
|
+
} catch (err) {
|
|
938
|
+
console.error(`Failed to create debug session: ${err}`);
|
|
939
|
+
process6.exit(1);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// src/jwt.ts
|
|
944
|
+
function decodeJwtPayload(token) {
|
|
945
|
+
try {
|
|
946
|
+
const parts = token.split(".");
|
|
947
|
+
if (parts.length !== 3) return null;
|
|
948
|
+
const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
949
|
+
const json = Buffer.from(base64, "base64").toString("utf8");
|
|
950
|
+
const payload = JSON.parse(json);
|
|
951
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
|
952
|
+
return null;
|
|
953
|
+
}
|
|
954
|
+
return payload;
|
|
955
|
+
} catch {
|
|
956
|
+
return null;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
function isSuperadminToken(token) {
|
|
960
|
+
if (!token) return false;
|
|
961
|
+
const payload = decodeJwtPayload(token);
|
|
962
|
+
return payload?.is_superadmin === true;
|
|
2218
963
|
}
|
|
2219
964
|
|
|
2220
965
|
// src/index.ts
|
|
2221
|
-
var
|
|
2222
|
-
var
|
|
966
|
+
var require2 = createRequire2(import.meta.url);
|
|
967
|
+
var pkg = require2("../package.json");
|
|
968
|
+
var loadMcp = () => import("./mcp-5N5Z343W.js").then((m) => m.runMcp);
|
|
969
|
+
var loadLocalBrowser = () => import("./local-browser-REU2RIYX.js").then((m) => m.runLocalBrowser);
|
|
2223
970
|
var canary = { run };
|
|
2224
|
-
var baseDir = typeof __dirname !== "undefined" ? __dirname :
|
|
2225
|
-
var preloadPath =
|
|
971
|
+
var baseDir = typeof __dirname !== "undefined" ? __dirname : path4.dirname(fileURLToPath2(import.meta.url));
|
|
972
|
+
var preloadPath = path4.join(baseDir, "runner", "preload.js");
|
|
2226
973
|
var requireFn = makeRequire();
|
|
2227
974
|
function runPlaywrightTests(args) {
|
|
2228
975
|
const playwrightCli = requireFn.resolve("@playwright/test/cli");
|
|
2229
976
|
const { runnerBin, preloadFlag } = resolveRunner(preloadPath);
|
|
2230
|
-
const nodeOptions =
|
|
977
|
+
const nodeOptions = process7.env.NODE_OPTIONS && preloadFlag ? `${process7.env.NODE_OPTIONS} ${preloadFlag}` : preloadFlag ?? process7.env.NODE_OPTIONS;
|
|
2231
978
|
const env = {
|
|
2232
|
-
...
|
|
2233
|
-
CANARY_ENABLED:
|
|
979
|
+
...process7.env,
|
|
980
|
+
CANARY_ENABLED: process7.env.CANARY_ENABLED ?? "1",
|
|
2234
981
|
CANARY_RUNNER: "canary",
|
|
2235
982
|
...nodeOptions ? { NODE_OPTIONS: nodeOptions } : {}
|
|
2236
983
|
};
|
|
2237
984
|
const result = spawnSync2(runnerBin, [playwrightCli, "test", ...args], {
|
|
2238
985
|
env,
|
|
2239
986
|
stdio: "inherit",
|
|
2240
|
-
cwd:
|
|
987
|
+
cwd: process7.cwd()
|
|
2241
988
|
});
|
|
2242
989
|
if (result.error) {
|
|
2243
990
|
console.error("canary failed to launch Playwright:", result.error);
|
|
2244
|
-
|
|
991
|
+
process7.exit(1);
|
|
2245
992
|
}
|
|
2246
|
-
|
|
993
|
+
process7.exit(result.status ?? 1);
|
|
994
|
+
}
|
|
995
|
+
function printVersion() {
|
|
996
|
+
console.log(`canary v${pkg.version}`);
|
|
997
|
+
}
|
|
998
|
+
function printHelp({ isSuperadmin }) {
|
|
999
|
+
const lines = [
|
|
1000
|
+
`canary v${pkg.version}: Local and remote testing CLI`,
|
|
1001
|
+
"",
|
|
1002
|
+
"Usage:",
|
|
1003
|
+
" canary test [playwright options] Run local Playwright tests",
|
|
1004
|
+
" canary test --remote [options] Run remote workflow tests",
|
|
1005
|
+
" canary local-run --tunnel-url <url> [options]",
|
|
1006
|
+
" canary tunnel --port <localPort> [options]",
|
|
1007
|
+
" canary run --port <localPort> [options]",
|
|
1008
|
+
" canary mcp",
|
|
1009
|
+
" canary browser [--mode playwright|cdp] [--cdp-url <url>] [--no-headless]",
|
|
1010
|
+
" canary login [--org <name>] [--app-url https://app.trycanary.ai] [--no-open]",
|
|
1011
|
+
" canary orgs List organizations"
|
|
1012
|
+
];
|
|
1013
|
+
if (isSuperadmin) {
|
|
1014
|
+
lines.push(
|
|
1015
|
+
" canary debug-session [--env dev|local] [--json] Create browser debug session",
|
|
1016
|
+
" canary psql <query> [--json] Execute read-only SQL",
|
|
1017
|
+
" canary redis <command> [--json] Execute read-only Redis commands",
|
|
1018
|
+
" canary feature-flag <sub-command> Manage feature flags"
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
lines.push(
|
|
1022
|
+
" canary version Show version",
|
|
1023
|
+
" canary help",
|
|
1024
|
+
"",
|
|
1025
|
+
"Remote test options:",
|
|
1026
|
+
" --token <key> API key (or set CANARY_API_TOKEN)",
|
|
1027
|
+
" --api-url <url> API URL (default: https://api.trycanary.ai)",
|
|
1028
|
+
" --tag <tag> Filter workflows by tag",
|
|
1029
|
+
" --name-pattern <pat> Filter workflows by name pattern",
|
|
1030
|
+
" --verbose, -v Show all events",
|
|
1031
|
+
"",
|
|
1032
|
+
"Browser options:",
|
|
1033
|
+
" --mode <playwright|cdp> Browser mode (default: playwright)",
|
|
1034
|
+
" --cdp-url <url> CDP endpoint for existing Chrome",
|
|
1035
|
+
" --no-headless Run browser with visible UI",
|
|
1036
|
+
" --storage-state <path> Path to storage state JSON",
|
|
1037
|
+
" --instructions <text> Instructions for the cloud agent",
|
|
1038
|
+
"",
|
|
1039
|
+
"Login options:",
|
|
1040
|
+
" --org <name> Select organization by name or ID (for multi-org users)",
|
|
1041
|
+
"",
|
|
1042
|
+
"Login environments:",
|
|
1043
|
+
" Production: canary login",
|
|
1044
|
+
" Dev: canary login --app-url https://app.dev.trycanary.ai --api-url https://api.dev.trycanary.ai",
|
|
1045
|
+
" Local: canary login --app-url http://localhost:5173 --api-url http://localhost:3000",
|
|
1046
|
+
"",
|
|
1047
|
+
" Or set CANARY_API_URL env var for non-production environments:",
|
|
1048
|
+
" export CANARY_API_URL=http://localhost:3000"
|
|
1049
|
+
);
|
|
1050
|
+
if (isSuperadmin) {
|
|
1051
|
+
lines.push(
|
|
1052
|
+
"",
|
|
1053
|
+
"PSQL options:",
|
|
1054
|
+
" --json Output results as JSON",
|
|
1055
|
+
" --query <sql> SQL query (alternative to positional)",
|
|
1056
|
+
"",
|
|
1057
|
+
"Redis options:",
|
|
1058
|
+
" --json Output results as JSON",
|
|
1059
|
+
"",
|
|
1060
|
+
"Feature flag sub-commands:",
|
|
1061
|
+
" list List all flags",
|
|
1062
|
+
" create <name> [--description <text>] Create a flag",
|
|
1063
|
+
" delete <name> Delete a flag and its gates",
|
|
1064
|
+
" enable <name> --org <orgId> Enable for an org",
|
|
1065
|
+
" disable <name> --org <orgId> Disable for an org"
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
lines.push(
|
|
1069
|
+
"",
|
|
1070
|
+
"Flags:",
|
|
1071
|
+
" -h, --help Show help",
|
|
1072
|
+
" -V, --version Show version"
|
|
1073
|
+
);
|
|
1074
|
+
console.log(lines.join("\n"));
|
|
2247
1075
|
}
|
|
2248
|
-
function
|
|
1076
|
+
function printTestHelp() {
|
|
2249
1077
|
console.log(
|
|
2250
1078
|
[
|
|
2251
|
-
|
|
1079
|
+
`canary v${pkg.version}: Test command`,
|
|
2252
1080
|
"",
|
|
2253
1081
|
"Usage:",
|
|
2254
1082
|
" canary test [playwright options] Run local Playwright tests",
|
|
2255
1083
|
" canary test --remote [options] Run remote workflow tests",
|
|
2256
|
-
" canary local-run --tunnel-url <url> [options]",
|
|
2257
|
-
" canary tunnel --port <localPort> [options]",
|
|
2258
|
-
" canary run --port <localPort> [options]",
|
|
2259
|
-
" canary mcp",
|
|
2260
|
-
" canary browser [--mode playwright|cdp] [--cdp-url <url>] [--no-headless]",
|
|
2261
|
-
" canary login [--app-url https://app.trycanary.ai] [--no-open]",
|
|
2262
|
-
" canary help",
|
|
2263
1084
|
"",
|
|
2264
|
-
"
|
|
2265
|
-
" --
|
|
2266
|
-
" --
|
|
2267
|
-
" --
|
|
2268
|
-
" --
|
|
2269
|
-
" --
|
|
1085
|
+
"Local Playwright options (passed through to Playwright):",
|
|
1086
|
+
" --grep <pattern> Only run tests matching pattern",
|
|
1087
|
+
" --headed Run in headed browser mode",
|
|
1088
|
+
" --workers <n> Number of parallel workers",
|
|
1089
|
+
" --project <name> Run specific project",
|
|
1090
|
+
" --reporter <reporter> Use a specific reporter",
|
|
1091
|
+
" --retries <n> Number of retries for failed tests",
|
|
1092
|
+
" --timeout <ms> Test timeout in milliseconds",
|
|
2270
1093
|
"",
|
|
2271
|
-
"
|
|
2272
|
-
" --
|
|
2273
|
-
" --
|
|
2274
|
-
" --
|
|
2275
|
-
" --
|
|
2276
|
-
" --
|
|
1094
|
+
"Remote test options:",
|
|
1095
|
+
" --remote Run tests remotely (required)",
|
|
1096
|
+
" --token <key> API key (or set CANARY_API_TOKEN)",
|
|
1097
|
+
" --api-url <url> API URL (default: https://api.trycanary.ai)",
|
|
1098
|
+
" --tag <tag> Filter workflows by tag",
|
|
1099
|
+
" --name-pattern <pat> Filter workflows by name pattern",
|
|
1100
|
+
" --verbose, -v Show all events",
|
|
2277
1101
|
"",
|
|
2278
|
-
"
|
|
2279
|
-
"
|
|
1102
|
+
"Examples:",
|
|
1103
|
+
" canary test Run all local tests",
|
|
1104
|
+
' canary test --grep "login" Run tests matching "login"',
|
|
1105
|
+
" canary test --headed --workers 1 Debug with visible browser",
|
|
1106
|
+
" canary test --remote --tag smoke Run remote smoke tests"
|
|
2280
1107
|
].join("\n")
|
|
2281
1108
|
);
|
|
2282
1109
|
}
|
|
1110
|
+
var COMMANDS_WITH_HELP = /* @__PURE__ */ new Set(["test"]);
|
|
1111
|
+
async function resolveToken() {
|
|
1112
|
+
return process7.env.CANARY_API_TOKEN ?? await readStoredToken();
|
|
1113
|
+
}
|
|
2283
1114
|
async function main(argv) {
|
|
2284
|
-
if (argv.includes("--
|
|
2285
|
-
|
|
1115
|
+
if (argv.includes("--version") || argv.includes("-V")) {
|
|
1116
|
+
printVersion();
|
|
2286
1117
|
return;
|
|
2287
1118
|
}
|
|
2288
1119
|
const [command, ...rest] = argv;
|
|
1120
|
+
const hasHelpFlag = argv.includes("--help") || argv.includes("-h");
|
|
1121
|
+
if (hasHelpFlag && (!command || !COMMANDS_WITH_HELP.has(command))) {
|
|
1122
|
+
const token2 = await resolveToken();
|
|
1123
|
+
printHelp({ isSuperadmin: isSuperadminToken(token2) });
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
2289
1126
|
if (!command || command === "help") {
|
|
2290
|
-
|
|
1127
|
+
const token2 = await resolveToken();
|
|
1128
|
+
printHelp({ isSuperadmin: isSuperadminToken(token2) });
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
if (command === "version") {
|
|
1132
|
+
printVersion();
|
|
2291
1133
|
return;
|
|
2292
1134
|
}
|
|
2293
1135
|
if (command === "test") {
|
|
1136
|
+
if (rest.includes("--help") || rest.includes("-h")) {
|
|
1137
|
+
printTestHelp();
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
2294
1140
|
if (rest.includes("--remote")) {
|
|
2295
1141
|
const remoteArgs = rest.filter((arg) => arg !== "--remote");
|
|
2296
1142
|
await runRemoteTest(remoteArgs);
|
|
@@ -2308,8 +1154,8 @@ async function main(argv) {
|
|
|
2308
1154
|
return;
|
|
2309
1155
|
}
|
|
2310
1156
|
if (command === "mcp") {
|
|
2311
|
-
const
|
|
2312
|
-
await
|
|
1157
|
+
const runMcp = await loadMcp();
|
|
1158
|
+
await runMcp(rest);
|
|
2313
1159
|
return;
|
|
2314
1160
|
}
|
|
2315
1161
|
if (command === "tunnel") {
|
|
@@ -2320,17 +1166,41 @@ async function main(argv) {
|
|
|
2320
1166
|
await runLogin(rest);
|
|
2321
1167
|
return;
|
|
2322
1168
|
}
|
|
1169
|
+
if (command === "orgs") {
|
|
1170
|
+
await runOrgs(rest);
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
2323
1173
|
if (command === "browser") {
|
|
2324
|
-
const
|
|
2325
|
-
await
|
|
1174
|
+
const runLocalBrowser = await loadLocalBrowser();
|
|
1175
|
+
await runLocalBrowser(rest);
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
if (command === "debug-session") {
|
|
1179
|
+
await runDebugSession(rest);
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
1182
|
+
if (command === "psql") {
|
|
1183
|
+
const { runPsql } = await import("./psql-7AEFGJWI.js");
|
|
1184
|
+
await runPsql(rest);
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
if (command === "redis") {
|
|
1188
|
+
const { runRedis } = await import("./redis-BXYEPX4T.js");
|
|
1189
|
+
await runRedis(rest);
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
if (command === "feature-flag") {
|
|
1193
|
+
const { runFeatureFlag } = await import("./feature-flag-43WAHIUZ.js");
|
|
1194
|
+
await runFeatureFlag(rest);
|
|
2326
1195
|
return;
|
|
2327
1196
|
}
|
|
2328
1197
|
console.log(`Unknown command "${command}".`);
|
|
2329
|
-
|
|
2330
|
-
|
|
1198
|
+
const token = await resolveToken();
|
|
1199
|
+
printHelp({ isSuperadmin: isSuperadminToken(token) });
|
|
1200
|
+
process7.exit(1);
|
|
2331
1201
|
}
|
|
2332
|
-
if (import.meta.url === pathToFileURL2(
|
|
2333
|
-
void main(
|
|
1202
|
+
if (import.meta.url === pathToFileURL2(process7.argv[1]).href) {
|
|
1203
|
+
void main(process7.argv.slice(2));
|
|
2334
1204
|
}
|
|
2335
1205
|
export {
|
|
2336
1206
|
canary,
|