@restregular/dsh-visual-browser 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +129 -3
- package/client.js +835 -0
- package/cordis.patch.yml +17 -0
- package/icon.svg +21 -0
- package/index.js +43 -0
- package/lib/browser.js +1045 -0
- package/lib/cdp.js +208 -0
- package/lib/format.js +87 -0
- package/lib/page-scripts.js +528 -0
- package/lib/routes.js +201 -0
- package/lib/tools.js +168 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +70 -4
- package/test/format.test.mjs +62 -0
- package/test/package.test.mjs +56 -0
- package/test/routes.test.mjs +207 -0
- package/test/scripts.test.mjs +214 -0
|
@@ -0,0 +1,207 @@
|
|
|
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
|
+
return {
|
|
9
|
+
statusCode: 200,
|
|
10
|
+
headers: {},
|
|
11
|
+
chunks: [],
|
|
12
|
+
ended: false,
|
|
13
|
+
setHeader(name, value) {
|
|
14
|
+
this.headers[String(name).toLowerCase()] = value;
|
|
15
|
+
},
|
|
16
|
+
end(data) {
|
|
17
|
+
if (data !== undefined) this.chunks.push(Buffer.isBuffer(data) ? data : Buffer.from(String(data)));
|
|
18
|
+
this.ended = true;
|
|
19
|
+
},
|
|
20
|
+
get body() {
|
|
21
|
+
return Buffer.concat(this.chunks).toString('utf8');
|
|
22
|
+
},
|
|
23
|
+
get json() {
|
|
24
|
+
return JSON.parse(this.body);
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function makeReq(method, url, body) {
|
|
30
|
+
if (body === undefined) return { method, url };
|
|
31
|
+
return {
|
|
32
|
+
method,
|
|
33
|
+
url,
|
|
34
|
+
async *[Symbol.asyncIterator]() {
|
|
35
|
+
yield Buffer.from(JSON.stringify(body));
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function makeManager() {
|
|
41
|
+
const manager = {
|
|
42
|
+
calls: {},
|
|
43
|
+
state() {
|
|
44
|
+
return { running: true, url: 'https://demo.test/', frameSeq: 3, actions: [] };
|
|
45
|
+
},
|
|
46
|
+
frameFor(seq) {
|
|
47
|
+
return Number(seq) === 3 ? { seq: 3, jpeg: Buffer.from('jpeg-bytes') } : null;
|
|
48
|
+
},
|
|
49
|
+
async refreshFrame() {
|
|
50
|
+
return this.state();
|
|
51
|
+
},
|
|
52
|
+
async open(url) {
|
|
53
|
+
this.calls.open = url;
|
|
54
|
+
},
|
|
55
|
+
async ensureStarted() {
|
|
56
|
+
this.calls.started = true;
|
|
57
|
+
},
|
|
58
|
+
async close() {
|
|
59
|
+
this.calls.closed = true;
|
|
60
|
+
},
|
|
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
|
+
async act(input) {
|
|
71
|
+
this.calls.act = input;
|
|
72
|
+
},
|
|
73
|
+
noteError(error) {
|
|
74
|
+
this.calls.error = error;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
return manager;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function request(manager, method, path, body) {
|
|
81
|
+
const res = makeRes();
|
|
82
|
+
await handle(manager, PREFIX, makeReq(method, PREFIX + path, body), res);
|
|
83
|
+
return res;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// state
|
|
87
|
+
{
|
|
88
|
+
const manager = makeManager();
|
|
89
|
+
const res = await request(manager, 'GET', '/state');
|
|
90
|
+
assert.equal(res.statusCode, 200);
|
|
91
|
+
assert.equal(res.headers['content-type'], 'application/json; charset=utf-8');
|
|
92
|
+
assert.equal(res.json.ok, true);
|
|
93
|
+
assert.equal(res.json.state.frameSeq, 3);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// frame hit and miss
|
|
97
|
+
{
|
|
98
|
+
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);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// live capture
|
|
110
|
+
{
|
|
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');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// start with and without a URL
|
|
118
|
+
{
|
|
119
|
+
const manager = makeManager();
|
|
120
|
+
const started = await request(manager, 'POST', '/start', { url: 'example.com' });
|
|
121
|
+
assert.equal(started.statusCode, 200);
|
|
122
|
+
assert.equal(manager.calls.open, 'example.com');
|
|
123
|
+
|
|
124
|
+
const plain = await request(manager, 'POST', '/start', {});
|
|
125
|
+
assert.equal(plain.statusCode, 200);
|
|
126
|
+
assert.equal(manager.calls.started, true);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// user interactions
|
|
130
|
+
{
|
|
131
|
+
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');
|
|
143
|
+
|
|
144
|
+
const nav = await request(manager, 'POST', '/nav', { action: 'back' });
|
|
145
|
+
assert.equal(nav.statusCode, 200);
|
|
146
|
+
assert.deepEqual(manager.calls.act, { action: 'back' });
|
|
147
|
+
|
|
148
|
+
const stop = await request(manager, 'POST', '/stop', {});
|
|
149
|
+
assert.equal(stop.statusCode, 200);
|
|
150
|
+
assert.equal(manager.calls.closed, true);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// failures
|
|
154
|
+
{
|
|
155
|
+
const manager = makeManager();
|
|
156
|
+
const badNav = await request(manager, 'POST', '/nav', { action: 'teleport' });
|
|
157
|
+
assert.equal(badNav.statusCode, 400);
|
|
158
|
+
assert.equal(badNav.json.error.code, 'BAD_ACTION');
|
|
159
|
+
|
|
160
|
+
const unknown = await request(manager, 'GET', '/nope');
|
|
161
|
+
assert.equal(unknown.statusCode, 404);
|
|
162
|
+
assert.equal(unknown.json.error.code, 'NOT_FOUND');
|
|
163
|
+
|
|
164
|
+
const res = makeRes();
|
|
165
|
+
const badReq = {
|
|
166
|
+
method: 'POST',
|
|
167
|
+
url: PREFIX + '/click',
|
|
168
|
+
async *[Symbol.asyncIterator]() {
|
|
169
|
+
yield Buffer.from('{not json');
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
await handle(manager, PREFIX, badReq, res);
|
|
173
|
+
assert.equal(res.statusCode, 400);
|
|
174
|
+
assert.equal(res.json.error.code, 'BAD_JSON');
|
|
175
|
+
|
|
176
|
+
const toobig = makeRes();
|
|
177
|
+
const huge = {
|
|
178
|
+
method: 'POST',
|
|
179
|
+
url: PREFIX + '/click',
|
|
180
|
+
async *[Symbol.asyncIterator]() {
|
|
181
|
+
yield Buffer.alloc(70 * 1024, 0x20);
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
await handle(manager, PREFIX, huge, toobig);
|
|
185
|
+
assert.equal(toobig.statusCode, 413);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// manager failures keep their code and status
|
|
189
|
+
{
|
|
190
|
+
const manager = makeManager();
|
|
191
|
+
manager.act = async () => {
|
|
192
|
+
throw new BrowserError('TARGET_NOT_FOUND', 'no such element');
|
|
193
|
+
};
|
|
194
|
+
const res = await request(manager, 'POST', '/nav', { action: 'reload' });
|
|
195
|
+
assert.equal(res.statusCode, 409);
|
|
196
|
+
assert.equal(res.json.error.code, 'TARGET_NOT_FOUND');
|
|
197
|
+
assert.ok(manager.calls.error instanceof BrowserError, 'the failure is remembered for the panel');
|
|
198
|
+
|
|
199
|
+
manager.act = async () => {
|
|
200
|
+
throw new BrowserError('NOT_RUNNING', 'browser is not running');
|
|
201
|
+
};
|
|
202
|
+
const offline = await request(manager, 'POST', '/nav', { action: 'reload' });
|
|
203
|
+
assert.equal(offline.statusCode, 500);
|
|
204
|
+
assert.equal(offline.json.error.code, 'NOT_RUNNING');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
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');
|