browserclaw 0.5.3 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -829,15 +829,16 @@ async function connectBrowser(cdpUrl, authToken) {
829
829
  const headers = {};
830
830
  if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
831
831
  const browser = await playwrightCore.chromium.connectOverCDP(endpoint, { timeout, headers });
832
- const connected = { browser, cdpUrl: normalized, authToken };
833
- cached = connected;
834
- observeBrowser(browser);
835
- browser.on("disconnected", () => {
832
+ const onDisconnected = () => {
836
833
  if (cached?.browser === browser) cached = null;
837
834
  for (const key of roleRefsByTarget.keys()) {
838
835
  if (key.startsWith(normalized + "::")) roleRefsByTarget.delete(key);
839
836
  }
840
- });
837
+ };
838
+ const connected = { browser, cdpUrl: normalized, authToken, onDisconnected };
839
+ cached = connected;
840
+ observeBrowser(browser);
841
+ browser.on("disconnected", onDisconnected);
841
842
  return connected;
842
843
  } catch (err) {
843
844
  lastErr = err;
@@ -867,6 +868,14 @@ async function disconnectBrowser() {
867
868
  if (cur) await cur.browser.close().catch(() => {
868
869
  });
869
870
  }
871
+ function cdpSocketNeedsAttach(wsUrl) {
872
+ try {
873
+ const pathname = new URL(wsUrl).pathname;
874
+ return pathname === "/cdp" || pathname.endsWith("/cdp") || pathname.includes("/devtools/browser/") || pathname === "/";
875
+ } catch {
876
+ return false;
877
+ }
878
+ }
870
879
  async function tryTerminateExecutionViaCdp(cdpUrl, targetId) {
871
880
  const httpBase = normalizeCdpHttpBaseForJsonEndpoints(cdpUrl);
872
881
  const ctrl = new AbortController();
@@ -883,8 +892,10 @@ async function tryTerminateExecutionViaCdp(cdpUrl, targetId) {
883
892
  }
884
893
  if (!Array.isArray(targets)) return;
885
894
  const target = targets.find((entry) => String(entry?.id ?? "").trim() === targetId);
886
- const wsUrl = String(target?.webSocketDebuggerUrl ?? "").trim();
887
- if (!wsUrl) return;
895
+ const wsUrlRaw = String(target?.webSocketDebuggerUrl ?? "").trim();
896
+ if (!wsUrlRaw) return;
897
+ const wsUrl = normalizeCdpWsUrl(wsUrlRaw, httpBase);
898
+ const needsAttach = cdpSocketNeedsAttach(wsUrl);
888
899
  await new Promise((resolve2) => {
889
900
  let done = false;
890
901
  const finish = () => {
@@ -897,8 +908,9 @@ async function tryTerminateExecutionViaCdp(cdpUrl, targetId) {
897
908
  }
898
909
  resolve2();
899
910
  };
900
- const timer = setTimeout(finish, 2e3);
911
+ const timer = setTimeout(finish, 3e3);
901
912
  let ws;
913
+ let nextId = 1;
902
914
  try {
903
915
  ws = new WebSocket(wsUrl);
904
916
  } catch {
@@ -906,11 +918,27 @@ async function tryTerminateExecutionViaCdp(cdpUrl, targetId) {
906
918
  return;
907
919
  }
908
920
  ws.onopen = () => {
921
+ if (needsAttach) {
922
+ ws.send(JSON.stringify({ id: nextId++, method: "Target.attachToTarget", params: { targetId, flatten: true } }));
923
+ } else {
924
+ ws.send(JSON.stringify({ id: nextId++, method: "Runtime.terminateExecution" }));
925
+ setTimeout(finish, 300);
926
+ }
927
+ };
928
+ ws.onmessage = (event) => {
929
+ if (!needsAttach) return;
909
930
  try {
910
- ws.send(JSON.stringify({ id: 1, method: "Runtime.terminateExecution" }));
931
+ const msg = JSON.parse(String(event.data));
932
+ if (msg.id && msg.result?.sessionId) {
933
+ ws.send(JSON.stringify({ id: nextId++, sessionId: msg.result.sessionId, method: "Runtime.terminateExecution" }));
934
+ try {
935
+ ws.send(JSON.stringify({ id: nextId++, method: "Target.detachFromTarget", params: { sessionId: msg.result.sessionId } }));
936
+ } catch {
937
+ }
938
+ setTimeout(finish, 300);
939
+ }
911
940
  } catch {
912
941
  }
913
- setTimeout(finish, 300);
914
942
  };
915
943
  ws.onerror = () => finish();
916
944
  ws.onclose = () => finish();
@@ -922,6 +950,9 @@ async function forceDisconnectPlaywrightForTarget(opts) {
922
950
  if (!cur || cur.cdpUrl !== normalized) return;
923
951
  cached = null;
924
952
  connectingByUrl.delete(normalized);
953
+ if (cur.onDisconnected && typeof cur.browser.off === "function") {
954
+ cur.browser.off("disconnected", cur.onDisconnected);
955
+ }
925
956
  const targetId = opts.targetId?.trim() || "";
926
957
  if (targetId) {
927
958
  await tryTerminateExecutionViaCdp(normalized, targetId).catch(() => {
@@ -1512,20 +1543,6 @@ function isBlockedHostnameNormalized(normalized) {
1512
1543
  if (BLOCKED_HOSTNAMES.has(normalized)) return true;
1513
1544
  return normalized.endsWith(".localhost") || normalized.endsWith(".local") || normalized.endsWith(".internal");
1514
1545
  }
1515
- function isStrictDecimalOctet(part) {
1516
- if (!/^[0-9]+$/.test(part)) return false;
1517
- const n = parseInt(part, 10);
1518
- if (n < 0 || n > 255) return false;
1519
- if (String(n) !== part) return false;
1520
- return true;
1521
- }
1522
- function isUnsupportedIPv4Literal(ip) {
1523
- if (/^[0-9]+$/.test(ip)) return true;
1524
- const parts = ip.split(".");
1525
- if (parts.length !== 4) return true;
1526
- if (!parts.every(isStrictDecimalOctet)) return true;
1527
- return false;
1528
- }
1529
1546
  var BLOCKED_IPV4_RANGES = /* @__PURE__ */ new Set([
1530
1547
  "unspecified",
1531
1548
  "broadcast",
@@ -1544,6 +1561,97 @@ var BLOCKED_IPV6_RANGES = /* @__PURE__ */ new Set([
1544
1561
  "multicast"
1545
1562
  ]);
1546
1563
  var RFC2544_BENCHMARK_PREFIX = [ipaddr__namespace.IPv4.parse("198.18.0.0"), 15];
1564
+ var EMBEDDED_IPV4_SENTINEL_RULES = [
1565
+ // IPv4-compatible (::a.b.c.d)
1566
+ {
1567
+ matches: (parts) => parts[0] === 0 && parts[1] === 0 && parts[2] === 0 && parts[3] === 0 && parts[4] === 0 && parts[5] === 0,
1568
+ toHextets: (parts) => [parts[6], parts[7]]
1569
+ },
1570
+ // NAT64 local-use (64:ff9b:1::/48)
1571
+ {
1572
+ matches: (parts) => parts[0] === 100 && parts[1] === 65435 && parts[2] === 1 && parts[3] === 0 && parts[4] === 0 && parts[5] === 0,
1573
+ toHextets: (parts) => [parts[6], parts[7]]
1574
+ },
1575
+ // 6to4 (2002::/16)
1576
+ {
1577
+ matches: (parts) => parts[0] === 8194,
1578
+ toHextets: (parts) => [parts[1], parts[2]]
1579
+ },
1580
+ // Teredo (2001:0000::/32) — IPv4 XOR'd
1581
+ {
1582
+ matches: (parts) => parts[0] === 8193 && parts[1] === 0,
1583
+ toHextets: (parts) => [parts[6] ^ 65535, parts[7] ^ 65535]
1584
+ },
1585
+ // ISATAP — sentinel in parts[4-5]: 0x0000:0x5efe or 0x0200:0x5efe
1586
+ {
1587
+ matches: (parts) => (parts[4] & 64767) === 0 && parts[5] === 24318,
1588
+ toHextets: (parts) => [parts[6], parts[7]]
1589
+ }
1590
+ ];
1591
+ function stripIpv6Brackets(value) {
1592
+ if (value.startsWith("[") && value.endsWith("]")) return value.slice(1, -1);
1593
+ return value;
1594
+ }
1595
+ function isNumericIpv4LiteralPart(value) {
1596
+ return /^[0-9]+$/.test(value) || /^0x[0-9a-f]+$/i.test(value);
1597
+ }
1598
+ function parseIpv6WithEmbeddedIpv4(raw) {
1599
+ if (!raw.includes(":") || !raw.includes(".")) return;
1600
+ const match = /^(.*:)([^:%]+(?:\.[^:%]+){3})(%[0-9A-Za-z]+)?$/i.exec(raw);
1601
+ if (!match) return;
1602
+ const [, prefix, embeddedIpv4, zoneSuffix = ""] = match;
1603
+ if (!ipaddr__namespace.IPv4.isValidFourPartDecimal(embeddedIpv4)) return;
1604
+ const octets = embeddedIpv4.split(".").map((part) => Number.parseInt(part, 10));
1605
+ const normalizedIpv6 = `${prefix}${(octets[0] << 8 | octets[1]).toString(16)}:${(octets[2] << 8 | octets[3]).toString(16)}${zoneSuffix}`;
1606
+ if (!ipaddr__namespace.IPv6.isValid(normalizedIpv6)) return;
1607
+ return ipaddr__namespace.IPv6.parse(normalizedIpv6);
1608
+ }
1609
+ function normalizeIpParseInput(raw) {
1610
+ const trimmed = raw?.trim();
1611
+ if (!trimmed) return;
1612
+ return stripIpv6Brackets(trimmed);
1613
+ }
1614
+ function parseCanonicalIpAddress(raw) {
1615
+ const normalized = normalizeIpParseInput(raw);
1616
+ if (!normalized) return;
1617
+ if (ipaddr__namespace.IPv4.isValid(normalized)) {
1618
+ if (!ipaddr__namespace.IPv4.isValidFourPartDecimal(normalized)) return;
1619
+ return ipaddr__namespace.IPv4.parse(normalized);
1620
+ }
1621
+ if (ipaddr__namespace.IPv6.isValid(normalized)) return ipaddr__namespace.IPv6.parse(normalized);
1622
+ return parseIpv6WithEmbeddedIpv4(normalized);
1623
+ }
1624
+ function parseLooseIpAddress(raw) {
1625
+ const normalized = normalizeIpParseInput(raw);
1626
+ if (!normalized) return;
1627
+ if (ipaddr__namespace.isValid(normalized)) return ipaddr__namespace.parse(normalized);
1628
+ return parseIpv6WithEmbeddedIpv4(normalized);
1629
+ }
1630
+ function isCanonicalDottedDecimalIPv4(raw) {
1631
+ const trimmed = raw?.trim();
1632
+ if (!trimmed) return false;
1633
+ const normalized = stripIpv6Brackets(trimmed);
1634
+ if (!normalized) return false;
1635
+ return ipaddr__namespace.IPv4.isValidFourPartDecimal(normalized);
1636
+ }
1637
+ function isLegacyIpv4Literal(raw) {
1638
+ const trimmed = raw?.trim();
1639
+ if (!trimmed) return false;
1640
+ const normalized = stripIpv6Brackets(trimmed);
1641
+ if (!normalized || normalized.includes(":")) return false;
1642
+ if (isCanonicalDottedDecimalIPv4(normalized)) return false;
1643
+ const parts = normalized.split(".");
1644
+ if (parts.length === 0 || parts.length > 4) return false;
1645
+ if (parts.some((part) => part.length === 0)) return false;
1646
+ if (!parts.every((part) => isNumericIpv4LiteralPart(part))) return false;
1647
+ return true;
1648
+ }
1649
+ function looksLikeUnsupportedIpv4Literal(address) {
1650
+ const parts = address.split(".");
1651
+ if (parts.length === 0 || parts.length > 4) return false;
1652
+ if (parts.some((part) => part.length === 0)) return true;
1653
+ return parts.every((part) => /^[0-9]+$/.test(part) || /^0x/i.test(part));
1654
+ }
1547
1655
  function isBlockedSpecialUseIpv4Address(address, opts) {
1548
1656
  const inRfc2544 = address.match(RFC2544_BENCHMARK_PREFIX);
1549
1657
  if (inRfc2544 && opts?.allowRfc2544BenchmarkRange === true) return false;
@@ -1553,89 +1661,50 @@ function isBlockedSpecialUseIpv6Address(address) {
1553
1661
  if (BLOCKED_IPV6_RANGES.has(address.range())) return true;
1554
1662
  return (address.parts[0] & 65472) === 65216;
1555
1663
  }
1556
- function extractEmbeddedIpv4FromIpv6(v6, opts) {
1557
- if (v6.isIPv4MappedAddress()) {
1558
- return isBlockedSpecialUseIpv4Address(v6.toIPv4Address(), opts);
1559
- }
1560
- const parts = v6.parts;
1561
- if (parts[0] === 100 && parts[1] === 65435 && parts[2] === 0 && parts[3] === 0 && parts[4] === 0 && parts[5] === 0) {
1562
- const ip4str = `${parts[6] >> 8 & 255}.${parts[6] & 255}.${parts[7] >> 8 & 255}.${parts[7] & 255}`;
1563
- try {
1564
- return isBlockedSpecialUseIpv4Address(ipaddr__namespace.IPv4.parse(ip4str), opts);
1565
- } catch {
1566
- return true;
1567
- }
1568
- }
1569
- if (parts[0] === 100 && parts[1] === 65435 && parts[2] === 1) {
1570
- const ip4str = `${parts[6] >> 8 & 255}.${parts[6] & 255}.${parts[7] >> 8 & 255}.${parts[7] & 255}`;
1571
- try {
1572
- return isBlockedSpecialUseIpv4Address(ipaddr__namespace.IPv4.parse(ip4str), opts);
1573
- } catch {
1574
- return true;
1575
- }
1576
- }
1577
- if (parts[0] === 8194) {
1578
- const ip4str = `${parts[1] >> 8 & 255}.${parts[1] & 255}.${parts[2] >> 8 & 255}.${parts[2] & 255}`;
1579
- try {
1580
- return isBlockedSpecialUseIpv4Address(ipaddr__namespace.IPv4.parse(ip4str), opts);
1581
- } catch {
1582
- return true;
1583
- }
1584
- }
1585
- if (parts[0] === 8193 && parts[1] === 0) {
1586
- const hiXored = parts[6] ^ 65535;
1587
- const loXored = parts[7] ^ 65535;
1588
- const ip4str = `${hiXored >> 8 & 255}.${hiXored & 255}.${loXored >> 8 & 255}.${loXored & 255}`;
1589
- try {
1590
- return isBlockedSpecialUseIpv4Address(ipaddr__namespace.IPv4.parse(ip4str), opts);
1591
- } catch {
1592
- return true;
1593
- }
1594
- }
1595
- if (parts[0] === 0 && parts[1] === 0 && parts[2] === 0 && parts[3] === 0 && parts[4] === 65535 && parts[5] === 0) {
1596
- const ip4str = `${parts[6] >> 8 & 255}.${parts[6] & 255}.${parts[7] >> 8 & 255}.${parts[7] & 255}`;
1597
- try {
1598
- return isBlockedSpecialUseIpv4Address(ipaddr__namespace.IPv4.parse(ip4str), opts);
1599
- } catch {
1600
- return true;
1601
- }
1602
- }
1603
- if (parts[0] === 0 && parts[1] === 0 && parts[2] === 0 && parts[3] === 0 && parts[4] === 0 && parts[5] === 0) {
1604
- const ip4str = `${parts[6] >> 8 & 255}.${parts[6] & 255}.${parts[7] >> 8 & 255}.${parts[7] & 255}`;
1605
- try {
1606
- return isBlockedSpecialUseIpv4Address(ipaddr__namespace.IPv4.parse(ip4str), opts);
1607
- } catch {
1608
- return true;
1609
- }
1610
- }
1611
- if ((parts[4] & 65023) === 0 && parts[5] === 24318) {
1612
- const ip4str = `${parts[6] >> 8 & 255}.${parts[6] & 255}.${parts[7] >> 8 & 255}.${parts[7] & 255}`;
1613
- try {
1614
- return isBlockedSpecialUseIpv4Address(ipaddr__namespace.IPv4.parse(ip4str), opts);
1615
- } catch {
1616
- return true;
1617
- }
1664
+ function decodeIpv4FromHextets(high, low) {
1665
+ const octets = [
1666
+ high >>> 8 & 255,
1667
+ high & 255,
1668
+ low >>> 8 & 255,
1669
+ low & 255
1670
+ ];
1671
+ return ipaddr__namespace.IPv4.parse(octets.join("."));
1672
+ }
1673
+ function extractEmbeddedIpv4FromIpv6(address) {
1674
+ if (address.isIPv4MappedAddress()) return address.toIPv4Address();
1675
+ if (address.range() === "rfc6145") return decodeIpv4FromHextets(address.parts[6], address.parts[7]);
1676
+ if (address.range() === "rfc6052") return decodeIpv4FromHextets(address.parts[6], address.parts[7]);
1677
+ for (const rule of EMBEDDED_IPV4_SENTINEL_RULES) {
1678
+ if (!rule.matches(address.parts)) continue;
1679
+ const [high, low] = rule.toHextets(address.parts);
1680
+ return decodeIpv4FromHextets(high, low);
1618
1681
  }
1619
- return null;
1620
1682
  }
1621
- function isPrivateIpAddress(address, opts) {
1683
+ function resolveIpv4SpecialUseBlockOptions(policy) {
1684
+ return { allowRfc2544BenchmarkRange: policy?.allowRfc2544BenchmarkRange === true };
1685
+ }
1686
+ function isBlockedHostnameOrIp(hostname, policy) {
1687
+ const normalized = normalizeHostname(hostname);
1688
+ if (!normalized) return false;
1689
+ return isBlockedHostnameNormalized(normalized) || isPrivateIpAddress(normalized, policy);
1690
+ }
1691
+ function isPrivateIpAddress(address, policy) {
1622
1692
  let normalized = address.trim().toLowerCase();
1623
1693
  if (normalized.startsWith("[") && normalized.endsWith("]")) normalized = normalized.slice(1, -1);
1624
1694
  if (!normalized) return false;
1625
- try {
1626
- const parsed = ipaddr__namespace.parse(normalized);
1627
- if (parsed.kind() === "ipv4") {
1628
- return isBlockedSpecialUseIpv4Address(parsed, opts);
1629
- }
1630
- const v6 = parsed;
1695
+ const blockOptions = resolveIpv4SpecialUseBlockOptions(policy);
1696
+ const strictIp = parseCanonicalIpAddress(normalized);
1697
+ if (strictIp) {
1698
+ if (strictIp.kind() === "ipv4") return isBlockedSpecialUseIpv4Address(strictIp, blockOptions);
1699
+ const v6 = strictIp;
1631
1700
  if (isBlockedSpecialUseIpv6Address(v6)) return true;
1632
- const embeddedV4 = extractEmbeddedIpv4FromIpv6(v6, opts);
1633
- if (embeddedV4 !== null) return embeddedV4;
1701
+ const embeddedIpv4 = extractEmbeddedIpv4FromIpv6(v6);
1702
+ if (embeddedIpv4) return isBlockedSpecialUseIpv4Address(embeddedIpv4, blockOptions);
1634
1703
  return false;
1635
- } catch {
1636
1704
  }
1637
- if (!normalized.includes(":") && isUnsupportedIPv4Literal(normalized)) return true;
1638
- if (normalized.includes(":")) return true;
1705
+ if (normalized.includes(":") && !parseLooseIpAddress(normalized)) return true;
1706
+ if (!isCanonicalDottedDecimalIPv4(normalized) && isLegacyIpv4Literal(normalized)) return true;
1707
+ if (looksLikeUnsupportedIpv4Literal(normalized)) return true;
1639
1708
  return false;
1640
1709
  }
1641
1710
  function normalizeHostnameSet(values) {
@@ -1717,13 +1786,7 @@ async function resolvePinnedHostnameWithPolicy(hostname, params = {}) {
1717
1786
  );
1718
1787
  }
1719
1788
  if (!skipPrivateNetworkChecks) {
1720
- if (isBlockedHostnameNormalized(normalized)) {
1721
- throw new InvalidBrowserNavigationUrlError(
1722
- `Navigation to internal/loopback address blocked: "${hostname}". ssrfPolicy.dangerouslyAllowPrivateNetwork is false (strict mode).`
1723
- );
1724
- }
1725
- const ipOpts = { allowRfc2544BenchmarkRange: params.policy?.allowRfc2544BenchmarkRange };
1726
- if (isPrivateIpAddress(normalized, ipOpts)) {
1789
+ if (isBlockedHostnameOrIp(normalized, params.policy)) {
1727
1790
  throw new InvalidBrowserNavigationUrlError(
1728
1791
  `Navigation to internal/loopback address blocked: "${hostname}". ssrfPolicy.dangerouslyAllowPrivateNetwork is false (strict mode).`
1729
1792
  );
@@ -1744,9 +1807,8 @@ async function resolvePinnedHostnameWithPolicy(hostname, params = {}) {
1744
1807
  );
1745
1808
  }
1746
1809
  if (!skipPrivateNetworkChecks) {
1747
- const ipOpts = { allowRfc2544BenchmarkRange: params.policy?.allowRfc2544BenchmarkRange };
1748
1810
  for (const r of results) {
1749
- if (isPrivateIpAddress(r.address, ipOpts)) {
1811
+ if (isBlockedHostnameOrIp(r.address, params.policy)) {
1750
1812
  throw new InvalidBrowserNavigationUrlError(
1751
1813
  `Navigation to internal/loopback address blocked: "${hostname}" resolves to "${r.address}". ssrfPolicy.dangerouslyAllowPrivateNetwork is false (strict mode).`
1752
1814
  );
@@ -1767,6 +1829,7 @@ async function resolvePinnedHostnameWithPolicy(hostname, params = {}) {
1767
1829
  }
1768
1830
  async function assertBrowserNavigationAllowed(opts) {
1769
1831
  const rawUrl = String(opts.url ?? "").trim();
1832
+ if (!rawUrl) throw new InvalidBrowserNavigationUrlError("url is required");
1770
1833
  let parsed;
1771
1834
  try {
1772
1835
  parsed = new URL(rawUrl);
@@ -1923,12 +1986,30 @@ function resolveBoundedDelayMs(value, label, maxMs) {
1923
1986
  if (normalized > maxMs) throw new Error(`${label} exceeds maximum of ${maxMs}ms`);
1924
1987
  return normalized;
1925
1988
  }
1926
- async function clickViaPlaywright(opts) {
1989
+ function resolveInteractionTimeoutMs(timeoutMs) {
1990
+ return Math.max(500, Math.min(6e4, Math.floor(timeoutMs ?? 8e3)));
1991
+ }
1992
+ function requireRefOrSelector(ref, selector) {
1993
+ const trimmedRef = typeof ref === "string" ? ref.trim() : "";
1994
+ const trimmedSelector = typeof selector === "string" ? selector.trim() : "";
1995
+ if (!trimmedRef && !trimmedSelector) throw new Error("ref or selector is required");
1996
+ return { ref: trimmedRef || void 0, selector: trimmedSelector || void 0 };
1997
+ }
1998
+ function resolveLocator(page, resolved) {
1999
+ return resolved.ref ? refLocator(page, resolved.ref) : page.locator(resolved.selector);
2000
+ }
2001
+ async function getRestoredPageForTarget(opts) {
1927
2002
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
1928
2003
  ensurePageState(page);
1929
2004
  restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
1930
- const locator = refLocator(page, opts.ref);
1931
- const timeout = normalizeTimeoutMs(opts.timeoutMs, 8e3, 6e4);
2005
+ return page;
2006
+ }
2007
+ async function clickViaPlaywright(opts) {
2008
+ const resolved = requireRefOrSelector(opts.ref, opts.selector);
2009
+ const page = await getRestoredPageForTarget(opts);
2010
+ const label = resolved.ref ?? resolved.selector;
2011
+ const locator = resolveLocator(page, resolved);
2012
+ const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
1932
2013
  try {
1933
2014
  const delayMs = resolveBoundedDelayMs(opts.delayMs, "click delayMs", MAX_CLICK_DELAY_MS);
1934
2015
  if (delayMs > 0) {
@@ -1941,28 +2022,27 @@ async function clickViaPlaywright(opts) {
1941
2022
  await locator.click({ timeout, button: opts.button, modifiers: opts.modifiers });
1942
2023
  }
1943
2024
  } catch (err) {
1944
- throw toAIFriendlyError(err, opts.ref);
2025
+ throw toAIFriendlyError(err, label);
1945
2026
  }
1946
2027
  }
1947
2028
  async function hoverViaPlaywright(opts) {
1948
- const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
1949
- ensurePageState(page);
1950
- restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
2029
+ const resolved = requireRefOrSelector(opts.ref, opts.selector);
2030
+ const page = await getRestoredPageForTarget(opts);
2031
+ const label = resolved.ref ?? resolved.selector;
2032
+ const locator = resolveLocator(page, resolved);
1951
2033
  try {
1952
- await refLocator(page, opts.ref).hover({
1953
- timeout: normalizeTimeoutMs(opts.timeoutMs, 8e3, 6e4)
1954
- });
2034
+ await locator.hover({ timeout: resolveInteractionTimeoutMs(opts.timeoutMs) });
1955
2035
  } catch (err) {
1956
- throw toAIFriendlyError(err, opts.ref);
2036
+ throw toAIFriendlyError(err, label);
1957
2037
  }
1958
2038
  }
1959
2039
  async function typeViaPlaywright(opts) {
2040
+ const resolved = requireRefOrSelector(opts.ref, opts.selector);
1960
2041
  const text = String(opts.text ?? "");
1961
- const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
1962
- ensurePageState(page);
1963
- restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
1964
- const locator = refLocator(page, opts.ref);
1965
- const timeout = normalizeTimeoutMs(opts.timeoutMs, 8e3, 6e4);
2042
+ const page = await getRestoredPageForTarget(opts);
2043
+ const label = resolved.ref ?? resolved.selector;
2044
+ const locator = resolveLocator(page, resolved);
2045
+ const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
1966
2046
  try {
1967
2047
  if (opts.slowly) {
1968
2048
  await locator.click({ timeout });
@@ -1972,39 +2052,38 @@ async function typeViaPlaywright(opts) {
1972
2052
  }
1973
2053
  if (opts.submit) await locator.press("Enter", { timeout });
1974
2054
  } catch (err) {
1975
- throw toAIFriendlyError(err, opts.ref);
2055
+ throw toAIFriendlyError(err, label);
1976
2056
  }
1977
2057
  }
1978
2058
  async function selectOptionViaPlaywright(opts) {
2059
+ const resolved = requireRefOrSelector(opts.ref, opts.selector);
1979
2060
  if (!opts.values?.length) throw new Error("values are required");
1980
- const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
1981
- ensurePageState(page);
1982
- restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
2061
+ const page = await getRestoredPageForTarget(opts);
2062
+ const label = resolved.ref ?? resolved.selector;
2063
+ const locator = resolveLocator(page, resolved);
1983
2064
  try {
1984
- await refLocator(page, opts.ref).selectOption(opts.values, {
1985
- timeout: normalizeTimeoutMs(opts.timeoutMs, 8e3, 6e4)
1986
- });
2065
+ await locator.selectOption(opts.values, { timeout: resolveInteractionTimeoutMs(opts.timeoutMs) });
1987
2066
  } catch (err) {
1988
- throw toAIFriendlyError(err, opts.ref);
2067
+ throw toAIFriendlyError(err, label);
1989
2068
  }
1990
2069
  }
1991
2070
  async function dragViaPlaywright(opts) {
1992
- const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
1993
- ensurePageState(page);
1994
- restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
2071
+ const resolvedStart = requireRefOrSelector(opts.startRef, opts.startSelector);
2072
+ const resolvedEnd = requireRefOrSelector(opts.endRef, opts.endSelector);
2073
+ const page = await getRestoredPageForTarget(opts);
2074
+ const startLocator = resolveLocator(page, resolvedStart);
2075
+ const endLocator = resolveLocator(page, resolvedEnd);
2076
+ const startLabel = resolvedStart.ref ?? resolvedStart.selector;
2077
+ const endLabel = resolvedEnd.ref ?? resolvedEnd.selector;
1995
2078
  try {
1996
- await refLocator(page, opts.startRef).dragTo(refLocator(page, opts.endRef), {
1997
- timeout: normalizeTimeoutMs(opts.timeoutMs, 8e3, 6e4)
1998
- });
2079
+ await startLocator.dragTo(endLocator, { timeout: resolveInteractionTimeoutMs(opts.timeoutMs) });
1999
2080
  } catch (err) {
2000
- throw toAIFriendlyError(err, `${opts.startRef} -> ${opts.endRef}`);
2081
+ throw toAIFriendlyError(err, `${startLabel} -> ${endLabel}`);
2001
2082
  }
2002
2083
  }
2003
2084
  async function fillFormViaPlaywright(opts) {
2004
- const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
2005
- ensurePageState(page);
2006
- restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
2007
- const timeout = normalizeTimeoutMs(opts.timeoutMs, 8e3, 6e4);
2085
+ const page = await getRestoredPageForTarget(opts);
2086
+ const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
2008
2087
  for (const field of opts.fields) {
2009
2088
  const ref = field.ref.trim();
2010
2089
  const type = (typeof field.type === "string" ? field.type.trim() : "") || "text";
@@ -2029,21 +2108,18 @@ async function fillFormViaPlaywright(opts) {
2029
2108
  }
2030
2109
  }
2031
2110
  async function scrollIntoViewViaPlaywright(opts) {
2032
- const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
2033
- ensurePageState(page);
2034
- restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
2111
+ const resolved = requireRefOrSelector(opts.ref, opts.selector);
2112
+ const page = await getRestoredPageForTarget(opts);
2113
+ const label = resolved.ref ?? resolved.selector;
2114
+ const locator = resolveLocator(page, resolved);
2035
2115
  try {
2036
- await refLocator(page, opts.ref).scrollIntoViewIfNeeded({
2037
- timeout: normalizeTimeoutMs(opts.timeoutMs, 2e4)
2038
- });
2116
+ await locator.scrollIntoViewIfNeeded({ timeout: normalizeTimeoutMs(opts.timeoutMs, 2e4) });
2039
2117
  } catch (err) {
2040
- throw toAIFriendlyError(err, opts.ref);
2118
+ throw toAIFriendlyError(err, label);
2041
2119
  }
2042
2120
  }
2043
2121
  async function highlightViaPlaywright(opts) {
2044
- const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
2045
- ensurePageState(page);
2046
- restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
2122
+ const page = await getRestoredPageForTarget(opts);
2047
2123
  try {
2048
2124
  await refLocator(page, opts.ref).highlight();
2049
2125
  } catch (err) {
@@ -2051,9 +2127,7 @@ async function highlightViaPlaywright(opts) {
2051
2127
  }
2052
2128
  }
2053
2129
  async function setInputFilesViaPlaywright(opts) {
2054
- const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
2055
- ensurePageState(page);
2056
- restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
2130
+ const page = await getRestoredPageForTarget(opts);
2057
2131
  if (!opts.paths.length) throw new Error("paths are required");
2058
2132
  const inputRef = typeof opts.ref === "string" ? opts.ref.trim() : "";
2059
2133
  const element = typeof opts.element === "string" ? opts.element.trim() : "";
@@ -2246,13 +2320,18 @@ async function resizeViewportViaPlaywright(opts) {
2246
2320
 
2247
2321
  // src/actions/wait.ts
2248
2322
  var MAX_WAIT_TIME_MS = 3e4;
2323
+ function resolveBoundedDelayMs2(value, label, maxMs) {
2324
+ const normalized = Math.floor(value ?? 0);
2325
+ if (!Number.isFinite(normalized) || normalized < 0) throw new Error(`${label} must be >= 0`);
2326
+ if (normalized > maxMs) throw new Error(`${label} exceeds maximum of ${maxMs}ms`);
2327
+ return normalized;
2328
+ }
2249
2329
  async function waitForViaPlaywright(opts) {
2250
2330
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
2251
2331
  ensurePageState(page);
2252
2332
  const timeout = normalizeTimeoutMs(opts.timeoutMs, 2e4);
2253
2333
  if (typeof opts.timeMs === "number" && Number.isFinite(opts.timeMs)) {
2254
- const bounded = Math.max(0, Math.min(MAX_WAIT_TIME_MS, Math.floor(opts.timeMs)));
2255
- await page.waitForTimeout(bounded);
2334
+ await page.waitForTimeout(resolveBoundedDelayMs2(opts.timeMs, "wait timeMs", MAX_WAIT_TIME_MS));
2256
2335
  }
2257
2336
  if (opts.text) {
2258
2337
  await page.getByText(opts.text).first().waitFor({ state: "visible", timeout });
@@ -2414,55 +2493,106 @@ async function evaluateViaPlaywright(opts) {
2414
2493
  if (signal && abortListener) signal.removeEventListener("abort", abortListener);
2415
2494
  }
2416
2495
  }
2496
+ function createPageDownloadWaiter(page, timeoutMs) {
2497
+ let done = false;
2498
+ let timer;
2499
+ let handler;
2500
+ const cleanup = () => {
2501
+ if (timer) clearTimeout(timer);
2502
+ timer = void 0;
2503
+ if (handler) {
2504
+ page.off("download", handler);
2505
+ handler = void 0;
2506
+ }
2507
+ };
2508
+ return {
2509
+ promise: new Promise((resolve2, reject) => {
2510
+ handler = (download) => {
2511
+ if (done) return;
2512
+ done = true;
2513
+ cleanup();
2514
+ resolve2(download);
2515
+ };
2516
+ page.on("download", handler);
2517
+ timer = setTimeout(() => {
2518
+ if (done) return;
2519
+ done = true;
2520
+ cleanup();
2521
+ reject(new Error("Timeout waiting for download"));
2522
+ }, timeoutMs);
2523
+ }),
2524
+ cancel: () => {
2525
+ if (done) return;
2526
+ done = true;
2527
+ cleanup();
2528
+ }
2529
+ };
2530
+ }
2531
+ async function saveDownloadPayload(download, outPath) {
2532
+ await writeViaSiblingTempPath({
2533
+ rootDir: path.dirname(outPath),
2534
+ targetPath: outPath,
2535
+ writeTemp: async (tempPath) => {
2536
+ await download.saveAs(tempPath);
2537
+ }
2538
+ });
2539
+ return {
2540
+ url: download.url(),
2541
+ suggestedFilename: download.suggestedFilename(),
2542
+ path: outPath
2543
+ };
2544
+ }
2545
+ async function awaitDownloadPayload(params) {
2546
+ try {
2547
+ const download = await params.waiter.promise;
2548
+ if (params.state.armIdDownload !== params.armId) throw new Error("Download was superseded by another waiter");
2549
+ return await saveDownloadPayload(download, params.outPath);
2550
+ } catch (err) {
2551
+ params.waiter.cancel();
2552
+ throw err;
2553
+ }
2554
+ }
2417
2555
  async function downloadViaPlaywright(opts) {
2418
2556
  await assertSafeOutputPath(opts.path, opts.allowedOutputRoots);
2419
2557
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
2420
2558
  const state = ensurePageState(page);
2421
2559
  restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
2422
2560
  const timeout = normalizeTimeoutMs(opts.timeoutMs, 12e4);
2423
- const locator = refLocator(page, opts.ref);
2561
+ const outPath = String(opts.path ?? "").trim();
2562
+ if (!outPath) throw new Error("path is required");
2424
2563
  state.armIdDownload = bumpDownloadArmId();
2564
+ const armId = state.armIdDownload;
2565
+ const waiter = createPageDownloadWaiter(page, timeout);
2425
2566
  try {
2426
- const [download] = await Promise.all([
2427
- page.waitForEvent("download", { timeout }),
2428
- locator.click({ timeout })
2429
- ]);
2430
- const outPath = opts.path;
2431
- await writeViaSiblingTempPath({
2432
- rootDir: path.dirname(outPath),
2433
- targetPath: outPath,
2434
- writeTemp: async (tempPath) => {
2435
- await download.saveAs(tempPath);
2436
- }
2437
- });
2438
- return {
2439
- url: download.url(),
2440
- suggestedFilename: download.suggestedFilename(),
2441
- path: outPath
2442
- };
2567
+ const locator = refLocator(page, opts.ref);
2568
+ try {
2569
+ await locator.click({ timeout });
2570
+ } catch (err) {
2571
+ throw toAIFriendlyError(err, opts.ref);
2572
+ }
2573
+ return await awaitDownloadPayload({ waiter, state, armId, outPath });
2443
2574
  } catch (err) {
2444
- throw toAIFriendlyError(err, opts.ref);
2575
+ waiter.cancel();
2576
+ throw err;
2445
2577
  }
2446
2578
  }
2447
2579
  async function waitForDownloadViaPlaywright(opts) {
2448
2580
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
2449
- ensurePageState(page);
2581
+ const state = ensurePageState(page);
2450
2582
  const timeout = normalizeTimeoutMs(opts.timeoutMs, 12e4);
2451
- const download = await page.waitForEvent("download", { timeout });
2452
- const savePath = opts.path ?? download.suggestedFilename();
2453
- await assertSafeOutputPath(savePath, opts.allowedOutputRoots);
2454
- await writeViaSiblingTempPath({
2455
- rootDir: path.dirname(savePath),
2456
- targetPath: savePath,
2457
- writeTemp: async (tempPath) => {
2458
- await download.saveAs(tempPath);
2459
- }
2460
- });
2461
- return {
2462
- url: download.url(),
2463
- suggestedFilename: download.suggestedFilename(),
2464
- path: savePath
2465
- };
2583
+ state.armIdDownload = bumpDownloadArmId();
2584
+ const armId = state.armIdDownload;
2585
+ const waiter = createPageDownloadWaiter(page, timeout);
2586
+ try {
2587
+ const download = await waiter.promise;
2588
+ if (state.armIdDownload !== armId) throw new Error("Download was superseded by another waiter");
2589
+ const savePath = opts.path ?? download.suggestedFilename();
2590
+ await assertSafeOutputPath(savePath, opts.allowedOutputRoots);
2591
+ return await saveDownloadPayload(download, savePath);
2592
+ } catch (err) {
2593
+ waiter.cancel();
2594
+ throw err;
2595
+ }
2466
2596
  }
2467
2597
  async function emulateMediaViaPlaywright(opts) {
2468
2598
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
@@ -2577,7 +2707,7 @@ async function setLocaleViaPlaywright(opts) {
2577
2707
  async function setOfflineViaPlaywright(opts) {
2578
2708
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
2579
2709
  ensurePageState(page);
2580
- await page.context().setOffline(opts.offline);
2710
+ await page.context().setOffline(Boolean(opts.offline));
2581
2711
  }
2582
2712
  async function setTimezoneViaPlaywright(opts) {
2583
2713
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });