@monster-orange/den 0.0.0-stage → 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.
Files changed (231) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/LICENSE +21 -0
  3. package/README.md +166 -2
  4. package/bin/den +61 -0
  5. package/docs/distributions.md +256 -0
  6. package/make/demo.mk +82 -0
  7. package/make/den.mk +80 -0
  8. package/make/journeys.mk +57 -0
  9. package/make/local_dev.mk +154 -0
  10. package/make/security.mk +59 -0
  11. package/make/test.mk +405 -0
  12. package/package.json +104 -4
  13. package/scripts/auth-configure.ts +415 -0
  14. package/scripts/heal-config.ts +92 -0
  15. package/scripts/jsonl-playwright-reporter.js +70 -0
  16. package/scripts/manifest-read.cjs +91 -0
  17. package/scripts/setup-1password.ts +570 -0
  18. package/scripts/setup-secrets.ts +149 -0
  19. package/scripts/view-results.js +156 -0
  20. package/service/install.sh +112 -0
  21. package/src/pillars/api/fixtures/index.ts +128 -0
  22. package/src/pillars/api/fixtures/openapi-validator.ts +111 -0
  23. package/src/pillars/api/global-setup.ts +103 -0
  24. package/src/pillars/api/playwright.config.ts +105 -0
  25. package/src/pillars/api/schemas/example-api.json +110 -0
  26. package/src/pillars/api/tests/health/health.spec.ts +19 -0
  27. package/src/pillars/api/tests/health/manifest.yml +6 -0
  28. package/src/pillars/browser/fixtures/index.ts +232 -0
  29. package/src/pillars/browser/fixtures/package.json +10 -0
  30. package/src/pillars/browser/global-setup.ts +140 -0
  31. package/src/pillars/browser/global-teardown.ts +48 -0
  32. package/src/pillars/browser/playwright.config.ts +212 -0
  33. package/src/pillars/browser/setup.ts +219 -0
  34. package/src/pillars/journeys/app-config.ts +237 -0
  35. package/src/pillars/journeys/discover.ts +160 -0
  36. package/src/pillars/journeys/hooks/deny-shell.cjs +44 -0
  37. package/src/pillars/journeys/observability/events.ts +288 -0
  38. package/src/pillars/journeys/observability/sdk-mapper.ts +306 -0
  39. package/src/pillars/journeys/report/friction.ts +304 -0
  40. package/src/pillars/journeys/report/junit.ts +105 -0
  41. package/src/pillars/journeys/report/render.ts +273 -0
  42. package/src/pillars/journeys/report/results.ts +323 -0
  43. package/src/pillars/journeys/run.ts +673 -0
  44. package/src/pillars/journeys/runner.ts +1025 -0
  45. package/src/pillars/journeys/sandbox.ts +406 -0
  46. package/src/pillars/journeys/spec/loader.ts +152 -0
  47. package/src/pillars/journeys/spec/schema.ts +208 -0
  48. package/src/pillars/journeys/teardown.ts +98 -0
  49. package/src/pillars/journeys/tools/browser-session.ts +219 -0
  50. package/src/pillars/journeys/tools/browser-tool.ts +914 -0
  51. package/src/pillars/journeys/tools/http-tool.ts +564 -0
  52. package/src/pillars/journeys/tools/ledger.ts +278 -0
  53. package/src/pillars/journeys/tools/snapshot-store.ts +117 -0
  54. package/src/pillars/journeys/verify/assertions.ts +502 -0
  55. package/src/pillars/journeys/verify/verdict.ts +71 -0
  56. package/src/pillars/perf/build-report.ts +36 -0
  57. package/src/pillars/perf/k6.config.js +112 -0
  58. package/src/pillars/perf/run.ts +345 -0
  59. package/src/pillars/perf/scenarios/load-profile.js +97 -0
  60. package/src/pillars/perf/staging.ts +73 -0
  61. package/src/pillars/security/discover.ts +108 -0
  62. package/src/pillars/security/findings.ts +151 -0
  63. package/src/pillars/security/plan.ts +355 -0
  64. package/src/pillars/security/run.ts +729 -0
  65. package/src/pillars/smoke/admin-probe.ts +68 -0
  66. package/src/pillars/smoke/base-url.ts +56 -0
  67. package/src/pillars/smoke/cleanup.ts +8 -0
  68. package/src/pillars/smoke/discover.ts +237 -0
  69. package/src/pillars/smoke/ensure-smoke-teardown.ts +40 -0
  70. package/src/pillars/smoke/fixture-manager.ts +217 -0
  71. package/src/pillars/smoke/fixtures.ts +468 -0
  72. package/src/pillars/smoke/global-setup-all.ts +262 -0
  73. package/src/pillars/smoke/global-setup-global.ts +262 -0
  74. package/src/pillars/smoke/global-setup.ts +273 -0
  75. package/src/pillars/smoke/global-teardown-all.ts +8 -0
  76. package/src/pillars/smoke/global-teardown-global.ts +8 -0
  77. package/src/pillars/smoke/global-teardown.ts +139 -0
  78. package/src/pillars/smoke/playwright-all.config.ts +147 -0
  79. package/src/pillars/smoke/playwright-global.config.ts +141 -0
  80. package/src/pillars/smoke/playwright.config.ts +162 -0
  81. package/src/pillars/smoke/setup-env.ts +132 -0
  82. package/src/pillars/smoke/setup-global.ts +126 -0
  83. package/src/pillars/smoke/setup.ts +115 -0
  84. package/src/pillars/smoke/smoke-repo.ts +16 -0
  85. package/src/pillars/smoke/staging.ts +49 -0
  86. package/src/pillars/suites/run.ts +147 -0
  87. package/src/scripts/configure-aws-profiles.ts +114 -0
  88. package/src/scripts/register-repos.ts +307 -0
  89. package/src/scripts/setup-default-workspace.ts +203 -0
  90. package/src/shared/applications.ts +138 -0
  91. package/src/shared/auth/api-token.ts +416 -0
  92. package/src/shared/auth/auth0-helper.ts +284 -0
  93. package/src/shared/auth/headless-login.ts +265 -0
  94. package/src/shared/auth/jwt-helper.ts +186 -0
  95. package/src/shared/auth/sudo-vault.ts +69 -0
  96. package/src/shared/auth/test-accounts.ts +98 -0
  97. package/src/shared/auth/token-injection.ts +184 -0
  98. package/src/shared/auth/token-providers/auth0.ts +80 -0
  99. package/src/shared/auth/token-providers/index.ts +182 -0
  100. package/src/shared/auth/token-refresh.ts +273 -0
  101. package/src/shared/banner.ts +15 -0
  102. package/src/shared/brand.ts +79 -0
  103. package/src/shared/cli-tabs.ts +131 -0
  104. package/src/shared/constants.ts +128 -0
  105. package/src/shared/den-config.ts +566 -0
  106. package/src/shared/dev-metrics/emit.ts +354 -0
  107. package/src/shared/edge/config.ts +229 -0
  108. package/src/shared/edge/render.ts +221 -0
  109. package/src/shared/edge/runtime.ts +268 -0
  110. package/src/shared/edge/types.ts +62 -0
  111. package/src/shared/engine.ts +67 -0
  112. package/src/shared/environment/aws-profiles.ts +49 -0
  113. package/src/shared/environment/env-selector.ts +161 -0
  114. package/src/shared/environment/playwright-env.ts +82 -0
  115. package/src/shared/environment-manager/catalog.ts +77 -0
  116. package/src/shared/environment-manager/clone-guard.ts +163 -0
  117. package/src/shared/environment-manager/dag.ts +286 -0
  118. package/src/shared/environment-manager/default-workspace.ts +182 -0
  119. package/src/shared/environment-manager/derivation/alias.ts +90 -0
  120. package/src/shared/environment-manager/derivation/cache.ts +97 -0
  121. package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
  122. package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
  123. package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
  124. package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
  125. package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
  126. package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
  127. package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
  128. package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
  129. package/src/shared/environment-manager/derivation/scan.ts +160 -0
  130. package/src/shared/environment-manager/derivation/text.ts +40 -0
  131. package/src/shared/environment-manager/derivation/types.ts +69 -0
  132. package/src/shared/environment-manager/health.ts +589 -0
  133. package/src/shared/environment-manager/index.ts +6 -0
  134. package/src/shared/environment-manager/load-applications.ts +78 -0
  135. package/src/shared/environment-manager/log-tail.ts +212 -0
  136. package/src/shared/environment-manager/make-targets.ts +78 -0
  137. package/src/shared/environment-manager/opctl.ts +334 -0
  138. package/src/shared/environment-manager/readiness.ts +93 -0
  139. package/src/shared/environment-manager/register-local.ts +72 -0
  140. package/src/shared/environment-manager/secrets-check.ts +260 -0
  141. package/src/shared/environment-manager/service-env.ts +110 -0
  142. package/src/shared/environment-manager/service-runner.ts +1588 -0
  143. package/src/shared/environment-manager/up-plan.ts +302 -0
  144. package/src/shared/environment-manager/workspace-manager.ts +905 -0
  145. package/src/shared/environment-manager/workspaces.ts +860 -0
  146. package/src/shared/manifest/deps.ts +27 -0
  147. package/src/shared/manifest/migrate-manifest.ts +101 -0
  148. package/src/shared/manifest/read-manifest.ts +160 -0
  149. package/src/shared/manifest/schema.ts +299 -0
  150. package/src/shared/manifest/service-links.ts +93 -0
  151. package/src/shared/manifest/validate-manifest.ts +140 -0
  152. package/src/shared/manifest/workspace-manifests.ts +92 -0
  153. package/src/shared/open-url.ts +122 -0
  154. package/src/shared/org.ts +44 -0
  155. package/src/shared/paths.ts +178 -0
  156. package/src/shared/perf/optional-fields.ts +105 -0
  157. package/src/shared/perf/report-normalizer.ts +404 -0
  158. package/src/shared/perf/report-paths.ts +19 -0
  159. package/src/shared/perf/report-store.ts +110 -0
  160. package/src/shared/perf/report-types.ts +70 -0
  161. package/src/shared/perf/test-paths.ts +25 -0
  162. package/src/shared/process/log-rotation.ts +147 -0
  163. package/src/shared/process/spawn-streaming.ts +229 -0
  164. package/src/shared/product.ts +92 -0
  165. package/src/shared/profile.ts +141 -0
  166. package/src/shared/repo-kind.ts +32 -0
  167. package/src/shared/reporting/jsonl-reporter.ts +131 -0
  168. package/src/shared/reporting/slack-reporter.ts +340 -0
  169. package/src/shared/results/result-reader.ts +266 -0
  170. package/src/shared/results/result-writer.ts +168 -0
  171. package/src/shared/secrets/doppler.ts +126 -0
  172. package/src/shared/secrets/env.ts +48 -0
  173. package/src/shared/secrets/index.ts +197 -0
  174. package/src/shared/secrets/keys.ts +33 -0
  175. package/src/shared/secrets/onepassword.ts +75 -0
  176. package/src/shared/secrets/types.ts +52 -0
  177. package/src/shared/service-url.ts +119 -0
  178. package/src/shared/smoke/branch-resolver.ts +141 -0
  179. package/src/shared/smoke/git-remote.ts +70 -0
  180. package/src/shared/smoke/repo-cloner.ts +151 -0
  181. package/src/shared/stacks/config.ts +137 -0
  182. package/src/shared/stacks/manifest-commands.ts +88 -0
  183. package/src/shared/stacks/render.ts +253 -0
  184. package/src/shared/stacks/runtime.ts +334 -0
  185. package/src/shared/stacks/types.ts +53 -0
  186. package/src/shared/terminal/tabs.ts +212 -0
  187. package/src/shared/test-discovery/module-scanner.ts +569 -0
  188. package/src/shared/types/index.ts +678 -0
  189. package/src/shared/ui-marker.ts +19 -0
  190. package/src/ui/cli/applications-command.ts +116 -0
  191. package/src/ui/cli/config-command.ts +68 -0
  192. package/src/ui/cli/create-main.ts +9 -0
  193. package/src/ui/cli/create.ts +360 -0
  194. package/src/ui/cli/edge-command.ts +91 -0
  195. package/src/ui/cli/flag-parser.ts +246 -0
  196. package/src/ui/cli/graph-command.ts +524 -0
  197. package/src/ui/cli/health-display.ts +63 -0
  198. package/src/ui/cli/index.ts +3656 -0
  199. package/src/ui/cli/init.ts +855 -0
  200. package/src/ui/cli/invocation-target.ts +175 -0
  201. package/src/ui/cli/logs-command.ts +104 -0
  202. package/src/ui/cli/module-selection.ts +31 -0
  203. package/src/ui/cli/prompts.ts +767 -0
  204. package/src/ui/cli/results-viewer.ts +436 -0
  205. package/src/ui/cli/secrets-preflight.ts +91 -0
  206. package/src/ui/cli/stack-command.ts +144 -0
  207. package/src/ui/cli/stalled-setup-recovery.ts +129 -0
  208. package/src/ui/cli/test-runner.ts +1312 -0
  209. package/src/ui/cli/web-command.ts +1073 -0
  210. package/src/ui/server/changelog-parser.ts +94 -0
  211. package/src/ui/server/daemon-reaper.ts +129 -0
  212. package/src/ui/server/editor-manager.ts +301 -0
  213. package/src/ui/server/github-service.ts +223 -0
  214. package/src/ui/server/http-helpers.ts +98 -0
  215. package/src/ui/server/index.ts +3236 -0
  216. package/src/ui/server/log-history.ts +452 -0
  217. package/src/ui/server/perf-report.ts +42 -0
  218. package/src/ui/server/public/environments.html +494 -0
  219. package/src/ui/server/public/favicon.ico +0 -0
  220. package/src/ui/server/public/header.js +1371 -0
  221. package/src/ui/server/public/index.html +2595 -0
  222. package/src/ui/server/public/local-dev.html +7405 -0
  223. package/src/ui/server/public/logs.html +1489 -0
  224. package/src/ui/server/public/perf-report.js +359 -0
  225. package/src/ui/server/public/release.html +2111 -0
  226. package/src/ui/server/public/services.html +1098 -0
  227. package/src/ui/server/public/workspace-picker.js +69 -0
  228. package/src/ui/server/release-deploy-session.ts +246 -0
  229. package/src/ui/server/release-service.ts +1125 -0
  230. package/src/ui/server/sudo-store.ts +111 -0
  231. 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();