@kkeve/suite 0.0.15 → 0.0.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/keve.js +2 -1
- package/dist/commands/clean.js +2 -2
- package/dist/commands/config.js +7 -7
- package/dist/commands/init.js +2 -2
- package/dist/commands/report.js +7 -7
- package/dist/commands/run.js +12 -11
- package/dist/commands/test.js +7 -7
- package/dist/config.js +7 -7
- package/dist/core/action-log-writer.js +4 -1
- package/dist/core/decorator/keve-decorators.d.ts +128 -0
- package/dist/core/decorator/keve-decorators.js +254 -0
- package/dist/core/decorator/keve-host.d.ts +40 -0
- package/dist/core/decorator/keve-host.js +33 -0
- package/dist/core/{keve-registry.d.ts → decorator/keve-registry.d.ts} +19 -0
- package/dist/core/{keve-registry.js → decorator/keve-registry.js} +13 -1
- package/dist/core/generateReport/report.d.ts +1 -1
- package/dist/core/generateReport/report.js +2 -2
- package/dist/core/generateReport/reportData.d.ts +3 -0
- package/dist/core/generateReport/reportData.js +49 -15
- package/dist/core/judge/confidence.d.ts +119 -0
- package/dist/core/judge/confidence.js +295 -0
- package/dist/core/judge/goal-heuristics.d.ts +32 -0
- package/dist/core/judge/goal-heuristics.js +128 -0
- package/dist/core/judge/pngCodec.d.ts +23 -0
- package/dist/core/judge/pngCodec.js +215 -0
- package/dist/core/judge/screenshotDiff.d.ts +112 -0
- package/dist/core/judge/screenshotDiff.js +420 -0
- package/dist/core/page-object/definePage.d.ts +95 -0
- package/dist/core/page-object/definePage.js +62 -0
- package/dist/core/page-object/elementResolver.d.ts +32 -0
- package/dist/core/page-object/elementResolver.js +236 -0
- package/dist/core/page-object/engine-types.d.ts +140 -0
- package/dist/core/page-object/engine-types.js +34 -0
- package/dist/core/sso-cookie.d.ts +18 -0
- package/dist/core/sso-cookie.js +10 -0
- package/dist/dsl/index.d.ts +15 -0
- package/dist/dsl/index.js +29 -0
- package/dist/dsl/locatorSource.d.ts +25 -0
- package/dist/dsl/locatorSource.js +323 -0
- package/dist/dsl/ops.d.ts +46 -0
- package/dist/dsl/ops.js +95 -0
- package/dist/dsl/toDecorator.d.ts +98 -0
- package/dist/dsl/toDecorator.js +747 -0
- package/dist/engine-cypress/adapter.d.ts +108 -0
- package/dist/engine-cypress/adapter.js +514 -0
- package/dist/engine-cypress/agent-page.d.ts +159 -0
- package/dist/engine-cypress/agent-page.js +566 -0
- package/dist/engine-cypress/agentDom.d.ts +22 -0
- package/dist/engine-cypress/agentDom.js +590 -0
- package/dist/engine-cypress/bridge.d.ts +39 -0
- package/dist/engine-cypress/bridge.js +598 -0
- package/dist/engine-cypress/cdp.d.ts +78 -0
- package/dist/engine-cypress/cdp.js +339 -0
- package/dist/engine-cypress/host.d.ts +22 -0
- package/dist/engine-cypress/host.js +480 -0
- package/dist/engine-cypress/index.d.ts +26 -0
- package/dist/engine-cypress/index.js +58 -0
- package/dist/engine-cypress/keveGoal.d.ts +43 -0
- package/dist/engine-cypress/keveGoal.js +265 -0
- package/dist/engine-cypress/pageOps.d.ts +41 -0
- package/dist/engine-cypress/pageOps.js +439 -0
- package/dist/engine-cypress/reporter.d.ts +20 -0
- package/dist/engine-cypress/reporter.js +128 -0
- package/dist/engine-cypress/setup.d.ts +21 -0
- package/dist/engine-cypress/setup.js +27 -0
- package/dist/engine-cypress/shotBuffer.d.ts +40 -0
- package/dist/engine-cypress/shotBuffer.js +41 -0
- package/dist/engine-cypress/state.d.ts +56 -0
- package/dist/engine-cypress/state.js +81 -0
- package/dist/engine-playwright/engineAdapter.d.ts +78 -0
- package/dist/engine-playwright/engineAdapter.js +371 -0
- package/dist/engine-playwright/global-setup.d.ts +28 -0
- package/dist/engine-playwright/global-setup.js +142 -0
- package/dist/{core → engine-playwright}/keve-report.d.ts +0 -1
- package/dist/engine-playwright/keve-report.js +137 -0
- package/dist/{core → engine-playwright}/keve-test.d.ts +10 -8
- package/dist/{core → engine-playwright}/keve-test.js +204 -200
- package/dist/{core → engine-playwright}/pw-config.js +8 -7
- package/dist/index.d.ts +13 -8
- package/dist/index.js +43 -28
- package/dist/page-agent/agent.d.ts +82 -7
- package/dist/page-agent/agent.js +252 -88
- package/dist/page-agent/diagnostics.d.ts +184 -0
- package/dist/page-agent/diagnostics.js +377 -0
- package/dist/page-agent/hooks.d.ts +3 -2
- package/dist/page-agent/hooks.js +4 -3
- package/dist/page-agent/page-like.d.ts +116 -0
- package/dist/page-agent/page-like.js +14 -0
- package/dist/page-agent/playwright-page.d.ts +20 -0
- package/dist/page-agent/playwright-page.js +98 -0
- package/dist/page-agent/system-prompt.d.ts +7 -0
- package/dist/page-agent/system-prompt.js +10 -0
- package/dist/page-agent/tools.d.ts +1 -1
- package/dist/page-agent/tools.js +144 -8
- package/package.json +24 -11
- package/scripts/gen-system-prompt.mjs +36 -0
- package/scripts/run-dsl-task.mjs +652 -0
- package/scripts/runtime/cypress-preprocessor.mjs +44 -0
- package/scripts/runtime/cypress.config.mjs +35 -0
- package/scripts/runtime/playwright.config.mjs +41 -0
- package/scripts/verify-shot-diff.mjs +243 -0
- package/dist/commands/auth-update.d.ts +0 -16
- package/dist/commands/auth-update.js +0 -88
- package/dist/core/auth.d.ts +0 -17
- package/dist/core/auth.js +0 -224
- package/dist/core/cdp-resolver.d.ts +0 -5
- package/dist/core/cdp-resolver.js +0 -93
- package/dist/core/global-setup.d.ts +0 -6
- package/dist/core/global-setup.js +0 -123
- package/dist/core/keve-decorators.d.ts +0 -51
- package/dist/core/keve-decorators.js +0 -95
- package/dist/core/keve-report.js +0 -376
- /package/dist/core/{keve-aspect.d.ts → decorator/keve-aspect.d.ts} +0 -0
- /package/dist/core/{keve-aspect.js → decorator/keve-aspect.js} +0 -0
- /package/dist/{core → engine-playwright}/pw-config.d.ts +0 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 把 page-agent 的 system_prompt.md 编译成浏览器安全的 TS 模块。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要:Cypress 引擎会把 page-agent 打进浏览器 bundle,运行时用
|
|
5
|
+
* `node:fs` 读 md 文件会让 esbuild 直接失败。这里在构建期把文本内联为
|
|
6
|
+
* 一个普通字符串常量,两个引擎共用同一份 prompt,避免出现两套文案漂移。
|
|
7
|
+
*
|
|
8
|
+
* 用法:node scripts/gen-system-prompt.mjs
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from 'node:fs';
|
|
11
|
+
import * as path from 'node:path';
|
|
12
|
+
import * as url from 'node:url';
|
|
13
|
+
|
|
14
|
+
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
15
|
+
const src = path.join(here, '..', 'src', 'page-agent', 'system_prompt.md');
|
|
16
|
+
const out = path.join(here, '..', 'src', 'page-agent', 'system-prompt.ts');
|
|
17
|
+
|
|
18
|
+
const text = fs.readFileSync(src, 'utf8');
|
|
19
|
+
|
|
20
|
+
fs.writeFileSync(
|
|
21
|
+
out,
|
|
22
|
+
`/**
|
|
23
|
+
* system-prompt — 由 scripts/gen-system-prompt.mjs 从 system_prompt.md 生成,请勿手改。
|
|
24
|
+
*
|
|
25
|
+
* 生成物是纯字符串常量,不含任何 Node 运行时依赖,因此可以被 Cypress 的浏览器
|
|
26
|
+
* bundle 安全地打包。修改 prompt 请改 system_prompt.md 后重新生成本文件。
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export const SYSTEM_PROMPT: string = ${JSON.stringify(text)};
|
|
30
|
+
`,
|
|
31
|
+
'utf8',
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
console.log(
|
|
35
|
+
`[gen-system-prompt] ${path.relative(process.cwd(), src)} → ${path.relative(process.cwd(), out)} (${text.length} chars)`,
|
|
36
|
+
);
|
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* run-dsl-task — 平台任务统一执行入口(Cypress / Playwright)
|
|
4
|
+
*
|
|
5
|
+
* 职责边界:
|
|
6
|
+
* - eve-backend 负责取数(stepGroup / stepDsl / element / variable / account)
|
|
7
|
+
* 并把已展开、已解析的 dslList 写进 execution-input.json;
|
|
8
|
+
* - 本 runner 只做「投影 → 落盘 → 执行 → 出报告」,不直连业务库。
|
|
9
|
+
*
|
|
10
|
+
* 两个引擎共用同一份 @kkeve/suite 装饰器脚本与同一份 report-data.json 生成逻辑,
|
|
11
|
+
* 因此 Cypress / Playwright 的报告结构完全一致。
|
|
12
|
+
*
|
|
13
|
+
* 用法:
|
|
14
|
+
* node run-dsl-task.mjs --input execution-input.json
|
|
15
|
+
*
|
|
16
|
+
* 输出:
|
|
17
|
+
* <taskRoot>/runs/execution-result.json(执行摘要,供 backend 回写任务终态)
|
|
18
|
+
* <taskRoot>/runs/manifest.json(任务产物索引,供 backend 单点读取)
|
|
19
|
+
* <taskRoot>/reports/round-<round>/report-data.json(前端报告)
|
|
20
|
+
*
|
|
21
|
+
* 任务目录结构(两个引擎完全一致):
|
|
22
|
+
* <taskRoot>/specs/ 脚本
|
|
23
|
+
* <taskRoot>/reports/round-<round>/ 报告 + 媒体(latest 软链指向最新轮次)
|
|
24
|
+
* <taskRoot>/runs/ 单次执行编排产物
|
|
25
|
+
* <taskRoot>/cases/test-cases.yaml 报告补全步骤文案的输入
|
|
26
|
+
*
|
|
27
|
+
* 引擎配置不再写入任务目录:统一使用 scripts/runtime/ 下的静态模板,
|
|
28
|
+
* 通过 KEVE_TASK_DIR / KEVE_RESULT_DIR 等环境变量注入任务差异。
|
|
29
|
+
*/
|
|
30
|
+
import * as fs from 'node:fs';
|
|
31
|
+
import * as path from 'node:path';
|
|
32
|
+
import * as url from 'node:url';
|
|
33
|
+
import { spawnSync } from 'node:child_process';
|
|
34
|
+
|
|
35
|
+
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
|
36
|
+
const suiteRoot = path.resolve(__dirname, '..');
|
|
37
|
+
const runtimeDir = path.join(__dirname, 'runtime');
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Cypress / esbuild / @playwright/test 由 eve-backend(平台执行侧)声明并安装。
|
|
41
|
+
* 这里按「环境变量显式指定 → eve-backend/node_modules → suite/node_modules」回退,
|
|
42
|
+
* 保证 runner 既能被 backend spawn,也能在 suite 本地调试。
|
|
43
|
+
*/
|
|
44
|
+
const defaultBackendRoot = path.resolve(suiteRoot, '..', '..', '..', 'eve-backend');
|
|
45
|
+
|
|
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
|
+
function parseArgs(argv) {
|
|
74
|
+
const args = {};
|
|
75
|
+
for (let i = 0; i < argv.length; i++) {
|
|
76
|
+
const key = argv[i];
|
|
77
|
+
if (!key?.startsWith('--')) continue;
|
|
78
|
+
const name = key.slice(2);
|
|
79
|
+
const next = argv[i + 1];
|
|
80
|
+
if (next === undefined || next.startsWith('--')) args[name] = true;
|
|
81
|
+
else { args[name] = next; i++; }
|
|
82
|
+
}
|
|
83
|
+
return args;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function fail(message) {
|
|
87
|
+
console.error(`[run-dsl-task] ${message}`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function readJson(file, label) {
|
|
92
|
+
if (!fs.existsSync(file)) fail(`${label} 不存在: ${file}`);
|
|
93
|
+
try {
|
|
94
|
+
return JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
95
|
+
} catch (err) {
|
|
96
|
+
return fail(`${label} 不是合法 JSON: ${err.message}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** DSL 字段在 DB 里可能是 JSON 字符串,投影前统一还原 */
|
|
101
|
+
function parseJsonValue(value) {
|
|
102
|
+
if (typeof value !== 'string') return value;
|
|
103
|
+
const trimmed = value.trim();
|
|
104
|
+
if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('[') && !trimmed.startsWith('"'))) {
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
try { return JSON.parse(trimmed); } catch { return value; }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 归一化 DSL 步骤,使其满足 toDecorator 的输入契约。
|
|
112
|
+
* 任务里存的 dslList 已经过 backend 的 traveseFn + handleElement,
|
|
113
|
+
* 这里只做「JSON 字符串还原」的兜底,不重复解析元素库。
|
|
114
|
+
*/
|
|
115
|
+
function normalizeStep(step) {
|
|
116
|
+
const expectations = parseJsonValue(step?.expectation) || [];
|
|
117
|
+
return {
|
|
118
|
+
...step,
|
|
119
|
+
value: parseJsonValue(step?.value),
|
|
120
|
+
context: parseJsonValue(step?.context) || [],
|
|
121
|
+
wait: parseJsonValue(step?.wait) || [],
|
|
122
|
+
expectation: (Array.isArray(expectations) ? expectations : []).map((item) => ({
|
|
123
|
+
...item,
|
|
124
|
+
value: parseJsonValue(item?.value),
|
|
125
|
+
context: parseJsonValue(item?.context) || [],
|
|
126
|
+
})),
|
|
127
|
+
child: parseJsonValue(step?.child),
|
|
128
|
+
children: parseJsonValue(step?.children),
|
|
129
|
+
stepIds: parseJsonValue(step?.stepIds),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 投影 DSL 步骤为报告 outline,语义与 toDecorator 的 keveGoal 参数保持一致。
|
|
135
|
+
* OPEN_PAGE 后紧跟的 WAIT_RESPONSE 会合并进同一个导航步骤,避免报告步骤数
|
|
136
|
+
* 与脚本实际 keveGoal 数不一致。
|
|
137
|
+
*/
|
|
138
|
+
function buildCaseSteps(dslList) {
|
|
139
|
+
const steps = [];
|
|
140
|
+
for (let i = 0; i < dslList.length; i++) {
|
|
141
|
+
const d = dslList[i];
|
|
142
|
+
const op = String(d?.operation || '');
|
|
143
|
+
if (op === 'OPEN_PAGE') {
|
|
144
|
+
let j = i + 1;
|
|
145
|
+
const gates = [];
|
|
146
|
+
while (j < dslList.length && String(dslList[j]?.operation || '') === 'WAIT_RESPONSE') {
|
|
147
|
+
gates.push(dslList[j++]);
|
|
148
|
+
}
|
|
149
|
+
if (gates.length) {
|
|
150
|
+
const gateTexts = gates.map((g) => String(g?.text || '')).filter(Boolean);
|
|
151
|
+
const gateExpects = gates
|
|
152
|
+
.flatMap((g) => (g?.expectation || []).map((e) => String(e?.text || '')))
|
|
153
|
+
.filter(Boolean);
|
|
154
|
+
steps.push({
|
|
155
|
+
step: String(d?.text || 'OPEN_PAGE'),
|
|
156
|
+
expected: [...gateTexts, ...gateExpects].filter(Boolean).join(';') || '打开目标页面并等待就绪响应',
|
|
157
|
+
});
|
|
158
|
+
i = j - 1;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const stepText = String(d?.text || op || '');
|
|
163
|
+
const expTexts = (d?.expectation || []).map((e) => String(e?.text || '')).filter(Boolean);
|
|
164
|
+
const expected = expTexts.length ? expTexts.join(';') : '操作完成,页面正常响应';
|
|
165
|
+
if (!stepText) continue;
|
|
166
|
+
steps.push({ step: stepText, expected });
|
|
167
|
+
}
|
|
168
|
+
return steps;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 从账号信息里提取 toDecorator 需要的占位账号(不下发密码到脚本正文以外的位置) */
|
|
172
|
+
function resolveAccount(accountInfo) {
|
|
173
|
+
const account = accountInfo?.accountInfo || accountInfo;
|
|
174
|
+
if (!account?.name) return undefined;
|
|
175
|
+
return { name: String(account.name), password: String(account.password || '') };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function main() {
|
|
179
|
+
const args = parseArgs(process.argv.slice(2));
|
|
180
|
+
if (!args.input) fail('缺少 --input <execution-input.json>');
|
|
181
|
+
|
|
182
|
+
const inputFile = path.resolve(args.input);
|
|
183
|
+
const input = readJson(inputFile, '执行输入');
|
|
184
|
+
|
|
185
|
+
const engine = input.engine === 'cypress' ? 'cypress' : 'playwright';
|
|
186
|
+
const taskId = String(input.taskId ?? 'unknown');
|
|
187
|
+
const round = String(input.round ?? 'latest');
|
|
188
|
+
// taskRoot 是任务级自包含目录:脚本、报告、执行元数据都在其下。
|
|
189
|
+
const taskRoot = path.resolve(
|
|
190
|
+
input.taskDir || path.join(input.backendRoot || defaultBackendRoot, 'e2e', `task-${taskId}`),
|
|
191
|
+
);
|
|
192
|
+
const backendRoot = input.backendRoot || defaultBackendRoot;
|
|
193
|
+
const specDir = path.join(taskRoot, 'specs');
|
|
194
|
+
const reportsDir = path.join(taskRoot, 'reports');
|
|
195
|
+
const runsDir = path.join(taskRoot, 'runs');
|
|
196
|
+
const resultDir = path.join(reportsDir, `round-${round}`);
|
|
197
|
+
const resultFile = path.join(runsDir, 'execution-result.json');
|
|
198
|
+
const manifestFile = path.join(runsDir, 'manifest.json');
|
|
199
|
+
|
|
200
|
+
const cases = Array.isArray(input.cases) ? input.cases : [];
|
|
201
|
+
if (!cases.length) fail('执行输入里没有 cases,拒绝空跑');
|
|
202
|
+
|
|
203
|
+
// Cypress host 需要一个 baseUrl 兜底(Playwright 侧同理)。
|
|
204
|
+
// 优先使用 DSL 的真实 OPEN_PAGE 地址,避免环境映射覆盖用例目标。
|
|
205
|
+
input.targetUrl = deriveTargetUrl(input, cases);
|
|
206
|
+
// DSL 步骤里的本地地址(localhost / 127.0.0.1)会跟着用户手工用例原样落库,
|
|
207
|
+
// 在远端执行时不可达;当调度入参带真实目标地址时,用目标地址的 host/port 替换。
|
|
208
|
+
rewriteOpenPageTargets(input, cases);
|
|
209
|
+
|
|
210
|
+
fs.rmSync(resultDir, { recursive: true, force: true });
|
|
211
|
+
fs.rmSync(specDir, { recursive: true, force: true });
|
|
212
|
+
fs.mkdirSync(specDir, { recursive: true });
|
|
213
|
+
fs.mkdirSync(resultDir, { recursive: true });
|
|
214
|
+
fs.mkdirSync(runsDir, { recursive: true });
|
|
215
|
+
// 上一次执行可能在报告收口前被外部重启打断,旧 result/manifest 会
|
|
216
|
+
// 让 backend 误以为是本次结果;开始前必须清掉,只认本次新写出的文件。
|
|
217
|
+
fs.rmSync(resultFile, { force: true });
|
|
218
|
+
fs.rmSync(manifestFile, { force: true });
|
|
219
|
+
|
|
220
|
+
const { toDecorator } = await import(
|
|
221
|
+
url.pathToFileURL(path.join(suiteRoot, 'dist/dsl/index.js')).href
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
// ── 逐条用例投影成装饰器脚本 ──
|
|
225
|
+
const caseEntries = [];
|
|
226
|
+
for (const item of cases) {
|
|
227
|
+
const stepGroupId = Number(item.stepGroupId);
|
|
228
|
+
if (!Number.isFinite(stepGroupId)) fail(`用例缺少合法 stepGroupId: ${JSON.stringify(item.stepGroupId)}`);
|
|
229
|
+
const rawList = Array.isArray(item.dslList) ? item.dslList : [];
|
|
230
|
+
if (!rawList.length) fail(`用例 ${stepGroupId} 的 dslList 为空,拒绝生成空脚本`);
|
|
231
|
+
|
|
232
|
+
const dslList = rawList.map(normalizeStep);
|
|
233
|
+
const importFrom = engine === 'cypress'
|
|
234
|
+
? '@kkeve/suite/engine-cypress'
|
|
235
|
+
: '@kkeve/suite/keve-test';
|
|
236
|
+
const code = toDecorator({
|
|
237
|
+
stepGroupId,
|
|
238
|
+
caseName: item.caseName || `用例 ${stepGroupId}`,
|
|
239
|
+
dslList,
|
|
240
|
+
env: input.env || 'test',
|
|
241
|
+
variableList: Array.isArray(item.variableList) ? item.variableList : [],
|
|
242
|
+
account: resolveAccount(item.accountInfo || input.accountInfo),
|
|
243
|
+
ssoInject: input.ssoInject !== false,
|
|
244
|
+
importFrom,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const fileName = `sg${stepGroupId}.spec.ts`;
|
|
248
|
+
fs.writeFileSync(path.join(specDir, fileName), code, 'utf-8');
|
|
249
|
+
const dslSteps = buildCaseSteps(dslList);
|
|
250
|
+
caseEntries.push({
|
|
251
|
+
caseId: `S${stepGroupId}`,
|
|
252
|
+
stepGroupId,
|
|
253
|
+
caseName: item.caseName || `用例 ${stepGroupId}`,
|
|
254
|
+
specFile: fileName,
|
|
255
|
+
stepCount: dslList.length,
|
|
256
|
+
steps: dslSteps,
|
|
257
|
+
});
|
|
258
|
+
console.log(`[run-dsl-task] 生成脚本 ${fileName}(${dslList.length} 步)`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── 用例定义写入 cases/test-cases.yaml,让报告能补全步骤文案 ──
|
|
262
|
+
const casesDir = path.join(taskRoot, 'cases');
|
|
263
|
+
fs.mkdirSync(casesDir, { recursive: true });
|
|
264
|
+
fs.writeFileSync(
|
|
265
|
+
path.join(casesDir, 'test-cases.yaml'),
|
|
266
|
+
buildCasesYaml(caseEntries),
|
|
267
|
+
'utf-8',
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
// ── 执行 ──
|
|
271
|
+
const runner = engine === 'cypress' ? runCypress : runPlaywright;
|
|
272
|
+
const exitCode = runner({ input, taskDir: taskRoot, specDir, resultDir, round });
|
|
273
|
+
|
|
274
|
+
// ── 汇总结果 ──
|
|
275
|
+
// report-data.json 的顶层 summary 只带 total,逐条状态在 caseResults 里,
|
|
276
|
+
// 因此以 caseResults 为准统计 passed/failed/skipped,summary 仅作兜底。
|
|
277
|
+
const reportData = readReportData(resultDir);
|
|
278
|
+
// 引擎退出码是 0 且报告缺失时,旧的实现会把 0 误判成成功。
|
|
279
|
+
// 报告是前端明细与任务终态的唯一事实来源,缺失时无论退出码如何都按失败处理。
|
|
280
|
+
if (!reportData) {
|
|
281
|
+
fail(`执行结束后 report-data.json 不存在: ${path.join(resultDir, 'report-data.json')}(exitCode=${exitCode})`);
|
|
282
|
+
}
|
|
283
|
+
const stats = summarizeReport(reportData);
|
|
284
|
+
if (stats.total === 0) {
|
|
285
|
+
fail(`report-data.json 没有可用用例结果(exitCode=${exitCode}),拒绝写成功结果`);
|
|
286
|
+
}
|
|
287
|
+
const passed = stats.passed;
|
|
288
|
+
const failed = stats.failed;
|
|
289
|
+
const skipped = stats.skipped;
|
|
290
|
+
|
|
291
|
+
// ── 报告轮次索引:latest 软链 + manifest ──
|
|
292
|
+
updateLatestLink(reportsDir, resultDir);
|
|
293
|
+
writeManifest({
|
|
294
|
+
manifestFile,
|
|
295
|
+
taskRoot,
|
|
296
|
+
taskId,
|
|
297
|
+
engine,
|
|
298
|
+
casePath: input.casePath || '',
|
|
299
|
+
round,
|
|
300
|
+
reportDataPath: path.join(resultDir, 'report-data.json'),
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const result = {
|
|
304
|
+
ok: exitCode === 0 && failed === 0,
|
|
305
|
+
engine,
|
|
306
|
+
taskId,
|
|
307
|
+
exitCode,
|
|
308
|
+
passed,
|
|
309
|
+
failed,
|
|
310
|
+
skipped,
|
|
311
|
+
total: stats.total || passed + failed + skipped,
|
|
312
|
+
caseEntries,
|
|
313
|
+
taskDir: taskRoot,
|
|
314
|
+
taskRoot,
|
|
315
|
+
resultDir,
|
|
316
|
+
reportDataPath: path.join(resultDir, 'report-data.json'),
|
|
317
|
+
manifestPath: manifestFile,
|
|
318
|
+
specDir,
|
|
319
|
+
finishedAt: new Date().toISOString(),
|
|
320
|
+
};
|
|
321
|
+
fs.writeFileSync(resultFile, JSON.stringify(result, null, 2) + '\n', 'utf-8');
|
|
322
|
+
console.log(`[run-dsl-task] ${engine} 执行结束: passed=${passed} failed=${failed} skipped=${skipped}`);
|
|
323
|
+
process.exit(result.ok ? 0 : 1);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** reports/latest 软链指向最新轮次,便于 backend 按任务读取最近一次报告 */
|
|
327
|
+
function updateLatestLink(reportsDir, resultDir) {
|
|
328
|
+
const latestDir = path.join(reportsDir, 'latest');
|
|
329
|
+
if (path.resolve(latestDir) === path.resolve(resultDir)) return;
|
|
330
|
+
try {
|
|
331
|
+
if (fs.existsSync(latestDir)) {
|
|
332
|
+
const stat = fs.lstatSync(latestDir);
|
|
333
|
+
if (stat.isSymbolicLink()) fs.unlinkSync(latestDir);
|
|
334
|
+
else fs.rmSync(latestDir, { recursive: true, force: true });
|
|
335
|
+
}
|
|
336
|
+
fs.symlinkSync(path.basename(resultDir), latestDir, 'junction');
|
|
337
|
+
} catch (err) {
|
|
338
|
+
console.warn(`[run-dsl-task] 创建 latest 软链失败: ${err.message}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* 写任务产物索引。backend 侧只用这一份 manifest 定位脚本 / 报告 / 轮次,
|
|
344
|
+
* 不再各自拼接引擎相关的目录名。
|
|
345
|
+
*/
|
|
346
|
+
function writeManifest({ manifestFile, taskRoot, taskId, engine, casePath, round, reportDataPath }) {
|
|
347
|
+
let previous = {};
|
|
348
|
+
try {
|
|
349
|
+
if (fs.existsSync(manifestFile)) previous = JSON.parse(fs.readFileSync(manifestFile, 'utf-8'));
|
|
350
|
+
} catch { /* 索引损坏时重建 */ }
|
|
351
|
+
|
|
352
|
+
const rel = (target) => path.relative(taskRoot, target).split(path.sep).join('/');
|
|
353
|
+
const rounds = Array.isArray(previous.rounds) ? previous.rounds.filter(Boolean) : [];
|
|
354
|
+
const roundNumber = Number(round);
|
|
355
|
+
const entry = {
|
|
356
|
+
round: Number.isFinite(roundNumber) ? roundNumber : round,
|
|
357
|
+
resultDir: rel(path.dirname(reportDataPath)),
|
|
358
|
+
reportData: rel(reportDataPath),
|
|
359
|
+
updatedAt: new Date().toISOString(),
|
|
360
|
+
};
|
|
361
|
+
const existingIndex = rounds.findIndex((item) => String(item.round) === String(entry.round));
|
|
362
|
+
if (existingIndex >= 0) rounds[existingIndex] = entry;
|
|
363
|
+
else rounds.push(entry);
|
|
364
|
+
rounds.sort((a, b) => Number(a.round) - Number(b.round));
|
|
365
|
+
|
|
366
|
+
const manifest = {
|
|
367
|
+
taskId,
|
|
368
|
+
engine,
|
|
369
|
+
casePath,
|
|
370
|
+
taskRoot,
|
|
371
|
+
specDir: 'specs',
|
|
372
|
+
reportsDir: 'reports',
|
|
373
|
+
latestRound: entry.round,
|
|
374
|
+
rounds,
|
|
375
|
+
updatedAt: entry.updatedAt,
|
|
376
|
+
};
|
|
377
|
+
fs.mkdirSync(path.dirname(manifestFile), { recursive: true });
|
|
378
|
+
fs.writeFileSync(manifestFile, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
|
|
379
|
+
return manifest;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** 供报告补全步骤文案的最小用例清单 */
|
|
383
|
+
function buildCasesYaml(caseEntries) {
|
|
384
|
+
const lines = ['modules:', ' - name: plan', ' cases:'];
|
|
385
|
+
for (const entry of caseEntries) {
|
|
386
|
+
lines.push(` - id: ${entry.caseId}`);
|
|
387
|
+
lines.push(` title: ${JSON.stringify(entry.caseName)}`);
|
|
388
|
+
if (Array.isArray(entry.steps) && entry.steps.length) {
|
|
389
|
+
lines.push(' steps:');
|
|
390
|
+
for (const step of entry.steps) {
|
|
391
|
+
lines.push(` - step: ${JSON.stringify(step.step || '')}`);
|
|
392
|
+
lines.push(` expected: ${JSON.stringify(step.expected || '')}`);
|
|
393
|
+
}
|
|
394
|
+
} else {
|
|
395
|
+
lines.push(' steps: []');
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return lines.join('\n') + '\n';
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function readReportData(resultDir) {
|
|
402
|
+
const file = path.join(resultDir, 'report-data.json');
|
|
403
|
+
if (!fs.existsSync(file)) return null;
|
|
404
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf-8')); } catch { return null; }
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* 目标地址解析:优先使用用例 OPEN_PAGE 中的真实业务地址。
|
|
409
|
+
* 只有 DSL 里的地址是本机占位地址,或 DSL 没有可用地址时,才使用调度入参。
|
|
410
|
+
* 不做任何环境映射兜底——用例指定什么页面就访问什么页面,不私自替换为平台域名。
|
|
411
|
+
*
|
|
412
|
+
* 支持三种 OPEN_PAGE value 格式:
|
|
413
|
+
* 1. 字符串:直接用
|
|
414
|
+
* 2. { url } / { value }:取对应字段
|
|
415
|
+
* 3. { type: 'VARIABLE', value: varId, variable: [{key,value}] }:
|
|
416
|
+
* 从 variableList 查出对应环境的 URL,再应用 step.variable 改写
|
|
417
|
+
*/
|
|
418
|
+
function deriveTargetUrl(input, cases) {
|
|
419
|
+
const env = String(input?.env || 'test');
|
|
420
|
+
for (const item of cases) {
|
|
421
|
+
const variableList = Array.isArray(item.variableList) ? item.variableList : [];
|
|
422
|
+
for (const raw of Array.isArray(item?.dslList) ? item.dslList : []) {
|
|
423
|
+
const step = normalizeStep(raw);
|
|
424
|
+
if (String(step?.operation || '') !== 'OPEN_PAGE') continue;
|
|
425
|
+
const value = step.value;
|
|
426
|
+
let candidateUrl = '';
|
|
427
|
+
if (typeof value === 'string') {
|
|
428
|
+
candidateUrl = value.trim();
|
|
429
|
+
} else if (value && typeof value === 'object') {
|
|
430
|
+
if (value.type === 'VARIABLE' && variableList.length) {
|
|
431
|
+
// 变量库引用:按 id 查找变量行,取对应环境列
|
|
432
|
+
const varId = value.value;
|
|
433
|
+
const row = variableList.find((r) => String(r.id) === String(varId));
|
|
434
|
+
if (row) {
|
|
435
|
+
const cols = [env, 'test', 'online', 'rc', 'pre'];
|
|
436
|
+
const isUrl = (x) => typeof x === 'string' && /^https?:\/\//i.test(x.trim());
|
|
437
|
+
const isPlaceholder = (x) => /^(1|0|true|false)$/i.test(String(x ?? '').trim());
|
|
438
|
+
let base = '';
|
|
439
|
+
for (const c of cols) { if (isUrl(row[c])) { base = row[c]; break; } }
|
|
440
|
+
if (!base) {
|
|
441
|
+
for (const c of cols) {
|
|
442
|
+
const v = row[c];
|
|
443
|
+
if (typeof v === 'string' && v.trim() && !isPlaceholder(v)) { base = v; break; }
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
if (base) {
|
|
447
|
+
// 应用 step.variable 改写(与 buildVariableUrl 同逻辑)
|
|
448
|
+
const kv = new Map();
|
|
449
|
+
for (const item of row.variable || []) {
|
|
450
|
+
if (item && item.key !== undefined) kv.set(String(item.key), String(item.value ?? ''));
|
|
451
|
+
}
|
|
452
|
+
for (const item of value.variable || []) {
|
|
453
|
+
if (item && item.key !== undefined) kv.set(String(item.key), String(item.value ?? ''));
|
|
454
|
+
}
|
|
455
|
+
let url = String(base).replace(/\$\{([^}]+)}/g, (m, k) => kv.has(k) ? kv.get(k) : m);
|
|
456
|
+
for (const [k, v] of kv) {
|
|
457
|
+
if (!v || !/^[A-Za-z_][A-Za-z0-9_-]*$/.test(k)) continue;
|
|
458
|
+
const re = new RegExp(`([?&])${k}=[^&#]*`);
|
|
459
|
+
if (re.test(url)) url = url.replace(re, `$1${k}=${v}`);
|
|
460
|
+
else url += (url.includes('?') ? '&' : '?') + `${k}=${v}`;
|
|
461
|
+
}
|
|
462
|
+
candidateUrl = url.trim();
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
} else {
|
|
466
|
+
candidateUrl = String(value?.url || value?.value || '').trim();
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (!/^https?:\/\//i.test(candidateUrl)) continue;
|
|
470
|
+
if (isLocalPlaceholderUrl(candidateUrl)) continue;
|
|
471
|
+
return candidateUrl;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const explicit = String(input?.targetUrl || '').trim();
|
|
476
|
+
if (/^https?:\/\//i.test(explicit)) {
|
|
477
|
+
return explicit;
|
|
478
|
+
}
|
|
479
|
+
// 无法从用例和调度参数中取到有效地址,不做环境映射兜底,返回空串。
|
|
480
|
+
// cypress.config 中 baseUrl 留空时 Cypress 不会自行 visit,避免访问到平台自身域名。
|
|
481
|
+
return '';
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function isLocalPlaceholderUrl(value) {
|
|
485
|
+
try {
|
|
486
|
+
const host = new URL(value).hostname;
|
|
487
|
+
return /^(localhost|127\.0\.0\.1|0\.0\.0\.0)$/i.test(host);
|
|
488
|
+
} catch {
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* 把 DSL 中指向本机的 OPEN_PAGE 地址投影为真实执行目标地址。
|
|
495
|
+
*
|
|
496
|
+
* 仅当调度入参 targetUrl 已明确解析时生效;保留原 URL 的 path/search/hash,
|
|
497
|
+
* 避免破坏用例里「打开某页面 + dashboadId=xxx」这类带参数的导航。
|
|
498
|
+
*/
|
|
499
|
+
function rewriteOpenPageTargets(input, cases) {
|
|
500
|
+
const targetUrl = String(input?.targetUrl || '').trim();
|
|
501
|
+
if (!/^https?:\/\//i.test(targetUrl)) return;
|
|
502
|
+
|
|
503
|
+
let target;
|
|
504
|
+
try {
|
|
505
|
+
target = new URL(targetUrl);
|
|
506
|
+
} catch {
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const localHostRe = /^(localhost|127\.0\.0\.1|0\.0\.0\.0)(:\d+)?$/i;
|
|
511
|
+
const rewrite = (value) => {
|
|
512
|
+
if (typeof value !== 'string') return value;
|
|
513
|
+
const raw = value.trim();
|
|
514
|
+
if (!/^https?:\/\//i.test(raw)) return value;
|
|
515
|
+
let parsed;
|
|
516
|
+
try {
|
|
517
|
+
parsed = new URL(raw);
|
|
518
|
+
} catch {
|
|
519
|
+
return value;
|
|
520
|
+
}
|
|
521
|
+
if (!localHostRe.test(parsed.host)) return value;
|
|
522
|
+
parsed.protocol = target.protocol;
|
|
523
|
+
parsed.hostname = target.hostname;
|
|
524
|
+
parsed.port = target.port;
|
|
525
|
+
return parsed.toString();
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
for (const item of cases) {
|
|
529
|
+
if (!Array.isArray(item?.dslList)) continue;
|
|
530
|
+
for (const step of item.dslList) {
|
|
531
|
+
if (String(step?.operation || '') !== 'OPEN_PAGE') continue;
|
|
532
|
+
if (step.value && typeof step.value === 'object' && 'value' in step.value) {
|
|
533
|
+
step.value.value = rewrite(step.value.value);
|
|
534
|
+
} else {
|
|
535
|
+
step.value = rewrite(step.value);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* 逐条统计 caseResults 状态。
|
|
543
|
+
* 状态语义与 core/generateReport 保持一致:
|
|
544
|
+
* passed / failed 之外的一律按 skipped 计入,保证 total = passed + failed + skipped。
|
|
545
|
+
*/
|
|
546
|
+
function summarizeReport(reportData) {
|
|
547
|
+
const cases = Array.isArray(reportData?.caseResults) ? reportData.caseResults : [];
|
|
548
|
+
if (cases.length) {
|
|
549
|
+
const passed = cases.filter((item) => item?.status === 'passed').length;
|
|
550
|
+
const failed = cases.filter((item) => item?.status === 'failed').length;
|
|
551
|
+
const skipped = cases.length - passed - failed;
|
|
552
|
+
return { passed, failed, skipped, total: cases.length };
|
|
553
|
+
}
|
|
554
|
+
const summary = reportData?.summary?.summary || reportData?.summary || {};
|
|
555
|
+
const passed = Number(summary.passed || 0);
|
|
556
|
+
const failed = Number(summary.failed || 0);
|
|
557
|
+
const skipped = Number(summary.skipped || 0);
|
|
558
|
+
return {
|
|
559
|
+
passed,
|
|
560
|
+
failed,
|
|
561
|
+
skipped,
|
|
562
|
+
total: Number(summary.total || passed + failed + skipped),
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function runPlaywright({ input, taskDir, specDir, resultDir, round }) {
|
|
567
|
+
const configPath = path.join(runtimeDir, 'playwright.config.mjs');
|
|
568
|
+
const backendRoot = input.backendRoot || defaultBackendRoot;
|
|
569
|
+
const cli = resolvePlaywrightBin(backendRoot);
|
|
570
|
+
if (!cli || !fs.existsSync(cli)) fail('找不到 Playwright 可执行文件,请设置 KEVE_PLAYWRIGHT_BIN');
|
|
571
|
+
const result = spawnSync(cli, ['test', '--config', configPath], {
|
|
572
|
+
cwd: taskDir,
|
|
573
|
+
stdio: 'inherit',
|
|
574
|
+
env: buildExecEnv({ input, taskDir, round, engine: 'playwright' }),
|
|
575
|
+
});
|
|
576
|
+
if (result.error) fail(`Playwright 启动失败: ${result.error.message}`);
|
|
577
|
+
return result.status ?? 1;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function useCypressConfig({ taskDir, specDir, resultDir }) {
|
|
581
|
+
return path.join(runtimeDir, 'cypress.config.mjs');
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function runCypress({ input, taskDir, specDir, resultDir, round }) {
|
|
585
|
+
const configPath = useCypressConfig({ taskDir, specDir, resultDir });
|
|
586
|
+
const backendRoot = input.backendRoot || defaultBackendRoot;
|
|
587
|
+
const cypressBin = resolveCypressBin(backendRoot);
|
|
588
|
+
if (!cypressBin || !fs.existsSync(cypressBin)) {
|
|
589
|
+
fail('找不到 Cypress 可执行文件,请设置 KEVE_CYPRESS_BIN');
|
|
590
|
+
}
|
|
591
|
+
const result = spawnSync(cypressBin, [
|
|
592
|
+
'run',
|
|
593
|
+
'--project', taskDir,
|
|
594
|
+
'--config-file', configPath,
|
|
595
|
+
'--browser', process.env.KEVE_CYPRESS_BROWSER || 'electron',
|
|
596
|
+
], {
|
|
597
|
+
cwd: taskDir,
|
|
598
|
+
stdio: 'inherit',
|
|
599
|
+
env: {
|
|
600
|
+
...buildExecEnv({ input, taskDir, round, engine: 'cypress' }),
|
|
601
|
+
// Cypress 内部会 fork Electron;清空该变量避免以纯 Node 模式启动
|
|
602
|
+
ELECTRON_RUN_AS_NODE: '',
|
|
603
|
+
},
|
|
604
|
+
});
|
|
605
|
+
if (result.error) fail(`Cypress 启动失败: ${result.error.message}`);
|
|
606
|
+
return result.status ?? 1;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** 两个引擎共用同一套环境变量契约 */
|
|
610
|
+
function buildExecEnv({ input, taskDir, round, engine }) {
|
|
611
|
+
const backendRoot = input.backendRoot || defaultBackendRoot;
|
|
612
|
+
return {
|
|
613
|
+
...process.env,
|
|
614
|
+
KEVE_TASK_DIR: taskDir,
|
|
615
|
+
KEVE_RESULT_DIR: path.join(taskDir, 'reports', `round-${round}`),
|
|
616
|
+
KEVE_CASES_DIR: path.join(taskDir, 'cases'),
|
|
617
|
+
// 媒体相对路径以任务根为基准,保证 report-data.json 里不出现 `..`
|
|
618
|
+
KEVE_ARTIFACT_BASE: taskDir,
|
|
619
|
+
KEVE_ROUND: round,
|
|
620
|
+
KEVE_ENV: input.env || 'test',
|
|
621
|
+
KEVE_TARGET_URL: input.targetUrl || '',
|
|
622
|
+
KEVE_STORAGE_STATE: path.join(taskDir, '.auth', 'storage-state.json'),
|
|
623
|
+
KEVE_SSO_COOKIES: input.ssoCookiesJson || '[]',
|
|
624
|
+
KEVE_IDENTITY_SSO_COOKIES: JSON.stringify(input.identitySsoCookies || {}),
|
|
625
|
+
KEVE_LLM_BASE_URL: input.llm?.baseUrl || process.env.KEVE_LLM_BASE_URL || '',
|
|
626
|
+
KEVE_LLM_MODEL_NAME: input.llm?.model || process.env.KEVE_LLM_MODEL_NAME || '',
|
|
627
|
+
KEVE_LLM_API_KEY: input.llm?.apiKey || process.env.KEVE_LLM_API_KEY || '',
|
|
628
|
+
KEVE_ENGINE: engine,
|
|
629
|
+
// Cypress 预处理器位于 suite 静态配置目录,esbuild 需由 backend 提供
|
|
630
|
+
KEVE_ESBUILD_MODULE: resolveEsbuildModule(backendRoot),
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
export {
|
|
635
|
+
deriveTargetUrl,
|
|
636
|
+
isLocalPlaceholderUrl,
|
|
637
|
+
rewriteOpenPageTargets,
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
/** 入口判定:node_modules 软链会让 argv[1] 与 import.meta.url 不是同一路径,需按真实路径比较。 */
|
|
641
|
+
function isMainEntry() {
|
|
642
|
+
if (!process.argv[1]) return false;
|
|
643
|
+
try {
|
|
644
|
+
return fs.realpathSync(process.argv[1]) === fs.realpathSync(url.fileURLToPath(import.meta.url));
|
|
645
|
+
} catch {
|
|
646
|
+
return import.meta.url === url.pathToFileURL(path.resolve(process.argv[1])).href;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (isMainEntry()) {
|
|
651
|
+
main().catch((err) => fail(err?.stack || err?.message || String(err)));
|
|
652
|
+
}
|