dsh-inner-browser-use 0.43.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/read.js CHANGED
@@ -1124,10 +1124,12 @@ export async function findOnPage(page, query, options = {}) {
1124
1124
  }
1125
1125
 
1126
1126
  /**
1127
- * Collect the page as blocks of prose, each with the heading it sits under and
1128
- * how much of it is links. Navigation, sidebars and footers are mostly links;
1129
- * Readability's oldest signal is that such a block is not the content, and it
1130
- * costs one number to carry instead of a guess.
1127
+ * Collect the page as blocks of prose. Each block carries the trail of headings
1128
+ * it sits under, how much of it is links, and one number for how much of it
1129
+ * reads like written content: navigation, sidebars and footers are mostly links
1130
+ * and mostly short, which is Readability's oldest signal. A table row is one
1131
+ * block, with its column names written into it, so a fact that lives in a table
1132
+ * costs a line instead of a page.
1131
1133
  * @param {number} budget - characters of block text to collect.
1132
1134
  * @returns {string} an expression to evaluate.
1133
1135
  */
@@ -1136,32 +1138,66 @@ export function blocks(budget) {
1136
1138
  const root = document.querySelector('article, main, [role="main"]') || document.body
1137
1139
  if (!root) return JSON.stringify({ blocks: [] })
1138
1140
  const limit = ${budget}
1139
- const BLOCK = new Set(['P', 'LI', 'TD', 'TH', 'BLOCKQUOTE', 'PRE', 'FIGCAPTION', 'DD', 'DT', 'SUMMARY'])
1141
+ const guard = limit * 6
1142
+ const BLOCK = new Set(['P', 'LI', 'BLOCKQUOTE', 'PRE', 'FIGCAPTION', 'DD', 'DT', 'SUMMARY'])
1140
1143
  const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'SVG', 'IFRAME', 'CANVAS', 'VIDEO', 'HEADER', 'FOOTER', 'NAV', 'ASIDE', 'FORM', 'BUTTON', 'SELECT', 'TEXTAREA'])
1141
1144
  const out = []
1142
1145
  const seen = new Set()
1146
+ const stack = []
1143
1147
  let chars = 0
1144
- let heading = ''
1145
1148
  const flatten = (text) => String(text ?? '').replace(/\\s+/g, ' ').trim()
1149
+ const trail = () => stack.filter((one) => one !== '').join(' \\u203a ')
1150
+ const said = () => stack.filter((one) => one !== '').pop() ?? ''
1151
+ /* How much of a block reads like prose rather than a list of links: ends of
1152
+ sentences per word, plus a length floor. One number, no dictionary. */
1153
+ const written = (text) => {
1154
+ const words = text.toLowerCase().match(/[a-z0-9]+|[\\u3400-\\u9fff]/g) ?? []
1155
+ if (words.length === 0) return 0
1156
+ const ends = text.match(/[.!?\\u3002\\uff01\\uff1f;\\uff1b]/g) ?? []
1157
+ return Math.min(1, (ends.length / words.length) * 12)
1158
+ }
1159
+ const quality = (text, density) => Math.round((0.45 * (1 - density) + 0.35 * written(text) + 0.2 * Math.min(1, text.length / 180)) * 100) / 100
1146
1160
  const push = (el) => {
1147
- if (chars >= limit) return
1161
+ if (chars >= guard) return
1148
1162
  const text = flatten(el.innerText ?? el.textContent ?? '')
1149
1163
  if (text === '' || text.length > 4000) return
1150
1164
  let linkChars = 0
1151
1165
  for (const link of el.querySelectorAll('a')) linkChars += flatten(link.innerText ?? '').length
1152
1166
  if (el.tagName === 'A') linkChars += text.length
1153
- const density = text.length === 0 ? 0 : Math.min(1, linkChars / text.length)
1154
- out.push({ h: heading, t: text, d: Math.round(density * 100) / 100 })
1167
+ const density = Math.min(1, linkChars / text.length)
1168
+ /* A block that is almost all link and short is a menu item, not a sentence;
1169
+ carrying it only spends the budget. */
1170
+ if (density >= 0.8 && text.length < 80) return
1171
+ const path = trail()
1172
+ out.push({ h: said(), p: path, t: text, d: Math.round(density * 100) / 100, q: quality(text, density) })
1155
1173
  chars += text.length
1156
1174
  }
1175
+ const row = (tr) => {
1176
+ if (chars >= guard) return
1177
+ const cells = Array.from(tr.children).filter((cell) => cell.tagName === 'TD' || cell.tagName === 'TH')
1178
+ const words = cells.map((cell) => flatten(cell.innerText ?? ''))
1179
+ if (words.filter((one) => one !== '').length < 2) return
1180
+ const table = tr.closest('table')
1181
+ const first = table === null ? null : table.querySelector('tr')
1182
+ const head = first === null || first === tr ? [] : Array.from(first.children).map((cell) => flatten(cell.innerText ?? ''))
1183
+ const line = words.map((one, at) => (head[at] === undefined || head[at] === '' || head[at] === one ? one : head[at] + ': ' + one)).join(' | ')
1184
+ out.push({ h: said(), p: trail(), t: line, d: 0, q: 0.95, r: 1 })
1185
+ chars += line.length
1186
+ }
1157
1187
  const walk = (el) => {
1158
- if (chars >= limit || el === null) return
1188
+ if (el === null || chars >= guard) return
1159
1189
  const tag = el.tagName
1160
1190
  if (SKIP.has(tag) || el.hidden === true || el.getAttribute?.('aria-hidden') === 'true') return
1161
1191
  const style = getComputedStyle(el)
1162
1192
  if (style.display === 'none' || style.visibility === 'hidden') return
1163
1193
  if (/^H[1-6]$/.test(tag)) {
1164
- heading = flatten(el.innerText ?? '')
1194
+ const level = Number(tag.slice(1))
1195
+ stack.length = level
1196
+ stack[level - 1] = flatten(el.innerText ?? '')
1197
+ return
1198
+ }
1199
+ if (tag === 'TR') {
1200
+ if (!seen.has(el)) { seen.add(el); row(el) }
1165
1201
  return
1166
1202
  }
1167
1203
  if (BLOCK.has(tag)) {
@@ -1172,7 +1208,23 @@ export function blocks(budget) {
1172
1208
  }
1173
1209
  walk(root)
1174
1210
  const title = document.title
1175
- return JSON.stringify({ title, blocks: out })
1211
+ /* The page arrives in document order, so a menu at the top would spend the
1212
+ whole budget before the article starts. Keep the blocks that read like
1213
+ content, still in page order, until the budget is spent. */
1214
+ let kept = out
1215
+ const total = out.reduce((sum, block) => sum + block.t.length, 0)
1216
+ if (total > limit) {
1217
+ const byQuality = out.map((block, at) => ({ block, at })).sort((a, b) => b.block.q - a.block.q || a.at - b.at)
1218
+ const keep = new Set()
1219
+ let used = 0
1220
+ for (const one of byQuality) {
1221
+ if (used + one.block.t.length > limit) continue
1222
+ keep.add(one.at)
1223
+ used += one.block.t.length
1224
+ }
1225
+ kept = out.filter((block, at) => keep.has(at))
1226
+ }
1227
+ return JSON.stringify({ title, blocks: kept })
1176
1228
  })()`
1177
1229
  }
1178
1230
 
@@ -1217,6 +1269,20 @@ function brief(value) {
1217
1269
  return body.length > 260 ? `${body.slice(0, 260).replace(/\s+\S*$/, '')}…` : body
1218
1270
  }
1219
1271
 
1272
+ /**
1273
+ * The trail of headings a block sits under, when it says more than the nearest
1274
+ * one: "Pricing › Refunds" tells a caller where a fact lives, which a bare
1275
+ * sentence cannot. Kept short so location never costs more than it explains.
1276
+ * @param {{h?: string, p?: string}} block - the block.
1277
+ * @returns {string} what to print above the passage.
1278
+ */
1279
+ export function trailOf(block) {
1280
+ const path = String(block?.p ?? '')
1281
+ const near = String(block?.h ?? '')
1282
+ if (path === '' || path === near || path.length > 90) return near
1283
+ return path
1284
+ }
1285
+
1220
1286
  /**
1221
1287
  * Rank blocks against a query the way a search engine ranks a passage: BM25,
1222
1288
  * with the heading counted as a place a term can match. Then read only the
@@ -1228,7 +1294,7 @@ function brief(value) {
1228
1294
  export function rank(blocks, query) {
1229
1295
  const asked = terms(query)
1230
1296
  if (asked.length === 0) return []
1231
- const docs = blocks.map((block) => ({ block, terms: terms(`${block.h ?? ''} ${block.t}`) }))
1297
+ const docs = blocks.map((block) => ({ block, terms: terms(`${block.p ?? ''} ${block.h ?? ''} ${block.t}`) }))
1232
1298
  const average = docs.reduce((sum, one) => sum + one.terms.length, 0) / Math.max(1, docs.length)
1233
1299
  const seen = new Map()
1234
1300
  for (const one of docs) for (const term of new Set(one.terms)) seen.set(term, (seen.get(term) ?? 0) + 1)
@@ -1250,6 +1316,9 @@ export function rank(blocks, query) {
1250
1316
  /* A block that is nearly all link is a menu: it matches words without
1251
1317
  saying anything about them. */
1252
1318
  score *= 1 - 0.8 * Number(one.block.d ?? 0)
1319
+ /* Prose that answers a question outranks a fragment of navigation that
1320
+ happens to contain the same words; the quality number says which. */
1321
+ score *= 0.85 + 0.15 * Number(one.block.q ?? 0.5)
1253
1322
  if (score > 0) scored.push({ block: one.block, score })
1254
1323
  }
1255
1324
  scored.sort((a, b) => b.score - a.score)
@@ -1297,7 +1366,7 @@ export function evidence(blocks, query, options = {}) {
1297
1366
  const near = rank(blocks, query).slice(0, Math.max(1, Math.min(60, Number(options.blocks ?? 40))))
1298
1367
  const units = []
1299
1368
  for (const row of near) {
1300
- const heading = String(row.block.h ?? '')
1369
+ const heading = trailOf(row.block)
1301
1370
  const body = String(row.block.t ?? '')
1302
1371
  const parts = sentences(body)
1303
1372
  for (let step = 0; step < parts.length; step += 1) {
@@ -1339,8 +1408,9 @@ export function evidence(blocks, query, options = {}) {
1339
1408
  both say "this is the right place", so they lift their sentences. */
1340
1409
  const headingHit = asked.filter((term) => terms(unit.heading).includes(term)).length
1341
1410
  /* A menu sentence can match the words without saying anything about them;
1342
- the paragraph's link density is what tells the two apart. */
1343
- const linkish = 1 - 0.8 * Number(unit.block?.d ?? 0)
1411
+ the paragraph's link density and how much it reads like prose tell the
1412
+ two apart, and a table row is data rather than a sentence to distrust. */
1413
+ const linkish = (1 - 0.8 * Number(unit.block?.d ?? 0)) * (unit.block?.r === 1 ? 1 : 0.9 + 0.1 * Number(unit.block?.q ?? 0.5))
1344
1414
  unit.score = score * linkish * (1 + 0.25 * headingHit) * (1 + 0.5 * (unit.prior > 0 ? 1 : 0))
1345
1415
  }
1346
1416
  /* Below a fifth of the best sentence, a match is coincidence rather than
@@ -1424,7 +1494,7 @@ export async function readRelevant(page, query, options = {}) {
1424
1494
  for (const row of pieces.slice(0, limit)) {
1425
1495
  const body = String(row.block.t ?? '')
1426
1496
  if (used + body.length > budget) continue
1427
- const heading = String(row.block.h ?? '')
1497
+ const heading = trailOf(row.block)
1428
1498
  if (heading !== '' && heading !== lastHeading) { lines.push(`## ${heading}`); lastHeading = heading }
1429
1499
  lines.push(body)
1430
1500
  used += body.length
@@ -28,6 +28,10 @@ try {
28
28
  await sidecar.watch(() => {})
29
29
  await sidecar.page.navigate(url).catch(() => {})
30
30
  await wait(9000)
31
+ /* A port that answers is not a plugin that loaded: ask the page, because a
32
+ missing plugin shows up as a black movie and reads like a slow one. */
33
+ const plugin = sidecar.flashCheck ?? (await sidecar.checkFlash())
34
+ const pluginOk = plugin?.ok === true
31
35
  const said = []
32
36
  const timed = async (name, args) => {
33
37
  const started = Date.now()
@@ -64,8 +68,8 @@ try {
64
68
  up whole. Otherwise a bad minute at the site reads as a regression here. */
65
69
  const degraded = said[0].chars < 300 || said[0].text.includes('page trouble')
66
70
  const fast = () => degraded || (median(clicks) < 120 && median(types) < 120)
67
- ok = clicks.length === 5 && fast() && clicks.every(within) && types.every(within) && Number(last?.chars ?? 0) > 0 && viewAgain.chars < said[0].chars && viewThird.chars < said[0].chars
68
- console.log(JSON.stringify({ view: said[0], clickMedianMs: median(clicks), clickMaxMs: Math.max(...clicks.map((one) => one.ms)), typeMedianMs: median(types), typeMaxMs: Math.max(...types.map((one) => one.ms)), clickChars: clicks[0].chars, typeChars: types[0].chars, viewAgainChars: viewAgain.chars, viewThirdChars: viewThird.chars, viewAgainText: viewAgain.text, degraded, ok }))
71
+ ok = pluginOk && clicks.length === 5 && fast() && clicks.every(within) && types.every(within) && Number(last?.chars ?? 0) > 0 && viewAgain.chars < said[0].chars && viewThird.chars < said[0].chars
72
+ console.log(JSON.stringify({ plugin, view: said[0], clickMedianMs: median(clicks), clickMaxMs: Math.max(...clicks.map((one) => one.ms)), typeMedianMs: median(types), typeMaxMs: Math.max(...types.map((one) => one.ms)), clickChars: clicks[0].chars, typeChars: types[0].chars, viewAgainChars: viewAgain.chars, viewThirdChars: viewThird.chars, viewAgainText: viewAgain.text, degraded, ok }))
69
73
  } catch (error) {
70
74
  console.log(JSON.stringify({ ok: false, error: String(error?.message ?? error).slice(0, 160) }))
71
75
  } finally {
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * browser_look against a real Chrome and a page that draws itself.
4
+ *
5
+ * node scripts/check-look.mjs
6
+ *
7
+ * Paints a canvas, reads the screen as letters, paints one square, reads again
8
+ * and reports whether the second read named only that square. Exits non-zero
9
+ * when the tool stops being able to see a self-drawn page.
10
+ */
11
+ import { mkdtempSync, writeFileSync } from 'node:fs'
12
+ import { tmpdir } from 'node:os'
13
+ import { join } from 'node:path'
14
+ import { pathToFileURL } from 'node:url'
15
+
16
+ process.env.DSH_INNER_BROWSER_STATE_DIR = mkdtempSync(join(tmpdir(), 'dsh-look-'))
17
+ const folder = mkdtempSync(join(tmpdir(), 'dsh-look-page-'))
18
+ writeFileSync(join(folder, 'index.html'), `<!doctype html><meta charset="utf-8">
19
+ <style>html,body{margin:0;height:100%;background:#101010}canvas{display:block}</style>
20
+ <canvas id="c"></canvas>
21
+ <script>
22
+ const c = document.getElementById('c')
23
+ c.width = innerWidth
24
+ c.height = innerHeight
25
+ const g = c.getContext('2d')
26
+ g.fillStyle = '#202020'
27
+ g.fillRect(0, 0, c.width, c.height)
28
+ g.fillStyle = '#e0e0e0'
29
+ g.fillRect(0, 0, c.width, c.height / 2)
30
+ </script>`)
31
+
32
+ const { Sidecar } = await import('../sidecar.js')
33
+ const { sidecarTools, sessionScope, workScope } = await import('../tools.js')
34
+
35
+ const wait = (ms) => new Promise((settle) => setTimeout(settle, ms))
36
+ const sidecar = new Sidecar({ profile: 'look-check', headless: true })
37
+ const tools = sidecarTools(sidecar, { byChat: new Map() }, {})
38
+ const tool = (name) => tools.find((one) => one.name === name)
39
+ /* Every tool path reads the chat from the scope it is called in, so the check
40
+ binds the same chat the plugin would bind for a real conversation. */
41
+ const run = (name, args) => sessionScope.run('look-check', () => workScope.run(folder, () => tool(name).execute(args, { agent: { id: 'look-check' } })))
42
+ let failed = 0
43
+ const check = (ok, what) => {
44
+ console.log(`${ok ? 'ok ' : 'FAIL'} ${what}`)
45
+ if (!ok) failed += 1
46
+ }
47
+ try {
48
+ await sidecar.launch('about:blank')
49
+ await sidecar.watch(() => {})
50
+ const page = await sidecar.ensure({ session: 'look-check' })
51
+ await page.navigate(pathToFileURL(join(folder, 'index.html')).href).catch(() => {})
52
+ await wait(900)
53
+
54
+ const first = await run('browser_look', { cols: 12 })
55
+ const firstText = String(first?.text ?? '')
56
+ console.log('--- first look ---\n' + firstText)
57
+ check(/^look 12x\d+ of the screen/m.test(firstText), 'the whole screen is read as a 12-wide grid')
58
+ check(/1 canvas/.test(firstText), 'the call says the page draws itself')
59
+ check((firstText.match(/^[.:+\-*#rgbcmyopn]+$/gm) ?? []).length >= 6, 'the grid arrives as rows of palette letters')
60
+ check(/first look at this screen/.test(firstText), 'the first look says there is nothing to compare yet')
61
+
62
+ const painted = await run('browser_eval', { code: "(() => { const c = document.querySelector('canvas'); const g = c.getContext('2d'); g.fillStyle = '#ff2020'; g.fillRect(c.width * 0.2, c.height * 0.15, c.width * 0.4, c.height * 0.2); return 'painted' })()" })
63
+ check(/painted/.test(String(painted?.text ?? '')), 'the page can be asked to paint a square')
64
+ await wait(300)
65
+
66
+ const second = await run('browser_look', { cols: 12 })
67
+ const secondText = String(second?.text ?? '')
68
+ console.log('--- second look ---\n' + secondText)
69
+ check(/changed: \d+ cells? in box \d+,\d+ \d+x\d+/.test(secondText), 'the second look names only the part that moved')
70
+ /* Red has to be in the changed box, not anywhere in a page that may already
71
+ use it: the diff block is only palette rows, so one of those rows must
72
+ carry r. */
73
+ const diffBlock = secondText.slice(secondText.search(/changed:/))
74
+ check(/^[.:+*#rgbcmyopn-]*r[.:+*#rgbcmyopn-]*$/m.test(diffBlock), 'the picture of the change shows red where the square is')
75
+
76
+ const same = await run('browser_look', { cols: 12 })
77
+ check(/nothing changed since the last look/.test(String(same?.text ?? '')), 'a look that sees nothing new says so')
78
+ const chars = firstText.length + secondText.length + String(same?.text ?? '').length
79
+ console.log(`three looks cost ${chars} characters and no picture`)
80
+ } catch (error) {
81
+ console.error('look check failed:', error)
82
+ failed += 1
83
+ } finally {
84
+ await sidecar.close?.().catch(() => {})
85
+ }
86
+ process.exitCode = failed === 0 ? 0 : 1
@@ -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 ?? '',