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
@@ -36,6 +36,22 @@ import { join } from 'node:path';
36
36
  * exited, and that exit was reported as a bad browser path.
37
37
  */
38
38
  const EXIT_GRACE_MS = 3_000;
39
+ /**
40
+ * Ceiling on one attempt to ask a discovered CDP port who it is.
41
+ *
42
+ * The launch loop below polls: it reads `DevToolsActivePort`, tries `/json/version` on the port
43
+ * inside it, and waits 250 ms between attempts under a 30 s deadline. An unbounded fetch can
44
+ * park inside one iteration for the whole budget, so a bound is needed — but it has to be
45
+ * small enough that the loop still behaves like a loop.
46
+ *
47
+ * The usual case is NOT a slow answer, it is NO answer: a browser that has written the file but
48
+ * is not listening yet refuses the connection instantly, and that immediacy is what lets the
49
+ * loop spin. Any timeout added here is paid on every iteration in which the port is not ready,
50
+ * so an earlier attempt at this used 2000 ms — eight times the poll interval — and cut the
51
+ * attempts available in the budget from roughly 120 to 15. This value is the same size as the
52
+ * interval, so a worst-case iteration costs 500 ms and the budget still affords ~60 attempts.
53
+ */
54
+ const PORT_PROBE_MS = 250;
39
55
  /** The real launcher: spawn a detached-free child with pipes closed. */
40
56
  const defaultLauncher = (path, args) => spawn(path, [...args], { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: false });
41
57
  import { spawn } from 'node:child_process';
@@ -441,6 +457,18 @@ export class SystemBrowserViewHost {
441
457
  return false;
442
458
  }
443
459
  }
460
+ /**
461
+ * Forget which browser issued what, after that browser is gone.
462
+ *
463
+ * The two maps must move together: `views` maps a view id to a session id and `sessions`
464
+ * maps a session id to a target id, so clearing one alone leaves a mapping the next command
465
+ * will act on. Every path that abandons a browser calls this rather than spelling the pair
466
+ * out — four separate copies is how the third one came to be missing.
467
+ */
468
+ forgetBrowserState() {
469
+ this.views.clear();
470
+ this.sessions.clear();
471
+ }
444
472
  async ensureClient() {
445
473
  // A disposed host must never start a browser: doing so would spawn a process
446
474
  // nobody owns and nobody will kill.
@@ -469,8 +497,7 @@ export class SystemBrowserViewHost {
469
497
  // child left to kill: the process already exited, its exit listener already set
470
498
  // `this.child` to undefined, so start() skips that branch and publishes a new browser
471
499
  // while the views still point at the dead one. That is the state that never recovers.
472
- this.views.clear();
473
- this.sessions.clear();
500
+ this.forgetBrowserState();
474
501
  }
475
502
  // Concurrent first calls share one startup rather than racing two browsers.
476
503
  this.starting ??= this.start().finally(() => { this.starting = undefined; });
@@ -514,8 +541,7 @@ export class SystemBrowserViewHost {
514
541
  // heard of them — the "Session with given id not found" loop, which never clears.
515
542
  this.client?.close();
516
543
  this.client = undefined;
517
- this.views.clear();
518
- this.sessions.clear();
544
+ this.forgetBrowserState();
519
545
  // Wait for it to actually die. kill() only sends the signal: on Windows it is
520
546
  // TerminateProcess, which is asynchronous, and the browser may still hold this profile's
521
547
  // singleton lock for a while. Spawning immediately meant the replacement saw an existing
@@ -585,8 +611,7 @@ export class SystemBrowserViewHost {
585
611
  // The pages this client opened belong to the process that just died; keeping their
586
612
  // session ids means every later command is sent to a browser that never issued them.
587
613
  // Clearing these is what makes the restart usable rather than merely alive.
588
- this.views.clear();
589
- this.sessions.clear();
614
+ this.forgetBrowserState();
590
615
  });
591
616
  const portFile = join(this.profileDir, 'DevToolsActivePort');
592
617
  /**
@@ -643,7 +668,11 @@ export class SystemBrowserViewHost {
643
668
  stopped = 'timeout';
644
669
  break;
645
670
  }
646
- if (child.exitCode !== null) {
671
+ // signalCode too: a browser killed by a signal never sets exitCode, so checking exitCode
672
+ // alone made a killed browser look alive and the launch run out its full 30s deadline
673
+ // before reporting "did not expose CDP" — a timeout for something that died at once.
674
+ // The sibling check on the release path already looked at both.
675
+ if (child.exitCode !== null || child.signalCode !== null) {
647
676
  if (this.child === child)
648
677
  this.child = undefined;
649
678
  stopped = 'exited';
@@ -654,9 +683,19 @@ export class SystemBrowserViewHost {
654
683
  const port = freshPort();
655
684
  if (port !== undefined) {
656
685
  try {
657
- const version = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json();
686
+ const version = await (await fetch(`http://127.0.0.1:${port}/json/version`, {
687
+ // Bounded: an unanswered loopback request would otherwise park inside this
688
+ // iteration for the rest of the budget, and the deadline above is only re-checked
689
+ // between iterations. Deliberately no larger than the 250 ms sleep — see
690
+ // PORT_PROBE_MS.
691
+ signal: AbortSignal.timeout(PORT_PROBE_MS),
692
+ })).json();
658
693
  if (typeof version.webSocketDebuggerUrl === 'string') {
659
694
  const client = new CdpClient(version.webSocketDebuggerUrl);
695
+ // `whenReady` has its own 30 s ceiling, and it is reached only after the port
696
+ // answered above — so the browser is listening and this is a handshake, not a
697
+ // discovery. A handshake that outlives the launch is reported as the timeout it
698
+ // is, by the same deadline that governs everything else here.
660
699
  await client.whenReady();
661
700
  // Still wanted? A dispose() during the handshake has already killed the
662
701
  // process, so publishing the client would leave a dangling connection.
@@ -682,14 +721,22 @@ export class SystemBrowserViewHost {
682
721
  throw new Error(`dsh-builtin-browser: could not launch ${target}: ${launchError?.message ?? 'unknown error'}`);
683
722
  }
684
723
  if (stopped === 'exited') {
685
- // Exit code 0 is the whole story here: the browser decided to hand the request to an
686
- // instance that already owns this profile directory and quit, rather than failing. That is
687
- // what a leftover browser from a previous run (or a second DSH on the same profile) looks
688
- // like. "Check that the path is a runnable browser" pointed at the one cause that is almost
689
- // never it, and sent people looking for a broken install that was not there.
690
- throw new Error(`dsh-builtin-browser: ${target} exited immediately after launch (exit code ${child.exitCode}). `
691
- + 'That exit code means it handed the request to an instance already using this profile '
692
- + 'directory and quit — look for a leftover browser process from an earlier run, then retry.');
724
+ // Only exit code 0 is the hand-off story. A non-zero code or a signal means the browser
725
+ // died on its own — a crash, a missing library, a sandbox refusal — and asserting the
726
+ // profile story for those sent people looking for a leftover process that was not there,
727
+ // which is the same mistake this message was written to correct, pointing the other way.
728
+ const code = child.exitCode;
729
+ const signal = child.signalCode;
730
+ const clean = code === 0 && signal === null;
731
+ throw new Error(clean
732
+ ? `dsh-builtin-browser: ${target} exited immediately after launch (exit code 0). `
733
+ + 'That means it handed the request to an instance already using this profile '
734
+ + 'directory and quit — look for a leftover browser process from an earlier run, then retry.'
735
+ : `dsh-builtin-browser: ${target} exited immediately after launch `
736
+ + `(${signal !== null ? `killed by ${signal}` : `exit code ${String(code)}`}), so it did not `
737
+ + 'hand the request anywhere — the browser failed to start rather than deferring to another '
738
+ + 'instance. Check that it can run at all: run it by hand, and look for a crash or a missing '
739
+ + 'dependency rather than a leftover process.');
693
740
  }
694
741
  if (stopped === 'released') {
695
742
  throw new Error(`dsh-builtin-browser: ${target} was released while it was starting`);
@@ -755,8 +802,17 @@ export class SystemBrowserViewHost {
755
802
  if (!client.isAlive()) {
756
803
  // Only THIS call's client. Under concurrency the field may already hold a
757
804
  // connection another call just published, and closing that would kill a healthy one.
758
- if (this.client === client)
805
+ if (this.client === client) {
759
806
  this.client = undefined;
807
+ // This is the third of the three paths that discard a client, and it was the one
808
+ // that did not retire what the client had issued. A view holding a session id
809
+ // from the dead browser kept handing it to the replacement, which had never heard
810
+ // of it: every page command then failed with "Session with given id not found"
811
+ // and no later call could clear it, because the cached session was returned
812
+ // unconditionally. Gated on the same identity check as the field itself, so a
813
+ // concurrent call that just published a healthy client does not lose its maps.
814
+ this.forgetBrowserState();
815
+ }
760
816
  client.close();
761
817
  }
762
818
  throw error;
@@ -782,10 +838,31 @@ export class SystemBrowserViewHost {
782
838
  }
783
839
  /** Nothing to show: the browser owns its own windows. */
784
840
  showView() { }
785
- /** Same as {@link showView}. */
786
- async presentView() { }
787
- /** The browser owns window grouping. */
788
- groupView() { }
841
+ /**
842
+ * Bring the target's page to the front, then report success.
843
+ *
844
+ * The comment on `focus` below used to say this was impossible — that raising the page needs
845
+ * `Page.bringToFront` on a target session, which is a view-level concern the host cannot
846
+ * reach. The handle this method is handed IS that view, and it can send commands, so the
847
+ * capability was here all along; it just was not used. Without it `presentView` was an empty
848
+ * async method, so the provider's whole presentation barrier — the one that stops
849
+ * `Input.*` from being silently dropped for a view with no display surface, and reports
850
+ * `BROWSER_VIEW_NOT_PRESENTED` when it cannot present — never ran on this carrier.
851
+ *
852
+ * A failure is swallowed rather than propagated: a real Chrome window that refuses to come
853
+ * forward is not a reason to refuse the click that follows. The barrier exists to order the
854
+ * two, and the order is honoured either way.
855
+ * @param handle - the view to present.
856
+ */
857
+ async presentView(handle) {
858
+ try {
859
+ await handle.sendCommand('Page.bringToFront');
860
+ }
861
+ catch {
862
+ // Backgrounded, minimised, or a target that went away between the snapshot and the
863
+ // click. The command that called this will report its own failure if the page is gone.
864
+ }
865
+ }
789
866
  /**
790
867
  * Best effort at bringing the browser's window forward.
791
868
  *
@@ -804,10 +881,6 @@ export class SystemBrowserViewHost {
804
881
  }
805
882
  catch { /* Windows has no SIGCONT; nothing to do */ }
806
883
  }
807
- /** The browser reports its own window lifecycle. */
808
- onUserAction() { }
809
- /** The browser reports its own window lifecycle. */
810
- onViewClosed() { }
811
884
  /**
812
885
  * Close the browser we launched, if we ever launched one.
813
886
  *
@@ -819,8 +892,7 @@ export class SystemBrowserViewHost {
819
892
  if (this.disposed)
820
893
  return;
821
894
  this.disposed = true;
822
- this.views.clear();
823
- this.sessions.clear();
895
+ this.forgetBrowserState();
824
896
  this.client?.close();
825
897
  try {
826
898
  this.child?.kill();
@@ -869,6 +941,21 @@ export class SystemBrowserViewHost {
869
941
  * a second copy of that rule.
870
942
  */
871
943
  async ensureSession(viewId) {
944
+ // One target per view, even when two commands arrive together. The two CDP round trips
945
+ // below are awaits, so without this a second caller entering during the first's flight saw
946
+ // no session in `views` and created its own target — leaving an orphan page that nothing
947
+ // closes, because only the last id survives in the map.
948
+ const inFlight = this.sessionBuilding.get(viewId);
949
+ if (inFlight !== undefined)
950
+ return await inFlight;
951
+ const building = this.buildSession(viewId).finally(() => {
952
+ this.sessionBuilding.delete(viewId);
953
+ });
954
+ this.sessionBuilding.set(viewId, building);
955
+ return await building;
956
+ }
957
+ /** Create and attach the target for one view. Serialised by {@link ensureSession}. */
958
+ async buildSession(viewId) {
872
959
  const client = await this.ensureClient();
873
960
  const created = await client.send('Target.createTarget', { url: 'about:blank' });
874
961
  const targetId = created?.targetId;
@@ -883,6 +970,8 @@ export class SystemBrowserViewHost {
883
970
  this.views.set(viewId, { session: sessionId, client });
884
971
  return sessionId;
885
972
  }
973
+ /** Views whose session is being created right now, so a second caller waits for the first. */
974
+ sessionBuilding = new Map();
886
975
  /** sessionId -> targetId, so a destroyed view can close the right page. */
887
976
  sessions = new Map();
888
977
  }
package/lib/index.js CHANGED
@@ -22,7 +22,6 @@ export const name = 'dsh-builtin-browser';
22
22
  * never reaches the browser (the plugin works, its client half is invisible).
23
23
  */
24
24
  export function apply() {
25
- // Intentionally empty: see the module comment above.
26
25
  }
27
26
  export { BrowserError } from './browser/types.js';
28
27
  export { BrowserRuntime } from './browser/runtime.js';
@@ -0,0 +1,145 @@
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
+ * The `elements` item schema for browser_snapshot and browser_open.
28
+ *
29
+ * Written as a literal rather than generated, because `defineTool` validates the output schema
30
+ * against its own `ValueSchemaSpec` and a generated `Record<string, …>` is too wide to satisfy
31
+ * it. The field table above remains the single source of truth: `tests/element-fields.test.mjs`
32
+ * asserts that these properties and `projectElement`'s output are exactly the table's keys, so
33
+ * the two cannot drift even though one is typed by hand.
34
+ */
35
+ export declare const ELEMENT_ITEM_SCHEMA: {
36
+ readonly type: "object";
37
+ readonly additionalProperties: false;
38
+ readonly properties: {
39
+ readonly ref: {
40
+ readonly type: "number";
41
+ readonly required: true;
42
+ };
43
+ readonly kind: {
44
+ readonly type: "string";
45
+ readonly required: true;
46
+ };
47
+ readonly label: {
48
+ readonly type: "string";
49
+ readonly required: true;
50
+ };
51
+ readonly selector: {
52
+ readonly type: "string";
53
+ };
54
+ readonly x: {
55
+ readonly type: "number";
56
+ readonly required: true;
57
+ };
58
+ readonly y: {
59
+ readonly type: "number";
60
+ readonly required: true;
61
+ };
62
+ readonly frame: {
63
+ readonly type: "boolean";
64
+ };
65
+ };
66
+ };
67
+ /**
68
+ * The `nodes` item schema for browser_a11y.
69
+ *
70
+ * Same reasoning as {@link ELEMENT_ITEM_SCHEMA}: literal for the type checker, table-backed by
71
+ * test.
72
+ */
73
+ export declare const NODE_ITEM_SCHEMA: {
74
+ readonly type: "object";
75
+ readonly additionalProperties: false;
76
+ readonly properties: {
77
+ readonly ref: {
78
+ readonly type: "number";
79
+ readonly required: true;
80
+ };
81
+ readonly role: {
82
+ readonly type: "string";
83
+ readonly required: true;
84
+ };
85
+ readonly name: {
86
+ readonly type: "string";
87
+ readonly required: true;
88
+ };
89
+ readonly value: {
90
+ readonly type: "string";
91
+ };
92
+ readonly states: {
93
+ readonly type: "array";
94
+ readonly required: true;
95
+ readonly items: {
96
+ readonly type: "string";
97
+ };
98
+ };
99
+ readonly depth: {
100
+ readonly type: "number";
101
+ readonly required: true;
102
+ };
103
+ readonly tag: {
104
+ readonly type: "string";
105
+ readonly required: true;
106
+ };
107
+ readonly selector: {
108
+ readonly type: "string";
109
+ readonly required: true;
110
+ };
111
+ readonly x: {
112
+ readonly type: "number";
113
+ readonly required: true;
114
+ };
115
+ readonly y: {
116
+ readonly type: "number";
117
+ readonly required: true;
118
+ };
119
+ readonly frame: {
120
+ readonly type: "boolean";
121
+ };
122
+ };
123
+ };
124
+ /**
125
+ * Project one snapshot element into the shape the schema declares.
126
+ *
127
+ * Generic in the source type so the caller keeps the precise shape `defineTool` inferred from
128
+ * the schema: the projection decides which fields survive at run time, and the field table is
129
+ * what guarantees that set matches the schema. Widening the return type here would only move
130
+ * the mismatch to the call site.
131
+ * @param element - the element as the provider produced it.
132
+ * @returns exactly the declared fields.
133
+ */
134
+ export declare function projectElement<T extends object>(element: T): T;
135
+ /**
136
+ * Project one accessibility node into the shape the schema declares.
137
+ * @param node - the node as the provider produced it.
138
+ * @returns exactly the declared fields.
139
+ */
140
+ export declare function projectNode<T extends object>(node: T): T;
141
+ /** The field names each table declares, for tests that check the two stay in step. */
142
+ export declare const DECLARED_FIELDS: {
143
+ readonly element: string[];
144
+ readonly node: string[];
145
+ };
@@ -0,0 +1,152 @@
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
+ * The fields of one entry in `browser_snapshot` / `browser_open`'s `elements`.
28
+ *
29
+ * `selector` is the reason this file exists; see the header. It was added to the schema and
30
+ * the projection separately, and only one of them landed.
31
+ */
32
+ const ELEMENT_FIELDS = {
33
+ ref: { schema: { type: 'number' }, required: true },
34
+ kind: { schema: { type: 'string' }, required: true },
35
+ label: { schema: { type: 'string' }, required: true },
36
+ selector: { schema: { type: 'string' } },
37
+ x: { schema: { type: 'number' }, required: true },
38
+ y: { schema: { type: 'number' }, required: true },
39
+ frame: { schema: { type: 'boolean' } },
40
+ };
41
+ /**
42
+ * The fields of one entry in `browser_a11y`'s `nodes`.
43
+ *
44
+ * Different shape from an element on purpose: a role and an accessible name carry meaning a
45
+ * tag and a label string do not, and `depth` is what renders the tree.
46
+ */
47
+ const NODE_FIELDS = {
48
+ ref: { schema: { type: 'number' }, required: true },
49
+ role: { schema: { type: 'string' }, required: true },
50
+ name: { schema: { type: 'string' }, required: true },
51
+ value: { schema: { type: 'string' } },
52
+ states: { schema: { type: 'array', items: { type: 'string' } }, required: true },
53
+ depth: { schema: { type: 'number' }, required: true },
54
+ tag: { schema: { type: 'string' }, required: true },
55
+ // Always present: the provider derives it for every node and yields an empty string when the
56
+ // element has neither an id nor a name. Declaring it optional described a shape the provider
57
+ // never produced, and the type said required — the same disagreement, one level down.
58
+ selector: { schema: { type: 'string' }, required: true },
59
+ x: { schema: { type: 'number' }, required: true },
60
+ y: { schema: { type: 'number' }, required: true },
61
+ frame: { schema: { type: 'boolean' } },
62
+ };
63
+ /**
64
+ * Copy the declared fields out of a source value.
65
+ *
66
+ * Reads the table rather than naming fields, so anything the provider produces that the table
67
+ * declares is carried through, and anything the table does not declare is left out — which is
68
+ * what keeps the result and the schema in agreement in both directions.
69
+ * @param fields - the field table to project through.
70
+ * @param source - the value the provider produced.
71
+ * @returns a new object holding exactly the declared fields that are present.
72
+ */
73
+ function project(fields, source) {
74
+ const out = {};
75
+ for (const name of Object.keys(fields)) {
76
+ const value = source[name];
77
+ if (value !== undefined && value !== null)
78
+ out[name] = value;
79
+ }
80
+ return out;
81
+ }
82
+ /**
83
+ * The `elements` item schema for browser_snapshot and browser_open.
84
+ *
85
+ * Written as a literal rather than generated, because `defineTool` validates the output schema
86
+ * against its own `ValueSchemaSpec` and a generated `Record<string, …>` is too wide to satisfy
87
+ * it. The field table above remains the single source of truth: `tests/element-fields.test.mjs`
88
+ * asserts that these properties and `projectElement`'s output are exactly the table's keys, so
89
+ * the two cannot drift even though one is typed by hand.
90
+ */
91
+ export const ELEMENT_ITEM_SCHEMA = {
92
+ type: 'object',
93
+ additionalProperties: false,
94
+ properties: {
95
+ ref: { type: 'number', required: true },
96
+ kind: { type: 'string', required: true },
97
+ label: { type: 'string', required: true },
98
+ selector: { type: 'string' },
99
+ x: { type: 'number', required: true },
100
+ y: { type: 'number', required: true },
101
+ frame: { type: 'boolean' },
102
+ },
103
+ };
104
+ /**
105
+ * The `nodes` item schema for browser_a11y.
106
+ *
107
+ * Same reasoning as {@link ELEMENT_ITEM_SCHEMA}: literal for the type checker, table-backed by
108
+ * test.
109
+ */
110
+ export const NODE_ITEM_SCHEMA = {
111
+ type: 'object',
112
+ additionalProperties: false,
113
+ properties: {
114
+ ref: { type: 'number', required: true },
115
+ role: { type: 'string', required: true },
116
+ name: { type: 'string', required: true },
117
+ value: { type: 'string' },
118
+ states: { type: 'array', required: true, items: { type: 'string' } },
119
+ depth: { type: 'number', required: true },
120
+ tag: { type: 'string', required: true },
121
+ selector: { type: 'string', required: true },
122
+ x: { type: 'number', required: true },
123
+ y: { type: 'number', required: true },
124
+ frame: { type: 'boolean' },
125
+ },
126
+ };
127
+ /**
128
+ * Project one snapshot element into the shape the schema declares.
129
+ *
130
+ * Generic in the source type so the caller keeps the precise shape `defineTool` inferred from
131
+ * the schema: the projection decides which fields survive at run time, and the field table is
132
+ * what guarantees that set matches the schema. Widening the return type here would only move
133
+ * the mismatch to the call site.
134
+ * @param element - the element as the provider produced it.
135
+ * @returns exactly the declared fields.
136
+ */
137
+ export function projectElement(element) {
138
+ return project(ELEMENT_FIELDS, element);
139
+ }
140
+ /**
141
+ * Project one accessibility node into the shape the schema declares.
142
+ * @param node - the node as the provider produced it.
143
+ * @returns exactly the declared fields.
144
+ */
145
+ export function projectNode(node) {
146
+ return project(NODE_FIELDS, node);
147
+ }
148
+ /** The field names each table declares, for tests that check the two stay in step. */
149
+ export const DECLARED_FIELDS = {
150
+ element: Object.keys(ELEMENT_FIELDS),
151
+ node: Object.keys(NODE_FIELDS),
152
+ };