From 8b8633783dea9564e56e1e8850bf533510eec6d0 Mon Sep 17 00:00:00 2001 From: 0xSolace Date: Thu, 23 Jul 2026 01:15:57 +0000 Subject: [PATCH] test(ui): pin non-blocking startup hydration invariant (first-5 strike F2 port) Ports the regression contract from milady-ai/milady#2209 to the demo repo (elizaOS/eliza). The milady code fix (defer wallet, background VRM/world prefetch, parallelize config reads, void autonomy replay) is UNNECESSARY here: eliza's runHydrating already runs all shell-decoration work (getWalletAddresses / getConfig / getStreamSettings / fetchAutonomyReplay) inside decorateShellAfterReady(), fired AFTER dispatch(HYDRATION_COMPLETE), via the #15178 lineage. So the app-core serial-await structure milady#2209 targeted does not exist on this path. This test pins that invariant so a future refactor cannot silently re-await any of those slow calls on the ready critical path: every decoration dependency (wallet, config, stream settings, autonomy replay) hangs FOREVER and the test proves HYDRATION_COMPLETE still fires (<3s), the wallet fetch is kicked off but never blocks (its setter never runs before completion), and autonomy replay is not awaited. Evidence that the canary's 13.6s post-login stall is NOT in the hydrate window: a live staging startup-trace (projects/eliza-fleet/F2-PORT-2026-07-22.md) shows coordinator:hydrating -> coordinator:ready is ~4s and dominated by the post-ready agent-base fetch waterfall + lazy chunks; the real 12.4s stall is in first-run-required -> starting-runtime (login/session establish + agent-base resolution), a separate phase this PR documents for follow-up lanes. Co-authored-by: shadow --- .../startup-phase-hydrate.nonblocking.test.ts | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 packages/ui/src/state/startup-phase-hydrate.nonblocking.test.ts diff --git a/packages/ui/src/state/startup-phase-hydrate.nonblocking.test.ts b/packages/ui/src/state/startup-phase-hydrate.nonblocking.test.ts new file mode 100644 index 0000000000000..3c85143cee7b9 --- /dev/null +++ b/packages/ui/src/state/startup-phase-hydrate.nonblocking.test.ts @@ -0,0 +1,125 @@ +// @vitest-environment jsdom + +/** + * Regression: runHydrating() must reach HYDRATION_COMPLETE without blocking on + * slow/hanging shell-decoration work (first-5 strike F2, ported from + * milady-ai/milady#2209). + * + * On cloud containers getWalletAddresses() was measured at 1.3–12.4 s + * (staging median 7.8 s, probe evidence in + * projects/eliza-fleet/F5-FIRSTLOAD-2026-07-22.md). eliza's runHydrating + * already runs wallet/avatar/autonomy-replay AFTER dispatching + * HYDRATION_COMPLETE (decorateShellAfterReady, #15178) — this test pins that + * invariant so a future refactor cannot quietly re-await any of them on the + * ready critical path. Every decoration dependency hangs FOREVER here; the + * dashboard must still become interactive. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { StartupEvent } from "./startup-coordinator"; + +const hangForever = () => new Promise(() => {}); + +const clientMock = vi.hoisted(() => { + const hang = () => new Promise(() => {}); + return { + connectWs: vi.fn(), + getBaseUrl: vi.fn(() => "http://127.0.0.1:31337"), + // The hangers — must NOT block hydration: + getWalletAddresses: vi.fn(() => hang()), + getConfig: vi.fn(() => hang()), + getStreamSettings: vi.fn(() => hang()), + }; +}); + +vi.mock("../api", () => ({ client: clientMock })); +vi.mock("../components/apps/load-apps-catalog", () => ({ + prefetchAppsCatalog: vi.fn(async () => undefined), +})); + +import { type HydratingDeps, runHydrating } from "./startup-phase-hydrate"; + +function makeDeps(): HydratingDeps { + return { + setStartupError: vi.fn(), + setFirstRunLoading: vi.fn(), + hydrateInitialConversationState: vi.fn(async () => null), + requestGreetingWhenRunningRef: { current: vi.fn(async () => undefined) }, + loadWorkbench: vi.fn(async () => {}), + loadPlugins: vi.fn(async () => {}), + loadSkills: vi.fn(async () => {}), + loadCharacter: vi.fn(async () => {}), + loadWalletConfig: vi.fn(async () => {}), + loadInventory: vi.fn(async () => {}), + loadUpdateStatus: vi.fn(async () => {}), + checkExtensionStatus: vi.fn(async () => {}), + pollCloudCredits: vi.fn(), + // Autonomy replay hangs forever — must NOT block hydration. + fetchAutonomyReplay: vi.fn(() => hangForever()), + setSelectedVrmIndex: vi.fn(), + setWalletAddresses: vi.fn(), + setTab: vi.fn(), + setTabRaw: vi.fn(), + initialTabSetRef: { current: false }, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + window.history.replaceState(null, "", "/"); +}); + +describe("runHydrating — non-blocking first-load (F2)", () => { + it("dispatches HYDRATION_COMPLETE even when wallet, config, stream settings, and autonomy replay all hang forever", async () => { + const deps = makeDeps(); + const events: StartupEvent[] = []; + + // Must resolve quickly. If any hanging decoration is awaited before + // HYDRATION_COMPLETE, this rejects on the timeout instead. + await Promise.race([ + runHydrating(deps, (event) => events.push(event), { current: false }), + new Promise((_resolve, reject) => + setTimeout( + () => + reject( + new Error( + "runHydrating blocked on non-critical shell decoration", + ), + ), + 3_000, + ), + ), + ]); + + expect(events).toContainEqual({ type: "HYDRATION_COMPLETE" }); + }); + + it("defers the wallet fetch off the ready critical path but still kicks it off", async () => { + const deps = makeDeps(); + const events: StartupEvent[] = []; + + await runHydrating(deps, (event) => events.push(event), { + current: false, + }); + + expect(events).toContainEqual({ type: "HYDRATION_COMPLETE" }); + // The fetch was started (background decoration)… + expect(clientMock.getWalletAddresses).toHaveBeenCalledTimes(1); + // …but since it never resolves, the setter must not have run by the time + // hydration completed — proving the await is NOT on the critical path. + expect(deps.setWalletAddresses).not.toHaveBeenCalled(); + }); + + it("does not await the autonomy replay before completing hydration", async () => { + const deps = makeDeps(); + const events: StartupEvent[] = []; + + await runHydrating(deps, (event) => events.push(event), { + current: false, + }); + + expect(events).toContainEqual({ type: "HYDRATION_COMPLETE" }); + expect(deps.fetchAutonomyReplay).toHaveBeenCalledTimes(1); + }); +});