diff --git a/packages/playwright-ct-core/src/tsxTransform.ts b/packages/playwright-ct-core/src/tsxTransform.ts index edbaa3e05b..4d1d95bd67 100644 --- a/packages/playwright-ct-core/src/tsxTransform.ts +++ b/packages/playwright-ct-core/src/tsxTransform.ts @@ -166,10 +166,11 @@ export function collectComponentUsages(node: T.Node) { } export type ComponentInfo = { - fullName: string, - importPath: string, - isModuleOrAlias: boolean, - importedName?: string + fullName: string; + importPath: string; + isModuleOrAlias: boolean; + importedName?: string; + deps: string[]; }; export function componentInfo(specifier: T.ImportSpecifier | T.ImportDefaultSpecifier, importSource: string, filename: string): ComponentInfo { @@ -183,9 +184,9 @@ export function componentInfo(specifier: T.ImportSpecifier | T.ImportDefaultSpec const pathInfo = { importPath, isModuleOrAlias }; if (t.isImportDefaultSpecifier(specifier)) - return { fullName: prefix, ...pathInfo }; + return { fullName: prefix, deps: [], ...pathInfo }; if (t.isIdentifier(specifier.imported)) - return { fullName: prefix + '_' + specifier.imported.name, importedName: specifier.imported.name, ...pathInfo }; - return { fullName: prefix + '_' + specifier.imported.value, importedName: specifier.imported.value, ...pathInfo }; + return { fullName: prefix + '_' + specifier.imported.name, importedName: specifier.imported.name, deps: [], ...pathInfo }; + return { fullName: prefix + '_' + specifier.imported.value, importedName: specifier.imported.value, deps: [], ...pathInfo }; } diff --git a/packages/playwright-ct-core/src/vitePlugin.ts b/packages/playwright-ct-core/src/vitePlugin.ts index 626659c533..f714564bd9 100644 --- a/packages/playwright-ct-core/src/vitePlugin.ts +++ b/packages/playwright-ct-core/src/vitePlugin.ts @@ -17,11 +17,12 @@ import type { Suite } from '@playwright/test/reporter'; import type { PlaywrightTestConfig as BasePlaywrightTestConfig, FullConfig } from '@playwright/test'; -import type { InlineConfig, Plugin, ResolveFn, ResolvedConfig } from 'vite'; +import type { InlineConfig, Plugin, ResolveFn, ResolvedConfig, UserConfig } from 'vite'; import type { TestRunnerPlugin } from '../../playwright-test/src/plugins'; import type { ComponentInfo } from './tsxTransform'; import type { AddressInfo } from 'net'; import type { PluginContext } from 'rollup'; +import { debug } from 'playwright-core/lib/utilsBundle'; import fs from 'fs'; import path from 'path'; @@ -31,6 +32,9 @@ import { assert, calculateSha1 } from 'playwright-core/lib/utils'; import { getPlaywrightVersion } from 'playwright-core/lib/utils'; import { setExternalDependencies } from '@playwright/test/lib/transform/compilationCache'; import { collectComponentUsages, componentInfo } from './tsxTransform'; +import { version as viteVersion, build, preview, mergeConfig } from 'vite'; + +const log = debug('pw:vite'); let stoppableServer: any; const playwrightVersion = getPlaywrightVersion(); @@ -59,23 +63,51 @@ export function createPlugin( }, begin: async (suite: Suite) => { + // We are going to have 3 config files: + // - the defaults that user config overrides (baseConfig) + // - the user config (userConfig) + // - frameworks overrides (frameworkOverrides); + const use = config.projects[0].use as CtConfig; const port = use.ctPort || 3100; - const viteConfig = typeof use.ctViteConfig === 'function' ? await use.ctViteConfig() : (use.ctViteConfig || {}); - const templateDirConfig = use.ctTemplateDir || 'playwright'; + const relativeTemplateDir = use.ctTemplateDir || 'playwright'; - const rootDir = viteConfig.root || configDir; - const templateDir = path.resolve(rootDir, templateDirConfig); - const outDir = viteConfig?.build?.outDir || (use.ctCacheDir ? path.resolve(rootDir, use.ctCacheDir) : path.resolve(templateDir, '.cache')); + // FIXME: use build plugin to determine html location to resolve this. + // TemplateDir must be relative, otherwise we can't move the final index.html into its target location post-build. + // This regressed in https://github.com/microsoft/playwright/pull/26526 + const templateDir = path.join(configDir, relativeTemplateDir); + + // Compose base config from the playwright config only. + const baseConfig = { + root: configDir, + configFile: false, + define: { + __VUE_PROD_DEVTOOLS__: true, + }, + css: { + devSourcemap: true, + }, + build: { + outDir: use.ctCacheDir ? path.resolve(configDir, use.ctCacheDir) : path.resolve(templateDir, '.cache') + }, + preview: { + port + }, + // Vite preview server will otherwise always return the index.html with 200. + appType: 'custom', + }; + + // Apply user config on top of the base config. This could have changed root and build.outDir. + const userConfig = typeof use.ctViteConfig === 'function' ? await use.ctViteConfig() : (use.ctViteConfig || {}); + const baseAndUserConfig = mergeConfig(baseConfig, userConfig); + const buildInfoFile = path.join(baseAndUserConfig.build.outDir, 'metainfo.json'); - const buildInfoFile = path.join(outDir, 'metainfo.json'); let buildExists = false; let buildInfo: BuildInfo; const registerSource = await fs.promises.readFile(registerSourceFile, 'utf-8'); const registerSourceHash = calculateSha1(registerSource); - const { version: viteVersion } = await import('vite'); try { buildInfo = JSON.parse(await fs.promises.readFile(buildInfoFile, 'utf-8')) as BuildInfo; assert(buildInfo.version === playwrightVersion); @@ -92,54 +124,52 @@ export function createPlugin( sources: {}, }; } + log('build exists:', buildExists); const componentRegistry: ComponentRegistry = new Map(); // 1. Re-parse changed tests and collect required components. const hasNewTests = await checkNewTests(suite, buildInfo, componentRegistry); + log('has new tests:', hasNewTests); + // 2. Check if the set of required components has changed. const hasNewComponents = await checkNewComponents(buildInfo, componentRegistry); + log('has new components:', hasNewComponents); + // 3. Check component sources. const sourcesDirty = !buildExists || hasNewComponents || await checkSources(buildInfo); + log('sourcesDirty:', sourcesDirty); + // 4. Update component info. buildInfo.components = [...componentRegistry.values()]; - viteConfig.root = rootDir; - viteConfig.preview = { port, ...viteConfig.preview }; - // Vite preview server will otherwise always return the index.html with 200. - viteConfig.appType = viteConfig.appType || 'custom'; + const frameworkOverrides: UserConfig = { plugins: [] }; // React heuristic. If we see a component in a file with .js extension, // consider it a potential JSX-in-JS scenario and enable JSX loader for all // .js files. if (hasJSComponents(buildInfo.components)) { - viteConfig.esbuild = { + log('jsx-in-js detected'); + frameworkOverrides.esbuild = { loader: 'jsx', include: /.*\.jsx?$/, exclude: [], }; - viteConfig.optimizeDeps = { + frameworkOverrides.optimizeDeps = { esbuildOptions: { loader: { '.js': 'jsx' }, } }; } - const { build, preview } = await import('vite'); - // Build config unconditionally, either build or build & preview will use it. - viteConfig.plugins ??= []; - if (frameworkPluginFactory && !viteConfig.plugins.length) - viteConfig.plugins = [await frameworkPluginFactory()]; + + // We assume that any non-empty plugin list includes `vite-react` or similar. + if (frameworkPluginFactory && !baseAndUserConfig.plugins?.length) + frameworkOverrides.plugins = [await frameworkPluginFactory()]; // But only add out own plugin when we actually build / transform. if (sourcesDirty) - viteConfig.plugins.push(vitePlugin(registerSource, templateDir, buildInfo, componentRegistry)); - viteConfig.configFile = viteConfig.configFile || false; - viteConfig.define = viteConfig.define || {}; - viteConfig.define.__VUE_PROD_DEVTOOLS__ = true; - viteConfig.css = viteConfig.css || {}; - viteConfig.css.devSourcemap = true; - viteConfig.build = { - ...viteConfig.build, - outDir, + frameworkOverrides.plugins!.push(vitePlugin(registerSource, templateDir, buildInfo, componentRegistry)); + + frameworkOverrides.build = { target: 'esnext', minify: false, rollupOptions: { @@ -151,24 +181,34 @@ export function createPlugin( sourcemap: true, }; + const finalConfig = mergeConfig(baseAndUserConfig, frameworkOverrides); + if (sourcesDirty) { - await build(viteConfig); - const relativeTemplateDir = path.relative(rootDir, templateDir); - await fs.promises.rename(path.resolve(outDir, relativeTemplateDir, 'index.html'), `${outDir}/index.html`); + log('build'); + await build(finalConfig); + await fs.promises.rename(`${finalConfig.build.outDir}/${relativeTemplateDir}/index.html`, `${finalConfig.build.outDir}/index.html`); } - if (hasNewTests || hasNewComponents || sourcesDirty) + if (hasNewTests || hasNewComponents || sourcesDirty) { + log('write manifest'); await fs.promises.writeFile(buildInfoFile, JSON.stringify(buildInfo, undefined, 2)); + } - for (const [filename, testInfo] of Object.entries(buildInfo.tests)) - setExternalDependencies(filename, testInfo.deps); + for (const [filename, testInfo] of Object.entries(buildInfo.tests)) { + const deps = new Set(); + for (const componentName of testInfo.components) { + const component = componentRegistry.get(componentName); + component?.deps.forEach(d => deps.add(d)); + } + setExternalDependencies(filename, [...deps]); + } - const previewServer = await preview(viteConfig); + const previewServer = await preview(finalConfig); stoppableServer = stoppable(previewServer.httpServer, 0); const isAddressInfo = (x: any): x is AddressInfo => x?.address; const address = previewServer.httpServer.address(); if (isAddressInfo(address)) { - const protocol = viteConfig.preview.https ? 'https:' : 'http:'; + const protocol = finalConfig.preview.https ? 'https:' : 'http:'; process.env.PLAYWRIGHT_TEST_BASE_URL = `${protocol}//localhost:${address.port}`; } }, @@ -194,7 +234,6 @@ type BuildInfo = { [key: string]: { timestamp: number; components: string[]; - deps: string[]; } }; }; @@ -205,9 +244,12 @@ async function checkSources(buildInfo: BuildInfo): Promise { for (const [source, sourceInfo] of Object.entries(buildInfo.sources)) { try { const timestamp = (await fs.promises.stat(source)).mtimeMs; - if (sourceInfo.timestamp !== timestamp) + if (sourceInfo.timestamp !== timestamp) { + log('source has changed:', source); return true; + } } catch (e) { + log('check source failed:', e); return true; } } @@ -226,9 +268,10 @@ async function checkNewTests(suite: Suite, buildInfo: BuildInfo, componentRegist const timestamp = (await fs.promises.stat(testFile)).mtimeMs; if (buildInfo.tests[testFile]?.timestamp !== timestamp) { const components = await parseTestFile(testFile); + log('changed test:', testFile); for (const component of components) componentRegistry.set(component.fullName, component); - buildInfo.tests[testFile] = { timestamp, components: components.map(c => c.fullName), deps: [] }; + buildInfo.tests[testFile] = { timestamp, components: components.map(c => c.fullName) }; hasNewTests = true; } } @@ -336,23 +379,13 @@ function vitePlugin(registerSource: string, templateDir: string, buildInfo: Buil }, 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]; + component.deps = [...deps]; } }, }; diff --git a/packages/playwright-ct-react/registerSource.mjs b/packages/playwright-ct-react/registerSource.mjs index 400924f1ed..e6380757a1 100644 --- a/packages/playwright-ct-react/registerSource.mjs +++ b/packages/playwright-ct-react/registerSource.mjs @@ -53,7 +53,7 @@ function isComponent(component) { */ async function __pwResolveComponent(component) { if (!isComponent(component)) - return + return; let componentFactory = __pwLoaderRegistry.get(component.type); if (!componentFactory) { @@ -69,11 +69,11 @@ async function __pwResolveComponent(component) { if (!componentFactory && component.type[0].toUpperCase() === component.type[0]) throw new Error(`Unregistered component: ${component.type}. Following components are registered: ${[...__pwRegistry.keys()]}`); - if(componentFactory) - __pwRegistry.set(component.type, await componentFactory()) + if (componentFactory) + __pwRegistry.set(component.type, await componentFactory()); if ('children' in component) - await Promise.all(component.children.map(child => __pwResolveComponent(child))) + await Promise.all(component.children.map(child => __pwResolveComponent(child))); } /** diff --git a/packages/playwright-ct-react17/registerSource.mjs b/packages/playwright-ct-react17/registerSource.mjs index 06a0ec10b1..0a3bef1b5b 100644 --- a/packages/playwright-ct-react17/registerSource.mjs +++ b/packages/playwright-ct-react17/registerSource.mjs @@ -52,7 +52,7 @@ function isComponent(component) { */ async function __pwResolveComponent(component) { if (!isComponent(component)) - return + return; let componentFactory = __pwLoaderRegistry.get(component.type); if (!componentFactory) { @@ -68,11 +68,11 @@ async function __pwResolveComponent(component) { if (!componentFactory && component.type[0].toUpperCase() === component.type[0]) throw new Error(`Unregistered component: ${component.type}. Following components are registered: ${[...__pwRegistry.keys()]}`); - if(componentFactory) - __pwRegistry.set(component.type, await componentFactory()) + if (componentFactory) + __pwRegistry.set(component.type, await componentFactory()); if ('children' in component) - await Promise.all(component.children.map(child => __pwResolveComponent(child))) + await Promise.all(component.children.map(child => __pwResolveComponent(child))); } /** @@ -83,7 +83,7 @@ function __pwRender(component) { return component; const componentFunc = __pwRegistry.get(component.type); - + if (component.kind !== 'jsx') throw new Error('Object mount notation is not supported'); diff --git a/packages/playwright-test/src/transform/compilationCache.ts b/packages/playwright-test/src/transform/compilationCache.ts index 52e89fc3c9..df768cfe3e 100644 --- a/packages/playwright-test/src/transform/compilationCache.ts +++ b/packages/playwright-test/src/transform/compilationCache.ts @@ -177,7 +177,12 @@ export function collectAffectedTestFiles(dependency: string, testFileCollector: } export function dependenciesForTestFile(filename: string): Set { - return fileDependencies.get(filename) || new Set(); + const result = new Set(); + for (const dep of fileDependencies.get(filename) || []) + result.add(dep); + for (const dep of externalDependencies.get(filename) || []) + result.add(dep); + return result; } // These two are only used in the dev mode, they are specifically excluding diff --git a/tests/playwright-test/playwright.ct-build.spec.ts b/tests/playwright-test/playwright.ct-build.spec.ts index 2b341a1ad9..866fedcab6 100644 --- a/tests/playwright-test/playwright.ct-build.spec.ts +++ b/tests/playwright-test/playwright.ct-build.spec.ts @@ -138,31 +138,55 @@ test('should extract component list', async ({ runInlineTest }, testInfo) => { fullName: expect.stringContaining('playwright_test_src_button_tsx_Button'), importedName: 'Button', importPath: expect.stringContaining('button.tsx'), - isModuleOrAlias: false + isModuleOrAlias: false, + deps: [ + expect.stringContaining('button.tsx'), + expect.stringContaining('jsx-runtime.js'), + ] }, { fullName: expect.stringContaining('playwright_test_src_clashingNames1_tsx_ClashingName'), importedName: 'ClashingName', importPath: expect.stringContaining('clashingNames1.tsx'), - isModuleOrAlias: false + isModuleOrAlias: false, + deps: [ + expect.stringContaining('clashingNames1.tsx'), + expect.stringContaining('jsx-runtime.js'), + ] }, { fullName: expect.stringContaining('playwright_test_src_clashingNames2_tsx_ClashingName'), importedName: 'ClashingName', importPath: expect.stringContaining('clashingNames2.tsx'), - isModuleOrAlias: false + isModuleOrAlias: false, + deps: [ + expect.stringContaining('clashingNames2.tsx'), + expect.stringContaining('jsx-runtime.js'), + ] }, { fullName: expect.stringContaining('playwright_test_src_components_tsx_Component1'), importedName: 'Component1', importPath: expect.stringContaining('components.tsx'), - isModuleOrAlias: false + isModuleOrAlias: false, + deps: [ + expect.stringContaining('components.tsx'), + expect.stringContaining('jsx-runtime.js'), + ] }, { fullName: expect.stringContaining('playwright_test_src_components_tsx_Component2'), importedName: 'Component2', importPath: expect.stringContaining('components.tsx'), - isModuleOrAlias: false + isModuleOrAlias: false, + deps: [ + expect.stringContaining('components.tsx'), + expect.stringContaining('jsx-runtime.js'), + ] }, { fullName: expect.stringContaining('playwright_test_src_defaultExport_tsx'), importPath: expect.stringContaining('defaultExport.tsx'), - isModuleOrAlias: false + isModuleOrAlias: false, + deps: [ + expect.stringContaining('defaultExport.tsx'), + expect.stringContaining('jsx-runtime.js'), + ] }]); for (const [file, test] of Object.entries(metainfo.tests)) { @@ -173,11 +197,6 @@ test('should extract component list', async ({ runInlineTest }, testInfo) => { expect.stringContaining('clashingNames1_tsx_ClashingName'), expect.stringContaining('clashingNames2_tsx_ClashingName'), ], - deps: [ - expect.stringContaining('clashingNames1.tsx'), - expect.stringContaining('jsx-runtime.js'), - expect.stringContaining('clashingNames2.tsx'), - ], }); } if (file.endsWith('default-import.spec.tsx')) { @@ -186,10 +205,6 @@ test('should extract component list', async ({ runInlineTest }, testInfo) => { components: [ expect.stringContaining('defaultExport_tsx'), ], - deps: [ - expect.stringContaining('defaultExport.tsx'), - expect.stringContaining('jsx-runtime.js'), - ] }); } if (file.endsWith('named-imports.spec.tsx')) { @@ -199,10 +214,6 @@ test('should extract component list', async ({ runInlineTest }, testInfo) => { expect.stringContaining('components_tsx_Component1'), expect.stringContaining('components_tsx_Component2'), ], - deps: [ - expect.stringContaining('components.tsx'), - expect.stringContaining('jsx-runtime.js'), - ] }); } if (file.endsWith('one-import.spec.tsx')) { @@ -211,10 +222,6 @@ test('should extract component list', async ({ runInlineTest }, testInfo) => { components: [ expect.stringContaining('button_tsx_Button'), ], - deps: [ - expect.stringContaining('button.tsx'), - expect.stringContaining('jsx-runtime.js'), - ] }); } } @@ -436,3 +443,73 @@ test('list compilation cache should not clash with the run one', async ({ runInl expect(runResult.exitCode).toBe(0); expect(runResult.passed).toBe(1); }); + +test('should retain deps when test changes', async ({ runInlineTest }, testInfo) => { + test.slow(); + + await test.step('original test', async () => { + const result = await runInlineTest({ + 'playwright.config.ts': playwrightConfig, + 'playwright/index.html': ``, + 'playwright/index.ts': ``, + 'src/button.tsx': ` + export const Button = () => ; + `, + 'src/button.test.tsx': ` + import { test, expect } from '@playwright/experimental-ct-react'; + import { Button } from './button.tsx'; + test('pass', async ({ mount }) => { + const component = await mount(); + await expect(component).toHaveText('Button'); + }); + `, + }, { workers: 1 }); + + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); + const output = result.output; + expect(output).toContain('modules transformed'); + }); + + await test.step('modify test and run it again', async () => { + const result = await runInlineTest({ + 'src/button.test.tsx': ` + import { test, expect } from '@playwright/experimental-ct-react'; + import { Button } from './button.tsx'; + test('pass', async ({ mount }) => { + const component1 = await mount(); + await expect(component1).toHaveText('Button'); + }); + `, + }, { workers: 1 }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); + const output = result.output; + expect(output).not.toContain('modules transformed'); + }); + + const metainfo = JSON.parse(fs.readFileSync(testInfo.outputPath('playwright/.cache/metainfo.json'), 'utf-8')); + + expect(metainfo.components).toEqual([{ + fullName: expect.stringContaining('playwright_test_src_button_tsx_Button'), + importedName: 'Button', + importPath: expect.stringContaining('button.tsx'), + isModuleOrAlias: false, + deps: [ + expect.stringContaining('button.tsx'), + expect.stringContaining('jsx-runtime.js'), + ] + }]); + + expect(Object.entries(metainfo.tests)).toEqual([ + [ + expect.stringContaining('button.test.tsx'), + { + components: [ + expect.stringContaining('src_button_tsx_Button'), + ], + timestamp: expect.any(Number) + } + ] + ]); +}); diff --git a/tests/playwright-test/ui-mode-test-ct.spec.ts b/tests/playwright-test/ui-mode-test-ct.spec.ts new file mode 100644 index 0000000000..13cfb49d56 --- /dev/null +++ b/tests/playwright-test/ui-mode-test-ct.spec.ts @@ -0,0 +1,209 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect, retries, dumpTestTree } from './ui-mode-fixtures'; + +test.describe.configure({ mode: 'parallel', retries }); + +const basicTestTree = { + 'playwright.config.ts': ` + import { defineConfig } from '@playwright/experimental-ct-react'; + export default defineConfig({}); + `, + 'playwright/index.html': ``, + 'playwright/index.ts': ``, + 'src/button.tsx': ` + export const Button = () => ; + `, + 'src/button.test.tsx': ` + 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: 1 }); + }); + `, +}; + +test('should run component tests', async ({ runUITest }) => { + const { page } = await runUITest(basicTestTree); + await expect.poll(dumpTestTree(page)).toBe(` + ▼ ◯ button.test.tsx + ◯ pass + `); + await page.getByTitle('Run all').click(); + await expect.poll(dumpTestTree(page)).toBe(` + ▼ ✅ button.test.tsx + ✅ pass + `); +}); + +test('should run component tests after editing test', async ({ runUITest, writeFiles }) => { + const { page } = await runUITest(basicTestTree); + await expect.poll(dumpTestTree(page)).toBe(` + ▼ ◯ button.test.tsx + ◯ pass + `); + await page.getByTitle('Run all').click(); + await expect.poll(dumpTestTree(page)).toBe(` + ▼ ✅ button.test.tsx + ✅ pass + `); + + await writeFiles({ + 'src/button.test.tsx': ` + import { test, expect } from '@playwright/experimental-ct-react'; + import { Button } from './button'; + + test('fail', async ({ mount }) => { + const component = await mount(); + await expect(component).toHaveText('Button2', { timeout: 1 }); + }); + ` + }); + await expect.poll(dumpTestTree(page)).toBe(` + ▼ ◯ button.test.tsx + ◯ fail + `); + await page.getByTitle('Run all').click(); + await expect.poll(dumpTestTree(page)).toBe(` + ▼ ❌ button.test.tsx + ❌ fail <= + `); +}); + +test('should run component tests after editing component', async ({ runUITest, writeFiles }) => { + const { page } = await runUITest(basicTestTree); + await expect.poll(dumpTestTree(page)).toBe(` + ▼ ◯ button.test.tsx + ◯ pass + `); + await page.getByTitle('Run all').click(); + await expect.poll(dumpTestTree(page)).toBe(` + ▼ ✅ button.test.tsx + ✅ pass + `); + + await writeFiles({ + 'src/button.tsx': ` + export const Button = () => ; + ` + }); + await page.getByTitle('Run all').click(); + await expect.poll(dumpTestTree(page)).toBe(` + ▼ ❌ button.test.tsx + ❌ pass <= + `); +}); + +test('should run component tests after editing test and component', async ({ runUITest, writeFiles }) => { + const { page } = await runUITest(basicTestTree); + await expect.poll(dumpTestTree(page)).toBe(` + ▼ ◯ button.test.tsx + ◯ pass + `); + await page.getByTitle('Run all').click(); + await expect.poll(dumpTestTree(page)).toBe(` + ▼ ✅ button.test.tsx + ✅ pass + `); + + await writeFiles({ + 'src/button.test.tsx': ` + import { test, expect } from '@playwright/experimental-ct-react'; + import { Button } from './button'; + + test('pass 2', async ({ mount }) => { + const component = await mount(); + await expect(component).toHaveText('Button2', { timeout: 1 }); + }); + `, + 'src/button.tsx': ` + export const Button = () => ; + ` + }); + await expect.poll(dumpTestTree(page)).toBe(` + ▼ ◯ button.test.tsx + ◯ pass 2 + `); + + await page.getByTitle('Run all').click(); + await expect.poll(dumpTestTree(page)).toBe(` + ▼ ✅ button.test.tsx + ✅ pass 2 + `); +}); + +test('should watch test', async ({ runUITest, writeFiles }) => { + const { page } = await runUITest(basicTestTree); + await expect.poll(dumpTestTree(page)).toBe(` + ▼ ◯ button.test.tsx + ◯ pass + `); + + await page.getByTitle('Watch all').click(); + await page.getByTitle('Run all').click(); + + await expect.poll(dumpTestTree(page)).toBe(` + ▼ ✅ button.test.tsx + ✅ pass + `); + + await writeFiles({ + 'src/button.test.tsx': ` + import { test, expect } from '@playwright/experimental-ct-react'; + import { Button } from './button'; + + test('pass', async ({ mount }) => { + const component = await mount(); + await expect(component).toHaveText('Button2', { timeout: 1 }); + }); + ` + }); + + await expect.poll(dumpTestTree(page)).toBe(` + ▼ ❌ button.test.tsx + ❌ pass <= + `); +}); + +test('should watch component', async ({ runUITest, writeFiles }) => { + const { page } = await runUITest(basicTestTree); + await expect.poll(dumpTestTree(page)).toBe(` + ▼ ◯ button.test.tsx + ◯ pass + `); + + await page.getByTitle('Watch all').click(); + await page.getByTitle('Run all').click(); + + await expect.poll(dumpTestTree(page)).toBe(` + ▼ ✅ button.test.tsx + ✅ pass + `); + + await writeFiles({ + 'src/button.tsx': ` + export const Button = () => ; + ` + }); + + await expect.poll(dumpTestTree(page)).toBe(` + ▼ ❌ button.test.tsx + ❌ pass <= + `); +});