Skip to content

Commit f79c09b

Browse files
Merge pull request #9 from chrisreddington/refactoring
Refactor and enhance test coverage for devcontainer validation utilities
2 parents 2b1615b + 67e9ac5 commit f79c09b

11 files changed

Lines changed: 692 additions & 338 deletions

File tree

__tests__/main.test.ts

Lines changed: 198 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -1,163 +1,235 @@
11
import * as core from '@actions/core'
2-
import {
3-
DevcontainerContent,
4-
validateExtensions,
5-
validateTasks,
6-
validateFeatures,
7-
run
8-
} from '../src/main'
9-
10-
// Mock fs module with constants and promises
2+
import * as fs from 'fs'
3+
import { run } from '../src/main'
4+
5+
jest.mock('@actions/core')
116
jest.mock('fs', () => ({
12-
existsSync: jest.fn(),
13-
readFileSync: jest.fn(),
147
promises: {
15-
access: jest.fn().mockResolvedValue(undefined),
16-
readFile: jest.fn().mockResolvedValue(
17-
JSON.stringify({
18-
customizations: {
19-
vscode: {
20-
extensions: ['ext1', 'ext2']
21-
}
22-
},
23-
tasks: {
24-
build: 'npm run build',
25-
test: 'npm test',
26-
run: 'npm start'
27-
},
28-
features: {
29-
'ghcr.io/devcontainers/features/github-cli:1': {},
30-
'ghcr.io/devcontainers-contrib/features/prettier:1': {}
31-
}
32-
})
33-
),
34-
appendFile: jest.fn(),
35-
writeFile: jest.fn()
8+
access: jest.fn(),
9+
readFile: jest.fn()
3610
},
3711
constants: {
38-
O_RDONLY: 0,
39-
O_WRONLY: 1,
40-
O_RDWR: 2,
41-
S_IFMT: 0o170000,
42-
S_IFREG: 0o100000,
43-
S_IFDIR: 0o040000,
44-
S_IFCHR: 0o020000,
45-
S_IFBLK: 0o060000,
46-
S_IFIFO: 0o010000,
47-
S_IFLNK: 0o120000,
48-
S_IFSOCK: 0o140000,
49-
F_OK: 0,
50-
R_OK: 4,
51-
W_OK: 2,
52-
X_OK: 1
12+
O_RDONLY: 0
5313
}
5414
}))
5515

56-
const getInputMock = jest.spyOn(core, 'getInput').mockImplementation()
57-
const setFailedMock = jest.spyOn(core, 'setFailed').mockImplementation()
58-
const infoMock = jest.spyOn(core, 'info').mockImplementation()
59-
60-
const mockDevcontainer: DevcontainerContent = {
61-
customizations: {
62-
vscode: {
63-
extensions: ['ext1', 'ext2']
64-
}
65-
},
66-
tasks: {
67-
build: 'npm run build',
68-
test: 'npm test',
69-
run: 'npm start'
70-
},
71-
features: {
72-
'ghcr.io/devcontainers/features/github-cli:1': {},
73-
'ghcr.io/devcontainers-contrib/features/prettier:1': {}
74-
}
75-
}
76-
77-
describe('validate-devcontainer', () => {
16+
describe('run', () => {
7817
beforeEach(() => {
7918
jest.clearAllMocks()
8019
})
8120

82-
test('validates extensions', () => {
83-
const requiredExtensions = ['ext1', 'ext3']
84-
const result = validateExtensions(mockDevcontainer, requiredExtensions)
85-
expect(result).toBeDefined()
86-
})
87-
88-
test('validates tasks', () => {
89-
const result = validateTasks(mockDevcontainer)
90-
expect(result).toBeDefined()
91-
})
92-
93-
test('validates features', () => {
94-
const requiredFeatures = [
95-
'ghcr.io/devcontainers/features/github-cli:1',
96-
'ghcr.io/devcontainers-contrib/features/prettier:1',
97-
'ghcr.io/devcontainers/features/docker-in-docker:1'
98-
]
99-
const result = validateFeatures(mockDevcontainer, requiredFeatures)
100-
expect(result).toBeDefined()
101-
})
21+
test('should validate successfully with valid devcontainer.json', async () => {
22+
const mockContent = {
23+
customizations: {
24+
vscode: {
25+
extensions: ['required-ext']
26+
}
27+
},
28+
tasks: {
29+
build: 'npm run build',
30+
test: 'npm test',
31+
run: 'npm start'
32+
}
33+
}
10234

103-
test('runs validation', async () => {
104-
getInputMock.mockImplementation(name => {
35+
// Mock inputs and file operations
36+
jest.spyOn(core, 'getInput').mockImplementation(name => {
10537
switch (name) {
10638
case 'required-extensions':
107-
return 'ext1,ext2'
108-
case 'devcontainer-path':
109-
return 'path/to/devcontainer.json'
39+
return 'required-ext'
11040
case 'validate-tasks':
11141
return 'true'
112-
case 'required-features':
113-
return 'ghcr.io/devcontainers/features/github-cli:1,ghcr.io/devcontainers-contrib/features/prettier:1'
11442
default:
11543
return ''
11644
}
11745
})
46+
;(fs.promises.access as jest.Mock).mockResolvedValue(undefined)
47+
;(fs.promises.readFile as jest.Mock).mockResolvedValue(
48+
JSON.stringify(mockContent)
49+
)
11850

11951
await run()
120-
expect(setFailedMock).not.toHaveBeenCalled()
121-
expect(infoMock).toHaveBeenCalledWith('All validations passed successfully')
52+
expect(core.setFailed).not.toHaveBeenCalled()
12253
})
12354

124-
test('does not call validateFeatures when required-features is an empty string', async () => {
125-
getInputMock.mockImplementation(name => {
126-
switch (name) {
127-
case 'required-extensions':
128-
return 'ext1,ext2'
129-
case 'devcontainer-path':
130-
return 'path/to/devcontainer.json'
131-
case 'validate-tasks':
132-
return 'true'
133-
case 'required-features':
134-
return ''
135-
default:
136-
return ''
55+
test('should throw error when devcontainer.json is not found', async () => {
56+
jest.spyOn(core, 'getInput').mockImplementation(name => {
57+
if (name === 'required-extensions') return 'ext1'
58+
return ''
59+
})
60+
;(fs.promises.access as jest.Mock).mockRejectedValue(
61+
new Error('File not found')
62+
)
63+
64+
await run()
65+
expect(core.setFailed).toHaveBeenCalledWith(
66+
expect.stringContaining('devcontainer.json not found')
67+
)
68+
})
69+
70+
test('should throw error when JSON is invalid', async () => {
71+
jest.spyOn(core, 'getInput').mockReturnValue('ext1')
72+
;(fs.promises.access as jest.Mock).mockResolvedValue(undefined)
73+
;(fs.promises.readFile as jest.Mock).mockResolvedValue('invalid json')
74+
75+
await run()
76+
expect(core.setFailed).toHaveBeenCalledWith(
77+
expect.stringContaining('Invalid JSON')
78+
)
79+
})
80+
81+
test('should throw error when devcontainer structure is invalid', async () => {
82+
const invalidContent = {
83+
customizations: {
84+
vscode: {
85+
extensions: 'not-an-array'
86+
}
87+
}
88+
}
89+
90+
jest.spyOn(core, 'getInput').mockReturnValue('ext1')
91+
;(fs.promises.access as jest.Mock).mockResolvedValue(undefined)
92+
;(fs.promises.readFile as jest.Mock).mockResolvedValue(
93+
JSON.stringify(invalidContent)
94+
)
95+
96+
await run()
97+
expect(core.setFailed).toHaveBeenCalledWith(
98+
'Invalid devcontainer.json structure'
99+
)
100+
})
101+
102+
test('should throw error when required extensions are missing', async () => {
103+
const content = {
104+
customizations: {
105+
vscode: {
106+
extensions: ['ext1']
107+
}
137108
}
109+
}
110+
111+
jest.spyOn(core, 'getInput').mockImplementation(name => {
112+
if (name === 'required-extensions') return 'ext1,ext2'
113+
return ''
138114
})
115+
;(fs.promises.access as jest.Mock).mockResolvedValue(undefined)
116+
;(fs.promises.readFile as jest.Mock).mockResolvedValue(
117+
JSON.stringify(content)
118+
)
139119

140120
await run()
141-
expect(setFailedMock).not.toHaveBeenCalled()
142-
expect(infoMock).toHaveBeenCalledWith('All validations passed successfully')
121+
expect(core.setFailed).toHaveBeenCalledWith(
122+
expect.stringContaining('Missing required extensions')
123+
)
143124
})
144125

145-
test('does not call validateFeatures when required-features is not present', async () => {
146-
getInputMock.mockImplementation(name => {
147-
switch (name) {
148-
case 'required-extensions':
149-
return 'ext1,ext2'
150-
case 'devcontainer-path':
151-
return 'path/to/devcontainer.json'
152-
case 'validate-tasks':
153-
return 'true'
154-
default:
155-
return ''
126+
test('should validate features when required-features input is provided', async () => {
127+
const content = {
128+
customizations: {
129+
vscode: {
130+
extensions: ['ext1']
131+
}
132+
},
133+
features: {
134+
feature1: {}
156135
}
136+
}
137+
138+
jest.spyOn(core, 'getInput').mockImplementation(name => {
139+
if (name === 'required-extensions') return 'ext1'
140+
if (name === 'required-features') return 'feature1,feature2'
141+
return ''
157142
})
143+
;(fs.promises.access as jest.Mock).mockResolvedValue(undefined)
144+
;(fs.promises.readFile as jest.Mock).mockResolvedValue(
145+
JSON.stringify(content)
146+
)
158147

159148
await run()
160-
expect(setFailedMock).not.toHaveBeenCalled()
161-
expect(infoMock).toHaveBeenCalledWith('All validations passed successfully')
149+
expect(core.setFailed).toHaveBeenCalledWith(
150+
expect.stringContaining('Missing required features')
151+
)
152+
})
153+
154+
test('should handle task validation when enabled', async () => {
155+
const content = {
156+
customizations: {
157+
vscode: {
158+
extensions: ['ext1']
159+
}
160+
},
161+
tasks: {
162+
build: 'npm build'
163+
// missing test and run tasks
164+
}
165+
}
166+
167+
jest.spyOn(core, 'getInput').mockImplementation(name => {
168+
if (name === 'required-extensions') return 'ext1'
169+
if (name === 'validate-tasks') return 'true'
170+
return ''
171+
})
172+
;(fs.promises.access as jest.Mock).mockResolvedValue(undefined)
173+
;(fs.promises.readFile as jest.Mock).mockResolvedValue(
174+
JSON.stringify(content)
175+
)
176+
177+
await run()
178+
expect(core.setFailed).toHaveBeenCalledWith(
179+
expect.stringContaining('Missing or invalid required tasks')
180+
)
181+
})
182+
183+
test('should skip feature validation when required-features input is empty', async () => {
184+
const content = {
185+
customizations: {
186+
vscode: {
187+
extensions: ['ext1']
188+
}
189+
}
190+
}
191+
192+
jest.spyOn(core, 'getInput').mockImplementation(name => {
193+
if (name === 'required-extensions') return 'ext1'
194+
if (name === 'required-features') return ''
195+
return ''
196+
})
197+
;(fs.promises.access as jest.Mock).mockResolvedValue(undefined)
198+
;(fs.promises.readFile as jest.Mock).mockResolvedValue(
199+
JSON.stringify(content)
200+
)
201+
202+
await run()
203+
expect(core.setFailed).not.toHaveBeenCalled()
204+
})
205+
206+
test('should handle unknown errors correctly', async () => {
207+
// Create an object that matches Error interface but isn't an Error instance
208+
const errorLike = {
209+
name: 'CustomError',
210+
message: 'Custom error message',
211+
toString(): string {
212+
return this.message
213+
}
214+
} as Error
215+
216+
jest.spyOn(core, 'getInput').mockImplementation(() => {
217+
throw errorLike
218+
})
219+
220+
await run()
221+
expect(core.setFailed).toHaveBeenCalledWith('An unknown error occurred')
222+
})
223+
224+
test('should handle string errors correctly', async () => {
225+
jest.spyOn(core, 'getInput').mockReturnValue('ext1')
226+
;(fs.promises.access as jest.Mock).mockResolvedValue(undefined)
227+
;(fs.promises.readFile as jest.Mock).mockRejectedValue(
228+
'String error message'
229+
)
230+
231+
await run()
232+
233+
expect(core.setFailed).toHaveBeenCalledWith('String error message')
162234
})
163235
})

__tests__/test-utils.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { DevcontainerContent } from '../src/types'
2+
3+
export function createMockDevcontainer(
4+
options: Partial<DevcontainerContent> = {}
5+
): DevcontainerContent {
6+
return {
7+
customizations: {
8+
vscode: {
9+
extensions: []
10+
}
11+
},
12+
tasks: {
13+
build: 'npm run build',
14+
test: 'npm test',
15+
run: 'npm start'
16+
},
17+
features: {},
18+
...options
19+
}
20+
}

0 commit comments

Comments
 (0)