android-midscene-automation 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +160 -0
  2. package/bin/android-midscene-automation.js +27 -0
  3. package/index.html +12 -0
  4. package/package.json +49 -0
  5. package/remote-agent/index.ts +206 -0
  6. package/server/appium-recorder/appium-runner.ts +427 -0
  7. package/server/appium-recorder/repository.ts +228 -0
  8. package/server/appium-recorder/routes.ts +219 -0
  9. package/server/config-store.ts +167 -0
  10. package/server/config.ts +130 -0
  11. package/server/device-locks/repository.ts +147 -0
  12. package/server/device-locks/service.ts +72 -0
  13. package/server/device-locks/types.ts +22 -0
  14. package/server/device-sessions/repository.ts +169 -0
  15. package/server/device-sessions/service.ts +59 -0
  16. package/server/device-sessions/types.ts +24 -0
  17. package/server/http-api.ts +1389 -0
  18. package/server/model-call-usage-importer.ts +108 -0
  19. package/server/model-tester.ts +104 -0
  20. package/server/model-usage-repository.ts +131 -0
  21. package/server/operations/repository.ts +218 -0
  22. package/server/operations/service.ts +84 -0
  23. package/server/operations/types.ts +27 -0
  24. package/server/paths.ts +27 -0
  25. package/server/remote-agents/protocol.ts +38 -0
  26. package/server/remote-agents/registry.ts +136 -0
  27. package/server/remote-agents/routes.ts +89 -0
  28. package/server/script-agent.ts +284 -0
  29. package/server/script-db.ts +281 -0
  30. package/server/script-runner.ts +551 -0
  31. package/server/storage/sqlite.ts +49 -0
  32. package/server/test-case-import/formatter.ts +28 -0
  33. package/server/test-case-import/parsers/excel.ts +69 -0
  34. package/server/test-case-import/parsers/txt.ts +11 -0
  35. package/server/test-case-import/parsers/word.ts +9 -0
  36. package/server/test-case-import/service.ts +58 -0
  37. package/server/test-case-import/text-normalizer.ts +98 -0
  38. package/server/test-case-import/types.ts +24 -0
  39. package/server/test-case-import/validator.ts +34 -0
  40. package/src/App.vue +1450 -0
  41. package/src/api.ts +290 -0
  42. package/src/appium-recorder/AppiumPage.vue +894 -0
  43. package/src/appium-recorder/api.ts +64 -0
  44. package/src/appium-recorder/components/ComponentTree.vue +44 -0
  45. package/src/appium-recorder/components/NodeDetail.vue +152 -0
  46. package/src/appium-recorder/components/RecordedSteps.vue +79 -0
  47. package/src/appium-recorder/tree.ts +129 -0
  48. package/src/appium-recorder/types.ts +88 -0
  49. package/src/assets/device-actions/back.svg +5 -0
  50. package/src/assets/device-actions/home.svg +3 -0
  51. package/src/assets/device-actions/power.svg +5 -0
  52. package/src/assets/device-actions/tasks.svg +3 -0
  53. package/src/assets/device-actions/volume-down.svg +3 -0
  54. package/src/assets/device-actions/volume-up.svg +3 -0
  55. package/src/components/config/ModelUsageChart.vue +188 -0
  56. package/src/components/device/DevicePreviewPanel.vue +266 -0
  57. package/src/components/generator/GeneratedCodePanel.vue +70 -0
  58. package/src/components/generator/TestCaseFileUpload.vue +97 -0
  59. package/src/config/midscene-model-presets.ts +75 -0
  60. package/src/config/prompt-example.ts +6 -0
  61. package/src/main.ts +7 -0
  62. package/src/pages/AiGeneratorPage.vue +90 -0
  63. package/src/pages/AutomationPage.vue +161 -0
  64. package/src/pages/ConfigPage.vue +273 -0
  65. package/src/pages/GeneratorPage.vue +97 -0
  66. package/src/pages/ManualStepsPage.vue +179 -0
  67. package/src/script-generator/codegen.ts +126 -0
  68. package/src/script-generator/index.ts +4 -0
  69. package/src/script-generator/presets.ts +37 -0
  70. package/src/script-generator/step-options.ts +52 -0
  71. package/src/script-generator/types.ts +27 -0
  72. package/src/style.css +1983 -0
  73. package/src/types.ts +157 -0
  74. package/src/vite-env.d.ts +1 -0
  75. package/tsconfig.app.json +8 -0
  76. package/tsconfig.json +11 -0
  77. package/tsconfig.node.json +16 -0
  78. package/vite.config.ts +28 -0
@@ -0,0 +1,64 @@
1
+ import { APP_BASE } from '../api';
2
+ import type { AppiumRecordedScript, AppiumRecordedStep } from './types';
3
+
4
+ async function readJson<T>(response: Response) {
5
+ const payload = (await response.json().catch(() => ({}))) as T & {
6
+ message?: string;
7
+ output?: string;
8
+ error?: string;
9
+ };
10
+ if (!response.ok) {
11
+ throw new Error(payload.output || payload.message || payload.error || `请求失败(HTTP ${response.status})`);
12
+ }
13
+ return payload;
14
+ }
15
+
16
+ function postJson<T>(url: string, body?: unknown) {
17
+ return fetch(url, {
18
+ method: 'POST',
19
+ headers: { 'Content-Type': 'application/json' },
20
+ body: JSON.stringify(body || {}),
21
+ }).then((response) => readJson<T>(response));
22
+ }
23
+
24
+ export async function getAppiumTree(deviceId: string) {
25
+ const response = await fetch(`${APP_BASE}/api/appium-recorder/tree?deviceId=${encodeURIComponent(deviceId)}`);
26
+ return readJson<{ deviceId: string; xml: string; activity: string; dumpedAt: string }>(response);
27
+ }
28
+
29
+ export function tapAppiumDevice(input: { deviceId: string; x: number; y: number }) {
30
+ return postJson<{ success: boolean }>(`${APP_BASE}/api/appium-recorder/tap`, input);
31
+ }
32
+
33
+ export function pressAppiumDeviceKey(input: { deviceId: string; keyCode: number }) {
34
+ return postJson<{ success: boolean }>(`${APP_BASE}/api/appium-recorder/key`, input);
35
+ }
36
+
37
+ export async function getAppiumScripts() {
38
+ const response = await fetch(`${APP_BASE}/api/appium-recorder/scripts`);
39
+ return readJson<{ scripts: AppiumRecordedScript[] }>(response);
40
+ }
41
+
42
+ export function saveAppiumScript(input: {
43
+ id?: string;
44
+ name: string;
45
+ appPackage: string;
46
+ appActivity?: string;
47
+ deviceId?: string;
48
+ steps: AppiumRecordedStep[];
49
+ }) {
50
+ return postJson<{ script: AppiumRecordedScript }>(`${APP_BASE}/api/appium-recorder/scripts`, input);
51
+ }
52
+
53
+ export function deleteAppiumScript(id: string) {
54
+ return fetch(`${APP_BASE}/api/appium-recorder/scripts/${encodeURIComponent(id)}`, {
55
+ method: 'DELETE',
56
+ }).then((response) => readJson<{ success: boolean }>(response));
57
+ }
58
+
59
+ export function replayAppiumScript(input: { id: string; deviceId?: string }) {
60
+ return postJson<{ success: boolean; output: string }>(
61
+ `${APP_BASE}/api/appium-recorder/scripts/${encodeURIComponent(input.id)}/replay`,
62
+ { deviceId: input.deviceId },
63
+ );
64
+ }
@@ -0,0 +1,44 @@
1
+ <script setup lang="ts">
2
+ import { nextTick, ref, watch } from 'vue';
3
+ import type { AppiumNode } from '../types';
4
+
5
+ const props = defineProps<{
6
+ tree: AppiumNode | null;
7
+ selectedId: string;
8
+ }>();
9
+
10
+ defineEmits<{
11
+ select: [node: AppiumNode];
12
+ }>();
13
+
14
+ const containerRef = ref<HTMLElement | null>(null);
15
+ const treeRef = ref<{ setCurrentKey: (key: string) => void } | null>(null);
16
+
17
+ watch(
18
+ () => props.selectedId,
19
+ async () => {
20
+ treeRef.value?.setCurrentKey(props.selectedId);
21
+ await nextTick();
22
+ containerRef.value
23
+ ?.querySelector('.el-tree-node.is-current')
24
+ ?.scrollIntoView({ block: 'center', inline: 'nearest' });
25
+ },
26
+ );
27
+ </script>
28
+
29
+ <template>
30
+ <div ref="containerRef" class="appium-tree">
31
+ <el-empty v-if="!tree" description="暂无组件树" />
32
+ <el-tree
33
+ v-else
34
+ ref="treeRef"
35
+ :data="[tree]"
36
+ node-key="id"
37
+ :props="{ label: 'label', children: 'children' }"
38
+ :current-node-key="selectedId"
39
+ highlight-current
40
+ default-expand-all
41
+ @node-click="$emit('select', $event)"
42
+ />
43
+ </div>
44
+ </template>
@@ -0,0 +1,152 @@
1
+ <script setup lang="ts">
2
+ import type { AppiumNode } from '../types';
3
+ import tapIcon from '../../../demo/icon-share-master/crosshair.svg';
4
+ import inputIcon from '../../../demo/icon-share-master/text.svg';
5
+ import assertIcon from '../../../demo/icon-share-master/select.svg';
6
+ import backIcon from '../../../demo/icon-share-master/back.svg';
7
+ import homeIcon from '../../../demo/icon-share-master/phone.svg';
8
+ import activityIcon from '../../../demo/icon-share-master/browser.svg';
9
+ import swipeIcon from '../../../demo/icon-share-master/bidirection.svg';
10
+ import clearIcon from '../../../demo/icon-share-master/clear.svg';
11
+ import coordinateIcon from '../../../demo/icon-share-master/pin.svg';
12
+ import launchIcon from '../../../demo/icon-share-master/android.svg';
13
+ import disappearIcon from '../../../demo/icon-share-master/eye.svg';
14
+ import powerIcon from '../../../demo/icon-share-master/power.svg';
15
+ import tasksIcon from '../../../demo/icon-share-master/sidebar.svg';
16
+ import longPressIcon from '../../../demo/icon-share-master/pin.svg';
17
+ import pinchIcon from '../../../demo/icon-share-master/zoom.svg';
18
+ import delayIcon from '../../../demo/icon-share-master/pause.svg';
19
+ import addIcon from '../../../demo/icon-share-master/add.svg';
20
+
21
+ type RecorderAction =
22
+ | 'delay'
23
+ | 'tap'
24
+ | 'input'
25
+ | 'assertExists'
26
+ | 'keyBack'
27
+ | 'keyHome'
28
+ | 'keyRecent'
29
+ | 'keyPower'
30
+ | 'waitActivity'
31
+ | 'swipe'
32
+ | 'clearInput'
33
+ | 'coordinateTap'
34
+ | 'launchApp'
35
+ | 'waitDisappear'
36
+ | 'assertText'
37
+ | 'longPress'
38
+ | 'pinch';
39
+
40
+ type ActionItem = {
41
+ type: RecorderAction;
42
+ label: string;
43
+ icon: string;
44
+ requiresNode: boolean;
45
+ };
46
+
47
+ const actionGroups: Array<{ title: string; actions: ActionItem[] }> = [
48
+ {
49
+ title: '组件操作',
50
+ actions: [
51
+ { type: 'tap', label: '录制点击', icon: tapIcon, requiresNode: true },
52
+ { type: 'input', label: '录制输入', icon: inputIcon, requiresNode: true },
53
+ { type: 'clearInput', label: '清空输入', icon: clearIcon, requiresNode: true },
54
+ { type: 'coordinateTap', label: '点击坐标', icon: coordinateIcon, requiresNode: true },
55
+ { type: 'longPress', label: '长按', icon: longPressIcon, requiresNode: true },
56
+ ],
57
+ },
58
+ {
59
+ title: '设备操作',
60
+ actions: [
61
+ { type: 'keyBack', label: '返回键', icon: backIcon, requiresNode: false },
62
+ { type: 'keyHome', label: 'Home 键', icon: homeIcon, requiresNode: false },
63
+ { type: 'keyRecent', label: '最近任务', icon: tasksIcon, requiresNode: false },
64
+ { type: 'keyPower', label: '电源键', icon: powerIcon, requiresNode: false },
65
+ { type: 'swipe', label: '滑动', icon: swipeIcon, requiresNode: false },
66
+ { type: 'pinch', label: '双指缩放', icon: pinchIcon, requiresNode: false },
67
+ { type: 'launchApp', label: '启动 App', icon: launchIcon, requiresNode: false },
68
+ ],
69
+ },
70
+ {
71
+ title: '等待断言',
72
+ actions: [
73
+ { type: 'delay', label: '添加延时', icon: delayIcon, requiresNode: false },
74
+ { type: 'assertExists', label: '断言存在', icon: assertIcon, requiresNode: true },
75
+ { type: 'assertText', label: '断言文本', icon: inputIcon, requiresNode: true },
76
+ { type: 'waitDisappear', label: '等待元素消失', icon: disappearIcon, requiresNode: true },
77
+ { type: 'waitActivity', label: '等待 Activity', icon: activityIcon, requiresNode: false },
78
+ ],
79
+ },
80
+ ];
81
+
82
+ defineProps<{
83
+ node: AppiumNode | null;
84
+ disabled?: boolean;
85
+ loading?: boolean;
86
+ }>();
87
+
88
+ const emit = defineEmits<{
89
+ addAction: [type: RecorderAction];
90
+ }>();
91
+
92
+ function selectAction(command: string | number | object) {
93
+ emit('addAction', command as RecorderAction);
94
+ }
95
+ </script>
96
+
97
+ <template>
98
+ <div
99
+ v-loading="loading"
100
+ element-loading-text="正在检测页面变化..."
101
+ class="appium-node-detail"
102
+ >
103
+ <template v-if="node">
104
+ <div class="appium-node-detail__title">{{ node.label }}</div>
105
+ <el-descriptions :column="1" border size="small">
106
+ <el-descriptions-item label="resource-id">{{ node.resourceId || '-' }}</el-descriptions-item>
107
+ <el-descriptions-item label="content-desc">{{ node.contentDesc || '-' }}</el-descriptions-item>
108
+ <el-descriptions-item label="text">{{ node.text || '-' }}</el-descriptions-item>
109
+ <el-descriptions-item label="class">{{ node.className || '-' }}</el-descriptions-item>
110
+ <el-descriptions-item label="selector">{{ node.selector.strategy }} {{ node.selector.value || '' }}</el-descriptions-item>
111
+ <el-descriptions-item label="bounds">
112
+ <span v-if="node.bounds">
113
+ [{{ node.bounds.left }},{{ node.bounds.top }}][{{ node.bounds.right }},{{ node.bounds.bottom }}]
114
+ </span>
115
+ <span v-else>-</span>
116
+ </el-descriptions-item>
117
+ </el-descriptions>
118
+ </template>
119
+ <el-empty v-else description="请选择组件" />
120
+
121
+ <el-dropdown
122
+ trigger="click"
123
+ :disabled="disabled"
124
+ max-height="320px"
125
+ popper-class="appium-action-dropdown"
126
+ @command="selectAction"
127
+ >
128
+ <el-button class="appium-action-trigger" :disabled="disabled">
129
+ <img :src="addIcon" alt="" class="appium-action-trigger__icon" />
130
+ 添加操作
131
+ </el-button>
132
+ <template #dropdown>
133
+ <el-dropdown-menu>
134
+ <template v-for="group in actionGroups" :key="group.title">
135
+ <div class="appium-action-dropdown__group">{{ group.title }}</div>
136
+ <el-dropdown-item
137
+ v-for="action in group.actions"
138
+ :key="action.type"
139
+ :command="action.type"
140
+ :disabled="action.requiresNode && !node"
141
+ >
142
+ <span class="appium-action-dropdown__item">
143
+ <img :src="action.icon" :alt="action.label" />
144
+ <span>{{ action.label }}</span>
145
+ </span>
146
+ </el-dropdown-item>
147
+ </template>
148
+ </el-dropdown-menu>
149
+ </template>
150
+ </el-dropdown>
151
+ </div>
152
+ </template>
@@ -0,0 +1,79 @@
1
+ <script setup lang="ts">
2
+ import { Delete, Edit, Timer } from '@element-plus/icons-vue';
3
+ import type { AppiumRecordedStep } from '../types';
4
+
5
+ defineProps<{
6
+ steps: AppiumRecordedStep[];
7
+ disabled?: boolean;
8
+ }>();
9
+
10
+ defineEmits<{
11
+ remove: [index: number];
12
+ addDelay: [index?: number];
13
+ editInput: [index: number];
14
+ }>();
15
+
16
+ function stepMeta(step: AppiumRecordedStep) {
17
+ if (step.type === 'delay') return `${step.timeoutMs || 1000}ms`;
18
+ if (step.type === 'input') return `输入内容:${step.value || '空'}`;
19
+ if (step.type === 'key') return `keyCode ${step.keyCode || ''}`;
20
+ if (step.type === 'waitActivity') return step.value || '';
21
+ if (step.type === 'launchApp') return step.value || '';
22
+ if (step.type === 'screenshot') return '保存当前截图';
23
+ if (step.type === 'swipe') {
24
+ const swipe = step.swipe;
25
+ return swipe ? `[${swipe.startX},${swipe.startY}] -> [${swipe.endX},${swipe.endY}]` : '';
26
+ }
27
+ if (step.type === 'pinch') return step.pinch?.direction === 'out' ? '放大' : '缩小';
28
+ if (step.type === 'longPress') return `${step.fallback?.centerX || ''},${step.fallback?.centerY || ''}`;
29
+ if (step.type === 'coordinateTap') return `${step.fallback?.centerX || ''},${step.fallback?.centerY || ''}`;
30
+ if (step.type === 'assertText') return step.value || '';
31
+ return `${step.selector?.strategy || ''} ${step.selector?.value || ''}`;
32
+ }
33
+ </script>
34
+
35
+ <template>
36
+ <div class="appium-recorded-steps-panel">
37
+ <div class="appium-recorded-steps">
38
+ <el-empty v-if="!steps.length" description="暂无录制步骤" />
39
+ <div
40
+ v-for="(step, index) in steps"
41
+ v-else
42
+ :key="step.id"
43
+ class="appium-step-item"
44
+ >
45
+ <div
46
+ class="appium-step-row"
47
+ :class="{ 'appium-step-row--delay': step.type === 'delay' }"
48
+ >
49
+ <span class="appium-step-row__index">{{ index + 1 }}</span>
50
+ <div class="appium-step-row__main">
51
+ <strong :title="step.label">{{ step.label }}</strong>
52
+ <small :title="stepMeta(step)">{{ stepMeta(step) }}</small>
53
+ </div>
54
+ <div class="appium-step-row__actions">
55
+ <el-button
56
+ v-if="step.type === 'input'"
57
+ text
58
+ size="small"
59
+ :icon="Edit"
60
+ :disabled="disabled"
61
+ title="修改输入内容"
62
+ @click="$emit('editInput', index)"
63
+ />
64
+ <el-button text size="small" :icon="Delete" :disabled="disabled" @click="$emit('remove', index)" />
65
+ </div>
66
+ </div>
67
+ <button
68
+ type="button"
69
+ class="appium-step-insert-delay"
70
+ :disabled="disabled"
71
+ @click="$emit('addDelay', index)"
72
+ >
73
+ <el-icon><Timer /></el-icon>
74
+ <span>在此步骤后插入延时</span>
75
+ </button>
76
+ </div>
77
+ </div>
78
+ </div>
79
+ </template>
@@ -0,0 +1,129 @@
1
+ import type { AppiumBounds, AppiumNode, AppiumSelector } from './types';
2
+
3
+ function attr(element: Element, name: string) {
4
+ return element.getAttribute(name) || '';
5
+ }
6
+
7
+ function parseBounds(value: string): AppiumBounds | undefined {
8
+ const match = value.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
9
+ if (!match) return undefined;
10
+ const left = Number(match[1]);
11
+ const top = Number(match[2]);
12
+ const right = Number(match[3]);
13
+ const bottom = Number(match[4]);
14
+ return {
15
+ left,
16
+ top,
17
+ right,
18
+ bottom,
19
+ centerX: Math.round((left + right) / 2),
20
+ centerY: Math.round((top + bottom) / 2),
21
+ };
22
+ }
23
+
24
+ function escapeUiAutomatorText(value: string) {
25
+ return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
26
+ }
27
+
28
+ function buildSelector(element: Element, bounds?: AppiumBounds): AppiumSelector {
29
+ const contentDesc = attr(element, 'content-desc');
30
+ const resourceId = attr(element, 'resource-id');
31
+ const text = attr(element, 'text');
32
+ const className = attr(element, 'class');
33
+ if (contentDesc) return { strategy: 'accessibilityId', value: contentDesc };
34
+ if (resourceId) return { strategy: 'id', value: resourceId };
35
+ if (text) {
36
+ const classClause = className ? `.className("${escapeUiAutomatorText(className)}")` : '';
37
+ return { strategy: 'androidUiAutomator', value: `new UiSelector().text("${escapeUiAutomatorText(text)}")${classClause}` };
38
+ }
39
+ if (bounds) return { strategy: 'bounds', centerX: bounds.centerX, centerY: bounds.centerY };
40
+ return { strategy: 'xpath', value: '' };
41
+ }
42
+
43
+ function nodeLabel(element: Element) {
44
+ return attr(element, 'resource-id')
45
+ || attr(element, 'content-desc')
46
+ || attr(element, 'text')
47
+ || attr(element, 'class')
48
+ || element.tagName;
49
+ }
50
+
51
+ function elementChildren(element: Element) {
52
+ return Array.from(element.children).filter((child) => child.tagName === 'node');
53
+ }
54
+
55
+ function escapeXpathValue(value: string) {
56
+ return value.replace(/'/g, "&apos;");
57
+ }
58
+
59
+ function transformNode(
60
+ element: Element,
61
+ parentPath: string,
62
+ index: number,
63
+ resourceIdCounts: Map<string, number>,
64
+ ): AppiumNode {
65
+ const bounds = parseBounds(attr(element, 'bounds'));
66
+ const className = attr(element, 'class') || element.tagName;
67
+ const resourceId = attr(element, 'resource-id');
68
+ const siblings = element.parentElement ? elementChildren(element.parentElement) : [element];
69
+ const sameClassIndex = siblings
70
+ .slice(0, index + 1)
71
+ .filter((sibling) => (attr(sibling, 'class') || sibling.tagName) === className).length;
72
+ const xpath = resourceId && resourceIdCounts.get(resourceId) === 1
73
+ ? `//*[@resource-id='${escapeXpathValue(resourceId)}']`
74
+ : `${parentPath}/${className}[${sameClassIndex}]`;
75
+ const children = elementChildren(element).map((child, childIndex) => (
76
+ transformNode(child, xpath, childIndex, resourceIdCounts)
77
+ ));
78
+ return {
79
+ id: `${xpath}-${attr(element, 'bounds') || index}`,
80
+ label: nodeLabel(element),
81
+ resourceId,
82
+ text: attr(element, 'text'),
83
+ contentDesc: attr(element, 'content-desc'),
84
+ className,
85
+ packageName: attr(element, 'package'),
86
+ clickable: attr(element, 'clickable') === 'true',
87
+ enabled: attr(element, 'enabled') !== 'false',
88
+ xpath,
89
+ bounds,
90
+ selector: buildSelector(element, bounds),
91
+ children,
92
+ };
93
+ }
94
+
95
+ export function parseWindowHierarchy(xml: string) {
96
+ const doc = new DOMParser().parseFromString(xml, 'text/xml');
97
+ const root = doc.querySelector('hierarchy > node') || doc.querySelector('node');
98
+ if (!root) return null;
99
+ const resourceIdCounts = new Map<string, number>();
100
+ doc.querySelectorAll('node[resource-id]').forEach((element) => {
101
+ const resourceId = attr(element, 'resource-id');
102
+ if (resourceId) resourceIdCounts.set(resourceId, (resourceIdCounts.get(resourceId) || 0) + 1);
103
+ });
104
+ return transformNode(root, '', 0, resourceIdCounts);
105
+ }
106
+
107
+ export function flattenNodes(node: AppiumNode | null): AppiumNode[] {
108
+ if (!node) return [];
109
+ return [node, ...node.children.flatMap((child) => flattenNodes(child))];
110
+ }
111
+
112
+ export function findSmallestNodeAtPoint(node: AppiumNode | null, x: number, y: number) {
113
+ if (!node) return null;
114
+ let result: AppiumNode | null = null;
115
+ let resultArea = Number.POSITIVE_INFINITY;
116
+
117
+ for (const item of flattenNodes(node)) {
118
+ const bounds = item.bounds;
119
+ if (!bounds) continue;
120
+ if (x < bounds.left || x > bounds.right || y < bounds.top || y > bounds.bottom) continue;
121
+ const area = (bounds.right - bounds.left) * (bounds.bottom - bounds.top);
122
+ if (area <= resultArea) {
123
+ result = item;
124
+ resultArea = area;
125
+ }
126
+ }
127
+
128
+ return result;
129
+ }
@@ -0,0 +1,88 @@
1
+ export type AppiumBounds = {
2
+ left: number;
3
+ top: number;
4
+ right: number;
5
+ bottom: number;
6
+ centerX: number;
7
+ centerY: number;
8
+ };
9
+
10
+ export type AppiumSelector = {
11
+ strategy: 'accessibilityId' | 'id' | 'androidUiAutomator' | 'xpath' | 'bounds';
12
+ value?: string;
13
+ centerX?: number;
14
+ centerY?: number;
15
+ };
16
+
17
+ export type AppiumNode = {
18
+ id: string;
19
+ label: string;
20
+ resourceId: string;
21
+ text: string;
22
+ contentDesc: string;
23
+ className: string;
24
+ packageName: string;
25
+ clickable: boolean;
26
+ enabled: boolean;
27
+ xpath: string;
28
+ bounds?: AppiumBounds;
29
+ selector: AppiumSelector;
30
+ children: AppiumNode[];
31
+ };
32
+
33
+ export type AppiumRecordedStep = {
34
+ id: string;
35
+ type:
36
+ | 'tap'
37
+ | 'input'
38
+ | 'waitFor'
39
+ | 'assertExists'
40
+ | 'key'
41
+ | 'waitActivity'
42
+ | 'delay'
43
+ | 'clearInput'
44
+ | 'coordinateTap'
45
+ | 'swipe'
46
+ | 'screenshot'
47
+ | 'launchApp'
48
+ | 'waitDisappear'
49
+ | 'assertText'
50
+ | 'longPress'
51
+ | 'pinch';
52
+ label: string;
53
+ selector?: AppiumSelector;
54
+ fallback?: AppiumSelector;
55
+ value?: string;
56
+ keyCode?: number;
57
+ timeoutMs?: number;
58
+ swipe?: {
59
+ startX: number;
60
+ startY: number;
61
+ endX: number;
62
+ endY: number;
63
+ duration: number;
64
+ };
65
+ pinch?: {
66
+ direction: 'in' | 'out';
67
+ centerX: number;
68
+ centerY: number;
69
+ percent: number;
70
+ };
71
+ snapshot?: {
72
+ text: string;
73
+ resourceId: string;
74
+ contentDesc: string;
75
+ className: string;
76
+ };
77
+ };
78
+
79
+ export type AppiumRecordedScript = {
80
+ id: string;
81
+ name: string;
82
+ appPackage: string;
83
+ appActivity: string;
84
+ deviceId: string;
85
+ steps: AppiumRecordedStep[];
86
+ createdAt: string;
87
+ updatedAt: string;
88
+ };
@@ -0,0 +1,5 @@
1
+ <svg t="1737900068491" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
2
+ <path
3
+ d="M853.333333 128v768a42.666667 42.666667 0 0 1-65.706666 35.882667l-597.333334-384a42.666667 42.666667 0 0 1 0-71.765334l597.333334-384A42.666667 42.666667 0 0 1 853.333333 128z m-85.333333 78.165333L292.266667 512 768 817.792V206.165333z"
4
+ fill="currentColor"></path>
5
+ </svg>
@@ -0,0 +1,3 @@
1
+ <svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="m512 85.333333a426.666667 426.666667 0 1 0 426.666667 426.666667 426.666667 426.666667 0 0 0 -426.666667-426.666667zm0 768a341.333333 341.333333 0 1 1 341.333333-341.333333 341.333333 341.333333 0 0 1 -341.333333 341.333333z" fill="currentColor"/>
3
+ </svg>
@@ -0,0 +1,5 @@
1
+ <svg t="1737786833367" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
2
+ <path
3
+ d="M813.653333 253.013333q59.008 59.008 91.349333 135.68 33.664 79.317333 33.664 166.016t-33.664 166.016q-32.341333 76.672-91.349333 135.68t-135.68 91.349333q-79.317333 33.664-166.016 33.664t-166.016-33.664q-76.672-32.341333-135.68-91.349333t-91.349333-135.68q-33.664-79.317333-33.664-166.016t33.664-166.016q32.341333-76.672 91.349333-135.68 12.330667-12.330667 29.994667-12.330667t30.336 12.330667 12.672 29.994667-12.672 30.336q-47.018667 47.018667-73.344 108.330667-26.666667 64-26.666667 132.992t26.666667 132.992q26.325333 61.312 73.344 108.330667t108.330667 73.344q64 26.666667 132.992 26.666667t132.992-26.666667q61.312-26.325333 108.330667-73.344t73.344-108.330667q26.666667-64 26.666667-132.992t-26.666667-132.992q-26.325333-61.312-73.344-108.330667-12.672-12.672-12.672-30.336t12.672-29.994667 30.336-12.330667 29.994667 12.330667zM512 42.666667q17.664 0 30.165333 12.501333t12.501333 30.165333l0 426.666667q0 17.664-12.501333 30.165333t-30.165333 12.501333-30.165333-12.501333-12.501333-30.165333l0-426.666667q0-17.664 12.501333-30.165333t30.165333-12.501333z"
4
+ fill="currentColor"></path>
5
+ </svg>
@@ -0,0 +1,3 @@
1
+ <svg t="1626961815522" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M942.6 82.2v859.6H82.2V82.2h860.4M947.5 2h-871C35.2 2 2 35.2 2 76.5v870.9c0 41.3 33.2 74.5 74.5 74.5h870.9c41.3 0 74.5-33.2 74.5-74.5v-870c0.9-42.2-32.3-75.4-74.4-75.4 0.8 0 0 0 0 0z" fill="currentColor"/>
3
+ </svg>
@@ -0,0 +1,3 @@
1
+ <svg t="1608646631805" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M558.05 144.08L380.11 322H175.99c-26.52 0-48 21.48-48 48v288c0 26.5 21.48 48 48 48h204.12l177.94 177.9c30.06 30.06 81.94 8.94 81.94-33.94V178.04c0-42.94-51.92-63.96-81.94-33.96z m246.4 216.16c-23.16-12.66-52.38-4.32-65.22 18.9-12.78 23.22-4.32 52.4 18.9 65.22C783.95 458.56 799.99 485.24 799.99 514c0 28.76-16.04 55.44-41.84 69.62-23.22 12.82-31.68 42-18.9 65.22 12.86 23.32 42.1 31.6 65.22 18.9 56.46-31.1 91.54-90 91.54-153.76s-35.08-122.64-91.56-153.74z" fill="currentColor"></path>
3
+ </svg>
@@ -0,0 +1,3 @@
1
+ <svg t="1608646511970" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M462.06 142.1L284.12 320H80c-26.52 0-48 21.48-48 48v288c0 26.5 21.48 48 48 48h204.12l177.94 177.9c30.06 30.06 81.94 8.94 81.94-33.94V176.04c0-42.92-51.92-63.96-81.94-33.94zM992 512c0-127.06-64.12-243.88-171.54-312.48-22.38-14.28-52.06-7.64-66.24 14.92s-7.56 52.42 14.82 66.72C848.54 331.94 896 418.22 896 512s-47.46 180.06-126.96 230.84c-22.38 14.28-29 44.14-14.82 66.72 13.02 20.72 42.24 30.28 66.24 14.92C927.88 755.88 992 639.06 992 512z m-283.54-153.74c-23.16-12.66-52.38-4.32-65.22 18.9-12.78 23.22-4.32 52.4 18.9 65.22C687.96 456.56 704 483.26 704 512c0 28.76-16.04 55.44-41.84 69.62-23.22 12.82-31.68 42-18.9 65.22 12.86 23.32 42.1 31.6 65.22 18.9 56.46-31.1 91.54-90 91.54-153.76s-35.08-122.64-91.56-153.72z" fill="currentColor"></path>
3
+ </svg>