|
| 1 | +// Property-based tests (tape-six-fast-check): the streaming invariants that |
| 2 | +// example-based tests structurally miss — the same document split at arbitrary |
| 3 | +// chunk boundaries must yield the same tokens (GHSA-hqr4-qq8f-hg3x was exactly |
| 4 | +// this class), and the parser/assembler and disassembler/stringer pairs must |
| 5 | +// round-trip against JSON.parse / JSON.stringify. |
| 6 | +import test from 'tape-six'; |
| 7 | +import fc from 'fast-check'; |
| 8 | +import 'tape-six-fast-check'; |
| 9 | +import chain from 'stream-chain'; |
| 10 | +import {none} from 'stream-chain/core'; |
| 11 | +import {Readable} from 'node:stream'; |
| 12 | + |
| 13 | +import {parser} from '../../src/index.js'; |
| 14 | +import {parser as jsoncParser} from '../../src/jsonc/parser.js'; |
| 15 | +import Assembler from '../../src/assembler.js'; |
| 16 | +import {disassembler} from '../../src/disassembler.js'; |
| 17 | +import {stringer} from '../../src/stringer.js'; |
| 18 | + |
| 19 | +const splitAt = (text, offsets) => { |
| 20 | + const cuts = [...new Set(offsets.map(o => o % (text.length + 1)))].sort((a, b) => a - b); |
| 21 | + const chunks = []; |
| 22 | + let prev = 0; |
| 23 | + for (const cut of cuts) { |
| 24 | + if (cut > prev) chunks.push(text.slice(prev, cut)); |
| 25 | + prev = cut; |
| 26 | + } |
| 27 | + if (prev < text.length) chunks.push(text.slice(prev)); |
| 28 | + return chunks; |
| 29 | +}; |
| 30 | + |
| 31 | +const tokensOf = (chunks, factory, options) => |
| 32 | + new Promise((resolve, reject) => { |
| 33 | + const tokens = [], |
| 34 | + pipeline = chain([Readable.from(chunks), factory(options)]); |
| 35 | + pipeline.on('data', token => tokens.push(token)); |
| 36 | + pipeline.on('error', reject); |
| 37 | + pipeline.on('end', () => resolve(tokens)); |
| 38 | + }); |
| 39 | + |
| 40 | +// merge streamed pieces so two splits of the same text compare equal |
| 41 | +const coalesce = tokens => { |
| 42 | + const out = []; |
| 43 | + for (const token of tokens) { |
| 44 | + const last = out[out.length - 1]; |
| 45 | + if (last && last.name === token.name && (token.name === 'stringChunk' || token.name === 'numberChunk' || token.name === 'commentChunk')) { |
| 46 | + out[out.length - 1] = {name: last.name, value: last.value + token.value}; |
| 47 | + } else { |
| 48 | + out.push(token); |
| 49 | + } |
| 50 | + } |
| 51 | + return out.filter(token => token.name !== 'whitespace'); |
| 52 | +}; |
| 53 | + |
| 54 | +const same = (a, b) => JSON.stringify(a) === JSON.stringify(b); |
| 55 | +const json = () => fc.jsonValue({maxDepth: 4}); |
| 56 | +const offsets = () => fc.array(fc.nat(4096), {maxLength: 12}); |
| 57 | + |
| 58 | +test('parser: chunk boundaries do not change the token stream', async t => { |
| 59 | + await t.prop( |
| 60 | + [json(), offsets()], |
| 61 | + async (value, cuts) => { |
| 62 | + const text = JSON.stringify(value); |
| 63 | + return same(await tokensOf([text], parser, {streamValues: false}), await tokensOf(splitAt(text, cuts), parser, {streamValues: false})); |
| 64 | + }, |
| 65 | + {numRuns: 100}, |
| 66 | + 'packed tokens are split-invariant' |
| 67 | + ); |
| 68 | + await t.prop( |
| 69 | + [json(), offsets()], |
| 70 | + async (value, cuts) => { |
| 71 | + const text = JSON.stringify(value); |
| 72 | + return same(coalesce(await tokensOf([text], parser)), coalesce(await tokensOf(splitAt(text, cuts), parser))); |
| 73 | + }, |
| 74 | + {numRuns: 100}, |
| 75 | + 'streamed chunks coalesce to the same tokens' |
| 76 | + ); |
| 77 | +}); |
| 78 | + |
| 79 | +test('parser → assembler round-trips against JSON.parse', async t => { |
| 80 | + await t.prop( |
| 81 | + [json(), offsets()], |
| 82 | + async (value, cuts) => { |
| 83 | + const asm = new Assembler(); |
| 84 | + for (const token of await tokensOf(splitAt(JSON.stringify(value), cuts), parser)) asm.consume(token); |
| 85 | + return same(asm.current, value); |
| 86 | + }, |
| 87 | + {numRuns: 100}, |
| 88 | + 'assembled value equals the original' |
| 89 | + ); |
| 90 | +}); |
| 91 | + |
| 92 | +test('disassembler → stringer round-trips against JSON.stringify', async t => { |
| 93 | + await t.prop( |
| 94 | + [json()], |
| 95 | + value => { |
| 96 | + const write = stringer(); |
| 97 | + let text = ''; |
| 98 | + for (const token of disassembler()(value)) { |
| 99 | + const piece = write(token); |
| 100 | + if (piece !== none) text += piece; |
| 101 | + } |
| 102 | + const tail = write(none); |
| 103 | + if (tail !== none) text += tail; |
| 104 | + return same(JSON.parse(text), value); |
| 105 | + }, |
| 106 | + {numRuns: 200}, |
| 107 | + 'stringer output parses back to the original' |
| 108 | + ); |
| 109 | +}); |
| 110 | + |
| 111 | +// JSONC: comments and whitespace inserted between tokens, then split anywhere — |
| 112 | +// comments may straddle chunk boundaries, including their delimiters |
| 113 | +const commentBody = () => fc.string({maxLength: 12}).filter(s => !s.includes('*/') && !/[\r\n]/.test(s)); |
| 114 | +const decorate = (pretty, kinds, bodies) => { |
| 115 | + let i = 0; |
| 116 | + return pretty.replace(/\n/g, () => { |
| 117 | + const kind = kinds[i % kinds.length], |
| 118 | + body = bodies[i % bodies.length]; |
| 119 | + ++i; |
| 120 | + return kind === 1 ? ' /*' + body + '*/\n' : kind === 2 ? ' //' + body + '\n' : '\n'; |
| 121 | + }); |
| 122 | +}; |
| 123 | + |
| 124 | +test('jsonc parser: comments survive arbitrary chunk boundaries', async t => { |
| 125 | + await t.prop( |
| 126 | + [json(), fc.array(fc.constantFrom(0, 1, 2), {minLength: 1, maxLength: 8}), fc.array(commentBody(), {minLength: 1, maxLength: 8}), offsets()], |
| 127 | + async (value, kinds, bodies, cuts) => { |
| 128 | + const text = decorate(JSON.stringify(value, null, 1), kinds, bodies); |
| 129 | + const whole = coalesce(await tokensOf([text], jsoncParser)), |
| 130 | + chunked = coalesce(await tokensOf(splitAt(text, cuts), jsoncParser)); |
| 131 | + if (!same(whole, chunked)) return false; |
| 132 | + const asm = new Assembler(); |
| 133 | + for (const token of chunked) asm.consume(token); |
| 134 | + return same(asm.current, value); |
| 135 | + }, |
| 136 | + {numRuns: 100}, |
| 137 | + 'tokens and comments are split-invariant' |
| 138 | + ); |
| 139 | +}); |
0 commit comments