170 lines
11 KiB
JavaScript
170 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"));
|
|
const runtimeSource = fs.readFileSync(path.join(__dirname, "..", "companion", "src", "Lumi.Companion.App", "CompanionRuntime.cs"), "utf8");
|
|
assert(runtimeSource.includes("UpdateCheckTimeout = TimeSpan.FromMinutes(11)"));
|
|
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;
|
|
});
|