123 lines
6.9 KiB
JavaScript
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.");
|