angular-compat-matrix 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/run.js ADDED
@@ -0,0 +1,280 @@
1
+ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { freemem, availableParallelism } from 'node:os';
4
+ import { join, relative, sep } from 'node:path';
5
+ import { configureBuild, generateApp, readJson, writeJson } from "./app.js";
6
+ import { findUndeclaredImports, isDtsOnlyFailure, ngBuild } from "./build.js";
7
+ import { installLibrary } from "./install.js";
8
+ import { CHECKS, result } from "./model.js";
9
+ import { judgePartial } from "./partial.js";
10
+ import { planPeers } from "./peers.js";
11
+ import { entryPoints, hasSignalWriteBrand, readPublicApi, renderSmoke, writeSmoke } from "./smoke.js";
12
+ import { runLibraryTests, swapVersions } from "./tests.js";
13
+ /** About what one Angular production build needs; versions beyond what memory allows wait their turn. */
14
+ const MEMORY_PER_VERSION = 2.5 * 1024 ** 3;
15
+ export function defaultConcurrency(versions) {
16
+ const byMemory = Math.floor(freemem() / MEMORY_PER_VERSION);
17
+ return Math.max(1, Math.min(versions, byMemory, availableParallelism()));
18
+ }
19
+ export async function runAll(ctx, majors, concurrency) {
20
+ const reports = new Array(majors.length);
21
+ let next = 0;
22
+ const worker = async () => {
23
+ while (next < majors.length) {
24
+ const index = next++;
25
+ reports[index] = await checkVersion(ctx, majors[index]);
26
+ }
27
+ };
28
+ await Promise.all(Array.from({ length: Math.min(concurrency, majors.length) }, worker));
29
+ return reports;
30
+ }
31
+ function installedAngular(appDir, fallback) {
32
+ try {
33
+ const require = createRequire(join(appDir, 'package.json'));
34
+ return JSON.parse(readFileSync(require.resolve('@angular/core/package.json'), 'utf8')).version;
35
+ }
36
+ catch {
37
+ return fallback;
38
+ }
39
+ }
40
+ export async function checkVersion(ctx, angular) {
41
+ const started = Date.now();
42
+ const tag = `Angular ${angular.major}`;
43
+ const checks = Object.fromEntries(CHECKS.map((c) => [c, result('not-run')]));
44
+ if (!ctx.tests)
45
+ checks.tests = result('off', 'Not requested (--tests).');
46
+ const report = {
47
+ major: angular.major,
48
+ angularVersion: angular.version,
49
+ supported: angular.supported,
50
+ prerelease: angular.prerelease,
51
+ claimed: null,
52
+ peerMisses: [],
53
+ checks,
54
+ durationMs: 0,
55
+ };
56
+ const finish = () => {
57
+ report.durationMs = Date.now() - started;
58
+ ctx.progress?.done(angular.major);
59
+ ctx.log(`${tag}: done in ${Math.round(report.durationMs / 1000)} s`);
60
+ return report;
61
+ };
62
+ // A timeout, a full disk or a network error says nothing about the package: this version
63
+ // wasn't checked (exit 2), rather than failing a check.
64
+ const notChecked = (step, why, lines) => {
65
+ report.setupError = [`${step} on ${tag}: ${why}.`, ...lines].join('\n');
66
+ return finish();
67
+ };
68
+ // Peer range: reported, but never stops the other checks.
69
+ const plan = await planPeers(ctx.library.manifest, angular.version, ctx.registry);
70
+ report.claimed = plan.claimed;
71
+ report.peerMisses = plan.misses;
72
+ checks.peer =
73
+ plan.claimed === null
74
+ ? result('none', 'The package declares no @angular/* peer dependency.')
75
+ : plan.claimed
76
+ ? result('pass', `Claimed: ${Object.entries(plan.checked).map(([n, v]) => `${n}@${v}`).join(', ')}.`)
77
+ : result('fail', plan.misses.map((m) => `${m.name} "${m.range}" doesn't allow ${m.version}`).join('; ') + '.');
78
+ const versionDir = join(ctx.workDir, `angular-${angular.major}`);
79
+ mkdirSync(versionDir, { recursive: true });
80
+ let appDir;
81
+ try {
82
+ ctx.progress?.step(angular.major, ctx.consumer ? 'creating consumer app' : `ng new (CLI ${angular.cliVersion})`);
83
+ ctx.log(`${tag}: creating the app (${ctx.consumer ? 'your --consumer app' : `ng new, CLI ${angular.cliVersion}`})`);
84
+ appDir = ctx.consumer
85
+ ? await copyConsumer(ctx, angular, plan.installs, versionDir)
86
+ : await generateApp(angular, ctx.pm, versionDir, ctx.cacheDir, ctx.offline);
87
+ if (ctx.keep)
88
+ report.appDir = appDir;
89
+ }
90
+ catch (error) {
91
+ report.setupError = error.message;
92
+ if (!ctx.keep)
93
+ rmSync(versionDir, { recursive: true, force: true, maxRetries: 3 });
94
+ return finish();
95
+ }
96
+ try {
97
+ ctx.progress?.step(angular.major, `installing with ${ctx.pm}`);
98
+ ctx.log(`${tag}: installing ${ctx.library.manifest.name} with ${ctx.pm}`);
99
+ const installStarted = Date.now();
100
+ const installed = await installLibrary({
101
+ appDir,
102
+ pm: ctx.pm,
103
+ libraryName: ctx.library.manifest.name,
104
+ tarball: ctx.library.tarball,
105
+ plan,
106
+ angularMajor: angular.major,
107
+ cacheDir: ctx.cacheDir,
108
+ offline: ctx.offline,
109
+ allowScripts: ctx.allowScripts,
110
+ });
111
+ if (installed.skippedScripts.length)
112
+ report.skippedInstallScripts = installed.skippedScripts;
113
+ if (installed.environment)
114
+ return notChecked('The install', installed.environment, installed.errorLines);
115
+ checks.install = result(installed.pass ? 'pass' : 'fail', installed.message, {
116
+ errorLines: installed.errorLines,
117
+ fullOutput: installed.fullOutput,
118
+ hint: installed.pass ? undefined : installed.errorLines[0],
119
+ durationMs: Date.now() - installStarted,
120
+ });
121
+ if (!installed.pass)
122
+ return finish();
123
+ const actual = installedAngular(appDir, angular.version);
124
+ report.angularVersion = actual;
125
+ const partial = judgePartial(ctx.summary, actual);
126
+ checks.partial = result(partial.pass ? 'pass' : 'fail', partial.message, {
127
+ advisory: partial.advisory,
128
+ hint: partial.pass ? undefined : 'Build the library with the oldest Angular you support, or take this version out of the peer range.',
129
+ });
130
+ if (!partial.pass)
131
+ return finish();
132
+ let files;
133
+ let api;
134
+ if (!ctx.consumer) {
135
+ try {
136
+ api = readPublicApi(appDir, entryPoints(ctx.library.manifest));
137
+ writeSmoke(appDir, renderSmoke(api, { signalWriteBrand: hasSignalWriteBrand(appDir) }));
138
+ files = ['src/ngm-main.ts'];
139
+ }
140
+ catch (error) {
141
+ checks.aot = result('fail', `Couldn't generate the smoke app from the package's typings: ${error.message}`, {
142
+ hint: 'If the typings are fine, give ng-matrix an example app with --consumer.',
143
+ });
144
+ return finish();
145
+ }
146
+ }
147
+ const project = configureBuild(appDir, files?.[0]);
148
+ ctx.progress?.step(angular.major, 'AOT build');
149
+ ctx.log(`${tag}: AOT build`);
150
+ let aot = await ngBuild(appDir, project.tsConfig, { strictTemplates: false, skipLibCheck: true }, files);
151
+ if (aot.environment)
152
+ return notChecked('The AOT build', aot.environment, aot.errorLines);
153
+ const omittedList = [];
154
+ while (!aot.pass && !ctx.consumer && api) {
155
+ const undeclared = findUndeclaredImports(aot.errorLines, ctx.library.manifest, api.entries);
156
+ if (!undeclared.length)
157
+ break;
158
+ const mainName = ctx.library.manifest.name;
159
+ const isMain = (entry) => entry === mainName || entry === '.';
160
+ const mainUndeclared = undeclared.find((u) => isMain(u.entry));
161
+ const omittedSet = new Set(undeclared.map((u) => u.entry));
162
+ const remainingEntries = api.entries.filter((e) => !omittedSet.has(e));
163
+ if (mainUndeclared || remainingEntries.length === 0) {
164
+ const item = mainUndeclared ?? undeclared[0];
165
+ const reason = `${item.entry} imports ${item.missingPackage}, which isn't declared in dependencies or peers. Every user of ${item.entry} needs ${item.missingPackage} installed.`;
166
+ checks.aot = result('fail', reason, {
167
+ errorLines: aot.errorLines,
168
+ fullOutput: aot.fullOutput,
169
+ hint: reason,
170
+ durationMs: aot.durationMs,
171
+ });
172
+ return finish();
173
+ }
174
+ omittedList.push(...undeclared);
175
+ api.entries = remainingEntries;
176
+ api.declarables = api.declarables.filter((d) => !d.entry || !omittedSet.has(d.entry));
177
+ api.pipes = api.pipes.filter((p) => !p.entry || !omittedSet.has(p.entry));
178
+ api.modules = api.modules.filter((m) => !omittedSet.has(m.entry));
179
+ api.services = api.services.filter((s) => !omittedSet.has(s.entry));
180
+ writeSmoke(appDir, renderSmoke(api, { signalWriteBrand: hasSignalWriteBrand(appDir) }));
181
+ ctx.log(`${tag}: re-running AOT build without omitted entry points: ${[...omittedSet].join(', ')}`);
182
+ aot = await ngBuild(appDir, project.tsConfig, { strictTemplates: false, skipLibCheck: true }, files);
183
+ if (aot.environment)
184
+ return notChecked('The AOT build', aot.environment, aot.errorLines);
185
+ }
186
+ if (omittedList.length) {
187
+ report.omittedEntryPoints = omittedList;
188
+ }
189
+ checks.aot = result(aot.pass ? 'pass' : 'fail', aot.pass ? 'Production build succeeded.' : 'Production build failed.', {
190
+ errorLines: aot.errorLines,
191
+ fullOutput: aot.fullOutput,
192
+ hint: aot.hint ?? aot.errorLines[0],
193
+ durationMs: aot.durationMs,
194
+ });
195
+ if (!aot.pass)
196
+ return finish();
197
+ ctx.progress?.step(angular.major, 'strict build');
198
+ ctx.log(`${tag}: strict build`);
199
+ const strict = await ngBuild(appDir, project.tsConfig, { strictTemplates: true, skipLibCheck: false }, files);
200
+ if (strict.environment)
201
+ return notChecked('The strict build', strict.environment, strict.errorLines);
202
+ const dtsOnly = !strict.pass && isDtsOnlyFailure(strict.errorLines);
203
+ if (dtsOnly) {
204
+ report.dtsErrors = true;
205
+ }
206
+ const dtsHint = strict.hint ?? (dtsOnly ? 'library typings have errors with skipLibCheck: false' : undefined);
207
+ checks.strict = result(strict.pass || dtsOnly ? 'pass' : 'fail', strict.pass
208
+ ? 'Builds with strictTemplates: true and skipLibCheck: false.'
209
+ : dtsOnly
210
+ ? 'Builds with strictTemplates: true; library typings have errors with skipLibCheck: false.'
211
+ : (dtsHint ?? strict.errorLines[0] ?? 'Fails with strictTemplates: true and skipLibCheck: false.'), {
212
+ errorLines: strict.errorLines,
213
+ fullOutput: strict.fullOutput,
214
+ hint: dtsHint ?? strict.errorLines[0],
215
+ durationMs: strict.durationMs,
216
+ });
217
+ if (checks.strict.status === 'fail')
218
+ return finish();
219
+ if (ctx.tests) {
220
+ if (!ctx.workspace) {
221
+ checks.tests = result('off', 'No workspace to test: the package came from --tarball or --package.');
222
+ }
223
+ else {
224
+ ctx.progress?.step(angular.major, 'library tests');
225
+ ctx.log(`${tag}: library tests`);
226
+ const tests = await runLibraryTests({
227
+ workspace: ctx.workspace,
228
+ into: join(versionDir, 'tests'),
229
+ angular,
230
+ peerVersions: plan.installs,
231
+ pm: ctx.pm,
232
+ command: ctx.testCommand,
233
+ registry: ctx.registry,
234
+ cacheDir: ctx.cacheDir,
235
+ offline: ctx.offline,
236
+ allowScripts: ctx.allowScripts,
237
+ });
238
+ if (tests.environment)
239
+ return notChecked('The library tests', tests.environment, tests.errorLines);
240
+ checks.tests = result(tests.pass ? 'pass' : 'fail', tests.message, {
241
+ errorLines: tests.errorLines,
242
+ fullOutput: tests.fullOutput,
243
+ hint: tests.pass ? undefined : tests.errorLines[0],
244
+ durationMs: tests.durationMs,
245
+ });
246
+ }
247
+ }
248
+ return finish();
249
+ }
250
+ finally {
251
+ if (!ctx.keep)
252
+ rmSync(versionDir, { recursive: true, force: true, maxRetries: 3 });
253
+ }
254
+ }
255
+ /** Copies the author's example app and moves its Angular dependencies to this version. */
256
+ async function copyConsumer(ctx, angular, peerVersions, versionDir) {
257
+ const source = ctx.consumer;
258
+ if (!existsSync(join(source, 'angular.json')))
259
+ throw new Error(`--consumer ${source} has no angular.json.`);
260
+ const appDir = join(versionDir, 'app');
261
+ const skip = new Set(['node_modules', '.angular', 'dist', '.git']);
262
+ cpSync(source, appDir, {
263
+ recursive: true,
264
+ filter: (path) => !relative(source, path).split(sep).some((part) => skip.has(part)),
265
+ });
266
+ for (const lock of ['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock'])
267
+ rmSync(join(appDir, lock), { force: true });
268
+ const manifestPath = join(appDir, 'package.json');
269
+ const manifest = readJson(manifestPath);
270
+ const request = { angular, peerVersions };
271
+ if (manifest.dependencies)
272
+ manifest.dependencies = swapVersions(manifest.dependencies, request);
273
+ if (manifest.devDependencies)
274
+ manifest.devDependencies = swapVersions(manifest.devDependencies, request);
275
+ // The package under test comes from the tarball, not from wherever the example points.
276
+ delete manifest.dependencies?.[ctx.library.manifest.name];
277
+ delete manifest.devDependencies?.[ctx.library.manifest.name];
278
+ writeJson(manifestPath, manifest);
279
+ return appDir;
280
+ }
@@ -0,0 +1,73 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { npm } from "./exec.js";
4
+ /*
5
+ * Install scripts run arbitrary code on the machine. By default ng-matrix runs only the ones the
6
+ * Angular toolchain itself needs: every package with an install script in a fresh Angular 17 to 22
7
+ * app is on this list. The package under test and its dependencies run none, unless --allow-scripts.
8
+ */
9
+ export const TOOLCHAIN_BUILDS = ['@parcel/watcher', 'esbuild', 'lmdb', 'msgpackr-extract'];
10
+ /**
11
+ * In the toolchain's tree with an install script that isn't needed, so it stays off and isn't
12
+ * reported: fsevents (macOS only) ships a prebuilt binary; its script would rebuild it with node-gyp.
13
+ */
14
+ const TOOLCHAIN_UNNEEDED = ['fsevents'];
15
+ export const ALLOW_SCRIPTS_WARNING = '--allow-scripts: install scripts of the package under test and all of its dependencies will run on this machine.';
16
+ /**
17
+ * `npm install` with scripts off, then `npm rebuild` of just the toolchain packages: npm has no
18
+ * allow-list that works on both npm 10 and 11, but this does.
19
+ */
20
+ export async function npmInstall(args, options, allowScripts) {
21
+ if (allowScripts)
22
+ return npm(args, options);
23
+ const installed = await npm([...args, '--ignore-scripts'], options);
24
+ if (installed.code !== 0)
25
+ return installed;
26
+ const present = TOOLCHAIN_BUILDS.filter((name) => existsSync(join(options.cwd, 'node_modules', ...name.split('/'), 'package.json')));
27
+ if (!present.length)
28
+ return installed;
29
+ const rebuilt = await npm(['rebuild', ...present], options);
30
+ return {
31
+ code: rebuilt.code,
32
+ output: `${installed.output}\n${rebuilt.output}`,
33
+ durationMs: installed.durationMs + rebuilt.durationMs,
34
+ timedOut: rebuilt.timedOut,
35
+ };
36
+ }
37
+ /** pnpm-workspace.yaml lines: builds allowed for the toolchain only, or for everything. */
38
+ export function pnpmBuildSettings(allowScripts) {
39
+ if (allowScripts)
40
+ return ['strictDepBuilds: false', 'dangerouslyAllowAllBuilds: true'];
41
+ return ['strictDepBuilds: false', 'allowBuilds:', ...TOOLCHAIN_BUILDS.map((name) => ` ${JSON.stringify(name)}: true`)];
42
+ }
43
+ /** `name@version` of every installed package whose install script npm didn't run: its lockfile marks them. */
44
+ export function skippedByNpm(appDir) {
45
+ let lock;
46
+ try {
47
+ lock = JSON.parse(readFileSync(join(appDir, 'package-lock.json'), 'utf8'));
48
+ }
49
+ catch {
50
+ return [];
51
+ }
52
+ const skipped = new Set();
53
+ for (const [path, entry] of Object.entries(lock.packages ?? {})) {
54
+ if (!entry.hasInstallScript || !path.includes('node_modules/'))
55
+ continue;
56
+ const name = path.slice(path.lastIndexOf('node_modules/') + 'node_modules/'.length);
57
+ if (TOOLCHAIN_BUILDS.includes(name) || TOOLCHAIN_UNNEEDED.includes(name))
58
+ continue;
59
+ // The lockfile lists every platform's optional packages; only what's on disk counts.
60
+ if (!existsSync(join(appDir, path, 'package.json')))
61
+ continue;
62
+ skipped.add(entry.version ? `${name}@${entry.version}` : name);
63
+ }
64
+ return [...skipped].sort();
65
+ }
66
+ /** pnpm says so itself: `Ignored build scripts: core-js@3.46.0, sharp@0.34.1.` */
67
+ export function skippedByPnpm(output) {
68
+ const line = /Ignored build scripts:\s*(.+?)\.?\s*$/m.exec(output);
69
+ if (!line)
70
+ return [];
71
+ const names = line[1].split(/,\s*/).map((s) => s.trim()).filter(Boolean);
72
+ return [...new Set(names.filter((spec) => !TOOLCHAIN_UNNEEDED.includes(spec.replace(/(.)@[^@]*$/, '$1'))))].sort();
73
+ }