@jasonshimmy/vite-plugin-cer-app 0.7.0 → 0.9.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.
Files changed (98) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/ROADMAP.md +278 -0
  3. package/commits.txt +1 -1
  4. package/dist/cli/commands/preview-isr.d.ts +6 -0
  5. package/dist/cli/commands/preview-isr.d.ts.map +1 -1
  6. package/dist/cli/commands/preview-isr.js +12 -0
  7. package/dist/cli/commands/preview-isr.js.map +1 -1
  8. package/dist/cli/commands/preview.d.ts.map +1 -1
  9. package/dist/cli/commands/preview.js +66 -6
  10. package/dist/cli/commands/preview.js.map +1 -1
  11. package/dist/index.d.ts +3 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/plugin/dev-server.d.ts +1 -0
  14. package/dist/plugin/dev-server.d.ts.map +1 -1
  15. package/dist/plugin/dts-generator.d.ts.map +1 -1
  16. package/dist/plugin/dts-generator.js +4 -2
  17. package/dist/plugin/dts-generator.js.map +1 -1
  18. package/dist/plugin/index.d.ts.map +1 -1
  19. package/dist/plugin/index.js +30 -12
  20. package/dist/plugin/index.js.map +1 -1
  21. package/dist/plugin/transforms/auto-import.d.ts.map +1 -1
  22. package/dist/plugin/transforms/auto-import.js +5 -4
  23. package/dist/plugin/transforms/auto-import.js.map +1 -1
  24. package/dist/plugin/virtual/routes.d.ts.map +1 -1
  25. package/dist/plugin/virtual/routes.js +7 -1
  26. package/dist/plugin/virtual/routes.js.map +1 -1
  27. package/dist/runtime/composables/define-middleware.d.ts +15 -0
  28. package/dist/runtime/composables/define-middleware.d.ts.map +1 -0
  29. package/dist/runtime/composables/define-middleware.js +16 -0
  30. package/dist/runtime/composables/define-middleware.js.map +1 -0
  31. package/dist/runtime/composables/index.d.ts +7 -1
  32. package/dist/runtime/composables/index.d.ts.map +1 -1
  33. package/dist/runtime/composables/index.js +4 -1
  34. package/dist/runtime/composables/index.js.map +1 -1
  35. package/dist/runtime/composables/use-cookie.d.ts +38 -0
  36. package/dist/runtime/composables/use-cookie.d.ts.map +1 -0
  37. package/dist/runtime/composables/use-cookie.js +104 -0
  38. package/dist/runtime/composables/use-cookie.js.map +1 -0
  39. package/dist/runtime/composables/use-runtime-config.d.ts +32 -14
  40. package/dist/runtime/composables/use-runtime-config.d.ts.map +1 -1
  41. package/dist/runtime/composables/use-runtime-config.js +42 -8
  42. package/dist/runtime/composables/use-runtime-config.js.map +1 -1
  43. package/dist/runtime/composables/use-seo-meta.d.ts +42 -0
  44. package/dist/runtime/composables/use-seo-meta.d.ts.map +1 -0
  45. package/dist/runtime/composables/use-seo-meta.js +58 -0
  46. package/dist/runtime/composables/use-seo-meta.js.map +1 -0
  47. package/dist/runtime/entry-server-template.d.ts +1 -1
  48. package/dist/runtime/entry-server-template.d.ts.map +1 -1
  49. package/dist/runtime/entry-server-template.js +15 -3
  50. package/dist/runtime/entry-server-template.js.map +1 -1
  51. package/dist/types/config.d.ts +14 -0
  52. package/dist/types/config.d.ts.map +1 -1
  53. package/dist/types/config.js.map +1 -1
  54. package/dist/types/index.d.ts +2 -2
  55. package/dist/types/index.d.ts.map +1 -1
  56. package/dist/types/middleware.d.ts +8 -2
  57. package/dist/types/middleware.d.ts.map +1 -1
  58. package/docs/cli.md +5 -0
  59. package/docs/composables.md +165 -7
  60. package/docs/configuration.md +53 -3
  61. package/docs/middleware.md +53 -25
  62. package/e2e/cypress/e2e/cookie.cy.ts +68 -0
  63. package/e2e/cypress/e2e/middleware.cy.ts +45 -0
  64. package/e2e/cypress/e2e/preview-hardening.cy.ts +79 -0
  65. package/e2e/cypress/e2e/seo-meta.cy.ts +108 -0
  66. package/e2e/kitchen-sink/app/middleware/auth.ts +3 -7
  67. package/e2e/kitchen-sink/app/pages/cookie-test.ts +22 -0
  68. package/e2e/kitchen-sink/app/pages/seo-test.ts +23 -0
  69. package/package.json +1 -1
  70. package/src/__tests__/cli/preview-hardening.test.ts +175 -0
  71. package/src/__tests__/cli/preview-isr.test.ts +30 -0
  72. package/src/__tests__/plugin/cer-app-plugin.test.ts +50 -0
  73. package/src/__tests__/plugin/entry-server-template.test.ts +21 -0
  74. package/src/__tests__/plugin/resolve-config.test.ts +18 -0
  75. package/src/__tests__/plugin/transforms/auto-import.test.ts +39 -0
  76. package/src/__tests__/plugin/virtual/middleware.test.ts +15 -0
  77. package/src/__tests__/plugin/virtual/routes.test.ts +32 -0
  78. package/src/__tests__/runtime/define-middleware.test.ts +43 -0
  79. package/src/__tests__/runtime/use-cookie.test.ts +218 -0
  80. package/src/__tests__/runtime/use-runtime-config.test.ts +86 -2
  81. package/src/__tests__/runtime/use-seo-meta.test.ts +109 -0
  82. package/src/cli/commands/preview-isr.ts +14 -0
  83. package/src/cli/commands/preview.ts +78 -6
  84. package/src/index.ts +3 -1
  85. package/src/plugin/dev-server.ts +1 -1
  86. package/src/plugin/dts-generator.ts +4 -2
  87. package/src/plugin/index.ts +32 -11
  88. package/src/plugin/transforms/auto-import.ts +5 -4
  89. package/src/plugin/virtual/routes.ts +7 -1
  90. package/src/runtime/composables/define-middleware.ts +17 -0
  91. package/src/runtime/composables/index.ts +7 -1
  92. package/src/runtime/composables/use-cookie.ts +128 -0
  93. package/src/runtime/composables/use-runtime-config.ts +67 -11
  94. package/src/runtime/composables/use-seo-meta.ts +75 -0
  95. package/src/runtime/entry-server-template.ts +15 -3
  96. package/src/types/config.ts +15 -0
  97. package/src/types/index.ts +2 -2
  98. package/src/types/middleware.ts +8 -6
@@ -0,0 +1,175 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { readFileSync } from 'node:fs'
3
+ import { resolve } from 'pathe'
4
+
5
+ // Read the preview command source to verify hardening requirements are present.
6
+ const src = readFileSync(
7
+ resolve(import.meta.dirname, '../../cli/commands/preview.ts'),
8
+ 'utf-8',
9
+ )
10
+
11
+ // ─── Security headers ─────────────────────────────────────────────────────────
12
+
13
+ describe('preview server — security headers', () => {
14
+ it('defines setSecurityHeaders helper', () => {
15
+ expect(src).toContain('function setSecurityHeaders(')
16
+ })
17
+
18
+ it('sets X-Content-Type-Options: nosniff', () => {
19
+ expect(src).toContain("'X-Content-Type-Options'")
20
+ expect(src).toContain('nosniff')
21
+ })
22
+
23
+ it('sets X-Frame-Options: DENY', () => {
24
+ expect(src).toContain("'X-Frame-Options'")
25
+ expect(src).toContain('DENY')
26
+ })
27
+
28
+ it('sets Referrer-Policy: strict-origin-when-cross-origin', () => {
29
+ expect(src).toContain("'Referrer-Policy'")
30
+ expect(src).toContain('strict-origin-when-cross-origin')
31
+ })
32
+
33
+ it('calls setSecurityHeaders in serveStaticFile', () => {
34
+ const fnStart = src.indexOf('function serveStaticFile(')
35
+ const nextFn = src.indexOf('\nfunction ', fnStart + 1)
36
+ const body = src.slice(fnStart, nextFn > -1 ? nextFn : undefined)
37
+ expect(body).toContain('setSecurityHeaders(res)')
38
+ })
39
+
40
+ it('calls setSecurityHeaders at the top of the SSR request handler', () => {
41
+ const handlerStart = src.indexOf('createHttpServer(async (req: IncomingMessage, res: ServerResponse)')
42
+ const handlerEnd = src.indexOf('server.listen(port', handlerStart)
43
+ const handler = src.slice(handlerStart, handlerEnd)
44
+ expect(handler).toContain('setSecurityHeaders(res)')
45
+ // It should be the first thing done before any routing logic
46
+ const secHeadersIdx = handler.indexOf('setSecurityHeaders(res)')
47
+ const urlParseIdx = handler.indexOf('req.url ?? ')
48
+ expect(secHeadersIdx).toBeLessThan(urlParseIdx)
49
+ })
50
+
51
+ it('calls setSecurityHeaders at the top of the static request handler', () => {
52
+ const handlerStart = src.indexOf('createHttpServer((req: IncomingMessage, res: ServerResponse)')
53
+ const handlerEnd = src.indexOf('server.listen(port', handlerStart)
54
+ const handler = src.slice(handlerStart, handlerEnd)
55
+ expect(handler).toContain('setSecurityHeaders(res)')
56
+ })
57
+ })
58
+
59
+ // ─── Cache-Control ────────────────────────────────────────────────────────────
60
+
61
+ describe('preview server — Cache-Control', () => {
62
+ it('defines getCacheControl helper', () => {
63
+ expect(src).toContain('function getCacheControl(')
64
+ })
65
+
66
+ it('returns immutable cache for /assets/ paths', () => {
67
+ expect(src).toContain("'/assets/'")
68
+ expect(src).toContain('public, max-age=31536000, immutable')
69
+ })
70
+
71
+ it('returns no-cache for non-asset paths', () => {
72
+ // The getCacheControl function must return 'no-cache' as fallback
73
+ const fnStart = src.indexOf('function getCacheControl(')
74
+ const fnEnd = src.indexOf('\nfunction ', fnStart + 1)
75
+ const body = src.slice(fnStart, fnEnd > -1 ? fnEnd : undefined)
76
+ expect(body).toContain("'no-cache'")
77
+ })
78
+
79
+ it('uses getCacheControl in serveStaticFile instead of hardcoded no-cache', () => {
80
+ const fnStart = src.indexOf('function serveStaticFile(')
81
+ const nextFn = src.indexOf('\nfunction ', fnStart + 1)
82
+ const body = src.slice(fnStart, nextFn > -1 ? nextFn : undefined)
83
+ expect(body).toContain('getCacheControl(filePath)')
84
+ // Must NOT have a hardcoded no-cache
85
+ expect(body).not.toContain("'no-cache'")
86
+ })
87
+
88
+ it('uses getCacheControl for client assets in the static server', () => {
89
+ // The static server also serves assets from dist/client — it should use getCacheControl
90
+ const staticHandlerStart = src.indexOf('createHttpServer((req: IncomingMessage, res: ServerResponse)')
91
+ expect(src.slice(staticHandlerStart)).toContain('getCacheControl(')
92
+ })
93
+ })
94
+
95
+ // ─── Graceful shutdown ────────────────────────────────────────────────────────
96
+
97
+ describe('preview server — graceful shutdown', () => {
98
+ it('defines registerGracefulShutdown helper', () => {
99
+ expect(src).toContain('function registerGracefulShutdown(')
100
+ })
101
+
102
+ it('calls server.close() for graceful drain', () => {
103
+ const fnStart = src.indexOf('function registerGracefulShutdown(')
104
+ const fnEnd = src.indexOf('\nexport ', fnStart)
105
+ const body = src.slice(fnStart, fnEnd > -1 ? fnEnd : undefined)
106
+ expect(body).toContain('server.close(')
107
+ })
108
+
109
+ it('sets a 10-second force-exit timeout', () => {
110
+ const fnStart = src.indexOf('function registerGracefulShutdown(')
111
+ const fnEnd = src.indexOf('\nexport ', fnStart)
112
+ const body = src.slice(fnStart, fnEnd > -1 ? fnEnd : undefined)
113
+ expect(body).toContain('10_000')
114
+ expect(body).toContain('process.exit(1)')
115
+ })
116
+
117
+ it('calls t.unref() so the timeout does not keep the event loop alive', () => {
118
+ const fnStart = src.indexOf('function registerGracefulShutdown(')
119
+ const fnEnd = src.indexOf('\nexport ', fnStart)
120
+ const body = src.slice(fnStart, fnEnd > -1 ? fnEnd : undefined)
121
+ expect(body).toContain('.unref()')
122
+ })
123
+
124
+ it('listens for both SIGTERM and SIGINT', () => {
125
+ expect(src).toContain("'SIGTERM'")
126
+ expect(src).toContain("'SIGINT'")
127
+ })
128
+
129
+ it('logs the signal name on shutdown', () => {
130
+ const fnStart = src.indexOf('function registerGracefulShutdown(')
131
+ const fnEnd = src.indexOf('\nexport ', fnStart)
132
+ const body = src.slice(fnStart, fnEnd > -1 ? fnEnd : undefined)
133
+ expect(body).toContain('signal')
134
+ expect(body).toContain('console.log(')
135
+ })
136
+
137
+ it('calls registerGracefulShutdown for the SSR server', () => {
138
+ // registerGracefulShutdown must be called after each server's listen()
139
+ const firstListen = src.indexOf('server.listen(port')
140
+ const firstShutdown = src.indexOf('registerGracefulShutdown(server)')
141
+ expect(firstShutdown).toBeGreaterThan(firstListen)
142
+ })
143
+
144
+ it('calls registerGracefulShutdown for the static server', () => {
145
+ // Both SSR and static server paths should register graceful shutdown
146
+ const allShutdowns = src.split('registerGracefulShutdown(server)').length - 1
147
+ expect(allShutdowns).toBeGreaterThanOrEqual(2)
148
+ })
149
+ })
150
+
151
+ // ─── Request timeouts ─────────────────────────────────────────────────────────
152
+
153
+ describe('preview server — request timeouts', () => {
154
+ it('sets server.headersTimeout to protect against slow-send attacks', () => {
155
+ expect(src).toContain('server.headersTimeout')
156
+ expect(src).toContain('10_000')
157
+ })
158
+
159
+ it('sets server.requestTimeout to limit total request duration', () => {
160
+ expect(src).toContain('server.requestTimeout')
161
+ expect(src).toContain('30_000')
162
+ })
163
+
164
+ it('applies timeouts to the SSR server', () => {
165
+ const ssrListenIdx = src.indexOf("console.log(`[cer-app] SSR preview running at")
166
+ const ssrTimeoutIdx = src.lastIndexOf('server.requestTimeout', ssrListenIdx)
167
+ expect(ssrTimeoutIdx).toBeGreaterThan(-1)
168
+ })
169
+
170
+ it('applies timeouts to the static server', () => {
171
+ const staticListenIdx = src.indexOf("console.log(`[cer-app] Static preview running at")
172
+ const staticTimeoutIdx = src.lastIndexOf('server.requestTimeout', staticListenIdx)
173
+ expect(staticTimeoutIdx).toBeGreaterThan(-1)
174
+ })
175
+ })
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect, vi } from 'vitest'
2
2
  import type { IncomingMessage, ServerResponse } from 'node:http'
3
3
  import {
4
+ isPathBounded,
4
5
  matchRoutePattern,
5
6
  findRevalidate,
6
7
  findRenderMode,
@@ -9,6 +10,35 @@ import {
9
10
  type IsrCacheEntry,
10
11
  } from '../../cli/commands/preview-isr.js'
11
12
 
13
+ // ─── isPathBounded ────────────────────────────────────────────────────────────
14
+
15
+ describe('isPathBounded', () => {
16
+ it('allows normal file paths inside the root', () => {
17
+ expect(isPathBounded('/dist', '/index.html')).toBe(true)
18
+ })
19
+
20
+ it('allows nested paths inside the root', () => {
21
+ expect(isPathBounded('/dist', '/assets/app.js')).toBe(true)
22
+ })
23
+
24
+ it('allows the root path itself', () => {
25
+ expect(isPathBounded('/dist', '/')).toBe(true)
26
+ })
27
+
28
+ it('blocks simple path traversal (../ escape)', () => {
29
+ expect(isPathBounded('/dist', '/../../../../etc/passwd')).toBe(false)
30
+ })
31
+
32
+ it('blocks a path that escapes by one level', () => {
33
+ expect(isPathBounded('/dist', '/../secret')).toBe(false)
34
+ })
35
+
36
+ it('does not confuse a sibling directory for the root', () => {
37
+ // /dist-other starts with /dist but is NOT inside /dist
38
+ expect(isPathBounded('/dist', '/../dist-other/file')).toBe(false)
39
+ })
40
+ })
41
+
12
42
  // ─── matchRoutePattern ────────────────────────────────────────────────────────
13
43
 
14
44
  describe('matchRoutePattern', () => {
@@ -245,6 +245,36 @@ describe('cerApp plugin — load hook', () => {
245
245
  expect(result).toContain('appConfig')
246
246
  expect(result).toContain('ssg')
247
247
  })
248
+
249
+ it('excludes _runtimePrivateDefaults from the client bundle', async () => {
250
+ const plugin = getCerPlugin({ runtimeConfig: { private: { dbUrl: '', secretKey: '' } } })
251
+ plugin.config({ root: '/project' }, { command: 'serve', mode: 'development' })
252
+ plugin.configResolved(FAKE_RESOLVED)
253
+ // Client load (ssr: false / omitted)
254
+ const clientResult = await plugin.load('\0virtual:cer-app-config') as string
255
+ expect(clientResult).not.toContain('_runtimePrivateDefaults')
256
+ expect(clientResult).not.toContain('dbUrl')
257
+ })
258
+
259
+ it('includes _runtimePrivateDefaults in the SSR bundle', async () => {
260
+ const plugin = getCerPlugin({ runtimeConfig: { private: { dbUrl: '', secretKey: '' } } })
261
+ plugin.config({ root: '/project' }, { command: 'serve', mode: 'development' })
262
+ plugin.configResolved(FAKE_RESOLVED)
263
+ // SSR load (ssr: true)
264
+ const ssrResult = await plugin.load('\0virtual:cer-app-config', { ssr: true }) as string
265
+ expect(ssrResult).toContain('_runtimePrivateDefaults')
266
+ expect(ssrResult).toContain('dbUrl')
267
+ })
268
+
269
+ it('caches SSR and client virtual:cer-app-config separately', async () => {
270
+ const plugin = getCerPlugin({ runtimeConfig: { private: { token: '' } } })
271
+ plugin.config({ root: '/project' }, { command: 'serve', mode: 'development' })
272
+ plugin.configResolved(FAKE_RESOLVED)
273
+ const client = await plugin.load('\0virtual:cer-app-config') as string
274
+ const ssr = await plugin.load('\0virtual:cer-app-config', { ssr: true }) as string
275
+ expect(client).not.toContain('_runtimePrivateDefaults')
276
+ expect(ssr).toContain('_runtimePrivateDefaults')
277
+ })
248
278
  })
249
279
 
250
280
  describe('cerApp plugin — transform hook', () => {
@@ -363,6 +393,26 @@ describe('cerApp plugin — buildStart hook', () => {
363
393
  await plugin.buildStart()
364
394
  expect(writeTsconfigPaths).toHaveBeenCalledTimes(1)
365
395
  })
396
+
397
+ it('warms virtual:cer-app-config cache under :client key so load() hits it', async () => {
398
+ // After buildStart(), a subsequent client load (ssr: false) should be served
399
+ // from cache (generateAppConfigModule is not a public mock, so we verify by
400
+ // ensuring the load hook returns a non-null result without triggering the
401
+ // real generator — which is mocked at module level to a fixed string '// mock'
402
+ // for other modules; appConfig is not mocked, so it generates real code).
403
+ const plugin = getCerPlugin({ runtimeConfig: { private: { token: '' } } })
404
+ plugin.config({ root: '/project' }, { command: 'build', mode: 'production' })
405
+ plugin.configResolved(FAKE_RESOLVED)
406
+ await plugin.buildStart()
407
+
408
+ // Client load should return a result that does NOT include _runtimePrivateDefaults
409
+ const result = await plugin.load('\0virtual:cer-app-config') as string
410
+ expect(result).not.toContain('_runtimePrivateDefaults')
411
+
412
+ // SSR load (different cache key) should include _runtimePrivateDefaults
413
+ const ssrResult = await plugin.load('\0virtual:cer-app-config', { ssr: true }) as string
414
+ expect(ssrResult).toContain('_runtimePrivateDefaults')
415
+ })
366
416
  })
367
417
 
368
418
  describe('cerApp plugin — configureServer hook', () => {
@@ -195,4 +195,25 @@ describe('entry-server-template (ENTRY_SERVER_TEMPLATE content)', () => {
195
195
  expect(src).toContain('export const isrHandler')
196
196
  expect(src).toContain('createIsrHandler(routes, handler)')
197
197
  })
198
+
199
+ // ─── useCookie req/res store ──────────────────────────────────────────────────
200
+
201
+ it('creates _cerReqStore AsyncLocalStorage for request-scoped cookie access', () => {
202
+ expect(src).toContain('_cerReqStore')
203
+ expect(src).toContain('__CER_REQ_STORE__')
204
+ })
205
+
206
+ it('exposes _cerReqStore on globalThis as __CER_REQ_STORE__', () => {
207
+ expect(src).toContain('__CER_REQ_STORE__ = _cerReqStore')
208
+ })
209
+
210
+ it('wraps handler body in _cerReqStore.run({ req, res }, ...) so useCookie can access req/res', () => {
211
+ expect(src).toContain('_cerReqStore.run({ req, res }')
212
+ // req/res store wraps the data store — it must appear before _cerDataStore.run
213
+ const reqStoreIdx = src.indexOf('_cerReqStore.run(')
214
+ const dataStoreIdx = src.indexOf('_cerDataStore.run(')
215
+ expect(reqStoreIdx).toBeGreaterThan(-1)
216
+ expect(dataStoreIdx).toBeGreaterThan(-1)
217
+ expect(reqStoreIdx).toBeLessThan(dataStoreIdx)
218
+ })
198
219
  })
@@ -155,4 +155,22 @@ describe('resolveConfig', () => {
155
155
  const cfg = resolveConfig({ runtimeConfig: { public: { foo: 42 } } }, ROOT)
156
156
  expect(cfg.runtimeConfig.public.foo).toBe(42)
157
157
  })
158
+
159
+ it('defaults runtimeConfig.private to an empty object', () => {
160
+ const cfg = resolveConfig({}, ROOT)
161
+ expect(cfg.runtimeConfig.private).toEqual({})
162
+ })
163
+
164
+ it('passes runtimeConfig.private values through', () => {
165
+ const cfg = resolveConfig({ runtimeConfig: { private: { dbUrl: '', secretKey: '' } } }, ROOT)
166
+ expect(cfg.runtimeConfig.private).toEqual({ dbUrl: '', secretKey: '' })
167
+ })
168
+
169
+ it('preserves both public and private when both are supplied', () => {
170
+ const cfg = resolveConfig({
171
+ runtimeConfig: { public: { apiBase: '/api' }, private: { token: '' } },
172
+ }, ROOT)
173
+ expect(cfg.runtimeConfig.public).toEqual({ apiBase: '/api' })
174
+ expect(cfg.runtimeConfig.private).toEqual({ token: '' })
175
+ })
158
176
  })
@@ -55,6 +55,22 @@ describe('autoImportTransform — target directory gating', () => {
55
55
  )
56
56
  expect(result).not.toBeNull()
57
57
  })
58
+
59
+ it('transforms files in middleware/ (so defineMiddleware is auto-imported)', () => {
60
+ const result = autoImportTransform(
61
+ "export default defineMiddleware(() => true)",
62
+ '/project/app/middleware/auth.ts',
63
+ opts,
64
+ )
65
+ expect(result).not.toBeNull()
66
+ expect(result).toContain('defineMiddleware')
67
+ })
68
+
69
+ it('still returns null for composables/ (not in scope)', () => {
70
+ expect(
71
+ autoImportTransform("export default defineMiddleware(() => true)", '/project/app/composables/useTheme.ts', opts),
72
+ ).toBeNull()
73
+ })
58
74
  })
59
75
 
60
76
  // ─── No injection needed ─────────────────────────────────────────────────────
@@ -249,6 +265,29 @@ describe('autoImportTransform — framework composable injection', () => {
249
265
  const count = result.split(`from ${FRAMEWORK_PKG}`).length - 1
250
266
  expect(count).toBe(1)
251
267
  })
268
+
269
+ it('injects useSeoMeta import when useSeoMeta is used', () => {
270
+ const code = "component('page-home', () => { useSeoMeta({ title: 'Home', description: 'Welcome' }); return html`<h1>Home</h1>` })"
271
+ const result = autoImportTransform(code, '/project/app/pages/index.ts', opts)!
272
+ expect(result).toContain(`from ${FRAMEWORK_PKG}`)
273
+ expect(result).toContain('useSeoMeta')
274
+ })
275
+
276
+ it('injects useCookie import when useCookie is used', () => {
277
+ const code = "component('page-profile', () => { const session = useCookie('session'); return html`<div></div>` })"
278
+ const result = autoImportTransform(code, '/project/app/pages/profile.ts', opts)!
279
+ expect(result).toContain(`from ${FRAMEWORK_PKG}`)
280
+ expect(result).toContain('useCookie')
281
+ })
282
+
283
+ it('injects useSeoMeta and useCookie alongside other framework composables in a single import', () => {
284
+ const code = "component('page-shop', () => { useSeoMeta({ title: 'Shop' }); const cart = useCookie('cart'); return html`<div></div>` })"
285
+ const result = autoImportTransform(code, '/project/app/pages/shop.ts', opts)!
286
+ expect(result).toContain('useSeoMeta')
287
+ expect(result).toContain('useCookie')
288
+ const count = result.split(`from ${FRAMEWORK_PKG}`).length - 1
289
+ expect(count).toBe(1)
290
+ })
252
291
  })
253
292
 
254
293
  // ─── Composable import injection ─────────────────────────────────────────────
@@ -65,4 +65,19 @@ describe('generateMiddlewareCode', () => {
65
65
  expect(code).toContain('export const middleware')
66
66
  expect(code).toContain('export default middleware')
67
67
  })
68
+
69
+ it('prefixes identifier with underscore when filename starts with a digit', async () => {
70
+ vi.mocked(scanDirectory).mockResolvedValue([`${MIDDLEWARE}/2fa.ts`])
71
+ const code = await generateMiddlewareCode(MIDDLEWARE)
72
+ // Leading digit is not valid in a JS identifier — must be prefixed
73
+ expect(code).toContain('_m__2fa')
74
+ expect(code).toContain('"2fa"')
75
+ })
76
+
77
+ it('strips .js extension as well as .ts', async () => {
78
+ vi.mocked(scanDirectory).mockResolvedValue([`${MIDDLEWARE}/auth.js`])
79
+ const code = await generateMiddlewareCode(MIDDLEWARE)
80
+ expect(code).toContain('_m_auth')
81
+ expect(code).toContain('"auth": _m_auth')
82
+ })
68
83
  })
@@ -129,6 +129,38 @@ describe('generateRoutesCode', () => {
129
129
  const code = await generateRoutesCode(PAGES)
130
130
  expect(code).not.toContain('beforeEnter')
131
131
  })
132
+
133
+ it('uses return-value API (await handler) not callback-style (new Promise)', async () => {
134
+ vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/dashboard.ts`])
135
+ vi.mocked(readFile).mockResolvedValue(
136
+ `component('page-dashboard', () => html\`<div/>\`)\nexport const meta = { middleware: ['auth'] }` as never,
137
+ )
138
+ const code = await generateRoutesCode(PAGES)
139
+ expect(code).toContain('await handler(to, from)')
140
+ expect(code).not.toContain('new Promise')
141
+ })
142
+
143
+ it('wraps handler call in try-catch (blocks navigation on error)', async () => {
144
+ vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/dashboard.ts`])
145
+ vi.mocked(readFile).mockResolvedValue(
146
+ `component('page-dashboard', () => html\`<div/>\`)\nexport const meta = { middleware: ['auth'] }` as never,
147
+ )
148
+ const code = await generateRoutesCode(PAGES)
149
+ expect(code).toContain('try {')
150
+ expect(code).toContain('} catch (err) {')
151
+ expect(code).toContain('return false')
152
+ })
153
+
154
+ it('logs the middleware name in the error message', async () => {
155
+ vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/dashboard.ts`])
156
+ vi.mocked(readFile).mockResolvedValue(
157
+ `component('page-dashboard', () => html\`<div/>\`)\nexport const meta = { middleware: ['auth'] }` as never,
158
+ )
159
+ const code = await generateRoutesCode(PAGES)
160
+ expect(code).toContain('console.error')
161
+ expect(code).toContain('Middleware')
162
+ expect(code).toContain('err)')
163
+ })
132
164
  })
133
165
 
134
166
  // ─── meta.layout ─────────────────────────────────────────────────────────────
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { defineMiddleware } from '../../runtime/composables/define-middleware.js'
3
+ import type { MiddlewareFn } from '../../types/middleware.js'
4
+
5
+ describe('defineMiddleware', () => {
6
+ it('returns the function passed to it unchanged', () => {
7
+ const fn: MiddlewareFn = async () => true
8
+ expect(defineMiddleware(fn)).toBe(fn)
9
+ })
10
+
11
+ it('the returned function returns true to allow navigation', async () => {
12
+ const mw = defineMiddleware(async () => true)
13
+ const result = await mw({} as never, null)
14
+ expect(result).toBe(true)
15
+ })
16
+
17
+ it('the returned function returns false to block navigation', async () => {
18
+ const mw = defineMiddleware(async () => false)
19
+ const result = await mw({} as never, null)
20
+ expect(result).toBe(false)
21
+ })
22
+
23
+ it('the returned function returns a string to redirect', async () => {
24
+ const mw = defineMiddleware(async () => '/login')
25
+ const result = await mw({} as never, null)
26
+ expect(result).toBe('/login')
27
+ })
28
+
29
+ it('the returned function receives to and from route states', async () => {
30
+ let capturedTo: unknown
31
+ let capturedFrom: unknown
32
+ const mw = defineMiddleware((to, from) => {
33
+ capturedTo = to
34
+ capturedFrom = from
35
+ return true
36
+ })
37
+ const to = { path: '/dashboard', params: {}, query: {} }
38
+ const from = { path: '/login', params: {}, query: {} }
39
+ await mw(to as never, from as never)
40
+ expect(capturedTo).toBe(to)
41
+ expect(capturedFrom).toBe(from)
42
+ })
43
+ })