refactor(backend-dynamic-feature-service): all-in-one feature integration tests

Signed-off-by: David Festal <dfestal@redhat.com>
This commit is contained in:
David Festal
2024-10-07 13:35:36 +02:00
parent 4c89e4759d
commit bb5b95f613
17 changed files with 531 additions and 0 deletions
@@ -76,6 +76,8 @@
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@backstage/plugin-app-backend": "workspace:^",
"triple-beam": "^1.4.1",
"wait-for-expect": "^3.0.2"
}
}
@@ -0,0 +1,2 @@
!dist
!node_modules
@@ -0,0 +1,18 @@
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"test-backend": {
"type": "object",
"required": [
"secretValue"
],
"properties": {
"secretValue": {
"type": "string",
"visibility": "secret"
}
}
}
}
}
@@ -0,0 +1,59 @@
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
const { dynamicPluginsServiceRef } = require('../../../../../manager');
var backendPluginApi = require('@backstage/backend-plugin-api');
const express = require('express');
const path = require('path');
const url = require('url');
const privateDep = require('private-dep-with-frontend-plugin-index-path');
const testPlugin = backendPluginApi.createBackendPlugin({
pluginId: "test",
register(env) {
env.registerInit({
deps: {
http: backendPluginApi.coreServices.httpRouter,
logger: backendPluginApi.coreServices.rootLogger,
discovery: backendPluginApi.coreServices.discovery,
dynamicPlugins: dynamicPluginsServiceRef,
metadata: backendPluginApi.coreServices.pluginMetadata,
},
async init({
http,
logger,
discovery,
dynamicPlugins,
metadata,
}) {
logger.info("This secret value should be hidden by the dynamic-plugin-aware logger: AVerySecretValue");
const externalBaseUrl = await discovery.getExternalBaseUrl(metadata.getId());
const router = express.Router();
const frontendPluginsIndexPath = privateDep.frontendPluginsIndexPath;
const frontendPluginManifests = Object.fromEntries(dynamicPlugins.frontendPlugins().map(fp => {
const pluginScannedPackage = dynamicPlugins.getScannedPackage(fp);
const pkgDistLocation = path.resolve(
url.fileURLToPath(pluginScannedPackage.location),
'dist',
);
router.use(`/${frontendPluginsIndexPath}/${fp.name}`, express.static(pkgDistLocation))
return [fp.name, `${externalBaseUrl}/${frontendPluginsIndexPath}/${fp.name}/mf-manifest.json`]
}));
router.get(`/${frontendPluginsIndexPath}`, (req, res) => {
res.status(200).json(frontendPluginManifests);
});
http.use(router);
http.addAuthPolicy({
path: `/`,
allow: 'unauthenticated',
});
}
});
}
});
exports.default = testPlugin;
//# sourceMappingURL=alpha.cjs.js.map
@@ -0,0 +1,7 @@
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
const frontendPluginsIndexPath = 'frontend-plugins';
exports.frontendPluginsIndexPath = frontendPluginsIndexPath;
@@ -0,0 +1,7 @@
{
"name": "private-dep-with-frontend-plugin-index-path",
"version": "0.0.0",
"description": "private dependency of the test backend plugin",
"main": "index.js",
"dependencies": {}
}
@@ -0,0 +1,35 @@
{
"name": "plugin-test-backend-dynamic",
"version": "0.0.0",
"description": "A test dynamic backend module that exposes the dynamic frontend plugins to an endpoint.",
"backstage": {
"role": "backend-plugin",
"pluginId": "test",
"pluginPackages": [
"plugin-test",
"plugin-test-backend"
]
},
"publishConfig": {
"access": "public"
},
"keywords": [
"backstage",
"dynamic"
],
"exports": {
".": {
"require": "./dist/index.cjs.js",
"default": "./dist/index.cjs.js"
},
"./package.json": "./package.json"
},
"main": "./dist/index.cjs.js",
"files": [
"dist"
],
"dependencies": {
"private-dep-with-frontend-plugin-index-path": "0.0.0"
},
"bundleDependencies": true
}
@@ -0,0 +1,18 @@
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"test-frontend": {
"type": "object",
"required": [
"frontendValue"
],
"properties": {
"frontendValue": {
"type": "string",
"visibility": "frontend"
}
}
}
}
}
@@ -0,0 +1,29 @@
{
"id": "backstage__plugin_test",
"name": "backstage__plugin_test",
"metaData": {
"name": "backstage__plugin_test",
"type": "app",
"buildInfo": {
"buildVersion": "0.0.0",
"buildName": "@backstage/plugin-test"
},
"remoteEntry": {
"name": "remoteEntry.js",
"path": "",
"type": "global"
},
"types": {
"path": "",
"name": "",
"zip": "",
"api": ""
},
"globalName": "backstage__plugin_test",
"pluginVersion": "0.0.0",
"publicPath": "auto"
},
"shared": [],
"remotes": [],
"exposes": []
}
@@ -0,0 +1,17 @@
/*
* 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.
*/
(function doNothing(){})();
@@ -0,0 +1,20 @@
{
"name": "plugin-test-dynamic",
"version": "0.0.0",
"description": "A test dynamic, module-federation-based, Backstage frontend plugin that does nothing",
"backstage": {
"role": "frontend-dynamic-container",
"pluginId": "test",
"pluginPackages": [
"plugin-test"
]
},
"publishConfig": {
"access": "public"
},
"keywords": [
"backstage",
"dynamic"
],
"main": "remote-entry.js"
}
@@ -0,0 +1 @@
throw new Error("False @backstage/backend-plugin-api package which should be skipped by the CommonJSModuleLoader");
@@ -0,0 +1,7 @@
{
"name": "@backstage/backend-plugin-api",
"version": "0.0.0",
"description": "dummy backstage package that should be skipped by the ComonJSLoduleLoader",
"main": "index.js",
"dependencies": {}
}
@@ -0,0 +1,306 @@
/*
* 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 {
startTestBackend,
mockServices,
createMockDirectory,
} from '@backstage/backend-test-utils';
import { dynamicPluginsFeatureLoader } from './features';
import { DynamicPlugin, dynamicPluginsServiceRef } from '../manager';
import path, { resolve as resolvePath } from 'path';
import {
BackendFeature,
createBackendPlugin,
LoggerService,
} from '@backstage/backend-plugin-api';
import { CommonJSModuleLoader } from '../loader/CommonJSModuleLoader';
import * as winston from 'winston';
import { MESSAGE } from 'triple-beam';
import { overridePackagePathResolution } from '@backstage/backend-plugin-api/testUtils';
async function jestFreeTypescriptAwareModuleLoader(
logger: LoggerService,
dontBootstrap: boolean = false,
) {
const loader = new CommonJSModuleLoader(logger);
(loader as any).module = await loader.load('node:module');
loader.load(path.resolve(__dirname, '../../../cli/config/nodeTransform.cjs'));
if (dontBootstrap) {
loader.bootstrap = async () => {};
}
return loader;
}
class MockedTransport extends winston.transports.Console {
readonly logs: string[] = [];
public log(info: any, callback: () => void) {
if (!info[MESSAGE]?.includes('info: Plugin initialization ')) {
this.logs.push(info[MESSAGE]);
}
super.log!(info, callback);
}
public logv(info: any, callback: () => void) {
if (!info[MESSAGE]?.includes('info: Plugin initialization ')) {
this.logs.push(info[MESSAGE]);
}
super.log!(info, callback);
}
}
class DynamicPluginLister {
readonly loadedPlugins: DynamicPlugin[] = [];
feature(): BackendFeature {
// eslint-disable-next-line consistent-this
const that = this;
return createBackendPlugin({
pluginId: 'dynamicPluginsLister',
register(reg) {
reg.registerInit({
deps: {
dynamicPlugins: dynamicPluginsServiceRef,
},
async init({ dynamicPlugins }) {
that.loadedPlugins.push(...dynamicPlugins.plugins(true));
},
});
},
});
}
}
describe('dynamicPluginsFeatureLoader', () => {
const dynamicPluginsRootDirectory = resolvePath(
__dirname,
'__fixtures__/dynamic-plugins-root',
);
// A dummy `@backstage/backend-plugin-api` package which throws an error is available inside the test fixtures,
// in a `node_modules` folder which is a sibling of the `dynamic-plugins-root`.
// This test demonstrates how, without the skipping logic implemented in the {@link CommonJSModelLoader},
// this dummy package would be loaded by the backend dynamic plugins instead of the one of the backstage root.
it('should fail because the model loader is not skipping modules living in unexpected locations.', async () => {
const dynamicPLuginsLister = new DynamicPluginLister();
const mockedTransport = new MockedTransport();
await startTestBackend({
features: [
mockServices.rootConfig.factory({
data: {
dynamicPlugins: {
rootDirectory: dynamicPluginsRootDirectory,
},
},
}),
dynamicPluginsFeatureLoader({
moduleLoader: logger =>
jestFreeTypescriptAwareModuleLoader(logger, true),
transports: [mockedTransport],
format: winston.format.simple(),
}),
dynamicPLuginsLister.feature(),
],
});
expect(mockedTransport.logs).toContainEqual(
expect.stringMatching(
"error: an error occurred while loading dynamic backend plugin 'plugin-test-backend-dynamic' from '.*/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic",
),
);
expect(dynamicPLuginsLister.loadedPlugins).toMatchObject([
{
name: 'plugin-test-backend-dynamic',
platform: 'node',
role: 'backend-plugin',
version: '0.0.0',
failure:
'Error: False @backstage/backend-plugin-api package which should be skipped by the CommonJSModuleLoader',
},
expect.anything(),
]);
});
it('should load and show the 2 dynamic plugins in a list of dynamic plugins returned by a static backend plugin', async () => {
const dynamicPLuginsLister = new DynamicPluginLister();
await startTestBackend({
features: [
mockServices.rootConfig.factory({
data: {
dynamicPlugins: {
rootDirectory: dynamicPluginsRootDirectory,
},
},
}),
dynamicPluginsFeatureLoader({
moduleLoader: jestFreeTypescriptAwareModuleLoader,
}),
dynamicPLuginsLister.feature(),
],
});
expect(dynamicPLuginsLister.loadedPlugins).toMatchObject([
{
installer: {
kind: 'new',
},
name: 'plugin-test-backend-dynamic',
platform: 'node',
role: 'backend-plugin',
version: '0.0.0',
},
{
name: 'plugin-test-dynamic',
platform: 'web',
role: 'frontend-dynamic-container',
version: '0.0.0',
},
]);
});
it('should redact the secret config values of dynamic plugin config schemas in logs', async () => {
const mockedTransport = new MockedTransport();
await startTestBackend({
features: [
mockServices.rootConfig.factory({
data: {
dynamicPlugins: {
rootDirectory: dynamicPluginsRootDirectory,
},
'test-backend': {
secretValue: 'AVerySecretValue',
},
},
}),
dynamicPluginsFeatureLoader({
moduleLoader: jestFreeTypescriptAwareModuleLoader,
transports: [mockedTransport],
format: winston.format.simple(),
}),
],
});
expect(mockedTransport.logs).toContainEqual(
'info: Found 1 new secrets in config that will be redacted {"service":"backstage"}',
);
expect(mockedTransport.logs).toContainEqual(
'info: This secret value should be hidden by the dynamic-plugin-aware logger: *** {"service":"backstage"}',
);
});
const mockAppDir = createMockDirectory();
overridePackagePathResolution({
packageName: 'app',
path: mockAppDir.path,
});
it('should inject frontend config values of dynamic frontend plugin config schemas to the frontend application', async () => {
mockAppDir.setContent({
'package.json': '{}',
dist: {
static: {},
'index.html.tmpl': '<head></head>',
'.config-schema.json': `
{
"backstageConfigSchemaVersion": 1,
"schemas": []
}
`,
},
});
const { server } = await startTestBackend({
features: [
mockServices.rootConfig.factory({
data: {
dynamicPlugins: {
rootDirectory: dynamicPluginsRootDirectory,
},
'test-frontend': {
frontendValue: 'AFrontendValue',
},
},
}),
dynamicPluginsFeatureLoader({
moduleLoader: jestFreeTypescriptAwareModuleLoader,
}),
import('@backstage/plugin-app-backend/alpha'),
],
});
await expect(
fetch(`http://localhost:${server.port()}`).then(res => res.text()),
).resolves.toBe(`<head>
<script type="backstage.io/config">
[
{
"context": "app",
"deprecatedKeys": [],
"data": {
"test-frontend": {
"frontendValue": "AFrontendValue"
}
}
}
]
</script>
</head>`);
});
it('should access the module federation assets of the frontend plugin through the backend plugin', async () => {
const { server } = await startTestBackend({
features: [
mockServices.rootConfig.factory({
data: {
dynamicPlugins: {
rootDirectory: dynamicPluginsRootDirectory,
},
},
}),
dynamicPluginsFeatureLoader({
moduleLoader: jestFreeTypescriptAwareModuleLoader,
}),
],
});
const list = await fetch(
`http://localhost:${server.port()}/api/test/frontend-plugins`,
);
expect(list.ok).toBe(true);
expect(await list.json()).toEqual({
'plugin-test-dynamic': `http://localhost:${server.port()}/api/test/frontend-plugins/plugin-test-dynamic/mf-manifest.json`,
});
const manifest = await fetch(
`http://localhost:${server.port()}/api/test/frontend-plugins/plugin-test-dynamic/mf-manifest.json`,
);
expect(manifest.ok).toBe(true);
expect(await manifest.json()).toMatchObject({
exposes: [],
id: 'backstage__plugin_test',
name: 'backstage__plugin_test',
metaData: {
buildInfo: {
buildName: '@backstage/plugin-test',
buildVersion: '0.0.0',
},
globalName: 'backstage__plugin_test',
name: 'backstage__plugin_test',
pluginVersion: '0.0.0',
publicPath: 'auto',
},
});
});
});
@@ -44,6 +44,7 @@ export const dynamicPluginsFrontendSchemas = createBackendModule({
config.getOptionalString('app.packageName') ?? 'app';
const appDistDir = resolvePackagePath(appPackageName, 'dist');
const compiledConfigSchema = await loadCompiledConfigSchema(appDistDir);
// TODO(davidfestal): Add dynamic pliugin config schemas even if the compiled schemas are empty.
if (compiledConfigSchema) {
configSchemaExtension.setConfigSchema(
(await schemas.addDynamicPluginsSchemas(compiledConfigSchema))
+2
View File
@@ -3712,6 +3712,7 @@ __metadata:
"@backstage/config": "workspace:^"
"@backstage/config-loader": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/plugin-app-backend": "workspace:^"
"@backstage/plugin-app-node": "workspace:^"
"@backstage/plugin-auth-node": "workspace:^"
"@backstage/plugin-catalog-backend": "workspace:^"
@@ -3729,6 +3730,7 @@ __metadata:
express: ^4.17.1
fs-extra: ^11.2.0
lodash: ^4.17.21
triple-beam: ^1.4.1
wait-for-expect: ^3.0.2
winston: ^3.2.1
languageName: unknown