From 63de189fb9fec54ff036af8543ccf7f430a91b6a Mon Sep 17 00:00:00 2001 From: adrianpawlak Date: Tue, 23 Jun 2026 15:38:51 +0200 Subject: [PATCH 01/12] IBX-11739: Setup for Trash tests in playwright --- .github/workflows/playwright-tests.yml | 37 +++ tests/playwright-tests/.gitignore | 9 + .../Pages/ContentManagementPage.ts | 258 ++++++++++++++++++ tests/playwright-tests/Pages/TrashPage.ts | 88 ++++++ tests/playwright-tests/Tests/Trash.spec.ts | 128 +++++++++ tests/playwright-tests/package-lock.json | 37 +++ tests/playwright-tests/package.json | 14 + tests/playwright-tests/playwright.config.ts | 3 + tests/playwright-tests/tsconfig.json | 14 + 9 files changed, 588 insertions(+) create mode 100644 .github/workflows/playwright-tests.yml create mode 100644 tests/playwright-tests/.gitignore create mode 100644 tests/playwright-tests/Pages/ContentManagementPage.ts create mode 100644 tests/playwright-tests/Pages/TrashPage.ts create mode 100644 tests/playwright-tests/Tests/Trash.spec.ts create mode 100644 tests/playwright-tests/package-lock.json create mode 100644 tests/playwright-tests/package.json create mode 100644 tests/playwright-tests/playwright.config.ts create mode 100644 tests/playwright-tests/tsconfig.json diff --git a/.github/workflows/playwright-tests.yml b/.github/workflows/playwright-tests.yml new file mode 100644 index 0000000000..6a08b531ed --- /dev/null +++ b/.github/workflows/playwright-tests.yml @@ -0,0 +1,37 @@ +name: Playwright tests + +on: + push: + branches: + - main + - '[0-9]+.[0-9]+' + pull_request: ~ + +jobs: + playwright-oss: + name: "Playwright/OSS" + uses: ibexa/gh-workflows/.github/workflows/playwright-browser-tests.yml@main + with: + project-edition: 'oss' + secrets: inherit + + playwright-headless: + name: "Playwright/Headless" + uses: ibexa/gh-workflows/.github/workflows/playwright-browser-tests.yml@main + with: + project-edition: 'headless' + secrets: inherit + + playwright-experience: + name: "Playwright/Experience" + uses: ibexa/gh-workflows/.github/workflows/playwright-browser-tests.yml@main + with: + project-edition: 'experience' + secrets: inherit + + playwright-commerce: + name: "Playwright/Commerce" + uses: ibexa/gh-workflows/.github/workflows/playwright-browser-tests.yml@main + with: + project-edition: 'commerce' + secrets: inherit diff --git a/tests/playwright-tests/.gitignore b/tests/playwright-tests/.gitignore new file mode 100644 index 0000000000..9487927609 --- /dev/null +++ b/tests/playwright-tests/.gitignore @@ -0,0 +1,9 @@ +.env +.auth/ +playwright-report/ +test-results/ +node_modules/ +dist/ +# un-ignore files excluded by root .gitignore +!tsconfig.json +!package-lock.json diff --git a/tests/playwright-tests/Pages/ContentManagementPage.ts b/tests/playwright-tests/Pages/ContentManagementPage.ts new file mode 100644 index 0000000000..ca2cc60f6f --- /dev/null +++ b/tests/playwright-tests/Pages/ContentManagementPage.ts @@ -0,0 +1,258 @@ +import { Page, expect } from '@playwright/test'; +import { AdminUiPage } from '@ibexa/cohesivo-playwright'; + +export class ContentManagementPage extends AdminUiPage { + constructor(page: Page) { + super(page); + } + + async open(contentId: number, locationId: number): Promise { + await this.page.goto(`/admin/view/content/${contentId}/full/1/${locationId}`); + await this.page.waitForLoadState('networkidle'); + // Wait for the React content tree (c-tb-* toolbox tree) to render initial items + await this.page.locator('.c-tb-list-item-single').first() + .waitFor({ state: 'attached', timeout: 20_000 }).catch(() => {}); + // Click "See more" to load additional items until the current item appears + // (tree loads 30 items at a time; newly created items may not be in the first batch) + for (let i = 0; i < 20; i++) { + const active = await this.page.locator('.c-tb-list-item-single--active') + .first().isVisible({ timeout: 500 }).catch(() => false); + if (active) break; + const loadMore = this.page.locator('.c-tb-list-item-single__load-more').first(); + const found = await loadMore.count() > 0; + if (!found) break; + await loadMore.click(); + await this.page.waitForTimeout(1_500); + } + } + + /** + * Clicks an action button in the context menu by its visible label text. + * Handles both primary (visible) buttons and items hidden behind the "More" overflow button. + */ + async performAction(label: string): Promise { + const contextMenu = this.page.locator('.ibexa-context-menu'); + await contextMenu.waitFor({ state: 'visible', timeout: 10_000 }); + + // Check primary buttons — only click ones not physically covered by the "More" overlay. + // Use elementFromPoint to confirm the button is the topmost element at its center. + const primaryButtons = contextMenu.locator( + '.ibexa-context-menu__item:not(.ibexa-context-menu__item--more) .ibexa-btn', + ); + const count = await primaryButtons.count(); + for (let i = 0; i < count; i++) { + const btn = primaryButtons.nth(i); + const text = (await btn.textContent() ?? '').trim(); + if (!text.includes(label)) continue; + + const box = await btn.boundingBox(); + if (!box) continue; + + const cx = box.x + box.width / 2; + const cy = box.y + box.height / 2; + + // Check whether the button (or its child) is the topmost element at (cx, cy) + const isOnTop = await this.page.evaluate( + ([x, y, btnId]: [number, number, string]) => { + const el = document.elementFromPoint(x, y); + const target = document.getElementById(btnId) ?? document.querySelector(`[id="${btnId}"]`); + return target ? target.contains(el) || el === target : false; + }, + [cx, cy, await btn.getAttribute('id') ?? ''] as [number, number, string], + ); + + if (isOnTop) { + await btn.click({ force: true }); + await this.page.waitForLoadState('networkidle'); + return; + } + } + + // Fall back to the "More" overflow popup — trigger is the .ibexa-btn--more button, not the
  • + const moreButton = contextMenu.locator('.ibexa-btn--more'); + await moreButton.waitFor({ state: 'visible', timeout: 5_000 }); + await moreButton.click(); + + // The multilevel popup branch is appended to by the JS. + // Visibility is toggled via CSS class ibexa-popup-menu--hidden (not HTML hidden attr). + const popupItems = this.page.locator( + '.ibexa-multilevel-popup-menu__branch:not(.ibexa-popup-menu--hidden) .ibexa-popup-menu__item:not(.ibexa-popup-menu__item--hidden) .ibexa-multilevel-popup-menu__item-content', + ); + await popupItems.first().waitFor({ state: 'visible', timeout: 5_000 }); + + const popupCount = await popupItems.count(); + const popupTexts: string[] = []; + for (let i = 0; i < popupCount; i++) { + const item = popupItems.nth(i); + const text = (await item.textContent() ?? '').trim(); + popupTexts.push(text); + if (text.includes(label)) { + await item.click(); + await this.page.waitForLoadState('networkidle'); + return; + } + } + + throw new Error(`Action button '${label}' not found in context menu`); + } + + async sendToTrash(): Promise { + await this.performAction('Send to trash'); + + // "Send to trash" always opens #trash-location-modal. + // For items with children/relations a confirm checkbox is required to enable the button; + // for empty items the button is already enabled — we just click it. + const trashModal = this.page.locator('#trash-location-modal, .ibexa-modal--trash-location'); + const isModalVisible = await trashModal.waitFor({ state: 'visible', timeout: 5_000 }) + .then(() => true).catch(() => false); + + if (!isModalVisible) { + // No modal — action was handled without confirmation + await this.page.waitForLoadState('networkidle'); + return; + } + + // Check all unchecked checkboxes in the modal (options + confirm checkbox) + await this.page.evaluate(() => { + const modal = document.querySelector('#trash-location-modal, .ibexa-modal--trash-location'); + if (!modal) return; + modal.querySelectorAll('input[type="checkbox"]:not(:checked)') + .forEach(cb => cb.click()); + }); + await this.page.waitForTimeout(300); + + // Click the submit button inside the modal + const submitBtn = trashModal.locator('.ibexa-btn--confirm-send-to-trash'); + await submitBtn.waitFor({ state: 'visible', timeout: 5_000 }); + await submitBtn.click(); + await this.page.waitForLoadState('networkidle'); + } + + /** + * Selects a content item in the UDW by navigating the tree path (e.g. "Media/Files"). + * Each path segment is a branch level in the UDW finder. + */ + async selectInUDW(itemPath: string): Promise { + const udw = this.page.locator('.m-ud'); + await udw.waitFor({ state: 'visible', timeout: 10_000 }); + + const segments = itemPath.split('/'); + for (let level = 1; level <= segments.length; level++) { + const segmentName = segments[level - 1]; + const branchLocator = this.page.locator(`div.c-finder-branch:nth-of-type(${level}) .c-finder-leaf`); + await branchLocator.first().waitFor({ state: 'visible', timeout: 10_000 }); + + const leafCount = await branchLocator.count(); + let found = false; + for (let i = 0; i < leafCount; i++) { + const leaf = branchLocator.nth(i); + const text = (await leaf.textContent() ?? '').trim(); + if (text.includes(segmentName)) { + if (level < segments.length) { + await leaf.locator('.c-finder-leaf__name').click(); + } else { + // Last segment: check the checkbox to select (multiple-mode UDW) + const checkbox = leaf.locator('input[type="checkbox"]'); + if (await checkbox.count() > 0) { + await this.page.evaluate((el) => (el as HTMLInputElement).click(), await checkbox.elementHandle()); + await this.page.waitForTimeout(300); + } else { + await leaf.locator('.c-finder-leaf__name').click(); + } + } + found = true; + break; + } + } + + if (!found) { + console.warn(`UDW tree item '${segmentName}' not found at level ${level} — stopping navigation`); + return; + } + + if (level < segments.length) { + // For intermediate nodes: wait for next branch to appear (navigation happened) + const nextBranch = this.page.locator(`div.c-finder-branch:nth-of-type(${level + 1})`); + await nextBranch.waitFor({ state: 'visible', timeout: 10_000 }).catch(() => {}); + } + } + } + + async confirmUDW(): Promise { + const confirmButton = this.page.locator('.c-actions-menu__confirm-btn'); + await confirmButton.waitFor({ state: 'visible', timeout: 10_000 }); + await confirmButton.click({ force: true }); + await this.page.locator('.m-ud').waitFor({ state: 'hidden', timeout: 10_000 }).catch(() => {}); + await this.page.waitForLoadState('domcontentloaded'); + } + + async closeUDW(): Promise { + const cancelButton = this.page.locator('.c-top-menu__cancel-btn'); + await cancelButton.waitFor({ state: 'visible', timeout: 10_000 }); + await cancelButton.click(); + await this.page.locator('.m-ud').waitFor({ state: 'hidden', timeout: 10_000 }).catch(() => {}); + await this.page.waitForLoadState('domcontentloaded'); + } + + async assertOnContentView(itemName: string): Promise { + const pageTitle = this.page.locator('.ibexa-page-title h1'); + await pageTitle.waitFor({ state: 'visible', timeout: 10_000 }); + await expect(pageTitle).toContainText(itemName); + } + + async assertSuccessNotification(text: string): Promise { + const notification = this.page.locator('.ibexa-notifications-container .ibexa-alert--success'); + await notification.waitFor({ state: 'visible', timeout: 10_000 }); + await expect(notification).toContainText(text); + } + + async assertSubitemAbsent(name: string): Promise { + // .m-sub-items is a React mount point — wait for it to render rows inside + const subItemsTable = this.page.locator('.m-sub-items'); + await subItemsTable.waitFor({ state: 'attached', timeout: 10_000 }); + await this.page.waitForFunction( + (sel) => { + const el = document.querySelector(sel); + return el && el.querySelectorAll('.ibexa-table__row').length > 0; + }, + '.m-sub-items', + { timeout: 10_000 }, + ); + const items = subItemsTable.locator('.ibexa-table__row'); + const count = await items.count(); + for (let i = 0; i < count; i++) { + const text = (await items.nth(i).textContent() ?? '').trim(); + expect(text).not.toContain(name); + } + } + + async hide(): Promise { + // Hide works by submitting form[name="content_visibility_update"] with visible=0. + // We set the visibility field and submit the form directly. + await this.page.locator('.ibexa-context-menu').waitFor({ state: 'visible', timeout: 10_000 }); + + const submitted = await this.page.evaluate(() => { + const form = document.querySelector('form[name="content_visibility_update"]') as HTMLFormElement | null; + if (!form) return 'NO_FORM'; + const visField = form.querySelector('#content_visibility_update_visible') as HTMLInputElement | null; + if (!visField) return 'NO_FIELD'; + visField.value = '0'; + form.submit(); + return 'OK'; + }); + + if (submitted !== 'OK') { + throw new Error(`Hide form issue: ${submitted}`); + } + + await this.page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {}); + } + + async assertSubitemPresent(name: string): Promise { + const subItemsTable = this.page.locator('.m-sub-items'); + await subItemsTable.waitFor({ state: 'attached', timeout: 10_000 }); + const row = subItemsTable.locator('.ibexa-table__row').filter({ hasText: name }).first(); + await row.waitFor({ state: 'attached', timeout: 10_000 }); + await expect(row).toContainText(name); + } +} diff --git a/tests/playwright-tests/Pages/TrashPage.ts b/tests/playwright-tests/Pages/TrashPage.ts new file mode 100644 index 0000000000..09bda4212f --- /dev/null +++ b/tests/playwright-tests/Pages/TrashPage.ts @@ -0,0 +1,88 @@ +import { Page, expect } from '@playwright/test'; +import { AdminUiPage } from '@ibexa/cohesivo-playwright'; + +export class TrashPage extends AdminUiPage { + constructor(page: Page) { + super(page); + } + + async open(): Promise { + await this.navigateTo(`/admin/trash/list`); + } + + async assertNotEmpty(): Promise { + const rows = this.page.locator('.ibexa-table__row').filter({ hasText: /\S/ }); + const count = await rows.count(); + expect(count).toBeGreaterThan(0); + } + + async assertEmpty(): Promise { + const emptyEl = this.page.locator('.ibexa-table__empty-table-text') + .or(this.page.getByText('Trash is empty')) + .or(this.page.getByText('No items')); + await emptyEl.first().waitFor({ state: 'visible', timeout: 10_000 }); + } + + async emptyTrash(): Promise { + const emptyBtn = this.page.locator('.ibexa-context-menu .ibexa-btn').filter({ hasText: 'Empty Trash' }) + .or(this.page.locator('.ibexa-context-menu .ibexa-btn').filter({ hasText: 'Empty' })).first(); + await emptyBtn.waitFor({ state: 'visible', timeout: 10_000 }); + await emptyBtn.click({ force: true }); + await this.confirmDialogButton('Delete'); + await this.page.waitForLoadState('networkidle'); + } + + async assertItemInTrash(name: string): Promise { + await this.assertTableRowPresent(name); + } + + async assertItemNotInTrash(name: string): Promise { + await this.assertTableRowAbsent(name); + } + + async deleteFromTrash(items: string[]): Promise { + for (const item of items) { + await this.checkTableRow(item); + } + const deleteBtn = this.page.locator('button:not([data-bs-dismiss])').filter({ hasText: 'Delete' }).first(); + await deleteBtn.waitFor({ state: 'visible', timeout: 10_000 }); + await deleteBtn.click({ force: true }); + await this.confirmDialogButton('Delete'); + await this.page.waitForLoadState('networkidle'); + } + + async restoreFromTrash(items: string[]): Promise { + for (const item of items) { + await this.checkTableRow(item); + } + // Find "Restore" button (not "Restore in a new location") by matching inner text exactly + const restoreBtn = this.page.locator('button').filter({ hasNotText: 'in a new location' }).filter({ hasText: 'Restore' }).first(); + await restoreBtn.waitFor({ state: 'visible', timeout: 10_000 }); + await restoreBtn.click(); + await this.page.waitForLoadState('networkidle'); + } + + async restoreUnderNewLocation(items: string[], newLocationPath: string): Promise { + for (const item of items) { + await this.checkTableRow(item); + } + const restoreBtn = this.page.locator('button').filter({ hasText: 'Restore in a new location' }) + .or(this.page.locator('button.ibexa-btn--open-udw')).first(); + await restoreBtn.waitFor({ state: 'visible', timeout: 10_000 }); + await restoreBtn.click(); + await this.page.waitForTimeout(1000); + } + + async searchInTrash(query: string): Promise { + const url = this.page.url().split('?')[0]; + await this.page.goto(`${url}?trash_search[content_name]=${encodeURIComponent(query)}`); + await this.page.waitForLoadState('networkidle'); + } + + async filterByContentType(contentTypeName: string): Promise { + const select = this.page.locator('select').first(); + await select.waitFor({ state: 'visible', timeout: 10_000 }); + await select.selectOption({ label: contentTypeName }); + await this.page.waitForLoadState('networkidle'); + } +} diff --git a/tests/playwright-tests/Tests/Trash.spec.ts b/tests/playwright-tests/Tests/Trash.spec.ts new file mode 100644 index 0000000000..d919d19833 --- /dev/null +++ b/tests/playwright-tests/Tests/Trash.spec.ts @@ -0,0 +1,128 @@ +import { test, expect } from '@playwright/test'; +import { TrashPage } from '../Pages/TrashPage'; +import { ContentManagementPage } from '../Pages/ContentManagementPage'; +import { IbexaApiClient } from '@ibexa/cohesivo-playwright'; + + +test.describe('Trash management', { tag: ['@IbexaHeadless', '@IbexaExperience', '@IbexaCommerce'] }, () => { + let api: IbexaApiClient; + let trashTestLocationId: number; + let trashTestContentId: number; + let runId: string; + + test.beforeAll(async () => { + api = new IbexaApiClient(); + await api.init(); + runId = Date.now().toString().slice(-6); + + trashTestContentId = await api.createFolder('TrashTest', 2); + trashTestLocationId = await api.getMainLocationId(trashTestContentId); + }); + + test('Trash can be emptied', async ({ page }) => { + const childId = await api.createFolder(`FolderToTrash${runId}`, trashTestLocationId); + const childLocId = await api.getMainLocationId(childId); + + const contentPage = new ContentManagementPage(page); + await contentPage.open(childId, childLocId); + await contentPage.sendToTrash(); + + const trash = new TrashPage(page); + await trash.open(); + await trash.assertNotEmpty(); + await trash.emptyTrash(); + await trash.assertEmpty(); + }); + + test('Content can be moved to trash', async ({ page }) => { + const name = `FolderToTrashManually${runId}`; + const childId = await api.createFolder(name, trashTestLocationId); + const childLocId = await api.getMainLocationId(childId); + + const contentPage = new ContentManagementPage(page); + await contentPage.open(childId, childLocId); + await contentPage.sendToTrash(); + + await contentPage.assertSuccessNotification(`Location '${name}' moved to Trash`); + + const trash = new TrashPage(page); + await trash.open(); + await trash.assertItemInTrash(name); + }); + + test('Element in trash can be deleted', async ({ page }) => { + const name = `DeleteFromTrash${runId}`; + const childId = await api.createFolder(name, trashTestLocationId); + const childLocId = await api.getMainLocationId(childId); + + const contentPage = new ContentManagementPage(page); + await contentPage.open(childId, childLocId); + await contentPage.sendToTrash(); + + const trash = new TrashPage(page); + await trash.open(); + await trash.assertItemInTrash(name); + await trash.deleteFromTrash([name]); + await trash.assertSuccessNotification('Deleted selected item(s) from Trash'); + await trash.assertItemNotInTrash(name); + }); + + test('Element in trash can be restored', async ({ page }) => { + const name = `RestoreFromTrash${runId}`; + const childId = await api.createFolder(name, trashTestLocationId); + const childLocId = await api.getMainLocationId(childId); + + const contentPage = new ContentManagementPage(page); + await contentPage.open(childId, childLocId); + await contentPage.sendToTrash(); + + const trash = new TrashPage(page); + await trash.open(); + await trash.assertItemInTrash(name); + await trash.restoreFromTrash([name]); + await trash.assertSuccessNotification('Restored content to its original Location'); + await trash.assertItemNotInTrash(name); + }); + + test('Element in trash can be restored under new location', async ({ page }) => { + const name = `RestoreFromTrashNewLocation${runId}`; + const childId = await api.createFolder(name, trashTestLocationId); + const childLocId = await api.getMainLocationId(childId); + + const contentPage = new ContentManagementPage(page); + await contentPage.open(childId, childLocId); + await contentPage.sendToTrash(); + + const trash = new TrashPage(page); + await trash.open(); + await trash.assertItemInTrash(name); + await trash.restoreUnderNewLocation([name], 'Media/Files'); + + const contentMgmt = new ContentManagementPage(page); + await contentMgmt.selectInUDW('Media/Files'); + await contentMgmt.confirmUDW(); + + await trash.assertSuccessNotification("Restored content under Location 'Files'"); + await trash.assertItemNotInTrash(name); + }); + + test('Element in trash can be found by search', async ({ page }) => { + const name1 = `TrashSearch1${runId}`; + const name2 = `TrashSearch2${runId}`; + const childId1 = await api.createFolder(name1, trashTestLocationId); + const childLocId1 = await api.getMainLocationId(childId1); + const childId2 = await api.createFolder(name2, trashTestLocationId); + const childLocId2 = await api.getMainLocationId(childId2); + + const contentPage = new ContentManagementPage(page); + await contentPage.open(childId1, childLocId1); + await contentPage.sendToTrash(); + await contentPage.open(childId2, childLocId2); + await contentPage.sendToTrash(); + + const trash = new TrashPage(page); + await trash.open(); + await trash.searchInTrash(name1); + await trash.assertItemInTrash(name1); + }); +}); diff --git a/tests/playwright-tests/package-lock.json b/tests/playwright-tests/package-lock.json new file mode 100644 index 0000000000..69a29fe8bc --- /dev/null +++ b/tests/playwright-tests/package-lock.json @@ -0,0 +1,37 @@ +{ + "name": "@ibexa/admin-ui-playwright-tests", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@ibexa/admin-ui-playwright-tests", + "version": "1.0.0", + "dependencies": { + "@ibexa/cohesivo-playwright": "file:../../../cohesivo-playwright" + } + }, + "../../../cohesivo-playwright": { + "name": "@ibexa/cohesivo-playwright", + "version": "1.0.0", + "dependencies": { + "@playwright/test": "^1.60.0", + "@types/node": "^20.0.0", + "dotenv": "^17.4.2", + "typescript": "^5.4.0" + }, + "devDependencies": { + "@typescript-eslint/eslint-plugin": "^7.0.0", + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.57.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ibexa/cohesivo-playwright": { + "resolved": "../../../cohesivo-playwright", + "link": true + } + } +} diff --git a/tests/playwright-tests/package.json b/tests/playwright-tests/package.json new file mode 100644 index 0000000000..7304c7b54e --- /dev/null +++ b/tests/playwright-tests/package.json @@ -0,0 +1,14 @@ +{ + "name": "@ibexa/admin-ui-playwright-tests", + "version": "1.0.0", + "private": true, + "scripts": { + "test": "NODE_PATH=./node_modules/@ibexa/cohesivo-playwright/node_modules ./node_modules/@ibexa/cohesivo-playwright/node_modules/.bin/playwright test", + "test:headless": "NODE_PATH=./node_modules/@ibexa/cohesivo-playwright/node_modules ./node_modules/@ibexa/cohesivo-playwright/node_modules/.bin/playwright test --project=headless", + "test:experience": "NODE_PATH=./node_modules/@ibexa/cohesivo-playwright/node_modules ./node_modules/@ibexa/cohesivo-playwright/node_modules/.bin/playwright test --project=experience", + "test:commerce": "NODE_PATH=./node_modules/@ibexa/cohesivo-playwright/node_modules ./node_modules/@ibexa/cohesivo-playwright/node_modules/.bin/playwright test --project=commerce" + }, + "dependencies": { + "@ibexa/cohesivo-playwright": "file:../../../cohesivo-playwright" + } +} diff --git a/tests/playwright-tests/playwright.config.ts b/tests/playwright-tests/playwright.config.ts new file mode 100644 index 0000000000..b5d98db730 --- /dev/null +++ b/tests/playwright-tests/playwright.config.ts @@ -0,0 +1,3 @@ +import { defineIbexaConfig } from '@ibexa/cohesivo-playwright'; + +export default defineIbexaConfig({ testDir: './Tests' }); diff --git a/tests/playwright-tests/tsconfig.json b/tests/playwright-tests/tsconfig.json new file mode 100644 index 0000000000..0684779cca --- /dev/null +++ b/tests/playwright-tests/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./node_modules/@ibexa/cohesivo-playwright/tsconfig.playwright.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "." + }, + "include": [ + "Tests/**/*.ts", + "Pages/**/*.ts", + "Utils/**/*.ts", + "playwright.config.ts" + ], + "exclude": ["node_modules", "dist"] +} From 57ee92bfcb3e7c3df6891e4c5280945c7e5a3029 Mon Sep 17 00:00:00 2001 From: adrianpawlak Date: Tue, 23 Jun 2026 17:04:00 +0200 Subject: [PATCH 02/12] IBX-11739: Changed branch to gh-workflows --- .github/workflows/playwright-tests.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/playwright-tests.yml b/.github/workflows/playwright-tests.yml index 6a08b531ed..c0f4c22431 100644 --- a/.github/workflows/playwright-tests.yml +++ b/.github/workflows/playwright-tests.yml @@ -31,7 +31,8 @@ jobs: playwright-commerce: name: "Playwright/Commerce" - uses: ibexa/gh-workflows/.github/workflows/playwright-browser-tests.yml@main + uses: ibexa/gh-workflows/.github/workflows/playwright-browser-tests.yml@ibx-11740-playwright with: project-edition: 'commerce' + test-suite: '' secrets: inherit From 093d1a699e77c5784a326ad78217dce3fc8dcd26 Mon Sep 17 00:00:00 2001 From: adrianpawlak Date: Tue, 23 Jun 2026 17:05:38 +0200 Subject: [PATCH 03/12] IBX-11739: Changed branch to gh-workflows --- .github/workflows/playwright-tests.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/playwright-tests.yml b/.github/workflows/playwright-tests.yml index c0f4c22431..96b2c9d43f 100644 --- a/.github/workflows/playwright-tests.yml +++ b/.github/workflows/playwright-tests.yml @@ -10,23 +10,26 @@ on: jobs: playwright-oss: name: "Playwright/OSS" - uses: ibexa/gh-workflows/.github/workflows/playwright-browser-tests.yml@main + uses: ibexa/gh-workflows/.github/workflows/playwright-browser-tests.yml@ibx-11740-playwright with: project-edition: 'oss' + test-suite: '' secrets: inherit playwright-headless: name: "Playwright/Headless" - uses: ibexa/gh-workflows/.github/workflows/playwright-browser-tests.yml@main + uses: ibexa/gh-workflows/.github/workflows/playwright-browser-tests.yml@ibx-11740-playwright with: project-edition: 'headless' + test-suite: '' secrets: inherit playwright-experience: name: "Playwright/Experience" - uses: ibexa/gh-workflows/.github/workflows/playwright-browser-tests.yml@main + uses: ibexa/gh-workflows/.github/workflows/playwright-browser-tests.yml@ibx-11740-playwright with: project-edition: 'experience' + test-suite: '' secrets: inherit playwright-commerce: From fa16fbd51827492735e06d9a3d029b7ac633b5f4 Mon Sep 17 00:00:00 2001 From: adrianpawlak Date: Mon, 13 Jul 2026 15:57:40 +0200 Subject: [PATCH 04/12] IBX-11739: Refactor --- .github/workflows/playwright-tests.yml | 8 +- .../Pages/ContentManagementPage.ts | 276 ++++-------------- tests/playwright-tests/Pages/TrashPage.ts | 34 +-- tests/playwright-tests/Tests/Trash.spec.ts | 19 +- tests/playwright-tests/package.json | 3 +- 5 files changed, 95 insertions(+), 245 deletions(-) diff --git a/.github/workflows/playwright-tests.yml b/.github/workflows/playwright-tests.yml index 96b2c9d43f..3bfa65570a 100644 --- a/.github/workflows/playwright-tests.yml +++ b/.github/workflows/playwright-tests.yml @@ -13,7 +13,7 @@ jobs: uses: ibexa/gh-workflows/.github/workflows/playwright-browser-tests.yml@ibx-11740-playwright with: project-edition: 'oss' - test-suite: '' + test-package: 'admin-ui' secrets: inherit playwright-headless: @@ -21,7 +21,7 @@ jobs: uses: ibexa/gh-workflows/.github/workflows/playwright-browser-tests.yml@ibx-11740-playwright with: project-edition: 'headless' - test-suite: '' + test-package: 'admin-ui' secrets: inherit playwright-experience: @@ -29,7 +29,7 @@ jobs: uses: ibexa/gh-workflows/.github/workflows/playwright-browser-tests.yml@ibx-11740-playwright with: project-edition: 'experience' - test-suite: '' + test-package: 'admin-ui' secrets: inherit playwright-commerce: @@ -37,5 +37,5 @@ jobs: uses: ibexa/gh-workflows/.github/workflows/playwright-browser-tests.yml@ibx-11740-playwright with: project-edition: 'commerce' - test-suite: '' + test-package: 'admin-ui' secrets: inherit diff --git a/tests/playwright-tests/Pages/ContentManagementPage.ts b/tests/playwright-tests/Pages/ContentManagementPage.ts index ca2cc60f6f..d653a5ef43 100644 --- a/tests/playwright-tests/Pages/ContentManagementPage.ts +++ b/tests/playwright-tests/Pages/ContentManagementPage.ts @@ -1,29 +1,17 @@ import { Page, expect } from '@playwright/test'; -import { AdminUiPage } from '@ibexa/cohesivo-playwright'; +import { AdminUiPage, UniversalDiscoveryWidget } from '@ibexa/cohesivo-playwright'; export class ContentManagementPage extends AdminUiPage { + readonly udw: UniversalDiscoveryWidget; + constructor(page: Page) { super(page); + this.udw = new UniversalDiscoveryWidget(page); } async open(contentId: number, locationId: number): Promise { await this.page.goto(`/admin/view/content/${contentId}/full/1/${locationId}`); - await this.page.waitForLoadState('networkidle'); - // Wait for the React content tree (c-tb-* toolbox tree) to render initial items - await this.page.locator('.c-tb-list-item-single').first() - .waitFor({ state: 'attached', timeout: 20_000 }).catch(() => {}); - // Click "See more" to load additional items until the current item appears - // (tree loads 30 items at a time; newly created items may not be in the first batch) - for (let i = 0; i < 20; i++) { - const active = await this.page.locator('.c-tb-list-item-single--active') - .first().isVisible({ timeout: 500 }).catch(() => false); - if (active) break; - const loadMore = this.page.locator('.c-tb-list-item-single__load-more').first(); - const found = await loadMore.count() > 0; - if (!found) break; - await loadMore.click(); - await this.page.waitForTimeout(1_500); - } + await expect(this.page.locator('.ibexa-context-menu')).toBeVisible({ timeout: 20_000 }); } /** @@ -32,227 +20,83 @@ export class ContentManagementPage extends AdminUiPage { */ async performAction(label: string): Promise { const contextMenu = this.page.locator('.ibexa-context-menu'); - await contextMenu.waitFor({ state: 'visible', timeout: 10_000 }); - - // Check primary buttons — only click ones not physically covered by the "More" overlay. - // Use elementFromPoint to confirm the button is the topmost element at its center. - const primaryButtons = contextMenu.locator( - '.ibexa-context-menu__item:not(.ibexa-context-menu__item--more) .ibexa-btn', - ); - const count = await primaryButtons.count(); - for (let i = 0; i < count; i++) { - const btn = primaryButtons.nth(i); - const text = (await btn.textContent() ?? '').trim(); - if (!text.includes(label)) continue; - - const box = await btn.boundingBox(); - if (!box) continue; - - const cx = box.x + box.width / 2; - const cy = box.y + box.height / 2; + await expect(contextMenu).toBeVisible({ timeout: 10_000 }); - // Check whether the button (or its child) is the topmost element at (cx, cy) - const isOnTop = await this.page.evaluate( - ([x, y, btnId]: [number, number, string]) => { - const el = document.elementFromPoint(x, y); - const target = document.getElementById(btnId) ?? document.querySelector(`[id="${btnId}"]`); - return target ? target.contains(el) || el === target : false; - }, - [cx, cy, await btn.getAttribute('id') ?? ''] as [number, number, string], - ); + const primaryButton = contextMenu + .locator('.ibexa-context-menu__item:not(.ibexa-context-menu__item--more) .ibexa-btn') + .filter({ hasText: label }) + .first(); - if (isOnTop) { - await btn.click({ force: true }); - await this.page.waitForLoadState('networkidle'); - return; - } + // A primary button can be present in the DOM but covered by the "More" overflow — + // the click then fails its actionability check and we fall back to the "More" menu. + try { + await primaryButton.click({ timeout: 3_000 }); + return; + } catch { + // fall through to the "More" menu } - // Fall back to the "More" overflow popup — trigger is the .ibexa-btn--more button, not the
  • - const moreButton = contextMenu.locator('.ibexa-btn--more'); - await moreButton.waitFor({ state: 'visible', timeout: 5_000 }); - await moreButton.click(); - - // The multilevel popup branch is appended to by the JS. - // Visibility is toggled via CSS class ibexa-popup-menu--hidden (not HTML hidden attr). - const popupItems = this.page.locator( - '.ibexa-multilevel-popup-menu__branch:not(.ibexa-popup-menu--hidden) .ibexa-popup-menu__item:not(.ibexa-popup-menu__item--hidden) .ibexa-multilevel-popup-menu__item-content', - ); - await popupItems.first().waitFor({ state: 'visible', timeout: 5_000 }); - - const popupCount = await popupItems.count(); - const popupTexts: string[] = []; - for (let i = 0; i < popupCount; i++) { - const item = popupItems.nth(i); - const text = (await item.textContent() ?? '').trim(); - popupTexts.push(text); - if (text.includes(label)) { - await item.click(); - await this.page.waitForLoadState('networkidle'); - return; - } - } + await contextMenu.locator('.ibexa-btn--more').click(); - throw new Error(`Action button '${label}' not found in context menu`); + // The multilevel popup branch is appended to ; visibility is toggled + // via the ibexa-popup-menu--hidden CSS class. + const popupItem = this.page + .locator('.ibexa-multilevel-popup-menu__branch:not(.ibexa-popup-menu--hidden) .ibexa-popup-menu__item:not(.ibexa-popup-menu__item--hidden)') + .filter({ hasText: label }) + .first(); + await expect(popupItem, `Action '${label}' not found in context menu`).toBeVisible({ timeout: 5_000 }); + await popupItem.click(); } async sendToTrash(): Promise { await this.performAction('Send to trash'); - // "Send to trash" always opens #trash-location-modal. - // For items with children/relations a confirm checkbox is required to enable the button; - // for empty items the button is already enabled — we just click it. - const trashModal = this.page.locator('#trash-location-modal, .ibexa-modal--trash-location'); - const isModalVisible = await trashModal.waitFor({ state: 'visible', timeout: 5_000 }) - .then(() => true).catch(() => false); - - if (!isModalVisible) { - // No modal — action was handled without confirmation - await this.page.waitForLoadState('networkidle'); - return; - } - - // Check all unchecked checkboxes in the modal (options + confirm checkbox) - await this.page.evaluate(() => { - const modal = document.querySelector('#trash-location-modal, .ibexa-modal--trash-location'); - if (!modal) return; - modal.querySelectorAll('input[type="checkbox"]:not(:checked)') - .forEach(cb => cb.click()); - }); - await this.page.waitForTimeout(300); - - // Click the submit button inside the modal - const submitBtn = trashModal.locator('.ibexa-btn--confirm-send-to-trash'); - await submitBtn.waitFor({ state: 'visible', timeout: 5_000 }); - await submitBtn.click(); - await this.page.waitForLoadState('networkidle'); - } - - /** - * Selects a content item in the UDW by navigating the tree path (e.g. "Media/Files"). - * Each path segment is a branch level in the UDW finder. - */ - async selectInUDW(itemPath: string): Promise { - const udw = this.page.locator('.m-ud'); - await udw.waitFor({ state: 'visible', timeout: 10_000 }); - - const segments = itemPath.split('/'); - for (let level = 1; level <= segments.length; level++) { - const segmentName = segments[level - 1]; - const branchLocator = this.page.locator(`div.c-finder-branch:nth-of-type(${level}) .c-finder-leaf`); - await branchLocator.first().waitFor({ state: 'visible', timeout: 10_000 }); - - const leafCount = await branchLocator.count(); - let found = false; - for (let i = 0; i < leafCount; i++) { - const leaf = branchLocator.nth(i); - const text = (await leaf.textContent() ?? '').trim(); - if (text.includes(segmentName)) { - if (level < segments.length) { - await leaf.locator('.c-finder-leaf__name').click(); - } else { - // Last segment: check the checkbox to select (multiple-mode UDW) - const checkbox = leaf.locator('input[type="checkbox"]'); - if (await checkbox.count() > 0) { - await this.page.evaluate((el) => (el as HTMLInputElement).click(), await checkbox.elementHandle()); - await this.page.waitForTimeout(300); - } else { - await leaf.locator('.c-finder-leaf__name').click(); - } - } - found = true; - break; - } - } - - if (!found) { - console.warn(`UDW tree item '${segmentName}' not found at level ${level} — stopping navigation`); - return; - } - - if (level < segments.length) { - // For intermediate nodes: wait for next branch to appear (navigation happened) - const nextBranch = this.page.locator(`div.c-finder-branch:nth-of-type(${level + 1})`); - await nextBranch.waitFor({ state: 'visible', timeout: 10_000 }).catch(() => {}); + const modal = this.page.locator('#trash-location-modal, .ibexa-modal--trash-location').first(); + await expect(modal).toBeVisible({ timeout: 10_000 }); + + // For items with children/relations the modal requires ticking confirmation + // checkboxes before the submit button becomes enabled. + const submitButton = modal.locator('.ibexa-btn--confirm-send-to-trash'); + if (await submitButton.isDisabled()) { + const checkboxes = modal.locator('input[type="checkbox"]:not(:checked)'); + const count = await checkboxes.count(); + for (let i = 0; i < count; i++) { + await checkboxes.nth(i).check({ force: true }); } } - } - async confirmUDW(): Promise { - const confirmButton = this.page.locator('.c-actions-menu__confirm-btn'); - await confirmButton.waitFor({ state: 'visible', timeout: 10_000 }); - await confirmButton.click({ force: true }); - await this.page.locator('.m-ud').waitFor({ state: 'hidden', timeout: 10_000 }).catch(() => {}); - await this.page.waitForLoadState('domcontentloaded'); + await expect(submitButton).toBeEnabled({ timeout: 5_000 }); + await submitButton.click(); + await expect(modal).toBeHidden({ timeout: 10_000 }); } - async closeUDW(): Promise { - const cancelButton = this.page.locator('.c-top-menu__cancel-btn'); - await cancelButton.waitFor({ state: 'visible', timeout: 10_000 }); - await cancelButton.click(); - await this.page.locator('.m-ud').waitFor({ state: 'hidden', timeout: 10_000 }).catch(() => {}); - await this.page.waitForLoadState('domcontentloaded'); + async hide(): Promise { + await this.performAction('Hide'); + // "Hide" opens the "Schedule hiding" panel; confirm with the default "Hide now" option + await expect( + this.page.getByRole('heading', { name: 'Schedule hiding' }).filter({ visible: true }).first(), + ).toBeVisible({ timeout: 10_000 }); + await this.page.getByRole('button', { name: 'Confirm' }).filter({ visible: true }).first().click(); } async assertOnContentView(itemName: string): Promise { - const pageTitle = this.page.locator('.ibexa-page-title h1'); - await pageTitle.waitFor({ state: 'visible', timeout: 10_000 }); - await expect(pageTitle).toContainText(itemName); + await expect(this.page.locator('.ibexa-page-title h1')).toContainText(itemName, { timeout: 10_000 }); } - async assertSuccessNotification(text: string): Promise { - const notification = this.page.locator('.ibexa-notifications-container .ibexa-alert--success'); - await notification.waitFor({ state: 'visible', timeout: 10_000 }); - await expect(notification).toContainText(text); + async assertSubitemPresent(name: string): Promise { + const subItems = this.page.locator('.m-sub-items'); + await expect( + subItems.locator('.ibexa-table__row').filter({ hasText: name }).first(), + ).toBeVisible({ timeout: 10_000 }); } async assertSubitemAbsent(name: string): Promise { - // .m-sub-items is a React mount point — wait for it to render rows inside - const subItemsTable = this.page.locator('.m-sub-items'); - await subItemsTable.waitFor({ state: 'attached', timeout: 10_000 }); - await this.page.waitForFunction( - (sel) => { - const el = document.querySelector(sel); - return el && el.querySelectorAll('.ibexa-table__row').length > 0; - }, - '.m-sub-items', - { timeout: 10_000 }, - ); - const items = subItemsTable.locator('.ibexa-table__row'); - const count = await items.count(); - for (let i = 0; i < count; i++) { - const text = (await items.nth(i).textContent() ?? '').trim(); - expect(text).not.toContain(name); - } - } - - async hide(): Promise { - // Hide works by submitting form[name="content_visibility_update"] with visible=0. - // We set the visibility field and submit the form directly. - await this.page.locator('.ibexa-context-menu').waitFor({ state: 'visible', timeout: 10_000 }); - - const submitted = await this.page.evaluate(() => { - const form = document.querySelector('form[name="content_visibility_update"]') as HTMLFormElement | null; - if (!form) return 'NO_FORM'; - const visField = form.querySelector('#content_visibility_update_visible') as HTMLInputElement | null; - if (!visField) return 'NO_FIELD'; - visField.value = '0'; - form.submit(); - return 'OK'; - }); - - if (submitted !== 'OK') { - throw new Error(`Hide form issue: ${submitted}`); - } - - await this.page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {}); - } - - async assertSubitemPresent(name: string): Promise { - const subItemsTable = this.page.locator('.m-sub-items'); - await subItemsTable.waitFor({ state: 'attached', timeout: 10_000 }); - const row = subItemsTable.locator('.ibexa-table__row').filter({ hasText: name }).first(); - await row.waitFor({ state: 'attached', timeout: 10_000 }); - await expect(row).toContainText(name); + // .m-sub-items is a React mount point — anchor on the rendered table + // (or its empty state) before asserting absence, to avoid passing on a blank mount. + const subItems = this.page.locator('.m-sub-items'); + await expect( + subItems.locator('.ibexa-table, .ibexa-table__empty-table-text').first(), + ).toBeVisible({ timeout: 10_000 }); + await expect(subItems.locator('.ibexa-table__row').filter({ hasText: name })).toHaveCount(0); } } diff --git a/tests/playwright-tests/Pages/TrashPage.ts b/tests/playwright-tests/Pages/TrashPage.ts index 09bda4212f..f0d53d4054 100644 --- a/tests/playwright-tests/Pages/TrashPage.ts +++ b/tests/playwright-tests/Pages/TrashPage.ts @@ -1,9 +1,12 @@ import { Page, expect } from '@playwright/test'; -import { AdminUiPage } from '@ibexa/cohesivo-playwright'; +import { AdminUiPage, UniversalDiscoveryWidget } from '@ibexa/cohesivo-playwright'; export class TrashPage extends AdminUiPage { + readonly udw: UniversalDiscoveryWidget; + constructor(page: Page) { super(page); + this.udw = new UniversalDiscoveryWidget(page); } async open(): Promise { @@ -11,25 +14,22 @@ export class TrashPage extends AdminUiPage { } async assertNotEmpty(): Promise { - const rows = this.page.locator('.ibexa-table__row').filter({ hasText: /\S/ }); - const count = await rows.count(); - expect(count).toBeGreaterThan(0); + await expect(this.page.locator('.ibexa-table__row').first()).toBeVisible({ timeout: 10_000 }); } async assertEmpty(): Promise { const emptyEl = this.page.locator('.ibexa-table__empty-table-text') .or(this.page.getByText('Trash is empty')) .or(this.page.getByText('No items')); - await emptyEl.first().waitFor({ state: 'visible', timeout: 10_000 }); + await expect(emptyEl.first()).toBeVisible({ timeout: 10_000 }); } async emptyTrash(): Promise { const emptyBtn = this.page.locator('.ibexa-context-menu .ibexa-btn').filter({ hasText: 'Empty Trash' }) .or(this.page.locator('.ibexa-context-menu .ibexa-btn').filter({ hasText: 'Empty' })).first(); - await emptyBtn.waitFor({ state: 'visible', timeout: 10_000 }); - await emptyBtn.click({ force: true }); + await emptyBtn.click(); await this.confirmDialogButton('Delete'); - await this.page.waitForLoadState('networkidle'); + await this.assertEmpty(); } async assertItemInTrash(name: string): Promise { @@ -45,10 +45,8 @@ export class TrashPage extends AdminUiPage { await this.checkTableRow(item); } const deleteBtn = this.page.locator('button:not([data-bs-dismiss])').filter({ hasText: 'Delete' }).first(); - await deleteBtn.waitFor({ state: 'visible', timeout: 10_000 }); - await deleteBtn.click({ force: true }); + await deleteBtn.click(); await this.confirmDialogButton('Delete'); - await this.page.waitForLoadState('networkidle'); } async restoreFromTrash(items: string[]): Promise { @@ -57,32 +55,32 @@ export class TrashPage extends AdminUiPage { } // Find "Restore" button (not "Restore in a new location") by matching inner text exactly const restoreBtn = this.page.locator('button').filter({ hasNotText: 'in a new location' }).filter({ hasText: 'Restore' }).first(); - await restoreBtn.waitFor({ state: 'visible', timeout: 10_000 }); await restoreBtn.click(); - await this.page.waitForLoadState('networkidle'); } + /** + * Restores the checked items under the location given by path (e.g. "Media/Files"), + * driving the whole flow: restore button → UDW navigation → UDW confirm. + */ async restoreUnderNewLocation(items: string[], newLocationPath: string): Promise { for (const item of items) { await this.checkTableRow(item); } const restoreBtn = this.page.locator('button').filter({ hasText: 'Restore in a new location' }) .or(this.page.locator('button.ibexa-btn--open-udw')).first(); - await restoreBtn.waitFor({ state: 'visible', timeout: 10_000 }); await restoreBtn.click(); - await this.page.waitForTimeout(1000); + + await this.udw.selectPath(newLocationPath); + await this.udw.confirm(); } async searchInTrash(query: string): Promise { const url = this.page.url().split('?')[0]; await this.page.goto(`${url}?trash_search[content_name]=${encodeURIComponent(query)}`); - await this.page.waitForLoadState('networkidle'); } async filterByContentType(contentTypeName: string): Promise { const select = this.page.locator('select').first(); - await select.waitFor({ state: 'visible', timeout: 10_000 }); await select.selectOption({ label: contentTypeName }); - await this.page.waitForLoadState('networkidle'); } } diff --git a/tests/playwright-tests/Tests/Trash.spec.ts b/tests/playwright-tests/Tests/Trash.spec.ts index d919d19833..0972c6f8ed 100644 --- a/tests/playwright-tests/Tests/Trash.spec.ts +++ b/tests/playwright-tests/Tests/Trash.spec.ts @@ -4,7 +4,7 @@ import { ContentManagementPage } from '../Pages/ContentManagementPage'; import { IbexaApiClient } from '@ibexa/cohesivo-playwright'; -test.describe('Trash management', { tag: ['@IbexaHeadless', '@IbexaExperience', '@IbexaCommerce'] }, () => { +test.describe('Trash management', { tag: ['@IbexaOSS', '@IbexaHeadless', '@IbexaExperience', '@IbexaCommerce'] }, () => { let api: IbexaApiClient; let trashTestLocationId: number; let trashTestContentId: number; @@ -15,10 +15,14 @@ test.describe('Trash management', { tag: ['@IbexaHeadless', '@IbexaExperience', await api.init(); runId = Date.now().toString().slice(-6); - trashTestContentId = await api.createFolder('TrashTest', 2); + trashTestContentId = await api.createFolder(`TrashTest${runId}`, 2); trashTestLocationId = await api.getMainLocationId(trashTestContentId); }); + test.afterAll(async () => { + await api.deleteContent(trashTestContentId); + }); + test('Trash can be emptied', async ({ page }) => { const childId = await api.createFolder(`FolderToTrash${runId}`, trashTestLocationId); const childLocId = await api.getMainLocationId(childId); @@ -98,12 +102,14 @@ test.describe('Trash management', { tag: ['@IbexaHeadless', '@IbexaExperience', await trash.assertItemInTrash(name); await trash.restoreUnderNewLocation([name], 'Media/Files'); - const contentMgmt = new ContentManagementPage(page); - await contentMgmt.selectInUDW('Media/Files'); - await contentMgmt.confirmUDW(); - await trash.assertSuccessNotification("Restored content under Location 'Files'"); await trash.assertItemNotInTrash(name); + + // Verify the content actually landed under Media/Files (path resolution throws if absent) + const restoredContentId = await api.getContentIdByPath(`Media/Files/${name}`); + expect(restoredContentId).toBe(childId); + + await api.deleteContent(childId); }); test('Element in trash can be found by search', async ({ page }) => { @@ -124,5 +130,6 @@ test.describe('Trash management', { tag: ['@IbexaHeadless', '@IbexaExperience', await trash.open(); await trash.searchInTrash(name1); await trash.assertItemInTrash(name1); + await trash.assertItemNotInTrash(name2); }); }); diff --git a/tests/playwright-tests/package.json b/tests/playwright-tests/package.json index 7304c7b54e..d9cfbd8c36 100644 --- a/tests/playwright-tests/package.json +++ b/tests/playwright-tests/package.json @@ -6,7 +6,8 @@ "test": "NODE_PATH=./node_modules/@ibexa/cohesivo-playwright/node_modules ./node_modules/@ibexa/cohesivo-playwright/node_modules/.bin/playwright test", "test:headless": "NODE_PATH=./node_modules/@ibexa/cohesivo-playwright/node_modules ./node_modules/@ibexa/cohesivo-playwright/node_modules/.bin/playwright test --project=headless", "test:experience": "NODE_PATH=./node_modules/@ibexa/cohesivo-playwright/node_modules ./node_modules/@ibexa/cohesivo-playwright/node_modules/.bin/playwright test --project=experience", - "test:commerce": "NODE_PATH=./node_modules/@ibexa/cohesivo-playwright/node_modules ./node_modules/@ibexa/cohesivo-playwright/node_modules/.bin/playwright test --project=commerce" + "test:commerce": "NODE_PATH=./node_modules/@ibexa/cohesivo-playwright/node_modules ./node_modules/@ibexa/cohesivo-playwright/node_modules/.bin/playwright test --project=commerce", + "typecheck": "./node_modules/@ibexa/cohesivo-playwright/node_modules/.bin/tsc --noEmit" }, "dependencies": { "@ibexa/cohesivo-playwright": "file:../../../cohesivo-playwright" From 2e049e16fb5b7cb70bd0523fdac9fccad2deb535 Mon Sep 17 00:00:00 2001 From: adrianpawlak Date: Wed, 15 Jul 2026 15:34:13 +0200 Subject: [PATCH 05/12] IBX-11739: Refactor --- .../Pages/ContentManagementPage.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/tests/playwright-tests/Pages/ContentManagementPage.ts b/tests/playwright-tests/Pages/ContentManagementPage.ts index d653a5ef43..a7abf42344 100644 --- a/tests/playwright-tests/Pages/ContentManagementPage.ts +++ b/tests/playwright-tests/Pages/ContentManagementPage.ts @@ -20,20 +20,25 @@ export class ContentManagementPage extends AdminUiPage { */ async performAction(label: string): Promise { const contextMenu = this.page.locator('.ibexa-context-menu'); - await expect(contextMenu).toBeVisible({ timeout: 10_000 }); + await expect(contextMenu.locator('.ibexa-btn').first()).toBeVisible({ timeout: 10_000 }); const primaryButton = contextMenu .locator('.ibexa-context-menu__item:not(.ibexa-context-menu__item--more) .ibexa-btn') .filter({ hasText: label }) .first(); - // A primary button can be present in the DOM but covered by the "More" overflow — - // the click then fails its actionability check and we fall back to the "More" menu. - try { - await primaryButton.click({ timeout: 3_000 }); + // A primary button can be present in the DOM but covered by the "More" overflow. + // Probe with elementFromPoint (read-only, no failed click attempt in the report) + // and only then click for real, with Playwright's actionability checks intact. + const isClickable = await primaryButton.count() > 0 && await primaryButton.evaluate((el) => { + const box = el.getBoundingClientRect(); + const topmost = document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2); + return topmost !== null && (el === topmost || el.contains(topmost)); + }); + + if (isClickable) { + await primaryButton.click(); return; - } catch { - // fall through to the "More" menu } await contextMenu.locator('.ibexa-btn--more').click(); From bad524c6bee97c8fc3ac07ec771a17595fb5ee3e Mon Sep 17 00:00:00 2001 From: adrianpawlak Date: Fri, 24 Jul 2026 13:29:06 +0200 Subject: [PATCH 06/12] IBX-11739: Removed main branch from workflow --- .github/workflows/playwright-tests.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/playwright-tests.yml b/.github/workflows/playwright-tests.yml index 3bfa65570a..6f70f9fae0 100644 --- a/.github/workflows/playwright-tests.yml +++ b/.github/workflows/playwright-tests.yml @@ -3,7 +3,6 @@ name: Playwright tests on: push: branches: - - main - '[0-9]+.[0-9]+' pull_request: ~ From 5c17ea7f6ba5b2fd052fecbcebd6aec88b132473 Mon Sep 17 00:00:00 2001 From: adrianpawlak Date: Fri, 24 Jul 2026 13:29:15 +0200 Subject: [PATCH 07/12] IBX-11739: Refactor locators --- .../Pages/ContentManagementPage.ts | 85 +++++++++++-------- tests/playwright-tests/Pages/TrashPage.ts | 48 ++++++----- 2 files changed, 78 insertions(+), 55 deletions(-) diff --git a/tests/playwright-tests/Pages/ContentManagementPage.ts b/tests/playwright-tests/Pages/ContentManagementPage.ts index a7abf42344..8e571436c9 100644 --- a/tests/playwright-tests/Pages/ContentManagementPage.ts +++ b/tests/playwright-tests/Pages/ContentManagementPage.ts @@ -1,17 +1,48 @@ -import { Page, expect } from '@playwright/test'; +import { Page, Locator, expect } from '@playwright/test'; import { AdminUiPage, UniversalDiscoveryWidget } from '@ibexa/cohesivo-playwright'; export class ContentManagementPage extends AdminUiPage { readonly udw: UniversalDiscoveryWidget; + private readonly contextMenu: Locator; + private readonly moreButton: Locator; + private readonly trashModal: Locator; + private readonly trashModalSubmit: Locator; + private readonly subItems: Locator; + constructor(page: Page) { super(page); this.udw = new UniversalDiscoveryWidget(page); + this.contextMenu = page.locator('.ibexa-context-menu'); + this.moreButton = this.contextMenu.locator('.ibexa-btn--more'); + this.trashModal = page.locator('#trash-location-modal, .ibexa-modal--trash-location').first(); + this.trashModalSubmit = this.trashModal.locator('.ibexa-btn--confirm-send-to-trash'); + this.subItems = page.locator('.m-sub-items'); + } + + /** Primary (always-visible) context-menu action button by its label. */ + private primaryAction(label: string): Locator { + return this.contextMenu + .locator('.ibexa-context-menu__item:not(.ibexa-context-menu__item--more) .ibexa-btn') + .filter({ hasText: label }) + .first(); + } + + /** Same action when it lives in the "More" overflow popup (appended to ). */ + private overflowAction(label: string): Locator { + return this.page + .locator('.ibexa-multilevel-popup-menu__branch:not(.ibexa-popup-menu--hidden) .ibexa-popup-menu__item:not(.ibexa-popup-menu__item--hidden)') + .filter({ hasText: label }) + .first(); + } + + private subItemRow(name: string): Locator { + return this.subItems.locator('.ibexa-table__row').filter({ hasText: name }); } async open(contentId: number, locationId: number): Promise { await this.page.goto(`/admin/view/content/${contentId}/full/1/${locationId}`); - await expect(this.page.locator('.ibexa-context-menu')).toBeVisible({ timeout: 20_000 }); + await expect(this.contextMenu).toBeVisible({ timeout: 20_000 }); } /** @@ -19,13 +50,9 @@ export class ContentManagementPage extends AdminUiPage { * Handles both primary (visible) buttons and items hidden behind the "More" overflow button. */ async performAction(label: string): Promise { - const contextMenu = this.page.locator('.ibexa-context-menu'); - await expect(contextMenu.locator('.ibexa-btn').first()).toBeVisible({ timeout: 10_000 }); + await expect(this.contextMenu.locator('.ibexa-btn').first()).toBeVisible({ timeout: 10_000 }); - const primaryButton = contextMenu - .locator('.ibexa-context-menu__item:not(.ibexa-context-menu__item--more) .ibexa-btn') - .filter({ hasText: label }) - .first(); + const primaryButton = this.primaryAction(label); // A primary button can be present in the DOM but covered by the "More" overflow. // Probe with elementFromPoint (read-only, no failed click attempt in the report) @@ -41,38 +68,29 @@ export class ContentManagementPage extends AdminUiPage { return; } - await contextMenu.locator('.ibexa-btn--more').click(); - - // The multilevel popup branch is appended to ; visibility is toggled - // via the ibexa-popup-menu--hidden CSS class. - const popupItem = this.page - .locator('.ibexa-multilevel-popup-menu__branch:not(.ibexa-popup-menu--hidden) .ibexa-popup-menu__item:not(.ibexa-popup-menu__item--hidden)') - .filter({ hasText: label }) - .first(); - await expect(popupItem, `Action '${label}' not found in context menu`).toBeVisible({ timeout: 5_000 }); - await popupItem.click(); + await this.moreButton.click(); + const item = this.overflowAction(label); + await expect(item, `Action '${label}' not found in context menu`).toBeVisible({ timeout: 5_000 }); + await item.click(); } async sendToTrash(): Promise { await this.performAction('Send to trash'); - - const modal = this.page.locator('#trash-location-modal, .ibexa-modal--trash-location').first(); - await expect(modal).toBeVisible({ timeout: 10_000 }); + await expect(this.trashModal).toBeVisible({ timeout: 10_000 }); // For items with children/relations the modal requires ticking confirmation // checkboxes before the submit button becomes enabled. - const submitButton = modal.locator('.ibexa-btn--confirm-send-to-trash'); - if (await submitButton.isDisabled()) { - const checkboxes = modal.locator('input[type="checkbox"]:not(:checked)'); + if (await this.trashModalSubmit.isDisabled()) { + const checkboxes = this.trashModal.locator('input[type="checkbox"]:not(:checked)'); const count = await checkboxes.count(); for (let i = 0; i < count; i++) { await checkboxes.nth(i).check({ force: true }); } } - await expect(submitButton).toBeEnabled({ timeout: 5_000 }); - await submitButton.click(); - await expect(modal).toBeHidden({ timeout: 10_000 }); + await expect(this.trashModalSubmit).toBeEnabled({ timeout: 5_000 }); + await this.trashModalSubmit.click(); + await expect(this.trashModal).toBeHidden({ timeout: 10_000 }); } async hide(): Promise { @@ -85,23 +103,20 @@ export class ContentManagementPage extends AdminUiPage { } async assertOnContentView(itemName: string): Promise { - await expect(this.page.locator('.ibexa-page-title h1')).toContainText(itemName, { timeout: 10_000 }); + // reuse the shared page-title assertion from AdminUiPage + await this.assertPageTitle(itemName); } async assertSubitemPresent(name: string): Promise { - const subItems = this.page.locator('.m-sub-items'); - await expect( - subItems.locator('.ibexa-table__row').filter({ hasText: name }).first(), - ).toBeVisible({ timeout: 10_000 }); + await expect(this.subItemRow(name).first()).toBeVisible({ timeout: 10_000 }); } async assertSubitemAbsent(name: string): Promise { // .m-sub-items is a React mount point — anchor on the rendered table // (or its empty state) before asserting absence, to avoid passing on a blank mount. - const subItems = this.page.locator('.m-sub-items'); await expect( - subItems.locator('.ibexa-table, .ibexa-table__empty-table-text').first(), + this.subItems.locator('.ibexa-table, .ibexa-table__empty-table-text').first(), ).toBeVisible({ timeout: 10_000 }); - await expect(subItems.locator('.ibexa-table__row').filter({ hasText: name })).toHaveCount(0); + await expect(this.subItemRow(name)).toHaveCount(0); } } diff --git a/tests/playwright-tests/Pages/TrashPage.ts b/tests/playwright-tests/Pages/TrashPage.ts index f0d53d4054..7627b2bb14 100644 --- a/tests/playwright-tests/Pages/TrashPage.ts +++ b/tests/playwright-tests/Pages/TrashPage.ts @@ -1,12 +1,32 @@ -import { Page, expect } from '@playwright/test'; +import { Page, Locator, expect } from '@playwright/test'; import { AdminUiPage, UniversalDiscoveryWidget } from '@ibexa/cohesivo-playwright'; export class TrashPage extends AdminUiPage { readonly udw: UniversalDiscoveryWidget; + private readonly firstRow: Locator; + private readonly emptyState: Locator; + private readonly emptyTrashButton: Locator; + private readonly restoreButton: Locator; + private readonly restoreUnderNewLocationButton: Locator; + private readonly bulkDeleteButton: Locator; + constructor(page: Page) { super(page); this.udw = new UniversalDiscoveryWidget(page); + + this.firstRow = page.locator('.ibexa-table__row').first(); + this.emptyState = page.locator('.ibexa-table__empty-table-text') + .or(page.getByText('Trash is empty')) + .or(page.getByText('No items')); + this.emptyTrashButton = page.locator('.ibexa-context-menu .ibexa-btn') + .filter({ hasText: /Empty( Trash)?/ }) + .first(); + this.restoreButton = page.getByRole('button', { name: 'Restore', exact: true }); + this.restoreUnderNewLocationButton = page.getByRole('button', { name: 'Restore in a new location' }) + .or(page.locator('button.ibexa-btn--open-udw')).first(); + // toolbar Delete only — [data-bs-dismiss] excludes the modal's own dismiss button + this.bulkDeleteButton = page.locator('button:not([data-bs-dismiss])').filter({ hasText: 'Delete' }).first(); } async open(): Promise { @@ -14,20 +34,15 @@ export class TrashPage extends AdminUiPage { } async assertNotEmpty(): Promise { - await expect(this.page.locator('.ibexa-table__row').first()).toBeVisible({ timeout: 10_000 }); + await expect(this.firstRow).toBeVisible({ timeout: 10_000 }); } async assertEmpty(): Promise { - const emptyEl = this.page.locator('.ibexa-table__empty-table-text') - .or(this.page.getByText('Trash is empty')) - .or(this.page.getByText('No items')); - await expect(emptyEl.first()).toBeVisible({ timeout: 10_000 }); + await expect(this.emptyState.first()).toBeVisible({ timeout: 10_000 }); } async emptyTrash(): Promise { - const emptyBtn = this.page.locator('.ibexa-context-menu .ibexa-btn').filter({ hasText: 'Empty Trash' }) - .or(this.page.locator('.ibexa-context-menu .ibexa-btn').filter({ hasText: 'Empty' })).first(); - await emptyBtn.click(); + await this.emptyTrashButton.click(); await this.confirmDialogButton('Delete'); await this.assertEmpty(); } @@ -44,8 +59,7 @@ export class TrashPage extends AdminUiPage { for (const item of items) { await this.checkTableRow(item); } - const deleteBtn = this.page.locator('button:not([data-bs-dismiss])').filter({ hasText: 'Delete' }).first(); - await deleteBtn.click(); + await this.bulkDeleteButton.click(); await this.confirmDialogButton('Delete'); } @@ -53,9 +67,7 @@ export class TrashPage extends AdminUiPage { for (const item of items) { await this.checkTableRow(item); } - // Find "Restore" button (not "Restore in a new location") by matching inner text exactly - const restoreBtn = this.page.locator('button').filter({ hasNotText: 'in a new location' }).filter({ hasText: 'Restore' }).first(); - await restoreBtn.click(); + await this.restoreButton.click(); } /** @@ -66,10 +78,7 @@ export class TrashPage extends AdminUiPage { for (const item of items) { await this.checkTableRow(item); } - const restoreBtn = this.page.locator('button').filter({ hasText: 'Restore in a new location' }) - .or(this.page.locator('button.ibexa-btn--open-udw')).first(); - await restoreBtn.click(); - + await this.restoreUnderNewLocationButton.click(); await this.udw.selectPath(newLocationPath); await this.udw.confirm(); } @@ -80,7 +89,6 @@ export class TrashPage extends AdminUiPage { } async filterByContentType(contentTypeName: string): Promise { - const select = this.page.locator('select').first(); - await select.selectOption({ label: contentTypeName }); + await this.page.locator('select').first().selectOption({ label: contentTypeName }); } } From ad83bc2ef211bbc2e83ced008604404014a63d38 Mon Sep 17 00:00:00 2001 From: adrianpawlak Date: Fri, 24 Jul 2026 13:43:24 +0200 Subject: [PATCH 08/12] Added ContextMenu --- .../Pages/ContentManagementPage.ts | 59 ++----------------- tests/playwright-tests/Pages/TrashPage.ts | 10 ++-- 2 files changed, 10 insertions(+), 59 deletions(-) diff --git a/tests/playwright-tests/Pages/ContentManagementPage.ts b/tests/playwright-tests/Pages/ContentManagementPage.ts index 8e571436c9..0afe5923d0 100644 --- a/tests/playwright-tests/Pages/ContentManagementPage.ts +++ b/tests/playwright-tests/Pages/ContentManagementPage.ts @@ -1,11 +1,10 @@ import { Page, Locator, expect } from '@playwright/test'; -import { AdminUiPage, UniversalDiscoveryWidget } from '@ibexa/cohesivo-playwright'; +import { AdminUiPage, UniversalDiscoveryWidget, ContextMenu } from '@ibexa/cohesivo-playwright'; export class ContentManagementPage extends AdminUiPage { readonly udw: UniversalDiscoveryWidget; + readonly contextMenu: ContextMenu; - private readonly contextMenu: Locator; - private readonly moreButton: Locator; private readonly trashModal: Locator; private readonly trashModalSubmit: Locator; private readonly subItems: Locator; @@ -13,69 +12,23 @@ export class ContentManagementPage extends AdminUiPage { constructor(page: Page) { super(page); this.udw = new UniversalDiscoveryWidget(page); - this.contextMenu = page.locator('.ibexa-context-menu'); - this.moreButton = this.contextMenu.locator('.ibexa-btn--more'); + this.contextMenu = new ContextMenu(page); this.trashModal = page.locator('#trash-location-modal, .ibexa-modal--trash-location').first(); this.trashModalSubmit = this.trashModal.locator('.ibexa-btn--confirm-send-to-trash'); this.subItems = page.locator('.m-sub-items'); } - /** Primary (always-visible) context-menu action button by its label. */ - private primaryAction(label: string): Locator { - return this.contextMenu - .locator('.ibexa-context-menu__item:not(.ibexa-context-menu__item--more) .ibexa-btn') - .filter({ hasText: label }) - .first(); - } - - /** Same action when it lives in the "More" overflow popup (appended to ). */ - private overflowAction(label: string): Locator { - return this.page - .locator('.ibexa-multilevel-popup-menu__branch:not(.ibexa-popup-menu--hidden) .ibexa-popup-menu__item:not(.ibexa-popup-menu__item--hidden)') - .filter({ hasText: label }) - .first(); - } - private subItemRow(name: string): Locator { return this.subItems.locator('.ibexa-table__row').filter({ hasText: name }); } async open(contentId: number, locationId: number): Promise { await this.page.goto(`/admin/view/content/${contentId}/full/1/${locationId}`); - await expect(this.contextMenu).toBeVisible({ timeout: 20_000 }); - } - - /** - * Clicks an action button in the context menu by its visible label text. - * Handles both primary (visible) buttons and items hidden behind the "More" overflow button. - */ - async performAction(label: string): Promise { - await expect(this.contextMenu.locator('.ibexa-btn').first()).toBeVisible({ timeout: 10_000 }); - - const primaryButton = this.primaryAction(label); - - // A primary button can be present in the DOM but covered by the "More" overflow. - // Probe with elementFromPoint (read-only, no failed click attempt in the report) - // and only then click for real, with Playwright's actionability checks intact. - const isClickable = await primaryButton.count() > 0 && await primaryButton.evaluate((el) => { - const box = el.getBoundingClientRect(); - const topmost = document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2); - return topmost !== null && (el === topmost || el.contains(topmost)); - }); - - if (isClickable) { - await primaryButton.click(); - return; - } - - await this.moreButton.click(); - const item = this.overflowAction(label); - await expect(item, `Action '${label}' not found in context menu`).toBeVisible({ timeout: 5_000 }); - await item.click(); + await this.contextMenu.expectVisible(); } async sendToTrash(): Promise { - await this.performAction('Send to trash'); + await this.contextMenu.clickAction('Send to trash'); await expect(this.trashModal).toBeVisible({ timeout: 10_000 }); // For items with children/relations the modal requires ticking confirmation @@ -94,7 +47,7 @@ export class ContentManagementPage extends AdminUiPage { } async hide(): Promise { - await this.performAction('Hide'); + await this.contextMenu.clickAction('Hide'); // "Hide" opens the "Schedule hiding" panel; confirm with the default "Hide now" option await expect( this.page.getByRole('heading', { name: 'Schedule hiding' }).filter({ visible: true }).first(), diff --git a/tests/playwright-tests/Pages/TrashPage.ts b/tests/playwright-tests/Pages/TrashPage.ts index 7627b2bb14..1191da344b 100644 --- a/tests/playwright-tests/Pages/TrashPage.ts +++ b/tests/playwright-tests/Pages/TrashPage.ts @@ -1,12 +1,12 @@ import { Page, Locator, expect } from '@playwright/test'; -import { AdminUiPage, UniversalDiscoveryWidget } from '@ibexa/cohesivo-playwright'; +import { AdminUiPage, UniversalDiscoveryWidget, ContextMenu } from '@ibexa/cohesivo-playwright'; export class TrashPage extends AdminUiPage { readonly udw: UniversalDiscoveryWidget; + readonly contextMenu: ContextMenu; private readonly firstRow: Locator; private readonly emptyState: Locator; - private readonly emptyTrashButton: Locator; private readonly restoreButton: Locator; private readonly restoreUnderNewLocationButton: Locator; private readonly bulkDeleteButton: Locator; @@ -14,14 +14,12 @@ export class TrashPage extends AdminUiPage { constructor(page: Page) { super(page); this.udw = new UniversalDiscoveryWidget(page); + this.contextMenu = new ContextMenu(page); this.firstRow = page.locator('.ibexa-table__row').first(); this.emptyState = page.locator('.ibexa-table__empty-table-text') .or(page.getByText('Trash is empty')) .or(page.getByText('No items')); - this.emptyTrashButton = page.locator('.ibexa-context-menu .ibexa-btn') - .filter({ hasText: /Empty( Trash)?/ }) - .first(); this.restoreButton = page.getByRole('button', { name: 'Restore', exact: true }); this.restoreUnderNewLocationButton = page.getByRole('button', { name: 'Restore in a new location' }) .or(page.locator('button.ibexa-btn--open-udw')).first(); @@ -42,7 +40,7 @@ export class TrashPage extends AdminUiPage { } async emptyTrash(): Promise { - await this.emptyTrashButton.click(); + await this.contextMenu.clickAction(/Empty( Trash)?/); await this.confirmDialogButton('Delete'); await this.assertEmpty(); } From 062797f82c2ad88a9f6a5b8153f35a105c4eca86 Mon Sep 17 00:00:00 2001 From: adrianpawlak Date: Fri, 24 Jul 2026 13:50:28 +0200 Subject: [PATCH 09/12] Refactor locators --- tests/playwright-tests/Pages/TrashPage.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/playwright-tests/Pages/TrashPage.ts b/tests/playwright-tests/Pages/TrashPage.ts index 1191da344b..4baf128c9f 100644 --- a/tests/playwright-tests/Pages/TrashPage.ts +++ b/tests/playwright-tests/Pages/TrashPage.ts @@ -7,6 +7,7 @@ export class TrashPage extends AdminUiPage { private readonly firstRow: Locator; private readonly emptyState: Locator; + private readonly searchInput: Locator; private readonly restoreButton: Locator; private readonly restoreUnderNewLocationButton: Locator; private readonly bulkDeleteButton: Locator; @@ -20,6 +21,7 @@ export class TrashPage extends AdminUiPage { this.emptyState = page.locator('.ibexa-table__empty-table-text') .or(page.getByText('Trash is empty')) .or(page.getByText('No items')); + this.searchInput = page.locator('input[name="trash_search[content_name]"]'); this.restoreButton = page.getByRole('button', { name: 'Restore', exact: true }); this.restoreUnderNewLocationButton = page.getByRole('button', { name: 'Restore in a new location' }) .or(page.locator('button.ibexa-btn--open-udw')).first(); @@ -82,8 +84,11 @@ export class TrashPage extends AdminUiPage { } async searchInTrash(query: string): Promise { - const url = this.page.url().split('?')[0]; - await this.page.goto(`${url}?trash_search[content_name]=${encodeURIComponent(query)}`); + // Drive the search form as a user would: type the query and submit. + // The form is a plain GET, so pressing Enter navigates to the filtered list. + await this.searchInput.fill(query); + await this.searchInput.press('Enter'); + await this.page.waitForLoadState('networkidle'); } async filterByContentType(contentTypeName: string): Promise { From a005d77c9d409f3f14fe221198a701ab173d4156 Mon Sep 17 00:00:00 2001 From: adrianpawlak Date: Tue, 4 Aug 2026 11:45:39 +0200 Subject: [PATCH 10/12] Refactor naming component for consistency with Behat --- .../playwright-tests/Pages/ContentManagementPage.ts | 12 ++++++------ tests/playwright-tests/Pages/TrashPage.ts | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/playwright-tests/Pages/ContentManagementPage.ts b/tests/playwright-tests/Pages/ContentManagementPage.ts index 0afe5923d0..c704befb2d 100644 --- a/tests/playwright-tests/Pages/ContentManagementPage.ts +++ b/tests/playwright-tests/Pages/ContentManagementPage.ts @@ -1,9 +1,9 @@ import { Page, Locator, expect } from '@playwright/test'; -import { AdminUiPage, UniversalDiscoveryWidget, ContextMenu } from '@ibexa/cohesivo-playwright'; +import { AdminUiPage, UniversalDiscoveryWidget, ContentActionsMenu } from '@ibexa/cohesivo-playwright'; export class ContentManagementPage extends AdminUiPage { readonly udw: UniversalDiscoveryWidget; - readonly contextMenu: ContextMenu; + readonly contentActionsMenu: ContentActionsMenu; private readonly trashModal: Locator; private readonly trashModalSubmit: Locator; @@ -12,7 +12,7 @@ export class ContentManagementPage extends AdminUiPage { constructor(page: Page) { super(page); this.udw = new UniversalDiscoveryWidget(page); - this.contextMenu = new ContextMenu(page); + this.contentActionsMenu = new ContentActionsMenu(page); this.trashModal = page.locator('#trash-location-modal, .ibexa-modal--trash-location').first(); this.trashModalSubmit = this.trashModal.locator('.ibexa-btn--confirm-send-to-trash'); this.subItems = page.locator('.m-sub-items'); @@ -24,11 +24,11 @@ export class ContentManagementPage extends AdminUiPage { async open(contentId: number, locationId: number): Promise { await this.page.goto(`/admin/view/content/${contentId}/full/1/${locationId}`); - await this.contextMenu.expectVisible(); + await this.contentActionsMenu.expectVisible(); } async sendToTrash(): Promise { - await this.contextMenu.clickAction('Send to trash'); + await this.contentActionsMenu.clickButton('Send to trash'); await expect(this.trashModal).toBeVisible({ timeout: 10_000 }); // For items with children/relations the modal requires ticking confirmation @@ -47,7 +47,7 @@ export class ContentManagementPage extends AdminUiPage { } async hide(): Promise { - await this.contextMenu.clickAction('Hide'); + await this.contentActionsMenu.clickButton('Hide'); // "Hide" opens the "Schedule hiding" panel; confirm with the default "Hide now" option await expect( this.page.getByRole('heading', { name: 'Schedule hiding' }).filter({ visible: true }).first(), diff --git a/tests/playwright-tests/Pages/TrashPage.ts b/tests/playwright-tests/Pages/TrashPage.ts index 4baf128c9f..bd59b1d826 100644 --- a/tests/playwright-tests/Pages/TrashPage.ts +++ b/tests/playwright-tests/Pages/TrashPage.ts @@ -1,9 +1,9 @@ import { Page, Locator, expect } from '@playwright/test'; -import { AdminUiPage, UniversalDiscoveryWidget, ContextMenu } from '@ibexa/cohesivo-playwright'; +import { AdminUiPage, UniversalDiscoveryWidget, ContentActionsMenu } from '@ibexa/cohesivo-playwright'; export class TrashPage extends AdminUiPage { readonly udw: UniversalDiscoveryWidget; - readonly contextMenu: ContextMenu; + readonly contentActionsMenu: ContentActionsMenu; private readonly firstRow: Locator; private readonly emptyState: Locator; @@ -15,7 +15,7 @@ export class TrashPage extends AdminUiPage { constructor(page: Page) { super(page); this.udw = new UniversalDiscoveryWidget(page); - this.contextMenu = new ContextMenu(page); + this.contentActionsMenu = new ContentActionsMenu(page); this.firstRow = page.locator('.ibexa-table__row').first(); this.emptyState = page.locator('.ibexa-table__empty-table-text') @@ -42,7 +42,7 @@ export class TrashPage extends AdminUiPage { } async emptyTrash(): Promise { - await this.contextMenu.clickAction(/Empty( Trash)?/); + await this.contentActionsMenu.clickButton(/Empty( Trash)?/); await this.confirmDialogButton('Delete'); await this.assertEmpty(); } From 8072569aa4de45853eaef345f3df4da781219898 Mon Sep 17 00:00:00 2001 From: tomaszszopinski Date: Tue, 25 Aug 2026 12:58:49 +0200 Subject: [PATCH 11/12] Updated PW version, added scripts to package.json --- package.json | 7 ++++++- tests/playwright-tests/package-lock.json | 4 ++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index bd45552855..7161accf32 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,11 @@ "ts-test": "tsc --noEmit", "eslint-test": "eslint \"./src/bundle/Resources/**/*.{js,ts}\" \"./src/bundle/ui-dev/**/*.{js,tsx}\"", "prettier-test": "yarn prettier \"./src/bundle/Resources/**/*.{js,ts,scss}\" \"./src/bundle/ui-dev/**/*.{js,tsx}\" --check", - "postinstall": "yarn ibexa-generate-tsconfig --use-root-project-tsconfig" + "postinstall": "yarn ibexa-generate-tsconfig --use-root-project-tsconfig", + "pw:install": "npm --prefix tests/playwright-tests install", + "pw:test": "npm --prefix tests/playwright-tests test --", + "pw:test:headless": "npm --prefix tests/playwright-tests run test:headless --", + "pw:test:experience": "npm --prefix tests/playwright-tests run test:experience --", + "pw:test:commerce": "npm --prefix tests/playwright-tests run test:commerce --" } } diff --git a/tests/playwright-tests/package-lock.json b/tests/playwright-tests/package-lock.json index 69a29fe8bc..4d9b7f1921 100644 --- a/tests/playwright-tests/package-lock.json +++ b/tests/playwright-tests/package-lock.json @@ -15,10 +15,10 @@ "name": "@ibexa/cohesivo-playwright", "version": "1.0.0", "dependencies": { - "@playwright/test": "^1.60.0", + "@playwright/test": "^1.62.1", "@types/node": "^20.0.0", "dotenv": "^17.4.2", - "typescript": "^5.4.0" + "typescript": "^5.5.0" }, "devDependencies": { "@typescript-eslint/eslint-plugin": "^7.0.0", From c509bcb5e69503dd7b16143c75406276d9859ae6 Mon Sep 17 00:00:00 2001 From: adrianpawlak Date: Fri, 28 Aug 2026 14:10:27 +0200 Subject: [PATCH 12/12] Migrate Trash.spec.ts page objects to @ibexa/cohesivo-playwright --- .../Pages/ContentManagementPage.ts | 75 -------------- tests/playwright-tests/Pages/TrashPage.ts | 97 ------------------- tests/playwright-tests/Tests/Trash.spec.ts | 16 +-- tests/playwright-tests/tsconfig.json | 1 - 4 files changed, 8 insertions(+), 181 deletions(-) delete mode 100644 tests/playwright-tests/Pages/ContentManagementPage.ts delete mode 100644 tests/playwright-tests/Pages/TrashPage.ts diff --git a/tests/playwright-tests/Pages/ContentManagementPage.ts b/tests/playwright-tests/Pages/ContentManagementPage.ts deleted file mode 100644 index c704befb2d..0000000000 --- a/tests/playwright-tests/Pages/ContentManagementPage.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { Page, Locator, expect } from '@playwright/test'; -import { AdminUiPage, UniversalDiscoveryWidget, ContentActionsMenu } from '@ibexa/cohesivo-playwright'; - -export class ContentManagementPage extends AdminUiPage { - readonly udw: UniversalDiscoveryWidget; - readonly contentActionsMenu: ContentActionsMenu; - - private readonly trashModal: Locator; - private readonly trashModalSubmit: Locator; - private readonly subItems: Locator; - - constructor(page: Page) { - super(page); - this.udw = new UniversalDiscoveryWidget(page); - this.contentActionsMenu = new ContentActionsMenu(page); - this.trashModal = page.locator('#trash-location-modal, .ibexa-modal--trash-location').first(); - this.trashModalSubmit = this.trashModal.locator('.ibexa-btn--confirm-send-to-trash'); - this.subItems = page.locator('.m-sub-items'); - } - - private subItemRow(name: string): Locator { - return this.subItems.locator('.ibexa-table__row').filter({ hasText: name }); - } - - async open(contentId: number, locationId: number): Promise { - await this.page.goto(`/admin/view/content/${contentId}/full/1/${locationId}`); - await this.contentActionsMenu.expectVisible(); - } - - async sendToTrash(): Promise { - await this.contentActionsMenu.clickButton('Send to trash'); - await expect(this.trashModal).toBeVisible({ timeout: 10_000 }); - - // For items with children/relations the modal requires ticking confirmation - // checkboxes before the submit button becomes enabled. - if (await this.trashModalSubmit.isDisabled()) { - const checkboxes = this.trashModal.locator('input[type="checkbox"]:not(:checked)'); - const count = await checkboxes.count(); - for (let i = 0; i < count; i++) { - await checkboxes.nth(i).check({ force: true }); - } - } - - await expect(this.trashModalSubmit).toBeEnabled({ timeout: 5_000 }); - await this.trashModalSubmit.click(); - await expect(this.trashModal).toBeHidden({ timeout: 10_000 }); - } - - async hide(): Promise { - await this.contentActionsMenu.clickButton('Hide'); - // "Hide" opens the "Schedule hiding" panel; confirm with the default "Hide now" option - await expect( - this.page.getByRole('heading', { name: 'Schedule hiding' }).filter({ visible: true }).first(), - ).toBeVisible({ timeout: 10_000 }); - await this.page.getByRole('button', { name: 'Confirm' }).filter({ visible: true }).first().click(); - } - - async assertOnContentView(itemName: string): Promise { - // reuse the shared page-title assertion from AdminUiPage - await this.assertPageTitle(itemName); - } - - async assertSubitemPresent(name: string): Promise { - await expect(this.subItemRow(name).first()).toBeVisible({ timeout: 10_000 }); - } - - async assertSubitemAbsent(name: string): Promise { - // .m-sub-items is a React mount point — anchor on the rendered table - // (or its empty state) before asserting absence, to avoid passing on a blank mount. - await expect( - this.subItems.locator('.ibexa-table, .ibexa-table__empty-table-text').first(), - ).toBeVisible({ timeout: 10_000 }); - await expect(this.subItemRow(name)).toHaveCount(0); - } -} diff --git a/tests/playwright-tests/Pages/TrashPage.ts b/tests/playwright-tests/Pages/TrashPage.ts deleted file mode 100644 index bd59b1d826..0000000000 --- a/tests/playwright-tests/Pages/TrashPage.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { Page, Locator, expect } from '@playwright/test'; -import { AdminUiPage, UniversalDiscoveryWidget, ContentActionsMenu } from '@ibexa/cohesivo-playwright'; - -export class TrashPage extends AdminUiPage { - readonly udw: UniversalDiscoveryWidget; - readonly contentActionsMenu: ContentActionsMenu; - - private readonly firstRow: Locator; - private readonly emptyState: Locator; - private readonly searchInput: Locator; - private readonly restoreButton: Locator; - private readonly restoreUnderNewLocationButton: Locator; - private readonly bulkDeleteButton: Locator; - - constructor(page: Page) { - super(page); - this.udw = new UniversalDiscoveryWidget(page); - this.contentActionsMenu = new ContentActionsMenu(page); - - this.firstRow = page.locator('.ibexa-table__row').first(); - this.emptyState = page.locator('.ibexa-table__empty-table-text') - .or(page.getByText('Trash is empty')) - .or(page.getByText('No items')); - this.searchInput = page.locator('input[name="trash_search[content_name]"]'); - this.restoreButton = page.getByRole('button', { name: 'Restore', exact: true }); - this.restoreUnderNewLocationButton = page.getByRole('button', { name: 'Restore in a new location' }) - .or(page.locator('button.ibexa-btn--open-udw')).first(); - // toolbar Delete only — [data-bs-dismiss] excludes the modal's own dismiss button - this.bulkDeleteButton = page.locator('button:not([data-bs-dismiss])').filter({ hasText: 'Delete' }).first(); - } - - async open(): Promise { - await this.navigateTo(`/admin/trash/list`); - } - - async assertNotEmpty(): Promise { - await expect(this.firstRow).toBeVisible({ timeout: 10_000 }); - } - - async assertEmpty(): Promise { - await expect(this.emptyState.first()).toBeVisible({ timeout: 10_000 }); - } - - async emptyTrash(): Promise { - await this.contentActionsMenu.clickButton(/Empty( Trash)?/); - await this.confirmDialogButton('Delete'); - await this.assertEmpty(); - } - - async assertItemInTrash(name: string): Promise { - await this.assertTableRowPresent(name); - } - - async assertItemNotInTrash(name: string): Promise { - await this.assertTableRowAbsent(name); - } - - async deleteFromTrash(items: string[]): Promise { - for (const item of items) { - await this.checkTableRow(item); - } - await this.bulkDeleteButton.click(); - await this.confirmDialogButton('Delete'); - } - - async restoreFromTrash(items: string[]): Promise { - for (const item of items) { - await this.checkTableRow(item); - } - await this.restoreButton.click(); - } - - /** - * Restores the checked items under the location given by path (e.g. "Media/Files"), - * driving the whole flow: restore button → UDW navigation → UDW confirm. - */ - async restoreUnderNewLocation(items: string[], newLocationPath: string): Promise { - for (const item of items) { - await this.checkTableRow(item); - } - await this.restoreUnderNewLocationButton.click(); - await this.udw.selectPath(newLocationPath); - await this.udw.confirm(); - } - - async searchInTrash(query: string): Promise { - // Drive the search form as a user would: type the query and submit. - // The form is a plain GET, so pressing Enter navigates to the filtered list. - await this.searchInput.fill(query); - await this.searchInput.press('Enter'); - await this.page.waitForLoadState('networkidle'); - } - - async filterByContentType(contentTypeName: string): Promise { - await this.page.locator('select').first().selectOption({ label: contentTypeName }); - } -} diff --git a/tests/playwright-tests/Tests/Trash.spec.ts b/tests/playwright-tests/Tests/Trash.spec.ts index 0972c6f8ed..9f50b028d5 100644 --- a/tests/playwright-tests/Tests/Trash.spec.ts +++ b/tests/playwright-tests/Tests/Trash.spec.ts @@ -1,7 +1,5 @@ import { test, expect } from '@playwright/test'; -import { TrashPage } from '../Pages/TrashPage'; -import { ContentManagementPage } from '../Pages/ContentManagementPage'; -import { IbexaApiClient } from '@ibexa/cohesivo-playwright'; +import { TrashPage, ContentManagementPage, IbexaApiClient } from '@ibexa/cohesivo-playwright'; test.describe('Trash management', { tag: ['@IbexaOSS', '@IbexaHeadless', '@IbexaExperience', '@IbexaCommerce'] }, () => { @@ -20,7 +18,9 @@ test.describe('Trash management', { tag: ['@IbexaOSS', '@IbexaHeadless', '@Ibexa }); test.afterAll(async () => { - await api.deleteContent(trashTestContentId); + if (api && trashTestContentId) { + await api.deleteContent(trashTestContentId); + } }); test('Trash can be emptied', async ({ page }) => { @@ -47,7 +47,7 @@ test.describe('Trash management', { tag: ['@IbexaOSS', '@IbexaHeadless', '@Ibexa await contentPage.open(childId, childLocId); await contentPage.sendToTrash(); - await contentPage.assertSuccessNotification(`Location '${name}' moved to Trash`); + await contentPage.notifications.assertSuccess(`Location '${name}' moved to Trash`); const trash = new TrashPage(page); await trash.open(); @@ -67,7 +67,7 @@ test.describe('Trash management', { tag: ['@IbexaOSS', '@IbexaHeadless', '@Ibexa await trash.open(); await trash.assertItemInTrash(name); await trash.deleteFromTrash([name]); - await trash.assertSuccessNotification('Deleted selected item(s) from Trash'); + await trash.notifications.assertSuccess('Deleted selected item(s) from Trash'); await trash.assertItemNotInTrash(name); }); @@ -84,7 +84,7 @@ test.describe('Trash management', { tag: ['@IbexaOSS', '@IbexaHeadless', '@Ibexa await trash.open(); await trash.assertItemInTrash(name); await trash.restoreFromTrash([name]); - await trash.assertSuccessNotification('Restored content to its original Location'); + await trash.notifications.assertSuccess('Restored content to its original Location'); await trash.assertItemNotInTrash(name); }); @@ -102,7 +102,7 @@ test.describe('Trash management', { tag: ['@IbexaOSS', '@IbexaHeadless', '@Ibexa await trash.assertItemInTrash(name); await trash.restoreUnderNewLocation([name], 'Media/Files'); - await trash.assertSuccessNotification("Restored content under Location 'Files'"); + await trash.notifications.assertSuccess("Restored content under Location 'Files'"); await trash.assertItemNotInTrash(name); // Verify the content actually landed under Media/Files (path resolution throws if absent) diff --git a/tests/playwright-tests/tsconfig.json b/tests/playwright-tests/tsconfig.json index 0684779cca..6406e62188 100644 --- a/tests/playwright-tests/tsconfig.json +++ b/tests/playwright-tests/tsconfig.json @@ -6,7 +6,6 @@ }, "include": [ "Tests/**/*.ts", - "Pages/**/*.ts", "Utils/**/*.ts", "playwright.config.ts" ],