Skip to content

Commit 672c101

Browse files
committed
test: assert config edits hold their invariants across config shapes
1 parent 14903e5 commit 672c101

1 file changed

Lines changed: 179 additions & 0 deletions

File tree

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import{mkdir,mkdtemp,readFile,realpath,symlink,writeFile}from'node:fs/promises'
2+
import{tmpdir}from'node:os'
3+
import{fileURLToPath}from'node:url'
4+
5+
import{dirname,join}from'pathe'
6+
import{parseSync}from'rolldown/utils'
7+
import{afterAll,afterEach,describe,expect,it,vi}from'vitest'
8+
9+
import{addNuxtConfigEntries,readNuxtConfig,removeNuxtConfigEntries}from'../../../src/utils/config'
10+
11+
constrolldownPath=dirname(fileURLToPath(import.meta.resolve('rolldown/package.json')))
12+
13+
interfaceShape{
14+
name: string
15+
source: string
16+
modules: string[]
17+
}
18+
19+
constWRAPPERS: [name: string,wrap: (object: string)=>string][]=[
20+
['defineNuxtConfig',object=>`export default defineNuxtConfig(${object})`],
21+
['plain object',object=>`export default ${object}`],
22+
['satisfies',object=>`export default defineNuxtConfig(${object}) satisfies NuxtConfig`],
23+
['as const',object=>`export default defineNuxtConfig(${object}) as NuxtConfig`],
24+
]
25+
26+
constENTRIES: [name: string,entries: string[],modules: string[]][]=[
27+
['no modules key',[],[]],
28+
['empty array',[],[]],
29+
['one entry',['@nuxt/eslint'],['@nuxt/eslint']],
30+
['three entries',['@nuxt/eslint','@nuxt/image','@nuxt/fonts'],['@nuxt/eslint','@nuxt/image','@nuxt/fonts']],
31+
['array-form entry',['[\'@nuxt/image\', { quality: 80 }]'],['@nuxt/image']],
32+
['mixed entries',['@nuxt/eslint','[\'@nuxt/image\', { quality: 80 }]'],['@nuxt/eslint','@nuxt/image']],
33+
]
34+
35+
constLAYOUTS: [name: string,render: (entries: string[],quote: string,indent: string,trailingComma: boolean)=>string][]=[
36+
['inline',(entries,quote,_indent,trailingComma)=>`[${entries.map(entry=>quoted(entry,quote)).join(', ')}${entries.length&&trailingComma ? ',' : ''}]`],
37+
['multi-line',(entries,quote,indent,trailingComma)=>entries.length===0
38+
? '[]'
39+
: `[\n${entries.map(entry=>`${indent}${indent}${quoted(entry,quote)}`).join(',\n')}${trailingComma ? ',' : ''}\n${indent}]`],
40+
]
41+
42+
functionquoted(entry: string,quote: string): string{
43+
returnentry.startsWith('[') ? entry.replaceAll('\'',quote) : `${quote}${entry}${quote}`
44+
}
45+
46+
function*shapes(): Generator<Shape>{
47+
for(const[wrapperName,wrap]ofWRAPPERS){
48+
for(const[entriesName,entries,modules]ofENTRIES){
49+
for(const[layoutName,render]ofLAYOUTS){
50+
for(constquoteof['\'','"']){
51+
for(constindentof[' ',' ','\t']){
52+
for(consttrailingCommaof[true,false]){
53+
consthasKey=entriesName!=='no modules key'
54+
constbody=[
55+
`${indent}ssr: true,`,
56+
hasKey ? `${indent}modules: ${render(entries,quote,indent,trailingComma)},` : '',
57+
`${indent}// keep me`,
58+
`${indent}devtools: { enabled: true },`,
59+
].filter(Boolean).join('\n')
60+
yield{
61+
name: `${wrapperName} / ${entriesName} / ${layoutName} / ${quote==='\'' ? 'single' : 'double'} / ${indent==='\t' ? 'tab' : `${indent.length} spaces`} / ${trailingComma ? 'trailing comma' : 'no trailing comma'}`,
62+
source: `${wrap(`{\n${body}\n}`)}\n`,
63+
modules,
64+
}
65+
}
66+
}
67+
}
68+
}
69+
}
70+
}
71+
}
72+
73+
/** Read the `modules` list straight from the parser, independently of the editor. */
74+
functionreadModules(source: string): string[]{
75+
const{ program, errors }=parseSync('nuxt.config.ts',source)
76+
if(errors.length){
77+
thrownewError(`config no longer parses: ${JSON.stringify(errors[0])}`)
78+
}
79+
constexported=(program.bodyasany[]).find(node=>node.type==='ExportDefaultDeclaration')
80+
letobject=exported?.declaration
81+
while(object&&['TSAsExpression','TSSatisfiesExpression','ParenthesizedExpression'].includes(object.type)){
82+
object=object.expression
83+
}
84+
if(object?.type==='CallExpression'){
85+
object=object.arguments[0]
86+
}
87+
constproperty=(object?.propertiesasany[]|undefined)?.find(entry=>entry.type==='Property'&&entry.key?.name==='modules')
88+
if(!property){
89+
return[]
90+
}
91+
return(property.value.elementsasany[]).map((element)=>{
92+
consttarget=element?.type==='ArrayExpression' ? element.elements[0] : element
93+
returntarget?.valueasstring
94+
})
95+
}
96+
97+
constdirectories: string[]=[]
98+
99+
asyncfunctioncreateProject(parser: boolean): Promise<string>{
100+
constcwd=awaitrealpath(awaitmkdtemp(join(tmpdir(),'nuxi-config-property-')))
101+
directories.push(cwd)
102+
if(parser){
103+
awaitmkdir(join(cwd,'node_modules'),{recursive: true})
104+
awaitsymlink(rolldownPath,join(cwd,'node_modules/rolldown'),'dir')
105+
}
106+
returncwd
107+
}
108+
109+
afterEach(()=>{
110+
vi.unstubAllEnvs()
111+
})
112+
113+
afterAll(async()=>{
114+
const{ rm }=awaitimport('node:fs/promises')
115+
awaitPromise.all(directories.splice(0).map(directory=>rm(directory,{recursive: true,force: true})))
116+
})
117+
118+
describe.each([
119+
['scanner',false],
120+
['parser',true],
121+
])('config editing invariants (%s)',(engine,useParser)=>{
122+
it('should preserve the module list through an add and a remove',async()=>{
123+
if(!useParser){
124+
vi.stubEnv('NUXT_CLI_PARSER','scanner')
125+
}
126+
constcwd=awaitcreateProject(useParser)
127+
constfile=join(cwd,'nuxt.config.ts')
128+
constrefused: string[]=[]
129+
letchecked=0
130+
131+
for(constshapeofshapes()){
132+
awaitwriteFile(file,shape.source,'utf8')
133+
134+
letconfig
135+
try{
136+
config=awaitreadNuxtConfig(cwd)
137+
awaitaddNuxtConfigEntries(config!,{modules: ['@nuxt/test-utils','@nuxt/eslint']})
138+
}
139+
catch(error){
140+
refused.push(`${shape.name}: ${(errorasError).message}`)
141+
expect(awaitreadFile(file,'utf8'),shape.name).toBe(shape.source)
142+
continue
143+
}
144+
145+
constadded=[...newSet([...shape.modules,'@nuxt/test-utils','@nuxt/eslint'])]
146+
constafterAdd=awaitreadFile(file,'utf8')
147+
expect(readModules(afterAdd),`add: ${shape.name}`).toEqual(added)
148+
expect(afterAdd,`add: ${shape.name}`).toContain('// keep me')
149+
expect(afterAdd,`add: ${shape.name}`).toContain('ssr: true')
150+
151+
awaitremoveNuxtConfigEntries((awaitreadNuxtConfig(cwd))!,{modules: ['@nuxt/test-utils','@nuxt/fonts']})
152+
153+
constafterRemove=awaitreadFile(file,'utf8')
154+
expect(readModules(afterRemove),`remove: ${shape.name}`).toEqual(added.filter(name=>name!=='@nuxt/test-utils'&&name!=='@nuxt/fonts'))
155+
expect(afterRemove,`remove: ${shape.name}`).toContain('devtools: { enabled: true }')
156+
checked++
157+
}
158+
159+
expect(checked,`${engine} refusals: ${refused.join('\n')}`).toBeGreaterThan(0)
160+
expect(refused).toEqual([])
161+
},60_000)
162+
163+
it('should keep CRLF endings and a nested array entry intact',async()=>{
164+
if(!useParser){
165+
vi.stubEnv('NUXT_CLI_PARSER','scanner')
166+
}
167+
constcwd=awaitcreateProject(useParser)
168+
constfile=join(cwd,'nuxt.config.ts')
169+
constsource='export default defineNuxtConfig({\r\n modules: [\r\n [\'@nuxt/image\', { quality: 80 }],\r\n ],\r\n})\r\n'
170+
awaitwriteFile(file,source,'utf8')
171+
172+
awaitaddNuxtConfigEntries((awaitreadNuxtConfig(cwd))!,{modules: ['@nuxt/fonts']})
173+
174+
constafter=awaitreadFile(file,'utf8')
175+
expect(after.split('\n').every(line=>line===''||line.endsWith('\r'))).toBe(true)
176+
expect(after).toContain('{ quality: 80 }')
177+
expect(readModules(after)).toEqual(['@nuxt/image','@nuxt/fonts'])
178+
})
179+
})

0 commit comments

Comments
 (0)