superghost 0.1.1 → 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/package.json +7 -2
- package/src/agent/agent-runner.ts +23 -10
- package/src/agent/mcp-manager.ts +7 -14
- package/src/agent/model-factory.ts +1 -1
- package/src/agent/types.ts +1 -18
- package/src/cache/cache-manager.ts +52 -5
- package/src/cache/step-recorder.ts +1 -1
- package/src/cache/step-replayer.ts +11 -6
- package/src/cache/types.ts +1 -1
- package/src/cli.ts +282 -103
- package/src/config/loader.ts +6 -14
- package/src/config/types.ts +3 -2
- package/src/infra/preflight.ts +13 -0
- package/src/infra/process-manager.ts +6 -2
- package/src/infra/signals.ts +1 -1
- package/src/output/banner.ts +66 -0
- package/src/output/json-formatter.ts +150 -0
- package/src/output/reporter.ts +49 -20
- package/src/output/tool-name-map.ts +62 -0
- package/src/output/types.ts +27 -1
- package/src/runner/test-executor.ts +36 -33
- package/src/runner/test-runner.ts +7 -15
- package/src/runner/types.ts +1 -0
package/src/cli.ts
CHANGED
|
@@ -2,129 +2,308 @@
|
|
|
2
2
|
|
|
3
3
|
import { Command } from "commander";
|
|
4
4
|
import pc from "picocolors";
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
9
|
-
import { ProcessManager } from "./infra/process-manager.ts";
|
|
10
|
-
import { setupSignalHandlers } from "./infra/signals.ts";
|
|
5
|
+
import picomatch from "picomatch";
|
|
6
|
+
|
|
7
|
+
import pkg from "../package.json";
|
|
8
|
+
import { executeAgent } from "./agent/agent-runner.ts";
|
|
11
9
|
import { McpManager } from "./agent/mcp-manager.ts";
|
|
10
|
+
import { createModel, inferProvider, type ProviderName, validateApiKey } from "./agent/model-factory.ts";
|
|
12
11
|
import { CacheManager } from "./cache/cache-manager.ts";
|
|
13
|
-
import { StepReplayer } from "./cache/step-replayer.ts";
|
|
14
|
-
import
|
|
15
|
-
import { TestExecutor } from "./runner/test-executor.ts";
|
|
16
|
-
import {
|
|
17
|
-
inferProvider,
|
|
18
|
-
validateApiKey,
|
|
19
|
-
createModel,
|
|
20
|
-
} from "./agent/model-factory.ts";
|
|
21
|
-
import type { ProviderName } from "./agent/model-factory.ts";
|
|
22
|
-
import { executeAgent } from "./agent/agent-runner.ts";
|
|
12
|
+
import { StepReplayer, type ToolExecutor } from "./cache/step-replayer.ts";
|
|
13
|
+
import { ConfigLoadError, loadConfig } from "./config/loader.ts";
|
|
23
14
|
import { isStandaloneBinary } from "./dist/paths.ts";
|
|
24
15
|
import { ensureMcpDependencies } from "./dist/setup.ts";
|
|
25
|
-
import
|
|
16
|
+
import { checkBaseUrlReachable } from "./infra/preflight.ts";
|
|
17
|
+
import { ProcessManager } from "./infra/process-manager.ts";
|
|
18
|
+
import { setupSignalHandlers } from "./infra/signals.ts";
|
|
19
|
+
import { animateBanner } from "./output/banner.ts";
|
|
20
|
+
import {
|
|
21
|
+
formatJsonDryRun,
|
|
22
|
+
formatJsonError,
|
|
23
|
+
formatJsonOutput,
|
|
24
|
+
type JsonOutputMetadata,
|
|
25
|
+
} from "./output/json-formatter.ts";
|
|
26
|
+
import { ConsoleReporter, writeStderr } from "./output/reporter.ts";
|
|
27
|
+
import { type OnStepProgress } from "./output/types.ts";
|
|
28
|
+
import { TestExecutor } from "./runner/test-executor.ts";
|
|
29
|
+
import { type ExecuteFn, TestRunner } from "./runner/test-runner.ts";
|
|
30
|
+
|
|
31
|
+
/** Print the run header and any stacked annotations to stderr */
|
|
32
|
+
function printRunHeader(testCount: number, totalTestCount: number | undefined, annotations: string[]): void {
|
|
33
|
+
let header = `\n${pc.bold("superghost")} v${pkg.version} / Running ${testCount}`;
|
|
34
|
+
if (totalTestCount !== undefined) {
|
|
35
|
+
header += ` of ${totalTestCount}`;
|
|
36
|
+
}
|
|
37
|
+
header += ` test(s)...`;
|
|
38
|
+
writeStderr(header);
|
|
39
|
+
writeStderr("");
|
|
40
|
+
|
|
41
|
+
for (const annotation of annotations) {
|
|
42
|
+
writeStderr(pc.dim(` ${annotation}`));
|
|
43
|
+
}
|
|
44
|
+
if (annotations.length > 0) {
|
|
45
|
+
writeStderr("");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
26
48
|
|
|
27
49
|
const program = new Command();
|
|
28
50
|
|
|
51
|
+
program.configureOutput({
|
|
52
|
+
writeOut: (str) => writeStderr(str.trimEnd()),
|
|
53
|
+
writeErr: (str) => writeStderr(str.trimEnd()),
|
|
54
|
+
});
|
|
55
|
+
|
|
29
56
|
program
|
|
30
57
|
.name("superghost")
|
|
31
58
|
.description("AI-powered end-to-end browser and API testing")
|
|
32
59
|
.version(pkg.version)
|
|
33
60
|
.requiredOption("-c, --config <path>", "Path to YAML config file")
|
|
34
61
|
.option("--headed", "Run browser in headed mode (visible browser window)")
|
|
35
|
-
.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
62
|
+
.option("--only <pattern>", "Run only tests matching glob pattern")
|
|
63
|
+
.option("--no-cache", "Bypass cache reads (still writes on success)")
|
|
64
|
+
.option("--dry-run", "List tests and validate config without executing")
|
|
65
|
+
.option("--verbose", "Show per-step tool call output during execution")
|
|
66
|
+
.option("--output <format>", "Output format (json)")
|
|
67
|
+
.exitOverride((err) => {
|
|
68
|
+
// Commander writes its own error message to stderr.
|
|
69
|
+
// Re-exit with code 2 for config-class errors (missing required option, unknown option).
|
|
70
|
+
if (err.exitCode !== 0) {
|
|
71
|
+
process.exit(2);
|
|
42
72
|
}
|
|
73
|
+
})
|
|
74
|
+
.action(
|
|
75
|
+
async (options: {
|
|
76
|
+
config: string;
|
|
77
|
+
headed?: boolean;
|
|
78
|
+
only?: string;
|
|
79
|
+
cache: boolean;
|
|
80
|
+
dryRun?: boolean;
|
|
81
|
+
verbose?: boolean;
|
|
82
|
+
output?: string;
|
|
83
|
+
}) => {
|
|
84
|
+
const pm = new ProcessManager();
|
|
85
|
+
setupSignalHandlers(pm);
|
|
43
86
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
config.headless = false;
|
|
50
|
-
}
|
|
51
|
-
const reporter = new ConsoleReporter();
|
|
52
|
-
|
|
53
|
-
// Infer provider: use explicit modelProvider unless it matches default and model suggests otherwise
|
|
54
|
-
const provider =
|
|
55
|
-
config.modelProvider === "anthropic"
|
|
56
|
-
? inferProvider(config.model)
|
|
57
|
-
: (config.modelProvider as ProviderName);
|
|
58
|
-
|
|
59
|
-
// Validate API key at startup before any tests run
|
|
60
|
-
validateApiKey(provider);
|
|
61
|
-
|
|
62
|
-
// Create AI model
|
|
63
|
-
const model = createModel(config.model, provider);
|
|
64
|
-
|
|
65
|
-
// Initialize MCP servers (shared across test suite, not per-test)
|
|
66
|
-
mcpManager = new McpManager({
|
|
67
|
-
browser: config.browser,
|
|
68
|
-
headless: config.headless,
|
|
69
|
-
});
|
|
70
|
-
await mcpManager.initialize();
|
|
71
|
-
const tools = await mcpManager.getTools();
|
|
72
|
-
|
|
73
|
-
// Create cache subsystem
|
|
74
|
-
const cacheManager = new CacheManager(config.cacheDir);
|
|
75
|
-
const toolExecutor: ToolExecutor = async (toolName, toolInput) => {
|
|
76
|
-
const tool = tools[toolName];
|
|
77
|
-
if (!tool) throw new Error(`Tool not found: ${toolName}`);
|
|
78
|
-
return await tool.execute(toolInput);
|
|
79
|
-
};
|
|
80
|
-
const replayer = new StepReplayer(toolExecutor);
|
|
81
|
-
|
|
82
|
-
// Create TestExecutor with cache-first strategy
|
|
83
|
-
const executor = new TestExecutor({
|
|
84
|
-
cacheManager,
|
|
85
|
-
replayer,
|
|
86
|
-
executeAgentFn: executeAgent,
|
|
87
|
-
model,
|
|
88
|
-
tools,
|
|
89
|
-
config,
|
|
90
|
-
globalContext: config.context,
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
// Wire execute function for TestRunner
|
|
94
|
-
const executeFn: ExecuteFn = async (testCase, baseUrl, testContext?) =>
|
|
95
|
-
executor.execute(testCase, baseUrl, testContext);
|
|
96
|
-
|
|
97
|
-
console.log(
|
|
98
|
-
`\n${pc.bold("superghost")} v${pkg.version} / Running ${config.tests.length} test(s)...\n`,
|
|
99
|
-
);
|
|
100
|
-
|
|
101
|
-
const runner = new TestRunner(config, reporter, executeFn);
|
|
102
|
-
const result = await runner.run();
|
|
103
|
-
|
|
104
|
-
await mcpManager.close();
|
|
105
|
-
await pm.killAll();
|
|
106
|
-
const code = result.failed > 0 ? 1 : 0;
|
|
107
|
-
setTimeout(() => process.exit(code), 100);
|
|
108
|
-
} catch (error) {
|
|
109
|
-
if (mcpManager) {
|
|
110
|
-
await mcpManager.close().catch(() => {});
|
|
87
|
+
// Validate --output format early
|
|
88
|
+
if (options.output && options.output !== "json") {
|
|
89
|
+
writeStderr(`${pc.red("Error:")} Unknown output format '${options.output}'. Supported: json`);
|
|
90
|
+
setTimeout(() => process.exit(2), 100);
|
|
91
|
+
return;
|
|
111
92
|
}
|
|
112
|
-
await pm.killAll();
|
|
113
93
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
return;
|
|
94
|
+
// Auto-install MCP dependencies for standalone binary on first run
|
|
95
|
+
if (isStandaloneBinary()) {
|
|
96
|
+
await ensureMcpDependencies();
|
|
118
97
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
98
|
+
|
|
99
|
+
let mcpManager: McpManager | null = null;
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const config = await loadConfig(options.config);
|
|
103
|
+
if (options.headed) {
|
|
104
|
+
config.headless = false;
|
|
105
|
+
}
|
|
106
|
+
const reporter = new ConsoleReporter(options.verbose ?? false);
|
|
107
|
+
|
|
108
|
+
// Infer provider: use explicit modelProvider unless it matches default and model suggests otherwise
|
|
109
|
+
const provider =
|
|
110
|
+
config.modelProvider === "anthropic" ? inferProvider(config.model) : (config.modelProvider as ProviderName);
|
|
111
|
+
|
|
112
|
+
// Validate API key at startup before any tests run
|
|
113
|
+
validateApiKey(provider);
|
|
114
|
+
|
|
115
|
+
// Apply --only filter before any expensive operations
|
|
116
|
+
const totalTestCount = config.tests.length;
|
|
117
|
+
if (options.only) {
|
|
118
|
+
const allTestNames = config.tests.map((t) => t.name);
|
|
119
|
+
const isMatch = picomatch(options.only, { nocase: true });
|
|
120
|
+
config.tests = config.tests.filter((t) => isMatch(t.name));
|
|
121
|
+
|
|
122
|
+
if (config.tests.length === 0) {
|
|
123
|
+
const names = allTestNames.map((n) => ` - ${n}`).join("\n");
|
|
124
|
+
writeStderr(`${pc.red("Error:")} No tests match pattern "${options.only}"\n\nAvailable tests:\n${names}`);
|
|
125
|
+
setTimeout(() => process.exit(2), 100);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Dry-run: list tests with cache/AI source labels, then exit
|
|
131
|
+
if (options.dryRun) {
|
|
132
|
+
const cacheManager = new CacheManager(config.cacheDir);
|
|
133
|
+
|
|
134
|
+
// Print header with annotations
|
|
135
|
+
const dryRunAnnotations = ["(dry-run)"];
|
|
136
|
+
if (options.only) dryRunAnnotations.push(`(filtered by --only "${options.only}")`);
|
|
137
|
+
printRunHeader(config.tests.length, options.only ? totalTestCount : undefined, dryRunAnnotations);
|
|
138
|
+
|
|
139
|
+
// Determine max test name length for padding
|
|
140
|
+
const maxNameLen = Math.max(...config.tests.map((t) => t.name.length));
|
|
141
|
+
let cachedCount = 0;
|
|
142
|
+
const dryRunTests: Array<{ name: string; case: string; source: "cache" | "ai" }> = [];
|
|
143
|
+
|
|
144
|
+
for (let i = 0; i < config.tests.length; i++) {
|
|
145
|
+
const test = config.tests[i];
|
|
146
|
+
const baseUrl = test.baseUrl ?? config.baseUrl ?? "";
|
|
147
|
+
const entry = await cacheManager.load(test.case, baseUrl);
|
|
148
|
+
const source: "cache" | "ai" = entry ? "cache" : "ai";
|
|
149
|
+
if (entry) cachedCount++;
|
|
150
|
+
dryRunTests.push({ name: test.name, case: test.case, source });
|
|
151
|
+
|
|
152
|
+
const paddedName = test.name.padEnd(maxNameLen);
|
|
153
|
+
writeStderr(` ${i + 1}. ${paddedName} (${source})`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
writeStderr("");
|
|
157
|
+
writeStderr(`${config.tests.length} tests, ${cachedCount} cached`);
|
|
158
|
+
|
|
159
|
+
// Write JSON to stdout when --output json is active
|
|
160
|
+
if (options.output === "json") {
|
|
161
|
+
const metadata: JsonOutputMetadata = {
|
|
162
|
+
model: config.model,
|
|
163
|
+
provider,
|
|
164
|
+
configFile: options.config,
|
|
165
|
+
baseUrl: config.baseUrl,
|
|
166
|
+
timestamp: new Date().toISOString(),
|
|
167
|
+
...(options.only
|
|
168
|
+
? { filter: { pattern: options.only, matched: config.tests.length, total: totalTestCount } }
|
|
169
|
+
: {}),
|
|
170
|
+
};
|
|
171
|
+
const testList = dryRunTests.map((t) => ({
|
|
172
|
+
name: t.name,
|
|
173
|
+
case: t.case,
|
|
174
|
+
source: t.source,
|
|
175
|
+
}));
|
|
176
|
+
const json = formatJsonDryRun(testList, metadata, pkg.version);
|
|
177
|
+
process.stdout.write(`${json}\n`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
setTimeout(() => process.exit(0), 100);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Preflight: check baseUrl reachability (only if global baseUrl configured)
|
|
185
|
+
if (config.baseUrl) {
|
|
186
|
+
try {
|
|
187
|
+
await checkBaseUrlReachable(config.baseUrl);
|
|
188
|
+
} catch {
|
|
189
|
+
writeStderr(`${pc.red("Error:")} baseUrl unreachable: ${config.baseUrl}`);
|
|
190
|
+
writeStderr(` Check that the server is running and the URL is correct.`);
|
|
191
|
+
setTimeout(() => process.exit(2), 100);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Create AI model
|
|
197
|
+
const model = createModel(config.model, provider);
|
|
198
|
+
|
|
199
|
+
// Initialize MCP servers (shared across test suite, not per-test)
|
|
200
|
+
mcpManager = new McpManager({
|
|
201
|
+
browser: config.browser,
|
|
202
|
+
headless: config.headless,
|
|
203
|
+
});
|
|
204
|
+
await mcpManager.initialize();
|
|
205
|
+
const tools = await mcpManager.getTools();
|
|
206
|
+
|
|
207
|
+
// Create cache subsystem
|
|
208
|
+
const cacheManager = new CacheManager(config.cacheDir);
|
|
209
|
+
await cacheManager.migrateV1Cache();
|
|
210
|
+
const toolExecutor: ToolExecutor = async (toolName, toolInput) => {
|
|
211
|
+
const tool = tools[toolName];
|
|
212
|
+
if (!tool) throw new Error(`Tool not found: ${toolName}`);
|
|
213
|
+
return await tool.execute(toolInput);
|
|
214
|
+
};
|
|
215
|
+
const replayer = new StepReplayer(toolExecutor);
|
|
216
|
+
|
|
217
|
+
// Create onStepProgress callback bound to reporter
|
|
218
|
+
const onStepProgress: OnStepProgress = (step) => reporter.onStepProgress(step);
|
|
219
|
+
|
|
220
|
+
// Create TestExecutor with cache-first strategy
|
|
221
|
+
const executor = new TestExecutor({
|
|
222
|
+
cacheManager,
|
|
223
|
+
replayer,
|
|
224
|
+
executeAgentFn: executeAgent,
|
|
225
|
+
model,
|
|
226
|
+
tools,
|
|
227
|
+
config,
|
|
228
|
+
globalContext: config.context,
|
|
229
|
+
noCache: !options.cache,
|
|
230
|
+
onStepProgress,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// Wire execute function for TestRunner
|
|
234
|
+
const executeFn: ExecuteFn = async (testCase, baseUrl, testContext?) =>
|
|
235
|
+
executor.execute(testCase, baseUrl, testContext);
|
|
236
|
+
|
|
237
|
+
const runAnnotations: string[] = [];
|
|
238
|
+
if (options.only) runAnnotations.push(`(filtered by --only "${options.only}")`);
|
|
239
|
+
if (!options.cache) runAnnotations.push("(cache disabled)");
|
|
240
|
+
if (options.verbose) runAnnotations.push("(verbose)");
|
|
241
|
+
printRunHeader(config.tests.length, options.only ? totalTestCount : undefined, runAnnotations);
|
|
242
|
+
|
|
243
|
+
const runner = new TestRunner(config, reporter, executeFn);
|
|
244
|
+
const result = await runner.run();
|
|
245
|
+
result.skipped = options.only ? totalTestCount - config.tests.length : 0;
|
|
246
|
+
|
|
247
|
+
await mcpManager.close();
|
|
248
|
+
await pm.killAll();
|
|
249
|
+
const code = result.failed > 0 ? 1 : 0;
|
|
250
|
+
|
|
251
|
+
// Write JSON to stdout when --output json is active
|
|
252
|
+
if (options.output === "json") {
|
|
253
|
+
const metadata: JsonOutputMetadata = {
|
|
254
|
+
model: config.model,
|
|
255
|
+
provider,
|
|
256
|
+
configFile: options.config,
|
|
257
|
+
baseUrl: config.baseUrl,
|
|
258
|
+
timestamp: new Date().toISOString(),
|
|
259
|
+
...(options.only
|
|
260
|
+
? { filter: { pattern: options.only, matched: config.tests.length, total: totalTestCount } }
|
|
261
|
+
: {}),
|
|
262
|
+
};
|
|
263
|
+
const json = formatJsonOutput(result, metadata, pkg.version, code);
|
|
264
|
+
process.stdout.write(`${json}\n`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
setTimeout(() => process.exit(code), 100);
|
|
268
|
+
} catch (error) {
|
|
269
|
+
if (mcpManager) {
|
|
270
|
+
await mcpManager.close().catch(() => {});
|
|
271
|
+
}
|
|
272
|
+
await pm.killAll();
|
|
273
|
+
|
|
274
|
+
if (error instanceof ConfigLoadError) {
|
|
275
|
+
writeStderr(`${pc.red("Error:")} ${error.message}`);
|
|
276
|
+
if (options.output === "json") {
|
|
277
|
+
const json = formatJsonError(error.message, pkg.version, { configFile: options.config });
|
|
278
|
+
process.stdout.write(`${json}\n`);
|
|
279
|
+
}
|
|
280
|
+
setTimeout(() => process.exit(2), 100);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (error instanceof Error && error.message.startsWith("Missing API key")) {
|
|
284
|
+
writeStderr(`${pc.red("Error:")} ${error.message}`);
|
|
285
|
+
if (options.output === "json") {
|
|
286
|
+
const json = formatJsonError(error.message, pkg.version, { configFile: options.config });
|
|
287
|
+
process.stdout.write(`${json}\n`);
|
|
288
|
+
}
|
|
289
|
+
setTimeout(() => process.exit(2), 100);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
293
|
+
writeStderr(`${pc.red("Unexpected error:")} ${msg}`);
|
|
294
|
+
if (options.output === "json") {
|
|
295
|
+
const json = formatJsonError(msg, pkg.version, { configFile: options.config });
|
|
296
|
+
process.stdout.write(`${json}\n`);
|
|
297
|
+
}
|
|
298
|
+
setTimeout(() => process.exit(2), 100);
|
|
123
299
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
});
|
|
300
|
+
},
|
|
301
|
+
);
|
|
127
302
|
|
|
128
303
|
(async () => {
|
|
304
|
+
const isHelpRequest = process.argv.includes("--help") || process.argv.includes("-h");
|
|
305
|
+
if (isHelpRequest) {
|
|
306
|
+
await animateBanner();
|
|
307
|
+
}
|
|
129
308
|
await program.parseAsync();
|
|
130
309
|
})();
|
package/src/config/loader.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { YAML } from "bun";
|
|
2
|
+
|
|
2
3
|
import { ConfigSchema } from "./schema.ts";
|
|
3
|
-
import type
|
|
4
|
+
import { type Config } from "./types.ts";
|
|
4
5
|
|
|
5
6
|
/** Error thrown when config loading or validation fails */
|
|
6
7
|
export class ConfigLoadError extends Error {
|
|
@@ -39,8 +40,7 @@ export async function loadConfig(filePath: string): Promise<Config> {
|
|
|
39
40
|
);
|
|
40
41
|
}
|
|
41
42
|
throw new ConfigLoadError(
|
|
42
|
-
`Cannot read config file: ${filePath}\n` +
|
|
43
|
-
` ${error instanceof Error ? error.message : String(error)}`,
|
|
43
|
+
`Cannot read config file: ${filePath}\n` + ` ${error instanceof Error ? error.message : String(error)}`,
|
|
44
44
|
error,
|
|
45
45
|
);
|
|
46
46
|
}
|
|
@@ -50,10 +50,7 @@ export async function loadConfig(filePath: string): Promise<Config> {
|
|
|
50
50
|
try {
|
|
51
51
|
raw = YAML.parse(content);
|
|
52
52
|
} catch (error) {
|
|
53
|
-
throw new ConfigLoadError(
|
|
54
|
-
`Invalid YAML syntax: ${error instanceof Error ? error.message : String(error)}`,
|
|
55
|
-
error,
|
|
56
|
-
);
|
|
53
|
+
throw new ConfigLoadError(`Invalid YAML syntax: ${error instanceof Error ? error.message : String(error)}`, error);
|
|
57
54
|
}
|
|
58
55
|
|
|
59
56
|
// Layer 3: Zod validation
|
|
@@ -61,15 +58,10 @@ export async function loadConfig(filePath: string): Promise<Config> {
|
|
|
61
58
|
const result = ConfigSchema.safeParse(raw);
|
|
62
59
|
if (!result.success) {
|
|
63
60
|
const issues = result.error.issues
|
|
64
|
-
.map(
|
|
65
|
-
(issue, i) =>
|
|
66
|
-
` ${i + 1}. ${issue.path.join(".")}: ${issue.message}`,
|
|
67
|
-
)
|
|
61
|
+
.map((issue, i) => ` ${i + 1}. ${issue.path.join(".")}: ${issue.message}`)
|
|
68
62
|
.join("\n");
|
|
69
63
|
const count = result.error.issues.length;
|
|
70
|
-
throw new ConfigLoadError(
|
|
71
|
-
`Invalid config (${count} issue${count > 1 ? "s" : ""})\n${issues}`,
|
|
72
|
-
);
|
|
64
|
+
throw new ConfigLoadError(`Invalid config (${count} issue${count > 1 ? "s" : ""})\n${issues}`);
|
|
73
65
|
}
|
|
74
66
|
|
|
75
67
|
return result.data;
|
package/src/config/types.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type
|
|
2
|
-
|
|
1
|
+
import { type z } from "zod";
|
|
2
|
+
|
|
3
|
+
import { type ConfigSchema, type TestCaseSchema } from "./schema.ts";
|
|
3
4
|
|
|
4
5
|
/** A single test case parsed from the config YAML */
|
|
5
6
|
export type TestCase = z.infer<typeof TestCaseSchema>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preflight reachability check for baseUrl.
|
|
3
|
+
*
|
|
4
|
+
* Resolves on ANY HTTP response (even 4xx/5xx -- those prove the server is reachable).
|
|
5
|
+
* Throws on network-level failures: connection refused, DNS failure, timeout.
|
|
6
|
+
*/
|
|
7
|
+
export async function checkBaseUrlReachable(url: string, timeoutMs = 5000): Promise<void> {
|
|
8
|
+
await fetch(url, {
|
|
9
|
+
method: "HEAD",
|
|
10
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
11
|
+
redirect: "follow",
|
|
12
|
+
});
|
|
13
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Subprocess } from "bun";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Tracks spawned subprocesses and ensures cleanup on shutdown.
|
|
@@ -25,7 +25,11 @@ export class ProcessManager {
|
|
|
25
25
|
proc.kill("SIGKILL");
|
|
26
26
|
}
|
|
27
27
|
}, 5000);
|
|
28
|
-
try {
|
|
28
|
+
try {
|
|
29
|
+
await proc.exited;
|
|
30
|
+
} finally {
|
|
31
|
+
clearTimeout(timeout);
|
|
32
|
+
}
|
|
29
33
|
}
|
|
30
34
|
});
|
|
31
35
|
await Promise.allSettled(kills);
|
package/src/infra/signals.ts
CHANGED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
|
2
|
+
s /= 100;
|
|
3
|
+
l /= 100;
|
|
4
|
+
const k = (n: number) => (n + h / 30) % 12;
|
|
5
|
+
const a = s * Math.min(l, 1 - l);
|
|
6
|
+
const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
|
7
|
+
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function colorChar(char: string, hue: number): string {
|
|
11
|
+
const [r, g, b] = hslToRgb(hue % 360, 100, 60);
|
|
12
|
+
return `\x1b[38;2;${r};${g};${b}m${char}\x1b[0m`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function rainbowLine(text: string, hueOffset: number): string {
|
|
16
|
+
const hueStep = 360 / text.length;
|
|
17
|
+
return text
|
|
18
|
+
.split("")
|
|
19
|
+
.map((char, i) => colorChar(char, (hueOffset + i * hueStep) % 360))
|
|
20
|
+
.join("");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const TITLE = " Super Ghost ";
|
|
24
|
+
const BANNER_LINES = [` 👻${TITLE}👻`, ` ─────────────────────`, ` AI-powered E2E testing`];
|
|
25
|
+
|
|
26
|
+
function renderBanner(hueOffset: number): string[] {
|
|
27
|
+
return [
|
|
28
|
+
` 👻${rainbowLine(TITLE, hueOffset)}👻`,
|
|
29
|
+
` \x1b[2m─────────────────────\x1b[0m`,
|
|
30
|
+
` \x1b[2mAI-powered E2E testing\x1b[0m`,
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const FRAMES = 15;
|
|
35
|
+
const FRAME_MS = 60;
|
|
36
|
+
const HUE_STEP = 24;
|
|
37
|
+
|
|
38
|
+
export async function animateBanner(): Promise<void> {
|
|
39
|
+
const isTTY = process.stderr.isTTY === true;
|
|
40
|
+
|
|
41
|
+
if (!isTTY) {
|
|
42
|
+
const lines = BANNER_LINES;
|
|
43
|
+
process.stderr.write(`${lines.join("\n")}\n\n`);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
process.stderr.write("\x1b[?25l"); // hide cursor
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
for (let frame = 0; frame < FRAMES; frame++) {
|
|
51
|
+
const lines = renderBanner(frame * HUE_STEP);
|
|
52
|
+
if (frame > 0) {
|
|
53
|
+
// Move cursor up N lines to overwrite previous frame
|
|
54
|
+
process.stderr.write(`\x1b[${lines.length}A`);
|
|
55
|
+
}
|
|
56
|
+
process.stderr.write(`${lines.join("\n")}\n`);
|
|
57
|
+
|
|
58
|
+
if (frame < FRAMES - 1) {
|
|
59
|
+
await new Promise<void>((resolve) => setTimeout(resolve, FRAME_MS));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
process.stderr.write("\n");
|
|
63
|
+
} finally {
|
|
64
|
+
process.stderr.write("\x1b[?25h"); // restore cursor
|
|
65
|
+
}
|
|
66
|
+
}
|