@tested/cli 0.1.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/LICENSE +21 -0
- package/README.md +174 -0
- package/dist/td.js +2579 -0
- package/dist/tested.js +2579 -0
- package/package.json +60 -0
package/dist/tested.js
ADDED
|
@@ -0,0 +1,2579 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command as Command10 } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands/init.ts
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "fs";
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
import "commander";
|
|
10
|
+
import { simpleGit } from "simple-git";
|
|
11
|
+
|
|
12
|
+
// src/output/ui.ts
|
|
13
|
+
import pc from "picocolors";
|
|
14
|
+
|
|
15
|
+
// src/token-help.ts
|
|
16
|
+
var INGEST_TOKEN_ENV_NAMES = [
|
|
17
|
+
"TESTED_TOKEN",
|
|
18
|
+
"TESTED_TOKEN_FILE",
|
|
19
|
+
"TESTED_INGEST_TOKEN"
|
|
20
|
+
];
|
|
21
|
+
var INGEST_TOKEN_SETTINGS_URL_SHAPE = "https://app.tested.dev/repos/{owner}/{name}/settings";
|
|
22
|
+
var GITHUB_NAME_RE = /^[\w.-]+$/;
|
|
23
|
+
function ingestTokenSettingsUrl(owner, name) {
|
|
24
|
+
if (owner && name && GITHUB_NAME_RE.test(owner) && GITHUB_NAME_RE.test(name)) {
|
|
25
|
+
return `https://app.tested.dev/repos/${owner}/${name}/settings`;
|
|
26
|
+
}
|
|
27
|
+
return INGEST_TOKEN_SETTINGS_URL_SHAPE;
|
|
28
|
+
}
|
|
29
|
+
function tokenMintGuidance(opts) {
|
|
30
|
+
return [
|
|
31
|
+
`Mint: ${ingestTokenSettingsUrl(opts?.owner, opts?.name)}`,
|
|
32
|
+
`Set ${INGEST_TOKEN_ENV_NAMES.join(" / ")}`
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// src/output/ui.ts
|
|
37
|
+
function badge(kind) {
|
|
38
|
+
switch (kind) {
|
|
39
|
+
case "pass":
|
|
40
|
+
return pc.green("[PASS]");
|
|
41
|
+
case "fail":
|
|
42
|
+
return pc.red("[FAIL]");
|
|
43
|
+
case "warn":
|
|
44
|
+
return pc.yellow("[WARN]");
|
|
45
|
+
case "info":
|
|
46
|
+
return pc.cyan("[INFO]");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function metricBar(pct3, width = 10) {
|
|
50
|
+
const clamped = Math.max(0, Math.min(100, pct3));
|
|
51
|
+
const filled = Math.round(clamped / 100 * width);
|
|
52
|
+
const bar = "#".repeat(filled) + ".".repeat(width - filled);
|
|
53
|
+
return `[${bar}]`;
|
|
54
|
+
}
|
|
55
|
+
function heading(text) {
|
|
56
|
+
return pc.bold(text);
|
|
57
|
+
}
|
|
58
|
+
function dim(text) {
|
|
59
|
+
return pc.dim(text);
|
|
60
|
+
}
|
|
61
|
+
function errorBlock(title, lines = []) {
|
|
62
|
+
const out = [`${pc.red("error")}: ${title}`];
|
|
63
|
+
if (lines.length > 0) {
|
|
64
|
+
out.push("");
|
|
65
|
+
for (const line of lines) {
|
|
66
|
+
out.push(line === "" ? "" : ` ${line}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out.join("\n") + "\n";
|
|
70
|
+
}
|
|
71
|
+
function successLine(text) {
|
|
72
|
+
return `${pc.green("\u2713")} ${text}`;
|
|
73
|
+
}
|
|
74
|
+
function nextSteps(steps) {
|
|
75
|
+
if (steps.length === 0) return "";
|
|
76
|
+
const lines = [heading("Next steps:")];
|
|
77
|
+
steps.forEach((step, i) => {
|
|
78
|
+
const body = /^\d+\./.test(step.trim()) ? step.trim() : `${i + 1}. ${step}`;
|
|
79
|
+
lines.push(` ${body}`);
|
|
80
|
+
});
|
|
81
|
+
return lines.join("\n");
|
|
82
|
+
}
|
|
83
|
+
function tip(text) {
|
|
84
|
+
return pc.dim(`\u2192 ${text}`);
|
|
85
|
+
}
|
|
86
|
+
function colorPct(pct3, digits = 1) {
|
|
87
|
+
const str = `${pct3.toFixed(digits)}%`;
|
|
88
|
+
if (pct3 >= 80) return pc.green(str);
|
|
89
|
+
if (pct3 >= 50) return pc.yellow(str);
|
|
90
|
+
return pc.red(str);
|
|
91
|
+
}
|
|
92
|
+
function shareUrl(url) {
|
|
93
|
+
return pc.bold(pc.cyan(url));
|
|
94
|
+
}
|
|
95
|
+
function progress(text) {
|
|
96
|
+
return pc.dim(text);
|
|
97
|
+
}
|
|
98
|
+
function formatCliError(message) {
|
|
99
|
+
const m = message.trim();
|
|
100
|
+
if (/coverage-final\.json not found/i.test(m)) {
|
|
101
|
+
const pathMatch = m.match(/at (.+?)\. Run/i);
|
|
102
|
+
const path = pathMatch?.[1]?.trim();
|
|
103
|
+
return errorBlock("coverage file missing", [
|
|
104
|
+
path ? `Expected: ${path}` : "Expected: coverage/coverage-final.json",
|
|
105
|
+
"",
|
|
106
|
+
"Run: tested run",
|
|
107
|
+
"Then: tested diff"
|
|
108
|
+
]);
|
|
109
|
+
}
|
|
110
|
+
if (/missing ingest token/i.test(m)) {
|
|
111
|
+
return errorBlock("missing ingest token", [
|
|
112
|
+
...tokenMintGuidance(),
|
|
113
|
+
"or pass --token <token> (avoid on shared hosts: visible in ps)"
|
|
114
|
+
]);
|
|
115
|
+
}
|
|
116
|
+
if (/invalid PR number/i.test(m)) {
|
|
117
|
+
return errorBlock("invalid PR number", [
|
|
118
|
+
m.replace(/^invalid PR number\s*/i, "").replace(/^—\s*/, "") || m,
|
|
119
|
+
"",
|
|
120
|
+
"Pass --pr <n> or set GITHUB_PR_NUMBER"
|
|
121
|
+
]);
|
|
122
|
+
}
|
|
123
|
+
if (m.startsWith("error:") || m.includes("\n")) {
|
|
124
|
+
return m.endsWith("\n") ? m : `${m}
|
|
125
|
+
`;
|
|
126
|
+
}
|
|
127
|
+
return `${pc.red("error")}: ${m}
|
|
128
|
+
`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// src/commands/init.ts
|
|
132
|
+
import pc2 from "picocolors";
|
|
133
|
+
var INIT_YAML_HEADER = "# Config schema \u2014 see https://tested.dev/docs/config\n";
|
|
134
|
+
var DEFAULT_INIT_IGNORES = [
|
|
135
|
+
"**/*.test.ts",
|
|
136
|
+
"**/*.spec.ts",
|
|
137
|
+
"**/node_modules/**",
|
|
138
|
+
"**/dist/**",
|
|
139
|
+
"**/coverage/**"
|
|
140
|
+
];
|
|
141
|
+
function detectTestRunner(cwd) {
|
|
142
|
+
const vitestCandidates = ["vitest.config.ts", "vitest.config.js", "vitest.config.mjs", "vitest.config.cjs"];
|
|
143
|
+
for (const f of vitestCandidates) {
|
|
144
|
+
if (existsSync(join(cwd, f))) return "vitest";
|
|
145
|
+
}
|
|
146
|
+
const jestCandidates = ["jest.config.ts", "jest.config.js", "jest.config.mjs", "jest.config.cjs", "jest.config.json"];
|
|
147
|
+
for (const f of jestCandidates) {
|
|
148
|
+
if (existsSync(join(cwd, f))) return "jest";
|
|
149
|
+
}
|
|
150
|
+
if (existsSync(join(cwd, "pyproject.toml"))) return "pytest";
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
async function detectDefaultBranch(cwd) {
|
|
154
|
+
try {
|
|
155
|
+
const git = simpleGit({ baseDir: cwd });
|
|
156
|
+
const isRepo = await git.checkIsRepo();
|
|
157
|
+
if (!isRepo) return "main";
|
|
158
|
+
try {
|
|
159
|
+
const ref = (await git.raw(["symbolic-ref", "refs/remotes/origin/HEAD"])).trim();
|
|
160
|
+
const branch = ref.replace(/^refs\/remotes\/origin\//, "");
|
|
161
|
+
if (branch) return branch;
|
|
162
|
+
} catch {
|
|
163
|
+
}
|
|
164
|
+
return "main";
|
|
165
|
+
} catch {
|
|
166
|
+
return "main";
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
async function detectProject(cwd) {
|
|
170
|
+
const hasPackageJson = existsSync(join(cwd, "package.json"));
|
|
171
|
+
const testRunner = detectTestRunner(cwd);
|
|
172
|
+
const defaultBranch = await detectDefaultBranch(cwd);
|
|
173
|
+
return { hasPackageJson, testRunner, defaultBranch };
|
|
174
|
+
}
|
|
175
|
+
function buildInitYaml(args) {
|
|
176
|
+
const lines = [];
|
|
177
|
+
lines.push(INIT_YAML_HEADER.trimEnd());
|
|
178
|
+
lines.push(`base: ${args.base}`);
|
|
179
|
+
if (args.testRunner) {
|
|
180
|
+
lines.push(`testRunner: ${args.testRunner}`);
|
|
181
|
+
}
|
|
182
|
+
lines.push("thresholds:");
|
|
183
|
+
lines.push(" patch: 80");
|
|
184
|
+
lines.push(" project: 60");
|
|
185
|
+
lines.push("ignores:");
|
|
186
|
+
for (const pattern of DEFAULT_INIT_IGNORES) {
|
|
187
|
+
lines.push(` - "${pattern}"`);
|
|
188
|
+
}
|
|
189
|
+
return lines.join("\n") + "\n";
|
|
190
|
+
}
|
|
191
|
+
function hasHuskyDevDep(pkgJsonPath) {
|
|
192
|
+
try {
|
|
193
|
+
const raw = readFileSync(pkgJsonPath, "utf8");
|
|
194
|
+
const pkg = JSON.parse(raw);
|
|
195
|
+
return Boolean(pkg.devDependencies?.husky || pkg.dependencies?.husky);
|
|
196
|
+
} catch {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
var PRE_PUSH_HOOK_BODY = `#!/usr/bin/env sh
|
|
201
|
+
# Installed by \`tested init\`. Skip with \`git push --no-verify\`.
|
|
202
|
+
tested diff
|
|
203
|
+
`;
|
|
204
|
+
async function runInit(opts) {
|
|
205
|
+
const { cwd, force, hooks } = opts;
|
|
206
|
+
const pkgJsonPath = join(cwd, "package.json");
|
|
207
|
+
if (!existsSync(pkgJsonPath)) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`No package.json found at ${cwd}. Run \`tested init\` from the root of a Node.js project.`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
const configPath = join(cwd, ".tested.yaml");
|
|
213
|
+
if (existsSync(configPath) && !force) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
`.tested.yaml already exists at ${configPath}. Pass --force to overwrite.`
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
const detected = await detectProject(cwd);
|
|
219
|
+
const yamlText = buildInitYaml({ base: detected.defaultBranch, testRunner: detected.testRunner });
|
|
220
|
+
writeFileSync(configPath, yamlText, "utf8");
|
|
221
|
+
const warnings = [];
|
|
222
|
+
let hookInstalled = false;
|
|
223
|
+
let hookPath = null;
|
|
224
|
+
if (hooks) {
|
|
225
|
+
if (!hasHuskyDevDep(pkgJsonPath)) {
|
|
226
|
+
warnings.push("husky is not a devDependency; skipped pre-push hook install. Run `pnpm add -D husky` then re-run `tested init --force`.");
|
|
227
|
+
} else {
|
|
228
|
+
const huskyDir = join(cwd, ".husky");
|
|
229
|
+
const huskyHook = join(huskyDir, "pre-push");
|
|
230
|
+
if (existsSync(huskyHook)) {
|
|
231
|
+
warnings.push(`.husky/pre-push already exists; left untouched. Add \`tested diff\` to it manually if desired.`);
|
|
232
|
+
} else {
|
|
233
|
+
if (!existsSync(huskyDir)) mkdirSync(huskyDir, { recursive: true });
|
|
234
|
+
writeFileSync(huskyHook, PRE_PUSH_HOOK_BODY, "utf8");
|
|
235
|
+
try {
|
|
236
|
+
chmodSync(huskyHook, 493);
|
|
237
|
+
} catch {
|
|
238
|
+
}
|
|
239
|
+
hookInstalled = true;
|
|
240
|
+
hookPath = huskyHook;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const nextStepsList = [
|
|
245
|
+
"1. tested run",
|
|
246
|
+
"2. tested diff",
|
|
247
|
+
"3. tested push --pr <n> (needs TESTED_TOKEN)",
|
|
248
|
+
"4. optional: wire CI (GitHub Actions / husky pre-push)"
|
|
249
|
+
];
|
|
250
|
+
return {
|
|
251
|
+
configPath,
|
|
252
|
+
configWritten: true,
|
|
253
|
+
hookInstalled,
|
|
254
|
+
hookPath,
|
|
255
|
+
detected,
|
|
256
|
+
nextSteps: nextStepsList,
|
|
257
|
+
warnings
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
function buildInitJsonOutput(result) {
|
|
261
|
+
return { schemaVersion: 1, ...result };
|
|
262
|
+
}
|
|
263
|
+
function formatInitResultHuman(result) {
|
|
264
|
+
const lines = [];
|
|
265
|
+
lines.push(heading("tested.dev \u2014 init"));
|
|
266
|
+
lines.push("");
|
|
267
|
+
if (result.configWritten) {
|
|
268
|
+
lines.push(successLine(`wrote ${pc2.cyan(result.configPath)}`));
|
|
269
|
+
}
|
|
270
|
+
const runnerLabel = result.detected.testRunner ?? "none detected";
|
|
271
|
+
lines.push(dim(` test runner: ${runnerLabel}`));
|
|
272
|
+
lines.push(dim(` base branch: ${result.detected.defaultBranch}`));
|
|
273
|
+
if (result.hookInstalled && result.hookPath) {
|
|
274
|
+
lines.push(successLine(`installed pre-push hook at ${pc2.cyan(result.hookPath)}`));
|
|
275
|
+
}
|
|
276
|
+
if (result.warnings.length > 0) {
|
|
277
|
+
lines.push("");
|
|
278
|
+
for (const w of result.warnings) {
|
|
279
|
+
lines.push(`${pc2.yellow("!")} ${w}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (result.nextSteps.length > 0) {
|
|
283
|
+
lines.push("");
|
|
284
|
+
lines.push(nextSteps(result.nextSteps));
|
|
285
|
+
}
|
|
286
|
+
return lines.join("\n");
|
|
287
|
+
}
|
|
288
|
+
function registerInitCommand(program2) {
|
|
289
|
+
program2.command("init").description("Initialize tested.dev in the current project (writes .tested.yaml)").option("--force", "Overwrite an existing .tested.yaml", false).option("--no-hooks", "Skip installing the husky pre-push hook").option("--json", "Emit JSON instead of human text", false).action(async (opts) => {
|
|
290
|
+
try {
|
|
291
|
+
if (opts.hooks && !process.stdin.isTTY && !opts.force) {
|
|
292
|
+
process.stderr.write(
|
|
293
|
+
errorBlock(
|
|
294
|
+
"--hooks in a non-TTY environment requires --force to confirm",
|
|
295
|
+
["Would install a git hook unattended."]
|
|
296
|
+
)
|
|
297
|
+
);
|
|
298
|
+
process.exit(1);
|
|
299
|
+
}
|
|
300
|
+
const result = await runInit({
|
|
301
|
+
cwd: process.cwd(),
|
|
302
|
+
force: opts.force,
|
|
303
|
+
hooks: opts.hooks
|
|
304
|
+
});
|
|
305
|
+
if (opts.json) {
|
|
306
|
+
process.stdout.write(JSON.stringify(buildInitJsonOutput(result), null, 2) + "\n");
|
|
307
|
+
} else {
|
|
308
|
+
process.stdout.write(formatInitResultHuman(result) + "\n");
|
|
309
|
+
}
|
|
310
|
+
} catch (err) {
|
|
311
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
312
|
+
if (opts.json) {
|
|
313
|
+
process.stderr.write(
|
|
314
|
+
JSON.stringify({ schemaVersion: 1, error: message }, null, 2) + "\n"
|
|
315
|
+
);
|
|
316
|
+
} else {
|
|
317
|
+
process.stderr.write(errorBlock(message));
|
|
318
|
+
}
|
|
319
|
+
process.exit(1);
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// src/commands/setup.ts
|
|
325
|
+
import { existsSync as existsSync4 } from "fs";
|
|
326
|
+
import { join as join5 } from "path";
|
|
327
|
+
import "commander";
|
|
328
|
+
|
|
329
|
+
// src/commands/doctor.ts
|
|
330
|
+
import { existsSync as existsSync3, accessSync, constants as fsConstants } from "fs";
|
|
331
|
+
import { basename, isAbsolute as isAbsolute2, join as join4, resolve as resolve4 } from "path";
|
|
332
|
+
import "commander";
|
|
333
|
+
import { simpleGit as simpleGit3 } from "simple-git";
|
|
334
|
+
|
|
335
|
+
// src/config.ts
|
|
336
|
+
import { readFile } from "fs/promises";
|
|
337
|
+
import { join as join2 } from "path";
|
|
338
|
+
import { parse as parseYaml } from "yaml";
|
|
339
|
+
|
|
340
|
+
// src/schemas.ts
|
|
341
|
+
import { z as z2 } from "zod";
|
|
342
|
+
|
|
343
|
+
// src/core/junit.ts
|
|
344
|
+
import { z } from "zod";
|
|
345
|
+
function testCaseKey(classname, name) {
|
|
346
|
+
return `${classname ?? ""}\0${name}`;
|
|
347
|
+
}
|
|
348
|
+
var TestCaseRefSchema = z.object({
|
|
349
|
+
name: z.string().min(1),
|
|
350
|
+
classname: z.string().optional(),
|
|
351
|
+
file: z.string().optional(),
|
|
352
|
+
durationMs: z.number().nonnegative()
|
|
353
|
+
});
|
|
354
|
+
var TestReportSchema = z.object({
|
|
355
|
+
schemaVersion: z.literal(1),
|
|
356
|
+
source: z.literal("junit"),
|
|
357
|
+
totals: z.object({
|
|
358
|
+
tests: z.number().int().nonnegative(),
|
|
359
|
+
passed: z.number().int().nonnegative(),
|
|
360
|
+
failed: z.number().int().nonnegative(),
|
|
361
|
+
skipped: z.number().int().nonnegative(),
|
|
362
|
+
errors: z.number().int().nonnegative(),
|
|
363
|
+
flaky: z.number().int().nonnegative(),
|
|
364
|
+
durationMs: z.number().nonnegative()
|
|
365
|
+
}),
|
|
366
|
+
/** Final failures (did not pass on last attempt). */
|
|
367
|
+
failures: z.array(
|
|
368
|
+
TestCaseRefSchema.extend({
|
|
369
|
+
message: z.string().optional()
|
|
370
|
+
})
|
|
371
|
+
).max(50),
|
|
372
|
+
/** Failed at least once and passed at least once in the same report. */
|
|
373
|
+
flakes: z.array(
|
|
374
|
+
TestCaseRefSchema.extend({
|
|
375
|
+
attempts: z.number().int().positive()
|
|
376
|
+
})
|
|
377
|
+
).max(50),
|
|
378
|
+
slowest: z.array(TestCaseRefSchema).max(15)
|
|
379
|
+
});
|
|
380
|
+
function decodeXmlEntities(s) {
|
|
381
|
+
return s.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&");
|
|
382
|
+
}
|
|
383
|
+
function attr(tag, name) {
|
|
384
|
+
const re = new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)')`, "i");
|
|
385
|
+
const m = tag.match(re);
|
|
386
|
+
if (!m) return void 0;
|
|
387
|
+
return decodeXmlEntities(m[2] ?? m[3] ?? "");
|
|
388
|
+
}
|
|
389
|
+
function parseJunitXml(xml) {
|
|
390
|
+
const cases = [];
|
|
391
|
+
const re = /<testcase\b([^>]*?)\s*\/>|<testcase\b([^>]*)>([\s\S]*?)<\/testcase>/gi;
|
|
392
|
+
let m;
|
|
393
|
+
while ((m = re.exec(xml)) !== null) {
|
|
394
|
+
const openAttrs = (m[1] ?? m[2] ?? "").trim();
|
|
395
|
+
const body = m[3] ?? "";
|
|
396
|
+
const name = attr(openAttrs, "name")?.trim();
|
|
397
|
+
if (!name) continue;
|
|
398
|
+
const classname = attr(openAttrs, "classname")?.trim() || void 0;
|
|
399
|
+
const file = attr(openAttrs, "file")?.trim() || void 0;
|
|
400
|
+
const timeRaw = attr(openAttrs, "time");
|
|
401
|
+
const timeSec = timeRaw != null && timeRaw !== "" ? Number(timeRaw) : 0;
|
|
402
|
+
const flakyAttr = (attr(openAttrs, "flaky") ?? "").toLowerCase() === "true" || (attr(openAttrs, "flaky") ?? "") === "1";
|
|
403
|
+
let status = "passed";
|
|
404
|
+
let message;
|
|
405
|
+
if (/<skipped\b/i.test(body)) {
|
|
406
|
+
status = "skipped";
|
|
407
|
+
message = attr(body.match(/<skipped\b[^>]*>/i)?.[0] ?? "", "message");
|
|
408
|
+
} else if (/<failure\b/i.test(body)) {
|
|
409
|
+
status = "failed";
|
|
410
|
+
const fm = body.match(/<failure\b([^>]*)\/?>/i);
|
|
411
|
+
message = fm ? attr(fm[1] ?? "", "message") : void 0;
|
|
412
|
+
if (!message) {
|
|
413
|
+
const inner = body.match(/<failure\b[^>]*>([\s\S]*?)<\/failure>/i);
|
|
414
|
+
if (inner?.[1]?.trim()) message = decodeXmlEntities(inner[1].trim()).slice(0, 500);
|
|
415
|
+
}
|
|
416
|
+
} else if (/<error\b/i.test(body)) {
|
|
417
|
+
status = "error";
|
|
418
|
+
const em = body.match(/<error\b([^>]*)\/?>/i);
|
|
419
|
+
message = em ? attr(em[1] ?? "", "message") : void 0;
|
|
420
|
+
}
|
|
421
|
+
cases.push({
|
|
422
|
+
name,
|
|
423
|
+
...classname ? { classname } : {},
|
|
424
|
+
...file ? { file } : {},
|
|
425
|
+
timeSec: Number.isFinite(timeSec) && timeSec >= 0 ? timeSec : 0,
|
|
426
|
+
status,
|
|
427
|
+
...message ? { message: message.slice(0, 500) } : {},
|
|
428
|
+
flakyAttr
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
return cases;
|
|
432
|
+
}
|
|
433
|
+
function buildTestReportFromCases(raw) {
|
|
434
|
+
const groups = /* @__PURE__ */ new Map();
|
|
435
|
+
for (const c of raw) {
|
|
436
|
+
const k = testCaseKey(c.classname, c.name);
|
|
437
|
+
const list = groups.get(k) ?? [];
|
|
438
|
+
list.push(c);
|
|
439
|
+
groups.set(k, list);
|
|
440
|
+
}
|
|
441
|
+
let passed = 0;
|
|
442
|
+
let failed = 0;
|
|
443
|
+
let skipped = 0;
|
|
444
|
+
let errors = 0;
|
|
445
|
+
let flaky = 0;
|
|
446
|
+
let durationMs = 0;
|
|
447
|
+
const failures = [];
|
|
448
|
+
const flakes = [];
|
|
449
|
+
const durationByKey = [];
|
|
450
|
+
for (const [, attempts] of groups) {
|
|
451
|
+
const totalTime = attempts.reduce((s, a) => s + a.timeSec, 0);
|
|
452
|
+
const durationCaseMs = Math.round(totalTime * 1e3);
|
|
453
|
+
durationMs += durationCaseMs;
|
|
454
|
+
const last = attempts[attempts.length - 1];
|
|
455
|
+
const hadFail = attempts.some((a) => a.status === "failed" || a.status === "error");
|
|
456
|
+
const hadPass = attempts.some((a) => a.status === "passed");
|
|
457
|
+
const isFlaky = Boolean(attempts.some((a) => a.flakyAttr) || hadFail && hadPass);
|
|
458
|
+
const ref = {
|
|
459
|
+
name: last.name,
|
|
460
|
+
...last.classname ? { classname: last.classname } : {},
|
|
461
|
+
...last.file ? { file: last.file } : {},
|
|
462
|
+
durationMs: durationCaseMs
|
|
463
|
+
};
|
|
464
|
+
durationByKey.push(ref);
|
|
465
|
+
if (isFlaky) {
|
|
466
|
+
flaky += 1;
|
|
467
|
+
if (flakes.length < 50) {
|
|
468
|
+
flakes.push({ ...ref, attempts: attempts.length });
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
const final = [...attempts].reverse().find((a) => a.status !== "skipped") ?? last;
|
|
472
|
+
if (final.status === "skipped") {
|
|
473
|
+
skipped += 1;
|
|
474
|
+
} else if (final.status === "passed") {
|
|
475
|
+
passed += 1;
|
|
476
|
+
} else if (final.status === "error") {
|
|
477
|
+
errors += 1;
|
|
478
|
+
if (!isFlaky && failures.length < 50) {
|
|
479
|
+
failures.push({
|
|
480
|
+
...ref,
|
|
481
|
+
...final.message ? { message: final.message } : {}
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
} else if (final.status === "failed") {
|
|
485
|
+
failed += 1;
|
|
486
|
+
if (!isFlaky && failures.length < 50) {
|
|
487
|
+
failures.push({
|
|
488
|
+
...ref,
|
|
489
|
+
...final.message ? { message: final.message } : {}
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
const slowest = [...durationByKey].sort((a, b) => b.durationMs - a.durationMs).slice(0, 10);
|
|
495
|
+
return {
|
|
496
|
+
schemaVersion: 1,
|
|
497
|
+
source: "junit",
|
|
498
|
+
totals: {
|
|
499
|
+
tests: groups.size,
|
|
500
|
+
passed,
|
|
501
|
+
failed,
|
|
502
|
+
skipped,
|
|
503
|
+
errors,
|
|
504
|
+
flaky,
|
|
505
|
+
durationMs
|
|
506
|
+
},
|
|
507
|
+
failures,
|
|
508
|
+
flakes,
|
|
509
|
+
slowest
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
function parseJunitToTestReport(xml) {
|
|
513
|
+
return buildTestReportFromCases(parseJunitXml(xml));
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// src/schemas.ts
|
|
517
|
+
var TestedConfigSchema = z2.object({
|
|
518
|
+
ignores: z2.array(z2.string()).default([]),
|
|
519
|
+
coverage: z2.object({
|
|
520
|
+
format: z2.literal("istanbul-json").default("istanbul-json"),
|
|
521
|
+
path: z2.string().default("coverage/coverage-final.json")
|
|
522
|
+
}).prefault({}),
|
|
523
|
+
base: z2.string().default("origin/main"),
|
|
524
|
+
testRunner: z2.enum(["vitest", "jest", "pytest"]).nullable().default(null),
|
|
525
|
+
// Patch / project coverage gates. `tested init` writes these so users can
|
|
526
|
+
// tune what counts as "passing" — schema MUST accept them so loadConfig
|
|
527
|
+
// doesn't silently drop the field. Enforcement in `diff` lands in a
|
|
528
|
+
// follow-up; today we just round-trip the values cleanly.
|
|
529
|
+
thresholds: z2.object({
|
|
530
|
+
patch: z2.number().min(0).max(100),
|
|
531
|
+
project: z2.number().min(0).max(100)
|
|
532
|
+
}).optional()
|
|
533
|
+
});
|
|
534
|
+
var UncoveredRangeSchema = z2.object({
|
|
535
|
+
start: z2.number().int().positive(),
|
|
536
|
+
end: z2.number().int().positive(),
|
|
537
|
+
kind: z2.enum(["line", "branch", "function"])
|
|
538
|
+
});
|
|
539
|
+
var FileCoverageSchema = z2.object({
|
|
540
|
+
path: z2.string(),
|
|
541
|
+
patchCoverage: z2.number().nullable(),
|
|
542
|
+
projectCoverage: z2.number(),
|
|
543
|
+
uncoveredRanges: z2.array(UncoveredRangeSchema)
|
|
544
|
+
});
|
|
545
|
+
var CoverageTotalsSchema = z2.object({
|
|
546
|
+
executable: z2.number().int().nonnegative(),
|
|
547
|
+
covered: z2.number().int().nonnegative(),
|
|
548
|
+
pct: z2.number().min(0).max(100)
|
|
549
|
+
});
|
|
550
|
+
var ProjectTotalsSchema = CoverageTotalsSchema.extend({
|
|
551
|
+
delta: z2.number().nullable()
|
|
552
|
+
});
|
|
553
|
+
var DiffOutputSchema = z2.object({
|
|
554
|
+
schemaVersion: z2.literal(1),
|
|
555
|
+
base: z2.string(),
|
|
556
|
+
head: z2.string(),
|
|
557
|
+
patch: CoverageTotalsSchema,
|
|
558
|
+
project: ProjectTotalsSchema,
|
|
559
|
+
files: z2.array(FileCoverageSchema),
|
|
560
|
+
ignored: z2.array(z2.string())
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
// src/config.ts
|
|
564
|
+
var DEFAULT_IGNORES = [
|
|
565
|
+
"migrations/**",
|
|
566
|
+
"seeds/**",
|
|
567
|
+
"tests/**",
|
|
568
|
+
"test/**",
|
|
569
|
+
"**/*.test.*",
|
|
570
|
+
"**/*.spec.*",
|
|
571
|
+
"mocks/**",
|
|
572
|
+
"__mocks__/**",
|
|
573
|
+
"vitest.setup.*",
|
|
574
|
+
"cypress/**",
|
|
575
|
+
"scripts/**",
|
|
576
|
+
"storybook/**",
|
|
577
|
+
".storybook/**",
|
|
578
|
+
"**/*.d.ts",
|
|
579
|
+
"stubs/**"
|
|
580
|
+
];
|
|
581
|
+
async function loadConfig(opts) {
|
|
582
|
+
const file = join2(opts.cwd, ".tested.yaml");
|
|
583
|
+
let raw = {};
|
|
584
|
+
try {
|
|
585
|
+
const text = await readFile(file, "utf8");
|
|
586
|
+
raw = parseYaml(text) ?? {};
|
|
587
|
+
} catch (err) {
|
|
588
|
+
if (err.code !== "ENOENT") throw err;
|
|
589
|
+
}
|
|
590
|
+
const parsed = TestedConfigSchema.parse(raw);
|
|
591
|
+
const merged = /* @__PURE__ */ new Set([...DEFAULT_IGNORES, ...parsed.ignores]);
|
|
592
|
+
return { ...parsed, ignores: [...merged] };
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// src/commands/push.ts
|
|
596
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, statSync } from "fs";
|
|
597
|
+
import { join as join3 } from "path";
|
|
598
|
+
import "commander";
|
|
599
|
+
|
|
600
|
+
// src/core/computeDiff.ts
|
|
601
|
+
import { resolve as resolve3 } from "path";
|
|
602
|
+
|
|
603
|
+
// src/git.ts
|
|
604
|
+
import { simpleGit as simpleGit2 } from "simple-git";
|
|
605
|
+
async function openRepo(cwd) {
|
|
606
|
+
const git = simpleGit2({ baseDir: cwd });
|
|
607
|
+
const repoRoot = (await git.revparse(["--show-toplevel"])).trim();
|
|
608
|
+
return { git, repoRoot };
|
|
609
|
+
}
|
|
610
|
+
async function resolveBase(ctx, base) {
|
|
611
|
+
return (await ctx.git.revparse([base])).trim();
|
|
612
|
+
}
|
|
613
|
+
async function headSha(ctx) {
|
|
614
|
+
return (await ctx.git.revparse(["HEAD"])).trim();
|
|
615
|
+
}
|
|
616
|
+
async function unifiedDiff(ctx, base) {
|
|
617
|
+
return ctx.git.diff([`${base}...HEAD`]);
|
|
618
|
+
}
|
|
619
|
+
async function remoteUrl(ctx, remote = "origin") {
|
|
620
|
+
return (await ctx.git.raw(["remote", "get-url", remote])).trim();
|
|
621
|
+
}
|
|
622
|
+
async function currentBranch(ctx) {
|
|
623
|
+
try {
|
|
624
|
+
const name = (await ctx.git.revparse(["--abbrev-ref", "HEAD"])).trim();
|
|
625
|
+
return name === "HEAD" ? "" : name;
|
|
626
|
+
} catch {
|
|
627
|
+
return "";
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
async function gitUserName(ctx) {
|
|
631
|
+
try {
|
|
632
|
+
const name = (await ctx.git.raw(["config", "user.name"])).trim();
|
|
633
|
+
return name || null;
|
|
634
|
+
} catch {
|
|
635
|
+
return null;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// src/git-ref.ts
|
|
640
|
+
var SAFE_GIT_REF_RE = /^[A-Za-z0-9_./@~^-]{1,256}$/;
|
|
641
|
+
function assertSafeGitRef(ref) {
|
|
642
|
+
if (!ref) {
|
|
643
|
+
throw new Error("git ref must not be empty");
|
|
644
|
+
}
|
|
645
|
+
if (ref.startsWith("-")) {
|
|
646
|
+
throw new Error(`git ref must not start with '-': ${ref}`);
|
|
647
|
+
}
|
|
648
|
+
if (!SAFE_GIT_REF_RE.test(ref)) {
|
|
649
|
+
throw new Error(
|
|
650
|
+
`git ref contains invalid characters or is too long (max 256): ${ref}`
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
return ref;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// src/core/istanbul.ts
|
|
657
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
658
|
+
import { isAbsolute, relative, resolve } from "path";
|
|
659
|
+
function isCoveragePathInsideRoot(repoRoot, entryPath) {
|
|
660
|
+
const root = resolve(repoRoot);
|
|
661
|
+
const absPath = resolve(entryPath);
|
|
662
|
+
const relPath = relative(root, absPath).split("\\").join("/");
|
|
663
|
+
if (!relPath || relPath === "") return true;
|
|
664
|
+
if (isAbsolute(relPath)) return false;
|
|
665
|
+
if (relPath === ".." || relPath.startsWith("../")) return false;
|
|
666
|
+
return true;
|
|
667
|
+
}
|
|
668
|
+
async function parseIstanbul(opts) {
|
|
669
|
+
let raw;
|
|
670
|
+
try {
|
|
671
|
+
raw = await readFile2(opts.path, "utf8");
|
|
672
|
+
} catch (err) {
|
|
673
|
+
if (err.code === "ENOENT") {
|
|
674
|
+
throw new Error(
|
|
675
|
+
`coverage-final.json not found at ${opts.path}. Run \`tested run\` first.`
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
throw err;
|
|
679
|
+
}
|
|
680
|
+
const data = JSON.parse(raw);
|
|
681
|
+
const root = resolve(opts.repoRoot);
|
|
682
|
+
const out = [];
|
|
683
|
+
for (const entry of Object.values(data)) {
|
|
684
|
+
if (!isCoveragePathInsideRoot(root, entry.path)) {
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
const absPath = resolve(entry.path);
|
|
688
|
+
const relPath = relative(root, absPath).split("\\").join("/");
|
|
689
|
+
const statements = Object.entries(entry.statementMap).map(([id, loc]) => ({
|
|
690
|
+
id,
|
|
691
|
+
startLine: loc.start.line,
|
|
692
|
+
endLine: loc.end.line,
|
|
693
|
+
hits: entry.s[id] ?? 0
|
|
694
|
+
}));
|
|
695
|
+
out.push({ path: relPath, absPath, statements });
|
|
696
|
+
}
|
|
697
|
+
return out;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// src/core/diff.ts
|
|
701
|
+
var FILE_HEADER = /^diff --git a\/(.+?) b\/(.+?)$/;
|
|
702
|
+
var HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
|
|
703
|
+
function parseUnifiedDiff(text) {
|
|
704
|
+
const result = /* @__PURE__ */ new Map();
|
|
705
|
+
const lines = text.split("\n");
|
|
706
|
+
let currentFile = null;
|
|
707
|
+
let currentLine = 0;
|
|
708
|
+
let inHunk = false;
|
|
709
|
+
for (const line of lines) {
|
|
710
|
+
const fileMatch = line.match(FILE_HEADER);
|
|
711
|
+
if (fileMatch) {
|
|
712
|
+
currentFile = fileMatch[2] ?? null;
|
|
713
|
+
inHunk = false;
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
if (!currentFile) continue;
|
|
717
|
+
const hunkMatch = line.match(HUNK_HEADER);
|
|
718
|
+
if (hunkMatch) {
|
|
719
|
+
currentLine = Number(hunkMatch[1]);
|
|
720
|
+
inHunk = true;
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
if (!inHunk) continue;
|
|
724
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
725
|
+
if (!result.has(currentFile)) result.set(currentFile, /* @__PURE__ */ new Set());
|
|
726
|
+
result.get(currentFile).add(currentLine);
|
|
727
|
+
currentLine += 1;
|
|
728
|
+
} else if (line.startsWith("-") && !line.startsWith("---")) {
|
|
729
|
+
} else if (line.startsWith(" ") || line === "") {
|
|
730
|
+
currentLine += 1;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
return result;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// src/core/ignores.ts
|
|
737
|
+
import { minimatch } from "minimatch";
|
|
738
|
+
function isIgnored(path, patterns) {
|
|
739
|
+
return patterns.some((p) => {
|
|
740
|
+
return minimatch(path, p, { dot: true, matchBase: true }) || minimatch(path, `**/${p}`, { dot: true, matchBase: true });
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
function splitByIgnore(paths, patterns) {
|
|
744
|
+
const kept = [];
|
|
745
|
+
const ignored = [];
|
|
746
|
+
for (const p of paths) {
|
|
747
|
+
if (isIgnored(p, patterns)) ignored.push(p);
|
|
748
|
+
else kept.push(p);
|
|
749
|
+
}
|
|
750
|
+
return { kept, ignored };
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// src/core/assert-within-root.ts
|
|
754
|
+
import { resolve as resolve2, sep } from "path";
|
|
755
|
+
function assertWithinRoot(root, resolvedPath) {
|
|
756
|
+
const safeRoot = resolve2(root) + sep;
|
|
757
|
+
const safePath = resolve2(resolvedPath);
|
|
758
|
+
if (!safePath.startsWith(safeRoot)) {
|
|
759
|
+
throw new Error(
|
|
760
|
+
`Path traversal rejected: ${safePath} is outside repository root ${safeRoot}`
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// src/core/patch.ts
|
|
766
|
+
function pct(covered, executable) {
|
|
767
|
+
if (executable === 0) return 0;
|
|
768
|
+
return Math.round(covered / executable * 1e3) / 10;
|
|
769
|
+
}
|
|
770
|
+
function computePatchCoverage(files, addedByFile) {
|
|
771
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
772
|
+
let execTotal = 0;
|
|
773
|
+
let covTotal = 0;
|
|
774
|
+
for (const file of files) {
|
|
775
|
+
const added = addedByFile.get(file.path);
|
|
776
|
+
if (!added || added.size === 0) continue;
|
|
777
|
+
let exec = 0;
|
|
778
|
+
let cov = 0;
|
|
779
|
+
for (const stmt of file.statements) {
|
|
780
|
+
const touched = lineRangeOverlaps(stmt.startLine, stmt.endLine, added);
|
|
781
|
+
if (!touched) continue;
|
|
782
|
+
exec += 1;
|
|
783
|
+
if (stmt.hits > 0) cov += 1;
|
|
784
|
+
}
|
|
785
|
+
if (exec === 0) continue;
|
|
786
|
+
byFile.set(file.path, { executable: exec, covered: cov, pct: pct(cov, exec) });
|
|
787
|
+
execTotal += exec;
|
|
788
|
+
covTotal += cov;
|
|
789
|
+
}
|
|
790
|
+
return {
|
|
791
|
+
totals: { executable: execTotal, covered: covTotal, pct: pct(covTotal, execTotal) },
|
|
792
|
+
byFile
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
function lineRangeOverlaps(start, end, added) {
|
|
796
|
+
for (let line = start; line <= end; line += 1) {
|
|
797
|
+
if (added.has(line)) return true;
|
|
798
|
+
}
|
|
799
|
+
return false;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// src/core/project.ts
|
|
803
|
+
function pct2(covered, executable) {
|
|
804
|
+
if (executable === 0) return 0;
|
|
805
|
+
return Math.round(covered / executable * 1e3) / 10;
|
|
806
|
+
}
|
|
807
|
+
function computeProjectCoverage(files) {
|
|
808
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
809
|
+
let execTotal = 0;
|
|
810
|
+
let covTotal = 0;
|
|
811
|
+
for (const file of files) {
|
|
812
|
+
let exec = 0;
|
|
813
|
+
let cov = 0;
|
|
814
|
+
for (const stmt of file.statements) {
|
|
815
|
+
exec += 1;
|
|
816
|
+
if (stmt.hits > 0) cov += 1;
|
|
817
|
+
}
|
|
818
|
+
byFile.set(file.path, { executable: exec, covered: cov, pct: pct2(cov, exec) });
|
|
819
|
+
execTotal += exec;
|
|
820
|
+
covTotal += cov;
|
|
821
|
+
}
|
|
822
|
+
return {
|
|
823
|
+
totals: { executable: execTotal, covered: covTotal, pct: pct2(covTotal, execTotal) },
|
|
824
|
+
byFile
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// src/core/uncovered.ts
|
|
829
|
+
function uncoveredRanges(file) {
|
|
830
|
+
const lines = /* @__PURE__ */ new Set();
|
|
831
|
+
for (const stmt of file.statements) {
|
|
832
|
+
if (stmt.hits > 0) continue;
|
|
833
|
+
for (let line = stmt.startLine; line <= stmt.endLine; line += 1) {
|
|
834
|
+
lines.add(line);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
const sorted = [...lines].sort((a, b) => a - b);
|
|
838
|
+
const ranges = [];
|
|
839
|
+
let start = null;
|
|
840
|
+
let prev = null;
|
|
841
|
+
for (const line of sorted) {
|
|
842
|
+
if (start === null) {
|
|
843
|
+
start = line;
|
|
844
|
+
prev = line;
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
if (prev !== null && line === prev + 1) {
|
|
848
|
+
prev = line;
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
851
|
+
ranges.push({ start, end: prev, kind: "line" });
|
|
852
|
+
start = line;
|
|
853
|
+
prev = line;
|
|
854
|
+
}
|
|
855
|
+
if (start !== null && prev !== null) {
|
|
856
|
+
ranges.push({ start, end: prev, kind: "line" });
|
|
857
|
+
}
|
|
858
|
+
return ranges;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// src/output/json.ts
|
|
862
|
+
function buildDiffOutput(args) {
|
|
863
|
+
const patch = computePatchCoverage(args.files, args.addedByFile);
|
|
864
|
+
const project = computeProjectCoverage(args.files);
|
|
865
|
+
const fileNames = /* @__PURE__ */ new Set([
|
|
866
|
+
...patch.byFile.keys(),
|
|
867
|
+
...project.byFile.keys()
|
|
868
|
+
]);
|
|
869
|
+
const files = [];
|
|
870
|
+
for (const name of fileNames) {
|
|
871
|
+
const file = args.files.find((f) => f.path === name);
|
|
872
|
+
if (!file) continue;
|
|
873
|
+
files.push({
|
|
874
|
+
path: name,
|
|
875
|
+
patchCoverage: patch.byFile.get(name)?.pct ?? null,
|
|
876
|
+
projectCoverage: project.byFile.get(name)?.pct ?? 0,
|
|
877
|
+
uncoveredRanges: uncoveredRanges(file)
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
files.sort((a, b) => a.path.localeCompare(b.path));
|
|
881
|
+
return {
|
|
882
|
+
schemaVersion: 1,
|
|
883
|
+
base: args.base,
|
|
884
|
+
head: args.head,
|
|
885
|
+
patch: patch.totals,
|
|
886
|
+
project: { ...project.totals, delta: args.projectDelta ?? null },
|
|
887
|
+
files,
|
|
888
|
+
ignored: [...args.ignored]
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// src/core/computeDiff.ts
|
|
893
|
+
async function computeDiff(opts) {
|
|
894
|
+
const { cwd, config } = opts;
|
|
895
|
+
const ctx = opts.ctx ?? await openRepo(cwd);
|
|
896
|
+
const baseRef = assertSafeGitRef(opts.baseRef ?? config.base);
|
|
897
|
+
const base = await resolveBase(ctx, baseRef);
|
|
898
|
+
const head = await headSha(ctx);
|
|
899
|
+
const diffText = await unifiedDiff(ctx, base);
|
|
900
|
+
const addedByFile = parseUnifiedDiff(diffText);
|
|
901
|
+
const coveragePath = resolve3(cwd, config.coverage.path);
|
|
902
|
+
assertWithinRoot(ctx.repoRoot, coveragePath);
|
|
903
|
+
const allFiles = await parseIstanbul({ path: coveragePath, repoRoot: ctx.repoRoot });
|
|
904
|
+
const { kept, ignored } = splitByIgnore(
|
|
905
|
+
allFiles.map((f) => f.path),
|
|
906
|
+
config.ignores
|
|
907
|
+
);
|
|
908
|
+
const keptSet = new Set(kept);
|
|
909
|
+
const files = allFiles.filter((f) => keptSet.has(f.path));
|
|
910
|
+
let projectDelta = null;
|
|
911
|
+
if (opts.withBaseCoverage) {
|
|
912
|
+
const baseCoveragePath = resolve3(cwd, opts.withBaseCoverage);
|
|
913
|
+
assertWithinRoot(ctx.repoRoot, baseCoveragePath);
|
|
914
|
+
const baseFiles = await parseIstanbul({
|
|
915
|
+
path: baseCoveragePath,
|
|
916
|
+
repoRoot: ctx.repoRoot
|
|
917
|
+
});
|
|
918
|
+
const baseKept = baseFiles.filter((f) => !ignored.includes(f.path));
|
|
919
|
+
const baseExec = baseKept.reduce((n, f) => n + f.statements.length, 0);
|
|
920
|
+
const baseCov = baseKept.reduce(
|
|
921
|
+
(n, f) => n + f.statements.filter((s) => s.hits > 0).length,
|
|
922
|
+
0
|
|
923
|
+
);
|
|
924
|
+
const basePct = baseExec === 0 ? 0 : Math.round(baseCov / baseExec * 1e3) / 10;
|
|
925
|
+
const headPct = (() => {
|
|
926
|
+
const exec = files.reduce((n, f) => n + f.statements.length, 0);
|
|
927
|
+
const cov = files.reduce(
|
|
928
|
+
(n, f) => n + f.statements.filter((s) => s.hits > 0).length,
|
|
929
|
+
0
|
|
930
|
+
);
|
|
931
|
+
return exec === 0 ? 0 : Math.round(cov / exec * 1e3) / 10;
|
|
932
|
+
})();
|
|
933
|
+
projectDelta = Math.round((headPct - basePct) * 10) / 10;
|
|
934
|
+
}
|
|
935
|
+
return buildDiffOutput({
|
|
936
|
+
base: baseRef,
|
|
937
|
+
head,
|
|
938
|
+
files,
|
|
939
|
+
addedByFile,
|
|
940
|
+
ignored,
|
|
941
|
+
projectDelta
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// src/commands/push.ts
|
|
946
|
+
var DEFAULT_API_BASE = "https://app.tested.dev";
|
|
947
|
+
var LOCAL_HTTP_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1"]);
|
|
948
|
+
var tokenArgvWarned = false;
|
|
949
|
+
function readTokenFile(filePath, opts) {
|
|
950
|
+
const read = opts?.readFileSyncFn ?? readFileSync2;
|
|
951
|
+
const stat = opts?.statSyncFn ?? statSync;
|
|
952
|
+
let mode;
|
|
953
|
+
try {
|
|
954
|
+
const st = stat(filePath);
|
|
955
|
+
mode = st.mode;
|
|
956
|
+
} catch (err) {
|
|
957
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
958
|
+
throw new Error(`could not stat TESTED_TOKEN_FILE "${filePath}": ${message}`);
|
|
959
|
+
}
|
|
960
|
+
if (typeof mode === "number" && (mode & 4) !== 0) {
|
|
961
|
+
throw new Error(
|
|
962
|
+
`TESTED_TOKEN_FILE "${filePath}" is world-readable; chmod 600 the file or move the token to TESTED_TOKEN`
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
let raw;
|
|
966
|
+
try {
|
|
967
|
+
raw = read(filePath, "utf8");
|
|
968
|
+
} catch (err) {
|
|
969
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
970
|
+
throw new Error(`could not read TESTED_TOKEN_FILE "${filePath}": ${message}`);
|
|
971
|
+
}
|
|
972
|
+
const token = raw.trim();
|
|
973
|
+
if (!token) {
|
|
974
|
+
throw new Error(`TESTED_TOKEN_FILE "${filePath}" is empty`);
|
|
975
|
+
}
|
|
976
|
+
return token;
|
|
977
|
+
}
|
|
978
|
+
function resolveToken(opts) {
|
|
979
|
+
const env = opts.env ?? process.env;
|
|
980
|
+
const isTTY = opts.isTTY ?? Boolean(process.stderr.isTTY);
|
|
981
|
+
const warn = opts.warn ?? ((msg) => {
|
|
982
|
+
process.stderr.write(msg);
|
|
983
|
+
});
|
|
984
|
+
if (opts.flag !== void 0 && opts.flag !== "") {
|
|
985
|
+
if (isTTY && !tokenArgvWarned) {
|
|
986
|
+
tokenArgvWarned = true;
|
|
987
|
+
warn(
|
|
988
|
+
"warning: --token exposes the secret on process argv (visible to `ps` and audit agents). Prefer TESTED_TOKEN, TESTED_INGEST_TOKEN, or TESTED_TOKEN_FILE.\n"
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
return opts.flag;
|
|
992
|
+
}
|
|
993
|
+
const fromEnv = env.TESTED_TOKEN ?? env.TESTED_INGEST_TOKEN;
|
|
994
|
+
if (fromEnv !== void 0 && fromEnv !== "") return fromEnv;
|
|
995
|
+
const tokenFile = env.TESTED_TOKEN_FILE;
|
|
996
|
+
if (tokenFile) {
|
|
997
|
+
return readTokenFile(tokenFile, {
|
|
998
|
+
...opts.readFileSyncFn ? { readFileSyncFn: opts.readFileSyncFn } : {},
|
|
999
|
+
...opts.statSyncFn ? { statSyncFn: opts.statSyncFn } : {}
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
return null;
|
|
1003
|
+
}
|
|
1004
|
+
function assertSafeApiBase(raw) {
|
|
1005
|
+
const trimmed = raw.trim();
|
|
1006
|
+
if (!trimmed) {
|
|
1007
|
+
throw new Error("API URL must not be empty");
|
|
1008
|
+
}
|
|
1009
|
+
let u;
|
|
1010
|
+
try {
|
|
1011
|
+
u = new URL(trimmed);
|
|
1012
|
+
} catch {
|
|
1013
|
+
throw new Error(
|
|
1014
|
+
`invalid API URL "${trimmed}" \u2014 expected an absolute URL (e.g. https://app.tested.dev)`
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
if (u.username || u.password) {
|
|
1018
|
+
throw new Error("API URL must not embed credentials");
|
|
1019
|
+
}
|
|
1020
|
+
const host = u.hostname.toLowerCase();
|
|
1021
|
+
const isLocalHttpHost = LOCAL_HTTP_HOSTS.has(host) || host.endsWith(".localhost");
|
|
1022
|
+
if (u.protocol === "https:") {
|
|
1023
|
+
} else if (u.protocol === "http:" && isLocalHttpHost) {
|
|
1024
|
+
} else {
|
|
1025
|
+
throw new Error(
|
|
1026
|
+
`API URL must use https:// (http:// allowed only for localhost). Got ${u.protocol}//${u.host}`
|
|
1027
|
+
);
|
|
1028
|
+
}
|
|
1029
|
+
const path = u.pathname.replace(/\/+$/, "");
|
|
1030
|
+
const pathPart = !path || path === "/" ? "" : path;
|
|
1031
|
+
return `${u.origin}${pathPart}`;
|
|
1032
|
+
}
|
|
1033
|
+
function resolveApiBase(opts) {
|
|
1034
|
+
const env = opts.env ?? process.env;
|
|
1035
|
+
const raw = opts.flag ?? env.TESTED_API_URL ?? DEFAULT_API_BASE;
|
|
1036
|
+
return assertSafeApiBase(raw);
|
|
1037
|
+
}
|
|
1038
|
+
function redactGitRemote(url) {
|
|
1039
|
+
const scrubbed = url.replace(/\/\/([^/@\s]+)@/g, "//***@");
|
|
1040
|
+
return scrubbed;
|
|
1041
|
+
}
|
|
1042
|
+
function resolvePrNumber(opts) {
|
|
1043
|
+
const env = opts.env ?? process.env;
|
|
1044
|
+
const raw = opts.flag ?? env.GITHUB_PR_NUMBER ?? env.PR_NUMBER;
|
|
1045
|
+
if (raw === void 0 || raw === "") return null;
|
|
1046
|
+
const n = Number(raw);
|
|
1047
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
1048
|
+
throw new Error(
|
|
1049
|
+
`invalid PR number "${raw}" \u2014 expected a positive integer (via --pr or GITHUB_PR_NUMBER)`
|
|
1050
|
+
);
|
|
1051
|
+
}
|
|
1052
|
+
return n;
|
|
1053
|
+
}
|
|
1054
|
+
function parseGitHubRemote(url) {
|
|
1055
|
+
const trimmed = url.trim();
|
|
1056
|
+
if (!trimmed) return null;
|
|
1057
|
+
const scp = trimmed.match(/^git@[^:]+:([^/]+)\/([^/]+?)(?:\.git)?$/i);
|
|
1058
|
+
if (scp) return { owner: scp[1], name: scp[2] };
|
|
1059
|
+
try {
|
|
1060
|
+
const normalized = trimmed.replace(/^git\+/, "");
|
|
1061
|
+
const withProto = /^[a-z][a-z0-9+.-]*:\/\//i.test(normalized) ? normalized : `https://${normalized}`;
|
|
1062
|
+
const u = new URL(withProto);
|
|
1063
|
+
const parts = u.pathname.replace(/^\//, "").replace(/\.git$/i, "").split("/");
|
|
1064
|
+
if (parts.length >= 2 && parts[0] && parts[1]) {
|
|
1065
|
+
return { owner: parts[0], name: parts[1] };
|
|
1066
|
+
}
|
|
1067
|
+
} catch {
|
|
1068
|
+
}
|
|
1069
|
+
return null;
|
|
1070
|
+
}
|
|
1071
|
+
function sanitizeAuthor(name) {
|
|
1072
|
+
const slug = name.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
|
|
1073
|
+
return slug || "unknown";
|
|
1074
|
+
}
|
|
1075
|
+
function toBranchName(ref) {
|
|
1076
|
+
return ref.replace(/^refs\/heads\//, "").replace(/^refs\/remotes\//, "").replace(/^origin\//, "");
|
|
1077
|
+
}
|
|
1078
|
+
function buildIngestBody(input) {
|
|
1079
|
+
const baseRefName = toBranchName(input.baseRef);
|
|
1080
|
+
return {
|
|
1081
|
+
repo: {
|
|
1082
|
+
owner: input.owner,
|
|
1083
|
+
name: input.name,
|
|
1084
|
+
defaultBranch: baseRefName
|
|
1085
|
+
},
|
|
1086
|
+
pr: {
|
|
1087
|
+
number: input.prNumber,
|
|
1088
|
+
title: input.prTitle,
|
|
1089
|
+
authorLogin: input.author,
|
|
1090
|
+
baseRef: baseRefName,
|
|
1091
|
+
headRef: input.headRef,
|
|
1092
|
+
headSha: input.headSha,
|
|
1093
|
+
state: "open"
|
|
1094
|
+
},
|
|
1095
|
+
runUrl: input.runUrl,
|
|
1096
|
+
diff: input.diff,
|
|
1097
|
+
...input.testReport ? { testReport: input.testReport } : {}
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
function buildMainlineIngestBody(input) {
|
|
1101
|
+
return {
|
|
1102
|
+
repo: {
|
|
1103
|
+
owner: input.owner,
|
|
1104
|
+
name: input.name,
|
|
1105
|
+
defaultBranch: input.defaultBranch
|
|
1106
|
+
},
|
|
1107
|
+
runUrl: input.runUrl,
|
|
1108
|
+
diff: input.diff,
|
|
1109
|
+
ref: input.ref,
|
|
1110
|
+
isDefaultBranch: true,
|
|
1111
|
+
headSha: input.headSha,
|
|
1112
|
+
...input.testReport ? { testReport: input.testReport } : {}
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
var DEFAULT_JUNIT_CANDIDATES = [
|
|
1116
|
+
"junit.xml",
|
|
1117
|
+
"test-results/junit.xml",
|
|
1118
|
+
"coverage/junit.xml",
|
|
1119
|
+
"reports/junit.xml"
|
|
1120
|
+
];
|
|
1121
|
+
function resolveJunitPath(opts) {
|
|
1122
|
+
const env = opts.env ?? process.env;
|
|
1123
|
+
const exists = opts.existsSyncFn ?? existsSync2;
|
|
1124
|
+
if (opts.flag && opts.flag.trim()) {
|
|
1125
|
+
const p = opts.flag.trim();
|
|
1126
|
+
const abs = p.startsWith("/") ? p : join3(opts.cwd, p);
|
|
1127
|
+
if (!exists(abs)) {
|
|
1128
|
+
throw new Error(`JUnit file not found: ${p}`);
|
|
1129
|
+
}
|
|
1130
|
+
return abs;
|
|
1131
|
+
}
|
|
1132
|
+
const fromEnv = env.TESTED_JUNIT?.trim();
|
|
1133
|
+
if (fromEnv) {
|
|
1134
|
+
const abs = fromEnv.startsWith("/") ? fromEnv : join3(opts.cwd, fromEnv);
|
|
1135
|
+
if (!exists(abs)) {
|
|
1136
|
+
throw new Error(`TESTED_JUNIT file not found: ${fromEnv}`);
|
|
1137
|
+
}
|
|
1138
|
+
return abs;
|
|
1139
|
+
}
|
|
1140
|
+
for (const rel of DEFAULT_JUNIT_CANDIDATES) {
|
|
1141
|
+
const abs = join3(opts.cwd, rel);
|
|
1142
|
+
if (exists(abs)) return abs;
|
|
1143
|
+
}
|
|
1144
|
+
return null;
|
|
1145
|
+
}
|
|
1146
|
+
function loadTestReportFromJunit(path, readFn = readFileSync2) {
|
|
1147
|
+
const xml = readFn(path, "utf8");
|
|
1148
|
+
return parseJunitToTestReport(xml);
|
|
1149
|
+
}
|
|
1150
|
+
async function postIngest(opts) {
|
|
1151
|
+
const fetchFn = opts.fetchFn ?? globalThis.fetch;
|
|
1152
|
+
const url = `${opts.apiBase}/api/ingest`;
|
|
1153
|
+
let res;
|
|
1154
|
+
try {
|
|
1155
|
+
res = await fetchFn(url, {
|
|
1156
|
+
method: "POST",
|
|
1157
|
+
// Do not follow redirects: a 3xx to another origin could exfiltrate the
|
|
1158
|
+
// Bearer token depending on the fetch implementation.
|
|
1159
|
+
redirect: "manual",
|
|
1160
|
+
headers: {
|
|
1161
|
+
Authorization: `Bearer ${opts.token}`,
|
|
1162
|
+
"Content-Type": "application/json",
|
|
1163
|
+
Accept: "application/json"
|
|
1164
|
+
},
|
|
1165
|
+
body: JSON.stringify(opts.body)
|
|
1166
|
+
});
|
|
1167
|
+
} catch (err) {
|
|
1168
|
+
const message2 = err instanceof Error ? err.message : String(err);
|
|
1169
|
+
return { ok: false, status: 0, message: `network error: ${message2}` };
|
|
1170
|
+
}
|
|
1171
|
+
if (res.status >= 300 && res.status < 400) {
|
|
1172
|
+
return {
|
|
1173
|
+
ok: false,
|
|
1174
|
+
status: res.status,
|
|
1175
|
+
message: `ingest redirected (${res.status}); refusing to follow redirects with Bearer token`
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
const text = await res.text();
|
|
1179
|
+
let parsed = null;
|
|
1180
|
+
if (text) {
|
|
1181
|
+
try {
|
|
1182
|
+
parsed = JSON.parse(text);
|
|
1183
|
+
} catch {
|
|
1184
|
+
parsed = null;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
if (res.status === 200) {
|
|
1188
|
+
const data = parsed;
|
|
1189
|
+
if (!data) {
|
|
1190
|
+
return {
|
|
1191
|
+
ok: false,
|
|
1192
|
+
status: res.status,
|
|
1193
|
+
message: "ingest succeeded but response was empty"
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
if (data.mainline === true) {
|
|
1197
|
+
return {
|
|
1198
|
+
ok: true,
|
|
1199
|
+
status: res.status,
|
|
1200
|
+
data: {
|
|
1201
|
+
mainline: true,
|
|
1202
|
+
...typeof data.date === "string" ? { date: data.date } : {},
|
|
1203
|
+
...typeof data.projectPct === "number" ? { projectPct: data.projectPct } : {}
|
|
1204
|
+
}
|
|
1205
|
+
};
|
|
1206
|
+
}
|
|
1207
|
+
if (typeof data.shareUrl !== "string" || !data.shareUrl) {
|
|
1208
|
+
return {
|
|
1209
|
+
ok: false,
|
|
1210
|
+
status: res.status,
|
|
1211
|
+
message: "ingest succeeded but response missing shareUrl"
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
return {
|
|
1215
|
+
ok: true,
|
|
1216
|
+
status: res.status,
|
|
1217
|
+
data: {
|
|
1218
|
+
shareUrl: data.shareUrl,
|
|
1219
|
+
...typeof data.expiresAt === "string" ? { expiresAt: data.expiresAt } : {}
|
|
1220
|
+
}
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
let message = text || res.statusText || "unknown error";
|
|
1224
|
+
let code;
|
|
1225
|
+
if (parsed && typeof parsed === "object") {
|
|
1226
|
+
const obj = parsed;
|
|
1227
|
+
if (typeof obj.message === "string") message = obj.message;
|
|
1228
|
+
else if (typeof obj.error === "string") message = obj.error;
|
|
1229
|
+
if (typeof obj.code === "string") code = obj.code;
|
|
1230
|
+
else if (typeof obj.error === "string" && /^[a-z0-9_]+$/i.test(obj.error)) {
|
|
1231
|
+
code = obj.error;
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
return { ok: false, status: res.status, message, ...code ? { code } : {} };
|
|
1235
|
+
}
|
|
1236
|
+
function formatPushSuccess(data, json) {
|
|
1237
|
+
if (json) {
|
|
1238
|
+
const payload = {};
|
|
1239
|
+
if (data.shareUrl) payload.shareUrl = data.shareUrl;
|
|
1240
|
+
if (data.expiresAt) payload.expiresAt = data.expiresAt;
|
|
1241
|
+
if (data.mainline) payload.mainline = true;
|
|
1242
|
+
if (data.date) payload.date = data.date;
|
|
1243
|
+
if (typeof data.projectPct === "number") payload.projectPct = data.projectPct;
|
|
1244
|
+
return { stdout: JSON.stringify(payload) + "\n", stderr: "" };
|
|
1245
|
+
}
|
|
1246
|
+
const lines = [];
|
|
1247
|
+
if (data.mainline) {
|
|
1248
|
+
lines.push(
|
|
1249
|
+
successLine(
|
|
1250
|
+
`mainline coverage recorded` + (typeof data.projectPct === "number" ? ` ${data.projectPct.toFixed(1)}%` : "") + (data.date ? ` ${data.date}` : "")
|
|
1251
|
+
)
|
|
1252
|
+
);
|
|
1253
|
+
return { stdout: lines.join("\n") + "\n", stderr: "" };
|
|
1254
|
+
}
|
|
1255
|
+
if (!data.shareUrl) {
|
|
1256
|
+
return { stdout: successLine("uploaded") + "\n", stderr: "" };
|
|
1257
|
+
}
|
|
1258
|
+
lines.push(successLine(`shared ${shareUrl(data.shareUrl)}`));
|
|
1259
|
+
if (data.expiresAt) {
|
|
1260
|
+
lines.push(dim(` expires ${data.expiresAt}`));
|
|
1261
|
+
}
|
|
1262
|
+
return { stdout: lines.join("\n") + "\n", stderr: "" };
|
|
1263
|
+
}
|
|
1264
|
+
function formatMissingTokenError(opts) {
|
|
1265
|
+
return errorBlock("missing ingest token", [
|
|
1266
|
+
...tokenMintGuidance(opts),
|
|
1267
|
+
"or pass --token <token> (avoid on shared hosts: visible in ps)"
|
|
1268
|
+
]);
|
|
1269
|
+
}
|
|
1270
|
+
function formatPushError(status, message, code) {
|
|
1271
|
+
if (status === 0) {
|
|
1272
|
+
return errorBlock(message);
|
|
1273
|
+
}
|
|
1274
|
+
const normalized = (code ?? message).toLowerCase();
|
|
1275
|
+
if (normalized.includes("token_required") || normalized.includes("invalid token") || normalized.includes("unauthorized") || status === 401) {
|
|
1276
|
+
return errorBlock("ingest auth failed", [
|
|
1277
|
+
message,
|
|
1278
|
+
"",
|
|
1279
|
+
...tokenMintGuidance(),
|
|
1280
|
+
"or --token"
|
|
1281
|
+
]);
|
|
1282
|
+
}
|
|
1283
|
+
if (normalized.includes("repo_not_found") || status === 404 && /repo/i.test(message)) {
|
|
1284
|
+
return errorBlock("repo not found", [
|
|
1285
|
+
message,
|
|
1286
|
+
"",
|
|
1287
|
+
"Check --owner / --name (or that the git remote origin is correct)",
|
|
1288
|
+
"and that this repo exists on app.tested.dev"
|
|
1289
|
+
]);
|
|
1290
|
+
}
|
|
1291
|
+
if (status === 403) {
|
|
1292
|
+
return errorBlock(`ingest failed (${status})`, [
|
|
1293
|
+
message,
|
|
1294
|
+
"",
|
|
1295
|
+
"Token may lack permission for this repo."
|
|
1296
|
+
]);
|
|
1297
|
+
}
|
|
1298
|
+
return errorBlock(`ingest failed (${status})`, [message]);
|
|
1299
|
+
}
|
|
1300
|
+
async function executePush(cli, deps) {
|
|
1301
|
+
const env = deps.env ?? process.env;
|
|
1302
|
+
const computeDiffFn = deps.computeDiffFn ?? computeDiff;
|
|
1303
|
+
const fetchFn = deps.fetchFn ?? globalThis.fetch;
|
|
1304
|
+
const openRepoFn = deps.openRepoFn ?? openRepo;
|
|
1305
|
+
const loadConfigFn = deps.loadConfigFn ?? loadConfig;
|
|
1306
|
+
const onProgress = deps.onProgress ?? ((msg) => {
|
|
1307
|
+
process.stderr.write(progress(msg) + "\n");
|
|
1308
|
+
});
|
|
1309
|
+
const token = resolveToken({
|
|
1310
|
+
...cli.token !== void 0 ? { flag: cli.token } : {},
|
|
1311
|
+
env
|
|
1312
|
+
});
|
|
1313
|
+
if (!token) {
|
|
1314
|
+
let owner2 = cli.owner ?? null;
|
|
1315
|
+
let name2 = cli.name ?? null;
|
|
1316
|
+
if (!owner2 || !name2) {
|
|
1317
|
+
try {
|
|
1318
|
+
const ctx2 = await openRepoFn(deps.cwd);
|
|
1319
|
+
const origin = await remoteUrl(ctx2, "origin");
|
|
1320
|
+
const parsed = parseGitHubRemote(origin);
|
|
1321
|
+
if (parsed) {
|
|
1322
|
+
owner2 = owner2 ?? parsed.owner;
|
|
1323
|
+
name2 = name2 ?? parsed.name;
|
|
1324
|
+
}
|
|
1325
|
+
} catch {
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
return {
|
|
1329
|
+
exitCode: 1,
|
|
1330
|
+
stdout: "",
|
|
1331
|
+
stderr: formatMissingTokenError({ owner: owner2, name: name2 })
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
let prNumber;
|
|
1335
|
+
try {
|
|
1336
|
+
prNumber = resolvePrNumber({
|
|
1337
|
+
...cli.pr !== void 0 ? { flag: cli.pr } : {},
|
|
1338
|
+
env
|
|
1339
|
+
});
|
|
1340
|
+
} catch (err) {
|
|
1341
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1342
|
+
return { exitCode: 1, stdout: "", stderr: errorBlock(message) };
|
|
1343
|
+
}
|
|
1344
|
+
const mainline = Boolean(cli.mainline);
|
|
1345
|
+
if (prNumber === null && !mainline) {
|
|
1346
|
+
return {
|
|
1347
|
+
exitCode: 1,
|
|
1348
|
+
stdout: "",
|
|
1349
|
+
stderr: errorBlock("PR number required", [
|
|
1350
|
+
"Pass --pr <number>",
|
|
1351
|
+
"or set GITHUB_PR_NUMBER (CI) / PR_NUMBER",
|
|
1352
|
+
"or pass --mainline for default-branch coverage (no share URL)"
|
|
1353
|
+
])
|
|
1354
|
+
};
|
|
1355
|
+
}
|
|
1356
|
+
let apiBase;
|
|
1357
|
+
try {
|
|
1358
|
+
apiBase = resolveApiBase({
|
|
1359
|
+
...cli.url !== void 0 ? { flag: cli.url } : {},
|
|
1360
|
+
env
|
|
1361
|
+
});
|
|
1362
|
+
} catch (err) {
|
|
1363
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1364
|
+
return { exitCode: 1, stdout: "", stderr: errorBlock(message) };
|
|
1365
|
+
}
|
|
1366
|
+
const config = await loadConfigFn({ cwd: deps.cwd });
|
|
1367
|
+
const ctx = await openRepoFn(deps.cwd);
|
|
1368
|
+
onProgress("computing diff\u2026");
|
|
1369
|
+
let diff;
|
|
1370
|
+
try {
|
|
1371
|
+
diff = await computeDiffFn({
|
|
1372
|
+
cwd: deps.cwd,
|
|
1373
|
+
config,
|
|
1374
|
+
...cli.base !== void 0 ? { baseRef: cli.base } : {},
|
|
1375
|
+
ctx
|
|
1376
|
+
});
|
|
1377
|
+
} catch (err) {
|
|
1378
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1379
|
+
return { exitCode: 1, stdout: "", stderr: errorBlock(message) };
|
|
1380
|
+
}
|
|
1381
|
+
let owner = cli.owner;
|
|
1382
|
+
let name = cli.name;
|
|
1383
|
+
if (!owner || !name) {
|
|
1384
|
+
let origin;
|
|
1385
|
+
try {
|
|
1386
|
+
origin = await remoteUrl(ctx, "origin");
|
|
1387
|
+
} catch {
|
|
1388
|
+
return {
|
|
1389
|
+
exitCode: 1,
|
|
1390
|
+
stdout: "",
|
|
1391
|
+
stderr: errorBlock("could not read git remote origin", [
|
|
1392
|
+
"Pass --owner and --name explicitly."
|
|
1393
|
+
])
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
const parsed = parseGitHubRemote(origin);
|
|
1397
|
+
if (!parsed) {
|
|
1398
|
+
return {
|
|
1399
|
+
exitCode: 1,
|
|
1400
|
+
stdout: "",
|
|
1401
|
+
stderr: errorBlock(
|
|
1402
|
+
`could not parse owner/name from remote "${redactGitRemote(origin)}"`,
|
|
1403
|
+
["Pass --owner and --name."]
|
|
1404
|
+
)
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
1407
|
+
owner = owner ?? parsed.owner;
|
|
1408
|
+
name = name ?? parsed.name;
|
|
1409
|
+
}
|
|
1410
|
+
const sha = await headSha(ctx);
|
|
1411
|
+
const branch = await currentBranch(ctx) || "coverage push";
|
|
1412
|
+
const baseRef = cli.baseRef ?? (toBranchName(config.base) || "main");
|
|
1413
|
+
const headRef = cli.headRef ?? branch;
|
|
1414
|
+
const prTitle = cli.prTitle ?? (branch !== "coverage push" ? branch : "coverage push");
|
|
1415
|
+
let author = cli.author;
|
|
1416
|
+
if (!author) {
|
|
1417
|
+
const gitName = await gitUserName(ctx);
|
|
1418
|
+
author = sanitizeAuthor(gitName ?? env.USER ?? env.USERNAME ?? "unknown");
|
|
1419
|
+
}
|
|
1420
|
+
let testReport;
|
|
1421
|
+
try {
|
|
1422
|
+
const junitPath = resolveJunitPath({
|
|
1423
|
+
...cli.junit !== void 0 ? { flag: cli.junit } : {},
|
|
1424
|
+
cwd: deps.cwd,
|
|
1425
|
+
env
|
|
1426
|
+
});
|
|
1427
|
+
if (junitPath) {
|
|
1428
|
+
onProgress("parsing JUnit\u2026");
|
|
1429
|
+
testReport = loadTestReportFromJunit(junitPath);
|
|
1430
|
+
}
|
|
1431
|
+
} catch (err) {
|
|
1432
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1433
|
+
return { exitCode: 1, stdout: "", stderr: errorBlock(message) };
|
|
1434
|
+
}
|
|
1435
|
+
const body = mainline ? buildMainlineIngestBody({
|
|
1436
|
+
owner,
|
|
1437
|
+
name,
|
|
1438
|
+
defaultBranch: baseRef,
|
|
1439
|
+
headSha: sha,
|
|
1440
|
+
ref: `refs/heads/${baseRef}`,
|
|
1441
|
+
runUrl: cli.runUrl ?? null,
|
|
1442
|
+
diff,
|
|
1443
|
+
...testReport ? { testReport } : {}
|
|
1444
|
+
}) : buildIngestBody({
|
|
1445
|
+
owner,
|
|
1446
|
+
name,
|
|
1447
|
+
baseRef,
|
|
1448
|
+
prNumber,
|
|
1449
|
+
prTitle,
|
|
1450
|
+
author,
|
|
1451
|
+
headRef,
|
|
1452
|
+
headSha: sha,
|
|
1453
|
+
runUrl: cli.runUrl ?? null,
|
|
1454
|
+
diff,
|
|
1455
|
+
...testReport ? { testReport } : {}
|
|
1456
|
+
});
|
|
1457
|
+
onProgress(mainline ? "uploading mainline coverage\u2026" : "uploading\u2026");
|
|
1458
|
+
const result = await postIngest({ apiBase, token, body, fetchFn });
|
|
1459
|
+
if (!result.ok) {
|
|
1460
|
+
return {
|
|
1461
|
+
exitCode: 1,
|
|
1462
|
+
stdout: "",
|
|
1463
|
+
stderr: formatPushError(result.status, result.message, result.code)
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1466
|
+
const formatted = formatPushSuccess(result.data, cli.json);
|
|
1467
|
+
return {
|
|
1468
|
+
exitCode: 0,
|
|
1469
|
+
stdout: formatted.stdout,
|
|
1470
|
+
stderr: formatted.stderr,
|
|
1471
|
+
...result.data.shareUrl !== void 0 ? { shareUrl: result.data.shareUrl } : {},
|
|
1472
|
+
...result.data.expiresAt !== void 0 ? { expiresAt: result.data.expiresAt } : {}
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
function registerPushCommand(program2) {
|
|
1476
|
+
program2.command("push").description("Push local coverage to tested.dev and get a share URL").option(
|
|
1477
|
+
"--token <token>",
|
|
1478
|
+
"Ingest token (prefer env TESTED_TOKEN / TESTED_INGEST_TOKEN / TESTED_TOKEN_FILE)"
|
|
1479
|
+
).option(
|
|
1480
|
+
"--url <url>",
|
|
1481
|
+
`API base URL (default ${DEFAULT_API_BASE}, or env TESTED_API_URL)`
|
|
1482
|
+
).option("--owner <owner>", "Repo owner (default: detect from git remote origin)").option("--name <name>", "Repo name (default: detect from git remote origin)").option("--pr <number>", "PR number (or env GITHUB_PR_NUMBER / PR_NUMBER)").option(
|
|
1483
|
+
"--mainline",
|
|
1484
|
+
"Upload default-branch project coverage only (no PR / no share URL)",
|
|
1485
|
+
false
|
|
1486
|
+
).option("--pr-title <title>", 'PR title (default: current branch or "coverage push")').option(
|
|
1487
|
+
"--author <login>",
|
|
1488
|
+
"PR author login (default: git user.name sanitized or $USER)"
|
|
1489
|
+
).option(
|
|
1490
|
+
"--base-ref <ref>",
|
|
1491
|
+
"Base branch name sent to the API (default: .tested.yaml base or main)"
|
|
1492
|
+
).option("--head-ref <ref>", "Head branch name (default: current branch)").option("--run-url <url>", "Optional CI run URL attached to the ingest").option("--base <ref>", "Git base ref to diff against (same as `tested diff --base`)").option(
|
|
1493
|
+
"--junit <path>",
|
|
1494
|
+
"JUnit XML for test analytics (flakes / slowest). Also TESTED_JUNIT or junit.xml"
|
|
1495
|
+
).option("--json", "Emit machine-readable JSON instead of the share URL only", false).action(async (opts) => {
|
|
1496
|
+
try {
|
|
1497
|
+
const result = await executePush(opts, { cwd: process.cwd() });
|
|
1498
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
1499
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
1500
|
+
process.exitCode = result.exitCode;
|
|
1501
|
+
} catch (err) {
|
|
1502
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1503
|
+
process.stderr.write(errorBlock(message));
|
|
1504
|
+
process.exitCode = 1;
|
|
1505
|
+
}
|
|
1506
|
+
});
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
// src/commands/doctor.ts
|
|
1510
|
+
var TESTED_BIN_BASENAME_RE = /^tested(\.js)?$/;
|
|
1511
|
+
function statusBadge(status) {
|
|
1512
|
+
const kind = status === "pass" ? "pass" : status === "fail" ? "fail" : status === "warn" ? "warn" : "info";
|
|
1513
|
+
return badge(kind);
|
|
1514
|
+
}
|
|
1515
|
+
function formatCheckLine(check) {
|
|
1516
|
+
const pad = check.label.padEnd(18);
|
|
1517
|
+
return ` ${pad} ${statusBadge(check.status)} ${dim(check.detail)}`;
|
|
1518
|
+
}
|
|
1519
|
+
function formatDoctorHuman(result) {
|
|
1520
|
+
const lines = [];
|
|
1521
|
+
lines.push(
|
|
1522
|
+
`${heading("tested.dev \u2014 doctor")} ${result.ok ? badge("pass") : badge("fail")}`
|
|
1523
|
+
);
|
|
1524
|
+
lines.push("");
|
|
1525
|
+
for (const c of result.checks) {
|
|
1526
|
+
lines.push(formatCheckLine(c));
|
|
1527
|
+
}
|
|
1528
|
+
lines.push("");
|
|
1529
|
+
if (result.ok) {
|
|
1530
|
+
lines.push(dim("environment looks ready"));
|
|
1531
|
+
} else {
|
|
1532
|
+
lines.push(tip("fix FAIL items, then re-run: tested doctor"));
|
|
1533
|
+
}
|
|
1534
|
+
lines.push("");
|
|
1535
|
+
return lines.join("\n");
|
|
1536
|
+
}
|
|
1537
|
+
function buildDoctorJson(result) {
|
|
1538
|
+
return {
|
|
1539
|
+
schemaVersion: 1,
|
|
1540
|
+
ok: result.ok,
|
|
1541
|
+
checks: result.checks.map((c) => ({
|
|
1542
|
+
id: c.id,
|
|
1543
|
+
label: c.label,
|
|
1544
|
+
status: c.status,
|
|
1545
|
+
detail: c.detail,
|
|
1546
|
+
...c.optional ? { optional: true } : {}
|
|
1547
|
+
}))
|
|
1548
|
+
};
|
|
1549
|
+
}
|
|
1550
|
+
function parseNodeMajor(version) {
|
|
1551
|
+
const m = version.replace(/^v/, "").match(/^(\d+)/);
|
|
1552
|
+
if (!m) return null;
|
|
1553
|
+
return Number(m[1]);
|
|
1554
|
+
}
|
|
1555
|
+
function isReadableFile(path, exists) {
|
|
1556
|
+
if (!exists(path)) return false;
|
|
1557
|
+
try {
|
|
1558
|
+
accessSync(path, fsConstants.R_OK);
|
|
1559
|
+
return true;
|
|
1560
|
+
} catch {
|
|
1561
|
+
return exists(path);
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
async function runDoctor(deps) {
|
|
1565
|
+
const cwd = deps.cwd;
|
|
1566
|
+
const env = deps.env ?? process.env;
|
|
1567
|
+
const exists = deps.existsSyncFn ?? existsSync3;
|
|
1568
|
+
const gitFactory = deps.gitFactory ?? simpleGit3;
|
|
1569
|
+
const loadConfigFn = deps.loadConfigFn ?? loadConfig;
|
|
1570
|
+
const resolveTokenFn = deps.resolveTokenFn ?? resolveToken;
|
|
1571
|
+
const assertSafe = deps.assertSafeApiBaseFn ?? assertSafeApiBase;
|
|
1572
|
+
const nodeVersion = deps.nodeVersion ?? process.version;
|
|
1573
|
+
const json = deps.json ?? false;
|
|
1574
|
+
const checks = [];
|
|
1575
|
+
const major = parseNodeMajor(nodeVersion);
|
|
1576
|
+
const nodeDisplay = nodeVersion.replace(/^v/, "v");
|
|
1577
|
+
if (major !== null && major >= 22) {
|
|
1578
|
+
checks.push({
|
|
1579
|
+
id: "node",
|
|
1580
|
+
label: "Node.js",
|
|
1581
|
+
status: "pass",
|
|
1582
|
+
detail: `${nodeDisplay} (>= 20.19; 22+ recommended)`
|
|
1583
|
+
});
|
|
1584
|
+
} else {
|
|
1585
|
+
checks.push({
|
|
1586
|
+
id: "node",
|
|
1587
|
+
label: "Node.js",
|
|
1588
|
+
status: "warn",
|
|
1589
|
+
detail: `${nodeDisplay} runs this CLI. Node >= 22 recommended.`,
|
|
1590
|
+
optional: true
|
|
1591
|
+
});
|
|
1592
|
+
}
|
|
1593
|
+
let isRepo = false;
|
|
1594
|
+
try {
|
|
1595
|
+
const git = gitFactory({ baseDir: cwd });
|
|
1596
|
+
isRepo = await git.checkIsRepo();
|
|
1597
|
+
} catch {
|
|
1598
|
+
isRepo = false;
|
|
1599
|
+
}
|
|
1600
|
+
if (isRepo) {
|
|
1601
|
+
checks.push({
|
|
1602
|
+
id: "git",
|
|
1603
|
+
label: "Git repo",
|
|
1604
|
+
status: "pass",
|
|
1605
|
+
detail: cwd
|
|
1606
|
+
});
|
|
1607
|
+
} else {
|
|
1608
|
+
checks.push({
|
|
1609
|
+
id: "git",
|
|
1610
|
+
label: "Git repo",
|
|
1611
|
+
status: "fail",
|
|
1612
|
+
detail: "not a git repository \u2014 run from a repo root"
|
|
1613
|
+
});
|
|
1614
|
+
}
|
|
1615
|
+
const configPath = join4(cwd, ".tested.yaml");
|
|
1616
|
+
const hasConfig = exists(configPath);
|
|
1617
|
+
if (hasConfig) {
|
|
1618
|
+
checks.push({
|
|
1619
|
+
id: "config",
|
|
1620
|
+
label: ".tested.yaml",
|
|
1621
|
+
status: "pass",
|
|
1622
|
+
detail: configPath
|
|
1623
|
+
});
|
|
1624
|
+
} else {
|
|
1625
|
+
checks.push({
|
|
1626
|
+
id: "config",
|
|
1627
|
+
label: ".tested.yaml",
|
|
1628
|
+
status: "fail",
|
|
1629
|
+
detail: "missing \u2014 run: tested setup (or tested init)"
|
|
1630
|
+
});
|
|
1631
|
+
}
|
|
1632
|
+
let coverageRel = "coverage/coverage-final.json";
|
|
1633
|
+
if (hasConfig) {
|
|
1634
|
+
try {
|
|
1635
|
+
const config = await loadConfigFn({ cwd });
|
|
1636
|
+
coverageRel = config.coverage.path;
|
|
1637
|
+
} catch {
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
const coverageAbs = resolve4(cwd, coverageRel);
|
|
1641
|
+
if (isReadableFile(coverageAbs, exists)) {
|
|
1642
|
+
checks.push({
|
|
1643
|
+
id: "coverage",
|
|
1644
|
+
label: "Coverage file",
|
|
1645
|
+
status: "pass",
|
|
1646
|
+
detail: coverageRel,
|
|
1647
|
+
optional: true
|
|
1648
|
+
});
|
|
1649
|
+
} else {
|
|
1650
|
+
checks.push({
|
|
1651
|
+
id: "coverage",
|
|
1652
|
+
label: "Coverage file",
|
|
1653
|
+
status: "warn",
|
|
1654
|
+
detail: `missing ${coverageRel} \u2014 run: tested run`,
|
|
1655
|
+
optional: true
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
let originOwner = null;
|
|
1659
|
+
let originName = null;
|
|
1660
|
+
if (isRepo) {
|
|
1661
|
+
try {
|
|
1662
|
+
const git = gitFactory({ baseDir: cwd });
|
|
1663
|
+
const url = (await git.raw(["remote", "get-url", "origin"])).trim();
|
|
1664
|
+
if (url) {
|
|
1665
|
+
const parsed = parseGitHubRemote(url);
|
|
1666
|
+
if (parsed) {
|
|
1667
|
+
originOwner = parsed.owner;
|
|
1668
|
+
originName = parsed.name;
|
|
1669
|
+
}
|
|
1670
|
+
const safe = url.replace(/\/\/([^/@\s]+)@/g, "//***@");
|
|
1671
|
+
checks.push({
|
|
1672
|
+
id: "origin",
|
|
1673
|
+
label: "origin remote",
|
|
1674
|
+
status: "pass",
|
|
1675
|
+
detail: safe
|
|
1676
|
+
});
|
|
1677
|
+
} else {
|
|
1678
|
+
checks.push({
|
|
1679
|
+
id: "origin",
|
|
1680
|
+
label: "origin remote",
|
|
1681
|
+
status: "fail",
|
|
1682
|
+
detail: "origin remote URL is empty"
|
|
1683
|
+
});
|
|
1684
|
+
}
|
|
1685
|
+
} catch {
|
|
1686
|
+
checks.push({
|
|
1687
|
+
id: "origin",
|
|
1688
|
+
label: "origin remote",
|
|
1689
|
+
status: "fail",
|
|
1690
|
+
detail: "no origin remote \u2014 git remote add origin <url>"
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
} else {
|
|
1694
|
+
checks.push({
|
|
1695
|
+
id: "origin",
|
|
1696
|
+
label: "origin remote",
|
|
1697
|
+
status: "skip",
|
|
1698
|
+
detail: "skipped (not a git repo)"
|
|
1699
|
+
});
|
|
1700
|
+
}
|
|
1701
|
+
let tokenPresent = false;
|
|
1702
|
+
let tokenSource = null;
|
|
1703
|
+
try {
|
|
1704
|
+
const token = resolveTokenFn({ env, isTTY: false, warn: () => {
|
|
1705
|
+
} });
|
|
1706
|
+
if (token) {
|
|
1707
|
+
tokenPresent = true;
|
|
1708
|
+
if (env.TESTED_TOKEN) tokenSource = "TESTED_TOKEN";
|
|
1709
|
+
else if (env.TESTED_INGEST_TOKEN) tokenSource = "TESTED_INGEST_TOKEN";
|
|
1710
|
+
else if (env.TESTED_TOKEN_FILE) tokenSource = "TESTED_TOKEN_FILE";
|
|
1711
|
+
else tokenSource = "token";
|
|
1712
|
+
}
|
|
1713
|
+
} catch (err) {
|
|
1714
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1715
|
+
checks.push({
|
|
1716
|
+
id: "token",
|
|
1717
|
+
label: "Ingest token",
|
|
1718
|
+
status: "fail",
|
|
1719
|
+
detail: message.replace(/["'].{8,}["']/g, '"\u2026"'),
|
|
1720
|
+
optional: true
|
|
1721
|
+
});
|
|
1722
|
+
tokenPresent = false;
|
|
1723
|
+
tokenSource = null;
|
|
1724
|
+
}
|
|
1725
|
+
if (!checks.some((c) => c.id === "token")) {
|
|
1726
|
+
if (tokenPresent) {
|
|
1727
|
+
checks.push({
|
|
1728
|
+
id: "token",
|
|
1729
|
+
label: "Ingest token",
|
|
1730
|
+
status: "pass",
|
|
1731
|
+
detail: `set via ${tokenSource} (value not shown)`,
|
|
1732
|
+
optional: true
|
|
1733
|
+
});
|
|
1734
|
+
} else {
|
|
1735
|
+
checks.push({
|
|
1736
|
+
id: "token",
|
|
1737
|
+
label: "Ingest token",
|
|
1738
|
+
status: "warn",
|
|
1739
|
+
detail: `not set. ${tokenMintGuidance({ owner: originOwner, name: originName }).join(". ")}`,
|
|
1740
|
+
optional: true
|
|
1741
|
+
});
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
const rawApi = env.TESTED_API_URL;
|
|
1745
|
+
if (rawApi !== void 0 && rawApi !== "") {
|
|
1746
|
+
try {
|
|
1747
|
+
const normalized = assertSafe(rawApi);
|
|
1748
|
+
checks.push({
|
|
1749
|
+
id: "api_url",
|
|
1750
|
+
label: "API URL",
|
|
1751
|
+
status: "pass",
|
|
1752
|
+
detail: normalized,
|
|
1753
|
+
optional: true
|
|
1754
|
+
});
|
|
1755
|
+
} catch (err) {
|
|
1756
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1757
|
+
checks.push({
|
|
1758
|
+
id: "api_url",
|
|
1759
|
+
label: "API URL",
|
|
1760
|
+
status: "fail",
|
|
1761
|
+
detail: message,
|
|
1762
|
+
optional: true
|
|
1763
|
+
});
|
|
1764
|
+
}
|
|
1765
|
+
} else {
|
|
1766
|
+
checks.push({
|
|
1767
|
+
id: "api_url",
|
|
1768
|
+
label: "API URL",
|
|
1769
|
+
status: "pass",
|
|
1770
|
+
detail: `default ${DEFAULT_API_BASE} (unset TESTED_API_URL)`,
|
|
1771
|
+
optional: true
|
|
1772
|
+
});
|
|
1773
|
+
}
|
|
1774
|
+
const testedBin = env.TESTED_BIN;
|
|
1775
|
+
if (testedBin !== void 0 && testedBin !== "") {
|
|
1776
|
+
const base = basename(testedBin);
|
|
1777
|
+
const okName = TESTED_BIN_BASENAME_RE.test(base);
|
|
1778
|
+
if (!okName) {
|
|
1779
|
+
checks.push({
|
|
1780
|
+
id: "tested_bin",
|
|
1781
|
+
label: "TESTED_BIN",
|
|
1782
|
+
status: "fail",
|
|
1783
|
+
detail: `basename "${base}" must match /^tested(\\.js)?$/`,
|
|
1784
|
+
optional: true
|
|
1785
|
+
});
|
|
1786
|
+
} else if (!isAbsolute2(testedBin)) {
|
|
1787
|
+
checks.push({
|
|
1788
|
+
id: "tested_bin",
|
|
1789
|
+
label: "TESTED_BIN",
|
|
1790
|
+
status: "warn",
|
|
1791
|
+
detail: "relative path \u2014 prefer an absolute path to tested.js",
|
|
1792
|
+
optional: true
|
|
1793
|
+
});
|
|
1794
|
+
} else {
|
|
1795
|
+
checks.push({
|
|
1796
|
+
id: "tested_bin",
|
|
1797
|
+
label: "TESTED_BIN",
|
|
1798
|
+
status: "pass",
|
|
1799
|
+
detail: `basename ${base}`,
|
|
1800
|
+
optional: true
|
|
1801
|
+
});
|
|
1802
|
+
}
|
|
1803
|
+
} else {
|
|
1804
|
+
checks.push({
|
|
1805
|
+
id: "tested_bin",
|
|
1806
|
+
label: "TESTED_BIN",
|
|
1807
|
+
status: "skip",
|
|
1808
|
+
detail: "unset (optional; used by MCP hosts)",
|
|
1809
|
+
optional: true
|
|
1810
|
+
});
|
|
1811
|
+
}
|
|
1812
|
+
const hardFailIds = /* @__PURE__ */ new Set(["git", "config", "origin"]);
|
|
1813
|
+
const safetyFailIds = /* @__PURE__ */ new Set(["api_url", "tested_bin", "token"]);
|
|
1814
|
+
const hasHardFail = checks.some(
|
|
1815
|
+
(c) => c.status === "fail" && hardFailIds.has(c.id)
|
|
1816
|
+
);
|
|
1817
|
+
const hasSafetyFail = checks.some(
|
|
1818
|
+
(c) => c.status === "fail" && safetyFailIds.has(c.id) && // token missing is warn; only real resolve errors are fail
|
|
1819
|
+
!(c.id === "token" && /not set/i.test(c.detail))
|
|
1820
|
+
);
|
|
1821
|
+
const ok = !hasHardFail && !hasSafetyFail;
|
|
1822
|
+
const exitCode = ok ? 0 : 1;
|
|
1823
|
+
const summary = { checks, ok, exitCode };
|
|
1824
|
+
if (json) {
|
|
1825
|
+
return {
|
|
1826
|
+
...summary,
|
|
1827
|
+
stdout: JSON.stringify(buildDoctorJson(summary), null, 2) + "\n",
|
|
1828
|
+
stderr: ""
|
|
1829
|
+
};
|
|
1830
|
+
}
|
|
1831
|
+
return {
|
|
1832
|
+
...summary,
|
|
1833
|
+
stdout: formatDoctorHuman(summary),
|
|
1834
|
+
stderr: ""
|
|
1835
|
+
};
|
|
1836
|
+
}
|
|
1837
|
+
function registerDoctorCommand(program2) {
|
|
1838
|
+
program2.command("doctor").description("Diagnose local environment for the tested.dev agent loop").option("--json", "Emit machine-readable JSON", false).action(async (opts) => {
|
|
1839
|
+
try {
|
|
1840
|
+
const result = await runDoctor({
|
|
1841
|
+
cwd: process.cwd(),
|
|
1842
|
+
json: opts.json
|
|
1843
|
+
});
|
|
1844
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
1845
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
1846
|
+
process.exitCode = result.exitCode;
|
|
1847
|
+
} catch (err) {
|
|
1848
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1849
|
+
process.stderr.write(formatCliError(message));
|
|
1850
|
+
process.exitCode = 1;
|
|
1851
|
+
}
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
// src/commands/setup.ts
|
|
1856
|
+
import pc3 from "picocolors";
|
|
1857
|
+
var PINNED_CLI = "@tested/cli";
|
|
1858
|
+
function buildCiSnippet() {
|
|
1859
|
+
return [
|
|
1860
|
+
"# .github/workflows/tested.yml",
|
|
1861
|
+
"name: tested",
|
|
1862
|
+
"on: [pull_request]",
|
|
1863
|
+
"jobs:",
|
|
1864
|
+
" coverage:",
|
|
1865
|
+
" runs-on: ubuntu-latest",
|
|
1866
|
+
" steps:",
|
|
1867
|
+
" - uses: actions/checkout@v4",
|
|
1868
|
+
" with:",
|
|
1869
|
+
" fetch-depth: 0",
|
|
1870
|
+
" - uses: tested-hq/cli/action@main",
|
|
1871
|
+
" with:",
|
|
1872
|
+
" # pin ref for reproducible installs (do not use floating tags in prod)",
|
|
1873
|
+
" cli-ref: main",
|
|
1874
|
+
" push: true",
|
|
1875
|
+
" pr-number: ${{ github.event.pull_request.number }}",
|
|
1876
|
+
" token: ${{ secrets.TESTED_TOKEN }}"
|
|
1877
|
+
].join("\n");
|
|
1878
|
+
}
|
|
1879
|
+
function buildTokenInstructions() {
|
|
1880
|
+
return [
|
|
1881
|
+
"Ingest token (for tested push):",
|
|
1882
|
+
` Mint: ${INGEST_TOKEN_SETTINGS_URL_SHAPE}`,
|
|
1883
|
+
` Then set ${INGEST_TOKEN_ENV_NAMES.join(" / ")}`,
|
|
1884
|
+
" Never commit the token. Prefer env / file over --token (visible in ps)."
|
|
1885
|
+
].join("\n");
|
|
1886
|
+
}
|
|
1887
|
+
function buildInstallInstructions() {
|
|
1888
|
+
return [
|
|
1889
|
+
"Install CLI:",
|
|
1890
|
+
` pnpm add -D ${PINNED_CLI}`,
|
|
1891
|
+
` # or: npx ${PINNED_CLI}`,
|
|
1892
|
+
"",
|
|
1893
|
+
" CI: uses: tested-hq/cli/action@main (secrets.TESTED_TOKEN)",
|
|
1894
|
+
"",
|
|
1895
|
+
" # monorepo / local path:",
|
|
1896
|
+
" pnpm install && pnpm build # from a clone of tested-hq/cli"
|
|
1897
|
+
].join("\n");
|
|
1898
|
+
}
|
|
1899
|
+
function formatSetupHuman(opts) {
|
|
1900
|
+
const lines = [];
|
|
1901
|
+
lines.push(heading("tested.dev \u2014 setup"));
|
|
1902
|
+
lines.push("");
|
|
1903
|
+
if (opts.initRan && opts.initResult) {
|
|
1904
|
+
lines.push(formatInitResultHuman(opts.initResult));
|
|
1905
|
+
lines.push("");
|
|
1906
|
+
} else if (!opts.initRan) {
|
|
1907
|
+
lines.push(dim(" .tested.yaml already present \u2014 skipped init"));
|
|
1908
|
+
lines.push("");
|
|
1909
|
+
}
|
|
1910
|
+
lines.push(opts.doctor.stdout.trimEnd());
|
|
1911
|
+
lines.push("");
|
|
1912
|
+
lines.push(heading("CI snippet"));
|
|
1913
|
+
lines.push(pc3.dim(buildCiSnippet()));
|
|
1914
|
+
lines.push("");
|
|
1915
|
+
lines.push(heading("Token"));
|
|
1916
|
+
for (const line of buildTokenInstructions().split("\n")) {
|
|
1917
|
+
lines.push(dim(` ${line}`));
|
|
1918
|
+
}
|
|
1919
|
+
lines.push("");
|
|
1920
|
+
lines.push(heading("Install"));
|
|
1921
|
+
for (const line of buildInstallInstructions().split("\n")) {
|
|
1922
|
+
lines.push(dim(` ${line}`));
|
|
1923
|
+
}
|
|
1924
|
+
lines.push("");
|
|
1925
|
+
lines.push(
|
|
1926
|
+
nextSteps([
|
|
1927
|
+
"1. tested run",
|
|
1928
|
+
"2. tested diff",
|
|
1929
|
+
"3. tested check",
|
|
1930
|
+
"4. tested push --pr <n> (needs TESTED_TOKEN)"
|
|
1931
|
+
])
|
|
1932
|
+
);
|
|
1933
|
+
lines.push("");
|
|
1934
|
+
lines.push(tip("re-check anytime: tested doctor"));
|
|
1935
|
+
lines.push("");
|
|
1936
|
+
return lines.join("\n");
|
|
1937
|
+
}
|
|
1938
|
+
async function runSetup(deps) {
|
|
1939
|
+
const cwd = deps.cwd;
|
|
1940
|
+
const env = deps.env ?? process.env;
|
|
1941
|
+
const exists = deps.existsSyncFn ?? existsSync4;
|
|
1942
|
+
const runInitFn = deps.runInitFn ?? runInit;
|
|
1943
|
+
const runDoctorFn = deps.runDoctorFn ?? runDoctor;
|
|
1944
|
+
const force = deps.force ?? false;
|
|
1945
|
+
const hooks = deps.hooks ?? false;
|
|
1946
|
+
const json = deps.json ?? false;
|
|
1947
|
+
const configPath = join5(cwd, ".tested.yaml");
|
|
1948
|
+
let initRan = false;
|
|
1949
|
+
let initResult = null;
|
|
1950
|
+
if (!exists(configPath) || force) {
|
|
1951
|
+
initResult = await runInitFn({
|
|
1952
|
+
cwd,
|
|
1953
|
+
force: force || exists(configPath),
|
|
1954
|
+
hooks
|
|
1955
|
+
});
|
|
1956
|
+
initRan = true;
|
|
1957
|
+
}
|
|
1958
|
+
const doctor = await runDoctorFn({
|
|
1959
|
+
cwd,
|
|
1960
|
+
env,
|
|
1961
|
+
json: false
|
|
1962
|
+
// always compute structured; re-encode below if needed
|
|
1963
|
+
});
|
|
1964
|
+
if (json) {
|
|
1965
|
+
const payload = {
|
|
1966
|
+
schemaVersion: 1,
|
|
1967
|
+
initRan,
|
|
1968
|
+
init: initResult,
|
|
1969
|
+
doctor: {
|
|
1970
|
+
ok: doctor.ok,
|
|
1971
|
+
exitCode: doctor.exitCode,
|
|
1972
|
+
checks: doctor.checks
|
|
1973
|
+
},
|
|
1974
|
+
ciSnippet: buildCiSnippet(),
|
|
1975
|
+
tokenInstructions: buildTokenInstructions(),
|
|
1976
|
+
install: buildInstallInstructions(),
|
|
1977
|
+
pinnedCli: PINNED_CLI
|
|
1978
|
+
};
|
|
1979
|
+
return {
|
|
1980
|
+
initRan,
|
|
1981
|
+
initResult,
|
|
1982
|
+
doctor,
|
|
1983
|
+
exitCode: doctor.exitCode,
|
|
1984
|
+
stdout: JSON.stringify(payload, null, 2) + "\n",
|
|
1985
|
+
stderr: ""
|
|
1986
|
+
};
|
|
1987
|
+
}
|
|
1988
|
+
const stdout = formatSetupHuman({ initRan, initResult, doctor });
|
|
1989
|
+
return {
|
|
1990
|
+
initRan,
|
|
1991
|
+
initResult,
|
|
1992
|
+
doctor,
|
|
1993
|
+
exitCode: doctor.exitCode,
|
|
1994
|
+
stdout,
|
|
1995
|
+
stderr: ""
|
|
1996
|
+
};
|
|
1997
|
+
}
|
|
1998
|
+
function registerSetupCommand(program2) {
|
|
1999
|
+
program2.command("setup").description(
|
|
2000
|
+
"First-run setup: init if needed, doctor, CI snippet, token instructions"
|
|
2001
|
+
).option("--force", "Re-run init and overwrite .tested.yaml", false).option("--hooks", "Install husky pre-push hook during init", false).option("--json", "Emit machine-readable JSON", false).action(async (opts) => {
|
|
2002
|
+
try {
|
|
2003
|
+
if (opts.hooks && !process.stdin.isTTY && !opts.force) {
|
|
2004
|
+
process.stderr.write(
|
|
2005
|
+
errorBlock(
|
|
2006
|
+
"--hooks in a non-TTY environment requires --force to confirm",
|
|
2007
|
+
["Would install a git hook unattended."]
|
|
2008
|
+
)
|
|
2009
|
+
);
|
|
2010
|
+
process.exitCode = 1;
|
|
2011
|
+
return;
|
|
2012
|
+
}
|
|
2013
|
+
const result = await runSetup({
|
|
2014
|
+
cwd: process.cwd(),
|
|
2015
|
+
force: opts.force,
|
|
2016
|
+
hooks: opts.hooks,
|
|
2017
|
+
json: opts.json
|
|
2018
|
+
});
|
|
2019
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
2020
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
2021
|
+
process.exitCode = result.exitCode;
|
|
2022
|
+
} catch (err) {
|
|
2023
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2024
|
+
process.stderr.write(formatCliError(message));
|
|
2025
|
+
process.exitCode = 1;
|
|
2026
|
+
}
|
|
2027
|
+
});
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
// src/commands/run.ts
|
|
2031
|
+
import { existsSync as existsSync5 } from "fs";
|
|
2032
|
+
import { isAbsolute as isAbsolute3, resolve as resolve5, sep as sep2 } from "path";
|
|
2033
|
+
import { spawn } from "child_process";
|
|
2034
|
+
import "commander";
|
|
2035
|
+
function resolveRunCommand(opts) {
|
|
2036
|
+
const runner = opts.runner ?? "vitest";
|
|
2037
|
+
switch (runner) {
|
|
2038
|
+
case "vitest":
|
|
2039
|
+
return {
|
|
2040
|
+
command: "npx",
|
|
2041
|
+
args: [
|
|
2042
|
+
"vitest",
|
|
2043
|
+
"run",
|
|
2044
|
+
"--coverage",
|
|
2045
|
+
"--coverage.reportOnFailure",
|
|
2046
|
+
...opts.extraArgs
|
|
2047
|
+
]
|
|
2048
|
+
};
|
|
2049
|
+
case "jest":
|
|
2050
|
+
return {
|
|
2051
|
+
command: "npx",
|
|
2052
|
+
args: ["jest", "--coverage", ...opts.extraArgs]
|
|
2053
|
+
};
|
|
2054
|
+
case "pytest":
|
|
2055
|
+
return {
|
|
2056
|
+
command: "python",
|
|
2057
|
+
args: ["-m", "pytest", "--cov", ...opts.extraArgs]
|
|
2058
|
+
};
|
|
2059
|
+
default: {
|
|
2060
|
+
const _exhaustive = runner;
|
|
2061
|
+
void _exhaustive;
|
|
2062
|
+
throw new Error(
|
|
2063
|
+
`Unsupported runner: ${String(runner)}. Supported: vitest, jest, pytest`
|
|
2064
|
+
);
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
function shouldEnforceSafeRun(opts) {
|
|
2069
|
+
const env = opts?.env ?? process.env;
|
|
2070
|
+
const safe = env.TESTED_SAFE_RUN;
|
|
2071
|
+
if (safe === "1" || safe === "true") return true;
|
|
2072
|
+
if (safe === "0" || safe === "false") {
|
|
2073
|
+
}
|
|
2074
|
+
const ci = env.CI;
|
|
2075
|
+
if (ci === "1" || ci === "true") return true;
|
|
2076
|
+
if (env.GITHUB_ACTIONS === "true" || env.GITLAB_CI === "true") return true;
|
|
2077
|
+
const isTTY = opts?.isTTY ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
2078
|
+
if (!isTTY) return true;
|
|
2079
|
+
return false;
|
|
2080
|
+
}
|
|
2081
|
+
function configPathEscapesRoot(configPath, repoRoot) {
|
|
2082
|
+
const root = resolve5(repoRoot);
|
|
2083
|
+
const abs = isAbsolute3(configPath) ? resolve5(configPath) : resolve5(repoRoot, configPath);
|
|
2084
|
+
const safeRoot = root.endsWith(sep2) ? root : root + sep2;
|
|
2085
|
+
return !(abs === root || abs.startsWith(safeRoot));
|
|
2086
|
+
}
|
|
2087
|
+
function assertSafeRunArgs(extraArgs, repoRoot) {
|
|
2088
|
+
for (let i = 0; i < extraArgs.length; i++) {
|
|
2089
|
+
const a = extraArgs[i];
|
|
2090
|
+
if (a === "--watch" || a === "--watchAll" || a === "-w" || a.startsWith("--watch=") || a.startsWith("--watchAll=")) {
|
|
2091
|
+
throw new Error(
|
|
2092
|
+
`unsafe run arg rejected in non-interactive/CI mode: ${a} (watch mode can hang pipelines; unset TESTED_SAFE_RUN only in interactive use)`
|
|
2093
|
+
);
|
|
2094
|
+
}
|
|
2095
|
+
let configPath;
|
|
2096
|
+
if (a === "--config" || a === "-c") {
|
|
2097
|
+
configPath = extraArgs[i + 1];
|
|
2098
|
+
} else if (a.startsWith("--config=")) {
|
|
2099
|
+
configPath = a.slice("--config=".length);
|
|
2100
|
+
}
|
|
2101
|
+
if (configPath !== void 0) {
|
|
2102
|
+
if (!configPath || configPath.startsWith("-")) {
|
|
2103
|
+
throw new Error(
|
|
2104
|
+
`unsafe run arg rejected: --config requires a path under the repository root`
|
|
2105
|
+
);
|
|
2106
|
+
}
|
|
2107
|
+
if (configPathEscapesRoot(configPath, repoRoot)) {
|
|
2108
|
+
throw new Error(
|
|
2109
|
+
`unsafe run arg rejected: --config path escapes repository root: ${configPath}`
|
|
2110
|
+
);
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
function registerRunCommand(program2) {
|
|
2116
|
+
program2.command("run").description(
|
|
2117
|
+
"Run the user's test suite with coverage enabled (runner read from .tested.yaml; defaults to vitest)"
|
|
2118
|
+
).allowUnknownOption(true).argument("[args...]", "Extra arguments forwarded to the runner").action(async (extraArgs) => {
|
|
2119
|
+
const cwd = process.cwd();
|
|
2120
|
+
try {
|
|
2121
|
+
if (shouldEnforceSafeRun()) {
|
|
2122
|
+
assertSafeRunArgs(extraArgs, cwd);
|
|
2123
|
+
}
|
|
2124
|
+
} catch (err) {
|
|
2125
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2126
|
+
process.stderr.write(errorBlock(message));
|
|
2127
|
+
process.exitCode = 1;
|
|
2128
|
+
return;
|
|
2129
|
+
}
|
|
2130
|
+
const config = await loadConfig({ cwd });
|
|
2131
|
+
const coveragePath = resolve5(cwd, config.coverage.path);
|
|
2132
|
+
const { command, args } = resolveRunCommand({
|
|
2133
|
+
runner: config.testRunner,
|
|
2134
|
+
extraArgs
|
|
2135
|
+
});
|
|
2136
|
+
process.stderr.write(heading("tested.dev \u2014 running tests with coverage") + "\n");
|
|
2137
|
+
process.stderr.write(dim(`${command} ${args.join(" ")}`) + "\n\n");
|
|
2138
|
+
const child = spawn(command, args, { stdio: "inherit" });
|
|
2139
|
+
child.on("exit", (code) => {
|
|
2140
|
+
const exit = code ?? 1;
|
|
2141
|
+
const coverageWritten = existsSync5(coveragePath);
|
|
2142
|
+
if (exit === 0) {
|
|
2143
|
+
process.stderr.write("\n");
|
|
2144
|
+
process.stderr.write(tip("tested diff") + "\n");
|
|
2145
|
+
process.stderr.write(tip("tested check") + "\n");
|
|
2146
|
+
} else {
|
|
2147
|
+
process.stderr.write("\n");
|
|
2148
|
+
process.stderr.write(
|
|
2149
|
+
dim(
|
|
2150
|
+
coverageWritten ? `tests failed (exit ${exit}); coverage still written to ${config.coverage.path}` : `tests failed (exit ${exit}); no coverage file at ${config.coverage.path}`
|
|
2151
|
+
) + "\n"
|
|
2152
|
+
);
|
|
2153
|
+
}
|
|
2154
|
+
process.exit(exit);
|
|
2155
|
+
});
|
|
2156
|
+
});
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
// src/commands/diff.ts
|
|
2160
|
+
import "commander";
|
|
2161
|
+
|
|
2162
|
+
// src/output/human.ts
|
|
2163
|
+
function formatRange(r) {
|
|
2164
|
+
return r.start === r.end ? `${r.start}` : `${r.start}-${r.end}`;
|
|
2165
|
+
}
|
|
2166
|
+
function formatRangeList(ranges, maxWidth = 56) {
|
|
2167
|
+
if (ranges.length === 0) return "";
|
|
2168
|
+
const parts = ranges.map(formatRange);
|
|
2169
|
+
const lines = [];
|
|
2170
|
+
let current = "";
|
|
2171
|
+
for (const part of parts) {
|
|
2172
|
+
if (!current) {
|
|
2173
|
+
current = part;
|
|
2174
|
+
continue;
|
|
2175
|
+
}
|
|
2176
|
+
if (current.length + 2 + part.length > maxWidth) {
|
|
2177
|
+
lines.push(current);
|
|
2178
|
+
current = part;
|
|
2179
|
+
} else {
|
|
2180
|
+
current += `, ${part}`;
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
if (current) lines.push(current);
|
|
2184
|
+
return lines.join(",\n" + " ".repeat(13));
|
|
2185
|
+
}
|
|
2186
|
+
function formatDelta(delta) {
|
|
2187
|
+
if (delta === null) return "";
|
|
2188
|
+
const sign = delta > 0 ? "+" : "";
|
|
2189
|
+
return dim(` delta ${sign}${delta.toFixed(1)}%`);
|
|
2190
|
+
}
|
|
2191
|
+
function coloredPctCell(pct3, width = 6) {
|
|
2192
|
+
const plain = `${pct3.toFixed(1)}%`;
|
|
2193
|
+
const pad = Math.max(0, width - plain.length);
|
|
2194
|
+
return colorPct(pct3) + " ".repeat(pad);
|
|
2195
|
+
}
|
|
2196
|
+
function formatMetricRow(label, pct3, covered, executable, emptyNote) {
|
|
2197
|
+
const labelPad = label.padEnd(8);
|
|
2198
|
+
if (executable === 0) {
|
|
2199
|
+
const note = emptyNote ?? "no executable lines";
|
|
2200
|
+
return ` ${labelPad} ${dim("-".padEnd(6))} ${dim(note)}`;
|
|
2201
|
+
}
|
|
2202
|
+
const pctStr = coloredPctCell(pct3);
|
|
2203
|
+
const bar = metricBar(pct3);
|
|
2204
|
+
const counts = dim(`${covered}/${executable}`);
|
|
2205
|
+
return ` ${labelPad} ${pctStr} ${bar} ${counts}`;
|
|
2206
|
+
}
|
|
2207
|
+
function formatHuman(out, opts = {}) {
|
|
2208
|
+
const tips = opts.tips !== false;
|
|
2209
|
+
const lines = [];
|
|
2210
|
+
lines.push(heading("tested.dev \u2014 coverage report"));
|
|
2211
|
+
lines.push(dim(`Base: ${out.base} Head: ${out.head.slice(0, 7)}`));
|
|
2212
|
+
lines.push("");
|
|
2213
|
+
lines.push(
|
|
2214
|
+
formatMetricRow(
|
|
2215
|
+
"Patch",
|
|
2216
|
+
out.patch.pct,
|
|
2217
|
+
out.patch.covered,
|
|
2218
|
+
out.patch.executable,
|
|
2219
|
+
"no executable lines in patch"
|
|
2220
|
+
)
|
|
2221
|
+
);
|
|
2222
|
+
const projectRow = formatMetricRow(
|
|
2223
|
+
"Project",
|
|
2224
|
+
out.project.pct,
|
|
2225
|
+
out.project.covered,
|
|
2226
|
+
out.project.executable
|
|
2227
|
+
);
|
|
2228
|
+
lines.push(projectRow + formatDelta(out.project.delta));
|
|
2229
|
+
if (opts.thresholds) {
|
|
2230
|
+
const patchPass = out.patch.pct >= opts.thresholds.patch;
|
|
2231
|
+
const projectPass = out.project.pct >= opts.thresholds.project;
|
|
2232
|
+
const patchOk = out.patch.executable === 0 ? true : patchPass;
|
|
2233
|
+
const overall = patchOk && projectPass;
|
|
2234
|
+
const details = [];
|
|
2235
|
+
if (!patchOk) {
|
|
2236
|
+
details.push(`patch ${out.patch.pct.toFixed(1)}% < ${opts.thresholds.patch}%`);
|
|
2237
|
+
}
|
|
2238
|
+
if (!projectPass) {
|
|
2239
|
+
details.push(
|
|
2240
|
+
`project ${out.project.pct.toFixed(1)}% < ${opts.thresholds.project}%`
|
|
2241
|
+
);
|
|
2242
|
+
}
|
|
2243
|
+
const detail = details.length > 0 ? dim(` ${details.join("; ")}`) : "";
|
|
2244
|
+
lines.push(
|
|
2245
|
+
` ${"Gate".padEnd(8)} ${overall ? badge("pass") : badge("fail")}${detail}`
|
|
2246
|
+
);
|
|
2247
|
+
if (!overall) {
|
|
2248
|
+
lines.push("");
|
|
2249
|
+
lines.push(
|
|
2250
|
+
tip(
|
|
2251
|
+
`tested check would FAIL (${details.join("; ")}). Diff exits 0; check is the gate.`
|
|
2252
|
+
)
|
|
2253
|
+
);
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
if (out.files.length > 0) {
|
|
2257
|
+
lines.push("");
|
|
2258
|
+
lines.push(heading("Files in diff:"));
|
|
2259
|
+
const anyPatch = out.files.some((f) => f.patchCoverage !== null);
|
|
2260
|
+
if (!anyPatch) {
|
|
2261
|
+
lines.push(dim(" (project coverage \u2014 no executable lines in patch)"));
|
|
2262
|
+
}
|
|
2263
|
+
for (const f of out.files) {
|
|
2264
|
+
const hasPatch = f.patchCoverage !== null;
|
|
2265
|
+
const pctPart = hasPatch ? coloredPctCell(f.patchCoverage) : coloredPctCell(f.projectCoverage);
|
|
2266
|
+
lines.push(` ${pctPart} ${f.path}`);
|
|
2267
|
+
if (f.uncoveredRanges.length > 0) {
|
|
2268
|
+
const ranges = formatRangeList(f.uncoveredRanges);
|
|
2269
|
+
lines.push(dim(` uncovered: ${ranges}`));
|
|
2270
|
+
} else if (hasPatch) {
|
|
2271
|
+
lines.push(dim(" fully covered in patch"));
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
if (out.ignored.length > 0) {
|
|
2276
|
+
lines.push("");
|
|
2277
|
+
lines.push(dim(`Ignored: ${out.ignored.length} patterns`));
|
|
2278
|
+
}
|
|
2279
|
+
if (tips) {
|
|
2280
|
+
lines.push("");
|
|
2281
|
+
if (!opts.thresholds) {
|
|
2282
|
+
lines.push(tip("tested check (enforce thresholds)"));
|
|
2283
|
+
}
|
|
2284
|
+
lines.push(tip("tested push --pr <n> (share on tested.dev)"));
|
|
2285
|
+
}
|
|
2286
|
+
return lines.join("\n");
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
// src/commands/diff.ts
|
|
2290
|
+
function registerDiffCommand(program2) {
|
|
2291
|
+
program2.command("diff").description("Compute patch + project coverage against a base ref").option("--base <ref>", "Git base ref to diff against", void 0).option("--with-base-coverage <path>", "Compare project coverage against a baseline JSON", void 0).option("--json", "Emit schema-v1 JSON instead of human text", false).action(async (opts) => {
|
|
2292
|
+
try {
|
|
2293
|
+
const cwd = process.cwd();
|
|
2294
|
+
const config = await loadConfig({ cwd });
|
|
2295
|
+
const output = await computeDiff({
|
|
2296
|
+
cwd,
|
|
2297
|
+
config,
|
|
2298
|
+
...opts.base !== void 0 ? { baseRef: opts.base } : {},
|
|
2299
|
+
...opts.withBaseCoverage !== void 0 ? { withBaseCoverage: opts.withBaseCoverage } : {}
|
|
2300
|
+
});
|
|
2301
|
+
if (opts.json) {
|
|
2302
|
+
process.stdout.write(JSON.stringify(output, null, 2) + "\n");
|
|
2303
|
+
} else {
|
|
2304
|
+
process.stdout.write(
|
|
2305
|
+
formatHuman(output, {
|
|
2306
|
+
...config.thresholds ? { thresholds: config.thresholds } : {},
|
|
2307
|
+
tips: true
|
|
2308
|
+
}) + "\n"
|
|
2309
|
+
);
|
|
2310
|
+
}
|
|
2311
|
+
} catch (err) {
|
|
2312
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2313
|
+
process.stderr.write(formatCliError(message));
|
|
2314
|
+
process.exitCode = 1;
|
|
2315
|
+
}
|
|
2316
|
+
});
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
// src/commands/check.ts
|
|
2320
|
+
import "commander";
|
|
2321
|
+
function formatMetricLine(label, pct3, threshold, pass) {
|
|
2322
|
+
const pctStr = pct3.toFixed(1);
|
|
2323
|
+
const status = pass ? badge("pass") : badge("fail");
|
|
2324
|
+
return ` ${label.padEnd(8)} ${pctStr}% (threshold ${threshold}) ${status}`;
|
|
2325
|
+
}
|
|
2326
|
+
function runCheck(input) {
|
|
2327
|
+
const { config, diff, json } = input;
|
|
2328
|
+
if (!config.thresholds) {
|
|
2329
|
+
return {
|
|
2330
|
+
skipped: true,
|
|
2331
|
+
patchPass: true,
|
|
2332
|
+
projectPass: true,
|
|
2333
|
+
overall: "pass",
|
|
2334
|
+
stdout: "",
|
|
2335
|
+
stderr: `${dim("tested.dev \u2014 coverage gate")} ${badge("info")}
|
|
2336
|
+
|
|
2337
|
+
${dim(" no thresholds in .tested.yaml \u2014 gate skipped")}
|
|
2338
|
+
${tip("add thresholds.patch / thresholds.project to enforce")}
|
|
2339
|
+
`,
|
|
2340
|
+
exitCode: 0
|
|
2341
|
+
};
|
|
2342
|
+
}
|
|
2343
|
+
const patchPct = diff.patch.pct;
|
|
2344
|
+
const projectPct = diff.project.pct;
|
|
2345
|
+
const patchThreshold = config.thresholds.patch;
|
|
2346
|
+
const projectThreshold = config.thresholds.project;
|
|
2347
|
+
const patchSkipped = diff.patch.executable === 0;
|
|
2348
|
+
const patchPass = patchSkipped ? true : patchPct >= patchThreshold;
|
|
2349
|
+
const projectPass = projectPct >= projectThreshold;
|
|
2350
|
+
const overall = patchPass && projectPass ? "pass" : "fail";
|
|
2351
|
+
const exitCode = overall === "pass" ? 0 : 1;
|
|
2352
|
+
if (json) {
|
|
2353
|
+
const payload = {
|
|
2354
|
+
patch: {
|
|
2355
|
+
pct: patchPct,
|
|
2356
|
+
threshold: patchThreshold,
|
|
2357
|
+
pass: patchPass,
|
|
2358
|
+
...patchSkipped ? { skipped: true } : {}
|
|
2359
|
+
},
|
|
2360
|
+
project: { pct: projectPct, threshold: projectThreshold, pass: projectPass },
|
|
2361
|
+
overall
|
|
2362
|
+
};
|
|
2363
|
+
return {
|
|
2364
|
+
skipped: false,
|
|
2365
|
+
patchPass,
|
|
2366
|
+
projectPass,
|
|
2367
|
+
overall,
|
|
2368
|
+
stdout: JSON.stringify(payload) + "\n",
|
|
2369
|
+
stderr: "",
|
|
2370
|
+
exitCode
|
|
2371
|
+
};
|
|
2372
|
+
}
|
|
2373
|
+
const lines = [];
|
|
2374
|
+
lines.push(
|
|
2375
|
+
`${heading("tested.dev \u2014 coverage gate")} ${overall === "pass" ? badge("pass") : badge("fail")}`
|
|
2376
|
+
);
|
|
2377
|
+
lines.push("");
|
|
2378
|
+
if (patchSkipped) {
|
|
2379
|
+
lines.push(
|
|
2380
|
+
` ${"Patch".padEnd(8)} ${dim("-")} ${dim("(no executable lines \u2014 skipped)")} ${badge("info")}`
|
|
2381
|
+
);
|
|
2382
|
+
} else {
|
|
2383
|
+
lines.push(formatMetricLine("Patch", patchPct, patchThreshold, patchPass));
|
|
2384
|
+
}
|
|
2385
|
+
lines.push(formatMetricLine("Project", projectPct, projectThreshold, projectPass));
|
|
2386
|
+
if (overall === "fail") {
|
|
2387
|
+
lines.push("");
|
|
2388
|
+
lines.push(tip("add tests for uncovered ranges: tested diff"));
|
|
2389
|
+
} else {
|
|
2390
|
+
lines.push("");
|
|
2391
|
+
lines.push(dim(patchSkipped ? "project thresholds met (patch skipped)" : "thresholds met"));
|
|
2392
|
+
}
|
|
2393
|
+
lines.push("");
|
|
2394
|
+
return {
|
|
2395
|
+
skipped: false,
|
|
2396
|
+
patchPass,
|
|
2397
|
+
projectPass,
|
|
2398
|
+
overall,
|
|
2399
|
+
stdout: lines.join("\n"),
|
|
2400
|
+
stderr: "",
|
|
2401
|
+
exitCode
|
|
2402
|
+
};
|
|
2403
|
+
}
|
|
2404
|
+
function registerCheckCommand(program2) {
|
|
2405
|
+
program2.command("check").description(
|
|
2406
|
+
"Exit non-zero if patch or project coverage falls below configured thresholds."
|
|
2407
|
+
).option("--json", "Emit machine-readable JSON to stdout (exit code unchanged).", false).option("--base <ref>", "Git base ref to diff against", void 0).action(async (opts) => {
|
|
2408
|
+
try {
|
|
2409
|
+
const cwd = process.cwd();
|
|
2410
|
+
const config = await loadConfig({ cwd });
|
|
2411
|
+
if (!config.thresholds) {
|
|
2412
|
+
const result2 = runCheck({
|
|
2413
|
+
config,
|
|
2414
|
+
// diff value is unused in the skip path; pass a stub.
|
|
2415
|
+
diff: {
|
|
2416
|
+
schemaVersion: 1,
|
|
2417
|
+
base: "",
|
|
2418
|
+
head: "",
|
|
2419
|
+
patch: { executable: 0, covered: 0, pct: 0 },
|
|
2420
|
+
project: { executable: 0, covered: 0, pct: 0, delta: null },
|
|
2421
|
+
files: [],
|
|
2422
|
+
ignored: []
|
|
2423
|
+
},
|
|
2424
|
+
json: opts.json
|
|
2425
|
+
});
|
|
2426
|
+
if (result2.stderr) process.stderr.write(result2.stderr);
|
|
2427
|
+
if (result2.stdout) process.stdout.write(result2.stdout);
|
|
2428
|
+
process.exitCode = result2.exitCode;
|
|
2429
|
+
return;
|
|
2430
|
+
}
|
|
2431
|
+
const diff = await computeDiff({
|
|
2432
|
+
cwd,
|
|
2433
|
+
config,
|
|
2434
|
+
...opts.base !== void 0 ? { baseRef: opts.base } : {}
|
|
2435
|
+
});
|
|
2436
|
+
const result = runCheck({ config, diff, json: opts.json });
|
|
2437
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
2438
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
2439
|
+
process.exitCode = result.exitCode;
|
|
2440
|
+
} catch (err) {
|
|
2441
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2442
|
+
process.stderr.write(formatCliError(message));
|
|
2443
|
+
process.exitCode = 1;
|
|
2444
|
+
}
|
|
2445
|
+
});
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
// src/commands/explain.ts
|
|
2449
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
2450
|
+
import { resolve as resolve6 } from "path";
|
|
2451
|
+
import "commander";
|
|
2452
|
+
function parseLocation(input) {
|
|
2453
|
+
const idx = input.lastIndexOf(":");
|
|
2454
|
+
if (idx < 0) throw new Error(`expected <file>:<line>, got ${input}`);
|
|
2455
|
+
const path = input.slice(0, idx);
|
|
2456
|
+
const line = Number(input.slice(idx + 1));
|
|
2457
|
+
if (!Number.isInteger(line) || line <= 0) {
|
|
2458
|
+
throw new Error(`expected <file>:<line>, got ${input}`);
|
|
2459
|
+
}
|
|
2460
|
+
return { path, line };
|
|
2461
|
+
}
|
|
2462
|
+
function explainAt(file, line, sourceLines) {
|
|
2463
|
+
const stmt = file.statements.find((s) => line >= s.startLine && line <= s.endLine);
|
|
2464
|
+
const excerptStart = Math.max(1, line - 2);
|
|
2465
|
+
const excerptEnd = Math.min(sourceLines.length, line + 2);
|
|
2466
|
+
const excerpt = sourceLines.slice(excerptStart - 1, excerptEnd + 1).map((text, i) => `${excerptStart + i} ${text}`).join("\n");
|
|
2467
|
+
if (!stmt) {
|
|
2468
|
+
return {
|
|
2469
|
+
path: file.path,
|
|
2470
|
+
line,
|
|
2471
|
+
uncovered: false,
|
|
2472
|
+
reason: `no executable statement on line ${line}`,
|
|
2473
|
+
codeExcerpt: excerpt
|
|
2474
|
+
};
|
|
2475
|
+
}
|
|
2476
|
+
if (stmt.hits === 0) {
|
|
2477
|
+
return {
|
|
2478
|
+
path: file.path,
|
|
2479
|
+
line,
|
|
2480
|
+
uncovered: true,
|
|
2481
|
+
reason: `no test exercises line ${line}`,
|
|
2482
|
+
codeExcerpt: excerpt
|
|
2483
|
+
};
|
|
2484
|
+
}
|
|
2485
|
+
return {
|
|
2486
|
+
path: file.path,
|
|
2487
|
+
line,
|
|
2488
|
+
uncovered: false,
|
|
2489
|
+
reason: `hit ${stmt.hits} time${stmt.hits === 1 ? "" : "s"}`,
|
|
2490
|
+
codeExcerpt: excerpt
|
|
2491
|
+
};
|
|
2492
|
+
}
|
|
2493
|
+
function formatExplainHuman(result) {
|
|
2494
|
+
const status = result.uncovered ? badge("fail") : badge("pass");
|
|
2495
|
+
const statusLabel = result.uncovered ? "UNCOVERED" : "covered";
|
|
2496
|
+
const lines = [];
|
|
2497
|
+
lines.push(heading("tested.dev \u2014 explain"));
|
|
2498
|
+
lines.push(`${result.path}:${result.line} ${status} ${statusLabel}`);
|
|
2499
|
+
lines.push(dim(result.reason));
|
|
2500
|
+
lines.push("");
|
|
2501
|
+
lines.push(result.codeExcerpt);
|
|
2502
|
+
return lines.join("\n");
|
|
2503
|
+
}
|
|
2504
|
+
function registerExplainCommand(program2) {
|
|
2505
|
+
program2.command("explain").description("Explain coverage at <file>:<line>").argument("<location>", "Location in the form path/to/file.ts:42").option("--json", "Emit JSON instead of human text", false).action(async (location, opts) => {
|
|
2506
|
+
try {
|
|
2507
|
+
const cwd = process.cwd();
|
|
2508
|
+
const { path: relPath, line } = parseLocation(location);
|
|
2509
|
+
const config = await loadConfig({ cwd });
|
|
2510
|
+
const ctx = await openRepo(cwd);
|
|
2511
|
+
const coveragePath = resolve6(cwd, config.coverage.path);
|
|
2512
|
+
assertWithinRoot(ctx.repoRoot, coveragePath);
|
|
2513
|
+
const files = await parseIstanbul({ path: coveragePath, repoRoot: ctx.repoRoot });
|
|
2514
|
+
const file = files.find((f) => f.path === relPath);
|
|
2515
|
+
if (!file) {
|
|
2516
|
+
process.stderr.write(`error: no coverage data for ${relPath}
|
|
2517
|
+
`);
|
|
2518
|
+
process.exitCode = 2;
|
|
2519
|
+
return;
|
|
2520
|
+
}
|
|
2521
|
+
const resolvedSource = resolve6(ctx.repoRoot, relPath);
|
|
2522
|
+
assertWithinRoot(ctx.repoRoot, resolvedSource);
|
|
2523
|
+
const source = await readFile3(resolvedSource, "utf8");
|
|
2524
|
+
const sourceLines = source.split("\n");
|
|
2525
|
+
const result = explainAt(file, line, sourceLines);
|
|
2526
|
+
if (opts.json) {
|
|
2527
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
2528
|
+
} else {
|
|
2529
|
+
process.stdout.write(formatExplainHuman(result) + "\n");
|
|
2530
|
+
}
|
|
2531
|
+
} catch (err) {
|
|
2532
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2533
|
+
process.stderr.write(`error: ${message}
|
|
2534
|
+
`);
|
|
2535
|
+
process.exitCode = 1;
|
|
2536
|
+
}
|
|
2537
|
+
});
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
// src/commands/ignores.ts
|
|
2541
|
+
import "commander";
|
|
2542
|
+
function formatIgnoresList(patterns, asJson) {
|
|
2543
|
+
if (asJson) return JSON.stringify({ ignores: [...patterns] });
|
|
2544
|
+
return patterns.join("\n");
|
|
2545
|
+
}
|
|
2546
|
+
function registerIgnoresCommand(program2) {
|
|
2547
|
+
const cmd = program2.command("ignores").description("Inspect the canonical ignore list");
|
|
2548
|
+
cmd.command("list").description("Print all ignore patterns (defaults + user)").option("--json", "Emit JSON", false).action(async (opts) => {
|
|
2549
|
+
const config = await loadConfig({ cwd: process.cwd() });
|
|
2550
|
+
process.stdout.write(formatIgnoresList(config.ignores, opts.json) + "\n");
|
|
2551
|
+
});
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
// src/cli.ts
|
|
2555
|
+
function createProgram() {
|
|
2556
|
+
const program2 = new Command10();
|
|
2557
|
+
program2.name("tested").description(
|
|
2558
|
+
[
|
|
2559
|
+
"Coverage your agent can use.",
|
|
2560
|
+
"",
|
|
2561
|
+
"Agent loop:",
|
|
2562
|
+
" tested setup \u2192 tested run \u2192 tested diff \u2192 tested check \u2192 tested push --pr <n>"
|
|
2563
|
+
].join("\n")
|
|
2564
|
+
).version("0.1.0");
|
|
2565
|
+
registerSetupCommand(program2);
|
|
2566
|
+
registerDoctorCommand(program2);
|
|
2567
|
+
registerInitCommand(program2);
|
|
2568
|
+
registerRunCommand(program2);
|
|
2569
|
+
registerDiffCommand(program2);
|
|
2570
|
+
registerCheckCommand(program2);
|
|
2571
|
+
registerPushCommand(program2);
|
|
2572
|
+
registerExplainCommand(program2);
|
|
2573
|
+
registerIgnoresCommand(program2);
|
|
2574
|
+
return program2;
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
// bin/tested.ts
|
|
2578
|
+
var program = createProgram();
|
|
2579
|
+
await program.parseAsync(process.argv);
|