dsh-agent-team-ui-testing 0.0.0-stage → 0.1.2

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.
Files changed (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +327 -2
  3. package/annotation-capture.js +111 -0
  4. package/banner.png +0 -0
  5. package/banner_zh.png +0 -0
  6. package/browser-safety.js +293 -0
  7. package/capture-p0.js +910 -0
  8. package/client.js +11302 -0
  9. package/context-menu.js +134 -0
  10. package/cordis.patch.yml +3 -0
  11. package/core/clock.js +88 -0
  12. package/core/config-store.js +566 -0
  13. package/core/config.js +892 -0
  14. package/core/contracts.js +240 -0
  15. package/core/errors.js +156 -0
  16. package/core/evidence.js +141 -0
  17. package/core/geometry.js +217 -0
  18. package/core/identity.js +180 -0
  19. package/core/index.js +24 -0
  20. package/core/p0.js +1101 -0
  21. package/core/readiness.js +175 -0
  22. package/core/registry.js +239 -0
  23. package/core/tab-access.js +1350 -0
  24. package/docs/adapters.md +160 -0
  25. package/docs/architecture.md +146 -0
  26. package/docs/capability-tiers.md +80 -0
  27. package/docs/capture-p0.md +90 -0
  28. package/docs/diagnose-reason-codes.md +157 -0
  29. package/docs/element-picker.md +143 -0
  30. package/docs/getting-started.md +136 -0
  31. package/docs/index.md +36 -0
  32. package/docs/multi-tab.md +162 -0
  33. package/docs/network-p0.md +117 -0
  34. package/docs/panel-context-menu.md +41 -0
  35. package/docs/t2-structured-gui.md +68 -0
  36. package/docs/t3-vision.md +84 -0
  37. package/docs/t4-dynamic-behavior.md +135 -0
  38. package/docs/tools.md +174 -0
  39. package/docs/verification.md +131 -0
  40. package/docs/viewport-control.md +94 -0
  41. package/dynamic-t4.js +5699 -0
  42. package/frame-push.js +508 -0
  43. package/frame-source.js +162 -0
  44. package/icon.png +0 -0
  45. package/index.js +23028 -0
  46. package/integration/cross-environment.js +1535 -0
  47. package/integration/diagnose.js +945 -0
  48. package/integration/tab-principal.js +493 -0
  49. package/locale/en.json +6 -0
  50. package/locale/zh.json +6 -0
  51. package/network-p0.js +4264 -0
  52. package/package.json +19 -4
  53. package/select-control.js +298 -0
  54. package/skills/ui-testing-common/SKILL.md +54 -0
  55. package/skills/ui-testing-fake/SKILL.md +43 -0
  56. package/skills/ui-testing-web/SKILL.md +520 -0
  57. package/skills/ui-testing-web/manifests/declared-capabilities.json +903 -0
  58. package/skills/ui-testing-web/references/contracts/capability.md +68 -0
  59. package/skills/ui-testing-web/references/contracts/evidence.md +41 -0
  60. package/skills/ui-testing-web/references/contracts/operation.md +44 -0
  61. package/skills/ui-testing-web/references/core/dynamic-visual.md +123 -0
  62. package/skills/ui-testing-web/references/core/fallback.md +83 -0
  63. package/skills/ui-testing-web/references/core/network-and-state.md +114 -0
  64. package/skills/ui-testing-web/references/core/page-identity.md +91 -0
  65. package/skills/ui-testing-web/references/core/report-template.md +144 -0
  66. package/skills/ui-testing-web/references/core/reporting.md +92 -0
  67. package/skills/ui-testing-web/references/core/safety.md +12 -0
  68. package/skills/ui-testing-web/references/core/selection.md +26 -0
  69. package/skills/ui-testing-web/references/core/tool-routing-and-limits.md +198 -0
  70. package/skills/ui-testing-web/references/core/verdict-examples.md +96 -0
  71. package/skills/ui-testing-web/references/core/verdict-gate.md +105 -0
  72. package/skills/ui-testing-web/references/core/verification.md +19 -0
  73. package/skills/ui-testing-web/references/core/workflow.md +16 -0
  74. package/skills/ui-testing-web/references/platforms/index.md +19 -0
  75. package/skills/ui-testing-web/references/platforms/web.md +17 -0
  76. package/skills/ui-testing-web/references/scenarios/annotations.md +104 -0
  77. package/skills/ui-testing-web/references/scenarios/functional.md +205 -0
  78. package/skills/ui-testing-web/references/scenarios/visual-motion.md +170 -0
  79. package/skills/ui-testing-web/references/tiers/t1-programmatic.md +25 -0
  80. package/skills/ui-testing-web/references/tiers/t2-structured.md +61 -0
  81. package/skills/ui-testing-web/references/tiers/t3-static-visual.md +45 -0
  82. package/skills/ui-testing-web/references/tiers/t4-dynamic-visual.md +58 -0
  83. package/skills/ui-testing-web/templates/capability-note.md +23 -0
  84. package/skills/ui-testing-web/templates/platform.md +30 -0
  85. package/skills/ui-testing-web/templates/regression-case.md +15 -0
  86. package/skills/ui-testing-web/templates/scenario.md +13 -0
  87. package/targets/fake/adapter.js +1163 -0
  88. package/targets/fake/fixtures.js +177 -0
  89. package/targets/web/adapter.js +1231 -0
  90. package/targets/web/provider.js +415 -0
  91. package/vision-baseline.js +1254 -0
  92. package/vision-t3.js +2657 -0
@@ -0,0 +1,293 @@
1
+ import { lookup } from 'node:dns/promises';
2
+ import { isIP } from 'node:net';
3
+
4
+ const TAXONOMY = {
5
+ NAV_FAILED: ['navigation', true], CDP_TIMEOUT: ['timeout', true],
6
+ CDP_DISCONNECTED: ['disconnected', true], TARGET_NOT_FOUND: ['action', false],
7
+ EVAL_ERROR: ['app', false], INVALID_ARGUMENT: ['input', false], BUSY: ['concurrency', true],
8
+ UNSUPPORTED: ['capability', false], INTERNAL: ['internal', false], ABORTED: ['cancelled', true],
9
+ // Headed mode without a window system is a capability refusal; it must never degrade to INTERNAL.
10
+ DISPLAY_UNAVAILABLE: ['capability', false],
11
+ // Independent HTTP client (docs/tools.md): HTTP timeouts are not CDP timeouts
12
+ // (they must never trigger the page "close the browser before releasing the queue" path).
13
+ HTTP_TIMEOUT: ['timeout', true], HTTP_FAILED: ['network', true], HTTP_REDIRECT_LIMIT: ['network', false],
14
+ // Cross-environment foundation (core/errors.js ERROR_TAXONOMY): adapter/config refusals are
15
+ // *classified* outcomes, so they must survive the tool boundary instead of degrading to INTERNAL.
16
+ // Without these entries a disabled environment or a stale config surfaced as `[INTERNAL]`.
17
+ ADAPTER_NOT_FOUND: ['environment', false], ADAPTER_UNSUPPORTED: ['capability', false],
18
+ ADAPTER_DISABLED: ['config', false], CONFIG_CONFLICT: ['config', true],
19
+ CONFIG_INVALID: ['config', false], CONFIG_STALE: ['config', true],
20
+ TARGET_NOT_BOUND: ['target', false], TARGET_GONE: ['target', true],
21
+ GENERATION_MISMATCH: ['state', false], CAPABILITY_UNSUPPORTED: ['capability', false],
22
+ CAPABILITY_UNAVAILABLE: ['environment', true], DEPENDENCY_MISSING: ['environment', false],
23
+ CONNECTION_UNTRUSTED: ['environment', true], PROBE_TIMEOUT: ['environment', true],
24
+ PROVIDER_UNAVAILABLE: ['environment', true], EVIDENCE_INCOMPLETE: ['evidence', false],
25
+ FORBIDDEN: ['capability', false],
26
+ // Workspace isolation (docs/architecture.md): these must never fall back to INTERNAL.
27
+ SCOPE_INVALID: ['input', false], SCOPE_NOT_FOUND: ['scope', false],
28
+ SCOPE_UNAVAILABLE: ['scope', true], SCOPE_FORBIDDEN: ['authorization', false],
29
+ RUNTIME_LIMIT: ['resource', true],
30
+ // Web T2 (structured GUI, docs/t2-structured-gui.md): a ref that no longer points at the node it
31
+ // was minted for, a selector that is not unique, a target that cannot be acted on, and a bounded
32
+ // wait that ran out. RETRYABLE only tells the caller a retry *may* help — a stale ref needs a new
33
+ // snapshot, not a replay.
34
+ REF_STALE: ['action', false], TARGET_NOT_UNIQUE: ['action', false],
35
+ NOT_INTERACTABLE: ['action', false], WAIT_TIMEOUT: ['timeout', true],
36
+ // Shared viewport (docs/viewport-control.md, Lead freeze 2026-11-01): an optimistic
37
+ // concurrency failure, not a bad argument and not a page failure. `state` + retryable=true means
38
+ // "read the current view and decide again"; the patch was NOT applied and page lastError is kept.
39
+ VIEWPORT_CONFLICT: ['state', true],
40
+ // P0 annotations (docs/viewport-control.md): the Agent's annotation write surface has two
41
+ // refusals of its own. `ANNOTATION_NOT_FOREGROUND` is a *precondition on the target surface*
42
+ // (nobody has this tab selected in the debug view, so a drawn note would be invisible) and is
43
+ // not retryable by the caller alone — a human has to select the tab. `ANNOTATION_REVISION_CONFLICT`
44
+ // is the optimistic-concurrency loser of `expectRevision`, retryable after re-reading.
45
+ ANNOTATION_NOT_FOREGROUND: ['action', false],
46
+ ANNOTATION_REVISION_CONFLICT: ['state', true],
47
+ // P0 capture infrastructure (docs/capture-p0.md): the capture pipeline has its own taxonomy so a
48
+ // bad screenshot never masquerades as INTERNAL. Size/format refusals are not retryable (the same
49
+ // page would produce the same bytes); a failed CDP capture and a failed output write are.
50
+ // ABORTED/CDP_TIMEOUT/CDP_DISCONNECTED keep their existing codes for capture too.
51
+ CAPTURE_FAILED: ['capture', true], INVALID_PNG: ['capture', false],
52
+ CAPTURE_SIZE_MISMATCH: ['capture', false], CAPTURE_UPSCALED: ['capture', false],
53
+ CAPTURE_TOO_LARGE: ['resource', false], OUTPUT_PATH_INVALID: ['input', false],
54
+ OUTPUT_EXISTS: ['output', false], OUTPUT_WRITE_FAILED: ['output', true],
55
+ // Web T3 (static vision, docs/t3-vision.md). Every code below is fixed by the contract:
56
+ // a vision refusal must never degrade into INTERNAL, and a product/baseline that cannot be
57
+ // read must never be reported as a visual FAIL. `vision` marks "the image/source side said no",
58
+ // `action` marks "the live page no longer matches the screenshot", `output` marks the managed
59
+ // baseline root on disk, `concurrency` marks the HEAD CAS loser.
60
+ VISION_SOURCE_NOT_FOUND: ['vision', false], VISION_FORMAT_UNSUPPORTED: ['vision', false],
61
+ VISION_LIMIT_EXCEEDED: ['resource', false], VISION_TIMEOUT: ['timeout', true],
62
+ VISION_SIZE_MISMATCH: ['vision', false], VISION_EMPTY_COMPARISON: ['vision', false],
63
+ VISION_STALE_IMAGE: ['action', false], VISION_HIT_MISMATCH: ['action', false],
64
+ VISION_BASELINE_ROOT_UNAVAILABLE: ['output', false], VISION_BASELINE_NAME_INVALID: ['input', false],
65
+ VISION_BASELINE_NOT_FOUND: ['vision', false], VISION_BASELINE_EXISTS: ['output', false],
66
+ VISION_BASELINE_CONFLICT: ['concurrency', false],
67
+ VISION_BASELINE_READ_FAILED: ['output', true], VISION_BASELINE_WRITE_FAILED: ['output', true],
68
+ // Web P0 network collection (docs/network-p0.md). Codes, categories and retryability are
69
+ // fixed by the contract table: `scope` = the session/store is not there, `action` = the cited
70
+ // event is gone, `resource` = a declared budget, `concurrency` = the page/session is owned
71
+ // elsewhere or an id/step name collides, `capability` = no CDP/page or an unsupported CDP call,
72
+ // `output`/`input` = the evidence file side, `authorization` = cross-agent/cross-scope access.
73
+ NET_SESSION_NOT_FOUND: ['scope', false], NET_SESSION_ACTIVE: ['concurrency', false],
74
+ NET_SESSION_NOT_ACTIVE: ['scope', true], NET_CONFIG_INVALID: ['input', false],
75
+ NET_EVENT_NOT_FOUND: ['action', false], NET_BODY_UNAVAILABLE: ['network', true],
76
+ NET_QUOTA_EXCEEDED: ['resource', false], NET_RULE_INVALID: ['input', false],
77
+ NET_RULE_CONFLICT: ['concurrency', false], NET_RULE_NOT_FOUND: ['input', false],
78
+ NET_CONTROL_UNAVAILABLE: ['capability', false], NET_CONTROL_TIMEOUT: ['timeout', true],
79
+ NET_EXPORT_FAILED: ['output', true], NET_IMPORT_FAILED: ['input', false],
80
+ NET_HAR_INVALID: ['input', false], NET_SUBSCRIPTION_NOT_FOUND: ['scope', false],
81
+ NET_SUBSCRIPTION_FORBIDDEN: ['authorization', false], NET_INTEGRITY_FAILED: ['integrity', false],
82
+ NET_SCOPE_FORBIDDEN: ['authorization', false],
83
+ // R1-5(docs/network-p0.md):新 7 工具的参数/路径/根/写盘错误一律 NET_*,
84
+ // 只有 BUSY/ABORTED/CDP_*/RUNTIME_LIMIT 这些平台码可以透传。
85
+ NET_INVALID_ARGUMENT: ['input', false], NET_OUTPUT_ROOT_UNKNOWN: ['output', false],
86
+ NET_OUTPUT_PATH_INVALID: ['input', false], NET_OUTPUT_WRITE_FAILED: ['output', true],
87
+ // Web T4 (docs/t4-dynamic-behavior.md): the DYNAMIC_* surface is its own taxonomy — a
88
+ // dynamic refusal must never degrade into INTERNAL, and missing frame/clock evidence is an
89
+ // evidence problem, not a page failure. `scope` = the session/event is gone or belongs to
90
+ // another scope, `input` = pure pre-check (zero start/control), `capability` = the requested
91
+ // frame source/encoding/adapter is unavailable (never silently substituted), `evidence` =
92
+ // clock/mapping limits make an exact claim impossible (keep the events, degrade the verdict),
93
+ // `state` = page-generation/control conflict, `resource` = a declared budget.
94
+ DYNAMIC_INVALID_ARGUMENT: ['input', false], DYNAMIC_SESSION_NOT_FOUND: ['scope', false],
95
+ DYNAMIC_EVENT_NOT_FOUND: ['scope', false], DYNAMIC_SESSION_ACTIVE: ['concurrency', false],
96
+ DYNAMIC_SOURCE_UNAVAILABLE: ['capability', false], DYNAMIC_CLOCK_UNCERTAIN: ['evidence', false],
97
+ DYNAMIC_LIMIT_EXCEEDED: ['resource', false], DYNAMIC_TIMEOUT: ['timeout', true],
98
+ DYNAMIC_CONTROL_CONFLICT: ['state', false], DYNAMIC_RESTORE_FAILED: ['state', false],
99
+ DYNAMIC_MAPPING_INCOMPATIBLE: ['evidence', false],
100
+ // P0-E page group (docs/multi-tab.md): a tab that is gone (`scope`), a tab owned by another
101
+ // workspace (`authorization`), the per-workspace/global page capacity (`resource`) and a popup
102
+ // whose first document cannot be hosted (`capability`). None of them may read as INTERNAL.
103
+ TAB_NOT_FOUND: ['scope', false], TAB_FORBIDDEN: ['authorization', false],
104
+ TAB_LIMIT: ['resource', false], POPUP_UNSUPPORTED: ['capability', false],
105
+ // Team/page access model (M1, docs/internal/m0-rulings-20261109.md §7.3). The pre-existing tab
106
+ // codes above only knew about a short lease; long-lived ownership adds *classification* failures
107
+ // that must not degrade to INTERNAL: a control conflict is `state` (read current, decide again —
108
+ // never an automatic BUSY replay), an unprovable identity is `authorization` (no downgrade to a
109
+ // self-made standalone domain), and "cannot observe without disturbing the page" is a
110
+ // `capability` answer, not a page error. TAB_OBSERVATION_STALE is retryable=true because the fix
111
+ // is simply to observe again, while TAB_CONTROL_CHANGED is not: retrying a stale control version
112
+ // would write with authority the caller no longer has.
113
+ TAB_CONTROL_CHANGED: ['state', false], TAB_OWNERSHIP_STALE: ['state', false],
114
+ TAB_IDENTITY_UNAVAILABLE: ['authorization', false], TAB_OBSERVATION_STALE: ['state', true],
115
+ OBSERVATION_UNAVAILABLE: ['capability', false],
116
+ // T4 extend on a stopped/retained session: the session is gone but starting a new one may
117
+ // recover, hence scope + retryable (docs/t4-dynamic-behavior.md §extend).
118
+ DYNAMIC_SESSION_NOT_ACTIVE: ['scope', true],
119
+ };
120
+
121
+ /** Frozen copy of the error taxonomy: every thrown code must have an entry (tests enforce it). */
122
+ export const ERROR_TAXONOMY = Object.freeze({ ...TAXONOMY });
123
+ export class BrowserError extends Error {
124
+ constructor(code, message, details = {}) {
125
+ super(`[${code}] ${redactText(message)}`);
126
+ this.name = 'BrowserError';
127
+ this.code = code;
128
+ [this.category, this.retryable] = TAXONOMY[code] || TAXONOMY.INTERNAL;
129
+ this.details = details;
130
+ }
131
+ payload() {
132
+ return { code: this.code, category: this.category, retryable: this.retryable,
133
+ message: this.message, details: this.details };
134
+ }
135
+ }
136
+ export function asBrowserError(error) {
137
+ if (error instanceof BrowserError) return error;
138
+ // A classified cross-environment error (core/errors.js `AdapterError`) carries `code`/`category`/
139
+ // `retryable`/`details`. Rebuilding it as a BrowserError keeps the code so a policy refusal reads
140
+ // as `[ADAPTER_DISABLED]` rather than `[INTERNAL]`; the category comes from this module's taxonomy.
141
+ if (error && typeof error.code === 'string' && Object.hasOwn(TAXONOMY, error.code)) {
142
+ const rebuilt = new BrowserError(error.code, String(error.message || error.code));
143
+ if (error.name === 'AdapterError' || error.category !== undefined) {
144
+ rebuilt.name = 'BrowserError';
145
+ rebuilt.adapterCategory = typeof error.category === 'string' ? error.category : null;
146
+ rebuilt.retryable = typeof error.retryable === 'boolean' ? error.retryable : rebuilt.retryable;
147
+ }
148
+ if (error.details !== undefined) rebuilt.details = error.details;
149
+ return rebuilt;
150
+ }
151
+ return new BrowserError('INTERNAL', String(error?.message || error));
152
+ }
153
+ export function checkAbort(signal) {
154
+ if (signal?.aborted) throw new BrowserError('ABORTED', '操作已取消');
155
+ }
156
+ export function abortable(promise, signal) {
157
+ checkAbort(signal);
158
+ if (!signal) return promise;
159
+ return new Promise((resolve, reject) => {
160
+ const abort = () => { cleanup(); reject(new BrowserError('ABORTED', '操作已取消')); };
161
+ const cleanup = () => signal.removeEventListener('abort', abort);
162
+ signal.addEventListener('abort', abort, { once: true });
163
+ Promise.resolve(promise).then((value) => { cleanup(); resolve(value); }, (error) => { cleanup(); reject(error); });
164
+ });
165
+ }
166
+ const sensitive = /token|key|secret|password|auth|code|session/i;
167
+ export function redactUrl(raw) {
168
+ try {
169
+ const url = new URL(raw);
170
+ if (url.username) url.username = '***';
171
+ if (url.password) url.password = '***';
172
+ for (const key of [...url.searchParams.keys()]) if (sensitive.test(key)) url.searchParams.set(key, '***');
173
+ if (url.hash) url.hash = '***';
174
+ return url.toString();
175
+ } catch { return String(raw).replace(/\b(token|key|secret|password|auth|code|session)=[^&\s]*/gi, '$1=***'); }
176
+ }
177
+ export function redactText(value) {
178
+ return String(value).replace(/https?:\/\/[^\s<>"']+/gi, (url) => redactUrl(url));
179
+ }
180
+ function blockedAddress(address, allowPrivate) {
181
+ let value = address.toLowerCase().replace(/^\[|\]$/g, '');
182
+ if (value.startsWith('::ffff:')) {
183
+ const tail = value.slice(7);
184
+ if (isIP(tail) === 4) value = tail;
185
+ else {
186
+ const parts = tail.split(':');
187
+ if (parts.length === 2) {
188
+ const n = parseInt(parts[0], 16) * 65536 + parseInt(parts[1], 16);
189
+ value = [n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join('.');
190
+ }
191
+ }
192
+ }
193
+ if (isIP(value) === 4) {
194
+ const [a, b] = value.split('.').map(Number);
195
+ if (a === 0 || (a === 169 && b === 254) || a >= 224) return true;
196
+ return !allowPrivate && (a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168));
197
+ }
198
+ if (isIP(value) === 6) {
199
+ if (value === '::' || /^ff/i.test(value) || /^fe[89ab]/i.test(value)) return true;
200
+ return !allowPrivate && /^f[cd]/i.test(value);
201
+ }
202
+ return true;
203
+ }
204
+ export async function validateTarget(raw, { origins = [], allowPrivate = process.env.DSH_UI_TESTING_ALLOW_PRIVATE === '1', signal, resolve = lookup } = {}) {
205
+ checkAbort(signal);
206
+ let url;
207
+ try { url = new URL(raw); } catch { throw new BrowserError('INVALID_ARGUMENT', '地址不是合法 URL'); }
208
+ if (!['http:', 'https:'].includes(url.protocol)) throw new BrowserError('INVALID_ARGUMENT', '只支持 http/https 地址');
209
+ if (url.username || url.password) throw new BrowserError('INVALID_ARGUMENT', '导航地址不能包含凭据');
210
+ const blockedOrigins = [...origins, process.env.DSH_WEB_URL].filter(Boolean).map((origin) => {
211
+ try { return new URL(origin).origin; } catch { return null; }
212
+ });
213
+ if (blockedOrigins.includes(url.origin)) throw new BrowserError('INVALID_ARGUMENT', '不能导航到 DSH 自身 origin');
214
+ const hostname = url.hostname.replace(/^\[|\]$/g, '');
215
+ let addresses;
216
+ try {
217
+ // `resolve` is an injection point for probes (mixed public/private DNS, zero-dispatch
218
+ // scenarios). Production always uses the real `dns/promises` lookup imported above.
219
+ addresses = isIP(hostname) ? [{ address: hostname }] : await abortable(resolve(hostname, { all: true }), signal);
220
+ } catch (error) {
221
+ if (error instanceof BrowserError) throw error;
222
+ throw new BrowserError('NAV_FAILED', '导航域名解析失败', { url: redactUrl(raw) });
223
+ }
224
+ if (!addresses.length || addresses.some(({ address }) => blockedAddress(address, allowPrivate))) {
225
+ throw new BrowserError('INVALID_ARGUMENT', '导航目标地址不在允许范围', { url: redactUrl(raw) });
226
+ }
227
+ return url.href;
228
+ }
229
+
230
+ /** Minimal cancellable CDP transport. Each settlement removes its timer and abort listener. */
231
+ export class Cdp {
232
+ constructor(url) {
233
+ this.url = url; this.nextId = 1; this.pending = new Map(); this.listeners = new Set(); this.closed = false;
234
+ }
235
+ connect({ signal, timeoutMs = 30000 } = {}) {
236
+ checkAbort(signal);
237
+ return new Promise((resolve, reject) => {
238
+ const ws = new WebSocket(this.url);
239
+ this.ws = ws;
240
+ let settled = false;
241
+ const finish = (error) => {
242
+ if (settled) return;
243
+ settled = true;
244
+ clearTimeout(timer); signal?.removeEventListener('abort', abort);
245
+ if (error) { this.close(); reject(error); } else resolve(this);
246
+ };
247
+ const abort = () => finish(new BrowserError('ABORTED', 'CDP 连接已取消'));
248
+ const timer = setTimeout(() => finish(new BrowserError('CDP_TIMEOUT', 'CDP 连接超时')), timeoutMs);
249
+ signal?.addEventListener('abort', abort, { once: true });
250
+ ws.onopen = () => finish();
251
+ ws.onerror = () => finish(new BrowserError('CDP_DISCONNECTED', 'CDP 连接失败'));
252
+ ws.onclose = () => {
253
+ this.closed = true;
254
+ finish(new BrowserError('CDP_DISCONNECTED', 'CDP 连接已关闭'));
255
+ this.rejectPending();
256
+ };
257
+ ws.onmessage = (event) => {
258
+ let message;
259
+ try { message = JSON.parse(event.data); } catch { return; }
260
+ if (message.id !== undefined) {
261
+ const entry = this.pending.get(message.id);
262
+ if (entry) entry.finish(message.error ? new BrowserError('INTERNAL', message.error.message || 'CDP error') : null, message.result);
263
+ } else for (const listener of this.listeners) listener(message);
264
+ };
265
+ });
266
+ }
267
+ send(method, params = {}, { signal, timeoutMs = 30000, sessionId } = {}) {
268
+ if (this.closed) return Promise.reject(new BrowserError('CDP_DISCONNECTED', 'CDP 连接已关闭', { method }));
269
+ if (signal?.aborted) return Promise.reject(new BrowserError('ABORTED', '操作已取消'));
270
+ const id = this.nextId++;
271
+ return new Promise((resolve, reject) => {
272
+ const abort = () => finish(new BrowserError('ABORTED', '操作已取消', { method }));
273
+ const finish = (error, value) => {
274
+ if (!this.pending.delete(id)) return;
275
+ clearTimeout(timer); signal?.removeEventListener('abort', abort);
276
+ if (error) reject(error); else resolve(value);
277
+ };
278
+ const timer = setTimeout(() => finish(new BrowserError('CDP_TIMEOUT', 'CDP 超时: ' + method)), timeoutMs);
279
+ this.pending.set(id, { finish, timer, method });
280
+ signal?.addEventListener('abort', abort, { once: true });
281
+ try { this.ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); }
282
+ catch {
283
+ finish(new BrowserError('CDP_DISCONNECTED', 'CDP 发送失败'));
284
+ this.close();
285
+ }
286
+ });
287
+ }
288
+ rejectPending() {
289
+ for (const entry of [...this.pending.values()]) entry.finish(new BrowserError('CDP_DISCONNECTED', 'CDP 连接已关闭', { method: entry.method }));
290
+ }
291
+ on(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
292
+ close() { this.closed = true; this.rejectPending(); try { this.ws?.close(); } catch { /* already closed */ } }
293
+ }