diff --git a/.changeset/healthy-laws-divide.md b/.changeset/healthy-laws-divide.md
new file mode 100644
index 0000000000..4ab29e6426
--- /dev/null
+++ b/.changeset/healthy-laws-divide.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-app-api': patch
+---
+
+Implement `toString()` and `toJSON()` for extension instances.
diff --git a/packages/frontend-app-api/src/wiring/createApp.test.tsx b/packages/frontend-app-api/src/wiring/createApp.test.tsx
index 14e52af469..abd0fe9b00 100644
--- a/packages/frontend-app-api/src/wiring/createApp.test.tsx
+++ b/packages/frontend-app-api/src/wiring/createApp.test.tsx
@@ -25,6 +25,7 @@ import { screen } from '@testing-library/react';
import { MockConfigApi, renderWithEffects } from '@backstage/test-utils';
import React from 'react';
import { createRouteRef } from '@backstage/core-plugin-api';
+import { createExtensionInstance } from './createExtensionInstance';
describe('createInstances', () => {
it('throws an error when a root extension is parametrized', () => {
@@ -132,4 +133,112 @@ describe('createApp', () => {
await expect(screen.findByText('Derp')).resolves.toBeInTheDocument();
});
+
+ it('should log an app', () => {
+ const { rootInstances } = createInstances({
+ config: new MockConfigApi({}),
+ plugins: [],
+ });
+ const root = createExtensionInstance({
+ extension: createExtension({
+ id: 'root',
+ attachTo: { id: '', input: '' },
+ output: {},
+ factory() {},
+ }),
+ config: undefined,
+ attachments: new Map([['children', rootInstances]]),
+ });
+
+ expect(String(root)).toMatchInlineSnapshot(`
+ "
+ children [
+
+ themes [
+
+
+ ]
+
+
+ content [
+
+ ]
+ nav [
+
+ ]
+
+ ]
+ "
+ `);
+ });
+
+ it('should serialize an app as JSON', () => {
+ const { rootInstances } = createInstances({
+ config: new MockConfigApi({}),
+ plugins: [],
+ });
+ const root = createExtensionInstance({
+ extension: createExtension({
+ id: 'root',
+ attachTo: { id: '', input: '' },
+ output: {},
+ factory() {},
+ }),
+ config: undefined,
+ attachments: new Map([['children', rootInstances]]),
+ });
+
+ expect(JSON.parse(JSON.stringify(root))).toMatchInlineSnapshot(`
+ {
+ "attachments": {
+ "children": [
+ {
+ "attachments": {
+ "themes": [
+ {
+ "id": "themes.light",
+ "output": [
+ "core.theme",
+ ],
+ },
+ {
+ "id": "themes.dark",
+ "output": [
+ "core.theme",
+ ],
+ },
+ ],
+ },
+ "id": "core",
+ },
+ {
+ "attachments": {
+ "content": [
+ {
+ "id": "core.routes",
+ "output": [
+ "core.reactElement",
+ ],
+ },
+ ],
+ "nav": [
+ {
+ "id": "core.nav",
+ "output": [
+ "core.reactElement",
+ ],
+ },
+ ],
+ },
+ "id": "core.layout",
+ "output": [
+ "core.reactElement",
+ ],
+ },
+ ],
+ },
+ "id": "root",
+ }
+ `);
+ });
});
diff --git a/packages/frontend-app-api/src/wiring/createExtensionInstance.ts b/packages/frontend-app-api/src/wiring/createExtensionInstance.ts
index 18551eb8da..9aec3078bc 100644
--- a/packages/frontend-app-api/src/wiring/createExtensionInstance.ts
+++ b/packages/frontend-app-api/src/wiring/createExtensionInstance.ts
@@ -90,6 +90,68 @@ function resolveInputs(
});
}
+function indent(str: string) {
+ return str.replace(/^/gm, ' ');
+}
+
+class ExtensionInstanceImpl implements ExtensionInstance {
+ readonly $$type = '@backstage/ExtensionInstance';
+
+ readonly id: string;
+ readonly #extensionData: Map;
+ readonly attachments: Map;
+ readonly source?: BackstagePlugin;
+
+ constructor(
+ id: string,
+ extensionData: Map,
+ attachments: Map,
+ source: BackstagePlugin | undefined,
+ ) {
+ this.id = id;
+ this.#extensionData = extensionData;
+ this.attachments = attachments;
+ this.source = source;
+ }
+
+ getData(ref: ExtensionDataRef): T | undefined {
+ return this.#extensionData.get(ref.id) as T | undefined;
+ }
+
+ toJSON() {
+ return {
+ id: this.id,
+ output:
+ this.#extensionData.size > 0
+ ? [...this.#extensionData.keys()]
+ : undefined,
+ attachments:
+ this.attachments.size > 0
+ ? Object.fromEntries(this.attachments)
+ : undefined,
+ };
+ }
+
+ toString() {
+ const out =
+ this.#extensionData.size > 0
+ ? ` out=[${[...this.#extensionData.keys()].join(', ')}]`
+ : '';
+
+ if (this.attachments.size === 0) {
+ return `<${this.id}${out} />`;
+ }
+
+ return [
+ `<${this.id}${out}>`,
+ ...[...this.attachments.entries()].map(([k, v]) =>
+ indent([`${k} [`, ...v.map(e => indent(e.toString())), `]`].join('\n')),
+ ),
+ `${this.id}>`,
+ ].join('\n');
+ }
+}
+
/** @internal */
export function createExtensionInstance(options: {
extension: Extension;
@@ -137,13 +199,10 @@ export function createExtensionInstance(options: {
);
}
- return {
- $$type: '@backstage/ExtensionInstance',
- id: options.extension.id,
- getData(ref: ExtensionDataRef): T | undefined {
- return extensionData.get(ref.id) as T | undefined;
- },
- source,
+ return new ExtensionInstanceImpl(
+ options.extension.id,
+ extensionData,
attachments,
- };
+ source,
+ );
}