@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.
- package/dist/child-marker.d.ts +62 -0
- package/dist/child-marker.d.ts.map +1 -0
- package/dist/compile-cache.d.ts +19 -0
- package/dist/compile-cache.d.ts.map +1 -0
- package/dist/component-manifest.d.ts +69 -0
- package/dist/component-manifest.d.ts.map +1 -0
- package/dist/corpus-program.d.ts +41 -0
- package/dist/corpus-program.d.ts.map +1 -0
- package/dist/debounced-serial-runner.d.ts +27 -0
- package/dist/debounced-serial-runner.d.ts.map +1 -0
- package/dist/dev-server.d.ts +99 -0
- package/dist/dev-server.d.ts.map +1 -0
- package/dist/discover.d.ts +117 -0
- package/dist/discover.d.ts.map +1 -0
- package/dist/emit.d.ts +9 -0
- package/dist/emit.d.ts.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24626 -0
- package/dist/manifest.d.ts +39 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/paths.d.ts +57 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/plugin.d.ts +5 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/resolve-client-js.d.ts +6 -0
- package/dist/resolve-client-js.d.ts.map +1 -0
- package/dist/types.d.ts +141 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +55 -0
- package/src/__tests__/child-marker.test.ts +24 -0
- package/src/__tests__/compile-cache.test.ts +73 -0
- package/src/__tests__/component-dir-entry.test.ts +239 -0
- package/src/__tests__/component-manifest.test.ts +124 -0
- package/src/__tests__/corpus-program.test.ts +244 -0
- package/src/__tests__/debounced-serial-runner.test.ts +131 -0
- package/src/__tests__/dev-server.test.ts +138 -0
- package/src/__tests__/discover.test.ts +148 -0
- package/src/__tests__/e2e-vite-build.test.ts +191 -0
- package/src/__tests__/e2e-vite-dev.test.ts +478 -0
- package/src/__tests__/emit.test.ts +73 -0
- package/src/__tests__/manifest.test.ts +146 -0
- package/src/__tests__/paths.test.ts +93 -0
- package/src/__tests__/plugin.test.ts +417 -0
- package/src/__tests__/relative-import-rewrite.test.ts +79 -0
- package/src/__tests__/resolve-client-js.test.ts +55 -0
- package/src/__tests__/templates-optional.test.ts +139 -0
- package/src/child-marker.ts +67 -0
- package/src/compile-cache.ts +63 -0
- package/src/component-manifest.ts +139 -0
- package/src/corpus-program.ts +125 -0
- package/src/debounced-serial-runner.ts +67 -0
- package/src/dev-server.ts +184 -0
- package/src/discover.ts +230 -0
- package/src/emit.ts +66 -0
- package/src/index.ts +25 -0
- package/src/manifest.ts +89 -0
- package/src/paths.ts +114 -0
- package/src/plugin.ts +792 -0
- package/src/resolve-client-js.ts +34 -0
- 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
|
+
})
|