From f32383a3efcc6b8ac69b9107a500ab724f057979 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sun, 4 Sep 2022 10:53:09 +0300 Subject: [PATCH] feat!: if not processed, CSS Modules return a proxy, scope class names by filename (#1803) --- docs/config/index.md | 22 +++++-- packages/vitest/src/defaults.ts | 2 +- .../src/integrations/css/css-modules.ts | 20 ++++++ packages/vitest/src/node/config.ts | 6 +- .../vitest/src/node/plugins/cssEnabler.ts | 61 ++++++++++++++++--- packages/vitest/src/node/plugins/index.ts | 21 ++++++- packages/vitest/src/types/config.ts | 6 +- pnpm-lock.yaml | 14 ++++- test/css/package.json | 12 ++++ test/css/src/App.css | 4 ++ test/css/src/App.module.css | 7 +++ test/css/test/default-css.spec.ts | 38 ++++++++++++ test/css/test/non-scope-module.spec.ts | 18 ++++++ test/css/test/process-css.spec.ts | 37 +++++++++++ test/css/test/process-module.spec.ts | 32 ++++++++++ test/css/test/scope-module.spec.ts | 28 +++++++++ test/css/test/utils.ts | 13 ++++ test/css/testing.mjs | 27 ++++++++ test/css/vitest.config.ts | 7 +++ 19 files changed, 353 insertions(+), 22 deletions(-) create mode 100644 packages/vitest/src/integrations/css/css-modules.ts create mode 100644 test/css/package.json create mode 100644 test/css/src/App.css create mode 100644 test/css/src/App.module.css create mode 100644 test/css/test/default-css.spec.ts create mode 100644 test/css/test/non-scope-module.spec.ts create mode 100644 test/css/test/process-css.spec.ts create mode 100644 test/css/test/process-module.spec.ts create mode 100644 test/css/test/scope-module.spec.ts create mode 100644 test/css/test/utils.ts create mode 100644 test/css/testing.mjs create mode 100644 test/css/vitest.config.ts diff --git a/docs/config/index.md b/docs/config/index.md index 93a03a19e436..0564e20cec5f 100644 --- a/docs/config/index.md +++ b/docs/config/index.md @@ -713,14 +713,12 @@ Show heap usage after each test. Useful for debugging memory leaks. - **Type**: `boolean | { include?, exclude? }` -Configure if CSS should be processed. When excluded, CSS files will be replaced with empty strings to bypass the subsequent processing. - -By default, processes only CSS Modules, because it affects runtime. JSDOM and Happy DOM don't fully support injecting CSS, so disabling this setting might help with performance. +Configure if CSS should be processed. When excluded, CSS files will be replaced with empty strings to bypass the subsequent processing. CSS Modules will return a proxy to not affect runtime. #### css.include - **Type**: `RegExp | RegExp[]` -- **Default**: `[/\.module\./]` +- **Default**: `[]` RegExp pattern for files that should return actual CSS and will be processed by Vite pipeline. @@ -731,6 +729,22 @@ RegExp pattern for files that should return actual CSS and will be processed by RegExp pattern for files that will return an empty CSS file. +#### css.modules + +- **Type**: `{ classNameStrategy? }` +- **Default**: `{}` + +#### css.modules.classNameStrategy + +- **Type**: `'stable' | 'scoped' | 'non-scoped'` +- **Default**: `'stable'` + +If you decide to process CSS files, you can configure if class names inside CSS modules should be scoped. By default, Vitest exports a proxy, bypassing CSS Modules processing. You can choose one of the options: + +- `stable`: class names will be generated as `_${name}_${hashedFilename}`, which means that generated class will stay the same, if CSS content is changed, but will change, if the name of the file is modified, or file is moved to another folder. This setting is useful, if you use snapshot feature. +- `scoped`: class names will be generated as usual, respecting `css.modules.generateScopeName` method, if you have one. By default, filename will be generated as `_${name}_${hash}`, where hash includes filename and content of the file. +- `non-scoped`: class names will stay as they are defined in CSS. + ### maxConcurrency - **Type**: `number` diff --git a/packages/vitest/src/defaults.ts b/packages/vitest/src/defaults.ts index e8d2814a01bd..bd29f09802f2 100644 --- a/packages/vitest/src/defaults.ts +++ b/packages/vitest/src/defaults.ts @@ -76,7 +76,7 @@ const config = { uiBase: '/__vitest__/', open: true, css: { - include: [/\.module\./], + include: [], }, coverage: coverageConfigDefaults, fakeTimers: fakeTimersDefaults, diff --git a/packages/vitest/src/integrations/css/css-modules.ts b/packages/vitest/src/integrations/css/css-modules.ts new file mode 100644 index 000000000000..71b8f354ee46 --- /dev/null +++ b/packages/vitest/src/integrations/css/css-modules.ts @@ -0,0 +1,20 @@ +import { createHash } from 'node:crypto' +import type { CSSModuleScopeStrategy } from '../../types' + +export function generateCssFilenameHash(filepath: string) { + return createHash('md5').update(filepath).digest('hex').slice(0, 6) +} + +export function generateScopedClassName( + strategy: CSSModuleScopeStrategy, + name: string, + filename: string, +) { + // should be configured by Vite defaults + if (strategy === 'scoped') + return null + if (strategy === 'non-scoped') + return name + const hash = generateCssFilenameHash(filename) + return `_${name}_${hash}` +} diff --git a/packages/vitest/src/node/config.ts b/packages/vitest/src/node/config.ts index 4e1b6ddba6cc..72999551a87f 100644 --- a/packages/vitest/src/node/config.ts +++ b/packages/vitest/src/node/config.ts @@ -182,8 +182,10 @@ export function resolveConfig( resolved.passWithNoTests ??= true resolved.css ??= {} - if (typeof resolved.css === 'object') - resolved.css.include ??= [/\.module\./] + if (typeof resolved.css === 'object') { + resolved.css.modules ??= {} + resolved.css.modules.classNameStrategy ??= 'stable' + } resolved.cache ??= { dir: '' } if (resolved.cache) diff --git a/packages/vitest/src/node/plugins/cssEnabler.ts b/packages/vitest/src/node/plugins/cssEnabler.ts index 6afa2548737b..fda273615eb6 100644 --- a/packages/vitest/src/node/plugins/cssEnabler.ts +++ b/packages/vitest/src/node/plugins/cssEnabler.ts @@ -1,15 +1,30 @@ +import { relative } from 'pathe' import type { Plugin as VitePlugin } from 'vite' +import { generateCssFilenameHash } from '../../integrations/css/css-modules' +import type { CSSModuleScopeStrategy } from '../../types' import { toArray } from '../../utils' import type { Vitest } from '../core' const cssLangs = '\\.(css|less|sass|scss|styl|stylus|pcss|postcss)($|\\?)' const cssLangRE = new RegExp(cssLangs) +const cssModuleRE = new RegExp(`\\.module${cssLangs}`) const isCSS = (id: string) => { return cssLangRE.test(id) } -export function CSSEnablerPlugin(ctx: Vitest): VitePlugin { +const isCSSModule = (id: string) => { + return cssModuleRE.test(id) +} + +const getCSSModuleProxyReturn = (strategy: CSSModuleScopeStrategy, filename: string) => { + if (strategy === 'non-scoped') + return 'style' + const hash = generateCssFilenameHash(filename) + return `\`_\${style}_${hash}\`` +} + +export function CSSEnablerPlugin(ctx: Vitest): VitePlugin[] { const shouldProcessCSS = (id: string) => { const { css } = ctx.config if (typeof css === 'boolean') @@ -21,14 +36,42 @@ export function CSSEnablerPlugin(ctx: Vitest): VitePlugin { return false } - return { - name: 'vitest:css-enabler', - enforce: 'pre', - transform(code, id) { - if (!isCSS(id)) - return - if (!shouldProcessCSS(id)) + return [ + { + name: 'vitest:css-disable', + enforce: 'pre', + transform(code, id) { + if (!isCSS(id)) + return + // css plugin inside Vite won't do anything if the code is empty + // but it will put __vite__updateStyle anyway + if (!shouldProcessCSS(id)) + return { code: '' } + }, + }, + { + name: 'vitest:css-empty-post', + enforce: 'post', + transform(_, id) { + if (!isCSS(id) || shouldProcessCSS(id)) + return + + if (isCSSModule(id)) { + // return proxy for css modules, so that imported module has names: + // styles.foo returns a "foo" instead of "undefined" + // we don't use code content to generate hash for "scoped", because it's empty + const scopeStrategy = (typeof ctx.config.css !== 'boolean' && ctx.config.css.modules?.classNameStrategy) || 'stable' + const proxyReturn = getCSSModuleProxyReturn(scopeStrategy, relative(ctx.config.root, id)) + const code = `export default new Proxy(Object.create(null), { + get(_, style) { + return ${proxyReturn}; + }, + })` + return { code } + } + return { code: '' } + }, }, - } + ] } diff --git a/packages/vitest/src/node/plugins/index.ts b/packages/vitest/src/node/plugins/index.ts index f0f02bda4475..cc4ab2974cd7 100644 --- a/packages/vitest/src/node/plugins/index.ts +++ b/packages/vitest/src/node/plugins/index.ts @@ -1,9 +1,11 @@ import type { UserConfig as ViteConfig, Plugin as VitePlugin } from 'vite' +import { relative } from 'pathe' import { configDefaults } from '../../defaults' import type { ResolvedConfig, UserConfig } from '../../types' import { deepMerge, ensurePackageInstalled, notNullish } from '../../utils' import { resolveApiConfig } from '../config' import { Vitest } from '../core' +import { generateScopedClassName } from '../../integrations/css/css-modules' import { EnvReplacerPlugin } from './envReplacer' import { GlobalSetupPlugin } from './globalSetup' import { MocksPlugin } from './mock' @@ -11,13 +13,15 @@ import { CSSEnablerPlugin } from './cssEnabler' import { CoverageTransform } from './coverageTransform' export async function VitestPlugin(options: UserConfig = {}, ctx = new Vitest()): Promise { + const getRoot = () => ctx.config?.root || options.root || process.cwd() + async function UIPlugin() { - await ensurePackageInstalled('@vitest/ui', ctx.config?.root || options.root || process.cwd()) + await ensurePackageInstalled('@vitest/ui', getRoot()) return (await import('@vitest/ui')).default(options.uiBase) } async function BrowserPlugin() { - await ensurePackageInstalled('@vitest/browser', ctx.config?.root || options.root || process.cwd()) + await ensurePackageInstalled('@vitest/browser', getRoot()) return (await import('@vitest/browser')).default('/') } @@ -98,6 +102,17 @@ export async function VitestPlugin(options: UserConfig = {}, ctx = new Vitest()) }, } + const classNameStrategy = preOptions.css && preOptions.css?.modules?.classNameStrategy + + if (classNameStrategy !== 'scoped') { + config.css ??= {} + config.css.modules ??= {} + config.css.modules.generateScopedName = (name: string, filename: string) => { + const root = getRoot() + return generateScopedClassName(classNameStrategy, name, relative(root, filename))! + } + } + if (!options.browser) { // disable deps optimization Object.assign(config, { @@ -164,7 +179,7 @@ export async function VitestPlugin(options: UserConfig = {}, ctx = new Vitest()) ...(options.browser ? await BrowserPlugin() : []), - CSSEnablerPlugin(ctx), + ...CSSEnablerPlugin(ctx), CoverageTransform(ctx), options.ui ? await UIPlugin() diff --git a/packages/vitest/src/types/config.ts b/packages/vitest/src/types/config.ts index db8767fde28f..b588aa514bcb 100644 --- a/packages/vitest/src/types/config.ts +++ b/packages/vitest/src/types/config.ts @@ -12,6 +12,7 @@ import type { Arrayable } from './general' export type BuiltinEnvironment = 'node' | 'jsdom' | 'happy-dom' | 'edge-runtime' // Record is used, so user can get intellisense for builtin environments, but still allow custom environments export type VitestEnvironment = BuiltinEnvironment | (string & Record) +export type CSSModuleScopeStrategy = 'stable' | 'scoped' | 'non-scoped' export type ApiConfig = Pick @@ -375,11 +376,14 @@ export interface InlineConfig { * * When excluded, the CSS files will be replaced with empty strings to bypass the subsequent processing. * - * @default { include: [/\.module\./] } + * @default { include: [], modules: { classNameStrategy: false } } */ css?: boolean | { include?: RegExp | RegExp[] exclude?: RegExp | RegExp[] + modules?: { + classNameStrategy?: CSSModuleScopeStrategy + } } /** * A number of tests that are allowed to run at the same time marked with `test.concurrent`. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7aefb30ea826..9d6359754131 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,7 +126,7 @@ importers: unocss: 0.45.13_vite@3.0.9 unplugin-vue-components: 0.22.4_vite@3.0.9+vue@3.2.38 vite: 3.0.9 - vite-plugin-pwa: 0.12.3_vite@3.0.9 + vite-plugin-pwa: 0.12.3_f7se6o6eqkwcix4u3svh6mkvda vitepress: 1.0.0-alpha.13 workbox-window: 6.5.4 @@ -932,6 +932,14 @@ importers: vitest: link:../../packages/vitest vue: 3.2.38 + test/css: + specifiers: + jsdom: ^20.0.0 + vitest: workspace:* + devDependencies: + jsdom: 20.0.0 + vitest: link:../../packages/vitest + test/edge-runtime: specifiers: '@edge-runtime/vm': 1.1.0-beta.26 @@ -17836,6 +17844,7 @@ packages: /unified/9.2.0: resolution: {integrity: sha512-vx2Z0vY+a3YoTj8+pttM3tiJHCwY5UFbYdiWrwBEbHmK8pvsPj2rtAX2BFfgXen8T39CJWblWRDT4L5WGXtDdg==} dependencies: + '@types/unist': 2.0.6 bail: 1.0.5 extend: 3.0.2 is-buffer: 2.0.5 @@ -18387,10 +18396,11 @@ packages: - supports-color dev: true - /vite-plugin-pwa/0.12.3_vite@3.0.9: + /vite-plugin-pwa/0.12.3_f7se6o6eqkwcix4u3svh6mkvda: resolution: {integrity: sha512-gmYdIVXpmBuNjzbJFPZFzxWYrX4lHqwMAlOtjmXBbxApiHjx9QPXKQPJjSpeTeosLKvVbNcKSAAhfxMda0QVNQ==} peerDependencies: vite: ^2.0.0 || ^3.0.0-0 + workbox-window: ^6.4.0 dependencies: debug: 4.3.4 fast-glob: 3.2.11 diff --git a/test/css/package.json b/test/css/package.json new file mode 100644 index 000000000000..fa8898f68362 --- /dev/null +++ b/test/css/package.json @@ -0,0 +1,12 @@ +{ + "name": "@vitest/test-css", + "private": true, + "scripts": { + "test": "node testing.mjs", + "coverage": "vitest run --coverage" + }, + "devDependencies": { + "jsdom": "^20.0.0", + "vitest": "workspace:*" + } +} diff --git a/test/css/src/App.css b/test/css/src/App.css new file mode 100644 index 000000000000..df27a059f185 --- /dev/null +++ b/test/css/src/App.css @@ -0,0 +1,4 @@ +.main { + display: flex; + width: 100px; +} diff --git a/test/css/src/App.module.css b/test/css/src/App.module.css new file mode 100644 index 000000000000..f9fbbb6ccd4e --- /dev/null +++ b/test/css/src/App.module.css @@ -0,0 +1,7 @@ +.main { + display: flex; +} + +.module { + width: 100px; +} \ No newline at end of file diff --git a/test/css/test/default-css.spec.ts b/test/css/test/default-css.spec.ts new file mode 100644 index 000000000000..d7d4f62eebff --- /dev/null +++ b/test/css/test/default-css.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'vitest' +import { useRemoveStyles } from './utils' + +describe('don\'t process css by default', () => { + useRemoveStyles() + + test('doesn\'t apply css', async () => { + await import('../src/App.css') + + const element = document.createElement('div') + element.className = 'main' + const computed = window.getComputedStyle(element) + expect(computed.display).toBe('block') + expect(element).toMatchInlineSnapshot(` +
+ `) + }) + + test('module is not processed', async () => { + const { default: styles } = await import('../src/App.module.css') + + // HASH is static, based on the filepath to root + expect(styles.module).toBe('_module_6dc87e') + expect(styles.someRandomValue).toBe('_someRandomValue_6dc87e') + const element = document.createElement('div') + element.className = '_module_6dc87e' + const computed = window.getComputedStyle(element) + expect(computed.display).toBe('block') + expect(computed.width).toBe('') + expect(element).toMatchInlineSnapshot(` +
+ `) + }) +}) diff --git a/test/css/test/non-scope-module.spec.ts b/test/css/test/non-scope-module.spec.ts new file mode 100644 index 000000000000..20aae1621771 --- /dev/null +++ b/test/css/test/non-scope-module.spec.ts @@ -0,0 +1,18 @@ +import { expect, test } from 'vitest' + +test('module is processed', async () => { + const { default: styles } = await import('../src/App.module.css') + + expect(styles.module).toBe('module') + expect(styles.someRandomValue).toBeUndefined() + const element = document.createElement('div') + element.className = `${styles.main} ${styles.module}` + const computed = window.getComputedStyle(element) + expect(computed.display).toBe('flex') + expect(computed.width).toBe('100px') + expect(element).toMatchInlineSnapshot(` +
+ `) +}) diff --git a/test/css/test/process-css.spec.ts b/test/css/test/process-css.spec.ts new file mode 100644 index 000000000000..9e3130c78a6e --- /dev/null +++ b/test/css/test/process-css.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'vitest' +import { useRemoveStyles } from './utils' + +describe('process only css, not module css', () => { + useRemoveStyles() + + test('apply css', async () => { + await import('../src/App.css') + + const element = document.createElement('div') + element.className = 'main' + const computed = window.getComputedStyle(element) + expect(computed.display).toBe('flex') + expect(element).toMatchInlineSnapshot(` +
+ `) + }) + + test('module is not processed', async () => { + const { default: styles } = await import('../src/App.module.css') + + expect(styles.module).toBe('_module_6dc87e') + expect(styles.someRandomValue).toBe('_someRandomValue_6dc87e') + const element = document.createElement('div') + element.className = '_module_6dc87e' + const computed = window.getComputedStyle(element) + expect(computed.display).toBe('block') + expect(computed.width).toBe('') + expect(element).toMatchInlineSnapshot(` +
+ `) + }) +}) diff --git a/test/css/test/process-module.spec.ts b/test/css/test/process-module.spec.ts new file mode 100644 index 000000000000..5510a95f536e --- /dev/null +++ b/test/css/test/process-module.spec.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from 'vitest' +import { useRemoveStyles } from './utils' + +describe('processing module css', () => { + useRemoveStyles() + + test('doesn\'t apply css', async () => { + await import('../src/App.css') + + const element = document.createElement('div') + element.className = 'main' + const computed = window.getComputedStyle(element) + expect(computed.display, 'css is not processed').toBe('block') + }) + + test('module is processed', async () => { + const { default: styles } = await import('../src/App.module.css') + + expect(styles.module).toBe('_module_6dc87e') + expect(styles.someRandomValue).toBeUndefined() + const element = document.createElement('div') + element.className = '_main_6dc87e _module_6dc87e' + const computed = window.getComputedStyle(element) + expect(computed.display, 'css is processed').toBe('flex') + expect(computed.width).toBe('100px') + expect(element).toMatchInlineSnapshot(` +
+ `) + }) +}) diff --git a/test/css/test/scope-module.spec.ts b/test/css/test/scope-module.spec.ts new file mode 100644 index 000000000000..1d3da3ab22df --- /dev/null +++ b/test/css/test/scope-module.spec.ts @@ -0,0 +1,28 @@ +import { expect, test } from 'vitest' + +test('module is processed and scoped', async () => { + const { default: styles } = await import('../src/App.module.css') + + expect(styles.module).toMatch(/_module_\w+_\w/) + expect(styles.someRandomValue).toBeUndefined() + const element = document.createElement('div') + element.className = 'module main' + const computedStatic = window.getComputedStyle(element) + expect(computedStatic.display).toBe('block') + expect(computedStatic.width).toBe('') + expect(element).toMatchInlineSnapshot(` +
+ `) + + element.className = `${styles.module} ${styles.main}` + const computedModules = window.getComputedStyle(element) + expect(computedModules.display).toBe('flex') + expect(computedModules.width).toBe('100px') + expect(element).toMatchInlineSnapshot(` +
+ `) +}) diff --git a/test/css/test/utils.ts b/test/css/test/utils.ts new file mode 100644 index 000000000000..ccb4219f5318 --- /dev/null +++ b/test/css/test/utils.ts @@ -0,0 +1,13 @@ +import { afterEach, beforeEach } from 'vitest' + +const removeStyles = () => { + document.head.querySelectorAll('style').forEach(style => style.remove()) +} +export function useRemoveStyles() { + beforeEach(() => removeStyles()) + afterEach(() => removeStyles()) + + return { + removeStyles, + } +} diff --git a/test/css/testing.mjs b/test/css/testing.mjs new file mode 100644 index 000000000000..cce302cac228 --- /dev/null +++ b/test/css/testing.mjs @@ -0,0 +1,27 @@ +import { startVitest } from 'vitest/node' + +const configs = [ + ['test/default-css', {}], + ['test/process-css', { include: [/App\.css/] }], + ['test/process-module', { include: [/App\.module\.css/] }], + ['test/scope-module', { include: [/App\.module\.css/], modules: { classNameStrategy: 'scoped' } }], + ['test/non-scope-module', { include: [/App\.module\.css/], modules: { classNameStrategy: 'non-scoped' } }], +] + +async function runTests() { + for (const [name, config] of configs) { + const success = await startVitest([name], { + run: true, + css: config, + update: false, + teardownTimeout: 1000_000_000, + }) + + if (!success) + process.exit(1) + } + + process.exit(0) +} + +runTests() diff --git a/test/css/vitest.config.ts b/test/css/vitest.config.ts new file mode 100644 index 000000000000..68e3449dc6ee --- /dev/null +++ b/test/css/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'jsdom', + }, +})