@aladac/hu 0.1.0-a1 → 0.1.0-a2

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,257 @@
1
+ /**
2
+ * Escaping Regression Tests
3
+ *
4
+ * These tests verify that special characters are handled correctly
5
+ * through the entire data pipeline. Each test documents the specific
6
+ * bug it prevents.
7
+ */
8
+
9
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
10
+ import * as fs from 'node:fs';
11
+ import * as path from 'node:path';
12
+ import * as os from 'node:os';
13
+ import { writeTempJson, readTempJson } from '../../src/lib/hook-io.ts';
14
+ import { readJsonlFileSync, appendToJsonl } from '../../src/lib/jsonl.ts';
15
+
16
+ describe('escaping', () => {
17
+ let tempDir: string;
18
+
19
+ beforeEach(() => {
20
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hu-escaping-test-'));
21
+ });
22
+
23
+ afterEach(() => {
24
+ try {
25
+ fs.rmSync(tempDir, { recursive: true });
26
+ } catch {
27
+ // Ignore cleanup errors
28
+ }
29
+ });
30
+
31
+ describe('quote characters', () => {
32
+ // Bug: Unescaped quotes break JSON parsing
33
+ it('handles double quotes in strings', () => {
34
+ const data = { message: 'He said "hello world"' };
35
+ const filePath = writeTempJson(data);
36
+ const read = readTempJson(filePath);
37
+ expect(read).toEqual(data);
38
+ });
39
+
40
+ // Bug: Single quotes can cause issues in shell contexts
41
+ it('handles single quotes in strings', () => {
42
+ const data = { message: "It's working" };
43
+ const filePath = writeTempJson(data);
44
+ const read = readTempJson(filePath);
45
+ expect(read).toEqual(data);
46
+ });
47
+
48
+ // Bug: Mixed quotes break naive escaping
49
+ it('handles mixed quotes', () => {
50
+ const data = { message: `He said "it's fine"` };
51
+ const filePath = writeTempJson(data);
52
+ const read = readTempJson(filePath);
53
+ expect(read).toEqual(data);
54
+ });
55
+ });
56
+
57
+ describe('backslashes', () => {
58
+ // Bug: Single backslash gets consumed as escape
59
+ it('handles single backslash', () => {
60
+ const data = { path: 'C:\\Users\\test' };
61
+ const filePath = writeTempJson(data);
62
+ const read = readTempJson(filePath);
63
+ expect(read).toEqual(data);
64
+ });
65
+
66
+ // Bug: Double backslash collapses to single
67
+ it('handles double backslash', () => {
68
+ const data = { regex: '\\\\n' };
69
+ const filePath = writeTempJson(data);
70
+ const read = readTempJson(filePath);
71
+ expect(read).toEqual(data);
72
+ });
73
+
74
+ // Bug: Backslash before quote is problematic
75
+ it('handles backslash before quote', () => {
76
+ const data = { value: 'escaped\\"quote' };
77
+ const filePath = writeTempJson(data);
78
+ const read = readTempJson(filePath);
79
+ expect(read).toEqual(data);
80
+ });
81
+ });
82
+
83
+ describe('newlines and whitespace', () => {
84
+ // Bug: Literal newlines break single-line JSON
85
+ it('handles newline characters', () => {
86
+ const data = { text: 'line1\nline2\nline3' };
87
+ const filePath = writeTempJson(data);
88
+ const read = readTempJson(filePath);
89
+ expect(read).toEqual(data);
90
+ });
91
+
92
+ // Bug: Tabs can be confused with field separators
93
+ it('handles tab characters', () => {
94
+ const data = { text: 'col1\tcol2\tcol3' };
95
+ const filePath = writeTempJson(data);
96
+ const read = readTempJson(filePath);
97
+ expect(read).toEqual(data);
98
+ });
99
+
100
+ // Bug: Carriage return causes issues on different platforms
101
+ it('handles carriage return', () => {
102
+ const data = { text: 'windows\r\nline ending' };
103
+ const filePath = writeTempJson(data);
104
+ const read = readTempJson(filePath);
105
+ expect(read).toEqual(data);
106
+ });
107
+ });
108
+
109
+ describe('shell special characters', () => {
110
+ // Bug: Dollar sign triggers variable expansion
111
+ it('handles dollar sign', () => {
112
+ const data = { cost: '$100', var: '$HOME' };
113
+ const filePath = writeTempJson(data);
114
+ const read = readTempJson(filePath);
115
+ expect(read).toEqual(data);
116
+ });
117
+
118
+ // Bug: Backticks trigger command substitution
119
+ it('handles backticks', () => {
120
+ const data = { code: '`npm install`' };
121
+ const filePath = writeTempJson(data);
122
+ const read = readTempJson(filePath);
123
+ expect(read).toEqual(data);
124
+ });
125
+
126
+ // Bug: $(cmd) triggers command substitution
127
+ it('handles $() syntax', () => {
128
+ const data = { cmd: '$(whoami)' };
129
+ const filePath = writeTempJson(data);
130
+ const read = readTempJson(filePath);
131
+ expect(read).toEqual(data);
132
+ });
133
+
134
+ // Bug: Exclamation can trigger history expansion in some shells
135
+ it('handles exclamation mark', () => {
136
+ const data = { message: 'Hello!' };
137
+ const filePath = writeTempJson(data);
138
+ const read = readTempJson(filePath);
139
+ expect(read).toEqual(data);
140
+ });
141
+
142
+ // Bug: Semicolon can terminate commands
143
+ it('handles semicolon', () => {
144
+ const data = { cmd: 'cmd1; cmd2' };
145
+ const filePath = writeTempJson(data);
146
+ const read = readTempJson(filePath);
147
+ expect(read).toEqual(data);
148
+ });
149
+
150
+ // Bug: Pipe can redirect output
151
+ it('handles pipe character', () => {
152
+ const data = { cmd: 'cat file | grep pattern' };
153
+ const filePath = writeTempJson(data);
154
+ const read = readTempJson(filePath);
155
+ expect(read).toEqual(data);
156
+ });
157
+
158
+ // Bug: Ampersand can background or chain commands
159
+ it('handles ampersand', () => {
160
+ const data = { cmd: 'cmd1 && cmd2', bg: 'cmd &' };
161
+ const filePath = writeTempJson(data);
162
+ const read = readTempJson(filePath);
163
+ expect(read).toEqual(data);
164
+ });
165
+ });
166
+
167
+ describe('nested JSON', () => {
168
+ // Bug: JSON inside JSON needs double escaping
169
+ it('handles JSON string as value', () => {
170
+ const inner = { nested: true, value: 'test' };
171
+ const data = { json_string: JSON.stringify(inner) };
172
+ const filePath = writeTempJson(data);
173
+ const read = readTempJson<{ json_string: string }>(filePath);
174
+ expect(read.json_string).toBe(JSON.stringify(inner));
175
+ expect(JSON.parse(read.json_string)).toEqual(inner);
176
+ });
177
+
178
+ // Bug: Deeply nested JSON with special chars
179
+ it('handles deeply nested JSON with special characters', () => {
180
+ const inner = {
181
+ message: 'He said "hello"',
182
+ path: 'C:\\test',
183
+ cmd: '$(rm -rf /)',
184
+ };
185
+ const data = {
186
+ level1: JSON.stringify({
187
+ level2: JSON.stringify(inner)
188
+ })
189
+ };
190
+ const filePath = writeTempJson(data);
191
+ const read = readTempJson<typeof data>(filePath);
192
+
193
+ const l1 = JSON.parse(read.level1);
194
+ const l2 = JSON.parse(l1.level2);
195
+ expect(l2).toEqual(inner);
196
+ });
197
+ });
198
+
199
+ describe('JSONL specific', () => {
200
+ // Bug: Newlines in content break JSONL line separation
201
+ it('preserves newlines in JSONL content', () => {
202
+ const jsonlPath = path.join(tempDir, 'test.jsonl');
203
+
204
+ appendToJsonl(jsonlPath, { content: 'line1\nline2' });
205
+ appendToJsonl(jsonlPath, { content: 'another\nmultiline' });
206
+
207
+ const entries = readJsonlFileSync<{ content: string }>(jsonlPath);
208
+ expect(entries).toHaveLength(2);
209
+ expect(entries[0].content).toBe('line1\nline2');
210
+ expect(entries[1].content).toBe('another\nmultiline');
211
+ });
212
+
213
+ // Bug: Quotes in JSONL can break parsing
214
+ it('handles quotes in JSONL values', () => {
215
+ const jsonlPath = path.join(tempDir, 'quotes.jsonl');
216
+
217
+ appendToJsonl(jsonlPath, { message: 'said "hello"' });
218
+
219
+ const entries = readJsonlFileSync<{ message: string }>(jsonlPath);
220
+ expect(entries[0].message).toBe('said "hello"');
221
+ });
222
+ });
223
+
224
+ describe('unicode edge cases', () => {
225
+ // Bug: Some unicode chars can cause encoding issues
226
+ it('handles zero-width characters', () => {
227
+ const data = { text: 'invisible\u200Bchar' };
228
+ const filePath = writeTempJson(data);
229
+ const read = readTempJson(filePath);
230
+ expect(read).toEqual(data);
231
+ });
232
+
233
+ // Bug: BOM can cause parsing issues
234
+ it('handles byte order mark', () => {
235
+ const data = { text: '\uFEFFwith BOM' };
236
+ const filePath = writeTempJson(data);
237
+ const read = readTempJson(filePath);
238
+ expect(read).toEqual(data);
239
+ });
240
+
241
+ // Bug: Combining characters can affect string length
242
+ it('handles combining characters', () => {
243
+ const data = { text: 'e\u0301' }; // é as e + combining accent
244
+ const filePath = writeTempJson(data);
245
+ const read = readTempJson(filePath);
246
+ expect(read).toEqual(data);
247
+ });
248
+
249
+ // Bug: Emoji sequences can be multi-codepoint
250
+ it('handles complex emoji sequences', () => {
251
+ const data = { emoji: '👨‍👩‍👧‍👦' }; // family emoji (multiple codepoints)
252
+ const filePath = writeTempJson(data);
253
+ const read = readTempJson(filePath);
254
+ expect(read).toEqual(data);
255
+ });
256
+ });
257
+ });
@@ -0,0 +1,151 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import * as os from 'node:os';
5
+ import {
6
+ writeTempJson,
7
+ readTempJson,
8
+ deleteTempFile,
9
+ cleanupTempFiles,
10
+ hooksEnabled,
11
+ } from '../../src/lib/hook-io.ts';
12
+
13
+ describe('hook-io', () => {
14
+ let tempDir: string;
15
+
16
+ beforeEach(() => {
17
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hu-hook-test-'));
18
+ });
19
+
20
+ afterEach(() => {
21
+ // Clean up test directory
22
+ try {
23
+ fs.rmSync(tempDir, { recursive: true });
24
+ } catch {
25
+ // Ignore cleanup errors
26
+ }
27
+ });
28
+
29
+ describe('writeTempJson / readTempJson', () => {
30
+ it('roundtrips simple objects', () => {
31
+ const data = { foo: 'bar', count: 42 };
32
+ const filePath = writeTempJson(data);
33
+ expect(fs.existsSync(filePath)).toBe(true);
34
+
35
+ const read = readTempJson(filePath);
36
+ expect(read).toEqual(data);
37
+
38
+ deleteTempFile(filePath);
39
+ });
40
+
41
+ it('preserves unicode characters', () => {
42
+ const data = {
43
+ emoji: '🎉🚀',
44
+ cjk: '日本語テスト',
45
+ arabic: 'مرحبا',
46
+ mixed: 'Hello 世界 🌍',
47
+ };
48
+ const filePath = writeTempJson(data);
49
+ const read = readTempJson(filePath);
50
+ expect(read).toEqual(data);
51
+
52
+ deleteTempFile(filePath);
53
+ });
54
+
55
+ it('handles special characters in strings', () => {
56
+ const data = {
57
+ quotes: 'He said "hello"',
58
+ backslashes: 'path\\to\\file',
59
+ newlines: 'line1\nline2\nline3',
60
+ tabs: 'col1\tcol2\tcol3',
61
+ dollar: '$HOME/path',
62
+ backticks: '`command`',
63
+ };
64
+ const filePath = writeTempJson(data);
65
+ const read = readTempJson(filePath);
66
+ expect(read).toEqual(data);
67
+
68
+ deleteTempFile(filePath);
69
+ });
70
+
71
+ it('handles nested objects with escaped strings', () => {
72
+ const data = {
73
+ outer: {
74
+ inner: {
75
+ value: 'nested "quoted" value',
76
+ array: ['item1', 'item\nwith\nnewlines'],
77
+ },
78
+ },
79
+ json_in_json: JSON.stringify({ nested: true }),
80
+ };
81
+ const filePath = writeTempJson(data);
82
+ const read = readTempJson(filePath);
83
+ expect(read).toEqual(data);
84
+
85
+ deleteTempFile(filePath);
86
+ });
87
+
88
+ it('generates unique file names', () => {
89
+ const paths = new Set<string>();
90
+
91
+ for (let i = 0; i < 100; i++) {
92
+ const filePath = writeTempJson({ index: i });
93
+ expect(paths.has(filePath)).toBe(false);
94
+ paths.add(filePath);
95
+ }
96
+
97
+ // Clean up
98
+ for (const p of paths) {
99
+ deleteTempFile(p);
100
+ }
101
+ });
102
+
103
+ it('handles arrays', () => {
104
+ const data = [1, 'two', { three: 3 }, [4, 5]];
105
+ const filePath = writeTempJson(data);
106
+ const read = readTempJson(filePath);
107
+ expect(read).toEqual(data);
108
+
109
+ deleteTempFile(filePath);
110
+ });
111
+
112
+ it('handles null and undefined values', () => {
113
+ const data = { nullVal: null, undef: undefined, empty: '' };
114
+ const filePath = writeTempJson(data);
115
+ const read = readTempJson<Record<string, unknown>>(filePath);
116
+ expect(read.nullVal).toBe(null);
117
+ expect(read.undef).toBeUndefined();
118
+ expect(read.empty).toBe('');
119
+
120
+ deleteTempFile(filePath);
121
+ });
122
+ });
123
+
124
+ describe('deleteTempFile', () => {
125
+ it('deletes existing file', () => {
126
+ const filePath = writeTempJson({ test: true });
127
+ expect(fs.existsSync(filePath)).toBe(true);
128
+
129
+ deleteTempFile(filePath);
130
+ expect(fs.existsSync(filePath)).toBe(false);
131
+ });
132
+
133
+ it('does not throw on non-existent file', () => {
134
+ expect(() => deleteTempFile('/nonexistent/path.json')).not.toThrow();
135
+ });
136
+ });
137
+
138
+ describe('hooksEnabled', () => {
139
+ it('returns a boolean', () => {
140
+ const enabled = hooksEnabled();
141
+ expect(typeof enabled).toBe('boolean');
142
+ });
143
+ });
144
+
145
+ describe('cleanupTempFiles', () => {
146
+ it('returns number of cleaned files', () => {
147
+ const cleaned = cleanupTempFiles();
148
+ expect(typeof cleaned).toBe('number');
149
+ });
150
+ });
151
+ });
@@ -0,0 +1,166 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import * as os from 'node:os';
5
+ import {
6
+ parseJsonl,
7
+ readJsonlFile,
8
+ readJsonlFileSync,
9
+ appendToJsonl,
10
+ writeJsonlFile,
11
+ } from '../../src/lib/jsonl.ts';
12
+
13
+ describe('jsonl', () => {
14
+ const testDir = path.join(os.tmpdir(), `hu-jsonl-test-${process.pid}`);
15
+
16
+ beforeEach(() => {
17
+ if (!fs.existsSync(testDir)) {
18
+ fs.mkdirSync(testDir, { recursive: true });
19
+ }
20
+ });
21
+
22
+ afterEach(() => {
23
+ if (fs.existsSync(testDir)) {
24
+ fs.rmSync(testDir, { recursive: true });
25
+ }
26
+ });
27
+
28
+ describe('readJsonlFileSync', () => {
29
+ it('parses valid JSONL', () => {
30
+ const filePath = path.join(testDir, 'valid.jsonl');
31
+ fs.writeFileSync(filePath, '{"a":1}\n{"b":2}\n{"c":3}\n');
32
+
33
+ const result = readJsonlFileSync<{ a?: number; b?: number; c?: number }>(filePath);
34
+ expect(result).toEqual([{ a: 1 }, { b: 2 }, { c: 3 }]);
35
+ });
36
+
37
+ it('handles empty file', () => {
38
+ const filePath = path.join(testDir, 'empty.jsonl');
39
+ fs.writeFileSync(filePath, '');
40
+
41
+ const result = readJsonlFileSync(filePath);
42
+ expect(result).toEqual([]);
43
+ });
44
+
45
+ it('handles non-existent file', () => {
46
+ const result = readJsonlFileSync(path.join(testDir, 'nonexistent.jsonl'));
47
+ expect(result).toEqual([]);
48
+ });
49
+
50
+ it('skips empty lines', () => {
51
+ const filePath = path.join(testDir, 'with-empty.jsonl');
52
+ fs.writeFileSync(filePath, '{"a":1}\n\n{"b":2}\n \n{"c":3}\n');
53
+
54
+ const result = readJsonlFileSync(filePath);
55
+ expect(result).toHaveLength(3);
56
+ });
57
+
58
+ it('skips malformed lines', () => {
59
+ const filePath = path.join(testDir, 'malformed.jsonl');
60
+ fs.writeFileSync(filePath, '{"a":1}\ninvalid json\n{"b":2}\n');
61
+
62
+ const result = readJsonlFileSync(filePath);
63
+ expect(result).toHaveLength(2);
64
+ });
65
+
66
+ it('preserves unicode content', () => {
67
+ const filePath = path.join(testDir, 'unicode.jsonl');
68
+ const data = { text: '日本語テスト 🎉 émojis', path: '/путь/к/файлу' };
69
+ fs.writeFileSync(filePath, JSON.stringify(data) + '\n');
70
+
71
+ const result = readJsonlFileSync<typeof data>(filePath);
72
+ expect(result[0]).toEqual(data);
73
+ });
74
+
75
+ it('handles escaped characters', () => {
76
+ const filePath = path.join(testDir, 'escaped.jsonl');
77
+ const data = {
78
+ quote: 'He said "hello"',
79
+ newline: 'line1\nline2',
80
+ tab: 'col1\tcol2',
81
+ backslash: 'path\\to\\file',
82
+ };
83
+ fs.writeFileSync(filePath, JSON.stringify(data) + '\n');
84
+
85
+ const result = readJsonlFileSync<typeof data>(filePath);
86
+ expect(result[0]).toEqual(data);
87
+ });
88
+
89
+ it('handles nested objects', () => {
90
+ const filePath = path.join(testDir, 'nested.jsonl');
91
+ const data = {
92
+ outer: {
93
+ inner: {
94
+ value: 42,
95
+ array: [1, 2, 3],
96
+ },
97
+ },
98
+ };
99
+ fs.writeFileSync(filePath, JSON.stringify(data) + '\n');
100
+
101
+ const result = readJsonlFileSync<typeof data>(filePath);
102
+ expect(result[0]).toEqual(data);
103
+ });
104
+ });
105
+
106
+ describe('readJsonlFile (async)', () => {
107
+ it('parses valid JSONL', async () => {
108
+ const filePath = path.join(testDir, 'valid-async.jsonl');
109
+ fs.writeFileSync(filePath, '{"a":1}\n{"b":2}\n{"c":3}\n');
110
+
111
+ const result = await readJsonlFile<{ a?: number; b?: number; c?: number }>(filePath);
112
+ expect(result).toEqual([{ a: 1 }, { b: 2 }, { c: 3 }]);
113
+ });
114
+
115
+ it('handles non-existent file', async () => {
116
+ const result = await readJsonlFile(path.join(testDir, 'nonexistent.jsonl'));
117
+ expect(result).toEqual([]);
118
+ });
119
+ });
120
+
121
+ describe('parseJsonl (generator)', () => {
122
+ it('yields items one by one', async () => {
123
+ const filePath = path.join(testDir, 'generator.jsonl');
124
+ fs.writeFileSync(filePath, '{"n":1}\n{"n":2}\n{"n":3}\n');
125
+
126
+ const items: { n: number }[] = [];
127
+ for await (const item of parseJsonl<{ n: number }>(filePath)) {
128
+ items.push(item);
129
+ }
130
+ expect(items).toEqual([{ n: 1 }, { n: 2 }, { n: 3 }]);
131
+ });
132
+ });
133
+
134
+ describe('writeJsonlFile', () => {
135
+ it('writes array to JSONL', () => {
136
+ const filePath = path.join(testDir, 'write.jsonl');
137
+ const data = [{ a: 1 }, { b: 2 }, { c: 3 }];
138
+
139
+ writeJsonlFile(filePath, data);
140
+
141
+ const result = readJsonlFileSync(filePath);
142
+ expect(result).toEqual(data);
143
+ });
144
+ });
145
+
146
+ describe('appendToJsonl', () => {
147
+ it('appends item to file', () => {
148
+ const filePath = path.join(testDir, 'append.jsonl');
149
+ fs.writeFileSync(filePath, '{"a":1}\n');
150
+
151
+ appendToJsonl(filePath, { b: 2 });
152
+
153
+ const result = readJsonlFileSync(filePath);
154
+ expect(result).toEqual([{ a: 1 }, { b: 2 }]);
155
+ });
156
+
157
+ it('creates file if not exists', () => {
158
+ const filePath = path.join(testDir, 'new-append.jsonl');
159
+
160
+ appendToJsonl(filePath, { a: 1 });
161
+
162
+ const result = readJsonlFileSync(filePath);
163
+ expect(result).toEqual([{ a: 1 }]);
164
+ });
165
+ });
166
+ });