android-midscene-automation 0.2.2 → 0.2.4
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 +10 -1
- package/README.md +34 -1
- package/package.json +7 -7
- package/remote-agent/index.ts +50 -22
- package/server/appium-recorder/appium-runner.ts +30 -9
- package/server/appium-recorder/managed-cleanup.test.ts +38 -8
- package/server/appium-recorder/replay-lifecycle.test.ts +123 -0
- package/server/appium-recorder/routes.ts +6 -6
- package/server/http-api.ts +1 -1
- package/server/remote-agents/protocol.ts +1 -0
- package/server/remote-agents/registry.test.ts +65 -0
- package/server/remote-agents/registry.ts +60 -5
- package/server/remote-agents/replay-integration.test.ts +79 -0
- package/server/remote-agents/routes.ts +10 -3
- package/src/App.vue +62 -18
- package/src/style.css +29 -65
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { registerRemoteAgent, makeRemoteDeviceId, sendRemoteCommand, pollRemoteCommand, completeRemoteCommand, stopRemoteReplay } from './registry';
|
|
4
|
+
|
|
5
|
+
test('replay heartbeat renews beyond ten minutes, cancellation survives dispatch, late results save once', async () => {
|
|
6
|
+
const originalSetTimeout = globalThis.setTimeout;
|
|
7
|
+
const originalClearTimeout = globalThis.clearTimeout;
|
|
8
|
+
const originalNow = Date.now;
|
|
9
|
+
const token = process.env.REMOTE_AGENT_TOKEN;
|
|
10
|
+
let now = originalNow();
|
|
11
|
+
const timers = new Set<{ at: number; callback: () => void; refresh: () => void }>();
|
|
12
|
+
const advance = (ms: number) => {
|
|
13
|
+
now += ms;
|
|
14
|
+
for (const timer of [...timers]) if (timer.at <= now) { timers.delete(timer); timer.callback(); }
|
|
15
|
+
};
|
|
16
|
+
try {
|
|
17
|
+
Date.now = () => now;
|
|
18
|
+
globalThis.setTimeout = ((callback: () => void, ms: number) => {
|
|
19
|
+
const timer = { at: now + ms, callback, refresh: () => { timer.at = now + ms; } };
|
|
20
|
+
timers.add(timer); return timer;
|
|
21
|
+
}) as unknown as typeof setTimeout;
|
|
22
|
+
globalThis.clearTimeout = ((timer: any) => { timers.delete(timer); }) as typeof clearTimeout;
|
|
23
|
+
const agentId = 'test-lease-agent';
|
|
24
|
+
const heartbeat = () => registerRemoteAgent({ agentId, token, devices: [{ id: 'device', status: 'device' }] });
|
|
25
|
+
const device = makeRemoteDeviceId(agentId, 'device');
|
|
26
|
+
heartbeat();
|
|
27
|
+
const result = sendRemoteCommand(device, 'replay', { script: { id: 'script' } });
|
|
28
|
+
let completed = false;
|
|
29
|
+
void result.then(() => { completed = true; });
|
|
30
|
+
const command = pollRemoteCommand({ agentId, token })!;
|
|
31
|
+
assert.throws(() => sendRemoteCommand(device, 'replay'), /已有回放任务/);
|
|
32
|
+
for (let i = 0; i < 70; i++) { advance(10000); heartbeat(); }
|
|
33
|
+
assert.equal(completed, false, 'healthy replay remains pending after 11 minutes');
|
|
34
|
+
assert.equal(stopRemoteReplay(device), true);
|
|
35
|
+
assert.deepEqual(heartbeat().cancelledCommandIds, [command.id]);
|
|
36
|
+
assert.throws(() => completeRemoteCommand({ agentId: 'other', token, commandId: command.id, ok: true }), /不属于/);
|
|
37
|
+
let saves = 0;
|
|
38
|
+
const stopped = { stopped: true, history: { status: 'stopped' } };
|
|
39
|
+
completeRemoteCommand({ agentId, token, commandId: command.id, ok: true, data: stopped }, () => { saves++; });
|
|
40
|
+
assert.deepEqual(await result, stopped);
|
|
41
|
+
completeRemoteCommand({ agentId, token, commandId: command.id, ok: true, data: stopped }, () => { saves++; });
|
|
42
|
+
assert.equal(saves, 1, 'result retry must not duplicate history');
|
|
43
|
+
assert.equal(stopRemoteReplay(device), false);
|
|
44
|
+
|
|
45
|
+
const queuedResult = sendRemoteCommand(device, 'replay');
|
|
46
|
+
assert.equal(stopRemoteReplay(device), true);
|
|
47
|
+
const queued = pollRemoteCommand({ agentId, token })!;
|
|
48
|
+
assert.equal(queued.cancelled, true, 'cancel before poll survives delivery');
|
|
49
|
+
completeRemoteCommand({ agentId, token, commandId: queued.id, ok: true }); await queuedResult;
|
|
50
|
+
|
|
51
|
+
const lateResult = sendRemoteCommand(device, 'replay', { script: { id: 'late-script' } });
|
|
52
|
+
const rejected = assert.rejects(lateResult, /心跳超时/);
|
|
53
|
+
const late = pollRemoteCommand({ agentId, token })!;
|
|
54
|
+
advance(45001); await rejected;
|
|
55
|
+
assert.deepEqual(heartbeat().cancelledCommandIds, [late.id], 'reconnecting agent stops expired replay');
|
|
56
|
+
const input = { agentId, token, commandId: late.id, ok: true, data: { history: {} } };
|
|
57
|
+
assert.throws(() => completeRemoteCommand(input, () => { throw new Error('disk busy'); }), /disk busy/);
|
|
58
|
+
completeRemoteCommand(input, command => { assert.equal((command.payload?.script as { id: string }).id, 'late-script'); saves++; });
|
|
59
|
+
completeRemoteCommand(input, () => { saves++; });
|
|
60
|
+
assert.equal(saves, 2, 'late result is saved once even after a temporary persistence failure');
|
|
61
|
+
assert.deepEqual(heartbeat().cancelledCommandIds, []);
|
|
62
|
+
} finally {
|
|
63
|
+
globalThis.setTimeout = originalSetTimeout; globalThis.clearTimeout = originalClearTimeout; Date.now = originalNow;
|
|
64
|
+
}
|
|
65
|
+
});
|
|
@@ -3,7 +3,9 @@ import type { RemoteAgentDevice, RemoteAndroidDevice, RemoteCommand, RemoteComma
|
|
|
3
3
|
const REMOTE_DEVICE_PREFIX = 'remote:';
|
|
4
4
|
const AGENT_TTL_MS = 15_000;
|
|
5
5
|
const COMMAND_TIMEOUT_MS = 15_000;
|
|
6
|
-
|
|
6
|
+
// 回放由代理心跳续期,运行时长不受固定十分钟限制。
|
|
7
|
+
const REPLAY_COMMAND_TIMEOUT_MS = 45_000;
|
|
8
|
+
const LATE_RESULT_TTL_MS = 24 * 60 * 60_000;
|
|
7
9
|
|
|
8
10
|
type RegisteredAgent = {
|
|
9
11
|
id: string;
|
|
@@ -14,6 +16,9 @@ type RegisteredAgent = {
|
|
|
14
16
|
};
|
|
15
17
|
|
|
16
18
|
type PendingCommand = RemoteCommand & {
|
|
19
|
+
agentId: string;
|
|
20
|
+
delivered: boolean;
|
|
21
|
+
cancelRequested: boolean;
|
|
17
22
|
resolve: (result: unknown) => void;
|
|
18
23
|
reject: (error: Error) => void;
|
|
19
24
|
timer: ReturnType<typeof setTimeout>;
|
|
@@ -22,6 +27,24 @@ type PendingCommand = RemoteCommand & {
|
|
|
22
27
|
const agents = new Map<string, RegisteredAgent>();
|
|
23
28
|
const queues = new Map<string, RemoteCommand[]>();
|
|
24
29
|
const pending = new Map<string, PendingCommand>();
|
|
30
|
+
// ponytail: 当前进程保留离线回放身份 24 小时;跨服务重启补交需要持久化任务表。
|
|
31
|
+
const lateReplays = new Map<string, { command: RemoteCommand; agentId: string; expiresAt: number }>();
|
|
32
|
+
function pruneLateReplays() {
|
|
33
|
+
for (const [id, receipt] of lateReplays) if (receipt.expiresAt <= Date.now()) lateReplays.delete(id);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function stopRemoteReplay(deviceId: string) {
|
|
37
|
+
const target = parseRemoteDeviceId(deviceId);
|
|
38
|
+
if (!target) return false;
|
|
39
|
+
let stopped = false;
|
|
40
|
+
for (const command of pending.values()) {
|
|
41
|
+
if (command.type === 'replay' && command.agentId === target.agentId && command.deviceId === target.deviceSerial) {
|
|
42
|
+
command.cancelRequested = true;
|
|
43
|
+
stopped = true;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return stopped;
|
|
47
|
+
}
|
|
25
48
|
|
|
26
49
|
function validateToken(token?: string) {
|
|
27
50
|
const expected = process.env.REMOTE_AGENT_TOKEN || '';
|
|
@@ -70,6 +93,17 @@ export function registerRemoteAgent(input: {
|
|
|
70
93
|
devices: input.devices || [],
|
|
71
94
|
lastSeenAt: Date.now(),
|
|
72
95
|
});
|
|
96
|
+
pruneLateReplays();
|
|
97
|
+
const cancelledCommandIds: string[] = [];
|
|
98
|
+
for (const command of pending.values()) {
|
|
99
|
+
if (command.agentId !== agentId || command.type !== 'replay') continue;
|
|
100
|
+
command.timer.refresh();
|
|
101
|
+
if (command.cancelRequested) cancelledCommandIds.push(command.id);
|
|
102
|
+
}
|
|
103
|
+
for (const receipt of lateReplays.values()) {
|
|
104
|
+
if (receipt.agentId === agentId) cancelledCommandIds.push(receipt.command.id);
|
|
105
|
+
}
|
|
106
|
+
return { cancelledCommandIds };
|
|
73
107
|
}
|
|
74
108
|
|
|
75
109
|
export function listRemoteAndroidDevices(): RemoteAndroidDevice[] {
|
|
@@ -88,15 +122,27 @@ export function listRemoteAndroidDevices(): RemoteAndroidDevice[] {
|
|
|
88
122
|
export function pollRemoteCommand(input: { agentId: string; token?: string }) {
|
|
89
123
|
validateToken(input.token);
|
|
90
124
|
const queue = queues.get(input.agentId) || [];
|
|
91
|
-
|
|
125
|
+
const command = queue.shift();
|
|
126
|
+
if (!command) return null;
|
|
127
|
+
const task = pending.get(command.id);
|
|
128
|
+
if (!task) return null;
|
|
129
|
+
task.delivered = true;
|
|
130
|
+
return { ...command, cancelled: task.cancelRequested };
|
|
92
131
|
}
|
|
93
132
|
|
|
94
133
|
export function completeRemoteCommand(input: {
|
|
95
134
|
agentId: string;
|
|
96
135
|
token?: string;
|
|
97
|
-
} & RemoteCommandResult) {
|
|
136
|
+
} & RemoteCommandResult, saveResult?: (command: RemoteCommand) => void) {
|
|
98
137
|
validateToken(input.token);
|
|
138
|
+
pruneLateReplays();
|
|
99
139
|
const command = pending.get(input.commandId);
|
|
140
|
+
const late = lateReplays.get(input.commandId);
|
|
141
|
+
if (!command && !late) return;
|
|
142
|
+
if ((command?.agentId ?? late?.agentId) !== input.agentId) throw new Error('远程命令不属于当前代理');
|
|
143
|
+
// 先落库再确认;保存失败时保留任务身份,让代理重试上报。
|
|
144
|
+
if (input.ok) saveResult?.(command ?? late!.command);
|
|
145
|
+
lateReplays.delete(input.commandId);
|
|
100
146
|
if (!command) return;
|
|
101
147
|
pending.delete(input.commandId);
|
|
102
148
|
clearTimeout(command.timer);
|
|
@@ -112,6 +158,10 @@ export function sendRemoteCommand(deviceId: string, type: RemoteCommandType, pay
|
|
|
112
158
|
if (!parsed) throw new Error('远程设备 ID 无效');
|
|
113
159
|
const agent = agents.get(parsed.agentId);
|
|
114
160
|
if (!agent || !isOnline(agent)) throw new Error('远程设备代理已离线');
|
|
161
|
+
if (type === 'replay' && [...pending.values()].some(command =>
|
|
162
|
+
command.type === 'replay' && command.agentId === parsed.agentId && command.deviceId === parsed.deviceSerial)) {
|
|
163
|
+
throw new Error('该设备已有回放任务正在执行');
|
|
164
|
+
}
|
|
115
165
|
const commandId = `remote_cmd_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
116
166
|
const command: RemoteCommand = {
|
|
117
167
|
id: commandId,
|
|
@@ -123,12 +173,17 @@ export function sendRemoteCommand(deviceId: string, type: RemoteCommandType, pay
|
|
|
123
173
|
return new Promise<unknown>((resolve, reject) => {
|
|
124
174
|
const timeoutMs = type === 'replay' ? REPLAY_COMMAND_TIMEOUT_MS : COMMAND_TIMEOUT_MS;
|
|
125
175
|
const timer = setTimeout(() => {
|
|
176
|
+
const task = pending.get(commandId);
|
|
126
177
|
pending.delete(commandId);
|
|
178
|
+
if (type === 'replay' && task?.delivered) {
|
|
179
|
+
pruneLateReplays();
|
|
180
|
+
lateReplays.set(commandId, { command, agentId: parsed.agentId, expiresAt: Date.now() + LATE_RESULT_TTL_MS });
|
|
181
|
+
}
|
|
127
182
|
const queue = queues.get(parsed.agentId) || [];
|
|
128
183
|
queues.set(parsed.agentId, queue.filter((item) => item.id !== commandId));
|
|
129
|
-
reject(new Error('远程设备命令超时'));
|
|
184
|
+
reject(new Error(type === 'replay' ? '远程代理心跳超时,恢复连接后将终止任务并补交结果' : '远程设备命令超时'));
|
|
130
185
|
}, timeoutMs);
|
|
131
|
-
pending.set(commandId, { ...command, resolve, reject, timer });
|
|
186
|
+
pending.set(commandId, { ...command, agentId: parsed.agentId, delivered: false, cancelRequested: false, resolve, reject, timer });
|
|
132
187
|
const queue = queues.get(parsed.agentId) || [];
|
|
133
188
|
queue.push(command);
|
|
134
189
|
queues.set(parsed.agentId, queue);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join, resolve } from 'node:path';
|
|
8
|
+
|
|
9
|
+
test('real agent receives stop during replay and retries result delivery without replaying', { skip: process.platform === 'win32', timeout: 25000 }, async () => {
|
|
10
|
+
const root = await mkdtemp(join(tmpdir(), 'remote-replay-'));
|
|
11
|
+
const env = { ...process.env };
|
|
12
|
+
process.env.ANDROID_MIDSCENE_DATA_ROOT = join(root, 'central');
|
|
13
|
+
process.env.REMOTE_AGENT_TOKEN = 'test-only-token';
|
|
14
|
+
const { handleRemoteAgentRequest } = await import('./routes');
|
|
15
|
+
const { handleAppiumRecorderRequest } = await import('../appium-recorder/routes');
|
|
16
|
+
const { listRemoteAndroidDevices } = await import('./registry');
|
|
17
|
+
const { saveAppiumRecordedScript } = await import('../appium-recorder/repository');
|
|
18
|
+
const { listRunHistory } = await import('../appium-recorder/run-history');
|
|
19
|
+
let sessions = 0, deletes = 0, keys = 0, resultAttempts = 0;
|
|
20
|
+
const server = createServer(async (req, res) => {
|
|
21
|
+
if (req.url === '/session') {
|
|
22
|
+
sessions++; res.setHeader('Content-Type', 'application/json'); res.end('{"value":{"sessionId":"test-session"}}'); return;
|
|
23
|
+
}
|
|
24
|
+
if (req.url?.startsWith('/session/')) {
|
|
25
|
+
if (req.method === 'DELETE') deletes++;
|
|
26
|
+
if (req.url.endsWith('/press_keycode')) keys++;
|
|
27
|
+
res.setHeader('Content-Type', 'application/json'); res.end('{"value":{}}'); return;
|
|
28
|
+
}
|
|
29
|
+
if (req.url === '/api/remote-agents/result' && ++resultAttempts === 1) {
|
|
30
|
+
req.resume(); res.statusCode = 503; res.end('{"message":"temporary failure"}'); return;
|
|
31
|
+
}
|
|
32
|
+
if (await handleRemoteAgentRequest(req, res)) return;
|
|
33
|
+
if (await handleAppiumRecorderRequest(req, res, '')) return;
|
|
34
|
+
res.statusCode = 404; res.end();
|
|
35
|
+
});
|
|
36
|
+
let agent: ReturnType<typeof spawn> | undefined;
|
|
37
|
+
const waitFor = async (check: () => boolean) => {
|
|
38
|
+
const until = Date.now() + 12000;
|
|
39
|
+
while (!check()) { assert.ok(Date.now() < until, 'agent action completed within deadline'); await new Promise(resolve => setTimeout(resolve, 25)); }
|
|
40
|
+
};
|
|
41
|
+
try {
|
|
42
|
+
const sdk = join(root, 'sdk'), bin = join(sdk, 'platform-tools');
|
|
43
|
+
await mkdir(bin, { recursive: true });
|
|
44
|
+
await writeFile(join(bin, 'adb'), '#!/usr/bin/env node\nif(process.argv.includes("devices")) console.log("List of devices attached\\nfake-device device model:Test");\n', { mode: 0o755 });
|
|
45
|
+
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
46
|
+
const base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
|
47
|
+
const script = saveAppiumRecordedScript({ name: 'remote-test', appPackage: 'example.test', steps: [
|
|
48
|
+
{ id: 'wait', type: 'delay', label: 'wait', timeoutMs: 60000 },
|
|
49
|
+
{ id: 'home', type: 'key', label: 'Home', keyCode: 3 },
|
|
50
|
+
] });
|
|
51
|
+
agent = spawn(process.execPath, ['--import', 'tsx', resolve('remote-agent/index.ts'), '--server', base, '--agent-id', 'integration-agent'], {
|
|
52
|
+
env: { ...process.env, ANDROID_MIDSCENE_DATA_ROOT: join(root, 'agent'), ANDROID_HOME: sdk, ANDROID_SDK_ROOT: sdk, APPIUM_SERVER_URL: base, PATH: `${bin}:${process.env.PATH}` },
|
|
53
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
54
|
+
});
|
|
55
|
+
let stderr = ''; agent.stderr?.on('data', chunk => { stderr += chunk; });
|
|
56
|
+
await waitFor(() => listRemoteAndroidDevices().length > 0);
|
|
57
|
+
const deviceId = listRemoteAndroidDevices()[0]!.id;
|
|
58
|
+
const post = (path: string, data: unknown) => fetch(base + path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), signal: AbortSignal.timeout(18000) });
|
|
59
|
+
const running = post(`/api/appium-recorder/scripts/${script.id}/replay`, { deviceId, reportSummaryEnabled: false }).then(response => response.json() as Promise<{ stopped: boolean }>);
|
|
60
|
+
await waitFor(() => sessions === 1);
|
|
61
|
+
const stop = await (await post('/api/appium-recorder/replay/stop', { deviceId })).json() as { stopped: boolean };
|
|
62
|
+
assert.equal(stop.stopped, true);
|
|
63
|
+
const result = await running;
|
|
64
|
+
assert.equal(result.stopped, true, stderr);
|
|
65
|
+
assert.equal(sessions, 1, 'retrying the result never replays device actions');
|
|
66
|
+
assert.equal(deletes, 1); assert.equal(keys, 0, 'Home must not run after cancellation');
|
|
67
|
+
assert.equal(resultAttempts, 2, 'temporary result failure is retried');
|
|
68
|
+
const history = listRunHistory(script.id);
|
|
69
|
+
assert.equal(history.length, 1); assert.equal(history[0]?.status, 'stopped'); assert.equal(history[0]?.deviceId, deviceId);
|
|
70
|
+
} finally {
|
|
71
|
+
if (agent && agent.exitCode === null) {
|
|
72
|
+
const exited = new Promise<void>(resolve => agent!.once('exit', () => resolve()));
|
|
73
|
+
agent.kill('SIGKILL'); await exited;
|
|
74
|
+
}
|
|
75
|
+
server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve()));
|
|
76
|
+
for (const key of ['ANDROID_MIDSCENE_DATA_ROOT', 'REMOTE_AGENT_TOKEN']) { if (env[key] === undefined) delete process.env[key]; else process.env[key] = env[key]; }
|
|
77
|
+
await rm(root, { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { saveRunHistory } from '../appium-recorder/run-history';
|
|
2
|
+
import type { RunDetail } from '../../src/appium-recorder/run-history';
|
|
1
3
|
import { readBody, requestErrorStatus } from '../request-body';
|
|
2
4
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
3
|
-
import { completeRemoteCommand, listRemoteAndroidDevices, pollRemoteCommand, registerRemoteAgent } from './registry';
|
|
5
|
+
import { completeRemoteCommand, makeRemoteDeviceId, listRemoteAndroidDevices, pollRemoteCommand, registerRemoteAgent } from './registry';
|
|
4
6
|
import type { RemoteAgentDevice } from './protocol';
|
|
5
7
|
|
|
6
8
|
function sendJson(res: ServerResponse, payload: unknown, statusCode = 200) {
|
|
@@ -29,14 +31,14 @@ export async function handleRemoteAgentRequest(req: IncomingMessage, res: Server
|
|
|
29
31
|
token?: string;
|
|
30
32
|
devices?: RemoteAgentDevice[];
|
|
31
33
|
}>(req);
|
|
32
|
-
registerRemoteAgent({
|
|
34
|
+
const control = registerRemoteAgent({
|
|
33
35
|
agentId: parsed.agentId || '',
|
|
34
36
|
agentName: parsed.agentName,
|
|
35
37
|
version: parsed.version,
|
|
36
38
|
token: parsed.token,
|
|
37
39
|
devices: parsed.devices || [],
|
|
38
40
|
});
|
|
39
|
-
sendJson(res, { success: true });
|
|
41
|
+
sendJson(res, { success: true, ...control });
|
|
40
42
|
return true;
|
|
41
43
|
}
|
|
42
44
|
|
|
@@ -65,6 +67,11 @@ export async function handleRemoteAgentRequest(req: IncomingMessage, res: Server
|
|
|
65
67
|
ok: parsed.ok === true,
|
|
66
68
|
data: parsed.data,
|
|
67
69
|
error: parsed.error,
|
|
70
|
+
}, command => {
|
|
71
|
+
const result = parsed.data as { history?: RunDetail } | undefined;
|
|
72
|
+
if (command.type !== 'replay' || !result?.history) return;
|
|
73
|
+
const script = command.payload?.script as { id: string };
|
|
74
|
+
saveRunHistory({ ...result.history, scriptId: script.id, deviceId: makeRemoteDeviceId(parsed.agentId!, command.deviceId) });
|
|
68
75
|
});
|
|
69
76
|
sendJson(res, { success: true });
|
|
70
77
|
return true;
|
package/src/App.vue
CHANGED
|
@@ -110,8 +110,9 @@ const { isRunningScript, isStoppingScript, executionLog, executionProcess, lastR
|
|
|
110
110
|
const visitedMenus = reactive(new Set<MenuKey>([activeMenu.value]));
|
|
111
111
|
const openMenus = ref<MenuKey[]>([...menuKeys]);
|
|
112
112
|
const visibleMenuItems = computed(() => openMenus.value.map(key => menuItems.find(item => item.key === key)!));
|
|
113
|
-
function closeMenu(key: MenuKey) {
|
|
113
|
+
async function closeMenu(key: MenuKey) {
|
|
114
114
|
if (openMenus.value.length === 1) return;
|
|
115
|
+
if (key === 'generator' && !(await confirmUnsavedGeneratorNavigation())) return;
|
|
115
116
|
const index = openMenus.value.indexOf(key);
|
|
116
117
|
openMenus.value.splice(index, 1);
|
|
117
118
|
if (activeMenu.value === key) activeMenu.value = openMenus.value[Math.max(0, index - 1)];
|
|
@@ -144,6 +145,23 @@ const appPromptPrefix = computed(() => {
|
|
|
144
145
|
const effectiveSourcePrompt = computed(() => `${appPromptPrefix.value}${sourcePrompt.value}`.trim());
|
|
145
146
|
const generatedCodeFromSteps = computed(() => buildScript(form, steps.value));
|
|
146
147
|
const generatedCode = computed(() => generatedCodeOverride.value ?? generatedCodeFromSteps.value);
|
|
148
|
+
const generatorContentSnapshot = computed(() => JSON.stringify({
|
|
149
|
+
form: {
|
|
150
|
+
promptTitle: form.promptTitle,
|
|
151
|
+
testName: form.testName,
|
|
152
|
+
appPresetId: form.appPresetId,
|
|
153
|
+
},
|
|
154
|
+
sourcePrompt: sourcePrompt.value,
|
|
155
|
+
steps: steps.value,
|
|
156
|
+
generatedCodeOverride: generatedCodeOverride.value,
|
|
157
|
+
generatedCodeDraft: generatedCodeEditing.value ? generatedCodeDraft.value : '',
|
|
158
|
+
}));
|
|
159
|
+
const savedGeneratorSnapshot = shallowRef('');
|
|
160
|
+
const generatorHasUnsavedContent = computed(() => (
|
|
161
|
+
showGeneratedCode.value
|
|
162
|
+
&& Boolean(generatedCode.value.trim())
|
|
163
|
+
&& generatorContentSnapshot.value !== savedGeneratorSnapshot.value
|
|
164
|
+
));
|
|
147
165
|
const selectedScript = computed(() => savedScripts.value.find((item) => item.id === selectedScriptId.value));
|
|
148
166
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
149
167
|
const deviceActions = [
|
|
@@ -155,8 +173,11 @@ const deviceActions = [
|
|
|
155
173
|
{ key: 'tasks', label: '任务', icon: tasksIcon, keyCode: 187 },
|
|
156
174
|
] as const;
|
|
157
175
|
|
|
158
|
-
const selectMenu = (key: string) => {
|
|
159
|
-
|
|
176
|
+
const selectMenu = async (key: string) => {
|
|
177
|
+
const nextMenu = key as MenuKey;
|
|
178
|
+
if (nextMenu === activeMenu.value) return;
|
|
179
|
+
if (activeMenu.value === 'generator' && !(await confirmUnsavedGeneratorNavigation())) return;
|
|
180
|
+
activeMenu.value = nextMenu;
|
|
160
181
|
};
|
|
161
182
|
|
|
162
183
|
const resetGeneratedCodeEditor = () => {
|
|
@@ -166,11 +187,6 @@ const resetGeneratedCodeEditor = () => {
|
|
|
166
187
|
generatedCodeEditSaving.value = false;
|
|
167
188
|
};
|
|
168
189
|
|
|
169
|
-
watch(activeGeneratorMode, () => {
|
|
170
|
-
showGeneratedCode.value = false;
|
|
171
|
-
resetGeneratedCodeEditor();
|
|
172
|
-
});
|
|
173
|
-
|
|
174
190
|
function formatScriptTime(value: string) {
|
|
175
191
|
if (!value) return '';
|
|
176
192
|
const date = new Date(value);
|
|
@@ -455,16 +471,16 @@ const buildSourcePromptForSave = () => {
|
|
|
455
471
|
|
|
456
472
|
const saveCurrentScript = async () => {
|
|
457
473
|
const validInput = validateCurrentGeneratorInputs();
|
|
458
|
-
if (!validInput) return;
|
|
474
|
+
if (!validInput) return false;
|
|
459
475
|
|
|
460
476
|
if (generatedCodeEditing.value) {
|
|
461
477
|
ElMessage.error('请先保存或撤回代码修改');
|
|
462
|
-
return;
|
|
478
|
+
return false;
|
|
463
479
|
}
|
|
464
480
|
|
|
465
481
|
if (!showGeneratedCode.value || !generatedCode.value.trim()) {
|
|
466
482
|
ElMessage.error('请先点击 AI 生成,生成代码后再保存脚本');
|
|
467
|
-
return;
|
|
483
|
+
return false;
|
|
468
484
|
}
|
|
469
485
|
|
|
470
486
|
const { scriptName, promptTitle } = validInput;
|
|
@@ -478,7 +494,7 @@ const saveCurrentScript = async () => {
|
|
|
478
494
|
cancelButtonText: '取消',
|
|
479
495
|
});
|
|
480
496
|
} catch {
|
|
481
|
-
return;
|
|
497
|
+
return false;
|
|
482
498
|
}
|
|
483
499
|
}
|
|
484
500
|
|
|
@@ -495,10 +511,34 @@ const saveCurrentScript = async () => {
|
|
|
495
511
|
});
|
|
496
512
|
|
|
497
513
|
await loadSavedScripts(payload.script?.id || '');
|
|
514
|
+
savedGeneratorSnapshot.value = generatorContentSnapshot.value;
|
|
498
515
|
await sleep(Math.max(0, 1200 - (Date.now() - startedAt)));
|
|
499
516
|
closeActionDialog();
|
|
517
|
+
return true;
|
|
500
518
|
} catch (error) {
|
|
501
519
|
failActionDialog(error instanceof Error ? error.message : '保存脚本失败');
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
const confirmUnsavedGeneratorNavigation = async () => {
|
|
525
|
+
if (!generatorHasUnsavedContent.value) return true;
|
|
526
|
+
|
|
527
|
+
try {
|
|
528
|
+
await ElMessageBox.confirm(
|
|
529
|
+
'当前生成内容尚未保存。可以先保存脚本,或暂不保存并继续切换页面。',
|
|
530
|
+
'未保存的测试脚本',
|
|
531
|
+
{
|
|
532
|
+
type: 'warning',
|
|
533
|
+
confirmButtonText: '保存脚本',
|
|
534
|
+
cancelButtonText: '暂不保存',
|
|
535
|
+
distinguishCancelAndClose: true,
|
|
536
|
+
closeOnClickModal: false,
|
|
537
|
+
},
|
|
538
|
+
);
|
|
539
|
+
return await saveCurrentScript();
|
|
540
|
+
} catch (action) {
|
|
541
|
+
return action === 'cancel';
|
|
502
542
|
}
|
|
503
543
|
};
|
|
504
544
|
|
|
@@ -554,7 +594,7 @@ const generateWithModel = async () => {
|
|
|
554
594
|
errorMessage.value = '';
|
|
555
595
|
const controller = new AbortController();
|
|
556
596
|
aiGenerateAbortController = controller;
|
|
557
|
-
openActionDialog('AI生成', {
|
|
597
|
+
openActionDialog('AI 生成', {
|
|
558
598
|
canRunInBackground: true,
|
|
559
599
|
canCancel: true,
|
|
560
600
|
cancelText: '终止',
|
|
@@ -674,7 +714,7 @@ watch(activeMenu, menu => {
|
|
|
674
714
|
if (!openMenus.value.includes(menu)) openMenus.value.push(menu);
|
|
675
715
|
}, { flush: 'sync' });
|
|
676
716
|
const warnBeforeUnload = (event: BeforeUnloadEvent) => {
|
|
677
|
-
if (!appiumAutoSave.dirty.value) return;
|
|
717
|
+
if (!appiumAutoSave.dirty.value && !generatorHasUnsavedContent.value) return;
|
|
678
718
|
event.preventDefault();
|
|
679
719
|
event.returnValue = '';
|
|
680
720
|
};
|
|
@@ -695,7 +735,9 @@ onUnmounted(() => {
|
|
|
695
735
|
<el-dialog
|
|
696
736
|
v-model="actionDialog.visible"
|
|
697
737
|
:title="actionDialog.title"
|
|
698
|
-
width="
|
|
738
|
+
width="min(440px, calc(100vw - 32px))"
|
|
739
|
+
align-center
|
|
740
|
+
append-to-body
|
|
699
741
|
:close-on-click-modal="false"
|
|
700
742
|
:close-on-press-escape="!actionDialog.loading"
|
|
701
743
|
:show-close="!actionDialog.loading"
|
|
@@ -712,7 +754,7 @@ onUnmounted(() => {
|
|
|
712
754
|
<div v-if="actionDialog.loading" class="action-dialog__footer-actions">
|
|
713
755
|
<el-button
|
|
714
756
|
v-if="actionDialog.canCancel"
|
|
715
|
-
class="action-dialog__footer-button
|
|
757
|
+
class="action-dialog__footer-button"
|
|
716
758
|
:icon="VideoPause"
|
|
717
759
|
:loading="actionDialog.canceling"
|
|
718
760
|
@click="stopGenerateWithModel"
|
|
@@ -721,7 +763,8 @@ onUnmounted(() => {
|
|
|
721
763
|
</el-button>
|
|
722
764
|
<el-button
|
|
723
765
|
v-if="actionDialog.canRunInBackground"
|
|
724
|
-
|
|
766
|
+
type="primary"
|
|
767
|
+
class="action-dialog__footer-button"
|
|
725
768
|
@click="sendActionDialogToBackground"
|
|
726
769
|
>
|
|
727
770
|
后台处理
|
|
@@ -729,7 +772,8 @@ onUnmounted(() => {
|
|
|
729
772
|
</div>
|
|
730
773
|
<el-button
|
|
731
774
|
v-else
|
|
732
|
-
|
|
775
|
+
type="primary"
|
|
776
|
+
class="action-dialog__footer-button"
|
|
733
777
|
@click="closeActionDialog"
|
|
734
778
|
>
|
|
735
779
|
关闭
|
package/src/style.css
CHANGED
|
@@ -68,38 +68,33 @@ select {
|
|
|
68
68
|
display: flex;
|
|
69
69
|
align-items: center;
|
|
70
70
|
justify-content: center;
|
|
71
|
-
gap:
|
|
72
|
-
min-height:
|
|
73
|
-
padding:
|
|
74
|
-
color:
|
|
71
|
+
gap: 10px;
|
|
72
|
+
min-height: 72px;
|
|
73
|
+
padding: 8px 0;
|
|
74
|
+
color: var(--ui-text);
|
|
75
75
|
font-size: 14px;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
.action-dialog__spinner {
|
|
79
|
-
|
|
79
|
+
color: var(--ui-primary);
|
|
80
|
+
font-size: 18px;
|
|
80
81
|
animation: action-dialog-spin 1s linear infinite;
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
.action-dialog__error {
|
|
84
|
-
min-height:
|
|
85
|
-
padding:
|
|
86
|
-
|
|
85
|
+
min-height: 72px;
|
|
86
|
+
padding: 14px 16px;
|
|
87
|
+
border: 1px solid #ffd6d6;
|
|
88
|
+
border-radius: var(--ui-radius-control);
|
|
89
|
+
background: #fff5f5;
|
|
90
|
+
color: var(--ui-danger);
|
|
87
91
|
font-size: 13px;
|
|
88
92
|
line-height: 1.6;
|
|
89
93
|
white-space: pre-wrap;
|
|
90
94
|
}
|
|
91
95
|
|
|
92
|
-
.action-dialog .el-dialog__header {
|
|
93
|
-
margin-right: 0;
|
|
94
|
-
padding: 14px 16px 6px;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
96
|
.action-dialog .el-dialog__body {
|
|
98
|
-
padding:
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
.action-dialog .el-dialog__footer {
|
|
102
|
-
padding: 6px 16px 14px;
|
|
97
|
+
padding-bottom: 14px;
|
|
103
98
|
}
|
|
104
99
|
|
|
105
100
|
.action-dialog__footer-actions {
|
|
@@ -110,44 +105,13 @@ select {
|
|
|
110
105
|
}
|
|
111
106
|
|
|
112
107
|
.action-dialog__footer-button.el-button {
|
|
113
|
-
min-width:
|
|
114
|
-
height: 34px;
|
|
115
|
-
padding: 0 16px;
|
|
116
|
-
border-radius: 7px;
|
|
117
|
-
font-size: 14px;
|
|
118
|
-
font-weight: 700;
|
|
108
|
+
min-width: 104px;
|
|
119
109
|
}
|
|
120
110
|
|
|
121
111
|
.action-dialog__footer-button.el-button + .action-dialog__footer-button.el-button {
|
|
122
112
|
margin-left: 0;
|
|
123
113
|
}
|
|
124
114
|
|
|
125
|
-
.action-dialog__footer-button--secondary.el-button {
|
|
126
|
-
border-color: #e5e7eb;
|
|
127
|
-
background: #fff;
|
|
128
|
-
color: #8b95a5;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
.action-dialog__footer-button--secondary.el-button:hover,
|
|
132
|
-
.action-dialog__footer-button--secondary.el-button:focus {
|
|
133
|
-
border-color: #d7dce5;
|
|
134
|
-
background: #f8fafc;
|
|
135
|
-
color: #6b7280;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
.action-dialog__footer-button--primary.el-button {
|
|
139
|
-
border-color: #ffd436;
|
|
140
|
-
background: #ffd436;
|
|
141
|
-
color: #111827;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
.action-dialog__footer-button--primary.el-button:hover,
|
|
145
|
-
.action-dialog__footer-button--primary.el-button:focus {
|
|
146
|
-
border-color: #ffca1f;
|
|
147
|
-
background: #ffca1f;
|
|
148
|
-
color: #111827;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
115
|
@keyframes action-dialog-spin {
|
|
152
116
|
from {
|
|
153
117
|
transform: rotate(0deg);
|
|
@@ -1298,7 +1262,7 @@ select {
|
|
|
1298
1262
|
|
|
1299
1263
|
.appium-replay-dialog.el-dialog {
|
|
1300
1264
|
display: flex;
|
|
1301
|
-
padding:
|
|
1265
|
+
padding: 0;
|
|
1302
1266
|
height: min(620px, calc(100vh - 64px));
|
|
1303
1267
|
min-width: 420px;
|
|
1304
1268
|
min-height: 280px;
|
|
@@ -1318,16 +1282,16 @@ select {
|
|
|
1318
1282
|
flex: 1 1 auto;
|
|
1319
1283
|
min-height: 0;
|
|
1320
1284
|
overflow: hidden;
|
|
1321
|
-
padding:
|
|
1285
|
+
padding: 16px 20px 0;
|
|
1322
1286
|
}
|
|
1323
1287
|
|
|
1324
1288
|
.appium-replay-dialog > .el-dialog__header {
|
|
1325
|
-
padding:
|
|
1326
|
-
border-bottom:
|
|
1289
|
+
padding: 18px 20px 14px;
|
|
1290
|
+
border-bottom: 1px solid var(--ui-border);
|
|
1327
1291
|
}
|
|
1328
1292
|
|
|
1329
1293
|
.appium-replay-dialog > .el-dialog__footer {
|
|
1330
|
-
padding:
|
|
1294
|
+
padding: 12px 20px 18px;
|
|
1331
1295
|
}
|
|
1332
1296
|
|
|
1333
1297
|
.appium-replay-dialog .appium-replay-output {
|
|
@@ -3592,22 +3556,22 @@ body {
|
|
|
3592
3556
|
flex-direction: column;
|
|
3593
3557
|
height: 86dvh;
|
|
3594
3558
|
max-height: calc(100dvh - 32px);
|
|
3595
|
-
overflow:
|
|
3596
|
-
padding:
|
|
3597
|
-
border:
|
|
3598
|
-
border-radius:
|
|
3599
|
-
box-shadow:
|
|
3559
|
+
overflow: hidden;
|
|
3560
|
+
padding: 0;
|
|
3561
|
+
border: 1px solid var(--ui-border);
|
|
3562
|
+
border-radius: var(--ui-radius);
|
|
3563
|
+
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.18);
|
|
3600
3564
|
}
|
|
3601
3565
|
|
|
3602
3566
|
.appium-flow-dialog .el-dialog__header {
|
|
3603
3567
|
flex: none;
|
|
3604
|
-
padding:
|
|
3605
|
-
border-bottom:
|
|
3568
|
+
padding: 18px 20px 14px;
|
|
3569
|
+
border-bottom: 1px solid var(--ui-border);
|
|
3606
3570
|
}
|
|
3607
3571
|
|
|
3608
3572
|
.appium-flow-dialog .el-dialog__title {
|
|
3609
|
-
font-size:
|
|
3610
|
-
font-weight:
|
|
3573
|
+
font-size: 16px;
|
|
3574
|
+
font-weight: 650;
|
|
3611
3575
|
}
|
|
3612
3576
|
|
|
3613
3577
|
.appium-flow-dialog .el-dialog__body {
|
|
@@ -3615,7 +3579,7 @@ body {
|
|
|
3615
3579
|
flex-direction: column;
|
|
3616
3580
|
flex: 1;
|
|
3617
3581
|
min-height: 0;
|
|
3618
|
-
padding:
|
|
3582
|
+
padding: 16px 20px 20px;
|
|
3619
3583
|
}
|
|
3620
3584
|
|
|
3621
3585
|
.appium-flow-dialog__toolbar { flex: none; }
|