dsh-inner-browser-use 0.44.0 → 0.46.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/pixels.js ADDED
@@ -0,0 +1,217 @@
1
+ /**
2
+ * The screen without the picture: a page that draws itself — a game, a canvas
3
+ * app, a Flash movie — has nothing a text tree can read, and a screenshot costs
4
+ * image tokens on every step. This turns the screen into a small grid of
5
+ * characters instead: one letter per block of pixels, the same letters for the
6
+ * same colours, so a caller can see what changed by reading text.
7
+ *
8
+ * Everything here is pure: decode, average, quantise, diff. No browser, no
9
+ * files, so it can be tested without one.
10
+ */
11
+
12
+ import { inflateSync } from 'node:zlib'
13
+
14
+ /** Colours a screen block can be called, most common things first. */
15
+ export const PALETTE = [
16
+ { char: '.', name: 'black', rgb: [16, 16, 16] },
17
+ { char: ':', name: 'dark', rgb: [64, 64, 64] },
18
+ { char: '-', name: 'gray', rgb: [112, 112, 112] },
19
+ { char: '+', name: 'light', rgb: [168, 168, 168] },
20
+ { char: '*', name: 'bright', rgb: [216, 216, 216] },
21
+ { char: '#', name: 'white', rgb: [248, 248, 248] },
22
+ { char: 'r', name: 'red', rgb: [208, 48, 48] },
23
+ { char: 'g', name: 'green', rgb: [64, 176, 72] },
24
+ { char: 'b', name: 'blue', rgb: [56, 104, 216] },
25
+ { char: 'y', name: 'yellow', rgb: [232, 208, 72] },
26
+ { char: 'c', name: 'cyan', rgb: [64, 200, 208] },
27
+ { char: 'm', name: 'magenta', rgb: [200, 72, 200] },
28
+ { char: 'o', name: 'orange', rgb: [224, 136, 48] },
29
+ { char: 'p', name: 'purple', rgb: [112, 56, 168] },
30
+ { char: 'n', name: 'brown', rgb: [136, 88, 48] },
31
+ ]
32
+
33
+ /**
34
+ * The letter closest to a colour. Grays and bright colours are judged by eye
35
+ * first so a dark sky does not read as blue and a red button does not read as
36
+ * brown.
37
+ * @param {number} r - red 0..255.
38
+ * @param {number} g - green 0..255.
39
+ * @param {number} b - blue 0..255.
40
+ * @returns {string} one character.
41
+ */
42
+ export function quantize(r, g, b) {
43
+ /* Red, green and blue are counted more heavily than a plain euclidean
44
+ distance would: two colours a person calls different should not collapse
45
+ into the same letter. */
46
+ const red = r * 1.30
47
+ const green = g * 1.15
48
+ const blue = b * 0.90
49
+ let best = PALETTE[0].char
50
+ let near = Infinity
51
+ for (const entry of PALETTE) {
52
+ const dr = red - entry.rgb[0] * 1.30
53
+ const dg = green - entry.rgb[1] * 1.15
54
+ const db = blue - entry.rgb[2] * 0.90
55
+ const d = dr * dr + dg * dg + db * db
56
+ if (d < near) {
57
+ near = d
58
+ best = entry.char
59
+ }
60
+ }
61
+ return best
62
+ }
63
+
64
+ /**
65
+ * Read a PNG made by Chrome into plain pixels. Chrome writes 8-bit
66
+ * non-interlaced images, RGB or RGBA; anything else is refused with a reason
67
+ * rather than a guess.
68
+ * @param {Buffer} file - the PNG bytes.
69
+ * @returns {{width: number, height: number, data: Uint8Array, channels: number}} pixels, row-major.
70
+ */
71
+ export function decodePng(file) {
72
+ const bytes = new Uint8Array(file.buffer !== undefined && file.byteOffset === 0 && file.byteLength === file.buffer.byteLength ? file.buffer : file)
73
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
74
+ const signature = [137, 80, 78, 71, 13, 10, 26, 10]
75
+ for (let i = 0; i < signature.length; i += 1) {
76
+ if (bytes[i] !== signature[i]) throw new Error('pixels: not a PNG')
77
+ }
78
+ let width = 0
79
+ let height = 0
80
+ let depth = 0
81
+ let type = 0
82
+ let interlace = 0
83
+ const chunks = []
84
+ let at = 8
85
+ while (at + 8 <= bytes.length) {
86
+ const length = view.getUint32(at)
87
+ const name = String.fromCharCode(bytes[at + 4], bytes[at + 5], bytes[at + 6], bytes[at + 7])
88
+ const body = bytes.subarray(at + 8, at + 8 + length)
89
+ if (name === 'IHDR') {
90
+ width = view.getUint32(at + 8)
91
+ height = view.getUint32(at + 12)
92
+ depth = body[8]
93
+ type = body[9]
94
+ interlace = body[12]
95
+ } else if (name === 'IDAT') chunks.push(body)
96
+ else if (name === 'IEND') break
97
+ at += 12 + length
98
+ }
99
+ if (depth !== 8) throw new Error(`pixels: PNG depth ${depth} is not 8`)
100
+ const channels = type === 6 ? 4 : type === 2 ? 3 : type === 0 ? 1 : 0
101
+ if (channels === 0) throw new Error(`pixels: PNG colour type ${type} is not RGB`)
102
+ if (interlace !== 0) throw new Error('pixels: interlaced PNG is not supported')
103
+ const packed = inflateSync(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))))
104
+ const stride = width * channels
105
+ const out = new Uint8Array(width * height * channels)
106
+ let raw = 0
107
+ for (let y = 0; y < height; y += 1) {
108
+ const filter = packed[raw]
109
+ raw += 1
110
+ const row = out.subarray(y * stride, (y + 1) * stride)
111
+ const up = y === 0 ? null : out.subarray((y - 1) * stride, y * stride)
112
+ for (let x = 0; x < stride; x += 1) {
113
+ const value = packed[raw + x]
114
+ const left = x >= channels ? row[x - channels] : 0
115
+ const above = up === null ? 0 : up[x]
116
+ const corner = up !== null && x >= channels ? up[x - channels] : 0
117
+ let said = value
118
+ if (filter === 1) said = value + left
119
+ else if (filter === 2) said = value + above
120
+ else if (filter === 3) said = value + ((left + above) >> 1)
121
+ else if (filter === 4) {
122
+ const guess = left + above - corner
123
+ const dl = Math.abs(guess - left)
124
+ const du = Math.abs(guess - above)
125
+ const dc = Math.abs(guess - corner)
126
+ said = value + (dl <= du && dl <= dc ? left : du <= dc ? above : corner)
127
+ } else if (filter !== 0) throw new Error(`pixels: PNG filter ${filter} is not known`)
128
+ row[x] = said & 0xff
129
+ }
130
+ raw += stride
131
+ }
132
+ return { width, height, data: out, channels }
133
+ }
134
+
135
+ /**
136
+ * Average each block of the picture into one character.
137
+ * @param {{width: number, height: number, data: Uint8Array, channels: number}} png - decoded picture.
138
+ * @param {number} cols - cells across.
139
+ * @returns {{rows: string[], cols: number, rowCount: number, blockW: number, blockH: number}} the grid.
140
+ */
141
+ export function gridOf(png, cols) {
142
+ const across = Math.min(64, Math.max(4, Math.round(cols)))
143
+ const rows = Math.min(64, Math.max(2, Math.round((across * png.height) / Math.max(1, png.width))))
144
+ const blockW = png.width / across
145
+ const blockH = png.height / rows
146
+ const lines = []
147
+ let seen = ''
148
+ for (let ry = 0; ry < rows; ry += 1) {
149
+ let line = ''
150
+ for (let rx = 0; rx < across; rx += 1) {
151
+ const x0 = Math.floor(rx * blockW)
152
+ const x1 = Math.max(x0 + 1, Math.min(png.width, Math.floor((rx + 1) * blockW)))
153
+ const y0 = Math.floor(ry * blockH)
154
+ const y1 = Math.max(y0 + 1, Math.min(png.height, Math.floor((ry + 1) * blockH)))
155
+ let r = 0
156
+ let g = 0
157
+ let b = 0
158
+ let n = 0
159
+ for (let y = y0; y < y1; y += 1) {
160
+ for (let x = x0; x < x1; x += 1) {
161
+ const at = (y * png.width + x) * png.channels
162
+ r += png.data[at]
163
+ g += png.data[at + 1] ?? png.data[at]
164
+ b += png.data[at + 2] ?? png.data[at]
165
+ n += 1
166
+ }
167
+ }
168
+ line += quantize(r / n, g / n, b / n)
169
+ }
170
+ lines.push(line)
171
+ seen += line
172
+ }
173
+ return { rows: lines, cols: across, rowCount: rows, blockW, blockH }
174
+ }
175
+
176
+ /**
177
+ * What changed between two grids of the same screen.
178
+ * @param {string[]} before - earlier rows.
179
+ * @param {string[]} after - later rows.
180
+ * @returns {{cells: number, box: {x: number, y: number, w: number, h: number} | null}} the change.
181
+ */
182
+ export function diffGrid(before, after) {
183
+ if (before.length !== after.length || before.some((row, i) => row.length !== after[i].length)) {
184
+ return { cells: Number.POSITIVE_INFINITY, box: null }
185
+ }
186
+ let cells = 0
187
+ let x0 = Infinity
188
+ let y0 = Infinity
189
+ let x1 = -1
190
+ let y1 = -1
191
+ for (let y = 0; y < after.length; y += 1) {
192
+ for (let x = 0; x < after[y].length; x += 1) {
193
+ if (before[y][x] === after[y][x]) continue
194
+ cells += 1
195
+ if (x < x0) x0 = x
196
+ if (y < y0) y0 = y
197
+ if (x > x1) x1 = x
198
+ if (y > y1) y1 = y
199
+ }
200
+ }
201
+ if (cells === 0) return { cells: 0, box: null }
202
+ return { cells, box: { x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1 } }
203
+ }
204
+
205
+ /**
206
+ * Cut one box out of a grid, with a cell of margin around it.
207
+ * @param {string[]} rows - the whole grid.
208
+ * @param {{x: number, y: number, w: number, h: number}} box - changed cells.
209
+ * @returns {{rows: string[], x: number, y: number}} the cut grid and where it starts.
210
+ */
211
+ export function around(rows, box) {
212
+ const x = Math.max(0, box.x - 1)
213
+ const y = Math.max(0, box.y - 1)
214
+ const to = Math.min(rows[0].length, box.x + box.w + 1)
215
+ const down = Math.min(rows.length, box.y + box.h + 1)
216
+ return { rows: rows.slice(y, down).map((row) => row.slice(x, to)), x, y }
217
+ }
package/rest.js ADDED
@@ -0,0 +1,124 @@
1
+ /* When to put the browser down.
2
+ *
3
+ * A fixed fifteen minutes is either too long (a chat that asks one question
4
+ * and walks away leaves a whole Chrome standing for a quarter of an hour) or
5
+ * too short (a long task that thinks for a minute between reads) — and the
6
+ * number that tells the two apart is already in the chat, in the gaps between
7
+ * one call and the next. So the browser learns its own rhythm: the 95th
8
+ * percentile of recent gaps says how long this chat pauses while it is still
9
+ * working, and the rest window is a multiple of that, floored and capped so a
10
+ * fast chat still gets a minute of grace and a slow one is not kept forever.
11
+ *
12
+ * Only the last few dozen gaps count. A stale rhythm from an hour ago is not
13
+ * evidence about now, and a sliding window forgets it without a decay constant
14
+ * to tune. The percentile is exact over that window: cheap at this size, and
15
+ * not an estimate that can drift.
16
+ */
17
+
18
+ /** Shortest rest window: a chat that works in two-second steps still gets grace. */
19
+ export const MIN_REST_MS = 90_000
20
+ /** Longest rest window: nothing learned may keep a browser up for longer than this. */
21
+ export const MAX_REST_MS = 15 * 60_000
22
+ /** Shortest quiet window: how soon an idle chat stops paying for a picture nobody sees. */
23
+ export const MIN_QUIET_MS = 20_000
24
+ /** Longest quiet window. */
25
+ export const MAX_QUIET_MS = 2 * 60_000
26
+ /** How many recent gaps make up the learned rhythm. */
27
+ const WINDOW = 64
28
+ /** Rest at this multiple of the learned p95: long enough to sit through a pause. */
29
+ const REST_FACTOR = 6
30
+ /** Quiet at this multiple: two slow steps in a row is a pause, not a break. */
31
+ const QUIET_FACTOR = 2
32
+
33
+ /**
34
+ * @param {number} value - what to keep in range.
35
+ * @param {number} low - smallest allowed.
36
+ * @param {number} high - largest allowed.
37
+ * @returns {number} `value` inside `[low, high]`.
38
+ */
39
+ function clamp(value, low, high) {
40
+ if (!Number.isFinite(value)) return low
41
+ return Math.min(high, Math.max(low, value))
42
+ }
43
+
44
+ /**
45
+ * The recent gaps between one use of this browser and the next, and what they
46
+ * say about how long a pause is still "working" rather than "gone".
47
+ */
48
+ export class GapClock {
49
+ /** @param {{window?: number}} [options] - how many gaps to keep. */
50
+ constructor(options = {}) {
51
+ /** @type {number} when the browser was last used, in ms. */
52
+ this.last = 0
53
+ /** @type {number[]} the recent gaps, oldest first. */
54
+ this.gaps = []
55
+ this.window = Math.max(4, Number(options.window ?? WINDOW) || WINDOW)
56
+ /** @type {number} how many gaps were seen at all, for reporting. */
57
+ this.seen = 0
58
+ }
59
+
60
+ /**
61
+ * Notes a use now and returns the gap it closed.
62
+ * @param {number} [now] - the moment of this use.
63
+ * @returns {number} the gap in ms, or 0 when there is no rhythm yet.
64
+ */
65
+ record(now = Date.now()) {
66
+ const at = Number(now)
67
+ const was = Number(this.last)
68
+ this.last = at
69
+ if (!Number.isFinite(was) || was <= 0 || !Number.isFinite(at) || at <= was) return 0
70
+ const gap = at - was
71
+ /* A gap longer than the longest rest window is a new sitting, not a step:
72
+ folding it in would teach the browser that hours are normal. */
73
+ if (gap > MAX_REST_MS) return 0
74
+ this.gaps.push(gap)
75
+ if (this.gaps.length > this.window) this.gaps.shift()
76
+ this.seen += 1
77
+ return gap
78
+ }
79
+
80
+ /**
81
+ * @param {number} q - a fraction in (0, 1].
82
+ * @returns {number} that percentile of the kept gaps, or 0 with no rhythm.
83
+ */
84
+ quantile(q = 0.95) {
85
+ const size = this.gaps.length
86
+ if (size === 0) return 0
87
+ const sorted = [...this.gaps].sort((a, b) => a - b)
88
+ const at = clamp(Math.ceil(q * size) - 1, 0, size - 1)
89
+ return sorted[at]
90
+ }
91
+
92
+ /** @returns {number} ms of silence after which the browser may be put down. */
93
+ restAfter() {
94
+ if (this.gaps.length === 0) return MIN_REST_MS
95
+ return clamp(this.quantile(0.95) * REST_FACTOR, MIN_REST_MS, MAX_REST_MS)
96
+ }
97
+
98
+ /** @returns {number} ms of silence after which an unwatched page may stop painting. */
99
+ quietAfter() {
100
+ if (this.gaps.length === 0) return MIN_QUIET_MS
101
+ return clamp(this.quantile(0.95) * QUIET_FACTOR, MIN_QUIET_MS, MAX_QUIET_MS)
102
+ }
103
+
104
+ /** @returns {number} the middle gap, for a report that has to be readable. */
105
+ typical() {
106
+ return this.quantile(0.5)
107
+ }
108
+ }
109
+
110
+ /**
111
+ * What the browser should do about time passing right now.
112
+ * @param {{now: number, lastUse: number, restAt: number, watched: number, quietAfter: number}} state - clocks and viewers.
113
+ * @returns {'awake'|'quiet'|'rest'} `rest` means nobody is watching and the window ran out.
114
+ */
115
+ export function decide(state) {
116
+ const now = Number(state.now ?? 0)
117
+ const watched = Number(state.watched ?? 0)
118
+ const lastUse = Number(state.lastUse ?? 0)
119
+ const restAt = Number(state.restAt ?? 0)
120
+ if (watched > 0) return lastUse > 0 && now - lastUse >= Number(state.quietAfter ?? MIN_QUIET_MS) ? 'quiet' : 'awake'
121
+ if (restAt > 0 && now >= restAt) return 'rest'
122
+ if (lastUse > 0 && now - lastUse >= Number(state.quietAfter ?? MIN_QUIET_MS)) return 'quiet'
123
+ return 'awake'
124
+ }
@@ -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,91 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * What putting the browser to rest costs, and what waking it back up buys.
4
+ *
5
+ * node scripts/check-rest.mjs
6
+ *
7
+ * Opens a page, lets the rest window run out with nobody watching, and checks
8
+ * that the browser really went away — no process, no memory, a reason on the
9
+ * record — and that the next tool call brings it back with the same page, in
10
+ * well under the time a cold launch takes. Exits non-zero when rest gets
11
+ * expensive, or when waking loses the page.
12
+ */
13
+ import { createServer } from 'node:http'
14
+ import { mkdtempSync } 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-rest-'))
19
+
20
+ const server = createServer((request, response) => {
21
+ response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
22
+ response.end('<!doctype html><meta charset="utf-8"><title>rest check</title><h1>rest check</h1><p>this page has to survive a rest.</p>')
23
+ })
24
+ await new Promise((done) => server.listen(0, '127.0.0.1', done))
25
+ const url = `http://127.0.0.1:${server.address().port}/page`
26
+
27
+ const { Sidecar } = await import('../sidecar.js')
28
+ const { sidecarTools, sessionScope, workScope } = await import('../tools.js')
29
+
30
+ const wait = (ms) => new Promise((settle) => setTimeout(settle, ms))
31
+ /* A short window on purpose: the point is the chain, not the length. */
32
+ const sidecar = new Sidecar({ profile: 'rest-check', headless: true, restFloor: 1200, restCeiling: 1600, budgetMb: 4000 })
33
+ const tools = sidecarTools(sidecar, { byChat: new Map() }, {})
34
+ const tool = (name) => tools.find((one) => one.name === name)
35
+ const cwd = mkdtempSync(join(tmpdir(), 'dsh-rest-cwd-'))
36
+ /* Chats are `session-…` ids: the tab memory on disk only keeps those, and a
37
+ throwaway id would lose the page across a rest instead of testing that it
38
+ is kept. */
39
+ const chat = 'session-rest-check'
40
+ const run = (name, args) => sessionScope.run(chat, () => workScope.run(cwd, () => tool(name).execute(args, { agent: { id: chat } })))
41
+ let failed = 0
42
+ const check = (ok, what) => {
43
+ console.log(`${ok ? 'ok ' : 'FAIL'} ${what}`)
44
+ if (!ok) failed += 1
45
+ }
46
+ const text = (said) => String(said?.text ?? '')
47
+ let sidecarDone = false
48
+ try {
49
+ await run('browser_open', { url })
50
+ const before = text(await run('browser_view', {}))
51
+ check(before.includes('this page has to survive a rest'), 'the page opened')
52
+ const upText = text(await run('browser_state', {}))
53
+ check(upText.includes('rests in') || upText.includes('no rest scheduled'), `the state names the rest (${upText.split('\n').filter((row) => row.includes('rest')).join(' / ')})`)
54
+ const up = await sidecar.state()
55
+ check(up.alive === true && up.memory > 0, `the browser is up and costs ${String(up.memory)} bytes`)
56
+
57
+ /* Nobody is watching: this is exactly the state the pane closes into. */
58
+ sidecar.touch(1200)
59
+ const restAt = Date.now() + 1200
60
+ for (let i = 0; i < 60; i += 1) {
61
+ await wait(250)
62
+ const said = await sidecar.state()
63
+ if (said.alive !== true) break
64
+ if (Date.now() > restAt + 15_000) break
65
+ }
66
+ const asleepText = text(await run('browser_state', {}))
67
+ check(asleepText.includes('stopped'), 'the state says the browser is stopped')
68
+ const asleep = await sidecar.state()
69
+ check(asleep.alive !== true, 'the browser went away when its rest ran out')
70
+ check(asleep.memory === 0, 'no memory is held while it rests')
71
+ check(asleep.rests >= 1, `the rest was counted (${String(asleep.rests)})`)
72
+ check(String(asleep.error).includes('put away') || String(asleep.restNote).length > 0, `it said why (${String(asleep.error || asleep.restNote)})`)
73
+
74
+ const asked = Date.now()
75
+ const after = text(await run('browser_view', {}))
76
+ const woke = Date.now() - asked
77
+ const back = await sidecar.state()
78
+ check(back.alive === true, 'the next call brought it back')
79
+ /* The address is the proof: a blank mirror would say `about:blank`, and the
80
+ read is deduplicated against the picture from before the rest. */
81
+ check(sidecar.pageFor(chat)?.url === url, `it came back to the same address (${String(sidecar.pageFor(chat)?.url)})`)
82
+ check(after.includes('this page has to survive a rest') || after.includes('nothing changed since the last read'), 'the page still says what it said before the rest')
83
+ check(woke < 4000, `the wake took ${String(woke)} ms`)
84
+ check(back.wakeMs > 0 && back.wakeMs <= woke + 50, `the sidecar timed its own wake (${String(back.wakeMs)} ms)`)
85
+ check(back.rests >= 1, 'the count survives the wake')
86
+ } finally {
87
+ server.close()
88
+ if (!sidecarDone) await sidecar.shutdown().catch(() => {})
89
+ }
90
+ console.log(failed === 0 ? '\nrest and wake hold.' : `\n${String(failed)} rest checks failed.`)
91
+ 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