Move the home startup sequence and consultation recovery poll into plain functions, and the error and onboarding screens into components. Landing rules, the 20s fatal screen, retry, and the chart settings deep link stay the same.
322 lines
11 KiB
TypeScript
322 lines
11 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 { 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, "");
|
|
});
|
|
});
|