@jamaynor/hal-config 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,360 @@
1
+ /**
2
+ * test-utils.test
3
+ *
4
+ * Responsibility: Verify all helpers exported by test-utils.js work correctly.
5
+ */
6
+
7
+ import { describe, it, afterEach } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import os from 'node:os';
12
+
13
+ import {
14
+ makeTmpDir,
15
+ makeTmpDirAsync,
16
+ cleanup,
17
+ cleanupAsync,
18
+ scaffold,
19
+ captureLog,
20
+ captureStderr,
21
+ captureWarn,
22
+ captureOutput,
23
+ silent,
24
+ withCapturedLog,
25
+ writeJson,
26
+ mockProcessExit,
27
+ } from '../test-utils.js';
28
+
29
+ // ============================================================================
30
+ // makeTmpDir / makeTmpDirAsync / cleanup / cleanupAsync
31
+ // ============================================================================
32
+
33
+ describe('makeTmpDir', () => {
34
+ it('returns a path that exists and starts with os.tmpdir()', () => {
35
+ const dir = makeTmpDir();
36
+ assert.ok(fs.existsSync(dir), 'directory must exist');
37
+ assert.ok(dir.startsWith(os.tmpdir()), 'must be under os.tmpdir()');
38
+ cleanup(dir);
39
+ });
40
+
41
+ it('uses a custom prefix', () => {
42
+ const dir = makeTmpDir('my-custom-prefix-');
43
+ assert.ok(fs.existsSync(dir));
44
+ const base = path.basename(dir);
45
+ assert.ok(base.startsWith('my-custom-prefix-'), `basename "${base}" should start with custom prefix`);
46
+ cleanup(dir);
47
+ });
48
+
49
+ it('makeTmpDirAsync returns a promise that resolves to an existing path', async () => {
50
+ const dir = await makeTmpDirAsync();
51
+ assert.ok(fs.existsSync(dir), 'directory must exist');
52
+ assert.ok(dir.startsWith(os.tmpdir()), 'must be under os.tmpdir()');
53
+ cleanup(dir);
54
+ });
55
+
56
+ it('cleanup removes the directory and all contents', () => {
57
+ const dir = makeTmpDir();
58
+ fs.writeFileSync(path.join(dir, 'file.txt'), 'hello');
59
+ cleanup(dir);
60
+ assert.ok(!fs.existsSync(dir), 'directory must not exist after cleanup');
61
+ });
62
+
63
+ it('cleanup(null) does not throw', () => {
64
+ assert.doesNotThrow(() => cleanup(null));
65
+ });
66
+
67
+ it('cleanup with nonexistent path does not throw', () => {
68
+ assert.doesNotThrow(() => cleanup('/nonexistent-path-xyz-hal-test'));
69
+ });
70
+
71
+ it('cleanupAsync removes the directory', async () => {
72
+ const dir = await makeTmpDirAsync();
73
+ fs.writeFileSync(path.join(dir, 'data.txt'), 'content');
74
+ await cleanupAsync(dir);
75
+ assert.ok(!fs.existsSync(dir), 'directory must not exist after cleanupAsync');
76
+ });
77
+ });
78
+
79
+ // ============================================================================
80
+ // scaffold
81
+ // ============================================================================
82
+
83
+ describe('scaffold', () => {
84
+ let tmpDir;
85
+
86
+ afterEach(() => { cleanup(tmpDir); });
87
+
88
+ it('creates nested directories', () => {
89
+ tmpDir = makeTmpDir();
90
+ scaffold(tmpDir, { dirs: ['a/b/c', 'd/e'] });
91
+ assert.ok(fs.existsSync(path.join(tmpDir, 'a', 'b', 'c')));
92
+ assert.ok(fs.existsSync(path.join(tmpDir, 'd', 'e')));
93
+ });
94
+
95
+ it('writes string files verbatim', () => {
96
+ tmpDir = makeTmpDir();
97
+ scaffold(tmpDir, { files: { 'hello.txt': 'world\n' } });
98
+ const content = fs.readFileSync(path.join(tmpDir, 'hello.txt'), 'utf8');
99
+ assert.equal(content, 'world\n');
100
+ });
101
+
102
+ it('JSON-stringifies object files', () => {
103
+ tmpDir = makeTmpDir();
104
+ scaffold(tmpDir, { files: { 'config.json': { key: 'value', num: 42 } } });
105
+ const content = fs.readFileSync(path.join(tmpDir, 'config.json'), 'utf8');
106
+ const parsed = JSON.parse(content);
107
+ assert.equal(parsed.key, 'value');
108
+ assert.equal(parsed.num, 42);
109
+ });
110
+
111
+ it('creates parent directories for files automatically', () => {
112
+ tmpDir = makeTmpDir();
113
+ scaffold(tmpDir, { files: { 'deep/nested/dir/file.txt': 'data' } });
114
+ const full = path.join(tmpDir, 'deep', 'nested', 'dir', 'file.txt');
115
+ assert.ok(fs.existsSync(full));
116
+ assert.equal(fs.readFileSync(full, 'utf8'), 'data');
117
+ });
118
+ });
119
+
120
+ // ============================================================================
121
+ // Output capture helpers
122
+ // ============================================================================
123
+
124
+ describe('captureLog', () => {
125
+ it('captures console.log calls', () => {
126
+ const cap = captureLog();
127
+ console.log('hello', 'world');
128
+ console.log('second');
129
+ cap.restore();
130
+ assert.deepStrictEqual(cap.lines, ['hello world', 'second']);
131
+ });
132
+
133
+ it('restore stops capturing', () => {
134
+ const cap = captureLog();
135
+ console.log('captured');
136
+ cap.restore();
137
+ console.log('not captured');
138
+ assert.deepStrictEqual(cap.lines, ['captured']);
139
+ });
140
+
141
+ it('captured lines are strings (arguments joined with space)', () => {
142
+ const cap = captureLog();
143
+ console.log('a', 'b', 'c');
144
+ cap.restore();
145
+ assert.equal(typeof cap.lines[0], 'string');
146
+ assert.equal(cap.lines[0], 'a b c');
147
+ });
148
+
149
+ it('restore is idempotent — calling twice does not throw', () => {
150
+ const cap = captureLog();
151
+ cap.restore();
152
+ assert.doesNotThrow(() => cap.restore());
153
+ });
154
+ });
155
+
156
+ describe('captureStderr', () => {
157
+ it('captures console.error calls', () => {
158
+ const cap = captureStderr();
159
+ console.error('err1');
160
+ console.error('err2', 'details');
161
+ cap.restore();
162
+ assert.deepStrictEqual(cap.lines, ['err1', 'err2 details']);
163
+ });
164
+
165
+ it('restore is idempotent', () => {
166
+ const cap = captureStderr();
167
+ cap.restore();
168
+ assert.doesNotThrow(() => cap.restore());
169
+ });
170
+ });
171
+
172
+ describe('captureWarn', () => {
173
+ it('captures console.warn calls', () => {
174
+ const cap = captureWarn();
175
+ console.warn('warning one');
176
+ console.warn('warning', 'two');
177
+ cap.restore();
178
+ assert.deepStrictEqual(cap.lines, ['warning one', 'warning two']);
179
+ });
180
+
181
+ it('restore is idempotent', () => {
182
+ const cap = captureWarn();
183
+ cap.restore();
184
+ assert.doesNotThrow(() => cap.restore());
185
+ });
186
+ });
187
+
188
+ describe('captureOutput', () => {
189
+ it('captures console.log into stdout array', () => {
190
+ const cap = captureOutput();
191
+ console.log('out1');
192
+ console.log('out2');
193
+ cap.restore();
194
+ assert.deepStrictEqual(cap.stdout, ['out1', 'out2']);
195
+ });
196
+
197
+ it('captures console.error into stderr array independently', () => {
198
+ const cap = captureOutput();
199
+ console.error('err1');
200
+ cap.restore();
201
+ assert.deepStrictEqual(cap.stderr, ['err1']);
202
+ assert.deepStrictEqual(cap.stdout, []);
203
+ });
204
+
205
+ it('captures process.stdout.write chunks into raw array', () => {
206
+ const cap = captureOutput();
207
+ process.stdout.write('chunk1');
208
+ process.stdout.write(Buffer.from('chunk2'));
209
+ cap.restore();
210
+ assert.equal(cap.raw.length, 2);
211
+ assert.equal(cap.raw[0], 'chunk1');
212
+ });
213
+
214
+ it('replaced process.stdout.write returns true', () => {
215
+ const cap = captureOutput();
216
+ const result = process.stdout.write('test');
217
+ cap.restore();
218
+ assert.equal(result, true);
219
+ });
220
+
221
+ it('all three channels captured independently', () => {
222
+ const cap = captureOutput();
223
+ console.log('log-msg');
224
+ console.error('err-msg');
225
+ process.stdout.write('raw-msg');
226
+ cap.restore();
227
+ assert.deepStrictEqual(cap.stdout, ['log-msg']);
228
+ assert.deepStrictEqual(cap.stderr, ['err-msg']);
229
+ assert.equal(cap.raw[0], 'raw-msg');
230
+ });
231
+
232
+ it('restore is idempotent', () => {
233
+ const cap = captureOutput();
234
+ cap.restore();
235
+ assert.doesNotThrow(() => cap.restore());
236
+ });
237
+ });
238
+
239
+ describe('silent', () => {
240
+ it('suppresses output and returns the function return value', () => {
241
+ let logged = false;
242
+ const origLog = console.log;
243
+ const result = silent(() => {
244
+ console.log('suppressed');
245
+ logged = true;
246
+ return 42;
247
+ });
248
+ // After silent, console.log should be restored
249
+ assert.equal(console.log, origLog);
250
+ assert.equal(result, 42);
251
+ });
252
+
253
+ it('handles async functions correctly', async () => {
254
+ const origLog = console.log;
255
+ const result = await silent(async () => {
256
+ console.log('async suppressed');
257
+ return 'async-result';
258
+ });
259
+ assert.equal(console.log, origLog);
260
+ assert.equal(result, 'async-result');
261
+ });
262
+ });
263
+
264
+ describe('withCapturedLog', () => {
265
+ it('returns captured lines from fn', () => {
266
+ const lines = withCapturedLog(() => {
267
+ console.log('line one');
268
+ console.log('line two');
269
+ });
270
+ assert.deepStrictEqual(lines, ['line one', 'line two']);
271
+ });
272
+
273
+ it('restores console.log after capture', () => {
274
+ const origLog = console.log;
275
+ withCapturedLog(() => { console.log('captured'); });
276
+ assert.equal(console.log, origLog);
277
+ });
278
+ });
279
+
280
+ // ============================================================================
281
+ // writeJson
282
+ // ============================================================================
283
+
284
+ describe('writeJson', () => {
285
+ let tmpDir;
286
+
287
+ afterEach(() => { cleanup(tmpDir); });
288
+
289
+ it('creates parent directories and writes valid JSON', () => {
290
+ tmpDir = makeTmpDir();
291
+ const filePath = path.join(tmpDir, 'nested', 'dir', 'config.json');
292
+ const data = { foo: 'bar', count: 7 };
293
+ writeJson(filePath, data);
294
+ assert.ok(fs.existsSync(filePath));
295
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
296
+ assert.deepStrictEqual(parsed, data);
297
+ });
298
+
299
+ it('writes valid JSON that can be read back and parsed', () => {
300
+ tmpDir = makeTmpDir();
301
+ const filePath = path.join(tmpDir, 'data.json');
302
+ const data = { arr: [1, 2, 3], nested: { x: true } };
303
+ writeJson(filePath, data);
304
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
305
+ assert.deepStrictEqual(parsed, data);
306
+ });
307
+ });
308
+
309
+ // ============================================================================
310
+ // mockProcessExit
311
+ // ============================================================================
312
+
313
+ describe('mockProcessExit', () => {
314
+ let mock;
315
+
316
+ afterEach(() => {
317
+ if (mock) {
318
+ mock.restore();
319
+ mock = null;
320
+ }
321
+ });
322
+
323
+ it('captures the exit code', () => {
324
+ mock = mockProcessExit();
325
+ try { process.exit(3); } catch { /* expected */ }
326
+ assert.equal(mock.code, 3);
327
+ });
328
+
329
+ it('throws on process.exit so test execution stops', () => {
330
+ mock = mockProcessExit();
331
+ assert.throws(
332
+ () => process.exit(1),
333
+ (err) => err.message === 'process.exit called'
334
+ );
335
+ });
336
+
337
+ it('thrown error has exitCode property', () => {
338
+ mock = mockProcessExit();
339
+ let caught;
340
+ try { process.exit(99); } catch (err) { caught = err; }
341
+ assert.equal(caught.exitCode, 99);
342
+ });
343
+
344
+ it('restore puts real process.exit back', () => {
345
+ mock = mockProcessExit();
346
+ mock.restore();
347
+ // After restore, calling our mock should no longer throw
348
+ // (we verify process.exit is the real one by checking it does not throw
349
+ // when called from our fake invoker — we do NOT actually call process.exit here)
350
+ assert.equal(typeof process.exit, 'function');
351
+ // The mock's throw should no longer be in effect — mock.restore() was called,
352
+ // so calling process.exit would use the real one. We just verify the type.
353
+ });
354
+
355
+ it('restore is idempotent — calling twice does not throw', () => {
356
+ mock = mockProcessExit();
357
+ mock.restore();
358
+ assert.doesNotThrow(() => mock.restore());
359
+ });
360
+ });