@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,131 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { createDebouncedSerialRunner } from '../debounced-serial-runner.ts'
3
+
4
+ function sleep(ms: number): Promise<void> {
5
+ return new Promise(r => setTimeout(r, ms))
6
+ }
7
+
8
+ /** A controllable async task: `resolveCall(n)` releases the nth call, and
9
+ * `activeCount`/`maxActiveCount` track how many calls were in flight at
10
+ * once — the thing this whole module exists to keep at 1. */
11
+ function controllableTask() {
12
+ const calls: Array<{ resolve: () => void }> = []
13
+ let activeCount = 0
14
+ let maxActiveCount = 0
15
+
16
+ const task = () =>
17
+ new Promise<void>(resolvePromise => {
18
+ activeCount++
19
+ maxActiveCount = Math.max(maxActiveCount, activeCount)
20
+ calls.push({
21
+ resolve: () => {
22
+ activeCount--
23
+ resolvePromise()
24
+ },
25
+ })
26
+ })
27
+
28
+ return {
29
+ task,
30
+ resolveCall(n: number) {
31
+ calls[n]?.resolve()
32
+ },
33
+ get callCount() {
34
+ return calls.length
35
+ },
36
+ get maxActiveCount() {
37
+ return maxActiveCount
38
+ },
39
+ }
40
+ }
41
+
42
+ describe('createDebouncedSerialRunner', () => {
43
+ test('a single trigger() runs the task once, after the debounce window', async () => {
44
+ let calls = 0
45
+ const runner = createDebouncedSerialRunner(async () => { calls++ }, 20, () => {})
46
+
47
+ runner.trigger()
48
+ expect(calls).toBe(0) // debounced — not yet
49
+ await sleep(60)
50
+ expect(calls).toBe(1)
51
+ })
52
+
53
+ test('a burst of trigger() calls within the debounce window collapses into ONE run', async () => {
54
+ let calls = 0
55
+ const runner = createDebouncedSerialRunner(async () => { calls++ }, 30, () => {})
56
+
57
+ for (let i = 0; i < 10; i++) {
58
+ runner.trigger()
59
+ await sleep(5) // well under the 30ms debounce — keeps re-arming it
60
+ }
61
+ await sleep(80)
62
+ expect(calls).toBe(1)
63
+ })
64
+
65
+ test('trigger() during an in-flight run does NOT start a second, overlapping call', async () => {
66
+ const c = controllableTask()
67
+ const runner = createDebouncedSerialRunner(c.task, 10, () => {})
68
+
69
+ runner.trigger()
70
+ await sleep(30) // debounce fires, first call starts and is now stuck awaiting resolveCall(0)
71
+ expect(c.callCount).toBe(1)
72
+
73
+ // A change arrives mid-pass.
74
+ runner.trigger()
75
+ await sleep(30) // long past the debounce window — but the first call is still in flight
76
+ expect(c.callCount).toBe(1) // no second call started while the first is running
77
+
78
+ c.resolveCall(0)
79
+ await sleep(20) // the queued follow-up now gets its turn
80
+ expect(c.callCount).toBe(2)
81
+
82
+ c.resolveCall(1)
83
+ await sleep(20)
84
+ expect(c.maxActiveCount).toBe(1) // never more than one task in flight, at any point
85
+ })
86
+
87
+ test('several trigger() calls while a run is in flight coalesce into exactly ONE follow-up, not one per trigger', async () => {
88
+ const c = controllableTask()
89
+ const runner = createDebouncedSerialRunner(c.task, 10, () => {})
90
+
91
+ runner.trigger()
92
+ await sleep(30)
93
+ expect(c.callCount).toBe(1)
94
+
95
+ // Five more changes land while the first pass is still running.
96
+ for (let i = 0; i < 5; i++) {
97
+ runner.trigger()
98
+ await sleep(15) // each spaced past the debounce window on its own
99
+ }
100
+
101
+ c.resolveCall(0)
102
+ await sleep(30)
103
+ expect(c.callCount).toBe(2) // exactly one follow-up, not five
104
+
105
+ c.resolveCall(1)
106
+ await sleep(20)
107
+ expect(c.callCount).toBe(2) // and nothing further queued after that
108
+ })
109
+
110
+ test('a rejected task is reported via onError and does not wedge future runs', async () => {
111
+ let calls = 0
112
+ const errors: unknown[] = []
113
+ const runner = createDebouncedSerialRunner(
114
+ async () => {
115
+ calls++
116
+ if (calls === 1) throw new Error('boom')
117
+ },
118
+ 10,
119
+ err => errors.push(err),
120
+ )
121
+
122
+ runner.trigger()
123
+ await sleep(30)
124
+ expect(calls).toBe(1)
125
+ expect(errors).toHaveLength(1)
126
+
127
+ runner.trigger()
128
+ await sleep(30)
129
+ expect(calls).toBe(2) // the runner recovered — a later trigger still runs
130
+ })
131
+ })
@@ -0,0 +1,138 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import type { ViteDevServer } from 'vite'
3
+ import {
4
+ DEFAULT_DEV_CORS_ORIGIN,
5
+ DEV_ARTIFACT_MARKER_CONTENT,
6
+ DEV_ARTIFACT_MARKER_FILENAME,
7
+ devModuleUrl,
8
+ devRequestPath,
9
+ devScriptAssets,
10
+ resolveDevOrigin,
11
+ } from '../dev-server.ts'
12
+
13
+ describe('devRequestPath', () => {
14
+ test('a file under root becomes a root-relative path, no leading slash', () => {
15
+ expect(devRequestPath({ root: '/proj/app' }, '/proj/app/src/components/Counter.tsx')).toBe(
16
+ 'src/components/Counter.tsx',
17
+ )
18
+ })
19
+
20
+ test('a file OUTSIDE root uses the /@fs/ absolute-path passthrough (no leading slash)', () => {
21
+ // The realistic layout this plugin has to support: an app's
22
+ // `vite.config.ts` root is the backend app dir, while `components`
23
+ // lives in a sibling `ui/`-style directory.
24
+ expect(devRequestPath({ root: '/proj/app' }, '/proj/ui/components/Counter.tsx')).toBe(
25
+ '@fs/proj/ui/components/Counter.tsx',
26
+ )
27
+ })
28
+
29
+ test('root itself resolves to the empty path', () => {
30
+ expect(devRequestPath({ root: '/proj/app' }, '/proj/app')).toBe('')
31
+ })
32
+ })
33
+
34
+ describe('devModuleUrl', () => {
35
+ test('joins origin + base + request path for an in-root file', () => {
36
+ expect(
37
+ devModuleUrl({ root: '/proj/app', base: '/' }, 'http://localhost:5173', '/proj/app/src/components/Counter.tsx'),
38
+ ).toBe('http://localhost:5173/src/components/Counter.tsx')
39
+ })
40
+
41
+ test('honors a non-default base', () => {
42
+ expect(
43
+ devModuleUrl(
44
+ { root: '/proj/app', base: '/static/build/' },
45
+ 'http://localhost:5173',
46
+ '/proj/app/src/components/Counter.tsx',
47
+ ),
48
+ ).toBe('http://localhost:5173/static/build/src/components/Counter.tsx')
49
+ })
50
+
51
+ test('honors base together with an out-of-root /@fs/ path', () => {
52
+ expect(
53
+ devModuleUrl(
54
+ { root: '/proj/app', base: '/static/build/' },
55
+ 'http://localhost:5173',
56
+ '/proj/ui/components/Counter.tsx',
57
+ ),
58
+ ).toBe('http://localhost:5173/static/build/@fs/proj/ui/components/Counter.tsx')
59
+ })
60
+ })
61
+
62
+ describe('devScriptAssets', () => {
63
+ test('returns [@vite/client, the component module URL], in that order', () => {
64
+ const config = { root: '/proj/app', base: '/' }
65
+ expect(devScriptAssets(config, 'http://localhost:5173', '/proj/app/src/components/Counter.tsx')).toEqual([
66
+ 'http://localhost:5173/@vite/client',
67
+ 'http://localhost:5173/src/components/Counter.tsx',
68
+ ])
69
+ })
70
+ })
71
+
72
+ describe('resolveDevOrigin', () => {
73
+ function fakeServer(overrides: {
74
+ origin?: string
75
+ port?: number
76
+ address?: { port: number } | string | null
77
+ }): ViteDevServer {
78
+ return {
79
+ config: {
80
+ server: {
81
+ origin: overrides.origin,
82
+ port: overrides.port,
83
+ },
84
+ },
85
+ httpServer:
86
+ overrides.address === undefined
87
+ ? null
88
+ : ({ address: () => overrides.address } as unknown as ViteDevServer['httpServer']),
89
+ // biome-ignore lint: minimal fake, only the fields resolveDevOrigin reads are real
90
+ } as any
91
+ }
92
+
93
+ test('returns the user-configured origin unchanged when set', () => {
94
+ const server = fakeServer({ origin: 'https://example.com' })
95
+ expect(resolveDevOrigin(server)).toBe('https://example.com')
96
+ })
97
+
98
+ test('derives from the ACTUAL bound port (httpServer.address()), not the configured port', () => {
99
+ // The case `strictPort: false` (the default) makes this matter: Vite
100
+ // auto-increments past an in-use configured port.
101
+ const server = fakeServer({ port: 5173, address: { port: 5174 } })
102
+ expect(resolveDevOrigin(server)).toBe('http://localhost:5174')
103
+ })
104
+
105
+ test('writes the computed default back onto server.config.server.origin', () => {
106
+ const server = fakeServer({ port: 5173, address: { port: 5174 } })
107
+ resolveDevOrigin(server)
108
+ expect(server.config.server.origin).toBe('http://localhost:5174')
109
+ })
110
+
111
+ test('falls back to the configured port when there is no httpServer (middleware mode)', () => {
112
+ const server = fakeServer({ port: 5173 })
113
+ expect(resolveDevOrigin(server)).toBe('http://localhost:5173')
114
+ })
115
+ })
116
+
117
+ describe('DEFAULT_DEV_CORS_ORIGIN', () => {
118
+ test('matches localhost and 127.0.0.1 at any port', () => {
119
+ expect(DEFAULT_DEV_CORS_ORIGIN.test('http://localhost:3010')).toBe(true)
120
+ expect(DEFAULT_DEV_CORS_ORIGIN.test('https://127.0.0.1:8080')).toBe(true)
121
+ expect(DEFAULT_DEV_CORS_ORIGIN.test('http://localhost')).toBe(true)
122
+ })
123
+
124
+ test('does NOT match an arbitrary remote origin', () => {
125
+ expect(DEFAULT_DEV_CORS_ORIGIN.test('https://evil.example.com')).toBe(false)
126
+ })
127
+ })
128
+
129
+ describe('dev-artifact marker', () => {
130
+ test('filename is a dotfile so it does not read as a template', () => {
131
+ expect(DEV_ARTIFACT_MARKER_FILENAME.startsWith('.')).toBe(true)
132
+ })
133
+
134
+ test('content warns against committing/deploying and names the fix', () => {
135
+ expect(DEV_ARTIFACT_MARKER_CONTENT).toContain('DEV BUILD OUTPUT')
136
+ expect(DEV_ARTIFACT_MARKER_CONTENT).toContain('vite build')
137
+ })
138
+ })
@@ -0,0 +1,148 @@
1
+ import { describe, test, expect, afterEach } from 'bun:test'
2
+ import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { buildChildNameIndex, hasUseClientDirective, discoverComponentFiles, discoverComponents } from '../discover.ts'
6
+
7
+ describe('hasUseClientDirective', () => {
8
+ test('detects a leading double-quoted directive', () => {
9
+ expect(hasUseClientDirective('"use client"\nexport function A() {}')).toBe(true)
10
+ })
11
+
12
+ test('detects a leading single-quoted directive', () => {
13
+ expect(hasUseClientDirective("'use client'\nexport function A() {}")).toBe(true)
14
+ })
15
+
16
+ test('skips leading block comments before the directive', () => {
17
+ expect(hasUseClientDirective('/* c */\n\'use client\'\nexport function A() {}')).toBe(true)
18
+ })
19
+
20
+ test('skips leading line comments before the directive', () => {
21
+ expect(hasUseClientDirective('// c\n\'use client\'\nexport function A() {}')).toBe(true)
22
+ })
23
+
24
+ test('returns false for a server-only file', () => {
25
+ expect(hasUseClientDirective('export function A() { return <div/> }')).toBe(false)
26
+ })
27
+
28
+ test('returns false when the directive is not the first statement', () => {
29
+ expect(hasUseClientDirective('const x = 1\n\'use client\'')).toBe(false)
30
+ })
31
+ })
32
+
33
+ describe('discoverComponentFiles', () => {
34
+ let dir: string
35
+
36
+ afterEach(async () => {
37
+ if (dir) await rm(dir, { recursive: true, force: true })
38
+ })
39
+
40
+ test('finds nested .tsx files and skips test/spec/preview variants', async () => {
41
+ dir = await mkdtemp(join(tmpdir(), 'barefoot-discover-'))
42
+ await mkdir(join(dir, 'nested'), { recursive: true })
43
+ await writeFile(join(dir, 'A.tsx'), 'export function A() {}')
44
+ await writeFile(join(dir, 'A.test.tsx'), 'export function A() {}')
45
+ await writeFile(join(dir, 'A.spec.tsx'), 'export function A() {}')
46
+ await writeFile(join(dir, 'A.preview.tsx'), 'export function A() {}')
47
+ await writeFile(join(dir, 'nested', 'B.tsx'), 'export function B() {}')
48
+ await writeFile(join(dir, 'not-a-component.ts'), 'export const x = 1')
49
+
50
+ const found = await discoverComponentFiles(dir)
51
+ const basenames = found.map(f => f.slice(dir.length + 1)).sort()
52
+ expect(basenames).toEqual(['A.tsx', 'nested/B.tsx'])
53
+ })
54
+
55
+ test('returns an empty array for a missing directory', async () => {
56
+ const found = await discoverComponentFiles(join(tmpdir(), 'does-not-exist-barefoot-vite'))
57
+ expect(found).toEqual([])
58
+ })
59
+ })
60
+
61
+ describe('discoverComponents', () => {
62
+ let dir: string
63
+
64
+ afterEach(async () => {
65
+ if (dir) await rm(dir, { recursive: true, force: true })
66
+ })
67
+
68
+ test('classifies each discovered file as client or server-only', async () => {
69
+ dir = await mkdtemp(join(tmpdir(), 'barefoot-discover-classify-'))
70
+ await writeFile(join(dir, 'Client.tsx'), '\'use client\'\nexport function Client() {}')
71
+ await writeFile(join(dir, 'Server.tsx'), 'export function Server() {}')
72
+
73
+ const found = await discoverComponents([dir], p => Bun.file(p).text())
74
+ const byName = Object.fromEntries(found.map(f => [f.absPath.slice(dir.length + 1), f.isClient]))
75
+ expect(byName['Client.tsx']).toBe(true)
76
+ expect(byName['Server.tsx']).toBe(false)
77
+ })
78
+ })
79
+
80
+ describe('buildChildNameIndex', () => {
81
+ test('keys \'use client\' files by their exported component names', () => {
82
+ const index = buildChildNameIndex([
83
+ { absPath: '/proj/components/TodoItem.tsx', isClient: true, exportedComponents: ['TodoItem'] },
84
+ { absPath: '/proj/blog/LikeButton.tsx', isClient: true, exportedComponents: ['LikeButton'] },
85
+ ])
86
+ expect(index.get('TodoItem')).toBe('/proj/components/TodoItem.tsx')
87
+ expect(index.get('LikeButton')).toBe('/proj/blog/LikeButton.tsx')
88
+ })
89
+
90
+ // The regression this index was rebuilt for. Keyed on the basename, an
91
+ // `index.tsx` exporting several components resolved only as `index`, so
92
+ // every `@bf-child:<Name>` marker into it fell through to the no-op
93
+ // module and the child silently never hydrated.
94
+ test('a file exporting several components is reachable by EVERY name, not by its basename', () => {
95
+ const index = buildChildNameIndex([
96
+ {
97
+ absPath: '/proj/components/icon/index.tsx',
98
+ isClient: true,
99
+ exportedComponents: ['CopyIcon', 'CheckIcon'],
100
+ },
101
+ ])
102
+ expect(index.get('CopyIcon')).toBe('/proj/components/icon/index.tsx')
103
+ expect(index.get('CheckIcon')).toBe('/proj/components/icon/index.tsx')
104
+ expect(index.has('index')).toBe(false)
105
+ })
106
+
107
+ // Wider than the multi-export case: keyed on the basename, EVERY
108
+ // colocated `index.tsx` collided on the single key "index" — so even a
109
+ // single-export `ui/button/index.tsx` was unreachable as a marker target.
110
+ test('single-export colocated index.tsx files resolve by name, and never collide on "index"', () => {
111
+ const index = buildChildNameIndex([
112
+ { absPath: '/proj/ui/button/index.tsx', isClient: true, exportedComponents: ['Button'] },
113
+ { absPath: '/proj/ui/toggle/index.tsx', isClient: true, exportedComponents: ['Toggle'] },
114
+ ])
115
+ expect(index.get('Button')).toBe('/proj/ui/button/index.tsx')
116
+ expect(index.get('Toggle')).toBe('/proj/ui/toggle/index.tsx')
117
+ expect(index.has('index')).toBe(false)
118
+ })
119
+
120
+ test('first writer wins on a duplicate name, so an earlier components dir shadows a later one', () => {
121
+ const index = buildChildNameIndex([
122
+ { absPath: '/proj/a/Button.tsx', isClient: true, exportedComponents: ['Button'] },
123
+ { absPath: '/proj/b/Button.tsx', isClient: true, exportedComponents: ['Button'] },
124
+ ])
125
+ expect(index.get('Button')).toBe('/proj/a/Button.tsx')
126
+ })
127
+
128
+ test('falls back to the basename when no exports were parsed, keeping the old convention working', () => {
129
+ const index = buildChildNameIndex([
130
+ { absPath: '/proj/components/Widget.tsx', isClient: true, exportedComponents: [] },
131
+ ])
132
+ expect(index.get('Widget')).toBe('/proj/components/Widget.tsx')
133
+ })
134
+
135
+ test('excludes server-only files — a @bf-child marker only ever names an interactive component', () => {
136
+ const index = buildChildNameIndex([
137
+ { absPath: '/proj/components/ServerOnly.tsx', isClient: false, exportedComponents: [] },
138
+ ])
139
+ expect(index.has('ServerOnly')).toBe(false)
140
+ })
141
+
142
+ test('accepts a .ts extension too', () => {
143
+ const index = buildChildNameIndex([
144
+ { absPath: '/proj/components/Widget.ts', isClient: true, exportedComponents: ['Widget'] },
145
+ ])
146
+ expect(index.get('Widget')).toBe('/proj/components/Widget.ts')
147
+ })
148
+ })
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Real end-to-end coverage: actually runs `vite build` (via Vite's Node
3
+ * API — the same function the `vite` CLI binary itself calls) against the
4
+ * small fixture project under `../../e2e-fixture`, then asserts BOTH
5
+ * halves of the design:
6
+ *
7
+ * - the client-asset half: Vite/Rollup produced hashed JS assets, and
8
+ * the shared `@barefootjs/client` runtime collapsed into ONE shared
9
+ * chunk imported by every entry (not duplicated per entry) — the
10
+ * exact behavior the spike (R1/R3) proved.
11
+ * - the template half: the emitted Go template for the `'use client'`
12
+ * component contains a `{{.Scripts.Register "…"}}` call pointing at
13
+ * the REAL, hashed, `base`-prefixed manifest URL, and the server-only
14
+ * component (never reachable from Rollup's module graph) still got a
15
+ * template, with no script registration at all.
16
+ *
17
+ * A plugin that only passes mocked unit tests hasn't been shown to work —
18
+ * this is the test that shows it.
19
+ */
20
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test'
21
+ import { build } from 'vite'
22
+ import { mkdtemp, rm, readFile, readdir } from 'node:fs/promises'
23
+ import { tmpdir } from 'node:os'
24
+ import { join, resolve } from 'node:path'
25
+ import { GoTemplateAdapter } from '@barefootjs/go-template/adapter'
26
+ import { barefoot } from '../plugin.ts'
27
+
28
+ const FIXTURE_ROOT = resolve(import.meta.dirname, '../../e2e-fixture')
29
+
30
+ describe('e2e: vite build', () => {
31
+ let outDir: string
32
+ let templatesDir: string
33
+
34
+ beforeAll(async () => {
35
+ outDir = await mkdtemp(join(tmpdir(), 'barefoot-vite-dist-'))
36
+ templatesDir = await mkdtemp(join(tmpdir(), 'barefoot-vite-views-'))
37
+
38
+ await build({
39
+ configFile: false,
40
+ root: FIXTURE_ROOT,
41
+ base: '/static/build/',
42
+ logLevel: 'warn',
43
+ build: {
44
+ outDir,
45
+ emptyOutDir: true,
46
+ },
47
+ plugins: [
48
+ barefoot({
49
+ adapter: new GoTemplateAdapter({ packageName: 'main' }),
50
+ components: ['src/components'],
51
+ templates: templatesDir,
52
+ }),
53
+ ],
54
+ })
55
+ }, 60_000)
56
+
57
+ afterAll(async () => {
58
+ await rm(outDir, { recursive: true, force: true })
59
+ await rm(templatesDir, { recursive: true, force: true })
60
+ })
61
+
62
+ test('emits a manifest with hashed entries for every "use client" component', async () => {
63
+ const manifest = JSON.parse(await readFile(resolve(outDir, '.vite/manifest.json'), 'utf8'))
64
+ const keys = Object.keys(manifest)
65
+ expect(keys.some(k => k.endsWith('Counter.tsx'))).toBe(true)
66
+ expect(keys.some(k => k.endsWith('SharedCounter.tsx'))).toBe(true)
67
+ expect(keys.some(k => k.endsWith('counterState.tsx'))).toBe(true)
68
+ // Greeting.tsx has no 'use client' directive — never an entry.
69
+ expect(keys.some(k => k.endsWith('Greeting.tsx'))).toBe(false)
70
+ })
71
+
72
+ test('the runtime collapses into one shared chunk imported by every client entry', async () => {
73
+ const manifest = JSON.parse(await readFile(resolve(outDir, '.vite/manifest.json'), 'utf8'))
74
+ const counterKey = Object.keys(manifest).find(k => k.endsWith('Counter.tsx') && !k.includes('Shared'))!
75
+ const sharedCounterKey = Object.keys(manifest).find(k => k.endsWith('SharedCounter.tsx'))!
76
+
77
+ const counterEntry = manifest[counterKey]
78
+ const sharedCounterEntry = manifest[sharedCounterKey]
79
+ expect(counterEntry.imports?.length).toBeGreaterThan(0)
80
+ expect(sharedCounterEntry.imports?.length).toBeGreaterThan(0)
81
+
82
+ // Both entries' shared-chunk import sets intersect on at least one
83
+ // chunk (the runtime) — i.e. it's a SHARED chunk, not duplicated.
84
+ const shared = (counterEntry.imports ?? []).filter((k: string) => (sharedCounterEntry.imports ?? []).includes(k))
85
+ expect(shared.length).toBeGreaterThan(0)
86
+
87
+ // That shared chunk is a real file on disk under outDir.
88
+ for (const key of shared) {
89
+ const file = manifest[key].file
90
+ const content = await readFile(resolve(outDir, file), 'utf8')
91
+ expect(content.length).toBeGreaterThan(0)
92
+ }
93
+ })
94
+
95
+ test('SharedCounter\'s relative "./counterState.client.js" import resolved to the real counterState chunk (resolveId shim, R2)', async () => {
96
+ const manifest = JSON.parse(await readFile(resolve(outDir, '.vite/manifest.json'), 'utf8'))
97
+ const sharedCounterKey = Object.keys(manifest).find(k => k.endsWith('SharedCounter.tsx'))!
98
+ const stateKey = Object.keys(manifest).find(k => k.endsWith('counterState.tsx'))!
99
+ const sharedCounterEntry = manifest[sharedCounterKey]
100
+
101
+ // counterState.tsx is itself a rollupOptions.input entry (it has 'use
102
+ // client' too) AND is import-reachable from SharedCounter — Rollup
103
+ // resolves both to the identical module id, so SharedCounter's
104
+ // manifest row lists it directly as an entry-to-entry import.
105
+ expect(sharedCounterEntry.imports).toContain(stateKey)
106
+ })
107
+
108
+ test('the built client JS is plain JS Vite/esbuild could bundle and minify without re-parsing JSX (R1)', async () => {
109
+ const manifest = JSON.parse(await readFile(resolve(outDir, '.vite/manifest.json'), 'utf8'))
110
+ const counterKey = Object.keys(manifest).find(k => k.endsWith('Counter.tsx') && !k.includes('Shared'))!
111
+ const file = manifest[counterKey].file
112
+ const content = await readFile(resolve(outDir, file), 'utf8')
113
+ // Production build output is minified — identifiers like `hydrate` get
114
+ // renamed, so assert on what survives minification instead: the
115
+ // hydration template's literal markup (proof this went through as
116
+ // plain JS, not raw JSX esbuild would have needed a JSX transform for)
117
+ // and the total absence of the 'use client' directive / JSX attribute
118
+ // syntax a live JSX expression would still carry.
119
+ expect(content).toContain('<button bf="s1">')
120
+ expect(content).not.toContain('use client')
121
+ expect(content).not.toMatch(/onClick=\{/)
122
+ })
123
+
124
+ test('emits a Go template for the "use client" component with the real hashed, base-prefixed script URL', async () => {
125
+ const manifest = JSON.parse(await readFile(resolve(outDir, '.vite/manifest.json'), 'utf8'))
126
+ const counterKey = Object.keys(manifest).find(k => k.endsWith('Counter.tsx') && !k.includes('Shared'))!
127
+ const expectedUrl = `/static/build/${manifest[counterKey].file}`
128
+
129
+ const template = await readFile(resolve(templatesDir, 'Counter.tmpl'), 'utf8')
130
+ expect(template).toContain(`{{.Scripts.Register "${expectedUrl}"}}`)
131
+ })
132
+
133
+ test('a sibling-imported child rendered inside a CSR .map() loop resolves to a REAL import, not the raw @bf-child: marker', async () => {
134
+ const manifest = JSON.parse(await readFile(resolve(outDir, '.vite/manifest.json'), 'utf8'))
135
+ const loopParentKey = Object.keys(manifest).find(k => k.endsWith('LoopParent.tsx'))!
136
+ const loopChildKey = Object.keys(manifest).find(k => k.endsWith('LoopChild.tsx'))!
137
+
138
+ // LoopChild is independently a Rollup entry (every 'use client' file is)
139
+ // AND reachable from LoopParent's own compiled output — proof the
140
+ // `@bf-child:LoopChild` marker resolved to LoopChild's real module
141
+ // instead of the unresolvable literal string, and that Rollup wired an
142
+ // entry-to-entry import rather than leaving it external.
143
+ expect(manifest[loopParentKey].imports).toContain(loopChildKey)
144
+
145
+ const parentContent = await readFile(resolve(outDir, manifest[loopParentKey].file), 'utf8')
146
+ expect(parentContent).not.toContain('@bf-child')
147
+ expect(parentContent).not.toContain('bf-child')
148
+ })
149
+
150
+ test('emits a template for the server-only component (never in the Rollup graph) with NO script registration', async () => {
151
+ const manifest = JSON.parse(await readFile(resolve(outDir, '.vite/manifest.json'), 'utf8'))
152
+ expect(Object.keys(manifest).some(k => k.endsWith('Greeting.tsx'))).toBe(false)
153
+
154
+ const template = await readFile(resolve(templatesDir, 'Greeting.tmpl'), 'utf8')
155
+ expect(template).not.toContain('Scripts.Register')
156
+ expect(template).toContain('Hello')
157
+ })
158
+
159
+ test('the templates dir mirrors every discovered component, client and server-only alike', async () => {
160
+ const files = await readdir(templatesDir)
161
+ expect(files).toContain('Counter.tmpl')
162
+ expect(files).toContain('SharedCounter.tmpl')
163
+ expect(files).toContain('Greeting.tmpl')
164
+ })
165
+
166
+ test('writes a combined manifest.json alongside the per-component templates, keyed by component name with markedTemplate + ssrDefaults (#2494 review)', async () => {
167
+ const manifest = JSON.parse(await readFile(resolve(templatesDir, 'manifest.json'), 'utf8'))
168
+
169
+ // Keyed by component name (GoTemplateAdapter isn't `templatesPerComponent`,
170
+ // so no `components` sub-map is expected — see `component-manifest.ts`).
171
+ // Counter's own `count` signal seeds a literal SSR default even with no
172
+ // backing prop (`ssrDefaults` covers every signal needing an in-template
173
+ // seed, not only optional-prop-derived ones); Greeting's required `name`
174
+ // prop reference seeds a `{ propName, value: null }` row the same way.
175
+ expect(manifest.Counter).toEqual({ markedTemplate: 'Counter.tmpl', ssrDefaults: { count: { value: 0 } } })
176
+ expect(manifest.Greeting).toEqual({
177
+ markedTemplate: 'Greeting.tmpl',
178
+ ssrDefaults: { name: { propName: 'name', value: null } },
179
+ })
180
+ expect('components' in manifest.Counter).toBe(false)
181
+ // The absent-key contract itself (no entry carries `ssrDefaults: {}`)
182
+ // is covered precisely, on fabricated input, by
183
+ // `component-manifest.test.ts` — this test's job is just proving the
184
+ // combined file actually lands on disk from a REAL build.
185
+ })
186
+
187
+ test('a production build leaves no dev-reload sentinel behind (see e2e-vite-dev.test.ts for the dev-side write)', async () => {
188
+ const sentinel = await readFile(resolve(templatesDir, '..', '.dev', 'build-id'), 'utf8').catch(() => null)
189
+ expect(sentinel).toBeNull()
190
+ })
191
+ })