Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
236 changes: 236 additions & 0 deletions packages/studio-server/src/routes/files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,145 @@ gsap.set("#box", { rotation: 45 });
expect(payload.content).not.toContain("opacity");
});

// ── Canvas z-order / patch-target regression suite ────────────────────────
// A right-click "move to back" on an id-less element (e.g. a caption `.sub`
// div) once serialized `target.id: null`, which findUnsafeDomPatchValues
// rejected as `body.target.id`, bricking the edit. The RULE: `target.id` is
// metadata, not a layout value — a null there is genuinely invalid and stays
// rejected; the fix is that the client omits an absent id instead of sending
// null, so the patch degrades to a hfId / selector + selectorIndex match.
it("rejects a null target.id in a DOM patch (documents the rule)", async () => {
const projectDir = createProjectDir();
writeFileSync(
join(projectDir, "index.html"),
'<div class="sub" style="z-index: 1">A</div><div class="sub" style="z-index: 2">B</div>',
);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));

const before = readFileSync(join(projectDir, "index.html"), "utf-8");
const response = await app.request(
"http://localhost/projects/demo/file-mutations/patch-element/index.html",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
target: { id: null, selector: ".sub", selectorIndex: 1 },
operations: [{ type: "inline-style", property: "z-index", value: "0" }],
}),
},
);
const payload = (await response.json()) as { error?: string; fields?: string[] };

expect(response.status).toBe(400);
expect(payload.error).toContain("unsafe values");
expect(payload.fields).toContain("body.target.id");
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(before);
});

it("z-reorder with an omitted id degrades to a selector patch (id-less element)", async () => {
const projectDir = createProjectDir();
writeFileSync(
join(projectDir, "index.html"),
'<div class="sub" style="z-index: 1">A</div><div class="sub" style="z-index: 2">B</div>',
);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));

const response = await app.request(
"http://localhost/projects/demo/file-mutations/patch-element/index.html",
{
method: "POST",
headers: { "Content-Type": "application/json" },
// id omitted (undefined) — the fixed client shape for an id-less element.
body: JSON.stringify({
target: { selector: ".sub", selectorIndex: 1 },
operations: [{ type: "inline-style", property: "z-index", value: "0" }],
}),
},
);
const payload = (await response.json()) as { changed?: boolean; content?: string };

expect(response.status).toBe(200);
expect(payload.changed).toBe(true);
// The SECOND `.sub` (selectorIndex 1) is the one restacked, not the first.
expect(payload.content).toContain('<div class="sub" style="z-index: 1">A</div>');
expect(payload.content).toContain("z-index: 0");
});

it("duplicate-id document: a selector+index patch hits the right element, not a rejection", async () => {
const projectDir = createProjectDir();
// Two elements share id="main" AND class="root" (mirrors the user's project,
// where sub-compositions each carry id="main"). Match by selector + index.
writeFileSync(
join(projectDir, "index.html"),
'<div class="root" id="main" style="z-index: 5">first</div>' +
'<div class="root" id="main" style="z-index: 6">second</div>',
);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));

const response = await app.request(
"http://localhost/projects/demo/file-mutations/patch-element/index.html",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
target: { selector: ".root", selectorIndex: 1 },
operations: [{ type: "inline-style", property: "z-index", value: "0" }],
}),
},
);
const payload = (await response.json()) as { changed?: boolean; content?: string };

expect(response.status).toBe(200);
expect(payload.changed).toBe(true);
// First "main" untouched; second one restacked.
expect(payload.content).toContain('<div class="root" id="main" style="z-index: 5">first</div>');
expect(payload.content).toContain("z-index: 0");
});

// Sibling canvas commits (position / size / text) carry real string ids like
// "v-hero" / "vo-part1" / "main". The guard must accept them — it only rejects
// null / non-finite numbers, never inspects the id string — so these never hit
// the z-order "unsafe values" variant.
it.each([
{
label: "position",
id: "v-hero",
op: { type: "inline-style", property: "left", value: "40px" },
},
{
label: "size",
id: "vo-part1",
op: { type: "inline-style", property: "width", value: "320px" },
},
{
label: "text",
id: "main",
op: { type: "text-content", property: "textContent", value: "Hi" },
},
])("accepts a $label commit with a real fixture id ($id)", async ({ id, op }) => {
const projectDir = createProjectDir();
writeFileSync(join(projectDir, "index.html"), `<div id="${id}">x</div>`);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));

const response = await app.request(
"http://localhost/projects/demo/file-mutations/patch-element/index.html",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ target: { id }, operations: [op] }),
},
);
const payload = (await response.json()) as { changed?: boolean; error?: string };

expect(response.status).toBe(200);
expect(payload.error).toBeUndefined();
expect(payload.changed).toBe(true);
});

it("update-from-property returns 400 for a non-fromTo animation", async () => {
const projectDir = createProjectDir();
const TO_COMP = `<!DOCTYPE html><html><body><script data-hyperframes-gsap>
Expand Down Expand Up @@ -805,4 +944,101 @@ tl.to("#box", { opacity: 1, duration: 1 }, 0);
// The variable target was not flattened to a string-literal selector
expect(result.after).toContain("tl.to(kicker,");
});

it("shift-positions-batch equals sequential single shifts (atomic multi-clip)", async () => {
const TWO_TWEENS = `<!DOCTYPE html><html><body><script data-hyperframes-gsap>
const tl = gsap.timeline({ paused: true });
tl.to("#a", { duration: 1, x: 100 }, 1);
tl.to("#b", { duration: 1, x: 200 }, 2);
</script></body></html>`;

const seqDir = createProjectDir();
writeHtml(seqDir, "seq.html", TWO_TWEENS);
const seqApp = new Hono();
registerFileRoutes(seqApp, createAdapter(seqDir));
await seqApp.request("http://localhost/projects/demo/gsap-mutations/seq.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "shift-positions", targetSelector: "#a", delta: 1 }),
});
const seqRes = await seqApp.request("http://localhost/projects/demo/gsap-mutations/seq.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "shift-positions", targetSelector: "#b", delta: 0.5 }),
});
const seqAfter = ((await seqRes.json()) as { after: string }).after;

const batchDir = createProjectDir();
writeHtml(batchDir, "batch.html", TWO_TWEENS);
const batchApp = new Hono();
registerFileRoutes(batchApp, createAdapter(batchDir));
const batchRes = await batchApp.request(
"http://localhost/projects/demo/gsap-mutations/batch.html",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "shift-positions-batch",
shifts: [
{ targetSelector: "#a", delta: 1 },
{ targetSelector: "#b", delta: 0.5 },
],
}),
},
);
const batch = (await batchRes.json()) as { ok: boolean; changed: boolean; after: string };

expect(batchRes.status).toBe(200);
expect(batch.ok).toBe(true);
expect(batch.changed).toBe(true);
// Batching #a then #b in one write == applying them as two sequential single shifts.
expect(batch.after).toBe(seqAfter);
});

it("reports no GSAP mutation for shift-positions-batch in a file with no GSAP script", async () => {
// Same contract as its shift-positions / scale-positions siblings: a file with
// no GSAP block is a no-op {ok, changed:false}, not a 400.
const projectDir = createProjectDir();
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));

const res = await app.request("http://localhost/projects/demo/gsap-mutations/index.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "shift-positions-batch",
shifts: [{ targetSelector: "#box", delta: 1 }],
}),
});
const result = (await res.json()) as { ok?: boolean; changed?: boolean; mutated?: boolean };

expect(res.status).toBe(200);
expect(result.ok).toBe(true);
expect(result.changed).toBe(false);
expect(result.mutated).toBe(false);
});

it("rejects a shift-positions-batch with a missing/non-array `shifts` field (400)", async () => {
const projectDir = createProjectDir();
writeHtml(
projectDir,
"comp.html",
`<!DOCTYPE html><html><body><script data-hyperframes-gsap>
const tl = gsap.timeline({ paused: true });
tl.to("#a", { duration: 1, x: 100 }, 1);
</script></body></html>`,
);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));

const res = await app.request("http://localhost/projects/demo/gsap-mutations/comp.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "shift-positions-batch" }),
});
const result = (await res.json()) as { error?: string };

expect(res.status).toBe(400);
expect(result.error).toContain("shifts");
});
});
37 changes: 36 additions & 1 deletion packages/studio-server/src/routes/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,13 @@ type GsapMutationRequest =
targetSelector: string;
delta: number;
}
| {
// Batched shift: fold shiftPositionsInScript over N selectors in one write.
// Lets a multi-clip timeline move (ripple / insert) shift every affected
// clip's tweens atomically instead of one racing server round-trip per clip.
type: "shift-positions-batch";
shifts: Array<{ targetSelector: string; delta: number }>;
}
| {
type: "scale-positions";
targetSelector: string;
Expand Down Expand Up @@ -815,6 +822,7 @@ const HOLD_SYNC_MUTATION_TYPES = new Set<string>([
// Time-shift / time-scale tweens, which can move a keyframed position tween's start
// across t=0, flipping hold need; stale holds are not repositioned by these ops.
"shift-positions",
"shift-positions-batch",
"scale-positions",
// Retargets keyframed position tweens to a cloned element's selector; the old hold is
// keyed to the prior selector, so holds must be rebuilt for the new target.
Expand Down Expand Up @@ -1101,6 +1109,14 @@ function executeGsapMutationAcorn(
if (!targetSelector || !Number.isFinite(delta) || delta === 0) return block.scriptText;
return shiftPositionsInScript(block.scriptText, targetSelector, delta);
}
case "shift-positions-batch": {
let script = block.scriptText;
for (const s of body.shifts) {
if (!s.targetSelector || !Number.isFinite(s.delta) || s.delta === 0) continue;
script = shiftPositionsInScript(script, s.targetSelector, s.delta);
}
return script;
}
case "scale-positions": {
const { targetSelector, oldStart, oldDuration, newStart, newDuration } = body;
if (
Expand Down Expand Up @@ -1463,6 +1479,15 @@ async function executeGsapMutationRecast(
const { shiftPositionsInScript } = parser;
return shiftPositionsInScript(block.scriptText, targetSelector, delta);
}
case "shift-positions-batch": {
const { shiftPositionsInScript } = parser;
let script = block.scriptText;
for (const s of body.shifts) {
if (!s.targetSelector || !Number.isFinite(s.delta) || s.delta === 0) continue;
script = shiftPositionsInScript(script, s.targetSelector, s.delta);
}
return script;
}
case "scale-positions": {
const { targetSelector, oldStart, oldDuration, newStart, newDuration } = body;
if (
Expand Down Expand Up @@ -2024,6 +2049,11 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
if (unsafeFields.length > 0) {
return rejectUnsafeMutationValues(c, unsafeFields);
}
// Defensive shape guard: a malformed shift-positions-batch (missing/non-array
// `shifts`) would otherwise TypeError inside `for (const s of body.shifts)`.
if (body.type === "shift-positions-batch" && !Array.isArray(body.shifts)) {
return c.json({ error: "shift-positions-batch requires a `shifts` array" }, 400);
}

let html = readFileSync(res.absPath, "utf-8");
let block = extractGsapScriptBlock(html);
Expand All @@ -2046,7 +2076,12 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
}
block = extractGsapScriptBlock(html);
}
if (!block && (body.type === "shift-positions" || body.type === "scale-positions")) {
if (
!block &&
(body.type === "shift-positions" ||
body.type === "scale-positions" ||
body.type === "shift-positions-batch")
) {
return c.json({
ok: true,
changed: false,
Expand Down
Loading
Loading