Files
hyperframes/packages/studio/src/components/editor/persistSeam.integration.test.ts
T
Miguel Ángel 22942280b6 fix(studio): per-child patch op builders and persist-seam harness (#1909)
* test(studio): add design-panel QA fixture and triage matrix

Fixture project covering all panel-editable element archetypes,
plus the QA findings matrix from the design-panel bug campaign.

* fix(studio): make canvas selection hit intended elements

- honor author pointer-events:none in hit-testing (was selecting invisible overlays)
- pause playback before mousedown sampling; fall back to hover selection on null resolve
- invalidate committed selection when the active composition changes
- double-click keeps selection and defers to multi-candidate click cycling

* fix(studio): close remaining selection-layer review findings

- hoverSelection fallback now wired at all 3 mousedown call sites (box-click,
  blocked-drag, plain overlay click) instead of just the overlay path
- pointer-events override detection reads computed style, not inline style,
  so a CSS-class opt-in (not just inline style=) on a descendant is honored
- defensively remove the pointer-events override before the group-fallback
  check too, closing a theoretical gap in the no-elementsFromPoint branch
- a click that resolves to nothing (dead-zone / deselect) no longer leaves
  playback paused if it was already playing

* fix(studio-server): child-scoped patch operations with batch abort

- PatchOperation gains optional childSelector/childIndex resolved under the matched parent
- pre-pass resolves every op target; any miss aborts the batch with matched:false, no partial write
- style-decl parsing extracted to sourceStyleMutation to stay under the file-size cap
- new ./source-mutation subpath export (mirrors ./finite-mutation)

* fix(studio): per-child patch op builders and persist-seam harness

- buildTextFieldChildLocator indexes over the parent's full same-tag child list
- buildTextFieldChildOperations emits per-field ops for same-shape multi-field edits
- SDK cutover declines child-scoped batches (hfId mapping would hit the parent)
- persist-seam integration harness drives real client ops through patchElementInHtml

* fix(studio): fail closed on unresolved text-field child index

buildTextFieldChildLocator guessed a synthetic field's position by
counting same-tag "child" fields elsewhere in the array whenever
sourceChildIndex was absent. That heuristic is unreachable today (the
count-mismatch guard in buildTextFieldChildOperations already refuses
add/remove edits before it's reached) but would silently locate the
wrong element for a future caller that wires up synthetic-field
support without also computing a real sourceChildIndex. Return null
instead so the caller falls back to the unsupported-structure path.
2026-07-03 18:14:17 -07:00

265 lines
9.9 KiB
TypeScript

// @vitest-environment jsdom
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
patchElementInHtml,
type PatchOperation,
type SourceMutationTarget,
} from "@hyperframes/studio-server/source-mutation";
import { describe, expect, it } from "vitest";
import {
collectDomEditTextFields,
buildDomEditPatchTarget,
buildDomEditStylePatchOperation,
buildDomEditTextPatchOperation,
} from "./domEditingLayers";
import { buildPathOffsetPatches } from "./manualEditsDomPatches";
import { STUDIO_OFFSET_X_PROP, STUDIO_PATH_OFFSET_ATTR } from "./manualEditsTypes";
import { makeSelection } from "../../hooks/domSelectionTestHarness";
import { buildTextFieldChildOperations } from "../../hooks/domEditTextFieldCommitOps";
const testDir = dirname(fileURLToPath(import.meta.url));
const fixtureDir = join(testDir, "../../../tests/e2e/fixtures/design-panel-qa");
function readFixture(relativePath: string): string {
return readFileSync(join(fixtureDir, relativePath), "utf-8");
}
function createSelection(input: {
id: string;
hfId: string;
tagName: string;
}): ReturnType<typeof makeSelection> {
const element = document.createElement(input.tagName);
element.id = input.id;
element.setAttribute("data-hf-id", input.hfId);
return {
...makeSelection(input.id, element),
hfId: input.hfId,
};
}
function clientTarget(input: { id: string; hfId: string; tagName: string }): SourceMutationTarget {
return buildDomEditPatchTarget(createSelection(input));
}
function patchAndExpectChange(
sourceHtml: string,
target: SourceMutationTarget,
operations: PatchOperation[],
): string {
const result = patchElementInHtml(sourceHtml, target, operations);
expect(result.matched).toBe(true);
expect(result.html).not.toBe(sourceHtml);
return result.html;
}
function parseHtml(html: string): Document {
return new DOMParser().parseFromString(html, "text/html");
}
function findElementInHtml(html: string, selector: string): Element {
const document = parseHtml(html);
const directMatch = document.querySelector(selector);
if (directMatch) return directMatch;
for (const template of Array.from(document.querySelectorAll("template"))) {
const templateMatch = template.content.querySelector(selector);
if (templateMatch) return templateMatch;
}
throw new Error(`Expected selector ${selector} to match`);
}
function findByHfId(html: string, hfId: string): Element {
return findElementInHtml(html, `[data-hf-id="${hfId}"]`);
}
function countOccurrences(value: string, needle: string): number {
return value.split(needle).length - 1;
}
describe("persist seam source mutation", () => {
const indexHtml = readFixture("index.html");
const subHtml = readFixture("compositions/qa-sub.html");
it("persists qa-headline text font-size style operation", () => {
const html = patchAndExpectChange(
indexHtml,
clientTarget({ id: "qa-headline", hfId: "qa-headline", tagName: "h1" }),
[buildDomEditStylePatchOperation("font-size", "64px")],
);
expect(findByHfId(html, "qa-headline").getAttribute("style")).toContain("font-size: 64px");
});
it("persists qa-shape fill style operation", () => {
const html = patchAndExpectChange(
indexHtml,
clientTarget({ id: "qa-shape", hfId: "qa-shape", tagName: "div" }),
[buildDomEditStylePatchOperation("background-color", "#ff0000")],
);
expect(findByHfId(html, "qa-shape").getAttribute("style")).toContain(
"background-color: #ff0000",
);
});
it("persists qa-multi text color style operation", () => {
const html = patchAndExpectChange(
indexHtml,
clientTarget({ id: "qa-multi", hfId: "qa-multi", tagName: "div" }),
[buildDomEditStylePatchOperation("color", "#00ff00")],
);
expect(findByHfId(html, "qa-multi").getAttribute("style")).toContain("color: #00ff00");
});
it("persists qa-image opacity style operation", () => {
const html = patchAndExpectChange(
indexHtml,
clientTarget({ id: "qa-image", hfId: "qa-image", tagName: "img" }),
[buildDomEditStylePatchOperation("opacity", "0.4")],
);
expect(findByHfId(html, "qa-image").getAttribute("style")).toContain("opacity: 0.4");
});
it("persists detached jsdom path offset operations", () => {
const element = document.createElement("div");
element.style.setProperty(STUDIO_OFFSET_X_PROP, "24px");
const html = patchAndExpectChange(
indexHtml,
clientTarget({ id: "qa-shape", hfId: "qa-shape", tagName: "div" }),
buildPathOffsetPatches(element),
);
const shape = findByHfId(html, "qa-shape");
expect(shape.getAttribute("style")).toContain(`${STUDIO_OFFSET_X_PROP}: 24px`);
expect(shape.getAttribute("style")).toContain("translate: var(--hf-studio-offset-x, 0px)");
expect(shape.getAttribute(STUDIO_PATH_OFFSET_ATTR)).toBe("true");
});
it("persists timeline data-start attribute operation", () => {
const html = patchAndExpectChange(
indexHtml,
clientTarget({ id: "qa-zone-headline", hfId: "qa-zone-headline", tagName: "div" }),
[{ type: "attribute", property: "start", value: "2.5" }],
);
expect(findByHfId(html, "qa-zone-headline").getAttribute("data-start")).toBe("2.5");
expect(countOccurrences(html, 'data-start="2.5"')).toBe(1);
});
it("persists media volume data attribute operation", () => {
const html = patchAndExpectChange(
indexHtml,
clientTarget({ id: "qa-video", hfId: "qa-video", tagName: "video" }),
[{ type: "attribute", property: "volume", value: "0.75" }],
);
expect(findByHfId(html, "qa-video").getAttribute("data-volume")).toBe("0.75");
expect(html).not.toContain('data-volume="0.5"');
});
it("returns matched false and unchanged html for a missing hfId target", () => {
const result = patchElementInHtml(indexHtml, { hfId: "qa-does-not-exist" }, [
buildDomEditStylePatchOperation("font-size", "64px"),
]);
expect(result.matched).toBe(false);
expect(result.html).toBe(indexHtml);
});
it("persists sub-composition child style operation inside a template", () => {
const html = patchAndExpectChange(
subHtml,
clientTarget({ id: "qa-sub-title", hfId: "qa-sub-title", tagName: "h2" }),
[buildDomEditStylePatchOperation("font-size", "50px")],
);
expect(findByHfId(html, "qa-sub-title").getAttribute("style")).toContain("font-size: 50px");
});
it("returns matched false for runtime-generated caption words absent from static source", () => {
const result = patchElementInHtml(
indexHtml,
{ selector: "#qa-caption-host span", selectorIndex: 0 },
[buildDomEditStylePatchOperation("color", "#ffffff")],
);
expect(result.matched).toBe(false);
expect(result.html).toBe(indexHtml);
});
it("fixes U4: child text-field style persists as an inline style on the correct child span", () => {
const html = patchAndExpectChange(indexHtml, { hfId: "qa-multi" }, [
buildDomEditStylePatchOperation("color", "#0000ff", {
childSelector: ":scope > span",
childIndex: 0,
}),
]);
const lineA = findElementInHtml(html, ".qa-line-a");
const lineB = findElementInHtml(html, ".qa-line-b");
expect(lineA.getAttribute("style")).toContain("color: #0000ff");
expect(lineB.getAttribute("style")).toBeNull();
expect(lineB.textContent).toBe("Second styled line");
expect(html).not.toContain("&lt;span");
});
it("targets the second direct child when siblings share the same tag and class", () => {
const source = `<div data-hf-id="dups"><span class="dup">First</span><span class="dup">Second</span></div>`;
const html = patchAndExpectChange(source, { hfId: "dups" }, [
buildDomEditStylePatchOperation("color", "#0000ff", {
childSelector: ":scope > span",
childIndex: 1,
}),
]);
const document = parseHtml(html);
const spans = Array.from(document.querySelectorAll(".dup"));
expect(spans[0]?.getAttribute("style")).toBeNull();
expect(spans[1]?.getAttribute("style")).toContain("color: #0000ff");
});
it("persists a child text-field content edit as plain text", () => {
const value = "A < B & C";
const html = patchAndExpectChange(indexHtml, { hfId: "qa-multi" }, [
buildDomEditTextPatchOperation(value, {
childSelector: ":scope > span",
childIndex: 0,
}),
]);
expect(findElementInHtml(html, ".qa-line-a").textContent).toBe(value);
expect(findElementInHtml(html, ".qa-line-b").textContent).toBe("Second styled line");
expect(html).not.toContain("&lt;span");
});
it("uses same-tag source child indexes when a non-leaf sibling sits between fields", () => {
const source = `<div data-hf-id="mixed"><span class="leaf-a">First</span><span class="wrapper"><b>Wrapper</b></span><span class="leaf-b">Second</span></div>`;
const previewHost = document.createElement("div");
previewHost.innerHTML = source;
const previewTarget = previewHost.querySelector('[data-hf-id="mixed"]');
if (!(previewTarget instanceof HTMLElement)) throw new Error("Expected preview target");
const originalFields = collectDomEditTextFields(previewTarget);
const secondField = originalFields.find((field) => field.value === "Second");
if (!secondField) throw new Error("Expected second text field");
const nextFields = originalFields.map((field) =>
field.key === secondField.key ? { ...field, value: "Second updated" } : field,
);
const operations = buildTextFieldChildOperations(originalFields, nextFields);
if (!operations) throw new Error("Expected child operations");
const html = patchAndExpectChange(source, { hfId: "mixed" }, operations);
expect(findElementInHtml(html, ".leaf-a").textContent).toBe("First");
expect(findElementInHtml(html, ".wrapper").textContent).toBe("Wrapper");
expect(findElementInHtml(html, ".leaf-b").textContent).toBe("Second updated");
});
});