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
package/context-menu.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/** Debug-panel context menu bridge. Chromium's browser UI is absent from page frames. */
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { BrowserError } from './browser-safety.js';
|
|
4
|
+
|
|
5
|
+
function frameOf(tree, id) {
|
|
6
|
+
if (tree?.frame?.id === id) return tree.frame;
|
|
7
|
+
for (const child of tree?.childFrames || []) {
|
|
8
|
+
const found = frameOf(child, id);
|
|
9
|
+
if (found) return found;
|
|
10
|
+
}
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
const stale = () => new BrowserError('INVALID_ARGUMENT', '右键目标已变化,请重新打开菜单', { reason: 'stale-target' });
|
|
14
|
+
const boundedText = (value) => typeof value === 'string' ? value.slice(0, 65536) : '';
|
|
15
|
+
function webUrl(value) {
|
|
16
|
+
try { const url = new URL(value); return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password ? url.href : ''; }
|
|
17
|
+
catch { return ''; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Run in an isolated world of the hit frame. Retain the Event itself, then read its final
|
|
21
|
+
// cancellation flag in a separate CDP call AFTER dispatch; capture-time/microtask guesses are wrong.
|
|
22
|
+
export function contextProbeExpression(key, read = false) {
|
|
23
|
+
const name = JSON.stringify(key);
|
|
24
|
+
if (read) return `(() => {
|
|
25
|
+
const probe = globalThis[${name}];
|
|
26
|
+
if (!probe) return null;
|
|
27
|
+
probe.dispose(); delete globalThis[${name}];
|
|
28
|
+
const event = probe.event;
|
|
29
|
+
if (!event || !event.isTrusted || event.eventPhase !== 0) return null;
|
|
30
|
+
if (event.defaultPrevented) return { status: 'handled' };
|
|
31
|
+
const node = probe.node;
|
|
32
|
+
const el = node && node.nodeType === 1 ? node : node?.parentElement;
|
|
33
|
+
if (!el || !el.isConnected) return null;
|
|
34
|
+
const input = el.closest('input,textarea');
|
|
35
|
+
const password = input?.type === 'password';
|
|
36
|
+
let text = '';
|
|
37
|
+
if (!password) {
|
|
38
|
+
if (input && typeof input.selectionStart === 'number') text = String(input.value).slice(input.selectionStart, input.selectionEnd);
|
|
39
|
+
else text = String(window.getSelection() || '');
|
|
40
|
+
}
|
|
41
|
+
const link = el.closest('a[href]');
|
|
42
|
+
return { status: 'default', text: text.slice(0,65536), link: link?.href || '',
|
|
43
|
+
image: el.tagName === 'IMG' ? el.currentSrc || el.src : '' };
|
|
44
|
+
})()`;
|
|
45
|
+
return `(() => {
|
|
46
|
+
const probe = { event: null, node: null };
|
|
47
|
+
const listener = event => { if (event.isTrusted && event.button === 2) { probe.event = event; probe.node = event.composedPath()[0]; } };
|
|
48
|
+
window.addEventListener('contextmenu', listener, true);
|
|
49
|
+
const timer = setTimeout(() => { probe.dispose(); delete globalThis[${name}]; }, 5000);
|
|
50
|
+
probe.dispose = () => { clearTimeout(timer); window.removeEventListener('contextmenu', listener, true); };
|
|
51
|
+
globalThis[${name}] = probe;
|
|
52
|
+
return true;
|
|
53
|
+
})()`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function contextItems(facts, history, pageUrl) {
|
|
57
|
+
const items = [
|
|
58
|
+
{ id: 'back', label: '后退', disabled: !(history.currentIndex > 0) },
|
|
59
|
+
{ id: 'forward', label: '前进', disabled: !(history.currentIndex < (history.entries?.length || 0) - 1) },
|
|
60
|
+
{ id: 'reload', label: '重新加载' },
|
|
61
|
+
];
|
|
62
|
+
const copy = (id, label, text) => { if (text) items.push({ id, label, text }); };
|
|
63
|
+
copy('copy', '复制', boundedText(facts.text));
|
|
64
|
+
copy('copy-link', '复制链接地址', webUrl(facts.link));
|
|
65
|
+
copy('copy-image', '复制图片地址', webUrl(facts.image));
|
|
66
|
+
copy('copy-page', '复制页面地址', webUrl(pageUrl));
|
|
67
|
+
return items;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Called inside the same Runtime serial queue/GUI gate as input. x/y are viewport CSS px. */
|
|
71
|
+
export async function openPageContextMenu(runtime, { x, y, modifiers = 0 }, signal) {
|
|
72
|
+
if (![x, y].every(Number.isFinite) || x < 0 || y < 0 || !Number.isInteger(modifiers) || modifiers < 0 || modifiers > 15) {
|
|
73
|
+
throw new BrowserError('INVALID_ARGUMENT', '右键坐标或修饰键无效');
|
|
74
|
+
}
|
|
75
|
+
runtime.panelContextMenu = null;
|
|
76
|
+
await runtime.ensure(signal);
|
|
77
|
+
const send = (method, params) => runtime.send(method, params, signal);
|
|
78
|
+
const metrics = await send('Page.getLayoutMetrics', {});
|
|
79
|
+
const view = metrics.cssLayoutViewport || metrics.layoutViewport;
|
|
80
|
+
if (!view || x >= view.clientWidth || y >= view.clientHeight) throw new BrowserError('INVALID_ARGUMENT', '右键坐标超出页面');
|
|
81
|
+
const tree = await send('Page.getFrameTree', {});
|
|
82
|
+
let contextId, backendNodeId, frame, key;
|
|
83
|
+
try {
|
|
84
|
+
const hit = await send('DOM.getNodeForLocation', { x: Math.floor(x + view.pageX), y: Math.floor(y + view.pageY), includeUserAgentShadowDOM: false });
|
|
85
|
+
frame = frameOf(tree.frameTree, hit.frameId || tree.frameTree.frame.id);
|
|
86
|
+
backendNodeId = hit.backendNodeId;
|
|
87
|
+
if (frame?.loaderId && backendNodeId) {
|
|
88
|
+
const world = await send('Page.createIsolatedWorld', { frameId: frame.id, worldName: 'dsh-panel-context-menu' });
|
|
89
|
+
contextId = world.executionContextId;
|
|
90
|
+
key = '__dsh_context_' + randomUUID().replaceAll('-', '');
|
|
91
|
+
const armed = await send('Runtime.evaluate', { contextId, expression: contextProbeExpression(key), returnByValue: true });
|
|
92
|
+
if (armed.result?.value !== true || armed.exceptionDetails) contextId = null;
|
|
93
|
+
}
|
|
94
|
+
} catch { contextId = null; }
|
|
95
|
+
let answer;
|
|
96
|
+
try {
|
|
97
|
+
await runtime.input([{ kind: 'mouse', type: 'mousePressed', x, y, button: 'right', buttons: 2, clickCount: 1, modifiers }], signal);
|
|
98
|
+
} finally {
|
|
99
|
+
// One queued gesture always pairs the release, including cancellation and leaving the image.
|
|
100
|
+
if (runtime.cdp && !runtime.cdp.closed) {
|
|
101
|
+
await runtime.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'right', buttons: 0, clickCount: 1, modifiers });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (!contextId) return { status: 'unknown' };
|
|
105
|
+
try {
|
|
106
|
+
answer = await send('Runtime.evaluate', { contextId, expression: contextProbeExpression(key, true), returnByValue: true });
|
|
107
|
+
} catch { return { status: 'unknown' }; }
|
|
108
|
+
const facts = answer.result?.value;
|
|
109
|
+
if (facts?.status === 'handled') return { status: 'handled' };
|
|
110
|
+
if (facts?.status !== 'default') return { status: 'unknown' };
|
|
111
|
+
const after = await send('Page.getFrameTree', {});
|
|
112
|
+
if (frameOf(after.frameTree, frame.id)?.loaderId !== frame.loaderId || after.frameTree.frame.loaderId !== tree.frameTree.frame.loaderId) return { status: 'unknown' };
|
|
113
|
+
const history = await send('Page.getNavigationHistory', {});
|
|
114
|
+
const items = contextItems(facts, history, after.frameTree.frame.url);
|
|
115
|
+
const token = randomUUID();
|
|
116
|
+
runtime.panelContextMenu = { token, at: Date.now(), frameId: frame.id, loaderId: frame.loaderId,
|
|
117
|
+
mainLoaderId: tree.frameTree.frame.loaderId, backendNodeId, contextId, items };
|
|
118
|
+
return { status: 'default', menu: { token, items } };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Exact recent gesture and document required; never execute a client-supplied URL or script. */
|
|
122
|
+
export async function applyPageContextAction(runtime, request, signal) {
|
|
123
|
+
const menu = runtime.panelContextMenu;
|
|
124
|
+
if (!menu || request.token !== menu.token || Date.now() - menu.at > 60000) throw stale();
|
|
125
|
+
const item = menu.items.find(item => item.id === request.action && !item.disabled);
|
|
126
|
+
if (!item) throw new BrowserError('INVALID_ARGUMENT', '右键菜单项无效');
|
|
127
|
+
const tree = await runtime.send('Page.getFrameTree', {}, signal);
|
|
128
|
+
if (tree.frameTree.frame.loaderId !== menu.mainLoaderId || frameOf(tree.frameTree, menu.frameId)?.loaderId !== menu.loaderId) throw stale();
|
|
129
|
+
runtime.panelContextMenu = null;
|
|
130
|
+
if (item.text) return { text: item.text };
|
|
131
|
+
if (item.id === 'reload') return runtime.reload(signal);
|
|
132
|
+
if (item.id === 'back' || item.id === 'forward') return runtime.historyMove(item.id, signal);
|
|
133
|
+
throw stale();
|
|
134
|
+
}
|
package/core/clock.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clock helpers (P0-A metadata, M0 §3).
|
|
3
|
+
*
|
|
4
|
+
* Two clocks are kept separate: wall time for human-facing timestamps, monotonic time for
|
|
5
|
+
* durations. A source timestamp is never invented: missing/non-finite values become `null`, and a
|
|
6
|
+
* frame without a source timestamp must not claim a derived source time.
|
|
7
|
+
*/
|
|
8
|
+
import { ERROR_CODES } from './contracts.js';
|
|
9
|
+
import { AdapterError } from './errors.js';
|
|
10
|
+
|
|
11
|
+
const NS_PER_MS = 1_000_000n;
|
|
12
|
+
|
|
13
|
+
/** Current wall-clock milliseconds (epoch). */
|
|
14
|
+
export function nowWallMs() {
|
|
15
|
+
return Date.now();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Current monotonic milliseconds from `process.hrtime.bigint()`.
|
|
20
|
+
*
|
|
21
|
+
* Monotonic values are only meaningful relative to each other; do not persist them as dates.
|
|
22
|
+
*/
|
|
23
|
+
export function nowMonoMs() {
|
|
24
|
+
return Number(process.hrtime.bigint()) / Number(NS_PER_MS);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function assertFinite(value, field) {
|
|
28
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
29
|
+
throw new AdapterError(ERROR_CODES.INVALID_ARGUMENT, `${field} must be a finite number`, {
|
|
30
|
+
details: { field, value: Number.isNaN(value) ? 'NaN' : typeof value },
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Anchor a monotonic reading to wall time once; later conversions reuse the anchor.
|
|
38
|
+
*
|
|
39
|
+
* @param {{wallMs:number, monoMs:number}} input
|
|
40
|
+
* @returns {{wallMs:number, monoMs:number}}
|
|
41
|
+
*/
|
|
42
|
+
export function createClockAnchor({ wallMs, monoMs } = {}) {
|
|
43
|
+
return {
|
|
44
|
+
wallMs: assertFinite(wallMs, 'wallMs'),
|
|
45
|
+
monoMs: assertFinite(monoMs, 'monoMs'),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Convert a monotonic reading into wall milliseconds using a fixed anchor. */
|
|
50
|
+
export function toSourceTime(anchor, monoMs) {
|
|
51
|
+
if (anchor === null || typeof anchor !== 'object') {
|
|
52
|
+
throw new AdapterError(ERROR_CODES.INVALID_ARGUMENT, 'anchor must be a clock anchor object', {
|
|
53
|
+
details: { field: 'anchor' },
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
const wall = assertFinite(anchor.wallMs, 'anchor.wallMs');
|
|
57
|
+
const base = assertFinite(anchor.monoMs, 'anchor.monoMs');
|
|
58
|
+
return wall + (assertFinite(monoMs, 'monoMs') - base);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Normalize a source timestamp: undefined/null/non-finite/non-number -> null.
|
|
63
|
+
*
|
|
64
|
+
* `0` is a valid timestamp and is preserved.
|
|
65
|
+
*/
|
|
66
|
+
export function sourceTimestampOrNull(value) {
|
|
67
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Refuse to fabricate a source time: a frame with `sourceTimestamp: null` must not carry a
|
|
72
|
+
* `derivedSource` value. Returns the frame when the invariant holds.
|
|
73
|
+
*/
|
|
74
|
+
export function assertNoFabricatedSourceTime(frame) {
|
|
75
|
+
if (frame === null || typeof frame !== 'object') {
|
|
76
|
+
throw new AdapterError(ERROR_CODES.INVALID_ARGUMENT, 'frame must be an object', {
|
|
77
|
+
details: { field: 'frame' },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
const hasTimestamp = typeof frame.sourceTimestamp === 'number' && Number.isFinite(frame.sourceTimestamp);
|
|
81
|
+
if (!hasTimestamp && Object.hasOwn(frame, 'derivedSource') && frame.derivedSource !== null
|
|
82
|
+
&& frame.derivedSource !== undefined) {
|
|
83
|
+
throw new AdapterError(ERROR_CODES.EVIDENCE_INCOMPLETE, 'frame has no source timestamp but claims a derived source time', {
|
|
84
|
+
details: { frameId: typeof frame.frameId === 'string' ? frame.frameId : null },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return frame;
|
|
88
|
+
}
|