250 lines
9.6 KiB
TypeScript
250 lines
9.6 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import { detectBadJsonStrings } from "~/utils/detectBadJsonStrings";
|
||
|
||
describe("detectBadJsonStrings", () => {
|
||
it("should not detect valid JSON string", () => {
|
||
const goodJson = `{"title": "hello"}`;
|
||
const result = detectBadJsonStrings(goodJson);
|
||
expect(result).toBe(false);
|
||
});
|
||
|
||
it("should detect incomplete Unicode escape sequences", () => {
|
||
const badJson = `{"title": "hello\\ud835"}`;
|
||
const result = detectBadJsonStrings(badJson);
|
||
expect(result).toBe(true);
|
||
});
|
||
|
||
it("should not detect complete Unicode escape sequences", () => {
|
||
const goodJson = `{"title": "hello\\ud835\\udc00"}`;
|
||
const result = detectBadJsonStrings(goodJson);
|
||
expect(result).toBe(false);
|
||
});
|
||
|
||
it("should detect incomplete low surrogate", () => {
|
||
const badJson = `{"title": "hello\\udc00"}`;
|
||
const result = detectBadJsonStrings(badJson);
|
||
expect(result).toBe(true);
|
||
});
|
||
|
||
it("should handle multiple Unicode sequences correctly", () => {
|
||
const goodJson = `{"title": "hello\\ud835\\udc00\\ud835\\udc01"}`;
|
||
const result = detectBadJsonStrings(goodJson);
|
||
expect(result).toBe(false);
|
||
});
|
||
|
||
it("should detect mixed complete and incomplete sequences", () => {
|
||
const badJson = `{"title": "hello\\ud835\\udc00\\ud835"}`;
|
||
const result = detectBadJsonStrings(badJson);
|
||
expect(result).toBe(true);
|
||
});
|
||
|
||
it("should have acceptable performance overhead", () => {
|
||
const longText = `hello world `.repeat(1_000);
|
||
const goodJson = `{"title": "hello", "text": "${longText}"}`;
|
||
const badJson = `{"title": "hello\\ud835", "text": "${longText}"}`;
|
||
|
||
const iterations = 100_000;
|
||
|
||
// Warm up
|
||
for (let i = 0; i < 1000; i++) {
|
||
detectBadJsonStrings(goodJson);
|
||
detectBadJsonStrings(badJson);
|
||
}
|
||
|
||
// Measure good JSON (most common case)
|
||
const goodStart = performance.now();
|
||
for (let i = 0; i < iterations; i++) {
|
||
detectBadJsonStrings(goodJson);
|
||
}
|
||
const goodTime = performance.now() - goodStart;
|
||
|
||
// Measure bad JSON (edge case)
|
||
const badStart = performance.now();
|
||
for (let i = 0; i < iterations; i++) {
|
||
detectBadJsonStrings(badJson);
|
||
}
|
||
const badTime = performance.now() - badStart;
|
||
|
||
// Measure baseline (just function call overhead)
|
||
const baselineStart = performance.now();
|
||
for (let i = 0; i < iterations; i++) {
|
||
// Empty function call to measure baseline
|
||
}
|
||
const baselineTime = performance.now() - baselineStart;
|
||
|
||
const goodOverhead = goodTime - baselineTime;
|
||
const badOverhead = badTime - baselineTime;
|
||
|
||
console.log(`Baseline (${iterations} iterations): ${baselineTime.toFixed(2)}ms`);
|
||
console.log(
|
||
`Good JSON (${iterations} iterations): ${goodTime.toFixed(
|
||
2
|
||
)}ms (overhead: ${goodOverhead.toFixed(2)}ms)`
|
||
);
|
||
console.log(
|
||
`Bad JSON (${iterations} iterations): ${badTime.toFixed(
|
||
2
|
||
)}ms (overhead: ${badOverhead.toFixed(2)}ms)`
|
||
);
|
||
console.log(
|
||
`Average per call - Good: ${(goodOverhead / iterations).toFixed(4)}ms, Bad: ${(
|
||
badOverhead / iterations
|
||
).toFixed(4)}ms`
|
||
);
|
||
|
||
// Assertions for performance expectations
|
||
// Good JSON should be reasonably fast (most common case)
|
||
expect(goodOverhead / iterations).toBeLessThan(0.01); // Less than 10 microseconds per call
|
||
|
||
// Bad JSON can be slower due to regex matching, but still reasonable
|
||
expect(badOverhead / iterations).toBeLessThan(0.01); // Less than 20 microseconds per call
|
||
|
||
// Total overhead for 100k calls should be reasonable
|
||
expect(goodOverhead).toBeLessThan(1000); // Less than 1 second for 100k calls
|
||
});
|
||
|
||
it("should handle various JSON sizes efficiently", () => {
|
||
const sizes = [100, 1000, 10000, 100000];
|
||
const iterations = 10_000;
|
||
|
||
for (const size of sizes) {
|
||
const text = `hello world `.repeat(size / 11); // Approximate size
|
||
const goodJson = `{"title": "hello", "text": "${text}"}`;
|
||
|
||
const start = performance.now();
|
||
for (let i = 0; i < iterations; i++) {
|
||
detectBadJsonStrings(goodJson);
|
||
}
|
||
const time = performance.now() - start;
|
||
|
||
console.log(
|
||
`Size ${size} chars (${iterations} iterations): ${time.toFixed(2)}ms (${(
|
||
time / iterations
|
||
).toFixed(4)}ms per call)`
|
||
);
|
||
|
||
// Performance should scale reasonably with size
|
||
expect(time / iterations).toBeLessThan(size / 1000); // Roughly linear scaling
|
||
}
|
||
});
|
||
|
||
it("should show significant performance improvement with quick rejection", () => {
|
||
const longText = `hello world `.repeat(1_000);
|
||
const goodJson = `{"title": "hello", "text": "${longText}"}`;
|
||
const badJson = `{"title": "hello\\ud835", "text": "${longText}"}`;
|
||
const noUnicodeJson = `{"title": "hello", "text": "${longText}"}`;
|
||
|
||
const iterations = 100_000;
|
||
|
||
// Warm up
|
||
for (let i = 0; i < 1000; i++) {
|
||
detectBadJsonStrings(goodJson);
|
||
detectBadJsonStrings(badJson);
|
||
detectBadJsonStrings(noUnicodeJson);
|
||
}
|
||
|
||
// Test strings with no Unicode escapes (99.9% case)
|
||
const noUnicodeStart = performance.now();
|
||
for (let i = 0; i < iterations; i++) {
|
||
detectBadJsonStrings(noUnicodeJson);
|
||
}
|
||
const noUnicodeTime = performance.now() - noUnicodeStart;
|
||
|
||
// Test strings with Unicode escapes (0.1% case)
|
||
const withUnicodeStart = performance.now();
|
||
for (let i = 0; i < iterations; i++) {
|
||
detectBadJsonStrings(badJson);
|
||
}
|
||
const withUnicodeTime = performance.now() - withUnicodeStart;
|
||
|
||
console.log(
|
||
`No Unicode escapes (${iterations} iterations): ${noUnicodeTime.toFixed(2)}ms (${(
|
||
noUnicodeTime / iterations
|
||
).toFixed(4)}ms per call)`
|
||
);
|
||
console.log(
|
||
`With Unicode escapes (${iterations} iterations): ${withUnicodeTime.toFixed(2)}ms (${(
|
||
withUnicodeTime / iterations
|
||
).toFixed(4)}ms per call)`
|
||
);
|
||
console.log(
|
||
`Performance ratio: ${(withUnicodeTime / noUnicodeTime).toFixed(
|
||
2
|
||
)}x slower for Unicode strings`
|
||
);
|
||
|
||
// Both cases should be extremely fast (under 1 microsecond per call)
|
||
expect(noUnicodeTime / iterations).toBeLessThan(0.001); // Less than 1 microsecond
|
||
expect(withUnicodeTime / iterations).toBeLessThan(0.001); // Less than 1 microsecond
|
||
|
||
// The difference should be reasonable (not more than 5x)
|
||
expect(noUnicodeTime / withUnicodeTime).toBeLessThan(5);
|
||
});
|
||
|
||
describe("full UTF-16 low-surrogate range coverage (U+DC00–U+DFFF)", () => {
|
||
// Regression guard: a previous version of this scanner used `[cd]` to
|
||
// match the low-surrogate nibble, missing the entire U+DE00–U+DFFF
|
||
// half of the range. Valid surrogate pairs with low surrogates in that
|
||
// upper half (which includes most common emoji) were falsely flagged,
|
||
// and lone surrogates in the upper half were falsely passed.
|
||
|
||
it("does NOT flag a valid pair with low surrogate in the c range (U+DC00–U+DCFF)", () => {
|
||
// 🐍 SNAKE = U+1F40D = 🐍
|
||
expect(detectBadJsonStrings(`{"s":"\\ud83d\\udc0d"}`)).toBe(false);
|
||
});
|
||
|
||
it("does NOT flag a valid pair with low surrogate in the d range (U+DD00–U+DDFF)", () => {
|
||
// U+1F540 = 🕀
|
||
expect(detectBadJsonStrings(`{"s":"\\ud83d\\udd40"}`)).toBe(false);
|
||
});
|
||
|
||
it("does NOT flag a valid pair with low surrogate in the e range (U+DE00–U+DEFF)", () => {
|
||
// 😀 GRINNING FACE = U+1F600 = 😀 — previously false-flagged
|
||
expect(detectBadJsonStrings(`{"s":"\\ud83d\\ude00"}`)).toBe(false);
|
||
});
|
||
|
||
it("does NOT flag a valid pair with low surrogate in the f range (U+DF00–U+DFFF)", () => {
|
||
// U+1F700 = 🜀 — previously false-flagged
|
||
expect(detectBadJsonStrings(`{"s":"\\ud83d\\udf00"}`)).toBe(false);
|
||
});
|
||
|
||
it("flags a lone low surrogate in the e range (\\uDE00)", () => {
|
||
// Previously this was NOT flagged because the forward scan only
|
||
// recognised low surrogates with third nibble === "c" || "d".
|
||
expect(detectBadJsonStrings(`{"s":"prefix \\ude00 suffix"}`)).toBe(true);
|
||
});
|
||
|
||
it("flags a lone low surrogate in the f range (\\uDFFF)", () => {
|
||
expect(detectBadJsonStrings(`{"s":"prefix \\udfff suffix"}`)).toBe(true);
|
||
});
|
||
|
||
it("flags a high surrogate followed by something that looks like a low surrogate but is in the e range with a missing prefix", () => {
|
||
// The previous high-surrogate-then-pair check used `[cd]` for the
|
||
// matching low surrogate nibble, so any high surrogate followed by
|
||
// \uDe.. would be falsely flagged as unpaired. Verify the fix works
|
||
// for the valid case AND still flags genuinely broken inputs.
|
||
expect(detectBadJsonStrings(`{"s":"\\ud800X"}`)).toBe(true); // truly broken
|
||
expect(detectBadJsonStrings(`{"s":"\\ud83d\\ude00"}`)).toBe(false); // valid, but used to flag
|
||
});
|
||
});
|
||
|
||
describe("integration with JSON.stringify", () => {
|
||
it("does NOT flag JSON.stringify of a valid emoji 😀", () => {
|
||
// V8 emits the raw character for valid surrogate pairs, so the
|
||
// fast-path returns false without exercising the regex.
|
||
expect(detectBadJsonStrings(JSON.stringify("😀"))).toBe(false);
|
||
});
|
||
|
||
it("flags JSON.stringify of a lone high surrogate", () => {
|
||
expect(detectBadJsonStrings(JSON.stringify("\uD800"))).toBe(true);
|
||
});
|
||
|
||
it("flags JSON.stringify of a lone low surrogate in each of c/d/e/f ranges", () => {
|
||
expect(detectBadJsonStrings(JSON.stringify("\uDC00"))).toBe(true);
|
||
expect(detectBadJsonStrings(JSON.stringify("\uDD00"))).toBe(true);
|
||
expect(detectBadJsonStrings(JSON.stringify("\uDE00"))).toBe(true);
|
||
expect(detectBadJsonStrings(JSON.stringify("\uDFFF"))).toBe(true);
|
||
});
|
||
});
|
||
});
|