Skip to content

Commit 4829df7

Browse files
committed
wip
1 parent 615dc2c commit 4829df7

File tree

5 files changed

+272
-10
lines changed

5 files changed

+272
-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: 222 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,29 @@
1-
import { access, writeFile } from 'node:fs/promises';
1+
import { access, rm, writeFile } from 'node:fs/promises';
2+
import { platform } from 'node:process';
23
import dedent from 'dedent';
3-
import { describe, expect, test } from 'vitest';
4-
import { runCMK } from './runner.js';
4+
import { afterEach, describe, expect, test, vi } from 'vitest';
5+
import type { Watcher } from './runner.js';
6+
import { runCMK, runCMKInWatchMode } from './runner.js';
57
import { formatDiagnostics } from './test/diagnostic.js';
68
import { fakeParsedArgs } from './test/faker.js';
79
import { createIFF } from './test/fixture.js';
810
import { createLoggerSpy } from './test/logger.js';
911

12+
async function sleep(ms: number): Promise<void> {
13+
// eslint-disable-next-line no-promise-executor-return
14+
return new Promise((resolve) => setTimeout(resolve, ms));
15+
}
16+
17+
async function waitForWatcherReady(): Promise<void> {
18+
if (platform === 'darwin') {
19+
// Workaround for https://github.com/paulmillr/chokidar/issues/1443
20+
await sleep(100);
21+
}
22+
}
23+
async function waitForWatcherEmit(): Promise<void> {
24+
await sleep(300);
25+
}
26+
1027
describe('runCMK', () => {
1128
test('emits .d.ts files', async () => {
1229
const iff = await createIFF({
@@ -95,4 +112,205 @@ describe('runCMK', () => {
95112
});
96113
});
97114

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

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)