@elliemae/encw-leak-runner 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (261) hide show
  1. package/.eslintrc.cjs +10 -0
  2. package/.stylelintignore +4 -0
  3. package/CHANGELOG.md +51 -0
  4. package/README.md +309 -0
  5. package/babel.config.cjs +2 -0
  6. package/bin/leak-runner.ts +9 -0
  7. package/dist/.tsbuildinfo +1 -0
  8. package/dist/bin/leak-runner.js +792 -0
  9. package/dist/cjs/analysis/thresholdEvaluator.js +46 -0
  10. package/dist/cjs/browser/iframeHeapProfiler.js +46 -0
  11. package/dist/cjs/cli/command.js +16 -0
  12. package/dist/cjs/cli/commands/listCommand.js +47 -0
  13. package/dist/cjs/cli/commands/runCommand.js +111 -0
  14. package/dist/cjs/cli/index.js +42 -0
  15. package/dist/cjs/config/missingRequiredParamError.js +34 -0
  16. package/dist/cjs/config/requiredEnvParams.js +57 -0
  17. package/dist/cjs/config/runnerConfigLoader.js +73 -0
  18. package/dist/cjs/config/runnerConfigSchema.js +40 -0
  19. package/dist/cjs/config/sources/cliOverrideConfigSource.js +44 -0
  20. package/dist/cjs/config/sources/configSource.js +35 -0
  21. package/dist/cjs/config/sources/envVarConfigSource.js +41 -0
  22. package/dist/cjs/config/sources/fileConfigSource.js +62 -0
  23. package/dist/cjs/index.js +52 -0
  24. package/dist/cjs/package.json +7 -0
  25. package/dist/cjs/registry/scenarioRegistry.js +51 -0
  26. package/dist/cjs/reporting/consoleReporter.js +60 -0
  27. package/dist/cjs/reporting/junitReporter.js +75 -0
  28. package/dist/cjs/reporting/reporter.js +16 -0
  29. package/dist/cjs/runner/aiEnhancementStep.js +39 -0
  30. package/dist/cjs/runner/batchRunner.js +76 -0
  31. package/dist/cjs/runner/scenarioRunner.js +165 -0
  32. package/dist/cjs/scenarios/index.js +29 -0
  33. package/dist/cjs/scenarios/one-admin/export-navigation.scenario.js +50 -0
  34. package/dist/cjs/scenarios/one-admin/index.js +27 -0
  35. package/dist/cjs/scenarios/one-admin/page-models/ExportPageModel.js +43 -0
  36. package/dist/cjs/scenarios/one-admin/page-models/SelectSettingsPageModel.js +47 -0
  37. package/dist/cjs/scenarios/one-admin/page-models/index.js +26 -0
  38. package/dist/cjs/types/config.js +27 -0
  39. package/dist/cjs/types/results.js +16 -0
  40. package/dist/cjs/types/scenario.js +16 -0
  41. package/dist/esm/analysis/thresholdEvaluator.js +26 -0
  42. package/dist/esm/browser/iframeHeapProfiler.js +26 -0
  43. package/dist/esm/cli/command.js +0 -0
  44. package/dist/esm/cli/commands/listCommand.js +27 -0
  45. package/dist/esm/cli/commands/runCommand.js +93 -0
  46. package/dist/esm/cli/index.js +22 -0
  47. package/dist/esm/config/missingRequiredParamError.js +14 -0
  48. package/dist/esm/config/requiredEnvParams.js +37 -0
  49. package/dist/esm/config/runnerConfigLoader.js +53 -0
  50. package/dist/esm/config/runnerConfigSchema.js +20 -0
  51. package/dist/esm/config/sources/cliOverrideConfigSource.js +24 -0
  52. package/dist/esm/config/sources/configSource.js +15 -0
  53. package/dist/esm/config/sources/envVarConfigSource.js +21 -0
  54. package/dist/esm/config/sources/fileConfigSource.js +34 -0
  55. package/dist/esm/index.js +35 -0
  56. package/dist/esm/package.json +7 -0
  57. package/dist/esm/registry/scenarioRegistry.js +31 -0
  58. package/dist/esm/reporting/consoleReporter.js +40 -0
  59. package/dist/esm/reporting/junitReporter.js +45 -0
  60. package/dist/esm/reporting/reporter.js +0 -0
  61. package/dist/esm/runner/aiEnhancementStep.js +22 -0
  62. package/dist/esm/runner/batchRunner.js +56 -0
  63. package/dist/esm/runner/scenarioRunner.js +137 -0
  64. package/dist/esm/scenarios/index.js +9 -0
  65. package/dist/esm/scenarios/one-admin/export-navigation.scenario.js +33 -0
  66. package/dist/esm/scenarios/one-admin/index.js +7 -0
  67. package/dist/esm/scenarios/one-admin/page-models/ExportPageModel.js +23 -0
  68. package/dist/esm/scenarios/one-admin/page-models/SelectSettingsPageModel.js +27 -0
  69. package/dist/esm/scenarios/one-admin/page-models/index.js +6 -0
  70. package/dist/esm/types/config.js +7 -0
  71. package/dist/esm/types/results.js +0 -0
  72. package/dist/esm/types/scenario.js +0 -0
  73. package/dist/types/bin/leak-runner.d.ts +2 -0
  74. package/dist/types/lib/analysis/tests/thresholdEvaluator.test.d.ts +1 -0
  75. package/dist/types/lib/analysis/thresholdEvaluator.d.ts +6 -0
  76. package/dist/types/lib/browser/iframeHeapProfiler.d.ts +9 -0
  77. package/dist/types/lib/browser/tests/iframeHeapProfiler.test.d.ts +1 -0
  78. package/dist/types/lib/cli/command.d.ts +17 -0
  79. package/dist/types/lib/cli/commands/listCommand.d.ts +5 -0
  80. package/dist/types/lib/cli/commands/runCommand.d.ts +7 -0
  81. package/dist/types/lib/cli/index.d.ts +4 -0
  82. package/dist/types/lib/config/missingRequiredParamError.d.ts +4 -0
  83. package/dist/types/lib/config/requiredEnvParams.d.ts +16 -0
  84. package/dist/types/lib/config/runnerConfigLoader.d.ts +13 -0
  85. package/dist/types/lib/config/runnerConfigSchema.d.ts +78 -0
  86. package/dist/types/lib/config/sources/cliOverrideConfigSource.d.ts +14 -0
  87. package/dist/types/lib/config/sources/configSource.d.ts +14 -0
  88. package/dist/types/lib/config/sources/envVarConfigSource.d.ts +7 -0
  89. package/dist/types/lib/config/sources/fileConfigSource.d.ts +9 -0
  90. package/dist/types/lib/config/tests/cliOverrideConfigSource.test.d.ts +1 -0
  91. package/dist/types/lib/config/tests/envVarConfigSource.test.d.ts +1 -0
  92. package/dist/types/lib/config/tests/fileConfigSource.test.d.ts +1 -0
  93. package/dist/types/lib/config/tests/requiredEnvParams.test.d.ts +1 -0
  94. package/dist/types/lib/config/tests/runnerConfigLoader.test.d.ts +1 -0
  95. package/dist/types/lib/index.d.ts +18 -0
  96. package/dist/types/lib/registry/scenarioRegistry.d.ts +18 -0
  97. package/dist/types/lib/registry/tests/scenarioRegistry.test.d.ts +1 -0
  98. package/dist/types/lib/reporting/consoleReporter.d.ts +5 -0
  99. package/dist/types/lib/reporting/junitReporter.d.ts +5 -0
  100. package/dist/types/lib/reporting/reporter.d.ts +4 -0
  101. package/dist/types/lib/reporting/tests/consoleReporter.test.d.ts +1 -0
  102. package/dist/types/lib/reporting/tests/junitReporter.test.d.ts +1 -0
  103. package/dist/types/lib/runner/aiEnhancementStep.d.ts +15 -0
  104. package/dist/types/lib/runner/batchRunner.d.ts +14 -0
  105. package/dist/types/lib/runner/scenarioRunner.d.ts +15 -0
  106. package/dist/types/lib/runner/tests/aiEnhancementStep.test.d.ts +1 -0
  107. package/dist/types/lib/runner/tests/batchRunner.test.d.ts +1 -0
  108. package/dist/types/lib/runner/tests/scenarioRunner.test.d.ts +1 -0
  109. package/dist/types/lib/scenarios/index.d.ts +2 -0
  110. package/dist/types/lib/scenarios/one-admin/export-navigation.scenario.d.ts +2 -0
  111. package/dist/types/lib/scenarios/one-admin/index.d.ts +2 -0
  112. package/dist/types/lib/scenarios/one-admin/page-models/ExportPageModel.d.ts +8 -0
  113. package/dist/types/lib/scenarios/one-admin/page-models/SelectSettingsPageModel.d.ts +10 -0
  114. package/dist/types/lib/scenarios/one-admin/page-models/index.d.ts +2 -0
  115. package/dist/types/lib/types/config.d.ts +26 -0
  116. package/dist/types/lib/types/results.d.ts +19 -0
  117. package/dist/types/lib/types/scenario.d.ts +17 -0
  118. package/jest.config.cjs +9 -0
  119. package/leak-runner.config.json +13 -0
  120. package/leak-runner.schema.json +27 -0
  121. package/lib/analysis/tests/thresholdEvaluator.test.ts +125 -0
  122. package/lib/analysis/thresholdEvaluator.ts +36 -0
  123. package/lib/browser/iframeHeapProfiler.ts +30 -0
  124. package/lib/browser/tests/iframeHeapProfiler.test.ts +71 -0
  125. package/lib/cli/command.ts +19 -0
  126. package/lib/cli/commands/listCommand.ts +36 -0
  127. package/lib/cli/commands/runCommand.ts +126 -0
  128. package/lib/cli/index.ts +25 -0
  129. package/lib/config/missingRequiredParamError.ts +10 -0
  130. package/lib/config/requiredEnvParams.ts +50 -0
  131. package/lib/config/runnerConfigLoader.ts +84 -0
  132. package/lib/config/runnerConfigSchema.ts +27 -0
  133. package/lib/config/sources/cliOverrideConfigSource.ts +30 -0
  134. package/lib/config/sources/configSource.ts +27 -0
  135. package/lib/config/sources/envVarConfigSource.ts +23 -0
  136. package/lib/config/sources/fileConfigSource.ts +39 -0
  137. package/lib/config/tests/cliOverrideConfigSource.test.ts +25 -0
  138. package/lib/config/tests/envVarConfigSource.test.ts +57 -0
  139. package/lib/config/tests/fileConfigSource.test.ts +49 -0
  140. package/lib/config/tests/requiredEnvParams.test.ts +113 -0
  141. package/lib/config/tests/runnerConfigLoader.test.ts +59 -0
  142. package/lib/index.ts +37 -0
  143. package/lib/registry/scenarioRegistry.ts +48 -0
  144. package/lib/registry/tests/scenarioRegistry.test.ts +96 -0
  145. package/lib/reporting/consoleReporter.ts +48 -0
  146. package/lib/reporting/junitReporter.ts +62 -0
  147. package/lib/reporting/reporter.ts +5 -0
  148. package/lib/reporting/tests/consoleReporter.test.ts +82 -0
  149. package/lib/reporting/tests/junitReporter.test.ts +103 -0
  150. package/lib/runner/aiEnhancementStep.ts +39 -0
  151. package/lib/runner/batchRunner.ts +71 -0
  152. package/lib/runner/scenarioRunner.ts +189 -0
  153. package/lib/runner/tests/aiEnhancementStep.test.ts +174 -0
  154. package/lib/runner/tests/batchRunner.test.ts +133 -0
  155. package/lib/runner/tests/scenarioRunner.test.ts +162 -0
  156. package/lib/scenarios/index.ts +8 -0
  157. package/lib/scenarios/one-admin/export-navigation.scenario.ts +38 -0
  158. package/lib/scenarios/one-admin/index.ts +6 -0
  159. package/lib/scenarios/one-admin/page-models/ExportPageModel.ts +26 -0
  160. package/lib/scenarios/one-admin/page-models/SelectSettingsPageModel.ts +30 -0
  161. package/lib/scenarios/one-admin/page-models/index.ts +2 -0
  162. package/lib/types/config.ts +34 -0
  163. package/lib/types/results.ts +22 -0
  164. package/lib/types/scenario.ts +18 -0
  165. package/package.json +46 -0
  166. package/reports/analysis/index.html +116 -0
  167. package/reports/analysis/thresholdEvaluator.ts.html +193 -0
  168. package/reports/base.css +224 -0
  169. package/reports/block-navigation.js +87 -0
  170. package/reports/browser/iframeHeapProfiler.ts.html +175 -0
  171. package/reports/browser/index.html +116 -0
  172. package/reports/cli/commands/index.html +131 -0
  173. package/reports/cli/commands/listCommand.ts.html +193 -0
  174. package/reports/cli/commands/runCommand.ts.html +463 -0
  175. package/reports/cli/index.html +116 -0
  176. package/reports/cli/index.ts.html +160 -0
  177. package/reports/config/index.html +161 -0
  178. package/reports/config/missingRequiredParamError.ts.html +115 -0
  179. package/reports/config/requiredEnvParams.ts.html +235 -0
  180. package/reports/config/runnerConfigLoader.ts.html +337 -0
  181. package/reports/config/runnerConfigSchema.ts.html +166 -0
  182. package/reports/config/sources/cliOverrideConfigSource.ts.html +175 -0
  183. package/reports/config/sources/configSource.ts.html +166 -0
  184. package/reports/config/sources/envVarConfigSource.ts.html +154 -0
  185. package/reports/config/sources/fileConfigSource.ts.html +202 -0
  186. package/reports/config/sources/index.html +161 -0
  187. package/reports/favicon.png +0 -0
  188. package/reports/index.html +296 -0
  189. package/reports/lcov-report/analysis/index.html +116 -0
  190. package/reports/lcov-report/analysis/thresholdEvaluator.ts.html +193 -0
  191. package/reports/lcov-report/base.css +224 -0
  192. package/reports/lcov-report/block-navigation.js +87 -0
  193. package/reports/lcov-report/browser/iframeHeapProfiler.ts.html +175 -0
  194. package/reports/lcov-report/browser/index.html +116 -0
  195. package/reports/lcov-report/cli/commands/index.html +131 -0
  196. package/reports/lcov-report/cli/commands/listCommand.ts.html +193 -0
  197. package/reports/lcov-report/cli/commands/runCommand.ts.html +463 -0
  198. package/reports/lcov-report/cli/index.html +116 -0
  199. package/reports/lcov-report/cli/index.ts.html +160 -0
  200. package/reports/lcov-report/config/index.html +161 -0
  201. package/reports/lcov-report/config/missingRequiredParamError.ts.html +115 -0
  202. package/reports/lcov-report/config/requiredEnvParams.ts.html +235 -0
  203. package/reports/lcov-report/config/runnerConfigLoader.ts.html +337 -0
  204. package/reports/lcov-report/config/runnerConfigSchema.ts.html +166 -0
  205. package/reports/lcov-report/config/sources/cliOverrideConfigSource.ts.html +175 -0
  206. package/reports/lcov-report/config/sources/configSource.ts.html +166 -0
  207. package/reports/lcov-report/config/sources/envVarConfigSource.ts.html +154 -0
  208. package/reports/lcov-report/config/sources/fileConfigSource.ts.html +202 -0
  209. package/reports/lcov-report/config/sources/index.html +161 -0
  210. package/reports/lcov-report/favicon.png +0 -0
  211. package/reports/lcov-report/index.html +296 -0
  212. package/reports/lcov-report/prettify.css +1 -0
  213. package/reports/lcov-report/prettify.js +2 -0
  214. package/reports/lcov-report/registry/index.html +116 -0
  215. package/reports/lcov-report/registry/scenarioRegistry.ts.html +229 -0
  216. package/reports/lcov-report/reporting/consoleReporter.ts.html +229 -0
  217. package/reports/lcov-report/reporting/index.html +131 -0
  218. package/reports/lcov-report/reporting/junitReporter.ts.html +271 -0
  219. package/reports/lcov-report/runner/aiEnhancementStep.ts.html +202 -0
  220. package/reports/lcov-report/runner/batchRunner.ts.html +298 -0
  221. package/reports/lcov-report/runner/index.html +146 -0
  222. package/reports/lcov-report/runner/scenarioRunner.ts.html +652 -0
  223. package/reports/lcov-report/scenarios/index.html +116 -0
  224. package/reports/lcov-report/scenarios/index.ts.html +109 -0
  225. package/reports/lcov-report/scenarios/one-admin/export-navigation.scenario.ts.html +199 -0
  226. package/reports/lcov-report/scenarios/one-admin/index.html +131 -0
  227. package/reports/lcov-report/scenarios/one-admin/index.ts.html +103 -0
  228. package/reports/lcov-report/scenarios/one-admin/page-models/ExportPageModel.ts.html +163 -0
  229. package/reports/lcov-report/scenarios/one-admin/page-models/SelectSettingsPageModel.ts.html +175 -0
  230. package/reports/lcov-report/scenarios/one-admin/page-models/index.html +131 -0
  231. package/reports/lcov-report/sort-arrow-sprite.png +0 -0
  232. package/reports/lcov-report/sorter.js +210 -0
  233. package/reports/lcov-report/types/config.ts.html +187 -0
  234. package/reports/lcov-report/types/index.html +116 -0
  235. package/reports/lcov.info +883 -0
  236. package/reports/prettify.css +1 -0
  237. package/reports/prettify.js +2 -0
  238. package/reports/registry/index.html +116 -0
  239. package/reports/registry/scenarioRegistry.ts.html +229 -0
  240. package/reports/reporting/consoleReporter.ts.html +229 -0
  241. package/reports/reporting/index.html +131 -0
  242. package/reports/reporting/junitReporter.ts.html +271 -0
  243. package/reports/runner/aiEnhancementStep.ts.html +202 -0
  244. package/reports/runner/batchRunner.ts.html +298 -0
  245. package/reports/runner/index.html +146 -0
  246. package/reports/runner/scenarioRunner.ts.html +652 -0
  247. package/reports/scenarios/index.html +116 -0
  248. package/reports/scenarios/index.ts.html +109 -0
  249. package/reports/scenarios/one-admin/export-navigation.scenario.ts.html +199 -0
  250. package/reports/scenarios/one-admin/index.html +131 -0
  251. package/reports/scenarios/one-admin/index.ts.html +103 -0
  252. package/reports/scenarios/one-admin/page-models/ExportPageModel.ts.html +163 -0
  253. package/reports/scenarios/one-admin/page-models/SelectSettingsPageModel.ts.html +175 -0
  254. package/reports/scenarios/one-admin/page-models/index.html +131 -0
  255. package/reports/sort-arrow-sprite.png +0 -0
  256. package/reports/sorter.js +210 -0
  257. package/reports/types/config.ts.html +187 -0
  258. package/reports/types/index.html +116 -0
  259. package/stylelint.config.cjs +2 -0
  260. package/test-report.xml +100 -0
  261. package/tsconfig.json +12 -0
@@ -0,0 +1,103 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { JunitReporter } from '../junitReporter.js';
5
+ import type { RunSummary } from '../../types/results.js';
6
+
7
+ function makeSummary(): RunSummary {
8
+ return {
9
+ totalDurationMs: 10000,
10
+ passCount: 1,
11
+ failCount: 1,
12
+ results: [
13
+ {
14
+ name: 'loan-pipeline',
15
+ passed: true,
16
+ thresholdResult: { passed: true, reason: null },
17
+ report: null,
18
+ durationMs: 4000,
19
+ error: null,
20
+ },
21
+ {
22
+ name: 'document-center',
23
+ passed: false,
24
+ thresholdResult: {
25
+ passed: false,
26
+ reason: 'Retained size delta 18.4 MB exceeds threshold 10.0 MB',
27
+ },
28
+ report: null,
29
+ durationMs: 6000,
30
+ error: null,
31
+ },
32
+ ],
33
+ };
34
+ }
35
+
36
+ describe('JunitReporter', () => {
37
+ let tmpDir: string;
38
+
39
+ beforeEach(() => {
40
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'junit-test-'));
41
+ });
42
+
43
+ afterEach(() => {
44
+ fs.rmSync(tmpDir, { recursive: true, force: true });
45
+ });
46
+
47
+ it('writes test-results.xml to outputDir', async () => {
48
+ const reporter = new JunitReporter();
49
+ await reporter.write(makeSummary(), tmpDir);
50
+ expect(fs.existsSync(path.join(tmpDir, 'test-results.xml'))).toBe(true);
51
+ });
52
+
53
+ it('XML contains one testcase per scenario', async () => {
54
+ const reporter = new JunitReporter();
55
+ await reporter.write(makeSummary(), tmpDir);
56
+ const xml = fs.readFileSync(path.join(tmpDir, 'test-results.xml'), 'utf8');
57
+ expect(xml).toContain('name="loan-pipeline"');
58
+ expect(xml).toContain('name="document-center"');
59
+ });
60
+
61
+ it('XML contains a failure element for failing scenarios', async () => {
62
+ const reporter = new JunitReporter();
63
+ await reporter.write(makeSummary(), tmpDir);
64
+ const xml = fs.readFileSync(path.join(tmpDir, 'test-results.xml'), 'utf8');
65
+ expect(xml).toContain('<failure');
66
+ expect(xml).toContain('18.4 MB');
67
+ });
68
+
69
+ it('testsuite has correct tests and failures count', async () => {
70
+ const reporter = new JunitReporter();
71
+ await reporter.write(makeSummary(), tmpDir);
72
+ const xml = fs.readFileSync(path.join(tmpDir, 'test-results.xml'), 'utf8');
73
+ expect(xml).toContain('tests="2"');
74
+ expect(xml).toContain('failures="1"');
75
+ });
76
+
77
+ it('XML content is properly escaped for special characters', async () => {
78
+ const reporter = new JunitReporter();
79
+ const summary: RunSummary = {
80
+ ...makeSummary(),
81
+ results: [
82
+ {
83
+ name: 'test-<scenario>',
84
+ passed: false,
85
+ thresholdResult: {
86
+ passed: false,
87
+ reason: 'Error: <leak> & "memory"',
88
+ },
89
+ report: null,
90
+ durationMs: 1000,
91
+ error: null,
92
+ },
93
+ ],
94
+ failCount: 1,
95
+ passCount: 0,
96
+ };
97
+ await reporter.write(summary, tmpDir);
98
+ const xml = fs.readFileSync(path.join(tmpDir, 'test-results.xml'), 'utf8');
99
+ expect(xml).toContain('&lt;scenario&gt;');
100
+ expect(xml).toContain('&amp;');
101
+ expect(xml).toContain('&quot;memory&quot;');
102
+ });
103
+ });
@@ -0,0 +1,39 @@
1
+ import {
2
+ AiEnhancer,
3
+ renderComparisonMarkdown,
4
+ type ComparisonReport,
5
+ type LeakResult,
6
+ } from '@elliemae/encw-heap-doctor';
7
+ import type { RunnerConfig } from '../types/config.js';
8
+
9
+ /**
10
+ * If the config carries an AI config, run the AiEnhancer over the report's leak
11
+ * results and return freshly-rendered markdown. If the AI call fails, log a
12
+ * single-line warning to stderr and return the report's original markdown so
13
+ * the scenario result is unaffected.
14
+ *
15
+ * If the config carries no AI config, return the report's original markdown
16
+ * untouched (no AI module loaded).
17
+ * @param report
18
+ * @param config
19
+ * @param scenarioName
20
+ */
21
+ export async function enhanceMarkdownIfConfigured(
22
+ report: ComparisonReport,
23
+ config: RunnerConfig,
24
+ scenarioName: string,
25
+ ): Promise<string> {
26
+ if (!config.ai?.apiKey) return report.markdown;
27
+
28
+ try {
29
+ const enhancer = new AiEnhancer(config.ai);
30
+ await enhancer.enhance(report.afterAnalysis.leakResults as LeakResult[]);
31
+ return renderComparisonMarkdown(report);
32
+ } catch (err) {
33
+ const message = err instanceof Error ? err.message : String(err);
34
+ process.stderr.write(
35
+ `Genice enhancement failed for ${scenarioName}: ${message}; writing report without AI section.\n`,
36
+ );
37
+ return report.markdown;
38
+ }
39
+ }
@@ -0,0 +1,71 @@
1
+ import type { MicroappLeakScenario } from '../types/scenario.js';
2
+ import type { RunnerConfig } from '../types/config.js';
3
+ import type { ScenarioResult, RunSummary } from '../types/results.js';
4
+ import type { ScenarioRegistry } from '../registry/scenarioRegistry.js';
5
+ import { ScenarioRunner } from './scenarioRunner.js';
6
+
7
+ function buildSummary(results: ScenarioResult[]): RunSummary {
8
+ return {
9
+ results,
10
+ totalDurationMs: results.reduce((acc, r) => acc + r.durationMs, 0),
11
+ passCount: results.filter((r) => r.passed).length,
12
+ failCount: results.filter((r) => !r.passed).length,
13
+ };
14
+ }
15
+
16
+ export class BatchRunner {
17
+ private readonly scenarioRunner: ScenarioRunner;
18
+
19
+ constructor(
20
+ private readonly config: RunnerConfig,
21
+ private readonly registry: ScenarioRegistry,
22
+ ) {
23
+ this.scenarioRunner = new ScenarioRunner(config);
24
+ }
25
+
26
+ async runAll(): Promise<RunSummary> {
27
+ const scenarios = this.registry.list().map((entry) => entry.scenario);
28
+ return this.executeScenarios(scenarios);
29
+ }
30
+
31
+ async runByTag(tag: string): Promise<RunSummary> {
32
+ const scenarios = this.registry
33
+ .filterByTag(tag)
34
+ .map((entry) => entry.scenario);
35
+ return this.executeScenarios(scenarios);
36
+ }
37
+
38
+ async runByName(key: string): Promise<RunSummary> {
39
+ const scenario = this.registry.get(key);
40
+ if (!scenario) {
41
+ throw new Error(`No scenario registered with key "${key}"`);
42
+ }
43
+ return this.executeScenarios([scenario]);
44
+ }
45
+
46
+ private async executeScenarios(
47
+ scenarios: readonly MicroappLeakScenario[],
48
+ ): Promise<RunSummary> {
49
+ const results = await scenarios.reduce<Promise<ScenarioResult[]>>(
50
+ async (acc, scenario) => {
51
+ const prior = await acc;
52
+ const result = await this.runOne(scenario);
53
+ return [...prior, result];
54
+ },
55
+ Promise.resolve([]),
56
+ );
57
+
58
+ return buildSummary(results);
59
+ }
60
+
61
+ private async runOne(
62
+ scenario: MicroappLeakScenario,
63
+ ): Promise<ScenarioResult> {
64
+ const browser = await this.scenarioRunner.launchBrowser();
65
+ try {
66
+ return await this.scenarioRunner.run(scenario, browser);
67
+ } finally {
68
+ await browser.close();
69
+ }
70
+ }
71
+ }
@@ -0,0 +1,189 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import {
4
+ chromium,
5
+ type Browser,
6
+ type Page,
7
+ type Frame,
8
+ } from '@playwright/test';
9
+ import { AuthManager, PageSetup } from '@elliemae/smoked-suite';
10
+ import type { ComparisonReport } from '@elliemae/encw-heap-doctor';
11
+ import { IframeHeapProfiler } from '../browser/iframeHeapProfiler.js';
12
+ import { ThresholdEvaluator } from '../analysis/thresholdEvaluator.js';
13
+ import { DEFAULT_THRESHOLDS } from '../types/config.js';
14
+ import type { RunnerConfig, ResolvedThresholds } from '../types/config.js';
15
+ import type { MicroappLeakScenario } from '../types/scenario.js';
16
+ import type { ScenarioResult } from '../types/results.js';
17
+ import { enhanceMarkdownIfConfigured } from './aiEnhancementStep.js';
18
+
19
+ async function resolveIframe(page: Page, selector: string): Promise<Frame> {
20
+ const handle = await page.locator(selector).elementHandle();
21
+ if (!handle) throw new Error(`Iframe not found for selector: ${selector}`);
22
+ const frame = await handle.contentFrame();
23
+ if (!frame)
24
+ throw new Error(`Could not get content frame for selector: ${selector}`);
25
+ return frame;
26
+ }
27
+
28
+ async function forceGarbageCollection(page: Page): Promise<void> {
29
+ const session = await page.context().newCDPSession(page);
30
+ await session.send('HeapProfiler.enable');
31
+ await session.send('HeapProfiler.collectGarbage');
32
+ await session.send('HeapProfiler.disable');
33
+ await session.detach();
34
+ await page.waitForTimeout(2000);
35
+ }
36
+
37
+ interface SnapshotPaths {
38
+ before: string;
39
+ after: string;
40
+ }
41
+
42
+ function cleanupSnapshots(paths: SnapshotPaths): void {
43
+ if (paths.before && fs.existsSync(paths.before)) fs.unlinkSync(paths.before);
44
+ if (paths.after && fs.existsSync(paths.after)) fs.unlinkSync(paths.after);
45
+ }
46
+
47
+ export class ScenarioRunner {
48
+ constructor(private readonly config: RunnerConfig) {}
49
+
50
+ async launchBrowser(): Promise<Browser> {
51
+ return chromium.launch({ headless: this.config.runner.headless });
52
+ }
53
+
54
+ async run(
55
+ scenario: MicroappLeakScenario,
56
+ browser: Browser,
57
+ ): Promise<ScenarioResult> {
58
+ const startTime = Date.now();
59
+ const context = await browser.newContext({
60
+ baseURL: this.config.env.baseUrl,
61
+ });
62
+ const page = await context.newPage();
63
+ const paths: SnapshotPaths = { before: '', after: '' };
64
+ let report: ComparisonReport | null = null;
65
+ let error: Error | null = null;
66
+
67
+ try {
68
+ report = await this.runScenarioInPage(page, scenario, paths);
69
+ } catch (err) {
70
+ error = err instanceof Error ? err : new Error(String(err));
71
+ } finally {
72
+ await context.close();
73
+ cleanupSnapshots(paths);
74
+ }
75
+
76
+ return this.buildResult({ scenario, report, error, startTime });
77
+ }
78
+
79
+ private async runScenarioInPage(
80
+ page: Page,
81
+ scenario: MicroappLeakScenario,
82
+ paths: SnapshotPaths,
83
+ ): Promise<ComparisonReport | null> {
84
+ const snapshotsDir = path.join(this.config.runner.outputDir, 'snapshots');
85
+ const { profiler, frame } = await this.setupAndProfile(
86
+ page,
87
+ scenario,
88
+ snapshotsDir,
89
+ );
90
+
91
+ paths.before = await profiler.captureSnapshot('before');
92
+ await this.repeatScenarioActions(scenario, page, frame);
93
+ await forceGarbageCollection(page);
94
+ paths.after = await profiler.captureSnapshot('after');
95
+
96
+ const report = await profiler.compare(
97
+ 'before',
98
+ 'after',
99
+ this.config.runner.topN,
100
+ );
101
+ if (report) await this.writeReport(report, scenario);
102
+ return report;
103
+ }
104
+
105
+ private async setupAndProfile(
106
+ page: Page,
107
+ scenario: MicroappLeakScenario,
108
+ snapshotsDir: string,
109
+ ): Promise<{ profiler: IframeHeapProfiler; frame: Frame }> {
110
+ const auth = new AuthManager();
111
+ const pageSetup = new PageSetup();
112
+
113
+ await pageSetup.apply(page);
114
+ await auth.login(page, {
115
+ username: this.config.env.userId,
116
+ password: this.config.env.password,
117
+ instanceId: this.config.env.instanceId,
118
+ });
119
+ await page.goto(scenario.url());
120
+
121
+ const frame = await resolveIframe(page, scenario.microappSelector);
122
+ fs.mkdirSync(snapshotsDir, { recursive: true });
123
+ const profiler = new IframeHeapProfiler(page, frame, snapshotsDir);
124
+ return { profiler, frame };
125
+ }
126
+
127
+ private async repeatScenarioActions(
128
+ scenario: MicroappLeakScenario,
129
+ page: Page,
130
+ frame: Frame,
131
+ ): Promise<void> {
132
+ const repeatCount = scenario.repeat?.() ?? 3;
133
+ for (let i = 0; i < repeatCount; i += 1) {
134
+ // eslint-disable-next-line no-await-in-loop
135
+ await scenario.action(page, frame);
136
+ if (scenario.back) {
137
+ // eslint-disable-next-line no-await-in-loop
138
+ await scenario.back(page);
139
+ }
140
+ }
141
+ }
142
+
143
+ private async writeReport(
144
+ report: ComparisonReport,
145
+ scenario: MicroappLeakScenario,
146
+ ): Promise<void> {
147
+ const reportPath = path.join(
148
+ this.config.runner.outputDir,
149
+ `${scenario.id}.md`,
150
+ );
151
+ fs.mkdirSync(path.dirname(reportPath), { recursive: true });
152
+ const markdown = await enhanceMarkdownIfConfigured(
153
+ report,
154
+ this.config,
155
+ scenario.name,
156
+ );
157
+ fs.writeFileSync(reportPath, markdown);
158
+ }
159
+
160
+ private buildResult(input: {
161
+ scenario: MicroappLeakScenario;
162
+ report: ComparisonReport | null;
163
+ error: Error | null;
164
+ startTime: number;
165
+ }): ScenarioResult {
166
+ const { scenario, report, error, startTime } = input;
167
+ const thresholds: ResolvedThresholds = {
168
+ ...DEFAULT_THRESHOLDS,
169
+ ...scenario.thresholds,
170
+ };
171
+ const failureFallback = {
172
+ passed: false,
173
+ reason: error?.message ?? 'Snapshot capture failed',
174
+ };
175
+ const thresholdResult =
176
+ report && !error
177
+ ? ThresholdEvaluator.evaluate(report, thresholds)
178
+ : failureFallback;
179
+
180
+ return {
181
+ name: scenario.name,
182
+ passed: thresholdResult.passed,
183
+ thresholdResult,
184
+ report,
185
+ durationMs: Date.now() - startTime,
186
+ error,
187
+ };
188
+ }
189
+ }
@@ -0,0 +1,174 @@
1
+ import type { ComparisonReport } from '@elliemae/encw-heap-doctor';
2
+ import { enhanceMarkdownIfConfigured } from '../aiEnhancementStep.js';
3
+ import type { RunnerConfig } from '../../types/config.js';
4
+
5
+ const mockEnhance = jest.fn<Promise<void>, [unknown]>();
6
+ const mockRender = jest.fn<string, [unknown]>();
7
+ const mockAiEnhancerCtor = jest.fn<{ enhance: typeof mockEnhance }, [unknown]>(
8
+ () => ({ enhance: mockEnhance }),
9
+ );
10
+
11
+ jest.mock('@elliemae/encw-heap-doctor', () => ({
12
+ AiEnhancer: jest.fn((config: unknown) => mockAiEnhancerCtor(config)),
13
+ renderComparisonMarkdown: (report: unknown): string => mockRender(report),
14
+ }));
15
+
16
+ function makeReport(): ComparisonReport {
17
+ return {
18
+ beforePath: '/tmp/before.heapsnapshot',
19
+ afterPath: '/tmp/after.heapsnapshot',
20
+ beforeNodeCount: 100,
21
+ afterNodeCount: 200,
22
+ delta: {
23
+ newNodeCount: 100,
24
+ removedNodeCount: 0,
25
+ retainedSizeDelta: 0,
26
+ newLeakGroups: [],
27
+ detachedDomDelta: new Map(),
28
+ },
29
+ afterAnalysis: {
30
+ filePath: '/tmp/after.heapsnapshot',
31
+ nodeCount: 200,
32
+ edgeCount: 400,
33
+ leakResults: [],
34
+ fixPriority: [],
35
+ markdown: 'after-md',
36
+ },
37
+ markdown: 'ORIGINAL-MARKDOWN',
38
+ };
39
+ }
40
+
41
+ function configWithAi(
42
+ aiOverrides: Partial<RunnerConfig['ai']> = {},
43
+ ): RunnerConfig {
44
+ return {
45
+ env: {
46
+ baseUrl: 'https://q3.elliemae.io',
47
+ instanceId: 'BE11226875',
48
+ userId: 'admin',
49
+ password: 'pw',
50
+ },
51
+ runner: { headless: true, outputDir: '/tmp', topN: 5 },
52
+ ai: {
53
+ model: 'Claude3.7',
54
+ temperature: 0.3,
55
+ apiKey: 'sk-abc',
56
+ ...aiOverrides,
57
+ },
58
+ };
59
+ }
60
+
61
+ function configWithoutAi(): RunnerConfig {
62
+ return {
63
+ env: {
64
+ baseUrl: 'https://q3.elliemae.io',
65
+ instanceId: 'BE11226875',
66
+ userId: 'admin',
67
+ password: 'pw',
68
+ },
69
+ runner: { headless: true, outputDir: '/tmp', topN: 5 },
70
+ };
71
+ }
72
+
73
+ describe('enhanceMarkdownIfConfigured', () => {
74
+ type StdSpy = jest.SpyInstance<boolean, [string | Uint8Array, ...unknown[]]>;
75
+ let stderrSpy: StdSpy;
76
+
77
+ beforeEach(() => {
78
+ mockEnhance.mockReset();
79
+ mockRender.mockReset();
80
+ mockAiEnhancerCtor.mockClear();
81
+ stderrSpy = jest
82
+ .spyOn(process.stderr, 'write')
83
+ .mockImplementation(() => true) as StdSpy;
84
+ });
85
+
86
+ afterEach(() => {
87
+ stderrSpy.mockRestore();
88
+ });
89
+
90
+ it('returns report.markdown unchanged when config.ai is undefined', async () => {
91
+ const report = makeReport();
92
+ const md = await enhanceMarkdownIfConfigured(
93
+ report,
94
+ configWithoutAi(),
95
+ 'scn',
96
+ );
97
+ expect(md).toBe('ORIGINAL-MARKDOWN');
98
+ expect(mockAiEnhancerCtor).not.toHaveBeenCalled();
99
+ expect(mockEnhance).not.toHaveBeenCalled();
100
+ expect(mockRender).not.toHaveBeenCalled();
101
+ });
102
+
103
+ it('passes config.ai through to the AiEnhancer constructor when ai is configured', async () => {
104
+ mockEnhance.mockResolvedValue(undefined);
105
+ mockRender.mockReturnValue('REGENERATED');
106
+ await enhanceMarkdownIfConfigured(
107
+ makeReport(),
108
+ configWithAi({ apiKey: 'sk-x', model: 'GPT4', temperature: 0.7 }),
109
+ 'scn',
110
+ );
111
+ expect(mockAiEnhancerCtor).toHaveBeenCalledWith({
112
+ apiKey: 'sk-x',
113
+ model: 'GPT4',
114
+ temperature: 0.7,
115
+ });
116
+ });
117
+
118
+ it('calls enhance with afterAnalysis.leakResults when ai is configured', async () => {
119
+ mockEnhance.mockResolvedValue(undefined);
120
+ mockRender.mockReturnValue('REGENERATED');
121
+ const report = makeReport();
122
+ await enhanceMarkdownIfConfigured(report, configWithAi(), 'scn');
123
+ expect(mockEnhance).toHaveBeenCalledTimes(1);
124
+ expect(mockEnhance).toHaveBeenCalledWith(report.afterAnalysis.leakResults);
125
+ });
126
+
127
+ it('returns the re-rendered markdown on AI success', async () => {
128
+ mockEnhance.mockResolvedValue(undefined);
129
+ mockRender.mockReturnValue('FRESH-MARKDOWN-WITH-AI');
130
+ const md = await enhanceMarkdownIfConfigured(
131
+ makeReport(),
132
+ configWithAi(),
133
+ 'scn',
134
+ );
135
+ expect(md).toBe('FRESH-MARKDOWN-WITH-AI');
136
+ });
137
+
138
+ it('returns the original report.markdown when AI throws', async () => {
139
+ mockEnhance.mockRejectedValue(new Error('network down'));
140
+ const md = await enhanceMarkdownIfConfigured(
141
+ makeReport(),
142
+ configWithAi(),
143
+ 'export-navigation',
144
+ );
145
+ expect(md).toBe('ORIGINAL-MARKDOWN');
146
+ expect(mockRender).not.toHaveBeenCalled();
147
+ });
148
+
149
+ it('writes a single-line stderr warning naming the scenario when AI throws', async () => {
150
+ mockEnhance.mockRejectedValue(new Error('boom'));
151
+ await enhanceMarkdownIfConfigured(
152
+ makeReport(),
153
+ configWithAi(),
154
+ 'export-navigation',
155
+ );
156
+ expect(stderrSpy).toHaveBeenCalledTimes(1);
157
+ const message = String(stderrSpy.mock.calls[0]?.[0]);
158
+ expect(message).toContain('export-navigation');
159
+ expect(message).toContain('boom');
160
+ expect(message).toMatch(/\n$/);
161
+ });
162
+
163
+ it('handles non-Error rejections by stringifying them', async () => {
164
+ mockEnhance.mockRejectedValue('weird-string-rejection');
165
+ const md = await enhanceMarkdownIfConfigured(
166
+ makeReport(),
167
+ configWithAi(),
168
+ 'scn',
169
+ );
170
+ expect(md).toBe('ORIGINAL-MARKDOWN');
171
+ const message = String(stderrSpy.mock.calls[0]?.[0]);
172
+ expect(message).toContain('weird-string-rejection');
173
+ });
174
+ });