android-midscene-automation 0.1.41 → 0.1.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/AI_RECOGNITION_GUIDE.md +370 -0
  2. package/CHANGELOG.md +16 -0
  3. package/README.md +35 -3
  4. package/USAGE.md +18 -3
  5. package/package.json +2 -1
  6. package/remote-agent/index.ts +1 -0
  7. package/server/appium-auto-save.test.ts +61 -0
  8. package/server/appium-config.test.ts +165 -0
  9. package/server/appium-recorder/ai-deduplication.test.ts +80 -0
  10. package/server/appium-recorder/ai-deduplication.ts +74 -0
  11. package/server/appium-recorder/ai-observation.test.ts +125 -0
  12. package/server/appium-recorder/ai-observation.ts +125 -0
  13. package/server/appium-recorder/ai-recognition.ts +118 -22
  14. package/server/appium-recorder/appium-runner.ts +79 -14
  15. package/server/appium-recorder/bounded-loop.test.ts +55 -0
  16. package/server/appium-recorder/bounded-loop.ts +12 -1
  17. package/server/appium-recorder/history-capture.ts +1 -0
  18. package/server/appium-recorder/image-check.ts +1 -1
  19. package/server/appium-recorder/managed-appium.ts +15 -0
  20. package/server/appium-recorder/managed-cleanup.test.ts +82 -0
  21. package/server/appium-recorder/prompt-optimizer.ts +57 -0
  22. package/server/appium-recorder/replay-log.ts +13 -0
  23. package/server/appium-recorder/report-summary.test.ts +78 -0
  24. package/server/appium-recorder/report-summary.ts +60 -0
  25. package/server/appium-recorder/report.ts +45 -14
  26. package/server/appium-recorder/repository.ts +7 -0
  27. package/server/appium-recorder/routes.ts +78 -4
  28. package/server/appium-recorder/screenshot.ts +1 -0
  29. package/server/config.ts +34 -4
  30. package/server/http-api.ts +31 -5
  31. package/server/model-tester.ts +2 -1
  32. package/src/App.vue +53 -30
  33. package/src/api.ts +11 -4
  34. package/src/appium-recorder/AppiumPage.vue +167 -68
  35. package/src/appium-recorder/RunHistoryDialog.vue +1 -1
  36. package/src/appium-recorder/action-descriptions.ts +2 -1
  37. package/src/appium-recorder/ai-deduplication.ts +34 -0
  38. package/src/appium-recorder/ai-prompt-presets.ts +34 -0
  39. package/src/appium-recorder/ai-recognition.ts +27 -2
  40. package/src/appium-recorder/api.ts +36 -2
  41. package/src/appium-recorder/bounded-loop.ts +9 -1
  42. package/src/appium-recorder/components/AiRecognitionPromptInput.vue +41 -0
  43. package/src/appium-recorder/components/AiRecognitionSettings.vue +167 -0
  44. package/src/appium-recorder/components/AiRecognitionTestDialog.vue +86 -0
  45. package/src/appium-recorder/components/BreakLoopSettings.vue +19 -3
  46. package/src/appium-recorder/components/ComponentTree.vue +5 -5
  47. package/src/appium-recorder/components/CoordinatePointPicker.vue +23 -0
  48. package/src/appium-recorder/components/FlowActionMenu.vue +50 -20
  49. package/src/appium-recorder/components/FlowCanvas.vue +3 -2
  50. package/src/appium-recorder/components/FlowNodeCard.vue +2 -2
  51. package/src/appium-recorder/components/FlowStepEditor.vue +17 -3
  52. package/src/appium-recorder/components/NestedConditionBranches.vue +25 -50
  53. package/src/appium-recorder/components/RecordedSteps.vue +23 -6
  54. package/src/appium-recorder/coordinate-preview.ts +37 -0
  55. package/src/appium-recorder/flow-appearance.ts +12 -1
  56. package/src/appium-recorder/flow-copy.ts +1 -0
  57. package/src/appium-recorder/flow-graph.ts +7 -4
  58. package/src/appium-recorder/flow-labels.ts +9 -5
  59. package/src/appium-recorder/flow-merge.ts +1 -1
  60. package/src/appium-recorder/flow-unmerge.ts +2 -2
  61. package/src/appium-recorder/report-summary.ts +60 -0
  62. package/src/appium-recorder/types.ts +6 -0
  63. package/src/components/config/AiDeduplicationSettings.vue +60 -0
  64. package/src/components/config/AiPromptPresetsSettings.vue +63 -0
  65. package/src/components/config/AppiumConfigPanel.vue +76 -3
  66. package/src/components/config/AppiumModelSettings.vue +45 -0
  67. package/src/components/config/BaseConfigPanel.vue +20 -8
  68. package/src/components/config/FlowAppearanceSettings.vue +51 -32
  69. package/src/components/config/MidsceneConfigPanel.vue +84 -86
  70. package/src/components/config/ReplayReportSummarySettings.vue +118 -0
  71. package/src/components/device/DevicePreviewPanel.vue +18 -3
  72. package/src/config/midscene-model-presets.ts +37 -9
  73. package/src/config/use-appium-auto-save.ts +62 -0
  74. package/src/pages/ConfigPage.vue +8 -6
  75. package/src/style.css +5 -55
  76. package/src/types.ts +4 -1
  77. package/src/ui-refresh.css +17 -4
@@ -0,0 +1,165 @@
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: [{ finish_reason: 'stop', 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
+ assert.equal((await post('/api/appium-recorder/prompts/optimize', {
77
+ kind: 'aiRecognition', prompt: '检查有没有广告', condition: true,
78
+ })).status, 500, 'prompt optimization requires its own tested model');
79
+ const unavailableSummaryModel = await post('/api/appium-recorder/report-summary/check', {});
80
+ assert.equal(unavailableSummaryModel.status, 200);
81
+ const unavailableSummaryStatus = await unavailableSummaryModel.json() as { available: boolean; message?: string };
82
+ assert.equal(unavailableSummaryStatus.available, false);
83
+ assert.equal(unavailableSummaryStatus.message, '模型配置不完整');
84
+ const promptOptimizerBefore = await (await fetch(base + '/api/config')).json() as AppConfig;
85
+ assert.equal((await post('/api/test-model', { modelKey: 'promptOptimizer', model: { ...model, name: 'fail' }, save: true })).status, 500);
86
+ assert.deepEqual(await (await fetch(base + '/api/config')).json(), promptOptimizerBefore);
87
+ assert.equal((await post('/api/test-model', { modelKey: 'promptOptimizer', model, save: true })).status, 200);
88
+ stored = await (await fetch(base + '/api/config')).json() as AppConfig;
89
+ assert.equal(stored.appium.promptOptimizer?.model.name, 'pass');
90
+ const optimizedRecognition = await post('/api/appium-recorder/prompts/optimize', {
91
+ kind: 'aiRecognition', prompt: '检查有没有广告', condition: true,
92
+ });
93
+ assert.equal(optimizedRecognition.status, 200);
94
+ assert.deepEqual(await optimizedRecognition.json(), { prompt: 'OK' });
95
+ const optimizedSummary = await post('/api/appium-recorder/prompts/optimize', {
96
+ kind: 'reportSummary', prompt: '总结出图时间',
97
+ });
98
+ assert.equal(optimizedSummary.status, 200);
99
+ assert.deepEqual(await optimizedSummary.json(), { prompt: 'OK' });
100
+ // Summary settings auto-save independently and reuse the prompt optimizer model.
101
+ const summaryBefore = await (await fetch(base + '/api/config')).json() as AppConfig;
102
+ assert.equal(summaryBefore.appium.reportSummary?.enabled, false);
103
+ const customReportPreset = { name: '支付流程', prompt: '输出支付流程的步骤和结果' };
104
+ assert.equal((await post('/api/config/appium', { appium: { reportSummary: {
105
+ enabled: true, prompt: '输出用例表格', customPresets: [customReportPreset], model,
106
+ } } })).status, 200);
107
+ let summaryConfig = await (await fetch(base + '/api/config')).json() as AppConfig;
108
+ assert.equal(summaryConfig.appium.reportSummary?.prompt, '输出用例表格');
109
+ assert.deepEqual(summaryConfig.appium.reportSummary?.customPresets, [customReportPreset]);
110
+ assert.equal('model' in summaryConfig.appium.reportSummary!, false);
111
+ assert.equal(summaryConfig.appium.promptOptimizer?.model.name, 'pass');
112
+ const summaryModelStatus = await post('/api/appium-recorder/report-summary/check', {});
113
+ assert.equal(summaryModelStatus.status, 200);
114
+ assert.deepEqual(await summaryModelStatus.json(), { enabled: true, available: true });
115
+ await post('/api/config/appium', { appium: { reportSummary: { enabled: false, prompt: '恢复测试格式', customPresets: [customReportPreset], model: { name: 'stale' } } } });
116
+ summaryConfig = await (await fetch(base + '/api/config')).json() as AppConfig;
117
+ assert.equal('model' in summaryConfig.appium.reportSummary!, false);
118
+ assert.deepEqual(summaryConfig.appium.reportSummary?.customPresets, [customReportPreset]);
119
+ assert.equal(summaryConfig.appium.promptOptimizer?.model.name, 'pass');
120
+ assert.deepEqual(summaryConfig.appium.model, summaryBefore.appium.model);
121
+ assert.equal((await post('/api/config/appium', { appium: { reportSummary: { prompt: '' } } })).status, 400);
122
+ // A slow test must not revert an appearance edit saved while it was running.
123
+ const started = new Promise<void>(resolve => { modelStarted = resolve; });
124
+ const slowTest = post('/api/test-model', { modelKey: 'appium', model: { ...model, name: 'slow' }, save: true });
125
+ await started;
126
+ assert.equal((await post('/api/config/appium', { appium: { flowLineColor: '#123456' } })).status, 200);
127
+ releaseModel!();
128
+ assert.equal((await slowTest).status, 200);
129
+ // A stale automatic-save payload must also preserve the newly tested model.
130
+ await post('/api/config/appium', { appium: { ...appium, flowLineColor: '#123456' } });
131
+ await post('/api/config', { runtime: initial.runtime });
132
+ stored = await (await fetch(base + '/api/config')).json() as AppConfig;
133
+ assert.equal(stored.appium.model.name, 'slow');
134
+ assert.equal(stored.appium.promptOptimizer?.model.name, 'pass');
135
+ assert.equal(stored.midscene.model.name, 'pass');
136
+ assert.equal(stored.appium.flowLineColor, '#123456');
137
+ assert.equal(loadModelConfigFromDb()?.appium?.model?.name, 'slow');
138
+
139
+ assert.equal(initial.appium.aiPromptPresets?.length, 4, 'older configurations receive default prompt presets');
140
+ await post('/api/config/appium', { appium: { aiPromptPresets: ['旧提示词内容'] } });
141
+ const migrated = await (await fetch(base + '/api/config')).json() as AppConfig;
142
+ assert.deepEqual(migrated.appium.aiPromptPresets, [{ name: '自定义场景 1', prompt: '旧提示词内容' }]);
143
+ const customPresets = [{ name: '广告检测', prompt: '当前画面是否出现广告?' }, { name: '标题提取', prompt: '请读取当前页面标题' }];
144
+ assert.equal((await post('/api/config/appium', { appium: { aiPromptPresets: customPresets } })).status, 200);
145
+ stored = await (await fetch(base + '/api/config')).json() as AppConfig;
146
+ assert.deepEqual(stored.appium.aiPromptPresets, customPresets);
147
+ assert.deepEqual(loadModelConfigFromDb()?.appium?.aiPromptPresets, customPresets);
148
+ for (const invalid of [null, 'prompt', [''], ['x'.repeat(4001)]]) {
149
+ assert.equal((await post('/api/config/appium', { appium: { aiPromptPresets: invalid } })).status, 400);
150
+ }
151
+ assert.deepEqual(loadModelConfigFromDb()?.appium?.aiPromptPresets, customPresets, 'invalid presets must not overwrite the saved list');
152
+ await post('/api/config/appium', { appium: { aiPromptPresets: [] } });
153
+ await post('/api/test-model', { modelKey: 'appium', model, save: true });
154
+ stored = await (await fetch(base + '/api/config')).json() as AppConfig;
155
+ assert.deepEqual(stored.appium.aiPromptPresets, [], 'deleting every preset must not restore defaults on subsequent saves');
156
+ assert.deepEqual(loadModelConfigFromDb()?.appium?.aiPromptPresets, []);
157
+ } finally {
158
+ releaseModel?.();
159
+ await new Promise<void>(resolve => modelServer.close(() => resolve()));
160
+ await new Promise<void>(resolve => server.close(() => resolve()));
161
+ if (oldRoot === undefined) delete process.env.ANDROID_MIDSCENE_DATA_ROOT;
162
+ else process.env.ANDROID_MIDSCENE_DATA_ROOT = oldRoot;
163
+ await rm(root, { recursive: true, force: true });
164
+ }
165
+ });
@@ -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
+ });
@@ -0,0 +1,125 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
2
+ import { constrainBase64ImageToMaxSize, convertBase64ImageToJpeg } from '@midscene/shared/img';
3
+ import { MAX_AI_OBSERVATION_FRAMES, type AiObservationConfig, type AiObservationFrame } from '../../src/appium-recorder/ai-recognition';
4
+ import { resolveAiDeduplication, type AiDeduplicationConfig } from '../../src/appium-recorder/ai-deduplication';
5
+ import { createObservationDeduplicator } from './ai-deduplication';
6
+
7
+ export type ObservationCapture = { imageBase64: string; elapsedMs: number; index: number };
8
+
9
+ // 采集不依赖模型响应;按实际采样时刻记录,慢设备串行截图,避免积压 ADB 请求。
10
+ export async function collectAiObservation(
11
+ config: AiObservationConfig,
12
+ capture: (signal: AbortSignal) => Promise<string>,
13
+ signal: AbortSignal,
14
+ onFrame?: (frame: ObservationCapture) => Promise<void> | void,
15
+ deduplication: AiDeduplicationConfig = resolveAiDeduplication(),
16
+ streaming?: { stopSignal: AbortSignal; onRetainedFrame: (frame: AiObservationFrame) => void },
17
+ ) {
18
+ signal.throwIfAborted();
19
+ const shouldKeep = await createObservationDeduplicator(deduplication);
20
+ signal.throwIfAborted();
21
+ const startedAt = performance.now();
22
+ const frames: AiObservationFrame[] = [];
23
+ let imageBase64 = '';
24
+ let bytes = 0;
25
+ let sampleCount = 0;
26
+ const activeSignal = streaming ? AbortSignal.any([signal, streaming.stopSignal]) : signal;
27
+ try {
28
+ while (true) {
29
+ activeSignal.throwIfAborted();
30
+ const captureStarted = performance.now();
31
+ imageBase64 = await capture(activeSignal);
32
+ activeSignal.throwIfAborted();
33
+ const elapsedMs = Math.round(performance.now() - startedAt);
34
+ sampleCount++;
35
+ let retainedFrame: AiObservationFrame | undefined;
36
+ if (await shouldKeep(imageBase64)) {
37
+ const bounded = await constrainBase64ImageToMaxSize(`data:image/png;base64,${imageBase64}`, { maxSize: 1600 });
38
+ const imageDataUrl = await convertBase64ImageToJpeg(bounded, 85);
39
+ bytes += imageDataUrl.length;
40
+ if (bytes > 24 * 1024 * 1024) throw new Error('观察画面超过 24 MB,请缩短观察时长或增大采样间隔');
41
+ retainedFrame = { elapsedMs, imageDataUrl };
42
+ frames.push(retainedFrame);
43
+ }
44
+ activeSignal.throwIfAborted();
45
+ await onFrame?.({ imageBase64, elapsedMs, index: sampleCount });
46
+ activeSignal.throwIfAborted();
47
+ if (retainedFrame) streaming?.onRetainedFrame(retainedFrame);
48
+ const now = performance.now();
49
+ if (now - startedAt >= config.durationMs) break;
50
+ if (sampleCount >= MAX_AI_OBSERVATION_FRAMES) throw new Error('观察帧数已达上限,尚未覆盖完整观察时长');
51
+ const nextCapture = Math.min(startedAt + config.durationMs, captureStarted + config.intervalMs);
52
+ await delay(Math.max(0, Math.ceil(nextCapture - now)), undefined, { signal: activeSignal });
53
+ }
54
+ } catch (error) {
55
+ signal.throwIfAborted();
56
+ const stopped = streaming?.stopSignal;
57
+ if (!stopped?.aborted || (error !== stopped.reason && !(error instanceof Error && error.name === 'AbortError'))) throw error;
58
+ }
59
+ if (sampleCount < 2 && !streaming?.stopSignal.aborted) throw new Error('设备截图过慢,观察期间不足 2 帧,请增加观察时长');
60
+ return { imageBase64, observation: { durationMs: Math.round(performance.now() - startedAt), frames, sampleCount, deduplicationMethod: deduplication.method } };
61
+ }
62
+
63
+ // 采样和识别并行;一次只发一个请求,期间积累的关键帧在下一次一起判断,避免丢掉短暂画面。
64
+ export async function observeUntilAiMatch(input: {
65
+ config: AiObservationConfig;
66
+ capture: (signal: AbortSignal) => Promise<string>;
67
+ signal: AbortSignal;
68
+ deduplication?: AiDeduplicationConfig;
69
+ onFrame?: (frame: ObservationCapture) => Promise<void> | void;
70
+ onProgress?: (message: string) => void;
71
+ analyze: (frames: AiObservationFrame[], signal: AbortSignal) => Promise<{ result: boolean | null; reason: string }>;
72
+ }) {
73
+ const stopCapture = new AbortController(), work = new AbortController();
74
+ const signal = AbortSignal.any([input.signal, work.signal]);
75
+ const pending: AiObservationFrame[] = [], submitted: AiObservationFrame[] = [];
76
+ let wake: (() => void) | undefined;
77
+ let captureDone = false, failure: unknown, failed = false, modelRequestCount = 0;
78
+ let match: { result: boolean; reason: string } | undefined;
79
+ const consumer = (async () => {
80
+ while (true) {
81
+ signal.throwIfAborted();
82
+ if (!pending.length) {
83
+ if (captureDone) return;
84
+ await new Promise<void>(resolve => { wake = resolve; });
85
+ continue;
86
+ }
87
+ const frames = pending.splice(0);
88
+ submitted.push(...frames);
89
+ modelRequestCount++;
90
+ input.onProgress?.(`第 ${modelRequestCount} 次检测:提交 ${frames.length} 张新关键帧`);
91
+ const result = await input.analyze(frames, signal);
92
+ signal.throwIfAborted();
93
+ if (typeof result.result !== 'boolean') throw new Error('AI 无法确定是否命中条件,已停止观察');
94
+ if (result.result) {
95
+ match = { result: true, reason: result.reason };
96
+ stopCapture.abort();
97
+ input.onProgress?.('检测命中,停止观察并结束当前节点');
98
+ return;
99
+ }
100
+ input.onProgress?.(`第 ${modelRequestCount} 次检测未命中,继续观察或检查剩余关键帧`);
101
+ }
102
+ })().catch(error => { failure = error; failed = true; work.abort(error); });
103
+ try {
104
+ const collected = await collectAiObservation(input.config, input.capture, signal, input.onFrame, input.deduplication, {
105
+ stopSignal: stopCapture.signal,
106
+ onRetainedFrame: frame => { pending.push(frame); wake?.(); },
107
+ });
108
+ captureDone = true;
109
+ wake?.();
110
+ if (!match) input.onProgress?.('已到最长观察时间,等待剩余关键帧识别完成');
111
+ await consumer;
112
+ if (failed) throw failure;
113
+ input.signal.throwIfAborted();
114
+ return {
115
+ ...collected,
116
+ ...(match ?? { result: false, reason: '观察时段内未命中条件,所有保留的关键帧均已判断。' }),
117
+ observation: { ...collected.observation, frames: submitted, modelRequestCount, completion: match ? 'matched' as const : 'elapsed' as const },
118
+ };
119
+ } finally {
120
+ captureDone = true;
121
+ work.abort();
122
+ wake?.();
123
+ await consumer;
124
+ }
125
+ }