run.ts•9.68 kB
import os from 'node:os'
import { finished } from 'node:stream/promises'
import { arg } from '@prisma/internals'
import { createReadStream, existsSync } from 'fs'
import fs from 'fs/promises'
import glob from 'globby'
import path from 'path'
import { $, ProcessOutput, sleep } from 'zx'
const monorepoRoot = path.resolve(__dirname, '..', '..', '..', '..', '..')
const e2eRoot = path.join(monorepoRoot, 'packages', 'client', 'tests', 'e2e')
const args = arg(
process.argv.slice(2),
{
// see which commands are run and the outputs of the failures
'--verbose': Boolean,
// like run jest in band, useful for debugging and CI
'--runInBand': Boolean,
'--run-in-band': '--runInBand',
// do not fully pack cli and client packages before packing
'--skipPack': Boolean,
'--skip-pack': '--skipPack',
// a way to cleanup created files that also works on linux
'--clean': Boolean,
// number of workers to use for parallel tests
'--maxWorkers': Number,
'--max-workers': '--maxWorkers',
},
true,
true,
)
async function main() {
if (args instanceof Error) {
console.log(args.message)
process.exit(1)
}
args['--maxWorkers'] = args['--maxWorkers'] ?? os.cpus().length
args['--runInBand'] = args['--runInBand'] ?? false
args['--skipPack'] = args['--skipPack'] ?? false
args['--verbose'] = args['--verbose'] ?? false
args['--clean'] = args['--clean'] ?? false
$.verbose = args['--verbose']
if (args['--verbose'] === true) {
await $`docker -v`
}
console.log('🧹 Cleaning up old files')
if (args['--clean'] === true) {
await $`docker compose -f ${__dirname}/docker-compose-clean.yml down --remove-orphans`
await $`docker compose -f ${__dirname}/docker-compose-clean.yml up clean`
}
console.log('🎠 Preparing e2e tests')
let allPackageFolderNames = await fs.readdir(path.join(monorepoRoot, 'packages'))
allPackageFolderNames = allPackageFolderNames.filter((p) => !p.includes('DS_Store'))
const prismaTmpDir = path.join(os.homedir(), '.local', 'share', 'prisma-tmp')
if (args['--skipPack'] === false) {
// this process will need to modify some package.json, we save copies
await $`pnpm -r exec cp package.json package.copy.json`
// we prepare to replace references to local packages with their tarballs names
const localPackageNames = [...allPackageFolderNames.map((p) => `@prisma/${p}`), 'prisma']
const allPackageFolders = allPackageFolderNames.map((p) => path.join(monorepoRoot, 'packages', p))
const allPkgJsonPaths = allPackageFolders.map((p) => path.join(p, 'package.json'))
const allPkgJson = allPkgJsonPaths.map((p) => require(p))
// replace references to unbundled local packages with built and packaged tarballs
for (let i = 0; i < allPkgJson.length; i++) {
for (const key of Object.keys(allPkgJson[i].dependencies ?? {})) {
if (localPackageNames.includes(key)) {
allPkgJson[i].dependencies[key] = `/tmp/${key.replace('@prisma/', 'prisma-')}-0.0.0.tgz`
}
}
await fs.writeFile(allPkgJsonPaths[i], JSON.stringify(allPkgJson[i], null, 2))
}
await $`pnpm -r --parallel exec pnpm pack --pack-destination ${prismaTmpDir}/`
await restoreOriginalState()
}
console.log('🐳 Starting tests in docker')
// tarball was created, ready to send it to docker and begin e2e tests
const testStepFiles = await glob(['../**/_steps.ts', '../**/_steps.cts'], { cwd: __dirname })
let e2eTestNames = testStepFiles.map((p) => path.relative('..', path.dirname(p)))
if (args._.length > 0) {
e2eTestNames = e2eTestNames.filter((p) => args._.some((a) => p.includes(a)))
}
const dockerVolumes = [
`${prismaTmpDir}/prisma-0.0.0.tgz:/tmp/prisma-0.0.0.tgz`, // hardcoded because folder doesn't match name
...allPackageFolderNames.map((p) => `${prismaTmpDir}/prisma-${p}-0.0.0.tgz:/tmp/prisma-${p}-0.0.0.tgz`),
`${path.join(monorepoRoot, 'packages', 'engines')}:/engines`,
`${path.join(monorepoRoot, 'packages', 'client')}:/client`,
`${e2eRoot}:/e2e`,
`${path.join(e2eRoot, '.cache')}:/root/.cache`,
`${(await $`pnpm store path`.quiet()).stdout.trim()}:/root/.local/share/pnpm/store/v3`,
]
const dockerVolumeArgs = dockerVolumes.flatMap((v) => ['-v', v])
await $`docker compose -f ${__dirname}/docker-compose.yaml build test-e2e`
const dockerJobs = e2eTestNames.map((testPath) => {
const composeFileArgs = ['-f', `${__dirname}/docker-compose.yaml`]
const localComposePath = path.join(e2eRoot, testPath, 'docker-compose.yaml')
if (existsSync(localComposePath)) {
composeFileArgs.push('-f', localComposePath)
}
const projectName = testPath.toLocaleLowerCase().replace(/[^0-9a-z_-]/g, '-')
const networkName = `${projectName}_default`
return async () => {
const result =
await $`docker compose ${composeFileArgs} -p ${projectName} run --rm ${dockerVolumeArgs} -e "NAME=${testPath}" test-e2e`.nothrow()
await $`docker compose ${composeFileArgs} -p ${projectName} logs > ${path.join(
e2eRoot,
testPath,
'LOGS.docker.txt',
)}`
await $`docker compose ${composeFileArgs} -p ${projectName} stop`
await $`docker compose ${composeFileArgs} -p ${projectName} rm -f`
await $`docker network rm -f ${networkName}`
return result
}
})
const jobResults: (ProcessOutput & { name: string })[] = []
if (args['--runInBand'] === true) {
console.log('🏃 Running tests in band')
for (const [i, job] of dockerJobs.entries()) {
console.log(`💡 Running test ${i + 1}/${dockerJobs.length}`)
jobResults.push(Object.assign(await job(), { name: e2eTestNames[i] }))
}
} else {
console.log('🏃 Running tests in parallel')
const pendingJobResults = [] as Promise<void>[]
const semaphore = new Semaphore(args['--maxWorkers'])
for (const [i, job] of dockerJobs.entries()) {
await semaphore.acquire()
const pendingJob = (async () => {
console.log(`💡 Running test ${i + 1}/${dockerJobs.length}`)
jobResults.push(Object.assign(await job(), { name: e2eTestNames[i] }))
})()
pendingJobResults.push(pendingJob.finally(() => semaphore.release()))
}
await Promise.allSettled(pendingJobResults)
}
const failedJobResults = jobResults.filter((r) => r.exitCode !== 0)
const passedJobResults = jobResults.filter((r) => r.exitCode === 0)
if (args['--verbose'] === true) {
for (const result of failedJobResults) {
console.log(`-----------------------------------------------------------------------`)
console.log(`🛑🛑🛑 Test "${result.name}" failed with exit code ${result.exitCode} 🛑🛑🛑`)
console.log(`\t\t⬇️ Container Log output below ⬇️\n\n`)
console.log(`-----------------------------------------------------------------------`)
const logsPath = path.resolve(__dirname, '..', result.name, 'LOGS.txt')
const dockerLogsPath = path.resolve(__dirname, '..', result.name, 'LOGS.docker.txt')
if (await isFile(logsPath)) {
await printFile(logsPath)
} else if (await isFile(dockerLogsPath)) {
await printFile(dockerLogsPath)
}
await sleep(50) // give some time for the logs to be printed (CI issue)
console.log(`-----------------------------------------------------------------------`)
console.log(`🛑 ⬆️ Container Log output of test failure "${result.name}" above ⬆️ 🛑`)
console.log(`-----------------------------------------------------------------------`)
}
}
// let the tests run and gather a list of logs for containers that have failed
if (failedJobResults.length > 0) {
const failedJobLogPaths = failedJobResults.map((result) => path.resolve(__dirname, '..', result.name, 'LOGS.txt'))
console.log(`-----------------------------------------------------------------------`)
console.log(`✅ ${passedJobResults.length}/${jobResults.length} tests passed`)
console.log(`🛑 ${failedJobResults.length}/${jobResults.length} tests failed`, failedJobLogPaths)
throw new Error('Some tests exited with a non-zero exit code')
} else {
console.log(`-----------------------------------------------------------------------`)
console.log(`✅ All ${passedJobResults.length}/${jobResults.length} tests passed`)
}
}
async function restoreOriginalState() {
if (args['--skipPack'] === false) {
await $`pnpm -r exec cp package.copy.json package.json`
}
}
async function printFile(filePath: string) {
try {
const fileStream = createReadStream(filePath)
fileStream.pipe(process.stdout, { end: false })
await finished(fileStream)
} catch (err) {
console.error(`Error trying to print log file "${filePath}":`, err)
}
}
async function isFile(filePath: string) {
try {
const stat = await fs.stat(filePath)
return stat.isFile()
} catch (e) {
if (e.code === 'ENOENT') {
return false
}
throw e
}
}
class Semaphore {
#permits: number
#waiting: Array<() => void> = []
constructor(permits: number) {
this.#permits = permits
}
async acquire(): Promise<void> {
if (this.#permits > 0) {
this.#permits--
} else {
await new Promise<void>((resolve) => this.#waiting.push(resolve))
}
}
release(): void {
if (this.#waiting.length > 0) {
this.#waiting.shift()!()
} else {
this.#permits++
}
}
}
process.on('SIGINT', async () => {
await restoreOriginalState()
process.exit(0)
})
void main().catch((e) => {
console.log(e)
void restoreOriginalState()
process.exit(1)
})