@eclipse-emfcloud/model-manager
Version:
Command-based model editing with undo/redo.
1,099 lines (908 loc) • 31.1 kB
text/typescript
// *****************************************************************************
// Copyright (C) 2023-2024 STMicroelectronics.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: MIT License which is
// available at https://opensource.org/licenses/MIT.
//
// SPDX-License-Identifier: EPL-2.0 OR MIT
// *****************************************************************************
import chai, { expect } from 'chai';
import chaiAsPromised from 'chai-as-promised';
import sinon from 'sinon';
import sinonChai from 'sinon-chai';
import {
CanExecutePredicate,
ModelPatchFunction,
ModelUpdater,
MultiPatchCommand,
PatchCommand,
createModelPatchCommand,
createModelUpdaterCommand,
createModelUpdaterCommandWithResult,
} from '../patch-command';
import { fail } from 'assert';
import chaiLike from 'chai-like';
import { Operation } from 'fast-json-patch';
import cloneDeep from 'lodash/cloneDeep';
import { groupByModelId } from '../../core';
chai.use(chaiLike);
chai.use(chaiAsPromised);
chai.use(sinonChai);
describe('PatchCommand', () => {
let sandbox: sinon.SinonSandbox;
beforeEach(() => {
sandbox = sinon.createSandbox();
});
afterEach(() => {
sandbox.restore();
});
it('Simple execute/undo/redo', async () => {
const patch: Operation[] = [
{
op: 'replace',
path: '/id',
value: 'updated-test-id',
} as Operation,
];
const document = cloneDeep(testDocument);
const expectedDocument = cloneDeep(testDocument);
expectedDocument.id = 'updated-test-id';
const command = new PatchCommand('Test Command', 'document', patch);
// Execute
await expect(command.canExecute(document)).to.eventually.be.true;
const executeResult = await command.execute(document);
expect(document).to.deep.equal(expectedDocument);
expect(executeResult).to.not.be.undefined;
if (executeResult) {
// Should contain a single 'replace' operation (and maybe some optional 'test' operations)
const operations = executeResult.filter(
(operation) => operation.op !== 'test'
);
expect(operations).to.be.an('array').of.length(1);
expect(operations[0].op).to.equal('replace');
}
// Undo
expect(command.canUndo(document)).to.be.true;
command.undo(document);
expect(document).to.deep.equal(testDocument);
// Redo
expect(command.canRedo(document)).to.be.true;
command.redo(document);
expect(document).to.deep.equal(expectedDocument);
});
it('Invalid patch', async () => {
const invalidPatch: Operation[] = [
{
op: 'remove',
path: '/wrong-id',
},
];
const document = cloneDeep(testDocument);
const expectedDocument = cloneDeep(testDocument);
const invalidCommand = new PatchCommand(
'Test Command',
'document',
invalidPatch
);
// Currently, we don't validate commands, so canExecute is always true
await expect(invalidCommand.canExecute(document)).to.eventually.be.true;
const executeResult = await invalidCommand.execute(document);
expect(document).to.deep.equal(expectedDocument);
expect(executeResult).to.not.be.undefined;
expect(executeResult).to.be.an('array').empty;
});
it('Command lifecycle', async () => {
const patch: Operation[] = [
{
op: 'replace',
path: '/id',
value: 'updated-test-id',
} as Operation,
];
const document = cloneDeep(testDocument);
const command = new PatchCommand('Test Command', 'document', patch);
await expect(command.canExecute(document)).to.eventually.be.true;
expect(command.canUndo(document)).to.be.false;
expect(command.canRedo(document)).to.be.false;
await command.execute(document);
await expect(command.canExecute(document)).to.eventually.be.false;
expect(command.canUndo(document)).to.be.true;
expect(command.canRedo(document)).to.be.false;
command.undo(document);
await expect(command.canExecute(document)).to.eventually.be.false;
expect(command.canUndo(document)).to.be.false;
expect(command.canRedo(document)).to.be.true;
command.redo(document);
await expect(command.canExecute(document)).to.eventually.be.false;
expect(command.canUndo(document)).to.be.true;
expect(command.canRedo(document)).to.be.false;
});
it('Command lifecycle (Invalid)', async () => {
const patch: Operation[] = [
{
op: 'replace',
path: '/id',
value: 'updated-test-id',
} as Operation,
];
const document = cloneDeep(testDocument);
const command = new PatchCommand('Test Command', 'document', patch);
// Undo command before it is executed: throws an error
let success = false;
try {
command.undo(document);
success = true;
} catch (error) {
// Okay
}
if (success) {
fail('Command should not be undoable');
}
// Redo command before it is executed: throws an error
success = false;
try {
command.redo(document);
success = true;
} catch (error) {
// Okay
}
if (success) {
fail('Command should not be redoable');
}
await command.execute(document);
// Execute command twice: throws an error
success = false;
try {
await command.execute(document);
success = true;
} catch (error) {
// Okay
}
if (success) {
fail('Command should not be executable');
}
// Redo command before it is undone: throws an error
success = false;
try {
command.redo(document);
success = true;
} catch (error) {
// Okay
}
if (success) {
fail('Command should not be redoable');
}
command.undo(document);
// Execute command twice: throws an error (even after undo)
success = false;
try {
await command.execute(document);
success = true;
} catch (error) {
// Okay
}
if (success) {
fail('Command should not be executable');
}
// Undo command twice: throws an error
success = false;
try {
command.undo(document);
success = true;
} catch (error) {
// Okay
}
if (success) {
fail('Command should not be undoable');
}
});
describe('MultiPatchCommand', () => {
it('1 Sub command', async () => {
const patch: Operation[] = [
{
op: 'replace',
path: '/id',
value: 'updated-test-id',
} as Operation,
];
const document = cloneDeep(testDocument);
const expectedDocument = cloneDeep(testDocument);
expectedDocument.id = 'updated-test-id';
const command = new MultiPatchCommand('Test Multi Command', {
modelId: 'test',
patch,
});
const getModel = (modelId: string) =>
modelId == 'test' ? document : undefined;
// Execute
await expect(command.canExecute(getModel)).to.eventually.be.true;
const executeResult = await command.execute(getModel);
expect(document).to.deep.equal(expectedDocument);
expect(executeResult).to.not.be.undefined;
if (executeResult) {
const modelToPatch = groupByModelId(executeResult);
expect(modelToPatch.size).to.equal(1);
const resultPatch = modelToPatch.get('test');
expect(resultPatch).to.not.be.undefined;
if (resultPatch !== undefined) {
const operations = resultPatch.filter(
(operation) => operation.op !== 'test'
);
// Should contain a single 'replace' operation (and maybe some optional 'test' operations)
expect(operations).to.be.an('array').of.length(1);
expect(operations[0].op).to.equal('replace');
}
}
// Undo
await expect(command.canUndo(getModel)).to.eventually.be.true;
await command.undo(getModel);
expect(document).to.deep.equal(testDocument);
// Redo
await expect(command.canRedo(getModel)).to.eventually.be.true;
await command.redo(getModel);
expect(document).to.deep.equal(expectedDocument);
});
it('2 Sub commands', async () => {
const patch1: Operation[] = [
{
op: 'replace',
path: '/id',
value: 'updated-test-id',
} as Operation,
];
const patch2: Operation[] = [
{
op: 'add',
path: '/children/-',
value: {
type: 'child-node',
value: 890,
valid: false,
},
} as Operation,
];
const document1 = cloneDeep(testDocument);
const document2 = cloneDeep(secondTestDocument);
const expectedDocument1 = cloneDeep(testDocument);
expectedDocument1.id = 'updated-test-id';
const expectedDocument2 = cloneDeep(secondTestDocument);
expectedDocument2.children.push({
type: 'child-node',
value: 890,
valid: false,
});
const command = new MultiPatchCommand(
'Test Multi Command',
{
modelId: 'document1',
patch: patch1,
},
{
modelId: 'document2',
patch: patch2,
}
);
const getModel = (modelId: string) => {
switch (modelId) {
case 'document1':
return document1;
case 'document2':
return document2;
default:
return undefined;
}
};
// Execute
// PatchCommand is synchronous, so MultiPatchCommand is, too
await expect(command.canExecute(getModel)).to.eventually.be.true;
const executeResult = await command.execute(getModel);
expect(document1).to.deep.equal(expectedDocument1);
expect(document2).to.deep.equal(expectedDocument2);
expect(executeResult).to.not.be.undefined;
if (executeResult) {
const modelToPatch = groupByModelId(executeResult);
expect(modelToPatch.size).to.equal(2);
const resultPatch1 = modelToPatch.get('document1');
expect(resultPatch1).to.not.be.undefined;
if (resultPatch1 !== undefined) {
const operations = resultPatch1.filter(
(operation) => operation.op !== 'test'
);
// Should contain a single 'replace' operation (and maybe some optional 'test' operations)
expect(operations).to.be.an('array').of.length(1);
expect(operations[0].op).to.equal('replace');
}
const resultPatch2 = modelToPatch.get('document2');
expect(resultPatch2).to.not.be.undefined;
if (resultPatch2 !== undefined) {
const operations = resultPatch2.filter(
(operation) => operation.op !== 'test'
);
// Should contain a single 'replace' operation (and maybe some optional 'test' operations)
expect(operations).to.be.an('array').of.length(1);
expect(operations[0].op).to.equal('add');
}
}
// Undo
await expect(command.canUndo(getModel)).to.eventually.be.true;
await command.undo(getModel);
expect(document1).to.deep.equal(testDocument);
expect(document2).to.deep.equal(secondTestDocument);
// Redo
await expect(command.canRedo(getModel)).to.eventually.be.true;
await command.redo(getModel);
expect(document1).to.deep.equal(expectedDocument1);
expect(document2).to.deep.equal(expectedDocument2);
});
it('2 Sub commands - 1 model', async () => {
const patch1: Operation[] = [
{
op: 'replace',
path: '/id',
value: 'updated-test-id',
} as Operation,
];
const patch2: Operation[] = [
{
op: 'add',
path: '/children/-',
value: {
type: 'child-node',
value: 90,
valid: true,
},
} as Operation,
];
const document = cloneDeep(testDocument);
const expectedDocument = cloneDeep(testDocument);
expectedDocument.id = 'updated-test-id';
expectedDocument.children.push({
type: 'child-node',
value: 90,
valid: true,
});
const command = new MultiPatchCommand(
'Test Multi Command',
{
modelId: 'document',
patch: patch1,
},
{
modelId: 'document',
patch: patch2,
}
);
const getModel = (modelId: string) =>
modelId === 'document' ? document : undefined;
// Execute
await expect(command.canExecute(getModel)).to.eventually.be.true;
const executeResult = await command.execute(getModel);
expect(document).to.deep.equal(expectedDocument);
expect(executeResult).to.not.be.undefined;
if (executeResult) {
const modelToPatch = groupByModelId(executeResult);
expect(modelToPatch.size).to.equal(1);
const resultPatch = modelToPatch.get('document');
expect(resultPatch).to.not.be.undefined;
if (resultPatch !== undefined) {
const operations = resultPatch.filter(
(operation) => operation.op !== 'test'
);
// Should contain one 'replace' operation and one 'add' operation
// (and maybe some optional 'test' operations)
expect(operations).to.be.an('array').of.length(2);
expect(operations[0].op).to.equal('replace');
expect(operations[1].op).to.equal('add');
}
}
// Undo
await expect(command.canUndo(getModel)).to.eventually.be.true;
await command.undo(getModel);
expect(document).to.deep.equal(testDocument);
// Redo
await expect(command.canRedo(getModel)).to.eventually.be.true;
await command.redo(getModel);
expect(document).to.deep.equal(expectedDocument);
});
});
describe('createModelUpdaterCommand', () => {
it('Simple execute/undo/redo', async () => {
const updater: ModelUpdater<string, typeof testDocument> = (
workingCopy,
modelId
) => {
expect(modelId).to.be.equal('document');
workingCopy.id = 'updated-test-id';
};
const document = cloneDeep(testDocument);
const expectedDocument = cloneDeep(testDocument);
expectedDocument.id = 'updated-test-id';
const command = createModelUpdaterCommand(
'Test Command',
'document',
updater
);
// Execute
await expect(command.canExecute(document)).to.eventually.be.true;
const executeResult = await command.execute(document);
expect(document).to.deep.equal(expectedDocument);
expect(executeResult).to.not.be.undefined;
if (executeResult) {
// Should contain a single 'replace' operation (and maybe some optional 'test' operations)
const operations = executeResult.filter(
(operation) => operation.op !== 'test'
);
expect(operations).to.be.an('array').of.length(1);
expect(operations[0].op).to.equal('replace');
}
// Undo
expect(command.canUndo(document)).to.be.true;
command.undo(document);
expect(document).to.deep.equal(testDocument);
// Redo
expect(command.canRedo(document)).to.be.true;
command.redo(document);
expect(document).to.deep.equal(expectedDocument);
});
it('Execute/undo/redo when modifying attribute to undefined', async () => {
const updater: ModelUpdater<
string,
RecursiveTestDocumentWithUndefined
> = (workingCopy, modelId) => {
expect(modelId).to.be.equal('document');
workingCopy.label = undefined;
if (workingCopy.children[0]) {
workingCopy.children[0].value = undefined;
}
};
const document = cloneDeep(testDocument);
const expectedDocument = (() => {
const expectedDocument: RecursiveTestDocumentWithUndefined =
cloneDeep(testDocument);
expectedDocument.label = undefined;
if (expectedDocument.children[0]) {
expectedDocument.children[0].value = undefined;
}
return JSON.parse(JSON.stringify(expectedDocument));
})();
const command = createModelUpdaterCommand(
'Test Command setting undefined attributes',
'document',
updater
);
// Execute
await expect(command.canExecute(document)).to.eventually.be.true;
const executeResult = await command.execute(document);
expect(document).to.deep.equal(expectedDocument);
expect(executeResult).to.not.be.undefined;
if (executeResult) {
// Should contain two 'remove' operations (and maybe some optional 'test' operations)
const operations = executeResult.filter(
(operation) => operation.op !== 'test'
);
expect(operations).to.be.an('array').of.length(2);
expect(operations[0].op).to.equal('remove');
expect(operations[1].op).to.equal('remove');
}
// Undo
expect(command.canUndo(document)).to.be.true;
command.undo(document);
expect(document).to.deep.equal(testDocument);
// Redo
expect(command.canRedo(document)).to.be.true;
command.redo(document);
expect(document).to.deep.equal(expectedDocument);
});
it('Execute/undo/redo when adding undefined value in array', async () => {
const updater: ModelUpdater<
string,
RecursiveTestDocumentWithUndefined
> = (workingCopy, modelId) => {
expect(modelId).to.be.equal('document');
workingCopy.children.push(undefined);
};
const document = cloneDeep(testDocument);
const expectedDocument = (() => {
const expectedDocument: RecursiveTestDocumentWithUndefined =
cloneDeep(testDocument);
expectedDocument.children.push(undefined);
return JSON.parse(JSON.stringify(expectedDocument));
})();
const command = createModelUpdaterCommand(
'Test Command adding an undefined value in array',
'document',
updater
);
// Execute
await expect(command.canExecute(document)).to.eventually.be.true;
const executeResult = await command.execute(document);
expect(document).to.deep.equal(expectedDocument);
expect(executeResult).to.not.be.undefined;
if (executeResult) {
// Should contain one 'add' operations (and maybe some optional 'test' operations)
const operations = executeResult.filter(
(operation) => operation.op !== 'test'
);
expect(operations).to.be.an('array').of.length(1);
expect(operations[0].op).to.equal('add');
}
// Undo
expect(command.canUndo(document)).to.be.true;
command.undo(document);
expect(document).to.deep.equal(testDocument);
// Redo
expect(command.canRedo(document)).to.be.true;
command.redo(document);
expect(document).to.deep.equal(expectedDocument);
});
it('Execute/undo/redo when modifying model to be recursive', async () => {
const updater: ModelUpdater<
string,
RecursiveTestDocumentWithUndefined
> = (workingCopy, modelId) => {
expect(modelId).to.be.equal('document');
workingCopy.children.push(workingCopy);
};
const document = cloneDeep(testDocument);
const command = createModelUpdaterCommand(
'Test Command adding an undefined value in array',
'document',
updater
);
await expect(command.canExecute(document)).to.eventually.be.true;
try {
await command.execute(document);
} catch (e) {
// success
return;
}
fail('execution should not be possible as the model is recursive');
});
it('Execute/undo/redo when modifying model to contain doubly linked references', async () => {
const updater: ModelUpdater<
string,
RecursiveTestDocumentWithUndefined
> = (workingCopy, modelId) => {
expect(modelId).to.be.equal('document');
if (workingCopy.children[0] && workingCopy.children[1]) {
workingCopy.children[0].link = workingCopy.children[1];
workingCopy.children[1].link = workingCopy.children[0];
} else {
fail('Unexpected test document');
}
};
const document = cloneDeep(testDocument);
const command = createModelUpdaterCommand(
'Test Command adding an undefined value in array',
'document',
updater
);
await expect(command.canExecute(document)).to.eventually.be.true;
try {
await command.execute(document);
} catch (e) {
// success
return;
}
fail('execution should not be possible as the model is doubly linked');
});
it('Execute/undo/redo empty change', async () => {
const updater: ModelUpdater<string, typeof testDocument> = (
workingCopy,
modelId
) => {
expect(modelId).to.be.equal('document');
expect(workingCopy).to.be.equal(document);
// Don't make any change. That's the point
};
const document = cloneDeep(testDocument);
const expectedDocument = cloneDeep(testDocument);
const command = createModelUpdaterCommand(
'Empty Command',
'document',
updater
);
// Execute
await expect(command.canExecute(document)).to.eventually.be.true;
const executeResult = await command.execute(document);
expect(document).to.deep.equal(expectedDocument);
expect(executeResult).to.not.be.undefined;
if (executeResult) {
// Should contain a single 'replace' operation (and maybe some optional 'test' operations)
const operations = executeResult.filter(
(operation) => operation.op !== 'test'
);
expect(operations).to.be.an('array').of.length(0);
}
// Undo
expect(command.canUndo(document)).to.be.true;
command.undo(document);
expect(document).to.deep.equal(testDocument);
// Redo
expect(command.canRedo(document)).to.be.true;
command.redo(document);
expect(document).to.deep.equal(expectedDocument);
});
describe('Options', () => {
it('Non-executable', async () => {
const updater: ModelUpdater<string, typeof testDocument> = (
model,
modelId
) => {
expect(model).to.be.equal(document);
expect(modelId).to.be.equal('document');
};
const canExecute: CanExecutePredicate<string, typeof testDocument> = (
model,
modelId
) => {
expect(model).to.be.equal(document);
expect(modelId).to.be.equal('document');
return Promise.resolve('test reason');
};
const document = cloneDeep(testDocument);
const command = createModelUpdaterCommand(
'Test Command',
'document',
updater,
{ canExecute }
);
await expect(command.canExecute(document)).to.eventually.be.false;
await expect(command.execute(document)).to.eventually.be.rejectedWith(
/PatchCommand.*cannot be executed: test reason/
);
});
it('Strict undo predicate', async () => {
const document = cloneDeep(testDocument);
const updater: ModelUpdater<string, typeof testDocument> = (
workingCopy
) => {
workingCopy.id = 'updated-id';
};
const command = createModelUpdaterCommand(
'Undo Preconditions',
'document',
updater
// Default mode is 'strict', so test that, too
);
await command.execute(document);
// Simulate an interfering change as from a trigger
document.id = 'broken';
let thrown: unknown;
try {
await command.undo(document);
} catch (error) {
thrown = error;
}
expect(thrown)
.to.be.an('Error')
.with.property('message')
.contains('Test operation failed');
});
it('Lax undo predicate', async () => {
const debug = sandbox.stub(console, 'debug');
const document = cloneDeep(testDocument);
const updater: ModelUpdater<string, typeof testDocument> = (
workingCopy
) => {
workingCopy.id = 'updated-id';
};
const command = createModelUpdaterCommand(
'Undo Preconditions',
'document',
updater,
{ preconditionsMode: 'lax' }
);
await command.execute(document);
// Simulate an interfering change as from a trigger
document.id = 'broken';
await command.undo(document);
expect(debug).to.have.been.calledWithMatch(
'model-manager/patch-command',
'Inapplicable undo/redo patch.',
Error
);
});
});
describe('With Result', () => {
it('success', async () => {
type Result = {
message: string;
};
const updaterWithResult: ModelUpdater<
string,
typeof testDocument,
Result
> = (workingCopy, modelId) => {
expect(modelId).to.be.equal('document');
workingCopy.id = 'updated-test-id';
return { message: 'Hello, world.' };
};
const document = cloneDeep(testDocument);
const expectedDocument = cloneDeep(testDocument);
expectedDocument.id = 'updated-test-id';
const command = createModelUpdaterCommandWithResult(
'Test Command',
'document',
updaterWithResult
);
expect(command.result).to.be.like({ status: 'pending' });
// Execute
await expect(command.canExecute(document)).to.eventually.be.true;
await command.execute(document);
expect(command.result).to.exist;
expect(command.result).to.be.like({
status: 'ready',
value: { message: 'Hello, world.' },
});
});
it('fails with Error', async () => {
const updaterWithResult: ModelUpdater<
string,
typeof testDocument,
unknown
> = () => {
throw new Error('💣');
};
const document = cloneDeep(testDocument);
const expectedDocument = cloneDeep(testDocument);
expectedDocument.id = 'updated-test-id';
const command = createModelUpdaterCommandWithResult(
'Test Command',
'document',
updaterWithResult
);
let error: Error | undefined;
await expect(command.canExecute(document)).to.eventually.be.true;
try {
await command.execute(document);
} catch (e) {
error = e;
}
expect(command.result).to.exist;
expect(command.result).to.be.like({
status: 'failed',
error,
});
});
it('fails with string', async () => {
const updaterWithResult: ModelUpdater<
string,
typeof testDocument,
unknown
> = () => {
throw '💣';
};
const document = cloneDeep(testDocument);
const expectedDocument = cloneDeep(testDocument);
expectedDocument.id = 'updated-test-id';
const command = createModelUpdaterCommandWithResult(
'Test Command',
'document',
updaterWithResult
);
let error: string | undefined;
await expect(command.canExecute(document)).to.eventually.be.true;
try {
await command.execute(document);
} catch (e) {
error = e;
}
expect(command.result).to.exist;
expect(command.result).to.be.like({
status: 'failed',
error,
});
});
});
});
describe('createModelPatchCommand', () => {
it('Simple execute/undo/redo', async () => {
const patchFunction: ModelPatchFunction<string, typeof testDocument> = (
workingCopy,
modelId
) => {
expect(modelId).to.be.equal('document');
expect(workingCopy).to.be.equal(document);
return [
{
op: 'replace',
path: '/id',
value: 'updated-test-id',
},
] as Operation[];
};
const document = cloneDeep(testDocument);
const expectedDocument = cloneDeep(testDocument);
expectedDocument.id = 'updated-test-id';
const command = createModelPatchCommand(
'Test Command',
'document',
patchFunction
);
// Execute
await expect(command.canExecute(document)).to.eventually.be.true;
const executeResult = await command.execute(document);
expect(document).to.deep.equal(expectedDocument);
expect(executeResult).to.not.be.undefined;
if (executeResult) {
// Should contain a single 'replace' operation (and maybe some optional 'test' operations)
const operations = executeResult.filter(
(operation) => operation.op !== 'test'
);
expect(operations).to.be.an('array').of.length(1);
expect(operations[0].op).to.equal('replace');
}
// Undo
expect(command.canUndo(document)).to.be.true;
command.undo(document);
expect(document).to.deep.equal(testDocument);
// Redo
expect(command.canRedo(document)).to.be.true;
command.redo(document);
expect(document).to.deep.equal(expectedDocument);
});
});
});
const testDocument = {
id: 'test-id',
label: 'Test Document',
children: [
{
type: 'child-node',
value: 13,
valid: true,
},
{
type: 'child-node',
value: 175,
valid: false,
},
{
type: 'child-node',
value: 1,
valid: true,
},
],
};
const secondTestDocument = {
id: 'second-test-id',
label: 'Second Test Document',
children: [
{
type: 'child-node',
value: 130,
valid: true,
},
{
type: 'child-node',
value: 775,
valid: false,
},
],
};
/**
* Used to test invalid modifications
*/
interface RecursiveTestDocumentWithUndefined {
id: string;
label?: string;
value?: number;
link?: RecursiveTestDocumentWithUndefined['children'];
children: Array<
| {
type: string;
value?: number;
valid: boolean;
link?: RecursiveTestDocumentWithUndefined['children'][0];
}
| undefined
| RecursiveTestDocumentWithUndefined
>;
}