Lumi/plugins/now_playing/tests/verify.js
2026-07-24 14:44:27 +02:00

123 lines
6.9 KiB
JavaScript

const assert = require("assert");
const fs = require("fs");
const path = require("path");
const root = path.resolve(__dirname, "..");
const repoRoot = path.resolve(root, "..", "..");
const plugin = require(path.join(root, "index.js"));
const test = plugin._test;
assert(test, "Plugin test helpers are unavailable.");
const incoming = test.normalizeIncomingEvent({
protocol_version: 1,
provider: "spotify",
device_id: "device",
session_id: "session",
sequence: 1,
event: "track_changed",
occurred_at: Date.now(),
playback: { status: "playing", position_ms: 2000, duration_ms: 180000, rate: 1 },
track: { key: "song", title: "Song", artist: "Artist", album: "Album", release_year: "2026", link: "https://example.com/song" }
});
assert.equal(incoming.playback.position_ms, 2000);
assert.equal(incoming.playback.duration_ms, 180000);
assert.equal(incoming.track.title, "Song");
const state = test.applyIncomingEvent(null, incoming, null);
assert.equal(state.track_key, "song");
assert.equal(state.playback_status, "playing");
assert.deepEqual(test.announcementTokens(state), {
song_name: "Song",
artist: "Artist",
album: "Album",
release_year: "2026",
link: "https://example.com/song"
});
const paused = test.applyIncomingEvent(state, test.normalizeIncomingEvent({
protocol_version: 1, provider: "spotify", device_id: "device", session_id: "session",
sequence: 2, event: "pause", playback: { status: "paused", position_ms: 8000, duration_ms: 180000, rate: 1 }
}), null);
assert.equal(paused.track_key, "song", "Track metadata should survive a status-only update.");
assert.equal(paused.position_ms, 8000);
const stopped = test.applyIncomingEvent(paused, test.normalizeIncomingEvent({
protocol_version: 1, provider: "spotify", device_id: "device", session_id: "session",
sequence: 3, event: "stop", playback: { status: "stopped", position_ms: 0, duration_ms: 0, rate: 1 }
}), null);
assert.equal(stopped.track_key, null, "A trackless stop event must clear the active song.");
let delegated = false;
const previousFrameworks = global.lumiFrameworks;
global.lumiFrameworks = {
companion: {
requireDevice(req, _res, next) {
delegated = req.marker === "shared-device-auth";
next();
}
}
};
let continued = false;
test.requireCompanionDevice({ marker: "shared-device-auth" }, {}, () => { continued = true; });
assert(delegated && continued, "Song Overlay must delegate authentication to the shared Companion device framework.");
global.lumiFrameworks = {};
let unavailableStatus = 0;
test.requireCompanionDevice({}, {
status(code) { unavailableStatus = code; return this; },
json() { return this; }
}, () => assert.fail("A missing Companion authentication framework must not allow the request."));
assert.equal(unavailableStatus, 503);
global.lumiFrameworks = previousFrameworks;
assert.equal(
test.withAuthenticatedDevice({ lumiDevice: { id: "paired-device" } }, { device_id: "spoofed-device" }).device_id,
"paired-device",
"Song Overlay state must use the authenticated Companion identity rather than a plugin-supplied device ID."
);
for (const file of ["views/admin.ejs", "views/render.ejs", "public/admin.js", "public/render.js", "public/admin.css", "public/render.css"]) {
assert(fs.existsSync(path.join(root, file)), `Missing ${file}`);
}
const serverSource = fs.readFileSync(path.join(root, "index.js"), "utf8");
const adminSource = fs.readFileSync(path.join(root, "views", "admin.ejs"), "utf8");
const renderSource = fs.readFileSync(path.join(root, "public", "render.js"), "utf8");
const renderStyles = fs.readFileSync(path.join(root, "public", "render.css"), "utf8");
const transportContract = fs.readFileSync(path.join(repoRoot, "companion", "src", "Lumi.Companion.Abstractions", "CompanionPluginTransport.cs"), "utf8");
const hostTransport = fs.readFileSync(path.join(repoRoot, "companion", "src", "Lumi.Companion.Core", "CompanionPluginTransport.cs"), "utf8");
const songRuntime = fs.readFileSync(path.join(repoRoot, "companion", "plugins", "Lumi.Companion.SongOverlay", "SongOverlayRuntime.cs"), "utf8");
const songSettings = fs.readFileSync(path.join(repoRoot, "companion", "plugins", "Lumi.Companion.SongOverlay", "SongOverlaySettings.cs"), "utf8");
const companionUi = fs.readFileSync(path.join(repoRoot, "companion", "src", "Lumi.Companion.App", "MainWindow.axaml"), "utf8");
const transcriptionSource = fs.readFileSync(path.join(repoRoot, "plugins", "lumi_transcription", "index.js"), "utf8");
assert(transportContract.includes("ICompanionPluginTransport"));
assert(hostTransport.includes('new AuthenticationHeaderValue(') && hostTransport.includes('"LumiDevice"'));
assert(hostTransport.includes('expectedPrefix = $"/plugins/{_pluginId}/"'), "the host transport must scope plugins to their own server route");
assert(songRuntime.includes("ICompanionPluginTransport") && !songRuntime.includes("SetConnectionKey"));
assert(!songSettings.includes("ProtectedConnectionKey") && !songSettings.includes("LumiBaseUrl"));
assert(!songSettings.includes("DeviceId"));
assert(!companionUi.includes("SongOverlayConnectionKeyBox") && !companionUi.includes("SongOverlayHostBox"));
assert(!serverSource.includes('router.post("/connection-key"') && !serverSource.includes("connection_token_hash"));
assert(!adminSource.includes("Generate connection key"));
assert(transcriptionSource.includes("global.lumiFrameworks.companion = companionFramework"));
assert(serverSource.includes('triggers: ["music"]'), "Song Overlay must register the !music command with Lumi's command router.");
assert(
serverSource.includes("renderAnnouncementMessage({ db, state, user: ctx?.user })")
&& serverSource.includes("renderAnnouncementMessage({ db, state, user: reqUser })"),
"!music and automatic announcements must share one announcement formatter."
);
assert(serverSource.includes('"Access-Control-Allow-Origin": "*"'), "the sandboxed overlay must be able to connect to its token event stream.");
assert(serverSource.includes('writeSse(client.res, "changed"'), "overlay updates should signal clients to re-fetch canonical state.");
assert(renderSource.includes('addEventListener("changed", refreshState)'));
assert(renderSource.includes('addEventListener("error", startFallbackPolling)'), "overlay updates need a fallback when SSE is interrupted.");
assert(renderStyles.includes("color-scheme: normal") && !renderStyles.includes("color-scheme: dark"));
assert(renderStyles.includes("background-color: rgba(0, 0, 0, 0) !important"), "the rendered overlay canvas must be transparent.");
try {
const ejs = require("ejs");
for (const file of ["views/admin.ejs", "views/render.ejs"]) ejs.compile(fs.readFileSync(path.join(root, file), "utf8"), { filename: path.join(root, file) });
console.log("EJS templates compiled.");
} catch (error) {
if (error.code === "MODULE_NOT_FOUND") console.warn("EJS is not installed in this checkout; template compilation was skipped.");
else throw error;
}
console.log("Song Overlay plugin verification passed.");