176 lines
7.6 KiB
TypeScript
176 lines
7.6 KiB
TypeScript
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";
|
|
import { STUDIO_PRESETS } from "../src/presets.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(STUDIO_PRESETS))
|
|
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);
|
|
});
|