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/README.md +13 -2
- package/README.zh.md +12 -2
- package/docs/ARCHITECTURE.md +51 -4
- package/docs/ARTIFACTS.md +19 -14
- package/docs/BENCH.md +36 -0
- package/docs/FLASH.md +10 -0
- package/docs/RESOURCES.md +83 -0
- package/docs/VERIFY.md +61 -0
- package/index.js +1 -1
- package/package.json +13 -3
- package/pixels.js +217 -0
- package/rest.js +124 -0
- package/scripts/check-flash-tools.mjs +6 -2
- package/scripts/check-look.mjs +86 -0
- package/scripts/check-rest.mjs +91 -0
- package/scripts/check-sites.mjs +94 -0
- package/scripts/soak.mjs +130 -0
- package/sidecar.js +348 -20
- package/tools.js +316 -25
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
|