@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.
@@ -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
+ }
@@ -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,5 @@
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";
@@ -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"}
@@ -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
+ });
@@ -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,5 @@
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";
@@ -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
+ }