evOo-local/apps/mobile/lib/computer-refresh.test.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

353 lines
14 KiB
TypeScript

import type { ComputerStatus } from "@rakazo/contracts";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createComputerRefresh, SCREEN_URL_RENEW_MS } from "./computer-refresh";
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
const running = { state: "running", controlHolder: "none" } as ComputerStatus;
const stopped = { state: "stopped", controlHolder: "none" } as ComputerStatus;
const controlled = { state: "running", controlHolder: "user" } as ComputerStatus;
function setup() {
const options = {
readStatus: vi.fn<() => Promise<ComputerStatus>>().mockResolvedValue(running),
readScreen: vi
.fn<(attempts: number) => Promise<string | null>>()
.mockResolvedValue("https://screen.example.test"),
onStatus: vi.fn(),
onScreen: vi.fn(),
onReady: vi.fn(),
onInitialError: vi.fn(),
};
return { ...options, controller: createComputerRefresh(options) };
}
afterEach(() => vi.useRealTimers());
describe("computer refresh lifecycle", () => {
it("waits for a slow refresh to finish before scheduling the next poll", async () => {
vi.useFakeTimers();
const fixture = setup();
const pending = deferred<ComputerStatus>();
fixture.readStatus.mockReturnValueOnce(pending.promise);
fixture.controller.start();
await vi.advanceTimersByTimeAsync(10_000);
expect(fixture.readStatus).toHaveBeenCalledTimes(1);
pending.resolve(running);
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(1999);
expect(fixture.readStatus).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
expect(fixture.readStatus).toHaveBeenCalledTimes(2);
fixture.controller.dispose();
});
it("lets an explicit refresh supersede an older screen read and retain its retry count", async () => {
vi.useFakeTimers();
const fixture = setup();
const oldScreen = deferred<string | null>();
fixture.readScreen.mockReturnValueOnce(oldScreen.promise);
fixture.controller.start();
await vi.advanceTimersByTimeAsync(0);
fixture.readStatus.mockResolvedValue(stopped);
fixture.readScreen.mockResolvedValue(null);
await fixture.controller.refresh({ screenAttempts: 5 });
oldScreen.resolve("https://old-screen.example.test");
await vi.advanceTimersByTimeAsync(0);
expect(fixture.onStatus).toHaveBeenLastCalledWith(stopped);
expect(fixture.onScreen).toHaveBeenCalledExactlyOnceWith(null);
expect(fixture.readScreen).toHaveBeenLastCalledWith(5);
fixture.controller.dispose();
});
it("ignores an old target's response after disposal and starts the new target independently", async () => {
vi.useFakeTimers();
const old = setup();
const pending = deferred<ComputerStatus>();
old.readStatus.mockReturnValue(pending.promise);
old.controller.start();
old.controller.dispose();
const current = setup();
current.controller.start();
pending.resolve(stopped);
await vi.advanceTimersByTimeAsync(0);
expect(old.onStatus).not.toHaveBeenCalled();
expect(old.onReady).not.toHaveBeenCalled();
expect(current.onStatus).toHaveBeenCalledWith(running);
current.controller.dispose();
await vi.advanceTimersByTimeAsync(10_000);
expect(old.readStatus).toHaveBeenCalledTimes(1);
expect(current.readStatus).toHaveBeenCalledTimes(1);
});
it("ignores a pending screen response after unmount, including strict-mode restart", async () => {
vi.useFakeTimers();
const fixture = setup();
const pending = deferred<string | null>();
fixture.readScreen.mockReturnValueOnce(pending.promise);
fixture.controller.start();
await vi.advanceTimersByTimeAsync(0);
fixture.controller.dispose();
fixture.controller.start();
await vi.advanceTimersByTimeAsync(0);
pending.resolve("https://old-screen.example.test");
await vi.advanceTimersByTimeAsync(0);
expect(fixture.onScreen).toHaveBeenCalledExactlyOnceWith("https://screen.example.test");
fixture.controller.dispose();
});
it("keeps the URL on screen failures, clears it on null, and recovers from status failures", async () => {
vi.useFakeTimers();
const fixture = setup();
const error = new Error("status failed");
fixture.readStatus.mockRejectedValueOnce(error);
fixture.controller.start();
await vi.advanceTimersByTimeAsync(0);
expect(fixture.onInitialError).toHaveBeenCalledWith(error);
expect(fixture.onReady).toHaveBeenCalledTimes(1);
fixture.readScreen.mockRejectedValueOnce(new Error("screen failed"));
await vi.advanceTimersByTimeAsync(2000);
expect(fixture.onScreen).not.toHaveBeenCalled();
expect(fixture.onReady).toHaveBeenCalledTimes(2);
fixture.readScreen.mockResolvedValue(null);
await vi.advanceTimersByTimeAsync(2000);
expect(fixture.onScreen).toHaveBeenCalledWith(null);
fixture.controller.dispose();
});
});
describe("computer action lifecycle", () => {
it("keeps overlapping actions valid and pauses polling until both finish", async () => {
vi.useFakeTimers();
const fixture = setup();
fixture.controller.start();
await vi.advanceTimersByTimeAsync(0);
const boot = fixture.controller.beginAction();
const release = fixture.controller.beginAction();
const bootMutation = deferred<void>();
const releaseMutation = deferred<void>();
const bootWork = (async () => {
await bootMutation.promise;
expect(boot.isActive()).toBe(true);
await boot.refresh({ screenAttempts: 5 });
boot.finish();
boot.finish();
})();
const releaseWork = (async () => {
await releaseMutation.promise;
expect(release.isActive()).toBe(true);
await release.refresh();
release.finish();
})();
await vi.advanceTimersByTimeAsync(10_000);
expect(fixture.readStatus).toHaveBeenCalledTimes(1);
bootMutation.resolve();
await bootWork;
expect(fixture.readScreen).toHaveBeenLastCalledWith(5);
await vi.advanceTimersByTimeAsync(10_000);
expect(fixture.readStatus).toHaveBeenCalledTimes(2);
releaseMutation.resolve();
await releaseWork;
expect(fixture.readStatus).toHaveBeenCalledTimes(3);
await vi.advanceTimersByTimeAsync(1999);
expect(fixture.readStatus).toHaveBeenCalledTimes(3);
await vi.advanceTimersByTimeAsync(1);
expect(fixture.readStatus).toHaveBeenCalledTimes(4);
fixture.controller.dispose();
});
it("invalidates an old screen when an action begins, before the mutation completes", async () => {
vi.useFakeTimers();
const fixture = setup();
const oldScreen = deferred<string | null>();
fixture.readScreen.mockReturnValueOnce(oldScreen.promise);
fixture.controller.start();
await vi.advanceTimersByTimeAsync(0);
const action = fixture.controller.beginAction();
oldScreen.resolve("https://obsolete.example.test");
await vi.advanceTimersByTimeAsync(5000);
expect(fixture.onScreen).not.toHaveBeenCalled();
expect(fixture.onReady).not.toHaveBeenCalled();
expect(fixture.readStatus).toHaveBeenCalledTimes(1);
await action.refresh();
action.finish();
expect(fixture.onScreen).toHaveBeenCalledExactlyOnceWith("https://screen.example.test");
fixture.controller.dispose();
});
it("lets explicit action recovery bypass a hung poll and suppresses its eventual failure", async () => {
vi.useFakeTimers();
const fixture = setup();
const hung = deferred<ComputerStatus>();
fixture.readStatus.mockReturnValueOnce(hung.promise);
fixture.controller.start();
const action = fixture.controller.beginAction();
await action.refresh({ screenAttempts: 5 });
action.finish();
expect(fixture.onStatus).toHaveBeenCalledExactlyOnceWith(running);
expect(fixture.readScreen).toHaveBeenCalledExactlyOnceWith(5);
hung.reject(new Error("obsolete poll failed"));
await vi.advanceTimersByTimeAsync(1999);
expect(fixture.onInitialError).not.toHaveBeenCalled();
expect(fixture.readStatus).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(1);
expect(fixture.readStatus).toHaveBeenCalledTimes(3);
fixture.controller.dispose();
});
it("waits for the latest refresh when an action finishes during another slow read", async () => {
vi.useFakeTimers();
const fixture = setup();
fixture.controller.start();
await vi.advanceTimersByTimeAsync(0);
const action = fixture.controller.beginAction();
const latest = deferred<ComputerStatus>();
fixture.readStatus.mockReturnValueOnce(latest.promise);
const refreshing = fixture.controller.refresh();
action.finish();
await vi.advanceTimersByTimeAsync(10_000);
expect(fixture.readStatus).toHaveBeenCalledTimes(2);
latest.resolve(stopped);
await refreshing;
expect(fixture.onStatus).toHaveBeenLastCalledWith(stopped);
await vi.advanceTimersByTimeAsync(2000);
expect(fixture.readStatus).toHaveBeenCalledTimes(3);
fixture.controller.dispose();
});
it("resumes recovery after a failed action even if an invalidated poll never settles", async () => {
vi.useFakeTimers();
const fixture = setup();
const hung = deferred<ComputerStatus>();
fixture.readStatus.mockReturnValueOnce(hung.promise);
fixture.controller.start();
const action = fixture.controller.beginAction();
const failure = new Error("mutation failed");
const mutation = deferred<void>();
const work = (async () => {
try {
await mutation.promise;
await action.refresh();
} finally {
action.finish();
}
})();
mutation.reject(failure);
await expect(work).rejects.toBe(failure);
await vi.advanceTimersByTimeAsync(2000);
expect(fixture.readStatus).toHaveBeenCalledTimes(2);
expect(fixture.onStatus).toHaveBeenCalledExactlyOnceWith(running);
fixture.controller.dispose();
});
it("keeps old action completions inactive across disposal and strict-mode restart", async () => {
vi.useFakeTimers();
const fixture = setup();
fixture.controller.start();
await vi.advanceTimersByTimeAsync(0);
const old = fixture.controller.beginAction();
fixture.controller.dispose();
fixture.controller.start();
await vi.advanceTimersByTimeAsync(0);
const current = fixture.controller.beginAction();
expect(old.isActive()).toBe(false);
expect(current.isActive()).toBe(true);
await old.refresh();
old.finish();
await vi.advanceTimersByTimeAsync(10_000);
expect(fixture.readStatus).toHaveBeenCalledTimes(2);
await current.refresh();
current.finish();
await vi.advanceTimersByTimeAsync(2000);
expect(fixture.readStatus).toHaveBeenCalledTimes(4);
fixture.controller.dispose();
});
});
describe("screen URL reuse", () => {
it("reads the screen once and keeps it across polls while the status is unchanged", async () => {
vi.useFakeTimers();
const fixture = setup();
fixture.controller.start();
await vi.advanceTimersByTimeAsync(0);
expect(fixture.readScreen).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(10_000);
expect(fixture.readStatus).toHaveBeenCalledTimes(6);
expect(fixture.readScreen).toHaveBeenCalledTimes(1);
expect(fixture.onScreen).toHaveBeenCalledExactlyOnceWith("https://screen.example.test");
fixture.controller.dispose();
});
it("caches a null screen result until the status changes", async () => {
vi.useFakeTimers();
const fixture = setup();
fixture.readStatus.mockResolvedValue(stopped);
fixture.readScreen.mockResolvedValue(null);
fixture.controller.start();
await vi.advanceTimersByTimeAsync(6000);
expect(fixture.readScreen).toHaveBeenCalledTimes(1);
expect(fixture.onScreen).toHaveBeenCalledExactlyOnceWith(null);
fixture.readStatus.mockResolvedValue(running);
fixture.readScreen.mockResolvedValue("https://screen.example.test");
await vi.advanceTimersByTimeAsync(2000);
expect(fixture.readScreen).toHaveBeenCalledTimes(2);
expect(fixture.onScreen).toHaveBeenLastCalledWith("https://screen.example.test");
fixture.controller.dispose();
});
it("re-reads the screen when the status that shapes it changes", async () => {
vi.useFakeTimers();
const fixture = setup();
fixture.controller.start();
await vi.advanceTimersByTimeAsync(0);
fixture.readStatus.mockResolvedValue(controlled);
fixture.readScreen.mockResolvedValue("https://control.example.test");
await vi.advanceTimersByTimeAsync(2000);
expect(fixture.readScreen).toHaveBeenCalledTimes(2);
expect(fixture.onScreen).toHaveBeenLastCalledWith("https://control.example.test");
await vi.advanceTimersByTimeAsync(4000);
expect(fixture.readScreen).toHaveBeenCalledTimes(2);
fixture.controller.dispose();
});
it("re-reads on the next poll after the viewer invalidates the screen", async () => {
vi.useFakeTimers();
const fixture = setup();
fixture.controller.start();
await vi.advanceTimersByTimeAsync(2000);
expect(fixture.readScreen).toHaveBeenCalledTimes(1);
fixture.controller.invalidateScreen();
await vi.advanceTimersByTimeAsync(2000);
expect(fixture.readScreen).toHaveBeenCalledTimes(2);
fixture.controller.dispose();
});
it("renews the screen URL before the capability expires", async () => {
vi.useFakeTimers();
const fixture = setup();
fixture.controller.start();
await vi.advanceTimersByTimeAsync(SCREEN_URL_RENEW_MS - 2000);
expect(fixture.readScreen).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(4000);
expect(fixture.readScreen).toHaveBeenCalledTimes(2);
fixture.controller.dispose();
});
it("always reads the screen on an explicit refresh", async () => {
vi.useFakeTimers();
const fixture = setup();
fixture.controller.start();
await vi.advanceTimersByTimeAsync(0);
const action = fixture.controller.beginAction();
await action.refresh();
action.finish();
expect(fixture.readScreen).toHaveBeenCalledTimes(2);
fixture.controller.dispose();
});
});