Files
hyperframes/packages/studio/src/hooks/timelineTimingSync.test.ts
T
Ular KimsanovandMiguel Angel Simon Sierra 89db718899 feat(studio): mirror canvas z-order actions into timeline lanes (track order = default paint order) (#2380)
* feat(studio): mirror canvas z-order actions into timeline lanes, badge z overrides

Track order = default paint order; authored z = advanced override.

- timelineZMirror.ts: pure resolver mapping a successful z-menu action to a
  timeline lane move — closest track in the action's direction that is free
  over the clip's whole span, else a new lane adjacent to the crossed
  neighbor; temporal-overlap scope (default pending product sign-off, see
  module doc); visual zone only; same-file reference scoping; persistTrack
  via the shared authored-space rules. null for non-clips (menu stays
  z-only) and at-extreme/no-overlap cases.
- useCanvasZOrderTimelineMirror.ts: after the z commit resolves, the mirror
  persists the lane move through the same machinery as a timeline lane drag
  (optimistic store update, authoredTrack refresh, rollback); inserts reuse
  commitTrackInsert's renumber via a shared buildTrackInsertEdits core. Both
  writes share one coalesce key (zReorderCoalesceKey) and fold into ONE undo
  entry (test proves it over the real history reducer). The mirror never
  triggers the lane->z stacking sync, so it cannot fight the z values the
  action just set.
- timelineZOverride.ts + TimelineClip badge: clips whose paint order
  contradicts lane order among temporally-overlapping same-context visual
  neighbors (laneIsAbove XOR paintsAbove, the stacking-sync predicates) show
  a 'z' badge — authored z overrides are surfaced instead of silently
  disagreeing with the timeline.
- Timeline.tsx track derivations extracted to useTimelineTrackDerivations
  (600-line cap).

* fix(studio): fold mirrored z-order gestures into one undo entry across slow persists

Live verification caught the z write and the mirrored lane write splitting
into two undo entries: the mirror runs after the z persist's server round
trip, which exceeds editHistory's default 300ms coalesce window under real
latency (the unit test's deterministic clock sat inside it).

zReorderCoalesceKey now mints a per-gesture-unique key (monotonic seq, the
laneChangeGestureSeq precedent) and both records carry coalesceMs Infinity —
distinct gestures can never merge, and one gesture always folds regardless
of write latency. coalesceMs threaded through the persist chain alongside
coalesceKey. Also hardens the existing lane-drag move->z fold, which had the
same latent split. Fold test now simulates a 400ms gap (failed before the
fix, passes after); a two-separate-gestures test asserts two entries.

* feat(studio): flashless lane mirror, z-order menu icons, close-gap track menu

- Track-only batch moves (the z-mirror's lane hop and the insert renumber)
  skip the GSAP fallback round-trip and the preview reload entirely — the
  renderer never reads data-track-index, and the live DOM patch + optimistic
  store update cover the UI. Mixed batches keep current behavior. Kills the
  canvas blink on mirrored Bring/Send actions (live-verified: an
  iframe-scoped marker survives the whole gesture).
- The four z-order menu items get 16px stroke icons (single layer diamond +
  directional arrow for Forward/Backward; pierced two-layer stack for
  Front/Back); labels unchanged — they are the industry-standard names.
- New track context menu on empty lane space: 'Close gap' (shifts the next
  clip and every clip after it on that lane left by the clicked gap's width;
  leading gaps count, so a single clip with empty space before it compacts
  to 0) and 'Close all gaps' (whole lane contiguous from 0). Pure gap math
  in timelineGaps.ts; persists through the drag path's atomic batch move
  (one undo per action); refuses when a clip that must shift is locked;
  items disable when there is nothing to close.

* fix(studio): rebind-only preview sync for unmutated timing edits, classical z-menu order

Timing edits that rewrote NO GSAP positions (gap closes and moves of
selector-addressed caption clips, zero-delta batches, comps without a
rewritable script) full-reloaded the preview — and the rerun-current-scripts
attempt was wrong for real compositions: re-executing init-style scripts
(three.js scenes, caption engines) is exactly the unsafe case, verified live
by doubled init warnings and a fallback reload anyway.

The correct observation: when mutated === false the existing __timelines are
still valid — only the runtime's clip visibility windows are stale, and the
live DOM timing attributes were already patched. So the no-mutation path now
runs applySoftReloadFinalization only (seek + __hfForceTimelineRebind +
manual-edits reapply), extracted from the soft-reload machinery — zero
script execution. This also un-blinks comps with no GSAP script at all,
which previously always remounted. Rewritten-script soft reloads,
cannot-soft-reload, otherFileChanged, and mutation failures keep their
existing behavior. gsapSoftReload's undo/redo restore section moved verbatim
to gsapUndoRestore.ts for the 600-line cap.

Also: z-order menu items reordered to the classical arrangement (Bring to
Front, Bring Forward, Send Backward, Send to Back).

Live-verified on a three.js-heavy composition: Close-all-gaps shifted 4
caption clips with correct cumulative amounts, the preview iframe was never
remounted (marker survived), and one undo reverted everything.

* fix(studio): bound forward/backward mirror to a one-element step

User-specified semantic: Bring Forward / Send Backward move the clip past
EXACTLY ONE element. The mirror's lane target is now bounded by the next
temporally-overlapping element beyond the crossed neighbor: a free lane
strictly between the two is taken (closest to the neighbor), and when they
are back-to-back a new track is inserted immediately beyond the crossed
element — never past the second one. Previously the resolver took the
closest free lane anywhere beyond the neighbor, which could carry the track
past a second element while the z action only stepped past one — a
track/paint contradiction our own zOverride badge would flag. Front/back
keep whole-set semantics (past everything; back stays above the audio
zone). End-to-end test pins the 3-stacked case through commitZMirrorLaneMove
to the persisted renumbered tracks.

* feat(studio): permanent gap-menu rows with hover and click-select gap highlights

- TrackGapContextMenu always renders both rows; an inapplicable action dims
  with a tooltip ("No gap here" / lock reason / "No gaps on this track")
  instead of vanishing into a one-item menu. Width badge only when a gap
  exists under the pointer.
- Hovering an ACTIONABLE row highlights the strip(s) it would close in the
  timeline: the single gap for Close gap, every current gap (leading included)
  for Close all gaps. New resolveAllGapIntervals in timelineGaps.ts reports
  present-state intervals (epsilon-tolerant, overlap-safe), distinct from
  resolveAllTrackGaps' post-compaction starts.
- Click-selecting a single clip paints a quieter tint over its lane's gaps
  (suppressed for marquee multi-selection and during drags; the gap-menu hover
  wins on its own lane). Derivation lives in useTimelineGapHighlights with the
  pure buildTimelineGapStrips exported and unit-tested.
- Strips render in TimelineCanvas with the drop-placeholder geometry (row top
  + clip inset), dashed accent for hover, faint tint for selection.
- Timeline.tsx stayed under the 600-line cap by extracting the scroll-viewport
  plumbing (ResizeObserver width + shortcut-hint sync) into
  useTimelineScrollViewport, behavior unchanged.

* feat(studio): stronger capcut-style timeline zoom steps

One button press / pinch gesture now moves the zoom meaningfully: step
factors 1.25x/0.8x -> 1.5x/(2/3) (kept reciprocal so in+out round-trips) and
pinch sensitivity 0.0035 -> 0.007. Addresses "zooming several times to get
anywhere" feedback; cursor anchoring unchanged.

* feat(studio): three-way z sync — layers drags mirror timeline lanes, panel tracks live z edits

Completes the layers/canvas/timeline sync triangle: the Layers panel was the
one surface whose reorders never reached the timeline, and the one that went
stale when the other two wrote z flashlessly.

- Layers drag -> minimal z + equal-jump lane mirror. handleReorder now uses
  the canvas menu's realization core via resolveZOrderReposition (one
  between-z write when a strict gap exists, band-safe scoped renumber
  otherwise) instead of computeReorderZValues' all-sibling stamp — that
  helper is deleted, completing the #2347 unification follow-up. The drop
  then mirrors into a timeline lane move through the same machinery as the
  canvas menu (new resolveRepositionLaneMove: the clip lands on a free lane
  strictly between its NEW paint neighbors' lanes — nearest clip siblings in
  the desired render order, decorations skipped — else a track insert at
  that boundary; audio zone never crossed). Both writes share one
  per-gesture zReorderCoalesceKey with an unbounded fold window, so a drag
  is exactly ONE undo entry; useCanvasZOrderTimelineMirror's plumbing is
  factored into useMirrorLaneMoveCommit and reused by the new
  useLayerReorderTimelineMirror. A same-slot drop is a hard no-op (new
  order-equality guard in resolveZOrderReposition).
- Panel staleness fix: flashless z commits (skipReload) reload nothing and
  bump no refreshKey, so the panel's z-sorted order went stale while paused.
  handleDomZIndexReorderCommit now bumps a store zEditVersion on apply AND
  rollback; the panel re-collects on it. Verified live: the panel re-sorts
  the instant a drag commits and again on undo.
- Layer click reveal (useLayerRevealOverride): clicking a layer that stays
  hidden at the current frame (animation-parked opacity, non-clip
  display/visibility hides, hidden ancestors) temporarily forces the chain
  visible with live inline styles — exact priors restored on deselect, on
  another reveal, on play, and on unmount; never persisted (file diff == 0
  verified live). Clips keep the existing seek-into-window behavior; the
  override applies on a short defer so a seek-revealed clip needs none.
- layerOrdering's unused hasExplicitZIndex probe (zero callers) removed.

Live-verified on a bed copy: a 2-position layers drag wrote exactly one
element (z 6->23 + data-track-index 15->2), the timeline lane moved without
a reload, and a single Cmd+Z restored the file byte-identically.

* feat(studio): full-track selection highlight, borderless gap hover strips

- Click-selecting a clip now lights the WHOLE lane minus its clips — leading
  gap, inter-clip gaps, and the open space after the last clip to the rendered
  end (new resolveLaneEmptyIntervals; displayDuration threaded into the strip
  derivation). Still click-only: any drag/resize suppresses the strips, and a
  marquee multi-select never shows them.
- The gap-menu hover strips drop the dashed border (user feedback) — fill only,
  nudged to 0.18 alpha to keep the same visual weight.

* feat(studio): selected layer paints on top via a reader-transparent z lift

Clicking a layer in the Layers tab now shows the element as if it were at the
very top of the stack while selected — whatever its authored z or panel
position — extending the reveal override (which already forced hidden chains
visible) with a temporary inline z lift:

- liftElementToTop parks the TRUE effective z in data-hf-reveal-prior-z and
  writes a far-top inline z; a static element gets a layout-preserving
  position:relative with its prior parked in data-hf-reveal-prior-pos. Only
  the RENDERER sees the lift: all three studio z readers
  (readTimelineElementZIndex, getElementZIndex, readEffectiveZIndex) return
  the parked prior while the attribute is present, so the canvas z-menu, the
  zOverride badge, the lane mirror, the stacking sync, and the panel sort
  keep reasoning on the element's real z.
- Strictly ephemeral: exact priors restored on deselect / another reveal /
  play / unmount, each property only while it still holds the value the
  override wrote (a later real edit is never clobbered). File diff == 0
  verified live across a full lift/restore cycle.
- A z-reorder commit CONSUMES an active lift (handleDomZIndexReorderCommit
  reads the parked position for its persist-position:relative static check,
  then drops the attributes) — the committed z becomes the truth and the
  later restore is a guarded no-op.

* fix(studio): flashless undo/redo — three full-reload causes in the soft-restore path

Cmd+Z blinked the canvas on essentially every undo. Three independent causes
in applyUndoRestoreToPreview, each sufficient on its own:

1. Master-view path gate: activeCompPath is NULL at the master view, so the
   'paths[0] === activeCompPath' eligibility check could never match the
   index.html restore and every default-view undo full-reloaded at the first
   gate. Normalized to the codebase-wide 'activeCompPath ?? "index.html"'.
2. Nested identity innerHTML check: the diff compared each identified
   element's innerHTML, but the composition root wraps every clip — any child
   change re-detected at the root rejected the restore. Change detection now
   compares only each element's OWN attribute surface; structure/text
   integrity is still guaranteed by the normalize-residual whole-doc pass
   (text nodes, added/removed elements, and un-identified attrs all remain
   after normalization and force the full reload).
3. id-only identity: elements addressed by data-hf-id / selector (no DOM id)
   fell outside the diff entirely. Identity is now id OR data-hf-id, with the
   live sync resolving either.

Also stop re-running an UNCHANGED GSAP script: attribute-only restores (z,
lane, timing, style — the overwhelmingly common undo) now use the rebind-only
finalization (seek + __hfForceTimelineRebind + manual reapply, zero script
execution — the same path as flashless timing edits), instead of tearing down
and rebuilding live timelines or full-reloading when the script can't be
scoped. A restore whose script text genuinely changed still re-runs it via
applySoftReload, and structural restores (split/delete) still full-reload.

Live-verified on the bed (iframe marker): gap-close undo AND redo both keep
the iframe mounted, live DOM lands on the restored values, disk restored
byte-identically.

* feat(studio): left breathing pad before t=0, double zoom sensitivity again

TRACKS_LEFT_PAD (48px) — the horizontal sibling of TRACKS_TOP_PAD: empty lane
surface between the sticky gutter and the ruler's 00:00 / the first clips,
scrolling WITH the content.

- The lanes and the ruler realize it as a plain flow spacer between the
  sticky gutter cell and the time-mapped content div, so every
  content-relative computation (clip left = t*pps, beat lines, lane-menu
  time, clip drag deltas) is untouched by construction.
- Canvas-space overlays shift by the pad: playhead (getTimelinePlayheadLeft),
  gap strips, drop placeholder, snap guide, range highlight, marquee clip
  rects, beat SVG; the insert line spans the pad.
- Every pointer->time inverse subtracts it symmetrically: seekFromX, razor,
  range/marquee anchors, asset drops, and the zoom-anchor gutter basis; fit
  pps and the display width account for the consumed viewport width.
- Live-verified: t=0 clip edge, the 00:00 tick, and the playhead line center
  all sit at GUTTER + TRACKS_LEFT_PAD, and a ruler click lands the playhead
  center exactly under the pointer.

Also doubles the timeline zoom sensitivity again (user feedback after
feel-testing the first bump): button steps 1.5x/(2/3) -> 2x/0.5, pinch
0.007 -> 0.014.

* fix(studio): left pad renders as true empty space, not lane surface

The pad before t=0 inherited each row's background and bottom border from the
row wrapper, so it read as track lanes. Lane visuals now live on the cells:
the sticky gutter keeps its own separator (header column stays delineated),
the time-mapped content div carries the row background + separator, and the
pad spacer stays transparent — bare shell background, no lines. The
new-track insertion line also starts at the pad's end instead of crossing it.

* fix(studio): no vertical line in the ruler band before 00:00

The ruler corner's right border drew the header-boundary line through the
ruler strip, so the band didn't read as starting at 00:00. Dropped it — the
boundary line belongs to the track rows below; the ruler stays completely
clean from the panel edge to the first tick, matching the empty left pad.

* refactor(studio): remove the timeline z-override badge

User decision: the "z" chip on clips never earned its place — dropped
entirely (timelineZOverride.ts + test deleted, TimelineClip badge rendering
and the zOverrideKeys derivation/threading removed). This also eliminates the
review's D2 finding at the root: the badge's cross-document comparison
(stackingContextId ?? null collides across source files in the expanded view)
produced false positives, and there is no longer a detector to mis-fire.
overlapsInTime/paintsAbove lose their export (the badge was their only
external consumer); the paint-order predicate itself is unchanged.

* fix(studio): collision-free expanded child lanes and host-window gap floors

Review findings D1 (blocker) and 4.

- D1: buildChildElements assigned expanded children synthetic display rows as
  `host.track + index` — integers that can EQUAL a real clip's lane in another
  file (host on 0 with two children puts child #2 on 1). Lane grouping merges
  purely by track number, so the collision fused clips from different source
  files into one display lane, and lane-scoped actions (the gap menu) then
  batch-persisted a foreign file's clip. Children now take FRACTIONS strictly
  between the host's lane and the next integer — structurally unable to
  collide with any normalized lane, while still rendering as ordered rows
  under the host. Regression test pins the reviewer's exact two-file scenario.
- Finding 4: gap math compacted toward absolute 0, but an expanded child's
  display time is host-anchored — close/compact could drag it before its host
  window and persist a wrong (even negative) local time. All gap functions
  now take a lane FLOOR (laneGapFloor: 0 for ordinary lanes, the children's
  expandedParentStart for child lanes — single-origin per lane post-D1),
  threaded through the menu model, hover highlights, selected-lane strips,
  and both commits. Close-gap shifts clamp at the gap's own left edge.

* fix(studio): scope mirror references, insert writes, and crossed-neighbor identity

Review findings 1, 2, and 3.

- Finding 1: buildTrackInsertEdits normalized the FULL display set and
  persisted every shifted clip — writing host-lane numbers into OTHER
  composition files when expanded children were showing. The renumber write
  set is now the edited element's own source file (the sanctioned multi-write
  converges one FILE to lane space, never neighbors' files); foreign clips
  keep their authored tracks and re-derive display lanes. The locked-clip
  refusal scopes the same way. Expanded-origin elements refuse the insert
  outright (a new lane is a host-space renumber, meaningless in the child's
  file), and the mirrors restrict an expanded child's lane candidates to its
  own siblings' lanes — a sub-comp child still mirrors WITHIN its sub-comp
  (persisting the sibling's authored track) but can never land on a host lane
  with no same-file occupant. authoredTrackForLane's offset fallback rounds:
  fractional synthetic rows must never leak fractions into data-track-index.
- Finding 2: the mirror comparison sets required only sameSourceFile, but a
  file can contain several CSS stacking contexts and leaf z is only
  comparable within one. Both resolvers now scope by samePaintScope — same
  source file AND same stackingContextId (the file check also stops null root
  contexts of different files from comparing equal in the expanded view).
- Finding 3: the crossed-neighbor key was derived without selectorIndex, so
  duplicate class selectors (.sub) resolved to occurrence 0 — a different
  clip. The key now carries getSelectorIndex, matching how z-reorder entries
  derive theirs.

* fix(studio): z-to-lane gestures are one serialized transaction gated on durable persists

Review findings 5 and 7.

- Finding 5: commitDomEditPatchBatches resolved successfully even when the
  server matched NO patch target — the z write never reached disk (the
  preview reloads to reconverge) yet the lane mirror still ran, desyncing
  track order from what actually paints. The commit now resolves a durability
  report ({allMatched, changed}; the save queue and commit types are generic
  over the result), and the mirror phase is skipped on allMatched === false.
- Finding 7: the z persist rides the DOM-edit save queue while the lane move
  rides the timeline/SDK path — two queues, so a second rapid gesture's z
  write could land BETWEEN the first gesture's z and lane phases. Every
  z-to-lane gesture (canvas z-order menu AND Layers-panel drag) now runs
  through runZLaneGesture: a single module-level tail that serializes the
  COMPLETE two-phase transaction, with unit tests for ordering, the
  durability gate, and queue resilience to failed gestures. The timeline
  lane-drag's inverse (move-then-z-sync) shares its phases' await ordering
  already; cross-gesture serialization for that path is noted as follow-up.
- LayersPanel's pure sort helpers moved to layersPanelSort.ts (600-line cap).

* fix(studio): multi-clip GSAP batch mutations roll back on late failure

Review finding 6. finishGroupTimingGsapFallback mutates files sequentially
per clip; a late per-clip failure left the earlier rewrites on disk with no
aggregate history entry — unreachable by undo. foldGsapMutationIntoHistory
already snapshots every touched path before mutating; on a mutation failure
it now restores each path whose disk content changed (all-or-nothing batch),
reports restore errors without masking the original failure, and rethrows.
Regression test drives a two-clip batch whose second rewrite fails and
asserts the first clip's write is restored byte-identically.

* fix(studio): scope mirror inserts to their lane zone

* fix(studio): unify source-scoped clip identity

* fix(studio): isolate track insert topology

* fix(studio): harden timeline paint synchronization

---------

Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
2026-07-14 14:31:58 -04:00

821 lines
31 KiB
TypeScript

// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { usePlayerStore } from "../player/store/playerStore";
import { jsonResponse, requestUrl } from "./fetchStubTestUtils";
import type { TimelineElement } from "../player/store/playerStore";
import {
captureDurationRollback,
finishClipTimingFallback,
finishGroupTimingGsapFallback,
readFileContent,
shiftGsapPositions,
} from "./timelineTimingSync";
afterEach(() => {
usePlayerStore.getState().reset();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
/**
* Stub fetch: `/files/` reads return contents from the queue (repeating the
* last entry), the GSAP-mutation endpoint owns the first/last contents as its
* atomic before/after pair, and rollback succeeds conditionally.
*/
function stubFetch(
fileContents: string[],
gsapBody: unknown | Error,
supportsOwnedMutations = true,
gsapStatus = 200,
) {
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
const url = requestUrl(input);
if (url.includes("/gsap-mutation-capabilities")) {
return supportsOwnedMutations
? jsonResponse({ atomicOwnershipPairs: true })
: new Response(JSON.stringify({ error: "not found" }), { status: 404 });
}
if (url.includes("/files/")) {
return jsonResponse({ content: fileContents.at(-1) });
}
if (url.includes("/gsap-mutation-rollback/")) {
return jsonResponse({ ok: true, restored: true, conflict: false });
}
if (url.includes("/gsap-mutations/")) {
if (gsapBody instanceof Error) {
return new Response(JSON.stringify({ error: gsapBody.message }), { status: 500 });
}
return new Response(JSON.stringify(gsapBody), {
status: gsapStatus,
headers: { "content-type": "application/json" },
});
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
function clipFallbackInput(overrides: {
reloadPreview: () => void;
recordEdit: (edit: unknown) => Promise<void>;
}) {
return {
iframe: null,
reloadPreview: overrides.reloadPreview,
projectId: "p1",
targetPath: "index.html",
domId: "clip",
label: "Move timeline clip",
recordEdit: overrides.recordEdit as never,
edit: { kind: "shift", delta: 1 } as const,
};
}
describe("finishClipTimingFallback failure domains", () => {
it("rolls back and skips preview sync when history recording fails", async () => {
stubFetch(["<before>", "<before>", "<after>"], {
mutated: true,
scriptText: "tl.to()",
before: "<before>",
after: "<after>",
});
const reloadPreview = vi.fn();
const foldError = new Error("history fold failed");
const recordEdit = vi.fn(async () => {
throw foldError;
});
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
await finishClipTimingFallback(clipFallbackInput({ reloadPreview, recordEdit }));
expect(recordEdit).toHaveBeenCalledTimes(1);
expect(reloadPreview).not.toHaveBeenCalled();
expect(consoleError).toHaveBeenCalledWith(expect.stringContaining("GSAP"), foldError);
const rollback = vi
.mocked(fetch)
.mock.calls.find(([input]) => requestUrl(input).includes("/gsap-mutation-rollback/"));
expect(JSON.parse(String(rollback?.[1]?.body))).toEqual({
expected: "<after>",
restore: "<before>",
});
});
it("reloads when the mutation endpoint fails after dispatch", async () => {
stubFetch(["<before>"], new Error("mutation blew up"));
const reloadPreview = vi.fn();
const recordEdit = vi.fn(async () => {});
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
await finishClipTimingFallback(clipFallbackInput({ reloadPreview, recordEdit }));
expect(recordEdit).not.toHaveBeenCalled();
expect(reloadPreview).toHaveBeenCalledTimes(1);
expect(consoleError).toHaveBeenCalledTimes(1);
});
it("reloads when a successful response omits the exact ownership pair", async () => {
stubFetch(["<possible-write>"], {});
const reloadPreview = vi.fn();
const recordEdit = vi.fn(async () => {});
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
await finishClipTimingFallback(clipFallbackInput({ reloadPreview, recordEdit }));
expect(recordEdit).not.toHaveBeenCalled();
expect(reloadPreview).toHaveBeenCalledTimes(1);
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining("GSAP"),
expect.objectContaining({ message: "Invalid owned GSAP mutation response" }),
);
});
it("reloads when the mutation transport fails after dispatch", async () => {
const transportError = new TypeError("connection reset");
vi.stubGlobal(
"fetch",
vi.fn(async (input: Parameters<typeof fetch>[0]) => {
const url = requestUrl(input);
if (url.includes("/gsap-mutation-capabilities")) {
return jsonResponse({ atomicOwnershipPairs: true });
}
if (url.includes("/gsap-mutations/")) throw transportError;
throw new Error(`Unexpected fetch: ${url}`);
}),
);
const reloadPreview = vi.fn();
const recordEdit = vi.fn(async () => {});
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
await finishClipTimingFallback(clipFallbackInput({ reloadPreview, recordEdit }));
expect(recordEdit).not.toHaveBeenCalled();
expect(reloadPreview).toHaveBeenCalledTimes(1);
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining("GSAP"),
expect.objectContaining({ cause: transportError }),
);
});
it("reloads the preview when the mutation endpoint reports an ownership conflict", async () => {
stubFetch(
["<successor>"],
{ error: "file changed during GSAP mutation", conflict: true },
true,
409,
);
const reloadPreview = vi.fn();
const recordEdit = vi.fn(async () => {});
vi.spyOn(console, "error").mockImplementation(() => {});
await finishClipTimingFallback(clipFallbackInput({ reloadPreview, recordEdit }));
expect(recordEdit).not.toHaveBeenCalled();
expect(reloadPreview).toHaveBeenCalledTimes(1);
});
it("records the fold and syncs on the happy path", async () => {
stubFetch(["<before>", "<before>", "<after>"], {
mutated: true,
scriptText: "tl.to()",
before: "<before>",
after: "<after>",
});
const reloadPreview = vi.fn();
const recordEdit = vi.fn(async () => {});
await finishClipTimingFallback(clipFallbackInput({ reloadPreview, recordEdit }));
expect(recordEdit).toHaveBeenCalledTimes(1);
expect(reloadPreview).toHaveBeenCalledTimes(1);
});
it("preflights ownership support before sending a mutation", async () => {
const fetchMock = stubFetch(["<before>"], { mutated: true, scriptText: null }, false);
const reloadPreview = vi.fn();
const recordEdit = vi.fn(async () => {});
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
await finishClipTimingFallback(clipFallbackInput({ reloadPreview, recordEdit }));
expect(
fetchMock.mock.calls.some(([input]) => requestUrl(input).includes("/gsap-mutations/")),
).toBe(false);
expect(recordEdit).not.toHaveBeenCalled();
expect(reloadPreview).toHaveBeenCalledTimes(1);
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining("GSAP"),
expect.objectContaining({ message: "Server does not support owned GSAP mutations" }),
);
});
it("reloads and surfaces a server that violates its advertised ownership contract", async () => {
const fetchMock = stubFetch(["<after>"], { mutated: true, scriptText: null });
const reloadPreview = vi.fn();
const recordEdit = vi.fn(async () => {});
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
await finishClipTimingFallback(clipFallbackInput({ reloadPreview, recordEdit }));
expect(
fetchMock.mock.calls.some(([input]) => requestUrl(input).includes("/gsap-mutations/")),
).toBe(true);
expect(
fetchMock.mock.calls.some(([input]) =>
requestUrl(input).includes("/gsap-mutation-rollback/"),
),
).toBe(false);
expect(recordEdit).not.toHaveBeenCalled();
expect(reloadPreview).toHaveBeenCalledTimes(1);
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining("GSAP"),
expect.objectContaining({
message: "Invalid owned GSAP mutation response",
}),
);
});
});
describe("captureDurationRollback", () => {
it("restores the pre-sync duration only when it changed", () => {
usePlayerStore.getState().setDuration(4);
const rollback = captureDurationRollback(null);
// No change → rollback is a no-op (no spurious set).
rollback();
expect(usePlayerStore.getState().duration).toBe(4);
usePlayerStore.getState().setDuration(9);
rollback();
expect(usePlayerStore.getState().duration).toBe(4);
});
});
describe("fetch URL encoding (user-influenced segments)", () => {
it("URI-encodes the projectId in file reads", async () => {
const fetchMock = stubFetch(["<html>"], {});
await readFileContent("p/../evil", "index.html");
expect(requestUrl(fetchMock.mock.calls[0]![0])).toBe(
"/api/projects/p%2F..%2Fevil/files/index.html",
);
});
it("URI-encodes the projectId in GSAP mutation calls", async () => {
const fetchMock = stubFetch([], {
mutated: false,
scriptText: null,
before: "<html>",
after: "<html>",
});
await shiftGsapPositions("p one", "scenes/intro.html", "clip", 1);
expect(requestUrl(fetchMock.mock.calls[0]![0])).toBe(
"/api/projects/p%20one/gsap-mutations/scenes%2Fintro.html",
);
});
});
/**
* Live-preview iframe stub: inline GSAP script elements plus the runtime hooks
* the timing rebind needs. `appendedScripts` records any script a sync path
* executes — the rebind-only path must record NONE — and `scriptEls` lets tests
* assert the original script elements were left untouched in the document.
*/
const LIVE_SCRIPT =
'window.__timelines = window.__timelines || {}; window.__timelines["root"] = tl;';
const LIVE_CAPTION_SCRIPT =
'window.__timelines = window.__timelines || {}; window.__timelines["captions"] = capTl;';
function buildLivePreviewIframe(liveScripts: string[] = [LIVE_SCRIPT]) {
const scriptEls = liveScripts.map((text) => {
const el = document.createElement("script");
el.textContent = text;
return el;
});
const container = document.createElement("div");
for (const el of scriptEls) container.appendChild(el);
const contentWindow = {
gsap: { timeline: vi.fn(), set: vi.fn() },
__hfForceTimelineRebind: vi.fn() as unknown,
__timelines: { root: { kill: vi.fn() } } as Record<string, unknown>,
__player: { getTime: () => 0, seek: vi.fn() },
__hfStudioManualEditsApply: vi.fn(),
};
const appendedScripts: string[] = [];
const realAppendChild = container.appendChild.bind(container);
container.appendChild = <T extends Node>(node: T): T => {
const result = realAppendChild(node);
if (node instanceof HTMLScriptElement) appendedScripts.push(node.textContent ?? "");
return result;
};
const contentDocument = {
querySelectorAll: (sel: string) => (sel === "script:not([src])" ? scriptEls : []),
createElement: (tag: string) => document.createElement(tag),
body: container,
head: document.createElement("div"),
};
return {
iframe: { contentWindow, contentDocument } as unknown as HTMLIFrameElement,
contentWindow,
container,
scriptEls,
appendedScripts,
};
}
describe("nothing-to-rewrite timing edits rebind in place (no script re-execution)", () => {
it("single clip without a domId: rebinds + seeks, executes NO script, no full reload", async () => {
// A selector-addressed clip (e.g. a .sub caption) has no domId, so there is
// no GSAP mutation to run — the timing attributes are already live-patched
// and __timelines is still valid, so the runtime only needs to re-derive
// the clip windows (rebind) and re-seek. Re-running init-style scripts
// (three.js setups etc.) is exactly the unsafe case this must avoid.
const { iframe, contentWindow, container, scriptEls, appendedScripts } =
buildLivePreviewIframe();
const reloadPreview = vi.fn();
await finishClipTimingFallback({
...clipFallbackInput({ reloadPreview, recordEdit: vi.fn(async () => {}) }),
iframe,
domId: undefined,
});
expect(reloadPreview).not.toHaveBeenCalled();
expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalledTimes(1);
expect(contentWindow.__player.seek).toHaveBeenCalledTimes(1);
expect(contentWindow.__hfStudioManualEditsApply).toHaveBeenCalledTimes(1);
// NO script executed, the original script element untouched in place.
expect(appendedScripts).toHaveLength(0);
expect(container.contains(scriptEls[0]!)).toBe(true);
expect(scriptEls[0]!.textContent).toBe(LIVE_SCRIPT);
});
it("multi-script document (e.g. three.js + captions): rebind-only, both scripts untouched", async () => {
// Real compositions commonly hold heavy inline scripts (main timeline,
// three.js setup, captions). None of them may run twice — the rebind path
// must not create, remove, or re-execute any of them.
const { iframe, contentWindow, container, scriptEls, appendedScripts } = buildLivePreviewIframe(
[LIVE_SCRIPT, LIVE_CAPTION_SCRIPT],
);
const rootKill = vi.fn();
const captionsKill = vi.fn();
contentWindow.__timelines = { root: { kill: rootKill }, captions: { kill: captionsKill } };
const reloadPreview = vi.fn();
await finishClipTimingFallback({
...clipFallbackInput({ reloadPreview, recordEdit: vi.fn(async () => {}) }),
iframe,
domId: undefined,
});
expect(reloadPreview).not.toHaveBeenCalled();
expect(appendedScripts).toHaveLength(0);
expect(container.contains(scriptEls[0]!)).toBe(true);
expect(container.contains(scriptEls[1]!)).toBe(true);
// The still-valid timelines are NOT killed — nothing re-registers them.
expect(rootKill).not.toHaveBeenCalled();
expect(captionsKill).not.toHaveBeenCalled();
expect(contentWindow.__timelines.root).toBeDefined();
expect(contentWindow.__timelines.captions).toBeDefined();
// One finalization: one seek, one rebind.
expect(contentWindow.__player.seek).toHaveBeenCalledTimes(1);
expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalledTimes(1);
});
it("server-confirmed no-op with an unchanged script rebinds without executing any script", async () => {
stubFetch([], {
mutated: false,
scriptText: LIVE_SCRIPT,
before: "<same>",
after: "<same>",
});
const { iframe, contentWindow, container, scriptEls, appendedScripts } = buildLivePreviewIframe(
[LIVE_SCRIPT, LIVE_CAPTION_SCRIPT],
);
const reloadPreview = vi.fn();
await finishClipTimingFallback({
...clipFallbackInput({ reloadPreview, recordEdit: vi.fn(async () => {}) }),
iframe,
});
expect(reloadPreview).not.toHaveBeenCalled();
expect(appendedScripts).toHaveLength(0);
expect(scriptEls.every((script) => container.contains(script))).toBe(true);
expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalledTimes(1);
});
it("comp with ZERO GSAP scripts also rebinds in place (previously full-reloaded)", async () => {
// The rebind hook is installed by the runtime unconditionally — it does not
// depend on GSAP — so a script-less comp gets the flashless path too.
const { iframe, contentWindow, appendedScripts } = buildLivePreviewIframe([]);
const reloadPreview = vi.fn();
await finishClipTimingFallback({
...clipFallbackInput({ reloadPreview, recordEdit: vi.fn(async () => {}) }),
iframe,
domId: undefined,
});
expect(reloadPreview).not.toHaveBeenCalled();
expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalledTimes(1);
expect(contentWindow.__player.seek).toHaveBeenCalledTimes(1);
expect(appendedScripts).toHaveLength(0);
});
it("full-reloads when the runtime rebind hook is unavailable", async () => {
const { iframe, contentWindow, appendedScripts } = buildLivePreviewIframe();
contentWindow.__hfForceTimelineRebind = undefined;
const reloadPreview = vi.fn();
await finishClipTimingFallback({
...clipFallbackInput({ reloadPreview, recordEdit: vi.fn(async () => {}) }),
iframe,
domId: undefined,
});
expect(reloadPreview).toHaveBeenCalledTimes(1);
expect(appendedScripts).toHaveLength(0);
});
it("still full-reloads when the server MUTATED the file but returned no script", async () => {
// mutated:true with scriptText:null (older server) means the live script is
// now STALE relative to disk — a rebind against it would show wrong
// positions.
stubFetch(["<before>", "<before>", "<after>"], {
mutated: true,
scriptText: null,
before: "<before>",
after: "<after>",
});
const { iframe, contentWindow, appendedScripts } = buildLivePreviewIframe();
const reloadPreview = vi.fn();
await finishClipTimingFallback({
...clipFallbackInput({ reloadPreview, recordEdit: vi.fn(async () => {}) }),
iframe,
});
expect(reloadPreview).toHaveBeenCalledTimes(1);
expect(contentWindow.__hfForceTimelineRebind).not.toHaveBeenCalled();
expect(appendedScripts).toHaveLength(0);
});
it("mutated WITH a rewritten script keeps the script-swap soft path (not rebind-only)", async () => {
// A genuine rewrite must re-run the REWRITTEN script — the rebind-only
// shortcut is reserved for the no-op case where every script is unchanged.
stubFetch(["<before>", "<before>", "<after>"], {
mutated: true,
scriptText: 'window.__timelines["root"] = tl2;',
before: "<before>",
after: "<after>",
});
const { iframe, contentWindow, appendedScripts } = buildLivePreviewIframe();
const reloadPreview = vi.fn();
await finishClipTimingFallback({
...clipFallbackInput({ reloadPreview, recordEdit: vi.fn(async () => {}) }),
iframe,
});
expect(reloadPreview).not.toHaveBeenCalled();
expect(appendedScripts).toHaveLength(1);
expect(appendedScripts[0]).toContain('__timelines["root"] = tl2;');
expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalledTimes(1);
});
it("group batch where every change had nothing to rewrite (gap close over no-domId clips) rebinds in place", async () => {
stubFetch(["<html>"], { mutated: false, scriptText: null });
const { iframe, contentWindow, container, scriptEls, appendedScripts } =
buildLivePreviewIframe();
const reloadPreview = vi.fn();
const element = { sourceFile: "index.html" } as TimelineElement;
await finishGroupTimingGsapFallback({
projectId: "p1",
iframe,
reloadPreview,
label: "Move timeline clips",
errorLabel: "Failed to shift GSAP positions",
recordEdit: vi.fn(async () => {}) as never,
activeCompPath: "index.html",
changes: [{ element }, { element }],
resolveChangePath: () => "index.html",
// No domId → nothing to rewrite for ANY change (the gap-close blink path).
mutateChange: () => null,
});
expect(reloadPreview).not.toHaveBeenCalled();
expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalledTimes(1);
expect(contentWindow.__player.seek).toHaveBeenCalledTimes(1);
// No script executed, the live script element untouched.
expect(appendedScripts).toHaveLength(0);
expect(container.contains(scriptEls[0]!)).toBe(true);
});
it("group batch touching ANOTHER file still full-reloads even when nothing was rewritten", async () => {
stubFetch(["<html>"], { mutated: false, scriptText: null });
const { iframe, contentWindow, appendedScripts } = buildLivePreviewIframe();
const reloadPreview = vi.fn();
await finishGroupTimingGsapFallback({
projectId: "p1",
iframe,
reloadPreview,
label: "Move timeline clips",
errorLabel: "Failed to shift GSAP positions",
recordEdit: vi.fn(async () => {}) as never,
activeCompPath: "index.html",
changes: [
{ element: { sourceFile: "index.html" } as TimelineElement },
{ element: { sourceFile: "scenes/intro.html" } as TimelineElement },
],
resolveChangePath: (el) => el.sourceFile ?? "index.html",
mutateChange: () => null,
});
expect(reloadPreview).toHaveBeenCalledTimes(1);
expect(contentWindow.__hfForceTimelineRebind).not.toHaveBeenCalled();
expect(appendedScripts).toHaveLength(0);
});
});
function installOwnedFileServer(
contents: Map<string, string>,
options: {
failReadAt?: number;
failRollback?: boolean;
beforeRollback?: (path: string) => void;
} = {},
) {
let reads = 0;
const rollbacks: Array<{ path: string; expected: string; restore: string; conflict: boolean }> =
[];
vi.stubGlobal(
"fetch",
// One in-memory server fixture owns capability, file, and CAS rollback
// routes so each transaction test observes a coherent content map.
// fallow-ignore-next-line complexity
vi.fn(async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
const url = requestUrl(input);
if (url.includes("/gsap-mutation-capabilities")) {
return jsonResponse({ atomicOwnershipPairs: true });
}
if (url.includes("/files/")) {
reads += 1;
if (reads === options.failReadAt) throw new Error("verification read failed");
const path = decodeURIComponent(url.split("/files/")[1] ?? "");
return jsonResponse({ content: contents.get(path) ?? "" });
}
if (url.includes("/gsap-mutation-rollback/")) {
const path = decodeURIComponent(url.split("/gsap-mutation-rollback/")[1] ?? "");
const body = JSON.parse(String(init?.body)) as { expected: string; restore: string };
if (options.failRollback) {
return new Response(JSON.stringify({ error: "rollback unavailable" }), { status: 503 });
}
options.beforeRollback?.(path);
const conflict = contents.get(path) !== body.expected;
if (!conflict) contents.set(path, body.restore);
rollbacks.push({ path, ...body, conflict });
return jsonResponse({ ok: true, restored: !conflict, conflict });
}
throw new Error(`Unexpected fetch: ${url}`);
}),
);
return { rollbacks, readCount: () => reads };
}
function ownedMutation(contents: Map<string, string>, path: string, before: string, after: string) {
contents.set(path, after);
return { mutated: true, scriptText: null, before, after };
}
describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
const element = { sourceFile: "index.html" } as TimelineElement;
it("reverse-rolls back every successful same-file step after a late failure", async () => {
const contents = new Map<string, string>([["index.html", "ORIGINAL"]]);
const server = installOwnedFileServer(contents);
let clipIndex = 0;
await finishGroupTimingGsapFallback({
projectId: "p1",
iframe: buildLivePreviewIframe().iframe,
reloadPreview: vi.fn(),
label: "Move timeline clips",
errorLabel: "Failed to shift GSAP positions",
recordEdit: vi.fn(async () => {}) as never,
activeCompPath: "index.html",
changes: [{ element }, { element }, { element }],
resolveChangePath: () => "index.html",
mutateChange: async () => {
clipIndex += 1;
if (clipIndex === 1) return ownedMutation(contents, "index.html", "ORIGINAL", "STEP-1");
if (clipIndex === 2) return ownedMutation(contents, "index.html", "STEP-1", "STEP-2");
throw new Error("late failure");
},
});
expect(server.rollbacks).toEqual([
{ path: "index.html", expected: "STEP-2", restore: "STEP-1", conflict: false },
{ path: "index.html", expected: "STEP-1", restore: "ORIGINAL", conflict: false },
]);
expect(contents.get("index.html")).toBe("ORIGINAL");
});
it("uses the endpoint's atomic before when a foreign write precedes mutation", async () => {
const contents = new Map<string, string>([["index.html", "FOREIGN"]]);
const server = installOwnedFileServer(contents);
let clipIndex = 0;
await finishGroupTimingGsapFallback({
projectId: "p1",
iframe: buildLivePreviewIframe().iframe,
reloadPreview: vi.fn(),
label: "Move timeline clips",
errorLabel: "Failed to shift GSAP positions",
recordEdit: vi.fn(async () => {}) as never,
activeCompPath: "index.html",
changes: [{ element }, { element }],
resolveChangePath: () => "index.html",
mutateChange: async () => {
clipIndex += 1;
if (clipIndex === 1) {
return ownedMutation(contents, "index.html", "FOREIGN", "FOREIGN+OWNED");
}
throw new Error("late failure");
},
});
expect(server.readCount()).toBe(0);
expect(server.rollbacks[0]).toEqual({
path: "index.html",
expected: "FOREIGN+OWNED",
restore: "FOREIGN",
conflict: false,
});
expect(contents.get("index.html")).toBe("FOREIGN");
});
it("server CAS preserves a successor that arrives as rollback starts", async () => {
const contents = new Map<string, string>([["index.html", "ORIGINAL"]]);
const server = installOwnedFileServer(contents, {
beforeRollback: () => contents.set("index.html", "SUCCESSOR"),
});
let clipIndex = 0;
const reloadPreview = vi.fn();
await finishGroupTimingGsapFallback({
projectId: "p1",
iframe: buildLivePreviewIframe().iframe,
reloadPreview,
label: "Move timeline clips",
errorLabel: "Failed to shift GSAP positions",
recordEdit: vi.fn(async () => {}) as never,
activeCompPath: "index.html",
changes: [{ element }, { element }],
resolveChangePath: () => "index.html",
mutateChange: async () => {
clipIndex += 1;
if (clipIndex === 1) return ownedMutation(contents, "index.html", "ORIGINAL", "OWNED");
throw new Error("late failure");
},
});
expect(server.rollbacks[0]?.conflict).toBe(true);
expect(contents.get("index.html")).toBe("SUCCESSOR");
expect(reloadPreview).toHaveBeenCalledTimes(1);
});
it("reloads when a failed rollback request leaves owned bytes without history", async () => {
const contents = new Map<string, string>([["index.html", "ORIGINAL"]]);
installOwnedFileServer(contents, { failRollback: true });
const reloadPreview = vi.fn();
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
let clipIndex = 0;
await finishGroupTimingGsapFallback({
projectId: "p1",
iframe: buildLivePreviewIframe().iframe,
reloadPreview,
label: "Move timeline clips",
errorLabel: "Failed to shift GSAP positions",
recordEdit: vi.fn(async () => {}) as never,
activeCompPath: "index.html",
changes: [{ element }, { element }],
resolveChangePath: () => "index.html",
mutateChange: async () => {
clipIndex += 1;
if (clipIndex === 1) return ownedMutation(contents, "index.html", "ORIGINAL", "OWNED");
throw new Error("late failure");
},
});
expect(contents.get("index.html")).toBe("OWNED");
expect(reloadPreview).toHaveBeenCalledTimes(1);
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining("GSAP"),
expect.objectContaining({ message: "Failed to restore index.html" }),
);
});
it("records first owned before and last owned after without a client snapshot", async () => {
const contents = new Map<string, string>([["index.html", "FOREIGN"]]);
const server = installOwnedFileServer(contents);
const recordEdit = vi.fn(async () => {});
await finishGroupTimingGsapFallback({
projectId: "p1",
iframe: buildLivePreviewIframe().iframe,
reloadPreview: vi.fn(),
label: "Move timeline clips",
errorLabel: "Failed to shift GSAP positions",
recordEdit: recordEdit as never,
activeCompPath: "index.html",
changes: [{ element }, { element }],
resolveChangePath: () => "index.html",
mutateChange: async (_change, _path) => {
const before = contents.get("index.html")!;
const after = before === "FOREIGN" ? "FOREIGN+ONE" : "FOREIGN+ONE+TWO";
return ownedMutation(contents, "index.html", before, after);
},
});
expect(server.readCount()).toBe(1); // final ownership verification only
expect(recordEdit).toHaveBeenCalledWith(
expect.objectContaining({
files: {
"index.html": { before: "FOREIGN", after: "FOREIGN+ONE+TWO" },
},
}),
);
});
it("rejects a discontinuous same-file chain without claiming foreign bytes", async () => {
const contents = new Map<string, string>([["index.html", "ORIGINAL"]]);
const server = installOwnedFileServer(contents);
const recordEdit = vi.fn(async () => {});
let clipIndex = 0;
const reloadPreview = vi.fn();
await finishGroupTimingGsapFallback({
projectId: "p1",
iframe: buildLivePreviewIframe().iframe,
reloadPreview,
label: "Move timeline clips",
errorLabel: "Failed to shift GSAP positions",
recordEdit: recordEdit as never,
activeCompPath: "index.html",
changes: [{ element }, { element }],
resolveChangePath: () => "index.html",
mutateChange: async () => {
clipIndex += 1;
if (clipIndex === 1) return ownedMutation(contents, "index.html", "ORIGINAL", "STEP-1");
return ownedMutation(contents, "index.html", "FOREIGN", "FOREIGN+STEP-2");
},
});
expect(recordEdit).not.toHaveBeenCalled();
expect(server.rollbacks).toEqual([
{
path: "index.html",
expected: "FOREIGN+STEP-2",
restore: "FOREIGN",
conflict: false,
},
{ path: "index.html", expected: "STEP-1", restore: "ORIGINAL", conflict: true },
]);
expect(contents.get("index.html")).toBe("FOREIGN");
expect(reloadPreview).toHaveBeenCalledTimes(1);
});
it("rolls back the owned output when the history verification reread fails", async () => {
const contents = new Map<string, string>([["index.html", "ORIGINAL"]]);
const server = installOwnedFileServer(contents, { failReadAt: 1 });
await finishGroupTimingGsapFallback({
projectId: "p1",
iframe: buildLivePreviewIframe().iframe,
reloadPreview: vi.fn(),
label: "Move timeline clips",
errorLabel: "Failed to shift GSAP positions",
recordEdit: vi.fn(async () => {}) as never,
activeCompPath: "index.html",
changes: [{ element }],
resolveChangePath: () => "index.html",
mutateChange: async () => ownedMutation(contents, "index.html", "ORIGINAL", "OWNED"),
});
expect(server.rollbacks[0]).toEqual({
path: "index.html",
expected: "OWNED",
restore: "ORIGINAL",
conflict: false,
});
expect(contents.get("index.html")).toBe("ORIGINAL");
});
});