android-midscene-automation 0.1.41 → 0.1.42
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 +11 -0
- package/README.md +13 -0
- package/USAGE.md +2 -0
- package/package.json +2 -1
- package/remote-agent/index.ts +1 -0
- package/server/appium-auto-save.test.ts +60 -0
- package/server/appium-config.test.ts +118 -0
- package/server/appium-recorder/ai-deduplication.test.ts +80 -0
- package/server/appium-recorder/ai-deduplication.ts +74 -0
- package/server/appium-recorder/ai-observation.test.ts +125 -0
- package/server/appium-recorder/ai-observation.ts +125 -0
- package/server/appium-recorder/ai-recognition.ts +118 -22
- package/server/appium-recorder/appium-runner.ts +59 -14
- package/server/appium-recorder/history-capture.ts +1 -0
- package/server/appium-recorder/image-check.ts +1 -1
- package/server/appium-recorder/managed-appium.ts +15 -0
- package/server/appium-recorder/managed-cleanup.test.ts +82 -0
- package/server/appium-recorder/replay-log.ts +13 -0
- package/server/appium-recorder/report.ts +14 -12
- package/server/appium-recorder/repository.ts +5 -0
- package/server/appium-recorder/routes.ts +26 -2
- package/server/appium-recorder/screenshot.ts +1 -0
- package/server/config.ts +19 -4
- package/server/http-api.ts +26 -4
- package/server/model-tester.ts +2 -1
- package/src/App.vue +41 -29
- package/src/api.ts +10 -3
- package/src/appium-recorder/AppiumPage.vue +41 -6
- package/src/appium-recorder/RunHistoryDialog.vue +1 -1
- package/src/appium-recorder/action-descriptions.ts +1 -1
- package/src/appium-recorder/ai-deduplication.ts +34 -0
- package/src/appium-recorder/ai-prompt-presets.ts +34 -0
- package/src/appium-recorder/ai-recognition.ts +27 -2
- package/src/appium-recorder/api.ts +14 -0
- package/src/appium-recorder/components/AiRecognitionPromptInput.vue +41 -0
- package/src/appium-recorder/components/AiRecognitionSettings.vue +143 -0
- package/src/appium-recorder/components/AiRecognitionTestDialog.vue +86 -0
- package/src/appium-recorder/components/FlowCanvas.vue +3 -2
- package/src/appium-recorder/components/FlowNodeCard.vue +2 -2
- package/src/appium-recorder/components/FlowStepEditor.vue +8 -2
- package/src/appium-recorder/components/NestedConditionBranches.vue +7 -1
- package/src/appium-recorder/components/RecordedSteps.vue +7 -1
- package/src/appium-recorder/flow-appearance.ts +12 -1
- package/src/appium-recorder/flow-graph.ts +2 -2
- package/src/appium-recorder/flow-labels.ts +6 -4
- package/src/appium-recorder/types.ts +4 -0
- package/src/components/config/AiDeduplicationSettings.vue +59 -0
- package/src/components/config/AiPromptPresetsSettings.vue +66 -0
- package/src/components/config/AppiumConfigPanel.vue +48 -3
- package/src/components/config/BaseConfigPanel.vue +20 -8
- package/src/components/config/FlowAppearanceSettings.vue +51 -31
- package/src/components/config/MidsceneConfigPanel.vue +84 -86
- package/src/config/midscene-model-presets.ts +37 -9
- package/src/config/use-appium-auto-save.ts +54 -0
- package/src/pages/ConfigPage.vue +4 -4
- package/src/style.css +5 -10
- package/src/types.ts +3 -1
- package/src/ui-refresh.css +17 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
# 更新记录
|
|
2
|
+
## v0.1.42
|
|
3
|
+
- 新增 AI 识别详细使用说明,涵盖预设场景、单帧与持续观察、提前结束、分支、去重、超时及开屏广告等配置示例。
|
|
4
|
+
- 预设提示词新增测试场景名称,管理列表同时展示场景和内容;AI 识别按场景名称选择。
|
|
5
|
+
- 修复回放终止或会话创建失败后设备端 UiAutomator2 残留,导致组件树刷新失败的问题。
|
|
6
|
+
- AI 持续观察新增“检测命中后提前结束”模式,边采样去重边识别,命中后停止观察并继续流程或进入“是”分支;到达最长观察时间后完成剩余关键帧判断,未命中走原顺序或“否”分支,支持命中条件校验、取消和逐次模型请求超时。
|
|
7
|
+
- Appium 参数的AI 识别配置,新增持续观察选项,支持 pixelmatch(默认)、OpenCV(SSIM)和不去重选项。
|
|
8
|
+
- AI 识别新增“持续观察”,可设置观察时长与采样间隔,按时间顺序采集多帧后统一判断目标是否出现过或读取内容;观察与模型超时分开计时,支持取消、采样日志与测试画面预览,原单帧识别保持兼容。
|
|
9
|
+
- Midscene 配置新增官方文档中的模型提供方预设。
|
|
10
|
+
- 新增 AI 识别默认顺序执行模式,模型回答写入日志后继续下一节点;勾选“启用分支”及修改分支问题时通过模型验证判断语义,不符合时提示且不保存分支修改。
|
|
11
|
+
- Appium 配置新增默认关闭的“截图与 HTML 报告”开关;开启后才采集节点前后截图并生成 HTML。
|
|
12
|
+
|
|
2
13
|
## v0.1.41
|
|
3
14
|
- 修复部分华为设备录屏启动时报编码器错误的问题:使用设备默认编码参数,视频流启动失败时降低分辨率和码率重试,并输出各次尝试的诊断日志。
|
|
4
15
|
|
package/README.md
CHANGED
|
@@ -171,6 +171,8 @@ midscene_run/ # Midscene 执行报告和运行产物
|
|
|
171
171
|
|
|
172
172
|
本文档随 npm 包发布,也可以从 README 的“功能文档”直接打开。常见问题请查看 [常见问题](#常见问题)。
|
|
173
173
|
|
|
174
|
+
AI 识别的场景选择、预设提示词、持续观察、分支、截图去重及超时配置,请查看 [AI 识别详细使用说明](./AI_RECOGNITION_GUIDE.md)。
|
|
175
|
+
|
|
174
176
|
## 使用前准备
|
|
175
177
|
|
|
176
178
|
### 1. 连接 Android 设备
|
|
@@ -1229,6 +1231,17 @@ Linux: ~/.local/share/android-midscene-automation
|
|
|
1229
1231
|
|
|
1230
1232
|
# 项目更新记录
|
|
1231
1233
|
|
|
1234
|
+
## v0.1.42
|
|
1235
|
+
- 新增 AI 识别详细使用说明,涵盖预设场景、单帧与持续观察、提前结束、分支、去重、超时及开屏广告等配置示例。
|
|
1236
|
+
- 预设提示词新增测试场景名称,管理列表同时展示场景和内容;AI 识别按场景名称选择。
|
|
1237
|
+
- 修复回放终止或会话创建失败后设备端 UiAutomator2 残留,导致组件树刷新失败的问题。
|
|
1238
|
+
- AI 持续观察新增“检测命中后提前结束”模式,边采样去重边识别,命中后停止观察并继续流程或进入“是”分支;到达最长观察时间后完成剩余关键帧判断,未命中走原顺序或“否”分支,支持命中条件校验、取消和逐次模型请求超时。
|
|
1239
|
+
- Appium 参数的AI 识别配置,新增持续观察选项,支持 pixelmatch(默认)、OpenCV(SSIM)和不去重选项。
|
|
1240
|
+
- AI 识别新增“持续观察”,可设置观察时长与采样间隔,按时间顺序采集多帧后统一判断目标是否出现过或读取内容;观察与模型超时分开计时,支持取消、采样日志与测试画面预览,原单帧识别保持兼容。
|
|
1241
|
+
- Midscene 配置新增官方文档中的模型提供方预设。
|
|
1242
|
+
- 新增 AI 识别默认顺序执行模式,模型回答写入日志后继续下一节点;勾选“启用分支”及修改分支问题时通过模型验证判断语义,不符合时提示且不保存分支修改。
|
|
1243
|
+
- Appium 配置新增默认关闭的“截图与 HTML 报告”开关;开启后才采集节点前后截图并生成 HTML。
|
|
1244
|
+
|
|
1232
1245
|
## v0.1.41
|
|
1233
1246
|
- 修复部分华为设备录屏启动时报编码器错误的问题:使用设备默认编码参数,视频流启动失败时降低分辨率和码率重试,并输出各次尝试的诊断日志。
|
|
1234
1247
|
|
package/USAGE.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "android-midscene-automation",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.42",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/oooooooko/android-midscene-automation.git"
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"vite.config.ts",
|
|
24
24
|
"README.md",
|
|
25
25
|
"USAGE.md",
|
|
26
|
+
"AI_RECOGNITION_GUIDE.md",
|
|
26
27
|
"FAQ.md",
|
|
27
28
|
"CHANGELOG.md"
|
|
28
29
|
],
|
package/remote-agent/index.ts
CHANGED
|
@@ -136,6 +136,7 @@ async function handleCommand(command: RemoteCommand) {
|
|
|
136
136
|
const script = payload.script as AppiumRecordedScriptRecord | undefined;
|
|
137
137
|
if (!script) throw new Error('远程回放缺少脚本内容');
|
|
138
138
|
return await replayAppiumScript(script, command.deviceId, undefined, undefined, {
|
|
139
|
+
screenshotReport: payload.screenshotReport === true,
|
|
139
140
|
parameters: payload.parameters as import('../src/appium-recorder/variables').TestVariable[] | undefined,
|
|
140
141
|
globalVariables: payload.globalVariables as import('../src/appium-recorder/variables').TestVariable[] | undefined,
|
|
141
142
|
linkedScripts: payload.linkedScripts as AppiumRecordedScriptRecord[] | undefined,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Run: npx tsx --test server/appium-auto-save.test.ts
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { test } from 'node:test';
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
|
+
import { effectScope, reactive } from 'vue';
|
|
6
|
+
import { useAppiumAutoSave } from '../src/config/use-appium-auto-save';
|
|
7
|
+
import type { ConfigForm } from '../src/types';
|
|
8
|
+
|
|
9
|
+
test('loads quietly, serializes rapid edits, validates colors and surfaces save errors', async () => {
|
|
10
|
+
const scope = effectScope();
|
|
11
|
+
const form = reactive({ appium: { model: { baseUrl: '', apiKey: '', name: '' } } } as ConfigForm);
|
|
12
|
+
const submissions: Omit<ConfigForm['appium'], 'model'>[] = [];
|
|
13
|
+
const errors: unknown[] = [];
|
|
14
|
+
let release: (() => void) | undefined;
|
|
15
|
+
let fail = false;
|
|
16
|
+
const auto = scope.run(() => useAppiumAutoSave(form, async value => {
|
|
17
|
+
submissions.push(value);
|
|
18
|
+
if (fail) throw new Error('offline');
|
|
19
|
+
if (submissions.length === 1) await new Promise<void>(resolve => { release = resolve; });
|
|
20
|
+
}, error => errors.push(error)))!;
|
|
21
|
+
try {
|
|
22
|
+
form.appium.model.name = 'loaded';
|
|
23
|
+
auto.initialize();
|
|
24
|
+
await delay(400);
|
|
25
|
+
assert.equal(submissions.length, 0, 'loading must never overwrite stored config');
|
|
26
|
+
form.appium.model.name = 'untested';
|
|
27
|
+
await delay(400);
|
|
28
|
+
assert.equal(submissions.length, 0, 'model drafts must never auto-save');
|
|
29
|
+
form.appium.flowBackgroundColor = '#abc';
|
|
30
|
+
form.appium.flowBackgroundColor = '#def';
|
|
31
|
+
await delay(400);
|
|
32
|
+
assert.equal(submissions.length, 1);
|
|
33
|
+
assert.equal(submissions[0].flowBackgroundColor, '#def');
|
|
34
|
+
assert.equal('model' in submissions[0], false);
|
|
35
|
+
form.appium.flowBackgroundColor = '#123456';
|
|
36
|
+
await delay(400);
|
|
37
|
+
assert.equal(submissions.length, 1, 'only one request may be in flight');
|
|
38
|
+
release!();
|
|
39
|
+
await delay(20);
|
|
40
|
+
assert.equal(submissions.length, 2);
|
|
41
|
+
assert.equal(submissions[1].flowBackgroundColor, '#123456');
|
|
42
|
+
assert.equal(auto.status.value, '已自动保存');
|
|
43
|
+
form.appium.flowLineColor = '#';
|
|
44
|
+
await delay(400);
|
|
45
|
+
assert.equal(submissions.length, 2, 'partial hex input must not be persisted');
|
|
46
|
+
form.appium.flowLineColor = '#123';
|
|
47
|
+
await delay(400);
|
|
48
|
+
assert.equal(submissions.length, 3);
|
|
49
|
+
fail = true;
|
|
50
|
+
form.appium.screenshotReport = true;
|
|
51
|
+
await delay(400);
|
|
52
|
+
assert.equal(errors.length, 1);
|
|
53
|
+
assert.match(auto.status.value, /失败/);
|
|
54
|
+
fail = false;
|
|
55
|
+
form.appium.flowBackgroundColor = '#abcdef';
|
|
56
|
+
await delay(400);
|
|
57
|
+
assert.equal(auto.status.value, '已自动保存');
|
|
58
|
+
assert.equal(submissions.at(-1)?.screenshotReport, true);
|
|
59
|
+
} finally { scope.stop(); }
|
|
60
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Run: npx tsx --test server/appium-config.test.ts
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { test } from 'node:test';
|
|
4
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { createServer } from 'node:http';
|
|
8
|
+
import type { AppConfig } from './config';
|
|
9
|
+
|
|
10
|
+
test('Appium autosave is isolated from manual config, persists and rejects invalid colors', async () => {
|
|
11
|
+
const root = await mkdtemp(join(tmpdir(), 'appium-config-check-'));
|
|
12
|
+
const oldRoot = process.env.ANDROID_MIDSCENE_DATA_ROOT;
|
|
13
|
+
process.env.ANDROID_MIDSCENE_DATA_ROOT = root;
|
|
14
|
+
const { createApiMiddleware } = await import('./http-api');
|
|
15
|
+
const handler = createApiMiddleware();
|
|
16
|
+
const server = createServer((req, res) => handler(req, res, () => { res.statusCode = 404; res.end(); }));
|
|
17
|
+
let releaseModel: (() => void) | undefined;
|
|
18
|
+
let modelStarted: (() => void) | undefined;
|
|
19
|
+
const modelServer = createServer(async (req, res) => {
|
|
20
|
+
const chunks = [];
|
|
21
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
22
|
+
const body = JSON.parse(Buffer.concat(chunks).toString());
|
|
23
|
+
res.setHeader('Content-Type', 'application/json');
|
|
24
|
+
if (body.model === 'fail') {
|
|
25
|
+
res.statusCode = 401;
|
|
26
|
+
res.end(JSON.stringify({ error: { message: 'invalid test key' } }));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (body.model === 'slow') {
|
|
30
|
+
await new Promise<void>(resolve => { releaseModel = resolve; modelStarted?.(); });
|
|
31
|
+
}
|
|
32
|
+
res.end(JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'OK' } }] }));
|
|
33
|
+
});
|
|
34
|
+
try {
|
|
35
|
+
await new Promise<void>(resolve => modelServer.listen(0, '127.0.0.1', resolve));
|
|
36
|
+
const modelAddress = modelServer.address();
|
|
37
|
+
assert.ok(modelAddress && typeof modelAddress === 'object');
|
|
38
|
+
const model = { baseUrl: `http://127.0.0.1:${modelAddress.port}/v1`, apiKey: 'test', name: 'pass', family: 'gpt-5' };
|
|
39
|
+
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
40
|
+
const address = server.address();
|
|
41
|
+
assert.ok(address && typeof address === 'object');
|
|
42
|
+
const base = `http://127.0.0.1:${address.port}`;
|
|
43
|
+
const post = (path: string, body: unknown) => fetch(base + path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
44
|
+
const initial = await (await fetch(base + '/api/config')).json() as AppConfig;
|
|
45
|
+
const appium = { ...initial.appium, flowLineColor: '#AbC', model: { baseUrl: 'https://example.invalid', apiKey: 'test', name: 'new-model' } };
|
|
46
|
+
assert.equal((await post('/api/config/appium', { appium, runtime: { androidSdkPath: 'invalid-draft' }, midscene: { model: { name: 'unsaved' } } })).status, 200);
|
|
47
|
+
let stored = await (await fetch(base + '/api/config')).json() as AppConfig;
|
|
48
|
+
assert.equal(stored.appium.flowLineColor, '#aabbcc');
|
|
49
|
+
assert.deepEqual(stored.runtime, initial.runtime);
|
|
50
|
+
assert.deepEqual(stored.midscene, initial.midscene);
|
|
51
|
+
const { appium: _appium, ...manual } = structuredClone(initial);
|
|
52
|
+
manual.midscene.model.name = 'saved-midscene';
|
|
53
|
+
assert.equal((await post('/api/config', manual)).status, 200);
|
|
54
|
+
stored = await (await fetch(base + '/api/config')).json() as AppConfig;
|
|
55
|
+
assert.deepEqual(stored.appium.model, initial.appium.model, 'automatic saves must not persist untested model drafts');
|
|
56
|
+
assert.equal(stored.midscene.model.name, initial.midscene.model.name, 'runtime save must not persist an untested model');
|
|
57
|
+
assert.equal((await post('/api/config/appium', { appium: { ...appium, flowLineColor: 'invalid' } })).status, 400);
|
|
58
|
+
assert.equal((await post('/api/config/appium', { appium: null })).status, 400);
|
|
59
|
+
const { loadModelConfigFromDb } = await import('./config-store');
|
|
60
|
+
assert.equal(loadModelConfigFromDb()?.appium?.flowLineColor, '#aabbcc');
|
|
61
|
+
|
|
62
|
+
for (const key of ['midscene', 'scriptOptimizer', 'appium'] as const) {
|
|
63
|
+
const before = await (await fetch(base + '/api/config')).json() as AppConfig;
|
|
64
|
+
const failed = await post('/api/test-model', { modelKey: key, model: { ...model, name: 'fail' }, save: true });
|
|
65
|
+
assert.equal(failed.status, 500);
|
|
66
|
+
assert.deepEqual(await (await fetch(base + '/api/config')).json(), before, 'failed test must leave all persisted config unchanged');
|
|
67
|
+
const passed = await post('/api/test-model', { modelKey: key, model, save: true });
|
|
68
|
+
assert.equal(passed.status, 200);
|
|
69
|
+
assert.equal((await passed.json() as { saved: boolean }).saved, true);
|
|
70
|
+
const after = await (await fetch(base + '/api/config')).json() as AppConfig;
|
|
71
|
+
assert.equal(after[key].model.name, 'pass');
|
|
72
|
+
for (const other of ['runtime', 'midscene', 'scriptOptimizer', 'appium'] as const) {
|
|
73
|
+
if (other !== key) assert.deepEqual(after[other], before[other]);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// A slow test must not revert an appearance edit saved while it was running.
|
|
77
|
+
const started = new Promise<void>(resolve => { modelStarted = resolve; });
|
|
78
|
+
const slowTest = post('/api/test-model', { modelKey: 'appium', model: { ...model, name: 'slow' }, save: true });
|
|
79
|
+
await started;
|
|
80
|
+
assert.equal((await post('/api/config/appium', { appium: { flowLineColor: '#123456' } })).status, 200);
|
|
81
|
+
releaseModel!();
|
|
82
|
+
assert.equal((await slowTest).status, 200);
|
|
83
|
+
// A stale automatic-save payload must also preserve the newly tested model.
|
|
84
|
+
await post('/api/config/appium', { appium: { ...appium, flowLineColor: '#123456' } });
|
|
85
|
+
await post('/api/config', { runtime: initial.runtime });
|
|
86
|
+
stored = await (await fetch(base + '/api/config')).json() as AppConfig;
|
|
87
|
+
assert.equal(stored.appium.model.name, 'slow');
|
|
88
|
+
assert.equal(stored.midscene.model.name, 'pass');
|
|
89
|
+
assert.equal(stored.appium.flowLineColor, '#123456');
|
|
90
|
+
assert.equal(loadModelConfigFromDb()?.appium?.model?.name, 'slow');
|
|
91
|
+
|
|
92
|
+
assert.equal(initial.appium.aiPromptPresets?.length, 4, 'older configurations receive default prompt presets');
|
|
93
|
+
await post('/api/config/appium', { appium: { aiPromptPresets: ['旧提示词内容'] } });
|
|
94
|
+
const migrated = await (await fetch(base + '/api/config')).json() as AppConfig;
|
|
95
|
+
assert.deepEqual(migrated.appium.aiPromptPresets, [{ name: '自定义场景 1', prompt: '旧提示词内容' }]);
|
|
96
|
+
const customPresets = [{ name: '广告检测', prompt: '当前画面是否出现广告?' }, { name: '标题提取', prompt: '请读取当前页面标题' }];
|
|
97
|
+
assert.equal((await post('/api/config/appium', { appium: { aiPromptPresets: customPresets } })).status, 200);
|
|
98
|
+
stored = await (await fetch(base + '/api/config')).json() as AppConfig;
|
|
99
|
+
assert.deepEqual(stored.appium.aiPromptPresets, customPresets);
|
|
100
|
+
assert.deepEqual(loadModelConfigFromDb()?.appium?.aiPromptPresets, customPresets);
|
|
101
|
+
for (const invalid of [null, 'prompt', [''], ['x'.repeat(4001)]]) {
|
|
102
|
+
assert.equal((await post('/api/config/appium', { appium: { aiPromptPresets: invalid } })).status, 400);
|
|
103
|
+
}
|
|
104
|
+
assert.deepEqual(loadModelConfigFromDb()?.appium?.aiPromptPresets, customPresets, 'invalid presets must not overwrite the saved list');
|
|
105
|
+
await post('/api/config/appium', { appium: { aiPromptPresets: [] } });
|
|
106
|
+
await post('/api/test-model', { modelKey: 'appium', model, save: true });
|
|
107
|
+
stored = await (await fetch(base + '/api/config')).json() as AppConfig;
|
|
108
|
+
assert.deepEqual(stored.appium.aiPromptPresets, [], 'deleting every preset must not restore defaults on subsequent saves');
|
|
109
|
+
assert.deepEqual(loadModelConfigFromDb()?.appium?.aiPromptPresets, []);
|
|
110
|
+
} finally {
|
|
111
|
+
releaseModel?.();
|
|
112
|
+
await new Promise<void>(resolve => modelServer.close(() => resolve()));
|
|
113
|
+
await new Promise<void>(resolve => server.close(() => resolve()));
|
|
114
|
+
if (oldRoot === undefined) delete process.env.ANDROID_MIDSCENE_DATA_ROOT;
|
|
115
|
+
else process.env.ANDROID_MIDSCENE_DATA_ROOT = oldRoot;
|
|
116
|
+
await rm(root, { recursive: true, force: true });
|
|
117
|
+
}
|
|
118
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Run: npx tsx --test server/appium-recorder/ai-deduplication.test.ts
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { test } from 'node:test';
|
|
4
|
+
import { PNG } from 'pngjs';
|
|
5
|
+
import { resolveAiDeduplication } from '../../src/appium-recorder/ai-deduplication';
|
|
6
|
+
import { createObservationDeduplicator } from './ai-deduplication';
|
|
7
|
+
import { collectAiObservation } from './ai-observation';
|
|
8
|
+
|
|
9
|
+
function screenshot(color: number[], width = 96, height = 128, patch = false) {
|
|
10
|
+
const png = new PNG({ width, height });
|
|
11
|
+
for (let i = 0; i < png.data.length; i += 4) png.data.set([...color, 255], i);
|
|
12
|
+
if (patch) png.data.set([255, 0, 0, 255], 0);
|
|
13
|
+
return PNG.sync.write(png).toString('base64');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const home = screenshot([30, 30, 30]), ad = screenshot([240, 240, 240]);
|
|
17
|
+
|
|
18
|
+
test('defaults and validation cover both active and inactive algorithms', () => {
|
|
19
|
+
assert.equal(resolveAiDeduplication().method, 'pixelmatch');
|
|
20
|
+
for (const value of [null, [], { method: 'fake' }, { pixelmatch: { threshold: NaN } },
|
|
21
|
+
{ pixelmatch: { maxChangedRatio: -1 } }, { opencv: { similarityThreshold: 1.1 } },
|
|
22
|
+
{ method: 'none', opencv: { maxDimension: 127 } }, { opencv: { maxDimension: 512.5 } }]) {
|
|
23
|
+
assert.throws(() => resolveAiDeduplication(value));
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
for (const method of ['pixelmatch', 'opencv', 'none'] as const) {
|
|
28
|
+
test(`${method}: preserve transient ad and transitions, never share state across observations`, async () => {
|
|
29
|
+
const config = resolveAiDeduplication({ method });
|
|
30
|
+
const keep = await createObservationDeduplicator(config);
|
|
31
|
+
const decisions = [];
|
|
32
|
+
for (const frame of [home, home, ad, ad, home]) decisions.push(await keep(frame));
|
|
33
|
+
assert.deepEqual(decisions, method === 'none' ? [true, true, true, true, true] : [true, false, true, false, true]);
|
|
34
|
+
assert.equal(await keep(screenshot([30, 30, 30], 128, 96)), true, 'rotation must be retained');
|
|
35
|
+
assert.equal(await (await createObservationDeduplicator(config))(home), true);
|
|
36
|
+
assert.equal(await keep(screenshot([255, 0, 0])), true);
|
|
37
|
+
assert.equal(await keep(screenshot([0, 130, 0])), true, 'different colors must not collapse into gray');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test(`${method}: full observation window survives deduplication down to one frame`, async () => {
|
|
41
|
+
const samples: number[] = [];
|
|
42
|
+
const result = await collectAiObservation({ durationMs: 1000, intervalMs: 500 }, async () => home,
|
|
43
|
+
new AbortController().signal, frame => { samples.push(frame.index); }, resolveAiDeduplication({ method }));
|
|
44
|
+
assert.ok(result.observation.durationMs >= 1000);
|
|
45
|
+
assert.ok(result.observation.sampleCount >= 2);
|
|
46
|
+
assert.equal(samples.length, result.observation.sampleCount);
|
|
47
|
+
assert.equal(result.observation.frames.length, method === 'none' ? samples.length : 1);
|
|
48
|
+
assert.equal(result.observation.deduplicationMethod, method);
|
|
49
|
+
assert.equal(result.imageBase64, home);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
test('pixelmatch parameters take effect and comparison uses the last retained frame', async () => {
|
|
54
|
+
const tolerant = await createObservationDeduplicator(resolveAiDeduplication());
|
|
55
|
+
await tolerant(home);
|
|
56
|
+
assert.equal(await tolerant(screenshot([30, 30, 30], 96, 128, true)), false);
|
|
57
|
+
const strict = await createObservationDeduplicator(resolveAiDeduplication({ pixelmatch: { threshold: 0, maxChangedRatio: 0 } }));
|
|
58
|
+
await strict(home);
|
|
59
|
+
assert.equal(await strict(screenshot([30, 30, 30], 96, 128, true)), true);
|
|
60
|
+
const gradual = await createObservationDeduplicator(resolveAiDeduplication({ pixelmatch: { threshold: 0.1, maxChangedRatio: 0 } }));
|
|
61
|
+
await gradual(screenshot([100, 100, 100]));
|
|
62
|
+
assert.equal(await gradual(screenshot([110, 110, 110])), false);
|
|
63
|
+
assert.equal(await gradual(screenshot([130, 130, 130])), true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('OpenCV SSIM threshold takes effect on non-identical frames', async () => {
|
|
67
|
+
const base = screenshot([100, 100, 100]), changed = screenshot([120, 120, 120]);
|
|
68
|
+
for (const [threshold, expected] of [[0.99, true], [0.95, false]] as const) {
|
|
69
|
+
const keep = await createObservationDeduplicator(resolveAiDeduplication({ method: 'opencv', opencv: { similarityThreshold: threshold, maxDimension: 128 } }));
|
|
70
|
+
await keep(base);
|
|
71
|
+
assert.equal(await keep(changed), expected);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('capture errors and cancellation stop observation rather than yielding an absence result', async () => {
|
|
76
|
+
await assert.rejects(collectAiObservation({ durationMs: 1000, intervalMs: 500 }, async () => { throw new Error('capture failed'); }, new AbortController().signal), /capture failed/);
|
|
77
|
+
const controller = new AbortController();
|
|
78
|
+
await assert.rejects(collectAiObservation({ durationMs: 1000, intervalMs: 500 }, async () => home,
|
|
79
|
+
controller.signal, () => { controller.abort(new Error('cancelled')); }), /cancelled/);
|
|
80
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import pixelmatch from 'pixelmatch';
|
|
2
|
+
import type { PNG } from 'pngjs';
|
|
3
|
+
import type { AiDeduplicationConfig } from '../../src/appium-recorder/ai-deduplication';
|
|
4
|
+
import { openCv, readImage } from './image-check';
|
|
5
|
+
|
|
6
|
+
// 彩色 SSIM:各通道均参与比较,避免灰度化丢失广告颜色变化。
|
|
7
|
+
// 使用 OpenCV 官方 SSIM 的 11×11 Gaussian 窗口和 C1/C2:
|
|
8
|
+
// https://docs.opencv.org/4.12.0/d5/dc4/tutorial_video_input_psnr_ssim.html
|
|
9
|
+
async function structuralSimilarity(first: PNG, second: PNG, maxDimension: number) {
|
|
10
|
+
const cv = await openCv();
|
|
11
|
+
const mats: InstanceType<typeof cv.Mat>[] = [];
|
|
12
|
+
const mat = () => { const value = new cv.Mat(); mats.push(value); return value; };
|
|
13
|
+
const scale = Math.min(1, maxDimension / Math.max(first.width, first.height));
|
|
14
|
+
const size = new cv.Size(Math.max(1, Math.round(first.width * scale)), Math.max(1, Math.round(first.height * scale)));
|
|
15
|
+
try {
|
|
16
|
+
const prepare = (image: PNG) => {
|
|
17
|
+
const raw = cv.matFromArray(image.height, image.width, cv.CV_8UC4, image.data);
|
|
18
|
+
mats.push(raw);
|
|
19
|
+
const rgb = mat(), resized = mat(), result = mat();
|
|
20
|
+
cv.cvtColor(raw, rgb, cv.COLOR_RGBA2RGB);
|
|
21
|
+
cv.resize(rgb, resized, size, 0, 0, cv.INTER_AREA);
|
|
22
|
+
resized.convertTo(result, cv.CV_32F);
|
|
23
|
+
return result;
|
|
24
|
+
};
|
|
25
|
+
const a = prepare(first), b = prepare(second);
|
|
26
|
+
const blur = (input: InstanceType<typeof cv.Mat>) => {
|
|
27
|
+
const result = mat();
|
|
28
|
+
cv.GaussianBlur(input, result, new cv.Size(11, 11), 1.5, 1.5, cv.BORDER_REFLECT);
|
|
29
|
+
return result;
|
|
30
|
+
};
|
|
31
|
+
const product = (x: InstanceType<typeof cv.Mat>, y: InstanceType<typeof cv.Mat>) => {
|
|
32
|
+
const result = mat(); cv.multiply(x, y, result); return result;
|
|
33
|
+
};
|
|
34
|
+
const meanAMat = blur(a), meanBMat = blur(b);
|
|
35
|
+
const squareAMat = blur(product(a, a)), squareBMat = blur(product(b, b));
|
|
36
|
+
const crossMat = blur(product(a, b));
|
|
37
|
+
const meanA = meanAMat.data32F, meanB = meanBMat.data32F;
|
|
38
|
+
const squareA = squareAMat.data32F, squareB = squareBMat.data32F, cross = crossMat.data32F;
|
|
39
|
+
let sum = 0;
|
|
40
|
+
for (let i = 0; i < meanA.length; i++) {
|
|
41
|
+
const x = meanA[i]!, y = meanB[i]!;
|
|
42
|
+
const varianceA = Math.max(0, squareA[i]! - x * x), varianceB = Math.max(0, squareB[i]! - y * y);
|
|
43
|
+
const covariance = cross[i]! - x * y;
|
|
44
|
+
sum += ((2 * x * y + 6.5025) * (2 * covariance + 58.5225))
|
|
45
|
+
/ ((x * x + y * y + 6.5025) * (varianceA + varianceB + 58.5225));
|
|
46
|
+
}
|
|
47
|
+
return Math.max(-1, Math.min(1, sum / meanA.length));
|
|
48
|
+
} finally { for (const value of mats.reverse()) value.delete(); }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 每次观察独立维护上一张保留帧;不与上一张被丢弃的帧比较,以免缓慢变化被连续吞掉。
|
|
52
|
+
export async function createObservationDeduplicator(config: AiDeduplicationConfig) {
|
|
53
|
+
if (config.method === 'opencv') await openCv();
|
|
54
|
+
let previous: PNG | undefined;
|
|
55
|
+
return async (imageBase64: string): Promise<boolean> => {
|
|
56
|
+
if (config.method === 'none') return true;
|
|
57
|
+
const current = readImage(Buffer.from(imageBase64, 'base64'));
|
|
58
|
+
let duplicate = false;
|
|
59
|
+
if (previous && previous.width === current.width && previous.height === current.height) {
|
|
60
|
+
if (previous.data.equals(current.data)) {
|
|
61
|
+
duplicate = true;
|
|
62
|
+
} else if (config.method === 'pixelmatch') {
|
|
63
|
+
const count = pixelmatch(previous.data, current.data, undefined, current.width, current.height,
|
|
64
|
+
{ threshold: config.pixelmatch.threshold, includeAA: true });
|
|
65
|
+
duplicate = count / (current.width * current.height) * 100 <= config.pixelmatch.maxChangedRatio;
|
|
66
|
+
} else {
|
|
67
|
+
const score = await structuralSimilarity(previous, current, config.opencv.maxDimension);
|
|
68
|
+
duplicate = score >= config.opencv.similarityThreshold;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (!duplicate) previous = current;
|
|
72
|
+
return !duplicate;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Run: npx tsx --test server/appium-recorder/ai-observation.test.ts
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { test } from 'node:test';
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
|
+
import { PNG } from 'pngjs';
|
|
6
|
+
import { observeUntilAiMatch } from './ai-observation';
|
|
7
|
+
import { resolveAiDeduplication } from '../../src/appium-recorder/ai-deduplication';
|
|
8
|
+
import { validateAiObservation } from '../../src/appium-recorder/ai-recognition';
|
|
9
|
+
|
|
10
|
+
function image(value: number) {
|
|
11
|
+
const png = new PNG({ width: 8, height: 8 });
|
|
12
|
+
png.data.fill(value);
|
|
13
|
+
for (let i = 3; i < png.data.length; i += 4) png.data[i] = 255;
|
|
14
|
+
return PNG.sync.write(png).toString('base64');
|
|
15
|
+
}
|
|
16
|
+
const home = image(30), ad = image(230);
|
|
17
|
+
const config = { durationMs: 2000, intervalMs: 500, mode: 'untilMatch' as const };
|
|
18
|
+
const signal = () => new AbortController().signal;
|
|
19
|
+
|
|
20
|
+
test('old observation settings remain batch-compatible; new mode validates', () => {
|
|
21
|
+
assert.deepEqual(validateAiObservation({ durationMs: 1000, intervalMs: 500 }), { durationMs: 1000, intervalMs: 500 });
|
|
22
|
+
assert.deepEqual(validateAiObservation(config), config);
|
|
23
|
+
assert.throws(() => validateAiObservation({ ...config, mode: 'invalid' }));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('first-frame match stops before the observation window and leaves no sampler running', async () => {
|
|
27
|
+
let captures = 0;
|
|
28
|
+
const result = await observeUntilAiMatch({ config, signal: signal(), capture: async () => { captures++; return ad; },
|
|
29
|
+
analyze: async () => ({ result: true, reason: '广告出现' }) });
|
|
30
|
+
assert.equal(result.result, true);
|
|
31
|
+
assert.equal(result.observation.completion, 'matched');
|
|
32
|
+
assert.equal(captures, 1);
|
|
33
|
+
assert.ok(result.observation.durationMs < config.durationMs);
|
|
34
|
+
await delay(550);
|
|
35
|
+
assert.equal(captures, 1);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('slow inference does not lose a transient frame, batches pending frames and never overlaps model requests', async () => {
|
|
39
|
+
let captures = 0, active = 0, maxActive = 0, calls = 0;
|
|
40
|
+
const result = await observeUntilAiMatch({ config, signal: signal(),
|
|
41
|
+
capture: async () => ++captures === 2 ? ad : home,
|
|
42
|
+
analyze: async frames => {
|
|
43
|
+
active++; maxActive = Math.max(maxActive, active); calls++;
|
|
44
|
+
try {
|
|
45
|
+
if (calls === 1) { await delay(1150); assert.ok(captures >= 3); return { result: false, reason: '首页' }; }
|
|
46
|
+
assert.equal(frames.length, 2, 'ad and return-home frames must both survive');
|
|
47
|
+
assert.notEqual(frames[0].imageDataUrl, frames[1].imageDataUrl);
|
|
48
|
+
return { result: true, reason: '第二次采样出现广告' };
|
|
49
|
+
} finally { active--; }
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
assert.equal(result.result, true);
|
|
53
|
+
assert.equal(maxActive, 1);
|
|
54
|
+
assert.equal(calls, 2);
|
|
55
|
+
assert.equal(result.observation.frames.length, 3);
|
|
56
|
+
assert.ok(result.observation.durationMs < 2000);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('no match observes the full window and drains pending frames after sampling stops', async () => {
|
|
60
|
+
let calls = 0;
|
|
61
|
+
const result = await observeUntilAiMatch({ config: { ...config, durationMs: 1000 }, signal: signal(),
|
|
62
|
+
deduplication: resolveAiDeduplication({ method: 'none' }), capture: async () => home,
|
|
63
|
+
analyze: async () => { calls++; if (calls === 1) await delay(1150); return { result: false, reason: '没有广告' }; },
|
|
64
|
+
});
|
|
65
|
+
assert.equal(result.result, false);
|
|
66
|
+
assert.equal(result.observation.completion, 'elapsed');
|
|
67
|
+
assert.equal(result.observation.frames.length, result.observation.sampleCount);
|
|
68
|
+
assert.equal(calls, 2);
|
|
69
|
+
assert.ok(result.observation.durationMs >= 1000);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('deduplication avoids repeating model requests for unchanged frames', async () => {
|
|
73
|
+
const result = await observeUntilAiMatch({ config: { ...config, durationMs: 1000 }, signal: signal(), capture: async () => home,
|
|
74
|
+
analyze: async () => ({ result: false, reason: '未命中' }) });
|
|
75
|
+
assert.equal(result.observation.modelRequestCount, 1);
|
|
76
|
+
assert.equal(result.observation.frames.length, 1);
|
|
77
|
+
assert.ok(result.observation.sampleCount >= 2);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('cancellation stops pending inference and sampling, errors never become false', async () => {
|
|
81
|
+
const controller = new AbortController();
|
|
82
|
+
let active = 0;
|
|
83
|
+
const run = observeUntilAiMatch({ config, signal: controller.signal, capture: async () => home,
|
|
84
|
+
analyze: async (_frames, signal) => {
|
|
85
|
+
active++;
|
|
86
|
+
try { controller.abort(new Error('user cancelled')); await delay(10000, undefined, { signal }); return { result: false, reason: '' }; }
|
|
87
|
+
finally { active--; }
|
|
88
|
+
} });
|
|
89
|
+
await assert.rejects(run, /user cancelled/);
|
|
90
|
+
assert.equal(active, 0);
|
|
91
|
+
await assert.rejects(observeUntilAiMatch({ config, signal: signal(), capture: async () => home,
|
|
92
|
+
analyze: async () => { throw new Error('model failed'); } }), /model failed/);
|
|
93
|
+
await assert.rejects(observeUntilAiMatch({ config, signal: signal(), capture: async () => home,
|
|
94
|
+
analyze: async () => ({ result: null, reason: '无法判断' }) }), /无法确定/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('screenshot failure aborts in-flight inference', async () => {
|
|
98
|
+
let captures = 0, active = 0;
|
|
99
|
+
await assert.rejects(observeUntilAiMatch({ config, signal: signal(),
|
|
100
|
+
capture: async () => { if (++captures === 2) throw new Error('capture failed'); return home; },
|
|
101
|
+
analyze: async (_frames, signal) => {
|
|
102
|
+
active++;
|
|
103
|
+
try { await delay(10000, undefined, { signal }); return { result: false, reason: '' }; }
|
|
104
|
+
finally { active--; }
|
|
105
|
+
},
|
|
106
|
+
}), /capture failed/);
|
|
107
|
+
assert.equal(active, 0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('a match cancels an in-flight screenshot without reporting a capture failure', async () => {
|
|
111
|
+
let captures = 0, captureActive = 0;
|
|
112
|
+
const result = await observeUntilAiMatch({ config, signal: signal(),
|
|
113
|
+
capture: async signal => {
|
|
114
|
+
if (++captures === 1) return ad;
|
|
115
|
+
captureActive++;
|
|
116
|
+
try { await delay(10000, undefined, { signal }); return home; }
|
|
117
|
+
finally { captureActive--; }
|
|
118
|
+
},
|
|
119
|
+
analyze: async () => { await delay(650); return { result: true, reason: '命中' }; },
|
|
120
|
+
});
|
|
121
|
+
assert.equal(result.result, true);
|
|
122
|
+
assert.equal(captures, 2);
|
|
123
|
+
assert.equal(captureActive, 0);
|
|
124
|
+
assert.equal(result.observation.sampleCount, 1);
|
|
125
|
+
});
|