Skip to content

Commit 1f8f68b

Browse files
feat: add support for clientDir in package extends and enhance E2E tests for file watching
1 parent 1b72122 commit 1f8f68b

File tree

5 files changed

+444
-1
lines changed

5 files changed

+444
-1
lines changed

src/nitro/setup/extend-loader.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,17 @@ export async function resolveExtendDirs(nitro: Nitro): Promise<string[]> {
5858

5959
for (const source of extend) {
6060
if (typeof source === 'string') {
61-
// Package name or local path - load config and get serverDir
61+
// Package name or local path - load config and get serverDir/clientDir
6262
const pkg = await loadPackageConfig(source, nitro.options.rootDir)
6363
if (pkg) {
6464
const serverDir = resolve(pkg.baseDir, pkg.config.serverDir || 'server/graphql')
6565
dirs.push(serverDir)
66+
67+
// Also add clientDir if configured
68+
if (pkg.config.clientDir) {
69+
const clientDir = resolve(pkg.baseDir, pkg.config.clientDir)
70+
dirs.push(clientDir)
71+
}
6672
}
6773
else if (isLocalPath(source)) {
6874
// Local path without config - use default serverDir
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/**
2+
* E2E test for file watcher with package-based extends clientDir
3+
*
4+
* This test verifies that when using package-based extends with clientDir configured,
5+
* the file watcher correctly watches the extend package's clientDir and regenerates
6+
* client types when .graphql files change.
7+
*
8+
* Bug: resolveExtendDirs only adds serverDir to watchDirs, not clientDir
9+
*/
10+
import type { Nitro } from 'nitro/types'
11+
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
12+
import { build, createNitro, prepare } from 'nitro/builder'
13+
import { join, resolve } from 'pathe'
14+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
15+
import graphql from '../../src'
16+
17+
const fixturesDir = resolve(__dirname, '../fixtures')
18+
const mainProjectDir = resolve(fixturesDir, 'extend-multi/main-project')
19+
const ecommerceDir = resolve(fixturesDir, 'extend-multi/ecommerce')
20+
const ecommerceClientDir = resolve(ecommerceDir, 'client/graphql')
21+
const clientQueryPath = join(ecommerceClientDir, 'products.graphql')
22+
23+
// Note: nitro-graphql uses .graphql as the output directory for types
24+
const graphqlBuildDir = join(mainProjectDir, '.graphql')
25+
const clientTypesPath = join(graphqlBuildDir, 'nitro-graphql-client.d.ts')
26+
27+
// Initial client query
28+
const initialQuery = `query GetProducts {
29+
products {
30+
id
31+
name
32+
price
33+
}
34+
}
35+
`
36+
37+
// Updated client query with new GetAllProducts query
38+
const updatedQuery = `query GetProducts {
39+
products {
40+
id
41+
name
42+
price
43+
}
44+
}
45+
46+
query GetAllProducts {
47+
products {
48+
id
49+
name
50+
price
51+
}
52+
}
53+
`
54+
55+
// Clean up generated files
56+
function cleanupGeneratedFiles() {
57+
const dirsToClean = [
58+
join(mainProjectDir, '.nitro'),
59+
join(mainProjectDir, '.output'),
60+
join(mainProjectDir, '.graphql'),
61+
join(mainProjectDir, 'node_modules/.nitro'),
62+
]
63+
64+
for (const dir of dirsToClean) {
65+
if (existsSync(dir)) {
66+
rmSync(dir, { recursive: true, force: true })
67+
}
68+
}
69+
}
70+
71+
// Reset fixture files to initial state
72+
function resetFixtureFiles() {
73+
writeFileSync(clientQueryPath, initialQuery, 'utf-8')
74+
}
75+
76+
// Helper to wait for file to be updated
77+
async function waitForFileChange(
78+
filePath: string,
79+
expectedContent: string,
80+
timeout = 5000,
81+
): Promise<boolean> {
82+
const startTime = Date.now()
83+
84+
while (Date.now() - startTime < timeout) {
85+
if (existsSync(filePath)) {
86+
const content = readFileSync(filePath, 'utf-8')
87+
if (content.includes(expectedContent)) {
88+
return true
89+
}
90+
}
91+
await new Promise(r => setTimeout(r, 100))
92+
}
93+
94+
return false
95+
}
96+
97+
describe('extend Package clientDir File Watcher E2E', () => {
98+
let nitro: Nitro
99+
100+
beforeAll(async () => {
101+
cleanupGeneratedFiles()
102+
resetFixtureFiles()
103+
104+
// Create Nitro with dev mode and package-based extends
105+
nitro = await createNitro({
106+
rootDir: mainProjectDir,
107+
dev: true, // This enables file watching
108+
modules: [
109+
graphql({
110+
framework: 'graphql-yoga',
111+
skipLocalScan: true,
112+
extend: [
113+
resolve(fixturesDir, 'extend-multi/auth'),
114+
resolve(fixturesDir, 'extend-multi/ecommerce'),
115+
],
116+
}),
117+
],
118+
})
119+
120+
await prepare(nitro)
121+
await build(nitro)
122+
123+
// Wait for initial type generation
124+
await new Promise(r => setTimeout(r, 500))
125+
}, 60000)
126+
127+
afterAll(async () => {
128+
await nitro?.close()
129+
cleanupGeneratedFiles()
130+
resetFixtureFiles()
131+
})
132+
133+
it('should include extend package clientDir in watchDirs', () => {
134+
const watchDirs = nitro.graphql.watchDirs || []
135+
136+
// Should include the ecommerce package's clientDir
137+
const hasEcommerceClientDir = watchDirs.some((dir: string) =>
138+
dir.includes('ecommerce') && dir.includes('client/graphql'),
139+
)
140+
141+
expect(hasEcommerceClientDir).toBe(true)
142+
})
143+
144+
it('should have generated initial client types from extend package', () => {
145+
expect(existsSync(clientTypesPath)).toBe(true)
146+
147+
const content = readFileSync(clientTypesPath, 'utf-8')
148+
expect(content).toContain('GetProducts')
149+
// Should NOT contain GetAllProducts yet
150+
expect(content).not.toContain('GetAllProducts')
151+
})
152+
153+
it('should regenerate client types when extend package clientDir .graphql file changes', async () => {
154+
// Verify initial state - no GetAllProducts
155+
const initialContent = readFileSync(clientTypesPath, 'utf-8')
156+
expect(initialContent).not.toContain('GetAllProducts')
157+
158+
// Modify the query file in the extend package's clientDir
159+
writeFileSync(clientQueryPath, updatedQuery, 'utf-8')
160+
161+
// Wait for file watcher to detect change and regenerate types
162+
// File watcher has 150ms debounce + processing time
163+
const found = await waitForFileChange(clientTypesPath, 'GetAllProducts', 5000)
164+
165+
expect(found).toBe(true)
166+
167+
// Verify the new query type is present
168+
const updatedContent = readFileSync(clientTypesPath, 'utf-8')
169+
expect(updatedContent).toContain('GetAllProducts')
170+
expect(updatedContent).toContain('GetProducts')
171+
}, 10000)
172+
})
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
/**
2+
* E2E test for file watcher with external clientDir
3+
*
4+
* This test verifies that when clientDir is set to a path outside the project root
5+
* (e.g., ../../apps/ecommerce/app/graphql), the file watcher:
6+
* - Correctly watches the external directory
7+
* - Regenerates client types when .graphql files change in that directory
8+
*/
9+
import type { Nitro } from 'nitro/types'
10+
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
11+
import { build, createNitro, prepare } from 'nitro/builder'
12+
import { join, resolve } from 'pathe'
13+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
14+
import graphql from '../../src'
15+
16+
const fixturesDir = resolve(__dirname, '../fixtures')
17+
const projectDir = resolve(fixturesDir, 'nitro-relative-parent/packages/graphql')
18+
const externalClientDir = resolve(fixturesDir, 'nitro-relative-parent/apps/ecommerce/app/graphql')
19+
const clientQueryPath = join(externalClientDir, 'queries.graphql')
20+
21+
// Note: nitro-graphql uses .graphql as the output directory for types (nitro.graphql.buildDir)
22+
const graphqlBuildDir = join(projectDir, '.graphql')
23+
const clientTypesPath = join(graphqlBuildDir, 'nitro-graphql-client.d.ts')
24+
const serverTypesPath = join(graphqlBuildDir, 'nitro-graphql-server.d.ts')
25+
26+
// Initial client query
27+
const initialQuery = `query GetUser($id: ID!) {
28+
user(id: $id) {
29+
id
30+
name
31+
}
32+
}
33+
34+
query GetHello {
35+
hello
36+
}
37+
`
38+
39+
// Updated client query with new GetUserDetails query
40+
const updatedQuery = `query GetUser($id: ID!) {
41+
user(id: $id) {
42+
id
43+
name
44+
}
45+
}
46+
47+
query GetHello {
48+
hello
49+
}
50+
51+
query GetUserDetails($id: ID!) {
52+
user(id: $id) {
53+
id
54+
name
55+
}
56+
hello
57+
}
58+
`
59+
60+
// Clean up generated files
61+
function cleanupGeneratedFiles() {
62+
const dirsToClean = [
63+
join(projectDir, '.nitro'),
64+
join(projectDir, '.output'),
65+
join(projectDir, '.graphql'),
66+
join(projectDir, 'node_modules/.nitro'),
67+
resolve(fixturesDir, 'nitro-relative-parent/apps/ecommerce/app/graphql/types'),
68+
resolve(fixturesDir, 'nitro-relative-parent/apps/ecommerce/app/graphql/default'),
69+
]
70+
71+
for (const dir of dirsToClean) {
72+
if (existsSync(dir)) {
73+
rmSync(dir, { recursive: true, force: true })
74+
}
75+
}
76+
}
77+
78+
// Reset fixture files to initial state
79+
function resetFixtureFiles() {
80+
writeFileSync(clientQueryPath, initialQuery, 'utf-8')
81+
}
82+
83+
// Helper to wait for file to be updated
84+
async function waitForFileChange(
85+
filePath: string,
86+
expectedContent: string,
87+
timeout = 5000,
88+
): Promise<boolean> {
89+
const startTime = Date.now()
90+
91+
while (Date.now() - startTime < timeout) {
92+
if (existsSync(filePath)) {
93+
const content = readFileSync(filePath, 'utf-8')
94+
if (content.includes(expectedContent)) {
95+
return true
96+
}
97+
}
98+
await new Promise(r => setTimeout(r, 100))
99+
}
100+
101+
return false
102+
}
103+
104+
describe('external clientDir File Watcher E2E', () => {
105+
let nitro: Nitro
106+
let initialServerTypesContent: string
107+
108+
beforeAll(async () => {
109+
cleanupGeneratedFiles()
110+
resetFixtureFiles()
111+
112+
// Create Nitro with dev mode and external clientDir
113+
nitro = await createNitro({
114+
rootDir: projectDir,
115+
dev: true, // This enables file watching
116+
modules: [
117+
graphql({
118+
framework: 'graphql-yoga',
119+
clientDir: '../../apps/ecommerce/app/graphql',
120+
}),
121+
],
122+
})
123+
124+
await prepare(nitro)
125+
await build(nitro)
126+
127+
// Wait for initial type generation
128+
await new Promise(r => setTimeout(r, 500))
129+
130+
// Store initial server types content for comparison
131+
if (existsSync(serverTypesPath)) {
132+
initialServerTypesContent = readFileSync(serverTypesPath, 'utf-8')
133+
}
134+
}, 60000)
135+
136+
afterAll(async () => {
137+
await nitro?.close()
138+
cleanupGeneratedFiles()
139+
resetFixtureFiles()
140+
})
141+
142+
it('should have generated initial types from external clientDir', () => {
143+
expect(existsSync(clientTypesPath)).toBe(true)
144+
expect(existsSync(serverTypesPath)).toBe(true)
145+
146+
const clientContent = readFileSync(clientTypesPath, 'utf-8')
147+
expect(clientContent).toContain('GetUser')
148+
expect(clientContent).toContain('GetHello')
149+
// Should NOT contain GetUserDetails yet
150+
expect(clientContent).not.toContain('GetUserDetails')
151+
})
152+
153+
it('should regenerate client types when external clientDir .graphql file changes', async () => {
154+
// Verify initial state - no GetUserDetails
155+
const initialContent = readFileSync(clientTypesPath, 'utf-8')
156+
expect(initialContent).not.toContain('GetUserDetails')
157+
158+
// Modify the query file in the external clientDir
159+
writeFileSync(clientQueryPath, updatedQuery, 'utf-8')
160+
161+
// Wait for file watcher to detect change and regenerate types
162+
// File watcher has 150ms debounce + processing time
163+
const found = await waitForFileChange(clientTypesPath, 'GetUserDetails', 5000)
164+
165+
expect(found).toBe(true)
166+
167+
// Verify the new query type is present
168+
const updatedContent = readFileSync(clientTypesPath, 'utf-8')
169+
expect(updatedContent).toContain('GetUserDetails')
170+
expect(updatedContent).toContain('GetUser')
171+
expect(updatedContent).toContain('GetHello')
172+
}, 10000)
173+
174+
it('should NOT trigger server type regeneration for client-only changes', () => {
175+
// Server types should remain unchanged when only client files change
176+
const currentServerContent = readFileSync(serverTypesPath, 'utf-8')
177+
178+
// The content should be the same (server types shouldn't have been regenerated)
179+
// Note: We're comparing content, not timestamps, because content is what matters
180+
expect(currentServerContent).toContain('Query')
181+
expect(currentServerContent).toContain('User')
182+
183+
// Verify the structure is the same as initial
184+
expect(currentServerContent).toBe(initialServerTypesContent)
185+
})
186+
})

0 commit comments

Comments
 (0)