UNPKG

@finos/legend-studio

Version:
577 lines (537 loc) 17.9 kB
/** * Copyright (c) 2020-present, Goldman Sachs * * 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 type { TreeData, TreeNodeData } from '@finos/legend-art'; import { type AssertionStatus, type Test, type Testable, type TestResult, type TestAssertion, RunTestsTestableInput, TestSuite, AtomicTest, AtomicTestId, TestError, TestFailed, TestPassed, AssertPass, AssertFail, PackageableElement, getNullableIDFromTestable, MultiExecutionServiceTestResult, } from '@finos/legend-graph'; import { type GeneratorFn, assertErrorThrown, isNonNullable, ActionState, uuid, assertTrue, guaranteeNonNullable, UnsupportedOperationError, filterByType, } from '@finos/legend-shared'; import { action, flow, makeObservable, observable } from 'mobx'; import { getElementTypeIcon } from '../../../components/shared/ElementIconUtils.js'; import type { EditorSDLCState } from '../../EditorSDLCState.js'; import type { EditorStore } from '../../EditorStore.js'; import type { LegendStudioApplicationPlugin, TestableMetadataGetter, } from '../../LegendStudioApplicationPlugin.js'; // Testable Metadata export interface TestableMetadata { testable: Testable; id: string; name: string; icon: React.ReactNode; } export const getTestableMetadata = ( testable: Testable, editorStore: EditorStore, extraTestableMetadataGetters: TestableMetadataGetter[], ): TestableMetadata => { if (testable instanceof PackageableElement) { return { testable: testable, id: getNullableIDFromTestable( testable, editorStore.graphManagerState.graph, editorStore.graphManagerState.pluginManager.getPureGraphManagerPlugins(), ) ?? uuid(), name: testable.name, icon: getElementTypeIcon( editorStore, editorStore.graphState.getPackageableElementType(testable), ), }; } const extraTestables = extraTestableMetadataGetters .map((getter) => getter(testable, editorStore)) .filter(isNonNullable); return ( extraTestables[0] ?? { testable, id: uuid(), name: '(unknown)', icon: null, } ); }; // TreeData export abstract class TestableExplorerTreeNodeData implements TreeNodeData { isSelected?: boolean | undefined; isOpen?: boolean | undefined; id: string; label: string; childrenIds?: string[] | undefined; constructor(id: string, label: string) { this.id = id; this.label = label; } } export class TestableTreeNodeData extends TestableExplorerTreeNodeData { testableMetadata: TestableMetadata; isRunning = false; constructor(testable: TestableMetadata) { super(testable.id, testable.id); this.testableMetadata = testable; makeObservable(this, { isRunning: observable, }); } } export abstract class TestTreeNodeData extends TestableExplorerTreeNodeData { isRunning = false; constructor(id: string, label: string) { super(id, label); makeObservable(this, { isRunning: observable, }); } } export class AtomicTestTreeNodeData extends TestTreeNodeData { atomicTest: AtomicTest; constructor(id: string, atomicTest: AtomicTest) { super(id, atomicTest.id); this.atomicTest = atomicTest; } } export class TestSuiteTreeNodeData extends TestTreeNodeData { testSuite: TestSuite; constructor(id: string, testSuite: TestSuite) { super(id, testSuite.id); this.testSuite = testSuite; } } export class AssertionTestTreeNodeData extends TestableExplorerTreeNodeData { assertion: TestAssertion; constructor(id: string, assertion: TestAssertion) { super(id, assertion.id); this.assertion = assertion; } } const buildTestNodeData = ( test: Test, parentId: string, ): TestTreeNodeData | undefined => { if (test instanceof AtomicTest) { return new AtomicTestTreeNodeData(`${parentId}.${test.id}`, test); } else if (test instanceof TestSuite) { return new TestSuiteTreeNodeData(`${parentId}.${test.id}`, test); } return undefined; }; const buildChildrenIfPossible = ( node: TestableExplorerTreeNodeData, treeData: TreeData<TestableExplorerTreeNodeData>, ): void => { if (!node.childrenIds) { let children: TestableExplorerTreeNodeData[] = []; if (node instanceof TestableTreeNodeData) { children = node.testableMetadata.testable.tests .map((t) => buildTestNodeData(t, node.id)) .filter(isNonNullable); } else if (node instanceof TestSuiteTreeNodeData) { children = node.testSuite.tests .map((t) => buildTestNodeData(t, node.id)) .filter(isNonNullable); } else if (node instanceof AtomicTestTreeNodeData) { children = node.atomicTest.assertions.map((assertion) => { const assertionNode = new AssertionTestTreeNodeData( `${node.id}.${assertion.id}`, assertion, ); return assertionNode; }); } node.childrenIds = children.map((c) => c.id); children.forEach((c) => treeData.nodes.set(c.id, c)); } }; const onTreeNodeSelect = ( node: TestableExplorerTreeNodeData, treeData: TreeData<TestableExplorerTreeNodeData>, ): void => { buildChildrenIfPossible(node, treeData); node.isOpen = !node.isOpen; }; // Result Helpers export const getAtomicTest_TestResult = ( atomicTest: AtomicTest, results: Map<AtomicTest, TestResult>, ): TestResult | undefined => results.get(atomicTest); const getAssertion_TestResult = ( assertion: TestAssertion, results: Map<AtomicTest, TestResult>, ): TestResult | undefined => { const test = assertion.parentTest; return test ? getAtomicTest_TestResult(test, results) : undefined; }; export const getAssertionStatus = ( assertion: TestAssertion, results: Map<AtomicTest, TestResult>, ): AssertionStatus | undefined => { const result = getAssertion_TestResult(assertion, results); if (result instanceof TestFailed) { return result.assertStatuses.find((s) => s.assertion === assertion); } return undefined; }; const getTestSuite_TestResults = ( suite: TestSuite, results: Map<AtomicTest, TestResult>, ): (TestResult | undefined)[] => suite.tests.map((t) => getAtomicTest_TestResult(t, results)); const getTest_TestResults = ( test: Test, results: Map<AtomicTest, TestResult>, ): (TestResult | undefined)[] => { if (test instanceof AtomicTest) { return [getAtomicTest_TestResult(test, results)]; } else if (test instanceof TestSuite) { return getTestSuite_TestResults(test, results); } return [undefined]; }; const getTestable_TestResult = ( test: Testable, results: Map<AtomicTest, TestResult>, ): (TestResult | undefined)[] => test.tests.flatMap((t) => getTest_TestResults(t, results)); export enum TESTABLE_RESULT { DID_NOT_RUN = 'DID_NOT_RUN', ERROR = 'ERROR', FAILED = 'FAILED', PASSED = 'PASSED', IN_PROGRESS = 'IN_PROGRESS', } export const getTestableResultFromTestResult = ( testResult: TestResult | undefined, ): TESTABLE_RESULT => { if (testResult instanceof TestPassed) { return TESTABLE_RESULT.PASSED; } else if (testResult instanceof TestFailed) { return TESTABLE_RESULT.FAILED; } else if (testResult instanceof TestError) { return TESTABLE_RESULT.ERROR; } else if (testResult instanceof MultiExecutionServiceTestResult) { const result = Array.from(testResult.keyIndexedTestResults.values()); if (result.every((t) => t instanceof TestPassed)) { return TESTABLE_RESULT.PASSED; } else if (result.some((t) => t instanceof TestError)) { return TESTABLE_RESULT.ERROR; } return TESTABLE_RESULT.FAILED; } return TESTABLE_RESULT.DID_NOT_RUN; }; export const getTestableResultFromAssertionStatus = ( assertionStatus: AssertionStatus | undefined, ): TESTABLE_RESULT => { if (assertionStatus instanceof AssertPass) { return TESTABLE_RESULT.PASSED; } else if (assertionStatus instanceof AssertFail) { return TESTABLE_RESULT.FAILED; } return TESTABLE_RESULT.DID_NOT_RUN; }; export const getTestableResultFromTestResults = ( testResults: (TestResult | undefined)[], ): TESTABLE_RESULT => { if (testResults.every((t) => t instanceof TestPassed)) { return TESTABLE_RESULT.PASSED; } else if (testResults.find((t) => t instanceof TestError)) { return TESTABLE_RESULT.ERROR; } else if (testResults.find((t) => t instanceof TestFailed)) { return TESTABLE_RESULT.FAILED; } return TESTABLE_RESULT.DID_NOT_RUN; }; export const getNodeTestableResult = ( node: TestableExplorerTreeNodeData, globalRun: boolean, results: Map<AtomicTest, TestResult>, ): TESTABLE_RESULT => { if (globalRun && node instanceof TestableTreeNodeData) { return TESTABLE_RESULT.IN_PROGRESS; } if ( (node instanceof TestTreeNodeData || node instanceof TestableTreeNodeData) && node.isRunning ) { return TESTABLE_RESULT.IN_PROGRESS; } if (node instanceof AssertionTestTreeNodeData) { const status = getAssertionStatus(node.assertion, results); if (status) { return getTestableResultFromAssertionStatus(status); } const result = node.assertion.parentTest ? results.get(node.assertion.parentTest) : undefined; return getTestableResultFromTestResult(result); } else if (node instanceof AtomicTestTreeNodeData) { return getTestableResultFromTestResult( getAtomicTest_TestResult(node.atomicTest, results), ); } else if (node instanceof TestSuiteTreeNodeData) { return getTestableResultFromTestResults( getTestSuite_TestResults(node.testSuite, results), ); } else if (node instanceof TestableTreeNodeData) { return getTestableResultFromTestResults( getTestable_TestResult(node.testableMetadata.testable, results), ); } return TESTABLE_RESULT.DID_NOT_RUN; }; export class TestableState { readonly uuid = uuid(); globalTestRunnerState: GlobalTestRunnerState; editorStore: EditorStore; testableMetadata: TestableMetadata; treeData: TreeData<TestableExplorerTreeNodeData>; results: Map<AtomicTest, TestResult> = new Map(); isRunningTests = ActionState.create(); constructor( editorStore: EditorStore, globalTestRunnerState: GlobalTestRunnerState, testable: Testable, ) { makeObservable(this, { editorStore: false, testableMetadata: observable, isRunningTests: observable, results: observable, treeData: observable.ref, handleTestableResult: action, setTreeData: action, onTreeNodeSelect: action, run: flow, }); this.editorStore = editorStore; this.globalTestRunnerState = globalTestRunnerState; this.testableMetadata = getTestableMetadata( testable, editorStore, this.globalTestRunnerState.extraTestableMetadataGetters, ); this.treeData = this.buildTreeData(this.testableMetadata); } *run(node: TestableExplorerTreeNodeData): GeneratorFn<void> { this.isRunningTests.inProgress(); let input: RunTestsTestableInput; let currentNode = node; try { if (node instanceof AssertionTestTreeNodeData) { const atomicTest = guaranteeNonNullable(node.assertion.parentTest); const suite = atomicTest.__parent instanceof TestSuite ? atomicTest.__parent : undefined; input = new RunTestsTestableInput(this.testableMetadata.testable); input.unitTestIds = [new AtomicTestId(suite, atomicTest)]; const parentNode = Array.from(this.treeData.nodes.values()) .filter(filterByType(AtomicTestTreeNodeData)) .find((n) => n.atomicTest === atomicTest); if (parentNode) { currentNode = parentNode; parentNode.isRunning = true; } } else if (node instanceof AtomicTestTreeNodeData) { const atomicTest = node.atomicTest; const suite = atomicTest.__parent instanceof TestSuite ? atomicTest.__parent : undefined; input = new RunTestsTestableInput(this.testableMetadata.testable); input.unitTestIds = [new AtomicTestId(suite, atomicTest)]; node.isRunning = true; } else if (node instanceof TestSuiteTreeNodeData) { input = new RunTestsTestableInput(this.testableMetadata.testable); input.unitTestIds = node.testSuite.tests.map( (s) => new AtomicTestId(node.testSuite, s), ); node.isRunning = true; } else if (node instanceof TestableTreeNodeData) { input = new RunTestsTestableInput(this.testableMetadata.testable); node.isRunning = true; } else { throw new UnsupportedOperationError( `Unable to run tests for node ${node}`, ); } const testResults = (yield this.editorStore.graphManagerState.graphManager.runTests( [input], this.editorStore.graphManagerState.graph, )) as TestResult[]; this.globalTestRunnerState.handleResults(testResults); this.isRunningTests.complete(); } catch (error) { assertErrorThrown(error); this.editorStore.applicationStore.notifyError(error); this.isRunningTests.fail(); } finally { if ( currentNode instanceof TestTreeNodeData || currentNode instanceof TestableTreeNodeData ) { currentNode.isRunning = false; } } } handleTestableResult(testResult: TestResult, openAssertions?: boolean): void { try { assertTrue(testResult.testable === this.testableMetadata.testable); this.results.set(testResult.atomicTestId.atomicTest, testResult); } catch (error) { assertErrorThrown(error); this.editorStore.applicationStore.notifyError( `Unable to update test result: ${error.message}`, ); } } buildTreeData( testable: TestableMetadata, ): TreeData<TestableExplorerTreeNodeData> { const rootIds: string[] = []; const nodes = new Map<string, TestableExplorerTreeNodeData>(); const treeData = { rootIds, nodes }; const testableTreeNodeData = new TestableTreeNodeData(testable); treeData.rootIds.push(testableTreeNodeData.id); treeData.nodes.set(testableTreeNodeData.id, testableTreeNodeData); return treeData; } setTreeData(data: TreeData<TestableExplorerTreeNodeData>): void { this.treeData = data; } onTreeNodeSelect( node: TestableExplorerTreeNodeData, treeData: TreeData<TestableExplorerTreeNodeData>, ): void { onTreeNodeSelect(node, treeData); this.setTreeData({ ...treeData }); } } export class GlobalTestRunnerState { editorStore: EditorStore; sdlcState: EditorSDLCState; testableStates: TestableState[] | undefined; isRunningTests = ActionState.create(); extraTestableMetadataGetters: TestableMetadataGetter[] = []; failureViewing: AssertFail | TestError | undefined; constructor(editorStore: EditorStore, sdlcState: EditorSDLCState) { makeObservable(this, { editorStore: false, sdlcState: false, testableStates: observable, init: action, runAllTests: flow, failureViewing: observable, setFailureViewing: action, }); this.editorStore = editorStore; this.sdlcState = sdlcState; this.extraTestableMetadataGetters = editorStore.pluginManager .getApplicationPlugins() .flatMap( (plugin: LegendStudioApplicationPlugin) => plugin.getExtraTestableMetadata?.() ?? [], ) .filter(isNonNullable); } init(force?: boolean): void { if (!this.testableStates || force) { const testables = this.editorStore.graphManagerState.graph.allOwnTestables; this.testableStates = testables.map( (testable) => new TestableState(this.editorStore, this, testable), ); } } get testables(): TestableState[] { return this.testableStates ?? []; } get isDispatchingAction(): boolean { return ( this.isRunningTests.isInProgress || this.testables.some((s) => s.isRunningTests.isInProgress) ); } setFailureViewing(val: AssertFail | TestError | undefined): void { this.failureViewing = val; } *runAllTests(testableState: TestableState | undefined): GeneratorFn<void> { try { this.isRunningTests.inProgress(); let inputs: RunTestsTestableInput[] = []; if (!testableState) { inputs = (this.testableStates ?? []).map( (e) => new RunTestsTestableInput(e.testableMetadata.testable), ); } else { inputs = [ new RunTestsTestableInput(testableState.testableMetadata.testable), ]; } const testResults = (yield this.editorStore.graphManagerState.graphManager.runTests( inputs, this.editorStore.graphManagerState.graph, )) as TestResult[]; this.handleResults(testResults); this.isRunningTests.complete(); } catch (error) { assertErrorThrown(error); this.editorStore.applicationStore.notifyError(error); this.isRunningTests.fail(); } } handleResults(testResults: TestResult[]): void { testResults.forEach((testResult) => { const testableState = this.testables.find( (tState) => tState.testableMetadata.testable === testResult.testable, ); if (testableState) { testableState.handleTestableResult(testResult, true); } }); } }