gz-laya-pilot 0.1.0

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/src/browser.ts ADDED
@@ -0,0 +1,20 @@
1
+ // Adapted from anthonyli/laya-pilot lib/browser-provider.mjs (MIT).
2
+ import { chromium, type LaunchOptions, type BrowserContextOptions } from 'playwright';
3
+ export async function openBrowser(
4
+ options: { launch?: LaunchOptions; context?: BrowserContextOptions } = {},
5
+ ) {
6
+ const browser = await chromium.launch(options.launch);
7
+ try {
8
+ const context = await browser.newContext({
9
+ viewport: { width: 1500, height: 980 },
10
+ locale: 'zh-CN',
11
+ ...options.context,
12
+ });
13
+ const page = await context.newPage();
14
+ page.setDefaultTimeout(10000);
15
+ return { browser, context, page };
16
+ } catch (error) {
17
+ await browser.close();
18
+ throw error;
19
+ }
20
+ }
@@ -0,0 +1,85 @@
1
+ // Functional port of anthonyli/laya-pilot lib/model.mjs (MIT).
2
+ export interface Decision {
3
+ choice: string;
4
+ probabilities: Record<string, number>;
5
+ confidence: number | null;
6
+ }
7
+ export type Decide = (state: string, criteria: Record<string, string>) => Promise<Decision>;
8
+ export function decodeDecision(body: unknown, criteria: Record<string, string>): Decision {
9
+ const envelope = body as { choices?: { message?: { content?: string } }[] };
10
+ let result: Decision;
11
+ try {
12
+ result = JSON.parse(envelope.choices![0].message!.content!).result.answers.scenario;
13
+ } catch {
14
+ throw Error('模型没有返回有效的 Laya 决策 JSON');
15
+ }
16
+ const keys = Object.keys(criteria);
17
+ if (
18
+ !result ||
19
+ !Object.hasOwn(criteria, result.choice) ||
20
+ !result.probabilities ||
21
+ Object.keys(result.probabilities).length !== keys.length ||
22
+ keys.some(
23
+ (key) =>
24
+ !Number.isFinite(result.probabilities[key]) ||
25
+ result.probabilities[key] < 0 ||
26
+ result.probabilities[key] > 1,
27
+ )
28
+ )
29
+ throw Error('模型返回了候选集合之外的选择或无效概率');
30
+ if (
31
+ Math.abs(keys.reduce((sum, key) => sum + result.probabilities[key], 0) - 1) > 0.02 ||
32
+ keys.some((key) => result.probabilities[key] > result.probabilities[result.choice])
33
+ )
34
+ throw Error('模型概率与选择不一致');
35
+ return {
36
+ choice: result.choice,
37
+ probabilities: result.probabilities,
38
+ confidence: Number.isFinite(result.confidence) ? result.confidence : null,
39
+ };
40
+ }
41
+ export function createApiDecision(options: {
42
+ base: string;
43
+ model: string;
44
+ key: string;
45
+ timeout?: number;
46
+ fetchImpl?: typeof fetch;
47
+ }): Decide {
48
+ const url = new URL(options.base);
49
+ if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash)
50
+ throw Error('API 地址必须是无凭据及查询参数的 HTTPS 地址');
51
+ if (!options.key.trim()) throw Error('缺少模型 API 密钥');
52
+ return async (state, criteria) => {
53
+ const response = await (options.fetchImpl ?? fetch)(
54
+ url.href.replace(/\/$/, '') + '/chat/completions',
55
+ {
56
+ method: 'POST',
57
+ redirect: 'error',
58
+ signal: AbortSignal.timeout(options.timeout ?? 30000),
59
+ headers: { Authorization: `Bearer ${options.key}`, 'Content-Type': 'application/json' },
60
+ body: JSON.stringify({
61
+ model: options.model,
62
+ stream: false,
63
+ messages: [
64
+ {
65
+ role: 'system',
66
+ content:
67
+ '你是浏览器测试决策器。只返回 JSON:{"result":{"answers":{"scenario":{"choice":"候选原文","probabilities":{"每个候选":0.0},"confidence":0.0}}}}。choice 必须属于 criteria,概率覆盖全部候选且和为1,所选概率最大。证据不足选择停止,不编造确定性。',
68
+ },
69
+ {
70
+ role: 'user',
71
+ content: JSON.stringify({
72
+ state,
73
+ questions: {
74
+ scenario: { type: 'choice', instructions: '用户请求对应哪个操作?', criteria },
75
+ },
76
+ }),
77
+ },
78
+ ],
79
+ }),
80
+ },
81
+ );
82
+ if (!response.ok) throw Error(`模型 API 返回 HTTP ${response.status}`);
83
+ return decodeDecision(await response.json(), criteria);
84
+ };
85
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export * from './schema.js';
2
+ export { runCase, type ReplayOptions } from './replay.js';
3
+ export { runAssertion } from './assertions.js';
4
+ export { openBrowser } from './browser.js';
5
+ export { waitForReady } from './readiness.js';
6
+ export { createApiDecision, decodeDecision, type Decision, type Decide } from './decision.js';
7
+ export { chooseStep } from './planner.js';
package/src/planner.ts ADDED
@@ -0,0 +1,28 @@
1
+ import type { Decide } from './decision.js';
2
+ import type { Step } from './schema.js';
3
+
4
+ /** A model selects from observed, host-provided candidates. It never generates executable code. */
5
+ export async function chooseStep(
6
+ instruction: string,
7
+ candidates: { description: string; step: Step }[],
8
+ decide: Decide,
9
+ ): Promise<Step | null> {
10
+ if (candidates.length < 1 || candidates.length > 7)
11
+ throw Error('每次提供 1–7 个可观察的候选操作');
12
+ const criteria = Object.fromEntries(
13
+ candidates.map((candidate, index) => [String(index), candidate.description]),
14
+ );
15
+ criteria.stop = '无法判断,停止并由用户确认';
16
+ const result = await decide(instruction, criteria);
17
+ if (result.choice === 'stop') return null;
18
+ const index = Number(result.choice);
19
+ if (!Number.isInteger(index) || !Object.hasOwn(criteria, result.choice) || !candidates[index])
20
+ throw Error('模型选择不属于候选集');
21
+ const probabilities = Object.values(result.probabilities).sort((a, b) => b - a);
22
+ if (
23
+ (result.probabilities[result.choice] ?? 0) < 0.7 ||
24
+ probabilities[0] - (probabilities[1] ?? 0) < 0.15
25
+ )
26
+ return null;
27
+ return structuredClone(candidates[index].step);
28
+ }
@@ -0,0 +1,92 @@
1
+ // Adapted from anthonyli/laya-pilot lib/readiness.mjs (MIT).
2
+ import type { Page } from 'playwright';
3
+ type Failure = { status: number; path: string; resourceType: string };
4
+ type ReadyOptions = { timeout?: number; minimum?: number; failures?: () => Failure[] };
5
+
6
+ // Wait for the application, not merely the outer document's DOMContentLoaded.
7
+ export async function waitForReady(
8
+ page: Page,
9
+ { timeout = 18000, minimum = 600, failures = () => [] }: ReadyOptions = {},
10
+ ) {
11
+ const start = Date.now();
12
+ let stableSince = Date.now(),
13
+ previous = '',
14
+ last;
15
+ while (Date.now() - start < timeout) {
16
+ const errors = failures().filter(
17
+ (x) =>
18
+ x.status >= 500 && (/\.html?$|\.js$|\.css$/i.test(x.path) || x.resourceType === 'document'),
19
+ );
20
+ const states = [];
21
+ for (const frame of page.frames())
22
+ try {
23
+ states.push(
24
+ await frame.evaluate(() => {
25
+ const visible = (e: Element) => {
26
+ const r = e.getBoundingClientRect(),
27
+ s = getComputedStyle(e);
28
+ return (
29
+ r.width > 0 &&
30
+ r.height > 0 &&
31
+ s.display !== 'none' &&
32
+ s.visibility !== 'hidden' &&
33
+ !e.closest('[aria-hidden="true"],[inert]')
34
+ );
35
+ };
36
+ const body = document.body;
37
+ if (!body) return { controls: 0, busy: true, text: '' };
38
+ const text = body.innerText.trim(),
39
+ controls = [
40
+ ...document.querySelectorAll(
41
+ 'button,input:not([type=hidden]),select,textarea,a[href],[role=tab],[role=combobox],[role=menuitem]',
42
+ ),
43
+ ].filter(visible);
44
+ const loading =
45
+ [
46
+ ...document.querySelectorAll(
47
+ '[aria-busy=true],.ant-spin-spinning,.el-loading-mask,[role=progressbar]',
48
+ ),
49
+ ].some(visible) ||
50
+ text
51
+ .split('\n')
52
+ .some((x) => /^(?:loading\.{0,3}|加载中[.。…]*|正在加载[.。…]*)$/i.test(x.trim()));
53
+ return {
54
+ controls: controls.length,
55
+ busy: loading,
56
+ text: text.slice(0, 240),
57
+ signature: controls
58
+ .slice(0, 60)
59
+ .map((x) =>
60
+ [
61
+ x.getAttribute('role'),
62
+ x.tagName,
63
+ x.getAttribute('placeholder'),
64
+ x.getAttribute('aria-label'),
65
+ x.textContent?.trim().slice(0, 25),
66
+ ].join(':'),
67
+ )
68
+ .join('|'),
69
+ };
70
+ }),
71
+ );
72
+ } catch { /* A frame can detach while the page is settling. */ }
73
+ const count = states.reduce((n, s) => n + s.controls, 0),
74
+ busy = states.some((s) => s.busy),
75
+ fingerprint = states.map((s) => s.signature || '').join('|');
76
+ last = { count, busy };
77
+ // A shell may preload unrelated microfrontends. Their errors must not block
78
+ // an already usable page; only correlate critical errors with an unready UI.
79
+ if (errors.length && (busy || count === 0))
80
+ throw new Error(errors.map((x) => x.path + ' HTTP ' + x.status).join(';'));
81
+ if (fingerprint !== previous || busy || count === 0) {
82
+ previous = fingerprint;
83
+ stableSince = Date.now();
84
+ }
85
+ if (count > 0 && !busy && Date.now() - stableSince >= minimum)
86
+ return { duration_ms: Date.now() - start, controls: count };
87
+ await page.waitForTimeout(150);
88
+ }
89
+ throw new Error(
90
+ `等待${timeout}ms后页面仍为空白或加载中(控件${last?.count || 0},loading=${last?.busy}),停止本次会话,不继续刷新后续用例`,
91
+ );
92
+ }
package/src/replay.ts ADDED
@@ -0,0 +1,154 @@
1
+ import type { Page, BrowserContext, LaunchOptions, BrowserContextOptions } from 'playwright';
2
+ import { openBrowser } from './browser.js';
3
+ import { waitForReady } from './readiness.js';
4
+ import { runAssertion } from './assertions.js';
5
+ import { parseCase, type RunStatus, type AssertionResult, type Target } from './schema.js';
6
+
7
+ export interface ReplayOptions {
8
+ origin: string;
9
+ allowedOrigins?: string[];
10
+ launch?: LaunchOptions;
11
+ context?: BrowserContextOptions;
12
+ delay?: number;
13
+ signal?: AbortSignal;
14
+ /** Host-owned login/setup, never serialized into a case or exposed to the browser. */
15
+ beforeRun?: (page: Page, context: BrowserContext) => Promise<void>;
16
+ onProgress?: (status: RunStatus) => void;
17
+ }
18
+
19
+ async function resolveTarget(page: Page, target: Target) {
20
+ await page.evaluate((selector) => document.querySelectorAll(selector).length, target.selector);
21
+ const locator = page.locator('css=' + target.selector);
22
+ await locator.waitFor({ state: 'attached' });
23
+ if ((await locator.count()) !== 1) throw Error('目标不是唯一控件,请重新录制');
24
+ const metadata = await locator.evaluate((e) => {
25
+ const element = e as HTMLInputElement;
26
+ const label =
27
+ element.getAttribute('aria-label') ||
28
+ element.getAttribute('placeholder') ||
29
+ element.labels?.[0]?.textContent ||
30
+ element.textContent ||
31
+ element.tagName;
32
+ return {
33
+ label: label.trim().slice(0, 200),
34
+ private:
35
+ Boolean(element.closest('[data-private],.rr-block,[data-research-assistant]')) ||
36
+ /password|passwd|token|secret|credential|验证码|密码|密钥|口令/i.test(
37
+ [element.type, element.name, element.id, element.autocomplete, label].join(' '),
38
+ ),
39
+ };
40
+ });
41
+ if (metadata.private) throw Error('不回放敏感字段或小蜜自身控件');
42
+ if (target.role) {
43
+ const named = page.getByRole(target.role as Parameters<Page['getByRole']>[0], {
44
+ name: target.label,
45
+ exact: true,
46
+ });
47
+ if ((await locator.and(named).count()) !== 1) throw Error('控件语义已变化,请重新录制');
48
+ } else if (metadata.label !== target.label) throw Error('控件内容已变化,停止回放以避免误操作');
49
+ if (!(await locator.isVisible())) await locator.locator('..').hover();
50
+ return locator;
51
+ }
52
+
53
+ /** Functions compose the execution path. No business route, login or data fixture is built in. */
54
+ export async function runCase(value: unknown, options: ReplayOptions): Promise<RunStatus> {
55
+ const testCase = parseCase(value);
56
+ const origin = new URL(options.origin);
57
+ if (!['http:', 'https:'].includes(origin.protocol) || origin.username || origin.password)
58
+ throw Error('测试站点地址无效');
59
+ const destination = new URL(testCase.path, origin);
60
+ if (destination.origin !== origin.origin) throw Error('用例入口超出测试站点');
61
+ const allowed = new Set([
62
+ origin.origin,
63
+ ...(options.allowedOrigins ?? []).map((url) => new URL(url).origin),
64
+ ]);
65
+ const assertions: AssertionResult[] = [];
66
+ let status: RunStatus = {
67
+ state: 'running',
68
+ step: 0,
69
+ total: testCase.steps.length,
70
+ message: '正在启动浏览器',
71
+ assertions,
72
+ };
73
+ const report = (patch: Partial<RunStatus>) => {
74
+ status = { ...status, ...patch, assertions: [...assertions] };
75
+ options.onProgress?.(status);
76
+ };
77
+ if (options.signal?.aborted) return { ...status, state: 'stopped', message: '回放已停止' };
78
+ const { browser, page, context } = await openBrowser({
79
+ launch: options.launch,
80
+ context: options.context,
81
+ });
82
+ const stop = () => {
83
+ void browser.close().catch(() => {});
84
+ };
85
+ options.signal?.addEventListener('abort', stop, { once: true });
86
+ try {
87
+ if (options.signal?.aborted) throw Error('已停止');
88
+ await context.route('**/*', (route) =>
89
+ allowed.has(new URL(route.request().url()).origin) ? route.continue() : route.abort(),
90
+ );
91
+ await context.addInitScript(() => {
92
+ const mount = () => {
93
+ const style = document.createElement('style');
94
+ style.textContent = '[data-research-assistant],.research-assistant{display:none!important}';
95
+ document.documentElement.append(style);
96
+ };
97
+ if (document.documentElement) mount();
98
+ else document.addEventListener('DOMContentLoaded', mount, { once: true });
99
+ });
100
+ page.setDefaultNavigationTimeout(60000);
101
+ await page.goto(destination.href, { waitUntil: 'domcontentloaded' });
102
+ await waitForReady(page, { timeout: 45000 });
103
+ await options.beforeRun?.(page, context);
104
+ for (const [index, step] of testCase.steps.entries()) {
105
+ if (options.signal?.aborted) throw Error('已停止');
106
+ report({ step: index + 1, message: `正在执行第 ${index + 1} 步:${step.kind}` });
107
+ if (options.delay) await page.waitForTimeout(options.delay);
108
+ if (step.kind === 'assert' || step.kind === 'assert-text') {
109
+ const assertion = step.assertion ?? {
110
+ name: step.value!,
111
+ source: 'page-text' as const,
112
+ operator: 'contains' as const,
113
+ expected: step.value,
114
+ timeout: 10000,
115
+ };
116
+ const result = await runAssertion(
117
+ page,
118
+ assertion,
119
+ index + 1,
120
+ () => options.signal?.aborted === true,
121
+ );
122
+ assertions.push(result);
123
+ report({});
124
+ if (!result.passed) throw Error(`第 ${index + 1} 步断言失败:${assertion.name}`);
125
+ } else {
126
+ if (step.kind === 'unsupported' || !step.target) throw Error('请先补充未支持的步骤');
127
+ const target = await resolveTarget(page, step.target);
128
+ if (step.kind === 'click') await target.click();
129
+ else if (step.kind === 'fill') await target.fill(step.value!);
130
+ else if (step.kind === 'select') await target.selectOption(step.value!);
131
+ else if (step.kind === 'check') await target.setChecked(step.checked!);
132
+ else if (step.kind === 'press') await target.press(step.value!);
133
+ }
134
+ await waitForReady(page, { timeout: 10000, minimum: 200 });
135
+ }
136
+ report({
137
+ state: assertions.length ? 'passed' : 'completed',
138
+ message: assertions.length
139
+ ? `回放完成,${assertions.length} 条断言全部通过`
140
+ : '操作回放完成;未设置断言,尚未验证业务结果',
141
+ });
142
+ } catch (error) {
143
+ report({
144
+ state: options.signal?.aborted ? 'stopped' : 'failed',
145
+ message: options.signal?.aborted
146
+ ? '回放已停止'
147
+ : (error instanceof Error ? error.message : String(error)).slice(0, 1500),
148
+ });
149
+ } finally {
150
+ options.signal?.removeEventListener('abort', stop);
151
+ await browser.close();
152
+ }
153
+ return status;
154
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,222 @@
1
+ export interface Target {
2
+ selector: string;
3
+ label: string;
4
+ role?: string;
5
+ }
6
+ export interface Step {
7
+ kind: 'click' | 'fill' | 'select' | 'check' | 'press' | 'assert-text' | 'assert' | 'unsupported';
8
+ assertion?: Assertion;
9
+ target?: Target;
10
+ value?: string;
11
+ checked?: boolean;
12
+ sourceEventId?: number;
13
+ supplemented?: boolean;
14
+ }
15
+ export const assertionSources = {
16
+ 'page-text': '页面文字',
17
+ 'element-text': '元素文字',
18
+ 'element-value': '输入框值',
19
+ 'element-state': '元素可见性',
20
+ 'element-count': '元素数量',
21
+ } as const;
22
+ export const assertionOperators = {
23
+ contains: '包含',
24
+ 'not-contains': '不包含',
25
+ equals: '等于',
26
+ 'not-equals': '不等于',
27
+ visible: '可见',
28
+ hidden: '不可见或不存在',
29
+ } as const;
30
+ export interface Assertion {
31
+ name: string;
32
+ source: keyof typeof assertionSources;
33
+ operator: keyof typeof assertionOperators;
34
+ selector?: string;
35
+ expected?: string;
36
+ timeout: number;
37
+ }
38
+ export function operatorsFor(source: Assertion['source']): Assertion['operator'][] {
39
+ if (source === 'element-state') return ['visible', 'hidden'];
40
+ if (source === 'element-count') return ['equals', 'not-equals'];
41
+ return ['contains', 'not-contains', 'equals', 'not-equals'];
42
+ }
43
+ export function parseAssertion(value: unknown): Assertion {
44
+ const a = value as Assertion;
45
+ if (!a || typeof a.name !== 'string' || !a.name.trim() || a.name.length > 100)
46
+ throw Error('请填写 1–100 字的断言名称');
47
+ if (!Object.hasOwn(assertionSources, a.source) || !operatorsFor(a.source).includes(a.operator))
48
+ throw Error('不支持的断言对象或条件');
49
+ if (!Number.isInteger(a.timeout) || a.timeout < 500 || a.timeout > 30000)
50
+ throw Error('断言等待时间应为 500–30,000 毫秒');
51
+ if (
52
+ a.source !== 'page-text' &&
53
+ (typeof a.selector !== 'string' || !a.selector.trim() || a.selector.length > 2000)
54
+ )
55
+ throw Error('请填写有效的 CSS 定位信息');
56
+ if (a.source !== 'element-state' && (typeof a.expected !== 'string' || a.expected.length > 2000))
57
+ throw Error('请填写期望值,最多 2,000 字');
58
+ if (a.source !== 'element-state' && a.source !== 'element-value' && !a.expected?.trim())
59
+ throw Error('期望值不能为空');
60
+ if (a.source === 'element-count' && (!/^\d+$/.test(a.expected!) || Number(a.expected) > 100000))
61
+ throw Error('元素数量应为 0–100,000 的整数');
62
+ return {
63
+ name: a.name.trim(),
64
+ source: a.source,
65
+ operator: a.operator,
66
+ timeout: a.timeout,
67
+ ...(a.source !== 'page-text' ? { selector: a.selector!.trim() } : {}),
68
+ ...(a.source !== 'element-state' ? { expected: a.expected } : {}),
69
+ };
70
+ }
71
+ export interface AssertionResult {
72
+ step: number;
73
+ name: string;
74
+ passed: boolean;
75
+ expected: string;
76
+ actual: string;
77
+ duration: number;
78
+ }
79
+ export interface TimelineEvidence {
80
+ id: number;
81
+ time: number;
82
+ kind: string;
83
+ title: string;
84
+ page: string;
85
+ actionId?: number;
86
+ }
87
+ export interface TestCase {
88
+ schema: 'zhidao-recorded-case';
89
+ version: 1;
90
+ name: string;
91
+ path: string;
92
+ steps: Step[];
93
+ timeline?: { capturedAt: string; events: TimelineEvidence[] };
94
+ }
95
+ export interface RunStatus {
96
+ id?: string;
97
+ state: 'idle' | 'running' | 'passed' | 'completed' | 'failed' | 'stopped';
98
+ step: number;
99
+ total: number;
100
+ message: string;
101
+ assertions?: AssertionResult[];
102
+ }
103
+
104
+ // Shared by the browser and local bridge. No executable JavaScript in case files.
105
+ export function parseCase(value: unknown): TestCase {
106
+ const input = value as TestCase;
107
+ if (!input || input.schema !== 'zhidao-recorded-case' || input.version !== 1)
108
+ throw Error('不是受支持的录制用例 JSON');
109
+ if (typeof input.name !== 'string' || !input.name.trim() || input.name.length > 100)
110
+ throw Error('用例名称应为 1–100 个字符');
111
+ if (
112
+ typeof input.path !== 'string' ||
113
+ !input.path.startsWith('/') ||
114
+ input.path.startsWith('//') ||
115
+ /[\\\r\n]/.test(input.path) ||
116
+ input.path.length > 2000
117
+ )
118
+ throw Error('用例入口必须是站点内的相对路径');
119
+ if (!Array.isArray(input.steps) || input.steps.length < 1 || input.steps.length > 100)
120
+ throw Error('用例需要 1–100 个步骤');
121
+ const steps = input.steps.map((step): Step => {
122
+ if (
123
+ !step ||
124
+ ![
125
+ 'click',
126
+ 'fill',
127
+ 'select',
128
+ 'check',
129
+ 'press',
130
+ 'assert-text',
131
+ 'assert',
132
+ 'unsupported',
133
+ ].includes(step.kind)
134
+ )
135
+ throw Error('用例包含未知操作');
136
+ if (step.value !== undefined && (typeof step.value !== 'string' || step.value.length > 2000))
137
+ throw Error('步骤内容不能超过 2000 个字符');
138
+ if (step.kind === 'press' && !['Enter', 'Escape', 'Tab'].includes(step.value ?? ''))
139
+ throw Error('不支持该快捷键');
140
+ if (step.kind === 'assert-text' && !step.value?.trim()) throw Error('检查点文字不能为空');
141
+ if (step.kind === 'check' && typeof step.checked !== 'boolean') throw Error('缺少勾选状态');
142
+ if (['fill', 'select'].includes(step.kind) && step.value === undefined)
143
+ throw Error('缺少输入值');
144
+ let target: Target | undefined;
145
+ if (!['assert-text', 'assert', 'unsupported'].includes(step.kind)) {
146
+ if (
147
+ !step.target ||
148
+ typeof step.target.selector !== 'string' ||
149
+ !step.target.selector ||
150
+ step.target.selector.length > 2000 ||
151
+ typeof step.target.label !== 'string' ||
152
+ step.target.label.length > 200
153
+ )
154
+ throw Error('步骤缺少有效定位信息');
155
+ target = {
156
+ selector: step.target.selector,
157
+ label: step.target.label,
158
+ ...(typeof step.target.role === 'string' && /^[a-z]+$/.test(step.target.role)
159
+ ? { role: step.target.role }
160
+ : {}),
161
+ };
162
+ }
163
+ return {
164
+ kind: step.kind,
165
+ ...(step.kind === 'assert' ? { assertion: parseAssertion(step.assertion) } : {}),
166
+ ...(target ? { target } : {}),
167
+ ...(step.value !== undefined ? { value: step.value } : {}),
168
+ ...(step.kind === 'check' ? { checked: step.checked } : {}),
169
+ ...(Number.isSafeInteger(step.sourceEventId) && step.sourceEventId! > 0
170
+ ? { sourceEventId: step.sourceEventId }
171
+ : {}),
172
+ ...(step.supplemented === true ? { supplemented: true } : {}),
173
+ };
174
+ });
175
+ let timeline: TestCase['timeline'];
176
+ if (input.timeline !== undefined) {
177
+ if (
178
+ !input.timeline ||
179
+ typeof input.timeline.capturedAt !== 'string' ||
180
+ !Number.isFinite(Date.parse(input.timeline.capturedAt)) ||
181
+ !Array.isArray(input.timeline.events) ||
182
+ input.timeline.events.length > 300
183
+ )
184
+ throw Error('时间线来源格式错误');
185
+ const ids = new Set<number>();
186
+ const events = input.timeline.events.map((e) => {
187
+ if (
188
+ !e ||
189
+ !Number.isSafeInteger(e.id) ||
190
+ e.id <= 0 ||
191
+ ids.has(e.id) ||
192
+ !Number.isFinite(e.time) ||
193
+ !['route', 'click', 'change', 'request', 'error', 'performance'].includes(e.kind) ||
194
+ typeof e.title !== 'string' ||
195
+ e.title.length > 200 ||
196
+ typeof e.page !== 'string' ||
197
+ e.page.length > 2000
198
+ )
199
+ throw Error('时间线来源事件格式错误');
200
+ ids.add(e.id);
201
+ return {
202
+ id: e.id,
203
+ time: e.time,
204
+ kind: e.kind,
205
+ title: e.title,
206
+ page: e.page,
207
+ ...(Number.isSafeInteger(e.actionId) && e.actionId! > 0 ? { actionId: e.actionId } : {}),
208
+ };
209
+ });
210
+ if (steps.some((s) => s.sourceEventId !== undefined && !ids.has(s.sourceEventId)))
211
+ throw Error('步骤引用的原始事件不存在');
212
+ timeline = { capturedAt: input.timeline.capturedAt, events };
213
+ } else if (steps.some((s) => s.sourceEventId !== undefined)) throw Error('缺少步骤的原始时间线');
214
+ return {
215
+ schema: 'zhidao-recorded-case',
216
+ version: 1,
217
+ name: input.name.trim(),
218
+ path: input.path,
219
+ steps,
220
+ ...(timeline ? { timeline } : {}),
221
+ };
222
+ }
package/src/targets.ts ADDED
@@ -0,0 +1,61 @@
1
+ import { computeAccessibleName, getRole } from 'dom-accessibility-api';
2
+ import type { Target } from './schema.js';
3
+
4
+ export const excluded = '[data-research-assistant],.research-assistant,[data-private],.rr-block';
5
+ export const controls =
6
+ 'button,a[href],input,textarea,select,[role="button"],[role="tab"],[role="option"],[role="menuitem"],[role="checkbox"],[role="combobox"],summary';
7
+ export const normalizeText = (value: string) => value.replace(/\s+/g, ' ').trim();
8
+
9
+ /** Accessible names follow the W3C AccName algorithm, including labelledby and label-for. */
10
+ export function describeControl(element: Element) {
11
+ const role = getRole(element) ?? undefined;
12
+ const explicit = element.getAttribute('data-assistant-label');
13
+ const accessible = computeAccessibleName(element, { computedStyleSupportsPseudoElements: false });
14
+ const fallback =
15
+ element.getAttribute('placeholder') ||
16
+ element.getAttribute('name') ||
17
+ role ||
18
+ element.tagName.toLowerCase();
19
+ return {
20
+ role,
21
+ label: normalizeText(explicit || accessible || fallback).slice(0, 200),
22
+ labelSource: explicit ? 'business-label' : accessible ? 'accessible-name' : 'attribute',
23
+ testId: element.getAttribute('data-testid') ?? undefined,
24
+ };
25
+ }
26
+ export function privateInput(element: Element) {
27
+ return (
28
+ Boolean(element.closest('[data-private],.rr-block')) ||
29
+ /password|passwd|token|secret|credential|验证码|密码|密钥|口令/i.test(
30
+ ['type', 'name', 'id', 'autocomplete', 'aria-label', 'placeholder']
31
+ .map((key) => element.getAttribute(key))
32
+ .join(' ') +
33
+ ' ' +
34
+ computeAccessibleName(element, { computedStyleSupportsPseudoElements: false }),
35
+ )
36
+ );
37
+ }
38
+ export function locate(element: Element): Target {
39
+ const { label, role, labelSource } = describeControl(element);
40
+ const semantic = role && labelSource === 'accessible-name' ? { role } : {};
41
+ const doc = element.ownerDocument;
42
+ for (const attribute of ['data-testid', 'id', 'aria-label', 'placeholder', 'name']) {
43
+ const value = element.getAttribute(attribute);
44
+ const selector = `${element.tagName.toLowerCase()}[${attribute}=${JSON.stringify(value ?? '')}]`;
45
+ if (value && doc.querySelectorAll(selector).length === 1)
46
+ return { selector, label, ...semantic };
47
+ }
48
+ const path: string[] = [];
49
+ for (
50
+ let current: Element | null = element;
51
+ current && current !== doc.body;
52
+ current = current.parentElement
53
+ ) {
54
+ const parent: Element | null = current.parentElement;
55
+ if (!parent) break;
56
+ const index =
57
+ [...parent.children].filter((e) => e.tagName === current!.tagName).indexOf(current) + 1;
58
+ path.unshift(`${current.tagName.toLowerCase()}:nth-of-type(${index})`);
59
+ }
60
+ return { selector: `body > ${path.join(' > ')}`, label, ...semantic };
61
+ }