dsh-builtin-browser 0.4.2 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +1699 -0
  2. package/README.en.md +63 -20
  3. package/README.md +60 -20
  4. package/client.js +418 -24
  5. package/desktop-bridge/install.mjs +66 -6
  6. package/desktop-bridge/plugin-browser-bridge.js +414 -163
  7. package/docs/architecture.md +4 -4
  8. package/docs/tool-reference.md +3 -3
  9. package/docs/user-guide.md +50 -2
  10. package/lib/browser/types.d.ts +9 -2
  11. package/lib/browser-electron/bridge-connection.d.ts +18 -21
  12. package/lib/browser-electron/bridge-connection.js +42 -1
  13. package/lib/browser-electron/desktop-bridge-host.d.ts +69 -36
  14. package/lib/browser-electron/desktop-bridge-host.js +151 -86
  15. package/lib/browser-electron/entry.js +73 -6
  16. package/lib/browser-electron/history-store.d.ts +11 -2
  17. package/lib/browser-electron/history-store.js +27 -5
  18. package/lib/browser-electron/host-main.js +11 -1
  19. package/lib/browser-electron/provider.d.ts +62 -2
  20. package/lib/browser-electron/provider.js +28 -12
  21. package/lib/browser-electron/remote-host.js +19 -6
  22. package/lib/browser-electron/rpc-ops.d.ts +18 -0
  23. package/lib/browser-electron/rpc-ops.js +36 -0
  24. package/lib/browser-electron/settings-route.d.ts +6 -6
  25. package/lib/browser-electron/settings-route.js +48 -8
  26. package/lib/browser-electron/settings-store.js +57 -16
  27. package/lib/browser-electron/system-browser.d.ts +31 -9
  28. package/lib/browser-electron/system-browser.js +116 -27
  29. package/lib/index.js +0 -1
  30. package/lib/tool-browser/element-fields.d.ts +145 -0
  31. package/lib/tool-browser/element-fields.js +152 -0
  32. package/lib/tool-browser/index.js +26 -60
  33. package/package.json +6 -2
  34. package/src/browser/types.ts +9 -2
  35. package/src/browser-electron/bridge-connection.ts +37 -1
  36. package/src/browser-electron/desktop-bridge-host.ts +146 -84
  37. package/src/browser-electron/entry.ts +75 -7
  38. package/src/browser-electron/history-store.ts +32 -7
  39. package/src/browser-electron/host-main.ts +10 -1
  40. package/src/browser-electron/provider.ts +91 -18
  41. package/src/browser-electron/remote-host.ts +19 -6
  42. package/src/browser-electron/rpc-ops.ts +41 -0
  43. package/src/browser-electron/settings-route.ts +54 -13
  44. package/src/browser-electron/settings-store.ts +56 -17
  45. package/src/browser-electron/system-browser.ts +121 -28
  46. package/src/index.ts +3 -2
  47. package/src/tool-browser/element-fields.ts +167 -0
  48. package/src/tool-browser/index.ts +31 -60
@@ -136,20 +136,42 @@ const SETTINGS_WHEN_UNREADABLE: BrowserSettings = {
136
136
  * @returns a valid channel.
137
137
  */
138
138
  function resolveChannel(value: unknown): BrowserChannel {
139
- return value === 'chrome' || value === 'edge' || value === 'auto' || value === 'bundled'
139
+ return value === 'chrome' || value === 'edge' || value === 'auto' || value === 'bundled' || value === 'sidebar'
140
140
  ? value
141
141
  : DEFAULT_SETTINGS.browser.channel
142
142
  }
143
143
 
144
144
  /** Coerce one positive-integer field, keeping the default when invalid. */
145
145
  function count(value: unknown, fallback: number): number {
146
- return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback
146
+ if (typeof value !== 'number' || !Number.isFinite(value)) return fallback
147
+ const floored = Math.floor(value)
148
+ // A fractional value below 1 floors to 0, and 0 is not "very few" in either place these are
149
+ // used: as a history entry cap it means `slice(len - 0)`, i.e. keep nothing, so a hand-typed
150
+ // `0.5` silently wiped the history file on the next rewrite. Anything that survives the
151
+ // floor has to still be a usable limit.
152
+ return floored > 0 ? floored : fallback
147
153
  }
148
154
 
149
155
  /** Read one nested section of an unknown document, or an empty object. */
150
- function section(source: Record<string, unknown>, key: string): Record<string, unknown> {
156
+ /**
157
+ * Read one section of the document, distinguishing ABSENT from UNREADABLE.
158
+ *
159
+ * Absent (the key is not there at all) is a document written before the section existed, and
160
+ * the defaults apply — that is the documented behaviour. Present-but-not-an-object
161
+ * (`"actions": false`, `null` from a truncated write, a stray string) is a document this code
162
+ * cannot read, and returning `{}` for it made every gate inside look absent, so each one took
163
+ * its permissive default. A whole section mistyped is exactly the shape a careless hand edit
164
+ * produces, and it opened every capability the section was there to bound.
165
+ * @param source - the parsed document.
166
+ * @param key - the section name.
167
+ * @returns the section, or undefined when it is present but not an object.
168
+ */
169
+ function section(source: Record<string, unknown>, key: string): Record<string, unknown> | undefined {
151
170
  const value = source[key]
152
- return typeof value === 'object' && value !== null ? value as Record<string, unknown> : {}
171
+ if (value === undefined) return {}
172
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
173
+ ? value as Record<string, unknown>
174
+ : undefined
153
175
  }
154
176
 
155
177
  /**
@@ -168,25 +190,42 @@ export function resolveSettings(raw: unknown): BrowserSettings {
168
190
  const credentials = section(source, 'credentials')
169
191
  const browser = section(source, 'browser')
170
192
  const actions = section(source, 'actions')
193
+ // A section that is present but unreadable refuses its gates rather than defaulting them
194
+ // open. The rest of a broken section keeps its defaults: refusing history or presentation
195
+ // would punish the user for a bad file without protecting anything.
196
+ const credentialsUsable = credentials ?? {}
197
+ const actionsUsable = actions ?? {}
171
198
  return {
172
199
  history: {
173
- enabled: bool(history.enabled, DEFAULT_SETTINGS.history.enabled),
174
- maxEntries: count(history.maxEntries, DEFAULT_SETTINGS.history.maxEntries),
175
- maxAgeDays: count(history.maxAgeDays, DEFAULT_SETTINGS.history.maxAgeDays),
200
+ enabled: bool(history?.enabled, DEFAULT_SETTINGS.history.enabled),
201
+ maxEntries: count(history?.maxEntries, DEFAULT_SETTINGS.history.maxEntries),
202
+ maxAgeDays: count(history?.maxAgeDays, DEFAULT_SETTINGS.history.maxAgeDays),
176
203
  },
177
- cookies: { persist: bool(cookies.persist, DEFAULT_SETTINGS.cookies.persist) },
204
+ cookies: { persist: bool(cookies?.persist, DEFAULT_SETTINGS.cookies.persist) },
178
205
  ui: {
179
- autoExpandOnce: bool(ui.autoExpandOnce, DEFAULT_SETTINGS.ui.autoExpandOnce),
180
- closeWithSession: bool(ui.closeWithSession, DEFAULT_SETTINGS.ui.closeWithSession),
181
- virtualCursor: bool(ui.virtualCursor, DEFAULT_SETTINGS.ui.virtualCursor),
206
+ autoExpandOnce: bool(ui?.autoExpandOnce, DEFAULT_SETTINGS.ui.autoExpandOnce),
207
+ closeWithSession: bool(ui?.closeWithSession, DEFAULT_SETTINGS.ui.closeWithSession),
208
+ virtualCursor: bool(ui?.virtualCursor, DEFAULT_SETTINGS.ui.virtualCursor),
209
+ },
210
+ vision: { strategy: vision?.strategy === 'nonVisual' ? 'nonVisual' : DEFAULT_SETTINGS.vision.strategy },
211
+ browser: { channel: resolveChannel(browser?.channel) },
212
+ credentials: {
213
+ // `gate` returns false for anything present-and-not-a-boolean, so an unreadable section
214
+ // is refused here for free: the section itself is the "present but wrong" value.
215
+ allowRead: credentials === undefined
216
+ ? false
217
+ : gate(credentialsUsable.allowRead, DEFAULT_SETTINGS.credentials.allowRead),
182
218
  },
183
- vision: { strategy: vision.strategy === 'nonVisual' ? 'nonVisual' : DEFAULT_SETTINGS.vision.strategy },
184
- browser: { channel: resolveChannel(browser.channel) },
185
- credentials: { allowRead: gate(credentials.allowRead, DEFAULT_SETTINGS.credentials.allowRead) },
186
219
  actions: {
187
- allowExecute: gate(actions.allowExecute, DEFAULT_SETTINGS.actions.allowExecute),
188
- allowDownload: gate(actions.allowDownload, DEFAULT_SETTINGS.actions.allowDownload),
189
- allowCredentialWrite: gate(actions.allowCredentialWrite, DEFAULT_SETTINGS.actions.allowCredentialWrite),
220
+ allowExecute: actions === undefined
221
+ ? false
222
+ : gate(actionsUsable.allowExecute, DEFAULT_SETTINGS.actions.allowExecute),
223
+ allowDownload: actions === undefined
224
+ ? false
225
+ : gate(actionsUsable.allowDownload, DEFAULT_SETTINGS.actions.allowDownload),
226
+ allowCredentialWrite: actions === undefined
227
+ ? false
228
+ : gate(actionsUsable.allowCredentialWrite, DEFAULT_SETTINGS.actions.allowCredentialWrite),
190
229
  },
191
230
  }
192
231
  }
@@ -39,6 +39,23 @@ import { join } from 'node:path'
39
39
  */
40
40
  const EXIT_GRACE_MS = 3_000
41
41
 
42
+ /**
43
+ * Ceiling on one attempt to ask a discovered CDP port who it is.
44
+ *
45
+ * The launch loop below polls: it reads `DevToolsActivePort`, tries `/json/version` on the port
46
+ * inside it, and waits 250 ms between attempts under a 30 s deadline. An unbounded fetch can
47
+ * park inside one iteration for the whole budget, so a bound is needed — but it has to be
48
+ * small enough that the loop still behaves like a loop.
49
+ *
50
+ * The usual case is NOT a slow answer, it is NO answer: a browser that has written the file but
51
+ * is not listening yet refuses the connection instantly, and that immediacy is what lets the
52
+ * loop spin. Any timeout added here is paid on every iteration in which the port is not ready,
53
+ * so an earlier attempt at this used 2000 ms — eight times the poll interval — and cut the
54
+ * attempts available in the budget from roughly 120 to 15. This value is the same size as the
55
+ * interval, so a worst-case iteration costs 500 ms and the budget still affords ~60 attempts.
56
+ */
57
+ const PORT_PROBE_MS = 250
58
+
42
59
  /**
43
60
  * Starts a browser process and returns a handle to it.
44
61
  *
@@ -58,7 +75,7 @@ import { randomUUID } from 'node:crypto'
58
75
  import type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.js'
59
76
 
60
77
  /** Which browser the user asked for. */
61
- export type BrowserChannel = 'bundled' | 'chrome' | 'edge' | 'auto'
78
+ export type BrowserChannel = 'bundled' | 'chrome' | 'edge' | 'auto' | 'sidebar'
62
79
 
63
80
  /** A resolved browser installation. */
64
81
  export interface DetectedBrowser {
@@ -475,6 +492,19 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
475
492
  }
476
493
  }
477
494
 
495
+ /**
496
+ * Forget which browser issued what, after that browser is gone.
497
+ *
498
+ * The two maps must move together: `views` maps a view id to a session id and `sessions`
499
+ * maps a session id to a target id, so clearing one alone leaves a mapping the next command
500
+ * will act on. Every path that abandons a browser calls this rather than spelling the pair
501
+ * out — four separate copies is how the third one came to be missing.
502
+ */
503
+ private forgetBrowserState(): void {
504
+ this.views.clear()
505
+ this.sessions.clear()
506
+ }
507
+
478
508
  private async ensureClient(): Promise<CdpClient> {
479
509
  // A disposed host must never start a browser: doing so would spawn a process
480
510
  // nobody owns and nobody will kill.
@@ -501,8 +531,7 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
501
531
  // child left to kill: the process already exited, its exit listener already set
502
532
  // `this.child` to undefined, so start() skips that branch and publishes a new browser
503
533
  // while the views still point at the dead one. That is the state that never recovers.
504
- this.views.clear()
505
- this.sessions.clear()
534
+ this.forgetBrowserState()
506
535
  }
507
536
  // Concurrent first calls share one startup rather than racing two browsers.
508
537
  this.starting ??= this.start().finally(() => { this.starting = undefined })
@@ -545,8 +574,7 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
545
574
  // heard of them — the "Session with given id not found" loop, which never clears.
546
575
  this.client?.close()
547
576
  this.client = undefined
548
- this.views.clear()
549
- this.sessions.clear()
577
+ this.forgetBrowserState()
550
578
 
551
579
  // Wait for it to actually die. kill() only sends the signal: on Windows it is
552
580
  // TerminateProcess, which is asynchronous, and the browser may still hold this profile's
@@ -607,8 +635,7 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
607
635
  // The pages this client opened belong to the process that just died; keeping their
608
636
  // session ids means every later command is sent to a browser that never issued them.
609
637
  // Clearing these is what makes the restart usable rather than merely alive.
610
- this.views.clear()
611
- this.sessions.clear()
638
+ this.forgetBrowserState()
612
639
  })
613
640
  const portFile = join(this.profileDir, 'DevToolsActivePort')
614
641
  /**
@@ -645,15 +672,29 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
645
672
  if (this.disposed) { child.kill(); if (this.child === child) this.child = undefined; stopped = 'released'; break }
646
673
  if (launchError !== undefined) { if (this.child === child) this.child = undefined; stopped = 'launch-failed'; break }
647
674
  if (Date.now() > deadline) { child.kill(); if (this.child === child) this.child = undefined; stopped = 'timeout'; break }
648
- if (child.exitCode !== null) { if (this.child === child) this.child = undefined; stopped = 'exited'; break }
675
+ // signalCode too: a browser killed by a signal never sets exitCode, so checking exitCode
676
+ // alone made a killed browser look alive and the launch run out its full 30s deadline
677
+ // before reporting "did not expose CDP" — a timeout for something that died at once.
678
+ // The sibling check on the release path already looked at both.
679
+ if (child.exitCode !== null || child.signalCode !== null) { if (this.child === child) this.child = undefined; stopped = 'exited'; break }
649
680
  // Only a port THIS launch wrote: reading whatever file happened to be there is how a
650
681
  // restart could adopt the previous browser, or any process now holding that port.
651
682
  const port = freshPort()
652
683
  if (port !== undefined) {
653
684
  try {
654
- const version = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json() as { webSocketDebuggerUrl?: string }
685
+ const version = await (await fetch(`http://127.0.0.1:${port}/json/version`, {
686
+ // Bounded: an unanswered loopback request would otherwise park inside this
687
+ // iteration for the rest of the budget, and the deadline above is only re-checked
688
+ // between iterations. Deliberately no larger than the 250 ms sleep — see
689
+ // PORT_PROBE_MS.
690
+ signal: AbortSignal.timeout(PORT_PROBE_MS),
691
+ })).json() as { webSocketDebuggerUrl?: string }
655
692
  if (typeof version.webSocketDebuggerUrl === 'string') {
656
693
  const client = new CdpClient(version.webSocketDebuggerUrl)
694
+ // `whenReady` has its own 30 s ceiling, and it is reached only after the port
695
+ // answered above — so the browser is listening and this is a handshake, not a
696
+ // discovery. A handshake that outlives the launch is reported as the timeout it
697
+ // is, by the same deadline that governs everything else here.
657
698
  await client.whenReady()
658
699
  // Still wanted? A dispose() during the handshake has already killed the
659
700
  // process, so publishing the client would leave a dangling connection.
@@ -675,15 +716,23 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
675
716
  throw new Error(`dsh-builtin-browser: could not launch ${target}: ${launchError?.message ?? 'unknown error'}`)
676
717
  }
677
718
  if (stopped === 'exited') {
678
- // Exit code 0 is the whole story here: the browser decided to hand the request to an
679
- // instance that already owns this profile directory and quit, rather than failing. That is
680
- // what a leftover browser from a previous run (or a second DSH on the same profile) looks
681
- // like. "Check that the path is a runnable browser" pointed at the one cause that is almost
682
- // never it, and sent people looking for a broken install that was not there.
719
+ // Only exit code 0 is the hand-off story. A non-zero code or a signal means the browser
720
+ // died on its own — a crash, a missing library, a sandbox refusal — and asserting the
721
+ // profile story for those sent people looking for a leftover process that was not there,
722
+ // which is the same mistake this message was written to correct, pointing the other way.
723
+ const code = child.exitCode
724
+ const signal = child.signalCode
725
+ const clean = code === 0 && signal === null
683
726
  throw new Error(
684
- `dsh-builtin-browser: ${target} exited immediately after launch (exit code ${child.exitCode}). `
685
- + 'That exit code means it handed the request to an instance already using this profile '
686
- + 'directory and quit — look for a leftover browser process from an earlier run, then retry.',
727
+ clean
728
+ ? `dsh-builtin-browser: ${target} exited immediately after launch (exit code 0). `
729
+ + 'That means it handed the request to an instance already using this profile '
730
+ + 'directory and quit — look for a leftover browser process from an earlier run, then retry.'
731
+ : `dsh-builtin-browser: ${target} exited immediately after launch `
732
+ + `(${signal !== null ? `killed by ${signal}` : `exit code ${String(code)}`}), so it did not `
733
+ + 'hand the request anywhere — the browser failed to start rather than deferring to another '
734
+ + 'instance. Check that it can run at all: run it by hand, and look for a crash or a missing '
735
+ + 'dependency rather than a leftover process.',
687
736
  )
688
737
  }
689
738
  if (stopped === 'released') {
@@ -750,7 +799,17 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
750
799
  if (!client.isAlive()) {
751
800
  // Only THIS call's client. Under concurrency the field may already hold a
752
801
  // connection another call just published, and closing that would kill a healthy one.
753
- if (this.client === client) this.client = undefined
802
+ if (this.client === client) {
803
+ this.client = undefined
804
+ // This is the third of the three paths that discard a client, and it was the one
805
+ // that did not retire what the client had issued. A view holding a session id
806
+ // from the dead browser kept handing it to the replacement, which had never heard
807
+ // of it: every page command then failed with "Session with given id not found"
808
+ // and no later call could clear it, because the cached session was returned
809
+ // unconditionally. Gated on the same identity check as the field itself, so a
810
+ // concurrent call that just published a healthy client does not lose its maps.
811
+ this.forgetBrowserState()
812
+ }
754
813
  client.close()
755
814
  }
756
815
  throw error
@@ -778,11 +837,32 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
778
837
  /** Nothing to show: the browser owns its own windows. */
779
838
  showView(): void {}
780
839
 
781
- /** Same as {@link showView}. */
782
- async presentView(): Promise<void> {}
840
+ /**
841
+ * Bring the target's page to the front, then report success.
842
+ *
843
+ * The comment on `focus` below used to say this was impossible — that raising the page needs
844
+ * `Page.bringToFront` on a target session, which is a view-level concern the host cannot
845
+ * reach. The handle this method is handed IS that view, and it can send commands, so the
846
+ * capability was here all along; it just was not used. Without it `presentView` was an empty
847
+ * async method, so the provider's whole presentation barrier — the one that stops
848
+ * `Input.*` from being silently dropped for a view with no display surface, and reports
849
+ * `BROWSER_VIEW_NOT_PRESENTED` when it cannot present — never ran on this carrier.
850
+ *
851
+ * A failure is swallowed rather than propagated: a real Chrome window that refuses to come
852
+ * forward is not a reason to refuse the click that follows. The barrier exists to order the
853
+ * two, and the order is honoured either way.
854
+ * @param handle - the view to present.
855
+ */
856
+ async presentView(handle: ElectronViewHandle): Promise<void> {
857
+ try {
858
+ await handle.sendCommand('Page.bringToFront')
859
+ } catch {
860
+ // Backgrounded, minimised, or a target that went away between the snapshot and the
861
+ // click. The command that called this will report its own failure if the page is gone.
862
+ }
863
+ }
864
+
783
865
 
784
- /** The browser owns window grouping. */
785
- groupView(): void {}
786
866
 
787
867
  /**
788
868
  * Best effort at bringing the browser's window forward.
@@ -800,11 +880,7 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
800
880
  try { this.child?.kill('SIGCONT') } catch { /* Windows has no SIGCONT; nothing to do */ }
801
881
  }
802
882
 
803
- /** The browser reports its own window lifecycle. */
804
- onUserAction(): void {}
805
883
 
806
- /** The browser reports its own window lifecycle. */
807
- onViewClosed(): void {}
808
884
 
809
885
  /**
810
886
  * Close the browser we launched, if we ever launched one.
@@ -816,8 +892,7 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
816
892
  dispose(): void {
817
893
  if (this.disposed) return
818
894
  this.disposed = true
819
- this.views.clear()
820
- this.sessions.clear()
895
+ this.forgetBrowserState()
821
896
  this.client?.close()
822
897
  try { this.child?.kill() } catch { /* already gone */ }
823
898
  // And anything that ignored an earlier kill. They hold this profile, so leaving them
@@ -854,6 +929,21 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
854
929
  * a second copy of that rule.
855
930
  */
856
931
  private async ensureSession(viewId: string): Promise<string> {
932
+ // One target per view, even when two commands arrive together. The two CDP round trips
933
+ // below are awaits, so without this a second caller entering during the first's flight saw
934
+ // no session in `views` and created its own target — leaving an orphan page that nothing
935
+ // closes, because only the last id survives in the map.
936
+ const inFlight = this.sessionBuilding.get(viewId)
937
+ if (inFlight !== undefined) return await inFlight
938
+ const building = this.buildSession(viewId).finally(() => {
939
+ this.sessionBuilding.delete(viewId)
940
+ })
941
+ this.sessionBuilding.set(viewId, building)
942
+ return await building
943
+ }
944
+
945
+ /** Create and attach the target for one view. Serialised by {@link ensureSession}. */
946
+ private async buildSession(viewId: string): Promise<string> {
857
947
  const client = await this.ensureClient()
858
948
  const created = await client.send('Target.createTarget', { url: 'about:blank' }) as { targetId?: string }
859
949
  const targetId = created?.targetId
@@ -867,6 +957,9 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
867
957
  return sessionId
868
958
  }
869
959
 
960
+ /** Views whose session is being created right now, so a second caller waits for the first. */
961
+ private readonly sessionBuilding = new Map<string, Promise<string>>()
962
+
870
963
  /** sessionId -> targetId, so a destroyed view can close the right page. */
871
964
  private readonly sessions = new Map<string, string>()
872
965
  }
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- /**
1
+ /**
2
2
  * dsh-builtin-browser plugin entry: aggregates the shared-browser capability
3
3
  * pieces. The cordis.patch.yml rows reference subpath exports:
4
4
  * - `dsh-builtin-browser/browser` -> the ctx.browser seam (Service)
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  /** Plugin identity for the root row (the composition surface is the subpath rows). */
17
+
17
18
  export const name = 'dsh-builtin-browser'
18
19
 
19
20
  /**
@@ -24,9 +25,9 @@ export const name = 'dsh-builtin-browser'
24
25
  * never reaches the browser (the plugin works, its client half is invisible).
25
26
  */
26
27
  export function apply(): void {
27
- // Intentionally empty: see the module comment above.
28
28
  }
29
29
 
30
+
30
31
  export { BrowserError } from './browser/types.js'
31
32
  export type {
32
33
  BrowserA11yNode,
@@ -0,0 +1,167 @@
1
+ /**
2
+ * The element and node field sets, declared once.
3
+ *
4
+ * WHY THIS FILE EXISTS
5
+ *
6
+ * The same field set used to be written out in four places: the page script produced an
7
+ * object, `types.ts` declared its shape, a tool's `execute` rebuilt it as an explicit list,
8
+ * and the tool's `output.schema` declared the properties. Nothing connected them, and the
9
+ * result is this project's most repeatable bug:
10
+ *
11
+ * - `browser_a11y`'s `selector` — computed by the provider, declared by the schema,
12
+ * printed by the renderer, and dropped by the projection. 134 tests stayed green.
13
+ * - `browser_screenshot`'s `width`/`height` — measured by the provider, declared by the
14
+ * schema, and dropped by the projection. 172 tests stayed green. It was found a round
15
+ * later, by reading the three places that agree and noticing the fourth did not.
16
+ *
17
+ * A hand-written allow-list is invisible to the type checker (it is an object literal) and to
18
+ * the tests (nothing ran a tool). The fix is not a careful list; it is one list. Both the
19
+ * schema fragment and the projection are derived from the tables below, so a field cannot be
20
+ * present in one and absent from the other.
21
+ *
22
+ * The schema is the dangerous half: `additionalProperties: false` means a field the schema
23
+ * does NOT declare causes the ENTIRE result to be rejected at run time, while a field the
24
+ * projection forgets is silently dropped. One table cannot get either wrong on its own.
25
+ */
26
+
27
+ /** A single declared field: how the schema describes it, and whether callers always get it. */
28
+ interface FieldSpec {
29
+ /** The JSON Schema fragment for this property. */
30
+ readonly schema: Record<string, unknown>
31
+ /** True when the value is always present, so both the schema and the result promise it. */
32
+ readonly required?: boolean
33
+ }
34
+
35
+ /**
36
+ * The fields of one entry in `browser_snapshot` / `browser_open`'s `elements`.
37
+ *
38
+ * `selector` is the reason this file exists; see the header. It was added to the schema and
39
+ * the projection separately, and only one of them landed.
40
+ */
41
+ const ELEMENT_FIELDS = {
42
+ ref: { schema: { type: 'number' }, required: true },
43
+ kind: { schema: { type: 'string' }, required: true },
44
+ label: { schema: { type: 'string' }, required: true },
45
+ selector: { schema: { type: 'string' } },
46
+ x: { schema: { type: 'number' }, required: true },
47
+ y: { schema: { type: 'number' }, required: true },
48
+ frame: { schema: { type: 'boolean' } },
49
+ } as const satisfies Record<string, FieldSpec>
50
+
51
+ /**
52
+ * The fields of one entry in `browser_a11y`'s `nodes`.
53
+ *
54
+ * Different shape from an element on purpose: a role and an accessible name carry meaning a
55
+ * tag and a label string do not, and `depth` is what renders the tree.
56
+ */
57
+ const NODE_FIELDS = {
58
+ ref: { schema: { type: 'number' }, required: true },
59
+ role: { schema: { type: 'string' }, required: true },
60
+ name: { schema: { type: 'string' }, required: true },
61
+ value: { schema: { type: 'string' } },
62
+ states: { schema: { type: 'array', items: { type: 'string' } }, required: true },
63
+ depth: { schema: { type: 'number' }, required: true },
64
+ tag: { schema: { type: 'string' }, required: true },
65
+ // Always present: the provider derives it for every node and yields an empty string when the
66
+ // element has neither an id nor a name. Declaring it optional described a shape the provider
67
+ // never produced, and the type said required — the same disagreement, one level down.
68
+ selector: { schema: { type: 'string' }, required: true },
69
+ x: { schema: { type: 'number' }, required: true },
70
+ y: { schema: { type: 'number' }, required: true },
71
+ frame: { schema: { type: 'boolean' } },
72
+ } as const satisfies Record<string, FieldSpec>
73
+
74
+ /**
75
+ * Copy the declared fields out of a source value.
76
+ *
77
+ * Reads the table rather than naming fields, so anything the provider produces that the table
78
+ * declares is carried through, and anything the table does not declare is left out — which is
79
+ * what keeps the result and the schema in agreement in both directions.
80
+ * @param fields - the field table to project through.
81
+ * @param source - the value the provider produced.
82
+ * @returns a new object holding exactly the declared fields that are present.
83
+ */
84
+ function project(fields: Record<string, FieldSpec>, source: Record<string, unknown>): Record<string, unknown> {
85
+ const out: Record<string, unknown> = {}
86
+ for (const name of Object.keys(fields)) {
87
+ const value = source[name]
88
+ if (value !== undefined && value !== null) out[name] = value
89
+ }
90
+ return out
91
+ }
92
+
93
+ /**
94
+ * The `elements` item schema for browser_snapshot and browser_open.
95
+ *
96
+ * Written as a literal rather than generated, because `defineTool` validates the output schema
97
+ * against its own `ValueSchemaSpec` and a generated `Record<string, …>` is too wide to satisfy
98
+ * it. The field table above remains the single source of truth: `tests/element-fields.test.mjs`
99
+ * asserts that these properties and `projectElement`'s output are exactly the table's keys, so
100
+ * the two cannot drift even though one is typed by hand.
101
+ */
102
+ export const ELEMENT_ITEM_SCHEMA = {
103
+ type: 'object',
104
+ additionalProperties: false,
105
+ properties: {
106
+ ref: { type: 'number', required: true },
107
+ kind: { type: 'string', required: true },
108
+ label: { type: 'string', required: true },
109
+ selector: { type: 'string' },
110
+ x: { type: 'number', required: true },
111
+ y: { type: 'number', required: true },
112
+ frame: { type: 'boolean' },
113
+ },
114
+ } as const
115
+
116
+ /**
117
+ * The `nodes` item schema for browser_a11y.
118
+ *
119
+ * Same reasoning as {@link ELEMENT_ITEM_SCHEMA}: literal for the type checker, table-backed by
120
+ * test.
121
+ */
122
+ export const NODE_ITEM_SCHEMA = {
123
+ type: 'object',
124
+ additionalProperties: false,
125
+ properties: {
126
+ ref: { type: 'number', required: true },
127
+ role: { type: 'string', required: true },
128
+ name: { type: 'string', required: true },
129
+ value: { type: 'string' },
130
+ states: { type: 'array', required: true, items: { type: 'string' } },
131
+ depth: { type: 'number', required: true },
132
+ tag: { type: 'string', required: true },
133
+ selector: { type: 'string', required: true },
134
+ x: { type: 'number', required: true },
135
+ y: { type: 'number', required: true },
136
+ frame: { type: 'boolean' },
137
+ },
138
+ } as const
139
+
140
+ /**
141
+ * Project one snapshot element into the shape the schema declares.
142
+ *
143
+ * Generic in the source type so the caller keeps the precise shape `defineTool` inferred from
144
+ * the schema: the projection decides which fields survive at run time, and the field table is
145
+ * what guarantees that set matches the schema. Widening the return type here would only move
146
+ * the mismatch to the call site.
147
+ * @param element - the element as the provider produced it.
148
+ * @returns exactly the declared fields.
149
+ */
150
+ export function projectElement<T extends object>(element: T): T {
151
+ return project(ELEMENT_FIELDS as Record<string, FieldSpec>, element as Record<string, unknown>) as T
152
+ }
153
+
154
+ /**
155
+ * Project one accessibility node into the shape the schema declares.
156
+ * @param node - the node as the provider produced it.
157
+ * @returns exactly the declared fields.
158
+ */
159
+ export function projectNode<T extends object>(node: T): T {
160
+ return project(NODE_FIELDS as Record<string, FieldSpec>, node as Record<string, unknown>) as T
161
+ }
162
+
163
+ /** The field names each table declares, for tests that check the two stay in step. */
164
+ export const DECLARED_FIELDS = {
165
+ element: Object.keys(ELEMENT_FIELDS),
166
+ node: Object.keys(NODE_FIELDS),
167
+ } as const