@monster-orange/den 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +67 -0
- package/LICENSE +21 -0
- package/README.md +165 -1
- package/bin/den +61 -0
- package/docs/distributions.md +256 -0
- package/make/demo.mk +82 -0
- package/make/den.mk +80 -0
- package/make/journeys.mk +57 -0
- package/make/local_dev.mk +154 -0
- package/make/security.mk +59 -0
- package/make/test.mk +405 -0
- package/package.json +99 -4
- package/scripts/auth-configure.ts +415 -0
- package/scripts/heal-config.ts +92 -0
- package/scripts/jsonl-playwright-reporter.js +70 -0
- package/scripts/manifest-read.cjs +91 -0
- package/scripts/setup-1password.ts +570 -0
- package/scripts/setup-secrets.ts +149 -0
- package/scripts/view-results.js +156 -0
- package/service/install.sh +112 -0
- package/src/pillars/api/fixtures/index.ts +128 -0
- package/src/pillars/api/fixtures/openapi-validator.ts +111 -0
- package/src/pillars/api/global-setup.ts +103 -0
- package/src/pillars/api/playwright.config.ts +105 -0
- package/src/pillars/api/schemas/example-api.json +110 -0
- package/src/pillars/api/tests/health/health.spec.ts +19 -0
- package/src/pillars/api/tests/health/manifest.yml +6 -0
- package/src/pillars/browser/fixtures/index.ts +232 -0
- package/src/pillars/browser/fixtures/package.json +10 -0
- package/src/pillars/browser/global-setup.ts +140 -0
- package/src/pillars/browser/global-teardown.ts +48 -0
- package/src/pillars/browser/playwright.config.ts +212 -0
- package/src/pillars/browser/setup.ts +219 -0
- package/src/pillars/journeys/app-config.ts +237 -0
- package/src/pillars/journeys/discover.ts +160 -0
- package/src/pillars/journeys/hooks/deny-shell.cjs +44 -0
- package/src/pillars/journeys/observability/events.ts +288 -0
- package/src/pillars/journeys/observability/sdk-mapper.ts +306 -0
- package/src/pillars/journeys/report/friction.ts +304 -0
- package/src/pillars/journeys/report/junit.ts +105 -0
- package/src/pillars/journeys/report/render.ts +273 -0
- package/src/pillars/journeys/report/results.ts +323 -0
- package/src/pillars/journeys/run.ts +673 -0
- package/src/pillars/journeys/runner.ts +1025 -0
- package/src/pillars/journeys/sandbox.ts +406 -0
- package/src/pillars/journeys/spec/loader.ts +152 -0
- package/src/pillars/journeys/spec/schema.ts +208 -0
- package/src/pillars/journeys/teardown.ts +98 -0
- package/src/pillars/journeys/tools/browser-session.ts +219 -0
- package/src/pillars/journeys/tools/browser-tool.ts +914 -0
- package/src/pillars/journeys/tools/http-tool.ts +564 -0
- package/src/pillars/journeys/tools/ledger.ts +278 -0
- package/src/pillars/journeys/tools/snapshot-store.ts +117 -0
- package/src/pillars/journeys/verify/assertions.ts +502 -0
- package/src/pillars/journeys/verify/verdict.ts +71 -0
- package/src/pillars/perf/build-report.ts +36 -0
- package/src/pillars/perf/k6.config.js +112 -0
- package/src/pillars/perf/run.ts +345 -0
- package/src/pillars/perf/scenarios/load-profile.js +97 -0
- package/src/pillars/perf/staging.ts +73 -0
- package/src/pillars/security/discover.ts +108 -0
- package/src/pillars/security/findings.ts +151 -0
- package/src/pillars/security/plan.ts +355 -0
- package/src/pillars/security/run.ts +729 -0
- package/src/pillars/smoke/admin-probe.ts +68 -0
- package/src/pillars/smoke/base-url.ts +56 -0
- package/src/pillars/smoke/cleanup.ts +8 -0
- package/src/pillars/smoke/discover.ts +237 -0
- package/src/pillars/smoke/ensure-smoke-teardown.ts +40 -0
- package/src/pillars/smoke/fixture-manager.ts +217 -0
- package/src/pillars/smoke/fixtures.ts +468 -0
- package/src/pillars/smoke/global-setup-all.ts +262 -0
- package/src/pillars/smoke/global-setup-global.ts +262 -0
- package/src/pillars/smoke/global-setup.ts +273 -0
- package/src/pillars/smoke/global-teardown-all.ts +8 -0
- package/src/pillars/smoke/global-teardown-global.ts +8 -0
- package/src/pillars/smoke/global-teardown.ts +139 -0
- package/src/pillars/smoke/playwright-all.config.ts +147 -0
- package/src/pillars/smoke/playwright-global.config.ts +141 -0
- package/src/pillars/smoke/playwright.config.ts +162 -0
- package/src/pillars/smoke/setup-env.ts +132 -0
- package/src/pillars/smoke/setup-global.ts +126 -0
- package/src/pillars/smoke/setup.ts +115 -0
- package/src/pillars/smoke/smoke-repo.ts +16 -0
- package/src/pillars/smoke/staging.ts +49 -0
- package/src/pillars/suites/run.ts +147 -0
- package/src/scripts/configure-aws-profiles.ts +114 -0
- package/src/scripts/register-repos.ts +307 -0
- package/src/scripts/setup-default-workspace.ts +203 -0
- package/src/shared/applications.ts +138 -0
- package/src/shared/auth/api-token.ts +416 -0
- package/src/shared/auth/auth0-helper.ts +284 -0
- package/src/shared/auth/headless-login.ts +265 -0
- package/src/shared/auth/jwt-helper.ts +186 -0
- package/src/shared/auth/sudo-vault.ts +69 -0
- package/src/shared/auth/test-accounts.ts +98 -0
- package/src/shared/auth/token-injection.ts +184 -0
- package/src/shared/auth/token-providers/auth0.ts +80 -0
- package/src/shared/auth/token-providers/index.ts +182 -0
- package/src/shared/auth/token-refresh.ts +273 -0
- package/src/shared/banner.ts +15 -0
- package/src/shared/brand.ts +79 -0
- package/src/shared/cli-tabs.ts +131 -0
- package/src/shared/constants.ts +128 -0
- package/src/shared/den-config.ts +566 -0
- package/src/shared/dev-metrics/emit.ts +354 -0
- package/src/shared/edge/config.ts +229 -0
- package/src/shared/edge/render.ts +221 -0
- package/src/shared/edge/runtime.ts +268 -0
- package/src/shared/edge/types.ts +62 -0
- package/src/shared/engine.ts +67 -0
- package/src/shared/environment/aws-profiles.ts +49 -0
- package/src/shared/environment/env-selector.ts +161 -0
- package/src/shared/environment/playwright-env.ts +82 -0
- package/src/shared/environment-manager/catalog.ts +77 -0
- package/src/shared/environment-manager/clone-guard.ts +163 -0
- package/src/shared/environment-manager/dag.ts +286 -0
- package/src/shared/environment-manager/default-workspace.ts +182 -0
- package/src/shared/environment-manager/derivation/alias.ts +90 -0
- package/src/shared/environment-manager/derivation/cache.ts +97 -0
- package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
- package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
- package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
- package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
- package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
- package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
- package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
- package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
- package/src/shared/environment-manager/derivation/scan.ts +160 -0
- package/src/shared/environment-manager/derivation/text.ts +40 -0
- package/src/shared/environment-manager/derivation/types.ts +69 -0
- package/src/shared/environment-manager/health.ts +589 -0
- package/src/shared/environment-manager/index.ts +6 -0
- package/src/shared/environment-manager/load-applications.ts +78 -0
- package/src/shared/environment-manager/log-tail.ts +212 -0
- package/src/shared/environment-manager/make-targets.ts +78 -0
- package/src/shared/environment-manager/opctl.ts +334 -0
- package/src/shared/environment-manager/readiness.ts +93 -0
- package/src/shared/environment-manager/register-local.ts +72 -0
- package/src/shared/environment-manager/secrets-check.ts +260 -0
- package/src/shared/environment-manager/service-env.ts +110 -0
- package/src/shared/environment-manager/service-runner.ts +1588 -0
- package/src/shared/environment-manager/up-plan.ts +302 -0
- package/src/shared/environment-manager/workspace-manager.ts +905 -0
- package/src/shared/environment-manager/workspaces.ts +860 -0
- package/src/shared/manifest/deps.ts +27 -0
- package/src/shared/manifest/migrate-manifest.ts +101 -0
- package/src/shared/manifest/read-manifest.ts +160 -0
- package/src/shared/manifest/schema.ts +299 -0
- package/src/shared/manifest/service-links.ts +93 -0
- package/src/shared/manifest/validate-manifest.ts +140 -0
- package/src/shared/manifest/workspace-manifests.ts +92 -0
- package/src/shared/open-url.ts +122 -0
- package/src/shared/org.ts +44 -0
- package/src/shared/paths.ts +178 -0
- package/src/shared/perf/optional-fields.ts +105 -0
- package/src/shared/perf/report-normalizer.ts +404 -0
- package/src/shared/perf/report-paths.ts +19 -0
- package/src/shared/perf/report-store.ts +110 -0
- package/src/shared/perf/report-types.ts +70 -0
- package/src/shared/perf/test-paths.ts +25 -0
- package/src/shared/process/log-rotation.ts +147 -0
- package/src/shared/process/spawn-streaming.ts +229 -0
- package/src/shared/product.ts +92 -0
- package/src/shared/profile.ts +141 -0
- package/src/shared/repo-kind.ts +32 -0
- package/src/shared/reporting/jsonl-reporter.ts +131 -0
- package/src/shared/reporting/slack-reporter.ts +340 -0
- package/src/shared/results/result-reader.ts +266 -0
- package/src/shared/results/result-writer.ts +168 -0
- package/src/shared/secrets/doppler.ts +126 -0
- package/src/shared/secrets/env.ts +48 -0
- package/src/shared/secrets/index.ts +197 -0
- package/src/shared/secrets/keys.ts +33 -0
- package/src/shared/secrets/onepassword.ts +75 -0
- package/src/shared/secrets/types.ts +52 -0
- package/src/shared/service-url.ts +119 -0
- package/src/shared/smoke/branch-resolver.ts +141 -0
- package/src/shared/smoke/git-remote.ts +70 -0
- package/src/shared/smoke/repo-cloner.ts +151 -0
- package/src/shared/stacks/config.ts +137 -0
- package/src/shared/stacks/manifest-commands.ts +88 -0
- package/src/shared/stacks/render.ts +253 -0
- package/src/shared/stacks/runtime.ts +334 -0
- package/src/shared/stacks/types.ts +53 -0
- package/src/shared/terminal/tabs.ts +212 -0
- package/src/shared/test-discovery/module-scanner.ts +569 -0
- package/src/shared/types/index.ts +678 -0
- package/src/shared/ui-marker.ts +19 -0
- package/src/ui/cli/applications-command.ts +116 -0
- package/src/ui/cli/config-command.ts +68 -0
- package/src/ui/cli/create-main.ts +9 -0
- package/src/ui/cli/create.ts +360 -0
- package/src/ui/cli/edge-command.ts +91 -0
- package/src/ui/cli/flag-parser.ts +246 -0
- package/src/ui/cli/graph-command.ts +524 -0
- package/src/ui/cli/health-display.ts +63 -0
- package/src/ui/cli/index.ts +3656 -0
- package/src/ui/cli/init.ts +855 -0
- package/src/ui/cli/invocation-target.ts +175 -0
- package/src/ui/cli/logs-command.ts +104 -0
- package/src/ui/cli/module-selection.ts +31 -0
- package/src/ui/cli/prompts.ts +767 -0
- package/src/ui/cli/results-viewer.ts +436 -0
- package/src/ui/cli/secrets-preflight.ts +91 -0
- package/src/ui/cli/stack-command.ts +144 -0
- package/src/ui/cli/stalled-setup-recovery.ts +129 -0
- package/src/ui/cli/test-runner.ts +1312 -0
- package/src/ui/cli/web-command.ts +1073 -0
- package/src/ui/server/changelog-parser.ts +94 -0
- package/src/ui/server/daemon-reaper.ts +129 -0
- package/src/ui/server/editor-manager.ts +301 -0
- package/src/ui/server/github-service.ts +223 -0
- package/src/ui/server/http-helpers.ts +98 -0
- package/src/ui/server/index.ts +3236 -0
- package/src/ui/server/log-history.ts +452 -0
- package/src/ui/server/perf-report.ts +42 -0
- package/src/ui/server/public/environments.html +494 -0
- package/src/ui/server/public/favicon.ico +0 -0
- package/src/ui/server/public/header.js +1371 -0
- package/src/ui/server/public/index.html +2595 -0
- package/src/ui/server/public/local-dev.html +7405 -0
- package/src/ui/server/public/logs.html +1489 -0
- package/src/ui/server/public/perf-report.js +359 -0
- package/src/ui/server/public/release.html +2111 -0
- package/src/ui/server/public/services.html +1098 -0
- package/src/ui/server/public/workspace-picker.js +69 -0
- package/src/ui/server/release-deploy-session.ts +246 -0
- package/src/ui/server/release-service.ts +1125 -0
- package/src/ui/server/sudo-store.ts +111 -0
- package/src/ui/server/workspace-routes.ts +2154 -0
|
@@ -0,0 +1,1312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test Runner
|
|
3
|
+
* Executes tests for each pillar
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { spawn } from "child_process";
|
|
7
|
+
import chalk from "chalk";
|
|
8
|
+
import { spawnStreaming as sharedSpawnStreaming } from "../../shared/process/spawn-streaming.js";
|
|
9
|
+
import * as fs from "fs";
|
|
10
|
+
import * as path from "path";
|
|
11
|
+
import type { Pillar, Environment } from "../../shared/types/index.js";
|
|
12
|
+
import { refreshAuthIfNeeded } from "../../shared/auth/token-refresh.js";
|
|
13
|
+
import inquirer from "inquirer";
|
|
14
|
+
import {
|
|
15
|
+
PERF_CURRENT_REPORT_DIR,
|
|
16
|
+
getPerfSummaryArtifactPath,
|
|
17
|
+
} from "../../shared/perf/report-paths.js";
|
|
18
|
+
import { resetCurrentPerfRunReport } from "../../shared/perf/report-store.js";
|
|
19
|
+
import { getPerfTestFile } from "../../shared/perf/test-paths.js";
|
|
20
|
+
import { getDistRoot } from "../../shared/paths.js";
|
|
21
|
+
import {
|
|
22
|
+
enginePath,
|
|
23
|
+
playwrightCommand,
|
|
24
|
+
tsxCommand,
|
|
25
|
+
type EngineCommand,
|
|
26
|
+
} from "../../shared/engine.js";
|
|
27
|
+
|
|
28
|
+
/** After Playwright smoke runs: fail if storage fixture handoff was left behind. */
|
|
29
|
+
function smokePostRunTeardownVerifyStep(env: NodeJS.ProcessEnv): CommandStep {
|
|
30
|
+
return {
|
|
31
|
+
...tsxCommand("src/pillars/smoke/ensure-smoke-teardown.ts"),
|
|
32
|
+
env,
|
|
33
|
+
label: "Verify smoke storage teardown",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface CommandStep {
|
|
38
|
+
command: string;
|
|
39
|
+
args: string[];
|
|
40
|
+
env: NodeJS.ProcessEnv;
|
|
41
|
+
label: string;
|
|
42
|
+
continueOnFailure?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface TestRunOptions {
|
|
46
|
+
pillar: Pillar;
|
|
47
|
+
environment: Environment;
|
|
48
|
+
headed?: boolean;
|
|
49
|
+
debug?: boolean;
|
|
50
|
+
grep?: string;
|
|
51
|
+
/** OWASP ZAP scan for the security pillar (default baseline). */
|
|
52
|
+
securityMode?: "baseline" | "api" | "full";
|
|
53
|
+
modules?: string[]; // selected modules; empty/undefined = all. e.g. ['items','storage']; smoke: ['shop/api']
|
|
54
|
+
smokeCollection?: string; // smoke suite within a service, e.g. 'items'
|
|
55
|
+
baseApiUrl?: string;
|
|
56
|
+
useLocalCheckout?: boolean;
|
|
57
|
+
// Per-perf-test env overrides, keyed by module name (the same name the module
|
|
58
|
+
// scanner / web UI use, e.g. "api-load" or "repo/module"). Each test's
|
|
59
|
+
// declared `optionalFields` are resolved to env vars by the server (via
|
|
60
|
+
// resolveFieldEnv) and injected ONLY into THAT test's k6 process — never
|
|
61
|
+
// globally. Empty/undefined for tests with no optional fields.
|
|
62
|
+
moduleEnv?: Record<string, Record<string, string>>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface TestRunResult {
|
|
66
|
+
success: boolean;
|
|
67
|
+
exitCode: number;
|
|
68
|
+
duration: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class TestRunner {
|
|
72
|
+
private getBaseRunnerEnv(): NodeJS.ProcessEnv {
|
|
73
|
+
const {
|
|
74
|
+
BASE_API_URL: _baseApiUrl,
|
|
75
|
+
BASE_URL: _baseUrl,
|
|
76
|
+
...rest
|
|
77
|
+
} = process.env;
|
|
78
|
+
return rest;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Run tests for a specific pillar
|
|
83
|
+
*/
|
|
84
|
+
async runTests(options: TestRunOptions): Promise<TestRunResult> {
|
|
85
|
+
const startTime = Date.now();
|
|
86
|
+
|
|
87
|
+
console.log(
|
|
88
|
+
chalk.cyan(
|
|
89
|
+
`\n🚀 Running ${options.pillar} tests against ${options.environment}\n`,
|
|
90
|
+
),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const result = await this.executeTestCommand(options);
|
|
94
|
+
|
|
95
|
+
const duration = Date.now() - startTime;
|
|
96
|
+
|
|
97
|
+
if (result.success) {
|
|
98
|
+
console.log(
|
|
99
|
+
chalk.green(
|
|
100
|
+
`\n✅ Tests completed successfully in ${this.formatDuration(duration)}\n`,
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
} else {
|
|
104
|
+
console.log(
|
|
105
|
+
chalk.red(`\n❌ Tests failed in ${this.formatDuration(duration)}\n`),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
...result,
|
|
111
|
+
duration,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Execute test command based on pillar
|
|
117
|
+
*/
|
|
118
|
+
private async executeTestCommand(
|
|
119
|
+
options: TestRunOptions,
|
|
120
|
+
): Promise<{ success: boolean; exitCode: number }> {
|
|
121
|
+
switch (options.pillar) {
|
|
122
|
+
case "synthetic":
|
|
123
|
+
case "integration":
|
|
124
|
+
return await this.runPlaywrightTests(options);
|
|
125
|
+
|
|
126
|
+
case "smoke":
|
|
127
|
+
return await this.runSmokeTests(options);
|
|
128
|
+
|
|
129
|
+
case "performance":
|
|
130
|
+
return await this.runK6Tests(options);
|
|
131
|
+
|
|
132
|
+
case "journeys":
|
|
133
|
+
return await this.runJourneyTests(options);
|
|
134
|
+
|
|
135
|
+
case "security":
|
|
136
|
+
return await this.runSecurityTests(options);
|
|
137
|
+
case "suites": {
|
|
138
|
+
const [step] = this.pillarBuilders[options.pillar](
|
|
139
|
+
options,
|
|
140
|
+
{ ...process.env, E2E_ENVIRONMENT: options.environment },
|
|
141
|
+
undefined,
|
|
142
|
+
"",
|
|
143
|
+
);
|
|
144
|
+
return await this.spawnProcess(step.command, step.args, step.env);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
default:
|
|
148
|
+
throw new Error(`Unknown pillar: ${options.pillar}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Run Playwright tests (synthetic or integration)
|
|
154
|
+
*/
|
|
155
|
+
private async runPlaywrightTests(
|
|
156
|
+
options: TestRunOptions,
|
|
157
|
+
): Promise<{ success: boolean; exitCode: number }> {
|
|
158
|
+
// 1. Check if server is up before anything else
|
|
159
|
+
const serverUp = await this.checkServerHealth(
|
|
160
|
+
options.environment,
|
|
161
|
+
options.pillar,
|
|
162
|
+
);
|
|
163
|
+
if (!serverUp) {
|
|
164
|
+
return { success: false, exitCode: 1 };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 2. Check auth tokens before running tests
|
|
168
|
+
const authValid = await this.checkAndRefreshAuth(options.environment);
|
|
169
|
+
if (!authValid) {
|
|
170
|
+
return { success: false, exitCode: 1 };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// For synthetic tests: stage from linked repo first, then run from .synth-run/
|
|
174
|
+
if (options.pillar === "synthetic") {
|
|
175
|
+
return this.runStagedSyntheticTests(options);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// integration is the only pillar reaching this point (synthetic exits early above)
|
|
179
|
+
const config = enginePath("src/pillars/api/playwright.config.ts");
|
|
180
|
+
const args = ["test", "--config", config];
|
|
181
|
+
const integrationMods = options.modules ?? [];
|
|
182
|
+
if (integrationMods.length > 0) {
|
|
183
|
+
integrationMods.forEach((m) =>
|
|
184
|
+
args.push(enginePath(`src/pillars/api/tests/${m}`) + "/"),
|
|
185
|
+
);
|
|
186
|
+
console.log(
|
|
187
|
+
chalk.cyan(`📦 Running module(s): ${integrationMods.join(", ")}\n`),
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Only run headed for manual/interactive tests or when explicitly requested
|
|
192
|
+
const shouldRunHeaded =
|
|
193
|
+
options.headed ||
|
|
194
|
+
(options.grep &&
|
|
195
|
+
(options.grep.toLowerCase().includes("manual") ||
|
|
196
|
+
options.grep.toLowerCase().includes("poc") ||
|
|
197
|
+
options.grep.toLowerCase().includes("multi-user")));
|
|
198
|
+
|
|
199
|
+
// Add options
|
|
200
|
+
if (shouldRunHeaded) {
|
|
201
|
+
args.push("--headed");
|
|
202
|
+
console.log(
|
|
203
|
+
chalk.yellow("📺 Running in headed mode (browser visible)\n"),
|
|
204
|
+
);
|
|
205
|
+
} else {
|
|
206
|
+
console.log(chalk.gray("🚀 Running in headless mode (fast!)\n"));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (options.debug) {
|
|
210
|
+
args.push("--debug");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (options.grep) {
|
|
214
|
+
args.push("--grep", options.grep);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Set environment variables
|
|
218
|
+
const env = {
|
|
219
|
+
...process.env,
|
|
220
|
+
E2E_ENVIRONMENT: options.environment,
|
|
221
|
+
...(options.useLocalCheckout ? { USE_LOCAL_CHECKOUT: "1" } : {}),
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
console.log(chalk.gray(`Environment: ${options.environment}`));
|
|
225
|
+
console.log(chalk.gray(`E2E_ENVIRONMENT set to: ${env.E2E_ENVIRONMENT}\n`));
|
|
226
|
+
|
|
227
|
+
return await this.spawnCommand(playwrightCommand(...args), env);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Stage synthetic tests from the linked repo, then run them.
|
|
232
|
+
* Uses synth/setup.ts to copy .spec.ts files + shims to .synth-run/<repo>/
|
|
233
|
+
* and then runs synth/playwright.config.ts.
|
|
234
|
+
*/
|
|
235
|
+
/**
|
|
236
|
+
* Build the playwright command for a synthetic test run.
|
|
237
|
+
* Single source of truth used by both runStagedSyntheticTests (CLI)
|
|
238
|
+
* and buildCommand (web server) so they can never drift.
|
|
239
|
+
*/
|
|
240
|
+
private buildSyntheticPlaywrightCommand(
|
|
241
|
+
options: TestRunOptions,
|
|
242
|
+
): EngineCommand {
|
|
243
|
+
const args = [
|
|
244
|
+
"test",
|
|
245
|
+
`--config=${enginePath("src/pillars/browser/playwright.config.ts")}`,
|
|
246
|
+
];
|
|
247
|
+
|
|
248
|
+
const modules = options.modules ?? [];
|
|
249
|
+
if (modules.length > 0) {
|
|
250
|
+
for (const mod of modules) {
|
|
251
|
+
// Pass just the module name — playwright matches it against the full
|
|
252
|
+
// file path, which includes .synth-run/<repo>/<module>/. This avoids
|
|
253
|
+
// hardcoding the repo name in the pattern.
|
|
254
|
+
args.push(mod);
|
|
255
|
+
}
|
|
256
|
+
// Skip project dependencies when targeting specific modules — prevents
|
|
257
|
+
// a run-last project (`*.last.spec.ts`) from triggering
|
|
258
|
+
// the full suite just because it declares dependencies for ordering.
|
|
259
|
+
args.push("--no-deps");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const shouldRunHeaded =
|
|
263
|
+
options.headed ||
|
|
264
|
+
(options.grep &&
|
|
265
|
+
(options.grep.toLowerCase().includes("manual") ||
|
|
266
|
+
options.grep.toLowerCase().includes("poc") ||
|
|
267
|
+
options.grep.toLowerCase().includes("multi-user")));
|
|
268
|
+
|
|
269
|
+
if (shouldRunHeaded) args.push("--headed");
|
|
270
|
+
if (options.debug) args.push("--debug");
|
|
271
|
+
if (options.grep) args.push("--grep", options.grep);
|
|
272
|
+
|
|
273
|
+
return playwrightCommand(...args);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private async runStagedSyntheticTests(
|
|
277
|
+
options: TestRunOptions,
|
|
278
|
+
): Promise<{ success: boolean; exitCode: number }> {
|
|
279
|
+
const stageEnv = {
|
|
280
|
+
...process.env,
|
|
281
|
+
E2E_ENVIRONMENT: options.environment,
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
console.log(chalk.cyan(`📦 Staging synthetic tests...\n`));
|
|
285
|
+
const stageResult = await this.spawnCommand(
|
|
286
|
+
tsxCommand("src/pillars/browser/setup.ts"),
|
|
287
|
+
stageEnv,
|
|
288
|
+
);
|
|
289
|
+
if (!stageResult.success) {
|
|
290
|
+
return stageResult;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const run = this.buildSyntheticPlaywrightCommand(options);
|
|
294
|
+
|
|
295
|
+
const modules = options.modules ?? [];
|
|
296
|
+
if (modules.length > 0) {
|
|
297
|
+
console.log(chalk.cyan(`📦 Running module(s): ${modules.join(", ")}\n`));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const shouldRunHeaded =
|
|
301
|
+
options.headed ||
|
|
302
|
+
(options.grep &&
|
|
303
|
+
(options.grep.toLowerCase().includes("manual") ||
|
|
304
|
+
options.grep.toLowerCase().includes("poc") ||
|
|
305
|
+
options.grep.toLowerCase().includes("multi-user")));
|
|
306
|
+
|
|
307
|
+
if (shouldRunHeaded) {
|
|
308
|
+
console.log(
|
|
309
|
+
chalk.yellow("📺 Running in headed mode (browser visible)\n"),
|
|
310
|
+
);
|
|
311
|
+
} else {
|
|
312
|
+
console.log(chalk.gray("🚀 Running in headless mode (fast!)\n"));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const env = { ...stageEnv };
|
|
316
|
+
console.log(chalk.gray(`Environment: ${options.environment}`));
|
|
317
|
+
console.log(chalk.gray(`E2E_ENVIRONMENT set to: ${env.E2E_ENVIRONMENT}\n`));
|
|
318
|
+
|
|
319
|
+
return await this.spawnCommand(run, env);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Run smoke tests via make test-smk-* targets
|
|
324
|
+
* module format: "{repo}/{service}" (e.g. "shop/api") or null for all
|
|
325
|
+
*/
|
|
326
|
+
private async runSmokeTests(
|
|
327
|
+
options: TestRunOptions,
|
|
328
|
+
): Promise<{ success: boolean; exitCode: number }> {
|
|
329
|
+
let makeTarget: string;
|
|
330
|
+
let repo: string | undefined;
|
|
331
|
+
|
|
332
|
+
const smokeMod = (options.modules ?? [])[0];
|
|
333
|
+
if (smokeMod) {
|
|
334
|
+
// module = "shop/api"
|
|
335
|
+
const [repoName, service] = smokeMod.split("/");
|
|
336
|
+
repo = repoName;
|
|
337
|
+
if (options.smokeCollection) {
|
|
338
|
+
// specific suite: make test-smk-api-items
|
|
339
|
+
makeTarget = `test-smk-${service}-${options.smokeCollection}`;
|
|
340
|
+
} else {
|
|
341
|
+
// all suites in service: make test-smk-api-all
|
|
342
|
+
makeTarget = `test-smk-${service}-all`;
|
|
343
|
+
}
|
|
344
|
+
} else {
|
|
345
|
+
// all services: make test-smk-all
|
|
346
|
+
makeTarget = "test-smk-all";
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const env = {
|
|
350
|
+
...process.env,
|
|
351
|
+
E2E_ENVIRONMENT: options.environment,
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
console.log(chalk.gray(`Environment: ${options.environment}`));
|
|
355
|
+
|
|
356
|
+
return await this.spawnProcess(
|
|
357
|
+
"make",
|
|
358
|
+
[
|
|
359
|
+
makeTarget,
|
|
360
|
+
...(repo ? [`repo=${repo}`] : []),
|
|
361
|
+
`env=${options.environment}`,
|
|
362
|
+
],
|
|
363
|
+
env,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Build the command steps for a journeys run — one `npx tsx
|
|
369
|
+
* src/pillars/journeys/run.ts --env=<env> [--journey=<repo/name>]`
|
|
370
|
+
* invocation per selected journey (or a single all-journeys run when
|
|
371
|
+
* none are selected). Module names come from the module scanner as
|
|
372
|
+
* "<repo>/<journey-name>" and flow through REPO-QUALIFIED (review
|
|
373
|
+
* finding 10) — the entrypoint filters on repo + name, so duplicate
|
|
374
|
+
* journey names across repos stay unambiguous. The selected
|
|
375
|
+
* environment flows through as the required --env flag exactly like
|
|
376
|
+
* other pillars — policy (production refusal, env allowlists) is owned
|
|
377
|
+
* by the entrypoint, never duplicated here.
|
|
378
|
+
*
|
|
379
|
+
* Single source of truth used by both runJourneyTests (interactive CLI)
|
|
380
|
+
* and pillarBuilders.journeys (web server) so they can never drift.
|
|
381
|
+
*/
|
|
382
|
+
private buildJourneysSteps(
|
|
383
|
+
options: TestRunOptions,
|
|
384
|
+
baseEnv: NodeJS.ProcessEnv,
|
|
385
|
+
): CommandStep[] {
|
|
386
|
+
const journeyNames = options.modules ?? [];
|
|
387
|
+
|
|
388
|
+
if (journeyNames.length === 0) {
|
|
389
|
+
return [
|
|
390
|
+
{
|
|
391
|
+
...tsxCommand(
|
|
392
|
+
"src/pillars/journeys/run.ts",
|
|
393
|
+
`--env=${options.environment}`,
|
|
394
|
+
),
|
|
395
|
+
env: baseEnv,
|
|
396
|
+
label: "Run all journeys",
|
|
397
|
+
},
|
|
398
|
+
];
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return journeyNames.map((name) => ({
|
|
402
|
+
...tsxCommand(
|
|
403
|
+
"src/pillars/journeys/run.ts",
|
|
404
|
+
`--env=${options.environment}`,
|
|
405
|
+
`--journey=${name}`,
|
|
406
|
+
),
|
|
407
|
+
env: baseEnv,
|
|
408
|
+
label: `Run journey ${name}`,
|
|
409
|
+
}));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Run journeys via the pillar entrypoint (sequential, abort on the
|
|
414
|
+
* first failing journey run — its exit code is the run's exit code).
|
|
415
|
+
*/
|
|
416
|
+
private async runJourneyTests(
|
|
417
|
+
options: TestRunOptions,
|
|
418
|
+
): Promise<{ success: boolean; exitCode: number }> {
|
|
419
|
+
const env: NodeJS.ProcessEnv = {
|
|
420
|
+
...process.env,
|
|
421
|
+
E2E_ENVIRONMENT: options.environment,
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
console.log(chalk.gray(`Environment: ${options.environment}\n`));
|
|
425
|
+
|
|
426
|
+
for (const step of this.buildJourneysSteps(options, env)) {
|
|
427
|
+
const result = await this.spawnProcess(step.command, step.args, step.env);
|
|
428
|
+
if (!result.success) {
|
|
429
|
+
return result;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return { success: true, exitCode: 0 };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Build the command steps for a security run: one `npx tsx
|
|
438
|
+
* src/pillars/security/run.ts --env=<env> [--service=<repo/name>]`
|
|
439
|
+
* invocation per selected target (or one all-targets run). Always the
|
|
440
|
+
* runner's default `baseline` (passive) mode: stronger modes are a
|
|
441
|
+
* deliberate `make test-sec-... mode=` act, never a picker default.
|
|
442
|
+
* Policy (production gate, mode ceiling) is owned by the entrypoint.
|
|
443
|
+
*/
|
|
444
|
+
private buildSecuritySteps(
|
|
445
|
+
options: TestRunOptions,
|
|
446
|
+
baseEnv: NodeJS.ProcessEnv,
|
|
447
|
+
): CommandStep[] {
|
|
448
|
+
const names = options.modules ?? [];
|
|
449
|
+
const script = "src/pillars/security/run.ts";
|
|
450
|
+
if (names.length === 0) {
|
|
451
|
+
return [
|
|
452
|
+
{
|
|
453
|
+
...tsxCommand(script, `--env=${options.environment}`),
|
|
454
|
+
env: baseEnv,
|
|
455
|
+
label: "Run all security scans",
|
|
456
|
+
},
|
|
457
|
+
];
|
|
458
|
+
}
|
|
459
|
+
return names.map((name) => ({
|
|
460
|
+
...tsxCommand(
|
|
461
|
+
script,
|
|
462
|
+
`--env=${options.environment}`,
|
|
463
|
+
`--service=${name}`,
|
|
464
|
+
),
|
|
465
|
+
env: baseEnv,
|
|
466
|
+
label: `Run security scan ${name}`,
|
|
467
|
+
}));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Run security scans via the pillar entrypoint (sequential, abort on first failure). */
|
|
471
|
+
private async runSecurityTests(
|
|
472
|
+
options: TestRunOptions,
|
|
473
|
+
): Promise<{ success: boolean; exitCode: number }> {
|
|
474
|
+
const env: NodeJS.ProcessEnv = {
|
|
475
|
+
...process.env,
|
|
476
|
+
E2E_ENVIRONMENT: options.environment,
|
|
477
|
+
};
|
|
478
|
+
console.log(
|
|
479
|
+
chalk.gray(`Environment: ${options.environment}
|
|
480
|
+
`),
|
|
481
|
+
);
|
|
482
|
+
for (const step of this.buildSecuritySteps(options, env)) {
|
|
483
|
+
const result = await this.spawnProcess(step.command, step.args, step.env);
|
|
484
|
+
if (!result.success) return result;
|
|
485
|
+
}
|
|
486
|
+
return { success: true, exitCode: 0 };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Run k6 performance tests
|
|
491
|
+
*/
|
|
492
|
+
private async runK6Tests(
|
|
493
|
+
options: TestRunOptions,
|
|
494
|
+
): Promise<{ success: boolean; exitCode: number }> {
|
|
495
|
+
// Check if k6 is installed
|
|
496
|
+
const k6Installed = await this.checkK6Installed();
|
|
497
|
+
|
|
498
|
+
if (!k6Installed) {
|
|
499
|
+
console.log(chalk.red("\n❌ k6 is not installed"));
|
|
500
|
+
console.log(
|
|
501
|
+
chalk.gray(
|
|
502
|
+
"Install k6 from: https://k6.io/docs/get-started/installation/",
|
|
503
|
+
),
|
|
504
|
+
);
|
|
505
|
+
console.log(chalk.gray(" macOS: brew install k6"));
|
|
506
|
+
console.log(chalk.gray(" Linux: See installation guide\n"));
|
|
507
|
+
return { success: false, exitCode: 1 };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const perfMods = options.modules ?? [];
|
|
511
|
+
if (perfMods.length === 0) {
|
|
512
|
+
console.log(chalk.red("\n❌ No performance test module specified\n"));
|
|
513
|
+
return { success: false, exitCode: 1 };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const explicitBaseUrl = options.baseApiUrl?.trim() || undefined;
|
|
517
|
+
const inheritedRunnerEnv = this.getBaseRunnerEnv();
|
|
518
|
+
const baseEnv: NodeJS.ProcessEnv = {
|
|
519
|
+
...inheritedRunnerEnv,
|
|
520
|
+
E2E_ENVIRONMENT: options.environment,
|
|
521
|
+
...(options.useLocalCheckout ? { USE_LOCAL_CHECKOUT: "1" } : {}),
|
|
522
|
+
...(explicitBaseUrl
|
|
523
|
+
? { BASE_URL: explicitBaseUrl, BASE_API_URL: explicitBaseUrl }
|
|
524
|
+
: {}),
|
|
525
|
+
};
|
|
526
|
+
const { internalModules, repoModules } =
|
|
527
|
+
this.partitionPerformanceModules(perfMods);
|
|
528
|
+
|
|
529
|
+
for (const [repoName, moduleNames] of repoModules.entries()) {
|
|
530
|
+
if (options.environment !== "local" && !options.useLocalCheckout) {
|
|
531
|
+
const setupEnvResult = await this.spawnCommand(
|
|
532
|
+
tsxCommand(
|
|
533
|
+
"src/pillars/smoke/setup-env.ts",
|
|
534
|
+
"--env",
|
|
535
|
+
options.environment,
|
|
536
|
+
"--repo",
|
|
537
|
+
repoName,
|
|
538
|
+
),
|
|
539
|
+
baseEnv,
|
|
540
|
+
);
|
|
541
|
+
if (!setupEnvResult.success) {
|
|
542
|
+
return setupEnvResult;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const repoRun = tsxCommand(
|
|
547
|
+
"src/pillars/perf/run.ts",
|
|
548
|
+
"--env",
|
|
549
|
+
options.environment,
|
|
550
|
+
...(explicitBaseUrl ? ["--base-url", explicitBaseUrl] : []),
|
|
551
|
+
...(options.useLocalCheckout ? ["--use-local-checkout"] : []),
|
|
552
|
+
...moduleNames.flatMap((moduleName) => [
|
|
553
|
+
"--repo",
|
|
554
|
+
repoName,
|
|
555
|
+
"--module",
|
|
556
|
+
moduleName,
|
|
557
|
+
]),
|
|
558
|
+
);
|
|
559
|
+
const repoRunResult = await this.spawnCommand(repoRun, baseEnv);
|
|
560
|
+
if (!repoRunResult.success) {
|
|
561
|
+
return repoRunResult;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (internalModules.length > 0) {
|
|
566
|
+
// Den-root anchored: the CLI's cwd is wherever the user ran `den`.
|
|
567
|
+
fs.mkdirSync(path.resolve(getDistRoot(), PERF_CURRENT_REPORT_DIR), {
|
|
568
|
+
recursive: true,
|
|
569
|
+
});
|
|
570
|
+
// Clear the prior run's report up front so a stale one is never served.
|
|
571
|
+
resetCurrentPerfRunReport();
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
let internalOverallFailed = false;
|
|
575
|
+
let internalFirstExitCode = 0;
|
|
576
|
+
for (const perfMod of internalModules) {
|
|
577
|
+
const result = await this.spawnProcess(
|
|
578
|
+
"k6",
|
|
579
|
+
[
|
|
580
|
+
"run",
|
|
581
|
+
"--summary-export",
|
|
582
|
+
// Absolute: k6 resolves relative paths against its inherited cwd.
|
|
583
|
+
path.resolve(getDistRoot(), getPerfSummaryArtifactPath(perfMod)),
|
|
584
|
+
getPerfTestFile(perfMod),
|
|
585
|
+
],
|
|
586
|
+
{ ...baseEnv, ...(options.moduleEnv?.[perfMod] ?? {}) },
|
|
587
|
+
);
|
|
588
|
+
if (!result.success) {
|
|
589
|
+
internalOverallFailed = true;
|
|
590
|
+
if (internalFirstExitCode === 0) {
|
|
591
|
+
internalFirstExitCode = result.exitCode;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (internalModules.length > 0) {
|
|
597
|
+
const reportResult = await this.spawnCommand(
|
|
598
|
+
tsxCommand("src/pillars/perf/build-report.ts"),
|
|
599
|
+
{
|
|
600
|
+
...baseEnv,
|
|
601
|
+
PERF_ENVIRONMENT: options.environment,
|
|
602
|
+
PERF_MODULES: internalModules.join(","),
|
|
603
|
+
},
|
|
604
|
+
);
|
|
605
|
+
if (!reportResult.success) {
|
|
606
|
+
return reportResult;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (internalOverallFailed) {
|
|
611
|
+
return { success: false, exitCode: internalFirstExitCode || 1 };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return { success: true, exitCode: 0 };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
private partitionPerformanceModules(modules: string[]): {
|
|
618
|
+
internalModules: string[];
|
|
619
|
+
repoModules: Map<string, string[]>;
|
|
620
|
+
} {
|
|
621
|
+
const internalModules: string[] = [];
|
|
622
|
+
const repoModules = new Map<string, string[]>();
|
|
623
|
+
|
|
624
|
+
for (const moduleName of modules) {
|
|
625
|
+
const [repoName, perfModuleName] = moduleName.split("/", 2);
|
|
626
|
+
if (!perfModuleName) {
|
|
627
|
+
internalModules.push(moduleName);
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
const existing = repoModules.get(repoName) ?? [];
|
|
631
|
+
existing.push(perfModuleName);
|
|
632
|
+
repoModules.set(repoName, existing);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
return { internalModules, repoModules };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Check if k6 is installed
|
|
640
|
+
*/
|
|
641
|
+
private async checkK6Installed(): Promise<boolean> {
|
|
642
|
+
return new Promise((resolve) => {
|
|
643
|
+
const proc = spawn("which", ["k6"]);
|
|
644
|
+
|
|
645
|
+
proc.on("close", (code) => {
|
|
646
|
+
resolve(code === 0);
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
proc.on("error", () => {
|
|
650
|
+
resolve(false);
|
|
651
|
+
});
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Spawn a child process and stream output
|
|
657
|
+
*/
|
|
658
|
+
private spawnCommand(
|
|
659
|
+
cmd: EngineCommand,
|
|
660
|
+
env: NodeJS.ProcessEnv,
|
|
661
|
+
): Promise<{ success: boolean; exitCode: number }> {
|
|
662
|
+
return this.spawnProcess(cmd.command, cmd.args, env);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
private async spawnProcess(
|
|
666
|
+
command: string,
|
|
667
|
+
args: string[],
|
|
668
|
+
env: NodeJS.ProcessEnv,
|
|
669
|
+
): Promise<{ success: boolean; exitCode: number }> {
|
|
670
|
+
return new Promise((resolve) => {
|
|
671
|
+
console.log(chalk.gray(`$ ${command} ${args.join(" ")}\n`));
|
|
672
|
+
|
|
673
|
+
const proc = spawn(command, args, {
|
|
674
|
+
// The distribution root: reports, results and staged tests land there,
|
|
675
|
+
// wherever the CLI was invoked from.
|
|
676
|
+
cwd: getDistRoot(),
|
|
677
|
+
env,
|
|
678
|
+
stdio: "inherit", // Stream output directly to console
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
proc.on("close", (code) => {
|
|
682
|
+
resolve({
|
|
683
|
+
success: code === 0,
|
|
684
|
+
exitCode: code || 0,
|
|
685
|
+
});
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
proc.on("error", (error) => {
|
|
689
|
+
console.error(chalk.red(`\n❌ Failed to execute: ${error.message}\n`));
|
|
690
|
+
resolve({
|
|
691
|
+
success: false,
|
|
692
|
+
exitCode: 1,
|
|
693
|
+
});
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Format duration in human-readable format
|
|
700
|
+
*/
|
|
701
|
+
private formatDuration(ms: number): string {
|
|
702
|
+
if (ms < 1000) {
|
|
703
|
+
return `${ms}ms`;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const seconds = Math.floor(ms / 1000);
|
|
707
|
+
const minutes = Math.floor(seconds / 60);
|
|
708
|
+
|
|
709
|
+
if (minutes > 0) {
|
|
710
|
+
const remainingSeconds = seconds % 60;
|
|
711
|
+
return `${minutes}m ${remainingSeconds}s`;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
return `${seconds}s`;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Check if server is up and accessible
|
|
719
|
+
*/
|
|
720
|
+
private async checkServerHealth(
|
|
721
|
+
environment: Environment,
|
|
722
|
+
pillar: Pillar,
|
|
723
|
+
): Promise<boolean> {
|
|
724
|
+
console.log(chalk.cyan("🏥 Checking server health...\n"));
|
|
725
|
+
|
|
726
|
+
const baseUrl = await this.getBaseUrl(environment);
|
|
727
|
+
const apiUrl = await this.getApiUrl(environment);
|
|
728
|
+
|
|
729
|
+
// For synthetic tests, check web app
|
|
730
|
+
if (pillar === "synthetic") {
|
|
731
|
+
const webHealthy = await this.pingServer(baseUrl, "Web App");
|
|
732
|
+
if (!webHealthy) {
|
|
733
|
+
console.log(
|
|
734
|
+
chalk.red(`\n❌ Web app is not accessible at: ${baseUrl}\n`),
|
|
735
|
+
);
|
|
736
|
+
const { shouldContinue } = await inquirer.prompt<{
|
|
737
|
+
shouldContinue: boolean;
|
|
738
|
+
}>([
|
|
739
|
+
{
|
|
740
|
+
type: "confirm",
|
|
741
|
+
name: "shouldContinue",
|
|
742
|
+
message: "The web app appears to be down. Continue anyway?",
|
|
743
|
+
default: false,
|
|
744
|
+
},
|
|
745
|
+
]);
|
|
746
|
+
|
|
747
|
+
if (!shouldContinue) {
|
|
748
|
+
console.log(chalk.yellow("\n💡 Make sure your app is running:"));
|
|
749
|
+
console.log(chalk.gray(" cd shop/apps/web && npm run dev"));
|
|
750
|
+
console.log(
|
|
751
|
+
chalk.gray(" or check docker-compose if using containers\n"),
|
|
752
|
+
);
|
|
753
|
+
return false;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// For integration tests, check API
|
|
759
|
+
if (pillar === "integration") {
|
|
760
|
+
const apiHealthy = await this.pingServer(`${apiUrl}/docs`, "API");
|
|
761
|
+
if (!apiHealthy) {
|
|
762
|
+
console.log(
|
|
763
|
+
chalk.red(`\n❌ API is not accessible at: ${apiUrl}/docs\n`),
|
|
764
|
+
);
|
|
765
|
+
const { shouldContinue } = await inquirer.prompt<{
|
|
766
|
+
shouldContinue: boolean;
|
|
767
|
+
}>([
|
|
768
|
+
{
|
|
769
|
+
type: "confirm",
|
|
770
|
+
name: "shouldContinue",
|
|
771
|
+
message: "The API appears to be down. Continue anyway?",
|
|
772
|
+
default: false,
|
|
773
|
+
},
|
|
774
|
+
]);
|
|
775
|
+
|
|
776
|
+
if (!shouldContinue) {
|
|
777
|
+
console.log(chalk.yellow("\n💡 Make sure your API is running:"));
|
|
778
|
+
console.log(chalk.gray(" cd shop/apps/api && npm run dev"));
|
|
779
|
+
console.log(
|
|
780
|
+
chalk.gray(" or check docker-compose if using containers\n"),
|
|
781
|
+
);
|
|
782
|
+
return false;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
console.log(chalk.green("✅ Server health check passed\n"));
|
|
788
|
+
return true;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Ping a server to check if it's responding
|
|
793
|
+
*/
|
|
794
|
+
private async pingServer(url: string, serverName: string): Promise<boolean> {
|
|
795
|
+
try {
|
|
796
|
+
console.log(chalk.gray(` Checking ${serverName}: ${url}`));
|
|
797
|
+
|
|
798
|
+
const controller = new AbortController();
|
|
799
|
+
const timeout = setTimeout(() => controller.abort(), 5000); // 5 second timeout
|
|
800
|
+
|
|
801
|
+
let response: Response;
|
|
802
|
+
try {
|
|
803
|
+
response = await fetch(url, {
|
|
804
|
+
method: "GET",
|
|
805
|
+
signal: controller.signal,
|
|
806
|
+
redirect: "manual", // Don't follow redirects (auth redirect is OK)
|
|
807
|
+
});
|
|
808
|
+
} finally {
|
|
809
|
+
clearTimeout(timeout);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// Accept 2xx (success) or 3xx (redirect - means server is up)
|
|
813
|
+
// For web app, also accept 401/403 (means auth is working)
|
|
814
|
+
const isHealthy =
|
|
815
|
+
(response.status >= 200 && response.status < 400) ||
|
|
816
|
+
response.status === 401 ||
|
|
817
|
+
response.status === 403;
|
|
818
|
+
|
|
819
|
+
if (isHealthy) {
|
|
820
|
+
console.log(
|
|
821
|
+
chalk.green(
|
|
822
|
+
` ✓ ${serverName} is responding (status: ${response.status})`,
|
|
823
|
+
),
|
|
824
|
+
);
|
|
825
|
+
return true;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// 4xx/5xx errors (except 401/403) indicate problems
|
|
829
|
+
console.log(
|
|
830
|
+
chalk.red(
|
|
831
|
+
` ✗ ${serverName} returned error status: ${response.status}`,
|
|
832
|
+
),
|
|
833
|
+
);
|
|
834
|
+
return false;
|
|
835
|
+
} catch (error: unknown) {
|
|
836
|
+
// Check specific error types
|
|
837
|
+
const err = error as { name?: string; code?: string; message?: string };
|
|
838
|
+
if (err.name === "AbortError") {
|
|
839
|
+
console.log(chalk.red(` ✗ ${serverName} timed out (5s)`));
|
|
840
|
+
} else if (err.code === "ECONNREFUSED") {
|
|
841
|
+
console.log(chalk.red(` ✗ ${serverName} connection refused`));
|
|
842
|
+
} else if (err.code === "ENOTFOUND") {
|
|
843
|
+
console.log(chalk.red(` ✗ ${serverName} hostname not found`));
|
|
844
|
+
} else {
|
|
845
|
+
console.log(
|
|
846
|
+
chalk.red(` ✗ ${serverName} not accessible: ${err.message}`),
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
return false;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Check and refresh authentication tokens
|
|
855
|
+
*/
|
|
856
|
+
private async checkAndRefreshAuth(
|
|
857
|
+
environment: Environment,
|
|
858
|
+
): Promise<boolean> {
|
|
859
|
+
const authDir = path.resolve(getDistRoot(), "playwright/.auth");
|
|
860
|
+
const adminAuthFile = path.join(authDir, "admin.json");
|
|
861
|
+
const userAuthFile = path.join(authDir, "user.json");
|
|
862
|
+
|
|
863
|
+
// Get base URL from environment config
|
|
864
|
+
const baseUrl = await this.getBaseUrl(environment);
|
|
865
|
+
|
|
866
|
+
console.log(chalk.cyan("🔍 Checking authentication sessions...\n"));
|
|
867
|
+
|
|
868
|
+
let hasValidAuth = false;
|
|
869
|
+
|
|
870
|
+
// Check admin session
|
|
871
|
+
if (fs.existsSync(adminAuthFile)) {
|
|
872
|
+
console.log(chalk.gray("🔐 Checking admin session..."));
|
|
873
|
+
const adminValid = await refreshAuthIfNeeded(adminAuthFile, baseUrl);
|
|
874
|
+
if (adminValid) {
|
|
875
|
+
console.log(chalk.green(" ✓ Admin session valid"));
|
|
876
|
+
hasValidAuth = true;
|
|
877
|
+
} else {
|
|
878
|
+
console.log(chalk.yellow(" ⚠️ Admin session expired"));
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// Check user session
|
|
883
|
+
if (fs.existsSync(userAuthFile)) {
|
|
884
|
+
console.log(chalk.gray("👤 Checking user session..."));
|
|
885
|
+
const userValid = await refreshAuthIfNeeded(userAuthFile, baseUrl);
|
|
886
|
+
if (userValid) {
|
|
887
|
+
console.log(chalk.green(" ✓ User session valid"));
|
|
888
|
+
hasValidAuth = true;
|
|
889
|
+
} else {
|
|
890
|
+
console.log(chalk.yellow(" ⚠️ User session expired"));
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// If no auth files exist at all
|
|
895
|
+
if (!fs.existsSync(adminAuthFile) && !fs.existsSync(userAuthFile)) {
|
|
896
|
+
console.log(chalk.yellow("⚠️ No authentication sessions found\n"));
|
|
897
|
+
const { shouldSetup } = await inquirer.prompt<{ shouldSetup: boolean }>([
|
|
898
|
+
{
|
|
899
|
+
type: "confirm",
|
|
900
|
+
name: "shouldSetup",
|
|
901
|
+
message: "Would you like to set up authentication now?",
|
|
902
|
+
default: true,
|
|
903
|
+
},
|
|
904
|
+
]);
|
|
905
|
+
|
|
906
|
+
if (shouldSetup) {
|
|
907
|
+
console.log(
|
|
908
|
+
chalk.cyan(
|
|
909
|
+
"\n📝 Sessions are created automatically — ensure 1Password is signed in:\n",
|
|
910
|
+
),
|
|
911
|
+
);
|
|
912
|
+
console.log(chalk.cyan(" eval $(op signin) - Sign in to 1Password"));
|
|
913
|
+
console.log(
|
|
914
|
+
chalk.cyan(" make auth-configure - One-time admin setup\n"),
|
|
915
|
+
);
|
|
916
|
+
return false;
|
|
917
|
+
} else {
|
|
918
|
+
console.log(chalk.red("Cannot run tests without authentication\n"));
|
|
919
|
+
return false;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// If auth exists but is invalid
|
|
924
|
+
if (!hasValidAuth) {
|
|
925
|
+
console.log(
|
|
926
|
+
chalk.yellow(
|
|
927
|
+
"\n⚠️ Authentication failed — sessions are created fresh each run\n",
|
|
928
|
+
),
|
|
929
|
+
);
|
|
930
|
+
console.log(chalk.cyan(" eval $(op signin) - Sign in to 1Password"));
|
|
931
|
+
console.log(
|
|
932
|
+
chalk.cyan(" make auth-configure - One-time admin setup\n"),
|
|
933
|
+
);
|
|
934
|
+
return false;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
console.log(chalk.green("\n✅ Authentication verified\n"));
|
|
938
|
+
return true;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Get base URL for environment
|
|
943
|
+
*/
|
|
944
|
+
private async getBaseUrl(environment: Environment): Promise<string> {
|
|
945
|
+
try {
|
|
946
|
+
const configPath = path.resolve(
|
|
947
|
+
getDistRoot(),
|
|
948
|
+
`config/environments/${environment}.json`,
|
|
949
|
+
);
|
|
950
|
+
const configFile = fs.readFileSync(configPath, "utf-8");
|
|
951
|
+
const config = JSON.parse(configFile);
|
|
952
|
+
return config.baseUrls.web;
|
|
953
|
+
} catch {
|
|
954
|
+
return "http://localhost:3000";
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* Build the sequence of shell commands for a test run without executing them.
|
|
960
|
+
* Synthetic tests produce two steps (stage + run); all other pillars produce one.
|
|
961
|
+
* Used by the web server to stream output via SSE without interactive prompts.
|
|
962
|
+
*/
|
|
963
|
+
buildCommand(options: TestRunOptions): CommandStep[] {
|
|
964
|
+
const explicitBaseUrlForCmd = options.baseApiUrl?.trim() || undefined;
|
|
965
|
+
const resolvedApiBaseUrl =
|
|
966
|
+
explicitBaseUrlForCmd || this.getApiUrlSync(options.environment);
|
|
967
|
+
const inheritedRunnerEnv = this.getBaseRunnerEnv();
|
|
968
|
+
const baseEnv: NodeJS.ProcessEnv = {
|
|
969
|
+
...inheritedRunnerEnv,
|
|
970
|
+
E2E_ENVIRONMENT: options.environment,
|
|
971
|
+
...(options.useLocalCheckout ? { USE_LOCAL_CHECKOUT: "1" } : {}),
|
|
972
|
+
...(options.baseApiUrl?.trim()
|
|
973
|
+
? { BASE_API_URL: options.baseApiUrl.trim() }
|
|
974
|
+
: {}),
|
|
975
|
+
// Prevent Playwright from auto-opening the HTML report in a browser window
|
|
976
|
+
PLAYWRIGHT_HTML_OPEN: "never",
|
|
977
|
+
};
|
|
978
|
+
|
|
979
|
+
const builder = this.pillarBuilders[options.pillar];
|
|
980
|
+
if (!builder) return [];
|
|
981
|
+
return builder(options, baseEnv, explicitBaseUrlForCmd, resolvedApiBaseUrl);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// ── Pillar command builders ────────────────────────────────────────────────
|
|
985
|
+
// Registry of per-pillar buildCommand implementations. Adding a new pillar
|
|
986
|
+
// means adding one entry here (and its run method in executeTestCommand).
|
|
987
|
+
|
|
988
|
+
private pillarBuilders: Record<
|
|
989
|
+
Pillar,
|
|
990
|
+
(
|
|
991
|
+
options: TestRunOptions,
|
|
992
|
+
baseEnv: NodeJS.ProcessEnv,
|
|
993
|
+
explicitBaseUrl: string | undefined,
|
|
994
|
+
resolvedApiBaseUrl: string,
|
|
995
|
+
) => CommandStep[]
|
|
996
|
+
> = {
|
|
997
|
+
synthetic: (options, baseEnv) => {
|
|
998
|
+
const env = { ...baseEnv };
|
|
999
|
+
const run = this.buildSyntheticPlaywrightCommand(options);
|
|
1000
|
+
return [
|
|
1001
|
+
{
|
|
1002
|
+
...tsxCommand("src/pillars/browser/setup.ts"),
|
|
1003
|
+
env,
|
|
1004
|
+
label: "Stage tests",
|
|
1005
|
+
},
|
|
1006
|
+
{ ...run, env, label: "Run Playwright" },
|
|
1007
|
+
];
|
|
1008
|
+
},
|
|
1009
|
+
|
|
1010
|
+
integration: (options, baseEnv) => {
|
|
1011
|
+
const args = [
|
|
1012
|
+
"test",
|
|
1013
|
+
"--config",
|
|
1014
|
+
enginePath("src/pillars/api/playwright.config.ts"),
|
|
1015
|
+
];
|
|
1016
|
+
for (const mod of options.modules ?? [])
|
|
1017
|
+
args.push(enginePath(`src/pillars/api/tests/${mod}`) + "/");
|
|
1018
|
+
if (options.headed) args.push("--headed");
|
|
1019
|
+
if (options.debug) args.push("--debug");
|
|
1020
|
+
if (options.grep) args.push("--grep", options.grep);
|
|
1021
|
+
return [
|
|
1022
|
+
{ ...playwrightCommand(...args), env: baseEnv, label: "Run API tests" },
|
|
1023
|
+
];
|
|
1024
|
+
},
|
|
1025
|
+
|
|
1026
|
+
smoke: (options, baseEnv) => {
|
|
1027
|
+
const mods = options.modules ?? [];
|
|
1028
|
+
const isLocal = options.environment === "local";
|
|
1029
|
+
const useLocalCheckout = options.useLocalCheckout === true;
|
|
1030
|
+
|
|
1031
|
+
// For non-local environments, prepend a step that clones each repo at
|
|
1032
|
+
// the deployed SHA before staging. Local uses the symlinked sibling.
|
|
1033
|
+
const envSetupStep =
|
|
1034
|
+
!isLocal && !useLocalCheckout
|
|
1035
|
+
? [
|
|
1036
|
+
{
|
|
1037
|
+
...tsxCommand(
|
|
1038
|
+
"src/pillars/smoke/setup-env.ts",
|
|
1039
|
+
"--env",
|
|
1040
|
+
options.environment,
|
|
1041
|
+
// Pass --repo for each selected module so we only clone
|
|
1042
|
+
// what we need; omit when running all (setup-env handles all)
|
|
1043
|
+
...mods.flatMap((m) => ["--repo", m.split("/")[0]]),
|
|
1044
|
+
),
|
|
1045
|
+
env: baseEnv,
|
|
1046
|
+
label: `Resolve deployed branch (${options.environment})`,
|
|
1047
|
+
},
|
|
1048
|
+
]
|
|
1049
|
+
: [];
|
|
1050
|
+
|
|
1051
|
+
if (mods.length === 0) {
|
|
1052
|
+
// No module selected — run all services across all registered repos
|
|
1053
|
+
return [
|
|
1054
|
+
...envSetupStep,
|
|
1055
|
+
{
|
|
1056
|
+
...tsxCommand("src/pillars/smoke/setup-global.ts"),
|
|
1057
|
+
env: baseEnv,
|
|
1058
|
+
label: "Stage all smoke tests",
|
|
1059
|
+
},
|
|
1060
|
+
{
|
|
1061
|
+
...playwrightCommand(
|
|
1062
|
+
"test",
|
|
1063
|
+
`--config=${enginePath("src/pillars/smoke/playwright-global.config.ts")}`,
|
|
1064
|
+
),
|
|
1065
|
+
env: baseEnv,
|
|
1066
|
+
label: "Run all smoke tests",
|
|
1067
|
+
},
|
|
1068
|
+
smokePostRunTeardownVerifyStep(baseEnv),
|
|
1069
|
+
];
|
|
1070
|
+
}
|
|
1071
|
+
// Stage each selected service, then ONE combined playwright run → one report.
|
|
1072
|
+
// All modules are assumed to be from the same repo (first module wins).
|
|
1073
|
+
const [repoName, firstService] = mods[0].split("/");
|
|
1074
|
+
const services = mods.map((m) => m.split("/")[1]);
|
|
1075
|
+
const smokeEnv = {
|
|
1076
|
+
...baseEnv,
|
|
1077
|
+
SMOKE_REPO: repoName,
|
|
1078
|
+
SMOKE_SERVICES: services.join(","),
|
|
1079
|
+
};
|
|
1080
|
+
|
|
1081
|
+
const setupSteps = services.map((svc) => ({
|
|
1082
|
+
...tsxCommand("src/pillars/smoke/setup.ts"),
|
|
1083
|
+
env: { ...smokeEnv, SMOKE_SERVICE: svc },
|
|
1084
|
+
label: `Stage ${svc} smoke tests`,
|
|
1085
|
+
}));
|
|
1086
|
+
|
|
1087
|
+
if (options.smokeCollection && mods.length === 1) {
|
|
1088
|
+
// Single service + specific suite
|
|
1089
|
+
const suiteDir = `.smoke-run/${repoName}-${firstService}/${options.smokeCollection}`;
|
|
1090
|
+
const runEnv = { ...smokeEnv, SMOKE_SERVICE: firstService };
|
|
1091
|
+
return [
|
|
1092
|
+
...envSetupStep,
|
|
1093
|
+
setupSteps[0],
|
|
1094
|
+
{
|
|
1095
|
+
...playwrightCommand(
|
|
1096
|
+
"test",
|
|
1097
|
+
`--config=${enginePath("src/pillars/smoke/playwright.config.ts")}`,
|
|
1098
|
+
suiteDir,
|
|
1099
|
+
),
|
|
1100
|
+
env: runEnv,
|
|
1101
|
+
label: `Run ${firstService}/${options.smokeCollection}`,
|
|
1102
|
+
},
|
|
1103
|
+
smokePostRunTeardownVerifyStep(runEnv),
|
|
1104
|
+
];
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// All suites for selected services — one combined report
|
|
1108
|
+
return [
|
|
1109
|
+
...envSetupStep,
|
|
1110
|
+
...setupSteps,
|
|
1111
|
+
{
|
|
1112
|
+
...playwrightCommand(
|
|
1113
|
+
"test",
|
|
1114
|
+
`--config=${enginePath("src/pillars/smoke/playwright-all.config.ts")}`,
|
|
1115
|
+
),
|
|
1116
|
+
env: smokeEnv,
|
|
1117
|
+
label: `Run smoke tests (${services.join(", ")})`,
|
|
1118
|
+
},
|
|
1119
|
+
smokePostRunTeardownVerifyStep(smokeEnv),
|
|
1120
|
+
];
|
|
1121
|
+
},
|
|
1122
|
+
|
|
1123
|
+
performance: (
|
|
1124
|
+
options,
|
|
1125
|
+
baseEnv,
|
|
1126
|
+
explicitBaseUrlForCmd,
|
|
1127
|
+
resolvedApiBaseUrl,
|
|
1128
|
+
) => {
|
|
1129
|
+
const mods = options.modules ?? [];
|
|
1130
|
+
if (mods.length === 0) return [];
|
|
1131
|
+
const { internalModules, repoModules } =
|
|
1132
|
+
this.partitionPerformanceModules(mods);
|
|
1133
|
+
const steps: CommandStep[] = [];
|
|
1134
|
+
|
|
1135
|
+
for (const [repoName, moduleNames] of repoModules.entries()) {
|
|
1136
|
+
const isLocal = options.environment === "local";
|
|
1137
|
+
if (!isLocal && !options.useLocalCheckout) {
|
|
1138
|
+
steps.push({
|
|
1139
|
+
...tsxCommand(
|
|
1140
|
+
"src/pillars/smoke/setup-env.ts",
|
|
1141
|
+
"--env",
|
|
1142
|
+
options.environment,
|
|
1143
|
+
"--repo",
|
|
1144
|
+
repoName,
|
|
1145
|
+
),
|
|
1146
|
+
env: baseEnv,
|
|
1147
|
+
label: `Resolve deployed branch (${options.environment})`,
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
steps.push({
|
|
1152
|
+
...tsxCommand(
|
|
1153
|
+
"src/pillars/perf/run.ts",
|
|
1154
|
+
"--env",
|
|
1155
|
+
options.environment,
|
|
1156
|
+
...(explicitBaseUrlForCmd
|
|
1157
|
+
? ["--base-url", explicitBaseUrlForCmd]
|
|
1158
|
+
: []),
|
|
1159
|
+
...(options.useLocalCheckout ? ["--use-local-checkout"] : []),
|
|
1160
|
+
...moduleNames.flatMap((moduleName) => [
|
|
1161
|
+
"--repo",
|
|
1162
|
+
repoName,
|
|
1163
|
+
"--module",
|
|
1164
|
+
moduleName,
|
|
1165
|
+
]),
|
|
1166
|
+
),
|
|
1167
|
+
env: {
|
|
1168
|
+
...baseEnv,
|
|
1169
|
+
...(explicitBaseUrlForCmd
|
|
1170
|
+
? {
|
|
1171
|
+
BASE_URL: explicitBaseUrlForCmd,
|
|
1172
|
+
BASE_API_URL: explicitBaseUrlForCmd,
|
|
1173
|
+
}
|
|
1174
|
+
: {}),
|
|
1175
|
+
},
|
|
1176
|
+
label: `Run k6 ${repoName} (${moduleNames.join(", ")})`,
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
if (internalModules.length > 0) {
|
|
1181
|
+
fs.mkdirSync(path.resolve(getDistRoot(), PERF_CURRENT_REPORT_DIR), {
|
|
1182
|
+
recursive: true,
|
|
1183
|
+
});
|
|
1184
|
+
// Clear the prior run's report up front so a stale one is never served
|
|
1185
|
+
// if this run is read before — or never — regenerates report.json.
|
|
1186
|
+
resetCurrentPerfRunReport();
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
for (const mod of internalModules) {
|
|
1190
|
+
// Per-test optional-field env, resolved upstream (server → resolveFieldEnv)
|
|
1191
|
+
// and routed ONLY to this module's k6 process — never globally.
|
|
1192
|
+
const fieldEnv = options.moduleEnv?.[mod] ?? {};
|
|
1193
|
+
const fieldKeys = Object.keys(fieldEnv);
|
|
1194
|
+
steps.push({
|
|
1195
|
+
command: "k6",
|
|
1196
|
+
args: [
|
|
1197
|
+
"run",
|
|
1198
|
+
"--summary-export",
|
|
1199
|
+
// Absolute: k6 resolves relative paths against its inherited cwd.
|
|
1200
|
+
path.resolve(getDistRoot(), getPerfSummaryArtifactPath(mod)),
|
|
1201
|
+
getPerfTestFile(mod),
|
|
1202
|
+
],
|
|
1203
|
+
env: {
|
|
1204
|
+
...baseEnv,
|
|
1205
|
+
BASE_URL: resolvedApiBaseUrl,
|
|
1206
|
+
BASE_API_URL: options.baseApiUrl?.trim() || resolvedApiBaseUrl,
|
|
1207
|
+
...fieldEnv,
|
|
1208
|
+
},
|
|
1209
|
+
label: `Run k6 ${mod}${
|
|
1210
|
+
fieldKeys.length > 0 ? ` (${fieldKeys.join(", ")})` : ""
|
|
1211
|
+
}`,
|
|
1212
|
+
// A perf run that breaches an SLO threshold exits k6 non-zero — that
|
|
1213
|
+
// is EXPECTED for a stress test, and the run must still proceed to
|
|
1214
|
+
// the "Build perf report" step. Otherwise the run aborts before the
|
|
1215
|
+
// report is regenerated and the web UI serves the previous run's
|
|
1216
|
+
// stale report (frozen at the last all-passing run). Always continue;
|
|
1217
|
+
// the overall run exit code still reflects the breach.
|
|
1218
|
+
continueOnFailure: true,
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
if (internalModules.length > 0) {
|
|
1223
|
+
steps.push({
|
|
1224
|
+
...tsxCommand("src/pillars/perf/build-report.ts"),
|
|
1225
|
+
env: {
|
|
1226
|
+
...baseEnv,
|
|
1227
|
+
PERF_ENVIRONMENT: options.environment,
|
|
1228
|
+
PERF_MODULES: internalModules.join(","),
|
|
1229
|
+
},
|
|
1230
|
+
label: "Build perf report",
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
return steps;
|
|
1235
|
+
},
|
|
1236
|
+
|
|
1237
|
+
journeys: (options, baseEnv) => this.buildJourneysSteps(options, baseEnv),
|
|
1238
|
+
|
|
1239
|
+
security: (options, baseEnv) => this.buildSecuritySteps(options, baseEnv),
|
|
1240
|
+
|
|
1241
|
+
suites: (options, baseEnv) => [
|
|
1242
|
+
{
|
|
1243
|
+
...tsxCommand(
|
|
1244
|
+
"src/pillars/suites/run.ts",
|
|
1245
|
+
"--env",
|
|
1246
|
+
options.environment,
|
|
1247
|
+
...(options.modules ?? []).flatMap((m) => ["--suite", m]),
|
|
1248
|
+
),
|
|
1249
|
+
env: baseEnv,
|
|
1250
|
+
label: "Run repo suites",
|
|
1251
|
+
},
|
|
1252
|
+
],
|
|
1253
|
+
};
|
|
1254
|
+
|
|
1255
|
+
/**
|
|
1256
|
+
* Spawn a process and stream stdout/stderr line-by-line via callbacks.
|
|
1257
|
+
* ANSI colour codes are stripped so browser output renders cleanly.
|
|
1258
|
+
* Used by the web server for SSE streaming.
|
|
1259
|
+
*/
|
|
1260
|
+
spawnStreaming(
|
|
1261
|
+
command: string,
|
|
1262
|
+
args: string[],
|
|
1263
|
+
env: NodeJS.ProcessEnv,
|
|
1264
|
+
onLine: (line: string) => void,
|
|
1265
|
+
onClose: (exitCode: number) => void,
|
|
1266
|
+
cwd?: string,
|
|
1267
|
+
detached?: boolean,
|
|
1268
|
+
): ReturnType<typeof spawn> {
|
|
1269
|
+
return sharedSpawnStreaming(
|
|
1270
|
+
command,
|
|
1271
|
+
args,
|
|
1272
|
+
env,
|
|
1273
|
+
onLine,
|
|
1274
|
+
onClose,
|
|
1275
|
+
cwd,
|
|
1276
|
+
detached,
|
|
1277
|
+
);
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/**
|
|
1281
|
+
* Get API URL for environment
|
|
1282
|
+
*/
|
|
1283
|
+
private async getApiUrl(environment: Environment): Promise<string> {
|
|
1284
|
+
try {
|
|
1285
|
+
const configPath = path.resolve(
|
|
1286
|
+
getDistRoot(),
|
|
1287
|
+
`config/environments/${environment}.json`,
|
|
1288
|
+
);
|
|
1289
|
+
const configFile = fs.readFileSync(configPath, "utf-8");
|
|
1290
|
+
const config = JSON.parse(configFile);
|
|
1291
|
+
return config.baseUrls.api;
|
|
1292
|
+
} catch {
|
|
1293
|
+
return "http://localhost:4000";
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
private getApiUrlSync(environment: Environment): string {
|
|
1298
|
+
try {
|
|
1299
|
+
const configPath = path.resolve(
|
|
1300
|
+
getDistRoot(),
|
|
1301
|
+
`config/environments/${environment}.json`,
|
|
1302
|
+
);
|
|
1303
|
+
const configFile = fs.readFileSync(configPath, "utf-8");
|
|
1304
|
+
const config = JSON.parse(configFile);
|
|
1305
|
+
return config.baseUrls.api;
|
|
1306
|
+
} catch {
|
|
1307
|
+
return "http://localhost:4000";
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
export const testRunner = new TestRunner();
|