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:
Vance Ingalls
2026-06-11 14:07:49 -07:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent 69d67f1d69
commit a0ee97210b
14 changed files with 436 additions and 28 deletions
+12
View File
@@ -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
+1
View File
@@ -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();
+4 -3
View File
@@ -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 });
+11
View File
@@ -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");
});
+59 -15
View File
@@ -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;
}
}
+3 -1
View File
@@ -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");
+1
View File
@@ -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
+2
View File
@@ -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";
+6 -3
View File
@@ -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;
+13 -2
View File
@@ -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 () => {
+16 -2
View File
@@ -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);
}
}
+274
View File
@@ -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");
});
});
+8 -1
View File
@@ -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;
}