Skip to content
Source
Sign inSign up
Open History to see the last commit

third-party-licenses.test.ts

500 lines17.3 KB

1// @effect-diagnostics nodeBuiltinImport:off - Tests exercise the Node filesystem build boundary.
2​
3import * as NodeFSP from "node:fs/promises";
4import * as NodeOS from "node:os";
5import * as NodePath from "node:path";
6import * as NodeURL from "node:url";
7​
8import { afterEach, describe, expect, it } from "vite-plus/test";
9​
10import { generateThirdPartyLicenseManifest } from "./third-party-licenses.js";
11​
12const tempDirectories: string[] = [];
13const REPOSITORY_ROOT = NodePath.resolve(
14 NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)),
15 "../..",
16);
17const DEVICE_TOOL_VERSIONS = JSON.parse(
18 await NodeFSP.readFile(
19 NodePath.join(REPOSITORY_ROOT, "apps/server/src/device/deviceToolVersions.json"),
20 "utf8",
21 ),
22) as { readonly "agent-device": string; readonly "expo-device-hub": string };
23​
24async function writeJson(path: string, value: unknown): Promise<void> {
25 await NodeFSP.mkdir(NodePath.dirname(path), { recursive: true });
26 await NodeFSP.writeFile(path, `${JSON.stringify(value)}\n`, "utf8");
28​
29async function createFixture(): Promise<{
30 readonly appManifest: string;
31 readonly configFile: string;
32 readonly dependencyRoot: string;
33 readonly root: string;
34}> {
35 const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3code-licenses-"));
36 tempDirectories.push(root);
37 const appManifest = NodePath.join(root, "package.json");
38 const dependencyRoot = NodePath.join(root, "node_modules", "demo-dependency");
39 const configFile = NodePath.join(root, "third-party-licenses.config.json");
40​
41 await writeJson(appManifest, {
42 name: "fixture-app",
43 dependencies: { "demo-dependency": "1.2.3" },
44 });
45 await writeJson(NodePath.join(dependencyRoot, "package.json"), {
46 name: "demo-dependency",
47 version: "1.2.3",
48 license: "MIT",
49 main: "index.js",
50 repository: "example/demo-dependency",
51 });
52 await NodeFSP.writeFile(NodePath.join(dependencyRoot, "index.js"), "export {};\n", "utf8");
53 await NodeFSP.writeFile(
54 NodePath.join(dependencyRoot, "LICENSE"),
55 "Demo MIT license text\n",
56 "utf8",
57 );
58 await NodeFSP.writeFile(NodePath.join(root, "asset-notice.txt"), "Asset notice text\n", "utf8");
59 await writeJson(configFile, {
60 customNotices: [
61 {
62 name: "demo-asset",
63 license: "CC-BY-4.0",
64 noticeFile: "asset-notice.txt",
65 bundles: ["assets", "web"],
66 },
67 ],
68 packageOverrides: [],
69 });
70 return { appManifest, configFile, dependencyRoot, root };
72​
73afterEach(async () => {
74 await Promise.all(
75 tempDirectories
76 .splice(0)
77 .map((directory) => NodeFSP.rm(directory, { force: true, recursive: true })),
78 );
79});
80​
81describe("third-party license generation", () => {
82 it.each(["web", "mobile"])(
83 "uses installer pins for device tool notices in the %s manifest",
84 async (bundle) => {
85 const fixture = await createFixture();
86 await writeJson(fixture.configFile, {
87 customNotices: [
88 {
89 name: "agent-device",
90 license: "MIT",
91 noticeFile: "asset-notice.txt",
92 bundles: ["device-tools"],
93 includeInBundles: ["web", "mobile"],
94 },
95 {
96 name: "expo-device-hub",
97 license: "MIT",
98 noticeFile: "asset-notice.txt",
99 version: "0.0.0",
100 bundles: ["device-tools"],
101 includeInBundles: ["web", "mobile"],
102 },
103 {
104 name: "vendored-asset",
105 license: "MIT",
106 noticeFile: "asset-notice.txt",
107 version: "1.2.3",
108 bundles: ["web", "mobile"],
109 },
110 ],
111 });
112​
113 const manifest = await generateThirdPartyLicenseManifest({
114 configFile: fixture.configFile,
115 packageManifests: [{ bundle, path: fixture.appManifest }],
116 });
117​
118 expect(manifest.entries.find((entry) => entry.name === "agent-device")).toMatchObject({
119 version: DEVICE_TOOL_VERSIONS["agent-device"],
120 bundles: ["device-tools"],
121 });
122 expect(manifest.entries.find((entry) => entry.name === "expo-device-hub")).toMatchObject({
123 version: DEVICE_TOOL_VERSIONS["expo-device-hub"],
124 bundles: ["device-tools"],
125 });
126 expect(manifest.entries.find((entry) => entry.name === "vendored-asset")?.version).toBe(
127 "1.2.3",
128 );
129 },
130 );
131​
132 it("collects production packages and custom asset notices", async () => {
133 const fixture = await createFixture();
134 const manifest = await generateThirdPartyLicenseManifest({
135 configFile: fixture.configFile,
136 packageManifests: [{ bundle: "web", path: fixture.appManifest }],
137 });
138​
139 expect(manifest).toEqual({
140 schemaVersion: 1,
141 entries: [
142 {
143 bundles: ["assets", "web"],
144 kind: "custom",
145 license: "CC-BY-4.0",
146 name: "demo-asset",
147 noticeText: "Asset notice text",
148 sourceUrl: null,
149 version: null,
150 },
151 {
152 bundles: ["web"],
153 kind: "package",
154 license: "MIT",
155 name: "demo-dependency",
156 noticeText: "Demo MIT license text",
157 sourceUrl: "https://github.com/example/demo-dependency",
158 version: "1.2.3",
159 },
160 ],
161 });
162 });
163​
164 it("renders generated notices from the committed SPDX templates", async () => {
165 const fixture = await createFixture();
166 await writeJson(NodePath.join(fixture.root, "third-party-licenses/spdx/v3.28.0/MIT.json"), {
167 licenseId: "MIT",
168 licenseText: "MIT License\n\nCopyright (c) <year> <copyright holders>\n\nPermission text",
169 });
170 await writeJson(fixture.configFile, {
171 customNotices: [
172 {
173 name: "generated-asset",
174 license: "MIT",
175 generatedNotices: [
176 {
177 licenseId: "MIT",
178 copyrights: ["Copyright (c) 2026 Example Author"],
179 preamble: ["Adapted for T3 Code."],
180 },
181 ],
182 bundles: ["assets", "web"],
183 },
184 ],
185 packageOverrides: [],
186 });
187​
188 const manifest = await generateThirdPartyLicenseManifest({
189 configFile: fixture.configFile,
190 packageManifests: [{ bundle: "web", path: fixture.appManifest }],
191 });
192​
193 expect(manifest.entries.find((entry) => entry.name === "generated-asset")?.noticeText).toBe(
194 "Adapted for T3 Code.\n\nMIT License\n\nCopyright (c) 2026 Example Author\n\nPermission text",
195 );
196 });
197​
198 it("finds packages whose exports hide both their manifest and entry point", async () => {
199 const fixture = await createFixture();
200 await writeJson(NodePath.join(fixture.dependencyRoot, "package.json"), {
201 name: "demo-dependency",
202 version: "1.2.3",
203 license: "MIT",
204 exports: {},
205 repository: "example/demo-dependency",
206 });
207​
208 const manifest = await generateThirdPartyLicenseManifest({
209 configFile: fixture.configFile,
210 packageManifests: [{ bundle: "web", path: fixture.appManifest }],
211 });
212​
213 expect(manifest.entries.some((entry) => entry.name === "demo-dependency")).toBe(true);
214 });
215​
216 it("leaves type-only packages out of the notices but still walks their dependencies", async () => {
217 const fixture = await createFixture();
218 const modules = NodePath.join(fixture.root, "node_modules");
219 const write = async (name: string, packageJson: Record<string, unknown>) => {
220 await writeJson(NodePath.join(modules, name, "package.json"), {
221 name,
222 version: "1.0.0",
223 license: "MIT",
224 ...packageJson,
225 });
226 await NodeFSP.writeFile(NodePath.join(modules, name, "LICENSE"), `${name} license\n`, "utf8");
227 };
228 await writeJson(fixture.appManifest, {
229 name: "fixture-app",
230 dependencies: { "demo-dependency": "1.2.3", "@types/demo": "1.0.0" },
231 });
232 await write("@types/demo", {
233 main: "",
234 types: "index.d.ts",
235 exports: {
236 ".": { types: { import: "./index.d.mts", default: "./index.d.ts" } },
237 "./package.json": "./package.json",
238 },
239 dependencies: { "demo-css-types": "1.0.0", "demo-runtime": "1.0.0" },
240 });
241 await write("demo-css-types", { main: "", types: "index.d.ts" });
242 await write("demo-runtime", { main: "index.js" });
243​
244 const manifest = await generateThirdPartyLicenseManifest({
245 configFile: fixture.configFile,
246 packageManifests: [{ bundle: "web", path: fixture.appManifest }],
247 });
248​
249 const names = manifest.entries.map((entry) => entry.name);
250 expect(names).toContain("demo-dependency");
251 expect(names).toContain("demo-runtime");
252 expect(names).not.toContain("@types/demo");
253 expect(names).not.toContain("demo-css-types");
254 });
255​
256 it.each([
257 {
258 shape: "a JS entry next to its declarations",
259 entry: {
260 main: "./dist/index.mjs",
261 exports: { ".": { types: "./dist/index.d.mts", import: "./dist/index.mjs" } },
262 },
263 indexFile: null,
264 },
265 { shape: "only a stylesheet", entry: { main: "", style: "dist/theme.css" }, indexFile: null },
266 {
267 shape: "only a react-native entry",
268 entry: { main: "", "react-native": "lib/index.native.js" },
269 indexFile: null,
270 },
271 { shape: "an index.js behind an empty main", entry: { main: "" }, indexFile: "index.js" },
272 { shape: "an index.node addon and no main", entry: {}, indexFile: "index.node" },
273 ])("keeps a package that declares types but ships $shape", async ({ entry, indexFile }) => {
274 const fixture = await createFixture();
275 await writeJson(NodePath.join(fixture.dependencyRoot, "package.json"), {
276 name: "demo-dependency",
277 version: "1.2.3",
278 license: "MIT",
279 types: "./dist/index.d.mts",
280 ...entry,
281 repository: "example/demo-dependency",
282 });
283 if (indexFile !== "index.js") {
284 await NodeFSP.rm(NodePath.join(fixture.dependencyRoot, "index.js"));
285 }
286 if (indexFile !== null) {
287 await NodeFSP.writeFile(NodePath.join(fixture.dependencyRoot, indexFile), "", "utf8");
288 }
289​
290 const manifest = await generateThirdPartyLicenseManifest({
291 configFile: fixture.configFile,
292 packageManifests: [{ bundle: "web", path: fixture.appManifest }],
293 });
294​
295 expect(manifest.entries.some((entry) => entry.name === "demo-dependency")).toBe(true);
296 });
297​
298 it("fails when a production package has no distributable notice text", async () => {
299 const fixture = await createFixture();
300 await NodeFSP.rm(NodePath.join(fixture.dependencyRoot, "LICENSE"));
301​
302 await expect(
303 generateThirdPartyLicenseManifest({
304 configFile: fixture.configFile,
305 packageManifests: [{ bundle: "web", path: fixture.appManifest }],
306 }),
307 ).rejects.toThrow("does not include a license or notice file");
308 });
309​
310 it("collects nested notices even when a package also has a root license", async () => {
311 const fixture = await createFixture();
312 await NodeFSP.mkdir(NodePath.join(fixture.dependencyRoot, "dist", "third-party"), {
313 recursive: true,
314 });
315 await NodeFSP.mkdir(NodePath.join(fixture.dependencyRoot, "lib"), { recursive: true });
316 await NodeFSP.writeFile(
317 NodePath.join(fixture.dependencyRoot, "dist", "third-party", "NOTICE.txt"),
318 "Nested notice\n",
319 "utf8",
320 );
321 await NodeFSP.writeFile(
322 NodePath.join(fixture.dependencyRoot, "lib", "license_header.js"),
323 "require('not-a-license');\n",
324 "utf8",
325 );
326​
327 const manifest = await generateThirdPartyLicenseManifest({
328 configFile: fixture.configFile,
329 packageManifests: [{ bundle: "web", path: fixture.appManifest }],
330 });
331​
332 expect(manifest.entries.find((entry) => entry.name === "demo-dependency")?.noticeText).toBe(
333 "dist/third-party/NOTICE.txt\n\nNested notice\n\n---\n\nLICENSE\n\nDemo MIT license text",
334 );
335 });
336​
337 it("uses package overrides for notices published outside the npm archive", async () => {
338 const fixture = await createFixture();
339 await NodeFSP.rm(NodePath.join(fixture.dependencyRoot, "LICENSE"));
340 await NodeFSP.writeFile(NodePath.join(fixture.root, "override.txt"), "Override text\n", "utf8");
341 await writeJson(fixture.configFile, {
342 customNotices: [],
343 packageOverrides: [
344 {
345 name: "demo-dependency",
346 noticeFile: "override.txt",
347 },
348 ],
349 });
350​
351 const manifest = await generateThirdPartyLicenseManifest({
352 configFile: fixture.configFile,
353 packageManifests: [{ bundle: "web", path: fixture.appManifest }],
354 });
355​
356 expect(manifest.entries[0]?.noticeText).toBe("Override text");
357 });
358​
359 it("keeps all licenses and attributions in a package with multiple generated notices", async () => {
360 const fixture = await createFixture();
361 await NodeFSP.rm(NodePath.join(fixture.dependencyRoot, "LICENSE"));
362 for (const licenseId of ["Apache-2.0", "BSD-3-Clause"]) {
363 await writeJson(
364 NodePath.join(fixture.root, `third-party-licenses/spdx/v3.28.0/${licenseId}.json`),
365 { licenseId, licenseText: `${licenseId} terms` },
366 );
367 }
368 await writeJson(fixture.configFile, {
369 packageOverrides: [
370 {
371 name: "demo-dependency",
372 license: "(Apache-2.0 AND BSD-3-Clause)",
373 generatedNotices: [
374 { licenseId: "Apache-2.0", copyrights: ["Copyright primary author"] },
375 { licenseId: "BSD-3-Clause", copyrights: ["Copyright vendored author"] },
376 ],
377 },
378 ],
379 });
380 const manifest = await generateThirdPartyLicenseManifest({
381 configFile: fixture.configFile,
382 packageManifests: [{ bundle: "web", path: fixture.appManifest }],
383 });
384 expect(manifest.entries[0]?.license).toBe("(Apache-2.0 AND BSD-3-Clause)");
385 expect(manifest.entries[0]?.noticeText).toBe(
386 "Copyright primary author\n\nApache-2.0 terms\n\n---\n\nCopyright vendored author\n\nBSD-3-Clause terms",
387 );
388 });
389​
390 it("reuses a repository license for packages from the same monorepo", async () => {
391 const fixture = await createFixture();
392 const siblingRoot = NodePath.join(fixture.root, "node_modules", "demo-sibling");
393 await writeJson(fixture.appManifest, {
394 name: "fixture-app",
395 dependencies: { "demo-dependency": "1.2.3", "demo-sibling": "2.0.0" },
396 });
397 await writeJson(NodePath.join(siblingRoot, "package.json"), {
398 name: "demo-sibling",
399 version: "2.0.0",
400 license: "MIT",
401 main: "index.js",
402 repository: "https://github.com/example/demo-dependency.git#main",
403 });
404 await NodeFSP.writeFile(NodePath.join(siblingRoot, "index.js"), "export {};\n", "utf8");
405​
406 const manifest = await generateThirdPartyLicenseManifest({
407 configFile: fixture.configFile,
408 packageManifests: [{ bundle: "web", path: fixture.appManifest }],
409 });
410​
411 expect(manifest.entries.find((entry) => entry.name === "demo-sibling")?.noticeText).toBe(
412 "Demo MIT license text",
413 );
414 });
415​
416 it("prefers version-specific repository overrides", async () => {
417 const fixture = await createFixture();
418 await NodeFSP.writeFile(NodePath.join(fixture.root, "generic.txt"), "Generic text\n", "utf8");
419 await NodeFSP.writeFile(NodePath.join(fixture.root, "exact.txt"), "Exact text\n", "utf8");
420 await writeJson(fixture.configFile, {
421 customNotices: [],
422 packageOverrides: [
423 {
424 repositoryUrl: "https://github.com/example/demo-dependency",
425 noticeFile: "generic.txt",
426 },
427 {
428 repositoryUrl: "https://github.com/example/demo-dependency",
429 version: "1.2.3",
430 noticeFile: "exact.txt",
431 },
432 ],
433 });
434​
435 const manifest = await generateThirdPartyLicenseManifest({
436 configFile: fixture.configFile,
437 packageManifests: [{ bundle: "web", path: fixture.appManifest }],
438 });
439​
440 expect(manifest.entries[0]?.noticeText).toBe("Exact text");
441 });
442​
443 it("omits custom notices for other bundles", async () => {
444 const fixture = await createFixture();
445 await writeJson(fixture.configFile, {
446 customNotices: [
447 {
448 name: "web-only-asset",
449 license: "MIT",
450 noticeFile: "asset-notice.txt",
451 bundles: ["assets", "web"],
452 },
453 ],
454 packageOverrides: [],
455 });
456​
457 const manifest = await generateThirdPartyLicenseManifest({
458 configFile: fixture.configFile,
459 packageManifests: [{ bundle: "mobile", path: fixture.appManifest }],
460 });
461​
462 expect(manifest.entries.some((entry) => entry.name === "web-only-asset")).toBe(false);
463 });
464​
465 it("can show a multi-file notice under a label that differs from its client manifests", async () => {
466 const fixture = await createFixture();
467 await NodeFSP.writeFile(
468 NodePath.join(fixture.root, "tool-license.txt"),
469 "Tool license\n",
470 "utf8",
471 );
472 await NodeFSP.writeFile(
473 NodePath.join(fixture.root, "vendor-notice.txt"),
474 "Vendor notice\n",
475 "utf8",
476 );
477 await writeJson(fixture.configFile, {
478 customNotices: [
479 {
480 name: "optional-tool",
481 license: "MIT AND Apache-2.0",
482 noticeFiles: ["tool-license.txt", "vendor-notice.txt"],
483 bundles: ["device-tools"],
484 includeInBundles: ["mobile", "web"],
485 },
486 ],
487 packageOverrides: [],
488 });
489​
490 const manifest = await generateThirdPartyLicenseManifest({
491 configFile: fixture.configFile,
492 packageManifests: [{ bundle: "mobile", path: fixture.appManifest }],
493 });
494​
495 expect(manifest.entries.find((entry) => entry.name === "optional-tool")).toMatchObject({
496 bundles: ["device-tools"],
497 noticeText: "Tool license\n\n---\n\nVendor notice",
498 });
499 });
500});