@lernapps/tooling 0.3.0 → 0.4.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.
|
@@ -32,6 +32,11 @@ const base: PlaywrightTestConfig = {
|
|
|
32
32
|
// another number of workers: the tooling's own tests set 1, because they run many apps' checks at the same time.
|
|
33
33
|
fullyParallel: true,
|
|
34
34
|
workers: Number(process.env["LERNAPPS_PLAYWRIGHT_WORKERS"]) || "75%",
|
|
35
|
+
// On CI (a small shared runner) a single interaction can now and then wait past the timeout under load
|
|
36
|
+
// (lernapps/app-templates#125): a failed test runs up to twice more there. One that passes on a retry counts as
|
|
37
|
+
// "flaky" in the output, not as passed, so it stays visible; a test that fails every time still fails the check.
|
|
38
|
+
// Locally no retry, so that a test that only sometimes passes shows up while the app is written.
|
|
39
|
+
retries: process.env["CI"] ? 2 : 0,
|
|
35
40
|
reporter: [["line"]],
|
|
36
41
|
// LERNAPPS_CHROMIUM: an installed Chromium that lernapps check starts where Playwright's own is missing
|
|
37
42
|
// (check/visit.ts)
|
package/dist/cli.mjs
CHANGED
|
@@ -5,14 +5,219 @@ import { a as countFailedChecks, c as planFrontMatter, i as PLAN_NAME, l as plan
|
|
|
5
5
|
import { createRequire } from "node:module";
|
|
6
6
|
import { spawnSync } from "node:child_process";
|
|
7
7
|
import { appendFileSync, closeSync, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
8
|
-
import { homedir, tmpdir } from "node:os";
|
|
8
|
+
import { homedir, hostname, tmpdir } from "node:os";
|
|
9
9
|
import { basename, delimiter, dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
10
10
|
import { parseArgs } from "node:util";
|
|
11
11
|
import { parse, stringify } from "yaml";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
12
13
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
13
14
|
import { createServer } from "node:http";
|
|
14
15
|
import { chromium } from "playwright";
|
|
15
|
-
|
|
16
|
+
/** Every package the lock file installs below node_modules, by its path: the version it asks for and whether it is optional. */
|
|
17
|
+
function lockedPackages(dir) {
|
|
18
|
+
const file = join(dir, "package-lock.json");
|
|
19
|
+
if (!existsSync(file)) return void 0;
|
|
20
|
+
const lock = JSON.parse(readFileSync(file, "utf8"));
|
|
21
|
+
return new Map(Object.entries(lock.packages ?? {}).filter(([path, entry]) => path.startsWith("node_modules/") && entry.link !== true));
|
|
22
|
+
}
|
|
23
|
+
/** The version of the package installed at `path` (below `dir`), or undefined when none is installed there. */
|
|
24
|
+
function installedVersion(dir, path) {
|
|
25
|
+
try {
|
|
26
|
+
const manifest = JSON.parse(readFileSync(join(dir, path, "package.json"), "utf8"));
|
|
27
|
+
return typeof manifest.version === "string" ? manifest.version : "";
|
|
28
|
+
} catch {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The installed version of every package the lock file names, as one line per package ("path version", "path -" when
|
|
34
|
+
* none is installed), sorted: what node_modules holds, for the key of a cached result. Undefined without a lock file.
|
|
35
|
+
*/
|
|
36
|
+
function installedPackages(dir) {
|
|
37
|
+
const locked = lockedPackages(dir);
|
|
38
|
+
if (locked === void 0) return void 0;
|
|
39
|
+
return [...locked.keys()].sort().map((path) => `${path} ${installedVersion(dir, path) ?? "-"}`).join("\n");
|
|
40
|
+
}
|
|
41
|
+
/** How many mismatches a finding names before it says how many more there are. */
|
|
42
|
+
const SHOWN = 5;
|
|
43
|
+
/**
|
|
44
|
+
* Whether node_modules holds what package-lock.json asks for: every package at its version. An optional package that
|
|
45
|
+
* is not installed (one for another platform) is no mismatch. Empty without a lock file or when everything matches;
|
|
46
|
+
* else one problem that names the first mismatches.
|
|
47
|
+
*/
|
|
48
|
+
function installedProblems(dir) {
|
|
49
|
+
const locked = lockedPackages(dir);
|
|
50
|
+
if (locked === void 0 || locked.size === 0) return [];
|
|
51
|
+
const mismatches = [];
|
|
52
|
+
for (const [path, entry] of [...locked].sort(([a], [b]) => a.localeCompare(b))) {
|
|
53
|
+
const installed = installedVersion(dir, path);
|
|
54
|
+
if (installed === void 0 && entry.optional === true) continue;
|
|
55
|
+
if (installed !== void 0 && (entry.version === void 0 || installed === entry.version)) continue;
|
|
56
|
+
const name = path.slice(path.lastIndexOf("node_modules/") + 13);
|
|
57
|
+
mismatches.push(installed === void 0 ? `${name} is not installed (the lock file asks for ${entry.version ?? "it"})` : `${name} ${installed} is installed, the lock file asks for ${entry.version}`);
|
|
58
|
+
}
|
|
59
|
+
if (mismatches.length === 0) return [];
|
|
60
|
+
const more = mismatches.length > SHOWN ? ` and ${mismatches.length - SHOWN} more` : "";
|
|
61
|
+
return [{
|
|
62
|
+
where: "node_modules",
|
|
63
|
+
found: `node_modules does not hold what package-lock.json asks for: ${mismatches.slice(0, SHOWN).join("; ")}${more}. CI installs with npm ci, so the check would test other packages than CI`,
|
|
64
|
+
fix: "Run npm ci, which installs exactly what package-lock.json asks for, then check again"
|
|
65
|
+
}];
|
|
66
|
+
}
|
|
67
|
+
/** A warning when this Node has another major version than CI, or undefined. */
|
|
68
|
+
function nodeMajorProblem(version = process.versions.node) {
|
|
69
|
+
if (Number(version.split(".")[0]) === 26) return void 0;
|
|
70
|
+
return {
|
|
71
|
+
where: "node",
|
|
72
|
+
found: `Node ${version} runs this check, CI runs Node 26: what passes here may fail in CI`,
|
|
73
|
+
fix: `Use Node 26 (for example nvm install 26); the check does not fail for this`,
|
|
74
|
+
severity: "warning"
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const git$2 = (cwd, ...args) => {
|
|
78
|
+
const result = spawnSync("git", args, {
|
|
79
|
+
cwd,
|
|
80
|
+
encoding: "utf8"
|
|
81
|
+
});
|
|
82
|
+
return result.status === 0 ? result.stdout : void 0;
|
|
83
|
+
};
|
|
84
|
+
/** The folder of the cache and the lock: <git common dir>/lernapps, or undefined outside a repository. */
|
|
85
|
+
function stateDir(cwd = ".") {
|
|
86
|
+
const common = git$2(cwd, "rev-parse", "--git-common-dir")?.trim();
|
|
87
|
+
return common === void 0 || common === "" ? void 0 : join(resolve(cwd, common), "lernapps");
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The key of the state in `cwd` for a check of `parts`, or the reason it has none: not a repository, no commit, a
|
|
91
|
+
* working tree with changes git sees (untracked files included), or a run in CI.
|
|
92
|
+
*/
|
|
93
|
+
function cacheKey(cwd, parts, toolingVersion, env = process.env) {
|
|
94
|
+
if (env["CI"] !== void 0 && env["CI"] !== "" && env["CI"] !== "false") return { none: "CI checks every time" };
|
|
95
|
+
const top = git$2(cwd, "rev-parse", "--show-toplevel")?.trim();
|
|
96
|
+
if (top === void 0) return { none: "not a git repository" };
|
|
97
|
+
const tree = git$2(cwd, "rev-parse", "--verify", "--quiet", "HEAD^{tree}")?.trim();
|
|
98
|
+
if (tree === void 0 || tree === "") return { none: "no commit yet" };
|
|
99
|
+
const status = git$2(top, "status", "--porcelain", "--untracked-files=all");
|
|
100
|
+
if (status === void 0) return { none: "git status failed" };
|
|
101
|
+
if (status.trim() !== "") return { none: "the working tree has changes that are not committed" };
|
|
102
|
+
const variables = Object.keys(env).filter((name) => name.startsWith("LERNAPPS_")).sort().map((name) => `${name}=${env[name]}`);
|
|
103
|
+
const hash = createHash("sha256");
|
|
104
|
+
for (const part of [
|
|
105
|
+
`tree ${tree}`,
|
|
106
|
+
`folder ${relative(top, resolve(cwd)).split("\\").join("/")}`,
|
|
107
|
+
`parts ${[...parts].sort().join(",")}`,
|
|
108
|
+
`tooling ${toolingVersion}`,
|
|
109
|
+
`node ${process.versions.node}`,
|
|
110
|
+
...variables,
|
|
111
|
+
`installed\n${installedPackages(cwd) ?? ""}`
|
|
112
|
+
]) {
|
|
113
|
+
hash.update(part);
|
|
114
|
+
hash.update("\0");
|
|
115
|
+
}
|
|
116
|
+
return { key: hash.digest("hex") };
|
|
117
|
+
}
|
|
118
|
+
const checkedFile = (dir, key) => join(dir, "checked", `${key}.yaml`);
|
|
119
|
+
/** The report of a passing check of this state, and when it was made; undefined when there is none. */
|
|
120
|
+
function cachedReport(dir, key) {
|
|
121
|
+
const file = checkedFile(dir, key);
|
|
122
|
+
try {
|
|
123
|
+
return {
|
|
124
|
+
report: readFileSync(file, "utf8"),
|
|
125
|
+
at: statSync(file).mtime
|
|
126
|
+
};
|
|
127
|
+
} catch {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** Keeps the report of a passing check of this state; the cache keeps the newest KEEP states. */
|
|
132
|
+
function storeReport(dir, key, report) {
|
|
133
|
+
const folder = join(dir, "checked");
|
|
134
|
+
mkdirSync(folder, { recursive: true });
|
|
135
|
+
writeFileSync(checkedFile(dir, key), report);
|
|
136
|
+
const files = readdirSync(folder).filter((name) => name.endsWith(".yaml")).map((name) => ({
|
|
137
|
+
file: join(folder, name),
|
|
138
|
+
at: statSync(join(folder, name)).mtimeMs
|
|
139
|
+
})).sort((a, b) => b.at - a.at);
|
|
140
|
+
for (const { file } of files.slice(20)) rmSync(file, { force: true });
|
|
141
|
+
}
|
|
142
|
+
/** Whether a process with this id runs on this machine. */
|
|
143
|
+
function alive(pid) {
|
|
144
|
+
try {
|
|
145
|
+
process.kill(pid, 0);
|
|
146
|
+
return true;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
return error.code === "EPERM";
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function readOwner(lock) {
|
|
152
|
+
try {
|
|
153
|
+
return JSON.parse(readFileSync(join(lock, "owner.json"), "utf8"));
|
|
154
|
+
} catch {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
159
|
+
/**
|
|
160
|
+
* Takes the lock of checks in this clone (a folder, made atomically with mkdir), waiting while another check holds
|
|
161
|
+
* it; `waiting` is called once, with its owner, when it has to wait. A lock whose process no longer runs on this
|
|
162
|
+
* machine is taken over. Returns the function that releases it; the lock is also released when the process exits or
|
|
163
|
+
* is interrupted.
|
|
164
|
+
*/
|
|
165
|
+
async function acquireLock(dir, waiting, poll = 1e3) {
|
|
166
|
+
mkdirSync(dir, { recursive: true });
|
|
167
|
+
const lock = join(dir, "check.lock");
|
|
168
|
+
let told = false;
|
|
169
|
+
for (;;) {
|
|
170
|
+
try {
|
|
171
|
+
mkdirSync(lock);
|
|
172
|
+
break;
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if (error.code !== "EEXIST") throw error;
|
|
175
|
+
}
|
|
176
|
+
const owner = readOwner(lock);
|
|
177
|
+
if (owner !== void 0 && owner.host === hostname() && !alive(owner.pid)) {
|
|
178
|
+
rmSync(lock, {
|
|
179
|
+
recursive: true,
|
|
180
|
+
force: true
|
|
181
|
+
});
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const made = statSync(lock, { throwIfNoEntry: false })?.mtimeMs;
|
|
185
|
+
if (owner === void 0 && made !== void 0 && Date.now() - made > 10 * poll) {
|
|
186
|
+
rmSync(lock, {
|
|
187
|
+
recursive: true,
|
|
188
|
+
force: true
|
|
189
|
+
});
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (!told) {
|
|
193
|
+
waiting(owner);
|
|
194
|
+
told = true;
|
|
195
|
+
}
|
|
196
|
+
await sleep(poll);
|
|
197
|
+
}
|
|
198
|
+
writeFileSync(join(lock, "owner.json"), JSON.stringify({
|
|
199
|
+
pid: process.pid,
|
|
200
|
+
host: hostname(),
|
|
201
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
202
|
+
}));
|
|
203
|
+
const release = () => {
|
|
204
|
+
process.off("exit", release);
|
|
205
|
+
process.off("SIGINT", interrupted);
|
|
206
|
+
process.off("SIGTERM", interrupted);
|
|
207
|
+
if (readOwner(lock)?.pid === process.pid) rmSync(lock, {
|
|
208
|
+
recursive: true,
|
|
209
|
+
force: true
|
|
210
|
+
});
|
|
211
|
+
};
|
|
212
|
+
const interrupted = (signal) => {
|
|
213
|
+
release();
|
|
214
|
+
process.exit(signal === "SIGINT" ? 130 : 143);
|
|
215
|
+
};
|
|
216
|
+
process.on("exit", release);
|
|
217
|
+
process.on("SIGINT", interrupted);
|
|
218
|
+
process.on("SIGTERM", interrupted);
|
|
219
|
+
return release;
|
|
220
|
+
}
|
|
16
221
|
// check/run.ts
|
|
17
222
|
const RULE_PAGE = "https://lernapps.net/tooling/rules/#";
|
|
18
223
|
const finding = (rule, severity, problem) => ({
|
|
@@ -644,6 +849,10 @@ const CHECK_OPTIONS = {
|
|
|
644
849
|
type: "boolean",
|
|
645
850
|
default: false
|
|
646
851
|
},
|
|
852
|
+
"no-cache": {
|
|
853
|
+
type: "boolean",
|
|
854
|
+
default: false
|
|
855
|
+
},
|
|
647
856
|
entry: { type: "string" },
|
|
648
857
|
report: { type: "string" },
|
|
649
858
|
help: {
|
|
@@ -652,7 +861,7 @@ const CHECK_OPTIONS = {
|
|
|
652
861
|
default: false
|
|
653
862
|
}
|
|
654
863
|
};
|
|
655
|
-
const CHECK_USAGE = `Usage: lernapps check [--pre-commit] [--pre-push] [<dir|url>] [--entry <file>] [--report <file>]
|
|
864
|
+
const CHECK_USAGE = `Usage: lernapps check [--pre-commit] [--pre-push] [--no-cache] [<dir|url>] [--entry <file>] [--report <file>]
|
|
656
865
|
|
|
657
866
|
Runs every check that has something to check. Passing, it prints one line; otherwise the validation report in
|
|
658
867
|
YAML (schema: https://lernapps.net/tooling/schemas/validation-report.v1.schema.json). Every finding names its
|
|
@@ -681,8 +890,20 @@ In a repo (no <dir|url>):
|
|
|
681
890
|
checks the site in a repo with one (npm run build, then npm run check:site in the tooling, npm run check in a
|
|
682
891
|
site). Then it prints one line and writes no report. Anything else, or changed files it cannot know, runs the
|
|
683
892
|
part. CI decides the same in the app check action (lernapps changes --help).
|
|
893
|
+
Every run in a repo first compares node_modules with package-lock.json: a package installed at another
|
|
894
|
+
version, or missing, fails the run before anything else (run npm ci), since CI installs with npm ci.
|
|
895
|
+
The heavy part (--pre-push, or both) then:
|
|
896
|
+
- reuses a passing result of the same state: the tree of HEAD, the folder, the parts, the versions of
|
|
897
|
+
the tooling and of Node, the packages installed and every LERNAPPS_* variable, with a working tree
|
|
898
|
+
that has no changes git sees (untracked files included). It prints that line with "already checked".
|
|
899
|
+
Kept in <git common dir>/lernapps/, which the worktrees of a clone share; never in CI (CI set)
|
|
900
|
+
- waits for another check of the heavy part in this clone (and its worktrees), saying so in one line
|
|
901
|
+
on standard error; a lock whose process no longer runs is taken over
|
|
902
|
+
- warns in one line on standard error when Node has another major version than CI (no finding:
|
|
903
|
+
the report is the same for the same state wherever it is checked)
|
|
684
904
|
Every run in a repo also writes the full report to node_modules/.cache/lernapps/validation-report.yaml,
|
|
685
|
-
passing or not; the app check action keeps it. A
|
|
905
|
+
passing or not; the app check action keeps it. A run that checked writes how long each step took to
|
|
906
|
+
node_modules/.cache/lernapps/timings.json; the passing line ends with the whole time. A failed run counts each rule with an error in failedChecks
|
|
686
907
|
of the current plan's front matter, when it has the key.
|
|
687
908
|
|
|
688
909
|
On a built app, without its repo:
|
|
@@ -712,6 +933,7 @@ Options:
|
|
|
712
933
|
#<section> (or #<section>/<sub>) is an element id of its page as the server sends it, the
|
|
713
934
|
fitness values match; without <dir|url> the app at the entry's url is checked
|
|
714
935
|
--report <file> also write the full report to <file>, passing or not
|
|
936
|
+
--no-cache check again even when this state passed before (in a repo)
|
|
715
937
|
-h, --help print this help
|
|
716
938
|
|
|
717
939
|
`;
|
|
@@ -2514,6 +2736,34 @@ const REPORT = "node_modules/.cache/lernapps/validation-report.yaml";
|
|
|
2514
2736
|
const CHECKS_PASS = "checks-pass";
|
|
2515
2737
|
/** A built app or a URL that could not be checked at all: a platform rule, unlike the toolchain's checks-pass. */
|
|
2516
2738
|
const APP_CHECKABLE = "app-checkable";
|
|
2739
|
+
/** The duration of every step of the last check in a repo that ran: not in the report, which is deterministic. */
|
|
2740
|
+
const TIMINGS = "node_modules/.cache/lernapps/timings.json";
|
|
2741
|
+
/** The steps of this run and how long each took, in seconds, for TIMINGS. */
|
|
2742
|
+
const timings = [];
|
|
2743
|
+
/** Runs `run` and records how long it took as the step `name`. */
|
|
2744
|
+
function timed(name, run) {
|
|
2745
|
+
const start = performance.now();
|
|
2746
|
+
try {
|
|
2747
|
+
return run();
|
|
2748
|
+
} finally {
|
|
2749
|
+
timings.push({
|
|
2750
|
+
step: name,
|
|
2751
|
+
seconds: Math.round(performance.now() - start) / 1e3
|
|
2752
|
+
});
|
|
2753
|
+
}
|
|
2754
|
+
}
|
|
2755
|
+
/** Like timed, for a step that returns a promise. */
|
|
2756
|
+
async function timedAsync(name, run) {
|
|
2757
|
+
const start = performance.now();
|
|
2758
|
+
try {
|
|
2759
|
+
return await run();
|
|
2760
|
+
} finally {
|
|
2761
|
+
timings.push({
|
|
2762
|
+
step: name,
|
|
2763
|
+
seconds: Math.round(performance.now() - start) / 1e3
|
|
2764
|
+
});
|
|
2765
|
+
}
|
|
2766
|
+
}
|
|
2517
2767
|
/** The Playwright tests' results, as its JSON reporter writes them, to tell the runtime's tests from the app's. */
|
|
2518
2768
|
const E2E_RESULTS = "node_modules/.cache/lernapps/playwright-results.json";
|
|
2519
2769
|
const RUNTIME_ISSUES = "https://github.com/lernapps/app-templates/issues";
|
|
@@ -2852,6 +3102,9 @@ function architectureDiffFindings(where, stdout, stderr) {
|
|
|
2852
3102
|
}
|
|
2853
3103
|
/** Runs a step and returns its findings: a diff's as architectureDiffFindings judges them, else runStep's one. */
|
|
2854
3104
|
function stepFindings(step) {
|
|
3105
|
+
return timed(step.name, () => untimedStepFindings(step));
|
|
3106
|
+
}
|
|
3107
|
+
function untimedStepFindings(step) {
|
|
2855
3108
|
if (!step.architectureDiff) {
|
|
2856
3109
|
const failure = runStep(step);
|
|
2857
3110
|
return failure ? [failure] : [];
|
|
@@ -2937,12 +3190,12 @@ async function checkProject(fast, heavy, context) {
|
|
|
2937
3190
|
if (heavy && built) {
|
|
2938
3191
|
context.project = process.cwd();
|
|
2939
3192
|
if (existsSync(join("dist", "index.html"))) try {
|
|
2940
|
-
context.app = await visit({ bundle: "dist" });
|
|
3193
|
+
context.app = await timedAsync("visit of the built app", () => visit({ bundle: "dist" }));
|
|
2941
3194
|
} catch (error) {
|
|
2942
3195
|
if (!(error instanceof BrowserError)) throw error;
|
|
2943
3196
|
findings.push(browserFinding(error));
|
|
2944
3197
|
}
|
|
2945
|
-
const checks = await runChecks(context);
|
|
3198
|
+
const checks = await timedAsync("checks of the built app", () => runChecks(context));
|
|
2946
3199
|
rules.push(...checks.rules);
|
|
2947
3200
|
findings.push(...checks.findings);
|
|
2948
3201
|
if ([
|
|
@@ -2951,7 +3204,8 @@ async function checkProject(fast, heavy, context) {
|
|
|
2951
3204
|
"mjs",
|
|
2952
3205
|
"cjs"
|
|
2953
3206
|
].map((ext) => `playwright.config.${ext}`).find((file) => existsSync(file)) !== void 0) try {
|
|
2954
|
-
const
|
|
3207
|
+
const browser = await browserForTests();
|
|
3208
|
+
const failure = timed("end-to-end tests", () => runEndToEndTests(browser));
|
|
2955
3209
|
if (failure) findings.push(failure);
|
|
2956
3210
|
} catch (error) {
|
|
2957
3211
|
if (!(error instanceof BrowserError)) throw error;
|
|
@@ -3014,6 +3268,62 @@ function checkSite(script) {
|
|
|
3014
3268
|
suppressions: []
|
|
3015
3269
|
};
|
|
3016
3270
|
}
|
|
3271
|
+
/**
|
|
3272
|
+
* The check of a repo: first whether node_modules holds what package-lock.json asks for (else nothing else runs, its
|
|
3273
|
+
* result would not be CI's). The heavy part warns on standard error when Node has another major version than CI,
|
|
3274
|
+
* then reuses a passing report of the same state (check/cache.ts) or waits for another check in this clone.
|
|
3275
|
+
*/
|
|
3276
|
+
async function checkRepo(fast, heavy, context, noCache) {
|
|
3277
|
+
const parts = [...fast ? ["pre-commit"] : [], ...heavy ? ["pre-push"] : []];
|
|
3278
|
+
const installed = timed("installed packages", () => installedProblems("."));
|
|
3279
|
+
if (installed.length > 0) return { report: {
|
|
3280
|
+
checked: commitOrDirectory(),
|
|
3281
|
+
parts,
|
|
3282
|
+
rules: [CHECKS_PASS],
|
|
3283
|
+
findings: installed.map((problem) => finding(CHECKS_PASS, "error", problem)),
|
|
3284
|
+
suppressions: []
|
|
3285
|
+
} };
|
|
3286
|
+
if (!heavy) return { report: await checkProject(fast, heavy, context) };
|
|
3287
|
+
const node = nodeMajorProblem();
|
|
3288
|
+
if (node !== void 0) process.stderr.write(`lernapps check: warning: ${node.found}. ${node.fix}\n`);
|
|
3289
|
+
const dir = stateDir();
|
|
3290
|
+
const keyOf = () => {
|
|
3291
|
+
if (noCache || dir === void 0) return void 0;
|
|
3292
|
+
const key = cacheKey(".", parts, version());
|
|
3293
|
+
return "key" in key ? key.key : void 0;
|
|
3294
|
+
};
|
|
3295
|
+
const cached = (key) => {
|
|
3296
|
+
if (key === void 0 || dir === void 0) return void 0;
|
|
3297
|
+
const hit = cachedReport(dir, key);
|
|
3298
|
+
if (hit === void 0) return void 0;
|
|
3299
|
+
return {
|
|
3300
|
+
report: {
|
|
3301
|
+
...parse(hit.report),
|
|
3302
|
+
checked: commitOrDirectory()
|
|
3303
|
+
},
|
|
3304
|
+
cachedAt: hit.at
|
|
3305
|
+
};
|
|
3306
|
+
};
|
|
3307
|
+
const before = keyOf();
|
|
3308
|
+
const hit = cached(before);
|
|
3309
|
+
if (hit !== void 0) return hit;
|
|
3310
|
+
let waited = false;
|
|
3311
|
+
const release = dir === void 0 ? () => {} : await acquireLock(dir, (owner) => {
|
|
3312
|
+
waited = true;
|
|
3313
|
+
process.stderr.write(`lernapps check: waiting for another lernapps check in this clone${owner === void 0 ? "" : ` (process ${owner.pid}, since ${owner.at})`}\n`);
|
|
3314
|
+
});
|
|
3315
|
+
try {
|
|
3316
|
+
if (waited) {
|
|
3317
|
+
const after = cached(keyOf());
|
|
3318
|
+
if (after !== void 0) return after;
|
|
3319
|
+
}
|
|
3320
|
+
const report = await checkProject(fast, heavy, context);
|
|
3321
|
+
if (!failed(report) && before !== void 0 && dir !== void 0 && keyOf() === before) storeReport(dir, before, toYaml(report));
|
|
3322
|
+
return { report };
|
|
3323
|
+
} finally {
|
|
3324
|
+
release();
|
|
3325
|
+
}
|
|
3326
|
+
}
|
|
3017
3327
|
/** The checks of a built app alone: a bundle directory or a URL. */
|
|
3018
3328
|
async function checkApp(target, context) {
|
|
3019
3329
|
const isUrl = /^https?:\/\//.test(target);
|
|
@@ -3054,6 +3364,9 @@ async function check(args) {
|
|
|
3054
3364
|
if (values.entry !== void 0) context.entry = readEntry(values.entry);
|
|
3055
3365
|
const target = positionals[0] ?? context.entry?.value.url;
|
|
3056
3366
|
let report;
|
|
3367
|
+
/** When the report comes from the cache: when that state was checked. */
|
|
3368
|
+
let cachedAt;
|
|
3369
|
+
const start = performance.now();
|
|
3057
3370
|
if (target !== void 0) {
|
|
3058
3371
|
if (values["pre-commit"] || values["pre-push"]) throw new UsageError("--pre-commit and --pre-push check a repo; a built app is checked without them");
|
|
3059
3372
|
report = await checkApp(target, context);
|
|
@@ -3073,21 +3386,32 @@ async function check(args) {
|
|
|
3073
3386
|
process.stdout.write(`${scope.skip}\n`);
|
|
3074
3387
|
return 0;
|
|
3075
3388
|
} else {
|
|
3076
|
-
|
|
3389
|
+
if (scope !== void 0) report = checkSite(scope.site);
|
|
3390
|
+
else {
|
|
3391
|
+
const result = await checkRepo(all || values["pre-commit"], all || values["pre-push"], context, values["no-cache"]);
|
|
3392
|
+
report = result.report;
|
|
3393
|
+
cachedAt = result.cachedAt;
|
|
3394
|
+
}
|
|
3077
3395
|
if (values["pre-push"] && failed(report)) countPrePushFailure();
|
|
3078
3396
|
}
|
|
3079
3397
|
}
|
|
3080
3398
|
if (target === void 0 && failed(report)) countFailedChecks(report.findings.filter((found) => found.severity === "error").map((found) => found.rule));
|
|
3081
3399
|
const yaml = toYaml(report);
|
|
3400
|
+
const seconds = Math.round((performance.now() - start) / 100) / 10;
|
|
3082
3401
|
if (target === void 0) {
|
|
3083
3402
|
mkdirSync(dirname(REPORT), { recursive: true });
|
|
3084
3403
|
writeFileSync(REPORT, yaml);
|
|
3404
|
+
if (cachedAt === void 0 && timings.length > 0) writeFileSync(TIMINGS, `${JSON.stringify({
|
|
3405
|
+
seconds,
|
|
3406
|
+
steps: timings
|
|
3407
|
+
}, null, 2)}\n`);
|
|
3085
3408
|
}
|
|
3086
3409
|
if (values.report !== void 0) writeFileSync(values.report, yaml);
|
|
3087
3410
|
if (report.findings.length > 0) process.stdout.write(yaml);
|
|
3088
3411
|
else {
|
|
3089
3412
|
const what = "commit" in report.checked ? `commit ${report.checked.commit.slice(0, 7)}` : "bundle" in report.checked ? relative(process.cwd(), resolve(report.checked.bundle)) || "." : "url" in report.checked ? report.checked.url : report.checked.directory;
|
|
3090
|
-
|
|
3413
|
+
const how = cachedAt !== void 0 ? `, already checked at ${cachedAt.toTimeString().slice(0, 5)} (--no-cache checks again)` : ` in ${seconds} s`;
|
|
3414
|
+
process.stdout.write(`lernapps check: ok, ${report.rules.length} rule${report.rules.length === 1 ? "" : "s"}, no findings (${what})${how}\n`);
|
|
3091
3415
|
}
|
|
3092
3416
|
return failed(report) ? 1 : 0;
|
|
3093
3417
|
}
|
package/dist/playwright.mjs
CHANGED
|
@@ -15,7 +15,9 @@ name: Pages
|
|
|
15
15
|
# paths filter: a check skipped by a paths filter stays "Pending" and blocks a pull request that requires it. GitHub
|
|
16
16
|
# may start no run for a push that creates main from commits it already has (the first publish): then start it by
|
|
17
17
|
# hand, Actions → Pages → Run workflow on main (gh workflow run Pages --ref main), as the plan says; a run started by
|
|
18
|
-
# hand checks and publishes everything.
|
|
18
|
+
# hand checks and publishes everything. A push to main whose commit has the same tree as the pull request that the job
|
|
19
|
+
# `app` checked already (the pull request was up to date with main when it was merged) takes that run's bundle and
|
|
20
|
+
# report instead of checking again.
|
|
19
21
|
on:
|
|
20
22
|
push:
|
|
21
23
|
branches: [main]
|
|
@@ -34,6 +36,9 @@ jobs:
|
|
|
34
36
|
app:
|
|
35
37
|
if: github.event.action != 'closed'
|
|
36
38
|
runs-on: ubuntu-latest
|
|
39
|
+
permissions:
|
|
40
|
+
contents: read
|
|
41
|
+
actions: read # on main: find the run of the pull request that checked the same tree, and take its result
|
|
37
42
|
steps:
|
|
38
43
|
- uses: lernapps/tooling/actions/app-check@{{tooling}}
|
|
39
44
|
deploy:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lernapps/tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Shared tooling of the lernapps.net apps and sites: the CLI lernapps, site actions, Renovate preset, guidance for building apps; documentation at https://lernapps.net/tooling/",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -115,7 +115,7 @@
|
|
|
115
115
|
},
|
|
116
116
|
"devDependencies": {
|
|
117
117
|
"@doctc/arc42": "0.38.0",
|
|
118
|
-
"@lernapps/site": "github:lernapps/lernapps.github.io#
|
|
118
|
+
"@lernapps/site": "github:lernapps/lernapps.github.io#0f6257d3dffcbf69ffe962d0b80bd7298a62de02",
|
|
119
119
|
"@types/node": "26.6.5",
|
|
120
120
|
"ajv": "8.20.0",
|
|
121
121
|
"vite-plus": "1.4.0"
|