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
###
-. Contributed by [@](https://github.com/) [#](https://github.com/backstage/backstage/pull/)
+. Contributed by [@](https://github.com/) in [#](https://github.com/backstage/backstage/pull/)
## 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(),
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(),
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(),
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 }>) => (
, 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 }>) => (
, 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
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
* {@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(props: TableProps) {
const hasFilters = !!filters?.length;
const Toolbar = useCallback(
- toolbarProps => {
+ (toolbarProps: any /* no type for this in material-table */) => {
return (
(props: TableProps) {
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 (
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((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((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)}
{
export const SampleSidebar = () => (
-
+ }>
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) {
} 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;
+}) => (
{children}
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 }) => (
} />);
@@ -235,7 +245,11 @@ export class DevAppBuilder {
window.location.pathname = this.defaultPage;
}
- ReactDOM.render( , document.getElementById('root'));
+ if ('createRoot' in ReactDOM) {
+ ReactDOM.createRoot(document.getElementById('root')!).render( );
+ } else {
+ ReactDOM.render( , 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;
+ 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 => {
+ 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 = {
+ 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;
}
+
+// @public
+export type EksClusterEntityTransformer = (
+ cluster: Cluster,
+ accountId: string,
+) => Promise;
```
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;
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 = ['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 (
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 (
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 = [
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();
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}
)}
- {entity.spec?.type}
+ {entity.spec?.type?.toString()}
{(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 }) {
{spec?.type && (
-
+
)}
{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;
+ 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;
+ 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;
+ 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;
+ 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 && (
-
+
)}
>
);
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 (
- No code coverage found for ${entity}
+
+ No code coverage found for {humanizeEntityRef(entity)}
+
);
}
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 => );
+const tooltipItems = items.map(item => (
+
+));
describe(' ', () => {
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(' ', () => {
- let Component: React.ReactNode;
+ let Component: ComponentType;
beforeEach(() => {
Component = () => (
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 ? (
-
+
+
+
) : (
-
+
+
+
)}
{
onClick={() => applyRating(FeedbackRatings.dislike)}
>
{rating === FeedbackRatings.dislike ? (
-
+
+
+
) : (
-
+
+
+
)}
{
+ 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/)
+
+
## 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[] | 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;
+ // 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;
getProjects(options: {
entity: CompoundEntityRef;
filter: {
@@ -82,6 +92,11 @@ export class JenkinsClient implements JenkinsApi {
buildNumber: string;
}): Promise;
// (undocumented)
+ getJobBuilds(options: {
+ entity: CompoundEntityRef;
+ jobFullName: string;
+ }): Promise;
+ // (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;
+ getJobBuilds(options: {
+ entity: CompoundEntityRef;
+ jobFullName: string;
+ }): Promise;
+
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 {
+ 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 {
+ return {
+ title: 'Last Run Duration',
+ align: 'left',
+ render: (row: Partial) => (
+ <>
+
+ {row?.lastBuild?.duration
+ ? (row?.lastBuild?.duration / 1000)
+ .toFixed(1)
+ .toString()
+ .concat(' s')
+ : ''}{' '}
+
+ >
+ ),
+ };
+ },
+
createActionsColumn(): TableColumn {
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 (
-
+
{row.lastBuild?.url && (
@@ -240,6 +261,17 @@ export const columnFactories = Object.freeze({
)}
+
+
+
+
+
+
+
);
};
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
[] = [
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) => {
+ return (
+
+
+ {row.number}
+
+
+ );
+ },
+ },
+ {
+ title: 'Timestamp',
+ field: 'timestamp',
+ render: (row: Partial) => {
+ return (
+
+
+ {row?.timestamp ? new Date(row?.timestamp).toLocaleString() : ' '}
+
+
+ );
+ },
+ },
+ {
+ title: 'Result',
+ field: 'result',
+ render: (row: Partial) => {
+ return (
+
+ {row.inProgress ? (
+ In Progress
+ ) : (
+
+ )}
+
+ );
+ },
+ },
+ {
+ title: 'Duration',
+ field: 'duration',
+ render: (row: Partial) => {
+ return (
+
+
+ {row?.duration
+ ? (row.duration / 1000).toFixed(1).toString().concat(' s')
+ : ''}
+
+
+ );
+ },
+ },
+
+ {
+ title: 'Actions',
+ render: (row: Partial) => {
+ const ActionWrapper = () => {
+ return (
+
+ {row?.url && (
+
+
+
+
+
+ )}
+
+ );
+ };
+ return ;
+ },
+ 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 (
+
+
+
+
+ {`${jobRuns?.displayName} Runs`}
+
+
+
+ Average Build Time For Last {successfulJobCount} Successful jobs :{' '}
+ {avgTime || 0}
+
+
+
+ }
+ columns={generatedColumns}
+ />
+ );
+};
+
+export const JobRunsTable = () => {
+ const { jobFullName } = useRouteRefParams(jobRunsRouteRef);
+ const [tableProps, { setPage, setPageSize }] = useJobRuns(jobFullName);
+
+ return (
+
+ );
+};
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[] }) => {
} />
} />
+ } />
);
};
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(
+ 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) => {
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) => (
@@ -158,7 +160,9 @@ export const CustomFieldExplorer = ({
noHtml5Validate
formData={fieldFormState}
formContext={{ fieldFormState }}
- onSubmit={e => handleFieldConfigChange(e.formData)}
+ onSubmit={(e: ISubmitEvent) =>
+ handleFieldConfigChange(e.formData)
+ }
schema={selectedField.schema?.uiOptions || {}}
>
{
+ // 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) => (
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 (
- {Array.isArray(value) ? value.join(',') : value}
+ {Array.isArray(value) ? value.join(',') : value?.toString()}
);
};
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()}
);
@@ -377,7 +377,7 @@ export function SearchResultGroupLayout(
filterOptions,
renderFilterOption = filterOption => (
- {filterOption}
+ {String(filterOption)}
),
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({
- {({ results }) =>
- results.map((result, index) => {
- const { noTrack } = config;
- const { document, ...rest } = result;
- const SearchResultListItem =
- getResultItemComponent(result);
- return (
-
- );
- })
- }
+ {({ results }) => (
+ <>
+ {results.map((result, index) => {
+ const { noTrack } = config;
+ const { document, ...rest } = result;
+ const SearchResultListItem =
+ getResultItemComponent(result);
+ return (
+
+ );
+ })}
+ >
+ )}
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 (
{
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 ? : null}
+ {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(
+ `
@@ -52,13 +55,20 @@ describe('copyToClipboard', () => {
`,
- {
- 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(
,
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: