dsh-browser-plus 0.0.0-stage → 0.5.0

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 (76) hide show
  1. package/CHANGELOG.md +166 -0
  2. package/LICENSE +22 -0
  3. package/NOTICE.md +7 -0
  4. package/README.en.md +100 -0
  5. package/README.md +99 -2
  6. package/assets/dsh-browser-plus-256.png +0 -0
  7. package/assets/dsh-browser-plus-512.png +0 -0
  8. package/assets/dsh-browser-plus-small.svg +9 -0
  9. package/assets/dsh-browser-plus.ico +0 -0
  10. package/assets/dsh-browser-plus.svg +11 -0
  11. package/assets/readme-workspace.png +0 -0
  12. package/cordis.patch.yml +17 -0
  13. package/docs/MIGRATION.md +48 -0
  14. package/docs/README.md +22 -0
  15. package/docs/SOAK-CHECKLIST.md +98 -0
  16. package/docs/architecture.md +88 -0
  17. package/docs/tool-reference.md +124 -0
  18. package/docs/user-guide.md +121 -0
  19. package/docs/why-browser.md +45 -0
  20. package/lib/browser/runtime.d.ts +225 -0
  21. package/lib/browser/runtime.js +302 -0
  22. package/lib/browser/types.d.ts +668 -0
  23. package/lib/browser/types.js +18 -0
  24. package/lib/browser-electron/auth-cookies.d.ts +54 -0
  25. package/lib/browser-electron/auth-cookies.js +83 -0
  26. package/lib/browser-electron/chrome-state.d.ts +187 -0
  27. package/lib/browser-electron/chrome-state.js +12 -0
  28. package/lib/browser-electron/entry.d.ts +66 -0
  29. package/lib/browser-electron/entry.js +62 -0
  30. package/lib/browser-electron/fingerprint.d.ts +29 -0
  31. package/lib/browser-electron/fingerprint.js +42 -0
  32. package/lib/browser-electron/host-main.d.ts +18 -0
  33. package/lib/browser-electron/host-main.js +2494 -0
  34. package/lib/browser-electron/icon.d.ts +11 -0
  35. package/lib/browser-electron/icon.js +23 -0
  36. package/lib/browser-electron/page-chrome.d.ts +21 -0
  37. package/lib/browser-electron/page-chrome.js +2034 -0
  38. package/lib/browser-electron/provider.d.ts +709 -0
  39. package/lib/browser-electron/provider.js +2575 -0
  40. package/lib/browser-electron/remote-host.d.ts +143 -0
  41. package/lib/browser-electron/remote-host.js +952 -0
  42. package/lib/browser-electron/task-summary.d.ts +2 -0
  43. package/lib/browser-electron/task-summary.js +12 -0
  44. package/lib/browser-electron/task-thumbnail.d.ts +11 -0
  45. package/lib/browser-electron/task-thumbnail.js +9 -0
  46. package/lib/browser-electron/write-guard.d.ts +41 -0
  47. package/lib/browser-electron/write-guard.js +123 -0
  48. package/lib/index.d.ts +16 -0
  49. package/lib/index.js +14 -0
  50. package/lib/tool-browser/index.d.ts +31 -0
  51. package/lib/tool-browser/index.js +1931 -0
  52. package/package.json +95 -4
  53. package/screenshots.json +3 -0
  54. package/scripts/build-icons.mjs +80 -0
  55. package/scripts/capture-window.ps1 +79 -0
  56. package/scripts/crop-image.ps1 +20 -0
  57. package/scripts/smoke-browser-tools.mjs +1968 -0
  58. package/scripts/smoke-chrome-world.mjs +63 -0
  59. package/scripts/smoke-electron-host.mjs +50 -0
  60. package/src/browser/runtime.ts +470 -0
  61. package/src/browser/types.ts +649 -0
  62. package/src/browser-electron/auth-cookies.ts +125 -0
  63. package/src/browser-electron/chrome-state.ts +174 -0
  64. package/src/browser-electron/entry.ts +115 -0
  65. package/src/browser-electron/fingerprint.ts +45 -0
  66. package/src/browser-electron/host-main.ts +2330 -0
  67. package/src/browser-electron/icon.ts +26 -0
  68. package/src/browser-electron/page-chrome.ts +2046 -0
  69. package/src/browser-electron/provider.ts +3088 -0
  70. package/src/browser-electron/remote-host.ts +1004 -0
  71. package/src/browser-electron/task-summary.ts +10 -0
  72. package/src/browser-electron/task-thumbnail.ts +17 -0
  73. package/src/browser-electron/write-guard.ts +134 -0
  74. package/src/index.ts +52 -0
  75. package/src/tool-browser/index.ts +1974 -0
  76. package/src/types/electron-shim.d.ts +143 -0
@@ -0,0 +1,1968 @@
1
+ /**
2
+ * Integration smoke for the provider's tool surface, against a REAL Electron
3
+ * host. The unit suite drives a fake host, so this is the only place the whole
4
+ * chain runs end to end: provider -> RPC -> host-main -> CDP -> Chromium.
5
+ *
6
+ * It takes its own profile via DSH_BROWSER_PLUS_USER_DATA, because Chromium
7
+ * holds a singleton lock per profile and the one a running DSH uses is busy.
8
+ * A window will appear for the duration; that is the point.
9
+ *
10
+ * Run: npm run smoke:browser-tools
11
+ */
12
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
13
+ import { tmpdir } from 'node:os'
14
+ import { join } from 'node:path'
15
+
16
+ import { ElectronBrowserProvider } from '../lib/browser-electron/provider.js'
17
+ import { validateJsonSchemaValue } from '@deepseek-ai/dsh-tools'
18
+ import { apply } from '../lib/tool-browser/index.js'
19
+ import { RemoteElectronViewHost, defaultHostMainPath } from '../lib/browser-electron/remote-host.js'
20
+
21
+ const profile = mkdtempSync(join(tmpdir(), 'dsh-bt-profile-'))
22
+ const outDir = mkdtempSync(join(tmpdir(), 'dsh-bt-out-'))
23
+ // Must be set before the host spawns: the child reads it at startup.
24
+ process.env.DSH_BROWSER_PLUS_USER_DATA = profile
25
+
26
+ const results = []
27
+ const check = async (name, fn) => {
28
+ try {
29
+ const value = await fn()
30
+ results.push({ name, ok: true })
31
+ console.log(' ok ' + name.padEnd(26) + (value === undefined ? '' : JSON.stringify(value).slice(0, 110)))
32
+ } catch (error) {
33
+ results.push({ name, ok: false, error: String(error?.message ?? error) })
34
+ console.log(' FAIL ' + name.padEnd(26) + String(error?.message ?? error).slice(0, 170))
35
+ }
36
+ }
37
+
38
+ const host = new RemoteElectronViewHost(defaultHostMainPath())
39
+ const provider = new ElectronBrowserProvider(host, { writeRoots: [outDir], readRoots: [outDir] })
40
+
41
+ try {
42
+ const session = await provider.open()
43
+ console.log('session', session)
44
+
45
+ await check('navigate', async () => {
46
+ await provider.navigate(session, { url: 'https://example.com/' })
47
+ const tabs = await provider.listTabs(session)
48
+ return tabs.find(tab => tab.active)?.url ?? '(no active tab)'
49
+ })
50
+ await check('content', async () => (await provider.content(session, { format: 'txt', maxChars: 300 })).content.slice(0, 50))
51
+ await check('snapshot', async () => (await provider.snapshot(session)).elements.length)
52
+ await check('screenshot', async () => (await provider.screenshot(session)).dataUrl.length)
53
+ // Non-navigating targets on purpose: clicking the "Learn more" link would move
54
+ // the page on and invalidate every later check. example.com currently has no
55
+ // h1 and splits its body text into one span per character, so the paragraph is
56
+ // the honest target — and matching it proves the matcher walks containers
57
+ // rather than only leaf nodes.
58
+ await check('click selector', async () => await provider.click(session, { selector: 'p' }))
59
+ await check('click text', async () => await provider.click(session, { text: 'This domain is for use' }))
60
+ await check('click coordinates', async () => await provider.click(session, { x: 5, y: 5 }))
61
+ await check('click text missing', async () => {
62
+ try { await provider.click(session, { text: 'zzz-not-here-zzz' }) } catch (error) { return error.code }
63
+ return 'no error'
64
+ })
65
+ await check('waitForElement', async () => (await provider.waitForElement(session, { selector: 'body' })).tag)
66
+ await check('scrape concurrent', async () => {
67
+ const out = join(outDir, 'rows.jsonl')
68
+ const started = await provider.startScrape(session, {
69
+ urls: ['https://example.com/', 'https://www.iana.org/help/example-domains'],
70
+ script: '({ title: document.title, links: document.querySelectorAll("a").length })',
71
+ outPath: out,
72
+ concurrency: 2,
73
+ timeoutMs: 20_000,
74
+ })
75
+ for (let i = 0; i < 120; i += 1) {
76
+ const status = await provider.scrapeStatus(started.id)
77
+ if (status.state !== 'running') {
78
+ const rows = readFileSync(out, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line))
79
+ return { state: status.state, rows: rows.length, seqs: rows.map(row => row.seq).sort((a, b) => a - b) }
80
+ }
81
+ await new Promise(resolve => setTimeout(resolve, 500))
82
+ }
83
+ return 'timed out'
84
+ })
85
+ // example.com sets no cookies, so the round trip needs a host that does.
86
+ await check('cookie round trip', async () => {
87
+ await provider.navigate(session, { url: 'https://httpbin.org/cookies/set?dsh_probe=1' })
88
+ const exported = await provider.flushAuth(session)
89
+ const file = join(outDir, 'cookies.json')
90
+ writeFileSync(file, JSON.stringify({ cookies: exported }))
91
+ const probe = () => exported.filter(cookie => cookie.name === 'dsh_probe').length
92
+ await provider.clearAuth(session, { name: 'dsh_probe' })
93
+ const afterClear = (await provider.flushAuth(session)).filter(cookie => cookie.name === 'dsh_probe').length
94
+ const imported = await provider.importAuth(session, file)
95
+ const afterImport = (await provider.flushAuth(session)).filter(cookie => cookie.name === 'dsh_probe').length
96
+ return { exported: exported.length, probe: probe(), afterClear, restored: imported.restored, failed: imported.failed, afterImport }
97
+ })
98
+ // The ⋮ menu's import: the chrome hands a cookie list to the host, which writes it
99
+ // into the profile. The file picker itself cannot be driven, so the seam below
100
+ // starts at the same place the picker's reader does.
101
+ await check('the cookie import path works from the chrome', async () => {
102
+ const probe = 'dsh_import_probe'
103
+ await provider.clearAuth(session, { name: probe })
104
+ const list = [{ domain: '.example.com', name: probe, value: 'from-the-menu', path: '/', secure: true }]
105
+ const sent = await provider.execute(session, { script: '((list) => (window.__dshChromeImport ? window.__dshChromeImport(list) : -1))(' + JSON.stringify(list) + ')' })
106
+ for (let i = 0; i < 25; i += 1) {
107
+ await new Promise(resolve => setTimeout(resolve, 200))
108
+ const found = (await provider.flushAuth(session)).filter(cookie => cookie.name === probe)
109
+ if (found.length > 0) return { sent: sent.value, value: found[0].value }
110
+ }
111
+ throw new Error('the imported cookie never reached the profile')
112
+ })
113
+ await check('chromeWorld config reachable', async () => (await provider.listTabs(session)).length)
114
+ // The chrome lives in the page's closed shadow root, so the only honest way to
115
+ // prove its + and × buttons work is to click where they are. That click travels
116
+ // the whole chain: CDP -> page chrome -> host binding -> host -> provider event
117
+ // -> tab model. The strip geometry is fixed (8px padding, 240px tabs, 2px gaps,
118
+ // a 28px + button with a 4px left margin, 18px close buttons 10px from the tab
119
+ // edge), so these candidates bracket the expected centres rather than betting
120
+ // the whole check on one pixel.
121
+ const CHROME_ROW_Y = 20
122
+ const CHROME_TOOLBAR_Y = 62
123
+ // The chrome lives in the host's own view, which is not a tab: driving it needs its
124
+ // own channel. (CDP input aimed at a page is delivered whatever is on top, so once
125
+ // the page stops drawing a toolbar of its own, these must go to the frame.)
126
+ const chromeClick = async (x, y) => {
127
+ await provider.chromeInput('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 })
128
+ await provider.chromeInput('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 })
129
+ }
130
+ const chromeType = async (text) => provider.chromeInput('Input.insertText', { text })
131
+ const chromeEnter = async () => {
132
+ await provider.chromeInput('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 })
133
+ await provider.chromeInput('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 })
134
+ }
135
+ await check('chrome + creates a provider tab', async () => {
136
+ const before = (await provider.listTabs(session)).length
137
+ for (const x of [258, 252, 266, 246]) {
138
+ await chromeClick(x, CHROME_ROW_Y)
139
+ await new Promise(resolve => setTimeout(resolve, 500))
140
+ const after = (await provider.listTabs(session)).length
141
+ if (after > before) return { before, after, x }
142
+ }
143
+ throw new Error('no tab was created: the + button was never hit (tabs=' + String(before) + ')')
144
+ })
145
+ await check('chrome x closes the tab it belongs to', async () => {
146
+ const before = (await provider.listTabs(session)).length
147
+ if (before < 2) throw new Error('needs two tabs to close one, has ' + String(before))
148
+ for (const x of [453, 447, 459, 441]) {
149
+ await chromeClick(x, CHROME_ROW_Y)
150
+ await new Promise(resolve => setTimeout(resolve, 500))
151
+ const after = (await provider.listTabs(session)).length
152
+ if (after < before) return { before, after, x }
153
+ }
154
+ throw new Error('no tab was closed: the × was never hit (tabs=' + String(before) + ')')
155
+ })
156
+ // The chrome also has a copy in the host's own 84px view (that is what will let the
157
+ // page viewport really shrink). That view is not a tab, so driving it needs its own
158
+ // channel — and its buttons must work, because a person's click lands there.
159
+ await check('the chrome frame view drives the tab list', async () => {
160
+ const before = (await provider.listTabs(session)).length
161
+ const click = async (x) => {
162
+ await provider.chromeInput('Input.dispatchMouseEvent', { type: 'mousePressed', x, y: CHROME_ROW_Y, button: 'left', clickCount: 1 })
163
+ await provider.chromeInput('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y: CHROME_ROW_Y, button: 'left', clickCount: 1 })
164
+ }
165
+ for (const x of [258, 252, 266, 246]) {
166
+ await click(x)
167
+ await new Promise(resolve => setTimeout(resolve, 500))
168
+ const after = (await provider.listTabs(session)).length
169
+ if (after > before) return { before, after, x }
170
+ }
171
+ throw new Error('the frame chrome never created a tab (tabs=' + String(before) + ')')
172
+ })
173
+ // Chrome's drag-to-reorder. The strip order lives in the host (a Set) and is mirrored
174
+ // by the provider, so a drag has to move both — what browser_list_tabs reports is the
175
+ // provider's order, which makes this an end-to-end assertion.
176
+ await check('dragging a tab reorders the strip', async () => {
177
+ const before = (await provider.listTabs(session)).map(tab => tab.id)
178
+ if (before.length < 2) throw new Error('needs two tabs to drag, has ' + String(before.length))
179
+ await provider.chromeInput('Input.dispatchMouseEvent', { type: 'mousePressed', x: 130, y: CHROME_ROW_Y, button: 'left', clickCount: 1 })
180
+ for (const x of [200, 300, 420, 560, 700]) {
181
+ await provider.chromeInput('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y: CHROME_ROW_Y, button: 'left' })
182
+ await new Promise(resolve => setTimeout(resolve, 60))
183
+ }
184
+ await provider.chromeInput('Input.dispatchMouseEvent', { type: 'mouseReleased', x: 700, y: CHROME_ROW_Y, button: 'left', clickCount: 1 })
185
+ await new Promise(resolve => setTimeout(resolve, 700))
186
+ const after = (await provider.listTabs(session)).map(tab => tab.id)
187
+ if (after.length !== before.length) throw new Error('the drag changed the tab count: ' + JSON.stringify({ before, after }))
188
+ if (after[0] === before[0]) throw new Error('the order did not change: ' + JSON.stringify({ before, after }))
189
+ if (JSON.stringify([...after].sort()) !== JSON.stringify([...before].sort())) throw new Error('the drag lost or duplicated a tab')
190
+ return { first: before[0].slice(0, 8) + ' -> ' + after[0].slice(0, 8) }
191
+ })
192
+ // The frame's chrome cannot touch the page itself — its address bar has to be
193
+ // relayed through the host. This drives it the way a person would: click the
194
+ // omnibox, type, Enter, and the PAGE navigates.
195
+ await check('the frame chrome drives the page it is showing', async () => {
196
+ const frameInput = async (method, params) => provider.chromeInput(method, params)
197
+ await frameInput('Input.dispatchMouseEvent', { type: 'mousePressed', x: 690, y: CHROME_TOOLBAR_Y, button: 'left', clickCount: 1 })
198
+ await frameInput('Input.dispatchMouseEvent', { type: 'mouseReleased', x: 690, y: CHROME_TOOLBAR_Y, button: 'left', clickCount: 1 })
199
+ await frameInput('Input.insertText', { text: 'example.com' })
200
+ await frameInput('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 })
201
+ await frameInput('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 })
202
+ let url = ''
203
+ for (let i = 0; i < 25; i += 1) {
204
+ await new Promise(resolve => setTimeout(resolve, 200))
205
+ url = (await provider.listTabs(session)).find(tab => tab.active)?.url ?? ''
206
+ if (url.includes('example.com')) return { url }
207
+ }
208
+ throw new Error('the page never navigated: ' + JSON.stringify(url))
209
+ })
210
+ // The frame's buttons are the real ones once it is on screen, but its own popups
211
+ // would be clipped at 84px — so it reports where the button is and the PAGE draws
212
+ // the menu there. Clicking the frame's star must open the page's bookmarks panel.
213
+ // Ctrl+L belongs to the toolbar, which lives in the frame now, so the page's copy
214
+ // relays it. If the relay works the frame's omnibox ends up focused — proved by
215
+ // typing into the frame and watching the PAGE navigate.
216
+ await check('Ctrl+L hands the page focus to the frame omnibox', async () => {
217
+ await provider.navigate(session, { url: 'https://example.com/' })
218
+ await new Promise(resolve => setTimeout(resolve, 600))
219
+ await provider.pressKey(session, { key: 'l', modifiers: ['ctrl'] })
220
+ await new Promise(resolve => setTimeout(resolve, 400))
221
+ await chromeType('example.org')
222
+ await chromeEnter()
223
+ let url = ''
224
+ for (let i = 0; i < 25; i += 1) {
225
+ await new Promise(resolve => setTimeout(resolve, 200))
226
+ url = (await provider.listTabs(session)).find(tab => tab.active)?.url ?? ''
227
+ if (url.includes('example.org')) return { url }
228
+ }
229
+ throw new Error('the omnibox never got the focus: ' + JSON.stringify(url))
230
+ })
231
+ // The user's report: the toolbar menus opened when the pointer merely crossed them.
232
+ // Hover now has to rest on the button for a moment, so this check needs BOTH halves —
233
+ // a sweep must open nothing, and resting on the same button must still open it.
234
+ // (Without the second half a "menus never open at all" regression would pass.)
235
+ await check('a pass-over opens nothing, resting on the button does', async () => {
236
+ const readPanels = async () => JSON.parse(String((await provider.execute(session, {
237
+ script: 'JSON.stringify(window.__dshChromePanels ? window.__dshChromePanels() : [])',
238
+ })).value))
239
+ const moveTo = async (x, y) => { await provider.chromeInput('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y }) }
240
+ const openIds = async () => (await readPanels()).filter(entry => entry.open === true).map(entry => entry.id)
241
+ // A click in the page is "outside the chrome" and closes every menu (the page copy
242
+ // has always done that), which is how each attempt starts from a known state.
243
+ const closeAnyMenu = async () => {
244
+ await provider.click(session, { x: 4, y: 4 })
245
+ await new Promise(resolve => setTimeout(resolve, 350))
246
+ }
247
+ // Cross the toolbar the way a pointer does on its way somewhere else: in and straight
248
+ // back out. Each RPC round trip is normally ~5-10ms (measured), but a busy machine can
249
+ // stall one for a second — and a one-second dwell IS a rest, so that sweep would be a
250
+ // false failure. Measure it, and only accept an attempt that really was a pass-over.
251
+ let swept = []
252
+ let dwell = Infinity
253
+ for (let attempt = 0; attempt < 6; attempt += 1) {
254
+ await closeAnyMenu()
255
+ await moveTo(700, CHROME_TOOLBAR_Y)
256
+ await new Promise(resolve => setTimeout(resolve, 250))
257
+ const t0 = Date.now()
258
+ await moveTo(1362, CHROME_TOOLBAR_Y)
259
+ await moveTo(700, CHROME_TOOLBAR_Y)
260
+ dwell = Date.now() - t0
261
+ if (dwell > 150) continue
262
+ await new Promise(resolve => setTimeout(resolve, 800))
263
+ swept = await openIds()
264
+ break
265
+ }
266
+ if (dwell > 150) throw new Error('could not simulate a fast pass-over: the sweep took ' + String(dwell) + 'ms')
267
+ if (swept.length > 0) throw new Error('crossing the toolbar opened ' + JSON.stringify(swept))
268
+ // Rest on it: the menu has to appear (this is the half that keeps the check honest).
269
+ await moveTo(1362, CHROME_TOOLBAR_Y)
270
+ let rested = []
271
+ for (let i = 0; i < 20; i += 1) {
272
+ await new Promise(resolve => setTimeout(resolve, 150))
273
+ rested = await openIds()
274
+ if (rested.length > 0) break
275
+ }
276
+ if (rested.length === 0) throw new Error('resting on the button never opened its menu')
277
+ // Put it back: click pins the hover-opened menu, a second click closes it.
278
+ const clickAt = async (x, y) => {
279
+ await provider.chromeInput('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 })
280
+ await provider.chromeInput('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 })
281
+ }
282
+ await clickAt(1362, CHROME_TOOLBAR_Y)
283
+ await new Promise(resolve => setTimeout(resolve, 350))
284
+ await clickAt(1362, CHROME_TOOLBAR_Y)
285
+ await new Promise(resolve => setTimeout(resolve, 400))
286
+ return { swept: 'nothing', rested: rested.join(',') }
287
+ })
288
+ // 动效:二级菜单是「浮出来」的,不是一个 display 硬切。弹层在闭影子根里,测试从外面
289
+ // 既点不到也算不到它的 opacity,所以由 chrome 自己记录动画事件,这里断言它真的跑了。
290
+ await check('opening a menu runs its entrance animation', async () => {
291
+ const motion = async () => JSON.parse(String((await provider.execute(session, {
292
+ script: 'JSON.stringify(window.__dshChromeMotion ? window.__dshChromeMotion() : null)',
293
+ })).value))
294
+ const moveTo = async (x, y) => { await provider.chromeInput('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y }) }
295
+ const clickAt = async (x, y) => {
296
+ await provider.chromeInput('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 })
297
+ await provider.chromeInput('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 })
298
+ }
299
+ await provider.execute(session, { script: '(() => { window.__dshChromeMotionClear?.(); return "cleared" })()' })
300
+ // Start from a closed menu: a click in the page is "outside the chrome".
301
+ await provider.click(session, { x: 4, y: 4 })
302
+ await new Promise(resolve => setTimeout(resolve, 400))
303
+ await moveTo(700, CHROME_TOOLBAR_Y)
304
+ await new Promise(resolve => setTimeout(resolve, 250))
305
+ await moveTo(1362, CHROME_TOOLBAR_Y)
306
+ let started
307
+ for (let i = 0; i < 24; i += 1) {
308
+ await new Promise(resolve => setTimeout(resolve, 100))
309
+ const log = await motion()
310
+ if (!Array.isArray(log)) throw new Error('window.__dshChromeMotion is missing: ' + JSON.stringify(log))
311
+ // 只看**入场**那条:上一条检查刚关过菜单,它的退场(dshPopOut)可能还在日志里,
312
+ // 拿「第一条 main 的动画」当入场会被它骗到(第 40 轮真踩了)。
313
+ started = log.find(entry => entry.id === 'main' && entry.phase === 'start' && entry.name === 'dshPopIn')
314
+ if (started !== undefined) break
315
+ }
316
+ if (started === undefined) throw new Error('the ⋮ menu opened without running an entrance animation: ' + JSON.stringify(await motion()))
317
+ // It has to finish, too: an animation stuck at its first keyframe would leave the
318
+ // menu invisible while every class-based check still passed.
319
+ let ended = false
320
+ for (let i = 0; i < 12 && !ended; i += 1) {
321
+ await new Promise(resolve => setTimeout(resolve, 80))
322
+ ended = (await motion()).some(entry => entry.id === 'main' && entry.phase === 'end' && entry.name === 'dshPopIn')
323
+ }
324
+ if (!ended) throw new Error('the entrance animation never finished')
325
+ // Put it back the way we found it: click pins the hover-opened menu, a second closes it.
326
+ await clickAt(1362, CHROME_TOOLBAR_Y)
327
+ await new Promise(resolve => setTimeout(resolve, 300))
328
+ await clickAt(1362, CHROME_TOOLBAR_Y)
329
+ await new Promise(resolve => setTimeout(resolve, 400))
330
+ // 指针挪开,别让下一个检查的「移进按钮」变成原地不动(那样没有 pointerenter)。
331
+ await moveTo(700, CHROME_TOOLBAR_Y)
332
+ return { animation: started.name, finished: true }
333
+ })
334
+ // 关掉一个标签,剩下的标签要**滑**到新位置,而不是瞬移。用「关掉一个后台标签」来验:
335
+ // 这样当前文档不变(日志就在这个文档里),标签栏的顺序变了,FLIP 必须记一笔。
336
+ await check('closing a tab slides the survivors into place', async () => {
337
+ const motion = async () => JSON.parse(String((await provider.execute(session, {
338
+ script: 'JSON.stringify(window.__dshChromeMotion ? window.__dshChromeMotion() : null)',
339
+ })).value))
340
+ const slide = log => (Array.isArray(log) ? log : []).filter(entry => entry.id === 'tab' && entry.phase === 'slide')
341
+ // 保证至少两个标签,并且让**不是第一个**的那个处于激活状态。
342
+ const opened = await provider.listTabs(session)
343
+ if (opened.length < 2) {
344
+ await provider.openUrl(session, { url: 'https://example.com/', newTab: true })
345
+ await new Promise(resolve => setTimeout(resolve, 900))
346
+ }
347
+ const firstId = (await provider.listTabs(session))[0]?.id
348
+ await provider.openUrl(session, { url: 'https://example.com/', newTab: true })
349
+ await new Promise(resolve => setTimeout(resolve, 900))
350
+ const before = (await provider.listTabs(session)).length
351
+ await provider.execute(session, { script: '(() => { window.__dshChromeMotionClear?.(); return "cleared" })()' })
352
+ // 关掉**第一个**标签:激活的那个不动,所以当前文档不变,日志留在原地。
353
+ await chromeClick(221, CHROME_ROW_Y)
354
+ await new Promise(resolve => setTimeout(resolve, 800))
355
+ const after = (await provider.listTabs(session)).length
356
+ if (after >= before) throw new Error('the first tab was not closed: ' + String(before) + ' -> ' + String(after))
357
+ if (firstId !== undefined && (await provider.listTabs(session)).some(tab => tab.id === firstId)) {
358
+ throw new Error('the wrong tab was closed')
359
+ }
360
+ const seen = slide(await motion())
361
+ if (seen.length === 0) throw new Error('the surviving tabs jumped instead of sliding')
362
+ return { slide: seen[0].name, tabs: String(before) + ' -> ' + String(after) }
363
+ })
364
+ // 关掉的那个标签自己也要收起来:原地淡出 + 宽度收到 0,而不是「啪」一下没了。
365
+ await check('a closed tab collapses instead of blinking out', async () => {
366
+ const motion = async () => JSON.parse(String((await provider.execute(session, {
367
+ script: 'JSON.stringify(window.__dshChromeMotion ? window.__dshChromeMotion() : null)',
368
+ })).value))
369
+ const opened = await provider.listTabs(session)
370
+ if (opened.length < 2) {
371
+ await provider.openUrl(session, { url: 'https://example.com/', newTab: true })
372
+ await new Promise(resolve => setTimeout(resolve, 900))
373
+ }
374
+ // 让第一个标签**不是**激活的那个:关它不会换文档,日志就留在原地。
375
+ await provider.openUrl(session, { url: 'https://example.com/', newTab: true })
376
+ await new Promise(resolve => setTimeout(resolve, 900))
377
+ const before = (await provider.listTabs(session)).length
378
+ await provider.execute(session, { script: '(() => { window.__dshChromeMotionClear?.(); return "cleared" })()' })
379
+ await chromeClick(221, CHROME_ROW_Y)
380
+ await new Promise(resolve => setTimeout(resolve, 700))
381
+ const after = (await provider.listTabs(session)).length
382
+ if (after >= before) throw new Error('the first tab was not closed: ' + String(before) + ' -> ' + String(after))
383
+ const closes = (await motion()).filter(entry => entry.id === 'tab' && entry.phase === 'close')
384
+ if (closes.length === 0) throw new Error('the closed tab blinked out instead of collapsing')
385
+ return { close: closes[0].name, tabs: String(before) + ' -> ' + String(after) }
386
+ })
387
+ // 同一个记录缝,验另一半动效:新标签浮入。两半都要断言 —— 初始渲染不许播(chrome 每次
388
+ // 导航都会重新注入,不挡的话整条标签栏会在每个页面加载时重弹一次),真的新建标签才播。
389
+ await check('a new tab floats in, but a plain page load does not', async () => {
390
+ const motion = async () => JSON.parse(String((await provider.execute(session, {
391
+ script: 'JSON.stringify(window.__dshChromeMotion ? window.__dshChromeMotion() : null)',
392
+ })).value))
393
+ const tabStarts = log => (Array.isArray(log) ? log : []).filter(entry => entry.id === 'tab' && entry.phase === 'start')
394
+ // 导航 = chrome 重新注入 = 新文档、新日志。所以不用清空:这个文档的第一次渲染
395
+ // 如果也播了动画,日志里当场就会有记录(去掉 guard 这条立刻变红)。
396
+ await provider.navigate(session, { url: 'https://example.com/' })
397
+ await new Promise(resolve => setTimeout(resolve, 1200))
398
+ const quiet = tabStarts(await motion())
399
+ if (quiet.length > 0) throw new Error('a plain page load animated the tab strip: ' + JSON.stringify(quiet))
400
+ // The entrance animation belongs to the document that was ALREADY open when the tab
401
+ // appeared — and the new tab takes focus, so the log has to be read from the old tab
402
+ // after switching back to it.
403
+ const opened = await provider.listTabs(session)
404
+ const firstTabId = opened.find(tab => tab.active)?.id
405
+ if (firstTabId === undefined) throw new Error('no active tab before opening a new one')
406
+ const before = opened.length
407
+ // 用 provider 的新标签 API,而不是点 :那个按钮的位置会随着标签数量往右漂。
408
+ await provider.openUrl(session, { url: 'https://example.com/', newTab: true })
409
+ await new Promise(resolve => setTimeout(resolve, 900))
410
+ if ((await provider.listTabs(session)).length <= before) throw new Error('no tab was created')
411
+ await provider.switchTab(session, firstTabId)
412
+ await new Promise(resolve => setTimeout(resolve, 500))
413
+ const started = tabStarts(await motion()).find(entry => entry.name === 'dshTabIn')
414
+ if (started === undefined) throw new Error('the new tab appeared without an entrance animation')
415
+ // 把指针挪开:下一个检查靠「移进按钮」触发悬停,指针停在按钮上的话不会有 pointerenter。
416
+ await provider.chromeInput('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 700, y: CHROME_TOOLBAR_Y })
417
+ return { animation: started.name, pageLoadsQuiet: true }
418
+ })
419
+ await check('hovering a button after using its menu shows the menu again', async () => {
420
+ const readPanels = async () => JSON.parse(String((await provider.execute(session, {
421
+ script: 'JSON.stringify(window.__dshChromePanels ? window.__dshChromePanels() : [])',
422
+ })).value))
423
+ const moveTo = async (x, y) => { await provider.chromeInput('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y }) }
424
+ const clickAt = async (x, y) => {
425
+ await provider.chromeInput('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 })
426
+ await provider.chromeInput('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 })
427
+ }
428
+ const waitForPanel = async (id) => {
429
+ for (let i = 0; i < 20; i += 1) {
430
+ await new Promise(resolve => setTimeout(resolve, 150))
431
+ const found = (await readPanels()).find(entry => entry.id === id && entry.open === true)
432
+ if (found) return found
433
+ }
434
+ return null
435
+ }
436
+ // Open the ⋮ menu from the FRAME (its button is the real one): the page puts its own
437
+ // copy of the menu under that button.
438
+ await moveTo(1362, CHROME_TOOLBAR_Y)
439
+ const menu = await waitForPanel('main')
440
+ if (menu === null) throw new Error('the ⋮ menu never opened: ' + JSON.stringify(await readPanels()))
441
+ // Leaving the button must NOT close it: the pointer has to travel from the button
442
+ // (in the frame) down into the menu (in the page), and the frame cannot see it once
443
+ // it crosses — so the frame must not decide anything on the way out.
444
+ await moveTo(1362, 80)
445
+ await new Promise(resolve => setTimeout(resolve, 700))
446
+ const stillOpen = (await readPanels()).find(entry => entry.id === 'main' && entry.open === true)
447
+ if (stillOpen === undefined) throw new Error('the menu vanished as the pointer left the button')
448
+ if (menu === null) throw new Error('the ⋮ menu never opened: ' + JSON.stringify(await readPanels()))
449
+ const item = Array.isArray(menu.items) ? menu.items.find(candidate => candidate.id === 'mmNewTab') : undefined
450
+ if (item === undefined || item.height <= 0) throw new Error('the menu published no usable items: ' + JSON.stringify(menu.items))
451
+ // Click that item in the PAGE's copy — the visible one. This is what the user did, and
452
+ // afterwards the frame's own (clipped) copy still believed its menu was open, so the
453
+ // next hover did nothing at all.
454
+ const tabsBefore = (await provider.listTabs(session)).length
455
+ await provider.click(session, { x: item.left + 12, y: item.top + Math.round(item.height / 2) })
456
+ await new Promise(resolve => setTimeout(resolve, 600))
457
+ const closed = (await readPanels()).every(entry => entry.open !== true)
458
+ // Hover the same button again: the menu must come back.
459
+ await moveTo(1350, CHROME_TOOLBAR_Y)
460
+ await moveTo(1362, CHROME_TOOLBAR_Y)
461
+ const reopened = await waitForPanel('main')
462
+ if (reopened === null) throw new Error('hovering ⋮ again after clicking an item showed no menu (closedAfterClick=' + String(closed) + ')')
463
+ // Close it again so it cannot sit over the page for every later check.
464
+ await clickAt(1362, CHROME_TOOLBAR_Y)
465
+ await new Promise(resolve => setTimeout(resolve, 500))
466
+ return { item: item.id, closedAfterClick: closed, reopened: true, tabs: tabsBefore + ' -> ' + String((await provider.listTabs(session)).length) }
467
+ })
468
+ // 关闭也要有动画:收起 ⋮ 菜单时先淡出,而不是「啪」一下没了。同时**逻辑上已经关了**必须
469
+ // 立刻可见 —— 上面那几条「点了菜单项菜单就该关了」的断言全靠这个(否则会被这 150ms 骗到)。
470
+ await check('closing a menu fades it out but reports it closed at once', async () => {
471
+ const motion = async () => JSON.parse(String((await provider.execute(session, {
472
+ script: 'JSON.stringify(window.__dshChromeMotion ? window.__dshChromeMotion() : null)',
473
+ })).value))
474
+ // 这几个辅助函数在上一条检查里是局部的,这里得自己来一份。
475
+ const readPanels = async () => JSON.parse(String((await provider.execute(session, {
476
+ script: 'JSON.stringify(window.__dshChromePanels ? window.__dshChromePanels() : [])',
477
+ })).value))
478
+ const moveTo = async (x, y) => { await provider.chromeInput('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y }) }
479
+ const waitForPanel = async id => {
480
+ for (let i = 0; i < 20; i += 1) {
481
+ await new Promise(resolve => setTimeout(resolve, 150))
482
+ const found = (await readPanels()).find(entry => entry.id === id && entry.open === true)
483
+ if (found) return found
484
+ }
485
+ return null
486
+ }
487
+ // 先把指针挪开再移回按钮,才会有真正的 pointerenter。
488
+ await provider.click(session, { x: 4, y: 4 })
489
+ await new Promise(resolve => setTimeout(resolve, 400))
490
+ await moveTo(700, CHROME_TOOLBAR_Y)
491
+ await new Promise(resolve => setTimeout(resolve, 250))
492
+ await moveTo(1362, CHROME_TOOLBAR_Y)
493
+ const opened = await waitForPanel('main')
494
+ if (opened === null) throw new Error('the ⋮ menu never opened: ' + JSON.stringify(await readPanels()))
495
+ await provider.execute(session, { script: '(() => { window.__dshChromeMotionClear?.(); return "cleared" })()' })
496
+ // 点页面空白处 = 在 chrome 之外 → 关。
497
+ await provider.click(session, { x: 4, y: 4 })
498
+ const immediately = (await readPanels()).find(entry => entry.id === 'main')
499
+ if (immediately === undefined || immediately.open === true) {
500
+ throw new Error('the menu still reported itself open right after being closed: ' + JSON.stringify(immediately))
501
+ }
502
+ let faded = false
503
+ for (let i = 0; i < 12 && !faded; i += 1) {
504
+ await new Promise(resolve => setTimeout(resolve, 80))
505
+ faded = (await motion()).some(entry => entry.id === 'main' && entry.phase === 'end' && entry.name === 'dshPopOut')
506
+ }
507
+ if (!faded) throw new Error('the menu vanished without fading out')
508
+ return { logicalOpen: false, animation: 'dshPopOut' }
509
+ })
510
+ // The three checks below assert that the page RECEIVED the event. Resolution
511
+ // alone is not a click: browser_click can resolve the right element, report
512
+ // success, and still leave the page untouched.
513
+ // Clicking the address bar must select the whole URL (Chrome's behaviour) AND
514
+ // must leave CDP typing working: the earlier attempt at this called select() in
515
+ // the focus handler, after which Input.insertText never landed in that shadow-DOM
516
+ // input again. Typing over a full selection replaces the address, so landing on
517
+ // example.com proves both halves at once.
518
+ await check('address bar select-all keeps CDP typing working', async () => {
519
+ // The star of this check is the select-all on pointerdown, which lives in the chrome.
520
+ await chromeClick(690, CHROME_TOOLBAR_Y)
521
+ await chromeType('example.com')
522
+ await chromeEnter()
523
+ for (let i = 0; i < 40; i += 1) {
524
+ const url = (await provider.listTabs(session)).find(tab => tab.active)?.url ?? ''
525
+ if (url.startsWith('https://example.com')) return url
526
+ await new Promise(resolve => setTimeout(resolve, 250))
527
+ }
528
+ const url = (await provider.listTabs(session)).find(tab => tab.active)?.url ?? '(unknown)'
529
+ throw new Error('the address bar did not navigate to example.com (now at ' + url + ')')
530
+ })
531
+ // The host pushes the loading flag from did-start/did-stop-loading; a flag that
532
+ // never clears would leave the strip spinning forever. (That the flag appears at
533
+ // all is checked against a deliberately slow page by hand — polling it here would
534
+ // race the navigation.)
535
+ // Chrome's tab shortcuts, over the same binding the toolbar buttons use.
536
+ //
537
+ // Only Ctrl+T is asserted here. Ctrl+W reaches the same binding, but in this
538
+ // harness the press lands on a view the host reports as unknown right after the
539
+ // new tab appears; driving a freshly created tab by hand (browser_type +
540
+ // browser_execute) works, so it looks specific to this process rather than to the
541
+ // shortcut. Asserting it here would be a flaky check, not a real one.
542
+ await check('Ctrl+T opens a tab through the provider', async () => {
543
+ // Land on a definitely-materialised view first: the tab-closing checks above can
544
+ // leave the session's active tab pointing at a view the host has dropped.
545
+ await provider.navigate(session, { url: 'https://example.com/' })
546
+ // Wait for the document to settle: a re-injection is the chrome's whole state,
547
+ // and a key pressed while the old document is being replaced goes nowhere.
548
+ for (let i = 0; i < 30; i += 1) {
549
+ const url = (await provider.listTabs(session)).find(tab => tab.active)?.url ?? ''
550
+ if (url.startsWith('https://example.com')) break
551
+ await new Promise(resolve => setTimeout(resolve, 200))
552
+ }
553
+ await new Promise(resolve => setTimeout(resolve, 600))
554
+ const before = (await provider.listTabs(session)).length
555
+ let opened = before
556
+ // Retry: the page may still be settling under the first press.
557
+ for (let attempt = 0; attempt < 3 && opened === before; attempt += 1) {
558
+ await provider.pressKey(session, { key: 't', modifiers: ['ctrl'] })
559
+ for (let i = 0; i < 12 && opened === before; i += 1) {
560
+ await new Promise(resolve => setTimeout(resolve, 150))
561
+ opened = (await provider.listTabs(session)).length
562
+ }
563
+ }
564
+ if (opened !== before + 1) {
565
+ const state = await provider.execute(session, { script: 'JSON.stringify(window.__dshChromeState ? window.__dshChromeState() : null)' })
566
+ throw new Error('Ctrl+T produced ' + String(opened) + ' tabs, expected ' + String(before + 1) + ' state=' + String(state.value))
567
+ }
568
+ return { before, opened }
569
+ })
570
+ await check('Ctrl+W does not fail on the tab it just closed', async () => {
571
+ const tabsNow = async () => provider.listTabs(session)
572
+ const before = (await tabsNow()).length
573
+ // 和上面那条 Ctrl+T 检查一样要重试:页面刚重新注入时第一次按键会落空。
574
+ let opened = before
575
+ for (let attempt = 0; attempt < 4 && opened === before; attempt += 1) {
576
+ await provider.pressKey(session, { key: 't', modifiers: ['ctrl'] })
577
+ for (let i = 0; i < 12 && opened === before; i += 1) {
578
+ await new Promise(resolve => setTimeout(resolve, 150))
579
+ opened = (await tabsNow()).length
580
+ }
581
+ }
582
+ if (opened !== before + 1) throw new Error('Ctrl+T did not open a tab: ' + before + ' -> ' + opened)
583
+ // 有牙的那半:修复前这一行会以 target closed / unknown view 抛出来。
584
+ await provider.pressKey(session, { key: 'w', modifiers: ['ctrl'] })
585
+ let closed = opened
586
+ for (let i = 0; i < 12 && closed === opened; i += 1) {
587
+ await new Promise(resolve => setTimeout(resolve, 150))
588
+ closed = (await tabsNow()).length
589
+ }
590
+ if (closed !== before) throw new Error('Ctrl+W did not close the tab: ' + opened + ' -> ' + closed)
591
+ await provider.navigate(session, { url: 'https://example.com/' })
592
+ await new Promise(resolve => setTimeout(resolve, 800))
593
+ return { tabs: before + ' -> ' + opened + ' -> ' + closed }
594
+ })
595
+
596
+ // Chrome's bookmark bar: off by default, and turning it on moves the page down by
597
+ // its height. The toggle goes chrome -> binding -> host -> patch -> chrome, so this
598
+ // also proves the preference round-trips through the host (localStorage is per origin).
599
+ // Chrome's other keybindings, driven through the provider so the whole chain is covered.
600
+ await check('Ctrl+D bookmarks the page it is on', async () => {
601
+ await provider.navigate(session, { url: 'https://example.com/' })
602
+ await new Promise(resolve => setTimeout(resolve, 500))
603
+ const count = async () => Number((await provider.execute(session, { script: 'String((window.__dshBookmarks || []).length)' })).value)
604
+ const before = await count()
605
+ // The chrome mounts at the navigation commit, so a key pressed in the first
606
+ // moments after a load goes nowhere; retry rather than assert on that race.
607
+ let after = before
608
+ for (let attempt = 0; attempt < 3 && after === before; attempt += 1) {
609
+ await provider.pressKey(session, { key: 'd', modifiers: ['ctrl'] })
610
+ await new Promise(resolve => setTimeout(resolve, 600))
611
+ after = await count()
612
+ }
613
+ if (after !== before + 1) throw new Error('bookmarks went ' + String(before) + ' -> ' + String(after))
614
+ // Pressing it again must not duplicate the entry (the host dedupes by URL).
615
+ await provider.pressKey(session, { key: 'd', modifiers: ['ctrl'] })
616
+ await new Promise(resolve => setTimeout(resolve, 600))
617
+ const again = await count()
618
+ if (again !== after) throw new Error('Ctrl+D duplicated the bookmark: ' + String(after) + ' -> ' + String(again))
619
+ return { before, after, again }
620
+ })
621
+ // 工具栏那颗星(收藏面板的触发按钮)跟着当前页亮/灭,翻面时弹一下。这轮顺手修了一个真 bug:
622
+ // frame 表面原来用**自己的** location 判断(那是宿主的一张 data: 页),于是工具栏那颗星永远
623
+ // 不会点亮。frame 是独立视图,这轮新加的 chromeEval 才能把它直接读回来。
624
+ // 工具栏地址栏要显示**完整地址**(Chrome 行为)。frame 是宿主自己的文档,所以宿主单独把真实
625
+ // URL 塞给它;页面里那份永远只拿 origin(那是隐私设计,不能被这条检查顺手破坏)。
626
+ await check('the toolbar address bar shows the real url, path and all', async () => {
627
+ const frameEval = async script => String(await provider.chromeEval(script))
628
+ const state = async () => JSON.parse(await frameEval('JSON.stringify(window.__dshChromeState ? window.__dshChromeState() : null)'))
629
+ const activeOrigin = async () => await frameEval("String(((window.__dshTabs || []).find(tab => tab.active) || {}).url || '')")
630
+ // 先把 frame 的可见标签落在一个已知页面上(宿主换可见标签不是瞬时的),否则测的是上一个
631
+ // 检查留下的那一页 —— 第 42 轮就在这里白等过一轮。
632
+ await provider.navigate(session, { url: 'https://example.com/' })
633
+ let origin = ''
634
+ for (let i = 0; i < 30 && origin !== 'https://example.com'; i += 1) {
635
+ await new Promise(resolve => setTimeout(resolve, 250))
636
+ origin = await activeOrigin()
637
+ }
638
+ if (origin !== 'https://example.com') throw new Error('the frame never showed example.com: ' + origin)
639
+ await frameEval("(() => { window.__dshChromeMotionClear(); return 'cleared' })()")
640
+ await provider.navigate(session, { url: 'https://www.iana.org/help/example-domains' })
641
+ let snapshot = await state()
642
+ for (let i = 0; i < 40 && !String(snapshot.address || '').includes('/help/example-domains'); i += 1) {
643
+ await new Promise(resolve => setTimeout(resolve, 250))
644
+ snapshot = await state()
645
+ }
646
+ const address = String(snapshot.address || '')
647
+ if (!address.includes('/help/example-domains')) {
648
+ throw new Error('the toolbar lost the path: ' + JSON.stringify(address) + ' (frame tab ' + await activeOrigin() + ')')
649
+ }
650
+ // 隐私不变量:页面那份显示的是**它自己的** location(页面本来就知道),宿主推给 frame 的
651
+ // 完整地址**没有**发给它 —— 两个值必须各自正确,不能是宿主塞给页面的一份。
652
+ const pageAddress = String((await provider.execute(session, { script: "String((window.__dshChromeState ? window.__dshChromeState() : {}).address || '')" })).value)
653
+ const log = JSON.parse(await frameEval('JSON.stringify(window.__dshChromeMotion ? window.__dshChromeMotion() : [])'))
654
+ const faded = Array.isArray(log) && log.some(entry => entry.id === 'address' && entry.name === 'dshAddressIn')
655
+ if (!faded) throw new Error('the address text swapped without fading: ' + JSON.stringify(log))
656
+ return { address, pageCopy: pageAddress, animation: 'dshAddressIn' }
657
+ })
658
+ // 面板里的**新增**行滑进来(整表重画不播)。
659
+ await check('a new trail row slides in instead of blinking on', async () => {
660
+ const pageEval = async script => String((await provider.execute(session, { script })).value)
661
+ // Ctrl+H 开关操作轨迹面板(只有它开着,宿主推来的新记录才会走「增量插入」那条路)。
662
+ await provider.pressKey(session, { key: 'h', modifiers: ['ctrl'] })
663
+ let open = ''
664
+ for (let i = 0; i < 20 && open !== 'trail'; i += 1) {
665
+ await new Promise(resolve => setTimeout(resolve, 250))
666
+ open = await pageEval("String((window.__dshChromePanels ? window.__dshChromePanels() : []).filter(panel => panel.open).map(panel => panel.id).join(','))")
667
+ }
668
+ if (!open.includes('trail')) throw new Error('the trail panel never opened: ' + open)
669
+ await pageEval("(() => { window.__dshChromeMotionClear(); return 'cleared' })()")
670
+ // 任何一次工具调用都会往轨迹里加一条(宿主 trail.append → 面板增量插一行)。
671
+ await provider.content(session, { format: 'txt', maxChars: 40 })
672
+ let log = []
673
+ for (let i = 0; i < 20 && !log.some(entry => entry.id === 'trail-row'); i += 1) {
674
+ await new Promise(resolve => setTimeout(resolve, 200))
675
+ log = JSON.parse(await pageEval('JSON.stringify(window.__dshChromeMotion ? window.__dshChromeMotion() : [])'))
676
+ }
677
+ if (!log.some(entry => entry.id === 'trail-row' && entry.name === 'dshRowIn')) {
678
+ throw new Error('the new row appeared without sliding in: ' + JSON.stringify(log))
679
+ }
680
+ await provider.pressKey(session, { key: 'h', modifiers: ['ctrl'] })
681
+ return { row: 'dshRowIn' }
682
+ })
683
+ // 缩放百分比换值时弹一下(不动真缩放:只调页面那份的显示路径,随后复原)。
684
+ await check('the zoom percentage pops when it changes', async () => {
685
+ const pageEval = async script => String((await provider.execute(session, { script })).value)
686
+ const zoom = async () => JSON.parse(await pageEval('JSON.stringify(window.__dshChromeState ? window.__dshChromeState() : null)')).zoom
687
+ if (await zoom() !== '100%') throw new Error('expected 100% to start with, got ' + await zoom())
688
+ await pageEval("(() => { window.__dshChromeMotionClear(); return 'cleared' })()")
689
+ await pageEval('window.__dshChromeSetZoom(1.1)')
690
+ const after = await zoom()
691
+ if (after !== '110%') throw new Error('the label did not follow the zoom: ' + after)
692
+ const log = JSON.parse(await pageEval('JSON.stringify(window.__dshChromeMotion ? window.__dshChromeMotion() : [])'))
693
+ await pageEval('window.__dshChromeSetZoom(1)')
694
+ if (await zoom() !== '100%') throw new Error('the label did not come back to 100%')
695
+ if (!Array.isArray(log) || !log.some(entry => entry.id === 'zoom' && entry.name === 'dshZoomIn')) {
696
+ throw new Error('the percentage swapped without popping: ' + JSON.stringify(log))
697
+ }
698
+ return { zoom: after, animation: 'dshZoomIn' }
699
+ })
700
+ // 列表行的悬停高亮要淡进来(Chrome 也是这样)—— 用**真实指针**悬停,然后读日志里那条
701
+ // 真实的 CSS 过渡:断言的是**行为**,不是「CSS 里写没写 transition」。
702
+ await check('hovering a list row fades its highlight in', async () => {
703
+ const pageEval = async script => String((await provider.execute(session, { script })).value)
704
+ const rowsOf = async () => JSON.parse(await pageEval("JSON.stringify((window.__dshChromePanels ? window.__dshChromePanels() : []).filter(panel => panel.open && panel.id === 'trail').flatMap(panel => panel.rows || []))"))
705
+ await provider.pressKey(session, { key: 'h', modifiers: ['ctrl'] })
706
+ let rows = []
707
+ for (let i = 0; i < 20 && rows.length === 0; i += 1) {
708
+ await new Promise(resolve => setTimeout(resolve, 250))
709
+ rows = await rowsOf()
710
+ }
711
+ if (rows.length === 0) throw new Error('the trail panel showed no rows to hover')
712
+ const row = rows[0]
713
+ // 先停在别处:指针已经在目标上时不会产生新的 :hover 变化。
714
+ await provider.hover(session, { x: 5, y: 500 })
715
+ await new Promise(resolve => setTimeout(resolve, 200))
716
+ await pageEval("(() => { window.__dshChromeMotionClear(); return 'cleared' })()")
717
+ await provider.hover(session, { x: row.left + Math.round(row.width / 2), y: row.top + Math.round(row.height / 2) })
718
+ let log = []
719
+ for (let i = 0; i < 15 && !log.some(entry => entry.name === 'css:background-color'); i += 1) {
720
+ await new Promise(resolve => setTimeout(resolve, 150))
721
+ log = JSON.parse(await pageEval('JSON.stringify(window.__dshChromeMotion ? window.__dshChromeMotion() : [])'))
722
+ }
723
+ await provider.pressKey(session, { key: 'h', modifiers: ['ctrl'] })
724
+ const hit = log.find(entry => entry.name === 'css:background-color' && entry.id === row.cls)
725
+ if (!hit) throw new Error('the row highlighted without a transition: ' + JSON.stringify({ row, log }))
726
+ return { row: hit.id, transition: hit.name }
727
+ })
728
+ // 删掉中间一项,后面的要**滑**过去而不是瞬移 —— 收藏面板和书签栏走同一个 reconcile。
729
+ // 删掉中间一项,后面的要**滑**过去而不是瞬移 —— 收藏面板和书签栏走同一个 reconcile。
730
+ await check('removing a bookmark slides the ones after it', async () => {
731
+ const pageEval = async script => String((await provider.execute(session, { script })).value)
732
+ const readPanels = async () => JSON.parse(await pageEval('JSON.stringify(window.__dshChromePanels ? window.__dshChromePanels() : [])'))
733
+ const moveTo = async (x, y) => { await provider.chromeInput('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y }) }
734
+ const clickAt = async (x, y) => {
735
+ await provider.chromeInput('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 })
736
+ await provider.chromeInput('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 })
737
+ }
738
+ const waitForPanel = async id => {
739
+ for (let i = 0; i < 24; i += 1) {
740
+ await new Promise(resolve => setTimeout(resolve, 150))
741
+ const found = (await readPanels()).find(entry => entry.id === id && entry.open === true)
742
+ if (found) return found
743
+ }
744
+ return null
745
+ }
746
+ // 先攒够两个书签(Ctrl+D 只加不删)。
747
+ await provider.navigate(session, { url: 'https://example.com/' })
748
+ await new Promise(resolve => setTimeout(resolve, 1200))
749
+ await provider.pressKey(session, { key: 'd', modifiers: ['ctrl'] })
750
+ await new Promise(resolve => setTimeout(resolve, 400))
751
+ await provider.navigate(session, { url: 'https://www.iana.org/help/example-domains' })
752
+ await new Promise(resolve => setTimeout(resolve, 1800))
753
+ await provider.pressKey(session, { key: 'd', modifiers: ['ctrl'] })
754
+ await new Promise(resolve => setTimeout(resolve, 400))
755
+ const count = Number(await pageEval('String((window.__dshBookmarks || []).length)'))
756
+ if (count < 2) throw new Error('expected at least two bookmarks, got ' + count)
757
+ // 书签栏也开着:它的 chip 同样按 key 复用(横排,靠 left 变化)。
758
+ const barWasOn = await pageEval('String(window.__dshBookmarkBar === true)') === 'true'
759
+ const restoreBar = async () => { if (!barWasOn) await pageEval('window.__dshChromeBookmarkBar()') }
760
+ if (!barWasOn) await pageEval('window.__dshChromeBookmarkBar()')
761
+ // 打开收藏面板:从 frame 的 ⋮ 进去(真人就是这么点的)。悬停意图要停够 240ms。
762
+ let menu = null
763
+ for (let attempt = 0; attempt < 3 && menu === null; attempt += 1) {
764
+ await moveTo(1362, 62)
765
+ await new Promise(resolve => setTimeout(resolve, 400))
766
+ if (attempt > 0) await clickAt(1362, 62)
767
+ menu = await waitForPanel('main')
768
+ }
769
+ if (menu === null) { await restoreBar(); throw new Error('the ⋮ menu never opened') }
770
+ const bookmarksItem = menu.items.find(item => item.id === 'mmBookmarks')
771
+ if (bookmarksItem === undefined) { await restoreBar(); throw new Error('no bookmarks item: ' + JSON.stringify(menu.items)) }
772
+ await provider.click(session, { x: bookmarksItem.left + 12, y: bookmarksItem.top + Math.round(bookmarksItem.height / 2) })
773
+ const panel = await waitForPanel('bookmarks')
774
+ if (panel === null) { await restoreBar(); throw new Error('the bookmarks panel never opened') }
775
+ const removeButtons = panel.items.filter(item => item.id === 'x' && item.height > 0)
776
+ if (removeButtons.length < 2) { await restoreBar(); throw new Error('the panel showed no per-row remove buttons: ' + JSON.stringify(panel.items)) }
777
+ await pageEval("(() => { window.__dshChromeMotionClear(); return 'cleared' })()")
778
+ const first = removeButtons[0]
779
+ await provider.click(session, { x: first.left + Math.round(first.height / 2), y: first.top + Math.round(first.height / 2) })
780
+ // 立刻复原书签栏:后面的检查假定它是关着的,而后面任何一步失败都会跳过收尾。
781
+ await restoreBar()
782
+ let log = []
783
+ for (let i = 0; i < 15 && !log.some(entry => entry.phase === 'slide'); i += 1) {
784
+ await new Promise(resolve => setTimeout(resolve, 150))
785
+ log = JSON.parse(await pageEval('JSON.stringify(window.__dshChromeMotion ? window.__dshChromeMotion() : [])'))
786
+ }
787
+ await provider.pressKey(session, { key: 'Escape' })
788
+ const slide = log.find(entry => entry.phase === 'slide' && (entry.id === 'bookmark' || entry.id === 'bookmark-bar'))
789
+ if (slide === undefined) throw new Error('the remaining rows jumped instead of sliding: ' + JSON.stringify(log))
790
+ return { list: slide.id, moved: slide.name }
791
+ })
792
+ await check('the back button fades in as it becomes available', async () => {
793
+ const frameEval = async script => String(await provider.chromeEval(script))
794
+ const readLog = async () => JSON.parse(await frameEval('JSON.stringify(window.__dshChromeMotion ? window.__dshChromeMotion() : [])'))
795
+ const state = async () => JSON.parse(await frameEval('JSON.stringify(window.__dshChromeState ? window.__dshChromeState() : {})'))
796
+ // 先导航到一个新地址:这样「前进」一定是灰的(历史末端)。
797
+ await provider.navigate(session, { url: 'https://example.com/' })
798
+ await new Promise(resolve => setTimeout(resolve, 1600))
799
+ const before = await state()
800
+ if (before.canForward !== false) throw new Error('forward was already available at the end of the history: ' + JSON.stringify(before))
801
+ // 后退一次 → 前进可用,而且必须是淡进去的。
802
+ await frameEval("(() => { window.__dshChromeMotionClear(); return 'cleared' })()")
803
+ await provider.pressKey(session, { key: 'ArrowLeft', modifiers: ['alt'] })
804
+ let log = []
805
+ for (let i = 0; i < 20 && !log.some(entry => entry.name === 'css:opacity' && entry.el === 'forward'); i += 1) {
806
+ await new Promise(resolve => setTimeout(resolve, 150))
807
+ log = await readLog()
808
+ }
809
+ const now = await state()
810
+ const faded = log.find(entry => entry.name === 'css:opacity' && entry.el === 'forward')
811
+ // 回到历史末端,把状态交回去。
812
+ await provider.pressKey(session, { key: 'ArrowRight', modifiers: ['alt'] })
813
+ await new Promise(resolve => setTimeout(resolve, 900))
814
+ if (now.canForward !== true) throw new Error('forward never became available after going back: ' + JSON.stringify(now))
815
+ if (faded === undefined) throw new Error('the forward button snapped instead of fading: ' + JSON.stringify(log))
816
+ return { atEnd: 'disabled', afterBack: 'css:opacity' }
817
+ })
818
+
819
+ await check('the toolbar star lights up and pops when the page is bookmarked', async () => {
820
+ const frameEval = async script => String(await provider.chromeEval(script))
821
+ await provider.navigate(session, { url: 'https://www.iana.org/help/example-domains' })
822
+ // 等导航真的落定再断言:宿主只把 origin 下发给 chrome,所以按 origin 比对。
823
+ const state = async () => JSON.parse(await frameEval('JSON.stringify(window.__dshChromeState ? window.__dshChromeState() : null)'))
824
+ const landedOrigin = async () => await frameEval("String(((window.__dshTabs || []).find(tab => tab.active) || {}).url || '')")
825
+ let origin = ''
826
+ for (let i = 0; i < 30 && origin !== 'https://www.iana.org'; i += 1) {
827
+ await new Promise(resolve => setTimeout(resolve, 250))
828
+ origin = await landedOrigin()
829
+ }
830
+ if (origin !== 'https://www.iana.org') throw new Error('the new page never became the active tab: ' + origin)
831
+ // 工具栏在 frame 的闭影子根里,外面查不到 —— 读 chrome 自己的测试缝。
832
+ const before = (await state()).starred
833
+ if (before !== '0') throw new Error('a page that is not bookmarked still reads as starred: ' + String(before))
834
+ await frameEval("(() => { window.__dshChromeMotionClear(); return 'cleared' })()")
835
+ // 导航后头几百毫秒按的键会丢(chrome 在 commit 才挂载),所以按键要重试 —— 和上面
836
+ // 那条 Ctrl+D 检查同一个道理。
837
+ let snapshot = { starred: before, iconAnimations: 0, log: [] }
838
+ for (let attempt = 0; attempt < 3 && snapshot.starred !== '1'; attempt += 1) {
839
+ await provider.pressKey(session, { key: 'd', modifiers: ['ctrl'] })
840
+ for (let i = 0; i < 12 && snapshot.starred !== '1'; i += 1) {
841
+ await new Promise(resolve => setTimeout(resolve, 60))
842
+ snapshot = { ...(await state()), log: JSON.parse(await frameEval('JSON.stringify(window.__dshChromeMotion ? window.__dshChromeMotion() : [])')) }
843
+ }
844
+ }
845
+ if (snapshot.starred !== '1') throw new Error('the toolbar star never lit up after Ctrl+D: ' + JSON.stringify(snapshot))
846
+ const popped = Array.isArray(snapshot.log) && snapshot.log.some(entry => entry.id === 'star' && entry.name === 'dshIconIn')
847
+ if (!popped) throw new Error('the star flipped without a pop: ' + JSON.stringify(snapshot.log))
848
+ return { starred: snapshot.starred, animation: 'dshIconIn', running: snapshot.iconAnimations }
849
+ })
850
+ await check('the bookmark bar is host state and the page offset follows it', async () => {
851
+ await provider.navigate(session, { url: 'https://example.com/' })
852
+ await new Promise(resolve => setTimeout(resolve, 500))
853
+ // The page's own viewport now starts below the chrome frame, so its offset is
854
+ // just the bookmark bar (34px) — the toolbar is no longer part of this document.
855
+ const read = async () => {
856
+ const state = await provider.execute(session, {
857
+ script: '(() => { const el = document.elementFromPoint(200, 16); return JSON.stringify({ bar: window.__dshBookmarkBar === true, pad: getComputedStyle(document.documentElement).paddingTop, atBar: (el && el.id) || (el && el.tagName) || "none" }) })()',
858
+ })
859
+ return JSON.parse(String(state.value))
860
+ }
861
+ const before = await read()
862
+ if (before.bar !== false || before.pad !== '0px') throw new Error('unexpected start state ' + JSON.stringify(before))
863
+ await provider.execute(session, { script: '(() => { window.__dshChromeBookmarkBar(); return "on" })()' })
864
+ await new Promise(resolve => setTimeout(resolve, 600))
865
+ const opened = await read()
866
+ if (opened.bar !== true) throw new Error('the host did not keep the bar on: ' + JSON.stringify(opened))
867
+ if (opened.pad !== '34px') throw new Error('the page was not moved down: ' + JSON.stringify(opened))
868
+ if (opened.atBar !== '__dsh_browser_chrome_host__') throw new Error('the bar is not painted at the top of the page: ' + JSON.stringify(opened))
869
+ await provider.execute(session, { script: '(() => { window.__dshChromeBookmarkBar(); return "off" })()' })
870
+ await new Promise(resolve => setTimeout(resolve, 600))
871
+ const closed = await read()
872
+ if (closed.bar !== false || closed.pad !== '0px') throw new Error('the bar did not go away: ' + JSON.stringify(closed))
873
+ return { opened: opened.pad, closed: closed.pad }
874
+ })
875
+ // 下移量现在是**过渡**过去的,不是一步跳过去 —— 而且过渡只在第二次应用之后才补上
876
+ // (chrome 每次导航都会重新注入,第一次就带过渡的话,每个页面加载内容都会自己滑一下)。
877
+ await check('the page inset eases, and only after the first paint', async () => {
878
+ const read = async () => JSON.parse(String((await provider.execute(session, {
879
+ // 用计算样式而不是 inline 值:浏览器会把 ".16s ease" 规范成 "0.16s"(并丢掉默认的 ease)。
880
+ script: 'JSON.stringify({ pad: getComputedStyle(document.documentElement).paddingTop, property: getComputedStyle(document.documentElement).transitionProperty, duration: getComputedStyle(document.documentElement).transitionDuration, bar: window.__dshBookmarkBar === true })',
881
+ })).value))
882
+ const state = await read()
883
+ if (state.property !== 'padding-top' || state.duration !== '0.16s') {
884
+ throw new Error('the inset transition was never armed: ' + JSON.stringify(state))
885
+ }
886
+ let mid = null
887
+ for (let attempt = 0; attempt < 3 && mid === null; attempt += 1) {
888
+ await provider.execute(session, { script: '(() => { window.__dshChromeBookmarkBar(); return "toggle" })()' })
889
+ for (let i = 0; i < 8; i += 1) {
890
+ const value = Number.parseFloat((await read()).pad)
891
+ // 160ms 的窗口里 RPC 抖动可能一次都采不到;但**每次**都是 0/34 的整数端点,
892
+ // 就说明根本没有过渡。
893
+ if (Number.isFinite(value) && value > 0 && value < 34) { mid = String(value); break }
894
+ }
895
+ if (mid === null) await new Promise(resolve => setTimeout(resolve, 400))
896
+ }
897
+ if (mid === null) throw new Error('the inset jumped instead of easing: never sampled a value between 0 and 34px')
898
+ await new Promise(resolve => setTimeout(resolve, 400))
899
+ // 收拾干净:这条检查会开关若干次,别把书签栏留在开着的状态给后面的检查。
900
+ const end = await read()
901
+ if (end.bar === true) {
902
+ await provider.execute(session, { script: '(() => { window.__dshChromeBookmarkBar(); return "off" })()' })
903
+ await new Promise(resolve => setTimeout(resolve, 400))
904
+ }
905
+ return { mid, end: (await read()).pad }
906
+ })
907
+ // toast 过去是「啪」一下消失(display:none)。现在先淡出再收 —— 而且收起**不能只靠
908
+ // animationend**:系统开了「减少动态效果」时动画不跑,那个事件永远不来,toast 就挂住了。
909
+ await check('a notice fades out instead of vanishing', async () => {
910
+ const motion = async () => JSON.parse(String((await provider.execute(session, {
911
+ script: 'JSON.stringify(window.__dshChromeMotion ? window.__dshChromeMotion() : null)',
912
+ })).value))
913
+ const toastState = async () => JSON.parse(String((await provider.execute(session, {
914
+ script: 'typeof window.__dshChromeToastState === "function" ? window.__dshChromeToastState() : "null"',
915
+ })).value))
916
+ await provider.execute(session, { script: '(() => { window.__dshChromeMotionClear?.(); return "cleared" })()' })
917
+ const shown = await provider.execute(session, { script: '(() => { if (typeof window.__dshChromeToast !== "function") return "missing"; window.__dshChromeToast("动效测试"); return "shown" })()' })
918
+ if (shown.value !== 'shown') throw new Error('the toast hook is ' + String(shown.value))
919
+ const opened = await toastState()
920
+ if (opened.open !== true) throw new Error('the toast never opened: ' + JSON.stringify(opened))
921
+ let dropped = false
922
+ for (let i = 0; i < 20 && !dropped; i += 1) {
923
+ await new Promise(resolve => setTimeout(resolve, 100))
924
+ dropped = (await motion()).some(entry => entry.id === 'toast' && entry.phase === 'end' && entry.name === 'dshDropIn')
925
+ }
926
+ if (!dropped) throw new Error('the toast appeared without its drop-in animation')
927
+ // 5 秒后自动收起,而且必须是淡出,不是直接消失。
928
+ let faded = false
929
+ for (let i = 0; i < 90 && !faded; i += 1) {
930
+ await new Promise(resolve => setTimeout(resolve, 100))
931
+ faded = (await motion()).some(entry => entry.id === 'toast' && entry.phase === 'start' && entry.name === 'dshToastOut')
932
+ }
933
+ if (!faded) throw new Error('the toast vanished without fading out')
934
+ await new Promise(resolve => setTimeout(resolve, 600))
935
+ const after = await toastState()
936
+ if (after.open !== false || after.closing !== false) throw new Error('the toast never finished hiding: ' + JSON.stringify(after))
937
+ return { drop: 'dshDropIn', hide: 'dshToastOut' }
938
+ })
939
+ await check('a settled tab is not stuck loading', async () => {
940
+ await provider.navigate(session, { url: 'https://example.com/' })
941
+ await new Promise(resolve => setTimeout(resolve, 600))
942
+ const flags = await provider.execute(session, { script: 'JSON.stringify((window.__dshTabs || []).map(tab => tab.loading === true))' })
943
+ const loading = JSON.parse(String(flags.value ?? '[]'))
944
+ if (loading.some(Boolean)) throw new Error('the strip still shows a tab as loading: ' + String(flags.value))
945
+ return { tabs: loading.length, loading: 0 }
946
+ })
947
+ // Home is this browser's own new-tab page, and that page is a data: document.
948
+ // Chromium blocks renderer-initiated navigation to one, so the button can only
949
+ // work if the frame relays it and the HOST does the loadURL — which is exactly
950
+ // what this drives end to end (the home button sits after back/forward/reload,
951
+ // at 33/66/99/133 in a full-width bar; the candidates bracket that centre).
952
+ await check('the home button returns to the new-tab page', async () => {
953
+ await provider.navigate(session, { url: 'https://example.com/' })
954
+ await new Promise(resolve => setTimeout(resolve, 600))
955
+ for (const x of [133, 127, 139, 121]) {
956
+ await chromeClick(x, CHROME_TOOLBAR_Y)
957
+ await new Promise(resolve => setTimeout(resolve, 700))
958
+ const url = (await provider.listTabs(session)).find(tab => tab.active)?.url ?? ''
959
+ if (url.startsWith('data:text/html')) {
960
+ // 起始页的 data: URL **不该**出现在地址栏里(真机截图里那是一整屏百分号编码)。
961
+ const address = String(await provider.chromeEval("String(window.__dshChromeState ? window.__dshChromeState().address : 'x')"))
962
+ if (address !== '') throw new Error('the new-tab address showed the data: URL: ' + address.slice(0, 40))
963
+ // Leave the tab on a real page: later checks navigate anyway, but the next
964
+ // few read the live document.
965
+ await provider.navigate(session, { url: 'https://example.com/' })
966
+ return { x, url: url.slice(0, 21) }
967
+ }
968
+ }
969
+ throw new Error('the home button never reached the new-tab page')
970
+ })
971
+ // 起始页是每次开新标签都会看到的那一页,所以它的悬停也不能硬切。这里直接量计算样式,
972
+ // 并且真的改一次底色、用 getAnimations() 确认渲染器**真的**起了一个过渡(不是只写了 CSS)。
973
+ await check('the new-tab page eases its hover states', async () => {
974
+ await provider.navigate(session, { url: 'https://example.com/' })
975
+ await new Promise(resolve => setTimeout(resolve, 600))
976
+ await chromeClick(133, CHROME_TOOLBAR_Y)
977
+ await new Promise(resolve => setTimeout(resolve, 800))
978
+ const url = (await provider.listTabs(session)).find(tab => tab.active)?.url ?? ''
979
+ if (!url.startsWith('data:text/html')) throw new Error('the home button did not reach the new-tab page: ' + url.slice(0, 40))
980
+ const read = async script => String((await provider.execute(session, { script })).value)
981
+ const armed = JSON.parse(await read(`JSON.stringify((() => {
982
+ const tile = document.querySelector('.tile')
983
+ if (tile === null) return { tiles: 0 }
984
+ const style = getComputedStyle(tile)
985
+ return { tiles: document.querySelectorAll('.tile').length, property: style.transitionProperty, duration: style.transitionDuration }
986
+ })())`))
987
+ if (armed.tiles === 0) throw new Error('the new-tab page has no shortcut tiles to hover')
988
+ if (!String(armed.property).includes('background')) throw new Error('a shortcut tile does not transition its background: ' + JSON.stringify(armed))
989
+ await read(`(() => { document.querySelector('.tile').style.background = 'rgb(1, 2, 3)'; return 'set' })()`)
990
+ const running = JSON.parse(await read(`JSON.stringify(document.querySelector('.tile').getAnimations().map(animation => ({ type: animation.constructor.name, property: String(animation.transitionProperty || ''), duration: animation.effect.getTiming().duration })))`))
991
+ await read(`(() => { document.querySelector('.tile').style.background = ''; return 'restored' })()`)
992
+ // 搜索框也一样:它是每次开新标签都要摸的东西,悬停/聚焦不能硬切。
993
+ const box = JSON.parse(await read(`JSON.stringify((() => {
994
+ const el = document.querySelector('.search')
995
+ if (el === null) return { found: false }
996
+ const style = getComputedStyle(el)
997
+ return { found: true, property: style.transitionProperty, duration: style.transitionDuration }
998
+ })())`))
999
+ if (!box.found) throw new Error('the new-tab page has no search box')
1000
+ if (!String(box.property).includes('background')) throw new Error('the search box does not transition its background: ' + JSON.stringify(box))
1001
+ await read(`(() => { document.querySelector('.search').style.background = 'rgb(1, 2, 3)'; return 'set' })()`)
1002
+ const boxRunning = JSON.parse(await read(`JSON.stringify(document.querySelector('.search').getAnimations().map(animation => ({ type: animation.constructor.name, property: animation.transitionProperty ?? '' })))`))
1003
+ await read(`(() => { document.querySelector('.search').style.background = ''; return 'restored' })()`)
1004
+ if (boxRunning.length === 0) throw new Error('the search box background changed without a running transition')
1005
+ // 必须离开起始页再交棒:后面的检查会「点 body 的正中间」,而起始页正中间就是磁贴
1006
+ // (一个 <a>)—— 点下去会导航走,那个检查的探针就跟着文档一起没了(第 41 轮真踩了:
1007
+ // 'left-click reaches the page' 报 the page saw undefined)。
1008
+ await provider.navigate(session, { url: 'https://example.com/' })
1009
+ await new Promise(resolve => setTimeout(resolve, 500))
1010
+ if (running.length === 0) throw new Error('the tile background changed without a running transition')
1011
+
1012
+ return { tiles: armed.tiles, transition: armed.duration, search: box.duration, running: running[0].property !== '' ? running[0].property : running[0].type }
1013
+ })
1014
+ // Page zoom lives on the webContents, so the chrome has to be told the factor
1015
+ // rather than derive it (a freshly navigated document's devicePixelRatio is
1016
+ // already scaled, which is what made the derived version stop compensating).
1017
+ // Bookmarks belong to the profile, so the host publishes the list to every page
1018
+ // instead of leaving it in the page's per-origin localStorage.
1019
+ // The chrome's mount is one linear script, so any exception inside it silently
1020
+ // removes everything after that point. With the assertion below, a half-mounted
1021
+ // chrome fails loudly instead of quietly losing its menus and shortcuts.
1022
+ await check('the chrome mounted without an error and its find hook exists', async () => {
1023
+ const result = await provider.execute(session, {
1024
+ script: 'JSON.stringify({ error: String(document.documentElement.dataset.dshChromeError), find: typeof window.__dshChromeFind })',
1025
+ })
1026
+ const state = JSON.parse(String(result.value ?? '{}'))
1027
+ if (state.error !== 'undefined') throw new Error('the chrome mount failed: ' + state.error)
1028
+ if (state.find !== 'object') throw new Error('window.__dshChromeFind is ' + String(state.find))
1029
+ return state
1030
+ })
1031
+ // 查找栏以前是「啪」一下消失(和菜单一样的病)。关它也要有退场,而且这段动画必须真的跑完。
1032
+ await check('the find bar fades out instead of blinking away', async () => {
1033
+ const motion = async () => JSON.parse(String((await provider.execute(session, {
1034
+ script: 'JSON.stringify(window.__dshChromeMotion ? window.__dshChromeMotion() : null)',
1035
+ })).value))
1036
+ await provider.execute(session, { script: '(() => { window.__dshChromeMotionClear?.(); window.__dshChromeFind.open(); return "opened" })()' })
1037
+ await new Promise(resolve => setTimeout(resolve, 500))
1038
+ if (!(await motion()).some(entry => entry.id === 'find' && entry.name === 'dshPopIn')) {
1039
+ throw new Error('the find bar opened without its entrance animation: ' + JSON.stringify(await motion()))
1040
+ }
1041
+ await provider.execute(session, { script: '(() => { window.__dshChromeMotionClear?.(); window.__dshChromeFind.close(); return "closed" })()' })
1042
+ let faded = false
1043
+ for (let i = 0; i < 12 && !faded; i += 1) {
1044
+ await new Promise(resolve => setTimeout(resolve, 80))
1045
+ faded = (await motion()).some(entry => entry.id === 'find' && entry.phase === 'end' && entry.name === 'dshPopOut')
1046
+ }
1047
+ if (!faded) throw new Error('the find bar vanished without fading out')
1048
+ return { open: 'dshPopIn', close: 'dshPopOut' }
1049
+ })
1050
+ // 安全指示:http 页面给琥珀提示、https 给锁 —— 换的时候淡入,不硬切。为了造出真正的 http
1051
+ // 页面,这里临时起一个只有本机能访问的小服务器(外网 http 站会重定向/升级,不可靠)。
1052
+ await check('the security icon cross-fades when the connection kind changes', async () => {
1053
+ const { createServer } = await import('node:http')
1054
+ const server = createServer((_request, response) => {
1055
+ response.writeHead(200, { 'content-type': 'text/html' })
1056
+ response.end('<!doctype html><title>plain http</title>ok')
1057
+ })
1058
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
1059
+ const port = server.address().port
1060
+ try {
1061
+ const frameEval = async script => String(await provider.chromeEval(script))
1062
+ await provider.navigate(session, { url: 'https://example.com/' })
1063
+ await new Promise(resolve => setTimeout(resolve, 900))
1064
+ const state = async () => JSON.parse(await frameEval('JSON.stringify(window.__dshChromeState ? window.__dshChromeState() : null)'))
1065
+ const secure = (await state()).insecure
1066
+ if (secure !== '0') throw new Error('an https page did not show the lock: ' + String(secure))
1067
+ await frameEval("(() => { window.__dshChromeMotionClear(); return 'cleared' })()")
1068
+ await provider.navigate(session, { url: 'http://127.0.0.1:' + String(port) + '/' })
1069
+ let insecure = secure
1070
+ for (let i = 0; i < 25 && insecure !== '1'; i += 1) {
1071
+ await new Promise(resolve => setTimeout(resolve, 150))
1072
+ insecure = (await state()).insecure
1073
+ }
1074
+ if (insecure !== '1') throw new Error('an http page did not switch to the insecure icon: ' + insecure)
1075
+ const log = JSON.parse(await frameEval('JSON.stringify(window.__dshChromeMotion ? window.__dshChromeMotion() : [])'))
1076
+ if (!Array.isArray(log) || !log.some(entry => entry.id === 'security' && entry.name === 'dshIconIn')) {
1077
+ throw new Error('the security icon swapped without a cross-fade: ' + JSON.stringify(log))
1078
+ }
1079
+ return { https: 'lock', http: 'amber', animation: 'dshIconIn' }
1080
+ } finally {
1081
+ await new Promise(resolve => server.close(resolve))
1082
+ }
1083
+ })
1084
+ await check('the host publishes the bookmark list to the chrome', async () => {
1085
+ const result = await provider.execute(session, { script: 'Array.isArray(window.__dshBookmarks) ? String(window.__dshBookmarks.length) : "missing"' })
1086
+ if (result.value === 'missing') throw new Error('window.__dshBookmarks was not published')
1087
+ return { bookmarks: result.value }
1088
+ })
1089
+ await check('the host publishes the zoom factor to the chrome', async () => {
1090
+ const result = await provider.execute(session, { script: 'String(window.__dshZoom)' })
1091
+ if (result.value !== '1') throw new Error('expected __dshZoom 1, got ' + String(result.value))
1092
+ return { zoom: result.value }
1093
+ })
1094
+ await check('left-click reaches the page', async () => {
1095
+ await provider.execute(session, { script: `(() => {
1096
+ window.__left = []
1097
+ for (const type of ['mousedown', 'mouseup', 'click']) {
1098
+ window.addEventListener(type, event => window.__left.push(type), true)
1099
+ }
1100
+ return 'armed'
1101
+ })()` })
1102
+ // body, not a paragraph: earlier checks may have navigated the page away.
1103
+ await provider.click(session, { selector: 'body' })
1104
+ await new Promise(resolve => setTimeout(resolve, 400))
1105
+ const seen = await provider.execute(session, { script: 'window.__left.join(",")' })
1106
+ if (seen.value !== 'mousedown,mouseup,click') throw new Error('the page saw ' + JSON.stringify(seen.value))
1107
+ return seen.value
1108
+ })
1109
+ await check('right-click reaches the page context menu', async () => {
1110
+ await provider.execute(session, { script: `(() => {
1111
+ window.__ctx = []
1112
+ document.addEventListener('contextmenu', event => window.__ctx.push('button:' + String(event.button)), { once: true })
1113
+ return 'armed'
1114
+ })()` })
1115
+ await provider.click(session, { selector: 'body', button: 'right' })
1116
+ await new Promise(resolve => setTimeout(resolve, 400))
1117
+ const seen = await provider.execute(session, { script: 'window.__ctx.join(",")' })
1118
+ // Returning "" used to pass silently, which hid the fact that no event arrived.
1119
+ if (seen.value !== 'button:2') throw new Error('the page saw ' + JSON.stringify(seen.value) + ' instead of a right-click')
1120
+ return seen.value
1121
+ })
1122
+ await check('modifiers reach the page', async () => {
1123
+ await provider.execute(session, { script: `(() => {
1124
+ window.__mods = []
1125
+ document.addEventListener('mousedown', event => window.__mods.push(String(event.shiftKey) + '/' + String(event.ctrlKey)), { once: true })
1126
+ return 'armed'
1127
+ })()` })
1128
+ await provider.click(session, { selector: 'body', modifiers: ['shift', 'ctrl'] })
1129
+ await new Promise(resolve => setTimeout(resolve, 400))
1130
+ const seen = await provider.execute(session, { script: 'window.__mods.join(",")' })
1131
+ if (seen.value !== 'true/true') throw new Error('the page saw ' + JSON.stringify(seen.value) + ' instead of a modified press')
1132
+ return seen.value
1133
+ })
1134
+ await check('drag reaches the page', async () => {
1135
+ await provider.execute(session, { script: `(() => {
1136
+ document.getElementById('__drag_probe')?.remove()
1137
+ const el = document.createElement('div')
1138
+ el.id = '__drag_probe'
1139
+ el.style.cssText = 'position:fixed;left:100px;top:100px;width:120px;height:60px;background:#39f;z-index:2147483647'
1140
+ document.body.appendChild(el)
1141
+ window.__drag = []
1142
+ el.addEventListener('mousedown', event => window.__drag.push('down@' + Math.round(event.clientX) + ',' + Math.round(event.clientY)), true)
1143
+ // On document, not on el: the gesture leaves the source almost immediately,
1144
+ // so an element-scoped listener would only ever see the first move.
1145
+ document.addEventListener('mousemove', event => window.__drag.push('move@' + Math.round(event.clientX)), true)
1146
+ document.addEventListener('mouseup', event => window.__drag.push('up@' + Math.round(event.clientX) + ',' + Math.round(event.clientY)), true)
1147
+ return 'armed'
1148
+ })()` })
1149
+ const result = await provider.drag(session, { from: { selector: '#__drag_probe' }, to: { x: 600, y: 400 }, steps: 6 })
1150
+ await new Promise(resolve => setTimeout(resolve, 300))
1151
+ const seen = await provider.execute(session, { script: 'window.__drag.join(" ")' })
1152
+ await provider.execute(session, { script: "document.getElementById('__drag_probe')?.remove()" })
1153
+ const log = String(seen.value ?? '')
1154
+ const moves = log.split(' ').filter(entry => entry.startsWith('move@')).length
1155
+ // The log opens with the pre-press hover, so the press is not necessarily first.
1156
+ if (!log.includes('down@160,130')) throw new Error('no press on the source: ' + JSON.stringify(log))
1157
+ if (!log.endsWith('up@600,400')) throw new Error('no release on the destination: ' + JSON.stringify(log))
1158
+ if (moves < 5) throw new Error('only ' + String(moves) + ' moves, so the gesture jumped: ' + JSON.stringify(log))
1159
+ return { moves, result: result.from.target + ' -> ' + String(result.to.x) + ',' + String(result.to.y) }
1160
+ })
1161
+ await check('keyboard input reaches the page', async () => {
1162
+ // Same gate as a mouse press: Chromium drops synthesized keys while the
1163
+ // renderer believes it is unfocused, so this was silently empty too.
1164
+ await provider.execute(session, { script: `(() => {
1165
+ window.__keys = []
1166
+ document.addEventListener('keydown', event => window.__keys.push(event.key), true)
1167
+ return 'armed'
1168
+ })()` })
1169
+ await provider.pressKey(session, { key: 'Enter' })
1170
+ await new Promise(resolve => setTimeout(resolve, 300))
1171
+ const seen = await provider.execute(session, { script: 'window.__keys.join(",")' })
1172
+ if (seen.value !== 'Enter') throw new Error('the page saw ' + JSON.stringify(seen.value))
1173
+ return seen.value
1174
+ })
1175
+ await check('typing lands in a focused field', async () => {
1176
+ await provider.execute(session, { script: `(() => {
1177
+ document.getElementById('__type_probe')?.remove()
1178
+ const input = document.createElement('input')
1179
+ input.id = '__type_probe'
1180
+ input.style.cssText = 'position:fixed;left:40px;top:40px;width:200px;height:30px;z-index:2147483647'
1181
+ document.body.appendChild(input)
1182
+ input.focus()
1183
+ return 'armed'
1184
+ })()` })
1185
+ await provider.type(session, { text: 'hello' })
1186
+ await new Promise(resolve => setTimeout(resolve, 300))
1187
+ const seen = await provider.execute(session, { script: "String((document.getElementById('__type_probe') || {}).value)" })
1188
+ await provider.execute(session, { script: "document.getElementById('__type_probe')?.remove()" })
1189
+ if (seen.value !== 'hello') throw new Error('the field holds ' + JSON.stringify(seen.value))
1190
+ return seen.value
1191
+ })
1192
+ await check('click_ref reaches the page', async () => {
1193
+ // The documented primary way to interact, and it had never been asserted to
1194
+ // actually deliver anything -- only that it returned.
1195
+ await provider.execute(session, { script: `(() => {
1196
+ document.getElementById('__ref_probe')?.remove()
1197
+ const button = document.createElement('button')
1198
+ button.id = '__ref_probe'
1199
+ button.textContent = 'Ref Probe'
1200
+ // Deliberately BELOW the injected chrome (0-84px). A fixed probe at top:60
1201
+ // sits under the toolbar, so the click lands on the chrome and this check
1202
+ // would be measuring the occlusion instead of click_ref; the occlusion has
1203
+ // its own check right below.
1204
+ button.style.cssText = 'position:fixed;left:60px;top:200px;width:140px;height:40px;z-index:2147483647'
1205
+ document.body.appendChild(button)
1206
+ window.__ref = []
1207
+ button.addEventListener('mousedown', () => window.__ref.push('mousedown'), true)
1208
+ button.addEventListener('click', () => window.__ref.push('click'), true)
1209
+ return 'armed'
1210
+ })()` })
1211
+ const snap = await provider.snapshot(session)
1212
+ const target = snap.elements.find(element => element.label === 'Ref Probe')
1213
+ if (target === undefined) throw new Error('the snapshot did not list the probe button')
1214
+ await provider.clickRef(session, { snapshotId: snap.snapshotId, ref: target.ref })
1215
+ await new Promise(resolve => setTimeout(resolve, 400))
1216
+ const seen = await provider.execute(session, { script: 'window.__ref.join(",")' })
1217
+ await provider.execute(session, { script: "document.getElementById('__ref_probe')?.remove()" })
1218
+ if (seen.value !== 'mousedown,click') throw new Error('the page saw ' + JSON.stringify(seen.value))
1219
+ return seen.value
1220
+ })
1221
+ // Characterisation of a known limitation, not an endorsement: the chrome is
1222
+ // injected INTO the page, so it covers the top 84 CSS pixels of the viewport and
1223
+ // a page's own position:fixed element there is both hidden and unreachable.
1224
+ // Shrinking the real viewport (chrome as its own WebContentsView) is what fixes
1225
+ // it. If this check ever starts failing, the occlusion is gone and this block
1226
+ // should be replaced by one that asserts the click DOES reach the page.
1227
+ await check('a fixed element at the top of the page is no longer occluded', async () => {
1228
+ await provider.execute(session, { script: `(() => {
1229
+ document.getElementById('__occl_probe')?.remove()
1230
+ const button = document.createElement('button')
1231
+ button.id = '__occl_probe'
1232
+ button.textContent = 'Top Probe'
1233
+ button.style.cssText = 'position:fixed;left:60px;top:60px;width:140px;height:40px;z-index:2147483647'
1234
+ document.body.appendChild(button)
1235
+ window.__occl = []
1236
+ button.addEventListener('click', () => window.__occl.push('click'), true)
1237
+ return 'armed'
1238
+ })()` })
1239
+ // The page's viewport starts below the chrome frame now, so a fixed element at
1240
+ // page y=60..100 is genuinely on screen and clickable. Before the chrome moved
1241
+ // into its own view this probe was unreachable — that was the whole reason for
1242
+ // the refactor.
1243
+ await provider.click(session, { x: 130, y: 80 })
1244
+ await new Promise(resolve => setTimeout(resolve, 400))
1245
+ const seen = await provider.execute(session, { script: 'window.__occl.join(",")' })
1246
+ await provider.execute(session, { script: "document.getElementById('__occl_probe')?.remove()" })
1247
+ if (seen.value !== 'click') {
1248
+ throw new Error('the page never received the click (' + JSON.stringify(seen.value) + '): the chrome still covers the top of the page')
1249
+ }
1250
+ return 'reachable'
1251
+ })
1252
+ await check('double-click reaches the page', async () => {
1253
+ await provider.execute(session, { script: `(() => {
1254
+ window.__dbl = []
1255
+ document.addEventListener('dblclick', () => window.__dbl.push('dblclick'), true)
1256
+ return 'armed'
1257
+ })()` })
1258
+ await provider.doubleClick(session, { selector: 'body' })
1259
+ await new Promise(resolve => setTimeout(resolve, 400))
1260
+ const seen = await provider.execute(session, { script: 'window.__dbl.join(",")' })
1261
+ if (seen.value !== 'dblclick') throw new Error('the page saw ' + JSON.stringify(seen.value))
1262
+ return seen.value
1263
+ })
1264
+ await check('scroll moves the page', async () => {
1265
+ // The current page may be shorter than the viewport, in which case a correct
1266
+ // scroll has nowhere to go -- give it something to scroll first.
1267
+ await provider.execute(session, { script: `(() => {
1268
+ document.getElementById('__tall')?.remove()
1269
+ const spacer = document.createElement('div')
1270
+ spacer.id = '__tall'
1271
+ spacer.style.cssText = 'height:3000px;width:10px'
1272
+ document.body.appendChild(spacer)
1273
+ window.scrollTo(0, 0)
1274
+ return 'armed'
1275
+ })()` })
1276
+ const before = await provider.execute(session, { script: 'Math.round(window.scrollY)' })
1277
+ await provider.scroll(session, { deltaY: 300 })
1278
+ await new Promise(resolve => setTimeout(resolve, 300))
1279
+ const after = await provider.execute(session, { script: 'Math.round(window.scrollY)' })
1280
+ await provider.execute(session, { script: "document.getElementById('__tall')?.remove()" })
1281
+ if (Number(after.value) <= Number(before.value)) throw new Error('scrollY went ' + String(before.value) + ' -> ' + String(after.value))
1282
+ return String(before.value) + ' -> ' + String(after.value)
1283
+ })
1284
+ await check('fill sets a select', async () => {
1285
+ await provider.execute(session, { script: `(() => {
1286
+ document.getElementById('__sel')?.remove()
1287
+ const select = document.createElement('select')
1288
+ select.id = '__sel'
1289
+ select.style.cssText = 'position:fixed;left:60px;top:120px;z-index:2147483647'
1290
+ for (const value of ['a', 'b', 'c']) {
1291
+ const option = document.createElement('option')
1292
+ option.value = value
1293
+ option.textContent = value
1294
+ select.appendChild(option)
1295
+ }
1296
+ document.body.appendChild(select)
1297
+ return 'armed'
1298
+ })()` })
1299
+ await provider.fillForm(session, { fields: [{ selector: '#__sel', kind: 'select', value: 'b' }] })
1300
+ await new Promise(resolve => setTimeout(resolve, 200))
1301
+ const seen = await provider.execute(session, { script: "String((document.getElementById('__sel') || {}).value)" })
1302
+ await provider.execute(session, { script: "document.getElementById('__sel')?.remove()" })
1303
+ if (seen.value !== 'b') throw new Error('the select holds ' + JSON.stringify(seen.value))
1304
+ return seen.value
1305
+ })
1306
+ await check('a browser-shaped cookie export imports', async () => {
1307
+ // What Cookie-Editor / EditThisCookie write: domain + path, no url. This is
1308
+ // the workflow the feature exists for, and it used to be rejected wholesale.
1309
+ const file = join(outDir, 'editor-export.json')
1310
+ writeFileSync(file, JSON.stringify([
1311
+ { domain: 'httpbin.org', name: 'dsh_editor', value: 'yes', path: '/', secure: true, httpOnly: false, sameSite: 'no_restriction' },
1312
+ ]))
1313
+ const imported = await provider.importAuth(session, file)
1314
+ const present = (await provider.flushAuth(session)).filter(cookie => cookie.name === 'dsh_editor').length
1315
+ await provider.clearAuth(session, { name: 'dsh_editor' })
1316
+ if (imported.restored !== 1) throw new Error('restored ' + String(imported.restored) + ', failed ' + String(imported.failed))
1317
+ if (present !== 1) throw new Error('the cookie is not in the session afterwards')
1318
+ return { restored: imported.restored, present }
1319
+ })
1320
+ await check('the page fingerprint is coherent', async () => {
1321
+ // Only the request headers were ever verified; the JS side never was. It is
1322
+ // a faithful Chromium: the UA carries Chrome/ like real Chromium does, the
1323
+ // brands list Chromium without Google Chrome exactly as real Chromium does,
1324
+ // and the two agree on the major version. Asserting the agreement is what
1325
+ // stops a future change from silently desynchronising them.
1326
+ const reply = await provider.execute(session, { script: `JSON.stringify({
1327
+ webdriver: navigator.webdriver,
1328
+ ua: navigator.userAgent,
1329
+ brands: navigator.userAgentData ? navigator.userAgentData.brands : null,
1330
+ languages: navigator.languages,
1331
+ vendor: navigator.vendor,
1332
+ })` })
1333
+ const seen = JSON.parse(String(reply.value ?? '{}'))
1334
+ if (seen.webdriver !== false) throw new Error('navigator.webdriver is ' + JSON.stringify(seen.webdriver))
1335
+ if (/Electron\//.test(String(seen.ua))) throw new Error('the UA still advertises Electron: ' + String(seen.ua))
1336
+ const major = /Chrome\/(\d+)/.exec(String(seen.ua))?.[1]
1337
+ if (major === undefined) throw new Error('the UA has no Chrome major: ' + String(seen.ua))
1338
+ const brands = seen.brands ?? []
1339
+ if (!brands.some(brand => brand.brand === 'Chromium')) throw new Error('the brands list no Chromium: ' + JSON.stringify(brands))
1340
+ if (!brands.some(brand => brand.version === major)) {
1341
+ throw new Error('the UA says Chrome/' + major + ' but no brand carries that major: ' + JSON.stringify(brands))
1342
+ }
1343
+ if (!Array.isArray(seen.languages) || seen.languages.length === 0) throw new Error('navigator.languages is empty')
1344
+ return { major, brands: brands.map(brand => brand.brand + ' ' + brand.version).join(', ') }
1345
+ })
1346
+ // --- the tool layer, over the same real provider -------------------------
1347
+ // The unit suite drives the tools against a fake browser, and the checks above
1348
+ // drive the provider directly. This is the only place the two meet: tool schema
1349
+ // -> tool layer -> provider -> RPC -> host-main -> CDP -> Chromium.
1350
+ const definitions = new Map()
1351
+ const toolCtx = {
1352
+ systemPrompt: { section() {} },
1353
+ tools: { register(definition) { definitions.set(definition.name, definition) }, schemas: () => [] },
1354
+ get(name) { return name === 'browser' ? provider : undefined },
1355
+ }
1356
+ apply(toolCtx)
1357
+ /**
1358
+ * DSH validates a tool's return value against its declared output schema, but
1359
+ * calling execute() directly skips that. Use the very validator DSH calls
1360
+ * (dsh-tools validateJsonSchemaValue on tool.output.schema) rather than a
1361
+ * hand-rolled approximation of it.
1362
+ */
1363
+ const assertShape = (name, value) => {
1364
+ const problems = validateJsonSchemaValue(definitions.get(name).output.schema, value, 'value')
1365
+ if (problems.length > 0) throw new Error(name + ' returned a value its schema rejects: ' + problems.join('; '))
1366
+ return value
1367
+ }
1368
+ const call = async (name, args, exec) => assertShape(name, await definitions.get(name).execute(args, exec))
1369
+ const toolTask = { agent: { id: 'smoke-tool-task' } }
1370
+
1371
+ await check('tool browser_open', async () => {
1372
+ const opened = await call('browser_open', { url: 'https://example.com/' }, toolTask)
1373
+ return opened.url ?? opened.title ?? 'opened'
1374
+ })
1375
+ await check('tool browser_content', async () => {
1376
+ const content = await call('browser_content', { format: 'txt', maxChars: 200 }, toolTask)
1377
+ return content.content.slice(0, 40)
1378
+ })
1379
+ await check('tool browser_click by text', async () => {
1380
+ const clicked = await call('browser_click', { text: 'Learn more' }, toolTask)
1381
+ return clicked.target ?? clicked.clicked
1382
+ })
1383
+ await check('tool browser_snapshot', async () => {
1384
+ const snap = await call('browser_snapshot', {}, toolTask)
1385
+ return (snap.elements ?? []).length
1386
+ })
1387
+ await check('tool browser_scrape start+status', async () => {
1388
+ const out = join(outDir, 'tool-rows.jsonl')
1389
+ const started = await call('browser_scrape', {
1390
+ action: 'start', urls: ['https://example.com/'], script: 'document.title', outPath: out, concurrency: 1,
1391
+ }, toolTask)
1392
+ for (let i = 0; i < 60; i += 1) {
1393
+ const status = await call('browser_scrape', { action: 'status', id: started.id }, toolTask)
1394
+ if (status.state !== 'running') return { state: status.state, done: status.done }
1395
+ await new Promise(resolve => setTimeout(resolve, 500))
1396
+ }
1397
+ return 'timed out'
1398
+ })
1399
+ await check('tool browser_auth file import', async () => {
1400
+ const exported = await provider.flushAuth(session)
1401
+ const file = join(outDir, 'tool-cookies.json')
1402
+ writeFileSync(file, JSON.stringify({ cookies: exported }))
1403
+ const imported = await call('browser_auth', { action: 'restore', file }, toolTask)
1404
+ return { restored: imported.restored, failed: imported.failed }
1405
+ })
1406
+
1407
+ // --- two tasks at once --------------------------------------------------
1408
+ // Per-task queues are what make this safe; a single shared queue would make the
1409
+ // second task wait for the first. The scrape on A is deliberately long.
1410
+ const taskA = { agent: { id: 'smoke-parallel-a' } }
1411
+ const taskB = { agent: { id: 'smoke-parallel-b' } }
1412
+ await check('two tasks do not block each other', async () => {
1413
+ await call('browser_open', { url: 'https://example.com/' }, taskA)
1414
+ await call('browser_open', { url: 'https://example.com/' }, taskB)
1415
+ const scrapeOut = join(outDir, 'parallel-a.jsonl')
1416
+ const started = await call('browser_scrape', {
1417
+ action: 'start', outPath: scrapeOut, concurrency: 2, timeoutMs: 20_000,
1418
+ urls: Array.from({ length: 8 }, (_, i) => (i % 2 === 0 ? 'https://example.com/' : 'https://www.iana.org/help/example-domains')),
1419
+ script: 'document.title',
1420
+ }, taskA)
1421
+ // B is busy with a long batch; A must still answer promptly.
1422
+ const began = Date.now()
1423
+ const snapB = await call('browser_snapshot', {}, taskB)
1424
+ const bMs = Date.now() - began
1425
+ const stillRunning = (await call('browser_scrape', { action: 'status', id: started.id }, taskA)).state === 'running'
1426
+ for (let i = 0; i < 90; i += 1) {
1427
+ if ((await call('browser_scrape', { action: 'status', id: started.id }, taskA)).state !== 'running') break
1428
+ await new Promise(resolve => setTimeout(resolve, 500))
1429
+ }
1430
+ return { bAnsweredInMs: bMs, bElements: (snapB.elements ?? []).length, aWasStillBusy: stillRunning }
1431
+ })
1432
+ await check('each task kept its own session', async () => {
1433
+ const a = await call('browser_session', {}, taskA)
1434
+ const b = await call('browser_session', {}, taskB)
1435
+ return { sameSession: a.session === b.session, aTabs: a.tabs?.length, bTabs: b.tabs?.length }
1436
+ })
1437
+
1438
+ // --- a wider concurrent scrape ------------------------------------------
1439
+ await check('100 urls at concurrency 8', async () => {
1440
+ const out = join(outDir, 'wide.jsonl')
1441
+ const urls = Array.from({ length: 100 }, (_, i) => (i % 2 === 0 ? 'https://example.com/' : 'https://www.iana.org/help/example-domains'))
1442
+ const began = Date.now()
1443
+ const started = await provider.startScrape(session, { urls, script: 'document.title', outPath: out, concurrency: 8, timeoutMs: 30_000 })
1444
+ for (let i = 0; i < 600; i += 1) {
1445
+ const status = await provider.scrapeStatus(started.id)
1446
+ if (status.state !== 'running') {
1447
+ const rows = readFileSync(out, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line))
1448
+ return { rows: rows.length, distinctSeqs: new Set(rows.map(r => r.seq)).size,
1449
+ failed: rows.filter(r => r.ok !== true).length, seconds: Math.round((Date.now() - began) / 100) / 10,
1450
+ tabsLeft: (await provider.listTabs(session)).length }
1451
+ }
1452
+ await new Promise(resolve => setTimeout(resolve, 500))
1453
+ }
1454
+ return 'timed out'
1455
+ })
1456
+ // 用户实测反馈:点收藏不该把当前页面顶掉 —— 必须是**新标签**,而且原来那个页面还在。
1457
+ // binding 的 token 是强校验的,所以只能**真点**(从 ⋮ 进收藏面板,和真人一样)。
1458
+ await check('a bookmark opens a new tab instead of replacing the page', async () => {
1459
+ const pageEval = async script => String((await provider.execute(session, { script })).value)
1460
+ const readPanels = async () => JSON.parse(await pageEval('JSON.stringify(window.__dshChromePanels ? window.__dshChromePanels() : [])'))
1461
+ const moveTo = async (x, y) => { await provider.chromeInput('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y }) }
1462
+ const clickAt = async (x, y) => {
1463
+ await provider.chromeInput('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 })
1464
+ await provider.chromeInput('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 })
1465
+ }
1466
+ const waitForPanel = async id => {
1467
+ for (let i = 0; i < 24; i += 1) {
1468
+ await new Promise(resolve => setTimeout(resolve, 150))
1469
+ const found = (await readPanels()).find(entry => entry.id === id && entry.open === true)
1470
+ if (found) return found
1471
+ }
1472
+ return null
1473
+ }
1474
+ let menu = null
1475
+ for (let attempt = 0; attempt < 4 && menu === null; attempt += 1) {
1476
+ await moveTo(5, 200)
1477
+ await new Promise(resolve => setTimeout(resolve, 150))
1478
+ await moveTo(1362, 62)
1479
+ await new Promise(resolve => setTimeout(resolve, 400))
1480
+ if (attempt > 0) await clickAt(1362, 62)
1481
+ menu = await waitForPanel('main')
1482
+ }
1483
+ if (menu === null) throw new Error('the ⋮ menu never opened')
1484
+ const item = menu.items.find(candidate => candidate.id === 'mmBookmarks')
1485
+ if (item === undefined) throw new Error('no bookmarks item: ' + JSON.stringify(menu.items.map(i => i.id)))
1486
+ await provider.click(session, { x: item.left + 12, y: item.top + Math.round(item.height / 2) })
1487
+ const panel = await waitForPanel('bookmarks')
1488
+ if (panel === null) throw new Error('the bookmarks panel never opened')
1489
+ // 行的可点区域没在 seam 里暴露(items 只有「保存」和每行的 ✕)—— 就照真人的点法:点这一行 ✕ 左边那块。
1490
+ const removeButtons = Array.isArray(panel.items) ? panel.items.filter(entry => entry.id === 'x') : []
1491
+ if (removeButtons.length === 0) throw new Error('the bookmarks panel showed no rows: ' + JSON.stringify(panel.items))
1492
+ const before = await provider.listTabs(session)
1493
+ const wasActive = before.find(tab => tab.active)
1494
+ const row = { x: removeButtons[0].left - 260, y: removeButtons[0].top + Math.round(removeButtons[0].height / 2) }
1495
+ await provider.click(session, { x: row.x, y: row.y })
1496
+ await new Promise(resolve => setTimeout(resolve, 1600))
1497
+ const after = await provider.listTabs(session)
1498
+ if (after.length !== before.length + 1) throw new Error('clicking a bookmark did not open a new tab: ' + before.length + ' -> ' + after.length)
1499
+ const kept = after.find(tab => tab.id === (wasActive && wasActive.id))
1500
+ if (kept === undefined || kept.url !== (wasActive && wasActive.url)) throw new Error('the page that was open got replaced: ' + JSON.stringify(kept && kept.url))
1501
+ const opened = after.find(tab => tab.active)
1502
+ if (opened === undefined || !String(opened.url).startsWith('http')) throw new Error('the new tab got no url: ' + JSON.stringify(opened && opened.url))
1503
+ await provider.closeTab(session, opened.id)
1504
+ await new Promise(resolve => setTimeout(resolve, 600))
1505
+ await provider.pressKey(session, { key: 'Escape' })
1506
+ // 交棒前把历史还原成后面的检查期待的样子(关掉新标签后活动的是哪个不由我挑)。
1507
+ await provider.navigate(session, { url: 'https://www.iana.org/help/example-domains' })
1508
+ await new Promise(resolve => setTimeout(resolve, 700))
1509
+ await provider.navigate(session, { url: 'https://example.com/' })
1510
+ await new Promise(resolve => setTimeout(resolve, 700))
1511
+ return { tabs: before.length + ' -> ' + after.length, kept: String(kept.url).slice(0, 24), opened: String(opened.url).slice(0, 24) }
1512
+ })
1513
+
1514
+ // 缩略图每隔几秒就来一帧:新的一帧要**淡入盖住旧的**,硬换会闪。
1515
+ await check('a new task thumbnail fades in over the previous one', async () => {
1516
+ const pageEval = async script => String((await provider.execute(session, { script })).value)
1517
+ const readPanels = async () => JSON.parse(await pageEval('JSON.stringify(window.__dshChromePanels ? window.__dshChromePanels() : [])'))
1518
+ const moveTo = async (x, y) => { await provider.chromeInput('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y }) }
1519
+ const clickAt = async (x, y) => {
1520
+ await provider.chromeInput('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 })
1521
+ await provider.chromeInput('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 })
1522
+ }
1523
+ const waitForPanel = async id => {
1524
+ for (let i = 0; i < 24; i += 1) {
1525
+ await new Promise(resolve => setTimeout(resolve, 150))
1526
+ const found = (await readPanels()).find(entry => entry.id === id && entry.open === true)
1527
+ if (found) return found
1528
+ }
1529
+ return null
1530
+ }
1531
+ let menu = null
1532
+ for (let attempt = 0; attempt < 4 && menu === null; attempt += 1) {
1533
+ // 先把指针挪开:**已经在按钮上**时不会再产生 pointerenter,悬停意图也就不会启动。
1534
+ await moveTo(5, 200)
1535
+ await new Promise(resolve => setTimeout(resolve, 150))
1536
+ await moveTo(1362, 62)
1537
+ await new Promise(resolve => setTimeout(resolve, 400))
1538
+ if (attempt > 0) await clickAt(1362, 62)
1539
+ menu = await waitForPanel('main')
1540
+ }
1541
+ if (menu === null) throw new Error('the ⋮ menu never opened')
1542
+ const tasksItem = menu.items.find(item => item.id === 'mmTasks')
1543
+ if (tasksItem === undefined) throw new Error('no tasks item: ' + JSON.stringify(menu.items.map(item => item.id)))
1544
+ await provider.click(session, { x: tasksItem.left + 12, y: tasksItem.top + Math.round(tasksItem.height / 2) })
1545
+ const panel = await waitForPanel('tasks')
1546
+ if (panel === null) throw new Error('the tasks panel never opened')
1547
+ // 用 Chromium 自己生成的 JPEG:一定是能解码的真帧。
1548
+ const jpeg = await pageEval("(() => { const c = document.createElement('canvas'); c.width = 16; c.height = 16; const x = c.getContext('2d'); x.fillStyle = '#336699'; x.fillRect(0, 0, 16, 16); return c.toDataURL('image/jpeg') })()")
1549
+ if (!jpeg.startsWith('data:image/jpeg;base64,')) throw new Error('could not build a jpeg frame: ' + jpeg.slice(0, 40))
1550
+ const first = Number(await pageEval('String(window.__dshChromeTaskThumb ? window.__dshChromeTaskThumb(' + JSON.stringify(jpeg) + ', 0) : 0)'))
1551
+ if (first !== 1) throw new Error('the tasks panel exposed no thumbnail to paint into')
1552
+ await new Promise(resolve => setTimeout(resolve, 600))
1553
+ await pageEval("(() => { window.__dshChromeMotionClear(); return 'cleared' })()")
1554
+ // 第二帧:这时已经有一张旧 canvas,走的是「淡入盖住旧的」那条路。
1555
+ await pageEval('String(window.__dshChromeTaskThumb(' + JSON.stringify(jpeg) + ', 0))')
1556
+ let log = []
1557
+ for (let i = 0; i < 20 && !log.some(entry => entry.id === 'task-thumb'); i += 1) {
1558
+ await new Promise(resolve => setTimeout(resolve, 150))
1559
+ log = JSON.parse(await pageEval('JSON.stringify(window.__dshChromeMotion ? window.__dshChromeMotion() : [])'))
1560
+ }
1561
+ await provider.pressKey(session, { key: 'Escape' })
1562
+ const hit = log.find(entry => entry.id === 'task-thumb' && entry.name === 'dshThumbIn')
1563
+ if (hit === undefined) throw new Error('the new thumbnail swapped in without a fade: ' + JSON.stringify(log))
1564
+ return { animation: hit.name }
1565
+ })
1566
+ // 书签栏的入场只在**人真的去开**时播:开关是宿主级的,而 chrome 每次导航都会重新注入 ——
1567
+ // 不挡的话,偏好开着的人每加载一个页面书签栏都会再滑一次。
1568
+ await check('the bookmark bar does not replay its entrance on every page load', async () => {
1569
+ const pageEval = async script => String((await provider.execute(session, { script })).value)
1570
+ const readLog = async () => JSON.parse(await pageEval('JSON.stringify(window.__dshChromeMotion ? window.__dshChromeMotion() : [])'))
1571
+ // 从已知状态开始:先确保它是关着的。
1572
+ if (await pageEval('String(window.__dshBookmarkBar === true)') === 'true') {
1573
+ await pageEval('window.__dshChromeBookmarkBar()')
1574
+ await new Promise(resolve => setTimeout(resolve, 400))
1575
+ }
1576
+ // 人开一次:必须播入场。
1577
+ await pageEval("(() => { window.__dshChromeMotionClear(); return 'cleared' })()")
1578
+ await pageEval('window.__dshChromeBookmarkBar()')
1579
+ let opened = []
1580
+ for (let i = 0; i < 15 && !opened.some(entry => entry.name === 'anim:dshBarIn'); i += 1) {
1581
+ await new Promise(resolve => setTimeout(resolve, 120))
1582
+ opened = await readLog()
1583
+ }
1584
+ if (!opened.some(entry => entry.name === 'anim:dshBarIn')) throw new Error('turning the bookmark bar on ran no entrance animation: ' + JSON.stringify(opened))
1585
+ // 再导航一次:宿主会把「开着」重推一遍,**不许再播**。
1586
+ await provider.navigate(session, { url: 'https://example.com/' })
1587
+ await new Promise(resolve => setTimeout(resolve, 1800))
1588
+ const after = await readLog()
1589
+ if (after.some(entry => entry.name === 'anim:dshBarIn')) throw new Error('the bookmark bar replayed its entrance after a navigation: ' + JSON.stringify(after))
1590
+ if (await pageEval('String(window.__dshBookmarkBar === true)') !== 'true') throw new Error('the bar did not stay open across the navigation')
1591
+ // 复原:后面的检查假定它是关着的;页面也回到上一条检查留下的那一页。
1592
+ await pageEval('window.__dshChromeBookmarkBar()')
1593
+ await provider.navigate(session, { url: 'https://www.iana.org/help/example-domains' })
1594
+ await new Promise(resolve => setTimeout(resolve, 1500))
1595
+ return { onToggle: 'anim:dshBarIn', onLoad: 'none' }
1596
+ })
1597
+ // 前进/后退能不能点由宿主算(tab.canGoBack/canGoForward)—— 变灰要**淡**进去,不是啪一下。
1598
+ // 前进/后退能不能点由宿主算(tab.canGoBack/canGoForward)—— 变灰要**淡**进去,不是啪一下。
1599
+ // toast 的正文是整段换掉的:第二句话来的时候要淡一下,不是啪地跳。
1600
+ // 任务行也是复用节点:换个顺序要滑过去,不是瞬移(书签第 46 轮修过同一个病)。
1601
+ await check('the task rows slide instead of jumping', async () => {
1602
+ const pageEval = async script => String((await provider.execute(session, { script })).value)
1603
+ const readPanels = async () => JSON.parse(await pageEval('JSON.stringify(window.__dshChromePanels ? window.__dshChromePanels() : [])'))
1604
+ const moveTo = async (x, y) => { await provider.chromeInput('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y }) }
1605
+ const clickAt = async (x, y) => {
1606
+ await provider.chromeInput('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 })
1607
+ await provider.chromeInput('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 })
1608
+ }
1609
+ const waitForPanel = async id => {
1610
+ for (let i = 0; i < 24; i += 1) {
1611
+ await new Promise(resolve => setTimeout(resolve, 150))
1612
+ const found = (await readPanels()).find(entry => entry.id === id && entry.open === true)
1613
+ if (found) return found
1614
+ }
1615
+ return null
1616
+ }
1617
+ let menu = null
1618
+ for (let attempt = 0; attempt < 4 && menu === null; attempt += 1) {
1619
+ // 先把指针挪开:已经在按钮上时不会再产生 pointerenter,悬停意图也就不会启动。
1620
+ await moveTo(5, 200)
1621
+ await new Promise(resolve => setTimeout(resolve, 150))
1622
+ await moveTo(1362, 62)
1623
+ await new Promise(resolve => setTimeout(resolve, 400))
1624
+ if (attempt > 0) await clickAt(1362, 62)
1625
+ menu = await waitForPanel('main')
1626
+ }
1627
+ if (menu === null) throw new Error('the ⋮ menu never opened')
1628
+ const tasksItem = menu.items.find(item => item.id === 'mmTasks')
1629
+ if (tasksItem === undefined) throw new Error('no tasks item: ' + JSON.stringify(menu.items.map(item => item.id)))
1630
+ await provider.click(session, { x: tasksItem.left + 12, y: tasksItem.top + Math.round(tasksItem.height / 2) })
1631
+ const panel = await waitForPanel('tasks')
1632
+ if (panel === null) throw new Error('the tasks panel never opened')
1633
+ // 用两个假任务驱动**真实的渲染路径**,量完把真实列表还回去。
1634
+ const fake = [
1635
+ { key: 'slide-a', label: 'A', status: 'idle', control: 'agent', active: false },
1636
+ { key: 'slide-b', label: 'B', status: 'running', control: 'agent', active: false },
1637
+ ]
1638
+ await pageEval('(() => { window.__dshTasksBackup = window.__dshTasks; window.__dshTasks = ' + JSON.stringify(fake) + '; window.__dshTaskRender(); return \'ok\' })()')
1639
+ await new Promise(resolve => setTimeout(resolve, 400))
1640
+ await pageEval("(() => { window.__dshChromeMotionClear(); return 'cleared' })()")
1641
+ const swapped = [fake[1], fake[0]]
1642
+ await pageEval('(() => { window.__dshTasks = ' + JSON.stringify(swapped) + '; window.__dshTaskRender(); return \'ok\' })()')
1643
+ let log = []
1644
+ for (let i = 0; i < 15 && !log.some(entry => entry.id === 'task-panel'); i += 1) {
1645
+ await new Promise(resolve => setTimeout(resolve, 120))
1646
+ log = JSON.parse(await pageEval('JSON.stringify(window.__dshChromeMotion ? window.__dshChromeMotion() : [])'))
1647
+ }
1648
+ await pageEval('(() => { if (window.__dshTasksBackup !== undefined) window.__dshTasks = window.__dshTasksBackup; window.__dshTaskRender(); return \'restored\' })()')
1649
+ await provider.pressKey(session, { key: 'Escape' })
1650
+ const hit = log.find(entry => entry.id === 'task-panel' && entry.phase === 'slide')
1651
+ if (hit === undefined) throw new Error('the task rows jumped instead of sliding: ' + JSON.stringify(log))
1652
+ return { list: hit.id, moved: hit.name }
1653
+ })
1654
+ // 切标签时页面从表面色淡进来;**导航绝不能播**(否则每次加载都闪一下)。
1655
+ // 关掉**最后一个**标签:provider 会补一个新标签,而新标签的宿主视图是异步建的 ——
1656
+ // 不等它就 showActive 会显示一个还不存在的视图(宿主报 unknown view,而且是条没人接的 rejection)。
1657
+ // frame 那份 chrome 只有 84px 高:书签栏(top:84px + 34px)会把它撑出滚动条(真机截图右边那条)。
1658
+ await check('the frame never grows a scrollbar', async () => {
1659
+ const pageEval = async script => String((await provider.execute(session, { script })).value)
1660
+ const frameEval = async script => String(await provider.chromeEval(script))
1661
+ const gutter = async () => Number(await frameEval('String(window.innerWidth - document.documentElement.clientWidth)'))
1662
+ const barWasOn = await pageEval('String(window.__dshBookmarkBar === true)') === 'true'
1663
+ if (!barWasOn) {
1664
+ await pageEval('window.__dshChromeBookmarkBar()')
1665
+ await new Promise(resolve => setTimeout(resolve, 700))
1666
+ }
1667
+ if (await pageEval('String(window.__dshBookmarkBar === true)') !== 'true') throw new Error('the bookmark bar would not turn on')
1668
+ await new Promise(resolve => setTimeout(resolve, 400))
1669
+ const width = await gutter()
1670
+ if (!Number.isFinite(width)) throw new Error('could not read the frame viewport: ' + width)
1671
+ // 交棒前还原:后面的检查假定书签栏是关着的(这条检查是第 58 轮加的,忘了还原,弄脏过它们)。
1672
+ if (!barWasOn) await pageEval('window.__dshChromeBookmarkBar()')
1673
+ if (width > 0) {
1674
+ const detail = await frameEval("JSON.stringify({ html: document.documentElement.scrollHeight, body: document.body.scrollHeight, view: document.documentElement.clientHeight, widest: (() => { let best = ''; let size = 0; for (const el of document.body.children) { const box = el.getBoundingClientRect(); if (box.bottom > size) { size = box.bottom; best = el.id || el.tagName } } return best + '@' + Math.round(size) })() })")
1675
+ throw new Error('the frame has a ' + width + 'px scrollbar gutter: ' + detail)
1676
+ }
1677
+ return { gutter: width }
1678
+ })
1679
+ await check('closing the last tab leaves a working one', async () => {
1680
+ const tabsNow = async () => provider.listTabs(session)
1681
+ for (let i = 0; i < 6; i += 1) {
1682
+ const list = await tabsNow()
1683
+ if (list.length <= 1) break
1684
+ await provider.closeTab(session, list[list.length - 1].id)
1685
+ await new Promise(resolve => setTimeout(resolve, 400))
1686
+ }
1687
+ const before = await tabsNow()
1688
+ if (before.length !== 1) throw new Error('could not get down to one tab: ' + before.length)
1689
+ await provider.closeTab(session, before[0].id)
1690
+ await new Promise(resolve => setTimeout(resolve, 1200))
1691
+ const after = await tabsNow()
1692
+ if (after.length !== 1) throw new Error('closing the last tab did not leave exactly one: ' + after.length)
1693
+ // 有牙的那半:补上来的那个标签必须是能用的 —— 视图没被显示出来,导航就会失败。
1694
+ await provider.navigate(session, { url: 'https://example.com/' })
1695
+ await new Promise(resolve => setTimeout(resolve, 900))
1696
+ const url = (await tabsNow()).find(tab => tab.active)?.url ?? ''
1697
+ if (!url.includes('example.com')) throw new Error('the replacement tab could not navigate: ' + url.slice(0, 40))
1698
+ return { tabs: after.length, url: url.slice(0, 18) }
1699
+ })
1700
+ // 真机实测:Ctrl+W 关标签时,provider 自己那次按键还在往**刚被这次按键关掉的**视图上发事件
1701
+ // (宿主日志:create view:X … destroy view:X,然后报 unknown view / target closed)。
1702
+ // 真机:语句形式的脚本必须能跑(以前只有表达式能跑)。
1703
+ await check('execute runs statements, not just an expression', async () => {
1704
+ const value = await provider.execute(session, {
1705
+ script: 'const h = document.documentElement; const t = h ? h.tagName : "none"; return t + ":" + String(1 + 1)',
1706
+ })
1707
+ if (value.ok !== true) throw new Error('statement script failed: ' + String(value.exception))
1708
+ if (String(value.value) !== 'HTML:2') throw new Error('statement script returned ' + JSON.stringify(value.value))
1709
+ // 坏脚本要报出原因,而不是静默 undefined
1710
+ const bad = await provider.execute(session, { script: 'this is not js' })
1711
+ if (bad.ok !== false || typeof bad.exception !== 'string' || !bad.exception.includes('could not parse')) {
1712
+ throw new Error('a broken script did not report a parse error: ' + JSON.stringify(bad))
1713
+ }
1714
+ return { value: String(value.value), broken: 'reported' }
1715
+ })
1716
+ // 真机:快照可以按关键词过滤、可以限制条数,而且过滤发生在**计上限之前**。
1717
+ // 隐藏的视图读出来是 0x0:元素全变 0 尺寸、坐标也没法判断。这台机器上偶尔会这样,
1718
+ // 那就**明确跳过**,而不是留下一条永远红的检查(那会训练我去忽略红色)。
1719
+ const laidOut = async () => {
1720
+ const size = JSON.parse(String((await provider.execute(session, { script: 'JSON.stringify({ w: window.innerWidth, h: window.innerHeight })' })).value))
1721
+ return typeof size.w === 'number' && typeof size.h === 'number' && size.w > 0 && size.h > 0 ? size : null
1722
+ }
1723
+ await check('snapshot can be filtered and capped', async () => {
1724
+ // 先在**有内容**的页面上做(上一条检查可能把标签留在很小的本地页上)。
1725
+ await provider.navigate(session, { url: 'https://example.com/' })
1726
+ await new Promise(resolve => setTimeout(resolve, 1400))
1727
+ const size = await laidOut()
1728
+ if (size === null) return { skipped: 'the view is not laid out in this run' }
1729
+ let all = await provider.snapshot(session)
1730
+ if (all.elements.length < 3) {
1731
+ // 视图没布局时元素全是 0 尺寸,会被快照过滤掉;截图会强制它显示出来。
1732
+ await provider.screenshot(session)
1733
+ await new Promise(resolve => setTimeout(resolve, 900))
1734
+ all = await provider.snapshot(session)
1735
+ }
1736
+ if (all.elements.length < 3) throw new Error('not enough elements to filter: ' + all.elements.length)
1737
+ const limited = await provider.snapshot(session, { limit: 2 })
1738
+ if (limited.elements.length !== 2) throw new Error('limit was not applied: got ' + limited.elements.length)
1739
+ const pick = all.elements[Math.min(all.elements.length - 1, 5)]
1740
+ const word = String(pick.label).split(/\s+/).find(part => part.length >= 4) ?? ''
1741
+ if (word === '') throw new Error('could not pick a word to query from ' + JSON.stringify(pick.label))
1742
+ const hit = await provider.snapshot(session, { query: word, limit: 5 })
1743
+ if (hit.elements.length === 0) throw new Error('query ' + JSON.stringify(word) + ' matched nothing')
1744
+ const wrong = hit.elements.filter(el => !(el.kind + ' ' + el.label).toLowerCase().includes(word.toLowerCase()))
1745
+ if (wrong.length > 0) throw new Error('query returned non-matching elements: ' + JSON.stringify(wrong[0]))
1746
+ return { all: all.elements.length, limited: limited.elements.length, query: word, matched: hit.elements.length }
1747
+ })
1748
+
1749
+ // 真机:视口外的坐标点击必须**明确拒绝**,而不是静默什么都不点(今天真踩过)。
1750
+ await check('an off-screen coordinate click is refused instead of silently dropped', async () => {
1751
+ const size = await laidOut()
1752
+ if (size === null) return { skipped: 'the view is not laid out in this run' }
1753
+ let refused = ''
1754
+ try {
1755
+ await provider.click(session, { x: Math.round(size.w) + 40, y: Math.round(size.h) + 40 })
1756
+ } catch (error) { refused = String(error) }
1757
+ if (!/outside the visible viewport/.test(refused)) throw new Error('an off-screen click was not refused: ' + refused.slice(0, 90))
1758
+ // 视口内的坐标仍然要能点,并且要报告那个点上是什么
1759
+ const inside = await provider.click(session, { x: Math.round(size.w / 2), y: Math.max(1, Math.round(size.h) - 5) })
1760
+ if (inside.target === undefined) throw new Error('an in-viewport click reported no target: ' + JSON.stringify(inside))
1761
+ return { refused: 'yes', viewport: size.w + 'x' + size.h, hit: String(inside.target).slice(0, 20) }
1762
+ })
1763
+ // 真机:页面上的 confirm() 必须能**被拒绝** —— 以前宿主只会一律接受。
1764
+ await check('a confirm dialog can be dismissed, not only accepted', async () => {
1765
+ const size = await laidOut()
1766
+ if (size === null) return { skipped: 'the view is not laid out in this run' }
1767
+ const { createServer } = await import('node:http')
1768
+ const server = createServer((_request, response) => {
1769
+ response.writeHead(200, { 'content-type': 'text/html' })
1770
+ response.end('<!doctype html><title>before</title><button id="go">go</button>'
1771
+ + '<script>document.getElementById("go").addEventListener("click", function () {'
1772
+ + ' document.title = confirm("delete?") ? "accepted" : "dismissed" })</script>')
1773
+ })
1774
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
1775
+ const port = server.address().port
1776
+ const title = async () => String((await provider.execute(session, { script: 'document.title' })).value)
1777
+ const waitTitle = async (want, from) => {
1778
+ let now = from
1779
+ for (let i = 0; i < 20 && now === from; i += 1) {
1780
+ await new Promise(resolve => setTimeout(resolve, 150))
1781
+ now = await title()
1782
+ }
1783
+ if (now !== want) throw new Error('title is ' + JSON.stringify(now) + ', expected ' + JSON.stringify(want))
1784
+ return now
1785
+ }
1786
+ try {
1787
+ await provider.navigate(session, { url: 'http://127.0.0.1:' + String(port) + '/' })
1788
+ await new Promise(resolve => setTimeout(resolve, 900))
1789
+ await provider.setDialogPolicy(session, { behavior: 'dismiss' })
1790
+ await provider.click(session, { selector: '#go' })
1791
+ const dismissed = await waitTitle('dismissed', 'before')
1792
+ const seen = provider.dialogState(session)
1793
+ await provider.setDialogPolicy(session, { behavior: 'accept' })
1794
+ await provider.click(session, { selector: '#go' })
1795
+ const accepted = await waitTitle('accepted', dismissed)
1796
+ return { dismissed, accepted, recorded: seen.dialog === null ? 'none' : String(seen.dialog.type) }
1797
+ } finally {
1798
+ server.close()
1799
+ await provider.setDialogPolicy(session, { behavior: 'accept' })
1800
+ await provider.navigate(session, { url: 'https://example.com/' })
1801
+ await new Promise(resolve => setTimeout(resolve, 800))
1802
+ }
1803
+ })
1804
+ // 真机:页面自己的 console 输出和网络请求必须能被读到(这是「为什么没成功」的唯一线索)。
1805
+ await check('console and network capture what the page did', async () => {
1806
+ const size = await laidOut()
1807
+ if (size === null) return { skipped: 'the view is not laid out in this run' }
1808
+ const { createServer } = await import('node:http')
1809
+ const server = createServer((request, response) => {
1810
+ if (request.url === '/api') {
1811
+ response.writeHead(404, { 'content-type': 'application/json' })
1812
+ response.end('{}')
1813
+ return
1814
+ }
1815
+ response.writeHead(200, { 'content-type': 'text/html' })
1816
+ response.end('<!doctype html><title>diag</title><script>'
1817
+ + 'console.log("hello", 42); console.error("boom");'
1818
+ + 'fetch("/api").then(function () {})'
1819
+ + '</script>')
1820
+ })
1821
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
1822
+ const port = server.address().port
1823
+ try {
1824
+ await provider.consoleMessages(session, { clear: true })
1825
+ await provider.networkRequests(session, { clear: true })
1826
+ await provider.navigate(session, { url: 'http://127.0.0.1:' + String(port) + '/' })
1827
+ let messages = []
1828
+ for (let i = 0; i < 20 && messages.length < 2; i += 1) {
1829
+ await new Promise(resolve => setTimeout(resolve, 150))
1830
+ messages = (await provider.consoleMessages(session)).messages
1831
+ }
1832
+ const texts = messages.map(entry => entry.level + ':' + entry.text)
1833
+ if (!texts.some(text => text === 'log:hello 42')) throw new Error('console.log was not captured: ' + JSON.stringify(texts))
1834
+ if (!texts.some(text => text === 'error:boom')) throw new Error('console.error was not captured: ' + JSON.stringify(texts))
1835
+ let request = null
1836
+ for (let i = 0; i < 20 && request === null; i += 1) {
1837
+ await new Promise(resolve => setTimeout(resolve, 150))
1838
+ request = (await provider.networkRequests(session, { urlContains: '/api' })).requests.at(-1) ?? null
1839
+ }
1840
+ if (request === null || request.status !== 404) throw new Error('the /api request was not captured: ' + JSON.stringify(request))
1841
+ return { messages: texts.join(' | '), api: request.method + ' ' + String(request.status) }
1842
+ } finally {
1843
+ server.close()
1844
+ await provider.consoleMessages(session, { clear: true })
1845
+ await provider.networkRequests(session, { clear: true })
1846
+ await provider.navigate(session, { url: 'https://example.com/' })
1847
+ await new Promise(resolve => setTimeout(resolve, 800))
1848
+ }
1849
+ })
1850
+ // 缩放 ≠ 100% 时,贴按钮的弹层必须还在按钮下面:host 被套了 zoom = 1/页面缩放,
1851
+ // 所以「页面 px」和「host px」差一个倍数 —— 实测 160% 下弹层曾偏出 330px。
1852
+ await check('a popup stays under its toolbar button when the page is zoomed', async () => {
1853
+ const size = await laidOut()
1854
+ if (size === null) return { skipped: 'the view is not laid out in this run' }
1855
+ const readPanels = async () => JSON.parse(String((await provider.execute(session, {
1856
+ script: 'JSON.stringify(window.__dshChromePanels ? window.__dshChromePanels() : [])',
1857
+ })).value))
1858
+ const moveTo = async (x, y) => { await provider.chromeInput('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y }) }
1859
+ const closeAnyMenu = async () => {
1860
+ await provider.click(session, { x: 4, y: 4 })
1861
+ await new Promise(resolve => setTimeout(resolve, 320))
1862
+ }
1863
+ // 停在工具栏最右那个按钮上,等页面把菜单画出来,读它的**可视**矩形。
1864
+ const mainPanel = async () => {
1865
+ await closeAnyMenu()
1866
+ await moveTo(1362, CHROME_TOOLBAR_Y)
1867
+ for (let i = 0; i < 24; i += 1) {
1868
+ await new Promise(resolve => setTimeout(resolve, 150))
1869
+ const panel = (await readPanels()).find(entry => entry.id === 'main' && entry.open === true)
1870
+ if (panel !== undefined) return panel
1871
+ }
1872
+ return null
1873
+ }
1874
+ const resetZoom = async () => {
1875
+ await provider.pressKey(session, { key: '0', modifiers: ['ctrl'] })
1876
+ await new Promise(resolve => setTimeout(resolve, 450))
1877
+ }
1878
+ await resetZoom()
1879
+ const at100 = await mainPanel()
1880
+ if (at100 === null) throw new Error('resting on the menu button opened no menu at 100%')
1881
+ for (let i = 0; i < 6; i += 1) {
1882
+ await provider.pressKey(session, { key: '=', modifiers: ['ctrl'] })
1883
+ await new Promise(resolve => setTimeout(resolve, 220))
1884
+ }
1885
+ const zoom = Number(String((await provider.execute(session, { script: 'window.__dshZoom' })).value)) || 1
1886
+ if (zoom < 1.5) throw new Error('could not raise the zoom past 150%: ' + String(zoom))
1887
+ const atZoom = await mainPanel()
1888
+ if (atZoom === null) throw new Error('resting on the menu button opened no menu at ' + String(zoom) + 'x')
1889
+ // frame 是另一个视图、不缩放,它的 innerWidth 就是窗口的屏幕 px。
1890
+ const frameWidth = Number(String(await provider.chromeEval('window.innerWidth'))) || 0
1891
+ if (frameWidth === 0) return { skipped: 'the frame reported no width' }
1892
+ const expected = frameWidth - 8
1893
+ const right100 = Math.round(at100.left + at100.width)
1894
+ const rightZoom = Math.round(atZoom.left + atZoom.width)
1895
+ if (Math.abs(right100 - expected) > 14) throw new Error('at 100% the popup right edge was ' + right100 + ', expected ~' + expected)
1896
+ if (Math.abs(rightZoom - expected) > 14) throw new Error('at ' + zoom + 'x the popup right edge was ' + rightZoom + ', expected ~' + expected + ' - the anchor did not follow the zoom')
1897
+ await resetZoom()
1898
+ return { at100: right100, atZoom: rightZoom, expected, zoom }
1899
+ })
1900
+ await check('a tab switch fades the incoming page in, a navigation does not', async () => {
1901
+ const pageEval = async script => String((await provider.execute(session, { script })).value)
1902
+ const readLog = async () => JSON.parse(await pageEval('JSON.stringify(window.__dshChromeMotion ? window.__dshChromeMotion() : [])'))
1903
+ const clickAt = async (x, y) => {
1904
+ await provider.chromeInput('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 })
1905
+ await provider.chromeInput('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 })
1906
+ }
1907
+ const waitForTabs = async want => {
1908
+ for (let i = 0; i < 25; i += 1) {
1909
+ const list = await provider.listTabs(session)
1910
+ if (list.length === want) return list
1911
+ await new Promise(resolve => setTimeout(resolve, 150))
1912
+ }
1913
+ return null
1914
+ }
1915
+ let tabs = await provider.listTabs(session)
1916
+ if (tabs.length < 2) { await clickAt(258, 20); tabs = await waitForTabs(2) }
1917
+ if (tabs === null || tabs.length < 2) throw new Error('could not get two tabs: ' + JSON.stringify(tabs))
1918
+ // 先导航一次(在第二个标签上):导航**不许**播 reveal。
1919
+ await provider.navigate(session, { url: 'https://example.com/' })
1920
+ await new Promise(resolve => setTimeout(resolve, 1600))
1921
+ await pageEval("(() => { window.__dshChromeMotionClear(); return 'cleared' })()")
1922
+ await provider.navigate(session, { url: 'https://www.iana.org/help/example-domains' })
1923
+ await new Promise(resolve => setTimeout(resolve, 1800))
1924
+ const afterNav = await readLog()
1925
+ if (afterNav.some(entry => entry.name === 'dshReveal')) throw new Error('a navigation replayed the reveal: ' + JSON.stringify(afterNav))
1926
+ // 再切回第一个标签:这一次**必须**播。
1927
+ await clickAt(221, 20)
1928
+ await new Promise(resolve => setTimeout(resolve, 900))
1929
+ let log = []
1930
+ for (let i = 0; i < 12 && !log.some(entry => entry.name === 'dshReveal'); i += 1) {
1931
+ await new Promise(resolve => setTimeout(resolve, 120))
1932
+ log = await readLog()
1933
+ }
1934
+ const hit = log.find(entry => entry.name === 'dshReveal')
1935
+ if (hit === undefined) throw new Error('switching tabs did not reveal the page: ' + JSON.stringify(log))
1936
+ // 收尾:把多出来的标签关掉,后面的检查假定只有一个。
1937
+ const extra = (await provider.listTabs(session)).length
1938
+ if (extra > 1) { await clickAt(453, 20); await waitForTabs(1) }
1939
+ return { switch: hit.name, navigation: 'none' }
1940
+ })
1941
+ await check('a second notice cross-fades its text', async () => {
1942
+ const pageEval = async script => String((await provider.execute(session, { script })).value)
1943
+ await pageEval("(() => { window.__dshChromeToast('first', 'info'); return 'ok' })()")
1944
+ await new Promise(resolve => setTimeout(resolve, 400))
1945
+ await pageEval("(() => { window.__dshChromeMotionClear(); return 'cleared' })()")
1946
+ await pageEval("(() => { window.__dshChromeToast('second', 'info'); return 'ok' })()")
1947
+ let log = []
1948
+ for (let i = 0; i < 15 && !log.some(entry => entry.name === 'dshTextIn'); i += 1) {
1949
+ await new Promise(resolve => setTimeout(resolve, 120))
1950
+ log = JSON.parse(await pageEval('JSON.stringify(window.__dshChromeMotion ? window.__dshChromeMotion() : [])'))
1951
+ }
1952
+ const hit = log.find(entry => entry.name === 'dshTextIn' && entry.id === 'toast')
1953
+ if (hit === undefined) throw new Error('the notice swapped its text without fading: ' + JSON.stringify(log))
1954
+ return { animation: hit.name }
1955
+ })
1956
+
1957
+
1958
+ } finally {
1959
+ host.dispose()
1960
+ await new Promise(resolve => setTimeout(resolve, 1500))
1961
+ try { rmSync(profile, { recursive: true, force: true }) } catch { /* locked briefly */ }
1962
+ try { rmSync(outDir, { recursive: true, force: true }) } catch { /* locked briefly */ }
1963
+ }
1964
+
1965
+ const failed = results.filter(result => !result.ok)
1966
+ console.log('')
1967
+ console.log(JSON.stringify({ passed: results.length - failed.length, failed: failed.length, failures: failed.map(f => f.name) }))
1968
+ process.exit(failed.length === 0 ? 0 : 1)