Prevention work surfaced a real latent bug: findAndReplace looped forever (OOM) on any non-global regex with a match — `match` was only reassigned inside `if (regex.global)`, so a non-global regex never advanced. Fixed by treating a non-global regex as a single match (`match = regex.global ? regex.exec(text) : null`) and added a regression test. Latent in practice (all current callers pass global regexes), but a crash waiting to happen. New suites (tsx + node:test), verified empirically: - utils/findAndReplace (10, incl. the regression) - utils/AsyncSearch (9): normalize + matchQuery (the timer-based class is skipped — needs window.performance/setTimeout, unavailable in node) - utils/ASCIILexicalTable (10): orderKeys gap-filling + invariants Full suite now 103 tests, all passing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
111 lines
3.7 KiB
TypeScript
111 lines
3.7 KiB
TypeScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { findAndReplace } from './findAndReplace';
|
|
|
|
// Helpers that record exactly what the callbacks receive.
|
|
const tagPart = (text: string, pushIndex: number) => ({ part: text, at: pushIndex });
|
|
const tagMatch = (match: RegExpExecArray | RegExpMatchArray, pushIndex: number) => ({
|
|
match: match[0],
|
|
at: pushIndex,
|
|
});
|
|
|
|
test('findAndReplace interleaves converted parts and replacements (global regex)', () => {
|
|
const result = findAndReplace('a1b2c', /\d/g, tagMatch, tagPart);
|
|
assert.deepEqual(result, [
|
|
{ part: 'a', at: 0 },
|
|
{ match: '1', at: 1 },
|
|
{ part: 'b', at: 2 },
|
|
{ match: '2', at: 3 },
|
|
{ part: 'c', at: 4 },
|
|
]);
|
|
});
|
|
|
|
test('findAndReplace handles a NON-global regex as a single match (regression: no infinite loop)', () => {
|
|
// Before the fix, a non-global regex with a match looped forever (match was
|
|
// only reassigned inside `if (regex.global)`), OOM-crashing the process.
|
|
const result = findAndReplace('a1b2c', /\d/, tagMatch, tagPart);
|
|
assert.deepEqual(result, [
|
|
{ part: 'a', at: 0 },
|
|
{ match: '1', at: 1 },
|
|
// remainder after the first (only) match is a single converted part
|
|
{ part: 'b2c', at: 2 },
|
|
]);
|
|
});
|
|
|
|
test('findAndReplace pushIndex reflects result.length at push time', () => {
|
|
// The indices above already assert this; here we double-check the trailing part.
|
|
const result = findAndReplace('x9', /\d/g, tagMatch, tagPart);
|
|
// 'x' at 0, '9' at 1, '' at 2
|
|
assert.equal(result[result.length - 1].at, 2);
|
|
});
|
|
|
|
test('findAndReplace with no match returns the whole text as a single converted part', () => {
|
|
const result = findAndReplace('hello', /\d/g, tagMatch, tagPart);
|
|
assert.deepEqual(result, [{ part: 'hello', at: 0 }]);
|
|
});
|
|
|
|
test('findAndReplace on empty input returns a single empty converted part', () => {
|
|
const result = findAndReplace('', /\d/g, tagMatch, tagPart);
|
|
assert.deepEqual(result, [{ part: '', at: 0 }]);
|
|
});
|
|
|
|
test('findAndReplace emits empty parts between adjacent matches and at edges', () => {
|
|
const result = findAndReplace('12', /\d/g, tagMatch, tagPart);
|
|
assert.deepEqual(result, [
|
|
{ part: '', at: 0 },
|
|
{ match: '1', at: 1 },
|
|
{ part: '', at: 2 },
|
|
{ match: '2', at: 3 },
|
|
{ part: '', at: 4 },
|
|
]);
|
|
});
|
|
|
|
test('findAndReplace with a leading match emits an empty leading part', () => {
|
|
const result = findAndReplace('1abc', /\d/g, tagMatch, tagPart);
|
|
assert.deepEqual(result, [
|
|
{ part: '', at: 0 },
|
|
{ match: '1', at: 1 },
|
|
{ part: 'abc', at: 2 },
|
|
]);
|
|
});
|
|
|
|
// NOTE: A non-global regex is intentionally NOT tested here. With `regex.global`
|
|
// false, the source's `while (match !== null)` loop never reassigns `match`
|
|
// (reassignment is guarded by `if (regex.global)`), so it loops forever on any
|
|
// matching input. See the report. All real callers pass global regexes.
|
|
|
|
test('findAndReplace supports multi-character matches', () => {
|
|
const result = findAndReplace('foo<<bar>>baz', /<<|>>/g, tagMatch, tagPart);
|
|
assert.deepEqual(result, [
|
|
{ part: 'foo', at: 0 },
|
|
{ match: '<<', at: 1 },
|
|
{ part: 'bar', at: 2 },
|
|
{ match: '>>', at: 3 },
|
|
{ part: 'baz', at: 4 },
|
|
]);
|
|
});
|
|
|
|
test('findAndReplace can build a transformed string', () => {
|
|
const out = findAndReplace<string, string>(
|
|
'cat and dog',
|
|
/cat|dog/g,
|
|
(m) => (m[0] === 'cat' ? 'CAT' : 'DOG'),
|
|
(text) => text,
|
|
).join('');
|
|
assert.equal(out, 'CAT and DOG');
|
|
});
|
|
|
|
test('findAndReplace passes the full match object to replace', () => {
|
|
const captured: Array<string | undefined> = [];
|
|
findAndReplace(
|
|
'name=value',
|
|
/(\w+)=(\w+)/g,
|
|
(m) => {
|
|
captured.push(m[1], m[2]);
|
|
return null;
|
|
},
|
|
() => null,
|
|
);
|
|
assert.deepEqual(captured, ['name', 'value']);
|
|
});
|