@glubean/cli 0.2.5 → 0.3.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/README.md +22 -2
- package/dist/commands/contracts.d.ts.map +1 -1
- package/dist/commands/contracts.js +28 -4
- package/dist/commands/contracts.js.map +1 -1
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +267 -60
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/migrate.d.ts +31 -0
- package/dist/commands/migrate.d.ts.map +1 -0
- package/dist/commands/migrate.js +516 -0
- package/dist/commands/migrate.js.map +1 -0
- package/dist/commands/redact.d.ts.map +1 -1
- package/dist/commands/redact.js +32 -8
- package/dist/commands/redact.js.map +1 -1
- package/dist/commands/run.d.ts +110 -2
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +493 -43
- package/dist/commands/run.js.map +1 -1
- package/dist/lib/config.d.ts +267 -43
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +744 -149
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/env.d.ts +29 -0
- package/dist/lib/env.d.ts.map +1 -0
- package/dist/lib/env.js +59 -0
- package/dist/lib/env.js.map +1 -0
- package/dist/lib/print-plan.d.ts +21 -0
- package/dist/lib/print-plan.d.ts.map +1 -0
- package/dist/lib/print-plan.js +108 -0
- package/dist/lib/print-plan.js.map +1 -0
- package/dist/lib/upload.d.ts +36 -1
- package/dist/lib/upload.d.ts.map +1 -1
- package/dist/lib/upload.js +126 -19
- package/dist/lib/upload.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +420 -27
- package/dist/main.js.map +1 -1
- package/package.json +15 -6
- package/templates/README.md +7 -13
- package/templates/demo/.env.example +7 -0
- package/templates/demo/.env.secrets.example +11 -0
- package/templates/demo/README.md +60 -0
- package/templates/demo/config/api.ts +24 -0
- package/templates/demo/gitignore.tpl +13 -0
- package/templates/demo/glubean.yaml +48 -0
- package/templates/demo/tests/api-flaky/search-flaky.test.ts +28 -0
- package/templates/demo/tests/api-stable/get-users.test.ts +30 -0
- package/templates/demo/tests/canary/synthetic-50pct-flaky.test.ts +23 -0
- package/templates/demo/tests/contracts/stable/users-contract.contract.ts +70 -0
- package/templates/demo/tsconfig.json +15 -0
- package/templates/AI-INSTRUCTIONS.md +0 -160
- package/templates/ci-config/ci.yaml +0 -13
- package/templates/ci-config/default.yaml +0 -9
- package/templates/ci-config/explore.yaml +0 -5
- package/templates/ci-config/staging.yaml +0 -9
package/dist/commands/run.js
CHANGED
|
@@ -2,13 +2,13 @@ import { bootstrap, evaluateThresholds, MetricCollector, ProjectRunner, buildRun
|
|
|
2
2
|
import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
|
|
3
3
|
import { stat, readdir, readFile, writeFile, mkdir, rm } from "node:fs/promises";
|
|
4
4
|
import { glob } from "node:fs/promises";
|
|
5
|
-
import {
|
|
5
|
+
import { CONFIG_DEFAULTS, mergeRunOptions, toSharedRunConfig } from "../lib/config.js";
|
|
6
6
|
import { loadProjectEnv } from "@glubean/runner";
|
|
7
7
|
import { resolveEnvFileName } from "../lib/active_env.js";
|
|
8
8
|
import { shouldSkipTest } from "../lib/skip.js";
|
|
9
9
|
import { CLI_VERSION } from "../version.js";
|
|
10
10
|
import { extractContractCases, extractFromSource } from "@glubean/scanner/static";
|
|
11
|
-
import { extractContractFromFile, loadProjectOverlays } from "@glubean/scanner";
|
|
11
|
+
import { extractContractFromFile, findTemplateMatch, loadProjectOverlays, matchesTemplateFilter, } from "@glubean/scanner";
|
|
12
12
|
import { applyEnvTemplating } from "@glubean/runner";
|
|
13
13
|
// ANSI color codes for pretty output
|
|
14
14
|
const colors = {
|
|
@@ -49,6 +49,22 @@ async function findProjectConfig(startDir) {
|
|
|
49
49
|
// No glubean project found — use the starting directory (scratch mode)
|
|
50
50
|
return { rootDir: startDir };
|
|
51
51
|
}
|
|
52
|
+
// Config consolidation (docs/06): the package.json `glubean` field is no
|
|
53
|
+
// longer a config source. Warn (don't error) when one lingers so users
|
|
54
|
+
// migrate it into glubean.yaml instead of wondering why it stopped working.
|
|
55
|
+
async function warnIfLegacyPackageJsonConfig(rootDir) {
|
|
56
|
+
try {
|
|
57
|
+
const pkg = JSON.parse(await readFile(resolve(rootDir, "package.json"), "utf-8"));
|
|
58
|
+
if (pkg.glubean && typeof pkg.glubean === "object") {
|
|
59
|
+
console.warn(`\x1b[33mWarning: the package.json \`glubean\` field is no longer read ` +
|
|
60
|
+
`(config consolidation — see docs/06). Move run/redaction/thresholds ` +
|
|
61
|
+
`settings into glubean.yaml; the field is currently inert.\x1b[0m`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// No package.json or parse error — nothing to warn about.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
52
68
|
const DEFAULT_SKIP_DIRS = ["node_modules", ".git", "dist", "build"];
|
|
53
69
|
const DEFAULT_EXTENSIONS = ["ts"];
|
|
54
70
|
function isGlob(target) {
|
|
@@ -86,7 +102,18 @@ async function walkTestFiles(dir, result) {
|
|
|
86
102
|
}
|
|
87
103
|
}
|
|
88
104
|
}
|
|
89
|
-
|
|
105
|
+
export function classifyGlubeanFile(filePath) {
|
|
106
|
+
if (filePath.endsWith(".test.ts"))
|
|
107
|
+
return "test";
|
|
108
|
+
if (filePath.endsWith(".contract.ts"))
|
|
109
|
+
return "contract";
|
|
110
|
+
if (filePath.endsWith(".flow.ts"))
|
|
111
|
+
return "flow";
|
|
112
|
+
if (filePath.endsWith(".bootstrap.ts"))
|
|
113
|
+
return "bootstrap";
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
async function resolveSingleTarget(target) {
|
|
90
117
|
const abs = resolve(target);
|
|
91
118
|
try {
|
|
92
119
|
const s = await stat(abs);
|
|
@@ -117,6 +144,80 @@ async function resolveTestFiles(target) {
|
|
|
117
144
|
}
|
|
118
145
|
return [abs];
|
|
119
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* Resolve one or more targets (file / dir / glob) to a deduped list of
|
|
149
|
+
* test file paths. Phase 4 multi-suite execution passes a per-suite
|
|
150
|
+
* array here so the runner can sweep all suites in a single pass with
|
|
151
|
+
* unified discovery, filtering, and reporter output.
|
|
152
|
+
*/
|
|
153
|
+
async function resolveTestFiles(target) {
|
|
154
|
+
const targets = Array.isArray(target) ? target : [target];
|
|
155
|
+
const all = [];
|
|
156
|
+
for (const t of targets) {
|
|
157
|
+
const files = await resolveSingleTarget(t);
|
|
158
|
+
all.push(...files);
|
|
159
|
+
}
|
|
160
|
+
// Dedupe (suites may share a directory) while preserving the caller-
|
|
161
|
+
// supplied order. Multi-suite main.ts depends on this — sorting here
|
|
162
|
+
// would mix files across suites and break failFast/failAfter
|
|
163
|
+
// short-circuit ordering. resolveSingleTarget still sorts within a
|
|
164
|
+
// single directory walk for determinism inside one suite.
|
|
165
|
+
const seen = new Set();
|
|
166
|
+
const ordered = [];
|
|
167
|
+
for (const f of all) {
|
|
168
|
+
if (seen.has(f))
|
|
169
|
+
continue;
|
|
170
|
+
seen.add(f);
|
|
171
|
+
ordered.push(f);
|
|
172
|
+
}
|
|
173
|
+
return ordered;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Per-suite resolution helper exposed for main.ts. Resolves a suite's
|
|
177
|
+
* `target` (file / dir / glob), then keeps only files whose
|
|
178
|
+
* `classifyGlubeanFile` result is in `kinds` (.bootstrap.ts files are
|
|
179
|
+
* always kept regardless of kinds so overlay registration still fires
|
|
180
|
+
* across the project — they emit no runnable tests on their own).
|
|
181
|
+
*
|
|
182
|
+
* `kinds.length === 0` means "no kind filter" (all Glubean files).
|
|
183
|
+
*
|
|
184
|
+
* KNOWN LIMITATION (file-level only): the filter operates on the file
|
|
185
|
+
* EXTENSION, not on individual exports. A `.contract.ts` file CAN
|
|
186
|
+
* legitimately export a flow inline (and vice versa). For canonical
|
|
187
|
+
* `tests/` + `contracts/` directory layouts this doesn't matter — each
|
|
188
|
+
* file kind matches its declared suite kind. For mixed exports inside
|
|
189
|
+
* a single file (`kinds: [contract]` running a flow exported from the
|
|
190
|
+
* same .contract.ts), authors should split flows into `.flow.ts`. A
|
|
191
|
+
* proper export-level kind filter would require threading suite kinds
|
|
192
|
+
* through discoverTests and is left as a follow-up.
|
|
193
|
+
*/
|
|
194
|
+
export async function resolveTestFilesForSuite(target, kinds) {
|
|
195
|
+
const files = await resolveSingleTarget(target);
|
|
196
|
+
if (kinds.length === 0)
|
|
197
|
+
return files;
|
|
198
|
+
const kindSet = new Set(kinds);
|
|
199
|
+
// Strict per-kind file filter: `.test.ts` ↔ "test", `.contract.ts` ↔
|
|
200
|
+
// "contract", `.flow.ts` ↔ "flow". This keeps the "zero files for
|
|
201
|
+
// declared suite" error a reliable signal of misconfiguration.
|
|
202
|
+
//
|
|
203
|
+
// KNOWN LIMITATION: a `.contract.ts` file that exports ONLY a flow
|
|
204
|
+
// (uncommon — flows usually live in `.flow.ts`) won't match a
|
|
205
|
+
// `kinds: [flow]` suite at the file-level filter. To run such a flow
|
|
206
|
+
// from a strict flow-only suite, either move the export into a
|
|
207
|
+
// `.flow.ts` file (recommended canonical layout) or declare the
|
|
208
|
+
// suite as `kinds: [contract, flow]` so both candidate file types
|
|
209
|
+
// are scanned and the runnable-level filter sorts them out.
|
|
210
|
+
return files.filter((f) => {
|
|
211
|
+
const k = classifyGlubeanFile(f);
|
|
212
|
+
if (k === undefined)
|
|
213
|
+
return false;
|
|
214
|
+
// Bootstrap files: always retain so contract.bootstrap() side-effects
|
|
215
|
+
// fire on import (per attachment-model §7.4 eager loading).
|
|
216
|
+
if (k === "bootstrap")
|
|
217
|
+
return true;
|
|
218
|
+
return kindSet.has(k);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
120
221
|
export async function discoverTests(filePath) {
|
|
121
222
|
// `.bootstrap.ts` files register overlays as a side-effect of import; they
|
|
122
223
|
// produce no runnable tests of their own. We don't even need to import here
|
|
@@ -135,16 +236,35 @@ export async function discoverTests(filePath) {
|
|
|
135
236
|
const result = await extractContractFromFile(filePath);
|
|
136
237
|
const results = [];
|
|
137
238
|
for (const ec of result.contracts) {
|
|
239
|
+
const contractTags = ec.tags ?? [];
|
|
138
240
|
for (const c of ec.cases) {
|
|
241
|
+
// Mirror SDK dispatchContract: finalTags = contract + case + runtime
|
|
242
|
+
// synthetic. Without this, pre-spawn excludeTags / --tag filtering
|
|
243
|
+
// skips contract cases entirely (Phase 1 filter reads meta.tags).
|
|
244
|
+
const caseTags = c.tags ?? [];
|
|
245
|
+
const requires = c.requires ?? "headless";
|
|
246
|
+
const defaultRun = c.defaultRun ?? (requires !== "headless" ? "opt-in" : "always");
|
|
247
|
+
const runtimeTags = [];
|
|
248
|
+
if (requires !== "headless")
|
|
249
|
+
runtimeTags.push(`requires:${requires}`);
|
|
250
|
+
if (defaultRun === "opt-in")
|
|
251
|
+
runtimeTags.push("default-run:opt-in");
|
|
252
|
+
const finalTags = [...contractTags, ...caseTags, ...runtimeTags];
|
|
139
253
|
results.push({
|
|
140
254
|
exportName: ec.exportName,
|
|
141
255
|
meta: {
|
|
142
256
|
id: `${ec.id}.${c.key}`,
|
|
257
|
+
// Mirror SDK dispatchContract testName: `${contractId} — ${caseKey}`.
|
|
258
|
+
// Phase 1 matchesFilter checks meta.name; without this, --filter
|
|
259
|
+
// matches against testId only for contract cases (uneven with test()).
|
|
260
|
+
name: `${ec.id} — ${c.key}`,
|
|
143
261
|
description: c.description,
|
|
262
|
+
tags: finalTags.length > 0 ? finalTags : undefined,
|
|
144
263
|
requires: c.requires,
|
|
145
264
|
defaultRun: c.defaultRun,
|
|
146
265
|
deferred: c.deferredReason,
|
|
147
266
|
deprecated: c.deprecatedReason,
|
|
267
|
+
kind: "contract",
|
|
148
268
|
},
|
|
149
269
|
});
|
|
150
270
|
}
|
|
@@ -152,6 +272,8 @@ export async function discoverTests(filePath) {
|
|
|
152
272
|
// Each flow has a single orchestrator Test (setup → steps → teardown).
|
|
153
273
|
// Discover it as one runnable entry with the flow id. Post-Phase 2f
|
|
154
274
|
// flows live as `kind: "flow"` entries inside `result.attachments`.
|
|
275
|
+
// SDK maps FlowMeta.skip → TestMeta.deferred (string reason); mirror
|
|
276
|
+
// that here so the runner's deferred-skip path applies uniformly.
|
|
155
277
|
for (const att of result.attachments) {
|
|
156
278
|
if (att.kind !== "flow")
|
|
157
279
|
continue;
|
|
@@ -160,32 +282,58 @@ export async function discoverTests(filePath) {
|
|
|
160
282
|
meta: {
|
|
161
283
|
id: att.flow.id,
|
|
162
284
|
description: att.flow.description,
|
|
285
|
+
tags: att.flow.tags,
|
|
286
|
+
only: att.flow.only,
|
|
287
|
+
deferred: att.flow.skip,
|
|
288
|
+
kind: "flow",
|
|
163
289
|
},
|
|
164
290
|
});
|
|
165
291
|
}
|
|
166
292
|
if (results.length > 0)
|
|
167
293
|
return results;
|
|
168
|
-
// Runtime failed — fall back to static regex
|
|
294
|
+
// Runtime failed — fall back to static regex ONLY for files that
|
|
295
|
+
// contain ONLY contract.http(...). Stricter than MCP's gate: CLI
|
|
296
|
+
// emits flows as runnable tests via discoverTests, so silently
|
|
297
|
+
// dropping `contract.flow(...)` would hide an actual test. Any
|
|
298
|
+
// non-HTTP usage (including flow) → fail closed and surface the
|
|
299
|
+
// import error so the user knows discovery is degraded.
|
|
169
300
|
if (result.errors.length > 0) {
|
|
170
|
-
|
|
301
|
+
// Allow whitespace/newlines between `contract` and `.method` so the
|
|
302
|
+
// common fluent style `contract\n .flow(...)` still trips the gate.
|
|
303
|
+
const hasHttp = /contract\s*\.\s*http\b/i.test(content);
|
|
304
|
+
const hasNonHttp = /contract\s*\.\s*(?!http\b)\w+\s*[.(]/i.test(content);
|
|
305
|
+
const contracts = hasHttp && !hasNonHttp ? extractContractCases(content) : [];
|
|
171
306
|
if (contracts.length > 0) {
|
|
172
307
|
for (const c of contracts) {
|
|
173
308
|
for (const caseItem of c.cases) {
|
|
309
|
+
const requires = caseItem.requires ?? "headless";
|
|
310
|
+
const defaultRun = caseItem.defaultRun ??
|
|
311
|
+
(requires !== "headless" ? "opt-in" : "always");
|
|
312
|
+
const runtimeTags = [];
|
|
313
|
+
if (requires !== "headless") {
|
|
314
|
+
runtimeTags.push(`requires:${requires}`);
|
|
315
|
+
}
|
|
316
|
+
if (defaultRun === "opt-in")
|
|
317
|
+
runtimeTags.push("default-run:opt-in");
|
|
174
318
|
results.push({
|
|
175
319
|
exportName: c.exportName,
|
|
176
320
|
meta: {
|
|
177
321
|
id: `${c.contractId}.${caseItem.key}`,
|
|
322
|
+
name: `${c.contractId} — ${caseItem.key}`,
|
|
178
323
|
description: caseItem.description,
|
|
324
|
+
tags: runtimeTags.length > 0 ? runtimeTags : undefined,
|
|
179
325
|
requires: caseItem.requires,
|
|
180
326
|
defaultRun: caseItem.defaultRun,
|
|
181
327
|
deferred: caseItem.deferred,
|
|
328
|
+
kind: "contract",
|
|
182
329
|
},
|
|
183
330
|
});
|
|
184
331
|
}
|
|
185
332
|
}
|
|
186
333
|
return results;
|
|
187
334
|
}
|
|
188
|
-
// Both runtime and static failed — surface the
|
|
335
|
+
// Both runtime and static failed (or non-HTTP detected) — surface the
|
|
336
|
+
// import error so the user knows discovery is degraded.
|
|
189
337
|
for (const err of result.errors) {
|
|
190
338
|
console.error(`\x1b[31m✗ Contract import failed: ${err.file}\x1b[0m`);
|
|
191
339
|
console.error(`\x1b[2m ${err.error}\x1b[0m`);
|
|
@@ -194,28 +342,55 @@ export async function discoverTests(filePath) {
|
|
|
194
342
|
return [];
|
|
195
343
|
}
|
|
196
344
|
const metas = extractFromSource(content);
|
|
197
|
-
return metas.map((m) =>
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
345
|
+
return metas.map((m) => {
|
|
346
|
+
// Mirror the contract-case path so .test.ts authors who declare
|
|
347
|
+
// `requires: "browser"` / `defaultRun: "opt-in"` see the same
|
|
348
|
+
// selection behavior (excludeTags via synthetic tag-names AND
|
|
349
|
+
// shouldSkipTest via meta.requires/defaultRun).
|
|
350
|
+
const userTags = m.tags ?? [];
|
|
351
|
+
const requires = m.requires ?? "headless";
|
|
352
|
+
// Mirror SDK dispatchContract: non-headless implicitly opt-in unless
|
|
353
|
+
// the author overrode defaultRun. Same default applied to test() so
|
|
354
|
+
// tag-based selection (e.g. `--exclude-tag default-run:opt-in`)
|
|
355
|
+
// treats equivalent test() and contract cases identically.
|
|
356
|
+
const defaultRun = m.defaultRun ?? (requires !== "headless" ? "opt-in" : "always");
|
|
357
|
+
const runtimeTags = [];
|
|
358
|
+
if (requires !== "headless")
|
|
359
|
+
runtimeTags.push(`requires:${requires}`);
|
|
360
|
+
if (defaultRun === "opt-in")
|
|
361
|
+
runtimeTags.push("default-run:opt-in");
|
|
362
|
+
const finalTags = [...userTags, ...runtimeTags];
|
|
363
|
+
return {
|
|
364
|
+
exportName: m.exportName,
|
|
365
|
+
meta: {
|
|
366
|
+
id: m.id,
|
|
367
|
+
name: m.name,
|
|
368
|
+
tags: finalTags.length > 0 ? finalTags : undefined,
|
|
369
|
+
timeout: m.timeout,
|
|
370
|
+
skip: m.skip,
|
|
371
|
+
only: m.only,
|
|
372
|
+
groupId: m.groupId ?? (m.variant === "pick" || m.parallel ? m.id : undefined),
|
|
373
|
+
parallel: m.parallel,
|
|
374
|
+
requires: m.requires,
|
|
375
|
+
defaultRun: m.defaultRun,
|
|
376
|
+
kind: "test",
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
});
|
|
210
380
|
}
|
|
211
381
|
function matchesFilter(testItem, filter) {
|
|
212
382
|
const lowerFilter = filter.toLowerCase();
|
|
213
|
-
if (testItem.meta.id
|
|
383
|
+
if (matchesTemplateFilter(testItem.meta.id, lowerFilter))
|
|
214
384
|
return true;
|
|
215
385
|
if (testItem.meta.name?.toLowerCase().includes(lowerFilter))
|
|
216
386
|
return true;
|
|
217
387
|
return false;
|
|
218
388
|
}
|
|
389
|
+
// Exported for testing only. Internal helpers otherwise.
|
|
390
|
+
export const __testing = {
|
|
391
|
+
matchesTags: (...args) => matchesTags(...args),
|
|
392
|
+
matchesExcludeTags: (...args) => matchesExcludeTags(...args),
|
|
393
|
+
};
|
|
219
394
|
function matchesTags(testItem, tags, mode = "or") {
|
|
220
395
|
if (!testItem.meta.tags?.length)
|
|
221
396
|
return false;
|
|
@@ -223,12 +398,29 @@ function matchesTags(testItem, tags, mode = "or") {
|
|
|
223
398
|
const match = (t) => lowerTestTags.includes(t.toLowerCase());
|
|
224
399
|
return mode === "and" ? tags.every(match) : tags.some(match);
|
|
225
400
|
}
|
|
401
|
+
/**
|
|
402
|
+
* Returns true if the test carries ANY tag in excludeTags (case-insensitive).
|
|
403
|
+
* Always OR-mode — independent of positive-side tagMode. A test with no
|
|
404
|
+
* tags is never excluded by this filter.
|
|
405
|
+
*/
|
|
406
|
+
function matchesExcludeTags(testItem, excludeTags) {
|
|
407
|
+
if (!excludeTags.length)
|
|
408
|
+
return false;
|
|
409
|
+
if (!testItem.meta.tags?.length)
|
|
410
|
+
return false;
|
|
411
|
+
const lowerTestTags = testItem.meta.tags.map((t) => t.toLowerCase());
|
|
412
|
+
return excludeTags.some((t) => lowerTestTags.includes(t.toLowerCase()));
|
|
413
|
+
}
|
|
226
414
|
function getLogFilePath(testFilePath) {
|
|
227
415
|
const lastDot = testFilePath.lastIndexOf(".");
|
|
228
416
|
if (lastDot === -1)
|
|
229
417
|
return testFilePath + ".log";
|
|
230
418
|
return testFilePath.slice(0, lastDot) + ".log";
|
|
231
419
|
}
|
|
420
|
+
function findFileTestByRuntimeId(tests, runtimeId) {
|
|
421
|
+
const match = findTemplateMatch(tests.map((ft) => ({ id: ft.test.meta.id, ft })), runtimeId);
|
|
422
|
+
return match?.ft;
|
|
423
|
+
}
|
|
232
424
|
function resolveOutputPath(userPath, cwd) {
|
|
233
425
|
if (isAbsolute(userPath)) {
|
|
234
426
|
return resolve(userPath);
|
|
@@ -243,7 +435,7 @@ function resolveOutputPath(userPath, cwd) {
|
|
|
243
435
|
}
|
|
244
436
|
async function writeEmptyResult(target, runAt) {
|
|
245
437
|
const payload = {
|
|
246
|
-
target,
|
|
438
|
+
target: Array.isArray(target) ? target.join(", ") : target,
|
|
247
439
|
files: [],
|
|
248
440
|
runAt,
|
|
249
441
|
summary: { total: 0, passed: 0, failed: 0, skipped: 0, durationMs: 0, stats: {} },
|
|
@@ -263,6 +455,10 @@ export async function runCommand(target, options = {}) {
|
|
|
263
455
|
const runStartDate = new Date();
|
|
264
456
|
const runStartTime = runStartDate.toISOString();
|
|
265
457
|
const runStartLocal = localTimeString(runStartDate);
|
|
458
|
+
if (options.uploadReceiptJson && !options.upload) {
|
|
459
|
+
console.error(`${colors.red}Error: --upload-receipt-json requires --upload or an upload-enabled profile.${colors.reset}`);
|
|
460
|
+
process.exit(1);
|
|
461
|
+
}
|
|
266
462
|
// ── Capability profile ──────────────────────────────────────────────────
|
|
267
463
|
const isCiEnv = process.env.CI === "true" || process.env.GLUBEAN_CI === "true";
|
|
268
464
|
// Hard fail: --include-browser/--include-out-of-band in CI
|
|
@@ -281,23 +477,36 @@ export async function runCommand(target, options = {}) {
|
|
|
281
477
|
console.log(`\n${colors.bold}${colors.blue}🧪 Glubean Test Runner${colors.reset}\n`);
|
|
282
478
|
const testFiles = await resolveTestFiles(target);
|
|
283
479
|
const isMultiFile = testFiles.length > 1;
|
|
480
|
+
// Single string view of target for serialization / display paths
|
|
481
|
+
// (result.json, junit, traces). Multi-suite passes an array; join with
|
|
482
|
+
// ", " so downstream consumers still see a printable target field.
|
|
483
|
+
const targetDisplay = Array.isArray(target) ? target.join(", ") : target;
|
|
284
484
|
if (testFiles.length === 0) {
|
|
285
|
-
console.error(`\n${colors.red}❌ No test files found for target: ${target}${colors.reset}`);
|
|
485
|
+
console.error(`\n${colors.red}❌ No test files found for target: ${Array.isArray(target) ? target.join(", ") : target}${colors.reset}`);
|
|
286
486
|
console.error(`${colors.dim}Glubean looks for files matching *.test.ts, *.contract.ts, or *.flow.ts in the target directory.${colors.reset}`);
|
|
287
487
|
console.error(`${colors.dim}Run "glubean run tests/" or "glubean run path/to/file.test.ts".${colors.reset}\n`);
|
|
288
488
|
await writeEmptyResult(target, runStartLocal);
|
|
289
489
|
process.exit(1);
|
|
290
490
|
}
|
|
291
491
|
if (isMultiFile) {
|
|
292
|
-
|
|
492
|
+
const targetDisplay = Array.isArray(target)
|
|
493
|
+
? target.map((t) => resolve(t)).join(", ")
|
|
494
|
+
: resolve(target);
|
|
495
|
+
console.log(`${colors.dim}Target: ${targetDisplay}${colors.reset}`);
|
|
293
496
|
console.log(`${colors.dim}Files: ${testFiles.length} test file(s)${colors.reset}\n`);
|
|
294
497
|
}
|
|
295
498
|
else {
|
|
296
499
|
console.log(`${colors.dim}File: ${testFiles[0]}${colors.reset}\n`);
|
|
297
500
|
}
|
|
298
501
|
const startDir = testFiles[0].substring(0, testFiles[0].lastIndexOf("/"));
|
|
299
|
-
const { rootDir
|
|
300
|
-
|
|
502
|
+
const { rootDir } = await findProjectConfig(startDir);
|
|
503
|
+
// Config consolidation (docs/06 P2): the legacy package.json `glubean`
|
|
504
|
+
// flat-shape is no longer read. Profile runs get run/redaction/thresholds
|
|
505
|
+
// from the resolved plan (threaded via `options`); non-profile target runs
|
|
506
|
+
// fall back to built-in defaults + CLI flags + env. Warn once if a stale
|
|
507
|
+
// `glubean` field lingers in package.json so users know it's inert now.
|
|
508
|
+
await warnIfLegacyPackageJsonConfig(rootDir);
|
|
509
|
+
const glubeanConfig = structuredClone(CONFIG_DEFAULTS);
|
|
301
510
|
const effectiveRun = mergeRunOptions(glubeanConfig.run, {
|
|
302
511
|
verbose: options.verbose,
|
|
303
512
|
pretty: options.pretty,
|
|
@@ -308,6 +517,12 @@ export async function runCommand(target, options = {}) {
|
|
|
308
517
|
envFile: options.envFile,
|
|
309
518
|
failFast: options.failFast,
|
|
310
519
|
failAfter: options.failAfter,
|
|
520
|
+
// Phase 1 sub-task E1: forward profile-driven execution settings.
|
|
521
|
+
// mergeRunOptions handles undefined as "no override" — so non-profile
|
|
522
|
+
// runs (where options.timeoutMs/concurrency are undefined) keep
|
|
523
|
+
// legacy GlubeanRunConfig defaults; profile runs get the resolved values.
|
|
524
|
+
timeoutMs: options.timeoutMs,
|
|
525
|
+
concurrency: options.concurrency,
|
|
311
526
|
});
|
|
312
527
|
if (effectiveRun.logFile && !isMultiFile) {
|
|
313
528
|
const logPath = getLogFilePath(testFiles[0]);
|
|
@@ -424,10 +639,24 @@ export async function runCommand(target, options = {}) {
|
|
|
424
639
|
for (const filePath of testFiles) {
|
|
425
640
|
try {
|
|
426
641
|
const tests = await discoverTests(filePath);
|
|
427
|
-
|
|
642
|
+
// Phase 4 multi-suite: enforce suite.kinds at the runnable level
|
|
643
|
+
// (not just file-level). A `.contract.ts` exporting an inline
|
|
644
|
+
// `contract.flow(...)` produces a flow runnable; if the contributing
|
|
645
|
+
// suite declared `kinds: [contract]`, drop the flow here.
|
|
646
|
+
const allowedKinds = options.allowedKindsPerFile?.get(filePath);
|
|
647
|
+
const filteredTests = allowedKinds
|
|
648
|
+
? tests.filter((t) => {
|
|
649
|
+
const k = t.meta.kind;
|
|
650
|
+
// Treat missing kind as "always allowed" — legacy / static-
|
|
651
|
+
// fallback paths populate kind, but the safety net keeps
|
|
652
|
+
// unknown shapes runnable rather than silently dropped.
|
|
653
|
+
return k === undefined || allowedKinds.has(k);
|
|
654
|
+
})
|
|
655
|
+
: tests;
|
|
656
|
+
for (const test of filteredTests) {
|
|
428
657
|
allFileTests.push({ filePath, exportName: test.exportName, test });
|
|
429
658
|
}
|
|
430
|
-
totalDiscovered +=
|
|
659
|
+
totalDiscovered += filteredTests.length;
|
|
431
660
|
}
|
|
432
661
|
catch (error) {
|
|
433
662
|
if (isMultiFile) {
|
|
@@ -461,6 +690,7 @@ export async function runCommand(target, options = {}) {
|
|
|
461
690
|
console.log(`${colors.yellow}ℹ️ Running only tests marked with .only${colors.reset}`);
|
|
462
691
|
}
|
|
463
692
|
const hasTags = options.tags && options.tags.length > 0;
|
|
693
|
+
const hasExcludeTags = options.excludeTags && options.excludeTags.length > 0;
|
|
464
694
|
const testsToRun = allFileTests.filter((ft) => {
|
|
465
695
|
const tc = ft.test;
|
|
466
696
|
if (tc.meta.skip)
|
|
@@ -471,6 +701,8 @@ export async function runCommand(target, options = {}) {
|
|
|
471
701
|
return false;
|
|
472
702
|
if (hasTags && !matchesTags(tc, options.tags, options.tagMode))
|
|
473
703
|
return false;
|
|
704
|
+
if (hasExcludeTags && matchesExcludeTags(tc, options.excludeTags))
|
|
705
|
+
return false;
|
|
474
706
|
return true;
|
|
475
707
|
});
|
|
476
708
|
if (testsToRun.length === 0) {
|
|
@@ -676,6 +908,7 @@ export async function runCommand(target, options = {}) {
|
|
|
676
908
|
// "start" event inside file:event handlers.
|
|
677
909
|
let currentGroupFilePath = "";
|
|
678
910
|
let currentTestMap;
|
|
911
|
+
let currentTestItems;
|
|
679
912
|
let testId = "";
|
|
680
913
|
let testName = "";
|
|
681
914
|
let testItem = null;
|
|
@@ -684,10 +917,17 @@ export async function runCommand(target, options = {}) {
|
|
|
684
917
|
let assertions = [];
|
|
685
918
|
let success = false;
|
|
686
919
|
let errorMsg;
|
|
920
|
+
let errorStack;
|
|
921
|
+
let errorReason;
|
|
922
|
+
let errorMissingPath;
|
|
923
|
+
let errorSuggestions;
|
|
687
924
|
let peakMemoryMB;
|
|
688
925
|
let stepAssertionCount = 0;
|
|
689
926
|
let stepTraceLines = [];
|
|
690
927
|
let testStarted = false;
|
|
928
|
+
// Plan 1 AC5: dedupe warning messages per session so the same warning
|
|
929
|
+
// doesn't repeat across session setup + each file's run() call.
|
|
930
|
+
const emittedWarnings = new Set();
|
|
691
931
|
const addLogEntry = (type, message, data) => {
|
|
692
932
|
if (effectiveRun.logFile) {
|
|
693
933
|
logEntries.push({
|
|
@@ -766,8 +1006,42 @@ export async function runCommand(target, options = {}) {
|
|
|
766
1006
|
}
|
|
767
1007
|
}
|
|
768
1008
|
if (errorMsg) {
|
|
769
|
-
|
|
1009
|
+
if (errorReason === "test_file_missing" && errorMissingPath) {
|
|
1010
|
+
console.log(` ${colors.red}✗ Test file not found: ${errorMissingPath}${colors.reset}`);
|
|
1011
|
+
if (errorSuggestions && errorSuggestions.length > 0) {
|
|
1012
|
+
console.log(` ${colors.dim}Did you mean:${colors.reset}`);
|
|
1013
|
+
for (const s of errorSuggestions) {
|
|
1014
|
+
console.log(` ${s}`);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
else {
|
|
1019
|
+
console.log(` ${colors.red}Error: ${errorMsg}${colors.reset}`);
|
|
1020
|
+
if (errorStack) {
|
|
1021
|
+
const lines = errorStack.split("\n").slice(1);
|
|
1022
|
+
for (const line of lines) {
|
|
1023
|
+
const trimmed = line.trim();
|
|
1024
|
+
if (!trimmed)
|
|
1025
|
+
continue;
|
|
1026
|
+
const isFramework = trimmed.includes("/node_modules/") ||
|
|
1027
|
+
trimmed.includes("/@glubean/runner/") ||
|
|
1028
|
+
trimmed.includes("internal/modules/");
|
|
1029
|
+
console.log(` ${isFramework ? colors.dim : colors.reset}${trimmed}${colors.reset}`);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
770
1033
|
}
|
|
1034
|
+
// Clear error fields after rendering so file:complete's orphan branch
|
|
1035
|
+
// (`!testStarted && errorMsg`) doesn't render this same failure again
|
|
1036
|
+
// and double-count it. The orphan branch is only meant for failures
|
|
1037
|
+
// that happened BEFORE any test started (e.g. harness died during
|
|
1038
|
+
// userModule load) — once we've finalized a started test, the error
|
|
1039
|
+
// belongs to that test alone.
|
|
1040
|
+
errorMsg = undefined;
|
|
1041
|
+
errorStack = undefined;
|
|
1042
|
+
errorReason = undefined;
|
|
1043
|
+
errorMissingPath = undefined;
|
|
1044
|
+
errorSuggestions = undefined;
|
|
771
1045
|
};
|
|
772
1046
|
// Pre-filter tests by capability profile so file:start can emit the
|
|
773
1047
|
// ⊘ lines inline (preserves the pre-migration output layout where these
|
|
@@ -803,6 +1077,60 @@ export async function runCommand(target, options = {}) {
|
|
|
803
1077
|
// Files ProjectRunner actually started. Any fileGroups entry that never
|
|
804
1078
|
// gets file:start is a fail-fast skip — handled post run:complete.
|
|
805
1079
|
const startedFiles = new Set();
|
|
1080
|
+
// Files that are 100% capability-skipped need ⊘ rows emitted manually
|
|
1081
|
+
// because ProjectRunner never starts a file with zero runnable tests
|
|
1082
|
+
// (file:start, which normally renders inline skip rows, won't fire).
|
|
1083
|
+
// We do NOT emit them up-front because that would re-order them ahead of
|
|
1084
|
+
// any earlier runnable files. Instead, we render them lazily — right
|
|
1085
|
+
// before the next runnable file's `file:start` fires (and one final pass
|
|
1086
|
+
// after run:complete for any trailing all-skipped files). This keeps the
|
|
1087
|
+
// visible file order matching `fileGroups` insertion order even in
|
|
1088
|
+
// multi-file fail-fast runs.
|
|
1089
|
+
const fileOrder = Array.from(fileGroups.keys());
|
|
1090
|
+
let nextFileIdx = 0;
|
|
1091
|
+
const emitAllSkippedFilesUpTo = (stopFilePath) => {
|
|
1092
|
+
while (nextFileIdx < fileOrder.length) {
|
|
1093
|
+
const filePath = fileOrder[nextFileIdx];
|
|
1094
|
+
if (filePath === stopFilePath)
|
|
1095
|
+
return;
|
|
1096
|
+
nextFileIdx++;
|
|
1097
|
+
if (runnableByFile.has(filePath))
|
|
1098
|
+
continue;
|
|
1099
|
+
const skips = fileCapabilitySkips.get(filePath);
|
|
1100
|
+
if (!skips || skips.length === 0)
|
|
1101
|
+
continue;
|
|
1102
|
+
if (isMultiFile) {
|
|
1103
|
+
const relPath = relative(process.cwd(), filePath);
|
|
1104
|
+
console.log(`${colors.bold}📁 ${relPath}${colors.reset}`);
|
|
1105
|
+
}
|
|
1106
|
+
for (const { ft, reason } of skips) {
|
|
1107
|
+
skipped++;
|
|
1108
|
+
const name = ft.test.meta.name || ft.test.meta.id;
|
|
1109
|
+
console.log(` ${colors.yellow}⊘${colors.reset} ${name} ${colors.dim}— skipped (${reason})${colors.reset}`);
|
|
1110
|
+
collectedRuns.push({
|
|
1111
|
+
testId: ft.test.meta.id,
|
|
1112
|
+
testName: name,
|
|
1113
|
+
tags: ft.test.meta.tags,
|
|
1114
|
+
filePath,
|
|
1115
|
+
events: [{ type: "status", status: "skipped", reason }],
|
|
1116
|
+
success: true,
|
|
1117
|
+
durationMs: 0,
|
|
1118
|
+
groupId: ft.test.meta.groupId,
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
fileCapabilitySkips.delete(filePath);
|
|
1122
|
+
startedFiles.add(filePath);
|
|
1123
|
+
}
|
|
1124
|
+
};
|
|
1125
|
+
// If every selected test was capability-skipped, ProjectRunner has
|
|
1126
|
+
// nothing to do. Running it anyway would still perform session setup,
|
|
1127
|
+
// which on a broken session.ts would mask the skip output behind a
|
|
1128
|
+
// session:setup:failed exit. Drain the skip rows now and short-circuit
|
|
1129
|
+
// to the summary block.
|
|
1130
|
+
const hasRunnable = runnableTests.length > 0;
|
|
1131
|
+
if (!hasRunnable) {
|
|
1132
|
+
emitAllSkippedFilesUpTo(null);
|
|
1133
|
+
}
|
|
806
1134
|
const runner = new ProjectRunner({
|
|
807
1135
|
rootDir,
|
|
808
1136
|
sharedConfig: shared,
|
|
@@ -822,7 +1150,10 @@ export async function runCommand(target, options = {}) {
|
|
|
822
1150
|
...(options.inspectBrk !== undefined && { inspectBrk: options.inspectBrk }),
|
|
823
1151
|
metricCollector,
|
|
824
1152
|
});
|
|
825
|
-
|
|
1153
|
+
// Only walk the runner stream when there are runnable tests. The empty
|
|
1154
|
+
// case has already emitted all capability skips above and falls
|
|
1155
|
+
// straight through to the summary.
|
|
1156
|
+
for await (const ev of hasRunnable ? runner.run() : []) {
|
|
826
1157
|
switch (ev.type) {
|
|
827
1158
|
case "bootstrap:start":
|
|
828
1159
|
case "bootstrap:done":
|
|
@@ -854,6 +1185,18 @@ export async function runCommand(target, options = {}) {
|
|
|
854
1185
|
else if (se.type === "log") {
|
|
855
1186
|
console.log(` ${colors.dim}[session] ${se.message}${colors.reset}`);
|
|
856
1187
|
}
|
|
1188
|
+
else if (se.type === "warning") {
|
|
1189
|
+
// Plan 1 AC5: render runner-fallback warnings emitted during
|
|
1190
|
+
// session setup. Only dedupe runner diagnostics (those carry a
|
|
1191
|
+
// `code` field — see ExecutionEvent.warning schema); user-emitted
|
|
1192
|
+
// ctx.warn(false, ...) warnings have no code and pass through.
|
|
1193
|
+
const isRunnerDiag = !!se.code;
|
|
1194
|
+
if (!isRunnerDiag || !emittedWarnings.has(se.message)) {
|
|
1195
|
+
if (isRunnerDiag)
|
|
1196
|
+
emittedWarnings.add(se.message);
|
|
1197
|
+
console.log(` ${colors.yellow}⚠ ${se.message}${colors.reset}`);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
857
1200
|
break;
|
|
858
1201
|
}
|
|
859
1202
|
case "session:setup:done": {
|
|
@@ -878,9 +1221,17 @@ export async function runCommand(target, options = {}) {
|
|
|
878
1221
|
break;
|
|
879
1222
|
}
|
|
880
1223
|
case "file:start": {
|
|
1224
|
+
// Flush any 100%-skipped files that come before this one in
|
|
1225
|
+
// fileGroups order, so the user sees them in their natural place.
|
|
1226
|
+
emitAllSkippedFilesUpTo(ev.filePath);
|
|
1227
|
+
if (nextFileIdx < fileOrder.length &&
|
|
1228
|
+
fileOrder[nextFileIdx] === ev.filePath) {
|
|
1229
|
+
nextFileIdx++;
|
|
1230
|
+
}
|
|
881
1231
|
currentGroupFilePath = ev.filePath;
|
|
882
1232
|
startedFiles.add(ev.filePath);
|
|
883
1233
|
const runnable = runnableByFile.get(ev.filePath) ?? [];
|
|
1234
|
+
currentTestItems = runnable;
|
|
884
1235
|
currentTestMap = new Map(runnable.map((ft) => [ft.test.meta.id, ft]));
|
|
885
1236
|
if (isMultiFile) {
|
|
886
1237
|
const relPath = relative(process.cwd(), ev.filePath);
|
|
@@ -913,7 +1264,8 @@ export async function runCommand(target, options = {}) {
|
|
|
913
1264
|
const event = ev.event;
|
|
914
1265
|
switch (event.type) {
|
|
915
1266
|
case "start": {
|
|
916
|
-
const entry = currentTestMap?.get(event.id)
|
|
1267
|
+
const entry = currentTestMap?.get(event.id) ??
|
|
1268
|
+
(currentTestItems ? findFileTestByRuntimeId(currentTestItems, event.id) : undefined);
|
|
917
1269
|
testId = event.id;
|
|
918
1270
|
testName = entry?.test.meta.name || event.name || event.id;
|
|
919
1271
|
testItem = entry?.test || null;
|
|
@@ -922,6 +1274,10 @@ export async function runCommand(target, options = {}) {
|
|
|
922
1274
|
assertions = [];
|
|
923
1275
|
success = false;
|
|
924
1276
|
errorMsg = undefined;
|
|
1277
|
+
errorStack = undefined;
|
|
1278
|
+
errorReason = undefined;
|
|
1279
|
+
errorMissingPath = undefined;
|
|
1280
|
+
errorSuggestions = undefined;
|
|
925
1281
|
peakMemoryMB = undefined;
|
|
926
1282
|
stepAssertionCount = 0;
|
|
927
1283
|
stepTraceLines = [];
|
|
@@ -939,6 +1295,10 @@ export async function runCommand(target, options = {}) {
|
|
|
939
1295
|
success = event.status === "completed";
|
|
940
1296
|
if (event.error) {
|
|
941
1297
|
errorMsg = event.error;
|
|
1298
|
+
errorStack = event.stack;
|
|
1299
|
+
errorReason = event.reason;
|
|
1300
|
+
errorMissingPath = event.missingPath;
|
|
1301
|
+
errorSuggestions = event.suggestions;
|
|
942
1302
|
addLogEntry("error", event.error);
|
|
943
1303
|
}
|
|
944
1304
|
if (event.peakMemoryMB)
|
|
@@ -947,8 +1307,13 @@ export async function runCommand(target, options = {}) {
|
|
|
947
1307
|
break;
|
|
948
1308
|
case "error":
|
|
949
1309
|
success = false;
|
|
950
|
-
if (!errorMsg)
|
|
1310
|
+
if (!errorMsg) {
|
|
951
1311
|
errorMsg = event.message;
|
|
1312
|
+
errorStack = event.stack;
|
|
1313
|
+
errorReason = event.reason;
|
|
1314
|
+
errorMissingPath = event.missingPath;
|
|
1315
|
+
errorSuggestions = event.suggestions;
|
|
1316
|
+
}
|
|
952
1317
|
addLogEntry("error", event.message);
|
|
953
1318
|
break;
|
|
954
1319
|
case "log":
|
|
@@ -1082,7 +1447,16 @@ export async function runCommand(target, options = {}) {
|
|
|
1082
1447
|
break;
|
|
1083
1448
|
case "warning": {
|
|
1084
1449
|
const warnIcon = event.condition ? `${colors.green}✓${colors.reset}` : `${colors.yellow}⚠${colors.reset}`;
|
|
1085
|
-
|
|
1450
|
+
// Plan 1 AC5: dedupe runner-fallback / protocol-min warnings
|
|
1451
|
+
// (carry a `code` field — see ExecutionEvent.warning schema).
|
|
1452
|
+
// User-emitted ctx.warn(false, ...) warnings have no code and
|
|
1453
|
+
// pass through every time so test authors can see them repeat.
|
|
1454
|
+
const isRunnerDiag = !!event.code;
|
|
1455
|
+
if (!isRunnerDiag || !emittedWarnings.has(event.message)) {
|
|
1456
|
+
if (isRunnerDiag)
|
|
1457
|
+
emittedWarnings.add(event.message);
|
|
1458
|
+
console.log(` ${warnIcon} ${colors.yellow}${event.message}${colors.reset}`);
|
|
1459
|
+
}
|
|
1086
1460
|
break;
|
|
1087
1461
|
}
|
|
1088
1462
|
case "schema_validation":
|
|
@@ -1107,7 +1481,32 @@ export async function runCommand(target, options = {}) {
|
|
|
1107
1481
|
// mid-test or emitted no start event, promote the leftover state
|
|
1108
1482
|
// to a visible failure row.
|
|
1109
1483
|
if (!testStarted && errorMsg) {
|
|
1110
|
-
|
|
1484
|
+
// Plan 4: rich render for orphan-error case (no leading start event,
|
|
1485
|
+
// e.g. harness died during userModule import).
|
|
1486
|
+
if (errorReason === "test_file_missing" && errorMissingPath) {
|
|
1487
|
+
console.log(` ${colors.red}✗ Test file not found: ${errorMissingPath}${colors.reset}`);
|
|
1488
|
+
if (errorSuggestions && errorSuggestions.length > 0) {
|
|
1489
|
+
console.log(` ${colors.dim}Did you mean:${colors.reset}`);
|
|
1490
|
+
for (const s of errorSuggestions) {
|
|
1491
|
+
console.log(` ${s}`);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
else {
|
|
1496
|
+
console.log(` ${colors.red}✗ ${errorMsg}${colors.reset}`);
|
|
1497
|
+
if (errorStack) {
|
|
1498
|
+
const lines = errorStack.split("\n").slice(1);
|
|
1499
|
+
for (const line of lines) {
|
|
1500
|
+
const trimmed = line.trim();
|
|
1501
|
+
if (!trimmed)
|
|
1502
|
+
continue;
|
|
1503
|
+
const isFramework = trimmed.includes("/node_modules/") ||
|
|
1504
|
+
trimmed.includes("/@glubean/runner/") ||
|
|
1505
|
+
trimmed.includes("internal/modules/");
|
|
1506
|
+
console.log(` ${isFramework ? colors.dim : colors.reset}${trimmed}${colors.reset}`);
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1111
1510
|
failed++;
|
|
1112
1511
|
}
|
|
1113
1512
|
if (testStarted) {
|
|
@@ -1117,6 +1516,13 @@ export async function runCommand(target, options = {}) {
|
|
|
1117
1516
|
}
|
|
1118
1517
|
break;
|
|
1119
1518
|
case "run:complete":
|
|
1519
|
+
// Flush any trailing 100%-skipped files (after the last runnable
|
|
1520
|
+
// file). Under fail-fast, also flush only up to the file that
|
|
1521
|
+
// actually started — files beyond the fail point still belong to
|
|
1522
|
+
// the fail-fast pass below, not to the capability-skip pass.
|
|
1523
|
+
if (failureLimit === undefined || ev.failedCount < failureLimit) {
|
|
1524
|
+
emitAllSkippedFilesUpTo(null);
|
|
1525
|
+
}
|
|
1120
1526
|
// Fail-fast skip display: any file ProjectRunner never started
|
|
1121
1527
|
// (because the failure limit kicked in between file groups) gets
|
|
1122
1528
|
// the old "○ (skipped — fail-fast)" lines here, preserving the
|
|
@@ -1179,9 +1585,15 @@ export async function runCommand(target, options = {}) {
|
|
|
1179
1585
|
console.log(`${colors.bold}Stats:${colors.reset} ${colors.dim}${parts.join(" · ")}${colors.reset}`);
|
|
1180
1586
|
}
|
|
1181
1587
|
// ── Threshold evaluation ──────────────────────────────────────────────────
|
|
1588
|
+
// Prefer the v1 plan's resolved thresholds when present (profile mode);
|
|
1589
|
+
// fall back to the legacy package.json `thresholds` otherwise. (P2 removes
|
|
1590
|
+
// the legacy source — see docs/06 config consolidation.)
|
|
1591
|
+
const effectiveThresholds = options.thresholds && Object.keys(options.thresholds).length > 0
|
|
1592
|
+
? options.thresholds
|
|
1593
|
+
: glubeanConfig.thresholds;
|
|
1182
1594
|
let thresholdSummary;
|
|
1183
|
-
if (
|
|
1184
|
-
thresholdSummary = evaluateThresholds(
|
|
1595
|
+
if (effectiveThresholds && Object.keys(effectiveThresholds).length > 0) {
|
|
1596
|
+
thresholdSummary = evaluateThresholds(effectiveThresholds, metricCollector);
|
|
1185
1597
|
const { results: thresholdResults, pass: allPass } = thresholdSummary;
|
|
1186
1598
|
if (thresholdResults.length > 0) {
|
|
1187
1599
|
console.log(`${colors.bold}Thresholds:${colors.reset}`);
|
|
@@ -1208,7 +1620,11 @@ export async function runCommand(target, options = {}) {
|
|
|
1208
1620
|
};
|
|
1209
1621
|
const logContent = [
|
|
1210
1622
|
`# Glubean Test Log`,
|
|
1211
|
-
`# Target: ${isMultiFile
|
|
1623
|
+
`# Target: ${isMultiFile
|
|
1624
|
+
? Array.isArray(target)
|
|
1625
|
+
? target.map((t) => resolve(t)).join(", ")
|
|
1626
|
+
: resolve(target)
|
|
1627
|
+
: testFiles[0]}`,
|
|
1212
1628
|
`# Run at: ${runStartTime}`,
|
|
1213
1629
|
`# Tests: ${passed} passed, ${failed} failed`,
|
|
1214
1630
|
``,
|
|
@@ -1262,7 +1678,7 @@ export async function runCommand(target, options = {}) {
|
|
|
1262
1678
|
const tracesPath = resolve(glubeanDir, "traces.json");
|
|
1263
1679
|
const traceSummary = {
|
|
1264
1680
|
runAt: runStartTime,
|
|
1265
|
-
target,
|
|
1681
|
+
target: targetDisplay,
|
|
1266
1682
|
files: testFiles.map((f) => relative(process.cwd(), f)),
|
|
1267
1683
|
traces: traceCollector,
|
|
1268
1684
|
};
|
|
@@ -1281,7 +1697,7 @@ export async function runCommand(target, options = {}) {
|
|
|
1281
1697
|
};
|
|
1282
1698
|
const resultPayload = {
|
|
1283
1699
|
context: runContext,
|
|
1284
|
-
target,
|
|
1700
|
+
target: targetDisplay,
|
|
1285
1701
|
files: testFiles.map((f) => relative(process.cwd(), f)),
|
|
1286
1702
|
runAt: runStartLocal,
|
|
1287
1703
|
summary: {
|
|
@@ -1337,7 +1753,7 @@ export async function runCommand(target, options = {}) {
|
|
|
1337
1753
|
skipped,
|
|
1338
1754
|
durationMs: totalDurationMs,
|
|
1339
1755
|
};
|
|
1340
|
-
const xml = toJunitXml(collectedRuns,
|
|
1756
|
+
const xml = toJunitXml(collectedRuns, targetDisplay, summaryData);
|
|
1341
1757
|
await mkdir(dirname(junitPath), { recursive: true });
|
|
1342
1758
|
await writeFile(junitPath, xml, "utf-8");
|
|
1343
1759
|
console.log(`${colors.dim}JUnit XML written to: ${junitPath}${colors.reset}\n`);
|
|
@@ -1397,10 +1813,17 @@ export async function runCommand(target, options = {}) {
|
|
|
1397
1813
|
}
|
|
1398
1814
|
else {
|
|
1399
1815
|
const { compileScopes, redactEvent, BUILTIN_SCOPES } = await import("@glubean/redaction");
|
|
1816
|
+
// Prefer the v1 plan's full redaction config when supplied
|
|
1817
|
+
// (Phase 4 init scaffolds `defaults.redaction` in glubean.yaml,
|
|
1818
|
+
// including any custom globalRules / sensitiveKeys / customPatterns).
|
|
1819
|
+
// The legacy loadConfig path doesn't read glubean.yaml — without
|
|
1820
|
+
// this, custom rules would be silently ignored and matching
|
|
1821
|
+
// secrets could be sent to Cloud.
|
|
1822
|
+
const effectiveRedaction = options.redactionConfig ?? glubeanConfig.redaction;
|
|
1400
1823
|
const compiledScopes = compileScopes({
|
|
1401
1824
|
builtinScopes: BUILTIN_SCOPES,
|
|
1402
|
-
globalRules:
|
|
1403
|
-
replacementFormat:
|
|
1825
|
+
globalRules: effectiveRedaction.globalRules,
|
|
1826
|
+
replacementFormat: effectiveRedaction.replacementFormat,
|
|
1404
1827
|
});
|
|
1405
1828
|
// Generate metadata for test registry
|
|
1406
1829
|
let metadata;
|
|
@@ -1417,21 +1840,48 @@ export async function runCommand(target, options = {}) {
|
|
|
1417
1840
|
catch {
|
|
1418
1841
|
// Non-critical: upload results without metadata
|
|
1419
1842
|
}
|
|
1843
|
+
// Phase 5 5a — attach run-plan provenance to the upload metadata
|
|
1844
|
+
// bucket. Cloud server projects this to top-level RunEntity fields
|
|
1845
|
+
// (see apps/server/src/tasks/helpers/extract-run-plan.ts). Nested
|
|
1846
|
+
// under `metadata` to clear the server DTO's `forbidNonWhitelisted`
|
|
1847
|
+
// top-level gate. Only emitted when:
|
|
1848
|
+
// 1. The run used a profile (no profile → nothing to record).
|
|
1849
|
+
// 2. The scan path produced metadata.
|
|
1850
|
+
// Skipping runPlan in the degraded-scan path is intentional —
|
|
1851
|
+
// synthesizing a runPlan-only shell with `files: {}` would make
|
|
1852
|
+
// the server's upsertTests treat all active tests as "removed"
|
|
1853
|
+
// (authoritative file map = empty). Better to lose runPlan
|
|
1854
|
+
// provenance on degraded scans than to corrupt the test registry.
|
|
1855
|
+
if (metadata && options.profile) {
|
|
1856
|
+
const runPlan = {
|
|
1857
|
+
profile: options.profile,
|
|
1858
|
+
};
|
|
1859
|
+
if (options.suites && options.suites.length > 0) {
|
|
1860
|
+
runPlan.suites = options.suites;
|
|
1861
|
+
}
|
|
1862
|
+
metadata = { ...metadata, runPlan };
|
|
1863
|
+
}
|
|
1420
1864
|
const redactedPayload = {
|
|
1421
1865
|
...resultPayload,
|
|
1422
1866
|
metadata,
|
|
1423
1867
|
tests: resultPayload.tests.map((t) => ({
|
|
1424
1868
|
...t,
|
|
1425
|
-
events: t.events.map((e) => redactEvent(e, compiledScopes,
|
|
1869
|
+
events: t.events.map((e) => redactEvent(e, compiledScopes, effectiveRedaction.replacementFormat)),
|
|
1426
1870
|
})),
|
|
1427
1871
|
};
|
|
1428
|
-
await uploadToCloud(redactedPayload, {
|
|
1872
|
+
const uploadReceipt = await uploadToCloud(redactedPayload, {
|
|
1429
1873
|
apiUrl,
|
|
1430
1874
|
token,
|
|
1431
1875
|
projectId,
|
|
1432
1876
|
envFile: effectiveRun.envFile,
|
|
1433
1877
|
rootDir,
|
|
1434
1878
|
});
|
|
1879
|
+
if (options.uploadReceiptJson) {
|
|
1880
|
+
const receiptPath = resolveOutputPath(options.uploadReceiptJson, process.cwd());
|
|
1881
|
+
await mkdir(dirname(receiptPath), { recursive: true });
|
|
1882
|
+
await writeFile(receiptPath, JSON.stringify(uploadReceipt, null, 2) + "\n", "utf-8");
|
|
1883
|
+
console.log(`${colors.dim}Upload receipt written to: ${receiptPath}${colors.reset}`);
|
|
1884
|
+
}
|
|
1435
1885
|
}
|
|
1436
1886
|
}
|
|
1437
1887
|
if (failed > 0 || (thresholdSummary && !thresholdSummary.pass)) {
|