dsh-apk-vm 0.6.2 → 0.8.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.
- package/CHANGELOG.md +54 -0
- package/README.md +8 -3
- package/client.js +884 -16
- package/index.js +202 -7
- package/lib/aab.js +736 -0
- package/lib/android.js +95 -1
- package/lib/har.js +423 -0
- package/lib/ui-tests.js +393 -0
- package/package.json +6 -3
package/index.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* and the open-source framework choices are documented in README.md / DESIGN.md.
|
|
15
15
|
*/
|
|
16
16
|
import { randomUUID } from 'node:crypto';
|
|
17
|
-
import { createReadStream, existsSync } from 'node:fs';
|
|
17
|
+
import { createReadStream, existsSync, readFileSync } from 'node:fs';
|
|
18
18
|
import { promises as fsp } from 'node:fs';
|
|
19
19
|
import net from 'node:net';
|
|
20
20
|
import os from 'node:os';
|
|
@@ -29,6 +29,17 @@ import { acceptWebSocket } from './lib/ws.js';
|
|
|
29
29
|
import { parseUiHierarchy, decorateUiNodes, buildInspectorPayload } from './lib/ui-inspector.js';
|
|
30
30
|
import { FlowRecorder } from './lib/flows.js';
|
|
31
31
|
import { collectStorage, cleanTempFiles, cleanSnapshots, cleanOrphanAvds, formatBytes } from './lib/storage.js';
|
|
32
|
+
import { buildApksFromAab, aabInfo, detectBundletool } from './lib/aab.js';
|
|
33
|
+
import { readHarFile, summarizeHar, filterHar, toCurl } from './lib/har.js';
|
|
34
|
+
import { runAssertions, compareScreenshot, createTestReport, reportMarkdown } from './lib/ui-tests.js';
|
|
35
|
+
|
|
36
|
+
const PLUGIN_VERSION = (() => {
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version || 'unknown';
|
|
39
|
+
} catch {
|
|
40
|
+
return 'unknown';
|
|
41
|
+
}
|
|
42
|
+
})();
|
|
32
43
|
|
|
33
44
|
export const name = 'dsh-apk-vm';
|
|
34
45
|
|
|
@@ -318,6 +329,42 @@ export function apply(ctx, config = {}) {
|
|
|
318
329
|
maxFps: Math.min(60, Math.max(1, Number(cfg.scrcpyMaxFps) || 60)),
|
|
319
330
|
port: Number(cfg.scrcpyPort) || 27183,
|
|
320
331
|
};
|
|
332
|
+
const STREAM_PRESETS = Object.freeze({
|
|
333
|
+
low: { maxSize: 540, bitrate: 2_500_000, maxFps: 30 },
|
|
334
|
+
balanced: { maxSize: 720, bitrate: 4_000_000, maxFps: 60 },
|
|
335
|
+
high: { maxSize: 960, bitrate: 6_000_000, maxFps: 60 },
|
|
336
|
+
});
|
|
337
|
+
let streamPreset = 'custom';
|
|
338
|
+
for (const [name, preset] of Object.entries(STREAM_PRESETS)) {
|
|
339
|
+
if (
|
|
340
|
+
preset.maxSize === scrcpyConfig.maxSize &&
|
|
341
|
+
preset.bitrate === scrcpyConfig.bitrate &&
|
|
342
|
+
preset.maxFps === scrcpyConfig.maxFps
|
|
343
|
+
) {
|
|
344
|
+
streamPreset = name;
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function setStreamQuality(preset) {
|
|
350
|
+
const name = String(preset || '').toLowerCase();
|
|
351
|
+
const config = STREAM_PRESETS[name];
|
|
352
|
+
if (!config) throw new Error(`unknown stream preset: ${preset || '(missing)'}`);
|
|
353
|
+
scrcpyConfig.maxSize = config.maxSize;
|
|
354
|
+
scrcpyConfig.bitrate = config.bitrate;
|
|
355
|
+
scrcpyConfig.maxFps = config.maxFps;
|
|
356
|
+
streamPreset = name;
|
|
357
|
+
const wasActive = Boolean(scrcpyState && scrcpyState.session && scrcpyState.session.state === 'running');
|
|
358
|
+
if (wasActive) await stopScrcpy();
|
|
359
|
+
return {
|
|
360
|
+
ok: true,
|
|
361
|
+
preset: name,
|
|
362
|
+
maxSize: config.maxSize,
|
|
363
|
+
bitrate: config.bitrate,
|
|
364
|
+
maxFps: config.maxFps,
|
|
365
|
+
restarted: wasActive,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
321
368
|
let scrcpyState = null;
|
|
322
369
|
let scrcpyIdleTimer = null;
|
|
323
370
|
let scrcpyInitClients = null; // WeakSet of clients that already got the init JSON
|
|
@@ -427,6 +474,9 @@ export function apply(ctx, config = {}) {
|
|
|
427
474
|
clients: st ? st.clients.size : 0,
|
|
428
475
|
fps: st ? st.fps : 0,
|
|
429
476
|
maxFps: scrcpyConfig.maxFps,
|
|
477
|
+
maxSize: scrcpyConfig.maxSize,
|
|
478
|
+
bitrate: scrcpyConfig.bitrate,
|
|
479
|
+
preset: streamPreset,
|
|
430
480
|
width: st && st.dims ? st.dims.width : null,
|
|
431
481
|
height: st && st.dims ? st.dims.height : null,
|
|
432
482
|
error: st && st.lastError ? String(st.lastError.message || st.lastError) : null,
|
|
@@ -612,12 +662,23 @@ export function apply(ctx, config = {}) {
|
|
|
612
662
|
let apkPath;
|
|
613
663
|
if (options.trustedPath === true) {
|
|
614
664
|
apkPath = path.resolve(String(candidate || ''));
|
|
615
|
-
if (!/\.(?:apk|apks|xapk)$/i.test(apkPath) || !existsSync(apkPath)) {
|
|
665
|
+
if (!/\.(?:apk|apks|xapk|aab)$/i.test(apkPath) || !existsSync(apkPath)) {
|
|
616
666
|
throw new Error(`uploaded APK/bundle is missing or invalid: ${apkPath}`);
|
|
617
667
|
}
|
|
618
668
|
} else {
|
|
619
669
|
apkPath = resolveApkPath(candidate, rootsFor());
|
|
620
670
|
}
|
|
671
|
+
if (/\.aab$/i.test(apkPath)) {
|
|
672
|
+
const built = await buildApksFromAab({
|
|
673
|
+
aabPath: apkPath,
|
|
674
|
+
outDir: path.join(os.tmpdir(), 'dsh-apk-vm-aab'),
|
|
675
|
+
mode: 'universal',
|
|
676
|
+
bundletoolPath: options.bundletoolPath,
|
|
677
|
+
javaPath: options.javaPath,
|
|
678
|
+
});
|
|
679
|
+
if (!built.ok) throw new Error(built.error || 'AAB conversion failed; install bundletool and retry');
|
|
680
|
+
apkPath = built.apksPath;
|
|
681
|
+
}
|
|
621
682
|
const status = await vm.status();
|
|
622
683
|
if (!isReady(status)) throw new Error(`VM is not ready (state: ${status.state}); start it first`);
|
|
623
684
|
const meta = await vm.installApk(apkPath);
|
|
@@ -747,6 +808,7 @@ export function apply(ctx, config = {}) {
|
|
|
747
808
|
maybeAutoStart(status);
|
|
748
809
|
return {
|
|
749
810
|
ok: true,
|
|
811
|
+
pluginVersion: PLUGIN_VERSION,
|
|
750
812
|
vm: {
|
|
751
813
|
state: status.state,
|
|
752
814
|
serial: status.serial || null,
|
|
@@ -980,7 +1042,7 @@ export function apply(ctx, config = {}) {
|
|
|
980
1042
|
return;
|
|
981
1043
|
}
|
|
982
1044
|
if (action === 'install-image') {
|
|
983
|
-
if (cfg.allowSetupDownloads !== true) {
|
|
1045
|
+
if (cfg.allowSetupDownloads !== true && body.confirmDownload !== true) {
|
|
984
1046
|
sendError(res, 403, 'downloading Android system images is disabled; set allowSetupDownloads: true to enable it');
|
|
985
1047
|
return;
|
|
986
1048
|
}
|
|
@@ -994,7 +1056,7 @@ export function apply(ctx, config = {}) {
|
|
|
994
1056
|
return;
|
|
995
1057
|
}
|
|
996
1058
|
if (action === 'install-tools') {
|
|
997
|
-
if (cfg.allowSetupDownloads !== true) {
|
|
1059
|
+
if (cfg.allowSetupDownloads !== true && body.confirmDownload !== true) {
|
|
998
1060
|
sendError(res, 403, 'downloading Android SDK components is disabled; set allowSetupDownloads: true to enable it');
|
|
999
1061
|
return;
|
|
1000
1062
|
}
|
|
@@ -1013,7 +1075,7 @@ export function apply(ctx, config = {}) {
|
|
|
1013
1075
|
return;
|
|
1014
1076
|
}
|
|
1015
1077
|
if (action === 'bootstrap-sdk') {
|
|
1016
|
-
if (cfg.allowSetupDownloads !== true) {
|
|
1078
|
+
if (cfg.allowSetupDownloads !== true && body.confirmDownload !== true) {
|
|
1017
1079
|
sendError(res, 403, 'downloading Android SDK components is disabled; set allowSetupDownloads: true to enable it');
|
|
1018
1080
|
return;
|
|
1019
1081
|
}
|
|
@@ -1127,6 +1189,7 @@ export function apply(ctx, config = {}) {
|
|
|
1127
1189
|
}
|
|
1128
1190
|
return {
|
|
1129
1191
|
ok: true,
|
|
1192
|
+
pluginVersion: PLUGIN_VERSION,
|
|
1130
1193
|
instance: { id: instance.id, kind: 'extra', avdName: instance.avdName, port: instance.port },
|
|
1131
1194
|
vm: {
|
|
1132
1195
|
state: status.state,
|
|
@@ -1224,6 +1287,27 @@ export function apply(ctx, config = {}) {
|
|
|
1224
1287
|
});
|
|
1225
1288
|
return;
|
|
1226
1289
|
}
|
|
1290
|
+
if (rest === 'snapshots') {
|
|
1291
|
+
if (req.method === 'GET') {
|
|
1292
|
+
sendJson(res, 200, instance.vm.listNamedSnapshots());
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
if (req.method !== 'POST') {
|
|
1296
|
+
sendError(res, 405, 'GET or POST only');
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
const body = await readJsonBody(req);
|
|
1300
|
+
const action = String(body.action || '').toLowerCase();
|
|
1301
|
+
try {
|
|
1302
|
+
if (action === 'save') sendJson(res, 200, await instance.vm.saveNamedSnapshot(body.name));
|
|
1303
|
+
else if (action === 'load') sendJson(res, 200, await instance.vm.loadNamedSnapshot(body.name));
|
|
1304
|
+
else if (action === 'delete') sendJson(res, 200, await instance.vm.deleteNamedSnapshot(body.name));
|
|
1305
|
+
else throw new Error(`unsupported snapshot action: ${action || '(missing)'}`);
|
|
1306
|
+
} catch (error) {
|
|
1307
|
+
sendError(res, 400, error.message);
|
|
1308
|
+
}
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1227
1311
|
if (rest === 'network') {
|
|
1228
1312
|
if (!(await ready())) return;
|
|
1229
1313
|
if (req.method === 'GET') {
|
|
@@ -1318,7 +1402,7 @@ export function apply(ctx, config = {}) {
|
|
|
1318
1402
|
name = 'upload.apk';
|
|
1319
1403
|
}
|
|
1320
1404
|
name = path.basename(name).replace(/[^A-Za-z0-9._-]+/g, '_') || 'upload.apk';
|
|
1321
|
-
if (!/\.apk$/i.test(name)) name += '.apk';
|
|
1405
|
+
if (!/\.(?:apk|aab)$/i.test(name)) name += '.apk';
|
|
1322
1406
|
const data = await readRawBody(req, UPLOAD_LIMIT_BYTES);
|
|
1323
1407
|
if (data.length < 4 || data[0] !== 0x50 || data[1] !== 0x4b) {
|
|
1324
1408
|
sendError(res, 400, 'uploaded file is not a ZIP/APK');
|
|
@@ -1443,6 +1527,10 @@ export function apply(ctx, config = {}) {
|
|
|
1443
1527
|
if (action === 'force-stop') result = await instance.forceStop(body.packageName);
|
|
1444
1528
|
else if (action === 'clear-data') result = await instance.clearData(body.packageName);
|
|
1445
1529
|
else if (action === 'uninstall') result = await instance.uninstall(body.packageName);
|
|
1530
|
+
else if (action === 'launch') result = await instance.vm.launchPackage(body.packageName, body.activity);
|
|
1531
|
+
else if (action === 'open-url') result = await instance.vm.openUrl(body.url);
|
|
1532
|
+
else if (action === 'grant') result = await instance.vm.setPermission(body.packageName, body.permission, true);
|
|
1533
|
+
else if (action === 'revoke') result = await instance.vm.setPermission(body.packageName, body.permission, false);
|
|
1446
1534
|
else {
|
|
1447
1535
|
sendError(res, 400, `unsupported debug action: ${action || '(missing)'}`);
|
|
1448
1536
|
return;
|
|
@@ -1509,6 +1597,7 @@ export function apply(ctx, config = {}) {
|
|
|
1509
1597
|
'flows',
|
|
1510
1598
|
'network',
|
|
1511
1599
|
'network/pcap',
|
|
1600
|
+
'snapshots',
|
|
1512
1601
|
];
|
|
1513
1602
|
const disposers = [];
|
|
1514
1603
|
for (const suffix of suffixes) {
|
|
@@ -1781,7 +1870,7 @@ export function apply(ctx, config = {}) {
|
|
|
1781
1870
|
}
|
|
1782
1871
|
let safeName = path.basename(rawName || 'upload.apk').replace(/[^A-Za-z0-9._-]+/g, '_');
|
|
1783
1872
|
if (!safeName) safeName = 'upload.apk';
|
|
1784
|
-
if (!/\.(?:apk|apks|xapk)$/i.test(safeName)) safeName += '.apk';
|
|
1873
|
+
if (!/\.(?:apk|apks|xapk|aab)$/i.test(safeName)) safeName += '.apk';
|
|
1785
1874
|
let data;
|
|
1786
1875
|
try {
|
|
1787
1876
|
data = await readRawBody(req, UPLOAD_LIMIT_BYTES);
|
|
@@ -1930,6 +2019,19 @@ export function apply(ctx, config = {}) {
|
|
|
1930
2019
|
sendJson(res, 200, { ok: true, fps: streamFps });
|
|
1931
2020
|
});
|
|
1932
2021
|
|
|
2022
|
+
register('/apk-vm/stream/quality', async (req, res) => {
|
|
2023
|
+
if (req.method !== 'POST') {
|
|
2024
|
+
sendError(res, 405, 'POST only');
|
|
2025
|
+
return;
|
|
2026
|
+
}
|
|
2027
|
+
const body = await readJsonBody(req);
|
|
2028
|
+
try {
|
|
2029
|
+
sendJson(res, 200, await setStreamQuality(body.preset));
|
|
2030
|
+
} catch (error) {
|
|
2031
|
+
sendError(res, 400, error.message);
|
|
2032
|
+
}
|
|
2033
|
+
});
|
|
2034
|
+
|
|
1933
2035
|
/* ---- rooted debug surface ---------------------------------------- *
|
|
1934
2036
|
* These routes (and the `apk_vm` Agent tool) expose the guest's root
|
|
1935
2037
|
* shell and low-level device operations to DSH. They are local-only,
|
|
@@ -2000,6 +2102,43 @@ export function apply(ctx, config = {}) {
|
|
|
2000
2102
|
sendError(res, 405, 'GET or POST only');
|
|
2001
2103
|
});
|
|
2002
2104
|
|
|
2105
|
+
register('/apk-vm/ui-test', async (req, res) => {
|
|
2106
|
+
if (req.method !== 'POST') {
|
|
2107
|
+
sendError(res, 405, 'POST only');
|
|
2108
|
+
return;
|
|
2109
|
+
}
|
|
2110
|
+
if (!(await ensureDebugReady(res))) return;
|
|
2111
|
+
const body = await readJsonBody(req);
|
|
2112
|
+
const name = String(body.name || 'ui-check').slice(0, 80);
|
|
2113
|
+
const started = Date.now();
|
|
2114
|
+
try {
|
|
2115
|
+
const dump = await vm.uiDump();
|
|
2116
|
+
const assertions = runAssertions(dump.xml, Array.isArray(body.assertions) ? body.assertions : []);
|
|
2117
|
+
const png = await vm.screenshot();
|
|
2118
|
+
const visual = await compareScreenshot(
|
|
2119
|
+
png,
|
|
2120
|
+
path.join(os.tmpdir(), 'dsh-apk-vm-baselines'),
|
|
2121
|
+
name,
|
|
2122
|
+
{ update: body.updateBaseline === true },
|
|
2123
|
+
);
|
|
2124
|
+
const report = createTestReport({
|
|
2125
|
+
name,
|
|
2126
|
+
instanceId: 'default',
|
|
2127
|
+
assertions,
|
|
2128
|
+
visual,
|
|
2129
|
+
durationMs: Date.now() - started,
|
|
2130
|
+
});
|
|
2131
|
+
const reportBody = report && report.report ? report.report : report;
|
|
2132
|
+
sendJson(res, 200, {
|
|
2133
|
+
ok: assertions.ok !== false && visual.ok !== false,
|
|
2134
|
+
report: reportBody,
|
|
2135
|
+
markdown: (report && report.markdown) || reportMarkdown(reportBody),
|
|
2136
|
+
});
|
|
2137
|
+
} catch (error) {
|
|
2138
|
+
sendError(res, 400, error.message);
|
|
2139
|
+
}
|
|
2140
|
+
});
|
|
2141
|
+
|
|
2003
2142
|
register('/apk-vm/debug/packages', async (req, res) => {
|
|
2004
2143
|
if (req.method !== 'GET') {
|
|
2005
2144
|
sendError(res, 405, 'GET only');
|
|
@@ -2065,6 +2204,10 @@ export function apply(ctx, config = {}) {
|
|
|
2065
2204
|
if (action === 'force-stop') result = await vm.forceStop(packageName);
|
|
2066
2205
|
else if (action === 'clear-data') result = await vm.clearData(packageName);
|
|
2067
2206
|
else if (action === 'uninstall') result = await vm.uninstall(packageName);
|
|
2207
|
+
else if (action === 'launch') result = await vm.launchPackage(packageName, body.activity);
|
|
2208
|
+
else if (action === 'open-url') result = await vm.openUrl(body.url);
|
|
2209
|
+
else if (action === 'grant') result = await vm.setPermission(packageName, body.permission, true);
|
|
2210
|
+
else if (action === 'revoke') result = await vm.setPermission(packageName, body.permission, false);
|
|
2068
2211
|
else {
|
|
2069
2212
|
sendError(res, 400, `unsupported debug action: ${action || '(missing)'}`);
|
|
2070
2213
|
return;
|
|
@@ -2072,6 +2215,27 @@ export function apply(ctx, config = {}) {
|
|
|
2072
2215
|
sendJson(res, 200, { ok: result.ok === true, action, result });
|
|
2073
2216
|
});
|
|
2074
2217
|
|
|
2218
|
+
register('/apk-vm/snapshots', async (req, res) => {
|
|
2219
|
+
if (req.method === 'GET') {
|
|
2220
|
+
sendJson(res, 200, vm.listNamedSnapshots());
|
|
2221
|
+
return;
|
|
2222
|
+
}
|
|
2223
|
+
if (req.method !== 'POST') {
|
|
2224
|
+
sendError(res, 405, 'GET or POST only');
|
|
2225
|
+
return;
|
|
2226
|
+
}
|
|
2227
|
+
const body = await readJsonBody(req);
|
|
2228
|
+
const action = String(body.action || '').toLowerCase();
|
|
2229
|
+
try {
|
|
2230
|
+
if (action === 'save') sendJson(res, 200, await vm.saveNamedSnapshot(body.name));
|
|
2231
|
+
else if (action === 'load') sendJson(res, 200, await vm.loadNamedSnapshot(body.name));
|
|
2232
|
+
else if (action === 'delete') sendJson(res, 200, await vm.deleteNamedSnapshot(body.name));
|
|
2233
|
+
else throw new Error(`unsupported snapshot action: ${action || '(missing)'}`);
|
|
2234
|
+
} catch (error) {
|
|
2235
|
+
sendError(res, 400, error.message);
|
|
2236
|
+
}
|
|
2237
|
+
});
|
|
2238
|
+
|
|
2075
2239
|
register('/apk-vm/debug/rotate', async (req, res) => {
|
|
2076
2240
|
if (req.method !== 'POST') {
|
|
2077
2241
|
sendError(res, 405, 'POST only');
|
|
@@ -2143,6 +2307,37 @@ export function apply(ctx, config = {}) {
|
|
|
2143
2307
|
}
|
|
2144
2308
|
});
|
|
2145
2309
|
|
|
2310
|
+
register('/apk-vm/network/har', async (req, res) => {
|
|
2311
|
+
if (req.method !== 'POST') {
|
|
2312
|
+
sendError(res, 405, 'POST only');
|
|
2313
|
+
return;
|
|
2314
|
+
}
|
|
2315
|
+
const body = await readJsonBody(req);
|
|
2316
|
+
try {
|
|
2317
|
+
const loaded = await readHarFile(body.path);
|
|
2318
|
+
const entries = filterHar(loaded.entries, {
|
|
2319
|
+
method: body.method,
|
|
2320
|
+
status: body.status,
|
|
2321
|
+
domain: body.domain,
|
|
2322
|
+
search: body.search,
|
|
2323
|
+
});
|
|
2324
|
+
const sliced = entries.slice(0, 500).map((entry) => ({
|
|
2325
|
+
...entry,
|
|
2326
|
+
curl: toCurl(entry),
|
|
2327
|
+
}));
|
|
2328
|
+
sendJson(res, 200, {
|
|
2329
|
+
ok: true,
|
|
2330
|
+
path: loaded.file,
|
|
2331
|
+
bytes: loaded.bytes,
|
|
2332
|
+
summary: summarizeHar(loaded.entries),
|
|
2333
|
+
filtered: entries.length,
|
|
2334
|
+
entries: sliced,
|
|
2335
|
+
});
|
|
2336
|
+
} catch (error) {
|
|
2337
|
+
sendError(res, 400, error.message);
|
|
2338
|
+
}
|
|
2339
|
+
});
|
|
2340
|
+
|
|
2146
2341
|
register('/apk-vm/network', async (req, res) => {
|
|
2147
2342
|
if (req.method === 'GET') {
|
|
2148
2343
|
if (!(await ensureDebugReady(res))) return;
|