162 lines
5.5 KiB
TypeScript
162 lines
5.5 KiB
TypeScript
import { CORE_DESIGN_SYSTEMS } from "@wrnexus/design-system";
|
|
|
|
// Exercise the compiled entry point, including package-relative Studio assets.
|
|
const reservation = Bun.serve({
|
|
port: 0,
|
|
hostname: "127.0.0.1",
|
|
fetch: () => new Response(),
|
|
});
|
|
const port = reservation.port!;
|
|
reservation.stop(true);
|
|
const server = Bun.spawn([process.execPath, "dist/server.js"], {
|
|
env: { ...process.env, PORT: String(port) },
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
const origin = `http://127.0.0.1:${port}`;
|
|
try {
|
|
let ready = false;
|
|
for (let attempt = 0; attempt < 60; attempt++) {
|
|
try {
|
|
ready = (await fetch(origin)).ok;
|
|
} catch {
|
|
/* Wait for the server to listen. */
|
|
}
|
|
if (ready) break;
|
|
await Bun.sleep(100);
|
|
}
|
|
if (!ready) throw Error("Compiled server did not become ready.");
|
|
for (const path of [
|
|
"/",
|
|
"/components/button",
|
|
"/design-studio/",
|
|
"/design-studio/studio.js",
|
|
"/design-studio/studio.css",
|
|
"/design-studio/site-help.js",
|
|
]) {
|
|
const response = await fetch(origin + path);
|
|
if (!response.ok) throw Error(`${path} returned ${response.status}`);
|
|
if (path === "/design-studio/") {
|
|
const html = await response.clone().text();
|
|
const presetMenu =
|
|
html.match(/<select id="preset">([\s\S]*?)<\/select>/)?.[1] ?? "";
|
|
if (
|
|
(presetMenu.match(/<option value="[^"]+"/g) ?? []).length !== 13 ||
|
|
!html.includes('id="show-draft"')
|
|
)
|
|
throw Error(
|
|
"The deployed Studio is missing its thirteen presets or proposal preview controls.",
|
|
);
|
|
}
|
|
if (
|
|
path === "/design-studio/" &&
|
|
!(await response.clone().text()).includes("How to use Design Studio")
|
|
)
|
|
throw Error("Studio's usage guide was not included in the compiled app.");
|
|
const policy = response.headers.get("content-security-policy") ?? "";
|
|
if (path === "/" && !policy.includes("require-trusted-types-for"))
|
|
throw Error(
|
|
"Normal component pages lost the production security policy.",
|
|
);
|
|
if (
|
|
path === "/design-studio/" &&
|
|
(!policy.includes("frame-ancestors 'none'") ||
|
|
policy.includes("require-trusted-types-for"))
|
|
)
|
|
throw Error("The app replaced Studio's isolated editor security policy.");
|
|
}
|
|
const application = await (
|
|
await fetch(origin + "/design-studio/api/application")
|
|
).json();
|
|
// Browser module requests carry Origin; the VM forwards them over plain HTTP.
|
|
const publicOrigin =
|
|
process.env.STUDIO_PUBLIC_ORIGIN ?? "https://component.wrnexusjs.dev";
|
|
const proxiedHeaders = {
|
|
origin: publicOrigin,
|
|
host: new URL(publicOrigin).host,
|
|
};
|
|
for (const path of [
|
|
"/design-studio/studio.js",
|
|
"/design-studio/api/application",
|
|
]) {
|
|
const proxied = await fetch(origin + path, { headers: proxiedHeaders });
|
|
if (!proxied.ok)
|
|
throw Error(
|
|
`HTTPS-origin request over HTTP failed: ${path} (${proxied.status})`,
|
|
);
|
|
}
|
|
const rejected = await fetch(origin + "/design-studio/studio.js", {
|
|
headers: { ...proxiedHeaders, origin: "https://attacker.example" },
|
|
});
|
|
if (rejected.status !== 403)
|
|
throw Error("Cross-origin Studio requests were not rejected.");
|
|
const generation = await (
|
|
await fetch(origin + "/design-studio/api/generation")
|
|
).json();
|
|
if (
|
|
process.env.APIMART_API_KEY?.trim() &&
|
|
(generation.provider !== "APIMart" ||
|
|
generation.model !== "gpt-6-luna" ||
|
|
generation.timeoutMs !== 60_000)
|
|
)
|
|
throw Error(
|
|
"The compiled app did not pick up its server-side AI configuration.",
|
|
);
|
|
if (application.enabled !== false)
|
|
throw Error("Hosted Studio must remain export-only.");
|
|
const preview = await fetch(origin + "/design-studio/api/preview", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", origin },
|
|
body: JSON.stringify({ system: CORE_DESIGN_SYSTEMS.soft }),
|
|
});
|
|
if (!preview.ok)
|
|
throw Error(
|
|
`Studio preview returned ${preview.status}: ${await preview.text()}`,
|
|
);
|
|
const { url } = await preview.json();
|
|
if (
|
|
!url.startsWith("/design-studio/preview/") ||
|
|
!(await fetch(origin + url)).ok
|
|
)
|
|
throw Error("Studio preview was not mounted under the app path.");
|
|
for (let page = 0; page < 7; page++) {
|
|
const gallery = await fetch(origin + "/design-studio/api/preview", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", origin },
|
|
body: JSON.stringify({
|
|
system: CORE_DESIGN_SYSTEMS.soft,
|
|
content: "catalog",
|
|
page,
|
|
}),
|
|
});
|
|
if (!gallery.ok)
|
|
throw Error(
|
|
`Component gallery page ${page + 1} failed: ${await gallery.text()}`,
|
|
);
|
|
const artifact = await gallery.json();
|
|
const rendered = await fetch(origin + artifact.url);
|
|
if (!rendered.ok) throw Error(`Gallery page ${page + 1} did not render.`);
|
|
const html = await rendered.text();
|
|
const count = (
|
|
html.match(/<section class="sample" data-gallery-component=/g) ?? []
|
|
).length;
|
|
if (
|
|
count !== (page === 6 ? 8 : 16) ||
|
|
!html.includes("bindPreview()") ||
|
|
/<div[^>]*data-component=/.test(html) ||
|
|
!html.includes("data:image/svg+xml,")
|
|
)
|
|
throw Error(
|
|
`Gallery page ${page + 1} is missing samples, icons or browser behavior.`,
|
|
);
|
|
}
|
|
console.log(
|
|
"Compiled server serves component pages, Studio assets, export-only APIs and previews on one port.",
|
|
);
|
|
} finally {
|
|
server.kill("SIGTERM");
|
|
await server.exited;
|
|
const errors = await new Response(server.stderr).text();
|
|
if (errors.trim()) console.error(errors);
|
|
}
|