From b825e5462ae700efd6f5275c4ddd1ed606e325fe Mon Sep 17 00:00:00 2001 From: Danilo Lakovic Date: Sun, 8 Nov 2026 17:31:30 +0100 Subject: [PATCH 1/2] Check the placeholder, 404 and not-installed pages instead of expecting failures The pages of the removed galaxy_ng screens are real checks now for both accounts: a placeholder shows its title and sentence, a removed route shows the not-found page, and neither sends a failing request or a request to an endpoint only galaxy_ng serves. A page of an optional plugin the server does not run is no longer skipped; it must say the plugin is not installed. --- e2e/remote/list-pages.spec.ts | 14 ++- e2e/remote/list-pages.ts | 152 ++++++++++++++++++--------- e2e/remote/list-pages.viewer.spec.ts | 9 +- 3 files changed, 123 insertions(+), 52 deletions(-) diff --git a/e2e/remote/list-pages.spec.ts b/e2e/remote/list-pages.spec.ts index c9eba0db..b532c487 100644 --- a/e2e/remote/list-pages.spec.ts +++ b/e2e/remote/list-pages.spec.ts @@ -1,6 +1,8 @@ import { expect, test } from '@playwright/test'; -import { title } from '../support/sign-in'; import { + absentOptionalPlugin, + expectNothingLoaded, + expectPageShown, failureShown, listPages, missingExpectedPlugins, @@ -11,7 +13,11 @@ import { // Every list page of every installed plugin, and of Pulp core, opens for the // admin account with its title, without the error boundary, without an error -// alert or error state and without an uncaught error in the page. +// alert or error state and without an uncaught error in the page. A screen +// this UI does not have shows its placeholder, a route that no longer exists +// shows the not-found page, and a page of an optional plugin the server does +// not run says the plugin is not installed; none of them sends a failing +// request or a request to an endpoint only galaxy_ng serves. test.beforeAll(async () => { if (missingPluginsAllowed()) { @@ -30,14 +36,16 @@ for (const entry of listPages) { // runs anyway: the run reports it the day the page works test.fail(!!entry.knownDefect?.admin, entry.knownDefect?.admin ?? ''); + const notInstalled = await absentOptionalPlugin(entry); const watch = watchPage(page); await page.goto(entry.path); - await expect(title(page, entry.title)).toBeVisible(); + await expectPageShown(page, entry, notInstalled); await page.waitForLoadState('networkidle'); await expect(page.getByText('The stack trace is:')).toHaveCount(0); await expect(failureShown(page)).toHaveCount(0); expect(watch.errors, 'uncaught errors of the page').toEqual([]); + expectNothingLoaded(watch, entry, notInstalled); }); } diff --git a/e2e/remote/list-pages.ts b/e2e/remote/list-pages.ts index 79c78bc1..498870bf 100644 --- a/e2e/remote/list-pages.ts +++ b/e2e/remote/list-pages.ts @@ -1,5 +1,6 @@ -import { type Page } from '@playwright/test'; +import { type Page, expect } from '@playwright/test'; import { apiContext } from '../support/api'; +import { title } from '../support/sign-in'; // Every list page of the application, with the plugin that has to be installed // on the server for the page to make sense (null for pages of Pulp core) and @@ -10,16 +11,27 @@ import { apiContext } from '../support/api'; // expected to fail with that reason, so it still runs and the run reports it // the day the page works. `viewerRefused` marks a page the limited account may // not read: the server answers it with 403. +// +// `outcome` names a page that is deliberately not a working screen: a +// placeholder that says the screen is not available yet, or the not-found page +// of a route that no longer exists. Such a page must show exactly that, with no +// failed request and no request to an endpoint that only galaxy_ng serves. +// For these entries `title` is the heading the page shows. + +export type Outcome = 'placeholder' | 'not-found'; export interface ListPage { name: string; path: string; plugin: string | null; title: string; + outcome?: Outcome; viewerRefused?: boolean; knownDefect?: { admin?: string; viewer?: string }; } +const NOT_FOUND_TITLE = '404 - Page not found'; + export const listPages: ListPage[] = [ { name: 'Status', path: 'status', plugin: null, title: 'Status' }, { @@ -61,12 +73,7 @@ export const listPages: ListPage[] = [ path: 'search', plugin: null, title: 'Search', - knownDefect: { - admin: - 'The page crashes into the error boundary: it builds a link without the repository name it needs.', - viewer: - 'The page crashes into the error boundary: it builds a link without the repository name it needs.', - }, + outcome: 'placeholder', }, { name: 'Ansible repositories', @@ -85,70 +92,49 @@ export const listPages: ListPage[] = [ path: 'ansible/collections', plugin: 'ansible', title: 'Collections', - knownDefect: { - admin: - 'The page crashes into the error boundary: it reads the namespace of a collection that the server does not return.', - }, + outcome: 'placeholder', }, { name: 'Ansible namespaces', path: 'ansible/namespaces', plugin: 'ansible', title: 'Namespaces', - knownDefect: { - admin: - 'The list cannot be loaded: it calls an endpoint that exists only on galaxy_ng and shows a 404 error alert.', - viewer: - 'The list cannot be loaded: it calls an endpoint that exists only on galaxy_ng and shows a 404 error alert.', - }, + outcome: 'placeholder', }, { name: 'Ansible my namespaces', path: 'ansible/my-namespaces', plugin: 'ansible', - title: 'Namespaces', - knownDefect: { - admin: - 'The list cannot be loaded: it calls an endpoint that exists only on galaxy_ng and shows a 404 error alert.', - viewer: - 'The list cannot be loaded: it calls an endpoint that exists only on galaxy_ng and shows a 404 error alert.', - }, + title: NOT_FOUND_TITLE, + outcome: 'not-found', }, { name: 'Ansible approvals', path: 'ansible/approvals', plugin: 'ansible', - title: 'Approvals', - knownDefect: { - admin: - 'The page is an empty under-construction placeholder with no title.', - viewer: - 'The page is an empty under-construction placeholder with no title.', - }, + title: NOT_FOUND_TITLE, + outcome: 'not-found', }, { name: 'Ansible imports', path: 'ansible/my-imports', plugin: 'ansible', - title: 'Imports', - knownDefect: { - admin: - 'The page loads namespaces from an endpoint that exists only on galaxy_ng and shows a 404 error alert.', - viewer: - 'The page loads namespaces from an endpoint that exists only on galaxy_ng and shows a 404 error alert.', - }, + title: NOT_FOUND_TITLE, + outcome: 'not-found', }, { name: 'Container repositories', path: 'container/containers', plugin: 'container', title: 'Containers', + outcome: 'placeholder', }, { name: 'Container remote registries', path: 'container/remotes', plugin: 'container', title: 'Remote registries', + outcome: 'placeholder', }, { name: 'Deb repositories', @@ -236,14 +222,17 @@ export async function missingExpectedPlugins(): Promise { return EXPECTED_PLUGINS.filter((plugin) => !present.has(plugin)); } -// Skip reason for a page whose plugin the server does not run, or null. The -// pages of an expected plugin are skipped only when the run allows it; every -// other plugin (container) is optional. +// Skip reason for a page whose plugin the server does not run, or null. Only +// the pages of an expected plugin are skipped, and only when the run allows +// it. A page of an optional plugin (container) is never skipped: the +// application says the plugin is not installed, and absentOptionalPlugin names +// that case. export async function missingPlugin(entry: ListPage): Promise { - if (!entry.plugin) { - return null; - } - if (EXPECTED_PLUGINS.includes(entry.plugin) && !missingPluginsAllowed()) { + if ( + !entry.plugin || + !EXPECTED_PLUGINS.includes(entry.plugin) || + !missingPluginsAllowed() + ) { return null; } return (await installedPlugins()).has(entry.plugin) @@ -251,16 +240,35 @@ export async function missingPlugin(entry: ListPage): Promise { : `the ${entry.plugin} plugin is not installed on this server`; } -// What a page did while it loaded: uncaught errors, and the answers of the -// API that were errors. +// The plugin of a page when that plugin is optional and the server does not +// report it, otherwise null. The page then shows the not-installed page in +// place of its screen. +export async function absentOptionalPlugin( + entry: ListPage, +): Promise { + if (!entry.plugin || EXPECTED_PLUGINS.includes(entry.plugin)) { + return null; + } + return (await installedPlugins()).has(entry.plugin) ? null : entry.plugin; +} + +// What a page did while it loaded: uncaught errors, the paths of the API +// requests it sent, and the answers of the API that were errors. export interface PageWatch { errors: string[]; + requests: string[]; failed: { url: string; status: number }[]; } export function watchPage(page: Page): PageWatch { - const watch: PageWatch = { errors: [], failed: [] }; + const watch: PageWatch = { errors: [], requests: [], failed: [] }; page.on('pageerror', (error) => watch.errors.push(error.message)); + page.on('request', (request) => { + const { pathname } = new URL(request.url()); + if (pathname.startsWith('/pulp/api/')) { + watch.requests.push(pathname); + } + }); page.on('response', (response) => { const { pathname } = new URL(response.url()); if (pathname.startsWith('/pulp/api/') && response.status() >= 400) { @@ -277,3 +285,51 @@ export function failureShown(page: Page) { .locator('.pf-v5-c-alert.pf-m-danger') .or(page.getByRole('button', { name: 'Retry', exact: true })); } + +// A request path that only a galaxy_ng server answers: its internal UI API, +// or its collection API, which is a second v3/ below the API root. +export function galaxyOnly(pathname: string): boolean { + return /\/_ui\//.test(pathname) || pathname.startsWith('/pulp/api/v3/v3/'); +} + +// The page of an entry is on screen: the not-installed page when the plugin +// is optional and absent, otherwise the entry's title, and for a placeholder +// its sentence. +export async function expectPageShown( + page: Page, + entry: ListPage, + notInstalled: string | null, +): Promise { + if (notInstalled) { + await expect(title(page, 'Plugin not installed')).toBeVisible(); + await expect( + page.getByText( + `The ${notInstalled} plugin is not installed on this Pulp server.`, + ), + ).toBeVisible(); + return; + } + await expect(title(page, entry.title)).toBeVisible(); + if (entry.outcome === 'placeholder') { + await expect( + page.getByText('This screen is not available in this UI yet.'), + ).toBeVisible(); + } +} + +// A placeholder, a not-found page and a not-installed page load nothing of +// their own: no failed answer, and no request to a galaxy-only endpoint. +export function expectNothingLoaded( + watch: PageWatch, + entry: ListPage, + notInstalled: string | null, +): void { + if (!entry.outcome && !notInstalled) { + return; + } + expect(watch.failed, 'API requests of the page that failed').toEqual([]); + expect( + watch.requests.filter(galaxyOnly), + 'requests of the page to endpoints only galaxy_ng serves', + ).toEqual([]); +} diff --git a/e2e/remote/list-pages.viewer.spec.ts b/e2e/remote/list-pages.viewer.spec.ts index 6eea18b2..ce1fcef3 100644 --- a/e2e/remote/list-pages.viewer.spec.ts +++ b/e2e/remote/list-pages.viewer.spec.ts @@ -1,6 +1,9 @@ import { expect, test } from '@playwright/test'; import { title } from '../support/sign-in'; import { + absentOptionalPlugin, + expectNothingLoaded, + expectPageShown, failureShown, listPages, missingPlugin, @@ -12,6 +15,8 @@ import { // is refused by the server with 403; it may show the title, or say so with an // error, but an error of any other kind (404, 500, no answer) is a fault of // the page. Neither may ever meet the error boundary or an uncaught error. +// A placeholder, a not-found page and a not-installed page are shown to this +// account exactly as to the admin. for (const entry of listPages) { const how = entry.viewerRefused @@ -22,6 +27,7 @@ for (const entry of listPages) { test.skip(absent !== null, absent ?? ''); test.fail(!!entry.knownDefect?.viewer, entry.knownDefect?.viewer ?? ''); + const notInstalled = await absentOptionalPlugin(entry); const watch = watchPage(page); await page.goto(entry.path); @@ -30,7 +36,7 @@ for (const entry of listPages) { title(page, entry.title).or(failureShown(page)).first(), ).toBeVisible(); } else { - await expect(title(page, entry.title)).toBeVisible(); + await expectPageShown(page, entry, notInstalled); } await page.waitForLoadState('networkidle'); @@ -40,6 +46,7 @@ for (const entry of listPages) { if (!entry.viewerRefused) { await expect(failureShown(page)).toHaveCount(0); expect(watch.failed, 'API requests of the page that failed').toEqual([]); + expectNothingLoaded(watch, entry, notInstalled); return; } From bb55a9c20727ab9f09dd16abc5f2cae4412ee5b7 Mon Sep 17 00:00:00 2001 From: Danilo Lakovic Date: Sun, 8 Nov 2026 17:31:35 +0100 Subject: [PATCH 2/2] Test the collection versions tab and the Add collection dialog on a real server An ansible repository made through the API shows the empty state without an error, the dialog lists the collection versions of the server, adding one puts it in the repository and removing it empties the tab again. Ansible repositories are registered for clean-up. --- .../ansible-collection-versions.spec.ts | 164 ++++++++++++++++++ e2e/support/api.ts | 1 + 2 files changed, 165 insertions(+) create mode 100644 e2e/remote/ansible-collection-versions.spec.ts diff --git a/e2e/remote/ansible-collection-versions.spec.ts b/e2e/remote/ansible-collection-versions.spec.ts new file mode 100644 index 00000000..95b94c24 --- /dev/null +++ b/e2e/remote/ansible-collection-versions.spec.ts @@ -0,0 +1,164 @@ +import { type Page, expect, test } from '@playwright/test'; +import { type PulpObject, TestObjects } from '../support/api'; +import { title } from '../support/sign-in'; +import { failureShown, watchPage } from './list-pages'; + +// The Collection versions tab of an ansible repository and its Add collection +// dialog work on a plain Pulp server: an empty repository shows the empty state +// and no error, the dialog lists the collection versions the server holds, +// adding one puts it in the repository and removing it empties the tab again. +// The repository is made through the API; the collection version is one the +// server already holds, which the test never changes. + +const COLLECTION_VERSIONS = 'content/ansible/collection_versions/'; +const REPOSITORIES = 'repositories/ansible/ansible/'; +const EMPTY_STATE = 'No collection versions yet'; + +let objects: TestObjects; +let repository: PulpObject; +// A collection version of the server, or undefined when it holds none. +let collection: + { namespace: string; name: string; version: string } | undefined; + +test.describe.configure({ mode: 'serial' }); + +test.beforeAll(async () => { + test.setTimeout(180_000); + objects = await TestObjects.open('collection-versions'); + repository = await objects.create(REPOSITORIES, { + name: objects.name('repo'), + }); + + const versions = (await objects.list(COLLECTION_VERSIONS, { + fields: 'namespace,name,version', + })) as unknown as { namespace: string; name: string; version: string }[]; + const perCollection = new Map(); + for (const { namespace, name } of versions) { + const key = `${namespace}.${name}`; + perCollection.set(key, (perCollection.get(key) ?? 0) + 1); + } + // a collection with few versions keeps the chosen row on the dialog's + // first page once the list is filtered by its namespace and name + collection = versions.find( + ({ namespace, name }) => + (perCollection.get(`${namespace}.${name}`) ?? 0) <= 10, + ); +}); + +test.afterAll(async () => { + test.setTimeout(180_000); + await objects.cleanUp(); +}); + +function tabUrl(): string { + return `ansible/repositories/detail/${repository.name as string}?tab=collection-versions`; +} + +function label(): string { + const { namespace, name, version } = collection as NonNullable< + typeof collection + >; + return `${namespace}.${name} v${version}`; +} + +// The version of the repository the server reports as the latest, by number. +async function latestVersion(): Promise { + const current = await objects.get(repository.pulp_href); + return Number( + String(current['latest_version_href']).replace(/\/$/, '').split('/').pop(), + ); +} + +async function openAddDialog(page: Page) { + await page.getByRole('button', { name: 'Add collection' }).first().click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toContainText('Select a collection'); + return dialog; +} + +test('an empty repository shows the empty state, and the Add collection dialog lists the collection versions of the server', async ({ + page, +}) => { + const watch = watchPage(page); + + await page.goto(tabUrl()); + await expect(title(page, repository.name as string)).toBeVisible(); + await expect(page.getByText(EMPTY_STATE)).toBeVisible(); + await page.waitForLoadState('networkidle'); + await expect(failureShown(page)).toHaveCount(0); + await expect(page.getByText('The stack trace is:')).toHaveCount(0); + expect(watch.failed, 'API requests of the tab that failed').toEqual([]); + + const dialog = await openAddDialog(page); + await page.waitForLoadState('networkidle'); + if (collection) { + await expect(dialog.getByRole('checkbox').first()).toBeVisible(); + } else { + await expect(dialog.getByText(EMPTY_STATE)).toBeVisible(); + } + await expect(failureShown(page)).toHaveCount(0); + await expect(page.getByText('The stack trace is:')).toHaveCount(0); + expect(watch.errors, 'uncaught errors of the page').toEqual([]); + expect(watch.failed, 'API requests of the dialog that failed').toEqual([]); + + await dialog.getByRole('button', { name: 'Cancel' }).click(); + await expect(dialog).toHaveCount(0); +}); + +test('adding a collection version shows it in the tab and removing it empties the tab again', async ({ + page, +}) => { + test.skip(!collection, 'the server holds no collection version'); + test.setTimeout(180_000); + const { namespace, name } = collection as NonNullable; + const watch = watchPage(page); + + await page.goto(tabUrl()); + await expect(page.getByText(EMPTY_STATE)).toBeVisible(); + + const dialog = await openAddDialog(page); + await dialog.getByRole('textbox', { name: 'namespace' }).fill(namespace); + await dialog.getByRole('textbox', { name: 'namespace' }).press('Enter'); + await dialog.getByRole('button', { name: /Namespace/ }).click(); + await page.getByRole('menuitem', { name: 'Name', exact: true }).click(); + await dialog.getByRole('textbox', { name: 'name' }).fill(name); + await dialog.getByRole('textbox', { name: 'name' }).press('Enter'); + + const row = dialog.getByRole('row').filter({ hasText: label() }); + await expect(row).toBeVisible(); + await row.getByRole('checkbox').check(); + await dialog.getByRole('button', { name: 'Select', exact: true }).click(); + + await expect( + page.getByText(`Started adding ${label()} to repository`), + ).toBeVisible(); + await expect.poll(latestVersion, { timeout: 60_000 }).toBe(1); + + // the tab lists the content of the version the repository had when the page + // loaded, so the page is loaded again to see the new version + await page.goto(tabUrl()); + const added = page.getByRole('row').filter({ hasText: label() }); + await expect(added).toBeVisible(); + await expect(page.getByText(EMPTY_STATE)).toHaveCount(0); + + await added.locator('[data-cy=kebab-toggle] button').click(); + await page.getByRole('menuitem', { name: 'Remove', exact: true }).click(); + const confirm = page.getByRole('dialog'); + await expect(confirm).toContainText('Remove collection version?'); + await confirm.getByRole('button', { name: 'Remove', exact: true }).click(); + + await expect( + page.getByText(`Removal of ${label()} from repository`), + ).toBeVisible(); + await expect.poll(latestVersion, { timeout: 60_000 }).toBe(2); + + await page.goto(tabUrl()); + await expect(page.getByText(EMPTY_STATE)).toBeVisible(); + await expect(page.getByRole('row').filter({ hasText: label() })).toHaveCount( + 0, + ); + + await expect(page.getByText('The stack trace is:')).toHaveCount(0); + expect(watch.errors, 'uncaught errors of the page').toEqual([]); + expect(watch.failed, 'API requests of the tab that failed').toEqual([]); +}); diff --git a/e2e/support/api.ts b/e2e/support/api.ts index 2503036f..645f2bd9 100644 --- a/e2e/support/api.ts +++ b/e2e/support/api.ts @@ -72,6 +72,7 @@ const REPOSITORIES = [ 'repositories/deb/apt/', 'repositories/rpm/rpm/', 'repositories/python/python/', + 'repositories/ansible/ansible/', ]; const REMOTES = [ 'remotes/file/file/',