Levix

Levix's zone

x
telegram

Take snapshot using Cypress

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.

1

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 angles:

The Comparison view can show which objects have been correctly released by garbage collection, typically 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 and reference counts in free memory.

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 displayed grouped by constructor name, mainly used for identifying DOM memory leak scenarios.

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 the object situation at a lower level.

2

Does Cypress support the Chrome DevTools Protocol?#

Running e2e tests (node) through Cypress to take runtime heap snapshots requires support for the Chrome Debugging Protocol (CDP). It is evident from the official Cypress documentation or issues on GitHub that Cypress supports CDP.

3

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 it would be beneficial to encapsulate a CDP plugin 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 a ws connection. By combining the chrome-remote-interface library, you can listen for task in e2e.setupNodeEvents and encapsulate 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.

4

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, binding 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, find the method for taking 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 execute heap snapshot taking only under certain specific modes for e2e test files and skip others, 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;
        },
    },
    // ...
});

5

Cypress Automation

Expose and document Chrome DevTools Protocol

Chrome DevTools Protocol

chrome-remote-interface

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.