const assert = require("node:assert/strict"); const { readFileSync } = require("node:fs"); const { join } = require("node:path"); const { test } = require("node:test"); const vm = require("node:vm"); const assets = join(__dirname, "../../apps/backend/chatballs/webchat/loader_assets"); function context() { const ctx = vm.createContext({ URL, origin: "https://widget.example" }); vm.runInContext(readFileSync(join(assets, "appearance.js"), "utf8"), ctx); vm.runInContext(readFileSync(join(assets, "genie.js"), "utf8"), ctx); return ctx; } test("launcher configuration covers both sides, all sizes and all shapes", () => { const ctx = context(); for (const position of ["left", "right"]) { for (const size of [48, 56, 64]) { for (const shape of ["circle", "rounded", "square"]) { ctx.appearance = ctx.readAppearance({ available: true, appearance: { accent: "#0d8a7e", launcherPosition: position, launcherSize: size, launcherShape: shape, launcherIcon: "/api/v1/webchat/assets/icon/", } }); assert.equal(ctx.appearance.position, position); assert.equal(ctx.appearance.size, size); assert.equal(ctx.appearance.shape, shape); assert.deepEqual(Array.from(ctx.appearance.rgb), [13, 138, 126]); assert.equal(ctx.appearance.icon, "https://widget.example/api/v1/webchat/assets/icon/"); assert.equal(ctx.launcherRadius(), shape === "square" ? 10 : size * (shape === "rounded" ? .3 : .5)); } } } }); test("legacy accent works and absent configuration leaves the default launcher", () => { const ctx = context(); const legacy = ctx.readAppearance({ available: true, accent: "#BE123C" }); assert.deepEqual(Array.from(legacy.rgb), [190, 18, 60]); assert.equal(legacy.position, "right"); assert.equal(legacy.size, 56); assert.equal(legacy.shape, "circle"); for (const response of [null, {}, { available: false }, { available: false, appearance: {} }]) { assert.equal(ctx.readAppearance(response), null); } assert.equal(ctx.appearance.accent, "#1677ff"); }); test("invalid configuration is rejected before any appearance is applied", () => { const ctx = context(); for (const appearance of [null, [], "text", { accent: "red" }, { launcherSize: 50 }, { launcherSize: true }, { launcherPosition: "top" }, { launcherShape: "oval" }, { launcherIcon: 42 }, { launcherIcon: "javascript:alert(1)" }, { launcherIcon: "//evil.example/icon.svg" }, { launcherIcon: "data:image/svg+xml," }, { launcherIcon: '/icon.svg" onerror="alert(1)' }]) { assert.throws(() => ctx.readAppearance({ available: true, appearance })); assert.equal(ctx.appearance.accent, "#1677ff"); } }); test("genie geometry mirrors across the viewport and ends in the selected launcher shape", () => { const ctx = context(); const viewport = 1280; const panel = { top: 100, bottom: 708, cx: 1036, half: 220, radius: 24 }; for (const size of [48, 56, 64]) { for (const radius of [size / 2, size * .3, 10]) { const icon = { top: 720, bottom: 720 + size, cx: 1224, half: size / 2, radius }; for (const p of [0, .25, .5, .75, 1]) { const right = ctx.shapeAt(p, panel, icon); const left = ctx.shapeAt(p, { ...panel, cx: viewport - panel.cx }, { ...icon, cx: viewport - icon.cx }); assert.ok(Math.abs(left.topL - (viewport - right.topRt)) < 1e-9); assert.ok(Math.abs(left.bottomRt - (viewport - right.bottomL)) < 1e-9); assert.equal(left.topY, right.topY); assert.equal(left.bottomY, right.bottomY); assert.ok(!/NaN|Infinity/.test(ctx.pathOf(left, 0, 0))); if (p === 1) { assert.equal(left.topR, radius); assert.equal(left.bottomR, radius); } } } } }); test("all loader modules assemble into one valid standalone script", () => { let source = readFileSync(join(assets, "runtime.js"), "utf8"); for (const module of ["site_fields", "appearance", "launcher", "genie"]) { source = source.replace(`/*__${module.toUpperCase()}__*/`, () => readFileSync(join(assets, `${module}.js`), "utf8")); } assert.doesNotThrow(() => new vm.Script(source)); });