diff --git a/.gitignore b/.gitignore index aed5ada5a..93e3039ad 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ /dist/ /dist/packages /tmp +packages/**/test-results/ +packages/**/playwright-report/ lerna-debug.log build.log node_modules diff --git a/package.json b/package.json index ae7a0591e..c6bbc4a6a 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "./packages/api", "./packages/cli", "./packages/memlab", - "./packages/mcp-server" + "./packages/mcp-server", + "./packages/playwright" ] } diff --git a/packages/playwright/LICENSE b/packages/playwright/LICENSE new file mode 100644 index 000000000..b93be9051 --- /dev/null +++ b/packages/playwright/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/playwright/README.md b/packages/playwright/README.md new file mode 100644 index 000000000..c4fc55e82 --- /dev/null +++ b/packages/playwright/README.md @@ -0,0 +1,48 @@ +## memlab Playwright + +This is the memlab Playwright integration. It exposes a drop-in `test` +fixture for `@playwright/test` so existing Playwright specs can attach +memlab's memory-leak detection by destructuring a `memlab` parameter. + +```ts +import {test, expect} from '@memlab/playwright'; + +test('closing a modal does not leak', async ({page, memlab}) => { + await page.goto('http://localhost:3000'); + await memlab.baseline(); + await page.getByRole('button', {name: 'Open'}).click(); + await page.getByRole('button', {name: 'Close'}).click(); + // target + final snapshots, leak detection, and the soft-assert run + // automatically at teardown. Omit `memlab` from the parameters and + // the fixture never attaches (no CDP session, no cost). +}); +``` + +For the common "run a flow, assert no leaks" pattern, call +`memlab.expectNoLeaks()` — it captures target/final, runs detection, and +throws a trimmed leak summary (not the full retention dict) if any leak +trace survives. Use it when you want a hard assertion mid-test rather +than the teardown soft-assert: + +```ts +test('modal close leaves no retained DOM', async ({page, memlab}) => { + await page.goto('http://localhost:3000'); + await memlab.baseline(); + await page.getByRole('button', {name: 'Open'}).click(); + await page.getByRole('button', {name: 'Close'}).click(); + await memlab.expectNoLeaks(); +}); +``` + +Use `memlab.findLeaks()` only when you need the raw trace list (e.g. to +assert a specific leak is present in a fixture test). + +Chromium only — heap snapshots go over CDP, which Playwright exposes +only for Chromium. On Firefox / WebKit the fixture becomes a no-op: +the test still runs and passes, but no leak detection happens. A +`memlab-skip` annotation records the reason. Restrict leak specs to a +Chromium project if you want them to fail loudly elsewhere. + +## Online Resources +* [Official Website and Demo](https://facebook.github.io/memlab) +* [Documentation](https://facebook.github.io/memlab/docs/intro) diff --git a/packages/playwright/__tests__/fixture.spec.ts b/packages/playwright/__tests__/fixture.spec.ts new file mode 100644 index 000000000..efddd42ac --- /dev/null +++ b/packages/playwright/__tests__/fixture.spec.ts @@ -0,0 +1,128 @@ +import {test, expect} from '@memlab/playwright'; +import type {LeakFilterFn} from '@memlab/playwright'; +import type {Page} from '@playwright/test'; + +const BASE = 'http://127.0.0.1:5174'; +const retainedSizeFilter: LeakFilterFn = node => node.retainedSize > 100_000; + +async function openFixture(page: Page, mode: string): Promise { + await page.goto(`${BASE}/?mode=${mode}`); + await page.waitForSelector('#open'); +} + +async function triggerLeakCycle(page: Page): Promise { + await page.click('#open'); + await page.waitForSelector('#slot'); + await page.click('#close'); + await page.waitForSelector('#slot', {state: 'detached'}); +} + +test.describe('configure({leakFilter})', () => { + test('routes user filter through to memlab detection', async ({ + page, + memlab, + }) => { + memlab.configure({leakFilter: retainedSizeFilter}); + await openFixture(page, 'store-leaky'); + await memlab.baseline(); + await triggerLeakCycle(page); + const leaks = await memlab.findLeaks(); + expect(leaks?.length ?? 0).toBeGreaterThan(0); + }); + + test('invokes the user callback during detection', async ({ + page, + memlab, + }) => { + let calls = 0; + memlab.configure({ + leakFilter: () => { + calls += 1; + return true; + }, + }); + await openFixture(page, 'store-leaky'); + await memlab.baseline(); + await triggerLeakCycle(page); + await memlab.findLeaks(); + expect(calls).toBeGreaterThan(0); + }); +}); + +test.describe('configure() merge semantics', () => { + test('later configure({gc}) preserves an earlier leakFilter', async ({ + page, + memlab, + }) => { + let invoked = false; + memlab.configure({ + leakFilter: () => { + invoked = true; + return false; + }, + }); + memlab.configure({gc: {repeat: 1}}); + await openFixture(page, 'store-leaky'); + await memlab.baseline(); + await triggerLeakCycle(page); + await memlab.findLeaks(); + expect(invoked).toBe(true); + }); +}); + +test.describe('configure({gc})', () => { + test('accepts a tuned cycle without breaking detection', async ({ + page, + memlab, + }) => { + memlab.configure({ + leakFilter: retainedSizeFilter, + gc: {repeat: 1, waitBetweenMs: 50, waitAfterMs: 100}, + }); + await openFixture(page, 'store-leaky'); + await memlab.baseline(); + await triggerLeakCycle(page); + const leaks = await memlab.findLeaks(); + expect(leaks?.length ?? 0).toBeGreaterThan(0); + }); +}); + +test.describe('findLeaks()', () => { + test('returns null when baseline was not captured', async ({ + page, + memlab, + }) => { + await openFixture(page, 'store-leaky'); + await triggerLeakCycle(page); + const leaks = await memlab.findLeaks(); + expect(leaks).toBeNull(); + }); +}); + +test.describe('expectNoLeaks()', () => { + test('passes when the flow is clean', async ({page, memlab}) => { + await openFixture(page, 'detached-dom-clean'); + await memlab.baseline(); + await triggerLeakCycle(page); + await memlab.expectNoLeaks(); + }); + + test('throws with leak summary when leaks are detected', async ({ + page, + memlab, + }) => { + await openFixture(page, 'detached-dom-leaky'); + await memlab.baseline(); + await triggerLeakCycle(page); + const err = await memlab.expectNoLeaks().catch(e => e); + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toMatch(/memlab detected \d+ leak trace/); + }); + + test('throws when baseline is missing', async ({page, memlab}) => { + await openFixture(page, 'store-leaky'); + const err = await memlab.expectNoLeaks().catch(e => e); + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toMatch(/baseline\(\)/); + }); +}); diff --git a/packages/playwright/__tests__/fixtures/index.html b/packages/playwright/__tests__/fixtures/index.html new file mode 100644 index 000000000..c71660606 --- /dev/null +++ b/packages/playwright/__tests__/fixtures/index.html @@ -0,0 +1,14 @@ + + + + + memlab playwright fixture + + +
mode:
+ + +
+ + + diff --git a/packages/playwright/__tests__/fixtures/index.js b/packages/playwright/__tests__/fixtures/index.js new file mode 100644 index 000000000..43d5312f9 --- /dev/null +++ b/packages/playwright/__tests__/fixtures/index.js @@ -0,0 +1,80 @@ +const container = document.getElementById('container'); +const mode = + new URLSearchParams(window.location.search).get('mode') ?? 'none'; +document.getElementById('mode').textContent = mode; + +function makePayload() { + const arr = new Array(50000); + for (let i = 0; i < arr.length; i++) { + arr[i] = {tag: 'memlab-payload', i, nested: {alive: true}}; + } + return arr; +} + +const externalStore = { + subs: new Set(), + subscribe(fn) { + this.subs.add(fn); + return () => this.subs.delete(fn); + }, +}; + +const detachedDomStash = []; + +function mountSlot(label) { + const slot = document.createElement('div'); + slot.id = 'slot'; + slot.textContent = label; + container.appendChild(slot); + return slot; +} + +const MODES = { + 'detached-dom-leaky': () => { + const slot = mountSlot('detached-dom-leaky'); + detachedDomStash.push(slot); + return () => container.removeChild(slot); + }, + 'detached-dom-clean': () => { + const slot = mountSlot('detached-dom-clean'); + detachedDomStash.push(slot); + return () => { + container.removeChild(slot); + const i = detachedDomStash.indexOf(slot); + if (i >= 0) detachedDomStash.splice(i, 1); + }; + }, + 'store-leaky': () => { + const payload = makePayload(); + const slot = mountSlot('store-leaky'); + externalStore.subscribe(() => { + if (payload.length < 0) console.log('x'); + }); + return () => container.removeChild(slot); + }, + 'interval-clean': () => { + const payload = makePayload(); + const slot = mountSlot('interval-clean'); + const id = setInterval(() => { + if (payload.length < 0) console.log('x'); + }, 1_000_000); + return () => { + container.removeChild(slot); + clearInterval(id); + }; + }, +}; + +let cleanup = null; + +document.getElementById('open').addEventListener('click', () => { + if (cleanup) return; + const factory = MODES[mode]; + if (factory) cleanup = factory(); +}); + +document.getElementById('close').addEventListener('click', () => { + if (!cleanup) return; + cleanup(); + cleanup = null; +}); diff --git a/packages/playwright/__tests__/fixtures/server.mjs b/packages/playwright/__tests__/fixtures/server.mjs new file mode 100644 index 000000000..5d6b964e3 --- /dev/null +++ b/packages/playwright/__tests__/fixtures/server.mjs @@ -0,0 +1,27 @@ +import {createServer} from 'node:http'; +import {readFile} from 'node:fs/promises'; +import {extname, join} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const root = fileURLToPath(new URL('.', import.meta.url)); +const port = Number(process.env.PORT ?? 5174); +const mime = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', +}; + +createServer(async (req, res) => { + const pathname = new URL(req.url, 'http://127.0.0.1').pathname; + const file = join(root, pathname === '/' ? '/index.html' : pathname); + try { + const body = await readFile(file); + res.writeHead(200, { + 'content-type': mime[extname(file)] ?? 'application/octet-stream', + 'cache-control': 'no-store', + }); + res.end(body); + } catch { + res.writeHead(404, {'content-type': 'text/plain'}); + res.end('not found'); + } +}).listen(port, '127.0.0.1'); diff --git a/packages/playwright/__tests__/leaky.html b/packages/playwright/__tests__/leaky.html new file mode 100644 index 000000000..b154525d1 --- /dev/null +++ b/packages/playwright/__tests__/leaky.html @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/packages/playwright/__tests__/playwright.config.ts b/packages/playwright/__tests__/playwright.config.ts new file mode 100644 index 000000000..34fb738e1 --- /dev/null +++ b/packages/playwright/__tests__/playwright.config.ts @@ -0,0 +1,27 @@ +import {defineConfig, devices} from '@playwright/test'; +import path from 'path'; + +const fixtureDir = path.join(__dirname, 'fixtures'); + +export default defineConfig({ + testDir: __dirname, + testMatch: /.*\.spec\.ts$/, + fullyParallel: false, + reporter: [['list'], ['html', {open: 'never'}]], + use: { + trace: 'off', + }, + webServer: { + command: 'node server.mjs', + cwd: fixtureDir, + url: 'http://127.0.0.1:5174', + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, + projects: [ + { + name: 'chromium', + use: {...devices['Desktop Chrome']}, + }, + ], +}); diff --git a/packages/playwright/__tests__/smoke.mjs b/packages/playwright/__tests__/smoke.mjs new file mode 100644 index 000000000..f5e5f4539 --- /dev/null +++ b/packages/playwright/__tests__/smoke.mjs @@ -0,0 +1,49 @@ +// Minimal spike to verify PlaywrightHeapCapturer end-to-end without booting +// the full Playwright test runner. +import {chromium} from 'playwright'; +import {pathToFileURL} from 'url'; +import fs from 'fs'; +import path from 'path'; +import {fileURLToPath} from 'url'; +import pkg from '../dist/index.js'; +const {PlaywrightHeapCapturer} = pkg; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const pageUrl = pathToFileURL(path.join(__dirname, 'leaky.html')).href; + +const workDir = fs.mkdtempSync(path.join(process.cwd(), 'memlab-smoke-')); +console.log('workDir:', workDir); + +const browser = await chromium.launch(); +const context = await browser.newContext(); +const page = await context.newPage(); +await page.goto(pageUrl); + +const capturer = await PlaywrightHeapCapturer.attach(page, { + workDir, + cleanupOnDispose: false, +}); + +await capturer.snapshot('baseline'); +await page.click('#leak'); +await capturer.snapshot('target'); +await page.click('#cleanup'); +await capturer.snapshot('final'); + +for (const label of ['baseline', 'target', 'final']) { + const p = capturer.getSnapshotPath(label); + const size = fs.statSync(p).size; + const tail = fs.readFileSync(p, 'utf8').slice(-5); + console.log(`${label}: ${p} (${size} bytes, ends with: ${JSON.stringify(tail)})`); +} + +const leaks = await capturer.findLeaks(); +console.log(`found ${leaks.length} leak trace(s)`); +if (leaks.length > 0) { + console.log(JSON.stringify(leaks[0], null, 2).slice(0, 500)); +} + +await capturer.dispose(); +await browser.close(); + +process.exit(leaks.length > 0 ? 0 : 1); diff --git a/packages/playwright/__tests__/smoke.spec.ts b/packages/playwright/__tests__/smoke.spec.ts new file mode 100644 index 000000000..e980328ee --- /dev/null +++ b/packages/playwright/__tests__/smoke.spec.ts @@ -0,0 +1,38 @@ +import {test, expect} from '@memlab/playwright'; + +const BASE = 'http://127.0.0.1:5174'; + +async function openThenClose(page: import('@playwright/test').Page) { + await page.click('#open'); + await page.waitForSelector('#slot'); + await page.click('#close'); + await page.waitForSelector('#slot', {state: 'detached'}); +} + +test('leaky fixture is detected', async ({page, memlab}) => { + await page.goto(`${BASE}/?mode=detached-dom-leaky`); + await page.waitForSelector('#open'); + await memlab.baseline(); + await openThenClose(page); + const leaks = await memlab.findLeaks(); + expect( + leaks?.length ?? 0, + `expected leaky fixture to produce at least one leak, got ${ + leaks?.length ?? 0 + }`, + ).toBeGreaterThan(0); +}); + +test('clean fixture passes', async ({page, memlab}) => { + await page.goto(`${BASE}/?mode=detached-dom-clean`); + await page.waitForSelector('#open'); + await memlab.baseline(); + await openThenClose(page); +}); + +test('no-op when memlab is not destructured', async ({page}) => { + await page.goto(`${BASE}/?mode=interval-clean`); + await page.waitForSelector('#open'); + await page.click('#open'); + expect(await page.textContent('#slot')).toContain('interval-clean'); +}); diff --git a/packages/playwright/__tests__/tsconfig.json b/packages/playwright/__tests__/tsconfig.json new file mode 100644 index 000000000..ddf80ab7a --- /dev/null +++ b/packages/playwright/__tests__/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "Node", + "esModuleInterop": true, + "strict": false, + "skipLibCheck": true, + "paths": {} + }, + "include": ["./**/*"] +} diff --git a/packages/playwright/package.json b/packages/playwright/package.json new file mode 100644 index 000000000..2332ab410 --- /dev/null +++ b/packages/playwright/package.json @@ -0,0 +1,72 @@ +{ + "name": "@memlab/playwright", + "version": "2.0.2", + "license": "MIT", + "description": "Playwright integration for memlab: attach memory leak detection to existing Playwright tests with a single tag", + "author": "Liang Gong ", + "contributors": [], + "keywords": [ + "playwright", + "memlab", + "memory", + "leak", + "e2e", + "heap", + "snapshot" + ], + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "LICENSE" + ], + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@memlab/api": "^2.0.2", + "@memlab/core": "^2.0.2", + "fs-extra": "^4.0.2" + }, + "peerDependencies": { + "@playwright/test": ">=1.40.0", + "playwright": ">=1.40.0" + }, + "peerDependenciesMeta": { + "@playwright/test": { + "optional": true + }, + "playwright": { + "optional": true + } + }, + "devDependencies": { + "@playwright/test": "^1.49.1", + "@types/fs-extra": "^9.0.3", + "@types/node": "^25.0.0", + "playwright": "^1.49.1", + "typescript": "^5.9.3" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/facebook/memlab.git", + "directory": "packages/playwright" + }, + "scripts": { + "build-pkg": "tsc", + "test-pkg": "npm run test-e2e", + "test-e2e": "playwright test --config=__tests__/playwright.config.ts --tsconfig=__tests__/tsconfig.json", + "publish-patch": "npm publish", + "clean-pkg": "rm -rf ./dist && rm -rf ./node_modules && rm -f ./tsconfig.tsbuildinfo && rm -rf ./test-results && rm -rf ./playwright-report" + }, + "bugs": { + "url": "https://github.com/facebook/memlab/issues" + }, + "homepage": "https://github.com/facebook/memlab#readme" +} diff --git a/packages/playwright/src/fixture.ts b/packages/playwright/src/fixture.ts new file mode 100644 index 000000000..8fe69c83d --- /dev/null +++ b/packages/playwright/src/fixture.ts @@ -0,0 +1,223 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @oncall memory_lab + */ + +import os from 'os'; +import path from 'path'; +import fs from 'fs-extra'; +import {test as baseTest, expect} from '@playwright/test'; +import type {Page, TestInfo} from '@playwright/test'; +import type {ISerializedInfo} from '@memlab/core'; + +import {writeHeapSnapshot, forceFullGC} from './snapshot'; +import type {CDPLike} from './snapshot'; +import { + formatLeakMessage, + isInspectorArtifact, + runFindLeaks, + stripInternalKeysReplacer, +} from './leak'; +import {PHASE_LABELS} from './types'; +import type { + MemlabConfigInput, + MemlabFixture, + PhaseLabel, +} from './types'; + +/** + * Playwright `test` with a `memlab` fixture attached. Destructuring + * `memlab` captures heap snapshots around the test body and runs + * memlab's leak detector during teardown. + * + * @example + * ```ts + * import {test, expect} from '@memlab/playwright'; + * + * test('modal close does not leak', async ({page, memlab}) => { + * await page.goto('/'); + * await memlab.baseline(); + * await page.click('text=Open'); + * await page.click('text=Close'); + * }); + * ``` + */ +export const test = baseTest.extend<{memlab: MemlabFixture}>({ + memlab: async ({page}, use, testInfo) => { + const session = await attachCDPSession(page, testInfo); + if (!session) { + await use(noopFixture()); + return; + } + + const workDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'memlab-playwright-'), + ); + const snapshotPaths: Partial> = {}; + let manualVerified = false; + let cachedLeaks: ISerializedInfo[] | null = null; + let userConfig: MemlabConfigInput = {}; + + const takeSnapshot = async (label: PhaseLabel): Promise => { + if (label === 'final') { + await forceFullGC(session, userConfig.gc ?? {}); + } + const file = path.join(workDir, `${label}.heapsnapshot`); + await writeHeapSnapshot(session, file); + snapshotPaths[label] = file; + }; + + const detectLeaks = async (): Promise => { + const raw = await runFindLeaks( + snapshotPaths as Record, + userConfig.leakFilter, + ); + return raw.filter(l => !isInspectorArtifact(l)); + }; + + const captureAndDetect = async (): Promise => { + if (!snapshotPaths.target) await takeSnapshot('target'); + if (!snapshotPaths.final) await takeSnapshot('final'); + cachedLeaks = await detectLeaks(); + return cachedLeaks; + }; + + const fixture: MemlabFixture = { + mark: takeSnapshot, + baseline: () => takeSnapshot('baseline'), + target: () => takeSnapshot('target'), + final: () => takeSnapshot('final'), + configure: config => { + userConfig = { + ...userConfig, + ...config, + gc: config.gc ? {...userConfig.gc, ...config.gc} : userConfig.gc, + }; + }, + findLeaks: async () => { + manualVerified = true; + if (!snapshotPaths.baseline) return null; + return captureAndDetect(); + }, + expectNoLeaks: async () => { + manualVerified = true; + if (!snapshotPaths.baseline) { + throw new Error( + 'memlab.expectNoLeaks(): call memlab.baseline() before the ' + + 'leak-inducing interaction.', + ); + } + const leaks = await captureAndDetect(); + if (leaks.length > 0) { + throw new Error(formatLeakMessage(leaks)); + } + }, + }; + + try { + await use(fixture); + } finally { + try { + if (!manualVerified) { + for (const label of PHASE_LABELS) { + if (!snapshotPaths[label]) await takeSnapshot(label); + } + cachedLeaks = await detectLeaks(); + } + if ( + cachedLeaks != null && + cachedLeaks.length > 0 && + allPhasesCaptured(snapshotPaths) + ) { + await attachArtifacts(testInfo, cachedLeaks, snapshotPaths); + } + if (!manualVerified && cachedLeaks != null && cachedLeaks.length > 0) { + expect + .soft(cachedLeaks.length, formatLeakMessage(cachedLeaks)) + .toBe(0); + } + } finally { + await closeSession(session); + await fs.remove(workDir).catch(() => undefined); + } + } + }, +}); + +export {expect}; +export type {Page}; + +async function attachCDPSession( + page: Page, + testInfo: TestInfo, +): Promise { + try { + const raw = await page.context().newCDPSession(page); + const session = raw as unknown as CDPLike; + await session.send('HeapProfiler.enable'); + return session; + } catch (err) { + testInfo.annotations.push({ + type: 'memlab-skip', + description: `memlab requires Chromium CDP (got: ${ + (err as Error).message + })`, + }); + return null; + } +} + +async function closeSession(session: CDPLike): Promise { + try { + await session.send('HeapProfiler.disable'); + } catch { + // session may already be closed with the page + } +} + +function allPhasesCaptured( + paths: Partial>, +): paths is Record { + return PHASE_LABELS.every(label => paths[label] != null); +} + +async function attachArtifacts( + testInfo: TestInfo, + leaks: ISerializedInfo[], + paths: Record, +): Promise { + const reportPath = testInfo.outputPath('memlab-leaks.json'); + await fs.outputJson(reportPath, leaks, { + spaces: 2, + replacer: stripInternalKeysReplacer, + }); + await Promise.all([ + testInfo.attach('memlab-leaks', { + path: reportPath, + contentType: 'application/json', + }), + ...PHASE_LABELS.map(label => + testInfo.attach(`${label}.heapsnapshot`, { + path: paths[label], + contentType: 'application/octet-stream', + }), + ), + ]); +} + +function noopFixture(): MemlabFixture { + return { + mark: async () => undefined, + baseline: async () => undefined, + target: async () => undefined, + final: async () => undefined, + configure: () => undefined, + findLeaks: async () => null, + expectNoLeaks: async () => undefined, + }; +} diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts new file mode 100644 index 000000000..ad9e08362 --- /dev/null +++ b/packages/playwright/src/index.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @oncall memory_lab + */ + +export {test, expect} from './fixture'; +export {PHASE_LABELS} from './types'; +export type { + LeakFilterFn, + MemlabConfigInput, + MemlabFixture, + MemlabGCOptions, + Page, + PhaseLabel, +} from './types'; diff --git a/packages/playwright/src/leak.ts b/packages/playwright/src/leak.ts new file mode 100644 index 000000000..d4693d402 --- /dev/null +++ b/packages/playwright/src/leak.ts @@ -0,0 +1,104 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @oncall memory_lab + */ + +import {ConsoleMode, SnapshotResultReader, findLeaks} from '@memlab/api'; +import {config as memlabConfig} from '@memlab/core'; +import type {ILeakFilter, ISerializedInfo} from '@memlab/core'; +import type {LeakFilterFn, PhaseLabel} from './types'; + +// CDP inspector-owned retainer labels ($0-$4, selector handles). +const INSPECTOR_PATTERNS = [ + /DevTools console/i, + /\(Inspector[^)]*\)/i, + /CommandLineAPI/i, +]; + +const INTERNAL_KEY_PREFIXES = ['$tabsOrder']; +const LEAKED_KEY_MARKERS = ['$memLabTag:leaked', '$highlight']; +const SUMMARY_MAX_LEN = 140; +const SUMMARY_TOP_N = 5; + +const isInternalKey = (key: string): boolean => + INTERNAL_KEY_PREFIXES.some(p => key.startsWith(p)); + +/** @internal */ +export function isInspectorArtifact(leak: ISerializedInfo): boolean { + const matches = (s: string): boolean => + INSPECTOR_PATTERNS.some(rx => rx.test(s)); + const walk = (value: unknown): boolean => { + if (typeof value === 'string') return matches(value); + if (value == null || typeof value !== 'object') return false; + for (const [key, child] of Object.entries(value)) { + if (matches(key)) return true; + if (walk(child)) return true; + } + return false; + }; + return walk(leak); +} + +/** @internal */ +export function leakSummary(leak: ISerializedInfo): string { + const keys = Object.keys(leak).filter(k => !isInternalKey(k)); + const leaked = keys.find(k => LEAKED_KEY_MARKERS.some(m => k.includes(m))); + const chosen = leaked ?? keys[keys.length - 1]; + if (!chosen) return 'leak'; + const clean = chosen.replace(/\s+/g, ' ').trim(); + return clean.length > SUMMARY_MAX_LEN + ? clean.slice(0, SUMMARY_MAX_LEN - 3) + '...' + : clean; +} + +/** @internal JSON.stringify replacer that drops memlab-internal keys. */ +export function stripInternalKeysReplacer( + key: string, + value: unknown, +): unknown { + return isInternalKey(key) ? undefined : value; +} + +/** @internal */ +export function formatLeakMessage(leaks: ISerializedInfo[]): string { + const head = leaks + .slice(0, SUMMARY_TOP_N) + .map((l, i) => ` #${i + 1}: ${leakSummary(l)}`) + .join('\n'); + const tail = + leaks.length > SUMMARY_TOP_N + ? `\n ... and ${leaks.length - SUMMARY_TOP_N} more` + : ''; + return `memlab detected ${leaks.length} leak trace(s):\n${head}${tail}`; +} + +/** + * Run memlab leak detection on a baseline/target/final snapshot triple. + * @internal + */ +export async function runFindLeaks( + paths: Record, + leakFilter: LeakFilterFn | undefined, +): Promise { + const reader = SnapshotResultReader.fromSnapshots( + paths.baseline, + paths.target, + paths.final, + ); + if (!leakFilter) { + return findLeaks(reader, {consoleMode: ConsoleMode.SILENT}); + } + const external: ILeakFilter = {leakFilter}; + const prev = memlabConfig.externalLeakFilter; + memlabConfig.externalLeakFilter = external; + try { + return await findLeaks(reader, {consoleMode: ConsoleMode.SILENT}); + } finally { + memlabConfig.externalLeakFilter = prev; + } +} diff --git a/packages/playwright/src/snapshot.ts b/packages/playwright/src/snapshot.ts new file mode 100644 index 000000000..b6e9a5d82 --- /dev/null +++ b/packages/playwright/src/snapshot.ts @@ -0,0 +1,108 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @oncall memory_lab + */ + +import fs from 'fs'; + +/** Minimal CDP session shape shared by Playwright and Puppeteer. */ +export interface CDPLike { + send(method: string, params?: Record): Promise; + /* eslint-disable @typescript-eslint/no-explicit-any */ + on(event: string, handler: (payload: any) => void): unknown; + off?(event: string, handler: (payload: any) => void): unknown; + removeListener?(event: string, handler: (payload: any) => void): unknown; + /* eslint-enable @typescript-eslint/no-explicit-any */ +} + +type ChunkEvent = {chunk: string}; +type ProgressEvent = {done: number; total: number; finished?: boolean}; + +export type GCOptions = { + /** Number of collectGarbage passes before the final snapshot. Default 6. */ + repeat?: number; + /** Delay between passes, in milliseconds. Default 200. */ + waitBetweenMs?: number; + /** Delay after the final pass, in milliseconds. Default 500. */ + waitAfterMs?: number; +}; + +function detach( + session: CDPLike, + event: string, + handler: (payload: unknown) => void, +): void { + if (typeof session.off === 'function') { + session.off(event, handler); + return; + } + if (typeof session.removeListener === 'function') { + session.removeListener(event, handler); + } +} + +/** Stream a V8 heap snapshot to disk via CDP. */ +export async function writeHeapSnapshot( + session: CDPLike, + filePath: string, + options: {onProgress?: (percent: number) => void} = {}, +): Promise { + const writeStream = fs.createWriteStream(filePath, {encoding: 'utf8'}); + let lastChunk = ''; + + const onChunk = (data: ChunkEvent) => { + writeStream.write(data.chunk); + lastChunk = data.chunk; + }; + const onProgress = (data: ProgressEvent) => { + if (options.onProgress) { + const percent = ((100 * data.done) / Math.max(1, data.total)) | 0; + options.onProgress(percent); + } + }; + + session.on('HeapProfiler.addHeapSnapshotChunk', onChunk); + session.on('HeapProfiler.reportHeapSnapshotProgress', onProgress); + + try { + await session.send('HeapProfiler.takeHeapSnapshot', { + reportProgress: !!options.onProgress, + captureNumericValue: true, + }); + } finally { + detach(session, 'HeapProfiler.addHeapSnapshotChunk', onChunk as never); + detach( + session, + 'HeapProfiler.reportHeapSnapshotProgress', + onProgress as never, + ); + await new Promise(resolve => writeStream.end(() => resolve())); + } + + if (!/\}\s*$/.test(lastChunk)) { + throw new Error( + 'resolved HeapProfiler.takeHeapSnapshot before writing the last chunk', + ); + } +} + +/** Force a full GC cycle and clear the DevTools console retention. */ +export async function forceFullGC( + session: CDPLike, + options: GCOptions = {}, +): Promise { + const repeat = options.repeat ?? 6; + const wait = options.waitBetweenMs ?? 200; + const waitAfter = options.waitAfterMs ?? 500; + await session.send('Runtime.discardConsoleEntries').catch(() => undefined); + for (let i = 0; i < repeat; i++) { + await session.send('HeapProfiler.collectGarbage'); + await new Promise(r => setTimeout(r, wait)); + } + await new Promise(r => setTimeout(r, waitAfter)); +} diff --git a/packages/playwright/src/types.ts b/packages/playwright/src/types.ts new file mode 100644 index 000000000..d961f5cad --- /dev/null +++ b/packages/playwright/src/types.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @oncall memory_lab + */ + +import type {Page} from '@playwright/test'; +import type {ILeakFilter, ISerializedInfo} from '@memlab/core'; +import type {GCOptions} from './snapshot'; + +export const PHASE_LABELS = ['baseline', 'target', 'final'] as const; +export type PhaseLabel = (typeof PHASE_LABELS)[number]; + +export type LeakFilterFn = NonNullable; +export type MemlabGCOptions = GCOptions; + +export type MemlabConfigInput = { + leakFilter?: LeakFilterFn; + gc?: MemlabGCOptions; +}; + +export type MemlabFixture = { + mark(label: PhaseLabel): Promise; + baseline(): Promise; + target(): Promise; + final(): Promise; + configure(config: MemlabConfigInput): void; + findLeaks(): Promise; + expectNoLeaks(): Promise; +}; + +export type {Page}; diff --git a/packages/playwright/tsconfig.json b/packages/playwright/tsconfig.json new file mode 100644 index 000000000..0685b3c40 --- /dev/null +++ b/packages/playwright/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "target": "ES2022" + }, + "include": ["src/**/*"], + "references": [{"path": "../core"}, {"path": "../api"}] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 3adc16186..4273adb2f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -21,7 +21,8 @@ "@memlab/cli": ["./packages/cli/src/"], "@memlab/heap-analysis": ["./packages/heap-analysis/src/"], "@memlab/memlab": ["./packages/memlab/src/"], - "@memlab/mcp-server": ["./packages/mcp-server/src/"] + "@memlab/mcp-server": ["./packages/mcp-server/src/"], + "@memlab/playwright": ["./packages/playwright/src/"] } }, "include": ["./src", "./packages/**/src", "./node_modules/@types/puppeteer/index.d.ts"], diff --git a/tsconfig.json b/tsconfig.json index 6e40ab1cd..292a99995 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ { "path": "./packages/api" }, { "path": "./packages/cli" }, { "path": "./packages/memlab" }, - { "path": "./packages/mcp-server" } + { "path": "./packages/mcp-server" }, + { "path": "./packages/playwright" } ] }