Skip to content

Commit 5eb1c54

Browse files
committed
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.
1 parent 2f6a530 commit 5eb1c54

File tree

3 files changed

+419
-131
lines changed

3 files changed

+419
-131
lines changed

heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts

Lines changed: 92 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,18 @@ import { SassProcessor } from '../SassProcessor';
99

1010
const projectFolder: string = PackageJsonLookup.instance.tryGetPackageFolderFor(__dirname)!;
1111
const fixturesFolder: string = `${projectFolder}/src/test/fixtures`;
12-
const testOutputFolder: string = `${projectFolder}/temp/test-output`;
1312

14-
function createProcessor(preserveIcssExports: boolean): {
13+
// Fake output folder paths — never actually written to disk because FileSystem.writeFileAsync is mocked.
14+
const CSS_OUTPUT_FOLDER: string = '/fake/output/css';
15+
const DTS_OUTPUT_FOLDER: string = '/fake/output/dts';
16+
17+
function createProcessor(
18+
terminalProvider: StringBufferTerminalProvider,
19+
preserveIcssExports: boolean
20+
): {
1521
processor: SassProcessor;
16-
dtsOutputFolder: string;
17-
cssOutputFolder: string;
1822
logger: MockScopedLogger;
1923
} {
20-
const suffix: string = preserveIcssExports ? 'preserve' : 'strip';
21-
const dtsOutputFolder: string = `${testOutputFolder}/${suffix}/dts`;
22-
const cssOutputFolder: string = `${testOutputFolder}/${suffix}/css`;
23-
24-
const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(false);
2524
const terminal: Terminal = new Terminal(terminalProvider);
2625
const logger: MockScopedLogger = new MockScopedLogger(terminal);
2726

@@ -30,91 +29,115 @@ function createProcessor(preserveIcssExports: boolean): {
3029
buildFolder: projectFolder,
3130
concurrency: 1,
3231
srcFolder: fixturesFolder,
33-
dtsOutputFolders: [dtsOutputFolder],
34-
cssOutputFolders: [{ folder: cssOutputFolder, shimModuleFormat: undefined }],
32+
dtsOutputFolders: [DTS_OUTPUT_FOLDER],
33+
cssOutputFolders: [{ folder: CSS_OUTPUT_FOLDER, shimModuleFormat: undefined }],
3534
exportAsDefault: true,
3635
preserveIcssExports
3736
});
3837

39-
return { processor, dtsOutputFolder, cssOutputFolder, logger };
38+
return { processor, logger };
4039
}
4140

4241
async function compileFixtureAsync(processor: SassProcessor, fixtureFilename: string): Promise<void> {
43-
const absolutePath: string = `${fixturesFolder}/${fixtureFilename}`;
44-
await processor.compileFilesAsync(new Set([absolutePath]));
42+
await processor.compileFilesAsync(new Set([`${fixturesFolder}/${fixtureFilename}`]));
4543
}
4644

47-
async function readCssOutputAsync(cssOutputFolder: string, fixtureFilename: string): Promise<string> {
48-
// Strip last extension (.scss/.sass), append .css
49-
const withoutExt: string = fixtureFilename.slice(0, fixtureFilename.lastIndexOf('.'));
50-
return await FileSystem.readFileAsync(`${cssOutputFolder}/${withoutExt}.css`);
51-
}
45+
describe(SassProcessor.name, () => {
46+
let terminalProvider: StringBufferTerminalProvider;
47+
/** Files captured by the mocked FileSystem.writeFileAsync, keyed by absolute path. */
48+
let writtenFiles: Map<string, string>;
49+
50+
/** Returns the content written to a path whose last segment matches the given filename. */
51+
function getWrittenFile(filename: string): string {
52+
for (const [filePath, content] of writtenFiles) {
53+
if (filePath.endsWith(`/${filename}`)) {
54+
return content;
55+
}
56+
}
5257

53-
async function readDtsOutputAsync(dtsOutputFolder: string, fixtureFilename: string): Promise<string> {
54-
return await FileSystem.readFileAsync(`${dtsOutputFolder}/${fixtureFilename}.d.ts`);
55-
}
58+
throw new Error(
59+
`No file written matching ".../${filename}". Written paths:\n${[...writtenFiles.keys()].join('\n')}`
60+
);
61+
}
5662

57-
describe(SassProcessor.name, () => {
58-
beforeEach(async () => {
59-
await FileSystem.ensureEmptyFolderAsync(testOutputFolder);
63+
function getCssOutput(fixtureFilename: string): string {
64+
// SassProcessor strips the last extension then appends .css
65+
// export-only.module.scss → export-only.module.css
66+
const withoutExt: string = fixtureFilename.slice(0, fixtureFilename.lastIndexOf('.'));
67+
return getWrittenFile(`${withoutExt}.css`);
68+
}
69+
70+
function getDtsOutput(fixtureFilename: string): string {
71+
return getWrittenFile(`${fixtureFilename}.d.ts`);
72+
}
73+
74+
beforeEach(() => {
75+
terminalProvider = new StringBufferTerminalProvider();
76+
77+
writtenFiles = new Map();
78+
jest.spyOn(FileSystem, 'writeFileAsync').mockImplementation(async (filePath, content) => {
79+
writtenFiles.set(filePath as string, content as string);
80+
});
81+
});
82+
83+
afterEach(() => {
84+
jest.restoreAllMocks();
85+
86+
expect(writtenFiles).toMatchSnapshot('written-files');
87+
expect(terminalProvider.getAllOutputAsChunks({ asLines: true })).toMatchSnapshot('terminal-output');
6088
});
6189

6290
describe('export-only.module.scss', () => {
6391
it('strips the :export block from CSS when preserveIcssExports is false', async () => {
64-
const { processor, cssOutputFolder } = createProcessor(false);
92+
const { processor } = createProcessor(terminalProvider, false);
6593
await compileFixtureAsync(processor, 'export-only.module.scss');
66-
const css: string = await readCssOutputAsync(cssOutputFolder, 'export-only.module.scss');
67-
expect(css).toMatchSnapshot();
94+
const css: string = getCssOutput('export-only.module.scss');
6895
expect(css).not.toContain(':export');
6996
});
7097

7198
it('preserves the :export block in CSS when preserveIcssExports is true', async () => {
72-
const { processor, cssOutputFolder } = createProcessor(true);
99+
const { processor } = createProcessor(terminalProvider, true);
73100
await compileFixtureAsync(processor, 'export-only.module.scss');
74-
const css: string = await readCssOutputAsync(cssOutputFolder, 'export-only.module.scss');
75-
expect(css).toMatchSnapshot();
101+
const css: string = getCssOutput('export-only.module.scss');
76102
expect(css).toContain(':export');
77103
});
78104

79105
it('generates the same .d.ts regardless of preserveIcssExports', async () => {
80-
const { processor: processorFalse, dtsOutputFolder: dtsFalseFolder } = createProcessor(false);
81-
const { processor: processorTrue, dtsOutputFolder: dtsTrueFolder } = createProcessor(true);
82-
106+
const { processor: processorFalse } = createProcessor(terminalProvider, false);
83107
await compileFixtureAsync(processorFalse, 'export-only.module.scss');
84-
await compileFixtureAsync(processorTrue, 'export-only.module.scss');
108+
const dtsFalse: string = getDtsOutput('export-only.module.scss');
109+
110+
writtenFiles.clear();
85111

86-
const dtsFalse: string = await readDtsOutputAsync(dtsFalseFolder, 'export-only.module.scss');
87-
const dtsTrue: string = await readDtsOutputAsync(dtsTrueFolder, 'export-only.module.scss');
112+
const { processor: processorTrue } = createProcessor(terminalProvider, true);
113+
await compileFixtureAsync(processorTrue, 'export-only.module.scss');
114+
const dtsTrue: string = getDtsOutput('export-only.module.scss');
88115

89-
expect(dtsFalse).toMatchSnapshot();
90116
expect(dtsFalse).toEqual(dtsTrue);
91117
});
92118
});
93119

94120
describe('classes-and-exports.module.scss', () => {
95121
it('strips the :export block from CSS when preserveIcssExports is false', async () => {
96-
const { processor, cssOutputFolder } = createProcessor(false);
122+
const { processor } = createProcessor(terminalProvider, false);
97123
await compileFixtureAsync(processor, 'classes-and-exports.module.scss');
98-
const css: string = await readCssOutputAsync(cssOutputFolder, 'classes-and-exports.module.scss');
99-
expect(css).toMatchSnapshot();
124+
const css: string = getCssOutput('classes-and-exports.module.scss');
100125
expect(css).not.toContain(':export');
101126
expect(css).toContain('.root');
102127
});
103128

104129
it('preserves the :export block in CSS when preserveIcssExports is true', async () => {
105-
const { processor, cssOutputFolder } = createProcessor(true);
130+
const { processor } = createProcessor(terminalProvider, true);
106131
await compileFixtureAsync(processor, 'classes-and-exports.module.scss');
107-
const css: string = await readCssOutputAsync(cssOutputFolder, 'classes-and-exports.module.scss');
108-
expect(css).toMatchSnapshot();
132+
const css: string = getCssOutput('classes-and-exports.module.scss');
109133
expect(css).toContain(':export');
110134
expect(css).toContain('.root');
111135
});
112136

113137
it('generates correct .d.ts with both class names and :export values', async () => {
114-
const { processor, dtsOutputFolder } = createProcessor(false);
138+
const { processor } = createProcessor(terminalProvider, false);
115139
await compileFixtureAsync(processor, 'classes-and-exports.module.scss');
116-
const dts: string = await readDtsOutputAsync(dtsOutputFolder, 'classes-and-exports.module.scss');
117-
expect(dts).toMatchSnapshot();
140+
const dts: string = getDtsOutput('classes-and-exports.module.scss');
118141
expect(dts).toContain('root');
119142
expect(dts).toContain('highlighted');
120143
expect(dts).toContain('themeColor');
@@ -124,10 +147,9 @@ describe(SassProcessor.name, () => {
124147

125148
describe('sass-variables-and-exports.module.scss (Sass variables, nesting, BEM)', () => {
126149
it('resolves Sass variables and expands nested rules in CSS output', async () => {
127-
const { processor, cssOutputFolder } = createProcessor(false);
150+
const { processor } = createProcessor(terminalProvider, false);
128151
await compileFixtureAsync(processor, 'sass-variables-and-exports.module.scss');
129-
const css: string = await readCssOutputAsync(cssOutputFolder, 'sass-variables-and-exports.module.scss');
130-
expect(css).toMatchSnapshot();
152+
const css: string = getCssOutput('sass-variables-and-exports.module.scss');
131153
// Sass variables should be resolved to literal values
132154
expect(css).toContain('#0078d4');
133155
expect(css).toContain('#106ebe');
@@ -139,21 +161,19 @@ describe(SassProcessor.name, () => {
139161
});
140162

141163
it('resolves Sass variables inside the :export block when preserveIcssExports is true', async () => {
142-
const { processor, cssOutputFolder } = createProcessor(true);
164+
const { processor } = createProcessor(terminalProvider, true);
143165
await compileFixtureAsync(processor, 'sass-variables-and-exports.module.scss');
144-
const css: string = await readCssOutputAsync(cssOutputFolder, 'sass-variables-and-exports.module.scss');
145-
expect(css).toMatchSnapshot();
146-
// The :export block should contain the resolved variable values, not the variable names
166+
const css: string = getCssOutput('sass-variables-and-exports.module.scss');
167+
// The :export block should contain resolved values, not Sass variable names
147168
expect(css).toContain(':export');
148169
expect(css).toContain('#0078d4');
149170
expect(css).not.toContain('$primary-color');
150171
});
151172

152173
it('generates .d.ts with resolved :export keys as typed properties', async () => {
153-
const { processor, dtsOutputFolder } = createProcessor(false);
174+
const { processor } = createProcessor(terminalProvider, false);
154175
await compileFixtureAsync(processor, 'sass-variables-and-exports.module.scss');
155-
const dts: string = await readDtsOutputAsync(dtsOutputFolder, 'sass-variables-and-exports.module.scss');
156-
expect(dts).toMatchSnapshot();
176+
const dts: string = getDtsOutput('sass-variables-and-exports.module.scss');
157177
expect(dts).toContain('container');
158178
expect(dts).toContain('primaryColor');
159179
expect(dts).toContain('secondaryColor');
@@ -163,10 +183,9 @@ describe(SassProcessor.name, () => {
163183

164184
describe('mixin-with-exports.module.scss (Sass @mixin)', () => {
165185
it('expands @mixin calls in CSS output', async () => {
166-
const { processor, cssOutputFolder } = createProcessor(false);
186+
const { processor } = createProcessor(terminalProvider, false);
167187
await compileFixtureAsync(processor, 'mixin-with-exports.module.scss');
168-
const css: string = await readCssOutputAsync(cssOutputFolder, 'mixin-with-exports.module.scss');
169-
expect(css).toMatchSnapshot();
188+
const css: string = getCssOutput('mixin-with-exports.module.scss');
170189
// Mixin output should be inlined — no @mixin or @include in the output
171190
expect(css).not.toContain('@mixin');
172191
expect(css).not.toContain('@include');
@@ -176,20 +195,18 @@ describe(SassProcessor.name, () => {
176195
});
177196

178197
it('preserves :export alongside expanded @mixin output when preserveIcssExports is true', async () => {
179-
const { processor, cssOutputFolder } = createProcessor(true);
198+
const { processor } = createProcessor(terminalProvider, true);
180199
await compileFixtureAsync(processor, 'mixin-with-exports.module.scss');
181-
const css: string = await readCssOutputAsync(cssOutputFolder, 'mixin-with-exports.module.scss');
182-
expect(css).toMatchSnapshot();
200+
const css: string = getCssOutput('mixin-with-exports.module.scss');
183201
expect(css).toContain(':export');
184202
expect(css).toContain('display: flex');
185203
expect(css).not.toContain('@mixin');
186204
});
187205

188206
it('generates .d.ts with :export values and class names from @mixin-using file', async () => {
189-
const { processor, dtsOutputFolder } = createProcessor(false);
207+
const { processor } = createProcessor(terminalProvider, false);
190208
await compileFixtureAsync(processor, 'mixin-with-exports.module.scss');
191-
const dts: string = await readDtsOutputAsync(dtsOutputFolder, 'mixin-with-exports.module.scss');
192-
expect(dts).toMatchSnapshot();
209+
const dts: string = getDtsOutput('mixin-with-exports.module.scss');
193210
expect(dts).toContain('card');
194211
expect(dts).toContain('cardRadius');
195212
expect(dts).toContain('animationDuration');
@@ -198,33 +215,29 @@ describe(SassProcessor.name, () => {
198215

199216
describe('extend-with-exports.module.scss (Sass @extend / placeholder selectors)', () => {
200217
it('merges @extend selectors and strips :export when preserveIcssExports is false', async () => {
201-
const { processor, cssOutputFolder } = createProcessor(false);
218+
const { processor } = createProcessor(terminalProvider, false);
202219
await compileFixtureAsync(processor, 'extend-with-exports.module.scss');
203-
const css: string = await readCssOutputAsync(cssOutputFolder, 'extend-with-exports.module.scss');
204-
expect(css).toMatchSnapshot();
205-
// Placeholder %button-base should not appear literally; its rules should be merged into the
206-
// selectors that @extend it
220+
const css: string = getCssOutput('extend-with-exports.module.scss');
221+
// Placeholder %button-base should not appear literally; its rules should be merged
207222
expect(css).not.toContain('%button-base');
208223
expect(css).toContain('.primaryButton');
209224
expect(css).toContain('.dangerButton');
210225
expect(css).not.toContain(':export');
211226
});
212227

213228
it('preserves :export alongside @extend-merged output when preserveIcssExports is true', async () => {
214-
const { processor, cssOutputFolder } = createProcessor(true);
229+
const { processor } = createProcessor(terminalProvider, true);
215230
await compileFixtureAsync(processor, 'extend-with-exports.module.scss');
216-
const css: string = await readCssOutputAsync(cssOutputFolder, 'extend-with-exports.module.scss');
217-
expect(css).toMatchSnapshot();
231+
const css: string = getCssOutput('extend-with-exports.module.scss');
218232
expect(css).toContain(':export');
219233
expect(css).toContain('.primaryButton');
220234
expect(css).not.toContain('%button-base');
221235
});
222236

223237
it('generates .d.ts with class names and :export values for @extend file', async () => {
224-
const { processor, dtsOutputFolder } = createProcessor(false);
238+
const { processor } = createProcessor(terminalProvider, false);
225239
await compileFixtureAsync(processor, 'extend-with-exports.module.scss');
226-
const dts: string = await readDtsOutputAsync(dtsOutputFolder, 'extend-with-exports.module.scss');
227-
expect(dts).toMatchSnapshot();
240+
const dts: string = getDtsOutput('extend-with-exports.module.scss');
228241
expect(dts).toContain('primaryButton');
229242
expect(dts).toContain('dangerButton');
230243
expect(dts).toContain('colorPrimary');
@@ -234,17 +247,8 @@ describe(SassProcessor.name, () => {
234247

235248
describe('error reporting', () => {
236249
it('emits an error for invalid SCSS syntax', async () => {
237-
// Write a temporary invalid fixture to disk, compile it, then clean up.
238-
const invalidFixturePath: string = `${fixturesFolder}/invalid.module.scss`;
239-
await FileSystem.writeFileAsync(invalidFixturePath, '.broken { color: ; }');
240-
241-
const { processor, logger } = createProcessor(false);
242-
try {
243-
await processor.compileFilesAsync(new Set([invalidFixturePath]));
244-
} finally {
245-
await FileSystem.deleteFileAsync(invalidFixturePath);
246-
}
247-
250+
const { processor, logger } = createProcessor(terminalProvider, false);
251+
await compileFixtureAsync(processor, 'invalid.module.scss');
248252
expect(logger.errors.length).toBeGreaterThan(0);
249253
});
250254
});

0 commit comments

Comments
 (0)