@glubean/cli 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +24 -0
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/load.d.ts +65 -0
- package/dist/commands/load.d.ts.map +1 -0
- package/dist/commands/load.js +462 -0
- package/dist/commands/load.js.map +1 -0
- package/dist/commands/login.d.ts +14 -1
- package/dist/commands/login.d.ts.map +1 -1
- package/dist/commands/login.js +110 -49
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/run.d.ts +14 -2
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +216 -122
- package/dist/commands/run.js.map +1 -1
- package/dist/lib/auth.d.ts +57 -0
- package/dist/lib/auth.d.ts.map +1 -1
- package/dist/lib/auth.js +134 -1
- package/dist/lib/auth.js.map +1 -1
- package/dist/lib/config.d.ts +17 -6
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +9 -2
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/constants.d.ts +6 -1
- package/dist/lib/constants.d.ts.map +1 -1
- package/dist/lib/constants.js +6 -1
- package/dist/lib/constants.js.map +1 -1
- package/dist/lib/print-plan.d.ts.map +1 -1
- package/dist/lib/print-plan.js +4 -0
- package/dist/lib/print-plan.js.map +1 -1
- package/dist/lib/upload.d.ts +88 -10
- package/dist/lib/upload.d.ts.map +1 -1
- package/dist/lib/upload.js +117 -188
- package/dist/lib/upload.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +57 -7
- package/dist/main.js.map +1 -1
- package/package.json +6 -6
- package/dist/lib/env.d.ts +0 -29
- package/dist/lib/env.d.ts.map +0 -1
- package/dist/lib/env.js +0 -59
- package/dist/lib/env.js.map +0 -1
package/dist/commands/run.js
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import { bootstrap, evaluateThresholds, MetricCollector, ProjectRunner, buildRunContext, } from "@glubean/runner";
|
|
2
2
|
import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
3
4
|
import { stat, readdir, readFile, writeFile, mkdir, rm } from "node:fs/promises";
|
|
4
5
|
import { glob } from "node:fs/promises";
|
|
5
6
|
import { CONFIG_DEFAULTS, mergeRunOptions, toSharedRunConfig } from "../lib/config.js";
|
|
6
7
|
import { loadProjectEnv } from "@glubean/runner";
|
|
7
8
|
import { resolveEnvFileName } from "../lib/active_env.js";
|
|
8
9
|
import { shouldSkipTest } from "../lib/skip.js";
|
|
9
|
-
import { CLI_VERSION } from "../version.js";
|
|
10
|
-
import { redactMetadataForUpload } from "../lib/redact-metadata.js";
|
|
11
10
|
import { extractContractCases, extractFromSource } from "@glubean/scanner/static";
|
|
12
|
-
import { extractContractFromFile, findTemplateMatch, loadProjectOverlays, matchesTemplateFilter, } from "@glubean/scanner";
|
|
11
|
+
import { buildSuffixes, classifyByStem, extractContractFromFile, findTemplateMatch, GLUBEAN_KINDS, loadProjectOverlays, matchesTemplateFilter, } from "@glubean/scanner";
|
|
13
12
|
import { applyEnvTemplating } from "@glubean/runner";
|
|
14
13
|
// ANSI color codes for pretty output
|
|
15
14
|
const colors = {
|
|
@@ -28,7 +27,7 @@ const CLOUD_MEMORY_LIMITS = {
|
|
|
28
27
|
pro: 700,
|
|
29
28
|
};
|
|
30
29
|
const MEMORY_WARNING_THRESHOLD_MB = CLOUD_MEMORY_LIMITS.free * 0.67;
|
|
31
|
-
async function findProjectConfig(startDir) {
|
|
30
|
+
export async function findProjectConfig(startDir) {
|
|
32
31
|
let dir = startDir;
|
|
33
32
|
while (dir !== "/") {
|
|
34
33
|
try {
|
|
@@ -77,13 +76,12 @@ function isGlob(target) {
|
|
|
77
76
|
// calls execute and register overlays before discovery runs (attachment-
|
|
78
77
|
// model §7.4). `discoverTests()` is responsible for distinguishing
|
|
79
78
|
// bootstrap-only files from runnable-emitting ones.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
];
|
|
79
|
+
// CLI target resolution for `glubean run` recognizes only `.ts` files across the
|
|
80
|
+
// test-runner kinds (including bootstrap), derived from the canonical kind
|
|
81
|
+
// registry (scanner/kinds.ts). `load` is EXCLUDED here: load plans run through
|
|
82
|
+
// the dedicated `glubean load` command + the closed-model orchestrator, not the
|
|
83
|
+
// per-test ProjectRunner, so `glubean run ./dir` must not sweep in `.load.ts`.
|
|
84
|
+
const TEST_FILE_SUFFIXES = GLUBEAN_KINDS.filter((k) => k.kind !== "load").flatMap((k) => buildSuffixes(k.stems, [".ts"]));
|
|
87
85
|
function isGlubeanTestFile(name) {
|
|
88
86
|
return TEST_FILE_SUFFIXES.some((suffix) => name.endsWith(suffix));
|
|
89
87
|
}
|
|
@@ -93,17 +91,9 @@ function isGlubeanTestFile(name) {
|
|
|
93
91
|
// the static test path — a `.workflow.` substring would mis-route it into
|
|
94
92
|
// contract extraction (which ignores `test()` exports), silently dropping its
|
|
95
93
|
// tests (codex 0.6 P2).
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
".contract.mjs",
|
|
100
|
-
".workflow.ts",
|
|
101
|
-
".workflow.js",
|
|
102
|
-
".workflow.mjs",
|
|
103
|
-
".flow.ts",
|
|
104
|
-
".flow.js",
|
|
105
|
-
".flow.mjs",
|
|
106
|
-
];
|
|
94
|
+
// Contract/workflow/flow artifact files (NOT bootstrap), all extensions —
|
|
95
|
+
// derived from the canonical kind registry (scanner/kinds.ts).
|
|
96
|
+
const RUNTIME_ARTIFACT_SUFFIXES = GLUBEAN_KINDS.filter((k) => k.runtimeArtifact && k.kind !== "bootstrap").flatMap((k) => buildSuffixes(k.stems));
|
|
107
97
|
function isRuntimeExtractableArtifact(name) {
|
|
108
98
|
return RUNTIME_ARTIFACT_SUFFIXES.some((suffix) => name.endsWith(suffix));
|
|
109
99
|
}
|
|
@@ -125,15 +115,8 @@ async function walkTestFiles(dir, result) {
|
|
|
125
115
|
}
|
|
126
116
|
}
|
|
127
117
|
export function classifyGlubeanFile(filePath) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (filePath.endsWith(".contract.ts"))
|
|
131
|
-
return "contract";
|
|
132
|
-
if (filePath.endsWith(".workflow.ts") || filePath.endsWith(".flow.ts"))
|
|
133
|
-
return "flow";
|
|
134
|
-
if (filePath.endsWith(".bootstrap.ts"))
|
|
135
|
-
return "bootstrap";
|
|
136
|
-
return undefined;
|
|
118
|
+
// `.ts`-only classification, by stem, from the canonical kind registry.
|
|
119
|
+
return classifyByStem(filePath, [".ts"]);
|
|
137
120
|
}
|
|
138
121
|
async function resolveSingleTarget(target) {
|
|
139
122
|
const abs = resolve(target);
|
|
@@ -450,6 +433,7 @@ function matchesFilter(testItem, filter) {
|
|
|
450
433
|
export const __testing = {
|
|
451
434
|
matchesTags: (...args) => matchesTags(...args),
|
|
452
435
|
matchesExcludeTags: (...args) => matchesExcludeTags(...args),
|
|
436
|
+
isGlubeanTestFile: (name) => isGlubeanTestFile(name),
|
|
453
437
|
};
|
|
454
438
|
function matchesTags(testItem, tags, mode = "or") {
|
|
455
439
|
if (!testItem.meta.tags?.length)
|
|
@@ -625,11 +609,15 @@ export async function runCommand(target, options = {}) {
|
|
|
625
609
|
console.log(`${colors.dim}Loaded ${Object.keys(envVars).length} vars from ${envFileName}${colors.reset}`);
|
|
626
610
|
}
|
|
627
611
|
// ── Preflight: verify auth before running tests when --upload is set ────
|
|
612
|
+
// The resolved upload target is hoisted so the post-run upload reuses it —
|
|
613
|
+
// resolution happens here (pre-run) so a misconfigured destination fails fast.
|
|
614
|
+
let resolvedUploadTargetId;
|
|
628
615
|
if (options.upload) {
|
|
629
|
-
const { resolveToken, resolveProjectId, resolveApiUrl } = await import("../lib/auth.js");
|
|
616
|
+
const { resolveToken, resolveProjectId, resolveApiUrl, resolveTargetId, resolveDefaultTargetId, checkUploadAuth, checkTargetInProject, } = await import("../lib/auth.js");
|
|
630
617
|
const authOpts = {
|
|
631
618
|
token: options.token,
|
|
632
619
|
project: options.project,
|
|
620
|
+
target: options.target,
|
|
633
621
|
apiUrl: options.apiUrl,
|
|
634
622
|
};
|
|
635
623
|
const sources = {
|
|
@@ -645,34 +633,87 @@ export async function runCommand(target, options = {}) {
|
|
|
645
633
|
console.error(`${colors.dim}This profile's upload.tokenEnv points at '${options.tokenEnv}', but it's empty/unset. Set it in .env.secrets or the environment.${colors.reset}`);
|
|
646
634
|
}
|
|
647
635
|
else {
|
|
648
|
-
console.error(`${colors.dim}
|
|
636
|
+
console.error(`${colors.dim}Create a project token (glb_…) in the dashboard (Project → Tokens), then run 'glubean login' to save it, set GLUBEAN_TOKEN / --token, or add it to .env.secrets.${colors.reset}`);
|
|
649
637
|
}
|
|
650
638
|
process.exit(1);
|
|
651
639
|
}
|
|
652
640
|
if (!preProject) {
|
|
653
641
|
console.error(`${colors.red}Error: --upload requires a project ID but none found.${colors.reset}`);
|
|
654
|
-
console.error(`${colors.dim}Use --project
|
|
642
|
+
console.error(`${colors.dim}Use --project or set GLUBEAN_PROJECT_ID.${colors.reset}`);
|
|
655
643
|
process.exit(1);
|
|
656
644
|
}
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
645
|
+
// Validate against the SAME server runs upload to. Don't pre-judge token
|
|
646
|
+
// format locally — let the server decide. A least-privilege ingest token
|
|
647
|
+
// (runs:write, no projects:read) gets 403 yet can still POST runs, so that
|
|
648
|
+
// alone proceeds; a known-bad config (401 invalid token, 404 mistyped
|
|
649
|
+
// project / wrong API URL, 5xx, unreachable) is fatal BEFORE running tests.
|
|
650
|
+
const check = await checkUploadAuth(preApiUrl, preProject, preToken);
|
|
651
|
+
if (!check.proceed) {
|
|
652
|
+
if (check.status === 401) {
|
|
653
|
+
console.error(`${colors.red}Error: authentication failed (401).${colors.reset}`);
|
|
654
|
+
console.error(`${colors.dim}The token is invalid/expired or not a platform project token (glb_…). Create one in the dashboard (Project → Tokens) and run 'glubean login' (or set GLUBEAN_TOKEN).${colors.reset}`);
|
|
655
|
+
}
|
|
656
|
+
else if (check.status === 404) {
|
|
657
|
+
console.error(`${colors.red}Error: project ${preProject} not found (404).${colors.reset}`);
|
|
658
|
+
console.error(`${colors.dim}Check that --project / GLUBEAN_PROJECT_ID is a real project id and --api-url / GLUBEAN_API_URL points at the right server.${colors.reset}`);
|
|
659
|
+
}
|
|
660
|
+
else if (check.status === 403) {
|
|
661
|
+
console.error(`${colors.red}Error: access to project ${preProject} is forbidden (403).${colors.reset}`);
|
|
662
|
+
console.error(`${colors.dim}The token's org has no access to this project (or its membership was revoked). Use a token whose org owns the project.${colors.reset}`);
|
|
663
|
+
}
|
|
664
|
+
else if (check.status === 0) {
|
|
665
|
+
console.error(`${colors.red}Error: cannot reach server at ${preApiUrl}.${colors.reset}`);
|
|
666
|
+
}
|
|
667
|
+
else {
|
|
668
|
+
console.error(`${colors.red}Error: upload preflight got an unexpected response (${check.status}).${colors.reset}`);
|
|
669
|
+
console.error(`${colors.dim}Check that --api-url / GLUBEAN_API_URL points at the Glubean platform API.${colors.reset}`);
|
|
670
|
+
}
|
|
671
|
+
process.exit(1);
|
|
672
|
+
}
|
|
673
|
+
if (check.unverified) {
|
|
674
|
+
// 403 — can't read the project with this token's scope, but it can write
|
|
675
|
+
// runs. Proceed; the post-run upload surfaces any genuine error.
|
|
676
|
+
console.log(`${colors.dim}Skipping pre-run project check (insufficient read scope); will upload to ${preApiUrl} after the run.${colors.reset}`);
|
|
677
|
+
}
|
|
678
|
+
else {
|
|
679
|
+
console.log(`${colors.dim}Authenticated · upload to ${preApiUrl} (project ${check.projectName ?? preProject})${colors.reset}`);
|
|
680
|
+
}
|
|
681
|
+
// Resolve the upload TARGET here too (pre-run) so a misconfigured target
|
|
682
|
+
// fails fast instead of after the whole suite. The post-run block reuses it.
|
|
683
|
+
let preTarget = await resolveTargetId(authOpts, sources);
|
|
684
|
+
if (preTarget) {
|
|
685
|
+
// EXPLICIT target — validate it belongs to the project (a typo would
|
|
686
|
+
// otherwise 404 only on the final POST, after the suite ran).
|
|
687
|
+
const tcheck = await checkTargetInProject(preApiUrl, preProject, preTarget, preToken);
|
|
688
|
+
if (!tcheck.proceed) {
|
|
689
|
+
if (tcheck.status === 404) {
|
|
690
|
+
console.error(`${colors.red}Error: target ${preTarget} not found in project ${preProject} (404).${colors.reset}`);
|
|
691
|
+
console.error(`${colors.dim}Check upload.targetId / GLUBEAN_TARGET_ID / --upload-target.${colors.reset}`);
|
|
692
|
+
}
|
|
693
|
+
else if (tcheck.status === 401) {
|
|
694
|
+
console.error(`${colors.red}Error: authentication failed validating the target (401).${colors.reset}`);
|
|
695
|
+
}
|
|
696
|
+
else if (tcheck.status === 0) {
|
|
697
|
+
console.error(`${colors.red}Error: cannot reach server at ${preApiUrl}.${colors.reset}`);
|
|
698
|
+
}
|
|
699
|
+
else {
|
|
700
|
+
console.error(`${colors.red}Error: could not validate target ${preTarget} (${tcheck.status}).${colors.reset}`);
|
|
665
701
|
}
|
|
666
702
|
process.exit(1);
|
|
667
703
|
}
|
|
668
|
-
|
|
669
|
-
console.log(`${colors.dim}Authenticated as ${identity.kind === "project_token" ? `project token (${identity.projectName})` : "user"} · upload to ${preApiUrl}${colors.reset}`);
|
|
704
|
+
// 403 insufficient_scope (unverified) → no targets:read; can't validate, proceed.
|
|
670
705
|
}
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
706
|
+
else {
|
|
707
|
+
// No explicit target → the project's default target (deterministic for a
|
|
708
|
+
// default project, else slug-validated by listing).
|
|
709
|
+
preTarget = await resolveDefaultTargetId(preApiUrl, preProject, preToken);
|
|
710
|
+
if (!preTarget) {
|
|
711
|
+
console.error(`${colors.red}Error: could not resolve an upload target for project ${preProject}.${colors.reset}`);
|
|
712
|
+
console.error(`${colors.dim}Set the target explicitly (upload.targetId in glubean.yaml, GLUBEAN_TARGET_ID, or --upload-target). Auto-resolving a non-default project's target needs a token with the targets:read scope.${colors.reset}`);
|
|
713
|
+
process.exit(1);
|
|
714
|
+
}
|
|
675
715
|
}
|
|
716
|
+
resolvedUploadTargetId = preTarget;
|
|
676
717
|
}
|
|
677
718
|
// ── Bootstrap plugins BEFORE discovery ─────────────────────────────────
|
|
678
719
|
// CLI's `discoverTests` dynamically imports each .contract.ts / .test.ts
|
|
@@ -1909,6 +1950,7 @@ export async function runCommand(target, options = {}) {
|
|
|
1909
1950
|
const authOpts = {
|
|
1910
1951
|
token: options.token,
|
|
1911
1952
|
project: options.project,
|
|
1953
|
+
target: options.target,
|
|
1912
1954
|
apiUrl: options.apiUrl,
|
|
1913
1955
|
};
|
|
1914
1956
|
const sources = {
|
|
@@ -1927,7 +1969,7 @@ export async function runCommand(target, options = {}) {
|
|
|
1927
1969
|
process.exit(1);
|
|
1928
1970
|
}
|
|
1929
1971
|
else {
|
|
1930
|
-
const { compileScopes, redactEvent, BUILTIN_SCOPES } = await import("@glubean/redaction");
|
|
1972
|
+
const { compileScopes, redactEvent, redactValue, BUILTIN_SCOPES } = await import("@glubean/redaction");
|
|
1931
1973
|
// Prefer the v1 plan's full redaction config when supplied
|
|
1932
1974
|
// (Phase 4 init scaffolds `defaults.redaction` in glubean.yaml,
|
|
1933
1975
|
// including any custom globalRules / sensitiveKeys / customPatterns).
|
|
@@ -1940,82 +1982,134 @@ export async function runCommand(target, options = {}) {
|
|
|
1940
1982
|
globalRules: effectiveRedaction.globalRules,
|
|
1941
1983
|
replacementFormat: effectiveRedaction.replacementFormat,
|
|
1942
1984
|
});
|
|
1943
|
-
//
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
projectId,
|
|
1952
|
-
// Upload path only: carry the lossless full CONTRACT projection for
|
|
1953
|
-
// the Cloud c/f metadata snapshot. Deep-redacted below before upload;
|
|
1954
|
-
// never written to the on-disk metadata.json (that path omits it).
|
|
1955
|
-
// `workflows` is always present (Design Y) and redacted in the same
|
|
1956
|
-
// pass below.
|
|
1957
|
-
includeProjection: true,
|
|
1958
|
-
});
|
|
1959
|
-
metadata = built;
|
|
1960
|
-
}
|
|
1961
|
-
catch {
|
|
1962
|
-
// Non-critical: upload results without metadata
|
|
1985
|
+
// The upload TARGET (the API/system under test runs belong to — ADR 0007)
|
|
1986
|
+
// was resolved + validated in the preflight (pre-run, so a misconfigured
|
|
1987
|
+
// destination fails fast); reuse it. The guard is defensive — the preflight
|
|
1988
|
+
// exits on a null target, so this can't normally fire.
|
|
1989
|
+
const targetId = resolvedUploadTargetId;
|
|
1990
|
+
if (!targetId) {
|
|
1991
|
+
console.error(`${colors.red}Upload failed: no upload target resolved.${colors.reset}`);
|
|
1992
|
+
process.exit(1);
|
|
1963
1993
|
}
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1994
|
+
else {
|
|
1995
|
+
// ── Result blob: the full ExecutionResult, run-data ONLY (per D7 the
|
|
1996
|
+
// contract/workflow projection is a separate c/f line). Events are
|
|
1997
|
+
// scope-redacted; the rest of the payload can ALSO carry secrets, so
|
|
1998
|
+
// scrub it too: `context.command` is raw argv (e.g. `--token glb_…`,
|
|
1999
|
+
// `--input-json '{"password":…}'`) → dropped outright; `customMetadata`
|
|
2000
|
+
// is user-supplied → deep-redacted. Without this the blob would store
|
|
2001
|
+
// those verbatim in Cloud.
|
|
2002
|
+
const { command: _rawCommand, ...safeContext } = runContext ?? {};
|
|
2003
|
+
const redactNonEvent = (v) => redactValue(v, {
|
|
2004
|
+
globalRules: effectiveRedaction.globalRules,
|
|
2005
|
+
replacementFormat: effectiveRedaction.replacementFormat,
|
|
2006
|
+
maxDepth: 64,
|
|
2007
|
+
});
|
|
2008
|
+
const redactedResult = {
|
|
2009
|
+
...resultPayload,
|
|
2010
|
+
context: redactNonEvent(safeContext),
|
|
2011
|
+
...(resultPayload.customMetadata
|
|
2012
|
+
? { customMetadata: redactNonEvent(resultPayload.customMetadata) }
|
|
2013
|
+
: {}),
|
|
2014
|
+
tests: resultPayload.tests.map((t) => ({
|
|
2015
|
+
...t,
|
|
2016
|
+
events: t.events.map((e) => redactEvent(e, compiledScopes, effectiveRedaction.replacementFormat)),
|
|
2017
|
+
})),
|
|
1979
2018
|
};
|
|
1980
|
-
|
|
1981
|
-
|
|
2019
|
+
// ── Analytics substrate. Server derive-on-ingest (plan D2) isn't built
|
|
2020
|
+
// yet, so the CLI sends per-test rows + metric points explicitly.
|
|
2021
|
+
const testResults = collectedRuns.map((r) => ({
|
|
2022
|
+
testId: r.testId,
|
|
2023
|
+
name: r.testName,
|
|
2024
|
+
// Mirror the CLI's own pass/fail/skip classification: a clean skip
|
|
2025
|
+
// (success:true + a status:"skipped" event) → "skipped" (excluded from
|
|
2026
|
+
// flaky denominators, plan D3). A test that FAILED then emitted skip is
|
|
2027
|
+
// counted as failed (success:false), so gate skip on success first —
|
|
2028
|
+
// otherwise the failure would wrongly drop out of the denominators.
|
|
2029
|
+
status: r.success
|
|
2030
|
+
? r.events.some((e) => e.type === "status" && e.status === "skipped")
|
|
2031
|
+
? "skipped"
|
|
2032
|
+
: "passed"
|
|
2033
|
+
: "failed",
|
|
2034
|
+
durationMs: r.durationMs,
|
|
2035
|
+
...(r.tags && r.tags.length ? { tags: r.tags } : {}),
|
|
2036
|
+
eventCount: r.events.length,
|
|
2037
|
+
}));
|
|
2038
|
+
// Metric tags (method/path) can in rare cases embed a secret in a path
|
|
2039
|
+
// segment — redact them with the same engine the projection line uses.
|
|
2040
|
+
const redactTags = (tags) => tags
|
|
2041
|
+
? redactValue(tags, {
|
|
2042
|
+
globalRules: effectiveRedaction.globalRules,
|
|
2043
|
+
replacementFormat: effectiveRedaction.replacementFormat,
|
|
2044
|
+
})
|
|
2045
|
+
: undefined;
|
|
2046
|
+
const metrics = [];
|
|
2047
|
+
for (const r of collectedRuns) {
|
|
2048
|
+
for (const e of r.events) {
|
|
2049
|
+
if (e.type !== "metric")
|
|
2050
|
+
continue;
|
|
2051
|
+
// Skip valueless metric events: the server requires a finite numeric
|
|
2052
|
+
// value, and one bad point must not reject the whole run's upload.
|
|
2053
|
+
if (!Number.isFinite(e.value))
|
|
2054
|
+
continue;
|
|
2055
|
+
metrics.push({
|
|
2056
|
+
name: e.name,
|
|
2057
|
+
value: e.value,
|
|
2058
|
+
...(e.unit ? { unit: e.unit } : {}),
|
|
2059
|
+
...(e.tags ? { tags: redactTags(e.tags) } : {}),
|
|
2060
|
+
testId: r.testId,
|
|
2061
|
+
});
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
const input = {
|
|
2065
|
+
kind: "test",
|
|
2066
|
+
schemaVersion: "glubean.test.v1",
|
|
2067
|
+
// Stable idempotency id for this run — reused across the upload retry so
|
|
2068
|
+
// a lost-response retry replaces this run instead of duplicating it (P1).
|
|
2069
|
+
clientRunId: randomUUID(),
|
|
2070
|
+
// A breached metric threshold fails the run (mirrors the process exit
|
|
2071
|
+
// below) even when every test passed — don't record it as "passed".
|
|
2072
|
+
status: failed > 0 || (thresholdSummary && !thresholdSummary.pass) ? "failed" : "passed",
|
|
2073
|
+
startedAt: runStartTime,
|
|
2074
|
+
completedAt: new Date(Date.parse(runStartTime) + totalDurationMs).toISOString(),
|
|
2075
|
+
durationMs: totalDurationMs,
|
|
2076
|
+
summary: {
|
|
2077
|
+
total: passed + failed + skipped,
|
|
2078
|
+
passed,
|
|
2079
|
+
failed,
|
|
2080
|
+
skipped,
|
|
2081
|
+
durationMs: totalDurationMs,
|
|
2082
|
+
// Run-plan provenance (was metadata.runPlan). The summary jsonb keeps
|
|
2083
|
+
// extras (SUMMARY_SCHEMA catchall), so profile/suite facets survive
|
|
2084
|
+
// for grouping even though the new run row has no dedicated columns.
|
|
2085
|
+
...(options.profile ? { profile: options.profile } : {}),
|
|
2086
|
+
...(options.suites && options.suites.length ? { suites: options.suites } : {}),
|
|
2087
|
+
},
|
|
2088
|
+
result: redactedResult,
|
|
2089
|
+
...(testResults.length ? { testResults } : {}),
|
|
2090
|
+
...(metrics.length ? { metrics } : {}),
|
|
2091
|
+
};
|
|
2092
|
+
const uploadReceipt = await uploadToCloud(input, {
|
|
2093
|
+
apiUrl,
|
|
2094
|
+
token,
|
|
2095
|
+
projectId,
|
|
2096
|
+
targetId,
|
|
2097
|
+
envFile: effectiveRun.envFile,
|
|
2098
|
+
rootDir,
|
|
2099
|
+
});
|
|
2100
|
+
if (options.uploadReceiptJson) {
|
|
2101
|
+
const receiptPath = resolveOutputPath(options.uploadReceiptJson, process.cwd());
|
|
2102
|
+
await mkdir(dirname(receiptPath), { recursive: true });
|
|
2103
|
+
await writeFile(receiptPath, JSON.stringify(uploadReceipt, null, 2) + "\n", "utf-8");
|
|
2104
|
+
console.log(`${colors.dim}Upload receipt written to: ${receiptPath}${colors.reset}`);
|
|
2105
|
+
}
|
|
2106
|
+
// A requested --upload that didn't create a run is a failure, even on a
|
|
2107
|
+
// green test run — exit non-zero so CI doesn't read false-green. (The
|
|
2108
|
+
// receipt is written above first, so the failure is still recorded.)
|
|
2109
|
+
if (uploadReceipt.resultUpload.status === "failed") {
|
|
2110
|
+
console.error(`${colors.red}Upload failed: the run was not recorded in Cloud (see the error above).${colors.reset}`);
|
|
2111
|
+
process.exit(1);
|
|
1982
2112
|
}
|
|
1983
|
-
metadata = { ...metadata, runPlan };
|
|
1984
|
-
}
|
|
1985
|
-
// Deep-redact the FULL contract + workflow projection before upload. Test
|
|
1986
|
-
// events are redacted below via scope-based `redactEvent`, but the
|
|
1987
|
-
// projection is a free-form tree that can carry secrets anywhere
|
|
1988
|
-
// (examples, default headers, gRPC metadata, `extensions`/`meta`, literal
|
|
1989
|
-
// compare/switch values, assertion messages). `redactMetadataForUpload`
|
|
1990
|
-
// redacts ONLY the projection buckets (contractsProjection + workflows) —
|
|
1991
|
-
// never `files`/`rootHash` — so the server's test registry/dedup keeps
|
|
1992
|
-
// its verbatim sha256 hashes. The projection is uploaded WHOLE (branch/
|
|
1993
|
-
// poll included): it is the lossless source for the server snapshot, not
|
|
1994
|
-
// a run view (see the buildMetadata R14 note); the branch/poll run-view
|
|
1995
|
-
// gate is a separate layer, untouched here.
|
|
1996
|
-
if (metadata) {
|
|
1997
|
-
metadata = await redactMetadataForUpload(metadata, effectiveRedaction);
|
|
1998
|
-
}
|
|
1999
|
-
const redactedPayload = {
|
|
2000
|
-
...resultPayload,
|
|
2001
|
-
metadata,
|
|
2002
|
-
tests: resultPayload.tests.map((t) => ({
|
|
2003
|
-
...t,
|
|
2004
|
-
events: t.events.map((e) => redactEvent(e, compiledScopes, effectiveRedaction.replacementFormat)),
|
|
2005
|
-
})),
|
|
2006
|
-
};
|
|
2007
|
-
const uploadReceipt = await uploadToCloud(redactedPayload, {
|
|
2008
|
-
apiUrl,
|
|
2009
|
-
token,
|
|
2010
|
-
projectId,
|
|
2011
|
-
envFile: effectiveRun.envFile,
|
|
2012
|
-
rootDir,
|
|
2013
|
-
});
|
|
2014
|
-
if (options.uploadReceiptJson) {
|
|
2015
|
-
const receiptPath = resolveOutputPath(options.uploadReceiptJson, process.cwd());
|
|
2016
|
-
await mkdir(dirname(receiptPath), { recursive: true });
|
|
2017
|
-
await writeFile(receiptPath, JSON.stringify(uploadReceipt, null, 2) + "\n", "utf-8");
|
|
2018
|
-
console.log(`${colors.dim}Upload receipt written to: ${receiptPath}${colors.reset}`);
|
|
2019
2113
|
}
|
|
2020
2114
|
}
|
|
2021
2115
|
}
|