@jasonshimmy/vite-plugin-cer-app 0.1.2 → 0.1.4
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/CHANGELOG.md +8 -0
- package/commits.txt +1 -1
- package/cypress.config.ts +16 -0
- package/dist/cli/create/index.js +1 -1
- package/dist/cli/create/index.js.map +1 -1
- package/dist/plugin/build-ssg.d.ts +7 -0
- package/dist/plugin/build-ssg.d.ts.map +1 -1
- package/dist/plugin/build-ssg.js +2 -1
- package/dist/plugin/build-ssg.js.map +1 -1
- package/dist/plugin/build-ssr.d.ts.map +1 -1
- package/dist/plugin/build-ssr.js +26 -6
- package/dist/plugin/build-ssr.js.map +1 -1
- package/dist/runtime/composables/index.d.ts +1 -1
- package/dist/runtime/composables/index.d.ts.map +1 -1
- package/dist/runtime/composables/index.js +1 -1
- package/dist/runtime/composables/index.js.map +1 -1
- package/dist/runtime/composables/use-head.d.ts.map +1 -1
- package/dist/runtime/composables/use-head.js +12 -8
- package/dist/runtime/composables/use-head.js.map +1 -1
- package/dist/runtime/entry-server-template.d.ts +1 -1
- package/dist/runtime/entry-server-template.d.ts.map +1 -1
- package/dist/runtime/entry-server-template.js +14 -4
- package/dist/runtime/entry-server-template.js.map +1 -1
- package/docs/cli.md +2 -0
- package/docs/components.md +57 -0
- package/docs/composables.md +9 -2
- package/docs/data-loading.md +45 -1
- package/docs/getting-started.md +71 -6
- package/docs/head-management.md +6 -0
- package/docs/plugins.md +25 -0
- package/docs/routing.md +48 -6
- package/e2e/cypress/e2e/api.cy.ts +81 -0
- package/e2e/cypress/e2e/data.cy.ts +111 -0
- package/e2e/cypress/e2e/fouc.cy.ts +65 -0
- package/e2e/cypress/e2e/head.cy.ts +89 -0
- package/e2e/cypress/e2e/interactive.cy.ts +122 -0
- package/e2e/cypress/e2e/routes.cy.ts +128 -0
- package/e2e/cypress/support/commands.ts +60 -0
- package/e2e/cypress/support/e2e.ts +10 -0
- package/{src/runtime/app-template.ts → e2e/kitchen-sink/app/app.ts} +43 -49
- package/e2e/kitchen-sink/app/components/ks-badge.ts +8 -0
- package/e2e/kitchen-sink/app/composables/useKsCounter.ts +9 -0
- package/e2e/kitchen-sink/app/error.ts +13 -0
- package/e2e/kitchen-sink/app/layouts/default.ts +21 -0
- package/e2e/kitchen-sink/app/layouts/minimal.ts +7 -0
- package/e2e/kitchen-sink/app/loading.ts +9 -0
- package/e2e/kitchen-sink/app/middleware/auth.ts +13 -0
- package/e2e/kitchen-sink/app/pages/(auth)/login.ts +13 -0
- package/e2e/kitchen-sink/app/pages/(auth)/protected.ts +20 -0
- package/e2e/kitchen-sink/app/pages/404.ts +9 -0
- package/e2e/kitchen-sink/app/pages/about.ts +17 -0
- package/e2e/kitchen-sink/app/pages/blog/[slug].ts +54 -0
- package/e2e/kitchen-sink/app/pages/blog/index.ts +46 -0
- package/e2e/kitchen-sink/app/pages/counter.ts +17 -0
- package/e2e/kitchen-sink/app/pages/head.ts +20 -0
- package/e2e/kitchen-sink/app/pages/index.ts +27 -0
- package/e2e/kitchen-sink/app/pages/items/[id].ts +20 -0
- package/e2e/kitchen-sink/app/plugins/01.setup.ts +7 -0
- package/e2e/kitchen-sink/cer-auto-imports.d.ts +50 -0
- package/e2e/kitchen-sink/cer-env.d.ts +36 -0
- package/e2e/kitchen-sink/cer-tsconfig.json +30 -0
- package/e2e/kitchen-sink/cer.config.ts +6 -0
- package/e2e/kitchen-sink/index.html +12 -0
- package/e2e/kitchen-sink/server/api/health.ts +3 -0
- package/e2e/kitchen-sink/server/api/posts/[slug].ts +11 -0
- package/e2e/kitchen-sink/server/api/posts/index.ts +5 -0
- package/e2e/kitchen-sink/server/data/posts.ts +21 -0
- package/e2e/scripts/clean.mjs +8 -0
- package/package.json +19 -2
- package/src/__tests__/plugin/build-ssg-render.test.ts +110 -0
- package/src/__tests__/plugin/build-ssg.test.ts +47 -1
- package/src/__tests__/plugin/build-ssr.test.ts +93 -1
- package/src/__tests__/plugin/dev-server.test.ts +493 -0
- package/src/__tests__/plugin/scanner.test.ts +15 -1
- package/src/__tests__/plugin/transforms/auto-import.test.ts +63 -0
- package/src/cli/create/index.ts +1 -1
- package/src/cli/create/templates/spa/app/app.ts.tpl +23 -3
- package/src/cli/create/templates/ssg/app/app.ts.tpl +27 -3
- package/src/cli/create/templates/ssg/app/pages/index.ts.tpl +0 -9
- package/src/cli/create/templates/ssr/app/app.ts.tpl +27 -3
- package/src/plugin/build-ssg.ts +2 -1
- package/src/plugin/build-ssr.ts +26 -6
- package/src/runtime/composables/index.ts +1 -1
- package/src/runtime/composables/use-head.ts +12 -8
- package/src/runtime/entry-server-template.ts +14 -4
- package/vitest.config.ts +5 -1
- package/VITE_PLUGIN_FRAMEWORK_PLAN.md +0 -594
- package/dist/runtime/app-template.d.ts +0 -10
- package/dist/runtime/app-template.d.ts.map +0 -1
- package/dist/runtime/app-template.js +0 -149
- package/dist/runtime/app-template.js.map +0 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
|
1
|
+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
2
|
import { readFileSync } from 'node:fs'
|
|
3
3
|
import { resolve } from 'pathe'
|
|
4
4
|
|
|
@@ -178,3 +178,95 @@ describe('buildSSR', () => {
|
|
|
178
178
|
expect(firstCall.define).toEqual({ MY_FLAG: 'true' })
|
|
179
179
|
})
|
|
180
180
|
})
|
|
181
|
+
|
|
182
|
+
// ─── resolveClientEntry fallback paths ───────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
describe('buildSSR — resolveClientEntry fallbacks', () => {
|
|
185
|
+
let buildMock: ReturnType<typeof vi.fn>
|
|
186
|
+
let existsSyncMock: ReturnType<typeof vi.fn>
|
|
187
|
+
let buildSSR: (config: ResolvedCerConfig) => Promise<void>
|
|
188
|
+
|
|
189
|
+
beforeEach(async () => {
|
|
190
|
+
const { build } = await import('vite')
|
|
191
|
+
buildMock = vi.mocked(build)
|
|
192
|
+
buildMock.mockClear()
|
|
193
|
+
buildMock.mockResolvedValue(undefined as never)
|
|
194
|
+
|
|
195
|
+
const { existsSync } = await import('node:fs')
|
|
196
|
+
existsSyncMock = vi.mocked(existsSync)
|
|
197
|
+
;({ buildSSR } = await import('../../plugin/build-ssr.js'))
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
afterEach(() => {
|
|
201
|
+
existsSyncMock.mockReturnValue(true) // restore default
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('uses index.html when it exists', async () => {
|
|
205
|
+
existsSyncMock.mockReturnValue(true) // index.html exists
|
|
206
|
+
await buildSSR(makeConfig())
|
|
207
|
+
const clientInput = (buildMock.mock.calls[0][0] as any).build.rollupOptions.input
|
|
208
|
+
expect(clientInput).toMatch(/index\.html$/)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('falls back to entry-client.ts when index.html is absent', async () => {
|
|
212
|
+
existsSyncMock.mockImplementation((p: unknown) => String(p).endsWith('entry-client.ts'))
|
|
213
|
+
await buildSSR(makeConfig())
|
|
214
|
+
const clientInput = (buildMock.mock.calls[0][0] as any).build.rollupOptions.input
|
|
215
|
+
expect(clientInput).toMatch(/entry-client\.ts$/)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('falls back to app.ts when neither index.html nor entry-client.ts exist', async () => {
|
|
219
|
+
existsSyncMock.mockReturnValue(false)
|
|
220
|
+
await buildSSR(makeConfig())
|
|
221
|
+
const clientInput = (buildMock.mock.calls[0][0] as any).build.rollupOptions.input
|
|
222
|
+
expect(clientInput).toMatch(/app\.ts$/)
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
// ─── Server build virtual plugin callbacks ────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
describe('buildSSR — virtual server-entry plugin', () => {
|
|
229
|
+
let buildMock: ReturnType<typeof vi.fn>
|
|
230
|
+
let buildSSR: (config: ResolvedCerConfig) => Promise<void>
|
|
231
|
+
|
|
232
|
+
beforeEach(async () => {
|
|
233
|
+
const { build } = await import('vite')
|
|
234
|
+
buildMock = vi.mocked(build)
|
|
235
|
+
buildMock.mockClear()
|
|
236
|
+
buildMock.mockResolvedValue(undefined as never)
|
|
237
|
+
;({ buildSSR } = await import('../../plugin/build-ssr.js'))
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
async function getServerPlugin() {
|
|
241
|
+
await buildSSR(makeConfig())
|
|
242
|
+
const serverCallPlugins: any[] = (buildMock.mock.calls[1][0] as any).plugins ?? []
|
|
243
|
+
return serverCallPlugins.find((p: any) => p?.name === 'vite-plugin-cer-server-entry')
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
it('server build includes vite-plugin-cer-server-entry plugin', async () => {
|
|
247
|
+
const plugin = await getServerPlugin()
|
|
248
|
+
expect(plugin).toBeDefined()
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('resolveId returns resolved id for virtual:cer-server-entry', async () => {
|
|
252
|
+
const plugin = await getServerPlugin()
|
|
253
|
+
expect(plugin.resolveId('virtual:cer-server-entry')).toBe('\0virtual:cer-server-entry')
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('resolveId returns undefined for unknown ids', async () => {
|
|
257
|
+
const plugin = await getServerPlugin()
|
|
258
|
+
expect(plugin.resolveId('some-other-id')).toBeUndefined()
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('load returns server entry source for resolved id', async () => {
|
|
262
|
+
const plugin = await getServerPlugin()
|
|
263
|
+
const source = plugin.load('\0virtual:cer-server-entry')
|
|
264
|
+
expect(typeof source).toBe('string')
|
|
265
|
+
expect(source).toContain('AUTO-GENERATED server entry')
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('load returns undefined for other ids', async () => {
|
|
269
|
+
const plugin = await getServerPlugin()
|
|
270
|
+
expect(plugin.load('something-else')).toBeUndefined()
|
|
271
|
+
})
|
|
272
|
+
})
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import { EventEmitter } from 'node:events'
|
|
3
|
+
import { configureCerDevServer } from '../../plugin/dev-server.js'
|
|
4
|
+
import type { ResolvedCerConfig } from '../../plugin/dev-server.js'
|
|
5
|
+
|
|
6
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Creates a minimal mock IncomingMessage.
|
|
10
|
+
* Body events are emitted via setImmediate so they fire after readBody
|
|
11
|
+
* registers its 'data'/'end' listeners (which happens inside the async
|
|
12
|
+
* middleware after several awaited ssrLoadModule calls).
|
|
13
|
+
*/
|
|
14
|
+
function createReq(opts: {
|
|
15
|
+
url?: string
|
|
16
|
+
method?: string
|
|
17
|
+
headers?: Record<string, string>
|
|
18
|
+
body?: string
|
|
19
|
+
} = {}) {
|
|
20
|
+
const emitter = new EventEmitter()
|
|
21
|
+
setImmediate(() => {
|
|
22
|
+
if (opts.body !== undefined) emitter.emit('data', Buffer.from(opts.body))
|
|
23
|
+
emitter.emit('end')
|
|
24
|
+
})
|
|
25
|
+
return Object.assign(emitter, {
|
|
26
|
+
url: opts.url ?? '/',
|
|
27
|
+
method: opts.method ?? 'GET',
|
|
28
|
+
headers: opts.headers ?? {},
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function createRes() {
|
|
33
|
+
const res: any = { statusCode: 200, setHeader: vi.fn(), end: vi.fn() }
|
|
34
|
+
return res
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function makeConfig(overrides: Partial<ResolvedCerConfig> = {}): ResolvedCerConfig {
|
|
38
|
+
return {
|
|
39
|
+
mode: 'spa',
|
|
40
|
+
root: '/project',
|
|
41
|
+
srcDir: '/project/app',
|
|
42
|
+
pagesDir: '/project/app/pages',
|
|
43
|
+
layoutsDir: '/project/app/layouts',
|
|
44
|
+
componentsDir: '/project/app/components',
|
|
45
|
+
composablesDir: '/project/app/composables',
|
|
46
|
+
pluginsDir: '/project/app/plugins',
|
|
47
|
+
middlewareDir: '/project/app/middleware',
|
|
48
|
+
serverApiDir: '/project/server/api',
|
|
49
|
+
serverMiddlewareDir: '/project/server/middleware',
|
|
50
|
+
port: 3000,
|
|
51
|
+
ssr: { dsd: true, streaming: false },
|
|
52
|
+
ssg: { routes: 'auto', concurrency: 2, fallback: false },
|
|
53
|
+
router: {},
|
|
54
|
+
jitCss: { content: [], extendedColors: false },
|
|
55
|
+
autoImports: { components: true, composables: true, directives: true, runtime: true },
|
|
56
|
+
...overrides,
|
|
57
|
+
} as ResolvedCerConfig
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type MockServer = {
|
|
61
|
+
middlewares: { use: ReturnType<typeof vi.fn> }
|
|
62
|
+
ssrLoadModule: ReturnType<typeof vi.fn>
|
|
63
|
+
ssrFixStacktrace: ReturnType<typeof vi.fn>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function makeServer(): { server: MockServer; getMiddleware: () => Function } {
|
|
67
|
+
const registered: Function[] = []
|
|
68
|
+
const server: MockServer = {
|
|
69
|
+
middlewares: { use: vi.fn((fn: Function) => registered.push(fn)) },
|
|
70
|
+
ssrLoadModule: vi.fn(async (path: string) => {
|
|
71
|
+
if (path.includes('server-middleware')) return { serverMiddleware: [] }
|
|
72
|
+
return { apiRoutes: [] }
|
|
73
|
+
}),
|
|
74
|
+
ssrFixStacktrace: vi.fn(),
|
|
75
|
+
}
|
|
76
|
+
return { server, getMiddleware: () => registered[0] }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─── Registration ─────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
describe('configureCerDevServer — registration', () => {
|
|
82
|
+
it('registers exactly one middleware via server.middlewares.use', () => {
|
|
83
|
+
const { server } = makeServer()
|
|
84
|
+
configureCerDevServer(server as any, makeConfig())
|
|
85
|
+
expect(server.middlewares.use).toHaveBeenCalledTimes(1)
|
|
86
|
+
expect(typeof (server.middlewares.use as any).mock.calls[0][0]).toBe('function')
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// ─── Non-API pass-through ─────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
describe('configureCerDevServer — non-API pass-through', () => {
|
|
93
|
+
it('calls next() for a plain page request with no matching routes', async () => {
|
|
94
|
+
const { server, getMiddleware } = makeServer()
|
|
95
|
+
configureCerDevServer(server as any, makeConfig())
|
|
96
|
+
const next = vi.fn()
|
|
97
|
+
await getMiddleware()(createReq({ url: '/about' }), createRes(), next)
|
|
98
|
+
expect(next).toHaveBeenCalled()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('calls next() when ssrLoadModule throws (module not ready)', async () => {
|
|
102
|
+
const { server, getMiddleware } = makeServer()
|
|
103
|
+
server.ssrLoadModule.mockRejectedValue(new Error('not ready'))
|
|
104
|
+
configureCerDevServer(server as any, makeConfig())
|
|
105
|
+
const next = vi.fn()
|
|
106
|
+
await getMiddleware()(createReq({ url: '/test' }), createRes(), next)
|
|
107
|
+
expect(next).toHaveBeenCalled()
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// ─── API route matching ───────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
describe('configureCerDevServer — API route matching', () => {
|
|
114
|
+
function makeServerWithRoute(route: { path: string; handlers: Record<string, unknown> }) {
|
|
115
|
+
const { server, getMiddleware } = makeServer()
|
|
116
|
+
server.ssrLoadModule.mockImplementation(async (path: string) => {
|
|
117
|
+
if (path.includes('server-middleware')) return { serverMiddleware: [] }
|
|
118
|
+
return { apiRoutes: [route] }
|
|
119
|
+
})
|
|
120
|
+
configureCerDevServer(server as any, makeConfig())
|
|
121
|
+
return getMiddleware()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
it('calls GET handler for matching path', async () => {
|
|
125
|
+
const handler = vi.fn((req: any, res: any) => res.end('ok'))
|
|
126
|
+
const middleware = makeServerWithRoute({ path: '/api/health', handlers: { get: handler } })
|
|
127
|
+
const next = vi.fn()
|
|
128
|
+
await middleware(createReq({ url: '/api/health' }), createRes(), next)
|
|
129
|
+
expect(handler).toHaveBeenCalled()
|
|
130
|
+
expect(next).not.toHaveBeenCalled()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('calls uppercase GET handler when lowercase is absent', async () => {
|
|
134
|
+
const handler = vi.fn((req: any, res: any) => res.end('ok'))
|
|
135
|
+
const middleware = makeServerWithRoute({ path: '/api/health', handlers: { GET: handler } })
|
|
136
|
+
await middleware(createReq({ url: '/api/health' }), createRes(), vi.fn())
|
|
137
|
+
expect(handler).toHaveBeenCalled()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('calls default handler as fallback', async () => {
|
|
141
|
+
const handler = vi.fn((req: any, res: any) => res.end('ok'))
|
|
142
|
+
const middleware = makeServerWithRoute({ path: '/api/health', handlers: { default: handler } })
|
|
143
|
+
await middleware(createReq({ url: '/api/health' }), createRes(), vi.fn())
|
|
144
|
+
expect(handler).toHaveBeenCalled()
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('calls next() when no handler matches the HTTP method', async () => {
|
|
148
|
+
const middleware = makeServerWithRoute({ path: '/api/health', handlers: {} })
|
|
149
|
+
const next = vi.fn()
|
|
150
|
+
await middleware(createReq({ url: '/api/health' }), createRes(), next)
|
|
151
|
+
expect(next).toHaveBeenCalled()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('does not match when segment counts differ', async () => {
|
|
155
|
+
const handler = vi.fn()
|
|
156
|
+
const middleware = makeServerWithRoute({ path: '/api/users/:id', handlers: { get: handler } })
|
|
157
|
+
const next = vi.fn()
|
|
158
|
+
await middleware(createReq({ url: '/api/users' }), createRes(), next)
|
|
159
|
+
expect(handler).not.toHaveBeenCalled()
|
|
160
|
+
expect(next).toHaveBeenCalled()
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('does not match when a static segment differs', async () => {
|
|
164
|
+
const handler = vi.fn()
|
|
165
|
+
const middleware = makeServerWithRoute({ path: '/api/posts', handlers: { get: handler } })
|
|
166
|
+
const next = vi.fn()
|
|
167
|
+
await middleware(createReq({ url: '/api/users' }), createRes(), next)
|
|
168
|
+
expect(handler).not.toHaveBeenCalled()
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('extracts dynamic route params and attaches to req', async () => {
|
|
172
|
+
let capturedReq: any
|
|
173
|
+
const handler = vi.fn((req: any, res: any) => { capturedReq = req; res.end('ok') })
|
|
174
|
+
const middleware = makeServerWithRoute({ path: '/api/users/:id', handlers: { get: handler } })
|
|
175
|
+
await middleware(createReq({ url: '/api/users/42' }), createRes(), vi.fn())
|
|
176
|
+
expect(capturedReq.params).toEqual({ id: '42' })
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('attaches parsed query string to req', async () => {
|
|
180
|
+
let capturedReq: any
|
|
181
|
+
const handler = vi.fn((req: any, res: any) => { capturedReq = req; res.end('ok') })
|
|
182
|
+
const middleware = makeServerWithRoute({ path: '/api/search', handlers: { get: handler } })
|
|
183
|
+
await middleware(createReq({ url: '/api/search?q=hello&page=2' }), createRes(), vi.fn())
|
|
184
|
+
expect(capturedReq.query).toEqual({ q: 'hello', page: '2' })
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('attaches empty query object when no query string', async () => {
|
|
188
|
+
let capturedReq: any
|
|
189
|
+
const handler = vi.fn((req: any, res: any) => { capturedReq = req; res.end('ok') })
|
|
190
|
+
const middleware = makeServerWithRoute({ path: '/api/health', handlers: { get: handler } })
|
|
191
|
+
await middleware(createReq({ url: '/api/health' }), createRes(), vi.fn())
|
|
192
|
+
expect(capturedReq.query).toEqual({})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('handles query param with no value (flag style ?active)', async () => {
|
|
196
|
+
let capturedReq: any
|
|
197
|
+
const handler = vi.fn((req: any, res: any) => { capturedReq = req; res.end('ok') })
|
|
198
|
+
const middleware = makeServerWithRoute({ path: '/api/items', handlers: { get: handler } })
|
|
199
|
+
await middleware(createReq({ url: '/api/items?active' }), createRes(), vi.fn())
|
|
200
|
+
expect(capturedReq.query).toEqual({ active: '' })
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('skips empty segments from double-ampersand query strings', async () => {
|
|
204
|
+
let capturedReq: any
|
|
205
|
+
const handler = vi.fn((req: any, res: any) => { capturedReq = req; res.end('ok') })
|
|
206
|
+
const middleware = makeServerWithRoute({ path: '/api/items', handlers: { get: handler } })
|
|
207
|
+
await middleware(createReq({ url: '/api/items?a=1&&b=2' }), createRes(), vi.fn())
|
|
208
|
+
expect(capturedReq.query).toEqual({ a: '1', b: '2' })
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('decodes URL-encoded route params', async () => {
|
|
212
|
+
let capturedReq: any
|
|
213
|
+
const handler = vi.fn((req: any, res: any) => { capturedReq = req; res.end('ok') })
|
|
214
|
+
const middleware = makeServerWithRoute({ path: '/api/posts/:slug', handlers: { get: handler } })
|
|
215
|
+
await middleware(createReq({ url: '/api/posts/hello%20world' }), createRes(), vi.fn())
|
|
216
|
+
expect(capturedReq.params.slug).toBe('hello world')
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('returns 500 when API handler throws', async () => {
|
|
220
|
+
const handler = vi.fn(() => { throw new Error('oops') })
|
|
221
|
+
const { server, getMiddleware } = makeServer()
|
|
222
|
+
server.ssrLoadModule.mockImplementation(async (path: string) => {
|
|
223
|
+
if (path.includes('server-middleware')) return { serverMiddleware: [] }
|
|
224
|
+
return { apiRoutes: [{ path: '/api/boom', handlers: { get: handler } }] }
|
|
225
|
+
})
|
|
226
|
+
configureCerDevServer(server as any, makeConfig())
|
|
227
|
+
const res = createRes()
|
|
228
|
+
await getMiddleware()(createReq({ url: '/api/boom' }), res, vi.fn())
|
|
229
|
+
expect(res.statusCode).toBe(500)
|
|
230
|
+
expect(res.end).toHaveBeenCalled()
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
// ─── Augmented response helpers ───────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
describe('configureCerDevServer — augmented response helpers', () => {
|
|
237
|
+
function makeServerWithRoute(handler: Function) {
|
|
238
|
+
const { server, getMiddleware } = makeServer()
|
|
239
|
+
server.ssrLoadModule.mockImplementation(async (path: string) => {
|
|
240
|
+
if (path.includes('server-middleware')) return { serverMiddleware: [] }
|
|
241
|
+
return { apiRoutes: [{ path: '/api/test', handlers: { get: handler } }] }
|
|
242
|
+
})
|
|
243
|
+
configureCerDevServer(server as any, makeConfig())
|
|
244
|
+
return getMiddleware()
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
it('res.json() serializes data and sets Content-Type', async () => {
|
|
248
|
+
const res = createRes()
|
|
249
|
+
const middleware = makeServerWithRoute((req: any, r: any) => r.json({ ok: true }))
|
|
250
|
+
await middleware(createReq({ url: '/api/test' }), res, vi.fn())
|
|
251
|
+
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'application/json')
|
|
252
|
+
expect(res.end).toHaveBeenCalledWith('{"ok":true}')
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('res.status() sets statusCode and is chainable', async () => {
|
|
256
|
+
const res = createRes()
|
|
257
|
+
const middleware = makeServerWithRoute((req: any, r: any) => r.status(404).end('Not Found'))
|
|
258
|
+
await middleware(createReq({ url: '/api/test' }), res, vi.fn())
|
|
259
|
+
expect(res.statusCode).toBe(404)
|
|
260
|
+
expect(res.end).toHaveBeenCalledWith('Not Found')
|
|
261
|
+
})
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
// ─── POST body parsing ────────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
describe('configureCerDevServer — POST body parsing', () => {
|
|
267
|
+
it('parses JSON body for POST request', async () => {
|
|
268
|
+
let capturedBody: unknown
|
|
269
|
+
const { server, getMiddleware } = makeServer()
|
|
270
|
+
server.ssrLoadModule.mockImplementation(async (path: string) => {
|
|
271
|
+
if (path.includes('server-middleware')) return { serverMiddleware: [] }
|
|
272
|
+
return {
|
|
273
|
+
apiRoutes: [{
|
|
274
|
+
path: '/api/items',
|
|
275
|
+
handlers: { post: (req: any, res: any) => { capturedBody = req.body; res.end('ok') } },
|
|
276
|
+
}],
|
|
277
|
+
}
|
|
278
|
+
})
|
|
279
|
+
configureCerDevServer(server as any, makeConfig())
|
|
280
|
+
const req = createReq({
|
|
281
|
+
url: '/api/items',
|
|
282
|
+
method: 'POST',
|
|
283
|
+
headers: { 'content-type': 'application/json' },
|
|
284
|
+
body: JSON.stringify({ name: 'test' }),
|
|
285
|
+
})
|
|
286
|
+
await getMiddleware()(req, createRes(), vi.fn())
|
|
287
|
+
expect(capturedBody).toEqual({ name: 'test' })
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('returns raw Buffer body for POST with non-JSON content-type', async () => {
|
|
291
|
+
let capturedBody: unknown
|
|
292
|
+
const { server, getMiddleware } = makeServer()
|
|
293
|
+
server.ssrLoadModule.mockImplementation(async (path: string) => {
|
|
294
|
+
if (path.includes('server-middleware')) return { serverMiddleware: [] }
|
|
295
|
+
return {
|
|
296
|
+
apiRoutes: [{
|
|
297
|
+
path: '/api/upload',
|
|
298
|
+
handlers: { post: (req: any, res: any) => { capturedBody = req.body; res.end('ok') } },
|
|
299
|
+
}],
|
|
300
|
+
}
|
|
301
|
+
})
|
|
302
|
+
configureCerDevServer(server as any, makeConfig())
|
|
303
|
+
const req = createReq({
|
|
304
|
+
url: '/api/upload',
|
|
305
|
+
method: 'POST',
|
|
306
|
+
headers: { 'content-type': 'text/plain' },
|
|
307
|
+
body: 'raw text',
|
|
308
|
+
})
|
|
309
|
+
await getMiddleware()(req, createRes(), vi.fn())
|
|
310
|
+
expect(Buffer.isBuffer(capturedBody)).toBe(true)
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('attaches undefined body for GET requests', async () => {
|
|
314
|
+
let capturedBody: unknown = 'sentinel'
|
|
315
|
+
const { server, getMiddleware } = makeServer()
|
|
316
|
+
server.ssrLoadModule.mockImplementation(async (path: string) => {
|
|
317
|
+
if (path.includes('server-middleware')) return { serverMiddleware: [] }
|
|
318
|
+
return {
|
|
319
|
+
apiRoutes: [{
|
|
320
|
+
path: '/api/items',
|
|
321
|
+
handlers: { get: (req: any, res: any) => { capturedBody = req.body; res.end('ok') } },
|
|
322
|
+
}],
|
|
323
|
+
}
|
|
324
|
+
})
|
|
325
|
+
configureCerDevServer(server as any, makeConfig())
|
|
326
|
+
await getMiddleware()(createReq({ url: '/api/items' }), createRes(), vi.fn())
|
|
327
|
+
expect(capturedBody).toBeUndefined()
|
|
328
|
+
})
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
// ─── Server middleware execution ──────────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
describe('configureCerDevServer — server middleware', () => {
|
|
334
|
+
it('runs server middleware before API routes', async () => {
|
|
335
|
+
const callOrder: string[] = []
|
|
336
|
+
const smHandler = vi.fn((_req: any, _res: any, next: () => void) => {
|
|
337
|
+
callOrder.push('sm')
|
|
338
|
+
next()
|
|
339
|
+
})
|
|
340
|
+
const apiHandler = vi.fn((req: any, res: any) => { callOrder.push('api'); res.end('ok') })
|
|
341
|
+
const { server, getMiddleware } = makeServer()
|
|
342
|
+
server.ssrLoadModule.mockImplementation(async (path: string) => {
|
|
343
|
+
if (path.includes('server-middleware')) return { serverMiddleware: [{ name: 'test', handler: smHandler }] }
|
|
344
|
+
return { apiRoutes: [{ path: '/api/health', handlers: { get: apiHandler } }] }
|
|
345
|
+
})
|
|
346
|
+
configureCerDevServer(server as any, makeConfig())
|
|
347
|
+
await getMiddleware()(createReq({ url: '/api/health' }), createRes(), vi.fn())
|
|
348
|
+
expect(callOrder).toEqual(['sm', 'api'])
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it('stops processing when server middleware does not call next()', async () => {
|
|
352
|
+
const smHandler = vi.fn((_req: any, res: any) => { res.end('blocked') }) // no next() call
|
|
353
|
+
const apiHandler = vi.fn()
|
|
354
|
+
const { server, getMiddleware } = makeServer()
|
|
355
|
+
server.ssrLoadModule.mockImplementation(async (path: string) => {
|
|
356
|
+
if (path.includes('server-middleware')) return { serverMiddleware: [{ name: 'block', handler: smHandler }] }
|
|
357
|
+
return { apiRoutes: [{ path: '/api/health', handlers: { get: apiHandler } }] }
|
|
358
|
+
})
|
|
359
|
+
configureCerDevServer(server as any, makeConfig())
|
|
360
|
+
await getMiddleware()(createReq({ url: '/api/health' }), createRes(), vi.fn())
|
|
361
|
+
expect(apiHandler).not.toHaveBeenCalled()
|
|
362
|
+
})
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
// ─── SSR mode ─────────────────────────────────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
describe('configureCerDevServer — SSR mode', () => {
|
|
368
|
+
it('invokes SSR handler for HTML requests in ssr mode', async () => {
|
|
369
|
+
const ssrHandler = vi.fn(async (req: any, res: any) => res.end('<html>SSR</html>'))
|
|
370
|
+
const { server, getMiddleware } = makeServer()
|
|
371
|
+
server.ssrLoadModule.mockImplementation(async (path: string) => {
|
|
372
|
+
if (path.includes('server-middleware')) return { serverMiddleware: [] }
|
|
373
|
+
if (path.includes('server-api')) return { apiRoutes: [] }
|
|
374
|
+
// SSR entry module
|
|
375
|
+
return { handler: ssrHandler }
|
|
376
|
+
})
|
|
377
|
+
configureCerDevServer(server as any, makeConfig({ mode: 'ssr' }))
|
|
378
|
+
const res = createRes()
|
|
379
|
+
await getMiddleware()(
|
|
380
|
+
createReq({ url: '/', headers: { accept: 'text/html' } }),
|
|
381
|
+
res,
|
|
382
|
+
vi.fn(),
|
|
383
|
+
)
|
|
384
|
+
expect(ssrHandler).toHaveBeenCalled()
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
it('does not invoke SSR handler for non-HTML requests (e.g. assets)', async () => {
|
|
388
|
+
const ssrHandler = vi.fn()
|
|
389
|
+
const { server, getMiddleware } = makeServer()
|
|
390
|
+
server.ssrLoadModule.mockImplementation(async (path: string) => {
|
|
391
|
+
if (path.includes('server-middleware')) return { serverMiddleware: [] }
|
|
392
|
+
if (path.includes('server-api')) return { apiRoutes: [] }
|
|
393
|
+
return { handler: ssrHandler }
|
|
394
|
+
})
|
|
395
|
+
configureCerDevServer(server as any, makeConfig({ mode: 'ssr' }))
|
|
396
|
+
const next = vi.fn()
|
|
397
|
+
await getMiddleware()(
|
|
398
|
+
createReq({ url: '/assets/main.js', headers: { accept: 'application/javascript' } }),
|
|
399
|
+
createRes(),
|
|
400
|
+
next,
|
|
401
|
+
)
|
|
402
|
+
expect(ssrHandler).not.toHaveBeenCalled()
|
|
403
|
+
expect(next).toHaveBeenCalled()
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it('returns 500 and error text when SSR handler throws', async () => {
|
|
407
|
+
const { server, getMiddleware } = makeServer()
|
|
408
|
+
server.ssrLoadModule.mockImplementation(async (path: string) => {
|
|
409
|
+
if (path.includes('server-middleware')) return { serverMiddleware: [] }
|
|
410
|
+
if (path.includes('server-api')) return { apiRoutes: [] }
|
|
411
|
+
return { handler: async () => { throw new Error('render failed') } }
|
|
412
|
+
})
|
|
413
|
+
configureCerDevServer(server as any, makeConfig({ mode: 'ssr' }))
|
|
414
|
+
const res = createRes()
|
|
415
|
+
await getMiddleware()(createReq({ url: '/', headers: { accept: 'text/html' } }), res, vi.fn())
|
|
416
|
+
expect(res.statusCode).toBe(500)
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
it('calls next() for non-HTML in ssr mode without API match', async () => {
|
|
420
|
+
const { server, getMiddleware } = makeServer()
|
|
421
|
+
server.ssrLoadModule.mockImplementation(async (path: string) => {
|
|
422
|
+
if (path.includes('server-middleware')) return { serverMiddleware: [] }
|
|
423
|
+
return { apiRoutes: [] }
|
|
424
|
+
})
|
|
425
|
+
configureCerDevServer(server as any, makeConfig({ mode: 'ssr' }))
|
|
426
|
+
const next = vi.fn()
|
|
427
|
+
await getMiddleware()(
|
|
428
|
+
createReq({ url: '/favicon.ico', headers: { accept: 'image/x-icon' } }),
|
|
429
|
+
createRes(),
|
|
430
|
+
next,
|
|
431
|
+
)
|
|
432
|
+
expect(next).toHaveBeenCalled()
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it('uses renderFn fallback when SSR entry exports render instead of handler', async () => {
|
|
436
|
+
const renderFn = vi.fn().mockResolvedValue({ html: '<div>rendered</div>' })
|
|
437
|
+
const { server, getMiddleware } = makeServer()
|
|
438
|
+
;(server as any).transformIndexHtml = vi.fn().mockResolvedValue(
|
|
439
|
+
'<!DOCTYPE html><html><body><div id="app"></div></body></html>',
|
|
440
|
+
)
|
|
441
|
+
server.ssrLoadModule.mockImplementation(async (path: string) => {
|
|
442
|
+
if (path.includes('server-middleware')) return { serverMiddleware: [] }
|
|
443
|
+
if (path.includes('server-api')) return { apiRoutes: [] }
|
|
444
|
+
return { render: renderFn }
|
|
445
|
+
})
|
|
446
|
+
configureCerDevServer(server as any, makeConfig({ mode: 'ssr' }))
|
|
447
|
+
const res = createRes()
|
|
448
|
+
await getMiddleware()(createReq({ url: '/', headers: { accept: 'text/html' } }), res, vi.fn())
|
|
449
|
+
expect(renderFn).toHaveBeenCalled()
|
|
450
|
+
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('<div>rendered</div>'))
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
it('accepts html request when url is / even without text/html accept header', async () => {
|
|
454
|
+
const ssrHandler = vi.fn(async (req: any, res: any) => res.end('<html>home</html>'))
|
|
455
|
+
const { server, getMiddleware } = makeServer()
|
|
456
|
+
server.ssrLoadModule.mockImplementation(async (path: string) => {
|
|
457
|
+
if (path.includes('server-middleware')) return { serverMiddleware: [] }
|
|
458
|
+
if (path.includes('server-api')) return { apiRoutes: [] }
|
|
459
|
+
return { handler: ssrHandler }
|
|
460
|
+
})
|
|
461
|
+
configureCerDevServer(server as any, makeConfig({ mode: 'ssr' }))
|
|
462
|
+
// url='/' triggers acceptsHtml without needing accept header
|
|
463
|
+
await getMiddleware()(createReq({ url: '/' }), createRes(), vi.fn())
|
|
464
|
+
expect(ssrHandler).toHaveBeenCalled()
|
|
465
|
+
})
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
// ─── parseBody edge cases ─────────────────────────────────────────────────────
|
|
469
|
+
|
|
470
|
+
describe('configureCerDevServer — malformed JSON body', () => {
|
|
471
|
+
it('returns undefined when POST body is malformed JSON', async () => {
|
|
472
|
+
let capturedBody: unknown = 'sentinel'
|
|
473
|
+
const { server, getMiddleware } = makeServer()
|
|
474
|
+
server.ssrLoadModule.mockImplementation(async (path: string) => {
|
|
475
|
+
if (path.includes('server-middleware')) return { serverMiddleware: [] }
|
|
476
|
+
return {
|
|
477
|
+
apiRoutes: [{
|
|
478
|
+
path: '/api/items',
|
|
479
|
+
handlers: { post: (req: any, res: any) => { capturedBody = req.body; res.end('ok') } },
|
|
480
|
+
}],
|
|
481
|
+
}
|
|
482
|
+
})
|
|
483
|
+
configureCerDevServer(server as any, makeConfig())
|
|
484
|
+
const req = createReq({
|
|
485
|
+
url: '/api/items',
|
|
486
|
+
method: 'POST',
|
|
487
|
+
headers: { 'content-type': 'application/json' },
|
|
488
|
+
body: '{ invalid json }',
|
|
489
|
+
})
|
|
490
|
+
await getMiddleware()(req, createRes(), vi.fn())
|
|
491
|
+
expect(capturedBody).toBeUndefined()
|
|
492
|
+
})
|
|
493
|
+
})
|
|
@@ -54,13 +54,27 @@ describe('createWatcher', () => {
|
|
|
54
54
|
expect(onChange).toHaveBeenCalledWith('change', '/project/app/pages/about.ts')
|
|
55
55
|
})
|
|
56
56
|
|
|
57
|
-
it('does not call onChange for files outside all watched directories', () => {
|
|
57
|
+
it('does not call onChange for files outside all watched directories (add)', () => {
|
|
58
58
|
const onChange = vi.fn()
|
|
59
59
|
createWatcher(watcher as unknown as FSWatcher, ['/project/app/pages'], onChange)
|
|
60
60
|
watcher.emit('add', '/project/other/about.ts')
|
|
61
61
|
expect(onChange).not.toHaveBeenCalled()
|
|
62
62
|
})
|
|
63
63
|
|
|
64
|
+
it('does not call onChange for files outside all watched directories (unlink)', () => {
|
|
65
|
+
const onChange = vi.fn()
|
|
66
|
+
createWatcher(watcher as unknown as FSWatcher, ['/project/app/pages'], onChange)
|
|
67
|
+
watcher.emit('unlink', '/project/other/about.ts')
|
|
68
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('does not call onChange for files outside all watched directories (change)', () => {
|
|
72
|
+
const onChange = vi.fn()
|
|
73
|
+
createWatcher(watcher as unknown as FSWatcher, ['/project/app/pages'], onChange)
|
|
74
|
+
watcher.emit('change', '/project/other/about.ts')
|
|
75
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
76
|
+
})
|
|
77
|
+
|
|
64
78
|
it('cleanup function removes event listeners so no further callbacks fire', () => {
|
|
65
79
|
const onChange = vi.fn()
|
|
66
80
|
const cleanup = createWatcher(watcher as unknown as FSWatcher, ['/project/app/pages'], onChange)
|