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,72 @@
1
+ import {
2
+ findActiveDeviceLockRecord,
3
+ insertDeviceLockRecord,
4
+ listActiveDeviceLockRecords,
5
+ refreshDeviceLockRecord,
6
+ releaseDeviceLockRecord,
7
+ } from './repository';
8
+ import { DeviceLockConflictError, type DeviceLockOwnerType } from './types';
9
+
10
+ const DEFAULT_LOCK_TTL_MS = 15 * 60 * 1000;
11
+
12
+ function nowIso() {
13
+ return new Date().toISOString();
14
+ }
15
+
16
+ function addMs(timestamp: string, ms: number) {
17
+ return new Date(new Date(timestamp).getTime() + ms).toISOString();
18
+ }
19
+
20
+ // 设备锁是脚本执行的并发保护:同一台手机同一时间只允许一个 owner 操作。
21
+ export function acquireDeviceLock(input: {
22
+ deviceId: string;
23
+ ownerType: DeviceLockOwnerType;
24
+ ownerId: string;
25
+ ttlMs?: number;
26
+ metadata?: Record<string, unknown>;
27
+ }) {
28
+ if (!input.deviceId) {
29
+ throw new Error('设备 ID 不能为空');
30
+ }
31
+
32
+ const now = nowIso();
33
+ const expiresAt = addMs(now, input.ttlMs || DEFAULT_LOCK_TTL_MS);
34
+ const active = findActiveDeviceLockRecord(input.deviceId, now);
35
+
36
+ if (active && active.ownerId !== input.ownerId) {
37
+ throw new DeviceLockConflictError(active);
38
+ }
39
+
40
+ if (active) {
41
+ refreshDeviceLockRecord({ lockId: active.id, expiresAt });
42
+ return { ...active, expiresAt };
43
+ }
44
+
45
+ const id = insertDeviceLockRecord({
46
+ deviceId: input.deviceId,
47
+ ownerType: input.ownerType,
48
+ ownerId: input.ownerId,
49
+ metadata: input.metadata || {},
50
+ acquiredAt: now,
51
+ expiresAt,
52
+ });
53
+
54
+ return {
55
+ id,
56
+ deviceId: input.deviceId,
57
+ ownerType: input.ownerType,
58
+ ownerId: input.ownerId,
59
+ metadata: input.metadata || {},
60
+ acquiredAt: now,
61
+ expiresAt,
62
+ releasedAt: '',
63
+ };
64
+ }
65
+
66
+ export function releaseDeviceLock(ownerId: string) {
67
+ releaseDeviceLockRecord(ownerId, nowIso());
68
+ }
69
+
70
+ export function listActiveDeviceLocks() {
71
+ return listActiveDeviceLockRecords(nowIso());
72
+ }
@@ -0,0 +1,22 @@
1
+ export type DeviceLockOwnerType = 'script_run' | 'manual_action' | 'preview';
2
+
3
+ export type DeviceLockRecord = {
4
+ id: string;
5
+ deviceId: string;
6
+ ownerType: DeviceLockOwnerType;
7
+ ownerId: string;
8
+ metadata: Record<string, unknown>;
9
+ acquiredAt: string;
10
+ expiresAt: string;
11
+ releasedAt: string;
12
+ };
13
+
14
+ export class DeviceLockConflictError extends Error {
15
+ readonly conflict: DeviceLockRecord;
16
+
17
+ constructor(conflict: DeviceLockRecord) {
18
+ super(`设备 ${conflict.deviceId} 正在被 ${conflict.ownerType} 占用`);
19
+ this.name = 'DeviceLockConflictError';
20
+ this.conflict = conflict;
21
+ }
22
+ }
@@ -0,0 +1,169 @@
1
+ import {
2
+ createId,
3
+ querySql,
4
+ runSql,
5
+ sqlNullableString,
6
+ sqlString,
7
+ } from '../storage/sqlite';
8
+ import type { DeviceSessionPatch, DeviceSessionRecord, DeviceSessionStatus } from './types';
9
+
10
+ type DeviceSessionRow = {
11
+ id: string;
12
+ device_id: string;
13
+ status: DeviceSessionStatus;
14
+ playground_url: string | null;
15
+ preview_kind: string | null;
16
+ session_connected: number;
17
+ setup_state: string | null;
18
+ preview_error: string | null;
19
+ created_at: string;
20
+ updated_at: string;
21
+ expires_at: string;
22
+ idle_expires_at: string;
23
+ };
24
+
25
+ let initialized = false;
26
+
27
+ function rowToRecord(row: DeviceSessionRow): DeviceSessionRecord {
28
+ return {
29
+ id: row.id,
30
+ deviceId: row.device_id,
31
+ status: row.status,
32
+ playgroundUrl: row.playground_url || '',
33
+ previewKind: row.preview_kind || '',
34
+ sessionConnected: row.session_connected === 1,
35
+ setupState: row.setup_state || '',
36
+ previewError: row.preview_error || '',
37
+ createdAt: row.created_at,
38
+ updatedAt: row.updated_at,
39
+ expiresAt: row.expires_at,
40
+ idleExpiresAt: row.idle_expires_at,
41
+ };
42
+ }
43
+
44
+ export function initDeviceSessionRepository() {
45
+ if (initialized) return;
46
+ initialized = true;
47
+ runSql(`
48
+ PRAGMA journal_mode = WAL;
49
+ CREATE TABLE IF NOT EXISTS device_sessions (
50
+ id TEXT PRIMARY KEY,
51
+ device_id TEXT NOT NULL,
52
+ status TEXT NOT NULL,
53
+ playground_url TEXT NULL,
54
+ preview_kind TEXT NULL,
55
+ session_connected INTEGER NOT NULL DEFAULT 0,
56
+ setup_state TEXT NULL,
57
+ preview_error TEXT NULL,
58
+ created_at TEXT NOT NULL,
59
+ updated_at TEXT NOT NULL,
60
+ expires_at TEXT NOT NULL,
61
+ idle_expires_at TEXT NOT NULL
62
+ );
63
+ CREATE INDEX IF NOT EXISTS idx_device_sessions_device_status
64
+ ON device_sessions(device_id, status, updated_at DESC);
65
+ `);
66
+ }
67
+
68
+ export function listDeviceSessionRecords() {
69
+ initDeviceSessionRepository();
70
+ return querySql<DeviceSessionRow>(`
71
+ SELECT *
72
+ FROM device_sessions
73
+ ORDER BY datetime(updated_at) DESC
74
+ LIMIT 50;
75
+ `).map(rowToRecord);
76
+ }
77
+
78
+ export function getActiveDeviceSessionRecord(deviceId: string, now: string) {
79
+ initDeviceSessionRepository();
80
+ const row = querySql<DeviceSessionRow>(`
81
+ SELECT *
82
+ FROM device_sessions
83
+ WHERE device_id = ${sqlString(deviceId)}
84
+ AND status = 'active'
85
+ AND datetime(expires_at) > datetime(${sqlString(now)})
86
+ AND datetime(idle_expires_at) > datetime(${sqlString(now)})
87
+ ORDER BY datetime(updated_at) DESC
88
+ LIMIT 1;
89
+ `)[0];
90
+
91
+ return row ? rowToRecord(row) : null;
92
+ }
93
+
94
+ export function expireStaleDeviceSessions(now: string) {
95
+ initDeviceSessionRepository();
96
+ runSql(`
97
+ UPDATE device_sessions
98
+ SET status = 'expired', updated_at = ${sqlString(now)}
99
+ WHERE status = 'active'
100
+ AND (
101
+ datetime(expires_at) <= datetime(${sqlString(now)})
102
+ OR datetime(idle_expires_at) <= datetime(${sqlString(now)})
103
+ );
104
+ `);
105
+ }
106
+
107
+ export function createDeviceSessionRecord(input: {
108
+ deviceId: string;
109
+ now: string;
110
+ expiresAt: string;
111
+ idleExpiresAt: string;
112
+ patch?: DeviceSessionPatch;
113
+ }) {
114
+ initDeviceSessionRepository();
115
+ const id = createId('dsess');
116
+ runSql(`
117
+ INSERT INTO device_sessions (
118
+ id,
119
+ device_id,
120
+ status,
121
+ playground_url,
122
+ preview_kind,
123
+ session_connected,
124
+ setup_state,
125
+ preview_error,
126
+ created_at,
127
+ updated_at,
128
+ expires_at,
129
+ idle_expires_at
130
+ )
131
+ VALUES (
132
+ ${sqlString(id)},
133
+ ${sqlString(input.deviceId)},
134
+ 'active',
135
+ ${sqlNullableString(input.patch?.playgroundUrl)},
136
+ ${sqlNullableString(input.patch?.previewKind)},
137
+ ${input.patch?.sessionConnected ? 1 : 0},
138
+ ${sqlNullableString(input.patch?.setupState)},
139
+ ${sqlNullableString(input.patch?.previewError)},
140
+ ${sqlString(input.now)},
141
+ ${sqlString(input.now)},
142
+ ${sqlString(input.expiresAt)},
143
+ ${sqlString(input.idleExpiresAt)}
144
+ );
145
+ `);
146
+
147
+ return getActiveDeviceSessionRecord(input.deviceId, input.now);
148
+ }
149
+
150
+ export function touchDeviceSessionRecord(input: {
151
+ sessionId: string;
152
+ now: string;
153
+ idleExpiresAt: string;
154
+ patch?: DeviceSessionPatch;
155
+ }) {
156
+ initDeviceSessionRepository();
157
+ runSql(`
158
+ UPDATE device_sessions
159
+ SET
160
+ updated_at = ${sqlString(input.now)},
161
+ idle_expires_at = ${sqlString(input.idleExpiresAt)},
162
+ playground_url = COALESCE(${sqlNullableString(input.patch?.playgroundUrl)}, playground_url),
163
+ preview_kind = COALESCE(${sqlNullableString(input.patch?.previewKind)}, preview_kind),
164
+ session_connected = ${input.patch?.sessionConnected === undefined ? 'session_connected' : input.patch.sessionConnected ? 1 : 0},
165
+ setup_state = COALESCE(${sqlNullableString(input.patch?.setupState)}, setup_state),
166
+ preview_error = COALESCE(${sqlNullableString(input.patch?.previewError)}, preview_error)
167
+ WHERE id = ${sqlString(input.sessionId)};
168
+ `);
169
+ }
@@ -0,0 +1,59 @@
1
+ import {
2
+ createDeviceSessionRecord,
3
+ expireStaleDeviceSessions,
4
+ getActiveDeviceSessionRecord,
5
+ listDeviceSessionRecords,
6
+ touchDeviceSessionRecord,
7
+ } from './repository';
8
+ import type { DeviceSessionPatch } from './types';
9
+
10
+ const SESSION_TTL_MS = 30 * 60 * 1000;
11
+ const SESSION_IDLE_TTL_MS = 10 * 60 * 1000;
12
+
13
+ function nowIso() {
14
+ return new Date().toISOString();
15
+ }
16
+
17
+ function addMs(timestamp: string, ms: number) {
18
+ return new Date(new Date(timestamp).getTime() + ms).toISOString();
19
+ }
20
+
21
+ // 复用同一设备的活动 session;不存在或过期时才创建新 session,避免页面切换导致设备状态丢失。
22
+ export function ensureDeviceSession(input: { deviceId: string; patch?: DeviceSessionPatch }) {
23
+ if (!input.deviceId) {
24
+ throw new Error('设备 ID 不能为空');
25
+ }
26
+
27
+ const now = nowIso();
28
+ expireStaleDeviceSessions(now);
29
+ const existing = getActiveDeviceSessionRecord(input.deviceId, now);
30
+ const idleExpiresAt = addMs(now, SESSION_IDLE_TTL_MS);
31
+
32
+ if (existing) {
33
+ touchDeviceSessionRecord({
34
+ sessionId: existing.id,
35
+ now,
36
+ idleExpiresAt,
37
+ patch: input.patch,
38
+ });
39
+ return getActiveDeviceSessionRecord(input.deviceId, now) || existing;
40
+ }
41
+
42
+ const created = createDeviceSessionRecord({
43
+ deviceId: input.deviceId,
44
+ now,
45
+ expiresAt: addMs(now, SESSION_TTL_MS),
46
+ idleExpiresAt,
47
+ patch: input.patch,
48
+ });
49
+
50
+ if (!created) {
51
+ throw new Error('创建设备会话失败');
52
+ }
53
+ return created;
54
+ }
55
+
56
+ export function listDeviceSessions() {
57
+ expireStaleDeviceSessions(nowIso());
58
+ return listDeviceSessionRecords();
59
+ }
@@ -0,0 +1,24 @@
1
+ export type DeviceSessionStatus = 'active' | 'expired' | 'closed';
2
+
3
+ export type DeviceSessionRecord = {
4
+ id: string;
5
+ deviceId: string;
6
+ status: DeviceSessionStatus;
7
+ playgroundUrl: string;
8
+ previewKind: string;
9
+ sessionConnected: boolean;
10
+ setupState: string;
11
+ previewError: string;
12
+ createdAt: string;
13
+ updatedAt: string;
14
+ expiresAt: string;
15
+ idleExpiresAt: string;
16
+ };
17
+
18
+ export type DeviceSessionPatch = {
19
+ playgroundUrl?: string;
20
+ previewKind?: string;
21
+ sessionConnected?: boolean;
22
+ setupState?: string;
23
+ previewError?: string;
24
+ };