agent-device 0.2.4 → 0.2.6
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/README.md +41 -4
- package/dist/src/bin.js +26 -21
- package/dist/src/daemon.js +9 -8
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj +2 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +16 -0
- package/package.json +3 -2
- package/skills/agent-device/SKILL.md +22 -6
- package/skills/agent-device/references/session-management.md +9 -0
- package/skills/agent-device/references/snapshot-refs.md +18 -5
- package/skills/agent-device/references/video-recording.md +2 -2
- package/src/cli.ts +6 -0
- package/src/core/__tests__/capabilities.test.ts +67 -0
- package/src/core/capabilities.ts +49 -0
- package/src/core/dispatch.ts +29 -118
- package/src/daemon/__tests__/is-predicates.test.ts +68 -0
- package/src/daemon/__tests__/selectors.test.ts +128 -0
- package/src/daemon/__tests__/session-routing.test.ts +108 -0
- package/src/daemon/__tests__/session-selector.test.ts +64 -0
- package/src/daemon/__tests__/session-store.test.ts +95 -0
- package/src/daemon/__tests__/snapshot-processing.test.ts +47 -0
- package/src/daemon/action-utils.ts +29 -0
- package/src/daemon/app-state.ts +66 -0
- package/src/daemon/context.ts +36 -0
- package/src/daemon/device-ready.ts +13 -0
- package/src/daemon/handlers/__tests__/find.test.ts +99 -0
- package/src/daemon/handlers/__tests__/replay-heal.test.ts +364 -0
- package/src/daemon/handlers/__tests__/snapshot.test.ts +128 -0
- package/src/daemon/handlers/find.ts +304 -0
- package/src/daemon/handlers/interaction.ts +510 -0
- package/src/daemon/handlers/parse-utils.ts +8 -0
- package/src/daemon/handlers/record-trace.ts +154 -0
- package/src/daemon/handlers/session.ts +732 -0
- package/src/daemon/handlers/snapshot.ts +396 -0
- package/src/daemon/is-predicates.ts +46 -0
- package/src/daemon/selectors.ts +423 -0
- package/src/daemon/session-routing.ts +22 -0
- package/src/daemon/session-selector.ts +39 -0
- package/src/daemon/session-store.ts +275 -0
- package/src/daemon/snapshot-processing.ts +127 -0
- package/src/daemon/types.ts +55 -0
- package/src/daemon.ts +66 -1592
- package/src/platforms/ios/index.ts +0 -62
- package/src/platforms/ios/runner-client.ts +2 -0
- package/src/utils/args.ts +19 -10
- package/src/utils/interactors.ts +102 -16
- package/src/utils/snapshot.ts +1 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import type { CommandFlags } from '../../../core/dispatch.ts';
|
|
7
|
+
import { handleSessionCommands } from '../session.ts';
|
|
8
|
+
import { SessionStore } from '../../session-store.ts';
|
|
9
|
+
import type { DaemonRequest, DaemonResponse, SessionAction, SessionState } from '../../types.ts';
|
|
10
|
+
import type { DeviceInfo } from '../../../utils/device.ts';
|
|
11
|
+
|
|
12
|
+
function makeDevice(): DeviceInfo {
|
|
13
|
+
return {
|
|
14
|
+
platform: 'ios',
|
|
15
|
+
id: 'sim-1',
|
|
16
|
+
name: 'iPhone Test',
|
|
17
|
+
kind: 'simulator',
|
|
18
|
+
booted: true,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function makeSession(name: string): SessionState {
|
|
23
|
+
return {
|
|
24
|
+
name,
|
|
25
|
+
device: makeDevice(),
|
|
26
|
+
createdAt: Date.now(),
|
|
27
|
+
appBundleId: 'com.example.app',
|
|
28
|
+
actions: [],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function writeReplayFile(filePath: string, action: SessionAction) {
|
|
33
|
+
const args = action.positionals.map((value) => JSON.stringify(value)).join(' ');
|
|
34
|
+
fs.writeFileSync(filePath, `${action.command}${args.length > 0 ? ` ${args}` : ''}\n`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readReplaySelector(filePath: string, command: string): string {
|
|
38
|
+
const lines = fs
|
|
39
|
+
.readFileSync(filePath, 'utf8')
|
|
40
|
+
.split(/\r?\n/)
|
|
41
|
+
.map((line) => line.trim())
|
|
42
|
+
.filter((line) => line.length > 0);
|
|
43
|
+
const line = lines.find((entry) => entry.startsWith(`${command} `) || entry === command);
|
|
44
|
+
if (!line) return '';
|
|
45
|
+
const args = tokenizeReplayLine(line).slice(1);
|
|
46
|
+
if (command === 'is') {
|
|
47
|
+
return args[1] ?? '';
|
|
48
|
+
}
|
|
49
|
+
return args[0] ?? '';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function tokenizeReplayLine(line: string): string[] {
|
|
53
|
+
const tokens: string[] = [];
|
|
54
|
+
let cursor = 0;
|
|
55
|
+
while (cursor < line.length) {
|
|
56
|
+
while (cursor < line.length && /\s/.test(line[cursor])) {
|
|
57
|
+
cursor += 1;
|
|
58
|
+
}
|
|
59
|
+
if (cursor >= line.length) break;
|
|
60
|
+
if (line[cursor] === '"') {
|
|
61
|
+
let end = cursor + 1;
|
|
62
|
+
let escaped = false;
|
|
63
|
+
while (end < line.length) {
|
|
64
|
+
const char = line[end];
|
|
65
|
+
if (char === '"' && !escaped) break;
|
|
66
|
+
escaped = char === '\\' && !escaped;
|
|
67
|
+
if (char !== '\\') escaped = false;
|
|
68
|
+
end += 1;
|
|
69
|
+
}
|
|
70
|
+
if (end >= line.length) {
|
|
71
|
+
throw new Error(`Invalid replay script line: ${line}`);
|
|
72
|
+
}
|
|
73
|
+
tokens.push(JSON.parse(line.slice(cursor, end + 1)) as string);
|
|
74
|
+
cursor = end + 1;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
let end = cursor;
|
|
78
|
+
while (end < line.length && !/\s/.test(line[end])) {
|
|
79
|
+
end += 1;
|
|
80
|
+
}
|
|
81
|
+
tokens.push(line.slice(cursor, end));
|
|
82
|
+
cursor = end;
|
|
83
|
+
}
|
|
84
|
+
return tokens;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
test('replay --update heals selector and rewrites replay file', async () => {
|
|
88
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-heal-'));
|
|
89
|
+
const sessionsDir = path.join(tempRoot, 'sessions');
|
|
90
|
+
const replayPath = path.join(tempRoot, 'replay.ad');
|
|
91
|
+
const sessionStore = new SessionStore(sessionsDir);
|
|
92
|
+
const sessionName = 'heal-session';
|
|
93
|
+
sessionStore.set(sessionName, makeSession(sessionName));
|
|
94
|
+
|
|
95
|
+
writeReplayFile(replayPath, {
|
|
96
|
+
ts: Date.now(),
|
|
97
|
+
command: 'click',
|
|
98
|
+
positionals: ['id="old_continue" || label="Continue"'],
|
|
99
|
+
flags: {},
|
|
100
|
+
result: {},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const invokeCalls: string[] = [];
|
|
104
|
+
const invoke = async (request: DaemonRequest): Promise<DaemonResponse> => {
|
|
105
|
+
if (request.command !== 'click') {
|
|
106
|
+
return { ok: false, error: { code: 'INVALID_ARGS', message: `unexpected command ${request.command}` } };
|
|
107
|
+
}
|
|
108
|
+
const selector = request.positionals?.[0] ?? '';
|
|
109
|
+
invokeCalls.push(selector);
|
|
110
|
+
if (selector.includes('old_continue')) {
|
|
111
|
+
return { ok: false, error: { code: 'COMMAND_FAILED', message: 'selector no longer exists' } };
|
|
112
|
+
}
|
|
113
|
+
if (selector.includes('auth_continue')) {
|
|
114
|
+
return { ok: true, data: { clicked: true } };
|
|
115
|
+
}
|
|
116
|
+
return { ok: false, error: { code: 'COMMAND_FAILED', message: 'unexpected selector' } };
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
let snapshotDispatchCalls = 0;
|
|
120
|
+
const dispatch = async (
|
|
121
|
+
_device: DeviceInfo,
|
|
122
|
+
command: string,
|
|
123
|
+
_positionals: string[],
|
|
124
|
+
_out?: string,
|
|
125
|
+
_context?: CommandFlags,
|
|
126
|
+
): Promise<Record<string, unknown> | void> => {
|
|
127
|
+
if (command !== 'snapshot') {
|
|
128
|
+
throw new Error(`unexpected dispatch command: ${command}`);
|
|
129
|
+
}
|
|
130
|
+
snapshotDispatchCalls += 1;
|
|
131
|
+
return {
|
|
132
|
+
nodes: [
|
|
133
|
+
{
|
|
134
|
+
index: 0,
|
|
135
|
+
type: 'XCUIElementTypeButton',
|
|
136
|
+
label: 'Continue',
|
|
137
|
+
identifier: 'auth_continue',
|
|
138
|
+
rect: { x: 10, y: 10, width: 100, height: 44 },
|
|
139
|
+
enabled: true,
|
|
140
|
+
hittable: true,
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
truncated: false,
|
|
144
|
+
backend: 'xctest',
|
|
145
|
+
};
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const response = await handleSessionCommands({
|
|
149
|
+
req: {
|
|
150
|
+
token: 't',
|
|
151
|
+
session: sessionName,
|
|
152
|
+
command: 'replay',
|
|
153
|
+
positionals: [replayPath],
|
|
154
|
+
flags: { replayUpdate: true },
|
|
155
|
+
},
|
|
156
|
+
sessionName,
|
|
157
|
+
logPath: path.join(tempRoot, 'daemon.log'),
|
|
158
|
+
sessionStore,
|
|
159
|
+
invoke,
|
|
160
|
+
dispatch,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
assert.ok(response);
|
|
164
|
+
assert.equal(response.ok, true, JSON.stringify(response));
|
|
165
|
+
if (response.ok) {
|
|
166
|
+
assert.equal(response.data?.healed, 1);
|
|
167
|
+
assert.equal(response.data?.replayed, 1);
|
|
168
|
+
}
|
|
169
|
+
assert.equal(snapshotDispatchCalls, 1);
|
|
170
|
+
assert.equal(invokeCalls.length, 2);
|
|
171
|
+
assert.ok(invokeCalls[0].includes('old_continue'));
|
|
172
|
+
assert.ok(invokeCalls[1].includes('auth_continue'));
|
|
173
|
+
const rewrittenSelector = readReplaySelector(replayPath, 'click');
|
|
174
|
+
assert.ok(rewrittenSelector.includes('auth_continue'));
|
|
175
|
+
assert.ok(!rewrittenSelector.includes('old_continue'));
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('replay without --update does not heal or rewrite', async () => {
|
|
179
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-noheal-'));
|
|
180
|
+
const sessionsDir = path.join(tempRoot, 'sessions');
|
|
181
|
+
const replayPath = path.join(tempRoot, 'replay.ad');
|
|
182
|
+
const sessionStore = new SessionStore(sessionsDir);
|
|
183
|
+
const sessionName = 'noheal-session';
|
|
184
|
+
sessionStore.set(sessionName, makeSession(sessionName));
|
|
185
|
+
|
|
186
|
+
writeReplayFile(replayPath, {
|
|
187
|
+
ts: Date.now(),
|
|
188
|
+
command: 'click',
|
|
189
|
+
positionals: ['id="old_continue" || label="Continue"'],
|
|
190
|
+
flags: {},
|
|
191
|
+
result: {},
|
|
192
|
+
});
|
|
193
|
+
const originalPayload = fs.readFileSync(replayPath, 'utf8');
|
|
194
|
+
|
|
195
|
+
const invoke = async (_request: DaemonRequest): Promise<DaemonResponse> => {
|
|
196
|
+
return { ok: false, error: { code: 'COMMAND_FAILED', message: 'selector no longer exists' } };
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
let snapshotDispatchCalls = 0;
|
|
200
|
+
const dispatch = async (
|
|
201
|
+
_device: DeviceInfo,
|
|
202
|
+
_command: string,
|
|
203
|
+
_positionals: string[],
|
|
204
|
+
_out?: string,
|
|
205
|
+
_context?: CommandFlags,
|
|
206
|
+
): Promise<Record<string, unknown> | void> => {
|
|
207
|
+
snapshotDispatchCalls += 1;
|
|
208
|
+
return {};
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const response = await handleSessionCommands({
|
|
212
|
+
req: {
|
|
213
|
+
token: 't',
|
|
214
|
+
session: sessionName,
|
|
215
|
+
command: 'replay',
|
|
216
|
+
positionals: [replayPath],
|
|
217
|
+
flags: {},
|
|
218
|
+
},
|
|
219
|
+
sessionName,
|
|
220
|
+
logPath: path.join(tempRoot, 'daemon.log'),
|
|
221
|
+
sessionStore,
|
|
222
|
+
invoke,
|
|
223
|
+
dispatch,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
assert.ok(response);
|
|
227
|
+
assert.equal(response.ok, false);
|
|
228
|
+
assert.equal(snapshotDispatchCalls, 0);
|
|
229
|
+
assert.equal(fs.readFileSync(replayPath, 'utf8'), originalPayload);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('replay --update heals selector in is command', async () => {
|
|
233
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-heal-is-'));
|
|
234
|
+
const sessionsDir = path.join(tempRoot, 'sessions');
|
|
235
|
+
const replayPath = path.join(tempRoot, 'replay.ad');
|
|
236
|
+
const sessionStore = new SessionStore(sessionsDir);
|
|
237
|
+
const sessionName = 'heal-is-session';
|
|
238
|
+
sessionStore.set(sessionName, makeSession(sessionName));
|
|
239
|
+
|
|
240
|
+
writeReplayFile(replayPath, {
|
|
241
|
+
ts: Date.now(),
|
|
242
|
+
command: 'is',
|
|
243
|
+
positionals: ['visible', 'id="old_continue" || label="Continue"'],
|
|
244
|
+
flags: {},
|
|
245
|
+
result: {},
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const invoke = async (request: DaemonRequest): Promise<DaemonResponse> => {
|
|
249
|
+
if (request.command !== 'is') {
|
|
250
|
+
return { ok: false, error: { code: 'INVALID_ARGS', message: `unexpected command ${request.command}` } };
|
|
251
|
+
}
|
|
252
|
+
const selector = request.positionals?.[1] ?? '';
|
|
253
|
+
if (selector.includes('old_continue')) {
|
|
254
|
+
return { ok: false, error: { code: 'COMMAND_FAILED', message: 'selector stale' } };
|
|
255
|
+
}
|
|
256
|
+
if (selector.includes('auth_continue')) {
|
|
257
|
+
return { ok: true, data: { predicate: 'visible', pass: true } };
|
|
258
|
+
}
|
|
259
|
+
return { ok: false, error: { code: 'COMMAND_FAILED', message: 'unexpected selector' } };
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const dispatch = async (): Promise<Record<string, unknown> | void> => {
|
|
263
|
+
return {
|
|
264
|
+
nodes: [
|
|
265
|
+
{
|
|
266
|
+
index: 0,
|
|
267
|
+
type: 'XCUIElementTypeButton',
|
|
268
|
+
label: 'Continue',
|
|
269
|
+
identifier: 'auth_continue',
|
|
270
|
+
rect: { x: 10, y: 10, width: 100, height: 44 },
|
|
271
|
+
enabled: true,
|
|
272
|
+
hittable: true,
|
|
273
|
+
},
|
|
274
|
+
],
|
|
275
|
+
truncated: false,
|
|
276
|
+
backend: 'xctest',
|
|
277
|
+
};
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const response = await handleSessionCommands({
|
|
281
|
+
req: {
|
|
282
|
+
token: 't',
|
|
283
|
+
session: sessionName,
|
|
284
|
+
command: 'replay',
|
|
285
|
+
positionals: [replayPath],
|
|
286
|
+
flags: { replayUpdate: true },
|
|
287
|
+
},
|
|
288
|
+
sessionName,
|
|
289
|
+
logPath: path.join(tempRoot, 'daemon.log'),
|
|
290
|
+
sessionStore,
|
|
291
|
+
invoke,
|
|
292
|
+
dispatch,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
assert.ok(response);
|
|
296
|
+
assert.equal(response.ok, true, JSON.stringify(response));
|
|
297
|
+
if (response.ok) {
|
|
298
|
+
assert.equal(response.data?.healed, 1);
|
|
299
|
+
}
|
|
300
|
+
const rewrittenSelector = readReplaySelector(replayPath, 'is');
|
|
301
|
+
assert.ok(rewrittenSelector.includes('auth_continue'));
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('replay rejects legacy JSON payload files', async () => {
|
|
305
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-json-rejected-'));
|
|
306
|
+
const sessionsDir = path.join(tempRoot, 'sessions');
|
|
307
|
+
const replayPath = path.join(tempRoot, 'replay.json');
|
|
308
|
+
const sessionStore = new SessionStore(sessionsDir);
|
|
309
|
+
const sessionName = 'json-rejected-session';
|
|
310
|
+
sessionStore.set(sessionName, makeSession(sessionName));
|
|
311
|
+
fs.writeFileSync(replayPath, JSON.stringify({ optimizedActions: [] }, null, 2));
|
|
312
|
+
|
|
313
|
+
const response = await handleSessionCommands({
|
|
314
|
+
req: {
|
|
315
|
+
token: 't',
|
|
316
|
+
session: sessionName,
|
|
317
|
+
command: 'replay',
|
|
318
|
+
positionals: [replayPath],
|
|
319
|
+
flags: {},
|
|
320
|
+
},
|
|
321
|
+
sessionName,
|
|
322
|
+
logPath: path.join(tempRoot, 'daemon.log'),
|
|
323
|
+
sessionStore,
|
|
324
|
+
invoke: async () => ({ ok: true, data: {} }),
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
assert.ok(response);
|
|
328
|
+
assert.equal(response.ok, false);
|
|
329
|
+
if (!response.ok) {
|
|
330
|
+
assert.equal(response.error.code, 'INVALID_ARGS');
|
|
331
|
+
assert.match(response.error.message, /\.ad script files/);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test('replay rejects malformed .ad lines with unclosed quotes', async () => {
|
|
336
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-invalid-ad-'));
|
|
337
|
+
const sessionsDir = path.join(tempRoot, 'sessions');
|
|
338
|
+
const replayPath = path.join(tempRoot, 'replay.ad');
|
|
339
|
+
const sessionStore = new SessionStore(sessionsDir);
|
|
340
|
+
const sessionName = 'invalid-ad-session';
|
|
341
|
+
sessionStore.set(sessionName, makeSession(sessionName));
|
|
342
|
+
fs.writeFileSync(replayPath, 'click "id=\\"broken\\"\n');
|
|
343
|
+
|
|
344
|
+
const response = await handleSessionCommands({
|
|
345
|
+
req: {
|
|
346
|
+
token: 't',
|
|
347
|
+
session: sessionName,
|
|
348
|
+
command: 'replay',
|
|
349
|
+
positionals: [replayPath],
|
|
350
|
+
flags: {},
|
|
351
|
+
},
|
|
352
|
+
sessionName,
|
|
353
|
+
logPath: path.join(tempRoot, 'daemon.log'),
|
|
354
|
+
sessionStore,
|
|
355
|
+
invoke: async () => ({ ok: true, data: {} }),
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
assert.ok(response);
|
|
359
|
+
assert.equal(response.ok, false);
|
|
360
|
+
if (!response.ok) {
|
|
361
|
+
assert.equal(response.error.code, 'INVALID_ARGS');
|
|
362
|
+
assert.match(response.error.message, /Invalid replay script line/);
|
|
363
|
+
}
|
|
364
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { parseWaitArgs } from '../snapshot.ts';
|
|
4
|
+
import { parseTimeout } from '../parse-utils.ts';
|
|
5
|
+
|
|
6
|
+
// --- parseTimeout ---
|
|
7
|
+
|
|
8
|
+
test('parseTimeout parses integer string', () => {
|
|
9
|
+
assert.equal(parseTimeout('500'), 500);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('parseTimeout parses zero', () => {
|
|
13
|
+
assert.equal(parseTimeout('0'), 0);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('parseTimeout returns null for non-numeric string', () => {
|
|
17
|
+
assert.equal(parseTimeout('abc'), null);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('parseTimeout returns null for Infinity', () => {
|
|
21
|
+
assert.equal(parseTimeout('Infinity'), null);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// --- parseWaitArgs ---
|
|
25
|
+
|
|
26
|
+
test('parseWaitArgs returns null for empty args', () => {
|
|
27
|
+
assert.equal(parseWaitArgs([]), null);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('parseWaitArgs returns sleep for numeric first arg', () => {
|
|
31
|
+
const result = parseWaitArgs(['500']);
|
|
32
|
+
assert.deepEqual(result, { kind: 'sleep', durationMs: 500 });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('parseWaitArgs returns sleep for zero', () => {
|
|
36
|
+
const result = parseWaitArgs(['0']);
|
|
37
|
+
assert.deepEqual(result, { kind: 'sleep', durationMs: 0 });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('parseWaitArgs parses text keyword with label', () => {
|
|
41
|
+
const result = parseWaitArgs(['text', 'Loading']);
|
|
42
|
+
assert.deepEqual(result, { kind: 'text', text: 'Loading', timeoutMs: null });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('parseWaitArgs parses text keyword with timeout', () => {
|
|
46
|
+
const result = parseWaitArgs(['text', 'Loading', '5000']);
|
|
47
|
+
assert.deepEqual(result, { kind: 'text', text: 'Loading', timeoutMs: 5000 });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('parseWaitArgs parses text keyword with multi-word and timeout', () => {
|
|
51
|
+
const result = parseWaitArgs(['text', 'Sign', 'In', '3000']);
|
|
52
|
+
assert.deepEqual(result, { kind: 'text', text: 'Sign In', timeoutMs: 3000 });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('parseWaitArgs parses text keyword with multi-word and no timeout', () => {
|
|
56
|
+
const result = parseWaitArgs(['text', 'Sign', 'In']);
|
|
57
|
+
assert.deepEqual(result, { kind: 'text', text: 'Sign In', timeoutMs: null });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('parseWaitArgs text keyword alone yields empty text', () => {
|
|
61
|
+
const result = parseWaitArgs(['text']);
|
|
62
|
+
assert.deepEqual(result, { kind: 'text', text: '', timeoutMs: null });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('parseWaitArgs parses ref', () => {
|
|
66
|
+
const result = parseWaitArgs(['@e3']);
|
|
67
|
+
assert.deepEqual(result, { kind: 'ref', rawRef: '@e3', timeoutMs: null });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('parseWaitArgs parses ref with timeout', () => {
|
|
71
|
+
const result = parseWaitArgs(['@e3', '5000']);
|
|
72
|
+
assert.deepEqual(result, { kind: 'ref', rawRef: '@e3', timeoutMs: 5000 });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('parseWaitArgs parses ref with non-numeric trailing arg as no timeout', () => {
|
|
76
|
+
const result = parseWaitArgs(['@e3', 'abc']);
|
|
77
|
+
assert.deepEqual(result, { kind: 'ref', rawRef: '@e3', timeoutMs: null });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('parseWaitArgs parses bare text', () => {
|
|
81
|
+
const result = parseWaitArgs(['Hello']);
|
|
82
|
+
assert.deepEqual(result, { kind: 'text', text: 'Hello', timeoutMs: null });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('parseWaitArgs parses bare text with timeout', () => {
|
|
86
|
+
const result = parseWaitArgs(['Hello', '5000']);
|
|
87
|
+
assert.deepEqual(result, { kind: 'text', text: 'Hello', timeoutMs: 5000 });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('parseWaitArgs parses selector expression', () => {
|
|
91
|
+
const result = parseWaitArgs(['id=login_email']);
|
|
92
|
+
assert.ok(result);
|
|
93
|
+
assert.equal(result.kind, 'selector');
|
|
94
|
+
if (result.kind === 'selector') {
|
|
95
|
+
assert.equal(result.selectorExpression, 'id=login_email');
|
|
96
|
+
assert.equal(result.timeoutMs, null);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('parseWaitArgs parses selector expression with timeout', () => {
|
|
101
|
+
const result = parseWaitArgs(['id=login_email', '5000']);
|
|
102
|
+
assert.ok(result);
|
|
103
|
+
assert.equal(result.kind, 'selector');
|
|
104
|
+
if (result.kind === 'selector') {
|
|
105
|
+
assert.equal(result.selectorExpression, 'id=login_email');
|
|
106
|
+
assert.equal(result.timeoutMs, 5000);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('parseWaitArgs falls back to text when selector-like token is invalid', () => {
|
|
111
|
+
const result = parseWaitArgs(['foo=bar', '5000']);
|
|
112
|
+
assert.deepEqual(result, { kind: 'text', text: 'foo=bar', timeoutMs: 5000 });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('parseWaitArgs parses bare multi-word text', () => {
|
|
116
|
+
const result = parseWaitArgs(['Sign', 'In']);
|
|
117
|
+
assert.deepEqual(result, { kind: 'text', text: 'Sign In', timeoutMs: null });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('parseWaitArgs parses bare multi-word text with timeout', () => {
|
|
121
|
+
const result = parseWaitArgs(['Sign', 'In', '3000']);
|
|
122
|
+
assert.deepEqual(result, { kind: 'text', text: 'Sign In', timeoutMs: 3000 });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('parseWaitArgs text keyword with non-numeric trailing leaves timeoutMs null', () => {
|
|
126
|
+
const result = parseWaitArgs(['text', 'Loading', 'abc']);
|
|
127
|
+
assert.deepEqual(result, { kind: 'text', text: 'Loading abc', timeoutMs: null });
|
|
128
|
+
});
|