start-command 0.38.0 → 0.39.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/CHANGELOG.md +20 -0
- package/README.md +4 -2
- package/bunfig.toml +4 -0
- package/eslint.config.mjs +1 -0
- package/package.json +4 -4
- package/src/lib/args-parser.js +9 -0
- package/src/lib/cgroup-memory.js +95 -23
- package/src/lib/command-builder.js +4 -0
- package/src/lib/container-removal.js +4 -5
- package/src/lib/detached-finalize.js +1 -0
- package/src/lib/docker-init-option.js +65 -0
- package/src/lib/docker-inspect-batch.js +176 -0
- package/src/lib/docker-resource-options.js +21 -2
- package/src/lib/docker-runtime-args.js +12 -3
- package/src/lib/execution-control.js +8 -6
- package/src/lib/execution-recovery.js +4 -1
- package/src/lib/execution-resume.js +4 -1
- package/src/lib/execution-store.js +19 -1
- package/src/lib/exit-evidence.js +139 -7
- package/src/lib/output-blocks.js +11 -0
- package/src/lib/resume-resources.js +2 -2
- package/src/lib/status-formatter.js +34 -130
- package/src/lib/status-probe.js +149 -0
- package/src/lib/store-decode-cache.js +141 -0
- package/src/lib/usage.js +3 -0
- package/test/cargo-lock-version.mjs +121 -0
- package/test/check-rust-coverage.mjs +71 -0
- package/test/check-web-archive.mjs +154 -0
- package/test/ci-workflow-invariants.js +204 -0
- package/test/command-builder-docker-cleanup.js +24 -0
- package/test/command-exit-codes.mjs +91 -0
- package/test/docker-runtime-options.js +2 -0
- package/test/docker-snapshot-safety.js +39 -0
- package/test/execution-resume.js +2 -0
- package/test/fixtures/fake-npm.sh +64 -0
- package/test/fixtures/lychee-report.md +39 -0
- package/test/javascript-first-ci.js +69 -0
- package/test/links-workflow.mjs +127 -0
- package/test/lint-gate.mjs +56 -0
- package/test/npm-publish.mjs +181 -0
- package/test/recheck-broken-links.mjs +452 -0
- package/test/regression-176.js +2 -0
- package/test/regression-187.js +2 -0
- package/test/regression-191.js +4 -0
- package/test/regression-193.js +2 -0
- package/test/regression-198.js +2 -0
- package/test/regression-207.js +8 -2
- package/test/regression-224.js +2 -0
- package/test/regression-236.js +250 -0
- package/test/regression-238.js +129 -0
- package/test/regression-241.js +254 -0
- package/test/regression-242.js +241 -0
- package/test/regression-243.js +493 -0
- package/test/regression-244.js +471 -0
- package/test/release-push-retry.mjs +172 -0
- package/test/run-js-tests.mjs +15 -3
- package/test/setup/private-snapshot-lock.js +17 -0
- package/test/shared-golden.js +29 -2
- package/test/transient-links.mjs +139 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live docker state of a detached execution, for `--status` and `--list`.
|
|
3
|
+
*
|
|
4
|
+
* Split out of `status-formatter` (as `status_probe.rs` is in Rust) so the
|
|
5
|
+
* probing stays separate from the rendering and both files stay under the
|
|
6
|
+
* repository line budget (issue #243).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { getDockerCommand, getDockerSpawnOptions } = require('./docker-cleanup');
|
|
10
|
+
const { normalizeDockerTimestamp } = require('./docker-post-mortem');
|
|
11
|
+
const {
|
|
12
|
+
CONTAINER_STATE_REMOVED,
|
|
13
|
+
dockerInspect,
|
|
14
|
+
} = require('./container-removal');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Inspect the live state of a detached docker container by name.
|
|
18
|
+
*
|
|
19
|
+
* Distinguishes three outcomes that matter for status reporting:
|
|
20
|
+
* - the container exists and is running,
|
|
21
|
+
* - the container exists but has stopped (with a real exit code), and
|
|
22
|
+
* - the container cannot be inspected at all.
|
|
23
|
+
*
|
|
24
|
+
* The last case is crucial on slow Docker-in-Docker hosts (issue #136): right
|
|
25
|
+
* after `docker run -d` returns, `docker inspect <name>` can transiently fail
|
|
26
|
+
* because the container is not visible yet. A failed inspect must NOT be read
|
|
27
|
+
* as "stopped"; it means "unknown", so callers can keep the session running
|
|
28
|
+
* instead of fabricating a terminal `-1` result.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} sessionName - Container name
|
|
31
|
+
* @param {Object|null} [probe] - Receives `sessionNotFound` (issue #225)
|
|
32
|
+
* @returns {{running: boolean, exitCode: number|null, oomKilled: boolean|null}|null} State,
|
|
33
|
+
* or null when the container cannot be inspected (not found yet, removed, or docker error)
|
|
34
|
+
*/
|
|
35
|
+
function inspectDockerState(sessionName, probe = null) {
|
|
36
|
+
const { stdout, notFound } = dockerInspect(
|
|
37
|
+
getDockerCommand(),
|
|
38
|
+
dockerProbeSpawnOptions(),
|
|
39
|
+
'{{.State.Running}} {{.State.ExitCode}} {{.State.OOMKilled}} ' +
|
|
40
|
+
'{{.State.StartedAt}} {{.State.FinishedAt}}',
|
|
41
|
+
sessionName
|
|
42
|
+
);
|
|
43
|
+
if (probe) {
|
|
44
|
+
// "No such container" is a fact, unlike a docker that cannot be reached
|
|
45
|
+
// (issue #225); it still reads as unknown liveness here (issue #136).
|
|
46
|
+
probe.sessionNotFound = notFound;
|
|
47
|
+
}
|
|
48
|
+
if (stdout === null) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
// Parsed positionally and defensively: an older docker (or a stubbed one)
|
|
52
|
+
// may answer with fewer fields than the template asks for.
|
|
53
|
+
const [runningRaw, exitRaw, oomKilledRaw, startedRaw, finishedRaw] = stdout
|
|
54
|
+
.trim()
|
|
55
|
+
.split(/\s+/);
|
|
56
|
+
const exitCode = Number.parseInt(exitRaw, 10);
|
|
57
|
+
return {
|
|
58
|
+
running: runningRaw === 'true',
|
|
59
|
+
exitCode: Number.isFinite(exitCode) ? exitCode : null,
|
|
60
|
+
oomKilled:
|
|
61
|
+
oomKilledRaw === 'true' ? true : oomKilledRaw === 'false' ? false : null,
|
|
62
|
+
startedAt: normalizeDockerTimestamp(startedRaw),
|
|
63
|
+
finishedAt: normalizeDockerTimestamp(finishedRaw),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isDetachedDockerRecord(record) {
|
|
68
|
+
const opts = record.options || {};
|
|
69
|
+
return (
|
|
70
|
+
opts.isolated === 'docker' &&
|
|
71
|
+
opts.isolationMode === 'detached' &&
|
|
72
|
+
Boolean(opts.sessionName)
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function dockerProbeSpawnOptions() {
|
|
77
|
+
return getDockerSpawnOptions({
|
|
78
|
+
encoding: 'utf8',
|
|
79
|
+
env: process.env,
|
|
80
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function readDockerState(record, probe = null) {
|
|
85
|
+
const opts = record.options || {};
|
|
86
|
+
if (opts.isolated !== 'docker' || !opts.sessionName) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
if (record.containerState === CONTAINER_STATE_REMOVED) {
|
|
90
|
+
// A removed container stays removed: a relaunch clears the marker, so
|
|
91
|
+
// there is nothing left to ask Docker (issue #243).
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
const state = inspectDockerState(opts.sessionName, probe);
|
|
95
|
+
if (
|
|
96
|
+
record.attempt &&
|
|
97
|
+
state?.startedAt &&
|
|
98
|
+
Date.parse(state.startedAt) < Date.parse(record.attempt.startedAt)
|
|
99
|
+
) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
return state;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Best-effort terminal exit code reported by the isolation backend itself
|
|
107
|
+
* (currently docker via `docker inspect .State.ExitCode`). Returns null when
|
|
108
|
+
* the backend cannot provide a real code, so callers never surface the `-1`
|
|
109
|
+
* sentinel for a session whose real exit code is simply not available yet.
|
|
110
|
+
* A running container has no terminal exit code (docker reports `0` for it),
|
|
111
|
+
* so only a stopped container contributes one.
|
|
112
|
+
* @param {{running: boolean, exitCode: number|null}|null} dockerState - Inspected state
|
|
113
|
+
* @returns {number|null}
|
|
114
|
+
*/
|
|
115
|
+
function backendExitCode(dockerState) {
|
|
116
|
+
return dockerState && !dockerState.running ? dockerState.exitCode : null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Reconcile the OOM observation from the stored record and from `docker inspect`.
|
|
121
|
+
*
|
|
122
|
+
* `State.OOMKilled` is a container-cgroup flag: the kernel sets it when ANY
|
|
123
|
+
* process in the cgroup is OOM-killed and it is never cleared for the life of
|
|
124
|
+
* the container (moby/moby#47618). It is therefore an *observation*, never a
|
|
125
|
+
* verdict about the session (issue #151) — a container that lost one child
|
|
126
|
+
* process keeps running and can still exit `0`. Once observed, the flag stays
|
|
127
|
+
* `true` for the record.
|
|
128
|
+
* @param {Object} record - Execution record
|
|
129
|
+
* @param {{oomKilled: boolean|null}|null} dockerState - Inspected state
|
|
130
|
+
* @returns {boolean|undefined} Observation, or undefined when nothing is known
|
|
131
|
+
*/
|
|
132
|
+
function resolveOomObservation(record, dockerState) {
|
|
133
|
+
if (record.oomKilled === true || dockerState?.oomKilled === true) {
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
if (record.oomKilled === false || dockerState?.oomKilled === false) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = {
|
|
143
|
+
backendExitCode,
|
|
144
|
+
dockerProbeSpawnOptions,
|
|
145
|
+
inspectDockerState,
|
|
146
|
+
isDetachedDockerRecord,
|
|
147
|
+
readDockerState,
|
|
148
|
+
resolveOomObservation,
|
|
149
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decoded execution store, shared within one process and between processes
|
|
3
|
+
* (issue #243).
|
|
4
|
+
*
|
|
5
|
+
* Decoding `executions.lino` costs about 1-4 ms per record, and one
|
|
6
|
+
* `--status` used to decode it up to three times (`get`, then `getAll` twice
|
|
7
|
+
* for the execution chain). The file is still read on every call, so a write
|
|
8
|
+
* by any process is seen at once, but it is decoded again only when its
|
|
9
|
+
* content differs from the last decoded copy. The content itself is the key:
|
|
10
|
+
* unlike an mtime, it cannot miss two same-size writes within one clock tick.
|
|
11
|
+
*
|
|
12
|
+
* A new process (every `$ --status` poll) still had to decode once. The
|
|
13
|
+
* decoded records are therefore also kept as JSON next to the store, tagged
|
|
14
|
+
* with the SHA-256 of the content they were decoded from; `JSON.parse` of
|
|
15
|
+
* that copy is about 250 times faster than the lino decode. A copy whose
|
|
16
|
+
* digest does not match the current content is ignored and replaced.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const crypto = require('crypto');
|
|
21
|
+
const { isDeepStrictEqual } = require('util');
|
|
22
|
+
const { atomicWrite } = require('./atomic-write');
|
|
23
|
+
|
|
24
|
+
/** Format tag of the JSON copy; a different tag is ignored. */
|
|
25
|
+
const DECODED_COPY_FORMAT = 'start-command-decoded-store-js-1';
|
|
26
|
+
|
|
27
|
+
/** @type {Map<string, {content: string, data: *, decodes: number}>} */
|
|
28
|
+
const decodedStores = new Map();
|
|
29
|
+
|
|
30
|
+
function digestOf(content) {
|
|
31
|
+
return crypto.createHash('sha256').update(content).digest('hex');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Records of the JSON copy at `copyPath`, when it was decoded from content
|
|
36
|
+
* with this digest; otherwise undefined.
|
|
37
|
+
*/
|
|
38
|
+
function readDecodedCopy(copyPath, digest) {
|
|
39
|
+
try {
|
|
40
|
+
const copy = JSON.parse(fs.readFileSync(copyPath, 'utf8'));
|
|
41
|
+
if (copy && copy.format === DECODED_COPY_FORMAT && copy.sha256 === digest) {
|
|
42
|
+
return copy.records;
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
/* Missing or unreadable: decode instead. */
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Keep `data` as JSON for the next process, unless JSON cannot represent it
|
|
52
|
+
* exactly. Best effort: a read-only folder only costs the next decode.
|
|
53
|
+
*/
|
|
54
|
+
function writeDecodedCopy(copyPath, digest, data) {
|
|
55
|
+
try {
|
|
56
|
+
const json = JSON.stringify({
|
|
57
|
+
format: DECODED_COPY_FORMAT,
|
|
58
|
+
sha256: digest,
|
|
59
|
+
records: data,
|
|
60
|
+
});
|
|
61
|
+
if (!isDeepStrictEqual(JSON.parse(json).records, data)) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
atomicWrite(copyPath, json);
|
|
65
|
+
} catch {
|
|
66
|
+
/* Not kept; the next process decodes again. */
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Decode `content` of the store at `filePath`, or reuse an earlier decode of
|
|
72
|
+
* the same content. Every call returns its own deep copy, so callers may
|
|
73
|
+
* mutate the records they get.
|
|
74
|
+
* @param {string} filePath - Store file the content was read from
|
|
75
|
+
* @param {string} content - Current file content
|
|
76
|
+
* @param {function(string): *} decode - Decoder for a changed content
|
|
77
|
+
* @param {{copyPath?: string}} [options] - JSON copy shared between processes
|
|
78
|
+
* @returns {{data: *, reused: boolean, source: 'memory'|'copy'|'decode'}}
|
|
79
|
+
*/
|
|
80
|
+
function decodeStoreContent(filePath, content, decode, options = {}) {
|
|
81
|
+
const cached = decodedStores.get(filePath);
|
|
82
|
+
if (cached && cached.content === content) {
|
|
83
|
+
return {
|
|
84
|
+
data: structuredClone(cached.data),
|
|
85
|
+
reused: true,
|
|
86
|
+
source: 'memory',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const decodes = cached ? cached.decodes : 0;
|
|
90
|
+
const { copyPath } = options;
|
|
91
|
+
const digest = copyPath ? digestOf(content) : null;
|
|
92
|
+
const copied = copyPath ? readDecodedCopy(copyPath, digest) : undefined;
|
|
93
|
+
if (copied !== undefined) {
|
|
94
|
+
decodedStores.set(filePath, {
|
|
95
|
+
content,
|
|
96
|
+
data: structuredClone(copied),
|
|
97
|
+
decodes,
|
|
98
|
+
});
|
|
99
|
+
return { data: copied, reused: true, source: 'copy' };
|
|
100
|
+
}
|
|
101
|
+
const data = decode(content);
|
|
102
|
+
decodedStores.set(filePath, {
|
|
103
|
+
content,
|
|
104
|
+
data: structuredClone(data),
|
|
105
|
+
decodes: decodes + 1,
|
|
106
|
+
});
|
|
107
|
+
if (copyPath) {
|
|
108
|
+
writeDecodedCopy(copyPath, digest, data);
|
|
109
|
+
}
|
|
110
|
+
return { data, reused: false, source: 'decode' };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* How many times this process decoded the store at `filePath` since it was
|
|
115
|
+
* last forgotten.
|
|
116
|
+
* @param {string} filePath - Store file
|
|
117
|
+
* @returns {number}
|
|
118
|
+
*/
|
|
119
|
+
function decodeCount(filePath) {
|
|
120
|
+
const cached = decodedStores.get(filePath);
|
|
121
|
+
return cached ? cached.decodes : 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Drop the decoded copy of a store held by this process.
|
|
126
|
+
* @param {string} [filePath] - Store file; all stores when omitted
|
|
127
|
+
*/
|
|
128
|
+
function forgetDecodedStore(filePath) {
|
|
129
|
+
if (filePath === undefined) {
|
|
130
|
+
decodedStores.clear();
|
|
131
|
+
} else {
|
|
132
|
+
decodedStores.delete(filePath);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = {
|
|
137
|
+
DECODED_COPY_FORMAT,
|
|
138
|
+
decodeCount,
|
|
139
|
+
decodeStoreContent,
|
|
140
|
+
forgetDecodedStore,
|
|
141
|
+
};
|
package/src/lib/usage.js
CHANGED
|
@@ -18,12 +18,14 @@ Options:
|
|
|
18
18
|
--env, -e <KEY=VALUE> Environment variable for docker container (repeatable, docker only)
|
|
19
19
|
--label <KEY=VALUE> Container attribution (repeatable, docker only; start-command.* reserved)
|
|
20
20
|
--privileged Run docker container in privileged mode (docker only)
|
|
21
|
+
--init, --no-init Run tini as PID 1 to reap orphaned processes (docker only; default START_COMMAND_DOCKER_INIT=1)
|
|
21
22
|
--network <name> Connect to a named network (repeatable, docker only)
|
|
22
23
|
--network-alias <alias> Add alias to the first network (repeatable, docker only)
|
|
23
24
|
--on-kill-resume <N> Resume a killed (exit 137 / OOM) detached docker session up to N times
|
|
24
25
|
--recovery-command <cmd> Command to run in the same container on such a resume
|
|
25
26
|
--on-kill-resume-delay <min[-max]> Wait a random number of seconds before each such resume (default 0)
|
|
26
27
|
--memory <size|N%|MIN%-MAX%> Docker RAM cap; percentages use daemon capacity
|
|
28
|
+
--memory-reservation <spec> Soft memory limit (Docker size or N%, below --memory)
|
|
27
29
|
--memory-swap <spec> Combined memory+swap cap (defaults to memory, no extra swap)
|
|
28
30
|
--cpus <n|N%|MIN%-MAX%> Docker CPU cap; ranges draw once per creation
|
|
29
31
|
--on-kill-resume-memory <spec> New memory cap before a qualifying OOM recovery
|
|
@@ -79,6 +81,7 @@ Examples:
|
|
|
79
81
|
$ --isolated docker --image ghcr.io/link-foundation/box-js:latest -- bun --version
|
|
80
82
|
$ -i docker -v ~/.config/gh:/root/.config/gh -e TOKEN=abc -- gh repo list
|
|
81
83
|
$ -i docker --image konard/hive-mind-dind:latest --privileged -- solve ...
|
|
84
|
+
$ -i docker --init -d -- solve ... # tini reaps zombies of long-running tasks
|
|
82
85
|
$ --isolated ssh --endpoint user@remote.server -- ls -la
|
|
83
86
|
$ --isolated-user -- npm test # Create isolated user
|
|
84
87
|
$ -u myuser -- npm start # Custom username
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, readFileSync, writeFileSync } from 'fs';
|
|
3
|
+
import { tmpdir } from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import {
|
|
6
|
+
setCargoLockPackageVersion,
|
|
7
|
+
syncCargoLockVersion,
|
|
8
|
+
} from '../../scripts/cargo-lock-version.mjs';
|
|
9
|
+
|
|
10
|
+
const repoRoot = path.resolve(import.meta.dir, '../..');
|
|
11
|
+
const lock = (version, eol = '\n') =>
|
|
12
|
+
[
|
|
13
|
+
'# This file is automatically @generated by Cargo.',
|
|
14
|
+
'version = 4',
|
|
15
|
+
'',
|
|
16
|
+
'[[package]]',
|
|
17
|
+
'name = "start-command"',
|
|
18
|
+
`version = "${version}"`,
|
|
19
|
+
'dependencies = [',
|
|
20
|
+
' "serde",',
|
|
21
|
+
']',
|
|
22
|
+
'',
|
|
23
|
+
'[[package]]',
|
|
24
|
+
'name = "start-command-helper"',
|
|
25
|
+
'version = "9.9.9"',
|
|
26
|
+
'',
|
|
27
|
+
].join(eol);
|
|
28
|
+
|
|
29
|
+
describe('setCargoLockPackageVersion', () => {
|
|
30
|
+
it('rewrites only the named package entry', () => {
|
|
31
|
+
const result = setCargoLockPackageVersion(
|
|
32
|
+
lock('0.23.0'),
|
|
33
|
+
'start-command',
|
|
34
|
+
'0.27.0'
|
|
35
|
+
);
|
|
36
|
+
expect(result).toEqual({
|
|
37
|
+
text: lock('0.27.0'),
|
|
38
|
+
found: true,
|
|
39
|
+
changed: true,
|
|
40
|
+
});
|
|
41
|
+
expect(result.text).toContain('version = "9.9.9"');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('reports an already synced entry as unchanged', () => {
|
|
45
|
+
expect(
|
|
46
|
+
setCargoLockPackageVersion(lock('0.27.0'), 'start-command', '0.27.0')
|
|
47
|
+
).toEqual({ text: lock('0.27.0'), found: true, changed: false });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('keeps CRLF line endings', () => {
|
|
51
|
+
expect(
|
|
52
|
+
setCargoLockPackageVersion(
|
|
53
|
+
lock('0.1.0', '\r\n'),
|
|
54
|
+
'start-command',
|
|
55
|
+
'0.2.0'
|
|
56
|
+
).text
|
|
57
|
+
).toBe(lock('0.2.0', '\r\n'));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('does not match a package whose name only starts with the crate name', () => {
|
|
61
|
+
expect(
|
|
62
|
+
setCargoLockPackageVersion(lock('0.1.0'), 'start', '1.0.0').found
|
|
63
|
+
).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('syncCargoLockVersion', () => {
|
|
68
|
+
it('writes the file only when the version changes', () => {
|
|
69
|
+
const file = path.join(mkdtempSync(path.join(tmpdir(), 'lock-')), 'l');
|
|
70
|
+
writeFileSync(file, lock('0.1.0'));
|
|
71
|
+
expect(syncCargoLockVersion(file, 'start-command', '0.2.0')).toBe(true);
|
|
72
|
+
expect(readFileSync(file, 'utf8')).toBe(lock('0.2.0'));
|
|
73
|
+
expect(syncCargoLockVersion(file, 'start-command', '0.2.0')).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('skips a missing lock file and rejects a lock without the crate', () => {
|
|
77
|
+
const dir = mkdtempSync(path.join(tmpdir(), 'lock-'));
|
|
78
|
+
expect(
|
|
79
|
+
syncCargoLockVersion(path.join(dir, 'none'), 'start-command', '1.0.0')
|
|
80
|
+
).toBe(false);
|
|
81
|
+
const file = path.join(dir, 'Cargo.lock');
|
|
82
|
+
writeFileSync(file, lock('0.1.0'));
|
|
83
|
+
expect(() => syncCargoLockVersion(file, 'other', '1.0.0')).toThrow(
|
|
84
|
+
/no \[\[package\]\] entry for other/
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('repository Cargo.lock (issue #234)', () => {
|
|
90
|
+
it('records the Cargo.toml version of the crate', () => {
|
|
91
|
+
const manifest = readFileSync(
|
|
92
|
+
path.join(repoRoot, 'rust/Cargo.toml'),
|
|
93
|
+
'utf8'
|
|
94
|
+
);
|
|
95
|
+
const name = manifest.match(/^name\s*=\s*"([^"]+)"/m)[1];
|
|
96
|
+
const version = manifest.match(/^version\s*=\s*"([^"]+)"/m)[1];
|
|
97
|
+
const result = setCargoLockPackageVersion(
|
|
98
|
+
readFileSync(path.join(repoRoot, 'rust/Cargo.lock'), 'utf8'),
|
|
99
|
+
name,
|
|
100
|
+
version
|
|
101
|
+
);
|
|
102
|
+
expect(result.found).toBe(true);
|
|
103
|
+
expect(result.changed).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('is synced and staged by both Rust version bump scripts', () => {
|
|
107
|
+
for (const script of ['version-and-commit.mjs', 'bump-version.mjs']) {
|
|
108
|
+
const source = readFileSync(
|
|
109
|
+
path.join(repoRoot, 'scripts', script),
|
|
110
|
+
'utf8'
|
|
111
|
+
);
|
|
112
|
+
expect(source).toContain("syncCargoLockVersion('Cargo.lock'");
|
|
113
|
+
}
|
|
114
|
+
expect(
|
|
115
|
+
readFileSync(
|
|
116
|
+
path.join(repoRoot, 'scripts/version-and-commit.mjs'),
|
|
117
|
+
'utf8'
|
|
118
|
+
)
|
|
119
|
+
).toContain("['Cargo.toml', 'Cargo.lock', 'CHANGELOG.md', 'changelog.d']");
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
checkCoverage,
|
|
5
|
+
parseTarpaulinCoverage,
|
|
6
|
+
} from '../../scripts/check-rust-coverage.mjs';
|
|
7
|
+
|
|
8
|
+
// Tail of a passing cargo-tarpaulin 0.37.5 run (Rust CI/CD, run 39256972823).
|
|
9
|
+
const PASSING_LOG = `|| src/lib/usage.rs: 0/1
|
|
10
|
+
|| src/lib/user_manager.rs: 48/170
|
|
11
|
+
||
|
|
12
|
+
73.81% coverage, 6296/8530 lines covered
|
|
13
|
+
`;
|
|
14
|
+
|
|
15
|
+
// Tail of the failing run on main (run 39257928573): a test failed, tarpaulin
|
|
16
|
+
// stopped before its summary, and the old step reported
|
|
17
|
+
// "Coverage % is below the 50% threshold" (issue #234).
|
|
18
|
+
const FAILED_TEST_LOG = `failures:
|
|
19
|
+
reservation_failure_after_cleanup_preserves_restart_state
|
|
20
|
+
|
|
21
|
+
test result: FAILED. 5 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.13s
|
|
22
|
+
|
|
23
|
+
Error: "Test failed during run"
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
describe('check-rust-coverage', () => {
|
|
27
|
+
it('parses the tarpaulin summary line', () => {
|
|
28
|
+
expect(parseTarpaulinCoverage(PASSING_LOG)).toBe(73.81);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('uses the last summary when several are printed', () => {
|
|
32
|
+
const log =
|
|
33
|
+
'10.00% coverage, 1/10 lines covered\r\n60.5% coverage, 6/10 lines covered\r\n';
|
|
34
|
+
expect(parseTarpaulinCoverage(log)).toBe(60.5);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('ignores lines that only mention coverage', () => {
|
|
38
|
+
expect(parseTarpaulinCoverage('Coverage: 73.81%\n')).toBeNull();
|
|
39
|
+
expect(parseTarpaulinCoverage('')).toBeNull();
|
|
40
|
+
expect(parseTarpaulinCoverage(undefined)).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('passes when coverage meets the threshold', () => {
|
|
44
|
+
const result = checkCoverage(PASSING_LOG, 50);
|
|
45
|
+
expect(result.ok).toBe(true);
|
|
46
|
+
expect(result.message).toBe('✅ Coverage 73.81% meets the 50% threshold');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('fails when coverage is below the threshold', () => {
|
|
50
|
+
const result = checkCoverage(PASSING_LOG, 80);
|
|
51
|
+
expect(result.ok).toBe(false);
|
|
52
|
+
expect(result.message).toBe(
|
|
53
|
+
'❌ Coverage 73.81% is below the 80% threshold'
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('reports a failed test instead of an empty coverage drop (issue #234)', () => {
|
|
58
|
+
const result = checkCoverage(FAILED_TEST_LOG, 50);
|
|
59
|
+
expect(result.ok).toBe(false);
|
|
60
|
+
expect(result.coverage).toBeNull();
|
|
61
|
+
expect(result.message).toContain('a test failed');
|
|
62
|
+
expect(result.message).not.toContain('below the');
|
|
63
|
+
expect(result.message).not.toContain('Coverage %');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('fails when the log has no summary for another reason', () => {
|
|
67
|
+
const result = checkCoverage('error: could not compile', 50);
|
|
68
|
+
expect(result.ok).toBe(false);
|
|
69
|
+
expect(result.message).toContain('Could not determine coverage');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Regression tests for the lychee report parser used by the Broken Link
|
|
2
|
+
// Checker workflow. A lychee error without an http(s) URL - a missing local
|
|
3
|
+
// file, an unresolvable root-relative link - has no Wayback equivalent. Such
|
|
4
|
+
// errors must still be reported, and must keep the check red.
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { describe, it } from 'bun:test';
|
|
7
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
extractBrokenLinks,
|
|
15
|
+
extractBrokenUrls,
|
|
16
|
+
extractErrorsSection,
|
|
17
|
+
} from '../../scripts/check-web-archive.mjs';
|
|
18
|
+
|
|
19
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const report = readFileSync(
|
|
21
|
+
join(here, 'fixtures', 'lychee-report.md'),
|
|
22
|
+
'utf-8'
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
describe('extractErrorsSection', () => {
|
|
26
|
+
it('stops at the next top-level heading', () => {
|
|
27
|
+
const section = extractErrorsSection(report);
|
|
28
|
+
assert.ok(section.includes('Errors in README.md'));
|
|
29
|
+
assert.ok(
|
|
30
|
+
!section.includes('Redirects per input'),
|
|
31
|
+
'the redirects section must not leak into the errors section'
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('falls back to the whole document when the heading is absent', () => {
|
|
36
|
+
const legacy = '# Link report\n\n* [404] https://broken.example/legacy\n';
|
|
37
|
+
assert.equal(extractErrorsSection(legacy), legacy);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('extractBrokenUrls', () => {
|
|
42
|
+
it('ignores successful redirects outside the errors section', () => {
|
|
43
|
+
const shortReport = `## Errors per input
|
|
44
|
+
|
|
45
|
+
### Errors in docs/reference.md
|
|
46
|
+
|
|
47
|
+
* [502] <https://broken.example/reference> (at 1:1) | Rejected status code: 502
|
|
48
|
+
|
|
49
|
+
## Redirects per input
|
|
50
|
+
|
|
51
|
+
### Redirects in README.md
|
|
52
|
+
|
|
53
|
+
* https://working.example/old --[301]--> https://working.example/current
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
assert.deepEqual(extractBrokenUrls(shortReport), [
|
|
57
|
+
'https://broken.example/reference',
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('parses the full document when the errors section is absent', () => {
|
|
62
|
+
const legacyReport = `# Link report
|
|
63
|
+
|
|
64
|
+
* [404] https://broken.example/legacy
|
|
65
|
+
`;
|
|
66
|
+
|
|
67
|
+
assert.deepEqual(extractBrokenUrls(legacyReport), [
|
|
68
|
+
'https://broken.example/legacy',
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('extracts every http error from a full report exactly once', () => {
|
|
73
|
+
assert.deepEqual(extractBrokenUrls(report), [
|
|
74
|
+
'https://example.com/csharp/',
|
|
75
|
+
'https://example.com/rust/link_cli/',
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('extractBrokenLinks', () => {
|
|
81
|
+
it('reports errors that the Wayback Machine cannot answer', () => {
|
|
82
|
+
const { others } = extractBrokenLinks(report);
|
|
83
|
+
assert.equal(
|
|
84
|
+
others.length,
|
|
85
|
+
2,
|
|
86
|
+
'the missing DocFX file and the unresolvable root-relative link must not be silently dropped'
|
|
87
|
+
);
|
|
88
|
+
assert.ok(
|
|
89
|
+
others.some((link) => link.endsWith('Foundation.Data.Doublets.Cli.yml')),
|
|
90
|
+
'the missing local file must be reported'
|
|
91
|
+
);
|
|
92
|
+
assert.ok(
|
|
93
|
+
others.includes('error:'),
|
|
94
|
+
'the unresolvable root-relative link must be reported'
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('never classifies a non-http error as a URL', () => {
|
|
99
|
+
const localOnly = `## Errors per input
|
|
100
|
+
|
|
101
|
+
### Errors in docs/index.md
|
|
102
|
+
|
|
103
|
+
* [ERROR] <file:///repo/docs/api/Some.Type.yml> (at 15:12) | File not found. Check if file exists and path is correct
|
|
104
|
+
`;
|
|
105
|
+
|
|
106
|
+
const { urls, others } = extractBrokenLinks(localOnly);
|
|
107
|
+
assert.deepEqual(urls, []);
|
|
108
|
+
assert.deepEqual(others, ['file:///repo/docs/api/Some.Type.yml']);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('matches the error count lychee itself reports', () => {
|
|
112
|
+
const { urls, others } = extractBrokenLinks(report);
|
|
113
|
+
const reported = Number(/🚫 Errors\s*\|\s*(\d+)/.exec(report)[1]);
|
|
114
|
+
assert.equal(urls.length + others.length, reported);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('check-web-archive.mjs end to end', () => {
|
|
119
|
+
it('fails when the only lychee errors have no http URL to archive', async () => {
|
|
120
|
+
const scriptPath = join(
|
|
121
|
+
here,
|
|
122
|
+
'..',
|
|
123
|
+
'..',
|
|
124
|
+
'scripts',
|
|
125
|
+
'check-web-archive.mjs'
|
|
126
|
+
);
|
|
127
|
+
const workDir = mkdtempSync(join(tmpdir(), 'web-archive-'));
|
|
128
|
+
const reportPath = join(workDir, 'out.md');
|
|
129
|
+
const outputPath = join(workDir, 'github-output.txt');
|
|
130
|
+
writeFileSync(
|
|
131
|
+
reportPath,
|
|
132
|
+
`## Errors per input
|
|
133
|
+
|
|
134
|
+
### Errors in docs/index.md
|
|
135
|
+
|
|
136
|
+
* [ERROR] <file:///repo/docs/api/Some.Type.yml> (at 15:12) | File not found. Check if file exists and path is correct
|
|
137
|
+
`
|
|
138
|
+
);
|
|
139
|
+
writeFileSync(outputPath, '');
|
|
140
|
+
|
|
141
|
+
const result = spawnSync(process.execPath, [scriptPath], {
|
|
142
|
+
encoding: 'utf-8',
|
|
143
|
+
env: {
|
|
144
|
+
...process.env,
|
|
145
|
+
LYCHEE_OUTPUT: reportPath,
|
|
146
|
+
GITHUB_OUTPUT: outputPath,
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
assert.equal(result.status, 1, result.stdout + result.stderr);
|
|
151
|
+
assert.match(readFileSync(outputPath, 'utf-8'), /all_archived=false/);
|
|
152
|
+
rmSync(workDir, { recursive: true, force: true });
|
|
153
|
+
});
|
|
154
|
+
});
|