@pyreon/compiler 0.14.0 → 0.16.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.
- package/README.md +17 -0
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +1189 -30
- package/lib/types/index.d.ts +109 -2
- package/package.json +20 -2
- package/src/event-names.ts +65 -0
- package/src/index.ts +17 -0
- package/src/island-audit.ts +675 -0
- package/src/jsx.ts +162 -39
- package/src/load-native.ts +155 -0
- package/src/pyreon-intercept.ts +352 -2
- package/src/ssg-audit.ts +513 -0
- package/src/tests/detector-tag-consistency.test.ts +31 -15
- package/src/tests/island-audit.test.ts +524 -0
- package/src/tests/jsx.test.ts +236 -4
- package/src/tests/load-native.test.ts +53 -0
- package/src/tests/native-equivalence.test.ts +77 -0
- package/src/tests/pyreon-intercept.test.ts +296 -0
- package/src/tests/runtime/control-flow.test.ts +159 -0
- package/src/tests/runtime/dom-properties.test.ts +138 -0
- package/src/tests/runtime/events.test.ts +301 -0
- package/src/tests/runtime/harness.ts +94 -0
- package/src/tests/runtime/pr-352-shapes.test.ts +121 -0
- package/src/tests/runtime/reactive-props.test.ts +81 -0
- package/src/tests/runtime/signals.test.ts +129 -0
- package/src/tests/runtime/whitespace.test.ts +106 -0
- package/src/tests/ssg-audit.test.ts +402 -0
- package/lib/index.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
/// <reference lib="dom" />
|
|
3
|
+
import { computed, signal } from '@pyreon/reactivity'
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
import { flush } from '@pyreon/test-utils/browser'
|
|
6
|
+
import { compileAndMount } from './harness'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Compiler-runtime tests — signal patterns in JSX.
|
|
10
|
+
*
|
|
11
|
+
* The #352 signal-method auto-call bug surfaced because the compiler
|
|
12
|
+
* couldn't tell `signal.set(x)` (call on the signal as object) from
|
|
13
|
+
* `signal()` (call the signal to read). The fix added scope-aware
|
|
14
|
+
* detection. This file pins down the matrix: bare reference, function
|
|
15
|
+
* call, member call, accessor wrapper, computed — in different positions.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
describe('compiler-runtime — signals', () => {
|
|
19
|
+
it('signal in text position is reactive', async () => {
|
|
20
|
+
const name = signal('alice')
|
|
21
|
+
const { container, unmount } = compileAndMount(
|
|
22
|
+
`<div><span id="s">{name()}</span></div>`,
|
|
23
|
+
{ name },
|
|
24
|
+
)
|
|
25
|
+
expect(container.querySelector('#s')!.textContent).toBe('alice')
|
|
26
|
+
name.set('bob')
|
|
27
|
+
await flush()
|
|
28
|
+
expect(container.querySelector('#s')!.textContent).toBe('bob')
|
|
29
|
+
unmount()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('signal.method() in event handler does not auto-call signal', () => {
|
|
33
|
+
const x = signal(0)
|
|
34
|
+
const { container, unmount } = compileAndMount(
|
|
35
|
+
`<div><button id="b" onClick={() => x.set(99)}>set</button></div>`,
|
|
36
|
+
{ x },
|
|
37
|
+
)
|
|
38
|
+
container.querySelector<HTMLButtonElement>('#b')!.click()
|
|
39
|
+
expect(x()).toBe(99)
|
|
40
|
+
unmount()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('signal.update() in event handler does not auto-call signal', () => {
|
|
44
|
+
const x = signal(10)
|
|
45
|
+
const { container, unmount } = compileAndMount(
|
|
46
|
+
`<div><button id="b" onClick={() => x.update((v) => v * 2)}>x2</button></div>`,
|
|
47
|
+
{ x },
|
|
48
|
+
)
|
|
49
|
+
const btn = container.querySelector<HTMLButtonElement>('#b')!
|
|
50
|
+
btn.click()
|
|
51
|
+
btn.click()
|
|
52
|
+
expect(x()).toBe(40)
|
|
53
|
+
unmount()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('signal.peek() in event handler does not auto-call signal', () => {
|
|
57
|
+
const x = signal(7)
|
|
58
|
+
const out = { value: 0 }
|
|
59
|
+
const { container, unmount } = compileAndMount(
|
|
60
|
+
`<div><button id="b" onClick={() => { out.value = x.peek() }}>read</button></div>`,
|
|
61
|
+
{ x, out },
|
|
62
|
+
)
|
|
63
|
+
container.querySelector<HTMLButtonElement>('#b')!.click()
|
|
64
|
+
expect(out.value).toBe(7)
|
|
65
|
+
unmount()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('computed value reflected in DOM updates when source changes', async () => {
|
|
69
|
+
const a = signal(2)
|
|
70
|
+
const b = signal(3)
|
|
71
|
+
const sum = computed(() => a() + b())
|
|
72
|
+
const { container, unmount } = compileAndMount(
|
|
73
|
+
`<div><span id="s">{sum()}</span></div>`,
|
|
74
|
+
{ sum },
|
|
75
|
+
)
|
|
76
|
+
expect(container.querySelector('#s')!.textContent).toBe('5')
|
|
77
|
+
a.set(10)
|
|
78
|
+
await flush()
|
|
79
|
+
expect(container.querySelector('#s')!.textContent).toBe('13')
|
|
80
|
+
unmount()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('explicit accessor wrapper preserves reactivity', async () => {
|
|
84
|
+
const x = signal('hi')
|
|
85
|
+
const { container, unmount } = compileAndMount(
|
|
86
|
+
`<div><span id="s">{() => x()}</span></div>`,
|
|
87
|
+
{ x },
|
|
88
|
+
)
|
|
89
|
+
expect(container.querySelector('#s')!.textContent).toBe('hi')
|
|
90
|
+
x.set('hey')
|
|
91
|
+
await flush()
|
|
92
|
+
expect(container.querySelector('#s')!.textContent).toBe('hey')
|
|
93
|
+
unmount()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('signal in attribute position is reactive', async () => {
|
|
97
|
+
const cls = signal('a')
|
|
98
|
+
const { container, unmount } = compileAndMount(
|
|
99
|
+
`<div><span id="s" class={cls()}>x</span></div>`,
|
|
100
|
+
{ cls },
|
|
101
|
+
)
|
|
102
|
+
expect(container.querySelector('#s')!.className).toBe('a')
|
|
103
|
+
cls.set('b')
|
|
104
|
+
await flush()
|
|
105
|
+
expect(container.querySelector('#s')!.className).toBe('b')
|
|
106
|
+
unmount()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('multiple signals on the same element track independently', async () => {
|
|
110
|
+
const txt = signal('hello')
|
|
111
|
+
const cls = signal('a')
|
|
112
|
+
const { container, unmount } = compileAndMount(
|
|
113
|
+
`<div><span id="s" class={cls()}>{txt()}</span></div>`,
|
|
114
|
+
{ txt, cls },
|
|
115
|
+
)
|
|
116
|
+
const span = container.querySelector('#s')!
|
|
117
|
+
expect(span.textContent).toBe('hello')
|
|
118
|
+
expect(span.className).toBe('a')
|
|
119
|
+
txt.set('world')
|
|
120
|
+
await flush()
|
|
121
|
+
expect(span.textContent).toBe('world')
|
|
122
|
+
expect(span.className).toBe('a')
|
|
123
|
+
cls.set('b')
|
|
124
|
+
await flush()
|
|
125
|
+
expect(span.textContent).toBe('world')
|
|
126
|
+
expect(span.className).toBe('b')
|
|
127
|
+
unmount()
|
|
128
|
+
})
|
|
129
|
+
})
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
/// <reference lib="dom" />
|
|
3
|
+
import { signal } from '@pyreon/reactivity'
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
import { flush } from '@pyreon/test-utils/browser'
|
|
6
|
+
import { compileAndMount } from './harness'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Compiler-runtime tests — JSX text/expression whitespace handling.
|
|
10
|
+
*
|
|
11
|
+
* The #352 whitespace bug stripped same-line spaces adjacent to
|
|
12
|
+
* expressions: `<p>doubled: {x}</p>` rendered "doubled:0" instead of
|
|
13
|
+
* "doubled: 0". The fix implements React/Babel's
|
|
14
|
+
* `cleanJSXElementLiteralChild` algorithm. This file pins down the
|
|
15
|
+
* matrix: same-line text±expression, multi-line text, fragments,
|
|
16
|
+
* leading/trailing/internal whitespace.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
describe('compiler-runtime — JSX whitespace', () => {
|
|
20
|
+
it('preserves trailing space before expression on same line', async () => {
|
|
21
|
+
const x = signal(7)
|
|
22
|
+
const { container, unmount } = compileAndMount(
|
|
23
|
+
`<div><p id="p">doubled: {x()}</p></div>`,
|
|
24
|
+
{ x },
|
|
25
|
+
)
|
|
26
|
+
expect(container.querySelector('#p')!.textContent).toBe('doubled: 7')
|
|
27
|
+
unmount()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
// Regression: when an expression sits BETWEEN static text segments
|
|
31
|
+
// on the same line, the compiler used to append the dynamic text
|
|
32
|
+
// node to the parent's children AFTER all static text via
|
|
33
|
+
// `appendChild`. Whitespace was preserved correctly (#352), but
|
|
34
|
+
// positioning was wrong:
|
|
35
|
+
// `<p>{x()} remaining</p>` → template `<p> remaining</p>` +
|
|
36
|
+
// appended text → renders as " remaining3" instead of "3 remaining".
|
|
37
|
+
// Fix: extend `analyzeChildren.useMixed` to fire whenever ≥2 of
|
|
38
|
+
// {element, text, expression} are present (not just element+nonElement).
|
|
39
|
+
// Then placeholder-based positional mounting puts the dynamic text in
|
|
40
|
+
// the right slot via `replaceChild`.
|
|
41
|
+
it('preserves leading space after expression on same line', async () => {
|
|
42
|
+
const x = signal(3)
|
|
43
|
+
const { container, unmount } = compileAndMount(
|
|
44
|
+
`<div><p id="p">{x()} remaining</p></div>`,
|
|
45
|
+
{ x },
|
|
46
|
+
)
|
|
47
|
+
expect(container.querySelector('#p')!.textContent).toBe('3 remaining')
|
|
48
|
+
unmount()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('preserves spaces on BOTH sides of expression', async () => {
|
|
52
|
+
const x = signal('cat')
|
|
53
|
+
const { container, unmount } = compileAndMount(
|
|
54
|
+
`<div><p id="p">a {x()} b</p></div>`,
|
|
55
|
+
{ x },
|
|
56
|
+
)
|
|
57
|
+
expect(container.querySelector('#p')!.textContent).toBe('a cat b')
|
|
58
|
+
unmount()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('multi-line JSX with indentation collapses correctly', async () => {
|
|
62
|
+
const x = signal('inner')
|
|
63
|
+
// Multi-line JSX expression. Whitespace inside the JSX literal between
|
|
64
|
+
// <p> and {x()} is treated by React/Babel cleanJSX as "indentation"
|
|
65
|
+
// and collapses; same for between {x()} and </p>.
|
|
66
|
+
const { container, unmount } = compileAndMount(
|
|
67
|
+
`<div>
|
|
68
|
+
<p id="p">
|
|
69
|
+
{x()}
|
|
70
|
+
</p>
|
|
71
|
+
</div>`,
|
|
72
|
+
{ x },
|
|
73
|
+
)
|
|
74
|
+
expect(container.querySelector('#p')!.textContent?.trim()).toBe('inner')
|
|
75
|
+
unmount()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('reactive text updates without losing surrounding whitespace', async () => {
|
|
79
|
+
const x = signal(0)
|
|
80
|
+
const { container, unmount } = compileAndMount(
|
|
81
|
+
`<div><p id="p">count: {x()} items</p></div>`,
|
|
82
|
+
{ x },
|
|
83
|
+
)
|
|
84
|
+
expect(container.querySelector('#p')!.textContent).toBe('count: 0 items')
|
|
85
|
+
x.set(42)
|
|
86
|
+
await flush()
|
|
87
|
+
expect(container.querySelector('#p')!.textContent).toBe('count: 42 items')
|
|
88
|
+
unmount()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('reactive text at end of paragraph updates correctly', async () => {
|
|
92
|
+
// The expression-at-END shape works because the dynamic text node is
|
|
93
|
+
// appended to the parent — which happens to match the source order
|
|
94
|
+
// when there's no text after.
|
|
95
|
+
const x = signal(0)
|
|
96
|
+
const { container, unmount } = compileAndMount(
|
|
97
|
+
`<div><p id="p">count: {x()}</p></div>`,
|
|
98
|
+
{ x },
|
|
99
|
+
)
|
|
100
|
+
expect(container.querySelector('#p')!.textContent).toBe('count: 0')
|
|
101
|
+
x.set(42)
|
|
102
|
+
await flush()
|
|
103
|
+
expect(container.querySelector('#p')!.textContent).toBe('count: 42')
|
|
104
|
+
unmount()
|
|
105
|
+
})
|
|
106
|
+
})
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixture-based tests for `auditSsg` (M3.4 of the SSG roadmap).
|
|
3
|
+
*
|
|
4
|
+
* Each finding type gets a parallel pair:
|
|
5
|
+
* - "broken" fixture → finding fires
|
|
6
|
+
* - "fixed" fixture → no finding fires
|
|
7
|
+
*
|
|
8
|
+
* Bisect-verified by removing the detector body and asserting the
|
|
9
|
+
* broken-shape test fails.
|
|
10
|
+
*/
|
|
11
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
12
|
+
import { tmpdir } from 'node:os'
|
|
13
|
+
import { dirname, join } from 'node:path'
|
|
14
|
+
import { auditSsg, formatSsgAudit, type SsgFindingCode } from '../ssg-audit'
|
|
15
|
+
|
|
16
|
+
interface Fixture {
|
|
17
|
+
root: string
|
|
18
|
+
write: (relPath: string, body: string) => void
|
|
19
|
+
cleanup: () => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function makeFixture(): Fixture {
|
|
23
|
+
const root = mkdtempSync(join(tmpdir(), 'pyreon-ssg-audit-fixture-'))
|
|
24
|
+
mkdirSync(join(root, 'packages'), { recursive: true })
|
|
25
|
+
return {
|
|
26
|
+
root,
|
|
27
|
+
write: (relPath, body) => {
|
|
28
|
+
const full = join(root, relPath)
|
|
29
|
+
mkdirSync(dirname(full), { recursive: true })
|
|
30
|
+
writeFileSync(full, body)
|
|
31
|
+
},
|
|
32
|
+
cleanup: () => rmSync(root, { recursive: true, force: true }),
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function findingCodes(result: ReturnType<typeof auditSsg>): SsgFindingCode[] {
|
|
37
|
+
return result.findings.map((f) => f.code)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
41
|
+
// Discovery
|
|
42
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
43
|
+
|
|
44
|
+
describe('auditSsg — discovery', () => {
|
|
45
|
+
it('returns empty results for a directory with no routes/ subdir', () => {
|
|
46
|
+
const empty = mkdtempSync(join(tmpdir(), 'pyreon-ssg-audit-empty-'))
|
|
47
|
+
try {
|
|
48
|
+
const result = auditSsg(empty)
|
|
49
|
+
expect(result.findings).toEqual([])
|
|
50
|
+
expect(result.summary.routesScanned).toBe(0)
|
|
51
|
+
} finally {
|
|
52
|
+
rmSync(empty, { recursive: true, force: true })
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('counts route files + dynamic routes + revalidate exports in summary', () => {
|
|
57
|
+
const fixture = makeFixture()
|
|
58
|
+
try {
|
|
59
|
+
// Plain page — no [param], no revalidate.
|
|
60
|
+
fixture.write('examples/myapp/src/routes/_layout.tsx', 'export const layout = () => null')
|
|
61
|
+
fixture.write('examples/myapp/src/routes/index.tsx', 'export default () => null')
|
|
62
|
+
fixture.write('examples/myapp/src/routes/about.tsx', 'export default () => null')
|
|
63
|
+
// Dynamic route with getStaticPaths + revalidate
|
|
64
|
+
fixture.write(
|
|
65
|
+
'examples/myapp/src/routes/posts/[id].tsx',
|
|
66
|
+
`export const getStaticPaths = () => [{ params: { id: '1' } }]
|
|
67
|
+
export const revalidate = 60
|
|
68
|
+
export default () => null`,
|
|
69
|
+
)
|
|
70
|
+
const result = auditSsg(fixture.root)
|
|
71
|
+
expect(result.summary.routesScanned).toBe(4)
|
|
72
|
+
expect(result.summary.dynamicRoutes).toBe(1)
|
|
73
|
+
expect(result.summary.revalidateExports).toBe(1)
|
|
74
|
+
} finally {
|
|
75
|
+
fixture.cleanup()
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
81
|
+
// 1) 404-outside-layout-dir
|
|
82
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
83
|
+
|
|
84
|
+
describe('auditSsg — 404-outside-layout-dir', () => {
|
|
85
|
+
it('FIRES when _404.tsx has no co-located _layout.tsx', () => {
|
|
86
|
+
const fixture = makeFixture()
|
|
87
|
+
try {
|
|
88
|
+
// Broken shape: _404.tsx alone in the routes dir, no _layout.tsx.
|
|
89
|
+
fixture.write('examples/myapp/src/routes/_404.tsx', 'export default () => null')
|
|
90
|
+
fixture.write('examples/myapp/src/routes/index.tsx', 'export default () => null')
|
|
91
|
+
const result = auditSsg(fixture.root)
|
|
92
|
+
expect(findingCodes(result)).toContain('404-outside-layout-dir')
|
|
93
|
+
const finding = result.findings.find((f) => f.code === '404-outside-layout-dir')!
|
|
94
|
+
expect(finding.location.relPath).toContain('_404.tsx')
|
|
95
|
+
expect(finding.message).toContain('_layout.tsx')
|
|
96
|
+
} finally {
|
|
97
|
+
fixture.cleanup()
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('FIRES for _not-found.tsx (alternate filename)', () => {
|
|
102
|
+
const fixture = makeFixture()
|
|
103
|
+
try {
|
|
104
|
+
fixture.write('examples/myapp/src/routes/_not-found.tsx', 'export default () => null')
|
|
105
|
+
const result = auditSsg(fixture.root)
|
|
106
|
+
expect(findingCodes(result)).toContain('404-outside-layout-dir')
|
|
107
|
+
} finally {
|
|
108
|
+
fixture.cleanup()
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('does NOT fire when _404.tsx is co-located with _layout.tsx', () => {
|
|
113
|
+
const fixture = makeFixture()
|
|
114
|
+
try {
|
|
115
|
+
// Fixed shape: same directory contains both.
|
|
116
|
+
fixture.write('examples/myapp/src/routes/_layout.tsx', 'export const layout = () => null')
|
|
117
|
+
fixture.write('examples/myapp/src/routes/_404.tsx', 'export default () => null')
|
|
118
|
+
const result = auditSsg(fixture.root)
|
|
119
|
+
expect(findingCodes(result)).not.toContain('404-outside-layout-dir')
|
|
120
|
+
} finally {
|
|
121
|
+
fixture.cleanup()
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
127
|
+
// 2) dynamic-route-missing-get-static-paths
|
|
128
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
129
|
+
|
|
130
|
+
describe('auditSsg — dynamic-route-missing-get-static-paths', () => {
|
|
131
|
+
it('FIRES for [id].tsx without getStaticPaths', () => {
|
|
132
|
+
const fixture = makeFixture()
|
|
133
|
+
try {
|
|
134
|
+
fixture.write(
|
|
135
|
+
'examples/myapp/src/routes/posts/[id].tsx',
|
|
136
|
+
'export default () => null',
|
|
137
|
+
)
|
|
138
|
+
const result = auditSsg(fixture.root)
|
|
139
|
+
expect(findingCodes(result)).toContain('dynamic-route-missing-get-static-paths')
|
|
140
|
+
const finding = result.findings.find(
|
|
141
|
+
(f) => f.code === 'dynamic-route-missing-get-static-paths',
|
|
142
|
+
)!
|
|
143
|
+
expect(finding.location.relPath).toContain('[id].tsx')
|
|
144
|
+
expect(finding.message).toContain('getStaticPaths')
|
|
145
|
+
} finally {
|
|
146
|
+
fixture.cleanup()
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('FIRES for catch-all [...slug].tsx without getStaticPaths', () => {
|
|
151
|
+
const fixture = makeFixture()
|
|
152
|
+
try {
|
|
153
|
+
fixture.write(
|
|
154
|
+
'examples/myapp/src/routes/blog/[...slug].tsx',
|
|
155
|
+
'export default () => null',
|
|
156
|
+
)
|
|
157
|
+
const result = auditSsg(fixture.root)
|
|
158
|
+
expect(findingCodes(result)).toContain('dynamic-route-missing-get-static-paths')
|
|
159
|
+
} finally {
|
|
160
|
+
fixture.cleanup()
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('does NOT fire for [id].tsx WITH `export const getStaticPaths`', () => {
|
|
165
|
+
const fixture = makeFixture()
|
|
166
|
+
try {
|
|
167
|
+
fixture.write(
|
|
168
|
+
'examples/myapp/src/routes/posts/[id].tsx',
|
|
169
|
+
`export const getStaticPaths = () => [{ params: { id: '1' } }]
|
|
170
|
+
export default () => null`,
|
|
171
|
+
)
|
|
172
|
+
const result = auditSsg(fixture.root)
|
|
173
|
+
expect(findingCodes(result)).not.toContain('dynamic-route-missing-get-static-paths')
|
|
174
|
+
} finally {
|
|
175
|
+
fixture.cleanup()
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('does NOT fire for [id].tsx WITH `export async function getStaticPaths`', () => {
|
|
180
|
+
const fixture = makeFixture()
|
|
181
|
+
try {
|
|
182
|
+
fixture.write(
|
|
183
|
+
'examples/myapp/src/routes/posts/[id].tsx',
|
|
184
|
+
`export async function getStaticPaths() { return [{ params: { id: '1' } }] }
|
|
185
|
+
export default () => null`,
|
|
186
|
+
)
|
|
187
|
+
const result = auditSsg(fixture.root)
|
|
188
|
+
expect(findingCodes(result)).not.toContain('dynamic-route-missing-get-static-paths')
|
|
189
|
+
} finally {
|
|
190
|
+
fixture.cleanup()
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('does NOT fire for static routes (no [param] in filename)', () => {
|
|
195
|
+
const fixture = makeFixture()
|
|
196
|
+
try {
|
|
197
|
+
fixture.write('examples/myapp/src/routes/about.tsx', 'export default () => null')
|
|
198
|
+
fixture.write('examples/myapp/src/routes/index.tsx', 'export default () => null')
|
|
199
|
+
const result = auditSsg(fixture.root)
|
|
200
|
+
expect(findingCodes(result)).not.toContain('dynamic-route-missing-get-static-paths')
|
|
201
|
+
} finally {
|
|
202
|
+
fixture.cleanup()
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('does NOT fire for _layout / _error / _loading / _404 even with brackets in name', () => {
|
|
207
|
+
// Defensive: special files with bracketed names (unlikely but
|
|
208
|
+
// possible — `_layout.[locale].tsx`) shouldn't be flagged.
|
|
209
|
+
const fixture = makeFixture()
|
|
210
|
+
try {
|
|
211
|
+
fixture.write(
|
|
212
|
+
'examples/myapp/src/routes/_layout.tsx',
|
|
213
|
+
'export const layout = () => null',
|
|
214
|
+
)
|
|
215
|
+
fixture.write('examples/myapp/src/routes/_404.tsx', 'export default () => null')
|
|
216
|
+
const result = auditSsg(fixture.root)
|
|
217
|
+
expect(findingCodes(result)).not.toContain('dynamic-route-missing-get-static-paths')
|
|
218
|
+
} finally {
|
|
219
|
+
fixture.cleanup()
|
|
220
|
+
}
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
// M3.B follow-up — false-positive class surfaced by cpa-pw-blog's
|
|
224
|
+
// `api/echo/[...path].ts` (real-world API route with bracket
|
|
225
|
+
// filename). API routes are runtime-only by definition.
|
|
226
|
+
it('does NOT fire for API routes under routes/api/ (path-based skip)', () => {
|
|
227
|
+
const fixture = makeFixture()
|
|
228
|
+
try {
|
|
229
|
+
fixture.write(
|
|
230
|
+
'examples/myapp/src/routes/api/echo/[...path].ts',
|
|
231
|
+
`export function GET({ params }) {
|
|
232
|
+
return new Response(\`segments: \${params.path}\`)
|
|
233
|
+
}`,
|
|
234
|
+
)
|
|
235
|
+
const result = auditSsg(fixture.root)
|
|
236
|
+
expect(findingCodes(result)).not.toContain('dynamic-route-missing-get-static-paths')
|
|
237
|
+
} finally {
|
|
238
|
+
fixture.cleanup()
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('does NOT fire for files without `export default` outside api/ (export-shape skip)', () => {
|
|
243
|
+
// Method-handler-only file outside api/ — covers users who put API
|
|
244
|
+
// routes somewhere non-conventional. Page routes structurally
|
|
245
|
+
// require a default export, so absence is a reliable signal.
|
|
246
|
+
const fixture = makeFixture()
|
|
247
|
+
try {
|
|
248
|
+
fixture.write(
|
|
249
|
+
'examples/myapp/src/routes/webhook/[id].ts',
|
|
250
|
+
`export function POST({ request }) {
|
|
251
|
+
return new Response('ok')
|
|
252
|
+
}`,
|
|
253
|
+
)
|
|
254
|
+
const result = auditSsg(fixture.root)
|
|
255
|
+
expect(findingCodes(result)).not.toContain('dynamic-route-missing-get-static-paths')
|
|
256
|
+
} finally {
|
|
257
|
+
fixture.cleanup()
|
|
258
|
+
}
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('STILL fires on page routes (with default export) missing getStaticPaths', () => {
|
|
262
|
+
// Sanity — the export-shape skip doesn't accidentally silence the
|
|
263
|
+
// rule on legitimate page routes.
|
|
264
|
+
const fixture = makeFixture()
|
|
265
|
+
try {
|
|
266
|
+
fixture.write(
|
|
267
|
+
'examples/myapp/src/routes/posts/[id].tsx',
|
|
268
|
+
`export const someHelper = 1
|
|
269
|
+
export default function Post() { return null }`,
|
|
270
|
+
)
|
|
271
|
+
const result = auditSsg(fixture.root)
|
|
272
|
+
expect(findingCodes(result)).toContain('dynamic-route-missing-get-static-paths')
|
|
273
|
+
} finally {
|
|
274
|
+
fixture.cleanup()
|
|
275
|
+
}
|
|
276
|
+
})
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
280
|
+
// 3) non-literal-revalidate-export
|
|
281
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
282
|
+
|
|
283
|
+
describe('auditSsg — non-literal-revalidate-export', () => {
|
|
284
|
+
it('FIRES for `export const revalidate = TTL` (identifier reference)', () => {
|
|
285
|
+
const fixture = makeFixture()
|
|
286
|
+
try {
|
|
287
|
+
fixture.write(
|
|
288
|
+
'examples/myapp/src/routes/posts/index.tsx',
|
|
289
|
+
`const TTL = 60
|
|
290
|
+
export const revalidate = TTL
|
|
291
|
+
export default () => null`,
|
|
292
|
+
)
|
|
293
|
+
const result = auditSsg(fixture.root)
|
|
294
|
+
expect(findingCodes(result)).toContain('non-literal-revalidate-export')
|
|
295
|
+
const finding = result.findings.find((f) => f.code === 'non-literal-revalidate-export')!
|
|
296
|
+
expect(finding.message).toContain('NUMERIC LITERAL')
|
|
297
|
+
} finally {
|
|
298
|
+
fixture.cleanup()
|
|
299
|
+
}
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('FIRES for `export const revalidate = 30 * 60` (arithmetic)', () => {
|
|
303
|
+
const fixture = makeFixture()
|
|
304
|
+
try {
|
|
305
|
+
fixture.write(
|
|
306
|
+
'examples/myapp/src/routes/posts/index.tsx',
|
|
307
|
+
`export const revalidate = 30 * 60
|
|
308
|
+
export default () => null`,
|
|
309
|
+
)
|
|
310
|
+
const result = auditSsg(fixture.root)
|
|
311
|
+
expect(findingCodes(result)).toContain('non-literal-revalidate-export')
|
|
312
|
+
} finally {
|
|
313
|
+
fixture.cleanup()
|
|
314
|
+
}
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('does NOT fire for `export const revalidate = 60` (numeric literal)', () => {
|
|
318
|
+
const fixture = makeFixture()
|
|
319
|
+
try {
|
|
320
|
+
fixture.write(
|
|
321
|
+
'examples/myapp/src/routes/posts/index.tsx',
|
|
322
|
+
`export const revalidate = 60
|
|
323
|
+
export default () => null`,
|
|
324
|
+
)
|
|
325
|
+
const result = auditSsg(fixture.root)
|
|
326
|
+
expect(findingCodes(result)).not.toContain('non-literal-revalidate-export')
|
|
327
|
+
} finally {
|
|
328
|
+
fixture.cleanup()
|
|
329
|
+
}
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('does NOT fire for `export const revalidate = false` (false keyword)', () => {
|
|
333
|
+
const fixture = makeFixture()
|
|
334
|
+
try {
|
|
335
|
+
fixture.write(
|
|
336
|
+
'examples/myapp/src/routes/posts/index.tsx',
|
|
337
|
+
`export const revalidate = false
|
|
338
|
+
export default () => null`,
|
|
339
|
+
)
|
|
340
|
+
const result = auditSsg(fixture.root)
|
|
341
|
+
expect(findingCodes(result)).not.toContain('non-literal-revalidate-export')
|
|
342
|
+
} finally {
|
|
343
|
+
fixture.cleanup()
|
|
344
|
+
}
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('does NOT fire when there is no revalidate export at all', () => {
|
|
348
|
+
const fixture = makeFixture()
|
|
349
|
+
try {
|
|
350
|
+
fixture.write('examples/myapp/src/routes/about.tsx', 'export default () => null')
|
|
351
|
+
const result = auditSsg(fixture.root)
|
|
352
|
+
expect(findingCodes(result)).not.toContain('non-literal-revalidate-export')
|
|
353
|
+
} finally {
|
|
354
|
+
fixture.cleanup()
|
|
355
|
+
}
|
|
356
|
+
})
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
360
|
+
// Formatter
|
|
361
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
362
|
+
|
|
363
|
+
describe('formatSsgAudit', () => {
|
|
364
|
+
it('renders a clean header when there are no findings', () => {
|
|
365
|
+
const fixture = makeFixture()
|
|
366
|
+
try {
|
|
367
|
+
fixture.write('examples/myapp/src/routes/index.tsx', 'export default () => null')
|
|
368
|
+
const result = auditSsg(fixture.root)
|
|
369
|
+
const output = formatSsgAudit(result)
|
|
370
|
+
expect(output).toContain('SSG audit')
|
|
371
|
+
expect(output).toContain('No SSG / ISR issues found')
|
|
372
|
+
} finally {
|
|
373
|
+
fixture.cleanup()
|
|
374
|
+
}
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
it('renders each finding with relPath:line:col + actionable message', () => {
|
|
378
|
+
const fixture = makeFixture()
|
|
379
|
+
try {
|
|
380
|
+
fixture.write('examples/myapp/src/routes/_404.tsx', 'export default () => null')
|
|
381
|
+
const result = auditSsg(fixture.root)
|
|
382
|
+
const output = formatSsgAudit(result)
|
|
383
|
+
expect(output).toContain('[404-outside-layout-dir]')
|
|
384
|
+
expect(output).toContain('_404.tsx')
|
|
385
|
+
expect(output).toContain('_layout.tsx')
|
|
386
|
+
} finally {
|
|
387
|
+
fixture.cleanup()
|
|
388
|
+
}
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it('mentions the --json flag for machine-readable output', () => {
|
|
392
|
+
const fixture = makeFixture()
|
|
393
|
+
try {
|
|
394
|
+
fixture.write('examples/myapp/src/routes/posts/[id].tsx', 'export default () => null')
|
|
395
|
+
const result = auditSsg(fixture.root)
|
|
396
|
+
const output = formatSsgAudit(result)
|
|
397
|
+
expect(output).toContain('--json')
|
|
398
|
+
} finally {
|
|
399
|
+
fixture.cleanup()
|
|
400
|
+
}
|
|
401
|
+
})
|
|
402
|
+
})
|