Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

build(QTM-597): Build css and save css styles in jest snapshots #552

Merged
merged 4 commits into from
Mar 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .babelrc
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"plugins": [
"transform-class-properties",
"transform-modern-regexp",
"babel-plugin-styled-components"
"babel-plugin-styled-components",
"./scripts/babel-css-modules.js"
],
"env": {
"production": {
Expand Down
5 changes: 5 additions & 0 deletions components/index.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/*
Here you should have the import of the stylizations used in each component.
Example:
@import "./Button/Button.module.css";
*/
3 changes: 2 additions & 1 deletion jest-config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'jest-styled-components';
import 'jest-styled-components'; // Must be maintained while migrating components to CSS modules
import '@testing-library/jest-dom';
import 'snapshot-diff/extend-expect';
import './scripts/JestCSSModulesSerializer';

import matchMediaMock from './components/shared/helpers';

Expand Down
15 changes: 13 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
"test:regression:update": "yarn loki update --port 9006",
"test:regression:update:storie": "yarn loki update --port 9006 --storiesFilter $STORIE",
"test:regression:storybook": "fuser -k 9006/tcp; storybook dev -c .storybook/regression-test --port 9006",
"build": "cross-env NODE_ENV=production babel ./components --out-dir dist --copy-files",
"build": "cross-env NODE_ENV=production yarn build:css && yarn build:components",
"build:components": "cross-env NODE_ENV=production babel ./components --out-dir dist --copy-files",
"build:css": "postcss components/index.css -o .temp/index.css",
"build-storybook": "storybook build",
"build:regression": "yarn test:regression:storybook & ./scripts/run-regression.sh",
"clean:files:build": "find ./dist -type f \\( -name '*.unit.test.js' -or -name '*.unit.test.jsx' \\) -delete",
Expand Down Expand Up @@ -52,6 +54,7 @@
"author": "Catho",
"license": "MIT",
"devDependencies": {
"@adobe/css-tools": "^4.3.3",
"@babel/cli": "^7.2.3",
"@babel/core": "^7.22.9",
"@babel/eslint-parser": "^7.22.9",
Expand All @@ -73,13 +76,15 @@
"@typescript-eslint/eslint-plugin": "^6.1.0",
"@typescript-eslint/parser": "^6.1.0",
"all-contributors-cli": "^6.8.0",
"autoprefixer": "^10.4.18",
"babel-core": "^7.0.0-bridge.0",
"babel-loader": "^8.0.0-beta.6",
"babel-plugin-styled-components": "^1.10.0",
"babel-plugin-transform-class-properties": "^6.24.1",
"babel-plugin-transform-modern-regexp": "^0.0.6",
"commitizen": "^4.2.4",
"cross-env": "^5.2.0",
"cssnano": "^6.1.0",
"cz-conventional-changelog": "2.1.0",
"eslint": "^8.45.0",
"eslint-config-airbnb": "^19.0.4",
Expand All @@ -95,7 +100,13 @@
"jest-styled-components": "^7.0.5",
"lint-staged": "^11.0.0",
"loki": "^0.31.2",
"identity-obj-proxy": "^3.0.0",
"prettier": "^3.0.0",
"postcss": "^8.4.35",
"postcss-cli": "^10.1.0",
"postcss-import": "15.1.0",
"postcss-modules": "^6.0.0",
"postcss-preset-env": "^9.5.0",
"react": "^18.2.0",
"react-copy-to-clipboard": "^5.1.0",
"react-dom": "^18.2.0",
Expand Down Expand Up @@ -133,7 +144,7 @@
"coverageDirectory": "coverage",
"moduleNameMapper": {
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/fileMock.js",
"\\.(css|less)$": "<rootDir>/styleMock.js"
"\\.(css|less|sass)$": "identity-obj-proxy"
},
"setupFilesAfterEnv": [
"<rootDir>/jest-config.js"
Expand Down
40 changes: 40 additions & 0 deletions postcss.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/* eslint-disable global-require */
const fs = require('fs');
const path = require('path');

const ENV = process.env.NODE_ENV;

module.exports = {
plugins: [
require('postcss-import')({
plugins: [
require('autoprefixer'),
require('cssnano')({
preset: 'default',
}),
require('postcss-modules')({
getJSON(cssFilePath, json) {
if (ENV === 'production' || ENV === 'jest') {
const tempFolder = path.resolve('.temp');
if (!fs.existsSync(tempFolder)) {
fs.mkdirSync(tempFolder);
}

const stylesFileNameWithouExtension = path.basename(
cssFilePath,
'.css',
);
const jsonFilePath = path.resolve(
`./.temp/${stylesFileNameWithouExtension}.json`,
);
const jsonFileContent = JSON.stringify(json);

fs.writeFileSync(jsonFilePath, jsonFileContent);
}
},
generateScopedName: `[name]__[local]___[hash:base64:5]`,
}),
],
}),
],
};
3 changes: 3 additions & 0 deletions scripts/JestCSSModulesSerializer/buildCss.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const { execSync } = require('child_process');

execSync('yarn cross-env NODE_ENV=jest yarn build:css');
4 changes: 4 additions & 0 deletions scripts/JestCSSModulesSerializer/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import './buildCss';
import styleSheetSerializer from './styleSheetSerializer';

expect.addSnapshotSerializer(styleSheetSerializer);
138 changes: 138 additions & 0 deletions scripts/JestCSSModulesSerializer/styleSheetSerializer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
const css = require('@adobe/css-tools');
const path = require('path');
const fs = require('fs');

const cache = new WeakSet();

const getCSSFromTempFolder = () => {
const cssFile = path.resolve('./.temp/index.css');
if (!fs.existsSync(cssFile)) {
throw new Error(
`\n\n CSS file not found in temporary folder: ${cssFile} \n\n`,
);
}
const cssFileContent = fs.readFileSync(cssFile, {
encoding: 'utf8',
});
return css.parse(cssFileContent);
};

const getNodes = (node, nodes = []) => {
if (typeof node === 'object') {
nodes.push(node);
}

if (node.children) {
Array.from(node.children).forEach((child) => getNodes(child, nodes));
}

return nodes;
};

const getClassNamesFromDOM = (node) => Array.from(node.classList);

const getClassNamesFromProps = (node) => {
const classNameProp =
node.props && (node.props.class || node.props.className);

if (classNameProp) {
return classNameProp.trim().split(/\s+/);
}

return [];
};

const getClassNames = (nodes) =>
nodes.reduce((classNames, node) => {
let newClassNames = null;

if (global.Element && node instanceof global.Element) {
newClassNames = getClassNamesFromDOM(node);
} else {
newClassNames = getClassNamesFromProps(node);
}

newClassNames.forEach((className) => classNames.add(className));

return classNames;
}, new Set());

const includesClassNames = (classNames, selectors) =>
classNames.some((className) =>
selectors.some((selector) => selector.includes(className)),
);

const includesUnknownClassNames = (classNames, selectors) =>
!selectors
.flatMap((selector) => selector.split(' '))
.every((chunk) =>
classNames.some((className) => chunk.includes(className)),
);

const filterRules = (classNames) => (rule) =>
rule.type === 'rule' &&
!includesUnknownClassNames(classNames, rule.selectors) &&
includesClassNames(classNames, rule.selectors) &&
rule.declarations.length;

const getAtRules = (ast, filter) =>
ast.stylesheet.rules
.filter((rule) => rule.type === 'media' || rule.type === 'supports')
.reduce((acc, atRule) => {
// eslint-disable-next-line no-param-reassign
atRule.rules = atRule.rules.filter(filter);
return acc.concat(atRule);
}, []);

const getStyle = (classNames, config = {}) => {
const ast = getCSSFromTempFolder();
const filter = filterRules(classNames);
const rules = ast.stylesheet.rules.filter(filter);

const atRules = getAtRules(ast, filter);
ast.stylesheet.rules = rules.concat(atRules);

return css.stringify(ast, { indent: config.indent });
};

const serializerOptionDefaults = {
addStyles: true,
};
let serializerOptions = serializerOptionDefaults;

module.exports = {
/**
* @param {{ addStyles?: boolean }} options
*/
setStyleSheetSerializerOptions(options = {}) {
serializerOptions = {
...serializerOptionDefaults,
...options,
};
},

test(val) {
return (
val &&
!cache.has(val) &&
(val.$$typeof === Symbol.for('react.test.json') ||
(global.Element && val instanceof global.Element))
);
},

serialize(val, config, indentation, depth, refs, printer) {
const nodes = getNodes(val);
nodes.forEach(cache.add, cache);

const classNames = [...getClassNames(nodes)];

const style = getStyle(classNames, config);
const code = printer(val, config, indentation, depth, refs);

const result = serializerOptions.addStyles
? `${style}${style ? '\n\n' : ''}${code}`
: code;
nodes.forEach(cache.delete, cache);
return result;
},
};
54 changes: 54 additions & 0 deletions scripts/babel-css-modules.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// babel-plugin-css-to-js.js

const fs = require('fs');
const path = require('path');

module.exports = function CssToJS({ types: t }) {
return {
visitor: {
ImportDeclaration(specifiers) {
const environment = process.env.NODE_ENV;
if (environment === 'production' || environment === 'test') {
const { node } = specifiers;

const module = node.source.value;
if (module.endsWith('.css')) {
const mappedModuleFile = module.replace('.css', '.json');

const jsonMapFilePath = path.resolve('./.temp', mappedModuleFile);

if (!fs.existsSync(jsonMapFilePath)) {
throw new Error(
`\n\n JSON map file not found in temporary folder: ${jsonMapFilePath} \n\n`,
);
}

const jsonMapFileContent = fs.readFileSync(jsonMapFilePath, {
encoding: 'utf8',
});

const mapFile = JSON.parse(jsonMapFileContent);

const importName = node.specifiers[0].local.name;

const cssVariableName = t.identifier(importName);
const cssVariableValue = t.objectExpression(
Object.keys(mapFile).map((originalClassName) =>
t.objectProperty(
t.identifier(originalClassName),
t.stringLiteral(mapFile[originalClassName]),
),
),
);

const stylesVarDeclaration = t.variableDeclaration('const', [
t.variableDeclarator(cssVariableName, cssVariableValue),
]);

specifiers.replaceWith(stylesVarDeclaration);
}
}
},
},
};
};
14 changes: 10 additions & 4 deletions scripts/copy-files.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,15 @@ import fs from 'fs';
import pkg from '../package.json';

const copyFile = (file) => {
const buildPath = path.resolve(__dirname, '../dist/', path.basename(file));
fs.copyFile(file, buildPath, () =>
console.log(`Copied ${file} to ${buildPath}`),
let filePath;

if (file === 'index.css') {
filePath = path.resolve(__dirname, '../.temp/', file);
}

const pathDir = path.resolve(__dirname, '../dist/', path.basename(file));
fs.copyFile(filePath || file, pathDir, () =>
console.log(`Copied ${file} to ${pathDir}`),
);
};

Expand Down Expand Up @@ -36,7 +42,7 @@ const createPackageJson = () => {
};

const run = () => {
['CHANGELOG.md', 'README.md'].map(copyFile);
['CHANGELOG.md', 'README.md', 'index.css'].map(copyFile);
createPackageJson();
};

Expand Down
24 changes: 24 additions & 0 deletions scripts/injectCssInDocument.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
const { readFileSync, readdirSync, statSync } = require('fs');
const path = require('path');

const injectCSS = (componentsPath) => {
const files = readdirSync(componentsPath);

files.forEach((file) => {
const filePath = path.join(componentsPath, file);

if (statSync(filePath).isDirectory()) {
injectCSS(filePath);
} else if (file.endsWith('.css')) {
const cssContent = readFileSync(filePath, 'utf-8');

const styleElement = document.createElement('style');
const cssFilePath = path.resolve(__dirname, componentsPath, filePath);
styleElement.dataset.cssPath = cssFilePath;
styleElement.appendChild(document.createTextNode(cssContent));
document.head.appendChild(styleElement);
}
});
};

injectCSS(path.join(__dirname, '../components'));
Loading
Loading