diff --git a/packages/app/e2e/mobile/status-ring/README.md b/packages/app/e2e/mobile/status-ring/README.md new file mode 100644 index 0000000000..213af8a580 --- /dev/null +++ b/packages/app/e2e/mobile/status-ring/README.md @@ -0,0 +1,45 @@ +# Native retained status-ring regression + +This runs the production native ring and clock inside real retained panels with +Reanimated on Android. A probe consumes the same public rotation hook and counts +UI-runtime value changes. No Reanimated mocks or provider processes are used. + +Use an Android development client compatible with the checkout's native dependencies. +In a disposable checkout, temporarily replace `packages/app/index.ts` with: + +```tsx +import "./src/polyfills/install-early"; +import "./src/styles/unistyles"; +import { registerRootComponent } from "expo"; +import NativeRingRegression from "./e2e/mobile/status-ring/native"; +registerRootComponent(NativeRingRegression); +``` + +Start Metro in `packages/app`: + +```sh +APP_VARIANT=development npx expo start --localhost --port 8096 +``` + +With one Android emulator running, open that bundle: + +```sh +agent-device open sh.paseo.debug 'exp+voice-mobile://expo-development-client/?url=http%3A%2F%2F10.0.2.2%3A8096' --platform android --session status-ring --metro-port 8096 +``` + +From the repository root: + +```sh +node packages/app/e2e/mobile/status-ring/android.mjs status-ring http://127.0.0.1:8096 +``` + +The test checks visible rotation, hidden-panel inactivity while another ring is +visible, inactivity with both panels hidden, and synchronized resumption. Each +sample reads counters on the UI runtime; elapsed time includes device-command +latency. The baseline fails at the first hidden-panel assertion. + +Restore the original `packages/app/index.ts`, close the agent-device session, +and stop Metro afterward. The fixture substitutes running rings for a live agent +and toggles retained panels directly; it does not exercise workspace navigation +or measure whole-app CPU usage. It is excluded from routine unit tests because +it requires a native runtime. diff --git a/packages/app/e2e/mobile/status-ring/android.mjs b/packages/app/e2e/mobile/status-ring/android.mjs new file mode 100644 index 0000000000..7b5735fa2b --- /dev/null +++ b/packages/app/e2e/mobile/status-ring/android.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { setTimeout as delay } from "node:timers/promises"; + +const [, , session, metroUrl] = process.argv; +assert(session && metroUrl, "Pass an agent-device session and Metro URL serving native.tsx"); +function ad(...args) { + return execFileSync("agent-device", ["--session", session, ...args], { + encoding: "utf8", + timeout: 60_000, + }); +} +function evaluate(expression) { + const value = ad("cdp", "runtime", "eval", "--expr", expression, "--json").trim(); + return value === "undefined" ? undefined : JSON.parse(value); +} +let target; +const deadline = Date.now() + 30_000; +do { + target = ad("cdp", "target", "list", "--url", metroUrl) + .split("\n") + .find((line) => line.includes("sh.paseo.debug")); + if (!target) await delay(250); +} while (!target && Date.now() < deadline); +assert(target, "Open the Android dev client on this Metro bundle first"); +ad("cdp", "target", "select", target.split(/\s+/)[0]); +let ready = false; +do { + ready = evaluate("Object.keys(globalThis.__ringReaders ?? {}).length === 2"); + if (!ready) await delay(250); +} while (!ready && Date.now() < deadline); +assert(ready, "The two native probes must mount before sampling"); +async function sample() { + evaluate("Object.values(globalThis.__ringReaders).forEach(read => read())"); + await delay(100); + return JSON.parse(evaluate("JSON.stringify(globalThis.__ringSamples)")); +} +async function observe(label) { + const before = await sample(); + await delay(600); + const after = await sample(); + const deltas = Object.fromEntries( + Object.keys(before).map((key) => [key, after[key].updates - before[key].updates]), + ); + console.log(JSON.stringify({ label, deltas, after })); + return { deltas, after }; +} +const visible = await observe("both visible"); +assert(visible.deltas.first > 0 && visible.deltas.second > 0); +assert.equal(visible.after.first.rotation, visible.after.second.rotation); +ad("press", 'label="HIDE FIRST"'); +await delay(200); +const hidden = await observe("first hidden, second visible"); +assert.equal(hidden.deltas.first, 0, "A retained hidden ring must stop UI-thread rotation updates"); +assert(hidden.deltas.second > 0, "Visible rings must continue rotating"); +ad("press", 'label="HIDE SECOND"'); +await delay(200); +const allHidden = await observe("both hidden"); +assert.equal(allHidden.deltas.first, 0); +assert.equal(allHidden.deltas.second, 0); +ad("press", 'label="SHOW FIRST"'); +ad("press", 'label="SHOW SECOND"'); +await delay(200); +const restored = await observe("both shown again"); +assert(restored.deltas.first > 0 && restored.deltas.second > 0); +assert.equal( + restored.after.first.rotation, + restored.after.second.rotation, + "Restored rings must share the current phase", +); +console.log("PASS native retained-ring lifecycle"); diff --git a/packages/app/e2e/mobile/status-ring/native.tsx b/packages/app/e2e/mobile/status-ring/native.tsx new file mode 100644 index 0000000000..987a225431 --- /dev/null +++ b/packages/app/e2e/mobile/status-ring/native.tsx @@ -0,0 +1,69 @@ +import { useCallback, useLayoutEffect, useState } from "react"; +import { Button, Text, View } from "react-native"; +import { useAnimatedReaction, useSharedValue } from "react-native-reanimated"; +import { scheduleOnRN, scheduleOnUI } from "react-native-worklets"; +import { RetainedPanel } from "@/components/retained-panel"; +import { useStatusRingRotation } from "@/components/status-ring/clock"; +import { StatusRing } from "@/components/status-ring"; + +interface Sample { + updates: number; + rotation: number; +} + +declare global { + var __ringSamples: Record; + var __ringReaders: Record void>; +} + +function recordSample(name: string, value: { updates: number; rotation: number }) { + globalThis.__ringSamples[name] = value; +} + +function Probe({ name }: { name: string }) { + const rotation = useStatusRingRotation(); + const updates = useSharedValue(0); + useAnimatedReaction( + () => rotation.value, + () => { + updates.value += 1; + }, + ); + // Read the real UI runtime, without scheduling a continuous JS observer. + useLayoutEffect(() => { + globalThis.__ringSamples ??= {}; + globalThis.__ringReaders ??= {}; + globalThis.__ringReaders[name] = () => + scheduleOnUI(() => { + "worklet"; + const sample = { updates: updates.value, rotation: rotation.value }; + scheduleOnRN(recordSample, name, sample); + }); + return () => { + delete globalThis.__ringReaders[name]; + }; + }, [name, rotation, updates]); + return ; +} + +const containerStyle = { flex: 1, padding: 60, backgroundColor: "white" }; + +export default function NativeRingRegression() { + const [first, setFirst] = useState(true); + const [second, setSecond] = useState(true); + const toggleFirst = useCallback(() => setFirst((value) => !value), []); + const toggleSecond = useCallback(() => setSecond((value) => !value), []); + return ( + + Native retained status rings +