@restregular/dsh-visual-browser 0.1.1 → 0.2.1

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/lib/routes.js CHANGED
@@ -1,15 +1,15 @@
1
1
  /**
2
2
  * Same-origin HTTP API behind the Visual Browser panel.
3
3
  *
4
- * The panel polls `/state` for the timeline and pulls `/frame?seq=` for the
5
- * picture; user clicks, wheel events and keys go back through the same
6
- * BrowserManager methods the agent tools use. Frames stay on the host, so a
7
- * 1 Hz picture never costs a session token.
4
+ * Three jobs: a tiny `/state` JSON poll for the operation log, one long-lived
5
+ * `/stream` response that carries Chrome's own screencast frames as a binary
6
+ * frame stream, and `/input` batches that go straight to the DevTools input
7
+ * domain. No screenshot per click, no per-frame request, no base64 hop.
8
8
  */
9
9
 
10
10
  import { BrowserError } from './browser.js';
11
11
 
12
- const BODY_LIMIT = 64 * 1024;
12
+ const BODY_LIMIT = 128 * 1024;
13
13
 
14
14
  class HttpError extends Error {
15
15
  constructor(status, code, message) {
@@ -28,17 +28,10 @@ function sendJson(res, status, payload) {
28
28
  res.end(body);
29
29
  }
30
30
 
31
- function sendFrame(res, frame) {
32
- if (frame === null || frame === undefined) {
33
- res.statusCode = 204;
34
- res.end();
35
- return;
36
- }
37
- res.statusCode = 200;
38
- res.setHeader('content-type', 'image/jpeg');
31
+ function sendEmpty(res, status) {
32
+ res.statusCode = status;
39
33
  res.setHeader('cache-control', 'no-store');
40
- res.setHeader('content-length', String(frame.jpeg.length));
41
- res.end(frame.jpeg);
34
+ res.end();
42
35
  }
43
36
 
44
37
  async function readJsonBody(req) {
@@ -75,6 +68,78 @@ function failure(res, error) {
75
68
  sendJson(res, status, { ok: false, error: { code, message } });
76
69
  }
77
70
 
71
+ /**
72
+ * Hold the response open as one binary frame stream:
73
+ *
74
+ * [uint32 LE payload length][uint32 LE sequence][JPEG bytes]...
75
+ *
76
+ * The panel reads it with `fetch` streaming and decodes each JPEG with
77
+ * `createImageBitmap` onto a canvas. Parsing the framing ourselves keeps the
78
+ * picture deterministic across browser builds and skips the base64 hop an
79
+ * event stream would need.
80
+ */
81
+ function openStream(manager, res) {
82
+ if (typeof manager.subscribeFrames !== 'function') {
83
+ sendEmpty(res, 503);
84
+ return;
85
+ }
86
+ if (!manager.running) {
87
+ sendEmpty(res, 204);
88
+ return;
89
+ }
90
+ res.statusCode = 200;
91
+ // A custom type stays out of every compressible mime table on purpose: the
92
+ // host's gzip middleware must never buffer a live picture.
93
+ res.setHeader('content-type', 'application/x-dsh-visual-browser-frames');
94
+ // no-transform keeps the host's gzip middleware out of a live picture.
95
+ res.setHeader('cache-control', 'no-store, no-transform');
96
+ res.setHeader('pragma', 'no-cache');
97
+ res.setHeader('x-accel-buffering', 'no');
98
+ res.setHeader('connection', 'keep-alive');
99
+ res.flushHeaders?.();
100
+
101
+ let closed = false;
102
+ let paused = false;
103
+ let unsubscribe = null;
104
+ let sequence = 0;
105
+
106
+ const cleanup = () => {
107
+ if (closed) return;
108
+ closed = true;
109
+ try {
110
+ unsubscribe?.();
111
+ } catch {
112
+ /* the browser may be gone */
113
+ }
114
+ unsubscribe = null;
115
+ };
116
+
117
+ const writeFrame = (jpeg) => {
118
+ if (closed || paused) return;
119
+ try {
120
+ const header = Buffer.allocUnsafe(8);
121
+ header.writeUInt32LE(jpeg.length, 0);
122
+ header.writeUInt32LE(++sequence, 4);
123
+ const headOk = res.write(header);
124
+ const bodyOk = res.write(jpeg);
125
+ if (!headOk || !bodyOk) {
126
+ // Slow consumer: drop frames until the socket drains instead of growing
127
+ // an unbounded queue of stale pictures.
128
+ paused = true;
129
+ res.once('drain', () => {
130
+ paused = false;
131
+ });
132
+ }
133
+ } catch {
134
+ cleanup();
135
+ }
136
+ };
137
+
138
+ unsubscribe = manager.subscribeFrames(writeFrame);
139
+ res.on('close', cleanup);
140
+ res.on('error', cleanup);
141
+ }
142
+
78
143
  /**
79
144
  * Handle one request under `prefix`. Exported so a smoke test can drive it with
80
145
  * a plain request/response pair and a stub manager.
@@ -90,7 +155,7 @@ export async function handle(manager, prefix, req, res) {
90
155
  }
91
156
  let route = url.pathname;
92
157
  if (route.startsWith(prefix)) route = route.slice(prefix.length);
93
- if (route === '' || route === '/') route = '/';
158
+ if (route === '') route = '/';
94
159
 
95
160
  try {
96
161
  if (method === 'GET' && route === '/') {
@@ -101,15 +166,15 @@ export async function handle(manager, prefix, req, res) {
101
166
  sendJson(res, 200, { ok: true, state: manager.state() });
102
167
  return;
103
168
  }
104
- if (method === 'GET' && route === '/frame') {
105
- const seq = Number(url.searchParams.get('seq') ?? '0');
106
- sendFrame(res, manager.frameFor(seq));
169
+ if (method === 'GET' && route === '/stream') {
170
+ openStream(manager, res);
107
171
  return;
108
172
  }
109
- if (method === 'GET' && route === '/live') {
110
- const state = await manager.refreshFrame();
111
- const frame = manager.frameFor(state.frameSeq);
112
- sendFrame(res, frame);
173
+ if (method === 'POST' && route === '/input') {
174
+ const body = await readJsonBody(req);
175
+ const events = Array.isArray(body.events) ? body.events : [];
176
+ const result = await manager.dispatchInput(events);
177
+ sendJson(res, 200, { ok: true, applied: result?.applied ?? 0, state: manager.state() });
113
178
  return;
114
179
  }
115
180
  if (method === 'POST' && route === '/start') {
@@ -117,6 +182,7 @@ export async function handle(manager, prefix, req, res) {
117
182
  const target = typeof body.url === 'string' ? body.url.trim() : '';
118
183
  if (target !== '') await manager.open(target);
119
184
  else await manager.ensureStarted();
185
+ manager.refreshLiveView?.();
120
186
  sendJson(res, 200, { ok: true, state: manager.state() });
121
187
  return;
122
188
  }
@@ -125,33 +191,12 @@ export async function handle(manager, prefix, req, res) {
125
191
  sendJson(res, 200, { ok: true, state: manager.state() });
126
192
  return;
127
193
  }
128
- if (method === 'POST' && route === '/capture') {
129
- const state = await manager.refreshFrame();
130
- sendJson(res, 200, { ok: true, state });
131
- return;
132
- }
133
- if (method === 'POST' && route === '/click') {
134
- const body = await readJsonBody(req);
135
- await manager.clickAt({ x: body.x, y: body.y });
136
- sendJson(res, 200, { ok: true, state: manager.state() });
137
- return;
138
- }
139
- if (method === 'POST' && route === '/wheel') {
140
- const body = await readJsonBody(req);
141
- await manager.wheelAt({ deltaY: body.deltaY, deltaX: body.deltaX });
142
- sendJson(res, 200, { ok: true, state: manager.state() });
143
- return;
144
- }
145
- if (method === 'POST' && route === '/key') {
146
- const body = await readJsonBody(req);
147
- await manager.keyFromUser(String(body.key ?? 'Enter'));
148
- sendJson(res, 200, { ok: true, state: manager.state() });
149
- return;
150
- }
151
194
  if (method === 'POST' && route === '/nav') {
152
195
  const body = await readJsonBody(req);
153
196
  const action = String(body.action ?? '');
154
- if (!['back', 'forward', 'reload'].includes(action)) throw new HttpError(400, 'BAD_ACTION', 'action must be back, forward, or reload');
197
+ if (!['back', 'forward', 'reload'].includes(action)) {
198
+ throw new HttpError(400, 'BAD_ACTION', 'action must be back, forward, or reload');
199
+ }
155
200
  await manager.act({ action });
156
201
  sendJson(res, 200, { ok: true, state: manager.state() });
157
202
  return;
@@ -159,6 +204,15 @@ export async function handle(manager, prefix, req, res) {
159
204
  sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: `No route for ${method} ${route}` } });
160
205
  } catch (error) {
161
206
  manager.noteError?.(error);
207
+ if (res.headersSent) {
208
+ // A stream that failed after its headers went out can only be torn down.
209
+ try {
210
+ res.end();
211
+ } catch {
212
+ /* already closed */
213
+ }
214
+ return;
215
+ }
162
216
  failure(res, error);
163
217
  }
164
218
  }
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Visual Browser",
4
- "description": "A real Chrome for the Agent plus a live view for you: the Agent reads a numbered, token-light list of interactive elements instead of the raw DOM, and every action lands in a panel with the page frame, a replayable timeline, and click-through control."
4
+ "description": "One extra door under the right Sidebar's built-in Browser: a live picture of the Chrome the Agent drives, with real click, drag, wheel and keyboard control, plus a collapsible, scrollable action log — no screenshot polling, no frame replay."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "可视化浏览器",
4
- "description": "给 Agent 一个真实的 Chrome,也给你一块实时画面:Agent 只读「可交互元素编号列表」这种精简视图而不是整页 DOM,每次操作都会在侧边栏留下画面、可回放的操作时间线,你也可以直接点画面接管浏览器。"
4
+ "description": "在右侧边栏「浏览器」下面多一个入口:实时映射 Agent 正在操作的 Chrome 画面,点击、拖动、滚轮、键盘都能直接操作,操作记录可折叠、可滚动,不再需要逐帧截图或回放。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@restregular/dsh-visual-browser",
3
- "version": "0.1.1",
4
- "description": "Visual browser for DeepSeek Harness: the Agent drives a real Chrome through a token-light numbered view of interactive elements (never the raw DOM), while the Web UI streams the live page frame, the click point of every action, and a replayable operation timeline the user can also take over by clicking the picture.",
3
+ "version": "0.2.1",
4
+ "description": "Visual browser for DeepSeek Harness: a right-sidebar tab under the built-in Browser that streams Chrome live as a binary JPEG frame stream (no screenshot polling, no per-frame request) while the Agent drives it through a token-light numbered view of interactive elements, never the raw DOM. Clicks, drags, wheel and typing are forwarded straight to the DevTools input domain, and every action stays in a collapsible, scrollable operation log.",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",
7
7
  "main": "index.js",
@@ -33,8 +33,7 @@
33
33
  "immediately": true,
34
34
  "inject": [
35
35
  "@deepseek-ai/dsh-client-locale",
36
- "@deepseek-ai/dsh-client-ui-layout",
37
- "@deepseek-ai/dsh-client-ui-sidebar"
36
+ "@deepseek-ai/dsh-client-ui-sidebar-right"
38
37
  ]
39
38
  }
40
39
  },
@@ -1,56 +1,70 @@
1
- import assert from 'node:assert/strict';
2
- import { readFile } from 'node:fs/promises';
3
- import { dirname, join } from 'node:path';
4
- import { fileURLToPath, pathToFileURL } from 'node:url';
5
-
6
- const root = dirname(dirname(fileURLToPath(import.meta.url)));
7
- const readJson = async (name) => JSON.parse(await readFile(join(root, name), 'utf8'));
8
-
9
- const pkg = await readJson('package.json');
10
- assert.equal(pkg.name, '@restregular/dsh-visual-browser');
11
- assert.match(pkg.version, /^\d+\.\d+\.\d+$/);
12
- assert.equal(pkg.type, 'module');
13
- assert.ok(pkg.description.length > 80, 'description explains the capability');
14
- assert.equal(pkg.icon, './icon.svg');
15
- assert.equal(pkg.dsh.bundle.patch, './cordis.patch.yml');
16
- assert.equal(pkg.dsh.client.platform, 'web');
17
- assert.ok(Array.isArray(pkg.dsh.client.inject) && pkg.dsh.client.inject.length > 0);
18
- assert.deepEqual(pkg.exports['./client'], './client.js');
19
- assert.ok(pkg.files.includes('client.js'));
20
- assert.ok(pkg.files.includes('index.js'));
21
- assert.ok(pkg.files.includes('lib/'));
22
- assert.ok(pkg.files.includes('cordis.patch.yml'));
23
- assert.ok(pkg.files.includes('icon.svg'));
24
- assert.ok(pkg.files.includes('locale/*.json'));
25
-
26
- const patch = await readFile(join(root, 'cordis.patch.yml'), 'utf8');
27
- assert.ok(patch.includes(`name: '${pkg.name}'`), 'patch row name must equal the package name');
28
- assert.ok(patch.includes('- insert:'));
29
- assert.ok(patch.includes('id: dsh-visual-browser'));
30
-
31
- for (const locale of ['en', 'zh']) {
32
- const dict = await readJson(`locale/${locale}.json`);
33
- assert.ok(dict.meta && dict.meta.title, `${locale} meta title`);
34
- assert.ok(dict.meta.description && dict.meta.description.length > 40, `${locale} meta description`);
35
- }
36
- assert.notEqual((await readJson('locale/en.json')).meta.title, (await readJson('locale/zh.json')).meta.title);
37
-
38
- const icon = await readFile(join(root, 'icon.svg'), 'utf8');
39
- assert.ok(icon.startsWith('<svg'), 'icon is an SVG');
40
- assert.ok(icon.includes('viewBox="0 0 256 256"'));
41
- assert.ok(icon.includes('</svg>'));
42
-
43
- // The Host half must be importable and expose the Loader surface.
44
- const host = await import(pathToFileURL(join(root, 'index.js')).href);
45
- assert.equal(host.name, 'dsh-visual-browser');
46
- assert.equal(typeof host.apply, 'function');
47
-
48
- // The client bundle is not a module: it registers through the module loader.
49
- const client = await readFile(join(root, 'client.js'), 'utf8');
50
- assert.ok(client.includes('__ModuleLoader__.load'), 'client registers with the module loader');
51
- assert.ok(client.includes(`id: MODULE_ID`));
52
- assert.ok(client.includes(`'sidebar.panellist'`), 'sidebar entry');
53
- assert.ok(client.includes(`'main'`), 'main panel');
54
- assert.ok(!/(^|\n)\s*(const|let)\s+[A-Za-z_$][\w$]*\s*=/.test(client.replace(/^\s*\/\/.*$/gm, '')) || client.trimStart().startsWith('(function'), 'client file is wrapped in an IIFE');
55
-
56
- console.log('package.test.mjs ok');
1
+ import assert from 'node:assert/strict';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath, pathToFileURL } from 'node:url';
5
+
6
+ const root = dirname(dirname(fileURLToPath(import.meta.url)));
7
+ const readJson = async (name) => JSON.parse(await readFile(join(root, name), 'utf8'));
8
+
9
+ const pkg = await readJson('package.json');
10
+ assert.equal(pkg.name, '@restregular/dsh-visual-browser');
11
+ assert.match(pkg.version, /^\d+\.\d+\.\d+$/);
12
+ assert.equal(pkg.type, 'module');
13
+ assert.ok(pkg.description.length > 80, 'description explains the capability');
14
+ assert.equal(pkg.icon, './icon.svg');
15
+ assert.equal(pkg.dsh.bundle.patch, './cordis.patch.yml');
16
+ assert.equal(pkg.dsh.client.platform, 'web');
17
+ assert.ok(Array.isArray(pkg.dsh.client.inject) && pkg.dsh.client.inject.length > 0);
18
+ assert.ok(pkg.dsh.client.inject.includes('@deepseek-ai/dsh-client-ui-sidebar-right'), 'the right Sidebar must load first');
19
+ assert.deepEqual(pkg.exports['./client'], './client.js');
20
+ assert.ok(pkg.files.includes('client.js'));
21
+ assert.ok(pkg.files.includes('index.js'));
22
+ assert.ok(pkg.files.includes('lib/'));
23
+ assert.ok(pkg.files.includes('cordis.patch.yml'));
24
+ assert.ok(pkg.files.includes('icon.svg'));
25
+ assert.ok(pkg.files.includes('locale/*.json'));
26
+
27
+ const patch = await readFile(join(root, 'cordis.patch.yml'), 'utf8');
28
+ assert.ok(patch.includes(`name: '${pkg.name}'`), 'patch row name must equal the package name');
29
+ assert.ok(patch.includes('- insert:'));
30
+ assert.ok(patch.includes('id: dsh-visual-browser'));
31
+
32
+ for (const locale of ['en', 'zh']) {
33
+ const dict = await readJson(`locale/${locale}.json`);
34
+ assert.ok(dict.meta && dict.meta.title, `${locale} meta title`);
35
+ assert.ok(dict.meta.description && dict.meta.description.length > 40, `${locale} meta description`);
36
+ }
37
+ assert.notEqual((await readJson('locale/en.json')).meta.title, (await readJson('locale/zh.json')).meta.title);
38
+
39
+ const icon = await readFile(join(root, 'icon.svg'), 'utf8');
40
+ assert.ok(icon.startsWith('<svg'), 'icon is an SVG');
41
+ assert.ok(icon.includes('viewBox="0 0 256 256"'));
42
+ assert.ok(icon.includes('</svg>'));
43
+
44
+ // The Host half must be importable and expose the Loader surface.
45
+ const host = await import(pathToFileURL(join(root, 'index.js')).href);
46
+ assert.equal(host.name, 'dsh-visual-browser');
47
+ assert.equal(typeof host.apply, 'function');
48
+
49
+ // The client bundle is not a module: it registers through the module loader.
50
+ const client = await readFile(join(root, 'client.js'), 'utf8');
51
+ assert.ok(client.includes('__ModuleLoader__.load'), 'client registers with the module loader');
52
+ assert.ok(client.includes(`id: MODULE_ID`));
53
+ assert.ok(client.includes(`'sidebar.right.pane.tab'`), 'right-sidebar tab body');
54
+ assert.ok(client.includes(`'sidebar.right.pane.tab.title'`), 'right-sidebar tab title');
55
+ assert.ok(client.includes(`'sidebar.right.tab.guide.entry'`), 'guide card seat');
56
+ assert.ok(client.includes('@deepseek-ai/dsh-client-ui-sidebar-browser'), 'extends the built-in browser guide card');
57
+ assert.ok(!client.includes(`'sidebar.panellist'`), 'the left sidebar entry is gone');
58
+ assert.ok(!client.includes(`'main'`), 'the left main panel page is gone');
59
+ assert.ok(client.includes(`ctx.inject(['sidebarRightTabs']`), 'the tab registry is waited for, not sampled');
60
+ assert.ok(!client.includes('data-ds-dark-theme'), 'theme switching comes from --dsw-alias-* tokens alone');
61
+ assert.ok(client.includes('createImageBitmap'), 'frames decode off the main thread');
62
+ assert.ok(client.includes('/stream?r='), 'the panel reads the binary frame stream');
63
+ assert.ok(!client.includes('previewSeq'), 'no frame replay is left in the panel');
64
+ assert.ok(client.includes("'dvb-log-body'"), 'the operation log is a scroll container');
65
+ assert.ok(client.includes("role: 'separator'"), 'the log height has a drag handle');
66
+ assert.ok(client.includes('LOG_HEIGHT_KEY') && client.includes('localStorage'), 'the chosen split is remembered');
67
+ assert.ok(client.includes('--dsw-alias-interactive-bg-hover'), 'hover uses the interactive token, not a surface token');
68
+ assert.ok(!/(^|\n)\s*(const|let)\s+[A-Za-z_$][\w$]*\s*=/.test(client.replace(/^\s*\/\/.*$/gm, '')) || client.trimStart().startsWith('(function'), 'client file is wrapped in an IIFE');
69
+
70
+ console.log('package.test.mjs ok');
@@ -5,17 +5,39 @@ import { handle } from '../lib/routes.js';
5
5
  const PREFIX = '/visual-browser/api';
6
6
 
7
7
  function makeRes() {
8
+ const handlers = new Map();
8
9
  return {
9
10
  statusCode: 200,
10
11
  headers: {},
11
12
  chunks: [],
12
13
  ended: false,
14
+ flushed: false,
13
15
  setHeader(name, value) {
14
16
  this.headers[String(name).toLowerCase()] = value;
15
17
  },
18
+ flushHeaders() {
19
+ this.flushed = true;
20
+ },
21
+ write(chunk) {
22
+ this.chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
23
+ return true;
24
+ },
16
25
  end(data) {
17
26
  if (data !== undefined) this.chunks.push(Buffer.isBuffer(data) ? data : Buffer.from(String(data)));
18
27
  this.ended = true;
28
+ this.emit('close');
29
+ },
30
+ on(event, callback) {
31
+ const bucket = handlers.get(event) ?? [];
32
+ bucket.push(callback);
33
+ handlers.set(event, bucket);
34
+ return this;
35
+ },
36
+ once(event, callback) {
37
+ return this.on(event, callback);
38
+ },
39
+ emit(event) {
40
+ for (const callback of handlers.get(event) ?? []) callback();
19
41
  },
20
42
  get body() {
21
43
  return Buffer.concat(this.chunks).toString('utf8');
@@ -37,17 +59,28 @@ function makeReq(method, url, body) {
37
59
  };
38
60
  }
39
61
 
40
- function makeManager() {
62
+ function makeManager({ running = true } = {}) {
41
63
  const manager = {
42
64
  calls: {},
65
+ running,
43
66
  state() {
44
- return { running: true, url: 'https://demo.test/', frameSeq: 3, actions: [] };
67
+ return { running, url: 'https://demo.test/', startedAt: 12, actionSeq: 3, actions: [] };
45
68
  },
46
- frameFor(seq) {
47
- return Number(seq) === 3 ? { seq: 3, jpeg: Buffer.from('jpeg-bytes') } : null;
69
+ framesListener: null,
70
+ subscribeFrames(listener) {
71
+ this.framesListener = listener;
72
+ this.calls.subscribed = (this.calls.subscribed ?? 0) + 1;
73
+ return () => {
74
+ this.framesListener = null;
75
+ this.calls.unsubscribed = true;
76
+ };
48
77
  },
49
- async refreshFrame() {
50
- return this.state();
78
+ refreshLiveView() {
79
+ this.calls.refreshed = true;
80
+ },
81
+ async dispatchInput(events) {
82
+ this.calls.input = events;
83
+ return { applied: events.length };
51
84
  },
52
85
  async open(url) {
53
86
  this.calls.open = url;
@@ -58,15 +91,6 @@ function makeManager() {
58
91
  async close() {
59
92
  this.calls.closed = true;
60
93
  },
61
- async clickAt(point) {
62
- this.calls.click = point;
63
- },
64
- async wheelAt(point) {
65
- this.calls.wheel = point;
66
- },
67
- async keyFromUser(key) {
68
- this.calls.key = key;
69
- },
70
94
  async act(input) {
71
95
  this.calls.act = input;
72
96
  },
@@ -90,28 +114,44 @@ async function request(manager, method, path, body) {
90
114
  assert.equal(res.statusCode, 200);
91
115
  assert.equal(res.headers['content-type'], 'application/json; charset=utf-8');
92
116
  assert.equal(res.json.ok, true);
93
- assert.equal(res.json.state.frameSeq, 3);
117
+ assert.equal(res.json.state.startedAt, 12);
94
118
  }
95
119
 
96
- // frame hit and miss
120
+ // live picture stream: binary framing, then frames pushed through subscribeFrames
97
121
  {
98
122
  const manager = makeManager();
99
- const hit = await request(manager, 'GET', '/frame?seq=3');
100
- assert.equal(hit.statusCode, 200);
101
- assert.equal(hit.headers['content-type'], 'image/jpeg');
102
- assert.equal(hit.body, 'jpeg-bytes');
103
-
104
- const miss = await request(manager, 'GET', '/frame?seq=2');
105
- assert.equal(miss.statusCode, 204);
106
- assert.equal(miss.chunks.length, 0);
123
+ const res = makeRes();
124
+ await handle(manager, PREFIX, makeReq('GET', PREFIX + '/stream'), res);
125
+ assert.equal(res.statusCode, 200);
126
+ assert.equal(res.headers['content-type'], 'application/x-dsh-visual-browser-frames');
127
+ assert.match(res.headers['cache-control'], /no-transform/, 'the host gzip middleware must stay out of a live picture');
128
+ assert.equal(res.flushed, true);
129
+ assert.equal(manager.calls.subscribed, 1);
130
+ assert.equal(typeof manager.framesListener, 'function');
131
+
132
+ manager.framesListener(Buffer.from('jpeg-bytes'));
133
+ const body = Buffer.concat(res.chunks);
134
+ assert.equal(body.length, 8 + 10);
135
+ assert.equal(body.readUInt32LE(0), 10, 'frame length prefix');
136
+ assert.equal(body.readUInt32LE(4), 1, 'frame sequence');
137
+ assert.equal(body.subarray(8).toString('utf8'), 'jpeg-bytes');
138
+
139
+ res.emit('close');
140
+ assert.equal(manager.calls.unsubscribed, true, 'the stream releases its subscription when the panel goes away');
107
141
  }
108
142
 
109
- // live capture
143
+ // no multipart: the panel parses frames itself
110
144
  {
111
- const manager = makeManager();
112
- const res = await request(manager, 'GET', '/live');
113
- assert.equal(res.statusCode, 200);
114
- assert.equal(res.body, 'jpeg-bytes');
145
+ const routes = await import('node:fs/promises').then((fs) => fs.readFile(new URL('../lib/routes.js', import.meta.url), 'utf8'));
146
+ assert.ok(!routes.includes('multipart/x-mixed-replace'), 'binary framing replaced MJPEG');
147
+ }
148
+
149
+ // stream without a running browser is an empty answer, not a hang
150
+ {
151
+ const manager = makeManager({ running: false });
152
+ const res = await request(manager, 'GET', '/stream');
153
+ assert.equal(res.statusCode, 204);
154
+ assert.equal(manager.calls.subscribed, undefined);
115
155
  }
116
156
 
117
157
  // start with and without a URL
@@ -120,26 +160,27 @@ async function request(manager, method, path, body) {
120
160
  const started = await request(manager, 'POST', '/start', { url: 'example.com' });
121
161
  assert.equal(started.statusCode, 200);
122
162
  assert.equal(manager.calls.open, 'example.com');
163
+ assert.equal(manager.calls.refreshed, true);
123
164
 
124
165
  const plain = await request(manager, 'POST', '/start', {});
125
166
  assert.equal(plain.statusCode, 200);
126
167
  assert.equal(manager.calls.started, true);
127
168
  }
128
169
 
129
- // user interactions
170
+ // one input batch, one round trip
130
171
  {
131
172
  const manager = makeManager();
132
- const click = await request(manager, 'POST', '/click', { x: 0.5, y: 0.25 });
133
- assert.equal(click.statusCode, 200);
134
- assert.deepEqual(manager.calls.click, { x: 0.5, y: 0.25 });
135
-
136
- const wheel = await request(manager, 'POST', '/wheel', { deltaY: 240 });
137
- assert.equal(wheel.statusCode, 200);
138
- assert.deepEqual(manager.calls.wheel, { deltaY: 240, deltaX: undefined });
139
-
140
- const key = await request(manager, 'POST', '/key', { key: 'Enter' });
141
- assert.equal(key.statusCode, 200);
142
- assert.equal(manager.calls.key, 'Enter');
173
+ const events = [
174
+ { t: 'pointer', type: 'down', x: 0.5, y: 0.25, button: 0, buttons: 1 },
175
+ { t: 'pointer', type: 'up', x: 0.5, y: 0.25, button: 0, buttons: 0 },
176
+ { t: 'wheel', x: 0.5, y: 0.5, dx: 0, dy: 240 },
177
+ { t: 'key', type: 'down', key: 'Enter', code: 'Enter', keyCode: 13 },
178
+ ];
179
+ const res = await request(manager, 'POST', '/input', { events });
180
+ assert.equal(res.statusCode, 200);
181
+ assert.equal(res.json.ok, true);
182
+ assert.equal(res.json.applied, 4);
183
+ assert.deepEqual(manager.calls.input, events);
143
184
 
144
185
  const nav = await request(manager, 'POST', '/nav', { action: 'back' });
145
186
  assert.equal(nav.statusCode, 200);
@@ -164,7 +205,7 @@ async function request(manager, method, path, body) {
164
205
  const res = makeRes();
165
206
  const badReq = {
166
207
  method: 'POST',
167
- url: PREFIX + '/click',
208
+ url: PREFIX + '/input',
168
209
  async *[Symbol.asyncIterator]() {
169
210
  yield Buffer.from('{not json');
170
211
  },
@@ -176,9 +217,9 @@ async function request(manager, method, path, body) {
176
217
  const toobig = makeRes();
177
218
  const huge = {
178
219
  method: 'POST',
179
- url: PREFIX + '/click',
220
+ url: PREFIX + '/input',
180
221
  async *[Symbol.asyncIterator]() {
181
- yield Buffer.alloc(70 * 1024, 0x20);
222
+ yield Buffer.alloc(200 * 1024, 0x20);
182
223
  },
183
224
  };
184
225
  await handle(manager, PREFIX, huge, toobig);