Lumi/scripts/verify-local-development-updates.js
2026-07-24 14:44:27 +02:00

168 lines
11 KiB
JavaScript

const assert = require("assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { EventEmitter } = require("events");
const { PassThrough } = require("stream");
const {
DevelopmentUpdateService
} = require("../src/services/development-updates");
const {
isStrictLoopbackRequest,
resolveRuntimeEnvironment
} = require("../src/services/runtime-environment");
const {
sameLocalhostOrigin
} = require("../plugins/lumi_transcription/backend/companion/device_store");
async function main() {
const companionManifest = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "plugins", "lumi_transcription", "companion_manifest.json"), "utf8"));
const transcriptionManifest = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "plugins", "lumi_transcription", "plugin.json"), "utf8"));
const companionProject = fs.readFileSync(path.join(__dirname, "..", "companion", "src", "Lumi.Companion.App", "Lumi.Companion.App.csproj"), "utf8");
const installerSource = fs.readFileSync(path.join(__dirname, "..", "companion", "installer", "Lumi.Companion.iss"), "utf8");
const publisherSource = fs.readFileSync(path.join(__dirname, "..", "companion", "scripts", "publish-companion.ps1"), "utf8");
const companionAppSource = fs.readFileSync(path.join(__dirname, "..", "companion", "src", "Lumi.Companion.App", "App.axaml.cs"), "utf8");
const iconFactorySource = fs.readFileSync(path.join(__dirname, "..", "companion", "src", "Lumi.Companion.App", "LumiIconFactory.cs"), "utf8");
const iconPath = path.join(__dirname, "..", "companion", "src", "Lumi.Companion.App", "Assets", "Lumi.Companion.ico");
const projectVersion = /<Version>([^<]+)<\/Version>/.exec(companionProject)?.[1];
const installerVersion = /#define AppVersion "([^"]+)"/.exec(installerSource)?.[1];
const publisherVersion = /\[string\]\$Version = "([^"]+)"/.exec(publisherSource)?.[1];
assert.equal(companionManifest.version, projectVersion, "the published Companion manifest and executable version must match");
assert.equal(transcriptionManifest.version, projectVersion, "the transcription plugin and Companion release version must match");
assert.equal(installerVersion, projectVersion, "the installer default version must match the Companion executable");
assert.equal(publisherVersion, projectVersion, "the release publisher default version must match the Companion executable");
assert(companionProject.includes("<ApplicationIcon>Assets\\Lumi.Companion.ico</ApplicationIcon>"));
assert(companionProject.includes('<AvaloniaResource Include="Assets\\Lumi.Companion.ico"'));
assert.deepEqual([...fs.readFileSync(iconPath).subarray(0, 4)], [0, 0, 1, 0], "the Companion Windows icon must be a valid ICO resource");
assert(iconFactorySource.includes("Icons.TryGetValue(health"), "tray health icons must be cached instead of recreated for every state event");
assert(companionAppSource.includes("if (state.Health != renderedHealth)"), "the native tray icon must only be assigned when its color state changes");
assert(companionAppSource.includes("CompanionState? pendingState"), "rapid tray state notifications must be coalesced on the UI thread");
for (const [directive, filename] of [
["LicenseFile", "LUMI-COMPANION-LICENCE.txt"],
["InfoBeforeFile", "PRIVACY-NOTICE.txt"],
["InfoAfterFile", "THIRD-PARTY-NOTICES.txt"]
]) {
assert(
installerSource.includes(`${directive}={#SourceRoot}\\legal\\${filename}`),
`${filename} must be presented by the Companion installer`
);
assert(fs.existsSync(path.join(__dirname, "..", "companion", "legal", filename)), `${filename} must exist`);
}
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-dev-updates-"));
try {
fs.mkdirSync(path.join(temp, ".git"));
fs.mkdirSync(path.join(temp, "companion", "src"), { recursive: true });
fs.mkdirSync(path.join(temp, "companion", "legal"), { recursive: true });
fs.mkdirSync(path.join(temp, "companion", "scripts"), { recursive: true });
fs.mkdirSync(path.join(temp, "companion", "plugins", "Song"), { recursive: true });
fs.mkdirSync(path.join(temp, "companion", "plugins", "Transcript"), { recursive: true });
fs.mkdirSync(path.join(temp, "plugins", "now_playing"), { recursive: true });
fs.writeFileSync(path.join(temp, "companion", "Lumi.Companion.sln"), "solution");
fs.writeFileSync(path.join(temp, "companion", "src", "App.cs"), "core-a");
fs.writeFileSync(path.join(temp, "companion", "legal", "notice.txt"), "notice-a");
fs.writeFileSync(path.join(temp, "companion", "scripts", "publish-dev-update.ps1"), "# publisher-a");
fs.writeFileSync(path.join(temp, "companion", "plugins", "Song", "Plugin.cs"), "song-a");
fs.writeFileSync(path.join(temp, "companion", "plugins", "Song", "plugin.json"), JSON.stringify({ id: "now_playing" }));
fs.writeFileSync(path.join(temp, "companion", "plugins", "Transcript", "Plugin.cs"), "transcript-a");
fs.writeFileSync(path.join(temp, "plugins", "now_playing", "index.js"), "server-a");
const sourceMode = resolveRuntimeEnvironment({ repoRoot: temp, env: {} });
assert.equal(sourceMode.isDevelopment, true);
assert.equal(resolveRuntimeEnvironment({ repoRoot: temp, env: { NODE_ENV: "production" } }).isProduction, true);
assert.equal(resolveRuntimeEnvironment({ repoRoot: temp, env: { LUMI_DEV_MODE: "1", NODE_ENV: "production" } }).isDevelopment, true);
assert.equal(resolveRuntimeEnvironment({ repoRoot: temp, env: { LUMI_DEV_MODE: "0" } }).isProduction, true);
assert.equal(isStrictLoopbackRequest({ hostname: "localhost", socket: { remoteAddress: "127.0.0.1" } }), true);
assert.equal(isStrictLoopbackRequest({ hostname: "localhost", socket: { remoteAddress: "192.168.1.50" } }), false);
assert.equal(isStrictLoopbackRequest({ hostname: "lumi.local", socket: { remoteAddress: "127.0.0.1" } }), false);
assert.equal(sameLocalhostOrigin("http://localhost:3000", "http://localhost:3000"), true);
assert.equal(sameLocalhostOrigin("https://localhost:3443", "https://localhost:3443"), true);
assert.equal(sameLocalhostOrigin("http://localhost:3000", "http://127.0.0.1:3000"), false);
const service = new DevelopmentUpdateService({ repoRoot: temp, cacheRoot: path.join(temp, "data", "development-updates"), runtime: sourceMode });
const first = service.inventory();
assert(first.components.some((entry) => entry.id === "companion:core"));
assert(
first.components.find((entry) => entry.id === "companion:core").paths.includes("companion/scripts/publish-dev-update.ps1"),
"changes to the localhost publisher must invalidate the cached Companion artifact"
);
assert(first.components.some((entry) => entry.id === "companion-plugin:Song"));
assert(first.components.some((entry) => entry.id === "lumi-plugin:now_playing"));
const firstMap = Object.fromEntries(first.updatable_components.map((entry) => [entry.id, entry.checksum]));
assert.equal(service.compare({ build_checksum: first.aggregate_checksum, component_checksums: firstMap }).update_available, false);
fs.writeFileSync(path.join(temp, "companion", "plugins", "Song", "Plugin.cs"), "song-b");
const second = service.compare({ build_checksum: first.aggregate_checksum, component_checksums: firstMap });
assert.equal(second.update_available, true);
assert.deepEqual(second.changed_components.map((entry) => entry.id), ["companion-plugin:Song"]);
fs.mkdirSync(path.join(temp, "companion", "plugins", "Song", "bin"), { recursive: true });
fs.writeFileSync(path.join(temp, "companion", "plugins", "Song", "bin", "ignored.dll"), "volatile");
const third = service.inventory();
assert.equal(third.aggregate_checksum, second.aggregate_checksum);
const beforeServerChange = third.aggregate_checksum;
const beforeServerChecksum = third.components.find((entry) => entry.id === "lumi-plugin:now_playing").checksum;
fs.writeFileSync(path.join(temp, "plugins", "now_playing", "index.js"), "server-b");
const fourth = service.compare({
build_checksum: beforeServerChange,
component_checksums: Object.fromEntries(third.tracked_components.map((entry) => [entry.id, entry.checksum]))
});
assert.notEqual(fourth.aggregate_checksum, beforeServerChange, "a Companion-related Lumi plugin change must be surfaced to the Companion");
assert.notEqual(fourth.components.find((entry) => entry.id === "lumi-plugin:now_playing").checksum, beforeServerChecksum);
assert(fourth.changed_components.some((entry) => entry.id === "lumi-plugin:now_playing"));
const fakeSpawn = (_command, args) => {
const child = new EventEmitter();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.kill = () => child.emit("exit", 1);
setImmediate(() => {
const outputIndex = args.indexOf("-OutputArchive");
const output = args[outputIndex + 1];
fs.mkdirSync(path.dirname(output), { recursive: true });
fs.writeFileSync(output, "fake-local-development-archive");
child.emit("exit", 0);
});
return child;
};
const buildService = new DevelopmentUpdateService({
repoRoot: temp,
cacheRoot: path.join(temp, "data", "development-updates"),
runtime: sourceMode,
spawn: fakeSpawn,
platform: "win32"
});
const built = await buildService.buildCompanionArtifact(fourth);
assert.equal(built.build_id, fourth.aggregate_checksum);
assert.equal(buildService.readArtifact(built.build_id).sha256, built.sha256);
assert.equal((await buildService.buildCompanionArtifact(fourth)).path, built.path, "the checksum-addressed build should be reused");
const serviceSource = fs.readFileSync(path.join(__dirname, "..", "src", "services", "development-updates.js"), "utf8");
assert(!/exec(?:File|Sync)?\([^\n]*git\b/i.test(serviceSource), "local development updates must not invoke Git");
const transcriptionSource = fs.readFileSync(path.join(__dirname, "..", "plugins", "lumi_transcription", "index.js"), "utf8");
assert(transcriptionSource.includes("allowsLocalDevelopmentUpdates"));
assert(transcriptionSource.includes("dev-artifact/:buildId"));
const updateSource = fs.readFileSync(path.join(__dirname, "..", "companion", "src", "Lumi.Companion.App", "UpdateService.cs"), "utf8");
assert(updateSource.includes("DevelopmentBuildManifest.Load()"));
assert(updateSource.includes("update.Development"));
assert(updateSource.includes("SameOrigin"));
assert(updateSource.includes("SupportsPendingBuild"));
assert(updateSource.includes("BuildPending"));
assert(transcriptionSource.includes("supports_pending_build"));
assert(transcriptionSource.includes("build_pending"));
const applierSource = fs.readFileSync(path.join(__dirname, "..", "companion", "src", "Lumi.Companion.App", "UpdateApplier.cs"), "utf8");
assert(applierSource.includes(".lumi-dev-build.json"));
console.log("Local development update verification passed: global runtime mode, strict loopback gating, per-plugin checksums, ignored build output, Companion-related server-plugin synchronization, Git-free comparison, and Companion same-version update flow.");
} finally {
fs.rmSync(temp, { recursive: true, force: true });
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});