@restregular/dsh-visual-browser 0.1.0 → 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 +57 -21
- package/client.js +1077 -437
- package/lib/browser.js +1255 -963
- package/lib/cdp.js +208 -192
- package/lib/routes.js +101 -47
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +6 -6
- package/test/format.test.mjs +62 -0
- package/test/package.test.mjs +68 -0
- package/test/routes.test.mjs +248 -0
- package/test/scripts.test.mjs +214 -0
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",
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
"cordis.patch.yml",
|
|
22
22
|
"icon.svg",
|
|
23
23
|
"README.md",
|
|
24
|
-
"LICENSE"
|
|
24
|
+
"LICENSE",
|
|
25
|
+
"test/"
|
|
25
26
|
],
|
|
26
27
|
"dsh": {
|
|
27
28
|
"bundle": {
|
|
@@ -32,8 +33,7 @@
|
|
|
32
33
|
"immediately": true,
|
|
33
34
|
"inject": [
|
|
34
35
|
"@deepseek-ai/dsh-client-locale",
|
|
35
|
-
"@deepseek-ai/dsh-client-ui-
|
|
36
|
-
"@deepseek-ai/dsh-client-ui-sidebar"
|
|
36
|
+
"@deepseek-ai/dsh-client-ui-sidebar-right"
|
|
37
37
|
]
|
|
38
38
|
}
|
|
39
39
|
},
|
|
@@ -66,6 +66,6 @@
|
|
|
66
66
|
"license": "MIT",
|
|
67
67
|
"author": "RestRegular",
|
|
68
68
|
"scripts": {
|
|
69
|
-
"test": "node --check index.js && node --check client.js && node --check lib/browser.js && node test/
|
|
69
|
+
"test": "node --check index.js && node --check client.js && node --check lib/browser.js && node test/format.test.mjs && node test/scripts.test.mjs && node test/routes.test.mjs && node test/package.test.mjs"
|
|
70
70
|
}
|
|
71
71
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { formatSnapshot, describeAction, scrollProgress } from '../lib/format.js';
|
|
3
|
+
|
|
4
|
+
const snapshot = {
|
|
5
|
+
url: 'https://demo.test/login',
|
|
6
|
+
title: '登录 - Demo',
|
|
7
|
+
viewport: { width: 1280, height: 800 },
|
|
8
|
+
scrollY: 400,
|
|
9
|
+
docHeight: 2400,
|
|
10
|
+
elements: [
|
|
11
|
+
{ ref: 1, role: 'textbox', name: '手机号', value: '13800000000' },
|
|
12
|
+
{ ref: 2, role: 'textbox', name: '密码', value: '••••••' },
|
|
13
|
+
{ ref: 3, role: 'button', name: '登录', state: ['disabled'] },
|
|
14
|
+
{ ref: 4, role: 'link', name: '忘记密码', href: '/forgot' },
|
|
15
|
+
],
|
|
16
|
+
total: 6,
|
|
17
|
+
headings: ['用户登录', '没有账号?'],
|
|
18
|
+
text: '用户登录 手机号 密码 登录 忘记密码',
|
|
19
|
+
active: { ref: 2, role: 'textbox', name: '密码' },
|
|
20
|
+
frames: 1,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const text = formatSnapshot(snapshot, { maxElements: 100 });
|
|
24
|
+
const lines = text.split('\n');
|
|
25
|
+
assert.equal(lines[0], 'URL: https://demo.test/login');
|
|
26
|
+
assert.equal(lines[1], 'TITLE: 登录 - Demo');
|
|
27
|
+
assert.equal(lines[2], 'VIEW: 1280x800 scroll 400/2400 · 4/6 interactive');
|
|
28
|
+
assert.ok(lines.includes('[1] textbox "手机号" value=13800000000'));
|
|
29
|
+
assert.ok(lines.includes('[3] button "登录" (disabled)'));
|
|
30
|
+
assert.ok(lines.includes('[4] link "忘记密码" → /forgot'));
|
|
31
|
+
assert.ok(text.includes('ELEMENTS') === false, 'elements exist, so no placeholder line');
|
|
32
|
+
assert.ok(text.includes('… 2 more interactive elements below the fold'), 'beyond-the-fold hint');
|
|
33
|
+
assert.ok(text.includes('HEADINGS: 用户登录 | 没有账号?'));
|
|
34
|
+
assert.ok(text.includes('TEXT: 用户登录 手机号 密码 登录 忘记密码'));
|
|
35
|
+
assert.ok(text.includes('ACTIVE: [2] textbox "密码"'));
|
|
36
|
+
assert.ok(text.includes('IFRAMES: 1'));
|
|
37
|
+
|
|
38
|
+
// Empty page
|
|
39
|
+
const empty = formatSnapshot({ url: 'about:blank', title: '', viewport: { width: 800, height: 600 }, elements: [], total: 0 });
|
|
40
|
+
assert.ok(empty.includes('ELEMENTS: none visible in the viewport'));
|
|
41
|
+
|
|
42
|
+
// maxElements clips and reports the remainder
|
|
43
|
+
const clipped = formatSnapshot(snapshot, { maxElements: 2 });
|
|
44
|
+
assert.equal(clipped.split('\n').filter((line) => line.startsWith('[')).length, 2);
|
|
45
|
+
assert.ok(clipped.includes('… 4 more interactive elements'));
|
|
46
|
+
|
|
47
|
+
// Guard against junk input
|
|
48
|
+
assert.equal(formatSnapshot(null), 'No page snapshot available.');
|
|
49
|
+
assert.ok(formatSnapshot({}).startsWith('URL: about:blank'));
|
|
50
|
+
assert.ok(formatSnapshot({ url: 'x' }).startsWith('URL: x'));
|
|
51
|
+
|
|
52
|
+
// Action descriptions for the timeline
|
|
53
|
+
assert.equal(describeAction({ kind: 'click', target: '[3]', detail: 'click [3] “登录”' }), 'click [3] “登录”');
|
|
54
|
+
assert.equal(describeAction({ kind: 'click', target: '[3]' }), 'click [3]');
|
|
55
|
+
assert.equal(describeAction('hello'), 'hello');
|
|
56
|
+
assert.equal(describeAction(null), '');
|
|
57
|
+
|
|
58
|
+
// Scroll progress
|
|
59
|
+
assert.equal(scrollProgress(snapshot), Math.round((400 / (2400 - 800)) * 100));
|
|
60
|
+
assert.equal(scrollProgress({ scrollY: 0, docHeight: 700, viewport: { height: 800 } }), 0);
|
|
61
|
+
|
|
62
|
+
console.log('format.test.mjs ok');
|
|
@@ -0,0 +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.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');
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { BrowserError } from '../lib/browser.js';
|
|
3
|
+
import { handle } from '../lib/routes.js';
|
|
4
|
+
|
|
5
|
+
const PREFIX = '/visual-browser/api';
|
|
6
|
+
|
|
7
|
+
function makeRes() {
|
|
8
|
+
const handlers = new Map();
|
|
9
|
+
return {
|
|
10
|
+
statusCode: 200,
|
|
11
|
+
headers: {},
|
|
12
|
+
chunks: [],
|
|
13
|
+
ended: false,
|
|
14
|
+
flushed: false,
|
|
15
|
+
setHeader(name, value) {
|
|
16
|
+
this.headers[String(name).toLowerCase()] = value;
|
|
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
|
+
},
|
|
25
|
+
end(data) {
|
|
26
|
+
if (data !== undefined) this.chunks.push(Buffer.isBuffer(data) ? data : Buffer.from(String(data)));
|
|
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();
|
|
41
|
+
},
|
|
42
|
+
get body() {
|
|
43
|
+
return Buffer.concat(this.chunks).toString('utf8');
|
|
44
|
+
},
|
|
45
|
+
get json() {
|
|
46
|
+
return JSON.parse(this.body);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function makeReq(method, url, body) {
|
|
52
|
+
if (body === undefined) return { method, url };
|
|
53
|
+
return {
|
|
54
|
+
method,
|
|
55
|
+
url,
|
|
56
|
+
async *[Symbol.asyncIterator]() {
|
|
57
|
+
yield Buffer.from(JSON.stringify(body));
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function makeManager({ running = true } = {}) {
|
|
63
|
+
const manager = {
|
|
64
|
+
calls: {},
|
|
65
|
+
running,
|
|
66
|
+
state() {
|
|
67
|
+
return { running, url: 'https://demo.test/', startedAt: 12, actionSeq: 3, actions: [] };
|
|
68
|
+
},
|
|
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
|
+
};
|
|
77
|
+
},
|
|
78
|
+
refreshLiveView() {
|
|
79
|
+
this.calls.refreshed = true;
|
|
80
|
+
},
|
|
81
|
+
async dispatchInput(events) {
|
|
82
|
+
this.calls.input = events;
|
|
83
|
+
return { applied: events.length };
|
|
84
|
+
},
|
|
85
|
+
async open(url) {
|
|
86
|
+
this.calls.open = url;
|
|
87
|
+
},
|
|
88
|
+
async ensureStarted() {
|
|
89
|
+
this.calls.started = true;
|
|
90
|
+
},
|
|
91
|
+
async close() {
|
|
92
|
+
this.calls.closed = true;
|
|
93
|
+
},
|
|
94
|
+
async act(input) {
|
|
95
|
+
this.calls.act = input;
|
|
96
|
+
},
|
|
97
|
+
noteError(error) {
|
|
98
|
+
this.calls.error = error;
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
return manager;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function request(manager, method, path, body) {
|
|
105
|
+
const res = makeRes();
|
|
106
|
+
await handle(manager, PREFIX, makeReq(method, PREFIX + path, body), res);
|
|
107
|
+
return res;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// state
|
|
111
|
+
{
|
|
112
|
+
const manager = makeManager();
|
|
113
|
+
const res = await request(manager, 'GET', '/state');
|
|
114
|
+
assert.equal(res.statusCode, 200);
|
|
115
|
+
assert.equal(res.headers['content-type'], 'application/json; charset=utf-8');
|
|
116
|
+
assert.equal(res.json.ok, true);
|
|
117
|
+
assert.equal(res.json.state.startedAt, 12);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// live picture stream: binary framing, then frames pushed through subscribeFrames
|
|
121
|
+
{
|
|
122
|
+
const manager = makeManager();
|
|
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');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// no multipart: the panel parses frames itself
|
|
144
|
+
{
|
|
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);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// start with and without a URL
|
|
158
|
+
{
|
|
159
|
+
const manager = makeManager();
|
|
160
|
+
const started = await request(manager, 'POST', '/start', { url: 'example.com' });
|
|
161
|
+
assert.equal(started.statusCode, 200);
|
|
162
|
+
assert.equal(manager.calls.open, 'example.com');
|
|
163
|
+
assert.equal(manager.calls.refreshed, true);
|
|
164
|
+
|
|
165
|
+
const plain = await request(manager, 'POST', '/start', {});
|
|
166
|
+
assert.equal(plain.statusCode, 200);
|
|
167
|
+
assert.equal(manager.calls.started, true);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// one input batch, one round trip
|
|
171
|
+
{
|
|
172
|
+
const manager = makeManager();
|
|
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);
|
|
184
|
+
|
|
185
|
+
const nav = await request(manager, 'POST', '/nav', { action: 'back' });
|
|
186
|
+
assert.equal(nav.statusCode, 200);
|
|
187
|
+
assert.deepEqual(manager.calls.act, { action: 'back' });
|
|
188
|
+
|
|
189
|
+
const stop = await request(manager, 'POST', '/stop', {});
|
|
190
|
+
assert.equal(stop.statusCode, 200);
|
|
191
|
+
assert.equal(manager.calls.closed, true);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// failures
|
|
195
|
+
{
|
|
196
|
+
const manager = makeManager();
|
|
197
|
+
const badNav = await request(manager, 'POST', '/nav', { action: 'teleport' });
|
|
198
|
+
assert.equal(badNav.statusCode, 400);
|
|
199
|
+
assert.equal(badNav.json.error.code, 'BAD_ACTION');
|
|
200
|
+
|
|
201
|
+
const unknown = await request(manager, 'GET', '/nope');
|
|
202
|
+
assert.equal(unknown.statusCode, 404);
|
|
203
|
+
assert.equal(unknown.json.error.code, 'NOT_FOUND');
|
|
204
|
+
|
|
205
|
+
const res = makeRes();
|
|
206
|
+
const badReq = {
|
|
207
|
+
method: 'POST',
|
|
208
|
+
url: PREFIX + '/input',
|
|
209
|
+
async *[Symbol.asyncIterator]() {
|
|
210
|
+
yield Buffer.from('{not json');
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
await handle(manager, PREFIX, badReq, res);
|
|
214
|
+
assert.equal(res.statusCode, 400);
|
|
215
|
+
assert.equal(res.json.error.code, 'BAD_JSON');
|
|
216
|
+
|
|
217
|
+
const toobig = makeRes();
|
|
218
|
+
const huge = {
|
|
219
|
+
method: 'POST',
|
|
220
|
+
url: PREFIX + '/input',
|
|
221
|
+
async *[Symbol.asyncIterator]() {
|
|
222
|
+
yield Buffer.alloc(200 * 1024, 0x20);
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
await handle(manager, PREFIX, huge, toobig);
|
|
226
|
+
assert.equal(toobig.statusCode, 413);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// manager failures keep their code and status
|
|
230
|
+
{
|
|
231
|
+
const manager = makeManager();
|
|
232
|
+
manager.act = async () => {
|
|
233
|
+
throw new BrowserError('TARGET_NOT_FOUND', 'no such element');
|
|
234
|
+
};
|
|
235
|
+
const res = await request(manager, 'POST', '/nav', { action: 'reload' });
|
|
236
|
+
assert.equal(res.statusCode, 409);
|
|
237
|
+
assert.equal(res.json.error.code, 'TARGET_NOT_FOUND');
|
|
238
|
+
assert.ok(manager.calls.error instanceof BrowserError, 'the failure is remembered for the panel');
|
|
239
|
+
|
|
240
|
+
manager.act = async () => {
|
|
241
|
+
throw new BrowserError('NOT_RUNNING', 'browser is not running');
|
|
242
|
+
};
|
|
243
|
+
const offline = await request(manager, 'POST', '/nav', { action: 'reload' });
|
|
244
|
+
assert.equal(offline.statusCode, 500);
|
|
245
|
+
assert.equal(offline.json.error.code, 'NOT_RUNNING');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
console.log('routes.test.mjs ok');
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {
|
|
3
|
+
prepareTypingExpression,
|
|
4
|
+
readTextExpression,
|
|
5
|
+
resolveExpression,
|
|
6
|
+
selectOptionExpression,
|
|
7
|
+
snapshotExpression,
|
|
8
|
+
} from '../lib/page-scripts.js';
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// A deliberately small DOM double: enough of the browser surface that the
|
|
12
|
+
// serialized page scripts run for real and their output can be asserted.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
function makeElement(options = {}) {
|
|
16
|
+
const attributes = new Map(Object.entries(options.attrs || {}));
|
|
17
|
+
const rect = { x: 0, y: 0, width: 120, height: 24, ...(options.rect || {}) };
|
|
18
|
+
const element = {
|
|
19
|
+
tagName: String(options.tag || 'div').toUpperCase(),
|
|
20
|
+
type: options.type,
|
|
21
|
+
value: options.value,
|
|
22
|
+
checked: options.checked,
|
|
23
|
+
disabled: options.disabled,
|
|
24
|
+
readOnly: false,
|
|
25
|
+
required: false,
|
|
26
|
+
isContentEditable: options.editable === true,
|
|
27
|
+
labels: [],
|
|
28
|
+
innerText: options.text || '',
|
|
29
|
+
textContent: options.text || '',
|
|
30
|
+
options: options.options || [],
|
|
31
|
+
selectedOptions: options.selectedOptions || [],
|
|
32
|
+
getAttribute(name) {
|
|
33
|
+
return attributes.has(name) ? attributes.get(name) : null;
|
|
34
|
+
},
|
|
35
|
+
setAttribute(name, value) {
|
|
36
|
+
attributes.set(name, String(value));
|
|
37
|
+
},
|
|
38
|
+
removeAttribute(name) {
|
|
39
|
+
attributes.delete(name);
|
|
40
|
+
},
|
|
41
|
+
hasAttribute(name) {
|
|
42
|
+
return attributes.has(name);
|
|
43
|
+
},
|
|
44
|
+
getBoundingClientRect() {
|
|
45
|
+
return {
|
|
46
|
+
left: rect.x,
|
|
47
|
+
top: rect.y,
|
|
48
|
+
width: rect.width,
|
|
49
|
+
height: rect.height,
|
|
50
|
+
right: rect.x + rect.width,
|
|
51
|
+
bottom: rect.y + rect.height,
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
checkVisibility() {
|
|
55
|
+
return options.visible !== false;
|
|
56
|
+
},
|
|
57
|
+
scrollIntoView() {},
|
|
58
|
+
focus() {
|
|
59
|
+
document.activeElement = element;
|
|
60
|
+
},
|
|
61
|
+
setSelectionRange() {},
|
|
62
|
+
dispatchEvent() {
|
|
63
|
+
return true;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
return element;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const phone = makeElement({ tag: 'input', type: 'text', attrs: { placeholder: '手机号' }, value: '13800000000', rect: { x: 10, y: 10, width: 200, height: 32 } });
|
|
70
|
+
const password = makeElement({ tag: 'input', type: 'password', attrs: { 'aria-label': '密码' }, value: 'secret' });
|
|
71
|
+
const submit = makeElement({ tag: 'button', text: '登录', rect: { x: 10, y: 100, width: 80, height: 32 } });
|
|
72
|
+
const hiddenButton = makeElement({ tag: 'button', text: '隐藏按钮', visible: false });
|
|
73
|
+
const checkbox = makeElement({ tag: 'input', type: 'checkbox', checked: true, attrs: { 'aria-label': '记住我' } });
|
|
74
|
+
const select = makeElement({
|
|
75
|
+
tag: 'select',
|
|
76
|
+
options: [
|
|
77
|
+
{ value: 'a', text: '选项一', textContent: '选项一' },
|
|
78
|
+
{ value: 'b', text: '选项二', textContent: '选项二' },
|
|
79
|
+
],
|
|
80
|
+
selectedOptions: [{ text: '选项一', textContent: '选项一' }],
|
|
81
|
+
});
|
|
82
|
+
const link = makeElement({ tag: 'a', text: '忘记密码', attrs: { href: '/forgot' } });
|
|
83
|
+
|
|
84
|
+
const heading = { tagName: 'H1', innerText: '用户登录', textContent: '用户登录' };
|
|
85
|
+
const elements = [phone, password, submit, hiddenButton, checkbox, select, link];
|
|
86
|
+
|
|
87
|
+
const document = {
|
|
88
|
+
title: '登录 - Demo',
|
|
89
|
+
readyState: 'complete',
|
|
90
|
+
activeElement: null,
|
|
91
|
+
body: { innerText: '用户登录 手机号 密码 登录 忘记密码', scrollHeight: 2400 },
|
|
92
|
+
documentElement: { innerText: '用户登录 手机号 密码 登录 忘记密码', scrollHeight: 2400 },
|
|
93
|
+
querySelectorAll(selector) {
|
|
94
|
+
if (selector === '[data-dsh-vb-ref]') return elements.filter((element) => element.hasAttribute('data-dsh-vb-ref'));
|
|
95
|
+
if (selector === 'h1,h2,h3') return [heading];
|
|
96
|
+
if (selector === 'iframe') return [];
|
|
97
|
+
return elements;
|
|
98
|
+
},
|
|
99
|
+
querySelector(selector) {
|
|
100
|
+
const match = /^\[data-dsh-vb-ref="(\d+)"\]$/.exec(selector);
|
|
101
|
+
if (match) return elements.find((element) => element.getAttribute('data-dsh-vb-ref') === match[1]) || null;
|
|
102
|
+
return null;
|
|
103
|
+
},
|
|
104
|
+
getElementById() {
|
|
105
|
+
return null;
|
|
106
|
+
},
|
|
107
|
+
querySelectorAllCount: 0,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const window = {
|
|
111
|
+
innerWidth: 1280,
|
|
112
|
+
innerHeight: 800,
|
|
113
|
+
scrollY: 0,
|
|
114
|
+
getComputedStyle() {
|
|
115
|
+
return { visibility: 'visible', display: 'block', opacity: '1' };
|
|
116
|
+
},
|
|
117
|
+
getSelection() {
|
|
118
|
+
return { removeAllRanges() {}, addRange() {} };
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const location = { href: 'https://demo.test/login' };
|
|
123
|
+
|
|
124
|
+
function run(expression, extra = {}) {
|
|
125
|
+
const factory = new Function(
|
|
126
|
+
'document',
|
|
127
|
+
'window',
|
|
128
|
+
'location',
|
|
129
|
+
'HTMLInputElement',
|
|
130
|
+
'HTMLTextAreaElement',
|
|
131
|
+
'Event',
|
|
132
|
+
`return ${expression}`,
|
|
133
|
+
);
|
|
134
|
+
return factory(document, window, location, { prototype: {} }, { prototype: {} }, function Event() {}, ...(extra.args || []));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// snapshot
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
const snapshot = run(snapshotExpression({ maxElements: 50, maxNameChars: 20, maxTextChars: 40 }));
|
|
142
|
+
assert.equal(snapshot.url, 'https://demo.test/login');
|
|
143
|
+
assert.equal(snapshot.title, '登录 - Demo');
|
|
144
|
+
assert.equal(snapshot.viewport.width, 1280);
|
|
145
|
+
assert.equal(snapshot.docHeight, 2400);
|
|
146
|
+
assert.equal(snapshot.readyState, 'complete');
|
|
147
|
+
assert.ok(!snapshot.elements.some((element) => element.name === '隐藏按钮'), 'invisible element excluded');
|
|
148
|
+
assert.equal(snapshot.total, 6);
|
|
149
|
+
assert.equal(snapshot.elements.length, 6);
|
|
150
|
+
assert.deepEqual(
|
|
151
|
+
snapshot.elements.map((element) => element.role),
|
|
152
|
+
['textbox', 'textbox', 'button', 'checkbox', 'combobox', 'link'],
|
|
153
|
+
);
|
|
154
|
+
assert.equal(snapshot.elements[0].name, '手机号');
|
|
155
|
+
assert.equal(snapshot.elements[0].value, '13800000000');
|
|
156
|
+
assert.equal(snapshot.elements[1].value, '••••••', 'password value is masked');
|
|
157
|
+
assert.equal(snapshot.elements[2].ref, 3);
|
|
158
|
+
assert.deepEqual(snapshot.elements[3].state, ['checked']);
|
|
159
|
+
assert.equal(snapshot.elements[4].value, '选项一', 'select shows its selected option');
|
|
160
|
+
assert.equal(snapshot.elements[5].href, '/forgot');
|
|
161
|
+
assert.deepEqual(snapshot.headings, ['用户登录']);
|
|
162
|
+
assert.ok(snapshot.text.length <= 40);
|
|
163
|
+
assert.equal(snapshot.frames, 0);
|
|
164
|
+
assert.equal(snapshot.truncated, false);
|
|
165
|
+
assert.equal(phone.getAttribute('data-dsh-vb-ref'), '1');
|
|
166
|
+
assert.equal(link.getAttribute('data-dsh-vb-ref'), '6');
|
|
167
|
+
|
|
168
|
+
// A second snapshot clears old refs before renumbering.
|
|
169
|
+
const again = run(snapshotExpression({ maxElements: 2 }));
|
|
170
|
+
assert.equal(again.elements.length, 2);
|
|
171
|
+
assert.equal(again.truncated, true);
|
|
172
|
+
assert.equal(again.total, 6);
|
|
173
|
+
assert.equal(link.getAttribute('data-dsh-vb-ref'), null, 'stale refs are cleared');
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// resolve / typing / select / text
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
// Restore a full numbered view: the clipped snapshot above cleared the refs.
|
|
180
|
+
run(snapshotExpression({ maxElements: 50 }));
|
|
181
|
+
const resolved = run(resolveExpression({ ref: 3 }));
|
|
182
|
+
assert.equal(resolved.ok, true);
|
|
183
|
+
assert.equal(resolved.name, '登录');
|
|
184
|
+
assert.equal(resolved.x, 50);
|
|
185
|
+
assert.equal(resolved.y, 116);
|
|
186
|
+
|
|
187
|
+
const stale = run(resolveExpression({ ref: 99 }));
|
|
188
|
+
assert.deepEqual(stale, { ok: false, reason: 'stale-ref' });
|
|
189
|
+
|
|
190
|
+
const focused = run(prepareTypingExpression({ ref: 3, mode: 'replace' }));
|
|
191
|
+
assert.equal(focused.ok, true);
|
|
192
|
+
assert.equal(focused.tag, 'button');
|
|
193
|
+
assert.equal(document.activeElement, submit);
|
|
194
|
+
|
|
195
|
+
const picked = run(selectOptionExpression({ ref: 5, value: 'b' }));
|
|
196
|
+
assert.equal(picked.ok, true);
|
|
197
|
+
assert.equal(picked.text, '选项二');
|
|
198
|
+
assert.equal(select.value, 'b');
|
|
199
|
+
|
|
200
|
+
const pageText = run(readTextExpression({ maxChars: 30 }));
|
|
201
|
+
assert.equal(pageText.ok, true);
|
|
202
|
+
assert.equal(pageText.kind, 'page');
|
|
203
|
+
assert.ok(pageText.text.length <= 30);
|
|
204
|
+
assert.equal(pageText.url, 'https://demo.test/login');
|
|
205
|
+
|
|
206
|
+
const linkText = run(readTextExpression({ ref: 6 }));
|
|
207
|
+
assert.equal(linkText.ok, true);
|
|
208
|
+
assert.equal(linkText.kind, 'element');
|
|
209
|
+
assert.equal(linkText.href, '/forgot');
|
|
210
|
+
|
|
211
|
+
const missing = run(readTextExpression({ ref: 42 }));
|
|
212
|
+
assert.deepEqual(missing, { ok: false, reason: 'stale-ref' });
|
|
213
|
+
|
|
214
|
+
console.log('scripts.test.mjs ok');
|