import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { once } from "node:events"; import { mkdtemp, rm } from "node:fs/promises"; import { createServer as createNetServer } from "node:net"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; const foundryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); test("Design Profile owns design-only page fragments while Page Library stays read-only", async () => { const runtimeDir = await mkdtemp(join(tmpdir(), "nodedc-foundry-design-profile-")); const port = await freePort(); const foundry = spawn(process.execPath, ["server/catalog-server.mjs"], { cwd: foundryRoot, env: { ...process.env, NODE_ENV: "development", HOST: "127.0.0.1", PORT: String(port), FOUNDRY_RUNTIME_DIR: runtimeDir, NODEDC_FOUNDRY_AUTH_REQUIRED: "false", }, stdio: ["ignore", "pipe", "pipe"], }); try { await waitForService(port, foundry); const base = `http://127.0.0.1:${port}`; const profileResponse = await fetch(`${base}/api/design-profiles/default`); assert.equal(profileResponse.status, 200); const profile = await profileResponse.json(); const fragment = profile.layout.pageTypes["map@0.1.0"]; assert.equal(fragment.templateId, "map"); assert.equal(fragment.templateVersion, "0.1.0"); assert.equal("camera" in fragment, false); assert.equal("pinBindings" in fragment, false); assert.equal("dataProductBindings" in fragment, false); const pageLibraryMutation = await fetch(`${base}/api/page-layouts/map`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({}), }); assert.equal(pageLibraryMutation.status, 405); assert.equal((await pageLibraryMutation.json()).error, "page_library_read_only"); const forbiddenFragment = await fetch(`${base}/api/design-profiles/default`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: profile.name, layout: { ...profile.layout, pageTypes: { ...profile.layout.pageTypes, "map@0.1.0": { ...fragment, camera: { longitude: 0, latitude: 0 } }, }, }, }), }); assert.equal(forbiddenFragment.status, 400); assert.equal((await forbiddenFragment.json()).error, "design_profile_page_fragment_contains_runtime"); const savedProfileResponse = await fetch(`${base}/api/design-profiles/default`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: profile.name, layout: { ...profile.layout, pageTypes: { ...profile.layout.pageTypes, "map@0.1.0": { ...fragment, settings: { ...fragment.settings, buildingsOpacity: 0.74 }, }, }, }, }), }); assert.equal(savedProfileResponse.status, 200); const savedProfile = await savedProfileResponse.json(); assert.equal(savedProfile.version, "0.6.1"); assert.equal(savedProfile.layout.pageTypes["map@0.1.0"].settings.buildingsOpacity, 0.74); const applicationResponse = await fetch(`${base}/api/applications`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "Profile merge test", slug: "profile-merge-test", templateId: "map", templateVersion: "0.1.0" }), }); assert.equal(applicationResponse.status, 201); const application = await applicationResponse.json(); application.pages[0].designOverrides = { map: { settings: { buildingsOpacity: 0.5 } } }; const applicationSaveResponse = await fetch(`${base}/api/applications/${application.id}`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(application), }); assert.equal(applicationSaveResponse.status, 200); const savedApplication = await applicationSaveResponse.json(); assert.deepEqual(savedApplication.pages[0].designOverrides, { map: { settings: { buildingsOpacity: 0.5 } } }); } finally { foundry.kill("SIGTERM"); await Promise.race([once(foundry, "exit"), new Promise((resolveWait) => setTimeout(resolveWait, 2_000))]); if (foundry.exitCode === null) foundry.kill("SIGKILL"); await rm(runtimeDir, { recursive: true, force: true }); } }); async function freePort() { const server = createNetServer(); server.listen(0, "127.0.0.1"); await once(server, "listening"); const address = server.address(); assert(address && typeof address === "object"); const port = address.port; server.close(); await once(server, "close"); return port; } async function waitForService(port, child) { let output = ""; child.stderr.on("data", (chunk) => { output += String(chunk); }); for (let attempt = 0; attempt < 100; attempt += 1) { if (child.exitCode !== null) throw new Error(`foundry_exited:${child.exitCode}:${output}`); try { const response = await fetch(`http://127.0.0.1:${port}/healthz`); if (response.ok) return; } catch { /* service is still starting */ } await new Promise((resolveWait) => setTimeout(resolveWait, 50)); } throw new Error(`foundry_start_timeout:${output}`); }