playwriter 0.0.63 → 0.0.89

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 (223) hide show
  1. package/dist/a11y-client.js +18 -8
  2. package/dist/aria-snapshot.d.ts +41 -3
  3. package/dist/aria-snapshot.d.ts.map +1 -1
  4. package/dist/aria-snapshot.js +134 -55
  5. package/dist/aria-snapshot.js.map +1 -1
  6. package/dist/aria-snapshot.test.js +5 -2
  7. package/dist/aria-snapshot.test.js.map +1 -1
  8. package/dist/aria-snapshot.unit.test.js +83 -41
  9. package/dist/aria-snapshot.unit.test.js.map +1 -1
  10. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts +5 -0
  11. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts.map +1 -0
  12. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js +5 -0
  13. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js.map +1 -0
  14. package/dist/bippy.js +1 -1
  15. package/dist/cdp-log.d.ts +1 -1
  16. package/dist/cdp-log.d.ts.map +1 -1
  17. package/dist/cdp-log.js +1 -1
  18. package/dist/cdp-log.js.map +1 -1
  19. package/dist/cdp-relay.d.ts.map +1 -1
  20. package/dist/cdp-relay.js +492 -298
  21. package/dist/cdp-relay.js.map +1 -1
  22. package/dist/cdp-session.d.ts.map +1 -1
  23. package/dist/cdp-session.js.map +1 -1
  24. package/dist/cdp-types.d.ts.map +1 -1
  25. package/dist/cdp-types.js +7 -7
  26. package/dist/cdp-types.js.map +1 -1
  27. package/dist/clean-html.d.ts.map +1 -1
  28. package/dist/clean-html.js +4 -5
  29. package/dist/clean-html.js.map +1 -1
  30. package/dist/cli.js +45 -27
  31. package/dist/cli.js.map +1 -1
  32. package/dist/create-logger.d.ts.map +1 -1
  33. package/dist/create-logger.js +3 -1
  34. package/dist/create-logger.js.map +1 -1
  35. package/dist/debugger-examples-types.d.ts.map +1 -1
  36. package/dist/debugger.d.ts.map +1 -1
  37. package/dist/debugger.js +1 -3
  38. package/dist/debugger.js.map +1 -1
  39. package/dist/diff-utils.d.ts.map +1 -1
  40. package/dist/diff-utils.js +1 -4
  41. package/dist/diff-utils.js.map +1 -1
  42. package/dist/editor-api.md +12 -2
  43. package/dist/editor-examples.d.ts +1 -1
  44. package/dist/editor-examples.d.ts.map +1 -1
  45. package/dist/editor-examples.js +1 -1
  46. package/dist/editor-examples.js.map +1 -1
  47. package/dist/editor.d.ts +1 -1
  48. package/dist/editor.d.ts.map +1 -1
  49. package/dist/editor.js +1 -1
  50. package/dist/editor.js.map +1 -1
  51. package/dist/executor.d.ts +26 -3
  52. package/dist/executor.d.ts.map +1 -1
  53. package/dist/executor.js +297 -64
  54. package/dist/executor.js.map +1 -1
  55. package/dist/executor.unit.test.js +38 -1
  56. package/dist/executor.unit.test.js.map +1 -1
  57. package/dist/extension-connection.test.js +139 -36
  58. package/dist/extension-connection.test.js.map +1 -1
  59. package/dist/ffmpeg.d.ts +148 -0
  60. package/dist/ffmpeg.d.ts.map +1 -0
  61. package/dist/ffmpeg.js +523 -0
  62. package/dist/ffmpeg.js.map +1 -0
  63. package/dist/ghost-browser.d.ts.map +1 -1
  64. package/dist/ghost-browser.js.map +1 -1
  65. package/dist/ghost-cursor-client.js +287 -0
  66. package/dist/ghost-cursor.d.ts +27 -0
  67. package/dist/ghost-cursor.d.ts.map +1 -0
  68. package/dist/ghost-cursor.js +63 -0
  69. package/dist/ghost-cursor.js.map +1 -0
  70. package/dist/htmlrewrite.d.ts.map +1 -1
  71. package/dist/htmlrewrite.js +17 -55
  72. package/dist/htmlrewrite.js.map +1 -1
  73. package/dist/htmlrewrite.test.js.map +1 -1
  74. package/dist/kill-port.d.ts.map +1 -1
  75. package/dist/kill-port.js +1 -3
  76. package/dist/kill-port.js.map +1 -1
  77. package/dist/locator-selector.test.d.ts +2 -0
  78. package/dist/locator-selector.test.d.ts.map +1 -0
  79. package/dist/locator-selector.test.js +96 -0
  80. package/dist/locator-selector.test.js.map +1 -0
  81. package/dist/mcp-client.js.map +1 -1
  82. package/dist/mcp.d.ts.map +1 -1
  83. package/dist/mcp.js +8 -3
  84. package/dist/mcp.js.map +1 -1
  85. package/dist/on-mouse-action.test.d.ts +2 -0
  86. package/dist/on-mouse-action.test.d.ts.map +1 -0
  87. package/dist/on-mouse-action.test.js +155 -0
  88. package/dist/on-mouse-action.test.js.map +1 -0
  89. package/dist/page-markdown.js +4 -4
  90. package/dist/page-markdown.js.map +1 -1
  91. package/dist/prompt.md +450 -377
  92. package/dist/protocol.d.ts +4 -0
  93. package/dist/protocol.d.ts.map +1 -1
  94. package/dist/readability.js +16 -2
  95. package/dist/recording-ghost-cursor.d.ts +41 -0
  96. package/dist/recording-ghost-cursor.d.ts.map +1 -0
  97. package/dist/recording-ghost-cursor.js +79 -0
  98. package/dist/recording-ghost-cursor.js.map +1 -0
  99. package/dist/recording-relay.d.ts.map +1 -1
  100. package/dist/recording-relay.js +8 -8
  101. package/dist/recording-relay.js.map +1 -1
  102. package/dist/relay-client.d.ts +17 -4
  103. package/dist/relay-client.d.ts.map +1 -1
  104. package/dist/relay-client.js +45 -11
  105. package/dist/relay-client.js.map +1 -1
  106. package/dist/relay-core.test.d.ts.map +1 -1
  107. package/dist/relay-core.test.js +515 -26
  108. package/dist/relay-core.test.js.map +1 -1
  109. package/dist/relay-navigation.test.d.ts.map +1 -1
  110. package/dist/relay-navigation.test.js +169 -31
  111. package/dist/relay-navigation.test.js.map +1 -1
  112. package/dist/relay-session.test.d.ts.map +1 -1
  113. package/dist/relay-session.test.js +113 -65
  114. package/dist/relay-session.test.js.map +1 -1
  115. package/dist/relay-state.d.ts +158 -0
  116. package/dist/relay-state.d.ts.map +1 -0
  117. package/dist/relay-state.js +306 -0
  118. package/dist/relay-state.js.map +1 -0
  119. package/dist/relay-state.test.d.ts +2 -0
  120. package/dist/relay-state.test.d.ts.map +1 -0
  121. package/dist/relay-state.test.js +472 -0
  122. package/dist/relay-state.test.js.map +1 -0
  123. package/dist/scoped-fs.d.ts.map +1 -1
  124. package/dist/scoped-fs.js.map +1 -1
  125. package/dist/screen-recording.d.ts +66 -4
  126. package/dist/screen-recording.d.ts.map +1 -1
  127. package/dist/screen-recording.js +150 -13
  128. package/dist/screen-recording.js.map +1 -1
  129. package/dist/screen-recording.test.d.ts +2 -0
  130. package/dist/screen-recording.test.d.ts.map +1 -0
  131. package/dist/screen-recording.test.js +102 -0
  132. package/dist/screen-recording.test.js.map +1 -0
  133. package/dist/selector-generator.js +1 -1
  134. package/dist/snapshot-tools.test.js +71 -28
  135. package/dist/snapshot-tools.test.js.map +1 -1
  136. package/dist/start-relay-server.d.ts +1 -1
  137. package/dist/start-relay-server.d.ts.map +1 -1
  138. package/dist/start-relay-server.js +1 -1
  139. package/dist/start-relay-server.js.map +1 -1
  140. package/dist/styles-api.md +8 -1
  141. package/dist/styles-examples.d.ts +1 -1
  142. package/dist/styles-examples.d.ts.map +1 -1
  143. package/dist/styles-examples.js +1 -1
  144. package/dist/styles-examples.js.map +1 -1
  145. package/dist/styles.d.ts.map +1 -1
  146. package/dist/styles.js +1 -3
  147. package/dist/styles.js.map +1 -1
  148. package/dist/test-declarations.d.ts.map +1 -1
  149. package/dist/test-utils.d.ts +1 -1
  150. package/dist/test-utils.d.ts.map +1 -1
  151. package/dist/test-utils.js +7 -5
  152. package/dist/test-utils.js.map +1 -1
  153. package/dist/utils.d.ts.map +1 -1
  154. package/dist/utils.js.map +1 -1
  155. package/dist/wait-for-page-load.d.ts.map +1 -1
  156. package/dist/wait-for-page-load.js +1 -1
  157. package/dist/wait-for-page-load.js.map +1 -1
  158. package/package.json +4 -3
  159. package/src/a11y-client.ts +5 -4
  160. package/src/aria-snapshot.test.ts +5 -2
  161. package/src/aria-snapshot.ts +306 -117
  162. package/src/aria-snapshot.unit.test.ts +199 -141
  163. package/src/aria-snapshots/github-interactive.txt +2 -0
  164. package/src/aria-snapshots/github-raw.txt +5 -1
  165. package/src/aria-snapshots/hackernews-interactive.txt +238 -241
  166. package/src/aria-snapshots/hackernews-raw.txt +265 -269
  167. package/src/assets/aria-labels-example.png +0 -0
  168. package/src/assets/aria-labels-github.png +0 -0
  169. package/src/assets/aria-labels-hacker-news.png +0 -0
  170. package/src/assets/aria-labels-old-reddit.png +0 -0
  171. package/src/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.ts +5 -0
  172. package/src/assets/cursors/screen-studio/pointer-macos-tahoe.svg +18 -0
  173. package/src/cdp-log.ts +4 -1
  174. package/src/cdp-relay.ts +1059 -737
  175. package/src/cdp-session.ts +12 -3
  176. package/src/cdp-types.ts +51 -51
  177. package/src/clean-html.ts +4 -5
  178. package/src/cli.ts +82 -55
  179. package/src/create-logger.ts +5 -3
  180. package/src/debugger-examples-types.ts +4 -1
  181. package/src/debugger.ts +1 -5
  182. package/src/diff-utils.ts +2 -5
  183. package/src/editor-examples.ts +11 -1
  184. package/src/editor.ts +10 -2
  185. package/src/executor.ts +374 -73
  186. package/src/executor.unit.test.ts +48 -1
  187. package/src/extension-connection.test.ts +612 -488
  188. package/src/ffmpeg.ts +769 -0
  189. package/src/ghost-browser.ts +4 -6
  190. package/src/ghost-cursor-client.ts +369 -0
  191. package/src/ghost-cursor.ts +110 -0
  192. package/src/htmlrewrite.test.ts +6 -2
  193. package/src/htmlrewrite.ts +348 -386
  194. package/src/kill-port.ts +1 -3
  195. package/src/locator-selector.test.ts +115 -0
  196. package/src/mcp-client.ts +1 -1
  197. package/src/mcp.ts +21 -15
  198. package/src/on-mouse-action.test.ts +196 -0
  199. package/src/page-markdown.ts +7 -7
  200. package/src/protocol.ts +73 -57
  201. package/src/recording-ghost-cursor.ts +113 -0
  202. package/src/recording-relay.ts +20 -12
  203. package/src/relay-client.ts +85 -18
  204. package/src/relay-core.test.ts +1117 -578
  205. package/src/relay-navigation.test.ts +648 -483
  206. package/src/relay-session.test.ts +984 -929
  207. package/src/relay-state.test.ts +570 -0
  208. package/src/relay-state.ts +497 -0
  209. package/src/resource.md +21 -49
  210. package/src/scoped-fs.ts +9 -3
  211. package/src/screen-recording.test.ts +111 -0
  212. package/src/screen-recording.ts +256 -31
  213. package/src/skill.md +476 -396
  214. package/src/snapshot-tools.test.ts +580 -528
  215. package/src/snapshots/shadcn-ui-accessibility-full.md +8 -8
  216. package/src/snapshots/shadcn-ui-accessibility-interactive.md +8 -8
  217. package/src/start-relay-server.ts +14 -11
  218. package/src/styles-examples.ts +8 -1
  219. package/src/styles.ts +20 -21
  220. package/src/test-declarations.ts +6 -6
  221. package/src/test-utils.ts +104 -91
  222. package/src/utils.ts +2 -1
  223. package/src/wait-for-page-load.ts +6 -1
@@ -0,0 +1,570 @@
1
+ /**
2
+ * Unit tests for relay state transitions.
3
+ * Data-in / data-out transitions for the unified extension map.
4
+ */
5
+ import { describe, test, expect } from 'vitest'
6
+ import type { WSContext } from 'hono/ws'
7
+ import type { Protocol } from './cdp-types.js'
8
+ import * as relayState from './relay-state.js'
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Helpers
12
+ // ---------------------------------------------------------------------------
13
+
14
+ function emptyState(): relayState.RelayState {
15
+ return {
16
+ extensions: new Map(),
17
+ playwrightClients: new Map(),
18
+ }
19
+ }
20
+
21
+ function fakeWs(): WSContext {
22
+ return {} as WSContext
23
+ }
24
+
25
+ function makeTargetInfo(overrides: Partial<Protocol.Target.TargetInfo> = {}): Protocol.Target.TargetInfo {
26
+ return {
27
+ targetId: 'target-1',
28
+ type: 'page',
29
+ title: 'Test Page',
30
+ url: 'https://example.com',
31
+ attached: true,
32
+ canAccessOpener: false,
33
+ ...overrides,
34
+ }
35
+ }
36
+
37
+ function stateWithExtension(
38
+ extensionId = 'ext-1',
39
+ info: relayState.ExtensionInfo = { browser: 'Chrome' },
40
+ stableKey = 'profile:chrome-1',
41
+ ): relayState.RelayState {
42
+ return relayState.addExtension(emptyState(), { id: extensionId, info, stableKey, ws: fakeWs() })
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // createRelayStore
47
+ // ---------------------------------------------------------------------------
48
+
49
+ describe('createRelayStore', () => {
50
+ test('creates store with empty maps', () => {
51
+ const store = relayState.createRelayStore()
52
+ const state = store.getState()
53
+ expect(state.extensions.size).toBe(0)
54
+ expect(state.playwrightClients.size).toBe(0)
55
+ })
56
+ })
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // addExtension / removeExtension
60
+ // ---------------------------------------------------------------------------
61
+
62
+ describe('addExtension', () => {
63
+ test('adds extension to empty state', () => {
64
+ const before = emptyState()
65
+ const after = relayState.addExtension(before, {
66
+ id: 'ext-1',
67
+ info: { browser: 'Chrome' },
68
+ stableKey: 'profile:chrome-1',
69
+ ws: fakeWs(),
70
+ })
71
+
72
+ expect(after.extensions.size).toBe(1)
73
+ const ext = after.extensions.get('ext-1')!
74
+ expect(ext.stableKey).toBe('profile:chrome-1')
75
+ expect(ext.connectedTargets.size).toBe(0)
76
+ expect(ext.ws).toBeTruthy()
77
+ expect(ext.messageId).toBe(0)
78
+ expect(ext.pendingRequests.size).toBe(0)
79
+ expect(ext.pingInterval).toBeNull()
80
+ // Original unchanged (immutable)
81
+ expect(before.extensions.size).toBe(0)
82
+ })
83
+
84
+ test('adding extension with same stableKey keeps old entry (removed on socket close)', () => {
85
+ const s1 = relayState.addExtension(emptyState(), {
86
+ id: 'ext-old',
87
+ info: { browser: 'Chrome' },
88
+ stableKey: 'profile:chrome-1',
89
+ ws: fakeWs(),
90
+ })
91
+ const s2 = relayState.addExtension(s1, {
92
+ id: 'ext-new',
93
+ info: { browser: 'Chrome', email: 'test@example.com' },
94
+ stableKey: 'profile:chrome-1',
95
+ ws: fakeWs(),
96
+ })
97
+
98
+ // Both coexist — old stays routable until its socket closes
99
+ expect(s2.extensions.size).toBe(2)
100
+ expect(s2.extensions.has('ext-old')).toBe(true)
101
+ expect(s2.extensions.has('ext-new')).toBe(true)
102
+ // findExtensionByStableKey returns newest
103
+ expect(relayState.findExtensionByStableKey(s2, 'profile:chrome-1')?.id).toBe('ext-new')
104
+ // Original unchanged
105
+ expect(s1.extensions.size).toBe(1)
106
+ })
107
+
108
+ test('allows multiple extensions with different stableKeys', () => {
109
+ let state = emptyState()
110
+ state = relayState.addExtension(state, { id: 'ext-1', info: { browser: 'Chrome' }, stableKey: 'profile:a', ws: fakeWs() })
111
+ state = relayState.addExtension(state, { id: 'ext-2', info: { browser: 'Firefox' }, stableKey: 'profile:b', ws: fakeWs() })
112
+
113
+ expect(state.extensions.size).toBe(2)
114
+ })
115
+ })
116
+
117
+ describe('removeExtension', () => {
118
+ test('removes existing extension', () => {
119
+ const before = stateWithExtension('ext-1')
120
+ const after = relayState.removeExtension(before, { extensionId: 'ext-1' })
121
+
122
+ expect(after.extensions.size).toBe(0)
123
+ // Original unchanged
124
+ expect(before.extensions.size).toBe(1)
125
+ })
126
+
127
+ test('no-op for nonexistent extension', () => {
128
+ const before = stateWithExtension('ext-1')
129
+ const after = relayState.removeExtension(before, { extensionId: 'ext-999' })
130
+
131
+ expect(after).toBe(before) // Same reference — no allocation
132
+ })
133
+
134
+ test('also removes playwright clients bound to the extension', () => {
135
+ let state = stateWithExtension('ext-1')
136
+ state = relayState.addPlaywrightClient(state, { id: 'c1', extensionId: 'ext-1', ws: fakeWs() })
137
+ state = relayState.addPlaywrightClient(state, { id: 'c2', extensionId: 'ext-1', ws: fakeWs() })
138
+ state = relayState.addPlaywrightClient(state, { id: 'c3', extensionId: 'ext-2', ws: fakeWs() })
139
+
140
+ const after = relayState.removeExtension(state, { extensionId: 'ext-1' })
141
+
142
+ expect(after.extensions.size).toBe(0)
143
+ expect(after.playwrightClients.size).toBe(1)
144
+ expect(after.playwrightClients.has('c3')).toBe(true)
145
+ })
146
+ })
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // addPlaywrightClient / removePlaywrightClient
150
+ // ---------------------------------------------------------------------------
151
+
152
+ describe('addPlaywrightClient', () => {
153
+ test('adds client with ws handle', () => {
154
+ const before = emptyState()
155
+ const ws = fakeWs()
156
+ const after = relayState.addPlaywrightClient(before, { id: 'client-1', extensionId: 'ext-1', ws })
157
+
158
+ expect(after.playwrightClients.size).toBe(1)
159
+ const client = after.playwrightClients.get('client-1')!
160
+ expect(client.extensionId).toBe('ext-1')
161
+ expect(client.ws).toBe(ws)
162
+ expect(before.playwrightClients.size).toBe(0)
163
+ })
164
+ })
165
+
166
+ describe('removePlaywrightClient', () => {
167
+ test('removes client', () => {
168
+ const before = relayState.addPlaywrightClient(emptyState(), { id: 'c1', extensionId: null, ws: fakeWs() })
169
+ const after = relayState.removePlaywrightClient(before, { clientId: 'c1' })
170
+
171
+ expect(after.playwrightClients.size).toBe(0)
172
+ expect(before.playwrightClients.size).toBe(1)
173
+ })
174
+
175
+ test('no-op for nonexistent client', () => {
176
+ const before = emptyState()
177
+ const after = relayState.removePlaywrightClient(before, { clientId: 'nope' })
178
+
179
+ expect(after).toBe(before)
180
+ })
181
+ })
182
+
183
+ describe('extension I/O fields', () => {
184
+ test('adds and removes pending extension requests', () => {
185
+ let state = stateWithExtension('ext-1')
186
+
187
+ const pending = {
188
+ resolve: (_result: unknown) => {
189
+ return
190
+ },
191
+ reject: (_error: Error) => {
192
+ return
193
+ },
194
+ }
195
+
196
+ state = relayState.addExtensionPendingRequest(state, {
197
+ extensionId: 'ext-1',
198
+ requestId: 7,
199
+ pendingRequest: pending,
200
+ })
201
+ expect(state.extensions.get('ext-1')?.pendingRequests.get(7)).toBe(pending)
202
+
203
+ state = relayState.removeExtensionPendingRequest(state, { extensionId: 'ext-1', requestId: 7 })
204
+ expect(state.extensions.get('ext-1')?.pendingRequests.has(7)).toBe(false)
205
+ })
206
+
207
+ test('updateExtensionIO updates ws and pingInterval', () => {
208
+ let state = stateWithExtension('ext-1')
209
+ expect(state.extensions.get('ext-1')?.pingInterval).toBeNull()
210
+
211
+ const interval = setInterval(() => {}, 99999)
212
+ try {
213
+ state = relayState.updateExtensionIO(state, { extensionId: 'ext-1', pingInterval: interval })
214
+ expect(state.extensions.get('ext-1')?.pingInterval).toBe(interval)
215
+
216
+ state = relayState.updateExtensionIO(state, { extensionId: 'ext-1', pingInterval: null })
217
+ expect(state.extensions.get('ext-1')?.pingInterval).toBeNull()
218
+ } finally {
219
+ clearInterval(interval)
220
+ }
221
+ })
222
+
223
+ test('playwright client ws handle is co-located with state', () => {
224
+ let state = emptyState()
225
+ const ws = fakeWs()
226
+ state = relayState.addPlaywrightClient(state, { id: 'c1', extensionId: null, ws })
227
+ expect(state.playwrightClients.get('c1')?.ws).toBe(ws)
228
+
229
+ state = relayState.removePlaywrightClient(state, { clientId: 'c1' })
230
+ expect(state.playwrightClients.size).toBe(0)
231
+ })
232
+ })
233
+
234
+ describe('rebindClientsToExtension', () => {
235
+ test('rebinds all clients from old extension to successor extension', () => {
236
+ let state = stateWithExtension('ext-old')
237
+ state = relayState.addExtension(state, {
238
+ id: 'ext-new',
239
+ info: { browser: 'Chrome' },
240
+ stableKey: 'profile:chrome-1',
241
+ ws: fakeWs(),
242
+ })
243
+ state = relayState.addPlaywrightClient(state, { id: 'c1', extensionId: 'ext-old', ws: fakeWs() })
244
+ state = relayState.addPlaywrightClient(state, { id: 'c2', extensionId: 'ext-old', ws: fakeWs() })
245
+ state = relayState.addPlaywrightClient(state, { id: 'c3', extensionId: 'ext-new', ws: fakeWs() })
246
+
247
+ const after = relayState.rebindClientsToExtension(state, {
248
+ fromExtensionId: 'ext-old',
249
+ toExtensionId: 'ext-new',
250
+ })
251
+
252
+ expect(after.playwrightClients.get('c1')?.extensionId).toBe('ext-new')
253
+ expect(after.playwrightClients.get('c2')?.extensionId).toBe('ext-new')
254
+ expect(after.playwrightClients.get('c3')?.extensionId).toBe('ext-new')
255
+ })
256
+ })
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // addTarget / removeTarget / removeTargetByCrash
260
+ // ---------------------------------------------------------------------------
261
+
262
+ describe('addTarget', () => {
263
+ test('adds target to extension', () => {
264
+ const before = stateWithExtension('ext-1')
265
+ const targetInfo = makeTargetInfo()
266
+ const after = relayState.addTarget(before, {
267
+ extensionId: 'ext-1',
268
+ sessionId: 'pw-tab-1',
269
+ targetId: 'target-1',
270
+ targetInfo,
271
+ })
272
+
273
+ const ext = after.extensions.get('ext-1')!
274
+ expect(ext.connectedTargets.size).toBe(1)
275
+ expect(ext.connectedTargets.get('pw-tab-1')?.targetId).toBe('target-1')
276
+ // Original unchanged
277
+ expect(before.extensions.get('ext-1')!.connectedTargets.size).toBe(0)
278
+ })
279
+
280
+ test('no-op if extension does not exist', () => {
281
+ const before = emptyState()
282
+ const after = relayState.addTarget(before, {
283
+ extensionId: 'ext-nope',
284
+ sessionId: 'pw-tab-1',
285
+ targetId: 'target-1',
286
+ targetInfo: makeTargetInfo(),
287
+ })
288
+
289
+ expect(after).toBe(before)
290
+ })
291
+
292
+ test('preserves existing frameIds on update', () => {
293
+ let state = stateWithExtension('ext-1')
294
+ state = relayState.addTarget(state, {
295
+ extensionId: 'ext-1',
296
+ sessionId: 'pw-tab-1',
297
+ targetId: 'target-1',
298
+ targetInfo: makeTargetInfo(),
299
+ existingFrameIds: new Set(['frame-A']),
300
+ })
301
+
302
+ // Update the same target with new targetInfo but no explicit frameIds
303
+ state = relayState.addTarget(state, {
304
+ extensionId: 'ext-1',
305
+ sessionId: 'pw-tab-1',
306
+ targetId: 'target-1',
307
+ targetInfo: makeTargetInfo({ url: 'https://updated.com' }),
308
+ })
309
+
310
+ const target = state.extensions.get('ext-1')!.connectedTargets.get('pw-tab-1')!
311
+ expect(target.targetInfo.url).toBe('https://updated.com')
312
+ expect(target.frameIds.has('frame-A')).toBe(true)
313
+ })
314
+ })
315
+
316
+ describe('removeTarget', () => {
317
+ test('removes target by sessionId', () => {
318
+ let state = stateWithExtension('ext-1')
319
+ state = relayState.addTarget(state, {
320
+ extensionId: 'ext-1',
321
+ sessionId: 'pw-tab-1',
322
+ targetId: 'target-1',
323
+ targetInfo: makeTargetInfo(),
324
+ })
325
+ const after = relayState.removeTarget(state, { extensionId: 'ext-1', sessionId: 'pw-tab-1' })
326
+
327
+ expect(after.extensions.get('ext-1')!.connectedTargets.size).toBe(0)
328
+ })
329
+
330
+ test('no-op if target does not exist', () => {
331
+ const before = stateWithExtension('ext-1')
332
+ const after = relayState.removeTarget(before, { extensionId: 'ext-1', sessionId: 'pw-tab-nope' })
333
+
334
+ expect(after).toBe(before)
335
+ })
336
+ })
337
+
338
+ describe('removeTargetByCrash', () => {
339
+ test('removes target by targetId', () => {
340
+ let state = stateWithExtension('ext-1')
341
+ state = relayState.addTarget(state, {
342
+ extensionId: 'ext-1',
343
+ sessionId: 'pw-tab-1',
344
+ targetId: 'target-1',
345
+ targetInfo: makeTargetInfo(),
346
+ })
347
+ const after = relayState.removeTargetByCrash(state, { extensionId: 'ext-1', targetId: 'target-1' })
348
+
349
+ expect(after.extensions.get('ext-1')!.connectedTargets.size).toBe(0)
350
+ })
351
+
352
+ test('no-op if targetId not found', () => {
353
+ const before = stateWithExtension('ext-1')
354
+ const after = relayState.removeTargetByCrash(before, { extensionId: 'ext-1', targetId: 'nope' })
355
+
356
+ expect(after).toBe(before)
357
+ })
358
+ })
359
+
360
+ // ---------------------------------------------------------------------------
361
+ // updateTargetInfo
362
+ // ---------------------------------------------------------------------------
363
+
364
+ describe('updateTargetInfo', () => {
365
+ test('updates targetInfo matched by targetId', () => {
366
+ let state = stateWithExtension('ext-1')
367
+ state = relayState.addTarget(state, {
368
+ extensionId: 'ext-1',
369
+ sessionId: 'pw-tab-1',
370
+ targetId: 'target-1',
371
+ targetInfo: makeTargetInfo({ title: 'Old Title' }),
372
+ })
373
+
374
+ const newInfo = makeTargetInfo({ title: 'New Title' })
375
+ const after = relayState.updateTargetInfo(state, { extensionId: 'ext-1', targetInfo: newInfo })
376
+
377
+ expect(after.extensions.get('ext-1')!.connectedTargets.get('pw-tab-1')!.targetInfo.title).toBe('New Title')
378
+ // Original unchanged
379
+ expect(state.extensions.get('ext-1')!.connectedTargets.get('pw-tab-1')!.targetInfo.title).toBe('Old Title')
380
+ })
381
+ })
382
+
383
+ // ---------------------------------------------------------------------------
384
+ // addFrameId / removeFrameId
385
+ // ---------------------------------------------------------------------------
386
+
387
+ describe('addFrameId', () => {
388
+ test('adds frameId to target', () => {
389
+ let state = stateWithExtension('ext-1')
390
+ state = relayState.addTarget(state, {
391
+ extensionId: 'ext-1',
392
+ sessionId: 'pw-tab-1',
393
+ targetId: 'target-1',
394
+ targetInfo: makeTargetInfo(),
395
+ })
396
+ const after = relayState.addFrameId(state, { extensionId: 'ext-1', sessionId: 'pw-tab-1', frameId: 'frame-1' })
397
+
398
+ expect(after.extensions.get('ext-1')!.connectedTargets.get('pw-tab-1')!.frameIds.has('frame-1')).toBe(true)
399
+ })
400
+
401
+ test('no-op if frameId already present', () => {
402
+ let state = stateWithExtension('ext-1')
403
+ state = relayState.addTarget(state, {
404
+ extensionId: 'ext-1',
405
+ sessionId: 'pw-tab-1',
406
+ targetId: 'target-1',
407
+ targetInfo: makeTargetInfo(),
408
+ })
409
+ state = relayState.addFrameId(state, { extensionId: 'ext-1', sessionId: 'pw-tab-1', frameId: 'frame-1' })
410
+ const after = relayState.addFrameId(state, { extensionId: 'ext-1', sessionId: 'pw-tab-1', frameId: 'frame-1' })
411
+
412
+ expect(after).toBe(state) // Same reference
413
+ })
414
+ })
415
+
416
+ describe('removeFrameId', () => {
417
+ test('removes frameId from owning target', () => {
418
+ let state = stateWithExtension('ext-1')
419
+ state = relayState.addTarget(state, {
420
+ extensionId: 'ext-1',
421
+ sessionId: 'pw-tab-1',
422
+ targetId: 'target-1',
423
+ targetInfo: makeTargetInfo(),
424
+ })
425
+ state = relayState.addFrameId(state, { extensionId: 'ext-1', sessionId: 'pw-tab-1', frameId: 'frame-1' })
426
+ const after = relayState.removeFrameId(state, { extensionId: 'ext-1', frameId: 'frame-1' })
427
+
428
+ expect(after.extensions.get('ext-1')!.connectedTargets.get('pw-tab-1')!.frameIds.has('frame-1')).toBe(false)
429
+ })
430
+
431
+ test('no-op if frameId not found', () => {
432
+ const before = stateWithExtension('ext-1')
433
+ const after = relayState.removeFrameId(before, { extensionId: 'ext-1', frameId: 'nope' })
434
+
435
+ expect(after).toBe(before)
436
+ })
437
+ })
438
+
439
+ // ---------------------------------------------------------------------------
440
+ // updateTargetUrl
441
+ // ---------------------------------------------------------------------------
442
+
443
+ describe('updateTargetUrl', () => {
444
+ test('frameNavigated on top-level frame updates URL and title', () => {
445
+ let state = stateWithExtension('ext-1')
446
+ state = relayState.addTarget(state, {
447
+ extensionId: 'ext-1',
448
+ sessionId: 'pw-tab-1',
449
+ targetId: 'target-1',
450
+ targetInfo: makeTargetInfo({ url: 'https://old.com', title: 'Old' }),
451
+ })
452
+
453
+ const after = relayState.updateTargetUrl(state, {
454
+ extensionId: 'ext-1',
455
+ sessionId: 'pw-tab-1',
456
+ url: 'https://new.com',
457
+ title: 'New Page',
458
+ })
459
+
460
+ const target = after.extensions.get('ext-1')!.connectedTargets.get('pw-tab-1')!
461
+ expect(target.targetInfo.url).toBe('https://new.com')
462
+ expect(target.targetInfo.title).toBe('New Page')
463
+ })
464
+
465
+ test('navigatedWithinDocument updates URL only (no title)', () => {
466
+ let state = stateWithExtension('ext-1')
467
+ state = relayState.addTarget(state, {
468
+ extensionId: 'ext-1',
469
+ sessionId: 'pw-tab-1',
470
+ targetId: 'target-1',
471
+ targetInfo: makeTargetInfo({ url: 'https://example.com', title: 'Keep This' }),
472
+ })
473
+
474
+ const after = relayState.updateTargetUrl(state, {
475
+ extensionId: 'ext-1',
476
+ sessionId: 'pw-tab-1',
477
+ url: 'https://example.com/new-path',
478
+ })
479
+
480
+ const target = after.extensions.get('ext-1')!.connectedTargets.get('pw-tab-1')!
481
+ expect(target.targetInfo.url).toBe('https://example.com/new-path')
482
+ expect(target.targetInfo.title).toBe('Keep This')
483
+ })
484
+
485
+ test('no-op if target does not exist', () => {
486
+ const before = stateWithExtension('ext-1')
487
+ const after = relayState.updateTargetUrl(before, {
488
+ extensionId: 'ext-1',
489
+ sessionId: 'pw-tab-nope',
490
+ url: 'https://example.com',
491
+ })
492
+
493
+ expect(after).toBe(before)
494
+ })
495
+ })
496
+
497
+
498
+
499
+ // ---------------------------------------------------------------------------
500
+ // Derivation helpers
501
+ // ---------------------------------------------------------------------------
502
+
503
+ describe('findExtensionByStableKey', () => {
504
+ test('finds extension by stableKey', () => {
505
+ const state = stateWithExtension('ext-1', { browser: 'Chrome' }, 'profile:chrome-1')
506
+
507
+ const found = relayState.findExtensionByStableKey(state, 'profile:chrome-1')
508
+ expect(found?.id).toBe('ext-1')
509
+ })
510
+
511
+ test('returns undefined if not found', () => {
512
+ const state = emptyState()
513
+ expect(relayState.findExtensionByStableKey(state, 'profile:nope')).toBeUndefined()
514
+ })
515
+ })
516
+
517
+ describe('findExtensionIdByCdpSession', () => {
518
+ test('finds extension owning a CDP sessionId', () => {
519
+ let state = stateWithExtension('ext-1')
520
+ state = relayState.addTarget(state, {
521
+ extensionId: 'ext-1',
522
+ sessionId: 'pw-tab-1',
523
+ targetId: 'target-1',
524
+ targetInfo: makeTargetInfo(),
525
+ })
526
+
527
+ expect(relayState.findExtensionIdByCdpSession(state, 'pw-tab-1')).toBe('ext-1')
528
+ })
529
+
530
+ test('returns null if session not found', () => {
531
+ const state = stateWithExtension('ext-1')
532
+ expect(relayState.findExtensionIdByCdpSession(state, 'pw-tab-nope')).toBeNull()
533
+ })
534
+ })
535
+
536
+ // ---------------------------------------------------------------------------
537
+ // Zustand store integration
538
+ // ---------------------------------------------------------------------------
539
+
540
+ describe('store.setState with transitions', () => {
541
+ test('setState updates store atomically', () => {
542
+ const store = relayState.createRelayStore()
543
+
544
+ store.setState((s) => {
545
+ return relayState.addExtension(s, { id: 'ext-1', info: { browser: 'Chrome' }, stableKey: 'profile:1', ws: fakeWs() })
546
+ })
547
+
548
+ expect(store.getState().extensions.size).toBe(1)
549
+ })
550
+
551
+ test('chained transitions compose correctly', () => {
552
+ const store = relayState.createRelayStore()
553
+
554
+ store.setState((s) => {
555
+ let next = relayState.addExtension(s, { id: 'ext-1', info: {}, stableKey: 'k1', ws: fakeWs() })
556
+ next = relayState.addTarget(next, {
557
+ extensionId: 'ext-1',
558
+ sessionId: 'pw-tab-1',
559
+ targetId: 'target-1',
560
+ targetInfo: makeTargetInfo(),
561
+ })
562
+ next = relayState.addPlaywrightClient(next, { id: 'c1', extensionId: 'ext-1', ws: fakeWs() })
563
+ return next
564
+ })
565
+
566
+ const state = store.getState()
567
+ expect(state.extensions.get('ext-1')!.connectedTargets.size).toBe(1)
568
+ expect(state.playwrightClients.size).toBe(1)
569
+ })
570
+ })