Skip to content

Commit ecc8bd6

Browse files
committed
feat(trace): Creating unit tests for parser.trace()
1 parent 818c4ff commit ecc8bd6

File tree

3 files changed

+256
-72
lines changed

3 files changed

+256
-72
lines changed

src/lib/debug/trace.js

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ export function createTracer({
4949

5050
// Keep originals so we can avoid double wrapping or restore later
5151
const originals = new WeakMap()
52+
// Track wrapped parsers so we can iterate at restore time
53+
const wrapped = new Set()
5254

5355
function overlaps(start, end) {
5456
// true if [start,end) intersects [winStart,winEnd)
@@ -92,12 +94,10 @@ export function createTracer({
9294

9395
const originalParse = targetParser.parse
9496
originals.set(targetParser, originalParse)
97+
wrapped.add(targetParser)
9598

9699
targetParser.parse = function tracedParse(input, index = 0) {
97-
const start =
98-
input && typeof input.location === 'function'
99-
? input.location(index)
100-
: index
100+
const start = input.location(index)
101101

102102
const shouldEnterLog = start >= winStart && start < winEnd
103103

@@ -111,12 +111,11 @@ export function createTracer({
111111
const res = originalParse.call(this, input, index)
112112

113113
// Compute end/consumption even when rejected
114-
const end = typeof res.offset === 'number' ? res.offset : index
114+
const end = res.offset
115115
const spanInWindow = overlaps(start, end) || shouldEnterLog
116116

117117
// (C) post-event (only if we touched the window, and keep rejects if asked)
118-
const accepted =
119-
typeof res.isAccepted === 'function' ? res.isAccepted() : false
118+
const accepted = res.isAccepted()
120119
if (spanInWindow && (accepted || includeRejects)) {
121120
const entry = {
122121
phase: 'exit',
@@ -160,11 +159,7 @@ export function createTracer({
160159
if (!meta) {
161160
continue
162161
}
163-
const per = resolvePerParserOpts(
164-
meta.name,
165-
meta.opts || {},
166-
options,
167-
)
162+
const per = resolvePerParserOpts(meta.name, meta.opts, options)
168163
wrapOne(parser, meta.name, per)
169164
}
170165
return (rootParser) => rootParser // pass-through to keep pipeline style
@@ -191,10 +186,14 @@ export function createTracer({
191186
* Remove all wrappers (optional)
192187
*/
193188
restore() {
194-
originals.forEach((orig, parser) => {
195-
parser.parse = orig
189+
wrapped.forEach((parser) => {
190+
const orig = originals.get(parser)
191+
if (orig) {
192+
parser.parse = orig
193+
originals.delete(parser)
194+
}
196195
})
197-
originals.clear()
196+
wrapped.clear()
198197
},
199198

200199
/**

src/test/debug/trace-test.js

Lines changed: 0 additions & 57 deletions
This file was deleted.

src/test/debug/trace.spec.js

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { C, F, Streams, createTracer } from '../../lib/index.js'
3+
import {
4+
registerTrace,
5+
TRACE_REGISTRY,
6+
TRACE_META,
7+
TRACE_NAME_SYM,
8+
} from '../../lib/debug/trace.js'
9+
10+
describe('createTracer / Parser.trace integration', () => {
11+
const input = `author: Nicolas\npurpose: Demo of Masala Parser\nyear: 2023\n---\n# Masala Parser rocks!`
12+
const eol = C.char('\n').drop()
13+
14+
// Grammar (mirrors trace-test.js)
15+
const leftText = F.regex(/[a-zA-Z_][a-zA-Z0-9_]*/)
16+
const separator = C.char(':').trace('sep')
17+
const rightText = F.moveUntil(eol, true)
18+
.map((s) => s.split(',').flatMap((x) => x.trim()))
19+
.trace('rightText')
20+
21+
const lineParser = leftText
22+
.then(separator.drop())
23+
.then(rightText)
24+
.map((tuple) => {
25+
const name = tuple.first()
26+
const values = tuple.last()
27+
return { name, value: values }
28+
})
29+
.trace('lineParser')
30+
31+
const endParser = C.string('---').drop().then(eol).trace('end')
32+
const fullParser = lineParser.rep().then(endParser)
33+
34+
it('logs enter/exit events for traced parsers within the window', () => {
35+
const start = 0
36+
const end = input.indexOf('---', start)
37+
const tracer = createTracer({
38+
window: [start, end],
39+
includeValues: true,
40+
})
41+
42+
const options = {
43+
showValue: false,
44+
byName: {
45+
rightText: { showValue: true },
46+
sep: { showValue: false },
47+
},
48+
}
49+
50+
const traced = tracer.traceAll(options)(fullParser)
51+
const stream = Streams.ofString(input)
52+
traced.parse(stream)
53+
54+
const logs = tracer.flush()
55+
56+
const names = new Set(logs.map((e) => e.name).filter(Boolean))
57+
expect(names.has('lineParser')).toBe(true)
58+
expect(names.has('rightText')).toBe(true)
59+
// Window excludes `end` parser which starts exactly at `end`
60+
expect(names.has('end')).toBe(false)
61+
62+
// Ensure we have exit events recorded
63+
expect(logs.some((e) => e.phase === 'exit')).toBe(true)
64+
})
65+
66+
it('respects per-parser showValue overrides and includes snippets when consumed', () => {
67+
const start = 0
68+
const end = input.indexOf('---', start)
69+
const tracer = createTracer({
70+
window: [start, end],
71+
includeValues: true,
72+
})
73+
const options = {
74+
showValue: false,
75+
byName: {
76+
rightText: { showValue: true },
77+
sep: { showValue: false },
78+
},
79+
}
80+
const traced = tracer.traceAll(options)(fullParser)
81+
traced.parse(Streams.ofString(input))
82+
const logs = tracer.flush()
83+
84+
const rightTextExit = logs.find(
85+
(e) => e.name === 'rightText' && e.phase === 'exit' && e.accepted,
86+
)
87+
expect(rightTextExit).toBeTruthy()
88+
expect('value' in rightTextExit).toBe(true)
89+
expect(Array.isArray(rightTextExit.value)).toBe(true)
90+
91+
const sepExit = logs.find(
92+
(e) => e.name === 'sep' && e.phase === 'exit' && e.accepted,
93+
)
94+
expect(sepExit).toBeTruthy()
95+
expect('value' in sepExit).toBe(false)
96+
97+
// When there is consumption (end > start), snippet is present
98+
const withSnippet = logs.find(
99+
(e) => e.phase === 'exit' && e.accepted && e.end > e.start,
100+
)
101+
expect(withSnippet && typeof withSnippet.snippet === 'string').toBe(
102+
true,
103+
)
104+
})
105+
})
106+
107+
describe('trace() and registerTrace() edge cases', () => {
108+
it('registerTrace: catch path is non-fatal when defineProperty throws', () => {
109+
const p = C.char('y')
110+
// preventExtensions causes defineProperty to throw on adding new prop
111+
Object.preventExtensions(p)
112+
expect(() => registerTrace(p, 'py')).not.toThrow()
113+
// meta is still recorded
114+
const meta = TRACE_META.get(p)
115+
expect(meta && meta.name).toBe('py')
116+
// property not defined due to failure
117+
expect(p[TRACE_NAME_SYM]).toBeUndefined()
118+
})
119+
it('registerTrace throws on invalid name and registers on valid one', () => {
120+
const p = C.char('x')
121+
expect(() => registerTrace(p, '')).toThrow(
122+
/name must be a non-empty string/i,
123+
)
124+
125+
registerTrace(p, 'px')
126+
expect(TRACE_REGISTRY.has(p)).toBe(true)
127+
const meta = TRACE_META.get(p)
128+
expect(meta && meta.name).toBe('px')
129+
// decorated property present (best effort)
130+
expect(p[TRACE_NAME_SYM]).toBe('px')
131+
})
132+
133+
it('trace(target) throws if target is not a Parser', () => {
134+
const tracer = createTracer()
135+
expect(() => tracer.trace({}, 'bad')).toThrow(/target is not a Parser/i)
136+
})
137+
138+
it('trace is idempotent on same parser, returns pass-through function', () => {
139+
const tracer = createTracer()
140+
const p = C.string('ok')
141+
const f = tracer.trace(p, 'p')
142+
// pass-through: returns its argument
143+
expect(f(p)).toBe(p)
144+
// idempotent: second call should not throw
145+
tracer.trace(p, 'p')
146+
147+
// Run once and ensure we have logs
148+
p.parse(Streams.ofString('ok'))
149+
const logs = tracer.flush()
150+
expect(logs.length > 0).toBe(true)
151+
})
152+
153+
it('includeRejects=false logs enter but not exit on rejection', () => {
154+
const tracer = createTracer({ window: [0, 100], includeRejects: false })
155+
const rejecter = C.string('Z')
156+
const traced = tracer.trace(rejecter, 'rej')(rejecter)
157+
traced.parse(Streams.ofString('abc'))
158+
const logs = tracer.flush()
159+
const enters = logs.filter(
160+
(e) => e.name === 'rej' && e.phase === 'enter',
161+
)
162+
const exits = logs.filter((e) => e.name === 'rej' && e.phase === 'exit')
163+
expect(enters.length).toBe(1)
164+
expect(exits.length).toBe(0)
165+
})
166+
167+
it('snippet=false omits snippet even when consumption occurs', () => {
168+
const tracer = createTracer({ window: [0, 100], snippet: false })
169+
const p = C.string('abc')
170+
const traced = tracer.trace(p, 'cons')(p)
171+
traced.parse(Streams.ofString('abc'))
172+
const logs = tracer.flush()
173+
const exit = logs.find((e) => e.name === 'cons' && e.phase === 'exit')
174+
expect(exit).toBeTruthy()
175+
expect('snippet' in exit).toBe(false)
176+
})
177+
178+
it('restore() removes wrappers and stops logging', () => {
179+
const tracer = createTracer({ window: [0, 100] })
180+
const p = C.string('x')
181+
tracer.trace(p, 'p')(p)
182+
p.parse(Streams.ofString('x'))
183+
tracer.flush()
184+
185+
tracer.restore()
186+
p.parse(Streams.ofString('x'))
187+
expect(tracer.flush()).toEqual([])
188+
})
189+
190+
it('takeSubstring: breaks early when input.get is missing (no-get path)', () => {
191+
const tracer = createTracer({ window: [0, 100], snippet: true })
192+
const p = C.string('abc')
193+
const traced = tracer.trace(p, 's')(p)
194+
// Fake input: supports location and subStreamAt for acceptance, but no get()
195+
const fakeInput = {
196+
location: (i) => i,
197+
subStreamAt: (arr, index) => true,
198+
}
199+
traced.parse(fakeInput, 0)
200+
const logs = tracer.flush()
201+
const exit = logs.find((e) => e.name === 's' && e.phase === 'exit')
202+
expect(exit).toBeTruthy()
203+
// snippet computed to empty string due to early break
204+
expect(typeof exit.snippet).toBe('string')
205+
})
206+
207+
it('takeSubstring: pushes ellipsis when exceeding snippetMax', () => {
208+
const tracer = createTracer({
209+
window: [0, 100],
210+
snippet: true,
211+
snippetMax: 3,
212+
})
213+
const p = C.string('abcdef')
214+
const traced = tracer.trace(p, 'ell')(p)
215+
traced.parse(Streams.ofString('abcdef'), 0)
216+
const logs = tracer.flush()
217+
const exit = logs.find((e) => e.name === 'ell' && e.phase === 'exit')
218+
expect(exit).toBeTruthy()
219+
expect(typeof exit.snippet).toBe('string')
220+
expect(exit.snippet.endsWith('…')).toBe(true)
221+
})
222+
223+
it('resolvePerParserOpts: returns base when options is undefined; traceAll continues on missing meta', () => {
224+
const tracer = createTracer({ window: [0, 100], includeValues: true })
225+
const p = C.string('v').trace('pv', { showValue: false })
226+
// Add a dummy parser without meta to cover the continue branch
227+
const dummy = { parse() {} }
228+
TRACE_REGISTRY.add(dummy)
229+
230+
// options undefined => should use base meta opts
231+
const traced = tracer.traceAll()(p)
232+
traced.parse(Streams.ofString('v'))
233+
const logs = tracer.flush()
234+
const exit = logs.find((e) => e.name === 'pv' && e.phase === 'exit')
235+
expect(exit).toBeTruthy()
236+
// showValue: false from base meta => no value field
237+
expect('value' in exit).toBe(false)
238+
239+
// cleanup the dummy from registry
240+
TRACE_REGISTRY.delete(dummy)
241+
})
242+
})

0 commit comments

Comments
 (0)