@taodigital/agent-cli 1.2.1 → 1.2.2

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/index.js CHANGED
@@ -6226,9 +6226,9 @@ var require_websocket = __commonJS({
6226
6226
  emitErrorAndClose(websocket, err);
6227
6227
  });
6228
6228
  req.on("response", (res) => {
6229
- const location = res.headers.location;
6229
+ const location2 = res.headers.location;
6230
6230
  const statusCode = res.statusCode;
6231
- if (location && opts.followRedirects && statusCode >= 300 && statusCode < 400) {
6231
+ if (location2 && opts.followRedirects && statusCode >= 300 && statusCode < 400) {
6232
6232
  if (++websocket._redirects > opts.maxRedirects) {
6233
6233
  abortHandshake(websocket, req, "Maximum redirects exceeded");
6234
6234
  return;
@@ -6236,9 +6236,9 @@ var require_websocket = __commonJS({
6236
6236
  req.abort();
6237
6237
  let addr;
6238
6238
  try {
6239
- addr = new URL2(location, address);
6239
+ addr = new URL2(location2, address);
6240
6240
  } catch (e) {
6241
- const err = new SyntaxError(`Invalid URL: ${location}`);
6241
+ const err = new SyntaxError(`Invalid URL: ${location2}`);
6242
6242
  emitErrorAndClose(websocket, err);
6243
6243
  return;
6244
6244
  }
@@ -7579,6 +7579,66 @@ async function runPostman(message, send, signal) {
7579
7579
  send({ ...result, duration: Date.now() - started });
7580
7580
  }
7581
7581
 
7582
+ // src/proxy.ts
7583
+ var import_node_net = require("node:net");
7584
+ var PROXY_HDR_LEN = 37;
7585
+ var PROXY_DATA_TO_AGENT = 1;
7586
+ var PROXY_DATA_FROM_AGENT = 2;
7587
+ var PROXY_CLOSE = 3;
7588
+ var tunnels = /* @__PURE__ */ new Map();
7589
+ function handleProxyConnect(ws, tunnelId, host, port) {
7590
+ const tcp = (0, import_node_net.createConnection)({ host, port }, () => {
7591
+ ws.send(JSON.stringify({ type: "proxy_connected", tunnelId }));
7592
+ tcp.on("data", (data) => {
7593
+ if (ws.readyState !== ws.OPEN)
7594
+ return;
7595
+ const hdr = Buffer.allocUnsafe(PROXY_HDR_LEN);
7596
+ hdr[0] = PROXY_DATA_FROM_AGENT;
7597
+ hdr.write(tunnelId, 1, "ascii");
7598
+ ws.send(Buffer.concat([hdr, data]));
7599
+ });
7600
+ });
7601
+ tcp.on("error", (err) => {
7602
+ ws.send(JSON.stringify({ type: "proxy_error", tunnelId, error: err.message }));
7603
+ tunnels.delete(tunnelId);
7604
+ });
7605
+ tcp.on("close", () => {
7606
+ if (!tunnels.has(tunnelId))
7607
+ return;
7608
+ tunnels.delete(tunnelId);
7609
+ if (ws.readyState === ws.OPEN) {
7610
+ const hdr = Buffer.allocUnsafe(PROXY_HDR_LEN);
7611
+ hdr[0] = PROXY_CLOSE;
7612
+ hdr.write(tunnelId, 1, "ascii");
7613
+ ws.send(hdr);
7614
+ }
7615
+ });
7616
+ tunnels.set(tunnelId, { socket: tcp });
7617
+ }
7618
+ function handleProxyBinaryFrame(ws, raw) {
7619
+ if (raw.length < PROXY_HDR_LEN)
7620
+ return;
7621
+ const msgType = raw[0];
7622
+ if (msgType !== PROXY_DATA_TO_AGENT && msgType !== PROXY_CLOSE)
7623
+ return;
7624
+ const tunnelId = raw.slice(1, PROXY_HDR_LEN).toString("ascii");
7625
+ const tunnel = tunnels.get(tunnelId);
7626
+ if (!tunnel)
7627
+ return;
7628
+ if (msgType === PROXY_DATA_TO_AGENT) {
7629
+ tunnel.socket.write(raw.slice(PROXY_HDR_LEN));
7630
+ } else {
7631
+ tunnel.socket.destroy();
7632
+ tunnels.delete(tunnelId);
7633
+ }
7634
+ }
7635
+ function closeAllTunnels() {
7636
+ for (const [id, tunnel] of tunnels) {
7637
+ tunnel.socket.destroy();
7638
+ tunnels.delete(id);
7639
+ }
7640
+ }
7641
+
7582
7642
  // src/index.ts
7583
7643
  var _pkg = (0, import_node_module2.createRequire)(__filename)("../package.json");
7584
7644
  var PKG_VERSION = _pkg.version;
@@ -7616,7 +7676,8 @@ var WsMessageType = {
7616
7676
  RUN_COMPLETE: "run_complete",
7617
7677
  SETUP: "setup",
7618
7678
  DOCTOR: "doctor",
7619
- RUN_POSTMAN: "run_postman"
7679
+ RUN_POSTMAN: "run_postman",
7680
+ LOCAL_CRAWL: "local_crawl"
7620
7681
  };
7621
7682
  var TERMINAL_JOB_STATUSES = /* @__PURE__ */ new Set([
7622
7683
  "DONE",
@@ -7625,7 +7686,7 @@ var TERMINAL_JOB_STATUSES = /* @__PURE__ */ new Set([
7625
7686
  "TIMED_OUT",
7626
7687
  "ABORTED"
7627
7688
  ]);
7628
- var IDENTITY_PATH = "/workspace/settings/identity";
7689
+ var IDENTITY_PATH = "/agent/ping";
7629
7690
  var CREDS_DIR = (0, import_node_path2.join)((0, import_node_os2.homedir)(), ".config", "zeta");
7630
7691
  var CREDS_FILE = (0, import_node_path2.join)(CREDS_DIR, "credentials.json");
7631
7692
  function resolveProfile(explicit) {
@@ -8713,6 +8774,7 @@ Projects (${projects.length}):`);
8713
8774
  }
8714
8775
  }
8715
8776
  function scheduleReconnect() {
8777
+ closeAllTunnels();
8716
8778
  const delay = Math.min(
8717
8779
  WS_RECONNECT_BASE_MS * Math.pow(2, reconnectAttempt) * (0.5 + Math.random() * 0.5),
8718
8780
  WS_RECONNECT_MAX_MS
@@ -8725,6 +8787,7 @@ Projects (${projects.length}):`);
8725
8787
  ws = new wrapper_default(wsUrl, {
8726
8788
  headers: { Authorization: `Bearer ${token}` }
8727
8789
  });
8790
+ ws.binaryType = "nodebuffer";
8728
8791
  const connection = ws;
8729
8792
  ws.on("open", () => {
8730
8793
  console.log("[ZeTa Agent] Connected to ZeTa Cloud.");
@@ -8735,6 +8798,10 @@ Projects (${projects.length}):`);
8735
8798
  }
8736
8799
  });
8737
8800
  ws.on("message", async (raw) => {
8801
+ if (Buffer.isBuffer(raw) && raw.length > 0 && raw[0] !== 123) {
8802
+ handleProxyBinaryFrame(connection, raw);
8803
+ return;
8804
+ }
8738
8805
  let msg;
8739
8806
  try {
8740
8807
  msg = JSON.parse(raw.toString());
@@ -8755,6 +8822,11 @@ Projects (${projects.length}):`);
8755
8822
  case WsMessageType.DOCTOR:
8756
8823
  await handleEnvironmentRequest(msg, connection);
8757
8824
  break;
8825
+ case "proxy_connect":
8826
+ if (typeof msg.tunnelId === "string" && typeof msg.host === "string" && typeof msg.port === "number") {
8827
+ handleProxyConnect(connection, msg.tunnelId, msg.host, msg.port);
8828
+ }
8829
+ break;
8758
8830
  case WsMessageType.RUN_POSTMAN: {
8759
8831
  const runId = typeof msg.runId === "string" ? msg.runId : null;
8760
8832
  const logPrefix = `[ZeTa Postman] Run ${runId}`;
@@ -8806,6 +8878,28 @@ Projects (${projects.length}):`);
8806
8878
  }
8807
8879
  break;
8808
8880
  }
8881
+ case WsMessageType.LOCAL_CRAWL: {
8882
+ const { crawlId, url: crawlUrl, maxScreens = 20, storageState, deviceProfiles, httpBasicUsername, httpBasicPassword, traversalStrategy, bestFirstKeywords } = msg;
8883
+ if (!crawlId || !crawlUrl)
8884
+ break;
8885
+ if (postmanRunRunning || environmentOperationRunning) {
8886
+ if (ws.readyState === wrapper_default.OPEN) {
8887
+ ws.send(JSON.stringify({ type: "crawl_done", crawlId, totalScreens: 0, error: "Another operation is running; retry later" }));
8888
+ }
8889
+ break;
8890
+ }
8891
+ handleLocalCrawl({ crawlId, url: crawlUrl, maxScreens, storageState, deviceProfiles, httpBasicUsername, httpBasicPassword, traversalStrategy, bestFirstKeywords }, ws).catch(() => {
8892
+ });
8893
+ break;
8894
+ }
8895
+ case "local_discover": {
8896
+ const { discoverJobId, url: discoverUrl, maxDiscoverUrls = 2e3, storageState: discoverStorageState } = msg;
8897
+ if (!discoverJobId || !discoverUrl)
8898
+ break;
8899
+ handleLocalDiscover({ discoverJobId, url: discoverUrl, maxDiscoverUrls, storageState: discoverStorageState }, ws).catch(() => {
8900
+ });
8901
+ break;
8902
+ }
8809
8903
  default:
8810
8904
  break;
8811
8905
  }
@@ -8853,6 +8947,809 @@ async function handleRunTests(msg, ws) {
8853
8947
  await (0, import_promises2.rm)(tmpDir, { recursive: true, force: true });
8854
8948
  }
8855
8949
  }
8950
+ var DEVICE_VIEWPORTS = {
8951
+ DESKTOP: { width: 1440, height: 900 },
8952
+ MOBILE_PORTRAIT: { width: 393, height: 852, isMobile: true, deviceScaleFactor: 3 },
8953
+ TABLET: { width: 834, height: 1194, isMobile: true, deviceScaleFactor: 2 }
8954
+ };
8955
+ async function handleLocalCrawl(msg, ws) {
8956
+ const { crawlId, url, maxScreens = 20 } = msg;
8957
+ const deviceProfiles = Array.isArray(msg.deviceProfiles) && msg.deviceProfiles.length > 0 ? msg.deviceProfiles : ["DESKTOP"];
8958
+ console.log(`[ZeTa Agent] Starting local crawl: ${url} (id: ${crawlId}, maxScreens: ${maxScreens})`);
8959
+ const send = (event) => {
8960
+ if (ws.readyState === 1)
8961
+ ws.send(JSON.stringify({ ...event, crawlId }));
8962
+ };
8963
+ try {
8964
+ let chromium;
8965
+ try {
8966
+ chromium = (await import("playwright")).chromium;
8967
+ } catch {
8968
+ send({ type: "crawl_progress", message: "Installing playwright \u2014 this only happens once\u2026" });
8969
+ const { execFileSync } = await import("child_process");
8970
+ const { resolve } = await import("path");
8971
+ const { platform } = await import("os");
8972
+ const agentDir = resolve(__dirname, "..");
8973
+ const isWin = platform() === "win32";
8974
+ const npm = isWin ? "npm.cmd" : "npm";
8975
+ const npx = isWin ? "npx.cmd" : "npx";
8976
+ execFileSync(npm, ["install", "playwright"], { cwd: agentDir, stdio: "pipe", timeout: 12e4 });
8977
+ send({ type: "crawl_progress", message: "Downloading Chromium browser \u2014 this only happens once\u2026" });
8978
+ execFileSync(npx, ["playwright", "install", "chromium"], { cwd: agentDir, stdio: "pipe", timeout: 3e5 });
8979
+ chromium = (await import("playwright")).chromium;
8980
+ }
8981
+ console.log("[ZeTa Agent] Playwright ready \u2014 launching browser");
8982
+ const browser = await chromium.launch({ headless: true });
8983
+ const contextOptions = {
8984
+ viewport: { width: 1440, height: 900 },
8985
+ userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
8986
+ };
8987
+ if (msg.storageState) {
8988
+ try {
8989
+ contextOptions.storageState = JSON.parse(msg.storageState);
8990
+ } catch {
8991
+ }
8992
+ }
8993
+ const context = await browser.newContext(contextOptions);
8994
+ if (msg.httpBasicUsername && msg.httpBasicPassword) {
8995
+ const basicCreds = Buffer.from(`${msg.httpBasicUsername}:${msg.httpBasicPassword}`).toString("base64");
8996
+ await context.setExtraHTTPHeaders({ "Authorization": `Basic ${basicCreds}` });
8997
+ }
8998
+ if (msg.httpAuthToken) {
8999
+ await context.setExtraHTTPHeaders({ Authorization: `Bearer ${msg.httpAuthToken}` });
9000
+ }
9001
+ if (msg.httpAuthHeaders && typeof msg.httpAuthHeaders === "object") {
9002
+ await context.setExtraHTTPHeaders(msg.httpAuthHeaders);
9003
+ }
9004
+ context.on("page", async (popup) => {
9005
+ try {
9006
+ await popup.waitForLoadState("domcontentloaded", { timeout: 1e4 }).catch(() => {
9007
+ });
9008
+ const popupUrl = popup.url();
9009
+ if (!popupUrl || popupUrl === "about:blank")
9010
+ return;
9011
+ const popupTitle = await popup.title().catch(() => popupUrl);
9012
+ const popupScreenshot = await popup.screenshot({ type: "jpeg", quality: 80, fullPage: true }).then((b) => b.toString("base64")).catch(() => null);
9013
+ const popupSnapshot = await popup.content().catch(() => null);
9014
+ send({
9015
+ type: "crawl_screen",
9016
+ screen: {
9017
+ url: popupUrl,
9018
+ title: `[Popup] ${popupTitle}`,
9019
+ screenshot: popupScreenshot,
9020
+ domSnapshot: popupSnapshot,
9021
+ inputs: [],
9022
+ links: [],
9023
+ elements: [],
9024
+ apiEndpoints: [],
9025
+ pageText: "",
9026
+ viewportScreenshots: {}
9027
+ }
9028
+ });
9029
+ screensCount++;
9030
+ } catch {
9031
+ }
9032
+ });
9033
+ const visited = /* @__PURE__ */ new Set();
9034
+ const queue = [url];
9035
+ let screensCount = 0;
9036
+ if (msg.seedUrls && Array.isArray(msg.seedUrls)) {
9037
+ for (const su of msg.seedUrls) {
9038
+ if (!queue.includes(su))
9039
+ queue.push(su);
9040
+ }
9041
+ }
9042
+ const traversalStrategy = msg.traversalStrategy ?? "BFS";
9043
+ const bestFirstKeywords = msg.bestFirstKeywords ?? [];
9044
+ const scoreUrl = (u) => {
9045
+ if (traversalStrategy !== "BEST_FIRST" || bestFirstKeywords.length === 0)
9046
+ return 0;
9047
+ const lower = u.toLowerCase();
9048
+ let score = 0;
9049
+ for (const kw of bestFirstKeywords) {
9050
+ if (lower.includes(kw.toLowerCase()))
9051
+ score -= 10;
9052
+ }
9053
+ return score;
9054
+ };
9055
+ const enqueue = (u) => {
9056
+ if (traversalStrategy === "DFS") {
9057
+ queue.unshift(u);
9058
+ } else if (traversalStrategy === "BEST_FIRST") {
9059
+ const score = scoreUrl(u);
9060
+ const idx = queue.findIndex((q) => scoreUrl(q) > score);
9061
+ if (idx === -1)
9062
+ queue.push(u);
9063
+ else
9064
+ queue.splice(idx, 0, u);
9065
+ } else {
9066
+ queue.push(u);
9067
+ }
9068
+ };
9069
+ const normalizeUrl = (u) => {
9070
+ try {
9071
+ const parsed = new URL(u);
9072
+ parsed.hash = "";
9073
+ let path = parsed.pathname.replace(/\/$/, "") || "/";
9074
+ path = path.replace(/\/[0-9a-f]{20,}(\/|$)/gi, "/:id$1").replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(\/|$)/gi, "/:id$1");
9075
+ parsed.pathname = path;
9076
+ return parsed.href;
9077
+ } catch {
9078
+ return u;
9079
+ }
9080
+ };
9081
+ try {
9082
+ const baseOrigin = new URL(url).origin;
9083
+ const sitemapRes = await fetch(`${baseOrigin}/sitemap.xml`, {
9084
+ signal: AbortSignal.timeout(5e3),
9085
+ headers: { "User-Agent": "Mozilla/5.0 (compatible; ZeTaBot/1.0)" }
9086
+ });
9087
+ if (sitemapRes.ok) {
9088
+ const xml = await sitemapRes.text();
9089
+ const locs = [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1].trim());
9090
+ for (const loc of locs) {
9091
+ try {
9092
+ const locUrl = new URL(loc);
9093
+ if (locUrl.origin === baseOrigin && !queue.includes(loc)) {
9094
+ enqueue(loc);
9095
+ }
9096
+ } catch {
9097
+ }
9098
+ }
9099
+ send({ type: "crawl_progress", message: `Sitemap: found ${locs.length} URLs` });
9100
+ }
9101
+ } catch {
9102
+ }
9103
+ const disallowedPaths = /* @__PURE__ */ new Set();
9104
+ try {
9105
+ const origin = new URL(url).origin;
9106
+ const robotsPage = await context.newPage();
9107
+ try {
9108
+ const robotsResp = await robotsPage.goto(`${origin}/robots.txt`, { waitUntil: "domcontentloaded", timeout: 5e3 });
9109
+ if (robotsResp?.ok()) {
9110
+ const robotsText = await robotsPage.content();
9111
+ let userAgentAll = false;
9112
+ for (const line of robotsText.split("\n")) {
9113
+ const trimmed = line.trim();
9114
+ if (trimmed.toLowerCase().startsWith("user-agent: *"))
9115
+ userAgentAll = true;
9116
+ else if (trimmed.toLowerCase().startsWith("user-agent:"))
9117
+ userAgentAll = false;
9118
+ else if (userAgentAll && trimmed.toLowerCase().startsWith("disallow:")) {
9119
+ const path = trimmed.slice("disallow:".length).trim();
9120
+ if (path)
9121
+ disallowedPaths.add(path);
9122
+ }
9123
+ }
9124
+ }
9125
+ } finally {
9126
+ await robotsPage.close().catch(() => {
9127
+ });
9128
+ }
9129
+ } catch {
9130
+ }
9131
+ const contentHashes = /* @__PURE__ */ new Set();
9132
+ while (queue.length > 0 && screensCount < maxScreens) {
9133
+ const pageUrl = queue.shift();
9134
+ const normalizedUrl = normalizeUrl(pageUrl);
9135
+ if (visited.has(normalizedUrl))
9136
+ continue;
9137
+ visited.add(normalizedUrl);
9138
+ const urlPath = new URL(pageUrl).pathname;
9139
+ if ([...disallowedPaths].some((d) => d !== "/" && urlPath.startsWith(d))) {
9140
+ send({ type: "crawl_progress", message: `Skipped (robots.txt): ${pageUrl}` });
9141
+ continue;
9142
+ }
9143
+ const page = await context.newPage();
9144
+ const observedApiEndpoints = [];
9145
+ try {
9146
+ page.on("request", (req) => {
9147
+ try {
9148
+ const reqUrl = req.url();
9149
+ if (req.resourceType() === "fetch" || req.resourceType() === "xhr") {
9150
+ const parsed = new URL(reqUrl);
9151
+ const path = parsed.pathname;
9152
+ if (!observedApiEndpoints.includes(path))
9153
+ observedApiEndpoints.push(path);
9154
+ }
9155
+ } catch {
9156
+ }
9157
+ });
9158
+ await page.addInitScript(() => {
9159
+ window.__ZETA_VITALS__ = { lcp: null, cls: 0, fcp: null };
9160
+ try {
9161
+ new PerformanceObserver((list) => {
9162
+ for (const entry of list.getEntries()) {
9163
+ window.__ZETA_VITALS__.lcp = entry.startTime;
9164
+ }
9165
+ }).observe({ type: "largest-contentful-paint", buffered: true });
9166
+ new PerformanceObserver((list) => {
9167
+ for (const entry of list.getEntries()) {
9168
+ window.__ZETA_VITALS__.cls += entry.value ?? 0;
9169
+ }
9170
+ }).observe({ type: "layout-shift", buffered: true });
9171
+ new PerformanceObserver((list) => {
9172
+ for (const entry of list.getEntries()) {
9173
+ if (entry.name === "first-contentful-paint")
9174
+ window.__ZETA_VITALS__.fcp = entry.startTime;
9175
+ }
9176
+ }).observe({ type: "paint", buffered: true });
9177
+ } catch {
9178
+ }
9179
+ }).catch(() => {
9180
+ });
9181
+ await page.goto(pageUrl, { waitUntil: "domcontentloaded", timeout: 25e3 });
9182
+ await page.waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
9183
+ });
9184
+ await page.evaluate(() => {
9185
+ const patterns = [
9186
+ 'button[id*="accept"i]',
9187
+ 'button[class*="accept"i]',
9188
+ 'button[id*="cookie"i][id*="agree"i]',
9189
+ 'button[class*="cookie"i][class*="agree"i]',
9190
+ 'button[data-testid*="accept"i]',
9191
+ 'button[aria-label*="accept"i]',
9192
+ '[id*="onetrust"i] button[class*="accept"i]',
9193
+ "#onetrust-accept-btn-handler",
9194
+ ".cc-accept-all",
9195
+ ".consent-accept"
9196
+ ];
9197
+ for (const sel of patterns) {
9198
+ const el = document.querySelector(sel);
9199
+ if (el) {
9200
+ el.click();
9201
+ return;
9202
+ }
9203
+ }
9204
+ }).catch(() => {
9205
+ });
9206
+ await page.waitForTimeout(500).catch(() => {
9207
+ });
9208
+ await page.addStyleTag({ content: `
9209
+ *, *::before, *::after {
9210
+ animation-duration: 0s !important;
9211
+ animation-delay: 0s !important;
9212
+ transition-duration: 0s !important;
9213
+ transition-delay: 0s !important;
9214
+ }
9215
+ ` }).catch(() => {
9216
+ });
9217
+ await page.waitForFunction(() => {
9218
+ const busy = document.querySelector('[aria-busy="true"]');
9219
+ const skeletons = document.querySelectorAll('[class*="skeleton"],[class*="Skeleton"],[class*="shimmer"],[class*="loading-pulse"]');
9220
+ return !busy && skeletons.length === 0;
9221
+ }, { timeout: 4e3 }).catch(() => {
9222
+ });
9223
+ await page.evaluate(async () => {
9224
+ await new Promise((resolve) => {
9225
+ let last = document.documentElement.scrollHeight;
9226
+ let iters = 0;
9227
+ const timer = setInterval(() => {
9228
+ window.scrollTo(0, document.documentElement.scrollHeight);
9229
+ if (document.documentElement.scrollHeight === last || iters++ > 5) {
9230
+ clearInterval(timer);
9231
+ window.scrollTo(0, 0);
9232
+ resolve();
9233
+ }
9234
+ last = document.documentElement.scrollHeight;
9235
+ }, 400);
9236
+ });
9237
+ }).catch(() => {
9238
+ });
9239
+ const title = await page.title().catch(() => pageUrl);
9240
+ const metaTags = await page.evaluate(() => {
9241
+ const getMeta = (name) => document.querySelector(`meta[name="${name}"], meta[property="${name}"]`)?.getAttribute("content") ?? null;
9242
+ return { description: getMeta("description"), ogImage: getMeta("og:image"), ogType: getMeta("og:type") };
9243
+ }).catch(() => null);
9244
+ const isErrorPage = /^\s*(404|not found|page not found|error|forbidden|403|500|unauthorized|401)\s*$/i.test(title);
9245
+ if (isErrorPage) {
9246
+ send({ type: "crawl_progress", message: `Skipped error page: ${title} (${pageUrl})` });
9247
+ continue;
9248
+ }
9249
+ const webVitals = await page.evaluate(() => {
9250
+ const nav = performance.getEntriesByType("navigation")[0];
9251
+ return {
9252
+ ttfb: nav ? Math.round(nav.responseStart - nav.requestStart) : null,
9253
+ domInteractive: nav ? Math.round(nav.domInteractive) : null,
9254
+ ...window.__ZETA_VITALS__
9255
+ };
9256
+ }).catch(() => null);
9257
+ const dpr = await page.evaluate(() => window.devicePixelRatio || 1).catch(() => 1);
9258
+ const viewport = await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })).catch(() => ({ width: 1440, height: 900 }));
9259
+ const screenshot = await page.screenshot({ type: "jpeg", quality: 85, fullPage: true }).then((b) => b.toString("base64")).catch(() => null);
9260
+ const domSnapshot = await page.content().catch(() => null);
9261
+ const inputs = await page.evaluate(() => {
9262
+ return Array.from(document.querySelectorAll("input,select,textarea")).slice(0, 150).map((el) => {
9263
+ const labelEl = el.id ? document.querySelector(`label[for="${el.id}"]`) : el.closest("label") || el.previousElementSibling?.tagName === "LABEL" ? el.previousElementSibling : null;
9264
+ return {
9265
+ type: el.type || el.tagName.toLowerCase(),
9266
+ name: el.name || "",
9267
+ id: el.id || "",
9268
+ placeholder: el.placeholder || "",
9269
+ label: labelEl?.textContent?.trim() || el.getAttribute("aria-label") || el.getAttribute("aria-labelledby") ? document.getElementById(el.getAttribute("aria-labelledby") || "")?.textContent?.trim() || "" : "",
9270
+ required: el.required || el.getAttribute("aria-required") === "true" || false,
9271
+ value: el.tagName === "SELECT" ? Array.from(el.options).map((o) => o.value).join(",") : el.value || ""
9272
+ };
9273
+ });
9274
+ }).catch(() => []);
9275
+ const links = await page.evaluate((baseUrl) => {
9276
+ const base = new URL(baseUrl);
9277
+ const hrefs = /* @__PURE__ */ new Set();
9278
+ const visited2 = /* @__PURE__ */ new Set();
9279
+ const queue2 = [document.documentElement];
9280
+ while (queue2.length > 0) {
9281
+ const root = queue2.shift();
9282
+ if (!root || visited2.has(root))
9283
+ continue;
9284
+ visited2.add(root);
9285
+ root.querySelectorAll("a[href]").forEach((el) => {
9286
+ try {
9287
+ const href = new URL(el.href, baseUrl).href;
9288
+ if (new URL(href).hostname === base.hostname && !href.includes("#"))
9289
+ hrefs.add(href);
9290
+ } catch {
9291
+ }
9292
+ });
9293
+ root.querySelectorAll("*").forEach((el) => {
9294
+ if (el.shadowRoot)
9295
+ queue2.push(el.shadowRoot);
9296
+ });
9297
+ }
9298
+ try {
9299
+ const nextData = window.__NEXT_DATA__;
9300
+ if (nextData?.buildManifest?.pages) {
9301
+ Object.keys(nextData.buildManifest.pages).forEach((route) => {
9302
+ if (!route.includes("[") && route !== "/_app" && route !== "/_error") {
9303
+ hrefs.add(base.origin + route);
9304
+ }
9305
+ });
9306
+ }
9307
+ } catch {
9308
+ }
9309
+ try {
9310
+ const nuxt = window.__NUXT__;
9311
+ if (Array.isArray(nuxt?.ssrContext?.payload?.modules)) {
9312
+ }
9313
+ } catch {
9314
+ }
9315
+ return Array.from(hrefs).slice(0, 80);
9316
+ }, pageUrl).catch(() => []);
9317
+ const elements = await page.evaluate(() => {
9318
+ const els = [];
9319
+ const seen = /* @__PURE__ */ new Set();
9320
+ const add = (el, role, text, isInteractive, expectedData) => {
9321
+ const t = text.trim().slice(0, 200);
9322
+ if (!t || t.length < 2)
9323
+ return;
9324
+ const key = `${role}::${t.toLowerCase()}`;
9325
+ if (seen.has(key))
9326
+ return;
9327
+ seen.add(key);
9328
+ const rect = el.getBoundingClientRect();
9329
+ const landmark = (() => {
9330
+ let p = el.parentElement;
9331
+ while (p) {
9332
+ const tag = p.tagName?.toLowerCase();
9333
+ const role2 = p.getAttribute("role");
9334
+ if (tag === "main" || role2 === "main")
9335
+ return "main";
9336
+ if (tag === "nav" || role2 === "navigation")
9337
+ return "nav";
9338
+ if (tag === "form" || role2 === "form")
9339
+ return "form";
9340
+ if (tag === "aside" || role2 === "complementary")
9341
+ return "aside";
9342
+ if (tag === "footer" || role2 === "contentinfo")
9343
+ return "footer";
9344
+ if (tag === "header" || role2 === "banner")
9345
+ return "header";
9346
+ if (tag === "dialog" || role2 === "dialog")
9347
+ return "dialog";
9348
+ p = p.parentElement;
9349
+ }
9350
+ return void 0;
9351
+ })();
9352
+ const ariaState = (() => {
9353
+ const attrs = [];
9354
+ const names = ["aria-expanded", "aria-selected", "aria-checked", "aria-pressed", "aria-disabled", "aria-current"];
9355
+ names.forEach((a) => {
9356
+ const v = el.getAttribute(a);
9357
+ if (v)
9358
+ attrs.push(`${a}=${v}`);
9359
+ });
9360
+ return attrs.join(";") || void 0;
9361
+ })();
9362
+ els.push({
9363
+ role,
9364
+ text: t,
9365
+ isInteractive,
9366
+ meaning: t,
9367
+ ...expectedData ? { expectedData } : {},
9368
+ ...ariaState ? { ariaState } : {},
9369
+ ...landmark ? { landmark } : {},
9370
+ boundingRect: rect.width > 0 ? { x: Math.round(rect.x), y: Math.round(rect.y), w: Math.round(rect.width), h: Math.round(rect.height) } : void 0
9371
+ });
9372
+ };
9373
+ const getLabel = (el) => {
9374
+ const ariaLabel = el.getAttribute("aria-label");
9375
+ if (ariaLabel)
9376
+ return ariaLabel;
9377
+ const labelledBy = el.getAttribute("aria-labelledby");
9378
+ if (labelledBy)
9379
+ return document.getElementById(labelledBy)?.textContent?.trim() || "";
9380
+ if (el.id)
9381
+ return document.querySelector(`label[for="${el.id}"]`)?.textContent?.trim() || "";
9382
+ const closestLabel = el.closest("label");
9383
+ if (closestLabel)
9384
+ return closestLabel.textContent?.trim() || "";
9385
+ return el.textContent?.trim() || el.placeholder || el.name || el.value || "";
9386
+ };
9387
+ const inferExpectedData = (el) => {
9388
+ const type = el.type?.toLowerCase() || "";
9389
+ const label = getLabel(el).toLowerCase();
9390
+ if (type === "email" || label.includes("email"))
9391
+ return "valid_email";
9392
+ if (type === "password" || label.includes("password") || label.includes("passwd"))
9393
+ return "password";
9394
+ if (type === "tel" || label.includes("phone") || label.includes("mobile"))
9395
+ return "phone";
9396
+ if (type === "date" || label.includes("date") || label.includes("dob"))
9397
+ return "date";
9398
+ if (label.includes("otp") || label.includes("pin") || label.includes("code"))
9399
+ return "otp";
9400
+ if (type === "search" || label.includes("search") || label.includes("query"))
9401
+ return "search_term";
9402
+ if (type === "number" || label.includes("amount") || label.includes("price"))
9403
+ return "number";
9404
+ return void 0;
9405
+ };
9406
+ document.querySelectorAll('button,[role="button"],[type="submit"],[type="reset"],[type="button"]').forEach((el) => {
9407
+ const text = getLabel(el);
9408
+ if (text)
9409
+ add(el, "button", text, true);
9410
+ });
9411
+ document.querySelectorAll("h1,h2,h3,h4,h5,h6").forEach((el) => {
9412
+ const text = el.textContent?.trim() || "";
9413
+ if (text)
9414
+ add(el, el.tagName.toLowerCase(), text, false);
9415
+ });
9416
+ document.querySelectorAll("a[href]").forEach((el) => {
9417
+ const text = (el.textContent?.trim() || el.getAttribute("aria-label") || "").slice(0, 150);
9418
+ if (text && text.length > 1)
9419
+ add(el, "nav-link", text, true);
9420
+ });
9421
+ document.querySelectorAll('input:not([type="hidden"]):not([type="submit"]):not([type="button"]):not([type="reset"])').forEach((el) => {
9422
+ const label = getLabel(el);
9423
+ if (label)
9424
+ add(el, `input-${el.type || "text"}`, label, true, inferExpectedData(el));
9425
+ });
9426
+ document.querySelectorAll("textarea").forEach((el) => {
9427
+ const label = getLabel(el);
9428
+ if (label)
9429
+ add(el, "textarea", label, true);
9430
+ });
9431
+ document.querySelectorAll("select").forEach((el) => {
9432
+ const label = getLabel(el);
9433
+ if (label)
9434
+ add(el, "select", label, true);
9435
+ });
9436
+ document.querySelectorAll('input[type="checkbox"],input[type="radio"]').forEach((el) => {
9437
+ const label = getLabel(el);
9438
+ if (label)
9439
+ add(el, el.type, label, true);
9440
+ });
9441
+ document.querySelectorAll('[role="tab"],[role="menuitem"],[role="option"],[role="switch"],[role="combobox"],[role="listbox"],[role="slider"],[role="spinbutton"]').forEach((el) => {
9442
+ const text = getLabel(el);
9443
+ if (text)
9444
+ add(el, el.getAttribute("role") || "widget", text, true);
9445
+ });
9446
+ document.querySelectorAll('[class*="card"],[class*="chip"],[class*="badge"],[class*="tag"],[class*="pill"],[class*="metric"],[class*="stat"]').forEach((el) => {
9447
+ const text = el.textContent?.trim()?.slice(0, 100);
9448
+ if (text && text.length > 2)
9449
+ add(el, "card", text, false);
9450
+ });
9451
+ document.querySelectorAll('[role="alert"],[role="status"],[class*="alert"],[class*="toast"],[class*="banner"],[class*="notification"]').forEach((el) => {
9452
+ const text = el.textContent?.trim()?.slice(0, 150);
9453
+ if (text)
9454
+ add(el, "alert", text, false);
9455
+ });
9456
+ document.querySelectorAll('img[alt]:not([alt=""])').forEach((el) => {
9457
+ const text = (el.alt || "").trim().slice(0, 100);
9458
+ if (text && text.length > 2)
9459
+ add(el, "image", text, false);
9460
+ });
9461
+ const shadowQueue = [document.documentElement];
9462
+ const shadowSeen = /* @__PURE__ */ new Set();
9463
+ while (shadowQueue.length) {
9464
+ const root = shadowQueue.shift();
9465
+ if (!root || shadowSeen.has(root))
9466
+ continue;
9467
+ shadowSeen.add(root);
9468
+ root.querySelectorAll('button,[role="button"]').forEach((el) => {
9469
+ const text = getLabel(el);
9470
+ if (text)
9471
+ add(el, "button", text, true);
9472
+ });
9473
+ root.querySelectorAll("*").forEach((el) => {
9474
+ if (el.shadowRoot)
9475
+ shadowQueue.push(el.shadowRoot);
9476
+ });
9477
+ }
9478
+ return els.slice(0, 400);
9479
+ }).catch(() => []);
9480
+ const a11ySnapshot = await page.accessibility.snapshot({ interestingOnly: false }).catch(() => null);
9481
+ let a11yViolations = [];
9482
+ if (a11ySnapshot) {
9483
+ const flattenTree = (node, out = []) => {
9484
+ if (node) {
9485
+ out.push(node);
9486
+ (node.children ?? []).forEach((c) => flattenTree(c, out));
9487
+ }
9488
+ return out;
9489
+ };
9490
+ const nodes = flattenTree(a11ySnapshot);
9491
+ const unlabeled = nodes.filter(
9492
+ (n) => ["textbox", "combobox", "listbox", "spinbutton", "searchbox"].includes(n.role) && !n.name
9493
+ );
9494
+ if (unlabeled.length > 0) {
9495
+ a11yViolations.push({
9496
+ id: "label-missing",
9497
+ impact: "critical",
9498
+ description: `${unlabeled.length} form control(s) have no accessible name`,
9499
+ nodes: unlabeled.length
9500
+ });
9501
+ }
9502
+ const imgNoAlt = nodes.filter((n) => n.role === "img" && !n.name);
9503
+ if (imgNoAlt.length > 0) {
9504
+ a11yViolations.push({
9505
+ id: "image-alt",
9506
+ impact: "critical",
9507
+ description: `${imgNoAlt.length} image(s) missing alt text`,
9508
+ nodes: imgNoAlt.length
9509
+ });
9510
+ }
9511
+ const hasH1 = nodes.some((n) => n.role === "heading" && n.level === 1);
9512
+ if (!hasH1) {
9513
+ a11yViolations.push({
9514
+ id: "page-has-heading-one",
9515
+ impact: "moderate",
9516
+ description: "Page does not contain an h1 heading",
9517
+ nodes: 1
9518
+ });
9519
+ }
9520
+ const noName = nodes.filter(
9521
+ (n) => ["button", "link"].includes(n.role) && !n.name
9522
+ );
9523
+ if (noName.length > 0) {
9524
+ a11yViolations.push({
9525
+ id: "interactive-no-name",
9526
+ impact: "serious",
9527
+ description: `${noName.length} interactive element(s) have no accessible name`,
9528
+ nodes: noName.length
9529
+ });
9530
+ }
9531
+ }
9532
+ const pageText = await page.evaluate(() => {
9533
+ const lines = [];
9534
+ const walk = (node, depth = 0) => {
9535
+ const tag = node.tagName?.toLowerCase();
9536
+ if (!tag)
9537
+ return;
9538
+ if (["script", "style", "noscript", "svg", "iframe"].includes(tag))
9539
+ return;
9540
+ if (["h1", "h2", "h3", "h4", "h5", "h6"].includes(tag)) {
9541
+ const t = node.textContent?.trim();
9542
+ if (t)
9543
+ lines.push(`${"#".repeat(parseInt(tag[1]))} ${t}`);
9544
+ } else if (tag === "p" || tag === "li") {
9545
+ const t = node.textContent?.trim()?.slice(0, 300);
9546
+ if (t && t.length > 10)
9547
+ lines.push(t);
9548
+ } else {
9549
+ Array.from(node.children).forEach((c) => walk(c, depth + 1));
9550
+ return;
9551
+ }
9552
+ };
9553
+ document.querySelectorAll('main, article, [role="main"], body > div').forEach((root) => walk(root));
9554
+ return lines.slice(0, 200).join("\n");
9555
+ }).catch(() => "");
9556
+ const contentHash = pageText ? pageText.slice(0, 500).replace(/\s+/g, " ") : "";
9557
+ if (contentHash && contentHash.length > 50 && contentHashes.has(contentHash)) {
9558
+ send({ type: "crawl_progress", message: `Skipped duplicate content: ${pageUrl}` });
9559
+ continue;
9560
+ }
9561
+ if (contentHash && contentHash.length > 50) {
9562
+ contentHashes.add(contentHash);
9563
+ }
9564
+ const viewportScreenshots = {};
9565
+ const extraProfiles = deviceProfiles.filter((p) => p !== "DESKTOP" && DEVICE_VIEWPORTS[p]);
9566
+ for (const profile of extraProfiles) {
9567
+ const vp = DEVICE_VIEWPORTS[profile];
9568
+ const vpCtx = await browser.newContext({
9569
+ viewport: { width: vp.width, height: vp.height },
9570
+ isMobile: vp.isMobile ?? false,
9571
+ deviceScaleFactor: vp.deviceScaleFactor ?? 1,
9572
+ userAgent: vp.isMobile ? "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" : contextOptions.userAgent
9573
+ }).catch(() => null);
9574
+ if (!vpCtx)
9575
+ continue;
9576
+ const vpPage = await vpCtx.newPage().catch(() => null);
9577
+ if (!vpPage) {
9578
+ await vpCtx.close().catch(() => {
9579
+ });
9580
+ continue;
9581
+ }
9582
+ try {
9583
+ await vpPage.goto(pageUrl, { waitUntil: "domcontentloaded", timeout: 2e4 });
9584
+ await vpPage.waitForLoadState("networkidle", { timeout: 5e3 }).catch(() => {
9585
+ });
9586
+ await vpPage.addStyleTag({ content: "*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }" }).catch(() => {
9587
+ });
9588
+ const vpShot = await vpPage.screenshot({ type: "jpeg", quality: 80, fullPage: true }).then((b) => b.toString("base64")).catch(() => null);
9589
+ viewportScreenshots[profile] = vpShot;
9590
+ } finally {
9591
+ await vpPage.close().catch(() => {
9592
+ });
9593
+ await vpCtx.close().catch(() => {
9594
+ });
9595
+ }
9596
+ }
9597
+ screensCount++;
9598
+ send({
9599
+ type: "crawl_screen",
9600
+ screen: { url: pageUrl, title, screenshot, domSnapshot, inputs, links, elements, apiEndpoints: observedApiEndpoints, dpr, viewport, pageText, viewportScreenshots, a11yViolations, webVitals, meta: metaTags }
9601
+ });
9602
+ for (const link of links) {
9603
+ const norm = normalizeUrl(link);
9604
+ if (!visited.has(norm) && !queue.some((q) => normalizeUrl(q) === norm)) {
9605
+ enqueue(link);
9606
+ }
9607
+ }
9608
+ send({ type: "crawl_progress", message: `Crawled ${screensCount}/${maxScreens}: ${title || pageUrl}` });
9609
+ } finally {
9610
+ await page.close().catch(() => {
9611
+ });
9612
+ }
9613
+ }
9614
+ try {
9615
+ const exportedState = await context.storageState();
9616
+ if (exportedState.cookies.length > 0 || exportedState.origins.length > 0) {
9617
+ send({ type: "crawl_session_state", storageState: JSON.stringify(exportedState) });
9618
+ }
9619
+ } catch {
9620
+ }
9621
+ await browser.close().catch(() => {
9622
+ });
9623
+ send({ type: "crawl_done", totalScreens: screensCount });
9624
+ } catch (err) {
9625
+ console.error("[ZeTa Agent] Crawl error:", err?.message ?? err);
9626
+ send({ type: "crawl_done", totalScreens: 0, error: err?.message ?? "Local crawl failed" });
9627
+ }
9628
+ }
9629
+ async function handleLocalDiscover(msg, ws) {
9630
+ const { discoverJobId, url, maxDiscoverUrls = 2e3, storageState } = msg;
9631
+ const send = (payload) => {
9632
+ if (ws.readyState === 1)
9633
+ ws.send(JSON.stringify({ ...payload, discoverJobId }));
9634
+ };
9635
+ try {
9636
+ let chromium;
9637
+ try {
9638
+ chromium = (await import("playwright")).chromium;
9639
+ } catch {
9640
+ const { execFileSync: execFileSync2 } = await import("child_process");
9641
+ const { resolve: resolve2 } = await import("path");
9642
+ const agentDir2 = resolve2(__dirname, "..");
9643
+ const npx = process.platform === "win32" ? "npx.cmd" : "npx";
9644
+ execFileSync2(npx, ["playwright", "install", "chromium"], { cwd: agentDir2, stdio: "pipe", timeout: 3e5 });
9645
+ chromium = (await import("playwright")).chromium;
9646
+ }
9647
+ const browser = await chromium.launch({ headless: true });
9648
+ const contextOptions = {
9649
+ viewport: { width: 1440, height: 900 },
9650
+ userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
9651
+ };
9652
+ if (storageState) {
9653
+ try {
9654
+ contextOptions.storageState = JSON.parse(storageState);
9655
+ } catch {
9656
+ }
9657
+ }
9658
+ const context = await browser.newContext(contextOptions);
9659
+ const page = await context.newPage();
9660
+ const origin = new URL(url).origin;
9661
+ const visited = /* @__PURE__ */ new Set();
9662
+ const queue = [url];
9663
+ const allUrls = /* @__PURE__ */ new Set([url]);
9664
+ let pagesVisited = 0;
9665
+ const normalizeDiscoverUrl = (u) => {
9666
+ try {
9667
+ const p = new URL(u);
9668
+ p.hash = "";
9669
+ p.search = p.search.replace(/[?&](utm_[^&]*|ref|source|medium|campaign|fbclid|gclid)=[^&]*/g, "").replace(/^[?&]/, "?");
9670
+ return p.href.replace(/\/$/, "") || p.origin;
9671
+ } catch {
9672
+ return u;
9673
+ }
9674
+ };
9675
+ try {
9676
+ const sitemapResp = await page.goto(`${origin}/sitemap.xml`, { waitUntil: "domcontentloaded", timeout: 8e3 });
9677
+ if (sitemapResp?.ok()) {
9678
+ const sitemapContent = await page.content();
9679
+ const locs = [...sitemapContent.matchAll(/<loc[^>]*>([^<]+)<\/loc>/gi)];
9680
+ for (const match of locs) {
9681
+ const loc = match[1].trim();
9682
+ try {
9683
+ const locUrl = new URL(loc);
9684
+ if (locUrl.origin === origin) {
9685
+ allUrls.add(loc);
9686
+ if (!visited.has(normalizeDiscoverUrl(loc)) && !queue.some((q) => normalizeDiscoverUrl(q) === normalizeDiscoverUrl(loc))) {
9687
+ queue.push(loc);
9688
+ }
9689
+ }
9690
+ } catch {
9691
+ }
9692
+ }
9693
+ if (allUrls.size > 1) {
9694
+ send({ type: "discover_progress", partialUrls: Array.from(allUrls), pct: 5 });
9695
+ }
9696
+ }
9697
+ } catch {
9698
+ }
9699
+ while (queue.length > 0 && allUrls.size < maxDiscoverUrls) {
9700
+ const pageUrl = queue.shift();
9701
+ const norm = normalizeDiscoverUrl(pageUrl);
9702
+ if (visited.has(norm))
9703
+ continue;
9704
+ visited.add(norm);
9705
+ try {
9706
+ await page.goto(pageUrl, { waitUntil: "domcontentloaded", timeout: 2e4 });
9707
+ await page.waitForTimeout(500);
9708
+ pagesVisited++;
9709
+ const links = await page.evaluate((origin2) => {
9710
+ const hrefs = [];
9711
+ const queue2 = [document.documentElement];
9712
+ while (queue2.length > 0) {
9713
+ const node = queue2.shift();
9714
+ if (node.tagName === "A") {
9715
+ try {
9716
+ const abs = new URL(node.href, location.href).href;
9717
+ if (abs.startsWith(origin2))
9718
+ hrefs.push(abs);
9719
+ } catch {
9720
+ }
9721
+ }
9722
+ queue2.push(...Array.from(node.children));
9723
+ if (node.shadowRoot) {
9724
+ queue2.push(...Array.from(node.shadowRoot.children));
9725
+ }
9726
+ }
9727
+ return hrefs;
9728
+ }, origin).catch(() => []);
9729
+ for (const link of links) {
9730
+ const n = normalizeDiscoverUrl(link);
9731
+ if (!visited.has(n) && !queue.some((q) => normalizeDiscoverUrl(q) === n)) {
9732
+ allUrls.add(link);
9733
+ queue.push(link);
9734
+ }
9735
+ }
9736
+ if (pagesVisited % 10 === 0) {
9737
+ const pct = Math.min(Math.round(allUrls.size / maxDiscoverUrls * 90), 90);
9738
+ send({ type: "discover_progress", partialUrls: Array.from(allUrls), pct });
9739
+ }
9740
+ } catch {
9741
+ }
9742
+ }
9743
+ await page.close().catch(() => {
9744
+ });
9745
+ await browser.close().catch(() => {
9746
+ });
9747
+ send({ type: "discover_done", discoveredUrls: Array.from(allUrls) });
9748
+ } catch (err) {
9749
+ console.error("[ZeTa Agent] Discover error:", err?.message ?? err);
9750
+ send({ type: "discover_done", discoveredUrls: [], error: err?.message ?? "Local discovery failed" });
9751
+ }
9752
+ }
8856
9753
  function buildRunCommand(language, scriptPath, _baseUrl) {
8857
9754
  if (language.startsWith("playwright")) {
8858
9755
  return { cmd: "npx", args: ["playwright", "test", scriptPath, "--reporter=json", "--reporter=line"] };