@glubean/cli 0.1.2
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/bin/gb.js +2 -0
- package/dist/commands/init.d.ts +19 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +842 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/login.d.ts +10 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +75 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/patch.d.ts +8 -0
- package/dist/commands/patch.d.ts.map +1 -0
- package/dist/commands/patch.js +73 -0
- package/dist/commands/patch.js.map +1 -0
- package/dist/commands/run.d.ts +26 -0
- package/dist/commands/run.d.ts.map +1 -0
- package/dist/commands/run.js +1093 -0
- package/dist/commands/run.js.map +1 -0
- package/dist/commands/scan.d.ts +6 -0
- package/dist/commands/scan.d.ts.map +1 -0
- package/dist/commands/scan.js +62 -0
- package/dist/commands/scan.js.map +1 -0
- package/dist/commands/spec_split.d.ts +5 -0
- package/dist/commands/spec_split.d.ts.map +1 -0
- package/dist/commands/spec_split.js +56 -0
- package/dist/commands/spec_split.js.map +1 -0
- package/dist/commands/sync.d.ts +13 -0
- package/dist/commands/sync.d.ts.map +1 -0
- package/dist/commands/sync.js +252 -0
- package/dist/commands/sync.js.map +1 -0
- package/dist/commands/trigger.d.ts +13 -0
- package/dist/commands/trigger.d.ts.map +1 -0
- package/dist/commands/trigger.js +213 -0
- package/dist/commands/trigger.js.map +1 -0
- package/dist/commands/validate_metadata.d.ts +6 -0
- package/dist/commands/validate_metadata.d.ts.map +1 -0
- package/dist/commands/validate_metadata.js +103 -0
- package/dist/commands/validate_metadata.js.map +1 -0
- package/dist/commands/worker.d.ts +14 -0
- package/dist/commands/worker.d.ts.map +1 -0
- package/dist/commands/worker.js +10 -0
- package/dist/commands/worker.js.map +1 -0
- package/dist/lib/auth.d.ts +39 -0
- package/dist/lib/auth.d.ts.map +1 -0
- package/dist/lib/auth.js +82 -0
- package/dist/lib/auth.js.map +1 -0
- package/dist/lib/ci.d.ts +12 -0
- package/dist/lib/ci.d.ts.map +1 -0
- package/dist/lib/ci.js +42 -0
- package/dist/lib/ci.js.map +1 -0
- package/dist/lib/config.d.ts +116 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.js +264 -0
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/constants.d.ts +6 -0
- package/dist/lib/constants.d.ts.map +1 -0
- package/dist/lib/constants.js +6 -0
- package/dist/lib/constants.js.map +1 -0
- package/dist/lib/env.d.ts +19 -0
- package/dist/lib/env.d.ts.map +1 -0
- package/dist/lib/env.js +40 -0
- package/dist/lib/env.js.map +1 -0
- package/dist/lib/git.d.ts +8 -0
- package/dist/lib/git.d.ts.map +1 -0
- package/dist/lib/git.js +68 -0
- package/dist/lib/git.js.map +1 -0
- package/dist/lib/openapi_patch.d.ts +23 -0
- package/dist/lib/openapi_patch.d.ts.map +1 -0
- package/dist/lib/openapi_patch.js +232 -0
- package/dist/lib/openapi_patch.js.map +1 -0
- package/dist/lib/openapi_split.d.ts +16 -0
- package/dist/lib/openapi_split.d.ts.map +1 -0
- package/dist/lib/openapi_split.js +188 -0
- package/dist/lib/openapi_split.js.map +1 -0
- package/dist/lib/upload.d.ts +44 -0
- package/dist/lib/upload.d.ts.map +1 -0
- package/dist/lib/upload.js +297 -0
- package/dist/lib/upload.js.map +1 -0
- package/dist/main.d.ts +8 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +319 -0
- package/dist/main.js.map +1 -0
- package/dist/metadata.d.ts +17 -0
- package/dist/metadata.d.ts.map +1 -0
- package/dist/metadata.js +61 -0
- package/dist/metadata.js.map +1 -0
- package/dist/update_check.d.ts +14 -0
- package/dist/update_check.d.ts.map +1 -0
- package/dist/update_check.js +130 -0
- package/dist/update_check.js.map +1 -0
- package/dist/version.d.ts +5 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +11 -0
- package/dist/version.js.map +1 -0
- package/package.json +34 -0
- package/templates/AI-INSTRUCTIONS.md +163 -0
- package/templates/README.md +226 -0
- package/templates/claude-skill-glubean-test.md +382 -0
- package/templates/data/create-user.json +14 -0
- package/templates/data/endpoints.csv +5 -0
- package/templates/data/scenarios.yaml +19 -0
- package/templates/data/search-examples.json +14 -0
- package/templates/data/users.json +17 -0
- package/templates/data-driven.test.ts.tpl +118 -0
- package/templates/demo.test.result.json +398 -0
- package/templates/demo.test.ts.tpl +226 -0
- package/templates/explore-api.test.result.json +79 -0
- package/templates/minimal/README.md +42 -0
- package/templates/minimal-api.test.ts.tpl +42 -0
- package/templates/minimal-auth.test.ts.tpl +45 -0
- package/templates/minimal-search.test.ts.tpl +34 -0
- package/templates/openapi.sample.json +97 -0
- package/templates/pick.test.result.json +165 -0
- package/templates/pick.test.ts.tpl +126 -0
|
@@ -0,0 +1,1093 @@
|
|
|
1
|
+
import { evaluateThresholds, MetricCollector, normalizePositiveTimeoutMs, TestExecutor, toSingleExecutionOptions, } from "@glubean/runner";
|
|
2
|
+
import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
import { stat, readdir, readFile, writeFile, mkdir, rm } from "node:fs/promises";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { glob } from "node:fs/promises";
|
|
6
|
+
import { loadConfig, mergeRunOptions, toSharedRunConfig } from "../lib/config.js";
|
|
7
|
+
import { loadEnvFile } from "../lib/env.js";
|
|
8
|
+
import { extractFromSource } from "@glubean/scanner/static";
|
|
9
|
+
// ANSI color codes for pretty output
|
|
10
|
+
const colors = {
|
|
11
|
+
reset: "\x1b[0m",
|
|
12
|
+
bold: "\x1b[1m",
|
|
13
|
+
dim: "\x1b[2m",
|
|
14
|
+
green: "\x1b[32m",
|
|
15
|
+
red: "\x1b[31m",
|
|
16
|
+
yellow: "\x1b[33m",
|
|
17
|
+
blue: "\x1b[34m",
|
|
18
|
+
cyan: "\x1b[36m",
|
|
19
|
+
gray: "\x1b[90m",
|
|
20
|
+
};
|
|
21
|
+
const CLOUD_MEMORY_LIMITS = {
|
|
22
|
+
free: 300,
|
|
23
|
+
pro: 700,
|
|
24
|
+
};
|
|
25
|
+
const MEMORY_WARNING_THRESHOLD_MB = CLOUD_MEMORY_LIMITS.free * 0.67;
|
|
26
|
+
async function findProjectConfig(startDir) {
|
|
27
|
+
let dir = startDir;
|
|
28
|
+
while (dir !== "/") {
|
|
29
|
+
try {
|
|
30
|
+
const pkgJson = resolve(dir, "package.json");
|
|
31
|
+
await stat(pkgJson);
|
|
32
|
+
return { rootDir: dir, configPath: pkgJson };
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
dir = resolve(dir, "..");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { rootDir: startDir };
|
|
39
|
+
}
|
|
40
|
+
const DEFAULT_SKIP_DIRS = ["node_modules", ".git", "dist", "build"];
|
|
41
|
+
const DEFAULT_EXTENSIONS = ["ts"];
|
|
42
|
+
function isGlob(target) {
|
|
43
|
+
return /[*?{[]/.test(target);
|
|
44
|
+
}
|
|
45
|
+
async function walkTestFiles(dir, result) {
|
|
46
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
if (DEFAULT_SKIP_DIRS.includes(entry.name))
|
|
49
|
+
continue;
|
|
50
|
+
const full = resolve(dir, entry.name);
|
|
51
|
+
if (entry.isFile() && entry.name.endsWith(".test.ts")) {
|
|
52
|
+
result.push(full);
|
|
53
|
+
}
|
|
54
|
+
else if (entry.isDirectory()) {
|
|
55
|
+
await walkTestFiles(full, result);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async function resolveTestFiles(target) {
|
|
60
|
+
const abs = resolve(target);
|
|
61
|
+
try {
|
|
62
|
+
const s = await stat(abs);
|
|
63
|
+
if (s.isFile())
|
|
64
|
+
return [abs];
|
|
65
|
+
if (s.isDirectory()) {
|
|
66
|
+
const files = [];
|
|
67
|
+
await walkTestFiles(abs, files);
|
|
68
|
+
files.sort();
|
|
69
|
+
return files;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// stat failed — might be a glob pattern
|
|
74
|
+
}
|
|
75
|
+
if (isGlob(target)) {
|
|
76
|
+
const files = [];
|
|
77
|
+
for await (const entry of glob(target, { cwd: process.cwd() })) {
|
|
78
|
+
const full = resolve(process.cwd(), entry);
|
|
79
|
+
if (full.endsWith(".test.ts")) {
|
|
80
|
+
const s = await stat(full).catch(() => null);
|
|
81
|
+
if (s?.isFile())
|
|
82
|
+
files.push(full);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
files.sort();
|
|
86
|
+
return files;
|
|
87
|
+
}
|
|
88
|
+
return [abs];
|
|
89
|
+
}
|
|
90
|
+
async function discoverTests(filePath) {
|
|
91
|
+
const content = await readFile(filePath, "utf-8");
|
|
92
|
+
const metas = extractFromSource(content);
|
|
93
|
+
return metas.map((m) => ({
|
|
94
|
+
exportName: m.exportName,
|
|
95
|
+
meta: {
|
|
96
|
+
id: m.id,
|
|
97
|
+
name: m.name,
|
|
98
|
+
tags: m.tags,
|
|
99
|
+
timeout: m.timeout,
|
|
100
|
+
skip: m.skip,
|
|
101
|
+
only: m.only,
|
|
102
|
+
groupId: m.groupId,
|
|
103
|
+
},
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
function matchesFilter(testItem, filter) {
|
|
107
|
+
const lowerFilter = filter.toLowerCase();
|
|
108
|
+
if (testItem.meta.id.toLowerCase().includes(lowerFilter))
|
|
109
|
+
return true;
|
|
110
|
+
if (testItem.meta.name?.toLowerCase().includes(lowerFilter))
|
|
111
|
+
return true;
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
function matchesTags(testItem, tags, mode = "or") {
|
|
115
|
+
if (!testItem.meta.tags?.length)
|
|
116
|
+
return false;
|
|
117
|
+
const lowerTestTags = testItem.meta.tags.map((t) => t.toLowerCase());
|
|
118
|
+
const match = (t) => lowerTestTags.includes(t.toLowerCase());
|
|
119
|
+
return mode === "and" ? tags.every(match) : tags.some(match);
|
|
120
|
+
}
|
|
121
|
+
function getLogFilePath(testFilePath) {
|
|
122
|
+
const lastDot = testFilePath.lastIndexOf(".");
|
|
123
|
+
if (lastDot === -1)
|
|
124
|
+
return testFilePath + ".log";
|
|
125
|
+
return testFilePath.slice(0, lastDot) + ".log";
|
|
126
|
+
}
|
|
127
|
+
function resolveOutputPath(userPath, cwd) {
|
|
128
|
+
if (isAbsolute(userPath)) {
|
|
129
|
+
return resolve(userPath);
|
|
130
|
+
}
|
|
131
|
+
const resolved = resolve(cwd, userPath);
|
|
132
|
+
const rel = relative(cwd, resolved);
|
|
133
|
+
if (rel.startsWith("..")) {
|
|
134
|
+
throw new Error(`Output path "${userPath}" escapes the project directory. ` +
|
|
135
|
+
`Use an absolute path to write outside the project.`);
|
|
136
|
+
}
|
|
137
|
+
return resolved;
|
|
138
|
+
}
|
|
139
|
+
async function writeEmptyResult(target, runAt) {
|
|
140
|
+
const payload = {
|
|
141
|
+
target,
|
|
142
|
+
files: [],
|
|
143
|
+
runAt,
|
|
144
|
+
summary: { total: 0, passed: 0, failed: 0, skipped: 0, durationMs: 0, stats: {} },
|
|
145
|
+
tests: [],
|
|
146
|
+
};
|
|
147
|
+
try {
|
|
148
|
+
const glubeanDir = resolve(process.cwd(), ".glubean");
|
|
149
|
+
await mkdir(glubeanDir, { recursive: true });
|
|
150
|
+
await writeFile(resolve(glubeanDir, "last-run.result.json"), JSON.stringify(payload, null, 2), "utf-8");
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// Non-critical
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
export async function runCommand(target, options = {}) {
|
|
157
|
+
const logEntries = [];
|
|
158
|
+
const runStartDate = new Date();
|
|
159
|
+
const runStartTime = runStartDate.toISOString();
|
|
160
|
+
const runStartLocal = localTimeString(runStartDate);
|
|
161
|
+
const traceCollector = [];
|
|
162
|
+
console.log(`\n${colors.bold}${colors.blue}🧪 Glubean Test Runner${colors.reset}\n`);
|
|
163
|
+
const testFiles = await resolveTestFiles(target);
|
|
164
|
+
const isMultiFile = testFiles.length > 1;
|
|
165
|
+
if (testFiles.length === 0) {
|
|
166
|
+
console.error(`\n${colors.red}❌ No test files found for target: ${target}${colors.reset}`);
|
|
167
|
+
console.error(`${colors.dim}Glubean looks for files matching *.test.ts in the target directory.${colors.reset}`);
|
|
168
|
+
console.error(`${colors.dim}Run "glubean run tests/" or "glubean run path/to/file.test.ts".${colors.reset}\n`);
|
|
169
|
+
await writeEmptyResult(target, runStartLocal);
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
if (isMultiFile) {
|
|
173
|
+
console.log(`${colors.dim}Target: ${resolve(target)}${colors.reset}`);
|
|
174
|
+
console.log(`${colors.dim}Files: ${testFiles.length} test file(s)${colors.reset}\n`);
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
console.log(`${colors.dim}File: ${testFiles[0]}${colors.reset}\n`);
|
|
178
|
+
}
|
|
179
|
+
const startDir = testFiles[0].substring(0, testFiles[0].lastIndexOf("/"));
|
|
180
|
+
const { rootDir, configPath } = await findProjectConfig(startDir);
|
|
181
|
+
const glubeanConfig = await loadConfig(rootDir, options.configFiles);
|
|
182
|
+
const effectiveRun = mergeRunOptions(glubeanConfig.run, {
|
|
183
|
+
verbose: options.verbose,
|
|
184
|
+
pretty: options.pretty,
|
|
185
|
+
logFile: options.logFile,
|
|
186
|
+
emitFullTrace: options.emitFullTrace,
|
|
187
|
+
envFile: options.envFile,
|
|
188
|
+
failFast: options.failFast,
|
|
189
|
+
failAfter: options.failAfter,
|
|
190
|
+
});
|
|
191
|
+
if (effectiveRun.logFile && !isMultiFile) {
|
|
192
|
+
const logPath = getLogFilePath(testFiles[0]);
|
|
193
|
+
console.log(`${colors.dim}Log file: ${logPath}${colors.reset}`);
|
|
194
|
+
}
|
|
195
|
+
const envFileName = effectiveRun.envFile || ".env";
|
|
196
|
+
const envPath = resolve(rootDir, envFileName);
|
|
197
|
+
const userSpecifiedEnvFile = !!options.envFile;
|
|
198
|
+
if (userSpecifiedEnvFile) {
|
|
199
|
+
try {
|
|
200
|
+
await stat(envPath);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
console.error(`${colors.red}Error: env file '${envFileName}' not found in ${rootDir}${colors.reset}`);
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const envVars = await loadEnvFile(envPath);
|
|
208
|
+
const secretsPath = resolve(rootDir, `${envFileName}.secrets`);
|
|
209
|
+
let secretsExist = true;
|
|
210
|
+
try {
|
|
211
|
+
await stat(secretsPath);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
secretsExist = false;
|
|
215
|
+
}
|
|
216
|
+
const secrets = secretsExist ? await loadEnvFile(secretsPath) : {};
|
|
217
|
+
if (!secretsExist && Object.keys(envVars).length > 0) {
|
|
218
|
+
console.warn(`${colors.yellow}Warning: secrets file '${envFileName}.secrets' not found in ${rootDir}${colors.reset}`);
|
|
219
|
+
}
|
|
220
|
+
if (Object.keys(envVars).length > 0) {
|
|
221
|
+
console.log(`${colors.dim}Loaded ${Object.keys(envVars).length} vars from ${envFileName}${colors.reset}`);
|
|
222
|
+
}
|
|
223
|
+
// ── Preflight: verify auth before running tests when --upload is set ────
|
|
224
|
+
if (options.upload) {
|
|
225
|
+
const { resolveToken, resolveProjectId, resolveApiUrl } = await import("../lib/auth.js");
|
|
226
|
+
const authOpts = {
|
|
227
|
+
token: options.token,
|
|
228
|
+
project: options.project,
|
|
229
|
+
apiUrl: options.apiUrl,
|
|
230
|
+
};
|
|
231
|
+
const sources = {
|
|
232
|
+
envFileVars: { ...envVars, ...secrets },
|
|
233
|
+
cloudConfig: glubeanConfig.cloud,
|
|
234
|
+
};
|
|
235
|
+
const preToken = await resolveToken(authOpts, sources);
|
|
236
|
+
const preProject = await resolveProjectId(authOpts, sources);
|
|
237
|
+
const preApiUrl = await resolveApiUrl(authOpts, sources);
|
|
238
|
+
if (!preToken) {
|
|
239
|
+
console.error(`${colors.red}Error: --upload requires authentication but no token found.${colors.reset}`);
|
|
240
|
+
console.error(`${colors.dim}Run 'glubean login', set GLUBEAN_TOKEN, or add token to .env.secrets or package.json glubean.cloud.${colors.reset}`);
|
|
241
|
+
process.exit(1);
|
|
242
|
+
}
|
|
243
|
+
if (!preProject) {
|
|
244
|
+
console.error(`${colors.red}Error: --upload requires a project ID but none found.${colors.reset}`);
|
|
245
|
+
console.error(`${colors.dim}Use --project, set projectId in package.json glubean.cloud, or run 'glubean login'.${colors.reset}`);
|
|
246
|
+
process.exit(1);
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
const resp = await fetch(`${preApiUrl}/open/v1/whoami`, {
|
|
250
|
+
headers: { Authorization: `Bearer ${preToken}` },
|
|
251
|
+
});
|
|
252
|
+
if (!resp.ok) {
|
|
253
|
+
console.error(`${colors.red}Error: authentication failed (${resp.status}).${colors.reset}`);
|
|
254
|
+
if (resp.status === 401) {
|
|
255
|
+
console.error(`${colors.dim}Token is invalid or expired. Run 'glubean login' to re-authenticate.${colors.reset}`);
|
|
256
|
+
}
|
|
257
|
+
process.exit(1);
|
|
258
|
+
}
|
|
259
|
+
const identity = await resp.json();
|
|
260
|
+
console.log(`${colors.dim}Authenticated as ${identity.kind === "project_token" ? `project token (${identity.projectName})` : "user"} · upload to ${preApiUrl}${colors.reset}`);
|
|
261
|
+
}
|
|
262
|
+
catch (err) {
|
|
263
|
+
console.error(`${colors.red}Error: cannot reach server at ${preApiUrl}${colors.reset}`);
|
|
264
|
+
console.error(`${colors.dim}${err.message}${colors.reset}`);
|
|
265
|
+
process.exit(1);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// ── Discover tests across all files ─────────────────────────────────────
|
|
269
|
+
console.log(`${colors.dim}Discovering tests...${colors.reset}`);
|
|
270
|
+
const allFileTests = [];
|
|
271
|
+
let totalDiscovered = 0;
|
|
272
|
+
for (const filePath of testFiles) {
|
|
273
|
+
try {
|
|
274
|
+
const tests = await discoverTests(filePath);
|
|
275
|
+
for (const test of tests) {
|
|
276
|
+
allFileTests.push({ filePath, exportName: test.exportName, test });
|
|
277
|
+
}
|
|
278
|
+
totalDiscovered += tests.length;
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
if (isMultiFile) {
|
|
282
|
+
const relPath = relative(process.cwd(), filePath);
|
|
283
|
+
console.error(` ${colors.red}✗${colors.reset} ${relPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
console.error(`\n${colors.red}❌ Failed to load test file${colors.reset}`);
|
|
287
|
+
console.error(`${colors.dim}${error instanceof Error ? error.message : String(error)}${colors.reset}`);
|
|
288
|
+
process.exit(1);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (allFileTests.length === 0) {
|
|
293
|
+
console.error(`\n${colors.red}❌ No test cases found${isMultiFile ? ` in ${testFiles.length} file(s)` : " in file"}${colors.reset}`);
|
|
294
|
+
console.error(`${colors.dim}Each test file must export tests: export const myTest = test("id")...${colors.reset}\n`);
|
|
295
|
+
process.exit(1);
|
|
296
|
+
}
|
|
297
|
+
if (isMultiFile) {
|
|
298
|
+
const fileCounts = new Map();
|
|
299
|
+
for (const ft of allFileTests) {
|
|
300
|
+
fileCounts.set(ft.filePath, (fileCounts.get(ft.filePath) || 0) + 1);
|
|
301
|
+
}
|
|
302
|
+
for (const [fp, count] of fileCounts) {
|
|
303
|
+
const relPath = relative(process.cwd(), fp);
|
|
304
|
+
console.log(` ${colors.dim}${relPath} (${count} test${count === 1 ? "" : "s"})${colors.reset}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const hasOnly = allFileTests.some((ft) => ft.test.meta.only);
|
|
308
|
+
if (hasOnly) {
|
|
309
|
+
console.log(`${colors.yellow}ℹ️ Running only tests marked with .only${colors.reset}`);
|
|
310
|
+
}
|
|
311
|
+
const hasTags = options.tags && options.tags.length > 0;
|
|
312
|
+
const testsToRun = allFileTests.filter((ft) => {
|
|
313
|
+
const tc = ft.test;
|
|
314
|
+
if (tc.meta.skip)
|
|
315
|
+
return false;
|
|
316
|
+
if (hasOnly && !tc.meta.only)
|
|
317
|
+
return false;
|
|
318
|
+
if (options.filter && !matchesFilter(tc, options.filter))
|
|
319
|
+
return false;
|
|
320
|
+
if (hasTags && !matchesTags(tc, options.tags, options.tagMode))
|
|
321
|
+
return false;
|
|
322
|
+
return true;
|
|
323
|
+
});
|
|
324
|
+
if (testsToRun.length === 0) {
|
|
325
|
+
if (options.filter || hasTags) {
|
|
326
|
+
const parts = [];
|
|
327
|
+
if (options.filter)
|
|
328
|
+
parts.push(`filter: "${options.filter}"`);
|
|
329
|
+
if (hasTags) {
|
|
330
|
+
const joiner = options.tagMode === "and" ? " AND " : " OR ";
|
|
331
|
+
parts.push(`tag: ${options.tags.join(joiner)}`);
|
|
332
|
+
}
|
|
333
|
+
console.error(`\n${colors.red}❌ No tests match ${parts.join(" + ")}${colors.reset}\n`);
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
console.error(`\n${colors.red}❌ All tests skipped${colors.reset}\n`);
|
|
337
|
+
}
|
|
338
|
+
process.exit(1);
|
|
339
|
+
}
|
|
340
|
+
if (options.filter || hasTags) {
|
|
341
|
+
const parts = [];
|
|
342
|
+
if (options.filter)
|
|
343
|
+
parts.push(`filter: "${options.filter}"`);
|
|
344
|
+
if (hasTags) {
|
|
345
|
+
const joiner = options.tagMode === "and" ? " AND " : " OR ";
|
|
346
|
+
parts.push(`tag: ${options.tags.join(joiner)}`);
|
|
347
|
+
}
|
|
348
|
+
console.log(`${colors.dim}${parts.join(" + ")} (${testsToRun.length}/${totalDiscovered} tests)${colors.reset}`);
|
|
349
|
+
}
|
|
350
|
+
console.log(`\n${colors.bold}Running ${testsToRun.length} test(s)...${colors.reset}\n`);
|
|
351
|
+
if (options.pick) {
|
|
352
|
+
process.env.GLUBEAN_PICK = options.pick;
|
|
353
|
+
console.log(`${colors.dim} pick: ${options.pick}${colors.reset}`);
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
delete process.env.GLUBEAN_PICK;
|
|
357
|
+
}
|
|
358
|
+
const shared = toSharedRunConfig(effectiveRun);
|
|
359
|
+
const executor = TestExecutor.fromSharedConfig(shared, {
|
|
360
|
+
cwd: rootDir,
|
|
361
|
+
...(options.inspectBrk && { inspectBrk: options.inspectBrk }),
|
|
362
|
+
});
|
|
363
|
+
let passed = 0;
|
|
364
|
+
let failed = 0;
|
|
365
|
+
let skipped = 0;
|
|
366
|
+
let overallPeakMemoryMB = 0;
|
|
367
|
+
const totalStartTime = Date.now();
|
|
368
|
+
const collectedRuns = [];
|
|
369
|
+
const metricCollector = new MetricCollector();
|
|
370
|
+
const runStats = {
|
|
371
|
+
httpRequestTotal: 0,
|
|
372
|
+
httpErrorTotal: 0,
|
|
373
|
+
assertionTotal: 0,
|
|
374
|
+
assertionFailed: 0,
|
|
375
|
+
warningTotal: 0,
|
|
376
|
+
warningTriggered: 0,
|
|
377
|
+
stepTotal: 0,
|
|
378
|
+
stepPassed: 0,
|
|
379
|
+
stepFailed: 0,
|
|
380
|
+
};
|
|
381
|
+
const failureLimit = effectiveRun.failAfter ??
|
|
382
|
+
(effectiveRun.failFast ? 1 : undefined);
|
|
383
|
+
const fileGroups = new Map();
|
|
384
|
+
for (const entry of testsToRun) {
|
|
385
|
+
const group = fileGroups.get(entry.filePath) || [];
|
|
386
|
+
group.push(entry);
|
|
387
|
+
fileGroups.set(entry.filePath, group);
|
|
388
|
+
}
|
|
389
|
+
const compactUrl = (url) => {
|
|
390
|
+
try {
|
|
391
|
+
const u = new URL(url);
|
|
392
|
+
return u.pathname + (u.search || "");
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
return url;
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
const colorStatus = (status) => {
|
|
399
|
+
if (status >= 500)
|
|
400
|
+
return `${colors.red}${status}${colors.reset}`;
|
|
401
|
+
if (status >= 400)
|
|
402
|
+
return `${colors.yellow}${status}${colors.reset}`;
|
|
403
|
+
return `${colors.green}${status}${colors.reset}`;
|
|
404
|
+
};
|
|
405
|
+
for (const [groupFilePath, fileTests] of fileGroups) {
|
|
406
|
+
if (isMultiFile) {
|
|
407
|
+
const relPath = relative(process.cwd(), groupFilePath);
|
|
408
|
+
console.log(`${colors.bold}📁 ${relPath}${colors.reset}`);
|
|
409
|
+
}
|
|
410
|
+
if (failureLimit !== undefined && failed >= failureLimit) {
|
|
411
|
+
for (const { test } of fileTests) {
|
|
412
|
+
skipped++;
|
|
413
|
+
const name = test.meta.name || test.meta.id;
|
|
414
|
+
console.log(` ${colors.yellow}○${colors.reset} ${name} ${colors.dim}(skipped — fail-fast)${colors.reset}`);
|
|
415
|
+
}
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
const testIds = fileTests.map((ft) => ft.test.meta.id);
|
|
419
|
+
const exportNames = {};
|
|
420
|
+
for (const ft of fileTests) {
|
|
421
|
+
exportNames[ft.test.meta.id] = ft.exportName;
|
|
422
|
+
}
|
|
423
|
+
const testMap = new Map(fileTests.map((ft) => [ft.test.meta.id, ft]));
|
|
424
|
+
const testFileUrl = pathToFileURL(groupFilePath).toString();
|
|
425
|
+
const batchTimeout = fileTests.reduce((sum, ft) => {
|
|
426
|
+
return sum +
|
|
427
|
+
(normalizePositiveTimeoutMs(ft.test.meta.timeout) ??
|
|
428
|
+
shared.perTestTimeoutMs ?? 30_000);
|
|
429
|
+
}, 0);
|
|
430
|
+
let testId = "";
|
|
431
|
+
let testName = "";
|
|
432
|
+
let testItem = null;
|
|
433
|
+
let startTime = Date.now();
|
|
434
|
+
let testEvents = [];
|
|
435
|
+
let assertions = [];
|
|
436
|
+
let success = false;
|
|
437
|
+
let errorMsg;
|
|
438
|
+
let peakMemoryMB;
|
|
439
|
+
let stepAssertionCount = 0;
|
|
440
|
+
let stepTraceLines = [];
|
|
441
|
+
let testStarted = false;
|
|
442
|
+
const addLogEntry = (type, message, data) => {
|
|
443
|
+
if (effectiveRun.logFile) {
|
|
444
|
+
logEntries.push({
|
|
445
|
+
timestamp: new Date().toISOString(),
|
|
446
|
+
testId,
|
|
447
|
+
testName,
|
|
448
|
+
type,
|
|
449
|
+
message,
|
|
450
|
+
data,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
const finalizeTest = () => {
|
|
455
|
+
if (!testStarted)
|
|
456
|
+
return;
|
|
457
|
+
testStarted = false;
|
|
458
|
+
const duration = Date.now() - startTime;
|
|
459
|
+
const allAssertionsPassed = assertions.every((a) => a.passed);
|
|
460
|
+
const finalSuccess = success && allAssertionsPassed;
|
|
461
|
+
collectedRuns.push({
|
|
462
|
+
testId,
|
|
463
|
+
testName,
|
|
464
|
+
tags: testItem?.meta.tags,
|
|
465
|
+
filePath: groupFilePath,
|
|
466
|
+
events: testEvents,
|
|
467
|
+
success: finalSuccess,
|
|
468
|
+
durationMs: duration,
|
|
469
|
+
groupId: testItem?.meta.groupId,
|
|
470
|
+
});
|
|
471
|
+
addLogEntry("result", finalSuccess ? "PASSED" : "FAILED", {
|
|
472
|
+
duration,
|
|
473
|
+
success: finalSuccess,
|
|
474
|
+
peakMemoryMB,
|
|
475
|
+
});
|
|
476
|
+
const peakMB = peakMemoryMB ? parseFloat(peakMemoryMB) : 0;
|
|
477
|
+
if (peakMB > overallPeakMemoryMB) {
|
|
478
|
+
overallPeakMemoryMB = peakMB;
|
|
479
|
+
}
|
|
480
|
+
const testHttpCalls = testEvents.filter((e) => e.type === "trace").length;
|
|
481
|
+
const testSteps = testEvents.filter((e) => e.type === "step_end").length;
|
|
482
|
+
const miniStats = [];
|
|
483
|
+
miniStats.push(`${duration}ms`);
|
|
484
|
+
if (testHttpCalls > 0)
|
|
485
|
+
miniStats.push(`${testHttpCalls} calls`);
|
|
486
|
+
if (assertions.length > 0)
|
|
487
|
+
miniStats.push(`${assertions.length} checks`);
|
|
488
|
+
if (testSteps > 0)
|
|
489
|
+
miniStats.push(`${testSteps} steps`);
|
|
490
|
+
if (finalSuccess) {
|
|
491
|
+
console.log(` ${colors.green}✓ PASSED${colors.reset} ${colors.dim}(${miniStats.join(", ")})${colors.reset}`);
|
|
492
|
+
passed++;
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
console.log(` ${colors.red}✗ FAILED${colors.reset} ${colors.dim}(${miniStats.join(", ")})${colors.reset}`);
|
|
496
|
+
failed++;
|
|
497
|
+
}
|
|
498
|
+
if (peakMB > MEMORY_WARNING_THRESHOLD_MB) {
|
|
499
|
+
if (peakMB > CLOUD_MEMORY_LIMITS.free) {
|
|
500
|
+
console.log(` ${colors.yellow}⚠ Memory (${peakMemoryMB} MB) exceeds Free cloud runner limit (${CLOUD_MEMORY_LIMITS.free} MB).${colors.reset}`);
|
|
501
|
+
}
|
|
502
|
+
else {
|
|
503
|
+
console.log(` ${colors.yellow}⚠ Memory (${peakMemoryMB} MB) is approaching Free cloud runner limit (${CLOUD_MEMORY_LIMITS.free} MB).${colors.reset}`);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
for (const assertion of assertions) {
|
|
507
|
+
if (!assertion.passed) {
|
|
508
|
+
console.log(` ${colors.red}✗ ${assertion.message}${colors.reset}`);
|
|
509
|
+
if (assertion.expected !== undefined || assertion.actual !== undefined) {
|
|
510
|
+
if (assertion.expected !== undefined) {
|
|
511
|
+
console.log(` ${colors.dim}Expected: ${JSON.stringify(assertion.expected)}${colors.reset}`);
|
|
512
|
+
}
|
|
513
|
+
if (assertion.actual !== undefined) {
|
|
514
|
+
console.log(` ${colors.dim}Actual: ${JSON.stringify(assertion.actual)}${colors.reset}`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
if (errorMsg) {
|
|
520
|
+
console.log(` ${colors.red}Error: ${errorMsg}${colors.reset}`);
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
for await (const event of executor.run(testFileUrl, "", {
|
|
524
|
+
vars: envVars,
|
|
525
|
+
secrets,
|
|
526
|
+
}, {
|
|
527
|
+
...toSingleExecutionOptions(shared),
|
|
528
|
+
timeout: batchTimeout,
|
|
529
|
+
testIds,
|
|
530
|
+
exportNames,
|
|
531
|
+
})) {
|
|
532
|
+
switch (event.type) {
|
|
533
|
+
case "start": {
|
|
534
|
+
const entry = testMap.get(event.id);
|
|
535
|
+
testId = event.id;
|
|
536
|
+
testName = entry?.test.meta.name || event.name || event.id;
|
|
537
|
+
testItem = entry?.test || null;
|
|
538
|
+
startTime = Date.now();
|
|
539
|
+
testEvents = [];
|
|
540
|
+
assertions = [];
|
|
541
|
+
success = false;
|
|
542
|
+
errorMsg = undefined;
|
|
543
|
+
peakMemoryMB = undefined;
|
|
544
|
+
stepAssertionCount = 0;
|
|
545
|
+
stepTraceLines = [];
|
|
546
|
+
testStarted = true;
|
|
547
|
+
const tags = testItem?.meta.tags?.length
|
|
548
|
+
? ` ${colors.dim}[${testItem.meta.tags.join(", ")}]${colors.reset}`
|
|
549
|
+
: "";
|
|
550
|
+
console.log(` ${colors.cyan}●${colors.reset} ${testName}${tags}`);
|
|
551
|
+
break;
|
|
552
|
+
}
|
|
553
|
+
case "status":
|
|
554
|
+
success = event.status === "completed";
|
|
555
|
+
if (event.error) {
|
|
556
|
+
errorMsg = event.error;
|
|
557
|
+
addLogEntry("error", event.error);
|
|
558
|
+
}
|
|
559
|
+
if (event.peakMemoryMB)
|
|
560
|
+
peakMemoryMB = event.peakMemoryMB;
|
|
561
|
+
finalizeTest();
|
|
562
|
+
break;
|
|
563
|
+
case "error":
|
|
564
|
+
success = false;
|
|
565
|
+
if (!errorMsg)
|
|
566
|
+
errorMsg = event.message;
|
|
567
|
+
addLogEntry("error", event.message);
|
|
568
|
+
break;
|
|
569
|
+
case "log":
|
|
570
|
+
addLogEntry("log", event.message);
|
|
571
|
+
if (event.message.startsWith("Loading test module:"))
|
|
572
|
+
break;
|
|
573
|
+
console.log(` ${colors.dim}${event.message}${colors.reset}`);
|
|
574
|
+
break;
|
|
575
|
+
case "assertion":
|
|
576
|
+
assertions.push({
|
|
577
|
+
passed: event.passed,
|
|
578
|
+
message: event.message,
|
|
579
|
+
actual: event.actual,
|
|
580
|
+
expected: event.expected,
|
|
581
|
+
});
|
|
582
|
+
stepAssertionCount++;
|
|
583
|
+
addLogEntry("assertion", event.message, {
|
|
584
|
+
passed: event.passed,
|
|
585
|
+
actual: event.actual,
|
|
586
|
+
expected: event.expected,
|
|
587
|
+
});
|
|
588
|
+
if (effectiveRun.verbose) {
|
|
589
|
+
const icon = event.passed ? `${colors.green}✓${colors.reset}` : `${colors.red}✗${colors.reset}`;
|
|
590
|
+
console.log(` ${icon} ${colors.dim}${event.message}${colors.reset}`);
|
|
591
|
+
}
|
|
592
|
+
break;
|
|
593
|
+
case "trace": {
|
|
594
|
+
const traceMsg = `${event.data.method} ${event.data.url} → ${event.data.status} (${event.data.duration}ms)`;
|
|
595
|
+
addLogEntry("trace", traceMsg, event.data);
|
|
596
|
+
traceCollector.push({
|
|
597
|
+
testId,
|
|
598
|
+
method: event.data.method,
|
|
599
|
+
url: event.data.url,
|
|
600
|
+
status: event.data.status,
|
|
601
|
+
});
|
|
602
|
+
const compactTrace = `${colors.dim}${event.data.method}${colors.reset} ${compactUrl(event.data.url)} ${colors.dim}→${colors.reset} ${colorStatus(event.data.status)} ${colors.dim}${event.data.duration}ms${colors.reset}`;
|
|
603
|
+
stepTraceLines.push(compactTrace);
|
|
604
|
+
console.log(` ${colors.dim}↳${colors.reset} ${compactTrace}`);
|
|
605
|
+
if (effectiveRun.verbose && event.data.requestBody) {
|
|
606
|
+
console.log(` ${colors.dim}req: ${JSON.stringify(event.data.requestBody).slice(0, 120)}${colors.reset}`);
|
|
607
|
+
}
|
|
608
|
+
if (effectiveRun.verbose && event.data.responseBody) {
|
|
609
|
+
const body = JSON.stringify(event.data.responseBody);
|
|
610
|
+
console.log(` ${colors.dim}res: ${body.slice(0, 120)}${body.length > 120 ? "…" : ""}${colors.reset}`);
|
|
611
|
+
}
|
|
612
|
+
break;
|
|
613
|
+
}
|
|
614
|
+
case "action": {
|
|
615
|
+
const a = event.data;
|
|
616
|
+
if (a.category === "http:request")
|
|
617
|
+
break;
|
|
618
|
+
const statusColor = a.status === "ok" ? colors.green : a.status === "error" ? colors.red : colors.yellow;
|
|
619
|
+
const statusIcon = a.status === "ok" ? "✓" : a.status === "error" ? "✗" : "⏱";
|
|
620
|
+
addLogEntry("action", `[${a.category}] ${a.target} ${a.duration}ms ${a.status}`, a);
|
|
621
|
+
console.log(` ${colors.dim}↳${colors.reset} ${colors.cyan}${a.category}${colors.reset} ${a.target} ${colors.dim}${a.duration}ms${colors.reset} ${statusColor}${statusIcon}${colors.reset}`);
|
|
622
|
+
break;
|
|
623
|
+
}
|
|
624
|
+
case "event": {
|
|
625
|
+
const ev = event.data;
|
|
626
|
+
addLogEntry("event", `[${ev.type}]`, ev);
|
|
627
|
+
if (effectiveRun.verbose) {
|
|
628
|
+
const summary = JSON.stringify(ev.data).slice(0, 80);
|
|
629
|
+
console.log(` ${colors.dim}[${ev.type}] ${summary}${colors.reset}`);
|
|
630
|
+
}
|
|
631
|
+
break;
|
|
632
|
+
}
|
|
633
|
+
case "metric": {
|
|
634
|
+
metricCollector.add(event.name, event.value);
|
|
635
|
+
const unit = event.unit ? ` ${event.unit}` : "";
|
|
636
|
+
const tagStr = event.tags
|
|
637
|
+
? ` ${colors.dim}{${Object.entries(event.tags)
|
|
638
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
639
|
+
.join(", ")}}${colors.reset}`
|
|
640
|
+
: "";
|
|
641
|
+
const metricMsg = `${event.name} = ${event.value}${unit}`;
|
|
642
|
+
addLogEntry("metric", metricMsg, {
|
|
643
|
+
name: event.name,
|
|
644
|
+
value: event.value,
|
|
645
|
+
unit: event.unit,
|
|
646
|
+
tags: event.tags,
|
|
647
|
+
});
|
|
648
|
+
if (effectiveRun.verbose) {
|
|
649
|
+
console.log(` ${colors.blue}📊 ${metricMsg}${colors.reset}${tagStr}`);
|
|
650
|
+
}
|
|
651
|
+
break;
|
|
652
|
+
}
|
|
653
|
+
case "step_start":
|
|
654
|
+
stepAssertionCount = 0;
|
|
655
|
+
stepTraceLines = [];
|
|
656
|
+
console.log(` ${colors.cyan}┌${colors.reset} ${colors.dim}step ${event.index + 1}/${event.total}${colors.reset} ${colors.bold}${event.name}${colors.reset}`);
|
|
657
|
+
break;
|
|
658
|
+
case "step_end": {
|
|
659
|
+
const stepIcon = event.status === "passed"
|
|
660
|
+
? `${colors.green}✓${colors.reset}`
|
|
661
|
+
: event.status === "failed"
|
|
662
|
+
? `${colors.red}✗${colors.reset}`
|
|
663
|
+
: `${colors.yellow}○${colors.reset}`;
|
|
664
|
+
const stepParts = [];
|
|
665
|
+
if (event.durationMs !== undefined)
|
|
666
|
+
stepParts.push(`${event.durationMs}ms`);
|
|
667
|
+
if (event.assertions > 0)
|
|
668
|
+
stepParts.push(`${event.assertions} assertions`);
|
|
669
|
+
const httpInStep = stepTraceLines.length;
|
|
670
|
+
if (httpInStep > 0)
|
|
671
|
+
stepParts.push(`${httpInStep} API call${httpInStep > 1 ? "s" : ""}`);
|
|
672
|
+
console.log(` ${colors.cyan}└${colors.reset} ${stepIcon} ${colors.dim}${stepParts.join(" · ")}${colors.reset}`);
|
|
673
|
+
if (event.error) {
|
|
674
|
+
console.log(` ${colors.red}${event.error}${colors.reset}`);
|
|
675
|
+
}
|
|
676
|
+
break;
|
|
677
|
+
}
|
|
678
|
+
case "summary":
|
|
679
|
+
runStats.httpRequestTotal += event.data.httpRequestTotal;
|
|
680
|
+
runStats.httpErrorTotal += event.data.httpErrorTotal;
|
|
681
|
+
runStats.assertionTotal += event.data.assertionTotal;
|
|
682
|
+
runStats.assertionFailed += event.data.assertionFailed;
|
|
683
|
+
runStats.warningTotal += event.data.warningTotal;
|
|
684
|
+
runStats.warningTriggered += event.data.warningTriggered;
|
|
685
|
+
runStats.stepTotal += event.data.stepTotal;
|
|
686
|
+
runStats.stepPassed += event.data.stepPassed;
|
|
687
|
+
runStats.stepFailed += event.data.stepFailed;
|
|
688
|
+
break;
|
|
689
|
+
case "warning": {
|
|
690
|
+
const warnIcon = event.condition ? `${colors.green}✓${colors.reset}` : `${colors.yellow}⚠${colors.reset}`;
|
|
691
|
+
console.log(` ${warnIcon} ${colors.yellow}${event.message}${colors.reset}`);
|
|
692
|
+
break;
|
|
693
|
+
}
|
|
694
|
+
case "schema_validation":
|
|
695
|
+
if (effectiveRun.verbose) {
|
|
696
|
+
const icon = event.success ? `${colors.green}✓${colors.reset}` : `${colors.red}✗${colors.reset}`;
|
|
697
|
+
console.log(` ${icon} ${colors.dim}schema: ${event.label}${colors.reset}`);
|
|
698
|
+
}
|
|
699
|
+
break;
|
|
700
|
+
}
|
|
701
|
+
if (testStarted)
|
|
702
|
+
testEvents.push(event);
|
|
703
|
+
}
|
|
704
|
+
if (testStarted) {
|
|
705
|
+
if (!errorMsg)
|
|
706
|
+
errorMsg = "Process exited before test completed";
|
|
707
|
+
finalizeTest();
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
const totalDurationMs = Date.now() - totalStartTime;
|
|
711
|
+
// ── Summary ──────────────────────────────────────────────────────────────
|
|
712
|
+
console.log(`\n${colors.bold}─────────────────────────────────────${colors.reset}`);
|
|
713
|
+
const summaryParts = [];
|
|
714
|
+
if (passed > 0)
|
|
715
|
+
summaryParts.push(`${colors.green}${passed} passed${colors.reset}`);
|
|
716
|
+
if (failed > 0)
|
|
717
|
+
summaryParts.push(`${colors.red}${failed} failed${colors.reset}`);
|
|
718
|
+
if (skipped > 0)
|
|
719
|
+
summaryParts.push(`${colors.yellow}${skipped} skipped${colors.reset}`);
|
|
720
|
+
console.log(`${colors.bold}Tests:${colors.reset} ${summaryParts.join(", ")}`);
|
|
721
|
+
console.log(`${colors.bold}Total:${colors.reset} ${passed + failed + skipped}`);
|
|
722
|
+
if (overallPeakMemoryMB > 0) {
|
|
723
|
+
const memColor = overallPeakMemoryMB > MEMORY_WARNING_THRESHOLD_MB ? colors.yellow : colors.dim;
|
|
724
|
+
console.log(`${colors.bold}Memory:${colors.reset} ${memColor}${overallPeakMemoryMB.toFixed(2)} MB peak${colors.reset}`);
|
|
725
|
+
}
|
|
726
|
+
const hasStats = runStats.httpRequestTotal > 0 || runStats.assertionTotal > 0 || runStats.stepTotal > 0;
|
|
727
|
+
if (hasStats) {
|
|
728
|
+
const parts = [];
|
|
729
|
+
if (runStats.httpRequestTotal > 0) {
|
|
730
|
+
const errPart = runStats.httpErrorTotal > 0
|
|
731
|
+
? ` ${colors.red}(${runStats.httpErrorTotal} errors)${colors.reset}` : "";
|
|
732
|
+
parts.push(`${runStats.httpRequestTotal} API calls${errPart}`);
|
|
733
|
+
}
|
|
734
|
+
if (runStats.assertionTotal > 0) {
|
|
735
|
+
const failPart = runStats.assertionFailed > 0
|
|
736
|
+
? ` ${colors.red}(${runStats.assertionFailed} failed)${colors.reset}` : "";
|
|
737
|
+
parts.push(`${runStats.assertionTotal} assertions${failPart}`);
|
|
738
|
+
}
|
|
739
|
+
if (runStats.stepTotal > 0)
|
|
740
|
+
parts.push(`${runStats.stepTotal} steps`);
|
|
741
|
+
if (runStats.warningTriggered > 0)
|
|
742
|
+
parts.push(`${colors.yellow}${runStats.warningTriggered} warnings${colors.reset}`);
|
|
743
|
+
console.log(`${colors.bold}Stats:${colors.reset} ${colors.dim}${parts.join(" · ")}${colors.reset}`);
|
|
744
|
+
}
|
|
745
|
+
// ── Threshold evaluation ──────────────────────────────────────────────────
|
|
746
|
+
let thresholdSummary;
|
|
747
|
+
if (glubeanConfig.thresholds && Object.keys(glubeanConfig.thresholds).length > 0) {
|
|
748
|
+
thresholdSummary = evaluateThresholds(glubeanConfig.thresholds, metricCollector);
|
|
749
|
+
const { results: thresholdResults, pass: allPass } = thresholdSummary;
|
|
750
|
+
if (thresholdResults.length > 0) {
|
|
751
|
+
console.log(`${colors.bold}Thresholds:${colors.reset}`);
|
|
752
|
+
for (const r of thresholdResults) {
|
|
753
|
+
const icon = r.pass ? `${colors.green}✓${colors.reset}` : `${colors.red}✗${colors.reset}`;
|
|
754
|
+
const actualStr = Number.isNaN(r.actual) ? "N/A" : String(r.actual);
|
|
755
|
+
console.log(` ${icon} ${r.metric}.${r.aggregation} ... ${actualStr} ${r.threshold}`);
|
|
756
|
+
}
|
|
757
|
+
const tPassed = thresholdResults.filter((r) => r.pass).length;
|
|
758
|
+
const statusColor = allPass ? colors.green : colors.red;
|
|
759
|
+
console.log(` ${statusColor}${tPassed}/${thresholdResults.length} passed${colors.reset}`);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
console.log();
|
|
763
|
+
// Write log file
|
|
764
|
+
if (effectiveRun.logFile && logEntries.length > 0) {
|
|
765
|
+
const logPath = isMultiFile ? resolve(process.cwd(), "glubean-run.log") : getLogFilePath(testFiles[0]);
|
|
766
|
+
const stringify = (value) => {
|
|
767
|
+
if (effectiveRun.pretty) {
|
|
768
|
+
const pretty = JSON.stringify(value, null, 2);
|
|
769
|
+
return pretty.split("\n").join("\n ");
|
|
770
|
+
}
|
|
771
|
+
return JSON.stringify(value);
|
|
772
|
+
};
|
|
773
|
+
const logContent = [
|
|
774
|
+
`# Glubean Test Log`,
|
|
775
|
+
`# Target: ${isMultiFile ? resolve(target) : testFiles[0]}`,
|
|
776
|
+
`# Run at: ${runStartTime}`,
|
|
777
|
+
`# Tests: ${passed} passed, ${failed} failed`,
|
|
778
|
+
``,
|
|
779
|
+
...logEntries.map((entry) => {
|
|
780
|
+
const prefix = `[${entry.timestamp}] [${entry.testId}]`;
|
|
781
|
+
if (entry.type === "result") {
|
|
782
|
+
return `${prefix} ${entry.message} (${entry.data.duration}ms)`;
|
|
783
|
+
}
|
|
784
|
+
if (entry.type === "assertion") {
|
|
785
|
+
const data = entry.data;
|
|
786
|
+
const status = data.passed ? "✓" : "✗";
|
|
787
|
+
let line = `${prefix} [ASSERT ${status}] ${entry.message}`;
|
|
788
|
+
if (data.expected !== undefined || data.actual !== undefined) {
|
|
789
|
+
if (data.expected !== undefined)
|
|
790
|
+
line += `\n Expected: ${stringify(data.expected)}`;
|
|
791
|
+
if (data.actual !== undefined)
|
|
792
|
+
line += `\n Actual: ${stringify(data.actual)}`;
|
|
793
|
+
}
|
|
794
|
+
return line;
|
|
795
|
+
}
|
|
796
|
+
if (entry.type === "trace") {
|
|
797
|
+
const data = entry.data;
|
|
798
|
+
let line = `${prefix} [TRACE] ${entry.message}`;
|
|
799
|
+
if (data.requestBody !== undefined)
|
|
800
|
+
line += `\n Request Body: ${stringify(data.requestBody)}`;
|
|
801
|
+
if (data.responseBody !== undefined)
|
|
802
|
+
line += `\n Response Body: ${stringify(data.responseBody)}`;
|
|
803
|
+
return line;
|
|
804
|
+
}
|
|
805
|
+
if (entry.type === "metric") {
|
|
806
|
+
const data = entry.data;
|
|
807
|
+
let line = `${prefix} [METRIC] ${entry.message}`;
|
|
808
|
+
if (data.tags && Object.keys(data.tags).length > 0)
|
|
809
|
+
line += `\n Tags: ${stringify(data.tags)}`;
|
|
810
|
+
return line;
|
|
811
|
+
}
|
|
812
|
+
if (entry.type === "error")
|
|
813
|
+
return `${prefix} [ERROR] ${entry.message}`;
|
|
814
|
+
return `${prefix} [LOG] ${entry.message}`;
|
|
815
|
+
}),
|
|
816
|
+
``,
|
|
817
|
+
].join("\n");
|
|
818
|
+
await writeFile(logPath, logContent, "utf-8");
|
|
819
|
+
console.log(`${colors.dim}Log written to: ${logPath}${colors.reset}\n`);
|
|
820
|
+
}
|
|
821
|
+
// Write .glubean/traces.json
|
|
822
|
+
if (traceCollector.length > 0) {
|
|
823
|
+
try {
|
|
824
|
+
const glubeanDir = resolve(rootDir, ".glubean");
|
|
825
|
+
await mkdir(glubeanDir, { recursive: true });
|
|
826
|
+
const tracesPath = resolve(glubeanDir, "traces.json");
|
|
827
|
+
const traceSummary = {
|
|
828
|
+
runAt: runStartTime,
|
|
829
|
+
target,
|
|
830
|
+
files: testFiles.map((f) => relative(process.cwd(), f)),
|
|
831
|
+
traces: traceCollector,
|
|
832
|
+
};
|
|
833
|
+
await writeFile(tracesPath, JSON.stringify(traceSummary, null, 2), "utf-8");
|
|
834
|
+
}
|
|
835
|
+
catch {
|
|
836
|
+
// Non-critical
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
// ── Result JSON output ───────────────────────────────────────────────────
|
|
840
|
+
const resultPayload = {
|
|
841
|
+
target,
|
|
842
|
+
files: testFiles.map((f) => relative(process.cwd(), f)),
|
|
843
|
+
runAt: runStartLocal,
|
|
844
|
+
summary: {
|
|
845
|
+
total: passed + failed + skipped,
|
|
846
|
+
passed,
|
|
847
|
+
failed,
|
|
848
|
+
skipped,
|
|
849
|
+
durationMs: totalDurationMs,
|
|
850
|
+
stats: runStats,
|
|
851
|
+
},
|
|
852
|
+
tests: collectedRuns.map((r) => ({
|
|
853
|
+
testId: r.testId,
|
|
854
|
+
testName: r.testName,
|
|
855
|
+
tags: r.tags,
|
|
856
|
+
success: r.success,
|
|
857
|
+
durationMs: r.durationMs,
|
|
858
|
+
events: r.events,
|
|
859
|
+
})),
|
|
860
|
+
...(thresholdSummary && { thresholds: thresholdSummary }),
|
|
861
|
+
};
|
|
862
|
+
const resultJson = JSON.stringify(resultPayload, null, 2);
|
|
863
|
+
try {
|
|
864
|
+
const glubeanDir = resolve(rootDir, ".glubean");
|
|
865
|
+
await mkdir(glubeanDir, { recursive: true });
|
|
866
|
+
await writeFile(resolve(glubeanDir, "last-run.result.json"), resultJson, "utf-8");
|
|
867
|
+
}
|
|
868
|
+
catch {
|
|
869
|
+
// Non-critical
|
|
870
|
+
}
|
|
871
|
+
if (options.resultJson) {
|
|
872
|
+
const resultPath = typeof options.resultJson === "string"
|
|
873
|
+
? resolveOutputPath(options.resultJson, process.cwd())
|
|
874
|
+
: isMultiFile
|
|
875
|
+
? resolve(process.cwd(), "glubean-run.result.json")
|
|
876
|
+
: getLogFilePath(testFiles[0]).replace(/\.log$/, ".result.json");
|
|
877
|
+
await mkdir(dirname(resultPath), { recursive: true });
|
|
878
|
+
await writeFile(resultPath, resultJson, "utf-8");
|
|
879
|
+
console.log(`${colors.dim}Result written to: ${resultPath}${colors.reset}`);
|
|
880
|
+
console.log(`${colors.dim}Open ${colors.reset}${colors.cyan}https://glubean.com/viewer${colors.reset}${colors.dim} to visualize it${colors.reset}\n`);
|
|
881
|
+
}
|
|
882
|
+
// ── JUnit XML output ───────────────────────────────────────────────────
|
|
883
|
+
if (options.reporter === "junit") {
|
|
884
|
+
const junitPath = options.reporterPath
|
|
885
|
+
? resolveOutputPath(options.reporterPath, process.cwd())
|
|
886
|
+
: isMultiFile
|
|
887
|
+
? resolve(process.cwd(), "glubean-run.junit.xml")
|
|
888
|
+
: getLogFilePath(testFiles[0]).replace(/\.log$/, ".junit.xml");
|
|
889
|
+
const summaryData = {
|
|
890
|
+
total: passed + failed + skipped,
|
|
891
|
+
passed,
|
|
892
|
+
failed,
|
|
893
|
+
skipped,
|
|
894
|
+
durationMs: totalDurationMs,
|
|
895
|
+
};
|
|
896
|
+
const xml = toJunitXml(collectedRuns, target, summaryData);
|
|
897
|
+
await mkdir(dirname(junitPath), { recursive: true });
|
|
898
|
+
await writeFile(junitPath, xml, "utf-8");
|
|
899
|
+
console.log(`${colors.dim}JUnit XML written to: ${junitPath}${colors.reset}\n`);
|
|
900
|
+
}
|
|
901
|
+
// ── Write .trace.jsonc files ──
|
|
902
|
+
if (effectiveRun.emitFullTrace) {
|
|
903
|
+
try {
|
|
904
|
+
await writeTraceFiles(collectedRuns, rootDir, effectiveRun.envFile, options.traceLimit);
|
|
905
|
+
}
|
|
906
|
+
catch {
|
|
907
|
+
// Non-critical
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
// ── Screenshot paths ──────────────────────────────────────────────────
|
|
911
|
+
{
|
|
912
|
+
const screenshotPaths = [];
|
|
913
|
+
for (const run of collectedRuns) {
|
|
914
|
+
for (const event of run.events) {
|
|
915
|
+
if (event.type !== "event")
|
|
916
|
+
continue;
|
|
917
|
+
const ev = event.data;
|
|
918
|
+
if (ev.type === "browser:screenshot" && typeof ev.data?.path === "string") {
|
|
919
|
+
screenshotPaths.push(resolve(rootDir, ev.data.path));
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
if (screenshotPaths.length > 0) {
|
|
924
|
+
for (const p of screenshotPaths) {
|
|
925
|
+
console.log(`${colors.dim}Screenshot: ${colors.reset}${p}`);
|
|
926
|
+
}
|
|
927
|
+
console.log();
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
// ── Cloud upload ────────────────────────────────────────────────────────
|
|
931
|
+
if (options.upload) {
|
|
932
|
+
const { resolveToken, resolveProjectId, resolveApiUrl } = await import("../lib/auth.js");
|
|
933
|
+
const { uploadToCloud } = await import("../lib/upload.js");
|
|
934
|
+
const authOpts = {
|
|
935
|
+
token: options.token,
|
|
936
|
+
project: options.project,
|
|
937
|
+
apiUrl: options.apiUrl,
|
|
938
|
+
};
|
|
939
|
+
const sources = {
|
|
940
|
+
envFileVars: { ...envVars, ...secrets },
|
|
941
|
+
cloudConfig: glubeanConfig.cloud,
|
|
942
|
+
};
|
|
943
|
+
const token = await resolveToken(authOpts, sources);
|
|
944
|
+
const projectId = await resolveProjectId(authOpts, sources);
|
|
945
|
+
const apiUrl = await resolveApiUrl(authOpts, sources);
|
|
946
|
+
if (!token) {
|
|
947
|
+
console.error(`${colors.red}Upload failed: no auth token found.${colors.reset}`);
|
|
948
|
+
process.exit(1);
|
|
949
|
+
}
|
|
950
|
+
else if (!projectId) {
|
|
951
|
+
console.error(`${colors.red}Upload failed: no project ID.${colors.reset}`);
|
|
952
|
+
process.exit(1);
|
|
953
|
+
}
|
|
954
|
+
else {
|
|
955
|
+
const { RedactionEngine, createBuiltinPlugins, redactEvent } = await import("@glubean/redaction");
|
|
956
|
+
const engine = new RedactionEngine({
|
|
957
|
+
config: glubeanConfig.redaction,
|
|
958
|
+
plugins: createBuiltinPlugins(glubeanConfig.redaction),
|
|
959
|
+
});
|
|
960
|
+
const redactedPayload = {
|
|
961
|
+
...resultPayload,
|
|
962
|
+
tests: resultPayload.tests.map((t) => ({
|
|
963
|
+
...t,
|
|
964
|
+
events: t.events.map((e) => redactEvent(engine, e)),
|
|
965
|
+
})),
|
|
966
|
+
};
|
|
967
|
+
await uploadToCloud(redactedPayload, {
|
|
968
|
+
apiUrl,
|
|
969
|
+
token,
|
|
970
|
+
projectId,
|
|
971
|
+
envFile: effectiveRun.envFile,
|
|
972
|
+
rootDir,
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
if (failed > 0 || (thresholdSummary && !thresholdSummary.pass)) {
|
|
977
|
+
process.exit(1);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
// ---------------------------------------------------------------------------
|
|
981
|
+
// JUnit XML generation
|
|
982
|
+
// ---------------------------------------------------------------------------
|
|
983
|
+
function escapeXml(str) {
|
|
984
|
+
return str
|
|
985
|
+
.replace(/&/g, "&")
|
|
986
|
+
.replace(/</g, "<")
|
|
987
|
+
.replace(/>/g, ">")
|
|
988
|
+
.replace(/"/g, """)
|
|
989
|
+
.replace(/'/g, "'");
|
|
990
|
+
}
|
|
991
|
+
function toJunitXml(collectedRuns, target, summary) {
|
|
992
|
+
const lines = [
|
|
993
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
994
|
+
`<testsuite name="${escapeXml(target)}" tests="${summary.total}" failures="${summary.failed}" skipped="${summary.skipped}" time="${(summary.durationMs / 1000).toFixed(3)}">`,
|
|
995
|
+
];
|
|
996
|
+
for (const run of collectedRuns) {
|
|
997
|
+
const classname = run.filePath ? escapeXml(relative(process.cwd(), run.filePath).replace(/\\/g, "/")) : "glubean";
|
|
998
|
+
const name = escapeXml(run.testName);
|
|
999
|
+
const time = (run.durationMs / 1000).toFixed(3);
|
|
1000
|
+
if (run.success) {
|
|
1001
|
+
lines.push(` <testcase classname="${classname}" name="${name}" time="${time}" />`);
|
|
1002
|
+
}
|
|
1003
|
+
else {
|
|
1004
|
+
const statusEvent = run.events.find((e) => e.type === "status" && "error" in e);
|
|
1005
|
+
const failedAssertions = run.events
|
|
1006
|
+
.filter((e) => e.type === "assertion" && !("passed" in e && e.passed))
|
|
1007
|
+
.map((e) => ("message" in e ? e.message : ""))
|
|
1008
|
+
.filter(Boolean);
|
|
1009
|
+
const message = statusEvent?.error || failedAssertions[0] || "Test failed";
|
|
1010
|
+
const detail = failedAssertions.length > 0 ? failedAssertions.join("\n") : message;
|
|
1011
|
+
lines.push(` <testcase classname="${classname}" name="${name}" time="${time}">`);
|
|
1012
|
+
lines.push(` <failure message="${escapeXml(message)}">${escapeXml(detail)}</failure>`);
|
|
1013
|
+
lines.push(` </testcase>`);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
lines.push("</testsuite>");
|
|
1017
|
+
return lines.join("\n");
|
|
1018
|
+
}
|
|
1019
|
+
// ---------------------------------------------------------------------------
|
|
1020
|
+
// Trace file generation
|
|
1021
|
+
// ---------------------------------------------------------------------------
|
|
1022
|
+
const TRACE_HISTORY_LIMIT = 20;
|
|
1023
|
+
function p2(n) {
|
|
1024
|
+
return String(n).padStart(2, "0");
|
|
1025
|
+
}
|
|
1026
|
+
function sanitizeForPath(s) {
|
|
1027
|
+
return s.replace(/[/\\:*?"<>|]/g, "_");
|
|
1028
|
+
}
|
|
1029
|
+
function localTimeString(d) {
|
|
1030
|
+
return (`${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())} ` +
|
|
1031
|
+
`${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}`);
|
|
1032
|
+
}
|
|
1033
|
+
async function writeTraceFiles(collectedRuns, rootDir, envFile, traceLimit) {
|
|
1034
|
+
const limit = traceLimit ?? TRACE_HISTORY_LIMIT;
|
|
1035
|
+
const now = new Date();
|
|
1036
|
+
const ts = `${now.getFullYear()}${p2(now.getMonth() + 1)}${p2(now.getDate())}` +
|
|
1037
|
+
`T${p2(now.getHours())}${p2(now.getMinutes())}${p2(now.getSeconds())}`;
|
|
1038
|
+
const envLabel = envFile || ".env";
|
|
1039
|
+
for (const run of collectedRuns) {
|
|
1040
|
+
const pairs = [];
|
|
1041
|
+
for (const event of run.events) {
|
|
1042
|
+
if (event.type !== "trace")
|
|
1043
|
+
continue;
|
|
1044
|
+
const d = event.data;
|
|
1045
|
+
pairs.push({
|
|
1046
|
+
request: {
|
|
1047
|
+
method: d.method,
|
|
1048
|
+
url: d.url,
|
|
1049
|
+
...(d.requestHeaders && Object.keys(d.requestHeaders).length > 0 ? { headers: d.requestHeaders } : {}),
|
|
1050
|
+
...(d.requestBody !== undefined ? { body: d.requestBody } : {}),
|
|
1051
|
+
},
|
|
1052
|
+
response: {
|
|
1053
|
+
status: d.status,
|
|
1054
|
+
durationMs: d.duration,
|
|
1055
|
+
...(d.responseHeaders && Object.keys(d.responseHeaders).length > 0 ? { headers: d.responseHeaders } : {}),
|
|
1056
|
+
...(d.responseBody !== undefined ? { body: d.responseBody } : {}),
|
|
1057
|
+
},
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
if (pairs.length === 0)
|
|
1061
|
+
continue;
|
|
1062
|
+
const fileName = basename(run.filePath).replace(/\.ts$/, "");
|
|
1063
|
+
const dirId = sanitizeForPath(run.groupId ?? run.testId);
|
|
1064
|
+
const tracesDir = resolve(rootDir, ".glubean", "traces", fileName, dirId);
|
|
1065
|
+
await mkdir(tracesDir, { recursive: true });
|
|
1066
|
+
const traceName = (run.groupId && run.groupId !== run.testId) ? `${ts}--${sanitizeForPath(run.testId)}` : ts;
|
|
1067
|
+
const traceFilePath = resolve(tracesDir, `${traceName}.trace.jsonc`);
|
|
1068
|
+
const relFile = relative(rootDir, run.filePath);
|
|
1069
|
+
const header = [
|
|
1070
|
+
`// ${relFile} → ${run.testId} — ${pairs.length} HTTP call${pairs.length > 1 ? "s" : ""}`,
|
|
1071
|
+
`// Run at: ${localTimeString(now)}`,
|
|
1072
|
+
`// Environment: ${envLabel}`,
|
|
1073
|
+
"",
|
|
1074
|
+
].join("\n");
|
|
1075
|
+
const content = header + JSON.stringify(pairs, null, 2) + "\n";
|
|
1076
|
+
await writeFile(traceFilePath, content, "utf-8");
|
|
1077
|
+
console.log(`${colors.dim}Trace: ${colors.reset}${traceFilePath}`);
|
|
1078
|
+
await cleanupTraceDir(tracesDir, limit);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
async function cleanupTraceDir(dir, limit) {
|
|
1082
|
+
try {
|
|
1083
|
+
const entries = await readdir(dir);
|
|
1084
|
+
const traceFiles = entries.filter((name) => name.endsWith(".trace.jsonc")).sort().reverse();
|
|
1085
|
+
for (const name of traceFiles.slice(limit)) {
|
|
1086
|
+
await rm(resolve(dir, name)).catch(() => { });
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
catch {
|
|
1090
|
+
// Cleanup is best-effort
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
//# sourceMappingURL=run.js.map
|