Files
Jyotisha/frontend/tests/home-first-load-20260930.test.tsx
T
Jesse_ChenandClaude Opus 5.5 c2265dabb2 fix(home): early boot reads load as an async data: script so stylesheets no longer hold them back (BUG-1127)
An inline classic script after the stylesheets waited for them to download;
on staging the reads left at 2.4-3.1 s. Measurement script gains
--css-delay and --ua iphone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
2026-10-01 01:36:35 +08:00

493 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import vm from "node:vm";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import * as acorn from "acorn";
import { bindCurrentSubjectAccount, currentSubjectStorageKey, LAST_SUBJECT_ACCOUNT_KEY, readCurrentSubjectId, resetSubjectCatalogForTests } from "../src/lib/current-subject.ts";
import { BOOT_ACCOUNT_PATH, BOOT_CHART_PROFILES_PATH, BOOT_MODELS_PATH, BOOT_READ_INIT, BOOT_READ_MAX_AGE_MS, BOOT_READS_GLOBAL, bootEarlyReadsScript, bootEarlyReadsScriptSrc, bootSessionsPath, clearBootReads, takeBootRead, takeBootSessionsRead, withBootFallback } from "../src/lib/boot-early-reads.ts";
import { chinaLocations } from "../src/data/china-locations.ts";
import { ensureChinaLocations, loadedChinaCountry, profileNeedsChinaLocations, resetChinaLocationsForTests } from "../src/lib/china-locations-client.ts";
import { readProfile, selectedBirthPlace } from "../src/lib/home-profile.ts";
import { HomeOnboardingShellSlot } from "../src/components/home-onboarding-shell-slot.tsx";
import type { BirthTimeGuidedController } from "../src/hooks/use-birth-time-guided-journey.ts";
import { loadHomeOnboardingShell } from "../src/lib/home-onboarding-shell-loader.ts";
import { emptyProfile, presetOnboardingMessage, previewModelCatalog, type Account } from "../src/lib/home-types.ts";
import { resetRectificationEntrySummaryReadForTests, startRectificationEntrySummaryRead, takeRectificationEntrySummary } from "../src/lib/rectification-entry-summary-read.ts";
import { runHomeBootstrap, type HomeBootstrapDeps } from "../src/lib/home-bootstrap-run.ts";
import { loadSessionList, type SessionListBoot } from "../src/lib/session-list-context.tsx";
import { installWindow } from "./home-bootstrap-fake-timers.ts";
// TASK-home-first-load-20260930 T1 (BUG-1127): the cold boot reads the account,
// the people list and the remembered person's sessions in one round. Synthetic
// data only.
Object.assign(globalThis, { React });
const ACCOUNT_ID = "11111111-1111-4111-8111-111111111127";
const OTHER_ID = "33333333-3333-4333-8333-333333333127";
const account: Account = {
user: { id: ACCOUNT_ID, email: null },
profile: { ...emptyProfile, name: "Synthetic" },
avatar: null, credits: 1, isAdmin: false, adminUrl: null,
rectificationPriceCredits: 0, activeSubscription: null,
hasConfirmedBirthTime: false, hasUsableBirthTime: false,
};
const otherProfile = { ...emptyProfile, name: "Synthetic other", chartRelationship: "friend" };
function sessionRow(id: string, subject: string) {
return {
id, title: `Synthetic ${subject}`, theme: "general", modelId: "test", messages: [],
createdAt: 1, updatedAt: 1, sessionType: "consultation", rectificationCaseId: null,
chartProfileId: subject === "self" ? null : subject, chartProfileName: null,
chartProfileRole: subject === "self" ? "self" : "other", pinned: false, archivedAt: null,
};
}
type Pending = { url: string; resolve: (payload: unknown, status?: number) => void };
function installStorage(values: Record<string, string>) {
const data = new Map(Object.entries(values));
const original = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (key: string) => data.get(key) ?? null,
setItem: (key: string, value: string) => { data.set(key, String(value)); },
removeItem: (key: string) => { data.delete(key); },
clear: () => data.clear(),
key: (index: number) => Array.from(data.keys())[index] ?? null,
get length() { return data.size; },
} satisfies Storage,
});
return () => {
if (original) Object.defineProperty(globalThis, "localStorage", original);
else Reflect.deleteProperty(globalThis, "localStorage");
};
}
/** Every request waits until the test answers it, so "sent in the same round" is observable. */
function installDeferredFetch() {
const pending: Pending[] = [];
const original = globalThis.fetch;
globalThis.fetch = ((input: RequestInfo | URL) => new Promise<Response>((resolve) => {
pending.push({ url: String(input), resolve: (payload, status = 200) => resolve(new Response(JSON.stringify(payload), { status })) });
})) as typeof fetch;
return { pending, restore: () => { globalThis.fetch = original; } };
}
async function flush() {
for (let i = 0; i < 20; i += 1) await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
function answer(pending: Pending[], match: (url: string) => boolean, payload: unknown, status = 200) {
const index = pending.findIndex((request) => match(request.url));
assert.notEqual(index, -1, "request not sent");
const [request] = pending.splice(index, 1);
request.resolve(payload, status);
}
async function withBoot(storage: Record<string, string>, run: (net: ReturnType<typeof installDeferredFetch>) => Promise<void>) {
resetSubjectCatalogForTests();
const restoreStorage = installStorage(storage);
const net = installDeferredFetch();
try {
// The module reads the remembered person when the tab starts; tests replay that.
const remembered = storage[LAST_SUBJECT_ACCOUNT_KEY];
if (remembered) bindCurrentSubjectAccount(remembered, null);
await run(net);
} finally {
net.restore();
restoreStorage();
resetSubjectCatalogForTests();
}
}
test("cold boot sends account, people list and sessions in the same round (BUG-1127)", async () => withBoot({}, async ({ pending }) => {
const boot = loadSessionList(new AbortController().signal);
await flush();
// Nothing has been answered yet, and all three reads are already out.
const sent = pending.map((request) => request.url.split("?")[0]).sort();
assert.deepEqual(sent, ["/api/account", "/api/chart-profiles", "/api/sessions"]);
assert.match(pending.find((request) => request.url.startsWith("/api/sessions"))!.url, /subject=self/);
answer(pending, (url) => url.startsWith("/api/sessions"), { sessions: [sessionRow("44444444-4444-4444-8444-444444444127", "self")], nextCursor: null });
answer(pending, (url) => url === "/api/chart-profiles", { profiles: [] });
answer(pending, (url) => url === "/api/account", account);
const result = await boot;
assert.equal(result.subjectId, "self");
assert.equal(result.account?.user.id, ACCOUNT_ID);
assert.deepEqual(result.sessions.map((session) => session.id), ["44444444-4444-4444-8444-444444444127"]);
assert.equal(pending.length, 0, "no second sessions read when the guess held");
}));
test("a remembered person that is no longer in the people list: the guessed list is dropped, self is read (red line 3)", async () => withBoot({
[LAST_SUBJECT_ACCOUNT_KEY]: ACCOUNT_ID,
[currentSubjectStorageKey(ACCOUNT_ID)]: OTHER_ID,
}, async ({ pending }) => {
assert.equal(readCurrentSubjectId(), OTHER_ID);
const boot = loadSessionList(new AbortController().signal);
await flush();
const guessed = pending.find((request) => request.url.startsWith("/api/sessions"))!;
assert.match(guessed.url, new RegExp(`subject=${OTHER_ID}`));
// The guessed person's list arrives first; it must never become the boot.
answer(pending, (url) => url === guessed.url, { sessions: [sessionRow("55555555-5555-4555-8555-555555555127", OTHER_ID)], nextCursor: null });
answer(pending, (url) => url === "/api/account", account);
answer(pending, (url) => url === "/api/chart-profiles", { profiles: [] });
await flush();
assert.equal(readCurrentSubjectId(), "self");
answer(pending, (url) => url.startsWith("/api/sessions") && url.includes("subject=self"), { sessions: [sessionRow("66666666-6666-4666-8666-666666666127", "self")], nextCursor: null });
const result = await boot;
assert.equal(result.subjectId, "self");
assert.deepEqual(result.sessions.map((session) => session.id), ["66666666-6666-4666-8666-666666666127"]);
}));
test("a remembered person still in the people list keeps the single-round read", async () => withBoot({
[LAST_SUBJECT_ACCOUNT_KEY]: ACCOUNT_ID,
[currentSubjectStorageKey(ACCOUNT_ID)]: OTHER_ID,
}, async ({ pending }) => {
const boot = loadSessionList(new AbortController().signal);
await flush();
answer(pending, (url) => url === "/api/account", account);
answer(pending, (url) => url === "/api/chart-profiles", { profiles: [{ id: OTHER_ID, role: "other", profile: otherProfile, updated_at: "2026-09-30T00:00:00Z" }] });
answer(pending, (url) => url.startsWith("/api/sessions") && url.includes(`subject=${OTHER_ID}`), { sessions: [sessionRow("77777777-7777-4777-8777-777777777127", OTHER_ID)], nextCursor: null });
const result = await boot;
assert.equal(result.subjectId, OTHER_ID);
assert.deepEqual(result.sessions.map((session) => session.id), ["77777777-7777-4777-8777-777777777127"]);
assert.equal(pending.length, 0);
}));
test("another account on this device: the previous account's person and people list are not reused (BUG-1040)", async () => withBoot({
[LAST_SUBJECT_ACCOUNT_KEY]: "99999999-9999-4999-8999-999999999127",
[currentSubjectStorageKey("99999999-9999-4999-8999-999999999127")]: OTHER_ID,
}, async ({ pending }) => {
const boot = loadSessionList(new AbortController().signal);
await flush();
const guessed = pending.find((request) => request.url.startsWith("/api/sessions"))!;
assert.match(guessed.url, new RegExp(`subject=${OTHER_ID}`), "the guess follows the previous account's choice");
answer(pending, (url) => url === "/api/account", account);
// The early people list was read before the account was known: it is dropped and read again.
answer(pending, (url) => url === "/api/chart-profiles", { profiles: [{ id: OTHER_ID, role: "other", profile: otherProfile, updated_at: "2026-09-30T00:00:00Z" }] });
answer(pending, (url) => url === guessed.url, { sessions: [sessionRow("55555555-5555-4555-8555-555555555127", OTHER_ID)], nextCursor: null });
await flush();
answer(pending, (url) => url === "/api/chart-profiles", { profiles: [] });
await flush();
answer(pending, (url) => url.startsWith("/api/sessions") && url.includes("subject=self"), { sessions: [sessionRow("66666666-6666-4666-8666-666666666127", "self")], nextCursor: null });
const result = await boot;
assert.equal(result.account?.user.id, ACCOUNT_ID);
assert.equal(result.subjectId, "self");
assert.deepEqual(result.sessions.map((session) => session.id), ["66666666-6666-4666-8666-666666666127"]);
}));
test("a signed-out cold boot still reports signed out when every read went out at once", async () => withBoot({}, async ({ pending }) => {
const boot = loadSessionList(new AbortController().signal);
await flush();
answer(pending, (url) => url === "/api/account", {}, 401);
answer(pending, (url) => url === "/api/chart-profiles", {}, 401);
answer(pending, (url) => url.startsWith("/api/sessions"), {}, 401);
const result = await boot;
assert.equal(result.signedOut, true);
assert.deepEqual(result.sessions, []);
}));
test("the entry-card summary started with the account reads is taken once, then read fresh (BUG-1127)", async () => {
resetRectificationEntrySummaryReadForTests();
let reads = 0;
const summary = { status: "none" } as never;
startRectificationEntrySummaryRead(async () => { reads += 1; return summary; });
assert.equal(reads, 1, "sent with the account round, not after the session list");
assert.equal(await takeRectificationEntrySummary(async () => { reads += 1; return null; }), summary);
assert.equal(reads, 1, "the started read is reused");
assert.equal(await takeRectificationEntrySummary(async () => { reads += 1; return null; }), null);
assert.equal(reads, 2, "a second take reads fresh");
startRectificationEntrySummaryRead(async () => { throw new Error("down"); });
assert.equal(await takeRectificationEntrySummary(), null, "a failed read keeps the default card labels");
resetRectificationEntrySummaryReadForTests();
});
// ---------------------------------------------------------------------------
// T8 (BUG-1127): reads sent by the HTML before the bundle runs.
// ---------------------------------------------------------------------------
type BootWindow = { location: { pathname: string }; fetch: (path: string, init: RequestInit) => Promise<Response>; Promise: PromiseConstructor; localStorage: Pick<Storage, "getItem">; [key: string]: unknown };
function runBootScript(pathname: string, storage: Record<string, string> = {}) {
const sent: { path: string; init: RequestInit }[] = [];
const window: BootWindow = {
location: { pathname },
fetch: (path, init) => { sent.push({ path, init }); return Promise.resolve(new Response("{}")); },
Promise,
localStorage: { getItem: (key: string) => storage[key] ?? null },
};
vm.runInNewContext(bootEarlyReadsScript(), { window, Date, encodeURIComponent });
return { sent, window };
}
test("the early-read script is classic ES5 and only acts on / (T8)", () => {
assert.doesNotThrow(() => acorn.parse(bootEarlyReadsScript(), { ecmaVersion: 5, sourceType: "script" }));
for (const path of ["/chart", "/reports", "/login", "/people", "/admin"]) {
assert.deepEqual(runBootScript(path).sent, [], `${path} sends nothing`);
}
const layout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8");
// 原值:assert.match(layout, /__html: bootEarlyReadsScript\(\)/)(内联经典脚本)。
// 新值:<script async src={bootEarlyReadsScriptSrc()} />,src 是同一段脚本的 base64 data: URL。
// 原因:TASK-home-first-load-fix-20261001——内联脚本排在样式表之后,要等样式表下完才执行,线上早发推迟 2.4~3.1 s。
assert.match(layout, /<script async src=\{bootEarlyReadsScriptSrc\(\)\} \/>/);
const src = bootEarlyReadsScriptSrc();
assert.match(src, /^data:text\/javascript;base64,[A-Za-z0-9+/=]+$/);
assert.equal(Buffer.from(src.slice(src.indexOf(",") + 1), "base64").toString("utf8"), bootEarlyReadsScript(), "the data URL carries exactly the ES5 script");
});
test("the early reads are the normal requests, byte for byte (T8)", () => {
const { sent, window } = runBootScript("/", {
[LAST_SUBJECT_ACCOUNT_KEY]: ACCOUNT_ID,
[currentSubjectStorageKey(ACCOUNT_ID)]: OTHER_ID,
});
assert.deepEqual(sent.map((request) => request.path).sort(), [BOOT_ACCOUNT_PATH, BOOT_CHART_PROFILES_PATH, BOOT_MODELS_PATH, bootSessionsPath(OTHER_ID)].sort());
for (const request of sent) assert.deepEqual({ ...request.init }, { ...BOOT_READ_INIT });
const reads = window[BOOT_READS_GLOBAL] as { sessionsSubject: string };
assert.equal(reads.sessionsSubject, OTHER_ID);
// No remembered account: the sessions read is for 本人.
assert.ok(runBootScript("/").sent.some((request) => request.path === bootSessionsPath("self")));
});
function withEarlyReads(reads: Record<string, unknown>, run: () => Promise<void>) {
const host = globalThis as unknown as { window?: unknown };
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
host.window = globalThis;
(globalThis as Record<string, unknown>)[BOOT_READS_GLOBAL] = { startedAt: Date.now(), ...reads };
return run().finally(() => {
clearBootReads();
if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow);
else Reflect.deleteProperty(globalThis, "window");
});
}
const json = (payload: unknown, status = 200) => Promise.resolve(new Response(JSON.stringify(payload), { status }));
test("the boot takes each early read once, and the rest of the tab reads normally (T8)", async () => withEarlyReads({ account: json(account), models: json({}) }, async () => {
assert.ok(takeBootRead("account"));
assert.equal(takeBootRead("account"), null, "consumed once");
assert.ok(takeBootRead("models"));
assert.equal(takeBootRead("chartProfiles"), null, "never sent here");
}));
test("an early sessions read for another person is dropped, not returned (T8)", async () => withEarlyReads({ sessions: json({ sessions: [] }), sessionsSubject: OTHER_ID }, async () => {
assert.equal(takeBootSessionsRead("self"), null);
assert.equal(takeBootSessionsRead(OTHER_ID), null, "dropped for good after the mismatch");
}));
test("an early read older than the budget is not trusted (T8)", async () => withEarlyReads({ account: json(account) }, async () => {
(globalThis as unknown as Record<string, { startedAt: number }>)[BOOT_READS_GLOBAL].startedAt = Date.now() - BOOT_READ_MAX_AGE_MS - 1;
assert.equal(takeBootRead("account"), null);
}));
test("a failed early read falls back to the normal request (T8)", async () => {
let fresh = 0;
const response = await withBootFallback(Promise.reject(new TypeError("network")), async () => { fresh += 1; return new Response("{}"); });
assert.equal(fresh, 1);
assert.equal(response.status, 200);
assert.equal(await withBootFallback(null, async () => { fresh += 1; return new Response("{}", { status: 202 }); }).then((r) => r.status), 202);
});
test("cold boot on / uses the HTML's reads and sends nothing itself (T8)", async () => withBoot({}, async ({ pending }) => withEarlyReads({
account: json(account),
chartProfiles: json({ profiles: [] }),
sessions: json({ sessions: [sessionRow("88888888-8888-4888-8888-888888888127", "self")], nextCursor: null }),
sessionsSubject: "self",
}, async () => {
const result = await loadSessionList(new AbortController().signal);
assert.equal(pending.length, 0, "no request left the bundle");
assert.equal(result.account?.user.id, ACCOUNT_ID);
assert.deepEqual(result.sessions.map((session) => session.id), ["88888888-8888-4888-8888-888888888127"]);
})));
test("an early 401 still signs out, exactly like the normal read (T8)", async () => withBoot({}, async ({ pending }) => withEarlyReads({
account: json({}, 401),
chartProfiles: json({}, 401),
sessions: json({}, 401),
sessionsSubject: "self",
}, async () => {
const result = await loadSessionList(new AbortController().signal);
assert.equal(result.signedOut, true);
assert.equal(pending.length, 0);
})));
test("an early read that failed on the network: the boot reads again and still lands (T8)", async () => withBoot({}, async ({ pending }) => withEarlyReads({
account: Promise.reject(new TypeError("network")),
chartProfiles: json({ profiles: [] }),
sessions: json({ sessions: [], nextCursor: null }),
sessionsSubject: "self",
}, async () => {
const boot = loadSessionList(new AbortController().signal);
await flush();
answer(pending, (url) => url === "/api/account", account);
const result = await boot;
assert.equal(result.account?.user.id, ACCOUNT_ID);
})));
test("another account than the one this device remembered: the early sessions list is discarded (T8, red line 8)", async () => withBoot({
[LAST_SUBJECT_ACCOUNT_KEY]: "99999999-9999-4999-8999-999999999127",
}, async ({ pending }) => withEarlyReads({
account: json(account),
chartProfiles: json({ profiles: [] }),
sessions: json({ sessions: [sessionRow("55555555-5555-4555-8555-555555555127", "self")], nextCursor: null }),
sessionsSubject: "self",
}, async () => {
const boot = loadSessionList(new AbortController().signal);
await flush();
// The account changed, so the people list is read again and so is the sessions list.
answer(pending, (url) => url === "/api/chart-profiles", { profiles: [] });
await flush();
answer(pending, (url) => url.startsWith("/api/sessions") && url.includes("subject=self"), { sessions: [sessionRow("66666666-6666-4666-8666-666666666127", "self")], nextCursor: null });
const result = await boot;
assert.deepEqual(result.sessions.map((session) => session.id), ["66666666-6666-4666-8666-666666666127"]);
})));
// ---------------------------------------------------------------------------
// T7 (BUG-1130): the China city table leaves the first-screen bundle.
// ---------------------------------------------------------------------------
const legacyPlaces = [
{ provinceCode: "130000", cityCode: "130100", districtCode: "130102" },
{ provinceCode: "110000", cityCode: "110000-city", districtCode: "110101" },
];
/** The pre-T7 lookup, written out against the JSON directly. */
function legacyPlace(codes: { provinceCode: string; cityCode: string; districtCode: string }) {
const country = chinaLocations.country;
const province = country.provinces.find((item) => item.code === codes.provinceCode)!;
const city = province.cities.find((item) => item.code === codes.cityCode)!;
const district = city.districts.find((item) => item.code === codes.districtCode);
const location = district ?? city;
return {
label: [country.name, province.name, city.name, district?.name].filter((name, index, names) => Boolean(name) && names.indexOf(name) === index).join(" · "),
lat: location.center[1], lon: location.center[0], tz: country.timezone, timezoneId: "Asia/Shanghai",
};
}
test("an old profile with only province / city codes resolves exactly as before once the table is loaded (T7)", async () => {
resetChinaLocationsForTests();
for (const codes of legacyPlaces) {
const profile = { ...emptyProfile, ...codes };
assert.equal(profileNeedsChinaLocations(profile), true);
assert.equal(selectedBirthPlace(profile), null, "not resolvable before the table is loaded");
}
await ensureChinaLocations();
for (const codes of legacyPlaces) {
assert.deepEqual(selectedBirthPlace({ ...emptyProfile, ...codes }), legacyPlace(codes));
}
// A profile saved with coordinates never asks for the table.
assert.equal(profileNeedsChinaLocations({ ...emptyProfile, provinceCode: "130000", birthPlaceLabel: "中国 · 河北省", latitude: 38, longitude: 114.5, timezoneId: "Asia/Shanghai", timezoneOffset: 8 }), false);
resetChinaLocationsForTests();
});
test("cold boot loads the table before the people list settles when the account profile is an old one (T7)", async () => withBoot({}, async ({ pending }) => {
resetChinaLocationsForTests();
const legacyAccount: Account = {
...account,
profile: { name: "Synthetic", birth_date: "1991-04-07", birth_time: "09:15", province_code: "130000", city_code: "130100", district_code: "130102" },
};
const readAccount = readProfile(legacyAccount.profile);
assert.equal(readAccount.provinceCode, "130000", "fixture uses the stored field names");
const boot = loadSessionList(new AbortController().signal);
await flush();
answer(pending, (url) => url === "/api/account", legacyAccount);
answer(pending, (url) => url === "/api/chart-profiles", { profiles: [] });
answer(pending, (url) => url.startsWith("/api/sessions"), { sessions: [], nextCursor: null });
await boot;
assert.ok(loadedChinaCountry(), "table loaded during boot, before anything reads the profile");
assert.deepEqual(selectedBirthPlace(readAccount), legacyPlace(legacyPlaces[0]));
resetChinaLocationsForTests();
}));
test("the home first screen no longer imports the city table by value (T7)", () => {
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
for (const file of ["../src/lib/home-types.ts", "../src/lib/home-profile.ts", "../src/lib/rectification-agentic/v9/confirmation-gate.ts"]) {
assert.doesNotMatch(read(file), /^import (?!type)[^;]*(data\/china-locations|rectification_sealed_holdout)/m, file);
}
});
test("an incomplete profile waits for the onboarding shell before reveal; a complete one does not load it (T7)", async () => {
installWindow();
const restoreStorage = installStorage({});
for (const complete of [false, true]) {
let release!: () => void;
const preload = new Promise<void>((resolve) => { release = resolve; });
let preloads = 0;
const phases: string[] = [];
const profile = complete
? { ...emptyProfile, name: "Synthetic", date: "1991-04-07", time: "09:15", birthTimeStatus: "confirmed", birthTimeSource: "remembered", birthPlaceLabel: "虚构市", latitude: 30.57, longitude: 104.07, timezoneId: "Asia/Shanghai", timezoneOffset: 8 }
: { ...emptyProfile, name: "Synthetic" };
const boot: SessionListBoot = { sessions: [], rawRows: [], cursor: null, account: { ...account, profile }, signedOut: false };
const storage = new Map<string, string>();
const noop = () => undefined;
const deps: HomeBootstrapDeps = {
sessionListReady: Promise.resolve(),
sessionListBoot: () => boot,
uiPreview: { current: false }, uiPreviewMode: { current: null },
consultationStatusMissingCount: { current: 0 },
isDevelopment: false,
readSearch: () => "",
storage: { getItem: (key: string) => storage.get(key) ?? null, removeItem: (key: string) => { storage.delete(key); } },
restoreConsultationRecovery: noop,
setComposerNotice: noop,
commit: {
setAccount: noop, setAccountError: (message) => { if (message) phases.push(`error:${message}`); }, setActiveSessionId: noop, setBirthTimeAssessmentPhase: noop,
setBirthTimeJourney: noop, setBootstrapPhase: (phase) => { phases.push(phase); }, setGuidedJourneyPreview: noop,
setHydrated: noop, setModelCatalog: noop, setOnboardingStep: noop, setProfile: noop, setProfileDraft: noop,
setSessions: noop, setSessionsCursor: noop, setStartGreeting: noop,
},
io: {
fetchModelCatalog: async () => previewModelCatalog,
fetchActiveConsultationStatus: async () => null,
startEntrySummaryRead: noop,
preloadOnboardingShell: () => { preloads += 1; return preload; },
resolveLookupBootstrap: async (input) => ({ selection: { sessionId: input.defaultSessionId, urlAction: "none", missing: false }, sessions: input.sessions, notice: null }),
},
};
const run = runHomeBootstrap(deps, new AbortController().signal);
await flush();
if (complete) {
assert.equal(preloads, 0, "a complete profile never loads the onboarding shell");
} else {
assert.equal(preloads, 1);
assert.deepEqual(phases, [], "no reveal step before the shell is there");
release();
}
await run;
assert.deepEqual(phases, ["prepare"]);
}
restoreStorage();
});
test("a preloaded onboarding shell renders in the reveal frame, not behind an empty fallback (T7)", async () => {
await loadHomeOnboardingShell();
const markup = renderToStaticMarkup(
<HomeOnboardingShellSlot
profileComplete={false}
profileDraft={emptyProfile}
setProfileDraft={() => undefined}
onboardingStep="name"
presetMessageLength={presetOnboardingMessage.length}
currentOnboardingMessage={presetOnboardingMessage}
onboardingJustCompleted={false}
onboardingCardReady={false}
presetMessageFinished
birthTimeJourney={null}
birthTimeAssessmentPhase={null}
birthTimeError=""
birthTimeGuided={{} as BirthTimeGuidedController}
profileSaving={false}
saveOnboardingBirth={() => undefined}
saveOnboardingPlace={() => undefined}
retryBirthTimeAssessment={() => undefined}
/>,
);
assert.match(markup, /你好,我是 Jyotisha/);
});