@jasonshimmy/vite-plugin-cer-app 0.6.0 → 0.7.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/CHANGELOG.md +4 -0
- package/IMPLEMENTATION_PLAN.md +2 -2
- package/commits.txt +1 -1
- package/dist/cli/commands/preview-isr.d.ts +9 -0
- package/dist/cli/commands/preview-isr.d.ts.map +1 -1
- package/dist/cli/commands/preview-isr.js +16 -0
- package/dist/cli/commands/preview-isr.js.map +1 -1
- package/dist/cli/commands/preview.d.ts.map +1 -1
- package/dist/cli/commands/preview.js +44 -1
- package/dist/cli/commands/preview.js.map +1 -1
- package/dist/plugin/build-ssg.d.ts.map +1 -1
- package/dist/plugin/build-ssg.js +13 -1
- package/dist/plugin/build-ssg.js.map +1 -1
- package/dist/plugin/dev-server.d.ts.map +1 -1
- package/dist/plugin/dev-server.js +33 -0
- package/dist/plugin/dev-server.js.map +1 -1
- package/dist/plugin/virtual/routes.d.ts.map +1 -1
- package/dist/plugin/virtual/routes.js +24 -2
- package/dist/plugin/virtual/routes.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 +21 -4
- package/dist/runtime/entry-server-template.js.map +1 -1
- package/dist/runtime/isr-handler.d.ts +40 -0
- package/dist/runtime/isr-handler.d.ts.map +1 -0
- package/dist/runtime/isr-handler.js +152 -0
- package/dist/runtime/isr-handler.js.map +1 -0
- package/dist/types/page.d.ts +14 -0
- package/dist/types/page.d.ts.map +1 -1
- package/docs/data-loading.md +69 -2
- package/docs/rendering-modes.md +63 -2
- package/docs/routing.md +33 -0
- package/e2e/cypress/e2e/error-boundary.cy.ts +43 -0
- package/e2e/cypress/e2e/isr-nested-runtime.cy.ts +6 -4
- package/e2e/cypress/e2e/per-route-render.cy.ts +70 -0
- package/e2e/kitchen-sink/app/error.ts +7 -2
- package/e2e/kitchen-sink/app/pages/loader-error-test.ts +13 -0
- package/e2e/kitchen-sink/app/pages/render-server-test.ts +12 -0
- package/e2e/kitchen-sink/app/pages/render-spa-test.ts +12 -0
- package/package.json +5 -1
- package/src/__tests__/cli/preview-isr.test.ts +44 -0
- package/src/__tests__/plugin/build-ssg.test.ts +126 -1
- package/src/__tests__/plugin/dev-server.test.ts +91 -0
- package/src/__tests__/plugin/entry-server-template.test.ts +53 -0
- package/src/__tests__/plugin/virtual/routes.test.ts +65 -0
- package/src/__tests__/runtime/isr-handler.test.ts +331 -0
- package/src/cli/commands/preview-isr.ts +19 -0
- package/src/cli/commands/preview.ts +46 -0
- package/src/plugin/build-ssg.ts +11 -1
- package/src/plugin/dev-server.ts +33 -0
- package/src/plugin/virtual/routes.ts +24 -2
- package/src/runtime/entry-server-template.ts +21 -4
- package/src/runtime/isr-handler.ts +183 -0
- package/src/types/page.ts +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jasonshimmy/vite-plugin-cer-app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Nuxt-style meta-framework for @jasonshimmy/custom-elements-runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -37,6 +37,10 @@
|
|
|
37
37
|
"import": "./dist/types/index.js",
|
|
38
38
|
"require": "./dist/types/index.cjs",
|
|
39
39
|
"types": "./dist/types/index.d.ts"
|
|
40
|
+
},
|
|
41
|
+
"./isr": {
|
|
42
|
+
"import": "./dist/runtime/isr-handler.js",
|
|
43
|
+
"types": "./dist/runtime/isr-handler.d.ts"
|
|
40
44
|
}
|
|
41
45
|
},
|
|
42
46
|
"bin": {
|
|
@@ -3,6 +3,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
|
3
3
|
import {
|
|
4
4
|
matchRoutePattern,
|
|
5
5
|
findRevalidate,
|
|
6
|
+
findRenderMode,
|
|
6
7
|
renderForIsr,
|
|
7
8
|
serveFromIsrCache,
|
|
8
9
|
type IsrCacheEntry,
|
|
@@ -119,6 +120,49 @@ describe('findRevalidate', () => {
|
|
|
119
120
|
})
|
|
120
121
|
})
|
|
121
122
|
|
|
123
|
+
// ─── findRenderMode ───────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
describe('findRenderMode', () => {
|
|
126
|
+
it('returns null for an empty routes array', () => {
|
|
127
|
+
expect(findRenderMode([], '/about')).toBeNull()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('returns null when no route matches', () => {
|
|
131
|
+
const routes = [{ path: '/contact', meta: { render: 'server' } }]
|
|
132
|
+
expect(findRenderMode(routes, '/about')).toBeNull()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('returns null when matched route has no render meta', () => {
|
|
136
|
+
const routes = [{ path: '/about' }]
|
|
137
|
+
expect(findRenderMode(routes, '/about')).toBeNull()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('returns "server" for a matching route with render: server', () => {
|
|
141
|
+
const routes = [{ path: '/dashboard', meta: { render: 'server' } }]
|
|
142
|
+
expect(findRenderMode(routes, '/dashboard')).toBe('server')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('returns "spa" for a matching route with render: spa', () => {
|
|
146
|
+
const routes = [{ path: '/profile', meta: { render: 'spa' } }]
|
|
147
|
+
expect(findRenderMode(routes, '/profile')).toBe('spa')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('returns "static" for a matching route with render: static', () => {
|
|
151
|
+
const routes = [{ path: '/about', meta: { render: 'static' } }]
|
|
152
|
+
expect(findRenderMode(routes, '/about')).toBe('static')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('returns null for unrecognised render values', () => {
|
|
156
|
+
const routes = [{ path: '/about', meta: { render: 'unknown' } }]
|
|
157
|
+
expect(findRenderMode(routes, '/about')).toBeNull()
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('matches dynamic routes', () => {
|
|
161
|
+
const routes = [{ path: '/blog/:slug', meta: { render: 'server' } }]
|
|
162
|
+
expect(findRenderMode(routes, '/blog/my-post')).toBe('server')
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
122
166
|
// ─── renderForIsr ─────────────────────────────────────────────────────────────
|
|
123
167
|
|
|
124
168
|
describe('renderForIsr', () => {
|
|
@@ -4,6 +4,7 @@ vi.mock('node:fs', () => ({ existsSync: vi.fn().mockReturnValue(false) }))
|
|
|
4
4
|
vi.mock('node:fs/promises', () => ({
|
|
5
5
|
writeFile: vi.fn().mockResolvedValue(undefined),
|
|
6
6
|
mkdir: vi.fn().mockResolvedValue(undefined),
|
|
7
|
+
readFile: vi.fn().mockResolvedValue(''),
|
|
7
8
|
}))
|
|
8
9
|
vi.mock('fast-glob', () => ({ default: vi.fn().mockResolvedValue([]) }))
|
|
9
10
|
vi.mock('vite', () => ({
|
|
@@ -14,7 +15,7 @@ vi.mock('../../plugin/build-ssr.js', () => ({ buildSSR: vi.fn().mockResolvedValu
|
|
|
14
15
|
vi.mock('../../plugin/path-utils.js', () => ({ buildRouteEntry: vi.fn() }))
|
|
15
16
|
|
|
16
17
|
import { existsSync } from 'node:fs'
|
|
17
|
-
import { writeFile, mkdir } from 'node:fs/promises'
|
|
18
|
+
import { writeFile, mkdir, readFile } from 'node:fs/promises'
|
|
18
19
|
import fg from 'fast-glob'
|
|
19
20
|
import { createServer } from 'vite'
|
|
20
21
|
import { buildSSR } from '../../plugin/build-ssr.js'
|
|
@@ -38,8 +39,10 @@ beforeEach(() => {
|
|
|
38
39
|
vi.mocked(writeFile).mockClear()
|
|
39
40
|
vi.mocked(mkdir).mockClear()
|
|
40
41
|
vi.mocked(fg).mockClear()
|
|
42
|
+
vi.mocked(readFile).mockClear()
|
|
41
43
|
vi.mocked(existsSync).mockReturnValue(false)
|
|
42
44
|
vi.mocked(fg).mockResolvedValue([])
|
|
45
|
+
vi.mocked(readFile).mockResolvedValue('')
|
|
43
46
|
vi.mocked(buildRouteEntry).mockReset()
|
|
44
47
|
})
|
|
45
48
|
|
|
@@ -263,6 +266,128 @@ describe('buildSSG — path collection', () => {
|
|
|
263
266
|
})
|
|
264
267
|
})
|
|
265
268
|
|
|
269
|
+
// ─── render: 'server' / 'spa' skip ───────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
describe('buildSSG — render strategy skip (static pages)', () => {
|
|
272
|
+
it('skips static page with render: server', async () => {
|
|
273
|
+
vi.mocked(existsSync).mockReturnValue(true)
|
|
274
|
+
vi.mocked(fg).mockResolvedValue(['/project/app/pages/dashboard.ts'])
|
|
275
|
+
vi.mocked(readFile).mockResolvedValue("export const meta = { render: 'server' }")
|
|
276
|
+
vi.mocked(buildRouteEntry).mockReturnValueOnce({
|
|
277
|
+
routePath: '/dashboard',
|
|
278
|
+
isDynamic: false,
|
|
279
|
+
isCatchAll: false,
|
|
280
|
+
} as ReturnType<typeof buildRouteEntry>)
|
|
281
|
+
|
|
282
|
+
const config = makeConfig({ ssg: { concurrency: 1 } } as Partial<ResolvedCerConfig>)
|
|
283
|
+
await buildSSG(config)
|
|
284
|
+
|
|
285
|
+
// buildRouteEntry should never be called — page is skipped before it
|
|
286
|
+
expect(buildRouteEntry).not.toHaveBeenCalled()
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('skips static page with render: spa', async () => {
|
|
290
|
+
vi.mocked(existsSync).mockReturnValue(true)
|
|
291
|
+
vi.mocked(fg).mockResolvedValue(['/project/app/pages/profile.ts'])
|
|
292
|
+
vi.mocked(readFile).mockResolvedValue("export const meta = { render: 'spa' }")
|
|
293
|
+
vi.mocked(buildRouteEntry).mockReturnValueOnce({
|
|
294
|
+
routePath: '/profile',
|
|
295
|
+
isDynamic: false,
|
|
296
|
+
isCatchAll: false,
|
|
297
|
+
} as ReturnType<typeof buildRouteEntry>)
|
|
298
|
+
|
|
299
|
+
const config = makeConfig({ ssg: { concurrency: 1 } } as Partial<ResolvedCerConfig>)
|
|
300
|
+
await buildSSG(config)
|
|
301
|
+
|
|
302
|
+
expect(buildRouteEntry).not.toHaveBeenCalled()
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it('does not skip static page with render: static', async () => {
|
|
306
|
+
vi.mocked(existsSync).mockReturnValue(true)
|
|
307
|
+
vi.mocked(fg).mockResolvedValue(['/project/app/pages/legal.ts'])
|
|
308
|
+
vi.mocked(readFile).mockResolvedValue("export const meta = { render: 'static' }")
|
|
309
|
+
vi.mocked(buildRouteEntry).mockReturnValueOnce({
|
|
310
|
+
routePath: '/legal',
|
|
311
|
+
isDynamic: false,
|
|
312
|
+
isCatchAll: false,
|
|
313
|
+
} as ReturnType<typeof buildRouteEntry>)
|
|
314
|
+
|
|
315
|
+
await buildSSG(makeConfig())
|
|
316
|
+
|
|
317
|
+
expect(buildRouteEntry).toHaveBeenCalledTimes(1)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('does not skip static page with no render meta', async () => {
|
|
321
|
+
vi.mocked(existsSync).mockReturnValue(true)
|
|
322
|
+
vi.mocked(fg).mockResolvedValue(['/project/app/pages/about.ts'])
|
|
323
|
+
vi.mocked(readFile).mockResolvedValue("component('page-about', () => html`<h1>About</h1>`)")
|
|
324
|
+
vi.mocked(buildRouteEntry).mockReturnValueOnce({
|
|
325
|
+
routePath: '/about',
|
|
326
|
+
isDynamic: false,
|
|
327
|
+
isCatchAll: false,
|
|
328
|
+
} as ReturnType<typeof buildRouteEntry>)
|
|
329
|
+
|
|
330
|
+
await buildSSG(makeConfig())
|
|
331
|
+
|
|
332
|
+
expect(buildRouteEntry).toHaveBeenCalledTimes(1)
|
|
333
|
+
})
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
describe('buildSSG — render strategy skip (dynamic pages)', () => {
|
|
337
|
+
it('skips dynamic page with render: server from ssrLoadModule', async () => {
|
|
338
|
+
vi.mocked(existsSync).mockReturnValue(true)
|
|
339
|
+
vi.mocked(fg).mockResolvedValue(['/project/app/pages/[slug].ts'])
|
|
340
|
+
vi.mocked(readFile).mockResolvedValue('')
|
|
341
|
+
vi.mocked(buildRouteEntry).mockReturnValueOnce({
|
|
342
|
+
routePath: '/:slug',
|
|
343
|
+
isDynamic: true,
|
|
344
|
+
isCatchAll: false,
|
|
345
|
+
} as ReturnType<typeof buildRouteEntry>)
|
|
346
|
+
|
|
347
|
+
const closeFn = vi.fn().mockResolvedValue(undefined)
|
|
348
|
+
vi.mocked(createServer).mockResolvedValue({
|
|
349
|
+
ssrLoadModule: vi.fn().mockResolvedValue({ meta: { render: 'server' } }),
|
|
350
|
+
close: closeFn,
|
|
351
|
+
} as unknown as Awaited<ReturnType<typeof createServer>>)
|
|
352
|
+
|
|
353
|
+
const config = makeConfig({ ssg: { concurrency: 1 } } as Partial<ResolvedCerConfig>)
|
|
354
|
+
await buildSSG(config)
|
|
355
|
+
|
|
356
|
+
// Only '/' (always added) — the dynamic route was skipped
|
|
357
|
+
const manifestCall = vi.mocked(writeFile).mock.calls.find(([p]) =>
|
|
358
|
+
String(p).includes('ssg-manifest.json'),
|
|
359
|
+
)
|
|
360
|
+
const manifest = JSON.parse(String(manifestCall![1]))
|
|
361
|
+
expect(manifest.paths.length + manifest.errors.length).toBe(1)
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('skips dynamic page with render: spa from ssrLoadModule', async () => {
|
|
365
|
+
vi.mocked(existsSync).mockReturnValue(true)
|
|
366
|
+
vi.mocked(fg).mockResolvedValue(['/project/app/pages/[id].ts'])
|
|
367
|
+
vi.mocked(readFile).mockResolvedValue('')
|
|
368
|
+
vi.mocked(buildRouteEntry).mockReturnValueOnce({
|
|
369
|
+
routePath: '/:id',
|
|
370
|
+
isDynamic: true,
|
|
371
|
+
isCatchAll: false,
|
|
372
|
+
} as ReturnType<typeof buildRouteEntry>)
|
|
373
|
+
|
|
374
|
+
const closeFn = vi.fn().mockResolvedValue(undefined)
|
|
375
|
+
vi.mocked(createServer).mockResolvedValue({
|
|
376
|
+
ssrLoadModule: vi.fn().mockResolvedValue({ meta: { render: 'spa' } }),
|
|
377
|
+
close: closeFn,
|
|
378
|
+
} as unknown as Awaited<ReturnType<typeof createServer>>)
|
|
379
|
+
|
|
380
|
+
const config = makeConfig({ ssg: { concurrency: 1 } } as Partial<ResolvedCerConfig>)
|
|
381
|
+
await buildSSG(config)
|
|
382
|
+
|
|
383
|
+
const manifestCall = vi.mocked(writeFile).mock.calls.find(([p]) =>
|
|
384
|
+
String(p).includes('ssg-manifest.json'),
|
|
385
|
+
)
|
|
386
|
+
const manifest = JSON.parse(String(manifestCall![1]))
|
|
387
|
+
expect(manifest.paths.length + manifest.errors.length).toBe(1)
|
|
388
|
+
})
|
|
389
|
+
})
|
|
390
|
+
|
|
266
391
|
// ─── writeRenderedPath ────────────────────────────────────────────────────────
|
|
267
392
|
|
|
268
393
|
describe('writeRenderedPath', () => {
|
|
@@ -464,6 +464,97 @@ describe('configureCerDevServer — SSR mode', () => {
|
|
|
464
464
|
})
|
|
465
465
|
})
|
|
466
466
|
|
|
467
|
+
// ─── Per-route render mode (SSR mode) ────────────────────────────────────────
|
|
468
|
+
|
|
469
|
+
describe("configureCerDevServer — per-route render mode in SSR", () => {
|
|
470
|
+
/** Build a server where virtual:cer-routes returns the given routes array. */
|
|
471
|
+
function makeServerWithRoutes(
|
|
472
|
+
pageRoutes: Array<{ path: string; meta?: Record<string, unknown> }>,
|
|
473
|
+
ssrHandler = vi.fn(async (_req: any, res: any) => res.end('<html>SSR</html>')),
|
|
474
|
+
) {
|
|
475
|
+
const { server, getMiddleware } = makeServer()
|
|
476
|
+
server.ssrLoadModule.mockImplementation(async (path: string) => {
|
|
477
|
+
if (path.includes('server-middleware')) return { serverMiddleware: [] }
|
|
478
|
+
if (path.includes('server-api') || path.includes('cer-server-api')) return { apiRoutes: [] }
|
|
479
|
+
if (path.includes('cer-routes')) return { default: pageRoutes }
|
|
480
|
+
return { handler: ssrHandler }
|
|
481
|
+
})
|
|
482
|
+
configureCerDevServer(server as any, makeConfig({ mode: 'ssr' }))
|
|
483
|
+
return { middleware: getMiddleware(), ssrHandler }
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
it("calls next() instead of SSR for a route with render: 'spa'", async () => {
|
|
487
|
+
const { middleware, ssrHandler } = makeServerWithRoutes([
|
|
488
|
+
{ path: '/spa-page', meta: { render: 'spa' } },
|
|
489
|
+
])
|
|
490
|
+
const next = vi.fn()
|
|
491
|
+
await middleware(
|
|
492
|
+
createReq({ url: '/spa-page', headers: { accept: 'text/html' } }),
|
|
493
|
+
createRes(),
|
|
494
|
+
next,
|
|
495
|
+
)
|
|
496
|
+
expect(next).toHaveBeenCalled()
|
|
497
|
+
expect(ssrHandler).not.toHaveBeenCalled()
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
it("proceeds to SSR for a route with render: 'server'", async () => {
|
|
501
|
+
const { middleware, ssrHandler } = makeServerWithRoutes([
|
|
502
|
+
{ path: '/server-page', meta: { render: 'server' } },
|
|
503
|
+
])
|
|
504
|
+
await middleware(
|
|
505
|
+
createReq({ url: '/server-page', headers: { accept: 'text/html' } }),
|
|
506
|
+
createRes(),
|
|
507
|
+
vi.fn(),
|
|
508
|
+
)
|
|
509
|
+
expect(ssrHandler).toHaveBeenCalled()
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
it("proceeds to SSR for a route with no render meta", async () => {
|
|
513
|
+
const { middleware, ssrHandler } = makeServerWithRoutes([
|
|
514
|
+
{ path: '/normal-page' },
|
|
515
|
+
])
|
|
516
|
+
await middleware(
|
|
517
|
+
createReq({ url: '/normal-page', headers: { accept: 'text/html' } }),
|
|
518
|
+
createRes(),
|
|
519
|
+
vi.fn(),
|
|
520
|
+
)
|
|
521
|
+
expect(ssrHandler).toHaveBeenCalled()
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
it("falls back to SSR when routes module throws during render mode check", async () => {
|
|
525
|
+
const ssrHandler = vi.fn(async (_req: any, res: any) => res.end('<html>SSR</html>'))
|
|
526
|
+
const { server, getMiddleware } = makeServer()
|
|
527
|
+
server.ssrLoadModule.mockImplementation(async (path: string) => {
|
|
528
|
+
if (path.includes('server-middleware')) return { serverMiddleware: [] }
|
|
529
|
+
if (path.includes('server-api') || path.includes('cer-server-api')) return { apiRoutes: [] }
|
|
530
|
+
if (path.includes('cer-routes')) throw new Error('module not ready')
|
|
531
|
+
return { handler: ssrHandler }
|
|
532
|
+
})
|
|
533
|
+
configureCerDevServer(server as any, makeConfig({ mode: 'ssr' }))
|
|
534
|
+
await getMiddleware()(
|
|
535
|
+
createReq({ url: '/some-page', headers: { accept: 'text/html' } }),
|
|
536
|
+
createRes(),
|
|
537
|
+
vi.fn(),
|
|
538
|
+
)
|
|
539
|
+
// Despite the routes module throwing, SSR should still run
|
|
540
|
+
expect(ssrHandler).toHaveBeenCalled()
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
it("does not load routes module in SPA mode (no render mode check needed)", async () => {
|
|
544
|
+
const { server, getMiddleware } = makeServer()
|
|
545
|
+
configureCerDevServer(server as any, makeConfig({ mode: 'spa' }))
|
|
546
|
+
await getMiddleware()(
|
|
547
|
+
createReq({ url: '/about', headers: { accept: 'text/html' } }),
|
|
548
|
+
createRes(),
|
|
549
|
+
vi.fn(),
|
|
550
|
+
)
|
|
551
|
+
const routesCalls = (server.ssrLoadModule as any).mock.calls.filter(
|
|
552
|
+
([p]: [string]) => p.includes('cer-routes'),
|
|
553
|
+
)
|
|
554
|
+
expect(routesCalls).toHaveLength(0)
|
|
555
|
+
})
|
|
556
|
+
})
|
|
557
|
+
|
|
467
558
|
// ─── parseBody edge cases ─────────────────────────────────────────────────────
|
|
468
559
|
|
|
469
560
|
describe('configureCerDevServer — malformed JSON body', () => {
|
|
@@ -142,4 +142,57 @@ describe('entry-server-template (ENTRY_SERVER_TEMPLATE content)', () => {
|
|
|
142
142
|
expect(src).toContain('stream.getReader()')
|
|
143
143
|
expect(src).toContain('reader.read()')
|
|
144
144
|
})
|
|
145
|
+
|
|
146
|
+
// ─── Error boundary ──────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
it('imports errorTag from virtual:cer-error', () => {
|
|
149
|
+
expect(src).toContain('errorTag')
|
|
150
|
+
expect(src).toContain('virtual:cer-error')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('catches loader errors in _prepareRequest', () => {
|
|
154
|
+
expect(src).toContain('catch (err)')
|
|
155
|
+
// The catch block is inside _prepareRequest, before the layout chain
|
|
156
|
+
const catchIdx = src.indexOf('catch (err)')
|
|
157
|
+
const prepareIdx = src.indexOf('_prepareRequest')
|
|
158
|
+
expect(catchIdx).toBeGreaterThan(prepareIdx)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('extracts .status from the thrown error for HTTP status code', () => {
|
|
162
|
+
expect(src).toContain("'status' in err")
|
|
163
|
+
expect(src).toContain('err.status')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('defaults to status 500 when thrown error has no .status', () => {
|
|
167
|
+
expect(src).toContain(': 500')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('renders errorTag component when loader throws and errorTag is set', () => {
|
|
171
|
+
expect(src).toContain('tag: errorTag')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('logs to console.error when loader throws and no errorTag is defined', () => {
|
|
175
|
+
expect(src).toContain('console.error')
|
|
176
|
+
expect(src).toContain('!errorTag')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('propagates status to res.statusCode', () => {
|
|
180
|
+
expect(src).toContain('res.statusCode = status')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('returns status: null on the happy path', () => {
|
|
184
|
+
expect(src).toContain('status: null')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
// ─── ISR production export ───────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
it('imports createIsrHandler from the isr subpath', () => {
|
|
190
|
+
expect(src).toContain('createIsrHandler')
|
|
191
|
+
expect(src).toContain('vite-plugin-cer-app/isr')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('exports isrHandler wrapping handler with ISR caching', () => {
|
|
195
|
+
expect(src).toContain('export const isrHandler')
|
|
196
|
+
expect(src).toContain('createIsrHandler(routes, handler)')
|
|
197
|
+
})
|
|
145
198
|
})
|
|
@@ -335,6 +335,71 @@ describe('generateRoutesCode — meta.transition', () => {
|
|
|
335
335
|
})
|
|
336
336
|
})
|
|
337
337
|
|
|
338
|
+
// ─── meta.render ──────────────────────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
describe('generateRoutesCode — meta.render', () => {
|
|
341
|
+
beforeEach(() => {
|
|
342
|
+
vi.mocked(existsSync).mockReturnValue(true)
|
|
343
|
+
vi.mocked(scanDirectory).mockResolvedValue([])
|
|
344
|
+
vi.mocked(readFile).mockResolvedValue('' as never)
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('emits meta.render "server" when declared', async () => {
|
|
348
|
+
vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/dashboard.ts`])
|
|
349
|
+
vi.mocked(readFile).mockResolvedValue(
|
|
350
|
+
`export const meta = { render: 'server' }` as never,
|
|
351
|
+
)
|
|
352
|
+
const code = await generateRoutesCode(PAGES)
|
|
353
|
+
expect(code).toContain('render: "server"')
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
it('emits meta.render "spa" when declared', async () => {
|
|
357
|
+
vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/profile.ts`])
|
|
358
|
+
vi.mocked(readFile).mockResolvedValue(
|
|
359
|
+
`export const meta = { render: 'spa' }` as never,
|
|
360
|
+
)
|
|
361
|
+
const code = await generateRoutesCode(PAGES)
|
|
362
|
+
expect(code).toContain('render: "spa"')
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
it('emits meta.render "static" when declared', async () => {
|
|
366
|
+
vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/about.ts`])
|
|
367
|
+
vi.mocked(readFile).mockResolvedValue(
|
|
368
|
+
`export const meta = { render: 'static' }` as never,
|
|
369
|
+
)
|
|
370
|
+
const code = await generateRoutesCode(PAGES)
|
|
371
|
+
expect(code).toContain('render: "static"')
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('omits render meta when not declared', async () => {
|
|
375
|
+
vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/about.ts`])
|
|
376
|
+
vi.mocked(readFile).mockResolvedValue(
|
|
377
|
+
`component('page-about', () => html\`<h1>About</h1>\`)` as never,
|
|
378
|
+
)
|
|
379
|
+
const code = await generateRoutesCode(PAGES)
|
|
380
|
+
expect(code).not.toContain('render:')
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
it('ignores unknown render values', async () => {
|
|
384
|
+
vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/about.ts`])
|
|
385
|
+
vi.mocked(readFile).mockResolvedValue(
|
|
386
|
+
`export const meta = { render: 'unknown' }` as never,
|
|
387
|
+
)
|
|
388
|
+
const code = await generateRoutesCode(PAGES)
|
|
389
|
+
expect(code).not.toContain('render:')
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
it('can combine render with layout', async () => {
|
|
393
|
+
vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/dashboard.ts`])
|
|
394
|
+
vi.mocked(readFile).mockResolvedValue(
|
|
395
|
+
`export const meta = { render: 'server', layout: 'admin' }` as never,
|
|
396
|
+
)
|
|
397
|
+
const code = await generateRoutesCode(PAGES)
|
|
398
|
+
expect(code).toContain('render: "server"')
|
|
399
|
+
expect(code).toContain('layout: "admin"')
|
|
400
|
+
})
|
|
401
|
+
})
|
|
402
|
+
|
|
338
403
|
// ─── nested layouts (layoutChain) ────────────────────────────────────────────
|
|
339
404
|
|
|
340
405
|
describe('generateRoutesCode — layoutChain (nested layouts)', () => {
|