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.
@@ -0,0 +1,155 @@
1
+ 'use strict';
2
+
3
+ const assert = require('node:assert/strict');
4
+ const test = require('node:test');
5
+
6
+ // Transport semantics introduced after the 2026-06-11 stress tests: in
7
+ // attached mode every command re-pins the session's webview target inside
8
+ // the same batch. The daemon's tab selection is process state that silently
9
+ // falls back to "the active tab" when the selected target dies — that
10
+ // fallback is how one session read another session's page.
11
+
12
+ const attach = require('../browser/attach');
13
+ const { attachedSessions, knownSessions } = require('../browser/sessions');
14
+
15
+ // Patch attach.run before engine destructures it (engine binds at require).
16
+ const calls = [];
17
+ let nextResults = [];
18
+ const realRun = attach.run;
19
+ attach.run = async (session, commandArgs, options) => {
20
+ calls.push({ session, commandArgs });
21
+ const next = nextResults.shift();
22
+ if (next instanceof Error) throw next;
23
+ return next ?? { success: true, data: null };
24
+ };
25
+ const engine = require('../browser/engine');
26
+
27
+ test('eval retries a top-level return as a function body', async () => {
28
+ calls.length = 0;
29
+ nextResults = [
30
+ new Error('Eval failed: Illegal return statement'),
31
+ { success: true, data: { origin: 'x', result: 42 } },
32
+ ];
33
+ const { result } = await engine.evaluate({ script: 'return 21 * 2', session: 'wrap-test' }, {});
34
+ assert.equal(result, 42);
35
+ assert.equal(calls.length, 2);
36
+ assert.deepEqual(calls[0].commandArgs, ['eval', 'return 21 * 2']);
37
+ assert.deepEqual(calls[1].commandArgs, ['eval', '(() => { return 21 * 2 })()']);
38
+ });
39
+
40
+ test('eval does not rewrite genuine script errors', async () => {
41
+ calls.length = 0;
42
+ nextResults = [new Error('Eval failed: ReferenceError: nope is not defined')];
43
+ await assert.rejects(
44
+ () => engine.evaluate({ script: 'nope()', session: 'wrap-test' }, {}),
45
+ /ReferenceError/,
46
+ );
47
+ assert.equal(calls.length, 1, 'only Illegal return triggers the wrapped retry');
48
+ });
49
+
50
+ test('select drives native dropdowns through the real select command', async () => {
51
+ calls.length = 0;
52
+ nextResults = [{ success: true, data: null }];
53
+ const result = await engine.select({ target: '#mode', value: 'gamma', session: 'select-test' }, {});
54
+ assert.deepEqual(calls[0].commandArgs, ['select', '#mode', 'gamma']);
55
+ assert.equal(result.selected, '#mode');
56
+ });
57
+
58
+ test('passthrough refuses tab hops in attached mode, allows listing', async () => {
59
+ const saved = process.env.AMALGM_BROWSER_BACKEND;
60
+ process.env.AMALGM_BROWSER_BACKEND = 'attached';
61
+ try {
62
+ await assert.rejects(
63
+ () => engine.passthrough({ args: ['tab', 't3'], session: 'hop-test' }, {}),
64
+ /Tab switching is managed per session/,
65
+ );
66
+ // Bare listing is diagnostics, not a hop — it goes through to the daemon.
67
+ calls.length = 0;
68
+ nextResults = [{ success: true, data: { tabs: [] } }];
69
+ await engine.passthrough({ args: ['tab'], session: 'hop-test' }, {});
70
+ assert.deepEqual(calls[0].commandArgs, ['tab']);
71
+ } finally {
72
+ if (saved === undefined) delete process.env.AMALGM_BROWSER_BACKEND;
73
+ else process.env.AMALGM_BROWSER_BACKEND = saved;
74
+ attach.run = realRun;
75
+ }
76
+ });
77
+
78
+ // The pin itself: execute() turns every attached call into a batch that
79
+ // re-selects the session's webview first, and maps the single-command result
80
+ // back to its unbatched shape.
81
+
82
+ test('attached commands re-pin the webview target inside the batch', async (t) => {
83
+ const fs = require('node:fs');
84
+ const os = require('node:os');
85
+ const path = require('node:path');
86
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-pin-test-'));
87
+ const stub = path.join(dir, 'stub.sh');
88
+ // The stub answers `batch` by echoing stdin back as batch entries.
89
+ fs.writeFileSync(stub, `#!/bin/sh
90
+ input=$(cat)
91
+ node -e '
92
+ const cmds = JSON.parse(process.argv[1]);
93
+ console.log(JSON.stringify({ success: true, data: cmds.map((c) => ({ command: c, success: true, result: { echoed: c } })) }));
94
+ ' "$input"
95
+ `, { mode: 0o755 });
96
+
97
+ const savedBin = process.env.AMALGM_AGENT_BROWSER_BIN;
98
+ const savedBackend = process.env.AMALGM_BROWSER_BACKEND;
99
+ const savedCdp = process.env.AMALGM_BROWSER_CDP_URL;
100
+ process.env.AMALGM_AGENT_BROWSER_BIN = stub;
101
+ process.env.AMALGM_BROWSER_BACKEND = 'attached';
102
+ process.env.AMALGM_BROWSER_CDP_URL = '9242';
103
+ attachedSessions.add('pin-test');
104
+ knownSessions.set('pin-test', { webviewTabId: 't7' });
105
+ try {
106
+ const single = await attach.run('pin-test', ['get', 'url'], { timeoutMs: 5000 });
107
+ assert.deepEqual(single.data, { echoed: ['get', 'url'] }, 'single result keeps its unbatched shape');
108
+
109
+ const entries = await attach.runBatch('pin-test', [['open', 'https://example.com'], ['get', 'url']], { timeoutMs: 5000 });
110
+ assert.equal(entries.length, 2, 'the pin step is internal — callers see their own commands only');
111
+ assert.deepEqual(entries[0].command, ['open', 'https://example.com']);
112
+ } finally {
113
+ attachedSessions.delete('pin-test');
114
+ knownSessions.delete('pin-test');
115
+ if (savedBin === undefined) delete process.env.AMALGM_AGENT_BROWSER_BIN; else process.env.AMALGM_AGENT_BROWSER_BIN = savedBin;
116
+ if (savedBackend === undefined) delete process.env.AMALGM_BROWSER_BACKEND; else process.env.AMALGM_BROWSER_BACKEND = savedBackend;
117
+ if (savedCdp === undefined) delete process.env.AMALGM_BROWSER_CDP_URL; else process.env.AMALGM_BROWSER_CDP_URL = savedCdp;
118
+ fs.rmSync(dir, { recursive: true, force: true });
119
+ }
120
+ });
121
+
122
+ test('a dead pin is classified as a lost target; page errors are not', () => {
123
+ // The pin failure carries the "surface target lost" prefix so the retry
124
+ // path re-attaches. Ordinary page errors must NOT look like lost
125
+ // connections — a spurious re-attach on "Element not found" would tear
126
+ // down and rebuild the surface on every typo'd selector.
127
+ assert.equal(attach.looksLikeLostConnection(new Error('surface target lost (tab t7: tab not found)')), true);
128
+ assert.equal(attach.looksLikeLostConnection(new Error('WebSocket connection closed')), true);
129
+ assert.equal(attach.looksLikeLostConnection(new Error('Element not found: #submit')), false);
130
+ assert.equal(attach.looksLikeLostConnection(new Error('Timed out waiting for selector')), false);
131
+ });
132
+
133
+ test('pinFailure reclassifies envelope-level batch rejections', () => {
134
+ // agent-browser --bail rejects with the serialized entries as the message
135
+ // (live-verified shape). A failed pin step inside must become "surface
136
+ // target lost" so the retry path re-attaches.
137
+ const envelope = new Error(JSON.stringify([
138
+ { command: ['tab', 't16'], error: 'Tab t16 not found; run `agent-browser tab` to list open tabs', result: null },
139
+ ]));
140
+ const classified = attach.pinFailure(envelope, 't16');
141
+ assert.match(classified.message, /surface target lost/);
142
+ assert.equal(attach.looksLikeLostConnection(classified), true);
143
+
144
+ // A failing page step inside the same shape stays a page error.
145
+ const pageError = new Error(JSON.stringify([
146
+ { command: ['tab', 't16'], error: null, result: { ok: true } },
147
+ { command: ['click', '#gone'], error: 'Element not found', result: null },
148
+ ]));
149
+ assert.equal(attach.pinFailure(pageError, 't16'), pageError);
150
+
151
+ // Headless (no pin) and non-JSON messages pass through untouched.
152
+ const plain = new Error('agent-browser exited with code 1');
153
+ assert.equal(attach.pinFailure(plain, null), plain);
154
+ assert.equal(attach.pinFailure(plain, 't16'), plain);
155
+ });
@@ -54,6 +54,7 @@ test('browser is a single toolbox entry holding every browser action', () => {
54
54
  'click',
55
55
  'fill',
56
56
  'press',
57
+ 'select',
57
58
  'eval',
58
59
  'wait',
59
60
  'cli',
@@ -106,7 +107,7 @@ test('browser actions carry capability tags so UIs can section one card', () =>
106
107
  .filter((tool) => tool.capability === capability)
107
108
  .map((tool) => tool.name);
108
109
 
109
- assert.equal(byCapability('core').length, 10);
110
+ assert.equal(byCapability('core').length, 11);
110
111
  assert.equal(byCapability('computer-use').every((name) => name.startsWith('cua_')), true);
111
112
  assert.deepEqual(byCapability('recording'), ['record_start', 'record_stop', 'record_list']);
112
113
  assert.deepEqual(byCapability('auth'), ['auth_list', 'auth_link_create', 'auth_save', 'auth_load']);
@@ -121,19 +121,20 @@ One persistent browser per chat session. When the Amalgm desktop app is open, th
121
121
 
122
122
  Browser actions are MCP tools named `toolbox__browser_<action>` (your harness may add its own prefix, e.g. `mcp__amalgm__toolbox__browser_open`).
123
123
 
124
- The loop: `open` a URL → `snapshot` to see interactive elements as @refs → `click`/`fill`/`press` by @ref → re-`snapshot` after the page changes.
124
+ The loop: `open` a URL → `snapshot` to see interactive elements as @refs → `click`/`fill`/`select`/`press` by @ref → re-`snapshot` after the page changes.
125
125
 
126
126
  - `open` — go to a URL (the session persists across calls; navigate once, then interact)
127
127
  - `snapshot` — read the page: every interactive element gets a stable @ref
128
128
  - `click` / `fill` / `press` — act on @refs, CSS selectors, or `text=Label` targets
129
- - `eval` — run JavaScript in the page and get the result
129
+ - `select` — choose an option in a native dropdown (clicks and key presses cannot drive those)
130
+ - `eval` — run JavaScript in the page and get the result (bare expressions or `return` bodies both work)
130
131
  - `wait` — wait for a selector, URL, or delay
131
- - `screenshot` — PNG when you need pixels (snapshot is cheaper for driving)
132
- - `cli` — the full agent-browser command surface: scroll, hover, back, tabs, cookies, storage, network, traces, find-by-role, and more
133
- - `close` — end the session
132
+ - `screenshot` — PNG when you need pixels (snapshot is cheaper for driving); image coordinates match cua input 1:1
133
+ - `cli` — the full agent-browser command surface: scroll, hover, back, cookies, storage, network, traces, find-by-role, and more
134
+ - `close` — end the session (in the desktop app this also tidies away its split view)
134
135
  - `cua_screenshot` / `cua_click` / `cua_type` / … — coordinate-based control for vision loops
135
136
  - `record_start` / `record_stop` / `record_list` — capture the session to a local WebM video (QA evidence)
136
- - `auth_list` / `auth_link_create` / `auth_save` / `auth_load` — reuse logins: mint a link for the user to sign in, then save/load encrypted auth bundles
137
+ - `auth_list` / `auth_link_create` / `auth_save` / `auth_load` — reuse logins: in the desktop app the login page opens directly in the split view for the user to sign in; from the web, share the minted link. Save/load encrypted auth bundles afterward
137
138
 
138
139
  Recordings and auth bundles stay on this computer. Use the browser to test apps, scrape data, or automate web interactions.
139
140