Skip to content

Commit 248ee7c

Browse files
test(studio): pin what the one DOM edit writer saves, records and reloads (#4947)
* test(studio): pin what the one DOM edit writer saves, records and reloads useDomEditPersist had no test file of its own. These cases pin its current behaviour before the patch-element and gsap-mutations clients are folded into one each. * test(studio): pin the DOM writer's partial save, SDK fallbacks and save counter * test(studio): pin that the DOM writer reloads only after its history record settles
1 parent da17c03 commit 248ee7c

1 file changed

Lines changed: 335 additions & 0 deletions

File tree

Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
// @vitest-environment happy-dom
2+
import { act, createElement } from "react";
3+
import { createRoot } from "react-dom/client";
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5+
import type { DomEditSelection } from "../components/editor/domEditing";
6+
import type { PatchOperation } from "../utils/sourcePatcher";
7+
import type { CutoverResult } from "../utils/sdkCutover";
8+
import { studioManualEditSavesIn } from "../components/editor/manualEditsDom";
9+
import {
10+
DomEditPersistPreparedWriteError,
11+
DomEditPersistUnresolvableError,
12+
DomEditPersistUnsafeValueError,
13+
} from "./domEditPersistFailure";
14+
import type { PersistDomEditOperations } from "./domEditCommitTypes";
15+
import { jsonResponse, requestUrl } from "./fetchStubTestUtils";
16+
import { useDomEditPersist, type UseDomEditPersistParams } from "./useDomEditPersist";
17+
18+
const trackStudioEvent = vi.fn();
19+
const reseekPreviewRuntime = vi.fn();
20+
vi.mock("../utils/studioTelemetry", () => ({
21+
trackStudioEvent: (...args: unknown[]) => trackStudioEvent(...args),
22+
}));
23+
vi.mock("./timelineTrackVisibility", () => ({
24+
reseekPreviewRuntime: (...args: unknown[]) => reseekPreviewRuntime(...args),
25+
}));
26+
27+
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
28+
29+
const SOURCE = '<div data-hf-id="hf-card" style="color: red">Card</div>';
30+
const PATCHED = '<div data-hf-id="hf-card" style="color: blue">Card</div>';
31+
const OPERATIONS: PatchOperation[] = [{ type: "inline-style", property: "color", value: "blue" }];
32+
const selection = {
33+
hfId: "hf-card",
34+
selector: '[data-hf-id="hf-card"]',
35+
selectorIndex: 0,
36+
sourceFile: "index.html",
37+
} as DomEditSelection;
38+
39+
function stubServer(patchResponse: Record<string, unknown>) {
40+
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0], _init?: RequestInit) => {
41+
const url = requestUrl(input);
42+
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: SOURCE });
43+
if (url.includes("/api/projects/p1/file-mutations/patch-element/")) {
44+
return jsonResponse(patchResponse);
45+
}
46+
throw new Error(`Unexpected fetch: ${url}`);
47+
});
48+
vi.stubGlobal("fetch", fetchMock);
49+
return fetchMock;
50+
}
51+
52+
const CHANGED = { changed: true, content: PATCHED, path: "index.html", version: "v2" };
53+
const PREPARED = PATCHED.replace("Card", "Card!");
54+
const sdkResult = (result: CutoverResult) => vi.fn(async (): Promise<CutoverResult> => result);
55+
56+
const patchPosts = (fetchMock: ReturnType<typeof stubServer>) =>
57+
fetchMock.mock.calls.filter(([input]) => requestUrl(input).includes("/patch-element/"));
58+
59+
let unmount: (() => void) | null = null;
60+
61+
function renderPersist(over: Partial<UseDomEditPersistParams> = {}) {
62+
const params: UseDomEditPersistParams = {
63+
activeCompPath: "index.html",
64+
previewIframeRef: { current: null },
65+
showToast: vi.fn(),
66+
queueDomEditSave: (save) => save(),
67+
writeProjectFile: vi.fn(async () => {}),
68+
editHistory: { recordEdit: vi.fn(async () => {}) },
69+
projectIdRef: { current: "p1" },
70+
reloadPreview: vi.fn(),
71+
forceReloadSdkSession: vi.fn(),
72+
...over,
73+
};
74+
const captured: { persist: PersistDomEditOperations | null } = { persist: null };
75+
function Probe() {
76+
captured.persist = useDomEditPersist(params);
77+
return null;
78+
}
79+
const root = createRoot(document.createElement("div"));
80+
act(() => root.render(createElement(Probe)));
81+
unmount = () => act(() => root.unmount());
82+
if (!captured.persist) throw new Error("hook did not initialize");
83+
return { persist: captured.persist, params };
84+
}
85+
86+
describe("useDomEditPersist", () => {
87+
beforeEach(() => {
88+
trackStudioEvent.mockReset();
89+
reseekPreviewRuntime.mockReset();
90+
});
91+
afterEach(() => {
92+
unmount?.();
93+
unmount = null;
94+
vi.unstubAllGlobals();
95+
});
96+
97+
it("patches the source, records the edit and reloads the preview", async () => {
98+
const fetchMock = stubServer({
99+
changed: true,
100+
content: PATCHED,
101+
path: "index.html",
102+
version: "v2",
103+
});
104+
const { persist, params } = renderPersist();
105+
106+
const outcome = await persist(selection, OPERATIONS, { label: "Recolor" });
107+
108+
expect(outcome).toEqual({ sourceFile: "index.html", version: "v2", changed: true });
109+
const [, init] = patchPosts(fetchMock)[0]!;
110+
expect(JSON.parse(String(init?.body))).toEqual({
111+
target: { hfId: "hf-card", selector: '[data-hf-id="hf-card"]', selectorIndex: 0 },
112+
operations: OPERATIONS,
113+
});
114+
expect(params.editHistory.recordEdit).toHaveBeenCalledWith({
115+
label: "Recolor",
116+
coalesceKey: undefined,
117+
coalesceMs: undefined,
118+
files: { "index.html": { before: SOURCE, after: PATCHED } },
119+
});
120+
expect(params.forceReloadSdkSession).toHaveBeenCalledTimes(1);
121+
expect(params.reloadPreview).toHaveBeenCalledTimes(1);
122+
expect(reseekPreviewRuntime).not.toHaveBeenCalled();
123+
});
124+
125+
it("with skipRefresh reseeks the preview instead of reloading it", async () => {
126+
stubServer({ changed: true, content: PATCHED, path: "index.html", version: "v2" });
127+
const { persist, params } = renderPersist();
128+
129+
await persist(selection, OPERATIONS, { skipRefresh: true });
130+
131+
expect(params.reloadPreview).not.toHaveBeenCalled();
132+
expect(reseekPreviewRuntime).toHaveBeenCalledTimes(1);
133+
});
134+
135+
it("an unchanged patch records nothing and reports changed: false", async () => {
136+
stubServer({ changed: false, matched: true, path: "index.html", version: "v1" });
137+
const { persist, params } = renderPersist();
138+
139+
const outcome = await persist(selection, OPERATIONS);
140+
141+
expect(outcome).toEqual({ sourceFile: "index.html", version: "v1", changed: false });
142+
expect(params.editHistory.recordEdit).not.toHaveBeenCalled();
143+
expect(params.reloadPreview).not.toHaveBeenCalled();
144+
});
145+
146+
it("an unmatched target throws and is reported once per target", async () => {
147+
stubServer({ changed: false, matched: false });
148+
const { persist } = renderPersist();
149+
150+
await expect(persist(selection, OPERATIONS)).rejects.toBeInstanceOf(
151+
DomEditPersistUnresolvableError,
152+
);
153+
await expect(persist(selection, OPERATIONS)).rejects.toBeInstanceOf(
154+
DomEditPersistUnresolvableError,
155+
);
156+
157+
expect(trackStudioEvent).toHaveBeenCalledTimes(1);
158+
expect(trackStudioEvent).toHaveBeenCalledWith(
159+
"save_skipped_unresolvable",
160+
expect.objectContaining({ target_selector: '[data-hf-id="hf-card"]' }),
161+
);
162+
});
163+
164+
it("refuses a non-finite layout value before any request, with a toast", async () => {
165+
const fetchMock = stubServer({ changed: true });
166+
const { persist, params } = renderPersist();
167+
168+
await expect(
169+
persist({ ...selection, selectorIndex: Number.NaN }, OPERATIONS),
170+
).rejects.toBeInstanceOf(DomEditPersistUnsafeValueError);
171+
172+
expect(fetchMock).not.toHaveBeenCalled();
173+
expect(params.showToast).toHaveBeenCalledWith(
174+
"Couldn't save edit because it contains invalid layout values",
175+
"error",
176+
);
177+
});
178+
179+
it("a committed SDK cutover skips the server patch and the reload", async () => {
180+
const fetchMock = stubServer({ changed: true });
181+
const onTrySdkPersist = vi.fn(
182+
async (): Promise<CutoverResult> => ({
183+
status: "committed",
184+
version: "sdk-3",
185+
before: SOURCE,
186+
after: PATCHED,
187+
}),
188+
);
189+
const { persist, params } = renderPersist({ onTrySdkPersist });
190+
191+
const outcome = await persist(selection, OPERATIONS, { label: "Recolor" });
192+
193+
expect(outcome).toEqual({ sourceFile: "index.html", version: "sdk-3", changed: true });
194+
expect(onTrySdkPersist).toHaveBeenCalledWith(selection, OPERATIONS, SOURCE, "index.html", {
195+
label: "Recolor",
196+
coalesceKey: undefined,
197+
skipRefresh: undefined,
198+
});
199+
expect(patchPosts(fetchMock)).toHaveLength(0);
200+
expect(params.reloadPreview).not.toHaveBeenCalled();
201+
expect(params.forceReloadSdkSession).not.toHaveBeenCalled();
202+
});
203+
204+
it("records the edit once, and reloads only after the record has settled", async () => {
205+
stubServer(CHANGED);
206+
let finishRecord = () => {};
207+
const recordEdit = vi.fn(
208+
() =>
209+
new Promise<void>((resolve) => {
210+
finishRecord = resolve;
211+
}),
212+
);
213+
const { persist, params } = renderPersist({ editHistory: { recordEdit } });
214+
215+
const saving = persist(selection, OPERATIONS);
216+
await vi.waitFor(() => expect(recordEdit).toHaveBeenCalledTimes(1));
217+
expect(params.reloadPreview).not.toHaveBeenCalled();
218+
finishRecord();
219+
await saving;
220+
221+
expect(recordEdit).toHaveBeenCalledTimes(1);
222+
expect(params.reloadPreview).toHaveBeenCalledTimes(1);
223+
});
224+
225+
it("writes prepared content after the patch and records it as the saved file", async () => {
226+
stubServer(CHANGED);
227+
const { persist, params } = renderPersist();
228+
229+
const outcome = await persist(selection, OPERATIONS, { prepareContent: () => PREPARED });
230+
231+
expect(params.writeProjectFile).toHaveBeenCalledWith("index.html", PREPARED, PATCHED);
232+
expect(params.editHistory.recordEdit).toHaveBeenCalledWith(
233+
expect.objectContaining({ files: { "index.html": { before: SOURCE, after: PREPARED } } }),
234+
);
235+
expect(params.reloadPreview).toHaveBeenCalledTimes(1);
236+
expect(outcome).toBeUndefined();
237+
});
238+
239+
it("a failed prepared write keeps the patch, reloads, then rejects", async () => {
240+
stubServer(CHANGED);
241+
const { persist, params } = renderPersist({
242+
writeProjectFile: vi.fn(async () => {
243+
throw new Error("disk full");
244+
}),
245+
});
246+
247+
const saving = persist(selection, OPERATIONS, { prepareContent: () => PREPARED });
248+
249+
await expect(saving).rejects.toBeInstanceOf(DomEditPersistPreparedWriteError);
250+
expect(params.editHistory.recordEdit).toHaveBeenCalledWith(
251+
expect.objectContaining({ files: { "index.html": { before: SOURCE, after: PATCHED } } }),
252+
);
253+
expect(params.reloadPreview).toHaveBeenCalledTimes(1);
254+
expect(params.showToast).toHaveBeenCalledWith(
255+
"Saved, but couldn't finish updating index.html: disk full",
256+
"error",
257+
);
258+
});
259+
260+
it("a declined SDK cutover falls back to the server patch", async () => {
261+
const fetchMock = stubServer(CHANGED);
262+
const onTrySdkPersist = sdkResult({ status: "declined", reason: "unsupported" });
263+
const { persist, params } = renderPersist({ onTrySdkPersist });
264+
265+
await persist(selection, OPERATIONS);
266+
267+
expect(onTrySdkPersist).toHaveBeenCalledTimes(1);
268+
expect(patchPosts(fetchMock)).toHaveLength(1);
269+
expect(params.editHistory.recordEdit).toHaveBeenCalledTimes(1);
270+
});
271+
272+
it("a failed SDK cutover rethrows without a server patch", async () => {
273+
const fetchMock = stubServer(CHANGED);
274+
const error = new Error("sdk broke");
275+
const { persist } = renderPersist({ onTrySdkPersist: sdkResult({ status: "failed", error }) });
276+
277+
await expect(persist(selection, OPERATIONS)).rejects.toBe(error);
278+
expect(patchPosts(fetchMock)).toHaveLength(0);
279+
});
280+
281+
it("an imported font or prepared content skips the SDK and goes to the server", async () => {
282+
const fetchMock = stubServer(CHANGED);
283+
const onTrySdkPersist = sdkResult({ status: "declined", reason: "unused" });
284+
const { persist } = renderPersist({ onTrySdkPersist });
285+
const importedFont = { family: "Inter", path: "fonts/inter.woff2", url: "/fonts/inter.woff2" };
286+
287+
await persist(selection, OPERATIONS, { importedFont });
288+
await persist(selection, OPERATIONS, { prepareContent: (html) => html });
289+
290+
expect(onTrySdkPersist).not.toHaveBeenCalled();
291+
const [[, fontInit]] = patchPosts(fetchMock);
292+
expect(JSON.parse(String(fontInit?.body)).fontFaceCss).toContain("Inter");
293+
expect(patchPosts(fetchMock)).toHaveLength(2);
294+
});
295+
296+
it("counts a save on the edited element's document until it settles", async () => {
297+
stubServer(CHANGED);
298+
const element = document.createElement("div");
299+
const { persist } = renderPersist();
300+
const before = studioManualEditSavesIn(document);
301+
302+
const saving = persist({ ...selection, element }, OPERATIONS);
303+
expect(studioManualEditSavesIn(document)).toBe(before + 1);
304+
await saving;
305+
306+
expect(studioManualEditSavesIn(document)).toBe(before + 2);
307+
});
308+
309+
it("does nothing when shouldSave says no", async () => {
310+
const fetchMock = stubServer({ changed: true });
311+
const { persist } = renderPersist();
312+
313+
await expect(persist(selection, OPERATIONS, { shouldSave: () => false })).resolves.toBe(
314+
undefined,
315+
);
316+
expect(fetchMock).not.toHaveBeenCalled();
317+
});
318+
319+
it("refuses to save once the active project has changed", async () => {
320+
stubServer({ changed: true });
321+
const projectIdRef = { current: "p1" as string | null };
322+
const { persist, params } = renderPersist({
323+
projectIdRef,
324+
queueDomEditSave: async (save) => {
325+
projectIdRef.current = "p2";
326+
return save();
327+
},
328+
});
329+
330+
await expect(persist(selection, OPERATIONS)).rejects.toThrow(
331+
"Active project changed before the edit could be saved",
332+
);
333+
expect(params.editHistory.recordEdit).not.toHaveBeenCalled();
334+
});
335+
});

0 commit comments

Comments
 (0)