From d38ee71e6c5c38f63d99790f415555a7d82b61ad Mon Sep 17 00:00:00 2001 From: Danilo Lakovic Date: Thu, 29 Oct 2026 19:19:36 +0100 Subject: [PATCH 1/3] Add an API helper for test objects and a browser test of served URLs - e2e/support/api.ts: Basic-authenticated API context, run-scoped names, task waiting and a clean-up that deletes only what a spec created - e2e/remote/urls.spec.ts: repository pages show the base_url the API returns and flag a distribution with nothing to serve --- e2e/remote/urls.spec.ts | 162 +++++++++++++++++++++++ e2e/support/api.ts | 279 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 441 insertions(+) create mode 100644 e2e/remote/urls.spec.ts create mode 100644 e2e/support/api.ts diff --git a/e2e/remote/urls.spec.ts b/e2e/remote/urls.spec.ts new file mode 100644 index 00000000..8ce56e85 --- /dev/null +++ b/e2e/remote/urls.spec.ts @@ -0,0 +1,162 @@ +import { type Page, expect, test } from '@playwright/test'; +import { type PulpObject, TestObjects } from '../support/api'; +import { title } from '../support/sign-in'; + +// The repository page shows the URL the server reports for each distribution +// (never one built from the base path) and flags a distribution that has +// nothing to serve. Every expectation is read from the API at run time; the +// seeded installations are only read, never written. + +interface Distribution extends PulpObject { + name: string; + base_path: string; + base_url: string; + publication: string | null; + repository: string | null; +} + +interface Installation { + repositoryName: string; + distributions: (Distribution & { served: boolean })[]; +} + +const NOT_SERVED = 'Not served: no publication'; + +// A repository version href belongs to the repository it ends with. +function repositoryOfVersion(versionHref: string): string { + return versionHref.replace(/versions\/\d+\/?$/, ''); +} + +let objects: TestObjects; +const found: { + publication?: Installation; + repositoryBoundPublished?: Installation; + notServed?: Installation; +} = {}; + +test.beforeAll(async () => { + objects = await TestObjects.open('urls'); + + const distributions = (await objects.list('distributions/deb/apt/', { + name__startswith: 'zz-synth-', + fields: 'pulp_href,name,base_path,base_url,publication,repository', + })) as Distribution[]; + const publications = await objects.list('publications/deb/apt/', { + fields: 'pulp_href,repository_version', + }); + + const repositoryOfPublication = new Map( + publications.map((publication) => [ + publication.pulp_href, + repositoryOfVersion(publication.repository_version as string), + ]), + ); + const published = new Set(repositoryOfPublication.values()); + + const byRepository = new Map(); + for (const distribution of distributions) { + const repository = + distribution.repository ?? + repositoryOfPublication.get(distribution.publication ?? ''); + if (repository) { + byRepository.set(repository, [ + ...(byRepository.get(repository) ?? []), + distribution, + ]); + } + } + + async function installation(repositoryHref: string): Promise { + const { name } = await objects.get(repositoryHref); + return { + repositoryName: name as string, + distributions: (byRepository.get(repositoryHref) ?? []).map( + (distribution) => ({ + ...distribution, + // a distribution bound to a publication serves it, one bound to the + // repository serves once the repository has a publication + served: !!distribution.publication || published.has(repositoryHref), + }), + ), + }; + } + + for (const [repository, own] of byRepository) { + if (!found.publication && own.some((d) => d.publication)) { + found.publication = await installation(repository); + } + if ( + !found.repositoryBoundPublished && + published.has(repository) && + own.some((d) => d.repository && !d.publication) + ) { + found.repositoryBoundPublished = await installation(repository); + } + if (!found.notServed && !published.has(repository) && own.length) { + found.notServed = await installation(repository); + } + } +}); + +test.afterAll(async () => { + await objects.cleanUp(); +}); + +async function expectUrlsAndLabels( + page: Page, + { repositoryName, distributions }: Installation, +) { + await page.goto(`deb/repositories/detail/${repositoryName}`); + await expect(title(page, repositoryName)).toBeVisible(); + + const details = page.locator('.pf-v5-c-description-list__group', { + hasText: 'Repository URL', + }); + for (const { base_url } of distributions) { + await expect(details.getByText(base_url, { exact: true })).toBeVisible(); + } + await expect(details.getByText(NOT_SERVED)).toHaveCount( + distributions.filter((distribution) => !distribution.served).length, + ); + + await page.goto( + `deb/repositories/detail/${repositoryName}?tab=distributions`, + ); + for (const { name, base_url, served } of distributions) { + const row = page.getByRole('row').filter({ + has: page.getByRole('gridcell', { name, exact: true }), + }); + await expect(row.getByText(base_url, { exact: true })).toBeVisible(); + await expect(row.getByText(NOT_SERVED)).toHaveCount(served ? 0 : 1); + } +} + +test('a repository served from a publication shows the base_url the API returns', async ({ + page, +}) => { + const installation = found.publication; + test.skip(!installation, 'no seeded repository is served from a publication'); + await expectUrlsAndLabels(page, installation as Installation); +}); + +test('a repository-bound distribution of a published repository shows its base_url and no label', async ({ + page, +}) => { + const installation = found.repositoryBoundPublished; + test.skip( + !installation, + 'no seeded repository has a publication and a repository-bound distribution', + ); + await expectUrlsAndLabels(page, installation as Installation); +}); + +test('a distribution with nothing to serve shows its base_url and the not-served label', async ({ + page, +}) => { + const installation = found.notServed; + test.skip(!installation, 'every seeded distribution is served'); + expect( + (installation as Installation).distributions.every((d) => !d.served), + ).toBe(true); + await expectUrlsAndLabels(page, installation as Installation); +}); diff --git a/e2e/support/api.ts b/e2e/support/api.ts new file mode 100644 index 00000000..3aac1e26 --- /dev/null +++ b/e2e/support/api.ts @@ -0,0 +1,279 @@ +import { + type APIRequestContext, + type APIResponse, + request, +} from '@playwright/test'; +import { randomBytes } from 'node:crypto'; +import { basicAuthorization } from '../../src/utilities/basic-authorization'; +import { account } from './accounts'; +import { origin } from './env'; + +// Set-up and clean-up of the objects a remote test needs, through the REST API +// with the admin account of the run. Basic-authenticated calls carry no session +// and need no CSRF token. +// +// Every object a test writes is named zz-e2e---. The run +// id is chosen once per run (and handed to the worker processes through the +// environment), the scope belongs to one spec file, so two runs and two files +// never touch each other's objects. Nothing else on the deployment is ever +// written: clean-up deletes only what the test registered plus whatever still +// carries the scope's own prefix. + +export const API_BASE = '/pulp/api/v3/'; + +const RUN_ID_VARIABLE = 'PULP_UI_E2E_RUN_ID'; + +// A short random id of this run; every worker of the run sees the same one. +export function runId(): string { + process.env[RUN_ID_VARIABLE] ??= randomBytes(3).toString('hex'); + return process.env[RUN_ID_VARIABLE]; +} + +export function apiContext(): Promise { + const { username, password } = account('admin'); + return request.newContext({ + baseURL: origin(), + extraHTTPHeaders: { + Authorization: basicAuthorization(username, password), + }, + }); +} + +export interface PulpObject { + pulp_href: string; + name?: string; + [field: string]: unknown; +} + +interface Task { + state: string; + created_resources: string[]; + error?: { description?: string } | null; +} + +// Endpoints that hold objects created by the tests, deepest dependants first. +const DISTRIBUTIONS = ['distributions/file/file/', 'distributions/deb/apt/']; +const REPOSITORIES = ['repositories/file/file/', 'repositories/deb/apt/']; + +function url(pathOrHref: string): string { + return pathOrHref.startsWith('/') ? pathOrHref : `${API_BASE}${pathOrHref}`; +} + +async function fail(what: string, response: APIResponse): Promise { + const body = (await response.text()).slice(0, 300); + throw new Error(`${what}: ${response.status()} ${body}`); +} + +// The objects of one spec file on one deployment. Create them through this +// class, call cleanUp() in afterAll. +export class TestObjects { + private readonly registered: string[] = []; + + private constructor( + readonly api: APIRequestContext, + readonly scope: string, + ) {} + + static async open(scope: string): Promise { + return new TestObjects(await apiContext(), scope); + } + + // zz-e2e--- + name(suffix: string): string { + return `${this.prefix}${suffix}`; + } + + get prefix(): string { + return `zz-e2e-${runId()}-${this.scope}-`; + } + + // Every object of a list endpoint matching the filters, all pages. + async list(path: string, params: Record = {}) { + const results: PulpObject[] = []; + for (let offset = 0; ; offset += 100) { + const response = await this.api.get(url(path), { + params: { ...params, limit: '100', offset: String(offset) }, + }); + if (!response.ok()) { + await fail(`GET ${path}`, response); + } + const page = (await response.json()) as { + results: PulpObject[]; + next: string | null; + }; + results.push(...page.results); + if (!page.next) { + return results; + } + } + } + + async get(href: string): Promise { + const response = await this.api.get(url(href)); + if (!response.ok()) { + await fail(`GET ${href}`, response); + } + return (await response.json()) as PulpObject; + } + + // Waits for a task to finish and returns it; a failed or canceled task throws. + async waitForTask(taskHref: string, timeoutMs = 120_000): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const task = (await this.get(taskHref)) as unknown as Task; + if (task.state === 'completed') { + return task; + } + if (task.state === 'failed' || task.state === 'canceled') { + throw new Error( + `task ${taskHref} ${task.state}: ${task.error?.description ?? ''}`, + ); + } + if (Date.now() > deadline) { + throw new Error( + `task ${taskHref} still ${task.state} after ${timeoutMs} ms`, + ); + } + await new Promise((resolve) => setTimeout(resolve, 400)); + } + } + + // Creates an object, waits for the task when the server answers with one, + // and registers the object for clean-up. A name that does not carry this + // scope's prefix is refused, so a typo can never write a foreign object. + async create(path: string, body: Record) { + if ( + typeof body['name'] === 'string' && + !body['name'].startsWith(this.prefix) + ) { + throw new Error( + `refusing to create "${body['name']}": not under ${this.prefix}`, + ); + } + + const response = await this.api.post(url(path), { data: body }); + if (!response.ok()) { + await fail(`POST ${path}`, response); + } + const answer = (await response.json()) as PulpObject & { task?: string }; + if (response.status() === 201) { + this.registered.push(answer.pulp_href); + return answer; + } + + const task = await this.waitForTask(answer.task as string); + const href = task.created_resources[0] as string; + this.registered.push(href); + return this.get(href); + } + + // Changes an object and waits for the task the server starts. + async patch(href: string, body: Record) { + const response = await this.api.patch(url(href), { data: body }); + if (!response.ok()) { + await fail(`PATCH ${href}`, response); + } + const answer = (await response.json()) as { task?: string }; + if (answer.task) { + await this.waitForTask(answer.task); + } + return this.get(href); + } + + // Deletes an object and waits for the task; an object that is already gone + // is fine. A named object outside this scope is refused. + async remove(href: string): Promise { + const current = await this.api.get(url(href)); + if (current.status() === 404) { + return; + } + if (!current.ok()) { + await fail(`GET ${href}`, current); + } + const { name } = (await current.json()) as PulpObject; + if (typeof name === 'string' && !name.startsWith(this.prefix)) { + throw new Error(`refusing to delete "${name}": not under ${this.prefix}`); + } + + const response = await this.api.delete(url(href)); + if (response.status() === 404) { + return; + } + if (!response.ok()) { + await fail(`DELETE ${href}`, response); + } + if (response.status() === 202) { + await this.waitForTask( + ((await response.json()) as { task: string }).task, + ); + } + } + + // Registers an object that something else created, for example the UI. + track(href: string): void { + this.registered.push(href); + } + + // Objects of this scope that still exist, by endpoint. + async leftovers(): Promise { + const found: string[] = []; + for (const path of [...DISTRIBUTIONS, ...REPOSITORIES]) { + const objects = await this.list(path, { + name__startswith: this.prefix, + fields: 'pulp_href,name', + }); + found.push(...objects.map((object) => `${path}${object.name}`)); + } + return found; + } + + // Deletes everything this scope created: registered objects in reverse order + // of creation (distributions, then publications, then repositories), then + // anything of the scope that is still listed, for objects created through + // the UI. Throws when something is left; always closes the API context. + async cleanUp(): Promise { + try { + const rank = (href: string) => + href.includes('/distributions/') + ? 0 + : href.includes('/publications/') + ? 1 + : href.includes('/repositories/') + ? 2 + : 3; + const ordered = [...this.registered] + .reverse() + .sort((a, b) => rank(a) - rank(b)); + this.registered.length = 0; + + for (const group of [0, 1, 2, 3]) { + await Promise.all( + ordered + .filter((href) => rank(href) === group) + .map((href) => this.remove(href)), + ); + } + + for (const endpoints of [DISTRIBUTIONS, REPOSITORIES]) { + for (const path of endpoints) { + const objects = await this.list(path, { + name__startswith: this.prefix, + fields: 'pulp_href,name', + }); + await Promise.all( + objects.map(({ pulp_href }) => this.remove(pulp_href)), + ); + } + } + + const left = await this.leftovers(); + if (left.length) { + throw new Error( + `objects left on the server after clean-up: ${left.join(', ')}`, + ); + } + } finally { + await this.api.dispose(); + } + } +} From c54b24b280bdaa8099629723b2e03b555d283e71 Mon Sep 17 00:00:00 2001 From: Danilo Lakovic Date: Thu, 29 Oct 2026 19:23:22 +0100 Subject: [PATCH 2/3] Add browser tests of the repository save rules and of server-side distribution changes - create.spec.ts: edit-save sends no POST, taken paths and names and reserved prefixes are refused before any POST, default base path and preview match the created distribution, a double click sends one repository POST - server-change.spec.ts: a distribution deleted or re-bound on the server stays on an open repository page and is gone after a reload --- e2e/remote/create.spec.ts | 215 +++++++++++++++++++++++++++++++ e2e/remote/server-change.spec.ts | 133 +++++++++++++++++++ 2 files changed, 348 insertions(+) create mode 100644 e2e/remote/create.spec.ts create mode 100644 e2e/remote/server-change.spec.ts diff --git a/e2e/remote/create.spec.ts b/e2e/remote/create.spec.ts new file mode 100644 index 00000000..547132ed --- /dev/null +++ b/e2e/remote/create.spec.ts @@ -0,0 +1,215 @@ +import { type Page, expect, test } from '@playwright/test'; +import { type PulpObject, TestObjects } from '../support/api'; +import { title } from '../support/sign-in'; + +// The save rules of the repository form, run against a real server with +// objects of this file's own prefix. Plugin: file, except where noted. + +const REPOSITORIES = 'repositories/file/file/'; +const DISTRIBUTIONS = 'distributions/file/file/'; + +let objects: TestObjects; + +test.beforeAll(async () => { + objects = await TestObjects.open('create'); +}); + +test.afterAll(async () => { + test.setTimeout(180_000); + await objects.cleanUp(); +}); + +// The paths of every POST to the API that the page sends. +function recordPosts(page: Page): string[] { + const posts: string[] = []; + page.on('request', (request) => { + const { pathname } = new URL(request.url()); + if (request.method() === 'POST' && pathname.startsWith('/pulp/api/')) { + posts.push(pathname); + } + }); + return posts; +} + +const save = (page: Page) => page.getByRole('button', { name: 'Save' }); + +async function openCreateForm(page: Page, name: string) { + await page.goto('file/repositories/edit/_'); + await page.locator('#name').fill(name); + await expect(page.locator('#create_distribution')).not.toBeChecked(); +} + +// Ticks the box and types a base path. +async function offerDistribution(page: Page, basePath?: string) { + await page.locator('#create_distribution').check(); + if (basePath !== undefined) { + await page.locator('#base_path').fill(basePath); + } +} + +test('saving an edit creates no distribution and sends no POST', async ({ + page, +}) => { + const repository = await objects.create(REPOSITORIES, { + name: objects.name('edit'), + }); + const distributions = await Promise.all( + ['one', 'two'].map((suffix) => + objects.create(DISTRIBUTIONS, { + name: objects.name(`edit-${suffix}`), + base_path: objects.name(`edit-${suffix}`), + repository: repository.pulp_href, + }), + ), + ); + + const posts = recordPosts(page); + await page.goto(`file/repositories/edit/${repository.name}`); + // the form lists what serves the repository once it has loaded it + for (const distribution of distributions) { + await expect(page.getByText(distribution.name as string)).toBeVisible(); + } + + await page.locator('#description').fill('changed by the browser test'); + await save(page).click(); + await page.waitForURL(/repositories\/detail\//); + await expect(title(page, repository.name as string)).toBeVisible(); + + expect(posts, 'POST requests of the page').toEqual([]); + expect( + await objects.list(DISTRIBUTIONS, { + name__startswith: objects.prefix, + fields: 'pulp_href', + }), + 'distributions of this file after the save', + ).toHaveLength(2); + expect( + ((await objects.get(repository.pulp_href)) as PulpObject)['description'], + 'the save went through', + ).toBe('changed by the browser test'); +}); + +test('a base path or name that is taken is refused before any POST', async ({ + page, +}) => { + const taken = await objects.create(DISTRIBUTIONS, { + name: objects.name('taken'), + base_path: `${objects.name('nest')}/inner`, + }); + const takenPath = taken['base_path'] as string; + const posts = recordPosts(page); + + // the same path, a path below it and a path above it all overlap + for (const [suffix, basePath] of [ + ['same', takenPath], + ['below', `${takenPath}/sub`], + ['above', objects.name('nest')], + ] as const) { + await openCreateForm(page, objects.name(`path-${suffix}`)); + await offerDistribution(page, basePath); + await save(page).click(); + await expect( + page.getByText(/is already used by or overlaps the distribution/), + `base path ${suffix}`, + ).toBeVisible(); + } + + // a name that a distribution already has + await openCreateForm(page, taken.name as string); + await offerDistribution(page, objects.name('free')); + await save(page).click(); + await expect( + page.getByText(/A distribution named .* already exists/), + ).toBeVisible(); + + expect(posts, 'POST requests of the page').toEqual([]); + // none of the refused saves left a repository behind + const filters: Record[] = [ + { name__startswith: objects.name('path-') }, + { name: taken.name as string }, + ]; + for (const filter of filters) { + expect( + await objects.list(REPOSITORIES, { ...filter, fields: 'pulp_href' }), + `repositories matching ${JSON.stringify(filter)}`, + ).toHaveLength(0); + } +}); + +test('a base path under a prefix the deployment reserves is refused', async ({ + page, +}) => { + // the list the deployment serves to the UI, not a copy of it + const response = await objects.api.get('/pulp-ui-config.json'); + const prefixes: string[] = + ((await response.json()) as { RESERVED_BASE_PATH_PREFIXES?: string[] }) + .RESERVED_BASE_PATH_PREFIXES ?? []; + test.skip(!prefixes.length, 'the deployment reserves no base path prefix'); + + const posts = recordPosts(page); + await openCreateForm(page, objects.name('reserved')); + for (const prefix of prefixes) { + await offerDistribution( + page, + `${prefix.replace(/\/+$/, '')}/${objects.name('reserved')}`, + ); + await expect( + page.getByText(/are reserved on this server/), + `prefix ${prefix}`, + ).toBeVisible(); + await expect(save(page)).toBeDisabled(); + } + + // a path a seeded installation already serves is refused as well + const [seeded] = await objects.list('distributions/deb/apt/', { + name__startswith: 'zz-synth-', + fields: 'base_path', + }); + await page.locator('#base_path').fill(seeded?.['base_path'] as string); + await expect(save(page)).toBeDisabled(); + + expect(posts, 'POST requests of the page').toEqual([]); +}); + +test('the default base path is the name and the preview is the URL the server then reports', async ({ + page, +}) => { + const name = objects.name('make'); + await openCreateForm(page, name); + await offerDistribution(page); + + await expect(page.locator('#base_path')).toHaveValue(name); + const preview = page.getByText(/URL \(preview\): /); + await expect(preview).toBeVisible(); + const previewed = (await preview.innerText()).replace( + /^URL \(preview\):\s*/, + '', + ); + + await save(page).click(); + await page.waitForURL(/repositories\/detail\//); + + const [created] = await objects.list(DISTRIBUTIONS, { + name, + fields: 'pulp_href,name,base_path,base_url,repository', + }); + expect(created, 'the distribution exists').toBeDefined(); + objects.track((created as PulpObject).pulp_href); + expect(created?.['base_path']).toBe(name); + expect(created?.['base_url']).toBe(previewed); +}); + +test('a double click on Save sends one repository POST', async ({ page }) => { + const name = objects.name('double'); + const posts = recordPosts(page); + await openCreateForm(page, name); + + await save(page).dblclick(); + await page.waitForURL(/repositories\/detail\//); + + expect( + posts.filter((path) => path.endsWith(`/${REPOSITORIES}`)), + 'POST requests to the repository endpoint', + ).toHaveLength(1); + expect(await objects.list(REPOSITORIES, { name })).toHaveLength(1); +}); diff --git a/e2e/remote/server-change.spec.ts b/e2e/remote/server-change.spec.ts new file mode 100644 index 00000000..006c4af6 --- /dev/null +++ b/e2e/remote/server-change.spec.ts @@ -0,0 +1,133 @@ +import { expect, test } from '@playwright/test'; +import { type PulpObject, TestObjects } from '../support/api'; +import { title } from '../support/sign-in'; + +// A distribution that is deleted or re-bound on the server while a repository +// page is open is still shown by that page (nothing is fetched behind the +// user's back) and is gone after a reload, from the details, the Distributions +// tab and the edit form alike. + +const NOT_SERVED = 'Not served: no publication'; + +let objects: TestObjects; + +test.beforeAll(async () => { + objects = await TestObjects.open('chg'); +}); + +test.afterAll(async () => { + test.setTimeout(180_000); + await objects.cleanUp(); +}); + +test('a distribution deleted or re-bound on the server leaves the page after a reload', async ({ + page, +}) => { + const repository = await objects.create('repositories/deb/apt/', { + name: objects.name('a'), + }); + const other = await objects.create('repositories/deb/apt/', { + name: objects.name('b'), + }); + const publication = await objects.create('publications/deb/apt/', { + repository_version: repository['latest_version_href'], + }); + const published = await objects.create('distributions/deb/apt/', { + name: objects.name('published'), + base_path: objects.name('published'), + publication: publication.pulp_href, + }); + const bound = await objects.create('distributions/deb/apt/', { + name: objects.name('bound'), + base_path: objects.name('bound'), + repository: repository.pulp_href, + }); + + const detailPath = (name: string) => `deb/repositories/detail/${name}`; + const details = page.locator('.pf-v5-c-description-list__group', { + hasText: 'Repository URL', + }); + const row = (distribution: PulpObject) => + page.getByRole('row').filter({ + has: page.getByRole('gridcell', { + name: distribution.name as string, + exact: true, + }), + }); + + // 1. both distributions are shown, in Details and in the Distributions tab + await page.goto(detailPath(repository.name as string)); + await expect(title(page, repository.name as string)).toBeVisible(); + for (const distribution of [published, bound]) { + await expect( + details.getByText(distribution['base_url'] as string, { exact: true }), + ).toBeVisible(); + } + await page.goto(`${detailPath(repository.name as string)}?tab=distributions`); + await expect(row(published)).toHaveCount(1); + await expect(row(bound)).toHaveCount(1); + await page.goto(`deb/repositories/edit/${repository.name}`); + await expect(page.getByText(published.name as string)).toBeVisible(); + + // 2. the page stays open on the repository while the server changes + await page.goto(detailPath(repository.name as string)); + for (const distribution of [published, bound]) { + await expect( + details.getByText(distribution['base_url'] as string, { exact: true }), + ).toBeVisible(); + } + const requests: string[] = []; + page.on('request', (request) => { + if (new URL(request.url()).pathname.startsWith('/pulp/api/')) { + requests.push(`${request.method()} ${request.url()}`); + } + }); + + await objects.remove(published.pulp_href); + await objects.patch(bound.pulp_href, { repository: other.pulp_href }); + + // 3. nothing refetches behind the user's back: the old state stays + await page.waitForTimeout(2500); + for (const distribution of [published, bound]) { + await expect( + details.getByText(distribution['base_url'] as string, { exact: true }), + ).toBeVisible(); + } + expect(requests, 'API requests while the page was left open').toEqual([]); + + // 4. after a reload both are gone from the details ... + await page.reload(); + await expect(title(page, repository.name as string)).toBeVisible(); + await expect(details).toContainText('None'); + for (const distribution of [published, bound]) { + await expect( + page.getByText(distribution['base_url'] as string), + ).toHaveCount(0); + } + + // ... from the Distributions tab ... + await page.goto(`${detailPath(repository.name as string)}?tab=distributions`); + await expect( + page.getByText('No distribution serves this repository'), + ).toBeVisible(); + await expect(row(published)).toHaveCount(0); + await expect(row(bound)).toHaveCount(0); + + // ... and from the edit form + await page.goto(`deb/repositories/edit/${repository.name}`); + const group = page.locator('.pf-v5-c-form__group', { + has: page.locator('label[for=distributions]'), + }); + await expect(group).toContainText('None'); + for (const distribution of [published, bound]) { + await expect(group).not.toContainText(distribution.name as string); + } + + // the re-bound distribution now belongs to the other repository's page, + // with the URL the server reports for it, and is not served there + await page.goto(`${detailPath(other.name as string)}`); + await expect( + details.getByText(bound['base_url'] as string, { exact: true }), + ).toBeVisible(); + await expect(details.getByText(NOT_SERVED)).toHaveCount(1); +}); From 5fd2407eb9229042b66134ec23b41b634d1f59f0 Mon Sep 17 00:00:00 2001 From: Danilo Lakovic Date: Thu, 29 Oct 2026 19:33:06 +0100 Subject: [PATCH 3/3] Add browser tests that open every list page for the admin and the limited account - list-pages.ts holds every list route with its plugin and title; pages of a plugin the server does not run are skipped with the plugin named - the admin tests require the title, no error boundary, no error alert and no uncaught error; the limited account must show its title or an error alert, never the error boundary - pages that fail because of defects of the deployed application are marked test.fixme with the reason --- e2e/remote/list-pages.spec.ts | 26 ++++ e2e/remote/list-pages.ts | 181 +++++++++++++++++++++++++++ e2e/remote/list-pages.viewer.spec.ts | 31 +++++ 3 files changed, 238 insertions(+) create mode 100644 e2e/remote/list-pages.spec.ts create mode 100644 e2e/remote/list-pages.ts create mode 100644 e2e/remote/list-pages.viewer.spec.ts diff --git a/e2e/remote/list-pages.spec.ts b/e2e/remote/list-pages.spec.ts new file mode 100644 index 00000000..c6431ca8 --- /dev/null +++ b/e2e/remote/list-pages.spec.ts @@ -0,0 +1,26 @@ +import { expect, test } from '@playwright/test'; +import { title } from '../support/sign-in'; +import { listPages, missingPlugin } from './list-pages'; + +// 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 and without an uncaught error in the page. + +for (const entry of listPages) { + test(`${entry.name} (${entry.path}) opens`, async ({ page }) => { + const absent = await missingPlugin(entry); + test.skip(absent !== null, absent ?? ''); + test.fixme(!!entry.fixme?.admin, entry.fixme?.admin ?? ''); + + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + + await page.goto(entry.path); + await expect(title(page, entry.title)).toBeVisible(); + await page.waitForLoadState('networkidle'); + + await expect(page.getByText('The stack trace is:')).toHaveCount(0); + await expect(page.locator('.pf-v5-c-alert.pf-m-danger')).toHaveCount(0); + expect(errors, 'uncaught errors of the page').toEqual([]); + }); +} diff --git a/e2e/remote/list-pages.ts b/e2e/remote/list-pages.ts new file mode 100644 index 00000000..ae4195c9 --- /dev/null +++ b/e2e/remote/list-pages.ts @@ -0,0 +1,181 @@ +import { apiContext } from '../support/api'; + +// 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 +// the title the page shows. The paths are relative to the UI root. A page whose +// plugin is not installed is skipped with the plugin named. +// +// `fixme` names, per account, a failure that comes from a defect of the +// deployed application and not of the test: the test is then marked fixme with +// that reason instead of being hidden or deleted. + +export interface ListPage { + name: string; + path: string; + plugin: string | null; + title: string; + fixme?: { admin?: string; viewer?: string }; +} + +export const listPages: ListPage[] = [ + { name: 'Status', path: 'status', plugin: null, title: 'Status' }, + { + name: 'Task management', + path: 'tasks', + plugin: null, + title: 'Task management', + }, + { name: 'Groups', path: 'groups', plugin: null, title: 'Groups' }, + { name: 'Roles', path: 'roles', plugin: null, title: 'Roles' }, + { name: 'Users', path: 'users', plugin: null, title: 'Users' }, + { + name: 'Signature keys', + path: 'signature-keys', + plugin: null, + title: 'Signature keys', + }, + { + name: 'About project', + path: 'about', + plugin: null, + title: 'About project', + }, + { + name: 'Search', + path: 'search', + plugin: null, + title: 'Search', + fixme: { + 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.', + }, + }, + { + name: 'Ansible repositories', + path: 'ansible/repositories', + plugin: 'ansible', + title: 'Repositories', + }, + { + name: 'Ansible remotes', + path: 'ansible/remotes', + plugin: 'ansible', + title: 'Remotes', + }, + { + name: 'Ansible collections', + path: 'ansible/collections', + plugin: 'ansible', + title: 'Collections', + fixme: { + admin: + 'The page crashes into the error boundary: it reads the namespace of a collection that the server does not return.', + }, + }, + { + name: 'Ansible namespaces', + path: 'ansible/namespaces', + plugin: 'ansible', + title: 'Namespaces', + fixme: { + admin: + 'The list cannot be loaded: it calls an endpoint that exists only on galaxy_ng and shows a 404 error alert.', + }, + }, + { + name: 'Ansible my namespaces', + path: 'ansible/my-namespaces', + plugin: 'ansible', + title: 'Namespaces', + fixme: { + admin: + 'The list cannot be loaded: it calls an endpoint that exists only on galaxy_ng and shows a 404 error alert.', + }, + }, + { + name: 'Ansible approvals', + path: 'ansible/approvals', + plugin: 'ansible', + title: 'Approvals', + fixme: { + admin: + 'The page is an empty under-construction placeholder with no title.', + viewer: + 'The page is an empty under-construction placeholder with no title.', + }, + }, + { + name: 'Ansible imports', + path: 'ansible/my-imports', + plugin: 'ansible', + title: 'Imports', + fixme: { + admin: + 'The page loads namespaces from an endpoint that exists only on galaxy_ng and shows a 404 error alert.', + }, + }, + { + name: 'Container repositories', + path: 'container/containers', + plugin: 'container', + title: 'Containers', + }, + { + name: 'Container remote registries', + path: 'container/remotes', + plugin: 'container', + title: 'Remote registries', + }, + { + name: 'Deb repositories', + path: 'deb/repositories', + plugin: 'deb', + title: 'Repositories', + }, + { name: 'Deb remotes', path: 'deb/remotes', plugin: 'deb', title: 'Remotes' }, + { + name: 'File repositories', + path: 'file/repositories', + plugin: 'file', + title: 'Repositories', + }, + { + name: 'File remotes', + path: 'file/remotes', + plugin: 'file', + title: 'Remotes', + }, + { name: 'RPM packages', path: 'rpm/rpms', plugin: 'rpm', title: 'Packages' }, +]; + +// The components the server reports in status/ versions, read once per +// worker. +let installed: Promise> | undefined; + +export function installedPlugins(): Promise> { + installed ??= (async () => { + const api = await apiContext(); + try { + const response = await api.get('/pulp/api/v3/status/'); + const { versions } = (await response.json()) as { + versions: { component: string }[]; + }; + return new Set(versions.map(({ component }) => component)); + } finally { + await api.dispose(); + } + })(); + return installed; +} + +// Skip reason for a page whose plugin the server does not run, or null. +export async function missingPlugin(entry: ListPage): Promise { + if (!entry.plugin) { + return null; + } + return (await installedPlugins()).has(entry.plugin) + ? null + : `the ${entry.plugin} plugin is not installed on this server`; +} diff --git a/e2e/remote/list-pages.viewer.spec.ts b/e2e/remote/list-pages.viewer.spec.ts new file mode 100644 index 00000000..3f446e27 --- /dev/null +++ b/e2e/remote/list-pages.viewer.spec.ts @@ -0,0 +1,31 @@ +import { expect, test } from '@playwright/test'; +import { title } from '../support/sign-in'; +import { listPages, missingPlugin } from './list-pages'; + +// The same pages as the limited account sees them. Whatever it may not read is +// refused by the server, and the page says so with an error alert; what it must +// never meet is the error boundary or an uncaught error. + +for (const entry of listPages) { + test(`${entry.name} (${entry.path}) shows its title or an error alert`, async ({ + page, + }) => { + const absent = await missingPlugin(entry); + test.skip(absent !== null, absent ?? ''); + test.fixme(!!entry.fixme?.viewer, entry.fixme?.viewer ?? ''); + + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + + await page.goto(entry.path); + await expect( + title(page, entry.title) + .or(page.locator('.pf-v5-c-alert.pf-m-danger')) + .first(), + ).toBeVisible(); + await page.waitForLoadState('networkidle'); + + await expect(page.getByText('The stack trace is:')).toHaveCount(0); + expect(errors, 'uncaught errors of the page').toEqual([]); + }); +}