diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index dd0f477a6..4f4943bcf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -378,6 +378,13 @@ jobs: - name: Stage ONNX Runtime run: npm run fetch:onnxruntime + # Fourth step spelled out for the same reason: `build:mac`, `build:win` and + # `build:linux` chain it before electron-builder. It writes the licence texts of the + # bundled npm packages and of the crates in the addon; scripts/before-pack.cjs + # refuses to package without them. + - name: Generate third-party licences + run: npm run licenses:generate + - name: Package .app bundle run: npx electron-builder --mac --${{ matrix.arch }} --dir --publish never env: diff --git a/.gitignore b/.gitignore index 8b2682b6e..16d17f3ac 100644 --- a/.gitignore +++ b/.gitignore @@ -72,6 +72,10 @@ result-* # Auto-caption model + ORT wasm — regenerated at build by scripts/fetch-caption-model.mjs /caption-assets/ +# Third-party licence texts, regenerated before every package by +# scripts/generate-third-party-licenses.mjs (`npm run licenses:generate`). +/build-licenses/ + # Native STT artifacts — whisper-stt-server binaries + ggml backend sidecars. /electron/native/bin/ /electron/native/models/ diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index f80ec2361..e7c74448d 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -5,8 +5,9 @@ bundle the third-party components below. This file ships inside the application resources and satisfies the attribution and source-offer obligations that come with them. -npm dependencies are not listed here: they are resolved from `package.json` and -distributed by their own registries, not redistributed inside our binaries. +npm dependencies are not listed here. Their code ships bundled inside the +application by Vite, and their licence texts are in `THIRD-PARTY-LICENSES.txt`, +generated at build time and shipped beside this file. --- @@ -182,6 +183,8 @@ distributed by their own registries, not redistributed inside our binaries. - **Licenses**: as each crate publishes them on crates.io. Where a crate offers a choice, OpenScreen uses it under MIT or Apache-2.0. Each crate's licence text is in its source package at the version listed. +- **Full texts**: the licence and NOTICE files of every crate compiled into the + addon are in `THIRD-PARTY-LICENSES.txt`, shipped beside this file. - **MIT OR Apache-2.0** (155): android_system_properties 0.1.5, anyhow 1.0.103, arrayvec 0.7.8, ash 0.38.0+1.3.281, autocfg 1.5.1, bit-set 0.8.0, bit-vec 0.8.0, bitflags 1.3.2, bitflags 2.13.1, bumpalo 3.20.3, cc 1.2.67, cexpr 0.6.0, cfg-if diff --git a/electron-builder.json5 b/electron-builder.json5 index 8da5be310..c5733ff9e 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -126,6 +126,14 @@ "from": "THIRD-PARTY-NOTICES.md", "to": "THIRD-PARTY-NOTICES.md" }, + // The full licence texts of the npm packages Vite bundles and of the crates compiled into + // the compositor addon. Generated, not committed: every build:* script and build.yml's + // macOS job run `npm run licenses:generate` first. electron-builder only warns about a + // missing source here, so scripts/before-pack.cjs is what refuses to package without it. + { + "from": "build-licenses/THIRD-PARTY-LICENSES.txt", + "to": "THIRD-PARTY-LICENSES.txt" + }, { "from": "LICENSE", "to": "LICENSE" diff --git a/package.json b/package.json index f707b8dbc..bb6c7956c 100644 --- a/package.json +++ b/package.json @@ -33,25 +33,26 @@ ], "scripts": { "dev": "vite", - "build": "tsc && vite build && electron-builder", + "build": "tsc && vite build && npm run licenses:generate && electron-builder", "lint": "biome check .", "lint:fix": "biome check --write .", "format": "biome format --write .", "i18n:check": "node scripts/i18n-check.mjs", "docs:check": "node scripts/check-docs.mjs", "assets:appx": "node scripts/generate-appx-assets.mjs", + "licenses:generate": "node scripts/generate-third-party-licenses.mjs", "preview": "vite preview", "build:native:mac": "node scripts/build-macos-screencapturekit-helper.mjs", - "build:mac": "npm run build:native:mac && npm run fetch:ffmpeg:mac && npm run fetch:onnxruntime && npm run build:native:compositor:mac && tsc && vite build && electron-builder --mac", + "build:mac": "npm run build:native:mac && npm run fetch:ffmpeg:mac && npm run fetch:onnxruntime && npm run build:native:compositor:mac && tsc && vite build && npm run licenses:generate && electron-builder --mac", "build:native:win": "node scripts/build-windows-wgc-helper.mjs", "stage:vcomp": "node scripts/stage-vcomp-runtime.mjs", "build:native:compositor": "node scripts/build-windows-compositor-addon.mjs", "build:native:compositor:mac": "node scripts/build-macos-compositor-addon.mjs", "build:native:compositor:linux": "node scripts/build-linux-compositor-addon.mjs", "build:native:linux": "node scripts/build-linux-pipewire-helper.mjs", - "build:win": "npm run build:native:win && npm run fetch:ffmpeg && npm run fetch:onnxruntime && npm run stage:vcomp && npm run build:native:compositor && tsc && vite build && electron-builder --win --config.npmRebuild=false", - "build:win:store": "npm run build:native:win && npm run fetch:ffmpeg && npm run fetch:onnxruntime && npm run stage:vcomp && npm run build:native:compositor && tsc && vite build && electron-builder --win appx --config.npmRebuild=false", - "build:linux": "npm run fetch:ffmpeg:sdk && npm run build:native:linux && npm run fetch:onnxruntime && npm run build:native:compositor:linux && tsc && vite build && electron-builder --linux AppImage deb pacman rpm --config.npmRebuild=false", + "build:win": "npm run build:native:win && npm run fetch:ffmpeg && npm run fetch:onnxruntime && npm run stage:vcomp && npm run build:native:compositor && tsc && vite build && npm run licenses:generate && electron-builder --win --config.npmRebuild=false", + "build:win:store": "npm run build:native:win && npm run fetch:ffmpeg && npm run fetch:onnxruntime && npm run stage:vcomp && npm run build:native:compositor && tsc && vite build && npm run licenses:generate && electron-builder --win appx --config.npmRebuild=false", + "build:linux": "npm run fetch:ffmpeg:sdk && npm run build:native:linux && npm run fetch:onnxruntime && npm run build:native:compositor:linux && tsc && vite build && npm run licenses:generate && electron-builder --linux AppImage deb pacman rpm --config.npmRebuild=false", "build:whisper-binaries": "bash scripts/build-whisper-stt.sh", "test:whisper-stt": "node scripts/test-whisper-stt.mjs", "test": "vitest --run", diff --git a/scripts/before-pack.cjs b/scripts/before-pack.cjs index f92d8e7ed..94b9ccf0e 100644 --- a/scripts/before-pack.cjs +++ b/scripts/before-pack.cjs @@ -1112,7 +1112,27 @@ function checkCompositorAddonFreshness( ); } +// electron-builder.json5 ships this as resources/THIRD-PARTY-LICENSES.txt, but for a missing +// extraResources source electron-builder only logs "file source doesn't exist" and packages +// without it. A packaging path that skipped `npm run licenses:generate` would ship an installer +// with no licence texts for the code it bundles, silently. The generator writes nothing when cargo +// cannot list the addon's crates, and never fails over a missing licence file, so only absence is +// refused here. +const THIRD_PARTY_LICENSES = path.join(ROOT, "build-licenses", "THIRD-PARTY-LICENSES.txt"); + +function checkThirdPartyLicenses() { + if (!fs.existsSync(THIRD_PARTY_LICENSES)) { + throw new Error( + "Refusing to package without the third-party licence texts.\n\n" + + ` expected: ${THIRD_PARTY_LICENSES}\n\n` + + "Generate them with:\n\n npm run licenses:generate\n\n" + + "or use the build: script, which does that for you.", + ); + } +} + exports.default = async function beforePack(context) { + checkThirdPartyLicenses(); const platform = context?.electronPlatformName ?? process.platform; if (platform === "win32") { // The copy that ships is the arch-tagged one under electron/native/bin/ @@ -1162,6 +1182,7 @@ exports.default = async function beforePack(context) { // Runnable on its own for debugging: `node scripts/before-pack.cjs` if (require.main === module) { try { + checkThirdPartyLicenses(); if (process.platform === "darwin") { checkMacNativePayload({ arch: undefined }); const tag = `darwin-${process.arch}`; diff --git a/scripts/generate-third-party-licenses.mjs b/scripts/generate-third-party-licenses.mjs new file mode 100644 index 000000000..ea9b5774c --- /dev/null +++ b/scripts/generate-third-party-licenses.mjs @@ -0,0 +1,230 @@ +// Writes build-licenses/THIRD-PARTY-LICENSES.txt, the full licence texts of the third-party code +// compiled into the installers. electron-builder.json5 ships it in resources/, beside +// THIRD-PARTY-NOTICES.md, which covers the other bundled components. +// +// Two sources, both read at build time so a dependency bump needs no manual step: +// - npm: every production entry of package-lock.json. node_modules is not packaged, but the +// code is: Vite bundles it into dist/ and dist-electron/. +// - Rust: every crate compiled into compositor_view.node, from `cargo metadata`. +// +// A package without a licence file is recorded with its declared licence, with a warning; that +// never fails a build. A missing or failing cargo does, and nothing is written: every installer +// ships the addon, so a file without its crates would be incomplete, and before-pack.cjs, which +// refuses to package when the file is absent, would accept it. +// +// Run: node scripts/generate-third-party-licenses.mjs [output path] + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); +const OUTPUT = path.join(ROOT, "build-licenses", "THIRD-PARTY-LICENSES.txt"); +const ADDON_CRATE = "compositor-view-napi"; +const LICENSE_FILE = /^(licen[cs]e|copying|notice)/i; +const NO_FILE = "(no licence file in the package)"; + +/** + * The licence and NOTICE files at the top of a package directory, in file-name order, then the + * `extra` files that exist. A text read twice is printed once: renderSection merges it. + */ +function readLicenseTexts(dir, extra = []) { + const names = fs + .readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && LICENSE_FILE.test(entry.name)) + .map((entry) => entry.name) + .sort(); + return [ + ...names.map((name) => path.join(dir, name)), + ...extra.filter((file) => fs.existsSync(file)), + ] + .map((file) => fs.readFileSync(file, "utf8")) + .filter((text) => text.trim()); +} + +/** + * SPDX expression from a package.json or a cargo package; tolerates the legacy object forms. + * A crate may declare only a `license_file`, a custom licence, which is then named instead. + */ +function declaredLicense({ license, licenses, license_file }) { + const values = [license ?? licenses].flat(); + const ids = values.map((value) => (typeof value === "string" ? value : value?.type)); + return ( + ids.filter(Boolean).join(" OR ") || + (license_file ? `see ${license_file}` : "no declared licence") + ); +} + +function readJson(file) { + try { + return JSON.parse(fs.readFileSync(file, "utf8")); + } catch { + return {}; + } +} + +/** + * Production npm packages: package-lock.json entries under node_modules/ that are not `dev` + * (`optional` and `devOptional` ones included). An entry not installed on this machine, such as + * another platform's optional binary, is skipped: none of its code was bundled here. + */ +export function collectNpmPackages(root = ROOT) { + const lock = JSON.parse(fs.readFileSync(path.join(root, "package-lock.json"), "utf8")); + const packages = new Map(); + const skipped = []; + for (const [key, entry] of Object.entries(lock.packages ?? {})) { + if (!key.startsWith("node_modules/") || entry.dev) continue; + const dir = path.join(root, key); + if (!fs.existsSync(dir)) { + skipped.push(key); + continue; + } + const name = key.split("node_modules/").pop(); + const info = { name, ...entry, ...readJson(path.join(dir, "package.json")) }; + const label = `${info.name} ${info.version} (${declaredLicense(info)})`; + if (!packages.has(label)) packages.set(label, readLicenseTexts(dir)); + } + return { entries: [...packages].map(([label, texts]) => ({ label, texts })), skipped }; +} + +/** + * Crates compiled into the addon: the closure of NORMAL dependencies of compositor-view-napi, + * for every target platform. Build-dependencies never reach the binary, and neither do + * procedural macros, which run inside rustc, nor what only they pull in, so the walk stops at + * them. Workspace members are OpenScreen's own code, under its LICENSE. + */ +export function collectCrates(metadata) { + const packages = new Map(metadata.packages.map((pkg) => [pkg.id, pkg])); + const deps = new Map(metadata.resolve.nodes.map((node) => [node.id, node.deps])); + const members = new Set(metadata.workspace_members); + const isProcMacro = (pkg) => pkg.targets.some((target) => target.kind.includes("proc-macro")); + const root = metadata.packages.find((pkg) => pkg.name === ADDON_CRATE && members.has(pkg.id)); + if (!root) throw new Error(`${ADDON_CRATE} is not a member of the cargo workspace`); + + const seen = new Set(); + const stack = [root.id]; + while (stack.length > 0) { + const id = stack.pop(); + if (seen.has(id) || isProcMacro(packages.get(id))) continue; + seen.add(id); + for (const dep of deps.get(id) ?? []) { + if (dep.dep_kinds.some((kind) => kind.kind === null)) stack.push(dep.pkg); + } + } + return [...seen] + .filter((id) => !members.has(id)) + .map((id) => packages.get(id)) + .map((pkg) => { + const dir = path.dirname(pkg.manifest_path); + // Cargo's `license-file` is relative to the manifest and may sit below the top level. + const declared = pkg.license_file ? [path.resolve(dir, pkg.license_file)] : []; + return { + label: `${pkg.name} ${pkg.version} (${declaredLicense(pkg)})`, + texts: readLicenseTexts(dir, declared), + }; + }); +} + +// Code-unit order, not localeCompare: the same input must give the same bytes on every runner. +const byLabel = (a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0); +const RULE = "=".repeat(80); +const LINE = "-".repeat(80); + +/** Each distinct text once, after the sorted labels of every package that ships it. */ +function renderSection(title, entries) { + const groups = new Map(); + for (const { label, texts } of [...entries].sort(byLabel)) { + for (const text of texts.length > 0 ? texts : [NO_FILE]) { + const key = text.replace(/\s+/g, " ").trim(); + const group = groups.get(key) ?? { text: text.replace(/\r\n?/g, "\n").trim(), labels: [] }; + groups.set(key, group); + if (group.labels.at(-1) !== label) group.labels.push(label); + } + } + const blocks = [...groups.values()].map(({ labels, text }) => + [LINE, ...labels, "", text, ""].join("\n"), + ); + return [RULE, title, RULE, "", ...blocks].join("\n"); +} + +function summary(noun, entries) { + const missing = entries.filter((entry) => entry.texts.length === 0).length; + return `${noun}: ${entries.length} (${missing} without a licence file).`; +} + +export function renderLicenses({ npm, crates }) { + const header = [ + "OpenScreen: third-party licences", + "", + "The full licence texts of the third-party code compiled into OpenScreen: the npm", + "production dependencies, which Vite bundles into the application, and the Rust crates", + "compiled into the native compositor addon (compositor_view.node). Each distinct text", + 'appears once, preceded by the packages that ship it as "name version (declared licence)".', + "", + "The other bundled components (FFmpeg, whisper.cpp, ONNX Runtime, fonts, model weights", + "and more) and their source offers are described in THIRD-PARTY-NOTICES.md, beside this", + "file. Generated at build time by scripts/generate-third-party-licenses.mjs.", + "", + summary("npm packages", npm), + summary("Rust crates", crates), + "", + ]; + const sections = [ + renderSection("npm packages", npm), + renderSection("Rust crates compiled into the compositor addon", crates), + ]; + return `${[...header, ...sections].join("\n").trimEnd()}\n`; +} + +function cargoMetadata() { + const exe = process.platform === "win32" ? "cargo.exe" : "cargo"; + const rustup = path.join(os.homedir(), ".cargo", "bin", exe); + const crates = path.join(ROOT, "crates"); + const result = spawnSync( + fs.existsSync(rustup) ? rustup : "cargo", + ["metadata", "--format-version", "1", "--manifest-path", path.join(crates, "Cargo.toml")], + // cwd = crates/ so cargo reads crates/.cargo/config.toml, like the addon builds do. + // The JSON is ~1 MB already, which is spawnSync's default buffer. + { cwd: crates, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 }, + ); + if (result.error) throw new Error(`cargo metadata could not run: ${result.error.message}`); + if (result.status !== 0) { + const last = result.stderr.trim().split("\n").pop(); + throw new Error(`cargo metadata failed, exit code ${result.status}: ${last}`); + } + return JSON.parse(result.stdout); +} + +// An annotation on GitHub Actions, so a degraded file shows on the run summary rather than +// scrolling past in a build log that still ends green. +const warn = (message) => + console.warn(process.env.GITHUB_ACTIONS ? `::warning::${message}` : `warning: ${message}`); + +function main() { + const output = process.argv[2] ? path.resolve(process.argv[2]) : OUTPUT; + const { entries: npm, skipped } = collectNpmPackages(); + // Throws without cargo: no fallback, see the top of this file. + const crates = collectCrates(cargoMetadata()); + const missing = [...npm, ...crates].filter((entry) => entry.texts.length === 0); + if (missing.length > 0) { + warn( + `third-party licences: ${missing.length} packages have no licence file; ` + + "recorded with their declared licence.", + ); + } + + const text = renderLicenses({ npm, crates }); + fs.mkdirSync(path.dirname(output), { recursive: true }); + fs.writeFileSync(output, text); + console.log( + `Wrote ${path.relative(ROOT, output)} (${Math.round(Buffer.byteLength(text) / 1024)} KB): ` + + `${npm.length} npm packages, ${crates.length} Rust crates` + + (skipped.length > 0 ? `; ${skipped.length} lock entries not installed here skipped.` : "."), + ); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(fs.realpathSync(process.argv[1])).href) { + main(); +} diff --git a/scripts/generate-third-party-licenses.test.mjs b/scripts/generate-third-party-licenses.test.mjs new file mode 100644 index 000000000..d872ed435 --- /dev/null +++ b/scripts/generate-third-party-licenses.test.mjs @@ -0,0 +1,198 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, describe, expect, it } from "vitest"; +import { + collectCrates, + collectNpmPackages, + renderLicenses, +} from "./generate-third-party-licenses.mjs"; + +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "openscreen-licenses-")); +afterAll(() => fs.rmSync(TMP, { recursive: true, force: true })); + +function write(root, files) { + for (const [file, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), content); + } +} + +const MIT = + "MIT License\n\nCopyright (c) Someone\n\nPermission is hereby granted, free of charge.\n"; +// The same text as another package ships it: CRLF, rewrapped, with a BOM. +const MIT_REWRAPPED = + "MIT License\r\n\r\nCopyright (c) Someone\r\n\r\nPermission is hereby\r\ngranted, free of charge."; +const pkg = (name, version, license) => JSON.stringify({ name, version, license }); + +const NPM_ROOT = path.join(TMP, "npm"); +write(NPM_ROOT, { + "package-lock.json": JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { name: "openscreen", version: "2.0.0" }, + "node_modules/alpha": { version: "1.0.0", license: "MIT" }, + "node_modules/beta": { version: "2.0.0", license: "MIT" }, + "node_modules/alpha/node_modules/nested": { version: "0.1.0", license: "Apache-2.0" }, + "node_modules/peer": { version: "1.0.0", license: "ISC", devOptional: true }, + "node_modules/dev-tool": { version: "1.0.0", license: "MIT", dev: true }, + "node_modules/other-platform": { version: "1.0.0", license: "MIT", optional: true }, + }, + }), + "node_modules/alpha/package.json": pkg("alpha", "1.0.0", "MIT"), + "node_modules/alpha/LICENSE": MIT, + "node_modules/alpha/README.md": "not a licence", + "node_modules/beta/package.json": pkg("beta", "2.0.0", "MIT"), + "node_modules/beta/licence.md": MIT_REWRAPPED, + "node_modules/alpha/node_modules/nested/package.json": pkg("nested", "0.1.0", "Apache-2.0"), + "node_modules/alpha/node_modules/nested/NOTICE": "nested NOTICE", + "node_modules/alpha/node_modules/nested/LICENSE-APACHE": "Apache License 2.0", + "node_modules/peer/package.json": pkg("peer", "1.0.0", "ISC"), + "node_modules/dev-tool/package.json": pkg("dev-tool", "1.0.0", "MIT"), + "node_modules/dev-tool/LICENSE": "dev only", +}); + +describe("collectNpmPackages", () => { + const { entries, skipped } = collectNpmPackages(NPM_ROOT); + const byLabel = Object.fromEntries(entries.map((entry) => [entry.label, entry.texts])); + + it("keeps the production entries, optional ones included, and drops dev ones", () => { + expect(Object.keys(byLabel).sort()).toEqual([ + "alpha 1.0.0 (MIT)", + "beta 2.0.0 (MIT)", + "nested 0.1.0 (Apache-2.0)", + "peer 1.0.0 (ISC)", + ]); + }); + + it("skips an entry that is not installed on this machine", () => { + expect(skipped).toEqual(["node_modules/other-platform"]); + }); + + it("reads LICENSE, LICENCE and NOTICE files in any case, in name order", () => { + expect(byLabel["alpha 1.0.0 (MIT)"]).toEqual([MIT]); + expect(byLabel["beta 2.0.0 (MIT)"]).toEqual([MIT_REWRAPPED]); + expect(byLabel["nested 0.1.0 (Apache-2.0)"]).toEqual(["Apache License 2.0", "nested NOTICE"]); + }); + + it("records a package without a licence file instead of failing", () => { + expect(byLabel["peer 1.0.0 (ISC)"]).toEqual([]); + expect(renderLicenses({ npm: entries, crates: [] })).toContain( + "peer 1.0.0 (ISC)\n\n(no licence file in the package)\n", + ); + }); +}); + +function crate(name, kind = "lib") { + const dir = path.join(TMP, "crates", name); + write(dir, { "LICENSE-MIT": `${name} licence` }); + const manifest_path = path.join(dir, "Cargo.toml"); + return { + id: `${name} 1.0.0`, + name, + version: "1.0.0", + license: "MIT", + manifest_path, + targets: [{ kind: [kind] }], + }; +} +const dep = (name, ...kinds) => ({ + pkg: `${name} 1.0.0`, + dep_kinds: kinds.map((kind) => ({ kind, target: null })), +}); + +const METADATA = { + packages: [ + crate("compositor-view-napi", "cdylib"), + crate("openscreen-compositor"), + crate("serde"), + crate("serde_derive", "proc-macro"), + crate("syn"), + crate("cc"), + crate("shlex"), + crate("criterion"), + crate("libc"), + crate("windows"), + ], + workspace_members: ["compositor-view-napi 1.0.0", "openscreen-compositor 1.0.0"], + resolve: { + nodes: [ + { + id: "compositor-view-napi 1.0.0", + deps: [ + dep("openscreen-compositor", null), + { pkg: "windows 1.0.0", dep_kinds: [{ kind: null, target: "cfg(windows)" }] }, + ], + }, + { + id: "openscreen-compositor 1.0.0", + deps: [ + dep("serde", null), + dep("cc", "build"), + dep("criterion", "dev"), + dep("libc", "build", null), + ], + }, + { id: "serde 1.0.0", deps: [dep("serde_derive", null)] }, + { id: "serde_derive 1.0.0", deps: [dep("syn", null)] }, + { id: "cc 1.0.0", deps: [dep("shlex", null)] }, + { id: "syn 1.0.0", deps: [] }, + { id: "shlex 1.0.0", deps: [] }, + { id: "criterion 1.0.0", deps: [] }, + { id: "libc 1.0.0", deps: [] }, + { id: "windows 1.0.0", deps: [] }, + ], + }, +}; + +describe("collectCrates", () => { + it("keeps the normal-dependency closure of the addon, on every platform", () => { + const labels = collectCrates(METADATA).map((entry) => entry.label); + // Out: the workspace members, the proc-macro and syn behind it, the build-dependency + // and shlex behind it, the dev-dependency. libc is a build AND a normal dependency. + expect(labels.sort()).toEqual(["libc 1.0.0 (MIT)", "serde 1.0.0 (MIT)", "windows 1.0.0 (MIT)"]); + }); + + it("reads each crate's licence files from its manifest directory", () => { + const serde = collectCrates(METADATA).find((entry) => entry.label.startsWith("serde ")); + expect(serde?.texts).toEqual(["serde licence"]); + }); + + it("reads a declared license-file below the top level, and names it without a licence", () => { + const custom = { ...crate("custom"), license: null, license_file: "legal/TERMS.txt" }; + write(path.dirname(custom.manifest_path), { "legal/TERMS.txt": "custom terms" }); + const metadata = { + packages: [crate("compositor-view-napi", "cdylib"), custom], + workspace_members: ["compositor-view-napi 1.0.0"], + resolve: { + nodes: [ + { id: "compositor-view-napi 1.0.0", deps: [dep("custom", null)] }, + { id: "custom 1.0.0", deps: [] }, + ], + }, + }; + + expect(collectCrates(metadata)).toEqual([ + { label: "custom 1.0.0 (see legal/TERMS.txt)", texts: ["custom licence", "custom terms"] }, + ]); + }); +}); + +describe("renderLicenses", () => { + const { entries: npm } = collectNpmPackages(NPM_ROOT); + const crates = collectCrates(METADATA); + + it("prints a text shared after whitespace normalisation once, after its sorted labels", () => { + const text = renderLicenses({ npm, crates }); + + expect(text).toContain("alpha 1.0.0 (MIT)\nbeta 2.0.0 (MIT)\n\nMIT License\n"); + expect(text.split("Permission is hereby").length - 1).toBe(1); + }); + + it("gives the same bytes whatever order the packages arrive in", () => { + const reversed = renderLicenses({ npm: [...npm].reverse(), crates: [...crates].reverse() }); + + expect(reversed).toBe(renderLicenses({ npm, crates })); + expect(reversed).not.toContain("\r"); + }); +});