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,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Panel-side surface for a *native* `<select>` (host half).
|
|
3
|
+
*
|
|
4
|
+
* The problem this module serves: a native `<select>` popup is painted by the browser UI layer, not
|
|
5
|
+
* by the page, so it never appears in the screencast/screenshot the panel renders
|
|
6
|
+
* (`select.matches(':open')` is true, the element is `document.activeElement`, and the frame still
|
|
7
|
+
* shows nothing). Keyboard `↑↓ + Enter` works because it goes through the page; the mouse path
|
|
8
|
+
* needs two things the panel can drive by itself:
|
|
9
|
+
*
|
|
10
|
+
* 1. **Read** the options of the hit `<select>` (read-only projection, no side effects) — done by
|
|
11
|
+
* `POST /element`, which uses `normalizeSelectProjection` + `selectShapeSupport` from here.
|
|
12
|
+
* 2. **Write** one value back through the page (native setter + `input`/`change`) — done by
|
|
13
|
+
* `POST /select`, which uses `selectWriteExpression` + `classifySelectWrite` from here.
|
|
14
|
+
*
|
|
15
|
+
* Everything in this file is pure and dependency-free so the contract suite can pin it without a
|
|
16
|
+
* browser, a Runtime or a Cordis context. It never touches `index.js` state, never allocates a
|
|
17
|
+
* runtime and never throws: refusals are *values* (`{ok:false, code, message, details}`) so the
|
|
18
|
+
* HTTP route decides the status code and the page's `lastError` projection stays untouched.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Options are capped so one pathological `<select>` cannot bloat a panel response. */
|
|
22
|
+
export const SELECT_OPTION_LIMIT = 200;
|
|
23
|
+
|
|
24
|
+
/** Reasons a `<select>` cannot be driven by the panel popover (keyboard still works). */
|
|
25
|
+
export const SELECT_UNSUPPORTED_REASON = Object.freeze({
|
|
26
|
+
MULTIPLE: 'multiple',
|
|
27
|
+
SIZE: 'size',
|
|
28
|
+
EMPTY: 'empty',
|
|
29
|
+
DETACHED: 'detached',
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** Human-facing hint the panel renders for an unsupported shape (one line, keyboard escape hatch). */
|
|
33
|
+
export const SELECT_UNSUPPORTED_HINT = '该下拉不支持浮层,请用键盘 ↑↓ + Enter';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Coerce the page-side probe into the public projection.
|
|
37
|
+
*
|
|
38
|
+
* The page function returns `{multiple, size, value, options[{value,label,disabled,selected}],
|
|
39
|
+
* truncated}`; every field is re-coerced here because the page is untrusted input from the Host's
|
|
40
|
+
* point of view (a page may hand back holes, huge arrays or non-strings).
|
|
41
|
+
*
|
|
42
|
+
* @returns {null|{multiple:boolean,size:number,value:string,
|
|
43
|
+
* options:Array<{value:string,label:string,disabled:boolean,selected:boolean}>,truncated:boolean}}
|
|
44
|
+
* `null` means "this is not a select projection" — the caller then leaves the /element reply
|
|
45
|
+
* shape untouched.
|
|
46
|
+
*/
|
|
47
|
+
export function normalizeSelectProjection(raw) {
|
|
48
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
49
|
+
const rawOptions = Array.isArray(raw.options) ? raw.options : null;
|
|
50
|
+
if (!rawOptions) return null;
|
|
51
|
+
const capped = rawOptions.slice(0, SELECT_OPTION_LIMIT);
|
|
52
|
+
const size = Number(raw.size);
|
|
53
|
+
return {
|
|
54
|
+
multiple: raw.multiple === true,
|
|
55
|
+
// `size` is the raw IDL value: 0 means "not set" (a closed single-line select), so both 0 and 1
|
|
56
|
+
// are the supported single-line shape.
|
|
57
|
+
size: Number.isFinite(size) && size >= 0 ? size : 0,
|
|
58
|
+
value: typeof raw.value === 'string' ? raw.value : String(raw.value === null || raw.value === undefined ? '' : raw.value),
|
|
59
|
+
options: capped.map((option) => {
|
|
60
|
+
const item = option && typeof option === 'object' ? option : {};
|
|
61
|
+
return {
|
|
62
|
+
value: typeof item.value === 'string' ? item.value : String(item.value === null || item.value === undefined ? '' : item.value),
|
|
63
|
+
label: typeof item.label === 'string' ? item.label : String(item.label === null || item.label === undefined ? '' : item.label),
|
|
64
|
+
disabled: item.disabled === true,
|
|
65
|
+
selected: item.selected === true,
|
|
66
|
+
};
|
|
67
|
+
}),
|
|
68
|
+
truncated: raw.truncated === true || rawOptions.length > SELECT_OPTION_LIMIT,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The page-side probe passed to `Runtime.callFunctionOn` (a function declaration, `this` = the
|
|
74
|
+
* node). Pure read: it never assigns, never dispatches, never awaits.
|
|
75
|
+
*
|
|
76
|
+
* `SELECT_OPTION_LIMIT` is inlined so the page never ships an unbounded list to the Host; the cap is
|
|
77
|
+
* reported through `truncated` instead of being silent.
|
|
78
|
+
*/
|
|
79
|
+
export function selectProbeDeclaration() {
|
|
80
|
+
return `function () {
|
|
81
|
+
if (!this || typeof this.tagName !== 'string' || this.tagName.toLowerCase() !== 'select') return null;
|
|
82
|
+
const all = Array.from(this.options || []);
|
|
83
|
+
const limit = ${SELECT_OPTION_LIMIT};
|
|
84
|
+
const capped = all.slice(0, limit);
|
|
85
|
+
return {
|
|
86
|
+
multiple: this.multiple === true,
|
|
87
|
+
size: Number(this.size) || 0,
|
|
88
|
+
value: String(this.value === null || this.value === undefined ? '' : this.value),
|
|
89
|
+
options: capped.map((option) => ({
|
|
90
|
+
value: String(option.value === null || option.value === undefined ? '' : option.value),
|
|
91
|
+
label: String(option.label === null || option.label === undefined ? (option.textContent || '') : option.label),
|
|
92
|
+
disabled: this.disabled === true || (typeof this.matches === 'function' && this.matches(':disabled')) ||
|
|
93
|
+
option.disabled === true || Boolean(option.parentElement && String(option.parentElement.tagName).toLowerCase() === 'optgroup' && option.parentElement.disabled === true),
|
|
94
|
+
selected: option.selected === true,
|
|
95
|
+
})),
|
|
96
|
+
truncated: all.length > limit,
|
|
97
|
+
};
|
|
98
|
+
}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Can the panel popover drive this select?
|
|
103
|
+
*
|
|
104
|
+
* Only a single-line, non-multiple `<select>` with at least one option qualifies. Anything else
|
|
105
|
+
* keeps the keyboard path (`↑↓ + Enter`), which the panel already forwards to the page.
|
|
106
|
+
*
|
|
107
|
+
* @returns {{supported:boolean, reason:string|null}}
|
|
108
|
+
*/
|
|
109
|
+
export function selectShapeSupport(projection) {
|
|
110
|
+
if (!projection || typeof projection !== 'object') return { supported: false, reason: SELECT_UNSUPPORTED_REASON.DETACHED };
|
|
111
|
+
if (projection.multiple === true) return { supported: false, reason: SELECT_UNSUPPORTED_REASON.MULTIPLE };
|
|
112
|
+
const size = Number(projection.size);
|
|
113
|
+
if (Number.isFinite(size) && size > 1) return { supported: false, reason: SELECT_UNSUPPORTED_REASON.SIZE };
|
|
114
|
+
const options = Array.isArray(projection.options) ? projection.options : [];
|
|
115
|
+
if (options.length === 0) return { supported: false, reason: SELECT_UNSUPPORTED_REASON.EMPTY };
|
|
116
|
+
return { supported: true, reason: null };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Validate the `POST /select` body.
|
|
121
|
+
*
|
|
122
|
+
* `value` is mandatory and must be a string (a `<select>` value is always a string). `selector` is
|
|
123
|
+
* optional; when present it must be a non-empty string and address exactly one element. Optional
|
|
124
|
+
* `optionIndex` identifies an exact option even when several options share the same value. Optional
|
|
125
|
+
* `target` carries the Host's node/document identity; the Host verifies it before binding the write.
|
|
126
|
+
*
|
|
127
|
+
* @returns {{ok:true,value:string,selector:string|null,optionIndex:number|null,target:object|null}} | {{ok:false,code:string,message:string,details:object}}
|
|
128
|
+
*/
|
|
129
|
+
export function normalizeSelectWriteRequest(body) {
|
|
130
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
131
|
+
return { ok: false, code: 'INVALID_ARGUMENT', message: '/select 需要 body: {value, selector?, optionIndex?, target?}', details: { field: 'body' } };
|
|
132
|
+
}
|
|
133
|
+
if (typeof body.value !== 'string') {
|
|
134
|
+
return { ok: false, code: 'INVALID_ARGUMENT', message: '/select 的 value 必须是字符串', details: { field: 'value', type: typeof body.value } };
|
|
135
|
+
}
|
|
136
|
+
let selector = null;
|
|
137
|
+
if (body.selector !== undefined && body.selector !== null) {
|
|
138
|
+
if (typeof body.selector !== 'string' || body.selector.trim() === '') {
|
|
139
|
+
return { ok: false, code: 'INVALID_ARGUMENT', message: 'selector 必须是非空字符串', details: { field: 'selector' } };
|
|
140
|
+
}
|
|
141
|
+
selector = body.selector.trim();
|
|
142
|
+
if (selector.length > 4096) {
|
|
143
|
+
return { ok: false, code: 'INVALID_ARGUMENT', message: 'selector 过长', details: { field: 'selector', maxLength: 4096 } };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
let optionIndex = null;
|
|
147
|
+
if (body.optionIndex !== undefined && body.optionIndex !== null) {
|
|
148
|
+
if (!Number.isSafeInteger(body.optionIndex) || body.optionIndex < 0) {
|
|
149
|
+
return { ok: false, code: 'INVALID_ARGUMENT', message: 'optionIndex 必须是非负安全整数', details: { field: 'optionIndex' } };
|
|
150
|
+
}
|
|
151
|
+
optionIndex = body.optionIndex;
|
|
152
|
+
}
|
|
153
|
+
let target = null;
|
|
154
|
+
if (body.target !== undefined && body.target !== null) {
|
|
155
|
+
const raw = body.target;
|
|
156
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
157
|
+
return { ok: false, code: 'INVALID_ARGUMENT', message: 'target 必须是包含 backendNodeId 和 loaderId 的对象', details: { field: 'target' } };
|
|
158
|
+
}
|
|
159
|
+
if (!Number.isSafeInteger(raw.backendNodeId) || raw.backendNodeId <= 0) {
|
|
160
|
+
return { ok: false, code: 'INVALID_ARGUMENT', message: 'target.backendNodeId 必须是正安全整数', details: { field: 'target.backendNodeId' } };
|
|
161
|
+
}
|
|
162
|
+
if (typeof raw.loaderId !== 'string' || raw.loaderId.trim() === '' || raw.loaderId.length > 4096) {
|
|
163
|
+
return { ok: false, code: 'INVALID_ARGUMENT', message: 'target.loaderId 必须是非空字符串且不超过 4096 字符', details: { field: 'target.loaderId' } };
|
|
164
|
+
}
|
|
165
|
+
target = { backendNodeId: raw.backendNodeId, loaderId: raw.loaderId };
|
|
166
|
+
}
|
|
167
|
+
return { ok: true, value: body.value, selector, optionIndex, target };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The page-side write: one self-contained IIFE.
|
|
172
|
+
*
|
|
173
|
+
* Use the native `selectedIndex` setter to identify an exact option even when values repeat. Calling
|
|
174
|
+
* the prototype setter also bypasses instance accessors installed by frameworks. The prior selected
|
|
175
|
+
* state is captured before writing, then `input` and `change` are dispatched in that order.
|
|
176
|
+
*
|
|
177
|
+
* With `objectBound:true`, the IIFE inherits `this` from the Host's `Runtime.callFunctionOn` wrapper.
|
|
178
|
+
* That exact node takes precedence over both selector and focus; a detached node is never replaced
|
|
179
|
+
* by another target.
|
|
180
|
+
*
|
|
181
|
+
* The function is pure with respect to the Host: it reads, writes one element and dispatches two
|
|
182
|
+
* events, and it answers a JSON-serialisable object in every branch.
|
|
183
|
+
*/
|
|
184
|
+
export function selectWriteExpression({ value, selector = null, optionIndex = null } = {}, { objectBound = false } = {}) {
|
|
185
|
+
const wanted = JSON.stringify(typeof value === 'string' ? value : String(value ?? ''));
|
|
186
|
+
const sel = selector === null || selector === undefined ? 'null' : JSON.stringify(String(selector));
|
|
187
|
+
const index = optionIndex === null || optionIndex === undefined ? 'null' : JSON.stringify(optionIndex);
|
|
188
|
+
return `(() => {
|
|
189
|
+
const wantedValue = ${wanted};
|
|
190
|
+
const selector = ${sel};
|
|
191
|
+
const requestedIndex = ${index};
|
|
192
|
+
const objectBound = ${objectBound === true};
|
|
193
|
+
let target = null;
|
|
194
|
+
if (objectBound) {
|
|
195
|
+
target = this;
|
|
196
|
+
if (!target || target.isConnected === false) return { ok: false, reason: 'target-detached' };
|
|
197
|
+
} else if (selector !== null) {
|
|
198
|
+
let matches;
|
|
199
|
+
try { matches = document.querySelectorAll(selector); } catch (error) {
|
|
200
|
+
return { ok: false, reason: 'selector-invalid', message: String(error && error.message || error) };
|
|
201
|
+
}
|
|
202
|
+
if (!matches || matches.length === 0) return { ok: false, reason: 'selector-not-found', selector };
|
|
203
|
+
if (matches.length > 1) return { ok: false, reason: 'selector-not-unique', selector, count: matches.length };
|
|
204
|
+
target = matches[0];
|
|
205
|
+
} else {
|
|
206
|
+
target = document.activeElement;
|
|
207
|
+
if (!target || target === document.body || target === document.documentElement) {
|
|
208
|
+
return { ok: false, reason: 'no-focused-element' };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const tag = String(target.tagName || '').toLowerCase();
|
|
212
|
+
if (tag !== 'select') return { ok: false, reason: 'not-a-select', tag, focused: !objectBound && selector === null };
|
|
213
|
+
if (target.disabled || (typeof target.matches === 'function' && target.matches(':disabled'))) {
|
|
214
|
+
return { ok: false, reason: 'select-disabled' };
|
|
215
|
+
}
|
|
216
|
+
const multiple = target.multiple === true;
|
|
217
|
+
const size = Number(target.size) || 0;
|
|
218
|
+
if (multiple || size > 1) return { ok: false, reason: 'unsupported-shape', multiple, size };
|
|
219
|
+
const options = Array.from(target.options || []);
|
|
220
|
+
if (options.length === 0) return { ok: false, reason: 'no-options' };
|
|
221
|
+
if (requestedIndex !== null && (!Number.isInteger(requestedIndex) || requestedIndex < 0 || requestedIndex >= options.length)) {
|
|
222
|
+
return { ok: false, reason: 'option-index-out-of-range', optionIndex: requestedIndex, optionCount: options.length };
|
|
223
|
+
}
|
|
224
|
+
const index = requestedIndex === null ? options.findIndex((item) => String(item.value) === wantedValue) : requestedIndex;
|
|
225
|
+
const option = options[index];
|
|
226
|
+
if (!option) {
|
|
227
|
+
return { ok: false, reason: 'value-not-in-options', value: wantedValue, optionCount: options.length,
|
|
228
|
+
values: options.slice(0, 50).map((item) => String(item.value)) };
|
|
229
|
+
}
|
|
230
|
+
if (String(option.value) !== wantedValue) {
|
|
231
|
+
return { ok: false, reason: 'option-value-mismatch', value: wantedValue, optionIndex: index };
|
|
232
|
+
}
|
|
233
|
+
if (option.disabled === true || (option.parentElement && String(option.parentElement.tagName).toLowerCase() === 'optgroup' && option.parentElement.disabled === true)) {
|
|
234
|
+
return { ok: false, reason: 'option-disabled', optionIndex: index, value: wantedValue };
|
|
235
|
+
}
|
|
236
|
+
const wasSelected = option.selected === true;
|
|
237
|
+
let setter = null;
|
|
238
|
+
try {
|
|
239
|
+
setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'selectedIndex');
|
|
240
|
+
} catch (error) { setter = null; }
|
|
241
|
+
if (setter && typeof setter.set === 'function') setter.set.call(target, index);
|
|
242
|
+
else target.selectedIndex = index;
|
|
243
|
+
target.dispatchEvent(new Event('input', { bubbles: true }));
|
|
244
|
+
target.dispatchEvent(new Event('change', { bubbles: true }));
|
|
245
|
+
return {
|
|
246
|
+
ok: true,
|
|
247
|
+
value: String(target.value),
|
|
248
|
+
selectedIndex: Number(target.selectedIndex),
|
|
249
|
+
optionIndex: index,
|
|
250
|
+
wasSelected,
|
|
251
|
+
};
|
|
252
|
+
})()`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Classify the page answer into the route's contract.
|
|
257
|
+
*
|
|
258
|
+
* Every refusal keeps a stable `reason` in `details` (the panel and the tests branch on that, not on
|
|
259
|
+
* message text) and uses `INVALID_ARGUMENT` — the request named something this page cannot honour,
|
|
260
|
+
* which is an input answer rather than a Host failure. `INTERNAL` is reserved for an answer the
|
|
261
|
+
* function itself could not produce.
|
|
262
|
+
*/
|
|
263
|
+
export function classifySelectWrite(raw) {
|
|
264
|
+
if (!raw || typeof raw !== 'object') {
|
|
265
|
+
return { ok: false, code: 'INTERNAL', message: '/select 的页面结果不可读', details: { reason: 'unreadable-result' } };
|
|
266
|
+
}
|
|
267
|
+
if (raw.ok === true) {
|
|
268
|
+
return { ok: true, value: String(raw.value ?? ''), selectedIndex: Number.isFinite(Number(raw.selectedIndex)) ? Number(raw.selectedIndex) : null };
|
|
269
|
+
}
|
|
270
|
+
const reason = typeof raw.reason === 'string' && raw.reason ? raw.reason : 'unknown';
|
|
271
|
+
const details = { reason };
|
|
272
|
+
if (typeof raw.selector === 'string') details.selector = raw.selector;
|
|
273
|
+
if (Number.isFinite(Number(raw.count))) details.count = Number(raw.count);
|
|
274
|
+
if (Number.isFinite(Number(raw.size))) details.size = Number(raw.size);
|
|
275
|
+
if (typeof raw.multiple === 'boolean') details.multiple = raw.multiple;
|
|
276
|
+
if (typeof raw.tag === 'string') details.tag = raw.tag;
|
|
277
|
+
if (typeof raw.focused === 'boolean') details.focused = raw.focused;
|
|
278
|
+
if (typeof raw.value === 'string') details.value = raw.value;
|
|
279
|
+
if (Number.isSafeInteger(raw.optionIndex) && raw.optionIndex >= 0) details.optionIndex = raw.optionIndex;
|
|
280
|
+
if (Number.isFinite(Number(raw.optionCount))) details.optionCount = Number(raw.optionCount);
|
|
281
|
+
if (Array.isArray(raw.values)) details.values = raw.values.slice(0, 50).map((item) => String(item));
|
|
282
|
+
const message = {
|
|
283
|
+
'selector-invalid': 'selector 不是合法的 CSS 选择器',
|
|
284
|
+
'selector-not-found': 'selector 没有命中任何元素',
|
|
285
|
+
'selector-not-unique': 'selector 命中了多个元素(必须唯一)',
|
|
286
|
+
'no-focused-element': '页面没有聚焦的元素:先点一下 <select>,或显式给 selector',
|
|
287
|
+
'not-a-select': '目标元素不是 <select>',
|
|
288
|
+
'select-disabled': '<select> 处于 disabled 状态',
|
|
289
|
+
'target-detached': '绑定的 <select> 已脱离文档',
|
|
290
|
+
'option-index-out-of-range': 'optionIndex 不在 <select> 的 options 范围内',
|
|
291
|
+
'option-value-mismatch': 'optionIndex 对应选项的 value 已变化',
|
|
292
|
+
'option-disabled': '目标 option 或所属 optgroup 处于 disabled 状态',
|
|
293
|
+
'unsupported-shape': '只支持单选 <select>(multiple/size>1 请用键盘 ↑↓ + Enter)',
|
|
294
|
+
'no-options': '<select> 没有可选项',
|
|
295
|
+
'value-not-in-options': '目标值不在 <select> 的 options 里',
|
|
296
|
+
}[reason] || '下拉写入失败';
|
|
297
|
+
return { ok: false, code: 'INVALID_ARGUMENT', message, details };
|
|
298
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ui-testing-common
|
|
3
|
+
description: UI 测试的跨环境中立操作规则,凡是「让你在某个已接入的目标上看一眼/操作一下并给结论」的常规表述都算命中:「帮我看看这个界面/这个页面/这个窗口」「点一下试试、填一下、走一遍这个流程」「为什么点了没反应/报错了」「这个环境能用吗/为什么工具不可用」「你看我标的这里/我圈的是这块」(人类在调试视图上的标注=通用输入通道,先读标注别让他口述)「给我证据/复现一下/能不能测」。规则:先锁目标与契约、再探能力、观测优先于动作、用独立证据判定、区分未检测/不可用/不支持;具体环境的工具名、协议与限制以该环境扩展 Skill 与运行态能力发现为准。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# UI Testing · 跨环境公共规则
|
|
7
|
+
|
|
8
|
+
## 何时使用
|
|
9
|
+
|
|
10
|
+
当你需要通过已接入的调试环境观察或操作一个目标(网页、桌面应用、设备等)并给出可复核结论时使用。本 Skill 只写环境中立规则;具体工具面、坐标空间、协议与限制由当前环境的扩展 Skill 与运行态能力发现提供。
|
|
11
|
+
|
|
12
|
+
命中示例(环境无关,换成任何目标都成立):「帮我看看这个界面现在什么状态」「点一下这个按钮试试」「走一遍登录流程」「为什么点了没反应/为什么会报错」「这个环境能用吗、为什么工具不可用」「你看我标的这里、我圈的是这块」「给我证据、复现一下」。不命中:纯文案写作、后端接口设计、与被测目标无关的通用编程问题。
|
|
13
|
+
|
|
14
|
+
## 事实来源与边界
|
|
15
|
+
|
|
16
|
+
- **工程支持、用户启用、依赖可用、连接可信、目标已绑定、能力就绪是六个独立问题**,不得合并成一个“有效”布尔值。
|
|
17
|
+
- 静态文档、清单与历史记录只说明工程状态,不构成运行态可用性证明;以能力发现与真实调用结果为准。
|
|
18
|
+
- 「本环境的工具面」= 该环境**注册并准入**的工具集合(本插件 Web 环境当前是 37 个 `browser_*`);环境未启用时这些工具不注册,调用报 `ADAPTER_DISABLED`,不是工具缺陷。
|
|
19
|
+
- 缺失能力要如实写 `unsupported`;暂时不可用写 `unavailable`;尚未检测写 `not-probed`。不得用推断代替观测。
|
|
20
|
+
- 不同目标、不同 generation、不可兼容的坐标/几何版本不得直接比较;切换目标后旧引用、旧帧、旧网络记录与旧标注按身份边界作废。
|
|
21
|
+
|
|
22
|
+
## 最短流程
|
|
23
|
+
|
|
24
|
+
1. **锁目标与契约**:写下验收原文与来源、目标身份、需要的坐标空间/视口/环境版本、前置条件。契约不明只能观察并记 `UNKNOWN`。
|
|
25
|
+
2. **探能力**:先调用能力发现,记录支持与现场可用性;缺能力就停在该步。
|
|
26
|
+
3. **页面/目标身份门禁**:动作前记录期望身份,动作后用独立读取核对;不一致不得用另一个目标的通过覆盖本题。
|
|
27
|
+
4. **先观测再动作**:记录初始状态与基线,再做最小必要动作。
|
|
28
|
+
5. **独立证据**:回读目标真值、结构/像素/网络/帧证据;`ok:true`、`dispatched:true`、`applied:N` 都不是断言结果。
|
|
29
|
+
6. **判定五元组**:`expected_contract` / `observation` / `evidence_ids` / `counterexamples` / `verdict`。
|
|
30
|
+
7. **覆盖声明**:任何替代路径都要写清替代了什么、仍缺哪条原始断言、`coverage=full|partial|blocked`。
|
|
31
|
+
8. **提交前交叉校验**:verdict↔observation 异常词、verdict↔缺陷清单、observation↔证据、数字↔来源、limitation↔status、截图↔文件名与声明;任一项未消解不得 PASS。
|
|
32
|
+
|
|
33
|
+
## 人类标注:环境无关的输入通道
|
|
34
|
+
|
|
35
|
+
- 人类在调试视图(面板画布)上画图是**指路信号**:他说「看我框的」「这个箭头指哪」时,先按当前环境的能力发现找读取入口(本插件 Web 环境是 `browser_state()` 顶层的 `annotations`,`browser_capabilities().annotations` 声明坐标空间与限额),再去定位元素;**不要让用户改用文字描述或给候选编号**。
|
|
36
|
+
- 标注是**画布坐标**,不是页面坐标:换算成页面坐标要减去**当前**的视图偏移,不能用发布那一刻的视图快照。
|
|
37
|
+
- `source.trusted` 恒为 false:标注是线索,不是页面事实,也不授权自动点击;结论仍要被结构化/像素/网络/帧证据支撑。
|
|
38
|
+
- 用户提到标注、画、圈、框、箭头或笔迹等指代面板标注时,读取结构化文档后还应查看当前环境提供的带标注截图(Web:`browser_screenshot {includeAnnotations:true}`)。Web 的 `includeAnnotations?:boolean=false` 默认关闭,默认截图、T3 原始源与 T4 帧源不含面板标注;默认图里没有线不能证明用户没画。
|
|
39
|
+
- Web 带标注截图只叠加已提交且经 Host 接受的六种形状,遵循面板渲染语义与本次截图的 region/scale 映射,不含未提交草稿、工具栏或其它面板 UI。用 `capture.annotations` 核对 revision、context、新鲜度、截断与 `source.trusted=false`;写标注走该环境的写入口(Web 为 `browser_annotation`),不是页面动作。
|
|
40
|
+
- `brush.points` 是几何点集,`text.text` 才有文字原文;可从带标注截图观察手写笔迹,无法可靠辨认时如实说明,不猜字。
|
|
41
|
+
- 标注按工作区隔离,导航/刷新会清空、跨页签或跨会话可能保留;引用时要带上 `revision` 与 `context.documentKey` 这类身份字段,过期就重读。
|
|
42
|
+
|
|
43
|
+
## 判定门禁
|
|
44
|
+
|
|
45
|
+
- 证据不足、采样不足、目标身份不明、坐标空间不兼容:`BLOCKED` 或 `INCONCLUSIVE`,不得写 PASS。
|
|
46
|
+
- 读取型探测不证明输入已生效;输入闭环必须在受控目标上验证。
|
|
47
|
+
- 配置帧率/请求参数不能冒充实测采样;报告实测值、时钟与空档。
|
|
48
|
+
- 历史已注入的上下文不会被开关自动撤回;隔离只对后续轮次生效。
|
|
49
|
+
|
|
50
|
+
## 安全
|
|
51
|
+
|
|
52
|
+
- 只操作被授权的目标;不读写被禁止的目录,不索取或回显明文秘密。
|
|
53
|
+
- 高风险操作先确认前置条件与回滚/清理路径;失败后复验。
|
|
54
|
+
- 不把密钥、token 写进命令、脚本或仓库文件;证据只保留必要元数据。
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ui-testing-fake
|
|
3
|
+
description: 仅用于契约测试的假调试环境(test-only):验证跨环境适配器、配置准入、证据与生命周期。它不是真实平台实现,不得据此声称任何真实桌面/Android/小程序环境已支持。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# UI Testing · 假环境(test-only)
|
|
7
|
+
|
|
8
|
+
## 边界
|
|
9
|
+
|
|
10
|
+
- 本 Skill 只在 `DSH_UI_TESTING_TEST_HOOKS=1` 或 `DSH_UI_TESTING_ALLOW_TEST_ENV=1` 的测试配置中可发现;生产默认不可见、不可加载。
|
|
11
|
+
- 假环境只提供确定性夹具:目标列表、帧/图像、网络事件、显示空间、标注与租约;不启动真实浏览器、不连接真实设备。
|
|
12
|
+
- 不得把假环境的通过写成真实平台可用;真实平台仍需独立适配器、独立探针与独立验收。
|
|
13
|
+
|
|
14
|
+
## 用途
|
|
15
|
+
|
|
16
|
+
用于验证三类 adapter 的公共契约:配置 revision 一致性、目标 generation、证据 envelope、P0 可选能力、取消/清理与跨环境隔离。测试记录必须区分“假适配器契约通过”与“真实平台未验证”。
|
|
17
|
+
|
|
18
|
+
## 怎么用(Agent 操作口径)
|
|
19
|
+
|
|
20
|
+
1. **先确认发现条件**:`browser_capabilities` 的 `readiness.environments` 里能看到 `fake`,且 `enabled=false` 时不要直接调工具——它还没被启用。
|
|
21
|
+
2. **启用并选择**:让用户(或测试脚本)在设置页面板启用 `fake` 环境;本会话选择用 `POST /plugins/ui-testing/config/select`(`scope:"session"`)。工具面会在下一步按新的启用集合重建。
|
|
22
|
+
3. **确认注册面**:启用 fake 后 `browser_capabilities.adapter.declaration.registeredTools` 应包含 `browser_fake_probe` + 两个环境无关诊断;Web 未启用时应**不含**任何 `browser_*` Web 工具。
|
|
23
|
+
4. **跑探针**:调 `browser_fake_probe`(可带 `payload`),确认返回 `environment:"fake"` 且结果 envelope 带 `configRevision`;这证明执行入口用的是当前配置。
|
|
24
|
+
5. **做对照**:Web 与 fake 的差异要落在"注册面 / Skill 目录 / 执行准入"三处,任一处不符就记 FAIL,别只截图。
|
|
25
|
+
|
|
26
|
+
## 断言清单(缺一条就不算契约通过)
|
|
27
|
+
|
|
28
|
+
| 检查 | 期望 |
|
|
29
|
+
| --- | --- |
|
|
30
|
+
| 配置 revision | 工具结果里的 `configRevision` 与 `GET /config` 的 `revision` 一致;旧 revision 的调用被判 `CONFIG_STALE` 或重新解析 |
|
|
31
|
+
| 目标 generation | `listTargets`→`bind` 后 `generation` 递增;重绑后旧帧/旧标注/旧租约按 `targetId + generation` 被拒(`GENERATION_MISMATCH`),不串目标 |
|
|
32
|
+
| 证据 envelope | P0 每次成功调用带 `version/scope/session/target/generation/configRevision/operation/source/capturedAt`,缺字段即 `EVIDENCE_INCOMPLETE` |
|
|
33
|
+
| 可选能力 | 去掉某个 P0 家族时该家族报 `unsupported`,其余家族仍可用;不得伪造空数据 |
|
|
34
|
+
| 取消/清理 | 取消后连接/帧源/网络/租约都被释放,不遗留后台任务 |
|
|
35
|
+
| 跨环境隔离 | 禁用某环境后其工具不进注册面、其 Skill 不在目录里(`ui-testing-web` 与 `ui-testing-common` 的差别就是判据) |
|
|
36
|
+
|
|
37
|
+
## 常见误判
|
|
38
|
+
|
|
39
|
+
- 用 fake 的结果去宣称 Android/桌面/小程序"已支持"——不行,那是另一套适配器与另一套验收。
|
|
40
|
+
- 把"工具被拒"当成隔离证据:要看**注册面/请求声明**,而不是 handler 返回 `ADAPTER_DISABLED`。
|
|
41
|
+
- 把"页面工具 available"当成"浏览器已经连上":连接状态看 `connection` 轴,未启动是 `not-probed`。
|
|
42
|
+
|
|
43
|
+
三类 adapter 的接口形状与完整坑点清单是**贡献者文档**:`docs/adapters.md`(Agent 日常操作不需要读它,按上面的断言清单用工具即可)。
|