mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 10:14:30 +00:00
Introduces <hf-audio-group> and data-audio-group as the group model B2–B7 and C1 build on: a non-rendering group element carries a label and (later) an FX chain, membership lives on the member's own data-audio-group attribute rather than DOM nesting, so a track removed from the document simply drops out of the group on the next resolve — nothing dangles. Groups do not nest: data-audio-group on the group element itself is ignored. A group with members but no <hf-audio-group> element still resolves, label falling back to the id, so hand-authored HTML degrades gracefully. Audio only in v1 — video members are ignored. Parse-only: nothing routes or sums audio yet (B3/B4). Adds the audio-groups canary at percentage: 0 gating the future Studio UI; the element and attribute parse and play regardless of enrollment. Verified rather than assumed per this plan's standing rule: the timeline's clip-collection selector ([data-start], [data-track-index], [data-composition-id], video, audio, img) already excludes the group element with zero changes, and no lint rule flags unknown elements or data-* attributes, so neither needed touching — confirmed by grep and by running `hyperframes lint` against a fixture containing the element (0 findings referencing it). The step doc's suggested display:none injection point (an existing base stylesheet in the runtime) does not exist in this codebase; skipped rather than inventing new infrastructure, since an empty, childless custom element already renders as a zero-size inline box with no visible output — the same reasoning the lint check above confirms empirically. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1484 lines
50 KiB
TypeScript
1484 lines
50 KiB
TypeScript
// fallow-ignore-file code-duplication
|
|
// @vitest-environment happy-dom
|
|
import { act, createElement } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { MutableRefObject } from "react";
|
|
import type { DomEditSelection, DomEditTextField } from "../components/editor/domEditing";
|
|
import type { ImportedFontAsset } from "../components/editor/fontAssets";
|
|
import { usePlayerStore } from "../player";
|
|
import { StudioSaveHttpError } from "../utils/studioSaveDiagnostics";
|
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
import { useDomEditCommits } from "./useDomEditCommits";
|
|
|
|
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
|
|
vi.mock("../utils/studioTelemetry", () => ({
|
|
trackStudioEvent: vi.fn(),
|
|
}));
|
|
|
|
interface PatchResponseBody {
|
|
ok?: boolean;
|
|
changed?: boolean;
|
|
matched?: boolean;
|
|
content?: string;
|
|
}
|
|
|
|
interface RenderedDomEditCommits {
|
|
hook: ReturnType<typeof useDomEditCommits>;
|
|
showToast: ReturnType<typeof makeShowToast>;
|
|
recordEdit: ReturnType<typeof vi.fn<() => Promise<void>>>;
|
|
reloadPreview: ReturnType<typeof vi.fn>;
|
|
cleanup: () => void;
|
|
}
|
|
|
|
interface RenderDomEditCommitsOptions {
|
|
importedFontAssets?: ImportedFontAsset[];
|
|
writeProjectFile?: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
}
|
|
|
|
type FetchHandler = (
|
|
input: Parameters<typeof fetch>[0],
|
|
init?: Parameters<typeof fetch>[1],
|
|
) => Promise<Response>;
|
|
|
|
function makeShowToast() {
|
|
return vi.fn<(message: string, tone?: "error" | "info") => void>();
|
|
}
|
|
|
|
function ensureCssEscape(): void {
|
|
const escape = (value: string) => value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
if (typeof globalThis.CSS === "undefined") {
|
|
Object.defineProperty(globalThis, "CSS", {
|
|
value: { escape },
|
|
configurable: true,
|
|
});
|
|
return;
|
|
}
|
|
if (typeof globalThis.CSS.escape !== "function") {
|
|
Object.defineProperty(globalThis.CSS, "escape", {
|
|
value: escape,
|
|
configurable: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
interface Deferred<T> {
|
|
promise: Promise<T>;
|
|
resolve: (value: T | PromiseLike<T>) => void;
|
|
reject: (reason?: unknown) => void;
|
|
}
|
|
|
|
function createDeferred<T>(): Deferred<T> {
|
|
let resolveFn: Deferred<T>["resolve"] | null = null;
|
|
let rejectFn: Deferred<T>["reject"] | null = null;
|
|
const promise = new Promise<T>((resolve, reject) => {
|
|
resolveFn = resolve;
|
|
rejectFn = reject;
|
|
});
|
|
if (!resolveFn || !rejectFn) throw new Error("Expected promise callbacks");
|
|
return { promise, resolve: resolveFn, reject: rejectFn };
|
|
}
|
|
|
|
function jsonResponse(body: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
|
|
function requestUrl(input: Parameters<typeof fetch>[0]): string {
|
|
if (typeof input === "string") return input;
|
|
if (input instanceof URL) return input.toString();
|
|
return input.url;
|
|
}
|
|
|
|
function stubPatchFetch(
|
|
patchResponse: PatchResponseBody | Error,
|
|
sourceContent = '<div data-hf-id="hf-card" style="color: red">Card</div>',
|
|
) {
|
|
const fetchMock = vi.fn(
|
|
async (
|
|
input: Parameters<typeof fetch>[0],
|
|
_init?: Parameters<typeof fetch>[1],
|
|
): Promise<Response> => {
|
|
const url = requestUrl(input);
|
|
if (url.includes("/api/projects/p1/files/")) {
|
|
return jsonResponse({ content: sourceContent });
|
|
}
|
|
if (url.includes("/api/projects/p1/file-mutations/patch-element/")) {
|
|
if (patchResponse instanceof Error) throw patchResponse;
|
|
return jsonResponse(patchResponse);
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
},
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
return fetchMock;
|
|
}
|
|
|
|
async function flushAsyncWork(): Promise<void> {
|
|
for (let i = 0; i < 8; i += 1) {
|
|
await Promise.resolve();
|
|
}
|
|
}
|
|
|
|
function createPreviewElement(
|
|
bodyHtml = '<div data-hf-id="hf-card" style="color: red">Card</div>',
|
|
): {
|
|
iframe: HTMLIFrameElement;
|
|
element: HTMLElement;
|
|
} {
|
|
const iframe = document.createElement("iframe");
|
|
document.body.append(iframe);
|
|
const doc = iframe.contentDocument;
|
|
if (!doc) throw new Error("Expected iframe contentDocument");
|
|
doc.body.innerHTML = bodyHtml;
|
|
const element = doc.querySelector('[data-hf-id="hf-card"]');
|
|
if (!(element instanceof HTMLElement)) throw new Error("Expected HTML target element");
|
|
return { iframe, element };
|
|
}
|
|
|
|
function textField(input: {
|
|
key: string;
|
|
value: string;
|
|
source: DomEditTextField["source"];
|
|
tagName?: string;
|
|
}): DomEditTextField {
|
|
return {
|
|
key: input.key,
|
|
label: input.key,
|
|
value: input.value,
|
|
tagName: input.tagName ?? "span",
|
|
attributes: [],
|
|
inlineStyles: {},
|
|
computedStyles: {},
|
|
source: input.source,
|
|
};
|
|
}
|
|
|
|
function createSelection(
|
|
element: HTMLElement,
|
|
overrides: Partial<DomEditSelection> = {},
|
|
): DomEditSelection {
|
|
const base: DomEditSelection = {
|
|
element,
|
|
label: "Hero title",
|
|
tagName: "div",
|
|
sourceFile: "index.html",
|
|
compositionPath: "index.html",
|
|
isCompositionHost: false,
|
|
isInsideLockedComposition: false,
|
|
boundingBox: { x: 0, y: 0, width: 120, height: 40 },
|
|
textContent: element.textContent,
|
|
dataAttributes: {},
|
|
inlineStyles: { color: "red" },
|
|
computedStyles: {},
|
|
textFields: [],
|
|
capabilities: {
|
|
canSelect: true,
|
|
canEditStyles: true,
|
|
canCrop: true,
|
|
canMove: true,
|
|
canResize: true,
|
|
canApplyManualOffset: true,
|
|
canApplyManualSize: true,
|
|
canApplyManualRotation: true,
|
|
},
|
|
hfId: "hf-card",
|
|
selector: '[data-hf-id="hf-card"]',
|
|
selectorIndex: 0,
|
|
};
|
|
return { ...base, ...overrides };
|
|
}
|
|
|
|
function renderDomEditCommits(
|
|
selection: DomEditSelection,
|
|
iframe: HTMLIFrameElement,
|
|
options: RenderDomEditCommitsOptions = {},
|
|
) {
|
|
const captured: { current: ReturnType<typeof useDomEditCommits> | null } = { current: null };
|
|
const showToast = makeShowToast();
|
|
const recordEdit = vi.fn(async () => {});
|
|
const previewIframeRef: MutableRefObject<HTMLIFrameElement | null> = { current: iframe };
|
|
const projectIdRef: MutableRefObject<string | null> = { current: "p1" };
|
|
const domEditSaveTimestampRef: MutableRefObject<number> = { current: 0 };
|
|
const reloadPreview = vi.fn();
|
|
|
|
function Probe() {
|
|
captured.current = useDomEditCommits({
|
|
activeCompPath: "index.html",
|
|
previewIframeRef,
|
|
showToast,
|
|
queueDomEditSave: async (save) => save(),
|
|
writeProjectFile: options.writeProjectFile ?? (async () => {}),
|
|
domEditSaveTimestampRef,
|
|
editHistory: { recordEdit },
|
|
fileTree: [],
|
|
importedFontAssetsRef: { current: options.importedFontAssets ?? [] },
|
|
projectId: "p1",
|
|
projectIdRef,
|
|
reloadPreview,
|
|
domEditSelection: selection,
|
|
applyDomSelection: vi.fn(),
|
|
clearDomSelection: vi.fn(),
|
|
refreshDomEditSelectionFromPreview: vi.fn(),
|
|
buildDomSelectionFromTarget: vi.fn(async () => null),
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const container = document.createElement("div");
|
|
const root: Root = createRoot(container);
|
|
act(() => {
|
|
root.render(createElement(Probe));
|
|
});
|
|
|
|
if (!captured.current) throw new Error("Expected hook result");
|
|
return {
|
|
hook: captured.current,
|
|
showToast,
|
|
recordEdit,
|
|
reloadPreview,
|
|
cleanup: () => {
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
container.remove();
|
|
},
|
|
} satisfies RenderedDomEditCommits;
|
|
}
|
|
|
|
describe("useDomEditCommits z-index reorder persistence", () => {
|
|
beforeEach(() => {
|
|
ensureCssEscape();
|
|
vi.clearAllMocks();
|
|
vi.unstubAllGlobals();
|
|
document.body.replaceChildren();
|
|
});
|
|
|
|
it("persists an N-element reorder with one batch POST, one undo entry, and NO iframe reload", async () => {
|
|
const original =
|
|
'<div id="a" style="z-index: 1"></div><div id="b" style="z-index: 2"></div><div id="c" style="z-index: 3"></div>';
|
|
const after =
|
|
'<div id="a" style="z-index: 3"></div><div id="b" style="z-index: 2"></div><div id="c" style="z-index: 1"></div>';
|
|
const fetchMock = vi.fn(
|
|
async (
|
|
input: Parameters<typeof fetch>[0],
|
|
_init?: Parameters<typeof fetch>[1],
|
|
): Promise<Response> => {
|
|
const url = requestUrl(input);
|
|
if (url.endsWith("/file-mutations/patch-element-batches")) {
|
|
return jsonResponse({
|
|
durable: true,
|
|
files: [
|
|
{
|
|
sourceFile: "index.html",
|
|
changed: true,
|
|
matched: [true, true, true],
|
|
before: original,
|
|
after,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
},
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const { iframe, element } = createPreviewElement(
|
|
'<div data-hf-id="hf-card"></div><div id="b"></div><div id="c"></div>',
|
|
);
|
|
element.id = "a";
|
|
const elements = [
|
|
element,
|
|
iframe.contentDocument!.getElementById("b")!,
|
|
iframe.contentDocument!.getElementById("c")!,
|
|
];
|
|
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
|
|
try {
|
|
await act(async () => {
|
|
await rendered.hook.handleDomZIndexReorderCommit(
|
|
elements.map((item, index) => ({
|
|
element: item,
|
|
zIndex: 3 - index,
|
|
id: item.id,
|
|
sourceFile: "index.html",
|
|
})),
|
|
"z-reorder:test",
|
|
);
|
|
});
|
|
|
|
const batchPosts = fetchMock.mock.calls.filter(([input]) =>
|
|
requestUrl(input).endsWith("/file-mutations/patch-element-batches"),
|
|
);
|
|
const singlePosts = fetchMock.mock.calls.filter(([input]) =>
|
|
requestUrl(input).includes("/file-mutations/patch-elements-batch/"),
|
|
);
|
|
expect(batchPosts).toHaveLength(1);
|
|
expect(singlePosts).toHaveLength(0);
|
|
expect(JSON.parse(String(batchPosts[0]?.[1]?.body))).toEqual({
|
|
batches: [
|
|
{
|
|
sourceFile: "index.html",
|
|
patches: expect.arrayContaining([
|
|
expect.objectContaining({ target: expect.objectContaining({ id: "a" }) }),
|
|
expect.objectContaining({ target: expect.objectContaining({ id: "b" }) }),
|
|
expect.objectContaining({ target: expect.objectContaining({ id: "c" }) }),
|
|
]),
|
|
},
|
|
],
|
|
});
|
|
expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
|
|
expect(rendered.recordEdit).toHaveBeenCalledWith({
|
|
label: "Reorder layers",
|
|
kind: "manual",
|
|
coalesceKey: "z-reorder:test",
|
|
// Unbounded per-gesture fold window (keys are unique per gesture):
|
|
// the z entry and its mirror/lane counterpart fold across the server
|
|
// round-trip that separates them.
|
|
coalesceMs: Number.POSITIVE_INFINITY,
|
|
files: { "index.html": { before: original, after } },
|
|
});
|
|
// FIX: a z-only reorder must NOT remount the preview iframe ("the blink").
|
|
// The live DOM + store already hold the final state and the server matched
|
|
// every style-only patch, so the reload is provably redundant.
|
|
expect(rendered.reloadPreview).not.toHaveBeenCalled();
|
|
} finally {
|
|
rendered.cleanup();
|
|
}
|
|
});
|
|
|
|
it("falls back to reloading when the server response omits matched[]", async () => {
|
|
// Without a matched[] confirmation the persist can't be proven in sync with
|
|
// the live DOM — the skip-reload path must not engage.
|
|
const original = '<div id="a" style="z-index: 1"></div>';
|
|
const after = '<div id="a" style="z-index: 2"></div>';
|
|
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
|
const url = requestUrl(input);
|
|
if (url.endsWith("/file-mutations/patch-element-batches")) {
|
|
return jsonResponse({
|
|
durable: true,
|
|
files: [{ sourceFile: "index.html", changed: true, before: original, after }],
|
|
});
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const { iframe, element } = createPreviewElement();
|
|
element.id = "a";
|
|
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
|
|
try {
|
|
await act(async () => {
|
|
await rendered.hook.handleDomZIndexReorderCommit([
|
|
{ element, zIndex: 2, id: "a", sourceFile: "index.html" },
|
|
]);
|
|
});
|
|
|
|
expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
|
|
expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
rendered.cleanup();
|
|
}
|
|
});
|
|
|
|
it("warns and reports telemetry for unmatched batch patches without throwing", async () => {
|
|
// The server reports per-patch matched[]: #b was not found in the source,
|
|
// so it atomically refuses the whole multi-file gesture. The reload
|
|
// reconverges the preview with disk while the lifecycle owner rolls back
|
|
// both optimistic writes.
|
|
const original = '<div id="a" style="z-index: 1"></div>';
|
|
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
|
const url = requestUrl(input);
|
|
if (url.endsWith("/file-mutations/patch-element-batches")) {
|
|
return jsonResponse({
|
|
durable: false,
|
|
files: [
|
|
{
|
|
sourceFile: "index.html",
|
|
changed: false,
|
|
matched: [true, false],
|
|
before: original,
|
|
after: original,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const { iframe, element } = createPreviewElement(
|
|
'<div data-hf-id="hf-card"></div><div id="b"></div>',
|
|
);
|
|
element.id = "a";
|
|
const second = iframe.contentDocument!.getElementById("b")!;
|
|
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
|
|
try {
|
|
await act(async () => {
|
|
await rendered.hook.handleDomZIndexReorderCommit([
|
|
{ element, zIndex: 2, id: "a", sourceFile: "index.html" },
|
|
{ element: second, zIndex: 1, id: "b", sourceFile: "index.html" },
|
|
]);
|
|
});
|
|
|
|
// No throw: incomplete durability rolls back both optimistic writes.
|
|
expect(element.style.zIndex).toBe("");
|
|
expect(second.style.zIndex).toBe("");
|
|
expect(rendered.recordEdit).not.toHaveBeenCalled();
|
|
expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("could not match 1 patch target(s) in index.html"),
|
|
"b",
|
|
);
|
|
expect(trackStudioEvent).toHaveBeenCalledWith(
|
|
"save_failure",
|
|
expect.objectContaining({
|
|
mutation_type: "z-reorder-unmatched",
|
|
file_path: "index.html",
|
|
error_message: expect.stringContaining("b"),
|
|
}),
|
|
);
|
|
} finally {
|
|
warnSpy.mockRestore();
|
|
rendered.cleanup();
|
|
}
|
|
});
|
|
|
|
it("uses one atomic request and rolls back every file when one target is unmatched", async () => {
|
|
const indexOriginal = '<div id="a" style="z-index: 4"></div>';
|
|
const sceneOriginal = '<div id="b" style="z-index: 5"></div>';
|
|
const fetchMock = vi.fn(
|
|
async (
|
|
input: Parameters<typeof fetch>[0],
|
|
init?: Parameters<typeof fetch>[1],
|
|
): Promise<Response> => {
|
|
const url = requestUrl(input);
|
|
if (url.endsWith("/file-mutations/patch-element-batches")) {
|
|
expect(JSON.parse(String(init?.body))).toEqual({
|
|
batches: [
|
|
expect.objectContaining({ sourceFile: "index.html" }),
|
|
expect.objectContaining({ sourceFile: "scene.html" }),
|
|
],
|
|
});
|
|
return jsonResponse({
|
|
durable: false,
|
|
files: [
|
|
{
|
|
sourceFile: "index.html",
|
|
changed: false,
|
|
matched: [true],
|
|
before: indexOriginal,
|
|
after: indexOriginal,
|
|
},
|
|
{
|
|
sourceFile: "scene.html",
|
|
changed: false,
|
|
matched: [false],
|
|
before: sceneOriginal,
|
|
after: sceneOriginal,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
},
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const { iframe, element } = createPreviewElement(
|
|
'<div data-hf-id="hf-card" style="z-index: 4"></div><div id="b" style="z-index: 5"></div>',
|
|
);
|
|
element.id = "a";
|
|
const second = iframe.contentDocument!.getElementById("b")!;
|
|
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
|
|
try {
|
|
let result:
|
|
| Awaited<ReturnType<typeof rendered.hook.handleDomZIndexReorderCommit>>
|
|
| undefined;
|
|
await act(async () => {
|
|
result = await rendered.hook.handleDomZIndexReorderCommit([
|
|
{ element, zIndex: 8, id: "a", sourceFile: "index.html" },
|
|
{ element: second, zIndex: 9, id: "b", sourceFile: "scene.html" },
|
|
]);
|
|
});
|
|
|
|
expect(result).toEqual({ durable: false, allMatched: false, changed: false });
|
|
expect(element.style.zIndex).toBe("4");
|
|
expect(second.style.zIndex).toBe("5");
|
|
expect(rendered.recordEdit).not.toHaveBeenCalled();
|
|
expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
warnSpy.mockRestore();
|
|
rendered.cleanup();
|
|
}
|
|
});
|
|
|
|
it("reloads and reports non-durable when an unmatched batch changes no bytes", async () => {
|
|
const original = '<div id="a" style="z-index: 1"></div>';
|
|
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
|
const url = requestUrl(input);
|
|
if (url.endsWith("/file-mutations/patch-element-batches")) {
|
|
return jsonResponse({
|
|
durable: false,
|
|
files: [
|
|
{
|
|
sourceFile: "index.html",
|
|
changed: false,
|
|
matched: [false],
|
|
before: original,
|
|
after: original,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const { iframe, element } = createPreviewElement();
|
|
element.id = "a";
|
|
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
|
|
try {
|
|
let result:
|
|
| Awaited<ReturnType<typeof rendered.hook.handleDomZIndexReorderCommit>>
|
|
| undefined;
|
|
await act(async () => {
|
|
result = await rendered.hook.handleDomZIndexReorderCommit([
|
|
{ element, zIndex: 2, id: "missing", sourceFile: "index.html" },
|
|
]);
|
|
});
|
|
|
|
expect(result).toEqual({ durable: false, allMatched: false, changed: false });
|
|
expect(element.style.zIndex).toBe("");
|
|
expect(rendered.recordEdit).not.toHaveBeenCalled();
|
|
expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
rendered.cleanup();
|
|
}
|
|
});
|
|
|
|
it("rolls back and reloads after a rejected batch POST", async () => {
|
|
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
|
const url = requestUrl(input);
|
|
if (url.endsWith("/file-mutations/patch-element-batches")) {
|
|
return jsonResponse({ error: "batch rejected" }, 500);
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const writeProjectFile = vi.fn(async () => {});
|
|
const { iframe, element } = createPreviewElement(
|
|
'<div data-hf-id="hf-card" style="z-index: 7"></div><div id="b"></div>',
|
|
);
|
|
element.id = "a";
|
|
const second = iframe.contentDocument!.getElementById("b")!;
|
|
usePlayerStore.getState().setElements([
|
|
{ id: "a", tag: "div", start: 0, duration: 1, track: 0, zIndex: 7, hasExplicitZIndex: true },
|
|
{ id: "b", tag: "div", start: 0, duration: 1, track: 1, zIndex: 0, hasExplicitZIndex: false },
|
|
]);
|
|
const rendered = renderDomEditCommits(createSelection(element), iframe, { writeProjectFile });
|
|
|
|
try {
|
|
let rejection: unknown;
|
|
await act(async () => {
|
|
try {
|
|
await rendered.hook.handleDomZIndexReorderCommit([
|
|
{ element, zIndex: 2, id: "a", sourceFile: "index.html", key: "a" },
|
|
{ element: second, zIndex: 1, id: "b", sourceFile: "index.html", key: "b" },
|
|
]);
|
|
} catch (error) {
|
|
rejection = error;
|
|
}
|
|
});
|
|
|
|
expect(rejection).toBeInstanceOf(Error);
|
|
expect((rejection as Error).message).toContain("batch rejected");
|
|
expect(element.style.zIndex).toBe("7");
|
|
expect(second.style.zIndex).toBe("");
|
|
expect(
|
|
usePlayerStore
|
|
.getState()
|
|
.elements.map(({ zIndex, hasExplicitZIndex }) => ({ zIndex, hasExplicitZIndex })),
|
|
).toEqual([
|
|
{ zIndex: 7, hasExplicitZIndex: true },
|
|
{ zIndex: 0, hasExplicitZIndex: false },
|
|
]);
|
|
expect(writeProjectFile).not.toHaveBeenCalled();
|
|
expect(rendered.recordEdit).not.toHaveBeenCalled();
|
|
expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
|
|
expect(
|
|
fetchMock.mock.calls.filter(([input]) =>
|
|
requestUrl(input).endsWith("/file-mutations/patch-element-batches"),
|
|
),
|
|
).toHaveLength(1);
|
|
} finally {
|
|
rendered.cleanup();
|
|
}
|
|
});
|
|
|
|
it("reloads after the server commits but the aggregate response is lost", async () => {
|
|
const original = '<div data-hf-id="hf-card" id="a" style="z-index: 7"></div>';
|
|
const after = '<div data-hf-id="hf-card" id="a" style="z-index: 2"></div>';
|
|
let diskContent = original;
|
|
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
|
const url = requestUrl(input);
|
|
if (url.endsWith("/file-mutations/patch-element-batches")) {
|
|
diskContent = after;
|
|
throw new TypeError("connection reset after commit");
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const { iframe, element } = createPreviewElement(original);
|
|
usePlayerStore.getState().setElements([
|
|
{
|
|
id: "a",
|
|
tag: "div",
|
|
start: 0,
|
|
duration: 1,
|
|
track: 0,
|
|
zIndex: 7,
|
|
hasExplicitZIndex: true,
|
|
},
|
|
]);
|
|
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
|
|
try {
|
|
let rejection: unknown;
|
|
await act(async () => {
|
|
try {
|
|
await rendered.hook.handleDomZIndexReorderCommit([
|
|
{ element, zIndex: 2, id: "a", sourceFile: "index.html", key: "a" },
|
|
]);
|
|
} catch (error) {
|
|
rejection = error;
|
|
}
|
|
});
|
|
|
|
expect(rejection).toBeInstanceOf(Error);
|
|
expect((rejection as Error).message).toContain("connection reset after commit");
|
|
expect(diskContent).toBe(after);
|
|
expect(element.style.zIndex).toBe("7");
|
|
expect(usePlayerStore.getState().elements[0]).toMatchObject({
|
|
zIndex: 7,
|
|
hasExplicitZIndex: true,
|
|
});
|
|
expect(rendered.recordEdit).not.toHaveBeenCalled();
|
|
expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
|
|
expect(
|
|
fetchMock.mock.calls.filter(([input]) =>
|
|
requestUrl(input).endsWith("/file-mutations/patch-element-batches"),
|
|
),
|
|
).toHaveLength(1);
|
|
} finally {
|
|
rendered.cleanup();
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
"changed=false with different snapshots",
|
|
{
|
|
durable: true,
|
|
files: [
|
|
{
|
|
sourceFile: "index.html",
|
|
changed: false,
|
|
matched: [true],
|
|
before: '<div id="a" style="z-index: 1"></div>',
|
|
after: '<div id="a" style="z-index: 2"></div>',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
[
|
|
"changed=true with identical snapshots",
|
|
{
|
|
durable: true,
|
|
files: [
|
|
{
|
|
sourceFile: "index.html",
|
|
changed: true,
|
|
matched: [true],
|
|
before: '<div id="a" style="z-index: 1"></div>',
|
|
after: '<div id="a" style="z-index: 1"></div>',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
[
|
|
"durable=false with a changed file",
|
|
{
|
|
durable: false,
|
|
files: [
|
|
{
|
|
sourceFile: "index.html",
|
|
changed: true,
|
|
matched: [true],
|
|
before: '<div id="a" style="z-index: 1"></div>',
|
|
after: '<div id="a" style="z-index: 2"></div>',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
])("rejects and reloads for malformed 200 response: %s", async (_label, responseBody) => {
|
|
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
|
const url = requestUrl(input);
|
|
if (url.endsWith("/file-mutations/patch-element-batches")) {
|
|
return jsonResponse(responseBody);
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const { iframe, element } = createPreviewElement();
|
|
element.id = "a";
|
|
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
|
|
try {
|
|
let rejection: unknown;
|
|
await act(async () => {
|
|
try {
|
|
await rendered.hook.handleDomZIndexReorderCommit([
|
|
{ element, zIndex: 2, id: "a", sourceFile: "index.html", key: "a" },
|
|
]);
|
|
} catch (error) {
|
|
rejection = error;
|
|
}
|
|
});
|
|
|
|
expect(rejection).toBeInstanceOf(Error);
|
|
expect((rejection as Error).message).toBe("Invalid atomic element patch response");
|
|
expect(rendered.recordEdit).not.toHaveBeenCalled();
|
|
expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
|
|
expect(
|
|
fetchMock.mock.calls.filter(([input]) =>
|
|
requestUrl(input).endsWith("/file-mutations/patch-element-batches"),
|
|
),
|
|
).toHaveLength(1);
|
|
} finally {
|
|
rendered.cleanup();
|
|
}
|
|
});
|
|
});
|
|
|
|
async function commitStyleAgainst(response: Parameters<typeof stubPatchFetch>[0]) {
|
|
stubPatchFetch(response);
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const { iframe, element } = createPreviewElement();
|
|
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
await act(async () => {
|
|
await rendered.hook.handleDomStyleCommit("color", "blue");
|
|
});
|
|
return {
|
|
element,
|
|
rendered,
|
|
warnSpy,
|
|
cleanup: () => {
|
|
warnSpy.mockRestore();
|
|
rendered.cleanup();
|
|
},
|
|
};
|
|
}
|
|
|
|
function renderStyleCommitWithFetch(fetchHandler: FetchHandler) {
|
|
const fetchMock = vi.fn(fetchHandler);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const { iframe, element } = createPreviewElement();
|
|
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
return {
|
|
element,
|
|
fetchMock,
|
|
rendered,
|
|
warnSpy,
|
|
cleanup: () => {
|
|
warnSpy.mockRestore();
|
|
rendered.cleanup();
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Adding or removing a text layer, which no per-child operation can express.
|
|
*
|
|
* Both used to be refused outright — the panel offered the buttons and neither
|
|
* could ever save — so this asserts the opposite of what it used to: one
|
|
* `rich-text` operation carrying the element's new markup, and no complaint.
|
|
*/
|
|
async function expectPersistedTextStructureEdit(
|
|
commit: (hook: ReturnType<typeof useDomEditCommits>) => Promise<unknown>,
|
|
expectedMarkup: (markup: string) => void,
|
|
): Promise<void> {
|
|
const fetchMock = stubPatchFetch({
|
|
ok: true,
|
|
changed: true,
|
|
matched: true,
|
|
content: '<div data-hf-id="hf-card"><span>First</span></div>',
|
|
});
|
|
const { iframe, element } = createPreviewElement(
|
|
'<div data-hf-id="hf-card"><span>First</span><span>Second</span></div>',
|
|
);
|
|
const selection = createSelection(element, {
|
|
textFields: [
|
|
textField({ key: "first", value: "First", source: "child" }),
|
|
textField({ key: "second", value: "Second", source: "child" }),
|
|
],
|
|
});
|
|
const rendered = renderDomEditCommits(selection, iframe);
|
|
|
|
try {
|
|
await act(async () => {
|
|
await commit(rendered.hook);
|
|
});
|
|
|
|
const patchPost = fetchMock.mock.calls.find((call) =>
|
|
requestUrl(call[0]).includes("/file-mutations/patch-element/"),
|
|
);
|
|
expect(patchPost).toBeDefined();
|
|
const body = JSON.parse(String(patchPost?.[1]?.body)) as {
|
|
operations: Array<{ type: string; value?: string }>;
|
|
};
|
|
expect(body.operations).toHaveLength(1);
|
|
expect(body.operations[0]?.type).toBe("rich-text");
|
|
expectedMarkup(body.operations[0]?.value ?? "");
|
|
expect(rendered.showToast).not.toHaveBeenCalled();
|
|
} finally {
|
|
rendered.cleanup();
|
|
}
|
|
}
|
|
|
|
describe("useDomEditCommits rich-text persist handling", () => {
|
|
beforeEach(() => {
|
|
ensureCssEscape();
|
|
vi.clearAllMocks();
|
|
vi.unstubAllGlobals();
|
|
document.body.replaceChildren();
|
|
});
|
|
|
|
it("restores the session snapshot when rich-text persistence fails", async () => {
|
|
stubPatchFetch({ ok: true, changed: false, matched: false });
|
|
const previousHtml = '<span style="color: red">Before</span>';
|
|
const html = '<span style="color: blue">After</span>';
|
|
const { iframe, element } = createPreviewElement(`<div data-hf-id="hf-card">${html}</div>`);
|
|
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
|
|
try {
|
|
await act(async () => {
|
|
await rendered.hook.handleDomRichTextCommit({ element, html, previousHtml });
|
|
});
|
|
|
|
expect(element.innerHTML).toBe(previousHtml);
|
|
expect(rendered.showToast).toHaveBeenCalledWith(
|
|
expect.stringMatching(/Couldn't save "Hero title"/),
|
|
"error",
|
|
);
|
|
} finally {
|
|
warnSpy.mockRestore();
|
|
rendered.cleanup();
|
|
}
|
|
});
|
|
|
|
it("does not retarget a commit onto a replacement preview node", async () => {
|
|
const fetchMock = stubPatchFetch({ ok: true, changed: true, matched: true });
|
|
const html = '<span style="color: blue">After</span>';
|
|
const { iframe, element } = createPreviewElement(`<div data-hf-id="hf-card">${html}</div>`);
|
|
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
const replacement = document.createElement("div");
|
|
replacement.dataset.hfId = "hf-card";
|
|
replacement.innerHTML = "Reloaded elsewhere";
|
|
element.replaceWith(replacement);
|
|
|
|
try {
|
|
await act(async () => {
|
|
await rendered.hook.handleDomRichTextCommit({
|
|
element,
|
|
html,
|
|
previousHtml: "Before",
|
|
});
|
|
});
|
|
|
|
expect(replacement.innerHTML).toBe("Reloaded elsewhere");
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
expect(rendered.showToast).not.toHaveBeenCalled();
|
|
} finally {
|
|
rendered.cleanup();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("useDomEditCommits style persist handling", () => {
|
|
beforeEach(() => {
|
|
ensureCssEscape();
|
|
vi.clearAllMocks();
|
|
vi.unstubAllGlobals();
|
|
document.body.replaceChildren();
|
|
});
|
|
|
|
it("toasts and reverts a style commit when the server cannot resolve the source element", async () => {
|
|
const { element, rendered, cleanup } = await commitStyleAgainst({
|
|
ok: true,
|
|
changed: false,
|
|
matched: false,
|
|
});
|
|
|
|
try {
|
|
expect(rendered.showToast).toHaveBeenCalledWith(
|
|
expect.stringMatching(/Couldn't save "Hero title": Couldn't find this element/),
|
|
"error",
|
|
);
|
|
expect(element.style.getPropertyValue("color")).toBe("red");
|
|
expect(trackStudioEvent).toHaveBeenCalledWith(
|
|
"save_skipped_unresolvable",
|
|
expect.objectContaining({ target_source_file: "index.html" }),
|
|
);
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it("warns without a toast when the server matched the element but reported no change", async () => {
|
|
const { rendered, warnSpy, cleanup } = await commitStyleAgainst({
|
|
ok: true,
|
|
changed: false,
|
|
matched: true,
|
|
});
|
|
|
|
try {
|
|
expect(rendered.showToast).not.toHaveBeenCalled();
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
"[Studio] DOM edit persist no-op",
|
|
expect.objectContaining({ operations: "inline-style:color" }),
|
|
);
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it("toasts and reverts a style commit when the patch request rejects", async () => {
|
|
const { element, rendered, cleanup } = await commitStyleAgainst(new Error("network down"));
|
|
|
|
try {
|
|
expect(rendered.showToast).toHaveBeenCalledWith(
|
|
'Couldn\'t save "Hero title": network down',
|
|
"error",
|
|
);
|
|
expect(element.style.getPropertyValue("color")).toBe("red");
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it("keeps the optimistic style and records history when the patch succeeds", async () => {
|
|
const { element, rendered, cleanup } = await commitStyleAgainst({
|
|
ok: true,
|
|
changed: true,
|
|
matched: true,
|
|
content: '<div data-hf-id="hf-card" style="color: blue">Card</div>',
|
|
});
|
|
|
|
try {
|
|
expect(rendered.showToast).not.toHaveBeenCalled();
|
|
expect(element.style.getPropertyValue("color")).toBe("blue");
|
|
expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it("keeps a newer style value when an older overlapping commit later fails", async () => {
|
|
const firstPatch = createDeferred<Response>();
|
|
const secondPatch = createDeferred<Response>();
|
|
let patchCount = 0;
|
|
const { element, rendered, cleanup } = renderStyleCommitWithFetch(async (input) => {
|
|
const url = requestUrl(input);
|
|
if (url.includes("/api/projects/p1/files/")) {
|
|
return jsonResponse({
|
|
content: '<div data-hf-id="hf-card" style="color: red">Card</div>',
|
|
});
|
|
}
|
|
if (url.includes("/api/projects/p1/file-mutations/patch-element/")) {
|
|
patchCount += 1;
|
|
return patchCount === 1 ? firstPatch.promise : secondPatch.promise;
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
|
|
try {
|
|
const firstCommit = rendered.hook.handleDomStyleCommit("color", "blue");
|
|
await flushAsyncWork();
|
|
expect(patchCount).toBe(1);
|
|
|
|
const secondCommit = rendered.hook.handleDomStyleCommit("color", "green");
|
|
await flushAsyncWork();
|
|
expect(patchCount).toBe(2);
|
|
|
|
secondPatch.resolve(
|
|
jsonResponse({
|
|
ok: true,
|
|
changed: true,
|
|
matched: true,
|
|
content: '<div data-hf-id="hf-card" style="color: green">Card</div>',
|
|
}),
|
|
);
|
|
await secondCommit;
|
|
expect(element.style.getPropertyValue("color")).toBe("green");
|
|
|
|
firstPatch.reject(new Error("server rejected blue"));
|
|
await firstCommit;
|
|
|
|
expect(element.style.getPropertyValue("color")).toBe("green");
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it("toasts read failures from the source file fetch", async () => {
|
|
const { rendered, cleanup } = renderStyleCommitWithFetch(async (input) => {
|
|
const url = requestUrl(input);
|
|
if (url.includes("/api/projects/p1/files/")) {
|
|
return new Response("read failed", { status: 503 });
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
|
|
try {
|
|
await act(async () => {
|
|
await rendered.hook.handleDomStyleCommit("color", "blue");
|
|
});
|
|
|
|
expect(rendered.showToast).toHaveBeenCalledWith(
|
|
expect.stringContaining("Failed to read index.html (503)"),
|
|
"error",
|
|
);
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it("keeps the already-persisted patch and toasts once when the prepareContent write fails", async () => {
|
|
stubPatchFetch(
|
|
{
|
|
ok: true,
|
|
changed: true,
|
|
matched: true,
|
|
content:
|
|
'<!doctype html><html><head></head><body><div data-hf-id="hf-card">Card</div></body></html>',
|
|
},
|
|
'<!doctype html><html><head></head><body><div data-hf-id="hf-card">Card</div></body></html>',
|
|
);
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const { iframe, element } = createPreviewElement();
|
|
const selection = createSelection(element, {
|
|
textFields: [textField({ key: "self", value: "Card", source: "self", tagName: "div" })],
|
|
});
|
|
const rendered = renderDomEditCommits(createSelection(element), iframe, {
|
|
writeProjectFile: async () => {
|
|
throw new StudioSaveHttpError("Failed to save index.html (500)", 500);
|
|
},
|
|
});
|
|
|
|
try {
|
|
await act(async () => {
|
|
await rendered.hook.commitDomTextFields(
|
|
selection,
|
|
[textField({ key: "self", value: "Card", source: "self", tagName: "div" })],
|
|
{
|
|
importedFont: {
|
|
family: "Imported",
|
|
path: "fonts/Imported.woff2",
|
|
url: "/api/projects/p1/preview/fonts/Imported.woff2",
|
|
},
|
|
},
|
|
);
|
|
});
|
|
|
|
// The base patch already landed server-side before the font-face write
|
|
// failed, so this is recorded as a completed edit (not reverted/re-toasted
|
|
// as a full failure) — only the font embellishment is reported as lost.
|
|
expect(rendered.showToast).toHaveBeenCalledTimes(1);
|
|
expect(rendered.showToast).toHaveBeenCalledWith(
|
|
expect.stringContaining("Saved, but couldn't finish updating index.html"),
|
|
"error",
|
|
);
|
|
expect(rendered.showToast).toHaveBeenCalledWith(
|
|
expect.stringContaining("Failed to save index.html (500)"),
|
|
"error",
|
|
);
|
|
expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
warnSpy.mockRestore();
|
|
rendered.cleanup();
|
|
}
|
|
});
|
|
|
|
it("uses the patched server content as the custom-font write precondition", async () => {
|
|
const patchedContent =
|
|
'<!doctype html><html><head></head><body><div data-hf-id="hf-card">Card</div></body></html>';
|
|
stubPatchFetch(
|
|
{ ok: true, changed: true, matched: true, content: patchedContent },
|
|
patchedContent,
|
|
);
|
|
const { iframe, element } = createPreviewElement();
|
|
const selection = createSelection(element, {
|
|
textFields: [textField({ key: "self", value: "Card", source: "self", tagName: "div" })],
|
|
});
|
|
const writeProjectFile = vi.fn(async () => {});
|
|
const rendered = renderDomEditCommits(selection, iframe, { writeProjectFile });
|
|
|
|
try {
|
|
await act(async () => {
|
|
await rendered.hook.commitDomTextFields(
|
|
selection,
|
|
[textField({ key: "self", value: "Card", source: "self", tagName: "div" })],
|
|
{
|
|
importedFont: {
|
|
family: "Imported",
|
|
path: "fonts/Imported.woff2",
|
|
url: "/api/projects/p1/preview/fonts/Imported.woff2",
|
|
},
|
|
},
|
|
);
|
|
});
|
|
|
|
expect(writeProjectFile).toHaveBeenCalledWith(
|
|
"index.html",
|
|
expect.stringContaining("@font-face"),
|
|
patchedContent,
|
|
);
|
|
expect(rendered.showToast).not.toHaveBeenCalled();
|
|
} finally {
|
|
rendered.cleanup();
|
|
}
|
|
});
|
|
|
|
it("keeps a rejected patch request (HTTP error) to one toast", async () => {
|
|
const { rendered, cleanup } = renderStyleCommitWithFetch(async (input) => {
|
|
const url = requestUrl(input);
|
|
if (url.includes("/api/projects/p1/files/")) {
|
|
return jsonResponse({
|
|
content: '<div data-hf-id="hf-card" style="color: red">Card</div>',
|
|
});
|
|
}
|
|
if (url.includes("/api/projects/p1/file-mutations/patch-element/")) {
|
|
return jsonResponse({ error: "invalid value", fields: ["style.color"] }, 400);
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
|
|
try {
|
|
await act(async () => {
|
|
await rendered.hook.handleDomStyleCommit("color", "blue");
|
|
});
|
|
|
|
expect(rendered.showToast).toHaveBeenCalledTimes(1);
|
|
expect(rendered.showToast).toHaveBeenCalledWith(
|
|
"Couldn't save edit: invalid value (style.color)",
|
|
"error",
|
|
);
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it("keeps the unsafe-value path to one toast", async () => {
|
|
stubPatchFetch({
|
|
ok: true,
|
|
changed: true,
|
|
matched: true,
|
|
content: '<div data-hf-id="hf-card" style="color: blue">Card</div>',
|
|
});
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const { iframe, element } = createPreviewElement();
|
|
const rendered = renderDomEditCommits(createSelection(element, { id: null }), iframe);
|
|
|
|
try {
|
|
await act(async () => {
|
|
await rendered.hook.handleDomStyleCommit("color", "blue");
|
|
});
|
|
|
|
expect(rendered.showToast).toHaveBeenCalledTimes(1);
|
|
expect(rendered.showToast).toHaveBeenCalledWith(
|
|
"Couldn't save edit because it contains invalid layout values",
|
|
"error",
|
|
);
|
|
} finally {
|
|
warnSpy.mockRestore();
|
|
rendered.cleanup();
|
|
}
|
|
});
|
|
|
|
it("persists an added child text field as the element's new markup", async () => {
|
|
await expectPersistedTextStructureEdit(
|
|
(hook) => hook.handleDomAddTextField("first"),
|
|
(markup) => {
|
|
expect(markup).toContain("First");
|
|
expect(markup).toContain("Second");
|
|
// The layer that was added, between the two that were there.
|
|
expect(markup.match(/<span/g) ?? []).toHaveLength(3);
|
|
},
|
|
);
|
|
});
|
|
|
|
it("persists a removed child text field as the element's new markup", async () => {
|
|
await expectPersistedTextStructureEdit(
|
|
(hook) => hook.handleDomRemoveTextField("first"),
|
|
(markup) => {
|
|
expect(markup).not.toContain("First");
|
|
expect(markup).toContain("Second");
|
|
},
|
|
);
|
|
});
|
|
|
|
it("keeps single self text commits on the text-content path", async () => {
|
|
stubPatchFetch({
|
|
ok: true,
|
|
changed: true,
|
|
matched: true,
|
|
content: '<div data-hf-id="hf-card">A < B</div>',
|
|
});
|
|
const { iframe, element } = createPreviewElement('<div data-hf-id="hf-card">Card</div>');
|
|
const selection = createSelection(element, {
|
|
textFields: [textField({ key: "self", value: "Card", source: "self", tagName: "div" })],
|
|
});
|
|
const rendered = renderDomEditCommits(selection, iframe);
|
|
|
|
try {
|
|
await act(async () => {
|
|
await rendered.hook.handleDomTextCommit("A < B", "self");
|
|
});
|
|
|
|
expect(rendered.showToast).not.toHaveBeenCalled();
|
|
expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
|
|
expect(element.textContent).toBe("A < B");
|
|
} finally {
|
|
rendered.cleanup();
|
|
}
|
|
});
|
|
|
|
it("reverts and toasts a text commit when the server rejects the patch", async () => {
|
|
stubPatchFetch(new Error("network down"));
|
|
const { iframe, element } = createPreviewElement('<div data-hf-id="hf-card">Card</div>');
|
|
const selection = createSelection(element, {
|
|
textFields: [textField({ key: "self", value: "Card", source: "self", tagName: "div" })],
|
|
});
|
|
const rendered = renderDomEditCommits(selection, iframe);
|
|
|
|
try {
|
|
await act(async () => {
|
|
await rendered.hook.handleDomTextCommit("Updated", "self");
|
|
});
|
|
|
|
expect(rendered.showToast).toHaveBeenCalledWith(
|
|
'Couldn\'t save "Hero title": network down',
|
|
"error",
|
|
);
|
|
expect(element.textContent).toBe("Card");
|
|
expect(rendered.recordEdit).not.toHaveBeenCalled();
|
|
} finally {
|
|
rendered.cleanup();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("useDomEditCommits attribute persist handling", () => {
|
|
beforeEach(() => {
|
|
ensureCssEscape();
|
|
vi.clearAllMocks();
|
|
vi.unstubAllGlobals();
|
|
document.body.replaceChildren();
|
|
});
|
|
|
|
it("toasts and reverts a data-attribute commit when the server cannot resolve the source element", async () => {
|
|
stubPatchFetch({ ok: true, changed: false, matched: false });
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const { iframe, element } = createPreviewElement();
|
|
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
|
|
try {
|
|
await act(async () => {
|
|
await rendered.hook.handleDomAttributeCommit("volume", "0.8");
|
|
});
|
|
|
|
expect(rendered.showToast).toHaveBeenCalledWith(
|
|
expect.stringMatching(/Couldn't save "Hero title": Couldn't find this element/),
|
|
"error",
|
|
);
|
|
expect(element.getAttribute("data-volume")).toBeNull();
|
|
} finally {
|
|
warnSpy.mockRestore();
|
|
rendered.cleanup();
|
|
}
|
|
});
|
|
|
|
it("toasts and reverts a data-attribute commit when the patch request rejects", async () => {
|
|
stubPatchFetch(new Error("network down"));
|
|
const { iframe, element } = createPreviewElement();
|
|
element.setAttribute("data-volume", "0.5");
|
|
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
|
|
try {
|
|
await act(async () => {
|
|
await rendered.hook.handleDomAttributeCommit("volume", "0.8");
|
|
});
|
|
|
|
expect(rendered.showToast).toHaveBeenCalledWith(
|
|
'Couldn\'t save "Hero title": network down',
|
|
"error",
|
|
);
|
|
expect(element.getAttribute("data-volume")).toBe("0.5");
|
|
} finally {
|
|
rendered.cleanup();
|
|
}
|
|
});
|
|
|
|
it("applies a preview-only write without persisting it", async () => {
|
|
// What a drag needs from every pointermove: the preview and the audio graph
|
|
// follow, the file does not. Persisting each move filled the undo stack with
|
|
// fragments of one gesture — and because those writes race, a follow-up's
|
|
// "before" often was not the previous entry's "after", so history refused to
|
|
// coalesce them and undo took back a few milliseconds of the drag.
|
|
const fetchSpy = stubPatchFetch({ ok: true, changed: true, matched: true });
|
|
const { iframe, element } = createPreviewElement();
|
|
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
|
|
try {
|
|
await act(async () => {
|
|
await rendered.hook.handleDomAttributeLiveCommit("volume", "0.7", undefined, {
|
|
previewOnly: true,
|
|
});
|
|
});
|
|
expect(element.getAttribute("data-volume")).toBe("0.7");
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
} finally {
|
|
rendered.cleanup();
|
|
}
|
|
});
|
|
|
|
it("still persists a live write that does not ask to be preview-only", async () => {
|
|
const fetchSpy = stubPatchFetch({ ok: true, changed: true, matched: true });
|
|
const { iframe, element } = createPreviewElement();
|
|
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
|
|
try {
|
|
await act(async () => {
|
|
await rendered.hook.handleDomAttributeLiveCommit("volume", "0.7");
|
|
});
|
|
expect(fetchSpy).toHaveBeenCalled();
|
|
} finally {
|
|
rendered.cleanup();
|
|
}
|
|
});
|
|
|
|
it("sets and removes data-audio-group like any other data attribute", async () => {
|
|
stubPatchFetch({ ok: true, changed: true, matched: true });
|
|
const { iframe, element } = createPreviewElement();
|
|
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
|
|
try {
|
|
await act(async () => {
|
|
await rendered.hook.handleDomAttributeLiveCommit("audio-group", "voiceover", undefined, {
|
|
previewOnly: true,
|
|
});
|
|
});
|
|
expect(element.getAttribute("data-audio-group")).toBe("voiceover");
|
|
|
|
await act(async () => {
|
|
await rendered.hook.handleDomAttributeLiveCommit("audio-group", "", undefined, {
|
|
previewOnly: true,
|
|
});
|
|
});
|
|
expect(element.getAttribute("data-audio-group")).toBeNull();
|
|
} finally {
|
|
rendered.cleanup();
|
|
}
|
|
});
|
|
|
|
it("keeps a data-attribute commit on success", async () => {
|
|
stubPatchFetch({
|
|
ok: true,
|
|
changed: true,
|
|
matched: true,
|
|
content: '<div data-hf-id="hf-card" data-volume="0.8">Card</div>',
|
|
});
|
|
const { iframe, element } = createPreviewElement();
|
|
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
|
|
try {
|
|
await act(async () => {
|
|
await rendered.hook.handleDomAttributeCommit("volume", "0.8");
|
|
});
|
|
|
|
expect(rendered.showToast).not.toHaveBeenCalled();
|
|
expect(element.getAttribute("data-volume")).toBe("0.8");
|
|
} finally {
|
|
rendered.cleanup();
|
|
}
|
|
});
|
|
|
|
it("toasts and reverts an html-attribute commit when the patch request rejects", async () => {
|
|
stubPatchFetch(new Error("network down"));
|
|
const { iframe, element } = createPreviewElement();
|
|
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
|
|
try {
|
|
await act(async () => {
|
|
await rendered.hook.handleDomHtmlAttributeCommit("muted", "true");
|
|
});
|
|
|
|
expect(rendered.showToast).toHaveBeenCalledWith(
|
|
'Couldn\'t save "Hero title": network down',
|
|
"error",
|
|
);
|
|
expect(element.getAttribute("muted")).toBeNull();
|
|
} finally {
|
|
rendered.cleanup();
|
|
}
|
|
});
|
|
|
|
it("keeps a newer html-attribute value when an older overlapping commit later fails", async () => {
|
|
const first = createDeferred<Response>();
|
|
let call = 0;
|
|
const { iframe, element } = createPreviewElement();
|
|
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: Parameters<typeof fetch>[0]) => {
|
|
const url = requestUrl(input);
|
|
if (url.includes("/api/projects/p1/files/")) {
|
|
return jsonResponse({ content: '<div data-hf-id="hf-card"></div>' });
|
|
}
|
|
call += 1;
|
|
if (call === 1) return first.promise;
|
|
return jsonResponse({ ok: true, changed: true, matched: true, content: "" });
|
|
}),
|
|
);
|
|
|
|
try {
|
|
// Older commit's persist stays pending (captures previousValue=null); the
|
|
// newer commit captures previousValue="first-value" (the older commit's
|
|
// optimistic apply) and succeeds before the older one rejects. Without the
|
|
// per-key version guard, the stale rejection would revert to the older
|
|
// commit's own previousValue (null) and stomp the newer commit's value.
|
|
const firstCommit = act(async () => {
|
|
await rendered.hook.handleDomHtmlAttributeCommit("muted", "first-value");
|
|
});
|
|
await act(async () => {
|
|
await rendered.hook.handleDomHtmlAttributeCommit("muted", "second-value");
|
|
});
|
|
first.reject(new Error("stale request failed"));
|
|
await firstCommit;
|
|
|
|
expect(element.getAttribute("muted")).toBe("second-value");
|
|
} finally {
|
|
rendered.cleanup();
|
|
}
|
|
});
|
|
});
|