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/README.md +3 -0
- package/README.zh.md +3 -0
- package/docs/ARCHITECTURE.md +36 -4
- package/docs/ARTIFACTS.md +19 -14
- package/docs/BENCH.md +30 -0
- package/docs/FLASH.md +10 -0
- package/docs/VERIFY.md +41 -0
- package/index.js +1 -1
- package/package.json +10 -3
- package/pixels.js +217 -0
- package/read.js +87 -17
- 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
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
|
|
1128
|
-
* how much of it is links
|
|
1129
|
-
*
|
|
1130
|
-
*
|
|
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
|
|
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 >=
|
|
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 =
|
|
1154
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
1343
|
-
|
|
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 =
|
|
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
|
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 ?? '',
|