angular-compat-matrix 0.1.0 → 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/registry.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  const DAY = 24 * 60 * 60 * 1000;
4
4
  const FULL_DOCUMENT = new Set(['@angular/core']);
@@ -26,9 +26,7 @@ export class Registry {
26
26
  }
27
27
  const dir = join(this.options.cacheDir, 'registry');
28
28
  const path = join(dir, file);
29
- const cached = existsSync(path)
30
- ? JSON.parse(readFileSync(path, 'utf8'))
31
- : undefined;
29
+ const cached = readCached(path);
32
30
  const maxAge = this.options.maxAgeMs ?? DAY;
33
31
  if (cached && (this.options.offline || Date.now() - cached.fetchedAt < maxAge))
34
32
  return cached.packument;
@@ -41,26 +39,78 @@ export class Registry {
41
39
  const accept = FULL_DOCUMENT.has(name)
42
40
  ? 'application/json'
43
41
  : 'application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8';
44
- let response;
45
- try {
46
- response = await fetch(url, { headers: { accept } });
42
+ const timeoutMs = this.options.timeoutMs ?? 30_000;
43
+ let packument;
44
+ let problem = '';
45
+ // Two tries: one dropped connection shouldn't end a run that takes minutes.
46
+ for (let attempt = 1; attempt <= 2 && !packument; attempt++) {
47
+ try {
48
+ const response = await fetch(url, { headers: { accept }, signal: AbortSignal.timeout(timeoutMs) });
49
+ if (response.status === 404)
50
+ throw new RegistryError(`${name} isn't on the npm registry (${base}).`);
51
+ if (!response.ok) {
52
+ problem = `it answered ${response.status} ${response.statusText}`;
53
+ continue;
54
+ }
55
+ packument = trim((await response.json()));
56
+ }
57
+ catch (error) {
58
+ if (error instanceof RegistryError)
59
+ throw error;
60
+ problem = describeFetchError(error, timeoutMs);
61
+ }
47
62
  }
48
- catch (error) {
63
+ if (!packument) {
49
64
  if (cached)
50
65
  return cached.packument;
51
- throw new Error(`Couldn't reach the npm registry for ${name}: ${error.message}`);
66
+ throw new RegistryError(`Couldn't get ${name} from the npm registry (${base}): ${problem}.`);
52
67
  }
53
- if (!response.ok) {
54
- if (cached)
55
- return cached.packument;
56
- throw new Error(`The npm registry answered ${response.status} for ${name}`);
68
+ writeCached(dir, path, packument);
69
+ return packument;
70
+ }
71
+ }
72
+ export class RegistryError extends Error {
73
+ }
74
+ function describeFetchError(error, timeoutMs) {
75
+ if (error.name === 'TimeoutError' || error.name === 'AbortError')
76
+ return `no answer within ${timeoutMs / 1000} s`;
77
+ const cause = error.cause;
78
+ const code = cause?.code;
79
+ if (code === 'ENOTFOUND' || code === 'EAI_AGAIN')
80
+ return 'the host name could not be resolved (are you offline?)';
81
+ if (code === 'ECONNREFUSED')
82
+ return 'the connection was refused';
83
+ if (code === 'ECONNRESET')
84
+ return 'the connection was reset';
85
+ return cause?.message ?? error.message;
86
+ }
87
+ /** A cache entry, or nothing when it's missing or unreadable (a crashed write, a full disk): it's refetched. */
88
+ function readCached(path) {
89
+ if (!existsSync(path))
90
+ return undefined;
91
+ try {
92
+ const entry = JSON.parse(readFileSync(path, 'utf8'));
93
+ if (typeof entry.fetchedAt === 'number' && entry.packument && typeof entry.packument.versions === 'object') {
94
+ return entry;
57
95
  }
58
- const packument = trim((await response.json()));
96
+ }
97
+ catch {
98
+ // fall through
99
+ }
100
+ rmSync(path, { force: true });
101
+ return undefined;
102
+ }
103
+ /** Writes through a temporary file, so a crash or a full disk never leaves a half-written entry. */
104
+ function writeCached(dir, path, packument) {
105
+ const tmp = `${path}.${process.pid}.tmp`;
106
+ try {
59
107
  mkdirSync(dir, { recursive: true });
60
- const tmp = `${path}.${process.pid}.tmp`;
61
108
  writeFileSync(tmp, JSON.stringify({ fetchedAt: Date.now(), packument }));
62
109
  renameSync(tmp, path);
63
- return packument;
110
+ }
111
+ catch {
112
+ // Caching is an optimisation; the run goes on without it.
113
+ rmSync(tmp, { force: true });
64
114
  }
65
115
  }
66
116
  export function trim(raw) {
package/dist/report.js CHANGED
@@ -31,7 +31,7 @@ angularImports = ['@angular/core']) {
31
31
  if (failure) {
32
32
  const [name, check] = failure;
33
33
  // Partial's message names the declaration; a build's message is just "failed", so its hint says more.
34
- const why = name === 'partial' && check.hint ? `${check.message} ${check.hint}` : (check.hint ?? check.message);
34
+ const why = name === 'partial' && check.hint ? `${check.message} ${check.hint}` : (check.hint ?? check.errorLines?.[0] ?? check.message);
35
35
  if (version.claimed === true) {
36
36
  raw.push({ level: 'error', majors: [m], key: `fail:${name}:${why}`, text: `${CHECK_LABELS[name]}: ${why}` });
37
37
  }
@@ -46,6 +46,15 @@ angularImports = ['@angular/core']) {
46
46
  else {
47
47
  raw.push({ level: 'warning', majors: [m], key: `fail:${name}:${why}`, text: `${CHECK_LABELS[name]}: ${why}` });
48
48
  }
49
+ if (version.skippedInstallScripts?.length) {
50
+ const list = version.skippedInstallScripts.join(', ');
51
+ raw.push({
52
+ level: 'note',
53
+ majors: [m],
54
+ key: `scripts:${list}`,
55
+ text: `Install scripts didn't run for ${list} (the default). If the failure comes from one of them, check again with --allow-scripts.`,
56
+ });
57
+ }
49
58
  }
50
59
  else if (version.claimed === false) {
51
60
  const ranges = version.peerMisses.map((miss) => `${miss.name} "${miss.range}"`).join(', ');
@@ -56,6 +65,24 @@ angularImports = ['@angular/core']) {
56
65
  text: `works, but peerDependencies doesn't allow it (${ranges}). Widen the range to include it.`,
57
66
  });
58
67
  }
68
+ if (version.omittedEntryPoints?.length) {
69
+ for (const item of version.omittedEntryPoints) {
70
+ raw.push({
71
+ level: 'warning',
72
+ majors: [m],
73
+ key: `undeclared:${item.entry}:${item.missingPackage}`,
74
+ text: `entry point ${item.entry} imports ${item.missingPackage}, which isn't declared in dependencies or peers`,
75
+ });
76
+ }
77
+ }
78
+ if (version.dtsErrors) {
79
+ raw.push({
80
+ level: 'warning',
81
+ majors: [m],
82
+ key: 'skipLibCheck:dts-errors',
83
+ text: 'library typings have errors with skipLibCheck: false',
84
+ });
85
+ }
59
86
  }
60
87
  // One finding per distinct problem, listing every major it applies to.
61
88
  const merged = [];
@@ -161,11 +188,18 @@ export function renderTerminal(report, verbose = false) {
161
188
  for (const version of failing) {
162
189
  const failure = firstFailure(version);
163
190
  const errors = version.setupError ? version.setupError.split('\n').slice(1) : (failure?.[1].errorLines ?? []);
164
- if (!errors.length)
191
+ const full = failure?.[1].fullOutput;
192
+ if (!errors.length && !full)
165
193
  continue;
166
194
  lines.push('', `${rowLabel(version)}, ${failure ? CHECK_LABELS[failure[0]] : 'setup'}:`);
167
- for (const line of errors.slice(0, verbose ? errors.length : 6))
168
- lines.push(` ${line.trim()}`);
195
+ if (verbose && full) {
196
+ for (const line of full.split(/\r?\n/))
197
+ lines.push(` ${line}`);
198
+ }
199
+ else {
200
+ for (const line of errors.slice(0, 6))
201
+ lines.push(` ${line.trim()}`);
202
+ }
169
203
  }
170
204
  lines.push('', exitSummary(report));
171
205
  return lines.join('\n') + '\n';
@@ -228,6 +262,19 @@ export function renderMarkdown(report) {
228
262
  }
229
263
  return lines.join('\n') + '\n';
230
264
  }
231
- export function renderJson(report) {
232
- return JSON.stringify(report, null, 2) + '\n';
265
+ export function renderJson(report, verbose = false) {
266
+ if (verbose) {
267
+ return JSON.stringify(report, null, 2) + '\n';
268
+ }
269
+ const stripped = {
270
+ ...report,
271
+ versions: report.versions.map((v) => ({
272
+ ...v,
273
+ checks: Object.fromEntries(Object.entries(v.checks).map(([name, check]) => {
274
+ const { fullOutput: _full, ...rest } = check;
275
+ return [name, rest];
276
+ })),
277
+ })),
278
+ };
279
+ return JSON.stringify(stripped, null, 2) + '\n';
233
280
  }
package/dist/run.js CHANGED
@@ -3,7 +3,7 @@ import { createRequire } from 'node:module';
3
3
  import { freemem, availableParallelism } from 'node:os';
4
4
  import { join, relative, sep } from 'node:path';
5
5
  import { configureBuild, generateApp, readJson, writeJson } from "./app.js";
6
- import { ngBuild } from "./build.js";
6
+ import { findUndeclaredImports, isDtsOnlyFailure, ngBuild } from "./build.js";
7
7
  import { installLibrary } from "./install.js";
8
8
  import { CHECKS, result } from "./model.js";
9
9
  import { judgePartial } from "./partial.js";
@@ -55,9 +55,16 @@ export async function checkVersion(ctx, angular) {
55
55
  };
56
56
  const finish = () => {
57
57
  report.durationMs = Date.now() - started;
58
+ ctx.progress?.done(angular.major);
58
59
  ctx.log(`${tag}: done in ${Math.round(report.durationMs / 1000)} s`);
59
60
  return report;
60
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
+ };
61
68
  // Peer range: reported, but never stops the other checks.
62
69
  const plan = await planPeers(ctx.library.manifest, angular.version, ctx.registry);
63
70
  report.claimed = plan.claimed;
@@ -72,6 +79,7 @@ export async function checkVersion(ctx, angular) {
72
79
  mkdirSync(versionDir, { recursive: true });
73
80
  let appDir;
74
81
  try {
82
+ ctx.progress?.step(angular.major, ctx.consumer ? 'creating consumer app' : `ng new (CLI ${angular.cliVersion})`);
75
83
  ctx.log(`${tag}: creating the app (${ctx.consumer ? 'your --consumer app' : `ng new, CLI ${angular.cliVersion}`})`);
76
84
  appDir = ctx.consumer
77
85
  ? await copyConsumer(ctx, angular, plan.installs, versionDir)
@@ -86,6 +94,7 @@ export async function checkVersion(ctx, angular) {
86
94
  return finish();
87
95
  }
88
96
  try {
97
+ ctx.progress?.step(angular.major, `installing with ${ctx.pm}`);
89
98
  ctx.log(`${tag}: installing ${ctx.library.manifest.name} with ${ctx.pm}`);
90
99
  const installStarted = Date.now();
91
100
  const installed = await installLibrary({
@@ -97,9 +106,16 @@ export async function checkVersion(ctx, angular) {
97
106
  angularMajor: angular.major,
98
107
  cacheDir: ctx.cacheDir,
99
108
  offline: ctx.offline,
109
+ allowScripts: ctx.allowScripts,
100
110
  });
111
+ if (installed.skippedScripts.length)
112
+ report.skippedInstallScripts = installed.skippedScripts;
113
+ if (installed.environment)
114
+ return notChecked('The install', installed.environment, installed.errorLines);
101
115
  checks.install = result(installed.pass ? 'pass' : 'fail', installed.message, {
102
116
  errorLines: installed.errorLines,
117
+ fullOutput: installed.fullOutput,
118
+ hint: installed.pass ? undefined : installed.errorLines[0],
103
119
  durationMs: Date.now() - installStarted,
104
120
  });
105
121
  if (!installed.pass)
@@ -114,9 +130,10 @@ export async function checkVersion(ctx, angular) {
114
130
  if (!partial.pass)
115
131
  return finish();
116
132
  let files;
133
+ let api;
117
134
  if (!ctx.consumer) {
118
135
  try {
119
- const api = readPublicApi(appDir, entryPoints(ctx.library.manifest));
136
+ api = readPublicApi(appDir, entryPoints(ctx.library.manifest));
120
137
  writeSmoke(appDir, renderSmoke(api, { signalWriteBrand: hasSignalWriteBrand(appDir) }));
121
138
  files = ['src/ngm-main.ts'];
122
139
  }
@@ -128,25 +145,83 @@ export async function checkVersion(ctx, angular) {
128
145
  }
129
146
  }
130
147
  const project = configureBuild(appDir, files?.[0]);
148
+ ctx.progress?.step(angular.major, 'AOT build');
131
149
  ctx.log(`${tag}: AOT build`);
132
- const aot = await ngBuild(appDir, project.tsConfig, { strictTemplates: false, skipLibCheck: true }, files);
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
+ }
133
189
  checks.aot = result(aot.pass ? 'pass' : 'fail', aot.pass ? 'Production build succeeded.' : 'Production build failed.', {
134
190
  errorLines: aot.errorLines,
135
- hint: aot.hint,
191
+ fullOutput: aot.fullOutput,
192
+ hint: aot.hint ?? aot.errorLines[0],
136
193
  durationMs: aot.durationMs,
137
194
  });
138
195
  if (!aot.pass)
139
196
  return finish();
197
+ ctx.progress?.step(angular.major, 'strict build');
140
198
  ctx.log(`${tag}: strict build`);
141
199
  const strict = await ngBuild(appDir, project.tsConfig, { strictTemplates: true, skipLibCheck: false }, files);
142
- checks.strict = result(strict.pass ? 'pass' : 'fail', strict.pass ? 'Builds with strictTemplates: true and skipLibCheck: false.' : 'Fails with strictTemplates: true and skipLibCheck: false.', { errorLines: strict.errorLines, hint: strict.hint, durationMs: strict.durationMs });
143
- if (!strict.pass)
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')
144
218
  return finish();
145
219
  if (ctx.tests) {
146
220
  if (!ctx.workspace) {
147
221
  checks.tests = result('off', 'No workspace to test: the package came from --tarball or --package.');
148
222
  }
149
223
  else {
224
+ ctx.progress?.step(angular.major, 'library tests');
150
225
  ctx.log(`${tag}: library tests`);
151
226
  const tests = await runLibraryTests({
152
227
  workspace: ctx.workspace,
@@ -158,9 +233,14 @@ export async function checkVersion(ctx, angular) {
158
233
  registry: ctx.registry,
159
234
  cacheDir: ctx.cacheDir,
160
235
  offline: ctx.offline,
236
+ allowScripts: ctx.allowScripts,
161
237
  });
238
+ if (tests.environment)
239
+ return notChecked('The library tests', tests.environment, tests.errorLines);
162
240
  checks.tests = result(tests.pass ? 'pass' : 'fail', tests.message, {
163
241
  errorLines: tests.errorLines,
242
+ fullOutput: tests.fullOutput,
243
+ hint: tests.pass ? undefined : tests.errorLines[0],
164
244
  durationMs: tests.durationMs,
165
245
  });
166
246
  }
@@ -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
+ }
package/dist/smoke.js CHANGED
@@ -240,18 +240,20 @@ class ApiReader {
240
240
  return inputs;
241
241
  }
242
242
  /**
243
- * Whether users write this input as a bare attribute (`<div myFlag>`): it takes a boolean,
244
- * a string, or anything. A number or object input is bound to a value instead.
243
+ * Whether users write this input as a bare attribute (`<div myFlag>`): it takes a string,
244
+ * accepts string/boolean (e.g. booleanAttribute transform), or anything.
245
+ * If an input accepts strictly boolean (without accepting string or empty string), it must be bound
246
+ * with `[prop]="..."` because a bare attribute passes string `""`, causing TS2322 in strict templates.
245
247
  */
246
248
  usedBare(node, property) {
247
249
  const ts = this.ts;
248
250
  const write = this.inputWriteType(node, property);
249
251
  if (!write)
250
- return true;
252
+ return false;
251
253
  const parts = write.isUnion() ? write.types : [write];
252
- // A union of other string literals ('ltr' | 'rtl') is bound to a value, like a number.
253
- const flags = ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.String | ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral;
254
- return parts.some((part) => (part.flags & flags) !== 0 || (part.isStringLiteral() && part.value === ''));
254
+ // Accepts string, any, unknown, or empty string literal "" (from booleanAttribute or union)
255
+ const stringFlags = ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.String;
256
+ return parts.some((part) => (part.flags & stringFlags) !== 0 || (part.isStringLiteral() && part.value === ''));
255
257
  }
256
258
  /** The type a binding to the input must have: a signal input's write type, `ngAcceptInputType_*`, or the property's type. */
257
259
  inputWriteType(node, property) {
package/dist/tests.js CHANGED
@@ -2,8 +2,9 @@ import { cpSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { join, relative, sep } from 'node:path';
3
3
  import semver from 'semver';
4
4
  import { readJson, writeJson } from "./app.js";
5
- import { errorLines, exec, npm, quietEnv } from "./exec.js";
5
+ import { environmentProblem, errorLines, exec, npm, quietEnv, stepTimeout } from "./exec.js";
6
6
  import { FRAMEWORK } from "./peers.js";
7
+ import { npmInstall, pnpmBuildSettings } from "./scripts.js";
7
8
  import { ensureTool, PNPM_VERSION, pnpmEntry, pnpmShimDir, withPath } from "./tools.js";
8
9
  /** Packages released with the Angular CLI, versioned with it. */
9
10
  const TOOLING = /^(@angular\/(cli|build|ssr|pwa)|@angular-devkit\/.+|@schematics\/angular)$/;
@@ -67,17 +68,17 @@ export async function runLibraryTests(request) {
67
68
  const args = ['install', '--legacy-peer-deps', '--no-audit', '--no-fund'];
68
69
  if (request.offline)
69
70
  args.push('--offline');
70
- install = await npm(args, { cwd: request.into, env, timeoutMs: 15 * 60_000 });
71
+ install = await npmInstall(args, { cwd: request.into, env, timeoutMs: stepTimeout() }, request.allowScripts);
71
72
  }
72
73
  else {
73
- writeFileSync(join(request.into, 'pnpm-workspace.yaml'), 'strictPeerDependencies: false\nstrictDepBuilds: false\n');
74
+ writeFileSync(join(request.into, 'pnpm-workspace.yaml'), ['strictPeerDependencies: false', ...pnpmBuildSettings(request.allowScripts)].join('\n') + '\n');
74
75
  const pnpm = await ensureTool(request.cacheDir, 'pnpm', PNPM_VERSION, request.offline);
75
76
  // The test command runs in a shell; it has to find this pnpm, not whatever is (or isn't) installed.
76
77
  env = withPath(env, pnpmShimDir(request.cacheDir, pnpm));
77
78
  install = await exec(process.execPath, [pnpmEntry(pnpm), 'install', '--no-frozen-lockfile'], {
78
79
  cwd: request.into,
79
80
  env,
80
- timeoutMs: 15 * 60_000,
81
+ timeoutMs: stepTimeout(),
81
82
  });
82
83
  }
83
84
  if (install.code !== 0) {
@@ -85,16 +86,20 @@ export async function runLibraryTests(request) {
85
86
  pass: false,
86
87
  message: `Installing the workspace with Angular ${request.angular.version} failed.`,
87
88
  errorLines: errorLines(install.output),
89
+ fullOutput: install.output,
88
90
  durationMs: Date.now() - started,
91
+ environment: environmentProblem(install),
89
92
  };
90
93
  }
91
94
  const command = request.command ?? `${request.pm} test`;
92
- const result = await exec(command, [], { cwd: request.into, env, shell: true, timeoutMs: 30 * 60_000 });
95
+ const result = await exec(command, [], { cwd: request.into, env, shell: true, timeoutMs: stepTimeout() });
93
96
  const tail = result.output.split(/\r?\n/).filter((l) => l.trim()).slice(-8);
94
97
  return {
95
98
  pass: result.code === 0,
96
99
  message: result.code === 0 ? `\`${command}\` passed.` : `\`${command}\` exited with ${result.code}.`,
97
100
  errorLines: result.code === 0 ? tail.slice(-3) : errorLines(result.output),
101
+ fullOutput: result.output,
98
102
  durationMs: Date.now() - started,
103
+ environment: result.code === 0 ? undefined : environmentProblem(result),
99
104
  };
100
105
  }
package/dist/tools.js CHANGED
@@ -1,7 +1,7 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { delimiter, dirname, join } from 'node:path';
4
- import { errorLines, npm } from "./exec.js";
4
+ import { environmentProblem, errorLines, npm } from "./exec.js";
5
5
  /** The pnpm ng-matrix uses, pinned so its peer and build-script rules don't shift under it. */
6
6
  export const PNPM_VERSION = '12.15.0';
7
7
  export function defaultCacheDir() {
@@ -11,6 +11,57 @@ export function defaultCacheDir() {
11
11
  return join(process.env.LOCALAPPDATA, 'ng-matrix', 'Cache');
12
12
  return join(process.env.XDG_CACHE_HOME ?? join(homedir(), '.cache'), 'ng-matrix');
13
13
  }
14
+ export function cacheStats(cacheDir) {
15
+ let bytes = 0;
16
+ let count = 0;
17
+ if (!existsSync(cacheDir))
18
+ return { bytes: 0, count: 0 };
19
+ const stack = [cacheDir];
20
+ while (stack.length) {
21
+ const current = stack.pop();
22
+ let entries;
23
+ try {
24
+ entries = readdirSync(current, { withFileTypes: true });
25
+ }
26
+ catch {
27
+ continue;
28
+ }
29
+ for (const entry of entries) {
30
+ const full = join(current, entry.name);
31
+ if (entry.isDirectory()) {
32
+ stack.push(full);
33
+ }
34
+ else if (entry.isFile()) {
35
+ try {
36
+ bytes += statSync(full).size;
37
+ count++;
38
+ }
39
+ catch {
40
+ // ignore transient access errors
41
+ }
42
+ }
43
+ }
44
+ }
45
+ return { bytes, count };
46
+ }
47
+ export function formatBytes(bytes) {
48
+ if (bytes < 1024)
49
+ return `${bytes} B`;
50
+ const units = ['KB', 'MB', 'GB', 'TB'];
51
+ let val = bytes / 1024;
52
+ for (const unit of units) {
53
+ if (val < 1024 || unit === 'TB') {
54
+ return `${val >= 10 ? val.toFixed(1) : val.toFixed(2)} ${unit}`;
55
+ }
56
+ val /= 1024;
57
+ }
58
+ return `${bytes} B`;
59
+ }
60
+ export function cleanCache(cacheDir) {
61
+ if (existsSync(cacheDir)) {
62
+ rmSync(cacheDir, { recursive: true, force: true, maxRetries: 5 });
63
+ }
64
+ }
14
65
  const pending = new Map();
15
66
  /**
16
67
  * Installs one npm package into the cache once (say, `@angular/cli@21.2.28`) and returns its folder,
@@ -28,8 +79,11 @@ export function ensureTool(cacheDir, name, version, offline = false) {
28
79
  async function install(cacheDir, name, version, offline) {
29
80
  const home = join(cacheDir, 'tools', `${name.replace('/', '+')}@${version}`);
30
81
  const pkgDir = join(home, 'node_modules', ...name.split('/'));
31
- if (existsSync(join(pkgDir, 'package.json')))
82
+ if (isComplete(pkgDir))
32
83
  return pkgDir;
84
+ // A damaged entry (a truncated package.json, a missing bin) is downloaded again.
85
+ if (existsSync(home))
86
+ rmSync(home, { recursive: true, force: true, maxRetries: 5 });
33
87
  const tmp = `${home}.${process.pid}.tmp`;
34
88
  rmSync(tmp, { recursive: true, force: true });
35
89
  mkdirSync(tmp, { recursive: true });
@@ -39,7 +93,8 @@ async function install(cacheDir, name, version, offline) {
39
93
  const result = await npm(args, { cwd: tmp });
40
94
  if (result.code !== 0) {
41
95
  rmSync(tmp, { recursive: true, force: true });
42
- throw new Error(`Couldn't download ${name}@${version}:\n${errorLines(result.output).join('\n')}`);
96
+ const why = environmentProblem(result);
97
+ throw new Error(`Couldn't download ${name}@${version}${why ? `: ${why}` : ''}:\n${errorLines(result.output).join('\n')}`);
43
98
  }
44
99
  mkdirSync(dirname(home), { recursive: true });
45
100
  try {
@@ -51,6 +106,17 @@ async function install(cacheDir, name, version, offline) {
51
106
  }
52
107
  return pkgDir;
53
108
  }
109
+ /** A cached tool is usable when its package.json parses and every bin it declares is there. */
110
+ export function isComplete(pkgDir) {
111
+ try {
112
+ const manifest = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'));
113
+ const bins = typeof manifest.bin === 'string' ? [manifest.bin] : Object.values(manifest.bin ?? {});
114
+ return bins.every((bin) => existsSync(join(pkgDir, bin)));
115
+ }
116
+ catch {
117
+ return false;
118
+ }
119
+ }
54
120
  export function binOf(pkgDir, bin) {
55
121
  const manifest = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'));
56
122
  const path = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.[bin];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "angular-compat-matrix",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Find out which Angular versions your published library really works with: it installs the packed package into a fresh app for each Angular major, builds it, and reports a matrix.",
5
5
  "keywords": [
6
6
  "angular",