mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-15 13:58:20 +00:00
* 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
118 lines
3.2 KiB
TypeScript
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();
|
|
});
|
|
});
|