Merge branch 'backstage:master' into feature/support-aoss
This commit is contained in:
@@ -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`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend': patch
|
||||
---
|
||||
|
||||
Fixed bug in oidc refresh handler, if token endpoints response on refresh request does not contain a scope, the requested scope is used.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-entity-feedback': patch
|
||||
---
|
||||
|
||||
Added tooltip to like dislike buttons
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
'@backstage/plugin-jenkins-backend': minor
|
||||
'@backstage/plugin-jenkins': minor
|
||||
---
|
||||
|
||||
Added JobRunTable Component.
|
||||
Added new Route and extended Api to get buildJobs.
|
||||
Actions column has a new icon button, clicking on which takes us to page where we
|
||||
can see all the job runs.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-code-coverage': patch
|
||||
---
|
||||
|
||||
The warning for missing code coverage will now render the entity as a reference.
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"mode": "pre",
|
||||
"mode": "exit",
|
||||
"tag": "next",
|
||||
"initialVersions": {
|
||||
"example-app": "0.2.87",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
---
|
||||
|
||||
Fixed the type declaration of `DependencyGraphProps`, the `defs` prop now expects `JSX.Element`s.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
The scaffolder-module template now recommends usage of `createMockDirectory` instead of `mock-fs`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-react': patch
|
||||
---
|
||||
|
||||
The `spec.type` field in entities will now always be rendered as a string.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
Fixed recursive reloading issues of the backend, caused by unwanted watched files.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-import': patch
|
||||
---
|
||||
|
||||
The `app.title` configuration is now properly required to be a string.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend-module-aws': minor
|
||||
---
|
||||
|
||||
AwsEksClusterProcessor supports Entity callback function and passes in region when initialize EKS cluster
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-search-react': patch
|
||||
---
|
||||
|
||||
The filter options passed to `SearchResultGroupLayout` are now always explicitly rendered as strings by default.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-search': patch
|
||||
---
|
||||
|
||||
Minor internal code cleanup.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-node': patch
|
||||
---
|
||||
|
||||
Adding optional audience parameter to OAuthState type declaration
|
||||
@@ -12,7 +12,7 @@ A huge thanks to the whole team of maintainers and contributors as well as the a
|
||||
|
||||
### <title>
|
||||
|
||||
<short description>. Contributed by [@<user>](https://github.com/<user>) [#<pr>](https://github.com/backstage/backstage/pull/<pr>)
|
||||
<short description>. Contributed by [@<user>](https://github.com/<user>) in [#<pr>](https://github.com/backstage/backstage/pull/<pr>)
|
||||
|
||||
## Security Fixes
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -124,9 +124,8 @@ export async function startBackendExperimental(options: BackendServeOptions) {
|
||||
|
||||
restart();
|
||||
|
||||
watcher = watch([paths.targetDir], {
|
||||
watcher = watch([], {
|
||||
cwd: process.cwd(),
|
||||
ignored: ['**/.*/**', '**/node_modules/**'],
|
||||
ignoreInitial: true,
|
||||
ignorePermissionErrors: true,
|
||||
}).on('all', restart);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>([
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -130,6 +130,9 @@ export class OidcAuthProvider implements OAuthHandlers {
|
||||
if (!tokenset.access_token) {
|
||||
throw new Error('Refresh failed');
|
||||
}
|
||||
if (!tokenset.scope) {
|
||||
tokenset.scope = req.scope;
|
||||
}
|
||||
const userinfo = await client.userinfo(tokenset.access_token);
|
||||
|
||||
return {
|
||||
|
||||
@@ -399,6 +399,7 @@ export type OAuthState = {
|
||||
scope?: string;
|
||||
redirectUrl?: string;
|
||||
flow?: string;
|
||||
audience?: string;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
|
||||
@@ -29,6 +29,7 @@ export type OAuthState = {
|
||||
scope?: string;
|
||||
redirectUrl?: string;
|
||||
flow?: string;
|
||||
audience?: string;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
|
||||
@@ -8,7 +8,9 @@ import { AwsCredentialsManager } from '@backstage/integration-aws-node';
|
||||
import { CatalogProcessor } from '@backstage/plugin-catalog-node';
|
||||
import { CatalogProcessorEmit } from '@backstage/plugin-catalog-node';
|
||||
import { CatalogProcessorParser } from '@backstage/plugin-catalog-node';
|
||||
import type { Cluster } from '@aws-sdk/client-eks';
|
||||
import { Config } from '@backstage/config';
|
||||
import type { Entity } from '@backstage/catalog-model';
|
||||
import { EntityProvider } from '@backstage/plugin-catalog-node';
|
||||
import { EntityProviderConnection } from '@backstage/plugin-catalog-node';
|
||||
import { LocationSpec } from '@backstage/plugin-catalog-common';
|
||||
@@ -17,6 +19,12 @@ import { PluginTaskScheduler } from '@backstage/backend-tasks';
|
||||
import { TaskRunner } from '@backstage/backend-tasks';
|
||||
import { UrlReader } from '@backstage/backend-common';
|
||||
|
||||
// @public
|
||||
export const ANNOTATION_AWS_ACCOUNT_ID: string;
|
||||
|
||||
// @public
|
||||
export const ANNOTATION_AWS_ARN: string;
|
||||
|
||||
// @public
|
||||
export type AWSCredentialFactory = (
|
||||
awsAccountId: string,
|
||||
@@ -27,14 +35,18 @@ export class AwsEKSClusterProcessor implements CatalogProcessor {
|
||||
constructor(options: {
|
||||
credentialsFactory?: AWSCredentialFactory;
|
||||
credentialsManager?: AwsCredentialsManager;
|
||||
clusterEntityTransformer?: EksClusterEntityTransformer;
|
||||
});
|
||||
// (undocumented)
|
||||
static fromConfig(configRoot: Config): AwsEKSClusterProcessor;
|
||||
static fromConfig(
|
||||
configRoot: Config,
|
||||
options?: {
|
||||
clusterEntityTransformer?: EksClusterEntityTransformer;
|
||||
},
|
||||
): AwsEKSClusterProcessor;
|
||||
// (undocumented)
|
||||
getProcessorName(): string;
|
||||
// (undocumented)
|
||||
normalizeName(name: string): string;
|
||||
// (undocumented)
|
||||
readLocation(
|
||||
location: LocationSpec,
|
||||
_optional: boolean,
|
||||
@@ -93,4 +105,10 @@ export class AwsS3EntityProvider implements EntityProvider {
|
||||
// (undocumented)
|
||||
refresh(logger: Logger): Promise<void>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type EksClusterEntityTransformer = (
|
||||
cluster: Cluster,
|
||||
accountId: string,
|
||||
) => Promise<Entity>;
|
||||
```
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Annotation for specifying AWS account id
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const ANNOTATION_AWS_ACCOUNT_ID: string = 'amazonaws.com/account-id';
|
||||
/**
|
||||
* Annotation for specifying AWS arn
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const ANNOTATION_AWS_ARN: string = 'amazonaws.com/arn';
|
||||
@@ -23,3 +23,4 @@
|
||||
export * from './processors';
|
||||
export * from './providers';
|
||||
export * from './types';
|
||||
export * from './constants';
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { type Cluster } from '@aws-sdk/client-eks';
|
||||
import {
|
||||
ANNOTATION_KUBERNETES_API_SERVER,
|
||||
ANNOTATION_KUBERNETES_API_SERVER_CA,
|
||||
ANNOTATION_KUBERNETES_AUTH_PROVIDER,
|
||||
} from '@backstage/plugin-kubernetes-common';
|
||||
import type { EksClusterEntityTransformer } from '../processors/types';
|
||||
import { ANNOTATION_AWS_ACCOUNT_ID, ANNOTATION_AWS_ARN } from '../constants';
|
||||
|
||||
/**
|
||||
* Default transformer for EKS Cluster to Resource Entity
|
||||
* @public
|
||||
*/
|
||||
export const defaultEksClusterEntityTransformer: EksClusterEntityTransformer =
|
||||
async (cluster: Cluster, accountId: string) => {
|
||||
const { arn, endpoint, certificateAuthority, name } = cluster;
|
||||
return {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Resource',
|
||||
metadata: {
|
||||
annotations: {
|
||||
[ANNOTATION_AWS_ACCOUNT_ID]: accountId,
|
||||
[ANNOTATION_AWS_ARN]: arn || '',
|
||||
[ANNOTATION_KUBERNETES_API_SERVER]: endpoint || '',
|
||||
[ANNOTATION_KUBERNETES_API_SERVER_CA]:
|
||||
certificateAuthority?.data || '',
|
||||
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'aws',
|
||||
},
|
||||
name: normalizeName(name as string),
|
||||
namespace: 'default',
|
||||
},
|
||||
spec: {
|
||||
type: 'kubernetes-cluster',
|
||||
owner: 'unknown',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
function normalizeName(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.toLocaleLowerCase('en-US')
|
||||
.replace(/[^a-zA-Z0-9\-]/g, '-');
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export * from './defaultTransformers';
|
||||
@@ -19,11 +19,6 @@ import {
|
||||
CatalogProcessorEmit,
|
||||
} from '@backstage/plugin-catalog-node';
|
||||
import { LocationSpec } from '@backstage/plugin-catalog-common';
|
||||
import {
|
||||
ANNOTATION_KUBERNETES_API_SERVER,
|
||||
ANNOTATION_KUBERNETES_API_SERVER_CA,
|
||||
ANNOTATION_KUBERNETES_AUTH_PROVIDER,
|
||||
} from '@backstage/plugin-kubernetes-common';
|
||||
import { EKS } from '@aws-sdk/client-eks';
|
||||
import { AWSCredentialFactory } from '../types';
|
||||
import { AwsCredentialIdentity, Provider } from '@aws-sdk/types';
|
||||
@@ -33,8 +28,8 @@ import {
|
||||
} from '@backstage/integration-aws-node';
|
||||
import { Config } from '@backstage/config';
|
||||
|
||||
const ACCOUNTID_ANNOTATION: string = 'amazonaws.com/account-id';
|
||||
const ARN_ANNOTATION: string = 'amazonaws.com/arn';
|
||||
import type { EksClusterEntityTransformer } from './types';
|
||||
import { defaultEksClusterEntityTransformer } from '../lib';
|
||||
|
||||
/**
|
||||
* A processor for automatic discovery of resources from EKS clusters. Handles the
|
||||
@@ -46,34 +41,39 @@ const ARN_ANNOTATION: string = 'amazonaws.com/arn';
|
||||
export class AwsEKSClusterProcessor implements CatalogProcessor {
|
||||
private credentialsFactory?: AWSCredentialFactory;
|
||||
private credentialsManager?: AwsCredentialsManager;
|
||||
private readonly clusterEntityTransformer: EksClusterEntityTransformer;
|
||||
|
||||
static fromConfig(configRoot: Config): AwsEKSClusterProcessor {
|
||||
static fromConfig(
|
||||
configRoot: Config,
|
||||
options?: {
|
||||
clusterEntityTransformer?: EksClusterEntityTransformer;
|
||||
},
|
||||
): AwsEKSClusterProcessor {
|
||||
const awsCredentaislManager =
|
||||
DefaultAwsCredentialsManager.fromConfig(configRoot);
|
||||
return new AwsEKSClusterProcessor({
|
||||
credentialsManager: awsCredentaislManager,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
constructor(options: {
|
||||
credentialsFactory?: AWSCredentialFactory;
|
||||
credentialsManager?: AwsCredentialsManager;
|
||||
clusterEntityTransformer?: EksClusterEntityTransformer;
|
||||
}) {
|
||||
this.credentialsFactory = options.credentialsFactory;
|
||||
this.credentialsManager = options.credentialsManager;
|
||||
|
||||
// If the callback function is not passed in, then default to the one upstream is using
|
||||
this.clusterEntityTransformer =
|
||||
options.clusterEntityTransformer || defaultEksClusterEntityTransformer;
|
||||
}
|
||||
|
||||
getProcessorName(): string {
|
||||
return 'aws-eks';
|
||||
}
|
||||
|
||||
normalizeName(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.toLocaleLowerCase('en-US')
|
||||
.replace(/[^a-zA-Z0-9\-]/g, '-');
|
||||
}
|
||||
|
||||
async readLocation(
|
||||
location: LocationSpec,
|
||||
_optional: boolean,
|
||||
@@ -108,6 +108,7 @@ export class AwsEKSClusterProcessor implements CatalogProcessor {
|
||||
const eksClient = new EKS({
|
||||
credentials,
|
||||
credentialDefaultProvider: providerFunction,
|
||||
region,
|
||||
});
|
||||
const clusters = await eksClient.listClusters({});
|
||||
if (clusters.clusters === undefined) {
|
||||
@@ -119,27 +120,11 @@ export class AwsEKSClusterProcessor implements CatalogProcessor {
|
||||
.map(async describedClusterPromise => {
|
||||
const describedCluster = await describedClusterPromise;
|
||||
if (describedCluster.cluster) {
|
||||
const entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Resource',
|
||||
metadata: {
|
||||
annotations: {
|
||||
[ACCOUNTID_ANNOTATION]: accountId,
|
||||
[ARN_ANNOTATION]: describedCluster.cluster.arn || '',
|
||||
[ANNOTATION_KUBERNETES_API_SERVER]:
|
||||
describedCluster.cluster.endpoint || '',
|
||||
[ANNOTATION_KUBERNETES_API_SERVER_CA]:
|
||||
describedCluster.cluster.certificateAuthority?.data || '',
|
||||
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'aws',
|
||||
},
|
||||
name: this.normalizeName(describedCluster.cluster.name as string),
|
||||
namespace: 'default',
|
||||
},
|
||||
spec: {
|
||||
type: 'kubernetes-cluster',
|
||||
owner: 'unknown',
|
||||
},
|
||||
};
|
||||
const entity = await this.clusterEntityTransformer(
|
||||
describedCluster.cluster,
|
||||
accountId,
|
||||
);
|
||||
|
||||
emit({
|
||||
type: 'entity',
|
||||
entity,
|
||||
|
||||
@@ -17,3 +17,4 @@
|
||||
export { AwsEKSClusterProcessor } from './AwsEKSClusterProcessor';
|
||||
export { AwsOrganizationCloudAccountProcessor } from './AwsOrganizationCloudAccountProcessor';
|
||||
export { AwsS3DiscoveryProcessor } from './AwsS3DiscoveryProcessor';
|
||||
export * from './types';
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import type { Cluster } from '@aws-sdk/client-eks';
|
||||
import type { Entity } from '@backstage/catalog-model';
|
||||
|
||||
/**
|
||||
* Options for the EKS cluster entity callback function
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type EksClusterEntityTransformer = (
|
||||
cluster: Cluster,
|
||||
accountId: string,
|
||||
) => Promise<Entity>;
|
||||
@@ -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>();
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+4
-1
@@ -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 && (
|
||||
|
||||
+17
-5
@@ -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>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { useAsyncEntity } from '@backstage/plugin-catalog-react';
|
||||
import { IconButton } from '@material-ui/core';
|
||||
import Tooltip from '@material-ui/core/Tooltip';
|
||||
import ThumbDownIcon from '@material-ui/icons/ThumbDown';
|
||||
import ThumbUpIcon from '@material-ui/icons/ThumbUp';
|
||||
import ThumbDownOutlinedIcon from '@material-ui/icons/ThumbDownOutlined';
|
||||
@@ -127,9 +128,13 @@ export const LikeDislikeButtons = (props: LikeDislikeButtonsProps) => {
|
||||
onClick={() => applyRating(FeedbackRatings.like)}
|
||||
>
|
||||
{rating === FeedbackRatings.like ? (
|
||||
<ThumbUpIcon fontSize="small" />
|
||||
<Tooltip title="Liked">
|
||||
<ThumbUpIcon fontSize="small" />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<ThumbUpOutlinedIcon fontSize="small" />
|
||||
<Tooltip title="Like">
|
||||
<ThumbUpOutlinedIcon fontSize="small" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
@@ -137,9 +142,13 @@ export const LikeDislikeButtons = (props: LikeDislikeButtonsProps) => {
|
||||
onClick={() => applyRating(FeedbackRatings.dislike)}
|
||||
>
|
||||
{rating === FeedbackRatings.dislike ? (
|
||||
<ThumbDownIcon fontSize="small" />
|
||||
<Tooltip title="Disliked">
|
||||
<ThumbDownIcon fontSize="small" />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<ThumbDownOutlinedIcon fontSize="small" />
|
||||
<Tooltip title="Dislike">
|
||||
<ThumbDownOutlinedIcon fontSize="small" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</IconButton>
|
||||
<FeedbackResponseDialog
|
||||
|
||||
@@ -54,6 +54,7 @@ export class JenkinsApiImpl {
|
||||
private static readonly jobTreeSpec = `actions[*],
|
||||
${JenkinsApiImpl.lastBuildTreeSpec}
|
||||
jobs{0,1},
|
||||
url,
|
||||
name,
|
||||
fullName,
|
||||
displayName,
|
||||
@@ -64,6 +65,16 @@ export class JenkinsApiImpl {
|
||||
${JenkinsApiImpl.jobTreeSpec}
|
||||
]{0,50}`;
|
||||
|
||||
private static readonly jobBuildsTreeSpec = `
|
||||
name,
|
||||
description,
|
||||
url,
|
||||
fullName,
|
||||
displayName,
|
||||
fullDisplayName,
|
||||
inQueue,
|
||||
builds[*]`;
|
||||
|
||||
constructor(private readonly permissionApi?: PermissionEvaluator) {}
|
||||
|
||||
/**
|
||||
@@ -329,4 +340,35 @@ export class JenkinsApiImpl {
|
||||
const jobs = jobFullName.split('/');
|
||||
return `${jenkinsInfo.baseUrl}/job/${jobs.join('/job/')}/${buildId}`;
|
||||
}
|
||||
|
||||
async getJobBuilds(jenkinsInfo: JenkinsInfo, jobFullName: string) {
|
||||
let jobName = jobFullName;
|
||||
|
||||
if (jobFullName.includes('/')) {
|
||||
const arr = jobFullName.split('/');
|
||||
const multibranchJobName = arr.shift();
|
||||
jobName = [
|
||||
multibranchJobName,
|
||||
'job',
|
||||
encodeURIComponent(arr.join('/')),
|
||||
].join('/');
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${
|
||||
jenkinsInfo.baseUrl
|
||||
}/job/${jobName}/api/json?tree=${JenkinsApiImpl.jobBuildsTreeSpec.replace(
|
||||
/\s/g,
|
||||
'',
|
||||
)}`,
|
||||
{
|
||||
method: 'get',
|
||||
headers: jenkinsInfo.headers as HeaderInit,
|
||||
},
|
||||
);
|
||||
|
||||
const jobBuilds = await response.json();
|
||||
|
||||
return jobBuilds;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +151,32 @@ export async function createRouter(
|
||||
},
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/v1/entity/:namespace/:kind/:name/job/:jobFullName',
|
||||
async (request, response) => {
|
||||
const token = getBearerTokenFromAuthorizationHeader(
|
||||
request.header('authorization'),
|
||||
);
|
||||
const { namespace, kind, name, jobFullName } = request.params;
|
||||
|
||||
const jenkinsInfo = await jenkinsInfoProvider.getInstance({
|
||||
entityRef: {
|
||||
kind,
|
||||
namespace,
|
||||
name,
|
||||
},
|
||||
jobFullName,
|
||||
backstageToken: token,
|
||||
});
|
||||
|
||||
const build = await jenkinsApi.getJobBuilds(jenkinsInfo, jobFullName);
|
||||
|
||||
response.json({
|
||||
build: build,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/v1/entity/:namespace/:kind/:name/job/:jobFullName/:buildNumber',
|
||||
async (request, response) => {
|
||||
|
||||
@@ -5,7 +5,9 @@ Website: [https://jenkins.io/](https://jenkins.io/)
|
||||
<img src="./src/assets/last-master-build.png" alt="Last master build"/>
|
||||
<img src="./src/assets/folder-results.png" alt="Folder results"/>
|
||||
<img src="./src/assets/build-details.png" alt="Build details"/>
|
||||
<img src="./src/assets/jobrun-table.png" alt="Job builds records"/>
|
||||
<img src="./src/assets/dynamic-columns.png" alt="Modify Table Columns"/>
|
||||
|
||||
## Setup
|
||||
|
||||
1. If you have a standalone app (you didn't clone this repo), then do
|
||||
@@ -74,7 +76,9 @@ metadata:
|
||||
name: 'your-component'
|
||||
description: 'a description'
|
||||
annotations:
|
||||
jenkins.io/github-folder: 'folder-name/project-name'
|
||||
jenkins.io/github-folder: 'folder-name/project-name' # deprecated
|
||||
jenkins.io/job-full-name: 'folder-name/project-name' # use this instead
|
||||
|
||||
spec:
|
||||
type: service
|
||||
lifecycle: experimental
|
||||
@@ -98,6 +102,11 @@ spec:
|
||||
- No pagination support currently, limited to 50 projects - don't run this on a
|
||||
Jenkins instance with lots of builds
|
||||
|
||||
## EntityJobRunsTable
|
||||
|
||||
- View all builds of a particular job
|
||||
- shows average build time for successful builds
|
||||
|
||||
## Modify Columns of EntityJenkinsContent
|
||||
|
||||
- now you can pass down column props to show the columns/metadata as per your use case.
|
||||
|
||||
@@ -22,6 +22,9 @@ export const EntityJenkinsContent: (props: {
|
||||
columns?: TableColumn<Project>[] | undefined;
|
||||
}) => JSX_2.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export const EntityJobRunsTable: () => JSX_2.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export const EntityLatestJenkinsRunCard: (props: {
|
||||
branch: string;
|
||||
@@ -48,6 +51,13 @@ export interface JenkinsApi {
|
||||
jobFullName: string;
|
||||
buildNumber: string;
|
||||
}): Promise<Build>;
|
||||
// Warning: (ae-forgotten-export) The symbol "Job" needs to be exported by the entry point index.d.ts
|
||||
//
|
||||
// (undocumented)
|
||||
getJobBuilds(options: {
|
||||
entity: CompoundEntityRef;
|
||||
jobFullName: string;
|
||||
}): Promise<Job>;
|
||||
getProjects(options: {
|
||||
entity: CompoundEntityRef;
|
||||
filter: {
|
||||
@@ -82,6 +92,11 @@ export class JenkinsClient implements JenkinsApi {
|
||||
buildNumber: string;
|
||||
}): Promise<Build>;
|
||||
// (undocumented)
|
||||
getJobBuilds(options: {
|
||||
entity: CompoundEntityRef;
|
||||
jobFullName: string;
|
||||
}): Promise<Job>;
|
||||
// (undocumented)
|
||||
getProjects(options: {
|
||||
entity: CompoundEntityRef;
|
||||
filter: {
|
||||
|
||||
@@ -56,6 +56,30 @@ export interface Build {
|
||||
};
|
||||
status: string; // == building ? 'running' : result,
|
||||
}
|
||||
export interface JobBuild {
|
||||
timestamp: number;
|
||||
building: boolean;
|
||||
duration: number;
|
||||
result?: string;
|
||||
fullDisplayName: string;
|
||||
displayName: string;
|
||||
url: string;
|
||||
number: number;
|
||||
inProgress: boolean;
|
||||
queueId: number;
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface Job {
|
||||
name: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
fullDisplayName: string;
|
||||
inQueue: boolean;
|
||||
fullName: string;
|
||||
url: string;
|
||||
builds: JobBuild[];
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface Project {
|
||||
@@ -99,6 +123,11 @@ export interface JenkinsApi {
|
||||
buildNumber: string;
|
||||
}): Promise<Build>;
|
||||
|
||||
getJobBuilds(options: {
|
||||
entity: CompoundEntityRef;
|
||||
jobFullName: string;
|
||||
}): Promise<Job>;
|
||||
|
||||
retry(options: {
|
||||
entity: CompoundEntityRef;
|
||||
jobFullName: string;
|
||||
@@ -213,4 +242,28 @@ export class JenkinsClient implements JenkinsApi {
|
||||
const { token } = await this.identityApi.getCredentials();
|
||||
return token;
|
||||
}
|
||||
|
||||
async getJobBuilds(options: {
|
||||
entity: CompoundEntityRef;
|
||||
jobFullName: string;
|
||||
}): Promise<Job> {
|
||||
const { entity, jobFullName } = options;
|
||||
const url = `${await this.discoveryApi.getBaseUrl(
|
||||
'jenkins',
|
||||
)}/v1/entity/${encodeURIComponent(entity.namespace)}/${encodeURIComponent(
|
||||
entity.kind,
|
||||
)}/${encodeURIComponent(entity.name)}/job/${encodeURIComponent(
|
||||
jobFullName,
|
||||
)}`;
|
||||
|
||||
const idToken = await this.getToken();
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...(idToken && { Authorization: `Bearer ${idToken}` }),
|
||||
},
|
||||
});
|
||||
|
||||
return (await response.json()).build;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user