@myrtilleio/redline 0.1.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/src/shots.js ADDED
@@ -0,0 +1,311 @@
1
+ // Screenshots of feedback items, taken with a headless Chrome driven over the
2
+ // DevTools protocol (Node's built-in WebSocket; no dependencies). The page loads
3
+ // through Redline itself, so the overlay's "shot mode" can restore the item's
4
+ // page state, draw its mark or apply its proposed edit/variant, and report the
5
+ // area to crop.
6
+ import { spawn } from 'node:child_process';
7
+ import { existsSync } from 'node:fs';
8
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { MOBILE, isGuarded, wantsBaseline, wantsMobileBaseline } from './guards.js';
12
+
13
+ const CANDIDATES = [
14
+ process.env.CHROME_PATH,
15
+ '/usr/bin/chromium',
16
+ '/usr/bin/chromium-browser',
17
+ '/usr/bin/google-chrome',
18
+ '/usr/bin/google-chrome-stable',
19
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
20
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
21
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
22
+ ];
23
+
24
+ export function findChrome() {
25
+ return CANDIDATES.find((p) => p && existsSync(p)) || null;
26
+ }
27
+
28
+ class Browser {
29
+ static async launch(executable) {
30
+ const profile = await mkdtemp(join(tmpdir(), 'redline-chrome-'));
31
+ const proc = spawn(
32
+ executable,
33
+ ['--headless=new', '--remote-debugging-port=0', `--user-data-dir=${profile}`, '--no-first-run', '--no-default-browser-check',
34
+ '--hide-scrollbars', '--mute-audio', '--disable-gpu',
35
+ // Pages keep their own pace (clocks, carousels) instead of a throttled background tab's.
36
+ '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows',
37
+ // Chrome refuses to start as root with its sandbox on (containers, CI).
38
+ ...(process.getuid?.() === 0 ? ['--no-sandbox'] : []), 'about:blank'],
39
+ { stdio: ['ignore', 'ignore', 'pipe'] },
40
+ );
41
+ const wsUrl = await new Promise((ok, fail) => {
42
+ let buf = '';
43
+ const timer = setTimeout(() => fail(new Error('Chrome did not start')), 15000);
44
+ proc.stderr.on('data', (d) => {
45
+ buf += d;
46
+ const m = /DevTools listening on (ws:\/\/\S+)/.exec(buf);
47
+ if (m) {
48
+ clearTimeout(timer);
49
+ ok(m[1]);
50
+ }
51
+ });
52
+ proc.on('exit', () => fail(new Error('Chrome exited during startup')));
53
+ });
54
+ const ws = new WebSocket(wsUrl);
55
+ await new Promise((ok, fail) => {
56
+ ws.onopen = ok;
57
+ ws.onerror = () => fail(new Error('could not connect to Chrome'));
58
+ });
59
+ return new Browser(proc, ws, profile);
60
+ }
61
+
62
+ constructor(proc, ws, profile) {
63
+ this.proc = proc;
64
+ this.ws = ws;
65
+ this.profile = profile;
66
+ this.nextId = 1;
67
+ this.pending = new Map();
68
+ this.listeners = new Set();
69
+ ws.onmessage = (e) => {
70
+ const msg = JSON.parse(e.data);
71
+ if (msg.id && this.pending.has(msg.id)) {
72
+ const { ok, fail } = this.pending.get(msg.id);
73
+ this.pending.delete(msg.id);
74
+ msg.error ? fail(new Error(msg.error.message)) : ok(msg.result);
75
+ } else for (const l of this.listeners) l(msg);
76
+ };
77
+ // If Chrome goes away, nothing waits for it forever.
78
+ ws.onclose = () => {
79
+ this.closed = true;
80
+ for (const { fail } of this.pending.values()) fail(new Error('Chrome went away'));
81
+ this.pending.clear();
82
+ };
83
+ }
84
+
85
+ send(method, params = {}, sessionId, timeout = 30000) {
86
+ if (this.closed) return Promise.reject(new Error('Chrome went away'));
87
+ const id = this.nextId++;
88
+ this.ws.send(JSON.stringify({ id, method, params, sessionId }));
89
+ return new Promise((ok, fail) => {
90
+ const timer = setTimeout(() => {
91
+ this.pending.delete(id);
92
+ fail(new Error(`Chrome did not answer ${method}`));
93
+ }, timeout);
94
+ this.pending.set(id, { ok: (v) => (clearTimeout(timer), ok(v)), fail: (e) => (clearTimeout(timer), fail(e)) });
95
+ });
96
+ }
97
+
98
+ waitFor(method, sessionId, timeout) {
99
+ return new Promise((ok) => {
100
+ const timer = setTimeout(() => done(null), timeout);
101
+ const listener = (msg) => msg.method === method && msg.sessionId === sessionId && done(msg.params);
102
+ const done = (v) => {
103
+ clearTimeout(timer);
104
+ this.listeners.delete(listener);
105
+ ok(v);
106
+ };
107
+ this.listeners.add(listener);
108
+ });
109
+ }
110
+
111
+ async close() {
112
+ try {
113
+ this.ws.close();
114
+ } catch {}
115
+ this.proc.kill();
116
+ await new Promise((ok) => (this.proc.exitCode !== null ? ok() : this.proc.once('exit', ok)));
117
+ await rm(this.profile, { recursive: true, force: true }).catch(() => {});
118
+ }
119
+ }
120
+
121
+ const sigOf = (v) => JSON.stringify(v);
122
+
123
+ /**
124
+ * The screenshots an item should have, each with a signature of what it depicts:
125
+ * the page as the reviewer saw it, their proposed edit, each agent option, the
126
+ * result once resolved, and for approvals, the guard's baseline and its check
127
+ * after each round (`ctx.checkAt`: when the last round came back).
128
+ */
129
+ export function wantedShots(a, ctx = {}) {
130
+ const want = {};
131
+ if (a.status !== 'resolved') want.before = sigOf([a.target && a.target.selector, a.stroke && a.stroke.paths && a.stroke.paths.length]);
132
+ // The same moment and framing without the mark: what Review lays over the page as "before".
133
+ if (a.status !== 'resolved') want.plain = want.before;
134
+ if (a.edits && a.status !== 'resolved') want.proposed = sigOf(a.edits);
135
+ for (const v of a.variants || []) want['variant-' + v.key] = sigOf(v);
136
+ // Once per resolution (a reopened item's next fix is a new one), not per edit of the item.
137
+ if (a.status === 'resolved') want.after = sigOf([a.resolution, (a.history || []).length, a.afterTarget]);
138
+ if (wantsBaseline(a)) want['guard-base'] = sigOf(['base', a.accepted ? 'accepted' : 'kept']);
139
+ if (wantsMobileBaseline(a)) want[`guard-base@${MOBILE}`] = sigOf(['base', MOBILE, a.accepted ? 'accepted' : 'kept']);
140
+ // Checked once per round, at each width, against a baseline taken before that round came back.
141
+ if (isGuarded(a) && ctx.checkAt) {
142
+ const base = a.guard && a.guard.base;
143
+ const mobile = a.guard && a.guard.mobile && a.guard.mobile.base;
144
+ if (base && base.at < ctx.checkAt) want['guard-now'] = sigOf(['check', ctx.checkAt]);
145
+ if (mobile && mobile.at < ctx.checkAt) want[`guard-now@${MOBILE}`] = sigOf(['check', MOBILE, ctx.checkAt]);
146
+ }
147
+ return want;
148
+ }
149
+
150
+ /** What the guards compare against: when the agent last closed a round. */
151
+ export function checkContext(rounds) {
152
+ const last = [...((rounds && rounds.rounds) || [])].reverse().find((r) => r.returnedAt);
153
+ return { checkAt: last ? last.returnedAt : null };
154
+ }
155
+
156
+ export class ShotService {
157
+ /**
158
+ * @param {object} o
159
+ * @param {import('./store.js').Store} o.store
160
+ * @param {() => string} o.baseUrl origin of the Redline server, e.g. http://127.0.0.1:4545
161
+ * @param {(key: string, a: object) => void} [o.onShot] called after each saved shot
162
+ */
163
+ constructor({ store, baseUrl, onShot = () => {}, log = () => {}, chrome = findChrome() }) {
164
+ this.store = store;
165
+ this.baseUrl = baseUrl;
166
+ this.onShot = onShot;
167
+ this.log = log;
168
+ this.chrome = typeof WebSocket === 'function' ? chrome : null;
169
+ this.queue = [];
170
+ this.running = false;
171
+ this.browser = null;
172
+ this.idle = null;
173
+ }
174
+
175
+ get available() {
176
+ return !!this.chrome;
177
+ }
178
+
179
+ /** Queue a shot; `key` is before | proposed | after | variant-A… Duplicates collapse. */
180
+ schedule(id, key, sig) {
181
+ if (!this.available) return;
182
+ this.queue = this.queue.filter((j) => !(j.id === id && j.key === key));
183
+ this.queue.push({ id, key, sig });
184
+ this.run();
185
+ }
186
+
187
+ /** Queue whatever shots the items are missing, or that no longer match them. */
188
+ reconcile(list, ctx) {
189
+ for (const a of list) {
190
+ for (const [key, sig] of Object.entries(wantedShots(a, ctx))) {
191
+ const same = (j) => j && j.id === a.id && j.key === key && j.sig === sig;
192
+ if ((a.shotSigs || {})[key] !== sig && !this.queue.some(same) && !same(this.current)) {
193
+ this.schedule(a.id, key, sig);
194
+ }
195
+ }
196
+ }
197
+ }
198
+
199
+ async run() {
200
+ if (this.running) return;
201
+ this.running = true;
202
+ clearTimeout(this.idle);
203
+ try {
204
+ while (this.queue.length) {
205
+ const job = (this.current = this.queue.shift());
206
+ try {
207
+ await this.capture(job.id, job.key, job.sig);
208
+ } catch (err) {
209
+ this.log(`screenshot ${job.id} ${job.key} failed: ${err.message}`);
210
+ }
211
+ }
212
+ this.current = null;
213
+ } finally {
214
+ this.running = false;
215
+ // Keep Chrome warm between bursts of feedback, then let it go.
216
+ this.armIdle();
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Photograph item `id` for `key` (before | after | … | guard-base | guard-now, with
222
+ * "@390" for a phone width) and record it. `dry`: record nothing, write no file,
223
+ * just return what the page reported ({ fp, box, … }), or null if the element is gone.
224
+ */
225
+ async capture(id, key, sig = '', { dry = false } = {}) {
226
+ const a = await this.store.get(id);
227
+ if (!a) return null;
228
+ // Queued a while ago: skip it if the item moved on (a "before" of an item that is fixed by now).
229
+ if (!key.startsWith('guard-') && sig && wantedShots(a)[key] !== sig) return null;
230
+ const [kind, at] = key.split('@');
231
+ const width = at ? Number(at) : 0;
232
+ clearTimeout(this.idle); // not while a capture is on
233
+ // One Chrome, however many captures start at once.
234
+ if (!this.browser) {
235
+ this.launching ??= Browser.launch(this.chrome).then((browser) => (this.browser = browser)).finally(() => (this.launching = null));
236
+ await this.launching;
237
+ }
238
+ const b = this.browser;
239
+ const { targetId } = await b.send('Target.createTarget', { url: 'about:blank' });
240
+ const { sessionId } = await b.send('Target.attachToTarget', { targetId, flatten: true });
241
+ this.inflight = (this.inflight || 0) + 1;
242
+ try {
243
+ const w = width || Math.round(a.viewport?.w || 1280), h = width ? 844 : Math.round(a.viewport?.h || 800);
244
+ await b.send('Emulation.setDeviceMetricsOverride', { width: w, height: h, deviceScaleFactor: 1, mobile: w < 768 }, sessionId);
245
+ await b.send('Page.enable', {}, sessionId);
246
+ await b.send('Page.addScriptToEvaluateOnNewDocument', { source: `window.__REDLINE_SHOT__ = ${JSON.stringify({ id, key })};` }, sessionId);
247
+ const loaded = b.waitFor('Page.loadEventFired', sessionId, 20000);
248
+ await b.send('Page.navigate', { url: this.baseUrl() + a.page + (a.url || '') }, sessionId);
249
+ await loaded;
250
+ const { result } = await b.send(
251
+ 'Runtime.evaluate',
252
+ {
253
+ expression: `new Promise((done) => {
254
+ const give = setTimeout(() => done(null), 15000);
255
+ const wait = () => window.__redlineShotReady ? window.__redlineShotReady.then((r) => { clearTimeout(give); done(r); }) : setTimeout(wait, 50);
256
+ wait();
257
+ })`,
258
+ awaitPromise: true,
259
+ returnByValue: true,
260
+ },
261
+ sessionId,
262
+ );
263
+ const found = result && result.value;
264
+ if (dry) return found || null;
265
+ if (!found) {
266
+ // An approval that can't be found anymore is itself a finding; one that isn't
267
+ // shown at phone width simply isn't guarded there.
268
+ if (kind === 'guard-now') this.onShot(key, await this.store.setGuardCheck(id, null, null, sig, width));
269
+ else if (kind === 'guard-base' && width) return this.store.setGuardBase(id, null, null, sig, null, width);
270
+ throw new Error('page did not get ready (is the element still there?)');
271
+ }
272
+ const { fp, box, ...clip } = found;
273
+ const shot = await b.send(
274
+ 'Page.captureScreenshot',
275
+ { format: 'png', clip: { ...clip, scale: 1 }, captureBeyondViewport: true },
276
+ sessionId,
277
+ );
278
+ await mkdir(join(this.store.dir, 'shots'), { recursive: true });
279
+ const file = `shots/${id}-${key.replace('@', '-at')}.png`;
280
+ await writeFile(join(this.store.dir, file), Buffer.from(shot.data, 'base64'));
281
+ const updated = kind === 'guard-base' ? await this.store.setGuardBase(id, file, fp, sig, box, width)
282
+ : kind === 'guard-now' ? await this.store.setGuardCheck(id, file, fp, sig, width)
283
+ : await this.store.setShot(id, key, file, sig, box);
284
+ this.onShot(key, updated);
285
+ return file;
286
+ } finally {
287
+ await b.send('Target.closeTarget', { targetId }).catch(() => {});
288
+ this.inflight--;
289
+ if (!this.running) this.armIdle();
290
+ }
291
+ }
292
+
293
+ // One idle timer at a time: a stray one would close Chrome under a capture.
294
+ armIdle() {
295
+ clearTimeout(this.idle);
296
+ this.idle = setTimeout(() => !this.running && !this.inflight && this.stopBrowser(), 60000);
297
+ this.idle.unref?.();
298
+ }
299
+
300
+ async stopBrowser() {
301
+ const b = this.browser;
302
+ this.browser = null;
303
+ if (b) await b.close();
304
+ }
305
+
306
+ async close() {
307
+ clearTimeout(this.idle);
308
+ this.queue = [];
309
+ await this.stopBrowser();
310
+ }
311
+ }