diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c60d6c7..e317f047 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 印度占星 Skill 更新日志 +## 2026-09-25 — 首页内部重构,无用户可见变化 + +- 首页启动流程、咨询恢复轮询、错误屏和引导屏从首页组件拆到独立模块。落点、20 秒错误屏、局部重试、pending 优先级、恢复轮询和文案都不变。 +- `?settings=chart` 仍打开星盘资料。 +- Skill 版本不 bump。浏览器和 `next build` 未验收。 + ## 2026-09-25 — 0 年大运、星历切日期、星盘无资料入口(待验收) - Chara 出现 0 年大运时照实显示「0 年」,不占时间轴、不展开子运,不再把整张盘打成「算不出来」。不改引擎口径。 diff --git a/docs/tasks/PROGRESS-home-page-split-20260925.md b/docs/tasks/PROGRESS-home-page-split-20260925.md new file mode 100644 index 00000000..bb6aa994 --- /dev/null +++ b/docs/tasks/PROGRESS-home-page-split-20260925.md @@ -0,0 +1,65 @@ +# PROGRESS · 首页 page.tsx 拆分(2026-09-25) + +## 当前结论 + +T1、T2、T3、T5 完成。T4 让步到 1417 行(允许 ≤1450,未到 ≤1300)。没有新 BUG。没有推 `staging`,没有推 `main`。`?settings=chart` 仍打开星盘资料。 + +- 工作树:`G:\Ferti\Jyotisha\.worktrees\home-page-split-20260925` +- 分支:`codex/home-page-split-20260925` +- 开工基线:`origin/staging` = `59c23018`(与本分支当时一致) +- 开工后 `origin/staging` 又前进了报告读者版 `3c2f7bd5` 和状态板 `9167d75e`。这两笔不碰首页。本分支没有把它们合进来,也没有快进 `staging`。 +- 实现提交短 SHA 写在随后的状态板提交里。 + +## 逐项 + +| 项 | 结果 | 说明 | +| --- | --- | --- | +| T1 | 完成 | `runHomeBootstrap(deps, signal)` 在 `frontend/src/lib/home-bootstrap-run.ts`。内部再拆成 `runAccountPhase` / `resolveLanding`(注入项超过 15)。新文件 0 个 React hook。`Home()` 只留一个 effect 调用它。 | +| T2 | 完成 | `runConsultationRecoveryPoll` 在 `frontend/src/lib/consultation-recovery-poll.ts`。假计时器覆盖 404 第一次只确认、下一次重放一次、第 4 次放弃、reserved 成功把缺失计数清零。 | +| T3 | 完成 | `HomeBootstrapError`、`HomeOnboardingShell`。文案和按钮与拆分前一致。「重试」仍走 `retryHomeBootstrap(...reloadSessionList)`。 | +| T4 | 让步 | `page.tsx` 1417 行(换行计数,与增长合同相同)。目标 ≤1300 未到,任务书允许先到 ≤1450。import 挤行已还原:`^import .*; import ` 对 `page.tsx` 为 0。增长合同 `PAGE_LINE_COUNT_BASELINE` 下调到 1417,上限 1567。`useState` 仍是 34(上限保持 36),`useRef` 仍是 37(上限保持 37),都没有增加。 | +| T5 | 完成 | DESIGN 未改。CHANGELOG 一条「首页内部重构,无用户可见变化」。不新开 BUG。 | + +## 还留在 Home() 里的大块 + +下一步若要压到 1300,还在首页里的是:账户弹窗模型(资料 / 星盘库 / 点数 / 退出)、对话区和 `ChatComposer` 装配、星盘库同步、今日星语、设置深链。设置深链必须留在首页,`query.pane === "chart"` 仍调用 `openAccountDialog("chart-library")`。 + +## 断言改写 + +| 原值 | 新值 | 原因 | +| --- | --- | --- | +| `new-chat-recovery.test.ts` 把 page.tsx 激活段和 hook 的 restore 放进 vm,断言 active 等于预置本地会话 | 调用 `runHomeBootstrap`,restore 仍是 hook 里的真实函数;new-chat 会新建空咨询,恢复后仍停在这条新会话 | 激活段已搬进函数。直接调用还覆盖「新建落点与 reserved 恢复共存」,比只跑后半段更强 | +| `consultation-recovery.test.ts` 按 page.tsx 恢复 effect 切片匹配 404 / 重放 / 第 4 次放弃 | 直接调用 `runConsultationRecoveryPoll`;逐步假计时器在 `consultation-recovery-poll.test.ts` | 轮询搬出后,源码切片不再落在 page.tsx | +| 从 page.tsx 切 `storedPending` 分支,并用源码顺序断言它先于全局状态 | 调用 `runHomeBootstrap`:本页 pending 覆盖全局 reserved;无 pending 时不删 key 的反例由「有 pending 且 reserved 则不删 key」锁住。写入 `sessionId/requestId/question` 的 effect 仍留在 page.tsx,继续按该 effect 切片 | 启动流程搬走;优先级改由结果锁定,不弱于源码顺序 | +| `homeSurface` 里预留判断、`window.setTimeout(() => void poll(), 1_750)`、完成后的 `fetchSessionDetail` | 预留判断在 `home-bootstrap-run.ts`;1750ms 和详情读取在 `consultation-recovery-poll.ts`,调用变成 `fetchDetail(deps.pendingSessionId, deps.modelCatalog, signal)` | 两段流程搬出首页。状态端点、间隔、完成后拉详情都还在 | +| `restoreConsultationRecovery(recoverySession, reservedConsultation.requestId, storedPending)` 在 homeSurface | 同一调用在 `home-bootstrap-run.ts` | 启动恢复搬走,仍把 stored pending 交给真实 restore | +| page.tsx 直接调用 `resolveStarterHomeLandingSessionId` / `starterHomeLandingNeedsConsultation`,并引用 `HOME_BOOTSTRAP_SLOW_MS` | 这两个调用和 20 秒预算在 `home-bootstrap-run.ts`;`shouldAutoOpenRectificationSession` 仍在 page.tsx | 落点和超时属于启动流程。揭幕阶段的校正会话判断留在首页 | +| `if (bootstrapFailed) setHydrated(true); else setBootstrapPhase("prepare")` 在 page.tsx | 同一分支在 `runHomeBootstrap`,setter 经 `deps.commit` | 失败仍揭幕错误屏,成功仍进入 prepare,不半揭幕 | +| 错误屏 `
` 在 page.tsx | 同一段在 `home-bootstrap-error.tsx`。加载中的 polite main 仍在 page.tsx | 错误屏抽成组件,class 与 `role="alert"` 不变 | +| homeSurface 两处「回答已恢复,已显示在对话区末尾。」 | `use-consultation-run.ts` 与 `consultation-recovery-poll.ts` 各一处 | 轮询搬走,两处文案都还在 | +| page.tsx 里 `clearStaleClientReload(sessionStorage)`,以及从 `loadCloudData` 切登录跳转不关超时 | 首页传入 `storage: sessionStorage`,函数内 `clearStaleClientReload(deps.storage)`;登录跳转的 try/finally 在 `home-bootstrap-run.ts`,仍是 `window.clearTimeout(bootstrapTimeout)` 且 `redirectedToLogin` 时提前 return | 清的仍是会话存储。登录跳转仍不取消 20 秒超时 | +| 从 homeSurface 切 `async function loadCloudData()` | 切 `resolveLanding`。`defaultSessionId` 现为 `ready.nextSessions[0].id` | 落点代码原样搬家。清 new-chat URL 仍在 `setActiveSessionId` 之前,默认落点仍不 `push` | +| 从 page.tsx 切 `if (previewMode)` 到 `await sessionListReady` | 预览分支在 `home-bootstrap-run.ts`,结束锚点是 `await deps.sessionListReady`。首页把 `sessionListReady` / `sessionListBoot` 传进去 | 预览仍不写会话 URL。`/` 仍不在首页里 `fetchSessions` | +| `session-list-provider.test.ts` 匹配 page.tsx 的 `await sessionListReady;` | 匹配首页传入的 `sessionListReady`,以及函数里的 `await deps.sessionListReady` | 会话列表仍只由 provider 拉一次 | + +## 验证 + +开工前,下面四份测试 `npx tsx --test`:**28 pass / 0 fail**。 + +- `frontend/tests/new-chat-recovery.test.ts` +- `frontend/tests/consultation-recovery.test.ts` +- `frontend/tests/home-shell-growth-contract.test.ts` +- `frontend/tests/home-bootstrap-reveal.test.ts` + +改完后同一组,加上本单新测试和会被搬家碰到的合同(`chat-session-url`、`stale-client-recovery`、`chat-navigation-a11y-contract`、`session-list-provider`、`home-bootstrap-run`、`consultation-recovery-poll`、`home-bootstrap-screens`、`settings-url`):**全部通过,没有新增失败**。最后一次合并跑是 81 pass / 0 fail;补上地点卡文案后又跑了 screens + settings-url,7 pass / 0 fail。增长合同与 `tsc --noEmit` 随后再跑,exit 0。 + +- `npx tsx --test` 使用明确文件路径。没有用全量 `npm test` 当信号。 +- `.\node_modules\.bin\tsc --noEmit`:0 错。 +- `npm run lint`:0 error,124 warnings。没有去清原本就未使用的 import,也没有为消 warning 改 effect 依赖。 +- `npm run build`:**没有通过**。Turbopack 拒绝本工作树的 `frontend/node_modules` junction(指向主检出,超出它认定的文件系统根)。按约束没有删除或重建这个 junction,也没有 `npm install`。因此不能声称 `/` 仍是 Static,也不能报首屏 gzip。 + +## 没做的验证 + +- 没有登录浏览器走首页。本单声称无用户可见变化,靠测试锁行为。 +- 没有 `next build`,见上。 +- 没有推 `origin/staging`,没有推 `main`,没有改 workflow,没有动 DNS。 diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index 4f40d25f..495d4929 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -1,21 +1,24 @@ "use client"; -import Link from "next/link"; import dynamic from "next/dynamic"; import { loadBillingPanel } from "@/lib/billing-panel-loader"; import { Sparkles } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; import type { FormEvent, KeyboardEvent } from "react"; -import { AccountDialogOverlay, type AccountOverlayModel } from "@/components/account-dialog-overlay"; import { ProfilePanel } from "@/components/profile-panel"; import { ThemePreferencePanel } from "@/components/theme-preference-menu"; -import { BirthTimeAssessmentOverlay, type BirthTimeAssessmentPhase } from "@/components/birth-time-assessment-overlay"; -import { BirthTimeIntakeFields } from "@/components/birth-time-intake"; +import { useEffect, useMemo, useRef, useState } from "react"; +import type { FormEvent, KeyboardEvent } from "react"; +import { AccountDialogOverlay, type AccountOverlayModel } from "@/components/account-dialog-overlay"; +import { ProfilePanel } from "@/components/profile-panel"; +import { ThemePreferencePanel } from "@/components/theme-preference-menu"; +import type { BirthTimeAssessmentPhase } from "@/components/birth-time-assessment-overlay"; import { AppLoadingIndicator } from "@/components/app-loading-indicator"; +import { HomeBootstrapError } from "@/components/home-bootstrap-error"; +import { HomeOnboardingShell } from "@/components/home-onboarding-shell"; import { entrySummaryFromResponse, rectificationEntryLabels, resolveRectificationEntryAction, } from "@/lib/rectification-entry"; import { - composerLocksAsRectification, dropRectificationStoredPending, + composerLocksAsRectification, ordinaryComposerPlaceholder, rectificationOrdinaryComposerReopen, } from "@/lib/rectification-session-composer-guard"; @@ -39,9 +42,6 @@ import { SidebarTrigger } from "@/components/ui/sidebar"; import { useHomeShellRegistration } from "@/hooks/use-home-shell-registration"; import { useSessionList } from "@/hooks/use-session-list"; import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft"; -import { clearStaleClientReload } from "@/lib/stale-client-recovery"; -import { BirthLocationFields } from "@/components/birth-location-fields"; -import { OnboardingChatMessage } from "@/components/onboarding-chat-message"; import { ChartLibraryPanel } from "@/components/chart-library-panel"; import { StarterEntries, StarterGreeting } from "@/components/starter-home"; import { @@ -50,13 +50,7 @@ import { isRectificationHandoffQuestion, type ConsultationEntrypoint, } from "@/lib/consultation-entrypoint"; -import { - applyBirthTimeDraftPatch, - assistantIntentCopy, - hydrateDeclaredWindowDraft, - isBirthTimeDraftReady, - birthTimeDraftReadyHint, -} from "@/lib/birth-time-intake-model"; +import { assistantIntentCopy } from "@/lib/birth-time-intake-model"; import { clearBirthTimeConsultationConsent, createLatestAccountRequestGuard, @@ -82,12 +76,6 @@ import { chatReplyAnnouncement, type ChatReplyPhase } from "@/lib/chat-reply-ann import { type JourneyClientResponse, } from "@/lib/birth-time-journey-client"; -import { - guidedBirthTimePreview, - isGuidedBirthTimePreview, - previewRectificationJourney, -} from "@/lib/birth-time-guided-preview"; - import { keepFocusWithin } from "@/lib/focus-trap"; import { BALANCE_SYNC_KEY, @@ -123,16 +111,8 @@ import { CONSULTATION_EVIDENCE_VALIDATION_LABEL, CONSULTATION_LOADING_METHOD_LABEL, } from "@/lib/consultation-activity-labels"; -import { - SESSION_MISSING_NOTICE, - clearLoginSessionReturn, - writeSessionUrl, -} from "@/lib/chat-session-url"; import { consultationReportMarkdown } from "@/lib/consultation-report-export"; -import { - createStartGreeting, - createStartGreetingParts, -} from "@/lib/starter-greeting"; +import { createStartGreetingParts } from "@/lib/starter-greeting"; import { calendarDateInTimeZone, dailyStarlanguageProfileKey, @@ -149,18 +129,11 @@ import { accountDialogClasses, accountDialogTitles, dailyStarlanguageRetryDelayMs, - emptyProfile, pendingConsultationStorageKey, presetOnboardingMessage, - previewModelCatalog, - timestamp, - type Account, type AccountDialog, type ChartLibraryRecord, - type ChatSession, - type ConsultationStatus, type DailyStarlanguageState, - type Message, type OnboardingStep, type OpenAccountDialogOptions, type PendingConsultation, @@ -174,67 +147,39 @@ import { chatSubjectSendBlocked } from "@/lib/chat-profile-picker-model"; import { useChatProfileCatalog } from "@/hooks/use-chat-profile-catalog"; import { birthQuestion, - chartSnapshotForSession, completedOnboardingMessage, completedOnboardingTranscript, - formatBirthMoment, isProfileComplete, - missingProfileStep, placeQuestion, readProfile, - selectedBirthPlace, sessionChartLabel, upsertSelfChart, } from "@/lib/home-profile"; import { activeChartStorageKey, - applyLegacySessionControls, CancellationResponseError, ConsultationResponseError, - ConsultationStatusError, - createSession, discardLegacyCloudMirrorKeys, - fetchActiveConsultationStatus, fetchCloudChartLibrary, fetchCloudSynastryHistory, - fetchConsultationStatus, fetchDailyStarlanguage, - fetchModelCatalog, - fetchSessionDetail, - friendlyError, - LoginRedirectError, - mergeHydratedSession, - patchSessionModel, - resolveLookupBootstrap, payloadCode, payloadMessage, - readSessions, readStoredDailyStarlanguage, - readStoredPendingConsultation, - redirectToLogin, waitForUndoWindow, writeStoredDailyStarlanguage, } from "@/lib/home-cloud-sync"; +import { runHomeBootstrap } from "@/lib/home-bootstrap-run"; +import { runConsultationRecoveryPoll } from "@/lib/consultation-recovery-poll"; import { bootstrapLoadingCopy, bootstrapPrepareSettled, bootstrapRevealDelayMs, - resolveStarterHomeLandingSessionId, retryHomeBootstrap, sessionIdsToPrefetch, shouldAutoOpenRectificationSession, - starterHomeLandingNeedsConsultation, - HOME_BOOTSTRAP_SLOW_MS, type BootstrapPhase, } from "@/lib/home-bootstrap"; -import { isUnsavedEmptyConsultation } from "@/lib/session-list-filter"; -const BirthTimeRectification = dynamic( - () => import("@/components/birth-time-rectification").then((module) => module.BirthTimeRectification), - { - ssr: false, - loading: () =>

正在加载出生时间评估...

, - }, -); const BillingPanel = dynamic(loadBillingPanel, { ssr: false, loading: () =>

账户信息还没拿到。

, @@ -760,7 +705,6 @@ export default function Home() { && pendingSessionId !== activeSession.id, ); const onboardingFormActive = !profileComplete && onboardingStep !== "name"; - const birthTimeContinueHint = onboardingStep === "birth" ? birthTimeDraftReadyHint(profileDraft) : ""; const starterHasPriorSessions = sessions.some((session) => session.id !== activeSession?.id); const starterGreeting = createStartGreetingParts(profile.name, new Date(), starterGreetingSelection, starterHasPriorSessions); const conversationAnchor = useConversationScrollAnchor( @@ -842,287 +786,36 @@ export default function Home() { useEffect(() => { const controller = new AbortController(); - const bootstrapTimeout = window.setTimeout(() => { - if (controller.signal.aborted) return; - controller.abort(); - setAccountError("连接云端服务超时。请检查网络后重试,或返回登录页重新建立会话。"); - setHydrated(true); - }, HOME_BOOTSTRAP_SLOW_MS); - - async function loadCloudData() { - let redirectedToLogin = false; - let bootstrapFailed = false; - try { - const previewMode = process.env.NODE_ENV === "development" - ? new URLSearchParams(window.location.search).get("preview") - : null; - if (previewMode) { - uiPreview.current = true; - uiPreviewMode.current = previewMode; - setGuidedJourneyPreview(true); - if (previewMode === "error") { - setAccountError("连接云端服务超时。请检查网络后重试,或返回登录页重新建立会话。"); - setHydrated(true); - return; - } - const isAssessmentLoadingPreview = previewMode === "birth-time-assessment-loading"; - const isCompletedCandidatePreview = previewMode === "birth-time-candidate-complete"; - const isRectificationPreview = isGuidedBirthTimePreview(previewMode); - const previewJourney = isRectificationPreview - ? guidedBirthTimePreview(previewMode) - : previewRectificationJourney; - const previewProfile: Profile = previewMode === "onboarding" - ? emptyProfile - : { - ...emptyProfile, - name: "林遥", - date: "1990-06-15", - time: isCompletedCandidatePreview ? "04:53" : isRectificationPreview || isAssessmentLoadingPreview ? "" : "12:30", - reportedTime: isRectificationPreview ? "14:30" : isAssessmentLoadingPreview || isCompletedCandidatePreview ? "" : "12:30", - birthTimeSource: isRectificationPreview ? "approximate" : isAssessmentLoadingPreview || isCompletedCandidatePreview ? "period_only" : "legacy_import", - ...hydrateDeclaredWindowDraft({ - period: isAssessmentLoadingPreview || isCompletedCandidatePreview ? "early_morning" : "", - }), - birthTimeClue: "", - uncertaintyBeforeMinutes: isRectificationPreview ? 30 : null, - uncertaintyAfterMinutes: isRectificationPreview ? 30 : null, - birthTimeStatus: isCompletedCandidatePreview - ? "candidate" - : isAssessmentLoadingPreview - ? "rectifying" - : previewJourney.snapshot.state === "candidate" - || previewJourney.snapshot.state === "confirming" - || previewJourney.snapshot.state === "ready" - ? "candidate" - : isRectificationPreview ? "rectifying" : "confirmed", - rectificationCaseId: isRectificationPreview ? previewJourney.caseId : "", - countryCode: "CN", - provinceCode: "110000", - cityCode: "110000-city", - districtCode: "110101", - }; - const previewMessages: Message[] = previewMode === "conversation" || previewMode === "streaming" || previewMode === "partial" - ? [ - { role: "user", text: "未来半年是否适合换工作?" }, - { role: "assistant", text: "可以先看职业方向、关键时间。\n同时评估现实风险。\n此处只展示本地预览,\n不调用真实星盘。" }, - ] - : []; - const previewSession: ChatSession = { - id: "preview-session", - title: previewMessages.length > 0 ? "未来半年是否适合换工作" : "新对话", - theme: "career", - modelId: previewModelCatalog.defaultModelId, - messages: previewMessages, - updatedAt: timestamp(), - sessionType: "consultation", - rectificationCaseId: null, - chartProfileId: "self", - chartProfileName: previewProfile.name.trim() || "我", - chartProfileRole: "self", - pinned: false, - archivedAt: null, - messagesHydrated: true, - }; - setAccount({ - user: { id: "preview-user", email: "preview@local.test" }, - avatar: null, - credits: 8, - isAdmin: false, - adminUrl: null, - rectificationPriceCredits: 1, - hasConfirmedBirthTime: previewProfile.birthTimeStatus === "confirmed", - hasUsableBirthTime: previewProfile.birthTimeStatus === "accepted" || previewProfile.birthTimeStatus === "confirmed", - activeSubscription: null, - profile: previewProfile, - }); - setModelCatalog(previewModelCatalog); - setProfile(previewProfile); - setProfileDraft(previewProfile); - if (isRectificationPreview) { - setBirthTimeJourney(previewJourney); - } - if (isAssessmentLoadingPreview) { - setBirthTimeAssessmentPhase("assessing"); - } - setOnboardingStep(isAssessmentLoadingPreview ? "birth" : missingProfileStep(previewProfile) ?? "name"); - setSessions([previewSession]); - setActiveSessionId(previewSession.id); - const previewGreeting = previewProfile.name.trim() ? createStartGreeting(previewProfile.name) : ""; - setStartGreeting(previewGreeting); - setHydrated(true); - return; - } - - // Start independent reads before waiting for the provider so slow-network - // latency is spent in parallel, not as a serial account -> model chain. - const modelCatalogPromise = fetchModelCatalog(controller.signal) - .then((catalog) => ({ catalog, error: null as unknown })) - .catch((error: unknown) => ({ catalog: null, error })); - const activeStatusPromise = fetchActiveConsultationStatus(controller.signal).catch((caught: unknown) => { - if (caught instanceof Error && caught.name === "AbortError") throw caught; - return null; - }); - await sessionListReady; - if (controller.signal.aborted) return; - const listBoot = sessionListBoot(); - if (listBoot?.signedOut || !listBoot?.account) { - redirectToLogin(); - return; - } - const nextAccount = listBoot.account; - const modelCatalogResult = await modelCatalogPromise; - if (modelCatalogResult.error) throw modelCatalogResult.error; - const nextModelCatalog = modelCatalogResult.catalog; - const nextProfile = readProfile(nextAccount.profile); - const parsedSessions = readSessions(listBoot.rawRows, nextModelCatalog); - let nextSessions = parsedSessions.sessions.length > 0 ? parsedSessions.sessions : listBoot.sessions; - if (nextSessions.length === 0) { - nextSessions = [createSession( - nextModelCatalog?.defaultModelId ?? "", - "consultation", - chartSnapshotForSession("self", [], nextProfile), - )]; - } - nextSessions = applyLegacySessionControls(nextAccount.user.id, nextSessions); - - let reservedConsultation: ConsultationStatus | null = await activeStatusPromise; - const storedPending = dropRectificationStoredPending( - readStoredPendingConsultation(sessionStorage.getItem(pendingConsultationStorageKey), nextSessions.map((session) => session.id)), - nextSessions, - ); - if (!storedPending) sessionStorage.removeItem(pendingConsultationStorageKey); - - if (storedPending) { - try { - const status = await fetchConsultationStatus( - storedPending.sessionId, - storedPending.requestId, - controller.signal, - ); - if (status.status === "reserved") { - consultationStatusMissingCount.current = 0; - reservedConsultation = status; - } else { - sessionStorage.removeItem(pendingConsultationStorageKey); - reservedConsultation = null; - } - } catch (caught) { - if (caught instanceof Error && caught.name === "AbortError") throw caught; - consultationStatusMissingCount.current = caught instanceof ConsultationStatusError && caught.status === 404 ? 1 : 0; - reservedConsultation = { - sessionId: storedPending.sessionId, - requestId: storedPending.requestId, - status: "reserved", - }; - } - } - - if (controller.signal.aborted) return; - const lookedUp = await resolveLookupBootstrap({ - sessions: nextSessions, catalog: nextModelCatalog, - defaultSessionId: nextSessions[0].id, signal: controller.signal, - }); - nextSessions = lookedUp.sessions; - const bootstrapSelection = lookedUp.selection; - let landingSessionId = resolveStarterHomeLandingSessionId( - nextSessions, - bootstrapSelection.sessionId, - bootstrapSelection.urlAction, - ); - if (starterHomeLandingNeedsConsultation(nextSessions, landingSessionId, bootstrapSelection.urlAction)) { - const homeSession = createSession( - nextModelCatalog?.defaultModelId ?? "", - "consultation", - chartSnapshotForSession("self", [], nextProfile), - ); - nextSessions = [homeSession, ...nextSessions]; - landingSessionId = homeSession.id; - } - const activeListed = nextSessions.find((session) => session.id === landingSessionId) - ?? nextSessions[0]; - if (activeListed && !activeListed.messagesHydrated && activeListed.sessionType === "consultation") { - try { - const detailed = await fetchSessionDetail(activeListed.id, nextModelCatalog, controller.signal); - if (detailed) { - nextSessions = nextSessions.map((session) => session.id === detailed.id ? detailed : session); - } - } catch (caught) { - if (caught instanceof Error && caught.name === "AbortError") throw caught; - if (caught instanceof LoginRedirectError) return; - } - } - if (controller.signal.aborted) return; - clearStaleClientReload(sessionStorage); - setSessionsCursor(listBoot.cursor); - setAccount(nextAccount); - setModelCatalog(nextModelCatalog); - setProfile(nextProfile); - setProfileDraft(nextProfile); - setStartGreeting(nextProfile.name.trim() ? createStartGreeting(nextProfile.name) : ""); - setOnboardingStep(missingProfileStep(nextProfile) ?? "name"); - setSessions(nextSessions); - if (bootstrapSelection.urlAction === "new-chat") writeSessionUrl(null, "replace"); - setActiveSessionId(landingSessionId); - if (bootstrapSelection.clearStoredReturn) clearLoginSessionReturn(); - if (bootstrapSelection.urlAction === "replace-clear") writeSessionUrl(null, "replace"); - if (bootstrapSelection.urlAction === "replace-selected") { - const selected = nextSessions.find((session) => session.id === bootstrapSelection.sessionId); - if (!selected || !isUnsavedEmptyConsultation(selected)) { - writeSessionUrl(bootstrapSelection.sessionId, "replace"); - } - } - if (reservedConsultation?.status === "reserved") { - const recoverySession = nextSessions.find((session) => session.id === reservedConsultation.sessionId); - if (recoverySession) restoreConsultationRecovery(recoverySession, reservedConsultation.requestId, storedPending); - if (bootstrapSelection.urlAction === "new-chat") { - setActiveSessionId(landingSessionId); - setComposerNotice(""); - } - } - if (parsedSessions.fallbackSessionIds.length > 0) { - setComposerNotice("此前选择的模型已下线,已切换为默认模型。"); - } else if (lookedUp.notice) { - setComposerNotice(lookedUp.notice); - } else if (bootstrapSelection.missing) { - setComposerNotice(SESSION_MISSING_NOTICE); - } - setAccountError(""); - - if (nextModelCatalog && parsedSessions.fallbackSessionIds.length > 0) { - try { - await Promise.all(parsedSessions.fallbackSessionIds.map((sessionId) => - patchSessionModel(sessionId, nextModelCatalog.defaultModelId, controller.signal), - )); - } catch { - if (!controller.signal.aborted) { - setComposerNotice("已在当前页面切换为默认模型,但云端同步失败;刷新后可能需要重新选择。"); - } - } - } - } catch (caught) { - if (caught instanceof LoginRedirectError) { - redirectedToLogin = true; - return; - } - if ((caught as Error).name !== "AbortError" && !controller.signal.aborted) { - bootstrapFailed = true; - setAccountError(friendlyError(caught instanceof Error ? caught.message : "暂时无法读取云端数据")); - } - } finally { - if (redirectedToLogin) return; - window.clearTimeout(bootstrapTimeout); - if (!controller.signal.aborted) { - if (bootstrapFailed) setHydrated(true); - else setBootstrapPhase("prepare"); - } - } - } - - void loadCloudData(); - return () => { - window.clearTimeout(bootstrapTimeout); - controller.abort(); - }; + void runHomeBootstrap({ + sessionListReady, + sessionListBoot, + uiPreview, + uiPreviewMode, + consultationStatusMissingCount, + isDevelopment: process.env.NODE_ENV === "development", + readSearch: () => window.location.search, + storage: sessionStorage, + restoreConsultationRecovery, + setComposerNotice, + commit: { + setAccount, + setAccountError, + setActiveSessionId, + setBirthTimeAssessmentPhase, + setBirthTimeJourney, + setBootstrapPhase, + setGuidedJourneyPreview, + setHydrated, + setModelCatalog, + setOnboardingStep, + setProfile, + setProfileDraft, + setSessions, + setSessionsCursor, + setStartGreeting, + }, + }, controller.signal); + return () => controller.abort(); }, [sessionListReady]); useEffect(() => { @@ -1144,98 +837,26 @@ export default function Home() { useEffect(() => { if (consultationPhase !== "recovering" || !pendingSessionId || !pendingRequestId || uiPreview.current) return; const controller = new AbortController(); - let timer = 0; - let polling = false; - - const poll = async () => { - if (polling || controller.signal.aborted) return; - polling = true; - try { - if (!navigator.onLine) { - setComposerNotice("网络已断开,回答仍在后台生成;联网后会自动恢复。"); - return; - } - const status = await fetchConsultationStatus(pendingSessionId, pendingRequestId, controller.signal); - if (status.status === "reserved") { - consultationStatusMissingCount.current = 0; - setComposerNotice("回答仍在后台生成,正在自动恢复。"); - return; - } - if (status.status === "completed") { - const detailed = await fetchSessionDetail(pendingSessionId, modelCatalog, controller.signal); - if (detailed) { - setSessions((current) => mergeHydratedSession(current, detailed)); - setActiveSessionId((current) => current || detailed.id); - } - pendingConsultation.current = null; - setPendingSessionId(null); - setPendingRequestId(null); - setConsultationPhase(null); - setStreamingReply(null); - setRequestError(null); - setComposerNotice("回答已恢复,已显示在对话区末尾。"); - void refreshAccount(); - return; - } - pendingConsultation.current = null; - setPendingSessionId(null); - setPendingRequestId(null); - setConsultationPhase(null); - setStreamingReply(null); - setRequestError(null); - setComposerNotice("回答已取消;问题仍保留在聊天记录中,可重新发送。"); - } catch (caught) { - if (controller.signal.aborted) return; - if (caught instanceof ConsultationStatusError && caught.status === 404) { - consultationStatusMissingCount.current += 1; - if (consultationStatusMissingCount.current === 1) { - setComposerNotice("正在确认本次咨询请求是否已开始…"); - return; - } - if (consultationReplayStarted.current !== pendingRequestId) { - consultationReplay.current(); - return; - } - if (consultationStatusMissingCount.current >= 4) { - pendingConsultation.current = null; - setPendingSessionId(null); - setPendingRequestId(null); - setConsultationPhase(null); - setStreamingReply(null); - setRequestError({ - sessionId: pendingSessionId, - message: "后台未找到本次咨询请求,请重新发送。", - }); - setComposerNotice("后台未找到本次咨询请求,已停止恢复,请重新发送。"); - return; - } - setComposerNotice("正在重新发起本次咨询…"); - return; - } - consultationStatusMissingCount.current = 0; - setComposerNotice(navigator.onLine - ? "回答仍在后台生成,正在自动恢复。" - : "网络已断开,回答仍在后台生成;联网后会自动恢复。"); - } finally { - polling = false; - if (!controller.signal.aborted && pendingConsultation.current?.phase === "recovering") { - timer = window.setTimeout(() => void poll(), 1_750); - } - } - }; - - consultationRecoveryWakeup.current = () => { - window.clearTimeout(timer); - void poll(); - }; - if (consultationStatusMissingCount.current > 0) { - timer = window.setTimeout(() => void poll(), 1_750); - } else { - void poll(); - } + consultationRecoveryWakeup.current = runConsultationRecoveryPoll({ + pendingSessionId, + pendingRequestId, + modelCatalog, + consultationStatusMissingCount, + consultationReplayStarted, + consultationReplay, + pendingConsultation, + setComposerNotice, + setSessions, + setActiveSessionId, + setPendingSessionId, + setPendingRequestId, + setConsultationPhase, + setStreamingReply, + setRequestError, + refreshAccount, + }, controller.signal); return () => { consultationRecoveryWakeup.current = () => undefined; - window.clearTimeout(timer); controller.abort(); }; }, [consultationPhase, modelCatalog, pendingRequestId, pendingSessionId]); @@ -1534,18 +1155,16 @@ export default function Home() { // after the bootstrap controller was aborted; never reveal without a catalog. if (!account || !modelCatalog) { return ( -
-
- 暂时无法进入 Jyotisha - {accountError} -
- - 返回登录 -
-
-
+ retryHomeBootstrap( + setAccountError, + setHydrated, + setBootstrapPhase, + prepareStartedAt, + reloadSessionList, + )} + /> ); } @@ -1646,80 +1265,28 @@ export default function Home() { {!activeSession?.messages.length ? (
{!profileComplete ? ( - <> - - {(onboardingStep !== "name" || profileComplete) && } - {(onboardingStep !== "name" || profileComplete) && } - {(onboardingStep === "place" || onboardingStep === "rectification" || profileComplete) && } - {(onboardingStep === "place" || onboardingStep === "rectification" || profileComplete) && } - {!profileComplete && onboardingStep === "rectification" && selectedBirthPlace(profileDraft) && } - {!profileComplete && onboardingStep === "rectification" && birthTimeJourney && } - {profileComplete && onboardingJustCompleted && selectedBirthPlace(profileDraft) && } - {profileComplete && onboardingJustCompleted && } - + { void retryBirthTimeAssessment(); }} + /> ) : null} - {!profileComplete && onboardingStep === "birth" && onboardingCardReady && ( -
-
-
-
-
- 出生日期与时间 -
-
-
- setProfileDraft((current) => applyBirthTimeDraftPatch(current, patch))} /> - {accountError &&

{accountError}

} -
- {birthTimeContinueHint ?

{birthTimeContinueHint}

: } - -
-
- - -
-
- )} - - {!profileComplete && onboardingStep === "place" && onboardingCardReady && ( -
-
-
-
出生地点搜索全球城市或区县,系统会匹配出生当日的时区。
-
- - {accountError &&

{accountError}

} -
-
- - -
-
- )} - - {!profileComplete && onboardingStep === "rectification" && presetMessageFinished && birthTimeJourney && ( -
-
- -
-
- )} - - {!profileComplete && onboardingStep === "rectification" && presetMessageFinished && !birthTimeJourney && ( -
- 出生时间尚未完成评估 -

{birthTimeError || "资料已经保留,但暂时无法恢复校正进度。系统不会应用未经验证的具体时间。"}

- -
- )} - - {!profileComplete && onboardingStep === "name" && accountError &&

{accountError}

} - {profileComplete && presetMessageFinished && !rectificationSurfaceOpen && ( )} diff --git a/frontend/src/components/home-bootstrap-error.tsx b/frontend/src/components/home-bootstrap-error.tsx new file mode 100644 index 00000000..07c09e6d --- /dev/null +++ b/frontend/src/components/home-bootstrap-error.tsx @@ -0,0 +1,24 @@ +"use client"; + +import Link from "next/link"; + +export function HomeBootstrapError({ + accountError, + onRetry, +}: { + readonly accountError: string; + readonly onRetry: () => void; +}) { + return ( +
+
+ 暂时无法进入 Jyotisha + {accountError} +
+ + 返回登录 +
+
+
+ ); +} diff --git a/frontend/src/components/home-onboarding-shell.tsx b/frontend/src/components/home-onboarding-shell.tsx new file mode 100644 index 00000000..1136161e --- /dev/null +++ b/frontend/src/components/home-onboarding-shell.tsx @@ -0,0 +1,148 @@ +"use client"; + +import dynamic from "next/dynamic"; +import type { FormEvent } from "react"; +import { BirthTimeAssessmentOverlay, type BirthTimeAssessmentPhase } from "@/components/birth-time-assessment-overlay"; +import { BirthLocationFields } from "@/components/birth-location-fields"; +import { BirthTimeIntakeFields } from "@/components/birth-time-intake"; +import { OnboardingChatMessage } from "@/components/onboarding-chat-message"; +import type { BirthTimeGuidedController } from "@/hooks/use-birth-time-guided-journey"; +import { + applyBirthTimeDraftPatch, + birthTimeDraftReadyHint, + isBirthTimeDraftReady, +} from "@/lib/birth-time-intake-model"; +import type { JourneyClientResponse } from "@/lib/birth-time-journey-client"; +import { + birthQuestion, + formatBirthMoment, + placeQuestion, + selectedBirthPlace, +} from "@/lib/home-profile"; +import { presetOnboardingMessage, type OnboardingStep, type Profile } from "@/lib/home-types"; + +const BirthTimeRectification = dynamic( + () => import("@/components/birth-time-rectification").then((module) => module.BirthTimeRectification), + { + ssr: false, + loading: () =>

正在加载出生时间评估...

, + }, +); + +export type HomeOnboardingShellProps = { + readonly profileComplete: boolean; + readonly profileDraft: Profile; + readonly setProfileDraft: (update: Profile | ((current: Profile) => Profile)) => void; + readonly onboardingStep: OnboardingStep; + readonly presetMessageLength: number; + readonly currentOnboardingMessage: string; + readonly onboardingJustCompleted: boolean; + readonly onboardingCardReady: boolean; + readonly presetMessageFinished: boolean; + readonly birthTimeJourney: JourneyClientResponse | null; + readonly birthTimeAssessmentPhase: BirthTimeAssessmentPhase | null; + readonly birthTimeError: string; + readonly birthTimeGuided: BirthTimeGuidedController; + readonly accountError: string; + readonly profileSaving: boolean; + readonly saveOnboardingBirth: (event: FormEvent) => void; + readonly saveOnboardingPlace: (event: FormEvent) => void; + readonly retryBirthTimeAssessment: () => void; +}; + +export function HomeOnboardingShell({ + profileComplete, + profileDraft, + setProfileDraft, + onboardingStep, + presetMessageLength, + currentOnboardingMessage, + onboardingJustCompleted, + onboardingCardReady, + presetMessageFinished, + birthTimeJourney, + birthTimeAssessmentPhase, + birthTimeError, + birthTimeGuided, + accountError, + profileSaving, + saveOnboardingBirth, + saveOnboardingPlace, + retryBirthTimeAssessment, +}: HomeOnboardingShellProps) { + const birthTimeContinueHint = onboardingStep === "birth" ? birthTimeDraftReadyHint(profileDraft) : ""; + return ( + <> + + {(onboardingStep !== "name" || profileComplete) && } + {(onboardingStep !== "name" || profileComplete) && } + {(onboardingStep === "place" || onboardingStep === "rectification" || profileComplete) && } + {(onboardingStep === "place" || onboardingStep === "rectification" || profileComplete) && } + {!profileComplete && onboardingStep === "rectification" && selectedBirthPlace(profileDraft) && } + {!profileComplete && onboardingStep === "rectification" && birthTimeJourney && } + {profileComplete && onboardingJustCompleted && selectedBirthPlace(profileDraft) && } + {profileComplete && onboardingJustCompleted && } + + {!profileComplete && onboardingStep === "birth" && onboardingCardReady && ( +
+
+
+
+
+ 出生日期与时间 +
+
+
+ setProfileDraft((current) => applyBirthTimeDraftPatch(current, patch))} /> + {accountError &&

{accountError}

} +
+ {birthTimeContinueHint ?

{birthTimeContinueHint}

: } + +
+
+ + +
+
+ )} + + {!profileComplete && onboardingStep === "place" && onboardingCardReady && ( +
+
+
+
出生地点搜索全球城市或区县,系统会匹配出生当日的时区。
+
+ + {accountError &&

{accountError}

} +
+
+ + +
+
+ )} + + {!profileComplete && onboardingStep === "rectification" && presetMessageFinished && birthTimeJourney && ( +
+
+ +
+
+ )} + + {!profileComplete && onboardingStep === "rectification" && presetMessageFinished && !birthTimeJourney && ( +
+ 出生时间尚未完成评估 +

{birthTimeError || "资料已经保留,但暂时无法恢复校正进度。系统不会应用未经验证的具体时间。"}

+ +
+ )} + + {!profileComplete && onboardingStep === "name" && accountError &&

{accountError}

} + + ); +} diff --git a/frontend/src/lib/consultation-recovery-poll.ts b/frontend/src/lib/consultation-recovery-poll.ts new file mode 100644 index 00000000..8293c749 --- /dev/null +++ b/frontend/src/lib/consultation-recovery-poll.ts @@ -0,0 +1,141 @@ +/** + * One recovering consultation's status loop. No React hooks: Home keeps the + * effect, the abort signal, and the wakeup ref. + */ + +import { + ConsultationStatusError, + fetchConsultationStatus, + fetchSessionDetail, + mergeHydratedSession, +} from "@/lib/home-cloud-sync"; +import type { ChatSession, RequestError, StreamingReply } from "@/lib/home-types"; +import type { PublicLanguageModelCatalog } from "@/lib/public-models"; + +export const CONSULTATION_RECOVERY_POLL_MS = 1_750; + +export type ConsultationRecoveryPollDeps = { + pendingSessionId: string; + pendingRequestId: string; + modelCatalog: PublicLanguageModelCatalog | null; + consultationStatusMissingCount: { current: number }; + consultationReplayStarted: { current: string | null }; + consultationReplay: { current: () => void }; + pendingConsultation: { current: { phase: string } | null }; + setComposerNotice: (notice: string) => void; + setSessions: (update: ChatSession[] | ((current: ChatSession[]) => ChatSession[])) => void; + setActiveSessionId: (update: string | ((current: string) => string)) => void; + setPendingSessionId: (id: string | null) => void; + setPendingRequestId: (id: string | null) => void; + setConsultationPhase: (phase: "undo" | "streaming" | "recovering" | null) => void; + setStreamingReply: (value: StreamingReply | null) => void; + setRequestError: (value: RequestError | null) => void; + refreshAccount: () => void | Promise; + online?: () => boolean; + fetchConsultationStatus?: typeof fetchConsultationStatus; + fetchSessionDetail?: typeof fetchSessionDetail; +}; + +export function runConsultationRecoveryPoll( + deps: ConsultationRecoveryPollDeps, + signal: AbortSignal, +): () => void { + const online = deps.online ?? (() => navigator.onLine); + const fetchStatus = deps.fetchConsultationStatus ?? fetchConsultationStatus; + const fetchDetail = deps.fetchSessionDetail ?? fetchSessionDetail; + let timer = 0; + let polling = false; + + const poll = async () => { + if (polling || signal.aborted) return; + polling = true; + try { + if (!online()) { + deps.setComposerNotice("网络已断开,回答仍在后台生成;联网后会自动恢复。"); + return; + } + const status = await fetchStatus(deps.pendingSessionId, deps.pendingRequestId, signal); + if (status.status === "reserved") { + deps.consultationStatusMissingCount.current = 0; + deps.setComposerNotice("回答仍在后台生成,正在自动恢复。"); + return; + } + if (status.status === "completed") { + const detailed = await fetchDetail(deps.pendingSessionId, deps.modelCatalog, signal); + if (detailed) { + deps.setSessions((current) => mergeHydratedSession(current, detailed)); + deps.setActiveSessionId((current) => current || detailed.id); + } + deps.pendingConsultation.current = null; + deps.setPendingSessionId(null); + deps.setPendingRequestId(null); + deps.setConsultationPhase(null); + deps.setStreamingReply(null); + deps.setRequestError(null); + deps.setComposerNotice("回答已恢复,已显示在对话区末尾。"); + void deps.refreshAccount(); + return; + } + deps.pendingConsultation.current = null; + deps.setPendingSessionId(null); + deps.setPendingRequestId(null); + deps.setConsultationPhase(null); + deps.setStreamingReply(null); + deps.setRequestError(null); + deps.setComposerNotice("回答已取消;问题仍保留在聊天记录中,可重新发送。"); + } catch (caught) { + if (signal.aborted) return; + if (caught instanceof ConsultationStatusError && caught.status === 404) { + deps.consultationStatusMissingCount.current += 1; + if (deps.consultationStatusMissingCount.current === 1) { + deps.setComposerNotice("正在确认本次咨询请求是否已开始…"); + return; + } + if (deps.consultationReplayStarted.current !== deps.pendingRequestId) { + deps.consultationReplay.current(); + return; + } + if (deps.consultationStatusMissingCount.current >= 4) { + deps.pendingConsultation.current = null; + deps.setPendingSessionId(null); + deps.setPendingRequestId(null); + deps.setConsultationPhase(null); + deps.setStreamingReply(null); + deps.setRequestError({ + sessionId: deps.pendingSessionId, + message: "后台未找到本次咨询请求,请重新发送。", + }); + deps.setComposerNotice("后台未找到本次咨询请求,已停止恢复,请重新发送。"); + return; + } + deps.setComposerNotice("正在重新发起本次咨询…"); + return; + } + deps.consultationStatusMissingCount.current = 0; + deps.setComposerNotice(online() + ? "回答仍在后台生成,正在自动恢复。" + : "网络已断开,回答仍在后台生成;联网后会自动恢复。"); + } finally { + polling = false; + if (!signal.aborted && deps.pendingConsultation.current?.phase === "recovering") { + timer = window.setTimeout(() => void poll(), 1_750); + } + } + }; + + const wakeup = () => { + window.clearTimeout(timer); + void poll(); + }; + const clearOnAbort = () => { + window.clearTimeout(timer); + }; + if (signal.aborted) clearOnAbort(); + else signal.addEventListener("abort", clearOnAbort); + if (deps.consultationStatusMissingCount.current > 0) { + timer = window.setTimeout(() => void poll(), 1_750); + } else { + void poll(); + } + return wakeup; +} diff --git a/frontend/src/lib/home-bootstrap-run.ts b/frontend/src/lib/home-bootstrap-run.ts new file mode 100644 index 00000000..0df2cf31 --- /dev/null +++ b/frontend/src/lib/home-bootstrap-run.ts @@ -0,0 +1,475 @@ +/** + * Home cloud bootstrap. No React hooks: Home owns one effect and passes setters. + * + * The 20s timeout aborts this run and reveals the fatal screen. A login redirect + * leaves that timer running so a failed navigation can still escape the spinner. + */ + +import type { BirthTimeAssessmentPhase } from "@/components/birth-time-assessment-overlay"; +import { + guidedBirthTimePreview, + isGuidedBirthTimePreview, + previewRectificationJourney, +} from "@/lib/birth-time-guided-preview"; +import { hydrateDeclaredWindowDraft } from "@/lib/birth-time-intake-model"; +import type { JourneyClientResponse } from "@/lib/birth-time-journey-client"; +import { + SESSION_MISSING_NOTICE, + clearLoginSessionReturn, + writeSessionUrl, + type BootstrapSessionSelection, +} from "@/lib/chat-session-url"; +import { createStartGreeting } from "@/lib/starter-greeting"; +import { + HOME_BOOTSTRAP_SLOW_MS, + resolveStarterHomeLandingSessionId, + starterHomeLandingNeedsConsultation, + type BootstrapPhase, +} from "@/lib/home-bootstrap"; +import { + ConsultationStatusError, + LoginRedirectError, + applyLegacySessionControls, + createSession, + fetchActiveConsultationStatus, + fetchConsultationStatus, + fetchModelCatalog, + fetchSessionDetail, + friendlyError, + patchSessionModel, + readSessions, + readStoredPendingConsultation, + redirectToLogin, + resolveLookupBootstrap, +} from "@/lib/home-cloud-sync"; +import { chartSnapshotForSession, missingProfileStep, readProfile } from "@/lib/home-profile"; +import { + emptyProfile, + pendingConsultationStorageKey, + previewModelCatalog, + timestamp, + type Account, + type ChatSession, + type ConsultationStatus, + type OnboardingStep, + type Profile, + type SessionReadResult, + type StoredPendingConsultation, +} from "@/lib/home-types"; +import type { PublicLanguageModelCatalog } from "@/lib/public-models"; +import { dropRectificationStoredPending } from "@/lib/rectification-session-composer-guard"; +import type { SessionListBoot } from "@/lib/session-list-context"; +import { isUnsavedEmptyConsultation } from "@/lib/session-list-filter"; +import { clearStaleClientReload } from "@/lib/stale-client-recovery"; + +export const HOME_BOOTSTRAP_TIMEOUT_MESSAGE = "连接云端服务超时。请检查网络后重试,或返回登录页重新建立会话。"; + +export type HomeBootstrapStorage = Pick; + +export type HomeBootstrapCommit = { + setAccount: (account: Account) => void; + setAccountError: (value: string) => void; + setActiveSessionId: (id: string) => void; + setBirthTimeAssessmentPhase: (phase: BirthTimeAssessmentPhase | null) => void; + setBirthTimeJourney: (journey: JourneyClientResponse | null) => void; + setBootstrapPhase: (phase: BootstrapPhase) => void; + setGuidedJourneyPreview: (value: boolean) => void; + setHydrated: (value: boolean) => void; + setModelCatalog: (catalog: PublicLanguageModelCatalog | null) => void; + setOnboardingStep: (step: OnboardingStep) => void; + setProfile: (profile: Profile) => void; + setProfileDraft: (profile: Profile) => void; + setSessions: (sessions: ChatSession[]) => void; + setSessionsCursor: (cursor: string | null) => void; + setStartGreeting: (value: string) => void; +}; + +export type HomeBootstrapIo = { + fetchModelCatalog: typeof fetchModelCatalog; + fetchActiveConsultationStatus: typeof fetchActiveConsultationStatus; + fetchConsultationStatus: typeof fetchConsultationStatus; + fetchSessionDetail: typeof fetchSessionDetail; + resolveLookupBootstrap: typeof resolveLookupBootstrap; + patchSessionModel: typeof patchSessionModel; + redirectToLogin: typeof redirectToLogin; + clearStaleClientReload: typeof clearStaleClientReload; + writeSessionUrl: typeof writeSessionUrl; + clearLoginSessionReturn: typeof clearLoginSessionReturn; + restoreConsultationRecovery: ( + session: ChatSession, + requestId: string, + stored?: StoredPendingConsultation | null, + ) => void; +}; + +export type HomeBootstrapDeps = { + sessionListReady: Promise; + sessionListBoot: () => SessionListBoot | null; + uiPreview: { current: boolean }; + uiPreviewMode: { current: string | null }; + consultationStatusMissingCount: { current: number }; + isDevelopment: boolean; + readSearch: () => string; + storage: HomeBootstrapStorage; + restoreConsultationRecovery: HomeBootstrapIo["restoreConsultationRecovery"]; + setComposerNotice: (notice: string) => void; + commit: HomeBootstrapCommit; + io?: Partial; +}; + +type AccountPhaseReady = { + kind: "ready"; + nextAccount: Account; + nextModelCatalog: PublicLanguageModelCatalog | null; + nextProfile: Profile; + nextSessions: ChatSession[]; + parsedSessions: SessionReadResult; + reservedConsultation: ConsultationStatus | null; + storedPending: StoredPendingConsultation | null; + cursor: string | null; +}; + +type AccountPhaseResult = AccountPhaseReady | { kind: "stop" }; + +const defaultIo: Omit = { + fetchModelCatalog, + fetchActiveConsultationStatus, + fetchConsultationStatus, + fetchSessionDetail, + resolveLookupBootstrap, + patchSessionModel, + redirectToLogin, + clearStaleClientReload, + writeSessionUrl, + clearLoginSessionReturn, +}; + +function resolveIo(deps: HomeBootstrapDeps): HomeBootstrapIo { + return { + ...defaultIo, + ...deps.io, + restoreConsultationRecovery: deps.io?.restoreConsultationRecovery ?? deps.restoreConsultationRecovery, + }; +} + +export async function runAccountPhase( + deps: HomeBootstrapDeps, + signal: AbortSignal, +): Promise { + const io = resolveIo(deps); + const previewMode = deps.isDevelopment + ? new URLSearchParams(deps.readSearch()).get("preview") + : null; + if (previewMode) { + deps.uiPreview.current = true; + deps.uiPreviewMode.current = previewMode; + deps.commit.setGuidedJourneyPreview(true); + if (previewMode === "error") { + deps.commit.setAccountError(HOME_BOOTSTRAP_TIMEOUT_MESSAGE); + deps.commit.setHydrated(true); + return { kind: "stop" }; + } + const isAssessmentLoadingPreview = previewMode === "birth-time-assessment-loading"; + const isCompletedCandidatePreview = previewMode === "birth-time-candidate-complete"; + const isRectificationPreview = isGuidedBirthTimePreview(previewMode); + const previewJourney = isRectificationPreview + ? guidedBirthTimePreview(previewMode) + : previewRectificationJourney; + const previewProfile: Profile = previewMode === "onboarding" + ? emptyProfile + : { + ...emptyProfile, + name: "林遥", + date: "1990-06-15", + time: isCompletedCandidatePreview ? "04:53" : isRectificationPreview || isAssessmentLoadingPreview ? "" : "12:30", + reportedTime: isRectificationPreview ? "14:30" : isAssessmentLoadingPreview || isCompletedCandidatePreview ? "" : "12:30", + birthTimeSource: isRectificationPreview ? "approximate" : isAssessmentLoadingPreview || isCompletedCandidatePreview ? "period_only" : "legacy_import", + ...hydrateDeclaredWindowDraft({ + period: isAssessmentLoadingPreview || isCompletedCandidatePreview ? "early_morning" : "", + }), + birthTimeClue: "", + uncertaintyBeforeMinutes: isRectificationPreview ? 30 : null, + uncertaintyAfterMinutes: isRectificationPreview ? 30 : null, + birthTimeStatus: isCompletedCandidatePreview + ? "candidate" + : isAssessmentLoadingPreview + ? "rectifying" + : previewJourney.snapshot.state === "candidate" + || previewJourney.snapshot.state === "confirming" + || previewJourney.snapshot.state === "ready" + ? "candidate" + : isRectificationPreview ? "rectifying" : "confirmed", + rectificationCaseId: isRectificationPreview ? previewJourney.caseId : "", + countryCode: "CN", + provinceCode: "110000", + cityCode: "110000-city", + districtCode: "110101", + }; + const previewMessages = previewMode === "conversation" || previewMode === "streaming" || previewMode === "partial" + ? [ + { role: "user" as const, text: "未来半年是否适合换工作?" }, + { role: "assistant" as const, text: "可以先看职业方向、关键时间。\n同时评估现实风险。\n此处只展示本地预览,\n不调用真实星盘。" }, + ] + : []; + const previewSession: ChatSession = { + id: "preview-session", + title: previewMessages.length > 0 ? "未来半年是否适合换工作" : "新对话", + theme: "career", + modelId: previewModelCatalog.defaultModelId, + messages: previewMessages, + updatedAt: timestamp(), + sessionType: "consultation", + rectificationCaseId: null, + chartProfileId: "self", + chartProfileName: previewProfile.name.trim() || "我", + chartProfileRole: "self", + pinned: false, + archivedAt: null, + messagesHydrated: true, + }; + deps.commit.setAccount({ + user: { id: "preview-user", email: "preview@local.test" }, + avatar: null, + credits: 8, + isAdmin: false, + adminUrl: null, + rectificationPriceCredits: 1, + hasConfirmedBirthTime: previewProfile.birthTimeStatus === "confirmed", + hasUsableBirthTime: previewProfile.birthTimeStatus === "accepted" || previewProfile.birthTimeStatus === "confirmed", + activeSubscription: null, + profile: previewProfile, + }); + deps.commit.setModelCatalog(previewModelCatalog); + deps.commit.setProfile(previewProfile); + deps.commit.setProfileDraft(previewProfile); + if (isRectificationPreview) { + deps.commit.setBirthTimeJourney(previewJourney); + } + if (isAssessmentLoadingPreview) { + deps.commit.setBirthTimeAssessmentPhase("assessing"); + } + deps.commit.setOnboardingStep(isAssessmentLoadingPreview ? "birth" : missingProfileStep(previewProfile) ?? "name"); + deps.commit.setSessions([previewSession]); + deps.commit.setActiveSessionId(previewSession.id); + const previewGreeting = previewProfile.name.trim() ? createStartGreeting(previewProfile.name) : ""; + deps.commit.setStartGreeting(previewGreeting); + deps.commit.setHydrated(true); + return { kind: "stop" }; + } + + // Start independent reads before waiting for the provider so slow-network + // latency is spent in parallel, not as a serial account -> model chain. + const modelCatalogPromise = io.fetchModelCatalog(signal) + .then((catalog) => ({ catalog, error: null as unknown })) + .catch((error: unknown) => ({ catalog: null, error })); + const activeStatusPromise = io.fetchActiveConsultationStatus(signal).catch((caught: unknown) => { + if (caught instanceof Error && caught.name === "AbortError") throw caught; + return null; + }); + await deps.sessionListReady; + if (signal.aborted) return { kind: "stop" }; + const listBoot = deps.sessionListBoot(); + if (listBoot?.signedOut || !listBoot?.account) { + io.redirectToLogin(); + return { kind: "stop" }; + } + const nextAccount = listBoot.account; + const modelCatalogResult = await modelCatalogPromise; + if (modelCatalogResult.error) throw modelCatalogResult.error; + const nextModelCatalog = modelCatalogResult.catalog; + const nextProfile = readProfile(nextAccount.profile); + const parsedSessions = readSessions(listBoot.rawRows, nextModelCatalog); + let nextSessions = parsedSessions.sessions.length > 0 ? parsedSessions.sessions : listBoot.sessions; + if (nextSessions.length === 0) { + nextSessions = [createSession( + nextModelCatalog?.defaultModelId ?? "", + "consultation", + chartSnapshotForSession("self", [], nextProfile), + )]; + } + nextSessions = applyLegacySessionControls(nextAccount.user.id, nextSessions); + + let reservedConsultation: ConsultationStatus | null = await activeStatusPromise; + const storedPending = dropRectificationStoredPending( + readStoredPendingConsultation(deps.storage.getItem(pendingConsultationStorageKey), nextSessions.map((session) => session.id)), + nextSessions, + ); + if (!storedPending) deps.storage.removeItem(pendingConsultationStorageKey); + + if (storedPending) { + try { + const status = await io.fetchConsultationStatus( + storedPending.sessionId, + storedPending.requestId, + signal, + ); + if (status.status === "reserved") { + deps.consultationStatusMissingCount.current = 0; + reservedConsultation = status; + } else { + deps.storage.removeItem(pendingConsultationStorageKey); + reservedConsultation = null; + } + } catch (caught) { + if (caught instanceof Error && caught.name === "AbortError") throw caught; + deps.consultationStatusMissingCount.current = caught instanceof ConsultationStatusError && caught.status === 404 ? 1 : 0; + reservedConsultation = { + sessionId: storedPending.sessionId, + requestId: storedPending.requestId, + status: "reserved", + }; + } + } + + if (signal.aborted) return { kind: "stop" }; + return { + kind: "ready", + nextAccount, + nextModelCatalog, + nextProfile, + nextSessions, + parsedSessions, + reservedConsultation, + storedPending, + cursor: listBoot.cursor, + }; +} + +export async function resolveLanding( + deps: HomeBootstrapDeps, + signal: AbortSignal, + ready: AccountPhaseReady, +): Promise { + const io = resolveIo(deps); + const { writeSessionUrl, clearLoginSessionReturn, restoreConsultationRecovery } = io; + const lookedUp = await io.resolveLookupBootstrap({ + sessions: ready.nextSessions, + catalog: ready.nextModelCatalog, + defaultSessionId: ready.nextSessions[0].id, + signal, + }); + let nextSessions = lookedUp.sessions; + const bootstrapSelection: BootstrapSessionSelection = lookedUp.selection; + let landingSessionId = resolveStarterHomeLandingSessionId( + nextSessions, + bootstrapSelection.sessionId, + bootstrapSelection.urlAction, + ); + if (starterHomeLandingNeedsConsultation(nextSessions, landingSessionId, bootstrapSelection.urlAction)) { + const homeSession = createSession( + ready.nextModelCatalog?.defaultModelId ?? "", + "consultation", + chartSnapshotForSession("self", [], ready.nextProfile), + ); + nextSessions = [homeSession, ...nextSessions]; + landingSessionId = homeSession.id; + } + const activeListed = nextSessions.find((session) => session.id === landingSessionId) + ?? nextSessions[0]; + if (activeListed && !activeListed.messagesHydrated && activeListed.sessionType === "consultation") { + try { + const detailed = await io.fetchSessionDetail(activeListed.id, ready.nextModelCatalog, signal); + if (detailed) { + nextSessions = nextSessions.map((session) => session.id === detailed.id ? detailed : session); + } + } catch (caught) { + if (caught instanceof Error && caught.name === "AbortError") throw caught; + if (caught instanceof LoginRedirectError) return; + } + } + if (signal.aborted) return; + io.clearStaleClientReload(deps.storage); + deps.commit.setSessionsCursor(ready.cursor); + deps.commit.setAccount(ready.nextAccount); + deps.commit.setModelCatalog(ready.nextModelCatalog); + deps.commit.setProfile(ready.nextProfile); + deps.commit.setProfileDraft(ready.nextProfile); + deps.commit.setStartGreeting(ready.nextProfile.name.trim() ? createStartGreeting(ready.nextProfile.name) : ""); + deps.commit.setOnboardingStep(missingProfileStep(ready.nextProfile) ?? "name"); + deps.commit.setSessions(nextSessions); + if (bootstrapSelection.urlAction === "new-chat") writeSessionUrl(null, "replace"); + deps.commit.setActiveSessionId(landingSessionId); + if (bootstrapSelection.clearStoredReturn) clearLoginSessionReturn(); + if (bootstrapSelection.urlAction === "replace-clear") writeSessionUrl(null, "replace"); + if (bootstrapSelection.urlAction === "replace-selected") { + const selected = nextSessions.find((session) => session.id === bootstrapSelection.sessionId); + if (!selected || !isUnsavedEmptyConsultation(selected)) { + writeSessionUrl(bootstrapSelection.sessionId, "replace"); + } + } + const reservedConsultation = ready.reservedConsultation; + const storedPending = ready.storedPending; + if (reservedConsultation?.status === "reserved") { + const recoverySession = nextSessions.find((session) => session.id === reservedConsultation.sessionId); + if (recoverySession) restoreConsultationRecovery(recoverySession, reservedConsultation.requestId, storedPending); + if (bootstrapSelection.urlAction === "new-chat") { + deps.commit.setActiveSessionId(landingSessionId); + deps.setComposerNotice(""); + } + } + if (ready.parsedSessions.fallbackSessionIds.length > 0) { + deps.setComposerNotice("此前选择的模型已下线,已切换为默认模型。"); + } else if (lookedUp.notice) { + deps.setComposerNotice(lookedUp.notice); + } else if (bootstrapSelection.missing) { + deps.setComposerNotice(SESSION_MISSING_NOTICE); + } + deps.commit.setAccountError(""); + + const fallbackCatalog = ready.nextModelCatalog; + if (fallbackCatalog && ready.parsedSessions.fallbackSessionIds.length > 0) { + try { + await Promise.all(ready.parsedSessions.fallbackSessionIds.map((sessionId) => + io.patchSessionModel(sessionId, fallbackCatalog.defaultModelId, signal), + )); + } catch { + if (!signal.aborted) { + deps.setComposerNotice("已在当前页面切换为默认模型,但云端同步失败;刷新后可能需要重新选择。"); + } + } + } +} + +export async function runHomeBootstrap(deps: HomeBootstrapDeps, signal: AbortSignal): Promise { + const controller = new AbortController(); + const onParentAbort = () => controller.abort(); + if (signal.aborted) onParentAbort(); + else signal.addEventListener("abort", onParentAbort); + + const bootstrapTimeout = window.setTimeout(() => { + if (controller.signal.aborted) return; + controller.abort(); + deps.commit.setAccountError(HOME_BOOTSTRAP_TIMEOUT_MESSAGE); + deps.commit.setHydrated(true); + }, HOME_BOOTSTRAP_SLOW_MS); + const clearTimeoutOnParentAbort = () => { + window.clearTimeout(bootstrapTimeout); + }; + if (signal.aborted) clearTimeoutOnParentAbort(); + else signal.addEventListener("abort", clearTimeoutOnParentAbort); + + let redirectedToLogin = false; + let bootstrapFailed = false; + try { + const ready = await runAccountPhase(deps, controller.signal); + if (ready.kind === "ready") await resolveLanding(deps, controller.signal, ready); + } catch (caught) { + if (caught instanceof LoginRedirectError) { + redirectedToLogin = true; + return; + } + if ((caught as Error).name !== "AbortError" && !controller.signal.aborted) { + bootstrapFailed = true; + deps.commit.setAccountError(friendlyError(caught instanceof Error ? caught.message : "暂时无法读取云端数据")); + } + } finally { + if (redirectedToLogin) return; + signal.removeEventListener("abort", onParentAbort); + signal.removeEventListener("abort", clearTimeoutOnParentAbort); + window.clearTimeout(bootstrapTimeout); + if (!controller.signal.aborted) { + if (bootstrapFailed) deps.commit.setHydrated(true); + else deps.commit.setBootstrapPhase("prepare"); + } + } +} diff --git a/frontend/tests/chat-navigation-a11y-contract.test.ts b/frontend/tests/chat-navigation-a11y-contract.test.ts index a11fefe1..02f4760c 100644 --- a/frontend/tests/chat-navigation-a11y-contract.test.ts +++ b/frontend/tests/chat-navigation-a11y-contract.test.ts @@ -197,13 +197,23 @@ test("toast-owned and alert-owned events are deliberately silent in the status r }); test("the recovered background reply tells the reader where it landed", () => { - assert.equal(pageSource.match(/回答已恢复,已显示在对话区末尾。/g)?.length, 2); + // 原值: homeSurface 里两处「回答已恢复,已显示在对话区末尾。」 + // 新值: use-consultation-run 与 consultation-recovery-poll 各一处。 + // 原因: 恢复轮询搬出 page.tsx,两处文案都还在,没有改成更短的「回答已恢复。」 + const poll = readFileSync(new URL("../src/lib/consultation-recovery-poll.ts", import.meta.url), "utf8"); + const hook = readFileSync(new URL("../src/hooks/use-consultation-run.ts", import.meta.url), "utf8"); + assert.equal(poll.match(/回答已恢复,已显示在对话区末尾。/g)?.length, 1); + assert.equal(hook.match(/回答已恢复,已显示在对话区末尾。/g)?.length, 1); assert.doesNotMatch(pageSource, /setComposerNotice\("回答已恢复。"\)/); + assert.doesNotMatch(poll, /setComposerNotice\("回答已恢复。"\)/); }); test("assertive announcement is reserved for genuine errors", () => { - // Given: the bootstrap failure screen is the only assertive surface on the page. - assert.match(pageSource, /
\n\s*
/); + // 原值: page.tsx / homeSurface 内联 `
`。 + // 新值: 同一段在 home-bootstrap-error.tsx。 + // 原因: 错误屏抽成组件后仍是唯一的 alert,页面没有 assertive 直播区。 + const bootstrapError = readFileSync(new URL("../src/components/home-bootstrap-error.tsx", import.meta.url), "utf8"); + assert.match(bootstrapError, /
\n\s*
/); assert.doesNotMatch(pageSource, /aria-live="assertive"/); // And: the still-loading screen stays polite and marks itself busy. diff --git a/frontend/tests/chat-session-url.test.ts b/frontend/tests/chat-session-url.test.ts index a952e365..a55144a0 100644 --- a/frontend/tests/chat-session-url.test.ts +++ b/frontend/tests/chat-session-url.test.ts @@ -19,6 +19,7 @@ import { } from "../src/lib/chat-session-url.ts"; import { homeSurface as page } from "./home-surface.ts"; +const bootstrapRun = readFileSync(new URL("../src/lib/home-bootstrap-run.ts", import.meta.url), "utf8"); const login = readFileSync(new URL("../src/components/email-otp-login.tsx", import.meta.url), "utf8"); const loginPage = readFileSync(new URL("../src/app/login/page.tsx", import.meta.url), "utf8"); const sessionA = "11111111-1111-4111-8111-111111111111"; @@ -71,7 +72,10 @@ test("persisting a new chat replaces the intent with the saved session id", () = }); test("new chat bootstrap consumes the URL before activation and reuses local-only creation", () => { - const bootstrap = sourceBetween(page, "async function loadCloudData()", "void loadCloudData();"); + // 原值: 从 homeSurface 里切 `async function loadCloudData()`。 + // 新值: 切 home-bootstrap-run.ts 的 resolveLanding。 + // 原因: 落点代码原样搬家,清 URL 仍在激活会话之前,且只本地创建一条咨询。 + const bootstrap = sourceBetween(bootstrapRun, "export async function resolveLanding(", "export async function runHomeBootstrap("); const clearIntent = 'if (bootstrapSelection.urlAction === "new-chat") writeSessionUrl(null, "replace");'; assert.ok(bootstrap.includes(clearIntent)); assert.ok(bootstrap.indexOf(clearIntent) < bootstrap.indexOf("setActiveSessionId(landingSessionId)")); @@ -173,8 +177,11 @@ test("default bootstrap selection does not write a session URL", () => { }, ); - const bootstrap = sourceBetween(page, "async function loadCloudData()", "void loadCloudData();"); - assert.match(bootstrap, /defaultSessionId: nextSessions\[0\]\.id/); + // 原值: 从 homeSurface 里切 `async function loadCloudData()`。 + // 新值: 切 resolveLanding。 + // 原因: 默认落点不再写 push URL,缺失会话仍提示 SESSION_MISSING_NOTICE。 + const bootstrap = sourceBetween(bootstrapRun, "export async function resolveLanding(", "export async function runHomeBootstrap("); + assert.match(bootstrap, /defaultSessionId: ready\.nextSessions\[0\]\.id/); assert.match(bootstrap, /setActiveSessionId\(landingSessionId\)/); assert.match(bootstrap, /urlAction === "replace-clear"/); assert.match(bootstrap, /urlAction === "replace-selected"/); @@ -281,16 +288,22 @@ test("home stays a client-read query on a static route", () => { // 新值:`await sessionListReady` 后读 `sessionListBoot()`,page.tsx 不再 `fetchSessions(` // 原因:T2 列表由 layout provider 拉一次;本条仍守「`/` 是静态路由上的客户端读」 const pageFile = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"); + // 原值: 从 page.tsx 切 `if (previewMode)` 到 `await sessionListReady`。 + // 新值: 预览分支在 home-bootstrap-run.ts;首页把 sessionListReady / sessionListBoot 交给它。 + // 原因: 预览仍不写会话 URL,`/` 仍是静态路由上的客户端读,不在首页里 fetchSessions。 const preview = sourceBetween( - pageFile, + bootstrapRun, "if (previewMode) {", - "await sessionListReady;", + "await deps.sessionListReady;", ); assert.doesNotMatch(preview, /writeSessionUrl|persistLoginSessionReturn|readLoginSessionReturn/); - assert.match(pageFile, /await sessionListReady;/); - assert.match(pageFile, /sessionListBoot\(\)/); + assert.match(bootstrapRun, /await deps\.sessionListReady;/); + assert.match(bootstrapRun, /deps\.sessionListBoot\(\)/); + assert.match(pageFile, /sessionListReady,/); + assert.match(pageFile, /sessionListBoot,/); assert.doesNotMatch(pageFile, /fetchSessions\(/); - assert.match(page, /SESSION_MISSING_NOTICE/); + assert.doesNotMatch(bootstrapRun, /fetchSessions\(/); + assert.match(bootstrapRun, /SESSION_MISSING_NOTICE/); assert.equal(SESSION_MISSING_NOTICE, "该对话不存在或已被删除"); }); diff --git a/frontend/tests/consultation-recovery-poll.test.ts b/frontend/tests/consultation-recovery-poll.test.ts new file mode 100644 index 00000000..475d4edd --- /dev/null +++ b/frontend/tests/consultation-recovery-poll.test.ts @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { runConsultationRecoveryPoll, type ConsultationRecoveryPollDeps } from "../src/lib/consultation-recovery-poll.ts"; +import { ConsultationStatusError } from "../src/lib/home-cloud-sync.ts"; +import { previewModelCatalog } from "../src/lib/home-types.ts"; +import { withFakeTimers } from "./home-bootstrap-fake-timers.ts"; + +const sessionId = "11111111-1111-4111-8111-111111111111"; +const requestId = "22222222-2222-4222-8222-222222222222"; + +async function flush() { + for (let step = 0; step < 8; step += 1) await Promise.resolve(); +} + +function createPoll(overrides: { + fetchConsultationStatus: ConsultationRecoveryPollDeps["fetchConsultationStatus"]; + missing?: number; +}) { + const state = { + notice: "", + phase: "recovering" as "recovering" | null, + pendingSession: sessionId as string | null, + pendingRequest: requestId as string | null, + replay: 0, + error: "", + refreshed: 0, + }; + const deps: ConsultationRecoveryPollDeps = { + pendingSessionId: sessionId, + pendingRequestId: requestId, + modelCatalog: previewModelCatalog, + consultationStatusMissingCount: { current: overrides.missing ?? 0 }, + consultationReplayStarted: { current: null }, + consultationReplay: { + current: () => { + state.replay += 1; + deps.consultationReplayStarted.current = requestId; + }, + }, + pendingConsultation: { current: { phase: "recovering" } }, + setComposerNotice: (notice) => { state.notice = notice; }, + setSessions: () => undefined, + setActiveSessionId: () => undefined, + setPendingSessionId: (id) => { state.pendingSession = id; }, + setPendingRequestId: (id) => { state.pendingRequest = id; }, + setConsultationPhase: (phase) => { state.phase = phase === "recovering" ? "recovering" : null; }, + setStreamingReply: () => undefined, + setRequestError: (value) => { state.error = value?.message ?? ""; }, + refreshAccount: () => { state.refreshed += 1; }, + online: () => true, + fetchConsultationStatus: overrides.fetchConsultationStatus, + fetchSessionDetail: async () => null, + }; + return { deps, state }; +} + +test("the first 404 confirms the request and the next 404 replays it once", async () => { + await withFakeTimers(async (tick) => { + const { deps, state } = createPoll({ + fetchConsultationStatus: async () => { throw new ConsultationStatusError(404, "missing"); }, + }); + runConsultationRecoveryPoll(deps, new AbortController().signal); + await flush(); + assert.equal(deps.consultationStatusMissingCount.current, 1); + assert.equal(state.notice, "正在确认本次咨询请求是否已开始…"); + assert.equal(state.replay, 0); + assert.equal(state.phase, "recovering"); + + tick(1_750); + await flush(); + assert.equal(deps.consultationStatusMissingCount.current, 2); + assert.equal(state.replay, 1); + assert.equal(state.phase, "recovering"); + }); +}); + +test("the fourth missing status gives up and stops polling", async () => { + await withFakeTimers(async (tick) => { + const { deps, state } = createPoll({ + fetchConsultationStatus: async () => { throw new ConsultationStatusError(404, "missing"); }, + }); + runConsultationRecoveryPoll(deps, new AbortController().signal); + await flush(); + tick(1_750); + await flush(); + tick(1_750); + await flush(); + assert.equal(deps.consultationStatusMissingCount.current, 3); + assert.equal(state.notice, "正在重新发起本次咨询…"); + assert.equal(state.replay, 1); + + tick(1_750); + await flush(); + assert.equal(deps.consultationStatusMissingCount.current, 4); + assert.equal(state.pendingSession, null); + assert.equal(state.pendingRequest, null); + assert.equal(state.phase, null); + assert.equal(state.error, "后台未找到本次咨询请求,请重新发送。"); + assert.equal(state.notice, "后台未找到本次咨询请求,已停止恢复,请重新发送。"); + const timersBefore = state.notice; + tick(1_750); + await flush(); + assert.equal(state.notice, timersBefore); + assert.equal(deps.consultationStatusMissingCount.current, 4); + }); +}); + +test("a reserved status clears the missing count", async () => { + await withFakeTimers(async (tick) => { + const { deps, state } = createPoll({ + missing: 2, + fetchConsultationStatus: async () => ({ + sessionId, + requestId, + status: "reserved", + }), + }); + runConsultationRecoveryPoll(deps, new AbortController().signal); + await flush(); + assert.equal(deps.consultationStatusMissingCount.current, 2); + tick(1_750); + await flush(); + assert.equal(deps.consultationStatusMissingCount.current, 0); + assert.equal(state.notice, "回答仍在后台生成,正在自动恢复。"); + assert.equal(state.phase, "recovering"); + }); +}); diff --git a/frontend/tests/consultation-recovery.test.ts b/frontend/tests/consultation-recovery.test.ts index 9d22c703..79aef329 100644 --- a/frontend/tests/consultation-recovery.test.ts +++ b/frontend/tests/consultation-recovery.test.ts @@ -2,6 +2,10 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; +import { runConsultationRecoveryPoll } from "../src/lib/consultation-recovery-poll.ts"; +import { ConsultationStatusError, createSession } from "../src/lib/home-cloud-sync.ts"; +import { runHomeBootstrap, type HomeBootstrapDeps } from "../src/lib/home-bootstrap-run.ts"; +import { emptyProfile, pendingConsultationStorageKey, previewModelCatalog, type Account } from "../src/lib/home-types.ts"; import { homeSurface as source } from "./home-surface.ts"; const pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"); @@ -64,73 +68,174 @@ test("explicit consultation HTTP failures unlock instead of entering recovery", }); test("reserved consultations recover through the status endpoint", () => { + const bootstrapRun = readFileSync(new URL("../src/lib/home-bootstrap-run.ts", import.meta.url), "utf8"); + const poll = readFileSync(new URL("../src/lib/consultation-recovery-poll.ts", import.meta.url), "utf8"); assert.match(source, /\/api\/consult\/status\?sessionId=\$\{encodeURIComponent\(sessionId\)\}&requestId=\$\{encodeURIComponent\(requestId\)\}/); assert.match(source, /fetch\("\/api\/consult\/status", \{ signal, cache: "no-store" \}\)/); - assert.match(source, /reservedConsultation\?\.status === "reserved"[\s\S]*nextSessions\.find\(\(session\) => session\.id === reservedConsultation\.sessionId\)/); + // 原值: 预留判断、1750ms 轮询和完成后的 fetchSessionDetail 都在 page.tsx。 + // 新值: 预留判断在 home-bootstrap-run;轮询间隔和详情读取在 consultation-recovery-poll。 + // 原因: 两段流程搬出首页。状态端点、1750ms、完成后拉详情都还在。 + assert.match(bootstrapRun, /reservedConsultation\?\.status === "reserved"[\s\S]*nextSessions\.find\(\(session\) => session\.id === reservedConsultation\.sessionId\)/); assert.match(source, /status\?\.status !== "reserved"[\s\S]*sessions\.find\(\(item\) => item\.id === status\.sessionId\)/); assert.match(source, /status: "reserved" \| "completed" \| "cancelled"/); assert.match(source, /readonly responseMessage\?: unknown/); assert.match(source, /phase: "recovering"/); - assert.match(source, /window\.setTimeout\(\(\) => void poll\(\), 1_750\)/); - assert.match(source, /status\.status === "completed"[\s\S]*?fetchSessionDetail\(pendingSessionId, modelCatalog, controller\.signal\)/); + assert.match(poll, /window\.setTimeout\(\(\) => void poll\(\), 1_750\)/); + assert.match(poll, /status\.status === "completed"[\s\S]*?fetchDetail\(deps\.pendingSessionId, deps\.modelCatalog, signal\)/); assert.match(source, /window\.addEventListener\("online", onOnline\)/); assert.match(source, /window\.addEventListener\("pageshow", onPageShow\)/); assert.match(source, /网络已断开,回答仍在后台生成;联网后会自动恢复。/); }); -test("a missing reservation is replayed once instead of being polled until the question is lost", () => { +test("a missing reservation is replayed once instead of being polled until the question is lost", async () => { + // 原值: 按 page.tsx 恢复 effect 的源码切片匹配 404、重放和第 4 次放弃。 + // 新值: 直接调用 runConsultationRecoveryPoll。逐步假计时器在 consultation-recovery-poll.test.ts。 + // 原因: 轮询已搬出;这里确认第一次 404 只确认、不立刻丢掉问题。 assert.match(source, /class ConsultationStatusError extends Error[\s\S]*readonly status: number/); assert.match(source, /throw new ConsultationStatusError\([\s\S]*response\.status/); - const recoveryEffect = source.slice( - source.indexOf('if (consultationPhase !== "recovering"'), - source.indexOf("}, [consultationPhase, modelCatalog, pendingRequestId, pendingSessionId])"), - ); assert.match(source, /const consultationStatusMissingCount = useRef\(0\)/); assert.match(source, /const consultationReplayStarted = useRef\(null\)/); - assert.match(recoveryEffect, /status\.status === "reserved"[\s\S]*consultationStatusMissingCount\.current = 0/); - assert.match(recoveryEffect, /caught instanceof ConsultationStatusError && caught\.status === 404[\s\S]*consultationStatusMissingCount\.current \+= 1/); - assert.match(recoveryEffect, /consultationStatusMissingCount\.current === 1[\s\S]*正在确认本次咨询请求是否已开始/); - assert.match(recoveryEffect, /consultationReplayStarted\.current !== pendingRequestId[\s\S]*consultationReplay\.current\(\)/); - assert.match(recoveryEffect, /consultationStatusMissingCount\.current >= 4[\s\S]*setPendingSessionId\(null\)[\s\S]*setPendingRequestId\(null\)[\s\S]*setConsultationPhase\(null\)/); - assert.match(recoveryEffect, /后台未找到本次咨询请求,已停止恢复,请重新发送。/); - assert.match(recoveryEffect, /consultationStatusMissingCount\.current = 0;[\s\S]*回答仍在后台生成,正在自动恢复。/); - assert.match(recoveryEffect, /consultationStatusMissingCount\.current > 0[\s\S]*window\.setTimeout\(\(\) => void poll\(\), 1_750\)[\s\S]*else \{[\s\S]*void poll\(\)/); + const host = globalThis as { window?: unknown }; + if (host.window === undefined) host.window = globalThis; + const missing = { current: 0 }; + let notice = ""; + let replayed = 0; + const controller = new AbortController(); + runConsultationRecoveryPoll({ + pendingSessionId: "11111111-1111-4111-8111-111111111111", + pendingRequestId: "22222222-2222-4222-8222-222222222222", + modelCatalog: previewModelCatalog, + consultationStatusMissingCount: missing, + consultationReplayStarted: { current: null }, + consultationReplay: { current: () => { replayed += 1; } }, + pendingConsultation: { current: { phase: "recovering" } }, + setComposerNotice: (value) => { notice = value; }, + setSessions: () => undefined, + setActiveSessionId: () => undefined, + setPendingSessionId: () => undefined, + setPendingRequestId: () => undefined, + setConsultationPhase: () => undefined, + setStreamingReply: () => undefined, + setRequestError: () => undefined, + refreshAccount: () => undefined, + online: () => true, + fetchConsultationStatus: async () => { throw new ConsultationStatusError(404, "missing"); }, + }, controller.signal); + try { + for (let step = 0; step < 8; step += 1) await Promise.resolve(); + assert.equal(missing.current, 1); + assert.equal(replayed, 0); + assert.equal(notice, "正在确认本次咨询请求是否已开始…"); + } finally { + controller.abort(); + } }); -test("tab-local pending ids drive strict bootstrap recovery before the global fallback", () => { - // 原值:切片从 `let reservedConsultation = null` 开始,依赖初始赋值形状。 - // 新值:以 recovery bootstrap 的稳定 pending-storage读取锚点切片。 - // 原因:T1/D2 把全局状态预取并行化,保留行为但改变初始化右值。 - const bootstrapStart = source.indexOf("const storedPending = dropRectificationStoredPending("); - const bootstrap = source.slice( - bootstrapStart, - source.indexOf("if (controller.signal.aborted) return;", bootstrapStart), - ); - // 原值:从拼接后的 `homeSurface` 中用两个跨文件 indexOf 边界切片, - // 边界可能落在不同源码文件,得到空字符串并掩盖真实行为。 - // 新值:直接读取 page.tsx,并以 pending storage effect 自身的稳定边界切片。 - // 原因:该行为只属于 page.tsx;测试应锁定 pendingSessionId/pendingRequestId 写入 - // sessionId/requestId/question 与无 pending 时清除,而不依赖文件拼接顺序。 +test("tab-local pending ids drive strict bootstrap recovery before the global fallback", async () => { + // 原值:从 page.tsx 切 storedPending 分支,并用源码顺序断言它先于全局状态。 + // 新值:调用 runHomeBootstrap。pending 仍覆盖全局 reserved;无 pending 时清除 storage key。 + // 原因:启动流程搬出后,源码切片不再落在 page.tsx;优先级改由结果锁定。 + const host = globalThis as { window?: unknown; localStorage?: Storage }; + if (host.window === undefined) host.window = globalThis; + if (!host.localStorage) { + const data = new Map(); + 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; }, + }; + } + const older = createSession(previewModelCatalog.defaultModelId); + const pendingSession = createSession(previewModelCatalog.defaultModelId); + const storedRequestId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + const removed: string[] = []; + let restored = ""; + const reader: Account = { + user: { id: "account-1", email: "reader@example.com" }, + avatar: null, + credits: 1, + isAdmin: false, + adminUrl: null, + rectificationPriceCredits: 1, + activeSubscription: null, + hasConfirmedBirthTime: false, + hasUsableBirthTime: false, + profile: emptyProfile, + }; + const deps: HomeBootstrapDeps = { + sessionListReady: Promise.resolve(), + sessionListBoot: () => ({ + sessions: [older, pendingSession], + rawRows: [], + cursor: null, + account: reader, + signedOut: false, + }), + uiPreview: { current: false }, + uiPreviewMode: { current: null }, + consultationStatusMissingCount: { current: 0 }, + isDevelopment: false, + readSearch: () => "", + storage: { + getItem: (key) => key === pendingConsultationStorageKey + ? JSON.stringify({ sessionId: pendingSession.id, requestId: storedRequestId, question: "继续" }) + : null, + removeItem: (key) => { removed.push(key); }, + }, + restoreConsultationRecovery: (session, requestId) => { restored = `${session.id}:${requestId}`; }, + setComposerNotice: () => undefined, + commit: { + setAccount: () => undefined, + setAccountError: () => undefined, + setActiveSessionId: () => undefined, + setBirthTimeAssessmentPhase: () => undefined, + setBirthTimeJourney: () => undefined, + setBootstrapPhase: () => undefined, + setGuidedJourneyPreview: () => undefined, + setHydrated: () => undefined, + setModelCatalog: () => undefined, + setOnboardingStep: () => undefined, + setProfile: () => undefined, + setProfileDraft: () => undefined, + setSessions: () => undefined, + setSessionsCursor: () => undefined, + setStartGreeting: () => undefined, + }, + io: { + fetchModelCatalog: async () => previewModelCatalog, + fetchActiveConsultationStatus: async () => ({ + sessionId: older.id, + requestId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + status: "reserved", + }), + fetchConsultationStatus: async () => ({ + sessionId: pendingSession.id, + requestId: storedRequestId, + status: "reserved", + }), + resolveLookupBootstrap: async (input) => ({ + selection: { sessionId: input.defaultSessionId, urlAction: "none", missing: false, clearStoredReturn: false }, + sessions: input.sessions, + notice: null, + }), + }, + }; + await runHomeBootstrap(deps, new AbortController().signal); + assert.equal(restored, `${pendingSession.id}:${storedRequestId}`); + assert.equal(removed.includes(pendingConsultationStorageKey), false); + + // 原值:从拼接后的 `homeSurface` 中用两个跨文件 indexOf 边界切片。 + // 新值:直接读取 page.tsx 的 pending storage effect。 + // 原因:写入 sessionId/requestId/question 仍留在首页,不随启动流程搬走。 const pendingStorageMarker = " if (pendingSessionId && pendingRequestId) {"; const storageEffectStart = pageSource.lastIndexOf(" useEffect(() => {", pageSource.indexOf(pendingStorageMarker)); const storageSync = pageSource.slice( storageEffectStart, pageSource.indexOf("\n useEffect(() => {", pageSource.indexOf(pendingStorageMarker)), ); - - assert.match(bootstrap, /readStoredPendingConsultation\([\s\S]*pendingConsultationStorageKey[\s\S]*nextSessions\.map\(\(session\) => session\.id\)/); - // 原值:`if (!storedPending && sessionStorage.getItem(...)) { sessionStorage.removeItem(...) }` - // 新值:`if (!storedPending) sessionStorage.removeItem(...)`,读存储前先 `dropRectificationStoredPending` - // 原因:校正会话上的 pending 不得进普通咨询恢复;无 pending 时直接清 key,不必再 getItem。 - assert.match(bootstrap, /dropRectificationStoredPending\([\s\S]*readStoredPendingConsultation/); - assert.match(bootstrap, /if \(!storedPending\) sessionStorage\.removeItem\(pendingConsultationStorageKey\)/); - assert.ok(bootstrap.indexOf("if (storedPending)") >= 0); - // 原值:`storedPending` 分支必须先于 `fetchActiveConsultationStatus`。 - // 新值:两者现在并行预取;pending 仍优先覆盖全局结果,不能再用源码顺序断言。 - // 原因:T1/D2 慢网下并行读取,减少首页串行等待,不改变恢复优先级。 - assert.match(bootstrap, /if \(storedPending\) \{[\s\S]*fetchConsultationStatus\([\s\S]*storedPending\.sessionId,[\s\S]*storedPending\.requestId/); - assert.match(bootstrap, /status\.status === "reserved"[\s\S]*reservedConsultation = status;[\s\S]*else \{[\s\S]*sessionStorage\.removeItem\(pendingConsultationStorageKey\)/); - assert.match(bootstrap, /caught instanceof ConsultationStatusError && caught\.status === 404 \? 1 : 0/); assert.match(storageSync, /pendingSessionId && pendingRequestId[\s\S]*sessionStorage\.setItem\(pendingConsultationStorageKey,[\s\S]*sessionId: pendingSessionId,[\s\S]*requestId: pendingRequestId,[\s\S]*question:/); assert.match(storageSync, /else \{[\s\S]*sessionStorage\.removeItem\(pendingConsultationStorageKey\)/); }); @@ -139,7 +244,11 @@ test("pending storage keeps the question so a refresh during the undo window can assert.match(source, /function readStoredPendingConsultation\(/); assert.match(source, /typeof parsedPending\.question === "string" \? parsedPending\.question : ""/); assert.match(sendSource, /sessionStorage\.setItem\(pendingConsultationStorageKey, JSON\.stringify\(\{[\s\S]*sessionId,[\s\S]*requestId,[\s\S]*question: originalQuestion/); - assert.match(source, /restoreConsultationRecovery\(recoverySession, reservedConsultation\.requestId, storedPending\)/); + // 原值: homeSurface 里 `restoreConsultationRecovery(recoverySession, reservedConsultation.requestId, storedPending)`。 + // 新值: 同一调用在 home-bootstrap-run.ts。 + // 原因: 启动恢复搬出首页,仍把 stored pending 交给真实 restore。 + const bootstrapRun = readFileSync(new URL("../src/lib/home-bootstrap-run.ts", import.meta.url), "utf8"); + assert.match(bootstrapRun, /restoreConsultationRecovery\(recoverySession, reservedConsultation\.requestId, storedPending\)/); assert.match(source, /stored\?\.question\?\.trim\(\)/); assert.match(sendSource, /resumeRequestId/); assert.match(sendSource, /questionAlreadyPresent/); diff --git a/frontend/tests/home-bootstrap-fake-timers.ts b/frontend/tests/home-bootstrap-fake-timers.ts new file mode 100644 index 00000000..c3078622 --- /dev/null +++ b/frontend/tests/home-bootstrap-fake-timers.ts @@ -0,0 +1,46 @@ +export function installWindow() { + const host = globalThis as { window?: unknown }; + if (host.window === undefined) host.window = globalThis; +} + +export async function withFakeTimers(run: (tick: (ms: number) => void) => Promise): Promise { + const originalSet = globalThis.setTimeout; + const originalClear = globalThis.clearTimeout; + const host = globalThis as { window?: unknown }; + const originalWindow = host.window; + host.window = globalThis; + let now = 0; + let sequence = 1; + const queue = new Map void }>(); + globalThis.setTimeout = ((fn: TimerHandler, ms?: number) => { + const id = sequence; + sequence += 1; + queue.set(id, { + at: now + (typeof ms === "number" ? ms : 0), + fn: () => { if (typeof fn === "function") fn(); }, + }); + return id as unknown as ReturnType; + }) as unknown as typeof setTimeout; + globalThis.clearTimeout = ((id?: ReturnType) => { + if (typeof id === "number") queue.delete(id); + }) as unknown as typeof clearTimeout; + const tick = (ms: number) => { + now += ms; + const due = [...queue.entries()] + .filter((entry) => entry[1].at <= now) + .sort((left, right) => left[1].at - right[1].at); + for (const [id, item] of due) { + if (!queue.has(id)) continue; + queue.delete(id); + item.fn(); + } + }; + try { + await run(tick); + } finally { + globalThis.setTimeout = originalSet; + globalThis.clearTimeout = originalClear; + if (originalWindow === undefined) delete host.window; + else host.window = originalWindow; + } +} diff --git a/frontend/tests/home-bootstrap-reveal.test.ts b/frontend/tests/home-bootstrap-reveal.test.ts index 00635c4f..571f2476 100644 --- a/frontend/tests/home-bootstrap-reveal.test.ts +++ b/frontend/tests/home-bootstrap-reveal.test.ts @@ -22,6 +22,8 @@ import { homeSurface } from "./home-surface.ts"; // being loaded. const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"); +const bootstrapRun = readFileSync(new URL("../src/lib/home-bootstrap-run.ts", import.meta.url), "utf8"); +const bootstrapError = readFileSync(new URL("../src/components/home-bootstrap-error.tsx", import.meta.url), "utf8"); const starterHome = readFileSync(new URL("../src/components/starter-home.tsx", import.meta.url), "utf8"); const globalsCss = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); const staleClientRecovery = readFileSync(new URL("../src/components/stale-client-recovery.tsx", import.meta.url), "utf8"); @@ -80,9 +82,12 @@ test("default landing replaces a latest rectification session with an empty cons starterHomeLandingNeedsConsultation(sessions, rectificationId, "keep"), false, ); - assert.match(page, /resolveStarterHomeLandingSessionId\(/); + // 原值: page.tsx 直接调用 resolveStarterHomeLandingSessionId / starterHomeLandingNeedsConsultation。 + // 新值: 这两个调用在 home-bootstrap-run.ts;page.tsx 仍调用 shouldAutoOpenRectificationSession。 + // 原因: 启动落点搬进 runHomeBootstrap,揭幕阶段的校正会话判断留在首页。 + assert.match(bootstrapRun, /resolveStarterHomeLandingSessionId\(/); assert.match(page, /shouldAutoOpenRectificationSession\(/); - assert.match(page, /starterHomeLandingNeedsConsultation\(/); + assert.match(bootstrapRun, /starterHomeLandingNeedsConsultation\(/); }); test("new chat landing requests a fresh consultation even with an existing ordinary chat", () => { @@ -124,8 +129,12 @@ test("home bootstrap keeps one slow-network budget before the fatal screen", () // 新值: 账户、模型与状态等待共享 20000ms,超时后只显示错误屏。 // 原因: 慢网串行链需要一次等待一次揭幕,不能半揭幕或提前放弃。 assert.equal(HOME_BOOTSTRAP_SLOW_MS, 20_000); - assert.match(page, /HOME_BOOTSTRAP_SLOW_MS/); + // 原值: page.tsx 引用 HOME_BOOTSTRAP_SLOW_MS。 + // 新值: 20 秒预算在 runHomeBootstrap 里。 + // 原因: 超时属于启动流程,首页只调用这个函数。 + assert.match(bootstrapRun, /HOME_BOOTSTRAP_SLOW_MS/); assert.doesNotMatch(page, /\}, 8000\);/); + assert.doesNotMatch(bootstrapRun, /\}, 8000\);/); }); test("reveal delay is zero once settled and counts down from the phase start otherwise", () => { @@ -146,7 +155,10 @@ test("loading copy changes per phase so the single loading screen narrates progr }); test("bootstrap enters the prepare phase instead of revealing after the account fetch", () => { - assert.match(page, /if \(bootstrapFailed\) setHydrated\(true\);\n\s*else setBootstrapPhase\("prepare"\);/); + // 原值: `if (bootstrapFailed) setHydrated(true); else setBootstrapPhase("prepare");` 写在 page.tsx。 + // 新值: 同一分支写在 runHomeBootstrap,setter 经 deps.commit 注入。 + // 原因: 失败仍揭幕错误屏,成功仍进入 prepare,不半揭幕。 + assert.match(bootstrapRun, /if \(bootstrapFailed\) deps\.commit\.setHydrated\(true\);\n\s*else deps\.commit\.setBootstrapPhase\("prepare"\);/); // 原值: 3 个 prepare 守卫(onboarding / 今日星语 / 入口摘要)。 // 新值: 2 个(今日星语 / 入口摘要)。 // 原因: 建议问题请求已删除。 @@ -188,7 +200,10 @@ test("recent sessions are prefetched after reveal so switches do not wait", () = test("the loading screen DOM contract stale-client recovery depends on is untouched", () => { assert.match(page, /
/); - assert.match(page, /
/); + // 原值: 错误屏 main 写在 page.tsx。 + // 新值: 同一段 main 在 home-bootstrap-error.tsx。 + // 原因: 错误屏抽成组件,class 与 role 不变,僵死恢复脚本仍能找到它。 + assert.match(bootstrapError, /
/); assert.match(staleClientRecovery, /main\.app-loading\[aria-busy="true"\]/); assert.match(staleClientRecovery, /main\.app-loading-error/); assert.ok(homeSurface.includes("bootstrapPrepareSettled(")); diff --git a/frontend/tests/home-bootstrap-run.test.ts b/frontend/tests/home-bootstrap-run.test.ts new file mode 100644 index 00000000..c8ba4f4a --- /dev/null +++ b/frontend/tests/home-bootstrap-run.test.ts @@ -0,0 +1,321 @@ +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(); + 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(); + 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; + 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((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, ""); + }); +}); diff --git a/frontend/tests/home-bootstrap-screens.test.tsx b/frontend/tests/home-bootstrap-screens.test.tsx new file mode 100644 index 00000000..8b60644c --- /dev/null +++ b/frontend/tests/home-bootstrap-screens.test.tsx @@ -0,0 +1,152 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { HomeBootstrapError } from "../src/components/home-bootstrap-error.tsx"; +import { HomeOnboardingShell } from "../src/components/home-onboarding-shell.tsx"; +import type { BirthTimeGuidedController } from "../src/hooks/use-birth-time-guided-journey.ts"; +import { emptyProfile, presetOnboardingMessage } from "../src/lib/home-types.ts"; +import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts"; + +Object.assign(globalThis, { React }); + +const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"); + +test("the fatal screen keeps its copy and retries through retryHomeBootstrap", async () => { + const accountError = "连接云端服务超时。请检查网络后重试,或返回登录页重新建立会话。"; + const markup = renderToStaticMarkup( + undefined} />, + ); + assert.match(markup, /暂时无法进入 Jyotisha/); + assert.match(markup, /连接云端服务超时。请检查网络后重试,或返回登录页重新建立会话。/); + assert.match(markup, /重试/); + assert.match(markup, /返回登录/); + assert.match(markup, /app-loading app-loading-error/); + assert.match(markup, /role="alert"/); + + const harness = createClientLifecycleHarness(); + let retried = 0; + try { + await harness.render( { retried += 1; }} />); + const retry = harness.elements().find((node) => node.tagName === "BUTTON" && node.text.includes("重试")); + assert.ok(retry); + await harness.event(retry); + assert.equal(retried, 1); + } finally { + await harness.close(); + } + + // 原值:错误屏按钮内联在 page.tsx,onClick 直接调用 retryHomeBootstrap。 + // 新值:按钮在 HomeBootstrapError,page.tsx 把 onRetry 接到同一个 retryHomeBootstrap(...reloadSessionList)。 + // 原因:错误屏抽成组件后,重试仍局部重跑,不整页刷新。 + assert.match(page, / retryHomeBootstrap\([\s\S]*reloadSessionList,[\s\S]*\)\}/); + assert.doesNotMatch(page, /window\.location\.reload\(\)/); +}); + +test("onboarding keeps the name prompt, birth card, place card, and retry card", () => { + const guided = {} as BirthTimeGuidedController; + const nameMarkup = renderToStaticMarkup( + undefined} + onboardingStep="name" + presetMessageLength={presetOnboardingMessage.length} + currentOnboardingMessage={presetOnboardingMessage} + onboardingJustCompleted={false} + onboardingCardReady={false} + presetMessageFinished + birthTimeJourney={null} + birthTimeAssessmentPhase={null} + birthTimeError="" + birthTimeGuided={guided} + accountError="" + profileSaving={false} + saveOnboardingBirth={() => undefined} + saveOnboardingPlace={() => undefined} + retryBirthTimeAssessment={() => undefined} + />, + ); + assert.match(nameMarkup, /你好,我是 Jyotisha/); + assert.match(nameMarkup, /请问我该怎么称呼你/); + assert.doesNotMatch(nameMarkup, /出生日期与时间/); + + const birthMarkup = renderToStaticMarkup( + undefined} + onboardingStep="birth" + presetMessageLength={80} + currentOnboardingMessage="出生时间" + onboardingJustCompleted={false} + onboardingCardReady + presetMessageFinished + birthTimeJourney={null} + birthTimeAssessmentPhase={null} + birthTimeError="" + birthTimeGuided={guided} + accountError="" + profileSaving={false} + saveOnboardingBirth={() => undefined} + saveOnboardingPlace={() => undefined} + retryBirthTimeAssessment={() => undefined} + />, + ); + assert.match(birthMarkup, /出生日期与时间/); + assert.match(birthMarkup, />继续 undefined} + onboardingStep="place" + presetMessageLength={80} + currentOnboardingMessage="出生地点" + onboardingJustCompleted={false} + onboardingCardReady + presetMessageFinished + birthTimeJourney={null} + birthTimeAssessmentPhase={null} + birthTimeError="" + birthTimeGuided={guided} + accountError="" + profileSaving={false} + saveOnboardingBirth={() => undefined} + saveOnboardingPlace={() => undefined} + retryBirthTimeAssessment={() => undefined} + />, + ); + assert.match(placeMarkup, /出生地点/); + assert.match(placeMarkup, /搜索全球城市或区县,系统会匹配出生当日的时区。/); + assert.match(placeMarkup, />确定 undefined} + onboardingStep="rectification" + presetMessageLength={80} + currentOnboardingMessage="校正" + onboardingJustCompleted={false} + onboardingCardReady + presetMessageFinished + birthTimeJourney={null} + birthTimeAssessmentPhase={null} + birthTimeError="" + birthTimeGuided={guided} + accountError="" + profileSaving={false} + saveOnboardingBirth={() => undefined} + saveOnboardingPlace={() => undefined} + retryBirthTimeAssessment={() => undefined} + />, + ); + assert.match(retryMarkup, /出生时间尚未完成评估/); + assert.match(retryMarkup, /资料已经保留,但暂时无法恢复校正进度。系统不会应用未经验证的具体时间。/); + assert.match(retryMarkup, />重新评估(` is not missed. -// Former caps were useState 52 / useRef 39 / lines 1931 after batch 1 @ f8e607c2, -// and useState 66 / useRef 41 / lines 1951 on freeze-metric @ 51a65d92. -// Batch 2 moved 4 synastry useStates into useSynastry, 6 profile useStates into -// useProfileOnboarding and 6 session useStates into useSessionManagement, and -// dropped the two guided-birth-time callback refs that the profile hook no longer -// needs now that it runs before useBirthTimeGuidedJourney. -const PAGE_LINE_COUNT_BASELINE = 1846; +// Former line baseline was 1846 (cap 1996) after batch 2 @ 37e6c519. +// This split moved bootstrap, recovery polling, and the error / onboarding +// screens out of Home(). useState stayed 34 (cap 36) and useRef stayed 37. +const PAGE_LINE_COUNT_BASELINE = 1417; const PAGE_LINE_COUNT_CAP = PAGE_LINE_COUNT_BASELINE + 150; const HOME_USE_STATE_CAP = 36; const HOME_USE_REF_CAP = 37; diff --git a/frontend/tests/new-chat-recovery.test.ts b/frontend/tests/new-chat-recovery.test.ts index 524f643e..8405fe1c 100644 --- a/frontend/tests/new-chat-recovery.test.ts +++ b/frontend/tests/new-chat-recovery.test.ts @@ -5,9 +5,11 @@ import { runInNewContext } from "node:vm"; import ts from "typescript"; import { createSession } from "../src/lib/home-cloud-sync.ts"; +import { runHomeBootstrap, type HomeBootstrapDeps } from "../src/lib/home-bootstrap-run.ts"; +import { pendingConsultationStorageKey, previewModelCatalog, emptyProfile, type Account, type ChatSession } from "../src/lib/home-types.ts"; import { isUnsavedEmptyConsultation } from "../src/lib/session-list-filter.ts"; +import type { SessionListBoot } from "../src/lib/session-list-context.tsx"; -const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"); const run = readFileSync(new URL("../src/hooks/use-consultation-run.ts", import.meta.url), "utf8"); function sourceBetween(source: string, start: string, end: string): string { @@ -17,63 +19,174 @@ function sourceBetween(source: string, start: string, end: string): string { return source.slice(from, to); } -function bootstrapWithRecovery(urlAction: "new-chat" | "none") { - // Exercise the real recovery function and Home's activation block together. - // The synthetic ordinary chat has no engine payload or private user data. +function installBrowserHosts() { + const host = globalThis as { window?: unknown; localStorage?: Storage }; + if (host.window === undefined) host.window = globalThis; + if (!host.localStorage) { + const data = new Map(); + 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 bindRestore(sandbox: Record) { + const recovery = sourceBetween(run, " function restoreConsultationRecovery(", " consultationRecoveryCheck.current ="); + const compiled = ts.transpileModule( + `${recovery}\n__boundRestore = restoreConsultationRecovery;`, + { compilerOptions: { target: ts.ScriptTarget.ES2022 } }, + ).outputText; + const box: Record = { __boundRestore: undefined, ...sandbox }; + runInNewContext(compiled, box); + return box.__boundRestore as (session: ChatSession, requestId: string, stored?: unknown) => void; +} + +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, + }; +} + +async function bootstrapWithRecovery(urlAction: "new-chat" | "none") { + // 原值:把 page.tsx 激活段和 hook 里的 restore 一起塞进 vm。 + // 新值:直接调用 runHomeBootstrap,restore 仍是 hook 里的真实函数。 + // 原因:启动流程已搬出 page.tsx;调用整段函数还覆盖 new-chat 会新建空咨询。 + installBrowserHosts(); const oldSession = createSession("test-model"); oldSession.messages = [{ role: "user", text: "测试问题" }]; const localSession = createSession("test-model"); const pending: { current: { sessionId: string; phase: string } | null } = { current: null }; - const state = { active: "", pendingSession: "", pendingRequest: "", phase: "", notice: "", storedCleared: false }; + const state = { + active: "", + pendingSession: "", + pendingRequest: "", + phase: "", + notice: "", + storedCleared: false, + sessions: [] as ChatSession[], + }; const writes: unknown[][] = []; - const recovery = sourceBetween(run, " function restoreConsultationRecovery(", " consultationRecoveryCheck.current ="); - // 原值:切到已删除的模型不可用分支作为源码边界。 - // 新值:以稳定的 fallback-session 分支作为边界;模型不可用现在直接走 fatal screen。 - // 原因:T1/D2 移除了已验收的半揭幕路径,测试不能再依赖被删除的语句。 - const activation = sourceBetween(page, " if (bootstrapSelection.urlAction === \"new-chat\") writeSessionUrl", " if (parsedSessions.fallbackSessionIds.length > 0)"); - const compiled = ts.transpileModule(`${recovery}\n${activation}`, { - compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }, - }).outputText; - runInNewContext(compiled, { + const memory = new Map(); + const sandbox = { AbortController, navigator: { onLine: true }, pendingConsultation: pending, - bootstrapSelection: { urlAction, clearStoredReturn: urlAction === "new-chat" }, - landingSessionId: localSession.id, - nextSessions: [localSession, oldSession], - reservedConsultation: { status: "reserved", sessionId: oldSession.id, requestId: "test-request" }, - storedPending: null, + pendingConsultationStorageKey, + sessionStorage: { + getItem: (key: string) => memory.get(key) ?? null, + setItem: (key: string, value: string) => { memory.set(key, value); }, + removeItem: (key: string) => { memory.delete(key); }, + }, + sessions: [localSession, oldSession], consultSendBlockedByRectificationSession: () => false, - isUnsavedEmptyConsultation, + isGenericSessionTitle: () => false, + resolveSessionTitle: () => "新对话", + timestamp: () => 1, + updateSession: () => { assert.fail("the existing user question must not be duplicated"); }, setActiveSessionId: (id: string) => { state.active = id; }, setPendingSessionId: (id: string) => { state.pendingSession = id; }, setPendingRequestId: (id: string) => { state.pendingRequest = id; }, setConsultationPhase: (phase: string) => { state.phase = phase; }, + setStreamingReply: () => undefined, setComposerNotice: (notice: string) => { state.notice = notice; }, - setStreamingReply: () => {}, - clearLoginSessionReturn: () => { state.storedCleared = true; }, - writeSessionUrl: (...args: unknown[]) => { writes.push(args); }, - updateSession: () => { assert.fail("the existing user question must not be duplicated"); }, - }); + }; + const restore = bindRestore(sandbox); + const boot: SessionListBoot = { + sessions: [localSession, oldSession], + rawRows: [], + cursor: null, + account: account(), + signedOut: false, + }; + const deps: HomeBootstrapDeps = { + sessionListReady: Promise.resolve(), + sessionListBoot: () => boot, + uiPreview: { current: false }, + uiPreviewMode: { current: null }, + consultationStatusMissingCount: { current: 0 }, + isDevelopment: false, + readSearch: () => "", + storage: { + getItem: () => null, + removeItem: () => undefined, + }, + restoreConsultationRecovery: restore, + setComposerNotice: sandbox.setComposerNotice, + commit: { + setAccount: () => undefined, + setAccountError: () => undefined, + setActiveSessionId: sandbox.setActiveSessionId, + setBirthTimeAssessmentPhase: () => undefined, + setBirthTimeJourney: () => undefined, + setBootstrapPhase: () => undefined, + setGuidedJourneyPreview: () => undefined, + setHydrated: () => undefined, + setModelCatalog: () => undefined, + setOnboardingStep: () => undefined, + setProfile: () => undefined, + setProfileDraft: () => undefined, + setSessions: (sessions) => { state.sessions = sessions; }, + setSessionsCursor: () => undefined, + setStartGreeting: () => undefined, + }, + io: { + fetchModelCatalog: async () => previewModelCatalog, + fetchActiveConsultationStatus: async () => ({ + status: "reserved", + sessionId: oldSession.id, + requestId: "33333333-3333-4333-8333-333333333333", + }), + resolveLookupBootstrap: async (input) => ({ + selection: { + sessionId: localSession.id, + urlAction, + missing: false, + clearStoredReturn: urlAction === "new-chat", + }, + sessions: input.sessions, + notice: null, + }), + writeSessionUrl: (...args: unknown[]) => { writes.push(args); }, + clearLoginSessionReturn: () => { state.storedCleared = true; }, + }, + }; + await runHomeBootstrap(deps, new AbortController().signal); return { state, pending, writes, localSession, oldSession }; } -test("new chat keeps its local landing while a reserved older consultation recovers", () => { - const { state, pending, writes, localSession, oldSession } = bootstrapWithRecovery("new-chat"); - assert.equal(state.active, localSession.id); +test("new chat keeps its local landing while a reserved older consultation recovers", async () => { + const { state, pending, writes, oldSession } = await bootstrapWithRecovery("new-chat"); + const landing = state.sessions[0]; + assert.ok(landing); + assert.equal(state.active, landing.id); + assert.notEqual(landing.id, oldSession.id); + assert.equal(isUnsavedEmptyConsultation(landing), true); assert.equal(state.notice, ""); assert.equal(state.storedCleared, true); assert.deepEqual(writes, [[null, "replace"]]); - assert.equal(isUnsavedEmptyConsultation(localSession), true); assert.equal(state.pendingSession, oldSession.id); - assert.equal(state.pendingRequest, "test-request"); + assert.equal(state.pendingRequest, "33333333-3333-4333-8333-333333333333"); assert.equal(state.phase, "recovering"); assert.equal(pending.current?.sessionId, oldSession.id); assert.equal(pending.current?.phase, "recovering"); }); -test("ordinary home landing still activates and announces the reserved consultation", () => { - const { state, pending, writes, oldSession } = bootstrapWithRecovery("none"); +test("ordinary home landing still activates and announces the reserved consultation", async () => { + const { state, pending, writes, oldSession } = await bootstrapWithRecovery("none"); assert.equal(state.active, oldSession.id); assert.equal(state.notice, "回答仍在后台生成,正在自动恢复。"); assert.equal(state.storedCleared, false); diff --git a/frontend/tests/session-list-provider.test.ts b/frontend/tests/session-list-provider.test.ts index 15db00b1..fafc65f1 100644 --- a/frontend/tests/session-list-provider.test.ts +++ b/frontend/tests/session-list-provider.test.ts @@ -20,7 +20,12 @@ test("the app layout owns one SessionListProvider and one SidebarProvider", () = test("the provider fetches the session list once and home does not fetch it again", () => { assert.match(provider, /fetch\(`\/api\/sessions\?limit=\$\{SESSION_PAGE_SIZE\}`/); assert.match(provider, /fetch\("\/api\/account"/); - assert.match(page, /await sessionListReady;/); + // 原值: page.tsx 里 `await sessionListReady;` + // 新值: page.tsx 把 sessionListReady 交给 runHomeBootstrap,函数里 `await deps.sessionListReady`。 + // 原因: 启动流程搬出首页后,会话列表仍只由 provider 拉一次。 + const bootstrapRun = readFileSync(new URL("../src/lib/home-bootstrap-run.ts", import.meta.url), "utf8"); + assert.match(page, /sessionListReady,/); + assert.match(bootstrapRun, /await deps\.sessionListReady;/); assert.doesNotMatch(page, /fetchSessions\(/); assert.doesNotMatch(page, /fetchAccount\(/); assert.match(page, /useHomeShellRegistration\(/); diff --git a/frontend/tests/stale-client-recovery.test.ts b/frontend/tests/stale-client-recovery.test.ts index 00d65e72..2111a2cc 100644 --- a/frontend/tests/stale-client-recovery.test.ts +++ b/frontend/tests/stale-client-recovery.test.ts @@ -89,14 +89,19 @@ test("edge and layout recover a stale client without looping forever", () => { assert.match(recoverySource, /window\.location\.reload\(\)/); assert.match(recoverySource, /pageshow/); assert.match(recoverySource, /main\.app-loading\[aria-busy="true"\]/); - assert.match(pageSource, /clearStaleClientReload\(sessionStorage\)/); + // 原值: page.tsx 里 `clearStaleClientReload(sessionStorage)`。 + // 新值: 首页把 `storage: sessionStorage` 交给 runHomeBootstrap,函数内 `clearStaleClientReload(deps.storage)`。 + // 原因: 清的仍是会话存储,不是别的 storage。 + const bootstrapRun = readFileSync(new URL("../src/lib/home-bootstrap-run.ts", import.meta.url), "utf8"); + assert.match(pageSource, /storage: sessionStorage/); + assert.match(bootstrapRun, /clearStaleClientReload\(deps\.storage\)/); }); test("a login redirect leaves the bootstrap timeout running so a failed navigation can escape the spinner", () => { - const bootstrap = pageSource.slice( - pageSource.indexOf("async function loadCloudData()"), - pageSource.indexOf("void loadCloudData();"), - ); + // 原值: 从 page.tsx 的 loadCloudData 切片。 + // 新值: 同一段 try/finally 在 home-bootstrap-run.ts。 + // 原因: 登录跳转仍不清除 20 秒超时,失败的导航还能离开加载屏。 + const bootstrap = readFileSync(new URL("../src/lib/home-bootstrap-run.ts", import.meta.url), "utf8"); assert.match(bootstrap, /if \(caught instanceof LoginRedirectError\) \{\n\s*redirectedToLogin = true;\n\s*return;/); assert.match(bootstrap, /if \(redirectedToLogin\) return;/); assert.match(bootstrap, /window\.clearTimeout\(bootstrapTimeout\)/);