@kkeve/suite 0.0.21 → 0.0.23
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/core/page-object/elementResolver.js +20 -4
- package/dist/core/page-object/engine-types.d.ts +3 -0
- package/dist/dsl/locatorSource.d.ts +1 -1
- package/dist/dsl/locatorSource.js +72 -20
- package/dist/dsl/toDecorator.d.ts +2 -0
- package/dist/dsl/toDecorator.js +318 -69
- package/dist/engine-cypress/adapter.d.ts +8 -0
- package/dist/engine-cypress/adapter.js +23 -3
- package/dist/engine-cypress/agent-page.js +1 -1
- package/dist/engine-cypress/agentDom.js +86 -6
- package/dist/engine-cypress/cdp.js +1 -1
- package/dist/engine-cypress/host.js +40 -4
- package/dist/engine-cypress/keveGoal.d.ts +9 -0
- package/dist/engine-cypress/keveGoal.js +20 -0
- package/dist/engine-cypress/pageOps.js +79 -3
- package/dist/engine-playwright/engineAdapter.d.ts +7 -0
- package/dist/engine-playwright/engineAdapter.js +10 -0
- package/dist/engine-playwright/keve-test.d.ts +8 -0
- package/dist/engine-playwright/keve-test.js +23 -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 +5 -1
- package/scripts/run-dsl-task.mjs +91 -171
- package/scripts/run-probe.mjs +35 -0
- package/scripts/runtime/cypress.config.mjs +2 -0
- package/scripts/runtime/playwright.config.mjs +1 -0
|
@@ -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.23",
|
|
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": [
|
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;
|
|
@@ -578,12 +523,12 @@ function summarizeReport(reportData) {
|
|
|
578
523
|
function runPlaywright({ input, taskDir, specDir, resultDir, round }) {
|
|
579
524
|
const configPath = path.join(runtimeDir, 'playwright.config.mjs');
|
|
580
525
|
const backendRoot = input.backendRoot || defaultBackendRoot;
|
|
581
|
-
const cli =
|
|
526
|
+
const cli = resolveEngineBin({ engine: 'playwright', backendRoot, suiteRoot });
|
|
582
527
|
if (!cli || !fs.existsSync(cli)) fail('找不到 Playwright 可执行文件,请设置 KEVE_PLAYWRIGHT_BIN');
|
|
583
528
|
const result = spawnSync(cli, ['test', '--config', configPath], {
|
|
584
529
|
cwd: taskDir,
|
|
585
530
|
stdio: 'inherit',
|
|
586
|
-
env:
|
|
531
|
+
env: buildExecEnvFromSuite({ input, taskDir, round, engine: 'playwright', defaultBackendRoot, suiteRoot }),
|
|
587
532
|
});
|
|
588
533
|
if (result.error) fail(`Playwright 启动失败: ${result.error.message}`);
|
|
589
534
|
return result.status ?? 1;
|
|
@@ -596,7 +541,7 @@ function useCypressConfig({ taskDir, specDir, resultDir }) {
|
|
|
596
541
|
function runCypress({ input, taskDir, specDir, resultDir, round }) {
|
|
597
542
|
const configPath = useCypressConfig({ taskDir, specDir, resultDir });
|
|
598
543
|
const backendRoot = input.backendRoot || defaultBackendRoot;
|
|
599
|
-
const cypressBin =
|
|
544
|
+
const cypressBin = resolveEngineBin({ engine: 'cypress', backendRoot, suiteRoot });
|
|
600
545
|
if (!cypressBin || !fs.existsSync(cypressBin)) {
|
|
601
546
|
fail('找不到 Cypress 可执行文件,请设置 KEVE_CYPRESS_BIN');
|
|
602
547
|
}
|
|
@@ -609,7 +554,7 @@ function runCypress({ input, taskDir, specDir, resultDir, round }) {
|
|
|
609
554
|
cwd: taskDir,
|
|
610
555
|
stdio: 'inherit',
|
|
611
556
|
env: {
|
|
612
|
-
...
|
|
557
|
+
...buildExecEnvFromSuite({ input, taskDir, round, engine: 'cypress', defaultBackendRoot, suiteRoot }),
|
|
613
558
|
// Cypress 内部会 fork Electron;清空该变量避免以纯 Node 模式启动
|
|
614
559
|
ELECTRON_RUN_AS_NODE: '',
|
|
615
560
|
},
|
|
@@ -618,31 +563,6 @@ function runCypress({ input, taskDir, specDir, resultDir, round }) {
|
|
|
618
563
|
return result.status ?? 1;
|
|
619
564
|
}
|
|
620
565
|
|
|
621
|
-
/** 两个引擎共用同一套环境变量契约 */
|
|
622
|
-
function buildExecEnv({ input, taskDir, round, engine }) {
|
|
623
|
-
const backendRoot = input.backendRoot || defaultBackendRoot;
|
|
624
|
-
return {
|
|
625
|
-
...process.env,
|
|
626
|
-
KEVE_TASK_DIR: taskDir,
|
|
627
|
-
KEVE_RESULT_DIR: path.join(taskDir, 'reports', `round-${round}`),
|
|
628
|
-
KEVE_CASES_DIR: path.join(taskDir, 'cases'),
|
|
629
|
-
// 媒体相对路径以任务根为基准,保证 report-data.json 里不出现 `..`
|
|
630
|
-
KEVE_ARTIFACT_BASE: taskDir,
|
|
631
|
-
KEVE_ROUND: round,
|
|
632
|
-
KEVE_ENV: input.env || 'test',
|
|
633
|
-
KEVE_TARGET_URL: input.targetUrl || '',
|
|
634
|
-
KEVE_STORAGE_STATE: path.join(taskDir, '.auth', 'storage-state.json'),
|
|
635
|
-
KEVE_SSO_COOKIES: input.ssoCookiesJson || '[]',
|
|
636
|
-
KEVE_IDENTITY_SSO_COOKIES: JSON.stringify(input.identitySsoCookies || {}),
|
|
637
|
-
KEVE_LLM_BASE_URL: input.llm?.baseUrl || process.env.KEVE_LLM_BASE_URL || '',
|
|
638
|
-
KEVE_LLM_MODEL_NAME: input.llm?.model || process.env.KEVE_LLM_MODEL_NAME || '',
|
|
639
|
-
KEVE_LLM_API_KEY: input.llm?.apiKey || process.env.KEVE_LLM_API_KEY || '',
|
|
640
|
-
KEVE_ENGINE: engine,
|
|
641
|
-
// Cypress 预处理器位于 suite 静态配置目录,esbuild 需由 backend 提供
|
|
642
|
-
KEVE_ESBUILD_MODULE: resolveEsbuildModule(backendRoot),
|
|
643
|
-
};
|
|
644
|
-
}
|
|
645
|
-
|
|
646
566
|
export {
|
|
647
567
|
deriveTargetUrl,
|
|
648
568
|
isLocalPlaceholderUrl,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import * as url from 'node:url';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { resolveEngineBin, buildExecEnv } from '../dist/runner/index.js';
|
|
7
|
+
|
|
8
|
+
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
|
9
|
+
const suiteRoot = path.resolve(__dirname, '..');
|
|
10
|
+
const runtimeDir = path.join(__dirname, 'runtime');
|
|
11
|
+
const defaultBackendRoot = path.resolve(suiteRoot, '..', '..', '..', 'eve-backend');
|
|
12
|
+
|
|
13
|
+
const inputFile = process.argv[2];
|
|
14
|
+
const specGlob = process.argv[3] || 'specs/__probe.spec.ts';
|
|
15
|
+
const round = process.argv[4] || 'probe';
|
|
16
|
+
const input = JSON.parse(fs.readFileSync(inputFile, 'utf-8'));
|
|
17
|
+
const taskDir = input.taskDir;
|
|
18
|
+
const backendRoot = input.backendRoot || defaultBackendRoot;
|
|
19
|
+
const resultDir = path.join(taskDir, 'reports', `round-${round}`);
|
|
20
|
+
fs.mkdirSync(resultDir, { recursive: true });
|
|
21
|
+
|
|
22
|
+
const env = buildExecEnv({
|
|
23
|
+
input, taskDir, round, engine: 'cypress', defaultBackendRoot, suiteRoot,
|
|
24
|
+
});
|
|
25
|
+
const cypressBin = resolveEngineBin({ engine: 'cypress', backendRoot, suiteRoot });
|
|
26
|
+
const configPath = path.join(runtimeDir, 'cypress.config.mjs');
|
|
27
|
+
const res = spawnSync(cypressBin, [
|
|
28
|
+
'run', '--project', taskDir, '--config-file', configPath,
|
|
29
|
+
'--browser', 'electron', '--spec', specGlob,
|
|
30
|
+
], {
|
|
31
|
+
cwd: taskDir,
|
|
32
|
+
stdio: 'inherit',
|
|
33
|
+
env: { ...env, ELECTRON_RUN_AS_NODE: '' },
|
|
34
|
+
});
|
|
35
|
+
process.exit(res.status ?? 1);
|
|
@@ -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',
|