Files
Jyotisha/frontend/tests/onboarding-client.test.ts
T
Jesse_Chen a6d4473ef0 feat(onboarding): write a question for every consultation domain and stop generating an unread greeting
The home screen renders all ten domains from the consultation registry,
but the Agent only ever wrote three of them; the other seven were static
registry prompts dressed up as personalized starting points. The payload
now has to cover every domain in registry order, validated as a set
rather than per item, so a short or misordered answer is rejected whole
instead of silently leaving cards on static copy.

The greeting went the other way. Nothing has rendered it since the hero
note was removed, so it leaves the schema, the prompt, and the client
contract rather than costing tokens for text no one reads.

Ten questions take much longer to generate than three, so the route,
the server generation budget, and the client request deadline all grow
together, and the cache version bump forces existing payloads to be
regenerated once under the new shape.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 15:44:19 +08:00

315 lines
11 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import {
OnboardingAuthenticationError,
OnboardingRequestError,
requestOnboardingWithRecovery,
} from "../src/lib/onboarding-client.ts";
import { onboardingSuggestionThemes } from "../src/lib/onboarding-payload.ts";
const personalizedOnboarding = {
suggestions: onboardingSuggestionThemes.map((theme, index) => ({
theme,
text: `请帮我看看${theme}方向第${index + 1}个重点。`,
})),
source: "cache",
} as const;
test("default request deadline leaves enough room for server-side Agent generation", async () => {
const originalFetch = globalThis.fetch;
const originalSetTimeout = globalThis.setTimeout;
let observedRequestDeadline: number | null = null;
globalThis.fetch = () => Promise.resolve(Response.json(personalizedOnboarding));
globalThis.setTimeout = ((callback: TimerHandler, delay?: number, ...args: unknown[]) => {
observedRequestDeadline ??= Number(delay);
return originalSetTimeout(callback, delay, ...args);
}) as typeof globalThis.setTimeout;
try {
await requestOnboardingWithRecovery(
new AbortController().signal,
() => undefined,
);
} finally {
globalThis.fetch = originalFetch;
globalThis.setTimeout = originalSetTimeout;
}
// One question per consultation domain takes far longer than the three-question era,
// so a single attempt must outlast the route's own generation budget.
assert.ok(observedRequestDeadline !== null);
assert.ok(observedRequestDeadline >= 45_000, `request deadline was only ${observedRequestDeadline}ms`);
});
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, { 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 partially covered terminal response with a typed error", async () => {
// Given: a cached payload from the era when only three domains were generated.
const originalFetch = globalThis.fetch;
globalThis.fetch = () => Promise.resolve(Response.json({
source: "cache",
greeting: "林遥,欢迎回来。",
suggestions: [
{ theme: "career", text: "请帮我看看事业方向。" },
{ theme: "marriage", text: "请帮我看看关系模式。" },
{ theme: "timing", text: "请帮我看看时机安排。" },
],
}));
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;
}
});
test("parent cancellation interrupts a pending-response retry delay", async () => {
// Given: the first response is pending and the next retry has a long delay.
const originalFetch = globalThis.fetch;
const parent = new AbortController();
let requestCount = 0;
globalThis.fetch = () => {
requestCount += 1;
return Promise.resolve(Response.json({ ...personalizedOnboarding, source: "pending" }));
};
try {
// When: the page unmounts after the provisional response enters its retry wait.
const request = requestOnboardingWithRecovery(parent.signal, () => undefined, {
requestTimeoutMs: 10_000, retryDelayMs: 10_000, maxAttempts: 3,
});
await new Promise<void>((resolve) => setImmediate(resolve));
parent.abort(new DOMException("page unmounted", "AbortError"));
// Then: the delay is cancelled and no second request starts.
await assert.rejects(request, (error: unknown) => error instanceof DOMException && error.name === "AbortError");
assert.equal(requestCount, 1);
} finally {
globalThis.fetch = originalFetch;
}
});