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/README.md +335 -55
- package/dist/app.js +4 -3
- package/dist/build.js +125 -4
- package/dist/cleanup.js +117 -0
- package/dist/cli.js +62 -10
- package/dist/exec.js +60 -4
- package/dist/install.js +27 -18
- package/dist/library.js +19 -3
- package/dist/model.js +1 -0
- package/dist/progress.js +65 -0
- package/dist/registry.js +66 -16
- package/dist/report.js +53 -6
- package/dist/run.js +86 -6
- package/dist/scripts.js +73 -0
- package/dist/smoke.js +8 -6
- package/dist/tests.js +10 -5
- package/dist/tools.js +70 -4
- package/package.json +1 -1
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 =
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
63
|
+
if (!packument) {
|
|
49
64
|
if (cached)
|
|
50
65
|
return cached.packument;
|
|
51
|
-
throw new
|
|
66
|
+
throw new RegistryError(`Couldn't get ${name} from the npm registry (${base}): ${problem}.`);
|
|
52
67
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
}
|
package/dist/scripts.js
ADDED
|
@@ -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
|
|
244
|
-
*
|
|
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
|
|
252
|
+
return false;
|
|
251
253
|
const parts = write.isUnion() ? write.types : [write];
|
|
252
|
-
//
|
|
253
|
-
const
|
|
254
|
-
return parts.some((part) => (part.flags &
|
|
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
|
|
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\
|
|
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:
|
|
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:
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
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",
|