What is Heap snapshot#
The Chrome DevTools heap profiler shows memory distribution by your page's JavaScript objects and related DOM nodes
The DevTools heap snapshot performance analysis shows the memory allocation of JavaScript objects and related DOM nodes on the webpage.
A common confusion is the difference between the four views in the snapshot results in the Profile panel: Comparison (requires generating two heap snapshots to appear), Dominator (deprecated), Containment, and Summary. The four views analyze the snapshot data from different perspectives:
The Comparison view can show which objects have been correctly released by garbage collection, generally comparing memory snapshot data before and after an operation in this view. It determines the existence and cause of memory leaks by checking the increment of variables in free memory and reference counts.
The Dominators view is used to confirm that an object has no other unknown references and that garbage collection can work properly. (In the new version of Chrome, this panel has been removed, and Statistics has been added to count the memory occupied by different types of data.)
The Summary view can track objects and their memory usage by type, with objects grouped by constructor name, mainly used to find scenarios of DOM memory leaks.
The Containment view provides a clearer understanding of the object's structure, allowing analysis of the reference situation of that object in the global scope (e.g., window), which can be used to analyze closures and view object situations at a lower level.
Does Cypress support the Chrome DevTools Protocol?#
Running e2e tests (node) through Cypress to take runtime heap snapshots must support the Chrome Debugging Protocol (CDP). It is evident from the official Cypress documentation or issues on GitHub that Cypress supports CDP.
Image source: https://www.processon.com/embed/5edc4c37e401fd69195b7f23
Related GitHub issue:
https://github.com/cypress-io/cypress/issues/7942
Cypress provides Cypress.automation("remote:debugger:protocol", {...}
to meet some common scenarios, such as setting the browser's language:
Cypress.automation("remote:debugger:protocol", {
command: "Emulation.setLocaleOverride",
params: {
locale: "de-AT",
},
})
How to take snapshots with Cypress#
In fact, using Cypress.automation
to listen for the remote:debugger:protocol
event does not have specific type inference and related quick encapsulation, which can be mentally burdensome. It can be combined with the
Chrome DevTools Protocol official documentation to assist in calling commands for certain scenarios.
I personally think a cdp plugin can be encapsulated, and use on('task', {...})
to listen, allowing direct use of cy.task('takeHeapSnapshot')
in e2e test files to take heap snapshots.
Cypress runs a node process and communicates with the browser via ws, combined with the chrome-remote-interface library, by listening for task
in e2e.setupNodeEvents
and encapsulating the corresponding tasks for invocation in e2e test files.
// cypress.config.ts
// ...
e2e: {
// ...
setupNodeEvents(on, config) {
// ...
on('task', {
takeHeapSnapshot: () => {}
}
// ...
}
// ...
}
// ...
// demo.test.ts
cy.task('takeHeapSnapshot');
In the daily development process, for example, when launching Chrome in VSCode, it is necessary to run on a known port, first configuring --remote-debugging-port=xxx
(default port 9222) to use the debugging feature.
Similarly, Cypress test code runs on the browser side, and before the browser loads, it needs to obtain an available port number and set --remote-debugging-port
, which also provides an effective port for initializing the CDP client (based on the chrome-remote-interface library).
// cdpPlugin.ts
let port = 0;
const setRdpPortWhenBrowserLaunch = (launchOptionsOrArgs: Cypress.BrowserLaunchOptions) => {
const args = Array.isArray(launchOptionsOrArgs) ? launchOptionsOrArgs : launchOptionsOrArgs.args;
const ensureRdpPort = (args: string[] | (Cypress.BrowserLaunchOptions & any[])) => {
const existing = args.find(arg => arg.slice(0, 23) === '--remote-debugging-port');
if (existing) {
return Number(existing.split('=')[1]);
}
const port = 40000 + Math.round(Math.random() * 25000);
args.push(`--remote-debugging-port=${port}`);
return port;
};
port = ensureRdpPort(args);
console.log('Ensure remote debugging port %d', port);
};
// cypress.config.ts
export default defineConfig({
// ...
e2e: {
setupNodeEvents(on, config) {
const { setRdpPortWhenBrowserLaunch } = cdpPlugin();
on('before:browser:launch', (_, launchOptionsOrArgs) => {
setRdpPortWhenBrowserLaunch(launchOptionsOrArgs);
});
return config;
},
},
// ...
});
Next, initialize the chrome-remote-interface library and bind the port number obtained earlier.
// cdpPlugin.ts
import CDP from 'chrome-remote-interface';
let client: CDP.Client | null = null;
// ...
const initCDPClient = async () => {
if (!port) {
throw new Error('Please set the remote debugging port first!');
}
if (!client) {
client = await CDP({
port,
});
}
};
// cypress.config.ts
export default defineConfig({
// ...
e2e: {
setupNodeEvents(on, config) {
const { setRdpPortWhenBrowserLaunch, initCDPClient } = cdpPlugin();
on('before:browser:launch', (_, launchOptionsOrArgs) => {
setRdpPortWhenBrowserLaunch(launchOptionsOrArgs);
});
on('task', {
takeHeapSnapshot: async (opts: TakeHeapSnapshotType) => {
await initCDPClient();
return null;
},
});
return config;
},
},
// ...
});
Referring to the Chrome DevTools Protocol official documentation, find the method to take heap snapshots and store it in a stream format in a .snapshot
file (providing the file storage location filePath) for subsequent memory leak analysis.
// cdpPlugin.ts
import CDP from 'chrome-remote-interface';
import fs from 'fs';
export interface TakeHeapSnapshotType {
filePath: string;
beforeTakeCallback?: () => void;
afterTakeCallback?: () => void;
}
let port = 0;
let client: CDP.Client | null = null;
const setRdpPortWhenBrowserLaunch = (launchOptionsOrArgs: Cypress.BrowserLaunchOptions) => {
const args = Array.isArray(launchOptionsOrArgs) ? launchOptionsOrArgs : launchOptionsOrArgs.args;
const ensureRdpPort = (args: string[] | (Cypress.BrowserLaunchOptions & any[])) => {
const existing = args.find(arg => arg.slice(0, 23) === '--remote-debugging-port');
if (existing) {
return Number(existing.split('=')[1]);
}
const port = 40000 + Math.round(Math.random() * 25000);
args.push(`--remote-debugging-port=${port}`);
return port;
};
port = ensureRdpPort(args);
console.log('Ensure remote debugging port %d', port);
};
const initCDPClient = async () => {
if (!port) {
throw new Error('Please set the remote debugging port first!');
}
if (!client) {
client = await CDP({
port,
});
}
};
const takeHeapSnapshot = async (opts: TakeHeapSnapshotType) => {
if (!client) {
throw new Error('Please init the cdp client first!');
}
const { filePath, beforeTakeCallback = null, afterTakeCallback = null } = opts;
if (beforeTakeCallback) {
beforeTakeCallback();
}
const writeStream = fs.createWriteStream(filePath, { encoding: 'utf-8' });
const dataHandler = (data: { chunk: string }) => {
writeStream.write(data.chunk);
};
const progressHander = (data: { done: number; total: number; finished: boolean }) => {
const percent = ((100 * data.done) / data.total) | 0;
console.log(`heap snapshot ${percent}% complete`);
};
client.on('HeapProfiler.addHeapSnapshotChunk', dataHandler);
client.on('HeapProfiler.reportHeapSnapshotProgress', progressHander as SafeAny);
await client.send('HeapProfiler.takeHeapSnapshot', {
reportProgress: true,
captureNumericValue: true,
});
writeStream.end();
if (afterTakeCallback) {
afterTakeCallback();
}
};
export const cdpPlugin = () => {
return {
setRdpPortWhenBrowserLaunch,
initCDPClient,
takeHeapSnapshot,
};
};
// cypress.config.ts
export default defineConfig({
// ...
e2e: {
setupNodeEvents(on, config) {
const { setRdpPortWhenBrowserLaunch, initCDPClient } = cdpPlugin();
on('before:browser:launch', (_, launchOptionsOrArgs) => {
setRdpPortWhenBrowserLaunch(launchOptionsOrArgs);
});
on('task', {
takeHeapSnapshot: async (opts: TakeHeapSnapshotType) => {
await initCDPClient();
await takeHeapSnapshot(opts);
return null;
},
});
return config;
},
},
// ...
});
After configuration, call it in the corresponding e2e test file.
// xxx.e2e.ts
describe('test', () => {
it('test', () => {
// baseline
cy.task('takeHeapSnapshot', {
// filePath is the storage location for the written file
filePath: path.join(__dirname, `../heap/s1.heapsnapshot`),
});
cy.contains('xxx').click();
// ...
// target
cy.task('takeHeapSnapshot', {
filePath: path.join(__dirname, `../heap/s2.heapsnapshot`)
});
// back
cy.get('xxx').click();
// final
cy.task('takeHeapSnapshot', {
filePath: path.join(__dirname, `../heap/s3.heapsnapshot`)
});
})
});
Note: Some projects may be large, which could lead to task timeout issues. You can set the Cypress task timeout to 2 minutes.
// cypress.config.ts
export default defineConfig({
// ...
e2e: {
taskTimeout: 120000,
},
// ...
});
If you want to take heap snapshots only for e2e test files under certain specific modes and skip them otherwise, you can set --env XXXX=xxx
.
// package.json
{
"scripts": {
"cy-test:e2e": "pnpm exec cypress open -C ./cypress/cypress.config.ts --e2e --browser chrome --env LOCAL_MODE=1",
}
}
// cypress.config.ts
export default defineConfig({
// ...
e2e: {
setupNodeEvents(on, config) {
on('task', {
takeHeapSnapshot: async (opts: TakeHeapSnapshotType) => {
/** Only run in specified modes, otherwise skip */
if (!config.env.LOCAL_MODE) {
console.log('Skip take heap snapshot.');
return null;
}
// ...
return null;
},
});
return config;
},
},
// ...
});