@lanternajs/android 0.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/dist/collector.d.ts +10 -0
- package/dist/collector.d.ts.map +1 -0
- package/dist/collector.js +64 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/parsers/gfxinfo.d.ts +8 -0
- package/dist/parsers/gfxinfo.d.ts.map +1 -0
- package/dist/parsers/gfxinfo.js +49 -0
- package/dist/parsers/meminfo.d.ts +8 -0
- package/dist/parsers/meminfo.d.ts.map +1 -0
- package/dist/parsers/meminfo.js +32 -0
- package/dist/parsers/top.d.ts +8 -0
- package/dist/parsers/top.d.ts.map +1 -0
- package/dist/parsers/top.js +43 -0
- package/dist/process.d.ts +7 -0
- package/dist/process.d.ts.map +1 -0
- package/dist/process.js +20 -0
- package/package.json +24 -0
- package/src/__tests__/parsers.test.ts +355 -0
- package/src/collector.ts +79 -0
- package/src/index.ts +5 -0
- package/src/parsers/gfxinfo.ts +57 -0
- package/src/parsers/meminfo.ts +38 -0
- package/src/parsers/top.ts +51 -0
- package/src/process.ts +28 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { CommandRunner, Device, MeasurementSession } from "@lanternajs/core";
|
|
2
|
+
/**
|
|
3
|
+
* Collect Android performance metrics for a given device and package.
|
|
4
|
+
*
|
|
5
|
+
* Runs adb commands to gather CPU (via top), memory (via dumpsys meminfo),
|
|
6
|
+
* and frame metrics (via dumpsys gfxinfo), then merges all samples into
|
|
7
|
+
* a single MeasurementSession.
|
|
8
|
+
*/
|
|
9
|
+
export declare function collectAndroidMetrics(runner: CommandRunner, device: Device, packageName: string, duration: number): Promise<MeasurementSession>;
|
|
10
|
+
//# sourceMappingURL=collector.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"collector.d.ts","sourceRoot":"","sources":["../src/collector.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,EAAE,kBAAkB,EAAgB,MAAM,kBAAkB,CAAC;AAMhG;;;;;;GAMG;AACH,wBAAsB,qBAAqB,CAC1C,MAAM,EAAE,aAAa,EACrB,MAAM,EAAE,MAAM,EACd,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,MAAM,GACd,OAAO,CAAC,kBAAkB,CAAC,CA4D7B"}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { parseGfxinfoOutput } from "./parsers/gfxinfo";
|
|
2
|
+
import { parseMeminfoOutput } from "./parsers/meminfo";
|
|
3
|
+
import { parseTopOutput } from "./parsers/top";
|
|
4
|
+
import { findAndroidPid } from "./process";
|
|
5
|
+
/**
|
|
6
|
+
* Collect Android performance metrics for a given device and package.
|
|
7
|
+
*
|
|
8
|
+
* Runs adb commands to gather CPU (via top), memory (via dumpsys meminfo),
|
|
9
|
+
* and frame metrics (via dumpsys gfxinfo), then merges all samples into
|
|
10
|
+
* a single MeasurementSession.
|
|
11
|
+
*/
|
|
12
|
+
export async function collectAndroidMetrics(runner, device, packageName, duration) {
|
|
13
|
+
const pid = await findAndroidPid(runner, device.id, packageName);
|
|
14
|
+
if (pid === null) {
|
|
15
|
+
throw new Error(`Process not found: "${packageName}" is not running on device "${device.name}" (${device.id}). ` +
|
|
16
|
+
"Make sure the app is launched before profiling.");
|
|
17
|
+
}
|
|
18
|
+
const startedAt = Date.now();
|
|
19
|
+
const samples = [];
|
|
20
|
+
// Run all data collection in parallel
|
|
21
|
+
const [topResult, meminfoResult, gfxinfoResult] = await Promise.all([
|
|
22
|
+
runner("adb", [
|
|
23
|
+
"-s",
|
|
24
|
+
device.id,
|
|
25
|
+
"shell",
|
|
26
|
+
"top",
|
|
27
|
+
"-H",
|
|
28
|
+
"-d",
|
|
29
|
+
"1",
|
|
30
|
+
"-n",
|
|
31
|
+
String(duration),
|
|
32
|
+
"-p",
|
|
33
|
+
String(pid),
|
|
34
|
+
]),
|
|
35
|
+
runner("adb", ["-s", device.id, "shell", "dumpsys", "meminfo", packageName]),
|
|
36
|
+
runner("adb", ["-s", device.id, "shell", "dumpsys", "gfxinfo", packageName]),
|
|
37
|
+
]);
|
|
38
|
+
// Parse top output — may contain multiple snapshots separated by blank lines
|
|
39
|
+
if (topResult.exitCode === 0 && topResult.stdout) {
|
|
40
|
+
const snapshots = topResult.stdout.split(/\n\n+/);
|
|
41
|
+
for (let i = 0; i < snapshots.length; i++) {
|
|
42
|
+
const snapshotTimestamp = startedAt + i * 1000;
|
|
43
|
+
const parsed = parseTopOutput(snapshots[i], snapshotTimestamp);
|
|
44
|
+
samples.push(...parsed);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Parse meminfo
|
|
48
|
+
if (meminfoResult.exitCode === 0 && meminfoResult.stdout) {
|
|
49
|
+
const parsed = parseMeminfoOutput(meminfoResult.stdout, startedAt);
|
|
50
|
+
samples.push(...parsed);
|
|
51
|
+
}
|
|
52
|
+
// Parse gfxinfo
|
|
53
|
+
if (gfxinfoResult.exitCode === 0 && gfxinfoResult.stdout) {
|
|
54
|
+
const parsed = parseGfxinfoOutput(gfxinfoResult.stdout, startedAt);
|
|
55
|
+
samples.push(...parsed);
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
device,
|
|
59
|
+
platform: "android",
|
|
60
|
+
samples,
|
|
61
|
+
duration,
|
|
62
|
+
startedAt,
|
|
63
|
+
};
|
|
64
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { collectAndroidMetrics } from "./collector";
|
|
2
|
+
export { parseGfxinfoOutput } from "./parsers/gfxinfo";
|
|
3
|
+
export { parseMeminfoOutput } from "./parsers/meminfo";
|
|
4
|
+
export { parseTopOutput } from "./parsers/top";
|
|
5
|
+
export { findAndroidPid } from "./process";
|
|
6
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AACpD,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,OAAO,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type MetricSample } from "@lanternajs/core";
|
|
2
|
+
/**
|
|
3
|
+
* Parse output from `adb shell dumpsys gfxinfo <package>`.
|
|
4
|
+
* Extracts janky frame percentage to calculate effective UI FPS and frame drop rate.
|
|
5
|
+
* Never throws — returns empty array on malformed input.
|
|
6
|
+
*/
|
|
7
|
+
export declare function parseGfxinfoOutput(output: string, timestamp: number): MetricSample[];
|
|
8
|
+
//# sourceMappingURL=gfxinfo.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gfxinfo.d.ts","sourceRoot":"","sources":["../../src/parsers/gfxinfo.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,YAAY,EAAc,MAAM,kBAAkB,CAAC;AAEjE;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,YAAY,EAAE,CAiDpF"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { MetricType } from "@lanternajs/core";
|
|
2
|
+
/**
|
|
3
|
+
* Parse output from `adb shell dumpsys gfxinfo <package>`.
|
|
4
|
+
* Extracts janky frame percentage to calculate effective UI FPS and frame drop rate.
|
|
5
|
+
* Never throws — returns empty array on malformed input.
|
|
6
|
+
*/
|
|
7
|
+
export function parseGfxinfoOutput(output, timestamp) {
|
|
8
|
+
if (!output || typeof output !== "string") {
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
const lines = output.split("\n");
|
|
12
|
+
let totalFrames = null;
|
|
13
|
+
let jankyFrames = null;
|
|
14
|
+
let jankyPercent = null;
|
|
15
|
+
for (const line of lines) {
|
|
16
|
+
const totalMatch = line.match(/Total frames rendered:\s*([\d]+)/);
|
|
17
|
+
if (totalMatch) {
|
|
18
|
+
totalFrames = Number.parseInt(totalMatch[1], 10);
|
|
19
|
+
}
|
|
20
|
+
// "Janky frames: 75 (5.00%)"
|
|
21
|
+
const jankyMatch = line.match(/Janky frames:\s*([\d]+)\s*\(([\d.]+)%\)/);
|
|
22
|
+
if (jankyMatch) {
|
|
23
|
+
jankyFrames = Number.parseInt(jankyMatch[1], 10);
|
|
24
|
+
jankyPercent = Number.parseFloat(jankyMatch[2]);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (totalFrames === null || jankyFrames === null || jankyPercent === null) {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
if (Number.isNaN(totalFrames) || Number.isNaN(jankyPercent)) {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
// Calculate effective FPS: 60 * (1 - jankyPercent/100)
|
|
34
|
+
const actualFps = 60 * (1 - jankyPercent / 100);
|
|
35
|
+
return [
|
|
36
|
+
{
|
|
37
|
+
type: MetricType.UI_FPS,
|
|
38
|
+
value: Math.round(actualFps * 100) / 100,
|
|
39
|
+
timestamp,
|
|
40
|
+
unit: "fps",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
type: MetricType.FRAME_DROPS,
|
|
44
|
+
value: jankyPercent,
|
|
45
|
+
timestamp,
|
|
46
|
+
unit: "%",
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type MetricSample } from "@lanternajs/core";
|
|
2
|
+
/**
|
|
3
|
+
* Parse output from `adb shell dumpsys meminfo <package>`.
|
|
4
|
+
* Extracts TOTAL Pss Total and converts KB to MB.
|
|
5
|
+
* Never throws — returns empty array on malformed input.
|
|
6
|
+
*/
|
|
7
|
+
export declare function parseMeminfoOutput(output: string, timestamp: number): MetricSample[];
|
|
8
|
+
//# sourceMappingURL=meminfo.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"meminfo.d.ts","sourceRoot":"","sources":["../../src/parsers/meminfo.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,YAAY,EAAc,MAAM,kBAAkB,CAAC;AAEjE;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,YAAY,EAAE,CA8BpF"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { MetricType } from "@lanternajs/core";
|
|
2
|
+
/**
|
|
3
|
+
* Parse output from `adb shell dumpsys meminfo <package>`.
|
|
4
|
+
* Extracts TOTAL Pss Total and converts KB to MB.
|
|
5
|
+
* Never throws — returns empty array on malformed input.
|
|
6
|
+
*/
|
|
7
|
+
export function parseMeminfoOutput(output, timestamp) {
|
|
8
|
+
if (!output || typeof output !== "string") {
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
const lines = output.split("\n");
|
|
12
|
+
for (const line of lines) {
|
|
13
|
+
// Look for the TOTAL line — format: " TOTAL 120000 100000 ..."
|
|
14
|
+
const match = line.match(/^\s*TOTAL\s+([\d]+)/);
|
|
15
|
+
if (match) {
|
|
16
|
+
const pssKb = Number.parseInt(match[1], 10);
|
|
17
|
+
if (Number.isNaN(pssKb)) {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
const pssMb = pssKb / 1024;
|
|
21
|
+
return [
|
|
22
|
+
{
|
|
23
|
+
type: MetricType.MEMORY,
|
|
24
|
+
value: Math.round(pssMb * 100) / 100,
|
|
25
|
+
timestamp,
|
|
26
|
+
unit: "MB",
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type MetricSample } from "@lanternajs/core";
|
|
2
|
+
/**
|
|
3
|
+
* Parse output from `adb shell top -H -d 1 -p <pid>`.
|
|
4
|
+
* Sums all thread CPU percentages to produce a single CPU sample.
|
|
5
|
+
* Never throws — returns empty array on malformed input.
|
|
6
|
+
*/
|
|
7
|
+
export declare function parseTopOutput(output: string, timestamp: number): MetricSample[];
|
|
8
|
+
//# sourceMappingURL=top.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"top.d.ts","sourceRoot":"","sources":["../../src/parsers/top.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,YAAY,EAAc,MAAM,kBAAkB,CAAC;AAEjE;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,YAAY,EAAE,CA2ChF"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { MetricType } from "@lanternajs/core";
|
|
2
|
+
/**
|
|
3
|
+
* Parse output from `adb shell top -H -d 1 -p <pid>`.
|
|
4
|
+
* Sums all thread CPU percentages to produce a single CPU sample.
|
|
5
|
+
* Never throws — returns empty array on malformed input.
|
|
6
|
+
*/
|
|
7
|
+
export function parseTopOutput(output, timestamp) {
|
|
8
|
+
if (!output || typeof output !== "string") {
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
const lines = output.split("\n");
|
|
12
|
+
let totalCpu = 0;
|
|
13
|
+
let foundAny = false;
|
|
14
|
+
for (const line of lines) {
|
|
15
|
+
// Match data lines: PID TID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ THREAD
|
|
16
|
+
// The CPU column contains a value like "25.0" and is preceded by a single-char state (S/R/D/Z/T)
|
|
17
|
+
const trimmed = line.trim();
|
|
18
|
+
if (!trimmed || trimmed.startsWith("Tasks:") || trimmed.startsWith("PID")) {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
// Try to extract CPU percentage from process lines
|
|
22
|
+
// Format: PID TID USER PR NI VIRT RES SHR S[%CPU] %MEM TIME+ THREAD
|
|
23
|
+
const match = trimmed.match(/^\s*\d+\s+\d+\s+\S+\s+\d+\s+[-\d]+\s+\S+\s+\S+\s+\S+\s+[SRDZT]\s+([\d.]+)/);
|
|
24
|
+
if (match) {
|
|
25
|
+
const cpu = Number.parseFloat(match[1]);
|
|
26
|
+
if (!Number.isNaN(cpu)) {
|
|
27
|
+
totalCpu += cpu;
|
|
28
|
+
foundAny = true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (!foundAny) {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
return [
|
|
36
|
+
{
|
|
37
|
+
type: MetricType.CPU,
|
|
38
|
+
value: totalCpu,
|
|
39
|
+
timestamp,
|
|
40
|
+
unit: "%",
|
|
41
|
+
},
|
|
42
|
+
];
|
|
43
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { CommandRunner } from "@lanternajs/core";
|
|
2
|
+
/**
|
|
3
|
+
* Find the PID of a running Android app by package name.
|
|
4
|
+
* Returns null if the process is not found or on error.
|
|
5
|
+
*/
|
|
6
|
+
export declare function findAndroidPid(runner: CommandRunner, deviceId: string, packageName: string): Promise<number | null>;
|
|
7
|
+
//# sourceMappingURL=process.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"process.d.ts","sourceRoot":"","sources":["../src/process.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD;;;GAGG;AACH,wBAAsB,cAAc,CACnC,MAAM,EAAE,aAAa,EACrB,QAAQ,EAAE,MAAM,EAChB,WAAW,EAAE,MAAM,GACjB,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAiBxB"}
|
package/dist/process.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Find the PID of a running Android app by package name.
|
|
3
|
+
* Returns null if the process is not found or on error.
|
|
4
|
+
*/
|
|
5
|
+
export async function findAndroidPid(runner, deviceId, packageName) {
|
|
6
|
+
try {
|
|
7
|
+
const result = await runner("adb", ["-s", deviceId, "shell", "pidof", packageName]);
|
|
8
|
+
if (result.exitCode !== 0 || !result.stdout.trim()) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
const pid = Number.parseInt(result.stdout.trim(), 10);
|
|
12
|
+
if (Number.isNaN(pid)) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
return pid;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lanternajs/android",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"files": ["dist", "src"],
|
|
8
|
+
"license": "Apache-2.0",
|
|
9
|
+
"description": "Android performance data collection via adb and perfetto for Lanterna",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "https://github.com/nicepkg/lanterna.git",
|
|
13
|
+
"directory": "packages/android"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": { "access": "public" },
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@lanternajs/core": "workspace:*"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc --project tsconfig.build.json",
|
|
21
|
+
"prepublishOnly": "npm run build",
|
|
22
|
+
"test": "bun test"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { type CommandRunner, type Device, MetricType } from "@lanternajs/core";
|
|
3
|
+
import { collectAndroidMetrics } from "../collector";
|
|
4
|
+
import { parseGfxinfoOutput } from "../parsers/gfxinfo";
|
|
5
|
+
import { parseMeminfoOutput } from "../parsers/meminfo";
|
|
6
|
+
import { parseTopOutput } from "../parsers/top";
|
|
7
|
+
import { findAndroidPid } from "../process";
|
|
8
|
+
|
|
9
|
+
// ── Fixtures ────────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
const TOP_OUTPUT = `Tasks: 1 total, 0 running, 1 sleeping, 0 stopped, 0 zombie
|
|
12
|
+
PID TID USER PR NI VIRT RES SHR S[%CPU] %MEM TIME+ THREAD
|
|
13
|
+
12345 12345 u0_a123 20 0 4.2G 180M 95M S 25.0 4.5 0:12.34 main
|
|
14
|
+
12345 12350 u0_a123 20 0 4.2G 180M 95M S 15.3 4.5 0:08.21 mqt_js
|
|
15
|
+
12345 12351 u0_a123 20 0 4.2G 180M 95M S 8.7 4.5 0:05.10 RenderThread`;
|
|
16
|
+
|
|
17
|
+
const MEMINFO_OUTPUT = `Applications Memory Usage (in Kilobytes):
|
|
18
|
+
Uptime: 123456 Realtime: 654321
|
|
19
|
+
|
|
20
|
+
** MEMINFO in pid 12345 [com.example.app] **
|
|
21
|
+
Pss Private Private SwapPss Rss Heap Heap Heap
|
|
22
|
+
Total Dirty Clean Dirty Total Size Alloc Free
|
|
23
|
+
------ ------ ------ ------ ------ ---- ---- ----
|
|
24
|
+
Native Heap 45678 45000 200 100 50000 65536 50000 15536
|
|
25
|
+
Dalvik Heap 12345 12000 100 50 15000 20480 12345 8135
|
|
26
|
+
TOTAL 120000 100000 5000 1000 150000`;
|
|
27
|
+
|
|
28
|
+
const GFXINFO_OUTPUT = `Applications Graphics Acceleration Info:
|
|
29
|
+
com.example.app/com.example.app.MainActivity:
|
|
30
|
+
|
|
31
|
+
Total frames rendered: 1500
|
|
32
|
+
Janky frames: 75 (5.00%)
|
|
33
|
+
Number of missed Vsync: 30
|
|
34
|
+
Number of High input latency: 10
|
|
35
|
+
Number of Slow UI thread: 25
|
|
36
|
+
Number of Slow bitmap uploads: 5
|
|
37
|
+
Number of Slow issue draw commands: 5`;
|
|
38
|
+
|
|
39
|
+
const MOCK_DEVICE: Device = {
|
|
40
|
+
id: "emulator-5554",
|
|
41
|
+
name: "Pixel 6 API 33",
|
|
42
|
+
platform: "android",
|
|
43
|
+
type: "emulator",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// ── parseTopOutput ──────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
describe("parseTopOutput", () => {
|
|
49
|
+
test("sums all thread CPU values into one sample", () => {
|
|
50
|
+
const samples = parseTopOutput(TOP_OUTPUT, 1000);
|
|
51
|
+
|
|
52
|
+
expect(samples).toHaveLength(1);
|
|
53
|
+
expect(samples[0].type).toBe(MetricType.CPU);
|
|
54
|
+
expect(samples[0].value).toBeCloseTo(49.0, 1); // 25.0 + 15.3 + 8.7
|
|
55
|
+
expect(samples[0].timestamp).toBe(1000);
|
|
56
|
+
expect(samples[0].unit).toBe("%");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("returns empty array for empty input", () => {
|
|
60
|
+
expect(parseTopOutput("", 1000)).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("returns empty array for malformed input", () => {
|
|
64
|
+
expect(parseTopOutput("some random text\nwith no data", 1000)).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("returns empty array for null/undefined-like input", () => {
|
|
68
|
+
expect(parseTopOutput(null as unknown as string, 1000)).toEqual([]);
|
|
69
|
+
expect(parseTopOutput(undefined as unknown as string, 1000)).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("handles single thread output", () => {
|
|
73
|
+
const singleThread = `Tasks: 1 total, 0 running, 1 sleeping, 0 stopped, 0 zombie
|
|
74
|
+
PID TID USER PR NI VIRT RES SHR S[%CPU] %MEM TIME+ THREAD
|
|
75
|
+
12345 12345 u0_a123 20 0 4.2G 180M 95M S 42.5 4.5 0:12.34 main`;
|
|
76
|
+
|
|
77
|
+
const samples = parseTopOutput(singleThread, 2000);
|
|
78
|
+
expect(samples).toHaveLength(1);
|
|
79
|
+
expect(samples[0].value).toBeCloseTo(42.5, 1);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ── parseMeminfoOutput ──────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
describe("parseMeminfoOutput", () => {
|
|
86
|
+
test("extracts TOTAL Pss and converts KB to MB", () => {
|
|
87
|
+
const samples = parseMeminfoOutput(MEMINFO_OUTPUT, 1000);
|
|
88
|
+
|
|
89
|
+
expect(samples).toHaveLength(1);
|
|
90
|
+
expect(samples[0].type).toBe(MetricType.MEMORY);
|
|
91
|
+
expect(samples[0].value).toBeCloseTo(120000 / 1024, 2); // ~117.19 MB
|
|
92
|
+
expect(samples[0].timestamp).toBe(1000);
|
|
93
|
+
expect(samples[0].unit).toBe("MB");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("returns empty array for empty input", () => {
|
|
97
|
+
expect(parseMeminfoOutput("", 1000)).toEqual([]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("returns empty array for malformed input", () => {
|
|
101
|
+
expect(parseMeminfoOutput("no total line here\njust garbage", 1000)).toEqual([]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("returns empty array for null/undefined-like input", () => {
|
|
105
|
+
expect(parseMeminfoOutput(null as unknown as string, 1000)).toEqual([]);
|
|
106
|
+
expect(parseMeminfoOutput(undefined as unknown as string, 1000)).toEqual([]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("handles TOTAL line with different spacing", () => {
|
|
110
|
+
const output = " TOTAL 98765 50000 2000 500 100000";
|
|
111
|
+
const samples = parseMeminfoOutput(output, 3000);
|
|
112
|
+
|
|
113
|
+
expect(samples).toHaveLength(1);
|
|
114
|
+
expect(samples[0].value).toBeCloseTo(98765 / 1024, 2);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ── parseGfxinfoOutput ──────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
describe("parseGfxinfoOutput", () => {
|
|
121
|
+
test("calculates FPS and frame drop rate", () => {
|
|
122
|
+
const samples = parseGfxinfoOutput(GFXINFO_OUTPUT, 1000);
|
|
123
|
+
|
|
124
|
+
expect(samples).toHaveLength(2);
|
|
125
|
+
|
|
126
|
+
const fpsSample = samples.find((s) => s.type === MetricType.UI_FPS);
|
|
127
|
+
const dropSample = samples.find((s) => s.type === MetricType.FRAME_DROPS);
|
|
128
|
+
|
|
129
|
+
expect(fpsSample).toBeDefined();
|
|
130
|
+
expect(fpsSample?.value).toBeCloseTo(57.0, 1); // 60 * (1 - 5/100) = 57
|
|
131
|
+
expect(fpsSample?.unit).toBe("fps");
|
|
132
|
+
expect(fpsSample?.timestamp).toBe(1000);
|
|
133
|
+
|
|
134
|
+
expect(dropSample).toBeDefined();
|
|
135
|
+
expect(dropSample?.value).toBe(5.0);
|
|
136
|
+
expect(dropSample?.unit).toBe("%");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("returns empty array for empty input", () => {
|
|
140
|
+
expect(parseGfxinfoOutput("", 1000)).toEqual([]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("returns empty array for malformed input", () => {
|
|
144
|
+
expect(parseGfxinfoOutput("no gfx data here", 1000)).toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("returns empty array for null/undefined-like input", () => {
|
|
148
|
+
expect(parseGfxinfoOutput(null as unknown as string, 1000)).toEqual([]);
|
|
149
|
+
expect(parseGfxinfoOutput(undefined as unknown as string, 1000)).toEqual([]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("returns empty array when only total frames present but no janky", () => {
|
|
153
|
+
const partial = "Total frames rendered: 1000\n";
|
|
154
|
+
expect(parseGfxinfoOutput(partial, 1000)).toEqual([]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("handles 0% janky frames", () => {
|
|
158
|
+
const output = `Total frames rendered: 500
|
|
159
|
+
Janky frames: 0 (0.00%)`;
|
|
160
|
+
const samples = parseGfxinfoOutput(output, 1000);
|
|
161
|
+
|
|
162
|
+
expect(samples).toHaveLength(2);
|
|
163
|
+
const fpsSample = samples.find((s) => s.type === MetricType.UI_FPS);
|
|
164
|
+
expect(fpsSample?.value).toBeCloseTo(60.0, 1);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ── findAndroidPid ──────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
describe("findAndroidPid", () => {
|
|
171
|
+
test("returns PID when process is found", async () => {
|
|
172
|
+
const runner: CommandRunner = async () => ({
|
|
173
|
+
stdout: "12345\n",
|
|
174
|
+
stderr: "",
|
|
175
|
+
exitCode: 0,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const pid = await findAndroidPid(runner, "emulator-5554", "com.example.app");
|
|
179
|
+
expect(pid).toBe(12345);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("returns null when process is not found", async () => {
|
|
183
|
+
const runner: CommandRunner = async () => ({
|
|
184
|
+
stdout: "",
|
|
185
|
+
stderr: "",
|
|
186
|
+
exitCode: 1,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const pid = await findAndroidPid(runner, "emulator-5554", "com.example.app");
|
|
190
|
+
expect(pid).toBeNull();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("returns null on empty stdout", async () => {
|
|
194
|
+
const runner: CommandRunner = async () => ({
|
|
195
|
+
stdout: " \n",
|
|
196
|
+
stderr: "",
|
|
197
|
+
exitCode: 0,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const pid = await findAndroidPid(runner, "emulator-5554", "com.example.app");
|
|
201
|
+
expect(pid).toBeNull();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("returns null when runner throws", async () => {
|
|
205
|
+
const runner: CommandRunner = async () => {
|
|
206
|
+
throw new Error("adb not found");
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const pid = await findAndroidPid(runner, "emulator-5554", "com.example.app");
|
|
210
|
+
expect(pid).toBeNull();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("passes correct arguments to runner", async () => {
|
|
214
|
+
let capturedCmd = "";
|
|
215
|
+
let capturedArgs: string[] = [];
|
|
216
|
+
|
|
217
|
+
const runner: CommandRunner = async (cmd, args) => {
|
|
218
|
+
capturedCmd = cmd;
|
|
219
|
+
capturedArgs = args;
|
|
220
|
+
return { stdout: "99999\n", stderr: "", exitCode: 0 };
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
await findAndroidPid(runner, "device-123", "com.test.pkg");
|
|
224
|
+
expect(capturedCmd).toBe("adb");
|
|
225
|
+
expect(capturedArgs).toEqual(["-s", "device-123", "shell", "pidof", "com.test.pkg"]);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// ── collectAndroidMetrics ───────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
describe("collectAndroidMetrics", () => {
|
|
232
|
+
test("collects and merges all metric samples", async () => {
|
|
233
|
+
const runner: CommandRunner = async (_cmd, args) => {
|
|
234
|
+
const fullCommand = args.join(" ");
|
|
235
|
+
|
|
236
|
+
if (fullCommand.includes("pidof")) {
|
|
237
|
+
return { stdout: "12345\n", stderr: "", exitCode: 0 };
|
|
238
|
+
}
|
|
239
|
+
if (fullCommand.includes("top")) {
|
|
240
|
+
return { stdout: TOP_OUTPUT, stderr: "", exitCode: 0 };
|
|
241
|
+
}
|
|
242
|
+
if (fullCommand.includes("meminfo")) {
|
|
243
|
+
return { stdout: MEMINFO_OUTPUT, stderr: "", exitCode: 0 };
|
|
244
|
+
}
|
|
245
|
+
if (fullCommand.includes("gfxinfo")) {
|
|
246
|
+
return { stdout: GFXINFO_OUTPUT, stderr: "", exitCode: 0 };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return { stdout: "", stderr: "", exitCode: 1 };
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const session = await collectAndroidMetrics(runner, MOCK_DEVICE, "com.example.app", 5);
|
|
253
|
+
|
|
254
|
+
expect(session.device).toBe(MOCK_DEVICE);
|
|
255
|
+
expect(session.platform).toBe("android");
|
|
256
|
+
expect(session.duration).toBe(5);
|
|
257
|
+
expect(typeof session.startedAt).toBe("number");
|
|
258
|
+
|
|
259
|
+
// Should have: 1 CPU sample (from top), 1 MEMORY (from meminfo), 1 UI_FPS + 1 FRAME_DROPS (from gfxinfo)
|
|
260
|
+
const cpuSamples = session.samples.filter((s) => s.type === MetricType.CPU);
|
|
261
|
+
const memorySamples = session.samples.filter((s) => s.type === MetricType.MEMORY);
|
|
262
|
+
const fpsSamples = session.samples.filter((s) => s.type === MetricType.UI_FPS);
|
|
263
|
+
const dropSamples = session.samples.filter((s) => s.type === MetricType.FRAME_DROPS);
|
|
264
|
+
|
|
265
|
+
expect(cpuSamples.length).toBeGreaterThanOrEqual(1);
|
|
266
|
+
expect(memorySamples).toHaveLength(1);
|
|
267
|
+
expect(fpsSamples).toHaveLength(1);
|
|
268
|
+
expect(dropSamples).toHaveLength(1);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("throws when PID is not found", async () => {
|
|
272
|
+
const runner: CommandRunner = async () => ({
|
|
273
|
+
stdout: "",
|
|
274
|
+
stderr: "",
|
|
275
|
+
exitCode: 1,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
expect(collectAndroidMetrics(runner, MOCK_DEVICE, "com.nonexistent.app", 5)).rejects.toThrow(
|
|
279
|
+
/Process not found/,
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("returns session with empty samples when commands fail", async () => {
|
|
284
|
+
const runner: CommandRunner = async (_cmd, args) => {
|
|
285
|
+
const fullCommand = args.join(" ");
|
|
286
|
+
|
|
287
|
+
if (fullCommand.includes("pidof")) {
|
|
288
|
+
return { stdout: "12345\n", stderr: "", exitCode: 0 };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// All other commands fail
|
|
292
|
+
return { stdout: "", stderr: "error", exitCode: 1 };
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const session = await collectAndroidMetrics(runner, MOCK_DEVICE, "com.example.app", 5);
|
|
296
|
+
expect(session.samples).toEqual([]);
|
|
297
|
+
expect(session.platform).toBe("android");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("passes correct adb arguments for each command", async () => {
|
|
301
|
+
const capturedCalls: { cmd: string; args: string[] }[] = [];
|
|
302
|
+
|
|
303
|
+
const runner: CommandRunner = async (cmd, args) => {
|
|
304
|
+
capturedCalls.push({ cmd, args: [...args] });
|
|
305
|
+
const fullCommand = args.join(" ");
|
|
306
|
+
|
|
307
|
+
if (fullCommand.includes("pidof")) {
|
|
308
|
+
return { stdout: "12345\n", stderr: "", exitCode: 0 };
|
|
309
|
+
}
|
|
310
|
+
return { stdout: "", stderr: "", exitCode: 1 };
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
await collectAndroidMetrics(runner, MOCK_DEVICE, "com.example.app", 10);
|
|
314
|
+
|
|
315
|
+
// First call is pidof
|
|
316
|
+
const pidCall = capturedCalls[0];
|
|
317
|
+
expect(pidCall.cmd).toBe("adb");
|
|
318
|
+
expect(pidCall.args).toContain("-s");
|
|
319
|
+
expect(pidCall.args).toContain("emulator-5554");
|
|
320
|
+
|
|
321
|
+
// Should have top, meminfo, gfxinfo calls
|
|
322
|
+
const topCall = capturedCalls.find((c) => c.args.includes("top"));
|
|
323
|
+
expect(topCall).toBeDefined();
|
|
324
|
+
expect(topCall?.args).toContain("-p");
|
|
325
|
+
expect(topCall?.args).toContain("12345");
|
|
326
|
+
expect(topCall?.args).toContain("-n");
|
|
327
|
+
expect(topCall?.args).toContain("10");
|
|
328
|
+
|
|
329
|
+
const meminfoCall = capturedCalls.find((c) => c.args.includes("meminfo"));
|
|
330
|
+
expect(meminfoCall).toBeDefined();
|
|
331
|
+
expect(meminfoCall?.args).toContain("com.example.app");
|
|
332
|
+
|
|
333
|
+
const gfxinfoCall = capturedCalls.find((c) => c.args.includes("gfxinfo"));
|
|
334
|
+
expect(gfxinfoCall).toBeDefined();
|
|
335
|
+
expect(gfxinfoCall?.args).toContain("com.example.app");
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("includes descriptive error message when process not found", async () => {
|
|
339
|
+
const runner: CommandRunner = async () => ({
|
|
340
|
+
stdout: "",
|
|
341
|
+
stderr: "",
|
|
342
|
+
exitCode: 1,
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
await collectAndroidMetrics(runner, MOCK_DEVICE, "com.example.app", 5);
|
|
347
|
+
expect(true).toBe(false); // Should not reach here
|
|
348
|
+
} catch (err) {
|
|
349
|
+
const msg = (err as Error).message;
|
|
350
|
+
expect(msg).toContain("com.example.app");
|
|
351
|
+
expect(msg).toContain("Pixel 6 API 33");
|
|
352
|
+
expect(msg).toContain("emulator-5554");
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
});
|
package/src/collector.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { CommandRunner, Device, MeasurementSession, MetricSample } from "@lanternajs/core";
|
|
2
|
+
import { parseGfxinfoOutput } from "./parsers/gfxinfo";
|
|
3
|
+
import { parseMeminfoOutput } from "./parsers/meminfo";
|
|
4
|
+
import { parseTopOutput } from "./parsers/top";
|
|
5
|
+
import { findAndroidPid } from "./process";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Collect Android performance metrics for a given device and package.
|
|
9
|
+
*
|
|
10
|
+
* Runs adb commands to gather CPU (via top), memory (via dumpsys meminfo),
|
|
11
|
+
* and frame metrics (via dumpsys gfxinfo), then merges all samples into
|
|
12
|
+
* a single MeasurementSession.
|
|
13
|
+
*/
|
|
14
|
+
export async function collectAndroidMetrics(
|
|
15
|
+
runner: CommandRunner,
|
|
16
|
+
device: Device,
|
|
17
|
+
packageName: string,
|
|
18
|
+
duration: number,
|
|
19
|
+
): Promise<MeasurementSession> {
|
|
20
|
+
const pid = await findAndroidPid(runner, device.id, packageName);
|
|
21
|
+
if (pid === null) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
`Process not found: "${packageName}" is not running on device "${device.name}" (${device.id}). ` +
|
|
24
|
+
"Make sure the app is launched before profiling.",
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const startedAt = Date.now();
|
|
29
|
+
const samples: MetricSample[] = [];
|
|
30
|
+
|
|
31
|
+
// Run all data collection in parallel
|
|
32
|
+
const [topResult, meminfoResult, gfxinfoResult] = await Promise.all([
|
|
33
|
+
runner("adb", [
|
|
34
|
+
"-s",
|
|
35
|
+
device.id,
|
|
36
|
+
"shell",
|
|
37
|
+
"top",
|
|
38
|
+
"-H",
|
|
39
|
+
"-d",
|
|
40
|
+
"1",
|
|
41
|
+
"-n",
|
|
42
|
+
String(duration),
|
|
43
|
+
"-p",
|
|
44
|
+
String(pid),
|
|
45
|
+
]),
|
|
46
|
+
runner("adb", ["-s", device.id, "shell", "dumpsys", "meminfo", packageName]),
|
|
47
|
+
runner("adb", ["-s", device.id, "shell", "dumpsys", "gfxinfo", packageName]),
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
// Parse top output — may contain multiple snapshots separated by blank lines
|
|
51
|
+
if (topResult.exitCode === 0 && topResult.stdout) {
|
|
52
|
+
const snapshots = topResult.stdout.split(/\n\n+/);
|
|
53
|
+
for (let i = 0; i < snapshots.length; i++) {
|
|
54
|
+
const snapshotTimestamp = startedAt + i * 1000;
|
|
55
|
+
const parsed = parseTopOutput(snapshots[i], snapshotTimestamp);
|
|
56
|
+
samples.push(...parsed);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Parse meminfo
|
|
61
|
+
if (meminfoResult.exitCode === 0 && meminfoResult.stdout) {
|
|
62
|
+
const parsed = parseMeminfoOutput(meminfoResult.stdout, startedAt);
|
|
63
|
+
samples.push(...parsed);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Parse gfxinfo
|
|
67
|
+
if (gfxinfoResult.exitCode === 0 && gfxinfoResult.stdout) {
|
|
68
|
+
const parsed = parseGfxinfoOutput(gfxinfoResult.stdout, startedAt);
|
|
69
|
+
samples.push(...parsed);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
device,
|
|
74
|
+
platform: "android",
|
|
75
|
+
samples,
|
|
76
|
+
duration,
|
|
77
|
+
startedAt,
|
|
78
|
+
};
|
|
79
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { type MetricSample, MetricType } from "@lanternajs/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parse output from `adb shell dumpsys gfxinfo <package>`.
|
|
5
|
+
* Extracts janky frame percentage to calculate effective UI FPS and frame drop rate.
|
|
6
|
+
* Never throws — returns empty array on malformed input.
|
|
7
|
+
*/
|
|
8
|
+
export function parseGfxinfoOutput(output: string, timestamp: number): MetricSample[] {
|
|
9
|
+
if (!output || typeof output !== "string") {
|
|
10
|
+
return [];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const lines = output.split("\n");
|
|
14
|
+
let totalFrames: number | null = null;
|
|
15
|
+
let jankyFrames: number | null = null;
|
|
16
|
+
let jankyPercent: number | null = null;
|
|
17
|
+
|
|
18
|
+
for (const line of lines) {
|
|
19
|
+
const totalMatch = line.match(/Total frames rendered:\s*([\d]+)/);
|
|
20
|
+
if (totalMatch) {
|
|
21
|
+
totalFrames = Number.parseInt(totalMatch[1], 10);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// "Janky frames: 75 (5.00%)"
|
|
25
|
+
const jankyMatch = line.match(/Janky frames:\s*([\d]+)\s*\(([\d.]+)%\)/);
|
|
26
|
+
if (jankyMatch) {
|
|
27
|
+
jankyFrames = Number.parseInt(jankyMatch[1], 10);
|
|
28
|
+
jankyPercent = Number.parseFloat(jankyMatch[2]);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (totalFrames === null || jankyFrames === null || jankyPercent === null) {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (Number.isNaN(totalFrames) || Number.isNaN(jankyPercent)) {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Calculate effective FPS: 60 * (1 - jankyPercent/100)
|
|
41
|
+
const actualFps = 60 * (1 - jankyPercent / 100);
|
|
42
|
+
|
|
43
|
+
return [
|
|
44
|
+
{
|
|
45
|
+
type: MetricType.UI_FPS,
|
|
46
|
+
value: Math.round(actualFps * 100) / 100,
|
|
47
|
+
timestamp,
|
|
48
|
+
unit: "fps",
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
type: MetricType.FRAME_DROPS,
|
|
52
|
+
value: jankyPercent,
|
|
53
|
+
timestamp,
|
|
54
|
+
unit: "%",
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type MetricSample, MetricType } from "@lanternajs/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parse output from `adb shell dumpsys meminfo <package>`.
|
|
5
|
+
* Extracts TOTAL Pss Total and converts KB to MB.
|
|
6
|
+
* Never throws — returns empty array on malformed input.
|
|
7
|
+
*/
|
|
8
|
+
export function parseMeminfoOutput(output: string, timestamp: number): MetricSample[] {
|
|
9
|
+
if (!output || typeof output !== "string") {
|
|
10
|
+
return [];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const lines = output.split("\n");
|
|
14
|
+
|
|
15
|
+
for (const line of lines) {
|
|
16
|
+
// Look for the TOTAL line — format: " TOTAL 120000 100000 ..."
|
|
17
|
+
const match = line.match(/^\s*TOTAL\s+([\d]+)/);
|
|
18
|
+
if (match) {
|
|
19
|
+
const pssKb = Number.parseInt(match[1], 10);
|
|
20
|
+
if (Number.isNaN(pssKb)) {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const pssMb = pssKb / 1024;
|
|
25
|
+
|
|
26
|
+
return [
|
|
27
|
+
{
|
|
28
|
+
type: MetricType.MEMORY,
|
|
29
|
+
value: Math.round(pssMb * 100) / 100,
|
|
30
|
+
timestamp,
|
|
31
|
+
unit: "MB",
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type MetricSample, MetricType } from "@lanternajs/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parse output from `adb shell top -H -d 1 -p <pid>`.
|
|
5
|
+
* Sums all thread CPU percentages to produce a single CPU sample.
|
|
6
|
+
* Never throws — returns empty array on malformed input.
|
|
7
|
+
*/
|
|
8
|
+
export function parseTopOutput(output: string, timestamp: number): MetricSample[] {
|
|
9
|
+
if (!output || typeof output !== "string") {
|
|
10
|
+
return [];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const lines = output.split("\n");
|
|
14
|
+
let totalCpu = 0;
|
|
15
|
+
let foundAny = false;
|
|
16
|
+
|
|
17
|
+
for (const line of lines) {
|
|
18
|
+
// Match data lines: PID TID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ THREAD
|
|
19
|
+
// The CPU column contains a value like "25.0" and is preceded by a single-char state (S/R/D/Z/T)
|
|
20
|
+
const trimmed = line.trim();
|
|
21
|
+
if (!trimmed || trimmed.startsWith("Tasks:") || trimmed.startsWith("PID")) {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Try to extract CPU percentage from process lines
|
|
26
|
+
// Format: PID TID USER PR NI VIRT RES SHR S[%CPU] %MEM TIME+ THREAD
|
|
27
|
+
const match = trimmed.match(
|
|
28
|
+
/^\s*\d+\s+\d+\s+\S+\s+\d+\s+[-\d]+\s+\S+\s+\S+\s+\S+\s+[SRDZT]\s+([\d.]+)/,
|
|
29
|
+
);
|
|
30
|
+
if (match) {
|
|
31
|
+
const cpu = Number.parseFloat(match[1]);
|
|
32
|
+
if (!Number.isNaN(cpu)) {
|
|
33
|
+
totalCpu += cpu;
|
|
34
|
+
foundAny = true;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!foundAny) {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return [
|
|
44
|
+
{
|
|
45
|
+
type: MetricType.CPU,
|
|
46
|
+
value: totalCpu,
|
|
47
|
+
timestamp,
|
|
48
|
+
unit: "%",
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
}
|
package/src/process.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { CommandRunner } from "@lanternajs/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Find the PID of a running Android app by package name.
|
|
5
|
+
* Returns null if the process is not found or on error.
|
|
6
|
+
*/
|
|
7
|
+
export async function findAndroidPid(
|
|
8
|
+
runner: CommandRunner,
|
|
9
|
+
deviceId: string,
|
|
10
|
+
packageName: string,
|
|
11
|
+
): Promise<number | null> {
|
|
12
|
+
try {
|
|
13
|
+
const result = await runner("adb", ["-s", deviceId, "shell", "pidof", packageName]);
|
|
14
|
+
|
|
15
|
+
if (result.exitCode !== 0 || !result.stdout.trim()) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const pid = Number.parseInt(result.stdout.trim(), 10);
|
|
20
|
+
if (Number.isNaN(pid)) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return pid;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|