cli: optimize repo test to filter out ununsed projects

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-10-26 11:59:38 +02:00
parent 7621b04cad
commit 6819f8cef2
4 changed files with 107 additions and 14 deletions
+9
View File
@@ -0,0 +1,9 @@
---
'@backstage/cli': minor
---
Added a new optimization to the `repo test` command that will filter out unused packages in watch mode if all provide filters are paths that point from the repo root. This significantly speeds up running individual tests from the repo root in a large workspace, for example:
```sh
yarn test packages/app/src/App.test.tsx
```
+41 -2
View File
@@ -15,8 +15,6 @@ Below is a cleaned up output of `yarn backstage-cli --help`:
```text
new [options] Open up an interactive guide to creating new things in
your app
test Run tests, forwarding args to Jest, defaulting to watch
mode [DEPRECATED]
config:docs [options] Browse the configuration reference documentation
config:print [options] Print the app configuration for the current package
config:check [options] Validate that the given configuration loads and matches
@@ -99,6 +97,47 @@ Options:
--fix Attempt to automatically fix violations
```
## repo test
Test packages in the project. It is recommended to have this command be used as the `test` script in the root `package.json` in your project:
```json title="package.json in the root of your project"
{
...
"scripts": {
...
"test": "backstage-cli repo test"
}
}
```
If run without any arguments it will default to running changed tests in watch mode, unless the `CI` environment flag is set, in which case it will run all tests without watching:
```sh title="Run changes tests from repo root"
yarn test
```
If arguments are provided, they will be forwarded to Jest and used to filter test to execute. If full paths to tests are provided, only those tests will be included, for example:
```sh title="Run specific tests from repo root"
yarn test packages/app/src/App.test.tsx
```
If you want to avoid re-running tests that have not changed since the last successful run in CI, you can use the `--successCache` flag. By default this cache is stored in `node_modules/.cache/backstage-cli`, but you can choose a different directory with the `--successCacheDir <path>`.
```text
Usage: backstage-cli repo test [options]
Run tests, forwarding args to Jest, defaulting to watch mode
Options:
--since <ref> Only test packages that changed since the specified ref
--successCache Enable success caching, which skips running tests for unchanged packages that were successful in the previous run
--successCacheDir <path> Set the success cache location, (default: node_modules/.cache/backstage-cli)
--jest-help Show help for Jest CLI options, which are passed through
-h, --help display help for command
```
## package start
Starts the package for local development. See the frontend and backend development parts in the build system [bundling](./02-build-system.md#bundling) section for more details.
+7 -3
View File
@@ -326,7 +326,7 @@ async function getRootConfig() {
),
).then(_ => _.flat());
let configs = await Promise.all(
let projects = await Promise.all(
projectPaths.flat().map(async projectPath => {
const packagePath = path.resolve(projectPath, 'package.json');
if (!(await fs.pathExists(packagePath))) {
@@ -357,12 +357,16 @@ async function getRootConfig() {
const cache = global.__backstageCli_jestSuccessCache;
if (cache) {
configs = await cache.filterConfigs(configs, globalRootConfig);
projects = await cache.filterConfigs(projects, globalRootConfig);
}
const watchProjectFilter = global.__backstageCli_watchProjectFilter;
if (watchProjectFilter) {
projects = await watchProjectFilter.filter(projects);
}
return {
rootDir: paths.targetRoot,
projects: configs,
projects,
testResultsProcessor: cache
? require.resolve('./jestCacheResultProcessor.cjs')
: undefined,
+50 -9
View File
@@ -17,6 +17,7 @@
import os from 'os';
import crypto from 'node:crypto';
import yargs from 'yargs';
import { run as runJest, yargsOptions as jestYargsOptions } from 'jest-cli';
import { relative as relativePath } from 'path';
import { Command, OptionValues } from 'commander';
import { Lockfile, PackageGraph } from '@backstage/cli-node';
@@ -27,9 +28,10 @@ import { SuccessCache } from '../../lib/cache/SuccessCache';
type JestProject = {
displayName: string;
rootDir: string;
};
interface GlobalWithCache extends Global {
interface TestGlobal extends Global {
__backstageCli_jestSuccessCache?: {
filterConfigs(
projectConfigs: JestProject[],
@@ -48,6 +50,9 @@ interface GlobalWithCache extends Global {
}>;
}): Promise<void>;
};
__backstageCli_watchProjectFilter?: {
filter(projectConfigs: JestProject[]): Promise<JestProject[]>;
};
}
/**
@@ -138,6 +143,8 @@ function removeOptionArg(args: string[], option: string, size: number = 2) {
}
export async function command(opts: OptionValues, cmd: Command): Promise<void> {
const testGlobal = global as TestGlobal;
// all args are forwarded to jest
let parent = cmd;
while (parent.parent) {
@@ -148,6 +155,9 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
const hasFlags = createFlagFinder(args);
// Parse the args to ensure that no file filters are provided, in which case we refuse to run
const { _: parsedArgs } = await yargs(args).options(jestYargsOptions).argv;
// Only include our config if caller isn't passing their own config
if (!hasFlags('-c', '--config')) {
args.push('--config', paths.resolveOwn('config/jest.js'));
@@ -158,6 +168,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
}
// Run in watch mode unless in CI, coverage mode, or running all tests
let isSingleWatchMode = args.includes('--watch');
if (
!opts.since &&
!process.env.CI &&
@@ -168,12 +179,47 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
const isMercurialRepo = () => runCheck('hg', '--cwd', '.', 'root');
if ((await isGitRepo()) || (await isMercurialRepo())) {
isSingleWatchMode = true;
args.push('--watch');
} else {
args.push('--watchAll');
}
}
// Due to our monorepo Jest project setup watch mode can be quite slow as it
// will always scan all projects for matches. This is an optimization where if
// the only provides filter paths from the repo root as args, we filter the
// projects to only run tests for those.
//
// This does mean you're not able to edit the watch filters during the watch
// session to point outside of the selected packages, but we consider that a
// worthwhile tradeoff, and you can always avoid providing paths upfront.
if (isSingleWatchMode && parsedArgs.length > 0) {
testGlobal.__backstageCli_watchProjectFilter = {
async filter(projectConfigs) {
const selectedProjects = [];
const usedArgs = new Set(parsedArgs);
for (const project of projectConfigs) {
for (const arg of usedArgs) {
if (isChildPath(project.rootDir, String(arg))) {
selectedProjects.push(project);
usedArgs.delete(arg);
}
}
}
// If we didn't end up using all args in the filtering we need to bail
// and let Jest do the full filtering instead.
if (usedArgs.size > 0) {
return projectConfigs;
}
return selectedProjects;
},
};
}
// When running tests from the repo root in large repos you can easily hit the heap limit.
// This is because Jest workers leak a lot of memory, and the workaround is to limit worker memory.
// We set a default memory limit, but if an explicit one is supplied it will be used instead
@@ -250,17 +296,13 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
(process.stdout as any)._handle.setBlocking(true);
}
const jestCli = require('jest-cli');
// This code path is enabled by the --successCache flag, which is specific to
// the `repo test` command in the Backstage CLI.
if (opts.successCache) {
removeOptionArg(args, '--successCache', 1);
removeOptionArg(args, '--successCacheDir');
// Parse the args to ensure that no file filters are provided, in which case we refuse to run
const { _: parsedArgs } = await yargs(args).options(jestCli.yargsOptions)
.argv;
// Refuse to run if file filters are provided
if (parsedArgs.length > 0) {
throw new Error(
`The --successCache flag can not be combined with the following arguments: ${parsedArgs.join(
@@ -284,8 +326,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
// Set up a bridge with the @backstage/cli/config/jest configuration file. These methods
// are picked up by the config script itself, as well as the custom result processor.
const globalWithCache = global as GlobalWithCache;
globalWithCache.__backstageCli_jestSuccessCache = {
testGlobal.__backstageCli_jestSuccessCache = {
// This is called by `config/jest.js` after the project configs have been gathered
async filterConfigs(projectConfigs, globalRootConfig) {
const cacheEntries = await cache.read();
@@ -381,5 +422,5 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
};
}
await jestCli.run(args);
await runJest(args);
}