Skip to content

Commit 5a3b590

Browse files
committed
wip
1 parent 615dc2c commit 5a3b590

File tree

5 files changed

+258
-10
lines changed

5 files changed

+258
-10
lines changed

packages/codegen/bin/cmk.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ try {
2626
process.exit(0);
2727
}
2828

29+
// Normal mode and watch mode behave differently when errors occur.
30+
// - Normal mode: Outputs errors to the terminal and exits the process with exit code 1.
31+
// - Watch mode: Outputs errors to the terminal but does not terminate the process. Continues watching the file.
2932
if (args.watch) {
3033
const watcher = await runCMKInWatchMode(args, logger);
3134
process.on('SIGINT', () => watcher.close());

packages/codegen/src/error.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,9 @@ export class ReadCSSModuleFileError extends SystemError {
1717
super('READ_CSS_MODULE_FILE_ERROR', `Failed to read CSS Module file ${fileName}.`, cause);
1818
}
1919
}
20+
21+
export class WatchInitializationError extends SystemError {
22+
constructor(cause: unknown) {
23+
super('WATCH_INITIALIZATION_ERROR', `Failed to initialize file watcher.`, cause);
24+
}
25+
}

packages/codegen/src/project.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ interface ProjectArgs {
1919
project: string;
2020
}
2121

22-
interface Project {
22+
export interface Project {
2323
config: CMKConfig;
2424
/** Whether the file matches the wildcard patterns in `include` / `exclude` options */
2525
isWildcardMatchedFile(fileName: string): boolean;

packages/codegen/src/runner.test.ts

Lines changed: 208 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { access, writeFile } from 'node:fs/promises';
1+
import { access, rm, writeFile } from 'node:fs/promises';
22
import dedent from 'dedent';
3-
import { describe, expect, test } from 'vitest';
4-
import { runCMK } from './runner.js';
3+
import { afterEach, describe, expect, test, vi } from 'vitest';
4+
import type { Watcher } from './runner.js';
5+
import { runCMK, runCMKInWatchMode } from './runner.js';
56
import { formatDiagnostics } from './test/diagnostic.js';
67
import { fakeParsedArgs } from './test/faker.js';
78
import { createIFF } from './test/fixture.js';
@@ -95,4 +96,207 @@ describe('runCMK', () => {
9596
});
9697
});
9798

98-
describe.todo('runCMKInWatchMode', () => {});
99+
describe('runCMKInWatchMode', () => {
100+
let watcher: Watcher | null = null;
101+
afterEach(async () => {
102+
if (watcher) {
103+
await watcher.close();
104+
// eslint-disable-next-line require-atomic-updates
105+
watcher = null;
106+
}
107+
});
108+
test('emits .d.ts files', async () => {
109+
const iff = await createIFF({
110+
'tsconfig.json': dedent`
111+
{
112+
"cmkOptions": { "dtsOutDir": "generated" }
113+
}
114+
`,
115+
'src/a.module.css': '.a_1 { color: red; }',
116+
'src/b.module.css': '.b_1 { color: blue; }',
117+
});
118+
watcher = await runCMKInWatchMode(fakeParsedArgs({ project: iff.rootDir }), createLoggerSpy());
119+
expect(await iff.readFile('generated/src/a.module.css.d.ts')).toMatchInlineSnapshot(`
120+
"// @ts-nocheck
121+
declare const styles = {
122+
a_1: '' as readonly string,
123+
};
124+
export default styles;
125+
"
126+
`);
127+
expect(await iff.readFile('generated/src/b.module.css.d.ts')).toMatchInlineSnapshot(`
128+
"// @ts-nocheck
129+
declare const styles = {
130+
b_1: '' as readonly string,
131+
};
132+
export default styles;
133+
"
134+
`);
135+
});
136+
test('reports diagnostics if errors are found', async () => {
137+
const iff = await createIFF({
138+
'tsconfig.json': '{}',
139+
'src/a.module.css': '.a_1 {',
140+
'src/b.module.css': '.b_1 { color: red; }',
141+
});
142+
const loggerSpy = createLoggerSpy();
143+
watcher = await runCMKInWatchMode(fakeParsedArgs({ project: iff.rootDir }), loggerSpy);
144+
expect(loggerSpy.logDiagnostics).toHaveBeenCalledTimes(1);
145+
expect(formatDiagnostics(loggerSpy.logDiagnostics.mock.calls[0]![0], iff.rootDir)).toMatchInlineSnapshot(`
146+
[
147+
{
148+
"category": "error",
149+
"fileName": "<rootDir>/src/a.module.css",
150+
"length": 1,
151+
"start": {
152+
"column": 1,
153+
"line": 1,
154+
},
155+
"text": "Unclosed block",
156+
},
157+
]
158+
`);
159+
});
160+
test('emits .d.ts files even if there are diagnostics', async () => {
161+
const iff = await createIFF({
162+
'tsconfig.json': '{}',
163+
'src/a.module.css': '.a_1 {',
164+
'src/b.module.css': '.b_1 { color: red; }',
165+
});
166+
const loggerSpy = createLoggerSpy();
167+
watcher = await runCMKInWatchMode(fakeParsedArgs({ project: iff.rootDir }), loggerSpy);
168+
expect(loggerSpy.logDiagnostics).toHaveBeenCalledTimes(1);
169+
await expect(access(iff.join('generated/src/a.module.css.d.ts'))).resolves.not.toThrow();
170+
await expect(access(iff.join('generated/src/b.module.css.d.ts'))).resolves.not.toThrow();
171+
});
172+
test('removes output directory before emitting files when `clean` is true', async () => {
173+
const iff = await createIFF({
174+
'tsconfig.json': '{}',
175+
'src/a.module.css': '.a_1 { color: red; }',
176+
'generated/src/old.module.css.d.ts': '',
177+
});
178+
watcher = await runCMKInWatchMode(fakeParsedArgs({ project: iff.rootDir, clean: true }), createLoggerSpy());
179+
await expect(access(iff.join('generated/src/a.module.css.d.ts'))).resolves.not.toThrow();
180+
await expect(access(iff.join('generated/src/old.module.css.d.ts'))).rejects.toThrow();
181+
});
182+
test('reports system error occurs during watching', async () => {
183+
const iff = await createIFF({
184+
'tsconfig.json': '{}',
185+
'src/a.module.css': '.a_1 { color: red; }',
186+
});
187+
const loggerSpy = createLoggerSpy();
188+
watcher = await runCMKInWatchMode(fakeParsedArgs({ project: iff.rootDir }), loggerSpy);
189+
190+
await vi.waitFor(() => {
191+
expect(loggerSpy.logMessage).toHaveBeenCalledWith('Found 0 errors. Watching for file changes.', { time: true });
192+
});
193+
194+
// Error when adding a file
195+
vi.spyOn(watcher.project, 'addFile').mockImplementationOnce(() => {
196+
throw new Error('test error');
197+
});
198+
await writeFile(iff.join('src/b.module.css'), '.b_1 { color: red; }');
199+
await vi.waitFor(() => {
200+
expect(loggerSpy.logError).toHaveBeenCalledTimes(1);
201+
});
202+
203+
// Error when changing a file
204+
vi.spyOn(watcher.project, 'updateFile').mockImplementationOnce(() => {
205+
throw new Error('test error');
206+
});
207+
await writeFile(iff.join('src/a.module.css'), '.a_1 { color: blue; }');
208+
await vi.waitFor(() => {
209+
expect(loggerSpy.logError).toHaveBeenCalledTimes(2);
210+
});
211+
212+
// Error when emitting files
213+
vi.spyOn(watcher.project, 'emitDtsFiles').mockImplementationOnce(() => {
214+
throw new Error('test error');
215+
});
216+
await writeFile(iff.join('src/a.module.css'), '.a_1 { color: yellow; }');
217+
await vi.waitFor(() => {
218+
expect(loggerSpy.logError).toHaveBeenCalledTimes(3);
219+
});
220+
});
221+
test('reports diagnostics and emits files on changes', async () => {
222+
const iff = await createIFF({
223+
'tsconfig.json': '{}',
224+
'src/a.module.css': '.a_1 { color: red; }',
225+
});
226+
const loggerSpy = createLoggerSpy();
227+
watcher = await runCMKInWatchMode(fakeParsedArgs({ project: iff.rootDir }), loggerSpy);
228+
229+
// Add a file
230+
await writeFile(iff.join('src/b.module.css'), '.b_1 {');
231+
await vi.waitFor(async () => {
232+
expect(loggerSpy.logDiagnostics).toHaveBeenCalledTimes(1);
233+
expect(formatDiagnostics(loggerSpy.logDiagnostics.mock.calls[0]![0], iff.rootDir)).toMatchInlineSnapshot(`
234+
[
235+
{
236+
"category": "error",
237+
"fileName": "<rootDir>/src/b.module.css",
238+
"length": 1,
239+
"start": {
240+
"column": 1,
241+
"line": 1,
242+
},
243+
"text": "Unclosed block",
244+
},
245+
]
246+
`);
247+
expect(await iff.readFile('generated/src/b.module.css.d.ts')).contain('b_1');
248+
});
249+
250+
// Change a file
251+
loggerSpy.logDiagnostics.mockClear();
252+
await writeFile(iff.join('src/b.module.css'), '.b_2 {');
253+
await vi.waitFor(async () => {
254+
expect(loggerSpy.logDiagnostics).toHaveBeenCalledTimes(1);
255+
expect(formatDiagnostics(loggerSpy.logDiagnostics.mock.calls[0]![0], iff.rootDir)).toMatchInlineSnapshot(`
256+
[
257+
{
258+
"category": "error",
259+
"fileName": "<rootDir>/src/b.module.css",
260+
"length": 1,
261+
"start": {
262+
"column": 1,
263+
"line": 1,
264+
},
265+
"text": "Unclosed block",
266+
},
267+
]
268+
`);
269+
expect(await iff.readFile('generated/src/b.module.css.d.ts')).contain('b_2');
270+
});
271+
272+
// Remove a file
273+
loggerSpy.logDiagnostics.mockClear();
274+
await rm(iff.join('src/b.module.css'));
275+
await vi.waitFor(() => {
276+
expect(loggerSpy.logDiagnostics).toHaveBeenCalledTimes(0);
277+
});
278+
});
279+
test('batches rapid file changes', async () => {
280+
const iff = await createIFF({
281+
'tsconfig.json': '{}',
282+
'src': {},
283+
});
284+
const loggerSpy = createLoggerSpy();
285+
watcher = await runCMKInWatchMode(fakeParsedArgs({ project: iff.rootDir }), loggerSpy);
286+
loggerSpy.logDiagnostics.mockClear();
287+
288+
// Make rapid changes
289+
const promises = [
290+
writeFile(iff.join('src/a.module.css'), '.a_1 {'),
291+
writeFile(iff.join('src/b.module.css'), '.b_1 {'),
292+
writeFile(iff.join('src/c.module.css'), '.c_1 {'),
293+
];
294+
expect(loggerSpy.logDiagnostics).toHaveBeenCalledTimes(0);
295+
await Promise.all(promises);
296+
await vi.waitFor(() => {
297+
expect(loggerSpy.logDiagnostics).toHaveBeenCalledTimes(1);
298+
// Diagnostics for three files are reported at once.
299+
expect(formatDiagnostics(loggerSpy.logDiagnostics.mock.calls[0]![0], iff.rootDir)).length(3);
300+
});
301+
});
302+
});

packages/codegen/src/runner.ts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import type { Stats } from 'node:fs';
22
import { rm } from 'node:fs/promises';
33
import chokidar, { type FSWatcher } from 'chokidar';
4+
import { WatchInitializationError } from './error.js';
45
import type { Logger } from './logger/logger.js';
5-
import { createProject } from './project.js';
6+
import { createProject, type Project } from './project.js';
67

78
interface RunnerArgs {
89
project: string;
910
clean: boolean;
1011
}
1112

12-
interface Watcher {
13+
export interface Watcher {
14+
/** Exported for testing purposes */
15+
project: Project;
1316
close(): Promise<void>;
1417
}
1518

@@ -37,13 +40,19 @@ export async function runCMK(args: RunnerArgs, logger: Logger): Promise<boolean>
3740
/**
3841
* Run css-modules-kit .d.ts generation in watch mode.
3942
*
43+
* The promise resolves when the initial diagnostics report, emit, and watcher initialization are complete.
44+
* If an error occurs before the promise resolves, the promise will be rejected. If an error occurs
45+
* during file watching, the promise will not be rejected. Errors are reported through the logger.
46+
*
4047
* NOTE: For implementation simplicity, config file changes are not watched.
4148
* @param project The absolute path to the project directory or the path to `tsconfig.json`.
4249
* @throws {TsConfigFileNotFoundError}
4350
* @throws {ReadCSSModuleFileError}
4451
* @throws {WriteDtsFileError}
52+
* @throws {WatchInitializationError}
4553
*/
4654
export async function runCMKInWatchMode(args: RunnerArgs, logger: Logger): Promise<Watcher> {
55+
let initialized = false;
4756
const fsWatchers: FSWatcher[] = [];
4857
const project = createProject(args);
4958
let emitAndReportDiagnosticsTimer: NodeJS.Timeout | undefined = undefined;
@@ -54,7 +63,10 @@ export async function runCMKInWatchMode(args: RunnerArgs, logger: Logger): Promi
5463
await emitAndReportDiagnostics();
5564

5665
// Watch project files and report diagnostics on changes
66+
const readyPromises: Promise<void>[] = [];
5767
for (const wildcardDirectory of project.config.wildcardDirectories) {
68+
const { promise, resolve, reject } = promiseWithResolvers<void>();
69+
readyPromises.push(promise);
5870
fsWatchers.push(
5971
chokidar
6072
.watch(wildcardDirectory.fileName, {
@@ -75,7 +87,6 @@ export async function runCMKInWatchMode(args: RunnerArgs, logger: Logger): Promi
7587
},
7688
ignoreInitial: true,
7789
...(wildcardDirectory.recursive ? {} : { depth: 0 }),
78-
awaitWriteFinish: true,
7990
})
8091
.on('add', (fileName) => {
8192
try {
@@ -99,9 +110,19 @@ export async function runCMKInWatchMode(args: RunnerArgs, logger: Logger): Promi
99110
project.removeFile(fileName);
100111
scheduleEmitAndReportDiagnostics();
101112
})
102-
.on('error', logger.logError.bind(logger)),
113+
// eslint-disable-next-line no-loop-func
114+
.on('error', (e) => {
115+
if (!initialized) {
116+
reject(new WatchInitializationError(e));
117+
} else {
118+
logger.logError(e);
119+
}
120+
})
121+
.on('ready', () => resolve()),
103122
);
104123
}
124+
await Promise.all(readyPromises);
125+
initialized = true;
105126

106127
function scheduleEmitAndReportDiagnostics() {
107128
// Switching between git branches results in numerous file changes occurring rapidly.
@@ -136,5 +157,19 @@ export async function runCMKInWatchMode(args: RunnerArgs, logger: Logger): Promi
136157
await Promise.all(fsWatchers.map(async (watcher) => watcher.close()));
137158
}
138159

139-
return { close };
160+
return { project, close };
161+
}
162+
163+
function promiseWithResolvers<T>() {
164+
let resolve;
165+
let reject;
166+
const promise = new Promise<T>((res, rej) => {
167+
resolve = res;
168+
reject = rej;
169+
});
170+
return {
171+
promise,
172+
resolve: resolve as unknown as (value: T) => void,
173+
reject: reject as unknown as (reason?: unknown) => void,
174+
};
140175
}

0 commit comments

Comments
 (0)