// In-process integration smoke test against a REAL Saltcorn DB (not the unit // suite -- needs @saltcorn + an initialized DB, so it is NOT a *.test.js file). // // ./test/runIntegration.sh (self-contained: throwaway DB + NODE_PATH) // // Registers theme-builder by location (runs the real onLoad -> Table.create), // then drives the actual apiHandlers with mock req/res through the full Phase-1 // lifecycle. Exits non-zero on the first failure. const path = require("path"); const db = require("@saltcorn/data/db"); const { getState } = require("@saltcorn/data/db/state"); const Plugin = require("@saltcorn/data/models/plugin"); const Table = require("@saltcorn/data/models/table"); const PLUGIN_DIR = path.resolve(__dirname, ".."); const { PLUGIN_NAME, THEME_TABLE } = require("../lib/constants"); let failures = 0; function ok(label, cond, detail) { const tag = cond ? "PASS" : "FAIL"; if (!cond) failures += 1; console.log(` [${tag}] ${label}${detail ? " -- " + detail : ""}`); } function mkRes() { const r = { statusCode: 200, headers: {}, body: undefined }; r.status = (c) => { r.statusCode = c; return r; }; r.json = (o) => { r.body = o; r._json = o; return r; }; r.send = (s) => { r.body = s; r._sent = s; return r; }; r.set = (k, v) => { if (k && typeof k === "object") Object.assign(r.headers, k); else r.headers[String(k).toLowerCase()] = v; return r; }; r.get = (k) => r.headers[String(k).toLowerCase()]; r.redirect = (u) => { r._redirect = u; return r; }; r.end = () => { r._ended = true; return r; }; return r; } function mkReq(over) { return Object.assign({ user: { role_id: 1, tenant: db.getTenantSchema(), email: "admin@test" }, params: {}, query: {}, body: {}, headers: {}, xhr: true, path: "/theme-builder/api/x", originalUrl: "/theme-builder/api/x", get: () => undefined, csrfToken: () => "testcsrf", }, over); } async function step(label, fn) { try { await fn(); } catch (e) { failures += 1; console.log(` [FAIL] ${label} THREW -- ${e && (e.stack || e.message)}`); } } async function main() { console.log(`DB = ${process.env.SQLITE_FILEPATH}`); // --- bootstrap state + register the plugin by location (runs onLoad) --- await Plugin.loadAllPlugins(); const plugin = new Plugin({ name: PLUGIN_NAME, source: "local", location: PLUGIN_DIR, configuration: {} }); await Plugin.loadAndSaveNewPlugin(plugin, true, false); console.log("registered theme-builder; cfg =", JSON.stringify(getState().plugin_cfgs[PLUGIN_NAME] || {})); const h = require("../lib/apiHandlers"); const liveCfg = () => getState().plugin_cfgs[PLUGIN_NAME] || {}; console.log("\n# onLoad / table"); await step("ensureTable created the themes Table", async () => { const t = Table.findOne({ name: THEME_TABLE }); ok("Table registered in _sc_tables", !!t, t ? `id=${t.id}` : "missing"); }); console.log("\n# GET /api/state"); let createdId; await step("buildState lists builtins", async () => { const res = mkRes(); await h.getState_(mkReq(), res); const themes = res.body && res.body.themes; ok("state returns themes array", Array.isArray(themes), `count=${themes && themes.length}`); ok("builtins present (>=3)", themes && themes.filter((x) => x.builtin).length >= 3); }); console.log("\n# create -> save -> activate"); await step("create a theme", async () => { const res = mkRes(); await h.createTheme(mkReq({ body: { name: "My Theme" } }), res); ok("create returns a theme with uuid id + version 1", !!(res.body && res.body.theme && res.body.theme.id && res.body.theme.version === 1), JSON.stringify(res.body && res.body.theme)); createdId = res.body && res.body.theme && res.body.theme.id; }); await step("save tokens (CAS on version)", async () => { const res = mkRes(); const tokens = { $tokensVersion: 1, colors: { primary: "#abcdef" } }; await h.saveTheme(mkReq({ params: { id: createdId }, body: { tokens, baseVersion: 1 } }), res); ok("save bumps version to 2", !!(res.body && res.body.theme && res.body.theme.version === 2), JSON.stringify(res.body && (res.body.theme || res.body.error))); }); await step("stale save -> 409 version_conflict", async () => { const res = mkRes(); await h.saveTheme(mkReq({ params: { id: createdId }, body: { tokens: { colors: {} }, baseVersion: 1 } }), res); ok("stale baseVersion rejected with 409", res.statusCode === 409 && res.body.error && res.body.error.code === "version_conflict", `status=${res.statusCode}`); }); await step("activate the theme (the only live-mutating op)", async () => { const res = mkRes(); await h.activateTheme(mkReq({ params: { id: createdId } }), res); ok("activate ok + pointer set", !!(res.body && res.body.ok && res.body.activeThemeId === createdId), JSON.stringify(res.body)); ok("activeThemeId persisted to cfg", liveCfg().activeThemeId === createdId, `cfg.activeThemeId=${liveCfg().activeThemeId}`); ok("activeHash written", !!liveCfg().activeHash, `hash=${liveCfg().activeHash}`); }); console.log("\n# GET /theme.css (public)"); await step("serve active overlay CSS", async () => { const res = mkRes(); await h.getThemeCss(mkReq({ query: {}, user: undefined }), res, liveCfg()); const css = res.body || ""; ok("returns text/css", (res.get("content-type") || "").includes("text/css")); ok("css contains the saved --bs-primary", css.includes("--bs-primary: #abcdef;"), css.slice(0, 80)); ok("deep overlay recolors buttons", css.includes(".btn-primary{") && css.includes("--bs-btn-bg:#abcdef;")); ok("braces balanced", (css.match(/{/g) || []).length === (css.match(/}/g) || []).length); }); console.log("\n# export -> import"); let exportedJson; await step("export the theme", async () => { const res = mkRes(); await h.exportTheme(mkReq({ params: { id: createdId } }), res); const raw = typeof res.body === "string" ? res.body : JSON.stringify(res.body); exportedJson = raw; const env = JSON.parse(raw); ok("envelope has $schema + tokens", env.$schema === "saltcorn-theme" && !!env.tokens, env.$schema); ok("Content-Disposition attachment set", (res.get("content-disposition") || "").includes("attachment")); }); await step("import the exported envelope -> fresh id", async () => { const res = mkRes(); await h.importTheme(mkReq({ body: JSON.parse(exportedJson) }), res); ok("import creates a new theme", !!(res.body && res.body.theme && res.body.theme.id && res.body.theme.id !== createdId), JSON.stringify(res.body && (res.body.theme || res.body.error))); }); console.log("\n# activate a BUILTIN (seam fix)"); await step("activate builtin:darkly", async () => { const res = mkRes(); await h.activateTheme(mkReq({ params: { id: "builtin:darkly" } }), res); ok("builtin activate ok", !!(res.body && res.body.ok), JSON.stringify(res.body)); const cssRes = mkRes(); await h.getThemeCss(mkReq({ query: {}, user: undefined }), cssRes, liveCfg()); ok("builtin CSS served (darkly primary #375a7f)", String(cssRes.body || "").includes("--bs-primary: #375a7f;"), String(cssRes.body || "").slice(0, 80)); }); console.log("\n# preview-css (editor WYSIWYG, same compiler as production)"); await step("preview-css compiles draft tokens incl. deep button overlay", async () => { const res = mkRes(); await h.previewCss(mkReq({ body: { tokens: { colors: { primary: "#e83e8c" } } } }), res); const css = res.body || ""; ok("returns text/css no-store", (res.get("content-type") || "").includes("text/css") && res.get("cache-control") === "no-store"); ok("preview recolors .btn-primary to draft color", css.includes(".btn-primary{") && css.includes("--bs-btn-bg:#e83e8c;")); }); console.log("\n# LAYOUT MODE: enable -> register as layout -> wrap() renders the page"); const activePointer = require("../lib/activePointer"); const cfgReaders = require("../lib/cfgReaders"); const { getPreset } = require("../lib/layoutTree"); let p2Id; await step("enable layout mode registers theme-builder as a layout", async () => { await activePointer.setActivePointer({ layoutMode: true }); ok("cfg.layoutMode is true", liveCfg().layoutMode === true); ok("registered in getState().layouts", !!getState().layouts[PLUGIN_NAME]); }); await step("create + save a theme with a sidebar layout tree, then activate", async () => { let res = mkRes(); await h.createTheme(mkReq({ body: { name: "P2 Theme" } }), res); p2Id = res.body.theme.id; res = mkRes(); await h.saveTheme(mkReq({ params: { id: p2Id }, body: { tokens: { colors: { primary: "#112233" } }, layoutTree: getPreset("sidebar"), baseVersion: 1 } }), res); ok("save persisted layoutTree", !!(res.body.theme && res.body.theme.layoutTree), JSON.stringify(res.body.theme && res.body.theme.layoutTree ? res.body.theme.layoutTree.type : res.body.error)); res = mkRes(); await h.activateTheme(mkReq({ params: { id: p2Id } }), res); ok("activate (layout mode) persisted activeLayoutTree", !!liveCfg().activeLayoutTree, liveCfg().activeLayoutTree && liveCfg().activeLayoutTree.type); }); await step("layout(cfg).wrap() renders the full themed document", async () => { const L = require("../lib/layout").layout(liveCfg()); ok("layout(cfg) returns a PluginLayout", !!(L && typeof L.wrap === "function")); const html = L.wrap({ title: "Home", body: "
page
", brand: { name: "Acme" }, menu: [{ items: [{ label: "Home", link: "/" }] }], alerts: [{ type: "success", msg: "ok" }], headers: [], role: 1, currentUrl: "/", }); ok("links vendored Bootstrap", html.includes("/themeBootstrap.min.css")); ok("links the theme overlay css", html.includes("/theme-builder/theme.css?v=")); ok("renders the layout tree (tb-root + sidebar)", html.includes("id=\"tb-root\"") && html.includes("tb-sidebar")); ok("injects the page body + brand", html.includes("id='pg'") && html.includes("Acme")); ok("angle brackets balanced", (html.match(//g) || []).length); }); await step("Phase-2 overlay header is suppressed for theme-builder-served requests", async () => { const req = mkReq({ user: { role_id: 1, tenant: db.getTenantSchema() } }); const rendersTB = cfgReaders.requestRendersViaThemeBuilder(req); const hdrs = require("../lib/headers").headers(liveCfg()); ok("overlay only_if returns boolean", typeof hdrs[0].only_if(req) === "boolean"); ok("overlay suppressed exactly when rendered via theme-builder", hdrs[0].only_if(req) === !rendersTB, `rendersTB=${rendersTB}`); }); console.log(`\n==== ${failures === 0 ? "ALL GREEN" : failures + " FAILURE(S)"} ====`); process.exit(failures === 0 ? 0 : 1); } main().catch((e) => { console.error("HARNESS CRASH:", e && (e.stack || e.message)); process.exit(2); });