fix: recover slow personalized starters
This commit is contained in:
+30
-51
@@ -43,6 +43,11 @@ import {
|
||||
} from "@/lib/birth-time-guided-preview";
|
||||
import { keepFocusWithin } from "@/lib/focus-trap";
|
||||
import { chatMessageViews, type ChatMessage } from "@/lib/chat-message-view";
|
||||
import {
|
||||
OnboardingAuthenticationError,
|
||||
type OnboardingContent,
|
||||
requestOnboardingWithRecovery,
|
||||
} from "@/lib/onboarding-client";
|
||||
import { protectOnboardingPhrases } from "@/lib/onboarding-copy";
|
||||
import {
|
||||
SessionModelPersistenceQueue,
|
||||
@@ -109,8 +114,6 @@ type RequestError = { sessionId: string; message: string };
|
||||
type StreamingReply = { sessionId: string; text: string };
|
||||
type BirthPlace = { label: string; lat: number; lon: number; tz: number };
|
||||
type Account = { user: { id: string; email: string | null }; credits: number; isAdmin: boolean };
|
||||
type OnboardingSuggestion = { theme: Exclude<Theme, "general">; text: string };
|
||||
type OnboardingContent = { greeting: string; suggestions: OnboardingSuggestion[] };
|
||||
type OnboardingStep = "name" | "birth" | "place" | "rectification";
|
||||
type GreetingPeriod = "morning" | "noon" | "afternoon" | "evening" | "late-night";
|
||||
type AccountDialog = "profile" | "redeem" | "logout";
|
||||
@@ -477,25 +480,6 @@ function readSuggestions(value: unknown) {
|
||||
.filter(Boolean))].slice(0, 3);
|
||||
}
|
||||
|
||||
function readOnboarding(value: unknown): OnboardingContent | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const payload = value as { greeting?: unknown; suggestions?: unknown };
|
||||
if (typeof payload.greeting !== "string" || !Array.isArray(payload.suggestions)) return null;
|
||||
|
||||
const expectedThemes: OnboardingSuggestion["theme"][] = ["career", "marriage", "timing"];
|
||||
const suggestions = payload.suggestions.flatMap((item, index): OnboardingSuggestion[] => {
|
||||
if (!item || typeof item !== "object") return [];
|
||||
const suggestion = item as { theme?: unknown; text?: unknown };
|
||||
const theme = expectedThemes[index];
|
||||
if (!theme || suggestion.theme !== theme || typeof suggestion.text !== "string") return [];
|
||||
const text = suggestion.text.replace(/\s+/g, " ").trim().slice(0, 80);
|
||||
return text ? [{ theme, text }] : [];
|
||||
});
|
||||
|
||||
const greeting = payload.greeting.replace(/\s+/g, " ").trim().slice(0, 180);
|
||||
return greeting.length >= 8 && suggestions.length === 3 ? { greeting, suggestions } : null;
|
||||
}
|
||||
|
||||
function readProfile(value: unknown): Profile {
|
||||
if (!value || typeof value !== "object") return emptyProfile;
|
||||
const profile = value as Partial<Profile> & {
|
||||
@@ -776,6 +760,8 @@ export default function Home() {
|
||||
const modelSelectionVersions = useRef(new Map<string, number>());
|
||||
const activeSessionIdRef = useRef("");
|
||||
const chartLibraryLoadedAccount = useRef("");
|
||||
const onboardingRequestIdentity = useRef("");
|
||||
const onboardingPresentation = useRef({ name: "", startGreeting: "" });
|
||||
const uiPreview = useRef(false);
|
||||
const uiPreviewMode = useRef<string | null>(null);
|
||||
const birthTimeRevisionPending = useRef(false);
|
||||
@@ -797,6 +783,7 @@ export default function Home() {
|
||||
const productEntrypointsDisabled = !hydrated || Boolean(pendingSessionId) || cancellationPending || !account || !modelCatalog;
|
||||
const activeStreamingText = streamingReply && streamingReply.sessionId === activeSession?.id ? streamingReply.text : "";
|
||||
const accountId = account?.user.id;
|
||||
onboardingPresentation.current = { name: profile.name, startGreeting };
|
||||
|
||||
useEffect(() => {
|
||||
activeSessionIdRef.current = activeSessionId;
|
||||
@@ -1126,44 +1113,36 @@ export default function Home() {
|
||||
}, [currentOnboardingMessage, hydrated, shouldStreamOnboarding]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || !accountId || !profileComplete || onboarding || onboardingError) return;
|
||||
if (!hydrated || !accountId || !profileComplete || uiPreview.current) return;
|
||||
const requestIdentity = `${accountId}:profile-complete`;
|
||||
if (onboardingRequestIdentity.current === requestIdentity) return;
|
||||
onboardingRequestIdentity.current = requestIdentity;
|
||||
const controller = new AbortController();
|
||||
const onboardingTimeout = window.setTimeout(() => {
|
||||
if (controller.signal.aborted) return;
|
||||
controller.abort();
|
||||
setOnboardingError("个性化入门问题准备超时");
|
||||
}, 12000);
|
||||
|
||||
async function loadOnboarding() {
|
||||
try {
|
||||
const response = await fetch("/api/onboarding", {
|
||||
method: "POST",
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
void requestOnboardingWithRecovery(controller.signal, () => {
|
||||
if (!controller.signal.aborted) setOnboardingError("个性化入门问题准备超时");
|
||||
})
|
||||
.then((content) => {
|
||||
if (controller.signal.aborted) return;
|
||||
const presentation = onboardingPresentation.current;
|
||||
setOnboarding({
|
||||
...content,
|
||||
greeting: presentation.startGreeting || createStartGreeting(presentation.name),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (response.status === 401) {
|
||||
setOnboardingError("");
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
if (caught instanceof OnboardingAuthenticationError) {
|
||||
window.location.assign("/login");
|
||||
return;
|
||||
}
|
||||
if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法准备初始问题"));
|
||||
const content = readOnboarding(payload);
|
||||
if (!content) throw new Error("Agent 返回的初始问题格式不正确");
|
||||
setOnboarding({ ...content, greeting: startGreeting || createStartGreeting(profile.name) });
|
||||
setOnboardingError("");
|
||||
} catch (caught) {
|
||||
if ((caught as Error).name !== "AbortError") {
|
||||
setOnboardingError(caught instanceof Error ? caught.message : "暂时无法准备初始问题");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadOnboarding();
|
||||
setOnboardingError(caught instanceof Error ? caught.message : "暂时无法准备初始问题");
|
||||
});
|
||||
return () => {
|
||||
window.clearTimeout(onboardingTimeout);
|
||||
if (onboardingRequestIdentity.current === requestIdentity) onboardingRequestIdentity.current = "";
|
||||
controller.abort();
|
||||
};
|
||||
}, [accountId, hydrated, onboarding, onboardingError, profile.name, profileComplete, startGreeting]);
|
||||
}, [accountId, hydrated, profileComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || !profileComplete || birthTimeDisplayState(profile)) return;
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const onboardingResponseSchema = z.object({
|
||||
greeting: z.string().transform((value) => value.replace(/\s+/g, " ").trim().slice(0, 180)).pipe(z.string().min(8)),
|
||||
suggestions: z.tuple([
|
||||
z.object({ theme: z.literal("career"), text: z.string().transform(normalizeSuggestion).pipe(z.string().min(1)) }),
|
||||
z.object({ theme: z.literal("marriage"), text: z.string().transform(normalizeSuggestion).pipe(z.string().min(1)) }),
|
||||
z.object({ theme: z.literal("timing"), text: z.string().transform(normalizeSuggestion).pipe(z.string().min(1)) }),
|
||||
]),
|
||||
source: z.enum(["agent", "cache", "fallback", "pending"]),
|
||||
});
|
||||
|
||||
const defaultPolicy = {
|
||||
requestTimeoutMs: 12_000,
|
||||
retryDelayMs: 4_000,
|
||||
maxAttempts: 3,
|
||||
} as const;
|
||||
|
||||
type OnboardingRecoveryPolicy = {
|
||||
readonly requestTimeoutMs: number;
|
||||
readonly retryDelayMs: number;
|
||||
readonly maxAttempts: number;
|
||||
};
|
||||
|
||||
type OnboardingAttemptResult =
|
||||
| { readonly kind: "response"; readonly response: Response; readonly payload: unknown }
|
||||
| { readonly kind: "timeout"; readonly error: unknown }
|
||||
| { readonly kind: "network"; readonly error: unknown }
|
||||
| { readonly kind: "invalid-response"; readonly error: unknown };
|
||||
|
||||
export type OnboardingSuggestion = {
|
||||
readonly theme: "career" | "marriage" | "timing";
|
||||
readonly text: string;
|
||||
};
|
||||
|
||||
export type OnboardingContent = {
|
||||
readonly greeting: string;
|
||||
readonly suggestions: readonly OnboardingSuggestion[];
|
||||
};
|
||||
|
||||
export class OnboardingAuthenticationError extends Error {
|
||||
readonly name = "OnboardingAuthenticationError";
|
||||
readonly status = 401;
|
||||
|
||||
constructor() {
|
||||
super("登录后才能准备初始问题。");
|
||||
}
|
||||
}
|
||||
|
||||
export class OnboardingRequestError extends Error {
|
||||
readonly name = "OnboardingRequestError";
|
||||
readonly reason: "http" | "invalid-response" | "timeout" | "network" | "pending";
|
||||
readonly status: number | null;
|
||||
|
||||
constructor(
|
||||
reason: "http" | "invalid-response" | "timeout" | "network" | "pending",
|
||||
status: number | null = null,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super("暂时无法准备初始问题", options);
|
||||
this.reason = reason;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSuggestion(value: string): string {
|
||||
return value.replace(/\s+/g, " ").trim().slice(0, 80);
|
||||
}
|
||||
|
||||
function abortReason(signal: AbortSignal): unknown {
|
||||
return signal.reason ?? new DOMException("The operation was aborted", "AbortError");
|
||||
}
|
||||
|
||||
function waitForRetry(delayMs: number, signal: AbortSignal): Promise<void> {
|
||||
signal.throwIfAborted();
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener("abort", handleAbort);
|
||||
resolve();
|
||||
}, delayMs);
|
||||
function handleAbort() {
|
||||
clearTimeout(timer);
|
||||
reject(abortReason(signal));
|
||||
}
|
||||
signal.addEventListener("abort", handleAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchAttempt(
|
||||
signal: AbortSignal,
|
||||
timeoutMs: number,
|
||||
): Promise<OnboardingAttemptResult> {
|
||||
signal.throwIfAborted();
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
let responseReceived = false;
|
||||
const handleAbort = () => controller.abort(abortReason(signal));
|
||||
signal.addEventListener("abort", handleAbort, { once: true });
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort(new DOMException("The operation timed out", "TimeoutError"));
|
||||
}, timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/onboarding", {
|
||||
method: "POST",
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) return { kind: "response", response, payload: null };
|
||||
responseReceived = true;
|
||||
const payload: unknown = await response.json();
|
||||
return { kind: "response", response, payload };
|
||||
} catch (error) {
|
||||
if (signal.aborted) throw abortReason(signal);
|
||||
if (timedOut) return { kind: "timeout", error };
|
||||
if (responseReceived) return { kind: "invalid-response", error };
|
||||
return { kind: "network", error };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener("abort", handleAbort);
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestOnboardingWithRecovery(
|
||||
signal: AbortSignal,
|
||||
onSlow: () => void,
|
||||
policy: OnboardingRecoveryPolicy = defaultPolicy,
|
||||
): Promise<OnboardingContent> {
|
||||
let slowReported = false;
|
||||
let lastError: OnboardingRequestError | null = null;
|
||||
|
||||
for (let attempt = 1; attempt <= policy.maxAttempts; attempt += 1) {
|
||||
const result = await fetchAttempt(signal, policy.requestTimeoutMs);
|
||||
switch (result.kind) {
|
||||
case "timeout":
|
||||
if (!slowReported) {
|
||||
slowReported = true;
|
||||
onSlow();
|
||||
}
|
||||
lastError = new OnboardingRequestError("timeout", null, { cause: result.error });
|
||||
break;
|
||||
case "network":
|
||||
lastError = new OnboardingRequestError("network", null, { cause: result.error });
|
||||
break;
|
||||
case "invalid-response":
|
||||
throw new OnboardingRequestError("invalid-response", null, { cause: result.error });
|
||||
case "response": {
|
||||
if (result.response.status === 401) throw new OnboardingAuthenticationError();
|
||||
if (!result.response.ok) {
|
||||
lastError = new OnboardingRequestError("http", result.response.status);
|
||||
break;
|
||||
}
|
||||
const parsed = onboardingResponseSchema.safeParse(result.payload);
|
||||
if (!parsed.success) throw new OnboardingRequestError("invalid-response", null, { cause: parsed.error });
|
||||
if (parsed.data.source !== "pending") {
|
||||
return { greeting: parsed.data.greeting, suggestions: parsed.data.suggestions };
|
||||
}
|
||||
lastError = new OnboardingRequestError("pending");
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const exhaustiveResult: never = result;
|
||||
throw exhaustiveResult;
|
||||
}
|
||||
}
|
||||
|
||||
if (attempt < policy.maxAttempts) await waitForRetry(policy.retryDelayMs, signal);
|
||||
}
|
||||
|
||||
throw lastError ?? new OnboardingRequestError("pending");
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
OnboardingAuthenticationError,
|
||||
OnboardingRequestError,
|
||||
requestOnboardingWithRecovery,
|
||||
} from "../src/lib/onboarding-client.ts";
|
||||
|
||||
const personalizedOnboarding = {
|
||||
greeting: "林遥,欢迎回来。想先从哪个方向开始?",
|
||||
suggestions: [
|
||||
{ theme: "career", text: "我现在的事业选择应该优先考虑什么?" },
|
||||
{ theme: "marriage", text: "我该怎样理解近期的关系模式?" },
|
||||
{ theme: "timing", text: "未来一年哪些阶段适合主动推进?" },
|
||||
],
|
||||
source: "cache",
|
||||
} as const;
|
||||
|
||||
test("returns personalized cache content after a timeout and pending response", async () => {
|
||||
// Given: the first request times out, the second is provisional, and the third is terminal.
|
||||
const originalFetch = globalThis.fetch;
|
||||
let requestCount = 0;
|
||||
let slowCount = 0;
|
||||
globalThis.fetch = (_input, init) => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) {
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true });
|
||||
});
|
||||
}
|
||||
if (requestCount === 2) {
|
||||
return Promise.resolve(Response.json({ ...personalizedOnboarding, source: "pending" }));
|
||||
}
|
||||
return Promise.resolve(Response.json(personalizedOnboarding));
|
||||
};
|
||||
|
||||
try {
|
||||
// When: bounded recovery runs with test-sized delays.
|
||||
const content = await requestOnboardingWithRecovery(
|
||||
new AbortController().signal,
|
||||
() => {
|
||||
slowCount += 1;
|
||||
},
|
||||
{ requestTimeoutMs: 5, retryDelayMs: 0, maxAttempts: 3 },
|
||||
);
|
||||
|
||||
// Then: slow fallback is shown once and terminal personalized content wins.
|
||||
assert.deepEqual(content, {
|
||||
greeting: personalizedOnboarding.greeting,
|
||||
suggestions: personalizedOnboarding.suggestions,
|
||||
});
|
||||
assert.equal(slowCount, 1);
|
||||
assert.equal(requestCount, 3);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("reports slow onboarding only once across repeated request timeouts", async () => {
|
||||
// Given: every bounded request waits until its child timeout aborts it.
|
||||
const originalFetch = globalThis.fetch;
|
||||
let slowCount = 0;
|
||||
globalThis.fetch = (_input, init) => new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true });
|
||||
});
|
||||
|
||||
try {
|
||||
// When: all attempts time out.
|
||||
await assert.rejects(
|
||||
requestOnboardingWithRecovery(
|
||||
new AbortController().signal,
|
||||
() => {
|
||||
slowCount += 1;
|
||||
},
|
||||
{ requestTimeoutMs: 2, retryDelayMs: 0, maxAttempts: 3 },
|
||||
),
|
||||
(error: unknown) => error instanceof OnboardingRequestError && error.reason === "timeout",
|
||||
);
|
||||
|
||||
// Then: the UI fallback signal is emitted once for the whole sequence.
|
||||
assert.equal(slowCount, 1);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("throws an authentication error without retrying a 401 response", async () => {
|
||||
// Given: the onboarding endpoint rejects the session.
|
||||
const originalFetch = globalThis.fetch;
|
||||
let requestCount = 0;
|
||||
globalThis.fetch = () => {
|
||||
requestCount += 1;
|
||||
return Promise.resolve(Response.json({}, { status: 401 }));
|
||||
};
|
||||
|
||||
try {
|
||||
// When: recovery receives the authentication response.
|
||||
await assert.rejects(
|
||||
requestOnboardingWithRecovery(
|
||||
new AbortController().signal,
|
||||
() => undefined,
|
||||
{ requestTimeoutMs: 20, retryDelayMs: 0, maxAttempts: 3 },
|
||||
),
|
||||
OnboardingAuthenticationError,
|
||||
);
|
||||
|
||||
// Then: authentication is terminal and no retry is attempted.
|
||||
assert.equal(requestCount, 1);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("throws a typed pending error after the bounded attempts are exhausted", async () => {
|
||||
// Given: every response remains provisional.
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = () => Promise.resolve(Response.json({ ...personalizedOnboarding, source: "pending" }));
|
||||
|
||||
try {
|
||||
// When: the pending response consumes every attempt.
|
||||
// Then: the caller receives a typed terminal failure.
|
||||
await assert.rejects(
|
||||
requestOnboardingWithRecovery(
|
||||
new AbortController().signal,
|
||||
() => undefined,
|
||||
{ requestTimeoutMs: 20, retryDelayMs: 0, maxAttempts: 2 },
|
||||
),
|
||||
(error: unknown) => error instanceof OnboardingRequestError && error.reason === "pending",
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("throws a typed HTTP error after non-authentication failures are exhausted", async () => {
|
||||
// Given: the endpoint remains unavailable.
|
||||
const originalFetch = globalThis.fetch;
|
||||
let requestCount = 0;
|
||||
globalThis.fetch = () => {
|
||||
requestCount += 1;
|
||||
return Promise.resolve(Response.json({}, { status: 503 }));
|
||||
};
|
||||
|
||||
try {
|
||||
// When: bounded recovery exhausts the failed responses.
|
||||
await assert.rejects(
|
||||
requestOnboardingWithRecovery(
|
||||
new AbortController().signal,
|
||||
() => undefined,
|
||||
{ requestTimeoutMs: 20, retryDelayMs: 0, maxAttempts: 2 },
|
||||
),
|
||||
(error: unknown) => error instanceof OnboardingRequestError
|
||||
&& error.reason === "http"
|
||||
&& error.status === 503,
|
||||
);
|
||||
|
||||
// Then: no request exceeds the policy bound.
|
||||
assert.equal(requestCount, 2);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a malformed terminal response with a typed error", async () => {
|
||||
// Given: a successful HTTP response violates the onboarding schema.
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = () => Promise.resolve(Response.json({ source: "cache", greeting: "short" }));
|
||||
|
||||
try {
|
||||
// When: the response crosses the client boundary.
|
||||
// Then: invalid external data cannot enter the page as onboarding content.
|
||||
await assert.rejects(
|
||||
requestOnboardingWithRecovery(
|
||||
new AbortController().signal,
|
||||
() => undefined,
|
||||
{ requestTimeoutMs: 20, retryDelayMs: 0, maxAttempts: 3 },
|
||||
),
|
||||
(error: unknown) => error instanceof OnboardingRequestError && error.reason === "invalid-response",
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("parent cancellation aborts the child request without reporting slow state", async () => {
|
||||
// Given: a request is in flight with a long child timeout.
|
||||
const originalFetch = globalThis.fetch;
|
||||
const parent = new AbortController();
|
||||
let markStarted: ((signal: AbortSignal) => void) | null = null;
|
||||
const started = new Promise<AbortSignal>((resolve) => {
|
||||
markStarted = resolve;
|
||||
});
|
||||
let slowCount = 0;
|
||||
globalThis.fetch = (_input, init) => new Promise<Response>((_resolve, reject) => {
|
||||
assert.ok(init?.signal);
|
||||
markStarted?.(init.signal);
|
||||
init.signal.addEventListener("abort", () => reject(init.signal?.reason), { once: true });
|
||||
});
|
||||
|
||||
try {
|
||||
// When: the page lifecycle is cancelled before the request timeout.
|
||||
const request = requestOnboardingWithRecovery(
|
||||
parent.signal,
|
||||
() => {
|
||||
slowCount += 1;
|
||||
},
|
||||
{ requestTimeoutMs: 10_000, retryDelayMs: 10_000, maxAttempts: 3 },
|
||||
);
|
||||
const childSignal = await started;
|
||||
parent.abort(new DOMException("page unmounted", "AbortError"));
|
||||
|
||||
// Then: cancellation reaches the child and produces no slow fallback signal.
|
||||
await assert.rejects(request, (error: unknown) => error instanceof DOMException && error.name === "AbortError");
|
||||
assert.equal(childSignal?.aborted, true);
|
||||
assert.equal(slowCount, 0);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("parent cancellation interrupts response body parsing", async () => {
|
||||
// Given: headers arrived, but the onboarding JSON body is still streaming.
|
||||
const originalFetch = globalThis.fetch;
|
||||
const parent = new AbortController();
|
||||
let markBodyRead: (() => void) | null = null;
|
||||
const bodyRead = new Promise<void>((resolve) => {
|
||||
markBodyRead = resolve;
|
||||
});
|
||||
globalThis.fetch = (_input, init) => {
|
||||
assert.ok(init?.signal);
|
||||
const childSignal = init.signal;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
childSignal.addEventListener("abort", () => controller.error(childSignal.reason), { once: true });
|
||||
},
|
||||
pull() {
|
||||
markBodyRead?.();
|
||||
},
|
||||
});
|
||||
return Promise.resolve(new Response(body, { headers: { "content-type": "application/json" } }));
|
||||
};
|
||||
|
||||
try {
|
||||
// When: the page unmounts while the body is being consumed.
|
||||
const request = requestOnboardingWithRecovery(
|
||||
parent.signal,
|
||||
() => undefined,
|
||||
{ requestTimeoutMs: 10_000, retryDelayMs: 10_000, maxAttempts: 3 },
|
||||
);
|
||||
await bodyRead;
|
||||
parent.abort(new DOMException("page unmounted", "AbortError"));
|
||||
|
||||
// Then: parsing is cancelled with the lifecycle reason, not converted into a schema error.
|
||||
await assert.rejects(request, (error: unknown) => error instanceof DOMException && error.name === "AbortError");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -28,6 +28,25 @@ test("keeps starter questions visible while the user edits a draft", () => {
|
||||
assert.doesNotMatch(starterVisibilityGuard, /\bdraft\b/);
|
||||
});
|
||||
|
||||
test("keeps onboarding recovery alive after safe defaults become visible", () => {
|
||||
// Given: the homepage owns one recovery sequence per completed account profile.
|
||||
const recoveryEffect = sourceBetween(
|
||||
pageSource,
|
||||
"const onboardingRequestIdentity =",
|
||||
" useEffect(() => {\n if (!hydrated || !profileComplete || birthTimeDisplayState(profile)) return;",
|
||||
);
|
||||
|
||||
// When: the request identity, client call, and effect dependencies are inspected.
|
||||
// Then: fallback state cannot cancel recovery and only authentication triggers login.
|
||||
assert.match(pageSource, /requestOnboardingWithRecovery/);
|
||||
assert.match(pageSource, /OnboardingAuthenticationError/);
|
||||
assert.match(recoveryEffect, /onboardingRequestIdentity\.current === requestIdentity/);
|
||||
assert.match(recoveryEffect, /requestOnboardingWithRecovery\(controller\.signal,/);
|
||||
assert.match(recoveryEffect, /caught instanceof OnboardingAuthenticationError/);
|
||||
assert.match(recoveryEffect, /\}, \[accountId, hydrated, profileComplete\]\);/);
|
||||
assert.doesNotMatch(pageSource, /function readOnboarding\(/);
|
||||
});
|
||||
|
||||
test("keeps follow-up suggestions visible while the user edits a draft", () => {
|
||||
// Given: the follow-up suggestion block and its render guard.
|
||||
const suggestionGuard = sourceBetween(
|
||||
|
||||
Reference in New Issue
Block a user