The China city table loads on demand; old code-only profiles load it before the people list settles, so they resolve exactly as before. The gate keeps the six holdout fields it reads, pinned to the JSON by a test. The onboarding shell is preloaded before reveal only when the profile is incomplete. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
373 lines
14 KiB
TypeScript
373 lines
14 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import test from "node:test";
|
||
|
||
import { ConsultationStatusError, createSession, LoginRedirectError } from "../src/lib/home-cloud-sync.ts";
|
||
import {
|
||
HOME_BOOTSTRAP_TIMEOUT_MESSAGE,
|
||
runHomeBootstrap,
|
||
type HomeBootstrapDeps,
|
||
type HomeBootstrapIo,
|
||
} from "../src/lib/home-bootstrap-run.ts";
|
||
import { HOME_BOOTSTRAP_SLOW_MS } from "../src/lib/home-bootstrap.ts";
|
||
import {
|
||
emptyProfile,
|
||
pendingConsultationStorageKey,
|
||
previewModelCatalog,
|
||
type Account,
|
||
type ChatSession,
|
||
type ConsultationStatus,
|
||
} from "../src/lib/home-types.ts";
|
||
import type { SessionListBoot } from "../src/lib/session-list-context.tsx";
|
||
import { bindCurrentSubjectAccount, readCurrentSubjectId, resetSubjectCatalogForTests, setCurrentSubject } from "../src/lib/current-subject.ts";
|
||
import { installWindow, withFakeTimers } from "./home-bootstrap-fake-timers.ts";
|
||
|
||
function installBrowserHosts() {
|
||
installWindow();
|
||
const host = globalThis as { localStorage?: Storage };
|
||
if (!host.localStorage) {
|
||
const data = new Map<string, string>();
|
||
host.localStorage = {
|
||
getItem: (key) => data.get(key) ?? null,
|
||
setItem: (key, value) => { data.set(key, String(value)); },
|
||
removeItem: (key) => { data.delete(key); },
|
||
clear: () => data.clear(),
|
||
key: (index) => Array.from(data.keys())[index] ?? null,
|
||
get length() { return data.size; },
|
||
};
|
||
}
|
||
}
|
||
|
||
function memoryStorage() {
|
||
const data = new Map<string, string>();
|
||
return {
|
||
getItem: (key: string) => data.get(key) ?? null,
|
||
removeItem: (key: string) => { data.delete(key); },
|
||
setItem: (key: string, value: string) => { data.set(key, value); },
|
||
};
|
||
}
|
||
|
||
function account(): Account {
|
||
return {
|
||
user: { id: "account-1", email: "reader@example.com" },
|
||
avatar: null,
|
||
credits: 3,
|
||
isAdmin: false,
|
||
adminUrl: null,
|
||
rectificationPriceCredits: 1,
|
||
activeSubscription: null,
|
||
hasConfirmedBirthTime: false,
|
||
hasUsableBirthTime: false,
|
||
profile: emptyProfile,
|
||
};
|
||
}
|
||
|
||
function createHarness(options: {
|
||
sessions: ChatSession[];
|
||
signedOut?: boolean;
|
||
io?: Partial<HomeBootstrapIo>;
|
||
restoreConsultationRecovery?: HomeBootstrapIo["restoreConsultationRecovery"];
|
||
} ) {
|
||
installBrowserHosts();
|
||
const state: {
|
||
account: Account | null;
|
||
error: string;
|
||
hydrated: boolean;
|
||
phase: string;
|
||
sessions: ChatSession[];
|
||
active: string;
|
||
notice: string;
|
||
profileSet: boolean;
|
||
catalogSet: boolean;
|
||
} = {
|
||
account: null,
|
||
error: "",
|
||
hydrated: false,
|
||
phase: "",
|
||
sessions: [] as ChatSession[],
|
||
active: "",
|
||
notice: "",
|
||
profileSet: false,
|
||
catalogSet: false,
|
||
};
|
||
const commits: string[] = [];
|
||
const storage = memoryStorage();
|
||
const boot: SessionListBoot = {
|
||
sessions: options.sessions,
|
||
rawRows: [],
|
||
cursor: "cursor-1",
|
||
account: options.signedOut ? null : account(),
|
||
signedOut: options.signedOut ?? false,
|
||
};
|
||
const note = (name: string) => { commits.push(name); };
|
||
const deps: HomeBootstrapDeps = {
|
||
sessionListReady: Promise.resolve(),
|
||
sessionListBoot: () => boot,
|
||
uiPreview: { current: false },
|
||
uiPreviewMode: { current: null },
|
||
consultationStatusMissingCount: { current: 0 },
|
||
isDevelopment: false,
|
||
readSearch: () => "",
|
||
storage,
|
||
restoreConsultationRecovery: options.restoreConsultationRecovery ?? (() => undefined),
|
||
setComposerNotice: (notice) => { state.notice = notice; note("notice"); },
|
||
commit: {
|
||
setAccount: (value) => { state.account = value; note("account"); },
|
||
setAccountError: (value) => { state.error = value; note("error"); },
|
||
setActiveSessionId: (id) => { state.active = id; note("active"); },
|
||
setBirthTimeAssessmentPhase: () => note("assessment"),
|
||
setBirthTimeJourney: () => note("journey"),
|
||
setBootstrapPhase: (phase) => { state.phase = phase; note("phase"); },
|
||
setGuidedJourneyPreview: () => note("preview"),
|
||
setHydrated: (value) => { state.hydrated = value; note("hydrated"); },
|
||
setModelCatalog: () => { state.catalogSet = true; note("catalog"); },
|
||
setOnboardingStep: () => note("onboarding"),
|
||
setProfile: () => { state.profileSet = true; note("profile"); },
|
||
setProfileDraft: () => note("draft"),
|
||
setSessions: (sessions) => { state.sessions = sessions; note("sessions"); },
|
||
setSessionsCursor: () => note("cursor"),
|
||
setStartGreeting: () => note("greeting"),
|
||
},
|
||
io: {
|
||
fetchModelCatalog: async () => previewModelCatalog,
|
||
fetchActiveConsultationStatus: async () => null,
|
||
// 原值:夹具不提供 preloadOnboardingShell / startEntrySummaryRead(io 当时没有这两项)。
|
||
// 新值:两者为空操作;断言不变。
|
||
// 原因:TASK-home-first-load T7/T1 新增的 io 项默认会真的 import 引导页组件、发校正入口请求,
|
||
// 夹具的 waitUntil 只让 30 个微任务,真实动态 import 等不到;本组测的是启动时序,不是这两项。
|
||
preloadOnboardingShell: async () => undefined,
|
||
startEntrySummaryRead: () => undefined,
|
||
fetchConsultationStatus: async () => {
|
||
throw new Error("unexpected status read");
|
||
},
|
||
resolveLookupBootstrap: async (input) => ({
|
||
selection: {
|
||
sessionId: input.defaultSessionId,
|
||
urlAction: "none",
|
||
missing: false,
|
||
},
|
||
sessions: input.sessions,
|
||
notice: null,
|
||
}),
|
||
...options.io,
|
||
},
|
||
};
|
||
return { deps, state, commits, storage };
|
||
}
|
||
|
||
async function waitUntil(predicate: () => boolean) {
|
||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||
if (predicate()) return;
|
||
await Promise.resolve();
|
||
}
|
||
assert.equal(predicate(), true);
|
||
}
|
||
|
||
test("a successful bootstrap fills the home once, after every read has settled", async () => {
|
||
const session = createSession(previewModelCatalog.defaultModelId);
|
||
let lookupStarted = false;
|
||
let releaseLookup: () => void = () => undefined;
|
||
const lookupGate = new Promise<void>((resolve) => { releaseLookup = resolve; });
|
||
const { deps, state, commits } = createHarness({
|
||
sessions: [session],
|
||
io: {
|
||
resolveLookupBootstrap: async (input) => {
|
||
lookupStarted = true;
|
||
await lookupGate;
|
||
return {
|
||
selection: {
|
||
sessionId: input.defaultSessionId,
|
||
urlAction: "none",
|
||
missing: false,
|
||
},
|
||
sessions: input.sessions,
|
||
notice: null,
|
||
};
|
||
},
|
||
},
|
||
});
|
||
const pending = runHomeBootstrap(deps, new AbortController().signal);
|
||
await waitUntil(() => lookupStarted);
|
||
assert.equal(state.account === null, true);
|
||
assert.equal(state.sessions.length, 0);
|
||
assert.equal(state.phase, "");
|
||
assert.equal(state.hydrated, false);
|
||
releaseLookup();
|
||
await pending;
|
||
assert.equal(state.account?.user.id, "account-1");
|
||
assert.equal(state.sessions[0]?.id, session.id);
|
||
assert.equal(state.active, session.id);
|
||
assert.equal(state.profileSet, true);
|
||
assert.equal(state.catalogSet, true);
|
||
assert.equal(state.phase, "prepare");
|
||
assert.equal(state.hydrated, false);
|
||
assert.equal(state.error, "");
|
||
assert.ok(commits.indexOf("account") < commits.indexOf("phase"));
|
||
});
|
||
|
||
test("a model catalog failure stays on the fatal screen and does not half-reveal", async () => {
|
||
const { deps, state } = createHarness({
|
||
sessions: [createSession(previewModelCatalog.defaultModelId)],
|
||
io: {
|
||
fetchModelCatalog: async () => { throw new Error("模型列表读取失败"); },
|
||
},
|
||
});
|
||
await runHomeBootstrap(deps, new AbortController().signal);
|
||
assert.equal(state.account, null);
|
||
assert.equal(state.sessions.length, 0);
|
||
assert.equal(state.catalogSet, false);
|
||
assert.equal(state.phase, "");
|
||
assert.equal(state.hydrated, true);
|
||
assert.equal(state.error, "模型列表读取失败");
|
||
});
|
||
|
||
test("a stored pending reservation wins over the global active status", async () => {
|
||
const older = createSession(previewModelCatalog.defaultModelId);
|
||
const pendingSession = createSession(previewModelCatalog.defaultModelId);
|
||
const globalStatus: ConsultationStatus = {
|
||
sessionId: older.id,
|
||
requestId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||
status: "reserved",
|
||
};
|
||
const storedRequestId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||
let restored: { sessionId: string; requestId: string } | null = null;
|
||
const { deps, storage } = createHarness({
|
||
sessions: [older, pendingSession],
|
||
restoreConsultationRecovery: (session, requestId) => {
|
||
restored = { sessionId: session.id, requestId };
|
||
},
|
||
io: {
|
||
fetchActiveConsultationStatus: async () => globalStatus,
|
||
fetchConsultationStatus: async (sessionId, requestId) => ({
|
||
sessionId,
|
||
requestId,
|
||
status: "reserved",
|
||
}),
|
||
},
|
||
});
|
||
storage.setItem(pendingConsultationStorageKey, JSON.stringify({
|
||
sessionId: pendingSession.id,
|
||
requestId: storedRequestId,
|
||
question: "稍后继续",
|
||
}));
|
||
await runHomeBootstrap(deps, new AbortController().signal);
|
||
assert.deepEqual(restored, { sessionId: pendingSession.id, requestId: storedRequestId });
|
||
assert.equal(deps.consultationStatusMissingCount.current, 0);
|
||
});
|
||
|
||
test("a stored 404 still reserves that request and counts the first miss", async () => {
|
||
const session = createSession(previewModelCatalog.defaultModelId);
|
||
const requestId = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
|
||
let restored: string | null = null;
|
||
const { deps, storage } = createHarness({
|
||
sessions: [session],
|
||
restoreConsultationRecovery: (_session, restoredRequestId) => { restored = restoredRequestId; },
|
||
io: {
|
||
fetchActiveConsultationStatus: async () => null,
|
||
fetchConsultationStatus: async () => { throw new ConsultationStatusError(404, "missing"); },
|
||
},
|
||
});
|
||
storage.setItem(pendingConsultationStorageKey, JSON.stringify({
|
||
sessionId: session.id,
|
||
requestId,
|
||
question: "",
|
||
}));
|
||
await runHomeBootstrap(deps, new AbortController().signal);
|
||
assert.equal(deps.consultationStatusMissingCount.current, 1);
|
||
assert.equal(restored, requestId);
|
||
});
|
||
|
||
test("the slow-network timeout aborts the run and does not enter prepare", async () => {
|
||
installBrowserHosts();
|
||
await withFakeTimers(async (tick) => {
|
||
const { deps, state } = createHarness({
|
||
sessions: [createSession(previewModelCatalog.defaultModelId)],
|
||
io: {
|
||
fetchModelCatalog: (signal) => new Promise((_resolve, reject) => {
|
||
const fail = () => {
|
||
const error = new Error("Aborted");
|
||
error.name = "AbortError";
|
||
reject(error);
|
||
};
|
||
if (signal?.aborted) fail();
|
||
else signal?.addEventListener("abort", fail, { once: true });
|
||
}),
|
||
},
|
||
});
|
||
const pending = runHomeBootstrap(deps, new AbortController().signal);
|
||
await Promise.resolve();
|
||
tick(HOME_BOOTSTRAP_SLOW_MS);
|
||
await pending;
|
||
assert.equal(state.error, HOME_BOOTSTRAP_TIMEOUT_MESSAGE);
|
||
assert.equal(state.hydrated, true);
|
||
assert.equal(state.phase, "");
|
||
assert.equal(state.account, null);
|
||
});
|
||
});
|
||
|
||
test("a login redirect leaves the bootstrap timeout running", async () => {
|
||
installBrowserHosts();
|
||
await withFakeTimers(async (tick) => {
|
||
const { deps, state } = createHarness({
|
||
sessions: [],
|
||
signedOut: true,
|
||
io: {
|
||
redirectToLogin: () => { throw new LoginRedirectError(); },
|
||
},
|
||
});
|
||
await runHomeBootstrap(deps, new AbortController().signal);
|
||
assert.equal(state.error, "");
|
||
assert.equal(state.hydrated, false);
|
||
tick(HOME_BOOTSTRAP_SLOW_MS);
|
||
await Promise.resolve();
|
||
assert.equal(state.error, HOME_BOOTSTRAP_TIMEOUT_MESSAGE);
|
||
assert.equal(state.hydrated, true);
|
||
assert.equal(state.phase, "");
|
||
});
|
||
});
|
||
|
||
|
||
test("bootstrap waits for the current provider generation rather than an already settled initial gate", async () => {
|
||
const { deps, state } = createHarness({ sessions: [createSession(previewModelCatalog.defaultModelId)] });
|
||
let release!: () => void;
|
||
deps.waitForSessionList = () => new Promise(resolve => { release = resolve; });
|
||
const pending = runHomeBootstrap(deps, new AbortController().signal);
|
||
await waitUntil(() => Boolean(release));
|
||
assert.equal(state.hydrated, false);
|
||
release();
|
||
await pending;
|
||
assert.equal(state.phase, "prepare");
|
||
assert.equal(state.account?.user.id, "account-1");
|
||
assert.equal(state.error, "");
|
||
});
|
||
|
||
test("a catalog outage is a recoverable bootstrap error, never a login redirect", async () => {
|
||
let redirects = 0;
|
||
const { deps, state } = createHarness({ sessions: [], io: { redirectToLogin: () => { redirects += 1; throw new Error("unexpected login redirect"); } } });
|
||
deps.sessionListBoot = () => ({ sessions: [], rawRows: [], cursor: null, account: null, signedOut: false, error: "session_list_unavailable" });
|
||
await runHomeBootstrap(deps, new AbortController().signal);
|
||
assert.equal(redirects, 0);
|
||
assert.equal(state.hydrated, true);
|
||
assert.notEqual(state.error, "");
|
||
});
|
||
|
||
|
||
test("standard new=1 bootstrap creates a conversation bound to the selected person", async () => {
|
||
const { deps, state } = createHarness({ sessions: [], io: {
|
||
resolveLookupBootstrap: async input => ({
|
||
selection: { sessionId: input.defaultSessionId, urlAction: "new-chat", missing: false },
|
||
sessions: input.sessions, notice: null,
|
||
}),
|
||
writeSessionUrl: () => {}, clearLoginSessionReturn: () => {},
|
||
} });
|
||
resetSubjectCatalogForTests();
|
||
bindCurrentSubjectAccount("fictional-account", ["fictional-person"]);
|
||
setCurrentSubject("fictional-person");
|
||
deps.readSearch = () => "?new=1";
|
||
try {
|
||
await runHomeBootstrap(deps, new AbortController().signal);
|
||
assert.equal(state.sessions.find(session => session.id === state.active)?.chartProfileId, "fictional-person");
|
||
assert.equal(readCurrentSubjectId(), "fictional-person");
|
||
assert.equal(state.error, "");
|
||
} finally { resetSubjectCatalogForTests(); }
|
||
});
|