@restregular/dsh-visual-browser 0.1.1 → 0.2.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/README.md +160 -129
- package/client.js +1077 -437
- package/lib/browser.js +1255 -1045
- package/lib/routes.js +101 -47
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +3 -4
- package/test/package.test.mjs +68 -56
- package/test/routes.test.mjs +86 -45
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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 =
|
|
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
|
|
32
|
-
|
|
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.
|
|
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 === ''
|
|
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 === '/
|
|
105
|
-
|
|
106
|
-
sendFrame(res, manager.frameFor(seq));
|
|
169
|
+
if (method === 'GET' && route === '/stream') {
|
|
170
|
+
openStream(manager, res);
|
|
107
171
|
return;
|
|
108
172
|
}
|
|
109
|
-
if (method === '
|
|
110
|
-
const
|
|
111
|
-
const
|
|
112
|
-
|
|
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))
|
|
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": "
|
|
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": "
|
|
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.
|
|
4
|
-
"description": "Visual browser for DeepSeek Harness: the
|
|
3
|
+
"version": "0.2.0",
|
|
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-
|
|
37
|
-
"@deepseek-ai/dsh-client-ui-sidebar"
|
|
36
|
+
"@deepseek-ai/dsh-client-ui-sidebar-right"
|
|
38
37
|
]
|
|
39
38
|
}
|
|
40
39
|
},
|
package/test/package.test.mjs
CHANGED
|
@@ -1,56 +1,68 @@
|
|
|
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.
|
|
19
|
-
assert.
|
|
20
|
-
assert.ok(pkg.files.includes('
|
|
21
|
-
assert.ok(pkg.files.includes('
|
|
22
|
-
assert.ok(pkg.files.includes('
|
|
23
|
-
assert.ok(pkg.files.includes('
|
|
24
|
-
assert.ok(pkg.files.includes('
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
assert.ok(patch.includes('
|
|
29
|
-
assert.ok(patch.includes('
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
assert.ok(dict.meta
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
assert.ok(icon.
|
|
41
|
-
assert.ok(icon.includes('
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
assert.equal(
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
assert.ok(client.includes(
|
|
52
|
-
assert.ok(client.includes(`
|
|
53
|
-
assert.ok(client.includes(`'
|
|
54
|
-
assert.ok(
|
|
55
|
-
|
|
56
|
-
|
|
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('--dsw-alias-interactive-bg-hover'), 'hover uses the interactive token, not a surface token');
|
|
66
|
+
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');
|
|
67
|
+
|
|
68
|
+
console.log('package.test.mjs ok');
|
package/test/routes.test.mjs
CHANGED
|
@@ -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
|
|
67
|
+
return { running, url: 'https://demo.test/', startedAt: 12, actionSeq: 3, actions: [] };
|
|
45
68
|
},
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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.
|
|
117
|
+
assert.equal(res.json.state.startedAt, 12);
|
|
94
118
|
}
|
|
95
119
|
|
|
96
|
-
//
|
|
120
|
+
// live picture stream: binary framing, then frames pushed through subscribeFrames
|
|
97
121
|
{
|
|
98
122
|
const manager = makeManager();
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
assert.equal(
|
|
102
|
-
assert.equal(
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
assert.equal(
|
|
106
|
-
assert.equal(
|
|
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
|
-
//
|
|
143
|
+
// no multipart: the panel parses frames itself
|
|
110
144
|
{
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
//
|
|
170
|
+
// one input batch, one round trip
|
|
130
171
|
{
|
|
131
172
|
const manager = makeManager();
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
assert.equal(
|
|
142
|
-
assert.
|
|
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 + '/
|
|
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 + '/
|
|
220
|
+
url: PREFIX + '/input',
|
|
180
221
|
async *[Symbol.asyncIterator]() {
|
|
181
|
-
yield Buffer.alloc(
|
|
222
|
+
yield Buffer.alloc(200 * 1024, 0x20);
|
|
182
223
|
},
|
|
183
224
|
};
|
|
184
225
|
await handle(manager, PREFIX, huge, toobig);
|