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.
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/browser/attach.js +158 -21
- package/runtime/scripts/amalgm-mcp/browser/auth-tools.js +21 -2
- package/runtime/scripts/amalgm-mcp/browser/capture.js +60 -102
- package/runtime/scripts/amalgm-mcp/browser/cdp.js +18 -11
- package/runtime/scripts/amalgm-mcp/browser/engine.js +38 -3
- package/runtime/scripts/amalgm-mcp/browser/recorder.js +27 -13
- package/runtime/scripts/amalgm-mcp/browser/tools.js +29 -6
- package/runtime/scripts/amalgm-mcp/fs/rest.js +136 -0
- package/runtime/scripts/amalgm-mcp/server/routes/files.js +4 -0
- package/runtime/scripts/amalgm-mcp/tests/browser-capture.test.js +47 -34
- package/runtime/scripts/amalgm-mcp/tests/browser-transport.test.js +155 -0
- package/runtime/scripts/amalgm-mcp/tests/core-tools.test.js +2 -1
- package/runtime/scripts/platform-context.txt +7 -6
|
@@ -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
|
|
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)
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
+ '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
7
|
-
* loop is: open → snapshot (@refs) → click/fill (@ref) →
|
|
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
|
|
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"], ["
|
|
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('
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
assert.deepEqual(
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
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\.
|
|
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
|
-
//
|
|
57
|
-
//
|
|
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('
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
{
|
|
86
|
+
{ guest: true },
|
|
74
87
|
);
|
|
75
|
-
await assert.rejects(() => probe, /Cannot capture this surface: .*
|
|
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, {
|
|
90
|
+
await capture.assertCapturable(starvedClient, { guest: false });
|
|
78
91
|
});
|
|
79
92
|
|
|
80
93
|
test('targetListUrl accepts bare ports and ws urls', () => {
|