Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions packages/serve-sim/src/__tests__/cors-allow-origin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, test } from "bun:test";

import { corsAllowOriginHeaders } from "../middleware-utils";

describe("corsAllowOriginHeaders", () => {
test("echoes an allowlisted origin, with Vary", () => {
expect(corsAllowOriginHeaders("https://expo.dev", ["https://expo.dev"])).toEqual({
"Access-Control-Allow-Origin": "https://expo.dev",
Vary: "Origin",
});
});

test("canonicalizes configured origins (case, default port, trailing slash) before matching", () => {
for (const configured of ["HTTPS://Expo.Dev", "https://expo.dev:443", "https://expo.dev/"]) {
expect(corsAllowOriginHeaders("https://expo.dev", [configured])).toEqual({
"Access-Control-Allow-Origin": "https://expo.dev",
Vary: "Origin",
});
}
});

test("skips a malformed configured origin instead of throwing", () => {
expect(corsAllowOriginHeaders("https://expo.dev", ["not a url", "https://expo.dev"])).toEqual({
"Access-Control-Allow-Origin": "https://expo.dev",
Vary: "Origin",
});
expect(corsAllowOriginHeaders("https://expo.dev", ["not a url"])).toEqual({});
});

test("allows any loopback origin without config", () => {
for (const origin of ["http://localhost:3000", "http://127.0.0.1:8081", "http://[::1]:9000"]) {
expect(corsAllowOriginHeaders(origin, [])).toEqual({
"Access-Control-Allow-Origin": origin,
Vary: "Origin",
});
}
});

test("emits no header for an unlisted origin", () => {
expect(corsAllowOriginHeaders("https://evil.example", ["https://expo.dev"])).toEqual({});
});

test("emits no header when the origin is absent or malformed", () => {
expect(corsAllowOriginHeaders(null, ["https://expo.dev"])).toEqual({});
expect(corsAllowOriginHeaders("not a url", ["https://expo.dev"])).toEqual({});
});
});
324 changes: 324 additions & 0 deletions packages/serve-sim/src/__tests__/cpu-mem-sampler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,324 @@
import { describe, expect, it } from "bun:test";

import {
createMetricsSamplerCache,
findUserAppProcesses,
MetricsSampler,
sampleUserApp,
sumPhysFootprintBytes,
type MetricSample,
} from "../cpu-mem-sampler";

const UDID = "ABCD1234-0000-0000-0000-0000000000EF";

// `ps -axo pid= cputime= rss= args=` — the user app and its extension run from the sim's
// Containers/Bundle/Application path; launchd_sim and a RuntimeRoot daemon do not,
// and an unrelated host process is off-device. Only the first two count. cputime is cumulative.
function psFixture(): string {
return [
` 101 0:00.10 15000 launchd_sim /x/Devices/${UDID}/data/var/run/launchd_bootstrap.plist`,
` 102 0:40.00 32000 /Runtime/RuntimeRoot/System/Library/PrivateFrameworks/ApplePushService.framework/apsd`,
` 103 0:12.00 80000 /x/Devices/${UDID}/data/Containers/Bundle/Application/AAA/MyApp.app/MyApp`,
` 104 0:03.00 20000 /x/Devices/${UDID}/data/Containers/Bundle/Application/AAA/MyApp.app/PlugIns/Share.appex/Share`,
` 105 0:09.90 999999 /some/host/process --unrelated`,
].join("\n");
}

// A second user app ("Two Words.app", a space in its path like real "Expo Go.app") on the same sim.
function psFixtureTwoApps(): string {
return [
psFixture(),
` 106 0:02.00 50000 /x/Devices/${UDID}/data/Containers/Bundle/Application/BBB/Two Words.app/Two Words`,
].join("\n");
}

describe("findUserAppProcesses", () => {
it("scopes to the frontmost app's bundle (host + extensions), ignoring other user/system apps", () => {
// frontmost pid 103 -> MyApp.app; sums MyApp (12s) + its Share extension (3s), not the other app
expect(findUserAppProcesses(psFixtureTwoApps(), UDID, 103)).toEqual({
pids: [103, 104],
cpuSeconds: 15,
rssKb: 80000 + 20000,
});
// frontmost is the space-named app -> just that bundle
expect(findUserAppProcesses(psFixtureTwoApps(), UDID, 106)).toEqual({
pids: [106],
cpuSeconds: 2,
rssKb: 50000,
});
});

it("matches the device path case-insensitively", () => {
const ps = `7 0:02.00 1000 /x/Devices/${UDID.toLowerCase()}/data/Containers/Bundle/Application/AAA/App.app/App`;
expect(findUserAppProcesses(ps, UDID, 7)).toEqual({ pids: [7], cpuSeconds: 2, rssKb: 1000 });
});

it("sums every user app when the frontmost pid is unknown or not a user app", () => {
// no frontmost pid, or one that isn't a user process -> aggregate all user apps
for (const pid of [undefined, 999999]) {
expect(findUserAppProcesses(psFixtureTwoApps(), UDID, pid)).toEqual({
pids: [103, 104, 106],
cpuSeconds: 15 + 2,
rssKb: 80000 + 20000 + 50000,
});
}
});

it("returns null when no user app is running on this sim", () => {
// right sim, but only a system process (no Containers/Bundle path)
expect(
findUserAppProcesses(`1 0.1 15000 launchd_sim /x/Devices/${UDID}/data/var/run/x.plist\n`, UDID),
).toBeNull();
expect(findUserAppProcesses("", UDID)).toBeNull();
});
});

describe("sumPhysFootprintBytes", () => {
// `footprint --noCategories --format bytes <pid> <pid>` output shape
const footprintFixture = [
"======================================================================",
"MyApp [103]: 64-bit Footprint: 253904480 B (16384 bytes per page)",
"======================================================================",
"",
"Auxiliary data:",
" phys_footprint: 253937248 B",
" phys_footprint_peak: 333989448 B",
"",
"======================================================================",
"Share [104]: 64-bit Footprint: 2244968 B (16384 bytes per page)",
"======================================================================",
"",
"Auxiliary data:",
" phys_footprint: 2261352 B",
" phys_footprint_peak: 2310504 B",
"",
"======================================================================",
"Summary Footprint: 256100296 B",
"======================================================================",
].join("\n");

it("sums per-process phys_footprint, ignoring peaks and the summary", () => {
expect(sumPhysFootprintBytes(footprintFixture)).toBe(253937248 + 2261352);
});

it("returns null when no process was reported", () => {
expect(sumPhysFootprintBytes("")).toBeNull();
expect(sumPhysFootprintBytes("footprint: Unable to find pid for process matching '9'")).toBeNull();
});
});

describe("sampleUserApp", () => {
const footprintFor = (pids: string[]): string =>
pids.map((pid) => `App [${pid}]:\nAuxiliary data:\n phys_footprint: 1000000 B\n`).join("\n");

const frontmost = (pid: number, bundleId = "dev.expo.MyApp") => async () => ({ pid, bundleId });

it("tags the sample with the frontmost bundleId and combines ps (cpu) with footprint (mem)", async () => {
const seen: string[] = [];
const exec = async (file: string, args: string[]): Promise<string> => {
seen.push(file);
if (file === "ps") return psFixtureTwoApps();
// footprint receives only the frontmost app's pids (MyApp 103 + its extension 104)
const pids = args.filter((a) => /^\d+$/.test(a));
expect(pids).toEqual(["103", "104"]);
return footprintFor(pids);
};
const usage = await sampleUserApp(UDID, { exec, frontmostApp: frontmost(103, "dev.expo.MyApp") });
// cumulative cpu seconds of MyApp (12) + its extension (3); the sampler turns this into a %
expect(usage).toEqual({ bundleId: "dev.expo.MyApp", cpuSeconds: 15, memBytes: 2_000_000 });
expect(seen.sort()).toEqual(["footprint", "ps"]);
});

it("falls back to RSS bytes when footprint fails", async () => {
const exec = async (file: string): Promise<string> => {
if (file === "ps") return psFixture();
throw new Error("footprint exited non-zero");
};
const usage = await sampleUserApp(UDID, { exec, frontmostApp: frontmost(103) });
expect(usage).toEqual({ bundleId: "dev.expo.MyApp", cpuSeconds: 15, memBytes: (80000 + 20000) * 1024 });
});

it("tags bundleId null and covers all user apps when nothing user-facing is foreground", async () => {
const exec = async (file: string, args: string[]): Promise<string> =>
file === "ps" ? psFixtureTwoApps() : footprintFor(args.filter((a) => /^\d+$/.test(a)));
// AX unavailable -> no frontmost app: sum all user apps (103 + 104 + 106), bundleId null
expect(await sampleUserApp(UDID, { exec, frontmostApp: async () => null })).toEqual({
bundleId: null,
cpuSeconds: 17,
memBytes: 3_000_000,
});
// a system app is frontmost (pid not among the user-app processes) -> same
expect(await sampleUserApp(UDID, { exec, frontmostApp: frontmost(999999) })).toEqual({
bundleId: null,
cpuSeconds: 17,
memBytes: 3_000_000,
});
});

it("returns null when no user app is running or ps fails", async () => {
const psFails = async (): Promise<string> => {
throw new Error("ps exited non-zero");
};
expect(await sampleUserApp(UDID, { exec: psFails, frontmostApp: frontmost(103) })).toBeNull();
// ps succeeds but only system processes are present
const systemOnly = async (file: string): Promise<string> =>
file === "ps" ? `1 0.1 15000 launchd_sim /x/Devices/${UDID}/data/var/run/x.plist` : "";
expect(await sampleUserApp(UDID, { exec: systemOnly, frontmostApp: async () => null })).toBeNull();
});
});

describe("MetricsSampler", () => {
function fakeClock(step = 1000): () => number {
let t = 0;
return () => {
const v = t;
t += step;
return v;
};
}

it("exposes meta (schema, udid, hostCores, interval)", () => {
const sampler = new MetricsSampler({ udid: UDID, intervalMs: 500, hostCores: 8 });
expect(sampler.meta).toEqual({
schemaVersion: 1,
udid: UDID,
hostCores: 8,
sampleIntervalMs: 500,
});
});

it("derives cpuPct from the cpu-time delta over each 1s interval", async () => {
// Cumulative cpu seconds per tick, at 1s spacing (fakeClock). Expected cpuPct:
// t1 no baseline -> 0; t2 +0.5s/1s -> 50; t3 drop (churn) -> clamped 0;
// t4 app switch (B) -> 0; t5 +0.6s/1s -> 60.
const readings = [
{ bundleId: "dev.expo.A", cpuSeconds: 10.0, memBytes: 100 },
{ bundleId: "dev.expo.A", cpuSeconds: 10.5, memBytes: 400 },
{ bundleId: "dev.expo.A", cpuSeconds: 10.4, memBytes: 250 },
{ bundleId: "dev.expo.B", cpuSeconds: 99.0, memBytes: 260 },
{ bundleId: "dev.expo.B", cpuSeconds: 99.6, memBytes: 270 },
];
let i = 0;
const sampler = new MetricsSampler({
udid: UDID,
sample: async () => readings[i++]!,
now: fakeClock(),
hostCores: 8,
});
const got: MetricSample[] = [];
sampler.onSample((s) => got.push(s));

for (let n = 0; n < readings.length; n++) await sampler.tickOnce();

expect(got).toEqual([
{ t: 1000, bundleId: "dev.expo.A", cpuPct: 0, memBytes: 100 },
{ t: 2000, bundleId: "dev.expo.A", cpuPct: 50, memBytes: 400 },
{ t: 3000, bundleId: "dev.expo.A", cpuPct: 0, memBytes: 250 },
{ t: 4000, bundleId: "dev.expo.B", cpuPct: 0, memBytes: 260 },
{ t: 5000, bundleId: "dev.expo.B", cpuPct: 60, memBytes: 270 },
]);
sampler.stop();
});

it("anchors the CPU delta to the observation time, not after the slow memory probe", async () => {
let clockMs = 0;
const now = () => clockMs;
const readings = [
{ bundleId: "dev.expo.A", cpuSeconds: 10, memBytes: 100 },
{ bundleId: "dev.expo.A", cpuSeconds: 10.5, memBytes: 100 },
];
let i = 0;
let footprintMs = 0;
// sample() reads cpu up front, then the footprint probe takes `footprintMs` before returning.
const sample = async (): Promise<(typeof readings)[number]> => {
const reading = readings[i++]!;
clockMs += footprintMs;
return reading;
};
const sampler = new MetricsSampler({ udid: UDID, sample, now, hostCores: 8 });
const got: MetricSample[] = [];
sampler.onSample((s) => got.push(s));

footprintMs = 0;
await sampler.tickOnce(); // baseline
clockMs += 1000; // 1s until the next observation
footprintMs = 1000; // this tick's footprint probe is slow
await sampler.tickOnce();

// 0.5 cpu-seconds over the 1s observation interval = 50%, unaffected by the 1s footprint latency
// (which used to inflate the denominator and would report 25% here).
expect(got.at(-1)!.cpuPct).toBe(50);
sampler.stop();
});

it("skips a tick when the sim isn't up (null reading)", async () => {
const sampler = new MetricsSampler({
udid: UDID,
sample: async () => null,
now: fakeClock(),
hostCores: 8,
});
const got: MetricSample[] = [];
sampler.onSample((s) => got.push(s));
expect(await sampler.tickOnce()).toBeNull();
expect(got).toHaveLength(0);
});
});

describe("createMetricsSamplerCache", () => {
it("shares one sampler across subscribers for the same udid and stops it on last unsubscribe", () => {
const built: MetricsSampler[] = [];
const cache = createMetricsSamplerCache((udid) => {
const s = new MetricsSampler({ udid, sample: async () => null, hostCores: 8 });
built.push(s);
return s;
});

const a = cache.subscribe(UDID, () => {});
const b = cache.subscribe(UDID, () => {});
expect(built).toHaveLength(1); // one shared sampler
expect(a.meta.udid).toBe(UDID);

a.unsubscribe();
expect(built[0]!.listenerCount).toBe(1); // still alive for b
b.unsubscribe();

// A fresh subscribe after the last leaves builds a new sampler.
cache.subscribe(UDID, () => {});
expect(built).toHaveLength(2);
});

it("fans one sample out to every subscriber", async () => {
let sampler!: MetricsSampler;
const cache = createMetricsSamplerCache((udid) => {
sampler = new MetricsSampler({ udid, sample: async () => ({ bundleId: "dev.expo.A", cpuSeconds: 5, memBytes: 9 }), now: (() => { let t = 0; return () => (t += 1000); })(), hostCores: 8 });
return sampler;
});
const seen: number[] = [];
cache.subscribe(UDID, () => seen.push(1));
cache.subscribe(UDID, () => seen.push(2));
await sampler.tickOnce();
expect(seen.sort()).toEqual([1, 2]);
});

it("a stale double-unsubscribe does not evict a replacement sampler", () => {
const built: MetricsSampler[] = [];
const cache = createMetricsSamplerCache((udid) => {
const s = new MetricsSampler({ udid, sample: async () => null, hostCores: 8 });
built.push(s);
return s;
});

const first = cache.subscribe(UDID, () => {}); // builds sampler #1
first.unsubscribe(); // last listener -> stops + evicts #1
const second = cache.subscribe(UDID, () => {}); // builds sampler #2 for the same udid
expect(built).toHaveLength(2);

first.unsubscribe(); // stale, replayed: must NOT evict #2

// #2 is still the active sampler, so a new subscriber reuses it (no #3 built).
cache.subscribe(UDID, () => {});
expect(built).toHaveLength(2);
expect(second.meta.udid).toBe(UDID);
});
});
Loading