@elliemae/encw-leak-runner 1.0.13 → 1.0.15

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 (144) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +63 -0
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/bin/leak-runner.js +258 -29
  5. package/dist/cjs/browser/heapMemoryProfiler.js +182 -0
  6. package/dist/cjs/browser/iframeHeapProfiler.js +2 -2
  7. package/dist/cjs/index.js +2 -0
  8. package/dist/cjs/runner/scenarioRunner.js +9 -6
  9. package/dist/cjs/scenarios/index.js +7 -4
  10. package/dist/cjs/scenarios/one-admin/index.js +5 -1
  11. package/dist/cjs/scenarios/pipeline/index.js +31 -0
  12. package/dist/cjs/scenarios/pipeline/page-models/PipelinePageModel.js +52 -0
  13. package/dist/cjs/scenarios/pipeline/page-models/index.js +24 -0
  14. package/dist/cjs/scenarios/pipeline/pipeline-task-navigation.scenario.js +56 -0
  15. package/dist/esm/browser/heapMemoryProfiler.js +152 -0
  16. package/dist/esm/browser/iframeHeapProfiler.js +1 -1
  17. package/dist/esm/index.js +2 -0
  18. package/dist/esm/runner/scenarioRunner.js +9 -6
  19. package/dist/esm/scenarios/index.js +8 -5
  20. package/dist/esm/scenarios/one-admin/index.js +5 -1
  21. package/dist/esm/scenarios/pipeline/index.js +11 -0
  22. package/dist/esm/scenarios/pipeline/page-models/PipelinePageModel.js +32 -0
  23. package/dist/esm/scenarios/pipeline/page-models/index.js +4 -0
  24. package/dist/esm/scenarios/pipeline/pipeline-task-navigation.scenario.js +36 -0
  25. package/dist/types/lib/browser/heapMemoryProfiler.d.ts +149 -0
  26. package/dist/types/lib/browser/iframeHeapProfiler.d.ts +1 -1
  27. package/dist/types/lib/browser/tests/heapMemoryProfiler.test.d.ts +1 -0
  28. package/dist/types/lib/config/runnerConfigSchema.d.ts +6 -6
  29. package/dist/types/lib/index.d.ts +2 -0
  30. package/dist/types/lib/scenarios/one-admin/index.d.ts +2 -2
  31. package/dist/types/lib/scenarios/pipeline/index.d.ts +2 -0
  32. package/dist/types/lib/scenarios/pipeline/page-models/PipelinePageModel.d.ts +14 -0
  33. package/dist/types/lib/scenarios/pipeline/page-models/index.d.ts +1 -0
  34. package/dist/types/lib/scenarios/pipeline/pipeline-task-navigation.scenario.d.ts +2 -0
  35. package/leak-runner.config.json +1 -1
  36. package/lib/browser/heapMemoryProfiler.ts +284 -0
  37. package/lib/browser/iframeHeapProfiler.ts +1 -1
  38. package/lib/browser/tests/heapMemoryProfiler.test.ts +64 -0
  39. package/lib/browser/tests/iframeHeapProfiler.test.ts +1 -1
  40. package/lib/index.ts +2 -0
  41. package/lib/runner/scenarioRunner.ts +9 -6
  42. package/lib/scenarios/index.ts +8 -6
  43. package/lib/scenarios/one-admin/index.ts +7 -1
  44. package/lib/scenarios/pipeline/index.ts +12 -0
  45. package/lib/scenarios/pipeline/page-models/PipelinePageModel.ts +46 -0
  46. package/lib/scenarios/pipeline/page-models/index.ts +1 -0
  47. package/lib/scenarios/pipeline/pipeline-task-navigation.scenario.ts +42 -0
  48. package/package.json +5 -4
  49. package/reports/analysis/index.html +1 -1
  50. package/reports/analysis/thresholdEvaluator.ts.html +1 -1
  51. package/reports/browser/heapMemoryProfiler.ts.html +937 -0
  52. package/reports/browser/iframeHeapProfiler.ts.html +5 -5
  53. package/reports/browser/index.html +25 -10
  54. package/reports/cli/commands/index.html +1 -1
  55. package/reports/cli/commands/listCommand.ts.html +1 -1
  56. package/reports/cli/commands/runCommand.ts.html +1 -1
  57. package/reports/cli/index.html +1 -1
  58. package/reports/cli/index.ts.html +1 -1
  59. package/reports/config/index.html +1 -1
  60. package/reports/config/missingRequiredParamError.ts.html +1 -1
  61. package/reports/config/requiredEnvParams.ts.html +1 -1
  62. package/reports/config/runnerConfigLoader.ts.html +1 -1
  63. package/reports/config/runnerConfigSchema.ts.html +1 -1
  64. package/reports/config/sources/cliOverrideConfigSource.ts.html +1 -1
  65. package/reports/config/sources/configSource.ts.html +1 -1
  66. package/reports/config/sources/envVarConfigSource.ts.html +1 -1
  67. package/reports/config/sources/fileConfigSource.ts.html +1 -1
  68. package/reports/config/sources/index.html +1 -1
  69. package/reports/config/unknownEnvError.ts.html +1 -1
  70. package/reports/index.html +63 -33
  71. package/reports/lcov-report/analysis/index.html +1 -1
  72. package/reports/lcov-report/analysis/thresholdEvaluator.ts.html +1 -1
  73. package/reports/lcov-report/browser/heapMemoryProfiler.ts.html +937 -0
  74. package/reports/lcov-report/browser/iframeHeapProfiler.ts.html +5 -5
  75. package/reports/lcov-report/browser/index.html +25 -10
  76. package/reports/lcov-report/cli/commands/index.html +1 -1
  77. package/reports/lcov-report/cli/commands/listCommand.ts.html +1 -1
  78. package/reports/lcov-report/cli/commands/runCommand.ts.html +1 -1
  79. package/reports/lcov-report/cli/index.html +1 -1
  80. package/reports/lcov-report/cli/index.ts.html +1 -1
  81. package/reports/lcov-report/config/index.html +1 -1
  82. package/reports/lcov-report/config/missingRequiredParamError.ts.html +1 -1
  83. package/reports/lcov-report/config/requiredEnvParams.ts.html +1 -1
  84. package/reports/lcov-report/config/runnerConfigLoader.ts.html +1 -1
  85. package/reports/lcov-report/config/runnerConfigSchema.ts.html +1 -1
  86. package/reports/lcov-report/config/sources/cliOverrideConfigSource.ts.html +1 -1
  87. package/reports/lcov-report/config/sources/configSource.ts.html +1 -1
  88. package/reports/lcov-report/config/sources/envVarConfigSource.ts.html +1 -1
  89. package/reports/lcov-report/config/sources/fileConfigSource.ts.html +1 -1
  90. package/reports/lcov-report/config/sources/index.html +1 -1
  91. package/reports/lcov-report/config/unknownEnvError.ts.html +1 -1
  92. package/reports/lcov-report/index.html +63 -33
  93. package/reports/lcov-report/registry/index.html +1 -1
  94. package/reports/lcov-report/registry/scenarioRegistry.ts.html +1 -1
  95. package/reports/lcov-report/reporting/consoleReporter.ts.html +1 -1
  96. package/reports/lcov-report/reporting/index.html +1 -1
  97. package/reports/lcov-report/reporting/junitReporter.ts.html +1 -1
  98. package/reports/lcov-report/runner/aiEnhancementStep.ts.html +1 -1
  99. package/reports/lcov-report/runner/batchRunner.ts.html +1 -1
  100. package/reports/lcov-report/runner/index.html +15 -15
  101. package/reports/lcov-report/runner/scenarioRunner.ts.html +23 -14
  102. package/reports/lcov-report/scenarios/index.html +8 -8
  103. package/reports/lcov-report/scenarios/index.ts.html +20 -14
  104. package/reports/lcov-report/scenarios/one-admin/export-navigation.scenario.ts.html +1 -1
  105. package/reports/lcov-report/scenarios/one-admin/index.html +5 -5
  106. package/reports/lcov-report/scenarios/one-admin/index.ts.html +24 -6
  107. package/reports/lcov-report/scenarios/one-admin/page-models/AdminLandingPageModel.ts.html +1 -1
  108. package/reports/lcov-report/scenarios/one-admin/page-models/ExportPageModel.ts.html +1 -1
  109. package/reports/lcov-report/scenarios/one-admin/page-models/SelectSettingsPageModel.ts.html +1 -1
  110. package/reports/lcov-report/scenarios/one-admin/page-models/index.html +1 -1
  111. package/reports/lcov-report/scenarios/pipeline/index.html +131 -0
  112. package/reports/lcov-report/scenarios/pipeline/index.ts.html +121 -0
  113. package/reports/lcov-report/scenarios/pipeline/page-models/PipelinePageModel.ts.html +223 -0
  114. package/reports/lcov-report/scenarios/pipeline/page-models/index.html +116 -0
  115. package/reports/lcov-report/scenarios/pipeline/pipeline-task-navigation.scenario.ts.html +211 -0
  116. package/reports/lcov-report/types/config.ts.html +1 -1
  117. package/reports/lcov-report/types/index.html +1 -1
  118. package/reports/lcov.info +225 -40
  119. package/reports/registry/index.html +1 -1
  120. package/reports/registry/scenarioRegistry.ts.html +1 -1
  121. package/reports/reporting/consoleReporter.ts.html +1 -1
  122. package/reports/reporting/index.html +1 -1
  123. package/reports/reporting/junitReporter.ts.html +1 -1
  124. package/reports/runner/aiEnhancementStep.ts.html +1 -1
  125. package/reports/runner/batchRunner.ts.html +1 -1
  126. package/reports/runner/index.html +15 -15
  127. package/reports/runner/scenarioRunner.ts.html +23 -14
  128. package/reports/scenarios/index.html +8 -8
  129. package/reports/scenarios/index.ts.html +20 -14
  130. package/reports/scenarios/one-admin/export-navigation.scenario.ts.html +1 -1
  131. package/reports/scenarios/one-admin/index.html +5 -5
  132. package/reports/scenarios/one-admin/index.ts.html +24 -6
  133. package/reports/scenarios/one-admin/page-models/AdminLandingPageModel.ts.html +1 -1
  134. package/reports/scenarios/one-admin/page-models/ExportPageModel.ts.html +1 -1
  135. package/reports/scenarios/one-admin/page-models/SelectSettingsPageModel.ts.html +1 -1
  136. package/reports/scenarios/one-admin/page-models/index.html +1 -1
  137. package/reports/scenarios/pipeline/index.html +131 -0
  138. package/reports/scenarios/pipeline/index.ts.html +121 -0
  139. package/reports/scenarios/pipeline/page-models/PipelinePageModel.ts.html +223 -0
  140. package/reports/scenarios/pipeline/page-models/index.html +116 -0
  141. package/reports/scenarios/pipeline/pipeline-task-navigation.scenario.ts.html +211 -0
  142. package/reports/types/config.ts.html +1 -1
  143. package/reports/types/index.html +1 -1
  144. package/test-report.xml +57 -53
@@ -4,15 +4,163 @@
4
4
  import { Command } from "commander";
5
5
 
6
6
  // lib/runner/scenarioRunner.ts
7
- import fs from "node:fs";
8
- import path from "node:path";
7
+ import fs2 from "node:fs";
8
+ import path2 from "node:path";
9
9
  import {
10
10
  chromium
11
11
  } from "@playwright/test";
12
12
  import { AuthManager, PageSetup } from "@elliemae/smoked-suite";
13
13
 
14
+ // lib/browser/heapMemoryProfiler.ts
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import { HeapDoctor } from "@elliemae/encw-heap-doctor";
18
+ var HeapMemoryProfiler = class {
19
+ /**
20
+ * @param {Page} page - The Playwright `Page` instance for the current test.
21
+ * @param {string} outputDir - Directory where `.heapsnapshot` and `.md` files are written.
22
+ * Created automatically if it does not exist.
23
+ */
24
+ constructor(page, outputDir) {
25
+ this.page = page;
26
+ this.outputDir = outputDir;
27
+ }
28
+ /** Memento store: maps snapshot label → absolute file path on disk. */
29
+ snapshots = /* @__PURE__ */ new Map();
30
+ getCDPTarget() {
31
+ return this.page;
32
+ }
33
+ // ─── Browser guard ──────────────────────────────────────────────────────────
34
+ isChromium() {
35
+ return this.page.context().browser()?.browserType().name() === "chromium";
36
+ }
37
+ // ─── Public API ─────────────────────────────────────────────────────────────
38
+ /**
39
+ * Capture a Chrome heap snapshot via CDP and persist it to disk.
40
+ *
41
+ * The snapshot is streamed in chunks via `HeapProfiler.addHeapSnapshotChunk`,
42
+ * joined, and written as a single `.heapsnapshot` file. The file path is cached
43
+ * internally under `label` so {@link compare} can resolve it by name.
44
+ * @param {string} label - Logical name for this snapshot (e.g. `'before'`, `'after'`).
45
+ * Used as the filename prefix and as the Memento key.
46
+ * @returns {Promise<string>} Absolute path to the written file, or `''` on non-Chromium browsers.
47
+ */
48
+ async captureSnapshot(label) {
49
+ if (!this.isChromium()) return "";
50
+ const client = await this.page.context().newCDPSession(this.getCDPTarget());
51
+ await client.send("HeapProfiler.enable");
52
+ fs.mkdirSync(this.outputDir, { recursive: true });
53
+ const filePath = path.join(
54
+ this.outputDir,
55
+ `${label}-${Date.now()}.heapsnapshot`
56
+ );
57
+ const writeStream = fs.createWriteStream(filePath);
58
+ client.on(
59
+ "HeapProfiler.addHeapSnapshotChunk",
60
+ ({ chunk }) => {
61
+ writeStream.write(chunk);
62
+ }
63
+ );
64
+ await client.send("HeapProfiler.takeHeapSnapshot", {
65
+ reportProgress: false
66
+ });
67
+ await client.send("HeapProfiler.disable");
68
+ await client.detach();
69
+ await new Promise((resolve, reject) => {
70
+ writeStream.end((err) => {
71
+ if (err) reject(err);
72
+ else resolve();
73
+ });
74
+ });
75
+ this.snapshots.set(label, filePath);
76
+ return filePath;
77
+ }
78
+ /**
79
+ * Compare two previously captured snapshots by their labels.
80
+ *
81
+ * Runs `HeapDoctor.compare()` on the resolved file paths and writes a
82
+ * markdown report to {@link outputDir}.
83
+ * @param {CompareParams} params - {@link CompareParams}
84
+ * @returns {Promise<ComparisonReport | null>} `ComparisonReport`, or `null` on non-Chromium browsers.
85
+ * @throws If either label has no cached snapshot path.
86
+ * @throws If `HeapDoctor.compare` returns `ok: false`.
87
+ */
88
+ async compare(params) {
89
+ const { beforeLabel, afterLabel, topN, originAllowList } = params;
90
+ if (!this.isChromium()) return null;
91
+ const before = this.snapshots.get(beforeLabel);
92
+ const after = this.snapshots.get(afterLabel);
93
+ if (!before || !after) {
94
+ throw new Error(
95
+ `HeapMemoryProfiler: snapshot not found for labels "${beforeLabel}" / "${afterLabel}". Call captureSnapshot() before compare().`
96
+ );
97
+ }
98
+ const result = await new HeapDoctor({ topN, originAllowList }).compare(
99
+ before,
100
+ after
101
+ );
102
+ if (!result.ok) throw result.error;
103
+ fs.writeFileSync(
104
+ path.join(this.outputDir, `comparison-${Date.now()}.md`),
105
+ result.value.markdown
106
+ );
107
+ return result.value;
108
+ }
109
+ /**
110
+ * Orchestrate the full heap profiling sequence for a user flow.
111
+ *
112
+ * Sequence (Template Method):
113
+ * 1. Capture **before** snapshot
114
+ * 2. Execute `flow` exactly `heapIterations` times
115
+ * 3. Capture **after** snapshot
116
+ * 4. Run `HeapDoctor.compare` and write the markdown report
117
+ * 5. If `failOnLeak` is `true` and `retainedSizeDelta > leakThresholdBytes`, throw
118
+ *
119
+ * On non-Chromium browsers: `flow` still runs `heapIterations` times,
120
+ * no snapshots are taken, and `null` is returned.
121
+ * @param {() => Promise<void>} flow - Async function containing the user actions to profile.
122
+ * @param {HeapProfilingOptions} [options] - {@link HeapProfilingOptions}
123
+ * @returns {Promise<ComparisonReport | null>} `ComparisonReport` on Chromium, `null` on all other browsers.
124
+ */
125
+ async withProfiling(flow, options = {}) {
126
+ const {
127
+ heapIterations = 1,
128
+ label = "flow",
129
+ topN,
130
+ failOnLeak = false,
131
+ leakThresholdBytes = 0,
132
+ originAllowList
133
+ } = options;
134
+ if (!this.isChromium()) {
135
+ for (let i = 0; i < heapIterations; i += 1) await flow();
136
+ return null;
137
+ }
138
+ await this.captureSnapshot(`${label}-before`);
139
+ for (let i = 0; i < heapIterations; i += 1) await flow();
140
+ await this.captureSnapshot(`${label}-after`);
141
+ const report = await this.compare({
142
+ beforeLabel: `${label}-before`,
143
+ afterLabel: `${label}-after`,
144
+ topN,
145
+ originAllowList
146
+ });
147
+ if (report && failOnLeak && report.delta.retainedSizeDelta > leakThresholdBytes) {
148
+ throw new Error(
149
+ `Memory leak detected: retained size grew by ${report.delta.retainedSizeDelta} bytes (threshold: ${leakThresholdBytes} bytes). New leak groups: ${report.delta.newLeakGroups.length}. See: ${this.outputDir}`
150
+ );
151
+ }
152
+ return report;
153
+ }
154
+ /**
155
+ * Clear the internal snapshot label cache.
156
+ * Call in `afterEach` to prevent snapshot paths bleeding across tests.
157
+ */
158
+ clearSnapshots() {
159
+ this.snapshots.clear();
160
+ }
161
+ };
162
+
14
163
  // lib/browser/iframeHeapProfiler.ts
15
- import { HeapMemoryProfiler } from "@elliemae/smoked-suite";
16
164
  var IframeHeapProfiler = class extends HeapMemoryProfiler {
17
165
  constructor(profiledPage, frame, outputDir) {
18
166
  super(profiledPage, outputDir);
@@ -104,8 +252,8 @@ async function forceGarbageCollection(page) {
104
252
  await page.waitForTimeout(2e3);
105
253
  }
106
254
  function cleanupSnapshots(paths) {
107
- if (paths.before && fs.existsSync(paths.before)) fs.unlinkSync(paths.before);
108
- if (paths.after && fs.existsSync(paths.after)) fs.unlinkSync(paths.after);
255
+ if (paths.before && fs2.existsSync(paths.before)) fs2.unlinkSync(paths.before);
256
+ if (paths.after && fs2.existsSync(paths.after)) fs2.unlinkSync(paths.after);
109
257
  }
110
258
  var ScenarioRunner = class {
111
259
  constructor(config) {
@@ -135,7 +283,7 @@ var ScenarioRunner = class {
135
283
  return this.buildResult({ scenario, report, error, startTime });
136
284
  }
137
285
  async runScenarioInPage(page, scenario, paths) {
138
- const snapshotsDir = path.join(this.config.runner.outputDir, "snapshots");
286
+ const snapshotsDir = path2.join(this.config.runner.outputDir, "snapshots");
139
287
  const { profiler, frame } = await this.setupAndProfile(
140
288
  page,
141
289
  scenario,
@@ -146,11 +294,12 @@ var ScenarioRunner = class {
146
294
  await this.repeatScenarioActions(scenario, page, frame);
147
295
  await forceGarbageCollection(page);
148
296
  paths.after = await profiler.captureSnapshot("after");
149
- const report = await profiler.compare(
150
- "before",
151
- "after",
152
- this.config.runner.topN
153
- );
297
+ const report = await profiler.compare({
298
+ beforeLabel: "before",
299
+ afterLabel: "after",
300
+ topN: this.config.runner.topN,
301
+ originAllowList: [new URL(this.config.env.baseUrl).origin]
302
+ });
154
303
  if (report) await this.writeReport(report, scenario);
155
304
  return report;
156
305
  }
@@ -166,13 +315,15 @@ var ScenarioRunner = class {
166
315
  });
167
316
  process.stderr.write(`[scenarioRunner] post-login URL = ${page.url()}
168
317
  `);
169
- await page.waitForURL(`**${scenario.url()}**`, { timeout: 3e4 });
318
+ if (!page.url().includes(scenario.url())) {
319
+ await page.goto(scenario.url(), { waitUntil: "load", timeout: 3e4 });
320
+ }
170
321
  process.stderr.write(
171
322
  `[scenarioRunner] settled URL before iframe = ${page.url()}
172
323
  `
173
324
  );
174
325
  const frame = await resolveIframe(page, scenario.microappSelector);
175
- fs.mkdirSync(snapshotsDir, { recursive: true });
326
+ fs2.mkdirSync(snapshotsDir, { recursive: true });
176
327
  const profiler = new IframeHeapProfiler(page, frame, snapshotsDir);
177
328
  return { profiler, frame };
178
329
  }
@@ -186,17 +337,17 @@ var ScenarioRunner = class {
186
337
  }
187
338
  }
188
339
  async writeReport(report, scenario) {
189
- const reportPath = path.join(
340
+ const reportPath = path2.join(
190
341
  this.config.runner.outputDir,
191
342
  `${scenario.id}.md`
192
343
  );
193
- fs.mkdirSync(path.dirname(reportPath), { recursive: true });
344
+ fs2.mkdirSync(path2.dirname(reportPath), { recursive: true });
194
345
  const markdown = await enhanceMarkdownIfConfigured(
195
346
  report,
196
347
  this.config,
197
348
  scenario.name
198
349
  );
199
- fs.writeFileSync(reportPath, markdown);
350
+ fs2.writeFileSync(reportPath, markdown);
200
351
  }
201
352
  buildResult(input) {
202
353
  const { scenario, report, error, startTime } = input;
@@ -312,8 +463,8 @@ var ConsoleReporter = class {
312
463
  };
313
464
 
314
465
  // lib/reporting/junitReporter.ts
315
- import fs2 from "node:fs";
316
- import path2 from "node:path";
466
+ import fs3 from "node:fs";
467
+ import path3 from "node:path";
317
468
  function escapeXml(str) {
318
469
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
319
470
  }
@@ -349,8 +500,8 @@ var JunitReporter = class {
349
500
  " </testsuite>",
350
501
  "</testsuites>"
351
502
  ].join("\n");
352
- fs2.mkdirSync(outputDir, { recursive: true });
353
- fs2.writeFileSync(path2.join(outputDir, "test-results.xml"), xml, "utf8");
503
+ fs3.mkdirSync(outputDir, { recursive: true });
504
+ fs3.writeFileSync(path3.join(outputDir, "test-results.xml"), xml, "utf8");
354
505
  return Promise.resolve();
355
506
  }
356
507
  };
@@ -398,7 +549,7 @@ var EnvVarConfigSource = class {
398
549
  };
399
550
 
400
551
  // lib/config/sources/fileConfigSource.ts
401
- import fs3 from "node:fs";
552
+ import fs4 from "node:fs";
402
553
 
403
554
  // lib/config/runnerConfigSchema.ts
404
555
  import { z } from "zod";
@@ -428,8 +579,8 @@ var FileConfigSource = class {
428
579
  priority = 1;
429
580
  name = "file";
430
581
  load() {
431
- if (!fs3.existsSync(this.filePath)) return {};
432
- const raw = fs3.readFileSync(this.filePath, "utf8");
582
+ if (!fs4.existsSync(this.filePath)) return {};
583
+ const raw = fs4.readFileSync(this.filePath, "utf8");
433
584
  let parsed;
434
585
  try {
435
586
  parsed = JSON.parse(raw);
@@ -805,8 +956,8 @@ var exportNavigationScenario = {
805
956
  await page.frameLocator(IFRAME_SELECTOR2).getByText("ONE ADMIN CONSOLE").waitFor({ state: "visible", timeout: 12e4 });
806
957
  },
807
958
  async action(page, frame) {
808
- const path3 = new URL(page.url()).pathname.replace(/\/$/, "");
809
- if (path3 === "/admin") {
959
+ const path4 = new URL(page.url()).pathname.replace(/\/$/, "");
960
+ if (path4 === "/admin") {
810
961
  await page.frameLocator(IFRAME_SELECTOR2).getByText("ONE ADMIN CONSOLE").click();
811
962
  }
812
963
  const settings = new SelectSettingsPageModel(frame);
@@ -832,12 +983,90 @@ var exportNavigationScenario = {
832
983
  var oneAdminScenarios = [
833
984
  exportNavigationScenario
834
985
  ];
835
-
836
- // lib/scenarios/index.ts
837
- var scenarioRegistry = new ScenarioRegistry().register({
986
+ var oneAdminScenarioGroup = {
838
987
  microapp: "one-admin",
839
988
  scenarios: oneAdminScenarios
840
- });
989
+ };
990
+
991
+ // lib/scenarios/pipeline/page-models/PipelinePageModel.ts
992
+ var PipelinePageModel = class _PipelinePageModel {
993
+ constructor(page) {
994
+ this.page = page;
995
+ }
996
+ static IFRAME_SELECTOR = '[data-testid="pui-iframe-container-pipelineui"]';
997
+ static TASKS_IFRAME_SELECTOR = '[data-testid="pui-iframe-container-taskspipeline"]';
998
+ static SPINNER_TEST_ID = "ds-circularindeterminateindicator-svg";
999
+ async waitForPipelineReady() {
1000
+ await this.#waitForSpinner(_PipelinePageModel.IFRAME_SELECTOR);
1001
+ }
1002
+ async clickTasksTab() {
1003
+ await this.page.getByRole("tab", { name: "Tasks" }).click();
1004
+ }
1005
+ async clickPipelineTab() {
1006
+ await this.page.getByRole("tab", { name: "Loans" }).click();
1007
+ }
1008
+ async waitForPipelineSpinner() {
1009
+ await this.#waitForSpinner(_PipelinePageModel.IFRAME_SELECTOR);
1010
+ }
1011
+ async waitForTasksReady() {
1012
+ await this.page.locator(_PipelinePageModel.TASKS_IFRAME_SELECTOR).contentFrame().getByTestId("circular-indicator").waitFor({ state: "hidden" });
1013
+ }
1014
+ async #waitForSpinner(iframeSelector) {
1015
+ const spinner = this.page.frameLocator(iframeSelector).getByTestId(_PipelinePageModel.SPINNER_TEST_ID);
1016
+ await spinner.waitFor({ state: "visible" });
1017
+ await spinner.waitFor({ state: "hidden" });
1018
+ }
1019
+ };
1020
+
1021
+ // lib/scenarios/pipeline/pipeline-task-navigation.scenario.ts
1022
+ var pipelineTaskNavigationScenario = {
1023
+ id: "pipeline-task-navigation",
1024
+ name: "Pipeline Task Navigation",
1025
+ description: "Navigate to pipeline task details and come back - verify iframe GC",
1026
+ tags: ["critical"],
1027
+ microappSelector: PipelinePageModel.IFRAME_SELECTOR,
1028
+ url: () => "/pipeline",
1029
+ async setup(page) {
1030
+ await AdminLandingPageModel.acceptCookiesIfShown(page);
1031
+ const pipeline = new PipelinePageModel(page);
1032
+ await pipeline.waitForPipelineReady();
1033
+ },
1034
+ async action(page) {
1035
+ const pipeline = new PipelinePageModel(page);
1036
+ const path4 = new URL(page.url()).pathname.replace(/\/$/, "");
1037
+ if (path4 === "/pipeline") {
1038
+ await pipeline.clickTasksTab();
1039
+ }
1040
+ await pipeline.waitForTasksReady();
1041
+ },
1042
+ async back(page) {
1043
+ const pipeline = new PipelinePageModel(page);
1044
+ await pipeline.clickPipelineTab();
1045
+ await pipeline.waitForPipelineSpinner();
1046
+ },
1047
+ repeat: () => 1,
1048
+ thresholds: {
1049
+ maxRetainedSizeDeltaBytes: 10 * 1024 * 1024,
1050
+ maxNewLeakGroups: 0
1051
+ }
1052
+ };
1053
+
1054
+ // lib/scenarios/pipeline/index.ts
1055
+ var pipelineScenarios = [
1056
+ pipelineTaskNavigationScenario
1057
+ ];
1058
+ var pipelineScenarioGroup = {
1059
+ microapp: "pipeline",
1060
+ scenarios: pipelineScenarios
1061
+ };
1062
+
1063
+ // lib/scenarios/index.ts
1064
+ var scenarioRegistry = (() => {
1065
+ const registry = new ScenarioRegistry();
1066
+ registry.register(oneAdminScenarioGroup);
1067
+ registry.register(pipelineScenarioGroup);
1068
+ return registry;
1069
+ })();
841
1070
 
842
1071
  // lib/cli/index.ts
843
1072
  function buildProgram(deps = defaultDeps()) {
@@ -0,0 +1,182 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+ var heapMemoryProfiler_exports = {};
30
+ __export(heapMemoryProfiler_exports, {
31
+ HeapMemoryProfiler: () => HeapMemoryProfiler
32
+ });
33
+ module.exports = __toCommonJS(heapMemoryProfiler_exports);
34
+ var import_node_fs = __toESM(require("node:fs"), 1);
35
+ var import_node_path = __toESM(require("node:path"), 1);
36
+ var import_encw_heap_doctor = require("@elliemae/encw-heap-doctor");
37
+ class HeapMemoryProfiler {
38
+ /**
39
+ * @param {Page} page - The Playwright `Page` instance for the current test.
40
+ * @param {string} outputDir - Directory where `.heapsnapshot` and `.md` files are written.
41
+ * Created automatically if it does not exist.
42
+ */
43
+ constructor(page, outputDir) {
44
+ this.page = page;
45
+ this.outputDir = outputDir;
46
+ }
47
+ page;
48
+ outputDir;
49
+ /** Memento store: maps snapshot label → absolute file path on disk. */
50
+ snapshots = /* @__PURE__ */ new Map();
51
+ getCDPTarget() {
52
+ return this.page;
53
+ }
54
+ // ─── Browser guard ──────────────────────────────────────────────────────────
55
+ isChromium() {
56
+ return this.page.context().browser()?.browserType().name() === "chromium";
57
+ }
58
+ // ─── Public API ─────────────────────────────────────────────────────────────
59
+ /**
60
+ * Capture a Chrome heap snapshot via CDP and persist it to disk.
61
+ *
62
+ * The snapshot is streamed in chunks via `HeapProfiler.addHeapSnapshotChunk`,
63
+ * joined, and written as a single `.heapsnapshot` file. The file path is cached
64
+ * internally under `label` so {@link compare} can resolve it by name.
65
+ * @param {string} label - Logical name for this snapshot (e.g. `'before'`, `'after'`).
66
+ * Used as the filename prefix and as the Memento key.
67
+ * @returns {Promise<string>} Absolute path to the written file, or `''` on non-Chromium browsers.
68
+ */
69
+ async captureSnapshot(label) {
70
+ if (!this.isChromium()) return "";
71
+ const client = await this.page.context().newCDPSession(this.getCDPTarget());
72
+ await client.send("HeapProfiler.enable");
73
+ import_node_fs.default.mkdirSync(this.outputDir, { recursive: true });
74
+ const filePath = import_node_path.default.join(
75
+ this.outputDir,
76
+ `${label}-${Date.now()}.heapsnapshot`
77
+ );
78
+ const writeStream = import_node_fs.default.createWriteStream(filePath);
79
+ client.on(
80
+ "HeapProfiler.addHeapSnapshotChunk",
81
+ ({ chunk }) => {
82
+ writeStream.write(chunk);
83
+ }
84
+ );
85
+ await client.send("HeapProfiler.takeHeapSnapshot", {
86
+ reportProgress: false
87
+ });
88
+ await client.send("HeapProfiler.disable");
89
+ await client.detach();
90
+ await new Promise((resolve, reject) => {
91
+ writeStream.end((err) => {
92
+ if (err) reject(err);
93
+ else resolve();
94
+ });
95
+ });
96
+ this.snapshots.set(label, filePath);
97
+ return filePath;
98
+ }
99
+ /**
100
+ * Compare two previously captured snapshots by their labels.
101
+ *
102
+ * Runs `HeapDoctor.compare()` on the resolved file paths and writes a
103
+ * markdown report to {@link outputDir}.
104
+ * @param {CompareParams} params - {@link CompareParams}
105
+ * @returns {Promise<ComparisonReport | null>} `ComparisonReport`, or `null` on non-Chromium browsers.
106
+ * @throws If either label has no cached snapshot path.
107
+ * @throws If `HeapDoctor.compare` returns `ok: false`.
108
+ */
109
+ async compare(params) {
110
+ const { beforeLabel, afterLabel, topN, originAllowList } = params;
111
+ if (!this.isChromium()) return null;
112
+ const before = this.snapshots.get(beforeLabel);
113
+ const after = this.snapshots.get(afterLabel);
114
+ if (!before || !after) {
115
+ throw new Error(
116
+ `HeapMemoryProfiler: snapshot not found for labels "${beforeLabel}" / "${afterLabel}". Call captureSnapshot() before compare().`
117
+ );
118
+ }
119
+ const result = await new import_encw_heap_doctor.HeapDoctor({ topN, originAllowList }).compare(
120
+ before,
121
+ after
122
+ );
123
+ if (!result.ok) throw result.error;
124
+ import_node_fs.default.writeFileSync(
125
+ import_node_path.default.join(this.outputDir, `comparison-${Date.now()}.md`),
126
+ result.value.markdown
127
+ );
128
+ return result.value;
129
+ }
130
+ /**
131
+ * Orchestrate the full heap profiling sequence for a user flow.
132
+ *
133
+ * Sequence (Template Method):
134
+ * 1. Capture **before** snapshot
135
+ * 2. Execute `flow` exactly `heapIterations` times
136
+ * 3. Capture **after** snapshot
137
+ * 4. Run `HeapDoctor.compare` and write the markdown report
138
+ * 5. If `failOnLeak` is `true` and `retainedSizeDelta > leakThresholdBytes`, throw
139
+ *
140
+ * On non-Chromium browsers: `flow` still runs `heapIterations` times,
141
+ * no snapshots are taken, and `null` is returned.
142
+ * @param {() => Promise<void>} flow - Async function containing the user actions to profile.
143
+ * @param {HeapProfilingOptions} [options] - {@link HeapProfilingOptions}
144
+ * @returns {Promise<ComparisonReport | null>} `ComparisonReport` on Chromium, `null` on all other browsers.
145
+ */
146
+ async withProfiling(flow, options = {}) {
147
+ const {
148
+ heapIterations = 1,
149
+ label = "flow",
150
+ topN,
151
+ failOnLeak = false,
152
+ leakThresholdBytes = 0,
153
+ originAllowList
154
+ } = options;
155
+ if (!this.isChromium()) {
156
+ for (let i = 0; i < heapIterations; i += 1) await flow();
157
+ return null;
158
+ }
159
+ await this.captureSnapshot(`${label}-before`);
160
+ for (let i = 0; i < heapIterations; i += 1) await flow();
161
+ await this.captureSnapshot(`${label}-after`);
162
+ const report = await this.compare({
163
+ beforeLabel: `${label}-before`,
164
+ afterLabel: `${label}-after`,
165
+ topN,
166
+ originAllowList
167
+ });
168
+ if (report && failOnLeak && report.delta.retainedSizeDelta > leakThresholdBytes) {
169
+ throw new Error(
170
+ `Memory leak detected: retained size grew by ${report.delta.retainedSizeDelta} bytes (threshold: ${leakThresholdBytes} bytes). New leak groups: ${report.delta.newLeakGroups.length}. See: ${this.outputDir}`
171
+ );
172
+ }
173
+ return report;
174
+ }
175
+ /**
176
+ * Clear the internal snapshot label cache.
177
+ * Call in `afterEach` to prevent snapshot paths bleeding across tests.
178
+ */
179
+ clearSnapshots() {
180
+ this.snapshots.clear();
181
+ }
182
+ }
@@ -21,8 +21,8 @@ __export(iframeHeapProfiler_exports, {
21
21
  IframeHeapProfiler: () => IframeHeapProfiler
22
22
  });
23
23
  module.exports = __toCommonJS(iframeHeapProfiler_exports);
24
- var import_smoked_suite = require("@elliemae/smoked-suite");
25
- class IframeHeapProfiler extends import_smoked_suite.HeapMemoryProfiler {
24
+ var import_heapMemoryProfiler = require("./heapMemoryProfiler.js");
25
+ class IframeHeapProfiler extends import_heapMemoryProfiler.HeapMemoryProfiler {
26
26
  constructor(profiledPage, frame, outputDir) {
27
27
  super(profiledPage, outputDir);
28
28
  this.profiledPage = profiledPage;
package/dist/cjs/index.js CHANGED
@@ -24,6 +24,7 @@ __export(index_exports, {
24
24
  DEFAULT_THRESHOLDS: () => import_config.DEFAULT_THRESHOLDS,
25
25
  EnvVarConfigSource: () => import_envVarConfigSource.EnvVarConfigSource,
26
26
  FileConfigSource: () => import_fileConfigSource.FileConfigSource,
27
+ HeapMemoryProfiler: () => import_heapMemoryProfiler.HeapMemoryProfiler,
27
28
  IframeHeapProfiler: () => import_iframeHeapProfiler.IframeHeapProfiler,
28
29
  JunitReporter: () => import_junitReporter.JunitReporter,
29
30
  MissingRequiredParamError: () => import_requiredEnvParams.MissingRequiredParamError,
@@ -41,6 +42,7 @@ var import_config = require("./types/config.js");
41
42
  var import_scenarioRegistry = require("./registry/scenarioRegistry.js");
42
43
  var import_batchRunner = require("./runner/batchRunner.js");
43
44
  var import_scenarioRunner = require("./runner/scenarioRunner.js");
45
+ var import_heapMemoryProfiler = require("./browser/heapMemoryProfiler.js");
44
46
  var import_iframeHeapProfiler = require("./browser/iframeHeapProfiler.js");
45
47
  var import_thresholdEvaluator = require("./analysis/thresholdEvaluator.js");
46
48
  var import_consoleReporter = require("./reporting/consoleReporter.js");
@@ -99,11 +99,12 @@ class ScenarioRunner {
99
99
  await this.repeatScenarioActions(scenario, page, frame);
100
100
  await forceGarbageCollection(page);
101
101
  paths.after = await profiler.captureSnapshot("after");
102
- const report = await profiler.compare(
103
- "before",
104
- "after",
105
- this.config.runner.topN
106
- );
102
+ const report = await profiler.compare({
103
+ beforeLabel: "before",
104
+ afterLabel: "after",
105
+ topN: this.config.runner.topN,
106
+ originAllowList: [new URL(this.config.env.baseUrl).origin]
107
+ });
107
108
  if (report) await this.writeReport(report, scenario);
108
109
  return report;
109
110
  }
@@ -119,7 +120,9 @@ class ScenarioRunner {
119
120
  });
120
121
  process.stderr.write(`[scenarioRunner] post-login URL = ${page.url()}
121
122
  `);
122
- await page.waitForURL(`**${scenario.url()}**`, { timeout: 3e4 });
123
+ if (!page.url().includes(scenario.url())) {
124
+ await page.goto(scenario.url(), { waitUntil: "load", timeout: 3e4 });
125
+ }
123
126
  process.stderr.write(
124
127
  `[scenarioRunner] settled URL before iframe = ${page.url()}
125
128
  `
@@ -23,7 +23,10 @@ __export(scenarios_exports, {
23
23
  module.exports = __toCommonJS(scenarios_exports);
24
24
  var import_scenarioRegistry = require("../registry/scenarioRegistry.js");
25
25
  var import_one_admin = require("./one-admin/index.js");
26
- const scenarioRegistry = new import_scenarioRegistry.ScenarioRegistry().register({
27
- microapp: "one-admin",
28
- scenarios: import_one_admin.oneAdminScenarios
29
- });
26
+ var import_pipeline = require("./pipeline/index.js");
27
+ const scenarioRegistry = (() => {
28
+ const registry = new import_scenarioRegistry.ScenarioRegistry();
29
+ registry.register(import_one_admin.oneAdminScenarioGroup);
30
+ registry.register(import_pipeline.pipelineScenarioGroup);
31
+ return registry;
32
+ })();
@@ -18,10 +18,14 @@ var __copyProps = (to, from, except, desc) => {
18
18
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
19
  var one_admin_exports = {};
20
20
  __export(one_admin_exports, {
21
- oneAdminScenarios: () => oneAdminScenarios
21
+ oneAdminScenarioGroup: () => oneAdminScenarioGroup
22
22
  });
23
23
  module.exports = __toCommonJS(one_admin_exports);
24
24
  var import_export_navigation_scenario = require("./export-navigation.scenario.js");
25
25
  const oneAdminScenarios = [
26
26
  import_export_navigation_scenario.exportNavigationScenario
27
27
  ];
28
+ const oneAdminScenarioGroup = {
29
+ microapp: "one-admin",
30
+ scenarios: oneAdminScenarios
31
+ };