From 3fc6a83365076ddf539efdaf6d0251e442c4fada Mon Sep 17 00:00:00 2001 From: GG ZIBLAKING Date: Thu, 2 Apr 2026 10:05:16 +0800 Subject: [PATCH 1/2] fix(browser-playwright): clear stale mock routes for duplicate URLs --- packages/browser-playwright/src/playwright.ts | 53 ++++++++++++------- .../manual-mock-alias-leak/src/modal.ts | 1 + .../manual-mock-alias-leak/src/probe.spec.ts | 17 ++++++ .../manual-mock-alias-leak/src/probe.ts | 5 ++ .../manual-mock-alias-leak/src/target.spec.ts | 9 ++++ .../manual-mock-alias-leak/src/target.ts | 5 ++ .../manual-mock-alias-leak/vitest.config.ts | 21 ++++++++ test/browser/specs/mocking.test.ts | 20 +++++++ 8 files changed, 111 insertions(+), 20 deletions(-) create mode 100644 test/browser/fixtures/manual-mock-alias-leak/src/modal.ts create mode 100644 test/browser/fixtures/manual-mock-alias-leak/src/probe.spec.ts create mode 100644 test/browser/fixtures/manual-mock-alias-leak/src/probe.ts create mode 100644 test/browser/fixtures/manual-mock-alias-leak/src/target.spec.ts create mode 100644 test/browser/fixtures/manual-mock-alias-leak/src/target.ts create mode 100644 test/browser/fixtures/manual-mock-alias-leak/vitest.config.ts diff --git a/packages/browser-playwright/src/playwright.ts b/packages/browser-playwright/src/playwright.ts index 36e53122d03b..21211de9e1ec 100644 --- a/packages/browser-playwright/src/playwright.ts +++ b/packages/browser-playwright/src/playwright.ts @@ -261,7 +261,15 @@ export class PlaywrightBrowserProvider implements BrowserProvider { private createMocker(): BrowserModuleMocker { const idPredicates = new Map boolean>() - const sessionIds = new Map() + const sessionIds = new Map>() + + function predicateKey(sessionId: string, url: string) { + return `${sessionId}:${url}` + } + + function normalizeUrl(url: string) { + return new URL(url, 'http://localhost').href + } function createPredicate(sessionId: string, url: string) { const moduleUrl = new URL(url, 'http://localhost') @@ -293,20 +301,36 @@ export class PlaywrightBrowserProvider implements BrowserProvider { return true } - const ids = sessionIds.get(sessionId) || [] - ids.push(moduleUrl.href) + const ids = sessionIds.get(sessionId) || new Set() + ids.add(moduleUrl.href) sessionIds.set(sessionId, ids) idPredicates.set(predicateKey(sessionId, moduleUrl.href), predicate) return predicate } - function predicateKey(sessionId: string, url: string) { - return `${sessionId}:${url}` + async function unregisterPredicate(page: Page, sessionId: string, url: string): Promise { + const normalizedUrl = normalizeUrl(url) + const key = predicateKey(sessionId, normalizedUrl) + const predicate = idPredicates.get(key) + if (!predicate) { + return + } + + await page.context().unroute(predicate).finally(() => { + idPredicates.delete(key) + + const ids = sessionIds.get(sessionId) + ids?.delete(normalizedUrl) + if (!ids?.size) { + sessionIds.delete(sessionId) + } + }) } return { register: async (sessionId: string, module: MockedModule): Promise => { const page = this.getPage(sessionId) + await unregisterPredicate(page, sessionId, module.url) await page.context().route(createPredicate(sessionId, module.url), async (route) => { if (module.type === 'manual') { const exports = Object.keys(await module.resolve()) @@ -373,24 +397,13 @@ export class PlaywrightBrowserProvider implements BrowserProvider { }, delete: async (sessionId: string, id: string): Promise => { const page = this.getPage(sessionId) - const key = predicateKey(sessionId, id) - const predicate = idPredicates.get(key) - if (predicate) { - await page.context().unroute(predicate).finally(() => idPredicates.delete(key)) - } + await unregisterPredicate(page, sessionId, id) }, clear: async (sessionId: string): Promise => { const page = this.getPage(sessionId) - const ids = sessionIds.get(sessionId) || [] - const promises = ids.map((id) => { - const key = predicateKey(sessionId, id) - const predicate = idPredicates.get(key) - if (predicate) { - return page.context().unroute(predicate).finally(() => idPredicates.delete(key)) - } - return null - }) - await Promise.all(promises).finally(() => sessionIds.delete(sessionId)) + const ids = sessionIds.get(sessionId) + const promises = [...(ids || [])].map(id => unregisterPredicate(page, sessionId, id)) + await Promise.all(promises) }, } } diff --git a/test/browser/fixtures/manual-mock-alias-leak/src/modal.ts b/test/browser/fixtures/manual-mock-alias-leak/src/modal.ts new file mode 100644 index 000000000000..ab66d7fb4209 --- /dev/null +++ b/test/browser/fixtures/manual-mock-alias-leak/src/modal.ts @@ -0,0 +1 @@ +export function useModalStore() {} diff --git a/test/browser/fixtures/manual-mock-alias-leak/src/probe.spec.ts b/test/browser/fixtures/manual-mock-alias-leak/src/probe.spec.ts new file mode 100644 index 000000000000..688efb621204 --- /dev/null +++ b/test/browser/fixtures/manual-mock-alias-leak/src/probe.spec.ts @@ -0,0 +1,17 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('~/modal', () => ({ + useModalStore() {}, +})) + +vi.mock('./modal', () => ({ + useModalStore() {}, +})) + +import { probe } from './probe' + +describe('probe', () => { + it('passes with duplicate manual mocks for the same module', () => { + expect(probe).toBe(true) + }) +}) diff --git a/test/browser/fixtures/manual-mock-alias-leak/src/probe.ts b/test/browser/fixtures/manual-mock-alias-leak/src/probe.ts new file mode 100644 index 000000000000..ee71437edb4f --- /dev/null +++ b/test/browser/fixtures/manual-mock-alias-leak/src/probe.ts @@ -0,0 +1,5 @@ +import { useModalStore } from '~/modal' + +useModalStore() + +export const probe = true diff --git a/test/browser/fixtures/manual-mock-alias-leak/src/target.spec.ts b/test/browser/fixtures/manual-mock-alias-leak/src/target.spec.ts new file mode 100644 index 000000000000..d8538ae138e7 --- /dev/null +++ b/test/browser/fixtures/manual-mock-alias-leak/src/target.spec.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest' + +import { target } from './target' + +describe('target', () => { + it('passes without registering a mock', () => { + expect(target).toBe(true) + }) +}) diff --git a/test/browser/fixtures/manual-mock-alias-leak/src/target.ts b/test/browser/fixtures/manual-mock-alias-leak/src/target.ts new file mode 100644 index 000000000000..5db84f9a0136 --- /dev/null +++ b/test/browser/fixtures/manual-mock-alias-leak/src/target.ts @@ -0,0 +1,5 @@ +import { useModalStore } from '~/modal' + +useModalStore() + +export const target = true diff --git a/test/browser/fixtures/manual-mock-alias-leak/vitest.config.ts b/test/browser/fixtures/manual-mock-alias-leak/vitest.config.ts new file mode 100644 index 000000000000..559955925aac --- /dev/null +++ b/test/browser/fixtures/manual-mock-alias-leak/vitest.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vitest/config' +import { instances, provider } from '../../settings' + +export default defineConfig({ + resolve: { + alias: { + '~/': `${new URL('src/', import.meta.url).pathname}`, + }, + }, + test: { + browser: { + enabled: true, + provider, + instances, + headless: true, + }, + fileParallelism: false, + maxWorkers: 1, + include: ['src/**/*.spec.ts'], + }, +}) diff --git a/test/browser/specs/mocking.test.ts b/test/browser/specs/mocking.test.ts index 688b19f7fac4..a6baa2079b31 100644 --- a/test/browser/specs/mocking.test.ts +++ b/test/browser/specs/mocking.test.ts @@ -78,3 +78,23 @@ test('mocking out of root', async () => { expect(vitest.stdout).toReportPassedTest('basic.test.js', browser) }) }) + +test('manual mocks do not leak across browser spec files when the same module is mocked via different ids', async () => { + const result = await runVitest({ + root: 'fixtures/manual-mock-alias-leak', + }) + + onTestFailed(() => { + console.error(result.stdout) + console.error(result.stderr) + }) + + expect(result.stderr).toReportNoErrors() + + instances.forEach(({ browser }) => { + expect(result.stdout).toReportPassedTest('src/probe.spec.ts', browser) + expect(result.stdout).toReportPassedTest('src/target.spec.ts', browser) + }) + + expect(result.exitCode).toBe(0) +}) From 23571caf7dc27f5343d4ca89347cd8492d37bf9b Mon Sep 17 00:00:00 2001 From: GG ZIBLAKING Date: Thu, 2 Apr 2026 21:05:08 +0800 Subject: [PATCH 2/2] chore: rerun CI