@elliemae/smoked-suite 26.2.22 → 26.2.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/base-page/index.js +28 -0
- package/dist/cjs/heap-memory-profiler/index.js +170 -0
- package/dist/cjs/index.js +2 -0
- package/dist/esm/base-page/index.js +28 -0
- package/dist/esm/heap-memory-profiler/index.js +140 -0
- package/dist/esm/index.js +2 -0
- package/dist/types/lib/auth/index.d.ts +2 -2
- package/dist/types/lib/base-page/index.d.ts +24 -2
- package/dist/types/lib/base-test/index.d.ts +2 -2
- package/dist/types/lib/heap-memory-profiler/index.d.ts +100 -0
- package/dist/types/lib/index.d.ts +3 -1
- package/dist/types/lib/monocartCoverage/index.d.ts +1 -1
- package/dist/types/lib/page-setup/index.d.ts +1 -1
- package/dist/types/lib/types.d.ts +40 -3
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -1
|
@@ -55,4 +55,32 @@ class BasePage {
|
|
|
55
55
|
getByTitle(text, options) {
|
|
56
56
|
return this.page.getByTitle(text, options);
|
|
57
57
|
}
|
|
58
|
+
// ─── Action helpers ──────────────────────────────────────────────────────────
|
|
59
|
+
/**
|
|
60
|
+
* Click a locator that triggers a navigation (react-router `navigate()`,
|
|
61
|
+
* browser back/forward, an `<a>` tag, a form submit that redirects, etc.)
|
|
62
|
+
* and wait for the URL to match before resolving.
|
|
63
|
+
*
|
|
64
|
+
* Using this helper instead of an ad-hoc `click()` + `toBeVisible()` pattern
|
|
65
|
+
* eliminates the race between the router and the DOM assertion — which
|
|
66
|
+
* manifests as flaky/slow CI runs even though local runs pass.
|
|
67
|
+
* @param locator - The element to click.
|
|
68
|
+
* @param urlPattern - Expected post-navigation URL (string or regex).
|
|
69
|
+
* @param options - Forwarded to `page.waitForURL`.
|
|
70
|
+
* @example
|
|
71
|
+
* ```ts
|
|
72
|
+
* // In a spec
|
|
73
|
+
* await this.auditPage.clickAndWaitForURL(
|
|
74
|
+
* this.auditPage.backButton,
|
|
75
|
+
* /\/admin\/oneadmin\/migrate(?:\/)?$/,
|
|
76
|
+
* );
|
|
77
|
+
* await expect(this.settingsPage.container).toBeVisible();
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
async clickAndWaitForURL(locator, urlPattern, options) {
|
|
81
|
+
await Promise.all([
|
|
82
|
+
this.page.waitForURL(urlPattern, options),
|
|
83
|
+
locator.click()
|
|
84
|
+
]);
|
|
85
|
+
}
|
|
58
86
|
}
|
|
@@ -0,0 +1,170 @@
|
|
|
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 heap_memory_profiler_exports = {};
|
|
30
|
+
__export(heap_memory_profiler_exports, {
|
|
31
|
+
HeapMemoryProfiler: () => HeapMemoryProfiler
|
|
32
|
+
});
|
|
33
|
+
module.exports = __toCommonJS(heap_memory_profiler_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 - The Playwright `Page` instance for the current test.
|
|
40
|
+
* @param 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
|
+
// ─── Browser guard ──────────────────────────────────────────────────────────
|
|
52
|
+
isChromium() {
|
|
53
|
+
return this.page.context().browser()?.browserType().name() === "chromium";
|
|
54
|
+
}
|
|
55
|
+
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
56
|
+
/**
|
|
57
|
+
* Capture a Chrome heap snapshot via CDP and persist it to disk.
|
|
58
|
+
*
|
|
59
|
+
* The snapshot is streamed in chunks via `HeapProfiler.addHeapSnapshotChunk`,
|
|
60
|
+
* joined, and written as a single `.heapsnapshot` file. The file path is cached
|
|
61
|
+
* internally under `label` so {@link compare} can resolve it by name.
|
|
62
|
+
* @param label - Logical name for this snapshot (e.g. `'before'`, `'after'`).
|
|
63
|
+
* Used as the filename prefix and as the Memento key.
|
|
64
|
+
* @returns Absolute path to the written file, or `''` on non-Chromium browsers.
|
|
65
|
+
*/
|
|
66
|
+
async captureSnapshot(label) {
|
|
67
|
+
if (!this.isChromium()) return "";
|
|
68
|
+
const client = await this.page.context().newCDPSession(this.page);
|
|
69
|
+
await client.send("HeapProfiler.enable");
|
|
70
|
+
const chunks = [];
|
|
71
|
+
client.on(
|
|
72
|
+
"HeapProfiler.addHeapSnapshotChunk",
|
|
73
|
+
({ chunk }) => {
|
|
74
|
+
chunks.push(chunk);
|
|
75
|
+
}
|
|
76
|
+
);
|
|
77
|
+
await client.send("HeapProfiler.takeHeapSnapshot", {
|
|
78
|
+
reportProgress: false
|
|
79
|
+
});
|
|
80
|
+
await client.send("HeapProfiler.disable");
|
|
81
|
+
await client.detach();
|
|
82
|
+
import_node_fs.default.mkdirSync(this.outputDir, { recursive: true });
|
|
83
|
+
const filePath = import_node_path.default.join(
|
|
84
|
+
this.outputDir,
|
|
85
|
+
`${label}-${Date.now()}.heapsnapshot`
|
|
86
|
+
);
|
|
87
|
+
import_node_fs.default.writeFileSync(filePath, chunks.join(""));
|
|
88
|
+
this.snapshots.set(label, filePath);
|
|
89
|
+
return filePath;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Compare two previously captured snapshots by their labels.
|
|
93
|
+
*
|
|
94
|
+
* Runs `HeapDoctor.compare()` on the resolved file paths and writes a
|
|
95
|
+
* markdown report to {@link outputDir}.
|
|
96
|
+
* @param beforeLabel - Label passed to {@link captureSnapshot} for the baseline.
|
|
97
|
+
* @param afterLabel - Label passed to {@link captureSnapshot} for the post-flow snapshot.
|
|
98
|
+
* @param topN - Override for the number of top leak groups in the report.
|
|
99
|
+
* @returns `ComparisonReport`, or `null` on non-Chromium browsers.
|
|
100
|
+
* @throws If either label has no cached snapshot path.
|
|
101
|
+
* @throws If `HeapDoctor.compare` returns `ok: false`.
|
|
102
|
+
*/
|
|
103
|
+
async compare(beforeLabel, afterLabel, topN) {
|
|
104
|
+
if (!this.isChromium()) return null;
|
|
105
|
+
const before = this.snapshots.get(beforeLabel);
|
|
106
|
+
const after = this.snapshots.get(afterLabel);
|
|
107
|
+
if (!before || !after) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`HeapMemoryProfiler: snapshot not found for labels "${beforeLabel}" / "${afterLabel}". Call captureSnapshot() before compare().`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
const result = await new import_encw_heap_doctor.HeapDoctor({ topN }).compare(before, after);
|
|
113
|
+
if (!result.ok) throw result.error;
|
|
114
|
+
import_node_fs.default.writeFileSync(
|
|
115
|
+
import_node_path.default.join(this.outputDir, `comparison-${Date.now()}.md`),
|
|
116
|
+
result.value.markdown
|
|
117
|
+
);
|
|
118
|
+
return result.value;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Orchestrate the full heap profiling sequence for a user flow.
|
|
122
|
+
*
|
|
123
|
+
* Sequence (Template Method):
|
|
124
|
+
* 1. Capture **before** snapshot
|
|
125
|
+
* 2. Execute `flow` exactly `heapIterations` times
|
|
126
|
+
* 3. Capture **after** snapshot
|
|
127
|
+
* 4. Run `HeapDoctor.compare` and write the markdown report
|
|
128
|
+
* 5. If `failOnLeak` is `true` and `retainedSizeDelta > leakThresholdBytes`, throw
|
|
129
|
+
*
|
|
130
|
+
* On non-Chromium browsers: `flow` still runs `heapIterations` times,
|
|
131
|
+
* no snapshots are taken, and `null` is returned.
|
|
132
|
+
* @param flow - Async function containing the user actions to profile.
|
|
133
|
+
* @param options - {@link HeapProfilingOptions}
|
|
134
|
+
* @returns `ComparisonReport` on Chromium, `null` on all other browsers.
|
|
135
|
+
*/
|
|
136
|
+
async withProfiling(flow, options = {}) {
|
|
137
|
+
const {
|
|
138
|
+
heapIterations = 1,
|
|
139
|
+
label = "flow",
|
|
140
|
+
topN,
|
|
141
|
+
failOnLeak = false,
|
|
142
|
+
leakThresholdBytes = 0
|
|
143
|
+
} = options;
|
|
144
|
+
if (!this.isChromium()) {
|
|
145
|
+
for (let i = 0; i < heapIterations; i++) await flow();
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
await this.captureSnapshot(`${label}-before`);
|
|
149
|
+
for (let i = 0; i < heapIterations; i++) await flow();
|
|
150
|
+
await this.captureSnapshot(`${label}-after`);
|
|
151
|
+
const report = await this.compare(
|
|
152
|
+
`${label}-before`,
|
|
153
|
+
`${label}-after`,
|
|
154
|
+
topN
|
|
155
|
+
);
|
|
156
|
+
if (report && failOnLeak && report.delta.retainedSizeDelta > leakThresholdBytes) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`Memory leak detected: retained size grew by ${report.delta.retainedSizeDelta} bytes (threshold: ${leakThresholdBytes} bytes). New leak groups: ${report.delta.newLeakGroups.length}. See: ${this.outputDir}`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return report;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Clear the internal snapshot label cache.
|
|
165
|
+
* Call in `afterEach` to prevent snapshot paths bleeding across tests.
|
|
166
|
+
*/
|
|
167
|
+
clearSnapshots() {
|
|
168
|
+
this.snapshots.clear();
|
|
169
|
+
}
|
|
170
|
+
}
|
package/dist/cjs/index.js
CHANGED
|
@@ -31,6 +31,7 @@ __export(index_exports, {
|
|
|
31
31
|
AuthManager: () => import_auth.AuthManager,
|
|
32
32
|
BasePage: () => import_base_page.BasePage,
|
|
33
33
|
BaseTest: () => import_base_test.BaseTest,
|
|
34
|
+
HeapMemoryProfiler: () => import_heap_memory_profiler.HeapMemoryProfiler,
|
|
34
35
|
PageSetup: () => import_page_setup.PageSetup,
|
|
35
36
|
createBrowserStackConfig: () => import_browserstack.createBrowserStackConfig,
|
|
36
37
|
createPlaywrightConfig: () => import_playwright_config.createPlaywrightConfig,
|
|
@@ -40,6 +41,7 @@ __export(index_exports, {
|
|
|
40
41
|
});
|
|
41
42
|
module.exports = __toCommonJS(index_exports);
|
|
42
43
|
var import_base_test = require("./base-test/index.js");
|
|
44
|
+
var import_heap_memory_profiler = require("./heap-memory-profiler/index.js");
|
|
43
45
|
var import_base_page = require("./base-page/index.js");
|
|
44
46
|
var import_auth = require("./auth/index.js");
|
|
45
47
|
var import_page_setup = require("./page-setup/index.js");
|
|
@@ -32,6 +32,34 @@ class BasePage {
|
|
|
32
32
|
getByTitle(text, options) {
|
|
33
33
|
return this.page.getByTitle(text, options);
|
|
34
34
|
}
|
|
35
|
+
// ─── Action helpers ──────────────────────────────────────────────────────────
|
|
36
|
+
/**
|
|
37
|
+
* Click a locator that triggers a navigation (react-router `navigate()`,
|
|
38
|
+
* browser back/forward, an `<a>` tag, a form submit that redirects, etc.)
|
|
39
|
+
* and wait for the URL to match before resolving.
|
|
40
|
+
*
|
|
41
|
+
* Using this helper instead of an ad-hoc `click()` + `toBeVisible()` pattern
|
|
42
|
+
* eliminates the race between the router and the DOM assertion — which
|
|
43
|
+
* manifests as flaky/slow CI runs even though local runs pass.
|
|
44
|
+
* @param locator - The element to click.
|
|
45
|
+
* @param urlPattern - Expected post-navigation URL (string or regex).
|
|
46
|
+
* @param options - Forwarded to `page.waitForURL`.
|
|
47
|
+
* @example
|
|
48
|
+
* ```ts
|
|
49
|
+
* // In a spec
|
|
50
|
+
* await this.auditPage.clickAndWaitForURL(
|
|
51
|
+
* this.auditPage.backButton,
|
|
52
|
+
* /\/admin\/oneadmin\/migrate(?:\/)?$/,
|
|
53
|
+
* );
|
|
54
|
+
* await expect(this.settingsPage.container).toBeVisible();
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
async clickAndWaitForURL(locator, urlPattern, options) {
|
|
58
|
+
await Promise.all([
|
|
59
|
+
this.page.waitForURL(urlPattern, options),
|
|
60
|
+
locator.click()
|
|
61
|
+
]);
|
|
62
|
+
}
|
|
35
63
|
}
|
|
36
64
|
export {
|
|
37
65
|
BasePage
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { HeapDoctor } from "@elliemae/encw-heap-doctor";
|
|
4
|
+
class HeapMemoryProfiler {
|
|
5
|
+
/**
|
|
6
|
+
* @param page - The Playwright `Page` instance for the current test.
|
|
7
|
+
* @param outputDir - Directory where `.heapsnapshot` and `.md` files are written.
|
|
8
|
+
* Created automatically if it does not exist.
|
|
9
|
+
*/
|
|
10
|
+
constructor(page, outputDir) {
|
|
11
|
+
this.page = page;
|
|
12
|
+
this.outputDir = outputDir;
|
|
13
|
+
}
|
|
14
|
+
page;
|
|
15
|
+
outputDir;
|
|
16
|
+
/** Memento store: maps snapshot label → absolute file path on disk. */
|
|
17
|
+
snapshots = /* @__PURE__ */ new Map();
|
|
18
|
+
// ─── Browser guard ──────────────────────────────────────────────────────────
|
|
19
|
+
isChromium() {
|
|
20
|
+
return this.page.context().browser()?.browserType().name() === "chromium";
|
|
21
|
+
}
|
|
22
|
+
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
23
|
+
/**
|
|
24
|
+
* Capture a Chrome heap snapshot via CDP and persist it to disk.
|
|
25
|
+
*
|
|
26
|
+
* The snapshot is streamed in chunks via `HeapProfiler.addHeapSnapshotChunk`,
|
|
27
|
+
* joined, and written as a single `.heapsnapshot` file. The file path is cached
|
|
28
|
+
* internally under `label` so {@link compare} can resolve it by name.
|
|
29
|
+
* @param label - Logical name for this snapshot (e.g. `'before'`, `'after'`).
|
|
30
|
+
* Used as the filename prefix and as the Memento key.
|
|
31
|
+
* @returns Absolute path to the written file, or `''` on non-Chromium browsers.
|
|
32
|
+
*/
|
|
33
|
+
async captureSnapshot(label) {
|
|
34
|
+
if (!this.isChromium()) return "";
|
|
35
|
+
const client = await this.page.context().newCDPSession(this.page);
|
|
36
|
+
await client.send("HeapProfiler.enable");
|
|
37
|
+
const chunks = [];
|
|
38
|
+
client.on(
|
|
39
|
+
"HeapProfiler.addHeapSnapshotChunk",
|
|
40
|
+
({ chunk }) => {
|
|
41
|
+
chunks.push(chunk);
|
|
42
|
+
}
|
|
43
|
+
);
|
|
44
|
+
await client.send("HeapProfiler.takeHeapSnapshot", {
|
|
45
|
+
reportProgress: false
|
|
46
|
+
});
|
|
47
|
+
await client.send("HeapProfiler.disable");
|
|
48
|
+
await client.detach();
|
|
49
|
+
fs.mkdirSync(this.outputDir, { recursive: true });
|
|
50
|
+
const filePath = path.join(
|
|
51
|
+
this.outputDir,
|
|
52
|
+
`${label}-${Date.now()}.heapsnapshot`
|
|
53
|
+
);
|
|
54
|
+
fs.writeFileSync(filePath, chunks.join(""));
|
|
55
|
+
this.snapshots.set(label, filePath);
|
|
56
|
+
return filePath;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Compare two previously captured snapshots by their labels.
|
|
60
|
+
*
|
|
61
|
+
* Runs `HeapDoctor.compare()` on the resolved file paths and writes a
|
|
62
|
+
* markdown report to {@link outputDir}.
|
|
63
|
+
* @param beforeLabel - Label passed to {@link captureSnapshot} for the baseline.
|
|
64
|
+
* @param afterLabel - Label passed to {@link captureSnapshot} for the post-flow snapshot.
|
|
65
|
+
* @param topN - Override for the number of top leak groups in the report.
|
|
66
|
+
* @returns `ComparisonReport`, or `null` on non-Chromium browsers.
|
|
67
|
+
* @throws If either label has no cached snapshot path.
|
|
68
|
+
* @throws If `HeapDoctor.compare` returns `ok: false`.
|
|
69
|
+
*/
|
|
70
|
+
async compare(beforeLabel, afterLabel, topN) {
|
|
71
|
+
if (!this.isChromium()) return null;
|
|
72
|
+
const before = this.snapshots.get(beforeLabel);
|
|
73
|
+
const after = this.snapshots.get(afterLabel);
|
|
74
|
+
if (!before || !after) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`HeapMemoryProfiler: snapshot not found for labels "${beforeLabel}" / "${afterLabel}". Call captureSnapshot() before compare().`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
const result = await new HeapDoctor({ topN }).compare(before, after);
|
|
80
|
+
if (!result.ok) throw result.error;
|
|
81
|
+
fs.writeFileSync(
|
|
82
|
+
path.join(this.outputDir, `comparison-${Date.now()}.md`),
|
|
83
|
+
result.value.markdown
|
|
84
|
+
);
|
|
85
|
+
return result.value;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Orchestrate the full heap profiling sequence for a user flow.
|
|
89
|
+
*
|
|
90
|
+
* Sequence (Template Method):
|
|
91
|
+
* 1. Capture **before** snapshot
|
|
92
|
+
* 2. Execute `flow` exactly `heapIterations` times
|
|
93
|
+
* 3. Capture **after** snapshot
|
|
94
|
+
* 4. Run `HeapDoctor.compare` and write the markdown report
|
|
95
|
+
* 5. If `failOnLeak` is `true` and `retainedSizeDelta > leakThresholdBytes`, throw
|
|
96
|
+
*
|
|
97
|
+
* On non-Chromium browsers: `flow` still runs `heapIterations` times,
|
|
98
|
+
* no snapshots are taken, and `null` is returned.
|
|
99
|
+
* @param flow - Async function containing the user actions to profile.
|
|
100
|
+
* @param options - {@link HeapProfilingOptions}
|
|
101
|
+
* @returns `ComparisonReport` on Chromium, `null` on all other browsers.
|
|
102
|
+
*/
|
|
103
|
+
async withProfiling(flow, options = {}) {
|
|
104
|
+
const {
|
|
105
|
+
heapIterations = 1,
|
|
106
|
+
label = "flow",
|
|
107
|
+
topN,
|
|
108
|
+
failOnLeak = false,
|
|
109
|
+
leakThresholdBytes = 0
|
|
110
|
+
} = options;
|
|
111
|
+
if (!this.isChromium()) {
|
|
112
|
+
for (let i = 0; i < heapIterations; i++) await flow();
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
await this.captureSnapshot(`${label}-before`);
|
|
116
|
+
for (let i = 0; i < heapIterations; i++) await flow();
|
|
117
|
+
await this.captureSnapshot(`${label}-after`);
|
|
118
|
+
const report = await this.compare(
|
|
119
|
+
`${label}-before`,
|
|
120
|
+
`${label}-after`,
|
|
121
|
+
topN
|
|
122
|
+
);
|
|
123
|
+
if (report && failOnLeak && report.delta.retainedSizeDelta > leakThresholdBytes) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`Memory leak detected: retained size grew by ${report.delta.retainedSizeDelta} bytes (threshold: ${leakThresholdBytes} bytes). New leak groups: ${report.delta.newLeakGroups.length}. See: ${this.outputDir}`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return report;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Clear the internal snapshot label cache.
|
|
132
|
+
* Call in `afterEach` to prevent snapshot paths bleeding across tests.
|
|
133
|
+
*/
|
|
134
|
+
clearSnapshots() {
|
|
135
|
+
this.snapshots.clear();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
export {
|
|
139
|
+
HeapMemoryProfiler
|
|
140
|
+
};
|
package/dist/esm/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { BaseTest } from "./base-test/index.js";
|
|
2
|
+
import { HeapMemoryProfiler } from "./heap-memory-profiler/index.js";
|
|
2
3
|
import { BasePage } from "./base-page/index.js";
|
|
3
4
|
import { AuthManager } from "./auth/index.js";
|
|
4
5
|
import { PageSetup } from "./page-setup/index.js";
|
|
@@ -13,6 +14,7 @@ export {
|
|
|
13
14
|
AuthManager,
|
|
14
15
|
BasePage,
|
|
15
16
|
BaseTest,
|
|
17
|
+
HeapMemoryProfiler,
|
|
16
18
|
PageSetup,
|
|
17
19
|
createBrowserStackConfig,
|
|
18
20
|
createPlaywrightConfig,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Page, Locator } from '@playwright/test';
|
|
2
|
-
import { AppContext, ContextProvider } from '../types.js';
|
|
1
|
+
import type { Page, Locator } from '@playwright/test';
|
|
2
|
+
import type { AppContext, ContextProvider } from '../types.js';
|
|
3
3
|
/**
|
|
4
4
|
* Base page object class for encw apps.
|
|
5
5
|
*
|
|
@@ -24,4 +24,26 @@ export declare abstract class BasePage {
|
|
|
24
24
|
protected getByPlaceholder(text: string | RegExp, options?: Parameters<Page['getByPlaceholder']>[1]): Locator;
|
|
25
25
|
protected getByAltText(text: string | RegExp, options?: Parameters<Page['getByAltText']>[1]): Locator;
|
|
26
26
|
protected getByTitle(text: string | RegExp, options?: Parameters<Page['getByTitle']>[1]): Locator;
|
|
27
|
+
/**
|
|
28
|
+
* Click a locator that triggers a navigation (react-router `navigate()`,
|
|
29
|
+
* browser back/forward, an `<a>` tag, a form submit that redirects, etc.)
|
|
30
|
+
* and wait for the URL to match before resolving.
|
|
31
|
+
*
|
|
32
|
+
* Using this helper instead of an ad-hoc `click()` + `toBeVisible()` pattern
|
|
33
|
+
* eliminates the race between the router and the DOM assertion — which
|
|
34
|
+
* manifests as flaky/slow CI runs even though local runs pass.
|
|
35
|
+
* @param locator - The element to click.
|
|
36
|
+
* @param urlPattern - Expected post-navigation URL (string or regex).
|
|
37
|
+
* @param options - Forwarded to `page.waitForURL`.
|
|
38
|
+
* @example
|
|
39
|
+
* ```ts
|
|
40
|
+
* // In a spec
|
|
41
|
+
* await this.auditPage.clickAndWaitForURL(
|
|
42
|
+
* this.auditPage.backButton,
|
|
43
|
+
* /\/admin\/oneadmin\/migrate(?:\/)?$/,
|
|
44
|
+
* );
|
|
45
|
+
* await expect(this.settingsPage.container).toBeVisible();
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
clickAndWaitForURL(locator: Locator, urlPattern: string | RegExp, options?: Parameters<Page['waitForURL']>[1]): Promise<void>;
|
|
27
49
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Page, Expect } from '@playwright/test';
|
|
2
|
-
import { Credentials, GotoOptions } from '../types.js';
|
|
1
|
+
import type { Page, Expect } from '@playwright/test';
|
|
2
|
+
import type { Credentials, GotoOptions } from '../types.js';
|
|
3
3
|
/**
|
|
4
4
|
* Base class for Playwright test specs that run against
|
|
5
5
|
* apps hosted inside the encw (Encompass Web) shell.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { Page } from '@playwright/test';
|
|
2
|
+
import type { ComparisonReport } from '@elliemae/encw-heap-doctor';
|
|
3
|
+
import type { HeapProfilingOptions } from '../types.js';
|
|
4
|
+
export type { HeapProfilingOptions };
|
|
5
|
+
/**
|
|
6
|
+
* Encapsulates heap memory profiling for a single Playwright test.
|
|
7
|
+
*
|
|
8
|
+
* **Responsibilities:**
|
|
9
|
+
* - CDP session lifecycle (open, stream chunks, detach)
|
|
10
|
+
* - `.heapsnapshot` file I/O
|
|
11
|
+
* - Snapshot label cache (Memento store, cleared per test)
|
|
12
|
+
* - HeapDoctor comparison and markdown report writing
|
|
13
|
+
* - Optional test failure on leak threshold breach
|
|
14
|
+
*
|
|
15
|
+
* **Usage:**
|
|
16
|
+
* ```ts
|
|
17
|
+
* class MySpec extends AdminBaseTest {
|
|
18
|
+
* private heapProfiler!: HeapMemoryProfiler;
|
|
19
|
+
*
|
|
20
|
+
* async beforeEach() {
|
|
21
|
+
* await super.beforeEach();
|
|
22
|
+
* this.heapProfiler = new HeapMemoryProfiler(this.page, 'reports/heap-snapshots/my-spec');
|
|
23
|
+
* }
|
|
24
|
+
*
|
|
25
|
+
* async afterEach() {
|
|
26
|
+
* this.heapProfiler.clearSnapshots();
|
|
27
|
+
* await super.afterEach();
|
|
28
|
+
* }
|
|
29
|
+
*
|
|
30
|
+
* async testFlow_Memory() {
|
|
31
|
+
* await this.goto('some.route');
|
|
32
|
+
* await this.heapProfiler.withProfiling(async () => {
|
|
33
|
+
* await this.somePage.doSomething();
|
|
34
|
+
* }, { heapIterations: 3, label: 'my-flow', failOnLeak: true, leakThresholdBytes: 500_000 });
|
|
35
|
+
* }
|
|
36
|
+
* }
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* **Non-Chromium:** All methods silently no-op and return `null` / empty string.
|
|
40
|
+
* The flow inside `withProfiling` still executes normally.
|
|
41
|
+
*/
|
|
42
|
+
export declare class HeapMemoryProfiler {
|
|
43
|
+
private readonly page;
|
|
44
|
+
private readonly outputDir;
|
|
45
|
+
/** Memento store: maps snapshot label → absolute file path on disk. */
|
|
46
|
+
private readonly snapshots;
|
|
47
|
+
/**
|
|
48
|
+
* @param page - The Playwright `Page` instance for the current test.
|
|
49
|
+
* @param outputDir - Directory where `.heapsnapshot` and `.md` files are written.
|
|
50
|
+
* Created automatically if it does not exist.
|
|
51
|
+
*/
|
|
52
|
+
constructor(page: Page, outputDir: string);
|
|
53
|
+
private isChromium;
|
|
54
|
+
/**
|
|
55
|
+
* Capture a Chrome heap snapshot via CDP and persist it to disk.
|
|
56
|
+
*
|
|
57
|
+
* The snapshot is streamed in chunks via `HeapProfiler.addHeapSnapshotChunk`,
|
|
58
|
+
* joined, and written as a single `.heapsnapshot` file. The file path is cached
|
|
59
|
+
* internally under `label` so {@link compare} can resolve it by name.
|
|
60
|
+
* @param label - Logical name for this snapshot (e.g. `'before'`, `'after'`).
|
|
61
|
+
* Used as the filename prefix and as the Memento key.
|
|
62
|
+
* @returns Absolute path to the written file, or `''` on non-Chromium browsers.
|
|
63
|
+
*/
|
|
64
|
+
captureSnapshot(label: string): Promise<string>;
|
|
65
|
+
/**
|
|
66
|
+
* Compare two previously captured snapshots by their labels.
|
|
67
|
+
*
|
|
68
|
+
* Runs `HeapDoctor.compare()` on the resolved file paths and writes a
|
|
69
|
+
* markdown report to {@link outputDir}.
|
|
70
|
+
* @param beforeLabel - Label passed to {@link captureSnapshot} for the baseline.
|
|
71
|
+
* @param afterLabel - Label passed to {@link captureSnapshot} for the post-flow snapshot.
|
|
72
|
+
* @param topN - Override for the number of top leak groups in the report.
|
|
73
|
+
* @returns `ComparisonReport`, or `null` on non-Chromium browsers.
|
|
74
|
+
* @throws If either label has no cached snapshot path.
|
|
75
|
+
* @throws If `HeapDoctor.compare` returns `ok: false`.
|
|
76
|
+
*/
|
|
77
|
+
compare(beforeLabel: string, afterLabel: string, topN?: number): Promise<ComparisonReport | null>;
|
|
78
|
+
/**
|
|
79
|
+
* Orchestrate the full heap profiling sequence for a user flow.
|
|
80
|
+
*
|
|
81
|
+
* Sequence (Template Method):
|
|
82
|
+
* 1. Capture **before** snapshot
|
|
83
|
+
* 2. Execute `flow` exactly `heapIterations` times
|
|
84
|
+
* 3. Capture **after** snapshot
|
|
85
|
+
* 4. Run `HeapDoctor.compare` and write the markdown report
|
|
86
|
+
* 5. If `failOnLeak` is `true` and `retainedSizeDelta > leakThresholdBytes`, throw
|
|
87
|
+
*
|
|
88
|
+
* On non-Chromium browsers: `flow` still runs `heapIterations` times,
|
|
89
|
+
* no snapshots are taken, and `null` is returned.
|
|
90
|
+
* @param flow - Async function containing the user actions to profile.
|
|
91
|
+
* @param options - {@link HeapProfilingOptions}
|
|
92
|
+
* @returns `ComparisonReport` on Chromium, `null` on all other browsers.
|
|
93
|
+
*/
|
|
94
|
+
withProfiling(flow: () => Promise<void>, options?: HeapProfilingOptions): Promise<ComparisonReport | null>;
|
|
95
|
+
/**
|
|
96
|
+
* Clear the internal snapshot label cache.
|
|
97
|
+
* Call in `afterEach` to prevent snapshot paths bleeding across tests.
|
|
98
|
+
*/
|
|
99
|
+
clearSnapshots(): void;
|
|
100
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { BaseTest } from './base-test/index.js';
|
|
2
|
+
export { HeapMemoryProfiler } from './heap-memory-profiler/index.js';
|
|
2
3
|
export { BasePage } from './base-page/index.js';
|
|
3
4
|
export { AuthManager } from './auth/index.js';
|
|
4
5
|
export { PageSetup } from './page-setup/index.js';
|
|
@@ -7,5 +8,6 @@ export { createPlaywrightConfig } from './playwright-config/index.js';
|
|
|
7
8
|
export { default as globalSetup } from './global-setup/index.js';
|
|
8
9
|
export { createBrowserStackConfig, writeBrowserStackConfig, } from './browserstack/index.js';
|
|
9
10
|
export type { BrowserStackOptions, BrowserStackPlatform, } from './browserstack/index.js';
|
|
10
|
-
export type { AppContext, ContextProvider, AuthState, GotoOptions, Credentials, SmokedSuiteConfig, } from './types.js';
|
|
11
|
+
export type { AppContext, ContextProvider, AuthState, GotoOptions, Credentials, SmokedSuiteConfig, HeapProfilingOptions, } from './types.js';
|
|
12
|
+
export type { ComparisonReport } from '@elliemae/encw-heap-doctor';
|
|
11
13
|
export type { CoverageConfig } from './monocartCoverage/index.js';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { PlaywrightTestConfig, Page } from '@playwright/test';
|
|
2
2
|
/**
|
|
3
3
|
* Mark coverage as configured. Called by {@link createPlaywrightConfig}
|
|
4
4
|
* when a `coverage` config is provided — no env var needed.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { Page } from '@playwright/test';
|
|
1
|
+
import type { Page, PlaywrightTestConfig } from '@playwright/test';
|
|
2
|
+
import type { CoverageConfig } from './monocartCoverage/index.js';
|
|
2
3
|
/** Page context for element interaction. */
|
|
3
4
|
export type AppContext = Page;
|
|
4
5
|
/**
|
|
@@ -33,12 +34,48 @@ export interface GotoOptions {
|
|
|
33
34
|
/** Custom timeout */
|
|
34
35
|
timeout?: number;
|
|
35
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Options for {@link HeapMemoryProfiler.withProfiling}.
|
|
39
|
+
*/
|
|
40
|
+
export interface HeapProfilingOptions {
|
|
41
|
+
/**
|
|
42
|
+
* Number of times to execute the flow between the before and after snapshots.
|
|
43
|
+
* More iterations amplify the leak signal — a leak of N bytes per run becomes
|
|
44
|
+
* N × heapIterations in the delta, making small leaks detectable above GC noise.
|
|
45
|
+
* Default: 1.
|
|
46
|
+
*/
|
|
47
|
+
heapIterations?: number;
|
|
48
|
+
/**
|
|
49
|
+
* Label prefix used in snapshot filenames and as the Memento key.
|
|
50
|
+
* Two files are written: `<label>-before-<epoch>.heapsnapshot` and
|
|
51
|
+
* `<label>-after-<epoch>.heapsnapshot`.
|
|
52
|
+
* Default: 'flow'.
|
|
53
|
+
*/
|
|
54
|
+
label?: string;
|
|
55
|
+
/**
|
|
56
|
+
* Number of top leak groups to include in the HeapDoctor report.
|
|
57
|
+
* Default: 5 (HeapDoctor default).
|
|
58
|
+
*/
|
|
59
|
+
topN?: number;
|
|
60
|
+
/**
|
|
61
|
+
* When `true`, throws after comparison if `retainedSizeDelta` exceeds
|
|
62
|
+
* {@link leakThresholdBytes}, causing the Playwright test to fail.
|
|
63
|
+
* Default: false.
|
|
64
|
+
*/
|
|
65
|
+
failOnLeak?: boolean;
|
|
66
|
+
/**
|
|
67
|
+
* Retained-size growth in bytes above which the test is failed when
|
|
68
|
+
* {@link failOnLeak} is `true`. Set to 0 to fail on any positive delta.
|
|
69
|
+
* Default: 0.
|
|
70
|
+
*/
|
|
71
|
+
leakThresholdBytes?: number;
|
|
72
|
+
}
|
|
36
73
|
/**
|
|
37
74
|
* Configuration for {@link createPlaywrightConfig}.
|
|
38
75
|
*/
|
|
39
76
|
export interface SmokedSuiteConfig {
|
|
40
77
|
/** Partial Playwright config to merge with sensible defaults. */
|
|
41
|
-
overrides?:
|
|
78
|
+
overrides?: PlaywrightTestConfig;
|
|
42
79
|
/**
|
|
43
80
|
* When `true` (default), a `globalSetup` function runs once before
|
|
44
81
|
* all workers to authenticate and persist the session to disk.
|
|
@@ -73,5 +110,5 @@ export interface SmokedSuiteConfig {
|
|
|
73
110
|
* });
|
|
74
111
|
* ```
|
|
75
112
|
*/
|
|
76
|
-
coverage?:
|
|
113
|
+
coverage?: CoverageConfig[];
|
|
77
114
|
}
|