diff --git a/.changeset/eleven-hounds-invent.md b/.changeset/eleven-hounds-invent.md new file mode 100644 index 0000000000..ba863ebc28 --- /dev/null +++ b/.changeset/eleven-hounds-invent.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +In frontend builds and tests `process.env.HAS_REACT_DOM_CLIENT` will now be defined if `react-dom/client` is present, i.e. if using React 18. This allows for conditional imports of `react-dom/client`. diff --git a/.changeset/five-spiders-listen.md b/.changeset/five-spiders-listen.md new file mode 100644 index 0000000000..a7968964e2 --- /dev/null +++ b/.changeset/five-spiders-listen.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Fixed bug in oidc refresh handler, if token endpoints response on refresh request does not contain a scope, the requested scope is used. diff --git a/.changeset/healthy-shirts-fold.md b/.changeset/healthy-shirts-fold.md new file mode 100644 index 0000000000..f2ac0a9e83 --- /dev/null +++ b/.changeset/healthy-shirts-fold.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-catalog': patch +'@backstage/plugin-techdocs': patch +--- + +The `spec.lifecycle' field in entities will now always be rendered as a string. diff --git a/.changeset/long-flies-battle.md b/.changeset/long-flies-battle.md new file mode 100644 index 0000000000..d143658004 --- /dev/null +++ b/.changeset/long-flies-battle.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-entity-feedback': patch +--- + +Added tooltip to like dislike buttons diff --git a/.changeset/perfect-cobras-bake.md b/.changeset/perfect-cobras-bake.md new file mode 100644 index 0000000000..bf66c12e83 --- /dev/null +++ b/.changeset/perfect-cobras-bake.md @@ -0,0 +1,9 @@ +--- +'@backstage/plugin-jenkins-backend': minor +'@backstage/plugin-jenkins': minor +--- + +Added JobRunTable Component. +Added new Route and extended Api to get buildJobs. +Actions column has a new icon button, clicking on which takes us to page where we +can see all the job runs. diff --git a/.changeset/popular-bikes-do.md b/.changeset/popular-bikes-do.md new file mode 100644 index 0000000000..b1cc5c2cdd --- /dev/null +++ b/.changeset/popular-bikes-do.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-code-coverage': patch +--- + +The warning for missing code coverage will now render the entity as a reference. diff --git a/.changeset/pre.json b/.changeset/pre.json index d6534f2b5a..85d6e9d017 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -1,5 +1,5 @@ { - "mode": "pre", + "mode": "exit", "tag": "next", "initialVersions": { "example-app": "0.2.87", diff --git a/.changeset/pretty-swans-worry.md b/.changeset/pretty-swans-worry.md new file mode 100644 index 0000000000..621ae98fe5 --- /dev/null +++ b/.changeset/pretty-swans-worry.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Fixed the type declaration of `DependencyGraphProps`, the `defs` prop now expects `JSX.Element`s. diff --git a/.changeset/serious-ravens-serve.md b/.changeset/serious-ravens-serve.md new file mode 100644 index 0000000000..66cbd795c0 --- /dev/null +++ b/.changeset/serious-ravens-serve.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +The scaffolder-module template now recommends usage of `createMockDirectory` instead of `mock-fs`. diff --git a/.changeset/shaggy-beers-collect.md b/.changeset/shaggy-beers-collect.md new file mode 100644 index 0000000000..471368d6f2 --- /dev/null +++ b/.changeset/shaggy-beers-collect.md @@ -0,0 +1,6 @@ +--- +'@backstage/dev-utils': patch +'@backstage/plugin-techdocs': patch +--- + +Added support for React 18. The new `createRoot` API from `react-dom/client` will now be used if present. diff --git a/.changeset/short-ears-rescue.md b/.changeset/short-ears-rescue.md new file mode 100644 index 0000000000..41968f3da4 --- /dev/null +++ b/.changeset/short-ears-rescue.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend-module-pinniped-provider': minor +--- + +Add new Pinniped auth module and authenticator to be used alongside the new Pinniped auth provider. diff --git a/.changeset/sweet-buckets-fry.md b/.changeset/sweet-buckets-fry.md new file mode 100644 index 0000000000..8f17617645 --- /dev/null +++ b/.changeset/sweet-buckets-fry.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +The `spec.type` field in entities will now always be rendered as a string. diff --git a/.changeset/tall-dragons-jump.md b/.changeset/tall-dragons-jump.md new file mode 100644 index 0000000000..2c35eb3fd2 --- /dev/null +++ b/.changeset/tall-dragons-jump.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Fixed recursive reloading issues of the backend, caused by unwanted watched files. diff --git a/.changeset/thick-dolphins-boil.md b/.changeset/thick-dolphins-boil.md new file mode 100644 index 0000000000..dd562e620c --- /dev/null +++ b/.changeset/thick-dolphins-boil.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-import': patch +--- + +The `app.title` configuration is now properly required to be a string. diff --git a/.changeset/twenty-masks-exist.md b/.changeset/twenty-masks-exist.md new file mode 100644 index 0000000000..7e78cf0dec --- /dev/null +++ b/.changeset/twenty-masks-exist.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend-module-aws': minor +--- + +AwsEksClusterProcessor supports Entity callback function and passes in region when initialize EKS cluster diff --git a/.changeset/wet-cows-brake.md b/.changeset/wet-cows-brake.md new file mode 100644 index 0000000000..9e694d8452 --- /dev/null +++ b/.changeset/wet-cows-brake.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search-react': patch +--- + +The filter options passed to `SearchResultGroupLayout` are now always explicitly rendered as strings by default. diff --git a/.changeset/wild-geese-occur.md b/.changeset/wild-geese-occur.md new file mode 100644 index 0000000000..b97620e7f5 --- /dev/null +++ b/.changeset/wild-geese-occur.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search': patch +--- + +Minor internal code cleanup. diff --git a/.changeset/young-ducks-heal.md b/.changeset/young-ducks-heal.md new file mode 100644 index 0000000000..28614e47bc --- /dev/null +++ b/.changeset/young-ducks-heal.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-node': patch +--- + +Adding optional audience parameter to OAuthState type declaration diff --git a/docs/release-notes-template.md b/docs/release-notes-template.md index 9f9a0b76ee..4861bf0b22 100644 --- a/docs/release-notes-template.md +++ b/docs/release-notes-template.md @@ -12,7 +12,7 @@ A huge thanks to the whole team of maintainers and contributors as well as the a ### -<short description>. Contributed by [@<user>](https://github.com/<user>) [#<pr>](https://github.com/backstage/backstage/pull/<pr>) +<short description>. Contributed by [@<user>](https://github.com/<user>) in [#<pr>](https://github.com/backstage/backstage/pull/<pr>) ## Security Fixes diff --git a/packages/cli/config/jest.js b/packages/cli/config/jest.js index 1756b37db2..4a8bf9694f 100644 --- a/packages/cli/config/jest.js +++ b/packages/cli/config/jest.js @@ -24,6 +24,13 @@ const envOptions = { oldTests: Boolean(process.env.BACKSTAGE_OLD_TESTS), }; +try { + require.resolve('react-dom/client'); + process.env.HAS_REACT_DOM_CLIENT = true; +} catch { + /* ignored */ +} + const transformIgnorePattern = [ '@material-ui', 'ajv', diff --git a/packages/cli/package.json b/packages/cli/package.json index 44ad5047e9..1a5af11de3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -158,7 +158,6 @@ "@types/http-proxy": "^1.17.4", "@types/inquirer": "^8.1.3", "@types/minimatch": "^5.0.0", - "@types/mock-fs": "^4.13.0", "@types/node": "^18.17.8", "@types/npm-packlist": "^3.0.0", "@types/recursive-readdir": "^2.2.0", @@ -169,7 +168,6 @@ "@types/terser-webpack-plugin": "^5.0.4", "@types/yarnpkg__lockfile": "^1.1.4", "del": "^7.0.0", - "mock-fs": "^5.2.0", "msw": "^1.0.0", "nodemon": "^3.0.1", "ts-node": "^10.0.0", diff --git a/packages/cli/src/commands/create-plugin/createPlugin.test.ts b/packages/cli/src/commands/create-plugin/createPlugin.test.ts index aa59f94fb4..84af6ebf69 100644 --- a/packages/cli/src/commands/create-plugin/createPlugin.test.ts +++ b/packages/cli/src/commands/create-plugin/createPlugin.test.ts @@ -16,23 +16,21 @@ import fs from 'fs-extra'; import path from 'path'; -import mockFs from 'mock-fs'; import { movePlugin } from './createPlugin'; +import { createMockDirectory } from '@backstage/backend-test-utils'; const id = 'testPluginMock'; describe('createPlugin', () => { - afterEach(() => { - mockFs.restore(); - }); + const mockDir = createMockDirectory(); describe('movePlugin', () => { it('should move the temporary plugin directory to its final place', async () => { - mockFs({ + mockDir.setContent({ [id]: {}, }); - const tempDir = id; - const pluginDir = path.join('test-temp', 'plugins', id); + const tempDir = mockDir.resolve(id); + const pluginDir = mockDir.resolve('test-temp/plugins', id); await movePlugin(tempDir, pluginDir, id); await expect(fs.pathExists(pluginDir)).resolves.toBe(true); diff --git a/packages/cli/src/commands/versions/bump.test.ts b/packages/cli/src/commands/versions/bump.test.ts index 7af6c6160b..d00b4ce8da 100644 --- a/packages/cli/src/commands/versions/bump.test.ts +++ b/packages/cli/src/commands/versions/bump.test.ts @@ -15,10 +15,7 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; import { Command } from 'commander'; -import { resolve as resolvePath } from 'path'; -import { paths } from '../../lib/paths'; import * as runObj from '../../lib/run'; import bump, { bumpBackstageJsonVersion, createVersionFinder } from './bump'; import { @@ -30,6 +27,10 @@ import { setupServer } from 'msw/node'; import { rest } from 'msw'; import { NotFoundError } from '@backstage/errors'; import { Lockfile } from '../../lib/versioning/Lockfile'; +import { + MockDirectory, + createMockDirectory, +} from '@backstage/backend-test-utils'; // Avoid mutating the global http(s) agent used in other tests jest.mock('global-agent/bootstrap', () => {}); @@ -56,6 +57,19 @@ jest.mock('ora', () => ({ }, })); +let mockDir: MockDirectory; + +jest.mock('../../lib/paths', () => ({ + paths: { + resolveTargetRoot(filename: string) { + return mockDir.resolve(filename); + }, + get targetDir() { + return mockDir.path; + }, + }, +})); + jest.mock('../../lib/run', () => { return { run: jest.fn(), @@ -117,7 +131,17 @@ const lockfileMockResult = `${HEADER} version "1.0.0" `; +// Avoid flakes by comparing sorted log lines. File system access is async, which leads to the log line order being indeterministic +const expectLogsToMatch = ( + recievedLogs: String[], + expected: String[], +): void => { + expect(recievedLogs.filter(Boolean).sort()).toEqual(expected.sort()); +}; + describe('bump', () => { + mockDir = createMockDirectory(); + beforeEach(() => { mockFetchPackageInfo.mockImplementation(async name => ({ name: name, @@ -128,7 +152,6 @@ describe('bump', () => { }); afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); @@ -136,31 +159,34 @@ describe('bump', () => { setupRequestMockHandlers(worker); it('should bump backstage dependencies', async () => { - mockFs({ - '/yarn.lock': lockfileMock, - '/package.json': JSON.stringify({ + mockDir.setContent({ + 'yarn.lock': lockfileMock, + 'package.json': JSON.stringify({ workspaces: { packages: ['packages/*'], }, }), - '/packages/a/package.json': JSON.stringify({ - name: 'a', - dependencies: { - '@backstage/core': '^1.0.5', + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '^1.0.5', + }, + }), }, - }), - '/packages/b/package.json': JSON.stringify({ - name: 'b', - dependencies: { - '@backstage/core': '^1.0.3', - '@backstage/theme': '^1.0.0', + b: { + 'package.json': JSON.stringify({ + name: 'b', + dependencies: { + '@backstage/core': '^1.0.3', + '@backstage/theme': '^1.0.0', + }, + }), }, - }), + }, }); - jest - .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...path) => resolvePath('/', ...path)); jest.spyOn(runObj, 'run').mockResolvedValue(undefined); worker.use( rest.get( @@ -177,7 +203,7 @@ describe('bump', () => { const { log: logs } = await withLogCollector(['log'], async () => { await bump({ pattern: null, release: 'main' } as unknown as Command); }); - expect(logs.filter(Boolean)).toEqual([ + expectLogsToMatch(logs, [ 'Using default pattern glob @backstage/*', 'Checking for updates of @backstage/core', 'Checking for updates of @backstage/theme', @@ -208,17 +234,24 @@ describe('bump', () => { expect.any(Object), ); - const lockfileContents = await fs.readFile('/yarn.lock', 'utf8'); + const lockfileContents = await fs.readFile( + mockDir.resolve('yarn.lock'), + 'utf8', + ); expect(lockfileContents).toBe(lockfileMockResult); - const packageA = await fs.readJson('/packages/a/package.json'); + const packageA = await fs.readJson( + mockDir.resolve('packages/a/package.json'), + ); expect(packageA).toEqual({ name: 'a', dependencies: { '@backstage/core': '^1.0.6', }, }); - const packageB = await fs.readJson('/packages/b/package.json'); + const packageB = await fs.readJson( + mockDir.resolve('packages/b/package.json'), + ); expect(packageB).toEqual({ name: 'b', dependencies: { @@ -229,31 +262,34 @@ describe('bump', () => { }); it('should bump backstage dependencies but not install them', async () => { - mockFs({ - '/yarn.lock': lockfileMock, - '/package.json': JSON.stringify({ + mockDir.setContent({ + 'yarn.lock': lockfileMock, + 'package.json': JSON.stringify({ workspaces: { packages: ['packages/*'], }, }), - '/packages/a/package.json': JSON.stringify({ - name: 'a', - dependencies: { - '@backstage/core': '^1.0.5', + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '^1.0.5', + }, + }), }, - }), - '/packages/b/package.json': JSON.stringify({ - name: 'b', - dependencies: { - '@backstage/core': '^1.0.3', - '@backstage/theme': '^1.0.0', + b: { + 'package.json': JSON.stringify({ + name: 'b', + dependencies: { + '@backstage/core': '^1.0.3', + '@backstage/theme': '^1.0.0', + }, + }), }, - }), + }, }); - jest - .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...path) => resolvePath('/', ...path)); jest.spyOn(runObj, 'run').mockResolvedValue(undefined); worker.use( rest.get( @@ -274,7 +310,7 @@ describe('bump', () => { skipInstall: true, } as unknown as Command); }); - expect(logs.filter(Boolean)).toEqual([ + expectLogsToMatch(logs, [ 'Using default pattern glob @backstage/*', 'Checking for updates of @backstage/core', 'Checking for updates of @backstage/theme', @@ -304,17 +340,24 @@ describe('bump', () => { expect.any(Object), ); - const lockfileContents = await fs.readFile('/yarn.lock', 'utf8'); + const lockfileContents = await fs.readFile( + mockDir.resolve('yarn.lock'), + 'utf8', + ); expect(lockfileContents).toBe(lockfileMockResult); - const packageA = await fs.readJson('/packages/a/package.json'); + const packageA = await fs.readJson( + mockDir.resolve('packages/a/package.json'), + ); expect(packageA).toEqual({ name: 'a', dependencies: { '@backstage/core': '^1.0.6', }, }); - const packageB = await fs.readJson('/packages/b/package.json'); + const packageB = await fs.readJson( + mockDir.resolve('packages/b/package.json'), + ); expect(packageB).toEqual({ name: 'b', dependencies: { @@ -325,31 +368,34 @@ describe('bump', () => { }); it('should prefer dependency versions from release manifest', async () => { - mockFs({ - '/yarn.lock': lockfileMock, - '/package.json': JSON.stringify({ + mockDir.setContent({ + 'yarn.lock': lockfileMock, + 'package.json': JSON.stringify({ workspaces: { packages: ['packages/*'], }, }), - '/packages/a/package.json': JSON.stringify({ - name: 'a', - dependencies: { - '@backstage/core': '^1.0.5', + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '^1.0.5', + }, + }), }, - }), - '/packages/b/package.json': JSON.stringify({ - name: 'b', - dependencies: { - '@backstage/core': '^1.0.3', - '@backstage/theme': '^1.0.0', + b: { + 'package.json': JSON.stringify({ + name: 'b', + dependencies: { + '@backstage/core': '^1.0.3', + '@backstage/theme': '^1.0.0', + }, + }), }, - }), + }, }); - jest - .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...path) => resolvePath('/', ...path)); jest.spyOn(runObj, 'run').mockResolvedValue(undefined); worker.use( rest.get( @@ -376,7 +422,7 @@ describe('bump', () => { const { log: logs } = await withLogCollector(['log'], async () => { await bump({ pattern: null, release: 'main' } as unknown as Command); }); - expect(logs.filter(Boolean)).toEqual([ + expectLogsToMatch(logs, [ 'Using default pattern glob @backstage/*', 'Checking for updates of @backstage/core', 'Checking for updates of @backstage/theme', @@ -408,17 +454,24 @@ describe('bump', () => { expect.any(Object), ); - const lockfileContents = await fs.readFile('/yarn.lock', 'utf8'); + const lockfileContents = await fs.readFile( + mockDir.resolve('yarn.lock'), + 'utf8', + ); expect(lockfileContents).toBe(lockfileMockResult); - const packageA = await fs.readJson('/packages/a/package.json'); + const packageA = await fs.readJson( + mockDir.resolve('packages/a/package.json'), + ); expect(packageA).toEqual({ name: 'a', dependencies: { '@backstage/core': '^1.0.6', }, }); - const packageB = await fs.readJson('/packages/b/package.json'); + const packageB = await fs.readJson( + mockDir.resolve('packages/b/package.json'), + ); expect(packageB).toEqual({ name: 'b', dependencies: { @@ -429,30 +482,33 @@ describe('bump', () => { }); it('should only bump packages in the manifest when a specific release is specified', async () => { - mockFs({ - '/yarn.lock': lockfileMock, - '/package.json': JSON.stringify({ + mockDir.setContent({ + 'yarn.lock': lockfileMock, + 'package.json': JSON.stringify({ workspaces: { packages: ['packages/*'], }, }), - '/packages/a/package.json': JSON.stringify({ - name: 'a', - dependencies: { - '@backstage/core': '^1.0.5', + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '^1.0.5', + }, + }), }, - }), - '/packages/b/package.json': JSON.stringify({ - name: 'b', - dependencies: { - '@backstage/core': '^1.0.3', - '@backstage/theme': '^1.0.0', + b: { + 'package.json': JSON.stringify({ + name: 'b', + dependencies: { + '@backstage/core': '^1.0.3', + '@backstage/theme': '^1.0.0', + }, + }), }, - }), + }, }); - jest - .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...path) => resolvePath('/', ...path)); jest.spyOn(runObj, 'run').mockResolvedValue(undefined); worker.use( @@ -472,14 +528,18 @@ describe('bump', () => { expect(runObj.run).toHaveBeenCalledTimes(0); - const packageA = await fs.readJson('/packages/a/package.json'); + const packageA = await fs.readJson( + mockDir.resolve('packages/a/package.json'), + ); expect(packageA).toEqual({ name: 'a', dependencies: { '@backstage/core': '^1.0.5', }, }); - const packageB = await fs.readJson('/packages/b/package.json'); + const packageB = await fs.readJson( + mockDir.resolve('packages/b/package.json'), + ); expect(packageB).toEqual({ name: 'b', dependencies: { @@ -489,32 +549,36 @@ describe('bump', () => { }); }); + // eslint-disable-next-line jest/expect-expect it('should prefer versions from the highest manifest version when main is not specified', async () => { - mockFs({ - '/yarn.lock': lockfileMock, - '/package.json': JSON.stringify({ + mockDir.setContent({ + 'yarn.lock': lockfileMock, + 'package.json': JSON.stringify({ workspaces: { packages: ['packages/*'], }, }), - '/packages/a/package.json': JSON.stringify({ - name: 'a', - dependencies: { - '@backstage/core': '^1.0.5', + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '^1.0.5', + }, + }), }, - }), - '/packages/b/package.json': JSON.stringify({ - name: 'b', - dependencies: { - '@backstage/core': '^1.0.3', - '@backstage/theme': '^1.0.0', + b: { + 'package.json': JSON.stringify({ + name: 'b', + dependencies: { + '@backstage/core': '^1.0.3', + '@backstage/theme': '^1.0.0', + }, + }), }, - }), + }, }); - jest - .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...path) => resolvePath('/', ...path)); jest.spyOn(runObj, 'run').mockResolvedValue(undefined); worker.use( rest.get( @@ -561,7 +625,7 @@ describe('bump', () => { const { log: logs } = await withLogCollector(['log'], async () => { await bump({ pattern: null, release: 'next' } as unknown as Command); }); - expect(logs.filter(Boolean)).toEqual([ + expectLogsToMatch(logs, [ 'Using default pattern glob @backstage/*', 'Checking for updates of @backstage/core', 'Checking for updates of @backstage/theme', @@ -609,35 +673,38 @@ describe('bump', () => { "@backstage/theme@^1.0.0": version "1.0.0" `; - mockFs({ - '/yarn.lock': customLockfileMock, - '/package.json': JSON.stringify({ + mockDir.setContent({ + 'yarn.lock': customLockfileMock, + 'package.json': JSON.stringify({ workspaces: { packages: ['packages/*'], }, }), - '/packages/a/package.json': JSON.stringify({ - name: 'a', - dependencies: { - '@backstage/core': '^1.0.5', - '@backstage-extra/custom': '^1.0.1', - '@backstage-extra/custom-two': '^1.0.0', + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '^1.0.5', + '@backstage-extra/custom': '^1.0.1', + '@backstage-extra/custom-two': '^1.0.0', + }, + }), }, - }), - '/packages/b/package.json': JSON.stringify({ - name: 'b', - dependencies: { - '@backstage/core': '^1.0.3', - '@backstage/theme': '^1.0.0', - '@backstage-extra/custom': '^1.1.0', - '@backstage-extra/custom-two': '^1.0.0', + b: { + 'package.json': JSON.stringify({ + name: 'b', + dependencies: { + '@backstage/core': '^1.0.3', + '@backstage/theme': '^1.0.0', + '@backstage-extra/custom': '^1.1.0', + '@backstage-extra/custom-two': '^1.0.0', + }, + }), }, - }), + }, }); - jest - .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...path) => resolvePath('/', ...path)); jest.spyOn(runObj, 'run').mockResolvedValue(undefined); worker.use( rest.get( @@ -657,7 +724,7 @@ describe('bump', () => { release: 'main', } as any); }); - expect(logs.filter(Boolean)).toEqual([ + expectLogsToMatch(logs, [ 'Using custom pattern glob @{backstage,backstage-extra}/*', 'Checking for updates of @backstage/core', 'Checking for updates of @backstage-extra/custom', @@ -696,10 +763,15 @@ describe('bump', () => { expect.any(Object), ); - const lockfileContents = await fs.readFile('/yarn.lock', 'utf8'); + const lockfileContents = await fs.readFile( + mockDir.resolve('yarn.lock'), + 'utf8', + ); expect(lockfileContents).toEqual(customLockfileMockResult); - const packageA = await fs.readJson('/packages/a/package.json'); + const packageA = await fs.readJson( + mockDir.resolve('packages/a/package.json'), + ); expect(packageA).toEqual({ name: 'a', dependencies: { @@ -708,7 +780,9 @@ describe('bump', () => { '@backstage/core': '^1.0.6', }, }); - const packageB = await fs.readJson('/packages/b/package.json'); + const packageB = await fs.readJson( + mockDir.resolve('packages/b/package.json'), + ); expect(packageB).toEqual({ name: 'b', dependencies: { @@ -721,31 +795,34 @@ describe('bump', () => { }); it('should ignore not found packages', async () => { - mockFs({ - '/yarn.lock': lockfileMockResult, - '/package.json': JSON.stringify({ + mockDir.setContent({ + 'yarn.lock': lockfileMockResult, + 'package.json': JSON.stringify({ workspaces: { packages: ['packages/*'], }, }), - '/packages/a/package.json': JSON.stringify({ - name: 'a', - dependencies: { - '@backstage/core': '^1.0.5', + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '^1.0.5', + }, + }), }, - }), - '/packages/b/package.json': JSON.stringify({ - name: 'b', - dependencies: { - '@backstage/core': '^1.0.3', - '@backstage/theme': '^2.0.0', + b: { + 'package.json': JSON.stringify({ + name: 'b', + dependencies: { + '@backstage/core': '^1.0.3', + '@backstage/theme': '^2.0.0', + }, + }), }, - }), + }, }); - jest - .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...path) => resolvePath('/', ...path)); mockFetchPackageInfo.mockRejectedValue(new NotFoundError('Nope')); jest.spyOn(runObj, 'run').mockResolvedValue(undefined); worker.use( @@ -763,7 +840,7 @@ describe('bump', () => { const { log: logs } = await withLogCollector(['log'], async () => { await bump({ pattern: null, release: 'main' } as unknown as Command); }); - expect(logs.filter(Boolean)).toEqual([ + expectLogsToMatch(logs, [ 'Using default pattern glob @backstage/*', 'Checking for updates of @backstage/core', 'Checking for updates of @backstage/theme', @@ -778,17 +855,24 @@ describe('bump', () => { expect(runObj.run).toHaveBeenCalledTimes(0); - const lockfileContents = await fs.readFile('/yarn.lock', 'utf8'); + const lockfileContents = await fs.readFile( + mockDir.resolve('yarn.lock'), + 'utf8', + ); expect(lockfileContents).toBe(lockfileMockResult); - const packageA = await fs.readJson('/packages/a/package.json'); + const packageA = await fs.readJson( + mockDir.resolve('packages/a/package.json'), + ); expect(packageA).toEqual({ name: 'a', dependencies: { '@backstage/core': '^1.0.5', // not bumped }, }); - const packageB = await fs.readJson('/packages/b/package.json'); + const packageB = await fs.readJson( + mockDir.resolve('packages/b/package.json'), + ); expect(packageB).toEqual({ name: 'b', dependencies: { @@ -798,6 +882,7 @@ describe('bump', () => { }); }); + // eslint-disable-next-line jest/expect-expect it('should log duplicates', async () => { jest.spyOn(Lockfile.prototype, 'analyze').mockReturnValue({ invalidRanges: [], @@ -826,31 +911,34 @@ describe('bump', () => { }, ], }); - mockFs({ - '/yarn.lock': lockfileMock, - '/package.json': JSON.stringify({ + mockDir.setContent({ + 'yarn.lock': lockfileMock, + 'package.json': JSON.stringify({ workspaces: { packages: ['packages/*'], }, }), - '/packages/a/package.json': JSON.stringify({ - name: 'a', - dependencies: { - '@backstage/core': '^1.0.5', + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '^1.0.5', + }, + }), }, - }), - '/packages/b/package.json': JSON.stringify({ - name: 'b', - dependencies: { - '@backstage/core': '^1.0.3', - '@backstage/theme': '^1.0.0', + b: { + 'package.json': JSON.stringify({ + name: 'b', + dependencies: { + '@backstage/core': '^1.0.3', + '@backstage/theme': '^1.0.0', + }, + }), }, - }), + }, }); - jest - .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...path) => resolvePath('/', ...path)); jest.spyOn(runObj, 'run').mockResolvedValue(undefined); worker.use( rest.get( @@ -867,7 +955,7 @@ describe('bump', () => { const { log: logs } = await withLogCollector(['log'], async () => { await bump({ pattern: null, release: 'main' } as unknown as Command); }); - expect(logs.filter(Boolean)).toEqual([ + expectLogsToMatch(logs, [ 'Using default pattern glob @backstage/*', 'Checking for updates of @backstage/core', 'Checking for updates of @backstage/theme', @@ -891,24 +979,23 @@ describe('bump', () => { }); describe('bumpBackstageJsonVersion', () => { + mockDir = createMockDirectory(); + afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should bump version in backstage.json', async () => { - mockFs({ - '/backstage.json': JSON.stringify({ version: '0.0.1' }), + mockDir.setContent({ + 'backstage.json': JSON.stringify({ version: '0.0.1' }), }); - paths.targetDir = '/'; - jest - .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...path) => resolvePath('/', ...path)); const { log } = await withLogCollector(async () => { await bumpBackstageJsonVersion('1.4.1'); }); - expect(await fs.readJson('/backstage.json')).toEqual({ version: '1.4.1' }); + expect(await fs.readJson(mockDir.resolve('backstage.json'))).toEqual({ + version: '1.4.1', + }); expect(log).toEqual([ 'Upgraded from release 0.0.1 to 1.4.1, please review these template changes:', undefined, @@ -918,17 +1005,15 @@ describe('bumpBackstageJsonVersion', () => { }); it("should create backstage.json if doesn't exist", async () => { - mockFs({}); - paths.targetDir = '/'; + mockDir.clear(); // empty temp test folder const latest = '1.4.1'; - jest - .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...path) => resolvePath('/', ...path)); const { log } = await withLogCollector(async () => { await bumpBackstageJsonVersion(latest); }); - expect(await fs.readJson('/backstage.json')).toEqual({ version: latest }); + expect(await fs.readJson(mockDir.resolve('backstage.json'))).toEqual({ + version: latest, + }); expect(log).toEqual([ 'Your project is now at version 1.4.1, which has been written to backstage.json', ]); diff --git a/packages/cli/src/lib/builder/plugins.test.ts b/packages/cli/src/lib/builder/plugins.test.ts index dcbdc55cc0..f6984dd67e 100644 --- a/packages/cli/src/lib/builder/plugins.test.ts +++ b/packages/cli/src/lib/builder/plugins.test.ts @@ -15,7 +15,6 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; import { NormalizedOutputOptions, OutputAsset, @@ -24,6 +23,7 @@ import { } from 'rollup'; import { forwardFileImports } from './plugins'; +import { createMockDirectory } from '@backstage/backend-test-utils'; const context = { meta: { @@ -91,16 +91,18 @@ describe('forwardFileImports', () => { ); }); - describe('with mock fs', () => { - beforeEach(() => { - mockFs({ - '/dev/src/my-module.ts': '', - '/dev/src/dir/my-image.png': 'my-image', - }); - }); + describe('with createMockDirectory', () => { + const mockDir = createMockDirectory(); - afterEach(() => { - mockFs.restore(); + beforeEach(() => { + mockDir.setContent({ + dev: { + src: { + 'my-module.ts': '', + dir: { 'my-image.png': 'my-image' }, + }, + }, + }); }); it('should extract files', async () => { @@ -111,23 +113,33 @@ describe('forwardFileImports', () => { throw new Error('options.external is not a function'); } - expect(options.external('./my-module', '/dev/src/index.ts', false)).toBe( - false, - ); expect( - options.external('./my-image.png', '/dev/src/dir/index.ts', false), + options.external( + './my-module', + mockDir.resolve('dev/src/index.ts'), + false, + ), + ).toBe(false); + expect( + options.external( + './my-image.png', + mockDir.resolve('dev', 'src', 'dir', 'index.ts'), + false, + ), ).toBe(true); - const outPath = '/dev/dist/dir/my-image.png'; + const outPath = mockDir.resolve('dev', 'dist', 'dir', 'my-image.png'); await expect(fs.pathExists(outPath)).resolves.toBe(false); await plugin.generateBundle?.call( context, - { dir: '/dev/dist' } as NormalizedOutputOptions, + { + dir: mockDir.resolve('dev/dist'), + } as NormalizedOutputOptions, { ['index.js']: { type: 'chunk', - facadeModuleId: '/dev/src/index.ts', + facadeModuleId: mockDir.resolve('dev/src/index.ts'), } as OutputChunk, }, false, // isWrite = false -> no write @@ -136,7 +148,9 @@ describe('forwardFileImports', () => { await plugin.generateBundle?.call( context, - { dir: '/dev/dist' } as NormalizedOutputOptions, + { + dir: mockDir.resolve('dev/dist'), + } as NormalizedOutputOptions, { // output assets should not cause a write ['index.js']: { type: 'asset' } as OutputAsset, @@ -150,11 +164,13 @@ describe('forwardFileImports', () => { // output chunk + isWrite -> generate files await plugin.generateBundle?.call( context, - { dir: '/dev/dist' } as NormalizedOutputOptions, + { + dir: mockDir.resolve('dev/dist'), + } as NormalizedOutputOptions, { ['index.js']: { type: 'chunk', - facadeModuleId: '/dev/src/index.ts', + facadeModuleId: mockDir.resolve('dev/src/index.ts'), } as OutputChunk, }, true, @@ -164,11 +180,13 @@ describe('forwardFileImports', () => { // should not break when triggering another write await plugin.generateBundle?.call( context, - { file: '/dev/dist/my-output.js' } as NormalizedOutputOptions, + { + file: mockDir.resolve('dev/dist/my-output.js'), + } as NormalizedOutputOptions, { ['index.js']: { type: 'chunk', - facadeModuleId: '/dev/src/index.ts', + facadeModuleId: mockDir.resolve('dev/src/index.ts'), } as OutputChunk, }, true, diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index ed20fe385a..534ffe8c58 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -81,6 +81,15 @@ async function readBuildInfo() { }; } +function hasReactDomClient() { + try { + require.resolve('react-dom/client'); + return true; + } catch { + return false; + } +} + export async function createConfig( paths: BundlingPaths, options: BundlingOptions, @@ -136,6 +145,9 @@ export async function createConfig( () => JSON.stringify(options.getFrontendAppConfigs()), true, ), + // This allows for conditional imports of react-dom/client, since there's no way + // to check for presence of it in source code without module resolution errors. + 'process.env.HAS_REACT_DOM_CLIENT': JSON.stringify(hasReactDomClient()), }), ); diff --git a/packages/cli/src/lib/experimental/startBackendExperimental.ts b/packages/cli/src/lib/experimental/startBackendExperimental.ts index 578fd1c747..c4271deb3d 100644 --- a/packages/cli/src/lib/experimental/startBackendExperimental.ts +++ b/packages/cli/src/lib/experimental/startBackendExperimental.ts @@ -124,9 +124,8 @@ export async function startBackendExperimental(options: BackendServeOptions) { restart(); - watcher = watch([paths.targetDir], { + watcher = watch([], { cwd: process.cwd(), - ignored: ['**/.*/**', '**/node_modules/**'], ignoreInitial: true, ignorePermissionErrors: true, }).on('all', restart); diff --git a/packages/cli/src/lib/new/factories/backendModule.test.ts b/packages/cli/src/lib/new/factories/backendModule.test.ts index c633b1c1b2..78ef5f96bb 100644 --- a/packages/cli/src/lib/new/factories/backendModule.test.ts +++ b/packages/cli/src/lib/new/factories/backendModule.test.ts @@ -15,39 +15,38 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; -import { sep, resolve as resolvePath } from 'path'; -import { paths } from '../../paths'; +import { sep } from 'path'; import { Task } from '../../tasks'; import { FactoryRegistry } from '../FactoryRegistry'; -import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { + createMockOutputStream, + expectLogsToMatch, + mockPaths, +} from './common/testUtils'; import { backendModule } from './backendModule'; +import { createMockDirectory } from '@backstage/backend-test-utils'; describe('backendModule factory', () => { + const mockDir = createMockDirectory(); + beforeEach(() => { mockPaths({ - targetRoot: '/root', + targetRoot: mockDir.path, }); }); afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should create a backend plugin', async () => { - mockFs({ - '/root': { - packages: { - backend: { - 'package.json': JSON.stringify({}), - }, + mockDir.setContent({ + packages: { + backend: { + 'package.json': JSON.stringify({}), }, - plugins: mockFs.directory(), }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + plugins: {}, }); const options = await FactoryRegistry.populateOptions(backendModule, { @@ -73,8 +72,7 @@ describe('backendModule factory', () => { expect(modified).toBe(true); - expect(output).toEqual([ - '', + expectLogsToMatch(output, [ 'Creating backend module backstage-plugin-test-backend-module-tester-two', 'Checking Prerequisites:', `availability plugins${sep}test-backend-module-tester-two`, @@ -91,14 +89,14 @@ describe('backendModule factory', () => { ]); await expect( - fs.readJson('/root/packages/backend/package.json'), + fs.readJson(mockDir.resolve('packages/backend/package.json')), ).resolves.toEqual({ dependencies: { 'backstage-plugin-test-backend-module-tester-two': '^1.0.0', }, }); const moduleFile = await fs.readFile( - '/root/plugins/test-backend-module-tester-two/src/module.ts', + mockDir.resolve('plugins/test-backend-module-tester-two/src/module.ts'), 'utf-8', ); @@ -110,11 +108,11 @@ describe('backendModule factory', () => { expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath('/root/plugins/test-backend-module-tester-two'), + cwd: mockDir.resolve('plugins/test-backend-module-tester-two'), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath('/root/plugins/test-backend-module-tester-two'), + cwd: mockDir.resolve('plugins/test-backend-module-tester-two'), optional: true, }); }); diff --git a/packages/cli/src/lib/new/factories/backendPlugin.test.ts b/packages/cli/src/lib/new/factories/backendPlugin.test.ts index f326de88b8..5c1d496e49 100644 --- a/packages/cli/src/lib/new/factories/backendPlugin.test.ts +++ b/packages/cli/src/lib/new/factories/backendPlugin.test.ts @@ -15,39 +15,38 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; -import { sep, resolve as resolvePath } from 'path'; -import { paths } from '../../paths'; +import { sep } from 'path'; import { Task } from '../../tasks'; import { FactoryRegistry } from '../FactoryRegistry'; -import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { + createMockOutputStream, + expectLogsToMatch, + mockPaths, +} from './common/testUtils'; import { backendPlugin } from './backendPlugin'; +import { createMockDirectory } from '@backstage/backend-test-utils'; describe('backendPlugin factory', () => { + const mockDir = createMockDirectory(); + beforeEach(() => { mockPaths({ - targetRoot: '/root', + targetRoot: mockDir.path, }); }); afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should create a backend plugin', async () => { - mockFs({ - '/root': { - packages: { - backend: { - 'package.json': JSON.stringify({}), - }, + mockDir.setContent({ + packages: { + backend: { + 'package.json': JSON.stringify({}), }, - plugins: mockFs.directory(), }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + plugins: {}, }); const options = await FactoryRegistry.populateOptions(backendPlugin, { @@ -72,8 +71,7 @@ describe('backendPlugin factory', () => { expect(modified).toBe(true); - expect(output).toEqual([ - '', + expectLogsToMatch(output, [ 'Creating backend plugin backstage-plugin-test-backend', 'Checking Prerequisites:', `availability plugins${sep}test-backend`, @@ -94,14 +92,14 @@ describe('backendPlugin factory', () => { ]); await expect( - fs.readJson('/root/packages/backend/package.json'), + fs.readJson(mockDir.resolve('packages/backend/package.json')), ).resolves.toEqual({ dependencies: { 'backstage-plugin-test-backend': '^1.0.0', }, }); const standaloneServerFile = await fs.readFile( - '/root/plugins/test-backend/src/service/standaloneServer.ts', + mockDir.resolve('plugins/test-backend/src/service/standaloneServer.ts'), 'utf-8', ); @@ -112,11 +110,11 @@ describe('backendPlugin factory', () => { expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath('/root/plugins/test-backend'), + cwd: mockDir.resolve('plugins/test-backend'), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath('/root/plugins/test-backend'), + cwd: mockDir.resolve('plugins/test-backend'), optional: true, }); }); diff --git a/packages/cli/src/lib/new/factories/common/tasks.test.ts b/packages/cli/src/lib/new/factories/common/tasks.test.ts index 49381676e7..60783344fc 100644 --- a/packages/cli/src/lib/new/factories/common/tasks.test.ts +++ b/packages/cli/src/lib/new/factories/common/tasks.test.ts @@ -15,32 +15,37 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; import { sep } from 'path'; -import { createMockOutputStream, mockPaths } from './testUtils'; +import { + createMockOutputStream, + expectLogsToMatch, + mockPaths, +} from './testUtils'; import { CreateContext } from '../../types'; import { executePluginPackageTemplate } from './tasks'; +import { createMockDirectory } from '@backstage/backend-test-utils'; + +const mockDir = createMockDirectory(); mockPaths({ - ownDir: '/own', - targetRoot: '/root', + ownDir: mockDir.resolve('own'), + targetRoot: mockDir.resolve('root'), }); describe('executePluginPackageTemplate', () => { afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should execute template', async () => { - mockFs({ - '/root': { + mockDir.setContent({ + root: { 'yarn.lock': ` some-package@^1.1.0: version "1.5.0" `, }, - '/own': { + own: { templates: { 'test-template': { 'package.json.hbs': ` @@ -78,7 +83,7 @@ some-package@^1.1.0: } as CreateContext, { templateName: 'test-template', - targetDir: '/target', + targetDir: mockDir.resolve('target'), values: { id: 'testing', makePrivate: true, @@ -87,7 +92,7 @@ some-package@^1.1.0: ); expect(modified).toBe(true); - expect(output).toEqual([ + expectLogsToMatch(output, [ 'Checking Prerequisites:', `availability ..${sep}target`, 'creating temp dir', @@ -98,7 +103,8 @@ some-package@^1.1.0: 'Installing:', `moving ..${sep}target`, ]); - await expect(fs.readFile('/target/package.json', 'utf8')).resolves.toBe(`{ + await expect(fs.readFile(mockDir.resolve('target/package.json'), 'utf8')) + .resolves.toBe(`{ "name": "my-testing-plugin", "private": true, "description": "testing", @@ -109,10 +115,10 @@ some-package@^1.1.0: } `); await expect( - fs.readFile('/target/subdir/templated.txt', 'utf8'), + fs.readFile(mockDir.resolve('target/subdir/templated.txt'), 'utf8'), ).resolves.toBe('Hello testing!'); await expect( - fs.readFile('/target/subdir/not-templated.txt', 'utf8'), + fs.readFile(mockDir.resolve('target/subdir/not-templated.txt'), 'utf8'), ).resolves.toBe('Hello {{id}}!'); }); }); diff --git a/packages/cli/src/lib/new/factories/common/testUtils.ts b/packages/cli/src/lib/new/factories/common/testUtils.ts index 01081a7486..1ade13a996 100644 --- a/packages/cli/src/lib/new/factories/common/testUtils.ts +++ b/packages/cli/src/lib/new/factories/common/testUtils.ts @@ -73,3 +73,11 @@ export function createMockOutputStream() { } as unknown as WriteStream & { fd: any }, ] as const; } + +// Avoid flakes by comparing sorted log lines. File system access is async, which leads to the log line order being indeterministic +export function expectLogsToMatch( + recievedLogs: String[], + expected: String[], +): void { + expect(recievedLogs.filter(Boolean).sort()).toEqual(expected.sort()); +} diff --git a/packages/cli/src/lib/new/factories/frontendPlugin.test.ts b/packages/cli/src/lib/new/factories/frontendPlugin.test.ts index 8390eb192e..94d4a7eb4c 100644 --- a/packages/cli/src/lib/new/factories/frontendPlugin.test.ts +++ b/packages/cli/src/lib/new/factories/frontendPlugin.test.ts @@ -15,13 +15,16 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; -import { sep, resolve as resolvePath } from 'path'; -import { paths } from '../../paths'; +import { sep } from 'path'; import { Task } from '../../tasks'; import { FactoryRegistry } from '../FactoryRegistry'; -import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { + createMockOutputStream, + expectLogsToMatch, + mockPaths, +} from './common/testUtils'; import { frontendPlugin } from './frontendPlugin'; +import { createMockDirectory } from '@backstage/backend-test-utils'; const appTsxContent = ` import { createApp } from '@backstage/app-defaults'; @@ -34,33 +37,29 @@ const router = ( `; describe('frontendPlugin factory', () => { + const mockDir = createMockDirectory(); + beforeEach(() => { mockPaths({ - targetRoot: '/root', + targetRoot: mockDir.path, }); }); afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should create a frontend plugin', async () => { - mockFs({ - '/root': { - packages: { - app: { - 'package.json': JSON.stringify({}), - src: { - 'App.tsx': appTsxContent, - }, + mockDir.setContent({ + packages: { + app: { + 'package.json': JSON.stringify({}), + src: { + 'App.tsx': appTsxContent, }, }, - plugins: mockFs.directory(), }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + plugins: {}, }); const options = await FactoryRegistry.populateOptions(frontendPlugin, { @@ -85,8 +84,7 @@ describe('frontendPlugin factory', () => { expect(modified).toBe(true); - expect(output).toEqual([ - '', + expectLogsToMatch(output, [ 'Creating frontend plugin backstage-plugin-test', 'Checking Prerequisites:', `availability plugins${sep}test`, @@ -114,15 +112,16 @@ describe('frontendPlugin factory', () => { ]); await expect( - fs.readJson('/root/packages/app/package.json'), + fs.readJson(mockDir.resolve('packages/app/package.json')), ).resolves.toEqual({ dependencies: { 'backstage-plugin-test': '^1.0.0', }, }); - await expect(fs.readFile('/root/packages/app/src/App.tsx', 'utf8')).resolves - .toBe(` + await expect( + fs.readFile(mockDir.resolve('packages/app/src/App.tsx'), 'utf8'), + ).resolves.toBe(` import { createApp } from '@backstage/app-defaults'; import { TestPage } from 'backstage-plugin-test'; @@ -136,32 +135,27 @@ const router = ( expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath('/root/plugins/test'), + cwd: mockDir.resolve('plugins/test'), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath('/root/plugins/test'), + cwd: mockDir.resolve('plugins/test'), optional: true, }); }); it('should create a frontend plugin with more options and codeowners', async () => { - mockFs({ - '/root': { - CODEOWNERS: '', - packages: { - app: { - 'package.json': JSON.stringify({}), - src: { - 'App.tsx': appTsxContent, - }, + mockDir.setContent({ + CODEOWNERS: '', + packages: { + app: { + 'package.json': JSON.stringify({}), + src: { + 'App.tsx': appTsxContent, }, }, - plugins: mockFs.directory(), }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + plugins: {}, }); const options = await FactoryRegistry.populateOptions(frontendPlugin, { @@ -183,15 +177,16 @@ const router = ( }); await expect( - fs.readJson('/root/packages/app/package.json'), + fs.readJson(mockDir.resolve('packages/app/package.json')), ).resolves.toEqual({ dependencies: { '@internal/plugin-test': '^1.0.0', }, }); - await expect(fs.readFile('/root/packages/app/src/App.tsx', 'utf8')).resolves - .toBe(` + await expect( + fs.readFile(mockDir.resolve('packages/app/src/App.tsx'), 'utf8'), + ).resolves.toBe(` import { createApp } from '@backstage/app-defaults'; import { TestPage } from '@internal/plugin-test'; @@ -205,11 +200,11 @@ const router = ( expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath('/root/plugins/test'), + cwd: mockDir.resolve('plugins/test'), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath('/root/plugins/test'), + cwd: mockDir.resolve('plugins/test'), optional: true, }); }); diff --git a/packages/cli/src/lib/new/factories/nodeLibraryPackage.test.ts b/packages/cli/src/lib/new/factories/nodeLibraryPackage.test.ts index d0b08f4f06..e3673d21ba 100644 --- a/packages/cli/src/lib/new/factories/nodeLibraryPackage.test.ts +++ b/packages/cli/src/lib/new/factories/nodeLibraryPackage.test.ts @@ -15,36 +15,35 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; -import { resolve as resolvePath, join as joinPath } from 'path'; -import { paths } from '../../paths'; +import { join as joinPath } from 'path'; import { Task } from '../../tasks'; import { FactoryRegistry } from '../FactoryRegistry'; -import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { + createMockOutputStream, + expectLogsToMatch, + mockPaths, +} from './common/testUtils'; import { nodeLibraryPackage } from './nodeLibraryPackage'; +import { createMockDirectory } from '@backstage/backend-test-utils'; describe('nodeLibraryPackage factory', () => { + const mockDir = createMockDirectory(); + beforeEach(() => { mockPaths({ - targetRoot: '/root', + targetRoot: mockDir.path, }); }); afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should create a node library package', async () => { const expectedNodeLibraryPackageName = 'test'; - mockFs({ - '/root': { - packages: mockFs.directory(), - }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + mockDir.setContent({ + packages: {}, }); const options = await FactoryRegistry.populateOptions(nodeLibraryPackage, { @@ -69,8 +68,7 @@ describe('nodeLibraryPackage factory', () => { expect(modified).toBe(true); - expect(output).toEqual([ - '', + expectLogsToMatch(output, [ `Creating node-library package ${expectedNodeLibraryPackageName}`, 'Checking Prerequisites:', `availability ${joinPath('packages', expectedNodeLibraryPackageName)}`, @@ -87,7 +85,11 @@ describe('nodeLibraryPackage factory', () => { await expect( fs.readJson( - `/root/packages/${expectedNodeLibraryPackageName}/package.json`, + mockDir.resolve( + 'packages', + expectedNodeLibraryPackageName, + 'package.json', + ), ), ).resolves.toEqual( expect.objectContaining({ @@ -99,11 +101,11 @@ describe('nodeLibraryPackage factory', () => { expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath(`/root/packages/${expectedNodeLibraryPackageName}`), + cwd: mockDir.resolve('packages', expectedNodeLibraryPackageName), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath(`/root/packages/${expectedNodeLibraryPackageName}`), + cwd: mockDir.resolve('packages', expectedNodeLibraryPackageName), optional: true, }); }); @@ -111,14 +113,9 @@ describe('nodeLibraryPackage factory', () => { it('should create a node library plugin with options and codeowners', async () => { const expectedNodeLibraryPackageName = 'test'; - mockFs({ - '/root': { - CODEOWNERS: '', - packages: mockFs.directory(), - }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + mockDir.setContent({ + CODEOWNERS: '', + packages: {}, }); const options = await FactoryRegistry.populateOptions(nodeLibraryPackage, { @@ -141,11 +138,11 @@ describe('nodeLibraryPackage factory', () => { expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath(`/root/${expectedNodeLibraryPackageName}`), + cwd: mockDir.resolve(expectedNodeLibraryPackageName), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath(`/root/${expectedNodeLibraryPackageName}`), + cwd: mockDir.resolve(expectedNodeLibraryPackageName), optional: true, }); }); diff --git a/packages/cli/src/lib/new/factories/pluginCommon.test.ts b/packages/cli/src/lib/new/factories/pluginCommon.test.ts index 25b6b51905..d85e490510 100644 --- a/packages/cli/src/lib/new/factories/pluginCommon.test.ts +++ b/packages/cli/src/lib/new/factories/pluginCommon.test.ts @@ -15,34 +15,33 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; -import { sep, resolve as resolvePath } from 'path'; -import { paths } from '../../paths'; +import { sep } from 'path'; import { Task } from '../../tasks'; import { FactoryRegistry } from '../FactoryRegistry'; -import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { + createMockOutputStream, + expectLogsToMatch, + mockPaths, +} from './common/testUtils'; import { pluginCommon } from './pluginCommon'; +import { createMockDirectory } from '@backstage/backend-test-utils'; describe('pluginCommon factory', () => { + const mockDir = createMockDirectory(); + beforeEach(() => { mockPaths({ - targetRoot: '/root', + targetRoot: mockDir.path, }); }); afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should create a common plugin package', async () => { - mockFs({ - '/root': { - plugins: mockFs.directory(), - }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + mockDir.setContent({ + plugins: {}, }); const options = await FactoryRegistry.populateOptions(pluginCommon, { @@ -67,8 +66,7 @@ describe('pluginCommon factory', () => { expect(modified).toBe(true); - expect(output).toEqual([ - '', + expectLogsToMatch(output, [ 'Creating backend plugin backstage-plugin-test-common', 'Checking Prerequisites:', `availability plugins${sep}test-common`, @@ -84,7 +82,7 @@ describe('pluginCommon factory', () => { ]); await expect( - fs.readJson('/root/plugins/test-common/package.json'), + fs.readJson(mockDir.resolve('plugins/test-common/package.json')), ).resolves.toEqual( expect.objectContaining({ name: 'backstage-plugin-test-common', @@ -96,11 +94,11 @@ describe('pluginCommon factory', () => { expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath('/root/plugins/test-common'), + cwd: mockDir.resolve('plugins/test-common'), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath('/root/plugins/test-common'), + cwd: mockDir.resolve('plugins/test-common'), optional: true, }); }); diff --git a/packages/cli/src/lib/new/factories/pluginNode.test.ts b/packages/cli/src/lib/new/factories/pluginNode.test.ts index 50971ff191..d8d9ea33ab 100644 --- a/packages/cli/src/lib/new/factories/pluginNode.test.ts +++ b/packages/cli/src/lib/new/factories/pluginNode.test.ts @@ -15,34 +15,33 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; -import { sep, resolve as resolvePath } from 'path'; -import { paths } from '../../paths'; +import { sep } from 'path'; import { Task } from '../../tasks'; import { FactoryRegistry } from '../FactoryRegistry'; -import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { + createMockOutputStream, + expectLogsToMatch, + mockPaths, +} from './common/testUtils'; import { pluginNode } from './pluginNode'; +import { createMockDirectory } from '@backstage/backend-test-utils'; describe('pluginNode factory', () => { + const mockDir = createMockDirectory(); + beforeEach(() => { mockPaths({ - targetRoot: '/root', + targetRoot: mockDir.path, }); }); afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should create a node plugin package', async () => { - mockFs({ - '/root': { - plugins: mockFs.directory(), - }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + mockDir.setContent({ + plugins: {}, }); const options = await FactoryRegistry.populateOptions(pluginNode, { @@ -67,8 +66,7 @@ describe('pluginNode factory', () => { expect(modified).toBe(true); - expect(output).toEqual([ - '', + expectLogsToMatch(output, [ 'Creating Node.js plugin library backstage-plugin-test-node', 'Checking Prerequisites:', `availability plugins${sep}test-node`, @@ -84,7 +82,7 @@ describe('pluginNode factory', () => { ]); await expect( - fs.readJson('/root/plugins/test-node/package.json'), + fs.readJson(mockDir.resolve('plugins/test-node/package.json')), ).resolves.toEqual( expect.objectContaining({ name: 'backstage-plugin-test-node', @@ -96,11 +94,11 @@ describe('pluginNode factory', () => { expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath('/root/plugins/test-node'), + cwd: mockDir.resolve('plugins/test-node'), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath('/root/plugins/test-node'), + cwd: mockDir.resolve('plugins/test-node'), optional: true, }); }); diff --git a/packages/cli/src/lib/new/factories/pluginWeb.test.ts b/packages/cli/src/lib/new/factories/pluginWeb.test.ts index bcd62f3bcd..ff4211d8fa 100644 --- a/packages/cli/src/lib/new/factories/pluginWeb.test.ts +++ b/packages/cli/src/lib/new/factories/pluginWeb.test.ts @@ -15,34 +15,33 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; -import { sep, resolve as resolvePath } from 'path'; -import { paths } from '../../paths'; +import { sep } from 'path'; import { Task } from '../../tasks'; import { FactoryRegistry } from '../FactoryRegistry'; -import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { + createMockOutputStream, + expectLogsToMatch, + mockPaths, +} from './common/testUtils'; import { pluginWeb } from './pluginWeb'; +import { createMockDirectory } from '@backstage/backend-test-utils'; describe('pluginWeb factory', () => { + const mockDir = createMockDirectory(); + beforeEach(() => { mockPaths({ - targetRoot: '/root', + targetRoot: mockDir.path, }); }); afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should create a react plugin package', async () => { - mockFs({ - '/root': { - plugins: mockFs.directory(), - }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + mockDir.setContent({ + plugins: {}, }); const options = await FactoryRegistry.populateOptions(pluginWeb, { @@ -67,8 +66,7 @@ describe('pluginWeb factory', () => { expect(modified).toBe(true); - expect(output).toEqual([ - '', + expectLogsToMatch(output, [ 'Creating web plugin library backstage-plugin-test-react', 'Checking Prerequisites:', `availability plugins${sep}test-react`, @@ -91,7 +89,7 @@ describe('pluginWeb factory', () => { ]); await expect( - fs.readJson('/root/plugins/test-react/package.json'), + fs.readJson(mockDir.resolve('plugins/test-react/package.json')), ).resolves.toEqual( expect.objectContaining({ name: 'backstage-plugin-test-react', @@ -103,11 +101,11 @@ describe('pluginWeb factory', () => { expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath('/root/plugins/test-react'), + cwd: mockDir.resolve('plugins/test-react'), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath('/root/plugins/test-react'), + cwd: mockDir.resolve('plugins/test-react'), optional: true, }); }); diff --git a/packages/cli/src/lib/new/factories/scaffolderModule.test.ts b/packages/cli/src/lib/new/factories/scaffolderModule.test.ts index 38d1207a3f..b43946228b 100644 --- a/packages/cli/src/lib/new/factories/scaffolderModule.test.ts +++ b/packages/cli/src/lib/new/factories/scaffolderModule.test.ts @@ -15,34 +15,33 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; -import { sep, resolve as resolvePath } from 'path'; -import { paths } from '../../paths'; +import { sep } from 'path'; import { Task } from '../../tasks'; import { FactoryRegistry } from '../FactoryRegistry'; -import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { + createMockOutputStream, + expectLogsToMatch, + mockPaths, +} from './common/testUtils'; import { scaffolderModule } from './scaffolderModule'; +import { createMockDirectory } from '@backstage/backend-test-utils'; describe('scaffolderModule factory', () => { + const mockDir = createMockDirectory(); + beforeEach(() => { mockPaths({ - targetRoot: '/root', + targetRoot: mockDir.path, }); }); afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should create a scaffolder backend module package', async () => { - mockFs({ - '/root': { - plugins: mockFs.directory(), - }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + mockDir.setContent({ + plugins: {}, }); const options = await FactoryRegistry.populateOptions(scaffolderModule, { @@ -67,8 +66,7 @@ describe('scaffolderModule factory', () => { expect(modified).toBe(true); - expect(output).toEqual([ - '', + expectLogsToMatch(output, [ 'Creating module backstage-plugin-scaffolder-backend-module-test', 'Checking Prerequisites:', `availability plugins${sep}scaffolder-backend-module-test`, @@ -87,7 +85,9 @@ describe('scaffolderModule factory', () => { ]); await expect( - fs.readJson('/root/plugins/scaffolder-backend-module-test/package.json'), + fs.readJson( + mockDir.resolve('plugins/scaffolder-backend-module-test/package.json'), + ), ).resolves.toEqual( expect.objectContaining({ name: 'backstage-plugin-scaffolder-backend-module-test', @@ -99,11 +99,11 @@ describe('scaffolderModule factory', () => { expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath('/root/plugins/scaffolder-backend-module-test'), + cwd: mockDir.resolve('plugins/scaffolder-backend-module-test'), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath('/root/plugins/scaffolder-backend-module-test'), + cwd: mockDir.resolve('plugins/scaffolder-backend-module-test'), optional: true, }); }); diff --git a/packages/cli/src/lib/new/factories/webLibraryPackage.test.ts b/packages/cli/src/lib/new/factories/webLibraryPackage.test.ts index 50fef44a67..b0c8461189 100644 --- a/packages/cli/src/lib/new/factories/webLibraryPackage.test.ts +++ b/packages/cli/src/lib/new/factories/webLibraryPackage.test.ts @@ -15,36 +15,35 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; -import { resolve as resolvePath, join as joinPath } from 'path'; -import { paths } from '../../paths'; +import { join as joinPath } from 'path'; import { Task } from '../../tasks'; import { FactoryRegistry } from '../FactoryRegistry'; -import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { + createMockOutputStream, + expectLogsToMatch, + mockPaths, +} from './common/testUtils'; import { webLibraryPackage } from './webLibraryPackage'; +import { createMockDirectory } from '@backstage/backend-test-utils'; describe('webLibraryPackage factory', () => { + const mockDir = createMockDirectory(); + beforeEach(() => { mockPaths({ - targetRoot: '/root', + targetRoot: mockDir.path, }); }); afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should create a web library package', async () => { const expectedwebLibraryPackageName = 'test'; - mockFs({ - '/root': { - packages: mockFs.directory(), - }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + mockDir.setContent({ + packages: {}, }); const options = await FactoryRegistry.populateOptions(webLibraryPackage, { @@ -69,8 +68,7 @@ describe('webLibraryPackage factory', () => { expect(modified).toBe(true); - expect(output).toEqual([ - '', + expectLogsToMatch(output, [ `Creating web-library package ${expectedwebLibraryPackageName}`, 'Checking Prerequisites:', `availability ${joinPath('packages', expectedwebLibraryPackageName)}`, @@ -87,7 +85,11 @@ describe('webLibraryPackage factory', () => { await expect( fs.readJson( - `/root/packages/${expectedwebLibraryPackageName}/package.json`, + mockDir.resolve( + 'packages', + expectedwebLibraryPackageName, + 'package.json', + ), ), ).resolves.toEqual( expect.objectContaining({ @@ -99,11 +101,11 @@ describe('webLibraryPackage factory', () => { expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath(`/root/packages/${expectedwebLibraryPackageName}`), + cwd: mockDir.resolve('packages', expectedwebLibraryPackageName), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath(`/root/packages/${expectedwebLibraryPackageName}`), + cwd: mockDir.resolve('packages', expectedwebLibraryPackageName), optional: true, }); }); @@ -111,14 +113,9 @@ describe('webLibraryPackage factory', () => { it('should create a web library plugin with options and codeowners', async () => { const expectedwebLibraryPackageName = 'test'; - mockFs({ - '/root': { - CODEOWNERS: '', - packages: mockFs.directory(), - }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + mockDir.setContent({ + CODEOWNERS: '', + packages: {}, }); const options = await FactoryRegistry.populateOptions(webLibraryPackage, { @@ -141,11 +138,11 @@ describe('webLibraryPackage factory', () => { expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath(`/root/${expectedwebLibraryPackageName}`), + cwd: mockDir.resolve(expectedwebLibraryPackageName), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath(`/root/${expectedwebLibraryPackageName}`), + cwd: mockDir.resolve(expectedwebLibraryPackageName), optional: true, }); }); diff --git a/packages/cli/src/lib/role.test.ts b/packages/cli/src/lib/role.test.ts index adca2d70b8..8163512b76 100644 --- a/packages/cli/src/lib/role.test.ts +++ b/packages/cli/src/lib/role.test.ts @@ -14,10 +14,20 @@ * limitations under the License. */ -import mockFs from 'mock-fs'; +import { createMockDirectory } from '@backstage/backend-test-utils'; import { Command } from 'commander'; import { findRoleFromCommand } from './role'; +const mockDir = createMockDirectory(); + +jest.mock('./paths', () => ({ + paths: { + resolveTarget(filename: string) { + return mockDir.resolve(filename); + }, + }, +})); + describe('findRoleFromCommand', () => { function mkCommand(args: string) { const parsed = new Command() @@ -27,7 +37,7 @@ describe('findRoleFromCommand', () => { } beforeEach(() => { - mockFs({ + mockDir.setContent({ 'package.json': JSON.stringify({ name: 'test', backstage: { @@ -37,10 +47,6 @@ describe('findRoleFromCommand', () => { }); }); - afterEach(() => { - mockFs.restore(); - }); - it('provides role info by role', async () => { await expect(findRoleFromCommand(mkCommand(''))).resolves.toEqual( 'web-library', diff --git a/packages/cli/src/lib/tasks.test.ts b/packages/cli/src/lib/tasks.test.ts index fb1f38536f..1119adb7f7 100644 --- a/packages/cli/src/lib/tasks.test.ts +++ b/packages/cli/src/lib/tasks.test.ts @@ -15,14 +15,11 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; -import { resolve as resolvePath } from 'path'; import { templatingTask } from './tasks'; +import { createMockDirectory } from '@backstage/backend-test-utils'; describe('templatingTask', () => { - afterEach(() => { - mockFs.restore(); - }); + const mockDir = createMockDirectory(); it('should template a directory with mix of regular files and templates', async () => { // Testing template directory @@ -36,7 +33,7 @@ describe('templatingTask', () => { const testVersionFileContent = "version: {{pluginVersion}} {{versionQuery 'mock-pkg'}}"; - mockFs({ + mockDir.setContent({ [tmplDir]: { sub: { 'version.txt.hbs': testVersionFileContent, @@ -47,8 +44,8 @@ describe('templatingTask', () => { }); await templatingTask( - tmplDir, - destDir, + mockDir.resolve(tmplDir), + mockDir.resolve(destDir), { pluginVersion: '0.0.0', }, @@ -57,10 +54,10 @@ describe('templatingTask', () => { ); await expect( - fs.readFile(resolvePath(destDir, 'test.txt'), 'utf8'), + fs.readFile(mockDir.resolve(destDir, 'test.txt'), 'utf8'), ).resolves.toBe(testFileContent); await expect( - fs.readFile(resolvePath(destDir, 'sub/version.txt'), 'utf8'), + fs.readFile(mockDir.resolve(destDir, 'sub/version.txt'), 'utf8'), ).resolves.toBe('version: 0.0.0 ^0.1.2'); }); }); diff --git a/packages/cli/src/lib/version.test.ts b/packages/cli/src/lib/version.test.ts index 604129679b..6b1aa391d5 100644 --- a/packages/cli/src/lib/version.test.ts +++ b/packages/cli/src/lib/version.test.ts @@ -14,15 +14,13 @@ * limitations under the License. */ -import mockFs from 'mock-fs'; import { packageVersions, createPackageVersionProvider } from './version'; import { Lockfile } from './versioning'; import corePluginApiPkg from '@backstage/core-plugin-api/package.json'; +import { createMockDirectory } from '@backstage/backend-test-utils'; describe('createPackageVersionProvider', () => { - afterEach(() => { - mockFs.restore(); - }); + const mockDir = createMockDirectory(); const HEADER = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. # yarn lockfile v1 @@ -30,7 +28,7 @@ describe('createPackageVersionProvider', () => { `; it('should provide package versions', async () => { - mockFs({ + mockDir.setContent({ 'yarn.lock': `${HEADER} "a@^0.1.0": version "0.1.5" @@ -55,7 +53,8 @@ describe('createPackageVersionProvider', () => { `, }); - const lockfile = await Lockfile.load('yarn.lock'); + const lockfilePath = mockDir.resolve('yarn.lock'); + const lockfile = await Lockfile.load(lockfilePath); const provider = createPackageVersionProvider(lockfile); expect(provider('a', '0.1.5')).toBe('^0.1.0'); diff --git a/packages/cli/src/lib/versioning/Lockfile.test.ts b/packages/cli/src/lib/versioning/Lockfile.test.ts index 909f8be9aa..a4267acdb7 100644 --- a/packages/cli/src/lib/versioning/Lockfile.test.ts +++ b/packages/cli/src/lib/versioning/Lockfile.test.ts @@ -15,9 +15,9 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; import { BackstagePackage } from '@backstage/cli-node'; import { Lockfile } from './Lockfile'; +import { createMockDirectory } from '@backstage/backend-test-utils'; const LEGACY_HEADER = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. # yarn lockfile v1 @@ -76,16 +76,14 @@ const mockBDedup = `${LEGACY_HEADER} `; describe('Lockfile', () => { - afterEach(() => { - mockFs.restore(); - }); + const mockDir = createMockDirectory(); it('should load and serialize mockA', async () => { - mockFs({ - '/yarn.lock': mockA, + mockDir.setContent({ + 'yarn.lock': mockA, }); - const lockfile = await Lockfile.load('/yarn.lock'); + const lockfile = await Lockfile.load(mockDir.resolve('yarn.lock')); expect(lockfile.get('a')).toEqual([ { range: '^1', version: '1.0.1', dataKey: 'a@^1' }, ]); @@ -97,11 +95,12 @@ describe('Lockfile', () => { }); it('should deduplicate and save mockA', async () => { - mockFs({ - '/yarn.lock': mockA, + mockDir.setContent({ + 'yarn.lock': mockA, }); - const lockfile = await Lockfile.load('/yarn.lock'); + const lockfilePath = mockDir.resolve('yarn.lock'); + const lockfile = await Lockfile.load(lockfilePath); const result = lockfile.analyze({ localPackages: new Map() }); expect(result).toEqual({ invalidRanges: [], @@ -120,17 +119,17 @@ describe('Lockfile', () => { lockfile.replaceVersions(result.newVersions); expect(lockfile.toString()).toBe(mockADedup); - await expect(fs.readFile('/yarn.lock', 'utf8')).resolves.toBe(mockA); - await expect(lockfile.save('/yarn.lock')).resolves.toBeUndefined(); - await expect(fs.readFile('/yarn.lock', 'utf8')).resolves.toBe(mockADedup); + await expect(fs.readFile(lockfilePath, 'utf8')).resolves.toBe(mockA); + await expect(lockfile.save(lockfilePath)).resolves.toBeUndefined(); + await expect(fs.readFile(lockfilePath, 'utf8')).resolves.toBe(mockADedup); }); it('should deduplicate mockB', async () => { - mockFs({ - '/yarn.lock': mockB, + mockDir.setContent({ + 'yarn.lock': mockB, }); - const lockfile = await Lockfile.load('/yarn.lock'); + const lockfile = await Lockfile.load(mockDir.resolve('yarn.lock')); const result = lockfile.analyze({ localPackages: new Map() }); expect(result).toEqual({ invalidRanges: [], @@ -226,16 +225,14 @@ b@^2: `; describe('New Lockfile', () => { - afterEach(() => { - mockFs.restore(); - }); + const mockDir = createMockDirectory(); it('should load and serialize mockANew', async () => { - mockFs({ - '/yarn.lock': mockANew, + mockDir.setContent({ + 'yarn.lock': mockANew, }); - const lockfile = await Lockfile.load('/yarn.lock'); + const lockfile = await Lockfile.load(mockDir.resolve('yarn.lock')); expect(lockfile.get('a')).toEqual([ { range: '^1', version: '1.0.1', dataKey: 'a@^1' }, ]); @@ -248,11 +245,12 @@ describe('New Lockfile', () => { }); it('should deduplicate and save mockANew', async () => { - mockFs({ - '/yarn.lock': mockANew, + mockDir.setContent({ + 'yarn.lock': mockANew, }); - const lockfile = await Lockfile.load('/yarn.lock'); + const lockfilePath = mockDir.resolve('yarn.lock'); + const lockfile = await Lockfile.load(lockfilePath); const result = lockfile.analyze({ localPackages: new Map() }); expect(result).toEqual({ invalidRanges: [], @@ -271,19 +269,20 @@ describe('New Lockfile', () => { lockfile.replaceVersions(result.newVersions); expect(lockfile.toString()).toBe(mockANewDedup); - await expect(fs.readFile('/yarn.lock', 'utf8')).resolves.toBe(mockANew); - await expect(lockfile.save('/yarn.lock')).resolves.toBeUndefined(); - await expect(fs.readFile('/yarn.lock', 'utf8')).resolves.toBe( + await expect(fs.readFile(lockfilePath, 'utf8')).resolves.toBe(mockANew); + await expect(lockfile.save(lockfilePath)).resolves.toBeUndefined(); + await expect(fs.readFile(lockfilePath, 'utf8')).resolves.toBe( mockANewDedup, ); }); it('should deduplicate and save mockANewLocal', async () => { - mockFs({ - '/yarn.lock': mockANewLocal, + mockDir.setContent({ + 'yarn.lock': mockANewLocal, }); - const lockfile = await Lockfile.load('/yarn.lock'); + const lockfilePath = mockDir.resolve('yarn.lock'); + const lockfile = await Lockfile.load(lockfilePath); const result = lockfile.analyze({ localPackages: new Map([ [ @@ -311,11 +310,11 @@ describe('New Lockfile', () => { lockfile.replaceVersions(result.newVersions); expect(lockfile.toString()).toBe(mockANewLocalDedup); - await expect(fs.readFile('/yarn.lock', 'utf8')).resolves.toBe( + await expect(fs.readFile(lockfilePath, 'utf8')).resolves.toBe( mockANewLocal, ); - await expect(lockfile.save('/yarn.lock')).resolves.toBeUndefined(); - await expect(fs.readFile('/yarn.lock', 'utf8')).resolves.toBe( + await expect(lockfile.save(lockfilePath)).resolves.toBeUndefined(); + await expect(fs.readFile(lockfilePath, 'utf8')).resolves.toBe( mockANewLocalDedup, ); }); diff --git a/packages/cli/src/lib/versioning/packages.test.ts b/packages/cli/src/lib/versioning/packages.test.ts index 5a255532f9..23ff927c48 100644 --- a/packages/cli/src/lib/versioning/packages.test.ts +++ b/packages/cli/src/lib/versioning/packages.test.ts @@ -14,12 +14,11 @@ * limitations under the License. */ -import mockFs from 'mock-fs'; -import path from 'path'; import * as runObj from '../run'; import * as yarn from '../yarn'; import { fetchPackageInfo, mapDependencies } from './packages'; import { NotFoundError } from '../errors'; +import { createMockDirectory } from '@backstage/backend-test-utils'; jest.mock('../run', () => { return { @@ -96,34 +95,41 @@ describe('fetchPackageInfo', () => { }); describe('mapDependencies', () => { + const mockDir = createMockDirectory(); + afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should read dependencies', async () => { - mockFs({ - '/root/package.json': JSON.stringify({ + mockDir.setContent({ + 'package.json': JSON.stringify({ workspaces: { packages: ['pkgs/*'], }, }), - '/root/pkgs/a/package.json': JSON.stringify({ - name: 'a', - dependencies: { - '@backstage/core': '1 || 2', + pkgs: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '1 || 2', + }, + }), }, - }), - '/root/pkgs/b/package.json': JSON.stringify({ - name: 'b', - dependencies: { - '@backstage/core': '3', - '@backstage/cli': '^0', + b: { + 'package.json': JSON.stringify({ + name: 'b', + dependencies: { + '@backstage/core': '3', + '@backstage/cli': '^0', + }, + }), }, - }), + }, }); - const dependencyMap = await mapDependencies('/root', '@backstage/*'); + const dependencyMap = await mapDependencies(mockDir.path, '@backstage/*'); expect(Array.from(dependencyMap)).toEqual([ [ '@backstage/core', @@ -131,12 +137,12 @@ describe('mapDependencies', () => { { name: 'a', range: '1 || 2', - location: path.resolve('/root/pkgs/a'), + location: mockDir.resolve('pkgs/a'), }, { name: 'b', range: '3', - location: path.resolve('/root/pkgs/b'), + location: mockDir.resolve('pkgs/b'), }, ], ], @@ -146,7 +152,7 @@ describe('mapDependencies', () => { { name: 'b', range: '^0', - location: path.resolve('/root/pkgs/b'), + location: mockDir.resolve('pkgs/b'), }, ], ], diff --git a/packages/cli/templates/scaffolder-module/src/actions/example/example.test.ts b/packages/cli/templates/scaffolder-module/src/actions/example/example.test.ts index 3f91a8f49d..242f93c2f3 100644 --- a/packages/cli/templates/scaffolder-module/src/actions/example/example.test.ts +++ b/packages/cli/templates/scaffolder-module/src/actions/example/example.test.ts @@ -22,7 +22,7 @@ describe('acme:example', () => { logStream: new PassThrough(), output: jest.fn(), createTemporaryDirectory() { - // Usage of mock-fs is recommended for testing of filesystem operations + // Usage of createMockDirectory is recommended for testing of filesystem operations throw new Error('Not implemented'); }, }); diff --git a/packages/core-app-api/src/routing/RouteResolver.beta.test.ts b/packages/core-app-api/src/routing/RouteResolver.beta.test.ts index c0ee452d28..09e6389e7f 100644 --- a/packages/core-app-api/src/routing/RouteResolver.beta.test.ts +++ b/packages/core-app-api/src/routing/RouteResolver.beta.test.ts @@ -31,9 +31,8 @@ jest.mock('react-router-dom', () => jest.requireActual('react-router-dom-beta'), ); -const element = () => null; const rest = { - element, + element: null, caseSensitive: false, children: [MATCH_ALL_ROUTE], plugins: new Set<BackstagePlugin>(), diff --git a/packages/core-app-api/src/routing/RouteResolver.compat.test.ts b/packages/core-app-api/src/routing/RouteResolver.compat.test.ts index 0b4bf588a2..39949b4cd6 100644 --- a/packages/core-app-api/src/routing/RouteResolver.compat.test.ts +++ b/packages/core-app-api/src/routing/RouteResolver.compat.test.ts @@ -25,9 +25,8 @@ import { } from '@backstage/core-plugin-api'; import { MATCH_ALL_ROUTE } from './collectors'; -const element = () => null; const rest = { - element, + element: null, caseSensitive: false, children: [MATCH_ALL_ROUTE], plugins: new Set<BackstagePlugin>(), diff --git a/packages/core-app-api/src/routing/RouteResolver.stable.test.ts b/packages/core-app-api/src/routing/RouteResolver.stable.test.ts index 05f7678078..f1f9ec7137 100644 --- a/packages/core-app-api/src/routing/RouteResolver.stable.test.ts +++ b/packages/core-app-api/src/routing/RouteResolver.stable.test.ts @@ -31,9 +31,8 @@ jest.mock('react-router-dom', () => jest.requireActual('react-router-dom-stable'), ); -const element = () => null; const rest = { - element, + element: null, caseSensitive: false, children: [MATCH_ALL_ROUTE], plugins: new Set<BackstagePlugin>(), diff --git a/packages/core-app-api/src/routing/RoutingProvider.beta.test.tsx b/packages/core-app-api/src/routing/RoutingProvider.beta.test.tsx index c3a5bab89e..0f7a5cd3ad 100644 --- a/packages/core-app-api/src/routing/RoutingProvider.beta.test.tsx +++ b/packages/core-app-api/src/routing/RoutingProvider.beta.test.tsx @@ -353,7 +353,9 @@ describe('v1 consumer', () => { initialProps: { routeRef: routeRef1 as AnyRouteRef, }, - wrapper: ({ children }: React.PropsWithChildren<{}>) => ( + wrapper: ({ + children, + }: React.PropsWithChildren<{ routeRef: AnyRouteRef }>) => ( <RoutingProvider routePaths={ new Map<RouteRef<any>, string>([ diff --git a/packages/core-app-api/src/routing/RoutingProvider.stable.test.tsx b/packages/core-app-api/src/routing/RoutingProvider.stable.test.tsx index 84a33842c9..495fc20b15 100644 --- a/packages/core-app-api/src/routing/RoutingProvider.stable.test.tsx +++ b/packages/core-app-api/src/routing/RoutingProvider.stable.test.tsx @@ -385,7 +385,9 @@ describe('v1 consumer', () => { initialProps: { routeRef: routeRef1 as AnyRouteRef, }, - wrapper: ({ children }: React.PropsWithChildren<{}>) => ( + wrapper: ({ + children, + }: React.PropsWithChildren<{ routeRef: AnyRouteRef }>) => ( <RoutingProvider routePaths={ new Map<RouteRef<any>, string>([ diff --git a/packages/core-components/api-report.md b/packages/core-components/api-report.md index 44a8b33e04..d40179dc62 100644 --- a/packages/core-components/api-report.md +++ b/packages/core-components/api-report.md @@ -249,7 +249,7 @@ export interface DependencyGraphProps<NodeData, EdgeData> acyclicer?: 'greedy'; align?: DependencyGraphTypes.Alignment; curve?: 'curveStepBefore' | 'curveMonotoneX'; - defs?: SVGDefsElement | SVGDefsElement[]; + defs?: JSX.Element | JSX.Element[]; direction?: DependencyGraphTypes.Direction; edgeMargin?: number; edgeRanks?: number; diff --git a/packages/core-components/src/components/DependencyGraph/DependencyGraph.tsx b/packages/core-components/src/components/DependencyGraph/DependencyGraph.tsx index 4928292424..3dd1025b8f 100644 --- a/packages/core-components/src/components/DependencyGraph/DependencyGraph.tsx +++ b/packages/core-components/src/components/DependencyGraph/DependencyGraph.tsx @@ -144,7 +144,7 @@ export interface DependencyGraphProps<NodeData, EdgeData> * {@link https://developer.mozilla.org/en-US/docs/Web/SVG/Element/defs | Defs} shared by rendered SVG to be used by * {@link DependencyGraphProps.renderNode} and/or {@link DependencyGraphProps.renderLabel} */ - defs?: SVGDefsElement | SVGDefsElement[]; + defs?: JSX.Element | JSX.Element[]; /** * Controls zoom behavior of graph * diff --git a/packages/core-components/src/components/Table/Table.tsx b/packages/core-components/src/components/Table/Table.tsx index 9f2b04c214..e4ee9b5995 100644 --- a/packages/core-components/src/components/Table/Table.tsx +++ b/packages/core-components/src/components/Table/Table.tsx @@ -455,7 +455,7 @@ export function Table<T extends object = {}>(props: TableProps<T>) { const hasFilters = !!filters?.length; const Toolbar = useCallback( - toolbarProps => { + (toolbarProps: any /* no type for this in material-table */) => { return ( <TableToolbar setSearch={setSearch} @@ -472,7 +472,7 @@ export function Table<T extends object = {}>(props: TableProps<T>) { const hasNoRows = typeof data !== 'function' && data.length === 0; const columnCount = columns.length; const Body = useCallback( - bodyProps => { + (bodyProps: any /* no type for this in material-table */) => { if (isLoading) { return ( <tbody data-testid="loading-indicator"> diff --git a/packages/core-components/src/layout/Sidebar/Items.tsx b/packages/core-components/src/layout/Sidebar/Items.tsx index c40d45f8b4..aba4c79181 100644 --- a/packages/core-components/src/layout/Sidebar/Items.tsx +++ b/packages/core-components/src/layout/Sidebar/Items.tsx @@ -312,7 +312,11 @@ const sidebarSubmenuType = React.createElement(SidebarSubmenu).type; // properly yet, matching for example /foobar with /foo. export const WorkaroundNavLink = React.forwardRef< HTMLAnchorElement, - NavLinkProps & { activeStyle?: CSSProperties; activeClassName?: string } + NavLinkProps & { + children?: ReactNode; + activeStyle?: CSSProperties; + activeClassName?: string; + } >(function WorkaroundNavLinkWithRef( { to, @@ -361,7 +365,10 @@ export const WorkaroundNavLink = React.forwardRef< /** * Common component used by SidebarItem & SidebarItemWithSubmenu */ -const SidebarItemBase = forwardRef<any, SidebarItemProps>((props, ref) => { +const SidebarItemBase = forwardRef< + any, + SidebarItemProps & { children: ReactNode } +>((props, ref) => { const { icon: Icon, text, @@ -553,7 +560,10 @@ const SidebarItemWithSubmenu = ({ * @remarks * If children contain a `SidebarSubmenu` component the `SidebarItem` will have a expandable submenu */ -export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => { +export const SidebarItem = forwardRef< + any, + SidebarItemProps & { children: ReactNode } +>((props, ref) => { // Filter children for SidebarSubmenu components const [submenu] = useElementFilter(props.children, elements => // Directly comparing child.type with SidebarSubmenu will not work with in diff --git a/packages/core-components/src/layout/Sidebar/MobileSidebar.tsx b/packages/core-components/src/layout/Sidebar/MobileSidebar.tsx index e124036648..88d653cea5 100644 --- a/packages/core-components/src/layout/Sidebar/MobileSidebar.tsx +++ b/packages/core-components/src/layout/Sidebar/MobileSidebar.tsx @@ -25,7 +25,7 @@ import Typography from '@material-ui/core/Typography'; import CloseIcon from '@material-ui/icons/Close'; import MenuIcon from '@material-ui/icons/Menu'; import { orderBy } from 'lodash'; -import React, { useEffect, useState, useContext } from 'react'; +import React, { useEffect, useState, useContext, ReactNode } from 'react'; import { useLocation } from 'react-router-dom'; import { SidebarOpenStateProvider } from './SidebarOpenStateContext'; import { SidebarGroup } from './SidebarGroup'; @@ -206,8 +206,7 @@ export const MobileSidebar = (props: MobileSidebarProps) => { onClose={() => setSelectedMenuItemIndex(-1)} > {sidebarGroups[selectedMenuItemIndex] && - (sidebarGroups[selectedMenuItemIndex].props - .children as React.ReactChildren)} + (sidebarGroups[selectedMenuItemIndex].props.children as ReactNode)} </OverlayMenu> <BottomNavigation className={classes.root} diff --git a/packages/core-components/src/layout/Sidebar/Sidebar.stories.tsx b/packages/core-components/src/layout/Sidebar/Sidebar.stories.tsx index f647166250..342a9b2a29 100644 --- a/packages/core-components/src/layout/Sidebar/Sidebar.stories.tsx +++ b/packages/core-components/src/layout/Sidebar/Sidebar.stories.tsx @@ -59,7 +59,7 @@ const handleSearch = (input: string) => { export const SampleSidebar = () => ( <SidebarPage> <Sidebar> - <SidebarGroup label="Menu" icon={MenuIcon}> + <SidebarGroup label="Menu" icon={<MenuIcon />}> <SidebarSearchField onSearch={handleSearch} to="/search" /> <SidebarDivider /> <SidebarItem icon={HomeOutlinedIcon} to="#" text="Plugins" /> diff --git a/packages/core-components/src/layout/TabbedCard/TabbedCard.tsx b/packages/core-components/src/layout/TabbedCard/TabbedCard.tsx index a09ea11b3d..4e75878a7d 100644 --- a/packages/core-components/src/layout/TabbedCard/TabbedCard.tsx +++ b/packages/core-components/src/layout/TabbedCard/TabbedCard.tsx @@ -96,7 +96,9 @@ export function TabbedCard(props: PropsWithChildren<Props>) { } else { React.Children.map(children, child => { if ( - React.isValidElement<{ children?: unknown; value?: unknown }>(child) && + React.isValidElement<{ children?: ReactNode; value?: unknown }>( + child, + ) && child?.props.value === value ) { selectedTabContent = child?.props.children; diff --git a/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx b/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx index 8795ecdded..9a3760d019 100644 --- a/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx +++ b/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx @@ -41,7 +41,12 @@ const FeatureFlagComponent = (_props: { }) => null; attachComponentData(FeatureFlagComponent, 'core.featureFlagged', true); const mockFeatureFlagsApi = new LocalStorageFeatureFlags(); -const Wrapper = ({ children }: { children?: React.ReactNode }) => ( +const Wrapper = ({ + children, +}: { + children?: React.ReactNode; + tree?: ReactNode; +}) => ( <TestApiProvider apis={[[featureFlagsApiRef, mockFeatureFlagsApi]]}> {children} </TestApiProvider> diff --git a/packages/core-plugin-api/src/translation/useTranslationRef.test.tsx b/packages/core-plugin-api/src/translation/useTranslationRef.test.tsx index a8dbdc0402..19f0516906 100644 --- a/packages/core-plugin-api/src/translation/useTranslationRef.test.tsx +++ b/packages/core-plugin-api/src/translation/useTranslationRef.test.tsx @@ -303,7 +303,12 @@ describe('useTranslationRef', () => { const translationApi = I18nextTranslationApi.create({ languageApi }); const { result, rerender } = renderHook( - ({ translationRef }) => useTranslationRef(translationRef), + ({ + translationRef, + }: { + translationRef: TranslationRef; + children?: ReactNode; + }) => useTranslationRef(translationRef), { wrapper: ({ children }) => ( <TestApiProvider diff --git a/packages/dev-utils/package.json b/packages/dev-utils/package.json index 5ec49ded70..bbb55d9b14 100644 --- a/packages/dev-utils/package.json +++ b/packages/dev-utils/package.json @@ -47,8 +47,8 @@ "react-use": "^17.2.4" }, "peerDependencies": { - "react": "^16.13.1 || ^17.0.0", - "react-dom": "^16.13.1 || ^17.0.0", + "react": "^16.13.1 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0", "react-router-dom": "6.0.0-beta.0 || ^6.3.0" }, "devDependencies": { diff --git a/packages/dev-utils/src/devApp/render.tsx b/packages/dev-utils/src/devApp/render.tsx index 5f2b21b8d2..335308ff5e 100644 --- a/packages/dev-utils/src/devApp/render.tsx +++ b/packages/dev-utils/src/devApp/render.tsx @@ -45,9 +45,19 @@ import { import { Box } from '@material-ui/core'; import BookmarkIcon from '@material-ui/icons/Bookmark'; import React, { ComponentType, ReactNode, PropsWithChildren } from 'react'; -import ReactDOM from 'react-dom'; import { createRoutesFromChildren, Route } from 'react-router-dom'; import { SidebarThemeSwitcher } from './SidebarThemeSwitcher'; +import 'react-dom'; + +let ReactDOM: + | typeof import('react-dom') + // TODO: replace with import('react-dom/client') when repo is migrated to 18 + | { createRoot(el: HTMLElement): { render(el: JSX.Element): void } }; +if (process.env.HAS_REACT_DOM_CLIENT) { + ReactDOM = require('react-dom/client'); +} else { + ReactDOM = require('react-dom'); +} export function isReactRouterBeta(): boolean { const [obj] = createRoutesFromChildren(<Route index element={<div />} />); @@ -235,7 +245,11 @@ export class DevAppBuilder { window.location.pathname = this.defaultPage; } - ReactDOM.render(<DevApp />, document.getElementById('root')); + if ('createRoot' in ReactDOM) { + ReactDOM.createRoot(document.getElementById('root')!).render(<DevApp />); + } else { + ReactDOM.render(<DevApp />, document.getElementById('root')); + } } } diff --git a/plugins/auth-backend-module-pinniped-provider/.eslintrc.js b/plugins/auth-backend-module-pinniped-provider/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/auth-backend-module-pinniped-provider/README.md b/plugins/auth-backend-module-pinniped-provider/README.md new file mode 100644 index 0000000000..bdb340d426 --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/README.md @@ -0,0 +1,7 @@ +# Auth Module: Pinniped Provider + +This module provides an Pinniped auth provider implementation for `@backstage/plugin-auth-backend`. + +## Links + +- [Backstage](https://backstage.io) diff --git a/plugins/auth-backend-module-pinniped-provider/api-report.md b/plugins/auth-backend-module-pinniped-provider/api-report.md new file mode 100644 index 0000000000..b9b993bd1e --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/api-report.md @@ -0,0 +1,28 @@ +## API Report File for "@backstage/plugin-auth-backend-module-pinniped-provider" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { BackendFeature } from '@backstage/backend-plugin-api'; +import { BaseClient } from 'openid-client'; +import { OAuthAuthenticator } from '@backstage/plugin-auth-node'; +import { Strategy } from 'openid-client'; +import { TokenSet } from 'openid-client'; + +// @public (undocumented) +export const authModulePinnipedProvider: () => BackendFeature; + +// @public (undocumented) +export const pinnipedAuthenticator: OAuthAuthenticator< + Promise<{ + providerStrategy: Strategy< + { + tokenset: TokenSet; + }, + BaseClient + >; + client: BaseClient; + }>, + unknown +>; +``` diff --git a/plugins/auth-backend-module-pinniped-provider/catalog-info.yaml b/plugins/auth-backend-module-pinniped-provider/catalog-info.yaml new file mode 100644 index 0000000000..9d1ef1c299 --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-plugin-auth-backend-module-pinniped-provider + title: '@backstage/plugin-auth-backend-module-pinniped-provider' + description: The pinniped-provider backend module for the auth plugin. +spec: + lifecycle: experimental + type: backstage-backend-plugin-module + owner: maintainers diff --git a/plugins/auth-backend-module-pinniped-provider/dev/index.ts b/plugins/auth-backend-module-pinniped-provider/dev/index.ts new file mode 100644 index 0000000000..bd09f77a1f --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/dev/index.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2023 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 { createBackend } from '@backstage/backend-defaults'; +import authPlugin from '@backstage/plugin-auth-backend'; +import { authModulePinnipedProvider } from '../src'; + +const backend = createBackend(); + +backend.add(authPlugin); +backend.add(authModulePinnipedProvider); + +backend.start(); diff --git a/plugins/auth-backend-module-pinniped-provider/package.json b/plugins/auth-backend-module-pinniped-provider/package.json new file mode 100644 index 0000000000..3d7dcf97d9 --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/package.json @@ -0,0 +1,49 @@ +{ + "name": "@backstage/plugin-auth-backend-module-pinniped-provider", + "description": "The pinniped-provider backend module for the auth plugin.", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "backend-plugin-module" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/backend-common": "workspace:^", + "@backstage/backend-plugin-api": "workspace:^", + "@backstage/plugin-auth-node": "workspace:^", + "openid-client": "^5.4.3" + }, + "devDependencies": { + "@backstage/backend-defaults": "workspace:^", + "@backstage/backend-test-utils": "workspace:^", + "@backstage/cli": "workspace:^", + "@backstage/config": "workspace:^", + "@backstage/plugin-auth-backend": "workspace:^", + "cookie-parser": "^1.4.6", + "express": "^4.18.2", + "express-promise-router": "^4.1.1", + "express-session": "^1.17.3", + "jose": "^4.14.6", + "msw": "^1.3.0", + "passport": "^0.6.0", + "supertest": "^6.3.3" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/auth-backend-module-pinniped-provider/src/authenticator.test.ts b/plugins/auth-backend-module-pinniped-provider/src/authenticator.test.ts new file mode 100644 index 0000000000..f908c1ea13 --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/src/authenticator.test.ts @@ -0,0 +1,503 @@ +/* + * Copyright 2023 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 { + OAuthAuthenticatorAuthenticateInput, + OAuthAuthenticatorRefreshInput, + OAuthAuthenticatorStartInput, + OAuthState, + decodeOAuthState, + encodeOAuthState, +} from '@backstage/plugin-auth-node'; +import { pinnipedAuthenticator } from './authenticator'; +import { setupServer } from 'msw/node'; +import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +import { ConfigReader } from '@backstage/config'; +import { JWK, SignJWT, exportJWK, generateKeyPair } from 'jose'; +import { rest } from 'msw'; +import express from 'express'; + +describe('pinnipedAuthenticator', () => { + let implementation: any; + let oauthState: OAuthState; + let idToken: string; + let publicKey: JWK; + + const mswServer = setupServer(); + setupRequestMockHandlers(mswServer); + + const issuerMetadata = { + issuer: 'https://pinniped.test', + authorization_endpoint: 'https://pinniped.test/oauth2/authorize', + token_endpoint: 'https://pinniped.test/oauth2/token', + revocation_endpoint: 'https://pinniped.test/oauth2/revoke_token', + userinfo_endpoint: 'https://pinniped.test/idp/userinfo.openid', + introspection_endpoint: 'https://pinniped.test/introspect.oauth2', + jwks_uri: 'https://pinniped.test/jwks.json', + scopes_supported: [ + 'openid', + 'offline_access', + 'pinniped:request-audience', + 'username', + 'groups', + ], + claims_supported: ['email', 'username', 'groups', 'additionalClaims'], + response_types_supported: ['code'], + id_token_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], + token_endpoint_auth_signing_alg_values_supported: [ + 'RS256', + 'RS512', + 'HS256', + ], + request_object_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], + }; + + const clusterScopedIdToken = 'dummy-token'; + + beforeAll(async () => { + const keyPair = await generateKeyPair('ES256'); + const privateKey = await exportJWK(keyPair.privateKey); + publicKey = await exportJWK(keyPair.publicKey); + publicKey.alg = privateKey.alg = 'ES256'; + + idToken = await new SignJWT({ + sub: 'test', + iss: 'https://pinniped.test', + iat: Date.now(), + aud: 'clientId', + exp: Date.now() + 10000, + }) + .setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid }) + .sign(keyPair.privateKey); + }); + + beforeEach(() => { + jest.clearAllMocks(); + + mswServer.use( + rest.get( + 'https://federationDomain.test/.well-known/openid-configuration', + (_req, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(issuerMetadata), + ), + ), + rest.get('https://pinniped.test/jwks.json', async (_req, res, ctx) => + res(ctx.status(200), ctx.json({ keys: [{ ...publicKey }] })), + ), + rest.post('https://pinniped.test/oauth2/token', async (req, res, ctx) => { + const formBody = new URLSearchParams(await req.text()); + const isGrantTypeTokenExchange = + formBody.get('grant_type') === + 'urn:ietf:params:oauth:grant-type:token-exchange'; + const hasValidTokenExchangeParams = + formBody.get('subject_token') === 'accessToken' && + formBody.get('audience') === 'test_cluster' && + formBody.get('subject_token_type') === + 'urn:ietf:params:oauth:token-type:access_token' && + formBody.get('requested_token_type') === + 'urn:ietf:params:oauth:token-type:jwt'; + + return res( + req.headers.get('Authorization') && + (!isGrantTypeTokenExchange || hasValidTokenExchangeParams) + ? ctx.json({ + access_token: isGrantTypeTokenExchange + ? clusterScopedIdToken + : 'accessToken', + refresh_token: 'refreshToken', + ...(!isGrantTypeTokenExchange && { id_token: idToken }), + scope: 'testScope', + }) + : ctx.status(401), + ); + }), + ); + + implementation = pinnipedAuthenticator.initialize({ + callbackUrl: 'https://backstage.test/callback', + config: new ConfigReader({ + federationDomain: 'https://federationDomain.test', + clientId: 'clientId', + clientSecret: 'clientSecret', + }), + }); + + oauthState = { + nonce: 'nonce', + env: 'env', + }; + }); + + describe('#start', () => { + let fakeSession: Record<string, any>; + let startRequest: OAuthAuthenticatorStartInput; + + beforeEach(() => { + fakeSession = {}; + startRequest = { + state: encodeOAuthState(oauthState), + req: { + method: 'GET', + url: 'test', + session: fakeSession, + }, + } as unknown as OAuthAuthenticatorStartInput; + }); + + it('redirects to authorization endpoint returned from OIDC metadata endpoint', async () => { + const startResponse = await pinnipedAuthenticator.start( + startRequest, + implementation, + ); + const url = new URL(startResponse.url); + + expect(url.protocol).toBe('https:'); + expect(url.hostname).toBe('pinniped.test'); + expect(url.pathname).toBe('/oauth2/authorize'); + }); + + it('initiates authorization code grant', async () => { + const startResponse = await pinnipedAuthenticator.start( + startRequest, + implementation, + ); + const { searchParams } = new URL(startResponse.url); + + expect(searchParams.get('response_type')).toBe('code'); + }); + + it('persists audience parameter in oauth state', async () => { + startRequest.req.query = { audience: 'test-cluster' }; + const startResponse = await pinnipedAuthenticator.start( + startRequest, + implementation, + ); + const { searchParams } = new URL(startResponse.url); + const stateParam = searchParams.get('state'); + const decodedState = decodeOAuthState(stateParam!); + + expect(decodedState).toMatchObject({ + nonce: 'nonce', + env: 'env', + audience: 'test-cluster', + }); + }); + + it('passes client ID from config', async () => { + const startResponse = await pinnipedAuthenticator.start( + startRequest, + implementation, + ); + const { searchParams } = new URL(startResponse.url); + + expect(searchParams.get('client_id')).toBe('clientId'); + }); + + it('passes callback URL from config', async () => { + const startResponse = await pinnipedAuthenticator.start( + startRequest, + implementation, + ); + const { searchParams } = new URL(startResponse.url); + + expect(searchParams.get('redirect_uri')).toBe( + 'https://backstage.test/callback', + ); + }); + + it('generates PKCE challenge', async () => { + const startResponse = await pinnipedAuthenticator.start( + startRequest, + implementation, + ); + const { searchParams } = new URL(startResponse.url); + + expect(searchParams.get('code_challenge_method')).toBe('S256'); + expect(searchParams.get('code_challenge')).not.toBeNull(); + }); + + it('stores PKCE verifier in session', async () => { + await pinnipedAuthenticator.start(startRequest, implementation); + expect(fakeSession['oidc:pinniped.test'].code_verifier).toBeDefined(); + }); + + it('requests sufficient scopes for token exchange by default', async () => { + const startResponse = await pinnipedAuthenticator.start( + startRequest, + implementation, + ); + const { searchParams } = new URL(startResponse.url); + const scopes = searchParams.get('scope')?.split(' ') ?? []; + + expect(scopes).toEqual( + expect.arrayContaining([ + 'openid', + 'pinniped:request-audience', + 'username', + 'offline_access', + ]), + ); + }); + + it('encodes OAuth state in query param', async () => { + const startResponse = await pinnipedAuthenticator.start( + startRequest, + implementation, + ); + const { searchParams } = new URL(startResponse.url); + const stateParam = searchParams.get('state'); + const decodedState = decodeOAuthState(stateParam!); + + expect(decodedState).toMatchObject(oauthState); + }); + + it('fails when request has no session', async () => { + return expect( + pinnipedAuthenticator.start( + { + state: encodeOAuthState(oauthState), + req: { + method: 'GET', + url: 'test', + }, + } as unknown as OAuthAuthenticatorStartInput, + implementation, + ), + ).rejects.toThrow('authentication requires session support'); + }); + }); + + describe('#authenticate', () => { + let handlerRequest: OAuthAuthenticatorAuthenticateInput; + + beforeEach(() => { + handlerRequest = { + req: { + method: 'GET', + url: `https://test?code=authorization_code&state=${encodeOAuthState( + oauthState, + )}`, + session: { + 'oidc:pinniped.test': { + state: encodeOAuthState(oauthState), + }, + }, + } as unknown as express.Request, + }; + }); + + it('exchanges authorization code for access token', async () => { + const handlerResponse = await pinnipedAuthenticator.authenticate( + handlerRequest, + implementation, + ); + const accessToken = handlerResponse.session.accessToken; + + expect(accessToken).toEqual('accessToken'); + }); + + it('exchanges authorization code for refresh token', async () => { + const handlerResponse = await pinnipedAuthenticator.authenticate( + handlerRequest, + implementation, + ); + const refreshToken = handlerResponse.session.refreshToken; + + expect(refreshToken).toEqual('refreshToken'); + }); + + it('returns granted scope', async () => { + const handlerResponse = await pinnipedAuthenticator.authenticate( + handlerRequest, + implementation, + ); + const responseScope = handlerResponse.session.scope; + + expect(responseScope).toEqual('testScope'); + }); + + it('returns cluster-scoped ID token when audience is specified', async () => { + oauthState.audience = 'test_cluster'; + handlerRequest = { + req: { + method: 'GET', + url: `https://test?code=authorization_code&state=${encodeOAuthState( + oauthState, + )}`, + session: { + 'oidc:pinniped.test': { + state: encodeOAuthState(oauthState), + }, + }, + } as unknown as express.Request, + }; + + const handlerResponse = await pinnipedAuthenticator.authenticate( + handlerRequest, + implementation, + ); + + expect(handlerResponse.session.idToken).toEqual(clusterScopedIdToken); + }); + + it('fails on network error during token exchange', async () => { + mswServer.use( + rest.post( + 'https://pinniped.test/oauth2/token', + async (req, res, ctx) => { + const formBody = new URLSearchParams(await req.text()); + const isGrantTypeTokenExchange = + formBody.get('grant_type') === + 'urn:ietf:params:oauth:grant-type:token-exchange'; + const hasValidTokenExchangeParams = + formBody.get('subject_token') === 'accessToken' && + formBody.get('audience') === 'test_cluster' && + formBody.get('subject_token_type') === + 'urn:ietf:params:oauth:token-type:access_token' && + formBody.get('requested_token_type') === + 'urn:ietf:params:oauth:token-type:jwt'; + + mswServer.use( + rest.post( + 'https://pinniped.test/oauth2/token', + async (_req, response, _ctx) => + response.networkError('Connection timed out'), + ), + ); + + return res( + req.headers.get('Authorization') && + (!isGrantTypeTokenExchange || hasValidTokenExchangeParams) + ? ctx.json({ + access_token: isGrantTypeTokenExchange + ? clusterScopedIdToken + : 'accessToken', + refresh_token: 'refreshToken', + ...(!isGrantTypeTokenExchange && { id_token: idToken }), + scope: 'testScope', + }) + : ctx.status(401), + ); + }, + ), + ); + + oauthState.audience = 'test_cluster'; + handlerRequest = { + req: { + method: 'GET', + url: `https://test?code=authorization_code&state=${encodeOAuthState( + oauthState, + )}`, + session: { + 'oidc:pinniped.test': { + state: encodeOAuthState(oauthState), + }, + }, + } as unknown as express.Request, + }; + + await expect( + pinnipedAuthenticator.authenticate(handlerRequest, implementation), + ).rejects.toThrow( + `Failed to get cluster specific ID token for "test_cluster": Error: RFC8693 token exchange failed with error: NetworkError: Connection timed out`, + ); + }); + + it('fails without authorization code', async () => { + handlerRequest.req.url = 'https://test.com'; + return expect( + pinnipedAuthenticator.authenticate(handlerRequest, implementation), + ).rejects.toThrow('Unexpected redirect'); + }); + + it('fails without oauth state', async () => { + return expect( + pinnipedAuthenticator.authenticate( + { + req: { + method: 'GET', + url: `https://test?code=authorization_code}`, + session: { + ['oidc:pinniped.test']: { + state: { handle: 'sessionid', code_verifier: 'foo' }, + }, + }, + } as unknown as express.Request, + }, + implementation, + ), + ).rejects.toThrow( + 'Authentication rejected, state missing from the response', + ); + }); + + it('fails when request has no session', async () => { + return expect( + pinnipedAuthenticator.authenticate( + { + req: { + method: 'GET', + url: 'https://test.com', + } as unknown as express.Request, + }, + implementation, + ), + ).rejects.toThrow('authentication requires session support'); + }); + }); + + describe('#refresh', () => { + let refreshRequest: OAuthAuthenticatorRefreshInput; + + beforeEach(() => { + refreshRequest = { + scope: '', + refreshToken: 'otherRefreshToken', + req: {} as express.Request, + }; + }); + + it('gets new refresh token', async () => { + const refreshResponse = await pinnipedAuthenticator.refresh( + refreshRequest, + implementation, + ); + + expect(refreshResponse.session.refreshToken).toBe('refreshToken'); + }); + + it('gets access token', async () => { + const refreshResponse = await pinnipedAuthenticator.refresh( + refreshRequest, + implementation, + ); + + expect(refreshResponse.session.accessToken).toBe('accessToken'); + }); + + it('gets id token', async () => { + const refreshResponse = await pinnipedAuthenticator.refresh( + refreshRequest, + implementation, + ); + + expect(refreshResponse.session.idToken).toBe(idToken); + }); + }); +}); diff --git a/plugins/auth-backend-module-pinniped-provider/src/authenticator.ts b/plugins/auth-backend-module-pinniped-provider/src/authenticator.ts new file mode 100644 index 0000000000..83c4204ad0 --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/src/authenticator.ts @@ -0,0 +1,195 @@ +/* + * Copyright 2023 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 { PassportDoneCallback } from '@backstage/plugin-auth-node'; +import { + createOAuthAuthenticator, + decodeOAuthState, + encodeOAuthState, +} from '@backstage/plugin-auth-node'; +import { + Client, + Issuer, + TokenSet, + Strategy as OidcStrategy, +} from 'openid-client'; + +const rfc8693TokenExchange = async ({ + subject_token, + target_audience, + ctx, +}: { + subject_token: string; + target_audience: string; + ctx: Promise<{ + providerStrategy: OidcStrategy<{}>; + client: Client; + }>; +}): Promise<string | undefined> => { + const { client } = await ctx; + return client + .grant({ + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + subject_token, + audience: target_audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:access_token', + requested_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }) + .then(tokenset => tokenset.access_token) + .catch(err => { + throw new Error(`RFC8693 token exchange failed with error: ${err}`); + }); +}; + +/** @public */ +export const pinnipedAuthenticator = createOAuthAuthenticator({ + defaultProfileTransform: async (_r, _c) => ({ profile: {} }), + async initialize({ callbackUrl, config }) { + const issuer = await Issuer.discover( + `${config.getString( + 'federationDomain', + )}/.well-known/openid-configuration`, + ); + const client = new issuer.Client({ + access_type: 'offline', // this option must be passed to provider to receive a refresh token + client_id: config.getString('clientId'), + client_secret: config.getString('clientSecret'), + redirect_uris: [callbackUrl], + response_types: ['code'], + scope: config.getOptionalString('scope') || '', + id_token_signed_response_alg: 'ES256', + }); + const providerStrategy = new OidcStrategy( + { + client, + passReqToCallback: false, + }, + ( + tokenset: TokenSet, + done: PassportDoneCallback< + { tokenset: TokenSet }, + { + refreshToken?: string; + } + >, + ) => { + done(undefined, { tokenset }, {}); + }, + ); + + return { providerStrategy, client }; + }, + + async start(input, ctx) { + const { providerStrategy } = await ctx; + const stringifiedAudience = input.req.query?.audience as string; + const decodedState = decodeOAuthState(input.state); + const state = { ...decodedState, audience: stringifiedAudience }; + const options: Record<string, string> = { + scope: + input.scope || + 'openid pinniped:request-audience username offline_access', + state: encodeOAuthState(state), + }; + + return new Promise((resolve, reject) => { + const strategy = Object.create(providerStrategy); + strategy.redirect = (url: string) => { + resolve({ url }); + }; + strategy.error = (error: Error) => { + reject(error); + }; + strategy.authenticate(input.req, { ...options }); + }); + }, + + async authenticate(input, ctx) { + const { providerStrategy } = await ctx; + const { req } = input; + const { searchParams } = new URL(req.url, 'https://pinniped.com'); + const stateParam = searchParams.get('state'); + const audience = stateParam + ? decodeOAuthState(stateParam).audience + : undefined; + + return new Promise((resolve, reject) => { + const strategy = Object.create(providerStrategy); + strategy.success = (user: any) => { + (audience + ? rfc8693TokenExchange({ + subject_token: user.tokenset.access_token, + target_audience: audience, + ctx, + }).catch(err => + reject( + new Error( + `Failed to get cluster specific ID token for "${audience}": ${err}`, + ), + ), + ) + : Promise.resolve(user.tokenset.id_token) + ).then(idToken => { + resolve({ + fullProfile: { provider: '', id: '', displayName: '' }, + session: { + accessToken: user.tokenset.access_token!, + tokenType: user.tokenset.token_type ?? 'bearer', + scope: user.tokenset.scope!, + idToken, + refreshToken: user.tokenset.refresh_token, + }, + }); + }); + }; + + strategy.fail = (info: any) => { + reject(new Error(`Authentication rejected, ${info.message || ''}`)); + }; + + strategy.error = (error: Error) => { + reject(error); + }; + + strategy.redirect = () => { + reject(new Error('Unexpected redirect')); + }; + + strategy.authenticate(req); + }); + }, + + async refresh(input, ctx) { + const { client } = await ctx; + const tokenset = await client.refresh(input.refreshToken); + + return new Promise((resolve, reject) => { + if (!tokenset.access_token) { + reject(new Error('Refresh Failed')); + } + + resolve({ + fullProfile: { provider: '', id: '', displayName: '' }, + session: { + accessToken: tokenset.access_token!, + tokenType: tokenset.token_type ?? 'bearer', + scope: tokenset.scope!, + idToken: tokenset.id_token, + refreshToken: tokenset.refresh_token, + }, + }); + }); + }, +}); diff --git a/plugins/auth-backend-module-pinniped-provider/src/config.d.ts b/plugins/auth-backend-module-pinniped-provider/src/config.d.ts new file mode 100644 index 0000000000..50685abfb0 --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/src/config.d.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2020 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. + */ + +export interface Config { + /** Configuration options for the auth plugin */ + auth?: { + providers?: { + pinniped?: { + [authEnv: string]: { + clientId: string; + federationDomain: string; + /** + * @visibility secret + */ + clientSecret: string; + scope?: string; + }; + }; + }; + }; +} diff --git a/plugins/auth-backend-module-pinniped-provider/src/index.ts b/plugins/auth-backend-module-pinniped-provider/src/index.ts new file mode 100644 index 0000000000..9a2ca6727e --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/src/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2023 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. + */ + +/** + * The pinniped-provider backend module for the auth plugin. + * + * @packageDocumentation + */ + +export { pinnipedAuthenticator } from './authenticator'; +export { authModulePinnipedProvider } from './module'; diff --git a/plugins/auth-backend-module-pinniped-provider/src/module.test.ts b/plugins/auth-backend-module-pinniped-provider/src/module.test.ts new file mode 100644 index 0000000000..ed285cff86 --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/src/module.test.ts @@ -0,0 +1,260 @@ +/* + * Copyright 2023 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 { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +import request from 'supertest'; +import { setupServer } from 'msw/node'; +import { rest } from 'msw'; +import { Server } from 'http'; +import express from 'express'; +import cookieParser from 'cookie-parser'; +import session from 'express-session'; +import passport from 'passport'; +import { AddressInfo } from 'net'; +import { + AuthProviderRouteHandlers, + createOAuthRouteHandlers, +} from '@backstage/plugin-auth-node'; +import Router from 'express-promise-router'; +import { pinnipedAuthenticator } from './authenticator'; +import { ConfigReader } from '@backstage/config'; +import { JWK, SignJWT, exportJWK, generateKeyPair } from 'jose'; + +describe('authModulePinnipedProvider', () => { + let app: express.Express; + let backstageServer: Server; + let appUrl: string; + let providerRouteHandler: AuthProviderRouteHandlers; + let idToken: string; + let publicKey: JWK; + + const mswServer = setupServer(); + setupRequestMockHandlers(mswServer); + + const issuerMetadata = { + issuer: 'https://pinniped.test', + authorization_endpoint: 'https://pinniped.test/oauth2/authorize', + token_endpoint: 'https://pinniped.test/oauth2/token', + revocation_endpoint: 'https://pinniped.test/oauth2/revoke_token', + userinfo_endpoint: 'https://pinniped.test/idp/userinfo.openid', + introspection_endpoint: 'https://pinniped.test/introspect.oauth2', + jwks_uri: 'https://pinniped.test/jwks.json', + scopes_supported: [ + 'openid', + 'offline_access', + 'pinniped:request-audience', + 'username', + 'groups', + ], + claims_supported: ['email', 'username', 'groups', 'additionalClaims'], + response_types_supported: ['code'], + id_token_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], + token_endpoint_auth_signing_alg_values_supported: [ + 'RS256', + 'RS512', + 'HS256', + ], + request_object_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], + }; + + const clusterScopedIdToken = 'dummy-token'; + + beforeAll(async () => { + const keyPair = await generateKeyPair('ES256'); + const privateKey = await exportJWK(keyPair.privateKey); + publicKey = await exportJWK(keyPair.publicKey); + publicKey.alg = privateKey.alg = 'ES256'; + + idToken = await new SignJWT({ + sub: 'test', + iss: 'https://pinniped.test', + iat: Date.now(), + aud: 'clientId', + exp: Date.now() + 10000, + }) + .setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid }) + .sign(keyPair.privateKey); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + + mswServer.use( + rest.get( + 'https://federationDomain.test/.well-known/openid-configuration', + (_req, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(issuerMetadata), + ), + ), + rest.get( + 'https://pinniped.test/oauth2/authorize', + async (req, res, ctx) => { + const callbackUrl = new URL( + req.url.searchParams.get('redirect_uri')!, + ); + callbackUrl.searchParams.set('code', 'authorization_code'); + callbackUrl.searchParams.set( + 'state', + req.url.searchParams.get('state')!, + ); + callbackUrl.searchParams.set('scope', 'test-scope'); + return res( + ctx.status(302), + ctx.set('Location', callbackUrl.toString()), + ); + }, + ), + rest.get('https://pinniped.test/jwks.json', async (_req, res, ctx) => + res(ctx.status(200), ctx.json({ keys: [{ ...publicKey }] })), + ), + rest.post('https://pinniped.test/oauth2/token', async (req, res, ctx) => { + const formBody = new URLSearchParams(await req.text()); + const isGrantTypeTokenExchange = + formBody.get('grant_type') === + 'urn:ietf:params:oauth:grant-type:token-exchange'; + const hasValidTokenExchangeParams = + formBody.get('subject_token') === 'accessToken' && + formBody.get('audience') === 'test_cluster' && + formBody.get('subject_token_type') === + 'urn:ietf:params:oauth:token-type:access_token' && + formBody.get('requested_token_type') === + 'urn:ietf:params:oauth:token-type:jwt'; + + return res( + req.headers.get('Authorization') && + (!isGrantTypeTokenExchange || hasValidTokenExchangeParams) + ? ctx.json({ + access_token: isGrantTypeTokenExchange + ? clusterScopedIdToken + : 'accessToken', + refresh_token: 'refreshToken', + ...(!isGrantTypeTokenExchange && { id_token: idToken }), + scope: 'testScope', + }) + : ctx.status(401), + ); + }), + ); + + const secret = 'secret'; + app = express() + .use(cookieParser(secret)) + .use( + session({ + secret, + saveUninitialized: false, + resave: false, + cookie: { secure: false }, + }), + ) + .use(passport.initialize()) + .use(passport.session()); + await new Promise(resolve => { + backstageServer = app.listen(0, '0.0.0.0', () => { + appUrl = `http://127.0.0.1:${ + (backstageServer.address() as AddressInfo).port + }`; + resolve(null); + }); + }); + + mswServer.use(rest.all(`${appUrl}/*`, req => req.passthrough())); + + providerRouteHandler = createOAuthRouteHandlers({ + authenticator: pinnipedAuthenticator, + appUrl, + baseUrl: `${appUrl}/api/auth`, + isOriginAllowed: _ => true, + providerId: 'pinniped', + config: new ConfigReader({ + federationDomain: 'https://federationDomain.test', + clientId: 'clientId', + clientSecret: 'clientSecret', + }), + resolverContext: { + issueToken: async _ => ({ token: '' }), + findCatalogUser: async _ => ({ + entity: { + apiVersion: '', + kind: '', + metadata: { name: '' }, + }, + }), + signInWithCatalogUser: async _ => ({ token: '' }), + }, + }); + + const router = Router(); + router + .use( + '/api/auth/pinniped/start', + providerRouteHandler.start.bind(providerRouteHandler), + ) + .use( + '/api/auth/pinniped/handler/frame', + providerRouteHandler.frameHandler.bind(providerRouteHandler), + ); + app.use(router); + }); + + afterEach(() => { + backstageServer.close(); + }); + + it('should start', async () => { + const agent = request.agent(backstageServer); + const startResponse = await agent.get( + `/api/auth/pinniped/start?env=development&audience=test_cluster`, + ); + + expect(startResponse.status).toBe(302); + }); + + it('/handler/frame exchanges authorization code from #start for Cluster Specific ID token', async () => { + const agent = request.agent(''); + + // make /start request with audience parameter + const startResponse = await agent.get( + `${appUrl}/api/auth/pinniped/start?env=development&audience=test_cluster`, + ); + // follow redirect to authorization endpoint + const authorizationResponse = await agent.get( + startResponse.header.location, + ); + // follow redirect to token_endpoint + const handlerResponse = await agent.get( + authorizationResponse.header.location, + ); + + expect(handlerResponse.text).toContain( + encodeURIComponent( + JSON.stringify({ + type: 'authorization_response', + response: { + profile: {}, + providerInfo: { + idToken: clusterScopedIdToken, + accessToken: 'accessToken', + scope: 'testScope', + }, + }, + }), + ), + ); + }); +}); diff --git a/plugins/auth-backend-module-pinniped-provider/src/module.ts b/plugins/auth-backend-module-pinniped-provider/src/module.ts new file mode 100644 index 0000000000..5fff30e82e --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/src/module.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2023 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 { createBackendModule } from '@backstage/backend-plugin-api'; +import { + authProvidersExtensionPoint, + commonSignInResolvers, + createOAuthProviderFactory, +} from '@backstage/plugin-auth-node'; +import { pinnipedAuthenticator } from './authenticator'; + +/** @public */ +export const authModulePinnipedProvider = createBackendModule({ + pluginId: 'auth', + moduleId: 'pinniped-provider', + register(reg) { + reg.registerInit({ + deps: { + providers: authProvidersExtensionPoint, + }, + async init({ providers }) { + providers.registerProvider({ + providerId: 'pinniped', + factory: createOAuthProviderFactory({ + authenticator: pinnipedAuthenticator, + signInResolverFactories: { + ...commonSignInResolvers, + }, + }), + }); + }, + }); + }, +}); diff --git a/plugins/auth-backend/src/providers/oidc/provider.ts b/plugins/auth-backend/src/providers/oidc/provider.ts index 7638027b01..b6608aebbd 100644 --- a/plugins/auth-backend/src/providers/oidc/provider.ts +++ b/plugins/auth-backend/src/providers/oidc/provider.ts @@ -130,6 +130,9 @@ export class OidcAuthProvider implements OAuthHandlers { if (!tokenset.access_token) { throw new Error('Refresh failed'); } + if (!tokenset.scope) { + tokenset.scope = req.scope; + } const userinfo = await client.userinfo(tokenset.access_token); return { diff --git a/plugins/auth-node/api-report.md b/plugins/auth-node/api-report.md index a01de2cf46..4b1a8a1f27 100644 --- a/plugins/auth-node/api-report.md +++ b/plugins/auth-node/api-report.md @@ -399,6 +399,7 @@ export type OAuthState = { scope?: string; redirectUrl?: string; flow?: string; + audience?: string; }; // @public (undocumented) diff --git a/plugins/auth-node/src/oauth/state.ts b/plugins/auth-node/src/oauth/state.ts index fc747d08a5..28f7d2fd2e 100644 --- a/plugins/auth-node/src/oauth/state.ts +++ b/plugins/auth-node/src/oauth/state.ts @@ -29,6 +29,7 @@ export type OAuthState = { scope?: string; redirectUrl?: string; flow?: string; + audience?: string; }; /** @public */ diff --git a/plugins/catalog-backend-module-aws/api-report.md b/plugins/catalog-backend-module-aws/api-report.md index 83b072061b..a7a3f6816a 100644 --- a/plugins/catalog-backend-module-aws/api-report.md +++ b/plugins/catalog-backend-module-aws/api-report.md @@ -8,7 +8,9 @@ import { AwsCredentialsManager } from '@backstage/integration-aws-node'; import { CatalogProcessor } from '@backstage/plugin-catalog-node'; import { CatalogProcessorEmit } from '@backstage/plugin-catalog-node'; import { CatalogProcessorParser } from '@backstage/plugin-catalog-node'; +import type { Cluster } from '@aws-sdk/client-eks'; import { Config } from '@backstage/config'; +import type { Entity } from '@backstage/catalog-model'; import { EntityProvider } from '@backstage/plugin-catalog-node'; import { EntityProviderConnection } from '@backstage/plugin-catalog-node'; import { LocationSpec } from '@backstage/plugin-catalog-common'; @@ -17,6 +19,12 @@ import { PluginTaskScheduler } from '@backstage/backend-tasks'; import { TaskRunner } from '@backstage/backend-tasks'; import { UrlReader } from '@backstage/backend-common'; +// @public +export const ANNOTATION_AWS_ACCOUNT_ID: string; + +// @public +export const ANNOTATION_AWS_ARN: string; + // @public export type AWSCredentialFactory = ( awsAccountId: string, @@ -27,14 +35,18 @@ export class AwsEKSClusterProcessor implements CatalogProcessor { constructor(options: { credentialsFactory?: AWSCredentialFactory; credentialsManager?: AwsCredentialsManager; + clusterEntityTransformer?: EksClusterEntityTransformer; }); // (undocumented) - static fromConfig(configRoot: Config): AwsEKSClusterProcessor; + static fromConfig( + configRoot: Config, + options?: { + clusterEntityTransformer?: EksClusterEntityTransformer; + }, + ): AwsEKSClusterProcessor; // (undocumented) getProcessorName(): string; // (undocumented) - normalizeName(name: string): string; - // (undocumented) readLocation( location: LocationSpec, _optional: boolean, @@ -93,4 +105,10 @@ export class AwsS3EntityProvider implements EntityProvider { // (undocumented) refresh(logger: Logger): Promise<void>; } + +// @public +export type EksClusterEntityTransformer = ( + cluster: Cluster, + accountId: string, +) => Promise<Entity>; ``` diff --git a/plugins/catalog-backend-module-aws/src/constants.ts b/plugins/catalog-backend-module-aws/src/constants.ts new file mode 100644 index 0000000000..7756031ed7 --- /dev/null +++ b/plugins/catalog-backend-module-aws/src/constants.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2023 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. + */ + +/** + * Annotation for specifying AWS account id + * + * @public + */ +export const ANNOTATION_AWS_ACCOUNT_ID: string = 'amazonaws.com/account-id'; +/** + * Annotation for specifying AWS arn + * + * @public + */ +export const ANNOTATION_AWS_ARN: string = 'amazonaws.com/arn'; diff --git a/plugins/catalog-backend-module-aws/src/index.ts b/plugins/catalog-backend-module-aws/src/index.ts index 212308edaa..72ddc6386a 100644 --- a/plugins/catalog-backend-module-aws/src/index.ts +++ b/plugins/catalog-backend-module-aws/src/index.ts @@ -23,3 +23,4 @@ export * from './processors'; export * from './providers'; export * from './types'; +export * from './constants'; diff --git a/plugins/catalog-backend-module-aws/src/lib/defaultTransformers.ts b/plugins/catalog-backend-module-aws/src/lib/defaultTransformers.ts new file mode 100644 index 0000000000..9de79e9811 --- /dev/null +++ b/plugins/catalog-backend-module-aws/src/lib/defaultTransformers.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2023 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 { type Cluster } from '@aws-sdk/client-eks'; +import { + ANNOTATION_KUBERNETES_API_SERVER, + ANNOTATION_KUBERNETES_API_SERVER_CA, + ANNOTATION_KUBERNETES_AUTH_PROVIDER, +} from '@backstage/plugin-kubernetes-common'; +import type { EksClusterEntityTransformer } from '../processors/types'; +import { ANNOTATION_AWS_ACCOUNT_ID, ANNOTATION_AWS_ARN } from '../constants'; + +/** + * Default transformer for EKS Cluster to Resource Entity + * @public + */ +export const defaultEksClusterEntityTransformer: EksClusterEntityTransformer = + async (cluster: Cluster, accountId: string) => { + const { arn, endpoint, certificateAuthority, name } = cluster; + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Resource', + metadata: { + annotations: { + [ANNOTATION_AWS_ACCOUNT_ID]: accountId, + [ANNOTATION_AWS_ARN]: arn || '', + [ANNOTATION_KUBERNETES_API_SERVER]: endpoint || '', + [ANNOTATION_KUBERNETES_API_SERVER_CA]: + certificateAuthority?.data || '', + [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'aws', + }, + name: normalizeName(name as string), + namespace: 'default', + }, + spec: { + type: 'kubernetes-cluster', + owner: 'unknown', + }, + }; + }; + +function normalizeName(name: string): string { + return name + .trim() + .toLocaleLowerCase('en-US') + .replace(/[^a-zA-Z0-9\-]/g, '-'); +} diff --git a/plugins/catalog-backend-module-aws/src/lib/index.ts b/plugins/catalog-backend-module-aws/src/lib/index.ts new file mode 100644 index 0000000000..1dc70c98f3 --- /dev/null +++ b/plugins/catalog-backend-module-aws/src/lib/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 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. + */ +export * from './defaultTransformers'; diff --git a/plugins/catalog-backend-module-aws/src/processors/AwsEKSClusterProcessor.ts b/plugins/catalog-backend-module-aws/src/processors/AwsEKSClusterProcessor.ts index 4a19b9eb7f..a491dbab7d 100644 --- a/plugins/catalog-backend-module-aws/src/processors/AwsEKSClusterProcessor.ts +++ b/plugins/catalog-backend-module-aws/src/processors/AwsEKSClusterProcessor.ts @@ -19,11 +19,6 @@ import { CatalogProcessorEmit, } from '@backstage/plugin-catalog-node'; import { LocationSpec } from '@backstage/plugin-catalog-common'; -import { - ANNOTATION_KUBERNETES_API_SERVER, - ANNOTATION_KUBERNETES_API_SERVER_CA, - ANNOTATION_KUBERNETES_AUTH_PROVIDER, -} from '@backstage/plugin-kubernetes-common'; import { EKS } from '@aws-sdk/client-eks'; import { AWSCredentialFactory } from '../types'; import { AwsCredentialIdentity, Provider } from '@aws-sdk/types'; @@ -33,8 +28,8 @@ import { } from '@backstage/integration-aws-node'; import { Config } from '@backstage/config'; -const ACCOUNTID_ANNOTATION: string = 'amazonaws.com/account-id'; -const ARN_ANNOTATION: string = 'amazonaws.com/arn'; +import type { EksClusterEntityTransformer } from './types'; +import { defaultEksClusterEntityTransformer } from '../lib'; /** * A processor for automatic discovery of resources from EKS clusters. Handles the @@ -46,34 +41,39 @@ const ARN_ANNOTATION: string = 'amazonaws.com/arn'; export class AwsEKSClusterProcessor implements CatalogProcessor { private credentialsFactory?: AWSCredentialFactory; private credentialsManager?: AwsCredentialsManager; + private readonly clusterEntityTransformer: EksClusterEntityTransformer; - static fromConfig(configRoot: Config): AwsEKSClusterProcessor { + static fromConfig( + configRoot: Config, + options?: { + clusterEntityTransformer?: EksClusterEntityTransformer; + }, + ): AwsEKSClusterProcessor { const awsCredentaislManager = DefaultAwsCredentialsManager.fromConfig(configRoot); return new AwsEKSClusterProcessor({ credentialsManager: awsCredentaislManager, + ...options, }); } constructor(options: { credentialsFactory?: AWSCredentialFactory; credentialsManager?: AwsCredentialsManager; + clusterEntityTransformer?: EksClusterEntityTransformer; }) { this.credentialsFactory = options.credentialsFactory; this.credentialsManager = options.credentialsManager; + + // If the callback function is not passed in, then default to the one upstream is using + this.clusterEntityTransformer = + options.clusterEntityTransformer || defaultEksClusterEntityTransformer; } getProcessorName(): string { return 'aws-eks'; } - normalizeName(name: string): string { - return name - .trim() - .toLocaleLowerCase('en-US') - .replace(/[^a-zA-Z0-9\-]/g, '-'); - } - async readLocation( location: LocationSpec, _optional: boolean, @@ -108,6 +108,7 @@ export class AwsEKSClusterProcessor implements CatalogProcessor { const eksClient = new EKS({ credentials, credentialDefaultProvider: providerFunction, + region, }); const clusters = await eksClient.listClusters({}); if (clusters.clusters === undefined) { @@ -119,27 +120,11 @@ export class AwsEKSClusterProcessor implements CatalogProcessor { .map(async describedClusterPromise => { const describedCluster = await describedClusterPromise; if (describedCluster.cluster) { - const entity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Resource', - metadata: { - annotations: { - [ACCOUNTID_ANNOTATION]: accountId, - [ARN_ANNOTATION]: describedCluster.cluster.arn || '', - [ANNOTATION_KUBERNETES_API_SERVER]: - describedCluster.cluster.endpoint || '', - [ANNOTATION_KUBERNETES_API_SERVER_CA]: - describedCluster.cluster.certificateAuthority?.data || '', - [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'aws', - }, - name: this.normalizeName(describedCluster.cluster.name as string), - namespace: 'default', - }, - spec: { - type: 'kubernetes-cluster', - owner: 'unknown', - }, - }; + const entity = await this.clusterEntityTransformer( + describedCluster.cluster, + accountId, + ); + emit({ type: 'entity', entity, diff --git a/plugins/catalog-backend-module-aws/src/processors/index.ts b/plugins/catalog-backend-module-aws/src/processors/index.ts index 63b8a47139..e5bf93ceaf 100644 --- a/plugins/catalog-backend-module-aws/src/processors/index.ts +++ b/plugins/catalog-backend-module-aws/src/processors/index.ts @@ -17,3 +17,4 @@ export { AwsEKSClusterProcessor } from './AwsEKSClusterProcessor'; export { AwsOrganizationCloudAccountProcessor } from './AwsOrganizationCloudAccountProcessor'; export { AwsS3DiscoveryProcessor } from './AwsS3DiscoveryProcessor'; +export * from './types'; diff --git a/plugins/catalog-backend-module-aws/src/processors/types.ts b/plugins/catalog-backend-module-aws/src/processors/types.ts new file mode 100644 index 0000000000..22e9e4982d --- /dev/null +++ b/plugins/catalog-backend-module-aws/src/processors/types.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2023 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 type { Cluster } from '@aws-sdk/client-eks'; +import type { Entity } from '@backstage/catalog-model'; + +/** + * Options for the EKS cluster entity callback function + * + * @public + */ +export type EksClusterEntityTransformer = ( + cluster: Cluster, + accountId: string, +) => Promise<Entity>; diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/CurveFilter.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/CurveFilter.tsx index 6cda334cf1..2f3278dc6b 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/CurveFilter.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/CurveFilter.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Select } from '@backstage/core-components'; +import { Select, SelectedItems } from '@backstage/core-components'; import { Box } from '@material-ui/core'; import React, { useCallback } from 'react'; @@ -31,7 +31,10 @@ export type Props = { const curves: Array<Curve> = ['curveMonotoneX', 'curveStepBefore']; export const CurveFilter = ({ value, onChange }: Props) => { - const handleChange = useCallback(v => onChange(v as Curve), [onChange]); + const handleChange = useCallback( + (v: SelectedItems) => onChange(v as Curve), + [onChange], + ); return ( <Box pb={1} pt={1}> diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.tsx index 815ed1f292..c614ed2b58 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Select } from '@backstage/core-components'; +import { Select, SelectedItems } from '@backstage/core-components'; import { Box } from '@material-ui/core'; import React, { useCallback } from 'react'; import { Direction } from '../EntityRelationsGraph'; @@ -31,7 +31,10 @@ export type Props = { }; export const DirectionFilter = ({ value, onChange }: Props) => { - const handleChange = useCallback(v => onChange(v as Direction), [onChange]); + const handleChange = useCallback( + (v: SelectedItems) => onChange(v as Direction), + [onChange], + ); return ( <Box pb={1} pt={1}> diff --git a/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.tsx b/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.tsx index c777953808..16000465b9 100644 --- a/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.tsx +++ b/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.tsx @@ -36,7 +36,7 @@ export const DefaultImportPage = () => { const theme = useTheme(); const configApi = useApi(configApiRef); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); - const appTitle = configApi.getOptional('app.title') || 'Backstage'; + const appTitle = configApi.getOptionalString('app.title') || 'Backstage'; const contentItems = [ <Grid item xs={12} md={4} lg={6} xl={8}> diff --git a/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx b/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx index f7086fce19..26b6b8eb52 100644 --- a/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx +++ b/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx @@ -43,7 +43,7 @@ export const ImportInfoCard = (props: ImportInfoCardProps) => { } = props; const configApi = useApi(configApiRef); - const appTitle = configApi.getOptional('app.title') || 'Backstage'; + const appTitle = configApi.getOptionalString('app.title') || 'Backstage'; const catalogImportApi = useApi(catalogImportApiRef); const hasGithubIntegration = configApi.has('integrations.github'); diff --git a/plugins/catalog-import/src/components/StepReviewLocation/StepReviewLocation.tsx b/plugins/catalog-import/src/components/StepReviewLocation/StepReviewLocation.tsx index 74ebd973d0..b0db174787 100644 --- a/plugins/catalog-import/src/components/StepReviewLocation/StepReviewLocation.tsx +++ b/plugins/catalog-import/src/components/StepReviewLocation/StepReviewLocation.tsx @@ -42,7 +42,7 @@ export const StepReviewLocation = ({ const configApi = useApi(configApiRef); const analytics = useAnalytics(); - const appTitle = configApi.getOptional('app.title') || 'Backstage'; + const appTitle = configApi.getOptionalString('app.title') || 'Backstage'; const [submitted, setSubmitted] = useState(false); const [error, setError] = useState<string>(); diff --git a/plugins/catalog-react/src/components/EntityPeekAheadPopover/EntityPeekAheadPopover.tsx b/plugins/catalog-react/src/components/EntityPeekAheadPopover/EntityPeekAheadPopover.tsx index 91d60d4f13..077274a6e9 100644 --- a/plugins/catalog-react/src/components/EntityPeekAheadPopover/EntityPeekAheadPopover.tsx +++ b/plugins/catalog-react/src/components/EntityPeekAheadPopover/EntityPeekAheadPopover.tsx @@ -164,7 +164,7 @@ export const EntityPeekAheadPopover = (props: EntityPeekAheadPopoverProps) => { {entity.metadata.description} </Typography> )} - <Typography>{entity.spec?.type}</Typography> + <Typography>{entity.spec?.type?.toString()}</Typography> <Box marginTop="0.5em"> {(entity.metadata.tags || []) .slice(0, maxTagChips) diff --git a/plugins/catalog-react/src/components/InspectEntityDialog/components/OverviewPage.tsx b/plugins/catalog-react/src/components/InspectEntityDialog/components/OverviewPage.tsx index 09b944605f..d2eb6d4266 100644 --- a/plugins/catalog-react/src/components/InspectEntityDialog/components/OverviewPage.tsx +++ b/plugins/catalog-react/src/components/InspectEntityDialog/components/OverviewPage.tsx @@ -74,7 +74,10 @@ export function OverviewPage(props: { entity: AlphaEntity }) { </ListItem> {spec?.type && ( <ListItem> - <ListItemText primary="spec.type" secondary={spec.type} /> + <ListItemText + primary="spec.type" + secondary={spec.type?.toString()} + /> </ListItem> )} {metadata.uid && ( diff --git a/plugins/catalog-react/src/components/UnregisterEntityDialog/useUnregisterEntityDialogState.test.tsx b/plugins/catalog-react/src/components/UnregisterEntityDialog/useUnregisterEntityDialogState.test.tsx index e458e7d157..8591cdad93 100644 --- a/plugins/catalog-react/src/components/UnregisterEntityDialog/useUnregisterEntityDialogState.test.tsx +++ b/plugins/catalog-react/src/components/UnregisterEntityDialog/useUnregisterEntityDialogState.test.tsx @@ -22,7 +22,7 @@ import { renderHook, RenderHookResult, } from '@testing-library/react-hooks'; -import React from 'react'; +import React, { ReactNode } from 'react'; import { UseUnregisterEntityDialogState, useUnregisterEntityDialogState, @@ -85,7 +85,10 @@ describe('useUnregisterEntityDialogState', () => { }); it('goes through the happy unregister path', async () => { - let rendered: RenderHookResult<unknown, UseUnregisterEntityDialogState>; + let rendered: RenderHookResult< + { children?: ReactNode }, + UseUnregisterEntityDialogState + >; act(() => { rendered = renderHook(() => useUnregisterEntityDialogState(entity), { wrapper: Wrapper, @@ -114,7 +117,10 @@ describe('useUnregisterEntityDialogState', () => { entity.metadata.annotations![ANNOTATION_ORIGIN_LOCATION] = 'bootstrap:bootstrap'; - let rendered: RenderHookResult<unknown, UseUnregisterEntityDialogState>; + let rendered: RenderHookResult< + { children?: ReactNode }, + UseUnregisterEntityDialogState + >; act(() => { rendered = renderHook(() => useUnregisterEntityDialogState(entity), { wrapper: Wrapper, @@ -137,7 +143,10 @@ describe('useUnregisterEntityDialogState', () => { it('chooses only-delete when there was no location annotation', async () => { delete entity.metadata.annotations![ANNOTATION_ORIGIN_LOCATION]; - let rendered: RenderHookResult<unknown, UseUnregisterEntityDialogState>; + let rendered: RenderHookResult< + { children?: ReactNode }, + UseUnregisterEntityDialogState + >; act(() => { rendered = renderHook(() => useUnregisterEntityDialogState(entity), { wrapper: Wrapper, @@ -157,7 +166,10 @@ describe('useUnregisterEntityDialogState', () => { }); it('chooses only-delete when the location could not be found', async () => { - let rendered: RenderHookResult<unknown, UseUnregisterEntityDialogState>; + let rendered: RenderHookResult< + { children?: ReactNode }, + UseUnregisterEntityDialogState + >; act(() => { rendered = renderHook(() => useUnregisterEntityDialogState(entity), { wrapper: Wrapper, diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx index a190984ffe..9505dad889 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx @@ -128,7 +128,10 @@ function EntityLabels(props: { entity: Entity }) { /> )} {entity.spec?.lifecycle && ( - <HeaderLabel label="Lifecycle" value={entity.spec.lifecycle} /> + <HeaderLabel + label="Lifecycle" + value={entity.spec.lifecycle?.toString()} + /> )} </> ); diff --git a/plugins/code-coverage/src/components/FileExplorer/FileExplorer.tsx b/plugins/code-coverage/src/components/FileExplorer/FileExplorer.tsx index 4017ba8264..57bb9a2be0 100644 --- a/plugins/code-coverage/src/components/FileExplorer/FileExplorer.tsx +++ b/plugins/code-coverage/src/components/FileExplorer/FileExplorer.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useEntity } from '@backstage/plugin-catalog-react'; +import { humanizeEntityRef, useEntity } from '@backstage/plugin-catalog-react'; import { Box, Modal, makeStyles } from '@material-ui/core'; import FolderIcon from '@material-ui/icons/Folder'; import FileOutlinedIcon from '@material-ui/icons/InsertDriveFileOutlined'; @@ -181,7 +181,9 @@ export const FileExplorer = () => { } if (!value) { return ( - <Alert severity="warning">No code coverage found for ${entity}</Alert> + <Alert severity="warning"> + No code coverage found for {humanizeEntityRef(entity)} + </Alert> ); } diff --git a/plugins/cost-insights/src/components/BarChart/BarChartTooltip.test.tsx b/plugins/cost-insights/src/components/BarChart/BarChartTooltip.test.tsx index 3a6dca9417..4e255917d8 100644 --- a/plugins/cost-insights/src/components/BarChart/BarChartTooltip.test.tsx +++ b/plugins/cost-insights/src/components/BarChart/BarChartTooltip.test.tsx @@ -33,8 +33,9 @@ const items = [ }, ]; -const tooltipItems = () => - items.map(item => <BarChartTooltipItem key={item.label} item={item} />); +const tooltipItems = items.map(item => ( + <BarChartTooltipItem key={item.label} item={item} /> +)); describe('<BarChartTooltip/>', () => { it('formats label and tooltip item text correctly', async () => { diff --git a/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.test.tsx b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.test.tsx index 7dd5b4253f..3a2f30bbfe 100644 --- a/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.test.tsx +++ b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React from 'react'; +import React, { ComponentType } from 'react'; import { getByRole, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ProjectSelect } from './ProjectSelect'; @@ -28,7 +28,7 @@ const mockProjects = [ ]; describe('<ProjectSelect />', () => { - let Component: React.ReactNode; + let Component: ComponentType; beforeEach(() => { Component = () => ( <MockFilterProvider> diff --git a/plugins/entity-feedback/src/components/LikeDislikeButtons/LikeDislikeButtons.tsx b/plugins/entity-feedback/src/components/LikeDislikeButtons/LikeDislikeButtons.tsx index 2d38e60ac2..6c634deefb 100644 --- a/plugins/entity-feedback/src/components/LikeDislikeButtons/LikeDislikeButtons.tsx +++ b/plugins/entity-feedback/src/components/LikeDislikeButtons/LikeDislikeButtons.tsx @@ -24,6 +24,7 @@ import { } from '@backstage/core-plugin-api'; import { useAsyncEntity } from '@backstage/plugin-catalog-react'; import { IconButton } from '@material-ui/core'; +import Tooltip from '@material-ui/core/Tooltip'; import ThumbDownIcon from '@material-ui/icons/ThumbDown'; import ThumbUpIcon from '@material-ui/icons/ThumbUp'; import ThumbDownOutlinedIcon from '@material-ui/icons/ThumbDownOutlined'; @@ -127,9 +128,13 @@ export const LikeDislikeButtons = (props: LikeDislikeButtonsProps) => { onClick={() => applyRating(FeedbackRatings.like)} > {rating === FeedbackRatings.like ? ( - <ThumbUpIcon fontSize="small" /> + <Tooltip title="Liked"> + <ThumbUpIcon fontSize="small" /> + </Tooltip> ) : ( - <ThumbUpOutlinedIcon fontSize="small" /> + <Tooltip title="Like"> + <ThumbUpOutlinedIcon fontSize="small" /> + </Tooltip> )} </IconButton> <IconButton @@ -137,9 +142,13 @@ export const LikeDislikeButtons = (props: LikeDislikeButtonsProps) => { onClick={() => applyRating(FeedbackRatings.dislike)} > {rating === FeedbackRatings.dislike ? ( - <ThumbDownIcon fontSize="small" /> + <Tooltip title="Disliked"> + <ThumbDownIcon fontSize="small" /> + </Tooltip> ) : ( - <ThumbDownOutlinedIcon fontSize="small" /> + <Tooltip title="Dislike"> + <ThumbDownOutlinedIcon fontSize="small" /> + </Tooltip> )} </IconButton> <FeedbackResponseDialog diff --git a/plugins/jenkins-backend/src/service/jenkinsApi.ts b/plugins/jenkins-backend/src/service/jenkinsApi.ts index 85271462eb..2cf432343f 100644 --- a/plugins/jenkins-backend/src/service/jenkinsApi.ts +++ b/plugins/jenkins-backend/src/service/jenkinsApi.ts @@ -54,6 +54,7 @@ export class JenkinsApiImpl { private static readonly jobTreeSpec = `actions[*], ${JenkinsApiImpl.lastBuildTreeSpec} jobs{0,1}, + url, name, fullName, displayName, @@ -64,6 +65,16 @@ export class JenkinsApiImpl { ${JenkinsApiImpl.jobTreeSpec} ]{0,50}`; + private static readonly jobBuildsTreeSpec = ` + name, + description, + url, + fullName, + displayName, + fullDisplayName, + inQueue, + builds[*]`; + constructor(private readonly permissionApi?: PermissionEvaluator) {} /** @@ -329,4 +340,35 @@ export class JenkinsApiImpl { const jobs = jobFullName.split('/'); return `${jenkinsInfo.baseUrl}/job/${jobs.join('/job/')}/${buildId}`; } + + async getJobBuilds(jenkinsInfo: JenkinsInfo, jobFullName: string) { + let jobName = jobFullName; + + if (jobFullName.includes('/')) { + const arr = jobFullName.split('/'); + const multibranchJobName = arr.shift(); + jobName = [ + multibranchJobName, + 'job', + encodeURIComponent(arr.join('/')), + ].join('/'); + } + + const response = await fetch( + `${ + jenkinsInfo.baseUrl + }/job/${jobName}/api/json?tree=${JenkinsApiImpl.jobBuildsTreeSpec.replace( + /\s/g, + '', + )}`, + { + method: 'get', + headers: jenkinsInfo.headers as HeaderInit, + }, + ); + + const jobBuilds = await response.json(); + + return jobBuilds; + } } diff --git a/plugins/jenkins-backend/src/service/router.ts b/plugins/jenkins-backend/src/service/router.ts index eea067fab6..06a04d8266 100644 --- a/plugins/jenkins-backend/src/service/router.ts +++ b/plugins/jenkins-backend/src/service/router.ts @@ -151,6 +151,32 @@ export async function createRouter( }, ); + router.get( + '/v1/entity/:namespace/:kind/:name/job/:jobFullName', + async (request, response) => { + const token = getBearerTokenFromAuthorizationHeader( + request.header('authorization'), + ); + const { namespace, kind, name, jobFullName } = request.params; + + const jenkinsInfo = await jenkinsInfoProvider.getInstance({ + entityRef: { + kind, + namespace, + name, + }, + jobFullName, + backstageToken: token, + }); + + const build = await jenkinsApi.getJobBuilds(jenkinsInfo, jobFullName); + + response.json({ + build: build, + }); + }, + ); + router.post( '/v1/entity/:namespace/:kind/:name/job/:jobFullName/:buildNumber', async (request, response) => { diff --git a/plugins/jenkins/README.md b/plugins/jenkins/README.md index d21fee2b8a..b62eaab514 100644 --- a/plugins/jenkins/README.md +++ b/plugins/jenkins/README.md @@ -5,7 +5,9 @@ Website: [https://jenkins.io/](https://jenkins.io/) <img src="./src/assets/last-master-build.png" alt="Last master build"/> <img src="./src/assets/folder-results.png" alt="Folder results"/> <img src="./src/assets/build-details.png" alt="Build details"/> +<img src="./src/assets/jobrun-table.png" alt="Job builds records"/> <img src="./src/assets/dynamic-columns.png" alt="Modify Table Columns"/> + ## Setup 1. If you have a standalone app (you didn't clone this repo), then do @@ -74,7 +76,9 @@ metadata: name: 'your-component' description: 'a description' annotations: - jenkins.io/github-folder: 'folder-name/project-name' + jenkins.io/github-folder: 'folder-name/project-name' # deprecated + jenkins.io/job-full-name: 'folder-name/project-name' # use this instead + spec: type: service lifecycle: experimental @@ -98,6 +102,11 @@ spec: - No pagination support currently, limited to 50 projects - don't run this on a Jenkins instance with lots of builds +## EntityJobRunsTable + +- View all builds of a particular job +- shows average build time for successful builds + ## Modify Columns of EntityJenkinsContent - now you can pass down column props to show the columns/metadata as per your use case. diff --git a/plugins/jenkins/api-report.md b/plugins/jenkins/api-report.md index 2274c11e13..c1b8ad2b81 100644 --- a/plugins/jenkins/api-report.md +++ b/plugins/jenkins/api-report.md @@ -22,6 +22,9 @@ export const EntityJenkinsContent: (props: { columns?: TableColumn<Project>[] | undefined; }) => JSX_2.Element; +// @public (undocumented) +export const EntityJobRunsTable: () => JSX_2.Element; + // @public (undocumented) export const EntityLatestJenkinsRunCard: (props: { branch: string; @@ -48,6 +51,13 @@ export interface JenkinsApi { jobFullName: string; buildNumber: string; }): Promise<Build>; + // Warning: (ae-forgotten-export) The symbol "Job" needs to be exported by the entry point index.d.ts + // + // (undocumented) + getJobBuilds(options: { + entity: CompoundEntityRef; + jobFullName: string; + }): Promise<Job>; getProjects(options: { entity: CompoundEntityRef; filter: { @@ -82,6 +92,11 @@ export class JenkinsClient implements JenkinsApi { buildNumber: string; }): Promise<Build>; // (undocumented) + getJobBuilds(options: { + entity: CompoundEntityRef; + jobFullName: string; + }): Promise<Job>; + // (undocumented) getProjects(options: { entity: CompoundEntityRef; filter: { diff --git a/plugins/jenkins/src/api/JenkinsApi.ts b/plugins/jenkins/src/api/JenkinsApi.ts index 83b38e63f0..1f6e7ec294 100644 --- a/plugins/jenkins/src/api/JenkinsApi.ts +++ b/plugins/jenkins/src/api/JenkinsApi.ts @@ -56,6 +56,30 @@ export interface Build { }; status: string; // == building ? 'running' : result, } +export interface JobBuild { + timestamp: number; + building: boolean; + duration: number; + result?: string; + fullDisplayName: string; + displayName: string; + url: string; + number: number; + inProgress: boolean; + queueId: number; + id: number; +} + +export interface Job { + name: string; + displayName: string; + description: string; + fullDisplayName: string; + inQueue: boolean; + fullName: string; + url: string; + builds: JobBuild[]; +} /** @public */ export interface Project { @@ -99,6 +123,11 @@ export interface JenkinsApi { buildNumber: string; }): Promise<Build>; + getJobBuilds(options: { + entity: CompoundEntityRef; + jobFullName: string; + }): Promise<Job>; + retry(options: { entity: CompoundEntityRef; jobFullName: string; @@ -213,4 +242,28 @@ export class JenkinsClient implements JenkinsApi { const { token } = await this.identityApi.getCredentials(); return token; } + + async getJobBuilds(options: { + entity: CompoundEntityRef; + jobFullName: string; + }): Promise<Job> { + const { entity, jobFullName } = options; + const url = `${await this.discoveryApi.getBaseUrl( + 'jenkins', + )}/v1/entity/${encodeURIComponent(entity.namespace)}/${encodeURIComponent( + entity.kind, + )}/${encodeURIComponent(entity.name)}/job/${encodeURIComponent( + jobFullName, + )}`; + + const idToken = await this.getToken(); + const response = await fetch(url, { + method: 'GET', + headers: { + ...(idToken && { Authorization: `Bearer ${idToken}` }), + }, + }); + + return (await response.json()).build; + } } diff --git a/plugins/jenkins/src/assets/jobrun-table.png b/plugins/jenkins/src/assets/jobrun-table.png new file mode 100644 index 0000000000..9fff841c3b Binary files /dev/null and b/plugins/jenkins/src/assets/jobrun-table.png differ diff --git a/plugins/jenkins/src/components/BuildsPage/lib/CITable/columns.tsx b/plugins/jenkins/src/components/BuildsPage/lib/CITable/columns.tsx index 733a60c1b2..f18d3e5b21 100644 --- a/plugins/jenkins/src/components/BuildsPage/lib/CITable/columns.tsx +++ b/plugins/jenkins/src/components/BuildsPage/lib/CITable/columns.tsx @@ -19,9 +19,10 @@ import { useEntityPermission } from '@backstage/plugin-catalog-react/alpha'; import { Box, IconButton, Tooltip, Typography } from '@material-ui/core'; import RetryIcon from '@material-ui/icons/Replay'; import VisibilityIcon from '@material-ui/icons/Visibility'; +import HistoryIcon from '@material-ui/icons/History'; import { default as React, useState } from 'react'; import { Project } from '../../../../api/JenkinsApi'; -import { buildRouteRef } from '../../../../plugin'; +import { buildRouteRef, jobRunsRouteRef } from '../../../../plugin'; import { JenkinsRunStatus } from '../Status'; import { jenkinsExecutePermission } from '@backstage/plugin-jenkins-common'; @@ -186,6 +187,25 @@ export const columnFactories = Object.freeze({ }; }, + createLastRunDuration(): TableColumn<Project> { + return { + title: 'Last Run Duration', + align: 'left', + render: (row: Partial<Project>) => ( + <> + <Typography> + {row?.lastBuild?.duration + ? (row?.lastBuild?.duration / 1000) + .toFixed(1) + .toString() + .concat(' s') + : ''}{' '} + </Typography> + </> + ), + }; + }, + createActionsColumn(): TableColumn<Project> { return { title: 'Actions', @@ -198,6 +218,7 @@ export const columnFactories = Object.freeze({ ); const alertApi = useApi(alertApiRef); + const jobRunsLink = useRouteRef(jobRunsRouteRef); const onRebuild = async () => { if (row.onRestartClick) { @@ -221,7 +242,7 @@ export const columnFactories = Object.freeze({ }; return ( - <div style={{ width: '98px' }}> + <div style={{ width: '148px' }}> {row.lastBuild?.url && ( <Tooltip title="View build"> <IconButton href={row.lastBuild.url} target="_blank"> @@ -240,6 +261,17 @@ export const columnFactories = Object.freeze({ </IconButton> </Tooltip> )} + <Link + to={jobRunsLink({ + jobFullName: encodeURIComponent(row.fullName || ''), + })} + > + <Tooltip title="View Runs"> + <IconButton> + <HistoryIcon /> + </IconButton> + </Tooltip> + </Link> </div> ); }; diff --git a/plugins/jenkins/src/components/BuildsPage/lib/CITable/presets.ts b/plugins/jenkins/src/components/BuildsPage/lib/CITable/presets.ts index 7c0dda9970..828e035c8e 100644 --- a/plugins/jenkins/src/components/BuildsPage/lib/CITable/presets.ts +++ b/plugins/jenkins/src/components/BuildsPage/lib/CITable/presets.ts @@ -24,5 +24,6 @@ export const defaultCITableColumns: TableColumn<Project>[] = [ columnFactories.createBuildColumn(), columnFactories.createTestColumn(), columnFactories.createStatusColumn(), + columnFactories.createLastRunDuration(), columnFactories.createActionsColumn(), ]; diff --git a/plugins/jenkins/src/components/JobRunsTable/JobRunsTable.tsx b/plugins/jenkins/src/components/JobRunsTable/JobRunsTable.tsx new file mode 100644 index 0000000000..78b66b98eb --- /dev/null +++ b/plugins/jenkins/src/components/JobRunsTable/JobRunsTable.tsx @@ -0,0 +1,187 @@ +/* + * Copyright 2020 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 { Link, Table, TableColumn } from '@backstage/core-components'; +import { Box, IconButton, Tooltip, Typography } from '@material-ui/core'; +import { default as React } from 'react'; +import JenkinsLogo from './../../assets/JenkinsLogo.svg'; +import { useJobRuns } from './../useJobRuns'; +import { Job, JobBuild } from './../../api/JenkinsApi'; +import { JenkinsRunStatus } from './../BuildsPage/lib/Status'; +import VisibilityIcon from '@material-ui/icons/Visibility'; +import { jobRunsRouteRef } from '../../plugin'; +import { useRouteRefParams } from '@backstage/core-plugin-api'; + +const generatedColumns: TableColumn[] = [ + { + title: 'Number', + field: 'number', + render: (row: Partial<JobBuild>) => { + return ( + <Box display="flex" alignItems="center"> + <Typography paragraph> + <Link to={row.url ?? ''}>{row.number}</Link> + </Typography> + </Box> + ); + }, + }, + { + title: 'Timestamp', + field: 'timestamp', + render: (row: Partial<JobBuild>) => { + return ( + <Box display="flex" alignItems="center"> + <Typography> + {row?.timestamp ? new Date(row?.timestamp).toLocaleString() : ' '} + </Typography> + </Box> + ); + }, + }, + { + title: 'Result', + field: 'result', + render: (row: Partial<JobBuild>) => { + return ( + <Box display="flex" alignItems="center"> + {row.inProgress ? ( + <Typography>In Progress</Typography> + ) : ( + <JenkinsRunStatus status={row?.result} /> + )} + </Box> + ); + }, + }, + { + title: 'Duration', + field: 'duration', + render: (row: Partial<JobBuild>) => { + return ( + <Box display="flex" alignItems="center"> + <Typography> + {row?.duration + ? (row.duration / 1000).toFixed(1).toString().concat(' s') + : ''} + </Typography> + </Box> + ); + }, + }, + + { + title: 'Actions', + render: (row: Partial<JobBuild>) => { + const ActionWrapper = () => { + return ( + <div style={{ width: '98px' }}> + {row?.url && ( + <Tooltip title="View build"> + <Link component={IconButton} to={row.url}> + <VisibilityIcon /> + </Link> + </Tooltip> + )} + </div> + ); + }; + return <ActionWrapper />; + }, + width: '10%', + }, +]; + +type Props = { + loading: boolean; + jobRuns?: Job; + page: number; + onChangePage: (page: number) => void; + pageSize: number; + onChangePageSize: (pageSize: number) => void; +}; + +export const JobRunsTableView = ({ + loading, + pageSize, + page, + jobRuns, + onChangePage, + onChangePageSize, +}: Props) => { + const builds = jobRuns?.builds.slice( + page * pageSize, + page * pageSize + pageSize, + ); + let sumOfAllSuccessfulJobDuration = 0; + + const successfulJobCount = + builds?.reduce((count, build) => { + if (!build.inProgress && build.result === 'SUCCESS') { + sumOfAllSuccessfulJobDuration += build.duration; + return count + 1; + } + return count; + }, 0) || 0; + + let avgTime; + + if (successfulJobCount > 0) { + avgTime = (sumOfAllSuccessfulJobDuration / successfulJobCount / 1000) + .toFixed(1) + .toString(); + } + + return ( + <Table + isLoading={loading} + options={{ paging: true, pageSize, padding: 'dense' }} + totalCount={jobRuns?.builds.length || 0} + page={page} + data={builds ?? []} + onPageChange={onChangePage} + onRowsPerPageChange={onChangePageSize} + title={ + <Box> + <Box display="flex" alignItems="center"> + <img src={JenkinsLogo} alt="Jenkins logo" height="50px" /> + <Box mr={2} /> + <Typography variant="h6">{`${jobRuns?.displayName} Runs`}</Typography> + </Box> + <Box display="flex" alignItems="center" mt={2}> + <Typography variant="h6"> + Average Build Time For Last {successfulJobCount} Successful jobs :{' '} + {avgTime || 0} + </Typography> + </Box> + </Box> + } + columns={generatedColumns} + /> + ); +}; + +export const JobRunsTable = () => { + const { jobFullName } = useRouteRefParams(jobRunsRouteRef); + const [tableProps, { setPage, setPageSize }] = useJobRuns(jobFullName); + + return ( + <JobRunsTableView + {...tableProps} + onChangePageSize={setPageSize} + onChangePage={setPage} + /> + ); +}; diff --git a/plugins/jenkins/src/components/JobRunsTable/index.ts b/plugins/jenkins/src/components/JobRunsTable/index.ts new file mode 100644 index 0000000000..b74beeb315 --- /dev/null +++ b/plugins/jenkins/src/components/JobRunsTable/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 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. + */ +export { JobRunsTable } from './JobRunsTable'; diff --git a/plugins/jenkins/src/components/Router.tsx b/plugins/jenkins/src/components/Router.tsx index 364b5fde0b..30ce999f31 100644 --- a/plugins/jenkins/src/components/Router.tsx +++ b/plugins/jenkins/src/components/Router.tsx @@ -23,9 +23,10 @@ import { useEntity } from '@backstage/plugin-catalog-react'; import React from 'react'; import { Route, Routes } from 'react-router-dom'; import { JENKINS_ANNOTATION, LEGACY_JENKINS_ANNOTATION } from '../constants'; -import { buildRouteRef } from '../plugin'; +import { buildRouteRef, jobRunsRouteRef } from '../plugin'; import { CITable } from './BuildsPage/lib/CITable'; import { DetailedViewPage } from './BuildWithStepsPage/'; +import { JobRunsTable } from './JobRunsTable'; import { Project } from '../api'; /** @public */ @@ -46,6 +47,7 @@ export const Router = (props: { columns?: TableColumn<Project>[] }) => { <Routes> <Route path="/" element={<CITable columns={columns} />} /> <Route path={`/${buildRouteRef.path}`} element={<DetailedViewPage />} /> + <Route path={`/${jobRunsRouteRef.path}`} element={<JobRunsTable />} /> </Routes> ); }; diff --git a/plugins/jenkins/src/components/useJobRuns.ts b/plugins/jenkins/src/components/useJobRuns.ts new file mode 100644 index 0000000000..d80e09b18b --- /dev/null +++ b/plugins/jenkins/src/components/useJobRuns.ts @@ -0,0 +1,70 @@ +/* + * Copyright 2020 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 { useState } from 'react'; +import useAsyncRetry from 'react-use/lib/useAsyncRetry'; +import { jenkinsApiRef } from '../api'; +import { errorApiRef, useApi } from '@backstage/core-plugin-api'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { getCompoundEntityRef } from '@backstage/catalog-model'; + +export enum ErrorType { + CONNECTION_ERROR, + NOT_FOUND, +} + +export function useJobRuns(jobFullName: string) { + const { entity } = useEntity(); + const api = useApi(jenkinsApiRef); + const errorApi = useApi(errorApiRef); + + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(5); + + const [error, setError] = useState<{ + message: string; + errorType: ErrorType; + }>(); + + const { loading, value: jobRuns } = useAsyncRetry(async () => { + try { + const jobBuilds = await api.getJobBuilds({ + entity: getCompoundEntityRef(entity), + jobFullName, + }); + return jobBuilds; + } catch (e) { + const errorType = e.notFound + ? ErrorType.NOT_FOUND + : ErrorType.CONNECTION_ERROR; + setError({ message: e.message, errorType }); + throw e; + } + }, [api, errorApi, entity]); + + return [ + { + page, + pageSize, + loading, + jobRuns, + error, + }, + { + setPage, + setPageSize, + }, + ] as const; +} diff --git a/plugins/jenkins/src/index.ts b/plugins/jenkins/src/index.ts index 7562026bdc..9d092014a0 100644 --- a/plugins/jenkins/src/index.ts +++ b/plugins/jenkins/src/index.ts @@ -25,6 +25,7 @@ export { jenkinsPlugin as plugin, EntityJenkinsContent, EntityLatestJenkinsRunCard, + EntityJobRunsTable, } from './plugin'; export { LatestRunCard } from './components/Cards'; export { diff --git a/plugins/jenkins/src/plugin.ts b/plugins/jenkins/src/plugin.ts index 7dbb6bfde1..1b666da9a9 100644 --- a/plugins/jenkins/src/plugin.ts +++ b/plugins/jenkins/src/plugin.ts @@ -38,6 +38,13 @@ export const buildRouteRef = createSubRouteRef({ parent: rootRouteRef, }); +/** @public */ +export const jobRunsRouteRef = createSubRouteRef({ + id: 'jenkins/job/runs', + path: '/builds/:jobFullName/runs', + parent: rootRouteRef, +}); + /** @public */ export const jenkinsPlugin = createPlugin({ id: 'jenkins', @@ -72,3 +79,13 @@ export const EntityLatestJenkinsRunCard = jenkinsPlugin.provide( }, }), ); + +/** @public */ +export const EntityJobRunsTable = jenkinsPlugin.provide( + createComponentExtension({ + name: 'EntityJobRunsTable', + component: { + lazy: () => import('./components/JobRunsTable').then(m => m.JobRunsTable), + }, + }), +); diff --git a/plugins/lighthouse/src/components/AuditList/index.tsx b/plugins/lighthouse/src/components/AuditList/index.tsx index c433bb1900..f030ec4708 100644 --- a/plugins/lighthouse/src/components/AuditList/index.tsx +++ b/plugins/lighthouse/src/components/AuditList/index.tsx @@ -44,7 +44,9 @@ import { useApi } from '@backstage/core-plugin-api'; export const LIMIT = 10; const AuditList = () => { - const [dismissedStored] = useLocalStorage(LIGHTHOUSE_INTRO_LOCAL_STORAGE); + const [dismissedStored] = useLocalStorage<boolean>( + LIGHTHOUSE_INTRO_LOCAL_STORAGE, + ); const [dismissed, setDismissed] = useState(dismissedStored); const query = useQuery(); diff --git a/plugins/org-react/src/components/GroupListPicker/GroupListPicker.tsx b/plugins/org-react/src/components/GroupListPicker/GroupListPicker.tsx index a47c346390..57a93ce0fe 100644 --- a/plugins/org-react/src/components/GroupListPicker/GroupListPicker.tsx +++ b/plugins/org-react/src/components/GroupListPicker/GroupListPicker.tsx @@ -74,7 +74,7 @@ export const GroupListPicker = (props: GroupListPickerProps) => { }, [catalogApi, groupTypes]); const handleChange = useCallback( - (_, v: GroupEntity | null) => { + (_: unknown, v: GroupEntity | null) => { onChange(v ?? undefined); setAnchorEl(null); }, diff --git a/plugins/playlist/package.json b/plugins/playlist/package.json index 5169e4828d..a479c2946d 100644 --- a/plugins/playlist/package.json +++ b/plugins/playlist/package.json @@ -59,6 +59,7 @@ "@backstage/cli": "workspace:^", "@backstage/core-app-api": "workspace:^", "@backstage/dev-utils": "workspace:^", + "@backstage/plugin-search-common": "workspace:^", "@backstage/test-utils": "workspace:^", "@testing-library/dom": "^9.0.0", "@testing-library/jest-dom": "^6.0.0", diff --git a/plugins/playlist/src/components/PlaylistPage/AddEntitiesDrawer.tsx b/plugins/playlist/src/components/PlaylistPage/AddEntitiesDrawer.tsx index d5a52dc83c..529327776e 100644 --- a/plugins/playlist/src/components/PlaylistPage/AddEntitiesDrawer.tsx +++ b/plugins/playlist/src/components/PlaylistPage/AddEntitiesDrawer.tsx @@ -15,6 +15,7 @@ */ import { + CompoundEntityRef, Entity, getCompoundEntityRef, stringifyEntityRef, @@ -22,6 +23,7 @@ import { import { useApi, useRouteRef } from '@backstage/core-plugin-api'; import { CatalogEntityDocument } from '@backstage/plugin-catalog-common'; import { catalogApiRef, entityRouteRef } from '@backstage/plugin-catalog-react'; +import type { SearchDocument } from '@backstage/plugin-search-common'; import { SearchBar, SearchContextProvider, @@ -131,13 +133,13 @@ export const AddEntitiesDrawer = ({ }; const addEntity = useCallback( - entityResult => { + (entityResult: SearchDocument) => { // TODO(kuangp): this parsing of the location is not great. Ideally `CatalogEntityDocument` // contains the `metadata.name` field so we can derive the full ref and we only fall back to // parsing location if it's missing (ie. for older versions) const match = entityResult.location.match(entityLocationRegex); if (match?.groups) { - onAdd(stringifyEntityRef(match?.groups)); + onAdd(stringifyEntityRef(match?.groups as CompoundEntityRef)); } else { // eslint-disable-next-line no-console console.error( diff --git a/plugins/playlist/src/components/PlaylistPage/PlaylistEntitiesTable.tsx b/plugins/playlist/src/components/PlaylistPage/PlaylistEntitiesTable.tsx index c74859ce20..72d50506b0 100644 --- a/plugins/playlist/src/components/PlaylistPage/PlaylistEntitiesTable.tsx +++ b/plugins/playlist/src/components/PlaylistPage/PlaylistEntitiesTable.tsx @@ -72,7 +72,7 @@ export const PlaylistEntitiesTable = ({ ); const removeEntity = useCallback( - async (_, entity: Entity | Entity[]) => { + async (_: unknown, entity: Entity | Entity[]) => { try { const entityArray = [entity].flat(); const entityNames = entityArray.map( diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx index ec95bad0e5..d8299a9b5f 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx +++ b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx @@ -29,7 +29,7 @@ import { useRouteRefParams, useApi, } from '@backstage/core-plugin-api'; -import { FormProps, IChangeEvent, withTheme } from '@rjsf/core'; +import { FormProps, IChangeEvent, ISubmitEvent, withTheme } from '@rjsf/core'; import { Theme as MuiTheme } from '@rjsf/material-ui'; import React, { ComponentType, useState } from 'react'; import { transformSchemaToProps } from './schema'; @@ -185,7 +185,7 @@ export const MultistepJsonForm = (props: MultistepJsonFormProps) => { formData={formData} formContext={{ formData }} onChange={onChange} - onSubmit={e => { + onSubmit={(e: ISubmitEvent<any>) => { if (e.errors.length === 0) handleNext(); }} {...formProps} diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/CustomFieldExplorer.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/CustomFieldExplorer.tsx index 43b3b49bcf..e470267b23 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/CustomFieldExplorer.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/CustomFieldExplorer.tsx @@ -28,7 +28,7 @@ import { Select, } from '@material-ui/core'; import CloseIcon from '@material-ui/icons/Close'; -import { withTheme } from '@rjsf/core'; +import { ISubmitEvent, withTheme } from '@rjsf/core'; import { Theme as MuiTheme } from '@rjsf/material-ui'; import CodeMirror from '@uiw/react-codemirror'; import React, { useCallback, useMemo, useState } from 'react'; @@ -104,7 +104,7 @@ export const CustomFieldExplorer = ({ }, [customFieldExtensions]); const handleSelectionChange = useCallback( - selection => { + (selection: FieldExtensionOptions) => { setSelectedField(selection); setFieldFormState({}); setFormState({}); @@ -113,7 +113,7 @@ export const CustomFieldExplorer = ({ ); const handleFieldConfigChange = useCallback( - state => { + (state: {}) => { setFieldFormState(state); setFormState({}); // Force TemplateEditorForm to re-render since some fields @@ -134,7 +134,9 @@ export const CustomFieldExplorer = ({ value={selectedField} label="Choose Custom Field Extension" labelId="select-field-label" - onChange={e => handleSelectionChange(e.target.value)} + onChange={e => + handleSelectionChange(e.target.value as FieldExtensionOptions) + } > {fieldOptions.map((option, idx) => ( <MenuItem key={idx} value={option as any}> @@ -158,7 +160,9 @@ export const CustomFieldExplorer = ({ noHtml5Validate formData={fieldFormState} formContext={{ fieldFormState }} - onSubmit={e => handleFieldConfigChange(e.formData)} + onSubmit={(e: ISubmitEvent<any>) => + handleFieldConfigChange(e.formData) + } schema={selectedField.schema?.uiOptions || {}} > <Button diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormPreviewer.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormPreviewer.tsx index 69433d5f51..9442394177 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormPreviewer.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormPreviewer.tsx @@ -164,7 +164,8 @@ export const TemplateFormPreviewer = ({ ); const handleSelectChange = useCallback( - selected => { + // TODO(Rugvip): Afaik this should be Entity, but didn't want to make runtime changes while fixing types + (selected: any) => { setSelectedTemplate(selected); setTemplateYaml(yaml.stringify(selected.spec)); }, diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/CustomFieldExplorer.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/CustomFieldExplorer.tsx index bf5ffe97ea..15298dff8a 100644 --- a/plugins/scaffolder/src/next/TemplateEditorPage/CustomFieldExplorer.tsx +++ b/plugins/scaffolder/src/next/TemplateEditorPage/CustomFieldExplorer.tsx @@ -102,7 +102,7 @@ export const CustomFieldExplorer = ({ }, [customFieldExtensions]); const handleSelectionChange = useCallback( - selection => { + (selection: NextFieldExtensionOptions) => { setSelectedField(selection); setFieldFormState({}); }, @@ -110,7 +110,7 @@ export const CustomFieldExplorer = ({ ); const handleFieldConfigChange = useCallback( - state => { + (state: {}) => { setFieldFormState(state); // Force TemplateEditorForm to re-render since some fields // may not be responsive to ui:option changes @@ -130,7 +130,9 @@ export const CustomFieldExplorer = ({ value={selectedField} label="Choose Custom Field Extension" labelId="select-field-label" - onChange={e => handleSelectionChange(e.target.value)} + onChange={e => + handleSelectionChange(e.target.value as NextFieldExtensionOptions) + } > {fieldOptions.map((option, idx) => ( <MenuItem key={idx} value={option as any}> diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateFormPreviewer.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateFormPreviewer.tsx index 2aa1cc764d..75d8c511b1 100644 --- a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateFormPreviewer.tsx +++ b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateFormPreviewer.tsx @@ -161,7 +161,8 @@ export const TemplateFormPreviewer = ({ ); const handleSelectChange = useCallback( - selected => { + // TODO(Rugvip): Afaik this should be Entity, but didn't want to make runtime changes while fixing types + (selected: any) => { setSelectedTemplate(selected); setTemplateYaml(yaml.stringify(selected.spec)); }, diff --git a/plugins/search-react/src/components/SearchFilter/SearchFilter.Autocomplete.test.tsx b/plugins/search-react/src/components/SearchFilter/SearchFilter.Autocomplete.test.tsx index 765d2a26d1..e95f7fa856 100644 --- a/plugins/search-react/src/components/SearchFilter/SearchFilter.Autocomplete.test.tsx +++ b/plugins/search-react/src/components/SearchFilter/SearchFilter.Autocomplete.test.tsx @@ -29,7 +29,7 @@ const SearchContextFilterSpy = ({ name }: { name: string }) => { const value = filters[name]; return ( <span data-testid={`${name}-filter-spy`}> - {Array.isArray(value) ? value.join(',') : value} + {Array.isArray(value) ? value.join(',') : value?.toString()} </span> ); }; diff --git a/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.tsx b/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.tsx index 2a3ae1e24c..ea1817193c 100644 --- a/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.tsx +++ b/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.tsx @@ -193,7 +193,7 @@ export const SearchResultGroupTextFilterField = ( contentEditable suppressContentEditableWarning > - {value} + {value?.toString()} </Typography> </SearchResultGroupFilterFieldLayout> ); @@ -377,7 +377,7 @@ export function SearchResultGroupLayout<FilterOption>( filterOptions, renderFilterOption = filterOption => ( <MenuItem key={String(filterOption)} value={String(filterOption)}> - {filterOption} + {String(filterOption)} </MenuItem> ), filterFields, diff --git a/plugins/search/src/alpha.tsx b/plugins/search/src/alpha.tsx index 6d74c85d49..f1c4f68d02 100644 --- a/plugins/search/src/alpha.tsx +++ b/plugins/search/src/alpha.tsx @@ -198,22 +198,24 @@ export const SearchPage = createPageExtension({ <Grid item xs> <SearchPagination /> <SearchResults> - {({ results }) => - results.map((result, index) => { - const { noTrack } = config; - const { document, ...rest } = result; - const SearchResultListItem = - getResultItemComponent(result); - return ( - <SearchResultListItem - {...rest} - key={index} - result={document} - noTrack={noTrack} - /> - ); - }) - } + {({ results }) => ( + <> + {results.map((result, index) => { + const { noTrack } = config; + const { document, ...rest } = result; + const SearchResultListItem = + getResultItemComponent(result); + return ( + <SearchResultListItem + {...rest} + key={index} + result={document} + noTrack={noTrack} + /> + ); + })} + </> + )} </SearchResults> <SearchResultPager /> </Grid> diff --git a/plugins/search/src/components/HomePageComponent/HomePageSearchBar.tsx b/plugins/search/src/components/HomePageComponent/HomePageSearchBar.tsx index 47ee15c7f3..19dfd8190d 100644 --- a/plugins/search/src/components/HomePageComponent/HomePageSearchBar.tsx +++ b/plugins/search/src/components/HomePageComponent/HomePageSearchBar.tsx @@ -57,18 +57,11 @@ export const HomePageSearchBar = (props: HomePageSearchBarProps) => { handleSearch({ query: ref.current?.value ?? '' }); }, [handleSearch]); - const handleChange = useCallback( - value => { - setQuery(value); - }, - [setQuery], - ); - return ( <SearchBarBase value={query} onSubmit={handleSubmit} - onChange={handleChange} + onChange={setQuery} inputProps={{ ref }} InputProps={{ ...props.InputProps, diff --git a/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.tsx b/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.tsx index 60ee6b7dbf..8f336c629b 100644 --- a/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.tsx +++ b/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.tsx @@ -74,12 +74,15 @@ const SentryIssuesTable = (props: SentryIssuesTableProps) => { const { sentryIssues, statsFor, tableOptions } = props; const [selected, setSelected] = useState(ONE_DAY_IN_MILLIS); - const filterByDate = useCallback((issue, selectedFilter) => { - return ( - DateTime.fromISO(issue.lastSeen) > - DateTime.now().minus(Duration.fromMillis(selectedFilter)) - ); - }, []); + const filterByDate = useCallback( + (issue: SentryIssue, selectedFilter: number) => { + return ( + DateTime.fromISO(issue.lastSeen) > + DateTime.now().minus(Duration.fromMillis(selectedFilter)) + ); + }, + [], + ); const [filteredIssues, setFilteredIssues] = useState( sentryIssues.filter(i => filterByDate(i, selected)), ); diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index a0fde4fd56..578535c351 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -74,8 +74,8 @@ "react-use": "^17.2.4" }, "peerDependencies": { - "react": "^16.13.1 || ^17.0.0", - "react-dom": "^16.13.1 || ^17.0.0", + "react": "^16.13.1 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0", "react-router-dom": "6.0.0-beta.0 || ^6.3.0" }, "devDependencies": { diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx index f7b4f2eff2..d84c5c5347 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx @@ -133,7 +133,9 @@ export const TechDocsReaderPageHeader = ( } /> )} - {lifecycle ? <HeaderLabel label="Lifecycle" value={lifecycle} /> : null} + {lifecycle ? ( + <HeaderLabel label="Lifecycle" value={String(lifecycle)} /> + ) : null} {locationMetadata && locationMetadata.type !== 'dir' && locationMetadata.type !== 'file' ? ( diff --git a/plugins/techdocs/src/reader/transformers/addGitFeedbackLink.ts b/plugins/techdocs/src/reader/transformers/addGitFeedbackLink.ts index 9239573bd1..58876cd806 100644 --- a/plugins/techdocs/src/reader/transformers/addGitFeedbackLink.ts +++ b/plugins/techdocs/src/reader/transformers/addGitFeedbackLink.ts @@ -21,8 +21,8 @@ import { } from '@backstage/integration'; import FeedbackOutlinedIcon from '@material-ui/icons/FeedbackOutlined'; import React from 'react'; -import ReactDOM from 'react-dom'; import parseGitUrl from 'git-url-parse'; +import { renderReactElement } from './renderReactElement'; // requires repo export const addGitFeedbackLink = ( @@ -75,7 +75,7 @@ export const addGitFeedbackLink = ( default: return dom; } - ReactDOM.render(React.createElement(FeedbackOutlinedIcon), feedbackLink); + renderReactElement(React.createElement(FeedbackOutlinedIcon), feedbackLink); feedbackLink.style.paddingLeft = '5px'; feedbackLink.title = 'Leave feedback for this page'; feedbackLink.id = 'git-feedback-link'; diff --git a/plugins/techdocs/src/reader/transformers/addSidebarToggle.ts b/plugins/techdocs/src/reader/transformers/addSidebarToggle.ts index 789c9179d5..352e396abf 100644 --- a/plugins/techdocs/src/reader/transformers/addSidebarToggle.ts +++ b/plugins/techdocs/src/reader/transformers/addSidebarToggle.ts @@ -17,7 +17,7 @@ import type { Transformer } from './transformer'; import MenuIcon from '@material-ui/icons/Menu'; import React from 'react'; -import ReactDOM from 'react-dom'; +import { renderReactElement } from './renderReactElement'; export const addSidebarToggle = (): Transformer => { return dom => { @@ -33,7 +33,7 @@ export const addSidebarToggle = (): Transformer => { } const toggleSidebar = mkdocsToggleSidebar.cloneNode() as HTMLLabelElement; - ReactDOM.render(React.createElement(MenuIcon), toggleSidebar); + renderReactElement(React.createElement(MenuIcon), toggleSidebar); toggleSidebar.id = 'toggle-sidebar'; toggleSidebar.title = 'Toggle Sidebar'; toggleSidebar.classList.add('md-content__button'); diff --git a/plugins/techdocs/src/reader/transformers/copyToClipboard.test.ts b/plugins/techdocs/src/reader/transformers/copyToClipboard.test.ts index cac7f76a0e..604957d5cd 100644 --- a/plugins/techdocs/src/reader/transformers/copyToClipboard.test.ts +++ b/plugins/techdocs/src/reader/transformers/copyToClipboard.test.ts @@ -17,7 +17,7 @@ import { createTestShadowDom } from '../../test-utils'; import { copyToClipboard } from './copyToClipboard'; import { lightTheme } from '@backstage/theme'; -import { waitFor } from '@testing-library/react'; +import { act, waitFor } from '@testing-library/react'; import useCopyToClipboard from 'react-use/lib/useCopyToClipboard'; const clipboardSpy = jest.fn(); @@ -43,8 +43,11 @@ describe('copyToClipboard', () => { spy.mockReturnValue([{}, copy]); const expectedClipboard = 'function foo() {return "bar";}'; - const shadowDom = await createTestShadowDom( - ` + + let shadowDom: ShadowRoot; + await act(async () => { + shadowDom = await createTestShadowDom( + ` <!DOCTYPE html> <html> <body> @@ -52,13 +55,20 @@ describe('copyToClipboard', () => { </body> </html> `, - { - preTransformers: [], - postTransformers: [copyToClipboard(lightTheme)], - }, - ); + { + preTransformers: [], + postTransformers: [copyToClipboard(lightTheme)], + }, + ); + }); - shadowDom.querySelector('button')?.click(); + await waitFor(() => { + expect(shadowDom.querySelector('button')).not.toBe(null); + }); + + await act(async () => { + shadowDom.querySelector('button')!.click(); + }); await waitFor(() => { const tooltip = document.querySelector('[role="tooltip"]'); diff --git a/plugins/techdocs/src/reader/transformers/copyToClipboard.tsx b/plugins/techdocs/src/reader/transformers/copyToClipboard.tsx index 28a6c789b6..1fd9d179a3 100644 --- a/plugins/techdocs/src/reader/transformers/copyToClipboard.tsx +++ b/plugins/techdocs/src/reader/transformers/copyToClipboard.tsx @@ -15,7 +15,7 @@ */ import React, { useState, useCallback } from 'react'; -import ReactDom from 'react-dom'; +import { renderReactElement } from './renderReactElement'; import { withStyles, Theme, @@ -91,7 +91,7 @@ export const copyToClipboard = (theme: Theme): Transformer => { const text = code.textContent || ''; const container = document.createElement('div'); code?.parentElement?.prepend(container); - ReactDom.render( + renderReactElement( <ThemeProvider theme={theme}> <CopyToClipboardButton text={text} /> </ThemeProvider>, diff --git a/plugins/techdocs/src/reader/transformers/renderReactElement.ts b/plugins/techdocs/src/reader/transformers/renderReactElement.ts new file mode 100644 index 0000000000..86d44532c5 --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/renderReactElement.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2023 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. + */ + +let ReactDOM: + | typeof import('react-dom') + // TODO: replace with import('react-dom/client') when repo is migrated to 18 + | { createRoot(el: HTMLElement): { render(el: JSX.Element): void } }; +if (process.env.HAS_REACT_DOM_CLIENT) { + ReactDOM = require('react-dom/client'); +} else { + ReactDOM = require('react-dom'); +} + +/** @internal */ +export function renderReactElement(element: JSX.Element, root: HTMLElement) { + if ('createRoot' in ReactDOM) { + ReactDOM.createRoot(root).render(element); + } else { + ReactDOM.render(element, root); + } +} diff --git a/yarn.lock b/yarn.lock index fd3b9da734..22143e37de 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3784,7 +3784,6 @@ __metadata: "@types/inquirer": ^8.1.3 "@types/jest": ^29.0.0 "@types/minimatch": ^5.0.0 - "@types/mock-fs": ^4.13.0 "@types/node": ^18.17.8 "@types/npm-packlist": ^3.0.0 "@types/recursive-readdir": ^2.2.0 @@ -3839,7 +3838,6 @@ __metadata: lodash: ^4.17.21 mini-css-extract-plugin: ^2.4.2 minimatch: ^5.1.1 - mock-fs: ^5.2.0 msw: ^1.0.0 node-fetch: ^2.6.7 node-libs-browser: ^2.2.1 @@ -4267,8 +4265,8 @@ __metadata: react-use: ^17.2.4 zen-observable: ^0.10.0 peerDependencies: - react: ^16.13.1 || ^17.0.0 - react-dom: ^16.13.1 || ^17.0.0 + react: ^16.13.1 || ^17.0.0 || ^18.0.0 + react-dom: ^16.13.1 || ^17.0.0 || ^18.0.0 react-router-dom: 6.0.0-beta.0 || ^6.3.0 languageName: unknown linkType: soft @@ -4972,6 +4970,30 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-auth-backend-module-pinniped-provider@workspace:plugins/auth-backend-module-pinniped-provider": + version: 0.0.0-use.local + resolution: "@backstage/plugin-auth-backend-module-pinniped-provider@workspace:plugins/auth-backend-module-pinniped-provider" + dependencies: + "@backstage/backend-common": "workspace:^" + "@backstage/backend-defaults": "workspace:^" + "@backstage/backend-plugin-api": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/config": "workspace:^" + "@backstage/plugin-auth-backend": "workspace:^" + "@backstage/plugin-auth-node": "workspace:^" + cookie-parser: ^1.4.6 + express: ^4.18.2 + express-promise-router: ^4.1.1 + express-session: ^1.17.3 + jose: ^4.14.6 + msw: ^1.3.0 + openid-client: ^5.4.3 + passport: ^0.6.0 + supertest: ^6.3.3 + languageName: unknown + linkType: soft + "@backstage/plugin-auth-backend@workspace:^, @backstage/plugin-auth-backend@workspace:plugins/auth-backend": version: 0.0.0-use.local resolution: "@backstage/plugin-auth-backend@workspace:plugins/auth-backend" @@ -8516,6 +8538,7 @@ __metadata: "@backstage/plugin-permission-common": "workspace:^" "@backstage/plugin-permission-react": "workspace:^" "@backstage/plugin-playlist-common": "workspace:^" + "@backstage/plugin-search-common": "workspace:^" "@backstage/plugin-search-react": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/theme": "workspace:^" @@ -9876,8 +9899,8 @@ __metadata: react-helmet: 6.1.0 react-use: ^17.2.4 peerDependencies: - react: ^16.13.1 || ^17.0.0 - react-dom: ^16.13.1 || ^17.0.0 + react: ^16.13.1 || ^17.0.0 || ^18.0.0 + react-dom: ^16.13.1 || ^17.0.0 || ^18.0.0 react-router-dom: 6.0.0-beta.0 || ^6.3.0 languageName: unknown linkType: soft @@ -17609,9 +17632,9 @@ __metadata: linkType: hard "@types/diff@npm:^5.0.0": - version: 5.0.5 - resolution: "@types/diff@npm:5.0.5" - checksum: 5f093341d5488cd33079400d4b2ff463dfd4f589bf0a54fbb25f7c87a7bdc16bd1e6125f252c081fa9a3ad44fc78b156c0a5f38bd8128edceb40ba084e051885 + version: 5.0.6 + resolution: "@types/diff@npm:5.0.6" + checksum: 8ce79492a89d568b54792a8491d01b1ff69526e19560670ef2de078c467738cdc0aae81fc4f895e49f915a2b9bbf694f6f95a2e5544e7c0989448956eba9ff66 languageName: node linkType: hard @@ -26018,7 +26041,7 @@ __metadata: languageName: node linkType: hard -"express-session@npm:^1.17.1": +"express-session@npm:^1.17.1, express-session@npm:^1.17.3": version: 1.17.3 resolution: "express-session@npm:1.17.3" dependencies: @@ -30222,7 +30245,7 @@ __metadata: languageName: node linkType: hard -"jose@npm:^4.15.1, jose@npm:^4.6.0": +"jose@npm:^4.14.6, jose@npm:^4.15.1, jose@npm:^4.6.0": version: 4.15.3 resolution: "jose@npm:4.15.3" checksum: b76eeccc1d40d0eaf26dfaadc0f88fc15802c9105ab66a24ee223bd84369f7cb217f4a2cb852f5080ff6996170b3a73db2b2d26878b8905d99c36ca432628134 @@ -33349,7 +33372,7 @@ __metadata: languageName: node linkType: hard -"msw@npm:^1.0.0, msw@npm:^1.0.1, msw@npm:^1.2.1, msw@npm:^1.2.3, msw@npm:^1.3.1": +"msw@npm:^1.0.0, msw@npm:^1.0.1, msw@npm:^1.2.1, msw@npm:^1.2.3, msw@npm:^1.3.0, msw@npm:^1.3.1": version: 1.3.2 resolution: "msw@npm:1.3.2" dependencies: @@ -34396,7 +34419,7 @@ __metadata: languageName: node linkType: hard -"openid-client@npm:^5.2.1, openid-client@npm:^5.3.0": +"openid-client@npm:^5.2.1, openid-client@npm:^5.3.0, openid-client@npm:^5.4.3": version: 5.6.1 resolution: "openid-client@npm:5.6.1" dependencies: