From a9bf55a3694b637731d669134a5425c8d64e0b19 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 2 Oct 2024 10:04:17 +0000 Subject: [PATCH 01/20] chore(deps): update jamesives/github-pages-deploy-action action to v4.6.8 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/deploy_microsite.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy_microsite.yml b/.github/workflows/deploy_microsite.yml index 9ae266280f..6bf1b74f50 100644 --- a/.github/workflows/deploy_microsite.yml +++ b/.github/workflows/deploy_microsite.yml @@ -225,7 +225,7 @@ jobs: run: ls microsite/build && ls microsite/build/storybook - name: Deploy both microsite and storybook to gh-pages - uses: JamesIves/github-pages-deploy-action@920cbb300dcd3f0568dbc42700c61e2fd9e6139c # v4.6.4 + uses: JamesIves/github-pages-deploy-action@881db5376404c5c8d621010bcbec0310b58d5e29 # v4.6.8 with: branch: gh-pages folder: microsite/build From d103a48fa306d745599dc0c793668c9e6a479d32 Mon Sep 17 00:00:00 2001 From: RJ Decilos III Date: Fri, 4 Oct 2024 11:11:37 +0800 Subject: [PATCH 02/20] fix: createBranch gitHelper function Signed-off-by: RJ Decilos III --- plugins/scaffolder-node/src/actions/gitHelpers.test.ts | 5 +++-- plugins/scaffolder-node/src/actions/gitHelpers.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/plugins/scaffolder-node/src/actions/gitHelpers.test.ts b/plugins/scaffolder-node/src/actions/gitHelpers.test.ts index 85fb9ba2e6..7a61e592ec 100644 --- a/plugins/scaffolder-node/src/actions/gitHelpers.test.ts +++ b/plugins/scaffolder-node/src/actions/gitHelpers.test.ts @@ -32,6 +32,7 @@ jest.mock('../scm', () => ({ init: jest.fn(), add: jest.fn(), checkout: jest.fn(), + branch: jest.fn(), commit: jest .fn() .mockResolvedValue('220f19cc36b551763d157f1b5e4a4b446165dbd6'), @@ -444,7 +445,7 @@ describe('createBranch', () => { }); it('create the branch', () => { - expect(mockedGit.checkout).toHaveBeenCalledWith({ + expect(mockedGit.branch).toHaveBeenCalledWith({ ref: 'trunk', dir: '/tmp/repo/dir/', }); @@ -460,7 +461,7 @@ describe('createBranch', () => { }, }); - expect(mockedGit.checkout).toHaveBeenCalledWith({ + expect(mockedGit.branch).toHaveBeenCalledWith({ ref: 'trunk', dir: '/tmp/repo/dir/', }); diff --git a/plugins/scaffolder-node/src/actions/gitHelpers.ts b/plugins/scaffolder-node/src/actions/gitHelpers.ts index 04f47d1648..9f2d5709a7 100644 --- a/plugins/scaffolder-node/src/actions/gitHelpers.ts +++ b/plugins/scaffolder-node/src/actions/gitHelpers.ts @@ -178,7 +178,7 @@ export async function createBranch(options: { logger, }); - await git.checkout({ dir, ref }); + await git.branch({ dir, ref }); } /** From d7a736cc6acbc36dd051fc0ffce4a51a0bed1adf Mon Sep 17 00:00:00 2001 From: RJ Decilos III Date: Fri, 4 Oct 2024 14:46:26 +0800 Subject: [PATCH 03/20] chore: create changeset Signed-off-by: RJ Decilos III --- .changeset/thick-tables-give.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/thick-tables-give.md diff --git a/.changeset/thick-tables-give.md b/.changeset/thick-tables-give.md new file mode 100644 index 0000000000..8fe55346b9 --- /dev/null +++ b/.changeset/thick-tables-give.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-node': patch +--- + +Use Git class' branch function instead of checkout function when creating branch From 5420e5b4c5e8cc88d45cb476904a8f082461fdc7 Mon Sep 17 00:00:00 2001 From: Tyler Davis Date: Sat, 5 Oct 2024 07:14:05 +1000 Subject: [PATCH 04/20] Fix rootConfigServiceFactory import in rootConfig docs Also I noticed clicking "edit this page" on https://backstage.io/docs/backend-system/core-services/root-config/ took me to a 404, not sure if that is a known issue or not. Signed-off-by: Tyler Davis --- docs/backend-system/core-services/root-config.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/backend-system/core-services/root-config.md b/docs/backend-system/core-services/root-config.md index 8505b9e7ec..505a9f90d1 100644 --- a/docs/backend-system/core-services/root-config.md +++ b/docs/backend-system/core-services/root-config.md @@ -44,7 +44,7 @@ There's additional configuration that you can optionally pass to setup the `conf You can configure these additional options by adding an override for the core service when calling `createBackend` like follows: ```ts -import { rootConfigServiceFactory } from '@backstage/backend-app-api'; +import { rootConfigServiceFactory } from '@backstage/backend-defaults/rootConfig'; const backend = createBackend(); From 80a1f413341a7b574e2635c26a6e88b343df63d7 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 6 Oct 2024 11:37:03 +0200 Subject: [PATCH 05/20] cli/templates: .eslintrc.js -> .eslintrc.js.hbs Signed-off-by: Patrik Oldsberg --- packages/cli/src/lib/new/factories/backendModule.test.ts | 2 +- packages/cli/src/lib/new/factories/backendPlugin.test.ts | 2 +- packages/cli/src/lib/new/factories/frontendPlugin.test.ts | 2 +- packages/cli/src/lib/new/factories/nodeLibraryPackage.test.ts | 2 +- packages/cli/src/lib/new/factories/pluginCommon.test.ts | 2 +- packages/cli/src/lib/new/factories/pluginNode.test.ts | 2 +- packages/cli/src/lib/new/factories/pluginWeb.test.ts | 2 +- packages/cli/src/lib/new/factories/scaffolderModule.test.ts | 2 +- packages/cli/src/lib/new/factories/webLibraryPackage.test.ts | 2 +- .../default-backend-module/{.eslintrc.js => .eslintrc.js.hbs} | 0 .../default-backend-plugin/{.eslintrc.js => .eslintrc.js.hbs} | 0 .../{.eslintrc.js => .eslintrc.js.hbs} | 0 .../{.eslintrc.js => .eslintrc.js.hbs} | 0 .../templates/default-plugin/{.eslintrc.js => .eslintrc.js.hbs} | 0 .../{.eslintrc.js => .eslintrc.js.hbs} | 0 .../node-library-package/{.eslintrc.js => .eslintrc.js.hbs} | 0 .../scaffolder-module/{.eslintrc.js => .eslintrc.js.hbs} | 0 .../web-library-package/{.eslintrc.js => .eslintrc.js.hbs} | 0 18 files changed, 9 insertions(+), 9 deletions(-) rename packages/cli/templates/default-backend-module/{.eslintrc.js => .eslintrc.js.hbs} (100%) rename packages/cli/templates/default-backend-plugin/{.eslintrc.js => .eslintrc.js.hbs} (100%) rename packages/cli/templates/default-common-plugin-package/{.eslintrc.js => .eslintrc.js.hbs} (100%) rename packages/cli/templates/default-node-plugin-package/{.eslintrc.js => .eslintrc.js.hbs} (100%) rename packages/cli/templates/default-plugin/{.eslintrc.js => .eslintrc.js.hbs} (100%) rename packages/cli/templates/default-react-plugin-package/{.eslintrc.js => .eslintrc.js.hbs} (100%) rename packages/cli/templates/node-library-package/{.eslintrc.js => .eslintrc.js.hbs} (100%) rename packages/cli/templates/scaffolder-module/{.eslintrc.js => .eslintrc.js.hbs} (100%) rename packages/cli/templates/web-library-package/{.eslintrc.js => .eslintrc.js.hbs} (100%) diff --git a/packages/cli/src/lib/new/factories/backendModule.test.ts b/packages/cli/src/lib/new/factories/backendModule.test.ts index 59b5403239..36ed9655a1 100644 --- a/packages/cli/src/lib/new/factories/backendModule.test.ts +++ b/packages/cli/src/lib/new/factories/backendModule.test.ts @@ -90,7 +90,7 @@ describe('backendModule factory', () => { `availability plugins${sep}test-backend-module-tester-two`, 'creating temp dir', 'Executing Template:', - 'copying .eslintrc.js', + 'templating .eslintrc.js.hbs', 'templating README.md.hbs', 'templating package.json.hbs', 'templating index.ts.hbs', diff --git a/packages/cli/src/lib/new/factories/backendPlugin.test.ts b/packages/cli/src/lib/new/factories/backendPlugin.test.ts index 3b90e2b0dd..c5dc237517 100644 --- a/packages/cli/src/lib/new/factories/backendPlugin.test.ts +++ b/packages/cli/src/lib/new/factories/backendPlugin.test.ts @@ -89,7 +89,7 @@ describe('backendPlugin factory', () => { `availability plugins${sep}test-backend`, 'creating temp dir', 'Executing Template:', - 'copying .eslintrc.js', + 'templating .eslintrc.js.hbs', 'templating README.md.hbs', 'templating index.ts.hbs', 'templating package.json.hbs', diff --git a/packages/cli/src/lib/new/factories/frontendPlugin.test.ts b/packages/cli/src/lib/new/factories/frontendPlugin.test.ts index 55e569a15f..a4a89c3245 100644 --- a/packages/cli/src/lib/new/factories/frontendPlugin.test.ts +++ b/packages/cli/src/lib/new/factories/frontendPlugin.test.ts @@ -91,7 +91,7 @@ describe('frontendPlugin factory', () => { `availability plugins${sep}test`, 'creating temp dir', 'Executing Template:', - 'copying .eslintrc.js', + 'templating .eslintrc.js.hbs', 'templating README.md.hbs', 'templating package.json.hbs', 'templating index.tsx.hbs', diff --git a/packages/cli/src/lib/new/factories/nodeLibraryPackage.test.ts b/packages/cli/src/lib/new/factories/nodeLibraryPackage.test.ts index 69999761e9..929182d84e 100644 --- a/packages/cli/src/lib/new/factories/nodeLibraryPackage.test.ts +++ b/packages/cli/src/lib/new/factories/nodeLibraryPackage.test.ts @@ -75,7 +75,7 @@ describe('nodeLibraryPackage factory', () => { `availability ${joinPath('packages', expectedNodeLibraryPackageName)}`, 'creating temp dir', 'Executing Template:', - 'copying .eslintrc.js', + 'templating .eslintrc.js.hbs', 'templating README.md.hbs', 'templating package.json.hbs', 'templating index.ts.hbs', diff --git a/packages/cli/src/lib/new/factories/pluginCommon.test.ts b/packages/cli/src/lib/new/factories/pluginCommon.test.ts index 8f01401435..6dfcc83536 100644 --- a/packages/cli/src/lib/new/factories/pluginCommon.test.ts +++ b/packages/cli/src/lib/new/factories/pluginCommon.test.ts @@ -73,7 +73,7 @@ describe('pluginCommon factory', () => { `availability plugins${sep}test-common`, 'creating temp dir', 'Executing Template:', - 'copying .eslintrc.js', + 'templating .eslintrc.js.hbs', 'templating README.md.hbs', 'templating package.json.hbs', 'templating index.ts.hbs', diff --git a/packages/cli/src/lib/new/factories/pluginNode.test.ts b/packages/cli/src/lib/new/factories/pluginNode.test.ts index b6aad0a129..e6ff093e22 100644 --- a/packages/cli/src/lib/new/factories/pluginNode.test.ts +++ b/packages/cli/src/lib/new/factories/pluginNode.test.ts @@ -73,7 +73,7 @@ describe('pluginNode factory', () => { `availability plugins${sep}test-node`, 'creating temp dir', 'Executing Template:', - 'copying .eslintrc.js', + 'templating .eslintrc.js.hbs', 'templating README.md.hbs', 'templating package.json.hbs', 'templating index.ts.hbs', diff --git a/packages/cli/src/lib/new/factories/pluginWeb.test.ts b/packages/cli/src/lib/new/factories/pluginWeb.test.ts index 20fbbbbdb7..ddc4920fb8 100644 --- a/packages/cli/src/lib/new/factories/pluginWeb.test.ts +++ b/packages/cli/src/lib/new/factories/pluginWeb.test.ts @@ -73,7 +73,7 @@ describe('pluginWeb factory', () => { `availability plugins${sep}test-react`, 'creating temp dir', 'Executing Template:', - 'copying .eslintrc.js', + 'templating .eslintrc.js.hbs', 'templating README.md.hbs', 'templating package.json.hbs', 'templating index.ts.hbs', diff --git a/packages/cli/src/lib/new/factories/scaffolderModule.test.ts b/packages/cli/src/lib/new/factories/scaffolderModule.test.ts index 60dade618a..bdcd8b6bdc 100644 --- a/packages/cli/src/lib/new/factories/scaffolderModule.test.ts +++ b/packages/cli/src/lib/new/factories/scaffolderModule.test.ts @@ -89,7 +89,7 @@ describe('scaffolderModule factory', () => { `availability plugins${sep}scaffolder-backend-module-test`, 'creating temp dir', 'Executing Template:', - 'copying .eslintrc.js', + 'templating .eslintrc.js.hbs', 'templating README.md.hbs', 'templating package.json.hbs', 'templating index.ts.hbs', diff --git a/packages/cli/src/lib/new/factories/webLibraryPackage.test.ts b/packages/cli/src/lib/new/factories/webLibraryPackage.test.ts index 691f0b9f1c..942b66f350 100644 --- a/packages/cli/src/lib/new/factories/webLibraryPackage.test.ts +++ b/packages/cli/src/lib/new/factories/webLibraryPackage.test.ts @@ -75,7 +75,7 @@ describe('webLibraryPackage factory', () => { `availability ${joinPath('packages', expectedwebLibraryPackageName)}`, 'creating temp dir', 'Executing Template:', - 'copying .eslintrc.js', + 'templating .eslintrc.js.hbs', 'templating README.md.hbs', 'templating package.json.hbs', 'templating index.ts.hbs', diff --git a/packages/cli/templates/default-backend-module/.eslintrc.js b/packages/cli/templates/default-backend-module/.eslintrc.js.hbs similarity index 100% rename from packages/cli/templates/default-backend-module/.eslintrc.js rename to packages/cli/templates/default-backend-module/.eslintrc.js.hbs diff --git a/packages/cli/templates/default-backend-plugin/.eslintrc.js b/packages/cli/templates/default-backend-plugin/.eslintrc.js.hbs similarity index 100% rename from packages/cli/templates/default-backend-plugin/.eslintrc.js rename to packages/cli/templates/default-backend-plugin/.eslintrc.js.hbs diff --git a/packages/cli/templates/default-common-plugin-package/.eslintrc.js b/packages/cli/templates/default-common-plugin-package/.eslintrc.js.hbs similarity index 100% rename from packages/cli/templates/default-common-plugin-package/.eslintrc.js rename to packages/cli/templates/default-common-plugin-package/.eslintrc.js.hbs diff --git a/packages/cli/templates/default-node-plugin-package/.eslintrc.js b/packages/cli/templates/default-node-plugin-package/.eslintrc.js.hbs similarity index 100% rename from packages/cli/templates/default-node-plugin-package/.eslintrc.js rename to packages/cli/templates/default-node-plugin-package/.eslintrc.js.hbs diff --git a/packages/cli/templates/default-plugin/.eslintrc.js b/packages/cli/templates/default-plugin/.eslintrc.js.hbs similarity index 100% rename from packages/cli/templates/default-plugin/.eslintrc.js rename to packages/cli/templates/default-plugin/.eslintrc.js.hbs diff --git a/packages/cli/templates/default-react-plugin-package/.eslintrc.js b/packages/cli/templates/default-react-plugin-package/.eslintrc.js.hbs similarity index 100% rename from packages/cli/templates/default-react-plugin-package/.eslintrc.js rename to packages/cli/templates/default-react-plugin-package/.eslintrc.js.hbs diff --git a/packages/cli/templates/node-library-package/.eslintrc.js b/packages/cli/templates/node-library-package/.eslintrc.js.hbs similarity index 100% rename from packages/cli/templates/node-library-package/.eslintrc.js rename to packages/cli/templates/node-library-package/.eslintrc.js.hbs diff --git a/packages/cli/templates/scaffolder-module/.eslintrc.js b/packages/cli/templates/scaffolder-module/.eslintrc.js.hbs similarity index 100% rename from packages/cli/templates/scaffolder-module/.eslintrc.js rename to packages/cli/templates/scaffolder-module/.eslintrc.js.hbs diff --git a/packages/cli/templates/web-library-package/.eslintrc.js b/packages/cli/templates/web-library-package/.eslintrc.js.hbs similarity index 100% rename from packages/cli/templates/web-library-package/.eslintrc.js rename to packages/cli/templates/web-library-package/.eslintrc.js.hbs From fec7278938e07ffd583be358f15f87285238322c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 6 Oct 2024 11:39:01 +0200 Subject: [PATCH 06/20] cli-node: add utility for generating package dependency hash Signed-off-by: Patrik Oldsberg --- .changeset/calm-owls-move.md | 5 ++ packages/cli-node/report.api.md | 2 + packages/cli-node/src/monorepo/Lockfile.ts | 8 +++ .../cli-node/src/monorepo/PackageGraph.ts | 51 +++++++++++++++++-- 4 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 .changeset/calm-owls-move.md diff --git a/.changeset/calm-owls-move.md b/.changeset/calm-owls-move.md new file mode 100644 index 0000000000..a4dcaeeff8 --- /dev/null +++ b/.changeset/calm-owls-move.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-node': patch +--- + +Added new `packageGraph.getDependencyHash(name)` utility. diff --git a/packages/cli-node/report.api.md b/packages/cli-node/report.api.md index 97dab9684d..6c2294012d 100644 --- a/packages/cli-node/report.api.md +++ b/packages/cli-node/report.api.md @@ -92,6 +92,7 @@ export function isMonoRepo(): Promise; export class Lockfile { createSimplifiedDependencyGraph(): Map>; diff(otherLockfile: Lockfile): LockfileDiff; + getVersions(name: string): string[]; static load(path: string): Promise; static parse(content: string): Lockfile; } @@ -116,6 +117,7 @@ export class PackageGraph extends Map { collectFn: (pkg: PackageGraphNode) => Iterable | undefined, ): Set; static fromPackages(packages: Package[]): PackageGraph; + getDependencyHash(name: string): Promise; listChangedPackages(options: { ref: string; analyzeLockfile?: boolean; diff --git a/packages/cli-node/src/monorepo/Lockfile.ts b/packages/cli-node/src/monorepo/Lockfile.ts index 8a481c77ad..d7336cf970 100644 --- a/packages/cli-node/src/monorepo/Lockfile.ts +++ b/packages/cli-node/src/monorepo/Lockfile.ts @@ -133,6 +133,14 @@ export class Lockfile { private readonly data: LockfileData, ) {} + /** + * Returns all versions of a package in the lockfile. + */ + getVersions(name: string): string[] { + const queries = this.packages.get(name); + return queries ? queries.map(q => q.version) : []; + } + /** * Creates a simplified dependency graph from the lockfile data, where each * key is a package, and the value is a set of all packages that it depends on diff --git a/packages/cli-node/src/monorepo/PackageGraph.ts b/packages/cli-node/src/monorepo/PackageGraph.ts index 69ec99c2dd..91c5312bc1 100644 --- a/packages/cli-node/src/monorepo/PackageGraph.ts +++ b/packages/cli-node/src/monorepo/PackageGraph.ts @@ -15,6 +15,7 @@ */ import path from 'path'; +import crypto from 'node:crypto'; import { getPackages, Package } from '@manypkg/get-packages'; import { paths } from '../paths'; import { PackageRole } from '../roles'; @@ -283,6 +284,43 @@ export class PackageGraph extends Map { return targets; } + /** + * Generates a sha1 hex hash of the dependency graph for a package. + */ + async getDependencyHash(name: string): Promise { + const pkg = this.get(name); + if (!pkg) { + throw new Error(`Package '${name}' not found`); + } + + const lockfile = await this.#getLockfile(); + const depGraph = lockfile.createSimplifiedDependencyGraph(); + + const seen = new Set(); + const queue = [name]; + + while (queue.length > 0) { + const deps = depGraph.get(queue.pop()!); + if (deps) { + for (const dep of deps) { + if (!seen.has(dep)) { + seen.add(dep); + queue.push(dep); + } + } + } + } + + const hash = crypto.createHash('sha1'); + for (const dep of Array.from(seen).sort()) { + hash.update(dep); + hash.update('\0'); + hash.update(lockfile.getVersions(dep).join(' ')); + hash.update('\0'); + } + return hash.digest('hex'); + } + /** * Lists all packages that have changed since a given git ref. * @@ -342,9 +380,7 @@ export class PackageGraph extends Map { let thisLockfile: Lockfile; let otherLockfile: Lockfile; try { - thisLockfile = await Lockfile.load( - paths.resolveTargetRoot('yarn.lock'), - ); + thisLockfile = await this.#getLockfile(); otherLockfile = Lockfile.parse( await GitUtils.readFileAtRef('yarn.lock', options.ref), ); @@ -410,4 +446,13 @@ export class PackageGraph extends Map { return result; } + + #lockfilePromise?: Promise; + #getLockfile(): Promise { + if (this.#lockfilePromise) { + return this.#lockfilePromise; + } + this.#lockfilePromise = Lockfile.load(paths.resolveTargetRoot('yarn.lock')); + return this.#lockfilePromise; + } } From 8fe740dc95d0f12b9aa0a21f67b0a43fe4b11afd Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 6 Oct 2024 11:51:28 +0200 Subject: [PATCH 07/20] cli: added option to cache successful lint runs with repo lint Signed-off-by: Patrik Oldsberg --- .changeset/small-donkeys-attack.md | 5 + packages/cli/cli-report.md | 1 + packages/cli/src/commands/index.ts | 4 + packages/cli/src/commands/repo/lint.ts | 131 +++++++++++++++++++++++-- 4 files changed, 131 insertions(+), 10 deletions(-) create mode 100644 .changeset/small-donkeys-attack.md diff --git a/.changeset/small-donkeys-attack.md b/.changeset/small-donkeys-attack.md new file mode 100644 index 0000000000..97a65cbfd9 --- /dev/null +++ b/.changeset/small-donkeys-attack.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Added a new `--cache [path]` option to the `backstage-cli repo lint` command. The cache keeps track of successful lint runs and avoids re-running linting of individual packages if they haven't changed. This option is primarily intended to be used in CI. diff --git a/packages/cli/cli-report.md b/packages/cli/cli-report.md index 70dc2614cc..64348a872e 100644 --- a/packages/cli/cli-report.md +++ b/packages/cli/cli-report.md @@ -446,6 +446,7 @@ Usage: backstage-cli repo lint [options] Options: --format --since + --cache [path] --fix -h, --help ``` diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index b54536cd55..9ae52a4972 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -61,6 +61,10 @@ export function registerRepoCommand(program: Command) { '--since ', 'Only lint packages that changed since the specified ref', ) + .option( + '--cache [path]', + 'Enable caching, storing it in node_modules/.cache/backstage-cli by default, or at the provided directory', + ) .option('--fix', 'Attempt to automatically fix violations') .action(lazy(() => import('./repo/lint').then(m => m.command))); diff --git a/packages/cli/src/commands/repo/lint.ts b/packages/cli/src/commands/repo/lint.ts index d0d6ee8f06..a6381d3520 100644 --- a/packages/cli/src/commands/repo/lint.ts +++ b/packages/cli/src/commands/repo/lint.ts @@ -16,7 +16,9 @@ import chalk from 'chalk'; import { Command, OptionValues } from 'commander'; -import { relative as relativePath } from 'path'; +import fs from 'fs-extra'; +import { createHash } from 'crypto'; +import { relative as relativePath, resolve as resolvePath } from 'path'; import { PackageGraph, BackstagePackageJson } from '@backstage/cli-node'; import { paths } from '../../lib/paths'; import { runWorkerQueueThreads } from '../../lib/parallel'; @@ -30,11 +32,42 @@ function depCount(pkg: BackstagePackageJson) { return deps + devDeps; } +const CACHE_FILE_NAME = 'lint-cache.json'; + +type Cache = string[]; + +async function readCache(dir: string): Promise { + try { + const data = await fs.readJson(resolvePath(dir, CACHE_FILE_NAME)); + if (!Array.isArray(data)) { + return undefined; + } + if (data.some(x => typeof x !== 'string')) { + return undefined; + } + return data as Cache; + } catch { + return undefined; + } +} + +async function writeCache(dir: string, cache: Cache) { + await fs.mkdirp(dir); + await fs.writeJson(resolvePath(dir, CACHE_FILE_NAME), cache, { spaces: 2 }); +} + export async function command(opts: OptionValues, cmd: Command): Promise { let packages = await PackageGraph.listTargetPackages(); + const cacheDir = + opts.cache === true + ? paths.resolveTargetRoot('node_modules/.cache/backstage-cli') + : opts.cache; + const cache = cacheDir ? await readCache(cacheDir) : undefined; + + const graph = PackageGraph.fromPackages(packages); + if (opts.since) { - const graph = PackageGraph.fromPackages(packages); packages = await graph.listChangedPackages({ ref: opts.since, analyzeLockfile: true, @@ -57,26 +90,61 @@ export async function command(opts: OptionValues, cmd: Command): Promise { const parseLintScript = createScriptOptionsParser(cmd, ['package', 'lint']); + const items = await Promise.all( + packages.map(async pkg => { + const base = { + fullDir: pkg.dir, + relativeDir: relativePath(paths.targetRoot, pkg.dir), + lintOptions: parseLintScript(pkg.packageJson.scripts?.lint), + parentHash: undefined, + }; + + if (!cacheDir) { + return base; + } + + const hash = createHash('sha1'); + + hash.update(await graph.getDependencyHash(pkg.packageJson.name)); + hash.update('\0'); + hash.update(process.version); // Node.js version + hash.update('\0'); + hash.update('v1'); // The version of this implementation + + return { + ...base, + parentHash: hash.digest('hex'), + }; + }), + ); + const resultsList = await runWorkerQueueThreads({ - items: packages.map(pkg => ({ - fullDir: pkg.dir, - relativeDir: relativePath(paths.targetRoot, pkg.dir), - lintOptions: parseLintScript(pkg.packageJson.scripts?.lint), - })), + items, workerData: { fix: Boolean(opts.fix), format: opts.format as string | undefined, + shouldCache: Boolean(cacheDir), + successCache: cache, }, - workerFactory: async ({ fix, format }) => { + workerFactory: async ({ fix, format, shouldCache, successCache }) => { const { ESLint } = require('eslint') as typeof import('eslint'); + const crypto = require('crypto') as typeof import('crypto'); + const recursiveReadDir = + require('recursive-readdir') as typeof import('recursive-readdir'); + const { readFile } = + require('fs/promises') as typeof import('fs/promises'); + const { relative: workerRelativePath } = + require('path') as typeof import('path'); return async ({ fullDir, relativeDir, lintOptions, + parentHash, }): Promise<{ relativeDir: string; - resultText: string; + sha?: string; + resultText?: string; failed: boolean; }> => { // Bit of a hack to make file resolutions happen from the correct directory @@ -89,6 +157,35 @@ export async function command(opts: OptionValues, cmd: Command): Promise { fix, extensions: ['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs'], }); + + let sha: string | undefined = undefined; + if (shouldCache) { + const result = await recursiveReadDir(fullDir); + + const hash = crypto.createHash('sha1'); + hash.update(parentHash!); + hash.update('\0'); + + for (const path of result.sort()) { + if (await eslint.isPathIgnored(path)) { + continue; + } + hash.update(workerRelativePath(fullDir, path)); + hash.update('\0'); + hash.update(await readFile(path)); + hash.update('\0'); + hash.update( + JSON.stringify(await eslint.calculateConfigForFile(path)), + ); + hash.update('\0'); + } + sha = await hash.digest('hex'); + if (successCache?.includes(sha)) { + console.log(`Skipped ${relativeDir} due to cache hit`); + return { relativeDir, sha, failed: false }; + } + } + const formatter = await eslint.loadFormatter(format); const results = await eslint.lintFiles(['.']); @@ -112,13 +209,21 @@ export async function command(opts: OptionValues, cmd: Command): Promise { relativeDir, resultText, failed, + sha, }; }; }, }); + const outputSuccessCache = []; + let failed = false; - for (const { relativeDir, resultText, failed: runFailed } of resultsList) { + for (const { + relativeDir, + resultText, + failed: runFailed, + sha, + } of resultsList) { if (runFailed) { console.log(chalk.red(`Lint failed in ${relativeDir}`)); failed = true; @@ -129,9 +234,15 @@ export async function command(opts: OptionValues, cmd: Command): Promise { console.log(); console.log(resultText.trimStart()); } + } else if (sha) { + outputSuccessCache.push(sha); } } + if (cacheDir) { + await writeCache(cacheDir, outputSuccessCache); + } + if (failed) { process.exit(1); } From d4ae3b2570c09d6ac22fd0598e1b65f7c7d1402d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 6 Oct 2024 12:04:01 +0200 Subject: [PATCH 08/20] docs/tooling: document caching options Signed-off-by: Patrik Oldsberg --- docs/tooling/cli/02-build-system.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/tooling/cli/02-build-system.md b/docs/tooling/cli/02-build-system.md index ab7dc07802..c922134a7c 100644 --- a/docs/tooling/cli/02-build-system.md +++ b/docs/tooling/cli/02-build-system.md @@ -559,6 +559,14 @@ The overrides in a single `package.json` may for example look like this: }, ``` +## Caching + +Caching is used sparingly throughout the Backstage build system. It is always used as a way to squeeze out a little bit of extra performance, rather than requirement to keep things fast. The following is a list of places where optional caching is available: + +- **TypeScript** - The default `tsconfig.json` used by Backstage projects has `incremental` set to `true`, which enables local caching of type checking results. It is however generally not recommended in CI, where `yarn tsc:full` is preferred, which sets `--incremental false`. +- **Linting** - The `backstage-cli repo lint` command has a `--cache` flag that enables caching of successful linting results. This is done at the package level, meaning that if a package has not been changed since the last lint run and it was successful, the linting will be skipped. This is recommended to be used in CI, but not during local development. +- **Webpack** - It is possible to enable experimental caching of frontend package builds using the `BACKSTAGE_CLI_EXPERIMENTAL_BUILD_CACHE` environment variable. This will enable the Webpack filesystem cache. + ### Debugging Jest Tests For your productivity working with unit tests it's quite essential to have your debugging configured in IDE. It will help you to identify the root cause of the issue faster. From de8732596a978adab4e66f23dcc1a3cd65c1b8cf Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 6 Oct 2024 12:24:09 +0200 Subject: [PATCH 09/20] .github/workflows: enable repo lint cache Signed-off-by: Patrik Oldsberg --- .github/workflows/ci.yml | 2 +- .github/workflows/deploy_packages.yml | 2 +- .github/workflows/verify_windows.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 886abafcd7..28c53db776 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -234,7 +234,7 @@ jobs: run: node scripts/verify-release.js - name: lint changed packages - run: yarn backstage-cli repo lint --since origin/master + run: yarn backstage-cli repo lint --since origin/master --cache - name: test changed packages run: yarn backstage-cli repo test --maxWorkers=3 --workerIdleMemoryLimit=1300M --since origin/master diff --git a/.github/workflows/deploy_packages.yml b/.github/workflows/deploy_packages.yml index 92210e90be..b09f419fed 100644 --- a/.github/workflows/deploy_packages.yml +++ b/.github/workflows/deploy_packages.yml @@ -94,7 +94,7 @@ jobs: run: yarn backstage-cli config:check --lax - name: lint - run: yarn backstage-cli repo lint + run: yarn backstage-cli repo lint --cache - name: type checking and declarations run: yarn tsc:full diff --git a/.github/workflows/verify_windows.yml b/.github/workflows/verify_windows.yml index 9846ff28d2..113c76d497 100644 --- a/.github/workflows/verify_windows.yml +++ b/.github/workflows/verify_windows.yml @@ -46,7 +46,7 @@ jobs: run: yarn install --immutable - name: lint - run: yarn backstage-cli repo lint + run: yarn backstage-cli repo lint --cache - name: type checking and declarations run: yarn tsc:full From fede18031dbd30ef954e7aa0524ad3bfe7df11b2 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 6 Oct 2024 12:30:47 +0200 Subject: [PATCH 10/20] cli: include package lint options in repo lint cache key Signed-off-by: Patrik Oldsberg --- packages/cli/src/commands/repo/lint.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/repo/lint.ts b/packages/cli/src/commands/repo/lint.ts index a6381d3520..b5a89471a7 100644 --- a/packages/cli/src/commands/repo/lint.ts +++ b/packages/cli/src/commands/repo/lint.ts @@ -92,10 +92,11 @@ export async function command(opts: OptionValues, cmd: Command): Promise { const items = await Promise.all( packages.map(async pkg => { + const lintOptions = parseLintScript(pkg.packageJson.scripts?.lint); const base = { fullDir: pkg.dir, relativeDir: relativePath(paths.targetRoot, pkg.dir), - lintOptions: parseLintScript(pkg.packageJson.scripts?.lint), + lintOptions, parentHash: undefined, }; @@ -107,6 +108,8 @@ export async function command(opts: OptionValues, cmd: Command): Promise { hash.update(await graph.getDependencyHash(pkg.packageJson.name)); hash.update('\0'); + hash.update(JSON.stringify(lintOptions)); + hash.update('\0'); hash.update(process.version); // Node.js version hash.update('\0'); hash.update('v1'); // The version of this implementation From e1bc9cc97dad94d45b3ca8a4b64d5ecc8a593b24 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 6 Oct 2024 13:36:31 +0200 Subject: [PATCH 11/20] cli-node: refactor to move getDependencyHash to Lockfile Signed-off-by: Patrik Oldsberg --- .changeset/calm-owls-move.md | 2 +- packages/cli-node/report.api.md | 3 +- packages/cli-node/src/monorepo/Lockfile.ts | 68 ++++++++++++++++--- .../cli-node/src/monorepo/PackageGraph.ts | 51 +------------- packages/cli/src/commands/repo/lint.ts | 24 +++++-- 5 files changed, 82 insertions(+), 66 deletions(-) diff --git a/.changeset/calm-owls-move.md b/.changeset/calm-owls-move.md index a4dcaeeff8..bd7fb72508 100644 --- a/.changeset/calm-owls-move.md +++ b/.changeset/calm-owls-move.md @@ -2,4 +2,4 @@ '@backstage/cli-node': patch --- -Added new `packageGraph.getDependencyHash(name)` utility. +Added new `lockfile.getDependencyTreeHash(name)` utility. diff --git a/packages/cli-node/report.api.md b/packages/cli-node/report.api.md index 6c2294012d..eb18e2e9f8 100644 --- a/packages/cli-node/report.api.md +++ b/packages/cli-node/report.api.md @@ -92,7 +92,7 @@ export function isMonoRepo(): Promise; export class Lockfile { createSimplifiedDependencyGraph(): Map>; diff(otherLockfile: Lockfile): LockfileDiff; - getVersions(name: string): string[]; + getDependencyTreeHash(startName: string): string; static load(path: string): Promise; static parse(content: string): Lockfile; } @@ -117,7 +117,6 @@ export class PackageGraph extends Map { collectFn: (pkg: PackageGraphNode) => Iterable | undefined, ): Set; static fromPackages(packages: Package[]): PackageGraph; - getDependencyHash(name: string): Promise; listChangedPackages(options: { ref: string; analyzeLockfile?: boolean; diff --git a/packages/cli-node/src/monorepo/Lockfile.ts b/packages/cli-node/src/monorepo/Lockfile.ts index d7336cf970..7c4dc04f33 100644 --- a/packages/cli-node/src/monorepo/Lockfile.ts +++ b/packages/cli-node/src/monorepo/Lockfile.ts @@ -15,6 +15,7 @@ */ import { parseSyml } from '@yarnpkg/parsers'; +import crypto from 'node:crypto'; import fs from 'fs-extra'; const ENTRY_PATTERN = /^((?:@[^/]+\/)?[^@/]+)@(.+)$/; @@ -133,14 +134,6 @@ export class Lockfile { private readonly data: LockfileData, ) {} - /** - * Returns all versions of a package in the lockfile. - */ - getVersions(name: string): string[] { - const queries = this.packages.get(name); - return queries ? queries.map(q => q.version) : []; - } - /** * Creates a simplified dependency graph from the lockfile data, where each * key is a package, and the value is a set of all packages that it depends on @@ -223,4 +216,63 @@ export class Lockfile { return diff; } + + /** + * Generates a sha1 hex hash of the dependency graph for a package. + */ + getDependencyTreeHash(startName: string): string { + if (!this.packages.has(startName)) { + throw new Error(`Package '${startName}' not found in lockfile`); + } + + const hash = crypto.createHash('sha1'); + + const queue = [startName]; + const seen = new Set(); + + while (queue.length > 0) { + const name = queue.pop()!; + + if (seen.has(name)) { + continue; + } + seen.add(name); + + const entries = this.packages.get(name); + if (!entries) { + continue; // In case of missing optional peer dependencies + } + + hash.update(`pkg:${name}`); + hash.update('\0'); + + // TODO(Rugvip): This uses the same simplified lookup as createSimplifiedDependencyGraph() + // we could match version queries to make the resulting tree a bit smaller. + const deps = new Array(); + for (const entry of entries) { + // We're not being particular about stable ordering here. If the lockfile ordering changes, so will likely hash. + hash.update(entry.version); + + const data = this.data[entry.dataKey]; + if (!data) { + continue; + } + + const checksum = data.checksum || data.integrity; + if (checksum) { + hash.update('#'); + hash.update(checksum); + } + + hash.update(' '); + + deps.push(...Object.keys(data.dependencies ?? {})); + deps.push(...Object.keys(data.peerDependencies ?? {})); + } + + queue.push(...new Set(deps)); + } + + return hash.digest('hex'); + } } diff --git a/packages/cli-node/src/monorepo/PackageGraph.ts b/packages/cli-node/src/monorepo/PackageGraph.ts index 91c5312bc1..69ec99c2dd 100644 --- a/packages/cli-node/src/monorepo/PackageGraph.ts +++ b/packages/cli-node/src/monorepo/PackageGraph.ts @@ -15,7 +15,6 @@ */ import path from 'path'; -import crypto from 'node:crypto'; import { getPackages, Package } from '@manypkg/get-packages'; import { paths } from '../paths'; import { PackageRole } from '../roles'; @@ -284,43 +283,6 @@ export class PackageGraph extends Map { return targets; } - /** - * Generates a sha1 hex hash of the dependency graph for a package. - */ - async getDependencyHash(name: string): Promise { - const pkg = this.get(name); - if (!pkg) { - throw new Error(`Package '${name}' not found`); - } - - const lockfile = await this.#getLockfile(); - const depGraph = lockfile.createSimplifiedDependencyGraph(); - - const seen = new Set(); - const queue = [name]; - - while (queue.length > 0) { - const deps = depGraph.get(queue.pop()!); - if (deps) { - for (const dep of deps) { - if (!seen.has(dep)) { - seen.add(dep); - queue.push(dep); - } - } - } - } - - const hash = crypto.createHash('sha1'); - for (const dep of Array.from(seen).sort()) { - hash.update(dep); - hash.update('\0'); - hash.update(lockfile.getVersions(dep).join(' ')); - hash.update('\0'); - } - return hash.digest('hex'); - } - /** * Lists all packages that have changed since a given git ref. * @@ -380,7 +342,9 @@ export class PackageGraph extends Map { let thisLockfile: Lockfile; let otherLockfile: Lockfile; try { - thisLockfile = await this.#getLockfile(); + thisLockfile = await Lockfile.load( + paths.resolveTargetRoot('yarn.lock'), + ); otherLockfile = Lockfile.parse( await GitUtils.readFileAtRef('yarn.lock', options.ref), ); @@ -446,13 +410,4 @@ export class PackageGraph extends Map { return result; } - - #lockfilePromise?: Promise; - #getLockfile(): Promise { - if (this.#lockfilePromise) { - return this.#lockfilePromise; - } - this.#lockfilePromise = Lockfile.load(paths.resolveTargetRoot('yarn.lock')); - return this.#lockfilePromise; - } } diff --git a/packages/cli/src/commands/repo/lint.ts b/packages/cli/src/commands/repo/lint.ts index b5a89471a7..655e524d19 100644 --- a/packages/cli/src/commands/repo/lint.ts +++ b/packages/cli/src/commands/repo/lint.ts @@ -19,7 +19,11 @@ import { Command, OptionValues } from 'commander'; import fs from 'fs-extra'; import { createHash } from 'crypto'; import { relative as relativePath, resolve as resolvePath } from 'path'; -import { PackageGraph, BackstagePackageJson } from '@backstage/cli-node'; +import { + PackageGraph, + BackstagePackageJson, + Lockfile, +} from '@backstage/cli-node'; import { paths } from '../../lib/paths'; import { runWorkerQueueThreads } from '../../lib/parallel'; import { createScriptOptionsParser } from './optionsParser'; @@ -63,11 +67,15 @@ export async function command(opts: OptionValues, cmd: Command): Promise { opts.cache === true ? paths.resolveTargetRoot('node_modules/.cache/backstage-cli') : opts.cache; - const cache = cacheDir ? await readCache(cacheDir) : undefined; - - const graph = PackageGraph.fromPackages(packages); + const cacheContext = cacheDir + ? { + cache: await readCache(cacheDir), + lockfile: await Lockfile.load(paths.resolveTargetRoot('yarn.lock')), + } + : undefined; if (opts.since) { + const graph = PackageGraph.fromPackages(packages); packages = await graph.listChangedPackages({ ref: opts.since, analyzeLockfile: true, @@ -100,13 +108,15 @@ export async function command(opts: OptionValues, cmd: Command): Promise { parentHash: undefined, }; - if (!cacheDir) { + if (!cacheContext) { return base; } const hash = createHash('sha1'); - hash.update(await graph.getDependencyHash(pkg.packageJson.name)); + hash.update( + cacheContext.lockfile.getDependencyTreeHash(pkg.packageJson.name), + ); hash.update('\0'); hash.update(JSON.stringify(lintOptions)); hash.update('\0'); @@ -127,7 +137,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise { fix: Boolean(opts.fix), format: opts.format as string | undefined, shouldCache: Boolean(cacheDir), - successCache: cache, + successCache: cacheContext?.cache, }, workerFactory: async ({ fix, format, shouldCache, successCache }) => { const { ESLint } = require('eslint') as typeof import('eslint'); From beaa723f1e084501725829c1709630d6f31ec521 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 6 Oct 2024 14:00:46 +0200 Subject: [PATCH 12/20] cli-node: added tests for lockfile.getDependencyTreeHash Signed-off-by: Patrik Oldsberg --- .../cli-node/src/monorepo/Lockfile.test.ts | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/packages/cli-node/src/monorepo/Lockfile.test.ts b/packages/cli-node/src/monorepo/Lockfile.test.ts index 4bc7a6d96e..03b50fea90 100644 --- a/packages/cli-node/src/monorepo/Lockfile.test.ts +++ b/packages/cli-node/src/monorepo/Lockfile.test.ts @@ -493,4 +493,95 @@ d@^1: ); }); }); + + describe('getDependencyTreeHash', () => { + const content = `${MODERN_HEADER} +"a@npm:^1": + version: "1.0.0" + checksum: sha512-a-1 + dependencies: + b: "^2" + +"b@npm:2.0.x, b@npm:^2": + version: "2.0.0" + checksum: sha512-b-1 + +"b@npm:4": + version: "3.0.0" + checksum: sha512-b-2 + +"c@npm:^1": + version: "4.0.0" + checksum: sha512-c-1 +`; + const lockfile = Lockfile.parse(content); + + const hashA = lockfile.getDependencyTreeHash('a'); + const hashB = lockfile.getDependencyTreeHash('b'); + const hashC = lockfile.getDependencyTreeHash('c'); + + it('should generate stable dependency hashes', () => { + expect(hashA).toMatchInlineSnapshot( + `"2d1d4c1c577c291e815e87779c72fc78a78e56cc"`, + ); + expect(hashB).toMatchInlineSnapshot( + `"7e46d0c7337540179b442c87a7c5555543798f15"`, + ); + expect(hashC).toMatchInlineSnapshot( + `"e65103abd217954bad40e2f834b990a5e6fa4054"`, + ); + }); + + it('should generate different hashes for different versions', () => { + const lockfileNewA = Lockfile.parse(content.replace('1.0.0', '1.0.1')); + expect(lockfileNewA.getDependencyTreeHash('a')).not.toBe(hashA); + expect(lockfileNewA.getDependencyTreeHash('b')).toBe(hashB); + expect(lockfileNewA.getDependencyTreeHash('c')).toBe(hashC); + + const lockfileNewB1 = Lockfile.parse(content.replace('2.0.0', '2.0.1')); + expect(lockfileNewB1.getDependencyTreeHash('a')).not.toBe(hashA); + expect(lockfileNewB1.getDependencyTreeHash('b')).not.toBe(hashB); + expect(lockfileNewB1.getDependencyTreeHash('c')).toBe(hashC); + + const lockfileNewB2 = Lockfile.parse(content.replace('3.0.0', '3.0.1')); + expect(lockfileNewB2.getDependencyTreeHash('a')).not.toBe(hashA); + expect(lockfileNewB2.getDependencyTreeHash('b')).not.toBe(hashB); + expect(lockfileNewB2.getDependencyTreeHash('c')).toBe(hashC); + + const lockfileNewC = Lockfile.parse(content.replace('4.0.0', '4.0.1')); + expect(lockfileNewC.getDependencyTreeHash('a')).toBe(hashA); + expect(lockfileNewC.getDependencyTreeHash('b')).toBe(hashB); + expect(lockfileNewC.getDependencyTreeHash('c')).not.toBe(hashC); + }); + + it('should generate different hashes for different checksums', () => { + const lockfileNewA = Lockfile.parse( + content.replace('sha512-a-1', 'sha512-a-1-new'), + ); + expect(lockfileNewA.getDependencyTreeHash('a')).not.toBe(hashA); + expect(lockfileNewA.getDependencyTreeHash('b')).toBe(hashB); + expect(lockfileNewA.getDependencyTreeHash('c')).toBe(hashC); + + const lockfileNewB1 = Lockfile.parse( + content.replace('sha512-b-1', 'sha512-b-1-new'), + ); + expect(lockfileNewB1.getDependencyTreeHash('a')).not.toBe(hashA); + expect(lockfileNewB1.getDependencyTreeHash('b')).not.toBe(hashB); + expect(lockfileNewB1.getDependencyTreeHash('c')).toBe(hashC); + + const lockfileNewB2 = Lockfile.parse( + content.replace('sha512-b-2', 'sha512-b-2-new'), + ); + expect(lockfileNewB2.getDependencyTreeHash('a')).not.toBe(hashA); + expect(lockfileNewB2.getDependencyTreeHash('b')).not.toBe(hashB); + expect(lockfileNewB2.getDependencyTreeHash('c')).toBe(hashC); + + const lockfileNewC = Lockfile.parse( + content.replace('sha512-c-1', 'sha512-c-1-new'), + ); + expect(lockfileNewC.getDependencyTreeHash('a')).toBe(hashA); + expect(lockfileNewC.getDependencyTreeHash('b')).toBe(hashB); + expect(lockfileNewC.getDependencyTreeHash('c')).not.toBe(hashC); + }); + }); }); From e5944f626a84910de0337d7acf74762a853120e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 7 Oct 2024 12:11:11 +0200 Subject: [PATCH 13/20] make room for the new named export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- plugins/catalog-node/package.json | 3 + plugins/catalog-react/package.json | 4 + plugins/catalog-react/report-testUtils.api.md | 19 ++++ plugins/catalog-react/report.api.md | 4 +- .../EntityAutocompletePicker.test.tsx | 2 +- .../EntityKindPicker.test.tsx | 2 +- .../EntityLifecyclePicker.test.tsx | 2 +- .../EntityNamespacePicker.test.tsx | 2 +- .../EntityOwnerPicker.test.tsx | 2 +- .../EntityProcessingStatusPicker.test.tsx | 2 +- .../EntitySearchBar/EntitySearchBar.test.tsx | 2 +- .../EntityTagPicker/EntityTagPicker.test.tsx | 2 +- .../EntityTypePicker.test.tsx | 2 +- .../UserListPicker/UserListPicker.test.tsx | 2 +- plugins/catalog-react/src/deprecated.tsx | 93 +++++++++++++++++++ plugins/catalog-react/src/index.ts | 2 +- .../src/{testUtils/index.ts => testUtils.ts} | 11 ++- ....tsx => MockEntityListContextProvider.tsx} | 8 +- 18 files changed, 147 insertions(+), 17 deletions(-) create mode 100644 plugins/catalog-react/report-testUtils.api.md create mode 100644 plugins/catalog-react/src/deprecated.tsx rename plugins/catalog-react/src/{testUtils/index.ts => testUtils.ts} (72%) rename plugins/catalog-react/src/testUtils/{providers.tsx => MockEntityListContextProvider.tsx} (95%) diff --git a/plugins/catalog-node/package.json b/plugins/catalog-node/package.json index 3c7052b78b..00bc98b251 100644 --- a/plugins/catalog-node/package.json +++ b/plugins/catalog-node/package.json @@ -16,6 +16,9 @@ "publishConfig": { "access": "public" }, + "keywords": [ + "backstage" + ], "homepage": "https://backstage.io", "repository": { "type": "git", diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json index c959e22b9e..cc59c46a6c 100644 --- a/plugins/catalog-react/package.json +++ b/plugins/catalog-react/package.json @@ -30,6 +30,7 @@ "exports": { ".": "./src/index.ts", "./alpha": "./src/alpha/index.ts", + "./testUtils": "./src/testUtils.ts", "./package.json": "./package.json" }, "main": "src/index.ts", @@ -39,6 +40,9 @@ "alpha": [ "src/alpha/index.ts" ], + "testUtils": [ + "src/testUtils.ts" + ], "package.json": [ "package.json" ] diff --git a/plugins/catalog-react/report-testUtils.api.md b/plugins/catalog-react/report-testUtils.api.md new file mode 100644 index 0000000000..5b00512236 --- /dev/null +++ b/plugins/catalog-react/report-testUtils.api.md @@ -0,0 +1,19 @@ +## API Report File for "@backstage/plugin-catalog-react" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { DefaultEntityFilters } from '@backstage/plugin-catalog-react'; +import { EntityListContextProps } from '@backstage/plugin-catalog-react'; +import { PropsWithChildren } from 'react'; +import { default as React_2 } from 'react'; + +// @public +export function MockEntityListContextProvider< + T extends DefaultEntityFilters = DefaultEntityFilters, +>( + props: PropsWithChildren<{ + value?: Partial>; + }>, +): React_2.JSX.Element; +``` diff --git a/plugins/catalog-react/report.api.md b/plugins/catalog-react/report.api.md index 0fbb27e70e..515a3526be 100644 --- a/plugins/catalog-react/report.api.md +++ b/plugins/catalog-react/report.api.md @@ -695,7 +695,7 @@ export function MissingAnnotationEmptyState(props: { // @public (undocumented) export type MissingAnnotationEmptyStateClassKey = 'code'; -// @public (undocumented) +// @public @deprecated (undocumented) export function MockEntityListContextProvider< T extends DefaultEntityFilters = DefaultEntityFilters, >( @@ -887,6 +887,7 @@ export function useStarredEntity( // src/components/UserListPicker/UserListPicker.d.ts:5:1 - (ae-undocumented) Missing documentation for "CatalogReactUserListPickerClassKey". // src/components/UserListPicker/UserListPicker.d.ts:15:1 - (ae-undocumented) Missing documentation for "UserListPickerProps". // src/components/UserListPicker/UserListPicker.d.ts:20:22 - (ae-undocumented) Missing documentation for "UserListPicker". +// src/deprecated.d.ts:7:1 - (ae-undocumented) Missing documentation for "MockEntityListContextProvider". // src/filters.d.ts:8:5 - (ae-undocumented) Missing documentation for "value". // src/filters.d.ts:10:5 - (ae-undocumented) Missing documentation for "getCatalogFilters". // src/filters.d.ts:11:5 - (ae-undocumented) Missing documentation for "toQueryValue". @@ -948,7 +949,6 @@ export function useStarredEntity( // src/hooks/useStarredEntity.d.ts:3:1 - (ae-undocumented) Missing documentation for "useStarredEntity". // src/overridableComponents.d.ts:6:1 - (ae-undocumented) Missing documentation for "CatalogReactComponentsNameToClassKey". // src/overridableComponents.d.ts:19:1 - (ae-undocumented) Missing documentation for "BackstageOverrides". -// src/testUtils/providers.d.ts:4:1 - (ae-undocumented) Missing documentation for "MockEntityListContextProvider". // src/types.d.ts:3:1 - (ae-undocumented) Missing documentation for "EntityFilter". // src/types.d.ts:25:1 - (ae-undocumented) Missing documentation for "UserListFilterKind". // src/types.d.ts:27:1 - (ae-undocumented) Missing documentation for "EntityListPagination". diff --git a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.test.tsx b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.test.tsx index 26dee40ec3..3e6ed3c2ed 100644 --- a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.test.tsx @@ -16,7 +16,7 @@ import { fireEvent, render, waitFor, screen } from '@testing-library/react'; import React from 'react'; -import { MockEntityListContextProvider } from '../../testUtils/providers'; +import { MockEntityListContextProvider } from '@backstage/plugin-catalog-react/testUtils'; import { EntityAutocompletePicker } from './EntityAutocompletePicker'; import { TestApiProvider } from '@backstage/test-utils'; import { catalogApiRef } from '../../api'; diff --git a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx index 612c515d28..4633862f2c 100644 --- a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx @@ -24,7 +24,7 @@ import { capitalize } from 'lodash'; import { default as React } from 'react'; import { catalogApiRef } from '../../api'; import { EntityKindFilter } from '../../filters'; -import { MockEntityListContextProvider } from '../../testUtils/providers'; +import { MockEntityListContextProvider } from '@backstage/plugin-catalog-react/testUtils'; import { EntityKindPicker } from './EntityKindPicker'; const entities: Entity[] = [ diff --git a/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.test.tsx b/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.test.tsx index fd779be1a2..701e1047c9 100644 --- a/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.test.tsx @@ -16,7 +16,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'; import React from 'react'; -import { MockEntityListContextProvider } from '../../testUtils/providers'; +import { MockEntityListContextProvider } from '@backstage/plugin-catalog-react/testUtils'; import { EntityLifecycleFilter } from '../../filters'; import { EntityLifecyclePicker } from './EntityLifecyclePicker'; import { TestApiProvider, renderInTestApp } from '@backstage/test-utils'; diff --git a/plugins/catalog-react/src/components/EntityNamespacePicker/EntityNamespacePicker.test.tsx b/plugins/catalog-react/src/components/EntityNamespacePicker/EntityNamespacePicker.test.tsx index 0fd336da42..5253dfae28 100644 --- a/plugins/catalog-react/src/components/EntityNamespacePicker/EntityNamespacePicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityNamespacePicker/EntityNamespacePicker.test.tsx @@ -16,7 +16,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'; import React from 'react'; -import { MockEntityListContextProvider } from '../../testUtils/providers'; +import { MockEntityListContextProvider } from '@backstage/plugin-catalog-react/testUtils'; import { EntityNamespaceFilter } from '../../filters'; import { EntityNamespacePicker } from './EntityNamespacePicker'; import { TestApiProvider, renderInTestApp } from '@backstage/test-utils'; diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.test.tsx b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.test.tsx index 8942dadca3..f8f2497b0f 100644 --- a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.test.tsx @@ -17,7 +17,7 @@ import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { fireEvent, screen, waitFor } from '@testing-library/react'; import React from 'react'; -import { MockEntityListContextProvider } from '../../testUtils/providers'; +import { MockEntityListContextProvider } from '@backstage/plugin-catalog-react/testUtils'; import { EntityOwnerFilter } from '../../filters'; import { EntityOwnerPicker } from './EntityOwnerPicker'; import { ApiProvider } from '@backstage/core-app-api'; diff --git a/plugins/catalog-react/src/components/EntityProcessingStatusPicker/EntityProcessingStatusPicker.test.tsx b/plugins/catalog-react/src/components/EntityProcessingStatusPicker/EntityProcessingStatusPicker.test.tsx index 329df71fae..16fb3130f3 100644 --- a/plugins/catalog-react/src/components/EntityProcessingStatusPicker/EntityProcessingStatusPicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityProcessingStatusPicker/EntityProcessingStatusPicker.test.tsx @@ -17,7 +17,7 @@ import { fireEvent, screen } from '@testing-library/react'; import React from 'react'; import { EntityErrorFilter, EntityOrphanFilter } from '../../filters'; -import { MockEntityListContextProvider } from '../../testUtils/providers'; +import { MockEntityListContextProvider } from '@backstage/plugin-catalog-react/testUtils'; import { EntityProcessingStatusPicker } from './EntityProcessingStatusPicker'; import { renderInTestApp } from '@backstage/test-utils'; diff --git a/plugins/catalog-react/src/components/EntitySearchBar/EntitySearchBar.test.tsx b/plugins/catalog-react/src/components/EntitySearchBar/EntitySearchBar.test.tsx index cbfeffa009..63047c163b 100644 --- a/plugins/catalog-react/src/components/EntitySearchBar/EntitySearchBar.test.tsx +++ b/plugins/catalog-react/src/components/EntitySearchBar/EntitySearchBar.test.tsx @@ -18,7 +18,7 @@ import React from 'react'; import { fireEvent, waitFor, screen } from '@testing-library/react'; import { EntitySearchBar } from './EntitySearchBar'; import { EntityTextFilter } from '../../filters'; -import { MockEntityListContextProvider } from '../../testUtils/providers'; +import { MockEntityListContextProvider } from '@backstage/plugin-catalog-react/testUtils'; import { renderInTestApp } from '@backstage/test-utils'; describe('EntitySearchBar', () => { diff --git a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx index 62f6991034..9b7fa2d2a3 100644 --- a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx @@ -16,7 +16,7 @@ import { fireEvent, waitFor, screen, act } from '@testing-library/react'; import React from 'react'; -import { MockEntityListContextProvider } from '../../testUtils/providers'; +import { MockEntityListContextProvider } from '@backstage/plugin-catalog-react/testUtils'; import { EntityTagFilter } from '../../filters'; import { EntityTagPicker } from './EntityTagPicker'; import { TestApiProvider, renderInTestApp } from '@backstage/test-utils'; diff --git a/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx b/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx index e558a36869..f7954f2604 100644 --- a/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx @@ -18,7 +18,7 @@ import React from 'react'; import { fireEvent, waitFor, screen, within } from '@testing-library/react'; import { Entity } from '@backstage/catalog-model'; import { EntityTypePicker } from './EntityTypePicker'; -import { MockEntityListContextProvider } from '../../testUtils/providers'; +import { MockEntityListContextProvider } from '@backstage/plugin-catalog-react/testUtils'; import { catalogApiRef } from '../../api'; import { EntityKindFilter, EntityTypeFilter } from '../../filters'; import { alertApiRef } from '@backstage/core-plugin-api'; diff --git a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx index 0b101e8174..5efb7339cf 100644 --- a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx +++ b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx @@ -18,7 +18,7 @@ import React from 'react'; import { fireEvent, waitFor, screen } from '@testing-library/react'; import { UserEntity } from '@backstage/catalog-model'; import { UserListPicker, UserListPickerProps } from './UserListPicker'; -import { MockEntityListContextProvider } from '../../testUtils/providers'; +import { MockEntityListContextProvider } from '@backstage/plugin-catalog-react/testUtils'; import { EntityKindFilter, EntityNamespaceFilter, diff --git a/plugins/catalog-react/src/deprecated.tsx b/plugins/catalog-react/src/deprecated.tsx new file mode 100644 index 0000000000..8e088502e8 --- /dev/null +++ b/plugins/catalog-react/src/deprecated.tsx @@ -0,0 +1,93 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { + PropsWithChildren, + useCallback, + useMemo, + useState, +} from 'react'; +import { + DefaultEntityFilters, + EntityListContext, + EntityListContextProps, +} from './hooks/useEntityListProvider'; + +/** + * @public + * @deprecated Moved to `@backstage/plugin-catalog-react/testUtils` + */ +export function MockEntityListContextProvider< + T extends DefaultEntityFilters = DefaultEntityFilters, +>( + props: PropsWithChildren<{ + value?: Partial>; + }>, +) { + const { children, value } = props; + + // Provides a default implementation that stores filter state, for testing components that + // reflect filter state. + const [filters, setFilters] = useState(value?.filters ?? ({} as T)); + + const updateFilters = useCallback( + (update: Partial | ((prevFilters: T) => Partial)) => { + setFilters(prevFilters => { + const newFilters = + typeof update === 'function' ? update(prevFilters) : update; + return { ...prevFilters, ...newFilters }; + }); + }, + [], + ); + + // Memoize the default values since pickers have useEffect triggers on these; naively defaulting + // below with `?? ` breaks referential equality on subsequent updates. + const defaultValues = useMemo( + () => ({ + entities: [], + backendEntities: [], + queryParameters: {}, + }), + [], + ); + + const resolvedValue: EntityListContextProps = useMemo( + () => ({ + entities: value?.entities ?? defaultValues.entities, + backendEntities: value?.backendEntities ?? defaultValues.backendEntities, + updateFilters: value?.updateFilters ?? updateFilters, + filters, + loading: value?.loading ?? false, + queryParameters: value?.queryParameters ?? defaultValues.queryParameters, + error: value?.error, + totalItems: + value?.totalItems ?? (value?.entities ?? defaultValues.entities).length, + limit: value?.limit ?? 20, + offset: value?.offset, + setLimit: value?.setLimit ?? (() => {}), + setOffset: value?.setOffset, + paginationMode: value?.paginationMode ?? 'none', + }), + [value, defaultValues, filters, updateFilters], + ); + + return ( + + {children} + + ); +} diff --git a/plugins/catalog-react/src/index.ts b/plugins/catalog-react/src/index.ts index b511116f84..2ea1eb6f53 100644 --- a/plugins/catalog-react/src/index.ts +++ b/plugins/catalog-react/src/index.ts @@ -28,8 +28,8 @@ export * from './components'; export * from './hooks'; export * from './filters'; export { entityRouteParams, entityRouteRef } from './routes'; -export * from './testUtils'; export * from './types'; export * from './overridableComponents'; export { getEntityRelations, getEntitySourceLocation } from './utils'; export type { EntitySourceLocation } from './utils'; +export * from './deprecated'; diff --git a/plugins/catalog-react/src/testUtils/index.ts b/plugins/catalog-react/src/testUtils.ts similarity index 72% rename from plugins/catalog-react/src/testUtils/index.ts rename to plugins/catalog-react/src/testUtils.ts index 2bfec07c81..4688e73feb 100644 --- a/plugins/catalog-react/src/testUtils/index.ts +++ b/plugins/catalog-react/src/testUtils.ts @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,4 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { MockEntityListContextProvider } from './providers'; + +/** + * Frontend test helpers for the Catalog plugin. + * + * @packageDocumentation + */ + +export { MockEntityListContextProvider } from './testUtils/MockEntityListContextProvider'; diff --git a/plugins/catalog-react/src/testUtils/providers.tsx b/plugins/catalog-react/src/testUtils/MockEntityListContextProvider.tsx similarity index 95% rename from plugins/catalog-react/src/testUtils/providers.tsx rename to plugins/catalog-react/src/testUtils/MockEntityListContextProvider.tsx index 9bb46dc6e5..fc01b5847b 100644 --- a/plugins/catalog-react/src/testUtils/providers.tsx +++ b/plugins/catalog-react/src/testUtils/MockEntityListContextProvider.tsx @@ -24,9 +24,13 @@ import { DefaultEntityFilters, EntityListContext, EntityListContextProps, -} from '../hooks/useEntityListProvider'; +} from '@backstage/plugin-catalog-react'; -/** @public */ +/** + * Simplifies testing of code that uses the entity list hooks. + * + * @public + */ export function MockEntityListContextProvider< T extends DefaultEntityFilters = DefaultEntityFilters, >( From 0801db61832fd55a4ad2dd2d72a9496ab2a06e3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 7 Oct 2024 12:35:52 +0200 Subject: [PATCH 14/20] add the api mock type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/great-eagles-repair.md | 5 + .changeset/healthy-years-search.md | 7 ++ .changeset/lovely-bees-walk.md | 5 + packages/frontend-test-utils/report.api.md | 11 ++ .../frontend-test-utils/src/apis/ApiMock.ts | 32 ++++++ .../frontend-test-utils/src/apis/index.ts | 1 + plugins/catalog-node/src/testUtils.ts | 6 + plugins/catalog-react/package.json | 2 +- plugins/catalog-react/report-testUtils.api.md | 17 +++ plugins/catalog-react/src/testUtils.ts | 1 + .../src/testUtils/catalogApiMock.test.ts | 63 +++++++++++ .../src/testUtils/catalogApiMock.ts | 104 ++++++++++++++++++ 12 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 .changeset/great-eagles-repair.md create mode 100644 .changeset/healthy-years-search.md create mode 100644 .changeset/lovely-bees-walk.md create mode 100644 packages/frontend-test-utils/src/apis/ApiMock.ts create mode 100644 plugins/catalog-react/src/testUtils/catalogApiMock.test.ts create mode 100644 plugins/catalog-react/src/testUtils/catalogApiMock.ts diff --git a/.changeset/great-eagles-repair.md b/.changeset/great-eagles-repair.md new file mode 100644 index 0000000000..886fb5df7a --- /dev/null +++ b/.changeset/great-eagles-repair.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-test-utils': patch +--- + +Added an `ApiMock`, analogous to `ServiceMock` from the backend test utils. diff --git a/.changeset/healthy-years-search.md b/.changeset/healthy-years-search.md new file mode 100644 index 0000000000..a613d11eea --- /dev/null +++ b/.changeset/healthy-years-search.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-catalog-react': minor +--- + +Add catalog service mocks under the `/testUtils` subpath export. + +You can now use e.g. `const catalog = catalogApiMock.mock()` in your test and then do assertions on `catalog.getEntities` without awkward type casting. diff --git a/.changeset/lovely-bees-walk.md b/.changeset/lovely-bees-walk.md new file mode 100644 index 0000000000..223acf1769 --- /dev/null +++ b/.changeset/lovely-bees-walk.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-node': patch +--- + +Documentation for the `testUtils` named export diff --git a/packages/frontend-test-utils/report.api.md b/packages/frontend-test-utils/report.api.md index 1337e5eb4f..3229da9898 100644 --- a/packages/frontend-test-utils/report.api.md +++ b/packages/frontend-test-utils/report.api.md @@ -3,11 +3,13 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +/// /// import { AnalyticsApi } from '@backstage/frontend-plugin-api'; import { AnalyticsEvent } from '@backstage/frontend-plugin-api'; import { AnyExtensionDataRef } from '@backstage/frontend-plugin-api'; +import { ApiFactory } from '@backstage/frontend-plugin-api'; import { AppNode } from '@backstage/frontend-plugin-api'; import { AppNodeInstance } from '@backstage/frontend-plugin-api'; import { ErrorWithContext } from '@backstage/test-utils'; @@ -32,6 +34,15 @@ import { TestApiProviderProps } from '@backstage/test-utils'; import { TestApiRegistry } from '@backstage/test-utils'; import { withLogCollector } from '@backstage/test-utils'; +// @public +export type ApiMock = { + factory: ApiFactory; +} & { + [Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return + ? TApi[Key] & jest.MockInstance + : TApi[Key]; +}; + // @public (undocumented) export function createExtensionTester( subject: ExtensionDefinition, diff --git a/packages/frontend-test-utils/src/apis/ApiMock.ts b/packages/frontend-test-utils/src/apis/ApiMock.ts new file mode 100644 index 0000000000..d11689f98a --- /dev/null +++ b/packages/frontend-test-utils/src/apis/ApiMock.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiFactory } from '@backstage/frontend-plugin-api'; + +/** + * Represents a mocked version of an API, where you automatically have access to + * the mocked versions of all of its methods along with a factory that returns + * that same mock. + * + * @public + */ +export type ApiMock = { + factory: ApiFactory; +} & { + [Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return + ? TApi[Key] & jest.MockInstance + : TApi[Key]; +}; diff --git a/packages/frontend-test-utils/src/apis/index.ts b/packages/frontend-test-utils/src/apis/index.ts index 1230be9be0..564c327aef 100644 --- a/packages/frontend-test-utils/src/apis/index.ts +++ b/packages/frontend-test-utils/src/apis/index.ts @@ -26,4 +26,5 @@ export { type MockStorageBucket, } from '@backstage/test-utils'; +export { type ApiMock } from './ApiMock'; export { MockAnalyticsApi } from './AnalyticsApi/MockAnalyticsApi'; diff --git a/plugins/catalog-node/src/testUtils.ts b/plugins/catalog-node/src/testUtils.ts index de60594701..fd1134daf4 100644 --- a/plugins/catalog-node/src/testUtils.ts +++ b/plugins/catalog-node/src/testUtils.ts @@ -14,4 +14,10 @@ * limitations under the License. */ +/** + * Backend test helpers for the Catalog plugin. + * + * @packageDocumentation + */ + export { catalogServiceMock } from './testUtils/catalogServiceMock'; diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json index cc59c46a6c..672ca2e0ed 100644 --- a/plugins/catalog-react/package.json +++ b/plugins/catalog-react/package.json @@ -68,6 +68,7 @@ "@backstage/core-plugin-api": "workspace:^", "@backstage/errors": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", + "@backstage/frontend-test-utils": "workspace:^", "@backstage/integration-react": "workspace:^", "@backstage/plugin-catalog-common": "workspace:^", "@backstage/plugin-permission-common": "workspace:^", @@ -89,7 +90,6 @@ "devDependencies": { "@backstage/cli": "workspace:^", "@backstage/core-app-api": "workspace:^", - "@backstage/frontend-test-utils": "workspace:^", "@backstage/plugin-catalog-common": "workspace:^", "@backstage/plugin-scaffolder-common": "workspace:^", "@backstage/test-utils": "workspace:^", diff --git a/plugins/catalog-react/report-testUtils.api.md b/plugins/catalog-react/report-testUtils.api.md index 5b00512236..331ef45988 100644 --- a/plugins/catalog-react/report-testUtils.api.md +++ b/plugins/catalog-react/report-testUtils.api.md @@ -3,11 +3,28 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { ApiFactory } from '@backstage/frontend-plugin-api'; +import { ApiMock } from '@backstage/frontend-test-utils'; +import { CatalogApi } from '@backstage/catalog-client'; import { DefaultEntityFilters } from '@backstage/plugin-catalog-react'; +import { Entity } from '@backstage/catalog-model'; import { EntityListContextProps } from '@backstage/plugin-catalog-react'; import { PropsWithChildren } from 'react'; import { default as React_2 } from 'react'; +// @public +export function catalogApiMock(options?: { entities?: Entity[] }): CatalogApi; + +// @public +export namespace catalogApiMock { + const factory: (options?: { + entities?: Entity[]; + }) => ApiFactory; + const mock: ( + partialImpl?: Partial | undefined, + ) => ApiMock; +} + // @public export function MockEntityListContextProvider< T extends DefaultEntityFilters = DefaultEntityFilters, diff --git a/plugins/catalog-react/src/testUtils.ts b/plugins/catalog-react/src/testUtils.ts index 4688e73feb..ffabf4f10c 100644 --- a/plugins/catalog-react/src/testUtils.ts +++ b/plugins/catalog-react/src/testUtils.ts @@ -20,4 +20,5 @@ * @packageDocumentation */ +export { catalogApiMock } from './testUtils/catalogApiMock'; export { MockEntityListContextProvider } from './testUtils/MockEntityListContextProvider'; diff --git a/plugins/catalog-react/src/testUtils/catalogApiMock.test.ts b/plugins/catalog-react/src/testUtils/catalogApiMock.test.ts new file mode 100644 index 0000000000..3501f808f2 --- /dev/null +++ b/plugins/catalog-react/src/testUtils/catalogApiMock.test.ts @@ -0,0 +1,63 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity } from '@backstage/catalog-model'; +import { catalogApiMock } from './catalogApiMock'; + +const entity1: Entity = { + apiVersion: 'v1', + kind: 'CustomKind', + metadata: { + namespace: 'default', + name: 'e1', + uid: 'u1', + }, +}; + +const entity2: Entity = { + apiVersion: 'v1', + kind: 'CustomKind', + metadata: { + namespace: 'default', + name: 'e2', + uid: 'u2', + }, +}; + +const entities = [entity1, entity2]; + +describe('catalogApiMock', () => { + it('exports the expected functionality', async () => { + const emptyFake = catalogApiMock(); + const notEmptyFake = catalogApiMock({ entities }); + + await expect(emptyFake.getEntities()).resolves.toEqual({ items: [] }); + await expect(notEmptyFake.getEntities()).resolves.toEqual({ + items: entities, + }); + + const mock = catalogApiMock.mock(); + expect(mock.getEntities).toHaveBeenCalledTimes(0); + expect(mock.getEntities()).toBeUndefined(); + mock.getEntities.mockResolvedValue({ items: entities }); + await expect(mock.getEntities()).resolves.toEqual({ items: entities }); + + const mock2 = catalogApiMock.mock({ + getEntities: async () => ({ items: [entity1] }), + }); + await expect(mock2.getEntities()).resolves.toEqual({ items: [entity1] }); + }); +}); diff --git a/plugins/catalog-react/src/testUtils/catalogApiMock.ts b/plugins/catalog-react/src/testUtils/catalogApiMock.ts new file mode 100644 index 0000000000..8cf3b740b0 --- /dev/null +++ b/plugins/catalog-react/src/testUtils/catalogApiMock.ts @@ -0,0 +1,104 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ApiFactory, + ApiRef, + createApiFactory, +} from '@backstage/frontend-plugin-api'; +import { InMemoryCatalogClient } from '@backstage/catalog-client/testUtils'; +import { Entity } from '@backstage/catalog-model'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { CatalogApi } from '@backstage/catalog-client'; +import { ApiMock } from '@backstage/frontend-test-utils'; + +/** @internal */ +function simpleMock( + ref: ApiRef, + mockFactory: () => jest.Mocked, +): (partialImpl?: Partial) => ApiMock { + return partialImpl => { + const mock = mockFactory(); + if (partialImpl) { + for (const [key, impl] of Object.entries(partialImpl)) { + if (typeof impl === 'function') { + (mock as any)[key].mockImplementation(impl); + } else { + (mock as any)[key] = impl; + } + } + } + return Object.assign(mock, { + factory: createApiFactory({ + api: ref, + deps: {}, + factory: () => mock, + }), + }) as ApiMock; + }; +} + +/** + * Creates a fake catalog client that handles entities in memory storage. Note + * that this client may be severely limited in functionality, and advanced + * functions may not be available at all. + * + * @public + */ +export function catalogApiMock(options?: { entities?: Entity[] }): CatalogApi { + return new InMemoryCatalogClient(options); +} + +/** + * A collection of mock functionality for the catalog service. + * + * @public + */ +export namespace catalogApiMock { + /** + * Creates a fake catalog client that handles entities in memory storage. Note + * that this client may be severely limited in functionality, and advanced + * functions may not be available at all. + */ + export const factory = (options?: { + entities?: Entity[]; + }): ApiFactory => + createApiFactory({ + api: catalogApiRef, + deps: {}, + factory: () => new InMemoryCatalogClient(options), + }); + /** + * Creates a catalog client whose methods are mock functions, possibly with + * some of them overloaded by the caller. + */ + export const mock = simpleMock(catalogApiRef, () => ({ + getEntities: jest.fn(), + getEntitiesByRefs: jest.fn(), + queryEntities: jest.fn(), + getEntityAncestors: jest.fn(), + getEntityByRef: jest.fn(), + removeEntityByUid: jest.fn(), + refreshEntity: jest.fn(), + getEntityFacets: jest.fn(), + getLocationById: jest.fn(), + getLocationByRef: jest.fn(), + addLocation: jest.fn(), + removeLocationById: jest.fn(), + getLocationByEntity: jest.fn(), + validateEntity: jest.fn(), + })); +} From d0ce429b1307c429613f30911ac6a2875a54a9a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 7 Oct 2024 12:40:28 +0200 Subject: [PATCH 15/20] stop using the deprecated provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../components/CatalogKindHeader/CatalogKindHeader.test.tsx | 2 +- .../catalog/src/components/CatalogTable/CatalogTable.test.tsx | 2 +- .../CatalogTable/CursorPaginatedCatalogTable.test.tsx | 3 ++- .../CatalogTable/OffsetPaginatedCatalogTable.test.tsx | 3 ++- .../components/TemplateTypePicker/TemplateTypePicker.test.tsx | 2 +- .../src/home/components/Grids/EntityListDocsGrid.test.tsx | 2 +- 6 files changed, 8 insertions(+), 6 deletions(-) diff --git a/plugins/catalog/src/components/CatalogKindHeader/CatalogKindHeader.test.tsx b/plugins/catalog/src/components/CatalogKindHeader/CatalogKindHeader.test.tsx index d6a1fb433a..a169e9139a 100644 --- a/plugins/catalog/src/components/CatalogKindHeader/CatalogKindHeader.test.tsx +++ b/plugins/catalog/src/components/CatalogKindHeader/CatalogKindHeader.test.tsx @@ -21,8 +21,8 @@ import { Entity } from '@backstage/catalog-model'; import { catalogApiRef, EntityKindFilter, - MockEntityListContextProvider, } from '@backstage/plugin-catalog-react'; +import { MockEntityListContextProvider } from '@backstage/plugin-catalog-react/testUtils'; import { ApiProvider } from '@backstage/core-app-api'; import { MockErrorApi, diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx index fea2580d2f..5b7f28e26a 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx @@ -23,11 +23,11 @@ import { ApiProvider } from '@backstage/core-app-api'; import { EntityKindFilter, entityRouteRef, - MockEntityListContextProvider, MockStarredEntitiesApi, starredEntitiesApiRef, UserListFilter, } from '@backstage/plugin-catalog-react'; +import { MockEntityListContextProvider } from '@backstage/plugin-catalog-react/testUtils'; import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; import { act, fireEvent, screen } from '@testing-library/react'; import * as React from 'react'; diff --git a/plugins/catalog/src/components/CatalogTable/CursorPaginatedCatalogTable.test.tsx b/plugins/catalog/src/components/CatalogTable/CursorPaginatedCatalogTable.test.tsx index be250fddbf..adc69794ae 100644 --- a/plugins/catalog/src/components/CatalogTable/CursorPaginatedCatalogTable.test.tsx +++ b/plugins/catalog/src/components/CatalogTable/CursorPaginatedCatalogTable.test.tsx @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import React, { ReactNode } from 'react'; import { fireEvent, screen, waitFor } from '@testing-library/react'; import { CursorPaginatedCatalogTable } from './CursorPaginatedCatalogTable'; @@ -22,8 +23,8 @@ import { DefaultEntityFilters, EntityKindFilter, EntityListContextProps, - MockEntityListContextProvider, } from '@backstage/plugin-catalog-react'; +import { MockEntityListContextProvider } from '@backstage/plugin-catalog-react/testUtils'; describe('CursorPaginatedCatalogTable', () => { const data = new Array(100).fill(0).map((_, index) => { diff --git a/plugins/catalog/src/components/CatalogTable/OffsetPaginatedCatalogTable.test.tsx b/plugins/catalog/src/components/CatalogTable/OffsetPaginatedCatalogTable.test.tsx index 3c8f51a6ad..aad9b7cb3d 100644 --- a/plugins/catalog/src/components/CatalogTable/OffsetPaginatedCatalogTable.test.tsx +++ b/plugins/catalog/src/components/CatalogTable/OffsetPaginatedCatalogTable.test.tsx @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import React, { ReactNode } from 'react'; import { fireEvent, screen } from '@testing-library/react'; import { CatalogTableRow } from './types'; @@ -20,8 +21,8 @@ import { renderInTestApp } from '@backstage/test-utils'; import { DefaultEntityFilters, EntityListContextProps, - MockEntityListContextProvider, } from '@backstage/plugin-catalog-react'; +import { MockEntityListContextProvider } from '@backstage/plugin-catalog-react/testUtils'; import { OffsetPaginatedCatalogTable } from './OffsetPaginatedCatalogTable'; describe('OffsetPaginatedCatalogTable', () => { diff --git a/plugins/scaffolder/src/components/TemplateTypePicker/TemplateTypePicker.test.tsx b/plugins/scaffolder/src/components/TemplateTypePicker/TemplateTypePicker.test.tsx index 4c4b88e471..e7b12f42c0 100644 --- a/plugins/scaffolder/src/components/TemplateTypePicker/TemplateTypePicker.test.tsx +++ b/plugins/scaffolder/src/components/TemplateTypePicker/TemplateTypePicker.test.tsx @@ -22,8 +22,8 @@ import { TemplateTypePicker } from './TemplateTypePicker'; import { catalogApiRef, EntityKindFilter, - MockEntityListContextProvider, } from '@backstage/plugin-catalog-react'; +import { MockEntityListContextProvider } from '@backstage/plugin-catalog-react/testUtils'; import { AlertApi, alertApiRef } from '@backstage/core-plugin-api'; import { ApiProvider } from '@backstage/core-app-api'; import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; diff --git a/plugins/techdocs/src/home/components/Grids/EntityListDocsGrid.test.tsx b/plugins/techdocs/src/home/components/Grids/EntityListDocsGrid.test.tsx index acaf01b318..1e65efb587 100644 --- a/plugins/techdocs/src/home/components/Grids/EntityListDocsGrid.test.tsx +++ b/plugins/techdocs/src/home/components/Grids/EntityListDocsGrid.test.tsx @@ -24,9 +24,9 @@ import { CatalogApi, catalogApiRef, starredEntitiesApiRef, - MockEntityListContextProvider, MockStarredEntitiesApi, } from '@backstage/plugin-catalog-react'; +import { MockEntityListContextProvider } from '@backstage/plugin-catalog-react/testUtils'; import { MockStorageApi, renderInTestApp, From 11299d488c0eda7137c9c0bf112e087e87e9ea4f Mon Sep 17 00:00:00 2001 From: Ben Lambert Date: Mon, 7 Oct 2024 13:09:35 +0200 Subject: [PATCH 16/20] Update thick-tables-give.md Signed-off-by: Ben Lambert --- .changeset/thick-tables-give.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/thick-tables-give.md b/.changeset/thick-tables-give.md index 8fe55346b9..e6b4026e51 100644 --- a/.changeset/thick-tables-give.md +++ b/.changeset/thick-tables-give.md @@ -2,4 +2,4 @@ '@backstage/plugin-scaffolder-node': patch --- -Use Git class' branch function instead of checkout function when creating branch +Use `branch` function instead of `checkout` function when creating branch From 38911b78e3993c85ea99b41db2d311a4bdfe1ae7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 7 Oct 2024 13:59:39 +0200 Subject: [PATCH 17/20] implement in some tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- plugins/catalog-node/report-testUtils.api.md | 2 - .../useUnregisterEntityDialogState.test.tsx | 15 ++--- .../OwnershipCard/useGetEntities.test.ts | 64 +++++++++---------- .../components/Workflow/Workflow.test.tsx | 10 +-- .../TemplateWizardPage.test.tsx | 17 +++-- 5 files changed, 49 insertions(+), 59 deletions(-) diff --git a/plugins/catalog-node/report-testUtils.api.md b/plugins/catalog-node/report-testUtils.api.md index c1dfb0bd7a..ec50d835be 100644 --- a/plugins/catalog-node/report-testUtils.api.md +++ b/plugins/catalog-node/report-testUtils.api.md @@ -22,6 +22,4 @@ export namespace catalogServiceMock { partialImpl?: Partial | undefined, ) => ServiceMock; } - -// (No @packageDocumentation comment for this package) ``` diff --git a/plugins/catalog-react/src/components/UnregisterEntityDialog/useUnregisterEntityDialogState.test.tsx b/plugins/catalog-react/src/components/UnregisterEntityDialog/useUnregisterEntityDialogState.test.tsx index f0da550d0b..a3230a0cc4 100644 --- a/plugins/catalog-react/src/components/UnregisterEntityDialog/useUnregisterEntityDialogState.test.tsx +++ b/plugins/catalog-react/src/components/UnregisterEntityDialog/useUnregisterEntityDialogState.test.tsx @@ -14,13 +14,14 @@ * limitations under the License. */ -import { CatalogApi, Location } from '@backstage/catalog-client'; +import { Location } from '@backstage/catalog-client'; import { Entity, ANNOTATION_ORIGIN_LOCATION } from '@backstage/catalog-model'; import { catalogApiRef } from '../../api'; import { renderHook, waitFor } from '@testing-library/react'; import React from 'react'; import { useUnregisterEntityDialogState } from './useUnregisterEntityDialogState'; import { TestApiProvider } from '@backstage/test-utils'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; function defer(): { promise: Promise; resolve: (value: T) => void } { let resolve: (value: T) => void = () => {}; @@ -31,13 +32,7 @@ function defer(): { promise: Promise; resolve: (value: T) => void } { } describe('useUnregisterEntityDialogState', () => { - const catalogApiMock = { - getLocationByRef: jest.fn(), - getEntities: jest.fn(), - removeLocationById: jest.fn(), - removeEntityByUid: jest.fn(), - }; - const catalogApi = catalogApiMock as Partial as CatalogApi; + const catalogApi = catalogApiMock.mock(); const Wrapper = (props: { children?: React.ReactNode }) => ( @@ -58,8 +53,8 @@ describe('useUnregisterEntityDialogState', () => { resolveLocation = deferredLocation.resolve; resolveColocatedEntities = deferredColocatedEntities.resolve; - catalogApiMock.getLocationByRef.mockReturnValue(deferredLocation.promise); - catalogApiMock.getEntities.mockReturnValue( + catalogApi.getLocationByRef.mockReturnValue(deferredLocation.promise); + catalogApi.getEntities.mockReturnValue( deferredColocatedEntities.promise.then(items => ({ items })), ); diff --git a/plugins/org/src/components/Cards/OwnershipCard/useGetEntities.test.ts b/plugins/org/src/components/Cards/OwnershipCard/useGetEntities.test.ts index 65a8dec226..b7331479ef 100644 --- a/plugins/org/src/components/Cards/OwnershipCard/useGetEntities.test.ts +++ b/plugins/org/src/components/Cards/OwnershipCard/useGetEntities.test.ts @@ -13,11 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { CompoundEntityRef, Entity } from '@backstage/catalog-model'; import { useGetEntities } from './useGetEntities'; -import { CatalogApi } from '@backstage/catalog-client'; import { renderHook, waitFor } from '@testing-library/react'; -import { getEntityRelations } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; const givenParentGroup = 'team.squad1'; const givenLeafGroup = 'team.squad2'; @@ -31,15 +31,14 @@ const givenUserEntity = { }, } as Partial as Entity; -const getEntitiesByRefsMock = jest.fn(); -const catalogApiMock: Pick = { +const catalogApi = catalogApiMock.mock({ getEntities: jest.fn(async () => Promise.resolve({ items: [] })), - getEntitiesByRefs: getEntitiesByRefsMock, -}; +}); -jest.mock('@backstage/core-plugin-api', () => ({ - useApi: jest.fn(() => catalogApiMock), -})); +jest.mock('@backstage/core-plugin-api', () => { + const actual = jest.requireActual('@backstage/core-plugin-api'); + return { ...actual, useApi: jest.fn(() => catalogApi) }; +}); const getEntityRelationsMock: jest.Mock< CompoundEntityRef[], @@ -50,7 +49,7 @@ jest.mock('@backstage/plugin-catalog-react', () => { catalogApiRef: {}, getEntityRelations: jest.fn(entity => { return getEntityRelationsMock(entity); - }) as typeof getEntityRelations, + }) as any, }; }); @@ -77,16 +76,17 @@ describe('useGetEntities', () => { }; beforeEach(() => { - getEntitiesByRefsMock.mockImplementation(async ({ entityRefs: [ref] }) => - ref.includes(givenParentGroup) - ? { items: [givenParentGroupEntity] } - : { items: [givenLeafGroupEntity] }, + catalogApi.getEntitiesByRefs.mockImplementation( + async ({ entityRefs: [ref] }) => + ref.includes(givenParentGroup) + ? { items: [givenParentGroupEntity] } + : { items: [givenLeafGroupEntity] }, ); }); afterEach(() => { getEntityRelationsMock.mockRestore(); - getEntitiesByRefsMock.mockRestore(); + catalogApi.getEntitiesByRefs.mockRestore(); }); describe('when given entity is a group', () => { @@ -98,7 +98,7 @@ describe('useGetEntities', () => { it('should aggregate child ownership', async () => { await whenHookIsCalledWith(givenParentGroupEntity); - expect(catalogApiMock.getEntities).toHaveBeenCalledWith( + expect(catalogApi.getEntities).toHaveBeenCalledWith( ownersFilter( `group:default/${givenParentGroup}`, `group:default/${givenLeafGroup}`, @@ -108,7 +108,7 @@ describe('useGetEntities', () => { it('should retrieve child with their relations', async () => { await whenHookIsCalledWith(givenParentGroupEntity); - expect(catalogApiMock.getEntitiesByRefs).toHaveBeenCalledWith({ + expect(catalogApi.getEntitiesByRefs).toHaveBeenCalledWith({ entityRefs: [`group:default/${givenLeafGroup}`], fields: ['kind', 'metadata.namespace', 'metadata.name', 'relations'], }); @@ -121,8 +121,8 @@ describe('useGetEntities', () => { ); beforeEach(() => { - getEntitiesByRefsMock.mockRestore(); - getEntitiesByRefsMock.mockImplementation( + catalogApi.getEntitiesByRefs.mockRestore(); + catalogApi.getEntitiesByRefs.mockImplementation( async ({ entityRefs: [ref] }) => { if (ref.includes(givenParentGroup)) { return { items: [givenParentGroupEntity] }; @@ -152,7 +152,7 @@ describe('useGetEntities', () => { }); await whenHookIsCalledWith(givenParentGroupEntity); - expect(catalogApiMock.getEntities).toHaveBeenCalledWith( + expect(catalogApi.getEntities).toHaveBeenCalledWith( ownersFilter( `group:default/${givenParentGroup}`, `group:default/${givenIntermediateGroup}`, @@ -177,7 +177,7 @@ describe('useGetEntities', () => { }); await whenHookIsCalledWith(givenParentGroupEntity); - expect(catalogApiMock.getEntities).toHaveBeenCalledWith( + expect(catalogApi.getEntities).toHaveBeenCalledWith( ownersFilter( `group:default/${givenParentGroup}`, `group:default/${givenIntermediateGroup}`, @@ -195,7 +195,7 @@ describe('useGetEntities', () => { ]); await whenHookIsCalledWith(givenUserEntity); - expect(catalogApiMock.getEntities).toHaveBeenCalledWith( + expect(catalogApi.getEntities).toHaveBeenCalledWith( ownersFilter( `group:default/${givenLeafGroup}`, `user:default/${givenUser}`, @@ -219,14 +219,14 @@ describe('useGetEntities', () => { it('given group entity should return directly owned entities', async () => { await whenHookIsCalledWith(givenLeafGroupEntity); - expect(catalogApiMock.getEntities).toHaveBeenCalledWith( + expect(catalogApi.getEntities).toHaveBeenCalledWith( ownersFilter(`group:default/${givenLeafGroup}`), ); }); it('given user entity should return directly owned entities', async () => { await whenHookIsCalledWith(givenUserEntity); - expect(catalogApiMock.getEntities).toHaveBeenCalledWith( + expect(catalogApi.getEntities).toHaveBeenCalledWith( ownersFilter(`user:default/${givenUser}`), ); }); @@ -255,7 +255,7 @@ describe('useGetEntities', () => { ? manyGroups.map(group => createGroupRefFromName(group.metadata.name)) : [], ); - (catalogApiMock.getEntities as jest.Mock).mockClear(); + catalogApi.getEntities.mockClear(); }); it('should handle 500+ relations without exceeding URL length limits', async () => { @@ -270,14 +270,13 @@ describe('useGetEntities', () => { timeout: 5000, }); - const callArgs = (catalogApiMock.getEntities as jest.Mock).mock - .calls[0][0]; + const callArgs = catalogApi.getEntities.mock.calls[0][0]; expect( - callArgs.filter[0]['relations.ownedBy'].length, + (callArgs!.filter as any)[0]['relations.ownedBy'].length, ).toBeLessThanOrEqual(100); - const owners = callArgs.filter[0]['relations.ownedBy']; + const owners = (callArgs!.filter as any)[0]['relations.ownedBy']; expect(Array.isArray(owners)).toBeTruthy(); expect(owners.length).toBeLessThanOrEqual(100); @@ -309,7 +308,7 @@ describe('useGetEntities', () => { ) : [], ); - (catalogApiMock.getEntities as jest.Mock).mockClear(); + catalogApi.getEntities.mockClear(); }); it('should batch the request to avoid exceeding header size limits', async () => { @@ -323,8 +322,7 @@ describe('useGetEntities', () => { await waitFor(() => expect(result.current.loading).toBe(false), { timeout: 5000, }); - const callArgs = (catalogApiMock.getEntities as jest.Mock).mock - .calls[0][0]; + const callArgs = catalogApi.getEntities.mock.calls[0][0]; const url = new URL( `http://localhost/api/catalog/entities?${new URLSearchParams( @@ -334,7 +332,7 @@ describe('useGetEntities', () => { const headerSize = url.href.length; expect(headerSize).toBeLessThanOrEqual(16384); - const owners = callArgs.filter[0]['relations.ownedBy']; + const owners = (callArgs!.filter as any)[0]['relations.ownedBy']; expect(Array.isArray(owners)).toBeTruthy(); expect(owners.length).toBeLessThanOrEqual(100); }); diff --git a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx index 3a2aa1469f..49f5c1feb0 100644 --- a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx +++ b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx @@ -25,7 +25,8 @@ import React from 'react'; import { Workflow } from './Workflow'; import { analyticsApiRef } from '@backstage/core-plugin-api'; import { ScaffolderApi, scaffolderApiRef } from '../../../api'; -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; const scaffolderApiMock: jest.Mocked = { cancelTask: jest.fn(), @@ -38,14 +39,13 @@ const scaffolderApiMock: jest.Mocked = { listTasks: jest.fn(), autocomplete: jest.fn(), }; -const catalogApiMock: jest.Mocked = { - getEntityByRef: jest.fn(), -} as any; + +const catalogApi = catalogApiMock.mock(); const analyticsMock = new MockAnalyticsApi(); const apis = TestApiRegistry.from( [scaffolderApiRef, scaffolderApiMock], - [catalogApiRef, catalogApiMock], + [catalogApiRef, catalogApi], [analyticsApiRef, analyticsMock], ); diff --git a/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.test.tsx b/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.test.tsx index 689a96b95d..750efd70d0 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.test.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.test.tsx @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { ApiProvider } from '@backstage/core-app-api'; import { analyticsApiRef } from '@backstage/core-plugin-api'; import { @@ -30,8 +31,8 @@ import { import { TemplateWizardPage } from './TemplateWizardPage'; import { rootRouteRef } from '../../../routes'; import { ANNOTATION_EDIT_URL } from '@backstage/catalog-model'; -import { CatalogApi } from '@backstage/catalog-client'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; jest.mock('react-router-dom', () => { return { @@ -54,16 +55,14 @@ const scaffolderApiMock: jest.Mocked = { autocomplete: jest.fn(), }; -const catalogApiMock: jest.Mocked = { - getEntityByRef: jest.fn(), -} as any; +const catalogApi = catalogApiMock.mock(); const analyticsMock = new MockAnalyticsApi(); const apis = TestApiRegistry.from( [scaffolderApiRef, scaffolderApiMock], - [catalogApiRef, catalogApiMock], + [catalogApiRef, catalogApi], [analyticsApiRef, analyticsMock], - [catalogApiRef, catalogApiMock], + [catalogApiRef, catalogApi], ); const entityRefResponse = { @@ -100,7 +99,7 @@ describe('TemplateWizardPage', () => { ], title: 'React JSON Schema Form Test', }); - catalogApiMock.getEntityByRef.mockResolvedValue(entityRefResponse); + catalogApi.getEntityByRef.mockResolvedValue(entityRefResponse); const { findByRole, getByRole } = await renderInTestApp( @@ -147,7 +146,7 @@ describe('TemplateWizardPage', () => { }); describe('scaffolder page context menu', () => { it('should render if editUrl is set to url', async () => { - catalogApiMock.getEntityByRef.mockResolvedValue({ + catalogApi.getEntityByRef.mockResolvedValue({ apiVersion: 'v1', kind: 'service', metadata: { @@ -177,7 +176,7 @@ describe('TemplateWizardPage', () => { expect(queryByTestId('menu-button')).toBeInTheDocument(); }); it('should not render if editUrl is undefined', async () => { - catalogApiMock.getEntityByRef.mockResolvedValue({ + catalogApi.getEntityByRef.mockResolvedValue({ apiVersion: 'v1', kind: 'service', metadata: { From 4ecd2e9f11ddc399679a1d4c785bc06b912f6787 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 7 Oct 2024 14:19:58 +0200 Subject: [PATCH 18/20] fixup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- packages/frontend-test-utils/package.json | 1 + yarn.lock | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/frontend-test-utils/package.json b/packages/frontend-test-utils/package.json index 27bb592879..49869b7df9 100644 --- a/packages/frontend-test-utils/package.json +++ b/packages/frontend-test-utils/package.json @@ -50,6 +50,7 @@ }, "peerDependencies": { "@testing-library/react": "^16.0.0", + "@types/jest": "*", "@types/react": "^16.13.1 || ^17.0.0 || ^18.0.0", "react": "^16.13.1 || ^17.0.0 || ^18.0.0", "react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0", diff --git a/yarn.lock b/yarn.lock index f5184e9149..cd65c170d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4674,6 +4674,7 @@ __metadata: zod: ^3.22.4 peerDependencies: "@testing-library/react": ^16.0.0 + "@types/jest": "*" "@types/react": ^16.13.1 || ^17.0.0 || ^18.0.0 react: ^16.13.1 || ^17.0.0 || ^18.0.0 react-dom: ^16.13.1 || ^17.0.0 || ^18.0.0 From 50cc7351cf6ce9f42b1d248e6af62758a603d802 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 7 Oct 2024 16:37:03 +0200 Subject: [PATCH 19/20] cli: switch repo lint --cache option to --successCache and --successCacheDir Signed-off-by: Patrik Oldsberg --- .changeset/small-donkeys-attack.md | 4 +++- .github/workflows/ci.yml | 2 +- .github/workflows/deploy_packages.yml | 2 +- .github/workflows/verify_windows.yml | 2 +- docs/tooling/cli/02-build-system.md | 2 +- packages/cli/cli-report.md | 3 ++- packages/cli/src/commands/index.ts | 8 ++++++-- packages/cli/src/commands/repo/lint.ts | 13 ++++++------- 8 files changed, 21 insertions(+), 15 deletions(-) diff --git a/.changeset/small-donkeys-attack.md b/.changeset/small-donkeys-attack.md index 97a65cbfd9..116cc611b5 100644 --- a/.changeset/small-donkeys-attack.md +++ b/.changeset/small-donkeys-attack.md @@ -2,4 +2,6 @@ '@backstage/cli': patch --- -Added a new `--cache [path]` option to the `backstage-cli repo lint` command. The cache keeps track of successful lint runs and avoids re-running linting of individual packages if they haven't changed. This option is primarily intended to be used in CI. +Added a new `--successCache` option to the `backstage-cli repo lint` command. The cache keeps track of successful lint runs and avoids re-running linting of individual packages if they haven't changed. This option is primarily intended to be used in CI. + +In addition a `--successCacheDir` option has also been added to be able to override the default cache directory. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28c53db776..abe36f40de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -234,7 +234,7 @@ jobs: run: node scripts/verify-release.js - name: lint changed packages - run: yarn backstage-cli repo lint --since origin/master --cache + run: yarn backstage-cli repo lint --since origin/master --successCache - name: test changed packages run: yarn backstage-cli repo test --maxWorkers=3 --workerIdleMemoryLimit=1300M --since origin/master diff --git a/.github/workflows/deploy_packages.yml b/.github/workflows/deploy_packages.yml index b09f419fed..073f210384 100644 --- a/.github/workflows/deploy_packages.yml +++ b/.github/workflows/deploy_packages.yml @@ -94,7 +94,7 @@ jobs: run: yarn backstage-cli config:check --lax - name: lint - run: yarn backstage-cli repo lint --cache + run: yarn backstage-cli repo lint --successCache - name: type checking and declarations run: yarn tsc:full diff --git a/.github/workflows/verify_windows.yml b/.github/workflows/verify_windows.yml index 113c76d497..80db28c2e1 100644 --- a/.github/workflows/verify_windows.yml +++ b/.github/workflows/verify_windows.yml @@ -46,7 +46,7 @@ jobs: run: yarn install --immutable - name: lint - run: yarn backstage-cli repo lint --cache + run: yarn backstage-cli repo lint --successCache - name: type checking and declarations run: yarn tsc:full diff --git a/docs/tooling/cli/02-build-system.md b/docs/tooling/cli/02-build-system.md index c922134a7c..52410001f7 100644 --- a/docs/tooling/cli/02-build-system.md +++ b/docs/tooling/cli/02-build-system.md @@ -564,7 +564,7 @@ The overrides in a single `package.json` may for example look like this: Caching is used sparingly throughout the Backstage build system. It is always used as a way to squeeze out a little bit of extra performance, rather than requirement to keep things fast. The following is a list of places where optional caching is available: - **TypeScript** - The default `tsconfig.json` used by Backstage projects has `incremental` set to `true`, which enables local caching of type checking results. It is however generally not recommended in CI, where `yarn tsc:full` is preferred, which sets `--incremental false`. -- **Linting** - The `backstage-cli repo lint` command has a `--cache` flag that enables caching of successful linting results. This is done at the package level, meaning that if a package has not been changed since the last lint run and it was successful, the linting will be skipped. This is recommended to be used in CI, but not during local development. +- **Linting** - The `backstage-cli repo lint` command has a `--successCache` flag that enables caching of successful linting results. This is done at the package level, meaning that if a package has not been changed since the last lint run and it was successful, the linting will be skipped. This is recommended to be used in CI, but not during local development. - **Webpack** - It is possible to enable experimental caching of frontend package builds using the `BACKSTAGE_CLI_EXPERIMENTAL_BUILD_CACHE` environment variable. This will enable the Webpack filesystem cache. ### Debugging Jest Tests diff --git a/packages/cli/cli-report.md b/packages/cli/cli-report.md index 64348a872e..72cf7c4505 100644 --- a/packages/cli/cli-report.md +++ b/packages/cli/cli-report.md @@ -446,7 +446,8 @@ Usage: backstage-cli repo lint [options] Options: --format --since - --cache [path] + --successCache + --successCacheDir --fix -h, --help ``` diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 9ae52a4972..0d72400bc4 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -62,8 +62,12 @@ export function registerRepoCommand(program: Command) { 'Only lint packages that changed since the specified ref', ) .option( - '--cache [path]', - 'Enable caching, storing it in node_modules/.cache/backstage-cli by default, or at the provided directory', + '--successCache', + 'Enable success caching, which skips running tests for unchanged packages that were successful in the previous run', + ) + .option( + '--successCacheDir ', + 'Set the success cache location, (default: node_modules/.cache/backstage-cli)', ) .option('--fix', 'Attempt to automatically fix violations') .action(lazy(() => import('./repo/lint').then(m => m.command))); diff --git a/packages/cli/src/commands/repo/lint.ts b/packages/cli/src/commands/repo/lint.ts index 655e524d19..8e1f6d52d7 100644 --- a/packages/cli/src/commands/repo/lint.ts +++ b/packages/cli/src/commands/repo/lint.ts @@ -63,11 +63,10 @@ async function writeCache(dir: string, cache: Cache) { export async function command(opts: OptionValues, cmd: Command): Promise { let packages = await PackageGraph.listTargetPackages(); - const cacheDir = - opts.cache === true - ? paths.resolveTargetRoot('node_modules/.cache/backstage-cli') - : opts.cache; - const cacheContext = cacheDir + const cacheDir = resolvePath( + opts.successCacheDir ?? 'node_modules/.cache/backstage-cli', + ); + const cacheContext = opts.successCache ? { cache: await readCache(cacheDir), lockfile: await Lockfile.load(paths.resolveTargetRoot('yarn.lock')), @@ -136,7 +135,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise { workerData: { fix: Boolean(opts.fix), format: opts.format as string | undefined, - shouldCache: Boolean(cacheDir), + shouldCache: Boolean(cacheContext), successCache: cacheContext?.cache, }, workerFactory: async ({ fix, format, shouldCache, successCache }) => { @@ -252,7 +251,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise { } } - if (cacheDir) { + if (cacheContext) { await writeCache(cacheDir, outputSuccessCache); } From ea1663376eb45753c0de4722a0e25e2faefbd82f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 7 Oct 2024 17:17:47 +0200 Subject: [PATCH 20/20] cli: use preserveModules for CJS builds Signed-off-by: Patrik Oldsberg --- .changeset/angry-cycles-call.md | 5 +++++ packages/cli/src/lib/builder/config.ts | 2 ++ 2 files changed, 7 insertions(+) create mode 100644 .changeset/angry-cycles-call.md diff --git a/.changeset/angry-cycles-call.md b/.changeset/angry-cycles-call.md new file mode 100644 index 0000000000..e8db20b701 --- /dev/null +++ b/.changeset/angry-cycles-call.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Preserve directory structure for CommonJS build output, just like ESM. This makes the build output more stable and easier to browse, and allows for more effective tree shaking and lazy imports. diff --git a/packages/cli/src/lib/builder/config.ts b/packages/cli/src/lib/builder/config.ts index a11ffe8845..59e1cd57c3 100644 --- a/packages/cli/src/lib/builder/config.ts +++ b/packages/cli/src/lib/builder/config.ts @@ -123,6 +123,8 @@ export async function makeRollupConfigs( format: 'commonjs', interop: 'compat', sourcemap: true, + preserveModules: true, + preserveModulesRoot: `${targetDir}/src`, exports: 'named', }); }