Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changeset/pagination-e2e-coverage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

test(e2e): add Playwright coverage for Collection Docs pagination and navigation
5 changes: 5 additions & 0 deletions packages/bruno-api-docs/e2e/components/sidebar.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
}
Expand Down
178 changes: 177 additions & 1 deletion packages/bruno-api-docs/e2e/tests/routing/routing.spec.ts
Original file line number Diff line number Diff line change
@@ -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'));
Expand All @@ -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');
Expand All @@ -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 }) => {
Expand Down Expand Up @@ -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<HTMLAnchorElement>('[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$/);
});
}
});
Loading