browser-debugger-cli 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/.claude/skills/bdg/SKILL.md +101 -187
  2. package/README.md +4 -4
  3. package/dist/commands/cdp.js +1 -0
  4. package/dist/commands/cleanup.js +3 -0
  5. package/dist/commands/console.js +5 -1
  6. package/dist/commands/dom/a11y.d.ts +1 -1
  7. package/dist/commands/dom/a11y.js +20 -20
  8. package/dist/commands/dom/eval.d.ts +3 -1
  9. package/dist/commands/dom/eval.js +8 -5
  10. package/dist/commands/dom/formInteraction.js +1 -1
  11. package/dist/commands/dom/get.js +25 -7
  12. package/dist/commands/dom/helpers/evalResult.d.ts +36 -0
  13. package/dist/commands/dom/helpers/evalResult.js +59 -0
  14. package/dist/commands/dom/index.js +7 -2
  15. package/dist/commands/dom/query.d.ts +2 -1
  16. package/dist/commands/dom/query.js +5 -3
  17. package/dist/commands/dom/screenshot.js +1 -0
  18. package/dist/commands/helpJson.d.ts +1 -1
  19. package/dist/commands/helpJson.js +4 -4
  20. package/dist/commands/helpTopic.js +10 -4
  21. package/dist/commands/network/har.js +18 -14
  22. package/dist/commands/network/list.js +46 -3
  23. package/dist/commands/optionBehaviors.d.ts +25 -2
  24. package/dist/commands/optionBehaviors.js +60 -42
  25. package/dist/commands/peek.js +3 -0
  26. package/dist/commands/shared/CommandRunner.js +13 -13
  27. package/dist/commands/shared/daemonErrorHandler.js +2 -2
  28. package/dist/commands/shared/dataFetcher.d.ts +4 -2
  29. package/dist/commands/shared/dataFetcher.js +11 -3
  30. package/dist/commands/shared/handleValidationError.js +3 -3
  31. package/dist/commands/shared/optionTypes.d.ts +15 -3
  32. package/dist/commands/shared/outputFile.d.ts +2 -1
  33. package/dist/commands/shared/outputFile.js +7 -4
  34. package/dist/commands/shared/startHelpers.js +3 -3
  35. package/dist/commands/status.js +3 -1
  36. package/dist/commands/stop.js +2 -1
  37. package/dist/connection/chromeIdentity.d.ts +8 -2
  38. package/dist/connection/chromeIdentity.js +85 -13
  39. package/dist/connection/launcher/flagsBuilder.d.ts +46 -0
  40. package/dist/connection/launcher/flagsBuilder.js +107 -23
  41. package/dist/connection/launcher.d.ts +1 -1
  42. package/dist/connection/launcher.js +1 -2
  43. package/dist/constants.d.ts +31 -5
  44. package/dist/constants.js +37 -5
  45. package/dist/daemon/SessionController.js +2 -0
  46. package/dist/daemon/launcher.d.ts +17 -3
  47. package/dist/daemon/launcher.js +37 -7
  48. package/dist/daemon/session/Session.d.ts +2 -1
  49. package/dist/daemon/session/Session.js +10 -2
  50. package/dist/daemon/session/TelemetryStore.d.ts +7 -0
  51. package/dist/daemon/session/TelemetryStore.js +6 -0
  52. package/dist/daemon/session/commandRegistry.js +25 -7
  53. package/dist/daemon/session/matchedStylesReset.d.ts +26 -0
  54. package/dist/daemon/session/matchedStylesReset.js +46 -0
  55. package/dist/daemon/session/plugins.js +1 -0
  56. package/dist/daemon/session/triggeredRequests.d.ts +0 -5
  57. package/dist/daemon/session/triggeredRequests.js +13 -7
  58. package/dist/daemon.js +8742 -8315
  59. package/dist/errors/messages.d.ts +31 -0
  60. package/dist/errors/messages.js +96 -6
  61. package/dist/index.js +1129 -548
  62. package/dist/ipc/client.d.ts +6 -1
  63. package/dist/ipc/client.js +11 -2
  64. package/dist/ipc/protocol/commands.d.ts +8 -0
  65. package/dist/ipc/protocol/inspectTypes.d.ts +5 -2
  66. package/dist/ipc/session/types.d.ts +5 -1
  67. package/dist/ipc/transport/index.d.ts +6 -0
  68. package/dist/ipc/transport/index.js +16 -1
  69. package/dist/program.d.ts +14 -0
  70. package/dist/program.js +53 -0
  71. package/dist/runtime/dom/elementGeometry.d.ts +23 -0
  72. package/dist/runtime/dom/elementGeometry.js +17 -15
  73. package/dist/runtime/dom/elementInfo.d.ts +13 -4
  74. package/dist/runtime/dom/elementInfo.js +15 -5
  75. package/dist/runtime/dom/evalHelpers.d.ts +24 -4
  76. package/dist/runtime/dom/evalHelpers.js +40 -12
  77. package/dist/runtime/dom/frameScopedConnection.d.ts +7 -0
  78. package/dist/runtime/dom/frameScopedConnection.js +2 -2
  79. package/dist/runtime/dom/frames.d.ts +2 -1
  80. package/dist/runtime/dom/frames.js +3 -1
  81. package/dist/runtime/dom/inspect.d.ts +17 -3
  82. package/dist/runtime/dom/inspect.js +40 -26
  83. package/dist/runtime/dom/inspectModel.d.ts +3 -3
  84. package/dist/runtime/dom/inspectRules.d.ts +29 -3
  85. package/dist/runtime/dom/inspectRules.js +205 -11
  86. package/dist/runtime/dom/layout.d.ts +0 -2
  87. package/dist/runtime/dom/layout.js +1 -2
  88. package/dist/runtime/dom/reactEventHelpers.d.ts +4 -1
  89. package/dist/runtime/dom/reactEventHelpers.js +9 -2
  90. package/dist/runtime/dom/targetNode.d.ts +10 -6
  91. package/dist/runtime/dom/targetNode.js +15 -8
  92. package/dist/runtime/page/emulation.js +6 -5
  93. package/dist/runtime/page/userAgent.d.ts +86 -2
  94. package/dist/runtime/page/userAgent.js +154 -33
  95. package/dist/session/paths.d.ts +38 -3
  96. package/dist/session/paths.js +154 -7
  97. package/dist/session/portClaims.d.ts +0 -8
  98. package/dist/session/portClaims.js +1 -22
  99. package/dist/session/sessionList.d.ts +5 -1
  100. package/dist/session/sessionList.js +5 -1
  101. package/dist/telemetry/a11y.d.ts +15 -1
  102. package/dist/telemetry/a11y.js +83 -0
  103. package/dist/telemetry/har/builder.d.ts +12 -1
  104. package/dist/telemetry/har/builder.js +11 -3
  105. package/dist/telemetry/har/sanitize.d.ts +24 -0
  106. package/dist/telemetry/har/sanitize.js +138 -0
  107. package/dist/telemetry/har/sanitizeBody.d.ts +38 -0
  108. package/dist/telemetry/har/sanitizeBody.js +168 -0
  109. package/dist/telemetry/network.d.ts +13 -16
  110. package/dist/telemetry/network.js +30 -52
  111. package/dist/telemetry/networkRetention.d.ts +83 -0
  112. package/dist/telemetry/networkRetention.js +117 -0
  113. package/dist/types.d.ts +26 -0
  114. package/dist/ui/OutputBuilder.d.ts +10 -0
  115. package/dist/ui/OutputBuilder.js +12 -0
  116. package/dist/ui/formatters/a11y.d.ts +5 -7
  117. package/dist/ui/formatters/a11y.js +7 -61
  118. package/dist/ui/formatters/console/chronological.js +4 -4
  119. package/dist/ui/formatters/console/follow.d.ts +4 -2
  120. package/dist/ui/formatters/console/follow.js +6 -3
  121. package/dist/ui/formatters/console/json.d.ts +3 -6
  122. package/dist/ui/formatters/console/json.js +9 -13
  123. package/dist/ui/formatters/console/shared.d.ts +17 -2
  124. package/dist/ui/formatters/console/shared.js +17 -0
  125. package/dist/ui/formatters/console/summarize.d.ts +2 -2
  126. package/dist/ui/formatters/console/summarize.js +22 -7
  127. package/dist/ui/formatters/console.d.ts +1 -1
  128. package/dist/ui/formatters/console.js +1 -5
  129. package/dist/ui/formatters/details.js +1 -1
  130. package/dist/ui/formatters/dom.d.ts +13 -4
  131. package/dist/ui/formatters/dom.js +25 -7
  132. package/dist/ui/formatters/layout.js +2 -1
  133. package/dist/ui/formatters/longValues.d.ts +14 -0
  134. package/dist/ui/formatters/longValues.js +23 -0
  135. package/dist/ui/formatters/networkList.d.ts +8 -2
  136. package/dist/ui/formatters/networkList.js +11 -2
  137. package/dist/ui/formatters/preview.d.ts +4 -1
  138. package/dist/ui/formatters/preview.js +55 -13
  139. package/dist/ui/formatters/sessions.d.ts +3 -2
  140. package/dist/ui/formatters/sessions.js +10 -3
  141. package/dist/ui/formatters/status.js +7 -0
  142. package/dist/ui/formatters/triggeredRequests.js +2 -1
  143. package/dist/ui/messages/chrome.d.ts +34 -7
  144. package/dist/ui/messages/chrome.js +81 -15
  145. package/dist/ui/messages/commands.d.ts +29 -8
  146. package/dist/ui/messages/commands.js +36 -8
  147. package/dist/ui/messages/networkMessages.d.ts +50 -0
  148. package/dist/ui/messages/networkMessages.js +66 -0
  149. package/dist/ui/messages/session.d.ts +8 -0
  150. package/dist/ui/messages/session.js +10 -0
  151. package/dist/utils/atomicFile.d.ts +2 -1
  152. package/dist/utils/atomicFile.js +5 -2
  153. package/dist/utils/directories.d.ts +41 -0
  154. package/dist/utils/directories.js +48 -0
  155. package/dist/utils/http.d.ts +9 -2
  156. package/dist/utils/http.js +4 -3
  157. package/dist/utils/strings.d.ts +19 -0
  158. package/dist/utils/strings.js +16 -0
  159. package/package.json +2 -2
@@ -26,16 +26,20 @@ export declare const UNBIND_TARGET_SCRIPT = "delete window.__bdgTarget; delete w
26
26
  * the text nodes of hidden ones (display or visibility; not those of
27
27
  * `<script>`, `<style>` or `<noscript>`), so text filters match hidden
28
28
  * elements like Playwright's and `:visible` decides visibility; button inputs
29
- * use their value. Whitespace is collapsed; filter texts arrive normalized
30
- * (`has-text` lowercased). `:visible` is checked before the text filters,
31
- * which read the text.
29
+ * use their value. Text is read in the flat tree, as `bdg dom query` shows
30
+ * it: the text a web component's open shadow root renders, slotted content
31
+ * in place of its slots. A visible component, slot or element holding either
32
+ * ({@link COMPOSED_JS}) is read with {@link FLAT_TEXT_JS}, including the
33
+ * selects and editable regions `innerText` reads. Whitespace is collapsed;
34
+ * filter texts arrive normalized (`has-text` lowercased). `:visible` is
35
+ * checked before the text filters, which read the text.
32
36
  *
33
37
  * Steps and `:has()` match CSS relative to an element (`:scope > css`). A
34
38
  * descendant step also searches the open shadow roots under the element (the
35
39
  * whole step CSS inside one shadow tree); a child step stays in the
36
40
  * element's own tree.
37
41
  */
38
- export declare const FILTER_MATCHING_JS = "(shadowRoots) => {\n const skipped = /^(script|style|noscript|template)$/;\n const hiddenText = (el) => {\n const walker = el.ownerDocument.createTreeWalker(el, 5, {\n acceptNode: (node) => (node.nodeType === 1 && skipped.test(node.localName) ? 2 : 1)\n });\n let text = '';\n for (let node = walker.nextNode(); node; node = walker.nextNode()) {\n if (node.nodeType === 3) text += node.data;\n }\n return text;\n };\n const textOf = (el) => {\n if (el.localName === 'input' && /^(submit|button|reset)$/i.test(el.type)) return el.value;\n const rendered = typeof el.innerText === 'string' &&\n (typeof el.checkVisibility !== 'function' || el.checkVisibility({ visibilityProperty: true }));\n return (rendered ? el.innerText : hiddenText(el)).replace(/\\s+/g, ' ').trim();\n };\n const passes = (el, filter) => {\n if (filter.kind === 'visible') {\n if (typeof el.checkVisibility === 'function' && !el.checkVisibility()) return false;\n const rect = el.getBoundingClientRect();\n return rect.width > 0 && rect.height > 0 && el.ownerDocument.defaultView.getComputedStyle(el).visibility === 'visible';\n }\n if (filter.kind === 'has') return filter.selectors.some((chain) => descend(el, chain).length > 0);\n const text = textOf(el);\n return filter.kind === 'text-is' ? text === filter.text : text.toLowerCase().includes(filter.text);\n };\n const passesAll = (el, filters) =>\n filters.every((filter) => filter.kind !== 'visible' || passes(el, filter)) &&\n filters.every((filter) => filter.kind === 'visible' || passes(el, filter));\n const isUnder = (scope, node) => {\n for (let current = node; current; current = current.getRootNode().host) {\n if (scope === current || scope.contains(current)) return true;\n }\n return false;\n };\n const stepMatches = (scope, step) => {\n const matches = [...scope.querySelectorAll(':scope ' + step.combinator + ' ' + step.css)];\n if (step.combinator !== ' ') return matches;\n for (const root of shadowRoots) {\n if (isUnder(scope, root.host)) matches.push(...root.querySelectorAll(step.css));\n }\n return matches;\n };\n const descend = (scope, steps) => {\n let current = [scope];\n for (const step of steps) {\n const next = new Set();\n for (const el of current) {\n for (const match of stepMatches(el, step)) {\n if (passesAll(match, step.filters)) next.add(match);\n }\n }\n current = [...next];\n }\n return current;\n };\n return { passesAll: passesAll, descend: descend };\n}";
42
+ export declare const FILTER_MATCHING_JS = "(shadowRoots) => {\n const composed = (node) => {\n const composes = (n) => Boolean(n.shadowRoot) || n.localName === 'slot';\n if (composes(node)) return true;\n const walker = node.ownerDocument.createTreeWalker(node, NodeFilter.SHOW_ELEMENT);\n while (walker.nextNode()) if (composes(walker.currentNode)) return true;\n return false;\n};\n const flatText = (el, limit, fields) => {\n const composed = (node) => {\n const composes = (n) => Boolean(n.shadowRoot) || n.localName === 'slot';\n if (composes(node)) return true;\n const walker = node.ownerDocument.createTreeWalker(node, NodeFilter.SHOW_ELEMENT);\n while (walker.nextNode()) if (composes(walker.currentNode)) return true;\n return false;\n};\n const view = el.ownerDocument.defaultView;\n let text = '';\n const nodesOf = (node) =>\n node.localName === 'slot' ? node.assignedNodes({ flatten: true }) : Array.from((node.shadowRoot || node).childNodes);\n const read = (node, visible) => {\n if (text.length >= limit) return;\n if (node.nodeType === 3 && visible) text += node.data.replace(/\\s+/g, ' ');\n if (node.nodeType !== 1) return;\n if (/^(input|textarea)$/.test(node.localName)) return;\n if (!fields && (node.localName === 'select' || node.isContentEditable)) return;\n const style = view.getComputedStyle(node);\n if (node.checkVisibility && !node.checkVisibility() && style.display !== 'contents') return;\n if (node.localName === 'br') text += '\\n';\n const apart = style.display.startsWith('inline') || style.display === 'contents' ? '' : '\\n';\n text += apart;\n const own = node.textContent || '';\n if (composed(node)) nodesOf(node).forEach((child) => read(child, style.visibility === 'visible'));\n else if (own.length > limit - text.length) text += own.slice(0, limit - text.length);\n else text += typeof node.innerText === 'string' ? node.innerText : own;\n text += apart;\n };\n const visible = view.getComputedStyle(el).visibility === 'visible';\n nodesOf(el).forEach((child) => read(child, visible));\n return text.slice(0, limit);\n};\n const skipped = /^(script|style|noscript|template)$/;\n const hiddenText = (el) => {\n const nodes = el.localName === 'slot' ? el.assignedNodes({ flatten: true }) : (el.shadowRoot || el).childNodes;\n let text = '';\n for (const node of nodes) {\n if (node.nodeType === 3) text += node.data;\n else if (node.nodeType === 1 && !skipped.test(node.localName)) text += hiddenText(node);\n }\n return text;\n };\n const textOf = (el) => {\n if (el.localName === 'input' && /^(submit|button|reset)$/i.test(el.type)) return el.value;\n const rendered = typeof el.innerText === 'string' &&\n (typeof el.checkVisibility !== 'function' || el.checkVisibility({ visibilityProperty: true }));\n const text = !rendered ? hiddenText(el) : composed(el) ? flatText(el, Infinity, true) : el.innerText;\n return text.replace(/\\s+/g, ' ').trim();\n };\n const passes = (el, filter) => {\n if (filter.kind === 'visible') {\n if (typeof el.checkVisibility === 'function' && !el.checkVisibility()) return false;\n const rect = el.getBoundingClientRect();\n return rect.width > 0 && rect.height > 0 && el.ownerDocument.defaultView.getComputedStyle(el).visibility === 'visible';\n }\n if (filter.kind === 'has') return filter.selectors.some((chain) => descend(el, chain).length > 0);\n const text = textOf(el);\n return filter.kind === 'text-is' ? text === filter.text : text.toLowerCase().includes(filter.text);\n };\n const passesAll = (el, filters) =>\n filters.every((filter) => filter.kind !== 'visible' || passes(el, filter)) &&\n filters.every((filter) => filter.kind === 'visible' || passes(el, filter));\n const isUnder = (scope, node) => {\n for (let current = node; current; current = current.getRootNode().host) {\n if (scope === current || scope.contains(current)) return true;\n }\n return false;\n };\n const stepMatches = (scope, step) => {\n const matches = [...scope.querySelectorAll(':scope ' + step.combinator + ' ' + step.css)];\n if (step.combinator !== ' ') return matches;\n for (const root of shadowRoots) {\n if (isUnder(scope, root.host)) matches.push(...root.querySelectorAll(step.css));\n }\n return matches;\n };\n const descend = (scope, steps) => {\n let current = [scope];\n for (const step of steps) {\n const next = new Set();\n for (const el of current) {\n for (const match of stepMatches(el, step)) {\n if (passesAll(match, step.filters)) next.add(match);\n }\n }\n current = [...next];\n }\n return current;\n };\n return { passesAll: passesAll, descend: descend };\n}";
39
43
  /**
40
44
  * Page-side selector search that also reaches into open shadow roots and
41
45
  * same-origin iframes (recursively), like a user sees the page.
@@ -49,7 +53,7 @@ export declare const FILTER_MATCHING_JS = "(shadowRoots) => {\n const skipped =
49
53
  * reached. Cross-origin iframes are separate processes and cannot be
50
54
  * searched. Throws a `SyntaxError` for an invalid selector.
51
55
  */
52
- export declare const DEEP_QUERY_JS = "function (selector, parts) {\n const css = parts ? parts.map((part) => part.css).join(', ') : selector;\n const found = [];\n const roots = [];\n const visit = (root) => {\n roots.push(root);\n for (const match of root.querySelectorAll(css)) found.push(match);\n for (const el of root.querySelectorAll('*')) {\n if (el.shadowRoot) visit(el.shadowRoot);\n if (el.tagName === 'IFRAME' || el.tagName === 'FRAME') {\n let frameDocument = null;\n try { frameDocument = el.contentDocument; } catch (e) { frameDocument = null; }\n if (frameDocument) visit(frameDocument);\n }\n }\n };\n visit(document);\n if (!parts) return found;\n const shadowRoots = roots.filter((root) => root.host);\n const { passesAll, descend } = ((shadowRoots) => {\n const skipped = /^(script|style|noscript|template)$/;\n const hiddenText = (el) => {\n const walker = el.ownerDocument.createTreeWalker(el, 5, {\n acceptNode: (node) => (node.nodeType === 1 && skipped.test(node.localName) ? 2 : 1)\n });\n let text = '';\n for (let node = walker.nextNode(); node; node = walker.nextNode()) {\n if (node.nodeType === 3) text += node.data;\n }\n return text;\n };\n const textOf = (el) => {\n if (el.localName === 'input' && /^(submit|button|reset)$/i.test(el.type)) return el.value;\n const rendered = typeof el.innerText === 'string' &&\n (typeof el.checkVisibility !== 'function' || el.checkVisibility({ visibilityProperty: true }));\n return (rendered ? el.innerText : hiddenText(el)).replace(/\\s+/g, ' ').trim();\n };\n const passes = (el, filter) => {\n if (filter.kind === 'visible') {\n if (typeof el.checkVisibility === 'function' && !el.checkVisibility()) return false;\n const rect = el.getBoundingClientRect();\n return rect.width > 0 && rect.height > 0 && el.ownerDocument.defaultView.getComputedStyle(el).visibility === 'visible';\n }\n if (filter.kind === 'has') return filter.selectors.some((chain) => descend(el, chain).length > 0);\n const text = textOf(el);\n return filter.kind === 'text-is' ? text === filter.text : text.toLowerCase().includes(filter.text);\n };\n const passesAll = (el, filters) =>\n filters.every((filter) => filter.kind !== 'visible' || passes(el, filter)) &&\n filters.every((filter) => filter.kind === 'visible' || passes(el, filter));\n const isUnder = (scope, node) => {\n for (let current = node; current; current = current.getRootNode().host) {\n if (scope === current || scope.contains(current)) return true;\n }\n return false;\n };\n const stepMatches = (scope, step) => {\n const matches = [...scope.querySelectorAll(':scope ' + step.combinator + ' ' + step.css)];\n if (step.combinator !== ' ') return matches;\n for (const root of shadowRoots) {\n if (isUnder(scope, root.host)) matches.push(...root.querySelectorAll(step.css));\n }\n return matches;\n };\n const descend = (scope, steps) => {\n let current = [scope];\n for (const step of steps) {\n const next = new Set();\n for (const el of current) {\n for (const match of stepMatches(el, step)) {\n if (passesAll(match, step.filters)) next.add(match);\n }\n }\n current = [...next];\n }\n return current;\n };\n return { passesAll: passesAll, descend: descend };\n})(shadowRoots);\n const result = new Set();\n for (const el of found) {\n for (const part of parts) {\n if (!el.matches(part.css) || !passesAll(el, part.filters)) continue;\n for (const match of descend(el, part.steps || [])) result.add(match);\n }\n }\n const rootOrder = new Map(roots.map((root, i) => [root, i]));\n const rootIndex = (el) => rootOrder.get(el.getRootNode()) ?? roots.length;\n return [...result].sort((a, b) =>\n rootIndex(a) - rootIndex(b) || (a.compareDocumentPosition(b) & 4 ? -1 : a === b ? 0 : 1)\n );\n}";
56
+ export declare const DEEP_QUERY_JS = "function (selector, parts) {\n const css = parts ? parts.map((part) => part.css).join(', ') : selector;\n const found = [];\n const roots = [];\n const visit = (root) => {\n roots.push(root);\n for (const match of root.querySelectorAll(css)) found.push(match);\n for (const el of root.querySelectorAll('*')) {\n if (el.shadowRoot) visit(el.shadowRoot);\n if (el.tagName === 'IFRAME' || el.tagName === 'FRAME') {\n let frameDocument = null;\n try { frameDocument = el.contentDocument; } catch (e) { frameDocument = null; }\n if (frameDocument) visit(frameDocument);\n }\n }\n };\n visit(document);\n if (!parts) return found;\n const shadowRoots = roots.filter((root) => root.host);\n const { passesAll, descend } = ((shadowRoots) => {\n const composed = (node) => {\n const composes = (n) => Boolean(n.shadowRoot) || n.localName === 'slot';\n if (composes(node)) return true;\n const walker = node.ownerDocument.createTreeWalker(node, NodeFilter.SHOW_ELEMENT);\n while (walker.nextNode()) if (composes(walker.currentNode)) return true;\n return false;\n};\n const flatText = (el, limit, fields) => {\n const composed = (node) => {\n const composes = (n) => Boolean(n.shadowRoot) || n.localName === 'slot';\n if (composes(node)) return true;\n const walker = node.ownerDocument.createTreeWalker(node, NodeFilter.SHOW_ELEMENT);\n while (walker.nextNode()) if (composes(walker.currentNode)) return true;\n return false;\n};\n const view = el.ownerDocument.defaultView;\n let text = '';\n const nodesOf = (node) =>\n node.localName === 'slot' ? node.assignedNodes({ flatten: true }) : Array.from((node.shadowRoot || node).childNodes);\n const read = (node, visible) => {\n if (text.length >= limit) return;\n if (node.nodeType === 3 && visible) text += node.data.replace(/\\s+/g, ' ');\n if (node.nodeType !== 1) return;\n if (/^(input|textarea)$/.test(node.localName)) return;\n if (!fields && (node.localName === 'select' || node.isContentEditable)) return;\n const style = view.getComputedStyle(node);\n if (node.checkVisibility && !node.checkVisibility() && style.display !== 'contents') return;\n if (node.localName === 'br') text += '\\n';\n const apart = style.display.startsWith('inline') || style.display === 'contents' ? '' : '\\n';\n text += apart;\n const own = node.textContent || '';\n if (composed(node)) nodesOf(node).forEach((child) => read(child, style.visibility === 'visible'));\n else if (own.length > limit - text.length) text += own.slice(0, limit - text.length);\n else text += typeof node.innerText === 'string' ? node.innerText : own;\n text += apart;\n };\n const visible = view.getComputedStyle(el).visibility === 'visible';\n nodesOf(el).forEach((child) => read(child, visible));\n return text.slice(0, limit);\n};\n const skipped = /^(script|style|noscript|template)$/;\n const hiddenText = (el) => {\n const nodes = el.localName === 'slot' ? el.assignedNodes({ flatten: true }) : (el.shadowRoot || el).childNodes;\n let text = '';\n for (const node of nodes) {\n if (node.nodeType === 3) text += node.data;\n else if (node.nodeType === 1 && !skipped.test(node.localName)) text += hiddenText(node);\n }\n return text;\n };\n const textOf = (el) => {\n if (el.localName === 'input' && /^(submit|button|reset)$/i.test(el.type)) return el.value;\n const rendered = typeof el.innerText === 'string' &&\n (typeof el.checkVisibility !== 'function' || el.checkVisibility({ visibilityProperty: true }));\n const text = !rendered ? hiddenText(el) : composed(el) ? flatText(el, Infinity, true) : el.innerText;\n return text.replace(/\\s+/g, ' ').trim();\n };\n const passes = (el, filter) => {\n if (filter.kind === 'visible') {\n if (typeof el.checkVisibility === 'function' && !el.checkVisibility()) return false;\n const rect = el.getBoundingClientRect();\n return rect.width > 0 && rect.height > 0 && el.ownerDocument.defaultView.getComputedStyle(el).visibility === 'visible';\n }\n if (filter.kind === 'has') return filter.selectors.some((chain) => descend(el, chain).length > 0);\n const text = textOf(el);\n return filter.kind === 'text-is' ? text === filter.text : text.toLowerCase().includes(filter.text);\n };\n const passesAll = (el, filters) =>\n filters.every((filter) => filter.kind !== 'visible' || passes(el, filter)) &&\n filters.every((filter) => filter.kind === 'visible' || passes(el, filter));\n const isUnder = (scope, node) => {\n for (let current = node; current; current = current.getRootNode().host) {\n if (scope === current || scope.contains(current)) return true;\n }\n return false;\n };\n const stepMatches = (scope, step) => {\n const matches = [...scope.querySelectorAll(':scope ' + step.combinator + ' ' + step.css)];\n if (step.combinator !== ' ') return matches;\n for (const root of shadowRoots) {\n if (isUnder(scope, root.host)) matches.push(...root.querySelectorAll(step.css));\n }\n return matches;\n };\n const descend = (scope, steps) => {\n let current = [scope];\n for (const step of steps) {\n const next = new Set();\n for (const el of current) {\n for (const match of stepMatches(el, step)) {\n if (passesAll(match, step.filters)) next.add(match);\n }\n }\n current = [...next];\n }\n return current;\n };\n return { passesAll: passesAll, descend: descend };\n})(shadowRoots);\n const result = new Set();\n for (const el of found) {\n for (const part of parts) {\n if (!el.matches(part.css) || !passesAll(el, part.filters)) continue;\n for (const match of descend(el, part.steps || [])) result.add(match);\n }\n }\n const rootOrder = new Map(roots.map((root, i) => [root, i]));\n const rootIndex = (el) => rootOrder.get(el.getRootNode()) ?? roots.length;\n return [...result].sort((a, b) =>\n rootIndex(a) - rootIndex(b) || (a.compareDocumentPosition(b) & 4 ? -1 : a === b ? 0 : 1)\n );\n}";
53
57
  /**
54
58
  * Page-side element lookup shared by the interaction scripts.
55
59
  *
@@ -58,7 +62,7 @@ export declare const DEEP_QUERY_JS = "function (selector, parts) {\n const css
58
62
  * built-ins ({@link bindMatches}), otherwise all matches of the selector
59
63
  * ({@link DEEP_QUERY_JS}).
60
64
  */
61
- export declare const FIND_ELEMENTS_JS = "function (selector, parts) {\n if (selector === '__bdg_bound_target__') {\n const el = window.__bdgTarget;\n return el && el.isConnected ? [el] : [];\n }\n const bound = window.__bdgMatches;\n if (bound && bound.selector === selector) {\n const connected = [];\n for (let i = 0; i < bound.nodes.length; i++) {\n if (bound.nodes[i] && bound.nodes[i].isConnected) connected[connected.length] = bound.nodes[i];\n }\n return connected;\n }\n return (function (selector, parts) {\n const css = parts ? parts.map((part) => part.css).join(', ') : selector;\n const found = [];\n const roots = [];\n const visit = (root) => {\n roots.push(root);\n for (const match of root.querySelectorAll(css)) found.push(match);\n for (const el of root.querySelectorAll('*')) {\n if (el.shadowRoot) visit(el.shadowRoot);\n if (el.tagName === 'IFRAME' || el.tagName === 'FRAME') {\n let frameDocument = null;\n try { frameDocument = el.contentDocument; } catch (e) { frameDocument = null; }\n if (frameDocument) visit(frameDocument);\n }\n }\n };\n visit(document);\n if (!parts) return found;\n const shadowRoots = roots.filter((root) => root.host);\n const { passesAll, descend } = ((shadowRoots) => {\n const skipped = /^(script|style|noscript|template)$/;\n const hiddenText = (el) => {\n const walker = el.ownerDocument.createTreeWalker(el, 5, {\n acceptNode: (node) => (node.nodeType === 1 && skipped.test(node.localName) ? 2 : 1)\n });\n let text = '';\n for (let node = walker.nextNode(); node; node = walker.nextNode()) {\n if (node.nodeType === 3) text += node.data;\n }\n return text;\n };\n const textOf = (el) => {\n if (el.localName === 'input' && /^(submit|button|reset)$/i.test(el.type)) return el.value;\n const rendered = typeof el.innerText === 'string' &&\n (typeof el.checkVisibility !== 'function' || el.checkVisibility({ visibilityProperty: true }));\n return (rendered ? el.innerText : hiddenText(el)).replace(/\\s+/g, ' ').trim();\n };\n const passes = (el, filter) => {\n if (filter.kind === 'visible') {\n if (typeof el.checkVisibility === 'function' && !el.checkVisibility()) return false;\n const rect = el.getBoundingClientRect();\n return rect.width > 0 && rect.height > 0 && el.ownerDocument.defaultView.getComputedStyle(el).visibility === 'visible';\n }\n if (filter.kind === 'has') return filter.selectors.some((chain) => descend(el, chain).length > 0);\n const text = textOf(el);\n return filter.kind === 'text-is' ? text === filter.text : text.toLowerCase().includes(filter.text);\n };\n const passesAll = (el, filters) =>\n filters.every((filter) => filter.kind !== 'visible' || passes(el, filter)) &&\n filters.every((filter) => filter.kind === 'visible' || passes(el, filter));\n const isUnder = (scope, node) => {\n for (let current = node; current; current = current.getRootNode().host) {\n if (scope === current || scope.contains(current)) return true;\n }\n return false;\n };\n const stepMatches = (scope, step) => {\n const matches = [...scope.querySelectorAll(':scope ' + step.combinator + ' ' + step.css)];\n if (step.combinator !== ' ') return matches;\n for (const root of shadowRoots) {\n if (isUnder(scope, root.host)) matches.push(...root.querySelectorAll(step.css));\n }\n return matches;\n };\n const descend = (scope, steps) => {\n let current = [scope];\n for (const step of steps) {\n const next = new Set();\n for (const el of current) {\n for (const match of stepMatches(el, step)) {\n if (passesAll(match, step.filters)) next.add(match);\n }\n }\n current = [...next];\n }\n return current;\n };\n return { passesAll: passesAll, descend: descend };\n})(shadowRoots);\n const result = new Set();\n for (const el of found) {\n for (const part of parts) {\n if (!el.matches(part.css) || !passesAll(el, part.filters)) continue;\n for (const match of descend(el, part.steps || [])) result.add(match);\n }\n }\n const rootOrder = new Map(roots.map((root, i) => [root, i]));\n const rootIndex = (el) => rootOrder.get(el.getRootNode()) ?? roots.length;\n return [...result].sort((a, b) =>\n rootIndex(a) - rootIndex(b) || (a.compareDocumentPosition(b) & 4 ? -1 : a === b ? 0 : 1)\n );\n})(selector, parts);\n}";
65
+ export declare const FIND_ELEMENTS_JS = "function (selector, parts) {\n if (selector === '__bdg_bound_target__') {\n const el = window.__bdgTarget;\n return el && el.isConnected ? [el] : [];\n }\n const bound = window.__bdgMatches;\n if (bound && bound.selector === selector) {\n const connected = [];\n for (let i = 0; i < bound.nodes.length; i++) {\n if (bound.nodes[i] && bound.nodes[i].isConnected) connected[connected.length] = bound.nodes[i];\n }\n return connected;\n }\n return (function (selector, parts) {\n const css = parts ? parts.map((part) => part.css).join(', ') : selector;\n const found = [];\n const roots = [];\n const visit = (root) => {\n roots.push(root);\n for (const match of root.querySelectorAll(css)) found.push(match);\n for (const el of root.querySelectorAll('*')) {\n if (el.shadowRoot) visit(el.shadowRoot);\n if (el.tagName === 'IFRAME' || el.tagName === 'FRAME') {\n let frameDocument = null;\n try { frameDocument = el.contentDocument; } catch (e) { frameDocument = null; }\n if (frameDocument) visit(frameDocument);\n }\n }\n };\n visit(document);\n if (!parts) return found;\n const shadowRoots = roots.filter((root) => root.host);\n const { passesAll, descend } = ((shadowRoots) => {\n const composed = (node) => {\n const composes = (n) => Boolean(n.shadowRoot) || n.localName === 'slot';\n if (composes(node)) return true;\n const walker = node.ownerDocument.createTreeWalker(node, NodeFilter.SHOW_ELEMENT);\n while (walker.nextNode()) if (composes(walker.currentNode)) return true;\n return false;\n};\n const flatText = (el, limit, fields) => {\n const composed = (node) => {\n const composes = (n) => Boolean(n.shadowRoot) || n.localName === 'slot';\n if (composes(node)) return true;\n const walker = node.ownerDocument.createTreeWalker(node, NodeFilter.SHOW_ELEMENT);\n while (walker.nextNode()) if (composes(walker.currentNode)) return true;\n return false;\n};\n const view = el.ownerDocument.defaultView;\n let text = '';\n const nodesOf = (node) =>\n node.localName === 'slot' ? node.assignedNodes({ flatten: true }) : Array.from((node.shadowRoot || node).childNodes);\n const read = (node, visible) => {\n if (text.length >= limit) return;\n if (node.nodeType === 3 && visible) text += node.data.replace(/\\s+/g, ' ');\n if (node.nodeType !== 1) return;\n if (/^(input|textarea)$/.test(node.localName)) return;\n if (!fields && (node.localName === 'select' || node.isContentEditable)) return;\n const style = view.getComputedStyle(node);\n if (node.checkVisibility && !node.checkVisibility() && style.display !== 'contents') return;\n if (node.localName === 'br') text += '\\n';\n const apart = style.display.startsWith('inline') || style.display === 'contents' ? '' : '\\n';\n text += apart;\n const own = node.textContent || '';\n if (composed(node)) nodesOf(node).forEach((child) => read(child, style.visibility === 'visible'));\n else if (own.length > limit - text.length) text += own.slice(0, limit - text.length);\n else text += typeof node.innerText === 'string' ? node.innerText : own;\n text += apart;\n };\n const visible = view.getComputedStyle(el).visibility === 'visible';\n nodesOf(el).forEach((child) => read(child, visible));\n return text.slice(0, limit);\n};\n const skipped = /^(script|style|noscript|template)$/;\n const hiddenText = (el) => {\n const nodes = el.localName === 'slot' ? el.assignedNodes({ flatten: true }) : (el.shadowRoot || el).childNodes;\n let text = '';\n for (const node of nodes) {\n if (node.nodeType === 3) text += node.data;\n else if (node.nodeType === 1 && !skipped.test(node.localName)) text += hiddenText(node);\n }\n return text;\n };\n const textOf = (el) => {\n if (el.localName === 'input' && /^(submit|button|reset)$/i.test(el.type)) return el.value;\n const rendered = typeof el.innerText === 'string' &&\n (typeof el.checkVisibility !== 'function' || el.checkVisibility({ visibilityProperty: true }));\n const text = !rendered ? hiddenText(el) : composed(el) ? flatText(el, Infinity, true) : el.innerText;\n return text.replace(/\\s+/g, ' ').trim();\n };\n const passes = (el, filter) => {\n if (filter.kind === 'visible') {\n if (typeof el.checkVisibility === 'function' && !el.checkVisibility()) return false;\n const rect = el.getBoundingClientRect();\n return rect.width > 0 && rect.height > 0 && el.ownerDocument.defaultView.getComputedStyle(el).visibility === 'visible';\n }\n if (filter.kind === 'has') return filter.selectors.some((chain) => descend(el, chain).length > 0);\n const text = textOf(el);\n return filter.kind === 'text-is' ? text === filter.text : text.toLowerCase().includes(filter.text);\n };\n const passesAll = (el, filters) =>\n filters.every((filter) => filter.kind !== 'visible' || passes(el, filter)) &&\n filters.every((filter) => filter.kind === 'visible' || passes(el, filter));\n const isUnder = (scope, node) => {\n for (let current = node; current; current = current.getRootNode().host) {\n if (scope === current || scope.contains(current)) return true;\n }\n return false;\n };\n const stepMatches = (scope, step) => {\n const matches = [...scope.querySelectorAll(':scope ' + step.combinator + ' ' + step.css)];\n if (step.combinator !== ' ') return matches;\n for (const root of shadowRoots) {\n if (isUnder(scope, root.host)) matches.push(...root.querySelectorAll(step.css));\n }\n return matches;\n };\n const descend = (scope, steps) => {\n let current = [scope];\n for (const step of steps) {\n const next = new Set();\n for (const el of current) {\n for (const match of stepMatches(el, step)) {\n if (passesAll(match, step.filters)) next.add(match);\n }\n }\n current = [...next];\n }\n return current;\n };\n return { passesAll: passesAll, descend: descend };\n})(shadowRoots);\n const result = new Set();\n for (const el of found) {\n for (const part of parts) {\n if (!el.matches(part.css) || !passesAll(el, part.filters)) continue;\n for (const match of descend(el, part.steps || [])) result.add(match);\n }\n }\n const rootOrder = new Map(roots.map((root, i) => [root, i]));\n const rootIndex = (el) => rootOrder.get(el.getRootNode()) ?? roots.length;\n return [...result].sort((a, b) =>\n rootIndex(a) - rootIndex(b) || (a.compareDocumentPosition(b) & 4 ? -1 : a === b ? 0 : 1)\n );\n})(selector, parts);\n}";
62
66
  /**
63
67
  * Page-side check of what selectors cannot search in the top document:
64
68
  * iframes whose document cannot be read (cross-origin) and `<object>`/`<embed>`
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { CommandError } from '../../errors/index.js';
12
12
  import { actionBrokenByPageError, actionScriptFailedError, emptySelectorError, indexOutOfRangeError, noNodesFoundError, staleNodeError, } from '../../errors/messages.js';
13
+ import { COMPOSED_JS, FLAT_TEXT_JS } from './elementInfo.js';
13
14
  import { ActionScriptError, throwIfInvalidSelector } from './formFillHelpers/shared.js';
14
15
  import { frameScopedConnection } from './frameScopedConnection.js';
15
16
  import { evaluateInBdgWorld } from '../page/bdgWorld.js';
@@ -102,9 +103,13 @@ const log = createLogger('dom');
102
103
  * the text nodes of hidden ones (display or visibility; not those of
103
104
  * `<script>`, `<style>` or `<noscript>`), so text filters match hidden
104
105
  * elements like Playwright's and `:visible` decides visibility; button inputs
105
- * use their value. Whitespace is collapsed; filter texts arrive normalized
106
- * (`has-text` lowercased). `:visible` is checked before the text filters,
107
- * which read the text.
106
+ * use their value. Text is read in the flat tree, as `bdg dom query` shows
107
+ * it: the text a web component's open shadow root renders, slotted content
108
+ * in place of its slots. A visible component, slot or element holding either
109
+ * ({@link COMPOSED_JS}) is read with {@link FLAT_TEXT_JS}, including the
110
+ * selects and editable regions `innerText` reads. Whitespace is collapsed;
111
+ * filter texts arrive normalized (`has-text` lowercased). `:visible` is
112
+ * checked before the text filters, which read the text.
108
113
  *
109
114
  * Steps and `:has()` match CSS relative to an element (`:scope > css`). A
110
115
  * descendant step also searches the open shadow roots under the element (the
@@ -112,14 +117,15 @@ const log = createLogger('dom');
112
117
  * element's own tree.
113
118
  */
114
119
  export const FILTER_MATCHING_JS = `(shadowRoots) => {
120
+ const composed = ${COMPOSED_JS};
121
+ const flatText = ${FLAT_TEXT_JS};
115
122
  const skipped = /^(script|style|noscript|template)$/;
116
123
  const hiddenText = (el) => {
117
- const walker = el.ownerDocument.createTreeWalker(el, 5, {
118
- acceptNode: (node) => (node.nodeType === 1 && skipped.test(node.localName) ? 2 : 1)
119
- });
124
+ const nodes = el.localName === 'slot' ? el.assignedNodes({ flatten: true }) : (el.shadowRoot || el).childNodes;
120
125
  let text = '';
121
- for (let node = walker.nextNode(); node; node = walker.nextNode()) {
126
+ for (const node of nodes) {
122
127
  if (node.nodeType === 3) text += node.data;
128
+ else if (node.nodeType === 1 && !skipped.test(node.localName)) text += hiddenText(node);
123
129
  }
124
130
  return text;
125
131
  };
@@ -127,7 +133,8 @@ export const FILTER_MATCHING_JS = `(shadowRoots) => {
127
133
  if (el.localName === 'input' && /^(submit|button|reset)$/i.test(el.type)) return el.value;
128
134
  const rendered = typeof el.innerText === 'string' &&
129
135
  (typeof el.checkVisibility !== 'function' || el.checkVisibility({ visibilityProperty: true }));
130
- return (rendered ? el.innerText : hiddenText(el)).replace(/\\s+/g, ' ').trim();
136
+ const text = !rendered ? hiddenText(el) : composed(el) ? flatText(el, Infinity, true) : el.innerText;
137
+ return text.replace(/\\s+/g, ' ').trim();
131
138
  };
132
139
  const passes = (el, filter) => {
133
140
  if (filter.kind === 'visible') {
@@ -60,12 +60,13 @@ export async function applySessionEmulation(cdp, emulation) {
60
60
  */
61
61
  async function emulatePhone(cdp, on) {
62
62
  await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: on, maxTouchPoints: on ? 5 : 1 });
63
- const { userAgent } = (await cdp.send('Browser.getVersion', {}));
63
+ const version = (await cdp.send('Browser.getVersion', {}));
64
+ const { userAgent } = version;
64
65
  if (!on) {
65
- const headless = userAgent.includes('HeadlessChrome');
66
- await cdp.send('Emulation.setUserAgentOverride', { userAgent: headless ? userAgent : '' });
67
- if (headless)
68
- await hideHeadlessUserAgent(cdp, log);
66
+ if (userAgent.includes('HeadlessChrome'))
67
+ await hideHeadlessUserAgent(cdp, log, version);
68
+ else
69
+ await cdp.send('Emulation.setUserAgentOverride', { userAgent: '' });
69
70
  return;
70
71
  }
71
72
  const major = /Chrome\/(\d+)/.exec(userAgent)?.[1] ?? '';
@@ -3,15 +3,99 @@
3
3
  * client hints, so sites serve the page a user sees.
4
4
  */
5
5
  import type { CDPConnection } from '../../connection/cdp.js';
6
- import type { Logger } from '../../ui/logging/index.js';
6
+ import { type Logger } from '../../ui/logging/index.js';
7
+ /** A brand in the client hints (`Sec-CH-UA`, `navigator.userAgentData.brands`) */
8
+ export interface BrandVersion {
9
+ brand: string;
10
+ version: string;
11
+ }
12
+ /** CDP `Emulation.UserAgentMetadata`: the client hints Chrome sends and reports */
13
+ export interface UserAgentMetadata {
14
+ brands: BrandVersion[];
15
+ fullVersionList: BrandVersion[];
16
+ fullVersion: string;
17
+ platform: string;
18
+ platformVersion: string;
19
+ architecture: string;
20
+ bitness: string;
21
+ model: string;
22
+ mobile: boolean;
23
+ wow64: boolean;
24
+ formFactors: string[];
25
+ }
26
+ /** The machine the daemon runs on, as client hints name it */
27
+ export interface HostPlatform {
28
+ /** Client-hint platform (`macOS`, `Windows`, `Linux`) */
29
+ platform: string;
30
+ /** OS version as Chrome reports it */
31
+ platformVersion: string;
32
+ /** `arm` or `x86` */
33
+ architecture: string;
34
+ /** `64` or `32` */
35
+ bitness: string;
36
+ }
37
+ /** `Browser.getVersion` fields the metadata is built from */
38
+ export interface BrowserVersion {
39
+ product: string;
40
+ userAgent: string;
41
+ }
42
+ /**
43
+ * Chrome's brand list for a version, built the way Chrome builds it
44
+ * (`GenerateBrandVersionList` in Chromium's `user_agent_utils.cc`): a
45
+ * made-up brand, Chromium and the browser's brand, in an order and with a
46
+ * made-up name and version that depend on the major version.
47
+ *
48
+ * @param major - Major version (the seed)
49
+ * @param chromium - Chromium version to list
50
+ * @param browser - Browser brand and version
51
+ * @param greaseSuffix - Appended to the made-up version (`.0.0.0` in full versions)
52
+ * @returns Brand list
53
+ */
54
+ export declare function chromeBrandList(major: number, chromium: string, browser: BrandVersion, greaseSuffix?: string): BrandVersion[];
55
+ /**
56
+ * The client hints regular Chrome sends, for the browser `Browser.getVersion`
57
+ * describes. The browser brand is Microsoft Edge when the user agent says
58
+ * `Edg/`, Google Chrome otherwise (Chromium and Chrome for Testing look the
59
+ * same over CDP). Edge's Chromium version is only known to the major
60
+ * version. The OS version, architecture and bitness are the host's when it
61
+ * runs the platform the user agent names, empty otherwise (a remote Chrome).
62
+ *
63
+ * @param version - `Browser.getVersion` product and user agent
64
+ * @param host - The daemon's machine
65
+ * @returns Metadata for `Emulation.setUserAgentOverride`
66
+ */
67
+ export declare function regularChromeMetadata(version: BrowserVersion, host: HostPlatform): UserAgentMetadata;
68
+ /**
69
+ * The OS version Chrome reports on Linux or Windows, from `os.release()`:
70
+ * the kernel version's first three numbers on Linux, and on Windows `13.0.0`
71
+ * for Windows 11 and `10.0.0` before it (Chrome reports a Windows API
72
+ * version there, not the OS build).
73
+ *
74
+ * @param platform - `process.platform`
75
+ * @param release - `os.release()`
76
+ * @returns OS version, or empty when unknown
77
+ */
78
+ export declare function releasePlatformVersion(platform: string, release: string): string;
79
+ /**
80
+ * The daemon's machine as client hints describe it.
81
+ *
82
+ * @returns Platform, OS version, architecture and bitness
83
+ */
84
+ export declare function hostPlatform(): HostPlatform;
7
85
  /**
8
86
  * Send the user agent and client hints of regular Chrome from headless
9
87
  * Chrome: sites serve "HeadlessChrome" a different page (or a bot
10
88
  * challenge), so the page would not be the one a user sees. Not for a
11
89
  * session emulating a phone, whose emulation sets a mobile user agent.
12
90
  *
91
+ * The client hints are built from `Browser.getVersion` and the host
92
+ * ({@link regularChromeMetadata}) rather than read from the page: the page is
93
+ * still `about:blank`, which has no `navigator.userAgentData`, and an
94
+ * override without metadata empties the client hints.
95
+ *
13
96
  * @param cdp - CDP connection
14
97
  * @param logger - Logger for failures (the session works without it)
98
+ * @param known - `Browser.getVersion` result, when the caller has it
15
99
  */
16
- export declare function hideHeadlessUserAgent(cdp: CDPConnection, logger: Logger): Promise<void>;
100
+ export declare function hideHeadlessUserAgent(cdp: CDPConnection, logger: Logger, known?: BrowserVersion): Promise<void>;
17
101
  //# sourceMappingURL=userAgent.d.ts.map
@@ -2,52 +2,173 @@
2
2
  * The session's user agent in headless Chrome: regular Chrome's string and
3
3
  * client hints, so sites serve the page a user sees.
4
4
  */
5
+ import { readFileSync } from 'node:fs';
6
+ import os from 'node:os';
7
+ import { createLogger } from '../../ui/logging/index.js';
5
8
  import { getErrorMessage } from '../../utils/errors.js';
6
- /** Reads the client hints Chrome reports, renaming the HeadlessChrome brand */
7
- const USER_AGENT_METADATA_SCRIPT = `(async () => {
8
- const data = navigator.userAgentData;
9
- if (!data) return null;
10
- const values = await data.getHighEntropyValues(
11
- ['architecture', 'bitness', 'model', 'platformVersion', 'fullVersionList', 'wow64']
12
- );
13
- const rename = (list) => (list || []).map((entry) => ({
14
- brand: entry.brand.replace('HeadlessChrome', 'Google Chrome'),
15
- version: entry.version,
16
- }));
17
- return {
18
- brands: rename(values.brands),
19
- fullVersionList: rename(values.fullVersionList),
20
- platform: values.platform,
21
- platformVersion: values.platformVersion,
22
- architecture: values.architecture,
23
- model: values.model,
24
- mobile: values.mobile,
25
- bitness: values.bitness,
26
- wow64: values.wow64,
27
- };
28
- })()`;
9
+ const log = createLogger('session');
10
+ /** Characters Chrome picks from for its made-up ("GREASE") brand name */
11
+ const GREASE_CHARS = [' ', '(', ':', '-', '.', '/', ')', ';', '=', '?', '_'];
12
+ /** Versions Chrome picks from for its made-up brand */
13
+ const GREASE_VERSIONS = ['8', '99', '24'];
14
+ /** Positions of the made-up brand, Chromium and the browser brand, by major version */
15
+ const BRAND_ORDERS = [
16
+ [0, 1, 2],
17
+ [0, 2, 1],
18
+ [1, 0, 2],
19
+ [1, 2, 0],
20
+ [2, 0, 1],
21
+ [2, 1, 0],
22
+ ];
23
+ /** Client-hint platform for each `process.platform` Chrome runs on */
24
+ const HOST_PLATFORMS = {
25
+ darwin: 'macOS',
26
+ win32: 'Windows',
27
+ linux: 'Linux',
28
+ };
29
+ /** Client-hint platform for each platform token of the user agent string */
30
+ const USER_AGENT_PLATFORMS = [
31
+ [/Android/, 'Android'],
32
+ [/CrOS/, 'Chrome OS'],
33
+ [/Macintosh/, 'macOS'],
34
+ [/Windows/, 'Windows'],
35
+ [/Linux|X11/, 'Linux'],
36
+ ];
37
+ /**
38
+ * Chrome's brand list for a version, built the way Chrome builds it
39
+ * (`GenerateBrandVersionList` in Chromium's `user_agent_utils.cc`): a
40
+ * made-up brand, Chromium and the browser's brand, in an order and with a
41
+ * made-up name and version that depend on the major version.
42
+ *
43
+ * @param major - Major version (the seed)
44
+ * @param chromium - Chromium version to list
45
+ * @param browser - Browser brand and version
46
+ * @param greaseSuffix - Appended to the made-up version (`.0.0.0` in full versions)
47
+ * @returns Brand list
48
+ */
49
+ export function chromeBrandList(major, chromium, browser, greaseSuffix = '') {
50
+ const grease = {
51
+ brand: `Not${GREASE_CHARS[major % GREASE_CHARS.length]}A${GREASE_CHARS[(major + 1) % GREASE_CHARS.length]}Brand`,
52
+ version: `${GREASE_VERSIONS[major % GREASE_VERSIONS.length]}${greaseSuffix}`,
53
+ };
54
+ const [greaseAt, chromiumAt, browserAt] = BRAND_ORDERS[major % BRAND_ORDERS.length] ?? [0, 1, 2];
55
+ const list = [];
56
+ list[greaseAt] = grease;
57
+ list[chromiumAt] = { brand: 'Chromium', version: chromium };
58
+ list[browserAt] = browser;
59
+ return list;
60
+ }
61
+ /**
62
+ * The client hints regular Chrome sends, for the browser `Browser.getVersion`
63
+ * describes. The browser brand is Microsoft Edge when the user agent says
64
+ * `Edg/`, Google Chrome otherwise (Chromium and Chrome for Testing look the
65
+ * same over CDP). Edge's Chromium version is only known to the major
66
+ * version. The OS version, architecture and bitness are the host's when it
67
+ * runs the platform the user agent names, empty otherwise (a remote Chrome).
68
+ *
69
+ * @param version - `Browser.getVersion` product and user agent
70
+ * @param host - The daemon's machine
71
+ * @returns Metadata for `Emulation.setUserAgentOverride`
72
+ */
73
+ export function regularChromeMetadata(version, host) {
74
+ const fullVersion = /\/([\d.]+)/.exec(version.product)?.[1] ?? '';
75
+ const major = Number.parseInt(fullVersion, 10) || 0;
76
+ const isEdge = / Edg\//.test(version.userAgent);
77
+ const chromiumVersion = isEdge ? `${major}.0.0.0` : fullVersion;
78
+ const brand = isEdge ? 'Microsoft Edge' : 'Google Chrome';
79
+ const platform = USER_AGENT_PLATFORMS.find(([token]) => token.test(version.userAgent))?.[1] ?? host.platform;
80
+ const isHost = platform === host.platform;
81
+ return {
82
+ brands: chromeBrandList(major, String(major), { brand, version: String(major) }),
83
+ fullVersionList: chromeBrandList(major, chromiumVersion, { brand, version: fullVersion }, '.0.0.0'),
84
+ fullVersion,
85
+ platform,
86
+ platformVersion: isHost ? host.platformVersion : '',
87
+ architecture: isHost ? host.architecture : '',
88
+ bitness: isHost ? host.bitness : '',
89
+ model: '',
90
+ mobile: false,
91
+ wow64: false,
92
+ formFactors: ['Desktop'],
93
+ };
94
+ }
95
+ /** First Windows build of Windows 11, which client hints report as version 13 */
96
+ const WINDOWS_11_BUILD = 22000;
97
+ /**
98
+ * The OS version Chrome reports on Linux or Windows, from `os.release()`:
99
+ * the kernel version's first three numbers on Linux, and on Windows `13.0.0`
100
+ * for Windows 11 and `10.0.0` before it (Chrome reports a Windows API
101
+ * version there, not the OS build).
102
+ *
103
+ * @param platform - `process.platform`
104
+ * @param release - `os.release()`
105
+ * @returns OS version, or empty when unknown
106
+ */
107
+ export function releasePlatformVersion(platform, release) {
108
+ if (platform === 'win32') {
109
+ const build = Number(release.split('.')[2]);
110
+ if (!build)
111
+ return '';
112
+ return build >= WINDOWS_11_BUILD ? '13.0.0' : '10.0.0';
113
+ }
114
+ return /^\d+(\.\d+){0,2}/.exec(release)?.[0] ?? '';
115
+ }
116
+ /**
117
+ * The OS version as Chrome reports it: the macOS product version (from
118
+ * `SystemVersion.plist`, as `sw_vers` prints it), or
119
+ * {@link releasePlatformVersion} elsewhere.
120
+ *
121
+ * @returns OS version, or empty when unknown
122
+ */
123
+ function hostPlatformVersion() {
124
+ if (process.platform !== 'darwin')
125
+ return releasePlatformVersion(process.platform, os.release());
126
+ try {
127
+ const plist = readFileSync('/System/Library/CoreServices/SystemVersion.plist', 'utf8');
128
+ return /<key>ProductVersion<\/key>\s*<string>([\d.]+)<\/string>/.exec(plist)?.[1] ?? '';
129
+ }
130
+ catch (error) {
131
+ log.debug(`macOS version unknown: ${getErrorMessage(error)}`);
132
+ return '';
133
+ }
134
+ }
135
+ /**
136
+ * The daemon's machine as client hints describe it.
137
+ *
138
+ * @returns Platform, OS version, architecture and bitness
139
+ */
140
+ export function hostPlatform() {
141
+ const arch = process.arch;
142
+ return {
143
+ platform: HOST_PLATFORMS[process.platform] ?? '',
144
+ platformVersion: hostPlatformVersion(),
145
+ architecture: arch.startsWith('arm') ? 'arm' : 'x86',
146
+ bitness: arch === 'arm64' || arch === 'x64' ? '64' : '32',
147
+ };
148
+ }
29
149
  /**
30
150
  * Send the user agent and client hints of regular Chrome from headless
31
151
  * Chrome: sites serve "HeadlessChrome" a different page (or a bot
32
152
  * challenge), so the page would not be the one a user sees. Not for a
33
153
  * session emulating a phone, whose emulation sets a mobile user agent.
34
154
  *
155
+ * The client hints are built from `Browser.getVersion` and the host
156
+ * ({@link regularChromeMetadata}) rather than read from the page: the page is
157
+ * still `about:blank`, which has no `navigator.userAgentData`, and an
158
+ * override without metadata empties the client hints.
159
+ *
35
160
  * @param cdp - CDP connection
36
161
  * @param logger - Logger for failures (the session works without it)
162
+ * @param known - `Browser.getVersion` result, when the caller has it
37
163
  */
38
- export async function hideHeadlessUserAgent(cdp, logger) {
164
+ export async function hideHeadlessUserAgent(cdp, logger, known) {
39
165
  try {
40
- const { userAgent } = (await cdp.send('Browser.getVersion'));
41
- if (!userAgent.includes('HeadlessChrome'))
166
+ const version = known ?? (await cdp.send('Browser.getVersion'));
167
+ if (!version.userAgent.includes('HeadlessChrome'))
42
168
  return;
43
- const metadata = (await cdp.send('Runtime.evaluate', {
44
- expression: USER_AGENT_METADATA_SCRIPT,
45
- awaitPromise: true,
46
- returnByValue: true,
47
- }));
48
169
  await cdp.send('Emulation.setUserAgentOverride', {
49
- userAgent: userAgent.replace('HeadlessChrome', 'Chrome'),
50
- ...(metadata.result?.value ? { userAgentMetadata: metadata.result.value } : {}),
170
+ userAgent: version.userAgent.replace('HeadlessChrome', 'Chrome'),
171
+ userAgentMetadata: regularChromeMetadata(version, hostPlatform()),
51
172
  });
52
173
  }
53
174
  catch (error) {
@@ -5,6 +5,7 @@
5
5
  * (or `~/.bdg/sessions/<name>/` for a named session).
6
6
  * WHY: Single source of truth for file locations prevents path inconsistencies.
7
7
  */
8
+ import { type DirTrustKind } from '../utils/directories.js';
8
9
  /**
9
10
  * Session file paths relative to ~/.bdg/
10
11
  * Centralized definition for all session-related files.
@@ -123,12 +124,46 @@ export declare function getDaemonSocketPath(): string;
123
124
  /**
124
125
  * Ensure the session directory exists.
125
126
  *
126
- * Creates ~/.bdg/ if it doesn't exist. Safe to call multiple times (idempotent).
127
- * A path that cannot hold a directory is refused before `mkdir`, which
128
- * would spin on a pseudo-filesystem ({@link makeDirectory}).
127
+ * Creates it, and missing parents (the base directory, `sessions/`), with
128
+ * mode 0700. Safe to call multiple times (idempotent). A path that cannot
129
+ * hold a directory is refused before `mkdir`, which would spin on a
130
+ * pseudo-filesystem ({@link makeDirectory}).
129
131
  *
130
132
  * @throws Error if the directory cannot be created
131
133
  */
132
134
  export declare function ensureSessionDir(): void;
135
+ /** A session directory (or one above it) that cannot be trusted */
136
+ export interface UntrustedSessionDir {
137
+ /** The untrusted directory */
138
+ dir: string;
139
+ /** Why, e.g. `writable by others (mode 777)` */
140
+ reason: string;
141
+ kind: DirTrustKind;
142
+ /** bdg owns it by convention (`~/.bdg`, `sessions/`, `sessions/<name>`) */
143
+ bdgOwned: boolean;
144
+ }
145
+ /**
146
+ * Check that a session directory and the directories above it up to the base
147
+ * directory can be trusted before a daemon is started there or its socket is
148
+ * connected to: another user who can write to one of them could replace the
149
+ * socket (and receive every command) or plant files. Each existing directory
150
+ * must be a real directory (not a symlink), owned by the user, and not
151
+ * writable by others ({@link dirTrustProblem}); group write is accepted, since
152
+ * under umask 002 (per-user groups) older versions created `~/.bdg` 0775.
153
+ * The base directory may be a symlink whose target passes the same rule
154
+ * ({@link chainDirProblem}). A directory owned by another uid (a bind mount,
155
+ * `sudo -E`) is refused.
156
+ *
157
+ * Trusted directories bdg owns (the default `~/.bdg`, `sessions/` and named
158
+ * session directories) that group or others can still use are tightened to
159
+ * 0700; a base directory chosen with `$BDG_SESSION_DIR`, or a symlinked base
160
+ * (opened without following links), is never changed.
161
+ * Missing paths are skipped (as is a path through a file, which fails on its
162
+ * own): {@link ensureSessionDir} creates them 0700.
163
+ *
164
+ * @param dir - Session directory (default: the selected session's)
165
+ * @returns The first untrusted directory and why, or null
166
+ */
167
+ export declare function secureSessionDir(dir?: string): UntrustedSessionDir | null;
133
168
  export {};
134
169
  //# sourceMappingURL=paths.d.ts.map