Skip to content
Merged
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
56 changes: 33 additions & 23 deletions packages/studio-server/src/routes/files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1307,30 +1307,40 @@ tl.fromTo("#box", { opacity: 0, x: -50 }, { opacity: 1, x: 0, duration: 1.5, eas
});

it("rejects a stale semantic no-op after a concurrent file write", async () => {
const projectDir = createProjectDir();
writeHtml(projectDir, "comp.html", FROMTO_COMP);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const successor = FROMTO_COMP.replace('data-duration="3"', 'data-duration="9"');
let releaseImport = () => {};
recastImportGate.wait = new Promise<void>((resolve) => {
releaseImport = resolve;
});
const parserEntered = new Promise<void>((resolve) => {
recastImportGate.onEnter = resolve;
});

const pending = postGsapMutationBatch(app, "comp.html", {
mutations: [{ type: "shift-positions", targetSelector: "#missing", delta: 1 }],
});
await parserEntered;
writeHtml(projectDir, "comp.html", successor);
releaseImport();
const response = await pending;
// Pins the recast writer: this test's interleave seam is recast's LAZY module
// import, which the acorn default no longer performs (acorn is statically
// imported). The 409 revalidation under test is writer-independent.
const previousWriter = process.env.HYPERFRAMES_GSAP_WRITER;
process.env.HYPERFRAMES_GSAP_WRITER = "recast";

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reasoning checks out — I verified the 409 revalidation at files.ts:1310-1313 really is outside the writer branch (after the mutation loop, before any write), so pinning this to recast drops no coverage of behavior acorn changes. The env mutation is safe too: no concurrent in this file, vitest isolates per file, and the finally restores.

The gap is that the default path now has no test that the conflict is detected at all. It does not need acorn to grow an async seam, though: the revalidate is a plain readFileSync(res.absPath, "utf-8"), so a spy that returns the successor bytes on the second read exercises the 409 under acorn without any interleave.

try {
const projectDir = createProjectDir();
writeHtml(projectDir, "comp.html", FROMTO_COMP);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const successor = FROMTO_COMP.replace('data-duration="3"', 'data-duration="9"');
let releaseImport = () => {};
recastImportGate.wait = new Promise<void>((resolve) => {
releaseImport = resolve;
});
const parserEntered = new Promise<void>((resolve) => {
recastImportGate.onEnter = resolve;
});

expect(response.status).toBe(409);
expect(await response.json()).toMatchObject({ conflict: true });
expect(readFileSync(join(projectDir, "comp.html"), "utf-8")).toBe(successor);
const pending = postGsapMutationBatch(app, "comp.html", {
mutations: [{ type: "shift-positions", targetSelector: "#missing", delta: 1 }],
});
await parserEntered;
writeHtml(projectDir, "comp.html", successor);
releaseImport();
const response = await pending;

expect(response.status).toBe(409);
expect(await response.json()).toMatchObject({ conflict: true });
expect(readFileSync(join(projectDir, "comp.html"), "utf-8")).toBe(successor);
} finally {
if (previousWriter === undefined) delete process.env.HYPERFRAMES_GSAP_WRITER;
else process.env.HYPERFRAMES_GSAP_WRITER = previousWriter;
}
});

it("applies an ordered GSAP mutation batch with one before/after write result", async () => {
Expand Down
33 changes: 28 additions & 5 deletions packages/studio-server/src/routes/gsapMutationCapabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import {
GSAP_MUTATION_CAPABILITIES,
acornDefaultBlockers,
behavioralParityOps,
renderGsapMutationCapabilityReport,
resolveGsapWriter,
} from "./gsapMutationCapabilities.js";
Expand Down Expand Up @@ -61,14 +61,37 @@ describe("GSAP writer capability matrix", () => {
);
});

it("renders every classified operation and keeps default blocked until parity is differential", () => {
it("renders every classified operation and names the behavioral-only ops", () => {
const report = renderGsapMutationCapabilityReport();
for (const operation of operations) expect(report).toContain(`| ${operation} |`);
expect(acornDefaultBlockers().length).toBeGreaterThan(0);
expect(report).toContain("Behavioral-only parity");
expect(behavioralParityOps().sort()).toEqual(
[
"add-with-keyframes",
"consolidate-position-writes",
"convert-to-keyframes",
"delete-all-for-selector",
"materialize-keyframes",
"move-keyframe",
"remove-all-keyframes",
"remove-arc-path",
"replace-with-keyframes",
"resize-keyframed-tween",
"scale-positions",
"set-arc-path",
"shift-positions",
"shift-positions-batch",
"split-animations",
"split-into-property-groups",
"unroll-timeline",
"update-arc-segment",
].sort(),
);
});

it("defaults to Recast and requires an explicit Acorn canary selection", () => {
expect(resolveGsapWriter({})).toBe("recast");
it("defaults to Acorn; recast is an explicit fallback", () => {
expect(resolveGsapWriter({})).toBe("acorn");
expect(resolveGsapWriter({ HYPERFRAMES_GSAP_WRITER: "recast" })).toBe("recast");
expect(resolveGsapWriter({ HYPERFRAMES_GSAP_WRITER: "acorn" })).toBe("acorn");
expect(() => resolveGsapWriter({ HYPERFRAMES_GSAP_WRITER: "unknown" })).toThrow(
"expected recast or acorn",
Expand Down
11 changes: 6 additions & 5 deletions packages/studio-server/src/routes/gsapMutationCapabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,16 +68,17 @@ const GSAP_WRITER_MIGRATION = Object.freeze({
owner: "studio-foundations",
deadline: "2026-09-30",
graduationCriteria:
"Every operation has differential parity, the Acorn path imports no Recast runtime, and canary divergence is zero for the agreed soak window.",
"Acorn is the default writer. Recast is retained one watch window as HYPERFRAMES_GSAP_WRITER=recast, then deleted (spec 2026-09-17 §5).",
});

export function resolveGsapWriter(env: { HYPERFRAMES_GSAP_WRITER?: string }): "recast" | "acorn" {
const configured = env.HYPERFRAMES_GSAP_WRITER ?? "recast";
const configured = env.HYPERFRAMES_GSAP_WRITER ?? "acorn";
if (configured === "recast" || configured === "acorn") return configured;
throw new Error(`Invalid ${GSAP_WRITER_MIGRATION.flag}=${configured}; expected recast or acorn`);
}

export function acornDefaultBlockers(): GsapMutationType[] {
/** Ops whose acorn/recast parity rests on behavioral tests, not a byte-diff oracle. */
export function behavioralParityOps(): GsapMutationType[] {
return (
Object.entries(GSAP_MUTATION_CAPABILITIES) as Array<[GsapMutationType, GsapMutationCapability]>
)
Expand All @@ -97,13 +98,13 @@ export function renderGsapMutationCapabilityReport(): string {
"",
`Owner: ${GSAP_WRITER_MIGRATION.owner}`,
`Deadline: ${GSAP_WRITER_MIGRATION.deadline}`,
`Flag: \`${GSAP_WRITER_MIGRATION.flag}=recast|acorn\``,
`Flag: \`${GSAP_WRITER_MIGRATION.flag}=recast|acorn\` (default: acorn)`,
"",
"| Operation | Family | Acorn | Recast | Parity evidence |",
"| --- | --- | --- | --- | --- |",
...rows,
"",
`Default blockers: ${acornDefaultBlockers().join(", ") || "none"}`,
`Behavioral-only parity (no byte-diff oracle): ${behavioralParityOps().join(", ") || "none"}`,
"",
].join("\n");
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveStudioBooleanEnvFlag } from "./manualEditingAvailability";
import { STUDIO_SDK_OPERATION_FAMILIES } from "../../utils/sdkCutoverPolicy";

async function loadAvailabilityWithEnv(env: Record<string, string | undefined>) {
vi.resetModules();
Expand Down Expand Up @@ -88,4 +89,22 @@ describe("manual editing availability", () => {
});
expect(off.STUDIO_FLAT_INSPECTOR_ENABLED).toBe(false);
});

it("defaults SDK cutover on for every family", async () => {
const all = await loadAvailabilityWithEnv({});
expect(all.STUDIO_SDK_CUTOVER_ENABLED).toBe(true);
expect(all.STUDIO_SDK_CUTOVER_FAMILIES).toEqual(new Set(STUDIO_SDK_OPERATION_FAMILIES));
});

it("honors the SDK cutover kill switches", async () => {
const off = await loadAvailabilityWithEnv({ VITE_STUDIO_SDK_CUTOVER_ENABLED: "false" });
expect(off.STUDIO_SDK_CUTOVER_ENABLED).toBe(false);
expect(off.STUDIO_SDK_CUTOVER_FAMILIES.size).toBe(0);

const subset = await loadAvailabilityWithEnv({
VITE_STUDIO_SDK_CUTOVER_FAMILIES: "dom,timing",
});
expect(subset.STUDIO_SDK_CUTOVER_ENABLED).toBe(true);
expect(subset.STUDIO_SDK_CUTOVER_FAMILIES).toEqual(new Set(["dom", "timing"]));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,20 @@ const runtimeEnv =
: {};
const env = { ...(import.meta.env ?? {}), ...runtimeEnv } as StudioFeatureFlagEnv;

// Stage 7 Step 3c: SDK cutover — routes inline-style ops through SDK dispatch
// instead of the server patch-element API. Default false; enable via
// VITE_STUDIO_SDK_CUTOVER_ENABLED=true. Requires SDK session to be open.
// Stage 7 SDK cutover — routes Studio edits through SDK dispatch instead of the
// server patch/mutation routes. Default TRUE as of the flip release (spec:
// 2026-09-17-studio-sdk-cutover-flip-design.md). Kill switch:
// VITE_STUDIO_SDK_CUTOVER_ENABLED=false (works at runtime via __HF_STUDIO_ENV__).
export const STUDIO_SDK_CUTOVER_ENABLED = resolveStudioBooleanEnvFlag(
env,
["VITE_STUDIO_SDK_CUTOVER_ENABLED"],
false,
true,
);

/** Explicit per-operation-family canary selection; the master flag alone enables nothing. */
/**
* Per-family selection. Unset = every family (the master alone enables all).
* Set `VITE_STUDIO_SDK_CUTOVER_FAMILIES=dom,timing` to RESTRICT to a subset.
*/
export const STUDIO_SDK_CUTOVER_FAMILIES = resolveEnabledSdkFamilies(
env,
STUDIO_SDK_CUTOVER_ENABLED,
Expand Down
76 changes: 76 additions & 0 deletions packages/studio/src/hooks/useSdkSession.lifecycle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ vi.mock("@hyperframes/sdk", () => ({
import type { Composition } from "@hyperframes/sdk";
import { useSdkSession, type SdkSessionHandle } from "./useSdkSession";

vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));

import { trackStudioEvent } from "../utils/studioTelemetry";

const trackMock = vi.mocked(trackStudioEvent);

function Probe({ projectId }: { projectId: string }) {
useSdkSession(projectId, "index.html");
return null;
}

(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

function fakeSession(): Composition {
Expand Down Expand Up @@ -135,3 +146,68 @@ describe("useSdkSession ownership", () => {
expect(published.dispose).toHaveBeenCalledOnce();
});
});

describe("useSdkSession unavailable telemetry", () => {
beforeEach(() => {
openComposition.mockReset();
trackMock.mockClear();
class FakeEventSource {
addEventListener(): void {}
close(): void {}
}
vi.stubGlobal("EventSource", FakeEventSource);
});

afterEach(() => {
vi.unstubAllGlobals();
});

// Every cutover chokepoint silently takes the server path when there is no
// session, and the shadow never runs either — so a missing session is a total,
// otherwise-invisible SDK bypass. These three exits are its only origin.
it("reports an unreadable composition", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: false, json: async () => ({}) }) as Response),
);
const root = createRoot(document.createElement("div"));
await act(async () => root.render(<Probe projectId="project-a" />));
await flushAsyncEffects();

expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", { stage: "read" });
await act(async () => root.unmount());
});

it("reports a parse failure with its message", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => response("PROJECT_A")),
);
openComposition.mockRejectedValue(new Error("unparseable composition"));

const root = createRoot(document.createElement("div"));
await act(async () => root.render(<Probe projectId="project-a" />));
await flushAsyncEffects();

expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
stage: "open",
error: "unparseable composition",
});
await act(async () => root.unmount());
});

it("stays silent on the happy path", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => response("PROJECT_A")),
);
openComposition.mockResolvedValue(fakeSession());

const root = createRoot(document.createElement("div"));
await act(async () => root.render(<Probe projectId="project-a" />));
await flushAsyncEffects();

expect(trackMock).not.toHaveBeenCalledWith("sdk_session_unavailable", expect.anything());
await act(async () => root.unmount());
});
});
24 changes: 21 additions & 3 deletions packages/studio/src/hooks/useSdkSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,15 @@ export function useSdkSession(

readProjectFileOptional(projectId, activeCompPath)
.then(async (content) => {
if (cancelled || typeof content !== "string") return;
if (cancelled) return;
if (typeof content !== "string") {
// No SDK session follows, so EVERY cutover chokepoint below takes the
// server path and emits nothing — the shadow never runs either. This
// is the only place a missing session can originate, so a broken read
// would otherwise be a silent, total SDK bypass.
trackStudioEvent("sdk_session_unavailable", { stage: "read" });

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

readProjectFileOptional (:14-29) returns undefined for three unrelated reasons, and all three land here as stage: "read":

  1. the traversal guard, path.includes("\0") || path.includes("..") — returns before any fetch is made;
  2. !res.ok — a 404, which for an ?optional=1 read is routine, and a 500, which is an incident;
  3. a response whose content isn't a string.

One is a security event, one is normal, one pages someone. For a commit titled "report why a fallback happened," read answers "one of three quite different things" — the same shape as the dom chokepoint I flagged last round, where a single reason string couldn't separate operator-caused from not-applicable.

All three branches sit in one small function, so splitting the stage is cheap: blocked_path, not_found vs read_error off res.status, and malformed.

return;
}
// No persist queue: Studio's writeProjectFile (via sdkCutover's
// persistSdkSerialize) is the SINGLE writer. Wiring the SDK persist
// queue too would double-write the file (queue auto-writes on every
Expand All @@ -206,6 +214,7 @@ export function useSdkSession(
)
) {
disposeSdkSession(comp);
trackStudioEvent("sdk_session_unavailable", { stage: "ownership" });

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The other two stages are terminal — no session follows and every chokepoint takes the server path. This one is reached because a newer generation is installing its own session: isSessionOwnerCurrent is false, so this run disposes its composition and the newer run is mid-openComposition. The user is a moment away from having a session, not without one.

That makes it the odd member of a counter whose stated meaning is "a total, otherwise-invisible SDK bypass." It inflates during comp switching and reload storms — and reloadToken is bumped by addExternalFileReloadListener, so every external write to the active comp file (code editor, agent, server-side patch) is a candidate. Agent-driven editing is both the workload most likely to produce it and the one you least want the metric lying about.

I read this rather than ran it, so on frequency I'll only claim the state is reachable — the guard exists because it is, and the refs it compares (:142/:144/:147) are assigned in the render body, ahead of the cleanup that sets cancelled. The semantic mismatch holds regardless of how often it fires.

It's also the only one of the three stages with no test: read, open, and the happy path are covered. Since it's the hardest to reason about, it's the one worth pinning.

Suggest dropping the emit — the newer generation reports its own outcome a moment later — or giving it a separate event name so the absence counter stays alertable.

return;
}
const displaced = ownedSessionRef.current;
Expand All @@ -215,8 +224,17 @@ export function useSdkSession(
setOwnedSession(installed);
if (displaced && displaced.session !== comp) disposeSdkSession(displaced.session);
})
.catch(() => {
if (!cancelled && generationRef.current === generation) setOwnedSession(null);
.catch((error: unknown) => {
if (!cancelled && generationRef.current === generation) {
setOwnedSession(null);
// openComposition threw (unparseable composition, OOM) — same total
// bypass as the read failure above, but this one is a real defect
// rather than a missing file. Carry the message; it is the only clue.
trackStudioEvent("sdk_session_unavailable", {
stage: "open",
error: error instanceof Error ? error.message : String(error),
});
}
});

return () => {
Expand Down
9 changes: 5 additions & 4 deletions packages/studio/src/utils/sdkCutover.gate.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { describe, expect, it, vi } from "vitest";

// Dark-launch contract: with STUDIO_SDK_CUTOVER_ENABLED=false, EVERY cutover
// Kill-switch contract: with VITE_STUDIO_SDK_CUTOVER_ENABLED=false, EVERY cutover
// persist chokepoint must explicitly decline so the caller takes the legacy server
// path — even when a valid SDK session exists (one always does, for
// shadow/selection). This is the contract the prod flag-flip rests on; a future
// refactor of the gate guards that silently re-enables cutover on flag-off
// turns these red. (sdkCutover.test.ts mocks the flag TRUE; this is its sibling.)
// shadow/selection). Default is ON (manualEditingAvailability.test.ts pins that);
// this file pins that the env override still turns it fully off. A future
// refactor of the gate guards that ignores the flag turns these red.
// (sdkCutover.test.ts mocks the flag TRUE; this is its sibling.)
vi.mock("../components/editor/manualEditingAvailability", () => ({
STUDIO_SDK_CUTOVER_ENABLED: false,
STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false,
Expand Down
Loading
Loading