mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
1c7eadc610
## Summary `profiler-load` `mode=list` listed every Android session under **both** the "Native Profiler Sessions (iOS)" and "(Android)" headings — and the iOS one was simply wrong. Cause: the list builder collected every `native-profiler-*` file under one map rendered as "(iOS)", then separately listed `.pftrace` files as "(Android)". An Android session's `-report.md` (and the `.pftrace` itself) match the native prefix, so it landed in both. Now each native session is classified by extension (`.pftrace` → Android, else iOS) and rendered once under the correct platform. ## Test plan - New `test/android-perfetto/profiler-load-list.test.ts` (Android-only-under-Android, iOS/Android separation, empty dir). - `npm test -w @argent/tool-server` — all pass; build + `typecheck:tests` clean. > Part of a set of native-profiler fixes found while testing on Android.
393 lines
17 KiB
TypeScript
393 lines
17 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { promises as fs } from "fs";
|
|
import * as os from "os";
|
|
import * as path from "path";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// True end-to-end test for `profiler-load { mode: "list" }`.
|
|
//
|
|
// Unlike profiler-load-list.test.ts (which vi.mocks getDebugDir), this exercises
|
|
// the REAL getDebugDir(): it returns `join(os.tmpdir(), "argent-profiler-cwd")`,
|
|
// and os.tmpdir() reads process.env.TMPDIR dynamically at call time. So we point
|
|
// TMPDIR at an isolated scratch dir per test and let the real code resolve the
|
|
// real on-disk path. Nothing in listSessions (the logic under test) is mocked.
|
|
//
|
|
// The `mode: "list"` execute path calls `listSessions(await getDebugDir())` and
|
|
// never touches `services`, so we drive it as execute({}, { mode: "list", ... }).
|
|
//
|
|
// Fixtures faithfully mirror what the writers put on disk:
|
|
// Android (platforms/android.ts + session-metadata.ts):
|
|
// native-profiler-<ts>.pftrace
|
|
// native-profiler-<ts>.pftrace.metadata.json { platform:"android", appProcess, wallClockStartMs }
|
|
// native-profiler-<ts>-report.md (written by native-profiler-analyze)
|
|
// iOS (platforms/ios.ts + ios-profiler/render.ts deriveReportPath):
|
|
// native-profiler-<ts>.trace (a directory in reality)
|
|
// native-profiler-<ts>_raw_cpu.xml / _raw_hangs.xml / _raw_leaks.xml
|
|
// native-profiler-<ts>-report.md
|
|
// Timestamp format on both platforms: YYYYMMDD-HHMMSS (15 chars).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let scratch: string;
|
|
let debugDir: string;
|
|
let savedTmpdir: string | undefined;
|
|
|
|
// Imported once; it reads getDebugDir() lazily inside execute, so per-test
|
|
// TMPDIR changes are honoured without re-importing.
|
|
import { profilerLoadTool } from "../../src/tools/profiler/query/profiler-load";
|
|
|
|
async function runList(): Promise<string> {
|
|
return (await profilerLoadTool.execute(
|
|
{} as never,
|
|
{ mode: "list", device_id: "emulator-5554", port: 8081 } as never
|
|
)) as string;
|
|
}
|
|
|
|
/** Faithful Android session on disk: pftrace + metadata sidecar + report. */
|
|
async function writeAndroidSession(
|
|
ts: string,
|
|
opts: { metadata?: boolean | string; report?: boolean | string } = {}
|
|
): Promise<void> {
|
|
const pftrace = path.join(debugDir, `native-profiler-${ts}.pftrace`);
|
|
// The real .pftrace is a binary perfetto blob; a non-empty byte string is a
|
|
// faithful enough stand-in for a listing test (list never parses it).
|
|
await fs.writeFile(pftrace, "\x0a\x00perfetto-trace-bytes");
|
|
if (opts.metadata !== false) {
|
|
const body =
|
|
typeof opts.metadata === "string"
|
|
? opts.metadata
|
|
: `${JSON.stringify(
|
|
{ platform: "android", appProcess: "com.example.app", wallClockStartMs: 1710000000000 },
|
|
null,
|
|
2
|
|
)}\n`;
|
|
await fs.writeFile(`${pftrace}.metadata.json`, body);
|
|
}
|
|
if (opts.report !== false) {
|
|
const body =
|
|
typeof opts.report === "string"
|
|
? opts.report
|
|
: "# Native Profiler Report (Android)\n\nBottlenecks: 0\n";
|
|
await fs.writeFile(path.join(debugDir, `native-profiler-${ts}-report.md`), body);
|
|
}
|
|
}
|
|
|
|
/** Faithful iOS session on disk: .trace dir + raw XML exports + report. */
|
|
async function writeIosSession(
|
|
ts: string,
|
|
exports: { cpu?: boolean; hangs?: boolean; leaks?: boolean; report?: boolean | string } = {
|
|
cpu: true,
|
|
}
|
|
): Promise<void> {
|
|
// The real .trace is a directory created by xctrace.
|
|
await fs.mkdir(path.join(debugDir, `native-profiler-${ts}.trace`), { recursive: true });
|
|
if (exports.cpu)
|
|
await fs.writeFile(
|
|
path.join(debugDir, `native-profiler-${ts}_raw_cpu.xml`),
|
|
"<trace-query-result></trace-query-result>"
|
|
);
|
|
if (exports.hangs)
|
|
await fs.writeFile(
|
|
path.join(debugDir, `native-profiler-${ts}_raw_hangs.xml`),
|
|
"<trace-query-result></trace-query-result>"
|
|
);
|
|
if (exports.leaks)
|
|
await fs.writeFile(
|
|
path.join(debugDir, `native-profiler-${ts}_raw_leaks.xml`),
|
|
"<trace-query-result></trace-query-result>"
|
|
);
|
|
if (exports.report !== false) {
|
|
const body =
|
|
typeof exports.report === "string"
|
|
? exports.report
|
|
: "# Native Profiler Report (iOS)\n\nHotspots: 0\n";
|
|
await fs.writeFile(path.join(debugDir, `native-profiler-${ts}-report.md`), body);
|
|
}
|
|
}
|
|
|
|
function countOccurrences(haystack: string, needle: string): number {
|
|
return haystack.split(needle).length - 1;
|
|
}
|
|
|
|
describe("profiler-load list — E2E against real on-disk fixtures (no mocks)", () => {
|
|
beforeEach(async () => {
|
|
savedTmpdir = process.env.TMPDIR;
|
|
scratch = await fs.mkdtemp(path.join(os.tmpdir(), "argent-load-e2e-"));
|
|
process.env.TMPDIR = scratch;
|
|
// Sanity: the real getDebugDir resolves under our scratch TMPDIR.
|
|
debugDir = path.join(os.tmpdir(), "argent-profiler-cwd");
|
|
expect(debugDir.startsWith(scratch)).toBe(true);
|
|
await fs.mkdir(debugDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (savedTmpdir === undefined) delete process.env.TMPDIR;
|
|
else process.env.TMPDIR = savedTmpdir;
|
|
await fs.rm(scratch, { recursive: true, force: true });
|
|
});
|
|
|
|
// ---- Matrix case 1: mixed Android + iOS (the headline case) ----
|
|
it("case 1 — mixed: each session under exactly one heading, correct columns & ordering", async () => {
|
|
await writeIosSession("20260101-090000", { cpu: true, hangs: true, leaks: true, report: true });
|
|
await writeAndroidSession("20260615-112028", { metadata: true, report: true });
|
|
|
|
const out = await runList();
|
|
// OBSERVABLE: print the rendered list for the headline case.
|
|
console.log(
|
|
"\n===== mixed (Android + iOS) — real list output =====\n" +
|
|
out +
|
|
"\n====================================================\n"
|
|
);
|
|
|
|
const iosIdx = out.indexOf("### Native Profiler Sessions (iOS)");
|
|
const androidIdx = out.indexOf("### Native Profiler Sessions (Android)");
|
|
expect(iosIdx).toBeGreaterThan(-1);
|
|
expect(androidIdx).toBeGreaterThan(-1);
|
|
// Section ordering: iOS heading comes before Android heading.
|
|
expect(androidIdx).toBeGreaterThan(iosIdx);
|
|
|
|
const iosSection = out.slice(iosIdx, androidIdx);
|
|
const androidSection = out.slice(androidIdx);
|
|
|
|
// iOS id only in iOS section; Android id only in Android section.
|
|
expect(iosSection).toContain("20260101-090000");
|
|
expect(iosSection).not.toContain("20260615-112028");
|
|
expect(androidSection).toContain("20260615-112028");
|
|
expect(androidSection).not.toContain("20260101-090000");
|
|
|
|
// iOS columns: cpu+hangs+leaks+report present → "CPU, hangs, leaks, report".
|
|
expect(iosSection).toContain("`20260101-090000` | CPU, hangs, leaks, report |");
|
|
// Android columns: pftrace + report tag.
|
|
expect(androidSection).toContain("`20260615-112028` | pftrace, report |");
|
|
|
|
// Each session id appears exactly once across the whole output.
|
|
expect(countOccurrences(out, "20260101-090000")).toBe(1);
|
|
expect(countOccurrences(out, "20260615-112028")).toBe(1);
|
|
});
|
|
|
|
// ---- Matrix case 2: Android-only ----
|
|
it("case 2 — Android-only: no iOS heading, session listed once", async () => {
|
|
await writeAndroidSession("20260615-112028", { metadata: true, report: true });
|
|
|
|
const out = await runList();
|
|
expect(out).toContain("### Native Profiler Sessions (Android)");
|
|
expect(out).not.toContain("### Native Profiler Sessions (iOS)");
|
|
expect(countOccurrences(out, "20260615-112028")).toBe(1);
|
|
expect(out).toContain("`20260615-112028` | pftrace, report |");
|
|
});
|
|
|
|
// ---- Matrix case 3: iOS-only ----
|
|
it("case 3 — iOS-only: no Android heading", async () => {
|
|
await writeIosSession("20260101-090000", {
|
|
cpu: true,
|
|
hangs: false,
|
|
leaks: false,
|
|
report: false,
|
|
});
|
|
|
|
const out = await runList();
|
|
expect(out).toContain("### Native Profiler Sessions (iOS)");
|
|
expect(out).not.toContain("### Native Profiler Sessions (Android)");
|
|
expect(countOccurrences(out, "20260101-090000")).toBe(1);
|
|
// Only CPU export → just "CPU".
|
|
expect(out).toContain("`20260101-090000` | CPU |");
|
|
});
|
|
|
|
// ---- Matrix case 4: empty dir ----
|
|
it("case 4 — empty debug dir: clean 'no sessions' output, no crash", async () => {
|
|
const out = await runList();
|
|
expect(out).toContain("No profiling sessions found");
|
|
expect(out).not.toContain("### Native Profiler Sessions");
|
|
});
|
|
|
|
// ---- Matrix case 5: adversarial filenames — classify by REAL extension ----
|
|
it("case 5 — adversarial: 'pftrace' substring in a .trace name is iOS; .pftrace is Android", async () => {
|
|
// iOS session whose id literally contains the word 'pftrace' but the file
|
|
// ends in .trace → must classify as iOS, NOT Android.
|
|
await fs.mkdir(path.join(debugDir, "native-profiler-20260201-101010.trace"), {
|
|
recursive: true,
|
|
});
|
|
await fs.writeFile(
|
|
path.join(debugDir, "native-profiler-20260201-101010_raw_cpu.xml"),
|
|
"<trace-query-result></trace-query-result>"
|
|
);
|
|
// Genuine Android .pftrace.
|
|
await writeAndroidSession("20260202-202020", { metadata: true, report: false });
|
|
|
|
const out = await runList();
|
|
console.log(
|
|
"\n===== adversarial extension classification — real list output =====\n" +
|
|
out +
|
|
"\n=================================================================\n"
|
|
);
|
|
|
|
const iosIdx = out.indexOf("(iOS)");
|
|
const androidIdx = out.indexOf("(Android)");
|
|
const iosSection = out.slice(iosIdx, androidIdx > iosIdx ? androidIdx : undefined);
|
|
const androidSection = androidIdx > -1 ? out.slice(androidIdx) : "";
|
|
|
|
// The 'pftrace'-named-but-.trace session is iOS only.
|
|
expect(iosSection).toContain("20260201-101010");
|
|
expect(androidSection).not.toContain("20260201-101010");
|
|
// The real .pftrace is Android only.
|
|
expect(androidSection).toContain("20260202-202020");
|
|
expect(iosSection).not.toContain("20260202-202020");
|
|
});
|
|
|
|
it("case 5b — '...trace.pftrace' oddity classifies as Android (real extension .pftrace)", async () => {
|
|
// A file whose name ends in `.trace.pftrace`: extension is .pftrace → Android.
|
|
// We bypass writeAndroidSession to craft the odd name precisely.
|
|
await fs.writeFile(
|
|
path.join(debugDir, "native-profiler-20260303-303030.trace.pftrace"),
|
|
"\x0a\x00bytes"
|
|
);
|
|
|
|
const out = await runList();
|
|
console.log(
|
|
"\n===== '.trace.pftrace' oddity — real list output =====\n" +
|
|
out +
|
|
"\n======================================================\n"
|
|
);
|
|
|
|
// Must be Android (ends in .pftrace), never iOS.
|
|
expect(out).toContain("### Native Profiler Sessions (Android)");
|
|
expect(out).not.toContain("### Native Profiler Sessions (iOS)");
|
|
expect(out).toContain("`20260303-303030`");
|
|
expect(countOccurrences(out, "20260303-303030")).toBe(1);
|
|
});
|
|
|
|
it("case 5c — an Android session with .pftrace + leftover -report.md never leaks into iOS (the regression)", async () => {
|
|
// This is the exact scenario the PR fixes: the -report.md also matches the
|
|
// native-profiler- prefix, so pre-fix it surfaced under BOTH headings.
|
|
await writeAndroidSession("20260615-112028", { metadata: true, report: true });
|
|
|
|
const out = await runList();
|
|
expect(out).not.toContain("### Native Profiler Sessions (iOS)");
|
|
expect(out).toContain("### Native Profiler Sessions (Android)");
|
|
expect(countOccurrences(out, "20260615-112028")).toBe(1);
|
|
});
|
|
|
|
// ---- Matrix case 6: malformed / missing sidecars degrade gracefully ----
|
|
it("case 6 — Android session with missing metadata & malformed report still classified, no throw", async () => {
|
|
// No metadata sidecar, a malformed (non-markdown garbage) report present.
|
|
await writeAndroidSession("20260404-040404", {
|
|
metadata: false,
|
|
report: "\x00\x01 not valid markdown \xff",
|
|
});
|
|
|
|
const out = await runList();
|
|
expect(out).toContain("### Native Profiler Sessions (Android)");
|
|
// listSessions does NOT read metadata/report contents for the list, so the
|
|
// session is still classified and listed once with the report tag.
|
|
expect(out).toContain("`20260404-040404` | pftrace, report |");
|
|
expect(countOccurrences(out, "20260404-040404")).toBe(1);
|
|
});
|
|
|
|
it("case 6b — iOS session with malformed report & only leaks export degrades gracefully", async () => {
|
|
await writeIosSession("20260505-050505", {
|
|
cpu: false,
|
|
hangs: false,
|
|
leaks: true,
|
|
report: "\x00 garbage report \xff",
|
|
});
|
|
|
|
const out = await runList();
|
|
expect(out).toContain("### Native Profiler Sessions (iOS)");
|
|
// Only leaks + report → "leaks, report".
|
|
expect(out).toContain("`20260505-050505` | leaks, report |");
|
|
expect(countOccurrences(out, "20260505-050505")).toBe(1);
|
|
});
|
|
|
|
// ---- Matrix case 7: co-timed iOS + Android under ONE session id ----
|
|
it("case 7 — co-timed: ONE session id with both iOS and Android artifacts appears under BOTH headings (M1)", async () => {
|
|
// iOS + Android captured in the same UTC second share one second-granular,
|
|
// discriminator-free session id in the shared debug dir. Pre-fix the whole
|
|
// group routed to Android and the iOS heading was HIDDEN (a visibility
|
|
// regression). The session must now surface under BOTH headings, each
|
|
// listing only its own artifacts.
|
|
const ts = "20260615-112028";
|
|
// iOS artifacts (xctrace .trace bundle + a raw CPU export).
|
|
await fs.mkdir(path.join(debugDir, `native-profiler-${ts}.trace`), { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(debugDir, `native-profiler-${ts}_raw_cpu.xml`),
|
|
"<trace-query-result></trace-query-result>"
|
|
);
|
|
// Android artifacts (.pftrace + metadata sidecar).
|
|
await fs.writeFile(path.join(debugDir, `native-profiler-${ts}.pftrace`), "\x0a\x00bytes");
|
|
await fs.writeFile(
|
|
path.join(debugDir, `native-profiler-${ts}.pftrace.metadata.json`),
|
|
`${JSON.stringify(
|
|
{ platform: "android", appProcess: "com.example.app", wallClockStartMs: 1710000000000 },
|
|
null,
|
|
2
|
|
)}\n`
|
|
);
|
|
// A single shared -report.md (matches the native prefix; both headings show it).
|
|
await fs.writeFile(path.join(debugDir, `native-profiler-${ts}-report.md`), "# report\n");
|
|
|
|
const out = await runList();
|
|
console.log(
|
|
"\n===== co-timed iOS+Android (M1) — real list output =====\n" +
|
|
out +
|
|
"\n========================================================\n"
|
|
);
|
|
|
|
const iosIdx = out.indexOf("### Native Profiler Sessions (iOS)");
|
|
const androidIdx = out.indexOf("### Native Profiler Sessions (Android)");
|
|
// BOTH headings present (pre-fix the iOS heading was missing entirely).
|
|
expect(iosIdx).toBeGreaterThan(-1);
|
|
expect(androidIdx).toBeGreaterThan(iosIdx);
|
|
|
|
const iosSection = out.slice(iosIdx, androidIdx);
|
|
const androidSection = out.slice(androidIdx);
|
|
|
|
// The iOS row shows its own files (CPU + shared report), NOT pftrace.
|
|
expect(iosSection).toContain(`\`${ts}\` | CPU, report |`);
|
|
expect(iosSection).not.toContain("pftrace");
|
|
// The Android row shows its own files (pftrace + shared report), NOT CPU.
|
|
expect(androidSection).toContain(`\`${ts}\` | pftrace, report |`);
|
|
expect(androidSection).not.toContain("CPU");
|
|
|
|
// The session id appears under each heading exactly once (twice overall).
|
|
expect(countOccurrences(out, ts)).toBe(2);
|
|
});
|
|
|
|
// ---- Matrix case 8: lone .pftrace.metadata.json (no bare .pftrace) ----
|
|
it("case 8 — lone .pftrace.metadata.json (no bare .pftrace) classifies as Android, not iOS (L1)", async () => {
|
|
// The metadata sidecar does NOT end in `.pftrace`, so the old
|
|
// `.endsWith(".pftrace")` check misclassified a session whose only Android
|
|
// marker was the sidecar as iOS. The `.pftrace.metadata.json` matcher fixes
|
|
// this; the Android Files tag must omit the (absent) bare-pftrace tag.
|
|
const ts = "20260707-070707";
|
|
await fs.writeFile(
|
|
path.join(debugDir, `native-profiler-${ts}.pftrace.metadata.json`),
|
|
`${JSON.stringify({ platform: "android", appProcess: "com.example.app" })}\n`
|
|
);
|
|
await fs.writeFile(path.join(debugDir, `native-profiler-${ts}-report.md`), "# report\n");
|
|
|
|
const out = await runList();
|
|
console.log(
|
|
"\n===== lone .pftrace.metadata.json (L1) — real list output =====\n" +
|
|
out +
|
|
"\n===============================================================\n"
|
|
);
|
|
|
|
expect(out).toContain("### Native Profiler Sessions (Android)");
|
|
expect(out).not.toContain("### Native Profiler Sessions (iOS)");
|
|
// No bare .pftrace on disk → the tag is just "report", never "pftrace".
|
|
expect(out).toContain(`\`${ts}\` | report |`);
|
|
expect(countOccurrences(out, ts)).toBe(1);
|
|
});
|
|
|
|
it("case 6c — debug dir missing entirely is recreated by getDebugDir, reports no sessions, no throw", async () => {
|
|
// Remove the debug dir. Through the real execute() entry point, getDebugDir()
|
|
// does `fs.mkdir(dir, { recursive: true })` BEFORE listSessions reads it, so
|
|
// the directory always exists by the time readdir runs and we get the empty
|
|
// "no sessions" message rather than the "_No debug directory found_" branch.
|
|
await fs.rm(debugDir, { recursive: true, force: true });
|
|
const out = await runList();
|
|
expect(out).toContain("No profiling sessions found");
|
|
// The dir was transparently recreated (empty) by getDebugDir.
|
|
await expect(fs.readdir(debugDir)).resolves.toEqual([]);
|
|
});
|
|
});
|