diff --git a/packages/playwright-test/src/common/compilationCache.ts b/packages/playwright-test/src/common/compilationCache.ts index 215bca061b..fd11c40a8d 100644 --- a/packages/playwright-test/src/common/compilationCache.ts +++ b/packages/playwright-test/src/common/compilationCache.ts @@ -32,7 +32,10 @@ const cacheDir = process.env.PWTEST_CACHE_DIR || path.join(os.tmpdir(), 'playwri const sourceMaps: Map = new Map(); const memoryCache = new Map(); +// Dependencies resolved by the loader. const fileDependencies = new Map>(); +// Dependencies resolved by the external bundler. +const externalDependencies = new Map>(); Error.stackTraceLimit = 200; @@ -93,6 +96,7 @@ export function serializeCompilationCache(): any { sourceMaps: [...sourceMaps.entries()], memoryCache: [...memoryCache.entries()], fileDependencies: [...fileDependencies.entries()].map(([filename, deps]) => ([filename, [...deps]])), + externalDependencies: [...externalDependencies.entries()].map(([filename, deps]) => ([filename, [...deps]])), }; } @@ -108,6 +112,8 @@ export function addToCompilationCache(payload: any) { memoryCache.set(entry[0], entry[1]); for (const entry of payload.fileDependencies) fileDependencies.set(entry[0], new Set(entry[1])); + for (const entry of payload.externalDependencies) + externalDependencies.set(entry[0], new Set(entry[1])); } function calculateCachePath(content: string, filePath: string, isModule: boolean): string { @@ -146,6 +152,11 @@ export function currentFileDepsCollector(): Set | undefined { return depsCollector; } +export function setExternalDependencies(filename: string, deps: string[]) { + const depsSet = new Set(deps.filter(dep => !belongsToNodeModules(dep) && dep !== filename)); + externalDependencies.set(filename, depsSet); +} + export function fileDependenciesForTest() { return fileDependencies; } @@ -156,6 +167,10 @@ export function collectAffectedTestFiles(dependency: string, testFileCollector: if (deps.has(dependency)) testFileCollector.add(testFile); } + for (const [testFile, deps] of externalDependencies) { + if (deps.has(dependency)) + testFileCollector.add(testFile); + } } // These two are only used in the dev mode, they are specifically excluding diff --git a/packages/playwright-test/src/plugins/vitePlugin.ts b/packages/playwright-test/src/plugins/vitePlugin.ts index 0d37b1efe9..e026c6c062 100644 --- a/packages/playwright-test/src/plugins/vitePlugin.ts +++ b/packages/playwright-test/src/plugins/vitePlugin.ts @@ -17,7 +17,7 @@ import fs from 'fs'; import type { Suite } from '../../types/testReporter'; import path from 'path'; -import type { InlineConfig, Plugin } from 'vite'; +import type { InlineConfig, Plugin, ResolveFn, ResolvedConfig } from 'vite'; import type { TestRunnerPlugin } from '.'; import { parse, traverse, types as t } from '../common/babelBundle'; import { stoppable } from '../utilsBundle'; @@ -28,6 +28,8 @@ import { assert, calculateSha1 } from 'playwright-core/lib/utils'; import type { AddressInfo } from 'net'; import { getPlaywrightVersion } from 'playwright-core/lib/utils'; import type { PlaywrightTestConfig as BasePlaywrightTestConfig } from '@playwright/test'; +import type { PluginContext } from 'rollup'; +import { setExternalDependencies } from '../common/compilationCache'; let stoppableServer: any; const playwrightVersion = getPlaywrightVersion(); @@ -155,6 +157,9 @@ export function createPlugin( if (hasNewTests || hasNewComponents || sourcesDirty) await fs.promises.writeFile(buildInfoFile, JSON.stringify(buildInfo, undefined, 2)); + for (const [filename, testInfo] of Object.entries(buildInfo.tests)) + setExternalDependencies(filename, testInfo.deps); + const previewServer = await preview(viteConfig); stoppableServer = stoppable(previewServer.httpServer, 0); const isAddressInfo = (x: any): x is AddressInfo => x?.address; @@ -185,6 +190,7 @@ type BuildInfo = { [key: string]: { timestamp: number; components: string[]; + deps: string[]; } }; }; @@ -218,7 +224,7 @@ async function checkNewTests(suite: Suite, buildInfo: BuildInfo, componentRegist const components = await parseTestFile(testFile); for (const component of components) componentRegistry.set(component.fullName, component); - buildInfo.tests[testFile] = { timestamp, components: components.map(c => c.fullName) }; + buildInfo.tests[testFile] = { timestamp, components: components.map(c => c.fullName), deps: [] }; hasNewTests = true; } } @@ -272,10 +278,15 @@ async function parseTestFile(testFile: string): Promise { function vitePlugin(registerSource: string, relativeTemplateDir: string, buildInfo: BuildInfo, componentRegistry: ComponentRegistry): Plugin { buildInfo.sources = {}; + let moduleResolver: ResolveFn; return { name: 'playwright:component-index', - transform: async (content, id) => { + configResolved(config: ResolvedConfig) { + moduleResolver = config.createResolver(); + }, + + async transform(this: PluginContext, content, id) { const queryIndex = id.indexOf('?'); const file = queryIndex !== -1 ? id.substring(0, queryIndex) : id; if (!buildInfo.sources[file]) { @@ -318,9 +329,43 @@ function vitePlugin(registerSource: string, relativeTemplateDir: string, buildIn map: { mappings: '' } }; }, + + async writeBundle(this: PluginContext) { + const componentDeps = new Map>(); + for (const component of componentRegistry.values()) { + const id = (await moduleResolver(component.importPath)); + if (!id) + continue; + const deps = new Set(); + collectViteModuleDependencies(this, id, deps); + componentDeps.set(component.fullName, deps); + } + + for (const testInfo of Object.values(buildInfo.tests)) { + const deps = new Set(); + for (const fullName of testInfo.components) { + for (const dep of componentDeps.get(fullName) || []) + deps.add(dep); + } + testInfo.deps = [...deps]; + } + }, }; } +function collectViteModuleDependencies(context: PluginContext, id: string, deps: Set) { + if (!path.isAbsolute(id)) + return; + if (deps.has(id)) + return; + deps.add(id); + const module = context.getModuleInfo(id); + for (const importedId of module?.importedIds || []) + collectViteModuleDependencies(context, importedId, deps); + for (const importedId of module?.dynamicallyImportedIds || []) + collectViteModuleDependencies(context, importedId, deps); +} + function hasJSComponents(components: ComponentInfo[]): boolean { for (const component of components) { const extname = path.extname(component.importPath); diff --git a/tests/playwright-test/playwright.ct-build.spec.ts b/tests/playwright-test/playwright.ct-build.spec.ts index 1a4a326c44..529fd4f9eb 100644 --- a/tests/playwright-test/playwright.ct-build.spec.ts +++ b/tests/playwright-test/playwright.ct-build.spec.ts @@ -177,7 +177,11 @@ test('should extract component list', async ({ runInlineTest }, testInfo) => { components: [ expect.stringContaining('clashingNames1_tsx_ClashingName'), expect.stringContaining('clashingNames2_tsx_ClashingName'), - ] + ], + deps: [ + expect.stringContaining('clashingNames1.tsx'), + expect.stringContaining('clashingNames2.tsx'), + ], }); } if (file.endsWith('default-import.spec.tsx')) { @@ -185,6 +189,9 @@ test('should extract component list', async ({ runInlineTest }, testInfo) => { timestamp: expect.any(Number), components: [ expect.stringContaining('defaultExport_tsx'), + ], + deps: [ + expect.stringContaining('defaultExport.tsx'), ] }); } @@ -194,6 +201,9 @@ test('should extract component list', async ({ runInlineTest }, testInfo) => { components: [ expect.stringContaining('components_tsx_Component1'), expect.stringContaining('components_tsx_Component2'), + ], + deps: [ + expect.stringContaining('components.tsx'), ] }); } @@ -202,6 +212,9 @@ test('should extract component list', async ({ runInlineTest }, testInfo) => { timestamp: expect.any(Number), components: [ expect.stringContaining('button_tsx_Button'), + ], + deps: [ + expect.stringContaining('button.tsx'), ] }); } diff --git a/tests/playwright-test/watch.spec.ts b/tests/playwright-test/watch.spec.ts index 7fd9f68142..09f246627a 100644 --- a/tests/playwright-test/watch.spec.ts +++ b/tests/playwright-test/watch.spec.ts @@ -308,7 +308,7 @@ test('should run on changed files', async ({ runWatchTest, writeFiles }) => { await testProcess.waitForOutput('Error: expect(received).toBe(expected)'); await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); - writeFiles({ + await writeFiles({ 'c.test.ts': ` pwt.test('passes', () => {}); `, @@ -337,7 +337,7 @@ test('should run on changed deps', async ({ runWatchTest, writeFiles }) => { await testProcess.waitForOutput('old helper'); await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); - writeFiles({ + await writeFiles({ 'helper.ts': ` console.log('new helper'); `, @@ -366,7 +366,7 @@ test('should re-run changed files on R', async ({ runWatchTest, writeFiles }) => await testProcess.waitForOutput('Error: expect(received).toBe(expected)'); await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); - writeFiles({ + await writeFiles({ 'c.test.ts': ` pwt.test('passes', () => {}); `, @@ -397,7 +397,7 @@ test('should not trigger on changes to non-tests', async ({ runWatchTest, writeF await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); - writeFiles({ + await writeFiles({ 'helper.ts': ` console.log('helper'); `, @@ -423,7 +423,7 @@ test('should only watch selected projects', async ({ runWatchTest, writeFiles }) await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); - writeFiles({ + await writeFiles({ 'a.test.ts': ` pwt.test('passes', () => {}); `, @@ -450,7 +450,7 @@ test('should watch filtered files', async ({ runWatchTest, writeFiles }) => { await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); - writeFiles({ + await writeFiles({ 'b.test.ts': ` pwt.test('passes', () => {}); `, @@ -475,7 +475,7 @@ test('should not watch unfiltered files', async ({ runWatchTest, writeFiles }) = await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); - writeFiles({ + await writeFiles({ 'a.test.ts': ` pwt.test('passes', () => {}); `, @@ -487,3 +487,96 @@ test('should not watch unfiltered files', async ({ runWatchTest, writeFiles }) = expect(testProcess.output).not.toContain('b.test'); await testProcess.waitForOutput('Waiting for file changes.'); }); + +test('should run CT on changed deps', async ({ runWatchTest, writeFiles }) => { + const testProcess = await runWatchTest({ + 'playwright.config.ts': ` + import { defineConfig } from '@playwright/experimental-ct-react'; + export default defineConfig({ projects: [{name: 'default'}] }); + `, + 'playwright/index.html': ``, + 'playwright/index.ts': ``, + 'src/button.tsx': ` + export const Button = () => ; + `, + 'src/button.spec.tsx': ` + //@no-header + import { test, expect } from '@playwright/experimental-ct-react'; + import { Button } from './button'; + test('pass', async ({ mount }) => { + const component = await mount(); + await expect(component).toHaveText('Button', { timeout: 1000 }); + }); + `, + 'src/link.spec.tsx': ` + //@no-header + import { test, expect } from '@playwright/experimental-ct-react'; + test('pass', async ({ mount }) => { + const component = await mount(hello); + await expect(component).toHaveText('hello'); + }); + `, + }, {}); + await testProcess.waitForOutput('button.spec.tsx:5:11 › pass'); + await testProcess.waitForOutput('link.spec.tsx:4:11 › pass'); + await testProcess.waitForOutput('Waiting for file changes.'); + testProcess.clearOutput(); + await writeFiles({ + 'src/button.tsx': ` + export const Button = () => ; + `, + }); + + await testProcess.waitForOutput('src/button.spec.tsx:5:11 › pass'); + expect(testProcess.output).not.toContain('src/link.spec.tsx'); + await testProcess.waitForOutput('Error: expect(received).toHaveText(expected)'); + await testProcess.waitForOutput('Waiting for file changes.'); +}); + +test('should run CT on indirect deps change', async ({ runWatchTest, writeFiles }) => { + const testProcess = await runWatchTest({ + 'playwright.config.ts': ` + import { defineConfig } from '@playwright/experimental-ct-react'; + export default defineConfig({ projects: [{name: 'default'}] }); + `, + 'playwright/index.html': ``, + 'playwright/index.ts': ``, + 'src/button.css': ` + button { color: red; } + `, + 'src/button.tsx': ` + import './button.css'; + export const Button = () => ; + `, + 'src/button.spec.tsx': ` + //@no-header + import { test, expect } from '@playwright/experimental-ct-react'; + import { Button } from './button'; + test('pass', async ({ mount }) => { + const component = await mount(); + await expect(component).toHaveText('Button', { timeout: 1000 }); + }); + `, + 'src/link.spec.tsx': ` + //@no-header + import { test, expect } from '@playwright/experimental-ct-react'; + test('pass', async ({ mount }) => { + const component = await mount(hello); + await expect(component).toHaveText('hello'); + }); + `, + }, {}); + await testProcess.waitForOutput('button.spec.tsx:5:11 › pass'); + await testProcess.waitForOutput('link.spec.tsx:4:11 › pass'); + await testProcess.waitForOutput('Waiting for file changes.'); + testProcess.clearOutput(); + await writeFiles({ + 'src/button.css': ` + button { color: blue; } + `, + }); + + await testProcess.waitForOutput('src/button.spec.tsx:5:11 › pass'); + expect(testProcess.output).not.toContain('src/link.spec.tsx'); + await testProcess.waitForOutput('Waiting for file changes.'); +});