From 4e0d8496f613c5cfc7972d3d97b046bd4bc12d27 Mon Sep 17 00:00:00 2001 From: Les Orchard Date: Wed, 18 Feb 2026 15:08:39 -0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20add=20multiple=20CDP=20endpoint=20f?= =?UTF-8?q?ailover=20(Phase=201=20=E2=80=94=20connection-time)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds support for a list of CDP endpoints tried in sequence when connecting to a remote Chromium browser. If an endpoint fails with a timeout or connection error, the next one is tried automatically. Once all are exhausted, the task fails immediately rather than burning through retry attempts. New option: --pw-cdp-endpoints (comma-separated, takes precedence over the existing singular --pw-cdp-endpoint which is unchanged). Key changes: - configDefaults.ts: new "string[]" ConfigFieldType; pw_cdp_endpoints field - config.ts: coerceValue() and addConfigOptions() support for "string[]" - playwrightBrowser.ts: connectOverCDPWithFailover() loop with isCdpConnectionError() classification; nextStartIndex state for per-start() endpoint cycling; updated pwCdpEndpoint getter returns active endpoint; new pwCdpEndpoints getter; onCdpEndpointCycle callback - cli/commands/run.ts + server/routes/spark.ts: normalize singular → plural and wire pwCdpEndpoints through - events.ts: CDP_ENDPOINT_CYCLE event + CdpEndpointCycleEventData - webAgent.ts: wire onCdpEndpointCycle callback to emit CDP_ENDPOINT_CYCLE; include pwCdpEndpoints in TASK_SETUP event - secretsRedactor.ts: redact pwCdpEndpoints to ["(redacted)"] to hide both values and array length - chalkConsole.ts: handle CDP_ENDPOINT_CYCLE event with warning log Co-Authored-By: Claude Sonnet 4.6 --- server/src/routes/spark.ts | 5 + src/browser/ariaTree/bundle.ts | 3 +- src/browser/playwrightBrowser.ts | 90 ++++++++++++++- src/cli/commands/run.ts | 4 + src/config.ts | 17 ++- src/configDefaults.ts | 13 ++- src/events.ts | 17 ++- src/loggers/chalkConsole.ts | 16 +++ src/loggers/secretsRedactor.ts | 9 +- src/webAgent.ts | 15 ++- test/config.test.ts | 1 + test/events.test.ts | 1 + test/playwrightBrowser.test.ts | 189 ++++++++++++++++++++++++++++++- 13 files changed, 366 insertions(+), 14 deletions(-) diff --git a/server/src/routes/spark.ts b/server/src/routes/spark.ts index 560e8b7d..414ed182 100644 --- a/server/src/routes/spark.ts +++ b/server/src/routes/spark.ts @@ -72,6 +72,7 @@ interface SparkTaskRequest { blockResources?: string[]; pwEndpoint?: string; pwCdpEndpoint?: string; + pwCdpEndpoints?: string[]; bypassCSP?: boolean; // WebAgent behavior overrides @@ -188,6 +189,10 @@ spark.post("/run", async (c) => { | undefined, pwEndpoint: body.pwEndpoint ?? serverConfig.pw_endpoint, pwCdpEndpoint: body.pwCdpEndpoint ?? serverConfig.pw_cdp_endpoint, + pwCdpEndpoints: + body.pwCdpEndpoints ?? + serverConfig.pw_cdp_endpoints ?? + (serverConfig.pw_cdp_endpoint ? [serverConfig.pw_cdp_endpoint] : undefined), bypassCSP: body.bypassCSP ?? serverConfig.bypass_csp, proxyServer: body.proxy ?? serverConfig.proxy, proxyUsername: body.proxyUsername ?? serverConfig.proxy_username, diff --git a/src/browser/ariaTree/bundle.ts b/src/browser/ariaTree/bundle.ts index 98525d9b..f5f6be96 100644 --- a/src/browser/ariaTree/bundle.ts +++ b/src/browser/ariaTree/bundle.ts @@ -1,4 +1,3 @@ // AUTO-GENERATED by scripts/bundle-aria-tree.ts — do not edit // Regenerate with: pnpm run bundle:aria -export const ARIA_TREE_SCRIPT = - '"use strict";\nvar __sparkAriaTree = (() => {\n var __defProp = Object.defineProperty;\n var __getOwnPropDesc = Object.getOwnPropertyDescriptor;\n var __getOwnPropNames = Object.getOwnPropertyNames;\n var __hasOwnProp = Object.prototype.hasOwnProperty;\n var __export = (target, all) => {\n for (var name in all)\n __defProp(target, name, { get: all[name], enumerable: true });\n };\n var __copyProps = (to, from, except, desc) => {\n if (from && typeof from === "object" || typeof from === "function") {\n for (let key of __getOwnPropNames(from))\n if (!__hasOwnProp.call(to, key) && key !== except)\n __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });\n }\n return to;\n };\n var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);\n\n // src/browser/ariaTree/ariaSnapshot.ts\n var ariaSnapshot_exports = {};\n __export(ariaSnapshot_exports, {\n applySetOfMarks: () => applySetOfMarks,\n generateAndRenderAriaTree: () => generateAndRenderAriaTree,\n isInteractiveElement: () => isInteractiveElement,\n removeSetOfMarks: () => removeSetOfMarks\n });\n\n // src/browser/ariaTree/stringUtils.ts\n function normalizeWhiteSpace(text) {\n if (!text) return "";\n return text.replace(/[\\u200b\\u00ad]/g, "").trim().replace(/\\s+/g, " ");\n }\n\n // src/browser/ariaTree/domUtils.ts\n function parentElementOrShadowHost(element) {\n if (element.parentElement) return element.parentElement;\n if (!element.parentNode) return;\n if (element.parentNode.nodeType === 11 && element.parentNode.host)\n return element.parentNode.host;\n }\n function enclosingShadowRootOrDocument(element) {\n let node = element;\n while (node.parentNode) node = node.parentNode;\n if (node.nodeType === 11 || node.nodeType === 9)\n return node;\n }\n function enclosingShadowHost(element) {\n while (element.parentElement) element = element.parentElement;\n return parentElementOrShadowHost(element);\n }\n function closestCrossShadow(element, css, scope) {\n while (element) {\n const closest = element.closest(css);\n if (scope && closest !== scope && closest?.contains(scope)) return;\n if (closest) return closest;\n element = enclosingShadowHost(element);\n }\n }\n function getElementComputedStyle(element, pseudo) {\n return element.ownerDocument && element.ownerDocument.defaultView ? element.ownerDocument.defaultView.getComputedStyle(element, pseudo) : void 0;\n }\n function isElementStyleVisibilityVisible(element, style) {\n style = style ?? getElementComputedStyle(element);\n if (!style) return true;\n if (Element.prototype.checkVisibility) {\n if (!element.checkVisibility()) return false;\n } else {\n const detailsOrSummary = element.closest("details,summary");\n if (detailsOrSummary !== element && detailsOrSummary?.nodeName === "DETAILS" && !detailsOrSummary.open)\n return false;\n }\n if (style.visibility !== "visible") return false;\n return true;\n }\n function box(element) {\n const style = getElementComputedStyle(element);\n if (!style) return { visible: true };\n if (style.display === "contents") {\n for (let child = element.firstChild; child; child = child.nextSibling) {\n if (child.nodeType === 1 && isElementVisible(child))\n return { visible: true, style };\n if (child.nodeType === 3 && isVisibleTextNode(child))\n return { visible: true, style };\n }\n return { visible: false, style };\n }\n if (!isElementStyleVisibilityVisible(element, style)) return { style, visible: false };\n const rect = element.getBoundingClientRect();\n return { rect, style, visible: rect.width > 0 && rect.height > 0 };\n }\n function isElementVisible(element) {\n return box(element).visible;\n }\n function isVisibleTextNode(node) {\n const range = node.ownerDocument.createRange();\n range.selectNode(node);\n const rect = range.getBoundingClientRect();\n return rect.width > 0 && rect.height > 0;\n }\n function elementSafeTagName(element) {\n if (element instanceof HTMLFormElement) return "FORM";\n return element.tagName.toUpperCase();\n }\n\n // src/browser/ariaTree/cssTokenizer.ts\n var between = function(num, first, last) {\n return num >= first && num <= last;\n };\n function digit(code) {\n return between(code, 48, 57);\n }\n function hexdigit(code) {\n return digit(code) || between(code, 65, 70) || between(code, 97, 102);\n }\n function uppercaseletter(code) {\n return between(code, 65, 90);\n }\n function lowercaseletter(code) {\n return between(code, 97, 122);\n }\n function letter(code) {\n return uppercaseletter(code) || lowercaseletter(code);\n }\n function nonascii(code) {\n return code >= 128;\n }\n function namestartchar(code) {\n return letter(code) || nonascii(code) || code === 95;\n }\n function namechar(code) {\n return namestartchar(code) || digit(code) || code === 45;\n }\n function nonprintable(code) {\n return between(code, 0, 8) || code === 11 || between(code, 14, 31) || code === 127;\n }\n function newline(code) {\n return code === 10;\n }\n function whitespace(code) {\n return newline(code) || code === 9 || code === 32;\n }\n var maximumallowedcodepoint = 1114111;\n function preprocess(str) {\n const codepoints = [];\n for (let i = 0; i < str.length; i++) {\n let code = str.charCodeAt(i);\n if (code === 13 && str.charCodeAt(i + 1) === 10) {\n code = 10;\n i++;\n }\n if (code === 13 || code === 12) code = 10;\n if (code === 0) code = 65533;\n if (between(code, 55296, 56319) && between(str.charCodeAt(i + 1), 56320, 57343)) {\n const lead = code - 55296;\n const trail = str.charCodeAt(i + 1) - 56320;\n code = Math.pow(2, 16) + lead * Math.pow(2, 10) + trail;\n i++;\n }\n codepoints.push(code);\n }\n return codepoints;\n }\n function stringFromCode(code) {\n if (code <= 65535) return String.fromCharCode(code);\n code -= Math.pow(2, 16);\n const lead = Math.floor(code / Math.pow(2, 10)) + 55296;\n const trail = code % Math.pow(2, 10) + 56320;\n return String.fromCharCode(lead) + String.fromCharCode(trail);\n }\n function tokenize(str1) {\n const str = preprocess(str1);\n let i = -1;\n const tokens = [];\n let code;\n let line = 0;\n let column = 0;\n let lastLineLength = 0;\n const incrLineno = function() {\n line += 1;\n lastLineLength = column;\n column = 0;\n };\n const codepoint = function(i2) {\n if (i2 >= str.length) return -1;\n return str[i2];\n };\n const next = function(num) {\n if (num === void 0) num = 1;\n if (num > 3) throw "Spec Error: no more than three codepoints of lookahead.";\n return codepoint(i + num);\n };\n const consume = function(num) {\n if (num === void 0) num = 1;\n i += num;\n code = codepoint(i);\n if (newline(code)) incrLineno();\n else column += num;\n return true;\n };\n const reconsume = function() {\n i -= 1;\n if (newline(code)) {\n line -= 1;\n column = lastLineLength;\n } else {\n column -= 1;\n }\n return true;\n };\n const eof = function(codepoint2) {\n if (codepoint2 === void 0) codepoint2 = code;\n return codepoint2 === -1;\n };\n const donothing = function() {\n };\n const parseerror = function() {\n };\n const consumeAToken = function() {\n consumeComments();\n consume();\n if (whitespace(code)) {\n while (whitespace(next())) consume();\n return new WhitespaceToken();\n } else if (code === 34) {\n return consumeAStringToken();\n } else if (code === 35) {\n if (namechar(next()) || areAValidEscape(next(1), next(2))) {\n const token = new HashToken("");\n if (wouldStartAnIdentifier(next(1), next(2), next(3))) token.type = "id";\n token.value = consumeAName();\n return token;\n } else {\n return new DelimToken(code);\n }\n } else if (code === 39) {\n return consumeAStringToken();\n } else if (code === 40) {\n return new OpenParenToken();\n } else if (code === 41) {\n return new CloseParenToken();\n } else if (code === 43) {\n if (startsWithANumber()) {\n reconsume();\n return consumeANumericToken();\n } else {\n return new DelimToken(code);\n }\n } else if (code === 44) {\n return new CommaToken();\n } else if (code === 45) {\n if (startsWithANumber()) {\n reconsume();\n return consumeANumericToken();\n } else if (startsWithAnIdentifier()) {\n reconsume();\n return consumeAnIdentlikeToken();\n } else {\n return new DelimToken(code);\n }\n } else if (code === 46) {\n if (startsWithANumber()) {\n reconsume();\n return consumeANumericToken();\n } else {\n return new DelimToken(code);\n }\n } else if (code === 58) {\n return new ColonToken();\n } else if (code === 59) {\n return new SemicolonToken();\n } else if (code === 64) {\n if (wouldStartAnIdentifier(next(1), next(2), next(3)))\n return new AtKeywordToken(consumeAName());\n else return new DelimToken(code);\n } else if (code === 91) {\n return new OpenSquareToken();\n } else if (code === 92) {\n if (startsWithAValidEscape()) {\n reconsume();\n return consumeAnIdentlikeToken();\n } else {\n parseerror();\n return new DelimToken(code);\n }\n } else if (code === 93) {\n return new CloseSquareToken();\n } else if (code === 123) {\n return new OpenCurlyToken();\n } else if (code === 125) {\n return new CloseCurlyToken();\n } else if (digit(code)) {\n reconsume();\n return consumeANumericToken();\n } else if (namestartchar(code)) {\n reconsume();\n return consumeAnIdentlikeToken();\n } else if (eof()) {\n return new EOFToken();\n } else {\n return new DelimToken(code);\n }\n };\n const consumeComments = function() {\n while (next(1) === 47 && next(2) === 42) {\n consume(2);\n while (true) {\n consume();\n if (code === 42 && next() === 47) {\n consume();\n break;\n } else if (eof()) {\n parseerror();\n return;\n }\n }\n }\n };\n const consumeANumericToken = function() {\n const num = consumeANumber();\n if (wouldStartAnIdentifier(next(1), next(2), next(3))) {\n const token = new DimensionToken();\n token.value = num.value;\n token.repr = num.repr;\n token.type = num.type;\n token.unit = consumeAName();\n return token;\n } else if (next() === 37) {\n consume();\n const token = new PercentageToken();\n token.value = num.value;\n token.repr = num.repr;\n return token;\n } else {\n const token = new NumberToken();\n token.value = num.value;\n token.repr = num.repr;\n token.type = num.type;\n return token;\n }\n };\n const consumeAnIdentlikeToken = function() {\n const str2 = consumeAName();\n if (str2.toLowerCase() === "url" && next() === 40) {\n consume();\n while (whitespace(next(1)) && whitespace(next(2))) consume();\n if (next() === 34 || next() === 39) return new FunctionToken(str2);\n else if (whitespace(next()) && (next(2) === 34 || next(2) === 39))\n return new FunctionToken(str2);\n else return consumeAURLToken();\n } else if (next() === 40) {\n consume();\n return new FunctionToken(str2);\n } else {\n return new IdentToken(str2);\n }\n };\n const consumeAStringToken = function(endingCodePoint) {\n if (endingCodePoint === void 0) endingCodePoint = code;\n let string = "";\n while (consume()) {\n if (code === endingCodePoint || eof()) {\n return new StringToken(string);\n } else if (newline(code)) {\n parseerror();\n reconsume();\n return new BadStringToken();\n } else if (code === 92) {\n if (eof(next())) donothing();\n else if (newline(next())) consume();\n else string += stringFromCode(consumeEscape());\n } else {\n string += stringFromCode(code);\n }\n }\n throw new Error("Internal error");\n };\n const consumeAURLToken = function() {\n const token = new URLToken("");\n while (whitespace(next())) consume();\n if (eof(next())) return token;\n while (consume()) {\n if (code === 41 || eof()) {\n return token;\n } else if (whitespace(code)) {\n while (whitespace(next())) consume();\n if (next() === 41 || eof(next())) {\n consume();\n return token;\n } else {\n consumeTheRemnantsOfABadURL();\n return new BadURLToken();\n }\n } else if (code === 34 || code === 39 || code === 40 || nonprintable(code)) {\n parseerror();\n consumeTheRemnantsOfABadURL();\n return new BadURLToken();\n } else if (code === 92) {\n if (startsWithAValidEscape()) {\n token.value += stringFromCode(consumeEscape());\n } else {\n parseerror();\n consumeTheRemnantsOfABadURL();\n return new BadURLToken();\n }\n } else {\n token.value += stringFromCode(code);\n }\n }\n throw new Error("Internal error");\n };\n const consumeEscape = function() {\n consume();\n if (hexdigit(code)) {\n const digits = [code];\n for (let total = 0; total < 5; total++) {\n if (hexdigit(next())) {\n consume();\n digits.push(code);\n } else {\n break;\n }\n }\n if (whitespace(next())) consume();\n let value = parseInt(\n digits.map(function(x) {\n return String.fromCharCode(x);\n }).join(""),\n 16\n );\n if (value > maximumallowedcodepoint) value = 65533;\n return value;\n } else if (eof()) {\n return 65533;\n } else {\n return code;\n }\n };\n const areAValidEscape = function(c1, c2) {\n if (c1 !== 92) return false;\n if (newline(c2)) return false;\n return true;\n };\n const startsWithAValidEscape = function() {\n return areAValidEscape(code, next());\n };\n const wouldStartAnIdentifier = function(c1, c2, c3) {\n if (c1 === 45) return namestartchar(c2) || c2 === 45 || areAValidEscape(c2, c3);\n else if (namestartchar(c1)) return true;\n else if (c1 === 92) return areAValidEscape(c1, c2);\n else return false;\n };\n const startsWithAnIdentifier = function() {\n return wouldStartAnIdentifier(code, next(1), next(2));\n };\n const wouldStartANumber = function(c1, c2, c3) {\n if (c1 === 43 || c1 === 45) {\n if (digit(c2)) return true;\n if (c2 === 46 && digit(c3)) return true;\n return false;\n } else if (c1 === 46) {\n if (digit(c2)) return true;\n return false;\n } else if (digit(c1)) {\n return true;\n } else {\n return false;\n }\n };\n const startsWithANumber = function() {\n return wouldStartANumber(code, next(1), next(2));\n };\n const consumeAName = function() {\n let result = "";\n while (consume()) {\n if (namechar(code)) {\n result += stringFromCode(code);\n } else if (startsWithAValidEscape()) {\n result += stringFromCode(consumeEscape());\n } else {\n reconsume();\n return result;\n }\n }\n throw new Error("Internal parse error");\n };\n const consumeANumber = function() {\n let repr = "";\n let type = "integer";\n if (next() === 43 || next() === 45) {\n consume();\n repr += stringFromCode(code);\n }\n while (digit(next())) {\n consume();\n repr += stringFromCode(code);\n }\n if (next(1) === 46 && digit(next(2))) {\n consume();\n repr += stringFromCode(code);\n consume();\n repr += stringFromCode(code);\n type = "number";\n while (digit(next())) {\n consume();\n repr += stringFromCode(code);\n }\n }\n const c1 = next(1), c2 = next(2), c3 = next(3);\n if ((c1 === 69 || c1 === 101) && digit(c2)) {\n consume();\n repr += stringFromCode(code);\n consume();\n repr += stringFromCode(code);\n type = "number";\n while (digit(next())) {\n consume();\n repr += stringFromCode(code);\n }\n } else if ((c1 === 69 || c1 === 101) && (c2 === 43 || c2 === 45) && digit(c3)) {\n consume();\n repr += stringFromCode(code);\n consume();\n repr += stringFromCode(code);\n consume();\n repr += stringFromCode(code);\n type = "number";\n while (digit(next())) {\n consume();\n repr += stringFromCode(code);\n }\n }\n const value = +repr;\n return { type, value, repr };\n };\n const consumeTheRemnantsOfABadURL = function() {\n while (consume()) {\n if (code === 41 || eof()) {\n return;\n } else if (startsWithAValidEscape()) {\n consumeEscape();\n donothing();\n } else {\n donothing();\n }\n }\n };\n let iterationCount = 0;\n while (!eof(next())) {\n tokens.push(consumeAToken());\n iterationCount++;\n if (iterationCount > str.length * 2) throw new Error("I\'m infinite-looping!");\n }\n return tokens;\n }\n var CSSParserToken = class {\n tokenType = "";\n value;\n toJSON() {\n return { token: this.tokenType };\n }\n toString() {\n return this.tokenType;\n }\n toSource() {\n return "" + this;\n }\n };\n var BadStringToken = class extends CSSParserToken {\n tokenType = "BADSTRING";\n };\n var BadURLToken = class extends CSSParserToken {\n tokenType = "BADURL";\n };\n var WhitespaceToken = class extends CSSParserToken {\n tokenType = "WHITESPACE";\n toString() {\n return "WS";\n }\n toSource() {\n return " ";\n }\n };\n var ColonToken = class extends CSSParserToken {\n tokenType = ":";\n };\n var SemicolonToken = class extends CSSParserToken {\n tokenType = ";";\n };\n var CommaToken = class extends CSSParserToken {\n tokenType = ",";\n };\n var GroupingToken = class extends CSSParserToken {\n value = "";\n mirror = "";\n };\n var OpenCurlyToken = class extends GroupingToken {\n tokenType = "{";\n constructor() {\n super();\n this.value = "{";\n this.mirror = "}";\n }\n };\n var CloseCurlyToken = class extends GroupingToken {\n tokenType = "}";\n constructor() {\n super();\n this.value = "}";\n this.mirror = "{";\n }\n };\n var OpenSquareToken = class extends GroupingToken {\n tokenType = "[";\n constructor() {\n super();\n this.value = "[";\n this.mirror = "]";\n }\n };\n var CloseSquareToken = class extends GroupingToken {\n tokenType = "]";\n constructor() {\n super();\n this.value = "]";\n this.mirror = "[";\n }\n };\n var OpenParenToken = class extends GroupingToken {\n tokenType = "(";\n constructor() {\n super();\n this.value = "(";\n this.mirror = ")";\n }\n };\n var CloseParenToken = class extends GroupingToken {\n tokenType = ")";\n constructor() {\n super();\n this.value = ")";\n this.mirror = "(";\n }\n };\n var EOFToken = class extends CSSParserToken {\n tokenType = "EOF";\n toSource() {\n return "";\n }\n };\n var DelimToken = class extends CSSParserToken {\n tokenType = "DELIM";\n value = "";\n constructor(code) {\n super();\n this.value = stringFromCode(code);\n }\n toString() {\n return "DELIM(" + this.value + ")";\n }\n toSource() {\n if (this.value === "\\\\") return "\\\\\\n";\n else return this.value;\n }\n };\n var StringValuedToken = class extends CSSParserToken {\n value = "";\n };\n var IdentToken = class extends StringValuedToken {\n constructor(val) {\n super();\n this.value = val;\n }\n tokenType = "IDENT";\n };\n var FunctionToken = class extends StringValuedToken {\n tokenType = "FUNCTION";\n mirror;\n constructor(val) {\n super();\n this.value = val;\n this.mirror = ")";\n }\n };\n var AtKeywordToken = class extends StringValuedToken {\n tokenType = "AT-KEYWORD";\n constructor(val) {\n super();\n this.value = val;\n }\n };\n var HashToken = class extends StringValuedToken {\n tokenType = "HASH";\n type;\n constructor(val) {\n super();\n this.value = val;\n this.type = "unrestricted";\n }\n };\n var StringToken = class extends StringValuedToken {\n tokenType = "STRING";\n constructor(val) {\n super();\n this.value = val;\n }\n };\n var URLToken = class extends StringValuedToken {\n tokenType = "URL";\n constructor(val) {\n super();\n this.value = val;\n }\n };\n var NumberToken = class extends CSSParserToken {\n tokenType = "NUMBER";\n type;\n repr;\n constructor() {\n super();\n this.type = "integer";\n this.repr = "";\n }\n };\n var PercentageToken = class extends CSSParserToken {\n tokenType = "PERCENTAGE";\n repr;\n constructor() {\n super();\n this.repr = "";\n }\n };\n var DimensionToken = class extends CSSParserToken {\n tokenType = "DIMENSION";\n type;\n repr;\n unit;\n constructor() {\n super();\n this.type = "integer";\n this.repr = "";\n this.unit = "";\n }\n };\n\n // src/browser/ariaTree/roleUtils.ts\n function hasExplicitAccessibleName(e) {\n return e.hasAttribute("aria-label") || e.hasAttribute("aria-labelledby");\n }\n var kAncestorPreventingLandmark = "article:not([role]), aside:not([role]), main:not([role]), nav:not([role]), section:not([role]), [role=article], [role=complementary], [role=main], [role=navigation], [role=region]";\n var kGlobalAriaAttributes = [\n ["aria-atomic", void 0],\n ["aria-busy", void 0],\n ["aria-controls", void 0],\n ["aria-current", void 0],\n ["aria-describedby", void 0],\n ["aria-details", void 0],\n ["aria-dropeffect", void 0],\n ["aria-flowto", void 0],\n ["aria-grabbed", void 0],\n ["aria-hidden", void 0],\n ["aria-keyshortcuts", void 0],\n [\n "aria-label",\n [\n "caption",\n "code",\n "deletion",\n "emphasis",\n "generic",\n "insertion",\n "paragraph",\n "presentation",\n "strong",\n "subscript",\n "superscript"\n ]\n ],\n [\n "aria-labelledby",\n [\n "caption",\n "code",\n "deletion",\n "emphasis",\n "generic",\n "insertion",\n "paragraph",\n "presentation",\n "strong",\n "subscript",\n "superscript"\n ]\n ],\n ["aria-live", void 0],\n ["aria-owns", void 0],\n ["aria-relevant", void 0],\n ["aria-roledescription", ["generic"]]\n ];\n function hasGlobalAriaAttribute(element, forRole) {\n return kGlobalAriaAttributes.some(([attr, prohibited]) => {\n return !prohibited?.includes(forRole || "") && element.hasAttribute(attr);\n });\n }\n function hasTabIndex(element) {\n return !Number.isNaN(Number(String(element.getAttribute("tabindex"))));\n }\n function isFocusable(element) {\n return !isNativelyDisabled(element) && (isNativelyFocusable(element) || hasTabIndex(element));\n }\n function isNativelyFocusable(element) {\n const tagName = elementSafeTagName(element);\n if (["BUTTON", "DETAILS", "SELECT", "TEXTAREA"].includes(tagName)) return true;\n if (tagName === "A" || tagName === "AREA") return element.hasAttribute("href");\n if (tagName === "INPUT") return !element.hidden;\n return false;\n }\n var kImplicitRoleByTagName = {\n A: (e) => e.hasAttribute("href") ? "link" : null,\n AREA: (e) => e.hasAttribute("href") ? "link" : null,\n ARTICLE: () => "article",\n ASIDE: () => "complementary",\n BLOCKQUOTE: () => "blockquote",\n BUTTON: () => "button",\n CAPTION: () => "caption",\n CODE: () => "code",\n DATALIST: () => "listbox",\n DD: () => "definition",\n DEL: () => "deletion",\n DETAILS: () => "group",\n DFN: () => "term",\n DIALOG: () => "dialog",\n DT: () => "term",\n EM: () => "emphasis",\n FIELDSET: () => "group",\n FIGURE: () => "figure",\n FOOTER: (e) => closestCrossShadow(e, kAncestorPreventingLandmark) ? null : "contentinfo",\n FORM: (e) => hasExplicitAccessibleName(e) ? "form" : null,\n H1: () => "heading",\n H2: () => "heading",\n H3: () => "heading",\n H4: () => "heading",\n H5: () => "heading",\n H6: () => "heading",\n HEADER: (e) => closestCrossShadow(e, kAncestorPreventingLandmark) ? null : "banner",\n HR: () => "separator",\n HTML: () => "document",\n IMG: (e) => e.getAttribute("alt") === "" && !e.getAttribute("title") && !hasGlobalAriaAttribute(e) && !hasTabIndex(e) ? "presentation" : "img",\n INPUT: (e) => {\n const type = e.type.toLowerCase();\n if (type === "search") return e.hasAttribute("list") ? "combobox" : "searchbox";\n if (["email", "tel", "text", "url", ""].includes(type)) {\n const list = getIdRefs(e, e.getAttribute("list"))[0];\n return list && elementSafeTagName(list) === "DATALIST" ? "combobox" : "textbox";\n }\n if (type === "hidden") return null;\n if (type === "file") return "button";\n return inputTypeToRole[type] || "textbox";\n },\n INS: () => "insertion",\n LI: () => "listitem",\n MAIN: () => "main",\n MARK: () => "mark",\n MATH: () => "math",\n MENU: () => "list",\n METER: () => "meter",\n NAV: () => "navigation",\n OL: () => "list",\n OPTGROUP: () => "group",\n OPTION: () => "option",\n OUTPUT: () => "status",\n P: () => "paragraph",\n PROGRESS: () => "progressbar",\n SECTION: (e) => hasExplicitAccessibleName(e) ? "region" : null,\n SELECT: (e) => e.hasAttribute("multiple") || e.size > 1 ? "listbox" : "combobox",\n STRONG: () => "strong",\n SUB: () => "subscript",\n SUP: () => "superscript",\n SVG: () => "img",\n TABLE: () => "table",\n TBODY: () => "rowgroup",\n TD: (e) => {\n const table = closestCrossShadow(e, "table");\n const role = table ? getExplicitAriaRole(table) : "";\n return role === "grid" || role === "treegrid" ? "gridcell" : "cell";\n },\n TEXTAREA: () => "textbox",\n TFOOT: () => "rowgroup",\n TH: (e) => {\n if (e.getAttribute("scope") === "col") return "columnheader";\n if (e.getAttribute("scope") === "row") return "rowheader";\n const table = closestCrossShadow(e, "table");\n const role = table ? getExplicitAriaRole(table) : "";\n return role === "grid" || role === "treegrid" ? "gridcell" : "cell";\n },\n THEAD: () => "rowgroup",\n TIME: () => "time",\n TR: () => "row",\n UL: () => "list"\n };\n var kPresentationInheritanceParents = {\n DD: ["DL", "DIV"],\n DIV: ["DL"],\n DT: ["DL", "DIV"],\n LI: ["OL", "UL"],\n TBODY: ["TABLE"],\n TD: ["TR"],\n TFOOT: ["TABLE"],\n TH: ["TR"],\n THEAD: ["TABLE"],\n TR: ["THEAD", "TBODY", "TFOOT", "TABLE"]\n };\n function getImplicitAriaRole(element) {\n const implicitRole = kImplicitRoleByTagName[elementSafeTagName(element)]?.(element) || "";\n if (!implicitRole) return null;\n let ancestor = element;\n while (ancestor) {\n const parent = parentElementOrShadowHost(ancestor);\n const parents = kPresentationInheritanceParents[elementSafeTagName(ancestor)];\n if (!parents || !parent || !parents.includes(elementSafeTagName(parent))) break;\n const parentExplicitRole = getExplicitAriaRole(parent);\n if ((parentExplicitRole === "none" || parentExplicitRole === "presentation") && !hasPresentationConflictResolution(parent, parentExplicitRole))\n return parentExplicitRole;\n ancestor = parent;\n }\n return implicitRole;\n }\n var validRoles = [\n "alert",\n "alertdialog",\n "application",\n "article",\n "banner",\n "blockquote",\n "button",\n "caption",\n "cell",\n "checkbox",\n "code",\n "columnheader",\n "combobox",\n "complementary",\n "contentinfo",\n "definition",\n "deletion",\n "dialog",\n "directory",\n "document",\n "emphasis",\n "feed",\n "figure",\n "form",\n "generic",\n "grid",\n "gridcell",\n "group",\n "heading",\n "img",\n "insertion",\n "link",\n "list",\n "listbox",\n "listitem",\n "log",\n "main",\n "mark",\n "marquee",\n "math",\n "meter",\n "menu",\n "menubar",\n "menuitem",\n "menuitemcheckbox",\n "menuitemradio",\n "navigation",\n "none",\n "note",\n "option",\n "paragraph",\n "presentation",\n "progressbar",\n "radio",\n "radiogroup",\n "region",\n "row",\n "rowgroup",\n "rowheader",\n "scrollbar",\n "search",\n "searchbox",\n "separator",\n "slider",\n "spinbutton",\n "status",\n "strong",\n "subscript",\n "superscript",\n "switch",\n "tab",\n "table",\n "tablist",\n "tabpanel",\n "term",\n "textbox",\n "time",\n "timer",\n "toolbar",\n "tooltip",\n "tree",\n "treegrid",\n "treeitem"\n ];\n function getExplicitAriaRole(element) {\n const roles = (element.getAttribute("role") || "").split(" ").map((role) => role.trim());\n return roles.find((role) => validRoles.includes(role)) || null;\n }\n function hasPresentationConflictResolution(element, role) {\n return hasGlobalAriaAttribute(element, role) || isFocusable(element);\n }\n function getAriaRole(element) {\n const explicitRole = getExplicitAriaRole(element);\n if (!explicitRole) return getImplicitAriaRole(element);\n if (explicitRole === "none" || explicitRole === "presentation") {\n const implicitRole = getImplicitAriaRole(element);\n if (hasPresentationConflictResolution(element, implicitRole)) return implicitRole;\n }\n return explicitRole;\n }\n function getAriaBoolean(attr) {\n return attr === null ? void 0 : attr.toLowerCase() === "true";\n }\n function isElementIgnoredForAria(element) {\n return ["STYLE", "SCRIPT", "NOSCRIPT", "TEMPLATE"].includes(elementSafeTagName(element));\n }\n function isElementHiddenForAria(element) {\n if (isElementIgnoredForAria(element)) return true;\n const style = getElementComputedStyle(element);\n const isSlot = element.nodeName === "SLOT";\n if (style?.display === "contents" && !isSlot) {\n for (let child = element.firstChild; child; child = child.nextSibling) {\n if (child.nodeType === 1 && !isElementHiddenForAria(child))\n return false;\n if (child.nodeType === 3 && isVisibleTextNode(child))\n return false;\n }\n return true;\n }\n const isOptionInsideSelect = element.nodeName === "OPTION" && !!element.closest("select");\n if (!isOptionInsideSelect && !isSlot && !isElementStyleVisibilityVisible(element, style))\n return true;\n return belongsToDisplayNoneOrAriaHiddenOrNonSlotted(element);\n }\n function belongsToDisplayNoneOrAriaHiddenOrNonSlotted(element) {\n let hidden = cacheIsHidden?.get(element);\n if (hidden === void 0) {\n hidden = false;\n if (element.parentElement && element.parentElement.shadowRoot && !element.assignedSlot)\n hidden = true;\n if (!hidden) {\n const style = getElementComputedStyle(element);\n hidden = !style || style.display === "none" || getAriaBoolean(element.getAttribute("aria-hidden")) === true;\n }\n if (!hidden) {\n const parent = parentElementOrShadowHost(element);\n if (parent) hidden = belongsToDisplayNoneOrAriaHiddenOrNonSlotted(parent);\n }\n cacheIsHidden?.set(element, hidden);\n }\n return hidden;\n }\n function getIdRefs(element, ref) {\n if (!ref) return [];\n const root = enclosingShadowRootOrDocument(element);\n if (!root) return [];\n try {\n const ids = ref.split(" ").filter((id) => !!id);\n const result = [];\n for (const id of ids) {\n const firstElement = root.querySelector("#" + CSS.escape(id));\n if (firstElement && !result.includes(firstElement)) result.push(firstElement);\n }\n return result;\n } catch {\n return [];\n }\n }\n function trimFlatString(s) {\n return s.trim();\n }\n function asFlatString(s) {\n return s.split("\\xA0").map(\n (chunk) => chunk.replace(/\\r\\n/g, "\\n").replace(/[\\u200b\\u00ad]/g, "").replace(/\\s\\s*/g, " ")\n ).join("\\xA0").trim();\n }\n function queryInAriaOwned(element, selector) {\n const result = [...element.querySelectorAll(selector)];\n for (const owned of getIdRefs(element, element.getAttribute("aria-owns"))) {\n if (owned.matches(selector)) result.push(owned);\n result.push(...owned.querySelectorAll(selector));\n }\n return result;\n }\n function getCSSContent(element, pseudo) {\n const cache = pseudo === "::before" ? cachePseudoContentBefore : pseudo === "::after" ? cachePseudoContentAfter : cachePseudoContent;\n if (cache?.has(element)) return cache?.get(element);\n const style = getElementComputedStyle(element, pseudo);\n let content;\n if (style && style.display !== "none" && style.visibility !== "hidden") {\n content = parseCSSContentPropertyAsString(element, style.content, !!pseudo);\n }\n if (pseudo && content !== void 0) {\n const display = style?.display || "inline";\n if (display !== "inline") content = " " + content + " ";\n }\n if (cache) cache.set(element, content);\n return content;\n }\n function parseCSSContentPropertyAsString(element, content, isPseudo) {\n if (!content || content === "none" || content === "normal") {\n return;\n }\n try {\n let tokens = tokenize(content).filter((token) => !(token instanceof WhitespaceToken));\n const delimIndex = tokens.findIndex(\n (token) => token instanceof DelimToken && token.value === "/"\n );\n if (delimIndex !== -1) {\n tokens = tokens.slice(delimIndex + 1);\n } else if (!isPseudo) {\n return;\n }\n const accumulated = [];\n let index = 0;\n while (index < tokens.length) {\n if (tokens[index] instanceof StringToken) {\n accumulated.push(tokens[index].value);\n index++;\n } else if (index + 2 < tokens.length && tokens[index] instanceof FunctionToken && tokens[index].value === "attr" && tokens[index + 1] instanceof IdentToken && tokens[index + 2] instanceof CloseParenToken) {\n const attrName = tokens[index + 1].value;\n accumulated.push(element.getAttribute(attrName) || "");\n index += 3;\n } else {\n return;\n }\n }\n return accumulated.join("");\n } catch {\n }\n }\n function getAriaLabelledByElements(element) {\n const ref = element.getAttribute("aria-labelledby");\n if (ref === null) return null;\n const refs = getIdRefs(element, ref);\n return refs.length ? refs : null;\n }\n function allowsNameFromContent(role, targetDescendant) {\n const alwaysAllowsNameFromContent = [\n "button",\n "cell",\n "checkbox",\n "columnheader",\n "gridcell",\n "heading",\n "link",\n "menuitem",\n "menuitemcheckbox",\n "menuitemradio",\n "option",\n "radio",\n "row",\n "rowheader",\n "switch",\n "tab",\n "tooltip",\n "treeitem"\n ].includes(role);\n const descendantAllowsNameFromContent = targetDescendant && [\n "",\n "caption",\n "code",\n "contentinfo",\n "definition",\n "deletion",\n "emphasis",\n "insertion",\n "list",\n "listitem",\n "mark",\n "none",\n "paragraph",\n "presentation",\n "region",\n "row",\n "rowgroup",\n "section",\n "strong",\n "subscript",\n "superscript",\n "table",\n "term",\n "time"\n ].includes(role);\n return alwaysAllowsNameFromContent || descendantAllowsNameFromContent;\n }\n function getElementAccessibleName(element, includeHidden) {\n const cache = includeHidden ? cacheAccessibleNameHidden : cacheAccessibleName;\n let accessibleName = cache?.get(element);\n if (accessibleName === void 0) {\n accessibleName = "";\n const elementProhibitsNaming = [\n "caption",\n "code",\n "definition",\n "deletion",\n "emphasis",\n "generic",\n "insertion",\n "mark",\n "paragraph",\n "presentation",\n "strong",\n "subscript",\n "suggestion",\n "superscript",\n "term",\n "time"\n ].includes(getAriaRole(element) || "");\n if (!elementProhibitsNaming) {\n accessibleName = asFlatString(\n getTextAlternativeInternal(element, {\n includeHidden,\n visitedElements: /* @__PURE__ */ new Set(),\n embeddedInTargetElement: "self"\n })\n );\n }\n cache?.set(element, accessibleName);\n }\n return accessibleName;\n }\n function getTextAlternativeInternal(element, options) {\n if (options.visitedElements.has(element)) return "";\n const childOptions = {\n ...options,\n embeddedInTargetElement: options.embeddedInTargetElement === "self" ? "descendant" : options.embeddedInTargetElement\n };\n if (!options.includeHidden) {\n const isEmbeddedInHiddenReferenceTraversal = !!options.embeddedInLabelledBy?.hidden || !!options.embeddedInDescribedBy?.hidden || !!options.embeddedInNativeTextAlternative?.hidden || !!options.embeddedInLabel?.hidden;\n if (isElementIgnoredForAria(element) || !isEmbeddedInHiddenReferenceTraversal && isElementHiddenForAria(element)) {\n options.visitedElements.add(element);\n return "";\n }\n }\n const labelledBy = getAriaLabelledByElements(element);\n if (!options.embeddedInLabelledBy) {\n const accessibleName = (labelledBy || []).map(\n (ref) => getTextAlternativeInternal(ref, {\n ...options,\n embeddedInLabelledBy: { element: ref, hidden: isElementHiddenForAria(ref) },\n embeddedInDescribedBy: void 0,\n embeddedInTargetElement: void 0,\n embeddedInLabel: void 0,\n embeddedInNativeTextAlternative: void 0\n })\n ).join(" ");\n if (accessibleName) return accessibleName;\n }\n const role = getAriaRole(element) || "";\n const tagName = elementSafeTagName(element);\n if (!!options.embeddedInLabel || !!options.embeddedInLabelledBy || options.embeddedInTargetElement === "descendant") {\n const isOwnLabel = [\n ...element.labels || []\n ].includes(element);\n const isOwnLabelledBy = (labelledBy || []).includes(element);\n if (!isOwnLabel && !isOwnLabelledBy) {\n if (role === "textbox") {\n options.visitedElements.add(element);\n if (tagName === "INPUT" || tagName === "TEXTAREA")\n return element.value;\n return element.textContent || "";\n }\n if (["combobox", "listbox"].includes(role)) {\n options.visitedElements.add(element);\n let selectedOptions;\n if (tagName === "SELECT") {\n selectedOptions = [...element.selectedOptions];\n if (!selectedOptions.length && element.options.length)\n selectedOptions.push(element.options[0]);\n } else {\n const listbox = role === "combobox" ? queryInAriaOwned(element, "*").find((e) => getAriaRole(e) === "listbox") : element;\n selectedOptions = listbox ? queryInAriaOwned(listbox, \'[aria-selected="true"]\').filter(\n (e) => getAriaRole(e) === "option"\n ) : [];\n }\n if (!selectedOptions.length && tagName === "INPUT") {\n return element.value;\n }\n return selectedOptions.map((option) => getTextAlternativeInternal(option, childOptions)).join(" ");\n }\n if (["progressbar", "scrollbar", "slider", "spinbutton", "meter"].includes(role)) {\n options.visitedElements.add(element);\n if (element.hasAttribute("aria-valuetext"))\n return element.getAttribute("aria-valuetext") || "";\n if (element.hasAttribute("aria-valuenow"))\n return element.getAttribute("aria-valuenow") || "";\n return element.getAttribute("value") || "";\n }\n if (["menu"].includes(role)) {\n options.visitedElements.add(element);\n return "";\n }\n }\n }\n const ariaLabel = element.getAttribute("aria-label") || "";\n if (trimFlatString(ariaLabel)) {\n options.visitedElements.add(element);\n return ariaLabel;\n }\n if (!["presentation", "none"].includes(role)) {\n if (tagName === "INPUT" && ["button", "submit", "reset"].includes(element.type)) {\n options.visitedElements.add(element);\n const value = element.value || "";\n if (trimFlatString(value)) return value;\n if (element.type === "submit") return "Submit";\n if (element.type === "reset") return "Reset";\n const title = element.getAttribute("title") || "";\n return title;\n }\n if (tagName === "INPUT" && element.type === "file") {\n options.visitedElements.add(element);\n const labels = element.labels || [];\n if (labels.length && !options.embeddedInLabelledBy)\n return getAccessibleNameFromAssociatedLabels(labels, options);\n return "Choose File";\n }\n if (tagName === "INPUT" && element.type === "image") {\n options.visitedElements.add(element);\n const labels = element.labels || [];\n if (labels.length && !options.embeddedInLabelledBy)\n return getAccessibleNameFromAssociatedLabels(labels, options);\n const alt = element.getAttribute("alt") || "";\n if (trimFlatString(alt)) return alt;\n const title = element.getAttribute("title") || "";\n if (trimFlatString(title)) return title;\n return "Submit";\n }\n if (!labelledBy && tagName === "BUTTON") {\n options.visitedElements.add(element);\n const labels = element.labels || [];\n if (labels.length) return getAccessibleNameFromAssociatedLabels(labels, options);\n }\n if (!labelledBy && tagName === "OUTPUT") {\n options.visitedElements.add(element);\n const labels = element.labels || [];\n if (labels.length) return getAccessibleNameFromAssociatedLabels(labels, options);\n return element.getAttribute("title") || "";\n }\n if (!labelledBy && (tagName === "TEXTAREA" || tagName === "SELECT" || tagName === "INPUT")) {\n options.visitedElements.add(element);\n const labels = element.labels || [];\n if (labels.length) return getAccessibleNameFromAssociatedLabels(labels, options);\n const usePlaceholder = tagName === "INPUT" && ["text", "password", "search", "tel", "email", "url"].includes(\n element.type\n ) || tagName === "TEXTAREA";\n const placeholder = element.getAttribute("placeholder") || "";\n const title = element.getAttribute("title") || "";\n if (!usePlaceholder || title) return title;\n return placeholder;\n }\n if (!labelledBy && tagName === "FIELDSET") {\n options.visitedElements.add(element);\n for (let child = element.firstElementChild; child; child = child.nextElementSibling) {\n if (elementSafeTagName(child) === "LEGEND") {\n return getTextAlternativeInternal(child, {\n ...childOptions,\n embeddedInNativeTextAlternative: {\n element: child,\n hidden: isElementHiddenForAria(child)\n }\n });\n }\n }\n const title = element.getAttribute("title") || "";\n return title;\n }\n if (!labelledBy && tagName === "FIGURE") {\n options.visitedElements.add(element);\n for (let child = element.firstElementChild; child; child = child.nextElementSibling) {\n if (elementSafeTagName(child) === "FIGCAPTION") {\n return getTextAlternativeInternal(child, {\n ...childOptions,\n embeddedInNativeTextAlternative: {\n element: child,\n hidden: isElementHiddenForAria(child)\n }\n });\n }\n }\n const title = element.getAttribute("title") || "";\n return title;\n }\n if (tagName === "IMG") {\n options.visitedElements.add(element);\n const alt = element.getAttribute("alt") || "";\n if (trimFlatString(alt)) return alt;\n const title = element.getAttribute("title") || "";\n return title;\n }\n if (tagName === "TABLE") {\n options.visitedElements.add(element);\n for (let child = element.firstElementChild; child; child = child.nextElementSibling) {\n if (elementSafeTagName(child) === "CAPTION") {\n return getTextAlternativeInternal(child, {\n ...childOptions,\n embeddedInNativeTextAlternative: {\n element: child,\n hidden: isElementHiddenForAria(child)\n }\n });\n }\n }\n const summary = element.getAttribute("summary") || "";\n if (summary) return summary;\n }\n if (tagName === "AREA") {\n options.visitedElements.add(element);\n const alt = element.getAttribute("alt") || "";\n if (trimFlatString(alt)) return alt;\n const title = element.getAttribute("title") || "";\n return title;\n }\n if (tagName === "SVG" || element.ownerSVGElement) {\n options.visitedElements.add(element);\n for (let child = element.firstElementChild; child; child = child.nextElementSibling) {\n if (elementSafeTagName(child) === "TITLE" && child.ownerSVGElement) {\n return getTextAlternativeInternal(child, {\n ...childOptions,\n embeddedInLabelledBy: { element: child, hidden: isElementHiddenForAria(child) }\n });\n }\n }\n }\n if (element.ownerSVGElement && tagName === "A") {\n const title = element.getAttribute("xlink:title") || "";\n if (trimFlatString(title)) {\n options.visitedElements.add(element);\n return title;\n }\n }\n }\n const shouldNameFromContentForSummary = tagName === "SUMMARY" && !["presentation", "none"].includes(role);\n if (allowsNameFromContent(role, options.embeddedInTargetElement === "descendant") || shouldNameFromContentForSummary || !!options.embeddedInLabelledBy || !!options.embeddedInDescribedBy || !!options.embeddedInLabel || !!options.embeddedInNativeTextAlternative) {\n options.visitedElements.add(element);\n const accessibleName = innerAccumulatedElementText(element, childOptions);\n const maybeTrimmedAccessibleName = options.embeddedInTargetElement === "self" ? trimFlatString(accessibleName) : accessibleName;\n if (maybeTrimmedAccessibleName) return accessibleName;\n }\n if (!["presentation", "none"].includes(role) || tagName === "IFRAME") {\n options.visitedElements.add(element);\n const title = element.getAttribute("title") || "";\n if (trimFlatString(title)) return title;\n }\n options.visitedElements.add(element);\n return "";\n }\n function innerAccumulatedElementText(element, options) {\n const tokens = [];\n const visit = (node, skipSlotted) => {\n if (skipSlotted && node.assignedSlot) return;\n if (node.nodeType === 1) {\n const display = getElementComputedStyle(node)?.display || "inline";\n let token = getTextAlternativeInternal(node, options);\n if (display !== "inline" || node.nodeName === "BR") token = " " + token + " ";\n tokens.push(token);\n } else if (node.nodeType === 3) {\n tokens.push(node.textContent || "");\n }\n };\n tokens.push(getCSSContent(element, "::before") || "");\n const content = getCSSContent(element);\n if (content !== void 0) {\n tokens.push(content);\n } else {\n const assignedNodes = element.nodeName === "SLOT" ? element.assignedNodes() : [];\n if (assignedNodes.length) {\n for (const child of assignedNodes) visit(child, false);\n } else {\n for (let child = element.firstChild; child; child = child.nextSibling) visit(child, true);\n if (element.shadowRoot) {\n for (let child = element.shadowRoot.firstChild; child; child = child.nextSibling)\n visit(child, true);\n }\n for (const owned of getIdRefs(element, element.getAttribute("aria-owns"))) visit(owned, true);\n }\n }\n tokens.push(getCSSContent(element, "::after") || "");\n return tokens.join("");\n }\n var kAriaSelectedRoles = [\n "gridcell",\n "option",\n "row",\n "tab",\n "rowheader",\n "columnheader",\n "treeitem"\n ];\n function getAriaSelected(element) {\n if (elementSafeTagName(element) === "OPTION") return element.selected;\n if (kAriaSelectedRoles.includes(getAriaRole(element) || ""))\n return getAriaBoolean(element.getAttribute("aria-selected")) === true;\n return false;\n }\n var kAriaCheckedRoles = [\n "checkbox",\n "menuitemcheckbox",\n "option",\n "radio",\n "switch",\n "menuitemradio",\n "treeitem"\n ];\n function getAriaChecked(element) {\n const result = getChecked(element, true);\n return result === "error" ? false : result;\n }\n function getChecked(element, allowMixed) {\n const tagName = elementSafeTagName(element);\n if (allowMixed && tagName === "INPUT" && element.indeterminate)\n return "mixed";\n if (tagName === "INPUT" && ["checkbox", "radio"].includes(element.type))\n return element.checked;\n if (kAriaCheckedRoles.includes(getAriaRole(element) || "")) {\n const checked = element.getAttribute("aria-checked");\n if (checked === "true") return true;\n if (allowMixed && checked === "mixed") return "mixed";\n return false;\n }\n return "error";\n }\n var kAriaPressedRoles = ["button"];\n function getAriaPressed(element) {\n if (kAriaPressedRoles.includes(getAriaRole(element) || "")) {\n const pressed = element.getAttribute("aria-pressed");\n if (pressed === "true") return true;\n if (pressed === "mixed") return "mixed";\n }\n return false;\n }\n var kAriaExpandedRoles = [\n "application",\n "button",\n "checkbox",\n "combobox",\n "gridcell",\n "link",\n "listbox",\n "menuitem",\n "row",\n "rowheader",\n "tab",\n "treeitem",\n "columnheader",\n "menuitemcheckbox",\n "menuitemradio",\n "rowheader",\n "switch"\n ];\n function getAriaExpanded(element) {\n if (elementSafeTagName(element) === "DETAILS") return element.open;\n if (kAriaExpandedRoles.includes(getAriaRole(element) || "")) {\n const expanded = element.getAttribute("aria-expanded");\n if (expanded === null) return void 0;\n if (expanded === "true") return true;\n return false;\n }\n return void 0;\n }\n var kAriaLevelRoles = ["heading", "listitem", "row", "treeitem"];\n function getAriaLevel(element) {\n const native = { H1: 1, H2: 2, H3: 3, H4: 4, H5: 5, H6: 6 }[elementSafeTagName(element)];\n if (native) return native;\n if (kAriaLevelRoles.includes(getAriaRole(element) || "")) {\n const attr = element.getAttribute("aria-level");\n const value = attr === null ? Number.NaN : Number(attr);\n if (Number.isInteger(value) && value >= 1) return value;\n }\n return 0;\n }\n var kAriaDisabledRoles = [\n "application",\n "button",\n "composite",\n "gridcell",\n "group",\n "input",\n "link",\n "menuitem",\n "scrollbar",\n "separator",\n "tab",\n "checkbox",\n "columnheader",\n "combobox",\n "grid",\n "listbox",\n "menu",\n "menubar",\n "menuitemcheckbox",\n "menuitemradio",\n "option",\n "radio",\n "radiogroup",\n "row",\n "rowheader",\n "searchbox",\n "select",\n "slider",\n "spinbutton",\n "switch",\n "tablist",\n "textbox",\n "toolbar",\n "tree",\n "treegrid",\n "treeitem"\n ];\n function getAriaDisabled(element) {\n return isNativelyDisabled(element) || hasExplicitAriaDisabled(element);\n }\n function isNativelyDisabled(element) {\n const isNativeFormControl = [\n "BUTTON",\n "INPUT",\n "SELECT",\n "TEXTAREA",\n "OPTION",\n "OPTGROUP"\n ].includes(element.tagName);\n return isNativeFormControl && (element.hasAttribute("disabled") || belongsToDisabledFieldSet(element));\n }\n function belongsToDisabledFieldSet(element) {\n const fieldSetElement = element?.closest("FIELDSET[DISABLED]");\n if (!fieldSetElement) return false;\n const legendElement = fieldSetElement.querySelector(":scope > LEGEND");\n return !legendElement || !legendElement.contains(element);\n }\n function hasExplicitAriaDisabled(element, isAncestor = false) {\n if (!element) return false;\n if (isAncestor || kAriaDisabledRoles.includes(getAriaRole(element) || "")) {\n const attribute = (element.getAttribute("aria-disabled") || "").toLowerCase();\n if (attribute === "true") return true;\n if (attribute === "false") return false;\n return hasExplicitAriaDisabled(parentElementOrShadowHost(element), true);\n }\n return false;\n }\n function getAccessibleNameFromAssociatedLabels(labels, options) {\n return [...labels].map(\n (label) => getTextAlternativeInternal(label, {\n ...options,\n embeddedInLabel: { element: label, hidden: isElementHiddenForAria(label) },\n embeddedInNativeTextAlternative: void 0,\n embeddedInLabelledBy: void 0,\n embeddedInDescribedBy: void 0,\n embeddedInTargetElement: void 0\n })\n ).filter((accessibleName) => !!accessibleName).join(" ");\n }\n function receivesPointerEvents(element) {\n const cache = cachePointerEvents;\n let e = element;\n let result;\n const parents = [];\n for (; e; e = parentElementOrShadowHost(e)) {\n const cached = cache.get(e);\n if (cached !== void 0) {\n result = cached;\n break;\n }\n parents.push(e);\n const style = getElementComputedStyle(e);\n if (!style) {\n result = true;\n break;\n }\n const value = style.pointerEvents;\n if (value) {\n result = value !== "none";\n break;\n }\n }\n if (result === void 0) result = true;\n for (const parent of parents) cache.set(parent, result);\n return result;\n }\n var cacheAccessibleName;\n var cacheAccessibleNameHidden;\n var cacheIsHidden;\n var cachePseudoContent;\n var cachePseudoContentBefore;\n var cachePseudoContentAfter;\n var cachePointerEvents;\n var cachesCounter = 0;\n function beginAriaCaches() {\n ++cachesCounter;\n cacheAccessibleName ??= /* @__PURE__ */ new Map();\n cacheAccessibleNameHidden ??= /* @__PURE__ */ new Map();\n cacheIsHidden ??= /* @__PURE__ */ new Map();\n cachePseudoContent ??= /* @__PURE__ */ new Map();\n cachePseudoContentBefore ??= /* @__PURE__ */ new Map();\n cachePseudoContentAfter ??= /* @__PURE__ */ new Map();\n cachePointerEvents ??= /* @__PURE__ */ new Map();\n }\n function endAriaCaches() {\n if (!--cachesCounter) {\n cacheAccessibleName = void 0;\n cacheAccessibleNameHidden = void 0;\n cacheIsHidden = void 0;\n cachePseudoContent = void 0;\n cachePseudoContentBefore = void 0;\n cachePseudoContentAfter = void 0;\n cachePointerEvents = void 0;\n }\n }\n var inputTypeToRole = {\n button: "button",\n checkbox: "checkbox",\n image: "button",\n number: "spinbutton",\n radio: "radio",\n range: "slider",\n reset: "button",\n submit: "button"\n };\n\n // src/browser/ariaTree/yamlUtils.ts\n function yamlEscapeKeyIfNeeded(str) {\n if (!yamlStringNeedsQuotes(str)) return str;\n return `\'` + str.replace(/\'/g, `\'\'`) + `\'`;\n }\n function yamlEscapeValueIfNeeded(str) {\n if (!yamlStringNeedsQuotes(str)) return str;\n return \'"\' + str.replace(/[\\\\"\\x00-\\x1f\\x7f-\\x9f]/g, (c) => {\n switch (c) {\n case "\\\\":\n return "\\\\\\\\";\n case \'"\':\n return \'\\\\"\';\n case "\\b":\n return "\\\\b";\n case "\\f":\n return "\\\\f";\n case "\\n":\n return "\\\\n";\n case "\\r":\n return "\\\\r";\n case "\t":\n return "\\\\t";\n default:\n const code = c.charCodeAt(0);\n return "\\\\x" + code.toString(16).padStart(2, "0");\n }\n }) + \'"\';\n }\n function yamlStringNeedsQuotes(str) {\n if (str.length === 0) return true;\n if (/^\\s|\\s$/.test(str)) return true;\n if (/[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f-\\x9f]/.test(str)) return true;\n if (/^-/.test(str)) return true;\n if (/[\\n:](\\s|$)/.test(str)) return true;\n if (/\\s#/.test(str)) return true;\n if (/[\\n\\r]/.test(str)) return true;\n if (/^[&*\\],?!>|@"\'#%]/.test(str)) return true;\n if (/[{}`]/.test(str)) return true;\n if (/^\\[/.test(str)) return true;\n if (!isNaN(Number(str)) || ["y", "n", "yes", "no", "true", "false", "on", "off", "null"].includes(str.toLowerCase()))\n return true;\n return false;\n }\n\n // src/browser/ariaTree/ariaSnapshot.ts\n function generateAndRenderAriaTree(root, counter) {\n const refCounter = counter || { value: 0 };\n root.querySelectorAll("[data-spark-ref]").forEach((el) => {\n el.removeAttribute("data-spark-ref");\n el.removeAttribute("data-spark-role");\n });\n const ariaTree = generateAriaTree(root);\n return renderAriaTree(ariaTree, refCounter);\n }\n var MAX_IFRAME_DEPTH = 5;\n function generateAriaTree(rootElement, iframeDepth = 0) {\n const visited = /* @__PURE__ */ new Set();\n const root = {\n role: "fragment",\n name: "",\n children: [],\n element: rootElement,\n props: {},\n box: box(rootElement),\n receivesPointerEvents: true\n };\n const visit = (ariaNode, node) => {\n if (visited.has(node)) return;\n visited.add(node);\n if (node.nodeType === Node.TEXT_NODE && node.nodeValue) {\n const text = node.nodeValue;\n if (ariaNode.role !== "textbox" && text) ariaNode.children.push(node.nodeValue || "");\n return;\n }\n if (node.nodeType !== Node.ELEMENT_NODE) return;\n const element = node;\n let isVisible = !isElementHiddenForAria(element);\n isVisible = isVisible || isElementVisible(element);\n if (!isVisible) return;\n const ariaChildren = [];\n if (element.hasAttribute("aria-owns")) {\n const ids = element.getAttribute("aria-owns").split(/\\s+/);\n for (const id of ids) {\n const ownedElement = rootElement.ownerDocument.getElementById(id);\n if (ownedElement) ariaChildren.push(ownedElement);\n }\n }\n if (element.nodeName === "IFRAME") {\n if (iframeDepth >= MAX_IFRAME_DEPTH) return;\n const iframe = element;\n try {\n const iframeDoc = iframe.contentDocument;\n if (iframeDoc && iframeDoc.body) {\n iframeDoc.body.querySelectorAll("[data-spark-ref]").forEach((el) => el.removeAttribute("data-spark-ref"));\n const iframeTree = generateAriaTree(iframeDoc.body, iframeDepth + 1);\n for (const child of iframeTree.children) {\n ariaNode.children.push(child);\n }\n return;\n }\n } catch {\n }\n const iframeNode = {\n role: "iframe",\n name: "",\n children: [],\n props: {},\n element,\n box: box(element),\n receivesPointerEvents: true\n };\n ariaNode.children.push(iframeNode);\n return;\n }\n const childAriaNode = toAriaNode(element);\n if (childAriaNode) {\n ariaNode.children.push(childAriaNode);\n }\n processElement(childAriaNode || ariaNode, element, ariaChildren);\n };\n function processElement(ariaNode, element, ariaChildren = []) {\n const display = getElementComputedStyle(element)?.display || "inline";\n const treatAsBlock = display !== "inline" || element.nodeName === "BR" ? " " : "";\n if (treatAsBlock) ariaNode.children.push(treatAsBlock);\n ariaNode.children.push(getCSSContent(element, "::before") || "");\n const assignedNodes = element.nodeName === "SLOT" ? element.assignedNodes() : [];\n if (assignedNodes.length) {\n for (const child of assignedNodes) visit(ariaNode, child);\n } else {\n for (let child = element.firstChild; child; child = child.nextSibling) {\n if (!child.assignedSlot) visit(ariaNode, child);\n }\n if (element.shadowRoot) {\n for (let child = element.shadowRoot.firstChild; child; child = child.nextSibling)\n visit(ariaNode, child);\n }\n }\n for (const child of ariaChildren) visit(ariaNode, child);\n ariaNode.children.push(getCSSContent(element, "::after") || "");\n if (treatAsBlock) ariaNode.children.push(treatAsBlock);\n if (ariaNode.children.length === 1 && ariaNode.name === ariaNode.children[0])\n ariaNode.children = [];\n if (ariaNode.role === "link" && element.hasAttribute("href")) {\n const href = element.getAttribute("href");\n ariaNode.props["url"] = href;\n }\n }\n beginAriaCaches();\n try {\n visit(root, rootElement);\n } finally {\n endAriaCaches();\n }\n normalizeStringChildren(root);\n normalizeGenericRoles(root);\n return root;\n }\n function toAriaNode(element) {\n const role = getAriaRole(element) ?? "generic";\n if (role === "presentation" || role === "none") return null;\n const name = normalizeWhiteSpace(getElementAccessibleName(element, false) || "");\n const pointerEvents = receivesPointerEvents(element);\n const result = {\n role,\n name,\n children: [],\n props: {},\n element,\n box: box(element),\n receivesPointerEvents: pointerEvents\n };\n if (kAriaCheckedRoles.includes(role))\n result.checked = getAriaChecked(element);\n if (kAriaDisabledRoles.includes(role))\n result.disabled = getAriaDisabled(element);\n if (kAriaExpandedRoles.includes(role))\n result.expanded = getAriaExpanded(element);\n if (kAriaLevelRoles.includes(role)) result.level = getAriaLevel(element);\n if (kAriaPressedRoles.includes(role))\n result.pressed = getAriaPressed(element);\n if (kAriaSelectedRoles.includes(role))\n result.selected = getAriaSelected(element);\n if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {\n const nonTextTypes = ["password", "checkbox", "radio", "file"];\n const sensitiveAutocomplete = [\n "cc-number",\n "cc-name",\n "cc-csc",\n "cc-exp",\n "cc-exp-month",\n "cc-exp-year",\n "cc-type",\n "new-password",\n "current-password",\n "one-time-code"\n ];\n const autocomplete = element.getAttribute("autocomplete") || "";\n if (!nonTextTypes.includes(element.type) && !sensitiveAutocomplete.includes(autocomplete)) {\n result.children = [element.value];\n }\n }\n return result;\n }\n function normalizeGenericRoles(node) {\n const normalizeChildren = (node2) => {\n const result = [];\n for (const child of node2.children || []) {\n if (typeof child === "string") {\n result.push(child);\n continue;\n }\n const normalized = normalizeChildren(child);\n result.push(...normalized);\n }\n const removeSelf = node2.role === "generic" && result.length <= 1 && result.every((c) => typeof c !== "string" && nodeReceivesPointerEvents(c));\n if (removeSelf) return result;\n node2.children = result;\n return [node2];\n };\n normalizeChildren(node);\n }\n function normalizeStringChildren(rootA11yNode) {\n const flushChildren = (buffer, normalizedChildren) => {\n if (!buffer.length) return;\n const text = normalizeWhiteSpace(buffer.join(""));\n if (text) normalizedChildren.push(text);\n buffer.length = 0;\n };\n const visit = (ariaNode) => {\n const normalizedChildren = [];\n const buffer = [];\n for (const child of ariaNode.children || []) {\n if (typeof child === "string") {\n buffer.push(child);\n } else {\n flushChildren(buffer, normalizedChildren);\n visit(child);\n normalizedChildren.push(child);\n }\n }\n flushChildren(buffer, normalizedChildren);\n ariaNode.children = normalizedChildren.length ? normalizedChildren : [];\n if (ariaNode.children.length === 1 && ariaNode.children[0] === ariaNode.name)\n ariaNode.children = [];\n };\n visit(rootA11yNode);\n }\n var SOM_CONTAINER_ID = "__spark-som-container";\n var SOM_COLORS = [\n "#e6194b",\n // red\n "#3cb44b",\n // green\n "#4363d8",\n // blue\n "#f58231",\n // orange\n "#911eb4",\n // purple\n "#42d4f4"\n // cyan\n ];\n var SOM_INTERACTIVE_TAGS = /* @__PURE__ */ new Set(["A", "BUTTON", "INPUT", "SELECT", "TEXTAREA", "SUMMARY"]);\n var SOM_INTERACTIVE_ROLES = /* @__PURE__ */ new Set([\n "button",\n "link",\n "textbox",\n "combobox",\n "checkbox",\n "radio",\n "switch",\n "slider",\n "spinbutton",\n "searchbox",\n "option",\n "menuitem",\n "menuitemcheckbox",\n "menuitemradio",\n "tab",\n "treeitem",\n "gridcell",\n "columnheader",\n "rowheader"\n ]);\n function isInteractiveElement(el) {\n if (SOM_INTERACTIVE_TAGS.has(el.tagName)) return true;\n const ce = el.getAttribute("contenteditable");\n if (ce === "true" || ce === "") return true;\n const computedRole = el.getAttribute("data-spark-role");\n if (computedRole && SOM_INTERACTIVE_ROLES.has(computedRole)) return true;\n const rawRole = el.getAttribute("role");\n if (rawRole) {\n const roles = rawRole.split(/\\s+/);\n if (roles.some((r) => SOM_INTERACTIVE_ROLES.has(r))) return true;\n }\n const tabindex = el.getAttribute("tabindex");\n if (tabindex !== null) {\n const tabValue = parseInt(tabindex, 10);\n if (!isNaN(tabValue) && tabValue >= 0 && (!computedRole || computedRole === "generic")) {\n return true;\n }\n }\n return false;\n }\n function applySetOfMarks() {\n removeSetOfMarks();\n if (!document.body) return;\n const container = document.createElement("div");\n container.id = SOM_CONTAINER_ID;\n container.style.cssText = "position:fixed;top:0;left:0;width:0;height:0;pointer-events:none;z-index:2147483647;";\n const elements = document.querySelectorAll("[data-spark-ref]");\n let colorIndex = 0;\n elements.forEach((el) => {\n const ref = el.getAttribute("data-spark-ref");\n if (!ref) return;\n if (!isInteractiveElement(el)) return;\n const rect = el.getBoundingClientRect();\n if (rect.width === 0 && rect.height === 0) return;\n const color = SOM_COLORS[colorIndex % SOM_COLORS.length];\n const absTop = rect.top + window.scrollY;\n const absLeft = rect.left + window.scrollX;\n const outline = document.createElement("div");\n outline.style.cssText = `position:absolute;top:${absTop}px;left:${absLeft}px;width:${rect.width}px;height:${rect.height}px;border:2px solid ${color};box-sizing:border-box;border-radius:2px;`;\n container.appendChild(outline);\n const badge = document.createElement("div");\n badge.textContent = ref;\n badge.style.cssText = `position:absolute;top:${absTop}px;left:${absLeft}px;background:${color};color:#fff;font:bold 11px monospace;padding:1px 3px;border-radius:2px;line-height:1.2;white-space:nowrap;`;\n container.appendChild(badge);\n colorIndex++;\n });\n document.body.appendChild(container);\n }\n function removeSetOfMarks() {\n const existing = document.getElementById(SOM_CONTAINER_ID);\n if (existing) existing.remove();\n }\n function nodeReceivesPointerEvents(ariaNode) {\n return ariaNode.box.visible && ariaNode.receivesPointerEvents;\n }\n function hasPointerCursor(ariaNode) {\n return ariaNode.box.style?.cursor === "pointer";\n }\n function renderAriaTree(root, counter) {\n const lines = [];\n const visit = (ariaNode, _parentAriaNode, indent) => {\n if (typeof ariaNode === "string") {\n const text = yamlEscapeValueIfNeeded(ariaNode);\n if (text) lines.push(indent + "- text: " + text);\n return;\n }\n let key = ariaNode.role;\n if (ariaNode.name) {\n const displayName = ariaNode.name.length > 900 ? ariaNode.name.slice(0, 900) + "..." : ariaNode.name;\n const stringifiedName = JSON.stringify(displayName);\n key += " " + stringifiedName;\n }\n if (ariaNode.checked === "mixed") key += ` [checked=mixed]`;\n if (ariaNode.checked === true) key += ` [checked]`;\n if (ariaNode.disabled) key += ` [disabled]`;\n if (ariaNode.expanded) key += ` [expanded]`;\n if (ariaNode.level) key += ` [level=${ariaNode.level}]`;\n if (ariaNode.pressed === "mixed") key += ` [pressed=mixed]`;\n if (ariaNode.pressed === true) key += ` [pressed]`;\n if (ariaNode.selected === true) key += ` [selected]`;\n if (nodeReceivesPointerEvents(ariaNode)) {\n const ref = "E" + ++counter.value;\n const cursor = hasPointerCursor(ariaNode) ? " [cursor=pointer]" : "";\n key += ` [ref=${ref}]${cursor}`;\n ariaNode.element?.setAttribute("data-spark-ref", ref);\n ariaNode.element?.setAttribute("data-spark-role", ariaNode.role);\n }\n const escapedKey = indent + "- " + yamlEscapeKeyIfNeeded(key);\n const hasProps = !!Object.keys(ariaNode.props).length;\n if (!ariaNode.children.length && !hasProps) {\n lines.push(escapedKey);\n } else if (ariaNode.children.length === 1 && typeof ariaNode.children[0] === "string" && !hasProps) {\n const text = ariaNode.children[0];\n if (text) lines.push(escapedKey + ": " + yamlEscapeValueIfNeeded(text));\n else lines.push(escapedKey);\n } else {\n lines.push(escapedKey + ":");\n for (const [name, value] of Object.entries(ariaNode.props))\n lines.push(indent + " - /" + name + ": " + yamlEscapeValueIfNeeded(value));\n for (const child of ariaNode.children || []) visit(child, ariaNode, indent + " ");\n }\n };\n if (root.role === "fragment") {\n for (const child of root.children || []) visit(child, root, "");\n } else {\n visit(root, null, "");\n }\n return lines.join("\\n");\n }\n return __toCommonJS(ariaSnapshot_exports);\n})();\n\nglobalThis.__sparkAriaTree = __sparkAriaTree;\n'; +export const ARIA_TREE_SCRIPT = "\"use strict\";\nvar __sparkAriaTree = (() => {\n var __defProp = Object.defineProperty;\n var __getOwnPropDesc = Object.getOwnPropertyDescriptor;\n var __getOwnPropNames = Object.getOwnPropertyNames;\n var __hasOwnProp = Object.prototype.hasOwnProperty;\n var __export = (target, all) => {\n for (var name in all)\n __defProp(target, name, { get: all[name], enumerable: true });\n };\n var __copyProps = (to, from, except, desc) => {\n if (from && typeof from === \"object\" || typeof from === \"function\") {\n for (let key of __getOwnPropNames(from))\n if (!__hasOwnProp.call(to, key) && key !== except)\n __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });\n }\n return to;\n };\n var __toCommonJS = (mod) => __copyProps(__defProp({}, \"__esModule\", { value: true }), mod);\n\n // src/browser/ariaTree/ariaSnapshot.ts\n var ariaSnapshot_exports = {};\n __export(ariaSnapshot_exports, {\n applySetOfMarks: () => applySetOfMarks,\n generateAndRenderAriaTree: () => generateAndRenderAriaTree,\n isInteractiveElement: () => isInteractiveElement,\n removeSetOfMarks: () => removeSetOfMarks\n });\n\n // src/browser/ariaTree/stringUtils.ts\n function normalizeWhiteSpace(text) {\n if (!text) return \"\";\n return text.replace(/[\\u200b\\u00ad]/g, \"\").trim().replace(/\\s+/g, \" \");\n }\n\n // src/browser/ariaTree/domUtils.ts\n function parentElementOrShadowHost(element) {\n if (element.parentElement) return element.parentElement;\n if (!element.parentNode) return;\n if (element.parentNode.nodeType === 11 && element.parentNode.host)\n return element.parentNode.host;\n }\n function enclosingShadowRootOrDocument(element) {\n let node = element;\n while (node.parentNode) node = node.parentNode;\n if (node.nodeType === 11 || node.nodeType === 9)\n return node;\n }\n function enclosingShadowHost(element) {\n while (element.parentElement) element = element.parentElement;\n return parentElementOrShadowHost(element);\n }\n function closestCrossShadow(element, css, scope) {\n while (element) {\n const closest = element.closest(css);\n if (scope && closest !== scope && closest?.contains(scope)) return;\n if (closest) return closest;\n element = enclosingShadowHost(element);\n }\n }\n function getElementComputedStyle(element, pseudo) {\n return element.ownerDocument && element.ownerDocument.defaultView ? element.ownerDocument.defaultView.getComputedStyle(element, pseudo) : void 0;\n }\n function isElementStyleVisibilityVisible(element, style) {\n style = style ?? getElementComputedStyle(element);\n if (!style) return true;\n if (Element.prototype.checkVisibility) {\n if (!element.checkVisibility()) return false;\n } else {\n const detailsOrSummary = element.closest(\"details,summary\");\n if (detailsOrSummary !== element && detailsOrSummary?.nodeName === \"DETAILS\" && !detailsOrSummary.open)\n return false;\n }\n if (style.visibility !== \"visible\") return false;\n return true;\n }\n function box(element) {\n const style = getElementComputedStyle(element);\n if (!style) return { visible: true };\n if (style.display === \"contents\") {\n for (let child = element.firstChild; child; child = child.nextSibling) {\n if (child.nodeType === 1 && isElementVisible(child))\n return { visible: true, style };\n if (child.nodeType === 3 && isVisibleTextNode(child))\n return { visible: true, style };\n }\n return { visible: false, style };\n }\n if (!isElementStyleVisibilityVisible(element, style)) return { style, visible: false };\n const rect = element.getBoundingClientRect();\n return { rect, style, visible: rect.width > 0 && rect.height > 0 };\n }\n function isElementVisible(element) {\n return box(element).visible;\n }\n function isVisibleTextNode(node) {\n const range = node.ownerDocument.createRange();\n range.selectNode(node);\n const rect = range.getBoundingClientRect();\n return rect.width > 0 && rect.height > 0;\n }\n function elementSafeTagName(element) {\n if (element instanceof HTMLFormElement) return \"FORM\";\n return element.tagName.toUpperCase();\n }\n\n // src/browser/ariaTree/cssTokenizer.ts\n var between = function(num, first, last) {\n return num >= first && num <= last;\n };\n function digit(code) {\n return between(code, 48, 57);\n }\n function hexdigit(code) {\n return digit(code) || between(code, 65, 70) || between(code, 97, 102);\n }\n function uppercaseletter(code) {\n return between(code, 65, 90);\n }\n function lowercaseletter(code) {\n return between(code, 97, 122);\n }\n function letter(code) {\n return uppercaseletter(code) || lowercaseletter(code);\n }\n function nonascii(code) {\n return code >= 128;\n }\n function namestartchar(code) {\n return letter(code) || nonascii(code) || code === 95;\n }\n function namechar(code) {\n return namestartchar(code) || digit(code) || code === 45;\n }\n function nonprintable(code) {\n return between(code, 0, 8) || code === 11 || between(code, 14, 31) || code === 127;\n }\n function newline(code) {\n return code === 10;\n }\n function whitespace(code) {\n return newline(code) || code === 9 || code === 32;\n }\n var maximumallowedcodepoint = 1114111;\n function preprocess(str) {\n const codepoints = [];\n for (let i = 0; i < str.length; i++) {\n let code = str.charCodeAt(i);\n if (code === 13 && str.charCodeAt(i + 1) === 10) {\n code = 10;\n i++;\n }\n if (code === 13 || code === 12) code = 10;\n if (code === 0) code = 65533;\n if (between(code, 55296, 56319) && between(str.charCodeAt(i + 1), 56320, 57343)) {\n const lead = code - 55296;\n const trail = str.charCodeAt(i + 1) - 56320;\n code = Math.pow(2, 16) + lead * Math.pow(2, 10) + trail;\n i++;\n }\n codepoints.push(code);\n }\n return codepoints;\n }\n function stringFromCode(code) {\n if (code <= 65535) return String.fromCharCode(code);\n code -= Math.pow(2, 16);\n const lead = Math.floor(code / Math.pow(2, 10)) + 55296;\n const trail = code % Math.pow(2, 10) + 56320;\n return String.fromCharCode(lead) + String.fromCharCode(trail);\n }\n function tokenize(str1) {\n const str = preprocess(str1);\n let i = -1;\n const tokens = [];\n let code;\n let line = 0;\n let column = 0;\n let lastLineLength = 0;\n const incrLineno = function() {\n line += 1;\n lastLineLength = column;\n column = 0;\n };\n const codepoint = function(i2) {\n if (i2 >= str.length) return -1;\n return str[i2];\n };\n const next = function(num) {\n if (num === void 0) num = 1;\n if (num > 3) throw \"Spec Error: no more than three codepoints of lookahead.\";\n return codepoint(i + num);\n };\n const consume = function(num) {\n if (num === void 0) num = 1;\n i += num;\n code = codepoint(i);\n if (newline(code)) incrLineno();\n else column += num;\n return true;\n };\n const reconsume = function() {\n i -= 1;\n if (newline(code)) {\n line -= 1;\n column = lastLineLength;\n } else {\n column -= 1;\n }\n return true;\n };\n const eof = function(codepoint2) {\n if (codepoint2 === void 0) codepoint2 = code;\n return codepoint2 === -1;\n };\n const donothing = function() {\n };\n const parseerror = function() {\n };\n const consumeAToken = function() {\n consumeComments();\n consume();\n if (whitespace(code)) {\n while (whitespace(next())) consume();\n return new WhitespaceToken();\n } else if (code === 34) {\n return consumeAStringToken();\n } else if (code === 35) {\n if (namechar(next()) || areAValidEscape(next(1), next(2))) {\n const token = new HashToken(\"\");\n if (wouldStartAnIdentifier(next(1), next(2), next(3))) token.type = \"id\";\n token.value = consumeAName();\n return token;\n } else {\n return new DelimToken(code);\n }\n } else if (code === 39) {\n return consumeAStringToken();\n } else if (code === 40) {\n return new OpenParenToken();\n } else if (code === 41) {\n return new CloseParenToken();\n } else if (code === 43) {\n if (startsWithANumber()) {\n reconsume();\n return consumeANumericToken();\n } else {\n return new DelimToken(code);\n }\n } else if (code === 44) {\n return new CommaToken();\n } else if (code === 45) {\n if (startsWithANumber()) {\n reconsume();\n return consumeANumericToken();\n } else if (startsWithAnIdentifier()) {\n reconsume();\n return consumeAnIdentlikeToken();\n } else {\n return new DelimToken(code);\n }\n } else if (code === 46) {\n if (startsWithANumber()) {\n reconsume();\n return consumeANumericToken();\n } else {\n return new DelimToken(code);\n }\n } else if (code === 58) {\n return new ColonToken();\n } else if (code === 59) {\n return new SemicolonToken();\n } else if (code === 64) {\n if (wouldStartAnIdentifier(next(1), next(2), next(3)))\n return new AtKeywordToken(consumeAName());\n else return new DelimToken(code);\n } else if (code === 91) {\n return new OpenSquareToken();\n } else if (code === 92) {\n if (startsWithAValidEscape()) {\n reconsume();\n return consumeAnIdentlikeToken();\n } else {\n parseerror();\n return new DelimToken(code);\n }\n } else if (code === 93) {\n return new CloseSquareToken();\n } else if (code === 123) {\n return new OpenCurlyToken();\n } else if (code === 125) {\n return new CloseCurlyToken();\n } else if (digit(code)) {\n reconsume();\n return consumeANumericToken();\n } else if (namestartchar(code)) {\n reconsume();\n return consumeAnIdentlikeToken();\n } else if (eof()) {\n return new EOFToken();\n } else {\n return new DelimToken(code);\n }\n };\n const consumeComments = function() {\n while (next(1) === 47 && next(2) === 42) {\n consume(2);\n while (true) {\n consume();\n if (code === 42 && next() === 47) {\n consume();\n break;\n } else if (eof()) {\n parseerror();\n return;\n }\n }\n }\n };\n const consumeANumericToken = function() {\n const num = consumeANumber();\n if (wouldStartAnIdentifier(next(1), next(2), next(3))) {\n const token = new DimensionToken();\n token.value = num.value;\n token.repr = num.repr;\n token.type = num.type;\n token.unit = consumeAName();\n return token;\n } else if (next() === 37) {\n consume();\n const token = new PercentageToken();\n token.value = num.value;\n token.repr = num.repr;\n return token;\n } else {\n const token = new NumberToken();\n token.value = num.value;\n token.repr = num.repr;\n token.type = num.type;\n return token;\n }\n };\n const consumeAnIdentlikeToken = function() {\n const str2 = consumeAName();\n if (str2.toLowerCase() === \"url\" && next() === 40) {\n consume();\n while (whitespace(next(1)) && whitespace(next(2))) consume();\n if (next() === 34 || next() === 39) return new FunctionToken(str2);\n else if (whitespace(next()) && (next(2) === 34 || next(2) === 39))\n return new FunctionToken(str2);\n else return consumeAURLToken();\n } else if (next() === 40) {\n consume();\n return new FunctionToken(str2);\n } else {\n return new IdentToken(str2);\n }\n };\n const consumeAStringToken = function(endingCodePoint) {\n if (endingCodePoint === void 0) endingCodePoint = code;\n let string = \"\";\n while (consume()) {\n if (code === endingCodePoint || eof()) {\n return new StringToken(string);\n } else if (newline(code)) {\n parseerror();\n reconsume();\n return new BadStringToken();\n } else if (code === 92) {\n if (eof(next())) donothing();\n else if (newline(next())) consume();\n else string += stringFromCode(consumeEscape());\n } else {\n string += stringFromCode(code);\n }\n }\n throw new Error(\"Internal error\");\n };\n const consumeAURLToken = function() {\n const token = new URLToken(\"\");\n while (whitespace(next())) consume();\n if (eof(next())) return token;\n while (consume()) {\n if (code === 41 || eof()) {\n return token;\n } else if (whitespace(code)) {\n while (whitespace(next())) consume();\n if (next() === 41 || eof(next())) {\n consume();\n return token;\n } else {\n consumeTheRemnantsOfABadURL();\n return new BadURLToken();\n }\n } else if (code === 34 || code === 39 || code === 40 || nonprintable(code)) {\n parseerror();\n consumeTheRemnantsOfABadURL();\n return new BadURLToken();\n } else if (code === 92) {\n if (startsWithAValidEscape()) {\n token.value += stringFromCode(consumeEscape());\n } else {\n parseerror();\n consumeTheRemnantsOfABadURL();\n return new BadURLToken();\n }\n } else {\n token.value += stringFromCode(code);\n }\n }\n throw new Error(\"Internal error\");\n };\n const consumeEscape = function() {\n consume();\n if (hexdigit(code)) {\n const digits = [code];\n for (let total = 0; total < 5; total++) {\n if (hexdigit(next())) {\n consume();\n digits.push(code);\n } else {\n break;\n }\n }\n if (whitespace(next())) consume();\n let value = parseInt(\n digits.map(function(x) {\n return String.fromCharCode(x);\n }).join(\"\"),\n 16\n );\n if (value > maximumallowedcodepoint) value = 65533;\n return value;\n } else if (eof()) {\n return 65533;\n } else {\n return code;\n }\n };\n const areAValidEscape = function(c1, c2) {\n if (c1 !== 92) return false;\n if (newline(c2)) return false;\n return true;\n };\n const startsWithAValidEscape = function() {\n return areAValidEscape(code, next());\n };\n const wouldStartAnIdentifier = function(c1, c2, c3) {\n if (c1 === 45) return namestartchar(c2) || c2 === 45 || areAValidEscape(c2, c3);\n else if (namestartchar(c1)) return true;\n else if (c1 === 92) return areAValidEscape(c1, c2);\n else return false;\n };\n const startsWithAnIdentifier = function() {\n return wouldStartAnIdentifier(code, next(1), next(2));\n };\n const wouldStartANumber = function(c1, c2, c3) {\n if (c1 === 43 || c1 === 45) {\n if (digit(c2)) return true;\n if (c2 === 46 && digit(c3)) return true;\n return false;\n } else if (c1 === 46) {\n if (digit(c2)) return true;\n return false;\n } else if (digit(c1)) {\n return true;\n } else {\n return false;\n }\n };\n const startsWithANumber = function() {\n return wouldStartANumber(code, next(1), next(2));\n };\n const consumeAName = function() {\n let result = \"\";\n while (consume()) {\n if (namechar(code)) {\n result += stringFromCode(code);\n } else if (startsWithAValidEscape()) {\n result += stringFromCode(consumeEscape());\n } else {\n reconsume();\n return result;\n }\n }\n throw new Error(\"Internal parse error\");\n };\n const consumeANumber = function() {\n let repr = \"\";\n let type = \"integer\";\n if (next() === 43 || next() === 45) {\n consume();\n repr += stringFromCode(code);\n }\n while (digit(next())) {\n consume();\n repr += stringFromCode(code);\n }\n if (next(1) === 46 && digit(next(2))) {\n consume();\n repr += stringFromCode(code);\n consume();\n repr += stringFromCode(code);\n type = \"number\";\n while (digit(next())) {\n consume();\n repr += stringFromCode(code);\n }\n }\n const c1 = next(1), c2 = next(2), c3 = next(3);\n if ((c1 === 69 || c1 === 101) && digit(c2)) {\n consume();\n repr += stringFromCode(code);\n consume();\n repr += stringFromCode(code);\n type = \"number\";\n while (digit(next())) {\n consume();\n repr += stringFromCode(code);\n }\n } else if ((c1 === 69 || c1 === 101) && (c2 === 43 || c2 === 45) && digit(c3)) {\n consume();\n repr += stringFromCode(code);\n consume();\n repr += stringFromCode(code);\n consume();\n repr += stringFromCode(code);\n type = \"number\";\n while (digit(next())) {\n consume();\n repr += stringFromCode(code);\n }\n }\n const value = +repr;\n return { type, value, repr };\n };\n const consumeTheRemnantsOfABadURL = function() {\n while (consume()) {\n if (code === 41 || eof()) {\n return;\n } else if (startsWithAValidEscape()) {\n consumeEscape();\n donothing();\n } else {\n donothing();\n }\n }\n };\n let iterationCount = 0;\n while (!eof(next())) {\n tokens.push(consumeAToken());\n iterationCount++;\n if (iterationCount > str.length * 2) throw new Error(\"I'm infinite-looping!\");\n }\n return tokens;\n }\n var CSSParserToken = class {\n tokenType = \"\";\n value;\n toJSON() {\n return { token: this.tokenType };\n }\n toString() {\n return this.tokenType;\n }\n toSource() {\n return \"\" + this;\n }\n };\n var BadStringToken = class extends CSSParserToken {\n tokenType = \"BADSTRING\";\n };\n var BadURLToken = class extends CSSParserToken {\n tokenType = \"BADURL\";\n };\n var WhitespaceToken = class extends CSSParserToken {\n tokenType = \"WHITESPACE\";\n toString() {\n return \"WS\";\n }\n toSource() {\n return \" \";\n }\n };\n var ColonToken = class extends CSSParserToken {\n tokenType = \":\";\n };\n var SemicolonToken = class extends CSSParserToken {\n tokenType = \";\";\n };\n var CommaToken = class extends CSSParserToken {\n tokenType = \",\";\n };\n var GroupingToken = class extends CSSParserToken {\n value = \"\";\n mirror = \"\";\n };\n var OpenCurlyToken = class extends GroupingToken {\n tokenType = \"{\";\n constructor() {\n super();\n this.value = \"{\";\n this.mirror = \"}\";\n }\n };\n var CloseCurlyToken = class extends GroupingToken {\n tokenType = \"}\";\n constructor() {\n super();\n this.value = \"}\";\n this.mirror = \"{\";\n }\n };\n var OpenSquareToken = class extends GroupingToken {\n tokenType = \"[\";\n constructor() {\n super();\n this.value = \"[\";\n this.mirror = \"]\";\n }\n };\n var CloseSquareToken = class extends GroupingToken {\n tokenType = \"]\";\n constructor() {\n super();\n this.value = \"]\";\n this.mirror = \"[\";\n }\n };\n var OpenParenToken = class extends GroupingToken {\n tokenType = \"(\";\n constructor() {\n super();\n this.value = \"(\";\n this.mirror = \")\";\n }\n };\n var CloseParenToken = class extends GroupingToken {\n tokenType = \")\";\n constructor() {\n super();\n this.value = \")\";\n this.mirror = \"(\";\n }\n };\n var EOFToken = class extends CSSParserToken {\n tokenType = \"EOF\";\n toSource() {\n return \"\";\n }\n };\n var DelimToken = class extends CSSParserToken {\n tokenType = \"DELIM\";\n value = \"\";\n constructor(code) {\n super();\n this.value = stringFromCode(code);\n }\n toString() {\n return \"DELIM(\" + this.value + \")\";\n }\n toSource() {\n if (this.value === \"\\\\\") return \"\\\\\\n\";\n else return this.value;\n }\n };\n var StringValuedToken = class extends CSSParserToken {\n value = \"\";\n };\n var IdentToken = class extends StringValuedToken {\n constructor(val) {\n super();\n this.value = val;\n }\n tokenType = \"IDENT\";\n };\n var FunctionToken = class extends StringValuedToken {\n tokenType = \"FUNCTION\";\n mirror;\n constructor(val) {\n super();\n this.value = val;\n this.mirror = \")\";\n }\n };\n var AtKeywordToken = class extends StringValuedToken {\n tokenType = \"AT-KEYWORD\";\n constructor(val) {\n super();\n this.value = val;\n }\n };\n var HashToken = class extends StringValuedToken {\n tokenType = \"HASH\";\n type;\n constructor(val) {\n super();\n this.value = val;\n this.type = \"unrestricted\";\n }\n };\n var StringToken = class extends StringValuedToken {\n tokenType = \"STRING\";\n constructor(val) {\n super();\n this.value = val;\n }\n };\n var URLToken = class extends StringValuedToken {\n tokenType = \"URL\";\n constructor(val) {\n super();\n this.value = val;\n }\n };\n var NumberToken = class extends CSSParserToken {\n tokenType = \"NUMBER\";\n type;\n repr;\n constructor() {\n super();\n this.type = \"integer\";\n this.repr = \"\";\n }\n };\n var PercentageToken = class extends CSSParserToken {\n tokenType = \"PERCENTAGE\";\n repr;\n constructor() {\n super();\n this.repr = \"\";\n }\n };\n var DimensionToken = class extends CSSParserToken {\n tokenType = \"DIMENSION\";\n type;\n repr;\n unit;\n constructor() {\n super();\n this.type = \"integer\";\n this.repr = \"\";\n this.unit = \"\";\n }\n };\n\n // src/browser/ariaTree/roleUtils.ts\n function hasExplicitAccessibleName(e) {\n return e.hasAttribute(\"aria-label\") || e.hasAttribute(\"aria-labelledby\");\n }\n var kAncestorPreventingLandmark = \"article:not([role]), aside:not([role]), main:not([role]), nav:not([role]), section:not([role]), [role=article], [role=complementary], [role=main], [role=navigation], [role=region]\";\n var kGlobalAriaAttributes = [\n [\"aria-atomic\", void 0],\n [\"aria-busy\", void 0],\n [\"aria-controls\", void 0],\n [\"aria-current\", void 0],\n [\"aria-describedby\", void 0],\n [\"aria-details\", void 0],\n [\"aria-dropeffect\", void 0],\n [\"aria-flowto\", void 0],\n [\"aria-grabbed\", void 0],\n [\"aria-hidden\", void 0],\n [\"aria-keyshortcuts\", void 0],\n [\n \"aria-label\",\n [\n \"caption\",\n \"code\",\n \"deletion\",\n \"emphasis\",\n \"generic\",\n \"insertion\",\n \"paragraph\",\n \"presentation\",\n \"strong\",\n \"subscript\",\n \"superscript\"\n ]\n ],\n [\n \"aria-labelledby\",\n [\n \"caption\",\n \"code\",\n \"deletion\",\n \"emphasis\",\n \"generic\",\n \"insertion\",\n \"paragraph\",\n \"presentation\",\n \"strong\",\n \"subscript\",\n \"superscript\"\n ]\n ],\n [\"aria-live\", void 0],\n [\"aria-owns\", void 0],\n [\"aria-relevant\", void 0],\n [\"aria-roledescription\", [\"generic\"]]\n ];\n function hasGlobalAriaAttribute(element, forRole) {\n return kGlobalAriaAttributes.some(([attr, prohibited]) => {\n return !prohibited?.includes(forRole || \"\") && element.hasAttribute(attr);\n });\n }\n function hasTabIndex(element) {\n return !Number.isNaN(Number(String(element.getAttribute(\"tabindex\"))));\n }\n function isFocusable(element) {\n return !isNativelyDisabled(element) && (isNativelyFocusable(element) || hasTabIndex(element));\n }\n function isNativelyFocusable(element) {\n const tagName = elementSafeTagName(element);\n if ([\"BUTTON\", \"DETAILS\", \"SELECT\", \"TEXTAREA\"].includes(tagName)) return true;\n if (tagName === \"A\" || tagName === \"AREA\") return element.hasAttribute(\"href\");\n if (tagName === \"INPUT\") return !element.hidden;\n return false;\n }\n var kImplicitRoleByTagName = {\n A: (e) => e.hasAttribute(\"href\") ? \"link\" : null,\n AREA: (e) => e.hasAttribute(\"href\") ? \"link\" : null,\n ARTICLE: () => \"article\",\n ASIDE: () => \"complementary\",\n BLOCKQUOTE: () => \"blockquote\",\n BUTTON: () => \"button\",\n CAPTION: () => \"caption\",\n CODE: () => \"code\",\n DATALIST: () => \"listbox\",\n DD: () => \"definition\",\n DEL: () => \"deletion\",\n DETAILS: () => \"group\",\n DFN: () => \"term\",\n DIALOG: () => \"dialog\",\n DT: () => \"term\",\n EM: () => \"emphasis\",\n FIELDSET: () => \"group\",\n FIGURE: () => \"figure\",\n FOOTER: (e) => closestCrossShadow(e, kAncestorPreventingLandmark) ? null : \"contentinfo\",\n FORM: (e) => hasExplicitAccessibleName(e) ? \"form\" : null,\n H1: () => \"heading\",\n H2: () => \"heading\",\n H3: () => \"heading\",\n H4: () => \"heading\",\n H5: () => \"heading\",\n H6: () => \"heading\",\n HEADER: (e) => closestCrossShadow(e, kAncestorPreventingLandmark) ? null : \"banner\",\n HR: () => \"separator\",\n HTML: () => \"document\",\n IMG: (e) => e.getAttribute(\"alt\") === \"\" && !e.getAttribute(\"title\") && !hasGlobalAriaAttribute(e) && !hasTabIndex(e) ? \"presentation\" : \"img\",\n INPUT: (e) => {\n const type = e.type.toLowerCase();\n if (type === \"search\") return e.hasAttribute(\"list\") ? \"combobox\" : \"searchbox\";\n if ([\"email\", \"tel\", \"text\", \"url\", \"\"].includes(type)) {\n const list = getIdRefs(e, e.getAttribute(\"list\"))[0];\n return list && elementSafeTagName(list) === \"DATALIST\" ? \"combobox\" : \"textbox\";\n }\n if (type === \"hidden\") return null;\n if (type === \"file\") return \"button\";\n return inputTypeToRole[type] || \"textbox\";\n },\n INS: () => \"insertion\",\n LI: () => \"listitem\",\n MAIN: () => \"main\",\n MARK: () => \"mark\",\n MATH: () => \"math\",\n MENU: () => \"list\",\n METER: () => \"meter\",\n NAV: () => \"navigation\",\n OL: () => \"list\",\n OPTGROUP: () => \"group\",\n OPTION: () => \"option\",\n OUTPUT: () => \"status\",\n P: () => \"paragraph\",\n PROGRESS: () => \"progressbar\",\n SECTION: (e) => hasExplicitAccessibleName(e) ? \"region\" : null,\n SELECT: (e) => e.hasAttribute(\"multiple\") || e.size > 1 ? \"listbox\" : \"combobox\",\n STRONG: () => \"strong\",\n SUB: () => \"subscript\",\n SUP: () => \"superscript\",\n SVG: () => \"img\",\n TABLE: () => \"table\",\n TBODY: () => \"rowgroup\",\n TD: (e) => {\n const table = closestCrossShadow(e, \"table\");\n const role = table ? getExplicitAriaRole(table) : \"\";\n return role === \"grid\" || role === \"treegrid\" ? \"gridcell\" : \"cell\";\n },\n TEXTAREA: () => \"textbox\",\n TFOOT: () => \"rowgroup\",\n TH: (e) => {\n if (e.getAttribute(\"scope\") === \"col\") return \"columnheader\";\n if (e.getAttribute(\"scope\") === \"row\") return \"rowheader\";\n const table = closestCrossShadow(e, \"table\");\n const role = table ? getExplicitAriaRole(table) : \"\";\n return role === \"grid\" || role === \"treegrid\" ? \"gridcell\" : \"cell\";\n },\n THEAD: () => \"rowgroup\",\n TIME: () => \"time\",\n TR: () => \"row\",\n UL: () => \"list\"\n };\n var kPresentationInheritanceParents = {\n DD: [\"DL\", \"DIV\"],\n DIV: [\"DL\"],\n DT: [\"DL\", \"DIV\"],\n LI: [\"OL\", \"UL\"],\n TBODY: [\"TABLE\"],\n TD: [\"TR\"],\n TFOOT: [\"TABLE\"],\n TH: [\"TR\"],\n THEAD: [\"TABLE\"],\n TR: [\"THEAD\", \"TBODY\", \"TFOOT\", \"TABLE\"]\n };\n function getImplicitAriaRole(element) {\n const implicitRole = kImplicitRoleByTagName[elementSafeTagName(element)]?.(element) || \"\";\n if (!implicitRole) return null;\n let ancestor = element;\n while (ancestor) {\n const parent = parentElementOrShadowHost(ancestor);\n const parents = kPresentationInheritanceParents[elementSafeTagName(ancestor)];\n if (!parents || !parent || !parents.includes(elementSafeTagName(parent))) break;\n const parentExplicitRole = getExplicitAriaRole(parent);\n if ((parentExplicitRole === \"none\" || parentExplicitRole === \"presentation\") && !hasPresentationConflictResolution(parent, parentExplicitRole))\n return parentExplicitRole;\n ancestor = parent;\n }\n return implicitRole;\n }\n var validRoles = [\n \"alert\",\n \"alertdialog\",\n \"application\",\n \"article\",\n \"banner\",\n \"blockquote\",\n \"button\",\n \"caption\",\n \"cell\",\n \"checkbox\",\n \"code\",\n \"columnheader\",\n \"combobox\",\n \"complementary\",\n \"contentinfo\",\n \"definition\",\n \"deletion\",\n \"dialog\",\n \"directory\",\n \"document\",\n \"emphasis\",\n \"feed\",\n \"figure\",\n \"form\",\n \"generic\",\n \"grid\",\n \"gridcell\",\n \"group\",\n \"heading\",\n \"img\",\n \"insertion\",\n \"link\",\n \"list\",\n \"listbox\",\n \"listitem\",\n \"log\",\n \"main\",\n \"mark\",\n \"marquee\",\n \"math\",\n \"meter\",\n \"menu\",\n \"menubar\",\n \"menuitem\",\n \"menuitemcheckbox\",\n \"menuitemradio\",\n \"navigation\",\n \"none\",\n \"note\",\n \"option\",\n \"paragraph\",\n \"presentation\",\n \"progressbar\",\n \"radio\",\n \"radiogroup\",\n \"region\",\n \"row\",\n \"rowgroup\",\n \"rowheader\",\n \"scrollbar\",\n \"search\",\n \"searchbox\",\n \"separator\",\n \"slider\",\n \"spinbutton\",\n \"status\",\n \"strong\",\n \"subscript\",\n \"superscript\",\n \"switch\",\n \"tab\",\n \"table\",\n \"tablist\",\n \"tabpanel\",\n \"term\",\n \"textbox\",\n \"time\",\n \"timer\",\n \"toolbar\",\n \"tooltip\",\n \"tree\",\n \"treegrid\",\n \"treeitem\"\n ];\n function getExplicitAriaRole(element) {\n const roles = (element.getAttribute(\"role\") || \"\").split(\" \").map((role) => role.trim());\n return roles.find((role) => validRoles.includes(role)) || null;\n }\n function hasPresentationConflictResolution(element, role) {\n return hasGlobalAriaAttribute(element, role) || isFocusable(element);\n }\n function getAriaRole(element) {\n const explicitRole = getExplicitAriaRole(element);\n if (!explicitRole) return getImplicitAriaRole(element);\n if (explicitRole === \"none\" || explicitRole === \"presentation\") {\n const implicitRole = getImplicitAriaRole(element);\n if (hasPresentationConflictResolution(element, implicitRole)) return implicitRole;\n }\n return explicitRole;\n }\n function getAriaBoolean(attr) {\n return attr === null ? void 0 : attr.toLowerCase() === \"true\";\n }\n function isElementIgnoredForAria(element) {\n return [\"STYLE\", \"SCRIPT\", \"NOSCRIPT\", \"TEMPLATE\"].includes(elementSafeTagName(element));\n }\n function isElementHiddenForAria(element) {\n if (isElementIgnoredForAria(element)) return true;\n const style = getElementComputedStyle(element);\n const isSlot = element.nodeName === \"SLOT\";\n if (style?.display === \"contents\" && !isSlot) {\n for (let child = element.firstChild; child; child = child.nextSibling) {\n if (child.nodeType === 1 && !isElementHiddenForAria(child))\n return false;\n if (child.nodeType === 3 && isVisibleTextNode(child))\n return false;\n }\n return true;\n }\n const isOptionInsideSelect = element.nodeName === \"OPTION\" && !!element.closest(\"select\");\n if (!isOptionInsideSelect && !isSlot && !isElementStyleVisibilityVisible(element, style))\n return true;\n return belongsToDisplayNoneOrAriaHiddenOrNonSlotted(element);\n }\n function belongsToDisplayNoneOrAriaHiddenOrNonSlotted(element) {\n let hidden = cacheIsHidden?.get(element);\n if (hidden === void 0) {\n hidden = false;\n if (element.parentElement && element.parentElement.shadowRoot && !element.assignedSlot)\n hidden = true;\n if (!hidden) {\n const style = getElementComputedStyle(element);\n hidden = !style || style.display === \"none\" || getAriaBoolean(element.getAttribute(\"aria-hidden\")) === true;\n }\n if (!hidden) {\n const parent = parentElementOrShadowHost(element);\n if (parent) hidden = belongsToDisplayNoneOrAriaHiddenOrNonSlotted(parent);\n }\n cacheIsHidden?.set(element, hidden);\n }\n return hidden;\n }\n function getIdRefs(element, ref) {\n if (!ref) return [];\n const root = enclosingShadowRootOrDocument(element);\n if (!root) return [];\n try {\n const ids = ref.split(\" \").filter((id) => !!id);\n const result = [];\n for (const id of ids) {\n const firstElement = root.querySelector(\"#\" + CSS.escape(id));\n if (firstElement && !result.includes(firstElement)) result.push(firstElement);\n }\n return result;\n } catch {\n return [];\n }\n }\n function trimFlatString(s) {\n return s.trim();\n }\n function asFlatString(s) {\n return s.split(\"\\xA0\").map(\n (chunk) => chunk.replace(/\\r\\n/g, \"\\n\").replace(/[\\u200b\\u00ad]/g, \"\").replace(/\\s\\s*/g, \" \")\n ).join(\"\\xA0\").trim();\n }\n function queryInAriaOwned(element, selector) {\n const result = [...element.querySelectorAll(selector)];\n for (const owned of getIdRefs(element, element.getAttribute(\"aria-owns\"))) {\n if (owned.matches(selector)) result.push(owned);\n result.push(...owned.querySelectorAll(selector));\n }\n return result;\n }\n function getCSSContent(element, pseudo) {\n const cache = pseudo === \"::before\" ? cachePseudoContentBefore : pseudo === \"::after\" ? cachePseudoContentAfter : cachePseudoContent;\n if (cache?.has(element)) return cache?.get(element);\n const style = getElementComputedStyle(element, pseudo);\n let content;\n if (style && style.display !== \"none\" && style.visibility !== \"hidden\") {\n content = parseCSSContentPropertyAsString(element, style.content, !!pseudo);\n }\n if (pseudo && content !== void 0) {\n const display = style?.display || \"inline\";\n if (display !== \"inline\") content = \" \" + content + \" \";\n }\n if (cache) cache.set(element, content);\n return content;\n }\n function parseCSSContentPropertyAsString(element, content, isPseudo) {\n if (!content || content === \"none\" || content === \"normal\") {\n return;\n }\n try {\n let tokens = tokenize(content).filter((token) => !(token instanceof WhitespaceToken));\n const delimIndex = tokens.findIndex(\n (token) => token instanceof DelimToken && token.value === \"/\"\n );\n if (delimIndex !== -1) {\n tokens = tokens.slice(delimIndex + 1);\n } else if (!isPseudo) {\n return;\n }\n const accumulated = [];\n let index = 0;\n while (index < tokens.length) {\n if (tokens[index] instanceof StringToken) {\n accumulated.push(tokens[index].value);\n index++;\n } else if (index + 2 < tokens.length && tokens[index] instanceof FunctionToken && tokens[index].value === \"attr\" && tokens[index + 1] instanceof IdentToken && tokens[index + 2] instanceof CloseParenToken) {\n const attrName = tokens[index + 1].value;\n accumulated.push(element.getAttribute(attrName) || \"\");\n index += 3;\n } else {\n return;\n }\n }\n return accumulated.join(\"\");\n } catch {\n }\n }\n function getAriaLabelledByElements(element) {\n const ref = element.getAttribute(\"aria-labelledby\");\n if (ref === null) return null;\n const refs = getIdRefs(element, ref);\n return refs.length ? refs : null;\n }\n function allowsNameFromContent(role, targetDescendant) {\n const alwaysAllowsNameFromContent = [\n \"button\",\n \"cell\",\n \"checkbox\",\n \"columnheader\",\n \"gridcell\",\n \"heading\",\n \"link\",\n \"menuitem\",\n \"menuitemcheckbox\",\n \"menuitemradio\",\n \"option\",\n \"radio\",\n \"row\",\n \"rowheader\",\n \"switch\",\n \"tab\",\n \"tooltip\",\n \"treeitem\"\n ].includes(role);\n const descendantAllowsNameFromContent = targetDescendant && [\n \"\",\n \"caption\",\n \"code\",\n \"contentinfo\",\n \"definition\",\n \"deletion\",\n \"emphasis\",\n \"insertion\",\n \"list\",\n \"listitem\",\n \"mark\",\n \"none\",\n \"paragraph\",\n \"presentation\",\n \"region\",\n \"row\",\n \"rowgroup\",\n \"section\",\n \"strong\",\n \"subscript\",\n \"superscript\",\n \"table\",\n \"term\",\n \"time\"\n ].includes(role);\n return alwaysAllowsNameFromContent || descendantAllowsNameFromContent;\n }\n function getElementAccessibleName(element, includeHidden) {\n const cache = includeHidden ? cacheAccessibleNameHidden : cacheAccessibleName;\n let accessibleName = cache?.get(element);\n if (accessibleName === void 0) {\n accessibleName = \"\";\n const elementProhibitsNaming = [\n \"caption\",\n \"code\",\n \"definition\",\n \"deletion\",\n \"emphasis\",\n \"generic\",\n \"insertion\",\n \"mark\",\n \"paragraph\",\n \"presentation\",\n \"strong\",\n \"subscript\",\n \"suggestion\",\n \"superscript\",\n \"term\",\n \"time\"\n ].includes(getAriaRole(element) || \"\");\n if (!elementProhibitsNaming) {\n accessibleName = asFlatString(\n getTextAlternativeInternal(element, {\n includeHidden,\n visitedElements: /* @__PURE__ */ new Set(),\n embeddedInTargetElement: \"self\"\n })\n );\n }\n cache?.set(element, accessibleName);\n }\n return accessibleName;\n }\n function getTextAlternativeInternal(element, options) {\n if (options.visitedElements.has(element)) return \"\";\n const childOptions = {\n ...options,\n embeddedInTargetElement: options.embeddedInTargetElement === \"self\" ? \"descendant\" : options.embeddedInTargetElement\n };\n if (!options.includeHidden) {\n const isEmbeddedInHiddenReferenceTraversal = !!options.embeddedInLabelledBy?.hidden || !!options.embeddedInDescribedBy?.hidden || !!options.embeddedInNativeTextAlternative?.hidden || !!options.embeddedInLabel?.hidden;\n if (isElementIgnoredForAria(element) || !isEmbeddedInHiddenReferenceTraversal && isElementHiddenForAria(element)) {\n options.visitedElements.add(element);\n return \"\";\n }\n }\n const labelledBy = getAriaLabelledByElements(element);\n if (!options.embeddedInLabelledBy) {\n const accessibleName = (labelledBy || []).map(\n (ref) => getTextAlternativeInternal(ref, {\n ...options,\n embeddedInLabelledBy: { element: ref, hidden: isElementHiddenForAria(ref) },\n embeddedInDescribedBy: void 0,\n embeddedInTargetElement: void 0,\n embeddedInLabel: void 0,\n embeddedInNativeTextAlternative: void 0\n })\n ).join(\" \");\n if (accessibleName) return accessibleName;\n }\n const role = getAriaRole(element) || \"\";\n const tagName = elementSafeTagName(element);\n if (!!options.embeddedInLabel || !!options.embeddedInLabelledBy || options.embeddedInTargetElement === \"descendant\") {\n const isOwnLabel = [\n ...element.labels || []\n ].includes(element);\n const isOwnLabelledBy = (labelledBy || []).includes(element);\n if (!isOwnLabel && !isOwnLabelledBy) {\n if (role === \"textbox\") {\n options.visitedElements.add(element);\n if (tagName === \"INPUT\" || tagName === \"TEXTAREA\")\n return element.value;\n return element.textContent || \"\";\n }\n if ([\"combobox\", \"listbox\"].includes(role)) {\n options.visitedElements.add(element);\n let selectedOptions;\n if (tagName === \"SELECT\") {\n selectedOptions = [...element.selectedOptions];\n if (!selectedOptions.length && element.options.length)\n selectedOptions.push(element.options[0]);\n } else {\n const listbox = role === \"combobox\" ? queryInAriaOwned(element, \"*\").find((e) => getAriaRole(e) === \"listbox\") : element;\n selectedOptions = listbox ? queryInAriaOwned(listbox, '[aria-selected=\"true\"]').filter(\n (e) => getAriaRole(e) === \"option\"\n ) : [];\n }\n if (!selectedOptions.length && tagName === \"INPUT\") {\n return element.value;\n }\n return selectedOptions.map((option) => getTextAlternativeInternal(option, childOptions)).join(\" \");\n }\n if ([\"progressbar\", \"scrollbar\", \"slider\", \"spinbutton\", \"meter\"].includes(role)) {\n options.visitedElements.add(element);\n if (element.hasAttribute(\"aria-valuetext\"))\n return element.getAttribute(\"aria-valuetext\") || \"\";\n if (element.hasAttribute(\"aria-valuenow\"))\n return element.getAttribute(\"aria-valuenow\") || \"\";\n return element.getAttribute(\"value\") || \"\";\n }\n if ([\"menu\"].includes(role)) {\n options.visitedElements.add(element);\n return \"\";\n }\n }\n }\n const ariaLabel = element.getAttribute(\"aria-label\") || \"\";\n if (trimFlatString(ariaLabel)) {\n options.visitedElements.add(element);\n return ariaLabel;\n }\n if (![\"presentation\", \"none\"].includes(role)) {\n if (tagName === \"INPUT\" && [\"button\", \"submit\", \"reset\"].includes(element.type)) {\n options.visitedElements.add(element);\n const value = element.value || \"\";\n if (trimFlatString(value)) return value;\n if (element.type === \"submit\") return \"Submit\";\n if (element.type === \"reset\") return \"Reset\";\n const title = element.getAttribute(\"title\") || \"\";\n return title;\n }\n if (tagName === \"INPUT\" && element.type === \"file\") {\n options.visitedElements.add(element);\n const labels = element.labels || [];\n if (labels.length && !options.embeddedInLabelledBy)\n return getAccessibleNameFromAssociatedLabels(labels, options);\n return \"Choose File\";\n }\n if (tagName === \"INPUT\" && element.type === \"image\") {\n options.visitedElements.add(element);\n const labels = element.labels || [];\n if (labels.length && !options.embeddedInLabelledBy)\n return getAccessibleNameFromAssociatedLabels(labels, options);\n const alt = element.getAttribute(\"alt\") || \"\";\n if (trimFlatString(alt)) return alt;\n const title = element.getAttribute(\"title\") || \"\";\n if (trimFlatString(title)) return title;\n return \"Submit\";\n }\n if (!labelledBy && tagName === \"BUTTON\") {\n options.visitedElements.add(element);\n const labels = element.labels || [];\n if (labels.length) return getAccessibleNameFromAssociatedLabels(labels, options);\n }\n if (!labelledBy && tagName === \"OUTPUT\") {\n options.visitedElements.add(element);\n const labels = element.labels || [];\n if (labels.length) return getAccessibleNameFromAssociatedLabels(labels, options);\n return element.getAttribute(\"title\") || \"\";\n }\n if (!labelledBy && (tagName === \"TEXTAREA\" || tagName === \"SELECT\" || tagName === \"INPUT\")) {\n options.visitedElements.add(element);\n const labels = element.labels || [];\n if (labels.length) return getAccessibleNameFromAssociatedLabels(labels, options);\n const usePlaceholder = tagName === \"INPUT\" && [\"text\", \"password\", \"search\", \"tel\", \"email\", \"url\"].includes(\n element.type\n ) || tagName === \"TEXTAREA\";\n const placeholder = element.getAttribute(\"placeholder\") || \"\";\n const title = element.getAttribute(\"title\") || \"\";\n if (!usePlaceholder || title) return title;\n return placeholder;\n }\n if (!labelledBy && tagName === \"FIELDSET\") {\n options.visitedElements.add(element);\n for (let child = element.firstElementChild; child; child = child.nextElementSibling) {\n if (elementSafeTagName(child) === \"LEGEND\") {\n return getTextAlternativeInternal(child, {\n ...childOptions,\n embeddedInNativeTextAlternative: {\n element: child,\n hidden: isElementHiddenForAria(child)\n }\n });\n }\n }\n const title = element.getAttribute(\"title\") || \"\";\n return title;\n }\n if (!labelledBy && tagName === \"FIGURE\") {\n options.visitedElements.add(element);\n for (let child = element.firstElementChild; child; child = child.nextElementSibling) {\n if (elementSafeTagName(child) === \"FIGCAPTION\") {\n return getTextAlternativeInternal(child, {\n ...childOptions,\n embeddedInNativeTextAlternative: {\n element: child,\n hidden: isElementHiddenForAria(child)\n }\n });\n }\n }\n const title = element.getAttribute(\"title\") || \"\";\n return title;\n }\n if (tagName === \"IMG\") {\n options.visitedElements.add(element);\n const alt = element.getAttribute(\"alt\") || \"\";\n if (trimFlatString(alt)) return alt;\n const title = element.getAttribute(\"title\") || \"\";\n return title;\n }\n if (tagName === \"TABLE\") {\n options.visitedElements.add(element);\n for (let child = element.firstElementChild; child; child = child.nextElementSibling) {\n if (elementSafeTagName(child) === \"CAPTION\") {\n return getTextAlternativeInternal(child, {\n ...childOptions,\n embeddedInNativeTextAlternative: {\n element: child,\n hidden: isElementHiddenForAria(child)\n }\n });\n }\n }\n const summary = element.getAttribute(\"summary\") || \"\";\n if (summary) return summary;\n }\n if (tagName === \"AREA\") {\n options.visitedElements.add(element);\n const alt = element.getAttribute(\"alt\") || \"\";\n if (trimFlatString(alt)) return alt;\n const title = element.getAttribute(\"title\") || \"\";\n return title;\n }\n if (tagName === \"SVG\" || element.ownerSVGElement) {\n options.visitedElements.add(element);\n for (let child = element.firstElementChild; child; child = child.nextElementSibling) {\n if (elementSafeTagName(child) === \"TITLE\" && child.ownerSVGElement) {\n return getTextAlternativeInternal(child, {\n ...childOptions,\n embeddedInLabelledBy: { element: child, hidden: isElementHiddenForAria(child) }\n });\n }\n }\n }\n if (element.ownerSVGElement && tagName === \"A\") {\n const title = element.getAttribute(\"xlink:title\") || \"\";\n if (trimFlatString(title)) {\n options.visitedElements.add(element);\n return title;\n }\n }\n }\n const shouldNameFromContentForSummary = tagName === \"SUMMARY\" && ![\"presentation\", \"none\"].includes(role);\n if (allowsNameFromContent(role, options.embeddedInTargetElement === \"descendant\") || shouldNameFromContentForSummary || !!options.embeddedInLabelledBy || !!options.embeddedInDescribedBy || !!options.embeddedInLabel || !!options.embeddedInNativeTextAlternative) {\n options.visitedElements.add(element);\n const accessibleName = innerAccumulatedElementText(element, childOptions);\n const maybeTrimmedAccessibleName = options.embeddedInTargetElement === \"self\" ? trimFlatString(accessibleName) : accessibleName;\n if (maybeTrimmedAccessibleName) return accessibleName;\n }\n if (![\"presentation\", \"none\"].includes(role) || tagName === \"IFRAME\") {\n options.visitedElements.add(element);\n const title = element.getAttribute(\"title\") || \"\";\n if (trimFlatString(title)) return title;\n }\n options.visitedElements.add(element);\n return \"\";\n }\n function innerAccumulatedElementText(element, options) {\n const tokens = [];\n const visit = (node, skipSlotted) => {\n if (skipSlotted && node.assignedSlot) return;\n if (node.nodeType === 1) {\n const display = getElementComputedStyle(node)?.display || \"inline\";\n let token = getTextAlternativeInternal(node, options);\n if (display !== \"inline\" || node.nodeName === \"BR\") token = \" \" + token + \" \";\n tokens.push(token);\n } else if (node.nodeType === 3) {\n tokens.push(node.textContent || \"\");\n }\n };\n tokens.push(getCSSContent(element, \"::before\") || \"\");\n const content = getCSSContent(element);\n if (content !== void 0) {\n tokens.push(content);\n } else {\n const assignedNodes = element.nodeName === \"SLOT\" ? element.assignedNodes() : [];\n if (assignedNodes.length) {\n for (const child of assignedNodes) visit(child, false);\n } else {\n for (let child = element.firstChild; child; child = child.nextSibling) visit(child, true);\n if (element.shadowRoot) {\n for (let child = element.shadowRoot.firstChild; child; child = child.nextSibling)\n visit(child, true);\n }\n for (const owned of getIdRefs(element, element.getAttribute(\"aria-owns\"))) visit(owned, true);\n }\n }\n tokens.push(getCSSContent(element, \"::after\") || \"\");\n return tokens.join(\"\");\n }\n var kAriaSelectedRoles = [\n \"gridcell\",\n \"option\",\n \"row\",\n \"tab\",\n \"rowheader\",\n \"columnheader\",\n \"treeitem\"\n ];\n function getAriaSelected(element) {\n if (elementSafeTagName(element) === \"OPTION\") return element.selected;\n if (kAriaSelectedRoles.includes(getAriaRole(element) || \"\"))\n return getAriaBoolean(element.getAttribute(\"aria-selected\")) === true;\n return false;\n }\n var kAriaCheckedRoles = [\n \"checkbox\",\n \"menuitemcheckbox\",\n \"option\",\n \"radio\",\n \"switch\",\n \"menuitemradio\",\n \"treeitem\"\n ];\n function getAriaChecked(element) {\n const result = getChecked(element, true);\n return result === \"error\" ? false : result;\n }\n function getChecked(element, allowMixed) {\n const tagName = elementSafeTagName(element);\n if (allowMixed && tagName === \"INPUT\" && element.indeterminate)\n return \"mixed\";\n if (tagName === \"INPUT\" && [\"checkbox\", \"radio\"].includes(element.type))\n return element.checked;\n if (kAriaCheckedRoles.includes(getAriaRole(element) || \"\")) {\n const checked = element.getAttribute(\"aria-checked\");\n if (checked === \"true\") return true;\n if (allowMixed && checked === \"mixed\") return \"mixed\";\n return false;\n }\n return \"error\";\n }\n var kAriaPressedRoles = [\"button\"];\n function getAriaPressed(element) {\n if (kAriaPressedRoles.includes(getAriaRole(element) || \"\")) {\n const pressed = element.getAttribute(\"aria-pressed\");\n if (pressed === \"true\") return true;\n if (pressed === \"mixed\") return \"mixed\";\n }\n return false;\n }\n var kAriaExpandedRoles = [\n \"application\",\n \"button\",\n \"checkbox\",\n \"combobox\",\n \"gridcell\",\n \"link\",\n \"listbox\",\n \"menuitem\",\n \"row\",\n \"rowheader\",\n \"tab\",\n \"treeitem\",\n \"columnheader\",\n \"menuitemcheckbox\",\n \"menuitemradio\",\n \"rowheader\",\n \"switch\"\n ];\n function getAriaExpanded(element) {\n if (elementSafeTagName(element) === \"DETAILS\") return element.open;\n if (kAriaExpandedRoles.includes(getAriaRole(element) || \"\")) {\n const expanded = element.getAttribute(\"aria-expanded\");\n if (expanded === null) return void 0;\n if (expanded === \"true\") return true;\n return false;\n }\n return void 0;\n }\n var kAriaLevelRoles = [\"heading\", \"listitem\", \"row\", \"treeitem\"];\n function getAriaLevel(element) {\n const native = { H1: 1, H2: 2, H3: 3, H4: 4, H5: 5, H6: 6 }[elementSafeTagName(element)];\n if (native) return native;\n if (kAriaLevelRoles.includes(getAriaRole(element) || \"\")) {\n const attr = element.getAttribute(\"aria-level\");\n const value = attr === null ? Number.NaN : Number(attr);\n if (Number.isInteger(value) && value >= 1) return value;\n }\n return 0;\n }\n var kAriaDisabledRoles = [\n \"application\",\n \"button\",\n \"composite\",\n \"gridcell\",\n \"group\",\n \"input\",\n \"link\",\n \"menuitem\",\n \"scrollbar\",\n \"separator\",\n \"tab\",\n \"checkbox\",\n \"columnheader\",\n \"combobox\",\n \"grid\",\n \"listbox\",\n \"menu\",\n \"menubar\",\n \"menuitemcheckbox\",\n \"menuitemradio\",\n \"option\",\n \"radio\",\n \"radiogroup\",\n \"row\",\n \"rowheader\",\n \"searchbox\",\n \"select\",\n \"slider\",\n \"spinbutton\",\n \"switch\",\n \"tablist\",\n \"textbox\",\n \"toolbar\",\n \"tree\",\n \"treegrid\",\n \"treeitem\"\n ];\n function getAriaDisabled(element) {\n return isNativelyDisabled(element) || hasExplicitAriaDisabled(element);\n }\n function isNativelyDisabled(element) {\n const isNativeFormControl = [\n \"BUTTON\",\n \"INPUT\",\n \"SELECT\",\n \"TEXTAREA\",\n \"OPTION\",\n \"OPTGROUP\"\n ].includes(element.tagName);\n return isNativeFormControl && (element.hasAttribute(\"disabled\") || belongsToDisabledFieldSet(element));\n }\n function belongsToDisabledFieldSet(element) {\n const fieldSetElement = element?.closest(\"FIELDSET[DISABLED]\");\n if (!fieldSetElement) return false;\n const legendElement = fieldSetElement.querySelector(\":scope > LEGEND\");\n return !legendElement || !legendElement.contains(element);\n }\n function hasExplicitAriaDisabled(element, isAncestor = false) {\n if (!element) return false;\n if (isAncestor || kAriaDisabledRoles.includes(getAriaRole(element) || \"\")) {\n const attribute = (element.getAttribute(\"aria-disabled\") || \"\").toLowerCase();\n if (attribute === \"true\") return true;\n if (attribute === \"false\") return false;\n return hasExplicitAriaDisabled(parentElementOrShadowHost(element), true);\n }\n return false;\n }\n function getAccessibleNameFromAssociatedLabels(labels, options) {\n return [...labels].map(\n (label) => getTextAlternativeInternal(label, {\n ...options,\n embeddedInLabel: { element: label, hidden: isElementHiddenForAria(label) },\n embeddedInNativeTextAlternative: void 0,\n embeddedInLabelledBy: void 0,\n embeddedInDescribedBy: void 0,\n embeddedInTargetElement: void 0\n })\n ).filter((accessibleName) => !!accessibleName).join(\" \");\n }\n function receivesPointerEvents(element) {\n const cache = cachePointerEvents;\n let e = element;\n let result;\n const parents = [];\n for (; e; e = parentElementOrShadowHost(e)) {\n const cached = cache.get(e);\n if (cached !== void 0) {\n result = cached;\n break;\n }\n parents.push(e);\n const style = getElementComputedStyle(e);\n if (!style) {\n result = true;\n break;\n }\n const value = style.pointerEvents;\n if (value) {\n result = value !== \"none\";\n break;\n }\n }\n if (result === void 0) result = true;\n for (const parent of parents) cache.set(parent, result);\n return result;\n }\n var cacheAccessibleName;\n var cacheAccessibleNameHidden;\n var cacheIsHidden;\n var cachePseudoContent;\n var cachePseudoContentBefore;\n var cachePseudoContentAfter;\n var cachePointerEvents;\n var cachesCounter = 0;\n function beginAriaCaches() {\n ++cachesCounter;\n cacheAccessibleName ??= /* @__PURE__ */ new Map();\n cacheAccessibleNameHidden ??= /* @__PURE__ */ new Map();\n cacheIsHidden ??= /* @__PURE__ */ new Map();\n cachePseudoContent ??= /* @__PURE__ */ new Map();\n cachePseudoContentBefore ??= /* @__PURE__ */ new Map();\n cachePseudoContentAfter ??= /* @__PURE__ */ new Map();\n cachePointerEvents ??= /* @__PURE__ */ new Map();\n }\n function endAriaCaches() {\n if (!--cachesCounter) {\n cacheAccessibleName = void 0;\n cacheAccessibleNameHidden = void 0;\n cacheIsHidden = void 0;\n cachePseudoContent = void 0;\n cachePseudoContentBefore = void 0;\n cachePseudoContentAfter = void 0;\n cachePointerEvents = void 0;\n }\n }\n var inputTypeToRole = {\n button: \"button\",\n checkbox: \"checkbox\",\n image: \"button\",\n number: \"spinbutton\",\n radio: \"radio\",\n range: \"slider\",\n reset: \"button\",\n submit: \"button\"\n };\n\n // src/browser/ariaTree/yamlUtils.ts\n function yamlEscapeKeyIfNeeded(str) {\n if (!yamlStringNeedsQuotes(str)) return str;\n return `'` + str.replace(/'/g, `''`) + `'`;\n }\n function yamlEscapeValueIfNeeded(str) {\n if (!yamlStringNeedsQuotes(str)) return str;\n return '\"' + str.replace(/[\\\\\"\\x00-\\x1f\\x7f-\\x9f]/g, (c) => {\n switch (c) {\n case \"\\\\\":\n return \"\\\\\\\\\";\n case '\"':\n return '\\\\\"';\n case \"\\b\":\n return \"\\\\b\";\n case \"\\f\":\n return \"\\\\f\";\n case \"\\n\":\n return \"\\\\n\";\n case \"\\r\":\n return \"\\\\r\";\n case \"\t\":\n return \"\\\\t\";\n default:\n const code = c.charCodeAt(0);\n return \"\\\\x\" + code.toString(16).padStart(2, \"0\");\n }\n }) + '\"';\n }\n function yamlStringNeedsQuotes(str) {\n if (str.length === 0) return true;\n if (/^\\s|\\s$/.test(str)) return true;\n if (/[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f-\\x9f]/.test(str)) return true;\n if (/^-/.test(str)) return true;\n if (/[\\n:](\\s|$)/.test(str)) return true;\n if (/\\s#/.test(str)) return true;\n if (/[\\n\\r]/.test(str)) return true;\n if (/^[&*\\],?!>|@\"'#%]/.test(str)) return true;\n if (/[{}`]/.test(str)) return true;\n if (/^\\[/.test(str)) return true;\n if (!isNaN(Number(str)) || [\"y\", \"n\", \"yes\", \"no\", \"true\", \"false\", \"on\", \"off\", \"null\"].includes(str.toLowerCase()))\n return true;\n return false;\n }\n\n // src/browser/ariaTree/ariaSnapshot.ts\n function generateAndRenderAriaTree(root, counter) {\n const refCounter = counter || { value: 0 };\n root.querySelectorAll(\"[data-spark-ref]\").forEach((el) => {\n el.removeAttribute(\"data-spark-ref\");\n el.removeAttribute(\"data-spark-role\");\n });\n const ariaTree = generateAriaTree(root);\n return renderAriaTree(ariaTree, refCounter);\n }\n var MAX_IFRAME_DEPTH = 5;\n function generateAriaTree(rootElement, iframeDepth = 0) {\n const visited = /* @__PURE__ */ new Set();\n const root = {\n role: \"fragment\",\n name: \"\",\n children: [],\n element: rootElement,\n props: {},\n box: box(rootElement),\n receivesPointerEvents: true\n };\n const visit = (ariaNode, node) => {\n if (visited.has(node)) return;\n visited.add(node);\n if (node.nodeType === Node.TEXT_NODE && node.nodeValue) {\n const text = node.nodeValue;\n if (ariaNode.role !== \"textbox\" && text) ariaNode.children.push(node.nodeValue || \"\");\n return;\n }\n if (node.nodeType !== Node.ELEMENT_NODE) return;\n const element = node;\n let isVisible = !isElementHiddenForAria(element);\n isVisible = isVisible || isElementVisible(element);\n if (!isVisible) return;\n const ariaChildren = [];\n if (element.hasAttribute(\"aria-owns\")) {\n const ids = element.getAttribute(\"aria-owns\").split(/\\s+/);\n for (const id of ids) {\n const ownedElement = rootElement.ownerDocument.getElementById(id);\n if (ownedElement) ariaChildren.push(ownedElement);\n }\n }\n if (element.nodeName === \"IFRAME\") {\n if (iframeDepth >= MAX_IFRAME_DEPTH) return;\n const iframe = element;\n try {\n const iframeDoc = iframe.contentDocument;\n if (iframeDoc && iframeDoc.body) {\n iframeDoc.body.querySelectorAll(\"[data-spark-ref]\").forEach((el) => el.removeAttribute(\"data-spark-ref\"));\n const iframeTree = generateAriaTree(iframeDoc.body, iframeDepth + 1);\n for (const child of iframeTree.children) {\n ariaNode.children.push(child);\n }\n return;\n }\n } catch {\n }\n const iframeNode = {\n role: \"iframe\",\n name: \"\",\n children: [],\n props: {},\n element,\n box: box(element),\n receivesPointerEvents: true\n };\n ariaNode.children.push(iframeNode);\n return;\n }\n const childAriaNode = toAriaNode(element);\n if (childAriaNode) {\n ariaNode.children.push(childAriaNode);\n }\n processElement(childAriaNode || ariaNode, element, ariaChildren);\n };\n function processElement(ariaNode, element, ariaChildren = []) {\n const display = getElementComputedStyle(element)?.display || \"inline\";\n const treatAsBlock = display !== \"inline\" || element.nodeName === \"BR\" ? \" \" : \"\";\n if (treatAsBlock) ariaNode.children.push(treatAsBlock);\n ariaNode.children.push(getCSSContent(element, \"::before\") || \"\");\n const assignedNodes = element.nodeName === \"SLOT\" ? element.assignedNodes() : [];\n if (assignedNodes.length) {\n for (const child of assignedNodes) visit(ariaNode, child);\n } else {\n for (let child = element.firstChild; child; child = child.nextSibling) {\n if (!child.assignedSlot) visit(ariaNode, child);\n }\n if (element.shadowRoot) {\n for (let child = element.shadowRoot.firstChild; child; child = child.nextSibling)\n visit(ariaNode, child);\n }\n }\n for (const child of ariaChildren) visit(ariaNode, child);\n ariaNode.children.push(getCSSContent(element, \"::after\") || \"\");\n if (treatAsBlock) ariaNode.children.push(treatAsBlock);\n if (ariaNode.children.length === 1 && ariaNode.name === ariaNode.children[0])\n ariaNode.children = [];\n if (ariaNode.role === \"link\" && element.hasAttribute(\"href\")) {\n const href = element.getAttribute(\"href\");\n ariaNode.props[\"url\"] = href;\n }\n }\n beginAriaCaches();\n try {\n visit(root, rootElement);\n } finally {\n endAriaCaches();\n }\n normalizeStringChildren(root);\n normalizeGenericRoles(root);\n return root;\n }\n function toAriaNode(element) {\n const role = getAriaRole(element) ?? \"generic\";\n if (role === \"presentation\" || role === \"none\") return null;\n const name = normalizeWhiteSpace(getElementAccessibleName(element, false) || \"\");\n const pointerEvents = receivesPointerEvents(element);\n const result = {\n role,\n name,\n children: [],\n props: {},\n element,\n box: box(element),\n receivesPointerEvents: pointerEvents\n };\n if (kAriaCheckedRoles.includes(role))\n result.checked = getAriaChecked(element);\n if (kAriaDisabledRoles.includes(role))\n result.disabled = getAriaDisabled(element);\n if (kAriaExpandedRoles.includes(role))\n result.expanded = getAriaExpanded(element);\n if (kAriaLevelRoles.includes(role)) result.level = getAriaLevel(element);\n if (kAriaPressedRoles.includes(role))\n result.pressed = getAriaPressed(element);\n if (kAriaSelectedRoles.includes(role))\n result.selected = getAriaSelected(element);\n if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {\n const nonTextTypes = [\"password\", \"checkbox\", \"radio\", \"file\"];\n const sensitiveAutocomplete = [\n \"cc-number\",\n \"cc-name\",\n \"cc-csc\",\n \"cc-exp\",\n \"cc-exp-month\",\n \"cc-exp-year\",\n \"cc-type\",\n \"new-password\",\n \"current-password\",\n \"one-time-code\"\n ];\n const autocomplete = element.getAttribute(\"autocomplete\") || \"\";\n if (!nonTextTypes.includes(element.type) && !sensitiveAutocomplete.includes(autocomplete)) {\n result.children = [element.value];\n }\n }\n return result;\n }\n function normalizeGenericRoles(node) {\n const normalizeChildren = (node2) => {\n const result = [];\n for (const child of node2.children || []) {\n if (typeof child === \"string\") {\n result.push(child);\n continue;\n }\n const normalized = normalizeChildren(child);\n result.push(...normalized);\n }\n const removeSelf = node2.role === \"generic\" && result.length <= 1 && result.every((c) => typeof c !== \"string\" && nodeReceivesPointerEvents(c));\n if (removeSelf) return result;\n node2.children = result;\n return [node2];\n };\n normalizeChildren(node);\n }\n function normalizeStringChildren(rootA11yNode) {\n const flushChildren = (buffer, normalizedChildren) => {\n if (!buffer.length) return;\n const text = normalizeWhiteSpace(buffer.join(\"\"));\n if (text) normalizedChildren.push(text);\n buffer.length = 0;\n };\n const visit = (ariaNode) => {\n const normalizedChildren = [];\n const buffer = [];\n for (const child of ariaNode.children || []) {\n if (typeof child === \"string\") {\n buffer.push(child);\n } else {\n flushChildren(buffer, normalizedChildren);\n visit(child);\n normalizedChildren.push(child);\n }\n }\n flushChildren(buffer, normalizedChildren);\n ariaNode.children = normalizedChildren.length ? normalizedChildren : [];\n if (ariaNode.children.length === 1 && ariaNode.children[0] === ariaNode.name)\n ariaNode.children = [];\n };\n visit(rootA11yNode);\n }\n var SOM_CONTAINER_ID = \"__spark-som-container\";\n var SOM_COLORS = [\n \"#e6194b\",\n // red\n \"#3cb44b\",\n // green\n \"#4363d8\",\n // blue\n \"#f58231\",\n // orange\n \"#911eb4\",\n // purple\n \"#42d4f4\"\n // cyan\n ];\n var SOM_INTERACTIVE_TAGS = /* @__PURE__ */ new Set([\"A\", \"BUTTON\", \"INPUT\", \"SELECT\", \"TEXTAREA\", \"SUMMARY\"]);\n var SOM_INTERACTIVE_ROLES = /* @__PURE__ */ new Set([\n \"button\",\n \"link\",\n \"textbox\",\n \"combobox\",\n \"checkbox\",\n \"radio\",\n \"switch\",\n \"slider\",\n \"spinbutton\",\n \"searchbox\",\n \"option\",\n \"menuitem\",\n \"menuitemcheckbox\",\n \"menuitemradio\",\n \"tab\",\n \"treeitem\",\n \"gridcell\",\n \"columnheader\",\n \"rowheader\"\n ]);\n function isInteractiveElement(el) {\n if (SOM_INTERACTIVE_TAGS.has(el.tagName)) return true;\n const ce = el.getAttribute(\"contenteditable\");\n if (ce === \"true\" || ce === \"\") return true;\n const computedRole = el.getAttribute(\"data-spark-role\");\n if (computedRole && SOM_INTERACTIVE_ROLES.has(computedRole)) return true;\n const rawRole = el.getAttribute(\"role\");\n if (rawRole) {\n const roles = rawRole.split(/\\s+/);\n if (roles.some((r) => SOM_INTERACTIVE_ROLES.has(r))) return true;\n }\n const tabindex = el.getAttribute(\"tabindex\");\n if (tabindex !== null) {\n const tabValue = parseInt(tabindex, 10);\n if (!isNaN(tabValue) && tabValue >= 0 && (!computedRole || computedRole === \"generic\")) {\n return true;\n }\n }\n return false;\n }\n function applySetOfMarks() {\n removeSetOfMarks();\n if (!document.body) return;\n const container = document.createElement(\"div\");\n container.id = SOM_CONTAINER_ID;\n container.style.cssText = \"position:fixed;top:0;left:0;width:0;height:0;pointer-events:none;z-index:2147483647;\";\n const elements = document.querySelectorAll(\"[data-spark-ref]\");\n let colorIndex = 0;\n elements.forEach((el) => {\n const ref = el.getAttribute(\"data-spark-ref\");\n if (!ref) return;\n if (!isInteractiveElement(el)) return;\n const rect = el.getBoundingClientRect();\n if (rect.width === 0 && rect.height === 0) return;\n const color = SOM_COLORS[colorIndex % SOM_COLORS.length];\n const absTop = rect.top + window.scrollY;\n const absLeft = rect.left + window.scrollX;\n const outline = document.createElement(\"div\");\n outline.style.cssText = `position:absolute;top:${absTop}px;left:${absLeft}px;width:${rect.width}px;height:${rect.height}px;border:2px solid ${color};box-sizing:border-box;border-radius:2px;`;\n container.appendChild(outline);\n const badge = document.createElement(\"div\");\n badge.textContent = ref;\n badge.style.cssText = `position:absolute;top:${absTop}px;left:${absLeft}px;background:${color};color:#fff;font:bold 11px monospace;padding:1px 3px;border-radius:2px;line-height:1.2;white-space:nowrap;`;\n container.appendChild(badge);\n colorIndex++;\n });\n document.body.appendChild(container);\n }\n function removeSetOfMarks() {\n const existing = document.getElementById(SOM_CONTAINER_ID);\n if (existing) existing.remove();\n }\n function nodeReceivesPointerEvents(ariaNode) {\n return ariaNode.box.visible && ariaNode.receivesPointerEvents;\n }\n function hasPointerCursor(ariaNode) {\n return ariaNode.box.style?.cursor === \"pointer\";\n }\n function renderAriaTree(root, counter) {\n const lines = [];\n const visit = (ariaNode, _parentAriaNode, indent) => {\n if (typeof ariaNode === \"string\") {\n const text = yamlEscapeValueIfNeeded(ariaNode);\n if (text) lines.push(indent + \"- text: \" + text);\n return;\n }\n let key = ariaNode.role;\n if (ariaNode.name) {\n const displayName = ariaNode.name.length > 900 ? ariaNode.name.slice(0, 900) + \"...\" : ariaNode.name;\n const stringifiedName = JSON.stringify(displayName);\n key += \" \" + stringifiedName;\n }\n if (ariaNode.checked === \"mixed\") key += ` [checked=mixed]`;\n if (ariaNode.checked === true) key += ` [checked]`;\n if (ariaNode.disabled) key += ` [disabled]`;\n if (ariaNode.expanded) key += ` [expanded]`;\n if (ariaNode.level) key += ` [level=${ariaNode.level}]`;\n if (ariaNode.pressed === \"mixed\") key += ` [pressed=mixed]`;\n if (ariaNode.pressed === true) key += ` [pressed]`;\n if (ariaNode.selected === true) key += ` [selected]`;\n if (nodeReceivesPointerEvents(ariaNode)) {\n const ref = \"E\" + ++counter.value;\n const cursor = hasPointerCursor(ariaNode) ? \" [cursor=pointer]\" : \"\";\n key += ` [ref=${ref}]${cursor}`;\n ariaNode.element?.setAttribute(\"data-spark-ref\", ref);\n ariaNode.element?.setAttribute(\"data-spark-role\", ariaNode.role);\n }\n const escapedKey = indent + \"- \" + yamlEscapeKeyIfNeeded(key);\n const hasProps = !!Object.keys(ariaNode.props).length;\n if (!ariaNode.children.length && !hasProps) {\n lines.push(escapedKey);\n } else if (ariaNode.children.length === 1 && typeof ariaNode.children[0] === \"string\" && !hasProps) {\n const text = ariaNode.children[0];\n if (text) lines.push(escapedKey + \": \" + yamlEscapeValueIfNeeded(text));\n else lines.push(escapedKey);\n } else {\n lines.push(escapedKey + \":\");\n for (const [name, value] of Object.entries(ariaNode.props))\n lines.push(indent + \" - /\" + name + \": \" + yamlEscapeValueIfNeeded(value));\n for (const child of ariaNode.children || []) visit(child, ariaNode, indent + \" \");\n }\n };\n if (root.role === \"fragment\") {\n for (const child of root.children || []) visit(child, root, \"\");\n } else {\n visit(root, null, \"\");\n }\n return lines.join(\"\\n\");\n }\n return __toCommonJS(ariaSnapshot_exports);\n})();\n\nglobalThis.__sparkAriaTree = __sparkAriaTree;\n"; diff --git a/src/browser/playwrightBrowser.ts b/src/browser/playwrightBrowser.ts index 658a5bfc..6055a8b1 100644 --- a/src/browser/playwrightBrowser.ts +++ b/src/browser/playwrightBrowser.ts @@ -49,6 +49,10 @@ export interface PlaywrightBrowserOptions { proxyPassword?: string; /** Chrome DevTools Protocol endpoint URL (chromium browsers only) */ pwCdpEndpoint?: string; + /** Ordered list of CDP endpoint URLs to try in sequence (chromium only, takes precedence over pwCdpEndpoint) */ + pwCdpEndpoints?: string[]; + /** Called when a CDP endpoint fails and the next one is being tried */ + onCdpEndpointCycle?: (attempt: number, error: Error) => void; /** Playwright endpoint URL to connect to remote browser */ pwEndpoint?: string; /** Navigation retry configuration */ @@ -69,6 +73,9 @@ export interface ExtendedPlaywrightBrowserOptions extends PlaywrightBrowserOptio /** * PlaywrightBrowser - Browser implementation using Playwright's accessibility features */ +/** Timeout per CDP endpoint connection attempt in milliseconds */ +const CDP_CONNECTION_TIMEOUT_MS = 10_000; + export class PlaywrightBrowser implements AriaBrowser { public browserName: string; public channel: string | undefined; @@ -83,6 +90,13 @@ export class PlaywrightBrowser implements AriaBrowser { // Navigation retry configuration private readonly navigationConfig: NavigationRetryConfig; + // CDP endpoint failover state + private readonly cdpEndpoints: string[]; + private nextStartIndex: number = 0; + + /** Called when a CDP endpoint fails and the next one is being tried. Can be set after construction. */ + public onCdpEndpointCycle: ((attempt: number, error: Error) => void) | undefined; + constructor(private options: ExtendedPlaywrightBrowserOptions = {}) { this.browserName = `playwright:${this.options.browser ?? "firefox"}`; this.channel = this.options.channel ?? this.getDefaultChannel(); @@ -93,14 +107,31 @@ export class PlaywrightBrowser implements AriaBrowser { // Initialize navigation retry config with defaults and overrides // Uses createNavigationRetryConfig which safely handles undefined values this.navigationConfig = createNavigationRetryConfig(options.navigationRetry); + + // Normalize singular → plural for CDP endpoints + this.cdpEndpoints = options.pwCdpEndpoints?.length + ? options.pwCdpEndpoints + : options.pwCdpEndpoint + ? [options.pwCdpEndpoint] + : []; + + // Initialize callback from options (can also be set directly after construction) + this.onCdpEndpointCycle = options.onCdpEndpointCycle; } get pwEndpoint(): string | undefined { return this.options.pwEndpoint; } + /** Returns the currently active CDP endpoint (the last one successfully connected to) */ get pwCdpEndpoint(): string | undefined { - return this.options.pwCdpEndpoint; + if (this.nextStartIndex === 0) return undefined; + return this.cdpEndpoints[this.nextStartIndex - 1]; + } + + /** Returns the full list of configured CDP endpoints */ + get pwCdpEndpoints(): readonly string[] { + return this.cdpEndpoints; } get proxyServer(): string | undefined { @@ -203,8 +234,8 @@ export class PlaywrightBrowser implements AriaBrowser { case "chrome": case "chromium": - if (this.options.pwCdpEndpoint) { - this.browser = await chromium.connectOverCDP(this.options.pwCdpEndpoint, connectOptions); + if (this.cdpEndpoints.length > 0) { + this.browser = await this.connectOverCDPWithFailover(connectOptions); } else if (this.options.pwEndpoint) { this.browser = await chromium.connect(this.options.pwEndpoint, connectOptions); } else { @@ -221,8 +252,8 @@ export class PlaywrightBrowser implements AriaBrowser { case "edge": // Edge uses chromium with channel setting (defaults to "msedge") - if (this.options.pwCdpEndpoint) { - this.browser = await chromium.connectOverCDP(this.options.pwCdpEndpoint, connectOptions); + if (this.cdpEndpoints.length > 0) { + this.browser = await this.connectOverCDPWithFailover(connectOptions); } else if (this.options.pwEndpoint) { this.browser = await chromium.connect(this.options.pwEndpoint, connectOptions); } else { @@ -372,6 +403,55 @@ export class PlaywrightBrowser implements AriaBrowser { } } + /** + * Try each CDP endpoint in sequence starting from nextStartIndex. + * Advances nextStartIndex on success. Throws a hard error if all are exhausted. + */ + private async connectOverCDPWithFailover( + connectOptions: ConnectOptions, + ): Promise { + for (let i = this.nextStartIndex; i < this.cdpEndpoints.length; i++) { + const endpoint = this.cdpEndpoints[i]; + try { + const browser = await chromium.connectOverCDP(endpoint, { + ...connectOptions, + timeout: CDP_CONNECTION_TIMEOUT_MS, + }); + this.nextStartIndex = i + 1; + return browser; + } catch (err) { + if (!(err instanceof Error) || !this.isCdpConnectionError(err)) { + throw err; + } + const attemptNumber = i + 1; + const remaining = this.cdpEndpoints.length - i - 1; + if (remaining > 0) { + console.warn( + `[PlaywrightBrowser] CDP endpoint ${attemptNumber} failed (${err.message}), trying next...`, + ); + this.onCdpEndpointCycle?.(attemptNumber, err); + } + } + } + throw new Error(`All ${this.cdpEndpoints.length} CDP endpoint(s) failed. Giving up.`); + } + + /** + * Returns true for connection-level errors that should trigger CDP endpoint cycling: + * timeouts and network-level failures. Auth errors, malformed URLs, etc. return false. + */ + private isCdpConnectionError(error: Error): boolean { + if (error instanceof playwrightErrors.TimeoutError) return true; + const message = error.message; + return ( + message.includes("ECONNREFUSED") || + message.includes("ECONNRESET") || + message.includes("ETIMEDOUT") || + message.includes("net::ERR_") || + message.includes("NS_ERROR_") + ); + } + /** * Check if an error is a retryable network error. * Covers Chromium (net::ERR_*) and Firefox (NS_ERROR_*, NS_BINDING_*) patterns. diff --git a/src/cli/commands/run.ts b/src/cli/commands/run.ts index a16e26b1..7a8d1d36 100644 --- a/src/cli/commands/run.ts +++ b/src/cli/commands/run.ts @@ -100,6 +100,10 @@ async function executeRunCommand(task: string, options: any): Promise { proxyPassword: options.proxyPassword ?? cfg.proxy_password, pwEndpoint: options.pwEndpoint ?? cfg.pw_endpoint, pwCdpEndpoint: options.pwCdpEndpoint ?? cfg.pw_cdp_endpoint, + pwCdpEndpoints: + (options.pwCdpEndpoints as string[] | undefined) ?? + cfg.pw_cdp_endpoints ?? + (cfg.pw_cdp_endpoint ? [cfg.pw_cdp_endpoint] : undefined), actionTimeoutMs: options.actionTimeoutMs ?? cfg.action_timeout_ms, navigationRetry: { baseTimeoutMs: options.navigationTimeoutMs ?? cfg.navigation_timeout_ms, diff --git a/src/config.ts b/src/config.ts index da9854f1..4fd2d232 100644 --- a/src/config.ts +++ b/src/config.ts @@ -65,7 +65,7 @@ function coerceValue( value: string, type: ConfigFieldType, envVar?: string, -): string | number | boolean | undefined { +): string | string[] | number | boolean | undefined { switch (type) { case "boolean": { const lower = value.toLowerCase(); @@ -90,6 +90,11 @@ function coerceValue( } return num; } + case "string[]": + return value + .split(",") + .map((s) => s.trim()) + .filter(Boolean); case "string": case "enum": return value; @@ -185,6 +190,16 @@ export function addConfigOptions(command: Command, exclude: string[] = []): Comm option.argParser(parseFloat); } + // For string arrays, use argParser to split comma-separated values + if (field.type === "string[]") { + option.argParser((val: string) => + val + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + ); + } + // Don't set Commander defaults - our config system handles defaults via // DEFAULTS, and setting them here would override env vars and config file values diff --git a/src/configDefaults.ts b/src/configDefaults.ts index 3e7c849d..571097e1 100644 --- a/src/configDefaults.ts +++ b/src/configDefaults.ts @@ -35,7 +35,7 @@ export type LoggerType = (typeof LOGGERS)[number]; export const SEARCH_PROVIDERS = ["none", "duckduckgo", "google", "bing", "parallel-api"] as const; export type SearchProviderName = (typeof SEARCH_PROVIDERS)[number]; -export type ConfigFieldType = "string" | "number" | "boolean" | "enum"; +export type ConfigFieldType = "string" | "string[]" | "number" | "boolean" | "enum"; export type ConfigCategory = | "ai" @@ -100,6 +100,7 @@ export interface SparkConfig { // Playwright Configuration pw_endpoint?: string; pw_cdp_endpoint?: string; + pw_cdp_endpoints?: string[]; bypass_csp?: boolean; // Navigation Configuration (timeouts in milliseconds) @@ -164,6 +165,7 @@ export interface SparkConfigResolved { // Playwright Configuration pw_endpoint?: string; pw_cdp_endpoint?: string; + pw_cdp_endpoints?: string[]; bypass_csp: boolean; // Navigation Configuration (timeouts in milliseconds) @@ -510,6 +512,15 @@ export const FIELDS: Record = { description: "Chrome DevTools Protocol endpoint URL (chromium only)", category: "playwright", }, + pw_cdp_endpoints: { + type: "string[]", + cli: "--pw-cdp-endpoints", + placeholder: "url1,url2,...", + env: ["SPARK_PW_CDP_ENDPOINTS"], + description: + "Comma-separated list of CDP endpoint URLs to try in order (chromium only, takes precedence over --pw-cdp-endpoint)", + category: "playwright", + }, bypass_csp: { default: false, type: "boolean", diff --git a/src/events.ts b/src/events.ts index 15f03f00..5af679ac 100644 --- a/src/events.ts +++ b/src/events.ts @@ -39,6 +39,9 @@ export enum WebAgentEventType { // System/Debug SYSTEM_DEBUG_COMPRESSION = "system:debug_compression", SYSTEM_DEBUG_MESSAGE = "system:debug_message", + + // CDP endpoint failover + CDP_ENDPOINT_CYCLE = "cdp:endpoint_cycle", } /** @@ -60,6 +63,7 @@ export interface TaskSetupEventData extends WebAgentEventData { data?: any; pwEndpoint?: string; pwCdpEndpoint?: string; + pwCdpEndpoints?: string[]; proxy?: string; vision?: boolean; provider?: string; @@ -68,6 +72,16 @@ export interface TaskSetupEventData extends WebAgentEventData { keySource?: "global" | "env" | "not_set"; } +/** + * Event data when a CDP endpoint fails and the next one is being tried + */ +export interface CdpEndpointCycleEventData extends WebAgentEventData { + /** 1-based index of the endpoint attempt that failed */ + attempt: number; + /** Error message from the failed connection attempt */ + error: string; +} + /** * Event data when a task is started */ @@ -284,7 +298,8 @@ export type WebAgentEvent = data: ScreenshotCapturedImageEventData; } | { type: WebAgentEventType.SYSTEM_DEBUG_COMPRESSION; data: CompressionDebugEventData } - | { type: WebAgentEventType.SYSTEM_DEBUG_MESSAGE; data: MessagesDebugEventData }; + | { type: WebAgentEventType.SYSTEM_DEBUG_MESSAGE; data: MessagesDebugEventData } + | { type: WebAgentEventType.CDP_ENDPOINT_CYCLE; data: CdpEndpointCycleEventData }; /** * Event emitter for WebAgent events diff --git a/src/loggers/chalkConsole.ts b/src/loggers/chalkConsole.ts index 1eb13a22..6c9dfbd7 100644 --- a/src/loggers/chalkConsole.ts +++ b/src/loggers/chalkConsole.ts @@ -4,6 +4,7 @@ import type { ActionExecutionEventData, ActionResultEventData, AgentStepEventData, + CdpEndpointCycleEventData, CompressionDebugEventData, ExtractedDataEventData, MessagesDebugEventData, @@ -81,6 +82,9 @@ export class ChalkConsoleLogger implements Logger { // AI events emitter.onEvent(WebAgentEventType.AI_GENERATION_ERROR, this.handleAIGenerationError); + + // CDP failover events + emitter.onEvent(WebAgentEventType.CDP_ENDPOINT_CYCLE, this.handleCdpEndpointCycle); } dispose(): void { @@ -128,6 +132,9 @@ export class ChalkConsoleLogger implements Logger { // AI events this.emitter.offEvent(WebAgentEventType.AI_GENERATION_ERROR, this.handleAIGenerationError); + // CDP failover events + this.emitter.offEvent(WebAgentEventType.CDP_ENDPOINT_CYCLE, this.handleCdpEndpointCycle); + // Reset emitter reference this.emitter = null; } @@ -141,6 +148,8 @@ export class ChalkConsoleLogger implements Logger { console.log(chalk.gray(`Browser: ${data.browserName}`)); if (data.pwEndpoint) console.log(chalk.gray(`Remote endpoint: ${data.pwEndpoint}`)); if (data.pwCdpEndpoint) console.log(chalk.gray(`CDP endpoint: ${data.pwCdpEndpoint}`)); + else if (data.pwCdpEndpoints?.length) + console.log(chalk.gray(`CDP endpoint: ${data.pwCdpEndpoints[0]}`)); if (data.proxy) console.log(chalk.gray(`Proxy: ${data.proxy}`)); if (data.url) console.log(chalk.gray(`Starting URL: ${data.url}`)); if (data.guardrails) console.log(chalk.gray(`Guardrails: ${data.guardrails}`)); @@ -294,6 +303,13 @@ export class ChalkConsoleLogger implements Logger { console.error(chalk.red.bold("❌ AI generation error:"), chalk.whiteBright(data.error)); }; + private handleCdpEndpointCycle = (data: CdpEndpointCycleEventData): void => { + console.warn( + chalk.yellow(`⚠️ CDP endpoint attempt ${data.attempt} failed, trying next...`), + chalk.gray(`(${data.error})`), + ); + }; + private handleTaskMetrics = (data: TaskMetricsEventData): void => { console.log(chalk.cyan.bold("\n📊 Task Metrics Summary")); console.log(chalk.gray("━".repeat(60))); diff --git a/src/loggers/secretsRedactor.ts b/src/loggers/secretsRedactor.ts index 2354c0b4..d0d27cbc 100644 --- a/src/loggers/secretsRedactor.ts +++ b/src/loggers/secretsRedactor.ts @@ -11,7 +11,7 @@ export type WebAgentEventDataFields = { }; export const SECRET_FIELDS_BY_EVENT_TYPE: WebAgentEventDataFields = { - [WebAgentEventType.TASK_SETUP]: ["pwEndpoint", "pwCdpEndpoint"], + [WebAgentEventType.TASK_SETUP]: ["pwEndpoint", "pwCdpEndpoint", "pwCdpEndpoints"], } as const; export class SecretsRedactor extends LoggerFilter { @@ -24,8 +24,13 @@ export class SecretsRedactor extends LoggerFilter { // Create shallow copy to avoid mutating original const redacted = { ...data }; for (const field of secretFields) { - if (field in redacted && typeof redacted[field] === "string" && redacted[field] !== "") { + if (!(field in redacted)) continue; + const value = redacted[field]; + if (typeof value === "string" && value !== "") { redacted[field] = "(redacted)"; + } else if (Array.isArray(value) && value.length > 0) { + // Collapse to a single-element array to hide both values and count + redacted[field] = ["(redacted)"]; } } return redacted; diff --git a/src/webAgent.ts b/src/webAgent.ts index 2edc5b9a..369520b8 100644 --- a/src/webAgent.ts +++ b/src/webAgent.ts @@ -10,7 +10,7 @@ import { streamText, ModelMessage, StreamTextResult } from "ai"; import type { ProviderConfig } from "./provider.js"; import { AriaBrowser } from "./browser/ariaBrowser.js"; -import { WebAgentEventEmitter, WebAgentEventType } from "./events.js"; +import { CdpEndpointCycleEventData, WebAgentEventEmitter, WebAgentEventType } from "./events.js"; import { SnapshotCompressor } from "./snapshotCompressor.js"; import { Logger } from "./loggers/types.js"; import { ConsoleLogger } from "./loggers/console.js"; @@ -220,6 +220,18 @@ export class WebAgent { // Initialize logger with event emitter this.logger.initialize(this.eventEmitter); + + // Wire up CDP endpoint cycle callback so browser failover events flow through the event system + const browserAny = this.browser as any; + if ("onCdpEndpointCycle" in browserAny) { + browserAny.onCdpEndpointCycle = (attempt: number, error: Error): void => { + const data: Omit = { + attempt, + error: error.message, + }; + this.emit(WebAgentEventType.CDP_ENDPOINT_CYCLE, data); + }; + } } /** @@ -1270,6 +1282,7 @@ export class WebAgent { data: this.data, pwEndpoint: (this.browser as any).pwEndpoint, pwCdpEndpoint: (this.browser as any).pwCdpEndpoint, + pwCdpEndpoints: (this.browser as any).pwCdpEndpoints, proxy: (this.browser as any).proxyServer, vision: this.vision, }); diff --git a/test/config.test.ts b/test/config.test.ts index 7574e1fd..e78978e2 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -180,6 +180,7 @@ describe("ConfigManager", () => { "guardrails", "pw_endpoint", "pw_cdp_endpoint", + "pw_cdp_endpoints", "bypass_csp", "navigation_timeout_ms", "navigation_max_timeout_ms", diff --git a/test/events.test.ts b/test/events.test.ts index 8c309bd7..e7d51f48 100644 --- a/test/events.test.ts +++ b/test/events.test.ts @@ -135,6 +135,7 @@ describe("WebAgentEventEmitter", () => { "browser:screenshot_captured_image", "system:debug_compression", "system:debug_message", + "cdp:endpoint_cycle", ]; const actualEventTypes = Object.values(WebAgentEventType); diff --git a/test/playwrightBrowser.test.ts b/test/playwrightBrowser.test.ts index 15412524..fb2d54d5 100644 --- a/test/playwrightBrowser.test.ts +++ b/test/playwrightBrowser.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; import { PlaywrightBrowser } from "../src/browser/playwrightBrowser.js"; import { PageAction, LoadState } from "../src/browser/ariaBrowser.js"; import { InvalidRefException, BrowserActionException } from "../src/errors.js"; @@ -795,4 +795,191 @@ describe("PlaywrightBrowser", () => { }); }); }); + + describe("CDP endpoint failover", () => { + // Mock the playwright chromium module for connection tests + vi.mock("playwright", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + chromium: { + ...actual.chromium, + connectOverCDP: vi.fn(), + }, + }; + }); + + let mockChromium: { connectOverCDP: ReturnType }; + + beforeEach(async () => { + const playwright = await import("playwright"); + mockChromium = playwright.chromium as any; + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + function makeMockBrowser() { + const mockPage = { setDefaultTimeout: vi.fn(), close: vi.fn() }; + const mockContext = { newPage: vi.fn().mockResolvedValue(mockPage), close: vi.fn() }; + const mockBrowser = { newContext: vi.fn().mockResolvedValue(mockContext), close: vi.fn() }; + return { mockBrowser, mockContext, mockPage }; + } + + it("normalizes singular pwCdpEndpoint to internal array", () => { + const browser = new PlaywrightBrowser({ pwCdpEndpoint: "ws://host-a:9222" }); + expect((browser as any).cdpEndpoints).toEqual(["ws://host-a:9222"]); + expect(browser.pwCdpEndpoints).toEqual(["ws://host-a:9222"]); + }); + + it("uses pwCdpEndpoints (plural) when provided, ignoring singular", () => { + const browser = new PlaywrightBrowser({ + pwCdpEndpoint: "ws://host-a:9222", + pwCdpEndpoints: ["ws://host-b:9222", "ws://host-c:9222"], + }); + expect((browser as any).cdpEndpoints).toEqual(["ws://host-b:9222", "ws://host-c:9222"]); + }); + + it("pwCdpEndpoint getter returns undefined before any connection", () => { + const browser = new PlaywrightBrowser({ pwCdpEndpoint: "ws://host-a:9222" }); + expect(browser.pwCdpEndpoint).toBeUndefined(); + }); + + it("pwCdpEndpoint getter returns active endpoint after successful start()", async () => { + const { mockBrowser } = makeMockBrowser(); + mockChromium.connectOverCDP.mockResolvedValue(mockBrowser); + + const browser = new PlaywrightBrowser({ + browser: "chromium", + pwCdpEndpoints: ["ws://host-a:9222", "ws://host-b:9222"], + }); + await browser.start(); + + expect(browser.pwCdpEndpoint).toBe("ws://host-a:9222"); + }); + + it("tries second endpoint when first fails with connection refused", async () => { + const { mockBrowser } = makeMockBrowser(); + mockChromium.connectOverCDP + .mockRejectedValueOnce(new Error("ECONNREFUSED")) + .mockResolvedValueOnce(mockBrowser); + + const browser = new PlaywrightBrowser({ + browser: "chromium", + pwCdpEndpoints: ["ws://host-a:9222", "ws://host-b:9222"], + }); + await browser.start(); + + expect(mockChromium.connectOverCDP).toHaveBeenCalledTimes(2); + expect(browser.pwCdpEndpoint).toBe("ws://host-b:9222"); + }); + + it("tries second endpoint when first times out", async () => { + const { errors } = await import("playwright"); + const { mockBrowser } = makeMockBrowser(); + mockChromium.connectOverCDP + .mockRejectedValueOnce(new errors.TimeoutError("Connection timed out")) + .mockResolvedValueOnce(mockBrowser); + + const browser = new PlaywrightBrowser({ + browser: "chromium", + pwCdpEndpoints: ["ws://host-a:9222", "ws://host-b:9222"], + }); + await browser.start(); + + expect(mockChromium.connectOverCDP).toHaveBeenCalledTimes(2); + expect(browser.pwCdpEndpoint).toBe("ws://host-b:9222"); + }); + + it("throws hard error when all endpoints are exhausted", async () => { + mockChromium.connectOverCDP.mockRejectedValue(new Error("ECONNREFUSED")); + + const browser = new PlaywrightBrowser({ + browser: "chromium", + pwCdpEndpoints: ["ws://host-a:9222", "ws://host-b:9222", "ws://host-c:9222"], + }); + + await expect(browser.start()).rejects.toThrow("All 3 CDP endpoint(s) failed"); + expect(mockChromium.connectOverCDP).toHaveBeenCalledTimes(3); + }); + + it("does not cycle on auth/hard errors", async () => { + mockChromium.connectOverCDP.mockRejectedValue(new Error("HTTP 401 Unauthorized")); + + const browser = new PlaywrightBrowser({ + browser: "chromium", + pwCdpEndpoints: ["ws://host-a:9222", "ws://host-b:9222"], + }); + + await expect(browser.start()).rejects.toThrow("HTTP 401 Unauthorized"); + // Only tried the first endpoint — didn't cycle + expect(mockChromium.connectOverCDP).toHaveBeenCalledTimes(1); + }); + + it("advances nextStartIndex after successful connection for mid-task restart", async () => { + const { mockBrowser } = makeMockBrowser(); + mockChromium.connectOverCDP.mockResolvedValue(mockBrowser); + + const browser = new PlaywrightBrowser({ + browser: "chromium", + pwCdpEndpoints: ["ws://host-a:9222", "ws://host-b:9222", "ws://host-c:9222"], + }); + + await browser.start(); + expect(mockChromium.connectOverCDP).toHaveBeenLastCalledWith( + "ws://host-a:9222", + expect.any(Object), + ); + expect((browser as any).nextStartIndex).toBe(1); + + await browser.shutdown(); + await browser.start(); + expect(mockChromium.connectOverCDP).toHaveBeenLastCalledWith( + "ws://host-b:9222", + expect.any(Object), + ); + expect((browser as any).nextStartIndex).toBe(2); + }); + + it("calls onCdpEndpointCycle callback when cycling", async () => { + const { mockBrowser } = makeMockBrowser(); + const cycleCallback = vi.fn(); + mockChromium.connectOverCDP + .mockRejectedValueOnce(new Error("ECONNREFUSED")) + .mockResolvedValueOnce(mockBrowser); + + const browser = new PlaywrightBrowser({ + browser: "chromium", + pwCdpEndpoints: ["ws://host-a:9222", "ws://host-b:9222"], + onCdpEndpointCycle: cycleCallback, + }); + await browser.start(); + + expect(cycleCallback).toHaveBeenCalledOnce(); + expect(cycleCallback).toHaveBeenCalledWith(1, expect.any(Error)); + }); + + it("does not call onCdpEndpointCycle on hard errors", async () => { + const cycleCallback = vi.fn(); + mockChromium.connectOverCDP.mockRejectedValue(new Error("HTTP 401 Unauthorized")); + + const browser = new PlaywrightBrowser({ + browser: "chromium", + pwCdpEndpoints: ["ws://host-a:9222", "ws://host-b:9222"], + onCdpEndpointCycle: cycleCallback, + }); + + await expect(browser.start()).rejects.toThrow(); + expect(cycleCallback).not.toHaveBeenCalled(); + }); + + it("falls through to local launch when cdpEndpoints is empty", () => { + // No CDP configured — should not touch connectOverCDP at all + // (We just verify the internal state; actual launch isn't tested here) + const browser = new PlaywrightBrowser({ browser: "chromium" }); + expect((browser as any).cdpEndpoints).toEqual([]); + }); + }); }); From 6162a718b7877517a02c471e9e0292c1cd2a4397 Mon Sep 17 00:00:00 2001 From: Les Orchard Date: Wed, 18 Feb 2026 15:08:43 -0800 Subject: [PATCH 2/4] docs: add dev session notes for cdp-endpoint-failover Phase 1 plan and Phase 2 plan (mid-task disconnect handling, not yet implemented) for the CDP endpoint failover feature. Co-Authored-By: Claude Sonnet 4.6 --- .../plan-phase2.md | 303 ++++++++++++++++ .../plan.md | 334 ++++++++++++++++++ 2 files changed, 637 insertions(+) create mode 100644 docs/dev-sessions/2026-02-18-1202-cdp-endpoint-failover/plan-phase2.md create mode 100644 docs/dev-sessions/2026-02-18-1202-cdp-endpoint-failover/plan.md diff --git a/docs/dev-sessions/2026-02-18-1202-cdp-endpoint-failover/plan-phase2.md b/docs/dev-sessions/2026-02-18-1202-cdp-endpoint-failover/plan-phase2.md new file mode 100644 index 00000000..e33904a3 --- /dev/null +++ b/docs/dev-sessions/2026-02-18-1202-cdp-endpoint-failover/plan-phase2.md @@ -0,0 +1,303 @@ +# Plan: Phase 2 — Mid-Task Browser Disconnect Handling + +## Background & Problem + +Phase 1 added CDP endpoint failover at connection time: when `chromium.connectOverCDP()` +fails, the next endpoint is tried. But the browser can also drop mid-task — after a +successful connection — producing Playwright's `TargetClosedError`: + +``` +Error: Target page, context or browser has been closed +``` + +This error surfaces from `getTreeWithRefs`, `getScreenshot`, or `performAction` when the +CDP session closes unexpectedly. Currently it propagates to `runMainLoop`'s per-iteration +catch block, which counts it as a regular agent error and calls `addErrorFeedback`. The +dead browser is not restarted, so every subsequent iteration fails the same way until +`maxConsecutiveErrors` is exhausted and the task fails. + +Phase 2 makes mid-task disconnects trigger a browser restart (advancing to the next CDP +endpoint via Phase 1's `nextStartIndex`) and then re-executes the task plan from the +beginning on the new browser. + +## Why Not Just Continue from the Current Page? + +A reconnected browser is a fresh browser with no state: no cookies, no DOM, no session. +The agent's message history at the point of disconnect describes actions against a browser +that no longer exists. Navigating to `currentPage.url` would produce a page in an unknown +state — mid-flow forms gone, auth sessions potentially lost — while the agent's conversation +history expects the opposite. + +The clean approach: keep the already-computed plan (skip re-planning), but restart +execution from the beginning on the new browser. The agent re-executes its plan with +a coherent browser state. + +## Design Goals + +1. Detect `TargetClosedError` in `PlaywrightBrowser` methods and surface it as a + distinct `BrowserDisconnectedError` so `WebAgent` can handle it specially +2. On disconnect: restart browser (uses Phase 1's `nextStartIndex` for next endpoint), + navigate to the original starting URL, and reset the execution message history +3. Do NOT re-run the planning phase — keep `plan`, `successCriteria`, `url`, `actionItems` +4. Do NOT count a disconnect against the agent's error thresholds — it's an + infrastructure failure, not an agent logic error +5. If reconnect fails (all endpoints exhausted), propagate a hard error immediately +6. Emit a new `BROWSER_RECONNECTED` event when reconnect succeeds + +## Design Decisions + +| # | Decision | +|---|---| +| 1 | `BrowserDisconnectedError extends RecoverableError` — fits the error hierarchy but gets special handling in `runMainLoop` | +| 2 | Detection lives in `PlaywrightBrowser` methods (not in `WebAgent`) — keeps Playwright internals encapsulated | +| 3 | Reconnect logic lives in a new `WebAgent.handleBrowserDisconnect()` private method | +| 4 | Disconnects do NOT increment `consecutiveErrors` or `totalErrors` | +| 5 | `consecutiveErrors` is reset to 0 on successful reconnect | +| 6 | After reconnect, navigate to `this.url` (original starting URL, not last page URL) and reset messages via `initializeSystemPromptAndTask()` | +| 7 | Planning phase state (`this.plan`, `this.successCriteria`, `this.url`, `this.actionItems`) is preserved — only message history is reset | +| 8 | Iteration counter is NOT reset — the agent continues against the same `maxIterations` budget | +| 9 | If `browser.start()` throws (endpoints exhausted), it propagates as a hard error → task fails | +| 10 | `navigateToStartWithRetry` already handles `RecoverableError` → no changes needed there | + +## Disconnect Detection in `PlaywrightBrowser` + +Playwright's `TargetClosedError` fires when the CDP session is lost mid-use. +Detect it with: + +```ts +private isBrowserDisconnectedError(error: Error): boolean { + if (error instanceof playwrightErrors.TargetClosedError) return true; + return error.message.includes("Target page, context or browser has been closed"); +} +``` + +The string fallback handles Playwright versions where `TargetClosedError` may not be +separately instantiated. + +### Methods that need disconnect detection + +**`performAction`** — the main action path. Currently wraps unknown errors in +`BrowserActionException`. Add a check before that wrap: + +```ts +} catch (error) { + if (error instanceof InvalidRefException || error instanceof BrowserActionException) { + throw error; + } + // NEW: surface disconnects distinctly before generic wrapping + if (error instanceof Error && this.isBrowserDisconnectedError(error)) { + throw new BrowserDisconnectedError(error.message); + } + throw new BrowserActionException(...); +} +``` + +**`getTreeWithRefs`** — currently no try/catch. Wrap the body and rethrow disconnects: + +```ts +async getTreeWithRefs(): Promise { + try { + // ... existing implementation ... + } catch (error) { + if (error instanceof Error && this.isBrowserDisconnectedError(error)) { + throw new BrowserDisconnectedError(error.message); + } + throw error; + } +} +``` + +**`getScreenshot`** — currently has try/finally but no catch. Add disconnect detection: + +```ts +try { + return await this.page.screenshot(...); +} catch (error) { + if (error instanceof Error && this.isBrowserDisconnectedError(error)) { + throw new BrowserDisconnectedError(error.message); + } + throw error; +} finally { + // ... existing mark cleanup ... +} +``` + +`getUrl()` and `getTitle()` are simpler calls; disconnect there is unlikely in practice +and will surface as uncaught errors counted normally — not worth adding detection. + +## Reconnect Logic in `WebAgent` + +### Modified catch block in `runMainLoop` + +```ts +} catch (error) { + // Browser disconnects are handled specially: trigger reconnect + execution reset + if (error instanceof BrowserDisconnectedError) { + // May throw if all endpoints exhausted — propagates as hard error + await this.handleBrowserDisconnect(task, error); + consecutiveErrors = 0; + needsPageSnapshot = true; + executionState.currentIteration++; + continue; + } + + trackError(); + // ... rest of existing error handling unchanged ... +} +``` + +### New `handleBrowserDisconnect()` private method + +```ts +private async handleBrowserDisconnect(task: string, error: BrowserDisconnectedError): Promise { + console.warn(`[WebAgent] Browser disconnected mid-task: ${error.message}`); + console.warn(`[WebAgent] Restarting on next CDP endpoint...`); + + // Shut down the dead browser + await this.browser.shutdown(); + + // Restart — uses nextStartIndex to try the next CDP endpoint (Phase 1) + // Throws a hard error (non-RecoverableError) if all endpoints are exhausted + await this.browser.start(); + + // Navigate to the original starting URL (not currentPage.url — the new browser + // has no prior state and we need a coherent starting point for re-execution) + if (this.url && this.url !== "about:blank") { + await this.browser.goto(this.url); + await this.updatePageState(); + } + + // Reset message history so the agent re-executes the plan cleanly on the new browser. + // Planning state (this.plan, this.successCriteria, this.url) is preserved. + this.initializeSystemPromptAndTask(task); + + this.emit(WebAgentEventType.BROWSER_RECONNECTED, { + startingUrl: this.url, + }); +} +``` + +If `browser.start()` throws (all CDP endpoints exhausted), that hard error propagates +out of `handleBrowserDisconnect`. JavaScript semantics: a throw inside a catch block +propagates to the enclosing scope — not back to the same catch — so it exits `runMainLoop` +and is converted to a task failure in `execute()`. + +## Error Counting Behavior + +| Scenario | `consecutiveErrors` | `totalErrors` | +|---|---|---| +| Normal agent error (bad action, timeout, etc.) | +1 | +1 | +| Browser disconnect, reconnect succeeds | reset to 0 | unchanged | +| Browser disconnect, reconnect fails (endpoints exhausted) | task fails immediately | N/A | +| Error after reconnect | +1 from 0 | +1 | + +Disconnects are not agent errors — they're infrastructure failures. The agent gets a +clean error slate on the new browser. + +## Changes Required + +### 1. `src/errors.ts` + +Add new error class: + +```ts +/** + * Thrown when the browser connection is lost mid-task (CDP session closed). + * WebAgent catches this to trigger a browser restart and execution reset rather + * than treating it as an ordinary agent error. + */ +export class BrowserDisconnectedError extends RecoverableError { + constructor(message: string) { + super(`Browser connection lost: ${message}`); + this.name = "BrowserDisconnectedError"; + } +} +``` + +### 2. `src/browser/playwrightBrowser.ts` + +- Import `BrowserDisconnectedError` from `errors.js` +- Add `isBrowserDisconnectedError(error: Error): boolean` private method +- Add disconnect detection in `performAction` catch block +- Add try/catch with disconnect detection to `getTreeWithRefs` +- Add catch with disconnect detection to `getScreenshot` + +### 3. `src/events.ts` + +Add new event type and data interface: + +```ts +BROWSER_RECONNECTED = "browser:reconnected" + +export interface BrowserReconnectedEventData extends WebAgentEventData { + startingUrl: string; // The original starting URL the agent is restarting from +} +``` + +Add `{ type: WebAgentEventType.BROWSER_RECONNECTED; data: BrowserReconnectedEventData }` +to the `WebAgentEvent` discriminated union. + +### 4. `src/webAgent.ts` + +- Import `BrowserDisconnectedError` from `errors.js` +- Import `BrowserReconnectedEventData` from `events.js` +- Add `handleBrowserDisconnect(task: string, error: BrowserDisconnectedError): Promise` + private method +- Modify `runMainLoop` catch block to check for `BrowserDisconnectedError` first + +### 5. `src/loggers/chalkConsole.ts` + +Handle the new event: + +```ts +private handleBrowserReconnected = (data: BrowserReconnectedEventData): void => { + console.log( + chalk.yellow(`⚠ Browser disconnected — reconnected and restarting task execution`), + data.startingUrl ? chalk.gray(`(from ${data.startingUrl})`) : "", + ); +}; +``` + +### 6. `src/loggers/secretsRedactor.ts` + +No changes needed. `BrowserReconnectedEventData` contains only `startingUrl` (a page +URL, not a credential) — nothing to redact. + +### 7. Tests + +**`test/playwrightBrowser.test.ts`** — new describe block for disconnect detection: + +- `TargetClosedError` in `performAction` → throws `BrowserDisconnectedError` +- Generic "Target page, context or browser has been closed" message → `BrowserDisconnectedError` +- Non-disconnect error in `performAction` → still wraps as `BrowserActionException` +- `TargetClosedError` in `getTreeWithRefs` → `BrowserDisconnectedError` +- `TargetClosedError` in `getScreenshot` → `BrowserDisconnectedError` + +**`test/webAgent.test.ts`** — new tests: + +- Browser disconnects mid-loop → `handleBrowserDisconnect` called, loop continues +- After reconnect, `consecutiveErrors` is reset to 0 +- Disconnects do NOT increment `totalErrors` +- Reconnect navigates to `this.url` (starting URL), NOT `currentPage.url` +- Messages are reset to initial execution state after reconnect +- `BROWSER_RECONNECTED` event emitted with correct `startingUrl` +- If reconnect `browser.start()` throws (all endpoints exhausted), task fails with hard error +- Iteration counter continues from where it was (not reset) + +## What's Not Changing + +- Phase 1 `connectOverCDPWithFailover` logic — Phase 2 builds on it transparently +- Planning phase state (`plan`, `successCriteria`, `url`) — preserved across reconnect +- `navigateToStartWithRetry` — no changes; it already handles `RecoverableError` correctly +- Error counting for non-disconnect errors — unchanged +- The `CDP_ENDPOINT_CYCLE` event — fires as usual during `browser.start()` when + cycling endpoints, which now also happens during mid-task reconnect + +## Implementation Order + +1. `src/errors.ts` — add `BrowserDisconnectedError` +2. `src/browser/playwrightBrowser.ts` — detection method + 3 method wrappers +3. `src/events.ts` — `BROWSER_RECONNECTED` event type and data interface +4. `src/webAgent.ts` — `handleBrowserDisconnect()` + modified `runMainLoop` catch +5. `src/loggers/chalkConsole.ts` — new event handler +6. Tests diff --git a/docs/dev-sessions/2026-02-18-1202-cdp-endpoint-failover/plan.md b/docs/dev-sessions/2026-02-18-1202-cdp-endpoint-failover/plan.md new file mode 100644 index 00000000..8601dc9e --- /dev/null +++ b/docs/dev-sessions/2026-02-18-1202-cdp-endpoint-failover/plan.md @@ -0,0 +1,334 @@ +# Plan: Multiple CDP Endpoint Failover + +## Background & Problem + +`pwCdpEndpoint` is currently a single `string` threaded through the entire stack: +config schema → CLI options → `PlaywrightBrowser` options → `chromium.connectOverCDP()`. +When the remote browser provider is unreliable, a timeout or connection refusal on that +single endpoint causes the entire task to fail with no recourse. + +## Design Goals + +1. Accept a list of CDP endpoints (ordered, tried in sequence) +2. Backward-compatible: existing single `pw_cdp_endpoint` string still works unchanged +3. On connection failure, automatically cycle to the next endpoint +4. On mid-task browser restart (existing `navigateToStartWithRetry` logic), use the next + endpoint rather than retrying the same failed one +5. Minimal surface area — failover logic lives in `PlaywrightBrowser`, not scattered across callers + +## Design Decisions + +| # | Decision | +| --- | ------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Each endpoint tried once per `start()` call — no per-endpoint retry | +| 2 | Only timeouts and connection refused trigger failover; auth failures, malformed URLs, and in-session browser errors are hard failures | +| 3 | Keep `--pw-cdp-endpoint` (singular, unchanged); add new `--pw-cdp-endpoints` (plural, comma-separated) | +| 4 | Keep `pw_cdp_endpoint` config key (singular, unchanged); add `pw_cdp_endpoints` (plural) | +| 5 | No wraparound — exhausting the endpoint list is a hard, immediate fatal error | +| 6 | CDP connection attempt timeout defaults to 10 seconds per endpoint | + +## Core Architecture: Endpoint Cycling in `PlaywrightBrowser.start()` + +The failover logic lives entirely inside `PlaywrightBrowser.start()`. The `WebAgent` +doesn't need to change — it already calls `browser.shutdown()` + `browser.start()` on +`RecoverableError`, and that's sufficient. + +### State in `PlaywrightBrowser` + +``` +cdpEndpoints: string[] // normalized list, immutable after construction +nextStartIndex: number = 0 // index of first endpoint to try on next start() +``` + +### `start()` behavior + +``` +If cdpEndpoints is empty: + Skip CDP logic entirely — fall through to pwEndpoint/local launch as before + +For each endpoint from nextStartIndex to end of list: + Try connectOverCDP(endpoint, { ...connectOptions, timeout: CDP_CONNECTION_TIMEOUT_MS }) + If success: + Set nextStartIndex = successfulIndex + 1 + Continue with context/page setup + Return + If connection failure (timeout / connection refused): + Log warning, try next endpoint + If other error: + Throw immediately (hard error — don't cycle) + +If all endpoints exhausted: + Throw hard error immediately (NOT RecoverableError) +``` + +Throwing a non-`RecoverableError` when endpoints are exhausted means the `WebAgent`'s +`navigateToStartWithRetry` won't attempt further restarts — the task fails fast rather +than burning through remaining retry attempts uselessly. + +The `{ ...connectOptions, timeout: CDP_CONNECTION_TIMEOUT_MS }` spread preserves any +other Playwright connection options (e.g. `slowMo`) while overriding only the timeout. + +### Endpoint Cycling Example + +Given endpoints `[A, B, C]`: + +- First `start()`: tries A → succeeds → `nextStartIndex = 1` +- Browser fails mid-task, second `start()`: tries B → succeeds → `nextStartIndex = 2` +- Browser fails mid-task, third `start()`: tries C → fails → hard error, task exits + +If A succeeds but then B and C both fail on the second `start()`, that's also an +immediate hard error — all remaining options exhausted. + +## Normalizing Singular → Plural + +Applied at the construction boundary (before `PlaywrightBrowser` is instantiated, in the +CLI command and server route): + +``` +if pw_cdp_endpoints is set → use it +else if pw_cdp_endpoint is set → [pw_cdp_endpoint] +else → [] (no CDP; use local launch or pwEndpoint) +``` + +## Classifying Connection Failures + +Reuse/extend the existing `isNetworkError()` private method in `PlaywrightBrowser`. +A connection failure during `connectOverCDP()` cycles to the next endpoint if the error: + +- Is a Playwright `TimeoutError` +- Has a message matching: `ECONNREFUSED`, `ECONNRESET`, `ETIMEDOUT`, `net::ERR_`, `NS_ERROR_` + +Everything else (e.g., HTTP 401, malformed URL throwing before connection) is rethrown +immediately as a hard error — do not cycle. + +## Changes Required + +### 1. `src/configDefaults.ts` + +Add `"string[]"` to `ConfigFieldType`: + +```ts +export type ConfigFieldType = "string" | "string[]" | "number" | "boolean" | "enum"; +``` + +Add to `SparkConfig` and `SparkConfigResolved`: + +```ts +pw_cdp_endpoints?: string[]; // new — plural, takes precedence over singular +``` + +Add to `FIELDS`: + +```ts +pw_cdp_endpoints: { + type: "string[]", + cli: "--pw-cdp-endpoints", + placeholder: "url1,url2,...", + env: ["SPARK_PW_CDP_ENDPOINTS"], + description: "Comma-separated list of CDP endpoint URLs (chromium only, takes precedence over --pw-cdp-endpoint)", + category: "playwright", +} +``` + +### 2. `src/config.ts` + +Three updates needed: + +**`coerceValue()`** — add `"string[]"` case, splitting on comma: + +```ts +case "string[]": + return value.split(",").map((s) => s.trim()).filter(Boolean); +``` + +Update the return type of `coerceValue()` to include `string[]`. + +**`parseEnvConfig()`** — update return type handling to accommodate `string[]` values +(the `result` accumulator and cast should support arrays). + +**`addConfigOptions()`** — add `.argParser()` for `"string[]"` fields so Commander +splits the comma-separated value into an array: + +```ts +if (field.type === "string[]") { + option.argParser((val: string) => + val + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + ); +} +``` + +### 3. `src/browser/playwrightBrowser.ts` + +**`PlaywrightBrowserOptions`** — add: + +```ts +pwCdpEndpoints?: string[]; // new — takes precedence over singular +``` + +**Internal state** — add: + +```ts +private readonly cdpEndpoints: string[]; // normalized in constructor +private nextStartIndex: number = 0; +``` + +**Public getter for endpoint list** — needed by `webAgent.ts` for event emission and +by loggers for count display: + +```ts +get pwCdpEndpoints(): readonly string[] { + return this.cdpEndpoints; +} +``` + +**Constructor** — normalize singular → plural: + +```ts +this.cdpEndpoints = options.pwCdpEndpoints?.length + ? options.pwCdpEndpoints + : options.pwCdpEndpoint + ? [options.pwCdpEndpoint] + : []; +``` + +**Existing `get pwCdpEndpoint()` getter** — update to return the currently active +endpoint (backward-compatible: callers that use this getter continue to get a meaningful +value regardless of whether singular or plural was passed): + +```ts +get pwCdpEndpoint(): string | undefined { + if (this.nextStartIndex === 0) return undefined; // no successful connection yet + return this.cdpEndpoints[this.nextStartIndex - 1]; +} +``` + +**`start()` method** — replace the single `connectOverCDP` call with a loop over +`cdpEndpoints` from `nextStartIndex`. Pass an explicit `timeout` in `connectOptions` +(default 10 seconds). On connection failure, log and advance. On non-connection error, +rethrow immediately. If loop exhausts all endpoints, throw a hard (non-`RecoverableError`) +error. + +**Connection timeout constant** — add near top of file: + +```ts +const CDP_CONNECTION_TIMEOUT_MS = 10_000; // 10 seconds per endpoint attempt +``` + +### 4. `src/cli/commands/run.ts` + +Normalize before constructing `PlaywrightBrowser`: + +```ts +const cdpEndpoints: string[] | undefined = + options.pwCdpEndpoints ?? // string[] from --pw-cdp-endpoints (split by argParser) + cfg.pw_cdp_endpoints ?? // string[] from config file + (cfg.pw_cdp_endpoint ? [cfg.pw_cdp_endpoint] : undefined); // compat + +const browser = new PlaywrightBrowser({ + // ...existing options unchanged... + pwCdpEndpoint: options.pwCdpEndpoint ?? cfg.pw_cdp_endpoint, // unchanged + pwCdpEndpoints: cdpEndpoints, // new +}); +``` + +`options.pwCdpEndpoints` is available automatically via `addConfigOptions()` once the +`"string[]"` type is supported there — no manual option wiring needed. + +### 5. `server/src/routes/spark.ts` + +Add `pwCdpEndpoints?: string[]` to the request body type. Apply same normalization +before constructing `PlaywrightBrowser`: + +```ts +const cdpEndpoints: string[] | undefined = + body.pwCdpEndpoints ?? + serverConfig.pw_cdp_endpoints ?? + (serverConfig.pw_cdp_endpoint ? [serverConfig.pw_cdp_endpoint] : undefined); +``` + +### 6. Events & Logging + +**`src/events.ts`** + +- Add `pwCdpEndpoints?: string[]` to `TaskSetupEventData` +- The existing `pwCdpEndpoint` field in the event should use `browser.pwCdpEndpoint` + (which now returns the active endpoint via the updated getter) +- Add a new event type `CDP_ENDPOINT_CYCLE` with data type `CdpEndpointCycleEventData`: + +```ts +interface CdpEndpointCycleEventData { + attempt: number; // 1-based index of the endpoint attempt that failed + error: string; // error message (not the URL — no sensitive data) +} +``` + +The URL and total count are intentionally omitted from the event data for now; they can +be added later once a redaction/exposure strategy is decided. + +**`src/browser/playwrightBrowser.ts`** + +- Add `onCdpEndpointCycle?: (attempt: number, error: Error) => void` to + `PlaywrightBrowserOptions` — called within the `start()` loop each time an endpoint + fails and cycling begins. This follows the same callback pattern as `navigationRetry.onRetry`. +- Within `start()`, also log failover directly (not via event system): + `CDP endpoint 1 failed (timeout), trying next...` +- Log fatal exhaustion: `All CDP endpoints exhausted, giving up` + +**`src/webAgent.ts`** + +- The existing `(this.browser as any).pwCdpEndpoint` call in the `TASK_SETUP` emit + continues to work correctly — the updated getter returns the active endpoint +- Add `pwCdpEndpoints: (this.browser as any).pwCdpEndpoints` to the emit to populate + the new event field (uses the new public getter) +- Wire up `onCdpEndpointCycle` callback to emit the new `CDP_ENDPOINT_CYCLE` event + +**`src/loggers/secretsRedactor.ts`** + +- Redact `pwCdpEndpoints` to a single-element array `["(redacted)"]` — this hides both + the endpoint values and the count (leaking the count could reveal infrastructure details) +- No redaction needed on `CdpEndpointCycleEventData` by default (contains only attempt + number and error message, no URLs) + +**`src/loggers/chalkConsole.ts`** + +- Log active endpoint simply as `CDP endpoint: (redacted)` on `TASK_SETUP` +- Log `CDP_ENDPOINT_CYCLE` event, e.g. `⚠️ CDP endpoint attempt N failed, trying next...` + +### 7. Implementation Note: Config File Array Loading + +Before implementing step 1, verify how the config manager loads config files. If the +file format is JSON/YAML, `pw_cdp_endpoints` would be a native array and should load +without changes. If it's a flat key-value format, comma-splitting may be needed there +too — same logic as the env var path. + +### 8. Tests + +New tests in `test/browser/playwrightBrowser.test.ts`: + +- Single endpoint string (backward compat via `pwCdpEndpoint`) +- First endpoint connection refused → second succeeds +- First endpoint times out → second succeeds +- All endpoints fail → hard (non-`RecoverableError`) error thrown +- Mid-task restart: `start()` succeeds on A → `nextStartIndex` advances → next `start()` tries B +- Auth failure on first endpoint → hard error immediately, no cycling to B +- `onCdpEndpointCycle` callback is called with correct attempt number and error when cycling occurs +- `onCdpEndpointCycle` is NOT called on hard errors (auth failure, all exhausted) + +## What's Not Changing + +- `--pw-cdp-endpoint` (singular) CLI flag — behavior identical +- `navigateToStartWithRetry` in `WebAgent` — endpoint cycling is transparent at the browser layer +- Navigation-level retry logic in `executeNavigationWithRetry` — separate concern, untouched +- The `pwEndpoint` (non-CDP Playwright endpoint) path — not touched + +## Implementation Order + +1. `src/configDefaults.ts` — add `"string[]"` type, new field +2. `src/config.ts` — `coerceValue()`, `parseEnvConfig()`, `addConfigOptions()` updates +3. `src/browser/playwrightBrowser.ts` — core cycling logic, updated getter, timeout constant +4. `src/cli/commands/run.ts` + `server/src/routes/spark.ts` — wire through new options +5. `src/events.ts` + loggers — endpoint tracking and redaction +6. Tests From 4ad9a9af5c7d9bf613d73fc36e511aaf7718bf27 Mon Sep 17 00:00:00 2001 From: Les Orchard Date: Wed, 18 Feb 2026 15:43:58 -0800 Subject: [PATCH 3/4] feat: add mid-task CDP endpoint failover (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detects mid-task browser disconnects (TargetClosedError) and automatically reconnects to the next configured CDP endpoint, then restarts task execution from the original starting URL with a fresh message history while preserving the existing plan, success criteria, and action items. Changes: - Add BrowserDisconnectedError (extends RecoverableError) for signalling mid-task disconnects - Detect TargetClosedError in PlaywrightBrowser.performAction, getTreeWithRefs, and getScreenshot; rethrow as BrowserDisconnectedError - Add WebAgent.handleBrowserDisconnect(): shuts down browser, starts on next endpoint, navigates to starting URL, resets message history - Intercept BrowserDisconnectedError in runMainLoop before error counters so disconnects don't consume the error budget - Add CDP_ENDPOINT_CONNECTED and BROWSER_RECONNECTED events; wire onCdpEndpointConnected callback through WebAgent to event system - Remove direct console.log/warn calls from PlaywrightBrowser; all output now flows through callbacks → events → loggers - Add endpoint index (never URL) to CDP events for metrics tracking - Fix double task metrics summary: MetricsCollector now triggers only on TASK_COMPLETED (which always fires via buildResult), not TASK_ABORTED - Use error.name instead of error.message in CDP_ENDPOINT_CYCLE events to avoid leaking credentials or vendor URLs from Playwright error strings - Reduce CDP connection timeout from 10s to 5s - Add cdp: prefix to event shortname stripping in chalkConsole metrics view Co-Authored-By: Claude Sonnet 4.6 --- .../plan-phase2.md | 40 +++---- src/browser/playwrightBrowser.ts | 53 ++++++++-- src/errors.ts | 12 +++ src/events.ts | 34 +++++- src/loggers/chalkConsole.ts | 27 ++++- src/loggers/metricsCollector.ts | 7 -- src/webAgent.ts | 77 +++++++++++++- test/events.test.ts | 2 + test/metricsCollector.test.ts | 13 +-- test/playwrightBrowser.test.ts | 100 +++++++++++++++++- 10 files changed, 317 insertions(+), 48 deletions(-) diff --git a/docs/dev-sessions/2026-02-18-1202-cdp-endpoint-failover/plan-phase2.md b/docs/dev-sessions/2026-02-18-1202-cdp-endpoint-failover/plan-phase2.md index e33904a3..b4233416 100644 --- a/docs/dev-sessions/2026-02-18-1202-cdp-endpoint-failover/plan-phase2.md +++ b/docs/dev-sessions/2026-02-18-1202-cdp-endpoint-failover/plan-phase2.md @@ -46,18 +46,18 @@ a coherent browser state. ## Design Decisions -| # | Decision | -|---|---| -| 1 | `BrowserDisconnectedError extends RecoverableError` — fits the error hierarchy but gets special handling in `runMainLoop` | -| 2 | Detection lives in `PlaywrightBrowser` methods (not in `WebAgent`) — keeps Playwright internals encapsulated | -| 3 | Reconnect logic lives in a new `WebAgent.handleBrowserDisconnect()` private method | -| 4 | Disconnects do NOT increment `consecutiveErrors` or `totalErrors` | -| 5 | `consecutiveErrors` is reset to 0 on successful reconnect | -| 6 | After reconnect, navigate to `this.url` (original starting URL, not last page URL) and reset messages via `initializeSystemPromptAndTask()` | -| 7 | Planning phase state (`this.plan`, `this.successCriteria`, `this.url`, `this.actionItems`) is preserved — only message history is reset | -| 8 | Iteration counter is NOT reset — the agent continues against the same `maxIterations` budget | -| 9 | If `browser.start()` throws (endpoints exhausted), it propagates as a hard error → task fails | -| 10 | `navigateToStartWithRetry` already handles `RecoverableError` → no changes needed there | +| # | Decision | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | `BrowserDisconnectedError extends RecoverableError` — fits the error hierarchy but gets special handling in `runMainLoop` | +| 2 | Detection lives in `PlaywrightBrowser` methods (not in `WebAgent`) — keeps Playwright internals encapsulated | +| 3 | Reconnect logic lives in a new `WebAgent.handleBrowserDisconnect()` private method | +| 4 | Disconnects do NOT increment `consecutiveErrors` or `totalErrors` | +| 5 | `consecutiveErrors` is reset to 0 on successful reconnect | +| 6 | After reconnect, navigate to `this.url` (original starting URL, not last page URL) and reset messages via `initializeSystemPromptAndTask()` | +| 7 | Planning phase state (`this.plan`, `this.successCriteria`, `this.url`, `this.actionItems`) is preserved — only message history is reset | +| 8 | Iteration counter is NOT reset — the agent continues against the same `maxIterations` budget | +| 9 | If `browser.start()` throws (endpoints exhausted), it propagates as a hard error → task fails | +| 10 | `navigateToStartWithRetry` already handles `RecoverableError` → no changes needed there | ## Disconnect Detection in `PlaywrightBrowser` @@ -184,12 +184,12 @@ and is converted to a task failure in `execute()`. ## Error Counting Behavior -| Scenario | `consecutiveErrors` | `totalErrors` | -|---|---|---| -| Normal agent error (bad action, timeout, etc.) | +1 | +1 | -| Browser disconnect, reconnect succeeds | reset to 0 | unchanged | -| Browser disconnect, reconnect fails (endpoints exhausted) | task fails immediately | N/A | -| Error after reconnect | +1 from 0 | +1 | +| Scenario | `consecutiveErrors` | `totalErrors` | +| --------------------------------------------------------- | ---------------------- | ------------- | +| Normal agent error (bad action, timeout, etc.) | +1 | +1 | +| Browser disconnect, reconnect succeeds | reset to 0 | unchanged | +| Browser disconnect, reconnect fails (endpoints exhausted) | task fails immediately | N/A | +| Error after reconnect | +1 from 0 | +1 | Disconnects are not agent errors — they're infrastructure failures. The agent gets a clean error slate on the new browser. @@ -227,10 +227,10 @@ export class BrowserDisconnectedError extends RecoverableError { Add new event type and data interface: ```ts -BROWSER_RECONNECTED = "browser:reconnected" +BROWSER_RECONNECTED = "browser:reconnected"; export interface BrowserReconnectedEventData extends WebAgentEventData { - startingUrl: string; // The original starting URL the agent is restarting from + startingUrl: string; // The original starting URL the agent is restarting from } ``` diff --git a/src/browser/playwrightBrowser.ts b/src/browser/playwrightBrowser.ts index 6055a8b1..bbe559c6 100644 --- a/src/browser/playwrightBrowser.ts +++ b/src/browser/playwrightBrowser.ts @@ -18,6 +18,7 @@ import TurndownService from "turndown"; import { InvalidRefException, BrowserActionException, + BrowserDisconnectedError, NavigationTimeoutException, NavigationNetworkException, } from "../errors.js"; @@ -53,6 +54,8 @@ export interface PlaywrightBrowserOptions { pwCdpEndpoints?: string[]; /** Called when a CDP endpoint fails and the next one is being tried */ onCdpEndpointCycle?: (attempt: number, error: Error) => void; + /** Called when a CDP endpoint is successfully connected to */ + onCdpEndpointConnected?: (endpointIndex: number, total: number) => void; /** Playwright endpoint URL to connect to remote browser */ pwEndpoint?: string; /** Navigation retry configuration */ @@ -74,7 +77,7 @@ export interface ExtendedPlaywrightBrowserOptions extends PlaywrightBrowserOptio * PlaywrightBrowser - Browser implementation using Playwright's accessibility features */ /** Timeout per CDP endpoint connection attempt in milliseconds */ -const CDP_CONNECTION_TIMEOUT_MS = 10_000; +const CDP_CONNECTION_TIMEOUT_MS = 5_000; export class PlaywrightBrowser implements AriaBrowser { public browserName: string; @@ -96,6 +99,8 @@ export class PlaywrightBrowser implements AriaBrowser { /** Called when a CDP endpoint fails and the next one is being tried. Can be set after construction. */ public onCdpEndpointCycle: ((attempt: number, error: Error) => void) | undefined; + /** Called when a CDP endpoint is successfully connected to. Can be set after construction. */ + public onCdpEndpointConnected: ((endpointIndex: number, total: number) => void) | undefined; constructor(private options: ExtendedPlaywrightBrowserOptions = {}) { this.browserName = `playwright:${this.options.browser ?? "firefox"}`; @@ -115,8 +120,9 @@ export class PlaywrightBrowser implements AriaBrowser { ? [options.pwCdpEndpoint] : []; - // Initialize callback from options (can also be set directly after construction) + // Initialize callbacks from options (can also be set directly after construction) this.onCdpEndpointCycle = options.onCdpEndpointCycle; + this.onCdpEndpointConnected = options.onCdpEndpointConnected; } get pwEndpoint(): string | undefined { @@ -418,6 +424,7 @@ export class PlaywrightBrowser implements AriaBrowser { timeout: CDP_CONNECTION_TIMEOUT_MS, }); this.nextStartIndex = i + 1; + this.onCdpEndpointConnected?.(i + 1, this.cdpEndpoints.length); return browser; } catch (err) { if (!(err instanceof Error) || !this.isCdpConnectionError(err)) { @@ -426,9 +433,6 @@ export class PlaywrightBrowser implements AriaBrowser { const attemptNumber = i + 1; const remaining = this.cdpEndpoints.length - i - 1; if (remaining > 0) { - console.warn( - `[PlaywrightBrowser] CDP endpoint ${attemptNumber} failed (${err.message}), trying next...`, - ); this.onCdpEndpointCycle?.(attemptNumber, err); } } @@ -452,6 +456,18 @@ export class PlaywrightBrowser implements AriaBrowser { ); } + /** + * Returns true for errors that indicate the CDP browser session was closed + * while the agent was mid-task. Triggers browser restart rather than ordinary + * error handling. + */ + private isBrowserDisconnectedError(error: Error): boolean { + return ( + error.message.includes("Target page, context or browser has been closed") || + error.constructor.name === "TargetClosedError" + ); + } + /** * Check if an error is a retryable network error. * Covers Chromium (net::ERR_*) and Firefox (NS_ERROR_*, NS_BINDING_*) patterns. @@ -536,8 +552,19 @@ export class PlaywrightBrowser implements AriaBrowser { async getTreeWithRefs(): Promise { if (!this.page) throw new Error("Browser not started"); + try { + return await this.getTreeWithRefsImpl(); + } catch (error) { + if (error instanceof Error && this.isBrowserDisconnectedError(error)) { + throw new BrowserDisconnectedError(error.message); + } + throw error; + } + } + + private async getTreeWithRefsImpl(): Promise { // Inject the ariaTree bundle and generate snapshot in the main frame - const mainResult = await this.page.evaluate( + const mainResult = await this.page!.evaluate( ({ script }) => { // Idempotent injection guard const win = window as any; @@ -554,13 +581,13 @@ export class PlaywrightBrowser implements AriaBrowser { ); // Handle cross-origin iframes via Playwright's frame API - const frames = this.page.frames(); + const frames = this.page!.frames(); const childYamls: string[] = []; let counter = mainResult.counterValue; for (const frame of frames) { // Skip the main frame - if (frame === this.page.mainFrame()) continue; + if (frame === this.page!.mainFrame()) continue; try { const frameResult = await frame.evaluate( @@ -663,6 +690,11 @@ export class PlaywrightBrowser implements AriaBrowser { quality: 80, scale: "css", }); + } catch (error) { + if (error instanceof Error && this.isBrowserDisconnectedError(error)) { + throw new BrowserDisconnectedError(error.message); + } + throw error; } finally { if (options?.withMarks) { try { @@ -830,6 +862,11 @@ export class PlaywrightBrowser implements AriaBrowser { throw error; } + // Surface browser disconnects distinctly so WebAgent can trigger a restart + if (error instanceof Error && this.isBrowserDisconnectedError(error)) { + throw new BrowserDisconnectedError(error.message); + } + // Wrap other errors throw new BrowserActionException( String(action), diff --git a/src/errors.ts b/src/errors.ts index 5e4fa348..a9971653 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -132,6 +132,18 @@ export class NavigationTimeoutException extends BrowserException { } } +/** + * Thrown when the browser connection is lost mid-task (CDP session closed). + * WebAgent catches this to trigger a browser restart and execution reset rather + * than treating it as an ordinary agent error. + */ +export class BrowserDisconnectedError extends RecoverableError { + constructor(message: string) { + super(`Browser connection lost: ${message}`); + this.name = "BrowserDisconnectedError"; + } +} + /** * Thrown when navigation fails due to network errors after all retry attempts. * Covers errors like net::ERR_CONNECTION_REFUSED, net::ERR_NAME_NOT_RESOLVED, etc. diff --git a/src/events.ts b/src/events.ts index 5af679ac..d725db98 100644 --- a/src/events.ts +++ b/src/events.ts @@ -41,7 +41,11 @@ export enum WebAgentEventType { SYSTEM_DEBUG_MESSAGE = "system:debug_message", // CDP endpoint failover + CDP_ENDPOINT_CONNECTED = "cdp:endpoint_connected", CDP_ENDPOINT_CYCLE = "cdp:endpoint_cycle", + + // Browser reconnect after mid-task disconnect + BROWSER_RECONNECTED = "browser:reconnected", } /** @@ -64,6 +68,8 @@ export interface TaskSetupEventData extends WebAgentEventData { pwEndpoint?: string; pwCdpEndpoint?: string; pwCdpEndpoints?: string[]; + /** Total number of CDP endpoints configured (index, not URLs) */ + pwCdpEndpointCount?: number; proxy?: string; vision?: boolean; provider?: string; @@ -72,16 +78,40 @@ export interface TaskSetupEventData extends WebAgentEventData { keySource?: "global" | "env" | "not_set"; } +/** + * Event data when a CDP endpoint is successfully connected to + */ +export interface CdpEndpointConnectedEventData extends WebAgentEventData { + /** 1-based index of the endpoint that connected */ + endpointIndex: number; + /** Total number of configured CDP endpoints */ + total: number; +} + /** * Event data when a CDP endpoint fails and the next one is being tried */ export interface CdpEndpointCycleEventData extends WebAgentEventData { /** 1-based index of the endpoint attempt that failed */ attempt: number; + /** Total number of configured CDP endpoints */ + total: number; /** Error message from the failed connection attempt */ error: string; } +/** + * Event data when the browser reconnects after a mid-task disconnect + */ +export interface BrowserReconnectedEventData extends WebAgentEventData { + /** The original starting URL the agent is restarting execution from */ + startingUrl: string; + /** 1-based index of the CDP endpoint now in use */ + endpointIndex: number; + /** Total number of configured CDP endpoints */ + total: number; +} + /** * Event data when a task is started */ @@ -299,7 +329,9 @@ export type WebAgentEvent = } | { type: WebAgentEventType.SYSTEM_DEBUG_COMPRESSION; data: CompressionDebugEventData } | { type: WebAgentEventType.SYSTEM_DEBUG_MESSAGE; data: MessagesDebugEventData } - | { type: WebAgentEventType.CDP_ENDPOINT_CYCLE; data: CdpEndpointCycleEventData }; + | { type: WebAgentEventType.CDP_ENDPOINT_CONNECTED; data: CdpEndpointConnectedEventData } + | { type: WebAgentEventType.CDP_ENDPOINT_CYCLE; data: CdpEndpointCycleEventData } + | { type: WebAgentEventType.BROWSER_RECONNECTED; data: BrowserReconnectedEventData }; /** * Event emitter for WebAgent events diff --git a/src/loggers/chalkConsole.ts b/src/loggers/chalkConsole.ts index 6c9dfbd7..4e26aa25 100644 --- a/src/loggers/chalkConsole.ts +++ b/src/loggers/chalkConsole.ts @@ -4,6 +4,8 @@ import type { ActionExecutionEventData, ActionResultEventData, AgentStepEventData, + BrowserReconnectedEventData, + CdpEndpointConnectedEventData, CdpEndpointCycleEventData, CompressionDebugEventData, ExtractedDataEventData, @@ -84,7 +86,9 @@ export class ChalkConsoleLogger implements Logger { emitter.onEvent(WebAgentEventType.AI_GENERATION_ERROR, this.handleAIGenerationError); // CDP failover events + emitter.onEvent(WebAgentEventType.CDP_ENDPOINT_CONNECTED, this.handleCdpEndpointConnected); emitter.onEvent(WebAgentEventType.CDP_ENDPOINT_CYCLE, this.handleCdpEndpointCycle); + emitter.onEvent(WebAgentEventType.BROWSER_RECONNECTED, this.handleBrowserReconnected); } dispose(): void { @@ -133,7 +137,12 @@ export class ChalkConsoleLogger implements Logger { this.emitter.offEvent(WebAgentEventType.AI_GENERATION_ERROR, this.handleAIGenerationError); // CDP failover events + this.emitter.offEvent( + WebAgentEventType.CDP_ENDPOINT_CONNECTED, + this.handleCdpEndpointConnected, + ); this.emitter.offEvent(WebAgentEventType.CDP_ENDPOINT_CYCLE, this.handleCdpEndpointCycle); + this.emitter.offEvent(WebAgentEventType.BROWSER_RECONNECTED, this.handleBrowserReconnected); // Reset emitter reference this.emitter = null; @@ -303,13 +312,27 @@ export class ChalkConsoleLogger implements Logger { console.error(chalk.red.bold("❌ AI generation error:"), chalk.whiteBright(data.error)); }; + private handleCdpEndpointConnected = (data: CdpEndpointConnectedEventData): void => { + console.log(chalk.green(`🌐 Connected to CDP endpoint ${data.endpointIndex} of ${data.total}`)); + }; + private handleCdpEndpointCycle = (data: CdpEndpointCycleEventData): void => { console.warn( - chalk.yellow(`⚠️ CDP endpoint attempt ${data.attempt} failed, trying next...`), + chalk.yellow(`⚠️ CDP endpoint ${data.attempt} of ${data.total} failed, trying next...`), chalk.gray(`(${data.error})`), ); }; + private handleBrowserReconnected = (data: BrowserReconnectedEventData): void => { + const indexInfo = + data.total > 0 ? chalk.gray(` (endpoint ${data.endpointIndex} of ${data.total})`) : ""; + console.warn( + chalk.yellow(`⚠️ Browser disconnected — reconnected and restarting task execution`), + indexInfo, + data.startingUrl ? chalk.gray(`from ${data.startingUrl}`) : "", + ); + }; + private handleTaskMetrics = (data: TaskMetricsEventData): void => { console.log(chalk.cyan.bold("\n📊 Task Metrics Summary")); console.log(chalk.gray("━".repeat(60))); @@ -348,7 +371,7 @@ export class ChalkConsoleLogger implements Logger { console.log(chalk.yellow.bold("\n📈 Top Events:")); eventEntries.forEach(([eventType, count]) => { // Shorten event type for display - const shortType = eventType.replace(/^(task|agent|browser|system|ai):/, ""); + const shortType = eventType.replace(/^(task|agent|browser|system|ai|cdp):/, ""); console.log(chalk.gray(` ${shortType.padEnd(25)} ${chalk.whiteBright(count)}`)); }); } diff --git a/src/loggers/metricsCollector.ts b/src/loggers/metricsCollector.ts index 5df3436b..377943b9 100644 --- a/src/loggers/metricsCollector.ts +++ b/src/loggers/metricsCollector.ts @@ -5,7 +5,6 @@ import { WebAgentEventType, AIGenerationEventData, TaskCompleteEventData, - TaskAbortedEventData, } from "../events.js"; export class MetricsCollector extends LoggerWrapper { @@ -33,7 +32,6 @@ export class MetricsCollector extends LoggerWrapper { emitter.onEvent(WebAgentEventType.AI_GENERATION, this.handleAiGeneration); emitter.onEvent(WebAgentEventType.AI_GENERATION_ERROR, this.handleAiGenerationError); emitter.onEvent(WebAgentEventType.TASK_COMPLETED, this.handleTaskComplete); - emitter.onEvent(WebAgentEventType.TASK_ABORTED, this.handleTaskAborted); super.initialize(emitter); } @@ -46,7 +44,6 @@ export class MetricsCollector extends LoggerWrapper { this.emitter.offEvent(WebAgentEventType.AI_GENERATION, this.handleAiGeneration); this.emitter.offEvent(WebAgentEventType.AI_GENERATION_ERROR, this.handleAiGenerationError); this.emitter.offEvent(WebAgentEventType.TASK_COMPLETED, this.handleTaskComplete); - this.emitter.offEvent(WebAgentEventType.TASK_ABORTED, this.handleTaskAborted); } super.dispose(); } @@ -100,8 +97,4 @@ export class MetricsCollector extends LoggerWrapper { private handleTaskComplete = (data: TaskCompleteEventData): void => { this.emitTaskMetrics(data.iterationId); }; - - private handleTaskAborted = (data: TaskAbortedEventData): void => { - this.emitTaskMetrics(data.iterationId); - }; } diff --git a/src/webAgent.ts b/src/webAgent.ts index 369520b8..7c5e15cc 100644 --- a/src/webAgent.ts +++ b/src/webAgent.ts @@ -10,11 +10,17 @@ import { streamText, ModelMessage, StreamTextResult } from "ai"; import type { ProviderConfig } from "./provider.js"; import { AriaBrowser } from "./browser/ariaBrowser.js"; -import { CdpEndpointCycleEventData, WebAgentEventEmitter, WebAgentEventType } from "./events.js"; +import { + BrowserReconnectedEventData, + CdpEndpointConnectedEventData, + CdpEndpointCycleEventData, + WebAgentEventEmitter, + WebAgentEventType, +} from "./events.js"; import { SnapshotCompressor } from "./snapshotCompressor.js"; import { Logger } from "./loggers/types.js"; import { ConsoleLogger } from "./loggers/console.js"; -import { RecoverableError, ToolExecutionError } from "./errors.js"; +import { BrowserDisconnectedError, RecoverableError, ToolExecutionError } from "./errors.js"; import { generateTextWithRetry } from "./utils/retry.js"; import type { AwaitedProperties } from "./utils/types.js"; import { @@ -223,11 +229,22 @@ export class WebAgent { // Wire up CDP endpoint cycle callback so browser failover events flow through the event system const browserAny = this.browser as any; + if ("onCdpEndpointConnected" in browserAny) { + browserAny.onCdpEndpointConnected = (endpointIndex: number, total: number): void => { + const data: Omit = { + endpointIndex, + total, + }; + this.emit(WebAgentEventType.CDP_ENDPOINT_CONNECTED, data); + }; + } + if ("onCdpEndpointCycle" in browserAny) { browserAny.onCdpEndpointCycle = (attempt: number, error: Error): void => { const data: Omit = { attempt, - error: error.message, + total: browserAny.pwCdpEndpoints?.length ?? 0, + error: error.name, }; this.emit(WebAgentEventType.CDP_ENDPOINT_CYCLE, data); }; @@ -385,6 +402,17 @@ export class WebAgent { needsPageSnapshot = result.pageChanged; } catch (error) { + // Browser disconnects are handled specially: restart on next CDP endpoint, + // reset execution state, and continue — not counted as an agent error. + if (error instanceof BrowserDisconnectedError) { + // May throw if all endpoints exhausted — propagates as hard error + await this.handleBrowserDisconnect(task, error); + consecutiveErrors = 0; + needsPageSnapshot = true; + executionState.currentIteration++; + continue; + } + trackError(); // Check if we should continue @@ -1283,6 +1311,7 @@ export class WebAgent { pwEndpoint: (this.browser as any).pwEndpoint, pwCdpEndpoint: (this.browser as any).pwCdpEndpoint, pwCdpEndpoints: (this.browser as any).pwCdpEndpoints, + pwCdpEndpointCount: (this.browser as any).pwCdpEndpoints?.length ?? 0, proxy: (this.browser as any).proxyServer, vision: this.vision, }); @@ -1352,6 +1381,48 @@ export class WebAgent { }); } + /** + * Handle a mid-task browser disconnect by restarting on the next CDP endpoint + * (Phase 1's nextStartIndex advances automatically) and resetting execution state + * so the agent re-runs its plan from the beginning on the new browser. + * + * Planning state (plan, successCriteria, url) is preserved. + * Throws if browser.start() fails (all endpoints exhausted). + */ + private async handleBrowserDisconnect( + task: string, + error: BrowserDisconnectedError, + ): Promise { + console.warn(`[WebAgent] Browser disconnected mid-task: ${error.message}`); + console.warn(`[WebAgent] Restarting on next CDP endpoint...`); + + await this.browser.shutdown(); + + // Throws a hard (non-RecoverableError) if all endpoints are exhausted + await this.browser.start(); + + // Navigate to the original starting URL — not currentPage.url. + // The new browser has no prior session state; we need a coherent starting point. + if (this.url && this.url !== "about:blank") { + await this.browser.goto(this.url); + await this.updatePageState(); + } + + // Reset message history so the agent re-executes the plan on the new browser. + // Planning state (this.plan, this.successCriteria, this.url) is preserved. + this.initializeSystemPromptAndTask(task); + + const browserAny = this.browser as any; + const data: Omit = { + startingUrl: this.url, + // nextStartIndex is i+1 after a successful connectOverCDP on endpoint i, + // so it equals the 1-based index of the endpoint just used + endpointIndex: browserAny.nextStartIndex ?? 0, + total: browserAny.pwCdpEndpoints?.length ?? 0, + }; + this.emit(WebAgentEventType.BROWSER_RECONNECTED, data); + } + private initializeSystemPromptAndTask(task: string): void { const hasGuardrails = Boolean(this.guardrails); const hasWebSearch = this.searchProvider !== "none"; diff --git a/test/events.test.ts b/test/events.test.ts index e7d51f48..fcc1b5cd 100644 --- a/test/events.test.ts +++ b/test/events.test.ts @@ -135,7 +135,9 @@ describe("WebAgentEventEmitter", () => { "browser:screenshot_captured_image", "system:debug_compression", "system:debug_message", + "cdp:endpoint_connected", "cdp:endpoint_cycle", + "browser:reconnected", ]; const actualEventTypes = Object.values(WebAgentEventType); diff --git a/test/metricsCollector.test.ts b/test/metricsCollector.test.ts index 0f4a88b8..7be8962e 100644 --- a/test/metricsCollector.test.ts +++ b/test/metricsCollector.test.ts @@ -135,8 +135,8 @@ describe("MetricsCollector", () => { data: { timestamp: Date.now(), iterationId: "test-1", - } as any, // Explicitly cast as any to bypass type checking for this test - }); + }, + } as any); // Cast as any to bypass discriminated union type checking for this test }); const counts = metricsCollector.getEventCounts(); @@ -471,7 +471,10 @@ describe("MetricsCollector", () => { expect(metricsData.totalOutputTokens).toBe(0); }); - it("should emit metrics on TASK_ABORTED", () => { + it("should NOT emit metrics on TASK_ABORTED (metrics fire via TASK_COMPLETED which always follows)", () => { + // TASK_ABORTED does not trigger metrics to avoid double-reporting. + // buildResult() always emits TASK_COMPLETED after any outcome (abort, complete, max iterations), + // so metrics are emitted exactly once via TASK_COMPLETED. const metricsListener = vi.fn(); emitter.onEvent(WebAgentEventType.TASK_METRICS, metricsListener); @@ -487,9 +490,7 @@ describe("MetricsCollector", () => { data: abortData, }); - expect(metricsListener).toHaveBeenCalledTimes(1); - const metricsData = metricsListener.mock.calls[0][0] as TaskMetricsEventData; - expect(metricsData.iterationId).toBe("test-1"); + expect(metricsListener).not.toHaveBeenCalled(); }); }); diff --git a/test/playwrightBrowser.test.ts b/test/playwrightBrowser.test.ts index fb2d54d5..f46a60e6 100644 --- a/test/playwrightBrowser.test.ts +++ b/test/playwrightBrowser.test.ts @@ -1,7 +1,11 @@ import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; import { PlaywrightBrowser } from "../src/browser/playwrightBrowser.js"; import { PageAction, LoadState } from "../src/browser/ariaBrowser.js"; -import { InvalidRefException, BrowserActionException } from "../src/errors.js"; +import { + InvalidRefException, + BrowserActionException, + BrowserDisconnectedError, +} from "../src/errors.js"; describe("PlaywrightBrowser", () => { describe("constructor and options", () => { @@ -982,4 +986,98 @@ describe("PlaywrightBrowser", () => { expect((browser as any).cdpEndpoints).toEqual([]); }); }); + + describe("browser disconnect detection", () => { + let browser: PlaywrightBrowser; + + beforeEach(() => { + browser = new PlaywrightBrowser({ browser: "chromium" }); + // Inject a mock page so the browser appears started + (browser as any).page = { + locator: vi.fn(), + screenshot: vi.fn(), + evaluate: vi.fn(), + frames: vi.fn().mockReturnValue([]), + mainFrame: vi.fn(), + waitForTimeout: vi.fn(), + url: vi.fn().mockReturnValue("https://example.com"), + }; + }); + + it("throws BrowserDisconnectedError from performAction on target-closed message", async () => { + const targetClosedError = new Error("Target page, context or browser has been closed"); + const mockLocator = { + count: vi.fn().mockResolvedValue(1), + scrollIntoViewIfNeeded: vi.fn().mockResolvedValue(undefined), + click: vi.fn().mockRejectedValue(targetClosedError), + }; + (browser as any).page.locator.mockReturnValue(mockLocator); + + await expect(browser.performAction("s1e1", PageAction.Click)).rejects.toThrow( + BrowserDisconnectedError, + ); + }); + + it("throws BrowserDisconnectedError from performAction on TargetClosedError constructor name", async () => { + const err = new Error("some internal playwright message"); + err.constructor = { name: "TargetClosedError" } as any; + Object.defineProperty(err, "constructor", { value: { name: "TargetClosedError" } }); + const mockLocator = { + count: vi.fn().mockResolvedValue(1), + scrollIntoViewIfNeeded: vi.fn().mockResolvedValue(undefined), + click: vi.fn().mockRejectedValue(err), + }; + (browser as any).page.locator.mockReturnValue(mockLocator); + + await expect(browser.performAction("s1e1", PageAction.Click)).rejects.toThrow( + BrowserDisconnectedError, + ); + }); + + it("wraps non-disconnect errors from performAction as BrowserActionException", async () => { + const genericError = new Error("Element not interactable"); + const mockLocator = { + count: vi.fn().mockResolvedValue(1), + scrollIntoViewIfNeeded: vi.fn().mockResolvedValue(undefined), + click: vi.fn().mockRejectedValue(genericError), + }; + (browser as any).page.locator.mockReturnValue(mockLocator); + + await expect(browser.performAction("s1e1", PageAction.Click)).rejects.toThrow( + BrowserActionException, + ); + }); + + it("throws BrowserDisconnectedError from getTreeWithRefs on target-closed message", async () => { + (browser as any).page.evaluate = vi + .fn() + .mockRejectedValue(new Error("Target page, context or browser has been closed")); + + await expect(browser.getTreeWithRefs()).rejects.toThrow(BrowserDisconnectedError); + }); + + it("rethrows non-disconnect errors from getTreeWithRefs unchanged", async () => { + const evalError = new Error("Script evaluation failed"); + (browser as any).page.evaluate = vi.fn().mockRejectedValue(evalError); + + await expect(browser.getTreeWithRefs()).rejects.toThrow("Script evaluation failed"); + await expect(browser.getTreeWithRefs()).rejects.not.toThrow(BrowserDisconnectedError); + }); + + it("throws BrowserDisconnectedError from getScreenshot on target-closed message", async () => { + (browser as any).page.screenshot = vi + .fn() + .mockRejectedValue(new Error("Target page, context or browser has been closed")); + + await expect(browser.getScreenshot()).rejects.toThrow(BrowserDisconnectedError); + }); + + it("rethrows non-disconnect errors from getScreenshot unchanged", async () => { + const screenshotError = new Error("Screenshot capture failed"); + (browser as any).page.screenshot = vi.fn().mockRejectedValue(screenshotError); + + await expect(browser.getScreenshot()).rejects.toThrow("Screenshot capture failed"); + await expect(browser.getScreenshot()).rejects.not.toThrow(BrowserDisconnectedError); + }); + }); }); From 12b709b95a62fbd9f0171f502db987772145dad6 Mon Sep 17 00:00:00 2001 From: Les Orchard Date: Wed, 18 Feb 2026 16:28:49 -0800 Subject: [PATCH 4/4] Prompt tweaks in an attempt to pass more eval tasks --- src/prompts.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/prompts.ts b/src/prompts.ts index eaea724b..6c5f2434 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -122,7 +122,7 @@ You adapt to situations and find creative ways to complete tasks without getting IMPORTANT: - You can see the entire page content through the accessibility tree snapshot. -- You do not need to scroll or click links to navigate within a page - all content is visible to you. +- The accessibility tree shows all currently loaded page elements. On dynamic pages, some content may only appear after scrolling or interaction — if expected data isn't visible, try scrolling or interacting to trigger loading. - Focus on the elements you need to interact with directly. `.trim(); @@ -285,15 +285,19 @@ Analyze the current page state and determine your next action based on previous - extract() if you need more information **Best Practices:** -- Full page content is visible - no scrolling needed +- The accessibility tree shows currently loaded elements; dynamic pages may load more content on scroll - Clear obstructing modals/popups first - Prefer click() over goto() for page navigation - Submit forms via enter() or submit button after filling - Find alternative elements if primary ones aren't available +- When click() fails due to element interception, try focus() first, then keyboard navigation (Tab, Enter, arrow keys), or press Escape to dismiss overlapping overlays +- For autocomplete/combobox search fields (e.g., flight origin/destination, location pickers): after fill(), use focus() on a visible suggestion in the dropdown followed by enter() to select it — click() on autocomplete suggestions often times out +- When you receive an 'Invalid element reference' error, the page DOM has changed — read the updated page snapshot on your next turn and use the new element refs; do not retry old ref IDs - Adapt your approach based on what's actually available - If you don't find relevant links or buttons, and the site has a search form, prioritize using it for navigation -- Use abort() only after trying reasonable alternatives (site down, access blocked, required data unavailable) +- If you have found the core information requested but cannot access supplementary details due to site limitations, use done() with what you have — only use abort() when the core task cannot be completed at all - For research: Use extract() immediately when finding relevant data +- For academic papers or documents, if the PDF is inaccessible, use webSearch to find an HTML version (e.g., ACL Anthology, Semantic Scholar) or check paper metadata pages {% if hasGuardrails %}- Verify guardrail compliance before each action{% endif %} **When using done():** @@ -512,6 +516,7 @@ const taskValidationFeedbackTemplate = buildPromptTemplate( **Feedback:** {{ feedback }} Do not repeat your previous answer. Address the issues identified above. +If you cannot address the feedback due to genuine site limitations (disabled UI, inaccessible content), call done() with the best answer available rather than aborting. `.trim(), );