android-midscene-automation 0.1.32 → 0.1.33

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/CHANGELOG.md CHANGED
@@ -1,4 +1,8 @@
1
1
  # 更新记录
2
+ ## v0.1.33
3
+ - 操作菜单移除“断言存在”和“断言文本”,统一保留“判断存在”及其文本匹配配置;旧脚本中的断言节点仍兼容原有执行行为。
4
+ - “点击坐标”节点支持编辑 X、Y 坐标,保存后回放使用新坐标;默认节点名称同步更新,自定义名称保持不变。
5
+ - 新增“预设变量”区域,支持全局变量和脚本私有变量,输入、判断、定位和日志支持 `{{变量名}}` 引用。
2
6
 
3
7
  ## v0.1.32
4
8
  - 添加画面变化结束节点时,多组可配对检测支持按名称及开始节点编号选择,允许交叉结束;只有一组时直接添加,保留分支及重复结束校验。
package/README.md CHANGED
@@ -1229,6 +1229,11 @@ Linux: ~/.local/share/android-midscene-automation
1229
1229
 
1230
1230
  # 项目更新记录
1231
1231
 
1232
+ ## v0.1.33
1233
+ - 操作菜单移除“断言存在”和“断言文本”,统一保留“判断存在”及其文本匹配配置;旧脚本中的断言节点仍兼容原有执行行为。
1234
+ - “点击坐标”节点支持编辑 X、Y 坐标,保存后回放使用新坐标;默认节点名称同步更新,自定义名称保持不变。
1235
+ - 新增“预设变量”区域,支持全局变量和脚本私有变量,输入、判断、定位和日志支持 `{{变量名}}` 引用。
1236
+
1232
1237
  ## v0.1.32
1233
1238
  - 添加画面变化结束节点时,多组可配对检测支持按名称及开始节点编号选择,允许交叉结束;只有一组时直接添加,保留分支及重复结束校验。
1234
1239
  - 画面变化结束节点校验覆盖祖先及子分支:同一执行路径已存在对应结束节点时,后续分支不能重复添加;互斥分支仍可分别结束。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "android-midscene-automation",
3
- "version": "0.1.32",
3
+ "version": "0.1.33",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/oooooooko/android-midscene-automation.git"
@@ -133,7 +133,11 @@ async function handleCommand(command: RemoteCommand) {
133
133
  if (command.type === 'replay') {
134
134
  const script = payload.script as AppiumRecordedScriptRecord | undefined;
135
135
  if (!script) throw new Error('远程回放缺少脚本内容');
136
- return await replayAppiumScript(script, command.deviceId);
136
+ return await replayAppiumScript(script, command.deviceId, undefined, undefined, {
137
+ parameters: payload.parameters as import('../src/appium-recorder/variables').TestVariable[] | undefined,
138
+ globalVariables: payload.globalVariables as import('../src/appium-recorder/variables').TestVariable[] | undefined,
139
+ linkedScripts: payload.linkedScripts as AppiumRecordedScriptRecord[] | undefined,
140
+ });
137
141
  }
138
142
  throw new Error(`不支持的命令:${command.type}`);
139
143
  }
@@ -1,4 +1,7 @@
1
1
  import { execFile } from 'node:child_process';
2
+ import { VariableScope, variableContext, resolveVariableStep } from './variables';
3
+ import { getPresetVariables } from './variable-store';
4
+ import { validateExtraction, validateVariables, type TestVariable } from '../../src/appium-recorder/variables';
2
5
  import { captureHistoryFrames, readAppVersion } from './history-capture';
3
6
  import { BoundedLoopTraversal } from './bounded-loop';
4
7
  import { validateLoopSteps } from '../../src/appium-recorder/bounded-loop';
@@ -10,7 +13,7 @@ import { textClickSelector } from '../../src/appium-recorder/text-click';
10
13
  import { AsyncLocalStorage } from 'node:async_hooks';
11
14
  import { mkdir, writeFile } from 'node:fs/promises';
12
15
  import { join } from 'node:path';
13
- import { getAppiumRecordedScript, type AppiumRecordedScriptRecord, type AppiumRecordedStepRecord } from './repository';
16
+ import { linkedScriptSnapshot, type AppiumRecordedScriptRecord, type AppiumRecordedStepRecord } from './repository';
14
17
  import { appDataPath } from '../paths';
15
18
  import { ensureAndroidSdkAvailable, getAdbCommand } from '../android-sdk';
16
19
  import { createAppiumReplayReport, type AppiumReplayFrame, type AppiumReplayVisualCheck } from './report';
@@ -59,6 +62,7 @@ const replayContext = new AsyncLocalStorage<{
59
62
  appiumLog: (line: string) => void;
60
63
  serverUrl: string;
61
64
  deviceId: string;
65
+ scripts: Map<string, AppiumRecordedScriptRecord>;
62
66
  frames: AppiumReplayFrame[];
63
67
  historyEvents: AppiumReplayFrame[];
64
68
  visualChecks: AppiumReplayVisualCheck[];
@@ -349,7 +353,7 @@ async function captureReplayFrame(
349
353
  nodeNumber,
350
354
  nodeLabel: step.label,
351
355
  nodeType: step.type,
352
- logContent: step.type === 'log' && phase === 'after' ? formatStageLog(step) : undefined,
356
+ logContent: step.type === 'log' && phase === 'after' ? formatStageLog(resolveVariableStep(step)) : undefined,
353
357
  note: step.note || '',
354
358
  selector: replayFrameSelector(step),
355
359
  phase,
@@ -519,6 +523,7 @@ async function waitForElementGone(sessionId: string, step: AppiumRecordedStepRec
519
523
  }
520
524
 
521
525
  async function saveScreenshot(deviceId: string) {
526
+ if (variableContext.getStore()?.privacy.enabled) return;
522
527
  const dir = appDataPath('.midscene-app', 'screenshots');
523
528
  await mkdir(dir, { recursive: true });
524
529
  const file = join(dir, `appium-${Date.now()}.png`);
@@ -701,6 +706,18 @@ async function runStep(
701
706
  meta: RunStepMeta,
702
707
  ): Promise<RunStepResult | void> {
703
708
  throwIfReplayStopped();
709
+ step = resolveVariableStep(step);
710
+ if (step.type === 'extractVariable') {
711
+ const config = validateExtraction(step.extractVariable);
712
+ const scope = variableContext.getStore();
713
+ if (!scope) throw new Error('变量作用域未初始化');
714
+ const elementId = await findElement(sessionId, step);
715
+ const endpoint = config.attribute === 'text' ? 'text' : `attribute/${encodeURIComponent(config.attribute)}`;
716
+ const payload = await appiumRequest<AppiumValueResponse<unknown>>(`/session/${sessionId}/element/${elementId}/${endpoint}`);
717
+ if (payload.value === null || payload.value === undefined) throw new Error(`组件不存在属性:${config.attribute}`);
718
+ scope.set({ name: config.name, value: String(payload.value), sensitive: config.sensitive }, true);
719
+ return `已提取变量:${config.name}`;
720
+ }
704
721
  if (step.type === 'delay') {
705
722
  await wait(Math.max(0, step.timeoutMs || 1000));
706
723
  return;
@@ -931,6 +948,7 @@ async function readNativeControlState(sessionId: string, step: AppiumRecordedSte
931
948
  }
932
949
 
933
950
  async function evaluateCondition(sessionId: string, deviceId: string, step: AppiumRecordedStepRecord) {
951
+ step = resolveVariableStep(step);
934
952
  if (step.type === 'loop') {
935
953
  if (step.loop?.exitWhen === 'never') return false;
936
954
  const selector = toAppiumUsing(step.selector!);
@@ -1048,7 +1066,7 @@ async function replayLinkedScript(
1048
1066
  const scriptId = step.value || '';
1049
1067
  if (!scriptId) throw new Error(`${step.label} 缺少连接脚本 ID`);
1050
1068
  if (stack.includes(scriptId)) throw new Error(`${step.label} 检测到循环连接脚本`);
1051
- const linkedScript = getAppiumRecordedScript(scriptId);
1069
+ const linkedScript = replayContext.getStore()?.scripts.get(scriptId);
1052
1070
  if (!linkedScript) throw new Error(`${step.label} 指向的脚本不存在`);
1053
1071
 
1054
1072
  if (linkedScript.appActivity) {
@@ -1076,10 +1094,12 @@ async function replayLinkedScript(
1076
1094
 
1077
1095
  lines.push(`连接脚本开始:${linkedScript.name}`);
1078
1096
  const nextStack = [...stack, linkedScript.id];
1079
- await replayScriptSteps(sessionId, deviceId, linkedScript.steps, lines, nextStack, {
1080
- skipAppInitialization: true,
1081
- scriptName: linkedScript.name,
1082
- });
1097
+ const parent = variableContext.getStore()!;
1098
+ const child = parent.child(linkedScript.variables || [], step.parameters);
1099
+ await variableContext.run(child, () => replayScriptSteps(sessionId, deviceId, linkedScript.steps, lines, nextStack, {
1100
+ skipAppInitialization: true, scriptName: linkedScript.name,
1101
+ }));
1102
+ if (!replayContext.getStore()?.flowEnded) parent.acceptReturns(child, step.returns);
1083
1103
  lines.push(`连接脚本完成:${linkedScript.name}`);
1084
1104
  }
1085
1105
 
@@ -1342,25 +1362,48 @@ export async function replayAppiumScript(
1342
1362
  deviceId: string,
1343
1363
  onOutput?: (line: string) => void,
1344
1364
  signal?: AbortSignal,
1365
+ runOptions: { parameters?: TestVariable[]; globalVariables?: TestVariable[]; linkedScripts?: AppiumRecordedScriptRecord[] } = {},
1345
1366
  ) {
1346
1367
  const targetDeviceId = deviceId || script.deviceId;
1347
1368
  if (!targetDeviceId) throw new Error('未检测到可用设备');
1348
1369
  if (!script.steps.length) throw new Error('脚本没有可回放步骤');
1349
1370
 
1371
+ const scope = new VariableScope(runOptions.globalVariables ?? getPresetVariables(), script.variables || []);
1372
+ const scripts = new Map((runOptions.linkedScripts ?? linkedScriptSnapshot(script)).map(item => [item.id, item]));
1373
+ for (const item of validateVariables(runOptions.parameters)) scope.set(item);
1374
+ // 提前登记子脚本敏感预设,避免进入子脚本前的原始日志或截图泄露。
1375
+ const visited = new Set<string>();
1376
+ const registerSecrets = (current: AppiumRecordedScriptRecord) => {
1377
+ if (visited.has(current.id)) return;
1378
+ visited.add(current.id);
1379
+ (current.variables || []).forEach(item => scope.track(item));
1380
+ for (const step of current.steps) {
1381
+ (step.parameters || []).forEach(item => scope.track(item));
1382
+ if (step.extractVariable) scope.track({ ...step.extractVariable, value: '' });
1383
+ if (step.type === 'runScript' && step.value) {
1384
+ const child = scripts.get(step.value);
1385
+ if (child) registerSecrets(child);
1386
+ }
1387
+ }
1388
+ };
1389
+ registerSecrets(script);
1390
+
1350
1391
  const startedAt = new Date();
1351
1392
  const appVersion = await readAppVersion(targetDeviceId, script.appPackage).catch(() => '');
1352
1393
  const lines: string[] = [];
1353
1394
  const pushLine = lines.push.bind(lines);
1354
1395
  lines.push = (...items: string[]) => {
1396
+ items = items.map(item => scope.redact(item));
1355
1397
  const length = pushLine(...items);
1356
1398
  items.forEach((line) => onOutput?.(line));
1357
1399
  return length;
1358
1400
  };
1359
1401
  const context = {
1360
1402
  signal,
1361
- appiumLog: (line) => lines.push(line),
1403
+ appiumLog: (line) => { if (!scope.privacy.enabled) lines.push(line); },
1362
1404
  serverUrl: configuredAppiumServerUrl(),
1363
1405
  deviceId: targetDeviceId,
1406
+ scripts,
1364
1407
  frames: [] as AppiumReplayFrame[],
1365
1408
  historyEvents: [] as AppiumReplayFrame[],
1366
1409
  visualChecks: [] as AppiumReplayVisualCheck[],
@@ -1368,7 +1411,8 @@ export async function replayAppiumScript(
1368
1411
  pendingVisualChecks: [] as PendingVisualChangeCheck[],
1369
1412
  softFailureCount: 0,
1370
1413
  };
1371
- return replayContext.run(context, async () => {
1414
+ return variableContext.run(scope, () => replayContext.run(context, async () => {
1415
+ if (scope.privacy.enabled) lines.push('敏感变量保护已启用:不保存报告截图、截图节点文件或 Appium 原始日志');
1372
1416
  lines.push(
1373
1417
  `目标设备:${targetDeviceId}`,
1374
1418
  `App 包名:${script.appPackage}`,
@@ -1386,7 +1430,7 @@ export async function replayAppiumScript(
1386
1430
  if (usesManagedAppiumServer()) {
1387
1431
  lines.push('正在启动本次回放的 Appium 服务...');
1388
1432
  lines.push('----- Appium 服务端原始日志开始 -----');
1389
- managedAppium = await startManagedAppiumServer((line) => lines.push(line), signal);
1433
+ managedAppium = await startManagedAppiumServer((line) => { if (!scope.privacy.enabled) lines.push(line); }, signal);
1390
1434
  context.serverUrl = managedAppium.serverUrl;
1391
1435
  }
1392
1436
  lines.push(`Appium 服务:${appiumServerUrl()}`);
@@ -1452,21 +1496,24 @@ export async function replayAppiumScript(
1452
1496
  }
1453
1497
 
1454
1498
  const completedAt = new Date();
1499
+ const safeFrames = scope.privacy.enabled ? [] : scope.scrub(context.frames);
1500
+ const safeChecks = scope.scrub(context.visualChecks.map(check => scope.privacy.enabled
1501
+ ? { ...check, baselineBase64: '', comparisonBase64: '', diffBase64: '' } : check));
1455
1502
  let reportPath = '';
1456
1503
  let reportId = '';
1457
1504
  let logPath = '';
1458
1505
  let htmlReportPath = '';
1459
1506
  try {
1460
1507
  const report = await createAppiumReplayReport({
1461
- script,
1508
+ script: scope.scrub(script),
1462
1509
  deviceId: targetDeviceId,
1463
1510
  success,
1464
1511
  stopped,
1465
- output: lines.join('\n'),
1512
+ output: scope.redact(lines.join('\n')),
1466
1513
  startedAt,
1467
1514
  completedAt,
1468
- frames: context.frames,
1469
- visualChecks: context.visualChecks,
1515
+ frames: safeFrames,
1516
+ visualChecks: safeChecks,
1470
1517
  });
1471
1518
  reportPath = report.filePath;
1472
1519
  reportId = report.id;
@@ -1481,7 +1528,7 @@ export async function replayAppiumScript(
1481
1528
  return {
1482
1529
  success,
1483
1530
  stopped,
1484
- output: lines.join('\n'),
1531
+ output: scope.redact(lines.join('\n')),
1485
1532
  reportPath,
1486
1533
  reportId,
1487
1534
  logPath,
@@ -1492,9 +1539,9 @@ export async function replayAppiumScript(
1492
1539
  appVersion, deviceId: targetDeviceId, startedAt: startedAt.toISOString(),
1493
1540
  durationMs: completedAt.getTime() - startedAt.getTime(),
1494
1541
  status: stopped ? 'stopped' as const : success ? 'passed' as const : 'failed' as const,
1495
- output: lines.join('\n'),
1496
- ...captureHistoryFrames(context.frames, context.visualChecks, context.historyEvents),
1542
+ output: scope.redact(lines.join('\n')),
1543
+ ...scope.scrub(captureHistoryFrames(safeFrames, safeChecks, context.historyEvents)),
1497
1544
  },
1498
1545
  };
1499
- });
1546
+ }));
1500
1547
  }
@@ -8,6 +8,8 @@ import {
8
8
  } from '../storage/sqlite';
9
9
  import { normalizeLegacyNestedConditionBranches } from '../../src/appium-recorder/flow-normalize';
10
10
  import { validateLoopSteps } from '../../src/appium-recorder/bounded-loop';
11
+ import { validateVariables, validateExtraction, validateReturns, type TestVariable, type VariableExtraction, type ScriptReturn } from '../../src/appium-recorder/variables';
12
+ import { getPresetVariables, savePresetVariables, deleteScriptVariables } from './variable-store';
11
13
 
12
14
  export type AppiumRecordedStepRecord = {
13
15
  id: string;
@@ -44,6 +46,7 @@ export type AppiumRecordedStepRecord = {
44
46
  | 'pinch'
45
47
  | 'runScript'
46
48
  | 'noop'
49
+ | 'extractVariable'
47
50
  | 'log'
48
51
  | 'visualChange';
49
52
  label: string;
@@ -86,6 +89,9 @@ export type AppiumRecordedStepRecord = {
86
89
  };
87
90
  value?: string;
88
91
  logPrefix?: string;
92
+ extractVariable?: VariableExtraction;
93
+ parameters?: TestVariable[];
94
+ returns?: ScriptReturn[];
89
95
  keyCode?: number;
90
96
  timeoutMs?: number;
91
97
  longPressMode?: 'element' | 'coordinates';
@@ -162,6 +168,7 @@ export type AppiumRecordedScriptRecord = {
162
168
  appPackage: string;
163
169
  appActivity: string;
164
170
  deviceId: string;
171
+ variables?: TestVariable[];
165
172
  steps: AppiumRecordedStepRecord[];
166
173
  createdAt: string;
167
174
  updatedAt: string;
@@ -290,6 +297,7 @@ function rowToRecord(row: AppiumRecordedScriptRow): AppiumRecordedScriptRecord {
290
297
  appPackage: row.app_package,
291
298
  appActivity: normalizeScriptActivity(row.app_package, row.app_activity, steps),
292
299
  deviceId: row.device_id || '',
300
+ variables: getPresetVariables(row.id),
293
301
  steps,
294
302
  createdAt: row.created_at,
295
303
  updatedAt: row.updated_at,
@@ -332,7 +340,20 @@ export function getAppiumRecordedScript(id: string) {
332
340
  return row ? rowToRecord(row) : null;
333
341
  }
334
342
 
343
+ export function linkedScriptSnapshot(root: AppiumRecordedScriptRecord) {
344
+ const found = new Map<string, AppiumRecordedScriptRecord>([[root.id, root]]);
345
+ for (const script of found.values()) {
346
+ for (const step of script.steps) {
347
+ if (step.type !== 'runScript' || !step.value || found.has(step.value)) continue;
348
+ const linked = getAppiumRecordedScript(step.value);
349
+ if (linked) found.set(linked.id, linked);
350
+ }
351
+ }
352
+ return [...found.values()];
353
+ }
354
+
335
355
  export function saveAppiumRecordedScript(input: {
356
+ variables?: TestVariable[];
336
357
  id?: string;
337
358
  name: string;
338
359
  appPackage: string;
@@ -346,6 +367,11 @@ export function saveAppiumRecordedScript(input: {
346
367
  const name = input.name.trim();
347
368
  const appPackage = input.appPackage.trim();
348
369
  const steps = normalizeLegacyNestedConditionBranches(input.steps || []);
370
+ if (input.variables !== undefined) validateVariables(input.variables);
371
+ for (const step of steps) {
372
+ if (step.type === 'extractVariable') validateExtraction(step.extractVariable);
373
+ if (step.type === 'runScript') { validateVariables(step.parameters); validateReturns(step.returns); }
374
+ }
349
375
  validateLoopSteps(steps);
350
376
  const appActivity = normalizeScriptActivity(appPackage, input.appActivity, steps);
351
377
 
@@ -382,6 +408,7 @@ export function saveAppiumRecordedScript(input: {
382
408
  flow_json = excluded.flow_json,
383
409
  updated_at = excluded.updated_at;
384
410
  `);
411
+ if (input.variables !== undefined) savePresetVariables(input.variables, input.id);
385
412
  return getAppiumRecordedScript(input.id);
386
413
  }
387
414
 
@@ -414,11 +441,14 @@ export function saveAppiumRecordedScript(input: {
414
441
  updated_at = excluded.updated_at;
415
442
  `);
416
443
 
417
- return listAppiumRecordedScripts().find((script) => script.name === name) || getAppiumRecordedScript(id);
444
+ const record = listAppiumRecordedScripts().find((script) => script.name === name) || getAppiumRecordedScript(id);
445
+ if (record && input.variables !== undefined) record.variables = savePresetVariables(input.variables, record.id);
446
+ return record;
418
447
  }
419
448
 
420
449
  export function deleteAppiumRecordedScript(id: string) {
421
450
  initDb();
451
+ deleteScriptVariables(id);
422
452
  runSql(`
423
453
  DELETE FROM appium_recorded_scripts
424
454
  WHERE id = ${sqlString(id)};
@@ -426,6 +456,7 @@ export function deleteAppiumRecordedScript(id: string) {
426
456
  }
427
457
 
428
458
  export function importAppiumRecordedScript(input: {
459
+ variables?: TestVariable[];
429
460
  name: string;
430
461
  appPackage: string;
431
462
  appActivity?: string;
@@ -1,10 +1,13 @@
1
1
  import { execFile } from 'node:child_process';
2
+ import { getPresetVariables, savePresetVariables } from './variable-store';
3
+ import type { TestVariable } from '../../src/appium-recorder/variables';
2
4
  import { deleteRunHistory, getRunHistory, listRunHistory, saveRunHistory } from './run-history';
3
5
  import { recognizeDeviceScreen } from './ai-recognition';
4
6
  import { readFreshWindowHierarchy } from './tree-dump';
5
7
  import type { IncomingMessage, ServerResponse } from 'node:http';
6
8
  import {
7
9
  deleteAppiumRecordedScript,
10
+ linkedScriptSnapshot,
8
11
  getAppiumRecordedScript,
9
12
  importAppiumRecordedScript,
10
13
  listAppiumRecordedScripts,
@@ -221,6 +224,17 @@ export async function handleAppiumRecorderRequest(
221
224
  return true;
222
225
  }
223
226
 
227
+ if (pathname === '/api/appium-recorder/variables') {
228
+ const scriptId = requestUrl.searchParams.get('scriptId') || undefined;
229
+ if (scriptId && !getAppiumRecordedScript(scriptId)) throw new Error('脚本不存在');
230
+ const scope = scriptId || (requestUrl.searchParams.get('draft') === '1' ? '__variable_draft__' : undefined);
231
+ if (req.method === 'GET') { sendJson(res, { variables: getPresetVariables(scope) }); return true; }
232
+ if (req.method === 'PUT') {
233
+ const body = await readBody<{ variables: TestVariable[] }>(req);
234
+ if (!Array.isArray(body.variables)) throw new Error('变量必须为数组');
235
+ sendJson(res, { variables: savePresetVariables(body.variables, scope) }); return true;
236
+ }
237
+ }
224
238
  if (pathname === '/api/appium-recorder/scripts' && req.method === 'GET') {
225
239
  sendJson(res, { scripts: listAppiumRecordedScripts() });
226
240
  return true;
@@ -234,6 +248,7 @@ export async function handleAppiumRecorderRequest(
234
248
  appActivity?: string;
235
249
  deviceId?: string;
236
250
  steps?: AppiumRecordedStepRecord[];
251
+ variables?: TestVariable[];
237
252
  }>(req);
238
253
  const script = saveAppiumRecordedScript({
239
254
  id: parsed.id,
@@ -242,6 +257,7 @@ export async function handleAppiumRecorderRequest(
242
257
  appActivity: parsed.appActivity || '',
243
258
  deviceId: parsed.deviceId || selectedDeviceId,
244
259
  steps: parsed.steps || [],
260
+ variables: parsed.variables,
245
261
  });
246
262
  sendJson(res, { script });
247
263
  return true;
@@ -256,6 +272,7 @@ export async function handleAppiumRecorderRequest(
256
272
  appActivity?: string;
257
273
  deviceId?: string;
258
274
  steps?: AppiumRecordedStepRecord[];
275
+ variables?: TestVariable[];
259
276
  };
260
277
  }>(req);
261
278
  const imported = parsed.script;
@@ -271,6 +288,7 @@ export async function handleAppiumRecorderRequest(
271
288
  appActivity: imported.appActivity || '',
272
289
  deviceId: imported.deviceId || '',
273
290
  steps: imported.steps,
291
+ variables: imported.variables,
274
292
  });
275
293
  sendJson(res, { script });
276
294
  return true;
@@ -325,7 +343,7 @@ export async function handleAppiumRecorderRequest(
325
343
  }
326
344
  const replayMatch = pathname.match(/^\/api\/appium-recorder\/scripts\/([^/]+)\/replay$/);
327
345
  if (replayMatch && req.method === 'POST') {
328
- const parsed = await readBody<{ deviceId?: string }>(req);
346
+ const parsed = await readBody<{ deviceId?: string; parameters?: TestVariable[] }>(req);
329
347
  const script = getAppiumRecordedScript(decodeURIComponent(replayMatch[1]));
330
348
  if (!script) throw new Error('Appium 录制脚本不存在');
331
349
  const deviceId = parsed.deviceId || selectedDeviceId;
@@ -339,7 +357,7 @@ export async function handleAppiumRecorderRequest(
339
357
  res.flushHeaders();
340
358
  }
341
359
  if (isRemoteDeviceId(deviceId)) {
342
- const result = await sendRemoteCommand(deviceId, 'replay', { script }) as Awaited<ReturnType<typeof replayAppiumScript>>;
360
+ const result = await sendRemoteCommand(deviceId, 'replay', { script, parameters: parsed.parameters, globalVariables: getPresetVariables(), linkedScripts: linkedScriptSnapshot(script) }) as Awaited<ReturnType<typeof replayAppiumScript>>;
343
361
  if (result.history) {
344
362
  try { saveRunHistory({ ...result.history, scriptId: script.id, deviceId }); }
345
363
  catch { result.output += '\n历史记录保存失败'; }
@@ -366,6 +384,7 @@ export async function handleAppiumRecorderRequest(
366
384
  deviceId,
367
385
  streamOutput ? (line) => sendStreamEvent(res, { type: 'log', line }) : undefined,
368
386
  replayAbortController.signal,
387
+ { parameters: parsed.parameters, globalVariables: getPresetVariables() },
369
388
  );
370
389
  try { saveRunHistory(result.history); }
371
390
  catch { result.output += '\n历史记录保存失败'; }
@@ -1,6 +1,8 @@
1
1
  import { buffer } from 'node:stream/consumers';
2
2
  import archiver from 'archiver';
3
3
  import type { AppiumRecordedScriptRecord } from './repository';
4
+ import { VariableScope } from './variables';
5
+ import { sensitiveVariableName } from '../../src/appium-recorder/variables';
4
6
 
5
7
  function safeExportName(name: string) {
6
8
  const base = Array.from(name.trim().replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_'))
@@ -29,7 +31,11 @@ export async function createAppiumScriptExport(
29
31
  }
30
32
 
31
33
  const exportedAt = new Date().toISOString();
32
- const serialize = (script: AppiumRecordedScriptRecord) => JSON.stringify({
34
+ const serialize = (script: AppiumRecordedScriptRecord) => {
35
+ const scope = new VariableScope([], script.variables || []);
36
+ script.steps.forEach(step => step.parameters?.forEach(item => scope.track(item)));
37
+ const clearSecrets = (items: typeof script.variables = []) => items.map(item => item.sensitive || sensitiveVariableName(item.name) ? { ...item, value: '', sensitive: true } : item);
38
+ return JSON.stringify(scope.scrub({
33
39
  schemaVersion: 1,
34
40
  exportedAt,
35
41
  script: {
@@ -38,9 +44,11 @@ export async function createAppiumScriptExport(
38
44
  appPackage: script.appPackage,
39
45
  appActivity: script.appActivity,
40
46
  deviceId: script.deviceId,
41
- steps: script.steps,
47
+ variables: clearSecrets(script.variables),
48
+ steps: script.steps.map(step => ({ ...step, ...(step.parameters ? { parameters: clearSecrets(step.parameters) } : {}) })),
42
49
  },
43
- }, null, 2);
50
+ }), null, 2);
51
+ };
44
52
  const baseName = safeExportName(root.name);
45
53
  if (!root.steps.some((step) => step.type === 'runScript')) {
46
54
  return {
@@ -0,0 +1,22 @@
1
+ import { querySql, runSql, sqlJson, sqlString } from '../storage/sqlite';
2
+ import { validateVariables, type TestVariable } from '../../src/appium-recorder/variables';
3
+
4
+ function init() {
5
+ runSql('CREATE TABLE IF NOT EXISTS appium_variables (scope TEXT PRIMARY KEY, value TEXT NOT NULL);');
6
+ }
7
+ // 预设只在编辑时持久化;回放使用独立快照,不将提取值写回全局。
8
+ export function getPresetVariables(scriptId?: string): TestVariable[] {
9
+ init();
10
+ const row = querySql<{ value: string }>(`SELECT value FROM appium_variables WHERE scope=${sqlString(scriptId ? `script:${scriptId}` : 'global')};`)?.[0];
11
+ return validateVariables(row ? JSON.parse(row.value) : []);
12
+ }
13
+ export function savePresetVariables(variables: TestVariable[], scriptId?: string) {
14
+ const values = validateVariables(variables);
15
+ init();
16
+ runSql(`INSERT INTO appium_variables VALUES (${sqlString(scriptId ? `script:${scriptId}` : 'global')}, ${sqlJson(values)}) ON CONFLICT(scope) DO UPDATE SET value=excluded.value;`);
17
+ return values;
18
+ }
19
+ export function deleteScriptVariables(scriptId: string) {
20
+ init();
21
+ runSql(`DELETE FROM appium_variables WHERE scope=${sqlString(`script:${scriptId}`)};`);
22
+ }
@@ -0,0 +1,89 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { sensitiveVariableName, validateVariables, validateReturns, type TestVariable, type ScriptReturn } from '../../src/appium-recorder/variables';
3
+ import type { AppiumRecordedStepRecord } from './repository';
4
+
5
+ export class VariableScope {
6
+ readonly locals = new Map<string, TestVariable & { literal?: boolean }>();
7
+ readonly globals: Map<string, TestVariable>;
8
+ readonly secrets: Set<string>;
9
+ readonly privacy: { enabled: boolean };
10
+ constructor(globals: TestVariable[], locals: TestVariable[], parent?: VariableScope) {
11
+ this.globals = parent?.globals || new Map(validateVariables(globals).map(item => [item.name, item]));
12
+ this.secrets = parent?.secrets || new Set();
13
+ this.privacy = parent?.privacy || { enabled: false };
14
+ for (const item of this.globals.values()) this.track(item);
15
+ for (const item of validateVariables(locals)) this.set(item);
16
+ }
17
+ track(item: TestVariable) {
18
+ if (item.sensitive || sensitiveVariableName(item.name)) {
19
+ this.privacy.enabled = true;
20
+ if (item.value) this.secrets.add(item.value);
21
+ }
22
+ }
23
+ set(item: TestVariable, literal = false) {
24
+ const value = validateVariables([item])[0];
25
+ value.sensitive ||= this.locals.get(value.name)?.sensitive || this.globals.get(value.name)?.sensitive;
26
+ this.track(value);
27
+ this.locals.set(value.name, { ...value, literal });
28
+ }
29
+ resolve(value: string, chain: string[] = []): { value: string; sensitive: boolean } {
30
+ let sensitive = false;
31
+ const resolved = value.replace(/\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g, (_match, name: string) => {
32
+ if (chain.includes(name)) throw new Error(`变量循环引用:${[...chain, name].join(' -> ')}`);
33
+ const item: (TestVariable & { literal?: boolean }) | undefined = this.locals.get(name) || this.globals.get(name);
34
+ if (!item) throw new Error(`变量未定义:${name}`);
35
+ const next = item.literal ? { value: item.value, sensitive: Boolean(item.sensitive) } : this.resolve(item.value, [...chain, name]);
36
+ sensitive ||= Boolean(item.sensitive) || sensitiveVariableName(name) || next.sensitive;
37
+ if (sensitive && next.value) this.secrets.add(next.value);
38
+ return next.value;
39
+ });
40
+ if (sensitive) { this.privacy.enabled = true; if (resolved) this.secrets.add(resolved); }
41
+ return { value: resolved, sensitive };
42
+ }
43
+ child(defaults: TestVariable[], params: TestVariable[] = []) {
44
+ const child = new VariableScope([], defaults, this);
45
+ for (const item of validateVariables(params)) {
46
+ const resolved = this.resolve(item.value);
47
+ child.set({ ...item, value: resolved.value, sensitive: item.sensitive || resolved.sensitive }, true);
48
+ }
49
+ return child;
50
+ }
51
+ acceptReturns(child: VariableScope, mappings: ScriptReturn[] = []) {
52
+ // 先校验全部返回值,缺失时不能只写回一半。
53
+ const values = validateReturns(mappings).map(mapping => {
54
+ const item = child.locals.get(mapping.name);
55
+ if (!item) throw new Error(`子脚本未产生返回变量:${mapping.name}`);
56
+ const resolved = child.resolve(`{{${mapping.name}}}`);
57
+ return { name: mapping.target, value: resolved.value, sensitive: item.sensitive || resolved.sensitive };
58
+ });
59
+ values.forEach(item => this.set(item, true));
60
+ }
61
+ redact(text: string) {
62
+ let result = text;
63
+ for (const secret of [...this.secrets].sort((a, b) => b.length - a.length)) {
64
+ for (const variant of new Set([secret, JSON.stringify(secret).slice(1, -1), encodeURIComponent(secret), JSON.stringify([...secret])])) {
65
+ if (variant) result = result.split(variant).join('[已脱敏]');
66
+ }
67
+ }
68
+ return result;
69
+ }
70
+ scrub<T>(value: T): T {
71
+ if (typeof value === 'string') return this.redact(value) as T;
72
+ if (Array.isArray(value)) return value.map(item => this.scrub(item)) as T;
73
+ if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, this.scrub(item)])) as T;
74
+ return value;
75
+ }
76
+ }
77
+
78
+ export const variableContext = new AsyncLocalStorage<VariableScope>();
79
+ export function resolveVariableStep(step: AppiumRecordedStepRecord) {
80
+ const scope = variableContext.getStore();
81
+ if (!scope) return step;
82
+ const resolve = (value: string | undefined) => value === undefined ? value : scope.resolve(value).value;
83
+ const selector = (item: typeof step.selector) => item ? { ...item, value: resolve(item.value) } : item;
84
+ return { ...step, label: resolve(step.label)!, note: resolve(step.note),
85
+ value: step.type === 'runScript' ? step.value : resolve(step.value), logPrefix: resolve(step.logPrefix),
86
+ selector: selector(step.selector), fallback: selector(step.fallback), contextSelector: selector(step.contextSelector),
87
+ selectorChain: step.selectorChain?.map(item => selector(item)!),
88
+ };
89
+ }