diff --git a/e2e-tests/src/page-objects/crossmodel-app.ts b/e2e-tests/src/page-objects/cm-app.ts similarity index 55% rename from e2e-tests/src/page-objects/crossmodel-app.ts rename to e2e-tests/src/page-objects/cm-app.ts index 8ad84854..8a658960 100644 --- a/e2e-tests/src/page-objects/crossmodel-app.ts +++ b/e2e-tests/src/page-objects/cm-app.ts @@ -3,15 +3,16 @@ ********************************************************************************/ import { IntegrationArgs, TheiaGLSPApp } from '@eclipse-glsp/glsp-playwright'; import { TheiaEditor } from '@theia/playwright'; +import { CMCompositeEditor, IntegratedEditorType } from './cm-composite-editor'; +import { CMExplorerView } from './cm-explorer-view'; import { CMTheiaIntegration } from './cm-theia-integration'; -import { CrossModelExplorerView } from './crossmodel-explorer-view'; export interface CMAppArgs extends Omit { workspaceUrl?: string; baseUrl?: string; } -export class CrossModelApp extends TheiaGLSPApp { - public static async load(args: CMAppArgs): Promise { +export class CMApp extends TheiaGLSPApp { + public static async load(args: CMAppArgs): Promise { const integration = new CMTheiaIntegration( { browser: args.browser, page: {} as any, playwright: args.playwright }, { @@ -40,15 +41,34 @@ export class CrossModelApp extends TheiaGLSPApp { return this._integration; } - async openExplorerView(): Promise { - const explorer = await this.openView(CrossModelExplorerView); + async openExplorerView(): Promise { + const explorer = await this.openView(CMExplorerView); await explorer.waitForVisibleFileNodes(); return explorer; } + async openCompositeEditor(filePath: string, editorType: T): Promise { + const editor = await this.openEditor(filePath, CMCompositeEditor); + await editor.waitForVisible(); + let integratedEditor: TheiaEditor | undefined = undefined; + if (editorType === 'Code Editor') { + integratedEditor = await editor.switchToCodeEditor(); + } else if (editorType === 'Form Editor') { + integratedEditor = await editor.switchToFormEditor(); + } else if (editorType === 'System Diagram') { + integratedEditor = await editor.switchToSystemDiagram(); + } else if (editorType === 'Mapping Diagram') { + integratedEditor = await editor.switchToMappingDiagram(); + } + if (integratedEditor === undefined) { + throw new Error(`Unknown editor type: ${editorType}`); + } + return integratedEditor as IntegratedEditorType[T]; + } + override openEditor( filePath: string, - editorFactory: new (editorFilePath: string, app: CrossModelApp) => T, + editorFactory: new (editorFilePath: string, app: CMApp) => T, editorName?: string | undefined, expectFileNodes?: boolean | undefined ): Promise { diff --git a/e2e-tests/src/page-objects/crossmodel-composite-editor.ts b/e2e-tests/src/page-objects/cm-composite-editor.ts similarity index 63% rename from e2e-tests/src/page-objects/crossmodel-composite-editor.ts rename to e2e-tests/src/page-objects/cm-composite-editor.ts index 0813508e..3af1a804 100644 --- a/e2e-tests/src/page-objects/crossmodel-composite-editor.ts +++ b/e2e-tests/src/page-objects/cm-composite-editor.ts @@ -3,19 +3,25 @@ ********************************************************************************/ import { ElementHandle, Page } from '@playwright/test'; -import { isElementVisible, normalizeId, OSUtil, TheiaApp, TheiaEditor, TheiaTextEditor, urlEncodePath } from '@theia/playwright'; +import { OSUtil, TheiaEditor, isElementVisible, normalizeId, urlEncodePath } from '@theia/playwright'; import { TheiaMonacoEditor } from '@theia/playwright/lib/theia-monaco-editor'; import { join } from 'path'; -import { CMTheiaIntegration } from './cm-theia-integration'; -import { CrossModelApp } from './crossmodel-app'; -import { SystemDiagram } from './system-diagram/system-diagram'; - -export type CompositeEditorName = 'Code Editor' | 'Form Editor' | 'System Diagram' | 'Mapping Diagram'; +import { CMApp } from './cm-app'; +import { IntegratedEditor, IntegratedTextEditor } from './cm-integrated-editor'; +import { IntegratedSystemDiagramEditor } from './system-diagram/integrated-system-diagram-editor'; + +export type CompositeEditorName = keyof IntegratedEditorType; +export interface IntegratedEditorType { + 'Code Editor': IntegratedCodeEditor; + 'Form Editor': IntegratedFormEditor; + 'System Diagram': IntegratedSystemDiagramEditor; + 'Mapping Diagram': IntegratedMappingDiagramEditor; +} -export class CrossModelCompositeEditor extends TheiaEditor { +export class CMCompositeEditor extends TheiaEditor { constructor( protected filePath: string, - app: CrossModelApp + public override app: CMApp ) { // shell-tab-code-editor-opener:file:///c%3A/Users/user/AppData/Local/Temp/cloud-ws-JBUhb6/sample.txt:1 // code-editor-opener:file:///c%3A/Users/user/AppData/Local/Temp/cloud-ws-JBUhb6/sample.txt:1 @@ -55,85 +61,57 @@ export class CrossModelCompositeEditor extends TheiaEditor { async switchToCodeEditor(): Promise { await this.switchToEditor('Code Editor'); - const textEditor = new IntegratedCodeEditor(this.filePath, this.app, this.editorTabSelector('Code Editor')); + const textEditor = new IntegratedCodeEditor(this.filePath, this, this.editorTabSelector('Code Editor')); await textEditor.waitForVisible(); return textEditor; } async switchToFormEditor(): Promise { await this.switchToEditor('Form Editor'); - const formEditor = new IntegratedFormEditor(this.filePath, this.app, this.editorTabSelector('Form Editor')); + const formEditor = new IntegratedFormEditor(this.filePath, this, this.editorTabSelector('Form Editor')); await formEditor.waitForVisible(); return formEditor; } async switchToSystemDiagram(): Promise { await this.switchToEditor('System Diagram'); - const diagramEditor = new IntegratedSystemDiagramEditor( - this.filePath, - this.app as CrossModelApp, - this.editorTabSelector('System Diagram') - ); + const diagramEditor = new IntegratedSystemDiagramEditor(this.filePath, this, this.editorTabSelector('System Diagram')); await diagramEditor.diagram.graph.waitForVisible(); return diagramEditor; } async switchToMappingDiagram(): Promise { await this.switchToEditor('Mapping Diagram'); - const diagramEditor = new IntegratedMappingDiagramEditor(this.filePath, this.app, this.editorTabSelector('Mapping Diagram')); + const diagramEditor = new IntegratedMappingDiagramEditor(this.filePath, this, this.editorTabSelector('Mapping Diagram')); await diagramEditor.waitForVisible(); return diagramEditor; } } -export class IntegratedCodeEditor extends TheiaTextEditor { - constructor(filePath: string, app: TheiaApp, tabSelector: string) { +export class IntegratedCodeEditor extends IntegratedTextEditor { + constructor(filePath: string, parent: CMCompositeEditor, tabSelector: string) { // shell-tab-code-editor-opener:file:///c%3A/Users/user/AppData/Local/Temp/cloud-ws-JBUhb6/sample.txt:1 // code-editor-opener:file:///c%3A/Users/user/AppData/Local/Temp/cloud-ws-JBUhb6/sample.txt:1 - super(filePath, app); + super(filePath, parent); this.data.viewSelector = normalizeId( - `#code-editor-opener:file://${urlEncodePath(join(app.workspace.escapedPath, OSUtil.fileSeparator, filePath))}` + `#code-editor-opener:file://${urlEncodePath(join(this.app.workspace.escapedPath, OSUtil.fileSeparator, filePath))}` ); this.data.tabSelector = tabSelector; - this.monacoEditor = new TheiaMonacoEditor(this.viewSelector, app); - } -} - -export class IntegratedFormEditor extends TheiaEditor { - constructor(filePath: string, app: TheiaApp, tabSelector: string) { - super( - { - tabSelector, - viewSelector: normalizeId( - `#form-editor-opener:file://${urlEncodePath(join(app.workspace.escapedPath, OSUtil.fileSeparator, filePath))}` - ) - }, - app - ); - } - - async hasError(errorMessage: string): Promise { - return hasViewError(this.page, this.viewSelector, errorMessage); + this.monacoEditor = new TheiaMonacoEditor(this.viewSelector, parent.app); } } -export class IntegratedSystemDiagramEditor extends TheiaEditor { - diagram: SystemDiagram; - constructor(filePath: string, app: CrossModelApp, tabSelector: string) { +export class IntegratedFormEditor extends IntegratedEditor { + constructor(filePath: string, parent: CMCompositeEditor, tabSelector: string) { super( { tabSelector, viewSelector: normalizeId( - `#system-diagram:file://${urlEncodePath(join(app.workspace.escapedPath, OSUtil.fileSeparator, filePath))}` + `#form-editor-opener:file://${urlEncodePath(join(parent.app.workspace.escapedPath, OSUtil.fileSeparator, filePath))}` ) }, - app + parent ); - this.diagram = this.createSystemDiagram(app.integration); - } - - protected createSystemDiagram(integration: CMTheiaIntegration): SystemDiagram { - return new SystemDiagram({ type: 'integration', integration }); } async hasError(errorMessage: string): Promise { @@ -141,16 +119,16 @@ export class IntegratedSystemDiagramEditor extends TheiaEditor { } } -export class IntegratedMappingDiagramEditor extends TheiaEditor { - constructor(filePath: string, app: TheiaApp, tabSelector: string) { +export class IntegratedMappingDiagramEditor extends IntegratedEditor { + constructor(filePath: string, parent: CMCompositeEditor, tabSelector: string) { super( { tabSelector, viewSelector: normalizeId( - `#mapping-diagram:file://${urlEncodePath(join(app.workspace.escapedPath, OSUtil.fileSeparator, filePath))}` + `#mapping-diagram:file://${urlEncodePath(join(parent.app.workspace.escapedPath, OSUtil.fileSeparator, filePath))}` ) }, - app + parent ); } diff --git a/e2e-tests/src/page-objects/crossmodel-explorer-view.ts b/e2e-tests/src/page-objects/cm-explorer-view.ts similarity index 88% rename from e2e-tests/src/page-objects/crossmodel-explorer-view.ts rename to e2e-tests/src/page-objects/cm-explorer-view.ts index 21a5832f..7eac74f8 100644 --- a/e2e-tests/src/page-objects/crossmodel-explorer-view.ts +++ b/e2e-tests/src/page-objects/cm-explorer-view.ts @@ -4,7 +4,7 @@ import { TheiaApp, TheiaExplorerView } from '@theia/playwright'; import { TheiaTabBarToolbar } from './theia-tabbar-toolbar'; -export class CrossModelExplorerView extends TheiaExplorerView { +export class CMExplorerView extends TheiaExplorerView { public readonly tabBarToolbar: TheiaTabBarToolbar; constructor(app: TheiaApp) { diff --git a/e2e-tests/src/page-objects/cm-integrated-editor.ts b/e2e-tests/src/page-objects/cm-integrated-editor.ts new file mode 100644 index 00000000..57f5cd97 --- /dev/null +++ b/e2e-tests/src/page-objects/cm-integrated-editor.ts @@ -0,0 +1,126 @@ +/******************************************************************************** + * Copyright (c) 2024 CrossBreeze. + ********************************************************************************/ + +import { TheiaEditor, TheiaTextEditor, TheiaViewData } from '@theia/playwright'; +import { CMCompositeEditor } from './cm-composite-editor'; + +export abstract class IntegratedEditor extends TheiaEditor { + constructor( + data: TheiaViewData, + protected readonly parent: CMCompositeEditor + ) { + super(data, parent.app); + } + + override async activate(): Promise { + await this.parent.activate(); + return super.activate(); + } + + override close(waitForClosed?: boolean | undefined): Promise { + return this.parent.close(waitForClosed); + } + + override closeWithoutSave(): Promise { + return this.parent.closeWithoutSave(); + } + + override async focus(): Promise { + await this.parent.focus(); + return super.focus(); + } + + override async save(): Promise { + await this.parent.save(); + } + + override async saveAndClose(): Promise { + await this.parent.saveAndClose(); + } + + override async undo(times?: number | undefined): Promise { + await this.parent.undo(times); + } + + override async redo(times?: number | undefined): Promise { + await this.parent.redo(times); + } + + override async isDirty(): Promise { + return this.parent.isDirty(); + } + + override async waitForVisible(): Promise { + await this.parent.waitForVisible(); + return super.waitForVisible(); + } + + override isClosable(): Promise { + return this.parent.isClosable(); + } + + override title(): Promise { + return this.parent.title(); + } +} + +export abstract class IntegratedTextEditor extends TheiaTextEditor { + constructor( + filePath: string, + protected readonly parent: CMCompositeEditor + ) { + super(filePath, parent.app); + } + + override async activate(): Promise { + await this.parent.activate(); + return super.activate(); + } + + override close(waitForClosed?: boolean | undefined): Promise { + return this.parent.close(waitForClosed); + } + + override closeWithoutSave(): Promise { + return this.parent.closeWithoutSave(); + } + + override async focus(): Promise { + await this.parent.focus(); + return super.focus(); + } + + override async save(): Promise { + await this.parent.save(); + } + + override async saveAndClose(): Promise { + await this.parent.saveAndClose(); + } + + override async undo(times?: number | undefined): Promise { + await this.parent.undo(times); + } + + override async redo(times?: number | undefined): Promise { + await this.parent.redo(times); + } + + override async isDirty(): Promise { + return this.parent.isDirty(); + } + + override async waitForVisible(): Promise { + await this.parent.waitForVisible(); + return super.waitForVisible(); + } + + override isClosable(): Promise { + return this.parent.isClosable(); + } + + override title(): Promise { + return this.parent.title(); + } +} diff --git a/e2e-tests/src/page-objects/cm-theia-integration.ts b/e2e-tests/src/page-objects/cm-theia-integration.ts index 7ace14d6..417487c2 100644 --- a/e2e-tests/src/page-objects/cm-theia-integration.ts +++ b/e2e-tests/src/page-objects/cm-theia-integration.ts @@ -5,17 +5,17 @@ import { ContextMenuIntegration, Integration, IntegrationArgs, TheiaIntegrationOptions } from '@eclipse-glsp/glsp-playwright'; import { Locator, Page } from '@playwright/test'; import { TheiaAppFactory, TheiaAppLoader } from '@theia/playwright'; -import { CrossModelApp } from './crossmodel-app'; -import { CrossModelWorkspace } from './crossmodel-workspace'; +import { CMApp } from './cm-app'; +import { CMWorkspace } from './cm-workspace'; export class CMTheiaIntegration extends Integration implements ContextMenuIntegration { - protected theiaApp: CrossModelApp; + protected theiaApp: CMApp; override get page(): Page { return this.theiaApp.page; } - get app(): CrossModelApp { + get app(): CMApp { return this.theiaApp; } @@ -31,8 +31,8 @@ export class CMTheiaIntegration extends Integration implements ContextMenuIntegr } protected override async launch(): Promise { - const ws = new CrossModelWorkspace(this.options.workspace ? [this.options.workspace] : undefined); - this.theiaApp = await TheiaAppLoader.load(this.args, ws, CrossModelApp as TheiaAppFactory); + const ws = new CMWorkspace(this.options.workspace ? [this.options.workspace] : undefined); + this.theiaApp = await TheiaAppLoader.load(this.args, ws, CMApp as TheiaAppFactory); this.theiaApp.integration = this; this.theiaApp.initialize(this.options); } diff --git a/e2e-tests/src/page-objects/crossmodel-workspace.ts b/e2e-tests/src/page-objects/cm-workspace.ts similarity index 81% rename from e2e-tests/src/page-objects/crossmodel-workspace.ts rename to e2e-tests/src/page-objects/cm-workspace.ts index faaa1155..9e3bb7e8 100644 --- a/e2e-tests/src/page-objects/crossmodel-workspace.ts +++ b/e2e-tests/src/page-objects/cm-workspace.ts @@ -3,4 +3,4 @@ ********************************************************************************/ import { TheiaWorkspace } from '@theia/playwright'; -export class CrossModelWorkspace extends TheiaWorkspace {} +export class CMWorkspace extends TheiaWorkspace {} diff --git a/e2e-tests/src/page-objects/form/cm-form.ts b/e2e-tests/src/page-objects/form/cm-form.ts new file mode 100644 index 00000000..75e21201 --- /dev/null +++ b/e2e-tests/src/page-objects/form/cm-form.ts @@ -0,0 +1,84 @@ +/******************************************************************************** + * Copyright (c) 2024 CrossBreeze. + ********************************************************************************/ + +import { ElementHandle, Locator } from '@playwright/test'; +import { TheiaApp, TheiaEditor, TheiaPageObject, TheiaView, isElementVisible } from '@theia/playwright'; +import { TheiaViewObject } from '../theia-view-object'; + +export const FormIcons = { + Entity: 'codicon-git-commit', + Relationship: 'codicon-git-compare', + SystemDiagram: 'codicon-type-hierarchy-sub', + Mapping: 'codicon-group-by-ref-type' +}; + +export type FormType = keyof typeof FormIcons; + +export abstract class CMForm extends TheiaViewObject { + protected abstract iconClass: string; + protected typeSelector: string; + readonly locator: Locator; + constructor(view: TheiaView, relativeSelector: string, type: FormType) { + super(view, relativeSelector); + this.typeSelector = `${this.selector} span.${FormIcons[type]}`; + this.locator = view.page.locator(this.selector); + } + + protected typeElementHandle(): Promise | null> { + return this.page.$(this.typeSelector); + } + + override async waitForVisible(): Promise { + await this.page.waitForSelector(this.typeSelector, { state: 'visible' }); + } + + override async isVisible(): Promise { + const viewObject = await this.typeElementHandle(); + return !!viewObject && viewObject.isVisible(); + } + + async isDirty(): Promise { + const title = await this.page.$(this.selector + ' .form-title:not(.p-mod-hidden)'); + const text = await title?.textContent(); + return text?.endsWith('*') ?? false; + } +} + +export abstract class FormSection extends TheiaPageObject { + readonly sectionLocator: Locator; + + constructor(form: CMForm, sectionName: string) { + super(form.app); + this.sectionLocator = form.locator.locator(`div.MuiAccordion-root:has(h6:has-text("${sectionName}"))`); + } +} + +const CMPropertiesViewData = { + tabSelector: '#shell-tab-property-view', + viewSelector: '#property-view', + viewName: 'Properties' +}; + +export abstract class CMPropertiesView extends TheiaEditor { + protected modelRootSelector = '#model-property-view'; + + abstract form(): Promise; + + constructor(app: TheiaApp) { + super(CMPropertiesViewData, app); + } + + protected async modelPropertyElement(): Promise | null> { + return this.page.$(this.viewSelector + ' ' + this.modelRootSelector); + } + + isModelPropertyElement(): Promise { + return isElementVisible(this.modelPropertyElement()); + } + + override async isDirty(): Promise { + const form = await this.form(); + return form.isDirty(); + } +} diff --git a/e2e-tests/src/page-objects/form/entiy-form.ts b/e2e-tests/src/page-objects/form/entiy-form.ts new file mode 100644 index 00000000..315c1eea --- /dev/null +++ b/e2e-tests/src/page-objects/form/entiy-form.ts @@ -0,0 +1,49 @@ +/******************************************************************************** + * Copyright (c) 2024 CrossBreeze. + ********************************************************************************/ + +import { TheiaView } from '@theia/playwright/lib/theia-view'; +import { CMForm, CMPropertiesView, FormIcons, FormSection } from './cm-form'; + +export class EntityForm extends CMForm { + protected override iconClass = FormIcons.Entity; + + readonly generalSection: EntityGeneralSection; + + constructor(view: TheiaView, relativeSelector: string) { + super(view, relativeSelector, 'Entity'); + this.generalSection = new EntityGeneralSection(this); + } +} + +export class EntityGeneralSection extends FormSection { + constructor(form: EntityForm) { + super(form, 'General'); + } + + async getName(): Promise { + return this.sectionLocator.getByLabel('Name').inputValue(); + } + + async setName(name: string): Promise { + await this.sectionLocator.getByLabel('Name').fill(name); + return this.page.waitForTimeout(250); + } + + async getDescription(): Promise { + return this.sectionLocator.getByLabel('Description').inputValue(); + } + + async setDescription(description: string): Promise { + await this.sectionLocator.getByLabel('Description').fill(description); + return this.page.waitForTimeout(250); + } +} + +export class EntityPropertiesView extends CMPropertiesView { + async form(): Promise { + const entityForm = new EntityForm(this, this.modelRootSelector); + await entityForm.waitForVisible(); + return entityForm; + } +} diff --git a/e2e-tests/src/page-objects/system-diagram/integrated-system-diagram-editor.ts b/e2e-tests/src/page-objects/system-diagram/integrated-system-diagram-editor.ts new file mode 100644 index 00000000..ec927079 --- /dev/null +++ b/e2e-tests/src/page-objects/system-diagram/integrated-system-diagram-editor.ts @@ -0,0 +1,73 @@ +/******************************************************************************** + * Copyright (c) 2024 CrossBreeze. + ********************************************************************************/ + +import { OSUtil, normalizeId, urlEncodePath } from '@theia/playwright'; +import { join } from 'path'; +import { CMCompositeEditor, hasViewError } from '../cm-composite-editor'; +import { IntegratedEditor } from '../cm-integrated-editor'; +import { CMTheiaIntegration } from '../cm-theia-integration'; +import { EntityPropertiesView } from '../form/entiy-form'; +import { Entity } from './diagram-elements'; +import { SystemDiagram } from './system-diagram'; +import { SystemTools } from './system-tool-box'; + +export class IntegratedSystemDiagramEditor extends IntegratedEditor { + diagram: SystemDiagram; + constructor(filePath: string, parent: CMCompositeEditor, tabSelector: string) { + super( + { + tabSelector, + viewSelector: normalizeId( + `#system-diagram:file://${urlEncodePath(join(parent.app.workspace.escapedPath, OSUtil.fileSeparator, filePath))}` + ) + }, + parent + ); + this.diagram = this.createSystemDiagram(parent.app.integration); + } + + override waitForVisible(): Promise { + return this.diagram.graph.waitForVisible(); + } + + protected createSystemDiagram(integration: CMTheiaIntegration): SystemDiagram { + return new SystemDiagram({ type: 'integration', integration }); + } + + async hasError(errorMessage: string): Promise { + return hasViewError(this.page, this.viewSelector, errorMessage); + } + + async enableTool(tool: SystemTools['default']): Promise { + const paletteItem = await this.diagram.toolPalette.content.toolElement('default', tool); + return paletteItem.click(); + } + + async getEntity(entityLabel: string): Promise { + return this.diagram.graph.getNodeByLabel(entityLabel, Entity); + } + + async findEntity(entityLabel: string): Promise { + const entities = await this.diagram.graph.getNodesByLabel(entityLabel, Entity); + return entities.length > 0 ? entities[0] : undefined; + } + + async selectEntityAndOpenProperties(entityLabel: string): Promise { + const entity = await this.diagram.graph.getNodeByLabel(entityLabel, Entity); + await entity.select(); + return this.app.openView(EntityPropertiesView); + } + + override isDirty(): Promise { + return this.parent.isDirty(); + } + + override isClosable(): Promise { + return this.parent.isClosable(); + } + + override closeWithoutSave(): Promise { + return this.parent.closeWithoutSave(); + } +} diff --git a/e2e-tests/src/page-objects/system-diagram/system-diagram.ts b/e2e-tests/src/page-objects/system-diagram/system-diagram.ts index 1ec66738..9d32714f 100644 --- a/e2e-tests/src/page-objects/system-diagram/system-diagram.ts +++ b/e2e-tests/src/page-objects/system-diagram/system-diagram.ts @@ -4,27 +4,35 @@ import { asLocator, + definedAttr, + EdgeSearchOptions, GLSPAppOptions, + GLSPIntegrationOptions, GLSPSemanticApp, GLSPSemanticGraph, GraphConstructorOptions, isEqualLocatorType, isPEdgeConstructor, isPNodeConstructor, + PEdge, + PEdgeConstructor, PMetadata, PModelElement, PModelElementConstructor, PNode, - PNodeConstructor + PNodeConstructor, + SVGMetadata, + SVGMetadataUtils, + TypedEdge } from '@eclipse-glsp/glsp-playwright'; import { Locator } from '@playwright/test'; -import { SystemToolBox, SystemTools } from './system-tool-box'; +import { SystemToolBox } from './system-tool-box'; export class SystemDiagram extends GLSPSemanticApp { override readonly toolPalette: SystemToolBox; override readonly graph: SystemDiagramGraph; - constructor(options: GLSPAppOptions) { + constructor(options: GLSPIntegrationOptions) { super(options); this.toolPalette = this.createToolPalette(); this.graph = this.createGraph(options); @@ -37,14 +45,72 @@ export class SystemDiagram extends GLSPSemanticApp { protected override createToolPalette(): SystemToolBox { return new SystemToolBox({ locator: SystemToolBox.locate(this) }); } - - async enableTool(tool: SystemTools['default']): Promise { - const paletteItem = await this.toolPalette.content.toolElement('default', tool); - return paletteItem.click(); - } } export class SystemDiagramGraph extends GLSPSemanticGraph { + // Temporary fix. The base getNodes methods does not account for "." in ids. The will be falsy treated as class selectors. + override async getEdgesOfType( + constructor: PEdgeConstructor, + options?: TOptions + ): Promise[]> { + const elements: TypedEdge[] = []; + + let query = SVGMetadataUtils.typeAttrOf(constructor); + if (options?.sourceId) { + query += `[${SVGMetadata.Edge.sourceId}="${options.sourceId}"]`; + } else if (options?.targetId) { + query += `[${SVGMetadata.Edge.targetId}="${options.targetId}"]`; + } + + for await (const locator of await this.locate().locator(query).all()) { + const id = await locator.getAttribute('id'); + // eslint-disable-next-line no-null/no-null + if (id !== null && (await isEqualLocatorType(locator, constructor))) { + const element = await this.getEdge(`id=${id}`, constructor, options); + const sourceChecks = []; + const targetChecks = []; + + if (options?.sourceConstructor) { + const sourceId = await element.sourceId(); + sourceChecks.push( + (await this.locate() + .locator(`[id$="${sourceId}"]${SVGMetadataUtils.typeAttrOf(options.sourceConstructor)}`) + .count()) > 0 + ); + } + + if (options?.targetConstructor) { + const targetId = await element.targetId(); + targetChecks.push( + (await this.locate() + .locator(`[id$="${targetId}"]${SVGMetadataUtils.typeAttrOf(options.targetConstructor)}`) + .count()) > 0 + ); + } + + if (options?.sourceSelectorOrLocator) { + const sourceLocator = asLocator(options.sourceSelectorOrLocator, selector => this.locate().locator(selector)); + const sourceId = await element.sourceId(); + const expectedId = await definedAttr(sourceLocator, 'id'); + sourceChecks.push(expectedId.includes(sourceId)); + } + + if (options?.targetSelectorOrLocator) { + const targetLocator = asLocator(options.targetSelectorOrLocator, selector => this.locate().locator(selector)); + const targetId = await element.targetId(); + const expectedId = await definedAttr(targetLocator, 'id'); + sourceChecks.push(expectedId.includes(targetId)); + } + + if (sourceChecks.every(c => c) && targetChecks.every(c => c)) { + elements.push(element); + } + } + } + + return elements; + } + // Temporary fix. The base getNodes methods does not account for "." in ids. The will be falsy treated as class selectors. override async getNodes( selectorOrLocator: string | Locator, @@ -85,4 +151,27 @@ export class SystemDiagramGraph extends GLSPSemanticGraph { return Promise.all(ids.map(id => retriever(`id=${id}`, constructor))); } + + // Temporary fix. The base getNodes methods does not account for "." in ids. The will be falsy treated as class selectors. + override async getModelElements( + selectorOrLocator: string | Locator, + constructor: PModelElementConstructor, + options?: GraphConstructorOptions + ): Promise { + super.getModelElements; + const locator = asLocator(selectorOrLocator, selector => this.locator.child(selector).locate()); + const elements: TElement[] = []; + + for await (const childLocator of await locator.all()) { + if ((await childLocator.count()) > 0) { + const id = await childLocator.getAttribute('id'); + // eslint-disable-next-line no-null/no-null + if (id !== null && (await isEqualLocatorType(childLocator, constructor))) { + elements.push(await this.getModelElement(`id=${id}`, constructor, options)); + } + } + } + + return elements; + } } diff --git a/e2e-tests/src/tests/crossmodel-app.spec.ts b/e2e-tests/src/tests/cm-app.spec.ts similarity index 75% rename from e2e-tests/src/tests/crossmodel-app.spec.ts rename to e2e-tests/src/tests/cm-app.spec.ts index 80dfbb24..04da3fb9 100644 --- a/e2e-tests/src/tests/crossmodel-app.spec.ts +++ b/e2e-tests/src/tests/cm-app.spec.ts @@ -2,13 +2,13 @@ * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ import { expect, test } from '@playwright/test'; -import { CrossModelApp } from '../page-objects/crossmodel-app'; +import { CMApp } from '../page-objects/cm-app'; test.describe('CrossModel App', () => { - let app: CrossModelApp; + let app: CMApp; test.beforeAll(async ({ browser, playwright }) => { - app = await CrossModelApp.load({ browser, playwright }); + app = await CMApp.load({ browser, playwright }); }); test('main content panel visible', async () => { diff --git a/e2e-tests/src/tests/crossmodel-error-view.spec.ts b/e2e-tests/src/tests/cm-error-view.spec.ts similarity index 85% rename from e2e-tests/src/tests/crossmodel-error-view.spec.ts rename to e2e-tests/src/tests/cm-error-view.spec.ts index b3a89567..29a814af 100644 --- a/e2e-tests/src/tests/crossmodel-error-view.spec.ts +++ b/e2e-tests/src/tests/cm-error-view.spec.ts @@ -2,17 +2,17 @@ * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ import { expect, test } from '@playwright/test'; -import { CrossModelApp } from '../page-objects/crossmodel-app'; -import { CrossModelCompositeEditor } from '../page-objects/crossmodel-composite-editor'; +import { CMApp } from '../page-objects/cm-app'; +import { CMCompositeEditor } from '../page-objects/cm-composite-editor'; test.describe('CrossModel Error Views', () => { - let app: CrossModelApp; + let app: CMApp; test.beforeAll(async ({ browser, playwright }) => { - app = await CrossModelApp.load({ browser, playwright }); + app = await CMApp.load({ browser, playwright }); }); test('Form Editor should show error if model code is broken', async () => { - const editor = await app.openEditor('example-entity.entity.cm', CrossModelCompositeEditor); + const editor = await app.openEditor('example-entity.entity.cm', CMCompositeEditor); expect(editor).toBeDefined(); const codeEditor = await editor.switchToCodeEditor(); @@ -29,7 +29,7 @@ test.describe('CrossModel Error Views', () => { }); test('System Diagram Editor should show error if model code is broken', async () => { - const editor = await app.openEditor('example-diagram.diagram.cm', CrossModelCompositeEditor); + const editor = await app.openEditor('example-diagram.diagram.cm', CMCompositeEditor); expect(editor).toBeDefined(); const codeEditor = await editor.switchToCodeEditor(); diff --git a/e2e-tests/src/tests/crossmodel-explorer-view.spec.ts b/e2e-tests/src/tests/cm-explorer-view.spec.ts similarity index 91% rename from e2e-tests/src/tests/crossmodel-explorer-view.spec.ts rename to e2e-tests/src/tests/cm-explorer-view.spec.ts index 87e106f8..afe1caa6 100644 --- a/e2e-tests/src/tests/crossmodel-explorer-view.spec.ts +++ b/e2e-tests/src/tests/cm-explorer-view.spec.ts @@ -2,8 +2,8 @@ * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ import { expect, Page, test } from '@playwright/test'; -import { CrossModelApp } from '../page-objects/crossmodel-app'; -import { CrossModelExplorerView } from '../page-objects/crossmodel-explorer-view'; +import { CMApp } from '../page-objects/cm-app'; +import { CMExplorerView } from '../page-objects/cm-explorer-view'; async function checkOpenWithItem(page: Page, text: string): Promise { // Locate all elements matching the selector @@ -22,11 +22,11 @@ async function checkOpenWithItem(page: Page, text: string): Promise { } test.describe('CrossModel Explorer View', () => { - let app: CrossModelApp; - let explorer: CrossModelExplorerView; + let app: CMApp; + let explorer: CMExplorerView; test.beforeAll(async ({ browser, playwright }) => { - app = await CrossModelApp.load({ browser, playwright }); + app = await CMApp.load({ browser, playwright }); explorer = await app.openExplorerView(); }); diff --git a/e2e-tests/src/tests/crossmodel-tabbar-toolbar.spec.ts b/e2e-tests/src/tests/cm-tabbar-toolbar.spec.ts similarity index 91% rename from e2e-tests/src/tests/crossmodel-tabbar-toolbar.spec.ts rename to e2e-tests/src/tests/cm-tabbar-toolbar.spec.ts index 3658ee16..c75b3c2c 100644 --- a/e2e-tests/src/tests/crossmodel-tabbar-toolbar.spec.ts +++ b/e2e-tests/src/tests/cm-tabbar-toolbar.spec.ts @@ -2,16 +2,16 @@ * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ import { expect, test } from '@playwright/test'; -import { CrossModelApp } from '../page-objects/crossmodel-app'; -import { CrossModelExplorerView } from '../page-objects/crossmodel-explorer-view'; +import { CMApp } from '../page-objects/cm-app'; +import { CMExplorerView } from '../page-objects/cm-explorer-view'; import { TheiaSingleInputDialog } from '../page-objects/theia-single-input-dialog'; test.describe('CrossModel TabBar Toolbar', () => { - let app: CrossModelApp; - let explorer: CrossModelExplorerView; + let app: CMApp; + let explorer: CMExplorerView; test.beforeAll(async ({ browser, playwright }) => { - app = await CrossModelApp.load({ browser, playwright }); + app = await CMApp.load({ browser, playwright }); explorer = await app.openExplorerView(); }); test.beforeEach(async () => { @@ -42,7 +42,7 @@ test.describe('CrossModel TabBar Toolbar', () => { // Wait until the dialog is closed. await newEntityDialog.waitForClosed(); - explorer = await app.openView(CrossModelExplorerView); + explorer = await app.openView(CMExplorerView); const file = await explorer.getFileStatNodeByLabel('entity-created-from-tabbar-toolbar.entity.cm'); expect(file).toBeDefined(); expect(await file.label()).toBe('entity-created-from-tabbar-toolbar.entity.cm'); @@ -72,7 +72,7 @@ test.describe('CrossModel TabBar Toolbar', () => { // Wait until the dialog is closed. await newRelationshipDialog.waitForClosed(); - explorer = await app.openView(CrossModelExplorerView); + explorer = await app.openView(CMExplorerView); const file = await explorer.getFileStatNodeByLabel('relationship-created-from-tabbar-toolbar.relationship.cm'); expect(file).toBeDefined(); expect(await file.label()).toBe('relationship-created-from-tabbar-toolbar.relationship.cm'); @@ -102,7 +102,7 @@ test.describe('CrossModel TabBar Toolbar', () => { // Wait until the dialog is closed. await newDiagramDialog.waitForClosed(); - explorer = await app.openView(CrossModelExplorerView); + explorer = await app.openView(CMExplorerView); const file = await explorer.getFileStatNodeByLabel('diagram-created-from-tabbar-toolbar.system-diagram.cm'); expect(file).toBeDefined(); expect(await file.label()).toBe('diagram-created-from-tabbar-toolbar.system-diagram.cm'); diff --git a/e2e-tests/src/tests/diagram/system-diagram.spec.ts b/e2e-tests/src/tests/diagram/system-diagram.spec.ts deleted file mode 100644 index 40a2accd..00000000 --- a/e2e-tests/src/tests/diagram/system-diagram.spec.ts +++ /dev/null @@ -1,86 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2024 CrossBreeze. - ********************************************************************************/ -import { expect, test } from '@eclipse-glsp/glsp-playwright'; -import { CrossModelApp } from '../../page-objects/crossmodel-app'; -import { CrossModelCompositeEditor } from '../../page-objects/crossmodel-composite-editor'; -import { Entity } from '../../page-objects/system-diagram/diagram-elements'; - -test.describe('Cross Model System Diagram ', () => { - let app: CrossModelApp; - - test.beforeAll(async ({ browser, playwright }) => { - app = await CrossModelApp.load({ browser, playwright, workspaceUrl: 'src/resources/mapping-example' }); - }); - - test.afterAll(async () => { - await app.page.close(); - }); - - test.describe.serial('Entity Create/Edit/Delete', () => { - test('create new entity via toolbox', async () => { - const diagramEditor = await app - .openEditor('ExampleDWH/diagrams/ExampleDWH.system-diagram.cm', CrossModelCompositeEditor) - .then(ed => ed.switchToSystemDiagram()); - const graph = diagramEditor.diagram.graph; - - // Create new entity - const entity = await graph.getNodeByLabel('CalcAge', Entity); - await diagramEditor.diagram.enableTool('Create Entity'); - const taskBounds = await entity.bounds(); - await taskBounds.position('bottom_center').moveRelative(0, 100).click(); - // graph.waitForCreationOfType is currently not working, therefore we use a timeout - await app.page.waitForTimeout(1000); - - // Verify that the entity was created as expected - const newEntity = await graph.getNodeByLabel('NewEntity', Entity); - expect(newEntity).toBeDefined(); - - const explorer = await app.openExplorerView(); - expect(await explorer.existsFileNode('ExampleDWH/entities/NewEntity.entity.cm')).toBeTruthy(); - - const entityCodeEditor = await app - .openEditor('ExampleDWH/entities/NewEntity.entity.cm', CrossModelCompositeEditor) - .then(ed => ed.switchToCodeEditor()); - expect(await entityCodeEditor.textContentOfLineByLineNumber(1)).toBe('entity:'); - expect(await entityCodeEditor.textContentOfLineByLineNumber(2)).toMatch('id: NewEntity'); - expect(await entityCodeEditor.textContentOfLineByLineNumber(3)).toMatch('name: "NewEntity'); - }); - - test('Rename new entity via diagram label', async () => { - const diagramEditor = await app - .openEditor('ExampleDWH/diagrams/ExampleDWH.system-diagram.cm', CrossModelCompositeEditor) - .then(ed => ed.switchToSystemDiagram()); - const graph = diagramEditor.diagram.graph; - const newEntity = await graph.getNodeByLabel('NewEntity', Entity); - // Rename new entity - await newEntity.rename('NewEntityRenamed'); - - expect(await newEntity.label).toBe('NewEntityRenamed'); - const entityCodeEditor = await app - .openEditor('ExampleDWH/entities/NewEntity.entity.cm', CrossModelCompositeEditor) - .then(ed => ed.switchToCodeEditor()); - expect(await entityCodeEditor.textContentOfLineByLineNumber(2)).toMatch('id: NewEntityRenamed'); - expect(await entityCodeEditor.textContentOfLineByLineNumber(3)).toMatch('name: "NewEntityRenamed'); - }); - - test('Hide new entity', async () => { - const diagramEditor = await app - .openEditor('ExampleDWH/diagrams/ExampleDWH.system-diagram.cm', CrossModelCompositeEditor) - .then(ed => ed.switchToSystemDiagram()); - const graph = diagramEditor.diagram.graph; - // Hide entity - const newEntity = await graph.getNodeByLabel('NewEntityRenamed', Entity); - await diagramEditor.diagram.enableTool('Hide'); - await newEntity.click(); - expect((await graph.getNodes('NewEntityRenamed', Entity)).length).toBe(0); - // Todo: Check if entity is actually hidden, i.e. can be shown again via toolbox - }); - - test('Delete new entity', async () => { - const explorer = await app.openExplorerView(); - await explorer.deleteNode('ExampleDWH/entities/NewEntity.entity.cm', true); - // Todo: Check if entity is actually deleted, i.e. can not be shown again via toolbox - }); - }); -}); diff --git a/e2e-tests/src/tests/diagram/system/add-edit-delete.spec.ts b/e2e-tests/src/tests/diagram/system/add-edit-delete.spec.ts new file mode 100644 index 00000000..928bdaf1 --- /dev/null +++ b/e2e-tests/src/tests/diagram/system/add-edit-delete.spec.ts @@ -0,0 +1,98 @@ +/******************************************************************************** + * Copyright (c) 2024 CrossBreeze. + ********************************************************************************/ +import { expect } from '@eclipse-glsp/glsp-playwright'; +import { test } from '@playwright/test'; +import { CMApp } from '../../../page-objects/cm-app'; +import { CMExplorerView } from '../../../page-objects/cm-explorer-view'; +import { Entity } from '../../../page-objects/system-diagram/diagram-elements'; + +test.describe.serial('[System Diagram] Add/Edit/Delete entity in a diagram ', () => { + let app: CMApp; + let explorer: CMExplorerView; + const NEW_ENTITY_LABEL = 'NewEntity'; + const RENAMED_ENTITY_LABEL = 'NewEntityRenamed'; + const RENAMED_ENTITY_DESCRIPTION = 'NewEntityDescription'; + + const entityFileName = (entityName: string): string => `ExampleDWH/entities/${entityName}.entity.cm`; + + test.beforeAll(async ({ browser, playwright }) => { + app = await CMApp.load({ browser, playwright, workspaceUrl: 'src/resources/mapping-example' }); + }); + + test.afterAll(async () => { + await app.page.close(); + }); + + test('create new entity via toolbox', async () => { + const diagramEditor = await app.openCompositeEditor('ExampleDWH/diagrams/ExampleDWH.system-diagram.cm', 'System Diagram'); + explorer = await app.openExplorerView(); + // Create new entity + + await diagramEditor.diagram.graph.waitForCreationOfType(Entity, async () => { + const entity = await diagramEditor.getEntity('CalcAge'); + await diagramEditor.enableTool('Create Entity'); + const taskBounds = await entity.bounds(); + await taskBounds.position('bottom_center').moveRelative(0, 100).click(); + }); + + // Verify that the entity was created as expected + const newEntity = await diagramEditor.getEntity(NEW_ENTITY_LABEL); + expect(newEntity).toBeDefined(); + await diagramEditor.saveAndClose(); + + await explorer.activate(); + const newEntityFile = entityFileName(NEW_ENTITY_LABEL); + expect(await explorer.existsFileNode(newEntityFile)).toBeTruthy(); + + const entityCodeEditor = await app.openCompositeEditor(newEntityFile, 'Code Editor'); + expect(await entityCodeEditor.textContentOfLineByLineNumber(1)).toBe('entity:'); + expect(await entityCodeEditor.textContentOfLineByLineNumber(2)).toMatch(`id: ${NEW_ENTITY_LABEL}`); + expect(await entityCodeEditor.textContentOfLineByLineNumber(3)).toMatch(`name: "${NEW_ENTITY_LABEL}"`); + await entityCodeEditor.saveAndClose(); + }); + + test('Edit entity name & description via properties', async () => { + const diagramEditor = await app.openCompositeEditor('ExampleDWH/diagrams/ExampleDWH.system-diagram.cm', 'System Diagram'); + const properties = await diagramEditor.selectEntityAndOpenProperties(NEW_ENTITY_LABEL); + const form = await properties.form(); + await form.generalSection.setName(RENAMED_ENTITY_LABEL); + await form.generalSection.setDescription(RENAMED_ENTITY_DESCRIPTION); + // Verify that the entity was renamed as expected + expect(await form.generalSection.getName()).toBe(RENAMED_ENTITY_LABEL); + expect(await form.generalSection.getDescription()).toBe(RENAMED_ENTITY_DESCRIPTION); + await properties.saveAndClose(); + await diagramEditor.activate(); + await diagramEditor.saveAndClose(); + + await explorer.activate(); + const renamedEntityFile = entityFileName(NEW_ENTITY_LABEL); + await explorer.waitForTreeNodeVisible(renamedEntityFile); + + const entityCodeEditor = await app.openCompositeEditor(renamedEntityFile, 'Code Editor'); + + expect(await entityCodeEditor.textContentOfLineByLineNumber(3)).toMatch(`name: "${RENAMED_ENTITY_LABEL}"`); + expect(await entityCodeEditor.textContentOfLineByLineNumber(4)).toMatch(`description: "${RENAMED_ENTITY_DESCRIPTION}"`); + await entityCodeEditor.saveAndClose(); + }); + + test('Hide new entity', async () => { + const diagramEditor = await app.openCompositeEditor('ExampleDWH/diagrams/ExampleDWH.system-diagram.cm', 'System Diagram'); + const renamedEntity = await diagramEditor.getEntity(RENAMED_ENTITY_LABEL); + // Hide entity + await diagramEditor.enableTool('Hide'); + await renamedEntity.click(); + await renamedEntity.waitFor({ state: 'detached' }); + // Verify that the entity was hidden as expected + expect(await diagramEditor.findEntity(RENAMED_ENTITY_LABEL)).toBeUndefined(); + // Todo: Check if entity is actually hidden, i.e. can be shown again via toolbox + await diagramEditor.saveAndClose(); + }); + + test('Delete new entity', async () => { + await explorer.activate(); + const entityFile = entityFileName(NEW_ENTITY_LABEL); + await explorer.deleteNode(entityFile, true); + // Todo: Check if entity is actually deleted, i.e. can not be shown again via toolbox + }); +}); diff --git a/packages/react-model-ui/src/views/form/Header.tsx b/packages/react-model-ui/src/views/form/Header.tsx index ad00fb87..6a07117e 100644 --- a/packages/react-model-ui/src/views/form/Header.tsx +++ b/packages/react-model-ui/src/views/form/Header.tsx @@ -27,7 +27,7 @@ export function Header({ name, id, iconClass }: HeaderProps): React.ReactElement {iconClass && } - + {name} {saveModel && dirty ? '*' : ''}