amalgm 0.1.93 → 0.1.94

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.
@@ -111,9 +111,30 @@ async function press(args = {}, context) {
111
111
  return sessionInfo(session, { pressed: args.key });
112
112
  }
113
113
 
114
+ /**
115
+ * Native <select> needs a real selection action — synthetic clicks and arrow
116
+ * keys do not move it (verified in stress testing). Value matching follows
117
+ * agent-browser: option value, then visible label.
118
+ */
119
+ async function select(args = {}, context) {
120
+ const session = sessionFor(args, context);
121
+ await run(session, ['select', String(args.target || ''), String(args.value ?? '')], { context });
122
+ return sessionInfo(session, { selected: args.target, value: args.value });
123
+ }
124
+
114
125
  async function evaluate(args = {}, context) {
115
126
  const session = sessionFor(args, context);
116
- const result = await run(session, ['eval', args.script || ''], { context });
127
+ const script = args.script || '';
128
+ let result;
129
+ try {
130
+ result = await run(session, ['eval', script], { context });
131
+ } catch (err) {
132
+ // The script is evaluated as an expression; a top-level `return` is the
133
+ // one natural idiom that breaks. Re-run it as a function body instead of
134
+ // making every agent learn the distinction.
135
+ if (!/illegal return/i.test(String(err?.message || ''))) throw err;
136
+ result = await run(session, ['eval', `(() => { ${script} })()`], { context });
137
+ }
117
138
  const data = result?.data ?? result;
118
139
  // agent-browser wraps eval results as { origin, result }.
119
140
  const value = data && typeof data === 'object' && 'result' in data ? data.result : data;
@@ -153,6 +174,14 @@ async function passthrough(args = {}, context) {
153
174
  const session = sessionFor(args, context);
154
175
  const commandArgs = (args.args || []).map(String);
155
176
  if (!commandArgs.length) throw new Error('args is required, e.g. ["scroll", "down"] or ["get", "text", "@e1"]');
177
+ if (backend.mode() === 'attached' && commandArgs[0] === 'tab' && commandArgs.length > 1) {
178
+ // In the desktop app a session IS its surface; hopping targets is how a
179
+ // session ends up driving another session's page. Listing stays allowed.
180
+ throw new Error(
181
+ 'Tab switching is managed per session in the desktop app — each session drives its own surface. '
182
+ + 'Use a different `session` id for a second page.',
183
+ );
184
+ }
156
185
  const result = await run(session, commandArgs, { timeoutMs: args.timeoutMs || 60_000, context });
157
186
  return { ...sessionInfo(session), result: result?.data ?? result };
158
187
  }
@@ -278,14 +307,19 @@ async function close(args = {}, context) {
278
307
  }
279
308
  // Tear down this session's daemon. In attached mode this only disconnects
280
309
  // from the app's chromium (verified live: the app, its webviews, and other
281
- // sessions' daemons survive) the visible surface belongs to the UI and
282
- // stays open for the user. Without this, every attached session leaks a
310
+ // sessions' daemons survive). Without this, every attached session leaks a
283
311
  // daemon process for the life of the machine.
284
312
  try {
285
313
  await cli.runCli(['close'], attach.cliOptions(session, { timeoutMs: 15_000 }));
286
314
  } catch {
287
315
  // No daemon (never started, or already gone) — nothing to tear down.
288
316
  }
317
+ // Tell the UI to unmount the splitview/tab. Each surface is a full
318
+ // renderer process; ended sessions must not accumulate them (ten zombie
319
+ // webviews were live in the app when this was written).
320
+ if (sessions.attachedSessions.has(session) || sessions.knownSessions.has(session)) {
321
+ attach.requestSurfaceClose(session);
322
+ }
289
323
  sessions.attachedSessions.delete(session);
290
324
  sessions.knownSessions.delete(session);
291
325
  }
@@ -312,6 +346,7 @@ module.exports = {
312
346
  press,
313
347
  saveState,
314
348
  screenshot,
349
+ select,
315
350
  snapshot,
316
351
  state,
317
352
  wait,
@@ -3,12 +3,11 @@
3
3
  /**
4
4
  * Session recorder: capture any browser session to a local WebM file.
5
5
  *
6
- * Frames come from capture.js (CDP Page.startScreencast on a target that
7
- * actually rasters — the page itself headless, the app window cropped to the
8
- * webview rectangle when attached) and pipe into ffmpeg. Recordings are
9
- * local-only: nothing leaves this computer, and in attached mode the crop
10
- * runs inside ffmpeg before any frame reaches disk, so the file only ever
11
- * contains the page — not the app around it.
6
+ * Frames come from capture.js (CDP Page.startScreencast on the session's own
7
+ * page target — the headless page, or the attached <webview> guest itself)
8
+ * and pipe into ffmpeg. The frames are the page and nothing else, so no
9
+ * cropping is ever needed; recordings are local-only and nothing leaves this
10
+ * computer.
12
11
  *
13
12
  * This is deliberately NOT agent-browser's `record` command:
14
13
  * - `record` needs ffmpeg on the daemon's PATH at daemon-spawn time, and
@@ -183,7 +182,8 @@ async function start(args = {}, context = {}) {
183
182
 
184
183
  const file = recordingFile(args, context, session);
185
184
  const fps = clampFps(args.fps);
186
- const filters = [source.crop, 'pad=ceil(iw/2)*2:ceil(ih/2)*2'].filter(Boolean).join(',');
185
+ // VP8 wants even dimensions; screencast frames can be odd-sized.
186
+ const filters = 'pad=ceil(iw/2)*2:ceil(ih/2)*2';
187
187
 
188
188
  const ffmpeg = spawn(binary, ffmpegArgs({ fps, filters, file }), { stdio: ['pipe', 'ignore', 'pipe'] });
189
189
  let ffmpegErr = '';
@@ -251,7 +251,7 @@ async function start(args = {}, context = {}) {
251
251
  fps,
252
252
  mimeType: 'video/webm',
253
253
  backend: backend.mode(),
254
- cropped: Boolean(source.crop),
254
+ source: source.guest ? 'visible surface' : 'page',
255
255
  };
256
256
  }
257
257
 
@@ -283,21 +283,26 @@ async function stop(args = {}, context = {}) {
283
283
  }
284
284
  });
285
285
 
286
- const durationMs = Date.now() - recording.startedAt;
286
+ const wallMs = Date.now() - recording.startedAt;
287
287
  const { received, encoded, skippedTicks } = recording.sampler.stats;
288
288
  const bytes = fs.existsSync(recording.file) ? fs.statSync(recording.file).size : 0;
289
289
 
290
290
  if (encoded === 0 || bytes === 0) {
291
291
  try { fs.rmSync(recording.file, { force: true }); } catch {}
292
292
  throw new Error(encoded === 0
293
- ? 'No frames captured — the surface never produced a pixel while recording '
294
- + '(it likely went off screen; in the desktop app the Amalgm window must stay on screen).'
293
+ ? 'No frames captured — the surface never produced a pixel while recording. '
294
+ + 'In the desktop app its tab likely stayed hidden; retry (the app refocuses the surface automatically).'
295
295
  : `ffmpeg failed (exit ${exitCode}): ${recording.ffmpegErr()}`);
296
296
  }
297
297
 
298
298
  // WebM is cluster-streamed: even a force-killed encode leaves the frames
299
299
  // written so far playable. Captured video beats a deleted file — return it
300
300
  // and say what happened instead of throwing it away.
301
+ //
302
+ // Two durations, named for what they measure: the file plays for
303
+ // videoSeconds (frames ÷ fps); the recording spanned wallSeconds of real
304
+ // time. They differ when frames starved — conflating them under one
305
+ // "duration" is how a 25-second number got reported for an 11-second file.
301
306
  const result = {
302
307
  session,
303
308
  path: recording.file,
@@ -307,7 +312,8 @@ async function stop(args = {}, context = {}) {
307
312
  framesReceived: received,
308
313
  skippedTicks,
309
314
  fps: recording.fps,
310
- durationMs,
315
+ videoSeconds: Math.round((encoded / recording.fps) * 10) / 10,
316
+ wallSeconds: Math.round(wallMs / 100) / 10,
311
317
  mimeType: 'video/webm',
312
318
  };
313
319
  if (exitCode !== 0) {
@@ -332,7 +338,15 @@ function list(args = {}, context = {}) {
332
338
  })
333
339
  .sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
334
340
  } catch {}
335
- return { dir, recordings: entries, activeSession: active.has(session) ? session : null };
341
+ // activeSessions is global on purpose: a recording from another session
342
+ // writing to disk while this one reads "no active recording" looks like a
343
+ // zombie encoder unless the listing says who owns it.
344
+ return {
345
+ dir,
346
+ recordings: entries,
347
+ activeSession: active.has(session) ? session : null,
348
+ activeSessions: [...active.keys()],
349
+ };
336
350
  }
337
351
 
338
352
  function isActive(session) {
@@ -3,8 +3,9 @@
3
3
  /**
4
4
  * Browser core actions — the thin surface.
5
5
  *
6
- * Ten verbs built on the agent-browser CLI (vercel-labs/agent-browser). The
7
- * loop is: open → snapshot (@refs) → click/fill (@ref) → re-snapshot.
6
+ * Eleven verbs built on the agent-browser CLI (vercel-labs/agent-browser).
7
+ * The loop is: open → snapshot (@refs) → click/fill/select (@ref) →
8
+ * re-snapshot.
8
9
  * Everything else agent-browser can do (scroll, hover, cookies, network
9
10
  * mocking, traces, diffs, React inspection, ...) is reachable through `cli`
10
11
  * without widening this schema.
@@ -76,7 +77,7 @@ module.exports = [
76
77
  },
77
78
  {
78
79
  name: 'screenshot',
79
- description: 'Screenshot the page (PNG). Prefer snapshot for driving the page; use this to verify visuals. fullPage captures the whole scroll height when headless; in the desktop app you always get the visible surface.',
80
+ description: 'Screenshot the page (PNG). Prefer snapshot for driving the page; use this to verify visuals. Image coordinates match cua_* input 1:1. fullPage captures the whole scroll height when headless; in the desktop app you get the visible surface (works even while the app window is hidden).',
80
81
  inputSchema: {
81
82
  type: 'object',
82
83
  properties: {
@@ -141,7 +142,7 @@ module.exports = [
141
142
  },
142
143
  {
143
144
  name: 'press',
144
- description: 'Press a key or combination at the focused element, e.g. "Enter", "Escape", "Tab", "Control+a".',
145
+ description: 'Press a key or combination at the focused element, e.g. "Enter", "Escape", "Tab", "Control+a". For native <select> dropdowns use the select action — key presses do not move them.',
145
146
  inputSchema: {
146
147
  type: 'object',
147
148
  properties: { key: { type: 'string', description: 'Key or combination' }, ...sessionProperty },
@@ -157,9 +158,31 @@ module.exports = [
157
158
  }
158
159
  },
159
160
  },
161
+ {
162
+ name: 'select',
163
+ description: 'Choose an option in a <select> dropdown by value or visible label. Clicks and key presses cannot drive native dropdowns — this can.',
164
+ inputSchema: {
165
+ type: 'object',
166
+ properties: {
167
+ ...targetProperty,
168
+ value: { type: 'string', description: 'Option value or visible label to select' },
169
+ ...sessionProperty,
170
+ },
171
+ required: ['target', 'value'],
172
+ },
173
+ async handler(args, ctx) {
174
+ if (!args.target || typeof args.value !== 'string') return errorResult('target and value are required');
175
+ try {
176
+ const result = await engine.select(args, ctx);
177
+ return textResult(`Selected "${args.value}" in ${args.target}\nSession: ${result.session}`);
178
+ } catch (err) {
179
+ return errorResult(`Select failed: ${err.message}`);
180
+ }
181
+ },
182
+ },
160
183
  {
161
184
  name: 'eval',
162
- description: 'Run JavaScript in the page and return the result — the escape hatch for reading or mutating anything the page knows.',
185
+ description: 'Run JavaScript in the page and return the result — the escape hatch for reading or mutating anything the page knows. Both bare expressions and function bodies with `return` work.',
163
186
  inputSchema: {
164
187
  type: 'object',
165
188
  properties: { script: { type: 'string', description: 'JavaScript to evaluate' }, ...sessionProperty },
@@ -200,7 +223,7 @@ module.exports = [
200
223
  },
201
224
  {
202
225
  name: 'cli',
203
- description: 'Run any agent-browser CLI command against this session — the full surface beyond the core verbs: ["scroll","down"], ["hover","@e3"], ["back"], ["tab"], ["get","html","@e1"], ["cookies","get"], ["network","requests"], ["find","role","button","click","--name","Submit"], and more. Run ["--help"] for the command list.',
226
+ description: 'Run any agent-browser CLI command against this session — the full surface beyond the core verbs: ["scroll","down"], ["hover","@e3"], ["back"], ["get","html","@e1"], ["cookies","get"], ["network","requests"], ["find","role","button","click","--name","Submit"], and more. Run ["--help"] for the command list. Commands run against this session\'s own page; in the desktop app, tab-switching to other surfaces is refused.',
204
227
  inputSchema: {
205
228
  type: 'object',
206
229
  properties: {
@@ -17,6 +17,31 @@ const TEXT_EXTENSIONS = new Set([
17
17
  'c', 'cpp', 'cs', 'php', 'scss', 'log', 'ini', 'cfg', 'conf',
18
18
  ]);
19
19
 
20
+ const MIME_TYPES = {
21
+ avif: 'image/avif',
22
+ bmp: 'image/bmp',
23
+ csv: 'text/csv; charset=utf-8',
24
+ doc: 'application/msword',
25
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
26
+ gif: 'image/gif',
27
+ html: 'text/html; charset=utf-8',
28
+ ico: 'image/x-icon',
29
+ jpeg: 'image/jpeg',
30
+ jpg: 'image/jpeg',
31
+ json: 'application/json; charset=utf-8',
32
+ md: 'text/markdown; charset=utf-8',
33
+ mov: 'video/quicktime',
34
+ mp4: 'video/mp4',
35
+ pdf: 'application/pdf',
36
+ png: 'image/png',
37
+ svg: 'image/svg+xml; charset=utf-8',
38
+ txt: 'text/plain; charset=utf-8',
39
+ webm: 'video/webm',
40
+ webp: 'image/webp',
41
+ xls: 'application/vnd.ms-excel',
42
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
43
+ };
44
+
20
45
  const IS_LOCAL_MACHINE = process.env.AMALGM_LOCAL_MODE === 'true';
21
46
  const CONTAINER_ROOT = process.env.AMALGM_WORKSPACE_ROOT || '/workspace';
22
47
  const MAX_READ_BYTES = Number.parseInt(process.env.AMALGM_FS_MAX_READ_BYTES || '', 10) || 25 * 1024 * 1024;
@@ -282,6 +307,50 @@ function classifyFile(targetPath) {
282
307
  };
283
308
  }
284
309
 
310
+ function mimeTypeForPath(targetPath) {
311
+ const { ext, isText } = classifyFile(targetPath);
312
+ if (MIME_TYPES[ext]) return MIME_TYPES[ext];
313
+ return isText ? 'text/plain; charset=utf-8' : 'application/octet-stream';
314
+ }
315
+
316
+ function inlineFilename(targetPath) {
317
+ return path.basename(targetPath).replace(/["\\\r\n]/g, '_') || 'file';
318
+ }
319
+
320
+ function sendContentError(res, statusCode, message) {
321
+ if (!res.headersSent) {
322
+ res.writeHead(statusCode, { 'Content-Type': 'application/json' });
323
+ }
324
+ res.end(JSON.stringify({ error: message }));
325
+ }
326
+
327
+ function parseByteRange(rangeHeader, size) {
328
+ if (!rangeHeader) return null;
329
+ const header = String(rangeHeader).trim();
330
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header);
331
+ if (!match || size <= 0) return { invalid: true };
332
+
333
+ const [, startRaw, endRaw] = match;
334
+ if (!startRaw && !endRaw) return { invalid: true };
335
+
336
+ let start;
337
+ let end;
338
+
339
+ if (!startRaw) {
340
+ const suffixLength = Number.parseInt(endRaw, 10);
341
+ if (!Number.isFinite(suffixLength) || suffixLength <= 0) return { invalid: true };
342
+ start = Math.max(size - suffixLength, 0);
343
+ end = size - 1;
344
+ } else {
345
+ start = Number.parseInt(startRaw, 10);
346
+ end = endRaw ? Number.parseInt(endRaw, 10) : size - 1;
347
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return { invalid: true };
348
+ }
349
+
350
+ if (start < 0 || start >= size || end < start) return { invalid: true };
351
+ return { start, end: Math.min(end, size - 1) };
352
+ }
353
+
285
354
  function directorySnapshot(targetPath) {
286
355
  const entries = fs.readdirSync(targetPath, { withFileTypes: true });
287
356
  const files = entries
@@ -524,6 +593,72 @@ async function handleRead(query, sendJson) {
524
593
  }
525
594
  }
526
595
 
596
+ async function handleContent(query, req, res) {
597
+ try {
598
+ const targetPath = await resolveSafePath(query.path);
599
+ const stats = await fs.promises.stat(targetPath);
600
+ if (stats.isDirectory()) {
601
+ return sendContentError(res, 400, 'Cannot read a directory');
602
+ }
603
+
604
+ const totalSize = stats.size;
605
+ const baseHeaders = {
606
+ 'Accept-Ranges': 'bytes',
607
+ 'Cache-Control': 'no-store',
608
+ 'Content-Disposition': `inline; filename="${inlineFilename(targetPath)}"`,
609
+ 'Content-Type': mimeTypeForPath(targetPath),
610
+ };
611
+ const range = parseByteRange(req.headers.range, totalSize);
612
+
613
+ if (range?.invalid) {
614
+ res.writeHead(416, {
615
+ ...baseHeaders,
616
+ 'Content-Range': `bytes */${totalSize}`,
617
+ });
618
+ res.end();
619
+ return;
620
+ }
621
+
622
+ if (totalSize === 0) {
623
+ res.writeHead(200, {
624
+ ...baseHeaders,
625
+ 'Content-Length': '0',
626
+ });
627
+ res.end();
628
+ return;
629
+ }
630
+
631
+ const start = range ? range.start : 0;
632
+ const end = range ? range.end : totalSize - 1;
633
+ const contentLength = end - start + 1;
634
+ const statusCode = range ? 206 : 200;
635
+ const headers = {
636
+ ...baseHeaders,
637
+ 'Content-Length': String(contentLength),
638
+ ...(range ? { 'Content-Range': `bytes ${start}-${end}/${totalSize}` } : {}),
639
+ };
640
+
641
+ res.writeHead(statusCode, headers);
642
+ if ((req.method || 'GET').toUpperCase() === 'HEAD') {
643
+ res.end();
644
+ return;
645
+ }
646
+
647
+ const stream = fs.createReadStream(targetPath, { start, end });
648
+ stream.on('error', (error) => {
649
+ if (!res.headersSent) {
650
+ sendContentError(res, statusForError(error), error instanceof Error ? error.message : 'Failed to read file');
651
+ return;
652
+ }
653
+ res.destroy(error);
654
+ });
655
+ stream.pipe(res);
656
+ } catch (error) {
657
+ const message = error instanceof Error ? error.message : 'Failed to read file';
658
+ sendContentError(res, statusForError(error), message);
659
+ }
660
+ }
661
+
527
662
  async function handleWrite(body, sendJson) {
528
663
  try {
529
664
  const targetPath = await resolveSafePath(body.path, { forCreate: true });
@@ -612,6 +747,7 @@ async function handleRename(body, sendJson) {
612
747
  }
613
748
 
614
749
  module.exports = {
750
+ handleContent,
615
751
  handleDelete,
616
752
  handleList,
617
753
  handleMkdir,
@@ -11,6 +11,10 @@ async function handleFileRoutes(ctx) {
11
11
  fsRest.handleRead(ctx.getQuery(), ctx.sendJson);
12
12
  return true;
13
13
  }
14
+ if (ctx.pathname === '/fs/content' && (ctx.method === 'GET' || ctx.method === 'HEAD')) {
15
+ await fsRest.handleContent(ctx.getQuery(), ctx.req, ctx.res);
16
+ return true;
17
+ }
14
18
  if (ctx.pathname === '/fs/write' && ctx.method === 'POST') {
15
19
  fsRest.handleWrite(await ctx.readJsonBody(), ctx.sendJson);
16
20
  return true;
@@ -6,28 +6,14 @@ const http = require('node:http');
6
6
 
7
7
  const capture = require('../browser/capture');
8
8
 
9
- test('clipFor turns a webview rect into integer CSS-px clip bounds', () => {
10
- // 411.5 + 868.75 overhangs the 1280px viewport by a rounding hair — the
11
- // clip clamps to the viewport edge (the overhang captures as a black bar).
12
- const clip = capture.clipFor({ x: 411.5, y: 52.25, width: 868.75, height: 781.5, vw: 1280, vh: 834 });
13
- assert.deepEqual(clip, { x: 412, y: 52, width: 868, height: 782, scale: 1 });
14
- // Negative origins (mid-animation surfaces) clamp to the viewport.
15
- assert.equal(capture.clipFor({ x: -4, y: 0, width: 100, height: 100 }).x, 0);
16
- // A splitview sliding open can report a rect mostly past the right edge.
17
- const sliding = capture.clipFor({ x: 1200, y: 0, width: 700, height: 600, vw: 1280, vh: 800 });
18
- assert.deepEqual(sliding, { x: 1200, y: 0, width: 80, height: 600, scale: 1 });
19
- // Retina displays render captures at devicePixelRatio; scale divides it
20
- // back out so image pixels map 1:1 onto cua input coordinates.
21
- assert.equal(capture.clipFor({ x: 0, y: 0, width: 100, height: 100, dpr: 2 }).scale, 0.5);
22
- });
23
-
24
- test('cropFilterFor crops by viewport fraction so any frame resolution works', () => {
25
- const filter = capture.cropFilterFor({ x: 320, y: 0, width: 640, height: 600, vw: 1280, vh: 800 });
26
- // width 640/1280=0.5, height 600/800=0.75, x 320/1280=0.25, y 0.
27
- assert.equal(filter, 'crop=floor(iw*0.500000/2)*2:floor(ih*0.750000/2)*2:floor(iw*0.250000):floor(ih*0.000000)');
28
- // Fractions clamp into [0, 1] even for slightly-out-of-viewport rects.
29
- assert.match(capture.cropFilterFor({ x: -10, y: 0, width: 2000, height: 100, vw: 1000, vh: 500 }),
30
- /^crop=floor\(iw\*1\.000000\/2\)\*2/);
9
+ test('cssViewportClip sizes the capture in CSS pixels for cua 1:1 mapping', async () => {
10
+ const client = {
11
+ call: async () => ({ result: { value: JSON.stringify({ w: 726, h: 767, dpr: 2 }) } }),
12
+ };
13
+ assert.deepEqual(await capture.cssViewportClip(client), { x: 0, y: 0, width: 726, height: 767, scale: 0.5 });
14
+ // A surface with no laid-out viewport yet must refuse, not capture nothing.
15
+ const empty = { call: async () => ({ result: { value: JSON.stringify({ w: 0, h: 0, dpr: 2 }) } }) };
16
+ await assert.rejects(() => capture.cssViewportClip(empty), /no viewport/);
31
17
  });
32
18
 
33
19
  test('pageTargets keeps only capturable page targets', () => {
@@ -49,32 +35,59 @@ test('rectScript identifies the session surface and never guesses', () => {
49
35
  assert.match(script, /https:\/\/example\.com\//);
50
36
  // No current URL → the byUrl branch is disabled, not matching everything.
51
37
  const bare = capture.rectScript('amalgm-bsid-abc', '');
52
- assert.match(bare, /"" \? views\.find/);
38
+ assert.match(bare, /"" \? views\.filter/);
53
39
  // "The only webview" is how one chat's capture reads another chat's page —
54
40
  // an unidentified surface must resolve to null, never to a guess.
55
41
  assert.doesNotMatch(script, /views\.length === 1/);
56
- // The embedder's visibilityState rides along: full geometry with a hidden
57
- // window is exactly the state captures must be able to name.
42
+ // Remounts leave stale 0x0 copies of a session's surface behind; the match
43
+ // with on-screen geometry must win or a live surface reads as "hidden".
44
+ assert.match(script, /r\.width && r\.height/);
58
45
  assert.match(script, /embedderVisibility/);
59
- assert.match(script, /src: match\.src/);
60
46
  });
61
47
 
62
- test('starvationReason names the app window state', () => {
63
- assert.match(capture.starvationReason({ embedderVisibility: 'hidden' }), /hidden or minimized/);
64
- assert.match(capture.starvationReason({ embedderVisibility: null }), /stopped rendering/);
48
+ test('rectScript prefers the on-screen copy over a stale 0x0 duplicate', async () => {
49
+ // Execute the script against a fake DOM: two elements match the marker —
50
+ // the first (a stale remnant) has no geometry, the second is live.
51
+ const script = capture.rectScript('amalgm-bsid-abc', '');
52
+ const fakeViews = [
53
+ {
54
+ getAttribute: (name) => (name === 'data-amalgm-surface' ? 'about:blank#amalgm-bsid-abc' : ''),
55
+ src: 'about:blank#amalgm-bsid-abc',
56
+ getBoundingClientRect: () => ({ x: 0, y: 0, width: 0, height: 0 }),
57
+ },
58
+ {
59
+ getAttribute: (name) => (name === 'data-amalgm-surface' ? 'about:blank#amalgm-bsid-abc' : ''),
60
+ src: 'https://example.com/',
61
+ getBoundingClientRect: () => ({ x: 700, y: 80, width: 726, height: 767 }),
62
+ },
63
+ ];
64
+ const result = new Function('document', 'window', `return (${script})`)(
65
+ { querySelectorAll: () => fakeViews, visibilityState: 'visible' },
66
+ { innerWidth: 1470, innerHeight: 920, devicePixelRatio: 2 },
67
+ );
68
+ const rect = JSON.parse(result);
69
+ assert.equal(rect.width, 726);
70
+ assert.equal(rect.src, 'https://example.com/');
71
+ });
72
+
73
+ test('starvationReason names the real precondition: the surface tab', () => {
74
+ const reason = capture.starvationReason();
75
+ assert.match(reason, /tab in the Amalgm app is hidden/);
76
+ assert.match(reason, /AMALGM_BROWSER_BACKEND=cli/);
77
+ // Window state is irrelevant since the app keeps attached surfaces
78
+ // compositing while hidden — the message must not send users window-hunting.
79
+ assert.doesNotMatch(reason, /window/i);
65
80
  });
66
81
 
67
82
  test('assertCapturable converts a starved probe into a named refusal', async () => {
68
83
  const starvedClient = { call: () => new Promise(() => {}) };
69
- // capture.js wraps the probe in its own deadline via cdp.call timeout — here
70
- // the fake client never resolves, so rely on the call-site deadline shape:
71
84
  const probe = capture.assertCapturable(
72
85
  { call: (m, p, t) => capture.deadline(new Promise(() => {}), 50, m) },
73
- { clip: { x: 0, y: 0, width: 10, height: 10 }, embedderVisibility: 'hidden' },
86
+ { guest: true },
74
87
  );
75
- await assert.rejects(() => probe, /Cannot capture this surface: .*hidden or minimized/);
88
+ await assert.rejects(() => probe, /Cannot capture this surface: .*tab in the Amalgm app is hidden/);
76
89
  // Headless page targets raster unconditionally — no probe, no rejection.
77
- await capture.assertCapturable(starvedClient, { clip: null });
90
+ await capture.assertCapturable(starvedClient, { guest: false });
78
91
  });
79
92
 
80
93
  test('targetListUrl accepts bare ports and ws urls', () => {