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/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; } 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/',