@canaryai/cli 0.1.1-alpha.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +1377 -41
- package/dist/bin.js.map +1 -1
- package/dist/index.js +1377 -41
- package/dist/index.js.map +1 -1
- package/dist/runner/preload.js +1 -1
- package/dist/runner/preload.js.map +1 -1
- package/dist/test.js +120 -147923
- package/dist/test.js.map +1 -1
- package/package.json +13 -1
- package/dist/fsevents-72LCIACT.node +0 -0
package/dist/index.js
CHANGED
|
@@ -8,7 +8,7 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
|
|
|
8
8
|
|
|
9
9
|
// src/index.ts
|
|
10
10
|
import { spawnSync as spawnSync2 } from "child_process";
|
|
11
|
-
import
|
|
11
|
+
import process9 from "process";
|
|
12
12
|
import path5 from "path";
|
|
13
13
|
import { fileURLToPath as fileURLToPath2, pathToFileURL as pathToFileURL2 } from "url";
|
|
14
14
|
|
|
@@ -426,24 +426,67 @@ async function runTunnel(argv) {
|
|
|
426
426
|
console.error("Missing token. Run `canary login` first or set CANARY_API_TOKEN.");
|
|
427
427
|
process3.exit(1);
|
|
428
428
|
}
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
429
|
+
const maxReconnectAttempts = 10;
|
|
430
|
+
const baseReconnectDelayMs = 1e3;
|
|
431
|
+
let reconnectAttempts = 0;
|
|
432
|
+
const connect = async () => {
|
|
433
|
+
try {
|
|
434
|
+
const data = await createTunnel({
|
|
435
|
+
apiUrl,
|
|
436
|
+
token,
|
|
437
|
+
port
|
|
438
|
+
});
|
|
439
|
+
console.log(`Tunnel connected: ${data.publicUrl ?? data.tunnelId}`);
|
|
440
|
+
if (data.publicUrl) {
|
|
441
|
+
console.log(`Public URL: ${data.publicUrl}`);
|
|
442
|
+
console.log("");
|
|
443
|
+
console.log("To use this tunnel for sandbox agent callbacks, add to apps/api/.env:");
|
|
444
|
+
console.log(` SANDBOX_AGENT_API_URL=${data.publicUrl}`);
|
|
445
|
+
console.log("");
|
|
446
|
+
}
|
|
447
|
+
const ws = connectTunnel({
|
|
448
|
+
apiUrl,
|
|
449
|
+
tunnelId: data.tunnelId,
|
|
450
|
+
token: data.token,
|
|
451
|
+
port,
|
|
452
|
+
onReady: () => {
|
|
453
|
+
reconnectAttempts = 0;
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
return new Promise((resolve, reject) => {
|
|
457
|
+
ws.onclose = (event) => {
|
|
458
|
+
console.log(`Tunnel closed (code: ${event.code})`);
|
|
459
|
+
if (reconnectAttempts < maxReconnectAttempts) {
|
|
460
|
+
const delay = Math.min(baseReconnectDelayMs * Math.pow(2, reconnectAttempts), 3e4);
|
|
461
|
+
reconnectAttempts++;
|
|
462
|
+
console.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempts}/${maxReconnectAttempts})...`);
|
|
463
|
+
setTimeout(() => {
|
|
464
|
+
connect().then(resolve).catch(reject);
|
|
465
|
+
}, delay);
|
|
466
|
+
} else {
|
|
467
|
+
console.error("Max reconnection attempts reached. Exiting.");
|
|
468
|
+
process3.exit(1);
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
ws.onerror = (event) => {
|
|
472
|
+
console.error("Tunnel error:", event);
|
|
473
|
+
};
|
|
474
|
+
});
|
|
475
|
+
} catch (error) {
|
|
476
|
+
if (reconnectAttempts < maxReconnectAttempts) {
|
|
477
|
+
const delay = Math.min(baseReconnectDelayMs * Math.pow(2, reconnectAttempts), 3e4);
|
|
478
|
+
reconnectAttempts++;
|
|
479
|
+
console.error(`Failed to create tunnel: ${error}`);
|
|
480
|
+
console.log(`Retrying in ${delay}ms (attempt ${reconnectAttempts}/${maxReconnectAttempts})...`);
|
|
481
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
482
|
+
return connect();
|
|
483
|
+
} else {
|
|
484
|
+
console.error("Max reconnection attempts reached. Exiting.");
|
|
485
|
+
process3.exit(1);
|
|
486
|
+
}
|
|
444
487
|
}
|
|
445
488
|
};
|
|
446
|
-
await
|
|
489
|
+
await connect();
|
|
447
490
|
}
|
|
448
491
|
async function createTunnel(input) {
|
|
449
492
|
const response = await fetch(`${input.apiUrl}/local-tests/tunnels`, {
|
|
@@ -468,6 +511,8 @@ function connectTunnel(input) {
|
|
|
468
511
|
`${input.apiUrl}/local-tests/tunnels/${input.tunnelId}/connect?token=${input.token}`
|
|
469
512
|
);
|
|
470
513
|
const ws = new WebSocket(wsUrl);
|
|
514
|
+
const wsConnections = /* @__PURE__ */ new Map();
|
|
515
|
+
const wsQueues = /* @__PURE__ */ new Map();
|
|
471
516
|
ws.onopen = () => {
|
|
472
517
|
input.onReady?.();
|
|
473
518
|
};
|
|
@@ -495,11 +540,22 @@ function connectTunnel(input) {
|
|
|
495
540
|
body: body ?? void 0
|
|
496
541
|
});
|
|
497
542
|
const resBody = await res.arrayBuffer();
|
|
543
|
+
const resHeaders = Object.fromEntries(res.headers.entries());
|
|
544
|
+
delete resHeaders["set-cookie"];
|
|
545
|
+
const getSetCookie = res.headers.getSetCookie;
|
|
546
|
+
const setCookieValues = typeof getSetCookie === "function" ? getSetCookie.call(res.headers) : [];
|
|
547
|
+
const fallbackSetCookie = res.headers.get("set-cookie");
|
|
548
|
+
if (setCookieValues.length === 0 && fallbackSetCookie) {
|
|
549
|
+
setCookieValues.push(fallbackSetCookie);
|
|
550
|
+
}
|
|
551
|
+
if (setCookieValues.length > 0) {
|
|
552
|
+
resHeaders["set-cookie"] = setCookieValues;
|
|
553
|
+
}
|
|
498
554
|
const responsePayload = {
|
|
499
555
|
type: "http_response",
|
|
500
556
|
id: request.id,
|
|
501
557
|
status: res.status,
|
|
502
|
-
headers:
|
|
558
|
+
headers: resHeaders,
|
|
503
559
|
bodyBase64: resBody.byteLength ? Buffer.from(resBody).toString("base64") : null
|
|
504
560
|
};
|
|
505
561
|
ws.send(JSON.stringify(responsePayload));
|
|
@@ -514,6 +570,82 @@ function connectTunnel(input) {
|
|
|
514
570
|
ws.send(JSON.stringify(responsePayload));
|
|
515
571
|
}
|
|
516
572
|
}
|
|
573
|
+
if (payload.type === "ws_open") {
|
|
574
|
+
const request = payload;
|
|
575
|
+
const targetUrl = `ws://localhost:${input.port}${request.path.startsWith("/") ? request.path : `/${request.path}`}`;
|
|
576
|
+
const protocolsHeader = request.headers["sec-websocket-protocol"] ?? request.headers["Sec-WebSocket-Protocol"];
|
|
577
|
+
const protocols = protocolsHeader ? protocolsHeader.split(",").map((value) => value.trim()).filter(Boolean) : void 0;
|
|
578
|
+
const localWs = new WebSocket(targetUrl, protocols);
|
|
579
|
+
wsConnections.set(request.id, localWs);
|
|
580
|
+
localWs.onopen = () => {
|
|
581
|
+
ws.send(JSON.stringify({ type: "ws_ready", id: request.id }));
|
|
582
|
+
const queued = wsQueues.get(request.id);
|
|
583
|
+
if (queued) {
|
|
584
|
+
for (const message of queued) {
|
|
585
|
+
ws.send(JSON.stringify(message));
|
|
586
|
+
}
|
|
587
|
+
wsQueues.delete(request.id);
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
localWs.onmessage = (event2) => {
|
|
591
|
+
const data = typeof event2.data === "string" ? Buffer.from(event2.data) : Buffer.from(event2.data);
|
|
592
|
+
const response = {
|
|
593
|
+
type: "ws_message",
|
|
594
|
+
id: request.id,
|
|
595
|
+
dataBase64: data.toString("base64"),
|
|
596
|
+
isBinary: typeof event2.data !== "string"
|
|
597
|
+
};
|
|
598
|
+
ws.send(JSON.stringify(response));
|
|
599
|
+
};
|
|
600
|
+
localWs.onclose = (event2) => {
|
|
601
|
+
wsConnections.delete(request.id);
|
|
602
|
+
const response = {
|
|
603
|
+
type: "ws_close",
|
|
604
|
+
id: request.id,
|
|
605
|
+
code: event2.code,
|
|
606
|
+
reason: event2.reason
|
|
607
|
+
};
|
|
608
|
+
ws.send(JSON.stringify(response));
|
|
609
|
+
};
|
|
610
|
+
localWs.onerror = () => {
|
|
611
|
+
wsConnections.delete(request.id);
|
|
612
|
+
const response = {
|
|
613
|
+
type: "ws_close",
|
|
614
|
+
id: request.id,
|
|
615
|
+
code: 1011,
|
|
616
|
+
reason: "local_ws_error"
|
|
617
|
+
};
|
|
618
|
+
ws.send(JSON.stringify(response));
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
if (payload.type === "ws_message") {
|
|
622
|
+
const message = payload;
|
|
623
|
+
const localWs = wsConnections.get(message.id);
|
|
624
|
+
const data = Buffer.from(message.dataBase64, "base64");
|
|
625
|
+
if (!localWs || localWs.readyState !== WebSocket.OPEN) {
|
|
626
|
+
const queued = wsQueues.get(message.id) ?? [];
|
|
627
|
+
queued.push(message);
|
|
628
|
+
wsQueues.set(message.id, queued);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
if (message.isBinary) {
|
|
632
|
+
localWs.send(data);
|
|
633
|
+
} else {
|
|
634
|
+
localWs.send(data.toString());
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
if (payload.type === "ws_close") {
|
|
638
|
+
const message = payload;
|
|
639
|
+
const localWs = wsConnections.get(message.id);
|
|
640
|
+
if (!localWs) {
|
|
641
|
+
const queued = wsQueues.get(message.id) ?? [];
|
|
642
|
+
queued.push(message);
|
|
643
|
+
wsQueues.set(message.id, queued);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
localWs.close(message.code ?? 1e3, message.reason ?? "");
|
|
647
|
+
wsConnections.delete(message.id);
|
|
648
|
+
}
|
|
517
649
|
if (payload.type === "health_ping") {
|
|
518
650
|
ws.send(JSON.stringify({ type: "health_pong" }));
|
|
519
651
|
}
|
|
@@ -693,6 +825,676 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
693
825
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
694
826
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
695
827
|
import { createParser } from "eventsource-parser";
|
|
828
|
+
|
|
829
|
+
// src/local-browser/host.ts
|
|
830
|
+
import { chromium } from "playwright";
|
|
831
|
+
var HEARTBEAT_INTERVAL_MS = 3e4;
|
|
832
|
+
var RECONNECT_DELAY_MS = 1e3;
|
|
833
|
+
var MAX_RECONNECT_DELAY_MS = 3e4;
|
|
834
|
+
var MAX_RECONNECT_ATTEMPTS = 10;
|
|
835
|
+
var LocalBrowserHost = class {
|
|
836
|
+
options;
|
|
837
|
+
ws = null;
|
|
838
|
+
browser = null;
|
|
839
|
+
context = null;
|
|
840
|
+
page = null;
|
|
841
|
+
pendingDialogs = [];
|
|
842
|
+
heartbeatTimer = null;
|
|
843
|
+
reconnectAttempts = 0;
|
|
844
|
+
isShuttingDown = false;
|
|
845
|
+
lastSnapshotYaml = "";
|
|
846
|
+
constructor(options) {
|
|
847
|
+
this.options = options;
|
|
848
|
+
}
|
|
849
|
+
log(level, message, data) {
|
|
850
|
+
if (this.options.onLog) {
|
|
851
|
+
this.options.onLog(level, message, data);
|
|
852
|
+
} else {
|
|
853
|
+
const fn = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
|
|
854
|
+
fn(`[LocalBrowserHost] ${message}`, data ?? "");
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
// =========================================================================
|
|
858
|
+
// Lifecycle
|
|
859
|
+
// =========================================================================
|
|
860
|
+
async start() {
|
|
861
|
+
this.log("info", "Starting local browser host", {
|
|
862
|
+
browserMode: this.options.browserMode,
|
|
863
|
+
sessionId: this.options.sessionId
|
|
864
|
+
});
|
|
865
|
+
await this.connectWebSocket();
|
|
866
|
+
await this.launchBrowser();
|
|
867
|
+
this.sendSessionEvent("browser_ready");
|
|
868
|
+
}
|
|
869
|
+
async stop() {
|
|
870
|
+
this.isShuttingDown = true;
|
|
871
|
+
this.log("info", "Stopping local browser host");
|
|
872
|
+
this.stopHeartbeat();
|
|
873
|
+
if (this.ws) {
|
|
874
|
+
try {
|
|
875
|
+
this.ws.close(1e3, "Shutdown");
|
|
876
|
+
} catch {
|
|
877
|
+
}
|
|
878
|
+
this.ws = null;
|
|
879
|
+
}
|
|
880
|
+
if (this.context) {
|
|
881
|
+
try {
|
|
882
|
+
await this.context.close();
|
|
883
|
+
} catch {
|
|
884
|
+
}
|
|
885
|
+
this.context = null;
|
|
886
|
+
}
|
|
887
|
+
if (this.browser) {
|
|
888
|
+
try {
|
|
889
|
+
await this.browser.close();
|
|
890
|
+
} catch {
|
|
891
|
+
}
|
|
892
|
+
this.browser = null;
|
|
893
|
+
}
|
|
894
|
+
this.page = null;
|
|
895
|
+
this.log("info", "Local browser host stopped");
|
|
896
|
+
}
|
|
897
|
+
// =========================================================================
|
|
898
|
+
// WebSocket Connection
|
|
899
|
+
// =========================================================================
|
|
900
|
+
async connectWebSocket() {
|
|
901
|
+
return new Promise((resolve, reject) => {
|
|
902
|
+
const wsUrl = `${this.options.apiUrl.replace("http", "ws")}/local-browser/sessions/${this.options.sessionId}/connect?token=${this.options.wsToken}`;
|
|
903
|
+
this.log("info", "Connecting to cloud API", { url: wsUrl.replace(/token=.*/, "token=***") });
|
|
904
|
+
const ws = new WebSocket(wsUrl);
|
|
905
|
+
ws.onopen = () => {
|
|
906
|
+
this.log("info", "Connected to cloud API");
|
|
907
|
+
this.ws = ws;
|
|
908
|
+
this.reconnectAttempts = 0;
|
|
909
|
+
this.startHeartbeat();
|
|
910
|
+
resolve();
|
|
911
|
+
};
|
|
912
|
+
ws.onmessage = (event) => {
|
|
913
|
+
this.handleMessage(event.data);
|
|
914
|
+
};
|
|
915
|
+
ws.onerror = (event) => {
|
|
916
|
+
this.log("error", "WebSocket error", event);
|
|
917
|
+
};
|
|
918
|
+
ws.onclose = () => {
|
|
919
|
+
this.log("info", "WebSocket closed");
|
|
920
|
+
this.stopHeartbeat();
|
|
921
|
+
this.ws = null;
|
|
922
|
+
if (!this.isShuttingDown) {
|
|
923
|
+
this.scheduleReconnect();
|
|
924
|
+
}
|
|
925
|
+
};
|
|
926
|
+
setTimeout(() => {
|
|
927
|
+
if (!this.ws) {
|
|
928
|
+
reject(new Error("WebSocket connection timeout"));
|
|
929
|
+
}
|
|
930
|
+
}, 3e4);
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
scheduleReconnect() {
|
|
934
|
+
if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
935
|
+
this.log("error", "Max reconnection attempts reached, giving up");
|
|
936
|
+
this.stop();
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
const delay = Math.min(
|
|
940
|
+
RECONNECT_DELAY_MS * Math.pow(2, this.reconnectAttempts),
|
|
941
|
+
MAX_RECONNECT_DELAY_MS
|
|
942
|
+
);
|
|
943
|
+
this.reconnectAttempts++;
|
|
944
|
+
this.log("info", `Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
|
945
|
+
setTimeout(async () => {
|
|
946
|
+
try {
|
|
947
|
+
await this.connectWebSocket();
|
|
948
|
+
this.sendSessionEvent("connected");
|
|
949
|
+
if (this.page) {
|
|
950
|
+
this.sendSessionEvent("browser_ready");
|
|
951
|
+
}
|
|
952
|
+
} catch (error) {
|
|
953
|
+
this.log("error", "Reconnection failed", error);
|
|
954
|
+
this.scheduleReconnect();
|
|
955
|
+
}
|
|
956
|
+
}, delay);
|
|
957
|
+
}
|
|
958
|
+
// =========================================================================
|
|
959
|
+
// Heartbeat
|
|
960
|
+
// =========================================================================
|
|
961
|
+
startHeartbeat() {
|
|
962
|
+
this.stopHeartbeat();
|
|
963
|
+
this.heartbeatTimer = setInterval(() => {
|
|
964
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
965
|
+
const ping = {
|
|
966
|
+
type: "heartbeat",
|
|
967
|
+
id: crypto.randomUUID(),
|
|
968
|
+
timestamp: Date.now(),
|
|
969
|
+
direction: "pong"
|
|
970
|
+
};
|
|
971
|
+
this.ws.send(JSON.stringify(ping));
|
|
972
|
+
}
|
|
973
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
974
|
+
}
|
|
975
|
+
stopHeartbeat() {
|
|
976
|
+
if (this.heartbeatTimer) {
|
|
977
|
+
clearInterval(this.heartbeatTimer);
|
|
978
|
+
this.heartbeatTimer = null;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
// =========================================================================
|
|
982
|
+
// Browser Management
|
|
983
|
+
// =========================================================================
|
|
984
|
+
async launchBrowser() {
|
|
985
|
+
const { browserMode, cdpUrl, headless = true, storageStatePath } = this.options;
|
|
986
|
+
if (browserMode === "cdp" && cdpUrl) {
|
|
987
|
+
this.log("info", "Connecting to existing Chrome via CDP", { cdpUrl });
|
|
988
|
+
this.browser = await chromium.connectOverCDP(cdpUrl);
|
|
989
|
+
const contexts = this.browser.contexts();
|
|
990
|
+
this.context = contexts[0] ?? await this.browser.newContext();
|
|
991
|
+
const pages = this.context.pages();
|
|
992
|
+
this.page = pages[0] ?? await this.context.newPage();
|
|
993
|
+
} else {
|
|
994
|
+
this.log("info", "Launching new Playwright browser", { headless });
|
|
995
|
+
this.browser = await chromium.launch({
|
|
996
|
+
headless,
|
|
997
|
+
args: ["--no-sandbox"]
|
|
998
|
+
});
|
|
999
|
+
const contextOptions = {
|
|
1000
|
+
viewport: { width: 1920, height: 1080 }
|
|
1001
|
+
};
|
|
1002
|
+
if (storageStatePath) {
|
|
1003
|
+
try {
|
|
1004
|
+
await Bun.file(storageStatePath).exists();
|
|
1005
|
+
contextOptions.storageState = storageStatePath;
|
|
1006
|
+
this.log("info", "Loading storage state", { storageStatePath });
|
|
1007
|
+
} catch {
|
|
1008
|
+
this.log("debug", "Storage state file not found, starting fresh");
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
this.context = await this.browser.newContext(contextOptions);
|
|
1012
|
+
this.page = await this.context.newPage();
|
|
1013
|
+
}
|
|
1014
|
+
this.page.on("dialog", (dialog) => {
|
|
1015
|
+
this.pendingDialogs.push(dialog);
|
|
1016
|
+
});
|
|
1017
|
+
this.log("info", "Browser ready");
|
|
1018
|
+
}
|
|
1019
|
+
// =========================================================================
|
|
1020
|
+
// Message Handling
|
|
1021
|
+
// =========================================================================
|
|
1022
|
+
handleMessage(data) {
|
|
1023
|
+
try {
|
|
1024
|
+
const message = JSON.parse(data);
|
|
1025
|
+
if (message.type === "heartbeat" && message.direction === "ping") {
|
|
1026
|
+
const pong = {
|
|
1027
|
+
type: "heartbeat",
|
|
1028
|
+
id: crypto.randomUUID(),
|
|
1029
|
+
timestamp: Date.now(),
|
|
1030
|
+
direction: "pong"
|
|
1031
|
+
};
|
|
1032
|
+
this.ws?.send(JSON.stringify(pong));
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
if (message.type === "command") {
|
|
1036
|
+
this.handleCommand(message);
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
this.log("debug", "Received unknown message type", message);
|
|
1040
|
+
} catch (error) {
|
|
1041
|
+
this.log("error", "Failed to parse message", { error, data });
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
async handleCommand(command) {
|
|
1045
|
+
const startTime = Date.now();
|
|
1046
|
+
this.log("debug", `Executing command: ${command.method}`, { id: command.id });
|
|
1047
|
+
try {
|
|
1048
|
+
const result = await this.executeMethod(command.method, command.args);
|
|
1049
|
+
const response = {
|
|
1050
|
+
type: "response",
|
|
1051
|
+
id: crypto.randomUUID(),
|
|
1052
|
+
timestamp: Date.now(),
|
|
1053
|
+
requestId: command.id,
|
|
1054
|
+
success: true,
|
|
1055
|
+
result
|
|
1056
|
+
};
|
|
1057
|
+
this.ws?.send(JSON.stringify(response));
|
|
1058
|
+
this.log("debug", `Command completed: ${command.method}`, {
|
|
1059
|
+
id: command.id,
|
|
1060
|
+
durationMs: Date.now() - startTime
|
|
1061
|
+
});
|
|
1062
|
+
} catch (error) {
|
|
1063
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1064
|
+
const response = {
|
|
1065
|
+
type: "response",
|
|
1066
|
+
id: crypto.randomUUID(),
|
|
1067
|
+
timestamp: Date.now(),
|
|
1068
|
+
requestId: command.id,
|
|
1069
|
+
success: false,
|
|
1070
|
+
error: errorMessage,
|
|
1071
|
+
stack: error instanceof Error ? error.stack : void 0
|
|
1072
|
+
};
|
|
1073
|
+
this.ws?.send(JSON.stringify(response));
|
|
1074
|
+
this.log("error", `Command failed: ${command.method}`, {
|
|
1075
|
+
id: command.id,
|
|
1076
|
+
error: errorMessage
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
sendSessionEvent(event, error) {
|
|
1081
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
1082
|
+
const message = {
|
|
1083
|
+
type: "session",
|
|
1084
|
+
id: crypto.randomUUID(),
|
|
1085
|
+
timestamp: Date.now(),
|
|
1086
|
+
event,
|
|
1087
|
+
browserMode: this.options.browserMode,
|
|
1088
|
+
error
|
|
1089
|
+
};
|
|
1090
|
+
this.ws.send(JSON.stringify(message));
|
|
1091
|
+
}
|
|
1092
|
+
// =========================================================================
|
|
1093
|
+
// Method Execution
|
|
1094
|
+
// =========================================================================
|
|
1095
|
+
async executeMethod(method, args) {
|
|
1096
|
+
switch (method) {
|
|
1097
|
+
// Lifecycle
|
|
1098
|
+
case "connect":
|
|
1099
|
+
return this.connect(args[0]);
|
|
1100
|
+
case "disconnect":
|
|
1101
|
+
return this.disconnect();
|
|
1102
|
+
// Navigation
|
|
1103
|
+
case "navigate":
|
|
1104
|
+
return this.navigate(args[0], args[1]);
|
|
1105
|
+
case "navigateBack":
|
|
1106
|
+
return this.navigateBack(args[0]);
|
|
1107
|
+
// Page Inspection
|
|
1108
|
+
case "snapshot":
|
|
1109
|
+
return this.snapshot(args[0]);
|
|
1110
|
+
case "takeScreenshot":
|
|
1111
|
+
return this.takeScreenshot(args[0]);
|
|
1112
|
+
case "evaluate":
|
|
1113
|
+
return this.evaluate(args[0], args[1]);
|
|
1114
|
+
case "runCode":
|
|
1115
|
+
return this.runCode(args[0], args[1]);
|
|
1116
|
+
case "consoleMessages":
|
|
1117
|
+
return this.consoleMessages(args[0]);
|
|
1118
|
+
case "networkRequests":
|
|
1119
|
+
return this.networkRequests(args[0]);
|
|
1120
|
+
// Interaction
|
|
1121
|
+
case "click":
|
|
1122
|
+
return this.click(args[0], args[1], args[2]);
|
|
1123
|
+
case "clickAtCoordinates":
|
|
1124
|
+
return this.clickAtCoordinates(
|
|
1125
|
+
args[0],
|
|
1126
|
+
args[1],
|
|
1127
|
+
args[2],
|
|
1128
|
+
args[3]
|
|
1129
|
+
);
|
|
1130
|
+
case "moveToCoordinates":
|
|
1131
|
+
return this.moveToCoordinates(
|
|
1132
|
+
args[0],
|
|
1133
|
+
args[1],
|
|
1134
|
+
args[2],
|
|
1135
|
+
args[3]
|
|
1136
|
+
);
|
|
1137
|
+
case "dragCoordinates":
|
|
1138
|
+
return this.dragCoordinates(
|
|
1139
|
+
args[0],
|
|
1140
|
+
args[1],
|
|
1141
|
+
args[2],
|
|
1142
|
+
args[3],
|
|
1143
|
+
args[4],
|
|
1144
|
+
args[5]
|
|
1145
|
+
);
|
|
1146
|
+
case "hover":
|
|
1147
|
+
return this.hover(args[0], args[1], args[2]);
|
|
1148
|
+
case "drag":
|
|
1149
|
+
return this.drag(
|
|
1150
|
+
args[0],
|
|
1151
|
+
args[1],
|
|
1152
|
+
args[2],
|
|
1153
|
+
args[3],
|
|
1154
|
+
args[4]
|
|
1155
|
+
);
|
|
1156
|
+
case "type":
|
|
1157
|
+
return this.type(
|
|
1158
|
+
args[0],
|
|
1159
|
+
args[1],
|
|
1160
|
+
args[2],
|
|
1161
|
+
args[3],
|
|
1162
|
+
args[4]
|
|
1163
|
+
);
|
|
1164
|
+
case "pressKey":
|
|
1165
|
+
return this.pressKey(args[0], args[1]);
|
|
1166
|
+
case "fillForm":
|
|
1167
|
+
return this.fillForm(args[0], args[1]);
|
|
1168
|
+
case "selectOption":
|
|
1169
|
+
return this.selectOption(
|
|
1170
|
+
args[0],
|
|
1171
|
+
args[1],
|
|
1172
|
+
args[2],
|
|
1173
|
+
args[3]
|
|
1174
|
+
);
|
|
1175
|
+
case "fileUpload":
|
|
1176
|
+
return this.fileUpload(args[0], args[1]);
|
|
1177
|
+
// Dialogs
|
|
1178
|
+
case "handleDialog":
|
|
1179
|
+
return this.handleDialog(args[0], args[1], args[2]);
|
|
1180
|
+
// Waiting
|
|
1181
|
+
case "waitFor":
|
|
1182
|
+
return this.waitFor(args[0]);
|
|
1183
|
+
// Browser Management
|
|
1184
|
+
case "close":
|
|
1185
|
+
return this.closePage(args[0]);
|
|
1186
|
+
case "resize":
|
|
1187
|
+
return this.resize(args[0], args[1], args[2]);
|
|
1188
|
+
case "tabs":
|
|
1189
|
+
return this.tabs(args[0], args[1], args[2]);
|
|
1190
|
+
// Storage
|
|
1191
|
+
case "getStorageState":
|
|
1192
|
+
return this.getStorageState(args[0]);
|
|
1193
|
+
case "getCurrentUrl":
|
|
1194
|
+
return this.getCurrentUrl(args[0]);
|
|
1195
|
+
case "getTitle":
|
|
1196
|
+
return this.getTitle(args[0]);
|
|
1197
|
+
case "getLinks":
|
|
1198
|
+
return this.getLinks(args[0]);
|
|
1199
|
+
case "getElementBoundingBox":
|
|
1200
|
+
return this.getElementBoundingBox(args[0], args[1]);
|
|
1201
|
+
// Tracing
|
|
1202
|
+
case "startTracing":
|
|
1203
|
+
return this.startTracing(args[0]);
|
|
1204
|
+
case "stopTracing":
|
|
1205
|
+
return this.stopTracing(args[0]);
|
|
1206
|
+
// Video
|
|
1207
|
+
case "isVideoRecordingEnabled":
|
|
1208
|
+
return false;
|
|
1209
|
+
// Video not supported in CLI host currently
|
|
1210
|
+
case "saveVideo":
|
|
1211
|
+
return null;
|
|
1212
|
+
case "getVideoPath":
|
|
1213
|
+
return null;
|
|
1214
|
+
default:
|
|
1215
|
+
throw new Error(`Unknown method: ${method}`);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
// =========================================================================
|
|
1219
|
+
// IBrowserClient Method Implementations
|
|
1220
|
+
// =========================================================================
|
|
1221
|
+
getPage() {
|
|
1222
|
+
if (!this.page) throw new Error("No page available");
|
|
1223
|
+
return this.page;
|
|
1224
|
+
}
|
|
1225
|
+
resolveRef(ref) {
|
|
1226
|
+
return this.getPage().locator(`aria-ref=${ref}`);
|
|
1227
|
+
}
|
|
1228
|
+
async connect(_options) {
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
async disconnect() {
|
|
1232
|
+
await this.stop();
|
|
1233
|
+
}
|
|
1234
|
+
async navigate(url, _opts) {
|
|
1235
|
+
const page = this.getPage();
|
|
1236
|
+
await page.goto(url, { waitUntil: "domcontentloaded" });
|
|
1237
|
+
await page.waitForLoadState("load", { timeout: 5e3 }).catch(() => {
|
|
1238
|
+
});
|
|
1239
|
+
return this.captureSnapshot();
|
|
1240
|
+
}
|
|
1241
|
+
async navigateBack(_opts) {
|
|
1242
|
+
await this.getPage().goBack();
|
|
1243
|
+
return this.captureSnapshot();
|
|
1244
|
+
}
|
|
1245
|
+
async snapshot(_opts) {
|
|
1246
|
+
return this.captureSnapshot();
|
|
1247
|
+
}
|
|
1248
|
+
async captureSnapshot() {
|
|
1249
|
+
const page = this.getPage();
|
|
1250
|
+
this.lastSnapshotYaml = await page._snapshotForAI({ mode: "full" });
|
|
1251
|
+
return this.lastSnapshotYaml;
|
|
1252
|
+
}
|
|
1253
|
+
async takeScreenshot(opts) {
|
|
1254
|
+
const page = this.getPage();
|
|
1255
|
+
const buffer = await page.screenshot({
|
|
1256
|
+
type: opts?.type ?? "jpeg",
|
|
1257
|
+
fullPage: opts?.fullPage ?? false
|
|
1258
|
+
});
|
|
1259
|
+
const mime = opts?.type === "png" ? "image/png" : "image/jpeg";
|
|
1260
|
+
return `data:${mime};base64,${buffer.toString("base64")}`;
|
|
1261
|
+
}
|
|
1262
|
+
async evaluate(fn, _opts) {
|
|
1263
|
+
const page = this.getPage();
|
|
1264
|
+
return page.evaluate(new Function(`return (${fn})()`));
|
|
1265
|
+
}
|
|
1266
|
+
async runCode(code, _opts) {
|
|
1267
|
+
const page = this.getPage();
|
|
1268
|
+
const fn = new Function("page", `return (async () => { ${code} })()`);
|
|
1269
|
+
return fn(page);
|
|
1270
|
+
}
|
|
1271
|
+
async consoleMessages(_opts) {
|
|
1272
|
+
return "Console message capture not implemented in CLI host";
|
|
1273
|
+
}
|
|
1274
|
+
async networkRequests(_opts) {
|
|
1275
|
+
return "Network request capture not implemented in CLI host";
|
|
1276
|
+
}
|
|
1277
|
+
async click(ref, _elementDesc, opts) {
|
|
1278
|
+
const locator = this.resolveRef(ref);
|
|
1279
|
+
await locator.scrollIntoViewIfNeeded({ timeout: 5e3 }).catch(() => {
|
|
1280
|
+
});
|
|
1281
|
+
const box = await locator.boundingBox();
|
|
1282
|
+
if (box) {
|
|
1283
|
+
const centerX = box.x + box.width / 2;
|
|
1284
|
+
const centerY = box.y + box.height / 2;
|
|
1285
|
+
const page = this.getPage();
|
|
1286
|
+
if (opts?.modifiers?.length) {
|
|
1287
|
+
for (const mod of opts.modifiers) {
|
|
1288
|
+
await page.keyboard.down(mod);
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
if (opts?.doubleClick) {
|
|
1292
|
+
await page.mouse.dblclick(centerX, centerY);
|
|
1293
|
+
} else {
|
|
1294
|
+
await page.mouse.click(centerX, centerY);
|
|
1295
|
+
}
|
|
1296
|
+
if (opts?.modifiers?.length) {
|
|
1297
|
+
for (const mod of opts.modifiers) {
|
|
1298
|
+
await page.keyboard.up(mod);
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
} else {
|
|
1302
|
+
if (opts?.doubleClick) {
|
|
1303
|
+
await locator.dblclick({ timeout: opts?.timeoutMs ?? 3e4 });
|
|
1304
|
+
} else {
|
|
1305
|
+
await locator.click({ timeout: opts?.timeoutMs ?? 3e4 });
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
async clickAtCoordinates(x, y, _elementDesc, opts) {
|
|
1310
|
+
const page = this.getPage();
|
|
1311
|
+
if (opts?.doubleClick) {
|
|
1312
|
+
await page.mouse.dblclick(x, y);
|
|
1313
|
+
} else {
|
|
1314
|
+
await page.mouse.click(x, y);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
async moveToCoordinates(x, y, _elementDesc, _opts) {
|
|
1318
|
+
await this.getPage().mouse.move(x, y);
|
|
1319
|
+
}
|
|
1320
|
+
async dragCoordinates(startX, startY, endX, endY, _elementDesc, _opts) {
|
|
1321
|
+
const page = this.getPage();
|
|
1322
|
+
await page.mouse.move(startX, startY);
|
|
1323
|
+
await page.mouse.down();
|
|
1324
|
+
await page.mouse.move(endX, endY);
|
|
1325
|
+
await page.mouse.up();
|
|
1326
|
+
}
|
|
1327
|
+
async hover(ref, _elementDesc, opts) {
|
|
1328
|
+
await this.resolveRef(ref).hover({ timeout: opts?.timeoutMs ?? 3e4 });
|
|
1329
|
+
}
|
|
1330
|
+
async drag(startRef, _startElement, endRef, _endElement, opts) {
|
|
1331
|
+
const startLocator = this.resolveRef(startRef);
|
|
1332
|
+
const endLocator = this.resolveRef(endRef);
|
|
1333
|
+
await startLocator.dragTo(endLocator, { timeout: opts?.timeoutMs ?? 6e4 });
|
|
1334
|
+
}
|
|
1335
|
+
async type(ref, text, _elementDesc, submit, opts) {
|
|
1336
|
+
const locator = this.resolveRef(ref);
|
|
1337
|
+
await locator.clear();
|
|
1338
|
+
await locator.pressSequentially(text, {
|
|
1339
|
+
delay: opts?.delay ?? 0,
|
|
1340
|
+
timeout: opts?.timeoutMs ?? 3e4
|
|
1341
|
+
});
|
|
1342
|
+
if (submit) {
|
|
1343
|
+
await locator.press("Enter");
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
async pressKey(key, _opts) {
|
|
1347
|
+
await this.getPage().keyboard.press(key);
|
|
1348
|
+
}
|
|
1349
|
+
async fillForm(fields, opts) {
|
|
1350
|
+
for (const field of fields) {
|
|
1351
|
+
const locator = this.resolveRef(field.ref);
|
|
1352
|
+
const fieldType = field.type ?? "textbox";
|
|
1353
|
+
switch (fieldType) {
|
|
1354
|
+
case "checkbox": {
|
|
1355
|
+
const isChecked = await locator.isChecked();
|
|
1356
|
+
const shouldBeChecked = field.value === "true";
|
|
1357
|
+
if (shouldBeChecked !== isChecked) {
|
|
1358
|
+
await locator.click({ timeout: opts?.timeoutMs ?? 3e4 });
|
|
1359
|
+
}
|
|
1360
|
+
break;
|
|
1361
|
+
}
|
|
1362
|
+
case "radio":
|
|
1363
|
+
await locator.check({ timeout: opts?.timeoutMs ?? 3e4 });
|
|
1364
|
+
break;
|
|
1365
|
+
case "combobox":
|
|
1366
|
+
await locator.selectOption(field.value, { timeout: opts?.timeoutMs ?? 3e4 });
|
|
1367
|
+
break;
|
|
1368
|
+
default:
|
|
1369
|
+
await locator.fill(field.value, { timeout: opts?.timeoutMs ?? 3e4 });
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
async selectOption(ref, value, _elementDesc, opts) {
|
|
1374
|
+
await this.resolveRef(ref).selectOption(value, { timeout: opts?.timeoutMs ?? 3e4 });
|
|
1375
|
+
}
|
|
1376
|
+
async fileUpload(paths, opts) {
|
|
1377
|
+
const fileChooser = await this.getPage().waitForEvent("filechooser", {
|
|
1378
|
+
timeout: opts?.timeoutMs ?? 3e4
|
|
1379
|
+
});
|
|
1380
|
+
await fileChooser.setFiles(paths);
|
|
1381
|
+
}
|
|
1382
|
+
async handleDialog(action, promptText, _opts) {
|
|
1383
|
+
const dialog = this.pendingDialogs.shift();
|
|
1384
|
+
if (dialog) {
|
|
1385
|
+
if (action === "accept") {
|
|
1386
|
+
await dialog.accept(promptText);
|
|
1387
|
+
} else {
|
|
1388
|
+
await dialog.dismiss();
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
async waitFor(opts) {
|
|
1393
|
+
const page = this.getPage();
|
|
1394
|
+
const timeout = opts?.timeout ?? opts?.timeoutMs ?? 3e4;
|
|
1395
|
+
if (opts?.timeSec) {
|
|
1396
|
+
await page.waitForTimeout(opts.timeSec * 1e3);
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
if (opts?.text) {
|
|
1400
|
+
await page.getByText(opts.text).first().waitFor({ state: "visible", timeout });
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
if (opts?.textGone) {
|
|
1404
|
+
await page.getByText(opts.textGone).first().waitFor({ state: "hidden", timeout });
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1407
|
+
if (opts?.selector) {
|
|
1408
|
+
await page.locator(opts.selector).waitFor({
|
|
1409
|
+
state: opts.state ?? "visible",
|
|
1410
|
+
timeout
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
async closePage(_opts) {
|
|
1415
|
+
await this.getPage().close();
|
|
1416
|
+
this.page = null;
|
|
1417
|
+
}
|
|
1418
|
+
async resize(width, height, _opts) {
|
|
1419
|
+
await this.getPage().setViewportSize({ width, height });
|
|
1420
|
+
}
|
|
1421
|
+
async tabs(action, index, _opts) {
|
|
1422
|
+
if (!this.context) throw new Error("No context available");
|
|
1423
|
+
const pages = this.context.pages();
|
|
1424
|
+
switch (action) {
|
|
1425
|
+
case "list":
|
|
1426
|
+
return Promise.all(
|
|
1427
|
+
pages.map(async (p, i) => ({
|
|
1428
|
+
index: i,
|
|
1429
|
+
url: p.url(),
|
|
1430
|
+
title: await p.title().catch(() => "")
|
|
1431
|
+
}))
|
|
1432
|
+
);
|
|
1433
|
+
case "new": {
|
|
1434
|
+
const newPage = await this.context.newPage();
|
|
1435
|
+
this.page = newPage;
|
|
1436
|
+
newPage.on("dialog", (dialog) => this.pendingDialogs.push(dialog));
|
|
1437
|
+
return { index: pages.length };
|
|
1438
|
+
}
|
|
1439
|
+
case "close":
|
|
1440
|
+
if (index !== void 0 && pages[index]) {
|
|
1441
|
+
await pages[index].close();
|
|
1442
|
+
} else {
|
|
1443
|
+
await this.page?.close();
|
|
1444
|
+
}
|
|
1445
|
+
this.page = this.context.pages()[0] ?? null;
|
|
1446
|
+
break;
|
|
1447
|
+
case "select":
|
|
1448
|
+
if (index !== void 0 && pages[index]) {
|
|
1449
|
+
this.page = pages[index];
|
|
1450
|
+
}
|
|
1451
|
+
break;
|
|
1452
|
+
}
|
|
1453
|
+
return null;
|
|
1454
|
+
}
|
|
1455
|
+
async getStorageState(_opts) {
|
|
1456
|
+
if (!this.context) throw new Error("No context available");
|
|
1457
|
+
return this.context.storageState();
|
|
1458
|
+
}
|
|
1459
|
+
async getCurrentUrl(_opts) {
|
|
1460
|
+
return this.getPage().url();
|
|
1461
|
+
}
|
|
1462
|
+
async getTitle(_opts) {
|
|
1463
|
+
return this.getPage().title();
|
|
1464
|
+
}
|
|
1465
|
+
async getLinks(_opts) {
|
|
1466
|
+
const page = this.getPage();
|
|
1467
|
+
return page.$$eval(
|
|
1468
|
+
"a[href]",
|
|
1469
|
+
(links) => links.map((a) => a.href).filter((h) => !!h && (h.startsWith("http://") || h.startsWith("https://")))
|
|
1470
|
+
);
|
|
1471
|
+
}
|
|
1472
|
+
async getElementBoundingBox(ref, _opts) {
|
|
1473
|
+
const locator = this.resolveRef(ref);
|
|
1474
|
+
const box = await locator.boundingBox();
|
|
1475
|
+
if (!box) return null;
|
|
1476
|
+
return { x: box.x, y: box.y, width: box.width, height: box.height };
|
|
1477
|
+
}
|
|
1478
|
+
async startTracing(_opts) {
|
|
1479
|
+
if (!this.context) throw new Error("No context available");
|
|
1480
|
+
await this.context.tracing.start({ screenshots: true, snapshots: true });
|
|
1481
|
+
}
|
|
1482
|
+
async stopTracing(_opts) {
|
|
1483
|
+
if (!this.context) throw new Error("No context available");
|
|
1484
|
+
const tracePath = `/tmp/trace-${Date.now()}.zip`;
|
|
1485
|
+
await this.context.tracing.stop({ path: tracePath });
|
|
1486
|
+
return {
|
|
1487
|
+
trace: tracePath,
|
|
1488
|
+
network: "",
|
|
1489
|
+
resources: "",
|
|
1490
|
+
directory: null,
|
|
1491
|
+
legend: `Trace saved to ${tracePath}`
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
};
|
|
1495
|
+
|
|
1496
|
+
// src/mcp.ts
|
|
1497
|
+
var browserSessions = /* @__PURE__ */ new Map();
|
|
696
1498
|
var DEFAULT_API_URL = "https://api.trycanary.ai";
|
|
697
1499
|
function resolveApiUrl(input) {
|
|
698
1500
|
return input ?? process6.env.CANARY_API_URL ?? DEFAULT_API_URL;
|
|
@@ -740,6 +1542,88 @@ async function runMcp(argv) {
|
|
|
740
1542
|
},
|
|
741
1543
|
required: ["runId"]
|
|
742
1544
|
}
|
|
1545
|
+
},
|
|
1546
|
+
{
|
|
1547
|
+
name: "local_browser_start",
|
|
1548
|
+
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.",
|
|
1549
|
+
inputSchema: {
|
|
1550
|
+
type: "object",
|
|
1551
|
+
properties: {
|
|
1552
|
+
mode: {
|
|
1553
|
+
type: "string",
|
|
1554
|
+
enum: ["playwright", "cdp"],
|
|
1555
|
+
description: "Browser mode: 'playwright' for fresh browser, 'cdp' to connect to existing Chrome"
|
|
1556
|
+
},
|
|
1557
|
+
cdpUrl: {
|
|
1558
|
+
type: "string",
|
|
1559
|
+
description: "CDP endpoint URL when mode is 'cdp' (e.g. http://localhost:9222)"
|
|
1560
|
+
},
|
|
1561
|
+
headless: {
|
|
1562
|
+
type: "boolean",
|
|
1563
|
+
description: "Run browser headless (default: true for playwright mode)"
|
|
1564
|
+
},
|
|
1565
|
+
storageStatePath: {
|
|
1566
|
+
type: "string",
|
|
1567
|
+
description: "Path to Playwright storage state JSON for pre-authenticated sessions"
|
|
1568
|
+
},
|
|
1569
|
+
instructions: {
|
|
1570
|
+
type: "string",
|
|
1571
|
+
description: "Instructions for the cloud agent on what to test"
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
},
|
|
1576
|
+
{
|
|
1577
|
+
name: "local_browser_status",
|
|
1578
|
+
description: "Check the status of a local browser session.",
|
|
1579
|
+
inputSchema: {
|
|
1580
|
+
type: "object",
|
|
1581
|
+
properties: {
|
|
1582
|
+
sessionId: { type: "string" }
|
|
1583
|
+
},
|
|
1584
|
+
required: ["sessionId"]
|
|
1585
|
+
}
|
|
1586
|
+
},
|
|
1587
|
+
{
|
|
1588
|
+
name: "local_browser_stop",
|
|
1589
|
+
description: "Stop a local browser session and close the browser.",
|
|
1590
|
+
inputSchema: {
|
|
1591
|
+
type: "object",
|
|
1592
|
+
properties: {
|
|
1593
|
+
sessionId: { type: "string" }
|
|
1594
|
+
},
|
|
1595
|
+
required: ["sessionId"]
|
|
1596
|
+
}
|
|
1597
|
+
},
|
|
1598
|
+
{
|
|
1599
|
+
name: "local_browser_list",
|
|
1600
|
+
description: "List all active local browser sessions.",
|
|
1601
|
+
inputSchema: {
|
|
1602
|
+
type: "object",
|
|
1603
|
+
properties: {}
|
|
1604
|
+
}
|
|
1605
|
+
},
|
|
1606
|
+
{
|
|
1607
|
+
name: "local_browser_run",
|
|
1608
|
+
description: "Start a test run on an active local browser session. The cloud agent will control the local browser according to the instructions.",
|
|
1609
|
+
inputSchema: {
|
|
1610
|
+
type: "object",
|
|
1611
|
+
properties: {
|
|
1612
|
+
sessionId: {
|
|
1613
|
+
type: "string",
|
|
1614
|
+
description: "The session ID from local_browser_start"
|
|
1615
|
+
},
|
|
1616
|
+
instructions: {
|
|
1617
|
+
type: "string",
|
|
1618
|
+
description: "Instructions for the cloud agent on what to test"
|
|
1619
|
+
},
|
|
1620
|
+
startUrl: {
|
|
1621
|
+
type: "string",
|
|
1622
|
+
description: "Optional URL to navigate to before starting"
|
|
1623
|
+
}
|
|
1624
|
+
},
|
|
1625
|
+
required: ["sessionId", "instructions"]
|
|
1626
|
+
}
|
|
743
1627
|
}
|
|
744
1628
|
]
|
|
745
1629
|
}));
|
|
@@ -762,7 +1646,7 @@ async function runMcp(argv) {
|
|
|
762
1646
|
token,
|
|
763
1647
|
title: input.title ?? "Local MCP run",
|
|
764
1648
|
featureSpec: input.instructions,
|
|
765
|
-
startUrl:
|
|
1649
|
+
startUrl: void 0,
|
|
766
1650
|
tunnelUrl
|
|
767
1651
|
});
|
|
768
1652
|
return toolJson({
|
|
@@ -778,6 +1662,133 @@ async function runMcp(argv) {
|
|
|
778
1662
|
const report = await waitForResult({ apiUrl, token, runId: input.runId });
|
|
779
1663
|
return toolJson(report);
|
|
780
1664
|
}
|
|
1665
|
+
if (tool === "local_browser_start") {
|
|
1666
|
+
const input = req.params.arguments;
|
|
1667
|
+
const apiUrl = resolveApiUrl();
|
|
1668
|
+
const mode = input.mode ?? "playwright";
|
|
1669
|
+
const sessionResponse = await fetch(`${apiUrl}/local-browser/sessions`, {
|
|
1670
|
+
method: "POST",
|
|
1671
|
+
headers: {
|
|
1672
|
+
"Content-Type": "application/json",
|
|
1673
|
+
Authorization: `Bearer ${token}`
|
|
1674
|
+
},
|
|
1675
|
+
body: JSON.stringify({
|
|
1676
|
+
browserMode: mode,
|
|
1677
|
+
instructions: input.instructions ?? null
|
|
1678
|
+
})
|
|
1679
|
+
});
|
|
1680
|
+
if (!sessionResponse.ok) {
|
|
1681
|
+
const text = await sessionResponse.text();
|
|
1682
|
+
return toolJson({ ok: false, error: `Failed to create session: ${text}` });
|
|
1683
|
+
}
|
|
1684
|
+
const session = await sessionResponse.json();
|
|
1685
|
+
const host = new LocalBrowserHost({
|
|
1686
|
+
apiUrl,
|
|
1687
|
+
wsToken: session.wsToken,
|
|
1688
|
+
sessionId: session.sessionId,
|
|
1689
|
+
browserMode: mode,
|
|
1690
|
+
cdpUrl: input.cdpUrl,
|
|
1691
|
+
headless: input.headless ?? true,
|
|
1692
|
+
storageStatePath: input.storageStatePath,
|
|
1693
|
+
onLog: (level, message) => {
|
|
1694
|
+
if (level === "error") {
|
|
1695
|
+
console.error(`[LocalBrowser] ${message}`);
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
});
|
|
1699
|
+
host.start().catch((err) => {
|
|
1700
|
+
console.error("Failed to start local browser:", err);
|
|
1701
|
+
browserSessions.delete(session.sessionId);
|
|
1702
|
+
});
|
|
1703
|
+
browserSessions.set(session.sessionId, {
|
|
1704
|
+
sessionId: session.sessionId,
|
|
1705
|
+
host,
|
|
1706
|
+
startedAt: Date.now(),
|
|
1707
|
+
mode
|
|
1708
|
+
});
|
|
1709
|
+
return toolJson({
|
|
1710
|
+
ok: true,
|
|
1711
|
+
sessionId: session.sessionId,
|
|
1712
|
+
mode,
|
|
1713
|
+
expiresAt: session.expiresAt,
|
|
1714
|
+
note: "Browser session started. The cloud agent can now control this browser. Use local_browser_stop to end the session."
|
|
1715
|
+
});
|
|
1716
|
+
}
|
|
1717
|
+
if (tool === "local_browser_status") {
|
|
1718
|
+
const input = req.params.arguments;
|
|
1719
|
+
const session = browserSessions.get(input.sessionId);
|
|
1720
|
+
if (!session) {
|
|
1721
|
+
return toolJson({ ok: false, error: "Session not found", sessionId: input.sessionId });
|
|
1722
|
+
}
|
|
1723
|
+
return toolJson({
|
|
1724
|
+
ok: true,
|
|
1725
|
+
sessionId: session.sessionId,
|
|
1726
|
+
mode: session.mode,
|
|
1727
|
+
startedAt: new Date(session.startedAt).toISOString(),
|
|
1728
|
+
uptimeMs: Date.now() - session.startedAt
|
|
1729
|
+
});
|
|
1730
|
+
}
|
|
1731
|
+
if (tool === "local_browser_stop") {
|
|
1732
|
+
const input = req.params.arguments;
|
|
1733
|
+
const session = browserSessions.get(input.sessionId);
|
|
1734
|
+
if (!session) {
|
|
1735
|
+
return toolJson({ ok: false, error: "Session not found", sessionId: input.sessionId });
|
|
1736
|
+
}
|
|
1737
|
+
await session.host.stop();
|
|
1738
|
+
browserSessions.delete(input.sessionId);
|
|
1739
|
+
return toolJson({
|
|
1740
|
+
ok: true,
|
|
1741
|
+
sessionId: input.sessionId,
|
|
1742
|
+
note: "Browser session stopped and browser closed."
|
|
1743
|
+
});
|
|
1744
|
+
}
|
|
1745
|
+
if (tool === "local_browser_list") {
|
|
1746
|
+
const sessions = Array.from(browserSessions.values()).map((s) => ({
|
|
1747
|
+
sessionId: s.sessionId,
|
|
1748
|
+
mode: s.mode,
|
|
1749
|
+
startedAt: new Date(s.startedAt).toISOString(),
|
|
1750
|
+
uptimeMs: Date.now() - s.startedAt
|
|
1751
|
+
}));
|
|
1752
|
+
return toolJson({
|
|
1753
|
+
ok: true,
|
|
1754
|
+
count: sessions.length,
|
|
1755
|
+
sessions
|
|
1756
|
+
});
|
|
1757
|
+
}
|
|
1758
|
+
if (tool === "local_browser_run") {
|
|
1759
|
+
const input = req.params.arguments;
|
|
1760
|
+
const apiUrl = resolveApiUrl();
|
|
1761
|
+
const session = browserSessions.get(input.sessionId);
|
|
1762
|
+
if (!session) {
|
|
1763
|
+
return toolJson({
|
|
1764
|
+
ok: false,
|
|
1765
|
+
error: "Session not found locally. Make sure you started it with local_browser_start.",
|
|
1766
|
+
sessionId: input.sessionId
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1769
|
+
const response = await fetch(`${apiUrl}/local-browser/sessions/${input.sessionId}/run`, {
|
|
1770
|
+
method: "POST",
|
|
1771
|
+
headers: {
|
|
1772
|
+
"Content-Type": "application/json",
|
|
1773
|
+
Authorization: `Bearer ${token}`
|
|
1774
|
+
},
|
|
1775
|
+
body: JSON.stringify({
|
|
1776
|
+
instructions: input.instructions,
|
|
1777
|
+
startUrl: input.startUrl ?? null
|
|
1778
|
+
})
|
|
1779
|
+
});
|
|
1780
|
+
if (!response.ok) {
|
|
1781
|
+
const text = await response.text();
|
|
1782
|
+
return toolJson({ ok: false, error: `Failed to start run: ${text}` });
|
|
1783
|
+
}
|
|
1784
|
+
const result = await response.json();
|
|
1785
|
+
return toolJson({
|
|
1786
|
+
ok: true,
|
|
1787
|
+
jobId: result.jobId,
|
|
1788
|
+
sessionId: result.sessionId,
|
|
1789
|
+
note: "Test run started. The cloud agent is now controlling your local browser. You can watch the browser to see the test in action."
|
|
1790
|
+
});
|
|
1791
|
+
}
|
|
781
1792
|
return toolText(`Unknown tool: ${tool}`);
|
|
782
1793
|
});
|
|
783
1794
|
const transport = new StdioServerTransport();
|
|
@@ -802,19 +1813,20 @@ async function streamUntilComplete(input) {
|
|
|
802
1813
|
if (!response.body) return;
|
|
803
1814
|
const reader = response.body.getReader();
|
|
804
1815
|
const decoder = new TextDecoder();
|
|
805
|
-
const parser = createParser(
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
1816
|
+
const parser = createParser({
|
|
1817
|
+
onEvent: (event) => {
|
|
1818
|
+
if (event.event === "status") {
|
|
1819
|
+
try {
|
|
1820
|
+
const payload = JSON.parse(event.data);
|
|
1821
|
+
if (payload?.status === "completed" || payload?.status === "failed") {
|
|
1822
|
+
reader.cancel().catch(() => void 0);
|
|
1823
|
+
}
|
|
1824
|
+
} catch {
|
|
812
1825
|
}
|
|
813
|
-
} catch {
|
|
814
1826
|
}
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
1827
|
+
if (event.event === "complete" || event.event === "error") {
|
|
1828
|
+
reader.cancel().catch(() => void 0);
|
|
1829
|
+
}
|
|
818
1830
|
}
|
|
819
1831
|
});
|
|
820
1832
|
while (true) {
|
|
@@ -844,6 +1856,305 @@ function formatReport(input) {
|
|
|
844
1856
|
};
|
|
845
1857
|
}
|
|
846
1858
|
|
|
1859
|
+
// src/remote-test.ts
|
|
1860
|
+
import process7 from "process";
|
|
1861
|
+
import { createParser as createParser2 } from "eventsource-parser";
|
|
1862
|
+
function getArgValue5(argv, key) {
|
|
1863
|
+
const index = argv.indexOf(key);
|
|
1864
|
+
if (index === -1 || index >= argv.length - 1) return void 0;
|
|
1865
|
+
return argv[index + 1];
|
|
1866
|
+
}
|
|
1867
|
+
function hasFlag(argv, ...flags) {
|
|
1868
|
+
return flags.some((flag) => argv.includes(flag));
|
|
1869
|
+
}
|
|
1870
|
+
async function runRemoteTest(argv) {
|
|
1871
|
+
const apiUrl = getArgValue5(argv, "--api-url") ?? process7.env.CANARY_API_URL ?? "https://api.trycanary.ai";
|
|
1872
|
+
const token = getArgValue5(argv, "--token") ?? process7.env.CANARY_API_TOKEN ?? await readStoredToken();
|
|
1873
|
+
const tag = getArgValue5(argv, "--tag");
|
|
1874
|
+
const namePattern = getArgValue5(argv, "--name-pattern");
|
|
1875
|
+
const verbose = hasFlag(argv, "--verbose", "-v");
|
|
1876
|
+
if (!token) {
|
|
1877
|
+
console.error("Error: No API token found.");
|
|
1878
|
+
console.error("");
|
|
1879
|
+
console.error("Set CANARY_API_TOKEN environment variable or run:");
|
|
1880
|
+
console.error(" canary login");
|
|
1881
|
+
console.error("");
|
|
1882
|
+
console.error("Or create an API key in Settings > API Keys and pass it:");
|
|
1883
|
+
console.error(" canary test --remote --token cnry_...");
|
|
1884
|
+
process7.exit(1);
|
|
1885
|
+
}
|
|
1886
|
+
console.log("Starting remote workflow tests...");
|
|
1887
|
+
if (tag) console.log(` Filtering by tag: ${tag}`);
|
|
1888
|
+
if (namePattern) console.log(` Filtering by name pattern: ${namePattern}`);
|
|
1889
|
+
console.log("");
|
|
1890
|
+
const queryParams = new URLSearchParams();
|
|
1891
|
+
if (tag) queryParams.set("tag", tag);
|
|
1892
|
+
if (namePattern) queryParams.set("namePattern", namePattern);
|
|
1893
|
+
const triggerUrl = `${apiUrl}/workflows/test-runs${queryParams.toString() ? `?${queryParams}` : ""}`;
|
|
1894
|
+
let triggerRes;
|
|
1895
|
+
try {
|
|
1896
|
+
triggerRes = await fetch(triggerUrl, {
|
|
1897
|
+
method: "POST",
|
|
1898
|
+
headers: {
|
|
1899
|
+
Authorization: `Bearer ${token}`,
|
|
1900
|
+
"Content-Type": "application/json"
|
|
1901
|
+
}
|
|
1902
|
+
});
|
|
1903
|
+
} catch (err) {
|
|
1904
|
+
console.error(`Failed to connect to API: ${err}`);
|
|
1905
|
+
process7.exit(1);
|
|
1906
|
+
}
|
|
1907
|
+
if (!triggerRes.ok) {
|
|
1908
|
+
const errorText = await triggerRes.text();
|
|
1909
|
+
console.error(`Failed to start tests: ${triggerRes.status}`);
|
|
1910
|
+
console.error(errorText);
|
|
1911
|
+
process7.exit(1);
|
|
1912
|
+
}
|
|
1913
|
+
const triggerData = await triggerRes.json();
|
|
1914
|
+
if (!triggerData.ok || !triggerData.suiteId) {
|
|
1915
|
+
console.error(`Failed to start tests: ${triggerData.error ?? "Unknown error"}`);
|
|
1916
|
+
process7.exit(1);
|
|
1917
|
+
}
|
|
1918
|
+
const { suiteId, jobId } = triggerData;
|
|
1919
|
+
if (verbose) {
|
|
1920
|
+
console.log(`Suite ID: ${suiteId}`);
|
|
1921
|
+
console.log(`Job ID: ${jobId}`);
|
|
1922
|
+
console.log("");
|
|
1923
|
+
}
|
|
1924
|
+
const streamUrl = `${apiUrl}/workflows/test-runs/stream?suiteId=${suiteId}`;
|
|
1925
|
+
let streamRes;
|
|
1926
|
+
try {
|
|
1927
|
+
streamRes = await fetch(streamUrl, {
|
|
1928
|
+
headers: {
|
|
1929
|
+
Authorization: `Bearer ${token}`,
|
|
1930
|
+
Accept: "text/event-stream"
|
|
1931
|
+
}
|
|
1932
|
+
});
|
|
1933
|
+
} catch (err) {
|
|
1934
|
+
console.error(`Failed to connect to event stream: ${err}`);
|
|
1935
|
+
process7.exit(1);
|
|
1936
|
+
}
|
|
1937
|
+
if (!streamRes.ok || !streamRes.body) {
|
|
1938
|
+
console.error(`Failed to connect to event stream: ${streamRes.status}`);
|
|
1939
|
+
process7.exit(1);
|
|
1940
|
+
}
|
|
1941
|
+
let exitCode = 0;
|
|
1942
|
+
let hasCompleted = false;
|
|
1943
|
+
const workflowNames = /* @__PURE__ */ new Map();
|
|
1944
|
+
let totalWorkflows = 0;
|
|
1945
|
+
let completedWorkflows = 0;
|
|
1946
|
+
let failedWorkflows = 0;
|
|
1947
|
+
let successfulWorkflows = 0;
|
|
1948
|
+
const parser = createParser2({
|
|
1949
|
+
onEvent: (event) => {
|
|
1950
|
+
if (!event.data) return;
|
|
1951
|
+
try {
|
|
1952
|
+
const data = JSON.parse(event.data);
|
|
1953
|
+
if (verbose) {
|
|
1954
|
+
console.log(`[${event.event}] ${JSON.stringify(data)}`);
|
|
1955
|
+
}
|
|
1956
|
+
if (event.event === "workflow-test") {
|
|
1957
|
+
const testEvent = data;
|
|
1958
|
+
const { status, workflowId, message, errorMessage } = testEvent;
|
|
1959
|
+
const name = workflowNames.get(workflowId) || message?.replace(/^Flow "(.+)" .*$/, "$1") || workflowId;
|
|
1960
|
+
if (message?.startsWith('Flow "')) {
|
|
1961
|
+
const match = message.match(/^Flow "(.+?)" /);
|
|
1962
|
+
if (match) workflowNames.set(workflowId, match[1]);
|
|
1963
|
+
}
|
|
1964
|
+
if (!verbose) {
|
|
1965
|
+
if (status === "success") {
|
|
1966
|
+
console.log(` \u2713 ${name}`);
|
|
1967
|
+
} else if (status === "failed") {
|
|
1968
|
+
console.log(` \u2717 ${name}`);
|
|
1969
|
+
if (errorMessage) {
|
|
1970
|
+
console.log(` Error: ${errorMessage.slice(0, 200)}`);
|
|
1971
|
+
}
|
|
1972
|
+
exitCode = 1;
|
|
1973
|
+
} else if (status === "running") {
|
|
1974
|
+
} else if (status === "waiting") {
|
|
1975
|
+
console.log(` \u23F3 ${name} (waiting for scheduled time)`);
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
if (event.event === "workflow-test-suite") {
|
|
1980
|
+
const suiteEvent = data;
|
|
1981
|
+
totalWorkflows = suiteEvent.totalWorkflows;
|
|
1982
|
+
completedWorkflows = suiteEvent.completedWorkflows;
|
|
1983
|
+
failedWorkflows = suiteEvent.failedWorkflows;
|
|
1984
|
+
successfulWorkflows = suiteEvent.successfulWorkflows;
|
|
1985
|
+
if (suiteEvent.status === "completed") {
|
|
1986
|
+
hasCompleted = true;
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
if (event.event === "error") {
|
|
1990
|
+
const errorData = data;
|
|
1991
|
+
console.error(`Stream error: ${errorData.error ?? "Unknown error"}`);
|
|
1992
|
+
exitCode = 1;
|
|
1993
|
+
}
|
|
1994
|
+
} catch {
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
});
|
|
1998
|
+
const reader = streamRes.body.getReader();
|
|
1999
|
+
const decoder = new TextDecoder();
|
|
2000
|
+
try {
|
|
2001
|
+
while (!hasCompleted) {
|
|
2002
|
+
const { done, value } = await reader.read();
|
|
2003
|
+
if (done) break;
|
|
2004
|
+
parser.feed(decoder.decode(value, { stream: true }));
|
|
2005
|
+
}
|
|
2006
|
+
} finally {
|
|
2007
|
+
reader.releaseLock();
|
|
2008
|
+
}
|
|
2009
|
+
console.log("");
|
|
2010
|
+
console.log("\u2500".repeat(50));
|
|
2011
|
+
if (totalWorkflows === 0) {
|
|
2012
|
+
console.log("No workflows found matching the filter criteria.");
|
|
2013
|
+
process7.exit(0);
|
|
2014
|
+
}
|
|
2015
|
+
const passRate = totalWorkflows > 0 ? Math.round(successfulWorkflows / totalWorkflows * 100) : 0;
|
|
2016
|
+
if (failedWorkflows > 0) {
|
|
2017
|
+
console.log(`FAILED: ${failedWorkflows} of ${totalWorkflows} workflows failed (${passRate}% pass rate)`);
|
|
2018
|
+
exitCode = 1;
|
|
2019
|
+
} else {
|
|
2020
|
+
console.log(`PASSED: ${successfulWorkflows} of ${totalWorkflows} workflows passed`);
|
|
2021
|
+
}
|
|
2022
|
+
const waitingWorkflows = totalWorkflows - completedWorkflows;
|
|
2023
|
+
if (waitingWorkflows > 0) {
|
|
2024
|
+
console.log(`Note: ${waitingWorkflows} workflow(s) are still waiting (scheduled for later)`);
|
|
2025
|
+
}
|
|
2026
|
+
process7.exit(exitCode);
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
// src/local-browser/index.ts
|
|
2030
|
+
import process8 from "process";
|
|
2031
|
+
var DEFAULT_API_URL2 = "https://api.trycanary.ai";
|
|
2032
|
+
function parseArgs(args) {
|
|
2033
|
+
const options = {
|
|
2034
|
+
mode: "playwright",
|
|
2035
|
+
headless: true,
|
|
2036
|
+
apiUrl: process8.env.CANARY_API_URL ?? DEFAULT_API_URL2
|
|
2037
|
+
};
|
|
2038
|
+
for (let i = 0; i < args.length; i++) {
|
|
2039
|
+
const arg = args[i];
|
|
2040
|
+
const nextArg = args[i + 1];
|
|
2041
|
+
switch (arg) {
|
|
2042
|
+
case "--mode":
|
|
2043
|
+
if (nextArg === "playwright" || nextArg === "cdp") {
|
|
2044
|
+
options.mode = nextArg;
|
|
2045
|
+
i++;
|
|
2046
|
+
}
|
|
2047
|
+
break;
|
|
2048
|
+
case "--cdp-url":
|
|
2049
|
+
options.cdpUrl = nextArg;
|
|
2050
|
+
options.mode = "cdp";
|
|
2051
|
+
i++;
|
|
2052
|
+
break;
|
|
2053
|
+
case "--headless":
|
|
2054
|
+
options.headless = true;
|
|
2055
|
+
break;
|
|
2056
|
+
case "--no-headless":
|
|
2057
|
+
options.headless = false;
|
|
2058
|
+
break;
|
|
2059
|
+
case "--storage-state":
|
|
2060
|
+
options.storageStatePath = nextArg;
|
|
2061
|
+
i++;
|
|
2062
|
+
break;
|
|
2063
|
+
case "--api-url":
|
|
2064
|
+
options.apiUrl = nextArg;
|
|
2065
|
+
i++;
|
|
2066
|
+
break;
|
|
2067
|
+
case "--instructions":
|
|
2068
|
+
options.instructions = nextArg;
|
|
2069
|
+
i++;
|
|
2070
|
+
break;
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
return options;
|
|
2074
|
+
}
|
|
2075
|
+
async function resolveToken2() {
|
|
2076
|
+
const token = process8.env.CANARY_API_TOKEN ?? await readStoredToken();
|
|
2077
|
+
if (!token) {
|
|
2078
|
+
throw new Error("Missing token. Run `canary login` first or set CANARY_API_TOKEN.");
|
|
2079
|
+
}
|
|
2080
|
+
return token;
|
|
2081
|
+
}
|
|
2082
|
+
async function createSession(apiUrl, token, options) {
|
|
2083
|
+
const response = await fetch(`${apiUrl}/local-browser/sessions`, {
|
|
2084
|
+
method: "POST",
|
|
2085
|
+
headers: {
|
|
2086
|
+
"Content-Type": "application/json",
|
|
2087
|
+
Authorization: `Bearer ${token}`
|
|
2088
|
+
},
|
|
2089
|
+
body: JSON.stringify({
|
|
2090
|
+
browserMode: options.mode,
|
|
2091
|
+
instructions: options.instructions ?? null
|
|
2092
|
+
})
|
|
2093
|
+
});
|
|
2094
|
+
if (!response.ok) {
|
|
2095
|
+
const text = await response.text();
|
|
2096
|
+
throw new Error(`Failed to create session: ${response.status} ${text}`);
|
|
2097
|
+
}
|
|
2098
|
+
return response.json();
|
|
2099
|
+
}
|
|
2100
|
+
async function runLocalBrowser(args) {
|
|
2101
|
+
const options = parseArgs(args);
|
|
2102
|
+
console.log("Starting local browser...");
|
|
2103
|
+
console.log(` Mode: ${options.mode}`);
|
|
2104
|
+
if (options.cdpUrl) {
|
|
2105
|
+
console.log(` CDP URL: ${options.cdpUrl}`);
|
|
2106
|
+
}
|
|
2107
|
+
console.log(` Headless: ${options.headless}`);
|
|
2108
|
+
console.log(` API URL: ${options.apiUrl}`);
|
|
2109
|
+
console.log();
|
|
2110
|
+
const token = await resolveToken2();
|
|
2111
|
+
console.log("Creating session with cloud API...");
|
|
2112
|
+
const session = await createSession(options.apiUrl, token, options);
|
|
2113
|
+
if (!session.ok) {
|
|
2114
|
+
throw new Error(`Failed to create session: ${session.error}`);
|
|
2115
|
+
}
|
|
2116
|
+
console.log(`Session created: ${session.sessionId}`);
|
|
2117
|
+
console.log(`Expires at: ${session.expiresAt}`);
|
|
2118
|
+
console.log();
|
|
2119
|
+
const host = new LocalBrowserHost({
|
|
2120
|
+
apiUrl: options.apiUrl,
|
|
2121
|
+
wsToken: session.wsToken,
|
|
2122
|
+
sessionId: session.sessionId,
|
|
2123
|
+
browserMode: options.mode,
|
|
2124
|
+
cdpUrl: options.cdpUrl,
|
|
2125
|
+
headless: options.headless,
|
|
2126
|
+
storageStatePath: options.storageStatePath,
|
|
2127
|
+
onLog: (level, message, data) => {
|
|
2128
|
+
const prefix = `[${level.toUpperCase()}]`;
|
|
2129
|
+
if (data) {
|
|
2130
|
+
console.log(prefix, message, data);
|
|
2131
|
+
} else {
|
|
2132
|
+
console.log(prefix, message);
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
});
|
|
2136
|
+
const shutdown = async () => {
|
|
2137
|
+
console.log("\nShutting down...");
|
|
2138
|
+
await host.stop();
|
|
2139
|
+
process8.exit(0);
|
|
2140
|
+
};
|
|
2141
|
+
process8.on("SIGINT", shutdown);
|
|
2142
|
+
process8.on("SIGTERM", shutdown);
|
|
2143
|
+
try {
|
|
2144
|
+
await host.start();
|
|
2145
|
+
console.log();
|
|
2146
|
+
console.log("Local browser is ready and connected to cloud.");
|
|
2147
|
+
console.log("Press Ctrl+C to stop.");
|
|
2148
|
+
console.log();
|
|
2149
|
+
await new Promise(() => {
|
|
2150
|
+
});
|
|
2151
|
+
} catch (error) {
|
|
2152
|
+
console.error("Failed to start local browser:", error);
|
|
2153
|
+
await host.stop();
|
|
2154
|
+
process8.exit(1);
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
|
|
847
2158
|
// src/index.ts
|
|
848
2159
|
var canary = { run };
|
|
849
2160
|
var baseDir = typeof __dirname !== "undefined" ? __dirname : path5.dirname(fileURLToPath2(import.meta.url));
|
|
@@ -852,38 +2163,54 @@ var requireFn = makeRequire();
|
|
|
852
2163
|
function runPlaywrightTests(args) {
|
|
853
2164
|
const playwrightCli = requireFn.resolve("@playwright/test/cli");
|
|
854
2165
|
const { runnerBin, preloadFlag } = resolveRunner(preloadPath);
|
|
855
|
-
const nodeOptions =
|
|
2166
|
+
const nodeOptions = process9.env.NODE_OPTIONS && preloadFlag ? `${process9.env.NODE_OPTIONS} ${preloadFlag}` : preloadFlag ?? process9.env.NODE_OPTIONS;
|
|
856
2167
|
const env = {
|
|
857
|
-
...
|
|
858
|
-
CANARY_ENABLED:
|
|
2168
|
+
...process9.env,
|
|
2169
|
+
CANARY_ENABLED: process9.env.CANARY_ENABLED ?? "1",
|
|
859
2170
|
CANARY_RUNNER: "canary",
|
|
860
2171
|
...nodeOptions ? { NODE_OPTIONS: nodeOptions } : {}
|
|
861
2172
|
};
|
|
862
2173
|
const result = spawnSync2(runnerBin, [playwrightCli, "test", ...args], {
|
|
863
2174
|
env,
|
|
864
2175
|
stdio: "inherit",
|
|
865
|
-
cwd:
|
|
2176
|
+
cwd: process9.cwd()
|
|
866
2177
|
});
|
|
867
2178
|
if (result.error) {
|
|
868
2179
|
console.error("canary failed to launch Playwright:", result.error);
|
|
869
|
-
|
|
2180
|
+
process9.exit(1);
|
|
870
2181
|
}
|
|
871
|
-
|
|
2182
|
+
process9.exit(result.status ?? 1);
|
|
872
2183
|
}
|
|
873
2184
|
function printHelp() {
|
|
874
2185
|
console.log(
|
|
875
2186
|
[
|
|
876
|
-
"canary: Local testing CLI",
|
|
2187
|
+
"canary: Local and remote testing CLI",
|
|
877
2188
|
"",
|
|
878
2189
|
"Usage:",
|
|
879
|
-
" canary test [playwright options]",
|
|
2190
|
+
" canary test [playwright options] Run local Playwright tests",
|
|
2191
|
+
" canary test --remote [options] Run remote workflow tests",
|
|
880
2192
|
" canary local-run --tunnel-url <url> [options]",
|
|
881
2193
|
" canary tunnel --port <localPort> [options]",
|
|
882
2194
|
" canary run --port <localPort> [options]",
|
|
883
2195
|
" canary mcp",
|
|
2196
|
+
" canary browser [--mode playwright|cdp] [--cdp-url <url>] [--no-headless]",
|
|
884
2197
|
" canary login [--app-url https://app.trycanary.ai] [--no-open]",
|
|
885
2198
|
" canary help",
|
|
886
2199
|
"",
|
|
2200
|
+
"Remote test options:",
|
|
2201
|
+
" --token <key> API key (or set CANARY_API_TOKEN)",
|
|
2202
|
+
" --api-url <url> API URL (default: https://api.trycanary.ai)",
|
|
2203
|
+
" --tag <tag> Filter workflows by tag",
|
|
2204
|
+
" --name-pattern <pat> Filter workflows by name pattern",
|
|
2205
|
+
" --verbose, -v Show all events",
|
|
2206
|
+
"",
|
|
2207
|
+
"Browser options:",
|
|
2208
|
+
" --mode <playwright|cdp> Browser mode (default: playwright)",
|
|
2209
|
+
" --cdp-url <url> CDP endpoint for existing Chrome",
|
|
2210
|
+
" --no-headless Run browser with visible UI",
|
|
2211
|
+
" --storage-state <path> Path to storage state JSON",
|
|
2212
|
+
" --instructions <text> Instructions for the cloud agent",
|
|
2213
|
+
"",
|
|
887
2214
|
"Flags:",
|
|
888
2215
|
" -h, --help Show help"
|
|
889
2216
|
].join("\n")
|
|
@@ -900,6 +2227,11 @@ async function main(argv) {
|
|
|
900
2227
|
return;
|
|
901
2228
|
}
|
|
902
2229
|
if (command === "test") {
|
|
2230
|
+
if (rest.includes("--remote")) {
|
|
2231
|
+
const remoteArgs = rest.filter((arg) => arg !== "--remote");
|
|
2232
|
+
await runRemoteTest(remoteArgs);
|
|
2233
|
+
return;
|
|
2234
|
+
}
|
|
903
2235
|
runPlaywrightTests(rest);
|
|
904
2236
|
return;
|
|
905
2237
|
}
|
|
@@ -923,12 +2255,16 @@ async function main(argv) {
|
|
|
923
2255
|
await runLogin(rest);
|
|
924
2256
|
return;
|
|
925
2257
|
}
|
|
2258
|
+
if (command === "browser") {
|
|
2259
|
+
await runLocalBrowser(rest);
|
|
2260
|
+
return;
|
|
2261
|
+
}
|
|
926
2262
|
console.log(`Unknown command "${command}".`);
|
|
927
2263
|
printHelp();
|
|
928
|
-
|
|
2264
|
+
process9.exit(1);
|
|
929
2265
|
}
|
|
930
|
-
if (import.meta.url === pathToFileURL2(
|
|
931
|
-
void main(
|
|
2266
|
+
if (import.meta.url === pathToFileURL2(process9.argv[1]).href) {
|
|
2267
|
+
void main(process9.argv.slice(2));
|
|
932
2268
|
}
|
|
933
2269
|
export {
|
|
934
2270
|
canary,
|