@skrillex1224/android-toolkit 0.1.9 → 1.0.1
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 +51 -64
- package/browser.d.ts +31 -0
- package/dist/browser.js +107 -0
- package/dist/browser.js.map +7 -0
- package/dist/index.cjs +1910 -0
- package/dist/index.cjs.map +7 -0
- package/dist/index.js +1880 -0
- package/dist/index.js.map +7 -0
- package/index.d.ts +157 -184
- package/package.json +30 -10
- package/entrys/node.js +0 -26
- package/index.js +0 -1
- package/scripts/postinstall.js +0 -72
- package/src/apify-kit.js +0 -217
- package/src/constants.js +0 -75
- package/src/context.js +0 -89
- package/src/device.js +0 -761
- package/src/errors.js +0 -37
- package/src/frida-client.js +0 -1074
- package/src/internals/compression.js +0 -188
- package/src/internals/frida/webview_event_agent.js +0 -227
- package/src/launch.js +0 -70
- package/src/logger.js +0 -111
- package/src/share.js +0 -644
package/src/device.js
DELETED
|
@@ -1,761 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import zlib from 'node:zlib';
|
|
3
|
-
import {execFile} from 'node:child_process';
|
|
4
|
-
import {promisify} from 'node:util';
|
|
5
|
-
|
|
6
|
-
import {Code} from './constants.js';
|
|
7
|
-
import {CrawlerError} from './errors.js';
|
|
8
|
-
import {Logger, sleep} from './logger.js';
|
|
9
|
-
|
|
10
|
-
const execFileAsync = promisify(execFile);
|
|
11
|
-
|
|
12
|
-
export async function adbShell(ctx, args, options = {}) {
|
|
13
|
-
// 在指定设备上执行 `adb shell ...`,所有设备侧命令都从这里收口。
|
|
14
|
-
if (!ctx.serial) throw new Error('device.serial is empty');
|
|
15
|
-
return adbExec(ctx, ['-s', ctx.serial, 'shell', ...args], options);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export async function adbExec(ctx, args, options = {}) {
|
|
19
|
-
// 执行原始 adb 命令;当云手机/TCP ADB 临时 offline 时,自动 adb connect 后重放一次。
|
|
20
|
-
const startedAt = Date.now();
|
|
21
|
-
Logger.debug('adb exec', {serial: ctx.serial, args: compactArgs(args), timeoutMs: Number(options.timeoutMs || 15000)});
|
|
22
|
-
try {
|
|
23
|
-
const {stdout} = await execFileAsync(ctx.adbPath, args, {
|
|
24
|
-
timeout: Number(options.timeoutMs || 15000),
|
|
25
|
-
maxBuffer: Number(options.maxBuffer || 12 * 1024 * 1024),
|
|
26
|
-
encoding: options.encoding || 'utf8'
|
|
27
|
-
});
|
|
28
|
-
Logger.debug('adb exec done', {serial: ctx.serial, duration: Logger.duration(startedAt)});
|
|
29
|
-
return stdout;
|
|
30
|
-
} catch (error) {
|
|
31
|
-
// ADB over TCP 的云手机/云真机场景经常临时 offline。
|
|
32
|
-
// 这里统一做一次 adb connect + 原命令重放,业务层不需要关心连接抖动。
|
|
33
|
-
if (!isAdbOfflineError(error) || !ctx.serial) {
|
|
34
|
-
if (isAdbUnavailableError(error)) throw makeAdbUnavailableError(ctx, args, error);
|
|
35
|
-
Logger.warn('adb exec failed', {serial: ctx.serial, args: compactArgs(args), message: error?.message || String(error)});
|
|
36
|
-
throw error;
|
|
37
|
-
}
|
|
38
|
-
Logger.warn('adb offline, reconnecting', {serial: ctx.serial, args: compactArgs(args)});
|
|
39
|
-
await execFileAsync(ctx.adbPath, ['connect', ctx.serial], {timeout: 15000}).catch(() => { });
|
|
40
|
-
await sleep(1200);
|
|
41
|
-
try {
|
|
42
|
-
const {stdout} = await execFileAsync(ctx.adbPath, args, {
|
|
43
|
-
timeout: Number(options.timeoutMs || 15000),
|
|
44
|
-
maxBuffer: Number(options.maxBuffer || 12 * 1024 * 1024),
|
|
45
|
-
encoding: options.encoding || 'utf8'
|
|
46
|
-
});
|
|
47
|
-
Logger.debug('adb exec retry done', {serial: ctx.serial, duration: Logger.duration(startedAt)});
|
|
48
|
-
return stdout;
|
|
49
|
-
} catch (retryError) {
|
|
50
|
-
if (isAdbUnavailableError(retryError)) throw makeAdbUnavailableError(ctx, args, retryError);
|
|
51
|
-
Logger.warn('adb exec retry failed', {serial: ctx.serial, args: compactArgs(args), message: retryError?.message || String(retryError)});
|
|
52
|
-
throw retryError;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export async function forceStopApp(ctx, packageName = ctx.packageName) {
|
|
58
|
-
// 强制关闭 App,用于每条任务开始前清理上一次页面状态。
|
|
59
|
-
Logger.info('force stop app', {serial: ctx.serial, packageName});
|
|
60
|
-
await adbShell(ctx, ['am', 'force-stop', packageName]).catch(() => { });
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export async function startActivity(ctx, component, args = []) {
|
|
64
|
-
// 启动指定 Android Activity,component 形如 `com.tencent.mm/.ui.LauncherUI`。
|
|
65
|
-
Logger.info('start activity', {serial: ctx.serial, component});
|
|
66
|
-
await adbShell(ctx, ['am', 'start', '-n', component, ...args]);
|
|
67
|
-
await sleep(1800);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export async function startLauncher(ctx, packageName = ctx.packageName, launcherActivity = '.ui.LauncherUI') {
|
|
71
|
-
// 按 launcher 方式启动 App 首页;业务方只需要传包名和首页 Activity。
|
|
72
|
-
await startActivity(ctx, `${packageName}/${launcherActivity}`, [
|
|
73
|
-
'-a', 'android.intent.action.MAIN',
|
|
74
|
-
'-c', 'android.intent.category.LAUNCHER'
|
|
75
|
-
]);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export async function waitForForeground(ctx, packageName = ctx.packageName, timeoutMs = 6000) {
|
|
79
|
-
// 轮询前台 Activity,直到目标包名进入前台或超时。
|
|
80
|
-
const deadline = Date.now() + timeoutMs;
|
|
81
|
-
while (Date.now() < deadline) {
|
|
82
|
-
const focused = await focusedActivity(ctx).catch(() => '');
|
|
83
|
-
if (focused.includes(packageName)) return true;
|
|
84
|
-
await sleep(500);
|
|
85
|
-
}
|
|
86
|
-
return false;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export async function screenSize(ctx) {
|
|
90
|
-
// 读取屏幕尺寸,失败时给一个常见竖屏默认值,避免后续坐标计算崩掉。
|
|
91
|
-
const out = await adbShell(ctx, ['wm', 'size']).catch(() => '');
|
|
92
|
-
const match = /(\d+)x(\d+)/.exec(String(out || ''));
|
|
93
|
-
return {
|
|
94
|
-
width: match ? Number(match[1]) : 720,
|
|
95
|
-
height: match ? Number(match[2]) : 1280
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export async function screenDensity(ctx) {
|
|
100
|
-
const out = await adbShell(ctx, ['wm', 'density']).catch(() => '');
|
|
101
|
-
const match = /(\d+)/.exec(String(out || ''));
|
|
102
|
-
return match ? Number(match[1]) : 0;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export async function overrideScreenSize(ctx, width, height) {
|
|
106
|
-
const safeWidth = Math.max(1, Math.round(Number(width) || 0));
|
|
107
|
-
const safeHeight = Math.max(1, Math.round(Number(height) || 0));
|
|
108
|
-
await adbShell(ctx, ['wm', 'size', `${safeWidth}x${safeHeight}`], {timeoutMs: 15000});
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
export async function resetScreenSize(ctx) {
|
|
112
|
-
await adbShell(ctx, ['wm', 'size', 'reset'], {timeoutMs: 15000});
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export async function resetScreenDensity(ctx) {
|
|
116
|
-
await adbShell(ctx, ['wm', 'density', 'reset'], {timeoutMs: 15000});
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
export async function tapAbsolute(ctx, x, y) {
|
|
120
|
-
// 点击绝对像素坐标,适合已经从截图或 DOM rect 算出的精确点。
|
|
121
|
-
await adbInput(ctx, ['tap', String(Math.round(Number(x))), String(Math.round(Number(y)))]);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export async function click(ctx, pointOrX, y) {
|
|
125
|
-
// 对齐 Playwright 的直觉命名:业务代码可以写 Device.click(ctx, point),
|
|
126
|
-
// 也可以直接传 View/DOM bounds。toolkit 统一判断点击中心点。
|
|
127
|
-
if (pointOrX && typeof pointOrX === 'object') {
|
|
128
|
-
if (isBounds(pointOrX)) {
|
|
129
|
-
return tapAbsolute(ctx, (Number(pointOrX.left) + Number(pointOrX.right)) / 2, (Number(pointOrX.top) + Number(pointOrX.bottom)) / 2);
|
|
130
|
-
}
|
|
131
|
-
if (isBounds(pointOrX.bounds)) {
|
|
132
|
-
return tapAbsolute(ctx, (Number(pointOrX.bounds.left) + Number(pointOrX.bounds.right)) / 2, (Number(pointOrX.bounds.top) + Number(pointOrX.bounds.bottom)) / 2);
|
|
133
|
-
}
|
|
134
|
-
return tapAbsolute(ctx, pointOrX.x, pointOrX.y);
|
|
135
|
-
}
|
|
136
|
-
return tapAbsolute(ctx, pointOrX, y);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
export async function move(ctx, from, to, durationMs = 500) {
|
|
140
|
-
// ADB 没有鼠标 hover 概念,这里的 move 表示拖动/滑动手势。
|
|
141
|
-
// from/to 使用屏幕绝对坐标,便于和截图/其他定位结果直接衔接。
|
|
142
|
-
await adbInput(ctx, [
|
|
143
|
-
'swipe',
|
|
144
|
-
String(Math.round(Number(from?.x))),
|
|
145
|
-
String(Math.round(Number(from?.y))),
|
|
146
|
-
String(Math.round(Number(to?.x))),
|
|
147
|
-
String(Math.round(Number(to?.y))),
|
|
148
|
-
String(durationMs)
|
|
149
|
-
]);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
export async function pressBack(ctx) {
|
|
153
|
-
// 发送 Android 返回键。
|
|
154
|
-
await adbInput(ctx, ['keyevent', 'BACK']).catch(() => { });
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
export async function dismissPermissionDialog(ctx) {
|
|
158
|
-
// Android 运行时权限弹窗会把原 App 的 Activity 压在下面。
|
|
159
|
-
// 这里只处理系统 permission controller:点击底部“拒绝”按钮区域,避免采集任务卡死。
|
|
160
|
-
const focused = await focusedActivity(ctx).catch(() => '');
|
|
161
|
-
if (!String(focused || '').includes('GrantPermissionsActivity')) return false;
|
|
162
|
-
const size = await screenSize(ctx);
|
|
163
|
-
await tapAbsolute(ctx, size.width * 0.50, size.height * 0.63).catch(() => { });
|
|
164
|
-
await sleep(1000);
|
|
165
|
-
return true;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
export async function wakeAndUnlock(ctx) {
|
|
169
|
-
// 唤醒并解锁设备。真机/云手机在息屏时 dumpsys 可能仍保留上一个 App,
|
|
170
|
-
// 业务层会误判前台 Activity,所以每条原生任务开始前都应该先恢复可交互屏幕。
|
|
171
|
-
Logger.info('wake and unlock', {serial: ctx.serial});
|
|
172
|
-
await adbInput(ctx, ['keyevent', 'KEYCODE_WAKEUP']).catch(() => { });
|
|
173
|
-
await sleep(500);
|
|
174
|
-
await adbInput(ctx, ['keyevent', 'MENU']).catch(() => { });
|
|
175
|
-
await sleep(500);
|
|
176
|
-
await swipeScreenRatio(ctx, 0.50, 0.82, 0.50, 0.25, 350).catch(() => { });
|
|
177
|
-
await sleep(800);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
export async function focusedActivity(ctx) {
|
|
181
|
-
// 优先从 activity manager 读取 resumed activity。`dumpsys window` 在微信 WebView
|
|
182
|
-
// 场景里可能同时保留旧窗口和新窗口,直接读 window focus 容易误判。
|
|
183
|
-
const resumed = await resumedActivity(ctx).catch(() => '');
|
|
184
|
-
if (resumed) return resumed;
|
|
185
|
-
|
|
186
|
-
// 从 dumpsys window 中提取当前前台 Activity,用于替代 OCR 做页面状态判断。
|
|
187
|
-
const out = await adbShell(ctx, ['dumpsys', 'window']);
|
|
188
|
-
let fallback = '';
|
|
189
|
-
let currentFocus = '';
|
|
190
|
-
let appWindowFocus = '';
|
|
191
|
-
for (const line of String(out || '').split('\n')) {
|
|
192
|
-
const trimmed = line.trim();
|
|
193
|
-
if (!trimmed.includes('mCurrentFocus') && !trimmed.includes('mFocusedApp')) continue;
|
|
194
|
-
if (!fallback) fallback = trimmed;
|
|
195
|
-
if (trimmed.includes('=null')) continue;
|
|
196
|
-
// dumpsys window 里会同时出现当前窗口和历史 token 行。
|
|
197
|
-
// 只信真实窗口焦点和 AppWindowToken,避免把旧 LauncherUI Token 当成当前页。
|
|
198
|
-
if (trimmed.includes('mCurrentFocus=Window')) currentFocus = trimmed;
|
|
199
|
-
else if (trimmed.includes('mFocusedApp=AppWindowToken')) appWindowFocus = trimmed;
|
|
200
|
-
}
|
|
201
|
-
return currentFocus || appWindowFocus || fallback || String(out || '').trim();
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
async function resumedActivity(ctx) {
|
|
205
|
-
const out = await adbShell(ctx, ['dumpsys', 'activity', 'activities'], {
|
|
206
|
-
timeoutMs: 15000,
|
|
207
|
-
maxBuffer: 12 * 1024 * 1024
|
|
208
|
-
});
|
|
209
|
-
let fallback = '';
|
|
210
|
-
for (const line of String(out || '').split('\n')) {
|
|
211
|
-
const trimmed = line.trim();
|
|
212
|
-
if (!trimmed.includes('ActivityRecord') || !trimmed.includes('/')) continue;
|
|
213
|
-
if (trimmed.includes('mTopResumedActivity') || trimmed.includes('topResumedActivity')) return trimmed;
|
|
214
|
-
if (trimmed.includes('mResumedActivity')) fallback = trimmed;
|
|
215
|
-
}
|
|
216
|
-
return fallback;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
export function activityIncludes(focused, fragment) {
|
|
220
|
-
// 小工具:判断当前 Activity 文本是否包含目标片段。
|
|
221
|
-
return String(focused || '').includes(fragment);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
export async function waitForActivity(ctx, predicate, timeoutMs = 15000, intervalMs = 700) {
|
|
225
|
-
// 等待前台 Activity 满足调用方 predicate。
|
|
226
|
-
const startedAt = Date.now();
|
|
227
|
-
Logger.info('wait activity start', {serial: ctx.serial, timeoutMs, intervalMs});
|
|
228
|
-
const deadline = Date.now() + timeoutMs;
|
|
229
|
-
while (Date.now() < deadline) {
|
|
230
|
-
const focused = await focusedActivity(ctx).catch(() => '');
|
|
231
|
-
if (predicate(focused)) {
|
|
232
|
-
Logger.info('wait activity matched', {serial: ctx.serial, duration: Logger.duration(startedAt), focused: summarizeText(focused, 160)});
|
|
233
|
-
return focused;
|
|
234
|
-
}
|
|
235
|
-
await sleep(intervalMs);
|
|
236
|
-
}
|
|
237
|
-
throw new Error(`waitForActivity timeout: focused=${await focusedActivity(ctx).catch(() => '')}`);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
export async function screenshotBase64(ctx) {
|
|
241
|
-
// 获取当前屏幕 PNG,并转成 base64 写入 dataset artifact。
|
|
242
|
-
const stdout = await screenshotPng(ctx);
|
|
243
|
-
return Buffer.from(stdout).toString('base64');
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
export async function longScreenshotBase64(ctx, options = {}) {
|
|
247
|
-
const startedAt = Date.now();
|
|
248
|
-
const size = await screenSize(ctx).catch(() => ({width: 720, height: 1280}));
|
|
249
|
-
const captureCount = Math.max(1, Math.min(8, Number(options.captureCount || 4)));
|
|
250
|
-
const topCrop = clampInt(Number(options.topCrop ?? Math.floor(size.height * 0.12)), 0, size.height - 1);
|
|
251
|
-
const bottomCrop = clampInt(Number(options.bottomCrop ?? Math.floor(size.height * 0.18)), 0, size.height - topCrop - 1);
|
|
252
|
-
const swipeFromY = Number(options.swipeFromY || 0.78);
|
|
253
|
-
const swipeToY = Number(options.swipeToY || 0.28);
|
|
254
|
-
const swipeDurationMs = Number(options.swipeDurationMs || 650);
|
|
255
|
-
const settleMs = Number(options.settleMs || 900);
|
|
256
|
-
const scrollToTopFirst = Boolean(options.scrollToTopFirst);
|
|
257
|
-
const scrollToTopSwipes = Math.max(0, Math.min(8, Number(options.scrollToTopSwipes || 4)));
|
|
258
|
-
const pngs = [];
|
|
259
|
-
|
|
260
|
-
Logger.info('long screenshot start', {
|
|
261
|
-
serial: ctx.serial,
|
|
262
|
-
captureCount,
|
|
263
|
-
topCrop,
|
|
264
|
-
bottomCrop,
|
|
265
|
-
width: size.width,
|
|
266
|
-
height: size.height
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
if (scrollToTopFirst) {
|
|
270
|
-
for (let i = 0; i < scrollToTopSwipes; i += 1) {
|
|
271
|
-
await swipeScreenRatio(ctx, 0.50, swipeToY, 0.50, swipeFromY, swipeDurationMs).catch(() => { });
|
|
272
|
-
await sleep(Math.max(350, Math.floor(settleMs * 0.6)));
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
for (let i = 0; i < captureCount; i += 1) {
|
|
277
|
-
const png = await screenshotPng(ctx);
|
|
278
|
-
pngs.push(png);
|
|
279
|
-
if (i < captureCount - 1) {
|
|
280
|
-
await swipeScreenRatio(ctx, 0.50, swipeFromY, 0.50, swipeToY, swipeDurationMs).catch(() => { });
|
|
281
|
-
await sleep(settleMs);
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
const stitched = stitchVerticalPngs(pngs, {
|
|
286
|
-
topCrop,
|
|
287
|
-
bottomCrop,
|
|
288
|
-
maxOutputHeight: Math.max(size.height, Number(options.maxOutputHeight || size.height * captureCount)),
|
|
289
|
-
dedupeAdjacent: options.dedupeAdjacent !== false
|
|
290
|
-
});
|
|
291
|
-
Logger.info('long screenshot done', {
|
|
292
|
-
serial: ctx.serial,
|
|
293
|
-
duration: Logger.duration(startedAt),
|
|
294
|
-
bytes: stitched.length,
|
|
295
|
-
captureCount: pngs.length
|
|
296
|
-
});
|
|
297
|
-
return stitched.toString('base64');
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
export async function saveDebugScreenshot(ctx, name) {
|
|
301
|
-
// 保存调试截图到 /tmp,主要用于本地定位点击点和页面状态。
|
|
302
|
-
if (!ctx.serial) return '';
|
|
303
|
-
const safeRunId = safeFilePart(ctx.runId || 'manual');
|
|
304
|
-
const safeName = safeFilePart(name || 'screenshot');
|
|
305
|
-
const out = `/tmp/android-toolkit-${safeRunId}-${safeName}.png`;
|
|
306
|
-
const stdout = await screenshotPng(ctx);
|
|
307
|
-
fs.writeFileSync(out, stdout);
|
|
308
|
-
Logger.info(`debug screenshot saved ${out}`);
|
|
309
|
-
return out;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
export async function clearInputByDelete(ctx, count = 40) {
|
|
313
|
-
// 先把光标移到文本末尾再删除。微信搜索框里如果光标停在中间,
|
|
314
|
-
// 直接 DEL 会只删前半段,留下旧 query 尾巴污染下一次搜索。
|
|
315
|
-
await adbInput(ctx, ['keyevent', 'KEYCODE_MOVE_END']).catch(() => { });
|
|
316
|
-
await sleep(150);
|
|
317
|
-
for (let i = 0; i < count; i += 1) {
|
|
318
|
-
await adbInput(ctx, ['keyevent', 'DEL']).catch(() => { });
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
export async function typeText(ctx, text) {
|
|
323
|
-
// 输入中文文本。要求设备安装 Appium Settings,因为原生 adb input text 不支持可靠中文。
|
|
324
|
-
const value = String(text || '');
|
|
325
|
-
if (!value) return;
|
|
326
|
-
const unicodeIME = 'io.appium.settings/.UnicodeIME';
|
|
327
|
-
const previousIME = String(await adbShell(ctx, ['settings', 'get', 'secure', 'default_input_method']).catch(() => '')).trim();
|
|
328
|
-
try {
|
|
329
|
-
// 中文输入统一走 Appium Settings UnicodeIME 思路:
|
|
330
|
-
// 普通 `adb input text` 会把中文打成乱码;IMAP UTF-7 编码可以让 UnicodeIME
|
|
331
|
-
// 在 Android 输入法层还原真实中文,适用于任意支持 ADB 的真机/云手机。
|
|
332
|
-
await adbShell(ctx, ['ime', 'enable', unicodeIME]).catch(() => { });
|
|
333
|
-
await adbShell(ctx, ['ime', 'set', unicodeIME]);
|
|
334
|
-
await sleep(800);
|
|
335
|
-
await adbInput(ctx, ['text', adbInputTextForShell(imapUtf7Encode(value))]);
|
|
336
|
-
await sleep(1200);
|
|
337
|
-
} finally {
|
|
338
|
-
if (previousIME && previousIME !== 'null' && previousIME !== unicodeIME) {
|
|
339
|
-
await adbShell(ctx, ['ime', 'set', previousIME]).catch(() => { });
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
export async function type(ctx, text) {
|
|
345
|
-
return typeText(ctx, text);
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
async function adbInput(ctx, args, options = {}) {
|
|
349
|
-
await adbShell(ctx, ['input', ...args], options);
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
export async function screenshotPng(ctx) {
|
|
353
|
-
return adbExec(ctx, ['-s', ctx.serial, 'exec-out', 'screencap', '-p'], {
|
|
354
|
-
timeoutMs: 15000,
|
|
355
|
-
encoding: 'buffer',
|
|
356
|
-
maxBuffer: 24 * 1024 * 1024
|
|
357
|
-
});
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
function stitchVerticalPngs(buffers, options = {}) {
|
|
361
|
-
const frames = buffers.map((buffer) => decodePng(buffer));
|
|
362
|
-
if (frames.length === 0) throw new Error('longScreenshotBase64 requires at least one screenshot');
|
|
363
|
-
const width = frames[0].width;
|
|
364
|
-
const colorType = frames[0].colorType;
|
|
365
|
-
const bitDepth = frames[0].bitDepth;
|
|
366
|
-
const bytesPerPixel = pngBytesPerPixel(colorType, bitDepth);
|
|
367
|
-
if (bytesPerPixel !== 4) {
|
|
368
|
-
throw new Error(`longScreenshotBase64 only supports RGBA PNG screenshots, got colorType=${colorType} bitDepth=${bitDepth}`);
|
|
369
|
-
}
|
|
370
|
-
for (const frame of frames) {
|
|
371
|
-
if (frame.width !== width || frame.colorType !== colorType || frame.bitDepth !== bitDepth) {
|
|
372
|
-
throw new Error('longScreenshotBase64 screenshots have inconsistent PNG formats');
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
const uniqueFrames = [];
|
|
377
|
-
for (const frame of frames) {
|
|
378
|
-
const previous = uniqueFrames[uniqueFrames.length - 1];
|
|
379
|
-
if (previous && isNearDuplicateFrame(previous, frame, width, bytesPerPixel)) {
|
|
380
|
-
break;
|
|
381
|
-
}
|
|
382
|
-
uniqueFrames.push(frame);
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
const parts = [];
|
|
386
|
-
let totalHeight = 0;
|
|
387
|
-
const topCrop = Math.max(0, Number(options.topCrop || 0));
|
|
388
|
-
const bottomCrop = Math.max(0, Number(options.bottomCrop || 0));
|
|
389
|
-
const maxOutputHeight = Math.max(1, Number(options.maxOutputHeight || Number.MAX_SAFE_INTEGER));
|
|
390
|
-
for (let i = 0; i < uniqueFrames.length; i += 1) {
|
|
391
|
-
const frame = uniqueFrames[i];
|
|
392
|
-
const cropTop = i === 0 ? 0 : Math.min(topCrop, frame.height - 1);
|
|
393
|
-
const cropBottom = i === uniqueFrames.length - 1 ? 0 : Math.min(bottomCrop, frame.height - cropTop - 1);
|
|
394
|
-
let slice = cropFrame(frame, cropTop, cropBottom, bytesPerPixel);
|
|
395
|
-
if (options.dedupeAdjacent !== false && parts.length > 0) {
|
|
396
|
-
slice = removeDuplicateOverlap(parts[parts.length - 1], slice, width, bytesPerPixel);
|
|
397
|
-
}
|
|
398
|
-
if (slice.height <= 0) continue;
|
|
399
|
-
if (totalHeight + slice.height > maxOutputHeight) {
|
|
400
|
-
slice = cropSliceHeight(slice, maxOutputHeight - totalHeight, width, bytesPerPixel);
|
|
401
|
-
}
|
|
402
|
-
if (slice.height <= 0) break;
|
|
403
|
-
parts.push(slice);
|
|
404
|
-
totalHeight += slice.height;
|
|
405
|
-
if (totalHeight >= maxOutputHeight) break;
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
const rgba = Buffer.alloc(width * totalHeight * bytesPerPixel);
|
|
409
|
-
let y = 0;
|
|
410
|
-
for (const part of parts) {
|
|
411
|
-
part.rgba.copy(rgba, y * width * bytesPerPixel);
|
|
412
|
-
y += part.height;
|
|
413
|
-
}
|
|
414
|
-
return encodePng({width, height: totalHeight, rgba});
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
function isNearDuplicateFrame(previous, current, width, bytesPerPixel) {
|
|
418
|
-
if (previous.height !== current.height || previous.width !== current.width) return false;
|
|
419
|
-
const rowBytes = width * bytesPerPixel;
|
|
420
|
-
const top = Math.floor(current.height * 0.18);
|
|
421
|
-
const bottom = Math.floor(current.height * 0.82);
|
|
422
|
-
const start = top * rowBytes;
|
|
423
|
-
const end = Math.max(start, bottom * rowBytes);
|
|
424
|
-
const previousSlice = previous.rgba.subarray(start, end);
|
|
425
|
-
const currentSlice = current.rgba.subarray(start, end);
|
|
426
|
-
return meanAbsoluteRowDifference(previousSlice, currentSlice) < 1.5;
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
function cropFrame(frame, cropTop, cropBottom, bytesPerPixel) {
|
|
430
|
-
const widthBytes = frame.width * bytesPerPixel;
|
|
431
|
-
const height = Math.max(0, frame.height - cropTop - cropBottom);
|
|
432
|
-
const start = cropTop * widthBytes;
|
|
433
|
-
const end = start + height * widthBytes;
|
|
434
|
-
return {
|
|
435
|
-
width: frame.width,
|
|
436
|
-
height,
|
|
437
|
-
rgba: Buffer.from(frame.rgba.subarray(start, end))
|
|
438
|
-
};
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
function cropSliceHeight(slice, height, width, bytesPerPixel) {
|
|
442
|
-
const cleanHeight = Math.max(0, Math.min(slice.height, Number(height || 0)));
|
|
443
|
-
return {
|
|
444
|
-
width: slice.width,
|
|
445
|
-
height: cleanHeight,
|
|
446
|
-
rgba: Buffer.from(slice.rgba.subarray(0, cleanHeight * width * bytesPerPixel))
|
|
447
|
-
};
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
function removeDuplicateOverlap(previous, current, width, bytesPerPixel) {
|
|
451
|
-
if (!previous?.rgba?.length || !current?.rgba?.length) return current;
|
|
452
|
-
const rowBytes = width * bytesPerPixel;
|
|
453
|
-
const maxOverlap = Math.min(previous.height, current.height, Math.floor(current.height * 0.82));
|
|
454
|
-
let overlapRows = 0;
|
|
455
|
-
for (let rows = maxOverlap; rows >= 48; rows -= 8) {
|
|
456
|
-
if (sampledOverlapDifference(previous, current, rows, width, bytesPerPixel) < 5.0) {
|
|
457
|
-
overlapRows = rows;
|
|
458
|
-
break;
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
if (overlapRows <= 0) return current;
|
|
462
|
-
return {
|
|
463
|
-
width: current.width,
|
|
464
|
-
height: current.height - overlapRows,
|
|
465
|
-
rgba: Buffer.from(current.rgba.subarray(overlapRows * rowBytes))
|
|
466
|
-
};
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
function sampledOverlapDifference(previous, current, rows, width, bytesPerPixel) {
|
|
470
|
-
const rowBytes = width * bytesPerPixel;
|
|
471
|
-
const sampleRows = Math.min(28, rows);
|
|
472
|
-
const rowStep = Math.max(1, Math.floor(rows / sampleRows));
|
|
473
|
-
const xStart = Math.floor(width * 0.06);
|
|
474
|
-
const xEnd = Math.max(xStart + 1, Math.floor(width * 0.94));
|
|
475
|
-
const xStep = Math.max(1, Math.floor((xEnd - xStart) / 180));
|
|
476
|
-
let diff = 0;
|
|
477
|
-
let count = 0;
|
|
478
|
-
for (let y = 0; y < rows; y += rowStep) {
|
|
479
|
-
const previousRow = previous.height - rows + y;
|
|
480
|
-
const currentRow = y;
|
|
481
|
-
const previousBase = previousRow * rowBytes;
|
|
482
|
-
const currentBase = currentRow * rowBytes;
|
|
483
|
-
for (let x = xStart; x < xEnd; x += xStep) {
|
|
484
|
-
const offset = x * bytesPerPixel;
|
|
485
|
-
diff += Math.abs(previous.rgba[previousBase + offset] - current.rgba[currentBase + offset]);
|
|
486
|
-
diff += Math.abs(previous.rgba[previousBase + offset + 1] - current.rgba[currentBase + offset + 1]);
|
|
487
|
-
diff += Math.abs(previous.rgba[previousBase + offset + 2] - current.rgba[currentBase + offset + 2]);
|
|
488
|
-
count += 3;
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
return diff / Math.max(1, count);
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
function meanAbsoluteRowDifference(left, right) {
|
|
495
|
-
const len = Math.min(left.length, right.length);
|
|
496
|
-
if (len <= 0) return Number.POSITIVE_INFINITY;
|
|
497
|
-
let diff = 0;
|
|
498
|
-
const stride = Math.max(1, Math.floor(len / 20000));
|
|
499
|
-
let count = 0;
|
|
500
|
-
for (let i = 0; i < len; i += stride) {
|
|
501
|
-
diff += Math.abs(left[i] - right[i]);
|
|
502
|
-
count += 1;
|
|
503
|
-
}
|
|
504
|
-
return diff / Math.max(1, count);
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
function decodePng(buffer) {
|
|
508
|
-
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
509
|
-
if (!Buffer.from(buffer).subarray(0, 8).equals(signature)) throw new Error('invalid PNG signature');
|
|
510
|
-
let offset = 8;
|
|
511
|
-
let width = 0;
|
|
512
|
-
let height = 0;
|
|
513
|
-
let bitDepth = 0;
|
|
514
|
-
let colorType = 0;
|
|
515
|
-
const idat = [];
|
|
516
|
-
|
|
517
|
-
while (offset + 12 <= buffer.length) {
|
|
518
|
-
const length = buffer.readUInt32BE(offset);
|
|
519
|
-
const type = buffer.subarray(offset + 4, offset + 8).toString('ascii');
|
|
520
|
-
const data = buffer.subarray(offset + 8, offset + 8 + length);
|
|
521
|
-
offset += 12 + length;
|
|
522
|
-
if (type === 'IHDR') {
|
|
523
|
-
width = data.readUInt32BE(0);
|
|
524
|
-
height = data.readUInt32BE(4);
|
|
525
|
-
bitDepth = data[8];
|
|
526
|
-
colorType = data[9];
|
|
527
|
-
} else if (type === 'IDAT') {
|
|
528
|
-
idat.push(data);
|
|
529
|
-
} else if (type === 'IEND') {
|
|
530
|
-
break;
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
if (!width || !height) throw new Error('invalid PNG IHDR');
|
|
534
|
-
const bytesPerPixel = pngBytesPerPixel(colorType, bitDepth);
|
|
535
|
-
const rowBytes = width * bytesPerPixel;
|
|
536
|
-
const inflated = zlib.inflateSync(Buffer.concat(idat));
|
|
537
|
-
const rgba = Buffer.alloc(rowBytes * height);
|
|
538
|
-
let src = 0;
|
|
539
|
-
let dst = 0;
|
|
540
|
-
let previous = Buffer.alloc(rowBytes);
|
|
541
|
-
for (let y = 0; y < height; y += 1) {
|
|
542
|
-
const filter = inflated[src++];
|
|
543
|
-
const row = Buffer.from(inflated.subarray(src, src + rowBytes));
|
|
544
|
-
src += rowBytes;
|
|
545
|
-
unfilterPngRow(row, previous, filter, bytesPerPixel);
|
|
546
|
-
row.copy(rgba, dst);
|
|
547
|
-
dst += rowBytes;
|
|
548
|
-
previous = row;
|
|
549
|
-
}
|
|
550
|
-
return {width, height, bitDepth, colorType, rgba};
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
function encodePng({width, height, rgba}) {
|
|
554
|
-
const rowBytes = width * 4;
|
|
555
|
-
const raw = Buffer.alloc((rowBytes + 1) * height);
|
|
556
|
-
for (let y = 0; y < height; y += 1) {
|
|
557
|
-
const rawOffset = y * (rowBytes + 1);
|
|
558
|
-
raw[rawOffset] = 0;
|
|
559
|
-
rgba.copy(raw, rawOffset + 1, y * rowBytes, (y + 1) * rowBytes);
|
|
560
|
-
}
|
|
561
|
-
const chunks = [
|
|
562
|
-
makePngChunk('IHDR', makeIhdr(width, height)),
|
|
563
|
-
makePngChunk('IDAT', zlib.deflateSync(raw, {level: 6})),
|
|
564
|
-
makePngChunk('IEND', Buffer.alloc(0))
|
|
565
|
-
];
|
|
566
|
-
return Buffer.concat([
|
|
567
|
-
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
568
|
-
...chunks
|
|
569
|
-
]);
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
function makeIhdr(width, height) {
|
|
573
|
-
const data = Buffer.alloc(13);
|
|
574
|
-
data.writeUInt32BE(width, 0);
|
|
575
|
-
data.writeUInt32BE(height, 4);
|
|
576
|
-
data[8] = 8;
|
|
577
|
-
data[9] = 6;
|
|
578
|
-
data[10] = 0;
|
|
579
|
-
data[11] = 0;
|
|
580
|
-
data[12] = 0;
|
|
581
|
-
return data;
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
function makePngChunk(type, data) {
|
|
585
|
-
const typeBuffer = Buffer.from(type, 'ascii');
|
|
586
|
-
const length = Buffer.alloc(4);
|
|
587
|
-
length.writeUInt32BE(data.length, 0);
|
|
588
|
-
const crcBuffer = Buffer.alloc(4);
|
|
589
|
-
crcBuffer.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 0);
|
|
590
|
-
return Buffer.concat([length, typeBuffer, data, crcBuffer]);
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
function unfilterPngRow(row, previous, filter, bytesPerPixel) {
|
|
594
|
-
if (filter === 0) return;
|
|
595
|
-
for (let i = 0; i < row.length; i += 1) {
|
|
596
|
-
const left = i >= bytesPerPixel ? row[i - bytesPerPixel] : 0;
|
|
597
|
-
const up = previous[i] || 0;
|
|
598
|
-
const upLeft = i >= bytesPerPixel ? previous[i - bytesPerPixel] || 0 : 0;
|
|
599
|
-
let value = row[i];
|
|
600
|
-
if (filter === 1) value += left;
|
|
601
|
-
else if (filter === 2) value += up;
|
|
602
|
-
else if (filter === 3) value += Math.floor((left + up) / 2);
|
|
603
|
-
else if (filter === 4) value += paethPredictor(left, up, upLeft);
|
|
604
|
-
else throw new Error(`unsupported PNG filter ${filter}`);
|
|
605
|
-
row[i] = value & 0xff;
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
function pngBytesPerPixel(colorType, bitDepth) {
|
|
610
|
-
if (bitDepth !== 8) throw new Error(`unsupported PNG bitDepth ${bitDepth}`);
|
|
611
|
-
if (colorType === 6) return 4;
|
|
612
|
-
if (colorType === 2) return 3;
|
|
613
|
-
if (colorType === 0) return 1;
|
|
614
|
-
throw new Error(`unsupported PNG colorType ${colorType}`);
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
function paethPredictor(a, b, c) {
|
|
618
|
-
const p = a + b - c;
|
|
619
|
-
const pa = Math.abs(p - a);
|
|
620
|
-
const pb = Math.abs(p - b);
|
|
621
|
-
const pc = Math.abs(p - c);
|
|
622
|
-
if (pa <= pb && pa <= pc) return a;
|
|
623
|
-
if (pb <= pc) return b;
|
|
624
|
-
return c;
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
const CRC_TABLE = makeCrcTable();
|
|
628
|
-
|
|
629
|
-
function crc32(buffer) {
|
|
630
|
-
let crc = 0xffffffff;
|
|
631
|
-
for (const byte of buffer) {
|
|
632
|
-
crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
633
|
-
}
|
|
634
|
-
return (crc ^ 0xffffffff) >>> 0;
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
function makeCrcTable() {
|
|
638
|
-
const table = new Uint32Array(256);
|
|
639
|
-
for (let n = 0; n < 256; n += 1) {
|
|
640
|
-
let c = n;
|
|
641
|
-
for (let k = 0; k < 8; k += 1) {
|
|
642
|
-
c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
|
643
|
-
}
|
|
644
|
-
table[n] = c >>> 0;
|
|
645
|
-
}
|
|
646
|
-
return table;
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
function clampInt(value, min, max) {
|
|
650
|
-
const clean = Number.isFinite(value) ? Math.trunc(value) : min;
|
|
651
|
-
return Math.max(min, Math.min(max, clean));
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
function isAdbOfflineError(error) {
|
|
655
|
-
const message = String(error?.message || error || '').toLowerCase();
|
|
656
|
-
return message.includes('device offline') || message.includes('device unauthorized');
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
function isAdbUnavailableError(error) {
|
|
660
|
-
const message = String(error?.message || error || '').toLowerCase();
|
|
661
|
-
return message.includes('device not found') || message.includes('no devices/emulators found');
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
function makeAdbUnavailableError(ctx, args, error) {
|
|
665
|
-
return new CrawlerError({
|
|
666
|
-
message: `adb_unavailable: ${error?.message || String(error)}`,
|
|
667
|
-
code: Code.AdbUnavailable,
|
|
668
|
-
context: {
|
|
669
|
-
serial: ctx.serial,
|
|
670
|
-
args
|
|
671
|
-
}
|
|
672
|
-
});
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
function isBounds(value) {
|
|
676
|
-
if (!value || typeof value !== 'object') return false;
|
|
677
|
-
const left = Number(value.left);
|
|
678
|
-
const top = Number(value.top);
|
|
679
|
-
const right = Number(value.right);
|
|
680
|
-
const bottom = Number(value.bottom);
|
|
681
|
-
return [left, top, right, bottom].every(Number.isFinite) && right > left && bottom > top;
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
async function swipeScreenRatio(ctx, fromX, fromY, toX, toY, durationMs = 500) {
|
|
685
|
-
const size = await screenSize(ctx).catch(() => ({width: 720, height: 1280}));
|
|
686
|
-
await adbInput(ctx, [
|
|
687
|
-
'swipe',
|
|
688
|
-
String(Math.floor(size.width * fromX)),
|
|
689
|
-
String(Math.floor(size.height * fromY)),
|
|
690
|
-
String(Math.floor(size.width * toX)),
|
|
691
|
-
String(Math.floor(size.height * toY)),
|
|
692
|
-
String(durationMs)
|
|
693
|
-
]);
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
function safeFilePart(value) {
|
|
697
|
-
return String(value).replace(/[^a-zA-Z0-9_.-]+/g, '-');
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
function imapUtf7Encode(value) {
|
|
701
|
-
return String(value || '')
|
|
702
|
-
.replace(/&/g, '&-')
|
|
703
|
-
.replace(/[^\x20-\x7e]+/g, (chunk) => `&${utf7EncodeChunk(chunk).replace(/\//g, ',')}-`);
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
function utf7EncodeChunk(value) {
|
|
707
|
-
const buffer = Buffer.alloc(String(value).length * 2, 'ascii');
|
|
708
|
-
let offset = 0;
|
|
709
|
-
for (let i = 0; i < String(value).length; i += 1) {
|
|
710
|
-
const code = String(value).charCodeAt(i);
|
|
711
|
-
buffer[offset++] = code >> 8;
|
|
712
|
-
buffer[offset++] = code & 0xff;
|
|
713
|
-
}
|
|
714
|
-
return buffer.toString('base64').replace(/=+$/, '');
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
function adbInputTextForShell(raw) {
|
|
718
|
-
const escapedText = String(raw || '').replace(/[()<>|;&*\\~^"'$`]/g, '\\$&').replace(/ /g, '%s');
|
|
719
|
-
return `''${escapedText}''`;
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
function compactArgs(args) {
|
|
723
|
-
return (args || []).map((arg) => summarizeText(arg, 80)).join(' ');
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
function summarizeText(value, maxLen) {
|
|
727
|
-
const text = String(value || '').replace(/\s+/g, ' ').trim();
|
|
728
|
-
if (text.length <= maxLen) return text;
|
|
729
|
-
return `${text.slice(0, maxLen)}...`;
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
export const Device = {
|
|
733
|
-
adbShell,
|
|
734
|
-
adbExec,
|
|
735
|
-
forceStopApp,
|
|
736
|
-
startActivity,
|
|
737
|
-
startLauncher,
|
|
738
|
-
waitForForeground,
|
|
739
|
-
screenSize,
|
|
740
|
-
screenDensity,
|
|
741
|
-
overrideScreenSize,
|
|
742
|
-
resetScreenSize,
|
|
743
|
-
resetScreenDensity,
|
|
744
|
-
tapAbsolute,
|
|
745
|
-
click,
|
|
746
|
-
move,
|
|
747
|
-
pressBack,
|
|
748
|
-
dismissPermissionDialog,
|
|
749
|
-
wakeAndUnlock,
|
|
750
|
-
focusedActivity,
|
|
751
|
-
activityIncludes,
|
|
752
|
-
waitForActivity,
|
|
753
|
-
screenshotPng,
|
|
754
|
-
screenshotBase64,
|
|
755
|
-
longScreenshotBase64,
|
|
756
|
-
saveDebugScreenshot,
|
|
757
|
-
clearInputByDelete,
|
|
758
|
-
typeText,
|
|
759
|
-
type,
|
|
760
|
-
sleep
|
|
761
|
-
};
|