ヒープスナップショットとは#
Chrome DevTools ヒーププロファイラは、ページの JavaScript オブジェクトと関連する DOM ノードによるメモリ分配を示します。
DevTools のヒープスナップショットパフォーマンス分析は、ウェブページの JavaScript オブジェクトと関連する DOM ノード内のメモリ割り当て状況を表示します。
一般的な疑問は、Profile パネルのスナップショット結果にある Comparison(2 つのヒープスナップショットを生成する必要があります)、Dominator(廃止)、Containment、Summary の 4 つのビューの違いです。4 つのビューはそれぞれ異なる角度からスナップショットデータを分析します:
Comparison ビュー(比較ビュー)は、どのオブジェクトがガベージコレクションによって正しく解放されたかを表示できます。一般的に、このビューでは操作前後のメモリスナップショットデータを比較します。空きメモリ内の変数の増加と参照数を確認することで、メモリリークの存在と原因を特定します。
Dominator ビューは、オブジェクトに他の未知の参照がないことを確認し、ガベージコレクションが正常に機能できることを示します。(新しいバージョンの Chrome では、このパネルは削除され、Statistics(統計情報)が追加され、異なるタイプのデータが占めるメモリを統計します)
Summary ビュー(要約ビュー)は、タイプに従ってオブジェクトとそのメモリ使用状況を追跡し、オブジェクトはコンストラクタ名でグループ化して表示され、主に DOM メモリリークのシナリオを探すために使用されます。
Containment ビュー(制御ビュー)は、オブジェクトの構造をより明確に理解できるようにし、これを通じてグローバルスコープ内でのそのオブジェクトの参照状況(例えば、window)を分析し、クロージャを分析するために、より低いレベルでオブジェクトの状況を確認できます。
Cypress は Chrome DevTools プロトコルをサポートしていますか?#
Cypress を使用して e2e テストを実行する方法(node)で、実行時のヒープスナップショットを撮影するには、Chrome Debugging プロトコル(以下 CDP)をサポートする必要があります。Cypress の公式ドキュメントや GitHub の issue からもわかるように、Cypress は CDP をサポートしています。
画像出典: https://www.processon.com/embed/5edc4c37e401fd69195b7f23
GitHub 関連 issue:
https://github.com/cypress-io/cypress/issues/7942
Cypress は Cypress.automation("remote:debugger:protocol", {...}
を提供しており、ブラウザの言語を設定するなどの一般的なシナリオに対応できます:
Cypress.automation("remote:debugger:protocol", {
command: "Emulation.setLocaleOverride",
params: {
locale: "de-AT",
},
})
Cypress はどのようにスナップショットを撮影しますか?#
実際には Cypress.automation
を使用して remote:debugger:protocol
イベントをリスンすることには具体的な型推論や関連するショートカットのラッピングがないため、使用する際に心的負担が大きくなります。これを補助するために、Chrome DevTools Protocol の公式ドキュメントを参照してコマンドを呼び出し、特定のシナリオを満たすことができます。
個人的には、cdp プラグインをラッピングし、on('task', {...})
を使用してリスンし、e2e テストファイル内で cy.task('takeHeapSnapshot')
を直接使用してヒープスナップショットを撮影できるようにすることをお勧めします。
Cypress は node プロセスを実行し、ws 接続を介してブラウザと通信します。chrome-remote-interface ライブラリを組み合わせて、e2e.setupNodeEvents
内で task
をリスンし、対応するタスクをラッピングすることで、e2e テストファイル内で呼び出すことができます。
// cypress.config.ts
// ...
e2e: {
// ...
setupNodeEvents(on, config) {
// ...
on('task', {
takeHeapSnapshot: () => {}
}
// ...
}
// ...
}
// ...
// demo.test.ts
cy.task('takeHeapSnapshot');
日常の開発プロセスでは、例えば VSCode で Chrome を起動する場合、既知のポートで実行する必要があります。まず、--remote-debugging-port=xxx
(デフォルトは 9222 ポート番号)を設定する必要があります。これにより、デバッグ機能を使用できます。
同様に、Cypress テストコードはブラウザ側で実行され、ブラウザが読み込まれる前に利用可能なポート番号を取得し、--remote-debugging-port
を設定する必要があります。これにより、後で CDP クライアント(chrome-remote-interface ライブラリに基づく)を初期化するための有効なポートが提供されます。
// 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;
},
},
// ...
});
次に、chrome-remote-interface ライブラリを初期化し、先ほど取得したポート番号をバインドします。
// 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;
},
},
// ...
});
Chrome DevTools Protocol の公式ドキュメントを参照し、ヒープスナップショットを撮影する方法を見つけ、これをストリーム形式で .snapshot
ファイルに保存します(ファイル保存場所 filePath を提供)し、後でメモリリークを分析しやすくします。
// 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;
},
},
// ...
});
設定が完了したら、対応する e2e テストファイル内で呼び出します。
// xxx.e2e.ts
describe('test', () => {
it('test', () => {
// baseline
cy.task('takeHeapSnapshot', {
// filePath は書き込むファイルの保存場所
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`)
});
})
});
注意:一部のプロジェクトは非常に大きいため、タスクの実行がタイムアウトする可能性があります。Cypress タスクのタイムアウト時間を 2 分に設定できます。
// cypress.config.ts
export default defineConfig({
// ...
e2e: {
taskTimeout: 120000,
},
// ...
});
特定のモードでのみ e2e テストファイルに対してヒープスナップショットを撮影し、他の状況ではスキップする場合は、--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) => {
/** 指定されたモードでのみ実行し、そうでない場合はスキップ */
if (!config.env.LOCAL_MODE) {
console.log('Skip take heap snapshot.');
return null;
}
// ...
return null;
},
});
return config;
},
},
// ...
});