feat: introduce local-first SVG workbench
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("loads from a deep path and keeps the last valid projection while source is invalid", async ({
|
||||
page,
|
||||
}) => {
|
||||
const pageErrors: string[] = [];
|
||||
page.on("pageerror", (error) => pageErrors.push(error.message));
|
||||
|
||||
await page.goto("/deep/nested/svg/");
|
||||
await expect(page).toHaveTitle("SVG Tools");
|
||||
await expect(
|
||||
page.getByText("Source synchronized", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("tree", { name: "SVG element structure" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTitle("Sanitized SVG preview")).toHaveAttribute(
|
||||
"sandbox",
|
||||
"allow-scripts",
|
||||
);
|
||||
|
||||
const treeItems = page.getByRole("treeitem");
|
||||
const initialTreeCount = await treeItems.count();
|
||||
expect(initialTreeCount).toBeGreaterThan(1);
|
||||
await page
|
||||
.frameLocator('iframe[title="Sanitized SVG preview"]')
|
||||
.locator("#sun")
|
||||
.click();
|
||||
await expect(page.locator('[data-node-key="id:sun"]')).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
|
||||
const source = page.locator(".cm-content");
|
||||
await source.click();
|
||||
await page.keyboard.press("Control+A");
|
||||
// insertText bypasses CodeMirror's helpful XML close-tag completion so this
|
||||
// exercise really leaves the canonical source in an incomplete state.
|
||||
await page.keyboard.insertText('<svg xmlns="http://www.w3.org/2000/svg"><g>');
|
||||
await expect(page.getByText("Source invalid", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("Showing the last valid canvas revision."),
|
||||
).toBeVisible();
|
||||
expect(await treeItems.count()).toBe(initialTreeCount);
|
||||
await expect(page.getByRole("treeitem").first()).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Download SVG" }).last(),
|
||||
).toBeDisabled();
|
||||
|
||||
await source.click();
|
||||
await page.keyboard.press("Control+z");
|
||||
await expect(
|
||||
page.getByText("Source synchronized", { exact: true }),
|
||||
).toBeVisible();
|
||||
expect(pageErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test("synchronizes tree selection, source patches, undo and redo", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/deep/nested/svg/");
|
||||
await expect(
|
||||
page.getByText("Source synchronized", { exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder("Filter elements…").fill("path");
|
||||
const pathRow = page.getByRole("treeitem").filter({ hasText: "path" }).last();
|
||||
await pathRow.click();
|
||||
await expect(pathRow).toHaveAttribute("aria-selected", "true");
|
||||
await page.getByRole("tab", { name: "Element" }).click();
|
||||
|
||||
const fill = page.getByLabel("Fill", { exact: true });
|
||||
await fill.fill("#123456");
|
||||
await fill.press("Enter");
|
||||
await expect(page.locator(".cm-content")).toContainText('fill="#123456"');
|
||||
|
||||
await page.getByRole("button", { name: "Undo" }).click();
|
||||
await expect(page.locator(".cm-content")).not.toContainText('fill="#123456"');
|
||||
await page.getByRole("button", { name: "Redo" }).click();
|
||||
await expect(page.locator(".cm-content")).toContainText('fill="#123456"');
|
||||
});
|
||||
|
||||
test("connects to a valid same-origin Toolbox catalogue from the nested build", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/deep/nested/svg/?toolbox=%2Ftoolbox.catalog.json");
|
||||
await expect(
|
||||
page.getByText("Source synchronized", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator(".toolbox-shell")).toHaveAttribute(
|
||||
"data-toolbox-context",
|
||||
"connected",
|
||||
);
|
||||
await page.getByRole("button", { name: "Apps" }).click();
|
||||
const switcher = page.getByRole("navigation", {
|
||||
name: "Toolbox applications",
|
||||
});
|
||||
await expect(switcher).toBeVisible();
|
||||
await expect(
|
||||
switcher.getByRole("link", { name: "SVG Tools" }),
|
||||
).toHaveAttribute("aria-current", "page");
|
||||
});
|
||||
|
||||
test("opens hostile SVG locally without executing scripts or fetching external URLs", async ({
|
||||
page,
|
||||
}) => {
|
||||
const forbiddenRequests: string[] = [];
|
||||
let executed = false;
|
||||
page.on("request", (request) => {
|
||||
if (request.url().includes("invalid.example"))
|
||||
forbiddenRequests.push(request.url());
|
||||
});
|
||||
await page.exposeFunction("svgToolsExecuted", () => {
|
||||
executed = true;
|
||||
});
|
||||
await page.goto("/deep/nested/svg/");
|
||||
|
||||
const hostile = `
|
||||
<!DOCTYPE svg SYSTEM "https://invalid.example/tracker.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<script>parent.svgToolsExecuted()</script>
|
||||
<image href="https://invalid.example/tracker.png" width="20" height="20"/>
|
||||
<rect width="20" height="20" onclick="parent.svgToolsExecuted()"/>
|
||||
</svg>`;
|
||||
await page.locator('input[type="file"]').evaluate((element, contents) => {
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(
|
||||
new File([contents], "hostile.svg", { type: "image/svg+xml" }),
|
||||
);
|
||||
(element as HTMLInputElement).files = transfer.files;
|
||||
element.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}, hostile);
|
||||
await expect(
|
||||
page.getByText("Source synchronized", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(/blocked-script|event-handler|unsafe-url/).first(),
|
||||
).toBeVisible();
|
||||
|
||||
const frame = page.frameLocator('iframe[title="Sanitized SVG preview"]');
|
||||
await expect(frame.locator("script")).toHaveCount(0);
|
||||
await expect(frame.locator("image")).not.toHaveAttribute(
|
||||
"href",
|
||||
/invalid\.example/u,
|
||||
);
|
||||
expect(executed).toBe(false);
|
||||
expect(forbiddenRequests).toEqual([]);
|
||||
});
|
||||
Reference in New Issue
Block a user