From f40ad7ecfad7cce81b27a2b04a4cd54aa5fc643f Mon Sep 17 00:00:00 2001 From: miinhho Date: Tue, 21 Apr 2026 00:39:40 +0900 Subject: [PATCH 1/7] feat(playwright): add @memlab/playwright package New package giving existing Playwright tests opt-in memory-leak detection via a single fixture. Destructuring `memlab` in the test body activates CDP-based heap capture; omitting it leaves the test untouched. - src/snapshot.ts: CDPLike duck-typed session + writeHeapSnapshot / forceFullGC, usable against Playwright or Puppeteer CDP without reshaping the existing @memlab/e2e code path. - src/capturer.ts: PlaywrightHeapCapturer low-level API (attach, snapshot, findLeaks via SnapshotResultReader). - src/test.ts: test.extend fixture that captures baseline/target/final, attaches memlab-leaks.json plus raw .heapsnapshot artifacts to testInfo, and soft-fails on detected leaks. Non-Chromium projects get a no-op fixture with a memlab-skip annotation. - __tests__/smoke.mjs: manual spike verifying CDP snapshot round-trip. --- package.json | 3 +- packages/playwright/README.md | 98 +++++++++++++ packages/playwright/__tests__/leaky.html | 19 +++ packages/playwright/__tests__/smoke.mjs | 49 +++++++ packages/playwright/package.json | 72 ++++++++++ packages/playwright/src/capturer.ts | 164 ++++++++++++++++++++++ packages/playwright/src/index.ts | 18 +++ packages/playwright/src/snapshot.ts | 107 +++++++++++++++ packages/playwright/src/test.ts | 167 +++++++++++++++++++++++ packages/playwright/tsconfig.json | 13 ++ tsconfig.base.json | 3 +- tsconfig.json | 3 +- 12 files changed, 713 insertions(+), 3 deletions(-) create mode 100644 packages/playwright/README.md create mode 100644 packages/playwright/__tests__/leaky.html create mode 100644 packages/playwright/__tests__/smoke.mjs create mode 100644 packages/playwright/package.json create mode 100644 packages/playwright/src/capturer.ts create mode 100644 packages/playwright/src/index.ts create mode 100644 packages/playwright/src/snapshot.ts create mode 100644 packages/playwright/src/test.ts create mode 100644 packages/playwright/tsconfig.json 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/README.md b/packages/playwright/README.md new file mode 100644 index 000000000..e1a66a6dc --- /dev/null +++ b/packages/playwright/README.md @@ -0,0 +1,98 @@ +# @memlab/playwright + +Attach memlab memory-leak detection to existing Playwright tests. Two entry +points, pick the one that fits your codebase. + +## Install + +```bash +npm install --save-dev @memlab/playwright @playwright/test +``` + +## 1. Drop-in `test` with the `memlab` fixture + +Swap the `@playwright/test` import for `@memlab/playwright/test`. Any test +that destructures `memlab` gets heap capture + leak analysis; tests that +don't are untouched. + +```ts +import { test, expect } from '@memlab/playwright/test'; + +test('closing a modal does not leak', async ({ page, memlab }) => { + await page.goto('http://localhost:3000'); + await memlab.baseline(); // before the action + await page.getByRole('button', { name: 'Open' }).click(); + await page.getByRole('button', { name: 'Close' }).click(); + // target + final are captured automatically at teardown +}); +``` + +The opt-in is the parameter destructure — no tags, no config. TypeScript +catches typos. Omit `memlab` from the parameter list and the fixture never +runs (no CDP session, no cost). + +### Phase capture + +| Phase | When captured | Override | +| --- | --- | --- | +| `baseline` | Fallback: just before `target` (degenerate — you almost always want to call `await memlab.baseline()` explicitly after navigation) | `await memlab.baseline()` | +| `target` | End of test body | `await memlab.target()` | +| `final` | After a forced GC cycle, before teardown | `await memlab.final()` | + +memlab's leak algorithm compares objects present at `target` that are still +retained at `final` but were **not** at `baseline`. Good shapes: + +- Open component / close component / assert +- Navigate to view / navigate away / assert +- Mount widget / unmount widget / assert + +### Output + +On every run with `memlab` in the params: + +- `memlab-leaks.json` attached to the test — parsed leak traces +- `baseline.heapsnapshot`, `target.heapsnapshot`, `final.heapsnapshot` + attached — open in Chrome DevTools → Memory → Load + +If any leak is reported, the test is marked failed via `expect.soft` (other +assertions still run). + +## 2. Low-level capturer + +If you have a custom runner or want manual control without the fixture: + +```ts +import { chromium } from 'playwright'; +import { PlaywrightHeapCapturer } from '@memlab/playwright'; + +const browser = await chromium.launch(); +const page = await browser.newPage(); +await page.goto('http://localhost:3000'); + +const capturer = await PlaywrightHeapCapturer.attach(page); +await capturer.snapshot('baseline'); + +await page.click('text=Open'); +await capturer.snapshot('target'); + +await page.click('text=Close'); +await capturer.snapshot('final'); + +const leaks = await capturer.findLeaks(); +console.log(`Found ${leaks.length} leak trace(s)`); + +await capturer.dispose(); +await browser.close(); +``` + +## Caveats + +- **Chromium only.** Heap snapshots go over CDP, which Playwright exposes only + for Chromium. Firefox / WebKit projects get an annotation on the test + (`memlab-skip`) and a no-op fixture. +- **Snapshots are large** (tens of MB each). The fixture writes them to the + test's output directory; `PlaywrightHeapCapturer` writes to an OS temp dir + by default and cleans up on `dispose()`. +- **GC is heuristic.** memlab asks V8 to collect garbage before `final`, but + references kept by long-lived structures by design will still appear as + leaks. Triage with the JSON report and the raw snapshots. 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__/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/package.json b/packages/playwright/package.json new file mode 100644 index 000000000..b48f82cdb --- /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" + }, + "./test": { + "types": "./dist/test.d.ts", + "default": "./dist/test.js" + } + }, + "files": [ + "dist", + "LICENSE" + ], + "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": "echo 'no tests yet'", + "publish-patch": "npm publish", + "clean-pkg": "rm -rf ./dist && rm -rf ./node_modules && rm -f ./tsconfig.tsbuildinfo" + }, + "bugs": { + "url": "https://github.com/facebook/memlab/issues" + }, + "homepage": "https://github.com/facebook/memlab#readme" +} diff --git a/packages/playwright/src/capturer.ts b/packages/playwright/src/capturer.ts new file mode 100644 index 000000000..ba8b10b59 --- /dev/null +++ b/packages/playwright/src/capturer.ts @@ -0,0 +1,164 @@ +/** + * 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 {ConsoleMode, SnapshotResultReader, findLeaks} from '@memlab/api'; +import type {ISerializedInfo} from '@memlab/core'; + +import {CDPLike, forceFullGC, writeHeapSnapshot} from './snapshot'; + +// Minimal Playwright Page surface we rely on. Kept as a structural type so +// consumers don't need `playwright` installed for the low-level capturer to +// type-check against arbitrary page-likes. +export interface PageLike { + context(): { + newCDPSession(page: PageLike): Promise; + }; +} + +export type PhaseLabel = 'baseline' | 'target' | 'final'; + +export type PlaywrightHeapCapturerOptions = { + /** + * Working directory for intermediate snapshot files. Defaults to a fresh + * directory under the OS temp dir. The caller owns cleanup unless + * `cleanupOnDispose` is true. + */ + workDir?: string; + /** Delete workDir on dispose(). Default: true when workDir is auto-generated. */ + cleanupOnDispose?: boolean; + /** Repeat count for forced GC cycles before the final snapshot. Default 6. */ + gcRepeat?: number; +}; + +/** + * Low-level capturer: attach to a Playwright Page, take baseline/target/final + * snapshots on demand, then hand them to memlab's leak detector. + * + * ```ts + * const capturer = await PlaywrightHeapCapturer.attach(page); + * await capturer.snapshot('baseline'); + * await page.click('text=Open'); + * await capturer.snapshot('target'); + * await page.click('text=Close'); + * await capturer.snapshot('final'); // runs full GC first + * const leaks = await capturer.findLeaks(); + * await capturer.dispose(); + * ``` + */ +export default class PlaywrightHeapCapturer { + private readonly page: PageLike; + private readonly session: CDPLike; + private readonly workDir: string; + private readonly cleanupOnDispose: boolean; + private readonly gcRepeat: number; + private readonly snapshotPaths: Partial> = {}; + private disposed = false; + + private constructor( + page: PageLike, + session: CDPLike, + workDir: string, + cleanupOnDispose: boolean, + gcRepeat: number, + ) { + this.page = page; + this.session = session; + this.workDir = workDir; + this.cleanupOnDispose = cleanupOnDispose; + this.gcRepeat = gcRepeat; + } + + static async attach( + page: PageLike, + options: PlaywrightHeapCapturerOptions = {}, + ): Promise { + const autoDir = options.workDir == null; + const workDir = + options.workDir ?? + fs.mkdtempSync(path.join(os.tmpdir(), 'memlab-playwright-')); + fs.ensureDirSync(workDir); + + const session = await page.context().newCDPSession(page); + await session.send('HeapProfiler.enable'); + + return new PlaywrightHeapCapturer( + page, + session, + workDir, + options.cleanupOnDispose ?? autoDir, + options.gcRepeat ?? 6, + ); + } + + /** + * Take a named heap snapshot. For `'final'`, a full GC cycle runs first so + * memlab's leak detector sees only objects retained past cleanup. + */ + async snapshot(label: PhaseLabel): Promise { + this.assertLive(); + if (label === 'final') { + await forceFullGC(this.session, {repeat: this.gcRepeat}); + } + const file = path.join(this.workDir, `${label}.heapsnapshot`); + await writeHeapSnapshot(this.session, file); + this.snapshotPaths[label] = file; + return file; + } + + getSnapshotPath(label: PhaseLabel): string | undefined { + return this.snapshotPaths[label]; + } + + hasAllSnapshots(): boolean { + return ( + this.snapshotPaths.baseline != null && + this.snapshotPaths.target != null && + this.snapshotPaths.final != null + ); + } + + /** + * Run memlab leak detection across the captured baseline/target/final + * snapshots. Throws if any of the three is missing. + */ + async findLeaks(): Promise { + this.assertLive(); + const {baseline, target, final} = this.snapshotPaths; + if (!baseline || !target || !final) { + throw new Error( + 'PlaywrightHeapCapturer.findLeaks requires baseline, target, and final snapshots', + ); + } + const reader = SnapshotResultReader.fromSnapshots(baseline, target, final); + return findLeaks(reader, {consoleMode: ConsoleMode.SILENT}); + } + + async dispose(): Promise { + if (this.disposed) return; + this.disposed = true; + try { + await this.session.send('HeapProfiler.disable'); + } catch { + // session may already be closed with the page + } + if (this.cleanupOnDispose) { + await fs.remove(this.workDir).catch(() => undefined); + } + } + + private assertLive(): void { + if (this.disposed) { + throw new Error('PlaywrightHeapCapturer has already been disposed'); + } + } +} diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts new file mode 100644 index 000000000..a0ef9c17d --- /dev/null +++ b/packages/playwright/src/index.ts @@ -0,0 +1,18 @@ +/** + * 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 {default as PlaywrightHeapCapturer} from './capturer'; +export type { + PageLike, + PhaseLabel, + PlaywrightHeapCapturerOptions, +} from './capturer'; +export type {CDPLike} from './snapshot'; +export {writeHeapSnapshot, forceFullGC} from './snapshot'; diff --git a/packages/playwright/src/snapshot.ts b/packages/playwright/src/snapshot.ts new file mode 100644 index 000000000..3d0bb9da0 --- /dev/null +++ b/packages/playwright/src/snapshot.ts @@ -0,0 +1,107 @@ +/** + * 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'; + +// Duck-typed CDP session. Playwright's CDPSession and Puppeteer's CDPSession +// both implement this shape for the events we care about. Handlers use +// `unknown` so the interface is structurally compatible with both vendors' +// strongly-typed overloads. +/* eslint-disable @typescript-eslint/no-explicit-any */ +export interface CDPLike { + send(method: string, params?: any): Promise; + on(event: string, handler: (payload: any) => void): any; + off?(event: string, handler: (payload: any) => void): any; + removeListener?(event: string, handler: (payload: any) => void): any; +} +/* eslint-enable @typescript-eslint/no-explicit-any */ + +type ChunkEvent = {chunk: string}; +type ProgressEvent = {done: number; total: number; finished?: boolean}; + +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); + } +} + +/** + * Drive HeapProfiler via a CDP session and stream the snapshot to disk. + * Works with any CDPLike session (Playwright or Puppeteer). + */ +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 series of full GCs so memlab's leak detection sees a clean final + * snapshot. Mirrors the 6x cycle used in @memlab/e2e. + */ +export async function forceFullGC( + session: CDPLike, + options: {repeat?: number; waitBetweenMs?: number; waitAfterMs?: number} = {}, +): Promise { + const repeat = options.repeat ?? 6; + const wait = options.waitBetweenMs ?? 200; + const waitAfter = options.waitAfterMs ?? 500; + 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/test.ts b/packages/playwright/src/test.ts new file mode 100644 index 000000000..c109f28f2 --- /dev/null +++ b/packages/playwright/src/test.ts @@ -0,0 +1,167 @@ +/** + * 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-extra'; +import {test as baseTest, expect} from '@playwright/test'; +import type {Page} from '@playwright/test'; +import type {ISerializedInfo} from '@memlab/core'; + +import PlaywrightHeapCapturer, {PageLike, PhaseLabel} from './capturer'; + +export type MemlabFixture = { + /** + * Capture a named heap snapshot. For `'final'`, a full GC cycle runs first. + * Overrides the automatic snapshot that would otherwise be taken for that + * phase. + */ + mark(label: PhaseLabel): Promise; + baseline(): Promise; + target(): Promise; + final(): Promise; + /** Returns the leak report, or null if snapshots are incomplete. */ + findLeaks(): Promise; +}; + +type PhaseState = { + baseline: boolean; + target: boolean; + final: boolean; +}; + +/** + * Drop-in replacement for `@playwright/test`'s `test`. Destructuring + * `memlab` in the test body enables heap capture + leak analysis for that + * test; omitting it leaves the test untouched. + * + * ```ts + * import { test, expect } from '@memlab/playwright/test'; + * + * 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) => { + let capturer: PlaywrightHeapCapturer; + try { + capturer = await PlaywrightHeapCapturer.attach(page as PageLike); + } catch (err) { + // Non-Chromium browsers cannot create a CDP session. Surface the + // reason as a test annotation and provide a no-op fixture so the + // rest of the test can run unchanged. + testInfo.annotations.push({ + type: 'memlab-skip', + description: `memlab requires Chromium CDP (got: ${ + (err as Error).message + })`, + }); + const noop: MemlabFixture = { + mark: async () => undefined, + baseline: async () => undefined, + target: async () => undefined, + final: async () => undefined, + findLeaks: async () => null, + }; + await use(noop); + return; + } + + const manual: PhaseState = {baseline: false, target: false, final: false}; + const mark = async (label: PhaseLabel) => { + await capturer.snapshot(label); + manual[label] = true; + }; + + const fixture: MemlabFixture = { + mark, + baseline: () => mark('baseline'), + target: () => mark('target'), + final: () => mark('final'), + findLeaks: async () => + capturer.hasAllSnapshots() ? capturer.findLeaks() : null, + }; + + try { + await use(fixture); + } finally { + try { + // Auto fallback for any phase the user did not mark. Baseline is + // the fragile one — if it wasn't captured before the action, the + // snapshot taken here reflects post-action state and the + // comparison is degenerate. Users should call + // `await memlab.baseline()` explicitly after navigation. + if (!manual.baseline && !capturer.getSnapshotPath('baseline')) { + await capturer.snapshot('baseline'); + } + if (!manual.target) { + await capturer.snapshot('target'); + } + if (!manual.final) { + await capturer.snapshot('final'); + } + + if (capturer.hasAllSnapshots()) { + const leaks = await capturer.findLeaks(); + const reportPath = testInfo.outputPath('memlab-leaks.json'); + await fs.outputJson(reportPath, leaks, {spaces: 2}); + await testInfo.attach('memlab-leaks', { + path: reportPath, + contentType: 'application/json', + }); + // Preserve the raw snapshots so users can open them in Chrome + // DevTools → Memory → Load. + for (const label of ['baseline', 'target', 'final'] as PhaseLabel[]) { + const src = capturer.getSnapshotPath(label); + if (!src) continue; + const dest = testInfo.outputPath(`${label}.heapsnapshot`); + await fs.copy(src, dest); + await testInfo.attach(`${label}.heapsnapshot`, { + path: dest, + contentType: 'application/octet-stream', + }); + } + if (leaks.length > 0) { + const summary = leaks + .slice(0, 5) + .map((l, i) => ` #${i + 1}: ${leakSummary(l)}`) + .join('\n'); + const msg = + `memlab detected ${leaks.length} leak trace(s):\n${summary}` + + (leaks.length > 5 ? `\n ... and ${leaks.length - 5} more` : ''); + expect.soft(leaks, msg).toHaveLength(0); + } + } + } finally { + await capturer.dispose(); + } + } + }, +}); + +function leakSummary(leak: ISerializedInfo): string { + const maybe = leak as unknown as { + retainedSize?: number; + type?: string; + name?: string; + }; + const parts: string[] = []; + if (maybe.type) parts.push(maybe.type); + if (maybe.name) parts.push(maybe.name); + if (typeof maybe.retainedSize === 'number') + parts.push(`${maybe.retainedSize}B retained`); + return parts.join(' · ') || 'leak'; +} + +export {expect}; +export type {Page}; diff --git a/packages/playwright/tsconfig.json b/packages/playwright/tsconfig.json new file mode 100644 index 000000000..97c1905f2 --- /dev/null +++ b/packages/playwright/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "types": ["node"] + }, + "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" } ] } From c5c02734f541c07c64f2f37d976194792a01948c Mon Sep 17 00:00:00 2001 From: miinhho Date: Tue, 21 Apr 2026 00:47:31 +0900 Subject: [PATCH 2/7] test(playwright): add React fixture spec + inspector-artifact leak filter Verifies the package end-to-end against a leaky React component (setInterval closure without cleanup), a clean counterpart, and a no-op control. - snapshot.ts: discard CDP console entries before the GC cycle so detached DOM nodes held by Chrome's console ring aren't reported as leaks. - test.ts: post-filter leak traces whose retainer path is owned by CDP inspector state (DevTools console, Inspector, CommandLineAPI). These are artifacts of how Playwright drives the page, not application leaks. - tsconfig.json: drop the base paths extend so Playwright's TS loader resolves @memlab/* via node_modules instead of rewriting to monorepo source files (which hit a circular import at require time). - __tests__/: React fixtures (unpkg UMD, no build step), playwright config, shielded tsconfig with empty paths, and the spec itself. Run with `npm run test-e2e` inside packages/playwright. --- .gitignore | 1 + .../playwright/__tests__/fixtures/clean.html | 58 ++++++++++++++++++ .../__tests__/fixtures/react-leak.html | 61 +++++++++++++++++++ .../playwright/__tests__/playwright.config.ts | 17 ++++++ .../playwright/__tests__/react-leak.spec.ts | 59 ++++++++++++++++++ packages/playwright/__tests__/tsconfig.json | 12 ++++ packages/playwright/package.json | 5 +- packages/playwright/src/snapshot.ts | 6 +- packages/playwright/src/test.ts | 21 ++++++- packages/playwright/tsconfig.json | 30 ++++++--- 10 files changed, 258 insertions(+), 12 deletions(-) create mode 100644 packages/playwright/__tests__/fixtures/clean.html create mode 100644 packages/playwright/__tests__/fixtures/react-leak.html create mode 100644 packages/playwright/__tests__/playwright.config.ts create mode 100644 packages/playwright/__tests__/react-leak.spec.ts create mode 100644 packages/playwright/__tests__/tsconfig.json diff --git a/.gitignore b/.gitignore index aed5ada5a..086ccd07a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ /dist/ /dist/packages /tmp +packages/**/test-results/ lerna-debug.log build.log node_modules diff --git a/packages/playwright/__tests__/fixtures/clean.html b/packages/playwright/__tests__/fixtures/clean.html new file mode 100644 index 000000000..4d0a101de --- /dev/null +++ b/packages/playwright/__tests__/fixtures/clean.html @@ -0,0 +1,58 @@ + + + + + memlab playwright clean fixture + + +
+ + + + + diff --git a/packages/playwright/__tests__/fixtures/react-leak.html b/packages/playwright/__tests__/fixtures/react-leak.html new file mode 100644 index 000000000..1481f6ee2 --- /dev/null +++ b/packages/playwright/__tests__/fixtures/react-leak.html @@ -0,0 +1,61 @@ + + + + + memlab playwright react fixture + + +
+ + + + + diff --git a/packages/playwright/__tests__/playwright.config.ts b/packages/playwright/__tests__/playwright.config.ts new file mode 100644 index 000000000..4e2a9e05a --- /dev/null +++ b/packages/playwright/__tests__/playwright.config.ts @@ -0,0 +1,17 @@ +import {defineConfig, devices} from '@playwright/test'; + +export default defineConfig({ + testDir: __dirname, + testMatch: /.*\.spec\.ts$/, + fullyParallel: false, + reporter: [['list']], + use: { + trace: 'off', + }, + projects: [ + { + name: 'chromium', + use: {...devices['Desktop Chrome']}, + }, + ], +}); diff --git a/packages/playwright/__tests__/react-leak.spec.ts b/packages/playwright/__tests__/react-leak.spec.ts new file mode 100644 index 000000000..998f18b72 --- /dev/null +++ b/packages/playwright/__tests__/react-leak.spec.ts @@ -0,0 +1,59 @@ +import path from 'path'; +import {pathToFileURL} from 'url'; +import {test, expect} from '@memlab/playwright/test'; +import {PlaywrightHeapCapturer} from '@memlab/playwright'; + +const leakyUrl = pathToFileURL( + path.join(__dirname, 'fixtures', 'react-leak.html'), +).href; +const cleanUrl = pathToFileURL( + path.join(__dirname, 'fixtures', 'clean.html'), +).href; + +async function openThenClose(page: import('@playwright/test').Page, detachSel: string) { + await page.click('#open'); + await page.waitForSelector(detachSel); + await page.click('#close'); + await page.waitForSelector(detachSel, {state: 'detached'}); +} + +// Uses the low-level capturer so we can assert on the leak count directly +// without the fixture's auto soft-fail behavior. +test('leaky React component is detected', async ({page}) => { + await page.goto(leakyUrl); + await page.waitForSelector('#open'); + + const capturer = await PlaywrightHeapCapturer.attach(page); + try { + await capturer.snapshot('baseline'); + await openThenClose(page, '#leaky'); + await capturer.snapshot('target'); + await capturer.snapshot('final'); + + const leaks = await capturer.findLeaks(); + expect( + leaks.length, + `expected the leaky component to produce at least one leak trace, got ${leaks.length}`, + ).toBeGreaterThan(0); + } finally { + await capturer.dispose(); + } +}); + +// Uses the high-level fixture. Relies on the fixture's auto soft-fail being +// silent when no leak is found. +test('clean React component passes the fixture', async ({page, memlab}) => { + await page.goto(cleanUrl); + await page.waitForSelector('#open'); + + await memlab.baseline(); + await openThenClose(page, '#clean'); + // target + final captured automatically at fixture teardown. +}); + +test('no-op when memlab is not destructured', async ({page}) => { + await page.goto(cleanUrl); + await page.waitForSelector('#open'); + await page.click('#open'); + expect(await page.textContent('#clean')).toContain('50000'); +}); 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 index b48f82cdb..edc0bcff8 100644 --- a/packages/playwright/package.json +++ b/packages/playwright/package.json @@ -61,9 +61,10 @@ }, "scripts": { "build-pkg": "tsc", - "test-pkg": "echo 'no tests yet'", + "test-pkg": "echo 'run \"npm run test-e2e\" inside packages/playwright for the Playwright spec'", + "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" + "clean-pkg": "rm -rf ./dist && rm -rf ./node_modules && rm -f ./tsconfig.tsbuildinfo && rm -rf ./test-results" }, "bugs": { "url": "https://github.com/facebook/memlab/issues" diff --git a/packages/playwright/src/snapshot.ts b/packages/playwright/src/snapshot.ts index 3d0bb9da0..284c4cd73 100644 --- a/packages/playwright/src/snapshot.ts +++ b/packages/playwright/src/snapshot.ts @@ -90,7 +90,9 @@ export async function writeHeapSnapshot( /** * Force a series of full GCs so memlab's leak detection sees a clean final - * snapshot. Mirrors the 6x cycle used in @memlab/e2e. + * snapshot. Mirrors the 6x cycle used in @memlab/e2e. Also discards the CDP + * console entries, since Chrome's DevTools console retains references to + * detached DOM nodes and would produce false-positive "leaks". */ export async function forceFullGC( session: CDPLike, @@ -99,6 +101,8 @@ export async function forceFullGC( const repeat = options.repeat ?? 6; const wait = options.waitBetweenMs ?? 200; const waitAfter = options.waitAfterMs ?? 500; + // Best-effort: some domains may not be enabled. Swallow errors. + 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)); diff --git a/packages/playwright/src/test.ts b/packages/playwright/src/test.ts index c109f28f2..ccc74776c 100644 --- a/packages/playwright/src/test.ts +++ b/packages/playwright/src/test.ts @@ -112,7 +112,12 @@ export const test = baseTest.extend<{memlab: MemlabFixture}>({ } if (capturer.hasAllSnapshots()) { - const leaks = await capturer.findLeaks(); + const rawLeaks = await capturer.findLeaks(); + // Filter out retainer paths owned by CDP's inspector state — those + // retain detached DOM nodes as an artifact of how Playwright drives + // the page (selector handles, console $0-$4), not because of real + // application leaks. + const leaks = rawLeaks.filter(l => !isInspectorArtifact(l)); const reportPath = testInfo.outputPath('memlab-leaks.json'); await fs.outputJson(reportPath, leaks, {spaces: 2}); await testInfo.attach('memlab-leaks', { @@ -149,6 +154,20 @@ export const test = baseTest.extend<{memlab: MemlabFixture}>({ }, }); +const INSPECTOR_PATTERNS = [ + /DevTools console/i, + /\(Inspector[^)]*\)/i, + /CommandLineAPI/i, +]; + +function isInspectorArtifact(leak: ISerializedInfo): boolean { + // ISerializedInfo is a key-value object where keys encode the retainer + // trace (e.g., "2: --12 / DevTools console (internal)---> [Detached …]"). + // We scan the serialized trace for known CDP-inspector retainer labels. + const keys = Object.keys(leak as Record); + return keys.some(k => INSPECTOR_PATTERNS.some(rx => rx.test(k))); +} + function leakSummary(leak: ISerializedInfo): string { const maybe = leak as unknown as { retainedSize?: number; diff --git a/packages/playwright/tsconfig.json b/packages/playwright/tsconfig.json index 97c1905f2..021c71373 100644 --- a/packages/playwright/tsconfig.json +++ b/packages/playwright/tsconfig.json @@ -1,13 +1,27 @@ { - "extends": "../../tsconfig.base.json", "compilerOptions": { - "rootDir": "./src", + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "Node", "outDir": "./dist", - "types": ["node"] + "rootDir": "./src", + "strict": true, + "strictPropertyInitialization": false, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "composite": true, + "incremental": true, + "noEmitOnError": true, + "types": ["node"], + "baseUrl": ".", + "paths": { + "@memlab/core": ["../core/dist/index.d.ts"], + "@memlab/api": ["../api/dist/index.d.ts"] + } }, - "include": ["src/**/*"], - "references": [ - {"path": "../core"}, - {"path": "../api"} - ] + "include": ["src/**/*"] } From 0df1cd6d0b4844bfd4e7bd37033968e68b730851 Mon Sep 17 00:00:00 2001 From: miinhho Date: Tue, 21 Apr 2026 01:09:04 +0900 Subject: [PATCH 3/7] test(playwright): replace file:// fixtures with Vite + React over HTTP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous spec used React UMD via unpkg loaded from file:// URLs, which skips JSX compilation, module resolution, and the HTTP origin semantics of any real application. This commit swaps in a Vite dev server running a production-shaped React 18 fixture so the spec exercises the same code paths users hit in practice. - packages/playwright/__tests__/fixtures/vite-react/: new fixture app with JSX (Leaky + Clean components driven by ?mode=leaky|clean), registered as a monorepo workspace so `npm install` at the root pulls in its deps. - playwright.config.ts: webServer launches `vite` on 127.0.0.1:5174 and Playwright sets baseURL so specs use relative paths. - react-leak.spec.ts: navigate to http://.../?mode=leaky|clean instead of pathToFileURL(...). - HMR disabled in vite.config.ts — the HMR websocket client pollutes heap snapshots and muddies the leak signal. All three existing specs continue to pass (leaky detected, clean clean, no-op fixture inert). --- package.json | 3 +- .../playwright/__tests__/fixtures/clean.html | 58 ----------------- .../__tests__/fixtures/react-leak.html | 61 ------------------ .../__tests__/fixtures/vite-react/index.html | 11 ++++ .../fixtures/vite-react/package.json | 23 +++++++ .../__tests__/fixtures/vite-react/src/App.tsx | 62 +++++++++++++++++++ .../fixtures/vite-react/src/main.tsx | 6 ++ .../fixtures/vite-react/tsconfig.json | 16 +++++ .../fixtures/vite-react/vite.config.ts | 14 +++++ .../playwright/__tests__/playwright.config.ts | 11 ++++ .../playwright/__tests__/react-leak.spec.ts | 28 ++++----- 11 files changed, 157 insertions(+), 136 deletions(-) delete mode 100644 packages/playwright/__tests__/fixtures/clean.html delete mode 100644 packages/playwright/__tests__/fixtures/react-leak.html create mode 100644 packages/playwright/__tests__/fixtures/vite-react/index.html create mode 100644 packages/playwright/__tests__/fixtures/vite-react/package.json create mode 100644 packages/playwright/__tests__/fixtures/vite-react/src/App.tsx create mode 100644 packages/playwright/__tests__/fixtures/vite-react/src/main.tsx create mode 100644 packages/playwright/__tests__/fixtures/vite-react/tsconfig.json create mode 100644 packages/playwright/__tests__/fixtures/vite-react/vite.config.ts diff --git a/package.json b/package.json index c6bbc4a6a..87d9e17ba 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "./packages/cli", "./packages/memlab", "./packages/mcp-server", - "./packages/playwright" + "./packages/playwright", + "./packages/playwright/__tests__/fixtures/vite-react" ] } diff --git a/packages/playwright/__tests__/fixtures/clean.html b/packages/playwright/__tests__/fixtures/clean.html deleted file mode 100644 index 4d0a101de..000000000 --- a/packages/playwright/__tests__/fixtures/clean.html +++ /dev/null @@ -1,58 +0,0 @@ - - - - - memlab playwright clean fixture - - -
- - - - - diff --git a/packages/playwright/__tests__/fixtures/react-leak.html b/packages/playwright/__tests__/fixtures/react-leak.html deleted file mode 100644 index 1481f6ee2..000000000 --- a/packages/playwright/__tests__/fixtures/react-leak.html +++ /dev/null @@ -1,61 +0,0 @@ - - - - - memlab playwright react fixture - - -
- - - - - diff --git a/packages/playwright/__tests__/fixtures/vite-react/index.html b/packages/playwright/__tests__/fixtures/vite-react/index.html new file mode 100644 index 000000000..76c69d6e5 --- /dev/null +++ b/packages/playwright/__tests__/fixtures/vite-react/index.html @@ -0,0 +1,11 @@ + + + + + memlab playwright fixture + + +
+ + + diff --git a/packages/playwright/__tests__/fixtures/vite-react/package.json b/packages/playwright/__tests__/fixtures/vite-react/package.json new file mode 100644 index 000000000..f7453b85e --- /dev/null +++ b/packages/playwright/__tests__/fixtures/vite-react/package.json @@ -0,0 +1,23 @@ +{ + "name": "memlab-playwright-vite-react-fixture", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build-pkg": "echo 'memlab playwright fixture: nothing to build'", + "test-pkg": "echo 'memlab playwright fixture: no unit tests'", + "clean-pkg": "rm -rf node_modules dist", + "publish-patch": "echo 'memlab playwright fixture: not published'" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "vite": "^5.4.10" + } +} diff --git a/packages/playwright/__tests__/fixtures/vite-react/src/App.tsx b/packages/playwright/__tests__/fixtures/vite-react/src/App.tsx new file mode 100644 index 000000000..2af72c223 --- /dev/null +++ b/packages/playwright/__tests__/fixtures/vite-react/src/App.tsx @@ -0,0 +1,62 @@ +import {useEffect, useState} from 'react'; + +function makePayload() { + const big = new Array(50000); + for (let i = 0; i < big.length; i++) { + big[i] = {tag: 'memlab-payload', i, nested: {alive: true}}; + } + return big; +} + +// Leak: setInterval whose callback closes over state, with NO cleanup. +// When unmounts, React drops the fiber but the interval keeps +// the closure (and thus `payload`) alive. +function Leaky() { + const [payload] = useState(makePayload); + useEffect(() => { + const id = setInterval(() => { + if (payload.length < 0) console.log('unreachable'); + }, 1_000_000); + // no return cleanup, intentionally + return undefined; + }, [payload]); + return
leaky ({payload.length} items)
; +} + +// Same shape as but with proper clearInterval cleanup. +function Clean() { + const [payload] = useState(makePayload); + useEffect(() => { + const id = setInterval(() => { + if (payload.length < 0) console.log('unreachable'); + }, 1_000_000); + return () => clearInterval(id); + }, [payload]); + return
clean ({payload.length} items)
; +} + +function getMode(): 'leaky' | 'clean' | null { + const m = new URLSearchParams(window.location.search).get('mode'); + return m === 'leaky' || m === 'clean' ? m : null; +} + +export function App() { + const [visible, setVisible] = useState(false); + const mode = getMode(); + const Target = mode === 'leaky' ? Leaky : mode === 'clean' ? Clean : null; + + return ( +
+
+ mode: {mode ?? 'none'} +
+ + + {visible && Target ? : null} +
+ ); +} diff --git a/packages/playwright/__tests__/fixtures/vite-react/src/main.tsx b/packages/playwright/__tests__/fixtures/vite-react/src/main.tsx new file mode 100644 index 000000000..471fe8e3e --- /dev/null +++ b/packages/playwright/__tests__/fixtures/vite-react/src/main.tsx @@ -0,0 +1,6 @@ +import {createRoot} from 'react-dom/client'; +import {App} from './App'; + +// Intentionally NOT wrapping in : its double-mount/unmount +// behavior in dev makes heap snapshots harder to reason about. +createRoot(document.getElementById('root')!).render(); diff --git a/packages/playwright/__tests__/fixtures/vite-react/tsconfig.json b/packages/playwright/__tests__/fixtures/vite-react/tsconfig.json new file mode 100644 index 000000000..d43a24892 --- /dev/null +++ b/packages/playwright/__tests__/fixtures/vite-react/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "noEmit": true, + "isolatedModules": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/packages/playwright/__tests__/fixtures/vite-react/vite.config.ts b/packages/playwright/__tests__/fixtures/vite-react/vite.config.ts new file mode 100644 index 000000000..a7734a8e2 --- /dev/null +++ b/packages/playwright/__tests__/fixtures/vite-react/vite.config.ts @@ -0,0 +1,14 @@ +import {defineConfig} from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5174, + strictPort: true, + host: '127.0.0.1', + // Disable HMR — the injected HMR client keeps websocket-based refs + // that show up in heap snapshots and muddy the leak signal. + hmr: false, + }, +}); diff --git a/packages/playwright/__tests__/playwright.config.ts b/packages/playwright/__tests__/playwright.config.ts index 4e2a9e05a..9c7d4fd1a 100644 --- a/packages/playwright/__tests__/playwright.config.ts +++ b/packages/playwright/__tests__/playwright.config.ts @@ -1,4 +1,7 @@ import {defineConfig, devices} from '@playwright/test'; +import path from 'path'; + +const fixtureDir = path.join(__dirname, 'fixtures', 'vite-react'); export default defineConfig({ testDir: __dirname, @@ -7,6 +10,14 @@ export default defineConfig({ reporter: [['list']], use: { trace: 'off', + baseURL: 'http://127.0.0.1:5174', + }, + webServer: { + command: 'npm run dev', + cwd: fixtureDir, + url: 'http://127.0.0.1:5174', + reuseExistingServer: !process.env.CI, + timeout: 60_000, }, projects: [ { diff --git a/packages/playwright/__tests__/react-leak.spec.ts b/packages/playwright/__tests__/react-leak.spec.ts index 998f18b72..2dd61c52f 100644 --- a/packages/playwright/__tests__/react-leak.spec.ts +++ b/packages/playwright/__tests__/react-leak.spec.ts @@ -1,26 +1,22 @@ -import path from 'path'; -import {pathToFileURL} from 'url'; import {test, expect} from '@memlab/playwright/test'; import {PlaywrightHeapCapturer} from '@memlab/playwright'; -const leakyUrl = pathToFileURL( - path.join(__dirname, 'fixtures', 'react-leak.html'), -).href; -const cleanUrl = pathToFileURL( - path.join(__dirname, 'fixtures', 'clean.html'), -).href; - -async function openThenClose(page: import('@playwright/test').Page, detachSel: string) { +async function openThenClose( + page: import('@playwright/test').Page, + detachSel: string, +) { await page.click('#open'); await page.waitForSelector(detachSel); await page.click('#close'); await page.waitForSelector(detachSel, {state: 'detached'}); } -// Uses the low-level capturer so we can assert on the leak count directly -// without the fixture's auto soft-fail behavior. +// Low-level capturer path: directly assert on the number of leak traces +// without going through the fixture's soft-fail behavior. This verifies +// memlab actually detects the intentional leak in a production-shaped +// React app served over HTTP by Vite. test('leaky React component is detected', async ({page}) => { - await page.goto(leakyUrl); + await page.goto('/?mode=leaky'); await page.waitForSelector('#open'); const capturer = await PlaywrightHeapCapturer.attach(page); @@ -40,10 +36,10 @@ test('leaky React component is detected', async ({page}) => { } }); -// Uses the high-level fixture. Relies on the fixture's auto soft-fail being +// High-level fixture path: relies on the fixture's auto soft-fail being // silent when no leak is found. test('clean React component passes the fixture', async ({page, memlab}) => { - await page.goto(cleanUrl); + await page.goto('/?mode=clean'); await page.waitForSelector('#open'); await memlab.baseline(); @@ -52,7 +48,7 @@ test('clean React component passes the fixture', async ({page, memlab}) => { }); test('no-op when memlab is not destructured', async ({page}) => { - await page.goto(cleanUrl); + await page.goto('/?mode=clean'); await page.waitForSelector('#open'); await page.click('#open'); expect(await page.textContent('#clean')).toContain('50000'); From c106c5fa9df6711e8e93490288de58aa7e7816e6 Mon Sep 17 00:00:00 2001 From: miinhho Date: Tue, 21 Apr 2026 15:42:34 +0900 Subject: [PATCH 4/7] test(playwright): add leak-pattern matrix with size + rule-gap checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 7-closure-pattern × leaky/clean matrix (plus detached-DOM) that asserts each leak's retainer trace includes the retained payload (>=1MB) and that, without a user leakFilter, memlab's built-in rules silently miss closure-retained JS state -- the gap that leakFilter exists to address. Required adding the leakFilter option on PlaywrightHeapCapturer and expanding the fixture App into ?mode=-routed leak-pattern components. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 3 +- packages/playwright/README.md | 14 + .../__tests__/fixtures/vite-react/src/App.tsx | 252 ++++++++++++++++-- .../playwright/__tests__/playwright.config.ts | 1 - .../playwright/__tests__/react-leak.spec.ts | 39 ++- .../__tests__/react-patterns.spec.ts | 221 +++++++++++++++ packages/playwright/package.json | 5 +- packages/playwright/src/capturer.ts | 59 +++- packages/playwright/src/index.ts | 1 + packages/playwright/src/test.ts | 2 + 10 files changed, 547 insertions(+), 50 deletions(-) create mode 100644 packages/playwright/__tests__/react-patterns.spec.ts diff --git a/package.json b/package.json index 87d9e17ba..c6bbc4a6a 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,6 @@ "./packages/cli", "./packages/memlab", "./packages/mcp-server", - "./packages/playwright", - "./packages/playwright/__tests__/fixtures/vite-react" + "./packages/playwright" ] } diff --git a/packages/playwright/README.md b/packages/playwright/README.md index e1a66a6dc..569d878bf 100644 --- a/packages/playwright/README.md +++ b/packages/playwright/README.md @@ -85,6 +85,20 @@ await capturer.dispose(); await browser.close(); ``` +## Framework support + +Verified end-to-end against React 18 on a Vite dev server. Vue 3 and +Svelte 4 were also exercised during development with the same leak pattern +and worked once their respective dev-mode devtools hooks +(`__VUE_DEVTOOLS_HOOK_REPLAY__` etc.) were filtered, but that filtering is +not shipped by default — extend `INSPECTOR_PATTERNS` in `src/test.ts` or +use the low-level capturer to apply your own. + +memlab's default detection is biased toward detached DOM / Fiber patterns. +Pure-JS leaks with no DOM involvement (e.g., module-scope `Set` accumulating +values) may be missed by the default filter and need a custom leak predicate +(tracked separately). + ## Caveats - **Chromium only.** Heap snapshots go over CDP, which Playwright exposes only diff --git a/packages/playwright/__tests__/fixtures/vite-react/src/App.tsx b/packages/playwright/__tests__/fixtures/vite-react/src/App.tsx index 2af72c223..531034bc1 100644 --- a/packages/playwright/__tests__/fixtures/vite-react/src/App.tsx +++ b/packages/playwright/__tests__/fixtures/vite-react/src/App.tsx @@ -1,4 +1,4 @@ -import {useEffect, useState} from 'react'; +import {useEffect, useRef, useState} from 'react'; function makePayload() { const big = new Array(50000); @@ -8,42 +8,260 @@ function makePayload() { return big; } -// Leak: setInterval whose callback closes over state, with NO cleanup. -// When unmounts, React drops the fiber but the interval keeps -// the closure (and thus `payload`) alive. -function Leaky() { +// --- Pattern 1: setInterval ------------------------------------------------ +function IntervalLeaky() { const [payload] = useState(makePayload); useEffect(() => { const id = setInterval(() => { - if (payload.length < 0) console.log('unreachable'); + if (payload.length < 0) console.log('x'); }, 1_000_000); - // no return cleanup, intentionally - return undefined; + void id; }, [payload]); - return
leaky ({payload.length} items)
; + return
interval-leaky
; } - -// Same shape as but with proper clearInterval cleanup. -function Clean() { +function IntervalClean() { const [payload] = useState(makePayload); useEffect(() => { const id = setInterval(() => { - if (payload.length < 0) console.log('unreachable'); + if (payload.length < 0) console.log('x'); }, 1_000_000); return () => clearInterval(id); }, [payload]); - return
clean ({payload.length} items)
; + return
interval-clean
; +} + +// --- Pattern 2: window event listener -------------------------------------- +function WindowListenerLeaky() { + const [payload] = useState(makePayload); + useEffect(() => { + const handler = () => { + if (payload.length < 0) console.log('x'); + }; + window.addEventListener('resize', handler); + // no removeEventListener — window retains handler → payload + }, [payload]); + return
window-listener-leaky
; +} +function WindowListenerClean() { + const [payload] = useState(makePayload); + useEffect(() => { + const handler = () => { + if (payload.length < 0) console.log('x'); + }; + window.addEventListener('resize', handler); + return () => window.removeEventListener('resize', handler); + }, [payload]); + return
window-listener-clean
; +} + +// --- Pattern 3: unresolved Promise holding closure ------------------------- +function PromiseLeaky() { + const [payload] = useState(makePayload); + useEffect(() => { + // Promise that never resolves — the then-callback closure retains payload + // indefinitely. No AbortController. + new Promise(resolve => { + setTimeout(resolve, 10_000_000); + }).then(() => { + if (payload.length < 0) console.log('x'); + }); + }, [payload]); + return
promise-leaky
; } +function PromiseClean() { + const [payload] = useState(makePayload); + useEffect(() => { + const controller = new AbortController(); + new Promise((resolve, reject) => { + const id = setTimeout(resolve, 10_000_000); + controller.signal.addEventListener('abort', () => { + clearTimeout(id); + reject(new DOMException('aborted', 'AbortError')); + }); + }) + .then(() => { + if (payload.length < 0) console.log('x'); + }) + .catch(() => undefined); + return () => controller.abort(); + }, [payload]); + return
promise-clean
; +} + +// --- Pattern 4: external store subscription -------------------------------- +const externalStore = { + subs: new Set<() => void>(), + subscribe(fn: () => void) { + this.subs.add(fn); + return () => this.subs.delete(fn); + }, +}; +function StoreLeaky() { + const [payload] = useState(makePayload); + useEffect(() => { + externalStore.subscribe(() => { + if (payload.length < 0) console.log('x'); + }); + // unsubscribe returned but ignored + }, [payload]); + return
store-leaky
; +} +function StoreClean() { + const [payload] = useState(makePayload); + useEffect(() => { + const unsub = externalStore.subscribe(() => { + if (payload.length < 0) console.log('x'); + }); + return () => { + unsub(); + }; + }, [payload]); + return
store-clean
; +} + +// --- Pattern 5: module-scope array accumulation ---------------------------- +const globalRefs: unknown[] = []; +function GlobalRefLeaky() { + const [payload] = useState(makePayload); + useEffect(() => { + globalRefs.push({payload}); + // no cleanup — module array grows forever + }, [payload]); + return
global-ref-leaky
; +} +function GlobalRefClean() { + const [payload] = useState(makePayload); + useEffect(() => { + const entry = {payload}; + globalRefs.push(entry); + return () => { + const i = globalRefs.indexOf(entry); + if (i >= 0) globalRefs.splice(i, 1); + }; + }, [payload]); + return
global-ref-clean
; +} + +// --- Pattern 6: MutationObserver on document.body without disconnect ------- +// Observing a GC root (document.body) keeps the observer intrinsically +// alive — its callback closure retains `payload` until the observer is +// explicitly disconnected. +function ObserverLeaky() { + const [payload] = useState(makePayload); + useEffect(() => { + const obs = new MutationObserver(() => { + if (payload.length < 0) console.log('x'); + }); + obs.observe(document.body, {childList: true, subtree: true}); + // no obs.disconnect() — observer is reachable from document.body's + // observer registry, callback retains payload + }, [payload]); + return
observer-leaky
; +} +function ObserverClean() { + const [payload] = useState(makePayload); + useEffect(() => { + const obs = new MutationObserver(() => { + if (payload.length < 0) console.log('x'); + }); + obs.observe(document.body, {childList: true, subtree: true}); + return () => obs.disconnect(); + }, [payload]); + return
observer-clean
; +} + +// --- Pattern 7: requestAnimationFrame recursive chain --------------------- +// Different retention class from setInterval: the browser render loop +// re-registers the callback each frame, so the retention is driven by +// rAF's internal queue rather than a timer registry. +function RafLeaky() { + const [payload] = useState(makePayload); + useEffect(() => { + const tick = () => { + if (payload.length < 0) console.log('x'); + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + // no cancelAnimationFrame — render loop keeps closure (→ payload) alive + }, [payload]); + return
raf-leaky
; +} +function RafClean() { + const [payload] = useState(makePayload); + useEffect(() => { + let id = 0; + let stopped = false; + const tick = () => { + if (stopped) return; + if (payload.length < 0) console.log('x'); + id = requestAnimationFrame(tick); + }; + id = requestAnimationFrame(tick); + return () => { + stopped = true; + cancelAnimationFrame(id); + }; + }, [payload]); + return
raf-clean
; +} + +// --- Pattern 8: Detached DOM retained by module-scope var ----------------- +// Exercises FilterDetachedDOMElement (rule #7) specifically. No large +// payload attached, so this does NOT pass a retained-size threshold — +// detection must come from memlab's detached-DOM rule, not size. +const detachedDomStash: HTMLDivElement[] = []; +function DetachedDomLeaky() { + const ref = useRef(null); + useEffect(() => { + if (ref.current) detachedDomStash.push(ref.current); + // no cleanup — on unmount the
is removed from the tree but + // still referenced by detachedDomStash, i.e. it becomes detached DOM + }, []); + return
detached-dom-leaky
; +} +function DetachedDomClean() { + const ref = useRef(null); + useEffect(() => { + const el = ref.current; + if (el) detachedDomStash.push(el); + return () => { + if (!el) return; + const i = detachedDomStash.indexOf(el); + if (i >= 0) detachedDomStash.splice(i, 1); + }; + }, []); + return
detached-dom-clean
; +} + +// --- Mode routing ---------------------------------------------------------- +const COMPONENTS: Record JSX.Element> = { + 'interval-leaky': IntervalLeaky, + 'interval-clean': IntervalClean, + 'window-listener-leaky': WindowListenerLeaky, + 'window-listener-clean': WindowListenerClean, + 'promise-leaky': PromiseLeaky, + 'promise-clean': PromiseClean, + 'store-leaky': StoreLeaky, + 'store-clean': StoreClean, + 'global-ref-leaky': GlobalRefLeaky, + 'global-ref-clean': GlobalRefClean, + 'observer-leaky': ObserverLeaky, + 'observer-clean': ObserverClean, + 'raf-leaky': RafLeaky, + 'raf-clean': RafClean, + 'detached-dom-leaky': DetachedDomLeaky, + 'detached-dom-clean': DetachedDomClean, +}; -function getMode(): 'leaky' | 'clean' | null { +function getMode(): string | null { const m = new URLSearchParams(window.location.search).get('mode'); - return m === 'leaky' || m === 'clean' ? m : null; + return m && m in COMPONENTS ? m : null; } export function App() { const [visible, setVisible] = useState(false); const mode = getMode(); - const Target = mode === 'leaky' ? Leaky : mode === 'clean' ? Clean : null; + const Target = mode ? COMPONENTS[mode] : null; return (
diff --git a/packages/playwright/__tests__/playwright.config.ts b/packages/playwright/__tests__/playwright.config.ts index 9c7d4fd1a..5e78d003e 100644 --- a/packages/playwright/__tests__/playwright.config.ts +++ b/packages/playwright/__tests__/playwright.config.ts @@ -10,7 +10,6 @@ export default defineConfig({ reporter: [['list']], use: { trace: 'off', - baseURL: 'http://127.0.0.1:5174', }, webServer: { command: 'npm run dev', diff --git a/packages/playwright/__tests__/react-leak.spec.ts b/packages/playwright/__tests__/react-leak.spec.ts index 2dd61c52f..20c28ba34 100644 --- a/packages/playwright/__tests__/react-leak.spec.ts +++ b/packages/playwright/__tests__/react-leak.spec.ts @@ -1,55 +1,46 @@ import {test, expect} from '@memlab/playwright/test'; import {PlaywrightHeapCapturer} from '@memlab/playwright'; -async function openThenClose( - page: import('@playwright/test').Page, - detachSel: string, -) { +const BASE = 'http://127.0.0.1:5174'; + +async function openThenClose(page: import('@playwright/test').Page) { await page.click('#open'); - await page.waitForSelector(detachSel); + await page.waitForSelector('#slot'); await page.click('#close'); - await page.waitForSelector(detachSel, {state: 'detached'}); + await page.waitForSelector('#slot', {state: 'detached'}); } -// Low-level capturer path: directly assert on the number of leak traces -// without going through the fixture's soft-fail behavior. This verifies -// memlab actually detects the intentional leak in a production-shaped -// React app served over HTTP by Vite. -test('leaky React component is detected', async ({page}) => { - await page.goto('/?mode=leaky'); +test('[react] leaky component is detected', async ({page}) => { + await page.goto(`${BASE}/?mode=interval-leaky`); await page.waitForSelector('#open'); const capturer = await PlaywrightHeapCapturer.attach(page); try { await capturer.snapshot('baseline'); - await openThenClose(page, '#leaky'); + await openThenClose(page); await capturer.snapshot('target'); await capturer.snapshot('final'); const leaks = await capturer.findLeaks(); expect( leaks.length, - `expected the leaky component to produce at least one leak trace, got ${leaks.length}`, + `expected leaky react component to produce at least one leak, got ${leaks.length}`, ).toBeGreaterThan(0); } finally { await capturer.dispose(); } }); -// High-level fixture path: relies on the fixture's auto soft-fail being -// silent when no leak is found. -test('clean React component passes the fixture', async ({page, memlab}) => { - await page.goto('/?mode=clean'); +test('[react] clean component passes the fixture', async ({page, memlab}) => { + await page.goto(`${BASE}/?mode=interval-clean`); await page.waitForSelector('#open'); - await memlab.baseline(); - await openThenClose(page, '#clean'); - // target + final captured automatically at fixture teardown. + await openThenClose(page); }); -test('no-op when memlab is not destructured', async ({page}) => { - await page.goto('/?mode=clean'); +test('[react] 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('#clean')).toContain('50000'); + expect(await page.textContent('#slot')).toContain('interval-clean'); }); diff --git a/packages/playwright/__tests__/react-patterns.spec.ts b/packages/playwright/__tests__/react-patterns.spec.ts new file mode 100644 index 000000000..1fb591cdc --- /dev/null +++ b/packages/playwright/__tests__/react-patterns.spec.ts @@ -0,0 +1,221 @@ +import {test, expect} from '@playwright/test'; +import type {TestInfo} from '@playwright/test'; +import type {ISerializedInfo} from '@memlab/core'; +import {PlaywrightHeapCapturer, LeakFilterFn} from '@memlab/playwright'; + +const BASE = 'http://127.0.0.1:5174'; + +// Remove CDP-inspector retention artifacts (Playwright's selector handles, +// DevTools console $0-$4) that memlab surfaces because the automation +// harness itself retains detached DOM — not the application. +const INSPECTOR_RX = /DevTools console|\(Inspector[^)]*\)|CommandLineAPI/i; +function stripInspectorArtifacts( + leaks: ISerializedInfo[], +): ISerializedInfo[] { + return leaks.filter( + l => + !Object.keys(l as unknown as Record).some(k => + INSPECTOR_RX.test(k), + ), + ); +} + +// Each leak fixture retains makePayload() ≈ 50k × ~50B ≈ 2.5MB. A 100KB +// floor sits comfortably above Playwright's inspector retention (~600B/node) +// and well below the real leak. The 1MB bound below is the conservative +// size we assert on leaky detections. +const RETAINED_SIZE_THRESHOLD = 100_000; +const PAYLOAD_MIN_BYTES = 1_000_000; +const retainedSizeFilter: LeakFilterFn = node => + node.retainedSize > RETAINED_SIZE_THRESHOLD; + +const PATTERNS = [ + 'interval', + 'window-listener', + 'promise', + 'store', + 'global-ref', + 'observer', + 'raf', +] as const; + +// memlab encodes retained size into ISerializedInfo keys as +// `$retained-size:N` (see packages/core/src/lib/Serializer.ts:40-43). +// Walk the recursive dict and return the max N seen anywhere in the trace. +const RETAIN_SIZE_RX = /\$retained-size:(\d+)/; +function maxRetainedSize(info: ISerializedInfo): number { + let max = 0; + const walk = (value: unknown): void => { + if (value == null || typeof value !== 'object') return; + for (const [key, child] of Object.entries(value)) { + const m = RETAIN_SIZE_RX.exec(key); + if (m) { + const n = parseInt(m[1], 10); + if (n > max) max = n; + } + walk(child); + } + }; + walk(info); + return max; +} + +async function runMode( + page: import('@playwright/test').Page, + mode: string, + leakFilter: LeakFilterFn | undefined, + testInfo: TestInfo, + label: string, +): Promise<{raw: ISerializedInfo[]; filtered: ISerializedInfo[]}> { + await page.goto(`${BASE}/?mode=${mode}`); + await page.waitForSelector('#open'); + const capturer = await PlaywrightHeapCapturer.attach(page, {leakFilter}); + try { + await capturer.snapshot('baseline'); + await page.click('#open'); + await page.waitForSelector('#slot'); + await page.click('#close'); + await page.waitForSelector('#slot', {state: 'detached'}); + await capturer.snapshot('target'); + await capturer.snapshot('final'); + const raw = await capturer.findLeaks(); + const filtered = stripInspectorArtifacts(raw); + await testInfo.attach(`${label}-raw.json`, { + body: JSON.stringify(raw, null, 2), + contentType: 'application/json', + }); + await testInfo.attach(`${label}-filtered.json`, { + body: JSON.stringify(filtered, null, 2), + contentType: 'application/json', + }); + return {raw, filtered}; + } finally { + await capturer.dispose(); + } +} + +for (const pattern of PATTERNS) { + test(`[${pattern}] leaky with retainedSize filter`, async ( + {page}, + testInfo, + ) => { + const {raw, filtered} = await runMode( + page, + `${pattern}-leaky`, + retainedSizeFilter, + testInfo, + pattern, + ); + console.log( + `[${pattern}-leaky] raw=${raw.length} filtered=${filtered.length}`, + ); + expect( + filtered.length, + `expected ${pattern}-leaky to produce ≥1 leak after inspector filter, got ${filtered.length}`, + ).toBeGreaterThan(0); + // Size sanity: the retainer trace should include a node whose retained + // size is at least ~1MB — i.e., the payload we stashed. This guards + // against false positives where memlab flags *something* but it isn't + // the retained payload. + const maxSizes = filtered.map(maxRetainedSize); + expect( + maxSizes.some(s => s >= PAYLOAD_MIN_BYTES), + `expected ≥1 leak with a retainer of ≥${PAYLOAD_MIN_BYTES}B (payload size). maxSizes: [${maxSizes.join( + ',', + )}]`, + ).toBe(true); + }); + + test(`[${pattern}] clean with retainedSize filter`, async ( + {page}, + testInfo, + ) => { + const {raw, filtered} = await runMode( + page, + `${pattern}-clean`, + retainedSizeFilter, + testInfo, + pattern, + ); + console.log( + `[${pattern}-clean] raw=${raw.length} filtered=${filtered.length}`, + ); + expect( + filtered.length, + `expected ${pattern}-clean to produce 0 leaks after inspector filter, got ${filtered.length}`, + ).toBe(0); + }); +} + +// Detached DOM: exercises FilterDetachedDOMElement (rule #7) specifically +// without a size filter. The fixture has no large payload, so a size-based +// filter would actively harm the signal here. +test('[detached-dom] leaky without filter → rule 7 catches it', async ( + {page}, + testInfo, +) => { + const {raw, filtered} = await runMode( + page, + 'detached-dom-leaky', + undefined, + testInfo, + 'detached-dom-leaky', + ); + console.log( + `[detached-dom-leaky] raw=${raw.length} filtered=${filtered.length}`, + ); + expect( + filtered.length, + `expected detached-dom-leaky to produce ≥1 leak via rule 7 alone, got ${filtered.length}`, + ).toBeGreaterThan(0); +}); + +test('[detached-dom] clean without filter', async ({page}, testInfo) => { + const {raw, filtered} = await runMode( + page, + 'detached-dom-clean', + undefined, + testInfo, + 'detached-dom-clean', + ); + console.log( + `[detached-dom-clean] raw=${raw.length} filtered=${filtered.length}`, + ); + expect( + filtered.length, + `expected detached-dom-clean to produce 0 leaks, got ${filtered.length}`, + ).toBe(0); +}); + +// ---- Negative control: built-in rules alone miss closure leaks ----------- +// capturer.ts:63-68 claims that without a user leakFilter, memlab's built-in +// rules flag only detached DOM / React Fiber — so JS closures retaining +// payload (timers, listeners, promises, module arrays, observers, rAF) are +// "silently missed". These tests confirm or contradict that claim. +// +// Using expect.soft so a surprise non-zero result surfaces as information +// without blocking the rest of the suite — the whole point of this matrix +// is to probe exactly what the built-ins do / don't catch. +for (const pattern of PATTERNS) { + test(`[${pattern}] leaky WITHOUT filter (built-in rules only)`, async ( + {page}, + testInfo, + ) => { + const {raw, filtered} = await runMode( + page, + `${pattern}-leaky`, + undefined, + testInfo, + `nofilter-${pattern}`, + ); + console.log( + `[${pattern}-leaky NO-FILTER] raw=${raw.length} filtered=${filtered.length}`, + ); + expect + .soft( + filtered.length, + `capturer docs claim built-ins silently miss closure leaks; ${pattern}-leaky NO-FILTER got ${filtered.length}`, + ) + .toBe(0); + }); +} diff --git a/packages/playwright/package.json b/packages/playwright/package.json index edc0bcff8..cd6dc5f1a 100644 --- a/packages/playwright/package.json +++ b/packages/playwright/package.json @@ -62,9 +62,10 @@ "scripts": { "build-pkg": "tsc", "test-pkg": "echo 'run \"npm run test-e2e\" inside packages/playwright for the Playwright spec'", - "test-e2e": "playwright test --config=__tests__/playwright.config.ts --tsconfig=__tests__/tsconfig.json", + "test-e2e:install-fixtures": "cd __tests__/fixtures/vite-react && npm install --no-audit --no-fund", + "test-e2e": "npm run test-e2e:install-fixtures && 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" + "clean-pkg": "rm -rf ./dist && rm -rf ./node_modules && rm -f ./tsconfig.tsbuildinfo && rm -rf ./test-results && rm -rf ./__tests__/fixtures/*/node_modules" }, "bugs": { "url": "https://github.com/facebook/memlab/issues" diff --git a/packages/playwright/src/capturer.ts b/packages/playwright/src/capturer.ts index ba8b10b59..283345cc1 100644 --- a/packages/playwright/src/capturer.ts +++ b/packages/playwright/src/capturer.ts @@ -12,7 +12,13 @@ import os from 'os'; import path from 'path'; import fs from 'fs-extra'; import {ConsoleMode, SnapshotResultReader, findLeaks} from '@memlab/api'; -import type {ISerializedInfo} from '@memlab/core'; +import {config as memlabConfig} from '@memlab/core'; +import type { + IHeapNode, + IHeapSnapshot, + ILeakFilter, + ISerializedInfo, +} from '@memlab/core'; import {CDPLike, forceFullGC, writeHeapSnapshot} from './snapshot'; @@ -27,6 +33,17 @@ export interface PageLike { export type PhaseLabel = 'baseline' | 'target' | 'final'; +/** + * Callback to decide whether an allocated-but-not-released heap node should + * be reported as a leak. Mirrors memlab's `ILeakFilter.leakFilter` signature + * so users can reuse the same shape. + */ +export type LeakFilterFn = ( + node: IHeapNode, + snapshot: IHeapSnapshot, + leakedNodeIds: Set, +) => boolean; + export type PlaywrightHeapCapturerOptions = { /** * Working directory for intermediate snapshot files. Defaults to a fresh @@ -38,6 +55,18 @@ export type PlaywrightHeapCapturerOptions = { cleanupOnDispose?: boolean; /** Repeat count for forced GC cycles before the final snapshot. Default 6. */ gcRepeat?: number; + /** + * Custom leak filter applied during `findLeaks`. Receives every heap + * object allocated between baseline/target that's still live at final, + * and returns `true` for objects to report as leaks. + * + * Without this, memlab's built-in filter only flags detached DOM / React + * Fiber nodes — so closure-retained JS state (event listeners, timers, + * external store subscriptions, module-scope arrays) is silently missed. + * A retained-size threshold is usually the simplest useful filter: + * `(node) => node.retainedSize > 100_000`. + */ + leakFilter?: LeakFilterFn; }; /** @@ -61,6 +90,7 @@ export default class PlaywrightHeapCapturer { private readonly workDir: string; private readonly cleanupOnDispose: boolean; private readonly gcRepeat: number; + private readonly leakFilter: LeakFilterFn | undefined; private readonly snapshotPaths: Partial> = {}; private disposed = false; @@ -70,12 +100,14 @@ export default class PlaywrightHeapCapturer { workDir: string, cleanupOnDispose: boolean, gcRepeat: number, + leakFilter: LeakFilterFn | undefined, ) { this.page = page; this.session = session; this.workDir = workDir; this.cleanupOnDispose = cleanupOnDispose; this.gcRepeat = gcRepeat; + this.leakFilter = leakFilter; } static async attach( @@ -97,6 +129,7 @@ export default class PlaywrightHeapCapturer { workDir, options.cleanupOnDispose ?? autoDir, options.gcRepeat ?? 6, + options.leakFilter, ); } @@ -129,9 +162,14 @@ export default class PlaywrightHeapCapturer { /** * Run memlab leak detection across the captured baseline/target/final - * snapshots. Throws if any of the three is missing. + * snapshots. Throws if any of the three is missing. If a `leakFilter` + * was passed to `attach()`, it is installed on memlab's global config for + * the duration of this call and restored afterwards (memlab does not + * accept a per-call filter through the `findLeaks` API). */ - async findLeaks(): Promise { + async findLeaks( + overrides: {leakFilter?: LeakFilterFn} = {}, + ): Promise { this.assertLive(); const {baseline, target, final} = this.snapshotPaths; if (!baseline || !target || !final) { @@ -140,7 +178,20 @@ export default class PlaywrightHeapCapturer { ); } const reader = SnapshotResultReader.fromSnapshots(baseline, target, final); - return findLeaks(reader, {consoleMode: ConsoleMode.SILENT}); + const filter = overrides.leakFilter ?? this.leakFilter; + + if (!filter) { + return findLeaks(reader, {consoleMode: ConsoleMode.SILENT}); + } + + const externalFilter: ILeakFilter = {leakFilter: filter}; + const prev = memlabConfig.externalLeakFilter; + memlabConfig.externalLeakFilter = externalFilter; + try { + return await findLeaks(reader, {consoleMode: ConsoleMode.SILENT}); + } finally { + memlabConfig.externalLeakFilter = prev; + } } async dispose(): Promise { diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index a0ef9c17d..16d2d1d9f 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -10,6 +10,7 @@ export {default as PlaywrightHeapCapturer} from './capturer'; export type { + LeakFilterFn, PageLike, PhaseLabel, PlaywrightHeapCapturerOptions, diff --git a/packages/playwright/src/test.ts b/packages/playwright/src/test.ts index ccc74776c..27434437b 100644 --- a/packages/playwright/src/test.ts +++ b/packages/playwright/src/test.ts @@ -155,6 +155,8 @@ export const test = baseTest.extend<{memlab: MemlabFixture}>({ }); const INSPECTOR_PATTERNS = [ + // Chrome DevTools / CDP inspector retention of DOM refs (Playwright uses + // this for selector queries, page.evaluate results, etc.) /DevTools console/i, /\(Inspector[^)]*\)/i, /CommandLineAPI/i, From dfb08ade191f4dfa2f501c4fc43c788ec088f92a Mon Sep 17 00:00:00 2001 From: miinhho Date: Thu, 23 Apr 2026 00:36:57 +0900 Subject: [PATCH 5/7] refactor(playwright): align with memlab conventions and fix leak reporting Split fixture into concern-scoped modules (types.ts, leak.ts, fixture.ts, thin index.ts barrel). Collapse the `@memlab/playwright/test` subpath into the root entry so users import from `@memlab/playwright`. Bring package metadata in line with other memlab packages: add LICENSE, publishConfig.access=public, tsconfig extends ../../tsconfig.base.json, test-pkg runs the e2e suite, README trimmed to the house format. Fix leakSummary: ISerializedInfo is a recursive dict where retainedSize is encoded inside keys as \`\$retained-size:N\`, so reading flat \`retainedSize\`/\`name\`/\`type\` was always undefined and the auto-mode teardown message always rendered \"#1: leak\". It now picks the first non-\$tabsOrder trace key as the one-line digest. isInspectorArtifact walks the full serialized trace instead of only top-level keys so DevTools/Inspector retainers appearing mid-chain are filtered too. Expose GC tuning through MemlabFixture.configure({gc: {...}}) instead of the hardcoded 6-cycle; tighten CDPLike (send returns Promise, event handlers stay \`any\` to accept narrower Playwright/Puppeteer signatures under strict function types). Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + packages/playwright/LICENSE | 21 ++ packages/playwright/README.md | 121 ++-------- .../playwright/__tests__/playwright.config.ts | 2 +- .../playwright/__tests__/react-leak.spec.ts | 36 ++- .../__tests__/react-patterns.spec.ts | 212 ++++++----------- packages/playwright/package.json | 11 +- packages/playwright/src/capturer.ts | 215 ------------------ packages/playwright/src/fixture.ts | 196 ++++++++++++++++ packages/playwright/src/index.ts | 13 +- packages/playwright/src/leak.ts | 92 ++++++++ packages/playwright/src/snapshot.ts | 32 ++- packages/playwright/src/test.ts | 188 --------------- packages/playwright/src/types.ts | 44 ++++ packages/playwright/tsconfig.json | 27 +-- 15 files changed, 491 insertions(+), 720 deletions(-) create mode 100644 packages/playwright/LICENSE delete mode 100644 packages/playwright/src/capturer.ts create mode 100644 packages/playwright/src/fixture.ts create mode 100644 packages/playwright/src/leak.ts delete mode 100644 packages/playwright/src/test.ts create mode 100644 packages/playwright/src/types.ts diff --git a/.gitignore b/.gitignore index 086ccd07a..93e3039ad 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ /dist/packages /tmp packages/**/test-results/ +packages/**/playwright-report/ lerna-debug.log build.log node_modules 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 index 569d878bf..9f566f8d0 100644 --- a/packages/playwright/README.md +++ b/packages/playwright/README.md @@ -1,112 +1,27 @@ -# @memlab/playwright +## memlab Playwright -Attach memlab memory-leak detection to existing Playwright tests. Two entry -points, pick the one that fits your codebase. - -## Install - -```bash -npm install --save-dev @memlab/playwright @playwright/test -``` - -## 1. Drop-in `test` with the `memlab` fixture - -Swap the `@playwright/test` import for `@memlab/playwright/test`. Any test -that destructures `memlab` gets heap capture + leak analysis; tests that -don't are untouched. +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'; +import {test, expect} from '@memlab/playwright'; -test('closing a modal does not leak', async ({ page, memlab }) => { +test('closing a modal does not leak', async ({page, memlab}) => { await page.goto('http://localhost:3000'); - await memlab.baseline(); // before the action - await page.getByRole('button', { name: 'Open' }).click(); - await page.getByRole('button', { name: 'Close' }).click(); - // target + final are captured automatically at teardown + 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). }); ``` -The opt-in is the parameter destructure — no tags, no config. TypeScript -catches typos. Omit `memlab` from the parameter list and the fixture never -runs (no CDP session, no cost). - -### Phase capture - -| Phase | When captured | Override | -| --- | --- | --- | -| `baseline` | Fallback: just before `target` (degenerate — you almost always want to call `await memlab.baseline()` explicitly after navigation) | `await memlab.baseline()` | -| `target` | End of test body | `await memlab.target()` | -| `final` | After a forced GC cycle, before teardown | `await memlab.final()` | - -memlab's leak algorithm compares objects present at `target` that are still -retained at `final` but were **not** at `baseline`. Good shapes: - -- Open component / close component / assert -- Navigate to view / navigate away / assert -- Mount widget / unmount widget / assert - -### Output - -On every run with `memlab` in the params: - -- `memlab-leaks.json` attached to the test — parsed leak traces -- `baseline.heapsnapshot`, `target.heapsnapshot`, `final.heapsnapshot` - attached — open in Chrome DevTools → Memory → Load - -If any leak is reported, the test is marked failed via `expect.soft` (other -assertions still run). - -## 2. Low-level capturer - -If you have a custom runner or want manual control without the fixture: - -```ts -import { chromium } from 'playwright'; -import { PlaywrightHeapCapturer } from '@memlab/playwright'; - -const browser = await chromium.launch(); -const page = await browser.newPage(); -await page.goto('http://localhost:3000'); - -const capturer = await PlaywrightHeapCapturer.attach(page); -await capturer.snapshot('baseline'); - -await page.click('text=Open'); -await capturer.snapshot('target'); - -await page.click('text=Close'); -await capturer.snapshot('final'); - -const leaks = await capturer.findLeaks(); -console.log(`Found ${leaks.length} leak trace(s)`); - -await capturer.dispose(); -await browser.close(); -``` - -## Framework support - -Verified end-to-end against React 18 on a Vite dev server. Vue 3 and -Svelte 4 were also exercised during development with the same leak pattern -and worked once their respective dev-mode devtools hooks -(`__VUE_DEVTOOLS_HOOK_REPLAY__` etc.) were filtered, but that filtering is -not shipped by default — extend `INSPECTOR_PATTERNS` in `src/test.ts` or -use the low-level capturer to apply your own. - -memlab's default detection is biased toward detached DOM / Fiber patterns. -Pure-JS leaks with no DOM involvement (e.g., module-scope `Set` accumulating -values) may be missed by the default filter and need a custom leak predicate -(tracked separately). - -## Caveats +Chromium only — heap snapshots go over CDP, which Playwright exposes +only for Chromium. Firefox / WebKit projects get a `memlab-skip` +annotation and a no-op fixture. -- **Chromium only.** Heap snapshots go over CDP, which Playwright exposes only - for Chromium. Firefox / WebKit projects get an annotation on the test - (`memlab-skip`) and a no-op fixture. -- **Snapshots are large** (tens of MB each). The fixture writes them to the - test's output directory; `PlaywrightHeapCapturer` writes to an OS temp dir - by default and cleans up on `dispose()`. -- **GC is heuristic.** memlab asks V8 to collect garbage before `final`, but - references kept by long-lived structures by design will still appear as - leaks. Triage with the JSON report and the raw snapshots. +## 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__/playwright.config.ts b/packages/playwright/__tests__/playwright.config.ts index 5e78d003e..e83ad3ce1 100644 --- a/packages/playwright/__tests__/playwright.config.ts +++ b/packages/playwright/__tests__/playwright.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ testDir: __dirname, testMatch: /.*\.spec\.ts$/, fullyParallel: false, - reporter: [['list']], + reporter: [['list'], ['html', {open: 'never'}]], use: { trace: 'off', }, diff --git a/packages/playwright/__tests__/react-leak.spec.ts b/packages/playwright/__tests__/react-leak.spec.ts index 20c28ba34..f0cb93c43 100644 --- a/packages/playwright/__tests__/react-leak.spec.ts +++ b/packages/playwright/__tests__/react-leak.spec.ts @@ -1,5 +1,4 @@ -import {test, expect} from '@memlab/playwright/test'; -import {PlaywrightHeapCapturer} from '@memlab/playwright'; +import {test, expect} from '@memlab/playwright'; const BASE = 'http://127.0.0.1:5174'; @@ -10,29 +9,24 @@ async function openThenClose(page: import('@playwright/test').Page) { await page.waitForSelector('#slot', {state: 'detached'}); } -test('[react] leaky component is detected', async ({page}) => { - await page.goto(`${BASE}/?mode=interval-leaky`); +// Uses detached-dom-{leaky,clean} so the built-in rule chain (rule 7, +// FilterDetachedDOMElement) catches the leak without a user leakFilter. +test('[react] leaky component is detected', async ({page, memlab}) => { + await page.goto(`${BASE}/?mode=detached-dom-leaky`); await page.waitForSelector('#open'); - - const capturer = await PlaywrightHeapCapturer.attach(page); - try { - await capturer.snapshot('baseline'); - await openThenClose(page); - await capturer.snapshot('target'); - await capturer.snapshot('final'); - - const leaks = await capturer.findLeaks(); - expect( - leaks.length, - `expected leaky react component to produce at least one leak, got ${leaks.length}`, - ).toBeGreaterThan(0); - } finally { - await capturer.dispose(); - } + await memlab.baseline(); + await openThenClose(page); + const leaks = await memlab.findLeaks(); + expect( + leaks?.length ?? 0, + `expected leaky react component to produce at least one leak, got ${ + leaks?.length ?? 0 + }`, + ).toBeGreaterThan(0); }); test('[react] clean component passes the fixture', async ({page, memlab}) => { - await page.goto(`${BASE}/?mode=interval-clean`); + await page.goto(`${BASE}/?mode=detached-dom-clean`); await page.waitForSelector('#open'); await memlab.baseline(); await openThenClose(page); diff --git a/packages/playwright/__tests__/react-patterns.spec.ts b/packages/playwright/__tests__/react-patterns.spec.ts index 1fb591cdc..509a2136f 100644 --- a/packages/playwright/__tests__/react-patterns.spec.ts +++ b/packages/playwright/__tests__/react-patterns.spec.ts @@ -1,29 +1,12 @@ -import {test, expect} from '@playwright/test'; -import type {TestInfo} from '@playwright/test'; +import {test, expect} from '@memlab/playwright'; +import type {LeakFilterFn} from '@memlab/playwright'; import type {ISerializedInfo} from '@memlab/core'; -import {PlaywrightHeapCapturer, LeakFilterFn} from '@memlab/playwright'; const BASE = 'http://127.0.0.1:5174'; -// Remove CDP-inspector retention artifacts (Playwright's selector handles, -// DevTools console $0-$4) that memlab surfaces because the automation -// harness itself retains detached DOM — not the application. -const INSPECTOR_RX = /DevTools console|\(Inspector[^)]*\)|CommandLineAPI/i; -function stripInspectorArtifacts( - leaks: ISerializedInfo[], -): ISerializedInfo[] { - return leaks.filter( - l => - !Object.keys(l as unknown as Record).some(k => - INSPECTOR_RX.test(k), - ), - ); -} - // Each leak fixture retains makePayload() ≈ 50k × ~50B ≈ 2.5MB. A 100KB -// floor sits comfortably above Playwright's inspector retention (~600B/node) -// and well below the real leak. The 1MB bound below is the conservative -// size we assert on leaky detections. +// floor sits comfortably above Playwright's inspector retention and well +// below the real leak; 1MB is the conservative lower bound we assert on. const RETAINED_SIZE_THRESHOLD = 100_000; const PAYLOAD_MIN_BYTES = 1_000_000; const retainedSizeFilter: LeakFilterFn = node => @@ -40,8 +23,8 @@ const PATTERNS = [ ] as const; // memlab encodes retained size into ISerializedInfo keys as -// `$retained-size:N` (see packages/core/src/lib/Serializer.ts:40-43). -// Walk the recursive dict and return the max N seen anywhere in the trace. +// `$retained-size:N` (packages/core/src/lib/Serializer.ts:40-43). Walk +// the recursive dict and return the max N seen anywhere in the trace. const RETAIN_SIZE_RX = /\$retained-size:(\d+)/; function maxRetainedSize(info: ISerializedInfo): number { let max = 0; @@ -60,162 +43,95 @@ function maxRetainedSize(info: ISerializedInfo): number { return max; } -async function runMode( +async function runPattern( page: import('@playwright/test').Page, + memlab: import('@memlab/playwright').MemlabFixture, mode: string, - leakFilter: LeakFilterFn | undefined, - testInfo: TestInfo, - label: string, -): Promise<{raw: ISerializedInfo[]; filtered: ISerializedInfo[]}> { +): Promise { await page.goto(`${BASE}/?mode=${mode}`); await page.waitForSelector('#open'); - const capturer = await PlaywrightHeapCapturer.attach(page, {leakFilter}); - try { - await capturer.snapshot('baseline'); - await page.click('#open'); - await page.waitForSelector('#slot'); - await page.click('#close'); - await page.waitForSelector('#slot', {state: 'detached'}); - await capturer.snapshot('target'); - await capturer.snapshot('final'); - const raw = await capturer.findLeaks(); - const filtered = stripInspectorArtifacts(raw); - await testInfo.attach(`${label}-raw.json`, { - body: JSON.stringify(raw, null, 2), - contentType: 'application/json', - }); - await testInfo.attach(`${label}-filtered.json`, { - body: JSON.stringify(filtered, null, 2), - contentType: 'application/json', - }); - return {raw, filtered}; - } finally { - await capturer.dispose(); - } + await memlab.baseline(); + await page.click('#open'); + await page.waitForSelector('#slot'); + await page.click('#close'); + await page.waitForSelector('#slot', {state: 'detached'}); + const leaks = await memlab.findLeaks(); + return leaks ?? []; } for (const pattern of PATTERNS) { - test(`[${pattern}] leaky with retainedSize filter`, async ( - {page}, - testInfo, - ) => { - const {raw, filtered} = await runMode( - page, - `${pattern}-leaky`, - retainedSizeFilter, - testInfo, - pattern, - ); - console.log( - `[${pattern}-leaky] raw=${raw.length} filtered=${filtered.length}`, - ); + test(`[${pattern}] leaky with retainedSize filter`, async ({ + page, + memlab, + }) => { + memlab.configure({leakFilter: retainedSizeFilter}); + const leaks = await runPattern(page, memlab, `${pattern}-leaky`); + console.log(`[${pattern}-leaky] leaks=${leaks.length}`); expect( - filtered.length, - `expected ${pattern}-leaky to produce ≥1 leak after inspector filter, got ${filtered.length}`, + leaks.length, + `expected ${pattern}-leaky to produce ≥1 leak, got ${leaks.length}`, ).toBeGreaterThan(0); - // Size sanity: the retainer trace should include a node whose retained - // size is at least ~1MB — i.e., the payload we stashed. This guards - // against false positives where memlab flags *something* but it isn't - // the retained payload. - const maxSizes = filtered.map(maxRetainedSize); + // Size sanity: retainer trace should include a node of ~payload size. + const maxSizes = leaks.map(maxRetainedSize); expect( maxSizes.some(s => s >= PAYLOAD_MIN_BYTES), - `expected ≥1 leak with a retainer of ≥${PAYLOAD_MIN_BYTES}B (payload size). maxSizes: [${maxSizes.join( + `expected ≥1 leak with a retainer ≥${PAYLOAD_MIN_BYTES}B. maxSizes: [${maxSizes.join( ',', )}]`, ).toBe(true); }); - test(`[${pattern}] clean with retainedSize filter`, async ( - {page}, - testInfo, - ) => { - const {raw, filtered} = await runMode( - page, - `${pattern}-clean`, - retainedSizeFilter, - testInfo, - pattern, - ); - console.log( - `[${pattern}-clean] raw=${raw.length} filtered=${filtered.length}`, - ); + test(`[${pattern}] clean with retainedSize filter`, async ({ + page, + memlab, + }) => { + memlab.configure({leakFilter: retainedSizeFilter}); + const leaks = await runPattern(page, memlab, `${pattern}-clean`); + console.log(`[${pattern}-clean] leaks=${leaks.length}`); expect( - filtered.length, - `expected ${pattern}-clean to produce 0 leaks after inspector filter, got ${filtered.length}`, + leaks.length, + `expected ${pattern}-clean to produce 0 leaks, got ${leaks.length}`, ).toBe(0); }); } // Detached DOM: exercises FilterDetachedDOMElement (rule #7) specifically -// without a size filter. The fixture has no large payload, so a size-based +// without a leakFilter. The fixture has no large payload, so a size-based // filter would actively harm the signal here. -test('[detached-dom] leaky without filter → rule 7 catches it', async ( - {page}, - testInfo, -) => { - const {raw, filtered} = await runMode( - page, - 'detached-dom-leaky', - undefined, - testInfo, - 'detached-dom-leaky', - ); - console.log( - `[detached-dom-leaky] raw=${raw.length} filtered=${filtered.length}`, - ); +test('[detached-dom] leaky without filter → rule 7 catches it', async ({ + page, + memlab, +}) => { + const leaks = await runPattern(page, memlab, 'detached-dom-leaky'); + console.log(`[detached-dom-leaky] leaks=${leaks.length}`); expect( - filtered.length, - `expected detached-dom-leaky to produce ≥1 leak via rule 7 alone, got ${filtered.length}`, + leaks.length, + `expected detached-dom-leaky to produce ≥1 leak via rule 7, got ${leaks.length}`, ).toBeGreaterThan(0); }); -test('[detached-dom] clean without filter', async ({page}, testInfo) => { - const {raw, filtered} = await runMode( - page, - 'detached-dom-clean', - undefined, - testInfo, - 'detached-dom-clean', - ); - console.log( - `[detached-dom-clean] raw=${raw.length} filtered=${filtered.length}`, - ); +test('[detached-dom] clean without filter', async ({page, memlab}) => { + const leaks = await runPattern(page, memlab, 'detached-dom-clean'); + console.log(`[detached-dom-clean] leaks=${leaks.length}`); expect( - filtered.length, - `expected detached-dom-clean to produce 0 leaks, got ${filtered.length}`, + leaks.length, + `expected detached-dom-clean to produce 0 leaks, got ${leaks.length}`, ).toBe(0); }); -// ---- Negative control: built-in rules alone miss closure leaks ----------- -// capturer.ts:63-68 claims that without a user leakFilter, memlab's built-in -// rules flag only detached DOM / React Fiber — so JS closures retaining -// payload (timers, listeners, promises, module arrays, observers, rAF) are -// "silently missed". These tests confirm or contradict that claim. -// -// Using expect.soft so a surprise non-zero result surfaces as information -// without blocking the rest of the suite — the whole point of this matrix -// is to probe exactly what the built-ins do / don't catch. +// Negative control: without a user leakFilter, memlab's built-in rules +// should miss closure-retained JS state. These tests assert 0 leaks so a +// non-zero result surfaces as a test failure that's worth investigating. for (const pattern of PATTERNS) { - test(`[${pattern}] leaky WITHOUT filter (built-in rules only)`, async ( - {page}, - testInfo, - ) => { - const {raw, filtered} = await runMode( - page, - `${pattern}-leaky`, - undefined, - testInfo, - `nofilter-${pattern}`, - ); - console.log( - `[${pattern}-leaky NO-FILTER] raw=${raw.length} filtered=${filtered.length}`, - ); - expect - .soft( - filtered.length, - `capturer docs claim built-ins silently miss closure leaks; ${pattern}-leaky NO-FILTER got ${filtered.length}`, - ) - .toBe(0); + test(`[${pattern}] leaky WITHOUT filter (built-in rules only)`, async ({ + page, + memlab, + }) => { + const leaks = await runPattern(page, memlab, `${pattern}-leaky`); + console.log(`[${pattern}-leaky NO-FILTER] leaks=${leaks.length}`); + expect( + leaks.length, + `built-in rules should miss ${pattern}-leaky without a leakFilter, got ${leaks.length}`, + ).toBe(0); }); } diff --git a/packages/playwright/package.json b/packages/playwright/package.json index cd6dc5f1a..9c1169250 100644 --- a/packages/playwright/package.json +++ b/packages/playwright/package.json @@ -20,16 +20,15 @@ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" - }, - "./test": { - "types": "./dist/test.d.ts", - "default": "./dist/test.js" } }, "files": [ "dist", "LICENSE" ], + "publishConfig": { + "access": "public" + }, "dependencies": { "@memlab/api": "^2.0.2", "@memlab/core": "^2.0.2", @@ -61,11 +60,11 @@ }, "scripts": { "build-pkg": "tsc", - "test-pkg": "echo 'run \"npm run test-e2e\" inside packages/playwright for the Playwright spec'", + "test-pkg": "npm run test-e2e", "test-e2e:install-fixtures": "cd __tests__/fixtures/vite-react && npm install --no-audit --no-fund", "test-e2e": "npm run test-e2e:install-fixtures && 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 ./__tests__/fixtures/*/node_modules" + "clean-pkg": "rm -rf ./dist && rm -rf ./node_modules && rm -f ./tsconfig.tsbuildinfo && rm -rf ./test-results && rm -rf ./playwright-report && rm -rf ./__tests__/fixtures/*/node_modules" }, "bugs": { "url": "https://github.com/facebook/memlab/issues" diff --git a/packages/playwright/src/capturer.ts b/packages/playwright/src/capturer.ts deleted file mode 100644 index 283345cc1..000000000 --- a/packages/playwright/src/capturer.ts +++ /dev/null @@ -1,215 +0,0 @@ -/** - * 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 {ConsoleMode, SnapshotResultReader, findLeaks} from '@memlab/api'; -import {config as memlabConfig} from '@memlab/core'; -import type { - IHeapNode, - IHeapSnapshot, - ILeakFilter, - ISerializedInfo, -} from '@memlab/core'; - -import {CDPLike, forceFullGC, writeHeapSnapshot} from './snapshot'; - -// Minimal Playwright Page surface we rely on. Kept as a structural type so -// consumers don't need `playwright` installed for the low-level capturer to -// type-check against arbitrary page-likes. -export interface PageLike { - context(): { - newCDPSession(page: PageLike): Promise; - }; -} - -export type PhaseLabel = 'baseline' | 'target' | 'final'; - -/** - * Callback to decide whether an allocated-but-not-released heap node should - * be reported as a leak. Mirrors memlab's `ILeakFilter.leakFilter` signature - * so users can reuse the same shape. - */ -export type LeakFilterFn = ( - node: IHeapNode, - snapshot: IHeapSnapshot, - leakedNodeIds: Set, -) => boolean; - -export type PlaywrightHeapCapturerOptions = { - /** - * Working directory for intermediate snapshot files. Defaults to a fresh - * directory under the OS temp dir. The caller owns cleanup unless - * `cleanupOnDispose` is true. - */ - workDir?: string; - /** Delete workDir on dispose(). Default: true when workDir is auto-generated. */ - cleanupOnDispose?: boolean; - /** Repeat count for forced GC cycles before the final snapshot. Default 6. */ - gcRepeat?: number; - /** - * Custom leak filter applied during `findLeaks`. Receives every heap - * object allocated between baseline/target that's still live at final, - * and returns `true` for objects to report as leaks. - * - * Without this, memlab's built-in filter only flags detached DOM / React - * Fiber nodes — so closure-retained JS state (event listeners, timers, - * external store subscriptions, module-scope arrays) is silently missed. - * A retained-size threshold is usually the simplest useful filter: - * `(node) => node.retainedSize > 100_000`. - */ - leakFilter?: LeakFilterFn; -}; - -/** - * Low-level capturer: attach to a Playwright Page, take baseline/target/final - * snapshots on demand, then hand them to memlab's leak detector. - * - * ```ts - * const capturer = await PlaywrightHeapCapturer.attach(page); - * await capturer.snapshot('baseline'); - * await page.click('text=Open'); - * await capturer.snapshot('target'); - * await page.click('text=Close'); - * await capturer.snapshot('final'); // runs full GC first - * const leaks = await capturer.findLeaks(); - * await capturer.dispose(); - * ``` - */ -export default class PlaywrightHeapCapturer { - private readonly page: PageLike; - private readonly session: CDPLike; - private readonly workDir: string; - private readonly cleanupOnDispose: boolean; - private readonly gcRepeat: number; - private readonly leakFilter: LeakFilterFn | undefined; - private readonly snapshotPaths: Partial> = {}; - private disposed = false; - - private constructor( - page: PageLike, - session: CDPLike, - workDir: string, - cleanupOnDispose: boolean, - gcRepeat: number, - leakFilter: LeakFilterFn | undefined, - ) { - this.page = page; - this.session = session; - this.workDir = workDir; - this.cleanupOnDispose = cleanupOnDispose; - this.gcRepeat = gcRepeat; - this.leakFilter = leakFilter; - } - - static async attach( - page: PageLike, - options: PlaywrightHeapCapturerOptions = {}, - ): Promise { - const autoDir = options.workDir == null; - const workDir = - options.workDir ?? - fs.mkdtempSync(path.join(os.tmpdir(), 'memlab-playwright-')); - fs.ensureDirSync(workDir); - - const session = await page.context().newCDPSession(page); - await session.send('HeapProfiler.enable'); - - return new PlaywrightHeapCapturer( - page, - session, - workDir, - options.cleanupOnDispose ?? autoDir, - options.gcRepeat ?? 6, - options.leakFilter, - ); - } - - /** - * Take a named heap snapshot. For `'final'`, a full GC cycle runs first so - * memlab's leak detector sees only objects retained past cleanup. - */ - async snapshot(label: PhaseLabel): Promise { - this.assertLive(); - if (label === 'final') { - await forceFullGC(this.session, {repeat: this.gcRepeat}); - } - const file = path.join(this.workDir, `${label}.heapsnapshot`); - await writeHeapSnapshot(this.session, file); - this.snapshotPaths[label] = file; - return file; - } - - getSnapshotPath(label: PhaseLabel): string | undefined { - return this.snapshotPaths[label]; - } - - hasAllSnapshots(): boolean { - return ( - this.snapshotPaths.baseline != null && - this.snapshotPaths.target != null && - this.snapshotPaths.final != null - ); - } - - /** - * Run memlab leak detection across the captured baseline/target/final - * snapshots. Throws if any of the three is missing. If a `leakFilter` - * was passed to `attach()`, it is installed on memlab's global config for - * the duration of this call and restored afterwards (memlab does not - * accept a per-call filter through the `findLeaks` API). - */ - async findLeaks( - overrides: {leakFilter?: LeakFilterFn} = {}, - ): Promise { - this.assertLive(); - const {baseline, target, final} = this.snapshotPaths; - if (!baseline || !target || !final) { - throw new Error( - 'PlaywrightHeapCapturer.findLeaks requires baseline, target, and final snapshots', - ); - } - const reader = SnapshotResultReader.fromSnapshots(baseline, target, final); - const filter = overrides.leakFilter ?? this.leakFilter; - - if (!filter) { - return findLeaks(reader, {consoleMode: ConsoleMode.SILENT}); - } - - const externalFilter: ILeakFilter = {leakFilter: filter}; - const prev = memlabConfig.externalLeakFilter; - memlabConfig.externalLeakFilter = externalFilter; - try { - return await findLeaks(reader, {consoleMode: ConsoleMode.SILENT}); - } finally { - memlabConfig.externalLeakFilter = prev; - } - } - - async dispose(): Promise { - if (this.disposed) return; - this.disposed = true; - try { - await this.session.send('HeapProfiler.disable'); - } catch { - // session may already be closed with the page - } - if (this.cleanupOnDispose) { - await fs.remove(this.workDir).catch(() => undefined); - } - } - - private assertLive(): void { - if (this.disposed) { - throw new Error('PlaywrightHeapCapturer has already been disposed'); - } - } -} diff --git a/packages/playwright/src/fixture.ts b/packages/playwright/src/fixture.ts new file mode 100644 index 000000000..7bb56aebe --- /dev/null +++ b/packages/playwright/src/fixture.ts @@ -0,0 +1,196 @@ +/** + * 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, +} from './leak'; +import {PHASE_LABELS} from './types'; +import type { + MemlabConfigInput, + MemlabFixture, + PhaseLabel, +} from './types'; + +/** + * Drop-in replacement for `@playwright/test`'s `test`. Destructure + * `memlab` in the test body to enable heap capture + leak analysis. + * + * @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 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; + if (!snapshotPaths.target) await takeSnapshot('target'); + if (!snapshotPaths.final) await takeSnapshot('final'); + cachedLeaks = await detectLeaks(); + return cachedLeaks; + }, + }; + + 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 && allPhasesCaptured(snapshotPaths)) { + await attachArtifacts(testInfo, cachedLeaks, snapshotPaths); + } + if (!manualVerified && cachedLeaks != null && cachedLeaks.length > 0) { + expect + .soft(cachedLeaks, formatLeakMessage(cachedLeaks)) + .toHaveLength(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}); + 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, + }; +} diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index 16d2d1d9f..ad9e08362 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -8,12 +8,13 @@ * @oncall memory_lab */ -export {default as PlaywrightHeapCapturer} from './capturer'; +export {test, expect} from './fixture'; +export {PHASE_LABELS} from './types'; export type { LeakFilterFn, - PageLike, + MemlabConfigInput, + MemlabFixture, + MemlabGCOptions, + Page, PhaseLabel, - PlaywrightHeapCapturerOptions, -} from './capturer'; -export type {CDPLike} from './snapshot'; -export {writeHeapSnapshot, forceFullGC} from './snapshot'; +} from './types'; diff --git a/packages/playwright/src/leak.ts b/packages/playwright/src/leak.ts new file mode 100644 index 000000000..8f2c14eed --- /dev/null +++ b/packages/playwright/src/leak.ts @@ -0,0 +1,92 @@ +/** + * 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'; + +// Inspector retention owned by CDP ($0-$4, selector handles). These +// labels are chrome-devtools-internal, so application node names cannot +// collide — safe to walk the full serialized trace. +const INSPECTOR_PATTERNS = [ + /DevTools console/i, + /\(Inspector[^)]*\)/i, + /CommandLineAPI/i, +]; + +const SUMMARY_MAX_LEN = 140; +const SUMMARY_TOP_N = 5; + +/** @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 trace = Object.keys(leak).find(k => !k.startsWith('$tabsOrder')); + if (!trace) return 'leak'; + const clean = trace.replace(/\s+/g, ' ').trim(); + return clean.length > SUMMARY_MAX_LEN + ? clean.slice(0, SUMMARY_MAX_LEN - 3) + '...' + : clean; +} + +/** @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's leak detector against the three snapshot files, installing + * `leakFilter` on the global memlab config and restoring it afterwards. + * @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 index 284c4cd73..91df5c37b 100644 --- a/packages/playwright/src/snapshot.ts +++ b/packages/playwright/src/snapshot.ts @@ -11,21 +11,33 @@ import fs from 'fs'; // Duck-typed CDP session. Playwright's CDPSession and Puppeteer's CDPSession -// both implement this shape for the events we care about. Handlers use -// `unknown` so the interface is structurally compatible with both vendors' -// strongly-typed overloads. -/* eslint-disable @typescript-eslint/no-explicit-any */ +// both implement this shape for the events we care about. `send` is typed +// with `unknown` so callers must narrow the result explicitly. Event +// handler params stay `any` because TypeScript's strict function types +// would otherwise reject passing a narrower handler like +// `(data: ChunkEvent) => void` to `(payload: unknown) => void`; both +// vendors ship the same pragma on their own CDPSession overloads. export interface CDPLike { - send(method: string, params?: any): Promise; - on(event: string, handler: (payload: any) => void): any; - off?(event: string, handler: (payload: any) => void): any; - removeListener?(event: string, handler: (payload: any) => void): any; + 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 */ } -/* 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, @@ -96,7 +108,7 @@ export async function writeHeapSnapshot( */ export async function forceFullGC( session: CDPLike, - options: {repeat?: number; waitBetweenMs?: number; waitAfterMs?: number} = {}, + options: GCOptions = {}, ): Promise { const repeat = options.repeat ?? 6; const wait = options.waitBetweenMs ?? 200; diff --git a/packages/playwright/src/test.ts b/packages/playwright/src/test.ts deleted file mode 100644 index 27434437b..000000000 --- a/packages/playwright/src/test.ts +++ /dev/null @@ -1,188 +0,0 @@ -/** - * 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-extra'; -import {test as baseTest, expect} from '@playwright/test'; -import type {Page} from '@playwright/test'; -import type {ISerializedInfo} from '@memlab/core'; - -import PlaywrightHeapCapturer, {PageLike, PhaseLabel} from './capturer'; - -export type MemlabFixture = { - /** - * Capture a named heap snapshot. For `'final'`, a full GC cycle runs first. - * Overrides the automatic snapshot that would otherwise be taken for that - * phase. - */ - mark(label: PhaseLabel): Promise; - baseline(): Promise; - target(): Promise; - final(): Promise; - /** Returns the leak report, or null if snapshots are incomplete. */ - findLeaks(): Promise; -}; - -type PhaseState = { - baseline: boolean; - target: boolean; - final: boolean; -}; - -/** - * Drop-in replacement for `@playwright/test`'s `test`. Destructuring - * `memlab` in the test body enables heap capture + leak analysis for that - * test; omitting it leaves the test untouched. - * - * ```ts - * import { test, expect } from '@memlab/playwright/test'; - * - * 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) => { - let capturer: PlaywrightHeapCapturer; - try { - capturer = await PlaywrightHeapCapturer.attach(page as PageLike); - } catch (err) { - // Non-Chromium browsers cannot create a CDP session. Surface the - // reason as a test annotation and provide a no-op fixture so the - // rest of the test can run unchanged. - testInfo.annotations.push({ - type: 'memlab-skip', - description: `memlab requires Chromium CDP (got: ${ - (err as Error).message - })`, - }); - const noop: MemlabFixture = { - mark: async () => undefined, - baseline: async () => undefined, - target: async () => undefined, - final: async () => undefined, - findLeaks: async () => null, - }; - await use(noop); - return; - } - - const manual: PhaseState = {baseline: false, target: false, final: false}; - const mark = async (label: PhaseLabel) => { - await capturer.snapshot(label); - manual[label] = true; - }; - - const fixture: MemlabFixture = { - mark, - baseline: () => mark('baseline'), - target: () => mark('target'), - final: () => mark('final'), - findLeaks: async () => - capturer.hasAllSnapshots() ? capturer.findLeaks() : null, - }; - - try { - await use(fixture); - } finally { - try { - // Auto fallback for any phase the user did not mark. Baseline is - // the fragile one — if it wasn't captured before the action, the - // snapshot taken here reflects post-action state and the - // comparison is degenerate. Users should call - // `await memlab.baseline()` explicitly after navigation. - if (!manual.baseline && !capturer.getSnapshotPath('baseline')) { - await capturer.snapshot('baseline'); - } - if (!manual.target) { - await capturer.snapshot('target'); - } - if (!manual.final) { - await capturer.snapshot('final'); - } - - if (capturer.hasAllSnapshots()) { - const rawLeaks = await capturer.findLeaks(); - // Filter out retainer paths owned by CDP's inspector state — those - // retain detached DOM nodes as an artifact of how Playwright drives - // the page (selector handles, console $0-$4), not because of real - // application leaks. - const leaks = rawLeaks.filter(l => !isInspectorArtifact(l)); - const reportPath = testInfo.outputPath('memlab-leaks.json'); - await fs.outputJson(reportPath, leaks, {spaces: 2}); - await testInfo.attach('memlab-leaks', { - path: reportPath, - contentType: 'application/json', - }); - // Preserve the raw snapshots so users can open them in Chrome - // DevTools → Memory → Load. - for (const label of ['baseline', 'target', 'final'] as PhaseLabel[]) { - const src = capturer.getSnapshotPath(label); - if (!src) continue; - const dest = testInfo.outputPath(`${label}.heapsnapshot`); - await fs.copy(src, dest); - await testInfo.attach(`${label}.heapsnapshot`, { - path: dest, - contentType: 'application/octet-stream', - }); - } - if (leaks.length > 0) { - const summary = leaks - .slice(0, 5) - .map((l, i) => ` #${i + 1}: ${leakSummary(l)}`) - .join('\n'); - const msg = - `memlab detected ${leaks.length} leak trace(s):\n${summary}` + - (leaks.length > 5 ? `\n ... and ${leaks.length - 5} more` : ''); - expect.soft(leaks, msg).toHaveLength(0); - } - } - } finally { - await capturer.dispose(); - } - } - }, -}); - -const INSPECTOR_PATTERNS = [ - // Chrome DevTools / CDP inspector retention of DOM refs (Playwright uses - // this for selector queries, page.evaluate results, etc.) - /DevTools console/i, - /\(Inspector[^)]*\)/i, - /CommandLineAPI/i, -]; - -function isInspectorArtifact(leak: ISerializedInfo): boolean { - // ISerializedInfo is a key-value object where keys encode the retainer - // trace (e.g., "2: --12 / DevTools console (internal)---> [Detached …]"). - // We scan the serialized trace for known CDP-inspector retainer labels. - const keys = Object.keys(leak as Record); - return keys.some(k => INSPECTOR_PATTERNS.some(rx => rx.test(k))); -} - -function leakSummary(leak: ISerializedInfo): string { - const maybe = leak as unknown as { - retainedSize?: number; - type?: string; - name?: string; - }; - const parts: string[] = []; - if (maybe.type) parts.push(maybe.type); - if (maybe.name) parts.push(maybe.name); - if (typeof maybe.retainedSize === 'number') - parts.push(`${maybe.retainedSize}B retained`); - return parts.join(' · ') || 'leak'; -} - -export {expect}; -export type {Page}; diff --git a/packages/playwright/src/types.ts b/packages/playwright/src/types.ts new file mode 100644 index 000000000..c5445d73d --- /dev/null +++ b/packages/playwright/src/types.ts @@ -0,0 +1,44 @@ +/** + * 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'; + +/** Ordered phase labels captured by the memlab fixture. */ +export const PHASE_LABELS = ['baseline', 'target', 'final'] as const; +export type PhaseLabel = (typeof PHASE_LABELS)[number]; + +/** + * Callback deciding whether a live heap node should be reported as a leak. + * Mirrors `ILeakFilter.leakFilter`. + */ +export type LeakFilterFn = NonNullable; + +/** GC-cycle tuning for the `final` snapshot. See {@link GCOptions}. */ +export type MemlabGCOptions = GCOptions; + +/** User configuration merged via `MemlabFixture.configure`. */ +export type MemlabConfigInput = { + leakFilter?: LeakFilterFn; + gc?: MemlabGCOptions; +}; + +/** Fixture surface injected into every test that destructures `memlab`. */ +export type MemlabFixture = { + mark(label: PhaseLabel): Promise; + baseline(): Promise; + target(): Promise; + final(): Promise; + configure(config: MemlabConfigInput): void; + findLeaks(): Promise; +}; + +export type {Page}; diff --git a/packages/playwright/tsconfig.json b/packages/playwright/tsconfig.json index 021c71373..0685b3c40 100644 --- a/packages/playwright/tsconfig.json +++ b/packages/playwright/tsconfig.json @@ -1,27 +1,10 @@ { + "extends": "../../tsconfig.base.json", "compilerOptions": { - "target": "ES2022", - "module": "CommonJS", - "moduleResolution": "Node", - "outDir": "./dist", "rootDir": "./src", - "strict": true, - "strictPropertyInitialization": false, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "composite": true, - "incremental": true, - "noEmitOnError": true, - "types": ["node"], - "baseUrl": ".", - "paths": { - "@memlab/core": ["../core/dist/index.d.ts"], - "@memlab/api": ["../api/dist/index.d.ts"] - } + "outDir": "./dist", + "target": "ES2022" }, - "include": ["src/**/*"] + "include": ["src/**/*"], + "references": [{"path": "../core"}, {"path": "../api"}] } From 4d90e15a846b6ea619b138b674c966a406cf701d Mon Sep 17 00:00:00 2001 From: miinhho Date: Thu, 23 Apr 2026 01:24:06 +0900 Subject: [PATCH 6/7] feat(playwright): add expectNoLeaks and trim CI failure output Replace the auto-teardown's array dump with a scalar assertion, pick the leaked-node key (not the Window root) for summaries, strip internal $tabsOrder metadata from attached JSON, and skip heap-snapshot attachment on clean runs. Add expectNoLeaks() for the common "run a flow, assert no leaks" pattern with triage-friendly failure messages. Replace the algorithm-focused matrix spec with an integration-focused fixture spec. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/playwright/README.md | 25 +++- packages/playwright/__tests__/fixture.spec.ts | 128 ++++++++++++++++ .../playwright/__tests__/react-leak.spec.ts | 2 - .../__tests__/react-patterns.spec.ts | 137 ------------------ packages/playwright/src/fixture.ts | 47 ++++-- packages/playwright/src/leak.ts | 28 +++- packages/playwright/src/snapshot.ts | 21 +-- packages/playwright/src/types.ts | 10 +- 8 files changed, 212 insertions(+), 186 deletions(-) create mode 100644 packages/playwright/__tests__/fixture.spec.ts delete mode 100644 packages/playwright/__tests__/react-patterns.spec.ts diff --git a/packages/playwright/README.md b/packages/playwright/README.md index 9f566f8d0..c4fc55e82 100644 --- a/packages/playwright/README.md +++ b/packages/playwright/README.md @@ -18,9 +18,30 @@ test('closing a modal does not leak', async ({page, memlab}) => { }); ``` +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. Firefox / WebKit projects get a `memlab-skip` -annotation and a no-op fixture. +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) 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__/react-leak.spec.ts b/packages/playwright/__tests__/react-leak.spec.ts index f0cb93c43..9245341a7 100644 --- a/packages/playwright/__tests__/react-leak.spec.ts +++ b/packages/playwright/__tests__/react-leak.spec.ts @@ -9,8 +9,6 @@ async function openThenClose(page: import('@playwright/test').Page) { await page.waitForSelector('#slot', {state: 'detached'}); } -// Uses detached-dom-{leaky,clean} so the built-in rule chain (rule 7, -// FilterDetachedDOMElement) catches the leak without a user leakFilter. test('[react] leaky component is detected', async ({page, memlab}) => { await page.goto(`${BASE}/?mode=detached-dom-leaky`); await page.waitForSelector('#open'); diff --git a/packages/playwright/__tests__/react-patterns.spec.ts b/packages/playwright/__tests__/react-patterns.spec.ts deleted file mode 100644 index 509a2136f..000000000 --- a/packages/playwright/__tests__/react-patterns.spec.ts +++ /dev/null @@ -1,137 +0,0 @@ -import {test, expect} from '@memlab/playwright'; -import type {LeakFilterFn} from '@memlab/playwright'; -import type {ISerializedInfo} from '@memlab/core'; - -const BASE = 'http://127.0.0.1:5174'; - -// Each leak fixture retains makePayload() ≈ 50k × ~50B ≈ 2.5MB. A 100KB -// floor sits comfortably above Playwright's inspector retention and well -// below the real leak; 1MB is the conservative lower bound we assert on. -const RETAINED_SIZE_THRESHOLD = 100_000; -const PAYLOAD_MIN_BYTES = 1_000_000; -const retainedSizeFilter: LeakFilterFn = node => - node.retainedSize > RETAINED_SIZE_THRESHOLD; - -const PATTERNS = [ - 'interval', - 'window-listener', - 'promise', - 'store', - 'global-ref', - 'observer', - 'raf', -] as const; - -// memlab encodes retained size into ISerializedInfo keys as -// `$retained-size:N` (packages/core/src/lib/Serializer.ts:40-43). Walk -// the recursive dict and return the max N seen anywhere in the trace. -const RETAIN_SIZE_RX = /\$retained-size:(\d+)/; -function maxRetainedSize(info: ISerializedInfo): number { - let max = 0; - const walk = (value: unknown): void => { - if (value == null || typeof value !== 'object') return; - for (const [key, child] of Object.entries(value)) { - const m = RETAIN_SIZE_RX.exec(key); - if (m) { - const n = parseInt(m[1], 10); - if (n > max) max = n; - } - walk(child); - } - }; - walk(info); - return max; -} - -async function runPattern( - page: import('@playwright/test').Page, - memlab: import('@memlab/playwright').MemlabFixture, - mode: string, -): Promise { - await page.goto(`${BASE}/?mode=${mode}`); - await page.waitForSelector('#open'); - await memlab.baseline(); - await page.click('#open'); - await page.waitForSelector('#slot'); - await page.click('#close'); - await page.waitForSelector('#slot', {state: 'detached'}); - const leaks = await memlab.findLeaks(); - return leaks ?? []; -} - -for (const pattern of PATTERNS) { - test(`[${pattern}] leaky with retainedSize filter`, async ({ - page, - memlab, - }) => { - memlab.configure({leakFilter: retainedSizeFilter}); - const leaks = await runPattern(page, memlab, `${pattern}-leaky`); - console.log(`[${pattern}-leaky] leaks=${leaks.length}`); - expect( - leaks.length, - `expected ${pattern}-leaky to produce ≥1 leak, got ${leaks.length}`, - ).toBeGreaterThan(0); - // Size sanity: retainer trace should include a node of ~payload size. - const maxSizes = leaks.map(maxRetainedSize); - expect( - maxSizes.some(s => s >= PAYLOAD_MIN_BYTES), - `expected ≥1 leak with a retainer ≥${PAYLOAD_MIN_BYTES}B. maxSizes: [${maxSizes.join( - ',', - )}]`, - ).toBe(true); - }); - - test(`[${pattern}] clean with retainedSize filter`, async ({ - page, - memlab, - }) => { - memlab.configure({leakFilter: retainedSizeFilter}); - const leaks = await runPattern(page, memlab, `${pattern}-clean`); - console.log(`[${pattern}-clean] leaks=${leaks.length}`); - expect( - leaks.length, - `expected ${pattern}-clean to produce 0 leaks, got ${leaks.length}`, - ).toBe(0); - }); -} - -// Detached DOM: exercises FilterDetachedDOMElement (rule #7) specifically -// without a leakFilter. The fixture has no large payload, so a size-based -// filter would actively harm the signal here. -test('[detached-dom] leaky without filter → rule 7 catches it', async ({ - page, - memlab, -}) => { - const leaks = await runPattern(page, memlab, 'detached-dom-leaky'); - console.log(`[detached-dom-leaky] leaks=${leaks.length}`); - expect( - leaks.length, - `expected detached-dom-leaky to produce ≥1 leak via rule 7, got ${leaks.length}`, - ).toBeGreaterThan(0); -}); - -test('[detached-dom] clean without filter', async ({page, memlab}) => { - const leaks = await runPattern(page, memlab, 'detached-dom-clean'); - console.log(`[detached-dom-clean] leaks=${leaks.length}`); - expect( - leaks.length, - `expected detached-dom-clean to produce 0 leaks, got ${leaks.length}`, - ).toBe(0); -}); - -// Negative control: without a user leakFilter, memlab's built-in rules -// should miss closure-retained JS state. These tests assert 0 leaks so a -// non-zero result surfaces as a test failure that's worth investigating. -for (const pattern of PATTERNS) { - test(`[${pattern}] leaky WITHOUT filter (built-in rules only)`, async ({ - page, - memlab, - }) => { - const leaks = await runPattern(page, memlab, `${pattern}-leaky`); - console.log(`[${pattern}-leaky NO-FILTER] leaks=${leaks.length}`); - expect( - leaks.length, - `built-in rules should miss ${pattern}-leaky without a leakFilter, got ${leaks.length}`, - ).toBe(0); - }); -} diff --git a/packages/playwright/src/fixture.ts b/packages/playwright/src/fixture.ts index 7bb56aebe..8fe69c83d 100644 --- a/packages/playwright/src/fixture.ts +++ b/packages/playwright/src/fixture.ts @@ -21,6 +21,7 @@ import { formatLeakMessage, isInspectorArtifact, runFindLeaks, + stripInternalKeysReplacer, } from './leak'; import {PHASE_LABELS} from './types'; import type { @@ -30,8 +31,9 @@ import type { } from './types'; /** - * Drop-in replacement for `@playwright/test`'s `test`. Destructure - * `memlab` in the test body to enable heap capture + leak analysis. + * 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 @@ -78,6 +80,13 @@ export const test = baseTest.extend<{memlab: MemlabFixture}>({ 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'), @@ -93,10 +102,20 @@ export const test = baseTest.extend<{memlab: MemlabFixture}>({ findLeaks: async () => { manualVerified = true; if (!snapshotPaths.baseline) return null; - if (!snapshotPaths.target) await takeSnapshot('target'); - if (!snapshotPaths.final) await takeSnapshot('final'); - cachedLeaks = await detectLeaks(); - return cachedLeaks; + 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)); + } }, }; @@ -110,13 +129,17 @@ export const test = baseTest.extend<{memlab: MemlabFixture}>({ } cachedLeaks = await detectLeaks(); } - if (cachedLeaks != null && allPhasesCaptured(snapshotPaths)) { + if ( + cachedLeaks != null && + cachedLeaks.length > 0 && + allPhasesCaptured(snapshotPaths) + ) { await attachArtifacts(testInfo, cachedLeaks, snapshotPaths); } if (!manualVerified && cachedLeaks != null && cachedLeaks.length > 0) { expect - .soft(cachedLeaks, formatLeakMessage(cachedLeaks)) - .toHaveLength(0); + .soft(cachedLeaks.length, formatLeakMessage(cachedLeaks)) + .toBe(0); } } finally { await closeSession(session); @@ -169,7 +192,10 @@ async function attachArtifacts( paths: Record, ): Promise { const reportPath = testInfo.outputPath('memlab-leaks.json'); - await fs.outputJson(reportPath, leaks, {spaces: 2}); + await fs.outputJson(reportPath, leaks, { + spaces: 2, + replacer: stripInternalKeysReplacer, + }); await Promise.all([ testInfo.attach('memlab-leaks', { path: reportPath, @@ -192,5 +218,6 @@ function noopFixture(): MemlabFixture { final: async () => undefined, configure: () => undefined, findLeaks: async () => null, + expectNoLeaks: async () => undefined, }; } diff --git a/packages/playwright/src/leak.ts b/packages/playwright/src/leak.ts index 8f2c14eed..d4693d402 100644 --- a/packages/playwright/src/leak.ts +++ b/packages/playwright/src/leak.ts @@ -13,18 +13,21 @@ import {config as memlabConfig} from '@memlab/core'; import type {ILeakFilter, ISerializedInfo} from '@memlab/core'; import type {LeakFilterFn, PhaseLabel} from './types'; -// Inspector retention owned by CDP ($0-$4, selector handles). These -// labels are chrome-devtools-internal, so application node names cannot -// collide — safe to walk the full serialized trace. +// 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 => @@ -43,14 +46,24 @@ export function isInspectorArtifact(leak: ISerializedInfo): boolean { /** @internal */ export function leakSummary(leak: ISerializedInfo): string { - const trace = Object.keys(leak).find(k => !k.startsWith('$tabsOrder')); - if (!trace) return 'leak'; - const clean = trace.replace(/\s+/g, ' ').trim(); + 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 @@ -65,8 +78,7 @@ export function formatLeakMessage(leaks: ISerializedInfo[]): string { } /** - * Run memlab's leak detector against the three snapshot files, installing - * `leakFilter` on the global memlab config and restoring it afterwards. + * Run memlab leak detection on a baseline/target/final snapshot triple. * @internal */ export async function runFindLeaks( diff --git a/packages/playwright/src/snapshot.ts b/packages/playwright/src/snapshot.ts index 91df5c37b..b6e9a5d82 100644 --- a/packages/playwright/src/snapshot.ts +++ b/packages/playwright/src/snapshot.ts @@ -10,13 +10,7 @@ import fs from 'fs'; -// Duck-typed CDP session. Playwright's CDPSession and Puppeteer's CDPSession -// both implement this shape for the events we care about. `send` is typed -// with `unknown` so callers must narrow the result explicitly. Event -// handler params stay `any` because TypeScript's strict function types -// would otherwise reject passing a narrower handler like -// `(data: ChunkEvent) => void` to `(payload: unknown) => void`; both -// vendors ship the same pragma on their own CDPSession overloads. +/** 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 */ @@ -52,10 +46,7 @@ function detach( } } -/** - * Drive HeapProfiler via a CDP session and stream the snapshot to disk. - * Works with any CDPLike session (Playwright or Puppeteer). - */ +/** Stream a V8 heap snapshot to disk via CDP. */ export async function writeHeapSnapshot( session: CDPLike, filePath: string, @@ -100,12 +91,7 @@ export async function writeHeapSnapshot( } } -/** - * Force a series of full GCs so memlab's leak detection sees a clean final - * snapshot. Mirrors the 6x cycle used in @memlab/e2e. Also discards the CDP - * console entries, since Chrome's DevTools console retains references to - * detached DOM nodes and would produce false-positive "leaks". - */ +/** Force a full GC cycle and clear the DevTools console retention. */ export async function forceFullGC( session: CDPLike, options: GCOptions = {}, @@ -113,7 +99,6 @@ export async function forceFullGC( const repeat = options.repeat ?? 6; const wait = options.waitBetweenMs ?? 200; const waitAfter = options.waitAfterMs ?? 500; - // Best-effort: some domains may not be enabled. Swallow errors. await session.send('Runtime.discardConsoleEntries').catch(() => undefined); for (let i = 0; i < repeat; i++) { await session.send('HeapProfiler.collectGarbage'); diff --git a/packages/playwright/src/types.ts b/packages/playwright/src/types.ts index c5445d73d..d961f5cad 100644 --- a/packages/playwright/src/types.ts +++ b/packages/playwright/src/types.ts @@ -12,26 +12,17 @@ import type {Page} from '@playwright/test'; import type {ILeakFilter, ISerializedInfo} from '@memlab/core'; import type {GCOptions} from './snapshot'; -/** Ordered phase labels captured by the memlab fixture. */ export const PHASE_LABELS = ['baseline', 'target', 'final'] as const; export type PhaseLabel = (typeof PHASE_LABELS)[number]; -/** - * Callback deciding whether a live heap node should be reported as a leak. - * Mirrors `ILeakFilter.leakFilter`. - */ export type LeakFilterFn = NonNullable; - -/** GC-cycle tuning for the `final` snapshot. See {@link GCOptions}. */ export type MemlabGCOptions = GCOptions; -/** User configuration merged via `MemlabFixture.configure`. */ export type MemlabConfigInput = { leakFilter?: LeakFilterFn; gc?: MemlabGCOptions; }; -/** Fixture surface injected into every test that destructures `memlab`. */ export type MemlabFixture = { mark(label: PhaseLabel): Promise; baseline(): Promise; @@ -39,6 +30,7 @@ export type MemlabFixture = { final(): Promise; configure(config: MemlabConfigInput): void; findLeaks(): Promise; + expectNoLeaks(): Promise; }; export type {Page}; From d07486f70d68cf380cc890c0a8ebe787a84d3780 Mon Sep 17 00:00:00 2001 From: miinhho Date: Thu, 23 Apr 2026 01:32:05 +0900 Subject: [PATCH 7/7] test(playwright): replace Vite+React fixture with vanilla HTML/JS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixture only needs to exercise leak patterns (detached DOM, subscription, interval closure) against a real HTTP origin — React and Vite were incidental. Replace with a static HTML + vanilla JS page served by a 27-line node:http script. Drops four test-only dependencies (react, react-dom, vite, @vitejs/plugin-react), removes the CI npm-install step, and simplifies clean-pkg. Rename the smoke spec to drop the now-inaccurate [react] tag. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../playwright/__tests__/fixtures/index.html | 14 + .../playwright/__tests__/fixtures/index.js | 80 +++++ .../playwright/__tests__/fixtures/server.mjs | 27 ++ .../__tests__/fixtures/vite-react/index.html | 11 - .../fixtures/vite-react/package.json | 23 -- .../__tests__/fixtures/vite-react/src/App.tsx | 280 ------------------ .../fixtures/vite-react/src/main.tsx | 6 - .../fixtures/vite-react/tsconfig.json | 16 - .../fixtures/vite-react/vite.config.ts | 14 - .../playwright/__tests__/playwright.config.ts | 4 +- .../{react-leak.spec.ts => smoke.spec.ts} | 8 +- packages/playwright/package.json | 5 +- 12 files changed, 129 insertions(+), 359 deletions(-) create mode 100644 packages/playwright/__tests__/fixtures/index.html create mode 100644 packages/playwright/__tests__/fixtures/index.js create mode 100644 packages/playwright/__tests__/fixtures/server.mjs delete mode 100644 packages/playwright/__tests__/fixtures/vite-react/index.html delete mode 100644 packages/playwright/__tests__/fixtures/vite-react/package.json delete mode 100644 packages/playwright/__tests__/fixtures/vite-react/src/App.tsx delete mode 100644 packages/playwright/__tests__/fixtures/vite-react/src/main.tsx delete mode 100644 packages/playwright/__tests__/fixtures/vite-react/tsconfig.json delete mode 100644 packages/playwright/__tests__/fixtures/vite-react/vite.config.ts rename packages/playwright/__tests__/{react-leak.spec.ts => smoke.spec.ts} (76%) 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__/fixtures/vite-react/index.html b/packages/playwright/__tests__/fixtures/vite-react/index.html deleted file mode 100644 index 76c69d6e5..000000000 --- a/packages/playwright/__tests__/fixtures/vite-react/index.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - - memlab playwright fixture - - -
- - - diff --git a/packages/playwright/__tests__/fixtures/vite-react/package.json b/packages/playwright/__tests__/fixtures/vite-react/package.json deleted file mode 100644 index f7453b85e..000000000 --- a/packages/playwright/__tests__/fixtures/vite-react/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "memlab-playwright-vite-react-fixture", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build-pkg": "echo 'memlab playwright fixture: nothing to build'", - "test-pkg": "echo 'memlab playwright fixture: no unit tests'", - "clean-pkg": "rm -rf node_modules dist", - "publish-patch": "echo 'memlab playwright fixture: not published'" - }, - "dependencies": { - "react": "^18.3.1", - "react-dom": "^18.3.1" - }, - "devDependencies": { - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^4.3.4", - "vite": "^5.4.10" - } -} diff --git a/packages/playwright/__tests__/fixtures/vite-react/src/App.tsx b/packages/playwright/__tests__/fixtures/vite-react/src/App.tsx deleted file mode 100644 index 531034bc1..000000000 --- a/packages/playwright/__tests__/fixtures/vite-react/src/App.tsx +++ /dev/null @@ -1,280 +0,0 @@ -import {useEffect, useRef, useState} from 'react'; - -function makePayload() { - const big = new Array(50000); - for (let i = 0; i < big.length; i++) { - big[i] = {tag: 'memlab-payload', i, nested: {alive: true}}; - } - return big; -} - -// --- Pattern 1: setInterval ------------------------------------------------ -function IntervalLeaky() { - const [payload] = useState(makePayload); - useEffect(() => { - const id = setInterval(() => { - if (payload.length < 0) console.log('x'); - }, 1_000_000); - void id; - }, [payload]); - return
interval-leaky
; -} -function IntervalClean() { - const [payload] = useState(makePayload); - useEffect(() => { - const id = setInterval(() => { - if (payload.length < 0) console.log('x'); - }, 1_000_000); - return () => clearInterval(id); - }, [payload]); - return
interval-clean
; -} - -// --- Pattern 2: window event listener -------------------------------------- -function WindowListenerLeaky() { - const [payload] = useState(makePayload); - useEffect(() => { - const handler = () => { - if (payload.length < 0) console.log('x'); - }; - window.addEventListener('resize', handler); - // no removeEventListener — window retains handler → payload - }, [payload]); - return
window-listener-leaky
; -} -function WindowListenerClean() { - const [payload] = useState(makePayload); - useEffect(() => { - const handler = () => { - if (payload.length < 0) console.log('x'); - }; - window.addEventListener('resize', handler); - return () => window.removeEventListener('resize', handler); - }, [payload]); - return
window-listener-clean
; -} - -// --- Pattern 3: unresolved Promise holding closure ------------------------- -function PromiseLeaky() { - const [payload] = useState(makePayload); - useEffect(() => { - // Promise that never resolves — the then-callback closure retains payload - // indefinitely. No AbortController. - new Promise(resolve => { - setTimeout(resolve, 10_000_000); - }).then(() => { - if (payload.length < 0) console.log('x'); - }); - }, [payload]); - return
promise-leaky
; -} -function PromiseClean() { - const [payload] = useState(makePayload); - useEffect(() => { - const controller = new AbortController(); - new Promise((resolve, reject) => { - const id = setTimeout(resolve, 10_000_000); - controller.signal.addEventListener('abort', () => { - clearTimeout(id); - reject(new DOMException('aborted', 'AbortError')); - }); - }) - .then(() => { - if (payload.length < 0) console.log('x'); - }) - .catch(() => undefined); - return () => controller.abort(); - }, [payload]); - return
promise-clean
; -} - -// --- Pattern 4: external store subscription -------------------------------- -const externalStore = { - subs: new Set<() => void>(), - subscribe(fn: () => void) { - this.subs.add(fn); - return () => this.subs.delete(fn); - }, -}; -function StoreLeaky() { - const [payload] = useState(makePayload); - useEffect(() => { - externalStore.subscribe(() => { - if (payload.length < 0) console.log('x'); - }); - // unsubscribe returned but ignored - }, [payload]); - return
store-leaky
; -} -function StoreClean() { - const [payload] = useState(makePayload); - useEffect(() => { - const unsub = externalStore.subscribe(() => { - if (payload.length < 0) console.log('x'); - }); - return () => { - unsub(); - }; - }, [payload]); - return
store-clean
; -} - -// --- Pattern 5: module-scope array accumulation ---------------------------- -const globalRefs: unknown[] = []; -function GlobalRefLeaky() { - const [payload] = useState(makePayload); - useEffect(() => { - globalRefs.push({payload}); - // no cleanup — module array grows forever - }, [payload]); - return
global-ref-leaky
; -} -function GlobalRefClean() { - const [payload] = useState(makePayload); - useEffect(() => { - const entry = {payload}; - globalRefs.push(entry); - return () => { - const i = globalRefs.indexOf(entry); - if (i >= 0) globalRefs.splice(i, 1); - }; - }, [payload]); - return
global-ref-clean
; -} - -// --- Pattern 6: MutationObserver on document.body without disconnect ------- -// Observing a GC root (document.body) keeps the observer intrinsically -// alive — its callback closure retains `payload` until the observer is -// explicitly disconnected. -function ObserverLeaky() { - const [payload] = useState(makePayload); - useEffect(() => { - const obs = new MutationObserver(() => { - if (payload.length < 0) console.log('x'); - }); - obs.observe(document.body, {childList: true, subtree: true}); - // no obs.disconnect() — observer is reachable from document.body's - // observer registry, callback retains payload - }, [payload]); - return
observer-leaky
; -} -function ObserverClean() { - const [payload] = useState(makePayload); - useEffect(() => { - const obs = new MutationObserver(() => { - if (payload.length < 0) console.log('x'); - }); - obs.observe(document.body, {childList: true, subtree: true}); - return () => obs.disconnect(); - }, [payload]); - return
observer-clean
; -} - -// --- Pattern 7: requestAnimationFrame recursive chain --------------------- -// Different retention class from setInterval: the browser render loop -// re-registers the callback each frame, so the retention is driven by -// rAF's internal queue rather than a timer registry. -function RafLeaky() { - const [payload] = useState(makePayload); - useEffect(() => { - const tick = () => { - if (payload.length < 0) console.log('x'); - requestAnimationFrame(tick); - }; - requestAnimationFrame(tick); - // no cancelAnimationFrame — render loop keeps closure (→ payload) alive - }, [payload]); - return
raf-leaky
; -} -function RafClean() { - const [payload] = useState(makePayload); - useEffect(() => { - let id = 0; - let stopped = false; - const tick = () => { - if (stopped) return; - if (payload.length < 0) console.log('x'); - id = requestAnimationFrame(tick); - }; - id = requestAnimationFrame(tick); - return () => { - stopped = true; - cancelAnimationFrame(id); - }; - }, [payload]); - return
raf-clean
; -} - -// --- Pattern 8: Detached DOM retained by module-scope var ----------------- -// Exercises FilterDetachedDOMElement (rule #7) specifically. No large -// payload attached, so this does NOT pass a retained-size threshold — -// detection must come from memlab's detached-DOM rule, not size. -const detachedDomStash: HTMLDivElement[] = []; -function DetachedDomLeaky() { - const ref = useRef(null); - useEffect(() => { - if (ref.current) detachedDomStash.push(ref.current); - // no cleanup — on unmount the
is removed from the tree but - // still referenced by detachedDomStash, i.e. it becomes detached DOM - }, []); - return
detached-dom-leaky
; -} -function DetachedDomClean() { - const ref = useRef(null); - useEffect(() => { - const el = ref.current; - if (el) detachedDomStash.push(el); - return () => { - if (!el) return; - const i = detachedDomStash.indexOf(el); - if (i >= 0) detachedDomStash.splice(i, 1); - }; - }, []); - return
detached-dom-clean
; -} - -// --- Mode routing ---------------------------------------------------------- -const COMPONENTS: Record JSX.Element> = { - 'interval-leaky': IntervalLeaky, - 'interval-clean': IntervalClean, - 'window-listener-leaky': WindowListenerLeaky, - 'window-listener-clean': WindowListenerClean, - 'promise-leaky': PromiseLeaky, - 'promise-clean': PromiseClean, - 'store-leaky': StoreLeaky, - 'store-clean': StoreClean, - 'global-ref-leaky': GlobalRefLeaky, - 'global-ref-clean': GlobalRefClean, - 'observer-leaky': ObserverLeaky, - 'observer-clean': ObserverClean, - 'raf-leaky': RafLeaky, - 'raf-clean': RafClean, - 'detached-dom-leaky': DetachedDomLeaky, - 'detached-dom-clean': DetachedDomClean, -}; - -function getMode(): string | null { - const m = new URLSearchParams(window.location.search).get('mode'); - return m && m in COMPONENTS ? m : null; -} - -export function App() { - const [visible, setVisible] = useState(false); - const mode = getMode(); - const Target = mode ? COMPONENTS[mode] : null; - - return ( -
-
- mode: {mode ?? 'none'} -
- - - {visible && Target ? : null} -
- ); -} diff --git a/packages/playwright/__tests__/fixtures/vite-react/src/main.tsx b/packages/playwright/__tests__/fixtures/vite-react/src/main.tsx deleted file mode 100644 index 471fe8e3e..000000000 --- a/packages/playwright/__tests__/fixtures/vite-react/src/main.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import {createRoot} from 'react-dom/client'; -import {App} from './App'; - -// Intentionally NOT wrapping in : its double-mount/unmount -// behavior in dev makes heap snapshots harder to reason about. -createRoot(document.getElementById('root')!).render(); diff --git a/packages/playwright/__tests__/fixtures/vite-react/tsconfig.json b/packages/playwright/__tests__/fixtures/vite-react/tsconfig.json deleted file mode 100644 index d43a24892..000000000 --- a/packages/playwright/__tests__/fixtures/vite-react/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "module": "ESNext", - "moduleResolution": "Bundler", - "jsx": "react-jsx", - "strict": true, - "skipLibCheck": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "noEmit": true, - "isolatedModules": true - }, - "include": ["src", "vite.config.ts"] -} diff --git a/packages/playwright/__tests__/fixtures/vite-react/vite.config.ts b/packages/playwright/__tests__/fixtures/vite-react/vite.config.ts deleted file mode 100644 index a7734a8e2..000000000 --- a/packages/playwright/__tests__/fixtures/vite-react/vite.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {defineConfig} from 'vite'; -import react from '@vitejs/plugin-react'; - -export default defineConfig({ - plugins: [react()], - server: { - port: 5174, - strictPort: true, - host: '127.0.0.1', - // Disable HMR — the injected HMR client keeps websocket-based refs - // that show up in heap snapshots and muddy the leak signal. - hmr: false, - }, -}); diff --git a/packages/playwright/__tests__/playwright.config.ts b/packages/playwright/__tests__/playwright.config.ts index e83ad3ce1..34fb738e1 100644 --- a/packages/playwright/__tests__/playwright.config.ts +++ b/packages/playwright/__tests__/playwright.config.ts @@ -1,7 +1,7 @@ import {defineConfig, devices} from '@playwright/test'; import path from 'path'; -const fixtureDir = path.join(__dirname, 'fixtures', 'vite-react'); +const fixtureDir = path.join(__dirname, 'fixtures'); export default defineConfig({ testDir: __dirname, @@ -12,7 +12,7 @@ export default defineConfig({ trace: 'off', }, webServer: { - command: 'npm run dev', + command: 'node server.mjs', cwd: fixtureDir, url: 'http://127.0.0.1:5174', reuseExistingServer: !process.env.CI, diff --git a/packages/playwright/__tests__/react-leak.spec.ts b/packages/playwright/__tests__/smoke.spec.ts similarity index 76% rename from packages/playwright/__tests__/react-leak.spec.ts rename to packages/playwright/__tests__/smoke.spec.ts index 9245341a7..e980328ee 100644 --- a/packages/playwright/__tests__/react-leak.spec.ts +++ b/packages/playwright/__tests__/smoke.spec.ts @@ -9,7 +9,7 @@ async function openThenClose(page: import('@playwright/test').Page) { await page.waitForSelector('#slot', {state: 'detached'}); } -test('[react] leaky component is detected', async ({page, memlab}) => { +test('leaky fixture is detected', async ({page, memlab}) => { await page.goto(`${BASE}/?mode=detached-dom-leaky`); await page.waitForSelector('#open'); await memlab.baseline(); @@ -17,20 +17,20 @@ test('[react] leaky component is detected', async ({page, memlab}) => { const leaks = await memlab.findLeaks(); expect( leaks?.length ?? 0, - `expected leaky react component to produce at least one leak, got ${ + `expected leaky fixture to produce at least one leak, got ${ leaks?.length ?? 0 }`, ).toBeGreaterThan(0); }); -test('[react] clean component passes the fixture', async ({page, memlab}) => { +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('[react] no-op when memlab is not destructured', async ({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'); diff --git a/packages/playwright/package.json b/packages/playwright/package.json index 9c1169250..2332ab410 100644 --- a/packages/playwright/package.json +++ b/packages/playwright/package.json @@ -61,10 +61,9 @@ "scripts": { "build-pkg": "tsc", "test-pkg": "npm run test-e2e", - "test-e2e:install-fixtures": "cd __tests__/fixtures/vite-react && npm install --no-audit --no-fund", - "test-e2e": "npm run test-e2e:install-fixtures && playwright test --config=__tests__/playwright.config.ts --tsconfig=__tests__/tsconfig.json", + "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 && rm -rf ./__tests__/fixtures/*/node_modules" + "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"