@bluewombat/slots 0.10.0 → 0.11.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.
@@ -1,227 +0,0 @@
1
- #!/usr/bin/env node
2
- import { spawn, spawnSync } from "node:child_process";
3
- import { existsSync } from "node:fs";
4
- import { join } from "node:path";
5
- import {
6
- emitVerdict,
7
- GATE_FLAGS,
8
- listOption,
9
- ownArgv,
10
- parseOptions,
11
- pathMatchesGlob,
12
- take,
13
- textOption,
14
- } from "@bluewombat/slot-kit";
15
- import { createOutputTail } from "../lib/output-tail.mjs";
16
-
17
- // The tests that were just written must FAIL, and for the right reason.
18
- //
19
- // A test that passes before the behaviour exists tests nothing, and one that fails on a
20
- // missing import or a syntax error says nothing about the behaviour either. This Gate
21
- // finds the test files the pass added or changed, runs the Project's own test command on
22
- // them, and answers:
23
- //
24
- // - no test file added or changed -> fail-retryable: the pass wrote no test
25
- // - the tests pass -> fail-retryable: they test nothing that is missing
26
- // - they fail on a wrong reason -> fail-retryable: fix imports, syntax, stubs
27
- // - they fail on something else -> pass: red, as a test written first should be
28
- // - the command cannot run, or hangs -> fail-blocking: no change to the tests fixes it
29
- //
30
- // --cmd COMMAND (required): run through the shell. `{files}` is replaced by the test files
31
- // of the pass, quoted, so "pnpm vitest run {files}" runs only those. Without `{files}`
32
- // the whole command runs as written.
33
- // --test-glob GLOB (repeatable): which paths are test files. Default: **/*.test.*,
34
- // **/*.spec.*, **/__tests__/**
35
- // --not-red-when REGEX (repeatable, case-insensitive): output that means the tests are red
36
- // for the wrong reason. Default: a module that cannot be found or resolved, a syntax
37
- // error, a TypeScript error. Naming any replaces the defaults.
38
-
39
- const cwd = process.cwd();
40
- const KEEP_BYTES = 12_000;
41
- const MARGIN_MS = 5_000;
42
- const MIN_RUN_MS = 1_000;
43
- const DEFAULT_GLOBS = ["**/*.test.*", "**/*.spec.*", "**/__tests__/**"];
44
- const DEFAULT_WRONG_REASONS = [
45
- "Cannot find module",
46
- "Failed to resolve (import|entry)",
47
- "ERR_MODULE_NOT_FOUND",
48
- "SyntaxError",
49
- "error TS\\d+",
50
- "Cannot find name",
51
- ];
52
-
53
- const parsed = parseOptions(
54
- ownArgv(process.argv, GATE_FLAGS),
55
- { "--cmd": {}, "--test-glob": { many: true }, "--not-red-when": { many: true } },
56
- "tests-red",
57
- );
58
- if (!parsed.ok) {
59
- emitVerdict("fail-blocking", `${parsed.reason} Use --cmd "<command>".`);
60
- process.exit(0);
61
- }
62
- const template = textOption(parsed.values, "cmd");
63
- if (template === undefined || template.trim().length === 0) {
64
- emitVerdict("fail-blocking", 'tests-red needs --cmd "<command>": the Project\'s test command.');
65
- process.exit(0);
66
- }
67
- const globs = listOption(parsed.values, "testGlob");
68
- const testGlobs = globs.length > 0 ? globs : DEFAULT_GLOBS;
69
- const reasons = listOption(parsed.values, "notRedWhen");
70
- let wrongReasons;
71
- try {
72
- wrongReasons = (reasons.length > 0 ? reasons : DEFAULT_WRONG_REASONS).map(
73
- (source) => new RegExp(source, "i"),
74
- );
75
- } catch (error) {
76
- emitVerdict(
77
- "fail-blocking",
78
- `tests-red: --not-red-when is not a pattern: ${error instanceof Error ? error.message : error}`,
79
- );
80
- process.exit(0);
81
- }
82
-
83
- if (git(["rev-parse", "--is-inside-work-tree"]).stdout.trim() !== "true") {
84
- emitVerdict("fail-blocking", "tests-red needs a git workspace.");
85
- process.exit(0);
86
- }
87
-
88
- const files = testFilesOfThePass();
89
- if (files.length === 0) {
90
- emitVerdict(
91
- "fail-retryable",
92
- `No test file was added or changed (test files match: ${testGlobs.join(", ")}). This pass writes the tests of the task, in the files its "Tests" section names.`,
93
- );
94
- process.exit(0);
95
- }
96
-
97
- const command = template.replaceAll("{files}", files.map(quote).join(" "));
98
- const ceilingMs = Number(take(process.argv, "--timeout-ms"));
99
- const limitMs =
100
- Number.isFinite(ceilingMs) && ceilingMs > 0
101
- ? Math.max(MIN_RUN_MS, ceilingMs - MARGIN_MS)
102
- : undefined;
103
-
104
- const child = spawn("sh", ["-c", command], {
105
- cwd,
106
- detached: true,
107
- stdio: ["ignore", "pipe", "pipe"],
108
- env: { ...process.env, CI: "true", NO_COLOR: "1", FORCE_COLOR: "0" },
109
- });
110
- const tail = createOutputTail(KEEP_BYTES * 2);
111
- child.stdout.on("data", (chunk) => tail.push("stdout", chunk));
112
- child.stderr.on("data", (chunk) => tail.push("stderr", chunk));
113
-
114
- let timedOut = false;
115
- const timer =
116
- limitMs === undefined
117
- ? undefined
118
- : setTimeout(() => {
119
- timedOut = true;
120
- killGroup();
121
- }, limitMs);
122
-
123
- child.on("error", (error) => {
124
- clearTimeout(timer);
125
- emitVerdict("fail-blocking", `tests-red cannot start "${command}": ${error.message}`);
126
- process.exit(0);
127
- });
128
-
129
- child.on("close", (code) => {
130
- clearTimeout(timer);
131
- const output = tail.text();
132
- const shown = endOf(output);
133
- if (timedOut) {
134
- emitVerdict(
135
- "fail-blocking",
136
- `"${command}" did not finish in ${Math.round((limitMs ?? 0) / 1000)}s and was stopped. A person decides: the tests hang, or this Gate's timeoutMs is too short.`,
137
- );
138
- return;
139
- }
140
- if (code === 126 || code === 127) {
141
- emitVerdict(
142
- "fail-blocking",
143
- `"${command}" could not be run here (exit ${code}): ${shown || "no output"}`,
144
- );
145
- return;
146
- }
147
- if (code === 0) {
148
- emitVerdict(
149
- "fail-retryable",
150
- [
151
- `These tests pass before the behaviour exists (${files.join(", ")}), so they test nothing that is missing.`,
152
- "Make each case assert the behaviour the task asks for and the code does not have yet.",
153
- ].join("\n"),
154
- );
155
- return;
156
- }
157
- const wrong = wrongReasons.find((pattern) => pattern.test(output));
158
- if (wrong !== undefined) {
159
- const line =
160
- output
161
- .split("\n")
162
- .find((one) => wrong.test(one))
163
- ?.trim() ?? wrong.source;
164
- emitVerdict(
165
- "fail-retryable",
166
- [
167
- `The tests fail, but not on the missing behaviour: ${line}`,
168
- "Make them compile and run: fix the import or the syntax, and give the interface under test a minimal stub (right name and signature, no behaviour), so they fail on what they check. End of the output:",
169
- shown,
170
- ].join("\n"),
171
- );
172
- return;
173
- }
174
- emitVerdict("pass");
175
- });
176
-
177
- function killGroup() {
178
- try {
179
- if (child.pid !== undefined) {
180
- process.kill(-child.pid, "SIGKILL");
181
- }
182
- } catch {
183
- child.kill("SIGKILL");
184
- }
185
- }
186
-
187
- /** The test files this pass added or changed, and that are still there. */
188
- function testFilesOfThePass() {
189
- const status = git(["status", "--porcelain", "-z", "-uall"]);
190
- if (status.status !== 0) {
191
- return [];
192
- }
193
- const found = [];
194
- const entries = status.stdout.split("\0").filter(Boolean);
195
- for (let i = 0; i < entries.length; i++) {
196
- const entry = entries[i] ?? "";
197
- const path = entry.slice(3);
198
- if (entry[0] === "R" || entry[1] === "R") {
199
- i += 1;
200
- }
201
- const deleted = entry[0] === "D" || entry[1] === "D";
202
- if (
203
- !deleted &&
204
- existsSync(join(cwd, path)) &&
205
- testGlobs.some((glob) => pathMatchesGlob(path, glob))
206
- ) {
207
- found.push(path);
208
- }
209
- }
210
- return found;
211
- }
212
-
213
- /** @param {string} path */
214
- function quote(path) {
215
- return `'${path.replaceAll("'", "'\\''")}'`;
216
- }
217
-
218
- /** The end of the output, where the error is, cut on a line boundary. */
219
- function endOf(/** @type {string} */ text) {
220
- const cut = text.length > KEEP_BYTES ? text.slice(text.length - KEEP_BYTES) : text;
221
- return (text.length > KEEP_BYTES ? cut.slice(cut.indexOf("\n") + 1) : cut).trim();
222
- }
223
-
224
- /** @param {string[]} args */
225
- function git(args) {
226
- return spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8" });
227
- }