Validate on Linux Node 22 and PostgreSQL 17: 3894 frontend tests and 64 database tests pass, with no removed test names or new failures. Preserve static Home, bounded gzip, assertion-change records and manual acceptance gaps. Co-Authored-By: Claude Code <noreply@anthropic.com>
369 lines
13 KiB
TypeScript
369 lines
13 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,
|
|
fetchConsultationStatus: async () => {
|
|
throw new Error("unexpected status read");
|
|
},
|
|
resolveLookupBootstrap: async (input) => ({
|
|
selection: {
|
|
sessionId: input.defaultSessionId,
|
|
urlAction: "none",
|
|
missing: false,
|
|
clearStoredReturn: 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,
|
|
clearStoredReturn: 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, clearStoredReturn: 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(); }
|
|
});
|