sc-theme-builder/test/integration.js
2026-07-01 20:07:28 -05:00

219 lines
11 KiB
JavaScript

// 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: "<p id='pg'>page</p>", 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 === (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); });