Skip to content

Commit 5f1d903

Browse files
👷 parallelize test app builds (#4567)
1 parent 1a19e42 commit 5f1d903

4 files changed

Lines changed: 118 additions & 34 deletions

File tree

‎.gitlab-ci.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ e2e:
231231
extends:
232232
- .base-configuration
233233
- .test-allowed-branches
234+
- .resource-allocation-4-cpus
234235
interruptible: true
235236
artifacts:
236237
when: always

‎eslint-local-rules/secureCommandExecution.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ function isCommandExecuted(node) {
5050
currentMethodCall = currentMethodCall.parent.parent
5151
}
5252

53-
return methodCallNames.includes('run')
53+
return methodCallNames.includes('run') || methodCallNames.includes('runAsync')
5454
}
5555

5656
function isCommandContainsShellCharacters(node) {

‎scripts/build/build-test-apps.ts‎

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ const APPS: AppConfig[] = [
3333
// React Router apps
3434
{ name: 'react-router-v6-app' },
3535
{ name: 'tanstack-router-app' },
36-
{ name: 'react-router-v7-app', builderFn: buildReactRouterv7App },
36+
{ name: 'react-router-v7-app', builderFn: buildReactRouterv7App, deps: ['react-router-v6-app'] },
3737

3838
// browser extensions
3939
{ name: 'base-extension' },
@@ -79,22 +79,29 @@ runMain(async () => {
7979
printLog('Packing packages...')
8080
command`yarn run pack`.run()
8181

82-
const built = new Set<string>()
83-
for (const app of appsToBuild) {
84-
for (const dep of app.deps ?? []) {
85-
if (!built.has(dep)) {
86-
buildApp(dep)
87-
built.add(dep)
88-
}
82+
const buildPromises = new Map<string, Promise<void>>()
83+
84+
function ensureBuild(app: AppConfig): Promise<void> {
85+
let promise = buildPromises.get(app.name)
86+
if (!promise) {
87+
promise = (async () => {
88+
// Ensure all dependencies are built
89+
const dependenciesToBuild = (app.deps ?? []).map((name) => APPS.find((a) => a.name === name)!)
90+
await Promise.all(dependenciesToBuild.map(ensureBuild))
91+
92+
if ('builderFn' in app) {
93+
await app.builderFn(app.name, app.options)
94+
} else {
95+
await buildApp(app.name)
96+
}
97+
})()
98+
buildPromises.set(app.name, promise)
8999
}
90-
if ('builderFn' in app) {
91-
await app.builderFn(app.name, app.options)
92-
} else {
93-
buildApp(app.name)
94-
}
95-
built.add(app.name)
100+
return promise
96101
}
97102

103+
await Promise.all(appsToBuild.map(ensureBuild))
104+
98105
printLog('Test apps and extensions built successfully.')
99106
})
100107

@@ -112,10 +119,10 @@ function showHelpAndExit() {
112119
process.exit(0)
113120
}
114121

115-
function buildApp(appName: string) {
122+
async function buildApp(appName: string) {
116123
const appPath = `test/apps/${appName}`
117124
printLog(`Building app at ${appPath}...`)
118-
command`yarn install --no-immutable`.withCurrentWorkingDirectory(appPath).run()
125+
await command`yarn install --no-immutable`.withCurrentWorkingDirectory(appPath).runAsync()
119126

120127
// install peer dependencies if any
121128
// intent: renovate does not allow to generate local packages before install
@@ -126,16 +133,18 @@ function buildApp(appName: string) {
126133
for (const [name] of Object.entries(packageJson.peerDependencies)) {
127134
const resolution = packageJson.resolutions?.[name]
128135
const specifier = resolution ? `${name}@${resolution}` : name
129-
command`yarn add -D ${specifier}`.withCurrentWorkingDirectory(appPath).run()
136+
await command`yarn add -D ${specifier}`.withCurrentWorkingDirectory(appPath).runAsync()
130137
}
131138
// revert package.json & yarn.lock changes if they are versioned
132-
const areFilesVersioned = command`git ls-files package.json yarn.lock`.withCurrentWorkingDirectory(appPath).run()
139+
const areFilesVersioned = await command`git ls-files package.json yarn.lock`
140+
.withCurrentWorkingDirectory(appPath)
141+
.runAsync()
133142
if (areFilesVersioned) {
134-
command`git checkout package.json yarn.lock`.withCurrentWorkingDirectory(appPath).run()
143+
await command`git checkout package.json yarn.lock`.withCurrentWorkingDirectory(appPath).runAsync()
135144
}
136145
}
137146

138-
command`yarn build`.withCurrentWorkingDirectory(appPath).run()
147+
await command`yarn build`.withCurrentWorkingDirectory(appPath).runAsync()
139148
}
140149

141150
async function buildReactRouterv7App() {
@@ -163,7 +172,7 @@ async function buildReactRouterv7App() {
163172
.replace('react-router-v6-app.js', 'react-router-v7-app.js')
164173
)
165174

166-
buildApp('react-router-v7-app')
175+
await buildApp('react-router-v7-app')
167176
}
168177

169178
async function buildExtension(appName: string, options?: { runAt?: string }): Promise<void> {

‎scripts/lib/command.ts‎

Lines changed: 86 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import childProcess from 'node:child_process'
2-
import { printDebug, printError } from './executionUtils.ts'
2+
import { printDebug, printError, printWarning } from './executionUtils.ts'
3+
4+
// Git operations share a single index lock, so they must not run concurrently.
5+
let gitMutex = Promise.resolve<unknown>(undefined)
36

47
interface CommandOptions {
58
cwd?: string
@@ -12,6 +15,7 @@ interface CommandBuilder {
1215
withCurrentWorkingDirectory(newCurrentWorkingDirectory: string): CommandBuilder
1316
withLogs(): CommandBuilder
1417
run(): string
18+
runAsync(): Promise<string>
1519
}
1620

1721
/**
@@ -31,6 +35,7 @@ interface CommandBuilder {
3135
export function command(...templateArguments: [TemplateStringsArray, ...any[]]): CommandBuilder {
3236
const [commandName, ...commandArguments] = parseCommandTemplateArguments(...templateArguments)
3337

38+
const formattedCommand = `${commandName} ${commandArguments.join(' ')}`
3439
let input = ''
3540
let env: Record<string, string> | undefined
3641
const extraOptions: CommandOptions = {}
@@ -57,7 +62,6 @@ export function command(...templateArguments: [TemplateStringsArray, ...any[]]):
5762
},
5863

5964
run(): string {
60-
const formattedCommand = `${commandName} ${commandArguments.join(' ')}`
6165
printDebug(`Running command: ${formattedCommand}`)
6266

6367
const commandResult = childProcess.spawnSync(commandName, commandArguments, {
@@ -68,16 +72,9 @@ export function command(...templateArguments: [TemplateStringsArray, ...any[]]):
6872
})
6973

7074
if (commandResult.status !== 0) {
71-
const formattedStderr = commandResult.stderr ? `\n---- stderr: ----\n${commandResult.stderr}\n----` : ''
72-
const formattedStdout = commandResult.stdout ? `\n---- stdout: ----\n${commandResult.stdout}\n----` : ''
73-
const exitCause =
74-
commandResult.signal !== null
75-
? ` due to signal ${commandResult.signal}`
76-
: commandResult.status !== null
77-
? ` with exit status ${commandResult.status}`
78-
: ''
79-
throw new Error(`Command failed${exitCause}: ${formattedCommand}${formattedStderr}${formattedStdout}`, {
80-
cause: commandResult.error,
75+
throw buildCommandError({
76+
formattedCommand,
77+
...commandResult,
8178
})
8279
}
8380

@@ -87,9 +84,86 @@ export function command(...templateArguments: [TemplateStringsArray, ...any[]]):
8784

8885
return commandResult.stdout
8986
},
87+
88+
runAsync(): Promise<string> {
89+
if (extraOptions.stdio === 'inherit') {
90+
printWarning(
91+
`runAsync() ignores withLogs() for command: ${formattedCommand} (running multiple commands concurrently would produce interleaved output)`
92+
)
93+
}
94+
printDebug(`Running command: ${formattedCommand}`)
95+
96+
const run = () =>
97+
new Promise<string>((resolve, reject) => {
98+
const child = childProcess.spawn(commandName, commandArguments, {
99+
env: { ...process.env, ...env },
100+
...extraOptions,
101+
stdio: 'pipe',
102+
})
103+
104+
let stdout = ''
105+
let stderr = ''
106+
107+
child.stdout.on('data', (chunk: Buffer) => {
108+
stdout += chunk.toString()
109+
})
110+
child.stderr.on('data', (chunk: Buffer) => {
111+
stderr += chunk.toString()
112+
})
113+
114+
child.on('error', (error) => {
115+
reject(buildCommandError({ formattedCommand, status: null, signal: null, stdout, stderr, error }))
116+
})
117+
child.on('close', (status, signal) => {
118+
if (status !== 0) {
119+
reject(buildCommandError({ formattedCommand, status, signal, stdout, stderr }))
120+
} else {
121+
resolve(stdout)
122+
}
123+
})
124+
})
125+
126+
if (commandName === 'git') {
127+
const result = gitMutex.then(run)
128+
gitMutex = result.then(
129+
() => {
130+
// ignore result
131+
},
132+
() => {
133+
// ignore exception
134+
}
135+
)
136+
return result
137+
}
138+
139+
return run()
140+
},
90141
}
91142
}
92143

144+
function buildCommandError({
145+
formattedCommand,
146+
status,
147+
signal,
148+
stdout,
149+
stderr,
150+
error,
151+
}: {
152+
formattedCommand: string
153+
status: number | null
154+
signal: NodeJS.Signals | null
155+
stdout: string
156+
stderr: string
157+
error?: Error
158+
}): Error {
159+
const formattedStderr = stderr ? `\n---- stderr: ----\n${stderr}\n----` : ''
160+
const formattedStdout = stdout ? `\n---- stdout: ----\n${stdout}\n----` : ''
161+
const exitCause = signal !== null ? ` due to signal ${signal}` : status !== null ? ` with exit status ${status}` : ''
162+
return new Error(`Command failed${exitCause}: ${formattedCommand}${formattedStderr}${formattedStdout}`, {
163+
cause: error,
164+
})
165+
}
166+
93167
/**
94168
* Parse template values passed to the `command` template tag, and return a list of arguments to run
95169
* the command.

0 commit comments

Comments
 (0)