Skip to content

Commit f80e767

Browse files
dennisamelingdscho
authored andcommitted
git.ts: extract spawn logic into a wrapper and add tests
Currently, it's a bit hard to ensure that the code in git.exe actually does what we expect it to do, especially considering that we're calling things like please.sh and assume that some executables are available on the PATH. Let's ensure we add some tests for these code paths, which we can easily extend with more tests if needed. Signed-off-by: Dennis Ameling <[email protected]> Signed-off-by: Johannes Schindelin <[email protected]>
1 parent 6affd57 commit f80e767

File tree

3 files changed

+139
-43
lines changed

3 files changed

+139
-43
lines changed

src/__tests__/git.test.ts

+88
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import * as fs from 'fs'
2+
import * as git from '../git'
3+
import * as spawn from '../spawn'
4+
import * as core from '@actions/core'
5+
6+
// We want to mock only the rmSync method on the fs module, and leave everything
7+
// else untouched.
8+
jest.mock('fs', () => ({
9+
...jest.requireActual('fs'),
10+
rmSync: jest.fn()
11+
}))
12+
13+
describe('git', () => {
14+
// We don't want to _actually_ spawn external commands, so we mock the function
15+
let spawnSpy: jest.SpyInstance
16+
// Capture the startGroup calls
17+
let coreSpy: jest.SpyInstance
18+
// The script calls fs.rmSync, so let's mock it and verify it was called
19+
let rmSyncSpy: jest.SpyInstance
20+
21+
beforeEach(() => {
22+
coreSpy = jest.spyOn(core, 'startGroup')
23+
spawnSpy = jest.spyOn(spawn, 'spawnAndWaitForExitCode').mockResolvedValue({
24+
// 0 is the exit code for success
25+
exitCode: 0
26+
})
27+
// We don't want to _actually_ clone the repo, so we mock the function
28+
jest.spyOn(git, 'clone').mockResolvedValue()
29+
rmSyncSpy = fs.rmSync as jest.Mocked<typeof fs>['rmSync']
30+
})
31+
32+
test('getViaGit build-installers x86_64', async () => {
33+
const flavor = 'build-installers'
34+
const architecture = 'x86_64'
35+
const outputDirectory = 'outputDirectory'
36+
const {artifactName, download} = await git.getViaGit(flavor, architecture)
37+
38+
expect(artifactName).toEqual('git-sdk-64-build-installers')
39+
40+
await download(outputDirectory, true)
41+
42+
expect(coreSpy).toHaveBeenCalledWith(`Creating ${flavor} artifact`)
43+
expect(spawnSpy).toHaveBeenCalledWith(
44+
expect.stringContaining('/bash.exe'),
45+
expect.arrayContaining([
46+
'.tmp/build-extra/please.sh',
47+
'create-sdk-artifact',
48+
`--architecture=${architecture}`,
49+
`--out=${outputDirectory}`
50+
]),
51+
expect.objectContaining({
52+
env: expect.objectContaining({
53+
// We want to ensure that the correct /bin folders are in the PATH,
54+
// so that please.sh can find git.exe
55+
// https://github.com/git-for-windows/setup-git-for-windows-sdk/issues/951
56+
PATH:
57+
expect.stringContaining('/clangarm64/bin') &&
58+
expect.stringContaining('/mingw64/bin')
59+
})
60+
})
61+
)
62+
expect(rmSyncSpy).toHaveBeenCalledWith('.tmp', {recursive: true})
63+
})
64+
65+
test('getViaGit full x86_64', async () => {
66+
const flavor = 'full'
67+
const architecture = 'x86_64'
68+
const outputDirectory = 'outputDirectory'
69+
const {artifactName, download} = await git.getViaGit(flavor, architecture)
70+
71+
expect(artifactName).toEqual('git-sdk-64-full')
72+
73+
await download(outputDirectory, true)
74+
75+
expect(coreSpy).toHaveBeenCalledWith(`Checking out git-sdk-64`)
76+
expect(spawnSpy).toHaveBeenCalledWith(
77+
expect.stringContaining('/git.exe'),
78+
expect.arrayContaining([
79+
'--git-dir=.tmp',
80+
'worktree',
81+
'add',
82+
outputDirectory
83+
]),
84+
expect.any(Object)
85+
)
86+
expect(rmSyncSpy).toHaveBeenCalledWith('.tmp', {recursive: true})
87+
})
88+
})

src/git.ts

+23-43
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as core from '@actions/core'
2-
import {ChildProcess, spawn} from 'child_process'
2+
import {spawnAndWaitForExitCode, SpawnReturnArgs} from './spawn'
33
import {Octokit} from '@octokit/rest'
44
import {delimiter} from 'path'
55
import * as fs from 'fs'
@@ -48,14 +48,14 @@ export function getArtifactMetadata(
4848
return {repo, artifactName}
4949
}
5050

51-
async function clone(
51+
export async function clone(
5252
url: string,
5353
destination: string,
5454
verbose: number | boolean,
5555
cloneExtraOptions: string[] = []
5656
): Promise<void> {
5757
if (verbose) core.info(`Cloning ${url} to ${destination}`)
58-
const child = spawn(
58+
const child = await spawnAndWaitForExitCode(
5959
gitExePath,
6060
[
6161
'clone',
@@ -69,44 +69,30 @@ async function clone(
6969
{
7070
env: {
7171
GIT_CONFIG_PARAMETERS
72-
},
73-
stdio: [undefined, 'inherit', 'inherit']
72+
}
7473
}
7574
)
76-
return new Promise<void>((resolve, reject) => {
77-
child.on('close', code => {
78-
if (code === 0) {
79-
resolve()
80-
} else {
81-
reject(new Error(`git clone: exited with code ${code}`))
82-
}
83-
})
84-
})
75+
if (child.exitCode !== 0) {
76+
throw new Error(`git clone: exited with code ${child.exitCode}`)
77+
}
8578
}
8679

8780
async function updateHEAD(
8881
bareRepositoryPath: string,
8982
headSHA: string
9083
): Promise<void> {
91-
const child = spawn(
84+
const child = await spawnAndWaitForExitCode(
9285
gitExePath,
9386
['--git-dir', bareRepositoryPath, 'update-ref', 'HEAD', headSHA],
9487
{
9588
env: {
9689
GIT_CONFIG_PARAMETERS
97-
},
98-
stdio: [undefined, 'inherit', 'inherit']
90+
}
9991
}
10092
)
101-
return new Promise<void>((resolve, reject) => {
102-
child.on('close', code => {
103-
if (code === 0) {
104-
resolve()
105-
} else {
106-
reject(new Error(`git: exited with code ${code}`))
107-
}
108-
})
109-
})
93+
if (child.exitCode !== 0) {
94+
throw new Error(`git: exited with code ${child.exitCode}`)
95+
}
11096
}
11197

11298
export async function getViaGit(
@@ -175,17 +161,16 @@ export async function getViaGit(
175161
])
176162
core.endGroup()
177163

178-
let child: ChildProcess
164+
let child: SpawnReturnArgs
179165
if (flavor === 'full') {
180166
core.startGroup(`Checking out ${repo}`)
181-
child = spawn(
167+
child = await spawnAndWaitForExitCode(
182168
gitExePath,
183169
[`--git-dir=.tmp`, 'worktree', 'add', outputDirectory, head_sha],
184170
{
185171
env: {
186172
GIT_CONFIG_PARAMETERS
187-
},
188-
stdio: [undefined, 'inherit', 'inherit']
173+
}
189174
}
190175
)
191176
} else {
@@ -200,7 +185,7 @@ export async function getViaGit(
200185

201186
core.startGroup(`Creating ${flavor} artifact`)
202187
const traceArg = verbose ? ['-x'] : []
203-
child = spawn(
188+
child = await spawnAndWaitForExitCode(
204189
`${gitForWindowsUsrBinPath}/bash.exe`,
205190
[
206191
...traceArg,
@@ -221,21 +206,16 @@ export async function getViaGit(
221206
CHERE_INVOKING: '1',
222207
MSYSTEM: 'MINGW64',
223208
PATH: `${gitForWindowsBinPaths.join(delimiter)}${delimiter}${process.env.PATH}`
224-
},
225-
stdio: [undefined, 'inherit', 'inherit']
209+
}
226210
}
227211
)
228212
}
229-
return new Promise<void>((resolve, reject) => {
230-
child.on('close', code => {
231-
core.endGroup()
232-
if (code === 0) {
233-
fs.rm('.tmp', {recursive: true}, () => resolve())
234-
} else {
235-
reject(new Error(`process exited with code ${code}`))
236-
}
237-
})
238-
})
213+
core.endGroup()
214+
if (child.exitCode === 0) {
215+
fs.rmSync('.tmp', {recursive: true})
216+
} else {
217+
throw new Error(`process exited with code ${child.exitCode}`)
218+
}
239219
}
240220
}
241221
}

src/spawn.ts

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import {spawn as SpawnInternal, SpawnOptions} from 'child_process'
2+
3+
export type SpawnReturnArgs = {
4+
exitCode: number | null
5+
}
6+
7+
/**
8+
* Simple wrapper around NodeJS's "child_process.spawn" function.
9+
* Since we only use the exit code, we only expose that.
10+
*/
11+
export async function spawnAndWaitForExitCode(
12+
command: string,
13+
args: readonly string[],
14+
options: SpawnOptions
15+
): Promise<SpawnReturnArgs> {
16+
const child = SpawnInternal(command, args, {
17+
...options,
18+
// 'inherit' means that the child process will use the same stdio/stderr as the parent process
19+
stdio: [undefined, 'inherit', 'inherit']
20+
})
21+
22+
return new Promise<SpawnReturnArgs>((resolve, reject) => {
23+
child.on('error', reject)
24+
child.on('close', code => {
25+
resolve({exitCode: code})
26+
})
27+
})
28+
}

0 commit comments

Comments
 (0)