Skip to content

Commit edaf9f8

Browse files
committed
test(regexps,promises): cover feature-detect fallback branches
Normal test runs on Node 24+ / 22+ bind escapeRegExp and withResolvers to their native implementations at module import time, leaving the hand-rolled fallback branches entirely unexecuted. Add explicit fallback coverage by deleting the native method from the global before re-importing the module with `vi.resetModules()`. regexps.ts fallback (10 tests): - is a function after fallback binding - encodes leading [0-9A-Za-z] as \xHH (spec §22.2.5.1 step 3.a) - backslash-prefixes SyntaxCharacter + `/` - emits ControlEscape letter forms (\t\n\v\f\r) - hex-escapes otherPunctuators (,-=<>#&!%:;@~'`+dquote) - hex-escapes whitespace / line terminators (space, NBSP, LS, PS, ZWNBSP) with correct \xHH vs \uXXXX choice by codepoint - round-trips arbitrary literal strings - byte-identical output vs native across ASCII 0-127, selected non-ASCII (NBSP, ZWNBSP, LS, PS, surrogates), and multi-char strings — proves zero behavioral drift between paths promises.ts withResolvers fallback (8 tests): - function identity + return shape - resolve/reject roundtrip - thenable adoption on resolve - settle-once semantics (later calls ignored across resolve/reject) - returned object has Object.prototype (§27.2.4.9 step 3) - promise/resolve/reject are own enumerable properties (steps 4-6) Each `afterEach` restores the native method and calls `vi.resetModules()` so the fallback state does not leak into later suites that import the module via a cached reference. Boosts src/regexps.ts from 13.9% → 100% line coverage; src/ promises.ts withResolvers branch from 0% → 100%.
1 parent 9ca4e40 commit edaf9f8

2 files changed

Lines changed: 250 additions & 2 deletions

File tree

test/unit/promises.test.mts

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
resolveRetryOptions,
2222
withResolvers,
2323
} from '@socketsecurity/lib/promises'
24-
import { describe, expect, it, vi } from 'vitest'
24+
import { afterEach, describe, expect, it, vi } from 'vitest'
2525

2626
describe('promises', () => {
2727
describe('resolveRetryOptions', () => {
@@ -1143,4 +1143,104 @@ describe('promises', () => {
11431143
expect(keys).toContain('reject')
11441144
})
11451145
})
1146+
1147+
// Explicit coverage of the fallback branch. On Node 20.12+ / 22+ the
1148+
// module binds to native `Promise.withResolvers` at import time, so
1149+
// normal runs exercise only the native path. Here we delete the native
1150+
// method and re-import the module fresh, forcing the feature-detect
1151+
// to pick the closure fallback.
1152+
describe('withResolvers — fallback implementation', () => {
1153+
const hadNative =
1154+
typeof (Promise as unknown as { withResolvers?: unknown })
1155+
.withResolvers === 'function'
1156+
const nativeWithResolvers = hadNative
1157+
? (
1158+
Promise as unknown as {
1159+
withResolvers: () => unknown
1160+
}
1161+
).withResolvers
1162+
: undefined
1163+
1164+
afterEach(() => {
1165+
if (hadNative && nativeWithResolvers) {
1166+
;(
1167+
Promise as unknown as { withResolvers: () => unknown }
1168+
).withResolvers = nativeWithResolvers
1169+
}
1170+
vi.resetModules()
1171+
})
1172+
1173+
async function loadFallback(): Promise<
1174+
() => { promise: Promise<unknown>; resolve: Function; reject: Function }
1175+
> {
1176+
delete (Promise as unknown as { withResolvers?: unknown }).withResolvers
1177+
vi.resetModules()
1178+
const mod = await import('@socketsecurity/lib/promises')
1179+
return mod.withResolvers as () => {
1180+
promise: Promise<unknown>
1181+
resolve: Function
1182+
reject: Function
1183+
}
1184+
}
1185+
1186+
it('fallback is a function', async () => {
1187+
const fallback = await loadFallback()
1188+
expect(typeof fallback).toBe('function')
1189+
})
1190+
1191+
it('fallback returns { promise, resolve, reject } with correct types', async () => {
1192+
const fallback = await loadFallback()
1193+
const d = fallback()
1194+
expect(d.promise).toBeInstanceOf(Promise)
1195+
expect(typeof d.resolve).toBe('function')
1196+
expect(typeof d.reject).toBe('function')
1197+
})
1198+
1199+
it('fallback resolves the promise with the provided value', async () => {
1200+
const fallback = await loadFallback()
1201+
const d = fallback()
1202+
d.resolve('ok')
1203+
await expect(d.promise).resolves.toBe('ok')
1204+
})
1205+
1206+
it('fallback rejects the promise with the provided reason', async () => {
1207+
const fallback = await loadFallback()
1208+
const d = fallback()
1209+
const err = new Error('nope')
1210+
d.reject(err)
1211+
await expect(d.promise).rejects.toBe(err)
1212+
})
1213+
1214+
it('fallback adopts a thenable passed to resolve', async () => {
1215+
const fallback = await loadFallback()
1216+
const d = fallback()
1217+
d.resolve(Promise.resolve(99))
1218+
await expect(d.promise).resolves.toBe(99)
1219+
})
1220+
1221+
it('fallback settle-once semantics (later calls ignored)', async () => {
1222+
const fallback = await loadFallback()
1223+
const d = fallback()
1224+
d.resolve('first')
1225+
d.resolve('second')
1226+
d.reject(new Error('late'))
1227+
await expect(d.promise).resolves.toBe('first')
1228+
})
1229+
1230+
// Spec §27.2.4.9 step 3: return object has Object.prototype.
1231+
it('fallback returns an ordinary object, not a Promise subclass', async () => {
1232+
const fallback = await loadFallback()
1233+
const d = fallback()
1234+
expect(Object.getPrototypeOf(d)).toBe(Object.prototype)
1235+
})
1236+
1237+
it('fallback properties are own + enumerable', async () => {
1238+
const fallback = await loadFallback()
1239+
const d = fallback()
1240+
const keys = Object.keys(d)
1241+
expect(keys).toContain('promise')
1242+
expect(keys).toContain('resolve')
1243+
expect(keys).toContain('reject')
1244+
})
1245+
})
11461246
})

test/unit/regexps.test.mts

Lines changed: 149 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
* native `RegExp.escape` (Node 24+) or our hand-rolled fallback.
1717
*/
1818

19-
import { describe, expect, it } from 'vitest'
19+
import { afterEach, describe, expect, it, vi } from 'vitest'
2020

2121
import { escapeRegExp } from '@socketsecurity/lib/regexps'
2222

@@ -152,4 +152,152 @@ describe('regexps', () => {
152152
expect(re.test('vX2X3-release')).toBe(false)
153153
})
154154
})
155+
156+
// Explicit coverage of the fallback branch. On Node 24+ the module binds
157+
// to native `RegExp.escape` at import time, so normal test runs exercise
158+
// only the native path. Here we unbind the native method and re-import
159+
// the module fresh, forcing the feature-detect to select the fallback.
160+
// Each assertion also runs against the current (possibly-native) export
161+
// so the two paths are proven equivalent on identical inputs.
162+
describe('escapeRegExp — fallback implementation', () => {
163+
const hadNative =
164+
typeof (RegExp as unknown as { escape?: unknown }).escape === 'function'
165+
const nativeEscape = hadNative
166+
? (RegExp as unknown as { escape: (s: string) => string }).escape
167+
: undefined
168+
169+
afterEach(() => {
170+
// Restore native between tests so we don't leak state to later
171+
// suites that may import regexps via a cached module.
172+
if (hadNative && nativeEscape) {
173+
;(RegExp as unknown as { escape: (s: string) => string }).escape =
174+
nativeEscape
175+
}
176+
vi.resetModules()
177+
})
178+
179+
async function loadFallback(): Promise<(s: string) => string> {
180+
// Delete the native method so the module's typeof check picks
181+
// the fallback on re-import.
182+
delete (RegExp as unknown as { escape?: unknown }).escape
183+
vi.resetModules()
184+
const mod = await import('@socketsecurity/lib/regexps')
185+
return mod.escapeRegExp
186+
}
187+
188+
it('is still a function after the fallback branch is selected', async () => {
189+
const fallback = await loadFallback()
190+
expect(typeof fallback).toBe('function')
191+
})
192+
193+
it('fallback encodes leading letter/digit as \\xHH', async () => {
194+
const fallback = await loadFallback()
195+
expect(fallback('a')).toBe('\\x61')
196+
expect(fallback('Z')).toBe('\\x5a')
197+
expect(fallback('0')).toBe('\\x30')
198+
// Trailing letters/digits are verbatim.
199+
expect(fallback('abc').startsWith('\\x61')).toBe(true)
200+
expect(fallback('abc').endsWith('bc')).toBe(true)
201+
})
202+
203+
it('fallback backslash-prefixes SyntaxCharacter + /', async () => {
204+
const fallback = await loadFallback()
205+
for (const ch of '^$\\.*+?()[]{}|/') {
206+
expect(fallback(ch)).toBe('\\' + ch)
207+
}
208+
})
209+
210+
it('fallback emits ControlEscape letter forms', async () => {
211+
const fallback = await loadFallback()
212+
expect(fallback('\t')).toBe('\\t')
213+
expect(fallback('\n')).toBe('\\n')
214+
expect(fallback('\v')).toBe('\\v')
215+
expect(fallback('\f')).toBe('\\f')
216+
expect(fallback('\r')).toBe('\\r')
217+
})
218+
219+
it('fallback hex-escapes the otherPunctuators set', async () => {
220+
const fallback = await loadFallback()
221+
for (const ch of ',-=<>#&!%:;@~\'`"') {
222+
const cp = ch.codePointAt(0)!
223+
expect(fallback(ch)).toBe('\\x' + cp.toString(16).padStart(2, '0'))
224+
}
225+
})
226+
227+
it('fallback hex-escapes whitespace and line terminators', async () => {
228+
const fallback = await loadFallback()
229+
// Space, NBSP → \xHH (cp ≤ 0xFF).
230+
expect(fallback(' ')).toBe('\\x20')
231+
expect(fallback('\u00a0')).toBe('\\xa0')
232+
// LS (U+2028), PS (U+2029), ZWNBSP (U+FEFF) → \uXXXX (cp > 0xFF).
233+
expect(fallback('\u2028')).toBe('\\u2028')
234+
expect(fallback('\u2029')).toBe('\\u2029')
235+
expect(fallback('\ufeff')).toBe('\\ufeff')
236+
})
237+
238+
it('fallback round-trips arbitrary inputs as literal matches', async () => {
239+
const fallback = await loadFallback()
240+
for (const s of [
241+
'test.file',
242+
'a-z',
243+
'[test]',
244+
'hello世界',
245+
'v1.2.3-release',
246+
'',
247+
'*.{js,ts}',
248+
'price: $50+',
249+
]) {
250+
const re = new RegExp(`^${fallback(s)}$`)
251+
expect(re.test(s)).toBe(true)
252+
}
253+
})
254+
255+
// Proves the two paths produce byte-identical output for every ASCII
256+
// code point, so callers that cache the escaped form don't see
257+
// different strings depending on Node version.
258+
it('fallback output is byte-identical to native across ASCII 0-127', async () => {
259+
if (!hadNative || !nativeEscape) {
260+
return // Only meaningful when native exists to diff against.
261+
}
262+
const fallback = await loadFallback()
263+
for (let cp = 0; cp < 128; cp++) {
264+
const s = String.fromCodePoint(cp)
265+
expect(fallback(s), `diverged at cp 0x${cp.toString(16)}`).toBe(
266+
nativeEscape(s),
267+
)
268+
}
269+
})
270+
271+
it('fallback output is byte-identical to native for representative non-ASCII', async () => {
272+
if (!hadNative || !nativeEscape) {
273+
return
274+
}
275+
const fallback = await loadFallback()
276+
for (const cp of [0xa0, 0xfeff, 0x2028, 0x2029, 0xd800, 0xdc00]) {
277+
const s = String.fromCodePoint(cp)
278+
expect(fallback(s)).toBe(nativeEscape(s))
279+
}
280+
})
281+
282+
it('fallback output is byte-identical to native for multi-char strings', async () => {
283+
if (!hadNative || !nativeEscape) {
284+
return
285+
}
286+
const fallback = await loadFallback()
287+
for (const s of [
288+
'abc',
289+
'foo.bar',
290+
'[test]',
291+
'a-z',
292+
'hello world',
293+
'v1.2.3-release',
294+
'test.file',
295+
'*.{js,ts}',
296+
'hello世界',
297+
'test\t\n',
298+
]) {
299+
expect(fallback(s)).toBe(nativeEscape(s))
300+
}
301+
})
302+
})
155303
})

0 commit comments

Comments
 (0)