import { describe, expect, it, vi } from "vitest"; import { StudioSaveHttpError, StudioSaveNetworkError, buildStudioSaveFailureProperties, getStudioSaveStatusCode, retryStudioSave, } from "./studioSaveDiagnostics"; describe("studio save diagnostics", () => { it("builds save_failure properties with stable diagnostics", () => { const error = new StudioSaveHttpError("Failed to save index.html (503)", 503); expect( buildStudioSaveFailureProperties({ source: "code_editor", error, filePath: "index.html", mutationType: "put", attempt: 3, }), ).toEqual({ source: "code_editor", error_message: "Failed to save index.html (503)", status_code: 503, file_path: "index.html", mutation_type: "put", attempt: 3, label: undefined, target_id: undefined, target_selector: undefined, target_source_file: undefined, }); }); it("reads nested status codes from error causes", () => { const cause = new StudioSaveHttpError("Too many requests", 429); const error = new Error("retry wrapper") as Error & { cause?: unknown }; error.cause = cause; expect(getStudioSaveStatusCode(error)).toBe(429); }); it("retries transient save failures with exponential backoff and jitter", async () => { const sleeps: number[] = []; const operation = vi .fn<(attempt: number) => Promise>() .mockRejectedValueOnce(new StudioSaveHttpError("Server restarting", 503)) .mockRejectedValueOnce(new StudioSaveHttpError("Still restarting", 503)) .mockRejectedValueOnce(new StudioSaveHttpError("Almost ready", 503)) .mockResolvedValue("saved"); await expect( retryStudioSave(operation, { random: () => 0.5, sleep: async (delayMs) => { sleeps.push(delayMs); }, }), ).resolves.toBe("saved"); expect(operation).toHaveBeenCalledTimes(4); expect(operation.mock.calls.map(([attempt]) => attempt)).toEqual([1, 2, 3, 4]); expect(sleeps).toEqual([500, 1000, 2000]); }); it("does not retry non-transient client failures", async () => { const operation = vi .fn<(attempt: number) => Promise>() .mockRejectedValue(new StudioSaveHttpError("Too large", 413)); await expect( retryStudioSave(operation, { sleep: async () => {}, }), ).rejects.toThrow("Too large"); expect(operation).toHaveBeenCalledTimes(1); }); it("retries typed network failures", async () => { const operation = vi .fn<(attempt: number) => Promise>() .mockRejectedValueOnce(new StudioSaveNetworkError("network dropped")) .mockResolvedValue("saved"); await expect( retryStudioSave(operation, { sleep: async () => {}, }), ).resolves.toBe("saved"); expect(operation).toHaveBeenCalledTimes(2); }); it("does not retry plain JavaScript errors", async () => { const operation = vi .fn<(attempt: number) => Promise>() .mockRejectedValue(new Error("local assertion failed")); await expect( retryStudioSave(operation, { sleep: async () => {}, }), ).rejects.toThrow("local assertion failed"); expect(operation).toHaveBeenCalledTimes(1); }); it("aborts while waiting between retry attempts", async () => { const controller = new AbortController(); const operation = vi .fn<(attempt: number) => Promise>() .mockRejectedValue(new StudioSaveHttpError("Server restarting", 503)); const pending = retryStudioSave(operation, { signal: controller.signal, sleep: async (_delayMs, signal) => { controller.abort(); if (signal?.aborted) throw new DOMException("Save aborted", "AbortError"); }, }); await expect(pending).rejects.toMatchObject({ name: "AbortError" }); expect(operation).toHaveBeenCalledTimes(1); }); });