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 +4 -0
- package/README.md +5 -0
- package/package.json +1 -1
- package/remote-agent/index.ts +5 -1
- package/server/appium-recorder/appium-runner.ts +65 -18
- package/server/appium-recorder/repository.ts +32 -1
- package/server/appium-recorder/routes.ts +21 -2
- package/server/appium-recorder/script-export.ts +11 -3
- package/server/appium-recorder/variable-store.ts +22 -0
- package/server/appium-recorder/variables.ts +89 -0
- package/src/appium-recorder/AppiumPage.vue +59 -18
- package/src/appium-recorder/api.ts +23 -2
- package/src/appium-recorder/components/FlowStepEditor.vue +28 -2
- package/src/appium-recorder/components/NestedConditionBranches.vue +2 -2
- package/src/appium-recorder/components/PresetVariables.vue +109 -0
- package/src/appium-recorder/components/RecordedSteps.vue +1 -2
- package/src/appium-recorder/components/ScriptParameterSettings.vue +31 -0
- package/src/appium-recorder/components/VariableExtractionSettings.vue +17 -0
- package/src/appium-recorder/components/VariableTable.vue +43 -0
- package/src/appium-recorder/flow-graph.ts +1 -0
- package/src/appium-recorder/flow-labels.ts +4 -1
- package/src/appium-recorder/types.ts +7 -0
- package/src/appium-recorder/variables.ts +34 -0
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
package/remote-agent/index.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
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:
|
|
1469
|
-
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(
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
+
}
|