// Tests for the mini-app static validator. node test/check-app.test.mjs import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; const CHECKER = new URL("../tools/check-app.mjs", import.meta.url).pathname; const dir = mkdtempSync(join(tmpdir(), "check-app-")); let n = 0; // Write `source` to a temp app file and run the checker on it. function run(source) { const file = join(dir, `app-${n++}.js`); writeFileSync(file, source); const res = spawnSync(process.execPath, [CHECKER, file], { encoding: "utf8" }); return { status: res.status, out: res.stdout + res.stderr }; } let failures = 0; async function test(name, fn) { try { await fn(); console.log("PASS " + name); } catch (e) { console.log("FAIL " + name + " — " + (e && e.message)); failures++; } } // A minimal valid app the good-path test uses; bad-path tests mutate it. const GOOD = `import { defineApp, addItem, removeItem } from "../apps.js"; export const app = defineApp({ name: "note", title: "Notes", glyph: "📝", places: ["you"], initialState: () => ({ items: [] }), events: { "note.add": (s, e) => (e.text ? { items: addItem(s.items, { id: e.id, text: e.text }) } : s), "note.remove": (s, e) => ({ items: removeItem(s.items, e.id) }), }, render(state, el, ctx) { const d = document.createElement("div"); d.textContent = "static label"; d.innerHTML = "static markup is fine"; el.appendChild(d); ctx.interval(() => {}, 1000); }, }); `; await test("a valid app passes", () => { const r = run(GOOD); assert.equal(r.status, 0, r.out); assert.ok(r.out.includes("all checks passed"), r.out); }); await test("a syntax error fails the parse check", () => { const r = run("import { defineApp } from \"../apps.js\";\nconst x = {;\n"); assert.notEqual(r.status, 0); assert.ok(r.out.includes("does not parse"), r.out); }); await test("zero and two defineApp calls both fail", () => { const none = run("export const x = 1;\n"); assert.notEqual(none.status, 0); assert.ok(none.out.includes("found 0"), none.out); const twice = run(GOOD + "\ndefineApp({});\n"); assert.notEqual(twice.status, 0); assert.ok(twice.out.includes("found 2"), twice.out); }); await test("a missing required field fails", () => { const r = run(GOOD.replace('glyph: "📝",\n', "")); assert.notEqual(r.status, 0); assert.ok(r.out.includes('required field "glyph" missing'), r.out); }); await test("places must be a non-empty valid subset — any combination, you not required", () => { const badEntry = run(GOOD.replace('places: ["you"]', 'places: ["you", "everywhere"]')); assert.notEqual(badEntry.status, 0); assert.ok(badEntry.out.includes("everywhere"), badEntry.out); const empty = run(GOOD.replace('places: ["you"]', "places: []")); assert.notEqual(empty.status, 0); assert.ok(empty.out.includes("at least one place"), empty.out); const chatToo = run(GOOD.replace('places: ["you"]', 'places: ["you", "chat"]')); assert.equal(chatToo.status, 0, chatToo.out); // A space-only app (one shared copy for everyone, gif-style) is legal. const spaceOnly = run(GOOD.replace('places: ["you"]', 'places: ["space"]')); assert.equal(spaceOnly.status, 0, spaceOnly.out); }); await test("legacy scope: personal still passes; missing both fails; scope: shared is retired", () => { const legacy = run(GOOD.replace('places: ["you"]', 'scope: "personal"')); assert.equal(legacy.status, 0, legacy.out); assert.ok(legacy.out.includes("legacy scope"), legacy.out); const neither = run(GOOD.replace('places: ["you"],\n', "")); assert.notEqual(neither.status, 0); assert.ok(neither.out.includes("declare places"), neither.out); const shared = run(GOOD.replace('places: ["you"]', 'scope: "shared"')); assert.notEqual(shared.status, 0); assert.ok(shared.out.includes("declare places"), shared.out); }); await test("event types must carry the app-name prefix", () => { const r = run(GOOD.replace('"note.remove"', '"other.remove"')); assert.notEqual(r.status, 0); assert.ok(r.out.includes("other.remove"), r.out); }); await test("fetch is forbidden", () => { const r = run(GOOD.replace("el.appendChild(d);", 'fetch("/api/x");')); assert.notEqual(r.status, 0); assert.ok(r.out.includes("fetch("), r.out); }); await test("localStorage and document.cookie are forbidden", () => { const r = run(GOOD.replace("el.appendChild(d);", 'localStorage.setItem("a", "b"); const c = document.cookie;')); assert.notEqual(r.status, 0); assert.ok(r.out.includes("localStorage"), r.out); assert.ok(r.out.includes("document.cookie"), r.out); }); await test("bare setInterval is flagged; ctx.interval is not", () => { const good = run(GOOD); // GOOD uses ctx.interval assert.equal(good.status, 0, good.out); const bad = run(GOOD.replace("ctx.interval(() => {}, 1000);", "setInterval(() => {}, 1000);")); assert.notEqual(bad.status, 0); assert.ok(bad.out.includes("setInterval"), bad.out); }); await test("a forbidden token in a comment or string does not fail", () => { const r = run(GOOD.replace("el.appendChild(d);", 'el.appendChild(d); // never call setInterval( or fetch(\nconst hint = "no localStorage here";')); assert.equal(r.status, 0, r.out); }); await test("innerHTML of dynamic content is flagged; a static literal is not", () => { const good = run(GOOD); // GOOD assigns a static literal assert.equal(good.status, 0, good.out); const bad = run(GOOD.replace('d.innerHTML = "static markup is fine";', "d.innerHTML = item.text;")); assert.notEqual(bad.status, 0); assert.ok(bad.out.includes("innerHTML"), bad.out); const tpl = run(GOOD.replace('d.innerHTML = "static markup is fine";', "d.innerHTML = `${state.x}`;")); assert.notEqual(tpl.status, 0); assert.ok(tpl.out.includes("innerHTML"), tpl.out); }); await test("imports outside ../apps.js and ../format.js are flagged", () => { const format = run(GOOD.replace('from "../apps.js";', 'from "../apps.js";\nimport { firstLink } from "../format.js";')); assert.equal(format.status, 0, format.out); const bad = run(GOOD.replace('from "../apps.js";', 'from "../apps.js";\nimport { x } from "./helpers.js";')); assert.notEqual(bad.status, 0); assert.ok(bad.out.includes("./helpers.js"), bad.out); }); await test("dynamic import() is flagged", () => { const r = run(GOOD.replace("el.appendChild(d);", 'import("../shell.js");')); assert.notEqual(r.status, 0); assert.ok(r.out.includes("dynamic import"), r.out); }); if (failures) { console.error(`\n${failures} test(s) failed`); process.exit(1); } console.log("\nAll check-app tests passed");