File size: 10,175 Bytes
4e23b01
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
import { PassThrough } from 'node:stream';
import { setImmediate as defer, setTimeout as delay } from 'node:timers/promises';

import { describe, expect, it } from 'vitest';

import { BufferedReadable, decodeTextWithErrors, globPatternToRegex } from '#/internal';

async function collectBytes(readable: AsyncIterable<Uint8Array | string>): Promise<Buffer> {
  const chunks: Buffer[] = [];
  for await (const chunk of readable) {
    chunks.push(Buffer.from(chunk));
  }
  return Buffer.concat(chunks);
}

function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
  return Promise.race([
    promise,
    (async () => {
      await delay(timeoutMs);
      throw new Error(`timed out after ${timeoutMs}ms`);
    })(),
  ]);
}

describe('BufferedReadable', () => {
  it('preserves source backpressure until the consumer starts reading', () => {
    const source = new PassThrough({ highWaterMark: 64 * 1024 });
    const buffered = new BufferedReadable(source);
    const chunk = Buffer.alloc(32 * 1024, 0x61);

    let writes = 0;
    let writeOk = true;
    while (writes < 20 && writeOk) {
      writeOk = source.write(chunk);
      writes++;
    }

    buffered.destroy();
    source.destroy();

    expect(writeOk).toBe(false);
    expect(writes).toBeLessThan(10);
  });

  it('stops pushing when its readable buffer reaches highWaterMark', async () => {
    const source = new PassThrough();
    const chunk = Buffer.alloc(32 * 1024, 0x62);

    for (let index = 0; index < 10; index++) {
      source.write(chunk);
    }
    source.end();

    const buffered = new BufferedReadable(source);
    buffered.read(0);
    await defer();

    expect(buffered.readableLength).toBeLessThanOrEqual(buffered.readableHighWaterMark);

    const output = await collectBytes(buffered);
    expect(output).toHaveLength(chunk.length * 10);
  });

  it('ends async iteration when the source closes without emitting end', async () => {
    const source = new PassThrough();
    const buffered = new BufferedReadable(source);
    const outputPromise = collectBytes(buffered).then((output) => output.toString('utf8'));

    await defer();
    source.write('hello');
    await defer();
    source.destroy();

    await expect(withTimeout(outputPromise, 250)).resolves.toBe('hello');
  });

  it('propagates a source error through destroy()', async () => {
    // When the source emits an 'error' event, BufferedReadable must tear
    // itself down with the same error so consumers see the failure rather
    // than waiting forever for data.
    const source = new PassThrough();
    const buffered = new BufferedReadable(source);
    const boom = new Error('source boom');

    const errorReceived = new Promise<Error>((resolve) => {
      buffered.on('error', (err: Error) => {
        resolve(err);
      });
    });

    source.emit('error', boom);

    const received = await withTimeout(errorReceived, 250);
    expect(received).toBe(boom);
    expect(buffered.destroyed).toBe(true);
  });

  it('destroys the underlying source when the wrapper is destroyed', () => {
    const source = new PassThrough();
    const buffered = new BufferedReadable(source);

    buffered.destroy();

    expect(source.destroyed).toBe(true);
  });
});

describe('decodeTextWithErrors', () => {
  it('decodes utf-16le content under strict mode', () => {
    // Covers the utf16le / ucs2 / ucs-2 alias branches that the readText
    // tests (which only exercise utf-8) never touch.
    const data = Buffer.from('hello', 'utf16le');
    expect(decodeTextWithErrors(data, 'utf16le')).toBe('hello');
  });

  it('accepts the ucs2 and ucs-2 encoding aliases', () => {
    const data = Buffer.from('hello', 'utf16le');
    expect(decodeTextWithErrors(data, 'ucs2')).toBe('hello');
    expect(decodeTextWithErrors(data, 'ucs-2' as BufferEncoding)).toBe('hello');
  });

  it('falls back to Buffer.toString for non-UTF encodings', () => {
    // hex / base64 / latin1 are lossless byte↔character mappings so `errors`
    // has no effect; the helper must take the non-TextDecoder branch.
    const data = Buffer.from([0x68, 0x69]); // 'hi' in latin1
    expect(decodeTextWithErrors(data, 'latin1')).toBe('hi');
    expect(decodeTextWithErrors(data, 'hex')).toBe('6869');
  });

  it('preserves valid U+FFFD when ignoring invalid utf-8 bytes', () => {
    const data = Buffer.concat([
      Buffer.from('A\uFFFDB', 'utf-8'),
      Buffer.from([0xff]),
      Buffer.from('C', 'utf-8'),
    ]);

    expect(decodeTextWithErrors(data, 'utf-8', 'ignore')).toBe('A\uFFFDBC');
  });

  it('matches Python ignore behavior for invalid utf-8 sequence boundaries', () => {
    const cases: Buffer[] = [
      Buffer.from([0xc2, 0x41]),
      Buffer.from([0xe0, 0xa0, 0x41]),
      Buffer.from([0xf0, 0x90, 0x80, 0x41]),
      Buffer.from([0xed, 0xa0, 0x80, 0x41]),
    ];

    for (const data of cases) {
      expect(decodeTextWithErrors(data, 'utf-8', 'ignore')).toBe('A');
    }
  });

  it('preserves valid U+FFFD when ignoring invalid utf-16le code units', () => {
    const data = Buffer.concat([
      Buffer.from('A\uFFFDB', 'utf16le'),
      Buffer.from([0x3d, 0xd8]), // lone high surrogate
      Buffer.from('C', 'utf16le'),
      Buffer.from([0xff]), // trailing half code unit
    ]);

    expect(decodeTextWithErrors(data, 'utf16le', 'ignore')).toBe('A\uFFFDBC');
  });

  it('matches Python ignore behavior for utf-16le surrogate boundaries', () => {
    const emojiPair = Buffer.from([0x3d, 0xd8, 0x00, 0xde]);
    expect(decodeTextWithErrors(emojiPair, 'utf16le', 'ignore')).toBe('\u{1F600}');

    const highThenNormalThenLow = Buffer.from([0x3d, 0xd8, 0x58, 0x00, 0x00, 0xdc]);
    expect(decodeTextWithErrors(highThenNormalThenLow, 'utf16le', 'ignore')).toBe('X');
  });
});

describe('globPatternToRegex additional cases', () => {
  it('converts ? to a single-character class that excludes /', () => {
    const regex = globPatternToRegex('f?o.txt', true);
    expect(regex.test('foo.txt')).toBe(true);
    expect(regex.test('fao.txt')).toBe(true);
    // ? must match exactly one character
    expect(regex.test('fo.txt')).toBe(false);
    // ? must not cross path segments
    expect(regex.test('f/o.txt')).toBe(false);
  });

  it('treats an unclosed [ as a literal bracket', () => {
    // Mirrors Python fnmatch/glob: a bare `[` with no closing `]` is
    // re-emitted as an escaped literal instead of starting a char class.
    const regex = globPatternToRegex('file[', true);
    expect(regex.test('file[')).toBe(true);
    expect(regex.test('file]')).toBe(false);
  });

  it('escapes regex metacharacters in the default branch', () => {
    // `+`, `.`, `(`, `)`, `$`, `^`, `|`, `{`, `}`, `\` must be escaped so
    // `a+b.c$d(e)` in a glob pattern matches that literal filename.
    const regex = globPatternToRegex('a+b.c$d(e)', true);
    expect(regex.test('a+b.c$d(e)')).toBe(true);
    // Without escaping, `+` would make `ab` match via one-or-more repetition.
    expect(regex.test('ab.c$d(e)')).toBe(false);
  });

  it('treats backslash as an escape character for the next literal', () => {
    // `\[` and `\]` should match literal brackets in the filename.
    const regex = globPatternToRegex('special \\[bracket\\].ts', true);
    expect(regex.test('special [bracket].ts')).toBe(true);
    expect(regex.test('special bracket.ts')).toBe(false);
  });

  it('escapes backslashes inside character classes to avoid an unterminated regex', () => {
    // A trailing backslash inside a glob char class (`[abc\\]`) would
    // escape the closing `]` in the resulting regex. We escape it so the
    // regex stays valid and the backslash is treated as a literal member.
    const regex = globPatternToRegex('file[abc\\].txt', true);
    expect(regex.test('filea.txt')).toBe(true);
    expect(regex.test('file\\.txt')).toBe(true);
    expect(regex.test('filez.txt')).toBe(false);
  });
});

describe('globPatternToRegex', () => {
  it('treats [!...] as a negated character class', () => {
    const regex = globPatternToRegex('[!a].txt', true);

    expect(regex.test('a.txt')).toBe(false);
    expect(regex.test('b.txt')).toBe(true);
  });

  it('treats ^ as a literal character inside glob character classes', () => {
    const regex = globPatternToRegex('[^a].txt', true);

    expect(regex.test('^.txt')).toBe(true);
    expect(regex.test('a.txt')).toBe(true);
    expect(regex.test('b.txt')).toBe(false);
  });

  describe('glob semantic compatibility (Python parity)', () => {
    it('treats brace expansion syntax as literal characters', () => {
      const regex = globPatternToRegex('*.{js,ts}', true);

      expect(regex.test('file.js')).toBe(false);
      expect(regex.test('file.ts')).toBe(false);
      expect(regex.test('file.{js,ts}')).toBe(true);
    });

    it('treats leading ^ as a literal character inside character classes', () => {
      const regex = globPatternToRegex('[^a].txt', true);

      expect(regex.test('^.txt')).toBe(true);
      expect(regex.test('a.txt')).toBe(true);
      expect(regex.test('b.txt')).toBe(false);
    });

    it('uses ! for character class negation', () => {
      const regex = globPatternToRegex('[!a].txt', true);

      expect(regex.test('b.txt')).toBe(true);
      expect(regex.test('a.txt')).toBe(false);
    });

    it('matches hidden files with dot-prefixed patterns', () => {
      const regex = globPatternToRegex('.*', true);

      expect(regex.test('.hidden')).toBe(true);
      expect(regex.test('.config')).toBe(true);
    });

    it.skip('Python treats **/foo.txt as recursive; current helper is segment-based and does not implement zero-or-more directories', () => {
      const regex = globPatternToRegex('**/foo.txt', true);

      expect(regex.test('foo.txt')).toBe(true);
      expect(regex.test('a/foo.txt')).toBe(true);
      expect(regex.test('a/b/foo.txt')).toBe(true);
    });

    it('keeps single-star matching to a single path segment', () => {
      const regex = globPatternToRegex('*/foo.txt', true);

      expect(regex.test('a/foo.txt')).toBe(true);
      expect(regex.test('foo.txt')).toBe(false);
      expect(regex.test('a/b/foo.txt')).toBe(false);
    });
  });
});