@kkeve/suite 0.0.15 → 0.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/dist/bin/keve.js +2 -1
  2. package/dist/commands/clean.js +2 -2
  3. package/dist/commands/config.js +7 -7
  4. package/dist/commands/init.js +2 -2
  5. package/dist/commands/report.js +7 -7
  6. package/dist/commands/run.js +12 -11
  7. package/dist/commands/test.js +7 -7
  8. package/dist/config.js +7 -7
  9. package/dist/core/action-log-writer.js +4 -1
  10. package/dist/core/decorator/keve-decorators.d.ts +128 -0
  11. package/dist/core/decorator/keve-decorators.js +254 -0
  12. package/dist/core/decorator/keve-host.d.ts +40 -0
  13. package/dist/core/decorator/keve-host.js +33 -0
  14. package/dist/core/{keve-registry.d.ts → decorator/keve-registry.d.ts} +19 -0
  15. package/dist/core/{keve-registry.js → decorator/keve-registry.js} +13 -1
  16. package/dist/core/generateReport/report.d.ts +1 -1
  17. package/dist/core/generateReport/report.js +2 -2
  18. package/dist/core/generateReport/reportData.d.ts +3 -0
  19. package/dist/core/generateReport/reportData.js +49 -15
  20. package/dist/core/judge/confidence.d.ts +119 -0
  21. package/dist/core/judge/confidence.js +295 -0
  22. package/dist/core/judge/goal-heuristics.d.ts +32 -0
  23. package/dist/core/judge/goal-heuristics.js +128 -0
  24. package/dist/core/judge/pngCodec.d.ts +23 -0
  25. package/dist/core/judge/pngCodec.js +215 -0
  26. package/dist/core/judge/screenshotDiff.d.ts +112 -0
  27. package/dist/core/judge/screenshotDiff.js +420 -0
  28. package/dist/core/page-object/definePage.d.ts +95 -0
  29. package/dist/core/page-object/definePage.js +62 -0
  30. package/dist/core/page-object/elementResolver.d.ts +32 -0
  31. package/dist/core/page-object/elementResolver.js +236 -0
  32. package/dist/core/page-object/engine-types.d.ts +140 -0
  33. package/dist/core/page-object/engine-types.js +34 -0
  34. package/dist/core/sso-cookie.d.ts +18 -0
  35. package/dist/core/sso-cookie.js +10 -0
  36. package/dist/dsl/index.d.ts +15 -0
  37. package/dist/dsl/index.js +29 -0
  38. package/dist/dsl/locatorSource.d.ts +25 -0
  39. package/dist/dsl/locatorSource.js +323 -0
  40. package/dist/dsl/ops.d.ts +46 -0
  41. package/dist/dsl/ops.js +95 -0
  42. package/dist/dsl/toDecorator.d.ts +98 -0
  43. package/dist/dsl/toDecorator.js +747 -0
  44. package/dist/engine-cypress/adapter.d.ts +108 -0
  45. package/dist/engine-cypress/adapter.js +514 -0
  46. package/dist/engine-cypress/agent-page.d.ts +159 -0
  47. package/dist/engine-cypress/agent-page.js +566 -0
  48. package/dist/engine-cypress/agentDom.d.ts +22 -0
  49. package/dist/engine-cypress/agentDom.js +590 -0
  50. package/dist/engine-cypress/bridge.d.ts +39 -0
  51. package/dist/engine-cypress/bridge.js +598 -0
  52. package/dist/engine-cypress/cdp.d.ts +78 -0
  53. package/dist/engine-cypress/cdp.js +339 -0
  54. package/dist/engine-cypress/host.d.ts +22 -0
  55. package/dist/engine-cypress/host.js +480 -0
  56. package/dist/engine-cypress/index.d.ts +26 -0
  57. package/dist/engine-cypress/index.js +58 -0
  58. package/dist/engine-cypress/keveGoal.d.ts +43 -0
  59. package/dist/engine-cypress/keveGoal.js +265 -0
  60. package/dist/engine-cypress/pageOps.d.ts +41 -0
  61. package/dist/engine-cypress/pageOps.js +439 -0
  62. package/dist/engine-cypress/reporter.d.ts +20 -0
  63. package/dist/engine-cypress/reporter.js +128 -0
  64. package/dist/engine-cypress/setup.d.ts +21 -0
  65. package/dist/engine-cypress/setup.js +27 -0
  66. package/dist/engine-cypress/shotBuffer.d.ts +40 -0
  67. package/dist/engine-cypress/shotBuffer.js +41 -0
  68. package/dist/engine-cypress/state.d.ts +56 -0
  69. package/dist/engine-cypress/state.js +81 -0
  70. package/dist/engine-playwright/engineAdapter.d.ts +78 -0
  71. package/dist/engine-playwright/engineAdapter.js +371 -0
  72. package/dist/engine-playwright/global-setup.d.ts +28 -0
  73. package/dist/engine-playwright/global-setup.js +142 -0
  74. package/dist/{core → engine-playwright}/keve-report.d.ts +0 -1
  75. package/dist/engine-playwright/keve-report.js +137 -0
  76. package/dist/{core → engine-playwright}/keve-test.d.ts +10 -8
  77. package/dist/{core → engine-playwright}/keve-test.js +204 -200
  78. package/dist/{core → engine-playwright}/pw-config.js +8 -7
  79. package/dist/index.d.ts +13 -8
  80. package/dist/index.js +43 -28
  81. package/dist/page-agent/agent.d.ts +82 -7
  82. package/dist/page-agent/agent.js +252 -88
  83. package/dist/page-agent/diagnostics.d.ts +184 -0
  84. package/dist/page-agent/diagnostics.js +377 -0
  85. package/dist/page-agent/hooks.d.ts +3 -2
  86. package/dist/page-agent/hooks.js +4 -3
  87. package/dist/page-agent/page-like.d.ts +116 -0
  88. package/dist/page-agent/page-like.js +14 -0
  89. package/dist/page-agent/playwright-page.d.ts +20 -0
  90. package/dist/page-agent/playwright-page.js +98 -0
  91. package/dist/page-agent/system-prompt.d.ts +7 -0
  92. package/dist/page-agent/system-prompt.js +10 -0
  93. package/dist/page-agent/tools.d.ts +1 -1
  94. package/dist/page-agent/tools.js +144 -8
  95. package/package.json +24 -11
  96. package/scripts/gen-system-prompt.mjs +36 -0
  97. package/scripts/run-dsl-task.mjs +652 -0
  98. package/scripts/runtime/cypress-preprocessor.mjs +44 -0
  99. package/scripts/runtime/cypress.config.mjs +35 -0
  100. package/scripts/runtime/playwright.config.mjs +41 -0
  101. package/scripts/verify-shot-diff.mjs +243 -0
  102. package/dist/commands/auth-update.d.ts +0 -16
  103. package/dist/commands/auth-update.js +0 -88
  104. package/dist/core/auth.d.ts +0 -17
  105. package/dist/core/auth.js +0 -224
  106. package/dist/core/cdp-resolver.d.ts +0 -5
  107. package/dist/core/cdp-resolver.js +0 -93
  108. package/dist/core/global-setup.d.ts +0 -6
  109. package/dist/core/global-setup.js +0 -123
  110. package/dist/core/keve-decorators.d.ts +0 -51
  111. package/dist/core/keve-decorators.js +0 -95
  112. package/dist/core/keve-report.js +0 -376
  113. /package/dist/core/{keve-aspect.d.ts → decorator/keve-aspect.d.ts} +0 -0
  114. /package/dist/core/{keve-aspect.js → decorator/keve-aspect.js} +0 -0
  115. /package/dist/{core → engine-playwright}/pw-config.d.ts +0 -0
@@ -0,0 +1,420 @@
1
+ "use strict";
2
+ /**
3
+ * screenshotDiff — 截图基线对比(平台「截图基线」断言的引擎侧实现)
4
+ *
5
+ * 判定语义(对齐《keve 统一用例工作台与场景插件完整方案》§2 视觉优先修正 + §9.5 首跑闭环):
6
+ * 无基线 → status 'baseline_required':保存候选基线(state=candidate),
7
+ * 首跑**不算通过**,差异率为 null,需人工审核转正后再次运行才做正式比对
8
+ * 表里已有候选 → status 'baseline_required':未审核候选不得作为正式有效基线,
9
+ * 不做像素比对、不判 matched/exceeded(差异率 null)
10
+ * 截图尺寸变化 → status 'size_mismatch':不得因 viewport 变化自动重新自举来掩盖变化;
11
+ * 按原 key+env 定位既有基线并明确报告尺寸差异
12
+ * diffRatio ≤ 阈值 → status 'matched':静默通过,仅记录证据(仅对 state=active 的基线)
13
+ * diffRatio > 阈值 → status 'exceeded':抛 ScreenshotDiffError(确定性事实,AI 不可翻案;
14
+ * keveGoal 对该错误短路跳过 Agent Re-Act —— diff 不是探索能修复的)
15
+ *
16
+ * 基线存储:<KEVE_BASELINE_DIR|cwd/baselines>/{key}.{env}.{w}x{h}.png + .meta.json
17
+ * meta: { version, state: 'candidate'|'active', sourceType, threshold, masks, updatedAt }
18
+ * key 建议沿用老平台基线 id(如 step-24497),可回溯迁移来源。
19
+ *
20
+ * 证据产物:actual 截图与 diff 热区图落盘 test-artifacts,路径写入结果,
21
+ * 由 keveGoalResult attachment 流到平台 stepsDetail 回收(evidence.screenshotDiff)。
22
+ */
23
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
24
+ if (k2 === undefined) k2 = k;
25
+ var desc = Object.getOwnPropertyDescriptor(m, k);
26
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
27
+ desc = { enumerable: true, get: function() { return m[k]; } };
28
+ }
29
+ Object.defineProperty(o, k2, desc);
30
+ }) : (function(o, m, k, k2) {
31
+ if (k2 === undefined) k2 = k;
32
+ o[k2] = m[k];
33
+ }));
34
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
35
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
36
+ }) : function(o, v) {
37
+ o["default"] = v;
38
+ });
39
+ var __importStar = (this && this.__importStar) || (function () {
40
+ var ownKeys = function(o) {
41
+ ownKeys = Object.getOwnPropertyNames || function (o) {
42
+ var ar = [];
43
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
44
+ return ar;
45
+ };
46
+ return ownKeys(o);
47
+ };
48
+ return function (mod) {
49
+ if (mod && mod.__esModule) return mod;
50
+ var result = {};
51
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
52
+ __setModuleDefault(result, mod);
53
+ return result;
54
+ };
55
+ })();
56
+ Object.defineProperty(exports, "__esModule", { value: true });
57
+ exports.ScreenshotDiffError = void 0;
58
+ exports.baselineDir = baselineDir;
59
+ exports.resolveBaselinePaths = resolveBaselinePaths;
60
+ exports.findBaselineByKeyEnv = findBaselineByKeyEnv;
61
+ exports.createBaseline = createBaseline;
62
+ exports.compareWithBaseline = compareWithBaseline;
63
+ exports.beginGoalShotDiffCapture = beginGoalShotDiffCapture;
64
+ exports.isGoalShotDiffCaptureActive = isGoalShotDiffCaptureActive;
65
+ exports.drainGoalShotDiff = drainGoalShotDiff;
66
+ exports.recordShotDiff = recordShotDiff;
67
+ const fs = __importStar(require("node:fs"));
68
+ const path = __importStar(require("node:path"));
69
+ const pngCodec_js_1 = require("./pngCodec.js");
70
+ /** 超阈值错误:携带完整对比结果,keveGoal 据此短路并透传 attachment */
71
+ class ScreenshotDiffError extends Error {
72
+ shotDiff;
73
+ constructor(result) {
74
+ super(result.message);
75
+ this.name = 'ScreenshotDiffError';
76
+ this.shotDiff = result;
77
+ }
78
+ }
79
+ exports.ScreenshotDiffError = ScreenshotDiffError;
80
+ // ─── Env / Path ────────────────────────────────────────────────────
81
+ const ENV = (['online', 'pre', 'rc', 'test'].includes(process.env.KEVE_ENV || '') ? process.env.KEVE_ENV : 'test');
82
+ function taskDir() {
83
+ return path.resolve(process.env.KEVE_TASK_DIR || '.keve');
84
+ }
85
+ function baselineDir() {
86
+ return process.env.KEVE_BASELINE_DIR || path.join(process.cwd(), 'baselines');
87
+ }
88
+ function sanitizeKey(key) {
89
+ // 保留 - 和 .:老平台基线 id 形如 step-24497,需原样保留以回溯来源
90
+ return String(key || 'shot').replace(/[^a-zA-Z0-9一-鿿.-]/g, '_').slice(0, 60);
91
+ }
92
+ /** 基线路径 = {safeKey}.{env}.{w}x{h} —— env×viewport 进 key,避免跨环境/跨视口假差异 */
93
+ function resolveBaselinePaths(key, width, height) {
94
+ const safe = sanitizeKey(key);
95
+ const viewport = `${width}x${height}`;
96
+ const base = path.join(baselineDir(), `${safe}.${ENV}.${viewport}`);
97
+ return { pngPath: `${base}.png`, metaPath: `${base}.json`, env: ENV, viewport, width, height };
98
+ }
99
+ /**
100
+ * 按 key+env 查找已存在基线(任意视口)。
101
+ * 命中表示「该目标曾建档」——此时若精确视口不存在,属于**截图尺寸变化**,
102
+ * 不允许重新自举成新候选来掩盖变化(方案 §9.4 / A20)。
103
+ */
104
+ function findBaselineByKeyEnv(key, preferWidth, preferHeight) {
105
+ const safe = sanitizeKey(key);
106
+ const prefix = `${safe}.${ENV}.`;
107
+ const dir = baselineDir();
108
+ if (!fs.existsSync(dir))
109
+ return null;
110
+ let candidates = [];
111
+ try {
112
+ candidates = fs.readdirSync(dir).filter((f) => f.startsWith(prefix) && f.endsWith('.png'));
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ if (candidates.length === 0)
118
+ return null;
119
+ const exact = preferWidth && preferHeight
120
+ ? candidates.find((f) => f === `${prefix}${preferWidth}x${preferHeight}.png`)
121
+ : undefined;
122
+ const file = exact || candidates.sort().pop(); // 无精确视口 → 取任一既有基线(尺寸变化分支用)
123
+ const base = path.join(dir, file.replace(/\.png$/, ''));
124
+ const viewport = file.slice(prefix.length, -4);
125
+ const [w, h] = viewport.split('x').map((x) => Number(x));
126
+ return { pngPath: `${base}.png`, metaPath: `${base}.json`, env: ENV, viewport, width: w || 0, height: h || 0 };
127
+ }
128
+ function readMeta(p) {
129
+ try {
130
+ const m = JSON.parse(fs.readFileSync(p, 'utf-8'));
131
+ if (m && typeof m.version === 'number')
132
+ return { masks: [], threshold: 1, ...m };
133
+ }
134
+ catch { /* 无 meta 或损坏 → 视为无 meta */ }
135
+ return null;
136
+ }
137
+ function writeMeta(p, meta) {
138
+ fs.mkdirSync(path.dirname(p), { recursive: true });
139
+ fs.writeFileSync(p, JSON.stringify(meta, null, 2));
140
+ }
141
+ // ─── Pixel compare ─────────────────────────────────────────────────
142
+ /** 像素容差(单通道最大差值),容忍抗锯齿/渲染抖动 */
143
+ const PIXEL_TOLERANCE = Number(process.env.KEVE_SHOT_DIFF_PIXEL_TOLERANCE ?? 24);
144
+ /** 差异区块聚类的网格边长(px) */
145
+ const REGION_BLOCK = 16;
146
+ /** 最多返回的差异区块数(超出部分合并提示) */
147
+ const MAX_REGIONS = 8;
148
+ function pixelMask(baseline, actual, masks) {
149
+ const w = Math.min(baseline.width, actual.width);
150
+ const h = Math.min(baseline.height, actual.height);
151
+ const masked = new Array(w * h).fill(false);
152
+ for (const m of masks || []) {
153
+ const x0 = Math.max(0, Math.floor(m.x)), y0 = Math.max(0, Math.floor(m.y));
154
+ const x1 = Math.min(w, Math.ceil(m.x + m.w)), y1 = Math.min(h, Math.ceil(m.y + m.h));
155
+ for (let y = y0; y < y1; y++)
156
+ for (let x = x0; x < x1; x++)
157
+ masked[y * w + x] = true;
158
+ }
159
+ return masked;
160
+ }
161
+ function comparePixels(baseline, actual, masks) {
162
+ const w = Math.min(baseline.width, actual.width);
163
+ const h = Math.min(baseline.height, actual.height);
164
+ const masked = pixelMask(baseline, actual, masks);
165
+ const diffMask = new Uint8Array(w * h);
166
+ let diffPixels = 0;
167
+ const bd = baseline.data, ad = actual.data;
168
+ const bStride = baseline.width * 4, aStride = actual.width * 4;
169
+ for (let y = 0; y < h; y++) {
170
+ for (let x = 0; x < w; x++) {
171
+ const idx = y * w + x;
172
+ if (masked[idx])
173
+ continue;
174
+ const bi = y * bStride + x * 4, ai = y * aStride + x * 4;
175
+ const dr = Math.abs(bd[bi] - ad[ai]);
176
+ const dg = Math.abs(bd[bi + 1] - ad[ai + 1]);
177
+ const db = Math.abs(bd[bi + 2] - ad[ai + 2]);
178
+ if (Math.max(dr, dg, db) > PIXEL_TOLERANCE) {
179
+ diffMask[idx] = 1;
180
+ diffPixels++;
181
+ }
182
+ }
183
+ }
184
+ // 差异区块聚类:16px 网格 → 相邻块 BFS 合并
185
+ const gx = Math.ceil(w / REGION_BLOCK), gy = Math.ceil(h / REGION_BLOCK);
186
+ const blockCount = new Int32Array(gx * gy);
187
+ for (let y = 0; y < h; y++) {
188
+ const by = (y / REGION_BLOCK) | 0;
189
+ for (let x = 0; x < w; x++) {
190
+ if (diffMask[y * w + x])
191
+ blockCount[by * gx + ((x / REGION_BLOCK) | 0)]++;
192
+ }
193
+ }
194
+ const visited = new Uint8Array(gx * gy);
195
+ const regions = [];
196
+ const blockTotal = REGION_BLOCK * REGION_BLOCK;
197
+ for (let start = 0; start < gx * gy; start++) {
198
+ if (visited[start] || blockCount[start] === 0)
199
+ continue;
200
+ // BFS
201
+ const queue = [start];
202
+ visited[start] = 1;
203
+ let minX = gx, minY = gy, maxX = -1, maxY = -1, pixels = 0;
204
+ while (queue.length) {
205
+ const cur = queue.pop();
206
+ const bx = cur % gx, by = (cur / gx) | 0;
207
+ minX = Math.min(minX, bx);
208
+ maxX = Math.max(maxX, bx);
209
+ minY = Math.min(minY, by);
210
+ maxY = Math.max(maxY, by);
211
+ pixels += blockCount[cur];
212
+ for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
213
+ const nx = bx + dx, ny = by + dy;
214
+ if (nx < 0 || ny < 0 || nx >= gx || ny >= gy)
215
+ continue;
216
+ const ni = ny * gx + nx;
217
+ if (visited[ni] || blockCount[ni] === 0)
218
+ continue;
219
+ visited[ni] = 1;
220
+ queue.push(ni);
221
+ }
222
+ }
223
+ const rx = minX * REGION_BLOCK, ry = minY * REGION_BLOCK;
224
+ const rw = Math.min(w, (maxX + 1) * REGION_BLOCK) - rx;
225
+ const rh = Math.min(h, (maxY + 1) * REGION_BLOCK) - ry;
226
+ regions.push({ x: rx, y: ry, w: rw, h: rh, ratio: Math.round((pixels / (rw * rh)) * 1000) / 10 });
227
+ }
228
+ regions.sort((a, b) => b.ratio - a.ratio);
229
+ return { diffMask, diffPixels, totalPixels: w * h, regions, overlapWidth: w, overlapHeight: h };
230
+ }
231
+ /** 差异热区图:实际截图为底,非差异像素压暗 35%,差异像素标红 */
232
+ function renderDiffImage(baseline, actual, cmp) {
233
+ const { overlapWidth: w, overlapHeight: h, diffMask } = cmp;
234
+ const out = new Uint8Array(w * h * 4);
235
+ const aStride = actual.width * 4;
236
+ for (let y = 0; y < h; y++) {
237
+ for (let x = 0; x < w; x++) {
238
+ const idx = y * w + x;
239
+ const ai = y * aStride + x * 4;
240
+ const o = idx * 4;
241
+ if (diffMask[idx]) {
242
+ out[o] = 233;
243
+ out[o + 1] = 42;
244
+ out[o + 2] = 27;
245
+ out[o + 3] = 255; // #E92A1B
246
+ }
247
+ else {
248
+ out[o] = (actual.data[ai] * 0.65) | 0;
249
+ out[o + 1] = (actual.data[ai + 1] * 0.65) | 0;
250
+ out[o + 2] = (actual.data[ai + 2] * 0.65) | 0;
251
+ out[o + 3] = 255;
252
+ }
253
+ }
254
+ }
255
+ return (0, pngCodec_js_1.encodePng)(w, h, out);
256
+ }
257
+ // ─── Artifacts ─────────────────────────────────────────────────────
258
+ function artifactsDir() {
259
+ // 优先与当前报告轮次同目录;缺失时回退旧布局,兼容历史执行环境。
260
+ if (process.env.KEVE_RESULT_DIR) {
261
+ return path.join(path.resolve(process.env.KEVE_RESULT_DIR), 'shotdiff');
262
+ }
263
+ const round = process.env.KEVE_ROUND || 'latest';
264
+ return path.join(taskDir(), 'test-artifacts', `round-${round}`, 'shotdiff');
265
+ }
266
+ function saveArtifact(buf, name) {
267
+ const dir = artifactsDir();
268
+ fs.mkdirSync(dir, { recursive: true });
269
+ const file = path.join(dir, `${name}-${Date.now()}.png`);
270
+ fs.writeFileSync(file, buf);
271
+ return file;
272
+ }
273
+ // ─── Public API ────────────────────────────────────────────────────
274
+ function fmtPct(n) {
275
+ return `${Math.round(n * 100) / 100}%`;
276
+ }
277
+ /** 无基线:保存候选基线,返回 created(判 PASS) */
278
+ function createBaseline(key, png, opts) {
279
+ // 视口尺寸从 PNG 头解析(decode 校验合法性)
280
+ const img = (0, pngCodec_js_1.decodePng)(png);
281
+ const paths = resolveBaselinePaths(key, img.width, img.height);
282
+ fs.mkdirSync(baselineDir(), { recursive: true });
283
+ fs.writeFileSync(paths.pngPath, png);
284
+ const threshold = opts?.threshold ?? defaultThreshold();
285
+ writeMeta(paths.metaPath, {
286
+ version: 1,
287
+ state: 'candidate',
288
+ sourceType: opts?.sourceUrl ? 'imported' : 'first-run',
289
+ sourceUrl: opts?.sourceUrl,
290
+ threshold,
291
+ masks: [],
292
+ updatedAt: new Date().toISOString(),
293
+ });
294
+ const actualPath = saveArtifact(png, sanitizeKey(key));
295
+ const result = {
296
+ key, status: 'baseline_required', diffRatio: null, threshold,
297
+ baselineVersion: 1, baselineState: 'candidate',
298
+ baselinePath: paths.pngPath, actualPath, diffPath: actualPath,
299
+ env: paths.env, viewport: paths.viewport, regions: [],
300
+ message: `基线不存在:已保存候选基线 v1(${paths.pngPath})。首跑不判通过(差异率 null)——需到「基线审核」确认转正后再次运行完成正式比对。`,
301
+ comparedAt: new Date().toISOString(),
302
+ };
303
+ recordShotDiff(result);
304
+ return result;
305
+ }
306
+ function defaultThreshold() {
307
+ const t = Number(process.env.KEVE_SHOT_DIFF_THRESHOLD);
308
+ return Number.isFinite(t) && t >= 0 ? t : 1.0;
309
+ }
310
+ /** 候选未审核:不做像素比对,返回 baseline_required(差异率 null,不算通过) */
311
+ function candidatePendingResult(key, png, paths, meta, opts) {
312
+ const actualPath = saveArtifact(png, sanitizeKey(key));
313
+ const result = {
314
+ key, status: 'baseline_required', diffRatio: null,
315
+ threshold: opts?.threshold ?? meta.threshold ?? defaultThreshold(),
316
+ baselineVersion: meta.version ?? 1, baselineState: 'candidate',
317
+ baselinePath: paths.pngPath, actualPath, diffPath: actualPath,
318
+ env: paths.env, viewport: paths.viewport, regions: [],
319
+ message: `候选基线 v${meta.version ?? 1}(${paths.pngPath})尚未审核,本次执行不比对、不判通过(差异率 null)。请到「基线审核」确认转正后再次运行。`,
320
+ comparedAt: new Date().toISOString(),
321
+ };
322
+ recordShotDiff(result);
323
+ return result;
324
+ }
325
+ /** 尺寸变化:同 key+env 已存在其它视口基线 → 不自动自举(A20,防止掩盖变化) */
326
+ function sizeMismatchResult(key, png, paths, sibling) {
327
+ const actualPath = saveArtifact(png, sanitizeKey(key));
328
+ const sMeta = readMeta(sibling.metaPath);
329
+ const result = {
330
+ key, status: 'size_mismatch', diffRatio: null,
331
+ threshold: sMeta?.threshold ?? defaultThreshold(),
332
+ baselineVersion: sMeta?.version ?? 1, baselineState: sMeta?.state,
333
+ baselinePath: sibling.pngPath, actualPath, diffPath: actualPath,
334
+ env: paths.env, viewport: paths.viewport, regions: [],
335
+ message: `截图尺寸变化:当前 ${paths.width}x${paths.height},既有基线为 ${sibling.viewport}(${sibling.pngPath})。尺寸变化不自动重建基线,请核对目标或按新尺寸显式重建。`,
336
+ comparedAt: new Date().toISOString(),
337
+ };
338
+ recordShotDiff(result);
339
+ return result;
340
+ }
341
+ /**
342
+ * 有基线:像素对比 → matched(≤阈值,pass)/ exceeded(>阈值,抛 ScreenshotDiffError)。
343
+ * 尺寸不一致按重叠区对比并在 message 中注明(视口不变的常规场景不受影响)。
344
+ */
345
+ function compareWithBaseline(key, png, opts) {
346
+ const actualImg = (0, pngCodec_js_1.decodePng)(png);
347
+ const paths = resolveBaselinePaths(key, actualImg.width, actualImg.height);
348
+ if (!fs.existsSync(paths.pngPath)) {
349
+ // 尺寸变化(同 key+env 已有其它视口基线)→ 不自动自举成新候选掩盖差异
350
+ const sibling = findBaselineByKeyEnv(key, actualImg.width, actualImg.height);
351
+ if (sibling)
352
+ return sizeMismatchResult(key, png, paths, sibling);
353
+ // 真·首跑:保存候选基线,不算通过(差异率 null)
354
+ return createBaseline(key, png, { threshold: opts?.threshold });
355
+ }
356
+ const baselineImg = (0, pngCodec_js_1.decodePng)(fs.readFileSync(paths.pngPath));
357
+ const meta = readMeta(paths.metaPath);
358
+ // 未审核候选不得作为正式有效基线:不做像素比对、不判 matched/exceeded(§2 / §9.5)
359
+ if (meta && meta.state === 'candidate')
360
+ return candidatePendingResult(key, png, paths, meta, opts);
361
+ const threshold = opts?.threshold ?? meta?.threshold ?? defaultThreshold();
362
+ const masks = [...(meta?.masks || []), ...(opts?.mask || [])];
363
+ const sizeMismatch = baselineImg.width !== actualImg.width || baselineImg.height !== actualImg.height;
364
+ const cmp = comparePixels(baselineImg, actualImg, masks);
365
+ const diffRatio = cmp.totalPixels > 0 ? (cmp.diffPixels / cmp.totalPixels) * 100 : 0;
366
+ const actualPath = saveArtifact(png, sanitizeKey(key));
367
+ const diffPath = cmp.diffPixels > 0
368
+ ? saveArtifact(renderDiffImage(baselineImg, actualImg, cmp), `${sanitizeKey(key)}-diff`)
369
+ : actualPath;
370
+ const baseNote = sizeMismatch
371
+ ? `(注意:基线 ${baselineImg.width}x${baselineImg.height} 与实际 ${actualImg.width}x${actualImg.height} 尺寸不一致,按重叠区 ${cmp.overlapWidth}x${cmp.overlapHeight} 对比)`
372
+ : '';
373
+ const regionNote = cmp.regions.length
374
+ ? `,${cmp.regions.length} 个差异区块${cmp.regions.length > MAX_REGIONS ? '(仅列出前 8 个)' : ''}`
375
+ : '';
376
+ const result = {
377
+ key, status: diffRatio <= threshold ? 'matched' : 'exceeded',
378
+ diffRatio: Math.round(diffRatio * 100) / 100,
379
+ threshold,
380
+ baselineVersion: meta?.version ?? 1,
381
+ baselineState: meta?.state,
382
+ baselinePath: paths.pngPath,
383
+ actualPath, diffPath,
384
+ env: paths.env, viewport: paths.viewport,
385
+ regions: cmp.regions.slice(0, MAX_REGIONS),
386
+ message: '',
387
+ comparedAt: new Date().toISOString(),
388
+ };
389
+ if (result.status === 'matched') {
390
+ result.message = `截图对比一致:差异率 ${fmtPct(diffRatio)} ≤ 阈值 ${fmtPct(threshold)}(基线 v${result.baselineVersion},key=${key})`;
391
+ }
392
+ else {
393
+ result.message = `截图对比不一致:差异率 ${fmtPct(diffRatio)} 超过阈值 ${fmtPct(threshold)}(基线 v${result.baselineVersion},key=${key}${regionNote})${baseNote}`;
394
+ recordShotDiff(result); // 抛错前记录:exceeded 证据也必须进 goal 缓冲 → attachment
395
+ throw new ScreenshotDiffError(result);
396
+ }
397
+ recordShotDiff(result);
398
+ return result;
399
+ }
400
+ // ─── Goal 捕获缓冲(engine 写入 → keveGoal 组装 attachment 时读取) ──
401
+ let goalBuffer = null;
402
+ /** keveGoal 执行 fn 前调用:开始收集本 goal 内的对比结果 */
403
+ function beginGoalShotDiffCapture() {
404
+ goalBuffer = [];
405
+ }
406
+ /** 当前是否处于 goal 收集期(engine 判断是否需要自开临时缓冲) */
407
+ function isGoalShotDiffCaptureActive() {
408
+ return goalBuffer !== null;
409
+ }
410
+ /** keveGoal fn 结束(无论成败)后调用:取出本 goal 的全部对比结果 */
411
+ function drainGoalShotDiff() {
412
+ const out = goalBuffer || [];
413
+ goalBuffer = null;
414
+ return out;
415
+ }
416
+ /** engine.expectScreenshot 内部调用:记录到当前 goal 缓冲(若在收集期) */
417
+ function recordShotDiff(r) {
418
+ if (goalBuffer)
419
+ goalBuffer.push(r);
420
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * definePage — 代码化元素管理
3
+ *
4
+ * 扁平 key-value 元素定义,零运行时开销。
5
+ * 嵌套关系由 step 级定位链(resolveChain)表达,不在 Page 内维护。
6
+ *
7
+ * Usage:
8
+ * import { definePage } from '@kkeve/suite';
9
+ *
10
+ * export const myPage = definePage({
11
+ * elements: {
12
+ * saveButton: { role: 'button', name: '保存' },
13
+ * container: { css: '.my-section' },
14
+ * // 动态元素工厂
15
+ * itemByName: (text: string) => ({ role: 'listitem', name: text }),
16
+ * },
17
+ * waitForReady: {
18
+ * api: '/api/list',
19
+ * ui: 'container',
20
+ * },
21
+ * });
22
+ *
23
+ * // 在 spec 中引用
24
+ * await engine.click(myPage.elements.saveButton);
25
+ * await engine.click(myPage.elements.itemByName('时间'));
26
+ */
27
+ /** 平台定位链单跳(stepDsl context 的原生形态,keve-wiki 导出代码直接携带) */
28
+ export interface ElementHop {
29
+ /** selector | class | id | shadow-dom | includes | text | label | iframe-url | javascript | source */
30
+ type?: string;
31
+ value?: any;
32
+ index?: number;
33
+ }
34
+ export interface ElementRef {
35
+ /** ARIA role 语义定位(优先) */
36
+ role?: string;
37
+ /** 可访问名称(配合 role 使用) */
38
+ name?: string;
39
+ /** CSS 选择器(fallback) */
40
+ css?: string;
41
+ /** AI 探索临时 ref 标识符(仅探索阶段) */
42
+ ref?: string;
43
+ /** 包含指定文本(复合定位) */
44
+ hasText?: string;
45
+ /** 包含指定子元素(复合定位) */
46
+ has?: ElementRef;
47
+ /** 不包含指定子元素(复合定位) */
48
+ notHas?: ElementRef;
49
+ /** 平台多跳定位链(selector/class/text/iframe-url 等原生 hop 序列,resolveElement 原生解析) */
50
+ chain?: ElementHop[];
51
+ }
52
+ /** 动态元素工厂函数类型 */
53
+ export type ElementFactory = (...args: any[]) => ElementRef;
54
+ /** 元素注册表值类型:静态定义或动态工厂 */
55
+ export type ElementEntry = ElementRef | ElementFactory;
56
+ /** Page 元素注册表 */
57
+ export type ElementRegistry = Record<string, ElementEntry>;
58
+ export interface WaitForReady {
59
+ /** 等待的关键 API 路径 */
60
+ api?: string;
61
+ /** 等待的 UI 元素 key(对应 elements 中的 key) */
62
+ ui?: string;
63
+ }
64
+ /** 页面入口 URL(四环境值 + 默认参数,PageObject 归属;数据仍以平台变量表为单一数据源,pages 引用 testData) */
65
+ export interface PageUrlValue {
66
+ name?: string;
67
+ online?: string;
68
+ pre?: string;
69
+ rc?: string;
70
+ test?: string;
71
+ /** URL 模板 ${param} 占位符的默认值(步骤级 overrides 优先) */
72
+ params?: Record<string, string>;
73
+ }
74
+ export interface PageDefinition {
75
+ elements: ElementRegistry;
76
+ waitForReady?: WaitForReady;
77
+ /** 页面入口(PageObject 第一职责:页面坐标)。engine.openPage(page) 按 KEVE_ENV 解析并填充 ${param} */
78
+ url?: PageUrlValue;
79
+ }
80
+ export interface DefinedPage extends PageDefinition {
81
+ /** 唯一标识,用于调试 */
82
+ __pageId: string;
83
+ /** 元素引用快照(展开工厂后的静态部分) */
84
+ __staticElements: Record<string, ElementRef>;
85
+ }
86
+ export declare function definePage(def: PageDefinition): DefinedPage;
87
+ /**
88
+ * 判断元素引用是否为动态工厂函数
89
+ */
90
+ export declare function isElementFactory(entry: ElementEntry): entry is ElementFactory;
91
+ /**
92
+ * 从 Page 中获取元素引用(如果是工厂,需要传参)
93
+ * 不传参时返回工厂函数本身
94
+ */
95
+ export declare function getElement(page: DefinedPage, key: keyof typeof page.elements): ElementEntry;
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ /**
3
+ * definePage — 代码化元素管理
4
+ *
5
+ * 扁平 key-value 元素定义,零运行时开销。
6
+ * 嵌套关系由 step 级定位链(resolveChain)表达,不在 Page 内维护。
7
+ *
8
+ * Usage:
9
+ * import { definePage } from '@kkeve/suite';
10
+ *
11
+ * export const myPage = definePage({
12
+ * elements: {
13
+ * saveButton: { role: 'button', name: '保存' },
14
+ * container: { css: '.my-section' },
15
+ * // 动态元素工厂
16
+ * itemByName: (text: string) => ({ role: 'listitem', name: text }),
17
+ * },
18
+ * waitForReady: {
19
+ * api: '/api/list',
20
+ * ui: 'container',
21
+ * },
22
+ * });
23
+ *
24
+ * // 在 spec 中引用
25
+ * await engine.click(myPage.elements.saveButton);
26
+ * await engine.click(myPage.elements.itemByName('时间'));
27
+ */
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.definePage = definePage;
30
+ exports.isElementFactory = isElementFactory;
31
+ exports.getElement = getElement;
32
+ // ─── definePage Implementation ───────────────────────────────────────
33
+ let __pageCounter = 0;
34
+ function definePage(def) {
35
+ const pageId = `page_${++__pageCounter}`;
36
+ // 提取静态元素快照(非函数的元素)
37
+ const staticElements = {};
38
+ for (const [key, entry] of Object.entries(def.elements)) {
39
+ if (typeof entry !== 'function') {
40
+ staticElements[key] = entry;
41
+ }
42
+ }
43
+ return {
44
+ ...def,
45
+ __pageId: pageId,
46
+ __staticElements: staticElements,
47
+ };
48
+ }
49
+ // ─── Helpers ─────────────────────────────────────────────────────────
50
+ /**
51
+ * 判断元素引用是否为动态工厂函数
52
+ */
53
+ function isElementFactory(entry) {
54
+ return typeof entry === 'function';
55
+ }
56
+ /**
57
+ * 从 Page 中获取元素引用(如果是工厂,需要传参)
58
+ * 不传参时返回工厂函数本身
59
+ */
60
+ function getElement(page, key) {
61
+ return page.elements[key];
62
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * elementResolver — 将 ElementRef 解析为 Playwright Locator
3
+ *
4
+ * 支持单元素解析和多级嵌套链式解析(resolveChain)。
5
+ * 引擎无关层:仅依赖 Playwright Locator API。
6
+ */
7
+ import type { Locator, Page } from '@playwright/test';
8
+ import type { ElementRef, ElementEntry, DefinedPage } from './definePage.js';
9
+ /**
10
+ * 将单个 ElementRef 解析为 Playwright Locator
11
+ *
12
+ * 优先级:ref > role+name > css
13
+ * 复合定位:hasText, has, notHas 在基础定位上叠加
14
+ */
15
+ export declare function resolveElement(page: Page, ref: ElementRef): Locator;
16
+ /**
17
+ * 多级嵌套解析:按顺序在父元素范围内查找子元素
18
+ *
19
+ * resolveChain(page, parent, child) → parent.locator(child)
20
+ * resolveChain(page, a, b, c, d) → a.locator(b).locator(c).locator(d)
21
+ *
22
+ * 支持 ElementRef 和 ElementEntry(工厂函数需先调用)
23
+ */
24
+ export declare function resolveChain(page: Page, ...refs: (ElementRef | ElementEntry)[]): Locator;
25
+ /**
26
+ * 从 DefinedPage 中解析元素(自动处理工厂函数)
27
+ *
28
+ * resolveFromPage(page, 'saveButton') → Locator
29
+ * resolveFromPage(page, 'itemByName', '时间') → Locator(工厂调用)
30
+ * resolveFromPage(page, 'dimensionList', ['itemByName', '时间']) → 链式解析
31
+ */
32
+ export declare function resolveFromPage(pwPage: Page, defPage: DefinedPage, ...keys: (string | any[])[]): Locator;