@kkeve/suite 0.0.20 → 0.0.22
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/dist/dsl/toDecorator.js +6 -1
- package/dist/engine-cypress/bridge.js +91 -2
- package/dist/engine-playwright/keve-test.js +2 -0
- package/dist/engine-playwright/pw-config.js +2 -0
- package/dist/runner/index.d.ts +85 -0
- package/dist/runner/index.js +220 -0
- package/package.json +6 -2
- package/scripts/run-dsl-task.mjs +97 -175
- package/scripts/runtime/cypress.config.mjs +2 -0
- package/scripts/runtime/playwright.config.mjs +1 -0
package/dist/dsl/toDecorator.js
CHANGED
|
@@ -203,7 +203,12 @@ function renderStep(d, ctx) {
|
|
|
203
203
|
break;
|
|
204
204
|
case 'DRAGGING': {
|
|
205
205
|
// 拖拽:context 为源,value/child 携带目标链;缺失目标不得伪装成悬停成功
|
|
206
|
-
const
|
|
206
|
+
const rawValue = d.value;
|
|
207
|
+
const valueCtx = Array.isArray(rawValue)
|
|
208
|
+
? rawValue
|
|
209
|
+
: (rawValue && typeof rawValue === 'object'
|
|
210
|
+
? rawValue.context || rawValue.value
|
|
211
|
+
: undefined);
|
|
207
212
|
const targetCtx = valueCtx || d.child?.context || d.child;
|
|
208
213
|
if (Array.isArray(targetCtx) && targetCtx.length) {
|
|
209
214
|
emit([`await engine.drag(${chain}, ${chainExpr(targetCtx)});`, ...waitExprs(d)]);
|
|
@@ -55,6 +55,82 @@ const path = __importStar(require("node:path"));
|
|
|
55
55
|
const screenshotDiff_js_1 = require("../core/judge/screenshotDiff.js");
|
|
56
56
|
const confidence_js_1 = require("../core/judge/confidence.js");
|
|
57
57
|
const reportData_js_1 = require("../core/generateReport/reportData.js");
|
|
58
|
+
/** Cypress 最终状态 → 报告状态;pending 明确是跳过,其余非 passed 均按失败处理 */
|
|
59
|
+
function mapCypressState(rawState) {
|
|
60
|
+
const state = String(rawState || '').toLowerCase();
|
|
61
|
+
if (state === 'passed')
|
|
62
|
+
return 'passed';
|
|
63
|
+
if (state === 'pending' || state === 'skipped')
|
|
64
|
+
return 'skipped';
|
|
65
|
+
if (state === 'timedout' || state === 'timed_out')
|
|
66
|
+
return 'timedOut';
|
|
67
|
+
return 'failed';
|
|
68
|
+
}
|
|
69
|
+
/** 从 Cypress after:spec / after:run 的 TestResult 提取开始时间与耗时 */
|
|
70
|
+
function timingOfCypressTest(test) {
|
|
71
|
+
const attempts = Array.isArray(test?.attempts) ? test.attempts : [];
|
|
72
|
+
const last = attempts[attempts.length - 1] || {};
|
|
73
|
+
const duration = Number(last.wallClockDuration ?? test?.duration ?? 0);
|
|
74
|
+
const startedAt = last.wallClockStartedAt || test?.wallClockStartedAt;
|
|
75
|
+
if (startedAt) {
|
|
76
|
+
const parsed = new Date(startedAt);
|
|
77
|
+
if (!Number.isNaN(parsed.getTime()))
|
|
78
|
+
return { duration, startTime: parsed.toISOString() };
|
|
79
|
+
}
|
|
80
|
+
return { duration, startTime: new Date(Date.now() - (Number.isFinite(duration) ? duration : 0)).toISOString() };
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* 用 Cypress 引擎掌握的最终测试结果补录 / 校正状态。
|
|
84
|
+
*
|
|
85
|
+
* 浏览器侧 `/report/test` 依赖全局 afterEach 与场景状态;beforeEach 等钩子
|
|
86
|
+
* 直接失败时状态可能拿不到,导致用例没有任何上报,最终被 reportData 补成
|
|
87
|
+
* `missing`。Cypress 的 after:spec / after:run 仍能提供权威结果,这里是唯一
|
|
88
|
+
* 不依赖用例内钩子的兜底入口。
|
|
89
|
+
*/
|
|
90
|
+
function mergeCypressRunTests(state, tests, specFile = '') {
|
|
91
|
+
let merged = 0;
|
|
92
|
+
for (const test of Array.isArray(tests) ? tests : []) {
|
|
93
|
+
const titlePath = Array.isArray(test?.title) ? test.title.map((part) => String(part)) : [];
|
|
94
|
+
const title = (titlePath[titlePath.length - 1] || String(test?.title || '')).trim();
|
|
95
|
+
if (!title)
|
|
96
|
+
continue;
|
|
97
|
+
const suiteTitle = titlePath.length >= 2 ? String(titlePath[titlePath.length - 2] || '').trim() : '';
|
|
98
|
+
const status = mapCypressState(test?.state);
|
|
99
|
+
const { duration, startTime } = timingOfCypressTest(test);
|
|
100
|
+
const error = status === 'passed' || status === 'skipped'
|
|
101
|
+
? undefined
|
|
102
|
+
: String(test?.displayError || test?.attempts?.[test.attempts.length - 1]?.error?.message || '');
|
|
103
|
+
const existing = state.results.find((r) => r.title === title && (!specFile || !r.file || r.file === specFile));
|
|
104
|
+
if (existing) {
|
|
105
|
+
// Cypress 的最终状态优先于浏览器侧可能拿到的中间状态;日志与附件保留。
|
|
106
|
+
existing.status = status;
|
|
107
|
+
existing.duration = duration || existing.duration;
|
|
108
|
+
existing.startTime = startTime || existing.startTime;
|
|
109
|
+
// Cypress 最终结论权威:失败才写错误,最终 passed/skipped 必须清掉
|
|
110
|
+
// 浏览器侧中间状态可能留下的误报错误,避免报告出现「通过但带错误」。
|
|
111
|
+
existing.error = error;
|
|
112
|
+
if (!existing.suiteTitle && suiteTitle)
|
|
113
|
+
existing.suiteTitle = suiteTitle;
|
|
114
|
+
if (!existing.file && specFile)
|
|
115
|
+
existing.file = specFile;
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
state.results.push({
|
|
119
|
+
title,
|
|
120
|
+
suiteTitle,
|
|
121
|
+
file: specFile,
|
|
122
|
+
status,
|
|
123
|
+
duration,
|
|
124
|
+
startTime,
|
|
125
|
+
error,
|
|
126
|
+
logs: [],
|
|
127
|
+
attachments: [],
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
merged++;
|
|
131
|
+
}
|
|
132
|
+
return merged;
|
|
133
|
+
}
|
|
58
134
|
function resultDirOf() {
|
|
59
135
|
// 优先使用 runner 注入的报告目录;缺失时回退旧布局,兼容历史执行环境。
|
|
60
136
|
if (process.env.KEVE_RESULT_DIR)
|
|
@@ -300,7 +376,10 @@ async function setupKeveBridge(on, config) {
|
|
|
300
376
|
}
|
|
301
377
|
catch { /* 软链失败不影响报告生成 */ }
|
|
302
378
|
}
|
|
303
|
-
|
|
379
|
+
// reportData.summary 是 summaryJson 本体,total 在其嵌套的 summary 字段下。
|
|
380
|
+
// 直接读 summary.total 恒为 undefined,日志会误报「用例 0」,掩盖真实用例数。
|
|
381
|
+
const cases = reportData?.summary?.summary?.total ?? reportData?.summary?.total;
|
|
382
|
+
return { ok: true, resultsPath, reportDataPath, cases: cases ?? undefined };
|
|
304
383
|
};
|
|
305
384
|
const handle = async (route, payload, signal) => {
|
|
306
385
|
switch (route) {
|
|
@@ -551,8 +630,15 @@ async function setupKeveBridge(on, config) {
|
|
|
551
630
|
console.log(`[keve-bridge] listening on 127.0.0.1:${port} → ${state.resultDir}`);
|
|
552
631
|
// 报告收口放在 after:run:此时所有 spec 的 /report/test 都已落盘,
|
|
553
632
|
// 且不依赖任何场景级的 afterAll(硬失败/中断也能出报告)。
|
|
554
|
-
on('after:run', async () => {
|
|
633
|
+
on('after:run', async (runResults) => {
|
|
555
634
|
try {
|
|
635
|
+
// 最后一次用 Cypress run 全量结果校正,防止个别 spec 的 after:spec 未触发。
|
|
636
|
+
let merged = 0;
|
|
637
|
+
for (const run of Array.isArray(runResults?.runs) ? runResults.runs : []) {
|
|
638
|
+
merged += mergeCypressRunTests(state, run?.tests || [], String(run?.spec?.relative || ''));
|
|
639
|
+
}
|
|
640
|
+
if (merged > 0)
|
|
641
|
+
console.log(`[keve-bridge] Cypress 最终结果校正 ${merged} 条`);
|
|
556
642
|
const out = await finalize();
|
|
557
643
|
console.log(`[keve-bridge] report-data.json → ${out.reportDataPath}(用例 ${out.cases ?? 0})`);
|
|
558
644
|
}
|
|
@@ -572,6 +658,9 @@ async function setupKeveBridge(on, config) {
|
|
|
572
658
|
* after:spec 早于 after:run 触发,此时 results 已由浏览器侧 /report/test 落盘。
|
|
573
659
|
*/
|
|
574
660
|
on('after:spec', async (_spec, results) => {
|
|
661
|
+
const merged = mergeCypressRunTests(state, results?.tests || [], String(results?.spec?.relative || ''));
|
|
662
|
+
if (merged > 0)
|
|
663
|
+
console.log(`[keve-bridge] Cypress 用例结果校正 ${merged} 条`);
|
|
575
664
|
const video = results?.video;
|
|
576
665
|
if (video && fs.existsSync(video)) {
|
|
577
666
|
// 相对 taskDir,与 Playwright 侧 videoPath 的语义一致
|
|
@@ -133,8 +133,10 @@ exports.test = test_1.test.extend({
|
|
|
133
133
|
//(自定义 context 不会自动附加视频附件,需在 context 关闭后显式 attach)
|
|
134
134
|
const videoOpt = testInfo.project?.use?.video;
|
|
135
135
|
const videoOn = !!videoOpt && (videoOpt === 'on' || videoOpt.mode === 'on' || videoOpt.mode === 'retain-on-failure');
|
|
136
|
+
const viewport = testInfo.project?.use?.viewport;
|
|
136
137
|
const context = await browser.newContext({
|
|
137
138
|
storageState,
|
|
139
|
+
...(viewport ? { viewport } : {}),
|
|
138
140
|
...(videoOn ? { recordVideo: { dir: typeof videoOpt === 'object' && videoOpt.dir ? videoOpt.dir : undefined } } : {}),
|
|
139
141
|
});
|
|
140
142
|
const page = await context.newPage();
|
|
@@ -88,6 +88,7 @@ export default defineConfig({
|
|
|
88
88
|
],
|
|
89
89
|
use: {
|
|
90
90
|
baseURL: ${baseURLLine},${cdp ? `\n // CDP mode: login state preserved via connected Chrome` : `\n storageState: path.resolve(projectRoot, '.auth/storage-state.json'),`}
|
|
91
|
+
viewport: { width: 2560, height: 1440 },
|
|
91
92
|
trace: 'on-first-retry',
|
|
92
93
|
screenshot: 'off',
|
|
93
94
|
video: 'on',
|
|
@@ -136,6 +137,7 @@ export default defineConfig({
|
|
|
136
137
|
],
|
|
137
138
|
use: {
|
|
138
139
|
baseURL: ${baseURLLine},${cdp ? `\n // CDP mode: login state preserved via connected Chrome` : `\n storageState: resolve(projectRoot, '.auth', 'storage-state.json'),`}
|
|
140
|
+
viewport: { width: 2560, height: 1440 },
|
|
139
141
|
trace: 'on-first-retry',
|
|
140
142
|
screenshot: 'off',
|
|
141
143
|
video: 'on',
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { type DslStep, type DslVariableRow } from '../dsl/index.js';
|
|
2
|
+
export type ExecutionEngine = 'cypress' | 'playwright';
|
|
3
|
+
export interface ResolveEngineBinOptions {
|
|
4
|
+
engine: ExecutionEngine;
|
|
5
|
+
/** 平台执行侧根目录(声明并安装了 cypress / @playwright/test / esbuild) */
|
|
6
|
+
backendRoot: string;
|
|
7
|
+
/** 本 runner 包根目录;本地调试等场景可退回到自身 node_modules */
|
|
8
|
+
suiteRoot: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* 按引擎解析可执行文件路径。
|
|
12
|
+
* 候选顺序:环境变量显式指定 → backendRoot/node_modules → suiteRoot/node_modules。
|
|
13
|
+
* 三种调用方(线上 runner / 本地调试 open 模式)共用同一份候选顺序,
|
|
14
|
+
* 避免「平台能跑、本地跑不起来」这类环境差异问题各自排查一遍。
|
|
15
|
+
*/
|
|
16
|
+
export declare function resolveEngineBin({ engine, backendRoot, suiteRoot }: ResolveEngineBinOptions): string | undefined;
|
|
17
|
+
/** esbuild 模块路径解析,Cypress 预处理器依赖。候选顺序与 resolveEngineBin 一致。 */
|
|
18
|
+
export declare function resolveEsbuildModule(backendRoot: string, suiteRoot: string): string | undefined;
|
|
19
|
+
export interface BuildExecEnvOptions {
|
|
20
|
+
input: {
|
|
21
|
+
backendRoot?: string;
|
|
22
|
+
env?: string;
|
|
23
|
+
targetUrl?: string;
|
|
24
|
+
ssoCookiesJson?: string;
|
|
25
|
+
identitySsoCookies?: Record<string, string>;
|
|
26
|
+
llm?: {
|
|
27
|
+
baseUrl?: string;
|
|
28
|
+
model?: string;
|
|
29
|
+
apiKey?: string;
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
taskDir: string;
|
|
33
|
+
round: string | number;
|
|
34
|
+
engine: ExecutionEngine;
|
|
35
|
+
/** 兜底 backendRoot:input.backendRoot 为空时使用 */
|
|
36
|
+
defaultBackendRoot: string;
|
|
37
|
+
suiteRoot: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* 两个引擎共用的 KEVE_* 环境变量契约。
|
|
41
|
+
* run(headless 批跑)和 open(交互调试)模式必须拼出完全一致的变量集,
|
|
42
|
+
* 否则同一份静态 cypress.config.mjs / playwright.config.mjs 在两种模式下读到不同的值。
|
|
43
|
+
*/
|
|
44
|
+
export declare function buildExecEnv({ input, taskDir, round, engine, defaultBackendRoot, suiteRoot, }: BuildExecEnvOptions): NodeJS.ProcessEnv;
|
|
45
|
+
export interface CaseInput {
|
|
46
|
+
stepGroupId: number | string;
|
|
47
|
+
caseName?: string;
|
|
48
|
+
dslList: DslStep[];
|
|
49
|
+
variableList?: DslVariableRow[];
|
|
50
|
+
accountInfo?: {
|
|
51
|
+
accountInfo?: {
|
|
52
|
+
name?: string;
|
|
53
|
+
password?: string;
|
|
54
|
+
};
|
|
55
|
+
name?: string;
|
|
56
|
+
password?: string;
|
|
57
|
+
} | null;
|
|
58
|
+
}
|
|
59
|
+
export interface ProjectCasesOptions {
|
|
60
|
+
cases: CaseInput[];
|
|
61
|
+
engine: ExecutionEngine;
|
|
62
|
+
specDir: string;
|
|
63
|
+
env: string;
|
|
64
|
+
ssoInject?: boolean;
|
|
65
|
+
/** 用例级账号未指定时的兜底账号 */
|
|
66
|
+
defaultAccountInfo?: CaseInput['accountInfo'];
|
|
67
|
+
}
|
|
68
|
+
export interface CaseEntry {
|
|
69
|
+
caseId: string;
|
|
70
|
+
stepGroupId: number;
|
|
71
|
+
caseName: string;
|
|
72
|
+
specFile: string;
|
|
73
|
+
stepCount: number;
|
|
74
|
+
steps: {
|
|
75
|
+
step: string;
|
|
76
|
+
expected: string;
|
|
77
|
+
}[];
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* 逐条用例投影成装饰器脚本,落盘到 specDir,并返回报告索引(caseEntries)。
|
|
81
|
+
* run / open 两种模式共用同一份投影,脚本内容、步骤文案完全一致。
|
|
82
|
+
*/
|
|
83
|
+
export declare function projectCasesToSpecs({ cases, engine, specDir, env, ssoInject, defaultAccountInfo, }: ProjectCasesOptions): CaseEntry[];
|
|
84
|
+
/** 供报告补全步骤文案的最小用例清单(cases/test-cases.yaml) */
|
|
85
|
+
export declare function buildCasesYaml(caseEntries: CaseEntry[]): string;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.resolveEngineBin = resolveEngineBin;
|
|
37
|
+
exports.resolveEsbuildModule = resolveEsbuildModule;
|
|
38
|
+
exports.buildExecEnv = buildExecEnv;
|
|
39
|
+
exports.projectCasesToSpecs = projectCasesToSpecs;
|
|
40
|
+
exports.buildCasesYaml = buildCasesYaml;
|
|
41
|
+
/**
|
|
42
|
+
* runner — 平台任务执行的「纯函数」库面:bin 定位 / env 契约 / 用例投影
|
|
43
|
+
*
|
|
44
|
+
* 为什么单独拆出这一层:
|
|
45
|
+
* run-dsl-task.mjs(cli 面,非交互批跑)和本地调试工具的交互模式(client 面,
|
|
46
|
+
* cypress.open() / playwright --ui)需要跑出**完全一致**的 spec 脚本和环境变量,
|
|
47
|
+
* 否则同一条用例在两种模式下会有不同行为,调试时看到的和线上跑的不是一回事。
|
|
48
|
+
*
|
|
49
|
+
* 分层约定(见 autotest-core同步新执行链路技术方案.md 2.7 节):
|
|
50
|
+
* - suite(本文件):只负责「给我 cases + env 描述,还你 spec 文件路径和 env 变量」,
|
|
51
|
+
* 零 spawn、零 process.exit、零文件系统之外的副作用。
|
|
52
|
+
* - cli(run-dsl-task.mjs / 本地调试工具的 execution-adapter.js):决定「谁来跑、
|
|
53
|
+
* 以什么方式跑(headless / 交互)」,调用本文件的函数拼出可执行动作。
|
|
54
|
+
* - backend:只消费 cli 入口的输入输出契约(execution-input.json /
|
|
55
|
+
* execution-result.json),不 import 本文件一个函数。
|
|
56
|
+
*/
|
|
57
|
+
const fs = __importStar(require("node:fs"));
|
|
58
|
+
const path = __importStar(require("node:path"));
|
|
59
|
+
const index_js_1 = require("../dsl/index.js");
|
|
60
|
+
/**
|
|
61
|
+
* 按引擎解析可执行文件路径。
|
|
62
|
+
* 候选顺序:环境变量显式指定 → backendRoot/node_modules → suiteRoot/node_modules。
|
|
63
|
+
* 三种调用方(线上 runner / 本地调试 open 模式)共用同一份候选顺序,
|
|
64
|
+
* 避免「平台能跑、本地跑不起来」这类环境差异问题各自排查一遍。
|
|
65
|
+
*/
|
|
66
|
+
function resolveEngineBin({ engine, backendRoot, suiteRoot }) {
|
|
67
|
+
const envVar = engine === 'cypress'
|
|
68
|
+
? process.env.KEVE_CYPRESS_BIN
|
|
69
|
+
: process.env.KEVE_PLAYWRIGHT_BIN;
|
|
70
|
+
const binName = engine === 'cypress' ? 'cypress' : 'playwright';
|
|
71
|
+
const candidates = [
|
|
72
|
+
envVar,
|
|
73
|
+
path.join(backendRoot, 'node_modules', '.bin', binName),
|
|
74
|
+
path.join(suiteRoot, 'node_modules', '.bin', binName),
|
|
75
|
+
].filter(Boolean);
|
|
76
|
+
return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0];
|
|
77
|
+
}
|
|
78
|
+
/** esbuild 模块路径解析,Cypress 预处理器依赖。候选顺序与 resolveEngineBin 一致。 */
|
|
79
|
+
function resolveEsbuildModule(backendRoot, suiteRoot) {
|
|
80
|
+
const candidates = [
|
|
81
|
+
process.env.KEVE_ESBUILD_MODULE,
|
|
82
|
+
path.join(backendRoot, 'node_modules', 'esbuild', 'lib', 'main.js'),
|
|
83
|
+
path.join(suiteRoot, 'node_modules', 'esbuild', 'lib', 'main.js'),
|
|
84
|
+
].filter(Boolean);
|
|
85
|
+
return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0];
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* 两个引擎共用的 KEVE_* 环境变量契约。
|
|
89
|
+
* run(headless 批跑)和 open(交互调试)模式必须拼出完全一致的变量集,
|
|
90
|
+
* 否则同一份静态 cypress.config.mjs / playwright.config.mjs 在两种模式下读到不同的值。
|
|
91
|
+
*/
|
|
92
|
+
function buildExecEnv({ input, taskDir, round, engine, defaultBackendRoot, suiteRoot, }) {
|
|
93
|
+
const backendRoot = input.backendRoot || defaultBackendRoot;
|
|
94
|
+
return {
|
|
95
|
+
...process.env,
|
|
96
|
+
KEVE_TASK_DIR: taskDir,
|
|
97
|
+
KEVE_RESULT_DIR: path.join(taskDir, 'reports', `round-${round}`),
|
|
98
|
+
KEVE_CASES_DIR: path.join(taskDir, 'cases'),
|
|
99
|
+
KEVE_ARTIFACT_BASE: taskDir,
|
|
100
|
+
KEVE_ROUND: String(round),
|
|
101
|
+
KEVE_ENV: input.env || 'test',
|
|
102
|
+
KEVE_TARGET_URL: input.targetUrl || '',
|
|
103
|
+
KEVE_STORAGE_STATE: path.join(taskDir, '.auth', 'storage-state.json'),
|
|
104
|
+
KEVE_SSO_COOKIES: input.ssoCookiesJson || '[]',
|
|
105
|
+
KEVE_IDENTITY_SSO_COOKIES: JSON.stringify(input.identitySsoCookies || {}),
|
|
106
|
+
KEVE_LLM_BASE_URL: input.llm?.baseUrl || process.env.KEVE_LLM_BASE_URL || '',
|
|
107
|
+
KEVE_LLM_MODEL_NAME: input.llm?.model || process.env.KEVE_LLM_MODEL_NAME || '',
|
|
108
|
+
KEVE_LLM_API_KEY: input.llm?.apiKey || process.env.KEVE_LLM_API_KEY || '',
|
|
109
|
+
KEVE_ENGINE: engine,
|
|
110
|
+
KEVE_ESBUILD_MODULE: resolveEsbuildModule(backendRoot, suiteRoot) || '',
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/** 从账号信息里提取 toDecorator 需要的占位账号(不下发密码到脚本正文以外的位置) */
|
|
114
|
+
function resolveAccount(accountInfo) {
|
|
115
|
+
const account = accountInfo?.accountInfo || accountInfo;
|
|
116
|
+
if (!account?.name)
|
|
117
|
+
return undefined;
|
|
118
|
+
return { name: String(account.name), password: String(account.password || '') };
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* 投影 DSL 步骤为报告 outline,语义与 toDecorator 的 keveGoal 参数保持一致。
|
|
122
|
+
* OPEN_PAGE 后紧跟的 WAIT_RESPONSE 会合并进同一个导航步骤,避免报告步骤数
|
|
123
|
+
* 与脚本实际 keveGoal 数不一致。
|
|
124
|
+
*/
|
|
125
|
+
function buildCaseSteps(dslList, ctx) {
|
|
126
|
+
const steps = [];
|
|
127
|
+
for (let i = 0; i < dslList.length; i++) {
|
|
128
|
+
const d = dslList[i];
|
|
129
|
+
const op = String(d?.operation || '');
|
|
130
|
+
if (op === 'OPEN_PAGE') {
|
|
131
|
+
let j = i + 1;
|
|
132
|
+
const gates = [];
|
|
133
|
+
while (j < dslList.length && String(dslList[j]?.operation || '') === 'WAIT_RESPONSE') {
|
|
134
|
+
gates.push(dslList[j++]);
|
|
135
|
+
}
|
|
136
|
+
if (gates.length) {
|
|
137
|
+
const gateTexts = gates.map((g) => String(g?.text || '')).filter(Boolean);
|
|
138
|
+
const gateExpects = gates
|
|
139
|
+
.filter((g) => (g?.expectation || []).length > 0)
|
|
140
|
+
.map((g) => (0, index_js_1.stepExpectedText)(g, ctx))
|
|
141
|
+
.filter(Boolean);
|
|
142
|
+
steps.push({
|
|
143
|
+
step: String(d?.text || 'OPEN_PAGE'),
|
|
144
|
+
expected: [...gateTexts, ...gateExpects].filter(Boolean).join(';') || '打开目标页面并等待就绪响应',
|
|
145
|
+
});
|
|
146
|
+
i = j - 1;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const stepText = String(d?.text || op || '');
|
|
151
|
+
const expected = (0, index_js_1.stepExpectedText)(d, ctx);
|
|
152
|
+
if (!stepText)
|
|
153
|
+
continue;
|
|
154
|
+
steps.push({ step: stepText, expected });
|
|
155
|
+
}
|
|
156
|
+
return steps;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* 逐条用例投影成装饰器脚本,落盘到 specDir,并返回报告索引(caseEntries)。
|
|
160
|
+
* run / open 两种模式共用同一份投影,脚本内容、步骤文案完全一致。
|
|
161
|
+
*/
|
|
162
|
+
function projectCasesToSpecs({ cases, engine, specDir, env, ssoInject = true, defaultAccountInfo, }) {
|
|
163
|
+
fs.mkdirSync(specDir, { recursive: true });
|
|
164
|
+
const importFrom = engine === 'cypress' ? '@kkeve/suite/engine-cypress' : '@kkeve/suite/keve-test';
|
|
165
|
+
const caseEntries = [];
|
|
166
|
+
for (const item of cases) {
|
|
167
|
+
const stepGroupId = Number(item.stepGroupId);
|
|
168
|
+
if (!Number.isFinite(stepGroupId)) {
|
|
169
|
+
throw new Error(`用例缺少合法 stepGroupId: ${JSON.stringify(item.stepGroupId)}`);
|
|
170
|
+
}
|
|
171
|
+
const dslList = Array.isArray(item.dslList) ? item.dslList : [];
|
|
172
|
+
if (!dslList.length) {
|
|
173
|
+
throw new Error(`用例 ${stepGroupId} 的 dslList 为空,拒绝生成空脚本`);
|
|
174
|
+
}
|
|
175
|
+
const account = resolveAccount(item.accountInfo || defaultAccountInfo);
|
|
176
|
+
const variableList = Array.isArray(item.variableList) ? item.variableList : [];
|
|
177
|
+
const renderCtx = { env, variableList, account };
|
|
178
|
+
const caseName = item.caseName || `用例 ${stepGroupId}`;
|
|
179
|
+
const code = (0, index_js_1.toDecorator)({
|
|
180
|
+
stepGroupId,
|
|
181
|
+
caseName,
|
|
182
|
+
dslList,
|
|
183
|
+
env,
|
|
184
|
+
variableList,
|
|
185
|
+
account,
|
|
186
|
+
ssoInject,
|
|
187
|
+
importFrom,
|
|
188
|
+
});
|
|
189
|
+
const fileName = `sg${stepGroupId}.spec.ts`;
|
|
190
|
+
fs.writeFileSync(path.join(specDir, fileName), code, 'utf-8');
|
|
191
|
+
caseEntries.push({
|
|
192
|
+
caseId: `S${stepGroupId}`,
|
|
193
|
+
stepGroupId,
|
|
194
|
+
caseName,
|
|
195
|
+
specFile: fileName,
|
|
196
|
+
stepCount: dslList.length,
|
|
197
|
+
steps: buildCaseSteps(dslList, renderCtx),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return caseEntries;
|
|
201
|
+
}
|
|
202
|
+
/** 供报告补全步骤文案的最小用例清单(cases/test-cases.yaml) */
|
|
203
|
+
function buildCasesYaml(caseEntries) {
|
|
204
|
+
const lines = ['modules:', ' - name: plan', ' cases:'];
|
|
205
|
+
for (const entry of caseEntries) {
|
|
206
|
+
lines.push(` - id: ${entry.caseId}`);
|
|
207
|
+
lines.push(` title: ${JSON.stringify(entry.caseName)}`);
|
|
208
|
+
if (Array.isArray(entry.steps) && entry.steps.length) {
|
|
209
|
+
lines.push(' steps:');
|
|
210
|
+
for (const step of entry.steps) {
|
|
211
|
+
lines.push(` - step: ${JSON.stringify(step.step || '')}`);
|
|
212
|
+
lines.push(` expected: ${JSON.stringify(step.expected || '')}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
lines.push(' steps: []');
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return lines.join('\n') + '\n';
|
|
220
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kkeve/suite",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.22",
|
|
4
4
|
"description": "AI-powered full-stack test agent CLI - static analysis, unit test, component test, E2E test, execute, evaluate, and report",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
@@ -55,6 +55,10 @@
|
|
|
55
55
|
"./spec/keve-read-doc": {
|
|
56
56
|
"types": "./dist/spec/keve-read-doc.d.ts",
|
|
57
57
|
"default": "./dist/spec/keve-read-doc.js"
|
|
58
|
+
},
|
|
59
|
+
"./runner": {
|
|
60
|
+
"types": "./dist/runner/index.d.ts",
|
|
61
|
+
"default": "./dist/runner/index.js"
|
|
58
62
|
}
|
|
59
63
|
},
|
|
60
64
|
"files": [
|
|
@@ -91,4 +95,4 @@
|
|
|
91
95
|
"tsx": "^4.19.0",
|
|
92
96
|
"typescript": "^6.0.2"
|
|
93
97
|
}
|
|
94
|
-
}
|
|
98
|
+
}
|
package/scripts/run-dsl-task.mjs
CHANGED
|
@@ -7,11 +7,21 @@
|
|
|
7
7
|
* 并把已展开、已解析的 dslList 写进 execution-input.json;
|
|
8
8
|
* - 本 runner 只做「投影 → 落盘 → 执行 → 出报告」,不直连业务库。
|
|
9
9
|
*
|
|
10
|
+
* 分层约定(详见 autotest-core同步新执行链路技术方案.md 2.7 节):
|
|
11
|
+
* - bin 解析 / env 契约 / DSL→spec 投影这三件「纯函数」逻辑统一在
|
|
12
|
+
* @kkeve/suite/runner 导出,本文件只负责「调函数 + 决定怎么跑」。
|
|
13
|
+
* - 本地调试工具(autotest-core)的交互模式(mode=open)调用同一套
|
|
14
|
+
* @kkeve/suite/runner 函数,不再自行重写一份,避免 run / open 行为漂移。
|
|
15
|
+
*
|
|
10
16
|
* 两个引擎共用同一份 @kkeve/suite 装饰器脚本与同一份 report-data.json 生成逻辑,
|
|
11
17
|
* 因此 Cypress / Playwright 的报告结构完全一致。
|
|
12
18
|
*
|
|
13
19
|
* 用法:
|
|
14
|
-
* node run-dsl-task.mjs --input execution-input.json
|
|
20
|
+
* node run-dsl-task.mjs --input execution-input.json [--mode=run|open]
|
|
21
|
+
*
|
|
22
|
+
* mode=run(默认):无头批跑,产出 execution-result.json / manifest.json / report-data.json。
|
|
23
|
+
* mode=open:交互调试。只做「投影 → 落盘 → 拉起 cypress.open() / playwright --ui」,
|
|
24
|
+
* 不等待测试结果;进程生命周期(杀旧进程、串行队列等)由调用方(本地调试工具)管理。
|
|
15
25
|
*
|
|
16
26
|
* 输出:
|
|
17
27
|
* <taskRoot>/runs/execution-result.json(执行摘要,供 backend 回写任务终态)
|
|
@@ -30,7 +40,13 @@
|
|
|
30
40
|
import * as fs from 'node:fs';
|
|
31
41
|
import * as path from 'node:path';
|
|
32
42
|
import * as url from 'node:url';
|
|
33
|
-
import { spawnSync } from 'node:child_process';
|
|
43
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
44
|
+
import {
|
|
45
|
+
resolveEngineBin,
|
|
46
|
+
buildExecEnv as buildExecEnvFromSuite,
|
|
47
|
+
projectCasesToSpecs,
|
|
48
|
+
buildCasesYaml,
|
|
49
|
+
} from '../dist/runner/index.js';
|
|
34
50
|
|
|
35
51
|
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
|
36
52
|
const suiteRoot = path.resolve(__dirname, '..');
|
|
@@ -43,33 +59,6 @@ const runtimeDir = path.join(__dirname, 'runtime');
|
|
|
43
59
|
*/
|
|
44
60
|
const defaultBackendRoot = path.resolve(suiteRoot, '..', '..', '..', 'eve-backend');
|
|
45
61
|
|
|
46
|
-
function resolveCypressBin(backendRoot) {
|
|
47
|
-
const candidates = [
|
|
48
|
-
process.env.KEVE_CYPRESS_BIN,
|
|
49
|
-
path.join(backendRoot, 'node_modules', '.bin', 'cypress'),
|
|
50
|
-
path.join(suiteRoot, 'node_modules', '.bin', 'cypress'),
|
|
51
|
-
].filter(Boolean);
|
|
52
|
-
return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0];
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function resolveEsbuildModule(backendRoot) {
|
|
56
|
-
const candidates = [
|
|
57
|
-
process.env.KEVE_ESBUILD_MODULE,
|
|
58
|
-
path.join(backendRoot, 'node_modules', 'esbuild', 'lib', 'main.js'),
|
|
59
|
-
path.join(suiteRoot, 'node_modules', 'esbuild', 'lib', 'main.js'),
|
|
60
|
-
].filter(Boolean);
|
|
61
|
-
return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0];
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function resolvePlaywrightBin(backendRoot) {
|
|
65
|
-
const candidates = [
|
|
66
|
-
process.env.KEVE_PLAYWRIGHT_BIN,
|
|
67
|
-
path.join(backendRoot, 'node_modules', '.bin', 'playwright'),
|
|
68
|
-
path.join(suiteRoot, 'node_modules', '.bin', 'playwright'),
|
|
69
|
-
].filter(Boolean);
|
|
70
|
-
return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0];
|
|
71
|
-
}
|
|
72
|
-
|
|
73
62
|
function parseArgs(argv) {
|
|
74
63
|
const args = {};
|
|
75
64
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -130,58 +119,10 @@ function normalizeStep(step) {
|
|
|
130
119
|
};
|
|
131
120
|
}
|
|
132
121
|
|
|
133
|
-
/**
|
|
134
|
-
* 投影 DSL 步骤为报告 outline,语义与 toDecorator 的 keveGoal 参数保持一致。
|
|
135
|
-
* OPEN_PAGE 后紧跟的 WAIT_RESPONSE 会合并进同一个导航步骤,避免报告步骤数
|
|
136
|
-
* 与脚本实际 keveGoal 数不一致。
|
|
137
|
-
*
|
|
138
|
-
* expected 文案统一走 stepExpectedText(与脚本投影同一个函数):报告里展示的
|
|
139
|
-
* 预期与评测 Agent 读到的预期必须逐字一致,否则平台标签(「报错4」)会在报告
|
|
140
|
-
* 与评测两侧产生不同解读。
|
|
141
|
-
*/
|
|
142
|
-
function buildCaseSteps(dslList, ctx, stepExpectedText) {
|
|
143
|
-
const steps = [];
|
|
144
|
-
for (let i = 0; i < dslList.length; i++) {
|
|
145
|
-
const d = dslList[i];
|
|
146
|
-
const op = String(d?.operation || '');
|
|
147
|
-
if (op === 'OPEN_PAGE') {
|
|
148
|
-
let j = i + 1;
|
|
149
|
-
const gates = [];
|
|
150
|
-
while (j < dslList.length && String(dslList[j]?.operation || '') === 'WAIT_RESPONSE') {
|
|
151
|
-
gates.push(dslList[j++]);
|
|
152
|
-
}
|
|
153
|
-
if (gates.length) {
|
|
154
|
-
const gateTexts = gates.map((g) => String(g?.text || '')).filter(Boolean);
|
|
155
|
-
const gateExpects = gates
|
|
156
|
-
.filter((g) => (g?.expectation || []).length > 0)
|
|
157
|
-
.map((g) => stepExpectedText(g, ctx))
|
|
158
|
-
.filter(Boolean);
|
|
159
|
-
steps.push({
|
|
160
|
-
step: String(d?.text || 'OPEN_PAGE'),
|
|
161
|
-
expected: [...gateTexts, ...gateExpects].filter(Boolean).join(';') || '打开目标页面并等待就绪响应',
|
|
162
|
-
});
|
|
163
|
-
i = j - 1;
|
|
164
|
-
continue;
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
const stepText = String(d?.text || op || '');
|
|
168
|
-
const expected = stepExpectedText(d, ctx);
|
|
169
|
-
if (!stepText) continue;
|
|
170
|
-
steps.push({ step: stepText, expected });
|
|
171
|
-
}
|
|
172
|
-
return steps;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/** 从账号信息里提取 toDecorator 需要的占位账号(不下发密码到脚本正文以外的位置) */
|
|
176
|
-
function resolveAccount(accountInfo) {
|
|
177
|
-
const account = accountInfo?.accountInfo || accountInfo;
|
|
178
|
-
if (!account?.name) return undefined;
|
|
179
|
-
return { name: String(account.name), password: String(account.password || '') };
|
|
180
|
-
}
|
|
181
|
-
|
|
182
122
|
async function main() {
|
|
183
123
|
const args = parseArgs(process.argv.slice(2));
|
|
184
124
|
if (!args.input) fail('缺少 --input <execution-input.json>');
|
|
125
|
+
const mode = args.mode === 'open' ? 'open' : 'run';
|
|
185
126
|
|
|
186
127
|
const inputFile = path.resolve(args.input);
|
|
187
128
|
const input = readJson(inputFile, '执行输入');
|
|
@@ -221,51 +162,22 @@ async function main() {
|
|
|
221
162
|
fs.rmSync(resultFile, { force: true });
|
|
222
163
|
fs.rmSync(manifestFile, { force: true });
|
|
223
164
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const account = resolveAccount(item.accountInfo || input.accountInfo);
|
|
241
|
-
const renderCtx = {
|
|
242
|
-
env: input.env || 'test',
|
|
243
|
-
variableList: Array.isArray(item.variableList) ? item.variableList : [],
|
|
244
|
-
account,
|
|
245
|
-
};
|
|
246
|
-
const code = toDecorator({
|
|
247
|
-
stepGroupId,
|
|
248
|
-
caseName: item.caseName || `用例 ${stepGroupId}`,
|
|
249
|
-
dslList,
|
|
250
|
-
env: renderCtx.env,
|
|
251
|
-
variableList: renderCtx.variableList,
|
|
252
|
-
account: renderCtx.account,
|
|
253
|
-
ssoInject: input.ssoInject !== false,
|
|
254
|
-
importFrom,
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
const fileName = `sg${stepGroupId}.spec.ts`;
|
|
258
|
-
fs.writeFileSync(path.join(specDir, fileName), code, 'utf-8');
|
|
259
|
-
const dslSteps = buildCaseSteps(dslList, renderCtx, stepExpectedText);
|
|
260
|
-
caseEntries.push({
|
|
261
|
-
caseId: `S${stepGroupId}`,
|
|
262
|
-
stepGroupId,
|
|
263
|
-
caseName: item.caseName || `用例 ${stepGroupId}`,
|
|
264
|
-
specFile: fileName,
|
|
265
|
-
stepCount: dslList.length,
|
|
266
|
-
steps: dslSteps,
|
|
267
|
-
});
|
|
268
|
-
console.log(`[run-dsl-task] 生成脚本 ${fileName}(${dslList.length} 步)`);
|
|
165
|
+
// ── 逐条用例投影成装饰器脚本(run / open 两种模式共用同一份实现) ──
|
|
166
|
+
const normalizedCases = cases.map((item) => ({
|
|
167
|
+
...item,
|
|
168
|
+
stepGroupId: Number(item.stepGroupId),
|
|
169
|
+
dslList: (Array.isArray(item.dslList) ? item.dslList : []).map(normalizeStep),
|
|
170
|
+
}));
|
|
171
|
+
const caseEntries = projectCasesToSpecs({
|
|
172
|
+
cases: normalizedCases,
|
|
173
|
+
engine,
|
|
174
|
+
specDir,
|
|
175
|
+
env: input.env || 'test',
|
|
176
|
+
ssoInject: input.ssoInject !== false,
|
|
177
|
+
defaultAccountInfo: input.accountInfo,
|
|
178
|
+
});
|
|
179
|
+
for (const entry of caseEntries) {
|
|
180
|
+
console.log(`[run-dsl-task] 生成脚本 ${entry.specFile}(${entry.stepCount} 步)`);
|
|
269
181
|
}
|
|
270
182
|
|
|
271
183
|
// ── 用例定义写入 cases/test-cases.yaml,让报告能补全步骤文案 ──
|
|
@@ -277,6 +189,14 @@ async function main() {
|
|
|
277
189
|
'utf-8',
|
|
278
190
|
);
|
|
279
191
|
|
|
192
|
+
if (mode === 'open') {
|
|
193
|
+
// 交互调试:只负责「投影 + 拉起交互入口」,不等待退出、不写执行结果。
|
|
194
|
+
// 进程生命周期(杀旧进程、串行排队、端口探测)留给调用方(本地调试工具)管理,
|
|
195
|
+
// 这是宿主(Electron/CLI)的关注点,不属于本 runner 的职责。
|
|
196
|
+
await openInteractive({ engine, input, taskRoot, specDir, resultDir, round });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
280
200
|
// ── 执行 ──
|
|
281
201
|
const runner = engine === 'cypress' ? runCypress : runPlaywright;
|
|
282
202
|
const exitCode = runner({ input, taskDir: taskRoot, specDir, resultDir, round });
|
|
@@ -333,6 +253,50 @@ async function main() {
|
|
|
333
253
|
process.exit(result.ok ? 0 : 1);
|
|
334
254
|
}
|
|
335
255
|
|
|
256
|
+
/**
|
|
257
|
+
* mode=open:拉起交互入口(cypress.open() / playwright test --ui)。
|
|
258
|
+
* env 契约与 run 模式完全一致(见 buildExecEnv),因此同一份 cypress.config.mjs /
|
|
259
|
+
* playwright.config.mjs 在两种模式下行为一致。本函数只负责「拉起」,不管进程生命周期:
|
|
260
|
+
* cypress.open() 阻塞到窗口关闭;playwright --ui 常驻进程交由调用方 detach + 管理。
|
|
261
|
+
*/
|
|
262
|
+
async function openInteractive({ engine, input, taskRoot, specDir, resultDir, round }) {
|
|
263
|
+
fs.mkdirSync(resultDir, { recursive: true });
|
|
264
|
+
const execEnv = buildExecEnvFromSuite({
|
|
265
|
+
input, taskDir: taskRoot, round, engine, defaultBackendRoot, suiteRoot,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
if (engine === 'playwright') {
|
|
269
|
+
const configPath = path.join(runtimeDir, 'playwright.config.mjs');
|
|
270
|
+
const backendRoot = input.backendRoot || defaultBackendRoot;
|
|
271
|
+
const cli = resolveEngineBin({ engine: 'playwright', backendRoot, suiteRoot });
|
|
272
|
+
if (!cli || !fs.existsSync(cli)) fail('找不到 Playwright 可执行文件,请设置 KEVE_PLAYWRIGHT_BIN');
|
|
273
|
+
const child = spawn(cli, ['test', '--ui', '--config', configPath], {
|
|
274
|
+
cwd: taskRoot,
|
|
275
|
+
env: execEnv,
|
|
276
|
+
detached: true,
|
|
277
|
+
stdio: 'inherit',
|
|
278
|
+
});
|
|
279
|
+
child.unref();
|
|
280
|
+
console.log(`[run-dsl-task] Playwright UI 已拉起(pid=${child.pid})`);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const configPath = path.join(runtimeDir, 'cypress.config.mjs');
|
|
285
|
+
const backendRoot = input.backendRoot || defaultBackendRoot;
|
|
286
|
+
const cypressBin = resolveEngineBin({ engine: 'cypress', backendRoot, suiteRoot });
|
|
287
|
+
if (!cypressBin || !fs.existsSync(cypressBin)) fail('找不到 Cypress 可执行文件,请设置 KEVE_CYPRESS_BIN');
|
|
288
|
+
// cypress open 阻塞到窗口关闭才返回,和线上无头跑一致地走同一个 CLI 可执行文件。
|
|
289
|
+
spawnSync(cypressBin, [
|
|
290
|
+
'open',
|
|
291
|
+
'--project', taskRoot,
|
|
292
|
+
'--config-file', configPath,
|
|
293
|
+
], {
|
|
294
|
+
cwd: taskRoot,
|
|
295
|
+
stdio: 'inherit',
|
|
296
|
+
env: { ...execEnv, ELECTRON_RUN_AS_NODE: '' },
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
336
300
|
/** reports/latest 软链指向最新轮次,便于 backend 按任务读取最近一次报告 */
|
|
337
301
|
function updateLatestLink(reportsDir, resultDir) {
|
|
338
302
|
const latestDir = path.join(reportsDir, 'latest');
|
|
@@ -389,25 +353,6 @@ function writeManifest({ manifestFile, taskRoot, taskId, engine, casePath, round
|
|
|
389
353
|
return manifest;
|
|
390
354
|
}
|
|
391
355
|
|
|
392
|
-
/** 供报告补全步骤文案的最小用例清单 */
|
|
393
|
-
function buildCasesYaml(caseEntries) {
|
|
394
|
-
const lines = ['modules:', ' - name: plan', ' cases:'];
|
|
395
|
-
for (const entry of caseEntries) {
|
|
396
|
-
lines.push(` - id: ${entry.caseId}`);
|
|
397
|
-
lines.push(` title: ${JSON.stringify(entry.caseName)}`);
|
|
398
|
-
if (Array.isArray(entry.steps) && entry.steps.length) {
|
|
399
|
-
lines.push(' steps:');
|
|
400
|
-
for (const step of entry.steps) {
|
|
401
|
-
lines.push(` - step: ${JSON.stringify(step.step || '')}`);
|
|
402
|
-
lines.push(` expected: ${JSON.stringify(step.expected || '')}`);
|
|
403
|
-
}
|
|
404
|
-
} else {
|
|
405
|
-
lines.push(' steps: []');
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
return lines.join('\n') + '\n';
|
|
409
|
-
}
|
|
410
|
-
|
|
411
356
|
function readReportData(resultDir) {
|
|
412
357
|
const file = path.join(resultDir, 'report-data.json');
|
|
413
358
|
if (!fs.existsSync(file)) return null;
|
|
@@ -550,15 +495,17 @@ function rewriteOpenPageTargets(input, cases) {
|
|
|
550
495
|
|
|
551
496
|
/**
|
|
552
497
|
* 逐条统计 caseResults 状态。
|
|
553
|
-
*
|
|
554
|
-
*
|
|
498
|
+
*
|
|
499
|
+
* 只有显式 `skipped` 才算跳过;`missing` / `timedOut` / `unknown` / `error`
|
|
500
|
+
* 都代表用例没有得到有效执行结果或执行异常,必须计为失败。历史实现把它们
|
|
501
|
+
* 统一算成 skipped,导致 Cypress 已报 `1 failed` 时平台仍展示 0 失败。
|
|
555
502
|
*/
|
|
556
503
|
function summarizeReport(reportData) {
|
|
557
504
|
const cases = Array.isArray(reportData?.caseResults) ? reportData.caseResults : [];
|
|
558
505
|
if (cases.length) {
|
|
559
506
|
const passed = cases.filter((item) => item?.status === 'passed').length;
|
|
560
|
-
const
|
|
561
|
-
const
|
|
507
|
+
const skipped = cases.filter((item) => item?.status === 'skipped').length;
|
|
508
|
+
const failed = cases.length - passed - skipped;
|
|
562
509
|
return { passed, failed, skipped, total: cases.length };
|
|
563
510
|
}
|
|
564
511
|
const summary = reportData?.summary?.summary || reportData?.summary || {};
|
|
@@ -576,12 +523,12 @@ function summarizeReport(reportData) {
|
|
|
576
523
|
function runPlaywright({ input, taskDir, specDir, resultDir, round }) {
|
|
577
524
|
const configPath = path.join(runtimeDir, 'playwright.config.mjs');
|
|
578
525
|
const backendRoot = input.backendRoot || defaultBackendRoot;
|
|
579
|
-
const cli =
|
|
526
|
+
const cli = resolveEngineBin({ engine: 'playwright', backendRoot, suiteRoot });
|
|
580
527
|
if (!cli || !fs.existsSync(cli)) fail('找不到 Playwright 可执行文件,请设置 KEVE_PLAYWRIGHT_BIN');
|
|
581
528
|
const result = spawnSync(cli, ['test', '--config', configPath], {
|
|
582
529
|
cwd: taskDir,
|
|
583
530
|
stdio: 'inherit',
|
|
584
|
-
env:
|
|
531
|
+
env: buildExecEnvFromSuite({ input, taskDir, round, engine: 'playwright', defaultBackendRoot, suiteRoot }),
|
|
585
532
|
});
|
|
586
533
|
if (result.error) fail(`Playwright 启动失败: ${result.error.message}`);
|
|
587
534
|
return result.status ?? 1;
|
|
@@ -594,7 +541,7 @@ function useCypressConfig({ taskDir, specDir, resultDir }) {
|
|
|
594
541
|
function runCypress({ input, taskDir, specDir, resultDir, round }) {
|
|
595
542
|
const configPath = useCypressConfig({ taskDir, specDir, resultDir });
|
|
596
543
|
const backendRoot = input.backendRoot || defaultBackendRoot;
|
|
597
|
-
const cypressBin =
|
|
544
|
+
const cypressBin = resolveEngineBin({ engine: 'cypress', backendRoot, suiteRoot });
|
|
598
545
|
if (!cypressBin || !fs.existsSync(cypressBin)) {
|
|
599
546
|
fail('找不到 Cypress 可执行文件,请设置 KEVE_CYPRESS_BIN');
|
|
600
547
|
}
|
|
@@ -607,7 +554,7 @@ function runCypress({ input, taskDir, specDir, resultDir, round }) {
|
|
|
607
554
|
cwd: taskDir,
|
|
608
555
|
stdio: 'inherit',
|
|
609
556
|
env: {
|
|
610
|
-
...
|
|
557
|
+
...buildExecEnvFromSuite({ input, taskDir, round, engine: 'cypress', defaultBackendRoot, suiteRoot }),
|
|
611
558
|
// Cypress 内部会 fork Electron;清空该变量避免以纯 Node 模式启动
|
|
612
559
|
ELECTRON_RUN_AS_NODE: '',
|
|
613
560
|
},
|
|
@@ -616,31 +563,6 @@ function runCypress({ input, taskDir, specDir, resultDir, round }) {
|
|
|
616
563
|
return result.status ?? 1;
|
|
617
564
|
}
|
|
618
565
|
|
|
619
|
-
/** 两个引擎共用同一套环境变量契约 */
|
|
620
|
-
function buildExecEnv({ input, taskDir, round, engine }) {
|
|
621
|
-
const backendRoot = input.backendRoot || defaultBackendRoot;
|
|
622
|
-
return {
|
|
623
|
-
...process.env,
|
|
624
|
-
KEVE_TASK_DIR: taskDir,
|
|
625
|
-
KEVE_RESULT_DIR: path.join(taskDir, 'reports', `round-${round}`),
|
|
626
|
-
KEVE_CASES_DIR: path.join(taskDir, 'cases'),
|
|
627
|
-
// 媒体相对路径以任务根为基准,保证 report-data.json 里不出现 `..`
|
|
628
|
-
KEVE_ARTIFACT_BASE: taskDir,
|
|
629
|
-
KEVE_ROUND: round,
|
|
630
|
-
KEVE_ENV: input.env || 'test',
|
|
631
|
-
KEVE_TARGET_URL: input.targetUrl || '',
|
|
632
|
-
KEVE_STORAGE_STATE: path.join(taskDir, '.auth', 'storage-state.json'),
|
|
633
|
-
KEVE_SSO_COOKIES: input.ssoCookiesJson || '[]',
|
|
634
|
-
KEVE_IDENTITY_SSO_COOKIES: JSON.stringify(input.identitySsoCookies || {}),
|
|
635
|
-
KEVE_LLM_BASE_URL: input.llm?.baseUrl || process.env.KEVE_LLM_BASE_URL || '',
|
|
636
|
-
KEVE_LLM_MODEL_NAME: input.llm?.model || process.env.KEVE_LLM_MODEL_NAME || '',
|
|
637
|
-
KEVE_LLM_API_KEY: input.llm?.apiKey || process.env.KEVE_LLM_API_KEY || '',
|
|
638
|
-
KEVE_ENGINE: engine,
|
|
639
|
-
// Cypress 预处理器位于 suite 静态配置目录,esbuild 需由 backend 提供
|
|
640
|
-
KEVE_ESBUILD_MODULE: resolveEsbuildModule(backendRoot),
|
|
641
|
-
};
|
|
642
|
-
}
|
|
643
|
-
|
|
644
566
|
export {
|
|
645
567
|
deriveTargetUrl,
|
|
646
568
|
isLocalPlaceholderUrl,
|
|
@@ -17,6 +17,8 @@ const resultDir = process.env.KEVE_RESULT_DIR || path.join(taskDir, 'reports', '
|
|
|
17
17
|
const targetUrl = process.env.KEVE_TARGET_URL || '';
|
|
18
18
|
|
|
19
19
|
export default {
|
|
20
|
+
viewportWidth: 2560,
|
|
21
|
+
viewportHeight: 1440,
|
|
20
22
|
e2e: {
|
|
21
23
|
...(targetUrl ? { baseUrl: targetUrl } : {}),
|
|
22
24
|
specPattern: 'specs/**/*.spec.ts',
|