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.
- package/CHANGELOG.md +69 -0
- package/LICENSE +21 -0
- package/README.md +226 -2
- package/cordis.patch.yml +8 -0
- package/icon.svg +8 -0
- package/lib/client.js +1651 -0
- package/lib/entry-0.2.6.js +1181 -0
- package/lib/index.js +1181 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +61 -4
- package/test/client-harness.mjs +687 -0
- package/test/host-harness.mjs +548 -0
- package/test/run.mjs +22 -0
- package/vendor/NOTICE.md +15 -0
- package/vendor/cosmokit/LICENSE +21 -0
- package/vendor/cosmokit/index.js +418 -0
- package/vendor/schemastery/LICENSE +21 -0
- package/vendor/schemastery/index.mjs +635 -0
|
@@ -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
|
+
})
|