dsh-sidebar-browser-agent 0.0.0-stage → 0.2.6

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.
@@ -0,0 +1,687 @@
1
+ /**
2
+ * Browser-half regression suite.
3
+ *
4
+ * client.js runs in a vm with mocked browser globals. The guest scripts for
5
+ * read/click/type/scroll/wait are executed against a fake DOM guest context
6
+ * behind a mocked native `<webview>`, so their real behaviour is exercised;
7
+ * the bridge transport is mocked in memory.
8
+ */
9
+ import fs from 'node:fs'
10
+ import path from 'node:path'
11
+ import vm from 'node:vm'
12
+ import { fileURLToPath } from 'node:url'
13
+
14
+ const here = path.dirname(fileURLToPath(import.meta.url))
15
+ const root = path.join(here, '..')
16
+ const source = fs.readFileSync(path.join(root, 'lib', 'client.js'), 'utf8')
17
+ const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))
18
+
19
+ let failures = 0
20
+ function check(name, condition, detail) {
21
+ if (condition) console.log(`PASS ${name}`)
22
+ else {
23
+ failures += 1
24
+ console.log(`FAIL ${name}${detail === undefined ? '' : ` :: ${typeof detail === 'string' ? detail : JSON.stringify(detail)}`}`)
25
+ }
26
+ }
27
+
28
+ check('client version matches package', source.includes(`PLUGIN_VERSION = '${packageJson.version}'`), packageJson.version)
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Fake guest DOM
32
+ // ---------------------------------------------------------------------------
33
+
34
+ class FakeElement {
35
+ constructor(tag, { attrs = {}, rect = { x: 0, y: 0, width: 100, height: 20 }, text = '' } = {}) {
36
+ this.tagName = tag.toUpperCase()
37
+ this.attributes = { ...attrs }
38
+ this.rect = rect
39
+ this.innerText = text
40
+ this.textContent = text
41
+ this.children = []
42
+ this.parentElement = null
43
+ this.isContentEditable = this.attributes.contenteditable === 'true'
44
+ this.disabled = this.attributes.disabled === 'true'
45
+ this.events = []
46
+ this.scrolledInto = false
47
+ this.focused = false
48
+ }
49
+
50
+ get id() {
51
+ return this.attributes.id ?? ''
52
+ }
53
+
54
+ get value() {
55
+ return this.attributes.value ?? ''
56
+ }
57
+
58
+ set value(next) {
59
+ this.attributes.value = String(next)
60
+ }
61
+
62
+ get href() {
63
+ return this.attributes.href ?? ''
64
+ }
65
+
66
+ getAttribute(name) {
67
+ return Object.prototype.hasOwnProperty.call(this.attributes, name) ? this.attributes[name] : null
68
+ }
69
+
70
+ setAttribute(name, value) {
71
+ this.attributes[name] = value
72
+ }
73
+
74
+ getBoundingClientRect() {
75
+ const { x, y, width, height } = this.rect
76
+ return { x, y, width, height, left: x, top: y, right: x + width, bottom: y + height }
77
+ }
78
+
79
+ scrollIntoView() {
80
+ this.scrolledInto = true
81
+ }
82
+
83
+ focus() {
84
+ this.focused = true
85
+ guest.activeElement = this
86
+ }
87
+
88
+ dispatchEvent(event) {
89
+ this.events.push(event.type)
90
+ return true
91
+ }
92
+
93
+ append(child) {
94
+ child.parentElement = this
95
+ this.children.push(child)
96
+ }
97
+ }
98
+
99
+ class FakeInputElement extends FakeElement {
100
+ get value() {
101
+ return this.attributes.value ?? ''
102
+ }
103
+
104
+ set value(next) {
105
+ this.attributes.value = String(next)
106
+ }
107
+ }
108
+
109
+ class FakeTextAreaElement extends FakeInputElement {}
110
+
111
+ class FakeAnchorElement extends FakeElement {
112
+ get href() {
113
+ return this.attributes.href ?? ''
114
+ }
115
+ }
116
+
117
+ const guest = { activeElement: null, scrollX: 0, scrollY: 0, scrollHeight: 2400 }
118
+
119
+ const body = new FakeElement('body', { rect: { x: 0, y: 0, width: 1280, height: 800 } })
120
+ body.innerText = 'Welcome to Test Page\nThis page demonstrates the native sidebar browser agent.\nSubmit answer\nMore information'
121
+
122
+ const heading = new FakeElement('h1', { rect: { x: 24, y: 24, width: 400, height: 40 }, text: 'Welcome to Test Page' })
123
+ const button = new FakeElement('button', { attrs: { type: 'button' }, rect: { x: 100, y: 200, width: 160, height: 40 }, text: 'Submit answer' })
124
+ const link = new FakeAnchorElement('a', { attrs: { href: 'https://example.org/more' }, rect: { x: 300, y: 100, width: 180, height: 24 }, text: 'More information' })
125
+ const input = new FakeInputElement('input', { attrs: { id: 'q', type: 'text', placeholder: 'Search' }, rect: { x: 24, y: 300, width: 320, height: 32 } })
126
+ const card = new FakeElement('div', { attrs: { onclick: 'return false' }, rect: { x: 24, y: 400, width: 420, height: 120 }, text: 'Card with click handler' })
127
+
128
+ for (const child of [heading, button, link, input, card]) body.append(child)
129
+
130
+ const documentElement = new FakeElement('html', { rect: { x: 0, y: 0, width: 1280, height: 2400 } })
131
+ documentElement.outerHTML = '<html><body><h1>Welcome to Test Page</h1><button>Submit answer</button><a href="https://example.org/more">More information</a><input id="q" placeholder="Search"><div onclick="return false">Card with click handler</div></body></html>'
132
+ documentElement.scrollHeight = 2400
133
+ documentElement.append(body)
134
+
135
+ /** Parse the small selector vocabulary the guest scripts use. */
136
+ function matches(el, compound) {
137
+ const match = /^([a-z0-9]*)(?:#([A-Za-z0-9_-]+))?((?:\[[^\]]*\])*)$/.exec(compound.trim())
138
+ if (match === null) return false
139
+ const [, tag, id, attributePart] = match
140
+ if (tag !== '' && el.tagName !== tag.toUpperCase()) return false
141
+ if (id !== undefined && el.id !== id) return false
142
+ for (const attrMatch of attributePart.matchAll(/\[([^\]=]+)(?:="([^"]*)")?\]/g)) {
143
+ const name = attrMatch[1]
144
+ const expected = attrMatch[2]
145
+ const actual = el.getAttribute(name)
146
+ if (actual === null) return false
147
+ if (expected !== undefined && actual !== expected) return false
148
+ }
149
+ return true
150
+ }
151
+
152
+ function queryAll(root, selector) {
153
+ const results = []
154
+ const parts = selector.split(',').map((part) => part.trim())
155
+ const visit = (element) => {
156
+ for (const child of element.children) {
157
+ visit(child)
158
+ for (const compound of parts) {
159
+ if (matches(child, compound)) {
160
+ results.push(child)
161
+ break
162
+ }
163
+ }
164
+ }
165
+ }
166
+ visit(root)
167
+ return results
168
+ }
169
+
170
+ const guestContext = {
171
+ document: {
172
+ title: 'Test Page',
173
+ readyState: 'complete',
174
+ body,
175
+ documentElement,
176
+ activeElement: null,
177
+ querySelector(selector) {
178
+ if (selector === 'body') return body
179
+ const found = queryAll({ children: [documentElement] }, selector)
180
+ return found.length > 0 ? found[0] : null
181
+ },
182
+ querySelectorAll(selector) {
183
+ if (selector === 'body') return [body]
184
+ const found = queryAll({ children: [documentElement] }, selector)
185
+ return found.length > 0 ? found : queryAll(body, selector)
186
+ },
187
+ createRange() {
188
+ return { selectNodeContents() {} }
189
+ },
190
+ execCommand(command, _showUi, value) {
191
+ guest.commands = guest.commands ?? []
192
+ guest.commands.push({ command, value })
193
+ return true
194
+ },
195
+ elementFromPoint(x, y) {
196
+ for (const child of body.children) {
197
+ const rect = child.getBoundingClientRect()
198
+ if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) return child
199
+ }
200
+ return null
201
+ },
202
+ },
203
+ location: { href: 'https://example.test/page' },
204
+ innerWidth: 1280,
205
+ innerHeight: 800,
206
+ get scrollX() {
207
+ return guest.scrollX
208
+ },
209
+ get scrollY() {
210
+ return guest.scrollY
211
+ },
212
+ scrollBy(options) {
213
+ guest.scrollX += Number(options.left ?? 0)
214
+ guest.scrollY += Number(options.top ?? 0)
215
+ },
216
+ getComputedStyle() {
217
+ return { display: 'block', visibility: 'visible', opacity: '1' }
218
+ },
219
+ getSelection() {
220
+ return { removeAllRanges() {}, addRange() {} }
221
+ },
222
+ performance: { now: () => Date.now() },
223
+ CSS: { escape: (value) => String(value) },
224
+ Event: class FakeEvent {
225
+ constructor(type, init) {
226
+ this.type = type
227
+ this.bubbles = Boolean(init?.bubbles)
228
+ }
229
+ },
230
+ HTMLInputElement: FakeInputElement,
231
+ HTMLTextAreaElement: FakeTextAreaElement,
232
+ Element: FakeElement,
233
+ setTimeout,
234
+ clearTimeout,
235
+ console,
236
+ Math,
237
+ Number,
238
+ String,
239
+ JSON,
240
+ Object,
241
+ Array,
242
+ Promise,
243
+ }
244
+ const guestVm = vm.createContext(guestContext)
245
+
246
+ /** Execute a guest script in the fake DOM context and await async results. */
247
+ async function guestEval(sourceText) {
248
+ const result = vm.runInContext(sourceText, guestVm, { filename: 'guest-script.js' })
249
+ return await result
250
+ }
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // Fake native webview + screenshots
254
+ // ---------------------------------------------------------------------------
255
+
256
+ const inputEvents = []
257
+
258
+ function makeImage(width, height, dataUrl = 'data:image/png;base64,QUJD') {
259
+ return {
260
+ getSize: () => ({ width, height }),
261
+ resize: ({ width: nextWidth }) => makeImage(nextWidth, Math.max(1, Math.round((height * nextWidth) / width)), dataUrl),
262
+ toDataURL: () => dataUrl,
263
+ }
264
+ }
265
+
266
+ const webview = {
267
+ dataset: { sidebarBrowserFrame: 'webview' },
268
+ focused: false,
269
+ loadUrlCalls: [],
270
+ reloadCalls: 0,
271
+ async executeJavaScript(sourceText) {
272
+ return await guestEval(sourceText)
273
+ },
274
+ sendInputEvent(event) {
275
+ inputEvents.push(event)
276
+ },
277
+ async capturePage() {
278
+ return makeImage(1024, 768)
279
+ },
280
+ getURL() {
281
+ return 'https://example.test/page'
282
+ },
283
+ getTitle() {
284
+ return 'Test Page'
285
+ },
286
+ isLoading() {
287
+ return false
288
+ },
289
+ focus() {
290
+ this.focused = true
291
+ },
292
+ async loadURL(url) {
293
+ this.loadUrlCalls.push(url)
294
+ },
295
+ reload() {
296
+ this.reloadCalls += 1
297
+ },
298
+ }
299
+
300
+ const titleWrapper = { querySelector: () => null }
301
+ const bodyWrapper = { querySelector: (selector) => (selector.startsWith('webview') ? webview : null) }
302
+ const sessionRoot = {
303
+ dataset: { sidebarRightSession: 'session-1' },
304
+ querySelectorAll: (selector) => (selector.includes('data-sidebar-right-tab') ? [titleWrapper, bodyWrapper] : []),
305
+ }
306
+ const documentMock = {
307
+ baseURI: 'http://127.0.0.1:58087/',
308
+ querySelectorAll(selector) {
309
+ if (selector === '[data-sidebar-right-session]') return [sessionRoot]
310
+ if (selector === 'webview') return [webview]
311
+ if (selector === '[data-sidebar-right-tab]') return [titleWrapper, bodyWrapper]
312
+ return []
313
+ },
314
+ querySelector() {
315
+ return null
316
+ },
317
+ }
318
+
319
+ // ---------------------------------------------------------------------------
320
+ // Mocked transport + sidebar service
321
+ // ---------------------------------------------------------------------------
322
+
323
+ const reports = []
324
+ const results = []
325
+ const polls = []
326
+ let pendingPoll = null
327
+ const queue = []
328
+
329
+ function jsonResponse(payload) {
330
+ return { ok: true, status: 200, json: async () => payload }
331
+ }
332
+
333
+ async function mockFetch(url, init = {}) {
334
+ const parsed = new URL(String(url))
335
+ if (parsed.pathname.endsWith('/report')) {
336
+ reports.push(JSON.parse(init.body))
337
+ return jsonResponse({ ok: true })
338
+ }
339
+ if (parsed.pathname.endsWith('/result')) {
340
+ results.push(JSON.parse(init.body))
341
+ return jsonResponse({ ok: true })
342
+ }
343
+ if (parsed.pathname.endsWith('/poll')) {
344
+ polls.push({ session: parsed.searchParams.get('session') })
345
+ if (queue.length > 0) return jsonResponse({ commands: [queue.shift()] })
346
+ return await new Promise((resolve) => {
347
+ pendingPoll = resolve
348
+ })
349
+ }
350
+ throw new Error(`unexpected fetch ${parsed.pathname}`)
351
+ }
352
+
353
+ function deliver(command) {
354
+ if (pendingPoll !== null) {
355
+ const resolve = pendingPoll
356
+ pendingPoll = null
357
+ resolve(jsonResponse({ commands: [command] }))
358
+ return
359
+ }
360
+ queue.push(command)
361
+ }
362
+
363
+ function createSidebarMock() {
364
+ const sessions = new Map()
365
+ let mounted = 'session-1'
366
+ let sequence = 0
367
+ let activeId
368
+ return {
369
+ mounted: { getSnapshot: () => mounted },
370
+ openTabs: { getSnapshot: () => [...sessions.values()].flat().map((tab) => ({ ...tab })) },
371
+ openTab(kind, options = {}) {
372
+ if (kind !== 'browser') throw new Error(`no tab type registered for kind ${kind}`)
373
+ const sessionId = mounted
374
+ const current = sessions.get(sessionId) ?? []
375
+ const kept = options.replaceTab === undefined ? current : current.filter((tab) => tab.tabId !== options.replaceTab)
376
+ sequence += 1
377
+ const tab = { sessionId, tabId: `tab-${sequence}`, kind, contentId: `content-${sequence}`, url: options.params?.url }
378
+ sessions.set(sessionId, [...kept, tab])
379
+ activeId = tab.tabId
380
+ },
381
+ focus(tabId) {
382
+ activeId = tabId
383
+ },
384
+ close(tabId) {
385
+ for (const [sessionId, tabs] of sessions) sessions.set(sessionId, tabs.filter((tab) => tab.tabId !== tabId))
386
+ },
387
+ active() {
388
+ const tabs = sessions.get(mounted) ?? []
389
+ const tab = tabs.find((candidate) => candidate.tabId === activeId)
390
+ return tab === undefined ? undefined : { id: tab.tabId, kind: tab.kind }
391
+ },
392
+ setMounted(next) {
393
+ mounted = next
394
+ },
395
+ seedBrowserTab(sessionId = 'session-1') {
396
+ sequence += 1
397
+ const tab = { sessionId, tabId: `tab-${sequence}`, kind: 'browser', contentId: `content-${sequence}`, url: 'https://example.test/page' }
398
+ sessions.set(sessionId, [...(sessions.get(sessionId) ?? []), tab])
399
+ activeId = tab.tabId
400
+ return tab
401
+ },
402
+ }
403
+ }
404
+
405
+ // ---------------------------------------------------------------------------
406
+ // Load client.js
407
+ // ---------------------------------------------------------------------------
408
+
409
+ const sidebar = createSidebarMock()
410
+ const tab = sidebar.seedBrowserTab()
411
+
412
+ let captured
413
+ const sandbox = {
414
+ window: { __ModuleLoader__: { load(payload) { captured = payload } } },
415
+ document: documentMock,
416
+ location: { href: 'http://127.0.0.1:58087/' },
417
+ crypto: globalThis.crypto,
418
+ fetch: mockFetch,
419
+ URL,
420
+ AbortController,
421
+ setTimeout,
422
+ clearTimeout,
423
+ console,
424
+ Math,
425
+ Date,
426
+ Error,
427
+ String,
428
+ Set,
429
+ Map,
430
+ Promise,
431
+ queueMicrotask,
432
+ encodeURIComponent,
433
+ CSS: { escape: (value) => String(value) },
434
+ dshDesktop: { browser: {} },
435
+ __DSH_BOOT__: { release: { version: '0.2.2-rc.1', commit: 'c59d10a' } },
436
+ }
437
+ vm.createContext(sandbox)
438
+ vm.runInContext(source, sandbox, { filename: 'client.js' })
439
+
440
+ check('module registered', captured !== undefined && captured.id === 'dsh-sidebar-browser-agent')
441
+
442
+ /** Minimal React stand-in: enough to render the configuration page once. */
443
+ const mockReact = {
444
+ createElement(type, props, ...children) {
445
+ return { type, props: props ?? {}, children }
446
+ },
447
+ useState(initial) {
448
+ const value = typeof initial === 'function' ? initial() : initial
449
+ return [value, () => {}]
450
+ },
451
+ useEffect() {},
452
+ }
453
+
454
+ const moduleExports = captured.factory((name) => {
455
+ if (name === 'react') return mockReact
456
+ throw new Error(`unexpected require(${name})`)
457
+ })
458
+
459
+ const waitFor = async (predicate, timeout = 3000) => {
460
+ const start = Date.now()
461
+ while (!predicate()) {
462
+ if (Date.now() - start > timeout) return false
463
+ await new Promise((resolve) => setTimeout(resolve, 5))
464
+ }
465
+ return true
466
+ }
467
+
468
+ async function send(command) {
469
+ const before = results.length
470
+ deliver(command)
471
+ if (!(await waitFor(() => results.length > before))) throw new Error(`no result for ${command.action}`)
472
+ return results[results.length - 1]
473
+ }
474
+
475
+ async function main() {
476
+ const disposers = []
477
+ const slotRegistrations = []
478
+ const localeRegistrations = []
479
+ const fakeConfigForms = {
480
+ get: () => ({
481
+ getSnapshot: () => ({
482
+ status: 'ready',
483
+ writable: true,
484
+ revision: 1,
485
+ value: { enabled: true, allowEval: true, allowNativeInput: true, allowedHosts: ['example.test'] },
486
+ }),
487
+ }),
488
+ }
489
+ moduleExports.apply({
490
+ sidebarRight: sidebar,
491
+ get: (name) => (name === 'configForms' ? fakeConfigForms : undefined),
492
+ effect(callback) {
493
+ disposers.push(callback())
494
+ return () => {}
495
+ },
496
+ slots: {
497
+ inject(_name, callback) {
498
+ return callback()
499
+ },
500
+ register(options, component) {
501
+ slotRegistrations.push({ options, component })
502
+ return () => {}
503
+ },
504
+ },
505
+ locale: {
506
+ register(ns, dicts) {
507
+ localeRegistrations.push({ ns, dicts })
508
+ return () => {}
509
+ },
510
+ },
511
+ })
512
+ const stopBridge = disposers[0]
513
+
514
+ check('bridge started', typeof stopBridge === 'function' && disposers.length === 3, disposers.length)
515
+ await waitFor(() => reports.length > 0)
516
+ check('started report carries carrier', reports[0]?.status === 'started' && reports[0]?.carrier === 'electron-webview', reports[0])
517
+ await waitFor(() => pendingPoll !== null)
518
+
519
+ // --- status diagnostics ---
520
+ const status = await send({ id: 's1', action: 'status', sessionId: 'session-1' })
521
+ const note = status.value?.note ?? ''
522
+ const match = note.match(/checks=(\{.*\})\.$/)
523
+ let checks = null
524
+ try {
525
+ checks = match === null ? null : JSON.parse(match[1])
526
+ } catch {
527
+ checks = null
528
+ }
529
+ check('status carries checks JSON', checks !== null, note)
530
+ check('status checks plugin version', checks?.plugin === packageJson.version, checks)
531
+ check('status checks harness release', checks?.harness === '0.2.2-rc.1' && checks?.commit === 'c59d10a', checks)
532
+ check('status checks carrier + surface', checks?.carrier === 'electron-webview' && checks?.native === true && checks?.surface === true && checks?.webview === true, checks)
533
+ check('status checks native API', Array.isArray(checks?.api)
534
+ && ['executeJavaScript', 'capturePage', 'sendInputEvent', 'loadURL', 'reload', 'getURL', 'getTitle'].every((name) => checks.api.includes(name)), checks?.api)
535
+ check('status checks guest URL', checks?.guestUrl === 'https://example.test/page' && checks?.guestLoading === false, checks)
536
+ check('status checks config form', checks?.configForm === 'ready', checks?.configForm)
537
+
538
+ // --- configuration page: locale, slot registration and rendering ---
539
+ const localeEntry = localeRegistrations.find((entry) => entry.ns === 'sidebarBrowserAgent')
540
+ check('config locale registered', localeEntry !== undefined && localeEntry.dicts.en !== undefined && localeEntry.dicts.zh !== undefined, localeRegistrations.map((entry) => entry.ns))
541
+ const slotEntry = slotRegistrations.find((entry) => entry.options?.name === 'plugins.row.config')
542
+ check('row config slot registered', slotEntry !== undefined
543
+ && slotEntry.options.key === 'dsh-sidebar-browser-agent#sidebar-browser-agent'
544
+ && slotEntry.options.locale === 'sidebarBrowserAgent', slotEntry?.options)
545
+ const configPage = slotEntry?.component
546
+ check('config summary renders', configPage({ view: 'summary', t: (key) => key }) === 'config.summary', configPage?.name)
547
+
548
+ const testHook = moduleExports.__test
549
+ check('config helpers exported', testHook !== undefined && typeof testHook.configOps === 'function', Object.keys(moduleExports))
550
+ check('hosts text round-trip', testHook.hostsToText(['example.com', 'localhost:8765']) === 'example.com\nlocalhost:8765'
551
+ && JSON.stringify(testHook.textToHosts('example.com, LocalHost:8765\n\nexample.com')) === JSON.stringify(['example.com', 'localhost:8765']), testHook.textToHosts('example.com, LocalHost:8765\n\nexample.com'))
552
+ const ops = testHook.configOps(
553
+ { enabled: false, allowEval: true, allowNativeInput: true, hostsText: 'example.com\nlocalhost:8765' },
554
+ { enabled: true, allowEval: true, allowNativeInput: true, allowedHosts: ['example.com'] },
555
+ )
556
+ check('config ops describe changes', JSON.stringify(ops) === JSON.stringify([
557
+ { op: 'set', path: ['enabled'], value: false },
558
+ { op: 'set', path: ['allowedHosts'], value: ['example.com', 'localhost:8765'] },
559
+ ]), JSON.stringify(ops))
560
+ const pageTree = configPage({ view: 'page', t: (key) => key, form: { state: fakeConfigForms.get().getSnapshot(), mutate: async () => true } })
561
+ const flatten = (input, found = []) => {
562
+ if (input === null || input === undefined || typeof input !== 'object') return found
563
+ if (typeof input.type === 'function') {
564
+ flatten(input.type(input.props ?? {}), found)
565
+ return found
566
+ }
567
+ found.push(input)
568
+ for (const child of input.children ?? []) flatten(child, found)
569
+ return found
570
+ }
571
+ const pageNodes = flatten(pageTree)
572
+ check('config page renders heading and switches',
573
+ pageNodes.some((node) => node.type === 'h3' && node.children?.[0] === 'config.heading')
574
+ && pageNodes.filter((node) => node.type === 'input' && node.props.type === 'checkbox').length === 3
575
+ && pageNodes.some((node) => node.type === 'textarea'), pageNodes.map((node) => node.type).join(','))
576
+
577
+ // --- tab management ---
578
+ const open = await send({ id: 'o1', action: 'open', sessionId: 'session-1', url: 'https://example.test/new' })
579
+ check('open result', open.ok === true && open.value.tabId === 'tab-2' && open.value.url === 'https://example.test/new', open.value)
580
+
581
+ const listed = await send({ id: 'l1', action: 'list', sessionId: 'session-1' })
582
+ check('list carries native facts', listed.ok === true && listed.value.tabs.length === 2 && listed.value.tabs.every((entry) => entry.url === 'https://example.test/page'), JSON.stringify(listed.value))
583
+
584
+ const navigate = await send({ id: 'n1', action: 'navigate', sessionId: 'session-1', tabId: tab.tabId, url: 'https://example.org/next' })
585
+ check('navigate native', navigate.ok === true && webview.loadUrlCalls.includes('https://example.org/next'), navigate.value)
586
+ const loaded = await send({ id: 'r1', action: 'reload', sessionId: 'session-1', tabId: tab.tabId })
587
+ check('reload native', loaded.ok === true && webview.reloadCalls === 1, loaded.value)
588
+
589
+ // --- page actions ---
590
+ const text = await send({ id: 'p1', action: 'read', sessionId: 'session-1', tabId: tab.tabId, mode: 'text' })
591
+ check('read text', text.ok === true && text.value.text.includes('Welcome to Test Page') && text.value.title === 'Test Page', text.value)
592
+
593
+ const interactive = await send({ id: 'p2', action: 'read', sessionId: 'session-1', tabId: tab.tabId, mode: 'interactive' })
594
+ const labels = (interactive.value?.elements ?? []).map((element) => element.label)
595
+ check('read interactive', interactive.ok === true && labels.includes('Submit answer') && labels.includes('More information'), labels)
596
+
597
+ inputEvents.length = 0
598
+ const click = await send({ id: 'p3', action: 'click', sessionId: 'session-1', tabId: tab.tabId, contains: 'Submit answer' })
599
+ check('click native input', click.ok === true && click.value.target.tag === 'button'
600
+ && inputEvents.some((event) => event.type === 'mouseDown' && event.x === 180 && event.y === 220), JSON.stringify(inputEvents))
601
+
602
+ inputEvents.length = 0
603
+ const type = await send({ id: 'p4', action: 'type', sessionId: 'session-1', tabId: tab.tabId, selector: '#q', text: 'hello world', clear: true, submit: true })
604
+ check('type writes value', type.ok === true && type.value.typed === 11 && input.value === 'hello world'
605
+ && input.events.includes('input') && input.events.includes('change'), `${input.value} ${JSON.stringify(input.events)}`)
606
+ check('type submits with Enter', inputEvents.some((event) => event.keyCode === 'Enter'), JSON.stringify(inputEvents))
607
+
608
+ inputEvents.length = 0
609
+ const press = await send({ id: 'p5', action: 'press', sessionId: 'session-1', tabId: tab.tabId, key: 'Tab' })
610
+ check('press native key', press.ok === true && press.value.key === 'Tab' && inputEvents.some((event) => event.keyCode === 'Tab'), JSON.stringify(inputEvents))
611
+
612
+ const beforeScroll = guest.scrollY
613
+ const scroll = await send({ id: 'p6', action: 'scroll', sessionId: 'session-1', tabId: tab.tabId, dy: 400 })
614
+ check('scroll', scroll.ok === true && scroll.value.scrollY === beforeScroll + 400 && scroll.value.scrollHeight === 2400, scroll.value)
615
+
616
+ const waited = await send({ id: 'p7', action: 'wait', sessionId: 'session-1', tabId: tab.tabId, selector: 'button' })
617
+ check('wait matched', waited.ok === true && waited.value.matched === true, waited.value)
618
+
619
+ const evaluated = await send({ id: 'p8', action: 'eval', sessionId: 'session-1', tabId: tab.tabId, code: 'document.querySelector("#q").value' })
620
+ check('eval returns JSON text', evaluated.ok === true && evaluated.value.result === '"hello world"', evaluated.value)
621
+
622
+ const shot = await send({ id: 'p9', action: 'screenshot', sessionId: 'session-1', tabId: tab.tabId })
623
+ check('screenshot payload', shot.ok === true && shot.value.mediaType === 'image/png' && shot.value.width === 1024
624
+ && typeof shot.value.base64 === 'string' && shot.value.base64.length > 0, JSON.stringify({ ...shot.value, base64: '…' }))
625
+
626
+ // --- timeout helpers and hung-page resilience ---
627
+ const timeoutError = await moduleExports.__test.withTimeout(new Promise(() => {}), 80, 'Probe')
628
+ .then(() => null, (error) => error.message)
629
+ check('withTimeout rejects hung operations', typeof timeoutError === 'string' && timeoutError.includes('did not finish within'), timeoutError)
630
+
631
+ const originalExecute = webview.executeJavaScript
632
+ const originalGuestTimeout = moduleExports.__test.TIMEOUTS.guest
633
+ const originalLoadURL = webview.loadURL
634
+ const originalNavigationTimeout = moduleExports.__test.TIMEOUTS.navigation
635
+ try {
636
+ moduleExports.__test.TIMEOUTS.guest = 150
637
+ webview.executeJavaScript = () => new Promise(() => {})
638
+ const hungRead = await send({ id: 'h1', action: 'read', sessionId: 'session-1', tabId: tab.tabId })
639
+ check('hung page becomes a command error', hungRead.ok === false && String(hungRead.error).includes('did not finish within'), hungRead.error)
640
+ webview.executeJavaScript = originalExecute
641
+ moduleExports.__test.TIMEOUTS.guest = originalGuestTimeout
642
+ const recoveredList = await send({ id: 'h2', action: 'list', sessionId: 'session-1' })
643
+ check('bridge keeps polling after a hung command', recoveredList.ok === true && Array.isArray(recoveredList.value.tabs), JSON.stringify(recoveredList).slice(0, 160))
644
+
645
+ moduleExports.__test.TIMEOUTS.navigation = 150
646
+ webview.loadURL = () => new Promise(() => {})
647
+ const hungNavigation = await send({ id: 'h3', action: 'navigate', sessionId: 'session-1', tabId: tab.tabId, url: 'https://example.test/hang' })
648
+ check('hung navigation becomes a command error', hungNavigation.ok === false && String(hungNavigation.error).includes('did not finish within'), hungNavigation.error)
649
+ webview.loadURL = originalLoadURL
650
+ moduleExports.__test.TIMEOUTS.navigation = originalNavigationTimeout
651
+ const recoveredAfterNavigation = await send({ id: 'h4', action: 'list', sessionId: 'session-1' })
652
+ check('bridge keeps polling after a hung navigation', recoveredAfterNavigation.ok === true, JSON.stringify(recoveredAfterNavigation).slice(0, 160))
653
+ } finally {
654
+ webview.executeJavaScript = originalExecute
655
+ webview.loadURL = originalLoadURL
656
+ moduleExports.__test.TIMEOUTS.guest = originalGuestTimeout
657
+ moduleExports.__test.TIMEOUTS.navigation = originalNavigationTimeout
658
+ }
659
+
660
+ // --- off-screen support ---
661
+ sidebar.setMounted('session-2')
662
+ const backgroundRead = await send({ id: 'b1', action: 'read', sessionId: 'session-1', tabId: tab.tabId })
663
+ check('background read works with note', backgroundRead.ok === true && typeof backgroundRead.value.note === 'string'
664
+ && backgroundRead.value.note.includes('not visible right now'), backgroundRead.value?.note)
665
+ const backgroundOpen = await send({ id: 'b2', action: 'open', sessionId: 'session-1', url: 'https://example.test/x' })
666
+ check('background open refuses', backgroundOpen.ok === false && backgroundOpen.error.includes('showing conversation session-2'), backgroundOpen.error)
667
+ sidebar.setMounted('session-1')
668
+
669
+ // --- session mismatch on page actions is refused ---
670
+ const mismatchRead = await send({ id: 'm1', action: 'read', sessionId: 'session-9', tabId: tab.tabId })
671
+ check('unknown session surface refuses', mismatchRead.ok === false
672
+ && mismatchRead.error.includes('no right-sidebar surface'), mismatchRead.error)
673
+
674
+ // --- teardown stops polling ---
675
+ stopBridge()
676
+ const pollCount = polls.length
677
+ await new Promise((resolve) => setTimeout(resolve, 120))
678
+ check('bridge stops after dispose', polls.length === pollCount, `${polls.length} vs ${pollCount}`)
679
+
680
+ console.log(failures === 0 ? '\nALL PASS' : `\n${failures} FAILURE(S)`)
681
+ process.exitCode = failures === 0 ? 0 : 1
682
+ }
683
+
684
+ main().catch((error) => {
685
+ console.error('harness crashed:', error)
686
+ process.exitCode = 1
687
+ })