@barefootjs/vite 0.30.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 (61) hide show
  1. package/dist/child-marker.d.ts +62 -0
  2. package/dist/child-marker.d.ts.map +1 -0
  3. package/dist/compile-cache.d.ts +19 -0
  4. package/dist/compile-cache.d.ts.map +1 -0
  5. package/dist/component-manifest.d.ts +69 -0
  6. package/dist/component-manifest.d.ts.map +1 -0
  7. package/dist/corpus-program.d.ts +41 -0
  8. package/dist/corpus-program.d.ts.map +1 -0
  9. package/dist/debounced-serial-runner.d.ts +27 -0
  10. package/dist/debounced-serial-runner.d.ts.map +1 -0
  11. package/dist/dev-server.d.ts +99 -0
  12. package/dist/dev-server.d.ts.map +1 -0
  13. package/dist/discover.d.ts +117 -0
  14. package/dist/discover.d.ts.map +1 -0
  15. package/dist/emit.d.ts +9 -0
  16. package/dist/emit.d.ts.map +1 -0
  17. package/dist/index.d.ts +8 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +24626 -0
  20. package/dist/manifest.d.ts +39 -0
  21. package/dist/manifest.d.ts.map +1 -0
  22. package/dist/paths.d.ts +57 -0
  23. package/dist/paths.d.ts.map +1 -0
  24. package/dist/plugin.d.ts +5 -0
  25. package/dist/plugin.d.ts.map +1 -0
  26. package/dist/resolve-client-js.d.ts +6 -0
  27. package/dist/resolve-client-js.d.ts.map +1 -0
  28. package/dist/types.d.ts +141 -0
  29. package/dist/types.d.ts.map +1 -0
  30. package/package.json +55 -0
  31. package/src/__tests__/child-marker.test.ts +24 -0
  32. package/src/__tests__/compile-cache.test.ts +73 -0
  33. package/src/__tests__/component-dir-entry.test.ts +239 -0
  34. package/src/__tests__/component-manifest.test.ts +124 -0
  35. package/src/__tests__/corpus-program.test.ts +244 -0
  36. package/src/__tests__/debounced-serial-runner.test.ts +131 -0
  37. package/src/__tests__/dev-server.test.ts +138 -0
  38. package/src/__tests__/discover.test.ts +148 -0
  39. package/src/__tests__/e2e-vite-build.test.ts +191 -0
  40. package/src/__tests__/e2e-vite-dev.test.ts +478 -0
  41. package/src/__tests__/emit.test.ts +73 -0
  42. package/src/__tests__/manifest.test.ts +146 -0
  43. package/src/__tests__/paths.test.ts +93 -0
  44. package/src/__tests__/plugin.test.ts +417 -0
  45. package/src/__tests__/relative-import-rewrite.test.ts +79 -0
  46. package/src/__tests__/resolve-client-js.test.ts +55 -0
  47. package/src/__tests__/templates-optional.test.ts +139 -0
  48. package/src/child-marker.ts +67 -0
  49. package/src/compile-cache.ts +63 -0
  50. package/src/component-manifest.ts +139 -0
  51. package/src/corpus-program.ts +125 -0
  52. package/src/debounced-serial-runner.ts +67 -0
  53. package/src/dev-server.ts +184 -0
  54. package/src/discover.ts +230 -0
  55. package/src/emit.ts +66 -0
  56. package/src/index.ts +25 -0
  57. package/src/manifest.ts +89 -0
  58. package/src/paths.ts +114 -0
  59. package/src/plugin.ts +792 -0
  60. package/src/resolve-client-js.ts +34 -0
  61. package/src/types.ts +144 -0
@@ -0,0 +1,478 @@
1
+ /**
2
+ * Real end-to-end coverage of `configureServer`: actually starts a Vite dev
3
+ * server (via Vite's Node `createServer` API, the same function the `vite`
4
+ * CLI itself calls) against `../../e2e-fixture-dev`, then drives it over
5
+ * real HTTP/fs, mirroring `e2e-vite-build.test.ts`'s rigor for the build
6
+ * half.
7
+ *
8
+ * The fixture's `components` dir is a SIBLING of the Vite project root
9
+ * (`app/`), not a descendant — mirroring this monorepo's real layouts (an
10
+ * app's `vite.config.ts` root is the backend app dir; components live in a
11
+ * shared `ui/`-style directory next to it).
12
+ *
13
+ * The server-only regression (below) runs against its OWN dedicated server
14
+ * that never fetches any `'use client'` component over HTTP. That isolation
15
+ * matters: Vite's OWN internal `ensureWatchedFile` adds any file it has
16
+ * transformed as a module to the watcher, regardless of this plugin. Fetch
17
+ * Counter.tsx once and Vite itself starts watching THAT SPECIFIC FILE —
18
+ * which would silently paper over a missing `server.watcher.add()` call the
19
+ * moment a later test's "any change → full re-run" pass happens to also
20
+ * pick up Greeting.tsx's already-edited-on-disk content. Keeping the
21
+ * server-only regression on a server that never fetches Counter.tsx (or any
22
+ * other client component) means the ONLY way Greeting.tsx's edit can ever
23
+ * reach the watcher is this plugin's own explicit `server.watcher.add()` —
24
+ * see `plugin.ts`'s `configureServer`.
25
+ */
26
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test'
27
+ import { createServer, type ViteDevServer } from 'vite'
28
+ import { mkdtemp, rm, readFile, writeFile } from 'node:fs/promises'
29
+ import { tmpdir } from 'node:os'
30
+ import { join, resolve } from 'node:path'
31
+ import { GoTemplateAdapter } from '@barefootjs/go-template/adapter'
32
+ import { barefoot } from '../plugin.ts'
33
+ import { devRequestPath } from '../dev-server.ts'
34
+
35
+ const FIXTURE_ROOT = resolve(import.meta.dirname, '../../e2e-fixture-dev')
36
+ const APP_ROOT = join(FIXTURE_ROOT, 'app')
37
+ const COMPONENTS_DIR = join(FIXTURE_ROOT, 'components')
38
+ const COUNTER_PATH = join(COMPONENTS_DIR, 'Counter.tsx')
39
+ const GREETING_PATH = join(COMPONENTS_DIR, 'Greeting.tsx')
40
+
41
+ async function waitFor(check: () => Promise<boolean> | boolean, timeoutMs = 10_000): Promise<void> {
42
+ const start = Date.now()
43
+ for (;;) {
44
+ if (await check()) return
45
+ if (Date.now() - start > timeoutMs) throw new Error('waitFor: condition not met within timeout')
46
+ await new Promise(r => setTimeout(r, 50))
47
+ }
48
+ }
49
+
50
+ async function readIfExists(path: string): Promise<string | null> {
51
+ try {
52
+ return await readFile(path, 'utf8')
53
+ } catch {
54
+ return null
55
+ }
56
+ }
57
+
58
+ function captureWsSends(server: ViteDevServer): { sent: unknown[]; restore: () => void } {
59
+ const sent: unknown[] = []
60
+ const originalSend = server.ws.send.bind(server.ws)
61
+ server.ws.send = ((payload: unknown) => {
62
+ sent.push(payload)
63
+ return originalSend(payload as never)
64
+ }) as typeof server.ws.send
65
+ return { sent, restore: () => { server.ws.send = originalSend } }
66
+ }
67
+
68
+ function captureLoggerErrors(server: ViteDevServer): { errors: unknown[]; restore: () => void } {
69
+ const errors: unknown[] = []
70
+ const originalError = server.config.logger.error.bind(server.config.logger)
71
+ server.config.logger.error = ((msg: string, opts?: unknown) => {
72
+ errors.push(msg)
73
+ return originalError(msg, opts as never)
74
+ }) as typeof server.config.logger.error
75
+ return { errors, restore: () => { server.config.logger.error = originalError } }
76
+ }
77
+
78
+ function countFullReloads(sent: unknown[]): number {
79
+ return sent.filter(m => (m as { type?: string }).type === 'full-reload').length
80
+ }
81
+
82
+ async function startDevServer(
83
+ templatesDir: string,
84
+ extraServerOptions: Record<string, unknown> = {},
85
+ afterEmit?: (ctx: { types: Map<string, string>; projectDir: string; templatesDir: string; outDir: string; mode: string }) => void,
86
+ ) {
87
+ const server = await createServer({
88
+ configFile: false,
89
+ root: APP_ROOT,
90
+ logLevel: 'silent',
91
+ server: {
92
+ // Port 0 (OS-assigned) exercises the real requirement: the origin
93
+ // this plugin bakes into templates MUST come from the actually
94
+ // bound port (`httpServer.address()`), not the configured one.
95
+ port: 0,
96
+ strictPort: false,
97
+ fs: { allow: [FIXTURE_ROOT] },
98
+ // Polling sidesteps native fs-event flakiness some sandboxed /
99
+ // containerized filesystems have with inotify.
100
+ watch: { usePolling: true, interval: 30 },
101
+ ...extraServerOptions,
102
+ },
103
+ plugins: [
104
+ barefoot({
105
+ adapter: new GoTemplateAdapter({ packageName: 'main' }),
106
+ components: ['../components'],
107
+ templates: templatesDir,
108
+ afterEmit,
109
+ }),
110
+ ],
111
+ })
112
+ await server.listen()
113
+
114
+ const address = server.httpServer!.address()
115
+ const port = typeof address === 'object' && address !== null ? address.port : 0
116
+ const baseUrl = `http://localhost:${port}`
117
+ return { server, baseUrl }
118
+ }
119
+
120
+ describe('e2e: vite dev server', () => {
121
+ let server: ViteDevServer
122
+ let templatesDir: string
123
+ let baseUrl: string
124
+ let originalCounterSource: string
125
+ let originalGreetingSource: string
126
+
127
+ beforeAll(async () => {
128
+ templatesDir = await mkdtemp(join(tmpdir(), 'barefoot-vite-dev-views-'))
129
+ originalCounterSource = await readFile(COUNTER_PATH, 'utf8')
130
+ originalGreetingSource = await readFile(GREETING_PATH, 'utf8')
131
+ ;({ server, baseUrl } = await startDevServer(templatesDir))
132
+
133
+ // The initial eager pass runs off the httpServer's 'listening' event,
134
+ // asynchronously with respect to `server.listen()` resolving — wait
135
+ // for its output to actually land on disk before asserting on it.
136
+ await waitFor(async () => (await readIfExists(join(templatesDir, 'Counter.tmpl'))) !== null)
137
+ await waitFor(async () => (await readIfExists(join(templatesDir, 'Greeting.tmpl'))) !== null)
138
+ }, 30_000)
139
+
140
+ afterAll(async () => {
141
+ // Always restore fixture sources and tear down the server, even if an
142
+ // assertion above threw — a leaked dev server hangs the whole suite.
143
+ await writeFile(COUNTER_PATH, originalCounterSource).catch(() => {})
144
+ await writeFile(GREETING_PATH, originalGreetingSource).catch(() => {})
145
+ await server?.close()
146
+ await rm(templatesDir, { recursive: true, force: true })
147
+ })
148
+
149
+ test('fetching the "use client" component\'s module URL returns compiled client JS', async () => {
150
+ const requestPath = devRequestPath({ root: APP_ROOT }, COUNTER_PATH)
151
+ // Counter lives OUTSIDE the vite root, so this must resolve through
152
+ // Vite's `/@fs/` absolute-path passthrough.
153
+ expect(requestPath.startsWith('@fs/')).toBe(true)
154
+
155
+ const res = await fetch(`${baseUrl}/${requestPath}`)
156
+ expect(res.status).toBe(200)
157
+
158
+ const body = await res.text()
159
+ expect(body).toContain('createSignal')
160
+ expect(body).toContain('hydrate(')
161
+ expect(body).not.toContain('use client')
162
+ expect(body).not.toMatch(/onClick=\{/)
163
+ })
164
+
165
+ test('emitted templates carry dev-origin URLs, including the @vite/client entry', async () => {
166
+ const requestPath = devRequestPath({ root: APP_ROOT }, COUNTER_PATH)
167
+ const template = await readFile(join(templatesDir, 'Counter.tmpl'), 'utf8')
168
+
169
+ expect(template).toContain(`{{.Scripts.Register "${baseUrl}/@vite/client"}}`)
170
+ expect(template).toContain(`{{.Scripts.Register "${baseUrl}/${requestPath}"}}`)
171
+ })
172
+
173
+ test('the server-only component has no script registration, dev or otherwise', async () => {
174
+ const template = await readFile(join(templatesDir, 'Greeting.tmpl'), 'utf8')
175
+ expect(template).not.toContain('Scripts.Register')
176
+ expect(template).toContain('Hello')
177
+ })
178
+
179
+ test('the templates dir carries the dev-artifact marker while the dev server is running', async () => {
180
+ const marker = await readIfExists(join(templatesDir, '.barefootjs-dev-build'))
181
+ expect(marker).not.toBeNull()
182
+ expect(marker).toContain('DEV BUILD OUTPUT')
183
+ })
184
+
185
+ test('writes the cross-language dev-reload sentinel one directory above templates', async () => {
186
+ const sentinel = await readIfExists(resolve(templatesDir, '..', '.dev', 'build-id'))
187
+ expect(sentinel).not.toBeNull()
188
+ expect(sentinel).toMatch(/^\d+$/)
189
+ })
190
+
191
+ test('editing a component updates the dev-reload sentinel value', async () => {
192
+ const sentinelPath = resolve(templatesDir, '..', '.dev', 'build-id')
193
+ const before = await readFile(sentinelPath, 'utf8')
194
+ try {
195
+ const edited = originalGreetingSource.replace('Hello', 'Hello!!!')
196
+ await writeFile(GREETING_PATH, edited)
197
+
198
+ await waitFor(async () => {
199
+ const after = await readIfExists(sentinelPath)
200
+ return after !== null && after !== before
201
+ })
202
+ } finally {
203
+ await writeFile(GREETING_PATH, originalGreetingSource)
204
+ }
205
+ })
206
+
207
+ test('a request with a cross-origin Origin header gets the localhost-only CORS default', async () => {
208
+ const res = await fetch(`${baseUrl}/@vite/client`, {
209
+ headers: { Origin: 'http://localhost:3010' },
210
+ })
211
+ expect(res.headers.get('access-control-allow-origin')).toBe('http://localhost:3010')
212
+ })
213
+
214
+ test('editing a "use client" component re-emits its template and triggers full-reload', async () => {
215
+ const { sent, restore } = captureWsSends(server)
216
+ try {
217
+ const edited = originalCounterSource.replace(
218
+ '<button onClick=',
219
+ '<button data-edited="counter-marker" onClick=',
220
+ )
221
+ await writeFile(COUNTER_PATH, edited)
222
+
223
+ await waitFor(async () => {
224
+ const tpl = await readIfExists(join(templatesDir, 'Counter.tmpl'))
225
+ return tpl !== null && tpl.includes('data-edited="counter-marker"')
226
+ })
227
+
228
+ expect(countFullReloads(sent)).toBeGreaterThanOrEqual(1)
229
+ } finally {
230
+ restore()
231
+ await writeFile(COUNTER_PATH, originalCounterSource)
232
+ }
233
+ })
234
+
235
+ // The deterministic proof that passes never overlap and a mid-pass
236
+ // trigger is coalesced into exactly one follow-up (not dropped, not
237
+ // duplicated) is `debounced-serial-runner.test.ts` — it controls task
238
+ // completion directly via manually-resolved promises, which a real e2e
239
+ // test cannot: this fixture's eager pass finishes in low single-digit
240
+ // milliseconds, far too fast to reliably force a real "change arrives
241
+ // while a pass is in flight" race through wall-clock timing alone. This
242
+ // test is the complementary end-to-end confirmation: real rapid disk
243
+ // writes, through the real watcher, into the real debounced runner,
244
+ // converge on the correct final state with no corruption and no crash.
245
+ test('rapid successive edits converge on the final content with no corruption or errors', async () => {
246
+ const { sent, restore: restoreWs } = captureWsSends(server)
247
+ const { errors, restore: restoreLogger } = captureLoggerErrors(server)
248
+ const EDIT_COUNT = 8
249
+
250
+ try {
251
+ // Fire edits back-to-back, faster than the 100ms watcher debounce —
252
+ // simulates a save-twice-quickly / multi-file-save / `git checkout`.
253
+ for (let i = 1; i <= EDIT_COUNT; i++) {
254
+ await writeFile(
255
+ COUNTER_PATH,
256
+ originalCounterSource.replace('<button onClick=', `<button data-edit-n="${i}" onClick=`),
257
+ )
258
+ }
259
+
260
+ // Give the debounce window plus a full eager pass time to settle.
261
+ await waitFor(async () => {
262
+ const tpl = await readIfExists(join(templatesDir, 'Counter.tmpl'))
263
+ return tpl !== null && tpl.includes(`data-edit-n="${EDIT_COUNT}"`)
264
+ })
265
+ // Settle further: if an overlapping/racing pass were still in
266
+ // flight and about to clobber the file with stale content, a short
267
+ // wait would catch it reverting.
268
+ await new Promise(r => setTimeout(r, 300))
269
+
270
+ const finalTemplate = await readFile(join(templatesDir, 'Counter.tmpl'), 'utf8')
271
+ expect(finalTemplate).toContain(`data-edit-n="${EDIT_COUNT}"`)
272
+ // Not just the last edit "eventually" landing — no EARLIER edit's
273
+ // marker should still be present either (that would mean two
274
+ // template-writing passes raced and left mixed output).
275
+ for (let i = 1; i < EDIT_COUNT; i++) {
276
+ expect(finalTemplate).not.toContain(`data-edit-n="${i}"`)
277
+ }
278
+
279
+ expect(errors).toEqual([])
280
+ const reloadCount = countFullReloads(sent)
281
+ expect(reloadCount).toBeGreaterThanOrEqual(1)
282
+ // A soft signal, not the proof (see the comment above the test): with
283
+ // 8 back-to-back writes this fast, the underlying watcher's own
284
+ // polling interval typically coalesces most of them into far fewer
285
+ // raw events before this plugin's debounce even runs — so this
286
+ // mainly guards against a REGRESSION to one-reload-per-write (e.g. a
287
+ // watcher config change to non-polling native events), not against
288
+ // debouncing being removed outright.
289
+ expect(reloadCount).toBeLessThan(EDIT_COUNT)
290
+ } finally {
291
+ restoreWs()
292
+ restoreLogger()
293
+ await writeFile(COUNTER_PATH, originalCounterSource)
294
+ await waitFor(async () => {
295
+ const tpl = await readIfExists(join(templatesDir, 'Counter.tmpl'))
296
+ return tpl !== null && !tpl.includes('data-edit-n=')
297
+ })
298
+ }
299
+ })
300
+ })
301
+
302
+ describe('e2e: vite dev server — server-only component watcher regression', () => {
303
+ // Deliberately its OWN server that never fetches ANY component over HTTP
304
+ // (see this file's header comment for why that isolation is required for
305
+ // this specific regression to be meaningful).
306
+ let server: ViteDevServer
307
+ let templatesDir: string
308
+ let originalGreetingSource: string
309
+
310
+ beforeAll(async () => {
311
+ templatesDir = await mkdtemp(join(tmpdir(), 'barefoot-vite-dev-views-serveronly-'))
312
+ originalGreetingSource = await readFile(GREETING_PATH, 'utf8')
313
+ ;({ server } = await startDevServer(templatesDir))
314
+
315
+ await waitFor(async () => (await readIfExists(join(templatesDir, 'Greeting.tmpl'))) !== null)
316
+ }, 30_000)
317
+
318
+ afterAll(async () => {
319
+ await writeFile(GREETING_PATH, originalGreetingSource).catch(() => {})
320
+ await server?.close()
321
+ await rm(templatesDir, { recursive: true, force: true })
322
+ })
323
+
324
+ test('editing the server-only component (no "use client", never fetched over HTTP) still re-emits and reloads', async () => {
325
+ // Greeting.tsx has no 'use client' directive, so it is NEVER part of
326
+ // Rollup's module graph and Vite never transforms/serves it as a
327
+ // module — the one path (`ensureWatchedFile`) that would otherwise get
328
+ // it onto the watcher for free. It also lives outside the vite `root`,
329
+ // so the default root-only chokidar watch doesn't cover it either. If
330
+ // `plugin.ts` ever drops its `server.watcher.add(componentDirs)` call,
331
+ // this test times out waiting for the reload.
332
+ const { sent, restore } = captureWsSends(server)
333
+ try {
334
+ const edited = originalGreetingSource.replace('<p>', '<p data-edited="greeting-marker">')
335
+ await writeFile(GREETING_PATH, edited)
336
+
337
+ await waitFor(async () => {
338
+ const tpl = await readIfExists(join(templatesDir, 'Greeting.tmpl'))
339
+ return tpl !== null && tpl.includes('data-edited="greeting-marker"')
340
+ })
341
+
342
+ expect(sent.some(m => (m as { type?: string }).type === 'full-reload')).toBe(true)
343
+ } finally {
344
+ restore()
345
+ await writeFile(GREETING_PATH, originalGreetingSource)
346
+ }
347
+ })
348
+ })
349
+
350
+ describe('e2e: vite dev server — afterEmit', () => {
351
+ let server: ViteDevServer
352
+ let templatesDir: string
353
+
354
+ afterAll(async () => {
355
+ await server?.close()
356
+ await rm(templatesDir, { recursive: true, force: true })
357
+ })
358
+
359
+ test('fires with mode "dev" on the initial pass, and again on a tracked-file change', async () => {
360
+ templatesDir = await mkdtemp(join(tmpdir(), 'barefoot-vite-dev-views-afteremit-'))
361
+ const calls: Array<{ mode: string; projectDir: string; templatesDir: string; outDir: string; types: Map<string, string> }> = []
362
+ ;({ server } = await startDevServer(templatesDir, {}, ctx => { calls.push(ctx as never) }))
363
+
364
+ await waitFor(() => calls.length >= 1)
365
+ expect(calls[0]?.mode).toBe('dev')
366
+ expect(calls[0]?.templatesDir).toBe(templatesDir)
367
+ expect(calls[0]?.projectDir).toBe(APP_ROOT)
368
+ // No component in this fixture produces a `types` output (testAdapter-
369
+ // shaped Go adapter with no Props needing a struct isn't guaranteed
370
+ // either way) — the meaningful assertion is the narrow shape itself,
371
+ // not that it's non-empty. Never carries client JS: the type alone
372
+ // makes that impossible, this just pins the field set at runtime too.
373
+ expect(Object.keys(calls[0] ?? {}).sort()).toEqual(['mode', 'outDir', 'projectDir', 'templatesDir', 'types'])
374
+
375
+ const originalSource = await readFile(COUNTER_PATH, 'utf8')
376
+ const callsBeforeEdit = calls.length
377
+ const edited = originalSource.replace(
378
+ '<button onClick=',
379
+ '<button data-aftertemit-marker="1" onClick=',
380
+ )
381
+ await writeFile(COUNTER_PATH, edited)
382
+ try {
383
+ await waitFor(() => calls.length > callsBeforeEdit)
384
+ expect(calls[calls.length - 1]?.mode).toBe('dev')
385
+ } finally {
386
+ await writeFile(COUNTER_PATH, originalSource)
387
+ }
388
+ })
389
+ })
390
+
391
+ describe('e2e: vite dev server — user-supplied server.cors is not overwritten', () => {
392
+ let server: ViteDevServer
393
+ let templatesDir: string
394
+
395
+ afterAll(async () => {
396
+ await server?.close()
397
+ await rm(templatesDir, { recursive: true, force: true })
398
+ })
399
+
400
+ test('a user-configured server.cors survives untouched', async () => {
401
+ templatesDir = await mkdtemp(join(tmpdir(), 'barefoot-vite-dev-views-cors-'))
402
+ ;({ server } = await startDevServer(templatesDir, {
403
+ cors: { origin: 'https://my-own-cors-policy.example.com' },
404
+ }))
405
+
406
+ expect(server.config.server.cors).toEqual({ origin: 'https://my-own-cors-policy.example.com' })
407
+ })
408
+ })
409
+
410
+ describe('e2e: vite dev server — user-supplied server.cors: false is not overwritten', () => {
411
+ let server: ViteDevServer
412
+ let templatesDir: string
413
+
414
+ afterAll(async () => {
415
+ await server?.close()
416
+ await rm(templatesDir, { recursive: true, force: true })
417
+ })
418
+
419
+ test('server.cors: false (explicitly disabled) survives untouched, unlike an unset value', async () => {
420
+ templatesDir = await mkdtemp(join(tmpdir(), 'barefoot-vite-dev-views-cors-false-'))
421
+ ;({ server } = await startDevServer(templatesDir, { cors: false }))
422
+
423
+ // `!false` is `true` — a naive "fill in if falsy" check would replace
424
+ // this with the localhost default. Only "fill in if unset" is correct.
425
+ expect(server.config.server.cors).toBe(false)
426
+ })
427
+ })
428
+
429
+ describe('e2e: vite dev server — creating and deleting a component file', () => {
430
+ // Its own server + templates dir, and its own component file (never
431
+ // touched by any other describe block), so these tests can freely
432
+ // create/delete `.tsx` files without disturbing shared fixture state.
433
+ let server: ViteDevServer
434
+ let templatesDir: string
435
+ const WIDGET_PATH = join(COMPONENTS_DIR, 'Widget.tsx')
436
+
437
+ beforeAll(async () => {
438
+ templatesDir = await mkdtemp(join(tmpdir(), 'barefoot-vite-dev-views-addunlink-'))
439
+ ;({ server } = await startDevServer(templatesDir))
440
+ await waitFor(async () => (await readIfExists(join(templatesDir, 'Counter.tmpl'))) !== null)
441
+ }, 30_000)
442
+
443
+ afterAll(async () => {
444
+ await rm(WIDGET_PATH, { force: true })
445
+ await server?.close()
446
+ await rm(templatesDir, { recursive: true, force: true })
447
+ })
448
+
449
+ test('creating a new component file mid-session emits a template for it and reloads', async () => {
450
+ const { sent, restore } = captureWsSends(server)
451
+ try {
452
+ await writeFile(WIDGET_PATH, 'export function Widget() { return <p>brand new</p> }\n')
453
+
454
+ await waitFor(async () => (await readIfExists(join(templatesDir, 'Widget.tmpl'))) !== null)
455
+ const template = await readFile(join(templatesDir, 'Widget.tmpl'), 'utf8')
456
+ expect(template).toContain('brand new')
457
+ expect(countFullReloads(sent)).toBeGreaterThanOrEqual(1)
458
+ } finally {
459
+ restore()
460
+ }
461
+ })
462
+
463
+ test('deleting a component file removes its emitted template and reloads', async () => {
464
+ // Widget.tsx and Widget.tmpl both exist already, from the previous
465
+ // test (bun test runs a describe's tests in declaration order).
466
+ expect(await readIfExists(join(templatesDir, 'Widget.tmpl'))).not.toBeNull()
467
+
468
+ const { sent, restore } = captureWsSends(server)
469
+ try {
470
+ await rm(WIDGET_PATH)
471
+
472
+ await waitFor(async () => (await readIfExists(join(templatesDir, 'Widget.tmpl'))) === null)
473
+ expect(countFullReloads(sent)).toBeGreaterThanOrEqual(1)
474
+ } finally {
475
+ restore()
476
+ }
477
+ })
478
+ })
@@ -0,0 +1,73 @@
1
+ import { describe, test, expect, afterEach } from 'bun:test'
2
+ import { mkdtemp, rm, readFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import type { CompileResult, TemplateAdapter } from '@barefootjs/jsx'
6
+ import { planEmits, writeEmits } from '../emit.ts'
7
+
8
+ const fakeAdapter = { extension: '.tmpl', templatesPerComponent: false } as TemplateAdapter
9
+ const perComponentAdapter = { extension: '.tmpl', templatesPerComponent: true } as TemplateAdapter
10
+
11
+ describe('planEmits', () => {
12
+ test('plans a markedTemplate + ssrDefaults + types output, mirroring source position', () => {
13
+ const result: CompileResult = {
14
+ files: [
15
+ { path: '/src/components/ui/button/index.html', content: '<button/>', type: 'markedTemplate' },
16
+ { path: '/src/components/ui/button/index.ssr-defaults.json', content: '{}', type: 'ssrDefaults' },
17
+ { path: '/src/components/ui/button/index.types', content: 'type ButtonProps struct{}', type: 'types' },
18
+ ],
19
+ errors: [],
20
+ }
21
+
22
+ const targets = planEmits(result, '/src/components/ui/button/index.tsx', ['/src/components'], fakeAdapter)
23
+ const byPath = Object.fromEntries(targets.map(t => [t.relPath, t.content]))
24
+
25
+ expect(byPath['ui/button/index.tmpl']).toBe('<button/>')
26
+ expect(byPath['ui/button/index.ssr-defaults.json']).toBe('{}')
27
+ expect(byPath['ui/button/index.types']).toBe('type ButtonProps struct{}')
28
+ })
29
+
30
+ test('names per-component templates after the component, for templatesPerComponent adapters', () => {
31
+ const result: CompileResult = {
32
+ files: [
33
+ { path: '/x', content: 'toast body', type: 'markedTemplate', componentName: 'Toast' },
34
+ { path: '/x', content: 'toaster body', type: 'markedTemplate', componentName: 'Toaster' },
35
+ ],
36
+ errors: [],
37
+ }
38
+
39
+ const targets = planEmits(result, '/src/components/ui/toast/index.tsx', ['/src/components'], perComponentAdapter)
40
+ const byPath = Object.fromEntries(targets.map(t => [t.relPath, t.content]))
41
+
42
+ expect(byPath['ui/toast/Toast.tmpl']).toBe('toast body')
43
+ expect(byPath['ui/toast/Toaster.tmpl']).toBe('toaster body')
44
+ })
45
+
46
+ test('plans nothing for a state-only compile with no markedTemplate output', () => {
47
+ const result: CompileResult = {
48
+ files: [{ path: '/x', content: 'export const x = 1', type: 'clientJs' }],
49
+ errors: [],
50
+ }
51
+ const targets = planEmits(result, '/src/components/state.tsx', ['/src/components'], fakeAdapter)
52
+ expect(targets).toEqual([])
53
+ })
54
+ })
55
+
56
+ describe('writeEmits', () => {
57
+ let dir: string
58
+
59
+ afterEach(async () => {
60
+ if (dir) await rm(dir, { recursive: true, force: true })
61
+ })
62
+
63
+ test('creates nested directories and writes every target', async () => {
64
+ dir = await mkdtemp(join(tmpdir(), 'barefoot-emit-'))
65
+ await writeEmits(dir, [
66
+ { relPath: 'ui/button/index.tmpl', content: '<button/>' },
67
+ { relPath: 'top.tmpl', content: 'top' },
68
+ ])
69
+
70
+ expect(await readFile(join(dir, 'ui/button/index.tmpl'), 'utf8')).toBe('<button/>')
71
+ expect(await readFile(join(dir, 'top.tmpl'), 'utf8')).toBe('top')
72
+ })
73
+ })