Merge branch 'backstage:master' into master2

This commit is contained in:
Abhinv Singh Parmar
2023-10-14 14:34:09 +05:30
committed by GitHub
101 changed files with 2153 additions and 643 deletions
+5
View File
@@ -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`.
+6
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-code-coverage': patch
---
The warning for missing code coverage will now render the entity as a reference.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': patch
---
Fixed the type declaration of `DependencyGraphProps`, the `defs` prop now expects `JSX.Element`s.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
The scaffolder-module template now recommends usage of `createMockDirectory` instead of `mock-fs`.
+6
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': patch
---
The `spec.type` field in entities will now always be rendered as a string.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-import': patch
---
The `app.title` configuration is now properly required to be a string.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search-react': patch
---
The filter options passed to `SearchResultGroupLayout` are now always explicitly rendered as strings by default.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search': patch
---
Minor internal code cleanup.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-node': patch
---
Adding optional audience parameter to OAuthState type declaration
+2
View File
@@ -70,6 +70,8 @@ yarn.lock @backstage/maintainers @backst
/plugins/playlist-* @backstage/maintainers @backstage/reviewers @kuangp
/plugins/rollbar @backstage/maintainers @backstage/reviewers @andrewthauer
/plugins/rollbar-backend @backstage/maintainers @backstage/reviewers @andrewthauer
/plugins/scaffolder @backstage/maintainers @backstage/reviewers @backstage/scaffolder-maintainers
/plugins/scaffolder-* @backstage/maintainers @backstage/reviewers @backstage/scaffolder-maintainers
/plugins/search @backstage/discoverability-maintainers
/plugins/search-* @backstage/discoverability-maintainers
/plugins/sonarqube @backstage/maintainers @backstage/reviewers @backstage/sda-se-reviewers
+7
View File
@@ -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',
-2
View File
@@ -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",
@@ -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);
+267 -182
View File
@@ -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',
]);
+40 -22
View File
@@ -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,
+12
View File
@@ -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()),
}),
);
@@ -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,
});
});
@@ -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,
});
});
@@ -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}}!');
});
});
@@ -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());
}
@@ -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,
});
});
@@ -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,
});
});
@@ -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,
});
});
@@ -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,
});
});
@@ -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,
});
});
@@ -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,
});
});
@@ -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,
});
});
+12 -6
View File
@@ -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',
+7 -10
View File
@@ -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');
});
});
+5 -6
View File
@@ -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');
@@ -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,
);
});
@@ -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'),
},
],
],
@@ -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');
},
});
@@ -31,9 +31,8 @@ jest.mock('react-router-dom', () =>
jest.requireActual('react-router-dom-beta'),
);
const element = () => null;
const rest = {
element,
element: null,
caseSensitive: false,
children: [MATCH_ALL_ROUTE],
plugins: new Set<BackstagePlugin>(),
@@ -25,9 +25,8 @@ import {
} from '@backstage/core-plugin-api';
import { MATCH_ALL_ROUTE } from './collectors';
const element = () => null;
const rest = {
element,
element: null,
caseSensitive: false,
children: [MATCH_ALL_ROUTE],
plugins: new Set<BackstagePlugin>(),
@@ -31,9 +31,8 @@ jest.mock('react-router-dom', () =>
jest.requireActual('react-router-dom-stable'),
);
const element = () => null;
const rest = {
element,
element: null,
caseSensitive: false,
children: [MATCH_ALL_ROUTE],
plugins: new Set<BackstagePlugin>(),
@@ -353,7 +353,9 @@ describe('v1 consumer', () => {
initialProps: {
routeRef: routeRef1 as AnyRouteRef,
},
wrapper: ({ children }: React.PropsWithChildren<{}>) => (
wrapper: ({
children,
}: React.PropsWithChildren<{ routeRef: AnyRouteRef }>) => (
<RoutingProvider
routePaths={
new Map<RouteRef<any>, string>([
@@ -385,7 +385,9 @@ describe('v1 consumer', () => {
initialProps: {
routeRef: routeRef1 as AnyRouteRef,
},
wrapper: ({ children }: React.PropsWithChildren<{}>) => (
wrapper: ({
children,
}: React.PropsWithChildren<{ routeRef: AnyRouteRef }>) => (
<RoutingProvider
routePaths={
new Map<RouteRef<any>, string>([
+1 -1
View File
@@ -249,7 +249,7 @@ export interface DependencyGraphProps<NodeData, EdgeData>
acyclicer?: 'greedy';
align?: DependencyGraphTypes.Alignment;
curve?: 'curveStepBefore' | 'curveMonotoneX';
defs?: SVGDefsElement | SVGDefsElement[];
defs?: JSX.Element | JSX.Element[];
direction?: DependencyGraphTypes.Direction;
edgeMargin?: number;
edgeRanks?: number;
@@ -144,7 +144,7 @@ export interface DependencyGraphProps<NodeData, EdgeData>
* {@link https://developer.mozilla.org/en-US/docs/Web/SVG/Element/defs | Defs} shared by rendered SVG to be used by
* {@link DependencyGraphProps.renderNode} and/or {@link DependencyGraphProps.renderLabel}
*/
defs?: SVGDefsElement | SVGDefsElement[];
defs?: JSX.Element | JSX.Element[];
/**
* Controls zoom behavior of graph
*
@@ -455,7 +455,7 @@ export function Table<T extends object = {}>(props: TableProps<T>) {
const hasFilters = !!filters?.length;
const Toolbar = useCallback(
toolbarProps => {
(toolbarProps: any /* no type for this in material-table */) => {
return (
<TableToolbar
setSearch={setSearch}
@@ -472,7 +472,7 @@ export function Table<T extends object = {}>(props: TableProps<T>) {
const hasNoRows = typeof data !== 'function' && data.length === 0;
const columnCount = columns.length;
const Body = useCallback(
bodyProps => {
(bodyProps: any /* no type for this in material-table */) => {
if (isLoading) {
return (
<tbody data-testid="loading-indicator">
@@ -312,7 +312,11 @@ const sidebarSubmenuType = React.createElement(SidebarSubmenu).type;
// properly yet, matching for example /foobar with /foo.
export const WorkaroundNavLink = React.forwardRef<
HTMLAnchorElement,
NavLinkProps & { activeStyle?: CSSProperties; activeClassName?: string }
NavLinkProps & {
children?: ReactNode;
activeStyle?: CSSProperties;
activeClassName?: string;
}
>(function WorkaroundNavLinkWithRef(
{
to,
@@ -361,7 +365,10 @@ export const WorkaroundNavLink = React.forwardRef<
/**
* Common component used by SidebarItem & SidebarItemWithSubmenu
*/
const SidebarItemBase = forwardRef<any, SidebarItemProps>((props, ref) => {
const SidebarItemBase = forwardRef<
any,
SidebarItemProps & { children: ReactNode }
>((props, ref) => {
const {
icon: Icon,
text,
@@ -553,7 +560,10 @@ const SidebarItemWithSubmenu = ({
* @remarks
* If children contain a `SidebarSubmenu` component the `SidebarItem` will have a expandable submenu
*/
export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
export const SidebarItem = forwardRef<
any,
SidebarItemProps & { children: ReactNode }
>((props, ref) => {
// Filter children for SidebarSubmenu components
const [submenu] = useElementFilter(props.children, elements =>
// Directly comparing child.type with SidebarSubmenu will not work with in
@@ -25,7 +25,7 @@ import Typography from '@material-ui/core/Typography';
import CloseIcon from '@material-ui/icons/Close';
import MenuIcon from '@material-ui/icons/Menu';
import { orderBy } from 'lodash';
import React, { useEffect, useState, useContext } from 'react';
import React, { useEffect, useState, useContext, ReactNode } from 'react';
import { useLocation } from 'react-router-dom';
import { SidebarOpenStateProvider } from './SidebarOpenStateContext';
import { SidebarGroup } from './SidebarGroup';
@@ -206,8 +206,7 @@ export const MobileSidebar = (props: MobileSidebarProps) => {
onClose={() => setSelectedMenuItemIndex(-1)}
>
{sidebarGroups[selectedMenuItemIndex] &&
(sidebarGroups[selectedMenuItemIndex].props
.children as React.ReactChildren)}
(sidebarGroups[selectedMenuItemIndex].props.children as ReactNode)}
</OverlayMenu>
<BottomNavigation
className={classes.root}
@@ -59,7 +59,7 @@ const handleSearch = (input: string) => {
export const SampleSidebar = () => (
<SidebarPage>
<Sidebar>
<SidebarGroup label="Menu" icon={MenuIcon}>
<SidebarGroup label="Menu" icon={<MenuIcon />}>
<SidebarSearchField onSearch={handleSearch} to="/search" />
<SidebarDivider />
<SidebarItem icon={HomeOutlinedIcon} to="#" text="Plugins" />
@@ -96,7 +96,9 @@ export function TabbedCard(props: PropsWithChildren<Props>) {
} else {
React.Children.map(children, child => {
if (
React.isValidElement<{ children?: unknown; value?: unknown }>(child) &&
React.isValidElement<{ children?: ReactNode; value?: unknown }>(
child,
) &&
child?.props.value === value
) {
selectedTabContent = child?.props.children;
@@ -41,7 +41,12 @@ const FeatureFlagComponent = (_props: {
}) => null;
attachComponentData(FeatureFlagComponent, 'core.featureFlagged', true);
const mockFeatureFlagsApi = new LocalStorageFeatureFlags();
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
const Wrapper = ({
children,
}: {
children?: React.ReactNode;
tree?: ReactNode;
}) => (
<TestApiProvider apis={[[featureFlagsApiRef, mockFeatureFlagsApi]]}>
{children}
</TestApiProvider>
@@ -303,7 +303,12 @@ describe('useTranslationRef', () => {
const translationApi = I18nextTranslationApi.create({ languageApi });
const { result, rerender } = renderHook(
({ translationRef }) => useTranslationRef(translationRef),
({
translationRef,
}: {
translationRef: TranslationRef;
children?: ReactNode;
}) => useTranslationRef(translationRef),
{
wrapper: ({ children }) => (
<TestApiProvider
+2 -2
View File
@@ -47,8 +47,8 @@
"react-use": "^17.2.4"
},
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0",
"react-dom": "^16.13.1 || ^17.0.0",
"react": "^16.13.1 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0",
"react-router-dom": "6.0.0-beta.0 || ^6.3.0"
},
"devDependencies": {
+16 -2
View File
@@ -45,9 +45,19 @@ import {
import { Box } from '@material-ui/core';
import BookmarkIcon from '@material-ui/icons/Bookmark';
import React, { ComponentType, ReactNode, PropsWithChildren } from 'react';
import ReactDOM from 'react-dom';
import { createRoutesFromChildren, Route } from 'react-router-dom';
import { SidebarThemeSwitcher } from './SidebarThemeSwitcher';
import 'react-dom';
let ReactDOM:
| typeof import('react-dom')
// TODO: replace with import('react-dom/client') when repo is migrated to 18
| { createRoot(el: HTMLElement): { render(el: JSX.Element): void } };
if (process.env.HAS_REACT_DOM_CLIENT) {
ReactDOM = require('react-dom/client');
} else {
ReactDOM = require('react-dom');
}
export function isReactRouterBeta(): boolean {
const [obj] = createRoutesFromChildren(<Route index element={<div />} />);
@@ -235,7 +245,11 @@ export class DevAppBuilder {
window.location.pathname = this.defaultPage;
}
ReactDOM.render(<DevApp />, document.getElementById('root'));
if ('createRoot' in ReactDOM) {
ReactDOM.createRoot(document.getElementById('root')!).render(<DevApp />);
} else {
ReactDOM.render(<DevApp />, document.getElementById('root'));
}
}
}
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
@@ -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)
@@ -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
>;
```
@@ -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
@@ -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();
@@ -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"
]
}
@@ -0,0 +1,503 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
OAuthAuthenticatorAuthenticateInput,
OAuthAuthenticatorRefreshInput,
OAuthAuthenticatorStartInput,
OAuthState,
decodeOAuthState,
encodeOAuthState,
} from '@backstage/plugin-auth-node';
import { pinnipedAuthenticator } from './authenticator';
import { setupServer } from 'msw/node';
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import { ConfigReader } from '@backstage/config';
import { JWK, SignJWT, exportJWK, generateKeyPair } from 'jose';
import { rest } from 'msw';
import express from 'express';
describe('pinnipedAuthenticator', () => {
let implementation: any;
let oauthState: OAuthState;
let idToken: string;
let publicKey: JWK;
const mswServer = setupServer();
setupRequestMockHandlers(mswServer);
const issuerMetadata = {
issuer: 'https://pinniped.test',
authorization_endpoint: 'https://pinniped.test/oauth2/authorize',
token_endpoint: 'https://pinniped.test/oauth2/token',
revocation_endpoint: 'https://pinniped.test/oauth2/revoke_token',
userinfo_endpoint: 'https://pinniped.test/idp/userinfo.openid',
introspection_endpoint: 'https://pinniped.test/introspect.oauth2',
jwks_uri: 'https://pinniped.test/jwks.json',
scopes_supported: [
'openid',
'offline_access',
'pinniped:request-audience',
'username',
'groups',
],
claims_supported: ['email', 'username', 'groups', 'additionalClaims'],
response_types_supported: ['code'],
id_token_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'],
token_endpoint_auth_signing_alg_values_supported: [
'RS256',
'RS512',
'HS256',
],
request_object_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'],
};
const clusterScopedIdToken = 'dummy-token';
beforeAll(async () => {
const keyPair = await generateKeyPair('ES256');
const privateKey = await exportJWK(keyPair.privateKey);
publicKey = await exportJWK(keyPair.publicKey);
publicKey.alg = privateKey.alg = 'ES256';
idToken = await new SignJWT({
sub: 'test',
iss: 'https://pinniped.test',
iat: Date.now(),
aud: 'clientId',
exp: Date.now() + 10000,
})
.setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid })
.sign(keyPair.privateKey);
});
beforeEach(() => {
jest.clearAllMocks();
mswServer.use(
rest.get(
'https://federationDomain.test/.well-known/openid-configuration',
(_req, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(issuerMetadata),
),
),
rest.get('https://pinniped.test/jwks.json', async (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ keys: [{ ...publicKey }] })),
),
rest.post('https://pinniped.test/oauth2/token', async (req, res, ctx) => {
const formBody = new URLSearchParams(await req.text());
const isGrantTypeTokenExchange =
formBody.get('grant_type') ===
'urn:ietf:params:oauth:grant-type:token-exchange';
const hasValidTokenExchangeParams =
formBody.get('subject_token') === 'accessToken' &&
formBody.get('audience') === 'test_cluster' &&
formBody.get('subject_token_type') ===
'urn:ietf:params:oauth:token-type:access_token' &&
formBody.get('requested_token_type') ===
'urn:ietf:params:oauth:token-type:jwt';
return res(
req.headers.get('Authorization') &&
(!isGrantTypeTokenExchange || hasValidTokenExchangeParams)
? ctx.json({
access_token: isGrantTypeTokenExchange
? clusterScopedIdToken
: 'accessToken',
refresh_token: 'refreshToken',
...(!isGrantTypeTokenExchange && { id_token: idToken }),
scope: 'testScope',
})
: ctx.status(401),
);
}),
);
implementation = pinnipedAuthenticator.initialize({
callbackUrl: 'https://backstage.test/callback',
config: new ConfigReader({
federationDomain: 'https://federationDomain.test',
clientId: 'clientId',
clientSecret: 'clientSecret',
}),
});
oauthState = {
nonce: 'nonce',
env: 'env',
};
});
describe('#start', () => {
let fakeSession: Record<string, any>;
let startRequest: OAuthAuthenticatorStartInput;
beforeEach(() => {
fakeSession = {};
startRequest = {
state: encodeOAuthState(oauthState),
req: {
method: 'GET',
url: 'test',
session: fakeSession,
},
} as unknown as OAuthAuthenticatorStartInput;
});
it('redirects to authorization endpoint returned from OIDC metadata endpoint', async () => {
const startResponse = await pinnipedAuthenticator.start(
startRequest,
implementation,
);
const url = new URL(startResponse.url);
expect(url.protocol).toBe('https:');
expect(url.hostname).toBe('pinniped.test');
expect(url.pathname).toBe('/oauth2/authorize');
});
it('initiates authorization code grant', async () => {
const startResponse = await pinnipedAuthenticator.start(
startRequest,
implementation,
);
const { searchParams } = new URL(startResponse.url);
expect(searchParams.get('response_type')).toBe('code');
});
it('persists audience parameter in oauth state', async () => {
startRequest.req.query = { audience: 'test-cluster' };
const startResponse = await pinnipedAuthenticator.start(
startRequest,
implementation,
);
const { searchParams } = new URL(startResponse.url);
const stateParam = searchParams.get('state');
const decodedState = decodeOAuthState(stateParam!);
expect(decodedState).toMatchObject({
nonce: 'nonce',
env: 'env',
audience: 'test-cluster',
});
});
it('passes client ID from config', async () => {
const startResponse = await pinnipedAuthenticator.start(
startRequest,
implementation,
);
const { searchParams } = new URL(startResponse.url);
expect(searchParams.get('client_id')).toBe('clientId');
});
it('passes callback URL from config', async () => {
const startResponse = await pinnipedAuthenticator.start(
startRequest,
implementation,
);
const { searchParams } = new URL(startResponse.url);
expect(searchParams.get('redirect_uri')).toBe(
'https://backstage.test/callback',
);
});
it('generates PKCE challenge', async () => {
const startResponse = await pinnipedAuthenticator.start(
startRequest,
implementation,
);
const { searchParams } = new URL(startResponse.url);
expect(searchParams.get('code_challenge_method')).toBe('S256');
expect(searchParams.get('code_challenge')).not.toBeNull();
});
it('stores PKCE verifier in session', async () => {
await pinnipedAuthenticator.start(startRequest, implementation);
expect(fakeSession['oidc:pinniped.test'].code_verifier).toBeDefined();
});
it('requests sufficient scopes for token exchange by default', async () => {
const startResponse = await pinnipedAuthenticator.start(
startRequest,
implementation,
);
const { searchParams } = new URL(startResponse.url);
const scopes = searchParams.get('scope')?.split(' ') ?? [];
expect(scopes).toEqual(
expect.arrayContaining([
'openid',
'pinniped:request-audience',
'username',
'offline_access',
]),
);
});
it('encodes OAuth state in query param', async () => {
const startResponse = await pinnipedAuthenticator.start(
startRequest,
implementation,
);
const { searchParams } = new URL(startResponse.url);
const stateParam = searchParams.get('state');
const decodedState = decodeOAuthState(stateParam!);
expect(decodedState).toMatchObject(oauthState);
});
it('fails when request has no session', async () => {
return expect(
pinnipedAuthenticator.start(
{
state: encodeOAuthState(oauthState),
req: {
method: 'GET',
url: 'test',
},
} as unknown as OAuthAuthenticatorStartInput,
implementation,
),
).rejects.toThrow('authentication requires session support');
});
});
describe('#authenticate', () => {
let handlerRequest: OAuthAuthenticatorAuthenticateInput;
beforeEach(() => {
handlerRequest = {
req: {
method: 'GET',
url: `https://test?code=authorization_code&state=${encodeOAuthState(
oauthState,
)}`,
session: {
'oidc:pinniped.test': {
state: encodeOAuthState(oauthState),
},
},
} as unknown as express.Request,
};
});
it('exchanges authorization code for access token', async () => {
const handlerResponse = await pinnipedAuthenticator.authenticate(
handlerRequest,
implementation,
);
const accessToken = handlerResponse.session.accessToken;
expect(accessToken).toEqual('accessToken');
});
it('exchanges authorization code for refresh token', async () => {
const handlerResponse = await pinnipedAuthenticator.authenticate(
handlerRequest,
implementation,
);
const refreshToken = handlerResponse.session.refreshToken;
expect(refreshToken).toEqual('refreshToken');
});
it('returns granted scope', async () => {
const handlerResponse = await pinnipedAuthenticator.authenticate(
handlerRequest,
implementation,
);
const responseScope = handlerResponse.session.scope;
expect(responseScope).toEqual('testScope');
});
it('returns cluster-scoped ID token when audience is specified', async () => {
oauthState.audience = 'test_cluster';
handlerRequest = {
req: {
method: 'GET',
url: `https://test?code=authorization_code&state=${encodeOAuthState(
oauthState,
)}`,
session: {
'oidc:pinniped.test': {
state: encodeOAuthState(oauthState),
},
},
} as unknown as express.Request,
};
const handlerResponse = await pinnipedAuthenticator.authenticate(
handlerRequest,
implementation,
);
expect(handlerResponse.session.idToken).toEqual(clusterScopedIdToken);
});
it('fails on network error during token exchange', async () => {
mswServer.use(
rest.post(
'https://pinniped.test/oauth2/token',
async (req, res, ctx) => {
const formBody = new URLSearchParams(await req.text());
const isGrantTypeTokenExchange =
formBody.get('grant_type') ===
'urn:ietf:params:oauth:grant-type:token-exchange';
const hasValidTokenExchangeParams =
formBody.get('subject_token') === 'accessToken' &&
formBody.get('audience') === 'test_cluster' &&
formBody.get('subject_token_type') ===
'urn:ietf:params:oauth:token-type:access_token' &&
formBody.get('requested_token_type') ===
'urn:ietf:params:oauth:token-type:jwt';
mswServer.use(
rest.post(
'https://pinniped.test/oauth2/token',
async (_req, response, _ctx) =>
response.networkError('Connection timed out'),
),
);
return res(
req.headers.get('Authorization') &&
(!isGrantTypeTokenExchange || hasValidTokenExchangeParams)
? ctx.json({
access_token: isGrantTypeTokenExchange
? clusterScopedIdToken
: 'accessToken',
refresh_token: 'refreshToken',
...(!isGrantTypeTokenExchange && { id_token: idToken }),
scope: 'testScope',
})
: ctx.status(401),
);
},
),
);
oauthState.audience = 'test_cluster';
handlerRequest = {
req: {
method: 'GET',
url: `https://test?code=authorization_code&state=${encodeOAuthState(
oauthState,
)}`,
session: {
'oidc:pinniped.test': {
state: encodeOAuthState(oauthState),
},
},
} as unknown as express.Request,
};
await expect(
pinnipedAuthenticator.authenticate(handlerRequest, implementation),
).rejects.toThrow(
`Failed to get cluster specific ID token for "test_cluster": Error: RFC8693 token exchange failed with error: NetworkError: Connection timed out`,
);
});
it('fails without authorization code', async () => {
handlerRequest.req.url = 'https://test.com';
return expect(
pinnipedAuthenticator.authenticate(handlerRequest, implementation),
).rejects.toThrow('Unexpected redirect');
});
it('fails without oauth state', async () => {
return expect(
pinnipedAuthenticator.authenticate(
{
req: {
method: 'GET',
url: `https://test?code=authorization_code}`,
session: {
['oidc:pinniped.test']: {
state: { handle: 'sessionid', code_verifier: 'foo' },
},
},
} as unknown as express.Request,
},
implementation,
),
).rejects.toThrow(
'Authentication rejected, state missing from the response',
);
});
it('fails when request has no session', async () => {
return expect(
pinnipedAuthenticator.authenticate(
{
req: {
method: 'GET',
url: 'https://test.com',
} as unknown as express.Request,
},
implementation,
),
).rejects.toThrow('authentication requires session support');
});
});
describe('#refresh', () => {
let refreshRequest: OAuthAuthenticatorRefreshInput;
beforeEach(() => {
refreshRequest = {
scope: '',
refreshToken: 'otherRefreshToken',
req: {} as express.Request,
};
});
it('gets new refresh token', async () => {
const refreshResponse = await pinnipedAuthenticator.refresh(
refreshRequest,
implementation,
);
expect(refreshResponse.session.refreshToken).toBe('refreshToken');
});
it('gets access token', async () => {
const refreshResponse = await pinnipedAuthenticator.refresh(
refreshRequest,
implementation,
);
expect(refreshResponse.session.accessToken).toBe('accessToken');
});
it('gets id token', async () => {
const refreshResponse = await pinnipedAuthenticator.refresh(
refreshRequest,
implementation,
);
expect(refreshResponse.session.idToken).toBe(idToken);
});
});
});
@@ -0,0 +1,195 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { PassportDoneCallback } from '@backstage/plugin-auth-node';
import {
createOAuthAuthenticator,
decodeOAuthState,
encodeOAuthState,
} from '@backstage/plugin-auth-node';
import {
Client,
Issuer,
TokenSet,
Strategy as OidcStrategy,
} from 'openid-client';
const rfc8693TokenExchange = async ({
subject_token,
target_audience,
ctx,
}: {
subject_token: string;
target_audience: string;
ctx: Promise<{
providerStrategy: OidcStrategy<{}>;
client: Client;
}>;
}): Promise<string | undefined> => {
const { client } = await ctx;
return client
.grant({
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
subject_token,
audience: target_audience,
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
requested_token_type: 'urn:ietf:params:oauth:token-type:jwt',
})
.then(tokenset => tokenset.access_token)
.catch(err => {
throw new Error(`RFC8693 token exchange failed with error: ${err}`);
});
};
/** @public */
export const pinnipedAuthenticator = createOAuthAuthenticator({
defaultProfileTransform: async (_r, _c) => ({ profile: {} }),
async initialize({ callbackUrl, config }) {
const issuer = await Issuer.discover(
`${config.getString(
'federationDomain',
)}/.well-known/openid-configuration`,
);
const client = new issuer.Client({
access_type: 'offline', // this option must be passed to provider to receive a refresh token
client_id: config.getString('clientId'),
client_secret: config.getString('clientSecret'),
redirect_uris: [callbackUrl],
response_types: ['code'],
scope: config.getOptionalString('scope') || '',
id_token_signed_response_alg: 'ES256',
});
const providerStrategy = new OidcStrategy(
{
client,
passReqToCallback: false,
},
(
tokenset: TokenSet,
done: PassportDoneCallback<
{ tokenset: TokenSet },
{
refreshToken?: string;
}
>,
) => {
done(undefined, { tokenset }, {});
},
);
return { providerStrategy, client };
},
async start(input, ctx) {
const { providerStrategy } = await ctx;
const stringifiedAudience = input.req.query?.audience as string;
const decodedState = decodeOAuthState(input.state);
const state = { ...decodedState, audience: stringifiedAudience };
const options: Record<string, string> = {
scope:
input.scope ||
'openid pinniped:request-audience username offline_access',
state: encodeOAuthState(state),
};
return new Promise((resolve, reject) => {
const strategy = Object.create(providerStrategy);
strategy.redirect = (url: string) => {
resolve({ url });
};
strategy.error = (error: Error) => {
reject(error);
};
strategy.authenticate(input.req, { ...options });
});
},
async authenticate(input, ctx) {
const { providerStrategy } = await ctx;
const { req } = input;
const { searchParams } = new URL(req.url, 'https://pinniped.com');
const stateParam = searchParams.get('state');
const audience = stateParam
? decodeOAuthState(stateParam).audience
: undefined;
return new Promise((resolve, reject) => {
const strategy = Object.create(providerStrategy);
strategy.success = (user: any) => {
(audience
? rfc8693TokenExchange({
subject_token: user.tokenset.access_token,
target_audience: audience,
ctx,
}).catch(err =>
reject(
new Error(
`Failed to get cluster specific ID token for "${audience}": ${err}`,
),
),
)
: Promise.resolve(user.tokenset.id_token)
).then(idToken => {
resolve({
fullProfile: { provider: '', id: '', displayName: '' },
session: {
accessToken: user.tokenset.access_token!,
tokenType: user.tokenset.token_type ?? 'bearer',
scope: user.tokenset.scope!,
idToken,
refreshToken: user.tokenset.refresh_token,
},
});
});
};
strategy.fail = (info: any) => {
reject(new Error(`Authentication rejected, ${info.message || ''}`));
};
strategy.error = (error: Error) => {
reject(error);
};
strategy.redirect = () => {
reject(new Error('Unexpected redirect'));
};
strategy.authenticate(req);
});
},
async refresh(input, ctx) {
const { client } = await ctx;
const tokenset = await client.refresh(input.refreshToken);
return new Promise((resolve, reject) => {
if (!tokenset.access_token) {
reject(new Error('Refresh Failed'));
}
resolve({
fullProfile: { provider: '', id: '', displayName: '' },
session: {
accessToken: tokenset.access_token!,
tokenType: tokenset.token_type ?? 'bearer',
scope: tokenset.scope!,
idToken: tokenset.id_token,
refreshToken: tokenset.refresh_token,
},
});
});
},
});
@@ -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;
};
};
};
};
}
@@ -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';
@@ -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',
},
},
}),
),
);
});
});
@@ -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,
},
}),
});
},
});
},
});
+1
View File
@@ -399,6 +399,7 @@ export type OAuthState = {
scope?: string;
redirectUrl?: string;
flow?: string;
audience?: string;
};
// @public (undocumented)
+1
View File
@@ -29,6 +29,7 @@ export type OAuthState = {
scope?: string;
redirectUrl?: string;
flow?: string;
audience?: string;
};
/** @public */
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Select } from '@backstage/core-components';
import { Select, SelectedItems } from '@backstage/core-components';
import { Box } from '@material-ui/core';
import React, { useCallback } from 'react';
@@ -31,7 +31,10 @@ export type Props = {
const curves: Array<Curve> = ['curveMonotoneX', 'curveStepBefore'];
export const CurveFilter = ({ value, onChange }: Props) => {
const handleChange = useCallback(v => onChange(v as Curve), [onChange]);
const handleChange = useCallback(
(v: SelectedItems) => onChange(v as Curve),
[onChange],
);
return (
<Box pb={1} pt={1}>
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Select } from '@backstage/core-components';
import { Select, SelectedItems } from '@backstage/core-components';
import { Box } from '@material-ui/core';
import React, { useCallback } from 'react';
import { Direction } from '../EntityRelationsGraph';
@@ -31,7 +31,10 @@ export type Props = {
};
export const DirectionFilter = ({ value, onChange }: Props) => {
const handleChange = useCallback(v => onChange(v as Direction), [onChange]);
const handleChange = useCallback(
(v: SelectedItems) => onChange(v as Direction),
[onChange],
);
return (
<Box pb={1} pt={1}>
@@ -36,7 +36,7 @@ export const DefaultImportPage = () => {
const theme = useTheme();
const configApi = useApi(configApiRef);
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const appTitle = configApi.getOptional('app.title') || 'Backstage';
const appTitle = configApi.getOptionalString('app.title') || 'Backstage';
const contentItems = [
<Grid item xs={12} md={4} lg={6} xl={8}>
@@ -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');
@@ -42,7 +42,7 @@ export const StepReviewLocation = ({
const configApi = useApi(configApiRef);
const analytics = useAnalytics();
const appTitle = configApi.getOptional('app.title') || 'Backstage';
const appTitle = configApi.getOptionalString('app.title') || 'Backstage';
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState<string>();
@@ -164,7 +164,7 @@ export const EntityPeekAheadPopover = (props: EntityPeekAheadPopoverProps) => {
{entity.metadata.description}
</Typography>
)}
<Typography>{entity.spec?.type}</Typography>
<Typography>{entity.spec?.type?.toString()}</Typography>
<Box marginTop="0.5em">
{(entity.metadata.tags || [])
.slice(0, maxTagChips)
@@ -74,7 +74,10 @@ export function OverviewPage(props: { entity: AlphaEntity }) {
</ListItem>
{spec?.type && (
<ListItem>
<ListItemText primary="spec.type" secondary={spec.type} />
<ListItemText
primary="spec.type"
secondary={spec.type?.toString()}
/>
</ListItem>
)}
{metadata.uid && (
@@ -22,7 +22,7 @@ import {
renderHook,
RenderHookResult,
} from '@testing-library/react-hooks';
import React from 'react';
import React, { ReactNode } from 'react';
import {
UseUnregisterEntityDialogState,
useUnregisterEntityDialogState,
@@ -85,7 +85,10 @@ describe('useUnregisterEntityDialogState', () => {
});
it('goes through the happy unregister path', async () => {
let rendered: RenderHookResult<unknown, UseUnregisterEntityDialogState>;
let rendered: RenderHookResult<
{ children?: ReactNode },
UseUnregisterEntityDialogState
>;
act(() => {
rendered = renderHook(() => useUnregisterEntityDialogState(entity), {
wrapper: Wrapper,
@@ -114,7 +117,10 @@ describe('useUnregisterEntityDialogState', () => {
entity.metadata.annotations![ANNOTATION_ORIGIN_LOCATION] =
'bootstrap:bootstrap';
let rendered: RenderHookResult<unknown, UseUnregisterEntityDialogState>;
let rendered: RenderHookResult<
{ children?: ReactNode },
UseUnregisterEntityDialogState
>;
act(() => {
rendered = renderHook(() => useUnregisterEntityDialogState(entity), {
wrapper: Wrapper,
@@ -137,7 +143,10 @@ describe('useUnregisterEntityDialogState', () => {
it('chooses only-delete when there was no location annotation', async () => {
delete entity.metadata.annotations![ANNOTATION_ORIGIN_LOCATION];
let rendered: RenderHookResult<unknown, UseUnregisterEntityDialogState>;
let rendered: RenderHookResult<
{ children?: ReactNode },
UseUnregisterEntityDialogState
>;
act(() => {
rendered = renderHook(() => useUnregisterEntityDialogState(entity), {
wrapper: Wrapper,
@@ -157,7 +166,10 @@ describe('useUnregisterEntityDialogState', () => {
});
it('chooses only-delete when the location could not be found', async () => {
let rendered: RenderHookResult<unknown, UseUnregisterEntityDialogState>;
let rendered: RenderHookResult<
{ children?: ReactNode },
UseUnregisterEntityDialogState
>;
act(() => {
rendered = renderHook(() => useUnregisterEntityDialogState(entity), {
wrapper: Wrapper,
@@ -128,7 +128,10 @@ function EntityLabels(props: { entity: Entity }) {
/>
)}
{entity.spec?.lifecycle && (
<HeaderLabel label="Lifecycle" value={entity.spec.lifecycle} />
<HeaderLabel
label="Lifecycle"
value={entity.spec.lifecycle?.toString()}
/>
)}
</>
);
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { useEntity } from '@backstage/plugin-catalog-react';
import { humanizeEntityRef, useEntity } from '@backstage/plugin-catalog-react';
import { Box, Modal, makeStyles } from '@material-ui/core';
import FolderIcon from '@material-ui/icons/Folder';
import FileOutlinedIcon from '@material-ui/icons/InsertDriveFileOutlined';
@@ -181,7 +181,9 @@ export const FileExplorer = () => {
}
if (!value) {
return (
<Alert severity="warning">No code coverage found for ${entity}</Alert>
<Alert severity="warning">
No code coverage found for {humanizeEntityRef(entity)}
</Alert>
);
}
@@ -33,8 +33,9 @@ const items = [
},
];
const tooltipItems = () =>
items.map(item => <BarChartTooltipItem key={item.label} item={item} />);
const tooltipItems = items.map(item => (
<BarChartTooltipItem key={item.label} item={item} />
));
describe('<BarChartTooltip/>', () => {
it('formats label and tooltip item text correctly', async () => {
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React from 'react';
import React, { ComponentType } from 'react';
import { getByRole, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ProjectSelect } from './ProjectSelect';
@@ -28,7 +28,7 @@ const mockProjects = [
];
describe('<ProjectSelect />', () => {
let Component: React.ReactNode;
let Component: ComponentType;
beforeEach(() => {
Component = () => (
<MockFilterProvider>
@@ -44,7 +44,9 @@ import { useApi } from '@backstage/core-plugin-api';
export const LIMIT = 10;
const AuditList = () => {
const [dismissedStored] = useLocalStorage(LIGHTHOUSE_INTRO_LOCAL_STORAGE);
const [dismissedStored] = useLocalStorage<boolean>(
LIGHTHOUSE_INTRO_LOCAL_STORAGE,
);
const [dismissed, setDismissed] = useState(dismissedStored);
const query = useQuery();
@@ -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);
},
+1
View File
@@ -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",
@@ -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(
@@ -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(
@@ -29,7 +29,7 @@ import {
useRouteRefParams,
useApi,
} from '@backstage/core-plugin-api';
import { FormProps, IChangeEvent, withTheme } from '@rjsf/core';
import { FormProps, IChangeEvent, ISubmitEvent, withTheme } from '@rjsf/core';
import { Theme as MuiTheme } from '@rjsf/material-ui';
import React, { ComponentType, useState } from 'react';
import { transformSchemaToProps } from './schema';
@@ -185,7 +185,7 @@ export const MultistepJsonForm = (props: MultistepJsonFormProps) => {
formData={formData}
formContext={{ formData }}
onChange={onChange}
onSubmit={e => {
onSubmit={(e: ISubmitEvent<any>) => {
if (e.errors.length === 0) handleNext();
}}
{...formProps}
@@ -28,7 +28,7 @@ import {
Select,
} from '@material-ui/core';
import CloseIcon from '@material-ui/icons/Close';
import { withTheme } from '@rjsf/core';
import { ISubmitEvent, withTheme } from '@rjsf/core';
import { Theme as MuiTheme } from '@rjsf/material-ui';
import CodeMirror from '@uiw/react-codemirror';
import React, { useCallback, useMemo, useState } from 'react';
@@ -104,7 +104,7 @@ export const CustomFieldExplorer = ({
}, [customFieldExtensions]);
const handleSelectionChange = useCallback(
selection => {
(selection: FieldExtensionOptions) => {
setSelectedField(selection);
setFieldFormState({});
setFormState({});
@@ -113,7 +113,7 @@ export const CustomFieldExplorer = ({
);
const handleFieldConfigChange = useCallback(
state => {
(state: {}) => {
setFieldFormState(state);
setFormState({});
// Force TemplateEditorForm to re-render since some fields
@@ -134,7 +134,9 @@ export const CustomFieldExplorer = ({
value={selectedField}
label="Choose Custom Field Extension"
labelId="select-field-label"
onChange={e => handleSelectionChange(e.target.value)}
onChange={e =>
handleSelectionChange(e.target.value as FieldExtensionOptions)
}
>
{fieldOptions.map((option, idx) => (
<MenuItem key={idx} value={option as any}>
@@ -158,7 +160,9 @@ export const CustomFieldExplorer = ({
noHtml5Validate
formData={fieldFormState}
formContext={{ fieldFormState }}
onSubmit={e => handleFieldConfigChange(e.formData)}
onSubmit={(e: ISubmitEvent<any>) =>
handleFieldConfigChange(e.formData)
}
schema={selectedField.schema?.uiOptions || {}}
>
<Button
@@ -164,7 +164,8 @@ export const TemplateFormPreviewer = ({
);
const handleSelectChange = useCallback(
selected => {
// TODO(Rugvip): Afaik this should be Entity, but didn't want to make runtime changes while fixing types
(selected: any) => {
setSelectedTemplate(selected);
setTemplateYaml(yaml.stringify(selected.spec));
},
@@ -102,7 +102,7 @@ export const CustomFieldExplorer = ({
}, [customFieldExtensions]);
const handleSelectionChange = useCallback(
selection => {
(selection: NextFieldExtensionOptions) => {
setSelectedField(selection);
setFieldFormState({});
},
@@ -110,7 +110,7 @@ export const CustomFieldExplorer = ({
);
const handleFieldConfigChange = useCallback(
state => {
(state: {}) => {
setFieldFormState(state);
// Force TemplateEditorForm to re-render since some fields
// may not be responsive to ui:option changes
@@ -130,7 +130,9 @@ export const CustomFieldExplorer = ({
value={selectedField}
label="Choose Custom Field Extension"
labelId="select-field-label"
onChange={e => handleSelectionChange(e.target.value)}
onChange={e =>
handleSelectionChange(e.target.value as NextFieldExtensionOptions)
}
>
{fieldOptions.map((option, idx) => (
<MenuItem key={idx} value={option as any}>
@@ -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));
},
@@ -29,7 +29,7 @@ const SearchContextFilterSpy = ({ name }: { name: string }) => {
const value = filters[name];
return (
<span data-testid={`${name}-filter-spy`}>
{Array.isArray(value) ? value.join(',') : value}
{Array.isArray(value) ? value.join(',') : value?.toString()}
</span>
);
};
@@ -193,7 +193,7 @@ export const SearchResultGroupTextFilterField = (
contentEditable
suppressContentEditableWarning
>
{value}
{value?.toString()}
</Typography>
</SearchResultGroupFilterFieldLayout>
);
@@ -377,7 +377,7 @@ export function SearchResultGroupLayout<FilterOption>(
filterOptions,
renderFilterOption = filterOption => (
<MenuItem key={String(filterOption)} value={String(filterOption)}>
{filterOption}
{String(filterOption)}
</MenuItem>
),
filterFields,
+18 -16
View File
@@ -198,22 +198,24 @@ export const SearchPage = createPageExtension({
<Grid item xs>
<SearchPagination />
<SearchResults>
{({ results }) =>
results.map((result, index) => {
const { noTrack } = config;
const { document, ...rest } = result;
const SearchResultListItem =
getResultItemComponent(result);
return (
<SearchResultListItem
{...rest}
key={index}
result={document}
noTrack={noTrack}
/>
);
})
}
{({ results }) => (
<>
{results.map((result, index) => {
const { noTrack } = config;
const { document, ...rest } = result;
const SearchResultListItem =
getResultItemComponent(result);
return (
<SearchResultListItem
{...rest}
key={index}
result={document}
noTrack={noTrack}
/>
);
})}
</>
)}
</SearchResults>
<SearchResultPager />
</Grid>
@@ -57,18 +57,11 @@ export const HomePageSearchBar = (props: HomePageSearchBarProps) => {
handleSearch({ query: ref.current?.value ?? '' });
}, [handleSearch]);
const handleChange = useCallback(
value => {
setQuery(value);
},
[setQuery],
);
return (
<SearchBarBase
value={query}
onSubmit={handleSubmit}
onChange={handleChange}
onChange={setQuery}
inputProps={{ ref }}
InputProps={{
...props.InputProps,
@@ -74,12 +74,15 @@ const SentryIssuesTable = (props: SentryIssuesTableProps) => {
const { sentryIssues, statsFor, tableOptions } = props;
const [selected, setSelected] = useState(ONE_DAY_IN_MILLIS);
const filterByDate = useCallback((issue, selectedFilter) => {
return (
DateTime.fromISO(issue.lastSeen) >
DateTime.now().minus(Duration.fromMillis(selectedFilter))
);
}, []);
const filterByDate = useCallback(
(issue: SentryIssue, selectedFilter: number) => {
return (
DateTime.fromISO(issue.lastSeen) >
DateTime.now().minus(Duration.fromMillis(selectedFilter))
);
},
[],
);
const [filteredIssues, setFilteredIssues] = useState(
sentryIssues.filter(i => filterByDate(i, selected)),
);
+2 -2
View File
@@ -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": {
@@ -133,7 +133,9 @@ export const TechDocsReaderPageHeader = (
}
/>
)}
{lifecycle ? <HeaderLabel label="Lifecycle" value={lifecycle} /> : null}
{lifecycle ? (
<HeaderLabel label="Lifecycle" value={String(lifecycle)} />
) : null}
{locationMetadata &&
locationMetadata.type !== 'dir' &&
locationMetadata.type !== 'file' ? (
@@ -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';
@@ -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');
@@ -17,7 +17,7 @@
import { createTestShadowDom } from '../../test-utils';
import { copyToClipboard } from './copyToClipboard';
import { lightTheme } from '@backstage/theme';
import { waitFor } from '@testing-library/react';
import { act, waitFor } from '@testing-library/react';
import useCopyToClipboard from 'react-use/lib/useCopyToClipboard';
const clipboardSpy = jest.fn();
@@ -43,8 +43,11 @@ describe('copyToClipboard', () => {
spy.mockReturnValue([{}, copy]);
const expectedClipboard = 'function foo() {return "bar";}';
const shadowDom = await createTestShadowDom(
`
let shadowDom: ShadowRoot;
await act(async () => {
shadowDom = await createTestShadowDom(
`
<!DOCTYPE html>
<html>
<body>
@@ -52,13 +55,20 @@ describe('copyToClipboard', () => {
</body>
</html>
`,
{
preTransformers: [],
postTransformers: [copyToClipboard(lightTheme)],
},
);
{
preTransformers: [],
postTransformers: [copyToClipboard(lightTheme)],
},
);
});
shadowDom.querySelector('button')?.click();
await waitFor(() => {
expect(shadowDom.querySelector('button')).not.toBe(null);
});
await act(async () => {
shadowDom.querySelector('button')!.click();
});
await waitFor(() => {
const tooltip = document.querySelector('[role="tooltip"]');
@@ -15,7 +15,7 @@
*/
import React, { useState, useCallback } from 'react';
import ReactDom from 'react-dom';
import { renderReactElement } from './renderReactElement';
import {
withStyles,
Theme,
@@ -91,7 +91,7 @@ export const copyToClipboard = (theme: Theme): Transformer => {
const text = code.textContent || '';
const container = document.createElement('div');
code?.parentElement?.prepend(container);
ReactDom.render(
renderReactElement(
<ThemeProvider theme={theme}>
<CopyToClipboardButton text={text} />
</ThemeProvider>,
@@ -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);
}
}

Some files were not shown because too many files have changed in this diff Show More