// 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");