@miroshnik/elves 0.0.0 → 2026.1018.1
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/AGENTS.md +310 -0
- package/README.md +344 -0
- package/bin/ai-dev.ts +7 -0
- package/bin/elves.ts +2847 -0
- package/bin/shims/timeout +113 -0
- package/claude/CLAUDE.md +71 -0
- package/dist/github/scripts/github.js +3428 -0
- package/dist/spec/scripts/architecture.js +628 -0
- package/dist/spec/scripts/eslint-config.js +54 -0
- package/dist/spec/scripts/harness-qzb932c8.js +959 -0
- package/dist/spec/scripts/harness-v9s1v9w3.js +16045 -0
- package/dist/spec/scripts/harness.js +53 -0
- package/dist/spec/scripts/knip-hints.cjs +34 -0
- package/docs/ci-concurrency.md +82 -0
- package/docs/cloud-sessions.md +204 -0
- package/docs/judgment.md +81 -0
- package/docs/parallel-checkouts.md +220 -0
- package/docs/pr-checks.md +117 -0
- package/docs/testing.md +304 -0
- package/package.json +70 -1
- package/skills/ci-wait/SKILL.md +180 -0
- package/skills/ci-wait/scripts/wait-ci.sh +377 -0
- package/skills/dashboard/SKILL.md +212 -0
- package/skills/dashboard/scripts/dashboard.ts +912 -0
- package/skills/dashboard/scripts/sessions.ts +772 -0
- package/skills/est/SKILL.md +222 -0
- package/skills/est/reference.md +381 -0
- package/skills/est/scripts/est.ts +4867 -0
- package/skills/github/SKILL.md +206 -0
- package/skills/github/locales/en.json +449 -0
- package/skills/github/locales/ru.json +449 -0
- package/skills/github/reference.md +1073 -0
- package/skills/github/scripts/github.ts +4390 -0
- package/skills/github/scripts/locale.ts +495 -0
- package/skills/github/scripts/package.json +3 -0
- package/skills/slot/SKILL.md +145 -0
- package/skills/slot/scripts/package.json +3 -0
- package/skills/slot/scripts/slot.ts +279 -0
- package/skills/spec/SKILL.md +247 -0
- package/skills/spec/canon.md +219 -0
- package/skills/spec/checks.md +709 -0
- package/skills/spec/ci.md +378 -0
- package/skills/spec/migration.md +125 -0
- package/skills/spec/reference.md +889 -0
- package/skills/spec/scripts/architecture.ts +839 -0
- package/skills/spec/scripts/c4.ts +184 -0
- package/skills/spec/scripts/eslint-config.ts +82 -0
- package/skills/spec/scripts/harness.ts +1483 -0
- package/skills/spec/scripts/knip-hints.cjs +34 -0
- package/skills/spec/scripts/knip-hints.d.cts +17 -0
- package/skills/spec/scripts/package-run.ts +22 -0
- package/skills/spec/scripts/package.json +3 -0
- package/skills/spec/scripts/spec-break.ts +232 -0
- package/skills/spec/scripts/spec-claims.ts +198 -0
- package/skills/spec/scripts/spec-diff.ts +1111 -0
- package/skills/spec/scripts/spec-doc.ts +744 -0
- package/skills/spec/scripts/spec-exceptions.ts +233 -0
- package/skills/spec/scripts/spec-publish.ts +214 -0
- package/skills/spec/scripts/spec-run.ts +235 -0
- package/skills/spec/scripts/spec-shard.ts +267 -0
- package/skills/spec/scripts/speclib.ts +826 -0
- package/skills/spec/scripts/vendor/babel-parser.LICENSE +19 -0
- package/skills/spec/scripts/vendor/babel-parser.cjs +14615 -0
- package/skills/spec/scripts/vendor/babel-parser.d.cts +3 -0
- package/skills/task-env/SKILL.md +229 -0
- package/skills/task-env/proxy/compose.yaml +48 -0
- package/skills/task-env/reference.md +246 -0
- package/skills/task-env/scripts/task-env.ts +981 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* spec-run — the green run that checked the merge commit's tree, for publishing the spec on a PR merge (without CI on
|
|
4
|
+
* main): the run id goes to stdout, `gh run download` downloads the artifact `docs-spec-<tree hash>` from it. A PR run
|
|
5
|
+
* checks the merge-ref and names the artifact by its tree; the merge commit has the same tree only if nothing was merged
|
|
6
|
+
* into main between the run's start and the merge — then main is checked as a whole. A PR that fell behind was merged —
|
|
7
|
+
* there is no such artifact, and publishing is skipped: from its head's run it would roll the spec branch back (the pages
|
|
8
|
+
* of a PR merged earlier would vanish from it).
|
|
9
|
+
*
|
|
10
|
+
* Among this repository's runs with an unexpired artifact of the tree: a green one exists — it (the newest); one is
|
|
11
|
+
* running — polling until its outcome, not finished by the ceiling — an error; otherwise (no artifact, expired, from a
|
|
12
|
+
* fork, the run isn't green) — skip: code 0, stdout empty, the reason in a stderr line.
|
|
13
|
+
*
|
|
14
|
+
* `--artifact <name>` — for spec-shard: the newest green run of this repository with an unexpired artifact of that name
|
|
15
|
+
* (the `spec` job's reports — durations of the last green run). Without waiting: a run in progress isn't green yet, the
|
|
16
|
+
* previous green one does. In Actions (`GITHUB_RUN_ID`) — green by the start of the current run's first attempt: every
|
|
17
|
+
* shard of the run gets one answer whenever it reaches the step, a re-run of failed shards too. None — code 0, stdout
|
|
18
|
+
* empty, the reason in stderr: the shards are split by count.
|
|
19
|
+
*
|
|
20
|
+
* bun spec-run.ts --tree <hash> [--timeout 1800] [--interval 15]
|
|
21
|
+
* bun spec-run.ts --artifact <name>
|
|
22
|
+
*
|
|
23
|
+
* Codes: 0 — found or publishing skipped, 1 — the run didn't finish by the ceiling, 2 — a call error (arguments, gh).
|
|
24
|
+
* Runs under Bun, no dependencies; needs `gh` with a token (in Actions — `GH_TOKEN`, `actions: read`).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { execFileSync } from "node:child_process";
|
|
28
|
+
import { parseArgs } from "node:util";
|
|
29
|
+
|
|
30
|
+
const USAGE = "spec-run --tree <hash> [--timeout 1800] [--interval 15]\nspec-run --artifact <name>";
|
|
31
|
+
/** `--help`, `-h` (tests/standards/cli-help): the synopsis, what it does and examples; `elves` prints it under its name. */
|
|
32
|
+
export const HELP = `${USAGE}
|
|
33
|
+
|
|
34
|
+
The green run that checked the merge commit's tree: its id to stdout, for gh run download of docs-spec-<tree hash>; no
|
|
35
|
+
such run — skip: code 0, stdout empty, the reason in stderr. Code 1 — the run didn't finish by the ceiling, 2 — a call
|
|
36
|
+
error. --artifact — the newest green run with an unexpired artifact of that name, without waiting (for spec-shard); in
|
|
37
|
+
Actions (GITHUB_RUN_ID) — green by the start of this run's first attempt: one answer for all of its shards.
|
|
38
|
+
|
|
39
|
+
Examples:
|
|
40
|
+
spec-run --tree "$(git rev-parse 'HEAD^{tree}')"
|
|
41
|
+
spec-run --artifact spec-reports`;
|
|
42
|
+
|
|
43
|
+
interface Artifact {
|
|
44
|
+
name: string;
|
|
45
|
+
expired: boolean;
|
|
46
|
+
created_at: string;
|
|
47
|
+
workflow_run: { id: number; repository_id: number; head_repository_id: number } | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A run (`runs/<id>` — its latest attempt) or one attempt of it (`runs/<id>/attempts/<k>`). */
|
|
51
|
+
interface Run {
|
|
52
|
+
id: number;
|
|
53
|
+
status: string;
|
|
54
|
+
conclusion: string | null;
|
|
55
|
+
html_url: string;
|
|
56
|
+
run_attempt?: number;
|
|
57
|
+
run_started_at?: string;
|
|
58
|
+
/** a completed attempt isn't updated any more: its finish */
|
|
59
|
+
updated_at?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
class Fail extends Error {
|
|
63
|
+
code: number;
|
|
64
|
+
constructor(message: string, code: number) {
|
|
65
|
+
super(message);
|
|
66
|
+
this.code = code;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function api<T>(endpoint: string): T {
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(execFileSync("gh", ["api", endpoint], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })) as T;
|
|
73
|
+
} catch (e) {
|
|
74
|
+
const stderr = (e as { stderr?: string }).stderr?.trim();
|
|
75
|
+
throw new Fail(`gh api ${endpoint}: ${stderr || (e as Error).message.split("\n")[0]}`, 2);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const time = (iso: string | undefined): number => Date.parse(iso ?? "");
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Ids of the runs with a live artifact, newest first. Only this repository's runs count: a PR run from a fork can upload
|
|
83
|
+
* an artifact with any name too. `before` — only artifacts uploaded by then: a run finished by a moment uploaded its
|
|
84
|
+
* artifact before it. A page of 100: newer artifacts keep coming while a run lasts, an older source stays on it.
|
|
85
|
+
*/
|
|
86
|
+
function runIds(artifact: string, before?: number): number[] {
|
|
87
|
+
const { artifacts } = api<{ artifacts: Artifact[] }>(`repos/{owner}/{repo}/actions/artifacts?name=${encodeURIComponent(artifact)}&per_page=100`);
|
|
88
|
+
const own = artifacts.filter(
|
|
89
|
+
(a) => !a.expired && a.workflow_run && a.workflow_run.head_repository_id === a.workflow_run.repository_id && !(time(a.created_at) > (before ?? Infinity)),
|
|
90
|
+
);
|
|
91
|
+
// a rerun uploads the artifact again under the same run id
|
|
92
|
+
return [...new Set(own.map((a) => a.workflow_run!.id))];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The outcome — a separate request for the run: the artifact is uploaded mid-run and doesn't prove a green outcome. */
|
|
96
|
+
const runOf = (id: number): Run => api<Run>(`repos/{owner}/{repo}/actions/runs/${id}`);
|
|
97
|
+
|
|
98
|
+
/** The moment the shards' source is fixed at (`--artifact`) and the run it belongs to. */
|
|
99
|
+
interface Anchor {
|
|
100
|
+
run: string;
|
|
101
|
+
at: number;
|
|
102
|
+
iso: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The start of the current run's first attempt (`GITHUB_RUN_ID` in Actions). The newest green run "now" depends on when
|
|
107
|
+
* a shard reaches the step: a run that finished between two shards' requests gave them different layouts, and files ran
|
|
108
|
+
* in two shards or in none. By a fixed moment every shard gets one answer. The first attempt, not the current one: a
|
|
109
|
+
* re-run of failed jobs keeps the passed shards of the first attempt with their layout. Outside Actions — none.
|
|
110
|
+
*/
|
|
111
|
+
function anchor(): Anchor | null {
|
|
112
|
+
const run = process.env.GITHUB_RUN_ID;
|
|
113
|
+
if (!run) return null;
|
|
114
|
+
const first = api<Run>(`repos/{owner}/{repo}/actions/runs/${run}/attempts/1`);
|
|
115
|
+
const at = time(first.run_started_at);
|
|
116
|
+
if (Number.isNaN(at)) throw new Fail(`run ${run}: attempt 1 has no run_started_at`, 2);
|
|
117
|
+
return { run, at, iso: first.run_started_at! };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The run as it was at `at`: its latest attempt started by then. A re-run of the run started later doesn't change the
|
|
122
|
+
* answer — the previous attempts are asked; null — the run started later.
|
|
123
|
+
*/
|
|
124
|
+
function asOf(id: number, at: number): Run | null {
|
|
125
|
+
let run = runOf(id);
|
|
126
|
+
for (let k = (run.run_attempt ?? 1) - 1; time(run.run_started_at) > at; k--) {
|
|
127
|
+
if (k < 1) return null;
|
|
128
|
+
run = api<Run>(`repos/{owner}/{repo}/actions/runs/${id}/attempts/${k}`);
|
|
129
|
+
}
|
|
130
|
+
return run;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Green by the moment `at` (none — now): succeeded and finished by then, a later finish counts as unfinished. */
|
|
134
|
+
const greenBy = (run: Run, at?: number): boolean =>
|
|
135
|
+
run.status === "completed" && run.conclusion === "success" && !(at !== undefined && time(run.updated_at) > at);
|
|
136
|
+
|
|
137
|
+
/** The run's state for the "no green run" line: its outcome, or status, and whether it finished after the moment. */
|
|
138
|
+
function stateOf(run: Run, at?: number): string {
|
|
139
|
+
const late = at !== undefined && run.status === "completed" && time(run.updated_at) > at;
|
|
140
|
+
return `${run.id} ${run.conclusion ?? run.status}${late ? " (finished later)" : ""} ${run.html_url}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The newest green run with the artifact (`--artifact`): runs are asked newest first, up to the first green one. In
|
|
145
|
+
* Actions — as of the start of the current run (`anchor`).
|
|
146
|
+
*/
|
|
147
|
+
function latest(artifact: string): number {
|
|
148
|
+
const by = anchor();
|
|
149
|
+
const at = by?.at;
|
|
150
|
+
const when = by ? ` before run ${by.run} started (${by.iso})` : "";
|
|
151
|
+
const asked: string[] = [];
|
|
152
|
+
for (const id of runIds(artifact, at)) {
|
|
153
|
+
const run = at === undefined ? runOf(id) : asOf(id, at);
|
|
154
|
+
if (!run) {
|
|
155
|
+
asked.push(`${id} started later`);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (greenBy(run, at)) {
|
|
159
|
+
console.error(`spec-run: artifact ${artifact}: run ${run.id} succeeded${when} — ${run.html_url}`);
|
|
160
|
+
process.stdout.write(`${run.id}\n`);
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
asked.push(stateOf(run, at));
|
|
164
|
+
}
|
|
165
|
+
const why = asked.length ? asked.join(", ") : `no artifact ${artifact}`;
|
|
166
|
+
console.error(`spec-run: artifact ${artifact}: no green run${when} — ${why}`);
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const seconds = (name: string, value: string): number => {
|
|
171
|
+
const n = Number(value);
|
|
172
|
+
if (!Number.isFinite(n) || n < 0) throw new Fail(`--${name}: a number of seconds is needed, not «${value}»\n${USAGE}`, 2);
|
|
173
|
+
return n;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
export async function main(argv: string[]): Promise<number> {
|
|
177
|
+
try {
|
|
178
|
+
let opts;
|
|
179
|
+
try {
|
|
180
|
+
opts = parseArgs({
|
|
181
|
+
args: argv,
|
|
182
|
+
options: {
|
|
183
|
+
tree: { type: "string" },
|
|
184
|
+
artifact: { type: "string" },
|
|
185
|
+
timeout: { type: "string", default: "1800" },
|
|
186
|
+
interval: { type: "string", default: "15" },
|
|
187
|
+
help: { type: "boolean", short: "h", default: false },
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
} catch (e) {
|
|
191
|
+
throw new Fail(`${(e as Error).message}\n${USAGE}`, 2);
|
|
192
|
+
}
|
|
193
|
+
const v = opts.values;
|
|
194
|
+
if (v.help) return console.log(HELP), 0;
|
|
195
|
+
if (v.tree && v.artifact) throw new Fail(`--tree and --artifact don't go together\n${USAGE}`, 2);
|
|
196
|
+
if (v.artifact) return latest(v.artifact);
|
|
197
|
+
if (!v.tree) throw new Fail(`--tree <hash> or --artifact <name> is required\n${USAGE}`, 2);
|
|
198
|
+
if (!/^[0-9a-f]{40,64}$/.test(v.tree)) throw new Fail(`--tree: a full tree hash is needed (git rev-parse 'HEAD^{tree}'), not «${v.tree}»\n${USAGE}`, 2);
|
|
199
|
+
const timeout = seconds("timeout", v.timeout);
|
|
200
|
+
const interval = seconds("interval", v.interval);
|
|
201
|
+
const artifact = `docs-spec-${v.tree}`;
|
|
202
|
+
const at = `main tree ${v.tree.slice(0, 12)}`;
|
|
203
|
+
const deadline = Date.now() + timeout * 1000;
|
|
204
|
+
let said = "";
|
|
205
|
+
for (;;) {
|
|
206
|
+
const all = runIds(artifact).map(runOf);
|
|
207
|
+
// the listing is newest first: of several green runs the latest is taken
|
|
208
|
+
const ok = all.find((r) => r.status === "completed" && r.conclusion === "success");
|
|
209
|
+
if (ok) {
|
|
210
|
+
console.error(`spec-run: ${at}: run ${ok.id} succeeded — ${ok.html_url}`);
|
|
211
|
+
process.stdout.write(`${ok.id}\n`);
|
|
212
|
+
return 0;
|
|
213
|
+
}
|
|
214
|
+
const going = all.filter((r) => r.status !== "completed");
|
|
215
|
+
if (!going.length) {
|
|
216
|
+
// not an error: a PR that fell behind was merged — no run checked such a main, the spec branch waits for the next merge
|
|
217
|
+
const why = all.length ? all.map((r) => `${r.id} ${r.conclusion} ${r.html_url}`).join(", ") : `no artifact ${artifact}`;
|
|
218
|
+
console.error(`spec-run: ${at} not fully checked — publishing skipped: ${why}`);
|
|
219
|
+
return 0;
|
|
220
|
+
}
|
|
221
|
+
if (Date.now() >= deadline) throw new Fail(`${at}: run ${going.map((r) => r.id).join(", ")} didn't finish in ${timeout} s`, 1);
|
|
222
|
+
const now = `${at}: run ${going.map((r) => `${r.id} ${r.status}`).join(", ")} — waiting`;
|
|
223
|
+
// only a change of state goes to the log, not a line on every poll
|
|
224
|
+
if (now !== said) console.error(`spec-run: ${(said = now)}`);
|
|
225
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(interval * 1000, Math.max(0, deadline - Date.now()))));
|
|
226
|
+
}
|
|
227
|
+
} catch (e) {
|
|
228
|
+
console.error(`spec-run: ${(e as Error).message}`);
|
|
229
|
+
return e instanceof Fail ? e.code : 2;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (import.meta.main) {
|
|
234
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
235
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* spec-shard — a CI shard's test files by the durations of the last green run, not by the count of tests. A runner's
|
|
4
|
+
* `--shard` doesn't look at time: Playwright cuts the suite in a row by the count of tests in path order, Vitest —
|
|
5
|
+
* files by the hash of the path; the heavy tests of one folder go to one shard, the PR waits for it, the others stand
|
|
6
|
+
* idle. The script lays out the files greedily — the longest into the lightest shard — and prints the shard's filters
|
|
7
|
+
* for the runner, one per line; CI passes them to the runner instead of `--shard`.
|
|
8
|
+
*
|
|
9
|
+
* bun spec-shard.ts --shard <k>/<n> --runner vitest|playwright|bun --list <file|-> [--report <file>]… [--root DIR]
|
|
10
|
+
*
|
|
11
|
+
* - `--list` — the run's test files: `vitest list --filesOnly --json=<file>`, `playwright test --list --reporter=json`
|
|
12
|
+
* or one path per line (bun test lists nothing without a run — `git ls-files`); absolute paths — relative to the root
|
|
13
|
+
* (`--root`, default — the current directory: the runner reads the filters from it).
|
|
14
|
+
* - `--report` — the last green run's reports, the same formats as for spec-doc (Vitest/Jest JSON, Playwright JSON,
|
|
15
|
+
* JUnit from `bun test`); a report's path is matched to the listed file it ends with — a report from another machine
|
|
16
|
+
* works. A missing report file isn't an error: the reports are an artifact of the previous run, and it may be gone.
|
|
17
|
+
* No durations — the files are split equally by count; a file without a measurement gets the mean.
|
|
18
|
+
* - A filter is what the runner understands: Vitest — the path (it takes a file whose path contains the filter),
|
|
19
|
+
* Playwright — an escaped regular expression anchored at the end of the path, bun test — the path with `./` (such a
|
|
20
|
+
* filter is the file's path, not a substring). A filter that also takes another file (a path inside another path) —
|
|
21
|
+
* both go into one shard: the shard's filters take exactly its files.
|
|
22
|
+
* - The same inputs give the same shards: files and ties — by path, without time or randomness.
|
|
23
|
+
*
|
|
24
|
+
* Codes: 0 — the shard is printed (an empty one too: more shards than files — stdout is empty, CI doesn't start the
|
|
25
|
+
* runner: without filters it would run everything), 2 — a call error (arguments, the list or a report unreadable).
|
|
26
|
+
* Runs under Bun, no dependencies.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
import { parseArgs } from "node:util";
|
|
32
|
+
|
|
33
|
+
import { loadDurations } from "./speclib.ts";
|
|
34
|
+
|
|
35
|
+
const posix = path.posix;
|
|
36
|
+
|
|
37
|
+
const USAGE = "spec-shard --shard <k>/<n> --runner vitest|playwright|bun --list <file|-> [--report <file>]… [--root DIR]";
|
|
38
|
+
/** `--help`, `-h` (tests/standards/cli-help): the synopsis, what it does and examples; `elves` prints it under its name. */
|
|
39
|
+
export const HELP = `${USAGE}
|
|
40
|
+
|
|
41
|
+
A CI shard's test files by the durations of the last green run, not by the count of tests: greedily — the longest file
|
|
42
|
+
into the lightest shard; the shard's filters for the runner go to stdout, one per line (an empty shard — nothing).
|
|
43
|
+
--list — vitest list --filesOnly --json=<file>, playwright test --list --reporter=json or one path per line (bun test —
|
|
44
|
+
git ls-files); --report — the last green run's reports (Vitest/Jest JSON, Playwright JSON, JUnit from bun test), a
|
|
45
|
+
missing one is skipped; no durations — the files are split equally by count, a file without a measurement gets the
|
|
46
|
+
mean. A filter: vitest — the path, playwright — a regular expression, bun — ./<path>. Code 2 — a call error.
|
|
47
|
+
|
|
48
|
+
Examples:
|
|
49
|
+
spec-shard --shard 2/3 --runner vitest --list .spec-files.json --report .spec-last/.spec-report.json
|
|
50
|
+
spec-shard --shard 1/2 --runner playwright --list .spec-files.json --report .spec-last/.spec-playwright.json
|
|
51
|
+
spec-shard --shard 1/3 --runner bun --list .spec-files.txt --report .spec-last/.spec-report-1.xml --report .spec-last/.spec-report-2.xml`;
|
|
52
|
+
|
|
53
|
+
const RUNNERS = ["vitest", "playwright", "bun"] as const;
|
|
54
|
+
type Runner = (typeof RUNNERS)[number];
|
|
55
|
+
|
|
56
|
+
class Fail extends Error {
|
|
57
|
+
code: number;
|
|
58
|
+
constructor(message: string, code: number) {
|
|
59
|
+
super(message);
|
|
60
|
+
this.code = code;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type Json = Record<string, unknown>;
|
|
65
|
+
const str = (v: unknown): string => (typeof v === "string" ? v : "");
|
|
66
|
+
|
|
67
|
+
/** The run's test files from the list: paths relative to the root, without repeats, by path. */
|
|
68
|
+
function listed(file: string, roots: string[]): string[] {
|
|
69
|
+
let text: string;
|
|
70
|
+
try {
|
|
71
|
+
text = readFileSync(file === "-" ? 0 : file, "utf8");
|
|
72
|
+
} catch (e) {
|
|
73
|
+
throw new Fail(`--list ${file}: ${(e as Error).message}`, 2);
|
|
74
|
+
}
|
|
75
|
+
let data: unknown;
|
|
76
|
+
try {
|
|
77
|
+
data = JSON.parse(text);
|
|
78
|
+
} catch {
|
|
79
|
+
data = undefined; // not JSON — one path per line
|
|
80
|
+
}
|
|
81
|
+
let paths: string[];
|
|
82
|
+
if (Array.isArray(data)) {
|
|
83
|
+
// vitest list --json: [{ file }], with --filesOnly too
|
|
84
|
+
paths = data.map((x) => (typeof x === "string" ? x : str((x as Json | null)?.file)));
|
|
85
|
+
} else if (data && typeof data === "object" && "suites" in data && "config" in data) {
|
|
86
|
+
// playwright test --list --reporter=json: a file's suite — the path relative to config.rootDir
|
|
87
|
+
const d = data as Json;
|
|
88
|
+
const rootDir = str((d.config as Json | null)?.rootDir).replace(/\\/g, "/");
|
|
89
|
+
paths = (Array.isArray(d.suites) ? (d.suites as Json[]) : []).map((s) => {
|
|
90
|
+
const f = (str(s.file) || str(s.title)).replace(/\\/g, "/");
|
|
91
|
+
return rootDir ? posix.join(rootDir, f) : f;
|
|
92
|
+
});
|
|
93
|
+
} else if (data !== undefined) {
|
|
94
|
+
throw new Fail(`--list ${file}: unknown format (expected vitest list --json, playwright test --list --reporter=json or one path per line)`, 2);
|
|
95
|
+
} else {
|
|
96
|
+
paths = text.split("\n");
|
|
97
|
+
}
|
|
98
|
+
const out = new Set<string>();
|
|
99
|
+
for (const raw of paths) {
|
|
100
|
+
const p = raw.trim().replace(/\\/g, "/");
|
|
101
|
+
if (p) out.add(relative(p, roots));
|
|
102
|
+
}
|
|
103
|
+
return [...out].sort();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A path from the list — relative to the root; the root is given as is and as the real path (a symlink in it). */
|
|
107
|
+
function relative(p: string, roots: string[]): string {
|
|
108
|
+
if (!posix.isAbsolute(p)) return posix.normalize(p);
|
|
109
|
+
for (const root of roots) {
|
|
110
|
+
const rel = posix.relative(root, p);
|
|
111
|
+
if (rel && rel !== ".." && !rel.startsWith("../")) return rel;
|
|
112
|
+
}
|
|
113
|
+
throw new Fail(`${p}: outside the root ${roots[0]} — run from the repository root or pass --root`, 2);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The listed files' durations, ms. A report's path goes to the listed file it ends with (the longest such); several
|
|
118
|
+
* paths of one report end with one listed file — the shortest is that file, the others lie deeper, in a directory the
|
|
119
|
+
* list doesn't have. A file in several reports — the longest duration: the same report passed twice doesn't double it.
|
|
120
|
+
*/
|
|
121
|
+
function durations(files: string[], reports: string[]): Map<string, number> {
|
|
122
|
+
const byName = new Map<string, string[]>();
|
|
123
|
+
for (const f of files) byName.set(posix.basename(f), [...(byName.get(posix.basename(f)) ?? []), f]);
|
|
124
|
+
const out = new Map<string, number>();
|
|
125
|
+
for (const report of reports) {
|
|
126
|
+
if (!existsSync(report)) {
|
|
127
|
+
console.error(`spec-shard: no report ${report} — no durations from it`);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
let found: Map<string, number>;
|
|
131
|
+
try {
|
|
132
|
+
found = loadDurations(report);
|
|
133
|
+
} catch (e) {
|
|
134
|
+
throw new Fail(`--report ${(e as Error).message}`, 2);
|
|
135
|
+
}
|
|
136
|
+
const mine = new Map<string, { length: number; ms: number }>();
|
|
137
|
+
for (const [p, ms] of found) {
|
|
138
|
+
const f = (byName.get(posix.basename(p)) ?? []).filter((l) => p === l || p.endsWith("/" + l)).sort((a, b) => b.length - a.length)[0];
|
|
139
|
+
if (!f) continue;
|
|
140
|
+
const seen = mine.get(f);
|
|
141
|
+
if (!seen || p.length < seen.length) mine.set(f, { length: p.length, ms });
|
|
142
|
+
}
|
|
143
|
+
for (const [f, { ms }] of mine) out.set(f, Math.max(out.get(f) ?? 0, ms));
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Does the filter of file `a` also take file `b` — by the runner's matching of a filter against a path.
|
|
150
|
+
*
|
|
151
|
+
* - Vitest (`filterFiles` in `vitest/node`) takes a file whose path relative to its project's root contains the filter
|
|
152
|
+
* or the filter's path relative to that root, case-insensitively; the project root isn't known here — any directory
|
|
153
|
+
* common to both paths is tried.
|
|
154
|
+
* - Playwright tests the regular expression against the absolute path: `(^|/)<a>$` takes a file whose path ends with
|
|
155
|
+
* `/<a>`.
|
|
156
|
+
* - bun test takes a filter starting with `./` as the file's path — only that file; without `./` it would take a file
|
|
157
|
+
* whose path from the current directory contains the filter, case-sensitively.
|
|
158
|
+
*/
|
|
159
|
+
function takes(runner: Runner, a: string, b: string, root: string): boolean {
|
|
160
|
+
if (runner === "bun") return false;
|
|
161
|
+
if (runner === "playwright") return `${root}/${b}`.endsWith(`/${a}`);
|
|
162
|
+
const [la, lb] = [a.toLowerCase(), b.toLowerCase()];
|
|
163
|
+
if (!lb.includes(posix.basename(la))) return false;
|
|
164
|
+
for (let i = 0; ; ) {
|
|
165
|
+
if (lb.slice(i).includes(la.slice(i))) return true;
|
|
166
|
+
const j = la.indexOf("/", i);
|
|
167
|
+
if (j < 0 || lb.slice(0, j + 1) !== la.slice(0, j + 1)) return false;
|
|
168
|
+
i = j + 1;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
173
|
+
|
|
174
|
+
/** The runner's filter of a file: it takes the file and at most files of its group (`groupsOf`). */
|
|
175
|
+
function filterOf(runner: Runner, file: string): string {
|
|
176
|
+
if (runner === "bun") return `./${file}`;
|
|
177
|
+
return runner === "playwright" ? `/(^|\\/)${escapeRe(file)}$/` : file;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Files whose filters take one another — groups (a union of the "takes" relation), each by path. */
|
|
181
|
+
function groupsOf(runner: Runner, files: string[], root: string): string[][] {
|
|
182
|
+
const parent = files.map((_, i) => i);
|
|
183
|
+
const find = (i: number): number => (parent[i] === i ? i : (parent[i] = find(parent[i]!)));
|
|
184
|
+
for (let i = 0; i < files.length; i++)
|
|
185
|
+
for (let j = 0; j < files.length; j++) if (i !== j && takes(runner, files[i]!, files[j]!, root)) parent[find(j)] = find(i);
|
|
186
|
+
const groups = new Map<number, string[]>();
|
|
187
|
+
files.forEach((f, i) => groups.set(find(i), [...(groups.get(find(i)) ?? []), f]));
|
|
188
|
+
return [...groups.values()];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Greedy layout: groups from the heaviest into the lightest shard (ties — the first). The sort is stable: groups of one
|
|
193
|
+
* weight stay in path order — the list is sorted.
|
|
194
|
+
*/
|
|
195
|
+
function layout(groups: { files: string[]; weight: number }[], n: number): { files: string[]; load: number }[] {
|
|
196
|
+
const shards = Array.from({ length: n }, () => ({ files: [] as string[], load: 0 }));
|
|
197
|
+
const order = [...groups].sort((x, y) => y.weight - x.weight);
|
|
198
|
+
for (const g of order) {
|
|
199
|
+
const lightest = shards.reduce((best, s) => (s.load < best.load ? s : best));
|
|
200
|
+
lightest.files.push(...g.files);
|
|
201
|
+
lightest.load += g.weight;
|
|
202
|
+
}
|
|
203
|
+
return shards;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const sec = (ms: number): string => `${Math.round(ms / 1000)} s`;
|
|
207
|
+
|
|
208
|
+
export function main(argv: string[]): number {
|
|
209
|
+
try {
|
|
210
|
+
let opts;
|
|
211
|
+
try {
|
|
212
|
+
opts = parseArgs({
|
|
213
|
+
args: argv,
|
|
214
|
+
options: {
|
|
215
|
+
shard: { type: "string" },
|
|
216
|
+
runner: { type: "string" },
|
|
217
|
+
list: { type: "string" },
|
|
218
|
+
report: { type: "string", multiple: true, default: [] },
|
|
219
|
+
root: { type: "string" },
|
|
220
|
+
help: { type: "boolean", short: "h", default: false },
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
} catch (e) {
|
|
224
|
+
throw new Fail(`${(e as Error).message}\n${USAGE}`, 2);
|
|
225
|
+
}
|
|
226
|
+
const v = opts.values;
|
|
227
|
+
if (v.help) return console.log(HELP), 0;
|
|
228
|
+
const m = /^(\d+)\/(\d+)$/.exec(v.shard ?? "");
|
|
229
|
+
const [k, n] = m ? [Number(m[1]), Number(m[2])] : [0, 0];
|
|
230
|
+
if (!m || k < 1 || k > n) throw new Fail(`--shard: <k>/<n> with 1 ≤ k ≤ n is needed, not «${v.shard ?? ""}»\n${USAGE}`, 2);
|
|
231
|
+
if (!RUNNERS.includes(v.runner as Runner)) throw new Fail(`--runner: ${RUNNERS.join(" or ")}, not «${v.runner ?? ""}»\n${USAGE}`, 2);
|
|
232
|
+
const runner = v.runner as Runner;
|
|
233
|
+
if (!v.list) throw new Fail(`--list <file|-> is required\n${USAGE}`, 2);
|
|
234
|
+
const root = path.resolve(v.root ?? process.cwd()).replace(/\\/g, "/");
|
|
235
|
+
if (!existsSync(root)) throw new Fail(`--root ${root}: no such directory`, 2);
|
|
236
|
+
const real = realpathSync(root).replace(/\\/g, "/");
|
|
237
|
+
|
|
238
|
+
const files = listed(v.list, [root, real]);
|
|
239
|
+
const measured = durations(files, v.report);
|
|
240
|
+
const known = [...measured.values()];
|
|
241
|
+
// no durations at all — every file weighs the same: the split is by count
|
|
242
|
+
const mean = known.length ? known.reduce((a, b) => a + b, 0) / known.length : 1;
|
|
243
|
+
const weight = (f: string): number => measured.get(f) ?? mean;
|
|
244
|
+
const groups = groupsOf(runner, files, real).map((g) => ({ files: g, weight: g.reduce((s, f) => s + weight(f), 0) }));
|
|
245
|
+
for (const g of groups)
|
|
246
|
+
if (g.files.length > 1) console.error(`spec-shard: one shard for ${g.files.join(", ")} — the filter of one also takes another`);
|
|
247
|
+
const shards = layout(groups, n);
|
|
248
|
+
const mine = shards[k - 1]!;
|
|
249
|
+
const total = shards.reduce((s, x) => s + x.load, 0);
|
|
250
|
+
const count = `shard ${k}/${n} — ${mine.files.length} of ${files.length} files`;
|
|
251
|
+
if (!known.length) console.error(`spec-shard: no durations of the last green run — the files are split equally by count; ${count}`);
|
|
252
|
+
else {
|
|
253
|
+
const unknown = files.filter((f) => !measured.has(f)).length;
|
|
254
|
+
console.error(`spec-shard: ${count}, ${sec(mine.load)} of ${sec(total)} by the last green run${unknown ? `; ${unknown} without a measurement — the mean, ${sec(mean)}` : ""}`);
|
|
255
|
+
}
|
|
256
|
+
const out = [...mine.files].sort().map((f) => filterOf(runner, f));
|
|
257
|
+
if (out.length) process.stdout.write(out.join("\n") + "\n");
|
|
258
|
+
return 0;
|
|
259
|
+
} catch (e) {
|
|
260
|
+
console.error(`spec-shard: ${(e as Error).message}`);
|
|
261
|
+
return e instanceof Fail ? e.code : 2;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (import.meta.main) {
|
|
266
|
+
process.exitCode = main(process.argv.slice(2));
|
|
267
|
+
}
|