@kkeve/suite 0.0.21 → 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.
@@ -203,7 +203,12 @@ function renderStep(d, ctx) {
203
203
  break;
204
204
  case 'DRAGGING': {
205
205
  // 拖拽:context 为源,value/child 携带目标链;缺失目标不得伪装成悬停成功
206
- const valueCtx = (typeof d.value === 'object' && d.value !== null ? d.value.context : undefined);
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)]);
@@ -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.21",
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
+ }
@@ -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
- const { toDecorator, stepExpectedText } = await import(
225
- url.pathToFileURL(path.join(suiteRoot, 'dist/dsl/index.js')).href
226
- );
227
-
228
- // ── 逐条用例投影成装饰器脚本 ──
229
- const caseEntries = [];
230
- for (const item of cases) {
231
- const stepGroupId = Number(item.stepGroupId);
232
- if (!Number.isFinite(stepGroupId)) fail(`用例缺少合法 stepGroupId: ${JSON.stringify(item.stepGroupId)}`);
233
- const rawList = Array.isArray(item.dslList) ? item.dslList : [];
234
- if (!rawList.length) fail(`用例 ${stepGroupId} 的 dslList 为空,拒绝生成空脚本`);
235
-
236
- const dslList = rawList.map(normalizeStep);
237
- const importFrom = engine === 'cypress'
238
- ? '@kkeve/suite/engine-cypress'
239
- : '@kkeve/suite/keve-test';
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 = resolvePlaywrightBin(backendRoot);
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: buildExecEnv({ input, taskDir, round, engine: 'playwright' }),
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 = resolveCypressBin(backendRoot);
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
- ...buildExecEnv({ input, taskDir, round, engine: 'cypress' }),
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,
@@ -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',
@@ -32,6 +32,7 @@ export default defineConfig({
32
32
  use: {
33
33
  baseURL: process.env.KEVE_TARGET_URL || '',
34
34
  storageState: process.env.KEVE_STORAGE_STATE,
35
+ viewport: { width: 2560, height: 1440 },
35
36
  trace: 'off',
36
37
  screenshot: 'off',
37
38
  video: 'on',