Skip to content

Commit 8db98be

Browse files
test: regression suite for #1695, #1696, #1697, #1698, #1699
Pin the behaviour of the seven-issue fix bundle so future churn breaks loudly instead of silently regressing: - #1695: bare function ref in event shorthand dispatches $event; signal-typed callables stay skipped. - #1696: nested dynamic `[id]/segment/index.stx` matches the URL without a trailing `/index`; pages/-prefixed pretty form works too. - #1697: signals runtime widens the scope walk to document.body and uses `__stx_disposers` + `scopeVars.__mounted` guards so persistent layout scopes don't double-bind or re-fire onMount across SPA navs. - #1698: view-level <script>/<style> outside @section survives an @extends-with-explicit-section view. - #1699: HTML comments are masked before directive expansion — @Push inside `<!-- -->` is no longer expanded, backticks survive, and directive-looking text stays literal. #1690 and #1692 are covered by the build pipeline itself (dist must import cleanly; Notification.stx must compile via the stx plugin) and don't get standalone tests here.
1 parent 0a71c7c commit 8db98be

2 files changed

Lines changed: 271 additions & 0 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* Regression suite for filed-and-fixed issues in bun-plugin-stx. Each
3+
* `describe` is named for its GitHub issue so a future failure points at
4+
* the originating bug.
5+
*/
6+
import { describe, expect, it } from 'bun:test'
7+
8+
/**
9+
* Mirror of the route-resolution logic in serve.ts so we can exercise the
10+
* regex builder without spinning up the dev server. Keep this in sync
11+
* with the implementation if the loop ever changes shape.
12+
*/
13+
function buildPatterns(relativeFilePath: string): RegExp[] {
14+
const fileRouteBase = relativeFilePath.replace(/\.(stx|md|html)$/, '')
15+
const routePattern = fileRouteBase
16+
.replace(/\[([^\]]+)\]/g, '([^/]+)')
17+
.replace(/\//g, '\\/')
18+
19+
const regexPatterns: RegExp[] = [new RegExp(`^${routePattern}$`)]
20+
21+
const fileRouteNoIndex = fileRouteBase.replace(/\/index$/, '')
22+
if (fileRouteNoIndex !== fileRouteBase) {
23+
const noIndexPattern = fileRouteNoIndex
24+
.replace(/\[([^\]]+)\]/g, '([^/]+)')
25+
.replace(/\//g, '\\/')
26+
regexPatterns.push(new RegExp(`^${noIndexPattern}$`))
27+
}
28+
29+
if (fileRouteBase.startsWith('pages/')) {
30+
const prettyBase = fileRouteBase.slice(6)
31+
const prettyPattern = prettyBase
32+
.replace(/\[([^\]]+)\]/g, '([^/]+)')
33+
.replace(/\//g, '\\/')
34+
regexPatterns.push(new RegExp(`^${prettyPattern}$`))
35+
36+
const prettyNoIndex = prettyBase.replace(/\/index$/, '')
37+
if (prettyNoIndex !== prettyBase) {
38+
const prettyNoIndexPattern = prettyNoIndex
39+
.replace(/\[([^\]]+)\]/g, '([^/]+)')
40+
.replace(/\//g, '\\/')
41+
regexPatterns.push(new RegExp(`^${prettyNoIndexPattern}$`))
42+
}
43+
}
44+
return regexPatterns
45+
}
46+
47+
function matches(file: string, url: string): boolean {
48+
return buildPatterns(file).some(re => re.test(url))
49+
}
50+
51+
describe('#1696 — nested dynamic [id]/segment/index.stx matches without /index', () => {
52+
it('matches /judges/35/profile when file is judges/[id]/profile/index.stx', () => {
53+
expect(matches('judges/[id]/profile/index.stx', 'judges/35/profile')).toBe(true)
54+
})
55+
56+
it('still matches the explicit /index path (backwards compatible)', () => {
57+
expect(matches('judges/[id]/profile/index.stx', 'judges/35/profile/index')).toBe(true)
58+
})
59+
60+
it('matches single-segment dynamic route /judges/:id without index suffix', () => {
61+
expect(matches('judges/[id].stx', 'judges/35')).toBe(true)
62+
})
63+
64+
it('matches /court-houses/12/reviews when file is court-houses/[id]/reviews/index.stx', () => {
65+
expect(matches('court-houses/[id]/reviews/index.stx', 'court-houses/12/reviews')).toBe(true)
66+
})
67+
68+
it('also matches the pages/-prefixed pretty form without /index', () => {
69+
expect(matches('pages/judges/[id]/profile/index.stx', 'judges/35/profile')).toBe(true)
70+
})
71+
72+
it('does not match unrelated URLs', () => {
73+
expect(matches('judges/[id]/profile/index.stx', 'admin/35/profile')).toBe(false)
74+
expect(matches('judges/[id]/profile/index.stx', 'judges/35/cases')).toBe(false)
75+
})
76+
})
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
/**
2+
* Regression suite for filed-and-fixed issues that aren't otherwise
3+
* covered by existing tests. Each `describe` is named for its GitHub
4+
* issue so a future failure points at the originating bug.
5+
*/
6+
import type { StxOptions } from '../../src/types'
7+
import { afterAll, beforeAll, describe, expect, it } from 'bun:test'
8+
import fs from 'node:fs'
9+
import path from 'node:path'
10+
import { processDirectives } from '../../src/process'
11+
import { generateSignalsRuntimeDev } from '../../src/signals'
12+
13+
const TMP = path.resolve(__dirname, './scratch-issue-fixes')
14+
15+
describe('#1699 — HTML comments are masked before directive expansion', () => {
16+
it('does not expand @push directive name inside an HTML comment', async () => {
17+
const out = await processDirectives(
18+
`<article>
19+
<!-- styles handled in @push('styles') below -->
20+
<p>body</p>
21+
</article>
22+
23+
@push('styles')
24+
<style>article { color: red }</style>
25+
@endpush`,
26+
{ __sections: {} },
27+
'view.stx',
28+
{} as StxOptions,
29+
new Set<string>(),
30+
)
31+
32+
// Comment intact with literal @push text inside.
33+
expect(out).toContain(`<!-- styles handled in @push('styles') below -->`)
34+
// Real @push block stripped — only the @push reference in the comment remains.
35+
expect((out.match(/@push\(/g) || []).length).toBe(1)
36+
expect(out).not.toContain('@endpush')
37+
// Pre-fix the <style> body got spliced into the article at the comment site.
38+
expect(out).not.toContain('color: red')
39+
})
40+
41+
it('preserves backticks in HTML comments verbatim', async () => {
42+
const out = await processDirectives(
43+
`<p>before</p>
44+
<!-- This comment uses \`template literals\` inside backticks. -->
45+
<p>after</p>`,
46+
{},
47+
'view.stx',
48+
{} as StxOptions,
49+
new Set<string>(),
50+
)
51+
expect(out).toContain('`template literals`')
52+
expect(out).toContain('<p>before</p>')
53+
expect(out).toContain('<p>after</p>')
54+
})
55+
56+
it('preserves nested directive-looking text inside comments', async () => {
57+
const out = await processDirectives(
58+
`<!-- see @include('foo'), @section('content'), @if(x) -->`,
59+
{},
60+
'view.stx',
61+
{} as StxOptions,
62+
new Set<string>(),
63+
)
64+
expect(out).toContain(`@include('foo')`)
65+
expect(out).toContain(`@section('content')`)
66+
expect(out).toContain(`@if(x)`)
67+
})
68+
})
69+
70+
describe('#1698 — view-level <script>/<style> salvaged when @extends is used', () => {
71+
const LAYOUTS = path.join(TMP, 'layouts')
72+
73+
beforeAll(() => {
74+
fs.mkdirSync(LAYOUTS, { recursive: true })
75+
fs.writeFileSync(
76+
path.join(LAYOUTS, 'default.stx'),
77+
`<!doctype html>
78+
<html><body>
79+
<main>@yield('content')</main>
80+
</body></html>`,
81+
)
82+
})
83+
afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }))
84+
85+
it('preserves a view-level <script client> when the view uses @extends + explicit @section', async () => {
86+
const view = `@extends('default')
87+
88+
<script client>
89+
console.log('view-level ran')
90+
function submitSearch() { console.log('submit fired') }
91+
</script>
92+
93+
@section('content')
94+
<form @submit.prevent="submitSearch()">
95+
<input />
96+
</form>
97+
@endsection`
98+
99+
const out = await processDirectives(
100+
view,
101+
{},
102+
path.join(TMP, 'search.stx'),
103+
{ layoutsDir: LAYOUTS } as StxOptions,
104+
new Set<string>(),
105+
)
106+
107+
expect(out).toContain('<form')
108+
expect(out).toContain('submitSearch')
109+
expect(out).toContain('view-level ran')
110+
expect(out).toContain('function submitSearch')
111+
})
112+
113+
it('preserves a view-level <style> the same way', async () => {
114+
const view = `@extends('default')
115+
116+
<style>.search-input { border: 1px solid red }</style>
117+
118+
@section('content')
119+
<input class="search-input" />
120+
@endsection`
121+
122+
const out = await processDirectives(
123+
view,
124+
{},
125+
path.join(TMP, 'search-with-style.stx'),
126+
{ layoutsDir: LAYOUTS } as StxOptions,
127+
new Set<string>(),
128+
)
129+
expect(out).toContain('.search-input')
130+
expect(out).toContain('border: 1px solid red')
131+
})
132+
})
133+
134+
describe('#1695 — bare function ref in event handler shorthand', () => {
135+
const runtime = generateSignalsRuntimeDev()
136+
137+
it('runtime contains the bare-id match path', () => {
138+
expect(runtime).toContain('bareIdMatch')
139+
expect(runtime).toContain('fn($event)')
140+
})
141+
142+
it('the shorthand logic dispatches $event to a bare function ref', () => {
143+
// Mirror the runtime branch verbatim so we test the actual logic shape.
144+
function parseShorthand(expr: string, scope: Record<string, any>) {
145+
const trimmed = expr.trim()
146+
const bareIdMatch = trimmed.match(/^([a-zA-Z_$][\w$]*)$/)
147+
if (bareIdMatch) {
148+
const fn = scope[bareIdMatch[1]]
149+
if (typeof fn === 'function' && !(fn as any)._isSignal)
150+
return ($event: any) => fn($event)
151+
}
152+
return null
153+
}
154+
155+
let captured: any = null
156+
const handler = parseShorthand('foo', { foo: (e: any) => { captured = e } })
157+
expect(handler).not.toBeNull()
158+
handler?.({ type: 'click', stub: true })
159+
expect(captured).toEqual({ type: 'click', stub: true })
160+
161+
// Signals (also callable) should NOT be invoked — reading them would be a no-op anyway.
162+
const signal: any = () => 42
163+
signal._isSignal = true
164+
expect(parseShorthand('count', { count: signal })).toBeNull()
165+
166+
// Missing identifier is a no-op (returns null).
167+
expect(parseShorthand('missing', {})).toBeNull()
168+
})
169+
})
170+
171+
describe('#1697 — layout scope rebind walks document.body', () => {
172+
const runtime = generateSignalsRuntimeDev()
173+
174+
it('runtime widens both the bindings and mount walks to document.body', () => {
175+
const occurrences = (runtime.match(/document\.body\.querySelectorAll\('\[data-stx-scope\]'\)/g) || []).length
176+
// Two: one for bindings re-apply, one for mount-callback firing.
177+
expect(occurrences).toBeGreaterThanOrEqual(2)
178+
})
179+
180+
it('skips re-binding scopes that already have __stx_disposers', () => {
181+
expect(runtime).toMatch(/if\s*\(\s*el\.__stx_disposers\s*\)\s*return/)
182+
})
183+
184+
it('guards mount-callback re-fires with scopeVars.__mounted', () => {
185+
expect(runtime).toMatch(/scopeVars\.__mounted\s*=\s*true/)
186+
expect(runtime).toMatch(/!\s*scopeVars\.__mounted/)
187+
})
188+
189+
it('DOMContentLoaded path also marks scopes mounted (so cross-nav doesn\'t re-fire onMount)', () => {
190+
const dclIdx = runtime.indexOf('DOMContentLoaded')
191+
expect(dclIdx).toBeGreaterThan(-1)
192+
const dclSection = runtime.slice(dclIdx, dclIdx + 5000)
193+
expect(dclSection).toMatch(/!\s*scopeVars\.__mounted/)
194+
})
195+
})

0 commit comments

Comments
 (0)