dsh-agent-team-ui-testing 0.1.1 → 0.1.3
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 +172 -36
- package/annotation-capture.js +111 -0
- package/browser-safety.js +45 -1
- package/client.js +3721 -132
- package/context-menu.js +134 -0
- package/core/clock.js +88 -0
- package/core/config-store.js +566 -0
- package/core/config.js +892 -0
- package/core/contracts.js +240 -0
- package/core/errors.js +156 -0
- package/core/evidence.js +141 -0
- package/core/geometry.js +217 -0
- package/core/identity.js +180 -0
- package/core/index.js +24 -0
- package/core/p0.js +1101 -0
- package/core/readiness.js +175 -0
- package/core/registry.js +239 -0
- package/core/tab-access.js +1350 -0
- package/docs/adapters.md +160 -0
- package/docs/architecture.md +44 -9
- package/docs/capability-tiers.md +20 -4
- package/docs/capture-p0.md +39 -2
- package/docs/diagnose-reason-codes.md +157 -0
- package/docs/element-picker.md +24 -6
- package/docs/getting-started.md +47 -1
- package/docs/images/tutorial-01-overview.png +0 -0
- package/docs/images/tutorial-02-history-dropdown.png +0 -0
- package/docs/images/tutorial-03-pick-element.png +0 -0
- package/docs/images/tutorial-04-annotate.png +0 -0
- package/docs/index.md +11 -7
- package/docs/multi-tab.md +55 -20
- package/docs/network-p0.md +2 -2
- package/docs/panel-context-menu.md +41 -0
- package/docs/t2-structured-gui.md +2 -2
- package/docs/t3-vision.md +6 -1
- package/docs/t4-dynamic-behavior.md +1 -0
- package/docs/tools.md +65 -7
- package/docs/tutorial.md +125 -0
- package/docs/verification.md +26 -2
- package/docs/viewport-control.md +21 -8
- package/index.js +3263 -251
- package/integration/cross-environment.js +1535 -0
- package/integration/diagnose.js +945 -0
- package/integration/tab-principal.js +493 -0
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/network-p0.js +24 -7
- package/package.json +4 -3
- package/select-control.js +298 -0
- package/skills/ui-testing-common/SKILL.md +54 -0
- package/skills/ui-testing-fake/SKILL.md +43 -0
- package/skills/{ui-testing → ui-testing-web}/SKILL.md +64 -24
- package/skills/{ui-testing → ui-testing-web}/manifests/declared-capabilities.json +9 -2
- package/skills/{ui-testing → ui-testing-web}/references/contracts/capability.md +3 -3
- package/skills/{ui-testing → ui-testing-web}/references/contracts/evidence.md +1 -1
- package/skills/{ui-testing → ui-testing-web}/references/contracts/operation.md +1 -1
- package/skills/{ui-testing → ui-testing-web}/references/core/fallback.md +1 -1
- package/skills/{ui-testing → ui-testing-web}/references/core/page-identity.md +3 -2
- package/skills/{ui-testing → ui-testing-web}/references/core/selection.md +1 -1
- package/skills/{ui-testing → ui-testing-web}/references/core/tool-routing-and-limits.md +6 -3
- package/skills/{ui-testing → ui-testing-web}/references/platforms/index.md +1 -1
- package/skills/{ui-testing → ui-testing-web}/references/platforms/web.md +2 -2
- package/skills/ui-testing-web/references/scenarios/annotations.md +104 -0
- package/skills/{ui-testing → ui-testing-web}/references/scenarios/functional.md +1 -1
- package/skills/{ui-testing → ui-testing-web}/references/scenarios/visual-motion.md +1 -1
- package/skills/{ui-testing → ui-testing-web}/references/tiers/t1-programmatic.md +2 -2
- package/skills/{ui-testing → ui-testing-web}/references/tiers/t3-static-visual.md +2 -2
- package/skills/{ui-testing → ui-testing-web}/references/tiers/t4-dynamic-visual.md +2 -1
- package/targets/fake/adapter.js +1163 -0
- package/targets/fake/fixtures.js +177 -0
- package/targets/web/adapter.js +1231 -0
- package/targets/web/provider.js +415 -0
- package/skills/ui-testing/references/scenarios/annotations.md +0 -87
- /package/skills/{ui-testing → ui-testing-web}/references/core/dynamic-visual.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/network-and-state.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/report-template.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/reporting.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/safety.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/verdict-examples.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/verdict-gate.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/verification.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/workflow.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/tiers/t2-structured.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/templates/capability-note.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/templates/platform.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/templates/regression-case.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/templates/scenario.md +0 -0
|
@@ -0,0 +1,945 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider diagnostics run (Host side, slice 1).
|
|
3
|
+
*
|
|
4
|
+
* `POST /plugins/ui-testing/diagnose` walks five real checks and streams them as NDJSON. It exists
|
|
5
|
+
* because the settings page used to *project* readiness onto a picture: the picture could show a
|
|
6
|
+
* green node above a dead edge. Here every stage performs an actual operation (registration
|
|
7
|
+
* comparison, a real tool dispatch through the registered handler, a real `Browser.getVersion`, a
|
|
8
|
+
* real executable check, a real `Target.getTargets` + read-only `browser_eval`) and reports what
|
|
9
|
+
* really happened — including "not run", which is never painted as success.
|
|
10
|
+
*
|
|
11
|
+
* Contract points that must not be relaxed:
|
|
12
|
+
* - body is exactly `{provider?, mode:'observe'|'start', requestId}`; anything else is 400.
|
|
13
|
+
* - `observe` never spawns a browser, never ensures, never creates a page. `start` goes through
|
|
14
|
+
* the existing `Runtime.ensure` (15s spawn budget, 30s run budget) and records what it created.
|
|
15
|
+
* - `durationMs` comes from the Host monotonic clock; a stage that did not run reports `null`.
|
|
16
|
+
* - `seq` is unique and strictly increasing for the whole run.
|
|
17
|
+
* - five-state (`PASS|WARN|FAIL|SKIPPED|UNKNOWN`) is kept separate from `phase`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createHash } from 'node:crypto';
|
|
21
|
+
|
|
22
|
+
const NDJSON = 'application/x-ndjson; charset=utf-8';
|
|
23
|
+
const STAGE_BUDGET_MS = { 1: 3000, 2: 2000, 3: 3000, 4: 2000, 5: 5000 };
|
|
24
|
+
const RUN_BUDGET_MS = 30000;
|
|
25
|
+
const START_BUDGET_MS = 15000;
|
|
26
|
+
const BODY_KEYS = new Set(['provider', 'mode', 'requestId']);
|
|
27
|
+
/**
|
|
28
|
+
* Hard ceilings for the read-only capability probes. They are exported so the acceptance suite can
|
|
29
|
+
* assert the rejection paths directly: `POST /diagnose` takes no probe arguments, so an out-of-range
|
|
30
|
+
* parameter can only be exercised against this validator.
|
|
31
|
+
*/
|
|
32
|
+
export const PROBE_LIMITS = Object.freeze({
|
|
33
|
+
snapshotMaxNodes: 100,
|
|
34
|
+
computedStyleMaxProperties: 5,
|
|
35
|
+
networkMaxLimit: 10,
|
|
36
|
+
computedStyleSelectors: Object.freeze(['body', 'html']),
|
|
37
|
+
probeBudgetMs: 8000,
|
|
38
|
+
probeTimeoutMs: 3000,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Validate one dispatcher call. Returns `{ ok: true, value }` with the *narrowed* arguments, or
|
|
43
|
+
* `{ ok: false, reason }`. Anything outside the read-only allow-list is rejected, never silently
|
|
44
|
+
* widened: these probes must not click, type, navigate or start a capture session.
|
|
45
|
+
*/
|
|
46
|
+
export function validateProbeArgs(tool, args) {
|
|
47
|
+
const input = args && typeof args === 'object' && !Array.isArray(args) ? args : null;
|
|
48
|
+
const keys = input ? Object.keys(input) : [];
|
|
49
|
+
const onlyKeys = (allowed) => keys.filter((key) => !allowed.includes(key));
|
|
50
|
+
if (tool === 'browser_capabilities') {
|
|
51
|
+
if (keys.length) return { ok: false, reason: 'browser_capabilities 不接受参数:' + keys.join(',') };
|
|
52
|
+
return { ok: true, value: {} };
|
|
53
|
+
}
|
|
54
|
+
if (tool === 'browser_eval') {
|
|
55
|
+
if (!input || input.expression !== '1') return { ok: false, reason: 'browser_eval 只允许只读表达式 \'1\'' };
|
|
56
|
+
if (onlyKeys(['expression']).length) return { ok: false, reason: 'browser_eval 不接受额外参数' };
|
|
57
|
+
return { ok: true, value: { expression: '1' } };
|
|
58
|
+
}
|
|
59
|
+
if (tool === 'browser_snapshot') {
|
|
60
|
+
if (!input) return { ok: true, value: { maxNodes: 50 } };
|
|
61
|
+
const extra = onlyKeys(['maxNodes']);
|
|
62
|
+
if (extra.length) return { ok: false, reason: 'browser_snapshot 不接受参数:' + extra.join(',') };
|
|
63
|
+
const maxNodes = input.maxNodes === undefined ? 50 : input.maxNodes;
|
|
64
|
+
if (!Number.isInteger(maxNodes) || maxNodes < 1 || maxNodes > PROBE_LIMITS.snapshotMaxNodes) {
|
|
65
|
+
return { ok: false, reason: 'maxNodes 必须是 1..' + PROBE_LIMITS.snapshotMaxNodes + ' 的整数' };
|
|
66
|
+
}
|
|
67
|
+
return { ok: true, value: { maxNodes } };
|
|
68
|
+
}
|
|
69
|
+
if (tool === 'browser_screenshot') {
|
|
70
|
+
if (!input) return { ok: true, value: {} };
|
|
71
|
+
if (keys.length) return { ok: false, reason: 'browser_screenshot 只允许空参数(拒绝 ' + keys.join(',') + ':不落盘、不裁剪、不改比例)' };
|
|
72
|
+
return { ok: true, value: {} };
|
|
73
|
+
}
|
|
74
|
+
if (tool === 'browser_computed_style') {
|
|
75
|
+
if (!input) return { ok: false, reason: 'browser_computed_style 需要 selector 与 properties' };
|
|
76
|
+
const extra = onlyKeys(['selector', 'properties']);
|
|
77
|
+
if (extra.length) return { ok: false, reason: 'browser_computed_style 不接受参数:' + extra.join(',') };
|
|
78
|
+
if (!PROBE_LIMITS.computedStyleSelectors.includes(input.selector)) {
|
|
79
|
+
return { ok: false, reason: 'selector 只允许 ' + PROBE_LIMITS.computedStyleSelectors.join('/') + ',收到 ' + String(input.selector) };
|
|
80
|
+
}
|
|
81
|
+
const properties = input.properties;
|
|
82
|
+
if (!Array.isArray(properties) || properties.length < 1 || properties.length > PROBE_LIMITS.computedStyleMaxProperties) {
|
|
83
|
+
return { ok: false, reason: 'properties 必须是长度 1..' + PROBE_LIMITS.computedStyleMaxProperties + ' 的数组' };
|
|
84
|
+
}
|
|
85
|
+
if (!properties.every((name) => typeof name === 'string' && /^[a-zA-Z-]{1,40}$/.test(name))) {
|
|
86
|
+
return { ok: false, reason: 'properties 只能是 CSS 属性名' };
|
|
87
|
+
}
|
|
88
|
+
return { ok: true, value: { selector: input.selector, properties: properties.slice(0, PROBE_LIMITS.computedStyleMaxProperties) } };
|
|
89
|
+
}
|
|
90
|
+
if (tool === 'browser_network') {
|
|
91
|
+
if (!input) return { ok: true, value: { limit: 5 } };
|
|
92
|
+
const extra = onlyKeys(['limit']);
|
|
93
|
+
if (extra.length) return { ok: false, reason: 'browser_network 只接受 limit(不启动采集):' + extra.join(',') };
|
|
94
|
+
const limit = input.limit === undefined ? 5 : input.limit;
|
|
95
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > PROBE_LIMITS.networkMaxLimit) {
|
|
96
|
+
return { ok: false, reason: 'limit 必须是 1..' + PROBE_LIMITS.networkMaxLimit + ' 的整数' };
|
|
97
|
+
}
|
|
98
|
+
return { ok: true, value: { limit } };
|
|
99
|
+
}
|
|
100
|
+
return { ok: false, reason: '诊断调度器不支持该操作:' + String(tool) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The only operations this dispatcher may run. Everything else is refused above. */
|
|
104
|
+
const DISPATCHER_ALLOWED = new Map([
|
|
105
|
+
['browser_capabilities', true],
|
|
106
|
+
['browser_eval', true],
|
|
107
|
+
['browser_snapshot', true],
|
|
108
|
+
['browser_screenshot', true],
|
|
109
|
+
['browser_computed_style', true],
|
|
110
|
+
['browser_network', true],
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
export function createDiagnose(deps) {
|
|
114
|
+
const {
|
|
115
|
+
log, crossEnvironment, registry, readJson, sendJson, nowMonoMs,
|
|
116
|
+
resolveProviderExecutable, toolDefinitions, randomUUID, webProviderSupport,
|
|
117
|
+
} = deps;
|
|
118
|
+
const mono = typeof nowMonoMs === 'function' ? nowMonoMs : () => Number(process.hrtime.bigint()) / 1e6;
|
|
119
|
+
const wall = () => Date.now();
|
|
120
|
+
|
|
121
|
+
const fail = (res, status, code, message) => {
|
|
122
|
+
sendJson(res, status, { ok: false, code, error: message });
|
|
123
|
+
return true;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/** Five-state helpers ---------------------------------------------------------------- */
|
|
127
|
+
const STAGE_IDS = ['1', '2', '3', '4', '5'];
|
|
128
|
+
const REQUIRED_STAGES = new Set(['1', '2', '3', '4', '5']);
|
|
129
|
+
const worstStatus = (statuses) => {
|
|
130
|
+
if (statuses.includes('FAIL')) return 'FAIL';
|
|
131
|
+
if (statuses.includes('UNKNOWN')) return 'UNKNOWN';
|
|
132
|
+
if (statuses.includes('WARN') || statuses.includes('SKIPPED')) return 'WARN';
|
|
133
|
+
return 'PASS';
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
function handleHttp(req, res, options = {}) {
|
|
137
|
+
const { action, scopeRequest, scoped, requestRejection, rejectUi, resolveScopeRequest } = options;
|
|
138
|
+
if (action !== '/diagnose') return false;
|
|
139
|
+
if (req.method !== 'POST') return fail(res, 405, 'UNSUPPORTED', '只支持 POST /diagnose');
|
|
140
|
+
// Scoped GUI only. `index.js` already ran the strict owner-only gate for scoped requests, so
|
|
141
|
+
// reaching here with `scoped === false` means a legacy/unscoped caller.
|
|
142
|
+
if (!scoped) {
|
|
143
|
+
if (typeof rejectUi === 'function') { rejectUi(res, 401); return true; }
|
|
144
|
+
return fail(res, 401, 'UNSUPPORTED', '需要已认证且已绑定的 GUI 会话');
|
|
145
|
+
}
|
|
146
|
+
run(req, res, { scopeRequest, resolveScopeRequest }).catch((error) => {
|
|
147
|
+
log('ui-testing diagnose failed: ' + String((error && error.stack) || error));
|
|
148
|
+
try { if (!res.headersSent) fail(res, 500, 'INTERNAL', '诊断执行失败'); else res.end(); } catch { /* ignore */ }
|
|
149
|
+
}).catch(() => {});
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The identity a probe runs as.
|
|
155
|
+
*
|
|
156
|
+
* Page groups are keyed by (workspace, provider, team root), so a synthetic actor derived from the
|
|
157
|
+
* *workspace* id lands in a different group than the page this run froze and every probe answers
|
|
158
|
+
* `TAB_NOT_FOUND`. A probe therefore runs as the **owner of the page it inspects**: the frozen
|
|
159
|
+
* entry records exactly that (`ownerSessionId`, falling back to `rootSessionId`). The cwd is kept
|
|
160
|
+
* from the resolved scope so admission and the config snapshot still resolve the same workspace.
|
|
161
|
+
*
|
|
162
|
+
* This is not "the diagnostician borrows somebody's identity": see `diagnosticPrincipalFor()` in
|
|
163
|
+
* index.js — the probe set is fixed, read-only, and the scope came from this panel's own
|
|
164
|
+
* authenticated resolution.
|
|
165
|
+
*/
|
|
166
|
+
function dispatchAgentFor(ctx) {
|
|
167
|
+
const owner = ctx.entry?.ownership?.ownerSessionId || ctx.entry?.rootSessionId || null;
|
|
168
|
+
if (!owner) return ctx.agent;
|
|
169
|
+
return { ...(ctx.agent || {}), id: String(owner) };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** A tool dispatch restricted to this contract's fixed operations, through the real handler. */
|
|
173
|
+
async function dispatch(ctx, tool, args, signal) {
|
|
174
|
+
if (!DISPATCHER_ALLOWED.has(tool)) {
|
|
175
|
+
throw Object.assign(new Error('诊断调度器不支持该操作:' + tool), { code: 'UNSUPPORTED' });
|
|
176
|
+
}
|
|
177
|
+
const validated = validateProbeArgs(tool, args);
|
|
178
|
+
if (!validated.ok) {
|
|
179
|
+
throw Object.assign(new Error(validated.reason), { code: 'INVALID_ARGUMENT' });
|
|
180
|
+
}
|
|
181
|
+
const guarded = validated.value;
|
|
182
|
+
const entry = toolDefinitions.get(tool);
|
|
183
|
+
if (!entry || !entry.definition || typeof entry.definition.execute !== 'function') {
|
|
184
|
+
throw Object.assign(new Error('工具未注册:' + tool), { code: 'TOOL_NOT_FOUND' });
|
|
185
|
+
}
|
|
186
|
+
const startedAt = mono();
|
|
187
|
+
const value = await entry.definition.execute(guarded, {
|
|
188
|
+
agent: dispatchAgentFor(ctx),
|
|
189
|
+
runtime: ctx.runtime,
|
|
190
|
+
signal,
|
|
191
|
+
// The diagnose run already froze the scope and the runtime it inspected; re-resolving would
|
|
192
|
+
// create a second runtime for the same workspace (or fail for an unassigned scope).
|
|
193
|
+
diagnosticScope: { resolved: ctx.resolved, entry: ctx.entry },
|
|
194
|
+
});
|
|
195
|
+
const call = {
|
|
196
|
+
tool,
|
|
197
|
+
ok: true,
|
|
198
|
+
startedAt: wall(),
|
|
199
|
+
durationMs: Math.round((mono() - startedAt) * 1000) / 1000,
|
|
200
|
+
provider: ctx.provider,
|
|
201
|
+
response: summarise(value),
|
|
202
|
+
};
|
|
203
|
+
ctx.actualCalls.push(call);
|
|
204
|
+
return { value, call };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Bounded structural summary of a tool value (never the whole payload). */
|
|
208
|
+
function summarise(value) {
|
|
209
|
+
if (!value || typeof value !== 'object') return { type: typeof value };
|
|
210
|
+
const out = { keys: Object.keys(value).slice(0, 12) };
|
|
211
|
+
if (typeof value.ok === 'boolean') out.ok = value.ok;
|
|
212
|
+
if (typeof value.value === 'number' || typeof value.value === 'string') out.value = value.value;
|
|
213
|
+
if (value.result && typeof value.result === 'object') {
|
|
214
|
+
out.result = {};
|
|
215
|
+
if (typeof value.result.type === 'string') out.result.type = value.result.type;
|
|
216
|
+
if (typeof value.result.value === 'number' || typeof value.result.value === 'string') out.result.value = value.result.value;
|
|
217
|
+
if (typeof value.result.description === 'string') out.result.description = value.result.description.slice(0, 120);
|
|
218
|
+
}
|
|
219
|
+
if (typeof value.tool === 'string') out.tool = value.tool;
|
|
220
|
+
if (Array.isArray(value.capabilities)) out.capabilities = value.capabilities.length;
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function run(req, res, { scopeRequest, resolveScopeRequest }) {
|
|
225
|
+
const startedMono = mono();
|
|
226
|
+
const startedAt = wall();
|
|
227
|
+
const runId = randomUUID();
|
|
228
|
+
let seq = 0;
|
|
229
|
+
const nextSeq = () => { seq += 1; return seq; };
|
|
230
|
+
|
|
231
|
+
// --- body validation (before any side effect) -------------------------------------
|
|
232
|
+
let body = {};
|
|
233
|
+
try { body = await readJson(req); } catch { body = null; }
|
|
234
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
235
|
+
return fail(res, 400, 'INVALID_ARGUMENT', 'body 必须是对象');
|
|
236
|
+
}
|
|
237
|
+
const keys = Object.keys(body);
|
|
238
|
+
const extra = keys.filter((key) => !BODY_KEYS.has(key));
|
|
239
|
+
if (extra.length) {
|
|
240
|
+
return fail(res, 400, 'INVALID_ARGUMENT', 'body 只允许 {provider?, mode, requestId},不接受 ' + extra.join(','));
|
|
241
|
+
}
|
|
242
|
+
const mode = body.mode === 'start' ? 'start' : 'observe';
|
|
243
|
+
if (body.mode !== undefined && body.mode !== 'observe' && body.mode !== 'start') {
|
|
244
|
+
return fail(res, 400, 'INVALID_ARGUMENT', 'mode 必须是 observe|start');
|
|
245
|
+
}
|
|
246
|
+
if (body.provider !== undefined && (typeof body.provider !== 'string' || !body.provider)) {
|
|
247
|
+
return fail(res, 400, 'INVALID_ARGUMENT', 'provider 必须是非空字符串');
|
|
248
|
+
}
|
|
249
|
+
const requestId = typeof body.requestId === 'string' ? body.requestId.slice(0, 128) : null;
|
|
250
|
+
|
|
251
|
+
// --- scope freeze -----------------------------------------------------------------
|
|
252
|
+
let resolved = null;
|
|
253
|
+
let scopeFail = null;
|
|
254
|
+
try {
|
|
255
|
+
resolved = await resolveScopeRequest(scopeRequest);
|
|
256
|
+
} catch (error) {
|
|
257
|
+
scopeFail = String((error && error.message) || error);
|
|
258
|
+
}
|
|
259
|
+
const scope = resolved ? resolved.scope : null;
|
|
260
|
+
|
|
261
|
+
res.writeHead(200, {
|
|
262
|
+
'content-type': NDJSON,
|
|
263
|
+
'cache-control': 'no-store',
|
|
264
|
+
'x-accel-buffering': 'no',
|
|
265
|
+
connection: 'keep-alive',
|
|
266
|
+
});
|
|
267
|
+
let closed = false;
|
|
268
|
+
let cancelled = false;
|
|
269
|
+
// Only a close *before* we finished writing means the client went away: `req.on('close')`
|
|
270
|
+
// fires as soon as the request body is consumed, which would cancel every run immediately.
|
|
271
|
+
const onClose = () => { if (!res.writableEnded) { closed = true; cancelled = true; } };
|
|
272
|
+
res.on('close', onClose);
|
|
273
|
+
res.on('error', onClose);
|
|
274
|
+
const emit = (event) => {
|
|
275
|
+
if (closed || res.writableEnded || res.destroyed) { closed = true; return null; }
|
|
276
|
+
const payload = JSON.stringify({ ...event, seq: nextSeq() });
|
|
277
|
+
try { res.write(payload + '\n'); } catch { closed = true; }
|
|
278
|
+
return payload;
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
// Scope could not be resolved: the whole run is UNKNOWN. We never guess with `recent`.
|
|
282
|
+
if (!resolved) {
|
|
283
|
+
emit({ type: 'run-start', runId, provider: body.provider || null, mode, scope: null, configRevision: null, startedAt });
|
|
284
|
+
emit({
|
|
285
|
+
type: 'run-end', runId, finishedAt: wall(),
|
|
286
|
+
overall: { status: 'UNKNOWN', reason: 'scope-unresolved' },
|
|
287
|
+
stages: { ...STAGE_IDS.reduce((acc, id) => ({ ...acc, [id]: { stageId: id, status: 'UNKNOWN', reason: 'scope-unresolved', blockedBy: null, durationMs: null } }), {}) },
|
|
288
|
+
axes: {},
|
|
289
|
+
actualCalls: [],
|
|
290
|
+
sideEffects: [],
|
|
291
|
+
error: scopeFail,
|
|
292
|
+
requestId,
|
|
293
|
+
});
|
|
294
|
+
try { res.end(); } catch { /* ignore */ }
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const config = crossEnvironment.configStore.getConfig();
|
|
299
|
+
const configRevision = typeof config.revision === 'number' ? config.revision : null;
|
|
300
|
+
const snapshot = crossEnvironment.snapshotForScope(scope);
|
|
301
|
+
const provider = body.provider || snapshot.provider || 'chrome';
|
|
302
|
+
const agent = crossEnvironment.agentForScope(scopeRequest, resolved)
|
|
303
|
+
|| { id: scope?.id ?? null, session: { header: { cwd: scope?.cwd ?? null } } };
|
|
304
|
+
|
|
305
|
+
emit({
|
|
306
|
+
type: 'run-start', runId, provider, mode,
|
|
307
|
+
scope: { id: scope?.id ?? null, kind: scope?.kind ?? null, workspaceId: scope?.workspaceId ?? null },
|
|
308
|
+
configRevision, startedAt, requestId,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
const ctx = {
|
|
312
|
+
provider, mode, agent, configRevision, snapshot, scope, resolved,
|
|
313
|
+
runtime: null, entry: null, actualCalls: [], sideEffects: [],
|
|
314
|
+
stage3: null, stage4: null, cancelled: () => cancelled,
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
// Resolve the runtime *identity* first. observe must not spawn; a cold entry is allowed and
|
|
318
|
+
// recorded. start goes further (ensure) inside stage 3's budget.
|
|
319
|
+
try {
|
|
320
|
+
const before = registry.entries.size;
|
|
321
|
+
ctx.entry = registry.getOrCreate(resolved);
|
|
322
|
+
ctx.runtime = ctx.entry.runtime;
|
|
323
|
+
if (registry.entries.size > before) {
|
|
324
|
+
ctx.sideEffects.push({ kind: 'cold-entry', at: wall(), provider: ctx.entry.provider, note: 'created a cold runtime entry (no browser started)' });
|
|
325
|
+
}
|
|
326
|
+
} catch (error) {
|
|
327
|
+
ctx.sideEffects.push({ kind: 'cold-entry', error: String((error && error.message) || error), at: wall() });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const stages = {};
|
|
331
|
+
const stageEvents = [];
|
|
332
|
+
const runAbort = new AbortController();
|
|
333
|
+
const runTimer = setTimeout(() => runAbort.abort(), RUN_BUDGET_MS);
|
|
334
|
+
let statuses = [];
|
|
335
|
+
|
|
336
|
+
const stageStart = (stageId) => {
|
|
337
|
+
const startedAtMs = wall();
|
|
338
|
+
const startedMonoMs = mono();
|
|
339
|
+
emit({ type: 'stage-start', runId, stageId, phase: 'running', startedAt: startedAtMs });
|
|
340
|
+
stageEvents.push({ stageId, startedAt: startedAtMs });
|
|
341
|
+
return {
|
|
342
|
+
finish: (status, reason, extra = {}) => {
|
|
343
|
+
const durationMs = Math.round((mono() - startedMonoMs) * 1000) / 1000;
|
|
344
|
+
const event = {
|
|
345
|
+
type: 'stage-end', runId, stageId, phase: 'finished',
|
|
346
|
+
status, reason: reason || null,
|
|
347
|
+
blockedBy: extra.blockedBy ?? null,
|
|
348
|
+
startedAt: startedAtMs, finishedAt: wall(), durationMs,
|
|
349
|
+
checks: extra.checks || [], evidenceRef: extra.evidenceRef || null,
|
|
350
|
+
};
|
|
351
|
+
stages[stageId] = {
|
|
352
|
+
stageId, phase: 'finished', status, reason: reason || null,
|
|
353
|
+
blockedBy: extra.blockedBy ?? null, durationMs,
|
|
354
|
+
startedAt: startedAtMs, finishedAt: event.finishedAt,
|
|
355
|
+
checks: extra.checks || [], evidenceRef: extra.evidenceRef || null,
|
|
356
|
+
};
|
|
357
|
+
statuses.push(status);
|
|
358
|
+
emit(event);
|
|
359
|
+
return stages[stageId];
|
|
360
|
+
},
|
|
361
|
+
// A stage that never ran: no start/end pair, no duration. Only a deterministic reason.
|
|
362
|
+
skip: (reason, blockedBy, checks = []) => {
|
|
363
|
+
stages[stageId] = {
|
|
364
|
+
stageId, phase: 'finished', status: 'SKIPPED', reason, blockedBy,
|
|
365
|
+
durationMs: null, startedAt: null, finishedAt: null, checks, evidenceRef: null,
|
|
366
|
+
};
|
|
367
|
+
statuses.push('SKIPPED');
|
|
368
|
+
emit({
|
|
369
|
+
type: 'stage-end', runId, stageId, phase: 'finished', status: 'SKIPPED',
|
|
370
|
+
reason, blockedBy: blockedBy ?? null, startedAt: null, finishedAt: null,
|
|
371
|
+
durationMs: null, checks, evidenceRef: null, skipped: true,
|
|
372
|
+
});
|
|
373
|
+
return stages[stageId];
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const budget = (stageId, promiseFactory) => withBudget(stageId, STAGE_BUDGET_MS[stageId], promiseFactory, runAbort.signal);
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
/* ---- stage 1: registration + one real dispatch ---------------------------------- */
|
|
382
|
+
{
|
|
383
|
+
const stage = stageStart('1');
|
|
384
|
+
if (cancelled) { stage.finish('UNKNOWN', 'cancelled'); }
|
|
385
|
+
else {
|
|
386
|
+
const result = await budget('1', () => stageOne(ctx, dispatch, crossEnvironment, toolDefinitions, runAbort.signal));
|
|
387
|
+
if (result.timeout) stage.finish('UNKNOWN', 'stage-timeout');
|
|
388
|
+
else stage.finish(result.status, result.reason, { checks: result.checks });
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
/* ---- stage 2: config + adapter + queue/lease facts ------------------------------- */
|
|
392
|
+
{
|
|
393
|
+
const stage = stageStart('2');
|
|
394
|
+
if (stages['1'] && stages['1'].status === 'FAIL') stage.skip('blocked-by-stage-1', '1');
|
|
395
|
+
else if (cancelled) stage.finish('UNKNOWN', 'cancelled');
|
|
396
|
+
else {
|
|
397
|
+
const result = await budget('2', () => stageTwo(ctx, crossEnvironment, registry, runAbort.signal));
|
|
398
|
+
if (result.timeout) stage.finish('UNKNOWN', 'stage-timeout');
|
|
399
|
+
else stage.finish(result.status, result.reason, { checks: result.checks });
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
/* ---- stage 3: existing transport + real Browser.getVersion ----------------------- */
|
|
403
|
+
{
|
|
404
|
+
const stage = stageStart('3');
|
|
405
|
+
if (stages['1'] && stages['1'].status === 'FAIL') stage.skip('blocked-by-stage-1', '1');
|
|
406
|
+
else if (cancelled) stage.finish('UNKNOWN', 'cancelled');
|
|
407
|
+
else {
|
|
408
|
+
const result = await budget('3', () => stageThree(ctx, runAbort.signal));
|
|
409
|
+
if (result.timeout) stage.finish('UNKNOWN', 'stage-timeout');
|
|
410
|
+
else stage.finish(result.status, result.reason, { checks: result.checks });
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
/* ---- stage 4: executable + product identity ------------------------------------- */
|
|
414
|
+
{
|
|
415
|
+
const stage = stageStart('4');
|
|
416
|
+
if (stages['1'] && stages['1'].status === 'FAIL') stage.skip('blocked-by-stage-1', '1');
|
|
417
|
+
else if (cancelled) stage.finish('UNKNOWN', 'cancelled');
|
|
418
|
+
else {
|
|
419
|
+
const result = await budget('4', () => stageFour(ctx, resolveProviderExecutable, webProviderSupport, runAbort.signal));
|
|
420
|
+
if (result.timeout) stage.finish('UNKNOWN', 'stage-timeout');
|
|
421
|
+
else stage.finish(result.status, result.reason, { checks: result.checks });
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
/* ---- stage 5: target list + binding + read-only eval ---------------------------- */
|
|
425
|
+
{
|
|
426
|
+
const stage = stageStart('5');
|
|
427
|
+
if (stages['1'] && stages['1'].status === 'FAIL') stage.skip('blocked-by-stage-1', '1');
|
|
428
|
+
else if (stages['3'] && stages['3'].status === 'SKIPPED') stage.skip('blocked-by-stage-3', '3');
|
|
429
|
+
// A provider mismatch leaves `ctx.runtime` present (the *other* provider's runtime), so the
|
|
430
|
+
// "no cdp" guard below does not catch it: the run would happily list that runtime's targets
|
|
431
|
+
// and answer "the page works" for a provider that has no page at all. No object to inspect
|
|
432
|
+
// means no conclusion — SKIPPED, with the two providers named in the evidence.
|
|
433
|
+
else if (providerSubjectMismatch(ctx)) {
|
|
434
|
+
ctx.stage5SkipReason = 'blocked-by-provider-mismatch';
|
|
435
|
+
stage.skip('blocked-by-provider-mismatch', '3', [providerMismatchCheck(ctx)]);
|
|
436
|
+
} else if (stages['3'] && (stages['3'].status === 'WARN' || stages['3'].status === 'FAIL') && !ctx.runtime?.cdp) {
|
|
437
|
+
ctx.stage5SkipReason = 'blocked-by-stage-3';
|
|
438
|
+
stage.skip('blocked-by-stage-3', '3');
|
|
439
|
+
} else if (cancelled) stage.finish('UNKNOWN', 'cancelled');
|
|
440
|
+
else {
|
|
441
|
+
const result = await budget('5', () => stageFive(ctx, dispatch, runAbort.signal));
|
|
442
|
+
if (result.timeout) stage.finish('UNKNOWN', 'stage-timeout');
|
|
443
|
+
else stage.finish(result.status, result.reason, { checks: result.checks });
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
} finally {
|
|
447
|
+
clearTimeout(runTimer);
|
|
448
|
+
try { res.removeListener('close', onClose); } catch { /* ignore */ }
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const axes = projectAxes(ctx, crossEnvironment, snapshot, provider);
|
|
452
|
+
const overallStatus = overallOf(stages);
|
|
453
|
+
const overall = {
|
|
454
|
+
status: overallStatus,
|
|
455
|
+
reason: overallStatus === 'PASS' ? '本次诊断检查通过' : firstReason(stages),
|
|
456
|
+
};
|
|
457
|
+
emit({
|
|
458
|
+
type: 'run-end', runId,
|
|
459
|
+
overall,
|
|
460
|
+
stages,
|
|
461
|
+
axes,
|
|
462
|
+
actualCalls: ctx.actualCalls,
|
|
463
|
+
sideEffects: ctx.sideEffects,
|
|
464
|
+
finishedAt: wall(),
|
|
465
|
+
durationMs: Math.round((mono() - startedMono) * 1000) / 1000,
|
|
466
|
+
cancelled: cancelled || undefined,
|
|
467
|
+
requestId,
|
|
468
|
+
});
|
|
469
|
+
try { res.end(); } catch { /* ignore */ }
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/* ------------------------------------------------------------------------------------
|
|
473
|
+
* Stages
|
|
474
|
+
* ---------------------------------------------------------------------------------- */
|
|
475
|
+
|
|
476
|
+
async function stageOne(ctx, dispatchTool, crossEnvironment, toolDefinitions, signal) {
|
|
477
|
+
const checks = [];
|
|
478
|
+
let declarable = [];
|
|
479
|
+
let registered = [];
|
|
480
|
+
try {
|
|
481
|
+
declarable = [...crossEnvironment.declarableToolNames()].sort();
|
|
482
|
+
registered = [...toolDefinitions.keys()].sort();
|
|
483
|
+
} catch (error) {
|
|
484
|
+
return { status: 'FAIL', reason: 'registration-unreadable', checks: [{ id: 'definitions', status: 'fail', detail: String((error && error.message) || error) }] };
|
|
485
|
+
}
|
|
486
|
+
const missing = declarable.filter((name) => !registered.includes(name));
|
|
487
|
+
checks.push({
|
|
488
|
+
id: 'definitions-registered',
|
|
489
|
+
status: missing.length ? 'fail' : 'pass',
|
|
490
|
+
detail: missing.length ? '未注册的定义:' + missing.slice(0, 6).join(',') : '声明集合与已注册定义一致(' + declarable.length + ' 个)',
|
|
491
|
+
});
|
|
492
|
+
let admissible = [];
|
|
493
|
+
try { admissible = [...crossEnvironment.admissibleToolNames(ctx.agent)].sort(); } catch { admissible = []; }
|
|
494
|
+
const notAdmitted = declarable.filter((name) => !admissible.includes(name));
|
|
495
|
+
checks.push({
|
|
496
|
+
id: 'tool-admission',
|
|
497
|
+
status: notAdmitted.length ? 'warn' : 'pass',
|
|
498
|
+
detail: notAdmitted.length ? '未准入:' + notAdmitted.slice(0, 6).join(',') : '当前作用域准入通过(' + admissible.length + ' 个)',
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
let callStatus = 'warn';
|
|
502
|
+
let callReason = 'tool-call-not-attempted';
|
|
503
|
+
try {
|
|
504
|
+
await dispatchTool(ctx, 'browser_capabilities', {}, signal);
|
|
505
|
+
callStatus = 'pass';
|
|
506
|
+
callReason = null;
|
|
507
|
+
checks.push({ id: 'tool-call', status: 'pass', detail: 'browser_capabilities 经真实 handler 返回' });
|
|
508
|
+
} catch (error) {
|
|
509
|
+
const code = String((error && error.code) || '');
|
|
510
|
+
callStatus = 'warn';
|
|
511
|
+
callReason = code ? 'tool-call-' + code.toLowerCase() : 'tool-call-failed';
|
|
512
|
+
checks.push({ id: 'tool-call', status: 'warn', detail: String((error && error.message) || error).slice(0, 200), code: code || null });
|
|
513
|
+
}
|
|
514
|
+
if (missing.length) return { status: 'FAIL', reason: 'registration-broken', checks };
|
|
515
|
+
if (callStatus !== 'pass') return { status: 'WARN', reason: callReason, checks };
|
|
516
|
+
if (notAdmitted.length) return { status: 'WARN', reason: 'tool-not-admitted', checks };
|
|
517
|
+
return { status: 'PASS', reason: null, checks };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async function stageTwo(ctx, crossEnvironment, registry, signal) {
|
|
521
|
+
const checks = [];
|
|
522
|
+
const revision = ctx.configRevision;
|
|
523
|
+
const configValid = typeof revision === 'number';
|
|
524
|
+
checks.push({ id: 'config-revision', status: configValid ? 'pass' : 'fail', detail: configValid ? 'revision ' + revision : '缺少合法 revision' });
|
|
525
|
+
let adapterOk = false;
|
|
526
|
+
let adapterError = null;
|
|
527
|
+
try {
|
|
528
|
+
const resolvedAdapter = crossEnvironment.adapterRegistry.resolve({
|
|
529
|
+
...ctx.snapshot, environment: 'web', adapterId: ctx.provider,
|
|
530
|
+
});
|
|
531
|
+
adapterOk = Boolean(resolvedAdapter && resolvedAdapter.targetAdapter);
|
|
532
|
+
checks.push({ id: 'adapter-resolve', status: adapterOk ? 'pass' : 'fail', detail: adapterOk ? 'adapter ' + ctx.provider + ' 可解析' : '未解析出适配器' });
|
|
533
|
+
} catch (error) {
|
|
534
|
+
adapterError = String((error && error.message) || error);
|
|
535
|
+
checks.push({ id: 'adapter-resolve', status: 'fail', detail: adapterError.slice(0, 200) });
|
|
536
|
+
}
|
|
537
|
+
// Real queue/lease facts. An empty queue is NOT evidence that anything executed.
|
|
538
|
+
const runtime = ctx.runtime;
|
|
539
|
+
const queue = runtime
|
|
540
|
+
? {
|
|
541
|
+
sessionId: typeof runtime.activeOwner === 'string' ? runtime.activeOwner : null,
|
|
542
|
+
inflight: Number(runtime.inflight) || 0,
|
|
543
|
+
active: Number(runtime.active) || 0,
|
|
544
|
+
lease: runtime.lease ? { sessionId: runtime.lease.sessionId, expiresAt: runtime.lease.expiresAt } : null,
|
|
545
|
+
}
|
|
546
|
+
: null;
|
|
547
|
+
checks.push({
|
|
548
|
+
id: 'queue-lease',
|
|
549
|
+
status: queue ? 'pass' : 'warn',
|
|
550
|
+
detail: queue
|
|
551
|
+
? 'inflight=' + queue.inflight + ' active=' + queue.active + ' lease=' + (queue.lease ? queue.lease.sessionId : 'none')
|
|
552
|
+
// The queue is a fact about the *existing* runtime; when that runtime belongs to another
|
|
553
|
+
// provider the numbers are still true but they are not about the requested provider.
|
|
554
|
+
+ (providerSubjectMismatch(ctx)
|
|
555
|
+
? '(现存运行时属于 ' + String(ctx.entry?.provider || 'unknown') + ',不是本次请求的 ' + String(ctx.provider) + ')'
|
|
556
|
+
: '')
|
|
557
|
+
: '没有运行时条目(冷态):队列与租约事实不可读,不能据此声称已执行',
|
|
558
|
+
});
|
|
559
|
+
if (!configValid || !adapterOk) return { status: 'FAIL', reason: configValid ? 'adapter-unresolved' : 'config-invalid', checks };
|
|
560
|
+
if (!queue) return { status: 'WARN', reason: 'no-runtime-entry', checks };
|
|
561
|
+
return { status: 'PASS', reason: null, checks };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async function stageThree(ctx, signal) {
|
|
565
|
+
const checks = [];
|
|
566
|
+
const runtime = ctx.runtime;
|
|
567
|
+
// `start` is the only mode allowed to create a browser, and it must go through the existing
|
|
568
|
+
// product path (Runtime.ensure → BrowserHost identity validation → page + CDP).
|
|
569
|
+
if (ctx.mode === 'start' && (!runtime || !runtime.cdp || runtime.cdp.closed)) {
|
|
570
|
+
if (!runtime) {
|
|
571
|
+
checks.push({ id: 'ensure', status: 'warn', detail: '没有可 ensure 的运行时条目' });
|
|
572
|
+
return { status: 'WARN', reason: 'no-runtime-entry', checks };
|
|
573
|
+
}
|
|
574
|
+
const ensureStarted = mono();
|
|
575
|
+
ctx.sideEffects.push({ kind: 'ensure', at: wall(), provider: ctx.provider, budgetMs: START_BUDGET_MS });
|
|
576
|
+
try {
|
|
577
|
+
await withBudget('ensure', START_BUDGET_MS, () => runtime.ensure(signal), signal);
|
|
578
|
+
checks.push({ id: 'ensure', status: 'pass', detail: 'Runtime.ensure 完成(' + Math.round(mono() - ensureStarted) + 'ms)' });
|
|
579
|
+
ctx.sideEffects.push({ kind: 'spawn', at: wall(), provider: ctx.provider, note: 'browser started by Runtime.ensure' });
|
|
580
|
+
const lease = runtime.lease;
|
|
581
|
+
ctx.sideEffects.push({ kind: 'lease', at: wall(), sessionId: lease ? lease.sessionId : null, expiresAt: lease ? lease.expiresAt : null });
|
|
582
|
+
} catch (error) {
|
|
583
|
+
const code = String((error && error.code) || '');
|
|
584
|
+
const busy = code === 'BUSY' || /BUSY/.test(String((error && error.message) || ''));
|
|
585
|
+
checks.push({ id: 'ensure', status: busy ? 'warn' : 'warn', detail: (code || 'ENSURE_FAILED') + ': ' + String((error && error.message) || error).slice(0, 160), code: code || null });
|
|
586
|
+
// BUSY never steals the lease; any other ensure failure is a preamble gap, not a protocol error.
|
|
587
|
+
return { status: 'WARN', reason: busy ? 'busy' : 'browser-not-running', checks };
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
if (!runtime || !runtime.cdp || runtime.cdp.closed) {
|
|
591
|
+
checks.push({ id: 'transport', status: 'warn', detail: '没有现存 CDP transport(observe 不启动浏览器)' });
|
|
592
|
+
return { status: 'WARN', reason: 'browser-not-running', checks };
|
|
593
|
+
}
|
|
594
|
+
if (ctx.entry && ctx.entry.provider && ctx.entry.provider !== ctx.provider) {
|
|
595
|
+
checks.push({ id: 'provider-match', status: 'warn', detail: '现存运行时属于 ' + ctx.entry.provider + ',本次请求是 ' + ctx.provider });
|
|
596
|
+
return { status: 'WARN', reason: 'provider-mismatch', checks };
|
|
597
|
+
}
|
|
598
|
+
try {
|
|
599
|
+
const info = await runtime.send('Browser.getVersion', {}, signal);
|
|
600
|
+
const product = typeof info?.product === 'string' ? info.product : '';
|
|
601
|
+
ctx.stage3 = { product, protocolVersion: typeof info?.protocolVersion === 'string' ? info.protocolVersion : null };
|
|
602
|
+
checks.push({ id: 'browser-get-version', status: 'pass', detail: product || '(empty product)' });
|
|
603
|
+
return { status: 'PASS', reason: null, checks };
|
|
604
|
+
} catch (error) {
|
|
605
|
+
const code = String((error && error.code) || '');
|
|
606
|
+
checks.push({ id: 'browser-get-version', status: 'fail', detail: (code || 'CDP_ERROR') + ': ' + String((error && error.message) || error).slice(0, 160) });
|
|
607
|
+
return { status: 'FAIL', reason: 'cdp-protocol-error', checks };
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async function stageFour(ctx, resolveExecutable, supportOf, signal) {
|
|
612
|
+
const checks = [];
|
|
613
|
+
if (signal?.aborted) return { status: 'UNKNOWN', reason: 'cancelled', checks };
|
|
614
|
+
let resolution = null;
|
|
615
|
+
try { resolution = resolveExecutable({ provider: ctx.provider }); } catch (error) { resolution = { path: null, reason: String((error && error.message) || error) }; }
|
|
616
|
+
const path = resolution && typeof resolution.path === 'string' ? resolution.path : null;
|
|
617
|
+
checks.push({
|
|
618
|
+
id: 'executable-file',
|
|
619
|
+
status: path ? 'pass' : 'warn',
|
|
620
|
+
detail: path ? path : '未找到可执行文件(' + String((resolution && resolution.reason) || 'executable-missing') + ')',
|
|
621
|
+
});
|
|
622
|
+
// Product identity comes from stage 3's real `Browser.getVersion`, never from the protocol name.
|
|
623
|
+
const product = ctx.stage3 && ctx.stage3.product ? ctx.stage3.product : '';
|
|
624
|
+
const isEdge = /Edg\//.test(product);
|
|
625
|
+
const isChrome = /Chrome\//.test(product) && !isEdge;
|
|
626
|
+
const expectedEdge = ctx.provider === 'edge';
|
|
627
|
+
let identity = 'unverified';
|
|
628
|
+
if (product) {
|
|
629
|
+
if (expectedEdge ? isEdge : isChrome) identity = 'match';
|
|
630
|
+
else identity = 'mismatch';
|
|
631
|
+
}
|
|
632
|
+
checks.push({
|
|
633
|
+
id: 'product-identity',
|
|
634
|
+
status: identity === 'match' ? 'pass' : identity === 'mismatch' ? 'fail' : 'warn',
|
|
635
|
+
detail: product
|
|
636
|
+
? (identity === 'match' ? '产品身份匹配:' + product : '产品身份不符:' + product)
|
|
637
|
+
// Distinguish "nothing is running" from "what is running is not the provider you asked
|
|
638
|
+
// for": the second must not read as "this provider's identity is unknown".
|
|
639
|
+
: (providerSubjectMismatch(ctx)
|
|
640
|
+
? '未取得产品身份:现存运行时属于 ' + String(ctx.entry?.provider || 'unknown')
|
|
641
|
+
+ ',不属于本次请求的 ' + String(ctx.provider) + '(该 provider 没有现存连接)'
|
|
642
|
+
: '未取得产品身份(没有现存连接)'),
|
|
643
|
+
});
|
|
644
|
+
let support = 'unsupported';
|
|
645
|
+
try { support = String(supportOf(ctx.provider) || 'unsupported'); } catch { support = 'unsupported'; }
|
|
646
|
+
checks.push({ id: 'provider-support', status: support === 'supported' ? 'pass' : 'warn', detail: support });
|
|
647
|
+
ctx.stage4 = { status: null, path, identity, product, support };
|
|
648
|
+
if (identity === 'mismatch') { ctx.stage4.status = 'FAIL'; return { status: 'FAIL', reason: 'identity-mismatch', checks }; }
|
|
649
|
+
if (!path) { ctx.stage4.status = 'WARN'; return { status: 'WARN', reason: 'executable-missing', checks }; }
|
|
650
|
+
if (identity !== 'match') { ctx.stage4.status = 'WARN'; return { status: 'WARN', reason: 'identity-unverified', checks }; }
|
|
651
|
+
ctx.stage4.status = 'PASS';
|
|
652
|
+
return { status: 'PASS', reason: null, checks };
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* True when the page group this run froze belongs to a *different* provider than the one requested.
|
|
657
|
+
*
|
|
658
|
+
* The two subjects of a run are not the same thing: stage 4 resolves an executable for
|
|
659
|
+
* `ctx.provider` (what was asked for), while stage 3/5 inspect `ctx.entry.runtime` (what already
|
|
660
|
+
* exists). When they disagree there is no object of the requested provider to inspect, so every
|
|
661
|
+
* downstream verdict about it — including "the target list works" — would describe the *other*
|
|
662
|
+
* provider. Callers must say so instead of answering.
|
|
663
|
+
*/
|
|
664
|
+
function providerSubjectMismatch(ctx) {
|
|
665
|
+
return Boolean(ctx && ctx.entry && ctx.entry.provider && ctx.entry.provider !== ctx.provider);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** One check row describing the mismatch, so the reason travels with the evidence. */
|
|
669
|
+
function providerMismatchCheck(ctx) {
|
|
670
|
+
return {
|
|
671
|
+
id: 'provider-match',
|
|
672
|
+
status: 'warn',
|
|
673
|
+
detail: '本次请求 provider=' + String(ctx.provider)
|
|
674
|
+
+ ',被诊断的运行时属于 ' + String(ctx.entry?.provider || 'unknown')
|
|
675
|
+
+ ':该 provider 下没有可诊断对象,本阶段不给结论',
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async function stageFive(ctx, dispatchTool, signal) {
|
|
680
|
+
const checks = [];
|
|
681
|
+
const runtime = ctx.runtime;
|
|
682
|
+
// Defence in depth: the orchestrator already refuses to call this stage for a mismatched
|
|
683
|
+
// provider. Answering here anyway would grade the *other* provider's page.
|
|
684
|
+
if (providerSubjectMismatch(ctx)) {
|
|
685
|
+
checks.push(providerMismatchCheck(ctx));
|
|
686
|
+
return { status: 'SKIPPED', reason: 'blocked-by-provider-mismatch', checks };
|
|
687
|
+
}
|
|
688
|
+
let targets = [];
|
|
689
|
+
try {
|
|
690
|
+
const listed = await runtime.send('Target.getTargets', {}, signal);
|
|
691
|
+
targets = Array.isArray(listed?.targetInfos) ? listed.targetInfos : [];
|
|
692
|
+
ctx.stage5ListOk = true;
|
|
693
|
+
checks.push({ id: 'target-list', status: 'pass', detail: targets.length + ' 个 target' });
|
|
694
|
+
} catch (error) {
|
|
695
|
+
checks.push({ id: 'target-list', status: 'fail', detail: String((error && error.message) || error).slice(0, 160) });
|
|
696
|
+
return { status: 'FAIL', reason: 'target-list-failed', checks };
|
|
697
|
+
}
|
|
698
|
+
const boundTargetId = runtime.page && runtime.page.targetId ? runtime.page.targetId : null;
|
|
699
|
+
if (!boundTargetId) {
|
|
700
|
+
checks.push({ id: 'binding', status: 'warn', detail: '没有已绑定的 page target(不自动挑页/建页/改绑定)' });
|
|
701
|
+
return { status: 'WARN', reason: 'no-bound-target', checks };
|
|
702
|
+
}
|
|
703
|
+
const pageTarget = targets.find((target) => target && target.targetId === boundTargetId);
|
|
704
|
+
checks.push({
|
|
705
|
+
id: 'binding',
|
|
706
|
+
status: pageTarget && pageTarget.type === 'page' ? 'pass' : 'warn',
|
|
707
|
+
detail: pageTarget ? 'targetId ' + boundTargetId + '(' + String(pageTarget.type || 'unknown') + ')' : 'targetId ' + boundTargetId + ' 不在当前列表',
|
|
708
|
+
});
|
|
709
|
+
let evalValue;
|
|
710
|
+
try {
|
|
711
|
+
const result = await dispatchTool(ctx, 'browser_eval', { expression: '1' }, signal);
|
|
712
|
+
const payload = result.value && typeof result.value === 'object' ? result.value : {};
|
|
713
|
+
// `browser_eval` answers `{ok, value, type, ...}`; tolerate the nested CDP shape as a fallback.
|
|
714
|
+
evalValue = 'value' in payload
|
|
715
|
+
? payload.value
|
|
716
|
+
: (payload.result && typeof payload.result === 'object' && 'value' in payload.result ? payload.result.value : undefined);
|
|
717
|
+
checks.push({ id: 'readonly-eval', status: evalValue === 1 ? 'pass' : 'warn', detail: 'browser_eval(\'1\') → ' + String(evalValue) });
|
|
718
|
+
} catch (error) {
|
|
719
|
+
const code = String((error && error.code) || '');
|
|
720
|
+
checks.push({ id: 'readonly-eval', status: 'fail', detail: (code || 'EVAL_FAILED') + ': ' + String((error && error.message) || error).slice(0, 160) });
|
|
721
|
+
return { status: 'FAIL', reason: 'readonly-eval-failed', checks };
|
|
722
|
+
}
|
|
723
|
+
ctx.stage5 = { targetId: boundTargetId, evalValue };
|
|
724
|
+
if (!pageTarget || pageTarget.type !== 'page') return { status: 'WARN', reason: 'target-not-in-list', checks };
|
|
725
|
+
if (evalValue !== 1) return { status: 'WARN', reason: 'readonly-eval-unexpected', checks };
|
|
726
|
+
|
|
727
|
+
// 加深诊断:真的跑几条**只读**能力探测(快照 / 截图 / 计算样式 / 网络元数据读取)。
|
|
728
|
+
// 只看摘要,绝不把页面树或图片塞进 NDJSON;预算 8s 总、单条 3s,BUSY 记 skip 不夺权。
|
|
729
|
+
const probes = await runProbes(ctx, dispatchTool, signal);
|
|
730
|
+
ctx.probes = probes;
|
|
731
|
+
for (const probe of probes) checks.push(probe);
|
|
732
|
+
const probeFailures = probes.filter((probe) => probe.status !== 'pass');
|
|
733
|
+
if (probeFailures.length) {
|
|
734
|
+
return { status: 'WARN', reason: 'capability-partial', checks };
|
|
735
|
+
}
|
|
736
|
+
return { status: 'PASS', reason: null, checks };
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Read-only capability probes. Every call still goes through the real handler (admission,
|
|
741
|
+
* adapter, Runtime.run); we only keep a *digest* of the answer, so `run-end` stays small.
|
|
742
|
+
*/
|
|
743
|
+
async function runProbes(ctx, dispatchTool, signal) {
|
|
744
|
+
const deadline = mono() + PROBE_LIMITS.probeBudgetMs;
|
|
745
|
+
const out = [];
|
|
746
|
+
const push = (id, status, detail) => { out.push({ id, status, detail }); };
|
|
747
|
+
const summariseScreenshot = (payload) => {
|
|
748
|
+
let image = '';
|
|
749
|
+
for (const candidate of [payload && payload.image, payload && payload.data, payload && payload.base64]) {
|
|
750
|
+
if (typeof candidate === 'string' && candidate.length > 64) { image = candidate; break; }
|
|
751
|
+
}
|
|
752
|
+
const stats = { bytes: 0, width: null, height: null, sha256: null };
|
|
753
|
+
const findNumber = (key) => {
|
|
754
|
+
for (const source of [payload, payload && payload.capture, payload && payload.capture && payload.capture.image, payload && payload.stats]) {
|
|
755
|
+
if (source && typeof source === 'object' && Number.isFinite(source[key])) return source[key];
|
|
756
|
+
}
|
|
757
|
+
return null;
|
|
758
|
+
};
|
|
759
|
+
stats.width = findNumber('width');
|
|
760
|
+
stats.height = findNumber('height');
|
|
761
|
+
if (image) {
|
|
762
|
+
let buffer = null;
|
|
763
|
+
try { buffer = Buffer.from(image, 'base64'); } catch { buffer = null; }
|
|
764
|
+
if (buffer) {
|
|
765
|
+
stats.bytes = buffer.length;
|
|
766
|
+
stats.sha256 = createHash('sha256').update(buffer).digest('hex').slice(0, 16);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
if (!stats.bytes && Number.isFinite(findNumber('bytes'))) stats.bytes = findNumber('bytes');
|
|
770
|
+
return stats;
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
const run = async (id, tool, args) => {
|
|
774
|
+
if (mono() > deadline) { push(id, 'skip', 'probe-budget-exhausted'); return; }
|
|
775
|
+
if (ctx.cancelled && ctx.cancelled()) { push(id, 'skip', 'cancelled'); return; }
|
|
776
|
+
try {
|
|
777
|
+
const result = await withBudget('probe-' + id, PROBE_LIMITS.probeTimeoutMs, () => dispatchTool(ctx, tool, args, signal), signal);
|
|
778
|
+
if (result && result.timeout) { push(id, 'fail', 'probe-timeout'); return; }
|
|
779
|
+
const payload = result && result.value && typeof result.value === 'object' ? result.value : {};
|
|
780
|
+
if (id === 'snapshot') {
|
|
781
|
+
const snapshot = payload.snapshot && typeof payload.snapshot === 'object' ? payload.snapshot : payload;
|
|
782
|
+
const nodes = Array.isArray(snapshot.nodes) ? snapshot.nodes.length
|
|
783
|
+
: (Number.isFinite(snapshot.nodeCount) ? snapshot.nodeCount : null);
|
|
784
|
+
const snapshotId = typeof snapshot.snapshotId === 'string' ? snapshot.snapshotId
|
|
785
|
+
: (typeof payload.snapshotId === 'string' ? payload.snapshotId : null);
|
|
786
|
+
ctx.probeDigests = Object.assign({}, ctx.probeDigests, { snapshot: { nodeCount: nodes, snapshotId } });
|
|
787
|
+
push(id, nodes === null ? 'fail' : 'pass', nodes === null ? '快照未返回节点数' : 'nodeCount=' + nodes + (snapshotId ? ' · id=' + snapshotId : ''));
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
if (id === 'screenshot') {
|
|
791
|
+
const stats = summariseScreenshot(payload);
|
|
792
|
+
ctx.probeDigests = Object.assign({}, ctx.probeDigests, { screenshot: stats });
|
|
793
|
+
push(id, stats.bytes > 0 ? 'pass' : 'fail',
|
|
794
|
+
stats.bytes > 0
|
|
795
|
+
? 'png ' + stats.bytes + 'B · ' + String(stats.width || '?') + 'x' + String(stats.height || '?') + ' · ' + String(stats.sha256)
|
|
796
|
+
: '截图未返回图像数据');
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
if (id === 'computed-style') {
|
|
800
|
+
const style = payload.style && typeof payload.style === 'object' ? payload.style : payload;
|
|
801
|
+
const display = style && (style.display || (style.properties && style.properties.display));
|
|
802
|
+
push(id, display ? 'pass' : 'fail', display ? 'body.display=' + String(display).slice(0, 24) : '未取到计算样式值');
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
if (id === 'network-read') {
|
|
806
|
+
const requests = Array.isArray(payload.requests) ? payload.requests
|
|
807
|
+
: (Array.isArray(payload.entries) ? payload.entries : (Array.isArray(payload) ? payload : null));
|
|
808
|
+
push(id, requests ? 'pass' : 'fail', requests ? 'read only · ' + requests.length + ' 条(未启动采集)' : '返回结构不合法');
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
push(id, 'fail', '未知探测');
|
|
812
|
+
} catch (error) {
|
|
813
|
+
const code = String((error && error.code) || '');
|
|
814
|
+
if (code === 'BUSY' || /BUSY/.test(String((error && error.message) || ''))) { push(id, 'skip', 'busy(不夺权)'); return; }
|
|
815
|
+
if (code === 'ABORTED' || (error && error.name === 'AbortError')) { push(id, 'skip', 'cancelled'); return; }
|
|
816
|
+
push(id, 'fail', (code || 'PROBE_FAILED') + ': ' + String((error && error.message) || error).slice(0, 120));
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
await run('snapshot', 'browser_snapshot', { maxNodes: 50 });
|
|
821
|
+
await run('screenshot', 'browser_screenshot', {});
|
|
822
|
+
await run('computed-style', 'browser_computed_style', { selector: 'body', properties: ['display'] });
|
|
823
|
+
await run('network-read', 'browser_network', { limit: 5 });
|
|
824
|
+
return out;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/* ------------------------------------------------------------------------------------
|
|
828
|
+
* Projection
|
|
829
|
+
* ---------------------------------------------------------------------------------- */
|
|
830
|
+
|
|
831
|
+
function projectAxes(ctx, crossEnvironment, snapshot, provider) {
|
|
832
|
+
const saved = (() => {
|
|
833
|
+
try {
|
|
834
|
+
const config = crossEnvironment.configStore.getConfig();
|
|
835
|
+
const web = config?.global?.environments?.web;
|
|
836
|
+
const value = web?.providers?.[provider]?.enabled;
|
|
837
|
+
return value === true ? 'true' : value === false ? 'false' : 'not-probed';
|
|
838
|
+
} catch { return 'not-probed'; }
|
|
839
|
+
})();
|
|
840
|
+
let support = 'not-probed';
|
|
841
|
+
try {
|
|
842
|
+
const raw = String((deps.webProviderSupport && deps.webProviderSupport(provider)) || '');
|
|
843
|
+
if (raw) support = raw === 'planned' ? 'planned' : raw;
|
|
844
|
+
} catch { support = 'not-probed'; }
|
|
845
|
+
const stage3 = ctx.stage3;
|
|
846
|
+
const stage4 = ctx.stage4;
|
|
847
|
+
const stage5 = ctx.stage5;
|
|
848
|
+
const dependency = stage4?.status === 'PASS' ? 'ok' : stage4?.status === 'FAIL' ? 'failed' : 'not-probed';
|
|
849
|
+
const connection = stage3?.product ? 'ok' : 'not-probed';
|
|
850
|
+
const target = stage5?.targetId ? 'bound' : 'unbound';
|
|
851
|
+
// capability only claims what was actually measured: a successful read-only eval proves the
|
|
852
|
+
// read-only JS path, nothing else.
|
|
853
|
+
// capability 现在由**真跑过的六条只读探测**决定:全过 → available,部分过 → partial,
|
|
854
|
+
// 一条没跑(冷态/无连接)→ not-probed。没有连接时明确记 skip(不是 fail),避免污染结论。
|
|
855
|
+
const probes = Array.isArray(ctx.probes) ? ctx.probes : [];
|
|
856
|
+
const checks = {};
|
|
857
|
+
const checksDetail = [];
|
|
858
|
+
const addCheck = (id, status, detail) => {
|
|
859
|
+
checks[id] = status;
|
|
860
|
+
checksDetail.push({ id, status, detail });
|
|
861
|
+
};
|
|
862
|
+
if (stage5) {
|
|
863
|
+
addCheck('readonly-eval', stage5.evalValue === 1 ? 'pass' : 'fail', stage5.evalValue === 1 ? '返回 1' : '返回值不是 1');
|
|
864
|
+
addCheck('target-list', ctx.stage5ListOk ? 'pass' : 'fail', ctx.stage5ListOk ? '已列出目标' : '未列出目标');
|
|
865
|
+
for (const probe of probes) addCheck(probe.id, probe.status, probe.detail);
|
|
866
|
+
} else {
|
|
867
|
+
const why = ctx.stage5SkipReason === 'blocked-by-provider-mismatch'
|
|
868
|
+
? '运行时不属于本次请求的 provider:没有该 provider 的可诊断对象,未执行探测'
|
|
869
|
+
: '无连接:本次诊断没有可用的调试连接,未执行探测';
|
|
870
|
+
for (const id of ['readonly-eval', 'target-list', 'snapshot', 'screenshot', 'computed-style', 'network-read']) {
|
|
871
|
+
addCheck(id, 'skip', why);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
const ran = checksDetail.filter((check) => check.status !== 'skip');
|
|
875
|
+
const passed = checksDetail.filter((check) => check.status === 'pass');
|
|
876
|
+
let capability = 'not-probed';
|
|
877
|
+
if (passed.length && passed.length === checksDetail.length) capability = 'available';
|
|
878
|
+
else if (passed.length) capability = 'partial';
|
|
879
|
+
else capability = 'not-probed';
|
|
880
|
+
return {
|
|
881
|
+
support,
|
|
882
|
+
enabled: saved,
|
|
883
|
+
dependency,
|
|
884
|
+
connection,
|
|
885
|
+
target,
|
|
886
|
+
capability,
|
|
887
|
+
checks,
|
|
888
|
+
checksDetail,
|
|
889
|
+
digests: ctx.probeDigests || {},
|
|
890
|
+
probeCount: { total: checksDetail.length, ran: ran.length, passed: passed.length },
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function overallOf(stages) {
|
|
895
|
+
const list = STAGE_IDS.map((id) => stages[id]).filter(Boolean);
|
|
896
|
+
const statuses = list.map((stage) => stage.status);
|
|
897
|
+
if (statuses.includes('FAIL')) return 'FAIL';
|
|
898
|
+
if (statuses.includes('UNKNOWN')) return 'UNKNOWN';
|
|
899
|
+
if (statuses.includes('WARN')) return 'WARN';
|
|
900
|
+
const requiredSkipped = list.filter((stage) => REQUIRED_STAGES.has(stage.stageId) && stage.status === 'SKIPPED');
|
|
901
|
+
if (requiredSkipped.length) return 'WARN';
|
|
902
|
+
return 'PASS';
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function firstReason(stages) {
|
|
906
|
+
for (const id of STAGE_IDS) {
|
|
907
|
+
const stage = stages[id];
|
|
908
|
+
if (!stage) continue;
|
|
909
|
+
if (stage.status === 'FAIL' || stage.status === 'UNKNOWN' || stage.status === 'WARN' || stage.status === 'SKIPPED') {
|
|
910
|
+
return (stage.reason || 'stage-' + id) + (stage.blockedBy ? ' (blockedBy:' + stage.blockedBy + ')' : '');
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
return null;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
return { handleHttp };
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/** Bound one stage; a timeout is UNKNOWN, never a silent PASS. */
|
|
920
|
+
async function withBudget(stageId, budgetMs, factory, outerSignal) {
|
|
921
|
+
const controller = new AbortController();
|
|
922
|
+
const onAbort = () => controller.abort();
|
|
923
|
+
if (outerSignal) {
|
|
924
|
+
if (outerSignal.aborted) controller.abort();
|
|
925
|
+
else outerSignal.addEventListener('abort', onAbort, { once: true });
|
|
926
|
+
}
|
|
927
|
+
let timer = null;
|
|
928
|
+
const timeout = new Promise((resolve) => {
|
|
929
|
+
timer = setTimeout(() => {
|
|
930
|
+
controller.abort();
|
|
931
|
+
resolve({ timeout: true });
|
|
932
|
+
}, budgetMs);
|
|
933
|
+
});
|
|
934
|
+
try {
|
|
935
|
+
const result = await Promise.race([
|
|
936
|
+
Promise.resolve().then(() => factory(controller.signal)).then((value) => ({ value })),
|
|
937
|
+
timeout,
|
|
938
|
+
]);
|
|
939
|
+
if (result.timeout) return { timeout: true, stageId };
|
|
940
|
+
return result.value;
|
|
941
|
+
} finally {
|
|
942
|
+
if (timer !== null) clearTimeout(timer);
|
|
943
|
+
if (outerSignal) outerSignal.removeEventListener('abort', onAbort);
|
|
944
|
+
}
|
|
945
|
+
}
|