Merge pull request #2268 from heygen-com/studio-dnd/pr03-studio-server-files-route

feat(studio-server): files route extensions
This commit is contained in:
Ular Kimsanov
2026-07-11 18:21:32 -07:00
committed by GitHub
2 changed files with 272 additions and 1 deletions
@@ -389,6 +389,145 @@ gsap.set("#box", { rotation: 45 });
expect(payload.content).not.toContain("opacity"); 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 () => { it("update-from-property returns 400 for a non-fromTo animation", async () => {
const projectDir = createProjectDir(); const projectDir = createProjectDir();
const TO_COMP = `<!DOCTYPE html><html><body><script data-hyperframes-gsap> const TO_COMP = `<!DOCTYPE html><html><body><script data-hyperframes-gsap>
@@ -805,4 +944,101 @@ tl.to("#box", { opacity: 1, duration: 1 }, 0);
// The variable target was not flattened to a string-literal selector // The variable target was not flattened to a string-literal selector
expect(result.after).toContain("tl.to(kicker,"); 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");
});
}); });
+36 -1
View File
@@ -773,6 +773,13 @@ type GsapMutationRequest =
targetSelector: string; targetSelector: string;
delta: number; 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"; type: "scale-positions";
targetSelector: string; targetSelector: string;
@@ -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 // 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. // across t=0, flipping hold need; stale holds are not repositioned by these ops.
"shift-positions", "shift-positions",
"shift-positions-batch",
"scale-positions", "scale-positions",
// Retargets keyframed position tweens to a cloned element's selector; the old hold is // 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. // keyed to the prior selector, so holds must be rebuilt for the new target.
@@ -1101,6 +1109,14 @@ function executeGsapMutationAcorn(
if (!targetSelector || !Number.isFinite(delta) || delta === 0) return block.scriptText; if (!targetSelector || !Number.isFinite(delta) || delta === 0) return block.scriptText;
return shiftPositionsInScript(block.scriptText, targetSelector, delta); 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": { case "scale-positions": {
const { targetSelector, oldStart, oldDuration, newStart, newDuration } = body; const { targetSelector, oldStart, oldDuration, newStart, newDuration } = body;
if ( if (
@@ -1463,6 +1479,15 @@ async function executeGsapMutationRecast(
const { shiftPositionsInScript } = parser; const { shiftPositionsInScript } = parser;
return shiftPositionsInScript(block.scriptText, targetSelector, delta); 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": { case "scale-positions": {
const { targetSelector, oldStart, oldDuration, newStart, newDuration } = body; const { targetSelector, oldStart, oldDuration, newStart, newDuration } = body;
if ( if (
@@ -2024,6 +2049,11 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
if (unsafeFields.length > 0) { if (unsafeFields.length > 0) {
return rejectUnsafeMutationValues(c, unsafeFields); 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 html = readFileSync(res.absPath, "utf-8");
let block = extractGsapScriptBlock(html); let block = extractGsapScriptBlock(html);
@@ -2046,7 +2076,12 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
} }
block = extractGsapScriptBlock(html); 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({ return c.json({
ok: true, ok: true,
changed: false, changed: false,