Repository navigation
test(e2e): add Playwright coverage for the Collection Docs sidebar - #110
lokesh-bruno wants to merge 7 commits into
Conversation
🦋 Changeset detectedLatest commit: 4330d48 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
| await folderOpen(sidebar, 'Lifecycle', true); | ||
| await expect(sidebar.item('Cancel Booking')).toBeVisible(); | ||
|
|
||
| await page.goto(`${FOLDERS}#/bookings/lifecycle/create-booking`); |
There was a problem hiding this comment.
the direct-link half of the test can never fail. The search step just before it has already opened Bookings and Lifecycle. page.goto to a URL that only changes after the # does not reload the page, so the open-folder state is still in memory, and the checks at lines 168 and 169 pass whatever the deep-link code does. Fix: close both folders first, or reload. Otherwise drop that half, because line 174 already covers a link opened in a fresh page.
| expect(html).toContain('DEL'); | ||
| expect(html).toContain('var(--oc-request-methods-delete)'); | ||
| expect(html).toContain('navlink-method'); | ||
| expect(html).toContain('data-testid="sidebar-method"'); |
There was a problem hiding this comment.
This line used to check the navlink-method class, and now it checks the test id instead. The class is what styles the method column (width, font, alignment), so the spec no longer catches it if the class is dropped or renamed. Could we keep the class check and add the test-id check next to it, rather than replacing it?
Same at line 51 (the script JS badge). If the scoped ids from the other comment go in, the test-id line here becomes sidebar-item-method, with testId="sidebar-item" passed to the render.
| expect(html).toContain('Overview'); | ||
| expect(html).toContain('<button'); | ||
| expect(html).toContain('navlink-main'); | ||
| expect(html).toContain('data-testid="sidebar-label"'); |
There was a problem hiding this comment.
These checks render SidebarNavLink without a testId and still expect sidebar-label / sidebar-method to be there. So the test locks in the fixed ids. If we move to ${testId}-label / ${testId}-method (see my comment on SidebarNavLink.tsx), could we update the spec to:
- Pass a
testIdand check the scoped ids appear:const html = renderToStaticMarkup(<SidebarNavLink label="Cancel Booking" method="DELETE" testId="sidebar-item" />); expect(html).toContain('data-testid="sidebar-item-label"'); expect(html).toContain('data-testid="sidebar-item-method"');
- Add one case with no
testIdand check neither id renders:const html = renderToStaticMarkup(<SidebarNavLink label="Cancel Booking" method="DELETE" />); expect(html).not.toContain('-label"'); expect(html).not.toContain('-method"');
| <button type="button" className="navlink-main" title={title ?? label} onClick={onClick}> | ||
| {leading} | ||
| <span className="navlink-label">{label}</span> | ||
| <span className="navlink-label" data-testid="sidebar-label">{label}</span> |
There was a problem hiding this comment.
The new test ids here are fixed strings, so every SidebarNavLink renders sidebar-label and sidebar-method, even when no testId is passed. Elsewhere in the app, child ids are built from the component's testId prop and left off when it is unset. See CopyButton.tsx:45 and Dropdown.tsx:59:
data-testid={testId ? `${testId}-button` : undefined}Could we follow the same pattern here? Line 76:
| <span className="navlink-label" data-testid="sidebar-label">{label}</span> | |
| <span className="navlink-label" data-testid={testId ? `${testId}-label` : undefined}>{label}</span> |
The same change applies to both method spans (lines 41 and 47):
data-testid={testId ? `${testId}-method` : undefined}Every caller already passes a base id (sidebar-item, sidebar-example, sidebar-overview, sidebar-environments), so no row loses its ids. They just become scoped to the row type:
| Row | Label id | Method id |
|---|---|---|
| request / folder | sidebar-item-label |
sidebar-item-method |
| saved example | sidebar-example-label |
n/a |
Follow-ups this needs:
e2e/components/sidebar.component.ts: updatelabels,item(),methodBadge()andexampleRow()to the scoped ids.e2e/tests/playground/method-selector.spec.ts: switch the fourgetByTestId('sidebar-method')calls tosidebar-item-method, ideally through a page-object method instead of chaining in the spec.SidebarNavLink.spec.tsx: pass atestIdand assert${testId}-label/${testId}-method, and add a case with notestIdthat asserts neither id renders. Right now the spec asserts the fixed ids, which locks in the current behaviour.
| await search.field.fill('cancel booking'); | ||
| await search.result('Cancel Booking').click(); | ||
|
|
||
| await expect(page.getByRole('heading', { name: 'Cancel Booking', level: 1 })).toBeVisible(); |
There was a problem hiding this comment.
page.getByRole('heading', { level: 1 }) can be replaced with requestPage.title, which the page object already has (request.page.ts:13, request-title).
| await expect(sidebar.item('Cancel Booking')).toBeVisible(); | ||
|
|
||
| await page.goto(`${FOLDERS}#/bookings/lifecycle/create-booking`); | ||
| await expect(page.getByRole('heading', { name: 'Create Booking', level: 1 })).toBeVisible(); |
There was a problem hiding this comment.
page.getByRole('heading', { level: 1 }) can be replaced with requestPage.title, which the page object already has (request.page.ts:13, request-title).
|
|
||
| test('collapses the whole panel and re-opens it', async ({ page, sidebar }) => { | ||
| await page.goto(FOLDERS); | ||
| const content = page.getByRole('main'); |
There was a problem hiding this comment.
the raw page.getByRole('main') for the content area should be a field on a page object.
| await sidebar.closeDrawerViaBackdrop(); | ||
|
|
||
| await page.getByTestId('next-link').click(); | ||
| await expect(page).not.toHaveURL(/200-ok-first-page/); |
There was a problem hiding this comment.
This check only says "we are no longer on the example URL". Any navigation passes it, even a wrong one. I tried clicking Previous instead of Next here and the test still passed (it landed on #/billing/customers).
To prove Next works, could we check where it actually lands? From this example, Next goes to the "Get Customers - Filter by Email" request:
| await expect(page).not.toHaveURL(/200-ok-first-page/); | |
| await expect(page).toHaveURL(/#\/billing\/customers\/get-customers-filter-by-email$/); | |
| await expect(requestPage.title).toHaveText('Get Customers - Filter by Email'); |
With this, clicking Previous by mistake fails the test as expected. Line 117 (page.getByTestId('page')).toBeVisible()) can go too, since the title check already proves a page rendered.
| await page.getByTestId('next-link').click(); | ||
| await expect(page).not.toHaveURL(/200-ok-first-page/); | ||
| await expect(page.getByTestId('page')).toBeVisible(); | ||
| await expect(sidebar.inline).toHaveCount(0); |
There was a problem hiding this comment.
This check can never fail at this screen size. AppShell.tsx only renders app-sidebar when the layout is desktop (isDesktop && !sidebarCollapsed), and desktop starts at 1024px. This test runs at 1023px, so the inline sidebar never exists here, before or after clicking Next.
It also isn't related to what the test is about (Next still working after opening an example from the drawer). "No inline sidebar below 1024px" is already covered in sidebar.spec.ts ("has no inline panel and opens the drawer from the hamburger"). Could we drop this line?
| await expect(sidebar.exampleRow(OK_EXAMPLE)).toHaveClass(/active/); | ||
| await sidebar.closeDrawerViaBackdrop(); | ||
|
|
||
| await page.getByTestId('next-link').click(); |
There was a problem hiding this comment.
Could we put the prev/next links in a page object instead of using page.getByTestId('next-link') in the spec? Nothing wraps them yet (routing.spec.ts uses the raw ids too), so a small component would serve both.
e2e/components/layout/prev-next.component.ts:
import type { Page } from '@playwright/test';
import { BaseComponent } from '../base.component';
export class PrevNextComponent extends BaseComponent {
constructor(page: Page) {
super(page, page.getByTestId('prevnext'));
}
readonly previous = this.root.getByTestId('prev-link');
readonly next = this.root.getByTestId('next-link');
}Register it in e2e/playwright/pages.fixture.ts next to pageHeader:
prevNext: async ({ page }, use) => {
await use(new PrevNextComponent(page));
},Then add prevNext to this test's arguments and use:
| await page.getByTestId('next-link').click(); | |
| await prevNext.next.click(); |
It's scoped to the prevnext wrapper, so it can't pick up a stray next-link elsewhere on the page.
…uno-api-docs into sidebar_coverage Co-authored-by: Cursor <cursoragent@cursor.com>
| readonly items = this.page.getByTestId('sidebar-item'); | ||
| readonly active = this.items.and(this.page.locator('.active')); | ||
| readonly labels = this.items.getByTestId('sidebar-item-label'); |
There was a problem hiding this comment.
SidebarComponent searches the whole page for sidebar-item rows. The playground renders the same SidebarTree (PlaygroundSidebar.tsx:74), with the same sidebar-item / sidebar-item-label test ids. Now that .first() is gone (good change), any sidebar lookup made while the playground is open matches two rows and fails Playwright's strict mode.
I checked it: open the playground on "get users", then sidebar.methodBadge('get users') fails with:
strict mode violation: ... resolved to 2 elements
No current test hits this, but the next person who uses sidebar in a playground test will.
Could we scope the component to the docs sidebar? Sidebar.tsx already renders data-testid="sidebar" on its root. Taking the root as a constructor argument also lets the playground reuse this component for its own tree (see my comment on playground.component.ts):
import type { Locator, Page } from '@playwright/test';
export class SidebarComponent extends BaseComponent {
constructor(page: Page, root: Locator = page.getByTestId('sidebar')) {
super(page, root);
}
readonly items = this.root.getByTestId('sidebar-item');
...
readonly overview = this.root.getByTestId('sidebar-overview');
readonly environments = this.root.getByTestId('sidebar-environments');and in exampleRow:
return this.root.getByTestId('sidebar-example').filter({inline, collapseButton, expandButton, resizer, drawer, backdrop and hamburger live outside the sidebar root (in AppShell / the topbar), so they stay on this.page. With this change the same check resolves to 1 row, and the full e2e suite passes.
| sidebarMethodBadge(name: string): Locator { | ||
| return this.sidebarItem(name).getByTestId('sidebar-item-method'); | ||
| } | ||
|
|
There was a problem hiding this comment.
sidebarMethodBadge is built on sidebarItem, which matches any row whose name contains the text and takes the first one. So 'get users' would quietly pick a "get users by id" row if one is ever added above it. The docs version (SidebarComponent.methodBadge) matches the exact name.
Once SidebarComponent takes a root (see my comment on sidebar.component.ts), the playground can reuse it scoped to its own panel instead of keeping a second, looser copy:
import { SidebarComponent } from './sidebar.component';
...
readonly sidebarPanel = this.page.getByTestId('playground-sidebar-panel');
readonly sidebarTree = new SidebarComponent(this.page, this.sidebarPanel);| sidebarMethodBadge(name: string): Locator { | |
| return this.sidebarItem(name).getByTestId('sidebar-item-method'); | |
| } | |
| sidebarMethodBadge(name: string): Locator { | |
| return this.sidebarTree.methodBadge(name); |
I ran method-selector.spec.ts with this and it passes. sidebarTree.item('get users') resolves to exactly one row, inside the playground panel.
| export abstract class BasePage { | ||
| readonly root = this.page.locator('#root'); | ||
| /** The AppShell content area, the `<main>` beside the sidebar. */ | ||
| readonly main = this.page.getByRole('main'); |
There was a problem hiding this comment.
main is part of the app layout (AppShell), not of any one page, but here it sits on BasePage. The sidebar collapse test then reaches it through overviewPage.main, which reads as if the test were about the overview page. It's also a role locator; the <main> has no test id yet (AppShell.tsx:182).
Could we give it a test id and expose it from a small layout component, like PrevNextComponent?
src/components/AppShell/AppShell.tsx, built from the existingtestIdprop (defaultapp-shell):<main className="appshell-content" ref={contentRef} data-testid={`${testId}-content`}>
e2e/components/layout/app-shell.component.ts:import type { Page } from '@playwright/test'; import { BaseComponent } from '../base.component'; export class AppShellComponent extends BaseComponent { constructor(page: Page) { super(page, page.getByTestId('app-shell')); } readonly content = this.root.getByTestId('app-shell-content'); }
- Register
appShellinpages.fixture.tsnext toprevNext, and remove these two lines fromBasePage. - In the collapse test (
sidebar.spec.ts:49), takeappShellinstead ofoverviewPageand useappShell.content.boundingBox()forbeforeandafter.
The collapse test passes with this.
| const label = sidebar.item(longName).getByTestId('sidebar-item-label'); | ||
| const truncated = await label.evaluate((el) => el.scrollWidth > el.clientWidth); | ||
| expect(truncated).toBe(true); |
There was a problem hiding this comment.
Two things on this check:
- It's built in the spec. It chains
.getByTestId('sidebar-item-label')ontosidebar.item(...)and hand-writes thescrollWidth > clientWidthcheck, which already exists asBaseComponent.isContentWiderThanBox(search.spec.ts:87uses it). - It doesn't prove the name is truncated.
scrollWidth > clientWidthonly says the text is wider than its box. That stays true iftext-overflow: ellipsisis removed from.navlink-label, which means the text gets cut off with no "...". I tried it: with the ellipsis removed, this test still passes.
Could we add an itemLabel helper in SidebarComponent, next to methodBadge:
itemLabel(name: string): Locator {
return this.item(name).getByTestId('sidebar-item-label');
}and change these lines to:
| const label = sidebar.item(longName).getByTestId('sidebar-item-label'); | |
| const truncated = await label.evaluate((el) => el.scrollWidth > el.clientWidth); | |
| expect(truncated).toBe(true); | |
| const longLabel = sidebar.itemLabel(longName); | |
| expect(await sidebar.isContentWiderThanBox(longLabel)).toBe(true); | |
| await expect(longLabel).toHaveCSS('text-overflow', 'ellipsis'); |
With this, removing the ellipsis fails the test with Expected: "ellipsis", Received: "clip".
| 'Customers/%$', | ||
| 'Café & Résumés', | ||
| 'Health Check', | ||
| 'Setup Script.js' |
There was a problem hiding this comment.
This order check can't catch scripts being mixed in with requests. In foldersCollection.ts, Setup Script has seq: 6 and Health Check has seq: 5. A bug that sorts scripts by seq together with requests puts the script after Health Check anyway, so the expected list is the same either way.
I tried it: I changed orderSiblings in navModel.ts to sort scripts by seq with the requests (...sortBySequence([...requests, ...files])), and this test still passed.
Could we give the script a lower seq than the request in src/e2eFixtures/foldersCollection.ts (line 127)?
{
name: 'Setup Script',
type: 'script',
seq: 4,With the correct code the sidebar order doesn't change (scripts always go last), so the expected list here stays the same. With the bug, the script jumps above Health Check and the test fails. The full e2e suite passes with this fixture change, including routing.spec.ts, which relies on the prev/next order.
| </span> | ||
| ) : script ? ( | ||
| <span className="navlink-leading navlink-method" style={{ color: 'var(--oc-colors-accent)' }}> | ||
| <span className="navlink-leading navlink-method" data-testid={partTestId('method')} style={{ color: 'var(--oc-colors-accent)' }}> |
There was a problem hiding this comment.
Small one, still here from last round: the script JS badge has a fixed inline style={{ color: 'var(--oc-colors-accent)' }}. The method span above only uses inline style because its color depends on the method at runtime. This one never changes, so it can be a class in StyledWrapper.ts. That also shortens this long line.
| <span className="navlink-leading navlink-method" data-testid={partTestId('method')} style={{ color: 'var(--oc-colors-accent)' }}> | |
| <span className="navlink-leading navlink-method navlink-script" data-testid={partTestId('method')}> |
StyledWrapper.ts, after the .navlink-method block:
.navlink-script {
color: var(--oc-colors-accent);
}SidebarNavLink.spec.tsx, in the JS badge test:
expect(html).toContain('navlink-script');
expect(html).not.toMatch(/<span class="[^"]*navlink-script[^"]*"[^>]*style=/);(The SSR output includes Emotion's <style> tag, so checking that the whole HTML doesn't contain the variable won't work. The pattern checks only the badge span's own style attribute.)
I compared the computed color before and after: rgb(185, 111, 29) in the default, hover and active states both ways, so nothing changes visually.
Ref BRU-4828
Description
Adds E2E coverage for the Collection Docs sidebar so the tree, collapse, drawer, and saved examples behave the same across navigation.
The coverage includes: