import {parse, Root, Rule} from 'postcss'; import {compileString} from 'sass'; import {runfiles} from '@bazel/runfiles'; import * as path from 'path'; import {compareNodes} from '../../../../../tools/postcss/compare-nodes'; import {createLocalAngularPackageImporter} from '../../../../../tools/sass/local-sass-importer'; // Note: For Windows compatibility, we need to resolve the directory paths through runfiles // which are guaranteed to reside in the source tree. const testDir = path.join(runfiles.resolvePackageRelative('../_all-theme.scss'), '../tests'); const packagesDir = path.join(runfiles.resolveWorkspaceRelative('src/cdk/_index.scss'), '../..'); const localPackageSassImporter = createLocalAngularPackageImporter(packagesDir); describe('theming api', () => { /** Map of known selectors for density styles and their corresponding AST rule. */ let knownDensitySelectors: Map; // Before all tests, we collect all nodes specific to density styles. We can then // use this check how density styles are generated. i.e. if they are properly scoped to a // given selector. beforeAll(() => { knownDensitySelectors = new Map(); parse(transpile(`@include mat.all-component-densities(0);`)).each(node => { if (node.type === 'rule') { node.selectors.forEach(s => knownDensitySelectors.set(s, node)); } }); }); it('should warn if color styles are duplicated', () => { spyOn(process.stderr, 'write'); transpile(` $theme: mat.m2-define-light-theme(( color: ( primary: mat.m2-define-palette(mat.$m2-red-palette), accent: mat.m2-define-palette(mat.$m2-red-palette), ) )); @include mat.all-component-themes($theme); .dark-theme { @include mat.all-component-themes($theme); } `); expectWarning(/The same color styles are generated multiple times/); }); it('should not warn if color styles and density are not duplicated', () => { const parsed = parse( transpile(` $theme: mat.m2-define-light-theme(( color: ( primary: mat.m2-define-palette(mat.$m2-red-palette), accent: mat.m2-define-palette(mat.$m2-red-palette), ) )); $theme2: mat.m2-define-light-theme(( color: ( primary: mat.m2-define-palette(mat.$m2-red-palette), accent: mat.m2-define-palette(mat.$m2-blue-palette), ) )); @include mat.all-component-themes($theme); .dark-theme { @include mat.all-component-colors($theme2); } `), ); expect(hasDensityStyles(parsed, null)).toBe('all'); expect(hasDensityStyles(parsed, '.dark-theme')).toBe('none'); expectNoWarning(/The same color styles are generated multiple times/); }); it('should be possible to modify color configuration directly', () => { const result = transpile(` $theme: mat.m2-define-light-theme(( color: ( primary: mat.m2-define-palette(mat.$m2-red-palette), accent: mat.m2-define-palette(mat.$m2-blue-palette), ) )); // Updates the "icon" foreground color to hotpink. $color: map-get($theme, color); $theme: map-merge($color, (foreground: map-merge(map-get($color, foreground), (icon: hotpink)))); @include mat.all-component-themes($theme); `); expect(result).toContain(': hotpink'); }); it('should warn if default density styles are duplicated', () => { spyOn(process.stderr, 'write'); const parsed = parse( transpile(` @include mat.all-component-themes((color: null)); .dark-theme { @include mat.all-component-themes((color: null)); } `), ); expect(hasDensityStyles(parsed, null)).toBe('all'); // TODO(mmalerba): Re-enable - disabled because this test does not account // for the fact that: // ```scss // @include mat.button-theme($theme); // @include mat.checkbox-theme($theme); // ``` // produces different results than: // ```scss // html { // @include mat.button-theme($theme); // @include mat.checkbox-theme($theme); // } // ``` // expect(hasDensityStyles(parsed, '.dark-theme')).toBe('all'); expectWarning(/The same density styles are generated multiple times/); }); it('should warn if density styles are duplicated', () => { spyOn(process.stderr, 'write'); transpile(` @include mat.all-component-themes((density: -1)); .dark-theme { @include mat.all-component-themes((density: -1)); } `); expectWarning(/The same density styles are generated multiple times/); }); it('should not warn if density styles are not duplicated', () => { spyOn(process.stderr, 'write'); transpile(` @include mat.all-component-themes((density: -1)); .dark-theme { @include mat.all-component-themes((density: -2)); } `); expect(process.stderr.write).toHaveBeenCalledTimes(0); }); it('should warn if typography styles are duplicated', () => { spyOn(process.stderr, 'write'); transpile(` $theme: (typography: mat.m2-define-typography-config(), density: null); @include mat.all-component-themes($theme); .dark-theme { @include mat.all-component-themes($theme); } `); expectWarning(/The same typography styles are generated multiple times/); }); it('should not warn if typography styles are not duplicated', () => { spyOn(process.stderr, 'write'); transpile(` @include mat.all-component-themes(( typography: mat.m2-define-typography-config(), density: null, )); .dark-theme { @include mat.all-component-themes(( typography: mat.m2-define-typography-config($font-family: "sans-serif"), density: null, )); } `); expect(process.stderr.write).toHaveBeenCalledTimes(0); }); /** * Checks whether the given parsed stylesheet contains density styles scoped to * a given selector. If the selector is `null`, then density is expected to be * generated at top-level. */ function hasDensityStyles(parsed: Root, baseSelector: string | null): 'all' | 'partial' | 'none' { expect(parsed.nodes).withContext('Expected CSS to be not empty.').toBeDefined(); expect(knownDensitySelectors.size).not.toBe(0); const missingDensitySelectors = new Set(knownDensitySelectors.keys()); const baseSelectorRegex = new RegExp(`^${baseSelector} `, 'g'); // Go through all rules in the stylesheet and check if they match with any // of the density style selectors. If so, we remove it from the copied set // of density selectors. If the set is empty at the end, we know that density // styles have been generated as expected. parsed.nodes!.forEach(node => { if (node.type !== 'rule') { return; } node.selectors.forEach(selector => { if (baseSelector && selector === baseSelector) { // Styles emitted directly to the baseSelector are emitted to html // when there is no baseSelector. selector = 'html'; } else { // Only check selectors that match the specified base selector. if (baseSelector && !baseSelectorRegex.test(selector)) { return; } } selector = selector.replace(baseSelectorRegex, ''); const matchingRule = knownDensitySelectors.get(selector); if (matchingRule && compareNodes(node, matchingRule)) { missingDensitySelectors.delete(selector); } }); }); // If there are no unmatched density selectors, then it's confirmed that // all density styles have been generated (scoped to the given selector). if (missingDensitySelectors.size === 0) { return 'all'; } // If no density selector has been matched at all, then no density // styles have been generated. if (missingDensitySelectors.size === knownDensitySelectors.size) { return 'none'; } console.error('MISSING!!! ', [...missingDensitySelectors].join(',')); return 'partial'; } /** Transpiles given Sass content into CSS. */ function transpile(content: string) { return compileString( ` @use '../../../index' as mat; ${content} `, { loadPaths: [testDir], importers: [localPackageSassImporter], }, ).css.toString(); } /** Expects the given warning to be reported in Sass. */ function expectWarning(message: RegExp) { expect(getMatchingWarning(message)) .withContext('Expected warning to be printed.') .toBeDefined(); } /** Expects the given warning not to be reported in Sass. */ function expectNoWarning(message: RegExp) { expect(getMatchingWarning(message)) .withContext('Expected no warning to be printed.') .toBeUndefined(); } /** * Gets first instance of the given warning reported in Sass. Dart sass directly writes * to the `process.stderr` stream, so we spy on the `stderr.write` method. We * cannot expect a specific amount of writes as Sass calls `stderr.write` multiple * times for a warning (e.g. spacing and stack trace) */ function getMatchingWarning(message: RegExp) { const writeSpy = process.stderr.write as jasmine.Spy; return (writeSpy.calls?.all() ?? []).find( (s: jasmine.CallInfo) => typeof s.args[0] === 'string' && message.test(s.args[0]), ); } });