feat: release design system engine, studio and component contracts
This commit is contained in:
1 parent
ff1065fb27
commit
3943b946d6
693 files changed
+175546
-3894
No files matched your search
@@ -0,0 +1,250 @@
|
||||
import { test, expect, afterEach } from "bun:test";
|
||||
import { mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { createFrameworkMcpServer } from "../../mcp/src/index.ts";
|
||||
import { DesignAgentService } from "../src/agent-server.ts";
|
||||
import { agentGenerationContext } from "../src/agent-context.ts";
|
||||
import { checkDesignConformance } from "../src/conformance.ts";
|
||||
import { agentPageSource, checkAgentPage, type AgentPage } from "../src/agent-pages.ts";
|
||||
import { CORE_DESIGN_SYSTEMS } from "../../design-system/src/index.ts";
|
||||
import { compileWrnFile } from "../../compiler/src/index.ts";
|
||||
const roots: string[] = [];
|
||||
function root() {
|
||||
const path = mkdtempSync(join(tmpdir(), "wrn-phase6-"));
|
||||
roots.push(path);
|
||||
writeFileSync(join(path, "package.json"), "{}");
|
||||
return path;
|
||||
}
|
||||
afterEach(() => {
|
||||
for (const path of roots.splice(0)) rmSync(path, { recursive: true, force: true });
|
||||
});
|
||||
const page = (id: string): AgentPage => ({
|
||||
id,
|
||||
title: id === "account" ? "Account settings" : "Contact support",
|
||||
components: [
|
||||
{ component: "PageHeader", props: { title: "Your workspace" } },
|
||||
{ component: "Input", props: { id: id + "-name", label: "Name" } },
|
||||
{ component: "Button", props: { label: "Continue", variant: "default" } },
|
||||
],
|
||||
});
|
||||
test("MCP defaults stay unchanged; explicit design opt-in exposes read tools and separate write opt-in", async () => {
|
||||
const path = root();
|
||||
expect(createFrameworkMcpServer(path).tools()).toHaveLength(8);
|
||||
const readonly = createFrameworkMcpServer(path, { design: true });
|
||||
expect(readonly.tools().some((t) => t.name === "wrnexus_design_context")).toBe(true);
|
||||
expect(readonly.tools().some((t) => t.name === "wrnexus_design_apply")).toBe(false);
|
||||
expect(
|
||||
((await readonly.call("wrnexus_design_context")) as any).components.Button.parts,
|
||||
).toBeDefined();
|
||||
expect(existsSync(join(path, ".wrnexus"))).toBe(false);
|
||||
const response = await readonly.handle({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: { name: "wrnexus_design_apply", arguments: { baseRevision: "none" } },
|
||||
});
|
||||
expect((response as any).result.isError).toBe(true);
|
||||
});
|
||||
test("recover invalid edits, stale conflicts, locks, undo and checkpoints across service instances", async () => {
|
||||
const path = root();
|
||||
let service = new DesignAgentService(path, true);
|
||||
let draft: any = await service.call("create", { baseRevision: "none" });
|
||||
const first = draft.revision;
|
||||
draft = await service.call("locks", { baseRevision: first, locks: ["/tokens/color.primary"] });
|
||||
await expect(service.call("create", { baseRevision: first })).rejects.toThrow("CONFLICT");
|
||||
draft = await service.call("propose", { baseRevision: draft.revision, source: "{unfinished" });
|
||||
expect(draft.diagnostics.length).toBeGreaterThan(0);
|
||||
service = new DesignAgentService(path, true);
|
||||
let state: any = await service.call("inspect");
|
||||
expect(state.source).toBe("{unfinished");
|
||||
expect(state.valid.id).toBe("square");
|
||||
await expect(
|
||||
service.call("apply", { baseRevision: state.revision, baseApplicationRevision: "none" }),
|
||||
).rejects.toThrow("INVALID");
|
||||
draft = await service.call("revert", { baseRevision: state.revision });
|
||||
state = await service.call("inspect");
|
||||
expect(state.diagnostics).toEqual([]);
|
||||
const locked = structuredClone(state.valid);
|
||||
locked.tokens["color.primary"].value = "#abcdef";
|
||||
draft = await service.call("propose", {
|
||||
baseRevision: draft.revision,
|
||||
source: JSON.stringify(locked),
|
||||
});
|
||||
expect(draft.diagnostics.length).toBeGreaterThan(0);
|
||||
await service.call("revert", { baseRevision: draft.revision, target: "checkpoint" });
|
||||
state = await service.call("inspect");
|
||||
expect(state.diagnostics).toEqual([]);
|
||||
expect(state.locks).toEqual(["/tokens/color.primary"]);
|
||||
});
|
||||
test("agents create two compilable consistent pages across all three designs and both modes", async () => {
|
||||
for (const system of Object.values(CORE_DESIGN_SYSTEMS)) {
|
||||
const service = new DesignAgentService(root(), true);
|
||||
let draft: any = await service.call("create", {
|
||||
baseRevision: "none",
|
||||
source: JSON.stringify(system),
|
||||
});
|
||||
for (const id of ["account", "support"]) {
|
||||
draft = await service.call("page", { baseRevision: draft.revision, page: page(id) });
|
||||
expect(draft.result.ok).toBe(true);
|
||||
const source = agentPageSource(page(id));
|
||||
expect(checkDesignConformance(source).ok).toBe(true);
|
||||
expect(compileWrnFile(source)).toBeTruthy();
|
||||
}
|
||||
for (const mode of ["light", "dark"])
|
||||
for (const id of ["account", "support"]) {
|
||||
const preview: any = await service.call("preview", { pageId: id, mode });
|
||||
expect(preview.html).toContain("Content-Security-Policy");
|
||||
expect(preview.html).toContain('data-wrn-component="Button"');
|
||||
expect(preview.html).toContain('data-theme="' + mode + '"');
|
||||
expect(preview.html).toContain("--wrn-radius");
|
||||
}
|
||||
const exported: any = await service.call("export");
|
||||
expect(Object.keys(exported.pages)).toEqual(["account.wrn", "support.wrn"]);
|
||||
expect(((await service.call("validate")) as any).ok).toBe(true);
|
||||
}
|
||||
});
|
||||
test("conformance rejects invented axes and props; only exact warnings can be waived", () => {
|
||||
const source =
|
||||
'page Test { view { <div data-component="Button" variant="invented" nonsense="x"></div> } style { main { color: #fff; padding: 17px; } } }';
|
||||
const report = checkDesignConformance(source);
|
||||
expect(report.ok).toBe(false);
|
||||
expect(report.diagnostics.some((d) => d.code === "WRN-CONFORMANCE-AXIS")).toBe(true);
|
||||
expect(report.diagnostics.some((d) => d.code === "WRN-CONFORMANCE-PROP")).toBe(true);
|
||||
const warning = "page Test { view { <main>Test</main> } style { main { color: #fff; } } }";
|
||||
const d = checkDesignConformance(warning).diagnostics[0]!;
|
||||
expect(
|
||||
checkDesignConformance(warning, [
|
||||
{ code: d.code, path: d.path, reason: "Reviewed brand illustration" },
|
||||
]).ok,
|
||||
).toBe(true);
|
||||
expect(
|
||||
checkDesignConformance(source, [
|
||||
{ code: "WRN-CONFORMANCE-AXIS", path: "$.view[0].variant", reason: "No" },
|
||||
]).ok,
|
||||
).toBe(false);
|
||||
expect(checkDesignConformance("broken").ok).toBe(false);
|
||||
expect(
|
||||
checkAgentPage({
|
||||
...page("account"),
|
||||
components: [{ component: "Button", props: { variant: "invented" } }],
|
||||
}).ok,
|
||||
).toBe(false);
|
||||
expect(
|
||||
checkAgentPage({
|
||||
...page("account"),
|
||||
components: [{ component: "Button", props: { label: "javascript:alert(1)" } }],
|
||||
}).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
test("application revision checks, exports and reversal preserve existing app configuration", async () => {
|
||||
const path = root();
|
||||
writeFileSync(join(path, "wrn.config.ts"), "existing config");
|
||||
const service = new DesignAgentService(path, true);
|
||||
let draft: any = await service.call("create", { baseRevision: "none" });
|
||||
await expect(
|
||||
service.call("apply", { baseRevision: draft.revision, baseApplicationRevision: "stale" }),
|
||||
).rejects.toThrow("CONFLICT");
|
||||
draft = await service.call("apply", {
|
||||
baseRevision: draft.revision,
|
||||
baseApplicationRevision: "none",
|
||||
});
|
||||
expect(draft.result.revision).not.toBe("none");
|
||||
expect(readFileSync(join(path, "wrn.config.ts"), "utf8")).toBe("existing config");
|
||||
const reverted: any = await service.call("revert", {
|
||||
baseRevision: draft.revision,
|
||||
target: "application",
|
||||
baseApplicationRevision: draft.result.revision,
|
||||
});
|
||||
expect(reverted.result.revision).toBe("none");
|
||||
mkdirSync(join(path, ".wrnexus", "design-studio", "agent", "LOCK"));
|
||||
await expect(service.call("create", { baseRevision: reverted.revision })).rejects.toThrow("BUSY");
|
||||
});
|
||||
test("agent context carries resolved tokens and real contract boundaries, with bounded selection", () => {
|
||||
for (const system of Object.values(CORE_DESIGN_SYSTEMS)) {
|
||||
const context = agentGenerationContext(system, { components: ["Button", "Input"] });
|
||||
expect(Object.keys(context.components)).toEqual(["Button", "Input"]);
|
||||
expect(context.tokens["color.primary"]).toBeDefined();
|
||||
expect((context.components.Button.axes as any).variant.values).toContain("default");
|
||||
}
|
||||
expect(() =>
|
||||
agentGenerationContext(CORE_DESIGN_SYSTEMS.square, { components: ["Invented"] }),
|
||||
).toThrow("CONTEXT");
|
||||
});
|
||||
test("stdio design protocol advertises opt-in tools and returns structured context without model credentials", async () => {
|
||||
const child = Bun.spawn(["bun", "packages/mcp/src/stdio.ts", "--root=" + root(), "--design"], {
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
child.stdin.write(
|
||||
JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }) +
|
||||
"\n" +
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: "tools/call",
|
||||
params: { name: "wrnexus_design_context", arguments: { components: ["Button"] } },
|
||||
}) +
|
||||
"\n",
|
||||
);
|
||||
child.stdin.end();
|
||||
const lines = (await new Response(child.stdout).text())
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line));
|
||||
expect(await child.exited).toBe(0);
|
||||
expect(lines[0].result.tools.some((t: any) => t.name === "wrnexus_design_context")).toBe(true);
|
||||
expect(lines[1].result.structuredContent.value.components.Button).toBeDefined();
|
||||
});
|
||||
|
||||
test("exported pages preserve literal string, boolean and structured props in compiled mount markup", async () => {
|
||||
const source = agentPageSource({
|
||||
id: "literal-props",
|
||||
title: "Literal props",
|
||||
components: [
|
||||
{ component: "Button", props: { label: "Continue", disabled: false } },
|
||||
{ component: "Select", props: { options: [{ label: "One", value: "one" }] } },
|
||||
],
|
||||
});
|
||||
const code = new Bun.Transpiler({ loader: "ts" }).transformSync(
|
||||
compileWrnFile(source).replace(
|
||||
/from (["'])@wrnexus\/core\1/g,
|
||||
`from ${JSON.stringify(new URL("../../core/src/index.ts", import.meta.url).href)}`,
|
||||
),
|
||||
);
|
||||
const mod = await import("data:text/javascript;base64," + Buffer.from(code).toString("base64"));
|
||||
const html = await mod.default({});
|
||||
expect(html).toContain('label="Continue"');
|
||||
expect(html).toContain('disabled="false"');
|
||||
expect(html).toContain(
|
||||
'options="[{"label":"One","value":"one"}]"',
|
||||
);
|
||||
});
|
||||
import { createDesignPackage } from "@wrnexus/design-system/package";
|
||||
test("MCP package upgrades preserve history and reject future agent storage without overwriting it", async () => {
|
||||
const path = root();
|
||||
const server = createFrameworkMcpServer(path, { design: true, designWrite: true });
|
||||
let draft: any = await server.call("wrnexus_design_create", { baseRevision: "none" });
|
||||
const next = structuredClone(CORE_DESIGN_SYSTEMS.square);
|
||||
next.version = "1.2.0";
|
||||
const source = JSON.stringify(await createDesignPackage(next));
|
||||
expect(((await server.call("wrnexus_design_upgrade_plan", { source })) as any).canApply).toBe(
|
||||
true,
|
||||
);
|
||||
draft = await server.call("wrnexus_design_upgrade", { baseRevision: draft.revision, source });
|
||||
expect(((await server.call("wrnexus_design_inspect")) as any).valid.version).toBe("1.2.0");
|
||||
await expect(
|
||||
server.call("wrnexus_design_upgrade", { baseRevision: "none", source }),
|
||||
).rejects.toThrow("CONFLICT");
|
||||
await server.call("wrnexus_design_revert", { baseRevision: draft.revision });
|
||||
expect(((await server.call("wrnexus_design_inspect")) as any).valid.version).toBe("1.0.0");
|
||||
const file = join(path, ".wrnexus", "design-studio", "agent", "STATE.json");
|
||||
const state = JSON.parse(readFileSync(file, "utf8"));
|
||||
const future = JSON.stringify({ ...state, storageVersion: 2 });
|
||||
writeFileSync(file, future);
|
||||
await expect(
|
||||
server.call("wrnexus_design_create", { baseRevision: state.revision }),
|
||||
).rejects.toThrow("RECOVERY");
|
||||
expect(readFileSync(file, "utf8")).toBe(future);
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { CORE_DESIGN_SYSTEMS, validateDesignSystem } from "../../design-system/src/index.ts";
|
||||
import {
|
||||
generateDesigns,
|
||||
DEFAULT_DESIGN_BRIEF,
|
||||
type GenerationRequest,
|
||||
type GenerationProvider,
|
||||
} from "../src/generation.ts";
|
||||
import { designRevision, checkDecisionLocks, preserveDecisionLocks } from "../src/decisions.ts";
|
||||
import { combineDesignProposals } from "../src/guidance.ts";
|
||||
import { StudioSession } from "../src/session.ts";
|
||||
|
||||
const source = () => structuredClone(CORE_DESIGN_SYSTEMS.square);
|
||||
async function request(
|
||||
kind: "directions" | "edit" = "directions",
|
||||
locks: string[] = [],
|
||||
system = source(),
|
||||
): Promise<GenerationRequest> {
|
||||
return {
|
||||
system,
|
||||
locks,
|
||||
brief: { ...DEFAULT_DESIGN_BRIEF, product: "Booking site", audience: "Mobile customers" },
|
||||
baseRevision: await designRevision(system, locks),
|
||||
kind,
|
||||
instruction: kind === "edit" ? "make buttons rounded and use spacious spacing" : undefined,
|
||||
};
|
||||
}
|
||||
const patched = (changes: unknown[]) =>
|
||||
JSON.stringify({
|
||||
alternatives: [{ name: "Edited", rationale: "Requested change", patch: changes }],
|
||||
});
|
||||
const set = (path: string, value: unknown) => ({ op: "set", path, value });
|
||||
|
||||
test("local guided generation produces three distinct validated directions without changing source", async () => {
|
||||
const input = await request();
|
||||
const original = JSON.stringify(input.system);
|
||||
const result = await generateDesigns(input);
|
||||
expect(result.diagnostics).toEqual([]);
|
||||
expect(result.alternatives).toHaveLength(3);
|
||||
expect(new Set(result.alternatives.map((p) => JSON.stringify(p.system))).size).toBe(3);
|
||||
for (const proposal of result.alternatives) {
|
||||
expect(validateDesignSystem(proposal.system)).toEqual([]);
|
||||
expect(Object.keys(proposal.system.recipes!)).toHaveLength(104);
|
||||
expect(proposal.system.id).toBe(input.system.id);
|
||||
}
|
||||
expect(JSON.stringify(input.system)).toBe(original);
|
||||
expect(result.provenance.provider).toBe("local-rules");
|
||||
expect(result.provenance.maxOutputTokens).toBe(0);
|
||||
});
|
||||
|
||||
test("guided geometry, density, surface and brand choices affect the brief direction", async () => {
|
||||
const input = await request();
|
||||
input.brief = {
|
||||
...input.brief,
|
||||
geometry: "rounded",
|
||||
density: "spacious",
|
||||
surface: "soft",
|
||||
brandColor: "#112233",
|
||||
};
|
||||
const result = await generateDesigns(input);
|
||||
expect(result.diagnostics).toEqual([]);
|
||||
const system = result.alternatives[0]!.system;
|
||||
expect(system.tokens["shape.control"]!.value).toBe("16px");
|
||||
expect(system.tokens["space.control"]!.value).toBe("1.25rem");
|
||||
expect(system.tokens["elevation.panel"]!.value).toContain("24px");
|
||||
expect(system.modes!.light!["color.primary"]!.value).toBe("#112233");
|
||||
expect(result.brief).toEqual(input.brief);
|
||||
});
|
||||
|
||||
test("direct locks, mode overrides and alias changes cannot alter a locked token", () => {
|
||||
const base = source();
|
||||
base.tokens["shape.alias"] = { type: "dimension", value: { ref: "shape.control" } };
|
||||
let next = structuredClone(base);
|
||||
next.tokens["shape.control"]!.value = "8px";
|
||||
expect(checkDecisionLocks(base, next, ["/tokens/shape.alias"])).not.toEqual([]);
|
||||
next = structuredClone(base);
|
||||
next.modes!.light!["shape.control"] = { type: "dimension", value: "8px" };
|
||||
expect(checkDecisionLocks(base, next, ["/tokens/shape.alias"])).not.toEqual([]);
|
||||
next = structuredClone(base);
|
||||
next.tokens["space.page"]!.value = "5rem";
|
||||
expect(checkDecisionLocks(base, next, ["/tokens/shape.alias"])).toEqual([]);
|
||||
});
|
||||
|
||||
test("component recipe locks protect referenced token values and absent decisions", () => {
|
||||
const base = source(),
|
||||
next = structuredClone(base);
|
||||
next.tokens["shape.control"]!.value = "8px";
|
||||
expect(checkDecisionLocks(base, next, ["/recipes/Button"])).not.toEqual([]);
|
||||
const added = structuredClone(base);
|
||||
added.recipes!.Button!.base!.control!.rotate = "2deg";
|
||||
expect(checkDecisionLocks(base, added, ["/recipes/Button/base/control/rotate"])).not.toEqual([]);
|
||||
});
|
||||
|
||||
test("directions preserve locks, including recipe token dependencies", async () => {
|
||||
const input = await request("directions", ["/recipes/Button"]);
|
||||
const result = await generateDesigns(input);
|
||||
expect(result.diagnostics).toEqual([]);
|
||||
for (const p of result.alternatives)
|
||||
expect(checkDecisionLocks(input.system, p.system, input.locks)).toEqual([]);
|
||||
const next = preserveDecisionLocks(
|
||||
input.system,
|
||||
structuredClone(CORE_DESIGN_SYSTEMS.soft),
|
||||
input.locks,
|
||||
);
|
||||
expect(checkDecisionLocks(input.system, next, input.locks)).toEqual([]);
|
||||
});
|
||||
|
||||
test("manual edits, undo, reload and unlock keep lock-violating drafts recoverable", () => {
|
||||
const memory = new Map<string, string>();
|
||||
const storage = {
|
||||
getItem: (k: string) => memory.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => {
|
||||
memory.set(k, v);
|
||||
},
|
||||
};
|
||||
const session = new StudioSession(undefined, storage);
|
||||
session.setLocks(["/tokens/shape.control"]);
|
||||
session.mutate((s) => {
|
||||
s.tokens["shape.control"]!.value = "9px";
|
||||
}, "Locked edit");
|
||||
expect(session.valid.tokens["shape.control"]!.value).toBe("0");
|
||||
expect(session.diagnostics[0]!.code).toBe("WRN-STUDIO-LOCK");
|
||||
expect(session.source).toContain("9px");
|
||||
const restored = new StudioSession(undefined, storage);
|
||||
expect(restored.restore()).toBe(true);
|
||||
expect(restored.locks).toEqual(session.locks);
|
||||
expect(restored.diagnostics[0]!.code).toBe("WRN-STUDIO-LOCK");
|
||||
restored.setLocks([]);
|
||||
expect(restored.valid.tokens["shape.control"]!.value).toBe("9px");
|
||||
restored.undo();
|
||||
expect(restored.valid.tokens["shape.control"]!.value).toBe("0");
|
||||
});
|
||||
|
||||
test("word-based edits validate whole requests and reject unknown or locked changes", async () => {
|
||||
const result = await generateDesigns(await request("edit"));
|
||||
expect(result.diagnostics).toEqual([]);
|
||||
expect(result.alternatives[0]!.system.tokens["shape.control"]!.value).toBe("16px");
|
||||
const locked = await generateDesigns(await request("edit", ["/tokens/shape.control"]));
|
||||
expect(locked.alternatives).toEqual([]);
|
||||
expect(locked.diagnostics[0]!.message).toContain("LOCK");
|
||||
const input = await request("edit");
|
||||
input.instruction = "make buttons rounded and invent a new calendar";
|
||||
const failed = await generateDesigns(input);
|
||||
expect(failed.alternatives).toEqual([]);
|
||||
expect(input.system.tokens["shape.control"]!.value).toBe("0");
|
||||
input.instruction = "set primary color to #aabbcc and remove shadows and use serif typography";
|
||||
expect((await generateDesigns(input)).diagnostics).toEqual([]);
|
||||
});
|
||||
|
||||
test("stale generation base and stale combinations fail without applying", async () => {
|
||||
const input = await request();
|
||||
input.baseRevision = "a".repeat(64);
|
||||
expect(generateDesigns(input)).rejects.toThrow("WRN-STUDIO-CONFLICT");
|
||||
const good = await request();
|
||||
const batch = await generateDesigns(good);
|
||||
const next = structuredClone(good.system);
|
||||
next.name = "Changed";
|
||||
await expect(combineDesignProposals(next, batch, { colors: "direction-2" }, [])).rejects.toThrow(
|
||||
"CONFLICT",
|
||||
);
|
||||
});
|
||||
|
||||
test("foundation decisions combine and revalidate before entering a draft", async () => {
|
||||
const input = await request();
|
||||
const batch = await generateDesigns(input);
|
||||
const next = await combineDesignProposals(
|
||||
input.system,
|
||||
batch,
|
||||
{ geometry: "direction-2", typography: "direction-3" },
|
||||
[],
|
||||
);
|
||||
expect(validateDesignSystem(next)).toEqual([]);
|
||||
expect(next.tokens["shape.control"]).toEqual(
|
||||
batch.alternatives[1]!.system.tokens["shape.control"],
|
||||
);
|
||||
expect(next.tokens["type.body"]).toEqual(batch.alternatives[2]!.system.tokens["type.body"]);
|
||||
});
|
||||
|
||||
test("provider adapter uses bounded patches, compact capabilities and one repair", async () => {
|
||||
let calls = 0;
|
||||
let context = "";
|
||||
const provider: GenerationProvider = {
|
||||
name: "fixture",
|
||||
async generate(prompt, options) {
|
||||
calls++;
|
||||
context = prompt;
|
||||
expect(options!.maxTokens).toBe(512);
|
||||
expect(options!.signal).toBeDefined();
|
||||
return {
|
||||
provider: "fixture",
|
||||
model: "test-model",
|
||||
value: calls === 1 ? "not json" : patched([set("/tokens/shape.control/value", "11px")]),
|
||||
};
|
||||
},
|
||||
};
|
||||
const result = await generateDesigns(await request("edit"), { provider, maxOutputTokens: 512 });
|
||||
expect(calls).toBe(2);
|
||||
expect(result.diagnostics).toEqual([]);
|
||||
expect(result.provenance.model).toBe("test-model");
|
||||
expect(result.provenance.attempts).toBe(2);
|
||||
expect(context).toContain("capabilities");
|
||||
expect(context).toContain("Repair these diagnostics");
|
||||
});
|
||||
|
||||
test("provider invalid parts and lock violations cannot survive bounded repair", async () => {
|
||||
let calls = 0;
|
||||
const provider: GenerationProvider = {
|
||||
name: "fixture",
|
||||
async generate() {
|
||||
calls++;
|
||||
return { provider: "fixture", value: patched([set("/tokens/shape.control/value", "12px")]) };
|
||||
},
|
||||
};
|
||||
const result = await generateDesigns(await request("edit", ["/tokens/shape.control"]), {
|
||||
provider,
|
||||
});
|
||||
expect(calls).toBe(2);
|
||||
expect(result.alternatives).toEqual([]);
|
||||
expect(result.diagnostics[0]!.code).toBe("WRN-STUDIO-PROPOSAL");
|
||||
const invalid: GenerationProvider = {
|
||||
name: "fixture",
|
||||
async generate() {
|
||||
return {
|
||||
provider: "fixture",
|
||||
value: patched([set("/recipes/Button/base/control/position", "fixed")]),
|
||||
};
|
||||
},
|
||||
};
|
||||
expect(
|
||||
(await generateDesigns(await request("edit"), { provider: invalid })).alternatives,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("prototype paths, excessive operations and execution strings are rejected", async () => {
|
||||
for (const patch of [
|
||||
[set("/tokens/__proto__/value", "x")],
|
||||
Array.from({ length: 49 }, () => set("/name", "x")),
|
||||
[set("/tokens/shape.control/value", "0;}body{display:none")],
|
||||
]) {
|
||||
const provider: GenerationProvider = {
|
||||
name: "fixture",
|
||||
async generate() {
|
||||
return { provider: "fixture", value: patched(patch) };
|
||||
},
|
||||
};
|
||||
expect((await generateDesigns(await request("edit"), { provider })).alternatives).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test("cancellation and deadline stop even a provider that ignores its signal", async () => {
|
||||
const provider: GenerationProvider = { name: "hung", generate: () => new Promise(() => {}) };
|
||||
const controller = new AbortController();
|
||||
const input = await request("edit");
|
||||
const pending = generateDesigns(input, { provider, signal: controller.signal });
|
||||
controller.abort();
|
||||
await expect(pending).rejects.toThrow();
|
||||
await expect(generateDesigns(input, { provider, timeoutMs: 15 })).rejects.toThrow("cancelled");
|
||||
expect(input.system.tokens["shape.control"]!.value).toBe("0");
|
||||
});
|
||||
|
||||
test("transport failure is not retried or returned with secret-bearing messages", async () => {
|
||||
let count = 0;
|
||||
const provider: GenerationProvider = {
|
||||
name: "fixture",
|
||||
async generate() {
|
||||
count++;
|
||||
throw Error("secret-key-in-provider-error");
|
||||
},
|
||||
};
|
||||
const result = await generateDesigns(await request("edit"), { provider });
|
||||
expect(count).toBe(1);
|
||||
expect(JSON.stringify(result)).not.toContain("secret-key-in-provider-error");
|
||||
expect(result.diagnostics[0]!.code).toBe("WRN-STUDIO-PROVIDER");
|
||||
});
|
||||
|
||||
test("untrusted brief fields and generation budgets are bounded", async () => {
|
||||
const input = await request();
|
||||
input.brief.product = "x".repeat(2001);
|
||||
await expect(generateDesigns(input)).rejects.toThrow("2000");
|
||||
await expect(generateDesigns(await request(), { maxOutputTokens: 9000 })).rejects.toThrow(
|
||||
"BUDGET",
|
||||
);
|
||||
const altered = await request();
|
||||
altered.brief.brandColor = "red;}body{}";
|
||||
await expect(generateDesigns(altered)).rejects.toThrow("BRIEF");
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { test, expect, afterAll } from "bun:test";
|
||||
import { mkdtempSync, readFileSync, existsSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { createStudioHandler } from "../src/server.ts";
|
||||
import { StudioApplication } from "../src/application.ts";
|
||||
import { CORE_DESIGN_SYSTEMS } from "../../design-system/src/index.ts";
|
||||
import { resolveThemeConfig } from "../../styles/src/theme.ts";
|
||||
import { designRevision } from "../src/decisions.ts";
|
||||
import { DEFAULT_DESIGN_BRIEF } from "../src/generation.ts";
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), "wrn-studio-test-"));
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
const handler = await createStudioHandler({ applicationRoot: root });
|
||||
const send = (path: string, body?: unknown, origin?: string) =>
|
||||
handler(
|
||||
new Request(
|
||||
"http://127.0.0.1:4320" + path,
|
||||
body === undefined
|
||||
? undefined
|
||||
: {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...(origin ? { origin } : {}) },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
test("hosted Studio keeps its path, same-origin checks and export-only boundary", async () => {
|
||||
const hosted = await createStudioHandler({ hosted: { basePath: "/design-studio" } });
|
||||
const origin = "https://components.example";
|
||||
const html = await hosted(new Request(origin + "/design-studio/"));
|
||||
expect(await html.text()).toContain('<base href="/design-studio/">');
|
||||
expect((await hosted(new Request(origin + "/api/application"))).status).toBe(404);
|
||||
const state = await hosted(new Request(origin + "/design-studio/api/application"));
|
||||
expect((await state.json()).enabled).toBe(false);
|
||||
const proxied = await hosted(
|
||||
new Request("http://components.example/design-studio/api/application", {
|
||||
headers: { origin, "x-forwarded-proto": "https" },
|
||||
}),
|
||||
);
|
||||
expect(proxied.status).toBe(200);
|
||||
const request = (path: string, body: unknown, from = origin) =>
|
||||
hosted(
|
||||
new Request(origin + "/design-studio" + path, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", origin: from },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
(await request("/api/apply", { system: CORE_DESIGN_SYSTEMS.soft, base: "none" })).status,
|
||||
).toBe(409);
|
||||
expect(
|
||||
(await request("/api/preview", { system: CORE_DESIGN_SYSTEMS.soft }, "https://other.example"))
|
||||
.status,
|
||||
).toBe(403);
|
||||
const preview = await request("/api/preview", { system: CORE_DESIGN_SYSTEMS.soft });
|
||||
const { url } = await preview.json();
|
||||
expect(url).toStartWith("/design-studio/preview/");
|
||||
expect((await hosted(new Request(origin + url))).status).toBe(200);
|
||||
await expect(
|
||||
createStudioHandler({ applicationRoot: root, hosted: { basePath: "/design-studio" } }),
|
||||
).rejects.toThrow("export-only");
|
||||
});
|
||||
|
||||
test("generation endpoint exposes only configured metadata and stages validated proposals", async () => {
|
||||
const meta = await (await send("/api/generation")).json();
|
||||
expect(meta.provider).toBe("local-rules");
|
||||
expect(JSON.stringify(meta)).not.toContain("apiKey");
|
||||
const system = structuredClone(CORE_DESIGN_SYSTEMS.soft),
|
||||
locks = ["/tokens/shape.control"];
|
||||
const input = {
|
||||
kind: "directions",
|
||||
brief: { ...DEFAULT_DESIGN_BRIEF, product: "A website", audience: "Customers" },
|
||||
system,
|
||||
locks,
|
||||
baseRevision: await designRevision(system, locks),
|
||||
};
|
||||
const response = await send("/api/generate", input);
|
||||
expect(response.status).toBe(200);
|
||||
const batch = await response.json();
|
||||
expect(batch.alternatives).toHaveLength(3);
|
||||
expect(batch.provenance.provider).toBe("local-rules");
|
||||
expect((await send("/api/generate", { ...input, baseRevision: "a".repeat(64) })).status).toBe(
|
||||
409,
|
||||
);
|
||||
expect((await send("/api/generate", input, "https://example.org")).status).toBe(403);
|
||||
});
|
||||
|
||||
test("local application supports immutable bundles, conflicts, rollback and existing config preservation", () => {
|
||||
const app = new StudioApplication(root);
|
||||
writeFileSync(join(root, "wrn.config.ts"), "export default { existing: true };");
|
||||
const first = app.apply(CORE_DESIGN_SYSTEMS.square, "none", { mode: "light" });
|
||||
expect(existsSync(join(first.directory, "system.css"))).toBe(true);
|
||||
const theme = JSON.parse(readFileSync(join(first.directory, "theme.json"), "utf8"));
|
||||
expect(resolveThemeConfig(theme).recipeCss).toContain("data-wrn-owner");
|
||||
expect(() => app.apply(CORE_DESIGN_SYSTEMS.soft, "none")).toThrow("WRN-STUDIO-CONFLICT");
|
||||
const second = app.apply(CORE_DESIGN_SYSTEMS.soft, first.revision);
|
||||
expect(app.rollback(second.revision).revision).toBe(first.revision);
|
||||
expect(app.system()!.id).toBe("square");
|
||||
expect(readFileSync(join(root, "wrn.config.ts"), "utf8")).toContain("existing: true");
|
||||
const fresh = mkdtempSync(join(root, "first-"));
|
||||
const initial = new StudioApplication(fresh);
|
||||
const applied = initial.apply(CORE_DESIGN_SYSTEMS.soft, "none");
|
||||
expect(initial.rollback(applied.revision).revision).toBe("none");
|
||||
expect(initial.system()).toBeUndefined();
|
||||
});
|
||||
|
||||
test("server serves a browser bundle, rejects cross-origin, invalid, oversized and unconfigured writes", async () => {
|
||||
expect((await send("/")).status).toBe(200);
|
||||
const bundle = await send("/studio.js");
|
||||
expect(bundle.status).toBe(200);
|
||||
expect(await bundle.text()).not.toContain("node:fs");
|
||||
expect(
|
||||
(
|
||||
await send(
|
||||
"/api/apply",
|
||||
{ system: CORE_DESIGN_SYSTEMS.soft, base: "none" },
|
||||
"https://other.example",
|
||||
)
|
||||
).status,
|
||||
).toBe(403);
|
||||
expect((await handler(new Request("http://evil.example/api/application"))).status).toBe(403);
|
||||
expect((await send("/api/apply", { system: {}, base: "none" })).status).toBe(422);
|
||||
expect((await send("/api/apply", { data: "x".repeat(2_000_001) })).status).toBe(413);
|
||||
const readOnly = await createStudioHandler();
|
||||
expect(
|
||||
(
|
||||
await readOnly(
|
||||
new Request("http://127.0.0.1/api/apply", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ system: CORE_DESIGN_SYSTEMS.soft, base: "none" }),
|
||||
}),
|
||||
)
|
||||
).status,
|
||||
).toBe(409);
|
||||
});
|
||||
|
||||
test("all presets generate isolated real component previews with light/dark and responsive/RTL controls", async () => {
|
||||
for (const system of Object.values(CORE_DESIGN_SYSTEMS))
|
||||
for (const mode of ["light", "dark"]) {
|
||||
const result = await send("/api/preview", { system, mode, direction: "rtl" });
|
||||
expect(result.status).toBe(200);
|
||||
const { url } = await result.json();
|
||||
const preview = await send(url);
|
||||
const html = await preview.text();
|
||||
expect(html).toContain(`data-theme="${mode}"`);
|
||||
expect(html).toContain('dir="rtl"');
|
||||
expect(html).toContain('data-wrn-component="Radio"');
|
||||
expect(html).toContain('data-wrn-component="InputNumber"');
|
||||
expect(html).toContain("data-scope");
|
||||
expect(html).toContain("data-wrn-controls");
|
||||
expect(html).not.toContain("failed to load component controllers");
|
||||
expect(preview.headers.get("content-security-policy")).toContain("connect-src 'none'");
|
||||
}
|
||||
});
|
||||
|
||||
test("catalog previews cover all 104 component defaults and unknown routes do not expose files", async () => {
|
||||
let count = 0;
|
||||
for (let page = 0; page < 7; page++) {
|
||||
const result = await send("/api/preview", {
|
||||
system: CORE_DESIGN_SYSTEMS.soft,
|
||||
content: "catalog",
|
||||
page,
|
||||
});
|
||||
const { url } = await result.json();
|
||||
const html = await (await send(url)).text();
|
||||
count += (html.match(/data-wrn-component=/g) ?? []).length;
|
||||
}
|
||||
expect(count).toBe(104);
|
||||
expect((await send("/../../package.json")).status).toBe(404);
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { StudioSession, importDesign, exportDesign, diffDesign } from "../src/index.ts";
|
||||
import { CORE_DESIGN_SYSTEMS, validateDesignSystem } from "../../design-system/src/index.ts";
|
||||
|
||||
test("invalid edits preserve a valid preview, raw input, undo/redo and reload", () => {
|
||||
const data = new Map<string, string>();
|
||||
const storage = {
|
||||
getItem: (k: string) => data.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => {
|
||||
data.set(k, v);
|
||||
},
|
||||
};
|
||||
const session = new StudioSession(CORE_DESIGN_SYSTEMS.soft, storage);
|
||||
session.mutate((s) => {
|
||||
s.tokens["shape.control"]!.value = "13px";
|
||||
}, "Changed radius");
|
||||
session.edit('{"tokens":', "Incomplete JSON");
|
||||
expect(session.diagnostics[0]!.code).toBe("WRN-STUDIO-IMPORT");
|
||||
expect(session.valid.tokens["shape.control"]!.value).toBe("13px");
|
||||
session.undo();
|
||||
expect(session.diagnostics).toEqual([]);
|
||||
session.redo();
|
||||
expect(session.source).toBe('{"tokens":');
|
||||
const restored = new StudioSession(undefined, storage);
|
||||
expect(restored.restore()).toBe(true);
|
||||
expect(restored.source).toBe(session.source);
|
||||
expect(restored.valid.tokens["shape.control"]!.value).toBe("13px");
|
||||
expect(() => restored.markApplied()).toThrow();
|
||||
expect(() => restored.mutate(() => {}, "x")).toThrow();
|
||||
});
|
||||
|
||||
test("history stays bounded, branches after undo, and realistic full-preset history reloads", () => {
|
||||
const data = new Map<string, string>();
|
||||
const storage = {
|
||||
getItem: (k: string) => data.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => {
|
||||
data.set(k, v);
|
||||
},
|
||||
};
|
||||
const session = new StudioSession(undefined, storage);
|
||||
for (let i = 0; i < 30; i++)
|
||||
session.mutate((s) => {
|
||||
s.name = "Revision " + i;
|
||||
}, "Name");
|
||||
expect(session.history).toHaveLength(25);
|
||||
const restored = new StudioSession(undefined, storage);
|
||||
expect(restored.restore()).toBe(true);
|
||||
restored.undo();
|
||||
restored.mutate((s) => {
|
||||
s.name = "Branch";
|
||||
}, "Branched");
|
||||
restored.redo();
|
||||
expect(restored.valid.name).toBe("Branch");
|
||||
expect(restored.history.at(-1)!.label).toBe("Branched");
|
||||
});
|
||||
|
||||
test("corrupted storage and quota failures do not destroy in-memory drafts", () => {
|
||||
const broken = new StudioSession(undefined, {
|
||||
getItem: () => "{",
|
||||
setItem: () => {
|
||||
throw Error("quota");
|
||||
},
|
||||
});
|
||||
expect(broken.restore()).toBe(false);
|
||||
expect(broken.storageError).toContain("could not be restored");
|
||||
broken.mutate((s) => {
|
||||
s.name = "Kept";
|
||||
}, "Edit");
|
||||
expect(broken.valid.name).toBe("Kept");
|
||||
expect(broken.storageError).toContain("Autosave unavailable");
|
||||
});
|
||||
|
||||
test("canonical import/export round trips all recipes, modes, aliases and metadata", () => {
|
||||
for (const system of Object.values(CORE_DESIGN_SYSTEMS)) {
|
||||
const artifact = exportDesign(system);
|
||||
const report = importDesign(artifact.canonical);
|
||||
expect(report.diagnostics).toEqual([]);
|
||||
expect(report.system).toEqual(system);
|
||||
expect(artifact.css).toContain("data-wrn-owner");
|
||||
expect(JSON.parse(artifact.theme).designSystem.system).toEqual(system);
|
||||
}
|
||||
});
|
||||
|
||||
test("legacy import reports unsupported fields and preserves literal semantic bindings", () => {
|
||||
const report = importDesign(
|
||||
JSON.stringify({
|
||||
default: "light",
|
||||
palette: "violet",
|
||||
themes: { light: { "color-primary": "#112233", unknown: "4px" } },
|
||||
}),
|
||||
"legacy",
|
||||
);
|
||||
expect(report.diagnostics).toEqual([]);
|
||||
expect(report.unsupported).toContain("palette");
|
||||
expect(report.unsupported).toContain("themes.light.unknown");
|
||||
expect(report.system!.modes!.light!["color.primary"]).toEqual({
|
||||
type: "color",
|
||||
value: "#112233",
|
||||
legacy: "color-primary",
|
||||
});
|
||||
});
|
||||
|
||||
test("exchange subset preserves aliases and reports ambiguous semantic mapping and unsupported composites", () => {
|
||||
const report = importDesign(
|
||||
JSON.stringify({
|
||||
brand: {
|
||||
$type: "color",
|
||||
base: { $value: "#112233" },
|
||||
action: { $value: "{brand.base}" },
|
||||
complex: { $value: { colorSpace: "srgb", components: [0, 0, 0] } },
|
||||
},
|
||||
$extensions: { x: 1 },
|
||||
}),
|
||||
"tokens",
|
||||
);
|
||||
expect(report.diagnostics).toEqual([]);
|
||||
expect(report.system!.tokens["brand.action"]!.value).toEqual({ ref: "brand.base" });
|
||||
expect(report.unsupported).toContain("brand.complex");
|
||||
expect(report.unsupported).toContain("$extensions");
|
||||
expect(report.ambiguous).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("imports reject oversized, cyclic, executable, polluted and missing-reference documents", () => {
|
||||
expect(importDesign(" ".repeat(2_000_001)).diagnostics).not.toEqual([]);
|
||||
expect(
|
||||
importDesign('{"__proto__":{"$type":"color","$value":"#000"}}', "tokens").diagnostics,
|
||||
).not.toEqual([]);
|
||||
for (const tokens of [
|
||||
{ a: { type: "color", value: { ref: "missing" } } },
|
||||
{ a: { type: "color", value: { ref: "b" } }, b: { type: "color", value: { ref: "a" } } },
|
||||
{ a: { type: "color", value: "red;}body{display:none" } },
|
||||
]) {
|
||||
const system = { schemaVersion: 1, id: "bad", name: "Bad", version: "1", tokens };
|
||||
expect(importDesign(JSON.stringify(system)).diagnostics).not.toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test("review diffs identify affected paths and applied snapshots are detached", () => {
|
||||
const session = new StudioSession();
|
||||
session.mutate((s) => {
|
||||
s.tokens["shape.control"]!.value = "6px";
|
||||
}, "Radius");
|
||||
expect(session.changes()).toContain("$.tokens.shape.control.value");
|
||||
session.markApplied();
|
||||
expect(session.changes()).toEqual([]);
|
||||
session.mutate((s) => {
|
||||
s.name = "Other";
|
||||
}, "Name");
|
||||
expect(session.applied.name).not.toBe("Other");
|
||||
expect(diffDesign({ a: 1 }, { a: 2, b: 3 })).toEqual(["$.a", "$.b"]);
|
||||
expect(validateDesignSystem(session.valid)).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { CORE_DESIGN_SYSTEMS } from "@wrnexus/design-system";
|
||||
import { createDesignPackage, readDesignPackage } from "@wrnexus/design-system/package";
|
||||
import { StudioSession } from "../src/session.ts";
|
||||
import { planDesignUpgrade, applyDesignUpgrade } from "../src/upgrades.ts";
|
||||
|
||||
test("shared packages have deterministic pins independent of object key order and reject changed or future content", async () => {
|
||||
const system = CORE_DESIGN_SYSTEMS.square;
|
||||
const first = await createDesignPackage(system);
|
||||
const reordered = {
|
||||
...system,
|
||||
tokens: Object.fromEntries(Object.entries(system.tokens).reverse()),
|
||||
};
|
||||
expect((await createDesignPackage(reordered)).integrity.digest).toBe(first.integrity.digest);
|
||||
expect((await readDesignPackage(JSON.stringify(first))).package).toEqual(first);
|
||||
const changed = structuredClone(first);
|
||||
changed.system.tokens["color.primary"]!.value = "#abcdef";
|
||||
await expect(readDesignPackage(JSON.stringify(changed))).rejects.toThrow("INTEGRITY");
|
||||
await expect(readDesignPackage(JSON.stringify({ ...first, formatVersion: 2 }))).rejects.toThrow(
|
||||
"VERSION",
|
||||
);
|
||||
await expect(createDesignPackage({ ...system, extends: ["company"] })).rejects.toThrow();
|
||||
expect((await readDesignPackage(JSON.stringify(system))).migrations).toEqual([
|
||||
"canonical-v1-to-package-v1",
|
||||
]);
|
||||
});
|
||||
test("upgrade review and explicit adoption respect identity, pins, locks, stale revisions and recoverable history", async () => {
|
||||
const session = new StudioSession();
|
||||
const next = structuredClone(session.valid);
|
||||
next.version = "1.1.0";
|
||||
next.tokens["color.primary"]!.value = "#abcdef";
|
||||
const source = JSON.stringify(await createDesignPackage(next));
|
||||
const plan = await planDesignUpgrade(session, source);
|
||||
expect(plan.canApply).toBe(true);
|
||||
expect(session.valid.version).toBe("1.0.0");
|
||||
session.setLocks(["/tokens/color.primary"]);
|
||||
expect((await planDesignUpgrade(session, source)).canApply).toBe(false);
|
||||
await expect(applyDesignUpgrade(session, source, plan.baseRevision)).rejects.toThrow("CONFLICT");
|
||||
const locked = await planDesignUpgrade(session, source);
|
||||
await expect(applyDesignUpgrade(session, source, locked.baseRevision)).rejects.toThrow("LOCK");
|
||||
session.setLocks([]);
|
||||
const fresh = await planDesignUpgrade(session, source);
|
||||
await applyDesignUpgrade(session, source, fresh.baseRevision);
|
||||
expect(session.valid.version).toBe("1.1.0");
|
||||
session.undo();
|
||||
expect(session.valid.version).toBe("1.0.0");
|
||||
session.redo();
|
||||
expect(session.valid.version).toBe("1.1.0");
|
||||
await expect(
|
||||
planDesignUpgrade(
|
||||
session,
|
||||
JSON.stringify(await createDesignPackage({ ...next, id: "different" })),
|
||||
),
|
||||
).rejects.toThrow("identity");
|
||||
session.edit("{unfinished");
|
||||
await expect(planDesignUpgrade(session, source)).rejects.toThrow("source errors");
|
||||
});
|
||||
test("legacy unversioned drafts migrate losslessly; future drafts remain untouched by autosave", () => {
|
||||
let saved = "";
|
||||
const storage = {
|
||||
getItem: () => saved || null,
|
||||
setItem: (_key: string, value: string) => {
|
||||
saved = value;
|
||||
},
|
||||
};
|
||||
const old = new StudioSession(undefined, storage);
|
||||
old.edit("{unfinished");
|
||||
const legacy = JSON.parse(saved);
|
||||
delete legacy.storageVersion;
|
||||
saved = JSON.stringify(legacy);
|
||||
const restored = new StudioSession(undefined, storage);
|
||||
expect(restored.restore()).toBe(true);
|
||||
expect(restored.source).toBe("{unfinished");
|
||||
restored.undo();
|
||||
expect(JSON.parse(saved).storageVersion).toBe(1);
|
||||
const future = JSON.stringify({ ...legacy, storageVersion: 99 });
|
||||
saved = future;
|
||||
const older = new StudioSession(undefined, storage);
|
||||
expect(older.restore()).toBe(false);
|
||||
expect(older.recoverySource).toBe(future);
|
||||
older.edit("{another edit");
|
||||
older.persist();
|
||||
expect(saved).toBe(future);
|
||||
expect(older.storageError).toContain("protected");
|
||||
});
|
||||
Reference in new issue
Block a user