From 31f01f277484d76025983617a86f1120689a1c55 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Thu, 9 Apr 2026 01:33:09 +0000 Subject: [PATCH 01/10] [heft-sass-plugin] Add preserveIcssExports option Add a new `preserveIcssExports` option to `sass.json` that preserves the ICSS `:export` block in the emitted CSS output. When false (the default), `postcss-modules` strips `:export` from the CSS as before. When true, the CSS is left unchanged so that downstream webpack loaders (e.g. css-loader's icssParser) can extract the `:export` values at bundle time to generate JavaScript module exports. --- ...reserve-icss-exports_2026-04-09-00-00.json | 10 +++++++++ .../heft-sass-plugin/src/SassPlugin.ts | 5 ++++- .../heft-sass-plugin/src/SassProcessor.ts | 21 +++++++++++++++++-- .../src/schemas/heft-sass-plugin.schema.json | 5 +++++ 4 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 common/changes/@rushstack/heft-sass-plugin/heft-sass-plugin-preserve-icss-exports_2026-04-09-00-00.json diff --git a/common/changes/@rushstack/heft-sass-plugin/heft-sass-plugin-preserve-icss-exports_2026-04-09-00-00.json b/common/changes/@rushstack/heft-sass-plugin/heft-sass-plugin-preserve-icss-exports_2026-04-09-00-00.json new file mode 100644 index 00000000000..b48a39e2b5f --- /dev/null +++ b/common/changes/@rushstack/heft-sass-plugin/heft-sass-plugin-preserve-icss-exports_2026-04-09-00-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/heft-sass-plugin", + "comment": "Add preserveIcssExports option to keep the ICSS :export block in emitted CSS output, required when downstream webpack loaders (e.g. css-loader icssParser) need to extract :export values at bundle time", + "type": "minor" + } + ], + "packageName": "@rushstack/heft-sass-plugin" +} diff --git a/heft-plugins/heft-sass-plugin/src/SassPlugin.ts b/heft-plugins/heft-sass-plugin/src/SassPlugin.ts index 0ab3e899db7..cc1f2f9e1eb 100644 --- a/heft-plugins/heft-sass-plugin/src/SassPlugin.ts +++ b/heft-plugins/heft-sass-plugin/src/SassPlugin.ts @@ -30,6 +30,7 @@ export interface ISassConfigurationJson { silenceDeprecations?: string[]; excludeFiles?: string[]; doNotTrimOriginalFileExtension?: boolean; + preserveIcssExports?: boolean; } const SASS_CONFIGURATION_LOCATION: string = 'config/sass.json'; @@ -98,7 +99,8 @@ export default class SassPlugin implements IHeftPlugin { nonModuleFileExtensions, silenceDeprecations, excludeFiles, - doNotTrimOriginalFileExtension + doNotTrimOriginalFileExtension, + preserveIcssExports } = sassConfigurationJson || {}; function resolveFolder(folder: string): string { @@ -126,6 +128,7 @@ export default class SassPlugin implements IHeftPlugin { }), silenceDeprecations, doNotTrimOriginalFileExtension, + preserveIcssExports, postProcessCssAsync: hooks.postProcessCss.isUsed() ? async (cssText: string) => hooks.postProcessCss.promise(cssText) : undefined diff --git a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts index df223f08ca7..9bddcd03f22 100644 --- a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts +++ b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts @@ -121,6 +121,15 @@ export interface ISassProcessorOptions { */ doNotTrimOriginalFileExtension?: boolean; + /** + * If true, the ICSS `:export` block will be preserved in the emitted CSS output. This is necessary + * when the CSS is consumed by a webpack loader (e.g. css-loader's icssParser) that extracts `:export` + * values at bundle time to generate JavaScript exports. + * + * Defaults to false. + */ + preserveIcssExports?: boolean; + /** * A callback to further modify the raw CSS text after it has been generated. Only relevant if emitting CSS files. */ @@ -751,7 +760,8 @@ export class SassProcessor { srcFolder, exportAsDefault, doNotTrimOriginalFileExtension, - postProcessCssAsync + postProcessCssAsync, + preserveIcssExports } = this._options; // Handle CSS modules @@ -769,7 +779,14 @@ export class SassProcessor { const postCssResult: postcss.Result = await postcss .default([postCssModules]) .process(css, { from: sourceFilePath }); - css = postCssResult.css; + + if (!preserveIcssExports) { + // Default behavior: use the transformed CSS output, which has the :export block stripped. + css = postCssResult.css; + } + // If preserveIcssExports is true, we discard the transformed CSS and keep the original so + // that the :export block remains in the output for downstream webpack loaders (e.g. + // css-loader's icssParser) that extract :export values at bundle time. } if (postProcessCssAsync) { diff --git a/heft-plugins/heft-sass-plugin/src/schemas/heft-sass-plugin.schema.json b/heft-plugins/heft-sass-plugin/src/schemas/heft-sass-plugin.schema.json index e31d0716507..0aa2b15934b 100644 --- a/heft-plugins/heft-sass-plugin/src/schemas/heft-sass-plugin.schema.json +++ b/heft-plugins/heft-sass-plugin/src/schemas/heft-sass-plugin.schema.json @@ -111,6 +111,11 @@ "doNotTrimOriginalFileExtension": { "type": "boolean", "description": "If true, the original file extension will not be trimmed when generating the output CSS file. The generated CSS file will retain its original extension. For example, \"styles.scss\" will generate \"styles.scss.css\" instead of \"styles.css\"." + }, + + "preserveIcssExports": { + "type": "boolean", + "description": "If true, the ICSS `:export` block will be preserved in the emitted CSS output. This is necessary when the CSS is consumed by a webpack loader (e.g. css-loader's icssParser) that extracts `:export` values at bundle time to generate JavaScript exports. Defaults to false." } } } From da630e53d3db7f1bae18d74dbd7fb7a41623a595 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Wed, 8 Apr 2026 18:46:40 -0700 Subject: [PATCH 02/10] fixup! [heft-sass-plugin] Add preserveIcssExports option --- ...heft-sass-plugin-preserve-icss-exports_2026-04-09-00-00.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/changes/@rushstack/heft-sass-plugin/heft-sass-plugin-preserve-icss-exports_2026-04-09-00-00.json b/common/changes/@rushstack/heft-sass-plugin/heft-sass-plugin-preserve-icss-exports_2026-04-09-00-00.json index b48a39e2b5f..21099abeab8 100644 --- a/common/changes/@rushstack/heft-sass-plugin/heft-sass-plugin-preserve-icss-exports_2026-04-09-00-00.json +++ b/common/changes/@rushstack/heft-sass-plugin/heft-sass-plugin-preserve-icss-exports_2026-04-09-00-00.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@rushstack/heft-sass-plugin", - "comment": "Add preserveIcssExports option to keep the ICSS :export block in emitted CSS output, required when downstream webpack loaders (e.g. css-loader icssParser) need to extract :export values at bundle time", + "comment": "Add `preserveIcssExports` option to keep the ICSS `:export` block in emitted CSS output, required when downstream webpack loaders (e.g. `css-loader` icssParser) need to extract `:export` values at bundle time.", "type": "minor" } ], From c268b83011e4a4b11e61ffde815141cf8347cdb2 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Thu, 9 Apr 2026 03:52:17 +0000 Subject: [PATCH 03/10] Add snapshot tests for SassProcessor Tests cover CSS output (preserveIcssExports true/false), .d.ts generation, Sass-specific features (variables + nesting, @mixin, @extend + placeholders), and error reporting for invalid SCSS. --- .../config/subspaces/default/pnpm-lock.yaml | 3 + .../heft-sass-plugin/config/jest.config.json | 3 + heft-plugins/heft-sass-plugin/package.json | 1 + .../src/test/SassProcessor.test.ts | 251 ++++++++++++++++++ .../__snapshots__/SassProcessor.test.ts.snap | 209 +++++++++++++++ .../fixtures/classes-and-exports.module.scss | 14 + .../src/test/fixtures/export-only.module.scss | 6 + .../fixtures/extend-with-exports.module.scss | 28 ++ .../fixtures/mixin-with-exports.module.scss | 24 ++ .../sass-variables-and-exports.module.scss | 24 ++ 10 files changed, 563 insertions(+) create mode 100644 heft-plugins/heft-sass-plugin/config/jest.config.json create mode 100644 heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts create mode 100644 heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap create mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/classes-and-exports.module.scss create mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/export-only.module.scss create mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/extend-with-exports.module.scss create mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/mixin-with-exports.module.scss create mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/sass-variables-and-exports.module.scss diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index 28275dea0df..c5f417c83ef 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -3481,6 +3481,9 @@ importers: '@rushstack/heft': specifier: workspace:* version: link:../../apps/heft + '@rushstack/terminal': + specifier: workspace:* + version: link:../../libraries/terminal eslint: specifier: ~9.37.0 version: 9.37.0 diff --git a/heft-plugins/heft-sass-plugin/config/jest.config.json b/heft-plugins/heft-sass-plugin/config/jest.config.json new file mode 100644 index 00000000000..d1749681d90 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/config/jest.config.json @@ -0,0 +1,3 @@ +{ + "extends": "local-node-rig/profiles/default/config/jest.config.json" +} diff --git a/heft-plugins/heft-sass-plugin/package.json b/heft-plugins/heft-sass-plugin/package.json index a69e61e9044..95bba09f470 100644 --- a/heft-plugins/heft-sass-plugin/package.json +++ b/heft-plugins/heft-sass-plugin/package.json @@ -56,6 +56,7 @@ "devDependencies": { "@microsoft/api-extractor": "workspace:*", "@rushstack/heft": "workspace:*", + "@rushstack/terminal": "workspace:*", "eslint": "~9.37.0", "local-node-rig": "workspace:*" }, diff --git a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts new file mode 100644 index 00000000000..31f57f5ebb1 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts @@ -0,0 +1,251 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { FileSystem, PackageJsonLookup } from '@rushstack/node-core-library'; +import { MockScopedLogger } from '@rushstack/heft/lib/pluginFramework/logging/MockScopedLogger'; +import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal'; + +import { SassProcessor } from '../SassProcessor'; + +const projectFolder: string = PackageJsonLookup.instance.tryGetPackageFolderFor(__dirname)!; +const fixturesFolder: string = `${projectFolder}/src/test/fixtures`; +const testOutputFolder: string = `${projectFolder}/temp/test-output`; + +function createProcessor(preserveIcssExports: boolean): { + processor: SassProcessor; + dtsOutputFolder: string; + cssOutputFolder: string; + logger: MockScopedLogger; +} { + const suffix: string = preserveIcssExports ? 'preserve' : 'strip'; + const dtsOutputFolder: string = `${testOutputFolder}/${suffix}/dts`; + const cssOutputFolder: string = `${testOutputFolder}/${suffix}/css`; + + const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(false); + const terminal: Terminal = new Terminal(terminalProvider); + const logger: MockScopedLogger = new MockScopedLogger(terminal); + + const processor: SassProcessor = new SassProcessor({ + logger, + buildFolder: projectFolder, + concurrency: 1, + srcFolder: fixturesFolder, + dtsOutputFolders: [dtsOutputFolder], + cssOutputFolders: [{ folder: cssOutputFolder, shimModuleFormat: undefined }], + exportAsDefault: true, + preserveIcssExports + }); + + return { processor, dtsOutputFolder, cssOutputFolder, logger }; +} + +async function compileFixtureAsync(processor: SassProcessor, fixtureFilename: string): Promise { + const absolutePath: string = `${fixturesFolder}/${fixtureFilename}`; + await processor.compileFilesAsync(new Set([absolutePath])); +} + +async function readCssOutputAsync(cssOutputFolder: string, fixtureFilename: string): Promise { + // Strip last extension (.scss/.sass), append .css + const withoutExt: string = fixtureFilename.slice(0, fixtureFilename.lastIndexOf('.')); + return await FileSystem.readFileAsync(`${cssOutputFolder}/${withoutExt}.css`); +} + +async function readDtsOutputAsync(dtsOutputFolder: string, fixtureFilename: string): Promise { + return await FileSystem.readFileAsync(`${dtsOutputFolder}/${fixtureFilename}.d.ts`); +} + +describe(SassProcessor.name, () => { + beforeEach(async () => { + await FileSystem.ensureEmptyFolderAsync(testOutputFolder); + }); + + describe('export-only.module.scss', () => { + it('strips the :export block from CSS when preserveIcssExports is false', async () => { + const { processor, cssOutputFolder } = createProcessor(false); + await compileFixtureAsync(processor, 'export-only.module.scss'); + const css: string = await readCssOutputAsync(cssOutputFolder, 'export-only.module.scss'); + expect(css).toMatchSnapshot(); + expect(css).not.toContain(':export'); + }); + + it('preserves the :export block in CSS when preserveIcssExports is true', async () => { + const { processor, cssOutputFolder } = createProcessor(true); + await compileFixtureAsync(processor, 'export-only.module.scss'); + const css: string = await readCssOutputAsync(cssOutputFolder, 'export-only.module.scss'); + expect(css).toMatchSnapshot(); + expect(css).toContain(':export'); + }); + + it('generates the same .d.ts regardless of preserveIcssExports', async () => { + const { processor: processorFalse, dtsOutputFolder: dtsFalseFolder } = createProcessor(false); + const { processor: processorTrue, dtsOutputFolder: dtsTrueFolder } = createProcessor(true); + + await compileFixtureAsync(processorFalse, 'export-only.module.scss'); + await compileFixtureAsync(processorTrue, 'export-only.module.scss'); + + const dtsFalse: string = await readDtsOutputAsync(dtsFalseFolder, 'export-only.module.scss'); + const dtsTrue: string = await readDtsOutputAsync(dtsTrueFolder, 'export-only.module.scss'); + + expect(dtsFalse).toMatchSnapshot(); + expect(dtsFalse).toEqual(dtsTrue); + }); + }); + + describe('classes-and-exports.module.scss', () => { + it('strips the :export block from CSS when preserveIcssExports is false', async () => { + const { processor, cssOutputFolder } = createProcessor(false); + await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); + const css: string = await readCssOutputAsync(cssOutputFolder, 'classes-and-exports.module.scss'); + expect(css).toMatchSnapshot(); + expect(css).not.toContain(':export'); + expect(css).toContain('.root'); + }); + + it('preserves the :export block in CSS when preserveIcssExports is true', async () => { + const { processor, cssOutputFolder } = createProcessor(true); + await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); + const css: string = await readCssOutputAsync(cssOutputFolder, 'classes-and-exports.module.scss'); + expect(css).toMatchSnapshot(); + expect(css).toContain(':export'); + expect(css).toContain('.root'); + }); + + it('generates correct .d.ts with both class names and :export values', async () => { + const { processor, dtsOutputFolder } = createProcessor(false); + await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); + const dts: string = await readDtsOutputAsync(dtsOutputFolder, 'classes-and-exports.module.scss'); + expect(dts).toMatchSnapshot(); + expect(dts).toContain('root'); + expect(dts).toContain('highlighted'); + expect(dts).toContain('themeColor'); + expect(dts).toContain('spacing'); + }); + }); + + describe('sass-variables-and-exports.module.scss (Sass variables, nesting, BEM)', () => { + it('resolves Sass variables and expands nested rules in CSS output', async () => { + const { processor, cssOutputFolder } = createProcessor(false); + await compileFixtureAsync(processor, 'sass-variables-and-exports.module.scss'); + const css: string = await readCssOutputAsync(cssOutputFolder, 'sass-variables-and-exports.module.scss'); + expect(css).toMatchSnapshot(); + // Sass variables should be resolved to literal values + expect(css).toContain('#0078d4'); + expect(css).toContain('#106ebe'); + // Nested rules should be expanded + expect(css).toContain('.container:hover'); + expect(css).toContain('.container__title'); + // :export block should be stripped (preserveIcssExports: false) + expect(css).not.toContain(':export'); + }); + + it('resolves Sass variables inside the :export block when preserveIcssExports is true', async () => { + const { processor, cssOutputFolder } = createProcessor(true); + await compileFixtureAsync(processor, 'sass-variables-and-exports.module.scss'); + const css: string = await readCssOutputAsync(cssOutputFolder, 'sass-variables-and-exports.module.scss'); + expect(css).toMatchSnapshot(); + // The :export block should contain the resolved variable values, not the variable names + expect(css).toContain(':export'); + expect(css).toContain('#0078d4'); + expect(css).not.toContain('$primary-color'); + }); + + it('generates .d.ts with resolved :export keys as typed properties', async () => { + const { processor, dtsOutputFolder } = createProcessor(false); + await compileFixtureAsync(processor, 'sass-variables-and-exports.module.scss'); + const dts: string = await readDtsOutputAsync(dtsOutputFolder, 'sass-variables-and-exports.module.scss'); + expect(dts).toMatchSnapshot(); + expect(dts).toContain('container'); + expect(dts).toContain('primaryColor'); + expect(dts).toContain('secondaryColor'); + expect(dts).toContain('baseSpacing'); + }); + }); + + describe('mixin-with-exports.module.scss (Sass @mixin)', () => { + it('expands @mixin calls in CSS output', async () => { + const { processor, cssOutputFolder } = createProcessor(false); + await compileFixtureAsync(processor, 'mixin-with-exports.module.scss'); + const css: string = await readCssOutputAsync(cssOutputFolder, 'mixin-with-exports.module.scss'); + expect(css).toMatchSnapshot(); + // Mixin output should be inlined — no @mixin or @include in the output + expect(css).not.toContain('@mixin'); + expect(css).not.toContain('@include'); + expect(css).toContain('display: flex'); + expect(css).toContain('.card'); + expect(css).toContain('.card--vertical'); + }); + + it('preserves :export alongside expanded @mixin output when preserveIcssExports is true', async () => { + const { processor, cssOutputFolder } = createProcessor(true); + await compileFixtureAsync(processor, 'mixin-with-exports.module.scss'); + const css: string = await readCssOutputAsync(cssOutputFolder, 'mixin-with-exports.module.scss'); + expect(css).toMatchSnapshot(); + expect(css).toContain(':export'); + expect(css).toContain('display: flex'); + expect(css).not.toContain('@mixin'); + }); + + it('generates .d.ts with :export values and class names from @mixin-using file', async () => { + const { processor, dtsOutputFolder } = createProcessor(false); + await compileFixtureAsync(processor, 'mixin-with-exports.module.scss'); + const dts: string = await readDtsOutputAsync(dtsOutputFolder, 'mixin-with-exports.module.scss'); + expect(dts).toMatchSnapshot(); + expect(dts).toContain('card'); + expect(dts).toContain('cardRadius'); + expect(dts).toContain('animationDuration'); + }); + }); + + describe('extend-with-exports.module.scss (Sass @extend / placeholder selectors)', () => { + it('merges @extend selectors and strips :export when preserveIcssExports is false', async () => { + const { processor, cssOutputFolder } = createProcessor(false); + await compileFixtureAsync(processor, 'extend-with-exports.module.scss'); + const css: string = await readCssOutputAsync(cssOutputFolder, 'extend-with-exports.module.scss'); + expect(css).toMatchSnapshot(); + // Placeholder %button-base should not appear literally; its rules should be merged into the + // selectors that @extend it + expect(css).not.toContain('%button-base'); + expect(css).toContain('.primaryButton'); + expect(css).toContain('.dangerButton'); + expect(css).not.toContain(':export'); + }); + + it('preserves :export alongside @extend-merged output when preserveIcssExports is true', async () => { + const { processor, cssOutputFolder } = createProcessor(true); + await compileFixtureAsync(processor, 'extend-with-exports.module.scss'); + const css: string = await readCssOutputAsync(cssOutputFolder, 'extend-with-exports.module.scss'); + expect(css).toMatchSnapshot(); + expect(css).toContain(':export'); + expect(css).toContain('.primaryButton'); + expect(css).not.toContain('%button-base'); + }); + + it('generates .d.ts with class names and :export values for @extend file', async () => { + const { processor, dtsOutputFolder } = createProcessor(false); + await compileFixtureAsync(processor, 'extend-with-exports.module.scss'); + const dts: string = await readDtsOutputAsync(dtsOutputFolder, 'extend-with-exports.module.scss'); + expect(dts).toMatchSnapshot(); + expect(dts).toContain('primaryButton'); + expect(dts).toContain('dangerButton'); + expect(dts).toContain('colorPrimary'); + expect(dts).toContain('colorDanger'); + }); + }); + + describe('error reporting', () => { + it('emits an error for invalid SCSS syntax', async () => { + // Write a temporary invalid fixture to disk, compile it, then clean up. + const invalidFixturePath: string = `${fixturesFolder}/invalid.module.scss`; + await FileSystem.writeFileAsync(invalidFixturePath, '.broken { color: ; }'); + + const { processor, logger } = createProcessor(false); + try { + await processor.compileFilesAsync(new Set([invalidFixturePath])); + } finally { + await FileSystem.deleteFileAsync(invalidFixturePath); + } + + expect(logger.errors.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap b/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap new file mode 100644 index 00000000000..eb5a2decbac --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap @@ -0,0 +1,209 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SassProcessor classes-and-exports.module.scss generates correct .d.ts with both class names and :export values 1`] = ` +"declare interface IStyles { + themeColor: string; + spacing: string; + root: string; + highlighted: string; +} +declare const styles: IStyles; +export default styles;" +`; + +exports[`SassProcessor classes-and-exports.module.scss preserves the :export block in CSS when preserveIcssExports is true 1`] = ` +".root { + color: red; + font-size: 14px; +} + +.highlighted { + background-color: yellow; +} + +:export { + themeColor: blue; + spacing: 8px; +}" +`; + +exports[`SassProcessor classes-and-exports.module.scss strips the :export block from CSS when preserveIcssExports is false 1`] = ` +".root { + color: red; + font-size: 14px; +} + +.highlighted { + background-color: yellow; +}" +`; + +exports[`SassProcessor export-only.module.scss generates the same .d.ts regardless of preserveIcssExports 1`] = ` +"declare interface IStyles { + primaryColor: string; + fontFamily: string; +} +declare const styles: IStyles; +export default styles;" +`; + +exports[`SassProcessor export-only.module.scss preserves the :export block in CSS when preserveIcssExports is true 1`] = ` +":export { + primaryColor: #0078d4; + fontFamily: \\"Segoe UI\\"; +}" +`; + +exports[`SassProcessor export-only.module.scss strips the :export block from CSS when preserveIcssExports is false 1`] = `""`; + +exports[`SassProcessor extend-with-exports.module.scss (Sass @extend / placeholder selectors) generates .d.ts with class names and :export values for @extend file 1`] = ` +"declare interface IStyles { + colorPrimary: string; + colorDanger: string; + dangerButton: string; + primaryButton: string; +} +declare const styles: IStyles; +export default styles;" +`; + +exports[`SassProcessor extend-with-exports.module.scss (Sass @extend / placeholder selectors) merges @extend selectors and strips :export when preserveIcssExports is false 1`] = ` +".dangerButton, .primaryButton { + display: inline-flex; + align-items: center; + cursor: pointer; + border: none; + border-radius: 2px; +} + +.primaryButton { + background-color: #0078d4; + color: white; +} + +.dangerButton { + background-color: #d13438; + color: white; +}" +`; + +exports[`SassProcessor extend-with-exports.module.scss (Sass @extend / placeholder selectors) preserves :export alongside @extend-merged output when preserveIcssExports is true 1`] = ` +".dangerButton, .primaryButton { + display: inline-flex; + align-items: center; + cursor: pointer; + border: none; + border-radius: 2px; +} + +.primaryButton { + background-color: #0078d4; + color: white; +} + +.dangerButton { + background-color: #d13438; + color: white; +} + +:export { + colorPrimary: #0078d4; + colorDanger: #d13438; +}" +`; + +exports[`SassProcessor mixin-with-exports.module.scss (Sass @mixin) expands @mixin calls in CSS output 1`] = ` +".card { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + border-radius: 4px; +} + +.card--vertical { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +}" +`; + +exports[`SassProcessor mixin-with-exports.module.scss (Sass @mixin) generates .d.ts with :export values and class names from @mixin-using file 1`] = ` +"declare interface IStyles { + cardRadius: string; + animationDuration: string; + card: string; + \\"card--vertical\\": string; +} +declare const styles: IStyles; +export default styles;" +`; + +exports[`SassProcessor mixin-with-exports.module.scss (Sass @mixin) preserves :export alongside expanded @mixin output when preserveIcssExports is true 1`] = ` +".card { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + border-radius: 4px; +} + +.card--vertical { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +:export { + cardRadius: 4px; + animationDuration: 200ms; +}" +`; + +exports[`SassProcessor sass-variables-and-exports.module.scss (Sass variables, nesting, BEM) generates .d.ts with resolved :export keys as typed properties 1`] = ` +"declare interface IStyles { + primaryColor: string; + secondaryColor: string; + baseSpacing: string; + container: string; + container__title: string; +} +declare const styles: IStyles; +export default styles;" +`; + +exports[`SassProcessor sass-variables-and-exports.module.scss (Sass variables, nesting, BEM) resolves Sass variables and expands nested rules in CSS output 1`] = ` +".container { + color: #0078d4; + padding: 8px; +} +.container:hover { + color: #106ebe; +} +.container__title { + font-size: 16px; + margin-bottom: 16px; +}" +`; + +exports[`SassProcessor sass-variables-and-exports.module.scss (Sass variables, nesting, BEM) resolves Sass variables inside the :export block when preserveIcssExports is true 1`] = ` +".container { + color: #0078d4; + padding: 8px; +} +.container:hover { + color: #106ebe; +} +.container__title { + font-size: 16px; + margin-bottom: 16px; +} + +:export { + primaryColor: #0078d4; + secondaryColor: #106ebe; + baseSpacing: 8px; +}" +`; diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/classes-and-exports.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/classes-and-exports.module.scss new file mode 100644 index 00000000000..8bd91e97f7e --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/classes-and-exports.module.scss @@ -0,0 +1,14 @@ +// A CSS module that exports both class names and ICSS :export values. +.root { + color: red; + font-size: 14px; +} + +.highlighted { + background-color: yellow; +} + +:export { + themeColor: blue; + spacing: 8px; +} diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/export-only.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/export-only.module.scss new file mode 100644 index 00000000000..abe60ef5051 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/export-only.module.scss @@ -0,0 +1,6 @@ +// A CSS module that only exports ICSS values — no class names. +// Used to verify that the :export block is preserved or stripped based on preserveIcssExports. +:export { + primaryColor: #0078d4; + fontFamily: 'Segoe UI'; +} diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/extend-with-exports.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/extend-with-exports.module.scss new file mode 100644 index 00000000000..f36921fbba9 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/extend-with-exports.module.scss @@ -0,0 +1,28 @@ +// Tests that @extend / placeholder selectors work alongside :export. +%button-base { + display: inline-flex; + align-items: center; + cursor: pointer; + border: none; + border-radius: 2px; +} + +$color-primary: #0078d4; +$color-danger: #d13438; + +.primaryButton { + @extend %button-base; + background-color: $color-primary; + color: white; +} + +.dangerButton { + @extend %button-base; + background-color: $color-danger; + color: white; +} + +:export { + colorPrimary: #{$color-primary}; + colorDanger: #{$color-danger}; +} diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/mixin-with-exports.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/mixin-with-exports.module.scss new file mode 100644 index 00000000000..28a8f98b9bb --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/mixin-with-exports.module.scss @@ -0,0 +1,24 @@ +// Tests that @mixin expansion works correctly alongside :export. +@mixin flex-center($direction: row) { + display: flex; + flex-direction: $direction; + align-items: center; + justify-content: center; +} + +$card-radius: 4px; +$animation-duration: 200ms; + +.card { + @include flex-center; + border-radius: $card-radius; +} + +.card--vertical { + @include flex-center(column); +} + +:export { + cardRadius: #{$card-radius}; + animationDuration: #{$animation-duration}; +} diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/sass-variables-and-exports.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/sass-variables-and-exports.module.scss new file mode 100644 index 00000000000..eda77f65c1e --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/sass-variables-and-exports.module.scss @@ -0,0 +1,24 @@ +// Tests that Sass variables are resolved in both class rules and the :export block. +$primary-color: #0078d4; +$secondary-color: #106ebe; +$base-spacing: 8px; + +.container { + color: $primary-color; + padding: $base-spacing; + + &:hover { + color: $secondary-color; + } + + &__title { + font-size: 16px; + margin-bottom: $base-spacing * 2; + } +} + +:export { + primaryColor: #{$primary-color}; + secondaryColor: #{$secondary-color}; + baseSpacing: #{$base-spacing}; +} From 6a3c19f76bd481ef8385445037feede36ce3ac95 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Thu, 9 Apr 2026 04:06:38 +0000 Subject: [PATCH 04/10] Add snapshot tests for SassProcessor Tests cover CSS output (preserveIcssExports true/false), .d.ts generation, Sass-specific features (variables + nesting, @mixin, @extend + placeholders), and error reporting for invalid SCSS. --- .../src/test/SassProcessor.test.ts | 180 ++++----- .../__snapshots__/SassProcessor.test.ts.snap | 366 ++++++++++++++++-- .../src/test/fixtures/invalid.module.scss | 4 + 3 files changed, 419 insertions(+), 131 deletions(-) create mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/invalid.module.scss diff --git a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts index 31f57f5ebb1..5956cf28940 100644 --- a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts +++ b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts @@ -9,19 +9,18 @@ import { SassProcessor } from '../SassProcessor'; const projectFolder: string = PackageJsonLookup.instance.tryGetPackageFolderFor(__dirname)!; const fixturesFolder: string = `${projectFolder}/src/test/fixtures`; -const testOutputFolder: string = `${projectFolder}/temp/test-output`; -function createProcessor(preserveIcssExports: boolean): { +// Fake output folder paths — never actually written to disk because FileSystem.writeFileAsync is mocked. +const CSS_OUTPUT_FOLDER: string = '/fake/output/css'; +const DTS_OUTPUT_FOLDER: string = '/fake/output/dts'; + +function createProcessor( + terminalProvider: StringBufferTerminalProvider, + preserveIcssExports: boolean +): { processor: SassProcessor; - dtsOutputFolder: string; - cssOutputFolder: string; logger: MockScopedLogger; } { - const suffix: string = preserveIcssExports ? 'preserve' : 'strip'; - const dtsOutputFolder: string = `${testOutputFolder}/${suffix}/dts`; - const cssOutputFolder: string = `${testOutputFolder}/${suffix}/css`; - - const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(false); const terminal: Terminal = new Terminal(terminalProvider); const logger: MockScopedLogger = new MockScopedLogger(terminal); @@ -30,91 +29,115 @@ function createProcessor(preserveIcssExports: boolean): { buildFolder: projectFolder, concurrency: 1, srcFolder: fixturesFolder, - dtsOutputFolders: [dtsOutputFolder], - cssOutputFolders: [{ folder: cssOutputFolder, shimModuleFormat: undefined }], + dtsOutputFolders: [DTS_OUTPUT_FOLDER], + cssOutputFolders: [{ folder: CSS_OUTPUT_FOLDER, shimModuleFormat: undefined }], exportAsDefault: true, preserveIcssExports }); - return { processor, dtsOutputFolder, cssOutputFolder, logger }; + return { processor, logger }; } async function compileFixtureAsync(processor: SassProcessor, fixtureFilename: string): Promise { - const absolutePath: string = `${fixturesFolder}/${fixtureFilename}`; - await processor.compileFilesAsync(new Set([absolutePath])); + await processor.compileFilesAsync(new Set([`${fixturesFolder}/${fixtureFilename}`])); } -async function readCssOutputAsync(cssOutputFolder: string, fixtureFilename: string): Promise { - // Strip last extension (.scss/.sass), append .css - const withoutExt: string = fixtureFilename.slice(0, fixtureFilename.lastIndexOf('.')); - return await FileSystem.readFileAsync(`${cssOutputFolder}/${withoutExt}.css`); -} +describe(SassProcessor.name, () => { + let terminalProvider: StringBufferTerminalProvider; + /** Files captured by the mocked FileSystem.writeFileAsync, keyed by absolute path. */ + let writtenFiles: Map; + + /** Returns the content written to a path whose last segment matches the given filename. */ + function getWrittenFile(filename: string): string { + for (const [filePath, content] of writtenFiles) { + if (filePath.endsWith(`/${filename}`)) { + return content; + } + } -async function readDtsOutputAsync(dtsOutputFolder: string, fixtureFilename: string): Promise { - return await FileSystem.readFileAsync(`${dtsOutputFolder}/${fixtureFilename}.d.ts`); -} + throw new Error( + `No file written matching ".../${filename}". Written paths:\n${[...writtenFiles.keys()].join('\n')}` + ); + } -describe(SassProcessor.name, () => { - beforeEach(async () => { - await FileSystem.ensureEmptyFolderAsync(testOutputFolder); + function getCssOutput(fixtureFilename: string): string { + // SassProcessor strips the last extension then appends .css + // export-only.module.scss → export-only.module.css + const withoutExt: string = fixtureFilename.slice(0, fixtureFilename.lastIndexOf('.')); + return getWrittenFile(`${withoutExt}.css`); + } + + function getDtsOutput(fixtureFilename: string): string { + return getWrittenFile(`${fixtureFilename}.d.ts`); + } + + beforeEach(() => { + terminalProvider = new StringBufferTerminalProvider(); + + writtenFiles = new Map(); + jest.spyOn(FileSystem, 'writeFileAsync').mockImplementation(async (filePath, content) => { + writtenFiles.set(filePath as string, content as string); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + + expect(writtenFiles).toMatchSnapshot('written-files'); + expect(terminalProvider.getAllOutputAsChunks({ asLines: true })).toMatchSnapshot('terminal-output'); }); describe('export-only.module.scss', () => { it('strips the :export block from CSS when preserveIcssExports is false', async () => { - const { processor, cssOutputFolder } = createProcessor(false); + const { processor } = createProcessor(terminalProvider, false); await compileFixtureAsync(processor, 'export-only.module.scss'); - const css: string = await readCssOutputAsync(cssOutputFolder, 'export-only.module.scss'); - expect(css).toMatchSnapshot(); + const css: string = getCssOutput('export-only.module.scss'); expect(css).not.toContain(':export'); }); it('preserves the :export block in CSS when preserveIcssExports is true', async () => { - const { processor, cssOutputFolder } = createProcessor(true); + const { processor } = createProcessor(terminalProvider, true); await compileFixtureAsync(processor, 'export-only.module.scss'); - const css: string = await readCssOutputAsync(cssOutputFolder, 'export-only.module.scss'); - expect(css).toMatchSnapshot(); + const css: string = getCssOutput('export-only.module.scss'); expect(css).toContain(':export'); }); it('generates the same .d.ts regardless of preserveIcssExports', async () => { - const { processor: processorFalse, dtsOutputFolder: dtsFalseFolder } = createProcessor(false); - const { processor: processorTrue, dtsOutputFolder: dtsTrueFolder } = createProcessor(true); - + const { processor: processorFalse } = createProcessor(terminalProvider, false); await compileFixtureAsync(processorFalse, 'export-only.module.scss'); - await compileFixtureAsync(processorTrue, 'export-only.module.scss'); + const dtsFalse: string = getDtsOutput('export-only.module.scss'); + + writtenFiles.clear(); - const dtsFalse: string = await readDtsOutputAsync(dtsFalseFolder, 'export-only.module.scss'); - const dtsTrue: string = await readDtsOutputAsync(dtsTrueFolder, 'export-only.module.scss'); + const { processor: processorTrue } = createProcessor(terminalProvider, true); + await compileFixtureAsync(processorTrue, 'export-only.module.scss'); + const dtsTrue: string = getDtsOutput('export-only.module.scss'); - expect(dtsFalse).toMatchSnapshot(); expect(dtsFalse).toEqual(dtsTrue); }); }); describe('classes-and-exports.module.scss', () => { it('strips the :export block from CSS when preserveIcssExports is false', async () => { - const { processor, cssOutputFolder } = createProcessor(false); + const { processor } = createProcessor(terminalProvider, false); await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); - const css: string = await readCssOutputAsync(cssOutputFolder, 'classes-and-exports.module.scss'); - expect(css).toMatchSnapshot(); + const css: string = getCssOutput('classes-and-exports.module.scss'); expect(css).not.toContain(':export'); expect(css).toContain('.root'); }); it('preserves the :export block in CSS when preserveIcssExports is true', async () => { - const { processor, cssOutputFolder } = createProcessor(true); + const { processor } = createProcessor(terminalProvider, true); await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); - const css: string = await readCssOutputAsync(cssOutputFolder, 'classes-and-exports.module.scss'); - expect(css).toMatchSnapshot(); + const css: string = getCssOutput('classes-and-exports.module.scss'); expect(css).toContain(':export'); expect(css).toContain('.root'); }); it('generates correct .d.ts with both class names and :export values', async () => { - const { processor, dtsOutputFolder } = createProcessor(false); + const { processor } = createProcessor(terminalProvider, false); await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); - const dts: string = await readDtsOutputAsync(dtsOutputFolder, 'classes-and-exports.module.scss'); - expect(dts).toMatchSnapshot(); + const dts: string = getDtsOutput('classes-and-exports.module.scss'); expect(dts).toContain('root'); expect(dts).toContain('highlighted'); expect(dts).toContain('themeColor'); @@ -124,10 +147,9 @@ describe(SassProcessor.name, () => { describe('sass-variables-and-exports.module.scss (Sass variables, nesting, BEM)', () => { it('resolves Sass variables and expands nested rules in CSS output', async () => { - const { processor, cssOutputFolder } = createProcessor(false); + const { processor } = createProcessor(terminalProvider, false); await compileFixtureAsync(processor, 'sass-variables-and-exports.module.scss'); - const css: string = await readCssOutputAsync(cssOutputFolder, 'sass-variables-and-exports.module.scss'); - expect(css).toMatchSnapshot(); + const css: string = getCssOutput('sass-variables-and-exports.module.scss'); // Sass variables should be resolved to literal values expect(css).toContain('#0078d4'); expect(css).toContain('#106ebe'); @@ -139,21 +161,19 @@ describe(SassProcessor.name, () => { }); it('resolves Sass variables inside the :export block when preserveIcssExports is true', async () => { - const { processor, cssOutputFolder } = createProcessor(true); + const { processor } = createProcessor(terminalProvider, true); await compileFixtureAsync(processor, 'sass-variables-and-exports.module.scss'); - const css: string = await readCssOutputAsync(cssOutputFolder, 'sass-variables-and-exports.module.scss'); - expect(css).toMatchSnapshot(); - // The :export block should contain the resolved variable values, not the variable names + const css: string = getCssOutput('sass-variables-and-exports.module.scss'); + // The :export block should contain resolved values, not Sass variable names expect(css).toContain(':export'); expect(css).toContain('#0078d4'); expect(css).not.toContain('$primary-color'); }); it('generates .d.ts with resolved :export keys as typed properties', async () => { - const { processor, dtsOutputFolder } = createProcessor(false); + const { processor } = createProcessor(terminalProvider, false); await compileFixtureAsync(processor, 'sass-variables-and-exports.module.scss'); - const dts: string = await readDtsOutputAsync(dtsOutputFolder, 'sass-variables-and-exports.module.scss'); - expect(dts).toMatchSnapshot(); + const dts: string = getDtsOutput('sass-variables-and-exports.module.scss'); expect(dts).toContain('container'); expect(dts).toContain('primaryColor'); expect(dts).toContain('secondaryColor'); @@ -163,10 +183,9 @@ describe(SassProcessor.name, () => { describe('mixin-with-exports.module.scss (Sass @mixin)', () => { it('expands @mixin calls in CSS output', async () => { - const { processor, cssOutputFolder } = createProcessor(false); + const { processor } = createProcessor(terminalProvider, false); await compileFixtureAsync(processor, 'mixin-with-exports.module.scss'); - const css: string = await readCssOutputAsync(cssOutputFolder, 'mixin-with-exports.module.scss'); - expect(css).toMatchSnapshot(); + const css: string = getCssOutput('mixin-with-exports.module.scss'); // Mixin output should be inlined — no @mixin or @include in the output expect(css).not.toContain('@mixin'); expect(css).not.toContain('@include'); @@ -176,20 +195,18 @@ describe(SassProcessor.name, () => { }); it('preserves :export alongside expanded @mixin output when preserveIcssExports is true', async () => { - const { processor, cssOutputFolder } = createProcessor(true); + const { processor } = createProcessor(terminalProvider, true); await compileFixtureAsync(processor, 'mixin-with-exports.module.scss'); - const css: string = await readCssOutputAsync(cssOutputFolder, 'mixin-with-exports.module.scss'); - expect(css).toMatchSnapshot(); + const css: string = getCssOutput('mixin-with-exports.module.scss'); expect(css).toContain(':export'); expect(css).toContain('display: flex'); expect(css).not.toContain('@mixin'); }); it('generates .d.ts with :export values and class names from @mixin-using file', async () => { - const { processor, dtsOutputFolder } = createProcessor(false); + const { processor } = createProcessor(terminalProvider, false); await compileFixtureAsync(processor, 'mixin-with-exports.module.scss'); - const dts: string = await readDtsOutputAsync(dtsOutputFolder, 'mixin-with-exports.module.scss'); - expect(dts).toMatchSnapshot(); + const dts: string = getDtsOutput('mixin-with-exports.module.scss'); expect(dts).toContain('card'); expect(dts).toContain('cardRadius'); expect(dts).toContain('animationDuration'); @@ -198,12 +215,10 @@ describe(SassProcessor.name, () => { describe('extend-with-exports.module.scss (Sass @extend / placeholder selectors)', () => { it('merges @extend selectors and strips :export when preserveIcssExports is false', async () => { - const { processor, cssOutputFolder } = createProcessor(false); + const { processor } = createProcessor(terminalProvider, false); await compileFixtureAsync(processor, 'extend-with-exports.module.scss'); - const css: string = await readCssOutputAsync(cssOutputFolder, 'extend-with-exports.module.scss'); - expect(css).toMatchSnapshot(); - // Placeholder %button-base should not appear literally; its rules should be merged into the - // selectors that @extend it + const css: string = getCssOutput('extend-with-exports.module.scss'); + // Placeholder %button-base should not appear literally; its rules should be merged expect(css).not.toContain('%button-base'); expect(css).toContain('.primaryButton'); expect(css).toContain('.dangerButton'); @@ -211,20 +226,18 @@ describe(SassProcessor.name, () => { }); it('preserves :export alongside @extend-merged output when preserveIcssExports is true', async () => { - const { processor, cssOutputFolder } = createProcessor(true); + const { processor } = createProcessor(terminalProvider, true); await compileFixtureAsync(processor, 'extend-with-exports.module.scss'); - const css: string = await readCssOutputAsync(cssOutputFolder, 'extend-with-exports.module.scss'); - expect(css).toMatchSnapshot(); + const css: string = getCssOutput('extend-with-exports.module.scss'); expect(css).toContain(':export'); expect(css).toContain('.primaryButton'); expect(css).not.toContain('%button-base'); }); it('generates .d.ts with class names and :export values for @extend file', async () => { - const { processor, dtsOutputFolder } = createProcessor(false); + const { processor } = createProcessor(terminalProvider, false); await compileFixtureAsync(processor, 'extend-with-exports.module.scss'); - const dts: string = await readDtsOutputAsync(dtsOutputFolder, 'extend-with-exports.module.scss'); - expect(dts).toMatchSnapshot(); + const dts: string = getDtsOutput('extend-with-exports.module.scss'); expect(dts).toContain('primaryButton'); expect(dts).toContain('dangerButton'); expect(dts).toContain('colorPrimary'); @@ -234,17 +247,8 @@ describe(SassProcessor.name, () => { describe('error reporting', () => { it('emits an error for invalid SCSS syntax', async () => { - // Write a temporary invalid fixture to disk, compile it, then clean up. - const invalidFixturePath: string = `${fixturesFolder}/invalid.module.scss`; - await FileSystem.writeFileAsync(invalidFixturePath, '.broken { color: ; }'); - - const { processor, logger } = createProcessor(false); - try { - await processor.compileFilesAsync(new Set([invalidFixturePath])); - } finally { - await FileSystem.deleteFileAsync(invalidFixturePath); - } - + const { processor, logger } = createProcessor(terminalProvider, false); + await compileFixtureAsync(processor, 'invalid.module.scss'); expect(logger.errors.length).toBeGreaterThan(0); }); }); diff --git a/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap b/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap index eb5a2decbac..7ff3b1525d5 100644 --- a/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap +++ b/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap @@ -1,18 +1,51 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`SassProcessor classes-and-exports.module.scss generates correct .d.ts with both class names and :export values 1`] = ` -"declare interface IStyles { +exports[`SassProcessor classes-and-exports.module.scss generates correct .d.ts with both class names and :export values: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor classes-and-exports.module.scss generates correct .d.ts with both class names and :export values: written-files 1`] = ` +Map { + "/fake/output/dts/classes-and-exports.module.scss.d.ts" => "declare interface IStyles { themeColor: string; spacing: string; root: string; highlighted: string; } declare const styles: IStyles; -export default styles;" +export default styles;", + "/fake/output/css/classes-and-exports.module.css" => ".root { + color: red; + font-size: 14px; +} + +.highlighted { + background-color: yellow; +}", +} `; -exports[`SassProcessor classes-and-exports.module.scss preserves the :export block in CSS when preserveIcssExports is true 1`] = ` -".root { +exports[`SassProcessor classes-and-exports.module.scss preserves the :export block in CSS when preserveIcssExports is true: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor classes-and-exports.module.scss preserves the :export block in CSS when preserveIcssExports is true: written-files 1`] = ` +Map { + "/fake/output/dts/classes-and-exports.module.scss.d.ts" => "declare interface IStyles { + themeColor: string; + spacing: string; + root: string; + highlighted: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/classes-and-exports.module.css" => ".root { color: red; font-size: 14px; } @@ -24,51 +57,167 @@ exports[`SassProcessor classes-and-exports.module.scss preserves the :export blo :export { themeColor: blue; spacing: 8px; -}" +}", +} +`; + +exports[`SassProcessor classes-and-exports.module.scss strips the :export block from CSS when preserveIcssExports is false: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] `; -exports[`SassProcessor classes-and-exports.module.scss strips the :export block from CSS when preserveIcssExports is false 1`] = ` -".root { +exports[`SassProcessor classes-and-exports.module.scss strips the :export block from CSS when preserveIcssExports is false: written-files 1`] = ` +Map { + "/fake/output/dts/classes-and-exports.module.scss.d.ts" => "declare interface IStyles { + themeColor: string; + spacing: string; + root: string; + highlighted: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/classes-and-exports.module.css" => ".root { color: red; font-size: 14px; } .highlighted { background-color: yellow; -}" +}", +} +`; + +exports[`SassProcessor error reporting emits an error for invalid SCSS syntax: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] `; -exports[`SassProcessor export-only.module.scss generates the same .d.ts regardless of preserveIcssExports 1`] = ` -"declare interface IStyles { +exports[`SassProcessor error reporting emits an error for invalid SCSS syntax: written-files 1`] = `Map {}`; + +exports[`SassProcessor export-only.module.scss generates the same .d.ts regardless of preserveIcssExports: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor export-only.module.scss generates the same .d.ts regardless of preserveIcssExports: written-files 1`] = ` +Map { + "/fake/output/dts/export-only.module.scss.d.ts" => "declare interface IStyles { primaryColor: string; fontFamily: string; } declare const styles: IStyles; -export default styles;" +export default styles;", + "/fake/output/css/export-only.module.css" => ":export { + primaryColor: #0078d4; + fontFamily: \\"Segoe UI\\"; +}", +} +`; + +exports[`SassProcessor export-only.module.scss preserves the :export block in CSS when preserveIcssExports is true: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] `; -exports[`SassProcessor export-only.module.scss preserves the :export block in CSS when preserveIcssExports is true 1`] = ` -":export { +exports[`SassProcessor export-only.module.scss preserves the :export block in CSS when preserveIcssExports is true: written-files 1`] = ` +Map { + "/fake/output/dts/export-only.module.scss.d.ts" => "declare interface IStyles { + primaryColor: string; + fontFamily: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/export-only.module.css" => ":export { primaryColor: #0078d4; fontFamily: \\"Segoe UI\\"; -}" +}", +} `; -exports[`SassProcessor export-only.module.scss strips the :export block from CSS when preserveIcssExports is false 1`] = `""`; +exports[`SassProcessor export-only.module.scss strips the :export block from CSS when preserveIcssExports is false: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; -exports[`SassProcessor extend-with-exports.module.scss (Sass @extend / placeholder selectors) generates .d.ts with class names and :export values for @extend file 1`] = ` -"declare interface IStyles { +exports[`SassProcessor export-only.module.scss strips the :export block from CSS when preserveIcssExports is false: written-files 1`] = ` +Map { + "/fake/output/dts/export-only.module.scss.d.ts" => "declare interface IStyles { + primaryColor: string; + fontFamily: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/export-only.module.css" => "", +} +`; + +exports[`SassProcessor extend-with-exports.module.scss (Sass @extend / placeholder selectors) generates .d.ts with class names and :export values for @extend file: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor extend-with-exports.module.scss (Sass @extend / placeholder selectors) generates .d.ts with class names and :export values for @extend file: written-files 1`] = ` +Map { + "/fake/output/dts/extend-with-exports.module.scss.d.ts" => "declare interface IStyles { colorPrimary: string; colorDanger: string; dangerButton: string; primaryButton: string; } declare const styles: IStyles; -export default styles;" +export default styles;", + "/fake/output/css/extend-with-exports.module.css" => ".dangerButton, .primaryButton { + display: inline-flex; + align-items: center; + cursor: pointer; + border: none; + border-radius: 2px; +} + +.primaryButton { + background-color: #0078d4; + color: white; +} + +.dangerButton { + background-color: #d13438; + color: white; +}", +} +`; + +exports[`SassProcessor extend-with-exports.module.scss (Sass @extend / placeholder selectors) merges @extend selectors and strips :export when preserveIcssExports is false: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] `; -exports[`SassProcessor extend-with-exports.module.scss (Sass @extend / placeholder selectors) merges @extend selectors and strips :export when preserveIcssExports is false 1`] = ` -".dangerButton, .primaryButton { +exports[`SassProcessor extend-with-exports.module.scss (Sass @extend / placeholder selectors) merges @extend selectors and strips :export when preserveIcssExports is false: written-files 1`] = ` +Map { + "/fake/output/dts/extend-with-exports.module.scss.d.ts" => "declare interface IStyles { + colorPrimary: string; + colorDanger: string; + dangerButton: string; + primaryButton: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/extend-with-exports.module.css" => ".dangerButton, .primaryButton { display: inline-flex; align-items: center; cursor: pointer; @@ -84,11 +233,28 @@ exports[`SassProcessor extend-with-exports.module.scss (Sass @extend / placehold .dangerButton { background-color: #d13438; color: white; -}" +}", +} +`; + +exports[`SassProcessor extend-with-exports.module.scss (Sass @extend / placeholder selectors) preserves :export alongside @extend-merged output when preserveIcssExports is true: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] `; -exports[`SassProcessor extend-with-exports.module.scss (Sass @extend / placeholder selectors) preserves :export alongside @extend-merged output when preserveIcssExports is true 1`] = ` -".dangerButton, .primaryButton { +exports[`SassProcessor extend-with-exports.module.scss (Sass @extend / placeholder selectors) preserves :export alongside @extend-merged output when preserveIcssExports is true: written-files 1`] = ` +Map { + "/fake/output/dts/extend-with-exports.module.scss.d.ts" => "declare interface IStyles { + colorPrimary: string; + colorDanger: string; + dangerButton: string; + primaryButton: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/extend-with-exports.module.css" => ".dangerButton, .primaryButton { display: inline-flex; align-items: center; cursor: pointer; @@ -109,11 +275,28 @@ exports[`SassProcessor extend-with-exports.module.scss (Sass @extend / placehold :export { colorPrimary: #0078d4; colorDanger: #d13438; -}" +}", +} `; -exports[`SassProcessor mixin-with-exports.module.scss (Sass @mixin) expands @mixin calls in CSS output 1`] = ` -".card { +exports[`SassProcessor mixin-with-exports.module.scss (Sass @mixin) expands @mixin calls in CSS output: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor mixin-with-exports.module.scss (Sass @mixin) expands @mixin calls in CSS output: written-files 1`] = ` +Map { + "/fake/output/dts/mixin-with-exports.module.scss.d.ts" => "declare interface IStyles { + cardRadius: string; + animationDuration: string; + card: string; + \\"card--vertical\\": string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/mixin-with-exports.module.css" => ".card { display: flex; flex-direction: row; align-items: center; @@ -126,22 +309,62 @@ exports[`SassProcessor mixin-with-exports.module.scss (Sass @mixin) expands @mix flex-direction: column; align-items: center; justify-content: center; -}" +}", +} `; -exports[`SassProcessor mixin-with-exports.module.scss (Sass @mixin) generates .d.ts with :export values and class names from @mixin-using file 1`] = ` -"declare interface IStyles { +exports[`SassProcessor mixin-with-exports.module.scss (Sass @mixin) generates .d.ts with :export values and class names from @mixin-using file: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor mixin-with-exports.module.scss (Sass @mixin) generates .d.ts with :export values and class names from @mixin-using file: written-files 1`] = ` +Map { + "/fake/output/dts/mixin-with-exports.module.scss.d.ts" => "declare interface IStyles { cardRadius: string; animationDuration: string; card: string; \\"card--vertical\\": string; } declare const styles: IStyles; -export default styles;" +export default styles;", + "/fake/output/css/mixin-with-exports.module.css" => ".card { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + border-radius: 4px; +} + +.card--vertical { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +}", +} `; -exports[`SassProcessor mixin-with-exports.module.scss (Sass @mixin) preserves :export alongside expanded @mixin output when preserveIcssExports is true 1`] = ` -".card { +exports[`SassProcessor mixin-with-exports.module.scss (Sass @mixin) preserves :export alongside expanded @mixin output when preserveIcssExports is true: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor mixin-with-exports.module.scss (Sass @mixin) preserves :export alongside expanded @mixin output when preserveIcssExports is true: written-files 1`] = ` +Map { + "/fake/output/dts/mixin-with-exports.module.scss.d.ts" => "declare interface IStyles { + cardRadius: string; + animationDuration: string; + card: string; + \\"card--vertical\\": string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/mixin-with-exports.module.css" => ".card { display: flex; flex-direction: row; align-items: center; @@ -159,11 +382,20 @@ exports[`SassProcessor mixin-with-exports.module.scss (Sass @mixin) preserves :e :export { cardRadius: 4px; animationDuration: 200ms; -}" +}", +} `; -exports[`SassProcessor sass-variables-and-exports.module.scss (Sass variables, nesting, BEM) generates .d.ts with resolved :export keys as typed properties 1`] = ` -"declare interface IStyles { +exports[`SassProcessor sass-variables-and-exports.module.scss (Sass variables, nesting, BEM) generates .d.ts with resolved :export keys as typed properties: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor sass-variables-and-exports.module.scss (Sass variables, nesting, BEM) generates .d.ts with resolved :export keys as typed properties: written-files 1`] = ` +Map { + "/fake/output/dts/sass-variables-and-exports.module.scss.d.ts" => "declare interface IStyles { primaryColor: string; secondaryColor: string; baseSpacing: string; @@ -171,11 +403,40 @@ exports[`SassProcessor sass-variables-and-exports.module.scss (Sass variables, n container__title: string; } declare const styles: IStyles; -export default styles;" +export default styles;", + "/fake/output/css/sass-variables-and-exports.module.css" => ".container { + color: #0078d4; + padding: 8px; +} +.container:hover { + color: #106ebe; +} +.container__title { + font-size: 16px; + margin-bottom: 16px; +}", +} +`; + +exports[`SassProcessor sass-variables-and-exports.module.scss (Sass variables, nesting, BEM) resolves Sass variables and expands nested rules in CSS output: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] `; -exports[`SassProcessor sass-variables-and-exports.module.scss (Sass variables, nesting, BEM) resolves Sass variables and expands nested rules in CSS output 1`] = ` -".container { +exports[`SassProcessor sass-variables-and-exports.module.scss (Sass variables, nesting, BEM) resolves Sass variables and expands nested rules in CSS output: written-files 1`] = ` +Map { + "/fake/output/dts/sass-variables-and-exports.module.scss.d.ts" => "declare interface IStyles { + primaryColor: string; + secondaryColor: string; + baseSpacing: string; + container: string; + container__title: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/sass-variables-and-exports.module.css" => ".container { color: #0078d4; padding: 8px; } @@ -185,11 +446,29 @@ exports[`SassProcessor sass-variables-and-exports.module.scss (Sass variables, n .container__title { font-size: 16px; margin-bottom: 16px; -}" +}", +} +`; + +exports[`SassProcessor sass-variables-and-exports.module.scss (Sass variables, nesting, BEM) resolves Sass variables inside the :export block when preserveIcssExports is true: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] `; -exports[`SassProcessor sass-variables-and-exports.module.scss (Sass variables, nesting, BEM) resolves Sass variables inside the :export block when preserveIcssExports is true 1`] = ` -".container { +exports[`SassProcessor sass-variables-and-exports.module.scss (Sass variables, nesting, BEM) resolves Sass variables inside the :export block when preserveIcssExports is true: written-files 1`] = ` +Map { + "/fake/output/dts/sass-variables-and-exports.module.scss.d.ts" => "declare interface IStyles { + primaryColor: string; + secondaryColor: string; + baseSpacing: string; + container: string; + container__title: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/sass-variables-and-exports.module.css" => ".container { color: #0078d4; padding: 8px; } @@ -205,5 +484,6 @@ exports[`SassProcessor sass-variables-and-exports.module.scss (Sass variables, n primaryColor: #0078d4; secondaryColor: #106ebe; baseSpacing: 8px; -}" +}", +} `; diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/invalid.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/invalid.module.scss new file mode 100644 index 00000000000..7c9df51305b --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/invalid.module.scss @@ -0,0 +1,4 @@ +// Intentionally invalid SCSS — used to verify that SassProcessor emits errors correctly. +.broken { + color:; +} From abee20466b4a18e6edbe3f13ad13e33def8a223e Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Thu, 9 Apr 2026 04:12:06 +0000 Subject: [PATCH 05/10] Add snapshot tests for SassProcessor Tests cover CSS output (preserveIcssExports true/false), .d.ts generation, Sass-specific features (variables + nesting, @mixin, @extend + placeholders), JS shim generation (commonjs/esnext, module/global), multiple output folders, postProcessCssAsync callback, exportAsDefault: false, and error reporting. Co-Authored-By: Claude Sonnet 4.6 --- .../src/test/SassProcessor.test.ts | 262 ++++++++++++- .../__snapshots__/SassProcessor.test.ts.snap | 350 ++++++++++++++++++ .../test/fixtures/global-styles.global.scss | 15 + 3 files changed, 607 insertions(+), 20 deletions(-) create mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/global-styles.global.scss diff --git a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts index 5956cf28940..55214728c78 100644 --- a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts +++ b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts @@ -5,7 +5,7 @@ import { FileSystem, PackageJsonLookup } from '@rushstack/node-core-library'; import { MockScopedLogger } from '@rushstack/heft/lib/pluginFramework/logging/MockScopedLogger'; import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal'; -import { SassProcessor } from '../SassProcessor'; +import { type ICssOutputFolder, type ISassProcessorOptions, SassProcessor } from '../SassProcessor'; const projectFolder: string = PackageJsonLookup.instance.tryGetPackageFolderFor(__dirname)!; const fixturesFolder: string = `${projectFolder}/src/test/fixtures`; @@ -14,9 +14,23 @@ const fixturesFolder: string = `${projectFolder}/src/test/fixtures`; const CSS_OUTPUT_FOLDER: string = '/fake/output/css'; const DTS_OUTPUT_FOLDER: string = '/fake/output/dts'; +type ICreateProcessorOptions = Partial< + Pick< + ISassProcessorOptions, + | 'cssOutputFolders' + | 'dtsOutputFolders' + | 'exportAsDefault' + | 'fileExtensions' + | 'nonModuleFileExtensions' + | 'postProcessCssAsync' + | 'preserveIcssExports' + | 'srcFolder' + > +>; + function createProcessor( terminalProvider: StringBufferTerminalProvider, - preserveIcssExports: boolean + options: ICreateProcessorOptions = {} ): { processor: SassProcessor; logger: MockScopedLogger; @@ -32,7 +46,7 @@ function createProcessor( dtsOutputFolders: [DTS_OUTPUT_FOLDER], cssOutputFolders: [{ folder: CSS_OUTPUT_FOLDER, shimModuleFormat: undefined }], exportAsDefault: true, - preserveIcssExports + ...options }); return { processor, logger }; @@ -60,6 +74,11 @@ describe(SassProcessor.name, () => { ); } + /** Returns all paths written that end with the given suffix. */ + function getAllWrittenPathsMatching(suffix: string): string[] { + return [...writtenFiles.keys()].filter((p) => p.endsWith(suffix)); + } + function getCssOutput(fixtureFilename: string): string { // SassProcessor strips the last extension then appends .css // export-only.module.scss → export-only.module.css @@ -71,6 +90,10 @@ describe(SassProcessor.name, () => { return getWrittenFile(`${fixtureFilename}.d.ts`); } + function getJsShimOutput(fixtureFilename: string): string { + return getWrittenFile(`${fixtureFilename}.js`); + } + beforeEach(() => { terminalProvider = new StringBufferTerminalProvider(); @@ -89,27 +112,29 @@ describe(SassProcessor.name, () => { describe('export-only.module.scss', () => { it('strips the :export block from CSS when preserveIcssExports is false', async () => { - const { processor } = createProcessor(terminalProvider, false); + const { processor } = createProcessor(terminalProvider); await compileFixtureAsync(processor, 'export-only.module.scss'); const css: string = getCssOutput('export-only.module.scss'); expect(css).not.toContain(':export'); }); it('preserves the :export block in CSS when preserveIcssExports is true', async () => { - const { processor } = createProcessor(terminalProvider, true); + const { processor } = createProcessor(terminalProvider, { preserveIcssExports: true }); await compileFixtureAsync(processor, 'export-only.module.scss'); const css: string = getCssOutput('export-only.module.scss'); expect(css).toContain(':export'); }); it('generates the same .d.ts regardless of preserveIcssExports', async () => { - const { processor: processorFalse } = createProcessor(terminalProvider, false); + const { processor: processorFalse } = createProcessor(terminalProvider); await compileFixtureAsync(processorFalse, 'export-only.module.scss'); const dtsFalse: string = getDtsOutput('export-only.module.scss'); writtenFiles.clear(); - const { processor: processorTrue } = createProcessor(terminalProvider, true); + const { processor: processorTrue } = createProcessor(terminalProvider, { + preserveIcssExports: true + }); await compileFixtureAsync(processorTrue, 'export-only.module.scss'); const dtsTrue: string = getDtsOutput('export-only.module.scss'); @@ -119,7 +144,7 @@ describe(SassProcessor.name, () => { describe('classes-and-exports.module.scss', () => { it('strips the :export block from CSS when preserveIcssExports is false', async () => { - const { processor } = createProcessor(terminalProvider, false); + const { processor } = createProcessor(terminalProvider); await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); const css: string = getCssOutput('classes-and-exports.module.scss'); expect(css).not.toContain(':export'); @@ -127,7 +152,7 @@ describe(SassProcessor.name, () => { }); it('preserves the :export block in CSS when preserveIcssExports is true', async () => { - const { processor } = createProcessor(terminalProvider, true); + const { processor } = createProcessor(terminalProvider, { preserveIcssExports: true }); await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); const css: string = getCssOutput('classes-and-exports.module.scss'); expect(css).toContain(':export'); @@ -135,7 +160,7 @@ describe(SassProcessor.name, () => { }); it('generates correct .d.ts with both class names and :export values', async () => { - const { processor } = createProcessor(terminalProvider, false); + const { processor } = createProcessor(terminalProvider); await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); const dts: string = getDtsOutput('classes-and-exports.module.scss'); expect(dts).toContain('root'); @@ -143,11 +168,26 @@ describe(SassProcessor.name, () => { expect(dts).toContain('themeColor'); expect(dts).toContain('spacing'); }); + + it('generates named exports in .d.ts when exportAsDefault is false', async () => { + // cssOutputFolders requires exportAsDefault: true, so omit it here + const { processor } = createProcessor(terminalProvider, { + exportAsDefault: false, + cssOutputFolders: [] + }); + await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); + const dts: string = getDtsOutput('classes-and-exports.module.scss'); + // Named exports: "export const root: string;" instead of a default interface + expect(dts).toContain('export const root'); + expect(dts).toContain('export const highlighted'); + expect(dts).toContain('export const themeColor'); + expect(dts).not.toContain('export default'); + }); }); describe('sass-variables-and-exports.module.scss (Sass variables, nesting, BEM)', () => { it('resolves Sass variables and expands nested rules in CSS output', async () => { - const { processor } = createProcessor(terminalProvider, false); + const { processor } = createProcessor(terminalProvider); await compileFixtureAsync(processor, 'sass-variables-and-exports.module.scss'); const css: string = getCssOutput('sass-variables-and-exports.module.scss'); // Sass variables should be resolved to literal values @@ -161,7 +201,7 @@ describe(SassProcessor.name, () => { }); it('resolves Sass variables inside the :export block when preserveIcssExports is true', async () => { - const { processor } = createProcessor(terminalProvider, true); + const { processor } = createProcessor(terminalProvider, { preserveIcssExports: true }); await compileFixtureAsync(processor, 'sass-variables-and-exports.module.scss'); const css: string = getCssOutput('sass-variables-and-exports.module.scss'); // The :export block should contain resolved values, not Sass variable names @@ -171,7 +211,7 @@ describe(SassProcessor.name, () => { }); it('generates .d.ts with resolved :export keys as typed properties', async () => { - const { processor } = createProcessor(terminalProvider, false); + const { processor } = createProcessor(terminalProvider); await compileFixtureAsync(processor, 'sass-variables-and-exports.module.scss'); const dts: string = getDtsOutput('sass-variables-and-exports.module.scss'); expect(dts).toContain('container'); @@ -183,7 +223,7 @@ describe(SassProcessor.name, () => { describe('mixin-with-exports.module.scss (Sass @mixin)', () => { it('expands @mixin calls in CSS output', async () => { - const { processor } = createProcessor(terminalProvider, false); + const { processor } = createProcessor(terminalProvider); await compileFixtureAsync(processor, 'mixin-with-exports.module.scss'); const css: string = getCssOutput('mixin-with-exports.module.scss'); // Mixin output should be inlined — no @mixin or @include in the output @@ -195,7 +235,7 @@ describe(SassProcessor.name, () => { }); it('preserves :export alongside expanded @mixin output when preserveIcssExports is true', async () => { - const { processor } = createProcessor(terminalProvider, true); + const { processor } = createProcessor(terminalProvider, { preserveIcssExports: true }); await compileFixtureAsync(processor, 'mixin-with-exports.module.scss'); const css: string = getCssOutput('mixin-with-exports.module.scss'); expect(css).toContain(':export'); @@ -204,7 +244,7 @@ describe(SassProcessor.name, () => { }); it('generates .d.ts with :export values and class names from @mixin-using file', async () => { - const { processor } = createProcessor(terminalProvider, false); + const { processor } = createProcessor(terminalProvider); await compileFixtureAsync(processor, 'mixin-with-exports.module.scss'); const dts: string = getDtsOutput('mixin-with-exports.module.scss'); expect(dts).toContain('card'); @@ -215,7 +255,7 @@ describe(SassProcessor.name, () => { describe('extend-with-exports.module.scss (Sass @extend / placeholder selectors)', () => { it('merges @extend selectors and strips :export when preserveIcssExports is false', async () => { - const { processor } = createProcessor(terminalProvider, false); + const { processor } = createProcessor(terminalProvider); await compileFixtureAsync(processor, 'extend-with-exports.module.scss'); const css: string = getCssOutput('extend-with-exports.module.scss'); // Placeholder %button-base should not appear literally; its rules should be merged @@ -226,7 +266,7 @@ describe(SassProcessor.name, () => { }); it('preserves :export alongside @extend-merged output when preserveIcssExports is true', async () => { - const { processor } = createProcessor(terminalProvider, true); + const { processor } = createProcessor(terminalProvider, { preserveIcssExports: true }); await compileFixtureAsync(processor, 'extend-with-exports.module.scss'); const css: string = getCssOutput('extend-with-exports.module.scss'); expect(css).toContain(':export'); @@ -235,7 +275,7 @@ describe(SassProcessor.name, () => { }); it('generates .d.ts with class names and :export values for @extend file', async () => { - const { processor } = createProcessor(terminalProvider, false); + const { processor } = createProcessor(terminalProvider); await compileFixtureAsync(processor, 'extend-with-exports.module.scss'); const dts: string = getDtsOutput('extend-with-exports.module.scss'); expect(dts).toContain('primaryButton'); @@ -245,9 +285,191 @@ describe(SassProcessor.name, () => { }); }); + describe('JS shim files', () => { + it('emits a CommonJS shim for a module file', async () => { + const { processor } = createProcessor(terminalProvider, { + cssOutputFolders: [{ folder: CSS_OUTPUT_FOLDER, shimModuleFormat: 'commonjs' }] + }); + await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); + const shim: string = getJsShimOutput('classes-and-exports.module.scss'); + // CJS module shim re-exports from the CSS file and mirrors it as .default + expect(shim).toContain(`require("./classes-and-exports.module.css")`); + expect(shim).toContain('module.exports.default = module.exports'); + }); + + it('emits an ESM shim for a module file', async () => { + const { processor } = createProcessor(terminalProvider, { + cssOutputFolders: [{ folder: CSS_OUTPUT_FOLDER, shimModuleFormat: 'esnext' }] + }); + await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); + const shim: string = getJsShimOutput('classes-and-exports.module.scss'); + // ESM module shim re-exports the default from the CSS file + expect(shim).toBe(`export { default } from "./classes-and-exports.module.css";`); + }); + + it('emits a CommonJS shim for a non-module (global) file', async () => { + const { processor } = createProcessor(terminalProvider, { + cssOutputFolders: [{ folder: CSS_OUTPUT_FOLDER, shimModuleFormat: 'commonjs' }], + // Register the .global.scss extension so the processor classifies it correctly + nonModuleFileExtensions: ['.global.scss'] + }); + await compileFixtureAsync(processor, 'global-styles.global.scss'); + const shim: string = getJsShimOutput('global-styles.global.scss'); + // CJS non-module shim: side-effect require only + expect(shim).toBe(`require("./global-styles.global.css");`); + }); + + it('emits an ESM shim for a non-module (global) file', async () => { + const { processor } = createProcessor(terminalProvider, { + cssOutputFolders: [{ folder: CSS_OUTPUT_FOLDER, shimModuleFormat: 'esnext' }], + nonModuleFileExtensions: ['.global.scss'] + }); + await compileFixtureAsync(processor, 'global-styles.global.scss'); + const shim: string = getJsShimOutput('global-styles.global.scss'); + // ESM non-module shim: side-effect import only + expect(shim).toBe(`import "./global-styles.global.css";export {};`); + }); + + it('does not emit a shim when shimModuleFormat is undefined', async () => { + const { processor } = createProcessor(terminalProvider, { + cssOutputFolders: [{ folder: CSS_OUTPUT_FOLDER, shimModuleFormat: undefined }] + }); + await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); + // Only the CSS and DTS files should be written — no .js shim + const shimPaths: string[] = getAllWrittenPathsMatching('.module.scss.js'); + expect(shimPaths).toHaveLength(0); + }); + + it('writes shims to each configured cssOutputFolder independently', async () => { + const CSS_FOLDER_ESM: string = '/fake/output/css-esm'; + const CSS_FOLDER_CJS: string = '/fake/output/css-cjs'; + const cssOutputFolders: ICssOutputFolder[] = [ + { folder: CSS_FOLDER_ESM, shimModuleFormat: 'esnext' }, + { folder: CSS_FOLDER_CJS, shimModuleFormat: 'commonjs' } + ]; + const { processor } = createProcessor(terminalProvider, { cssOutputFolders }); + await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); + + const esmShim: string = writtenFiles.get(`${CSS_FOLDER_ESM}/classes-and-exports.module.scss.js`)!; + const cjsShim: string = writtenFiles.get(`${CSS_FOLDER_CJS}/classes-and-exports.module.scss.js`)!; + + expect(esmShim).toBe(`export { default } from "./classes-and-exports.module.css";`); + expect(cjsShim).toContain('module.exports.default = module.exports'); + }); + }); + + describe('non-module (global) files', () => { + it('emits plain compiled CSS for a .global.scss file', async () => { + const { processor } = createProcessor(terminalProvider, { + nonModuleFileExtensions: ['.global.scss'] + }); + await compileFixtureAsync(processor, 'global-styles.global.scss'); + const css: string = getCssOutput('global-styles.global.scss'); + // Variables should be resolved; selectors should be present + expect(css).toContain('body'); + expect(css).toContain('h1'); + expect(css).toContain('font-family'); + expect(css).not.toContain('$body-font'); + }); + + it('emits export {}; in the .d.ts for a non-module file', async () => { + const { processor } = createProcessor(terminalProvider, { + nonModuleFileExtensions: ['.global.scss'] + }); + await compileFixtureAsync(processor, 'global-styles.global.scss'); + const dts: string = getDtsOutput('global-styles.global.scss'); + expect(dts).toBe('export {};'); + }); + }); + + describe('multiple output folders', () => { + it('writes .d.ts to every configured dtsOutputFolder', async () => { + const DTS_FOLDER_A: string = '/fake/output/dts-a'; + const DTS_FOLDER_B: string = '/fake/output/dts-b'; + const { processor } = createProcessor(terminalProvider, { + dtsOutputFolders: [DTS_FOLDER_A, DTS_FOLDER_B] + }); + await compileFixtureAsync(processor, 'export-only.module.scss'); + + const dtsA: string = writtenFiles.get(`${DTS_FOLDER_A}/export-only.module.scss.d.ts`)!; + const dtsB: string = writtenFiles.get(`${DTS_FOLDER_B}/export-only.module.scss.d.ts`)!; + + expect(dtsA).toBeDefined(); + expect(dtsA).toEqual(dtsB); + }); + + it('writes CSS to every configured cssOutputFolder', async () => { + const CSS_FOLDER_A: string = '/fake/output/css-a'; + const CSS_FOLDER_B: string = '/fake/output/css-b'; + const { processor } = createProcessor(terminalProvider, { + cssOutputFolders: [ + { folder: CSS_FOLDER_A, shimModuleFormat: undefined }, + { folder: CSS_FOLDER_B, shimModuleFormat: undefined } + ] + }); + await compileFixtureAsync(processor, 'export-only.module.scss'); + + const cssA: string = writtenFiles.get(`${CSS_FOLDER_A}/export-only.module.css`)!; + const cssB: string = writtenFiles.get(`${CSS_FOLDER_B}/export-only.module.css`)!; + + expect(cssA).toBeDefined(); + expect(cssA).toEqual(cssB); + }); + }); + + describe('postProcessCssAsync', () => { + it('passes compiled CSS through the post-processor callback', async () => { + const postProcessed: string[] = []; + const { processor } = createProcessor(terminalProvider, { + postProcessCssAsync: async (css: string) => { + postProcessed.push(css); + return css.replace(/color:/g, 'color: /* post-processed */'); + } + }); + await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); + + // The callback should have been called with the raw CSS + expect(postProcessed.length).toBe(1); + expect(postProcessed[0]).toContain('color:'); + + // The emitted CSS should reflect the transformation + const css: string = getCssOutput('classes-and-exports.module.scss'); + expect(css).toContain('color: /* post-processed */'); + }); + + it('post-processor runs after postcss-modules strips :export', async () => { + const seenCss: string[] = []; + const { processor } = createProcessor(terminalProvider, { + postProcessCssAsync: async (css: string) => { + seenCss.push(css); + return css; + } + }); + await compileFixtureAsync(processor, 'export-only.module.scss'); + + // With preserveIcssExports: false (default), the CSS seen by the callback + // should already have the :export block stripped + expect(seenCss[0]).not.toContain(':export'); + }); + + it('post-processor receives the original CSS including :export when preserveIcssExports is true', async () => { + const seenCss: string[] = []; + const { processor } = createProcessor(terminalProvider, { + preserveIcssExports: true, + postProcessCssAsync: async (css: string) => { + seenCss.push(css); + return css; + } + }); + await compileFixtureAsync(processor, 'export-only.module.scss'); + + expect(seenCss[0]).toContain(':export'); + }); + }); + describe('error reporting', () => { it('emits an error for invalid SCSS syntax', async () => { - const { processor, logger } = createProcessor(terminalProvider, false); + const { processor, logger } = createProcessor(terminalProvider); await compileFixtureAsync(processor, 'invalid.module.scss'); expect(logger.errors.length).toBeGreaterThan(0); }); diff --git a/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap b/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap index 7ff3b1525d5..2a14df22419 100644 --- a/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap +++ b/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap @@ -1,5 +1,179 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`SassProcessor JS shim files does not emit a shim when shimModuleFormat is undefined: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor JS shim files does not emit a shim when shimModuleFormat is undefined: written-files 1`] = ` +Map { + "/fake/output/dts/classes-and-exports.module.scss.d.ts" => "declare interface IStyles { + themeColor: string; + spacing: string; + root: string; + highlighted: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/classes-and-exports.module.css" => ".root { + color: red; + font-size: 14px; +} + +.highlighted { + background-color: yellow; +}", +} +`; + +exports[`SassProcessor JS shim files emits a CommonJS shim for a module file: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor JS shim files emits a CommonJS shim for a module file: written-files 1`] = ` +Map { + "/fake/output/dts/classes-and-exports.module.scss.d.ts" => "declare interface IStyles { + themeColor: string; + spacing: string; + root: string; + highlighted: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/classes-and-exports.module.css" => ".root { + color: red; + font-size: 14px; +} + +.highlighted { + background-color: yellow; +}", + "/fake/output/css/classes-and-exports.module.scss.js" => "module.exports = require(\\"./classes-and-exports.module.css\\"); +module.exports.default = module.exports;", +} +`; + +exports[`SassProcessor JS shim files emits a CommonJS shim for a non-module (global) file: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor JS shim files emits a CommonJS shim for a non-module (global) file: written-files 1`] = ` +Map { + "/fake/output/dts/global-styles.global.scss.d.ts" => "export {};", + "/fake/output/css/global-styles.global.css" => "body { + margin: 0; + padding: 0; + font-family: \\"Segoe UI\\", sans-serif; +} + +h1 { + font-size: 24px; + color: #333; +}", + "/fake/output/css/global-styles.global.scss.js" => "require(\\"./global-styles.global.css\\");", +} +`; + +exports[`SassProcessor JS shim files emits an ESM shim for a module file: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor JS shim files emits an ESM shim for a module file: written-files 1`] = ` +Map { + "/fake/output/dts/classes-and-exports.module.scss.d.ts" => "declare interface IStyles { + themeColor: string; + spacing: string; + root: string; + highlighted: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/classes-and-exports.module.css" => ".root { + color: red; + font-size: 14px; +} + +.highlighted { + background-color: yellow; +}", + "/fake/output/css/classes-and-exports.module.scss.js" => "export { default } from \\"./classes-and-exports.module.css\\";", +} +`; + +exports[`SassProcessor JS shim files emits an ESM shim for a non-module (global) file: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor JS shim files emits an ESM shim for a non-module (global) file: written-files 1`] = ` +Map { + "/fake/output/dts/global-styles.global.scss.d.ts" => "export {};", + "/fake/output/css/global-styles.global.css" => "body { + margin: 0; + padding: 0; + font-family: \\"Segoe UI\\", sans-serif; +} + +h1 { + font-size: 24px; + color: #333; +}", + "/fake/output/css/global-styles.global.scss.js" => "import \\"./global-styles.global.css\\";export {};", +} +`; + +exports[`SassProcessor JS shim files writes shims to each configured cssOutputFolder independently: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor JS shim files writes shims to each configured cssOutputFolder independently: written-files 1`] = ` +Map { + "/fake/output/dts/classes-and-exports.module.scss.d.ts" => "declare interface IStyles { + themeColor: string; + spacing: string; + root: string; + highlighted: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css-esm/classes-and-exports.module.css" => ".root { + color: red; + font-size: 14px; +} + +.highlighted { + background-color: yellow; +}", + "/fake/output/css-esm/classes-and-exports.module.scss.js" => "export { default } from \\"./classes-and-exports.module.css\\";", + "/fake/output/css-cjs/classes-and-exports.module.css" => ".root { + color: red; + font-size: 14px; +} + +.highlighted { + background-color: yellow; +}", + "/fake/output/css-cjs/classes-and-exports.module.scss.js" => "module.exports = require(\\"./classes-and-exports.module.css\\"); +module.exports.default = module.exports;", +} +`; + exports[`SassProcessor classes-and-exports.module.scss generates correct .d.ts with both class names and :export values: terminal-output 1`] = ` Array [ "[verbose] Checking for changes to 1 files...[n]", @@ -28,6 +202,22 @@ export default styles;", } `; +exports[`SassProcessor classes-and-exports.module.scss generates named exports in .d.ts when exportAsDefault is false: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor classes-and-exports.module.scss generates named exports in .d.ts when exportAsDefault is false: written-files 1`] = ` +Map { + "/fake/output/dts/classes-and-exports.module.scss.d.ts" => "export const themeColor: string; +export const spacing: string; +export const root: string; +export const highlighted: string;", +} +`; + exports[`SassProcessor classes-and-exports.module.scss preserves the :export block in CSS when preserveIcssExports is true: terminal-output 1`] = ` Array [ "[verbose] Checking for changes to 1 files...[n]", @@ -386,6 +576,166 @@ export default styles;", } `; +exports[`SassProcessor multiple output folders writes .d.ts to every configured dtsOutputFolder: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor multiple output folders writes .d.ts to every configured dtsOutputFolder: written-files 1`] = ` +Map { + "/fake/output/dts-a/export-only.module.scss.d.ts" => "declare interface IStyles { + primaryColor: string; + fontFamily: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/dts-b/export-only.module.scss.d.ts" => "declare interface IStyles { + primaryColor: string; + fontFamily: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/export-only.module.css" => "", +} +`; + +exports[`SassProcessor multiple output folders writes CSS to every configured cssOutputFolder: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor multiple output folders writes CSS to every configured cssOutputFolder: written-files 1`] = ` +Map { + "/fake/output/dts/export-only.module.scss.d.ts" => "declare interface IStyles { + primaryColor: string; + fontFamily: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css-a/export-only.module.css" => "", + "/fake/output/css-b/export-only.module.css" => "", +} +`; + +exports[`SassProcessor non-module (global) files emits export {}; in the .d.ts for a non-module file: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor non-module (global) files emits export {}; in the .d.ts for a non-module file: written-files 1`] = ` +Map { + "/fake/output/dts/global-styles.global.scss.d.ts" => "export {};", + "/fake/output/css/global-styles.global.css" => "body { + margin: 0; + padding: 0; + font-family: \\"Segoe UI\\", sans-serif; +} + +h1 { + font-size: 24px; + color: #333; +}", +} +`; + +exports[`SassProcessor non-module (global) files emits plain compiled CSS for a .global.scss file: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor non-module (global) files emits plain compiled CSS for a .global.scss file: written-files 1`] = ` +Map { + "/fake/output/dts/global-styles.global.scss.d.ts" => "export {};", + "/fake/output/css/global-styles.global.css" => "body { + margin: 0; + padding: 0; + font-family: \\"Segoe UI\\", sans-serif; +} + +h1 { + font-size: 24px; + color: #333; +}", +} +`; + +exports[`SassProcessor postProcessCssAsync passes compiled CSS through the post-processor callback: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor postProcessCssAsync passes compiled CSS through the post-processor callback: written-files 1`] = ` +Map { + "/fake/output/dts/classes-and-exports.module.scss.d.ts" => "declare interface IStyles { + themeColor: string; + spacing: string; + root: string; + highlighted: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/classes-and-exports.module.css" => ".root { + color: /* post-processed */ red; + font-size: 14px; +} + +.highlighted { + background-color: /* post-processed */ yellow; +}", +} +`; + +exports[`SassProcessor postProcessCssAsync post-processor receives the original CSS including :export when preserveIcssExports is true: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor postProcessCssAsync post-processor receives the original CSS including :export when preserveIcssExports is true: written-files 1`] = ` +Map { + "/fake/output/dts/export-only.module.scss.d.ts" => "declare interface IStyles { + primaryColor: string; + fontFamily: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/export-only.module.css" => ":export { + primaryColor: #0078d4; + fontFamily: \\"Segoe UI\\"; +}", +} +`; + +exports[`SassProcessor postProcessCssAsync post-processor runs after postcss-modules strips :export: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor postProcessCssAsync post-processor runs after postcss-modules strips :export: written-files 1`] = ` +Map { + "/fake/output/dts/export-only.module.scss.d.ts" => "declare interface IStyles { + primaryColor: string; + fontFamily: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/export-only.module.css" => "", +} +`; + exports[`SassProcessor sass-variables-and-exports.module.scss (Sass variables, nesting, BEM) generates .d.ts with resolved :export keys as typed properties: terminal-output 1`] = ` Array [ "[verbose] Checking for changes to 1 files...[n]", diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/global-styles.global.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/global-styles.global.scss new file mode 100644 index 00000000000..bd77ea5d649 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/global-styles.global.scss @@ -0,0 +1,15 @@ +// Non-module global stylesheet — processed as plain CSS, not a CSS module. +// Used to verify that global files produce no class exports and generate correct shims. +$body-font: 'Segoe UI', sans-serif; +$heading-color: #333; + +body { + margin: 0; + padding: 0; + font-family: $body-font; +} + +h1 { + font-size: 24px; + color: $heading-color; +} From ff73c70fe7979958e530354b33e3f0fba10f3d36 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Thu, 9 Apr 2026 12:59:17 -0700 Subject: [PATCH 06/10] [heft-sass-plugin] Use well-known path instead of PackageJsonLookup in tests Co-Authored-By: Claude Sonnet 4.6 --- .../heft-sass-plugin/src/test/SassProcessor.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts index 55214728c78..1562e58ecd5 100644 --- a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts +++ b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts @@ -1,14 +1,14 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { FileSystem, PackageJsonLookup } from '@rushstack/node-core-library'; +import { FileSystem } from '@rushstack/node-core-library'; import { MockScopedLogger } from '@rushstack/heft/lib/pluginFramework/logging/MockScopedLogger'; import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal'; import { type ICssOutputFolder, type ISassProcessorOptions, SassProcessor } from '../SassProcessor'; -const projectFolder: string = PackageJsonLookup.instance.tryGetPackageFolderFor(__dirname)!; -const fixturesFolder: string = `${projectFolder}/src/test/fixtures`; +const projectFolder: string = `${__dirname}/../..`; +const fixturesFolder: string = `${__dirname}/fixtures`; // Fake output folder paths — never actually written to disk because FileSystem.writeFileAsync is mocked. const CSS_OUTPUT_FOLDER: string = '/fake/output/css'; From 89665b5d239b5e63b4630aef51cfe21e96549ad6 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Thu, 9 Apr 2026 13:14:36 -0700 Subject: [PATCH 07/10] [heft-sass-plugin] Add unit tests for doNotTrimOriginalFileExtension option Co-Authored-By: Claude Sonnet 4.6 --- .../src/test/SassProcessor.test.ts | 55 ++++++++- .../__snapshots__/SassProcessor.test.ts.snap | 116 ++++++++++++++++++ 2 files changed, 169 insertions(+), 2 deletions(-) diff --git a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts index 1562e58ecd5..ed148cccbaf 100644 --- a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts +++ b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts @@ -1,14 +1,16 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import path from 'node:path'; + import { FileSystem } from '@rushstack/node-core-library'; import { MockScopedLogger } from '@rushstack/heft/lib/pluginFramework/logging/MockScopedLogger'; import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal'; import { type ICssOutputFolder, type ISassProcessorOptions, SassProcessor } from '../SassProcessor'; -const projectFolder: string = `${__dirname}/../..`; -const fixturesFolder: string = `${__dirname}/fixtures`; +const projectFolder: string = path.resolve(__dirname, '../..'); +const fixturesFolder: string = path.resolve(__dirname, '../../src/test/fixtures'); // Fake output folder paths — never actually written to disk because FileSystem.writeFileAsync is mocked. const CSS_OUTPUT_FOLDER: string = '/fake/output/css'; @@ -18,6 +20,7 @@ type ICreateProcessorOptions = Partial< Pick< ISassProcessorOptions, | 'cssOutputFolders' + | 'doNotTrimOriginalFileExtension' | 'dtsOutputFolders' | 'exportAsDefault' | 'fileExtensions' @@ -467,6 +470,54 @@ describe(SassProcessor.name, () => { }); }); + describe('doNotTrimOriginalFileExtension', () => { + it('strips the source extension by default (doNotTrimOriginalFileExtension: false)', async () => { + const { processor } = createProcessor(terminalProvider); + await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); + // Default: "classes-and-exports.module.scss" → "classes-and-exports.module.css" + const css: string = writtenFiles.get(`${CSS_OUTPUT_FOLDER}/classes-and-exports.module.css`)!; + expect(css).toBeDefined(); + expect(writtenFiles.has(`${CSS_OUTPUT_FOLDER}/classes-and-exports.module.scss.css`)).toBe(false); + }); + + it('preserves the source extension when doNotTrimOriginalFileExtension is true', async () => { + const { processor } = createProcessor(terminalProvider, { doNotTrimOriginalFileExtension: true }); + await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); + // "classes-and-exports.module.scss" → "classes-and-exports.module.scss.css" + const css: string = writtenFiles.get(`${CSS_OUTPUT_FOLDER}/classes-and-exports.module.scss.css`)!; + expect(css).toBeDefined(); + expect(writtenFiles.has(`${CSS_OUTPUT_FOLDER}/classes-and-exports.module.css`)).toBe(false); + }); + + it('uses the .scss.css filename in JS shims when doNotTrimOriginalFileExtension is true', async () => { + const { processor } = createProcessor(terminalProvider, { + doNotTrimOriginalFileExtension: true, + cssOutputFolders: [{ folder: CSS_OUTPUT_FOLDER, shimModuleFormat: 'commonjs' }] + }); + await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); + const shim: string = writtenFiles.get(`${CSS_OUTPUT_FOLDER}/classes-and-exports.module.scss.js`)!; + expect(shim).toContain(`require("./classes-and-exports.module.scss.css")`); + }); + + it('the CSS content is the same regardless of doNotTrimOriginalFileExtension', async () => { + const { processor: processorDefault } = createProcessor(terminalProvider); + await compileFixtureAsync(processorDefault, 'classes-and-exports.module.scss'); + const cssDefault: string = writtenFiles.get(`${CSS_OUTPUT_FOLDER}/classes-and-exports.module.css`)!; + + writtenFiles.clear(); + + const { processor: processorPreserve } = createProcessor(terminalProvider, { + doNotTrimOriginalFileExtension: true + }); + await compileFixtureAsync(processorPreserve, 'classes-and-exports.module.scss'); + const cssPreserve: string = writtenFiles.get( + `${CSS_OUTPUT_FOLDER}/classes-and-exports.module.scss.css` + )!; + + expect(cssDefault).toEqual(cssPreserve); + }); + }); + describe('error reporting', () => { it('emits an error for invalid SCSS syntax', async () => { const { processor, logger } = createProcessor(terminalProvider); diff --git a/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap b/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap index 2a14df22419..8ef7e174a5f 100644 --- a/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap +++ b/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap @@ -279,6 +279,122 @@ export default styles;", } `; +exports[`SassProcessor doNotTrimOriginalFileExtension preserves the source extension when doNotTrimOriginalFileExtension is true: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor doNotTrimOriginalFileExtension preserves the source extension when doNotTrimOriginalFileExtension is true: written-files 1`] = ` +Map { + "/fake/output/dts/classes-and-exports.module.scss.d.ts" => "declare interface IStyles { + themeColor: string; + spacing: string; + root: string; + highlighted: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/classes-and-exports.module.scss.css" => ".root { + color: red; + font-size: 14px; +} + +.highlighted { + background-color: yellow; +}", +} +`; + +exports[`SassProcessor doNotTrimOriginalFileExtension strips the source extension by default (doNotTrimOriginalFileExtension: false): terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor doNotTrimOriginalFileExtension strips the source extension by default (doNotTrimOriginalFileExtension: false): written-files 1`] = ` +Map { + "/fake/output/dts/classes-and-exports.module.scss.d.ts" => "declare interface IStyles { + themeColor: string; + spacing: string; + root: string; + highlighted: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/classes-and-exports.module.css" => ".root { + color: red; + font-size: 14px; +} + +.highlighted { + background-color: yellow; +}", +} +`; + +exports[`SassProcessor doNotTrimOriginalFileExtension the CSS content is the same regardless of doNotTrimOriginalFileExtension: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor doNotTrimOriginalFileExtension the CSS content is the same regardless of doNotTrimOriginalFileExtension: written-files 1`] = ` +Map { + "/fake/output/dts/classes-and-exports.module.scss.d.ts" => "declare interface IStyles { + themeColor: string; + spacing: string; + root: string; + highlighted: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/classes-and-exports.module.scss.css" => ".root { + color: red; + font-size: 14px; +} + +.highlighted { + background-color: yellow; +}", +} +`; + +exports[`SassProcessor doNotTrimOriginalFileExtension uses the .scss.css filename in JS shims when doNotTrimOriginalFileExtension is true: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor doNotTrimOriginalFileExtension uses the .scss.css filename in JS shims when doNotTrimOriginalFileExtension is true: written-files 1`] = ` +Map { + "/fake/output/dts/classes-and-exports.module.scss.d.ts" => "declare interface IStyles { + themeColor: string; + spacing: string; + root: string; + highlighted: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/classes-and-exports.module.scss.css" => ".root { + color: red; + font-size: 14px; +} + +.highlighted { + background-color: yellow; +}", + "/fake/output/css/classes-and-exports.module.scss.js" => "module.exports = require(\\"./classes-and-exports.module.scss.css\\"); +module.exports.default = module.exports;", +} +`; + exports[`SassProcessor error reporting emits an error for invalid SCSS syntax: terminal-output 1`] = ` Array [ "[verbose] Checking for changes to 1 files...[n]", From ce1b26c19f3928c1ba88b50ca3a94db739195422 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Thu, 9 Apr 2026 13:42:59 -0700 Subject: [PATCH 08/10] [heft-sass-plugin] Fix Windows path separators in SassProcessor tests On Windows, path.resolve('/fake/output/css', ...) prepends the drive letter and uses backslashes. Normalize paths in the FileSystem mock to forward slashes with no drive letter so that map lookups and snapshots are consistent across platforms. Co-Authored-By: Claude Sonnet 4.6 --- heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts index ed148cccbaf..a5794f6a2c2 100644 --- a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts +++ b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts @@ -102,7 +102,10 @@ describe(SassProcessor.name, () => { writtenFiles = new Map(); jest.spyOn(FileSystem, 'writeFileAsync').mockImplementation(async (filePath, content) => { - writtenFiles.set(filePath as string, content as string); + // Normalize to forward slashes and strip any Windows drive letter (e.g. "C:") so that + // path lookups and snapshots are consistent across platforms. + const normalizedPath: string = (filePath as string).replace(/\\/g, '/').replace(/^[A-Z]:/, ''); + writtenFiles.set(normalizedPath, content as string); }); }); From 9586326801bf84eb24dde2d190cd2377f148ea33 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Thu, 9 Apr 2026 13:45:01 -0700 Subject: [PATCH 09/10] Revert "[heft-sass-plugin] Fix Windows path separators in SassProcessor tests" This reverts commit ce1b26c19f3928c1ba88b50ca3a94db739195422. --- heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts index a5794f6a2c2..ed148cccbaf 100644 --- a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts +++ b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts @@ -102,10 +102,7 @@ describe(SassProcessor.name, () => { writtenFiles = new Map(); jest.spyOn(FileSystem, 'writeFileAsync').mockImplementation(async (filePath, content) => { - // Normalize to forward slashes and strip any Windows drive letter (e.g. "C:") so that - // path lookups and snapshots are consistent across platforms. - const normalizedPath: string = (filePath as string).replace(/\\/g, '/').replace(/^[A-Z]:/, ''); - writtenFiles.set(normalizedPath, content as string); + writtenFiles.set(filePath as string, content as string); }); }); From d547fc072e5809eeaa260cbaace2531a015488f2 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Thu, 9 Apr 2026 00:06:17 -0700 Subject: [PATCH 10/10] Fix tests on Windows. --- .../src/test/SassProcessor.test.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts index ed148cccbaf..6f9ca801125 100644 --- a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts +++ b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts @@ -2,8 +2,9 @@ // See LICENSE in the project root for license information. import path from 'node:path'; +import nodeJsPath from 'node:path'; -import { FileSystem } from '@rushstack/node-core-library'; +import { FileSystem, Path } from '@rushstack/node-core-library'; import { MockScopedLogger } from '@rushstack/heft/lib/pluginFramework/logging/MockScopedLogger'; import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal'; @@ -13,8 +14,12 @@ const projectFolder: string = path.resolve(__dirname, '../..'); const fixturesFolder: string = path.resolve(__dirname, '../../src/test/fixtures'); // Fake output folder paths — never actually written to disk because FileSystem.writeFileAsync is mocked. -const CSS_OUTPUT_FOLDER: string = '/fake/output/css'; -const DTS_OUTPUT_FOLDER: string = '/fake/output/dts'; +const FAKE_OUTPUT_BASE_FOLDER: string = '/fake/output'; +const NORMALIZED_PLATFORM_FAKE_OUTPUT_BASE_FOLDER: string = Path.convertToSlashes( + nodeJsPath.resolve(FAKE_OUTPUT_BASE_FOLDER) +); +const CSS_OUTPUT_FOLDER: string = `${FAKE_OUTPUT_BASE_FOLDER}/css`; +const DTS_OUTPUT_FOLDER: string = `${FAKE_OUTPUT_BASE_FOLDER}/dts`; type ICreateProcessorOptions = Partial< Pick< @@ -102,7 +107,11 @@ describe(SassProcessor.name, () => { writtenFiles = new Map(); jest.spyOn(FileSystem, 'writeFileAsync').mockImplementation(async (filePath, content) => { - writtenFiles.set(filePath as string, content as string); + filePath = Path.convertToSlashes(filePath).replace( + NORMALIZED_PLATFORM_FAKE_OUTPUT_BASE_FOLDER, + FAKE_OUTPUT_BASE_FOLDER + ); + writtenFiles.set(filePath, String(content)); }); });