android-midscene-automation 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +160 -0
  2. package/bin/android-midscene-automation.js +27 -0
  3. package/index.html +12 -0
  4. package/package.json +49 -0
  5. package/remote-agent/index.ts +206 -0
  6. package/server/appium-recorder/appium-runner.ts +427 -0
  7. package/server/appium-recorder/repository.ts +228 -0
  8. package/server/appium-recorder/routes.ts +219 -0
  9. package/server/config-store.ts +167 -0
  10. package/server/config.ts +130 -0
  11. package/server/device-locks/repository.ts +147 -0
  12. package/server/device-locks/service.ts +72 -0
  13. package/server/device-locks/types.ts +22 -0
  14. package/server/device-sessions/repository.ts +169 -0
  15. package/server/device-sessions/service.ts +59 -0
  16. package/server/device-sessions/types.ts +24 -0
  17. package/server/http-api.ts +1389 -0
  18. package/server/model-call-usage-importer.ts +108 -0
  19. package/server/model-tester.ts +104 -0
  20. package/server/model-usage-repository.ts +131 -0
  21. package/server/operations/repository.ts +218 -0
  22. package/server/operations/service.ts +84 -0
  23. package/server/operations/types.ts +27 -0
  24. package/server/paths.ts +27 -0
  25. package/server/remote-agents/protocol.ts +38 -0
  26. package/server/remote-agents/registry.ts +136 -0
  27. package/server/remote-agents/routes.ts +89 -0
  28. package/server/script-agent.ts +284 -0
  29. package/server/script-db.ts +281 -0
  30. package/server/script-runner.ts +551 -0
  31. package/server/storage/sqlite.ts +49 -0
  32. package/server/test-case-import/formatter.ts +28 -0
  33. package/server/test-case-import/parsers/excel.ts +69 -0
  34. package/server/test-case-import/parsers/txt.ts +11 -0
  35. package/server/test-case-import/parsers/word.ts +9 -0
  36. package/server/test-case-import/service.ts +58 -0
  37. package/server/test-case-import/text-normalizer.ts +98 -0
  38. package/server/test-case-import/types.ts +24 -0
  39. package/server/test-case-import/validator.ts +34 -0
  40. package/src/App.vue +1450 -0
  41. package/src/api.ts +290 -0
  42. package/src/appium-recorder/AppiumPage.vue +894 -0
  43. package/src/appium-recorder/api.ts +64 -0
  44. package/src/appium-recorder/components/ComponentTree.vue +44 -0
  45. package/src/appium-recorder/components/NodeDetail.vue +152 -0
  46. package/src/appium-recorder/components/RecordedSteps.vue +79 -0
  47. package/src/appium-recorder/tree.ts +129 -0
  48. package/src/appium-recorder/types.ts +88 -0
  49. package/src/assets/device-actions/back.svg +5 -0
  50. package/src/assets/device-actions/home.svg +3 -0
  51. package/src/assets/device-actions/power.svg +5 -0
  52. package/src/assets/device-actions/tasks.svg +3 -0
  53. package/src/assets/device-actions/volume-down.svg +3 -0
  54. package/src/assets/device-actions/volume-up.svg +3 -0
  55. package/src/components/config/ModelUsageChart.vue +188 -0
  56. package/src/components/device/DevicePreviewPanel.vue +266 -0
  57. package/src/components/generator/GeneratedCodePanel.vue +70 -0
  58. package/src/components/generator/TestCaseFileUpload.vue +97 -0
  59. package/src/config/midscene-model-presets.ts +75 -0
  60. package/src/config/prompt-example.ts +6 -0
  61. package/src/main.ts +7 -0
  62. package/src/pages/AiGeneratorPage.vue +90 -0
  63. package/src/pages/AutomationPage.vue +161 -0
  64. package/src/pages/ConfigPage.vue +273 -0
  65. package/src/pages/GeneratorPage.vue +97 -0
  66. package/src/pages/ManualStepsPage.vue +179 -0
  67. package/src/script-generator/codegen.ts +126 -0
  68. package/src/script-generator/index.ts +4 -0
  69. package/src/script-generator/presets.ts +37 -0
  70. package/src/script-generator/step-options.ts +52 -0
  71. package/src/script-generator/types.ts +27 -0
  72. package/src/style.css +1983 -0
  73. package/src/types.ts +157 -0
  74. package/src/vite-env.d.ts +1 -0
  75. package/tsconfig.app.json +8 -0
  76. package/tsconfig.json +11 -0
  77. package/tsconfig.node.json +16 -0
  78. package/vite.config.ts +28 -0
@@ -0,0 +1,551 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+ import { pathToFileURL } from 'node:url';
5
+ import ts from 'typescript';
6
+ import { appPath } from './paths';
7
+ import {
8
+ getScriptRecord,
9
+ removeScriptRecord,
10
+ updateScriptRecordCode,
11
+ upsertScriptRecord,
12
+ type ScriptStepRecord,
13
+ } from './script-db';
14
+
15
+ const STEP_EVENT_PREFIX = '__MIDSCENE_STEP_EVENT__';
16
+ const SCRIPT_TIMEOUT_MS = Number(process.env.MIDSCENE_SCRIPT_TIMEOUT_MS || 600_000);
17
+
18
+ function injectMidsceneEnv(code: string) {
19
+ const configModuleUrl = pathToFileURL(appPath('server', 'config.ts')).href;
20
+ const withImport = `import { applyMidsceneEnv } from ${JSON.stringify(configModuleUrl)};\n${code}`;
21
+ return withImport.replace('async function main() {', 'async function main() {\n applyMidsceneEnv();');
22
+ }
23
+
24
+ function toScriptFileName(scriptName: string) {
25
+ const normalized = scriptName.replace(/[^a-zA-Z0-9_-]+/g, '_') || 'generated-script';
26
+ return `${normalized}.ts`;
27
+ }
28
+
29
+ export function checkGeneratedScriptExists(input: { scriptName: string }) {
30
+ const outputDir = appPath('scripts-output');
31
+ const filePath = path.join(outputDir, toScriptFileName(input.scriptName));
32
+ return {
33
+ exists: fs.existsSync(filePath),
34
+ filePath,
35
+ };
36
+ }
37
+
38
+ export function saveGeneratedScript(input: {
39
+ code: string;
40
+ scriptName: string;
41
+ promptTitle?: string;
42
+ sourcePrompt?: string;
43
+ steps?: ScriptStepRecord[];
44
+ }) {
45
+ validateGeneratedScriptCode(input.code);
46
+
47
+ const outputDir = appPath('scripts-output');
48
+ fs.mkdirSync(outputDir, { recursive: true });
49
+
50
+ const filePath = path.join(outputDir, toScriptFileName(input.scriptName));
51
+ fs.writeFileSync(filePath, input.code, 'utf8');
52
+ const record = upsertScriptRecord({
53
+ name: input.scriptName,
54
+ promptTitle: input.promptTitle || input.scriptName,
55
+ sourcePrompt: input.sourcePrompt || '',
56
+ code: input.code,
57
+ filePath,
58
+ steps: input.steps || [],
59
+ });
60
+
61
+ return {
62
+ success: true,
63
+ filePath,
64
+ script: record,
65
+ };
66
+ }
67
+
68
+ export function validateGeneratedScriptCode(code: string) {
69
+ if (!code.trim()) {
70
+ throw new Error('代码不能为空');
71
+ }
72
+
73
+ const result = ts.transpileModule(code, {
74
+ fileName: 'script.ts',
75
+ reportDiagnostics: true,
76
+ compilerOptions: {
77
+ module: ts.ModuleKind.ESNext,
78
+ target: ts.ScriptTarget.ES2022,
79
+ moduleResolution: ts.ModuleResolutionKind.Node10,
80
+ esModuleInterop: true,
81
+ skipLibCheck: true,
82
+ },
83
+ });
84
+ const diagnostics = (result.diagnostics || []).filter((item) => item.category === ts.DiagnosticCategory.Error);
85
+
86
+ if (diagnostics.length) {
87
+ const message = diagnostics
88
+ .slice(0, 3)
89
+ .map((item) => ts.flattenDiagnosticMessageText(item.messageText, '\n'))
90
+ .join('\n');
91
+ throw new Error(`代码格式错误:${message}`);
92
+ }
93
+ }
94
+
95
+ export function updateGeneratedScriptCode(input: { id: string; code: string }) {
96
+ validateGeneratedScriptCode(input.code);
97
+
98
+ const record = getScriptRecord(input.id);
99
+ if (!record) {
100
+ throw new Error('脚本不存在或已被删除');
101
+ }
102
+
103
+ const filePath = path.resolve(record.filePath);
104
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
105
+ fs.writeFileSync(filePath, input.code, 'utf8');
106
+
107
+ const script = updateScriptRecordCode({
108
+ id: input.id,
109
+ code: input.code,
110
+ filePath,
111
+ });
112
+
113
+ if (!script) {
114
+ throw new Error('脚本保存失败');
115
+ }
116
+
117
+ return {
118
+ success: true,
119
+ script,
120
+ };
121
+ }
122
+
123
+ export function deleteGeneratedScript(input: { id: string }) {
124
+ const record = removeScriptRecord(input.id);
125
+ if (!record) {
126
+ return {
127
+ success: false,
128
+ deletedFile: false,
129
+ };
130
+ }
131
+
132
+ const outputDir = appPath('scripts-output');
133
+ const filePath = path.resolve(record.filePath);
134
+ const canDeleteFile = filePath.startsWith(`${outputDir}${path.sep}`) && fs.existsSync(filePath);
135
+ if (canDeleteFile) {
136
+ fs.unlinkSync(filePath);
137
+ }
138
+
139
+ return {
140
+ success: true,
141
+ deletedFile: canDeleteFile,
142
+ script: record,
143
+ };
144
+ }
145
+
146
+ function safeVar(value: string | undefined, fallback: string) {
147
+ const normalized = (value || '').trim().replace(/[^a-zA-Z0-9_$]+/g, '_').replace(/^(\d)/, '_$1');
148
+ return normalized || fallback;
149
+ }
150
+
151
+ function escapeTemplate(value: string) {
152
+ return value.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
153
+ }
154
+
155
+ function serializeStep(step: ScriptStepRecord, index: number) {
156
+ return JSON.stringify({
157
+ index,
158
+ id: step.id || String(index),
159
+ title: step.label || `步骤 ${index + 1}`,
160
+ method: step.type,
161
+ prompt: step.prompt || step.value || '',
162
+ });
163
+ }
164
+
165
+ function getRepeatCount(step: ScriptStepRecord) {
166
+ const repeat = Number(step.repeat || 1);
167
+ if (!Number.isFinite(repeat)) return 1;
168
+ return Math.max(1, Math.min(10, Math.floor(repeat)));
169
+ }
170
+
171
+ function normalizeRuntimeSteps(steps: ScriptStepRecord[]) {
172
+ const normalized = steps.map((step) => {
173
+ const label = step.label || '';
174
+ const prompt = step.prompt || '';
175
+ const stepText = `${label} ${prompt}`;
176
+ const isStartupPopupHandling = /(处理|关闭|循环处理).*(启动)?弹窗|启动阶段.*弹窗/.test(label);
177
+ const isSoftKeyboardLoginHint = /(软键盘|键盘遮挡)/.test(prompt) && /(登录按钮|提交登录|提交当前已填写)/.test(prompt);
178
+ const isConditionalNoopAction =
179
+ !isSoftKeyboardLoginHint &&
180
+ /(如果|若|如有|如果当前|如果.*未勾选|已勾选|不存在.*跳过|则跳过|保持当前|否则)/.test(prompt);
181
+ const isConditionalNoopTap =
182
+ step.type === 'tap' &&
183
+ isConditionalNoopAction;
184
+ const isLoginOneShotAction =
185
+ (step.type === 'act' || step.type === 'tap') &&
186
+ !isStartupPopupHandling &&
187
+ !isConditionalNoopAction &&
188
+ (/(登录按钮|提交登录|登录提交|账号密码登录入口|登录协议|协议勾选|勾选.*协议|复选框|同意协议弹窗|协议确认弹窗)/.test(label) ||
189
+ /^(点击登录页中的登录按钮|登录页中的登录按钮|登录页底部.*勾选框|.*协议.*确认.*按钮|.*同意.*按钮)/.test(prompt));
190
+ const isPopupHandlingAct =
191
+ (step.type === 'act' || isConditionalNoopTap) &&
192
+ (isStartupPopupHandling ||
193
+ (!/(登录按钮|提交登录|登录提交|登录协议|协议勾选|勾选.*协议|复选框)/.test(stepText) &&
194
+ /弹窗|权限|协议|隐私|广告|活动|更新|通知|声明与条款/.test(stepText)));
195
+
196
+ return {
197
+ ...step,
198
+ type: step.type === 'act' && isLoginOneShotAction ? 'tap' : isConditionalNoopTap ? 'act' : step.type,
199
+ repeat: isLoginOneShotAction ? 1 : step.repeat || (isPopupHandlingAct ? 2 : 1),
200
+ };
201
+ });
202
+
203
+ return normalized.flatMap((step, index) => {
204
+ const label = step.label || '';
205
+ const followingSteps = normalized.slice(index + 1, index + 3);
206
+ const shouldInsertResubmit =
207
+ /(确认协议弹窗|同意协议弹窗|协议确认弹窗)/.test(label) &&
208
+ followingSteps.some((item) => item.type === 'waitFor' && /(首页|主界面|登录成功)/.test(`${item.label || ''} ${item.prompt || ''}`)) &&
209
+ !followingSteps.some((item) => /(再次提交登录|重新提交登录|再次点击登录)/.test(item.label || ''));
210
+
211
+ if (!shouldInsertResubmit) {
212
+ return [step];
213
+ }
214
+
215
+ return [
216
+ step,
217
+ {
218
+ id: `${step.id || index}-resubmit-login`,
219
+ type: 'act',
220
+ label: '确认协议后再次提交登录',
221
+ prompt:
222
+ '如果当前仍停留在登录页且账号密码已填写、登录协议已同意,点击登录按钮再次提交;如果已经进入首页或正在加载首页,保持当前状态。不要修改账号、密码或其他输入框。',
223
+ repeat: 1,
224
+ enabled: step.enabled,
225
+ },
226
+ ];
227
+ });
228
+ }
229
+
230
+ function withRepeat(step: ScriptStepRecord, body: string) {
231
+ const repeat = getRepeatCount(step);
232
+ if (repeat <= 1) return body;
233
+
234
+ const inner = body
235
+ .split('\n')
236
+ .map((line) => ` ${line.replace(/^ /, '')}`)
237
+ .join('\n');
238
+
239
+ return ` for (let attempt = 1; attempt <= ${repeat}; attempt += 1) {
240
+ console.log(${JSON.stringify(`执行${step.label || step.type}`)}, attempt);
241
+ ${inner}
242
+ await new Promise((resolve) => setTimeout(resolve, 1000));
243
+ }`;
244
+ }
245
+
246
+ // 对动作期间的短暂 UI 进行多帧采样;finally 确保动作失败时也会释放观察器。
247
+ function withObservation(step: ScriptStepRecord, index: number, body: string) {
248
+ const assertion = step.observePrompt?.trim();
249
+ if (!assertion) return body;
250
+
251
+ const observerVar = `observer${index + 1}`;
252
+ const nestedBody = body
253
+ .split('\n')
254
+ .map((line) => ` ${line}`)
255
+ .join('\n');
256
+
257
+ return ` const ${observerVar} = await agent.startObserving({ intervalMs: 500, maxFrames: 20 });
258
+ try {
259
+ ${nestedBody}
260
+ } finally {
261
+ await ${observerVar}.stop();
262
+ }
263
+ await ${observerVar}.aiAssert(\`${escapeTemplate(assertion)}\`);`;
264
+ }
265
+
266
+ function getBooleanFailureRule(step: ScriptStepRecord, outputVar: string) {
267
+ const signalText = `${outputVar} ${step.label || ''}`;
268
+ const shouldFailWhenTrue =
269
+ /has.*(modal|popup|dialog|blocking)|hasBlocking|blockingModal/i.test(signalText) ||
270
+ (step.label || '').includes('确认无干扰弹窗') ||
271
+ (step.label || '').includes('遮挡弹窗');
272
+ const shouldFailWhenFalse =
273
+ /is.*(ready|loaded|success)|isLoginReady|loginReady/i.test(signalText) ||
274
+ (step.label || '').includes('验证登录页就绪') ||
275
+ (step.label || '').includes('登录页就绪');
276
+ return { shouldFailWhenTrue, shouldFailWhenFalse };
277
+ }
278
+
279
+ function buildAndroidStepCall(step: ScriptStepRecord, index: number) {
280
+ const prompt = escapeTemplate(step.prompt || '');
281
+ const value = step.value || '';
282
+ const outputVar = safeVar(step.outputVar, `step${index + 1}Result`);
283
+
284
+ switch (step.type) {
285
+ case 'comment':
286
+ return ` console.log(${JSON.stringify(step.prompt || step.label || '')});`;
287
+ case 'waitFor':
288
+ return ` await agent.aiWaitFor(\`${prompt}\`);`;
289
+ case 'act':
290
+ return withObservation(step, index, withRepeat(step, ` await agent.aiAct(\`${prompt}\`);`));
291
+ case 'tap':
292
+ return withObservation(step, index, withRepeat(step, ` await agent.aiTap(\`${prompt}\`);`));
293
+ case 'input':
294
+ return withObservation(
295
+ step,
296
+ index,
297
+ ` await agent.aiInput(\`${prompt}\`, { value: ${JSON.stringify(value)} });`,
298
+ );
299
+ case 'query':
300
+ return ` const ${outputVar} = await agent.aiQuery(${JSON.stringify(step.prompt || '')});\n console.log(${JSON.stringify(step.label || outputVar)}, ${outputVar});`;
301
+ case 'boolean':
302
+ {
303
+ const { shouldFailWhenTrue, shouldFailWhenFalse } = getBooleanFailureRule(step, outputVar);
304
+ return ` const ${outputVar} = await agent.aiBoolean(\`${prompt}\`);\n console.log(${JSON.stringify(step.label || outputVar)}, ${outputVar});${
305
+ shouldFailWhenTrue
306
+ ? `\n if (${outputVar}) {\n throw new Error(${JSON.stringify(`${step.label || outputVar} 未通过:仍存在遮挡操作的弹窗`)});\n }`
307
+ : ''
308
+ }${
309
+ shouldFailWhenFalse
310
+ ? `\n if (!${outputVar}) {\n throw new Error(${JSON.stringify(`${step.label || outputVar} 未通过:页面未达到预期状态`)});\n }`
311
+ : ''
312
+ }`;
313
+ }
314
+ case 'string':
315
+ return ` const ${outputVar} = await agent.aiString(\`${prompt}\`);\n console.log(${JSON.stringify(step.label || outputVar)}, ${outputVar});`;
316
+ case 'number':
317
+ return ` const ${outputVar} = await agent.aiNumber(\`${prompt}\`);\n console.log(${JSON.stringify(step.label || outputVar)}, ${outputVar});`;
318
+ default:
319
+ return ` await agent.aiAct(\`${prompt}\`);`;
320
+ }
321
+ }
322
+
323
+ function buildAndroidStepScript(input: {
324
+ scriptName: string;
325
+ promptTitle?: string;
326
+ sourcePrompt?: string;
327
+ steps: ScriptStepRecord[];
328
+ }) {
329
+ const activeSteps = normalizeRuntimeSteps(input.steps).filter((step) => step.enabled !== false);
330
+ const stepBlocks = activeSteps
331
+ .map((step, index) => {
332
+ const meta = serializeStep(step, index);
333
+ const timeoutMultiplier = getRepeatCount(step);
334
+ return ` emitStep('start', ${meta});
335
+ try {
336
+ await runStepWithTimeout(async () => {
337
+ ${buildAndroidStepCall(step, index)}
338
+ }, ${meta}, ${timeoutMultiplier});
339
+ emitStep('success', ${meta});
340
+ } catch (error) {
341
+ emitStep('error', { ...${meta}, detail: error instanceof Error ? error.message : String(error) });
342
+ throw error;
343
+ }`;
344
+ })
345
+ .join('\n\n');
346
+
347
+ return `import { agentFromAdbDevice } from '@midscene/android';
348
+
349
+ const STEP_EVENT_PREFIX = ${JSON.stringify(STEP_EVENT_PREFIX)};
350
+ const STEP_TIMEOUT_MS = Number(process.env.MIDSCENE_STEP_TIMEOUT_MS || 60000);
351
+
352
+ function emitStep(type, payload) {
353
+ console.log(STEP_EVENT_PREFIX + JSON.stringify({ type, ...payload }));
354
+ }
355
+
356
+ async function runStepWithTimeout(task, payload, timeoutMultiplier = 1) {
357
+ let timer;
358
+ const timeoutMs = STEP_TIMEOUT_MS * Math.max(1, Number(timeoutMultiplier) || 1);
359
+ try {
360
+ await Promise.race([
361
+ task(),
362
+ new Promise((_, reject) => {
363
+ timer = setTimeout(() => {
364
+ reject(new Error(\`步骤「\${payload.title}」执行超过 \${Math.round(timeoutMs / 1000)} 秒,已自动停止。\`));
365
+ }, timeoutMs);
366
+ }),
367
+ ]);
368
+ } finally {
369
+ clearTimeout(timer);
370
+ }
371
+ }
372
+
373
+ async function main() {
374
+ const deviceId = process.env.ANDROID_SERIAL;
375
+ const agent = await agentFromAdbDevice(deviceId);
376
+
377
+ try {
378
+ console.log(${JSON.stringify(`开始执行:${input.scriptName}`)});
379
+ ${stepBlocks || ' console.log("没有可执行步骤");'}
380
+ } finally {
381
+ await agent.destroy?.();
382
+ }
383
+ }
384
+
385
+ main().catch((error) => {
386
+ console.error(error);
387
+ setTimeout(() => process.exit(1), 50);
388
+ });
389
+ `;
390
+ }
391
+
392
+ function resolveExecutableCode(input: {
393
+ code: string;
394
+ scriptName: string;
395
+ promptTitle?: string;
396
+ sourcePrompt?: string;
397
+ steps?: ScriptStepRecord[];
398
+ }) {
399
+ if (input.steps?.some((step) => step.enabled !== false)) {
400
+ return buildAndroidStepScript({
401
+ scriptName: input.scriptName,
402
+ promptTitle: input.promptTitle,
403
+ sourcePrompt: input.sourcePrompt,
404
+ steps: input.steps,
405
+ });
406
+ }
407
+
408
+ return input.code;
409
+ }
410
+
411
+ export type RunGeneratedScriptEvent =
412
+ | { type: 'operation'; operation: unknown }
413
+ | { type: 'output'; chunk: string }
414
+ | { type: 'step'; status: 'start' | 'success' | 'error'; index: number; id?: string; title?: string; detail?: string }
415
+ | { type: 'done'; success: boolean; output: string; filePath: string };
416
+
417
+ export async function runGeneratedScript(input: {
418
+ code: string;
419
+ scriptName: string;
420
+ promptTitle?: string;
421
+ sourcePrompt?: string;
422
+ deviceId?: string;
423
+ steps?: ScriptStepRecord[];
424
+ signal?: AbortSignal;
425
+ onEvent?: (event: RunGeneratedScriptEvent) => void;
426
+ }) {
427
+ const runDir = appPath('.midscene-generated');
428
+ fs.mkdirSync(runDir, { recursive: true });
429
+
430
+ const fileName = `${toScriptFileName(input.scriptName).replace(/\.ts$/, '')}-${Date.now()}.ts`;
431
+ const filePath = path.join(runDir, fileName);
432
+ fs.writeFileSync(filePath, injectMidsceneEnv(resolveExecutableCode(input)), 'utf8');
433
+
434
+ return await new Promise<{ success: boolean; output: string; filePath: string }>((resolve) => {
435
+ const child = spawn('npx', ['tsx', filePath], {
436
+ cwd: appPath(),
437
+ env: {
438
+ ...process.env,
439
+ MIDSCENE_RECORD_MODEL_CALL: 'true',
440
+ ...(input.deviceId ? { ANDROID_SERIAL: input.deviceId } : {}),
441
+ },
442
+ });
443
+
444
+ let output = '';
445
+ let stdoutBuffer = '';
446
+ let settled = false;
447
+ let aborted = false;
448
+
449
+ const settle = (success: boolean) => {
450
+ if (settled) return;
451
+ settled = true;
452
+ clearTimeout(timeout);
453
+ input.signal?.removeEventListener('abort', abortRun);
454
+ const result = {
455
+ success: aborted ? false : success,
456
+ output: output.trim(),
457
+ filePath,
458
+ };
459
+ input.onEvent?.({ type: 'done', ...result });
460
+ resolve(result);
461
+ };
462
+
463
+ const killChild = () => {
464
+ child.kill('SIGTERM');
465
+ setTimeout(() => {
466
+ if (!settled) child.kill('SIGKILL');
467
+ }, 2_000);
468
+ };
469
+
470
+ const abortRun = () => {
471
+ if (settled || aborted) return;
472
+ aborted = true;
473
+ const message = '脚本执行已停止。';
474
+ output += `${message}\n`;
475
+ input.onEvent?.({ type: 'output', chunk: `${message}\n` });
476
+ killChild();
477
+ };
478
+
479
+ const handleLine = (line: string) => {
480
+ if (!line) return;
481
+ if (line.startsWith(STEP_EVENT_PREFIX)) {
482
+ try {
483
+ const event = JSON.parse(line.slice(STEP_EVENT_PREFIX.length)) as {
484
+ type?: 'start' | 'success' | 'error';
485
+ index?: number;
486
+ id?: string;
487
+ title?: string;
488
+ detail?: string;
489
+ };
490
+ if (event.type && typeof event.index === 'number') {
491
+ input.onEvent?.({
492
+ type: 'step',
493
+ status: event.type,
494
+ index: event.index,
495
+ id: event.id,
496
+ title: event.title,
497
+ detail: event.detail,
498
+ });
499
+ }
500
+ } catch {
501
+ // Keep malformed marker lines out of the user-facing log.
502
+ }
503
+ return;
504
+ }
505
+ output += `${line}\n`;
506
+ input.onEvent?.({ type: 'output', chunk: `${line}\n` });
507
+ };
508
+
509
+ const timeout = setTimeout(() => {
510
+ const message = `脚本执行超过 ${Math.round(SCRIPT_TIMEOUT_MS / 1000)} 秒,已自动终止。`;
511
+ output += `${message}\n`;
512
+ input.onEvent?.({ type: 'output', chunk: `${message}\n` });
513
+ killChild();
514
+ }, SCRIPT_TIMEOUT_MS);
515
+
516
+ if (input.signal?.aborted) {
517
+ abortRun();
518
+ } else {
519
+ input.signal?.addEventListener('abort', abortRun, { once: true });
520
+ }
521
+
522
+ child.stdout.on('data', (chunk) => {
523
+ stdoutBuffer += chunk.toString();
524
+ const lines = stdoutBuffer.split(/\r?\n/);
525
+ stdoutBuffer = lines.pop() || '';
526
+ for (const line of lines) {
527
+ handleLine(line);
528
+ }
529
+ });
530
+
531
+ child.stderr.on('data', (chunk) => {
532
+ const text = chunk.toString();
533
+ output += text;
534
+ input.onEvent?.({ type: 'output', chunk: text });
535
+ });
536
+
537
+ child.on('close', (code) => {
538
+ if (stdoutBuffer) {
539
+ handleLine(stdoutBuffer);
540
+ stdoutBuffer = '';
541
+ }
542
+ settle(code === 0);
543
+ });
544
+
545
+ child.on('error', (error) => {
546
+ output += `${error.message}\n`;
547
+ input.onEvent?.({ type: 'output', chunk: `${error.message}\n` });
548
+ settle(false);
549
+ });
550
+ });
551
+ }
@@ -0,0 +1,49 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { appPath } from '../paths';
5
+
6
+ const dbPath = appPath('.midscene-app', 'script-cache.sqlite');
7
+ const sqliteBin = process.env.SQLITE3_BIN || '/usr/bin/sqlite3';
8
+
9
+ export function getRuntimeDbPath() {
10
+ return dbPath;
11
+ }
12
+
13
+ export function createId(prefix: string) {
14
+ return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
15
+ }
16
+
17
+ export function sqlString(value: string) {
18
+ return `'${value.replace(/'/g, "''")}'`;
19
+ }
20
+
21
+ export function sqlNullableString(value: string | null | undefined) {
22
+ return value ? sqlString(value) : 'NULL';
23
+ }
24
+
25
+ export function sqlJson(value: unknown) {
26
+ return sqlString(JSON.stringify(value ?? null));
27
+ }
28
+
29
+ function ensureDbDir() {
30
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
31
+ }
32
+
33
+ // 使用 sqlite3 CLI 保持项目零新增依赖;所有运行态功能共用同一个 SQLite 文件。
34
+ export function runSql(sql: string) {
35
+ ensureDbDir();
36
+ execFileSync(sqliteBin, [dbPath], {
37
+ input: sql,
38
+ maxBuffer: 40 * 1024 * 1024,
39
+ });
40
+ }
41
+
42
+ export function querySql<T>(sql: string) {
43
+ ensureDbDir();
44
+ const output = execFileSync(sqliteBin, ['-json', dbPath, sql], {
45
+ encoding: 'utf8',
46
+ maxBuffer: 40 * 1024 * 1024,
47
+ });
48
+ return JSON.parse(output || '[]') as T[];
49
+ }
@@ -0,0 +1,28 @@
1
+ import type { ImportedTestCase } from './types';
2
+
3
+ function appendBulletSection(lines: string[], title: string, values: string[]) {
4
+ if (!values.length) return;
5
+ lines.push(`### ${title}`, ...values.map((value) => `- ${value}`), '');
6
+ }
7
+
8
+ function appendNumberedSection(lines: string[], title: string, values: string[]) {
9
+ if (!values.length) return;
10
+ lines.push(`### ${title}`, ...values.map((value, index) => `${index + 1}. ${value}`), '');
11
+ }
12
+
13
+ // 输出稳定的 Markdown 结构,便于用户阅读,也便于脚本生成模型识别字段边界。
14
+ export function formatTestCases(fileName: string, cases: ImportedTestCase[]) {
15
+ const lines = ['# 测试用例', '', `来源文件:${fileName}`, `用例数量:${cases.length}`, ''];
16
+
17
+ cases.forEach((item, index) => {
18
+ lines.push(`## 用例 ${index + 1}:${item.title || `未命名用例 ${index + 1}`}`, '');
19
+ if (item.priority) lines.push(`优先级:${item.priority}`, '');
20
+ appendBulletSection(lines, '前置条件', item.preconditions);
21
+ appendNumberedSection(lines, '测试步骤', item.steps);
22
+ appendNumberedSection(lines, '预期结果', item.expectedResults);
23
+ appendBulletSection(lines, '测试数据', item.testData);
24
+ appendBulletSection(lines, '测试描述', item.description);
25
+ });
26
+
27
+ return lines.join('\n').trim();
28
+ }
@@ -0,0 +1,69 @@
1
+ import * as XLSX from 'xlsx';
2
+ import { normalizeExtractedText, splitCellItems } from '../text-normalizer';
3
+ import type { ImportedTestCase } from '../types';
4
+
5
+ const aliases = {
6
+ title: ['用例名称', '用例标题', '测试用例', '场景标题', '测试场景', 'case name', 'title'],
7
+ preconditions: ['前置条件', '前提条件', '准备条件', 'precondition', 'preconditions'],
8
+ steps: ['测试步骤', '操作步骤', '执行步骤', '步骤', '操作', 'step', 'steps'],
9
+ expectedResults: ['预期结果', '期望结果', '预期', 'expected result', 'expected results', 'expected'],
10
+ testData: ['测试数据', '输入数据', '数据', 'test data', 'input data'],
11
+ priority: ['优先级', 'priority'],
12
+ } as const;
13
+
14
+ function normalizeHeader(value: string) {
15
+ return value.toLowerCase().replace(/[\s_\-::()()]/g, '');
16
+ }
17
+
18
+ function findCell(row: Record<string, unknown>, names: readonly string[]) {
19
+ const normalizedNames = names.map(normalizeHeader);
20
+ const entry = Object.entries(row).find(([header]) => normalizedNames.includes(normalizeHeader(header)));
21
+ return entry?.[1];
22
+ }
23
+
24
+ function rowToCase(row: Record<string, unknown>, sheetName: string, index: number): ImportedTestCase | null {
25
+ const title = String(findCell(row, aliases.title) ?? '').trim();
26
+ const preconditions = splitCellItems(findCell(row, aliases.preconditions));
27
+ const steps = splitCellItems(findCell(row, aliases.steps));
28
+ const expectedResults = splitCellItems(findCell(row, aliases.expectedResults));
29
+ const testData = splitCellItems(findCell(row, aliases.testData));
30
+ const priority = String(findCell(row, aliases.priority) ?? '').trim();
31
+ const hasRecognizedContent = Boolean(title || preconditions.length || steps.length || expectedResults.length || testData.length);
32
+ if (!hasRecognizedContent) return null;
33
+
34
+ return {
35
+ title: title || `${sheetName} - 用例 ${index + 1}`,
36
+ preconditions,
37
+ steps,
38
+ expectedResults,
39
+ testData,
40
+ description: [],
41
+ priority,
42
+ };
43
+ }
44
+
45
+ // Excel 按“每行一条用例”解析,同时保留 CSV 文本作为语义校验兜底。
46
+ export function parseExcel(buffer: Buffer) {
47
+ const workbook = XLSX.read(buffer, { type: 'buffer', cellDates: true });
48
+ const cases: ImportedTestCase[] = [];
49
+ const rawSections: string[] = [];
50
+
51
+ for (const sheetName of workbook.SheetNames) {
52
+ const sheet = workbook.Sheets[sheetName];
53
+ if (!sheet) continue;
54
+
55
+ const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet, { defval: '' });
56
+ rows.forEach((row, index) => {
57
+ const parsedCase = rowToCase(row, sheetName, index);
58
+ if (parsedCase) cases.push(parsedCase);
59
+ });
60
+
61
+ const csv = XLSX.utils.sheet_to_csv(sheet).trim();
62
+ if (csv) rawSections.push(`工作表:${sheetName}\n${csv}`);
63
+ }
64
+
65
+ return {
66
+ rawText: normalizeExtractedText(rawSections.join('\n\n')),
67
+ cases,
68
+ };
69
+ }