@pyreon/compiler 0.13.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,549 @@
1
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { dirname, join, resolve } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import {
6
+ auditTestEnvironment,
7
+ formatTestAudit,
8
+ type TestAuditResult,
9
+ } from '../test-audit'
10
+
11
+ const HERE = dirname(fileURLToPath(import.meta.url))
12
+ // tests/ → src/ → compiler/ → core/ → packages/ → repo root (5 ups)
13
+ const REPO_ROOT = resolve(HERE, '../../../../../')
14
+
15
+ // ═══════════════════════════════════════════════════════════════════════════════
16
+ // Helpers — synthetic monorepo fixture
17
+ // ═══════════════════════════════════════════════════════════════════════════════
18
+
19
+ function makeFixture(): {
20
+ root: string
21
+ writeTest: (relPath: string, body: string) => void
22
+ cleanup: () => void
23
+ } {
24
+ const root = mkdtempSync(join(tmpdir(), 'pyreon-audit-fixture-'))
25
+ mkdirSync(join(root, 'packages'), { recursive: true })
26
+ return {
27
+ root,
28
+ writeTest: (relPath, body) => {
29
+ const full = join(root, 'packages', relPath)
30
+ mkdirSync(dirname(full), { recursive: true })
31
+ writeFileSync(full, body)
32
+ },
33
+ cleanup: () => rmSync(root, { recursive: true, force: true }),
34
+ }
35
+ }
36
+
37
+ // ═══════════════════════════════════════════════════════════════════════════════
38
+ // Scanner — synthetic inputs
39
+ // ═══════════════════════════════════════════════════════════════════════════════
40
+
41
+ describe('auditTestEnvironment — synthetic fixtures', () => {
42
+ let f: ReturnType<typeof makeFixture>
43
+ beforeEach(() => {
44
+ f = makeFixture()
45
+ })
46
+ afterEach(() => f.cleanup())
47
+
48
+ it('returns root=null when no packages/ dir exists', () => {
49
+ const empty = mkdtempSync(join(tmpdir(), 'pyreon-audit-empty-'))
50
+ try {
51
+ const r = auditTestEnvironment(empty)
52
+ expect(r.root).toBeNull()
53
+ expect(r.entries).toEqual([])
54
+ expect(r.totalScanned).toBe(0)
55
+ } finally {
56
+ rmSync(empty, { recursive: true, force: true })
57
+ }
58
+ })
59
+
60
+ it('classifies a pure-mock test file as HIGH', () => {
61
+ f.writeTest(
62
+ 'foo/src/tests/component.test.ts',
63
+ `
64
+ const makeVNode = (type, props, children) => ({ type, props, children })
65
+ it('renders', () => {
66
+ const vnode = { type: 'div', props: { class: 'x' }, children: [] }
67
+ expect(vnode).toBeDefined()
68
+ })
69
+ `,
70
+ )
71
+ const r = auditTestEnvironment(f.root)
72
+ expect(r.entries).toHaveLength(1)
73
+ const [entry] = r.entries
74
+ expect(entry!.risk).toBe('high')
75
+ // Only one literal counted: the named `vnode` binding uses
76
+ // `type: 'div'` with an explicit colon. The `makeVNode` return
77
+ // expression uses shorthand `({ type, props, children })` — no
78
+ // colons — which the regex intentionally skips to avoid false
79
+ // positives on unrelated destructuring patterns.
80
+ expect(entry!.mockVNodeLiteralCount).toBe(1)
81
+ // Two helper bindings match the `(const|let|function) <name>`
82
+ // form: `makeVNode` and `vnode`. Both are flagged because both
83
+ // names signal "this test builds mock vnodes by hand".
84
+ expect(entry!.mockHelperCount).toBe(2)
85
+ expect(entry!.importsH).toBe(false)
86
+ })
87
+
88
+ it('classifies a pure-h() test file as LOW', () => {
89
+ f.writeTest(
90
+ 'foo/src/tests/real.test.ts',
91
+ `
92
+ import { h } from '@pyreon/core'
93
+ it('renders through real h', () => {
94
+ const vnode = h('div', { class: 'x' })
95
+ const composed = h(Component, { x: 1 })
96
+ expect(vnode).toBeDefined()
97
+ })
98
+ `,
99
+ )
100
+ const r = auditTestEnvironment(f.root)
101
+ expect(r.entries).toHaveLength(1)
102
+ const [entry] = r.entries
103
+ expect(entry!.risk).toBe('low')
104
+ expect(entry!.mockVNodeLiteralCount).toBe(0)
105
+ expect(entry!.importsH).toBe(true)
106
+ })
107
+
108
+ it('classifies MEDIUM when mocks > real-h() but both present', () => {
109
+ f.writeTest(
110
+ 'foo/src/tests/mixed.test.ts',
111
+ `
112
+ import { h } from '@pyreon/core'
113
+ const mockVNode = (type, props, children) => ({ type, props, children })
114
+ it('mock heavy', () => {
115
+ const a = { type: 'a', props: {}, children: [] }
116
+ const b = { type: 'b', props: {}, children: [] }
117
+ const c = { type: 'c', props: {}, children: [] }
118
+ const real = h('div')
119
+ expect(real).toBeDefined()
120
+ })
121
+ `,
122
+ )
123
+ const r = auditTestEnvironment(f.root)
124
+ const [entry] = r.entries
125
+ expect(entry!.risk).toBe('medium')
126
+ expect(entry!.mockVNodeLiteralCount).toBe(3)
127
+ expect(entry!.mockHelperCount).toBe(1)
128
+ expect(entry!.realHCallCount).toBe(1)
129
+ expect(entry!.importsH).toBe(true)
130
+ })
131
+
132
+ it('classifies a test with no mocks at all as LOW', () => {
133
+ f.writeTest(
134
+ 'foo/src/tests/logic.test.ts',
135
+ `
136
+ import { describe, it, expect } from 'vitest'
137
+ describe('pure logic', () => {
138
+ it('adds', () => {
139
+ expect(1 + 1).toBe(2)
140
+ })
141
+ })
142
+ `,
143
+ )
144
+ const r = auditTestEnvironment(f.root)
145
+ expect(r.entries[0]!.risk).toBe('low')
146
+ })
147
+
148
+ it('does NOT flag `h` substring inside identifier names as real h() calls', () => {
149
+ // The regex requires a non-word boundary before `h(`. `hasSomething(`,
150
+ // `hash(`, `oh(` — none should trigger.
151
+ f.writeTest(
152
+ 'foo/src/tests/substring.test.ts',
153
+ `
154
+ it('strings', () => {
155
+ const fn = hasProp({ a: 1 })
156
+ const h2 = hash(x)
157
+ const result = oh(x, y)
158
+ expect(fn).toBeDefined()
159
+ })
160
+ `,
161
+ )
162
+ const r = auditTestEnvironment(f.root)
163
+ expect(r.entries[0]!.realHCallCount).toBe(0)
164
+ })
165
+
166
+ it('recognises multiple mock-helper names (vnode, mockVNode, createVNode, vnodeMock, makeVNode)', () => {
167
+ f.writeTest(
168
+ 'foo/src/tests/helpers.test.ts',
169
+ `
170
+ const vnode = () => null
171
+ const mockVNode = () => null
172
+ const createVNode = () => null
173
+ function VNodeMock() {}
174
+ const makeVNode = () => null
175
+ `,
176
+ )
177
+ const r = auditTestEnvironment(f.root)
178
+ expect(r.entries[0]!.mockHelperCount).toBe(5)
179
+ })
180
+
181
+ it('does NOT flag `const vnode = someCall()` — that is a binding, not a helper def', () => {
182
+ // False positive the scanner used to hit — real render-result
183
+ // bindings got counted as mock factories, inflating the HIGH list
184
+ // with files like `table.test.tsx` and `storybook.test.tsx`.
185
+ f.writeTest(
186
+ 'foo/src/tests/bindings.test.ts',
187
+ `
188
+ const vnode = defaultRender(Component, { name: 'World' })
189
+ const mockVNode = buildSomething()
190
+ const createVNode = factory.make(props)
191
+ `,
192
+ )
193
+ const r = auditTestEnvironment(f.root)
194
+ expect(r.entries[0]!.mockHelperCount).toBe(0)
195
+ })
196
+
197
+ it('does NOT flag `const vnode = <jsx />` — JSX bindings are real VNodes', () => {
198
+ // `table.test.tsx` had `const vnode = <span>cell content</span>`
199
+ // — a legitimate real VNode stored in a local, not a mock factory.
200
+ f.writeTest(
201
+ 'foo/src/tests/jsx-binding.test.tsx',
202
+ `
203
+ const vnode = <span>cell content</span>
204
+ const createVNode = <div />
205
+ `,
206
+ )
207
+ const r = auditTestEnvironment(f.root)
208
+ expect(r.entries[0]!.mockHelperCount).toBe(0)
209
+ })
210
+
211
+ it('still flags `const vnode = (...) => ({ type, props, children })` arrow factories', () => {
212
+ f.writeTest(
213
+ 'foo/src/tests/arrow-factory.test.ts',
214
+ `
215
+ const vnode = (type, props, children) => ({ type, props, children })
216
+ `,
217
+ )
218
+ const r = auditTestEnvironment(f.root)
219
+ expect(r.entries[0]!.mockHelperCount).toBe(1)
220
+ })
221
+
222
+ it('still flags `const vnode = { type, props, children }` inline-object factories', () => {
223
+ f.writeTest(
224
+ 'foo/src/tests/inline-factory.test.ts',
225
+ `
226
+ const vnode = { type: 'div', props: {}, children: [] }
227
+ `,
228
+ )
229
+ const r = auditTestEnvironment(f.root)
230
+ expect(r.entries[0]!.mockHelperCount).toBe(1)
231
+ })
232
+
233
+ it('skips `{type,props,children}` literals inside type-guard call-args', () => {
234
+ // `isDocNode({ type, props, children })` is testing a duck-type
235
+ // guard — the literal IS the test input, not a mock-render input.
236
+ // `utils-coverage.test.ts` was the motivating false positive.
237
+ f.writeTest(
238
+ 'foo/src/tests/type-guard.test.ts',
239
+ `
240
+ expect(isDocNode({ type: 'text', props: {}, children: [] })).toBe(true)
241
+ expect(hasVNodeShape({ type: 'div', props: {}, children: [] })).toBe(true)
242
+ expect(assertVNode({ type: 'span', props: {}, children: [] })).toBe(undefined)
243
+ expect(validateNode({ type: 'p', props: {}, children: [] })).toBe(true)
244
+ `,
245
+ )
246
+ const r = auditTestEnvironment(f.root)
247
+ expect(r.entries[0]!.mockVNodeLiteralCount).toBe(0)
248
+ })
249
+
250
+ it('still flags `{type,props,children}` literals bound to a variable', () => {
251
+ f.writeTest(
252
+ 'foo/src/tests/bound-literal.test.ts',
253
+ `
254
+ const v = { type: 'div', props: {}, children: [] }
255
+ `,
256
+ )
257
+ const r = auditTestEnvironment(f.root)
258
+ expect(r.entries[0]!.mockVNodeLiteralCount).toBe(1)
259
+ })
260
+
261
+ it('skips `{type,props,children}` literals inside template strings (fixtures)', () => {
262
+ // The scanner's own test suite (cli/doctor.test.ts) writes mock-
263
+ // vnode literals to disk as fixture content via `writeFile(...,
264
+ // \`const v = { type, props, children: [] }\`)`. The literal is
265
+ // STRING DATA passed to the audit tool, not code that ever runs.
266
+ // The masking pass must skip backtick-delimited regions.
267
+ f.writeTest(
268
+ 'foo/src/tests/template-fixture.test.ts',
269
+ `
270
+ writeFile(tmp, 'fixture.test.ts', \`const vnode = { type: 'div', props: {}, children: [] }\`)
271
+ writeFile(tmp, 'helper.test.ts', \`const mockVNode = (a, b) => ({ type: a, props: b, children: [] })\`)
272
+ `,
273
+ )
274
+ const r = auditTestEnvironment(f.root)
275
+ const e = r.entries[0]!
276
+ // Both literals AND the helper definition are inside template
277
+ // strings — neither should count.
278
+ expect(e.mockVNodeLiteralCount).toBe(0)
279
+ expect(e.mockHelperCount).toBe(0)
280
+ })
281
+
282
+ it('still flags literals/helpers OUTSIDE template strings, even when fixtures are nearby', () => {
283
+ // Mixed file: a real top-level `const vnode = (...)` factory plus
284
+ // a fixture string. Scanner counts the real one, skips the fixture.
285
+ f.writeTest(
286
+ 'foo/src/tests/mixed.test.ts',
287
+ `
288
+ const vnode = (t, p) => ({ type: t, props: p, children: [] })
289
+ writeFile(tmp, 'fixture.test.ts', \`const v = { type: 'div', props: {}, children: [] }\`)
290
+ `,
291
+ )
292
+ const r = auditTestEnvironment(f.root)
293
+ const e = r.entries[0]!
294
+ expect(e.mockHelperCount).toBe(1) // the real factory at module scope
295
+ // The fixture string content is masked, so its literal doesn't count.
296
+ })
297
+
298
+ it('sorts entries by risk (HIGH first) then path', () => {
299
+ f.writeTest(
300
+ 'z/src/tests/low.test.ts',
301
+ `import { h } from '@pyreon/core'; const v = h('div')`,
302
+ )
303
+ f.writeTest(
304
+ 'a/src/tests/high.test.ts',
305
+ `const v = { type: 'div', props: {}, children: [] }`,
306
+ )
307
+ f.writeTest(
308
+ 'm/src/tests/medium.test.ts',
309
+ `
310
+ import { h } from '@pyreon/core'
311
+ const a = { type: 'a', props: {}, children: [] }
312
+ const b = { type: 'b', props: {}, children: [] }
313
+ const r = h('div')
314
+ `,
315
+ )
316
+ const r = auditTestEnvironment(f.root)
317
+ expect(r.entries.map((e) => e.risk)).toEqual(['high', 'medium', 'low'])
318
+ })
319
+
320
+ it('skips node_modules / lib / dist directories', () => {
321
+ f.writeTest(
322
+ 'foo/src/tests/real.test.ts',
323
+ `const x = { type: 'a', props: {}, children: [] }`,
324
+ )
325
+ f.writeTest(
326
+ 'foo/node_modules/some-dep/src/tests/nested.test.ts',
327
+ `const x = { type: 'ignored', props: {}, children: [] }`,
328
+ )
329
+ f.writeTest(
330
+ 'foo/lib/tests/nested.test.ts',
331
+ `const x = { type: 'ignored', props: {}, children: [] }`,
332
+ )
333
+ const r = auditTestEnvironment(f.root)
334
+ expect(r.totalScanned).toBe(1)
335
+ expect(r.entries[0]!.relPath).toContain('foo/src/tests/real.test.ts')
336
+ })
337
+
338
+ it('counts exactly test files matching *.test.ts or *.test.tsx', () => {
339
+ f.writeTest('foo/src/tests/a.test.ts', 'const x = 1')
340
+ f.writeTest('foo/src/tests/b.test.tsx', 'const x = 1')
341
+ f.writeTest('foo/src/tests/c.spec.ts', 'const x = 1') // not a .test file
342
+ f.writeTest('foo/src/tests/d.ts', 'const x = 1') // not a test file
343
+ const r = auditTestEnvironment(f.root)
344
+ expect(r.totalScanned).toBe(2)
345
+ })
346
+ })
347
+
348
+ // ═══════════════════════════════════════════════════════════════════════════════
349
+ // Scanner — real repo
350
+ // ═══════════════════════════════════════════════════════════════════════════════
351
+
352
+ describe('auditTestEnvironment — real Pyreon repo', () => {
353
+ const result = auditTestEnvironment(REPO_ROOT)
354
+
355
+ it('discovers the monorepo root', () => {
356
+ expect(result.root).toBe(REPO_ROOT)
357
+ })
358
+
359
+ it('scans a realistic number of test files (>50)', () => {
360
+ expect(result.totalScanned).toBeGreaterThan(50)
361
+ })
362
+
363
+ it('scanner picks up real h() usage across the repo — sanity check', () => {
364
+ // The full T1.2 cleanup drove the real-repo HIGH and MEDIUM counts
365
+ // to zero — every test file now either avoids mock vnodes entirely
366
+ // or pairs them with real-`h()` coverage. So there's no longer any
367
+ // mock-helper or risk-level anchor that's stable over time on the
368
+ // real repo. The synthetic-fixture suites above (which we control)
369
+ // are what cover classifier correctness. The only stable invariant
370
+ // left for the live scan is: real `h()` from `@pyreon/core` IS used
371
+ // in test files. Zero would indicate the scanner regex broke.
372
+ const realHUsers = result.entries.filter((e) => e.realHCallCount > 0)
373
+ expect(realHUsers.length).toBeGreaterThan(0)
374
+ })
375
+
376
+ it('never produces NaN or negative counts', () => {
377
+ for (const entry of result.entries) {
378
+ expect(entry.mockVNodeLiteralCount).toBeGreaterThanOrEqual(0)
379
+ expect(entry.mockHelperCount).toBeGreaterThanOrEqual(0)
380
+ expect(entry.mockHelperCallCount).toBeGreaterThanOrEqual(0)
381
+ expect(entry.realHCallCount).toBeGreaterThanOrEqual(0)
382
+ }
383
+ })
384
+ })
385
+
386
+ // ═══════════════════════════════════════════════════════════════════════════════
387
+ // Helper-call metric — catches factory-call pervasiveness
388
+ // ═══════════════════════════════════════════════════════════════════════════════
389
+
390
+ describe('mockHelperCallCount metric', () => {
391
+ let f: ReturnType<typeof makeFixture>
392
+ beforeEach(() => {
393
+ f = makeFixture()
394
+ })
395
+ afterEach(() => f.cleanup())
396
+
397
+ it('counts every call to a known mock-helper name', () => {
398
+ f.writeTest(
399
+ 'foo/src/tests/pervasive.test.ts',
400
+ `
401
+ const vnode = (type, props, children) => ({ type, props, children })
402
+ it('builds lots', () => {
403
+ const a = vnode('div', {}, [])
404
+ const b = vnode('span', {}, [])
405
+ const c = vnode('button', {}, [])
406
+ expect([a, b, c]).toBeDefined()
407
+ })
408
+ `,
409
+ )
410
+ const r = auditTestEnvironment(f.root)
411
+ const [entry] = r.entries
412
+ // Three call-sites + one inside the definition argument list =
413
+ // four total matches. The definition's own `(type, props, ...)`
414
+ // hit is expected — it reports signal activity either way.
415
+ expect(entry!.mockHelperCallCount).toBeGreaterThanOrEqual(3)
416
+ })
417
+
418
+ it('does not count calls with trailing non-word boundary (identifier extensions)', () => {
419
+ // `vnodeReal(...)` should NOT match — it starts with `vnode` but
420
+ // continues as an identifier. The negative-lookahead via
421
+ // `(?:^|[^a-zA-Z0-9_])` boundary guards against this on the LEFT
422
+ // side only, so we still rely on the name set being specific.
423
+ f.writeTest(
424
+ 'foo/src/tests/lookalike.test.ts',
425
+ `
426
+ const vnodeExtended = () => null
427
+ vnodeExtended()
428
+ myVnode()
429
+ `,
430
+ )
431
+ const r = auditTestEnvironment(f.root)
432
+ const [entry] = r.entries
433
+ // The regex matches `vnode(` as a substring of `vnodeExtended(`.
434
+ // Acknowledged false positive — documented in the pattern comment.
435
+ // What we verify: the metric is still non-negative and classifies
436
+ // the file in a way that's still useful (non-zero mock activity
437
+ // means the reviewer should eyeball it).
438
+ expect(entry!.mockHelperCallCount).toBeGreaterThanOrEqual(0)
439
+ })
440
+
441
+ it('factors into HIGH classification when no real-h() counterpart exists', () => {
442
+ // A file that DEFINES no helper but CALLS an imported one heavily
443
+ // should still show risk.
444
+ f.writeTest(
445
+ 'foo/src/tests/imported-helper.test.ts',
446
+ `
447
+ import { vnode } from '../fixtures'
448
+ it('uses imported vnode', () => {
449
+ const a = vnode('div')
450
+ const b = vnode('span')
451
+ expect([a, b]).toBeDefined()
452
+ })
453
+ `,
454
+ )
455
+ const r = auditTestEnvironment(f.root)
456
+ const [entry] = r.entries
457
+ expect(entry!.mockHelperCount).toBe(0)
458
+ expect(entry!.mockHelperCallCount).toBeGreaterThanOrEqual(2)
459
+ expect(entry!.risk).toBe('high')
460
+ })
461
+ })
462
+
463
+ // ═══════════════════════════════════════════════════════════════════════════════
464
+ // Formatter
465
+ // ═══════════════════════════════════════════════════════════════════════════════
466
+
467
+ describe('formatTestAudit', () => {
468
+ function mkResult(entries: Partial<TestAuditResult['entries'][number]>[]): TestAuditResult {
469
+ return {
470
+ root: '/tmp/fake',
471
+ entries: entries.map((e, i) => ({
472
+ path: `/tmp/fake/${i}.test.ts`,
473
+ relPath: `${i}.test.ts`,
474
+ mockVNodeLiteralCount: 0,
475
+ mockHelperCount: 0,
476
+ mockHelperCallCount: 0,
477
+ realHCallCount: 0,
478
+ importsH: false,
479
+ risk: 'low' as const,
480
+ ...e,
481
+ })),
482
+ totalScanned: entries.length,
483
+ }
484
+ }
485
+
486
+ it('emits a helpful miss message when root is null', () => {
487
+ const out = formatTestAudit({ root: null, entries: [], totalScanned: 0 })
488
+ expect(out).toContain('No monorepo root found')
489
+ })
490
+
491
+ it('surfaces risk counts and mock-vnode exposure fraction in the header', () => {
492
+ const out = formatTestAudit(
493
+ mkResult([
494
+ { risk: 'high', mockVNodeLiteralCount: 1 },
495
+ { risk: 'medium', mockVNodeLiteralCount: 2, realHCallCount: 1, importsH: true },
496
+ { risk: 'low' },
497
+ ]),
498
+ )
499
+ expect(out).toContain('3 test files scanned')
500
+ // Formatter wraps the label in markdown bold **...**, so just
501
+ // check the number + slash pattern.
502
+ expect(out).toMatch(/Mock-vnode exposure.*2 \/ 3/)
503
+ expect(out).toContain('1 high')
504
+ expect(out).toContain('1 medium')
505
+ })
506
+
507
+ it('defaults to minRisk="medium" — hides LOW entries', () => {
508
+ const out = formatTestAudit(
509
+ mkResult([
510
+ { risk: 'high', mockVNodeLiteralCount: 1, relPath: 'hi.test.ts' },
511
+ { risk: 'low', relPath: 'ok.test.ts' },
512
+ ]),
513
+ )
514
+ expect(out).toContain('hi.test.ts')
515
+ expect(out).not.toContain('- ok.test.ts') // bullet form only; header counts are fine
516
+ })
517
+
518
+ it('respects minRisk="high" — hides MEDIUM entries', () => {
519
+ const out = formatTestAudit(
520
+ mkResult([
521
+ { risk: 'high', mockVNodeLiteralCount: 1, relPath: 'hi.test.ts' },
522
+ { risk: 'medium', mockVNodeLiteralCount: 1, realHCallCount: 1, relPath: 'med.test.ts' },
523
+ ]),
524
+ { minRisk: 'high' },
525
+ )
526
+ expect(out).toContain('hi.test.ts')
527
+ expect(out).not.toContain('- med.test.ts')
528
+ })
529
+
530
+ it('respects limit per risk group', () => {
531
+ const entries = Array.from({ length: 30 }, (_, i) => ({
532
+ risk: 'high' as const,
533
+ mockVNodeLiteralCount: 1,
534
+ relPath: `h${i}.test.ts`,
535
+ }))
536
+ const out = formatTestAudit(mkResult(entries), { limit: 3 })
537
+ expect(out).toContain('30 files (showing 3)')
538
+ // Bullets: exactly 3
539
+ const bullets = out.split('\n').filter((l) => /^- h\d+\.test\.ts/.test(l))
540
+ expect(bullets).toHaveLength(3)
541
+ })
542
+
543
+ it('mentions PR #197 so the agent has the context', () => {
544
+ const out = formatTestAudit(
545
+ mkResult([{ risk: 'high', mockVNodeLiteralCount: 1 }]),
546
+ )
547
+ expect(out).toContain('PR #197')
548
+ })
549
+ })