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.
@@ -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
@@ -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 ?? '',