@monster-orange/den 0.0.0 → 0.2.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 (255) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/LICENSE +21 -0
  3. package/README.md +165 -1
  4. package/bin/den +68 -0
  5. package/docs/distributions.md +499 -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 +101 -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 +403 -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 +124 -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 +757 -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/agent-actor.ts +77 -0
  91. package/src/shared/applications.ts +138 -0
  92. package/src/shared/auth/api-token.ts +416 -0
  93. package/src/shared/auth/auth0-helper.ts +284 -0
  94. package/src/shared/auth/headless-login.ts +265 -0
  95. package/src/shared/auth/jwt-helper.ts +186 -0
  96. package/src/shared/auth/sudo-vault.ts +69 -0
  97. package/src/shared/auth/test-accounts.ts +98 -0
  98. package/src/shared/auth/token-injection.ts +184 -0
  99. package/src/shared/auth/token-providers/auth0.ts +80 -0
  100. package/src/shared/auth/token-providers/index.ts +182 -0
  101. package/src/shared/auth/token-refresh.ts +273 -0
  102. package/src/shared/banner.ts +15 -0
  103. package/src/shared/brand.ts +79 -0
  104. package/src/shared/cli-tabs.ts +131 -0
  105. package/src/shared/constants.ts +128 -0
  106. package/src/shared/den-config.ts +688 -0
  107. package/src/shared/dev-metrics/emit.ts +354 -0
  108. package/src/shared/edge/config.ts +229 -0
  109. package/src/shared/edge/render.ts +221 -0
  110. package/src/shared/edge/runtime.ts +268 -0
  111. package/src/shared/edge/status.ts +74 -0
  112. package/src/shared/edge/types.ts +62 -0
  113. package/src/shared/engine.ts +67 -0
  114. package/src/shared/environment/aws-profiles.ts +49 -0
  115. package/src/shared/environment/env-selector.ts +161 -0
  116. package/src/shared/environment/playwright-env.ts +82 -0
  117. package/src/shared/environment-manager/catalog.ts +77 -0
  118. package/src/shared/environment-manager/clone-guard.ts +163 -0
  119. package/src/shared/environment-manager/dag.ts +286 -0
  120. package/src/shared/environment-manager/default-workspace.ts +182 -0
  121. package/src/shared/environment-manager/derivation/alias.ts +90 -0
  122. package/src/shared/environment-manager/derivation/cache.ts +97 -0
  123. package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
  124. package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
  125. package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
  126. package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
  127. package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
  128. package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
  129. package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
  130. package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
  131. package/src/shared/environment-manager/derivation/scan.ts +160 -0
  132. package/src/shared/environment-manager/derivation/text.ts +40 -0
  133. package/src/shared/environment-manager/derivation/types.ts +69 -0
  134. package/src/shared/environment-manager/health.ts +635 -0
  135. package/src/shared/environment-manager/index.ts +6 -0
  136. package/src/shared/environment-manager/load-applications.ts +108 -0
  137. package/src/shared/environment-manager/log-tail.ts +213 -0
  138. package/src/shared/environment-manager/make-targets.ts +78 -0
  139. package/src/shared/environment-manager/opctl.ts +335 -0
  140. package/src/shared/environment-manager/readiness.ts +93 -0
  141. package/src/shared/environment-manager/register-local.ts +72 -0
  142. package/src/shared/environment-manager/secrets-check.ts +260 -0
  143. package/src/shared/environment-manager/service-container.ts +47 -0
  144. package/src/shared/environment-manager/service-env.ts +110 -0
  145. package/src/shared/environment-manager/service-runner.ts +1603 -0
  146. package/src/shared/environment-manager/sync-targets.ts +66 -0
  147. package/src/shared/environment-manager/up-plan.ts +326 -0
  148. package/src/shared/environment-manager/workspace-manager.ts +905 -0
  149. package/src/shared/environment-manager/workspaces.ts +860 -0
  150. package/src/shared/manifest/deps.ts +27 -0
  151. package/src/shared/manifest/merge-override.ts +75 -0
  152. package/src/shared/manifest/migrate-manifest.ts +101 -0
  153. package/src/shared/manifest/placement.ts +321 -0
  154. package/src/shared/manifest/read-manifest.ts +278 -0
  155. package/src/shared/manifest/schema.ts +313 -0
  156. package/src/shared/manifest/service-links.ts +93 -0
  157. package/src/shared/manifest/validate-layers.ts +229 -0
  158. package/src/shared/manifest/validate-manifest.ts +200 -0
  159. package/src/shared/manifest/workspace-manifests.ts +92 -0
  160. package/src/shared/open-url.ts +122 -0
  161. package/src/shared/org.ts +44 -0
  162. package/src/shared/paths.ts +190 -0
  163. package/src/shared/perf/optional-fields.ts +105 -0
  164. package/src/shared/perf/report-normalizer.ts +404 -0
  165. package/src/shared/perf/report-paths.ts +19 -0
  166. package/src/shared/perf/report-store.ts +110 -0
  167. package/src/shared/perf/report-types.ts +70 -0
  168. package/src/shared/perf/test-paths.ts +25 -0
  169. package/src/shared/process/log-rotation.ts +147 -0
  170. package/src/shared/process/spawn-streaming.ts +229 -0
  171. package/src/shared/product.ts +92 -0
  172. package/src/shared/profile.ts +141 -0
  173. package/src/shared/redact.ts +107 -0
  174. package/src/shared/repo-kind.ts +32 -0
  175. package/src/shared/reporting/jsonl-reporter.ts +131 -0
  176. package/src/shared/reporting/slack-reporter.ts +340 -0
  177. package/src/shared/results/result-reader.ts +266 -0
  178. package/src/shared/results/result-writer.ts +168 -0
  179. package/src/shared/secrets/doppler.ts +126 -0
  180. package/src/shared/secrets/env.ts +48 -0
  181. package/src/shared/secrets/index.ts +197 -0
  182. package/src/shared/secrets/keys.ts +33 -0
  183. package/src/shared/secrets/onepassword.ts +75 -0
  184. package/src/shared/secrets/types.ts +52 -0
  185. package/src/shared/service-url.ts +119 -0
  186. package/src/shared/smoke/branch-resolver.ts +141 -0
  187. package/src/shared/smoke/git-remote.ts +70 -0
  188. package/src/shared/smoke/repo-cloner.ts +151 -0
  189. package/src/shared/stacks/config.ts +137 -0
  190. package/src/shared/stacks/manifest-commands.ts +118 -0
  191. package/src/shared/stacks/render.ts +253 -0
  192. package/src/shared/stacks/runtime.ts +334 -0
  193. package/src/shared/stacks/status.ts +99 -0
  194. package/src/shared/stacks/types.ts +53 -0
  195. package/src/shared/terminal/tabs.ts +212 -0
  196. package/src/shared/test-discovery/module-scanner.ts +569 -0
  197. package/src/shared/types/index.ts +701 -0
  198. package/src/shared/ui-marker.ts +19 -0
  199. package/src/ui/cli/applications-command.ts +148 -0
  200. package/src/ui/cli/config-command.ts +68 -0
  201. package/src/ui/cli/create-main.ts +9 -0
  202. package/src/ui/cli/create.ts +360 -0
  203. package/src/ui/cli/edge-command.ts +81 -0
  204. package/src/ui/cli/flag-parser.ts +246 -0
  205. package/src/ui/cli/graph-command.ts +570 -0
  206. package/src/ui/cli/health-display.ts +63 -0
  207. package/src/ui/cli/index.ts +3742 -0
  208. package/src/ui/cli/init.ts +855 -0
  209. package/src/ui/cli/invocation-target.ts +175 -0
  210. package/src/ui/cli/logs-command.ts +104 -0
  211. package/src/ui/cli/mcp-main.ts +13 -0
  212. package/src/ui/cli/mcp-stdout-guard.ts +10 -0
  213. package/src/ui/cli/module-selection.ts +31 -0
  214. package/src/ui/cli/prompts.ts +767 -0
  215. package/src/ui/cli/results-viewer.ts +436 -0
  216. package/src/ui/cli/secrets-preflight.ts +91 -0
  217. package/src/ui/cli/stack-command.ts +117 -0
  218. package/src/ui/cli/stalled-setup-recovery.ts +129 -0
  219. package/src/ui/cli/test-runner.ts +1319 -0
  220. package/src/ui/cli/web-command.ts +1073 -0
  221. package/src/ui/mcp/api.ts +284 -0
  222. package/src/ui/mcp/guards.ts +141 -0
  223. package/src/ui/mcp/server.ts +147 -0
  224. package/src/ui/mcp/tools.ts +565 -0
  225. package/src/ui/server/audit.ts +71 -0
  226. package/src/ui/server/changelog-parser.ts +105 -0
  227. package/src/ui/server/daemon-reaper.ts +129 -0
  228. package/src/ui/server/editor-manager.ts +301 -0
  229. package/src/ui/server/github-service.ts +223 -0
  230. package/src/ui/server/graph-prefs.ts +89 -0
  231. package/src/ui/server/health-row.ts +15 -0
  232. package/src/ui/server/http-helpers.ts +98 -0
  233. package/src/ui/server/index.ts +3350 -0
  234. package/src/ui/server/log-history.ts +453 -0
  235. package/src/ui/server/perf-report.ts +42 -0
  236. package/src/ui/server/public/environments.html +496 -0
  237. package/src/ui/server/public/favicon.ico +0 -0
  238. package/src/ui/server/public/graph-model.js +416 -0
  239. package/src/ui/server/public/graph.css +976 -0
  240. package/src/ui/server/public/graph.js +986 -0
  241. package/src/ui/server/public/header.js +1373 -0
  242. package/src/ui/server/public/index.html +2595 -0
  243. package/src/ui/server/public/local-dev.html +5344 -0
  244. package/src/ui/server/public/logs.html +1496 -0
  245. package/src/ui/server/public/panel.js +787 -0
  246. package/src/ui/server/public/perf-report.js +359 -0
  247. package/src/ui/server/public/release.html +2111 -0
  248. package/src/ui/server/public/services.html +1103 -0
  249. package/src/ui/server/public/workspace-picker.js +69 -0
  250. package/src/ui/server/readonly-routes.ts +200 -0
  251. package/src/ui/server/release-deploy-session.ts +246 -0
  252. package/src/ui/server/release-service.ts +1125 -0
  253. package/src/ui/server/sudo-store.ts +111 -0
  254. package/src/ui/server/ui-assets.ts +73 -0
  255. package/src/ui/server/workspace-routes.ts +2185 -0
@@ -0,0 +1,569 @@
1
+ /**
2
+ * Test Module Scanner
3
+ * Auto-discovers test modules from filesystem
4
+ */
5
+
6
+ import { listReposWithManifests } from "../manifest/workspace-manifests.js";
7
+ import * as fs from "fs/promises";
8
+ import * as path from "path";
9
+ import * as yaml from "yaml";
10
+ import { discoverRepos } from "../../pillars/smoke/discover.js";
11
+ import { discoverJourneySpecs } from "../../pillars/journeys/discover.js";
12
+ import {
13
+ discoverSecurityTargets,
14
+ securityTargetId,
15
+ } from "../../pillars/security/discover.js";
16
+ import { TEST_FILE_EXTENSIONS, SKIP_TEST_DIRS } from "../constants.js";
17
+ import { getDistRoot } from "../paths.js";
18
+ import { enginePath } from "../engine.js";
19
+ import { getPerfTestsRoot } from "../perf/test-paths.js";
20
+ import type {
21
+ PerformanceModuleConfig,
22
+ PerfOptionalField,
23
+ Pillar,
24
+ } from "../types/index.js";
25
+
26
+ export interface ModuleManifest {
27
+ name?: string;
28
+ description?: string;
29
+ icon?: string;
30
+ tags?: string[];
31
+ /** Declarative launch-time options for perf tests (rendered by the web UI). */
32
+ optionalFields?: PerfOptionalField[];
33
+ }
34
+
35
+ export interface TestModule {
36
+ name: string;
37
+ displayName: string;
38
+ description: string;
39
+ icon: string;
40
+ path: string;
41
+ testCount: number;
42
+ tags: string[];
43
+ /** Declarative launch-time options for perf tests; empty for non-perf modules. */
44
+ optionalFields?: PerfOptionalField[];
45
+ }
46
+
47
+ export class ModuleScanner {
48
+ private rootDir: string;
49
+
50
+ constructor() {
51
+ // Den-root anchored (read-only discovery): a cwd default made `den`
52
+ // invoked outside the repo silently report zero test modules.
53
+ this.rootDir = getDistRoot();
54
+ }
55
+
56
+ /**
57
+ * Scan for test modules in a given pillar
58
+ */
59
+ async scanModules(pillar: Pillar): Promise<TestModule[]> {
60
+ if (pillar === "journeys") {
61
+ return this.scanJourneyModules();
62
+ }
63
+ if (pillar === "security") {
64
+ return this.scanSecurityTargets();
65
+ }
66
+ if (pillar === "smoke") {
67
+ return this.scanSmokeServices();
68
+ }
69
+ if (pillar === "performance") {
70
+ return this.scanPerformanceModules();
71
+ }
72
+ if (pillar === "synthetic") {
73
+ return this.scanSyntheticModules();
74
+ }
75
+ if (pillar === "suites") {
76
+ return this.scanRepoSuites();
77
+ }
78
+ return this.scanFilesystemModules(
79
+ enginePath("src", "pillars", "api", "tests"),
80
+ pillar,
81
+ );
82
+ }
83
+
84
+ /** Suites repos declare under `tests:` in their manifest. */
85
+ private scanRepoSuites(): TestModule[] {
86
+ return listReposWithManifests().flatMap((r) =>
87
+ Object.entries(r.manifest.tests ?? {}).map(([name, suite]) => ({
88
+ name: `${r.repo}/${name}`,
89
+ displayName: `${r.repo}/${suite.label ?? name}`,
90
+ description: suite.cmd,
91
+ icon: "🧪",
92
+ path: r.repoPath,
93
+ testCount: 1,
94
+ tags: r.org ? [r.org] : [],
95
+ })),
96
+ );
97
+ }
98
+
99
+ /**
100
+ * Check if directory should be skipped
101
+ */
102
+ private shouldSkipDirectory(dirName: string): boolean {
103
+ // Skip directories starting with underscore
104
+ if (dirName.startsWith("_")) {
105
+ return true;
106
+ }
107
+
108
+ return (SKIP_TEST_DIRS as readonly string[]).includes(dirName);
109
+ }
110
+
111
+ /**
112
+ * Count test files in directory
113
+ */
114
+ /**
115
+ * Scan registered repos (via discoverRepos) and return their smoke services as modules.
116
+ * Reads from both config/local/registered-repos.json and .den/ symlinks.
117
+ */
118
+ private async scanSmokeServices(): Promise<TestModule[]> {
119
+ const modules: TestModule[] = [];
120
+
121
+ const repos = discoverRepos();
122
+ for (const repo of repos) {
123
+ for (const [svcName, svc] of Object.entries(repo.manifest.smoke ?? {})) {
124
+ const testDir = path.resolve(repo.repoPath, svc.testDir);
125
+ const testCount = await this.countSmokeFiles(testDir);
126
+ modules.push({
127
+ name: `${repo.repoName}/${svcName}`,
128
+ displayName: `${repo.repoName} — ${svcName}`,
129
+ description: `${svcName} smoke tests from ${repo.repoName}`,
130
+ icon: svcName === "api" ? "🔗" : "🌐",
131
+ path: testDir,
132
+ testCount,
133
+ tags: [svcName],
134
+ });
135
+ }
136
+ }
137
+
138
+ return modules;
139
+ }
140
+
141
+ /**
142
+ * List declared security targets as modules — manifest-driven via
143
+ * discoverSecurityTargets(), named "<repo>/<service>" like smoke. A
144
+ * failing discovery degrades to a warned empty list (interactive picker);
145
+ * the run entrypoint keeps the loud fail-fast posture.
146
+ */
147
+ private async scanSecurityTargets(): Promise<TestModule[]> {
148
+ try {
149
+ return discoverSecurityTargets().map((target) => ({
150
+ name: securityTargetId(target),
151
+ displayName: `${target.repoName} — ${target.serviceName}`,
152
+ description: `ZAP scan target from ${target.repoName}`,
153
+ icon: "🛡️",
154
+ path: target.repoPath,
155
+ testCount: 1,
156
+ tags: [target.config.baseUrlKey ?? "hosts"],
157
+ }));
158
+ } catch (error) {
159
+ console.warn(
160
+ "Warning: Could not scan security modules:",
161
+ (error as Error).message,
162
+ );
163
+ return [];
164
+ }
165
+ }
166
+
167
+ /**
168
+ * List discovered journey specs as modules — manifest-driven via
169
+ * discoverJourneySpecs() (one module per journey spec, named
170
+ * "<repo>/<journey-name>" like smoke's "<repo>/<service>"). No repos
171
+ * registered naturally yields an empty list; a failing discovery
172
+ * (missing testDir, invalid spec) degrades to a warned empty list here
173
+ * because this scanner feeds interactive pickers — the run entrypoint
174
+ * keeps the loud fail-fast posture.
175
+ */
176
+ private async scanJourneyModules(): Promise<TestModule[]> {
177
+ try {
178
+ return discoverJourneySpecs().map((discovered) => ({
179
+ name: `${discovered.repoName}/${discovered.spec.name}`,
180
+ displayName: `${discovered.repoName} — ${discovered.spec.name}`,
181
+ description: `${discovered.spec.surface} journey from ${discovered.repoName}`,
182
+ icon: "🧭",
183
+ path: discovered.specPath,
184
+ testCount: 1,
185
+ tags: [discovered.spec.surface],
186
+ }));
187
+ } catch (error) {
188
+ console.warn(
189
+ "Warning: Could not scan journeys modules:",
190
+ (error as Error).message,
191
+ );
192
+ return [];
193
+ }
194
+ }
195
+
196
+ private async scanPerformanceModules(): Promise<TestModule[]> {
197
+ // The distribution's own suites (the engine ships none; a distribution
198
+ // without any has no directory, which is not an error), then the suites
199
+ // registered repos declare in their manifests.
200
+ const distTestsDir = getPerfTestsRoot(this.rootDir);
201
+ const modules = (await this.pathExists(distTestsDir))
202
+ ? await this.scanFilesystemModules(distTestsDir, "performance")
203
+ : [];
204
+
205
+ for (const repo of discoverRepos()) {
206
+ for (const [moduleName, config] of Object.entries(
207
+ repo.manifest.performance ?? {},
208
+ )) {
209
+ const modulePath = path.resolve(repo.repoPath, config.testDir);
210
+ const manifest = await this.loadManifest(modulePath);
211
+
212
+ modules.push({
213
+ name: `${repo.repoName}/${moduleName}`,
214
+ displayName: manifest.name || `${repo.repoName} — ${moduleName}`,
215
+ description:
216
+ manifest.description ||
217
+ `${moduleName} performance tests from ${repo.repoName}`,
218
+ icon: manifest.icon || this.getModuleIcon(moduleName),
219
+ path: modulePath,
220
+ testCount: await this.countPerformanceFiles(modulePath, config),
221
+ tags: manifest.tags || config.tags || [],
222
+ optionalFields:
223
+ manifest.optionalFields ?? config.optionalFields ?? [],
224
+ });
225
+ }
226
+ }
227
+
228
+ modules.sort((a, b) => a.name.localeCompare(b.name));
229
+ return modules;
230
+ }
231
+
232
+ /**
233
+ * Scan discovered repos for synthetic test modules.
234
+ * Uses workspace-aware discoverRepos() (active workspace when Local Dev is
235
+ * enabled, registered-repos.json + .den/ otherwise).
236
+ * Falls back to pillars/browser/tests/ if no repo provides synthetics config.
237
+ */
238
+ private async scanSyntheticModules(): Promise<TestModule[]> {
239
+ const modules: TestModule[] = [];
240
+ let foundAny = false;
241
+
242
+ const repos = discoverRepos();
243
+ for (const repo of repos) {
244
+ const manifest = repo.manifest as {
245
+ synthetics?: { testDir: string };
246
+ } | null;
247
+ if (!manifest?.synthetics?.testDir) continue;
248
+
249
+ const testDir = path.resolve(repo.repoPath, manifest.synthetics.testDir);
250
+
251
+ try {
252
+ const entries = await fs.readdir(testDir, { withFileTypes: true });
253
+ for (const entry of entries) {
254
+ if (!entry.isDirectory()) continue;
255
+ if (this.shouldSkipDirectory(entry.name)) continue;
256
+
257
+ const modulePath = path.join(testDir, entry.name);
258
+ const testCount = await this.countTestFiles(modulePath, "synthetic");
259
+ const modManifest = await this.loadManifest(modulePath);
260
+
261
+ modules.push({
262
+ name: entry.name,
263
+ displayName: modManifest.name || this.formatDisplayName(entry.name),
264
+ description:
265
+ modManifest.description ||
266
+ (await this.getModuleDescription(modulePath)),
267
+ icon: modManifest.icon || this.getModuleIcon(entry.name),
268
+ path: modulePath,
269
+ testCount,
270
+ tags: modManifest.tags || [],
271
+ });
272
+
273
+ foundAny = true;
274
+ }
275
+ } catch {
276
+ // testDir doesn't exist or isn't readable — skip
277
+ }
278
+ }
279
+
280
+ // Fall back to synthetic/tests/ if no discovered repos provide synthetics config
281
+ if (!foundAny) {
282
+ // Distribution-owned (Den ships no browser tests of its own).
283
+ const testsDir = path.join(
284
+ this.rootDir,
285
+ "src",
286
+ "pillars",
287
+ "browser",
288
+ "tests",
289
+ );
290
+ try {
291
+ const entries = await fs.readdir(testsDir, { withFileTypes: true });
292
+ for (const entry of entries) {
293
+ if (!entry.isDirectory()) continue;
294
+ if (this.shouldSkipDirectory(entry.name)) continue;
295
+
296
+ const modulePath = path.join(testsDir, entry.name);
297
+ const testCount = await this.countTestFiles(modulePath, "synthetic");
298
+ const manifest = await this.loadManifest(modulePath);
299
+
300
+ modules.push({
301
+ name: entry.name,
302
+ displayName: manifest.name || this.formatDisplayName(entry.name),
303
+ description:
304
+ manifest.description ||
305
+ (await this.getModuleDescription(modulePath)),
306
+ icon: manifest.icon || this.getModuleIcon(entry.name),
307
+ path: modulePath,
308
+ testCount,
309
+ tags: manifest.tags || [],
310
+ });
311
+ }
312
+ } catch {
313
+ // synthetic/tests/ doesn't exist
314
+ }
315
+ }
316
+
317
+ modules.sort((a, b) => a.name.localeCompare(b.name));
318
+ return modules;
319
+ }
320
+
321
+ /**
322
+ * Recursively count .smoke.ts files under a directory
323
+ */
324
+ private async countSmokeFiles(dirPath: string): Promise<number> {
325
+ let count = 0;
326
+ try {
327
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
328
+ for (const entry of entries) {
329
+ if (entry.isDirectory()) {
330
+ count += await this.countSmokeFiles(path.join(dirPath, entry.name));
331
+ } else if (
332
+ TEST_FILE_EXTENSIONS.smoke.some((ext) => entry.name.endsWith(ext))
333
+ ) {
334
+ count++;
335
+ }
336
+ }
337
+ } catch {
338
+ // directory unreadable (e.g. symlink not yet linked)
339
+ }
340
+ return count;
341
+ }
342
+
343
+ private async scanFilesystemModules(
344
+ testsDir: string,
345
+ pillar: Pillar,
346
+ ): Promise<TestModule[]> {
347
+ try {
348
+ const entries = await fs.readdir(testsDir, { withFileTypes: true });
349
+ const modules: TestModule[] = [];
350
+
351
+ for (const entry of entries) {
352
+ if (!entry.isDirectory()) {
353
+ continue;
354
+ }
355
+ if (this.shouldSkipDirectory(entry.name)) {
356
+ continue;
357
+ }
358
+
359
+ const modulePath = path.join(testsDir, entry.name);
360
+ const testCount = await this.countTestFiles(modulePath, pillar);
361
+ const manifest = await this.loadManifest(modulePath);
362
+
363
+ modules.push({
364
+ name: entry.name,
365
+ displayName: manifest.name || this.formatDisplayName(entry.name),
366
+ description:
367
+ manifest.description ||
368
+ (await this.getModuleDescription(modulePath)),
369
+ icon: manifest.icon || this.getModuleIcon(entry.name),
370
+ path: modulePath,
371
+ testCount,
372
+ tags: manifest.tags || [],
373
+ optionalFields:
374
+ pillar === "performance" ? (manifest.optionalFields ?? []) : [],
375
+ });
376
+ }
377
+
378
+ modules.sort((a, b) => a.name.localeCompare(b.name));
379
+ return modules;
380
+ } catch (error) {
381
+ console.warn(
382
+ `Warning: Could not scan ${pillar} test modules:`,
383
+ (error as Error).message,
384
+ );
385
+ return [];
386
+ }
387
+ }
388
+
389
+ private async pathExists(p: string): Promise<boolean> {
390
+ try {
391
+ await fs.access(p);
392
+ return true;
393
+ } catch {
394
+ return false;
395
+ }
396
+ }
397
+
398
+ private async countTestFiles(
399
+ dirPath: string,
400
+ pillar: Pillar,
401
+ ): Promise<number> {
402
+ try {
403
+ const files = await fs.readdir(dirPath);
404
+ const exts =
405
+ TEST_FILE_EXTENSIONS[pillar] ?? TEST_FILE_EXTENSIONS.synthetic;
406
+ return files.filter((file) => exts.some((ext) => file.endsWith(ext)))
407
+ .length;
408
+ } catch {
409
+ return 0;
410
+ }
411
+ }
412
+
413
+ private async countPerformanceFiles(
414
+ dirPath: string,
415
+ config: PerformanceModuleConfig,
416
+ ): Promise<number> {
417
+ if (typeof config.testMatch !== "string" || config.testMatch.length === 0) {
418
+ // One module's bad testMatch must not abort discovery of every other
419
+ // perf module — treat it as "no files found" and move on.
420
+ console.warn(`Invalid performance testMatch for ${dirPath} — skipping`);
421
+ return 0;
422
+ }
423
+
424
+ try {
425
+ return await this.countPerformanceFilesFromRoot(dirPath, config, "");
426
+ } catch {
427
+ return 0;
428
+ }
429
+ }
430
+
431
+ private async countPerformanceFilesFromRoot(
432
+ dirPath: string,
433
+ config: PerformanceModuleConfig,
434
+ relativeDir: string,
435
+ ): Promise<number> {
436
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
437
+ let count = 0;
438
+
439
+ for (const entry of entries) {
440
+ const entryPath = path.join(dirPath, entry.name);
441
+ const relativePath = relativeDir
442
+ ? path.join(relativeDir, entry.name)
443
+ : entry.name;
444
+ if (entry.isDirectory()) {
445
+ count += await this.countPerformanceFilesFromRoot(
446
+ entryPath,
447
+ config,
448
+ relativePath,
449
+ );
450
+ continue;
451
+ }
452
+
453
+ if (this.matchesPerformanceTestFile(relativePath, config.testMatch)) {
454
+ count += 1;
455
+ }
456
+ }
457
+
458
+ return count;
459
+ }
460
+
461
+ private matchesPerformanceTestFile(
462
+ relativePath: string,
463
+ testMatch: string,
464
+ ): boolean {
465
+ const normalizedPath = relativePath.split(path.sep).join("/");
466
+
467
+ if (normalizedPath === testMatch) {
468
+ return true;
469
+ }
470
+
471
+ if (testMatch.startsWith("**/")) {
472
+ const nestedPattern = testMatch.slice(3);
473
+ if (nestedPattern.startsWith("*.")) {
474
+ return normalizedPath.endsWith(nestedPattern.slice(1));
475
+ }
476
+ return (
477
+ normalizedPath === nestedPattern ||
478
+ normalizedPath.endsWith(`/${nestedPattern}`)
479
+ );
480
+ }
481
+
482
+ if (testMatch.startsWith("**/*.")) {
483
+ return normalizedPath.endsWith(testMatch.slice("**/*".length));
484
+ }
485
+
486
+ if (!testMatch.includes("/")) {
487
+ return path.basename(normalizedPath) === testMatch;
488
+ }
489
+
490
+ return false;
491
+ }
492
+
493
+ /**
494
+ * Format directory name for display
495
+ */
496
+ private formatDisplayName(dirName: string): string {
497
+ // Convert kebab-case or snake_case to Title Case
498
+ return dirName
499
+ .split(/[-_]/)
500
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
501
+ .join(" ");
502
+ }
503
+
504
+ /**
505
+ * Load manifest.yml from module directory
506
+ */
507
+ private async loadManifest(modulePath: string): Promise<ModuleManifest> {
508
+ const manifestPath = path.join(modulePath, "manifest.yml");
509
+
510
+ try {
511
+ const content = await fs.readFile(manifestPath, "utf-8");
512
+ const manifest = yaml.parse(content) as ModuleManifest;
513
+ return manifest || {};
514
+ } catch {
515
+ // No manifest or parse error - return empty object
516
+ return {};
517
+ }
518
+ }
519
+
520
+ /**
521
+ * Get module description from README or default
522
+ */
523
+ private async getModuleDescription(modulePath: string): Promise<string> {
524
+ // Try to read description from .gitkeep or README
525
+ const readmePath = path.join(modulePath, ".gitkeep");
526
+ try {
527
+ const content = await fs.readFile(readmePath, "utf-8");
528
+ const firstLine = content.split("\n")[0];
529
+ // Remove markdown heading markers
530
+ return firstLine.replace(/^#\s*/, "").trim();
531
+ } catch {
532
+ // Default descriptions based on common module names
533
+ return this.getDefaultDescription(path.basename(modulePath));
534
+ }
535
+ }
536
+
537
+ /**
538
+ * Get default description for common module names
539
+ */
540
+ private getDefaultDescription(moduleName: string): string {
541
+ const descriptions: Record<string, string> = {
542
+ auth: "Authentication & sessions",
543
+ basic: "Health checks & smoke tests",
544
+ health: "API health checks",
545
+ users: "User API endpoints",
546
+ };
547
+
548
+ return (
549
+ descriptions[moduleName] || `${this.formatDisplayName(moduleName)} tests`
550
+ );
551
+ }
552
+
553
+ /**
554
+ * Get emoji icon for module
555
+ */
556
+ getModuleIcon(moduleName: string): string {
557
+ const icons: Record<string, string> = {
558
+ basic: "🔍",
559
+ auth: "🔐",
560
+ health: "🔍",
561
+ users: "👥",
562
+ };
563
+
564
+ return icons[moduleName] || "📦";
565
+ }
566
+ }
567
+
568
+ // Export singleton
569
+ export const moduleScanner = new ModuleScanner();