diff --git a/e2e/crawl/crawl.spec.ts b/e2e/crawl/crawl.spec.ts index e0827d58..8b40dff9 100644 --- a/e2e/crawl/crawl.spec.ts +++ b/e2e/crawl/crawl.spec.ts @@ -22,10 +22,13 @@ import { crawlRoutes } from './routes'; // the accessibility findings of axe, and a screenshot. The crawl only // navigates and reads. It never clicks, types or submits anything and sends no // request of its own besides loading the pages, so it changes nothing on the -// deployment. Findings never fail a test; only a screen that cannot be crawled -// does. +// deployment. Findings never fail a test; only a screen that cannot be crawled, +// or that rendered signed out, does. const API = '/pulp/api/'; +// The one screen that is meant to be seen without a session. +const LOGIN_ROUTE = '/login'; +const USER_MENU = '[data-cy=user-dropdown]'; const SETTLE_MS = 15_000; const AXE_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']; const account = crawlAccount(); @@ -154,6 +157,7 @@ for (const route of crawlRoutes()) { const errorBoundary = (await page.getByText('The stack trace is:').count()) > 0 || (await page.getByText('Something went horribly wrong!').count()) > 0; + const signedIn = (await page.locator(USER_MENU).count()) > 0; const axe = await new AxeBuilder({ page }).withTags(AXE_TAGS).analyze(); @@ -184,6 +188,7 @@ for (const route of crawlRoutes()) { account, title: heading?.trim() || (await page.title()), errorBoundary, + signedIn, loadMs, settled, screenshot, @@ -211,5 +216,15 @@ for (const route of crawlRoutes()) { run: process.env['PULP_UI_CRAWL_RUN'] ?? '', capturedAt: new Date().toISOString(), }); + + // A screen that rendered signed out is the login page, not the screen the + // route names; a crawl of it would report a clean page that was never + // looked at. The report is written first so the screen stays on record. + if (route.pattern !== LOGIN_ROUTE) { + expect( + signedIn, + `the user menu of the ${account} account is shown on ${route.pattern}`, + ).toBe(true); + } }); } diff --git a/e2e/crawl/params.ts b/e2e/crawl/params.ts index b7f3659a..b613a63a 100644 --- a/e2e/crawl/params.ts +++ b/e2e/crawl/params.ts @@ -46,6 +46,10 @@ const NAMED_LISTS: Record = { '/file/remotes': 'remotes/file/file/', '/ansible/repositories': 'repositories/ansible/ansible/', '/ansible/remotes': 'remotes/ansible/collection/', + '/rpm/repositories': 'repositories/rpm/rpm/', + '/rpm/remotes': 'remotes/rpm/rpm/', + '/python/repositories': 'repositories/python/python/', + '/python/remotes': 'remotes/python/python/', }; function isCollectionRoute(route: CrawlRoute): boolean { diff --git a/e2e/crawl/report.ts b/e2e/crawl/report.ts index 234bee45..28223f0c 100644 --- a/e2e/crawl/report.ts +++ b/e2e/crawl/report.ts @@ -60,6 +60,10 @@ export interface ScreenReport { account: Role; title: string; errorBoundary: boolean; + // Whether the page showed the signed-in user's menu. A screen that rendered + // signed out (an expired or unusable session) shows the login page and says + // nothing about the screen it was meant to visit. + signedIn: boolean; loadMs: number; // False when the page still showed a spinner or kept the network busy at // the end of the wait. diff --git a/e2e/crawl/summarize.test.mjs b/e2e/crawl/summarize.test.mjs index 58d0d81b..26f3b141 100644 --- a/e2e/crawl/summarize.test.mjs +++ b/e2e/crawl/summarize.test.mjs @@ -35,6 +35,7 @@ function report(route, slug, theme, viewport, run, extra = {}) { account: 'admin', title: 'T', errorBoundary: false, + signedIn: true, loadMs: 5, settled: true, screenshot: `${slug}.${theme}.${viewport}.png`, diff --git a/e2e/crawl/verify-report.mjs b/e2e/crawl/verify-report.mjs index ae6945a3..d9922ae5 100644 --- a/e2e/crawl/verify-report.mjs +++ b/e2e/crawl/verify-report.mjs @@ -25,6 +25,7 @@ export const REQUIRED_FIELDS = [ 'account', 'title', 'errorBoundary', + 'signedIn', 'loadMs', 'screenshot', 'consoleErrors', diff --git a/e2e/crawl/verify-report.test.mjs b/e2e/crawl/verify-report.test.mjs index 66bd829d..29b38696 100644 --- a/e2e/crawl/verify-report.test.mjs +++ b/e2e/crawl/verify-report.test.mjs @@ -43,6 +43,7 @@ function screen(route, theme, viewport, slug) { account: 'admin', title: 'Status', errorBoundary: false, + signedIn: true, loadMs: 10, screenshot: `${slug}.${theme}.${viewport}.png`, consoleErrors: [], diff --git a/e2e/remote/list-order.spec.ts b/e2e/remote/list-order.spec.ts new file mode 100644 index 00000000..409e1596 --- /dev/null +++ b/e2e/remote/list-order.spec.ts @@ -0,0 +1,64 @@ +import { expect, test } from '@playwright/test'; +import { apiContext } from '../support/api'; +import { title } from '../support/sign-in'; + +// A list opens ordered by name, the way the server orders it, so the first +// repository a person sees is the alphabetically first one, and a form says +// which field it cannot be saved without. + +test('the file repository list opens with the repositories in the order the server sorts them by name', async ({ + page, +}) => { + const api = await apiContext(); + const names = async (params: Record) => { + const response = await api.get('/pulp/api/v3/repositories/file/file/', { + params: { ...params, ordering: 'name', fields: 'name' }, + }); + expect(response.ok(), 'the API list').toBe(true); + return ( + (await response.json()) as { results: { name: string }[] } + ).results.map(({ name }) => name); + }; + + try { + await page.goto('file/repositories'); + await expect(title(page, 'Repositories')).toBeVisible(); + const links = page.locator('tbody tr td:first-child a'); + await expect(links.first()).toBeVisible(); + + // Other specs create repositories while this one runs, so the rows shown + // are compared with the server's order of exactly those names, and the + // top row with the server's first name. + const shown = await links.allInnerTexts(); + expect(shown.length, 'rows shown').toBeGreaterThan(1); + expect(shown, 'the rows, top to bottom').toEqual( + await names({ name__in: shown.join(','), limit: '100' }), + ); + expect(shown[0], 'the top row').toBe((await names({ limit: '1' }))[0]); + } finally { + await api.dispose(); + } +}); + +test('the file repository add form marks Name as required and keeps Save disabled while it is empty', async ({ + page, +}) => { + await page.goto('file/repositories/edit/_'); + await expect(title(page, 'Add new repository')).toBeVisible(); + + const nameGroup = page.locator('.pf-v5-c-form__group', { + has: page.locator('#name'), + }); + await expect( + nameGroup.locator('.pf-v5-c-form__label-required'), + 'the required marker of Name', + ).toBeVisible(); + + const save = page.getByRole('button', { name: 'Save' }); + await expect(save).toBeDisabled(); + + await page.locator('#name').fill('zz-e2e-not-saved'); + await expect(save).toBeEnabled(); + await page.locator('#name').fill(''); + await expect(save).toBeDisabled(); +}); diff --git a/e2e/remote/list-pages.spec.ts b/e2e/remote/list-pages.spec.ts index c6431ca8..0398baf0 100644 --- a/e2e/remote/list-pages.spec.ts +++ b/e2e/remote/list-pages.spec.ts @@ -1,26 +1,44 @@ import { expect, test } from '@playwright/test'; import { title } from '../support/sign-in'; -import { listPages, missingPlugin } from './list-pages'; +import { + ALLOW_MISSING_PLUGINS_VARIABLE, + failureShown, + listPages, + missingExpectedPlugins, + missingPlugin, + missingPluginsAllowed, + watchPage, +} 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. +// alert or error state and without an uncaught error in the page. + +test.beforeAll(async () => { + if (missingPluginsAllowed()) { + return; + } + expect( + await missingExpectedPlugins(), + `plugins the server does not run (set ${ALLOW_MISSING_PLUGINS_VARIABLE} to skip their pages)`, + ).toEqual([]); +}); 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 ?? ''); + // runs anyway: the run reports it the day the page works + test.fail(!!entry.knownDefect?.admin, entry.knownDefect?.admin ?? ''); - const errors: string[] = []; - page.on('pageerror', (error) => errors.push(error.message)); + const watch = watchPage(page); 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([]); + await expect(failureShown(page)).toHaveCount(0); + expect(watch.errors, 'uncaught errors of the page').toEqual([]); }); } diff --git a/e2e/remote/list-pages.ts b/e2e/remote/list-pages.ts index ae4195c9..79c78bc1 100644 --- a/e2e/remote/list-pages.ts +++ b/e2e/remote/list-pages.ts @@ -1,20 +1,23 @@ +import { type Page } from '@playwright/test'; 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. +// the title the page shows. The paths are relative to the UI root. // -// `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. +// `knownDefect` names, per account, a failure that comes from a defect of the +// deployed application and not of the test: the test is then marked as +// 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. export interface ListPage { name: string; path: string; plugin: string | null; title: string; - fixme?: { admin?: string; viewer?: string }; + viewerRefused?: boolean; + knownDefect?: { admin?: string; viewer?: string }; } export const listPages: ListPage[] = [ @@ -26,13 +29,26 @@ export const listPages: ListPage[] = [ 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: 'Roles', + path: 'roles', + plugin: null, + title: 'Roles', + viewerRefused: true, + }, + { + name: 'Users', + path: 'users', + plugin: null, + title: 'Users', + viewerRefused: true, + }, { name: 'Signature keys', path: 'signature-keys', plugin: null, title: 'Signature keys', + viewerRefused: true, }, { name: 'About project', @@ -45,7 +61,7 @@ export const listPages: ListPage[] = [ path: 'search', plugin: null, title: 'Search', - fixme: { + knownDefect: { admin: 'The page crashes into the error boundary: it builds a link without the repository name it needs.', viewer: @@ -69,7 +85,7 @@ export const listPages: ListPage[] = [ path: 'ansible/collections', plugin: 'ansible', title: 'Collections', - fixme: { + knownDefect: { admin: 'The page crashes into the error boundary: it reads the namespace of a collection that the server does not return.', }, @@ -79,9 +95,11 @@ export const listPages: ListPage[] = [ path: 'ansible/namespaces', plugin: 'ansible', title: 'Namespaces', - fixme: { + 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.', }, }, { @@ -89,9 +107,11 @@ export const listPages: ListPage[] = [ path: 'ansible/my-namespaces', plugin: 'ansible', title: 'Namespaces', - fixme: { + 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.', }, }, { @@ -99,7 +119,7 @@ export const listPages: ListPage[] = [ path: 'ansible/approvals', plugin: 'ansible', title: 'Approvals', - fixme: { + knownDefect: { admin: 'The page is an empty under-construction placeholder with no title.', viewer: @@ -111,9 +131,11 @@ export const listPages: ListPage[] = [ path: 'ansible/my-imports', plugin: 'ansible', title: 'Imports', - fixme: { + 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.', }, }, { @@ -147,6 +169,31 @@ export const listPages: ListPage[] = [ plugin: 'file', title: 'Remotes', }, + { + name: 'Python repositories', + path: 'python/repositories', + plugin: 'python', + title: 'Repositories', + }, + { + name: 'Python remotes', + path: 'python/remotes', + plugin: 'python', + title: 'Remotes', + }, + { + name: 'Python packages', + path: 'python/packages', + plugin: 'python', + title: 'Packages', + }, + { + name: 'RPM repositories', + path: 'rpm/repositories', + plugin: 'rpm', + title: 'Repositories', + }, + { name: 'RPM remotes', path: 'rpm/remotes', plugin: 'rpm', title: 'Remotes' }, { name: 'RPM packages', path: 'rpm/rpms', plugin: 'rpm', title: 'Packages' }, ]; @@ -170,12 +217,63 @@ export function installedPlugins(): Promise> { return installed; } -// Skip reason for a page whose plugin the server does not run, or null. +// The plugins a deployment of this application is expected to run. A server +// without one of them is a broken deployment, not a reason to skip its pages. +export const EXPECTED_PLUGINS = ['file', 'deb', 'rpm', 'python', 'ansible']; + +// Lets a run on a deployment with fewer plugins skip their pages instead of +// failing; unset, a missing expected plugin fails the run. +export const ALLOW_MISSING_PLUGINS_VARIABLE = + 'PULP_UI_E2E_ALLOW_MISSING_PLUGINS'; + +export function missingPluginsAllowed(): boolean { + return !!process.env[ALLOW_MISSING_PLUGINS_VARIABLE]; +} + +// The expected plugins the server does not report. +export async function missingExpectedPlugins(): Promise { + const present = await installedPlugins(); + 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. export async function missingPlugin(entry: ListPage): Promise { if (!entry.plugin) { return null; } + if (EXPECTED_PLUGINS.includes(entry.plugin) && !missingPluginsAllowed()) { + return null; + } return (await installedPlugins()).has(entry.plugin) ? null : `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. +export interface PageWatch { + errors: string[]; + failed: { url: string; status: number }[]; +} + +export function watchPage(page: Page): PageWatch { + const watch: PageWatch = { errors: [], failed: [] }; + page.on('pageerror', (error) => watch.errors.push(error.message)); + page.on('response', (response) => { + const { pathname } = new URL(response.url()); + if (pathname.startsWith('/pulp/api/') && response.status() >= 400) { + watch.failed.push({ url: pathname, status: response.status() }); + } + }); + return watch; +} + +// The ways a list page says that loading failed: an error alert, or the error +// state with its Retry button. +export function failureShown(page: Page) { + return page + .locator('.pf-v5-c-alert.pf-m-danger') + .or(page.getByRole('button', { name: 'Retry', exact: true })); +} diff --git a/e2e/remote/list-pages.viewer.spec.ts b/e2e/remote/list-pages.viewer.spec.ts index 3f446e27..6eea18b2 100644 --- a/e2e/remote/list-pages.viewer.spec.ts +++ b/e2e/remote/list-pages.viewer.spec.ts @@ -1,31 +1,57 @@ import { expect, test } from '@playwright/test'; import { title } from '../support/sign-in'; -import { listPages, missingPlugin } from './list-pages'; +import { + failureShown, + listPages, + missingPlugin, + watchPage, +} 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. +// The same pages as the limited account sees them. A page its roles allow +// shows the title with no alert and no error state. A page they do not allow +// 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. for (const entry of listPages) { - test(`${entry.name} (${entry.path}) shows its title or an error alert`, async ({ - page, - }) => { + const how = entry.viewerRefused + ? 'is refused with 403 or shows its title' + : 'shows its title without an error'; + test(`${entry.name} (${entry.path}) ${how}`, async ({ page }) => { const absent = await missingPlugin(entry); test.skip(absent !== null, absent ?? ''); - test.fixme(!!entry.fixme?.viewer, entry.fixme?.viewer ?? ''); + test.fail(!!entry.knownDefect?.viewer, entry.knownDefect?.viewer ?? ''); - const errors: string[] = []; - page.on('pageerror', (error) => errors.push(error.message)); + const watch = watchPage(page); await page.goto(entry.path); - await expect( - title(page, entry.title) - .or(page.locator('.pf-v5-c-alert.pf-m-danger')) - .first(), - ).toBeVisible(); + if (entry.viewerRefused) { + await expect( + title(page, entry.title).or(failureShown(page)).first(), + ).toBeVisible(); + } else { + await expect(title(page, entry.title)).toBeVisible(); + } await page.waitForLoadState('networkidle'); await expect(page.getByText('The stack trace is:')).toHaveCount(0); - expect(errors, 'uncaught errors of the page').toEqual([]); + expect(watch.errors, 'uncaught errors of the page').toEqual([]); + + if (!entry.viewerRefused) { + await expect(failureShown(page)).toHaveCount(0); + expect(watch.failed, 'API requests of the page that failed').toEqual([]); + return; + } + + if ((await failureShown(page).count()) > 0) { + expect( + watch.failed.length, + 'the page shows an error, so a request failed', + ).toBeGreaterThan(0); + } + expect( + watch.failed.filter(({ status }) => status !== 403), + 'failed API requests of the page that are not 403', + ).toEqual([]); }); } diff --git a/e2e/remote/rpm-python.spec.ts b/e2e/remote/rpm-python.spec.ts new file mode 100644 index 00000000..7103073a --- /dev/null +++ b/e2e/remote/rpm-python.spec.ts @@ -0,0 +1,291 @@ +import { type Page, expect, test } from '@playwright/test'; +import { type PulpObject, TestObjects } from '../support/api'; +import { title } from '../support/sign-in'; + +// RPM and Python remotes and repositories, created and synced through the UI +// against a real server. Nothing outside the server is contacted: the sync +// source of each plugin is a repository of this file's own prefix that the +// server itself publishes through a distribution, and the remote points at the +// URL the server reports for that distribution. + +interface Plugin { + label: string; + // path of the plugin's screens relative to the UI root + screens: string; + repositories: string; + remotes: string; + distributions: string; + // the task name the plugin runs for a sync + syncTask: RegExp; + // builds the sync source: the object the source distribution serves + serve: ( + objects: TestObjects, + repository: PulpObject, + ) => Promise>; +} + +const rpm: Plugin = { + label: 'RPM', + screens: 'rpm', + repositories: 'repositories/rpm/rpm/', + remotes: 'remotes/rpm/rpm/', + distributions: 'distributions/rpm/rpm/', + syncTask: /rpm.*synchroni[sz]e/i, + // a distribution of a repository serves nothing until a publication exists + serve: async (objects, repository) => { + const publication = await objects.create('publications/rpm/rpm/', { + repository: repository.pulp_href, + }); + return { publication: publication.pulp_href }; + }, +}; + +const python: Plugin = { + label: 'Python', + screens: 'python', + repositories: 'repositories/python/python/', + remotes: 'remotes/python/python/', + distributions: 'distributions/python/pypi/', + syncTask: /python.*sync/i, + // a Python distribution serves the latest version of its repository + serve: async (_objects, repository) => ({ repository: repository.pulp_href }), +}; + +const NOT_SERVED = 'Not served: no publication'; + +// The message of a failed task that comes from the content source being out of +// reach for the server's own workers, as opposed to a fault of the sync. +const CONNECTION_FAILURE = + /cannot connect|connection (refused|reset|timed out)|name or service not known|temporary failure in name resolution|ssl|certificate|timed? ?out/i; + +interface FinishedTask { + pulp_href: string; + name: string; + state: string; + error?: { description?: string } | null; + reserved_resources_record?: string[]; +} + +for (const plugin of [rpm, python]) { + test.describe(`${plugin.label} remotes and repositories`, () => { + test.describe.configure({ mode: 'serial' }); + + const scope = plugin.screens; + let objects: TestObjects; + let sourceUrl: string; + const remoteName = () => objects.name('remote'); + const repositoryName = () => objects.name('repo'); + + test.beforeAll(async () => { + test.setTimeout(180_000); + objects = await TestObjects.open(scope); + + const source = await objects.create(plugin.repositories, { + name: objects.name('source'), + }); + const distribution = await objects.create(plugin.distributions, { + name: objects.name('source'), + base_path: objects.name('source'), + ...(await plugin.serve(objects, source)), + }); + sourceUrl = distribution['base_url'] as string; + expect(sourceUrl, 'the URL the server reports for the source').toMatch( + /^https?:\/\//, + ); + }); + + test.afterAll(async () => { + test.setTimeout(180_000); + await objects.cleanUp(); + }); + + // Opens a list page filtered by name, the way the filter box does. + async function listed(page: Page, kind: string, name: string) { + await page.goto( + `${plugin.screens}/${kind}?name__icontains=${encodeURIComponent(name)}`, + ); + await expect( + title(page, kind === 'remotes' ? 'Remotes' : 'Repositories'), + ).toBeVisible(); + return page.getByRole('link', { name, exact: true }); + } + + async function pickRemote(page: Page, name: string) { + const remote = page.locator('[data-cy=remote]'); + await remote.locator('input').fill(name); + await page.getByRole('option', { name, exact: true }).click(); + await expect(remote.locator('input')).toHaveValue(name); + } + + // The newest sync task that holds the repository exclusively and the + // remote as a shared resource, or null. The server answers the two + // resource filters for objects that exist; a sync of another repository or + // another remote never matches. + async function newestSync(repository: PulpObject, remote: PulpObject) { + const tasks = (await objects.list('tasks/', { + exclusive_resources: repository.pulp_href, + shared_resources: remote.pulp_href, + ordering: '-pulp_created', + })) as unknown as FinishedTask[]; + return tasks.find(({ name }) => plugin.syncTask.test(name)) ?? null; + } + + test('a remote created from the form is found in the list and shows its URL', async ({ + page, + }) => { + const name = remoteName(); + await page.goto(`${plugin.screens}/remotes/edit/_`); + await page.locator('#name').fill(name); + await page.locator('#url').fill(sourceUrl); + await page.getByRole('button', { name: 'Save' }).click(); + await page.waitForURL(/remotes\/detail\//); + await expect(title(page, name)).toBeVisible(); + await expect(page.getByText(sourceUrl).first()).toBeVisible(); + + await expect(await listed(page, 'remotes', name)).toBeVisible(); + + const [created] = await objects.list(plugin.remotes, { name }); + expect(created, 'the remote on the server').toBeDefined(); + expect(created?.['url']).toBe(sourceUrl); + expect(created?.['policy'], 'a form left alone sends on_demand').toBe( + 'on_demand', + ); + }); + + test('a repository created with a remote and a distribution is found in the list and shows the URL the server reports', async ({ + page, + }) => { + const name = repositoryName(); + await page.goto(`${plugin.screens}/repositories/edit/_`); + await page.locator('#name').fill(name); + await pickRemote(page, remoteName()); + await page.locator('#create_distribution').check(); + await expect(page.locator('#base_path')).toHaveValue(name); + await page.getByRole('button', { name: 'Save' }).click(); + await page.waitForURL(/repositories\/detail\//); + await expect(title(page, name)).toBeVisible(); + + await expect(await listed(page, 'repositories', name)).toBeVisible(); + + const [distribution] = await objects.list(plugin.distributions, { + name, + }); + expect(distribution, 'the distribution of the checkbox').toBeDefined(); + const reported = distribution?.['base_url'] as string; + expect(reported).toMatch(/^https?:\/\//); + + await page.goto(`${plugin.screens}/repositories/detail/${name}`); + await expect(title(page, name)).toBeVisible(); + const urlRow = page.locator('.pf-v5-c-description-list__group', { + hasText: 'Repository URL', + }); + await expect(urlRow).toContainText(reported); + + if (plugin === rpm) { + // nothing is published yet, so the distribution answers 404 + await expect(urlRow).toContainText(NOT_SERVED); + } else { + await expect(urlRow).not.toContainText(NOT_SERVED); + } + + const [repository] = await objects.list(plugin.repositories, { name }); + expect( + repository?.['remote'], + 'the repository is bound to the remote', + ).toBe( + (await objects.list(plugin.remotes, { name: remoteName() }))[0] + ?.pulp_href, + ); + }); + + test('a sync started from the dialog runs for the repository and its remote', async ({ + page, + }) => { + test.setTimeout(240_000); + const name = repositoryName(); + const [repository] = (await objects.list(plugin.repositories, { + name, + })) as PulpObject[]; + const [remote] = (await objects.list(plugin.remotes, { + name: remoteName(), + })) as PulpObject[]; + expect( + await newestSync(repository as PulpObject, remote as PulpObject), + 'a sync before the dialog was used', + ).toBeNull(); + + await page.goto(`${plugin.screens}/repositories/detail/${name}`); + await expect(title(page, name)).toBeVisible(); + await page.getByRole('button', { name: 'Sync', exact: true }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toContainText(`Sync repository "${name}"`); + const dispatched = page.waitForResponse( + (response) => + response.request().method() === 'POST' && + new URL(response.url()).pathname.endsWith( + `${(repository as PulpObject).pulp_href}sync/`, + ), + ); + await dialog.getByRole('button', { name: 'Sync', exact: true }).click(); + expect((await dispatched).status(), 'the sync request').toBe(202); + + // the task the server dispatched for this repository and this remote + let task = null as FinishedTask | null; + await expect + .poll( + async () => + (task = await newestSync( + repository as PulpObject, + remote as PulpObject, + )), + { + message: 'a sync task of the repository and remote', + timeout: 30_000, + }, + ) + .not.toBeNull(); + + const finished = await settle(objects, (task as FinishedTask).pulp_href); + test.info().annotations.push({ + type: 'sync-outcome', + description: `${plugin.label} sync ${finished.state}`, + }); + if (finished.state === 'completed') { + return; + } + + // The server's workers must reach the server's own content URL for the + // source to be read. When they cannot, the case proves the dispatch + // above and reports the limit instead of passing for a sync it did not + // see complete. + const reason = finished.error?.description ?? ''; + test.info().annotations.push({ + type: 'environment-limit', + description: `${plugin.label} sync ${finished.state}: ${reason.slice(0, 200)}`, + }); + expect( + reason, + `the ${plugin.label} sync ended ${finished.state} for a reason other than reaching the source`, + ).toMatch(CONNECTION_FAILURE); + }); + }); +} + +// Waits until the task is no longer waiting or running and returns it. +async function settle( + objects: TestObjects, + href: string, + timeoutMs = 150_000, +): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const task = (await objects.get(href)) as unknown as FinishedTask; + if (!['waiting', 'running', 'canceling'].includes(task.state)) { + return task; + } + if (Date.now() > deadline) { + throw new Error(`task ${href} still ${task.state} after ${timeoutMs} ms`); + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } +} diff --git a/e2e/remote/session.spec.ts b/e2e/remote/session.spec.ts index eddaf807..d4288416 100644 --- a/e2e/remote/session.spec.ts +++ b/e2e/remote/session.spec.ts @@ -31,6 +31,22 @@ test('a deep link to a list page survives a reload', async ({ page }) => { test.describe('sessions of their own', () => { test.use({ storageState: signedOut }); + // A test that signed in leaves a live session on the server unless it is + // ended: end whatever session the context still holds, through the menu the + // way a person does, and check the server dropped it. + test.afterEach(async ({ page, context }) => { + const open = async () => + (await context.cookies()).some(({ name }) => name === 'sessionid'); + if (!(await open())) { + return; + } + await page.goto('status'); + await expect(page.locator(userMenu)).toBeVisible(); + await logOutThroughMenu(page); + await expect(page.locator(loginMenuItem)).toBeVisible(); + expect(await open(), 'the session cookie after the sign out').toBe(false); + }); + test('signing in through the form keeps the session out of reach of scripts and stores no credential', async ({ page, context, @@ -171,6 +187,36 @@ test.describe('sessions of their own', () => { expect(wentElsewhere).toEqual([]); }); + test('signing out on a list page with a query and signing in again lands on the same address', async ({ + page, + context, + }) => { + const { username, password } = account('admin'); + await apiLogin(context); + + const query = '?page_size=20&sort=-name&page=1'; + await page.goto(`deb/repositories${query}`); + await expect(title(page, 'Repositories')).toBeVisible(); + await expect(page).toHaveURL(new RegExp(`deb/repositories\\${query}$`)); + + await logOutThroughMenu(page); + await expect(page.locator(loginMenuItem)).toBeVisible(); + + // the application asks for the sign in again once the page needs the + // server; a reload of the same address does that + await page.goto(`deb/repositories${query}`); + await expect(page.locator(loginForm)).toBeVisible(); + await submitLoginForm(page, username, password); + + await expect(title(page, 'Repositories')).toBeVisible(); + const landed = new URL(page.url()); + expect(landed.pathname).toMatch(/\/ui\/deb\/repositories\/?$/); + expect( + Object.fromEntries(landed.searchParams), + 'the query after signing in again', + ).toEqual({ page_size: '20', sort: '-name', page: '1' }); + }); + test('logging out ends the server session', async ({ page, context }) => { await apiLogin(context); await page.goto('status'); diff --git a/e2e/remote/sync-check.spec.ts b/e2e/remote/sync-check.spec.ts new file mode 100644 index 00000000..89cec4a8 --- /dev/null +++ b/e2e/remote/sync-check.spec.ts @@ -0,0 +1,67 @@ +import { expect, test } from '@playwright/test'; +import { TestObjects } from '../support/api'; +import { title } from '../support/sign-in'; + +// The sync dialog asks the server whether a task already holds the repository +// before it offers Sync. The question must be one the server accepts and +// answers: a refused question would leave the check silently without a result. + +let objects: TestObjects; + +test.beforeAll(async () => { + objects = await TestObjects.open('syncchk'); +}); + +test.afterAll(async () => { + test.setTimeout(180_000); + await objects.cleanUp(); +}); + +test('opening the sync dialog asks the server for running tasks of the repository and leaves Sync usable when none runs', async ({ + page, +}) => { + // the address is reserved for examples and is never contacted: nothing is + // synced in this test + const remote = await objects.create('remotes/file/file/', { + name: objects.name('remote'), + url: 'https://pulp.example/zz-e2e/PULP_MANIFEST', + }); + const repository = await objects.create('repositories/file/file/', { + name: objects.name('repo'), + remote: remote.pulp_href, + }); + const name = repository.name as string; + + await page.goto(`file/repositories/detail/${name}`); + await expect(title(page, name)).toBeVisible(); + + const question = page.waitForResponse((response) => { + const url = new URL(response.url()); + return ( + url.pathname === '/pulp/api/v3/tasks/' && + url.searchParams.has('exclusive_resources') && + url.searchParams.has('state__in') + ); + }); + await page.getByRole('button', { name: 'Sync', exact: true }).click(); + const answer = await question; + + const asked = new URL(answer.url()).searchParams; + expect(asked.get('exclusive_resources'), 'the repository asked about').toBe( + repository.pulp_href, + ); + expect(asked.get('state__in'), 'the states asked about').toBe( + 'waiting,running', + ); + expect(answer.status(), 'the server accepts the question').toBe(200); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toContainText(`Sync repository "${name}"`); + await expect( + dialog.getByRole('button', { name: 'Sync', exact: true }), + ).toBeEnabled(); + await expect(dialog.getByText(/already (queued|running)/)).toHaveCount(0); + await expect(dialog.getByText(/Could not check whether a sync/)).toHaveCount( + 0, + ); +});