// ๐Ÿค– The app-host protocol's catalogue report (docs/native-apps.md): the // serialized shapes the NATIVE shells consume via window.lchatNative. // appCatalog() / commandItems(). These shapes are a cross-platform contract โ€” // Swift decodes them โ€” so this test pins them against the REAL framework // (defineApp normalization included), not a reconstruction. Widening the // report is additive; renaming or dropping a field here means updating the // native decoders in the same PR. import assert from "assert"; import { defineApp, appCatalogReport, commandCatalog } from "../web/static/apps.js"; // A real shipped app as a sanity row: the vault is the canonical TAKEOVER app // (groupFeedMessage, no chatMessage โ€” docs/building-apps.md), so its record // pins the companion gate against the real thing. import "../web/static/apps/vault.js"; // A synthetic companion app exercising every reported field. defineApp({ name: "cataloguetest", title: "Catalogue Test", glyph: "๐Ÿงช", places: ["you", "chat"], quiet: true, defaultView: "interface", chatReadOnly: true, initialState: () => ({ items: [] }), events: { "cataloguetest.add": (s) => s }, chatMessage: () => null, defaultCommand: "/ct-add", commands: { "/ct": { run: () => {}, desc: "Test the catalogue", args: "" }, "/ct-add": { run: () => {}, desc: "Add locally", args: "", local: true }, "/ct-bare": () => {}, // bare-handler form must normalize to empty desc/args }, }); // A synthetic takeover app: no chatMessage, chat-place only. defineApp({ name: "takeovertest", title: "Takeover Test", glyph: "๐ŸŽจ", places: ["chat"], initialState: () => ({}), events: {}, render: () => {}, }); const report = appCatalogReport(); const byName = Object.fromEntries(report.map((a) => [a.name, a])); // The report is PLAIN JSON โ€” the whole point is that evaluateJavaScript can // hand it to Swift losslessly. A function or undefined leaking in breaks that. assert.deepStrictEqual(JSON.parse(JSON.stringify(report)), report, "report must round-trip through JSON unchanged"); // Every record carries the full field set, correctly typed. for (const a of report) { assert.strictEqual(typeof a.name, "string"); assert.ok(a.name, "name is never empty"); assert.strictEqual(typeof a.title, "string"); assert.strictEqual(typeof a.glyph, "string"); assert.strictEqual(typeof a.blurb, "string"); assert.strictEqual(typeof a.requiresService, "string"); assert.strictEqual(typeof a.openEvent, "string"); assert.ok(Array.isArray(a.places)); for (const p of a.places) assert.ok(["you", "chat", "space"].includes(p), `place ${p} unknown`); for (const k of ["listed", "quiet", "member", "surface", "companion", "chatOnly", "chatReadOnly"]) { assert.strictEqual(typeof a[k], "boolean", `${a.name}.${k} is a boolean`); } assert.ok(["chat", "split", "interface"].includes(a.defaultView), `${a.name}.defaultView normalized`); assert.strictEqual(typeof a.defaultCommand, "string"); assert.ok(Array.isArray(a.commands)); for (const c of a.commands) { assert.ok(c.name.startsWith("/"), `${a.name} command ${c.name} keeps its slash`); assert.strictEqual(typeof c.desc, "string"); assert.strictEqual(typeof c.args, "string"); assert.strictEqual(typeof c.local, "boolean"); } } // The companion record, field by field. const ct = byName.cataloguetest; assert.ok(ct, "synthetic companion app reported"); assert.strictEqual(ct.title, "Catalogue Test"); assert.strictEqual(ct.glyph, "๐Ÿงช"); assert.deepStrictEqual(ct.places, ["you", "chat"]); assert.strictEqual(ct.listed, true); // defaults on: places is non-empty assert.strictEqual(ct.quiet, true); assert.strictEqual(ct.member, false); assert.strictEqual(ct.companion, true); assert.strictEqual(ct.chatOnly, false); assert.strictEqual(ct.chatReadOnly, true); assert.strictEqual(ct.defaultView, "interface"); assert.strictEqual(ct.defaultCommand, "/ct-add"); assert.deepStrictEqual( ct.commands.find((c) => c.name === "/ct"), { name: "/ct", desc: "Test the catalogue", args: "", local: false }, ); assert.deepStrictEqual( ct.commands.find((c) => c.name === "/ct-add"), { name: "/ct-add", desc: "Add locally", args: "", local: true }, ); assert.deepStrictEqual( ct.commands.find((c) => c.name === "/ct-bare"), { name: "/ct-bare", desc: "", args: "", local: false }, ); // The takeover record: no chatMessage means no companion, and the unset // presentation fields land on their documented defaults. const to = byName.takeovertest; assert.ok(to, "synthetic takeover app reported"); assert.strictEqual(to.companion, false); assert.strictEqual(to.chatOnly, false); assert.strictEqual(to.chatReadOnly, false); assert.strictEqual(to.defaultView, "split"); assert.strictEqual(to.defaultCommand, ""); // The real vault: a takeover app (groupFeedMessage only) โ€” the companion gate // answered from shipped code, not this file's synthetics. const vault = byName.vault; assert.ok(vault, "vault registered and reported"); assert.strictEqual(vault.companion, false, "vault is a takeover app"); assert.ok(vault.title && vault.glyph, "vault carries identity"); // commandItems() serves commandCatalog(threadId); with no thread context the // rows still come back JSON-plain with the typeahead fields native ranks on. const rows = commandCatalog(); assert.deepStrictEqual(JSON.parse(JSON.stringify(rows)), rows, "command rows must round-trip through JSON"); const ctRow = rows.find((r) => r.name === "/ct"); assert.ok(ctRow, "global command listed in the catalog"); assert.strictEqual(typeof ctRow.desc, "string"); assert.strictEqual(ctRow.args, ""); assert.ok(!rows.some((r) => r.name === "/ct-add"), "local commands stay out of the global catalog"); const me = rows.find((r) => r.name === "/me"); assert.ok(me && me.bot === false, "/me rides the floor, client-answered"); console.log("appcatalog: all assertions passed");