feat: introduce local-first SVG workbench
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AnimationDefinition } from "../../src/animation/animation.types";
|
||||
import {
|
||||
animationStyle,
|
||||
withAnimationPreview,
|
||||
} from "../../src/animation/preview";
|
||||
|
||||
const fade: AnimationDefinition = {
|
||||
id: "fade",
|
||||
name: "Fade",
|
||||
targetNodeKey: "id:shape",
|
||||
property: "opacity",
|
||||
kind: "style",
|
||||
enabled: true,
|
||||
keyframes: [
|
||||
{ offset: 0, value: "0" },
|
||||
{ offset: 1, value: "1" },
|
||||
],
|
||||
timing: {
|
||||
durationMs: 500,
|
||||
delayMs: 20,
|
||||
iterations: "infinite",
|
||||
direction: "alternate",
|
||||
fillMode: "both",
|
||||
easing: "ease-in-out",
|
||||
},
|
||||
};
|
||||
|
||||
describe("isolated animation preview", () => {
|
||||
it("creates project-side CSS targeting only projection metadata", () => {
|
||||
const css = animationStyle([fade]);
|
||||
expect(css).toContain("@keyframes svg-tools-animation-0");
|
||||
expect(css).toContain('[data-svg-tools-node="id:shape"]');
|
||||
expect(css).toContain("500ms ease-in-out 20ms infinite alternate both");
|
||||
});
|
||||
|
||||
it("injects preview metadata without mutating the input projection", () => {
|
||||
const source =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><circle data-svg-tools-node="id:shape"/></svg>';
|
||||
const result = withAnimationPreview(source, [fade]);
|
||||
expect(source).not.toContain("data-svg-tools-preview");
|
||||
expect(result).toContain('data-svg-tools-preview="animation"');
|
||||
});
|
||||
|
||||
it("rejects unsafe properties and CSS rule or URL injection", () => {
|
||||
expect(() =>
|
||||
animationStyle([{ ...fade, property: "background-image" }]),
|
||||
).toThrow(/not application-safe/u);
|
||||
expect(() =>
|
||||
animationStyle([
|
||||
{
|
||||
...fade,
|
||||
keyframes: [{ offset: 0, value: "url(https://evil.test)" }],
|
||||
},
|
||||
]),
|
||||
).toThrow(/URLs or external resources/u);
|
||||
expect(() =>
|
||||
animationStyle([
|
||||
{
|
||||
...fade,
|
||||
timing: { ...fade.timing, easing: "linear; color:red" },
|
||||
},
|
||||
]),
|
||||
).toThrow(/unsafe animation easing/u);
|
||||
});
|
||||
|
||||
it("omits disabled animations", () => {
|
||||
expect(animationStyle([{ ...fade, enabled: false }])).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AnimationDefinition } from "../../src/animation/animation.types";
|
||||
import {
|
||||
AnimationValidationError,
|
||||
buildAnimationCss,
|
||||
validateAnimationDefinitions,
|
||||
} from "../../src/animation/validation";
|
||||
|
||||
const animation: AnimationDefinition = {
|
||||
id: "pulse",
|
||||
name: "Pulse",
|
||||
targetNodeKey: "id:shape",
|
||||
property: "opacity",
|
||||
kind: "style",
|
||||
enabled: true,
|
||||
keyframes: [
|
||||
{ offset: 0, value: "0.25", easing: "cubic-bezier(0.2, 0, 0.8, 1)" },
|
||||
{ offset: 1, value: "calc(1 - 0.1)" },
|
||||
],
|
||||
timing: {
|
||||
durationMs: 500,
|
||||
delayMs: -20,
|
||||
iterations: 2.5,
|
||||
direction: "alternate",
|
||||
fillMode: "both",
|
||||
easing: "steps(4, jump-end)",
|
||||
},
|
||||
};
|
||||
|
||||
function invalid(mutator: (value: AnimationDefinition) => void): () => void {
|
||||
const value = structuredClone(animation);
|
||||
mutator(value);
|
||||
return () => validateAnimationDefinitions([value]);
|
||||
}
|
||||
|
||||
describe("application-owned animation validation", () => {
|
||||
it("builds source-safe CSS through the same validated serializer", () => {
|
||||
const css = buildAnimationCss([animation], {
|
||||
keyframeNamePrefix: "svg-tools",
|
||||
selectorFor: () => "#shape",
|
||||
});
|
||||
|
||||
expect(css).toContain("@keyframes svg-tools-0");
|
||||
expect(css).toContain("#shape { animation: svg-tools-0 500ms");
|
||||
expect(css).toContain(
|
||||
"animation-timing-function: cubic-bezier(0.2, 0, 0.8, 1)",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"NaN offset",
|
||||
invalid((value) => (value.keyframes[0]!.offset = Number.NaN)),
|
||||
],
|
||||
["large offset", invalid((value) => (value.keyframes[0]!.offset = 1.1))],
|
||||
[
|
||||
"infinite duration",
|
||||
invalid((value) => (value.timing.durationMs = Number.POSITIVE_INFINITY)),
|
||||
],
|
||||
[
|
||||
"infinite delay",
|
||||
invalid((value) => (value.timing.delayMs = Number.NEGATIVE_INFINITY)),
|
||||
],
|
||||
[
|
||||
"infinite iterations",
|
||||
invalid((value) => (value.timing.iterations = Number.POSITIVE_INFINITY)),
|
||||
],
|
||||
["negative iterations", invalid((value) => (value.timing.iterations = -1))],
|
||||
])("rejects non-finite or out-of-range timing: %s", (_label, action) => {
|
||||
expect(action).toThrow(AnimationValidationError);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["property", invalid((value) => (value.property = "opacity; stroke: red"))],
|
||||
[
|
||||
"declaration",
|
||||
invalid((value) => (value.keyframes[0]!.value = "0; stroke: red")),
|
||||
],
|
||||
[
|
||||
"closing style element",
|
||||
invalid(
|
||||
(value) =>
|
||||
(value.keyframes[0]!.value = "0</style><script>alert(1)</script>"),
|
||||
),
|
||||
],
|
||||
[
|
||||
"external URL",
|
||||
invalid(
|
||||
(value) =>
|
||||
(value.keyframes[0]!.value = "url(https://attacker.invalid/a.svg)"),
|
||||
),
|
||||
],
|
||||
[
|
||||
"escaped external URL",
|
||||
invalid(
|
||||
(value) =>
|
||||
(value.keyframes[0]!.value = String.raw`u\72 l("https://attacker.invalid/a.svg")`),
|
||||
),
|
||||
],
|
||||
[
|
||||
"timing easing",
|
||||
invalid((value) => (value.timing.easing = "linear; stroke: red")),
|
||||
],
|
||||
[
|
||||
"keyframe easing",
|
||||
invalid(
|
||||
(value) => (value.keyframes[0]!.easing = "linear } body { color:red"),
|
||||
),
|
||||
],
|
||||
])("rejects CSS injection through %s", (_label, action) => {
|
||||
expect(action).toThrow(AnimationValidationError);
|
||||
});
|
||||
|
||||
it("validates disabled definitions instead of retaining dormant payloads", () => {
|
||||
const value = structuredClone(animation);
|
||||
value.enabled = false;
|
||||
value.keyframes[0]!.value = "url(https://attacker.invalid/payload)";
|
||||
|
||||
expect(() =>
|
||||
buildAnimationCss([value], {
|
||||
keyframeNamePrefix: "svg-tools",
|
||||
selectorFor: () => "#shape",
|
||||
}),
|
||||
).toThrow(AnimationValidationError);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user