| 1 | // π€ The app-host protocol's catalogue report (docs/native-apps.md): the |
| 2 | // serialized shapes the NATIVE shells consume via window.lchatNative. |
| 3 | // appCatalog() / commandItems(). These shapes are a cross-platform contract β |
| 4 | // Swift decodes them β so this test pins them against the REAL framework |
| 5 | // (defineApp normalization included), not a reconstruction. Widening the |
| 6 | // report is additive; renaming or dropping a field here means updating the |
| 7 | // native decoders in the same PR. |
| 8 | import assert from "assert"; |
| 9 | import { defineApp, appCatalogReport, commandCatalog } from "../web/static/apps.js"; |
| 10 | // A real shipped app as a sanity row: the vault is the canonical TAKEOVER app |
| 11 | // (groupFeedMessage, no chatMessage β docs/building-apps.md), so its record |
| 12 | // pins the companion gate against the real thing. |
| 13 | import "../web/static/apps/vault.js"; |
| 14 | |
| 15 | // A synthetic companion app exercising every reported field. |
| 16 | defineApp({ |
| 17 | name: "cataloguetest", |
| 18 | title: "Catalogue Test", |
| 19 | glyph: "π§ͺ", |
| 20 | places: ["you", "chat"], |
| 21 | quiet: true, |
| 22 | defaultView: "interface", |
| 23 | chatReadOnly: true, |
| 24 | initialState: () => ({ items: [] }), |
| 25 | events: { "cataloguetest.add": (s) => s }, |
| 26 | chatMessage: () => null, |
| 27 | defaultCommand: "/ct-add", |
| 28 | commands: { |
| 29 | "/ct": { run: () => {}, desc: "Test the catalogue", args: "<thing>" }, |
| 30 | "/ct-add": { run: () => {}, desc: "Add locally", args: "<thing>", local: true }, |
| 31 | "/ct-bare": () => {}, // bare-handler form must normalize to empty desc/args |
| 32 | }, |
| 33 | }); |
| 34 | |
| 35 | // A synthetic takeover app: no chatMessage, chat-place only. |
| 36 | defineApp({ |
| 37 | name: "takeovertest", |
| 38 | title: "Takeover Test", |
| 39 | glyph: "π¨", |
| 40 | places: ["chat"], |
| 41 | initialState: () => ({}), |
| 42 | events: {}, |
| 43 | render: () => {}, |
| 44 | }); |
| 45 | |
| 46 | const report = appCatalogReport(); |
| 47 | const byName = Object.fromEntries(report.map((a) => [a.name, a])); |
| 48 | |
| 49 | // The report is PLAIN JSON β the whole point is that evaluateJavaScript can |
| 50 | // hand it to Swift losslessly. A function or undefined leaking in breaks that. |
| 51 | assert.deepStrictEqual(JSON.parse(JSON.stringify(report)), report, "report must round-trip through JSON unchanged"); |
| 52 | |
| 53 | // Every record carries the full field set, correctly typed. |
| 54 | for (const a of report) { |
| 55 | assert.strictEqual(typeof a.name, "string"); |
| 56 | assert.ok(a.name, "name is never empty"); |
| 57 | assert.strictEqual(typeof a.title, "string"); |
| 58 | assert.strictEqual(typeof a.glyph, "string"); |
| 59 | assert.strictEqual(typeof a.blurb, "string"); |
| 60 | assert.strictEqual(typeof a.requiresService, "string"); |
| 61 | assert.strictEqual(typeof a.openEvent, "string"); |
| 62 | assert.ok(Array.isArray(a.places)); |
| 63 | for (const p of a.places) assert.ok(["you", "chat", "space"].includes(p), `place ${p} unknown`); |
| 64 | for (const k of ["listed", "quiet", "member", "surface", "companion", "chatOnly", "chatReadOnly"]) { |
| 65 | assert.strictEqual(typeof a[k], "boolean", `${a.name}.${k} is a boolean`); |
| 66 | } |
| 67 | assert.ok(["chat", "split", "interface"].includes(a.defaultView), `${a.name}.defaultView normalized`); |
| 68 | assert.strictEqual(typeof a.defaultCommand, "string"); |
| 69 | assert.ok(Array.isArray(a.commands)); |
| 70 | for (const c of a.commands) { |
| 71 | assert.ok(c.name.startsWith("/"), `${a.name} command ${c.name} keeps its slash`); |
| 72 | assert.strictEqual(typeof c.desc, "string"); |
| 73 | assert.strictEqual(typeof c.args, "string"); |
| 74 | assert.strictEqual(typeof c.local, "boolean"); |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | // The companion record, field by field. |
| 79 | const ct = byName.cataloguetest; |
| 80 | assert.ok(ct, "synthetic companion app reported"); |
| 81 | assert.strictEqual(ct.title, "Catalogue Test"); |
| 82 | assert.strictEqual(ct.glyph, "π§ͺ"); |
| 83 | assert.deepStrictEqual(ct.places, ["you", "chat"]); |
| 84 | assert.strictEqual(ct.listed, true); // defaults on: places is non-empty |
| 85 | assert.strictEqual(ct.quiet, true); |
| 86 | assert.strictEqual(ct.member, false); |
| 87 | assert.strictEqual(ct.companion, true); |
| 88 | assert.strictEqual(ct.chatOnly, false); |
| 89 | assert.strictEqual(ct.chatReadOnly, true); |
| 90 | assert.strictEqual(ct.defaultView, "interface"); |
| 91 | assert.strictEqual(ct.defaultCommand, "/ct-add"); |
| 92 | assert.deepStrictEqual( |
| 93 | ct.commands.find((c) => c.name === "/ct"), |
| 94 | { name: "/ct", desc: "Test the catalogue", args: "<thing>", local: false }, |
| 95 | ); |
| 96 | assert.deepStrictEqual( |
| 97 | ct.commands.find((c) => c.name === "/ct-add"), |
| 98 | { name: "/ct-add", desc: "Add locally", args: "<thing>", local: true }, |
| 99 | ); |
| 100 | assert.deepStrictEqual( |
| 101 | ct.commands.find((c) => c.name === "/ct-bare"), |
| 102 | { name: "/ct-bare", desc: "", args: "", local: false }, |
| 103 | ); |
| 104 | |
| 105 | // The takeover record: no chatMessage means no companion, and the unset |
| 106 | // presentation fields land on their documented defaults. |
| 107 | const to = byName.takeovertest; |
| 108 | assert.ok(to, "synthetic takeover app reported"); |
| 109 | assert.strictEqual(to.companion, false); |
| 110 | assert.strictEqual(to.chatOnly, false); |
| 111 | assert.strictEqual(to.chatReadOnly, false); |
| 112 | assert.strictEqual(to.defaultView, "split"); |
| 113 | assert.strictEqual(to.defaultCommand, ""); |
| 114 | |
| 115 | // The real vault: a takeover app (groupFeedMessage only) β the companion gate |
| 116 | // answered from shipped code, not this file's synthetics. |
| 117 | const vault = byName.vault; |
| 118 | assert.ok(vault, "vault registered and reported"); |
| 119 | assert.strictEqual(vault.companion, false, "vault is a takeover app"); |
| 120 | assert.ok(vault.title && vault.glyph, "vault carries identity"); |
| 121 | |
| 122 | // commandItems() serves commandCatalog(threadId); with no thread context the |
| 123 | // rows still come back JSON-plain with the typeahead fields native ranks on. |
| 124 | const rows = commandCatalog(); |
| 125 | assert.deepStrictEqual(JSON.parse(JSON.stringify(rows)), rows, "command rows must round-trip through JSON"); |
| 126 | const ctRow = rows.find((r) => r.name === "/ct"); |
| 127 | assert.ok(ctRow, "global command listed in the catalog"); |
| 128 | assert.strictEqual(typeof ctRow.desc, "string"); |
| 129 | assert.strictEqual(ctRow.args, "<thing>"); |
| 130 | assert.ok(!rows.some((r) => r.name === "/ct-add"), "local commands stay out of the global catalog"); |
| 131 | const me = rows.find((r) => r.name === "/me"); |
| 132 | assert.ok(me && me.bot === false, "/me rides the floor, client-answered"); |
| 133 | |
| 134 | console.log("appcatalog: all assertions passed"); |
| 135 | |