diff --git a/frontend/components/XamanLoginPanel.tsx b/frontend/components/XamanLoginPanel.tsx index 76b2dba..a0e5ab7 100644 --- a/frontend/components/XamanLoginPanel.tsx +++ b/frontend/components/XamanLoginPanel.tsx @@ -266,6 +266,7 @@ function retryAfterMilliseconds(response: Response): number { } type LoginStartRetryReason = "rate-limited" | "temporarily-unavailable"; +type EmbeddedLoginPreparationPhase = LoginStartRetryReason | "waking-up"; export async function startLoginWithRetry( signal: AbortSignal, @@ -319,6 +320,22 @@ export async function startLoginWithRetry( throw new BackendRequestTimeoutError(); } +export async function prepareEmbeddedLogin( + signal: AbortSignal, + onProgress: (phase: EmbeddedLoginPreparationPhase) => void, + retryWindowMs = EMBEDDED_LOGIN_START_RETRY_WINDOW_MS +): Promise { + // A GET readiness probe reliably wakes a spun-down Render Free backend. + // Sending login/start as the first request can be rejected by Render's edge + // with 429 responses before the Python service has started. + if (signal.aborted) { + throw signal.reason ?? new Error("Login cancelled"); + } + onProgress("waking-up"); + await waitForBackendReady(BACKEND_BASE_URL, signal); + return startLoginWithRetry(signal, onProgress, retryWindowMs); +} + export function XamanLoginPanel() { const [isLoading, setIsLoading] = useState(false); const [isLoggingOut, setIsLoggingOut] = useState(false); @@ -601,11 +618,13 @@ export function XamanLoginPanel() { ); try { - const data = await startLoginWithRetry( + const data = await prepareEmbeddedLogin( controller.signal, (reason) => { const message = - reason === "rate-limited" + reason === "waking-up" + ? "Xaman is ready. Starting the secure CalorieApp service. This can take about a minute. Keep this page open." + : reason === "rate-limited" ? "Xaman is ready. CalorieApp is temporarily busy and will retry automatically. Keep this page open." : "Xaman is ready. CalorieApp is still starting and will retry automatically. Keep this page open."; setLoginStatus(message); diff --git a/tools/tests/xaman_login_start_retry.test.mjs b/tools/tests/xaman_login_start_retry.test.mjs index 7833e89..0073182 100644 --- a/tools/tests/xaman_login_start_retry.test.mjs +++ b/tools/tests/xaman_login_start_retry.test.mjs @@ -118,3 +118,167 @@ test("login start retries transport errors and transient responses", async () => ]); assert.equal(result.state, "state-abcdefghijklmnopqrstuvwxyz-0123456789"); }); + +test("embedded login wakes the backend before creating login state", async () => { + const typescript = requireFromFrontend("typescript"); + const source = await readFile(COMPONENT_PATH, "utf8"); + const compiled = typescript.transpileModule(source, { + compilerOptions: { + jsx: typescript.JsxEmit.ReactJSX, + module: typescript.ModuleKind.CommonJS, + target: typescript.ScriptTarget.ES2022, + }, + }).outputText; + + const events = []; + const loginResponse = { + ok: true, + status: 200, + headers: { get: () => null }, + json: async () => ({ + state: "state-abcdefghijklmnopqrstuvwxyz-0123456789", + expires_at: "2099-01-01T00:00:00Z", + wordpress_signin_url: "https://calorietoken.net/?xl-signin=1", + browser_handoff_token: "token-abcdefghijklmnopqrstuvwxyz-0123456789", + }), + }; + + class TestBackendRequestTimeoutError extends Error {} + const module = { exports: {} }; + const context = vm.createContext({ + AbortController, + Error, + Math, + Number, + Promise, + URL, + clearImmediate, + console, + module, + exports: module.exports, + process: { env: {} }, + require(specifier) { + if (specifier === "react") { + return {}; + } + if (specifier === "react/jsx-runtime") { + return { Fragment: Symbol("Fragment"), jsx() {}, jsxs() {} }; + } + if (specifier === "@/components/authEvents") { + return { announceAuthState() {} }; + } + if (specifier === "@/lib/backendRequest") { + return { + backendRequest: async () => { + events.push("login-start"); + return loginResponse; + }, + backendUnavailableMessage: (_error, fallback) => fallback, + BackendRequestTimeoutError: TestBackendRequestTimeoutError, + waitForBackendReady: async () => { + events.push("backend-ready"); + }, + }; + } + throw new Error(`Unexpected require: ${specifier}`); + }, + setImmediate, + window: { + clearTimeout(timer) { + clearImmediate(timer); + }, + setTimeout(callback) { + return setImmediate(callback); + }, + }, + }); + + vm.runInContext(compiled, context); + const phases = []; + const result = await module.exports.prepareEmbeddedLogin( + new AbortController().signal, + (phase) => phases.push(phase), + 10_000 + ); + + assert.deepEqual(events, ["backend-ready", "login-start"]); + assert.deepEqual(phases, ["waking-up"]); + assert.equal(result.state, "state-abcdefghijklmnopqrstuvwxyz-0123456789"); +}); + +test("embedded login does not report progress after cancellation", async () => { + const typescript = requireFromFrontend("typescript"); + const source = await readFile(COMPONENT_PATH, "utf8"); + const compiled = typescript.transpileModule(source, { + compilerOptions: { + jsx: typescript.JsxEmit.ReactJSX, + module: typescript.ModuleKind.CommonJS, + target: typescript.ScriptTarget.ES2022, + }, + }).outputText; + + const events = []; + class TestBackendRequestTimeoutError extends Error {} + const module = { exports: {} }; + const context = vm.createContext({ + AbortController, + Error, + Math, + Number, + Promise, + URL, + clearImmediate, + console, + module, + exports: module.exports, + process: { env: {} }, + require(specifier) { + if (specifier === "react") { + return {}; + } + if (specifier === "react/jsx-runtime") { + return { Fragment: Symbol("Fragment"), jsx() {}, jsxs() {} }; + } + if (specifier === "@/components/authEvents") { + return { announceAuthState() {} }; + } + if (specifier === "@/lib/backendRequest") { + return { + backendRequest: async () => { + events.push("login-start"); + }, + backendUnavailableMessage: (_error, fallback) => fallback, + BackendRequestTimeoutError: TestBackendRequestTimeoutError, + waitForBackendReady: async () => { + events.push("backend-ready"); + }, + }; + } + throw new Error(`Unexpected require: ${specifier}`); + }, + setImmediate, + window: { + clearTimeout(timer) { + clearImmediate(timer); + }, + setTimeout(callback) { + return setImmediate(callback); + }, + }, + }); + + vm.runInContext(compiled, context); + const controller = new AbortController(); + const abortReason = new Error("cancelled before login"); + controller.abort(abortReason); + + await assert.rejects( + module.exports.prepareEmbeddedLogin( + controller.signal, + (phase) => events.push(phase), + 10_000 + ), + abortReason + ); + assert.deepEqual(events, []); +});