dsh-inner-browser-use 0.44.0 → 0.45.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.
- package/README.md +3 -0
- package/README.zh.md +3 -0
- package/docs/ARCHITECTURE.md +27 -1
- package/docs/ARTIFACTS.md +19 -14
- package/docs/BENCH.md +16 -0
- package/docs/FLASH.md +10 -0
- package/docs/VERIFY.md +41 -0
- package/index.js +1 -1
- package/package.json +9 -2
- package/pixels.js +217 -0
- package/scripts/check-flash-tools.mjs +6 -2
- package/scripts/check-look.mjs +86 -0
- package/scripts/check-sites.mjs +94 -0
- package/scripts/soak.mjs +110 -0
- package/sidecar.js +45 -0
- package/tools.js +302 -23
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* What the second read of the same site costs.
|
|
4
|
+
*
|
|
5
|
+
* node scripts/check-sites.mjs
|
|
6
|
+
*
|
|
7
|
+
* Serves two pages on two origins: an app shell with three hundred buttons and
|
|
8
|
+
* a document of twelve thousand characters. Reads each one three times and
|
|
9
|
+
* checks that the first read teaches the next — the app opens as a map, the
|
|
10
|
+
* document is asked for less — and that the lesson is on disk for the next run.
|
|
11
|
+
* Exits non-zero when a site stops being learned.
|
|
12
|
+
*/
|
|
13
|
+
import { createServer } from 'node:http'
|
|
14
|
+
import { existsSync, mkdtempSync, readFileSync } from 'node:fs'
|
|
15
|
+
import { tmpdir } from 'node:os'
|
|
16
|
+
import { join } from 'node:path'
|
|
17
|
+
|
|
18
|
+
process.env.DSH_INNER_BROWSER_STATE_DIR = mkdtempSync(join(tmpdir(), 'dsh-sites-'))
|
|
19
|
+
|
|
20
|
+
const buttons = Array.from({ length: 300 }, (_, i) => `<button>action ${i + 1}</button>`).join('')
|
|
21
|
+
const app = `<!doctype html><meta charset="utf-8"><title>app shell</title>
|
|
22
|
+
<h1>app shell</h1><p>a shell with three hundred things to click.</p><div>${buttons}</div>`
|
|
23
|
+
const words = Array.from({ length: 240 }, (_, i) => `Paragraph ${i + 1}: ` + 'the quick brown fox jumps over the lazy dog and keeps running through the field '.repeat(3)).join('</p><p>')
|
|
24
|
+
const doc = `<!doctype html><meta charset="utf-8"><title>long document</title>
|
|
25
|
+
<article><h1>long document</h1><p>${words}</p></article>`
|
|
26
|
+
|
|
27
|
+
const server = createServer((request, response) => {
|
|
28
|
+
const body = String(request.url ?? '').startsWith('/doc') ? doc : app
|
|
29
|
+
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
|
30
|
+
response.end(body)
|
|
31
|
+
})
|
|
32
|
+
await new Promise((done) => server.listen(0, '127.0.0.1', done))
|
|
33
|
+
const port = server.address().port
|
|
34
|
+
const appUrl = `http://127.0.0.1:${port}/app`
|
|
35
|
+
const docUrl = `http://localhost:${port}/doc`
|
|
36
|
+
|
|
37
|
+
const { Sidecar } = await import('../sidecar.js')
|
|
38
|
+
const { saveTactics, sidecarTools, sessionScope, workScope } = await import('../tools.js')
|
|
39
|
+
|
|
40
|
+
const wait = (ms) => new Promise((settle) => setTimeout(settle, ms))
|
|
41
|
+
const sidecar = new Sidecar({ profile: 'sites-check', headless: true })
|
|
42
|
+
const tools = sidecarTools(sidecar, { byChat: new Map() }, {})
|
|
43
|
+
const tool = (name) => tools.find((one) => one.name === name)
|
|
44
|
+
const cwd = mkdtempSync(join(tmpdir(), 'dsh-sites-cwd-'))
|
|
45
|
+
/* Same binding the plugin does: the chat and the project a call belongs to. */
|
|
46
|
+
const run = (name, args) => sessionScope.run('sites-check', () => workScope.run(cwd, () => tool(name).execute(args, { agent: { id: 'sites-check' } })))
|
|
47
|
+
let failed = 0
|
|
48
|
+
const check = (ok, what) => {
|
|
49
|
+
console.log(`${ok ? 'ok ' : 'FAIL'} ${what}`)
|
|
50
|
+
if (!ok) failed += 1
|
|
51
|
+
}
|
|
52
|
+
const text = (said) => String(said?.text ?? '')
|
|
53
|
+
try {
|
|
54
|
+
await sidecar.launch('about:blank')
|
|
55
|
+
await sidecar.watch(() => {})
|
|
56
|
+
await sidecar.ensure({ session: 'sites-check' })
|
|
57
|
+
|
|
58
|
+
await run('browser_open', { url: appUrl })
|
|
59
|
+
const appFirst = text(await run('browser_view', {}))
|
|
60
|
+
const appSecond = text(await run('browser_view', {}))
|
|
61
|
+
const appThird = text(await run('browser_view', {}))
|
|
62
|
+
console.log('--- app, third read ---\n' + appThird.slice(0, 1200))
|
|
63
|
+
check(!/^map: /m.test(appFirst), 'the first read of a big site is the page itself')
|
|
64
|
+
check(!/^map: /m.test(appSecond), 'one read is not yet a lesson')
|
|
65
|
+
check(/^map: \d+ controls/m.test(appThird), 'the third read opens as a map of what is there')
|
|
66
|
+
check(/this site reads big \(2 read\(s\)/.test(appThird), 'the map says which site it learned and why')
|
|
67
|
+
check(appThird.length < appFirst.length, `the map costs less than the page (${String(appThird.length)} < ${String(appFirst.length)} chars)`)
|
|
68
|
+
|
|
69
|
+
await run('browser_open', { url: docUrl })
|
|
70
|
+
const docFirst = text(await run('browser_read', {}))
|
|
71
|
+
const docSecond = text(await run('browser_read', { force: true }))
|
|
72
|
+
const docThird = text(await run('browser_read', {}))
|
|
73
|
+
console.log('--- doc, third read (head) ---\n' + docThird.slice(0, 400))
|
|
74
|
+
console.log('--- doc, third read (tail) ---\n' + docThird.slice(-300))
|
|
75
|
+
check(docFirst.length > 4000, `a long document really is long (${String(docFirst.length)} chars)`)
|
|
76
|
+
check(docSecond.length > 4000, 'the forced second read is the whole thing again')
|
|
77
|
+
check(/this site reads long \(2 read\(s\), ~\d{4,} chars of text\): cut at 3500 characters/.test(docThird), 'the third read says it was cut short and how to go past it')
|
|
78
|
+
check(docThird.length < docFirst.length, `the learned read costs less (${String(docThird.length)} < ${String(docFirst.length)} chars)`)
|
|
79
|
+
|
|
80
|
+
await saveTactics()
|
|
81
|
+
const file = join(process.env.DSH_INNER_BROWSER_STATE_DIR, 'sites.json')
|
|
82
|
+
check(existsSync(file), 'the lesson is written where the next run will find it')
|
|
83
|
+
const kept = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {}
|
|
84
|
+
check(kept[`http://127.0.0.1:${port}`]?.controls >= 60, 'the app origin remembers how many controls it has')
|
|
85
|
+
check(kept[`http://localhost:${port}`]?.text >= 5000, 'the document origin remembers how long its text is')
|
|
86
|
+
} catch (error) {
|
|
87
|
+
failed += 1
|
|
88
|
+
console.log('FAIL ' + String(error?.stack ?? error))
|
|
89
|
+
} finally {
|
|
90
|
+
await sidecar.close?.().catch(() => {})
|
|
91
|
+
server.close()
|
|
92
|
+
}
|
|
93
|
+
console.log(failed === 0 ? 'sites: ok' : `sites: ${String(failed)} failed`)
|
|
94
|
+
process.exitCode = failed === 0 ? 0 : 1
|
package/scripts/soak.mjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Does a long session grow without bound?
|
|
4
|
+
*
|
|
5
|
+
* node scripts/soak.mjs [cycles]
|
|
6
|
+
*
|
|
7
|
+
* One headless browser, one tab, one chat, over and over: open a page, read it,
|
|
8
|
+
* take a snapshot, read the same page again (the unchanged short-circuit), open
|
|
9
|
+
* the other page. Memory and page count are sampled every few cycles. The run
|
|
10
|
+
* fails when RSS climbs past the ceiling or a second tab appears, so a leak in
|
|
11
|
+
* the screencast, the CDP client or the read cache shows up as a number here
|
|
12
|
+
* instead of as a slow machine next week.
|
|
13
|
+
*
|
|
14
|
+
* Bounded on purpose: 24 cycles by default, about twenty seconds, no internet.
|
|
15
|
+
*/
|
|
16
|
+
import { createServer } from 'node:http'
|
|
17
|
+
import { mkdtempSync } from 'node:fs'
|
|
18
|
+
import { tmpdir } from 'node:os'
|
|
19
|
+
import { join } from 'node:path'
|
|
20
|
+
|
|
21
|
+
process.env.DSH_INNER_BROWSER_STATE_DIR = mkdtempSync(join(tmpdir(), 'dsh-soak-'))
|
|
22
|
+
const cycles = Math.max(4, Math.min(Number(process.argv[2] ?? 24) || 24, 200))
|
|
23
|
+
|
|
24
|
+
const buttons = Array.from({ length: 120 }, (_, i) => `<button>action ${i + 1}</button>`).join('')
|
|
25
|
+
const words = Array.from({ length: 80 }, (_, i) => `Paragraph ${i + 1}: ` + 'the quick brown fox jumps over the lazy dog. '.repeat(6)).join('</p><p>')
|
|
26
|
+
const pages = {
|
|
27
|
+
'/app': `<!doctype html><meta charset="utf-8"><title>app shell</title><h1>app shell</h1><div>${buttons}</div>`,
|
|
28
|
+
'/doc': `<!doctype html><meta charset="utf-8"><title>long document</title><article><h1>long document</h1><p>${words}</p></article>`,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const server = createServer((request, response) => {
|
|
32
|
+
const body = pages[String(request.url ?? '').split('?')[0]] ?? pages['/app']
|
|
33
|
+
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
|
34
|
+
response.end(body)
|
|
35
|
+
})
|
|
36
|
+
await new Promise((done) => server.listen(0, '127.0.0.1', done))
|
|
37
|
+
const port = server.address().port
|
|
38
|
+
const appUrl = `http://127.0.0.1:${port}/app`
|
|
39
|
+
const docUrl = `http://127.0.0.1:${port}/doc`
|
|
40
|
+
|
|
41
|
+
const { Sidecar } = await import('../sidecar.js')
|
|
42
|
+
const { sidecarTools, sessionScope, workScope } = await import('../tools.js')
|
|
43
|
+
|
|
44
|
+
const sidecar = new Sidecar({ profile: 'soak', headless: true })
|
|
45
|
+
const tools = sidecarTools(sidecar, { byChat: new Map() }, {})
|
|
46
|
+
const tool = (name) => tools.find((one) => one.name === name)
|
|
47
|
+
const cwd = mkdtempSync(join(tmpdir(), 'dsh-soak-cwd-'))
|
|
48
|
+
/* Same binding the plugin does: the chat and the project a call belongs to. */
|
|
49
|
+
const run = (name, args) => sessionScope.run('soak', () => workScope.run(cwd, () => tool(name).execute(args, { agent: { id: 'soak' } })))
|
|
50
|
+
const text = (said) => String(said?.text ?? '')
|
|
51
|
+
|
|
52
|
+
/* macOS reports every renderer's RSS in full, shared pages included, so a
|
|
53
|
+
healthy headless tree sits far above what one page needs; the bar is a
|
|
54
|
+
ceiling this machine never approaches plus growth over the run. */
|
|
55
|
+
const ceilingMb = 2500
|
|
56
|
+
const samples = []
|
|
57
|
+
let failed = 0
|
|
58
|
+
const check = (ok, what) => {
|
|
59
|
+
console.log(`${ok ? 'ok ' : 'FAIL'} ${what}`)
|
|
60
|
+
if (!ok) failed += 1
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const startedAt = Date.now()
|
|
64
|
+
try {
|
|
65
|
+
await sidecar.launch('about:blank')
|
|
66
|
+
await sidecar.watch(() => {})
|
|
67
|
+
await sidecar.ensure({ session: 'soak' })
|
|
68
|
+
|
|
69
|
+
let reads = 0
|
|
70
|
+
let snapshots = 0
|
|
71
|
+
for (let i = 0; i < cycles; i += 1) {
|
|
72
|
+
const url = i % 2 === 0 ? appUrl : docUrl
|
|
73
|
+
await run('browser_open', { url })
|
|
74
|
+
reads += text(await run('browser_view', {})).length > 0 ? 1 : 0
|
|
75
|
+
await run('browser_view', {})
|
|
76
|
+
if (i % 3 === 0) {
|
|
77
|
+
snapshots += 1
|
|
78
|
+
await run('browser_shot', {})
|
|
79
|
+
}
|
|
80
|
+
if (i % 2 === 1) await run('browser_read', {})
|
|
81
|
+
if (i % 4 === 3 || i === cycles - 1) {
|
|
82
|
+
/* sidecar.state() already reports what the whole process tree holds, so
|
|
83
|
+
no pid has to be found and no second sampling path exists. */
|
|
84
|
+
const state = await sidecar.state()
|
|
85
|
+
const mb = Math.round(Number(state?.memory ?? 0) / 1e6)
|
|
86
|
+
samples.push({ cycle: i + 1, mb, pages: Number(state?.pages ?? 0) })
|
|
87
|
+
console.log(` cycle ${String(i + 1).padStart(3)} · ${String(mb)} MB · pages ${String(state?.pages)}`)
|
|
88
|
+
}
|
|
89
|
+
/* A quiet moment, so a leak in the close/reopen path is not hidden by the
|
|
90
|
+
next navigation arriving before the old page is gone. */
|
|
91
|
+
if (i % 6 === 5) await new Promise((settle) => setTimeout(settle, 250))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const first = samples[0] ?? { mb: 0 }
|
|
95
|
+
const last = samples.at(-1) ?? first
|
|
96
|
+
const growth = last.mb - first.mb
|
|
97
|
+
const seconds = Math.round((Date.now() - startedAt) / 100) / 10
|
|
98
|
+
check(reads > 0, `the browser answered every cycle (${String(reads)} of ${String(cycles)})`)
|
|
99
|
+
check(samples.every((one) => one.pages <= 1), `one page per chat is kept (max ${String(Math.max(...samples.map((one) => one.pages)))})`)
|
|
100
|
+
check(last.mb < ceilingMb, `RSS stays inside this machine's ceiling (${String(last.mb)} < ${String(ceilingMb)} MB)`)
|
|
101
|
+
check(growth < 250, `RSS growth over ${String(cycles)} cycles is bounded (${growth >= 0 ? '+' : ''}${String(growth)} MB)`)
|
|
102
|
+
console.log(`soak: ${failed === 0 ? 'ok' : `${String(failed)} failed`} · ${String(cycles)} cycles, ${String(seconds)} s, rss ${String(first.mb)} → ${String(last.mb)} MB, pages ${String(last.pages)}, ${String(snapshots)} shots, ${String(reads)} reads`)
|
|
103
|
+
} catch (error) {
|
|
104
|
+
failed += 1
|
|
105
|
+
console.log('FAIL ' + String(error?.stack ?? error))
|
|
106
|
+
} finally {
|
|
107
|
+
await sidecar.close?.().catch(() => {})
|
|
108
|
+
server.close()
|
|
109
|
+
}
|
|
110
|
+
process.exitCode = failed === 0 ? 0 : 1
|
package/sidecar.js
CHANGED
|
@@ -175,6 +175,19 @@ export function flashRuntime() {
|
|
|
175
175
|
return { binary: ready.electron, plugin: ready.plugin, version }
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
/**
|
|
179
|
+
* What the browser said about its own plugin list. A plugin runtime that opens
|
|
180
|
+
* its port but carries no PPAPI module looks fine from outside and plays
|
|
181
|
+
* nothing, so the list is read and turned into a sentence rather than trusted.
|
|
182
|
+
* @param {unknown} names - plugin names from the page.
|
|
183
|
+
* @returns {{ok: boolean, name: string}} whether a Flash plugin is there.
|
|
184
|
+
*/
|
|
185
|
+
export function flashVerdict(names) {
|
|
186
|
+
const list = (Array.isArray(names) ? names : []).map((one) => String(one ?? ''))
|
|
187
|
+
const found = list.find((one) => one.toLowerCase().includes('shockwave'))
|
|
188
|
+
return { ok: found !== undefined, name: found ?? '' }
|
|
189
|
+
}
|
|
190
|
+
|
|
178
191
|
/* A chat's tab address outlives a plugin reload: the browser is still running
|
|
179
192
|
with that tab, and the chat that returns should get its page back, not a
|
|
180
193
|
blank one. */
|
|
@@ -2118,6 +2131,9 @@ export class Sidecar {
|
|
|
2118
2131
|
/* A movie page needs a runtime Chrome no longer has; the switch is the
|
|
2119
2132
|
person's, and it swaps the browser rather than the protocol. */
|
|
2120
2133
|
this.flash = options.flash === undefined ? process.env.DSH_INNER_BROWSER_FLASH === '1' || readFlashChoice() : options.flash === true
|
|
2134
|
+
/* Whether the launched runtime really carries the plugin, asked once at
|
|
2135
|
+
launch: null means it has not been asked. */
|
|
2136
|
+
this.flashCheck = null
|
|
2121
2137
|
/* The Flash app serves its own frames; the screencast cannot see a plugin. */
|
|
2122
2138
|
this.flashBridge = null
|
|
2123
2139
|
this.headless = true
|
|
@@ -2634,6 +2650,29 @@ export class Sidecar {
|
|
|
2634
2650
|
return this.starting
|
|
2635
2651
|
}
|
|
2636
2652
|
|
|
2653
|
+
/**
|
|
2654
|
+
* Ask the Flash-era browser which plugins it actually loaded.
|
|
2655
|
+
* @returns {Promise<{ok: boolean, name: string} | null>} the answer, or null when it could not be asked.
|
|
2656
|
+
*/
|
|
2657
|
+
async checkFlash() {
|
|
2658
|
+
this.flashCheck = null
|
|
2659
|
+
const page = this.page
|
|
2660
|
+
if (page === null || page === undefined) return null
|
|
2661
|
+
try {
|
|
2662
|
+
const out = await page.cdp.send('Runtime.evaluate', {
|
|
2663
|
+
expression: 'JSON.stringify(Array.from(navigator.plugins).map((one) => one.name))',
|
|
2664
|
+
returnByValue: true,
|
|
2665
|
+
})
|
|
2666
|
+
const said = out?.result?.value
|
|
2667
|
+
const names = JSON.parse(typeof said === 'string' ? said : '[]')
|
|
2668
|
+
this.flashCheck = flashVerdict(names)
|
|
2669
|
+
} catch {
|
|
2670
|
+
/* A page that cannot answer is not evidence either way. */
|
|
2671
|
+
this.flashCheck = null
|
|
2672
|
+
}
|
|
2673
|
+
return this.flashCheck
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2637
2676
|
/** @param {string | undefined} url - first address. @returns {Promise<SidecarPage>} the page. */
|
|
2638
2677
|
async launch(url) {
|
|
2639
2678
|
/* Chromium 87 opening the profile a current Chrome owns would downgrade it
|
|
@@ -2656,6 +2695,10 @@ export class Sidecar {
|
|
|
2656
2695
|
await this.restoreSessionCookies()
|
|
2657
2696
|
await this.page.cast()
|
|
2658
2697
|
if (url !== undefined && url !== '') await this.page.navigate(url)
|
|
2698
|
+
/* A runtime that opens a port is not yet a runtime that plays: ask the page
|
|
2699
|
+
whether the plugin is really in it, so a broken install is said out loud
|
|
2700
|
+
instead of showing up as a black movie. */
|
|
2701
|
+
if (this.flash) await this.checkFlash()
|
|
2659
2702
|
this.touch()
|
|
2660
2703
|
/* Sign-ins outlive this browser: what works now is worth a copy, taken
|
|
2661
2704
|
while the cookies are known good rather than after they are lost. */
|
|
@@ -3326,6 +3369,8 @@ export class Sidecar {
|
|
|
3326
3369
|
alive: this.alive, // ui-copy-ignore
|
|
3327
3370
|
profile: this.profile, // ui-copy-ignore
|
|
3328
3371
|
headless: this.headless, // ui-copy-ignore
|
|
3372
|
+
flash: this.flash, // ui-copy-ignore
|
|
3373
|
+
flashCheck: this.flashCheck, // ui-copy-ignore
|
|
3329
3374
|
url: this.page?.url ?? '', // ui-copy-ignore
|
|
3330
3375
|
pages: this.pages.size,
|
|
3331
3376
|
title: this.page?.title ?? '',
|