frontend-app-api: capture sign-in through identity proxy
Let prepared apps observe sign-in completion through the bootstrap identity proxy instead of overriding the sign-in page component, and surface finalization failures back through the default app root. Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com> Made-with: Cursor
This commit is contained in:
@@ -1292,357 +1292,6 @@ describe('createSpecializedApp', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should synchronously finalize feature flag predicates without sign-in', async () => {
|
||||
const featureFlagsApi = {
|
||||
isActive: jest.fn((name: string) => name === 'test-flag'),
|
||||
registerFlag: jest.fn(),
|
||||
getRegisteredFlags: () => [],
|
||||
save: jest.fn(),
|
||||
} as unknown as typeof featureFlagsApiRef.T;
|
||||
const noSignInAppPlugin = appPluginOriginal.withOverrides({
|
||||
extensions: [
|
||||
appPluginOriginal
|
||||
.getExtension('sign-in-page:app')
|
||||
.override({ disabled: true }),
|
||||
],
|
||||
});
|
||||
const preparedApp = prepareSpecializedApp({
|
||||
features: [
|
||||
noSignInAppPlugin,
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
featureFlags: [{ name: 'test-flag' }],
|
||||
extensions: [
|
||||
createExtension({
|
||||
name: 'bootstrap-element',
|
||||
attachTo: { id: 'app/root', input: 'elements' },
|
||||
output: [coreExtensionData.reactElement],
|
||||
factory: () => [
|
||||
coreExtensionData.reactElement(<div>Bootstrap Element</div>),
|
||||
],
|
||||
}),
|
||||
createExtension({
|
||||
name: 'deferred-element',
|
||||
attachTo: { id: 'app/root', input: 'elements' },
|
||||
if: { featureFlags: { $contains: 'test-flag' } },
|
||||
output: [coreExtensionData.reactElement],
|
||||
factory: () => [
|
||||
coreExtensionData.reactElement(<div>Deferred Element</div>),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: featureFlagsApiRef,
|
||||
deps: {},
|
||||
factory: () => featureFlagsApi,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
renderPreparedBootstrap(preparedApp);
|
||||
|
||||
expect(screen.getByText('Bootstrap Element')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Deferred Element')).not.toBeInTheDocument();
|
||||
|
||||
const finalizedApp = preparedApp.finalize();
|
||||
render(
|
||||
finalizedApp.tree.root.instance!.getData(
|
||||
coreExtensionData.reactElement,
|
||||
),
|
||||
);
|
||||
|
||||
expect(featureFlagsApi.isActive).toHaveBeenCalledWith('test-flag');
|
||||
await expect(
|
||||
screen.findByText('Deferred Element'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should defer conditional api roots without resetting visible api instances', () => {
|
||||
const featureFlagsApi = {
|
||||
isActive: jest.fn((name: string) => name === 'test-flag'),
|
||||
registerFlag: jest.fn(),
|
||||
getRegisteredFlags: () => [],
|
||||
save: jest.fn(),
|
||||
} as unknown as typeof featureFlagsApiRef.T;
|
||||
const visibleApiExtension = ApiBlueprint.make({
|
||||
name: 'visible-api',
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: createApiRef<{ value: string }>({ id: 'test.visible-api' }),
|
||||
deps: {},
|
||||
factory: () => ({ value: 'visible' }),
|
||||
}),
|
||||
});
|
||||
const deferredApiExtension = ApiBlueprint.make({
|
||||
name: 'deferred-api',
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: createApiRef<{ value: string }>({ id: 'test.deferred-api' }),
|
||||
deps: {},
|
||||
factory: () => ({ value: 'deferred' }),
|
||||
}),
|
||||
}).override({
|
||||
if: { featureFlags: { $contains: 'test-flag' } },
|
||||
});
|
||||
const preparedApp = prepareSpecializedApp({
|
||||
features: [
|
||||
makeAppPlugin(),
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
featureFlags: [{ name: 'test-flag' }],
|
||||
extensions: [
|
||||
visibleApiExtension,
|
||||
deferredApiExtension,
|
||||
ApiBlueprint.make({
|
||||
name: 'feature-flags',
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: featureFlagsApiRef,
|
||||
deps: {},
|
||||
factory: () => featureFlagsApi,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const bootstrapTree = preparedApp.getBootstrapApp().tree;
|
||||
const apiNodes = bootstrapTree.root.edges.attachments.get('apis') ?? [];
|
||||
const visibleApiNode = apiNodes.find(
|
||||
node => node.spec.id === 'api:test/visible-api',
|
||||
);
|
||||
const deferredApiNode = apiNodes.find(
|
||||
node => node.spec.id === 'api:test/deferred-api',
|
||||
);
|
||||
|
||||
expect(visibleApiNode?.instance).toBeDefined();
|
||||
expect(deferredApiNode?.instance).toBeUndefined();
|
||||
|
||||
const visibleApiInstance = visibleApiNode?.instance;
|
||||
preparedApp.finalize();
|
||||
|
||||
expect(visibleApiNode?.instance).toBe(visibleApiInstance);
|
||||
expect(deferredApiNode?.instance).toBeDefined();
|
||||
});
|
||||
|
||||
it('should ignore deferred overrides of materialized bootstrap APIs', () => {
|
||||
const apiRef = createApiRef<{ value: string }>({
|
||||
id: 'test.bootstrap-frozen-api',
|
||||
});
|
||||
let bootstrapApiValue: string | undefined;
|
||||
let finalApiValue: string | undefined;
|
||||
const featureFlagsApi = {
|
||||
isActive: jest.fn((name: string) => name === 'test-flag'),
|
||||
registerFlag: jest.fn(),
|
||||
getRegisteredFlags: () => [],
|
||||
save: jest.fn(),
|
||||
} as unknown as typeof featureFlagsApiRef.T;
|
||||
const noSignInAppPlugin = appPluginOriginal.withOverrides({
|
||||
extensions: [
|
||||
appPluginOriginal
|
||||
.getExtension('sign-in-page:app')
|
||||
.override({ disabled: true }),
|
||||
],
|
||||
});
|
||||
const preparedApp = prepareSpecializedApp({
|
||||
features: [
|
||||
noSignInAppPlugin,
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
featureFlags: [{ name: 'test-flag' }],
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
name: 'bootstrap-api',
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: apiRef,
|
||||
deps: {},
|
||||
factory: () => ({ value: 'bootstrap' }),
|
||||
}),
|
||||
}),
|
||||
ApiBlueprint.make({
|
||||
name: 'deferred-api',
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: apiRef,
|
||||
deps: {},
|
||||
factory: () => ({ value: 'final' }),
|
||||
}),
|
||||
}).override({
|
||||
if: { featureFlags: { $contains: 'test-flag' } },
|
||||
}),
|
||||
createExtension({
|
||||
name: 'bootstrap-reader',
|
||||
attachTo: { id: 'app/root', input: 'elements' },
|
||||
output: [coreExtensionData.reactElement],
|
||||
factory: ({ apis }) => {
|
||||
bootstrapApiValue = apis.get(apiRef)?.value;
|
||||
return [
|
||||
coreExtensionData.reactElement(<div>Bootstrap Reader</div>),
|
||||
];
|
||||
},
|
||||
}),
|
||||
createExtension({
|
||||
name: 'final-reader',
|
||||
attachTo: { id: 'app/root', input: 'elements' },
|
||||
if: { featureFlags: { $contains: 'test-flag' } },
|
||||
output: [coreExtensionData.reactElement],
|
||||
factory: ({ apis }) => {
|
||||
finalApiValue = apis.get(apiRef)?.value;
|
||||
return [
|
||||
coreExtensionData.reactElement(<div>Final Reader</div>),
|
||||
];
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: featureFlagsApiRef,
|
||||
deps: {},
|
||||
factory: () => featureFlagsApi,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
renderPreparedBootstrap(preparedApp);
|
||||
expect(bootstrapApiValue).toBe('bootstrap');
|
||||
|
||||
const finalizedApp = preparedApp.finalize();
|
||||
|
||||
expect(featureFlagsApi.isActive).toHaveBeenCalledWith('test-flag');
|
||||
expect(finalApiValue).toBe('bootstrap');
|
||||
expect(finalizedApp.errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: 'EXTENSION_BOOTSTRAP_API_OVERRIDE_IGNORED',
|
||||
context: expect.objectContaining({
|
||||
apiRefId: apiRef.id,
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow deferred overrides of bootstrap APIs that were not materialized', () => {
|
||||
const apiRef = createApiRef<{ value: string }>({
|
||||
id: 'test.bootstrap-overridable-api',
|
||||
});
|
||||
let finalApiValue: string | undefined;
|
||||
const featureFlagsApi = {
|
||||
isActive: jest.fn((name: string) => name === 'test-flag'),
|
||||
registerFlag: jest.fn(),
|
||||
getRegisteredFlags: () => [],
|
||||
save: jest.fn(),
|
||||
} as unknown as typeof featureFlagsApiRef.T;
|
||||
const noSignInAppPlugin = appPluginOriginal.withOverrides({
|
||||
extensions: [
|
||||
appPluginOriginal
|
||||
.getExtension('sign-in-page:app')
|
||||
.override({ disabled: true }),
|
||||
],
|
||||
});
|
||||
const preparedApp = prepareSpecializedApp({
|
||||
features: [
|
||||
noSignInAppPlugin,
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
featureFlags: [{ name: 'test-flag' }],
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
name: 'bootstrap-api',
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: apiRef,
|
||||
deps: {},
|
||||
factory: () => ({ value: 'bootstrap' }),
|
||||
}),
|
||||
}),
|
||||
ApiBlueprint.make({
|
||||
name: 'deferred-api',
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: apiRef,
|
||||
deps: {},
|
||||
factory: () => ({ value: 'final' }),
|
||||
}),
|
||||
}).override({
|
||||
if: { featureFlags: { $contains: 'test-flag' } },
|
||||
}),
|
||||
createExtension({
|
||||
name: 'bootstrap-element',
|
||||
attachTo: { id: 'app/root', input: 'elements' },
|
||||
output: [coreExtensionData.reactElement],
|
||||
factory: () => [
|
||||
coreExtensionData.reactElement(<div>Bootstrap Element</div>),
|
||||
],
|
||||
}),
|
||||
createExtension({
|
||||
name: 'final-reader',
|
||||
attachTo: { id: 'app/root', input: 'elements' },
|
||||
if: { featureFlags: { $contains: 'test-flag' } },
|
||||
output: [coreExtensionData.reactElement],
|
||||
factory: ({ apis }) => {
|
||||
finalApiValue = apis.get(apiRef)?.value;
|
||||
return [
|
||||
coreExtensionData.reactElement(<div>Final Reader</div>),
|
||||
];
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: featureFlagsApiRef,
|
||||
deps: {},
|
||||
factory: () => featureFlagsApi,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
renderPreparedBootstrap(preparedApp);
|
||||
expect(screen.getByText('Bootstrap Element')).toBeInTheDocument();
|
||||
|
||||
const finalizedApp = preparedApp.finalize();
|
||||
|
||||
expect(featureFlagsApi.isActive).toHaveBeenCalledWith('test-flag');
|
||||
expect(finalApiValue).toBe('final');
|
||||
expect(finalizedApp.errors ?? []).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: 'EXTENSION_BOOTSTRAP_API_OVERRIDE_IGNORED',
|
||||
context: expect.objectContaining({
|
||||
apiRefId: apiRef.id,
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should defer app root children until finalize', async () => {
|
||||
const identityApi = {
|
||||
getProfileInfo: async () => ({ displayName: 'Test User' }),
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
FrontendPluginInfo,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { ThemeBlueprint } from '@backstage/plugin-app-react';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { act, screen, waitFor } from '@testing-library/react';
|
||||
import { createApp } from './createApp';
|
||||
import { mockApis, renderWithEffects } from '@backstage/test-utils';
|
||||
import {
|
||||
@@ -229,6 +229,7 @@ describe('createApp', () => {
|
||||
getRegisteredFlags: () => [],
|
||||
save: jest.fn(),
|
||||
} as unknown as typeof featureFlagsApiRef.T;
|
||||
let onSignInSuccess: ((identity: IdentityApi) => void) | undefined;
|
||||
|
||||
const app = createApp({
|
||||
advanced: {
|
||||
@@ -252,9 +253,7 @@ describe('createApp', () => {
|
||||
function SignInPage(props: {
|
||||
onSignInSuccess(identity: IdentityApi): void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
props.onSignInSuccess(identityApi);
|
||||
}, [props]);
|
||||
onSignInSuccess = props.onSignInSuccess;
|
||||
|
||||
return <div>Custom Sign In</div>;
|
||||
}
|
||||
@@ -281,6 +280,16 @@ describe('createApp', () => {
|
||||
});
|
||||
|
||||
await renderWithEffects(app.createRoot());
|
||||
await expect(
|
||||
screen.findByText('Custom Sign In'),
|
||||
).resolves.toBeInTheDocument();
|
||||
if (!onSignInSuccess) {
|
||||
throw new Error('Expected sign-in success callback to be captured');
|
||||
}
|
||||
const triggerSignInSuccess = onSignInSuccess;
|
||||
act(() => {
|
||||
triggerSignInSuccess(identityApi);
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByText(/Error in app/),
|
||||
|
||||
@@ -152,12 +152,22 @@ function PreparedAppRoot(props: {
|
||||
const [finalizedApp, setFinalizedApp] = useState<
|
||||
FinalizedSpecializedApp | undefined
|
||||
>();
|
||||
const [bootstrapError, setBootstrapError] = useState<Error | undefined>();
|
||||
|
||||
useEffect(
|
||||
() => props.preparedApp.onFinalized(setFinalizedApp),
|
||||
() => props.preparedApp.onFinalized(setFinalizedApp, setBootstrapError),
|
||||
[props.preparedApp],
|
||||
);
|
||||
|
||||
if (bootstrapError) {
|
||||
return (
|
||||
<>
|
||||
<div>{bootstrapError.message}</div>
|
||||
<h1>Error in app</h1>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!finalizedApp) {
|
||||
return bootstrapApp.element;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user