445 lines
28 KiB
JavaScript
445 lines
28 KiB
JavaScript
const assert = require("assert");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const vm = require("vm");
|
|
|
|
const root = path.join(__dirname, "..");
|
|
const sandbox = fs.mkdtempSync(path.join(root, ".tmp-lumi-overlays-"));
|
|
const serviceDir = path.join(sandbox, "src", "services");
|
|
fs.mkdirSync(serviceDir, { recursive: true });
|
|
|
|
for (const file of [
|
|
"config.js",
|
|
"db.js",
|
|
"logger.js",
|
|
"settings.js",
|
|
"web-events.js",
|
|
"overlay-secrets.js",
|
|
"obs-browser-defaults.js",
|
|
"overlay-modules.js",
|
|
"lumi-events.js",
|
|
"overlay-event-hooks.js",
|
|
"overlays.js",
|
|
"overlay-permissions.js",
|
|
"overlay-connectors.js",
|
|
"overlay-chat.js",
|
|
"discord-chat-content.js",
|
|
"twitch-chat-assets.js"
|
|
]) {
|
|
fs.copyFileSync(path.join(root, "src", "services", file), path.join(serviceDir, file));
|
|
}
|
|
|
|
let database = null;
|
|
try {
|
|
database = require(path.join(serviceDir, "db.js"));
|
|
database.migrate();
|
|
const settings = require(path.join(serviceDir, "settings.js"));
|
|
settings.ensureDefaults();
|
|
settings.setSetting("session_secret", "overlay-verification-session-secret");
|
|
const overlays = require(path.join(serviceDir, "overlays.js"));
|
|
const overlayModules = require(path.join(serviceDir, "overlay-modules.js"));
|
|
const permissions = require(path.join(serviceDir, "overlay-permissions.js"));
|
|
const connectors = require(path.join(serviceDir, "overlay-connectors.js"));
|
|
const providerList = connectors.listOverlayConnectorProviders();
|
|
assert(providerList.some((provider) => provider.id === "local_obs_websocket" && provider.available !== false));
|
|
assert(providerList.some((provider) => provider.id === "obs_browser_bridge" && provider.available !== false));
|
|
assert(providerList.some((provider) => provider.id === "trusted_lumi_client" && provider.available === false));
|
|
const bridge = new connectors.ObsBrowserBridgeConnector({ overlay_id: "bridge-test", sync_direction: "obs_to_overlay" });
|
|
const bridgeReport = bridge.acceptReport({
|
|
instance_id: "obs-browser-test",
|
|
plugin_version: "2.24.0",
|
|
control_level: 2,
|
|
current_scene: "Starting Soon",
|
|
scenes: ["Starting Soon", "Live"],
|
|
transitions: ["Cut", "Fade"],
|
|
current_transition: "Fade",
|
|
canvas_width: 1920,
|
|
canvas_height: 1080,
|
|
outputs: { streaming: true },
|
|
source_active: true,
|
|
source_visible: true
|
|
});
|
|
assert.strictEqual(bridgeReport.accepted, true);
|
|
assert.strictEqual(bridgeReport.current_scene, "Starting Soon");
|
|
assert.deepStrictEqual(bridgeReport.scenes, ["Starting Soon", "Live"]);
|
|
assert.strictEqual(bridgeReport.control_level_label, "Read scenes and transitions");
|
|
assert.strictEqual(bridgeReport.profiles_supported, false);
|
|
bridge.disconnect();
|
|
const sourceTypes = overlayModules.listOverlayModuleTypes();
|
|
assert(sourceTypes.some((type) => type.id === "video"));
|
|
assert(sourceTypes.some((type) => type.id === "audio"));
|
|
assert(sourceTypes.some((type) => type.id === "chat"));
|
|
|
|
const created = overlays.createOverlay({ name: "Main Stream", sceneName: "Live" });
|
|
assert.strictEqual(created.scenes.length, 1, "new overlays must receive a first scene");
|
|
assert.strictEqual(created.canvas_width, 1920);
|
|
assert.strictEqual(created.canvas_height, 1080);
|
|
assert(created.public_token.length >= 40, "overlay token must be high entropy");
|
|
assert(created.scenes[0].public_token.length >= 40, "scene token must be high entropy");
|
|
|
|
const resolved = overlays.resolvePublicOverlay(created.public_token);
|
|
assert.strictEqual(resolved.overlay.id, created.id);
|
|
const fixed = overlays.resolvePublicOverlay(created.public_token, created.scenes[0].public_token);
|
|
assert.strictEqual(fixed.fixedScene.id, created.scenes[0].id);
|
|
|
|
const headlineId = overlays.addModule(created.scenes[0].id, {
|
|
name: "Headline",
|
|
type: "text",
|
|
enabled: true,
|
|
config: { text: "Starting soon", x: 5, y: 10, width: 90, height: 20 }
|
|
});
|
|
const soundId = overlays.addModule(created.scenes[0].id, {
|
|
name: "Follower sound",
|
|
type: "audio",
|
|
enabled: true,
|
|
config: { url: "https://example.com/follow.ogg", playBehavior: "manual" }
|
|
});
|
|
const eventHooks = require(path.join(serviceDir, "overlay-event-hooks.js"));
|
|
const followHook = eventHooks.createOverlayEventHook({
|
|
overlayId: created.id,
|
|
eventType: "twitch.follow",
|
|
actionType: "play_once",
|
|
moduleId: soundId,
|
|
enabled: true
|
|
});
|
|
eventHooks.createOverlayEventHook({
|
|
overlayId: created.id,
|
|
eventType: "discord.member_join",
|
|
actionType: "show_temporary",
|
|
moduleId: headlineId,
|
|
durationSeconds: 7.5,
|
|
enabled: true
|
|
});
|
|
assert.strictEqual(eventHooks.listOverlayEventHooks(created.id).length, 2);
|
|
assert.strictEqual(overlays.buildPublicState(created.id).scene.modules.find((module) => module.id === headlineId).config.eventOnly, true);
|
|
const dispatched = eventHooks.dispatchOverlayEvent({ id: "follow-1", type: "twitch.follow", payload: {}, source: "verify", test: false });
|
|
assert.strictEqual(dispatched.matched, 1);
|
|
assert.strictEqual(eventHooks.dispatchOverlayEvent({ id: "follow-1", type: "twitch.follow", payload: {}, source: "verify", test: false }).duplicate, true);
|
|
assert.throws(() => eventHooks.createOverlayEventHook({ overlayId: created.id, eventType: "twitch.raid", actionType: "play_once", moduleId: headlineId }), /requires audio or video source/);
|
|
assert.strictEqual(eventHooks.testOverlayEventHook(followHook.id, created.id).hook.module_id, soundId);
|
|
const state = overlays.buildPublicState(created.id);
|
|
assert.strictEqual(state.scene.modules.length, 2);
|
|
assert.strictEqual(state.scene.modules.find((module) => module.id === headlineId).config.text, "Starting soon");
|
|
overlays.updateModuleTransform(headlineId, { x: 50, y: 50, width: 40, height: 15, anchor: "center" });
|
|
const transformed = overlays.getOverlay(created.id).scenes[0].modules.find((module) => module.id === headlineId);
|
|
assert.strictEqual(transformed.config.anchor, "center");
|
|
assert.strictEqual(transformed.config.text, "Starting soon", "canvas transforms must preserve source content");
|
|
|
|
const textConfig = overlayModules.normalizeModuleConfig("text", {
|
|
horizontalAlign: "right",
|
|
verticalAlign: "bottom",
|
|
overflow: "shrink",
|
|
anchor: "bottom-right"
|
|
});
|
|
assert.strictEqual(textConfig.horizontalAlign, "right");
|
|
assert.strictEqual(textConfig.verticalAlign, "bottom");
|
|
assert.strictEqual(textConfig.overflow, "shrink");
|
|
assert.strictEqual(textConfig.anchor, "bottom-right");
|
|
for (const supportedColor of ["#fff", "#ffff", "#ffffff", "#ffffffff"]) {
|
|
assert.strictEqual(overlayModules.normalizeModuleConfig("text", { color: supportedColor }).color, supportedColor);
|
|
}
|
|
assert.strictEqual(overlayModules.normalizeModuleConfig("text", { color: "#fffff" }).color, "#ffffff");
|
|
const webConfig = overlayModules.normalizeModuleConfig("web", {
|
|
url: "https://example.com/alerts",
|
|
autoRefresh: true,
|
|
healthCheck: true,
|
|
refreshIntervalSeconds: 10,
|
|
retryLimit: 9
|
|
});
|
|
assert.strictEqual(webConfig.autoRefresh, true);
|
|
assert.strictEqual(webConfig.healthCheck, true);
|
|
assert.strictEqual(webConfig.refreshIntervalSeconds, 30);
|
|
assert.strictEqual(webConfig.retryLimit, 5);
|
|
assert.strictEqual(webConfig.injectPageCss, true);
|
|
assert.strictEqual(webConfig.forceTransparentBackground, true);
|
|
assert.strictEqual(webConfig.customCss, "body { background-color: rgba(0, 0, 0, 0); margin: 0px auto; overflow: hidden; }");
|
|
const videoConfig = overlayModules.normalizeModuleConfig("video", {
|
|
url: "https://example.com/alert.webm",
|
|
playBehavior: "loop",
|
|
muted: false,
|
|
volume: 0.65,
|
|
playbackRate: 1.25,
|
|
startAt: 2.5,
|
|
fit: "cover"
|
|
});
|
|
assert.strictEqual(videoConfig.playBehavior, "loop");
|
|
assert.strictEqual(videoConfig.volume, 0.65);
|
|
assert.strictEqual(videoConfig.playbackRate, 1.25);
|
|
assert.strictEqual(videoConfig.startAt, 2.5);
|
|
assert.strictEqual(videoConfig.fit, "cover");
|
|
const audioConfig = overlayModules.normalizeModuleConfig("audio", {
|
|
url: "https://example.com/alert.ogg",
|
|
playBehavior: "manual",
|
|
volume: 5
|
|
});
|
|
assert.strictEqual(audioConfig.playBehavior, "manual");
|
|
assert.strictEqual(audioConfig.showControls, true);
|
|
assert.strictEqual(audioConfig.volume, 1);
|
|
const chatConfig = overlayModules.normalizeModuleConfig("chat", {
|
|
platforms: ["twitch", "discord", "unsupported"],
|
|
channels: "twitch:cozycarnage\ndiscord:community-chat",
|
|
blockedUsers: "twitch:hidden_viewer|viewer-2\nlumi:linked-user|lumi-user-2",
|
|
maxMessages: 100,
|
|
messageTimeoutSeconds: -1,
|
|
background: "#0000",
|
|
messageBackground: "#0f172bcc",
|
|
inAnimation: "scale",
|
|
outAnimation: "slide-down",
|
|
customCss: ".message { border: 1px solid white; }"
|
|
});
|
|
assert.deepStrictEqual(chatConfig.platforms, ["twitch", "discord"]);
|
|
assert.deepStrictEqual(chatConfig.channels, ["twitch:cozycarnage", "discord:community-chat"]);
|
|
assert.deepStrictEqual(chatConfig.blockedUsers, ["twitch:hidden_viewer|viewer-2", "lumi:linked-user|lumi-user-2"]);
|
|
assert.strictEqual(chatConfig.maxMessages, 50);
|
|
assert.strictEqual(chatConfig.messageTimeoutSeconds, 0);
|
|
assert.strictEqual(chatConfig.background, "#0000");
|
|
assert.strictEqual(chatConfig.inAnimation, "scale");
|
|
assert(chatConfig.customCss.includes(".message"));
|
|
assert.strictEqual(overlayModules.normalizeModuleConfig("chat", { inAnimation: "slide-right", outAnimation: "slide-up" }).inAnimation, "slide-right");
|
|
assert.strictEqual(overlayModules.normalizeModuleConfig("chat", { inAnimation: "slide-right", outAnimation: "slide-up" }).outAnimation, "slide-up");
|
|
assert.deepStrictEqual(overlayModules.normalizeModuleConfig("chat", {}).platforms, ["twitch", "youtube", "discord"]);
|
|
const styledWebConfig = overlayModules.normalizeModuleConfig("web", {
|
|
url: "https://example.com/widget",
|
|
cropLeft: 12.5,
|
|
zoom: 1.4,
|
|
customCss: "iframe { filter: grayscale(1); }"
|
|
});
|
|
assert.strictEqual(styledWebConfig.cropLeft, 12.5);
|
|
assert.strictEqual(styledWebConfig.zoom, 1.4);
|
|
assert(styledWebConfig.customCss.endsWith("iframe { filter: grayscale(1); }"));
|
|
assert(styledWebConfig.customCss.startsWith("body { background-color: rgba(0, 0, 0, 0);"));
|
|
assert.strictEqual(overlayModules.normalizeModuleConfig("web", { injectPageCss: false }).injectPageCss, false);
|
|
|
|
overlays.updateOverlay(created.id, { canvasWidth: 1280, canvasHeight: 720 });
|
|
assert.deepStrictEqual(overlays.buildPublicState(created.id).canvas, { width: 1280, height: 720 });
|
|
|
|
const second = overlays.addScene(created.id, { name: "Break", obsSceneName: "BRB" });
|
|
overlays.setActiveScene(created.id, second.id, { source: "verify" });
|
|
assert.strictEqual(overlays.buildPublicState(created.id).scene.id, second.id);
|
|
assert.strictEqual(overlays.buildPublicState(created.id, created.scenes[0].id).scene.name, "Live");
|
|
|
|
const websiteId = overlays.addModule(created.scenes[0].id, {
|
|
name: "Alerts",
|
|
type: "web",
|
|
enabled: true,
|
|
config: { url: "https://alerts.example/widget" }
|
|
});
|
|
const chatId = overlays.addModule(created.scenes[0].id, {
|
|
name: "Combined chat",
|
|
type: "chat",
|
|
enabled: true,
|
|
config: { platforms: ["twitch", "youtube"], maxMessages: 6 }
|
|
});
|
|
const chatState = overlays.buildPublicState(created.id, created.scenes[0].id).scene.modules.find((module) => module.id === chatId);
|
|
assert.strictEqual(chatState.renderType, "chat");
|
|
assert.deepStrictEqual(chatState.config.platforms, ["twitch", "youtube"]);
|
|
const chatDockUrl = overlays.chatDockRenderUrl(chatId);
|
|
const chatDockTicket = chatDockUrl.split("/").at(-1);
|
|
assert(chatDockUrl.startsWith("/overlay-chat/"));
|
|
assert.strictEqual(overlays.resolveChatDockTicket(chatDockTicket).module.id, chatId);
|
|
assert.deepStrictEqual(overlays.resolveChatDockTicket(chatDockTicket).module.config.platforms, ["twitch", "youtube"]);
|
|
const overlayChat = require(path.join(serviceDir, "overlay-chat.js"));
|
|
const normalizedChat = overlayChat.normalizeChatMessage({
|
|
id: "message-1",
|
|
platform: "twitch",
|
|
text: "Hello chat",
|
|
channel: { id: "123", name: "#cozycarnage" },
|
|
author: { id: "viewer-1", name: "Viewer", username: "viewer", color: "#abcdef", badges: ["Subscriber"], lumi: { id: "lumi-user-1", username: "linked-user" } }
|
|
});
|
|
assert.strictEqual(normalizedChat.text, "Hello chat");
|
|
assert.strictEqual(normalizedChat.author.badges[0].label, "Subscriber");
|
|
assert.strictEqual(overlayChat.messageMatchesConfig(normalizedChat, chatState.config), true);
|
|
assert.strictEqual(overlayChat.messageMatchesConfig({ ...normalizedChat, platform: "discord" }, chatState.config), false);
|
|
assert.strictEqual(overlayChat.messageBlockedByConfig(normalizedChat, { blockedUsers: ["twitch:viewer|other-id"] }), true);
|
|
assert.strictEqual(overlayChat.messageBlockedByConfig(normalizedChat, { blockedUsers: ["lumi:linked-user|other-id"] }), true);
|
|
assert.strictEqual(overlayChat.messageBlockedByConfig(normalizedChat, { blockedUsers: ["youtube:viewer"] }), false);
|
|
const mediaOnlyChat = overlayChat.normalizeChatMessage({
|
|
id: "message-gif",
|
|
platform: "discord",
|
|
text: "",
|
|
media: [{ url: "https://cdn.example/dance.gif", type: "image", alt: "Dance" }],
|
|
author: { id: "discord-viewer", name: "Discord viewer" }
|
|
});
|
|
assert.strictEqual(mediaOnlyChat.media[0].url, "https://cdn.example/dance.gif");
|
|
assert.strictEqual(mediaOnlyChat.text, "");
|
|
const { discordOverlayContent } = require(path.join(serviceDir, "discord-chat-content.js"));
|
|
const discordContent = discordOverlayContent({
|
|
content: "Hello <a:party:123456> https://cdn.example/dance.gif",
|
|
attachments: new Map(),
|
|
embeds: [],
|
|
stickers: new Map()
|
|
});
|
|
assert.strictEqual(discordContent.text, "Hello <a:party:123456>");
|
|
assert.strictEqual(discordContent.emotes[0].image, "https://cdn.discordapp.com/emojis/123456.gif?size=96&quality=lossless");
|
|
assert.strictEqual(discordContent.media[0].url, "https://cdn.example/dance.gif");
|
|
const twitchChatAssets = require(path.join(serviceDir, "twitch-chat-assets.js"));
|
|
const betterTtvCatalog = twitchChatAssets.indexBetterTtvCatalog([
|
|
{ id: "emote-kekw", code: "KEKW", imageType: "webp", animated: false },
|
|
{ id: "emote-dance", code: "Dance", imageType: "gif", animated: true }
|
|
]);
|
|
const betterTtvMatches = twitchChatAssets.findBetterTtvEmotes("KEKW hello Dance KEKW!", betterTtvCatalog);
|
|
assert.deepStrictEqual(betterTtvMatches.map((emote) => [emote.start, emote.end, emote.label]), [[0, 3, "KEKW"], [11, 15, "Dance"]]);
|
|
assert.strictEqual(betterTtvMatches[0].image, "https://cdn.betterttv.net/emote/emote-kekw/3x");
|
|
overlayChat.publishOverlayChatMessage(normalizedChat);
|
|
assert(overlayChat.recentOverlayChatMessages(created.id).some((event) => event.payload.id === "message-1"));
|
|
const websiteState = overlays.buildPublicState(created.id, created.scenes[0].id);
|
|
const websiteRenderUrl = websiteState.scene.modules.find((module) => module.id === websiteId).config.renderUrl;
|
|
const websiteTicket = websiteRenderUrl.split("/").at(-1);
|
|
assert(websiteRenderUrl.startsWith("/overlay-web/"));
|
|
assert.strictEqual(overlays.resolveWebSourceTicket(websiteTicket).moduleId, websiteId);
|
|
|
|
const oldOverlayToken = created.public_token;
|
|
const newOverlayToken = overlays.regenerateOverlayToken(created.id);
|
|
assert.strictEqual(overlays.resolvePublicOverlay(oldOverlayToken), null, "regeneration must revoke old overlay tokens");
|
|
assert(overlays.resolvePublicOverlay(newOverlayToken));
|
|
assert.strictEqual(overlays.resolveWebSourceTicket(websiteTicket), null, "overlay URL regeneration must revoke website render tickets");
|
|
assert.strictEqual(overlays.resolveChatDockTicket(chatDockTicket), null, "overlay URL regeneration must revoke chat dock tickets");
|
|
const refreshedWebsiteUrl = overlays.buildPublicState(created.id, created.scenes[0].id).scene.modules.find((module) => module.id === websiteId).config.renderUrl;
|
|
const refreshedWebsiteTicket = refreshedWebsiteUrl.split("/").at(-1);
|
|
assert(overlays.resolveWebSourceTicket(refreshedWebsiteTicket));
|
|
const refreshedChatDockTicket = overlays.chatDockRenderUrl(chatId).split("/").at(-1);
|
|
assert(overlays.resolveChatDockTicket(refreshedChatDockTicket));
|
|
|
|
const refreshed = overlays.getOverlay(created.id, { includeSecrets: true });
|
|
const firstScene = refreshed.scenes.find((scene) => scene.name === "Live");
|
|
const oldSceneToken = firstScene.public_token;
|
|
const newSceneToken = overlays.regenerateSceneToken(firstScene.id);
|
|
assert.strictEqual(overlays.resolvePublicOverlay(newOverlayToken, oldSceneToken), null, "regeneration must revoke old fixed scene tokens");
|
|
assert(overlays.resolvePublicOverlay(newOverlayToken, newSceneToken));
|
|
assert.strictEqual(overlays.resolveWebSourceTicket(refreshedWebsiteTicket), null, "scene URL regeneration must revoke website render tickets");
|
|
assert.strictEqual(overlays.resolveChatDockTicket(refreshedChatDockTicket), null, "scene URL regeneration must revoke chat dock tickets");
|
|
const finalChatDockTicket = overlays.chatDockRenderUrl(chatId).split("/").at(-1);
|
|
overlays.deleteModule(websiteId);
|
|
overlays.deleteModule(chatId);
|
|
assert.strictEqual(overlays.resolveChatDockTicket(finalChatDockTicket), null, "deleting a chat source must revoke its dock ticket");
|
|
|
|
overlays.deleteScene(second.id);
|
|
assert.throws(() => overlays.deleteScene(firstScene.id), /at least one scene/);
|
|
|
|
const duplicate = overlays.duplicateOverlay(created.id);
|
|
assert.notStrictEqual(duplicate.public_token, newOverlayToken);
|
|
assert.strictEqual(duplicate.scenes.length, 1);
|
|
assert.strictEqual(duplicate.scenes[0].modules.length, 2);
|
|
assert.strictEqual(eventHooks.listOverlayEventHooks(duplicate.id).length, 2, "duplicating an overlay must duplicate its event alerts with remapped sources");
|
|
|
|
overlays.saveObsSettings(created.id, {
|
|
enabled: false,
|
|
provider: "local_obs_websocket",
|
|
endpoint: "ws://127.0.0.1:4455",
|
|
password: "verification-secret",
|
|
syncDirection: "bidirectional",
|
|
reconnect: true
|
|
});
|
|
const storedObs = database.db.prepare("SELECT password_encrypted FROM overlay_obs_settings WHERE overlay_id = ?").get(created.id);
|
|
assert(storedObs.password_encrypted && !storedObs.password_encrypted.includes("verification-secret"));
|
|
assert.strictEqual(overlays.getObsSettings(created.id, { includeSecret: true }).password, "verification-secret");
|
|
|
|
assert.strictEqual(permissions.canAccessOverlay({ isAdmin: true }, created.id, "view_secrets"), true);
|
|
assert.strictEqual(permissions.canAccessOverlay({ isMod: true }, created.id, "view_secrets"), false);
|
|
const unregister = permissions.registerOverlayAccessProvider({
|
|
allows: ({ overlayId, capability }) => overlayId === created.id && capability === "manage"
|
|
});
|
|
assert.strictEqual(permissions.canAccessOverlay({ isMod: true }, created.id, "manage"), true);
|
|
assert.strictEqual(permissions.canAccessOverlay({ isMod: true }, created.id, "view_secrets"), false);
|
|
unregister();
|
|
|
|
const editorView = fs.readFileSync(path.join(root, "src", "web", "views", "admin-overlay-detail.ejs"), "utf8");
|
|
const editorScript = fs.readFileSync(path.join(root, "src", "web", "public", "overlay-management.js"), "utf8");
|
|
const runtimeScript = fs.readFileSync(path.join(root, "src", "web", "public", "overlay-runtime.js"), "utf8");
|
|
const rendererScript = fs.readFileSync(path.join(root, "src", "web", "public", "overlay-renderer.js"), "utf8");
|
|
const chatDockScript = fs.readFileSync(path.join(root, "src", "web", "public", "overlay-chat-dock.js"), "utf8");
|
|
const chatDockView = fs.readFileSync(path.join(root, "src", "web", "views", "overlay-chat-dock.ejs"), "utf8");
|
|
const renderView = fs.readFileSync(path.join(root, "src", "web", "views", "overlay-render.ejs"), "utf8");
|
|
const { OBS_BROWSER_DEFAULT_CSS } = require(path.join(root, "src", "services", "obs-browser-defaults.js"));
|
|
const connectorScript = fs.readFileSync(path.join(root, "src", "services", "overlay-connectors.js"), "utf8");
|
|
const twitchBadgeScript = fs.readFileSync(path.join(root, "src", "services", "twitch-chat-assets.js"), "utf8");
|
|
const twitchServiceScript = fs.readFileSync(path.join(root, "src", "services", "twitch.js"), "utf8");
|
|
const twitchEventSubScript = fs.readFileSync(path.join(root, "src", "services", "twitch-eventsub.js"), "utf8");
|
|
const discordServiceScript = fs.readFileSync(path.join(root, "src", "services", "discord.js"), "utf8");
|
|
const serverScript = fs.readFileSync(path.join(root, "src", "web", "server.js"), "utf8");
|
|
const routeScript = fs.readFileSync(path.join(root, "src", "services", "overlay-routes.js"), "utf8");
|
|
assert(editorView.includes("data-overlay-canvas"), "management page must include a visual canvas");
|
|
assert(editorView.includes('data-module-fields="text"') && editorView.includes('data-module-fields="web"'));
|
|
assert(editorView.includes('data-module-fields="video"') && editorView.includes('data-module-fields="audio"'));
|
|
assert(editorView.includes('data-module-fields="chat"') && editorView.includes('name="chat_custom_css"'));
|
|
assert(editorView.includes('name="chat_blocked_users"') && editorView.includes("lumi:internal_name|user-id"));
|
|
assert(editorView.includes("OBS chat dock") && editorView.includes("module.chat_dock_url"));
|
|
assert(editorView.includes('name="custom_css"') && editorView.includes('name="crop_left"'));
|
|
assert(editorView.includes('name="inject_page_css"') && editorView.includes("Apply CSS inside the website"));
|
|
assert(editorView.includes('name="force_transparent_background"') && editorView.includes("Keep the website background transparent"));
|
|
assert(editorView.includes("data-preview-background") && editorView.includes("data-source-revert"));
|
|
assert(editorView.includes("obsBrowserDefaultCss") && editorView.includes("OBS Browser Source setup"));
|
|
assert(editorView.includes("data-color-picker") && editorView.includes("overlay-transform-disclosure"));
|
|
assert(editorView.includes('data-confirm-action="/admin/overlays/<%= overlay.id %>/modules/<%= module.id %>/delete"'), "source deletion must use the shared timed-confirmation action flow");
|
|
assert(editorScript.includes("data-resize-handle") && editorScript.includes("snapTargets"));
|
|
assert(editorScript.includes("previewForm(form)") && editorScript.includes("Live preview · not saved"));
|
|
assert(editorScript.includes("focusCanvas = true") && editorScript.includes("focusCanvas: false"), "draft rerenders must preserve form focus");
|
|
assert(editorScript.includes("--overlay-settings-max-height"), "settings height must follow its viewport position");
|
|
assert(runtimeScript.includes("overlay:module-refresh") && runtimeScript.includes("checkWebsiteSource"));
|
|
assert(runtimeScript.includes("overlay:action") && runtimeScript.includes("playMediaOnce") && runtimeScript.includes("showSourceTemporarily"));
|
|
assert(runtimeScript.includes("overlay:chat-message") && runtimeScript.includes("handleChatMessage"));
|
|
assert(runtimeScript.includes("window.obsstudio") && runtimeScript.includes("overlay:obs-browser-command"));
|
|
assert(runtimeScript.includes("saveReplayBuffer"), "Browser Bridge must support OBS replay saves");
|
|
assert(connectorScript.includes('this.client.call("SaveReplayBuffer")'), "local OBS connector must support replay saves");
|
|
assert(connectorScript.includes('action: "save_replay_buffer"'), "Browser Bridge connector must request replay saves");
|
|
assert(rendererScript.includes("buildMedia") && rendererScript.includes("buildWebsite"));
|
|
assert(rendererScript.includes("buildChat") && rendererScript.includes("chatAccepts"));
|
|
assert(rendererScript.includes("chatUserBlocked") && rendererScript.includes("/icons/platforms/"));
|
|
assert(rendererScript.includes("/icons/badges/"));
|
|
assert(rendererScript.includes("previewChatMessages") && rendererScript.includes("schedulePreviewChat"));
|
|
assert(rendererScript.includes("renderChatDock") && rendererScript.includes("chatDock: true"));
|
|
assert(rendererScript.includes("animation:none !important") && rendererScript.includes("min-height:100vh !important"));
|
|
assert(rendererScript.includes("width:fit-content") && rendererScript.includes("align-self:${alignment}"));
|
|
assert(rendererScript.includes("enter-slide-right") && rendererScript.includes("leave-slide-up"));
|
|
assert(rendererScript.includes('mediaData.type === "video"') && rendererScript.includes('className = "media-row"'));
|
|
assert(rendererScript.includes('frame.setAttribute("allow", "autoplay; fullscreen")'));
|
|
assert(rendererScript.includes('frame.setAttribute("allowtransparency", "true")'));
|
|
assert(rendererScript.includes("values.renderUrl || values.url"));
|
|
assert(!rendererScript.includes('${values.customCss || ""}'), "website CSS must be injected into the page, not scoped to the outer frame");
|
|
assert(rendererScript.includes('className = "lumi-overlay-stage"'), "preview and OBS must share a fixed canvas stage");
|
|
assert.strictEqual(OBS_BROWSER_DEFAULT_CSS, "body { background-color: rgba(0, 0, 0, 0); margin: 0px auto; overflow: hidden; }");
|
|
assert(renderView.includes('<style id="obs-browser-default-css"><%- obsBrowserDefaultCss %></style>'));
|
|
const rendererContext = { window: { matchMedia: () => ({ matches: false }) }, URL };
|
|
vm.runInNewContext(rendererScript, rendererContext);
|
|
const previewLayout = rendererContext.window.LumiOverlayRenderer.stageLayout(
|
|
{ clientWidth: 480, clientHeight: 270 },
|
|
{ width: 1920, height: 1080 }
|
|
);
|
|
const obsLayout = rendererContext.window.LumiOverlayRenderer.stageLayout(
|
|
{ clientWidth: 1920, clientHeight: 1080 },
|
|
{ width: 1920, height: 1080 }
|
|
);
|
|
assert.strictEqual(previewLayout.width, 1920);
|
|
assert.strictEqual(previewLayout.height, 1080);
|
|
assert.strictEqual(previewLayout.scaleX, 0.25);
|
|
assert.strictEqual(previewLayout.scaleY, 0.25);
|
|
assert.strictEqual(obsLayout.scaleX, 1);
|
|
assert.strictEqual(obsLayout.scaleY, 1);
|
|
assert(connectorScript.includes('id: "obs_browser_bridge"') && connectorScript.includes("acceptReport(report"));
|
|
assert(twitchBadgeScript.includes("api.twitch.tv/helix/${path}") && twitchBadgeScript.includes("chat/badges") && twitchBadgeScript.includes("image_url_2x"));
|
|
assert(twitchBadgeScript.includes("resolveTwitchAvatar") && twitchBadgeScript.includes("profile_image_url") && twitchBadgeScript.includes("users?${query}"));
|
|
assert(twitchBadgeScript.includes("api.betterttv.net/3/${path}") && twitchBadgeScript.includes("cached/emotes/global") && twitchBadgeScript.includes("cached/users/twitch/"));
|
|
assert(twitchBadgeScript.includes("channelEmotes") && twitchBadgeScript.includes("sharedEmotes") && twitchBadgeScript.includes("cdn.betterttv.net/emote/"));
|
|
assert(twitchServiceScript.includes("resolveBetterTtvEmotes") && twitchServiceScript.includes("...betterTtvEmotes"));
|
|
assert(twitchEventSubScript.includes('type: "channel.follow"') && twitchEventSubScript.includes('type: "channel.raid"'));
|
|
assert(twitchEventSubScript.includes('type: "channel.subscribe"') && twitchEventSubScript.includes('type: "channel.subscription.gift"'));
|
|
assert(twitchEventSubScript.includes("refreshAccessToken") && twitchEventSubScript.includes("session_reconnect"));
|
|
assert(discordServiceScript.includes('client.on("guildMemberAdd"') && discordServiceScript.includes('emitLumiEvent("discord.member_join"'));
|
|
assert(serverScript.includes('/admin/twitch-events/connect') && serverScript.includes("buildTwitchEventAuthUrl"));
|
|
assert(routeScript.includes("/obs-bridge") && routeScript.includes("/obs/import"));
|
|
assert(routeScript.includes('/overlay-web/:ticket') && routeScript.includes("loadInjectedOverlayDocument"));
|
|
assert(routeScript.includes("/preview") && routeScript.includes("web-preview/:ticket"));
|
|
assert(routeScript.includes('/overlay-chat/:ticket') && routeScript.includes("resolveChatDockTicket"));
|
|
assert(routeScript.includes("/event-hooks/:hookId/test") && routeScript.includes("testOverlayEventHook"));
|
|
assert(editorView.includes("Event alerts") && editorView.includes("data-event-hook-form"));
|
|
assert(editorView.includes('data-confirm-action="/admin/overlays/<%= overlay.id %>/event-hooks/<%= hook.id %>/delete"'));
|
|
assert(routeScript.includes("validate: () => Boolean(chatDockContext(req))"));
|
|
assert(chatDockView.includes("lumi-chat-dock-root") && chatDockView.includes("overlay-chat-dock.js"));
|
|
assert(chatDockScript.includes("renderChatDock") && chatDockScript.includes("overlay:chat-message"));
|
|
|
|
console.log("Overlay verification passed: CRUD, fixed-canvas preview parity, transparent OBS defaults, layout controls, external recovery, token revocation, encrypted OBS credentials, and permissions.");
|
|
} finally {
|
|
database?.db.close();
|
|
fs.rmSync(sandbox, { recursive: true, force: true });
|
|
}
|