From 23a5ebf650578de607ffa43073c189c08931579f Mon Sep 17 00:00:00 2001 From: Haydar Metin Date: Thu, 20 Feb 2025 11:21:22 +0100 Subject: [PATCH] Reduce flakiness --- examples/workflow-test/playwright.config.ts | 2 +- .../workflow-test/src/app/workflow-app.ts | 9 ++ .../tests/core/connectable-element.spec.ts | 1 + .../tests/core/debug.standalone.spec.ts | 3 +- .../workflow-test/tests/core/edge.spec.ts | 1 + .../tests/core/features/context-menu.spec.ts | 1 + .../workflow-test/tests/core/graph.spec.ts | 1 + .../workflow-test/tests/core/parent.spec.ts | 1 + .../tests/core/shortcuts.spec.ts | 1 + .../change-bounds/resize-handle.spec.ts | 1 + .../command-palette/command-palette.spec.ts | 1 + .../tests/features/hover/popup.spec.ts | 1 + .../label-edit/label-edit-tool.spec.ts | 4 +- .../features/routing/routing-point.spec.ts | 1 + .../tests/features/select/select.spec.ts | 3 +- .../tool-palette/tool-palette.spec.ts | 1 + .../tools/deletion/deletion-tool.spec.ts | 1 + .../edge-creation/edge-creation-tool.spec.ts | 1 + .../tools/edge-edit/edge-edit-tool.spec.ts | 15 +- .../node-creation/node-creation-tool.spec.ts | 2 + .../features/undo-redo/undo-redo.spec.ts | 1 + .../validation/marker-navigator.spec.ts | 1 + .../tests/features/validation/marker.spec.ts | 1 + packages/glsp-playwright/src/debug/utils.ts | 4 +- .../glsp-playwright/src/glsp/app/app.po.ts | 4 + .../glsp/features/routing/routing-point.po.ts | 38 ++--- .../content/tool-palette-content-item.po.ts | 12 +- .../src/glsp/graph/graph.po.ts | 139 +++++++++++++----- .../src/glsp/graph/graph.wait.ts | 78 ---------- .../glsp-playwright/src/glsp/graph/index.ts | 1 - .../standalone/standalone.integration.ts | 3 +- .../glsp-playwright/src/remote/locateable.ts | 6 +- .../glsp-playwright/src/remote/locator.ts | 37 ++++- .../glsp-playwright/src/test/assertions.ts | 16 +- 34 files changed, 228 insertions(+), 164 deletions(-) delete mode 100644 packages/glsp-playwright/src/glsp/graph/graph.wait.ts diff --git a/examples/workflow-test/playwright.config.ts b/examples/workflow-test/playwright.config.ts index 0a8e2c2..f3c2de2 100644 --- a/examples/workflow-test/playwright.config.ts +++ b/examples/workflow-test/playwright.config.ts @@ -34,7 +34,7 @@ const config: PlaywrightTestConfig = { }, fullyParallel: true, forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 1, + retries: process.env.CI ? 2 : 0, reporter: process.env.CI ? [['html', { open: 'never' }], ['@estruyf/github-actions-reporter']] : [['html', { open: 'never' }]], use: { actionTimeout: 0, diff --git a/examples/workflow-test/src/app/workflow-app.ts b/examples/workflow-test/src/app/workflow-app.ts index 7ba7242..cdf85f5 100644 --- a/examples/workflow-test/src/app/workflow-app.ts +++ b/examples/workflow-test/src/app/workflow-app.ts @@ -14,6 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ import { GLSPSemanticApp } from '@eclipse-glsp/glsp-playwright'; +import { expect } from '@eclipse-glsp/glsp-playwright/lib/test'; import { WorkflowToolPalette } from '../features/tool-palette/workflow-tool-palette'; import { WorkflowGraph } from '../graph/workflow.graph'; @@ -28,4 +29,12 @@ export class WorkflowApp extends GLSPSemanticApp { protected override createToolPalette(): WorkflowToolPalette { return new WorkflowToolPalette({ locator: WorkflowToolPalette.locate(this) }); } + + /** + * Wait for the application to be ready. + * The server can take some time to send the data. + */ + async waitForReady(): Promise { + await expect(this.locate().getByText('Push')).toBeVisible(); + } } diff --git a/examples/workflow-test/tests/core/connectable-element.spec.ts b/examples/workflow-test/tests/core/connectable-element.spec.ts index 572f7c3..3efe7ce 100644 --- a/examples/workflow-test/tests/core/connectable-element.spec.ts +++ b/examples/workflow-test/tests/core/connectable-element.spec.ts @@ -30,6 +30,7 @@ test.describe('The edge accessor of a connectable element', () => { type: 'integration', integration }); + await app.waitForReady(); graph = app.graph; }); diff --git a/examples/workflow-test/tests/core/debug.standalone.spec.ts b/examples/workflow-test/tests/core/debug.standalone.spec.ts index 033837c..2b2c90b 100644 --- a/examples/workflow-test/tests/core/debug.standalone.spec.ts +++ b/examples/workflow-test/tests/core/debug.standalone.spec.ts @@ -46,7 +46,7 @@ const expectedElementMetadata = { const expectedGLSPLocatorData = [ { locator: - "locator('body').locator('div.sprotty:not(.sprotty-hidden)').locator('[data-svg-metadata-type=\"graph\"]').locator('[id$=\"task_Push\"]').and(locator('body').locator('div.sprotty:not(.sprotty-hidden)').locator('[data-svg-metadata-type=\"graph\"]').locator('[data-svg-metadata-type=\"task:manual\"]'))", + "locator('body').locator('div.sprotty:not(.sprotty-hidden)').locator('[data-svg-metadata-type=\"graph\"]').locator('[id$=\"task_Push\"]').and(locator('body').locator('[data-svg-metadata-type=\"task:manual\"]'))", children: [ '...' ] @@ -67,6 +67,7 @@ test.describe('The debug functions', () => { type: 'integration', integration }); + await app.waitForReady(); graph = app.graph; }); diff --git a/examples/workflow-test/tests/core/edge.spec.ts b/examples/workflow-test/tests/core/edge.spec.ts index d4297ef..5fa83cb 100644 --- a/examples/workflow-test/tests/core/edge.spec.ts +++ b/examples/workflow-test/tests/core/edge.spec.ts @@ -30,6 +30,7 @@ test.describe('Edges', () => { type: 'integration', integration }); + await app.waitForReady(); graph = app.graph; }); diff --git a/examples/workflow-test/tests/core/features/context-menu.spec.ts b/examples/workflow-test/tests/core/features/context-menu.spec.ts index 7351bdf..afa77c0 100644 --- a/examples/workflow-test/tests/core/features/context-menu.spec.ts +++ b/examples/workflow-test/tests/core/features/context-menu.spec.ts @@ -25,6 +25,7 @@ test.describe('The context menu', () => { type: 'integration', integration }); + await app.waitForReady(); contextMenu = app.contextMenu; }); diff --git a/examples/workflow-test/tests/core/graph.spec.ts b/examples/workflow-test/tests/core/graph.spec.ts index bdb0a80..1bb9ac0 100644 --- a/examples/workflow-test/tests/core/graph.spec.ts +++ b/examples/workflow-test/tests/core/graph.spec.ts @@ -29,6 +29,7 @@ test.describe('The graph', () => { type: 'integration', integration }); + await app.waitForReady(); graph = app.graph; }); diff --git a/examples/workflow-test/tests/core/parent.spec.ts b/examples/workflow-test/tests/core/parent.spec.ts index dfa012e..0154188 100644 --- a/examples/workflow-test/tests/core/parent.spec.ts +++ b/examples/workflow-test/tests/core/parent.spec.ts @@ -29,6 +29,7 @@ test.describe('The children accessor of a parent element', () => { type: 'integration', integration }); + await app.waitForReady(); graph = app.graph; }); diff --git a/examples/workflow-test/tests/core/shortcuts.spec.ts b/examples/workflow-test/tests/core/shortcuts.spec.ts index 43cbbc6..071ff27 100644 --- a/examples/workflow-test/tests/core/shortcuts.spec.ts +++ b/examples/workflow-test/tests/core/shortcuts.spec.ts @@ -28,6 +28,7 @@ test.describe('Shortcuts', () => { type: 'integration', integration }); + await app.waitForReady(); graph = app.graph; }); diff --git a/examples/workflow-test/tests/features/change-bounds/resize-handle.spec.ts b/examples/workflow-test/tests/features/change-bounds/resize-handle.spec.ts index 5185425..9169e3d 100644 --- a/examples/workflow-test/tests/features/change-bounds/resize-handle.spec.ts +++ b/examples/workflow-test/tests/features/change-bounds/resize-handle.spec.ts @@ -28,6 +28,7 @@ test.describe('The resizing handle', () => { type: 'integration', integration }); + await app.waitForReady(); graph = app.graph; }); diff --git a/examples/workflow-test/tests/features/command-palette/command-palette.spec.ts b/examples/workflow-test/tests/features/command-palette/command-palette.spec.ts index 0631c49..85f9dc9 100644 --- a/examples/workflow-test/tests/features/command-palette/command-palette.spec.ts +++ b/examples/workflow-test/tests/features/command-palette/command-palette.spec.ts @@ -33,6 +33,7 @@ test.describe('The command palette', () => { type: 'integration', integration }); + await app.waitForReady(); graph = app.graph; globalCommandPalette = app.globalCommandPalette; server = glspServer; diff --git a/examples/workflow-test/tests/features/hover/popup.spec.ts b/examples/workflow-test/tests/features/hover/popup.spec.ts index 3643662..deb806f 100644 --- a/examples/workflow-test/tests/features/hover/popup.spec.ts +++ b/examples/workflow-test/tests/features/hover/popup.spec.ts @@ -78,6 +78,7 @@ test.describe('The popup', () => { type: 'integration', integration }); + await app.waitForReady(); graph = app.graph; expectedManualPopupText.setServer(glspServer); expectedAutomatedPopupText.setServer(glspServer); diff --git a/examples/workflow-test/tests/features/label-edit/label-edit-tool.spec.ts b/examples/workflow-test/tests/features/label-edit/label-edit-tool.spec.ts index 7d3ccb2..87d00cf 100644 --- a/examples/workflow-test/tests/features/label-edit/label-edit-tool.spec.ts +++ b/examples/workflow-test/tests/features/label-edit/label-edit-tool.spec.ts @@ -28,6 +28,7 @@ test.describe('The label edit tool', () => { type: 'integration', integration }); + await app.waitForReady(); graph = app.graph; }); @@ -45,6 +46,7 @@ test.describe('The label edit tool', () => { await node.page.keyboard.press('F2'); await node.page.keyboard.type('New Label'); await node.page.keyboard.press('Enter'); + await app.labelEditor.waitForHidden(); expect(await node.label).toBe('New Label'); }); @@ -58,7 +60,7 @@ test.describe('The label edit tool', () => { await node.page.keyboard.press('Backspace'); await node.page.keyboard.press('Enter'); - await expect(await app.labelEditor.getWarning()).toBe('Name must not be empty'); + expect(await app.labelEditor.getWarning()).toBe('Name must not be empty'); }); test.afterEach(async ({ integration }) => { diff --git a/examples/workflow-test/tests/features/routing/routing-point.spec.ts b/examples/workflow-test/tests/features/routing/routing-point.spec.ts index 5586d7f..bd452c8 100644 --- a/examples/workflow-test/tests/features/routing/routing-point.spec.ts +++ b/examples/workflow-test/tests/features/routing/routing-point.spec.ts @@ -28,6 +28,7 @@ test.describe('The routing points of an edge', () => { type: 'integration', integration }); + await app.waitForReady(); graph = app.graph; }); diff --git a/examples/workflow-test/tests/features/select/select.spec.ts b/examples/workflow-test/tests/features/select/select.spec.ts index faf1efc..9c0316c 100644 --- a/examples/workflow-test/tests/features/select/select.spec.ts +++ b/examples/workflow-test/tests/features/select/select.spec.ts @@ -28,6 +28,7 @@ test.describe('The select feature', () => { type: 'integration', integration }); + await app.waitForReady(); graph = app.graph; }); @@ -77,7 +78,7 @@ test.describe('The select feature', () => { await app.page.keyboard.press('Control+A'); await expect(graph).toHaveSelected({ type: PModelElement, - elements: await graph.getAllModelElements() + elements: () => graph.getAllModelElements() }); }); diff --git a/examples/workflow-test/tests/features/tool-palette/tool-palette.spec.ts b/examples/workflow-test/tests/features/tool-palette/tool-palette.spec.ts index 531ac72..ce80260 100644 --- a/examples/workflow-test/tests/features/tool-palette/tool-palette.spec.ts +++ b/examples/workflow-test/tests/features/tool-palette/tool-palette.spec.ts @@ -28,6 +28,7 @@ test.describe('The tool palette', () => { type: 'integration', integration }); + await app.waitForReady(); graph = app.graph; toolPalette = app.toolPalette; }); diff --git a/examples/workflow-test/tests/features/tools/deletion/deletion-tool.spec.ts b/examples/workflow-test/tests/features/tools/deletion/deletion-tool.spec.ts index b706fd8..697e229 100644 --- a/examples/workflow-test/tests/features/tools/deletion/deletion-tool.spec.ts +++ b/examples/workflow-test/tests/features/tools/deletion/deletion-tool.spec.ts @@ -30,6 +30,7 @@ test.describe('The deletion tool', () => { type: 'integration', integration }); + await app.waitForReady(); graph = app.graph; toolPalette = app.toolPalette; }); diff --git a/examples/workflow-test/tests/features/tools/edge-creation/edge-creation-tool.spec.ts b/examples/workflow-test/tests/features/tools/edge-creation/edge-creation-tool.spec.ts index fd36079..99b485e 100644 --- a/examples/workflow-test/tests/features/tools/edge-creation/edge-creation-tool.spec.ts +++ b/examples/workflow-test/tests/features/tools/edge-creation/edge-creation-tool.spec.ts @@ -35,6 +35,7 @@ test.describe('The edge creation tool', () => { type: 'integration', integration }); + await app.waitForReady(); graph = app.graph; toolPalette = app.toolPalette; }); diff --git a/examples/workflow-test/tests/features/tools/edge-edit/edge-edit-tool.spec.ts b/examples/workflow-test/tests/features/tools/edge-edit/edge-edit-tool.spec.ts index 1ef0555..fa92545 100644 --- a/examples/workflow-test/tests/features/tools/edge-edit/edge-edit-tool.spec.ts +++ b/examples/workflow-test/tests/features/tools/edge-edit/edge-edit-tool.spec.ts @@ -14,6 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ import { expect, test } from '@eclipse-glsp/glsp-playwright'; +import { PMetadata, VolatileRoutingPoint } from '@eclipse-glsp/glsp-playwright/src/glsp'; import { WorkflowApp } from '../../../../src/app/workflow-app'; import { Edge } from '../../../../src/graph/elements/edge.po'; import { TaskAutomated } from '../../../../src/graph/elements/task-automated.po'; @@ -30,6 +31,7 @@ test.describe('The edge edit tool', () => { type: 'integration', integration }); + await app.waitForReady(); graph = app.graph; }); @@ -59,7 +61,9 @@ test.describe('The edge edit tool', () => { expect(volatilePoints).toHaveLength(1); const volatilePoint = volatilePoints[0]; - await volatilePoint.dragToRelativePosition({ x: 50, y: 50 }); + await app.graph.waitForReplacement(PMetadata.getType(VolatileRoutingPoint), async () => { + await volatilePoint.dragToRelativePosition({ x: 50, y: 50 }); + }); points = await routingPoints.points(); expect(points).toHaveLength(currentPointsLength + 1); @@ -81,7 +85,9 @@ test.describe('The edge edit tool', () => { expect(volatilePoints).toHaveLength(1); // Middle one - await volatilePoints[0].dragToRelativePosition({ x: 0, y: 50 }); + await app.graph.waitForReplacement(PMetadata.getType(VolatileRoutingPoint), async () => { + await volatilePoints[0].dragToRelativePosition({ x: 0, y: 50 }); + }); points = await routingPoints.points(); expect(points).toHaveLength(currentPointsLength + 1); @@ -91,8 +97,9 @@ test.describe('The edge edit tool', () => { // Junction const junction = points.find(p => p.lastSnapshot?.kind === 'junction')!; - await junction.dragToRelativePosition({ x: 20, y: -40 }); - await junction.waitForHidden(); + await app.graph.waitForHide(junction.locator, async () => { + await junction.dragToRelativePosition({ x: 20, y: -40 }); + }); points = await routingPoints.points(); expect(points).toHaveLength(currentPointsLength); diff --git a/examples/workflow-test/tests/features/tools/node-creation/node-creation-tool.spec.ts b/examples/workflow-test/tests/features/tools/node-creation/node-creation-tool.spec.ts index e68d745..d4efd00 100644 --- a/examples/workflow-test/tests/features/tools/node-creation/node-creation-tool.spec.ts +++ b/examples/workflow-test/tests/features/tools/node-creation/node-creation-tool.spec.ts @@ -36,6 +36,7 @@ test.describe('The node creation tool', () => { type: 'integration', integration }); + await app.waitForReady(); graph = app.graph; toolPalette = app.toolPalette; taskManualCreatedLabel = TaskManualNodes.createdLabel(glspServer); @@ -69,6 +70,7 @@ test.describe('The node creation tool', () => { await paletteItem.click(); await expect(graph).toContainClass(CursorCSS.NODE_CREATION); + const node = await graph.getNodeByLabel(TaskManualNodes.pushLabel, TaskManual); const bounds = await node.bounds(); await bounds.position('bottom_left').moveRelative(0, 60).click(); diff --git a/examples/workflow-test/tests/features/undo-redo/undo-redo.spec.ts b/examples/workflow-test/tests/features/undo-redo/undo-redo.spec.ts index c8a7be4..f649477 100644 --- a/examples/workflow-test/tests/features/undo-redo/undo-redo.spec.ts +++ b/examples/workflow-test/tests/features/undo-redo/undo-redo.spec.ts @@ -31,6 +31,7 @@ test.describe('The undo redo trigger', () => { type: 'integration', integration }); + await app.waitForReady(); graph = app.graph; trigger = provideUndoRedoTriggerVariable(integration, app).get(); }); diff --git a/examples/workflow-test/tests/features/validation/marker-navigator.spec.ts b/examples/workflow-test/tests/features/validation/marker-navigator.spec.ts index e644c80..0834a02 100644 --- a/examples/workflow-test/tests/features/validation/marker-navigator.spec.ts +++ b/examples/workflow-test/tests/features/validation/marker-navigator.spec.ts @@ -40,6 +40,7 @@ test.describe('The marker navigator', () => { type: 'integration', integration }); + await app.waitForReady(); graph = app.graph; navigator = provideMarkerNavigatorVariable(integration, app).get(); await navigator.trigger(); diff --git a/examples/workflow-test/tests/features/validation/marker.spec.ts b/examples/workflow-test/tests/features/validation/marker.spec.ts index 56da440..29ab966 100644 --- a/examples/workflow-test/tests/features/validation/marker.spec.ts +++ b/examples/workflow-test/tests/features/validation/marker.spec.ts @@ -30,6 +30,7 @@ test.describe('The marker', () => { type: 'integration', integration }); + await app.waitForReady(); graph = app.graph; }); diff --git a/packages/glsp-playwright/src/debug/utils.ts b/packages/glsp-playwright/src/debug/utils.ts index cd19eff..a233f31 100644 --- a/packages/glsp-playwright/src/debug/utils.ts +++ b/packages/glsp-playwright/src/debug/utils.ts @@ -22,6 +22,7 @@ export interface DebugNode { type: string | null; parent: string | null; html: string | null; + class: string | null; children: DebugNode[]; } @@ -57,7 +58,8 @@ export async function extractElement(locator: Locator): Promise { type: await locator.getAttribute(SVGMetadata.type), parent: await locator.getAttribute(SVGMetadata.parentId), children: [], - html: await (await locator.allTextContents()).join(',') + html: (await locator.allTextContents()).join(','), + class: await locator.getAttribute('class') }; } diff --git a/packages/glsp-playwright/src/glsp/app/app.po.ts b/packages/glsp-playwright/src/glsp/app/app.po.ts index c13f0ae..fa32a05 100644 --- a/packages/glsp-playwright/src/glsp/app/app.po.ts +++ b/packages/glsp-playwright/src/glsp/app/app.po.ts @@ -127,6 +127,10 @@ export class GLSPApp { this.locator = this.rootLocator.child(this.sprottySelector); } + locate(): Locator { + return this.locator.locate(); + } + protected createGraph(_options: GLSPAppOptions): GLSPGraph { return new GLSPSemanticGraph({ locator: GLSPGraph.locate(this) }); } diff --git a/packages/glsp-playwright/src/glsp/features/routing/routing-point.po.ts b/packages/glsp-playwright/src/glsp/features/routing/routing-point.po.ts index 8aeedf0..e0b02ff 100644 --- a/packages/glsp-playwright/src/glsp/features/routing/routing-point.po.ts +++ b/packages/glsp-playwright/src/glsp/features/routing/routing-point.po.ts @@ -17,7 +17,6 @@ import type { Locator } from '@playwright/test'; import type { AutoPrepareOptions, AutoWaitOptions } from '~/extension'; import { Clickable, Mix, useDraggableFlow } from '~/extension'; import { ModelElementMetadata, PEdge, PMetadata, PModelElement, PModelElementData, PModelElementSnapshot, SVGMetadata } from '~/glsp/graph'; -import type { GLSPLocator } from '~/remote'; import type { Position } from '~/types'; import { definedAttr, definedGLSPAttr } from '~/utils/ts.utils'; @@ -55,9 +54,9 @@ export class RoutingPoints { for await (const childLocator of await this.pointsLocator.all()) { const id = await definedAttr(childLocator, 'id'); - const routingPoint = new RoutingPoint(this.element.locator.child(`id=${id}`), this); - await routingPoint.snapshot(); - elements.push(routingPoint); + const point = new RoutingPoint({ locator: this.element.locator.child(`id=${id}`), routingPoints: this }); + await point.snapshot(); + elements.push(point); } return elements; @@ -66,14 +65,17 @@ export class RoutingPoints { async volatilePoints(options?: AutoWaitOptions): Promise { await this.autoWait(options); - const elements: RoutingPoint[] = []; + const elements: VolatileRoutingPoint[] = []; for await (const childLocator of await this.volatilePointsLocator.all()) { const id = await definedAttr(childLocator, 'id'); - const routingPoint = new RoutingPoint(this.element.locator.child(`id=${id}`), this); - await routingPoint.snapshot(); - elements.push(routingPoint); + const point = new VolatileRoutingPoint({ + locator: this.element.locator.child(`id=${id}`), + routingPoints: this + }); + await point.snapshot(); + elements.push(point); } return elements; @@ -84,12 +86,16 @@ export interface RoutingPointSnapshot extends PModelElementSnapshot { kind: RoutingPointKind; } +export interface BaseRoutingPointData extends PModelElementData { + routingPoints: RoutingPoints; +} + const BaseRoutingPointMixin = Mix(PModelElement).flow(useDraggableFlow).build(); export abstract class BaseRoutingPoint extends BaseRoutingPointMixin { readonly routingPoints; override lastSnapshot?: RoutingPointSnapshot | undefined; - constructor(data: PModelElementData & { routingPoints: RoutingPoints }) { + constructor(data: BaseRoutingPointData) { super(data); this.routingPoints = data.routingPoints; @@ -155,11 +161,8 @@ export abstract class BaseRoutingPoint extends BaseRoutingPointMixin { type: 'routing-point' }) export class RoutingPoint extends BaseRoutingPoint { - constructor(locator: GLSPLocator, routingPoints: RoutingPoints) { - super({ - routingPoints, - locator - }); + constructor(data: BaseRoutingPointData) { + super(data); } } @@ -167,10 +170,7 @@ export class RoutingPoint extends BaseRoutingPoint { type: 'volatile-routing-point' }) export class VolatileRoutingPoint extends BaseRoutingPoint { - constructor(locator: GLSPLocator, routingPoints: RoutingPoints) { - super({ - routingPoints, - locator - }); + constructor(data: BaseRoutingPointData) { + super(data); } } diff --git a/packages/glsp-playwright/src/glsp/features/tool-palette/content/tool-palette-content-item.po.ts b/packages/glsp-playwright/src/glsp/features/tool-palette/content/tool-palette-content-item.po.ts index 4733eb3..84e14f3 100644 --- a/packages/glsp-playwright/src/glsp/features/tool-palette/content/tool-palette-content-item.po.ts +++ b/packages/glsp-playwright/src/glsp/features/tool-palette/content/tool-palette-content-item.po.ts @@ -13,11 +13,13 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +import { type Locator } from '@playwright/test'; import { useClickableFlow } from '~/extension/flows'; import { Mix } from '~/extension/mixin'; import type { GLSPLocator } from '~/remote/locator'; import type { ConstructorT } from '~/types'; import { definedGLSPAttr } from '~/utils/ts.utils'; +import { expect } from '../../../../test'; import { BaseToolPaletteItem } from '../tool-palette-item.base'; import type { ToolPaletteContentGroup } from './tool-palette-content-group.po'; @@ -28,7 +30,10 @@ export type ToolPaletteContentItemConstructor< const ToolPaletteContentItemMixin = Mix(BaseToolPaletteItem).flow(useClickableFlow).build(); export class ToolPaletteContentItem extends ToolPaletteContentItemMixin { - constructor(locator: GLSPLocator, public readonly toolGroup: TToolGroup) { + constructor( + locator: GLSPLocator, + public readonly toolGroup: TToolGroup + ) { super(locator, toolGroup.toolPalette); } @@ -43,4 +48,9 @@ export class ToolPaletteContentItem async text(): Promise { return this.locate().innerText(); } + + override async click(options?: Parameters[0] & { dispatch?: boolean }): Promise { + await super.click(options); + await expect(this.locate()).toContainClass('clicked'); + } } diff --git a/packages/glsp-playwright/src/glsp/graph/graph.po.ts b/packages/glsp-playwright/src/glsp/graph/graph.po.ts index 2ee6597..2115804 100644 --- a/packages/glsp-playwright/src/glsp/graph/graph.po.ts +++ b/packages/glsp-playwright/src/glsp/graph/graph.po.ts @@ -15,15 +15,15 @@ ********************************************************************************/ import type { Locator } from '@playwright/test'; import type { GLSPApp } from '~/glsp'; -import { asLocator, type GLSPLocator } from '~/remote'; +import { GLSPLocator, type LocateContext } from '~/remote'; import { definedAttr, isUndefinedOrValue } from '~/utils/ts.utils'; +import { expect } from '../../test'; import { ModelElementMetadata, PMetadata } from './decorators'; import { assertEqualType, createTypedEdgeProxy, getPModelElementConstructorOfType } from './elements'; import { isPEdgeConstructor, PEdge, PEdgeConstructor } from './elements/edge'; import { isEqualLocatorType, PModelElement, PModelElementConstructor } from './elements/element'; import { isPNodeConstructor, PNode, PNodeConstructor } from './elements/node'; -import type { EdgeConstructorOptions, EdgeSearchOptions, ElementQuery, GraphConstructorOptions, TypedEdge } from './graph.type'; -import { waitForElementChanges, waitForElementIncrease } from './graph.wait'; +import type { EdgeConstructorOptions, EdgeSearchOptions, GraphConstructorOptions, TypedEdge } from './graph.type'; import { SVGMetadata, SVGMetadataUtils } from './svg-metadata-api'; export interface GLSPGraphOptions { @@ -54,12 +54,16 @@ export class GLSPGraph extends PModelElement { }); } + protected getLocatorForType(constructor: PModelElementConstructor, context: LocateContext = 'self'): Locator { + return this.locate(context).locator(SVGMetadataUtils.typeAttrOf(constructor)); + } + async getModelElement( selectorOrLocator: string | Locator, constructor: PModelElementConstructor, options?: GraphConstructorOptions ): Promise { - const locator = asLocator(selectorOrLocator, selector => this.locator.child(selector).locate()); + const locator = GLSPLocator.asLocator(selectorOrLocator, selector => this.locator.child(selector).locate()); const element = new constructor({ locator: this.locator.override(locator) }); if (options === undefined || isUndefinedOrValue(options.assert, true)) { await assertEqualType(element); @@ -72,7 +76,7 @@ export class GLSPGraph extends PModelElement { constructor: PModelElementConstructor, options?: GraphConstructorOptions ): Promise { - return this.getModelElements(this.locate().locator(SVGMetadataUtils.typeAttrOf(constructor)), constructor, options); + return this.getModelElements(this.getLocatorForType(constructor), constructor, options); } async getModelElements( @@ -80,7 +84,7 @@ export class GLSPGraph extends PModelElement { constructor: PModelElementConstructor, options?: GraphConstructorOptions ): Promise { - const locator = asLocator(selectorOrLocator, selector => this.locator.child(selector).locate()); + const locator = GLSPLocator.asLocator(selectorOrLocator, selector => this.locator.child(selector).locate()); const elements: TElement[] = []; for await (const childLocator of await locator.all()) { @@ -104,9 +108,9 @@ export class GLSPGraph extends PModelElement { constructor: PNodeConstructor, options?: GraphConstructorOptions ): Promise { - const locator = asLocator(selectorOrLocator, selector => this.locator.child(selector).locate()); + const locator = GLSPLocator.asLocator(selectorOrLocator, selector => this.locator.child(selector).locate()); const element = new constructor({ - locator: this.locator.override(locator.and(this.locate().locator(SVGMetadataUtils.typeAttrOf(constructor)))) + locator: this.locator.override(locator.and(this.getLocatorForType(constructor, 'root'))) }); if (options === undefined || isUndefinedOrValue(options.assert, true)) { await assertEqualType(element); @@ -118,7 +122,7 @@ export class GLSPGraph extends PModelElement { constructor: PNodeConstructor, options?: GraphConstructorOptions ): Promise { - return this.getNodes(this.locate().locator(SVGMetadataUtils.typeAttrOf(constructor)), constructor, options); + return this.getNodes(this.getLocatorForType(constructor), constructor, options); } async getNodes( @@ -126,7 +130,7 @@ export class GLSPGraph extends PModelElement { constructor: PNodeConstructor, options?: GraphConstructorOptions ): Promise { - const locator = asLocator(selectorOrLocator, selector => this.locator.child(selector).locate()); + const locator = GLSPLocator.asLocator(selectorOrLocator, selector => this.locator.child(selector).locate()); const elements: TElement[] = []; for await (const childLocator of await locator.all()) { @@ -146,9 +150,9 @@ export class GLSPGraph extends PModelElement { constructor: PEdgeConstructor, options?: TOptions ): Promise> { - const locator = asLocator(selectorOrLocator, selector => this.locator.child(selector).locate()); + const locator = GLSPLocator.asLocator(selectorOrLocator, selector => this.locator.child(selector).locate()); const element = new constructor({ - locator: this.locator.override(locator.and(this.locate().locator(SVGMetadataUtils.typeAttrOf(constructor)))) + locator: this.locator.override(locator.and(this.getLocatorForType(constructor, 'root'))) }); await assertEqualType(element); return createTypedEdgeProxy(element, options); @@ -193,14 +197,18 @@ export class GLSPGraph extends PModelElement { } if (options?.sourceSelectorOrLocator) { - const sourceLocator = asLocator(options.sourceSelectorOrLocator, selector => this.locate().locator(selector)); + const sourceLocator = GLSPLocator.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 targetLocator = GLSPLocator.asLocator(options.targetSelectorOrLocator, selector => + this.locate().locator(selector) + ); const targetId = await element.targetId(); const expectedId = await definedAttr(targetLocator, 'id'); sourceChecks.push(expectedId.includes(targetId)); @@ -222,6 +230,10 @@ export class GLSPGraph extends PModelElement { await this.locate().click(); } + /** + * Waits for the creation of an element of a specific type. + * The creation is detected by waiting for a new element to be visible. + */ async waitForCreationOfType( constructor: PModelElementConstructor, creator: () => Promise @@ -230,39 +242,86 @@ export class GLSPGraph extends PModelElement { const ids = await this.waitForCreation(elementType, creator); - let retriever = this.getModelElement.bind(this); - if (isPNodeConstructor(constructor)) { - retriever = this.getNode.bind(this) as any; - } else if (isPEdgeConstructor(constructor)) { - retriever = this.getEdge.bind(this) as any; + return this.idsAsElements(ids, constructor); + } + + /** + * Waits for the creation of an element of a specific type. + * The creation is detected by waiting for a new element to be visible. + */ + async waitForCreation(elementType: string, creator: () => Promise): Promise { + const nodes = await this.getModelElementsOfType(getPModelElementConstructorOfType(elementType)); + const ids = await Promise.all(nodes.map(n => n.idAttr())); + + await expect(async () => { + await creator(); + }).toPass(); + + const ignore = ['.ghost-element', ...ids.map(id => `[id="${id}"]`)]; + const createdLocator = this.locate().locator(`[data-svg-metadata-type="${elementType}"]:not(${ignore.join(',')})`); + + await expect(createdLocator.first()).toBeVisible(); + await expect(createdLocator.last()).toBeVisible(); + + const newIds: string[] = []; + for (const locator of await createdLocator.all()) { + newIds.push(await definedAttr(locator, 'id')); } - return Promise.all(ids.map(id => retriever(`id=${id}`, constructor))); + return newIds; } - async waitForCreation(elementType: string, creator: () => Promise): Promise { - const query: ElementQuery = { - elementType, - all: () => this.getModelElementsOfType(getPModelElementConstructorOfType(elementType)), - filter: async elements => { - const filtered: PModelElement[] = []; - - for (const element of elements) { - const css = await element.classAttr(); - if (css && !css.includes('ghost-element')) { - filtered.push(element); - } - } + /** + * Waits for the replacement of an element of a specific type. + * The replacement is detected by waiting for the removal of the element and the creation of a new one. + */ + async waitForReplacement(elementType: string, run: () => Promise): Promise; + async waitForReplacement( + constructor: PModelElementConstructor, + run: () => Promise + ): Promise; + async waitForReplacement( + elementTypeOrConstructor: string | PModelElementConstructor, + run: () => Promise + ): Promise { + const elementType = + typeof elementTypeOrConstructor === 'string' ? elementTypeOrConstructor : PMetadata.getType(elementTypeOrConstructor); - return filtered; - } - }; + const nodes = await this.getModelElementsOfType(getPModelElementConstructorOfType(elementType)); + const removal = GLSPLocator.or(...nodes.map(n => n.locate())); + + const ids = await this.waitForCreation(elementType, run); + + await expect(removal).toBeHidden(); + + if (typeof elementTypeOrConstructor === 'string') { + return ids; + } + + return this.idsAsElements(ids, elementTypeOrConstructor); + } - const { before, after } = await waitForElementChanges(query, creator, b => waitForElementIncrease(this.locator, query, b.length)); + async waitForHide(selectorOrLocator: string | Locator | GLSPLocator, run: () => Promise): Promise { + const locator = GLSPLocator.asLocator(selectorOrLocator, selector => this.locate().locator(selector)); + await expect(locator).toBeVisible(); + await run(); + await expect(locator).toBeHidden(); + } - const beforeIds = await Promise.all(before.map(async element => element.idAttr())); - const afterIds = await Promise.all(after.map(async element => element.idAttr())); + /** + * Converts a list of ids to elements of a specific type. + */ + async idsAsElements( + ids: string[], + constructor: PModelElementConstructor + ): Promise { + let retriever = this.getModelElement.bind(this); + if (isPNodeConstructor(constructor)) { + retriever = this.getNode.bind(this) as any; + } else if (isPEdgeConstructor(constructor)) { + retriever = this.getEdge.bind(this) as any; + } - return afterIds.filter(id => !beforeIds.includes(id)); + return Promise.all(ids.map(id => retriever(`id=${id}`, constructor))); } } diff --git a/packages/glsp-playwright/src/glsp/graph/graph.wait.ts b/packages/glsp-playwright/src/glsp/graph/graph.wait.ts deleted file mode 100644 index e66c6bb..0000000 --- a/packages/glsp-playwright/src/glsp/graph/graph.wait.ts +++ /dev/null @@ -1,78 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2023 Business Informatics Group (TU Wien) and others. - * - * 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: GNU General Public License, version 2 - * with the GNU Classpath Exception which is available at - * https://www.gnu.org/software/classpath/license.html. - * - * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 - ********************************************************************************/ -import { waitForFunction } from '~/integration/wait.fixes'; -import type { GLSPLocator } from '~/remote/locator'; -import type { PModelElement } from './elements/element'; -import { ElementQuery } from './graph.type'; -import { SVGMetadataUtils } from './svg-metadata-api'; - -/** - * Determines the elements before and after an operation has been triggered. - * - * @param query Query to search for the elements in the graph before and after the operation - * @param operation Operation to trigger - * @param waitForOperation Promise to wait after the operation has been executed - * @returns Elements before and after the operations - */ -export async function waitForElementChanges( - query: ElementQuery, - operation: () => Promise, - waitForOperation: (elements: TElement[]) => Promise -): Promise<{ - before: TElement[]; - after: TElement[]; -}> { - const before = await ElementQuery.exec(query); - - await operation(); - await waitForOperation(before); - - const after = await ElementQuery.exec(query); - - return { - before, - after - }; -} - -/** - * Waits for the elements to increase in the remote page - * - * @param locator Locator of the elements - * @param query For querying the remote page for the added elements - * @param numberBefore The number of elements that were accessible before - */ -export async function waitForElementIncrease( - locator: GLSPLocator, - query: ElementQuery, - numberBefore: number -): Promise { - await waitForFunction(async () => { - const elements = await locator.locate().locator(SVGMetadataUtils.typeAttrOf(query.elementType)).all(); - - return !!elements && elements.length > numberBefore; - }); - - // wait for additional elements to become accessible from the outside - for (let i = 0; i < 10; i++) { - const elements = await query.all(); - if (elements.length > numberBefore) { - return; - } - await locator.page.waitForTimeout(500); - } - throw Error('Timeout while waiting for number of graph elements to increase'); -} diff --git a/packages/glsp-playwright/src/glsp/graph/index.ts b/packages/glsp-playwright/src/glsp/graph/index.ts index fc80d3a..61adab7 100644 --- a/packages/glsp-playwright/src/glsp/graph/index.ts +++ b/packages/glsp-playwright/src/glsp/graph/index.ts @@ -18,5 +18,4 @@ export * from './elements'; export * from './graph-semantic.po'; export * from './graph.po'; export * from './graph.type'; -export * from './graph.wait'; export * from './svg-metadata-api'; diff --git a/packages/glsp-playwright/src/integration/standalone/standalone.integration.ts b/packages/glsp-playwright/src/integration/standalone/standalone.integration.ts index 5a84255..0253c9d 100644 --- a/packages/glsp-playwright/src/integration/standalone/standalone.integration.ts +++ b/packages/glsp-playwright/src/integration/standalone/standalone.integration.ts @@ -14,6 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ import { SVGMetadataUtils } from '~/glsp/graph'; +import { expect } from '../../test'; import { Integration } from '../integration.base'; import type { IntegrationArgs } from '../integration.type'; import type { StandaloneIntegrationOptions } from './standalone.options'; @@ -40,6 +41,6 @@ export class StandaloneIntegration extends Integration { protected override async launch(): Promise { await this.page.goto(this.options.url); await this.assertMetadataAPI(); - await this.page.waitForSelector(`${SVGMetadataUtils.typeAttrOf('graph')} svg.sprotty-graph > g`); + await expect(this.page.locator(`${SVGMetadataUtils.typeAttrOf('graph')} svg.sprotty-graph > g`)).toBeVisible(); } } diff --git a/packages/glsp-playwright/src/remote/locateable.ts b/packages/glsp-playwright/src/remote/locateable.ts index 7d5f571..cd9bbe7 100644 --- a/packages/glsp-playwright/src/remote/locateable.ts +++ b/packages/glsp-playwright/src/remote/locateable.ts @@ -16,7 +16,7 @@ import type { Locator, Page } from '@playwright/test'; import type { GLSPApp } from '../glsp/app/app.po'; import { InteractableBoundingBox, interactableBoundsOf } from './browser/interactable'; -import type { GLSPLocator } from './locator'; +import type { GLSPLocator, LocateContext } from './locator'; /** * Root class of all locateable page objects. @@ -35,8 +35,8 @@ export class Locateable { /** * Returns the Playwright {@link Locator} */ - locate(): Locator { - return this.locator.locate(); + locate(context: LocateContext = 'self'): Locator { + return this.locator.locate(context); } /** diff --git a/packages/glsp-playwright/src/remote/locator.ts b/packages/glsp-playwright/src/remote/locator.ts index 90a9f33..6611047 100644 --- a/packages/glsp-playwright/src/remote/locator.ts +++ b/packages/glsp-playwright/src/remote/locator.ts @@ -16,6 +16,8 @@ import type { Locator, Page } from '@playwright/test'; import type { GLSPApp } from '~/glsp/app'; +export type LocateContext = 'self' | 'root'; + /** * Locators represent a way to find element(s) on the page at any moment. * They also have access to the {@link GLSPApp}. @@ -35,10 +37,21 @@ export class GLSPLocator { /** * Returns the Playwright {@link Locator} */ - locate(): Locator { + locate(context: LocateContext = 'self'): Locator { + if (context === 'root') { + return this.root().locator; + } + return this.locator; } + /** + * Returns the root {@link GLSPLocator} of the current locator. + */ + root(): GLSPLocator { + return this.parent?.root() ?? this; + } + /** * Appends the provided selector to the current {@link GLSPLocator} as child. * @@ -61,10 +74,24 @@ export class GLSPLocator { } } -export function asLocator(selectorOrLocator: string | Locator, operation: (selector: string) => Locator): Locator { - if (typeof selectorOrLocator === 'string') { - return operation(selectorOrLocator); +export namespace GLSPLocator { + export function asLocator(selectorOrLocator: string | Locator | GLSPLocator, transform?: (selector: string) => Locator): Locator { + if (typeof selectorOrLocator === 'string') { + if (!transform) { + throw new Error('Transform is required when passing a selector string'); + } + + return transform(selectorOrLocator); + } else if (selectorOrLocator instanceof GLSPLocator) { + return selectorOrLocator.locate(); + } + + return selectorOrLocator; } - return selectorOrLocator; + // Chains multiple locators with a logical OR + export function or(...locators: (Locator | GLSPLocator)[]): Locator { + const loc = locators.map(locator => GLSPLocator.asLocator(locator)); + return loc.reduce((acc, locator) => acc.or(locator), loc[0]); + } } diff --git a/packages/glsp-playwright/src/test/assertions.ts b/packages/glsp-playwright/src/test/assertions.ts index 29ae534..ee8e122 100644 --- a/packages/glsp-playwright/src/test/assertions.ts +++ b/packages/glsp-playwright/src/test/assertions.ts @@ -60,7 +60,7 @@ async function toBeSelected(this: ExpectMatcherState, element: PModelElement): P async function toHaveSelected( this: ExpectMatcherState, graph: GLSPSemanticGraph, - expected: { type: ConstructorT; elements: PModelElement[] | (() => PModelElement[]) } + expected: { type: ConstructorT; elements: PModelElement[] | (() => Promise) } ): Promise { const assertionName = 'toHaveSelected'; let pass: boolean; @@ -68,14 +68,16 @@ async function toHaveSelected( try { await baseExpect(graph.locate().locator(`.${Selectable.CSS}`).first()).toBeAttached(); - const nodes = await graph.getSelectedElements(expected.type); - const elements = typeof expected.elements === 'function' ? expected.elements() : expected.elements; - baseExpect(nodes).toHaveLength(elements.length); + await baseExpect(graph.locate().locator(`.${Selectable.CSS}`).last()).toBeAttached(); - const nodeIds = await Promise.all(nodes.map(n => n.idAttr())); - const expectedIds = await Promise.all(elements.map(n => n.idAttr())); + const receivedElements = await graph.getSelectedElements(expected.type); + const expectedElements = typeof expected.elements === 'function' ? await expected.elements() : expected.elements; - baseExpect(nodeIds.sort()).toEqual(expectedIds.sort()); + const receivedIds = await Promise.all(receivedElements.map(n => n.idAttr())); + const expectedIds = await Promise.all(expectedElements.map(n => n.idAttr())); + + baseExpect(receivedIds).toHaveLength(expectedIds.length); + baseExpect(receivedIds.sort()).toEqual(expectedIds.sort()); pass = true; } catch (e: any) { matcherResult = e.matcherResult ?? e.error.message;