@restregular/dsh-visual-browser 0.1.0 → 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/lib/cdp.js CHANGED
@@ -1,192 +1,208 @@
1
- /**
2
- * Minimal Chrome DevTools Protocol client.
3
- *
4
- * The plugin ships without dependencies: Node.js 22+ provides a WHATWG
5
- * `WebSocket`, which is all a flattened CDP session needs. Every command is a
6
- * JSON message with an `id`; flattened target sessions add `sessionId` to the
7
- * command and echo it on the matching response.
8
- */
9
-
10
- /** A CDP failure with a stable code so tools and routes can report a reason. */
11
- export class CdpError extends Error {
12
- constructor(code, message) {
13
- super(message);
14
- this.name = 'CdpError';
15
- this.code = code;
16
- }
17
- }
18
-
19
- const OPEN_TIMEOUT_MS = 15_000;
20
- const COMMAND_TIMEOUT_MS = 30_000;
21
-
22
- export class CdpConnection {
23
- #socket;
24
- #nextId = 1;
25
- #pending = new Map();
26
- #listeners = new Map();
27
- #closed = false;
28
-
29
- constructor(socket) {
30
- this.#socket = socket;
31
- socket.addEventListener('message', (event) => this.#onMessage(event.data));
32
- socket.addEventListener('close', () => this.#failAll(new CdpError('CDP_CLOSED', 'the DevTools connection closed')));
33
- socket.addEventListener('error', () => {
34
- /* The close event carries the outcome; keep this listener quiet. */
35
- });
36
- }
37
-
38
- /** Connect to a browser-level or page-level WebSocket endpoint. */
39
- static async open(url, { timeoutMs = OPEN_TIMEOUT_MS } = {}) {
40
- const Socket = globalThis.WebSocket;
41
- if (typeof Socket !== 'function') {
42
- throw new CdpError('CDP_UNAVAILABLE', 'this Node.js runtime has no built-in WebSocket (Node 22 or newer is required)');
43
- }
44
- const socket = new Socket(url);
45
- await new Promise((resolve, reject) => {
46
- const timer = setTimeout(() => {
47
- cleanup();
48
- try {
49
- socket.close();
50
- } catch {
51
- /* already unusable */
52
- }
53
- reject(new CdpError('CDP_TIMEOUT', `timed out connecting to ${url}`));
54
- }, timeoutMs);
55
- const cleanup = () => {
56
- clearTimeout(timer);
57
- socket.removeEventListener('open', onOpen);
58
- socket.removeEventListener('error', onError);
59
- };
60
- const onOpen = () => {
61
- cleanup();
62
- resolve();
63
- };
64
- const onError = () => {
65
- cleanup();
66
- reject(new CdpError('CDP_CONNECT_FAILED', `could not connect to ${url}`));
67
- };
68
- socket.addEventListener('open', onOpen);
69
- socket.addEventListener('error', onError);
70
- });
71
- return new CdpConnection(socket);
72
- }
73
-
74
- get closed() {
75
- return this.#closed;
76
- }
77
-
78
- /** Send one protocol command; `sessionId` targets a flattened session. */
79
- send(method, params = {}, sessionId) {
80
- if (this.#closed) return Promise.reject(new CdpError('CDP_CLOSED', 'the DevTools connection closed'));
81
- const id = this.#nextId++;
82
- const payload = { id, method, params };
83
- if (sessionId !== undefined) payload.sessionId = sessionId;
84
- return new Promise((resolve, reject) => {
85
- const timer = setTimeout(() => {
86
- this.#pending.delete(id);
87
- reject(new CdpError('CDP_COMMAND_TIMEOUT', `${method} did not answer within ${COMMAND_TIMEOUT_MS}ms`));
88
- }, COMMAND_TIMEOUT_MS);
89
- this.#pending.set(id, { resolve, reject, method, timer });
90
- try {
91
- this.#socket.send(JSON.stringify(payload));
92
- } catch (error) {
93
- clearTimeout(timer);
94
- this.#pending.delete(id);
95
- reject(new CdpError('CDP_SEND_FAILED', messageOf(error)));
96
- }
97
- });
98
- }
99
-
100
- /** Subscribe to one protocol event; the returned function unsubscribes. */
101
- on(method, listener) {
102
- const bucket = this.#listeners.get(method) ?? new Set();
103
- bucket.add(listener);
104
- this.#listeners.set(method, bucket);
105
- return () => {
106
- bucket.delete(listener);
107
- if (bucket.size === 0) this.#listeners.delete(method);
108
- };
109
- }
110
-
111
- /** Resolve on the next `method` event, or reject on timeout. */
112
- once(method, timeoutMs) {
113
- return new Promise((resolve, reject) => {
114
- const timer = setTimeout(() => {
115
- off();
116
- reject(new CdpError('CDP_EVENT_TIMEOUT', `waited ${timeoutMs}ms for ${method}`));
117
- }, timeoutMs);
118
- const off = this.on(method, (params, sessionId) => {
119
- clearTimeout(timer);
120
- off();
121
- resolve({ params, sessionId });
122
- });
123
- });
124
- }
125
-
126
- close() {
127
- if (this.#closed) return;
128
- this.#failAll(new CdpError('CDP_CLOSED', 'the DevTools connection closed'));
129
- try {
130
- this.#socket.close();
131
- } catch {
132
- /* already closed */
133
- }
134
- }
135
-
136
- #onMessage(raw) {
137
- let message;
138
- try {
139
- message = JSON.parse(typeof raw === 'string' ? raw : String(raw));
140
- } catch {
141
- return;
142
- }
143
- if (message && typeof message.id === 'number') {
144
- const pending = this.#pending.get(message.id);
145
- if (pending === undefined) return;
146
- this.#pending.delete(message.id);
147
- clearTimeout(pending.timer);
148
- if (message.error) {
149
- pending.reject(
150
- new CdpError(
151
- `CDP_${String(message.error.code ?? 'ERROR')}`,
152
- `${pending.method}: ${message.error.message ?? 'protocol error'}`,
153
- ),
154
- );
155
- } else {
156
- pending.resolve(message.result);
157
- }
158
- return;
159
- }
160
- if (!message || typeof message.method !== 'string') return;
161
- const bucket = this.#listeners.get(message.method);
162
- if (bucket === undefined) return;
163
- for (const listener of [...bucket]) {
164
- try {
165
- listener(message.params, message.sessionId);
166
- } catch {
167
- /* a failing listener must not break the socket */
168
- }
169
- }
170
- }
171
-
172
- #failAll(error) {
173
- if (this.#closed) return;
174
- this.#closed = true;
175
- for (const [id, pending] of this.#pending) {
176
- clearTimeout(pending.timer);
177
- this.#pending.delete(id);
178
- pending.reject(error);
179
- }
180
- this.#listeners.clear();
181
- }
182
- }
183
-
184
- export function messageOf(error) {
185
- if (error instanceof Error) return error.message;
186
- if (typeof error === 'string') return error;
187
- try {
188
- return JSON.stringify(error);
189
- } catch {
190
- return String(error);
191
- }
192
- }
1
+ /**
2
+ * Minimal Chrome DevTools Protocol client.
3
+ *
4
+ * The plugin ships without dependencies: Node.js 22+ provides a WHATWG
5
+ * `WebSocket`, which is all a flattened CDP session needs. Every command is a
6
+ * JSON message with an `id`; flattened target sessions add `sessionId` to the
7
+ * command and echo it on the matching response.
8
+ */
9
+
10
+ /** A CDP failure with a stable code so tools and routes can report a reason. */
11
+ export class CdpError extends Error {
12
+ constructor(code, message) {
13
+ super(message);
14
+ this.name = 'CdpError';
15
+ this.code = code;
16
+ }
17
+ }
18
+
19
+ const OPEN_TIMEOUT_MS = 15_000;
20
+ const COMMAND_TIMEOUT_MS = 30_000;
21
+
22
+ export class CdpConnection {
23
+ #socket;
24
+ #nextId = 1;
25
+ #pending = new Map();
26
+ #listeners = new Map();
27
+ #closeListeners = new Set();
28
+ #closed = false;
29
+
30
+ constructor(socket) {
31
+ this.#socket = socket;
32
+ socket.addEventListener('message', (event) => this.#onMessage(event.data));
33
+ socket.addEventListener('close', () => this.#failAll(new CdpError('CDP_CLOSED', 'the DevTools connection closed')));
34
+ socket.addEventListener('error', () => {
35
+ /* The close event carries the outcome; keep this listener quiet. */
36
+ });
37
+ }
38
+
39
+ /** Connect to a browser-level or page-level WebSocket endpoint. */
40
+ static async open(url, { timeoutMs = OPEN_TIMEOUT_MS } = {}) {
41
+ const Socket = globalThis.WebSocket;
42
+ if (typeof Socket !== 'function') {
43
+ throw new CdpError('CDP_UNAVAILABLE', 'this Node.js runtime has no built-in WebSocket (Node 22 or newer is required)');
44
+ }
45
+ const socket = new Socket(url);
46
+ await new Promise((resolve, reject) => {
47
+ const timer = setTimeout(() => {
48
+ cleanup();
49
+ try {
50
+ socket.close();
51
+ } catch {
52
+ /* already unusable */
53
+ }
54
+ reject(new CdpError('CDP_TIMEOUT', `timed out connecting to ${url}`));
55
+ }, timeoutMs);
56
+ const cleanup = () => {
57
+ clearTimeout(timer);
58
+ socket.removeEventListener('open', onOpen);
59
+ socket.removeEventListener('error', onError);
60
+ };
61
+ const onOpen = () => {
62
+ cleanup();
63
+ resolve();
64
+ };
65
+ const onError = () => {
66
+ cleanup();
67
+ reject(new CdpError('CDP_CONNECT_FAILED', `could not connect to ${url}`));
68
+ };
69
+ socket.addEventListener('open', onOpen);
70
+ socket.addEventListener('error', onError);
71
+ });
72
+ return new CdpConnection(socket);
73
+ }
74
+
75
+ get closed() {
76
+ return this.#closed;
77
+ }
78
+
79
+ /** Send one protocol command; `sessionId` targets a flattened session. */
80
+ send(method, params = {}, sessionId) {
81
+ if (this.#closed) return Promise.reject(new CdpError('CDP_CLOSED', 'the DevTools connection closed'));
82
+ const id = this.#nextId++;
83
+ const payload = { id, method, params };
84
+ if (sessionId !== undefined) payload.sessionId = sessionId;
85
+ return new Promise((resolve, reject) => {
86
+ const timer = setTimeout(() => {
87
+ this.#pending.delete(id);
88
+ reject(new CdpError('CDP_COMMAND_TIMEOUT', `${method} did not answer within ${COMMAND_TIMEOUT_MS}ms`));
89
+ }, COMMAND_TIMEOUT_MS);
90
+ this.#pending.set(id, { resolve, reject, method, timer });
91
+ try {
92
+ this.#socket.send(JSON.stringify(payload));
93
+ } catch (error) {
94
+ clearTimeout(timer);
95
+ this.#pending.delete(id);
96
+ reject(new CdpError('CDP_SEND_FAILED', messageOf(error)));
97
+ }
98
+ });
99
+ }
100
+
101
+ /** Subscribe to one protocol event; the returned function unsubscribes. */
102
+ on(method, listener) {
103
+ const bucket = this.#listeners.get(method) ?? new Set();
104
+ bucket.add(listener);
105
+ this.#listeners.set(method, bucket);
106
+ return () => {
107
+ bucket.delete(listener);
108
+ if (bucket.size === 0) this.#listeners.delete(method);
109
+ };
110
+ }
111
+
112
+ /** Resolve on the next `method` event, or reject on timeout. */
113
+ once(method, timeoutMs) {
114
+ return new Promise((resolve, reject) => {
115
+ const timer = setTimeout(() => {
116
+ off();
117
+ reject(new CdpError('CDP_EVENT_TIMEOUT', `waited ${timeoutMs}ms for ${method}`));
118
+ }, timeoutMs);
119
+ const off = this.on(method, (params, sessionId) => {
120
+ clearTimeout(timer);
121
+ off();
122
+ resolve({ params, sessionId });
123
+ });
124
+ });
125
+ }
126
+
127
+ /** Notified once with the failure that closed the socket. */
128
+ onClose(listener) {
129
+ this.#closeListeners.add(listener);
130
+ return () => this.#closeListeners.delete(listener);
131
+ }
132
+
133
+ close() {
134
+ if (this.#closed) return;
135
+ this.#failAll(new CdpError('CDP_CLOSED', 'the DevTools connection closed'));
136
+ try {
137
+ this.#socket.close();
138
+ } catch {
139
+ /* already closed */
140
+ }
141
+ }
142
+
143
+ #onMessage(raw) {
144
+ let message;
145
+ try {
146
+ message = JSON.parse(typeof raw === 'string' ? raw : String(raw));
147
+ } catch {
148
+ return;
149
+ }
150
+ if (message && typeof message.id === 'number') {
151
+ const pending = this.#pending.get(message.id);
152
+ if (pending === undefined) return;
153
+ this.#pending.delete(message.id);
154
+ clearTimeout(pending.timer);
155
+ if (message.error) {
156
+ pending.reject(
157
+ new CdpError(
158
+ `CDP_${String(message.error.code ?? 'ERROR')}`,
159
+ `${pending.method}: ${message.error.message ?? 'protocol error'}`,
160
+ ),
161
+ );
162
+ } else {
163
+ pending.resolve(message.result);
164
+ }
165
+ return;
166
+ }
167
+ if (!message || typeof message.method !== 'string') return;
168
+ const bucket = this.#listeners.get(message.method);
169
+ if (bucket === undefined) return;
170
+ for (const listener of [...bucket]) {
171
+ try {
172
+ listener(message.params, message.sessionId);
173
+ } catch {
174
+ /* a failing listener must not break the socket */
175
+ }
176
+ }
177
+ }
178
+
179
+ #failAll(error) {
180
+ if (this.#closed) return;
181
+ this.#closed = true;
182
+ for (const [id, pending] of this.#pending) {
183
+ clearTimeout(pending.timer);
184
+ this.#pending.delete(id);
185
+ pending.reject(error);
186
+ }
187
+ this.#listeners.clear();
188
+ const closeListeners = [...this.#closeListeners];
189
+ this.#closeListeners.clear();
190
+ for (const listener of closeListeners) {
191
+ try {
192
+ listener(error);
193
+ } catch {
194
+ /* a close listener must not block teardown */
195
+ }
196
+ }
197
+ }
198
+ }
199
+
200
+ export function messageOf(error) {
201
+ if (error instanceof Error) return error.message;
202
+ if (typeof error === 'string') return error;
203
+ try {
204
+ return JSON.stringify(error);
205
+ } catch {
206
+ return String(error);
207
+ }
208
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@restregular/dsh-visual-browser",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
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.",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",
@@ -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": {
@@ -66,6 +67,6 @@
66
67
  "license": "MIT",
67
68
  "author": "RestRegular",
68
69
  "scripts": {
69
- "test": "node --check index.js && node --check client.js && node --check lib/browser.js && node test/snapshot.test.mjs && node test/scripts.test.mjs && node test/package.test.mjs"
70
+ "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
71
  }
71
72
  }
@@ -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,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.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');