mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix(sdk,core): css tokenizer, override-set replay, setattribute safety, persist errors (#1350)
* fix(sdk,core): css tokenizer, override-set replay, setattribute safety, persist errors * test(sdk,ci): smoke test + explicit sdk-tests CI gate Smoke test covers the full public surface: openComposition → setStyle/setText/dispatch(moveElement) → serialize applyPatches + ORIGIN_APPLY_PATCHES tagging batch() coalescing + transactional rollback on throw undo/redo round-trip persist adapter write + persist:error surfacing T3 embedded mode: override-set apply on open + getOverrides round-trip Adds sdk-tests CI job so SDK coverage is explicitly named and required — prevents a repeat of the demo-next vitest-never-ran incident. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sdk): export adapter types, awaitable flush(), never-coalesce mode - Export PersistAdapter, PreviewAdapter, PersistVersionEntry from package root — callers can now write typed fakes without reaching into internals - Add flush(): Promise<void> to Composition interface + CompositionImpl — app-close handlers can await a clean drain of the persist queue - coalesceMs <= 0 disables coalescing entirely in createHistory — enables deterministic test scenarios without per-entry timestamp manipulation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(sdk): p2 edge cases — setText no-text-node, override-remove non-existent, flush in smoke - setText on element with no prior text node (firstTextIdx=-1 path) - applyOverrideSet null removal on non-existent prop is a no-op (no throw) - smoke persist test uses comp.flush() instead of setTimeout - can() JSDoc clarifies Phase 3b false-return is intentional feature-detection Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ci: trigger regression suite * fix(ci): add packages/sdk/package.json to Dockerfile.test workspace copy bun install --frozen-lockfile fails in the regression Docker build because the lockfile references the sdk workspace member but its package.json was not copied into the image before the install step. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
69d67f1d69
commit
a0ee97210b
@@ -218,6 +218,18 @@ jobs:
|
||||
- run: bun run --cwd packages/core build:hyperframes-runtime
|
||||
- run: bun run --filter '!@hyperframes/producer' test
|
||||
|
||||
sdk-tests:
|
||||
name: "SDK: unit + contract + smoke"
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
- run: bun install --frozen-lockfile
|
||||
- run: bun run --filter @hyperframes/sdk test
|
||||
|
||||
test-runtime-contract:
|
||||
name: "Test: runtime contract"
|
||||
needs: changes
|
||||
|
||||
@@ -80,6 +80,7 @@ COPY packages/studio/package.json packages/studio/package.json
|
||||
COPY packages/shader-transitions/package.json packages/shader-transitions/package.json
|
||||
COPY packages/aws-lambda/package.json packages/aws-lambda/package.json
|
||||
COPY packages/gcp-cloud-run/package.json packages/gcp-cloud-run/package.json
|
||||
COPY packages/sdk/package.json packages/sdk/package.json
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Copy source
|
||||
|
||||
@@ -223,10 +223,35 @@ function isSafeAttributeValue(name: string, value: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function patchStyleAttrString(style: string, property: string, value: string | null): string {
|
||||
const props = new Map<string, string>();
|
||||
const order: string[] = [];
|
||||
for (const decl of style.split(";")) {
|
||||
// Tokenize declarations robustly: values can contain ';' inside quoted strings
|
||||
// (e.g. content: ';') and ':' inside values (data URIs, url(), etc.).
|
||||
// Split on ';' only when outside quotes and balanced parens; the first ':' in
|
||||
// the resulting segment is the property/value separator (property names never
|
||||
// contain ':').
|
||||
let i = 0;
|
||||
while (i < style.length) {
|
||||
let depth = 0;
|
||||
let inSingle = false;
|
||||
let inDouble = false;
|
||||
const start = i;
|
||||
while (i < style.length) {
|
||||
const ch = style[i];
|
||||
if (ch === "'" && !inDouble) inSingle = !inSingle;
|
||||
else if (ch === '"' && !inSingle) inDouble = !inDouble;
|
||||
else if (!inSingle && !inDouble) {
|
||||
if (ch === "(") depth++;
|
||||
else if (ch === ")") depth = Math.max(0, depth - 1);
|
||||
else if (ch === ";" && depth === 0) break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
const decl = style.slice(start, i).trim();
|
||||
i++; // advance past ';'
|
||||
if (!decl) continue;
|
||||
const colon = decl.indexOf(":");
|
||||
if (colon < 0) continue;
|
||||
const key = decl.slice(0, colon).trim();
|
||||
|
||||
@@ -59,8 +59,9 @@ function parsePath(path: string): ParsedPath | null {
|
||||
|
||||
/**
|
||||
* Replay a stored override-set onto a freshly-parsed base document (T3 init).
|
||||
* Property keys with null mean "restore base value" — a no-op on a fresh base.
|
||||
* Bare element keys with null are removal markers — the element is removed.
|
||||
* A null value means the property was explicitly deleted — emit a remove patch
|
||||
* so the base document matches the session state. (Removing a non-existent
|
||||
* property is a no-op in applyOne, so this is safe against fresh-base misses.)
|
||||
*/
|
||||
export function applyOverrideSet(parsed: ParsedDocument, overrides: OverrideSet): void {
|
||||
const patches: JsonPatchOp[] = [];
|
||||
@@ -68,7 +69,7 @@ export function applyOverrideSet(parsed: ParsedDocument, overrides: OverrideSet)
|
||||
const path = keyToPath(key);
|
||||
if (!path) continue;
|
||||
if (value === null) {
|
||||
if (!key.includes(".")) patches.push({ op: "remove", path });
|
||||
patches.push({ op: "remove", path });
|
||||
continue;
|
||||
}
|
||||
patches.push({ op: "replace", path, value });
|
||||
|
||||
@@ -140,6 +140,17 @@ describe("setText", () => {
|
||||
expect(serializeDocument(parsed)).toBe(before);
|
||||
});
|
||||
|
||||
it("creates text node when element has no existing text node", () => {
|
||||
const parsed = parseMutable(
|
||||
'<div data-hf-id="hf-s" data-hf-root><span data-hf-id="hf-empty"></span></div>',
|
||||
);
|
||||
const result = applyOp(parsed, { type: "setText", target: "hf-empty", value: "Added" });
|
||||
const el = parsed.document.querySelector('[data-hf-id="hf-empty"]');
|
||||
expect(el?.textContent).toBe("Added");
|
||||
expect(result.forward[0]?.op).toBe("replace");
|
||||
expect(result.forward[0]?.value).toBe("Added");
|
||||
});
|
||||
|
||||
it("override-set key maps correctly", () => {
|
||||
expect(pathToKey("/elements/hf-title/text")).toBe("hf-title.text");
|
||||
});
|
||||
|
||||
@@ -40,19 +40,57 @@ export interface MutationResult {
|
||||
|
||||
const EMPTY: MutationResult = { forward: [], inverse: [] };
|
||||
|
||||
/** Ops that require the Phase 3b parser-backed engine (meriyah/css-tree). */
|
||||
const PHASE3B_OPS = new Set([
|
||||
"setClassStyle",
|
||||
"addGsapTween",
|
||||
"setGsapTween",
|
||||
"setGsapKeyframe",
|
||||
"addGsapKeyframe",
|
||||
"removeGsapKeyframe",
|
||||
"removeGsapTween",
|
||||
"addLabel",
|
||||
"removeLabel",
|
||||
// ─── setAttribute safety ────────────────────────────────────────────────────
|
||||
|
||||
// Composition-reserved attributes — changing these breaks element identity or
|
||||
// the core/studio data model. Reject before mutating.
|
||||
const RESERVED_ATTRS = new Set([
|
||||
"data-hf-id",
|
||||
"data-composition-id",
|
||||
"data-width",
|
||||
"data-height",
|
||||
"data-start",
|
||||
"data-end",
|
||||
"data-track-index",
|
||||
"data-hold-start",
|
||||
"data-hold-end",
|
||||
"data-hold-fill",
|
||||
]);
|
||||
|
||||
const DANGEROUS_URI_SCHEMES = /^(?:javascript|vbscript):/i;
|
||||
const DANGEROUS_DATA_URI = /^data\s*:\s*text\/html/i;
|
||||
const URI_BEARING_ATTRS = new Set([
|
||||
"src",
|
||||
"href",
|
||||
"action",
|
||||
"formaction",
|
||||
"poster",
|
||||
"srcset",
|
||||
"xlink:href",
|
||||
]);
|
||||
|
||||
function validateSetAttribute(name: string, value: string | null): void {
|
||||
const lower = name.toLowerCase();
|
||||
if (RESERVED_ATTRS.has(lower)) {
|
||||
throw new Error(
|
||||
`setAttribute: "${name}" is a reserved composition attribute and cannot be reassigned. ` +
|
||||
`Use the appropriate typed method (setTiming, setHold, etc.) instead.`,
|
||||
);
|
||||
}
|
||||
if (lower.startsWith("on")) {
|
||||
throw new Error(
|
||||
`setAttribute: event-handler attributes ("${name}") are not permitted — ` +
|
||||
`they produce executable HTML that cannot be safely serialized.`,
|
||||
);
|
||||
}
|
||||
if (value !== null && URI_BEARING_ATTRS.has(lower)) {
|
||||
const trimmed = value.trim();
|
||||
if (DANGEROUS_URI_SCHEMES.test(trimmed) || DANGEROUS_DATA_URI.test(trimmed)) {
|
||||
throw new Error(`setAttribute: unsafe URI value for "${name}".`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class UnsupportedOpError extends Error {
|
||||
// Stable error code — part of the public API contract (F7); hosts switch on
|
||||
// err.code rather than the message.
|
||||
@@ -169,7 +207,11 @@ function handleSetText(parsed: ParsedDocument, ids: HfId[], value: string): Muta
|
||||
const oldText = getOwnText(el);
|
||||
setOwnText(el, value);
|
||||
const path = textPath(id);
|
||||
const p = scalarChange(path, oldText || null, value);
|
||||
// getOwnText always returns string ("" for empty) — use it directly so
|
||||
// the forward patch is always op:'replace', not op:'add'. An op:'add' on
|
||||
// a text path is semantically wrong for external JSON-patch consumers
|
||||
// (the path already exists; add would fail on strict appliers).
|
||||
const p = scalarChange(path, oldText, value);
|
||||
result.forward.push(p.forward);
|
||||
result.inverse.push(p.inverse);
|
||||
}
|
||||
@@ -182,6 +224,7 @@ function handleSetAttribute(
|
||||
name: string,
|
||||
value: string | null,
|
||||
): MutationResult {
|
||||
validateSetAttribute(name, value);
|
||||
const result: MutationResult = { forward: [], inverse: [] };
|
||||
for (const id of ids) {
|
||||
const el = findById(parsed.document, id);
|
||||
@@ -400,9 +443,10 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): boolean {
|
||||
return findRoot(parsed.document) !== null;
|
||||
case "setCompositionMetadata":
|
||||
return true;
|
||||
// Phase 3b — not implemented yet; can() must report false so callers
|
||||
// can feature-detect instead of hitting UnsupportedOpError.
|
||||
// Phase 3b and unknown ops — report false so callers can feature-detect.
|
||||
// An unknown op type must never silently pass validation only to no-op
|
||||
// or throw in applyOp (which would violate the can() contract).
|
||||
default:
|
||||
return !PHASE3B_OPS.has(op.type);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +115,9 @@ export function keyToPath(key: string): string | null {
|
||||
if (text?.[1]) return textPath(text[1]);
|
||||
|
||||
const attr = /^([^.]+)\.attr\.(.+)$/.exec(key);
|
||||
if (attr?.[1] && attr[2]) return attrPath(attr[1], attr[2]);
|
||||
// pathToKey stores the RFC 6902-encoded segment verbatim; do NOT call attrPath()
|
||||
// here (it would re-escape '~' → '~0'), just reconstruct the path directly.
|
||||
if (attr?.[1] && attr[2]) return `/elements/${attr[1]}/attributes/${attr[2]}`;
|
||||
|
||||
const timing = /^([^.]+)\.timing\.(start|end|trackIndex)$/.exec(key);
|
||||
if (timing?.[1]) return timingPath(timing[1], timing[2] as "start" | "end" | "trackIndex");
|
||||
|
||||
@@ -68,6 +68,7 @@ export function createHistory(session: Composition, opts: HistoryOptions = {}):
|
||||
}
|
||||
|
||||
function shouldCoalesce(entry: HistoryEntry, incoming: PatchEvent): boolean {
|
||||
if (coalesceMs <= 0) return false;
|
||||
if (opTypesKey(entry.opTypes) !== opTypesKey(incoming.opTypes)) return false;
|
||||
if (entry.origin !== incoming.origin) return false;
|
||||
// Coalesce only when the SAME paths are touched (e.g. slider drag on one
|
||||
|
||||
@@ -30,3 +30,5 @@ export type { HistoryModule, HistoryOptions, HistoryEntry } from "./history.js";
|
||||
|
||||
export { createPersistQueue } from "./persist-queue.js";
|
||||
export type { PersistQueueModule, PersistQueueOptions } from "./persist-queue.js";
|
||||
|
||||
export type { PersistAdapter, PreviewAdapter, PersistVersionEntry } from "./adapters/types.js";
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* T3 (embedded) hosts own persistence — do not use this module.
|
||||
*/
|
||||
|
||||
import type { Composition } from "./types.js";
|
||||
import type { Composition, PersistErrorEvent } from "./types.js";
|
||||
import type { PersistAdapter } from "./adapters/types.js";
|
||||
|
||||
export interface PersistQueueModule {
|
||||
@@ -20,6 +20,8 @@ export interface PersistQueueModule {
|
||||
export interface PersistQueueOptions {
|
||||
/** Adapter path to write to. Default: "composition.html" */
|
||||
path?: string;
|
||||
/** Called when adapter.write() rejects. */
|
||||
onError?: (e: PersistErrorEvent) => void;
|
||||
}
|
||||
|
||||
export function createPersistQueue(
|
||||
@@ -48,8 +50,9 @@ export function createPersistQueue(
|
||||
if (disposed) return;
|
||||
try {
|
||||
await adapter.write(path, content);
|
||||
} catch {
|
||||
// error already surfaced via persist:error on the adapter
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
opts.onError?.({ error: { message, cause: err } });
|
||||
}
|
||||
});
|
||||
return writeChain;
|
||||
|
||||
@@ -89,11 +89,22 @@ describe("override-set replay on open", () => {
|
||||
expect(comp.serialize()).not.toContain("subtitle");
|
||||
});
|
||||
|
||||
it("treats property-level null as restore-base (no-op on fresh base)", async () => {
|
||||
it("treats property-level null as a deletion marker — removes the property from the base", async () => {
|
||||
// Null in the override-set is emitted only from patchRemove (explicit deletion).
|
||||
// On replay against a base that has the property set, it must be removed.
|
||||
const comp = await openComposition(BASE_HTML, {
|
||||
overrides: { "hf-title.style.color": null },
|
||||
});
|
||||
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#fff");
|
||||
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("null removal override on non-existent property is a safe no-op", async () => {
|
||||
// backgroundColor doesn't exist on hf-title in the base; removing it must not throw.
|
||||
const comp = await openComposition(BASE_HTML, {
|
||||
overrides: { "hf-title.style.backgroundColor": null },
|
||||
});
|
||||
expect(comp.getElement("hf-title")).not.toBeNull();
|
||||
expect(comp.getElement("hf-title")?.inlineStyles["backgroundColor"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("getOverrides returns the set the session was opened with", async () => {
|
||||
|
||||
@@ -95,6 +95,10 @@ class CompositionImpl implements Composition {
|
||||
this.persistQueueModule = module;
|
||||
}
|
||||
|
||||
_fireError(e: PersistErrorEvent): void {
|
||||
this.errorHandlers.forEach((h) => h(e));
|
||||
}
|
||||
|
||||
// ── Typed methods (F10 layer 1) ─────────────────────────────────────────────
|
||||
|
||||
setStyle(id: HfId, styles: Record<string, string | null>): void {
|
||||
@@ -272,9 +276,13 @@ class CompositionImpl implements Composition {
|
||||
this.batchOrigin,
|
||||
this.batchOpTypes,
|
||||
);
|
||||
this.resetBatchState();
|
||||
// Fire handlers before resetting batch state so that if a handler
|
||||
// throws the patch data (batchForward/batchInverse) is still intact
|
||||
// for callers that inspect it on error. The event was already built
|
||||
// from a snapshot so handler re-entrancy does not corrupt the event.
|
||||
this.patchHandlers.forEach((h) => h(event));
|
||||
this.changeHandlers.forEach((h) => h());
|
||||
this.resetBatchState();
|
||||
} else {
|
||||
if (threw && this.batchInverse.length > 0) {
|
||||
// Roll back: the dispatches inside the batch already mutated the
|
||||
@@ -383,6 +391,10 @@ class CompositionImpl implements Composition {
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────────────────
|
||||
|
||||
async flush(): Promise<void> {
|
||||
await this.persistQueueModule?.flush();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.persistQueueModule?.dispose();
|
||||
this.historyModule?.dispose();
|
||||
@@ -427,7 +439,9 @@ export async function openComposition(
|
||||
session.attachHistory(history);
|
||||
|
||||
if (opts?.persist) {
|
||||
const pq = createPersistQueue(session, opts.persist);
|
||||
const pq = createPersistQueue(session, opts.persist, {
|
||||
onError: (e) => session._fireError(e),
|
||||
});
|
||||
session.attachPersistQueue(pq);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* SDK smoke test — end-to-end pipeline.
|
||||
*
|
||||
* Exercises the full public surface: openComposition → mutate → applyPatches →
|
||||
* serialize round-trip, plus batch transactionality, history undo, persist
|
||||
* adapter, and patch subscription.
|
||||
*
|
||||
* This file is the "golden example" pinned as a regression smoke test.
|
||||
* If it breaks, the SDK's public contract has changed.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { openComposition, ORIGIN_APPLY_PATCHES } from "./index.js";
|
||||
import { createMemoryAdapter } from "./adapters/memory.js";
|
||||
|
||||
// ─── Fixture ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const BASE_HTML = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head></head>
|
||||
<body>
|
||||
<div id="stage" data-hf-root data-width="1920" data-height="1080" data-duration="5">
|
||||
<h1 id="title" data-hf-id="hf-title" data-start="0" data-end="3"
|
||||
style="color: #fff; font-size: 64px">Hello World</h1>
|
||||
<img id="logo" data-hf-id="hf-logo" src="/logo.png" alt="Logo"
|
||||
data-x="100" data-y="200" data-start="0" data-end="5" />
|
||||
<p id="body-copy" data-hf-id="hf-body" data-start="1" data-end="4"
|
||||
style="font-size: 24px">Body copy</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`.trim();
|
||||
|
||||
// ─── init → mutate → serialize ────────────────────────────────────────────────
|
||||
|
||||
describe("openComposition + basic mutations", () => {
|
||||
it("opens without error and exposes element snapshots", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const els = comp.getElements();
|
||||
expect(els.length).toBeGreaterThanOrEqual(3);
|
||||
const title = comp.getElement("hf-title");
|
||||
expect(title).not.toBeNull();
|
||||
expect(title?.inlineStyles.color).toBe("#fff");
|
||||
});
|
||||
|
||||
it("setStyle mutates inline styles and serializes back to HTML", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setStyle("hf-title", { color: "#f00", fontSize: "96px" });
|
||||
const html = comp.serialize();
|
||||
expect(html).toContain("color: #f00");
|
||||
expect(html).toContain("font-size: 96px");
|
||||
});
|
||||
|
||||
it("element handle sugar — same result as direct setStyle", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.element("hf-title").setStyle({ color: "#0f0" });
|
||||
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe("#0f0");
|
||||
});
|
||||
|
||||
it("setText updates text content", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setText("hf-title", "Goodbye World");
|
||||
expect(comp.getElement("hf-title")?.text).toContain("Goodbye World");
|
||||
expect(comp.serialize()).toContain("Goodbye World");
|
||||
});
|
||||
|
||||
it("dispatch moveElement writes data-x/data-y attributes", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.dispatch({ type: "moveElement", target: "hf-logo", x: 300, y: 400 });
|
||||
const el = comp.getElement("hf-logo");
|
||||
expect(el?.attributes["data-x"]).toBe("300");
|
||||
expect(el?.attributes["data-y"]).toBe("400");
|
||||
});
|
||||
|
||||
it("serialize round-trip: mutate → serialize → reopen → same state", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setStyle("hf-title", { color: "#0f0" });
|
||||
comp.setText("hf-body", "Round-tripped");
|
||||
const html = comp.serialize();
|
||||
|
||||
const comp2 = await openComposition(html);
|
||||
expect(comp2.getElement("hf-title")?.inlineStyles.color).toBe("#0f0");
|
||||
expect(comp2.getElement("hf-body")?.text).toContain("Round-tripped");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── patch subscription ───────────────────────────────────────────────────────
|
||||
|
||||
describe("patch events", () => {
|
||||
it("emits a patch event per dispatch with correct path and value", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const events: unknown[] = [];
|
||||
comp.on("patch", (e) => events.push(e));
|
||||
|
||||
comp.setStyle("hf-title", { fontSize: "48px" });
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
const event = events[0] as { patches: { path: string; value: unknown }[] };
|
||||
const patch = event.patches.find((p) => p.path.endsWith("/fontSize"));
|
||||
expect(patch?.value).toBe("48px");
|
||||
});
|
||||
|
||||
it("applyPatches origin is tagged ORIGIN_APPLY_PATCHES", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const origins: unknown[] = [];
|
||||
comp.on("patch", (e) => origins.push((e as { origin: unknown }).origin));
|
||||
|
||||
comp.applyPatches([
|
||||
{ op: "replace", path: "/elements/hf-title/inlineStyles/color", value: "#00f" },
|
||||
]);
|
||||
|
||||
expect(origins[0]).toBe(ORIGIN_APPLY_PATCHES);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── applyPatches ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("applyPatches", () => {
|
||||
it("applies external RFC 6902 patches to the live document", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.applyPatches([
|
||||
{ op: "replace", path: "/elements/hf-title/inlineStyles/color", value: "#00f" },
|
||||
{ op: "replace", path: "/elements/hf-title/text", value: "Patched" },
|
||||
]);
|
||||
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe("#00f");
|
||||
expect(comp.getElement("hf-title")?.text).toContain("Patched");
|
||||
});
|
||||
|
||||
it("applyPatches does NOT enter undo history — undo() is a no-op", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.applyPatches([
|
||||
{ op: "replace", path: "/elements/hf-title/inlineStyles/color", value: "#00f" },
|
||||
]);
|
||||
comp.undo(); // no-op: applyPatches bypasses history
|
||||
// color must still be the patched value (undo had nothing to revert)
|
||||
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe("#00f");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── batch transactionality ───────────────────────────────────────────────────
|
||||
|
||||
describe("batch()", () => {
|
||||
it("coalesces multiple dispatches into one patch event", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const events: unknown[] = [];
|
||||
comp.on("patch", (e) => events.push(e));
|
||||
|
||||
comp.batch(() => {
|
||||
comp.setStyle("hf-title", { color: "#f00" });
|
||||
comp.setText("hf-body", "Batched");
|
||||
});
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rolls back DOM on throw — model unchanged after throwing batch", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const beforeColor = comp.getElement("hf-title")?.inlineStyles.color;
|
||||
|
||||
try {
|
||||
comp.batch(() => {
|
||||
comp.setStyle("hf-title", { color: "#f00" });
|
||||
throw new Error("user cancelled");
|
||||
});
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
// DOM must be exactly as before
|
||||
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe(beforeColor);
|
||||
});
|
||||
|
||||
it("throwing batch does NOT add a history entry — undo is a no-op", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
try {
|
||||
comp.batch(() => {
|
||||
comp.setStyle("hf-title", { color: "#f00" });
|
||||
throw new Error("rollback");
|
||||
});
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
// undo should be a no-op since no history entry was added
|
||||
comp.undo();
|
||||
// color should still be the original (batch was rolled back + undo had nothing to do)
|
||||
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe("#fff");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── history ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("undo / redo", () => {
|
||||
it("undo reverts last mutation, redo re-applies it", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setStyle("hf-title", { color: "#f00" });
|
||||
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe("#f00");
|
||||
|
||||
comp.undo();
|
||||
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe("#fff");
|
||||
|
||||
comp.redo();
|
||||
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe("#f00");
|
||||
});
|
||||
|
||||
it("undo with no history is a no-op", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const before = comp.getElement("hf-title")?.inlineStyles.color;
|
||||
comp.undo(); // no-op
|
||||
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── persist adapter ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("persist adapter", () => {
|
||||
it("writes serialized HTML to the adapter on mutation", async () => {
|
||||
const adapter = createMemoryAdapter();
|
||||
const writeSpy = vi.spyOn(adapter, "write");
|
||||
|
||||
const comp = await openComposition(BASE_HTML, { persist: adapter });
|
||||
comp.setStyle("hf-title", { color: "#f00" });
|
||||
await comp.flush();
|
||||
|
||||
expect(writeSpy).toHaveBeenCalled();
|
||||
const [, content] = writeSpy.mock.calls[0] as [string, string];
|
||||
expect(content).toContain("color: #f00");
|
||||
});
|
||||
|
||||
it("surfaces persist errors via on('persist:error')", async () => {
|
||||
const adapter = createMemoryAdapter();
|
||||
const errors: unknown[] = [];
|
||||
|
||||
const comp = await openComposition(BASE_HTML, { persist: adapter });
|
||||
comp.on("persist:error", (e) => errors.push(e));
|
||||
|
||||
adapter.injectFault("disk full");
|
||||
comp.setStyle("hf-title", { color: "#f00" });
|
||||
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(errors).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── T3 embedded mode (override-set) ─────────────────────────────────────────
|
||||
|
||||
describe("T3 embedded mode", () => {
|
||||
it("applies override-set on open, mutations layer on top", async () => {
|
||||
const comp = await openComposition(BASE_HTML, {
|
||||
overrides: { "hf-title.style.color": "#0f0" },
|
||||
});
|
||||
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe("#0f0");
|
||||
});
|
||||
|
||||
it("getOverrides() returns accumulated override-set", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setStyle("hf-title", { color: "#f00" });
|
||||
const overrides = comp.getOverrides();
|
||||
expect(overrides["hf-title.style.color"]).toBe("#f00");
|
||||
});
|
||||
|
||||
it("serialize → reopen with overrides → same state as direct mutation", async () => {
|
||||
// Simulate host storing overrides + base template separately (T3 pattern)
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setStyle("hf-title", { color: "#0f0" });
|
||||
comp.setText("hf-body", "Override text");
|
||||
const overrides = comp.getOverrides();
|
||||
|
||||
// Host reopens the original template with the stored overrides
|
||||
const comp2 = await openComposition(BASE_HTML, { overrides });
|
||||
expect(comp2.getElement("hf-title")?.inlineStyles.color).toBe("#0f0");
|
||||
expect(comp2.getElement("hf-body")?.text).toContain("Override text");
|
||||
});
|
||||
});
|
||||
@@ -233,7 +233,12 @@ export interface Composition {
|
||||
// ── Advanced / agent layer (F10 layer 2) ──────────────────────────────────
|
||||
dispatch(op: EditOp, opts?: { origin?: unknown }): void;
|
||||
batch(fn: () => void, opts?: { origin?: unknown }): void;
|
||||
/** Dry-run validation — would dispatch(op) succeed? UI enablement, agent precondition checks. */
|
||||
/**
|
||||
* Dry-run validation — would dispatch(op) succeed?
|
||||
* Returns false for: unknown element id, missing root, unimplemented Phase 3b ops, unknown op types.
|
||||
* Use as a feature-detection gate: `if (!comp.can(op)) return;` — Phase 3b ops always return false
|
||||
* until the parser-backed engine ships. This is intentional: silent no-op is worse than skipping.
|
||||
*/
|
||||
can(op: EditOp): boolean;
|
||||
|
||||
// ── Events (one typed emitter — F10) ──────────────────────────────────────
|
||||
@@ -252,5 +257,7 @@ export interface Composition {
|
||||
applyPatches(patches: readonly JsonPatchOp[], opts?: { origin?: unknown }): void;
|
||||
|
||||
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
||||
/** Drain the persist queue — resolves when any queued write is committed. No-op if no adapter. */
|
||||
flush(): Promise<void>;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user