| 1 | // Tests for the mini-app static validator. node test/check-app.test.mjs |
| 2 | import assert from "node:assert/strict"; |
| 3 | import { spawnSync } from "node:child_process"; |
| 4 | import { mkdtempSync, writeFileSync } from "node:fs"; |
| 5 | import { tmpdir } from "node:os"; |
| 6 | import { join } from "node:path"; |
| 7 | |
| 8 | const CHECKER = new URL("../tools/check-app.mjs", import.meta.url).pathname; |
| 9 | const dir = mkdtempSync(join(tmpdir(), "check-app-")); |
| 10 | let n = 0; |
| 11 | |
| 12 | // Write `source` to a temp app file and run the checker on it. |
| 13 | function run(source) { |
| 14 | const file = join(dir, `app-${n++}.js`); |
| 15 | writeFileSync(file, source); |
| 16 | const res = spawnSync(process.execPath, [CHECKER, file], { encoding: "utf8" }); |
| 17 | return { status: res.status, out: res.stdout + res.stderr }; |
| 18 | } |
| 19 | |
| 20 | let failures = 0; |
| 21 | async function test(name, fn) { |
| 22 | try { |
| 23 | await fn(); |
| 24 | console.log("PASS " + name); |
| 25 | } catch (e) { |
| 26 | console.log("FAIL " + name + " β " + (e && e.message)); |
| 27 | failures++; |
| 28 | } |
| 29 | } |
| 30 | |
| 31 | // A minimal valid app the good-path test uses; bad-path tests mutate it. |
| 32 | const GOOD = `import { defineApp, addItem, removeItem } from "../apps.js"; |
| 33 | |
| 34 | export const app = defineApp({ |
| 35 | name: "note", |
| 36 | title: "Notes", |
| 37 | glyph: "π", |
| 38 | places: ["you"], |
| 39 | initialState: () => ({ items: [] }), |
| 40 | events: { |
| 41 | "note.add": (s, e) => (e.text ? { items: addItem(s.items, { id: e.id, text: e.text }) } : s), |
| 42 | "note.remove": (s, e) => ({ items: removeItem(s.items, e.id) }), |
| 43 | }, |
| 44 | render(state, el, ctx) { |
| 45 | const d = document.createElement("div"); |
| 46 | d.textContent = "static label"; |
| 47 | d.innerHTML = "<b>static markup is fine</b>"; |
| 48 | el.appendChild(d); |
| 49 | ctx.interval(() => {}, 1000); |
| 50 | }, |
| 51 | }); |
| 52 | `; |
| 53 | |
| 54 | await test("a valid app passes", () => { |
| 55 | const r = run(GOOD); |
| 56 | assert.equal(r.status, 0, r.out); |
| 57 | assert.ok(r.out.includes("all checks passed"), r.out); |
| 58 | }); |
| 59 | |
| 60 | await test("a syntax error fails the parse check", () => { |
| 61 | const r = run("import { defineApp } from \"../apps.js\";\nconst x = {;\n"); |
| 62 | assert.notEqual(r.status, 0); |
| 63 | assert.ok(r.out.includes("does not parse"), r.out); |
| 64 | }); |
| 65 | |
| 66 | await test("zero and two defineApp calls both fail", () => { |
| 67 | const none = run("export const x = 1;\n"); |
| 68 | assert.notEqual(none.status, 0); |
| 69 | assert.ok(none.out.includes("found 0"), none.out); |
| 70 | const twice = run(GOOD + "\ndefineApp({});\n"); |
| 71 | assert.notEqual(twice.status, 0); |
| 72 | assert.ok(twice.out.includes("found 2"), twice.out); |
| 73 | }); |
| 74 | |
| 75 | await test("a missing required field fails", () => { |
| 76 | const r = run(GOOD.replace('glyph: "π",\n', "")); |
| 77 | assert.notEqual(r.status, 0); |
| 78 | assert.ok(r.out.includes('required field "glyph" missing'), r.out); |
| 79 | }); |
| 80 | |
| 81 | await test("places must be a non-empty valid subset β any combination, you not required", () => { |
| 82 | const badEntry = run(GOOD.replace('places: ["you"]', 'places: ["you", "everywhere"]')); |
| 83 | assert.notEqual(badEntry.status, 0); |
| 84 | assert.ok(badEntry.out.includes("everywhere"), badEntry.out); |
| 85 | const empty = run(GOOD.replace('places: ["you"]', "places: []")); |
| 86 | assert.notEqual(empty.status, 0); |
| 87 | assert.ok(empty.out.includes("at least one place"), empty.out); |
| 88 | const chatToo = run(GOOD.replace('places: ["you"]', 'places: ["you", "chat"]')); |
| 89 | assert.equal(chatToo.status, 0, chatToo.out); |
| 90 | // A space-only app (one shared copy for everyone, gif-style) is legal. |
| 91 | const spaceOnly = run(GOOD.replace('places: ["you"]', 'places: ["space"]')); |
| 92 | assert.equal(spaceOnly.status, 0, spaceOnly.out); |
| 93 | }); |
| 94 | |
| 95 | await test("legacy scope: personal still passes; missing both fails; scope: shared is retired", () => { |
| 96 | const legacy = run(GOOD.replace('places: ["you"]', 'scope: "personal"')); |
| 97 | assert.equal(legacy.status, 0, legacy.out); |
| 98 | assert.ok(legacy.out.includes("legacy scope"), legacy.out); |
| 99 | const neither = run(GOOD.replace('places: ["you"],\n', "")); |
| 100 | assert.notEqual(neither.status, 0); |
| 101 | assert.ok(neither.out.includes("declare places"), neither.out); |
| 102 | const shared = run(GOOD.replace('places: ["you"]', 'scope: "shared"')); |
| 103 | assert.notEqual(shared.status, 0); |
| 104 | assert.ok(shared.out.includes("declare places"), shared.out); |
| 105 | }); |
| 106 | |
| 107 | await test("event types must carry the app-name prefix", () => { |
| 108 | const r = run(GOOD.replace('"note.remove"', '"other.remove"')); |
| 109 | assert.notEqual(r.status, 0); |
| 110 | assert.ok(r.out.includes("other.remove"), r.out); |
| 111 | }); |
| 112 | |
| 113 | await test("fetch is forbidden", () => { |
| 114 | const r = run(GOOD.replace("el.appendChild(d);", 'fetch("/api/x");')); |
| 115 | assert.notEqual(r.status, 0); |
| 116 | assert.ok(r.out.includes("fetch("), r.out); |
| 117 | }); |
| 118 | |
| 119 | await test("localStorage and document.cookie are forbidden", () => { |
| 120 | const r = run(GOOD.replace("el.appendChild(d);", 'localStorage.setItem("a", "b"); const c = document.cookie;')); |
| 121 | assert.notEqual(r.status, 0); |
| 122 | assert.ok(r.out.includes("localStorage"), r.out); |
| 123 | assert.ok(r.out.includes("document.cookie"), r.out); |
| 124 | }); |
| 125 | |
| 126 | await test("bare setInterval is flagged; ctx.interval is not", () => { |
| 127 | const good = run(GOOD); // GOOD uses ctx.interval |
| 128 | assert.equal(good.status, 0, good.out); |
| 129 | const bad = run(GOOD.replace("ctx.interval(() => {}, 1000);", "setInterval(() => {}, 1000);")); |
| 130 | assert.notEqual(bad.status, 0); |
| 131 | assert.ok(bad.out.includes("setInterval"), bad.out); |
| 132 | }); |
| 133 | |
| 134 | await test("a forbidden token in a comment or string does not fail", () => { |
| 135 | const r = run(GOOD.replace("el.appendChild(d);", 'el.appendChild(d); // never call setInterval( or fetch(\nconst hint = "no localStorage here";')); |
| 136 | assert.equal(r.status, 0, r.out); |
| 137 | }); |
| 138 | |
| 139 | await test("innerHTML of dynamic content is flagged; a static literal is not", () => { |
| 140 | const good = run(GOOD); // GOOD assigns a static literal |
| 141 | assert.equal(good.status, 0, good.out); |
| 142 | const bad = run(GOOD.replace('d.innerHTML = "<b>static markup is fine</b>";', "d.innerHTML = item.text;")); |
| 143 | assert.notEqual(bad.status, 0); |
| 144 | assert.ok(bad.out.includes("innerHTML"), bad.out); |
| 145 | const tpl = run(GOOD.replace('d.innerHTML = "<b>static markup is fine</b>";', "d.innerHTML = `<b>${state.x}</b>`;")); |
| 146 | assert.notEqual(tpl.status, 0); |
| 147 | assert.ok(tpl.out.includes("innerHTML"), tpl.out); |
| 148 | }); |
| 149 | |
| 150 | await test("imports outside ../apps.js and ../format.js are flagged", () => { |
| 151 | const format = run(GOOD.replace('from "../apps.js";', 'from "../apps.js";\nimport { firstLink } from "../format.js";')); |
| 152 | assert.equal(format.status, 0, format.out); |
| 153 | const bad = run(GOOD.replace('from "../apps.js";', 'from "../apps.js";\nimport { x } from "./helpers.js";')); |
| 154 | assert.notEqual(bad.status, 0); |
| 155 | assert.ok(bad.out.includes("./helpers.js"), bad.out); |
| 156 | }); |
| 157 | |
| 158 | await test("dynamic import() is flagged", () => { |
| 159 | const r = run(GOOD.replace("el.appendChild(d);", 'import("../shell.js");')); |
| 160 | assert.notEqual(r.status, 0); |
| 161 | assert.ok(r.out.includes("dynamic import"), r.out); |
| 162 | }); |
| 163 | |
| 164 | if (failures) { |
| 165 | console.error(`\n${failures} test(s) failed`); |
| 166 | process.exit(1); |
| 167 | } |
| 168 | console.log("\nAll check-app tests passed"); |
| 169 | |