@hyperframes/studio 0.8.19 → 0.8.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-BA-QO9CQ.js → hyperframes-player-BEKxuimO.js} +1 -1
- package/dist/assets/{index-Dd0fsIIL.js → index-BL1zYTW0.js} +1 -1
- package/dist/assets/{index-Bjd2hioj.js → index-CBSKXKAr.js} +118 -118
- package/dist/assets/{index-DxccOrkZ.js → index-Cehosfnu.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +36 -7
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/hooks/useEditorSave.test.tsx +81 -3
- package/src/hooks/useEditorSave.ts +31 -6
- package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +48 -16
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -2
- package/src/utils/studioFileVersion.test.ts +37 -1
- package/src/utils/studioFileVersion.ts +12 -2
- package/src/utils/studioSaveDiagnostics.test.ts +29 -0
- package/src/utils/studioSaveDiagnostics.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.20",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -48,11 +48,11 @@
|
|
|
48
48
|
"gsap": "^3.13.0",
|
|
49
49
|
"marked": "^14.1.4",
|
|
50
50
|
"mediabunny": "^1.45.3",
|
|
51
|
-
"@hyperframes/core": "0.8.
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/player": "0.8.
|
|
54
|
-
"@hyperframes/
|
|
55
|
-
"@hyperframes/
|
|
51
|
+
"@hyperframes/core": "0.8.20",
|
|
52
|
+
"@hyperframes/sdk": "0.8.20",
|
|
53
|
+
"@hyperframes/player": "0.8.20",
|
|
54
|
+
"@hyperframes/studio-server": "0.8.20",
|
|
55
|
+
"@hyperframes/parsers": "0.8.20"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@types/react": "19",
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
"vite": "^6.4.2",
|
|
70
70
|
"vitest": "^3.2.4",
|
|
71
71
|
"zustand": "^5.0.0",
|
|
72
|
-
"@hyperframes/producer": "0.8.
|
|
72
|
+
"@hyperframes/producer": "0.8.20"
|
|
73
73
|
},
|
|
74
74
|
"peerDependencies": {
|
|
75
75
|
"react": "19",
|
|
@@ -2,8 +2,15 @@
|
|
|
2
2
|
import { act } from "react";
|
|
3
3
|
import { createRoot } from "react-dom/client";
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
-
|
|
5
|
+
|
|
6
|
+
const trackStudioSaveFailure = vi.hoisted(() => vi.fn());
|
|
7
|
+
vi.mock("../utils/studioSaveDiagnostics", async (importOriginal) => ({
|
|
8
|
+
...(await importOriginal<typeof import("../utils/studioSaveDiagnostics")>()),
|
|
9
|
+
trackStudioSaveFailure,
|
|
10
|
+
}));
|
|
11
|
+
|
|
6
12
|
import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
|
|
13
|
+
import { useEditorSave, type EditorSaveHandle } from "./useEditorSave";
|
|
7
14
|
|
|
8
15
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
16
|
|
|
@@ -11,6 +18,7 @@ type WriteProjectFile = (path: string, content: string, expectedContent?: string
|
|
|
11
18
|
|
|
12
19
|
async function mountEditorSave(writeProjectFile: WriteProjectFile) {
|
|
13
20
|
const captured: { handle: EditorSaveHandle | null } = { handle: null };
|
|
21
|
+
const showToast = vi.fn();
|
|
14
22
|
|
|
15
23
|
function Probe() {
|
|
16
24
|
captured.handle = useEditorSave({
|
|
@@ -21,7 +29,7 @@ async function mountEditorSave(writeProjectFile: WriteProjectFile) {
|
|
|
21
29
|
recordEdit: vi.fn(async () => undefined),
|
|
22
30
|
domEditSaveTimestampRef: { current: 0 },
|
|
23
31
|
setRefreshKey: vi.fn(),
|
|
24
|
-
showToast
|
|
32
|
+
showToast,
|
|
25
33
|
});
|
|
26
34
|
return null;
|
|
27
35
|
}
|
|
@@ -32,12 +40,14 @@ async function mountEditorSave(writeProjectFile: WriteProjectFile) {
|
|
|
32
40
|
|
|
33
41
|
return {
|
|
34
42
|
handle: captured.handle,
|
|
43
|
+
showToast,
|
|
35
44
|
unmount: () => act(async () => root.unmount()),
|
|
36
45
|
};
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
describe("useEditorSave pending work", () => {
|
|
40
49
|
beforeEach(() => {
|
|
50
|
+
trackStudioSaveFailure.mockClear();
|
|
41
51
|
vi.stubGlobal(
|
|
42
52
|
"requestAnimationFrame",
|
|
43
53
|
vi.fn(() => 41),
|
|
@@ -45,7 +55,10 @@ describe("useEditorSave pending work", () => {
|
|
|
45
55
|
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
|
46
56
|
});
|
|
47
57
|
|
|
48
|
-
afterEach(() =>
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
vi.restoreAllMocks();
|
|
60
|
+
vi.unstubAllGlobals();
|
|
61
|
+
});
|
|
49
62
|
|
|
50
63
|
it("exposes and flushes the latest rAF-buffered source candidate", async () => {
|
|
51
64
|
const writeProjectFile = vi.fn(async () => undefined);
|
|
@@ -111,7 +124,72 @@ describe("useEditorSave pending work", () => {
|
|
|
111
124
|
status: "conflict",
|
|
112
125
|
error: conflict,
|
|
113
126
|
});
|
|
127
|
+
expect(trackStudioSaveFailure).toHaveBeenCalledWith({
|
|
128
|
+
source: "code_editor",
|
|
129
|
+
error: conflict,
|
|
130
|
+
filePath: "index.html",
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await mounted.unmount();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("emits one identical failure per five-second burst", async () => {
|
|
137
|
+
vi.spyOn(Date, "now").mockReturnValue(1_000);
|
|
138
|
+
const error = new Error("Load failed");
|
|
139
|
+
const mounted = await mountEditorSave(async () => {
|
|
140
|
+
throw error;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
act(() => mounted.handle.handleContentChange("first candidate"));
|
|
144
|
+
await mounted.handle.flushPendingSave();
|
|
145
|
+
vi.spyOn(Date, "now").mockReturnValue(2_000);
|
|
146
|
+
act(() => mounted.handle.handleContentChange("second candidate"));
|
|
147
|
+
await mounted.handle.flushPendingSave();
|
|
148
|
+
|
|
149
|
+
expect(trackStudioSaveFailure).toHaveBeenCalledOnce();
|
|
150
|
+
expect(mounted.showToast).toHaveBeenCalledOnce();
|
|
151
|
+
await mounted.unmount();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("emits a changed failure immediately and repeats after the burst window", async () => {
|
|
155
|
+
const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
|
|
156
|
+
const writeProjectFile = vi
|
|
157
|
+
.fn<WriteProjectFile>()
|
|
158
|
+
.mockRejectedValueOnce(new Error("Load failed"))
|
|
159
|
+
.mockRejectedValueOnce(new Error("Failed to fetch"))
|
|
160
|
+
.mockRejectedValueOnce(new Error("Failed to fetch"));
|
|
161
|
+
const mounted = await mountEditorSave(writeProjectFile);
|
|
162
|
+
|
|
163
|
+
act(() => mounted.handle.handleContentChange("first candidate"));
|
|
164
|
+
await mounted.handle.flushPendingSave();
|
|
165
|
+
now.mockReturnValue(2_000);
|
|
166
|
+
act(() => mounted.handle.handleContentChange("second candidate"));
|
|
167
|
+
await mounted.handle.flushPendingSave();
|
|
168
|
+
now.mockReturnValue(8_000);
|
|
169
|
+
act(() => mounted.handle.handleContentChange("third candidate"));
|
|
170
|
+
await mounted.handle.flushPendingSave();
|
|
171
|
+
|
|
172
|
+
expect(trackStudioSaveFailure).toHaveBeenCalledTimes(3);
|
|
173
|
+
await mounted.unmount();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("emits the same failure again after a successful save", async () => {
|
|
177
|
+
vi.spyOn(Date, "now").mockReturnValue(1_000);
|
|
178
|
+
const writeProjectFile = vi
|
|
179
|
+
.fn<WriteProjectFile>()
|
|
180
|
+
.mockRejectedValueOnce(new Error("Load failed"))
|
|
181
|
+
.mockResolvedValueOnce(undefined)
|
|
182
|
+
.mockRejectedValueOnce(new Error("Load failed"));
|
|
183
|
+
const mounted = await mountEditorSave(writeProjectFile);
|
|
184
|
+
|
|
185
|
+
act(() => mounted.handle.handleContentChange("first candidate"));
|
|
186
|
+
await mounted.handle.flushPendingSave();
|
|
187
|
+
act(() => mounted.handle.handleContentChange("successful candidate"));
|
|
188
|
+
await mounted.handle.flushPendingSave();
|
|
189
|
+
act(() => mounted.handle.handleContentChange("third candidate"));
|
|
190
|
+
await mounted.handle.flushPendingSave();
|
|
114
191
|
|
|
192
|
+
expect(trackStudioSaveFailure).toHaveBeenCalledTimes(2);
|
|
115
193
|
await mounted.unmount();
|
|
116
194
|
});
|
|
117
195
|
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { useCallback, useRef } from "react";
|
|
2
2
|
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
3
3
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
4
|
-
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
5
4
|
import {
|
|
6
5
|
StudioFileConflictError,
|
|
6
|
+
buildStudioSaveFailureProperties,
|
|
7
|
+
trackStudioSaveFailure,
|
|
7
8
|
type StudioSaveDrainResult,
|
|
8
9
|
} from "../utils/studioSaveDiagnostics";
|
|
9
10
|
|
|
11
|
+
const FAILURE_BURST_MS = 5_000;
|
|
12
|
+
|
|
10
13
|
interface RecordEditInput {
|
|
11
14
|
label: string;
|
|
12
15
|
kind: EditHistoryKind;
|
|
@@ -58,19 +61,40 @@ export function useEditorSave({
|
|
|
58
61
|
const refreshRafRef = useRef<number | null>(null);
|
|
59
62
|
// One error toast per burst of failures — every keystroke retries the save,
|
|
60
63
|
// and error toasts persist until dismissed, so don't stack duplicates.
|
|
61
|
-
const lastFailureToastAtRef = useRef(
|
|
64
|
+
const lastFailureToastAtRef = useRef<number | null>(null);
|
|
65
|
+
const lastFailureReportRef = useRef<{ fingerprint: string; emittedAt: number } | null>(null);
|
|
62
66
|
const pendingCandidateRef = useRef<EditorSaveCandidate | null>(null);
|
|
63
67
|
const inFlightRef = useRef<Promise<EditorSaveDrainResult> | null>(null);
|
|
64
68
|
const inFlightCandidateRef = useRef<EditorSaveCandidate | null>(null);
|
|
65
69
|
|
|
66
70
|
const reportFailure = useCallback(
|
|
67
71
|
(path: string, error: unknown) => {
|
|
68
|
-
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
const properties = buildStudioSaveFailureProperties({
|
|
69
74
|
source: "code_editor",
|
|
70
|
-
|
|
75
|
+
error,
|
|
76
|
+
filePath: path,
|
|
71
77
|
});
|
|
72
|
-
const
|
|
73
|
-
|
|
78
|
+
const errorName = error instanceof Error ? error.name : typeof error;
|
|
79
|
+
const fingerprint = JSON.stringify([
|
|
80
|
+
path,
|
|
81
|
+
errorName,
|
|
82
|
+
properties.error_message,
|
|
83
|
+
properties.status_code,
|
|
84
|
+
]);
|
|
85
|
+
const previous = lastFailureReportRef.current;
|
|
86
|
+
if (
|
|
87
|
+
previous === null ||
|
|
88
|
+
previous.fingerprint !== fingerprint ||
|
|
89
|
+
now - previous.emittedAt >= FAILURE_BURST_MS
|
|
90
|
+
) {
|
|
91
|
+
trackStudioSaveFailure({ source: "code_editor", error, filePath: path });
|
|
92
|
+
lastFailureReportRef.current = { fingerprint, emittedAt: now };
|
|
93
|
+
}
|
|
94
|
+
if (
|
|
95
|
+
lastFailureToastAtRef.current === null ||
|
|
96
|
+
now - lastFailureToastAtRef.current >= FAILURE_BURST_MS
|
|
97
|
+
) {
|
|
74
98
|
lastFailureToastAtRef.current = now;
|
|
75
99
|
showToast(
|
|
76
100
|
`Couldn't save ${path} — your latest edits are NOT persisted. Check the preview server; editing again retries the save.`,
|
|
@@ -95,6 +119,7 @@ export function useEditorSave({
|
|
|
95
119
|
})
|
|
96
120
|
.then<EditorSaveDrainResult>(() => {
|
|
97
121
|
if (pendingCandidateRef.current === candidate) pendingCandidateRef.current = null;
|
|
122
|
+
lastFailureReportRef.current = null;
|
|
98
123
|
if (refreshRafRef.current != null) cancelAnimationFrame(refreshRafRef.current);
|
|
99
124
|
refreshRafRef.current = requestAnimationFrame(() => setRefreshKey((k) => k + 1));
|
|
100
125
|
return { status: "clean" };
|
|
@@ -1,32 +1,49 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
|
-
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
6
6
|
import { mountReactHarness } from "./domSelectionTestHarness";
|
|
7
7
|
import { GsapEditBlockedError } from "./gsapEditOutcome";
|
|
8
8
|
|
|
9
9
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
10
|
|
|
11
|
-
const trackStudioSaveFailure = vi.hoisted(() =>
|
|
12
|
-
vi.
|
|
11
|
+
const { trackStudioEditBlocked, trackStudioSaveFailure } = vi.hoisted(() => ({
|
|
12
|
+
trackStudioEditBlocked: vi.fn(),
|
|
13
|
+
trackStudioSaveFailure: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
vi.mock("../utils/studioSaveDiagnostics", () => ({
|
|
16
|
+
trackStudioEditBlocked,
|
|
17
|
+
trackStudioSaveFailure,
|
|
18
|
+
}));
|
|
13
19
|
|
|
14
20
|
import { useGsapInteractionFailureTelemetry } from "./useGsapInteractionFailureTelemetry";
|
|
15
21
|
|
|
22
|
+
const selection = {
|
|
23
|
+
id: "clip",
|
|
24
|
+
selector: "#clip",
|
|
25
|
+
element: document.createElement("div"),
|
|
26
|
+
} as unknown as DomEditSelection;
|
|
27
|
+
|
|
28
|
+
function mountFailureTelemetry(showToast: ReturnType<typeof vi.fn>) {
|
|
29
|
+
let report!: ReturnType<typeof useGsapInteractionFailureTelemetry>;
|
|
30
|
+
function Harness() {
|
|
31
|
+
report = useGsapInteractionFailureTelemetry("index.html", showToast);
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const root = mountReactHarness(<Harness />);
|
|
35
|
+
return { report, root };
|
|
36
|
+
}
|
|
37
|
+
|
|
16
38
|
describe("useGsapInteractionFailureTelemetry", () => {
|
|
17
|
-
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
trackStudioEditBlocked.mockClear();
|
|
41
|
+
trackStudioSaveFailure.mockClear();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("tracks an expected edit block separately from save failures", () => {
|
|
18
45
|
const showToast = vi.fn();
|
|
19
|
-
const
|
|
20
|
-
id: "clip",
|
|
21
|
-
selector: "#clip",
|
|
22
|
-
element: document.createElement("div"),
|
|
23
|
-
} as unknown as DomEditSelection;
|
|
24
|
-
let report!: ReturnType<typeof useGsapInteractionFailureTelemetry>;
|
|
25
|
-
function Harness() {
|
|
26
|
-
report = useGsapInteractionFailureTelemetry("index.html", showToast);
|
|
27
|
-
return null;
|
|
28
|
-
}
|
|
29
|
-
const root = mountReactHarness(<Harness />);
|
|
46
|
+
const { report, root } = mountFailureTelemetry(showToast);
|
|
30
47
|
|
|
31
48
|
act(() => report(new GsapEditBlockedError("unroll-required"), selection, "drag", "Move"));
|
|
32
49
|
|
|
@@ -34,9 +51,24 @@ describe("useGsapInteractionFailureTelemetry", () => {
|
|
|
34
51
|
"This motion comes from a helper or loop. Choose Unroll to edit it explicitly.",
|
|
35
52
|
"error",
|
|
36
53
|
);
|
|
37
|
-
expect(
|
|
54
|
+
expect(trackStudioEditBlocked).toHaveBeenCalledWith(
|
|
38
55
|
expect.objectContaining({ source: "gsap_commit", mutationType: "drag", targetId: "clip" }),
|
|
39
56
|
);
|
|
57
|
+
expect(trackStudioSaveFailure).not.toHaveBeenCalled();
|
|
58
|
+
act(() => root.unmount());
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("keeps unexpected GSAP persistence errors in save_failure", () => {
|
|
62
|
+
const showToast = vi.fn();
|
|
63
|
+
const { report, root } = mountFailureTelemetry(showToast);
|
|
64
|
+
const error = new Error("network dropped");
|
|
65
|
+
|
|
66
|
+
act(() => report(error, selection, "drag", "Move"));
|
|
67
|
+
|
|
68
|
+
expect(trackStudioSaveFailure).toHaveBeenCalledWith(
|
|
69
|
+
expect.objectContaining({ source: "gsap_commit", error, mutationType: "drag" }),
|
|
70
|
+
);
|
|
71
|
+
expect(trackStudioEditBlocked).not.toHaveBeenCalled();
|
|
40
72
|
act(() => root.unmount());
|
|
41
73
|
});
|
|
42
74
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
2
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
3
|
-
import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
|
|
3
|
+
import { trackStudioEditBlocked, trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
|
|
4
4
|
import { isGsapEditBlockedError } from "./gsapEditOutcome";
|
|
5
5
|
|
|
6
6
|
export function useGsapInteractionFailureTelemetry(
|
|
@@ -9,7 +9,10 @@ export function useGsapInteractionFailureTelemetry(
|
|
|
9
9
|
) {
|
|
10
10
|
return useCallback(
|
|
11
11
|
(error: unknown, selection: DomEditSelection | null, mutationType: string, label: string) => {
|
|
12
|
-
|
|
12
|
+
const report = isGsapEditBlockedError(error)
|
|
13
|
+
? trackStudioEditBlocked
|
|
14
|
+
: trackStudioSaveFailure;
|
|
15
|
+
report({
|
|
13
16
|
source: "gsap_commit",
|
|
14
17
|
error,
|
|
15
18
|
filePath: selection?.sourceFile ?? activeCompPath ?? "index.html",
|
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import {
|
|
3
3
|
consumeStudioWriteToken,
|
|
4
|
+
createStudioWriteToken,
|
|
4
5
|
markStudioWriteToken,
|
|
5
6
|
resetStudioWriteTokens,
|
|
6
7
|
studioExpectedFileVersion,
|
|
7
8
|
studioFileContentVersion,
|
|
9
|
+
studioWriteHeaders,
|
|
8
10
|
} from "./studioFileVersion";
|
|
9
11
|
|
|
12
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
13
|
+
|
|
10
14
|
describe("studioFileContentVersion", () => {
|
|
11
15
|
it("matches the strong SHA-256 ETag format used by studio-server", async () => {
|
|
12
16
|
await expect(studioFileContentVersion("abc")).resolves.toBe(
|
|
@@ -42,6 +46,38 @@ describe("studioFileContentVersion", () => {
|
|
|
42
46
|
});
|
|
43
47
|
|
|
44
48
|
describe("studio write-token echo identity", () => {
|
|
49
|
+
it("prefers the platform randomUUID implementation", () => {
|
|
50
|
+
const randomUUID = vi.fn(() => "11111111-2222-4333-8444-555555555555");
|
|
51
|
+
vi.stubGlobal("crypto", { randomUUID });
|
|
52
|
+
resetStudioWriteTokens();
|
|
53
|
+
|
|
54
|
+
expect(studioWriteHeaders()).toEqual({
|
|
55
|
+
"X-Hyperframes-Write-Token": "11111111-2222-4333-8444-555555555555",
|
|
56
|
+
});
|
|
57
|
+
expect(randomUUID).toHaveBeenCalledOnce();
|
|
58
|
+
expect(consumeStudioWriteToken("11111111-2222-4333-8444-555555555555")).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("creates an RFC 4122 UUID-v4 token from getRandomValues when randomUUID is unavailable", () => {
|
|
62
|
+
const source = Uint8Array.from({ length: 16 }, (_, index) => index);
|
|
63
|
+
vi.stubGlobal("crypto", {
|
|
64
|
+
getRandomValues: vi.fn((target: Uint8Array) => {
|
|
65
|
+
target.set(source);
|
|
66
|
+
return target;
|
|
67
|
+
}),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(createStudioWriteToken()).toBe("00010203-0405-4607-8809-0a0b0c0d0e0f");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("fails explicitly when Web Crypto cannot provide secure random bytes", () => {
|
|
74
|
+
vi.stubGlobal("crypto", {});
|
|
75
|
+
|
|
76
|
+
expect(() => createStudioWriteToken()).toThrow(
|
|
77
|
+
"Web Crypto getRandomValues is required for Studio write identity",
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
45
81
|
it("suppresses exactly one matching API write receipt without hiding path-only external writes", () => {
|
|
46
82
|
resetStudioWriteTokens();
|
|
47
83
|
markStudioWriteToken("studio-write-1");
|
|
@@ -48,8 +48,18 @@ export async function studioExpectedFileVersion(
|
|
|
48
48
|
return versions.get(path);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
function createStudioWriteToken(): string {
|
|
52
|
-
|
|
51
|
+
export function createStudioWriteToken(): string {
|
|
52
|
+
const webCrypto = globalThis.crypto;
|
|
53
|
+
if (typeof webCrypto?.randomUUID === "function") return webCrypto.randomUUID();
|
|
54
|
+
if (typeof webCrypto?.getRandomValues !== "function") {
|
|
55
|
+
throw new Error("Web Crypto getRandomValues is required for Studio write identity");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const bytes = webCrypto.getRandomValues(new Uint8Array(16));
|
|
59
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
60
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
61
|
+
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
62
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
53
63
|
}
|
|
54
64
|
|
|
55
65
|
/**
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const trackStudioEvent = vi.hoisted(() => vi.fn());
|
|
4
|
+
vi.mock("./studioTelemetry", () => ({ trackStudioEvent }));
|
|
5
|
+
|
|
2
6
|
import {
|
|
3
7
|
StudioFileConflictError,
|
|
4
8
|
StudioSaveHttpError,
|
|
@@ -6,6 +10,7 @@ import {
|
|
|
6
10
|
buildStudioSaveFailureProperties,
|
|
7
11
|
getStudioSaveStatusCode,
|
|
8
12
|
retryStudioSave,
|
|
13
|
+
trackStudioEditBlocked,
|
|
9
14
|
} from "./studioSaveDiagnostics";
|
|
10
15
|
|
|
11
16
|
describe("studio save diagnostics", () => {
|
|
@@ -51,6 +56,30 @@ describe("studio save diagnostics", () => {
|
|
|
51
56
|
});
|
|
52
57
|
});
|
|
53
58
|
|
|
59
|
+
it("emits expected direct-edit refusals on edit_blocked", () => {
|
|
60
|
+
const error = new Error("This animation is computed at runtime");
|
|
61
|
+
|
|
62
|
+
trackStudioEditBlocked({
|
|
63
|
+
source: "gsap_commit",
|
|
64
|
+
error,
|
|
65
|
+
filePath: "index.html",
|
|
66
|
+
mutationType: "drag",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(trackStudioEvent).toHaveBeenCalledWith("edit_blocked", {
|
|
70
|
+
source: "gsap_commit",
|
|
71
|
+
error_message: error.message,
|
|
72
|
+
status_code: null,
|
|
73
|
+
file_path: "index.html",
|
|
74
|
+
mutation_type: "drag",
|
|
75
|
+
attempt: undefined,
|
|
76
|
+
label: undefined,
|
|
77
|
+
target_id: undefined,
|
|
78
|
+
target_selector: undefined,
|
|
79
|
+
target_source_file: undefined,
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
54
83
|
it("reads nested status codes from error causes", () => {
|
|
55
84
|
const cause = new StudioSaveHttpError("Too many requests", 429);
|
|
56
85
|
const error = new Error("retry wrapper") as Error & { cause?: unknown };
|
|
@@ -172,6 +172,10 @@ export function trackStudioSaveFailure(input: StudioSaveFailureInput): void {
|
|
|
172
172
|
trackStudioEvent("save_failure", buildStudioSaveFailureProperties(input));
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
export function trackStudioEditBlocked(input: StudioSaveFailureInput): void {
|
|
176
|
+
trackStudioEvent("edit_blocked", buildStudioSaveFailureProperties(input));
|
|
177
|
+
}
|
|
178
|
+
|
|
175
179
|
export async function createStudioSaveHttpError(
|
|
176
180
|
response: Response,
|
|
177
181
|
fallbackMessage: string,
|