diff --git a/.changeset/pagination-e2e-coverage.md b/.changeset/pagination-e2e-coverage.md new file mode 100644 index 00000000..b85ee23e --- /dev/null +++ b/.changeset/pagination-e2e-coverage.md @@ -0,0 +1,4 @@ +--- +--- + +test(e2e): add Playwright coverage for Collection Docs pagination and navigation diff --git a/packages/bruno-api-docs/e2e/components/sidebar.component.ts b/packages/bruno-api-docs/e2e/components/sidebar.component.ts index 95231e5c..0f845b87 100644 --- a/packages/bruno-api-docs/e2e/components/sidebar.component.ts +++ b/packages/bruno-api-docs/e2e/components/sidebar.component.ts @@ -3,6 +3,7 @@ import { BaseComponent } from './base.component'; export class SidebarComponent extends BaseComponent { readonly items = this.page.getByTestId('sidebar-item'); + readonly active = this.items.and(this.page.locator('.active')); readonly inline = this.page.getByTestId('app-sidebar'); readonly overview = this.page.getByTestId('sidebar-overview'); readonly environments = this.page.getByTestId('sidebar-environments'); @@ -17,6 +18,10 @@ export class SidebarComponent extends BaseComponent { return this.items.filter({ hasText: name }); } + itemBySlug(slug: string): Locator { + return this.items.and(this.page.locator(`[data-slug="${slug}"]`)); + } + folderChevron(name: string): Locator { return this.item(name).first().locator('.navlink-chevron'); } diff --git a/packages/bruno-api-docs/e2e/tests/routing/routing.spec.ts b/packages/bruno-api-docs/e2e/tests/routing/routing.spec.ts index d9a8291b..4d3a7870 100644 --- a/packages/bruno-api-docs/e2e/tests/routing/routing.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/routing/routing.spec.ts @@ -1,8 +1,31 @@ import { test, expect } from '../../playwright'; +import type { Page } from '@playwright/test'; const FIXTURE = '/?fixture=folders'; const page$ = (s: string) => `${FIXTURE}#/${s}`; +const DESKTOP = { width: 1280, height: 900 }; +const TABLET = { width: 768, height: 900 }; +const MOBILE = { width: 375, height: 800 }; + +/** Overview's slug is the hash root: either no hash or exactly `#/`. */ +const expectOverviewHash = (page: Page) => + expect(page).toHaveURL(/\/\?fixture=folders(?:#\/)?$/); + +/** Both pagination links are fully inside the viewport, and the document is not wider than it. */ +const paginationFits = async (page: Page, width: number) => { + // 17px covers a classic scrollbar; the rest is subpixel rounding. + await expect + .poll(() => page.evaluate(() => document.documentElement.scrollWidth)) + .toBeLessThanOrEqual(width + 20); + + for (const testId of ['prev-link', 'next-link'] as const) { + const link = page.getByTestId(testId); + await link.scrollIntoViewIfNeeded(); + await expect(link).toBeInViewport({ ratio: 1 }); + } +}; + test.describe('page-based navigation', () => { test('deep-link to a nested request renders only that page on fresh load', async ({ page }) => { await page.goto(page$('bookings/lifecycle/create-booking')); @@ -29,7 +52,7 @@ test.describe('page-based navigation', () => { await expect(page.getByTestId('sidebar-item').filter({ hasText: 'Cancel Booking' })).toBeVisible(); }); - test('prev/next walks the hierarchy in sequence order', async ({ page }) => { + test('prev/next walks the hierarchy in sequence order', async ({ page, sidebar }) => { await page.goto(page$('bookings/lifecycle/create-booking')); const next = page.getByTestId('next-link'); @@ -40,8 +63,20 @@ test.describe('page-based navigation', () => { 'data-page-slug', 'bookings/lifecycle/confirm-booking' ); + await expect(page).toHaveURL(/#\/bookings\/lifecycle\/confirm-booking$/); await expect(page.getByTestId('prev-link')).toContainText('Create Booking'); await expect(page.getByTestId('next-link')).toContainText('Cancel Booking'); + + await expect(sidebar.active).toHaveCount(1); + await expect(sidebar.active).toHaveAttribute('data-slug', 'bookings/lifecycle/confirm-booking'); + + await page.getByTestId('prev-link').click(); + await expect(page.getByTestId('page')).toHaveAttribute( + 'data-page-slug', + 'bookings/lifecycle/create-booking' + ); + await expect(page).toHaveURL(/#\/bookings\/lifecycle\/create-booking$/); + await expect(page.getByRole('heading', { name: 'Create Booking', level: 1 })).toBeVisible(); }); test('slug URL is stable across reload', async ({ page }) => { @@ -90,4 +125,145 @@ test.describe('page-based navigation', () => { await expect(page.getByTestId('page')).toHaveAttribute('data-page-slug', 'authentication'); await expect(page).toHaveURL(/#\/authentication$/); }); + + test('editing the hash navigates without a full reload and highlights the sidebar', async ({ page, sidebar }) => { + await page.goto(FIXTURE); + await expect(page.getByTestId('page')).toHaveAttribute('data-page-type', 'overview'); + await page.evaluate(() => { + (window as Window & { __docsStay?: boolean }).__docsStay = true; + }); + + await page.evaluate(() => { + window.location.hash = '#/authentication/login'; + }); + + await expect(page.getByTestId('page')).toHaveAttribute('data-page-slug', 'authentication/login'); + await expect(page.getByRole('heading', { name: 'Login', level: 1 })).toBeVisible(); + await expect(sidebar.itemBySlug('authentication/login')).toHaveClass(/active/); + expect(await page.evaluate(() => (window as Window & { __docsStay?: boolean }).__docsStay)).toBe(true); + }); + + test('browser back and forward keep url, content, and sidebar in sync', async ({ page, sidebar }) => { + await page.goto(FIXTURE); + await sidebar.itemBySlug('authentication').click(); + await expect(page.getByTestId('folder-title')).toHaveText('Authentication'); + await expect(sidebar.itemBySlug('authentication')).toHaveClass(/active/); + + await sidebar.itemBySlug('authentication/login').click(); + await expect(page.getByRole('heading', { name: 'Login', level: 1 })).toBeVisible(); + await expect(page).toHaveURL(/#\/authentication\/login$/); + await expect(sidebar.itemBySlug('authentication/login')).toHaveClass(/active/); + + await page.goBack(); + await expect(page).toHaveURL(/#\/authentication$/); + await expect(page.getByTestId('folder-title')).toHaveText('Authentication'); + await expect(sidebar.itemBySlug('authentication')).toHaveClass(/active/); + await expect(page.getByRole('heading', { name: 'Login', level: 1 })).toHaveCount(0); + + await page.goForward(); + await expect(page).toHaveURL(/#\/authentication\/login$/); + await expect(page.getByRole('heading', { name: 'Login', level: 1 })).toBeVisible(); + await expect(sidebar.itemBySlug('authentication/login')).toHaveClass(/active/); + }); + + test('first page shows only Next and last page shows only Previous', async ({ page }) => { + await page.goto(FIXTURE); + await expectOverviewHash(page); + await expect(page.getByTestId('prev-link')).toHaveCount(0); + await expect(page.getByTestId('next-link')).toContainText('Environments'); + + await page.getByTestId('next-link').click(); + await expect(page.getByTestId('environments-title')).toHaveText('Environments'); + await expect(page.getByTestId('prev-link')).toContainText('Hotel API'); + await expect(page.getByTestId('next-link')).toContainText('Authentication'); + + await page.getByTestId('prev-link').click(); + await expect(page.getByTestId('page')).toHaveAttribute('data-page-type', 'overview'); + await expectOverviewHash(page); + await expect(page.getByTestId('prev-link')).toHaveCount(0); + await expect(page.getByTestId('next-link')).toContainText('Environments'); + + await page.goto(page$('setup-script')); + await expect(page.getByTestId('script-title')).toHaveText('Setup Script'); + await expect(page.getByTestId('next-link')).toHaveCount(0); + await expect(page.getByTestId('prev-link')).toContainText('Health Check'); + + await page.getByTestId('prev-link').click(); + await expect(page.getByTestId('request-title')).toContainText('Health Check'); + await expect(page.getByTestId('next-link')).toContainText('Setup Script'); + await expect(page).toHaveURL(/#\/health-check$/); + + await page.getByTestId('next-link').click(); + await expect(page.getByTestId('next-link')).toHaveCount(0); + await expect(page.getByTestId('page')).toHaveAttribute('data-page-slug', 'setup-script'); + }); + + test('rapid Next clicks land on the last target with content, hash, and sidebar in sync', async ({ page, sidebar }) => { + await page.goto(page$('bookings/lifecycle/create-booking')); + await expect(page.getByTestId('next-link')).toBeVisible(); + + // Clicks run in the page so Playwright does not wait for each link to go stable. + await page.evaluate(async () => { + for (let i = 0; i < 3; i += 1) { + const link = document.querySelector('[data-testid="next-link"]'); + if (!link) throw new Error(`next link missing on click ${i + 1}`); + link.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + }); + + await expect(page.getByTestId('page')).toHaveAttribute('data-page-slug', 'bookings/payments'); + await expect(page.getByTestId('page')).toHaveAttribute('data-page-type', 'folder'); + await expect(page).toHaveURL(/#\/bookings\/payments$/); + await expect(page.getByTestId('folder-title')).toHaveText('Payments'); + await expect(sidebar.itemBySlug('bookings/payments')).toHaveClass(/active/); + await expect(page.getByRole('heading', { name: 'Create Booking', level: 1 })).toHaveCount(0); + await expect(page.getByRole('heading', { name: 'Confirm Booking', level: 1 })).toHaveCount(0); + }); + + test('a distant sidebar jump after Next shows only that page', async ({ page, sidebar }) => { + await page.goto(page$('bookings/lifecycle/create-booking')); + await page.getByTestId('next-link').click(); + await expect(page).toHaveURL(/#\/bookings\/lifecycle\/confirm-booking$/); + + await sidebar.itemBySlug('health-check').click(); + + await expect(page.getByTestId('page')).toHaveAttribute('data-page-slug', 'health-check'); + await expect(page).toHaveURL(/#\/health-check$/); + await expect(page.getByTestId('request-title')).toContainText('Health Check'); + await expect(sidebar.itemBySlug('health-check')).toHaveClass(/active/); + await expect(page.getByRole('heading', { name: 'Confirm Booking', level: 1 })).toHaveCount(0); + await expect(page.getByRole('heading', { name: 'Create Booking', level: 1 })).toHaveCount(0); + }); + + for (const viewport of [ + { name: 'desktop', size: DESKTOP }, + { name: 'tablet', size: TABLET }, + { name: 'mobile', size: MOBILE } + ]) { + test(`Next and Previous stay usable on ${viewport.name}`, async ({ page }) => { + await page.setViewportSize(viewport.size); + await page.goto(page$('bookings/lifecycle/create-booking')); + + // Create Booking is mid-hierarchy, so both cards are on screen. + await paginationFits(page, viewport.size.width); + + const next = page.getByTestId('next-link'); + await expect(next).toBeVisible(); + await next.click(); + + await expect(page.getByTestId('page')).toHaveAttribute( + 'data-page-slug', + 'bookings/lifecycle/confirm-booking' + ); + await expect(page).toHaveURL(/#\/bookings\/lifecycle\/confirm-booking$/); + await expect(page.getByRole('heading', { name: 'Create Booking', level: 1 })).toHaveCount(0); + await paginationFits(page, viewport.size.width); + + const prev = page.getByTestId('prev-link'); + await expect(prev).toBeVisible(); + await prev.click(); + await expect(page).toHaveURL(/#\/bookings\/lifecycle\/create-booking$/); + }); + } });