Repository: microsoft/griffel
Pull request #148 (2022-06-23 08:56:21)

feat(devtools): fix duplicated key + make input stick to top + fix [data-key-nav] selector
- fix key: previously slot name was used as key for `SlotCSSRules` component but slot name can be the same and causing issue
- make input bar stick to top
   <img width="446" alt="image" src="https://user-images.githubusercontent.com/28751745/175259766-a772c8b8-3408-49eb-ba56-1b7b7b08e72e.png">
- fix parsing of css rule selector when the rules are like `[data-key-nav] .fxxxx{color:red}`. 
   Before: 
    <img width="423" alt="Screenshot 2022-06-23 at 13 38 05" src="https://user-images.githubusercontent.com/28751745/175290371-c618e569-869c-4fe4-8505-5a7b8393cce6.png">
   After: 
    <img width="430" alt="Screenshot 2022-06-23 at 13 37 46" src="https://user-images.githubusercontent.com/28751745/175290384-2800fd5b-fb0a-48b5-beee-71f609ee1b49.png">


github-actions[bot]: ## 📊 Bundle size report

<sub>🤖 This report was generated against <a href='https://github.com/microsoft/griffel/commit/a7315c6fa2cb2ce78c30e0e449e97b1eaf3a1800'>a7315c6fa2cb2ce78c30e0e449e97b1eaf3a1800</a></sub>

<!-- Sticky Pull Request Comment -->

Patch:
diff --git a/package.json b/package.json
index 07dc0e3..d0f03df 100644
--- a/package.json
+++ b/package.json
@@ -57,6 +57,7 @@
     "@types/jest": "27.0.2",
     "@types/js-beautify": "^1.13.3",
     "@types/loader-utils": "2.0.3",
+    "@types/mini-css-extract-plugin": "2.5.1",
     "@types/node": "14.14.33",
     "@types/react": "17.0.30",
     "@types/react-dom": "17.0.9",
@@ -87,6 +88,7 @@
     "jest": "27.2.3",
     "js-beautify": "^1.14.0",
     "log-symbols": "5.1.0",
+    "mini-css-extract-plugin": "2.6.0",
     "monosize": "0.0.3",
     "monosize-storage-upstash": "0.0.2",
     "nano-staged": "0.5.0",
diff --git a/packages/core/.eslintrc.json b/packages/core/.eslintrc.json
index 707d8fb..82b5d5e 100644
--- a/packages/core/.eslintrc.json
+++ b/packages/core/.eslintrc.json
@@ -1,6 +1,6 @@
 {
   "extends": ["plugin:@nrwl/nx/react", "../../.eslintrc.json"],
-  "ignorePatterns": ["!**/*"],
+  "ignorePatterns": ["!**/*", "__fixtures__/**/*/output.ts"],
   "overrides": [
     {
       "files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
diff --git a/packages/devtools/package.json b/packages/devtools/package.json
index 6708bdd..e5e065c 100644
--- a/packages/devtools/package.json
+++ b/packages/devtools/package.json
@@ -10,6 +10,7 @@
   },
   "sideEffects": false,
   "dependencies": {
+    "@emotion/hash": "^0.8.0",
     "@griffel/react": "^1.2.0",
     "js-beautify": "^1.14.0",
     "prism-react-renderer": "1.2.1",
diff --git a/packages/devtools/src/FlattenView.tsx b/packages/devtools/src/FlattenView.tsx
index c60cfbd..278abff 100644
--- a/packages/devtools/src/FlattenView.tsx
+++ b/packages/devtools/src/FlattenView.tsx
@@ -1,4 +1,5 @@
 import * as React from 'react';
+import hash from '@emotion/hash';
 import { makeStaticStyles, makeStyles, shorthands } from '@griffel/react';
 
 import { SlotCSSRules } from './SlotCSSRules';
@@ -19,6 +20,11 @@ const useStyles = makeStyles({
     columnGap: '4px',
     fontFamily: 'system-ui',
     fontSize: '12px',
+
+    position: 'sticky',
+    top: 0,
+    zIndex: 1,
+    backgroundColor: tokens.background,
   },
   input: {
     color: 'inherit',
@@ -92,9 +98,10 @@ export const FlattenView: React.FC<FlattenViewProps> = props => {
       </div>
       <div className={classes.rules}>
         <ViewContext.Provider value={contextValue}>
-          {filteredSlots.map(({ slot, rules }) => (
-            <SlotCSSRules key={slot} slot={slot} atomicRules={rules} />
-          ))}
+          {filteredSlots.map(({ slot, rules }) => {
+            const key = slot + rules.map(rule => rule.cssRule).join('');
+            return <SlotCSSRules key={key} slot={slot} atomicRules={rules} />;
+          })}
         </ViewContext.Provider>
       </div>
     </div>
diff --git a/packages/devtools/src/getMonolithicCSSRules.ts b/packages/devtools/src/getMonolithicCSSRules.ts
index ca4fcc6..be43cbb 100644
--- a/packages/devtools/src/getMonolithicCSSRules.ts
+++ b/packages/devtools/src/getMonolithicCSSRules.ts
@@ -1,16 +1,28 @@
 import * as stylis from 'stylis';
 import type { AtomicRules, MonolithicAtRules, MonolithicRules, RuleDetail } from './types';
 
+const griffelClassNameRegex = /^\.f[0-9a-z]+$/;
+
 function parseRuleElement(element: stylis.Element, overriddenBy?: string) {
   // example of `value`: `.f3xbvq9:hover`
   // `children` contains all CSS rules under the `value` selector
   const { value: classNameSelector, children } = element;
 
   if (Array.isArray(children)) {
-    const selector = stylis.tokenize(classNameSelector).slice(1).join('');
+    let className: string | undefined;
+    const selector = stylis
+      .tokenize(classNameSelector)
+      .filter(token => {
+        if (griffelClassNameRegex.test(token)) {
+          className = token.slice(1);
+          return false;
+        }
+        return true;
+      })
+      .join('');
     const rules: RuleDetail[] = children.map(child => ({
       css: child.value,
-      className: stylis.tokenize(classNameSelector)[0].slice(1),
+      className: className ?? stylis.tokenize(classNameSelector)[0].slice(1),
       overriddenBy,
     }));
     return {
diff --git a/packages/devtools/src/stories/FlattenView.stories.tsx b/packages/devtools/src/stories/FlattenView.stories.tsx
index 7949b18..864e295 100644
--- a/packages/devtools/src/stories/FlattenView.stories.tsx
+++ b/packages/devtools/src/stories/FlattenView.stories.tsx
@@ -79,7 +79,7 @@ const debugResultRoot: DebugResult = {
         f7wpa5l: '@media screen and (max-width: 992px){.f7wpa5l:hover{color:red;}}',
         fy4xd57: '@media screen and (max-width: 992px){.fy4xd57:hover{background-color:blue;}}',
       },
-      slot: 'primaryRed',
+      slot: 'primary',
     },
   ],
   debugClassNames: [
@@ -105,6 +105,8 @@ export const Default: Story<{ theme: 'dark' | 'light' }> = ({ theme }) => {
         ...tokens,
         border: '3px solid gray',
         width: 400,
+        height: 600,
+        overflowY: 'auto',
       }}
     >
       <FlattenView debugResultRoot={debugResultRoot} />
diff --git a/packages/webpack-extraction-plugin/__fixtures__/webpack/basic-rules/fs.json b/packages/webpack-extraction-plugin/__fixtures__/webpack/basic-rules/fs.json
new file mode 100644
index 0000000..966fca3
--- /dev/null
+++ b/packages/webpack-extraction-plugin/__fixtures__/webpack/basic-rules/fs.json
@@ -0,0 +1,1 @@
+["bundle.js", "griffel.css"]
diff --git a/packages/webpack-extraction-plugin/__fixtures__/webpack/basic-rules/output.css b/packages/webpack-extraction-plugin/__fixtures__/webpack/basic-rules/output.css
new file mode 100644
index 0000000..807e034
--- /dev/null
+++ b/packages/webpack-extraction-plugin/__fixtures__/webpack/basic-rules/output.css
@@ -0,0 +1,12 @@
+.fe3e8s9 {
+  color: red;
+}
+.fycuoez {
+  padding-left: 4px;
+}
+.f8wuabp {
+  padding-right: 4px;
+}
+.fcnqdeg {
+  background-color: green;
+}
diff --git a/packages/webpack-extraction-plugin/src/GriffelCSSExtractionPlugin.ts b/packages/webpack-extraction-plugin/src/GriffelCSSExtractionPlugin.ts
new file mode 100644
index 0000000..2c83383
--- /dev/null
+++ b/packages/webpack-extraction-plugin/src/GriffelCSSExtractionPlugin.ts
@@ -0,0 +1,60 @@
+import { Compilation } from 'webpack';
+import type { Compiler, sources } from 'webpack';
+
+/**
+ * Forces all files with `griffel.css` be concatenated into a single asset.
+ */
+function forceCSSIntoOneStyleSheet(compiler: Compiler) {
+  compiler.options.optimization ??= {};
+  compiler.options.optimization.splitChunks ??= {};
+  compiler.options.optimization.splitChunks.cacheGroups ??= {};
+
+  compiler.options.optimization.splitChunks.cacheGroups['griffel'] = {
+    name: `griffel`,
+    type: 'css/mini-extract',
+    chunks: 'all',
+    test: /griffel.css/,
+    enforce: true,
+  };
+}
+
+function getAssetSourceContents(assetSource: sources.Source): string {
+  const source = assetSource.source();
+
+  if (typeof source === 'string') {
+    return source;
+  }
+
+  return source.toString();
+}
+
+export class GriffelCSSExtractionPlugin {
+  static loader = require.resolve('./webpackLoader');
+
+  apply(compiler: Compiler): void {
+    forceCSSIntoOneStyleSheet(compiler);
+
+    compiler.hooks.compilation.tap('GriffelExtractPlugin', compilation => {
+      compilation.hooks.processAssets.tap(
+        {
+          name: 'GriffelExtractPlugin',
+          stage: Compilation.PROCESS_ASSETS_STAGE_PRE_PROCESS,
+        },
+        assets => {
+          const griffelAsset = assets['griffel.css'];
+
+          if (!griffelAsset) {
+            return;
+          }
+
+          // TODO: implement sorting for buckets and media queries
+
+          compilation.updateAsset(
+            'griffel.css',
+            new compiler.webpack.sources.RawSource(getAssetSourceContents(griffelAsset)),
+          );
+        },
+      );
+    });
+  }
+}
diff --git a/packages/webpack-extraction-plugin/src/index.ts b/packages/webpack-extraction-plugin/src/index.ts
index cb0ff5c..6e1e6f2 100644
--- a/packages/webpack-extraction-plugin/src/index.ts
+++ b/packages/webpack-extraction-plugin/src/index.ts
@@ -1,1 +1,1 @@
-export {};
+export { GriffelCSSExtractionPlugin } from './GriffelCSSExtractionPlugin';
diff --git a/yarn.lock b/yarn.lock
index 0b14664..2840b10 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6262,6 +6262,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@types/mini-css-extract-plugin@npm:2.5.1":
+  version: 2.5.1
+  resolution: "@types/mini-css-extract-plugin@npm:2.5.1"
+  dependencies:
+    mini-css-extract-plugin: "*"
+  checksum: 047a8600d49e765e793c009a03af6cb5dcf5c4aa9ee37826d5a37a46bc7d986fd236b7c5eb03089c23462b5c20ee8b97fdb9e126a82312090f33f1e2a22a505f
+  languageName: node
+  linkType: hard
+
 "@types/minimatch@npm:*, @types/minimatch@npm:^3.0.3":
   version: 3.0.5
   resolution: "@types/minimatch@npm:3.0.5"
@@ -13676,6 +13685,7 @@ __metadata:
     "@types/jest": 27.0.2
     "@types/js-beautify": ^1.13.3
     "@types/loader-utils": 2.0.3
+    "@types/mini-css-extract-plugin": 2.5.1
     "@types/node": 14.14.33
     "@types/react": 17.0.30
     "@types/react-dom": 17.0.9
@@ -13711,6 +13721,7 @@ __metadata:
     js-beautify: ^1.14.0
     loader-utils: ^2.0.0
     log-symbols: 5.1.0
+    mini-css-extract-plugin: 2.6.0
     monosize: 0.0.3
     monosize-storage-upstash: 0.0.2
     nano-staged: 0.5.0
@@ -17388,6 +17399,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"mini-css-extract-plugin@npm:*, mini-css-extract-plugin@npm:2.6.0":
+  version: 2.6.0
+  resolution: "mini-css-extract-plugin@npm:2.6.0"
+  dependencies:
+    schema-utils: ^4.0.0
+  peerDependencies:
+    webpack: ^5.0.0
+  checksum: ea73bd66558de7a37db094fe68fa130e7a725ab15f880ff8467a75d9c4c2f1576d20720088ea22af9922a94b8600bbfef0c6acaf10d12a32a9bd20a90ba3c35f
+  languageName: node
+  linkType: hard
+
 "mini-css-extract-plugin@npm:^1.6.0":
   version: 1.6.2
   resolution: "mini-css-extract-plugin@npm:1.6.2"


Test patch:
diff --git a/packages/devtools/src/getMonolithicCSSRules.test.ts b/packages/devtools/src/getMonolithicCSSRules.test.ts
index be80a8c..4fd5f80 100644
--- a/packages/devtools/src/getMonolithicCSSRules.test.ts
+++ b/packages/devtools/src/getMonolithicCSSRules.test.ts
@@ -2,46 +2,58 @@ import { getMonolithicCSSRules } from './getMonolithicCSSRules';
 
 describe('getMonolithicCSSRules', () => {
   it('group atomic css with class names selector', () => {
-    const rules = ['.abcdef0{background-color:red;}', '.abcdef1{color:red;}'].map(cssRule => ({ cssRule }));
+    const rules = ['.fabcde0{background-color:red;}', '.fabcde1{color:red;}'].map(cssRule => ({ cssRule }));
     expect(getMonolithicCSSRules(rules)).toEqual({
       '': [
         {
-          className: 'abcdef0',
+          className: 'fabcde0',
           css: 'background-color:red;',
         },
         {
-          className: 'abcdef1',
+          className: 'fabcde1',
           css: 'color:red;',
         },
       ],
     });
   });
 
+  it('group atomic css with child class names selector', () => {
+    const rules = ['.f1dx00p .class1{padding-right: 10px;}'].map(cssRule => ({ cssRule }));
+    expect(getMonolithicCSSRules(rules)).toEqual({
+      ' .class1': [
+        {
+          className: 'f1dx00p',
+          css: 'padding-right:10px;',
+        },
+      ],
+    });
+  });
+
   it('separate atomic css with class names selector and pseudo selector', () => {
     const rules = [
-      '.abcdef0{background-color:red;}',
-      '.abcdef1{display:block;}',
-      '.abcdef2:hover{display:none;}',
-      '.abcdef3:hover{color:red;}',
+      '.fabcde0{background-color:red;}',
+      '.fabcde1{display:block;}',
+      '.fabcde2:hover{display:none;}',
+      '.fabcde3:hover{color:red;}',
     ].map(cssRule => ({ cssRule }));
     expect(getMonolithicCSSRules(rules)).toEqual({
       '': [
         {
-          className: 'abcdef0',
+          className: 'fabcde0',
           css: 'background-color:red;',
         },
         {
-          className: 'abcdef1',
+          className: 'fabcde1',
           css: 'display:block;',
         },
       ],
       ':hover': [
         {
-          className: 'abcdef2',
+          className: 'fabcde2',
           css: 'display:none;',
         },
         {
-          className: 'abcdef3',
+          className: 'fabcde3',
           css: 'color:red;',
         },
       ],
@@ -50,30 +62,30 @@ describe('getMonolithicCSSRules', () => {
 
   it('group atomic css with media selector', () => {
     const rules = [
-      '.abcdef0{background-color:red;}',
-      '.abcdef1{display:block;}',
-      '.abcdef2:hover{display:none;}',
-      '.abcdef3:hover{color:red;}',
+      '.fabcde0{background-color:red;}',
+      '.fabcde1{display:block;}',
+      '.fabcde2:hover{display:none;}',
+      '.fabcde3:hover{color:red;}',
     ].map(cssRule => ({ cssRule: `@media screen and (max-width: 992px){${cssRule}}` }));
     expect(getMonolithicCSSRules(rules)).toEqual({
       '@media screen and (max-width: 992px)': {
         '': [
           {
-            className: 'abcdef0',
+            className: 'fabcde0',
             css: 'background-color:red;',
           },
           {
-            className: 'abcdef1',
+            className: 'fabcde1',
             css: 'display:block;',
           },
         ],
         ':hover': [
           {
-            className: 'abcdef2',
+            className: 'fabcde2',
             css: 'display:none;',
           },
           {
-            className: 'abcdef3',
+            className: 'fabcde3',
             css: 'color:red;',
           },
         ],
@@ -83,21 +95,21 @@ describe('getMonolithicCSSRules', () => {
 
   it('separate atomic css with different media selector', () => {
     const rules = [
-      '.abcdef0{background-color:red;}',
-      '@media screen and (max-width: 992px){.abcdef0{background-color:blue;}}',
-      '@media screen and (max-width: 1024px){.abcdef0{background-color:green;}}',
+      '.fabcde0{background-color:red;}',
+      '@media screen and (max-width: 992px){.fabcde0{background-color:blue;}}',
+      '@media screen and (max-width: 1024px){.fabcde0{background-color:green;}}',
     ].map(cssRule => ({ cssRule }));
     expect(getMonolithicCSSRules(rules)).toEqual({
       '': [
         {
-          className: 'abcdef0',
+          className: 'fabcde0',
           css: 'background-color:red;',
         },
       ],
       '@media screen and (max-width: 1024px)': {
         '': [
           {
-            className: 'abcdef0',
+            className: 'fabcde0',
             css: 'background-color:green;',
           },
         ],
@@ -105,7 +117,7 @@ describe('getMonolithicCSSRules', () => {
       '@media screen and (max-width: 992px)': {
         '': [
           {
-            className: 'abcdef0',
+            className: 'fabcde0',
             css: 'background-color:blue;',
           },
         ],
@@ -115,21 +127,21 @@ describe('getMonolithicCSSRules', () => {
 
   it('pass on overriddenBy information from atomic css to monolithic css', () => {
     const rules = [
-      { cssRule: '.abcdef0{background-color:red;}', overriddenBy: 'bcdefg0' },
-      { cssRule: '.abcdef0>div{color:green;}', overriddenBy: 'bcdefg1' },
-      { cssRule: '@media screen and (max-width: 992px){.abcdef0{background-color:blue;}}', overriddenBy: 'bcdefg2' },
+      { cssRule: '.fabcde0{background-color:red;}', overriddenBy: 'bcdefg0' },
+      { cssRule: '.fabcde0>div{color:green;}', overriddenBy: 'bcdefg1' },
+      { cssRule: '@media screen and (max-width: 992px){.fabcde0{background-color:blue;}}', overriddenBy: 'bcdefg2' },
     ];
     expect(getMonolithicCSSRules(rules)).toEqual({
       '': [
         {
-          className: 'abcdef0',
+          className: 'fabcde0',
           css: 'background-color:red;',
           overriddenBy: 'bcdefg0',
         },
       ],
       '>div': [
         {
-          className: 'abcdef0',
+          className: 'fabcde0',
           css: 'color:green;',
           overriddenBy: 'bcdefg1',
         },
@@ -137,7 +149,7 @@ describe('getMonolithicCSSRules', () => {
       '@media screen and (max-width: 992px)': {
         '': [
           {
-            className: 'abcdef0',
+            className: 'fabcde0',
             css: 'background-color:blue;',
             overriddenBy: 'bcdefg2',
           },
@@ -145,4 +157,35 @@ describe('getMonolithicCSSRules', () => {
       },
     });
   });
+
+  it('group atomic css when class name selector has selector in front', () => {
+    const rules = [
+      '[data-keyboard-nav] .fabcde0:focus{background-color:red;}',
+      '[data-keyboard-nav] .fabcde1{color:red;}',
+      '[data-keyboard-nav] .fabcde1 .class1{padding-top:10px;}',
+    ].map(cssRule => ({ cssRule }));
+    expect(getMonolithicCSSRules(rules)).toEqual({
+      '[data-keyboard-nav] ': [
+        {
+          className: 'fabcde1',
+          css: 'color:red;',
+          overriddenBy: undefined,
+        },
+      ],
+      '[data-keyboard-nav]  .class1': [
+        {
+          className: 'fabcde1',
+          css: 'padding-top:10px;',
+          overriddenBy: undefined,
+        },
+      ],
+      '[data-keyboard-nav] :focus': [
+        {
+          className: 'fabcde0',
+          css: 'background-color:red;',
+          overriddenBy: undefined,
+        },
+      ],
+    });
+  });
 });
diff --git a/packages/webpack-extraction-plugin/src/GriffelCSSExtractionPlugin.test.ts b/packages/webpack-extraction-plugin/src/GriffelCSSExtractionPlugin.test.ts
new file mode 100644
index 0000000..af2d145
--- /dev/null
+++ b/packages/webpack-extraction-plugin/src/GriffelCSSExtractionPlugin.test.ts
@@ -0,0 +1,199 @@
+import * as fs from 'fs';
+import { createFsFromVolume, Volume } from 'memfs';
+import * as MiniCssExtractPlugin from 'mini-css-extract-plugin';
+import * as path from 'path';
+import * as prettier from 'prettier';
+import * as webpack from 'webpack';
+import { merge } from 'webpack-merge';
+
+import { GriffelCSSExtractionPlugin } from './GriffelCSSExtractionPlugin';
+
+type CompileOptions = {
+  webpackConfig?: webpack.Configuration;
+};
+
+const prettierConfig = JSON.parse(
+  fs.readFileSync(path.resolve(__dirname, '../../../.prettierrc'), { encoding: 'utf-8' }),
+);
+
+async function compileSourceWithWebpack(
+  entryPath: string,
+  options: CompileOptions,
+): Promise<{
+  filesList: string[];
+  cssOutput: string;
+}> {
+  const defaultConfig: webpack.Configuration = {
+    context: __dirname,
+    entry: entryPath,
+
+    mode: 'development',
+
+    output: {
+      path: path.resolve(__dirname),
+      filename: 'bundle.js',
+      pathinfo: false,
+    },
+
+    module: {
+      rules: [
+        {
+          test: /\.(ts|tsx)$/,
+          include: path.dirname(entryPath),
+          use: {
+            loader: GriffelCSSExtractionPlugin.loader,
+          },
+        },
+        {
+          test: /\.css$/,
+          use: [MiniCssExtractPlugin.loader, 'css-loader'],
+        },
+      ],
+    },
+    plugins: [new GriffelCSSExtractionPlugin(), new MiniCssExtractPlugin()],
+
+    resolve: {
+      alias: {
+        '@griffel/core': path.resolve(__dirname, '..', '..', '..', 'dist', 'packages', 'react', 'index.esm.js'),
+        '@griffel/react': path.resolve(__dirname, '..', '..', '..', 'dist', 'packages', 'react', 'index.esm.js'),
+      },
+      extensions: ['.js', '.ts'],
+    },
+  };
+
+  const webpackConfig = merge(defaultConfig, options.webpackConfig || {});
+  const compiler = webpack(webpackConfig);
+
+  const virtualFsVolume = createFsFromVolume(new Volume());
+
+  compiler.outputFileSystem = virtualFsVolume;
+  compiler.outputFileSystem.join = path.join.bind(path);
+
+  return new Promise((resolve, reject) => {
+    compiler.run((err, stats) => {
+      if (err) {
+        reject(err);
+        return;
+      }
+
+      if (typeof stats === 'undefined') {
+        reject(new Error('"stats" from Webpack are not available, unknown error...'));
+        return;
+      }
+
+      if (stats.hasErrors()) {
+        reject(stats.toJson().errors![0]);
+        return;
+      }
+
+      const cssFilePath = path.resolve(__dirname, 'griffel.css');
+
+      if (!virtualFsVolume.existsSync(cssFilePath)) {
+        reject(new Error('"griffel.css" does not exist in filesystem'));
+      }
+
+      const cssOutput = virtualFsVolume.readFileSync(cssFilePath, {
+        encoding: 'utf-8',
+      }) as string;
+      const filesList = virtualFsVolume.readdirSync(__dirname) as string[];
+
+      resolve({
+        cssOutput,
+        filesList,
+      });
+    });
+  });
+}
+
+function fixLineEndings(value: string) {
+  return String(value).replace(/\r?\n/g, '\n').trim();
+}
+
+/**
+ * Test utility similar to "babel-plugin-tester".
+ *
+ * See https://webpack.js.org/contribute/writing-a-loader/#testing.
+ */
+function testFixture(fixtureName: string, options: CompileOptions = {}) {
+  it(`"${fixtureName}" fixture`, async () => {
+    const fixturePath = path.resolve(__dirname, '..', '__fixtures__', 'webpack', fixtureName);
+
+    const tsCodePath = path.resolve(fixturePath, 'code.ts');
+    const tsxCodePath = path.resolve(fixturePath, 'code.tsx');
+
+    const inputPath = [fs.existsSync(tsCodePath) && tsCodePath, fs.existsSync(tsxCodePath) && tsxCodePath].find(
+      Boolean,
+    );
+
+    const errorPath = path.resolve(fixturePath, 'error.ts');
+    const expectedError = fs.existsSync(errorPath) && require(errorPath);
+
+    const fsSnapshotPath = path.resolve(fixturePath, 'fs.json');
+    const cssOutputPath = path.resolve(fixturePath, 'output.css');
+
+    if (!inputPath) {
+      throw new Error(`Failed to find "code.{js,ts,tsx}" in "${fixturePath}"`);
+    }
+
+    if (!fs.existsSync(fsSnapshotPath)) {
+      throw new Error(`Failed to find "fs.json" in "${fixturePath}"`);
+    }
+
+    if (!fs.existsSync(cssOutputPath)) {
+      throw new Error(`Failed to find "output.css" in "${fixturePath}"`);
+    }
+
+    if (!cssOutputPath && !expectedError) {
+      throw new Error(`Failed to find "output.css" or "error.ts" in "${fixturePath}"`);
+    }
+
+    if (expectedError) {
+      if (!expectedError.default) {
+        throw new Error(
+          `Please check that "error.ts" contains a default export with an error or regex in "${fixturePath}"`,
+        );
+      }
+    }
+
+    let resultCSS = '';
+    let resultFsSnapshot: string[] = [];
+
+    let resultError: Error | webpack.StatsError = new Error();
+
+    try {
+      const result = await compileSourceWithWebpack(inputPath, options);
+
+      resultCSS = fixLineEndings(
+        prettier.format(result.cssOutput, {
+          ...prettierConfig,
+          parser: 'css',
+        }),
+      );
+      resultFsSnapshot = result.filesList;
+    } catch (err) {
+      if (expectedError) {
+        resultError = err as webpack.StatsError;
+      } else {
+        throw err;
+      }
+    }
+
+    if (cssOutputPath) {
+      const cssOutput = fixLineEndings(await fs.promises.readFile(cssOutputPath, { encoding: 'utf8' }));
+      const fsSnapshot = JSON.parse(await fs.promises.readFile(fsSnapshotPath, { encoding: 'utf8' }));
+
+      expect(resultFsSnapshot).toMatchObject(fsSnapshot);
+      expect(resultCSS).toBe(cssOutput);
+      return;
+    }
+
+    if (expectedError) {
+      expect(resultError.message).toMatch(expectedError.default);
+    }
+  }, 15000);
+}
+
+describe('webpackLoader', () => {
+  // Basic assertions
+  testFixture('basic-rules');
+});