diff --git a/.github/workflows/ci-go.yml b/.github/workflows/ci-go.yml index 87144d2845023..01fb615ca0565 100644 --- a/.github/workflows/ci-go.yml +++ b/.github/workflows/ci-go.yml @@ -48,6 +48,23 @@ jobs: - name: Benchmark and Smoke Tests run: cd cli && make bench/turbo + - name: Benchmark E2E + run: cd cli && make bench/e2e + + - name: Store Benchmark Result + uses: benchmark-action/github-action-benchmark@v1 + with: + name: "${{ runner.os }} Benchmark" + # What benchmark tool the output is formatted as + tool: "customSmallerIsBetter" + output-file-path: ./cli/scripts/benchmarks.json + benchmark-data-dir-path: benchmarks/e2e/${{ runner.os }} + auto-push: ${{ github.event_name == 'push' }} + comment-on-alert: true + comment-always: true + # GitHub API token to make a commit on push, and a comment always + github-token: ${{ secrets.GITHUB_TOKEN }} + build-win: name: build and test timeout-minutes: 10 diff --git a/.gitignore b/.gitignore index a050116236c5d..6c802a6719600 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,7 @@ cli/npm/turbo-sunos-64/bin cli/npm/turbo-windows-32/turbo.exe cli/npm/turbo-windows-64/turbo.exe cli/npm/turbo-windows-arm64/turbo.exe +cli/scripts/benchmarks.json cli/scripts/turbo-* !/npm/turbo-windows-32/bin !/npm/turbo-windows-64/bin @@ -57,4 +58,4 @@ store todos.md examples/*/*.lock examples/*/*-lock.yaml -.store \ No newline at end of file +.store diff --git a/cli/Makefile b/cli/Makefile index a1c0bbdc1e56f..8d3348ab98b11 100644 --- a/cli/Makefile +++ b/cli/Makefile @@ -264,6 +264,9 @@ bench/turbo-new: demo/turbo bench: bench/lerna bench/lage bench/nx bench/turbo +bench/e2e: | scripts/node_modules + cd scripts && yarn uvu -r esbuild-register benchmark --output benchmarks.json + clean: rm -f turbo rm -rf npm/turbo-darwin-64/bin/turbo diff --git a/cli/scripts/benchmark/benchmark.ts b/cli/scripts/benchmark/benchmark.ts new file mode 100644 index 0000000000000..282db1bfaf6fd --- /dev/null +++ b/cli/scripts/benchmark/benchmark.ts @@ -0,0 +1,362 @@ +import execa from "execa"; +import fs from "fs"; +import * as uvu from "uvu"; +import * as assert from "uvu/assert"; +import { Monorepo } from "../monorepo"; +import path from "path"; + +const basicPipeline = { + pipeline: { + test: { + outputs: [], + }, + lint: { + outputs: [], + }, + build: { + dependsOn: ["^build"], + outputs: ["dist/**"], + }, + }, + globalDependencies: ["$GLOBAL_ENV_DEPENDENCY"], +}; + +// This is injected by github actions +process.env.TURBO_TOKEN = ""; + +// See https://github.com/benchmark-action/github-action-benchmark#examples +type Benchmark = { + name: string; + unit: string; + value: number; + range?: string; + extra?: string; +}; + +class Benchmarker { + readonly benchmarks: Benchmark[] = []; + + record(cb: uvu.Callback): uvu.Callback { + return async (ctx) => { + const start = new Date(); + await cb(ctx); + const end = new Date(); + this.benchmarks.push({ + name: `${ctx.__suite__} - ${ctx.__test__}`, + value: end.getTime() - start.getTime(), + unit: "ms", + }); + }; + } + + writeResults(filename: string) { + fs.writeFileSync(filename, JSON.stringify(this.benchmarks, null, 2)); + } +} + +const benchmarker = new Benchmarker(); +const NPM_CLIENTS = ["yarn", "berry", "pnpm", "npm"] as const; + +const suites: uvu.Test[] = []; +for (const npmClient of NPM_CLIENTS) { + const suite = uvu.suite(`${npmClient}`); + + const repo = new Monorepo("basics"); + repo.init(npmClient, basicPipeline); + repo.install(); + repo.addPackage("a", ["b"]); + repo.addPackage("b"); + repo.addPackage("c"); + repo.linkPackages(); + runSmokeTests(suite, benchmarker, repo, npmClient); + + // test that turbo can run from a subdirectory + const sub = new Monorepo("in-subdirectory"); + sub.init(npmClient, basicPipeline, "js"); + sub.install(); + sub.addPackage("a", ["b"]); + sub.addPackage("b"); + sub.addPackage("c"); + sub.linkPackages(); + runSmokeTests(suite, benchmarker, sub, npmClient, { + cwd: path.join(sub.root, sub.subdir), + }); + + suites.push(suite); +} + +// Hack to get a promise the resolves when the test run is complete. +const run = Promise.all( + suites.map((s) => { + return new Promise((resolve) => { + s.after(resolve); + s.run(); + }); + }) +); + +const argc = process.argv.length; +if (process.argv[argc - 2] == "--output") { + run.then(() => { + const outputFilename = process.argv[argc - 1]; + const resolved = path.resolve(path.join(process.cwd(), outputFilename)); + benchmarker.writeResults(outputFilename); + console.log(`Benchmarks written to ${resolved}`); + }); +} + +function runSmokeTests( + suite: uvu.Test, + benchmarker: Benchmarker, + repo: Monorepo, + npmClient: "yarn" | "berry" | "pnpm" | "npm", + options: execa.SyncOptions = {} +) { + suite.after(() => { + repo.cleanup(); + }); + + const relativePath = options.cwd + ? " from /" + path.relative(repo.root, options.cwd) + : " from "; + suite( + `runs tests and logs${relativePath}`, + benchmarker.record(async () => { + const results = repo.turbo("run", ["test", "--stream"], options); + assert.equal(0, results.exitCode, "exit code should be 0"); + const commandOutput = getCommandOutputAsArray(results); + const hash = getHashFromOutput(commandOutput, "c#test"); + assert.ok(!!hash, "No hash for c#test"); + const cachedLogFilePath = getCachedLogFilePathForTask( + getCachedDirForHash(repo, hash), + "test" + ); + let text = ""; + assert.not.throws(() => { + text = repo.readFileSync(cachedLogFilePath); + }, `Could not read cached log file from cache ${cachedLogFilePath}`); + assert.ok(text.includes("testing c"), "Contains correct output"); + }) + ); + + suite( + `handles filesystem changes${relativePath}`, + benchmarker.record(async () => { + repo.newBranch("my-feature-branch"); + repo.commitFiles({ + [path.join("packages", "a", "test.js")]: `console.log('testingz a');`, + }); + + const sinceCommandOutput = getCommandOutputAsArray( + repo.turbo("run", ["test", "--since=main", "--stream"], options) + ); + + assert.fixture( + `• Packages changed since main: a`, + sinceCommandOutput[0], + "Calculates changed packages (--since)" + ); + assert.fixture( + `• Packages in scope: a`, + sinceCommandOutput[1], + "Packages in scope" + ); + assert.fixture( + `• Running test in 1 packages`, + sinceCommandOutput[2], + "Runs only in changed packages" + ); + assert.fixture( + sinceCommandOutput[3], + `a:test: cache miss, executing ${getHashFromOutput( + sinceCommandOutput, + "a#test" + )}`, + "Cache miss in changed package" + ); + + // Check cache hit after another run + const sinceCommandSecondRunOutput = getCommandOutputAsArray( + repo.turbo("run", ["test", "--since=main", "--stream"], options) + ); + assert.equal( + `• Packages changed since main: a`, + sinceCommandSecondRunOutput[0], + "Calculates changed packages (--since) after a second run" + ); + assert.equal( + `• Packages in scope: a`, + sinceCommandSecondRunOutput[1], + "Packages in scope after a second run" + ); + assert.equal( + `• Running test in 1 packages`, + sinceCommandSecondRunOutput[2], + "Runs only in changed packages after a second run" + ); + + assert.fixture( + sinceCommandSecondRunOutput[3], + `a:test: cache hit, replaying output ${getHashFromOutput( + sinceCommandSecondRunOutput, + "a#test" + )}`, + + "Cache hit in changed package after a second run" + ); + + // Check that hashes are different and trigger a cascade + repo.commitFiles({ + [path.join("packages", "b", "test.js")]: `console.log('testingz b');`, + }); + + const commandOnceBHasChangedOutput = getCommandOutputAsArray( + repo.turbo("run", ["test", "--stream"], options) + ); + + assert.fixture( + `• Packages in scope: a, b, c`, + commandOnceBHasChangedOutput[0], + "After running, changing source of b, and running `turbo run test` again, should print `Packages in scope: a, b, c`" + ); + assert.fixture( + `• Running test in 3 packages`, + commandOnceBHasChangedOutput[1], + "After running, changing source of b, and running `turbo run test` again, should print `Running in 3 packages`" + ); + assert.ok( + commandOnceBHasChangedOutput.findIndex((l) => + l.startsWith("a:test: cache miss, executing") + ) >= 0, + "After running, changing source of b, and running `turbo run test` again, should print `a:test: cache miss, executing` since a depends on b and b has changed" + ); + assert.ok( + commandOnceBHasChangedOutput.findIndex((l) => + l.startsWith("b:test: cache miss, executing") + ) >= 0, + "After running, changing source of b, and running `turbo run test` again, should print `b:test: cache miss, executing` since b has changed" + ); + assert.ok( + commandOnceBHasChangedOutput.findIndex((l) => + l.startsWith("c:test: cache hit, replaying output") + ) >= 0, + "After running, changing source of b, and running `turbo run test` again, should print `c:test: cache hit, replaying output` since c should not be impacted by changes to b" + ); + + const scopeCommandOutput = getCommandOutputAsArray( + repo.turbo("run", ["test", '--scope="!b"', "--stream"], options) + ); + + assert.fixture( + `• Packages in scope: a, c`, + scopeCommandOutput[0], + "Packages in scope" + ); + assert.fixture( + `• Running test in 2 packages`, + scopeCommandOutput[1], + "Runs only in changed packages" + ); + }) + ); + + if (npmClient === "yarn") { + // Test `turbo prune --scope=a` + // @todo refactor with other package managers + suite( + `turbo prune${relativePath}`, + benchmarker.record(async () => { + const pruneCommandOutput = getCommandOutputAsArray( + repo.turbo("prune", ["--scope=a"], options) + ); + assert.fixture(pruneCommandOutput[1], " - Added a"); + assert.fixture(pruneCommandOutput[2], " - Added b"); + + let files = []; + assert.not.throws(() => { + files = repo.globbySync("out/**/*", { + cwd: options.cwd ?? repo.root, + }); + }, `Could not read generated \`out\` directory after \`turbo prune\``); + const expected = [ + "out/package.json", + "out/turbo.json", + "out/yarn.lock", + "out/packages/a/build.js", + "out/packages/a/lint.js", + "out/packages/a/package.json", + "out/packages/a/test.js", + "out/packages/b/build.js", + "out/packages/b/lint.js", + "out/packages/b/package.json", + "out/packages/b/test.js", + ]; + for (const file of expected) { + assert.ok( + files.includes(file), + `Expected file ${file} to be generated` + ); + } + const install = repo.run("install", ["--frozen-lockfile"], { + cwd: options.cwd + ? path.join(options.cwd, "out") + : path.join(repo.root, "out"), + }); + assert.is( + install.exitCode, + 0, + "Expected yarn install --frozen-lockfile to succeed" + ); + }) + ); + } +} + +type PackageManager = "yarn" | "pnpm" | "npm" | "berry"; + +// getLockfileForPackageManager returns the name of the lockfile for the given package manager +function getLockfileForPackageManager(ws: PackageManager) { + switch (ws) { + case "yarn": + return "yarn.lock"; + case "pnpm": + return "pnpm-lock.yaml"; + case "npm": + return "package-lock.json"; + case "berry": + return "yarn.lock"; + default: + throw new Error(`Unknown package manager: ${ws}`); + } +} + +function getCommandOutputAsArray( + results: execa.ExecaSyncReturnValue +): string[] { + return (results.stdout + results.stderr).split("\n"); +} + +function getHashFromOutput(lines: string[], taskId: string): string { + const normalizedTaskId = taskId.replace("#", ":"); + const line = lines.find((l) => l.startsWith(normalizedTaskId)); + const splitMessage = line.split(" "); + const hash = splitMessage[splitMessage.length - 1]; + return hash; +} + +function getCachedDirForHash(repo: Monorepo, hash: string): string { + return path.join( + repo.subdir ? repo.subdir : ".", + "node_modules", + ".cache", + "turbo", + hash + ); +} + +function getCachedLogFilePathForTask( + cacheDir: string, + taskName: string +): string { + return path.join(cacheDir, ".turbo", `turbo-${taskName}.log`); +}