fix(studio): correct save failure telemetry (#3499)

This commit is contained in:
Miguel Ángel
2026-08-30 00:24:46 -04:00
committed by GitHub
parent 0fd70b1d21
commit 28be8dddfa
8 changed files with 248 additions and 31 deletions
@@ -1,12 +1,16 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
consumeStudioWriteToken,
createStudioWriteToken,
markStudioWriteToken,
resetStudioWriteTokens,
studioExpectedFileVersion,
studioFileContentVersion,
studioWriteHeaders,
} from "./studioFileVersion";
afterEach(() => vi.unstubAllGlobals());
describe("studioFileContentVersion", () => {
it("matches the strong SHA-256 ETag format used by studio-server", async () => {
await expect(studioFileContentVersion("abc")).resolves.toBe(
@@ -42,6 +46,38 @@ describe("studioFileContentVersion", () => {
});
describe("studio write-token echo identity", () => {
it("prefers the platform randomUUID implementation", () => {
const randomUUID = vi.fn(() => "11111111-2222-4333-8444-555555555555");
vi.stubGlobal("crypto", { randomUUID });
resetStudioWriteTokens();
expect(studioWriteHeaders()).toEqual({
"X-Hyperframes-Write-Token": "11111111-2222-4333-8444-555555555555",
});
expect(randomUUID).toHaveBeenCalledOnce();
expect(consumeStudioWriteToken("11111111-2222-4333-8444-555555555555")).toBe(true);
});
it("creates an RFC 4122 UUID-v4 token from getRandomValues when randomUUID is unavailable", () => {
const source = Uint8Array.from({ length: 16 }, (_, index) => index);
vi.stubGlobal("crypto", {
getRandomValues: vi.fn((target: Uint8Array) => {
target.set(source);
return target;
}),
});
expect(createStudioWriteToken()).toBe("00010203-0405-4607-8809-0a0b0c0d0e0f");
});
it("fails explicitly when Web Crypto cannot provide secure random bytes", () => {
vi.stubGlobal("crypto", {});
expect(() => createStudioWriteToken()).toThrow(
"Web Crypto getRandomValues is required for Studio write identity",
);
});
it("suppresses exactly one matching API write receipt without hiding path-only external writes", () => {
resetStudioWriteTokens();
markStudioWriteToken("studio-write-1");
+12 -2
View File
@@ -48,8 +48,18 @@ export async function studioExpectedFileVersion(
return versions.get(path);
}
function createStudioWriteToken(): string {
return globalThis.crypto.randomUUID();
export function createStudioWriteToken(): string {
const webCrypto = globalThis.crypto;
if (typeof webCrypto?.randomUUID === "function") return webCrypto.randomUUID();
if (typeof webCrypto?.getRandomValues !== "function") {
throw new Error("Web Crypto getRandomValues is required for Studio write identity");
}
const bytes = webCrypto.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
/**
@@ -1,4 +1,8 @@
import { describe, expect, it, vi } from "vitest";
const trackStudioEvent = vi.hoisted(() => vi.fn());
vi.mock("./studioTelemetry", () => ({ trackStudioEvent }));
import {
StudioFileConflictError,
StudioSaveHttpError,
@@ -6,6 +10,7 @@ import {
buildStudioSaveFailureProperties,
getStudioSaveStatusCode,
retryStudioSave,
trackStudioEditBlocked,
} from "./studioSaveDiagnostics";
describe("studio save diagnostics", () => {
@@ -51,6 +56,30 @@ describe("studio save diagnostics", () => {
});
});
it("emits expected direct-edit refusals on edit_blocked", () => {
const error = new Error("This animation is computed at runtime");
trackStudioEditBlocked({
source: "gsap_commit",
error,
filePath: "index.html",
mutationType: "drag",
});
expect(trackStudioEvent).toHaveBeenCalledWith("edit_blocked", {
source: "gsap_commit",
error_message: error.message,
status_code: null,
file_path: "index.html",
mutation_type: "drag",
attempt: undefined,
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 };
@@ -172,6 +172,10 @@ export function trackStudioSaveFailure(input: StudioSaveFailureInput): void {
trackStudioEvent("save_failure", buildStudioSaveFailureProperties(input));
}
export function trackStudioEditBlocked(input: StudioSaveFailureInput): void {
trackStudioEvent("edit_blocked", buildStudioSaveFailureProperties(input));
}
export async function createStudioSaveHttpError(
response: Response,
fallbackMessage: string,