Files
hyperframes/packages/studio/src/utils/domEditSaveQueue.test.ts
T
Miguel Ángel 5f12e692d5 fix(studio): save retries, mutation queue circuit breaker, save_failure diagnostics (#1366)
* fix(studio): save retries, mutation queue circuit breaker, save_failure diagnostics

Save failures could silently drop user work: code-editor saves fired a single
PUT with no retry, DOM-edit failures drained the whole queue against a failing
server, and several failure paths only logged to the console.

- Retry code-editor saves with exponential backoff instead of dropping the
  edit on the first failed PUT.
- Circuit breaker on the DOM-edit save queue: a failing server pauses the
  queue with a user-visible error state instead of burning every queued
  mutation against it.
- save_failure events now carry error_message, status_code, and source on
  every emission path; style/attribute DOM-edit failures that previously only
  logged to the console now emit telemetry too.
- Route unawaited commitMutation call sites (GSAP drag, property scrubbing,
  undo/redo, text fields) through a safe wrapper that reports failures via
  telemetry instead of unhandledrejection.

Follow-ups (deferred): version/ETag conflict guard on file PUTs, offline
save queue.

* fix(studio): narrow save retry changes for fallow
2026-06-12 22:02:17 -04:00

118 lines
3.2 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { createDomEditSaveQueue } from "./domEditSaveQueue";
import { StudioSaveHttpError } from "./studioSaveDiagnostics";
describe("dom edit save queue", () => {
afterEach(() => {
vi.useRealTimers();
});
it("opens the breaker after consecutive failures and rejects new work until reset", async () => {
const onOpen = vi.fn();
const onReset = vi.fn();
const queue = createDomEditSaveQueue({
failureThreshold: 2,
onOpen,
onReset,
});
await expect(
queue.enqueue(async () => {
throw new StudioSaveHttpError("Server down", 503);
}),
).rejects.toThrow("Server down");
await expect(
queue.enqueue(async () => {
throw new StudioSaveHttpError("Still down", 503);
}),
).rejects.toThrow("Still down");
expect(onOpen).toHaveBeenCalledWith({
consecutiveFailures: 2,
errorMessage: "Still down",
statusCode: 503,
});
let thirdRan = false;
await expect(
queue.enqueue(async () => {
thirdRan = true;
}),
).rejects.toThrow("Auto-save is paused");
expect(thirdRan).toBe(false);
queue.reset();
expect(onReset).toHaveBeenCalledOnce();
await queue.enqueue(async () => {
thirdRan = true;
});
expect(thirdRan).toBe(true);
queue.destroy();
});
it("keeps an open breaker paused even when already queued work succeeds", async () => {
const onOpen = vi.fn();
const onReset = vi.fn();
const queue = createDomEditSaveQueue({
failureThreshold: 1,
onOpen,
onReset,
});
let rejectFirst: ((error: Error) => void) | null = null;
let resolveFirstStarted: (() => void) | null = null;
const firstStarted = new Promise<void>((resolve) => {
resolveFirstStarted = resolve;
});
const first = queue.enqueue(
() =>
new Promise<void>((_resolve, reject) => {
rejectFirst = reject;
resolveFirstStarted?.();
}),
);
const second = queue.enqueue(async () => {});
await firstStarted;
expect(rejectFirst).toBeTypeOf("function");
rejectFirst?.(new StudioSaveHttpError("Server down", 503));
await expect(first).rejects.toThrow("Server down");
await expect(second).resolves.toBeUndefined();
expect(onOpen).toHaveBeenCalledOnce();
expect(onReset).not.toHaveBeenCalled();
await expect(queue.enqueue(async () => {})).rejects.toThrow("Auto-save is paused");
queue.reset();
expect(onReset).toHaveBeenCalledOnce();
queue.destroy();
});
it("resets consecutive failures after a successful save", async () => {
const onOpen = vi.fn();
const queue = createDomEditSaveQueue({
failureThreshold: 2,
onOpen,
});
await expect(
queue.enqueue(async () => {
throw new Error("first failure");
}),
).rejects.toThrow("first failure");
await queue.enqueue(async () => {});
await expect(
queue.enqueue(async () => {
throw new Error("second failure after success");
}),
).rejects.toThrow("second failure after success");
expect(onOpen).not.toHaveBeenCalled();
queue.destroy();
});
});