Add extension snapshot testing support to frontend-test-utils

Adds the snapshot() method to ExtensionTester, enabling snapshot
testing of extension tree structures. The snapshots use a tree-shaped
format that mirrors the extension hierarchy, with empty fields and
default values omitted for clarity.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2026-02-02 01:30:22 +01:00
parent a5e536d899
commit 15ed3f9ccb
7 changed files with 297 additions and 2 deletions
@@ -188,4 +188,161 @@ describe('createExtensionTester', () => {
}),
);
});
describe('snapshot', () => {
it('should return a snapshot of the extension tree', () => {
const extension = createExtension({
name: 'root',
attachTo: { id: 'ignored', input: 'ignored' },
output: [stringDataRef],
factory: () => [stringDataRef('test-text')],
});
const tester = createExtensionTester(extension);
expect(tester.snapshot()).toMatchInlineSnapshot(`
{
"id": "root",
"outputs": [
"test.string",
],
}
`);
});
it('should include child extensions in the tree', () => {
const childInput = createExtensionInput([stringDataRef]);
const rootExtension = createExtension({
name: 'root',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
children: childInput,
},
output: [coreExtensionData.reactElement],
factory: () => [coreExtensionData.reactElement(<div>root</div>)],
});
const childExtension = createExtension({
name: 'child',
attachTo: { id: 'root', input: 'children' },
output: [stringDataRef],
factory: () => [stringDataRef('child-data')],
});
const tester = createExtensionTester(rootExtension).add(childExtension);
expect(tester.snapshot()).toMatchInlineSnapshot(`
{
"children": {
"children": [
{
"id": "child",
"outputs": [
"test.string",
],
},
],
},
"id": "root",
"outputs": [
"core.reactElement",
],
}
`);
});
it('should include multiple children in sorted order', () => {
const childInput = createExtensionInput([stringDataRef]);
const rootExtension = createExtension({
name: 'root',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
children: childInput,
},
output: [coreExtensionData.reactElement],
factory: () => [coreExtensionData.reactElement(<div>root</div>)],
});
const child1 = createExtension({
name: 'child1',
attachTo: { id: 'root', input: 'children' },
output: [stringDataRef],
factory: () => [stringDataRef('child1-data')],
});
const child2 = createExtension({
name: 'child2',
attachTo: { id: 'root', input: 'children' },
output: [stringDataRef],
factory: () => [stringDataRef('child2-data')],
});
const tester = createExtensionTester(rootExtension)
.add(child1)
.add(child2);
expect(tester.snapshot()).toMatchInlineSnapshot(`
{
"children": {
"children": [
{
"id": "child1",
"outputs": [
"test.string",
],
},
{
"id": "child2",
"outputs": [
"test.string",
],
},
],
},
"id": "root",
"outputs": [
"core.reactElement",
],
}
`);
});
it('should omit empty children and outputs', () => {
const extension = createExtension({
name: 'root',
attachTo: { id: 'ignored', input: 'ignored' },
output: [],
factory: () => [],
});
const tester = createExtensionTester(extension);
expect(tester.snapshot()).toMatchInlineSnapshot(`
{
"id": "root",
}
`);
});
it('should produce serializable snapshot data', () => {
const extension = createExtension({
name: 'root',
attachTo: { id: 'ignored', input: 'ignored' },
output: [stringDataRef],
factory: () => [stringDataRef('test-text')],
});
const tester = createExtensionTester(extension);
const snapshot = tester.snapshot();
expect(snapshot).toEqual({
id: 'root',
outputs: ['test.string'],
});
expect(JSON.parse(JSON.stringify(snapshot))).toEqual(snapshot);
});
});
});
@@ -40,6 +40,22 @@ import { createErrorCollector } from '../../../frontend-app-api/src/wiring/creat
import { OpaqueExtensionDefinition } from '@internal/frontend';
import { TestApiRegistry, type TestApiPairs } from '../utils';
/**
* Represents a snapshot of an extension in the app tree.
*
* @public
*/
export interface ExtensionSnapshotNode {
/** The ID of the extension */
id: string;
/** The IDs of output data refs produced by this extension */
outputs?: string[];
/** Child extensions organized by input name */
children?: Record<string, ExtensionSnapshotNode[]>;
/** Whether this extension is disabled */
disabled?: true;
}
/** @public */
export class ExtensionQuery<UOutput extends ExtensionDataRef> {
#node: AppNode;
@@ -192,6 +208,54 @@ export class ExtensionTester<UOutput extends ExtensionDataRef> {
return element;
}
/**
* Returns a snapshot of the extension tree structure for testing and debugging.
* Convenient to use with Jest's inline snapshot testing.
*
* @example
* ```tsx
* const tester = createExtensionTester(myExtension);
* expect(tester.snapshot()).toMatchInlineSnapshot();
* ```
*/
snapshot(): ExtensionSnapshotNode {
const tree = this.#resolveTree();
const buildNode = (node: AppNode): ExtensionSnapshotNode => {
const outputs = node.instance
? Array.from(node.instance.getDataRefs())
.map(ref => ref.id)
.sort()
: [];
const children: Record<string, ExtensionSnapshotNode[]> = {};
for (const [inputName, attachedNodes] of node.edges.attachments) {
children[inputName] = attachedNodes
.map(n => buildNode(n))
.sort((a, b) => a.id.localeCompare(b.id));
}
const result: ExtensionSnapshotNode = {
id: node.spec.id,
};
// Only include non-empty/non-default fields
if (outputs.length > 0) {
result.outputs = outputs;
}
if (Object.keys(children).length > 0) {
result.children = children;
}
if (node.spec.disabled) {
result.disabled = true;
}
return result;
};
return buildNode(tree.root);
}
#resolveTree() {
if (this.#tree) {
return this.#tree;
@@ -18,6 +18,7 @@ export {
createExtensionTester,
type ExtensionTester,
type ExtensionQuery,
type ExtensionSnapshotNode,
} from './createExtensionTester';
export { renderInTestApp, type TestAppOptions } from './renderInTestApp';