import { test, expect } from '../support/fixtures.js'; /** * Back is the platform's, and the app has to have somewhere for it to * go (reported from a device: "the Android back button does not * navigate back in the app"). * * The scaffold's `MainActivity.onBackPressed` asks `webView.canGoBack()` * and finishes the activity otherwise. This app never touched * `history`, so that was always false and back quit from any depth. A * navigation is a history entry now, which is why this is assertable * here at all: `page.goBack()` is the same `popstate` the phone's * gesture produces, so the browser tier can answer a question that * otherwise needs a device. * * What it cannot answer is whether Android's *gesture* reaches the * WebView, which is between the OS and the scaffold. * * **And `data-active-view` is not the behaviour.** Every assertion here * used to be that attribute, which the shell sets on every path * including `_isBack` — so this file was green throughout #72, in * which both navs highlighted the view the user had just *left*. The * shell's own bookkeeping was the one thing that was already right; * what a person sees is `aria-current`, and that is asserted below as * well. This is the same trap `layout-overflow.spec.ts` set for #69: a * spec named for the behaviour, measuring the plumbing. */ type Page = import('@playwright/test').Page; const activeView = (page: Page) => page.getByTestId('main-content'); /** A common phone, where the bottom bar is the primary navigation. */ const PHONE = { width: 390, height: 844 }; /** * The nav item for a destination, in whichever navigation is on screen. * * Both navs carry a button named `Albums`, and only one of them is ever * in the accessibility tree — the other is `display: none` — so the * role query resolves to the one the user can see at this viewport. * That is the point: the highlight has to be right in both, and #72 was * two different-looking symptoms of one cause. */ const navItem = (page: Page, label: string) => page.getByRole('button', { name: label, exact: true }); /** * `aria-current="page"` is the accessible fact and the assertion worth * making; `.active` is a class and could be restyled without breaking * anything real. */ async function expectHighlighted(page: Page, label: string): Promise { await expect(navItem(page, label)).toHaveAttribute('aria-current', 'page'); } async function expectNotHighlighted(page: Page, label: string): Promise { await expect(navItem(page, label)).toHaveAttribute('aria-current', 'false'); } /** * Open an artist's detail view, which is the deepest ordinary route. * * A library artist opens `explore-artist-details` -- the catalog panel * standing in for a library one, as `explore-link.ts` describes -- and * the view name follows the component, not the source of the click. */ async function openAnArtist(app: Page): Promise { await app.getByTestId('nav-artists').click(); await expect(activeView(app)).toHaveAttribute('data-active-view', 'artists'); // A card, by the name on it: the grid is virtualized and positioned // by transform, so a click at coordinates is a click at whatever // happens to be there. await app.locator('artists-view').getByText('Aurora Fields').first().click(); await expect(activeView(app)).toHaveAttribute( 'data-active-view', 'explore-artist-details', ); } /** * The global back/forward control (#6). * * It is desktop chrome — hidden below 900px, where the sidebar has * already given up its labels — so these set a desktop viewport * explicitly rather than trusting the runner's default. */ const DESKTOP = { width: 1280, height: 800 }; const backButton = (page: Page) => page.locator('nav-history').getByRole('button', { name: 'Back' }); const forwardButton = (page: Page) => page.locator('nav-history').getByRole('button', { name: 'Forward' }); test.describe('global back and forward', () => { test.beforeEach(async ({ app }) => { await app.setViewportSize(DESKTOP); }); test('offers nothing at launch, in either direction', async ({ app }) => { // The launch entry is *replaced*, not pushed, so there is nothing // of ours behind it — and a Back button that is live at the root // is a press that does nothing on desktop and, on Android, the // press that should have exited the app (#142). This assertion is // what pins that: it failed before the launch navigation stopped // recording two entries. await expect(backButton(app)).toBeDisabled(); await expect(forwardButton(app)).toBeDisabled(); }); test('walks the history in both directions, and says which are available', async ({ app, }) => { await app.getByTestId('nav-albums').click(); await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums'); await expect(backButton(app)).toBeEnabled(); await expect(forwardButton(app)).toBeDisabled(); await app.getByTestId('nav-tracks').click(); await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks'); await backButton(app).click(); await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums'); // Standing in the middle of the list: both directions live, which // is the state a single depth counter cannot express. await expect(backButton(app)).toBeEnabled(); await expect(forwardButton(app)).toBeEnabled(); await forwardButton(app).click(); await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks'); await expect(forwardButton(app)).toBeDisabled(); }); test('reaches the detail view a tab click left behind', async ({ app }) => { // The report, exactly: the album is one entry away the whole time, // and before this control the only way back to it was a button // that had gone off screen with the view it belonged to. await app.getByTestId('nav-artists').click(); await openAnArtist(app); await app.getByTestId('nav-tracks').click(); await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks'); await backButton(app).click(); await expect(activeView(app)).toHaveAttribute( 'data-active-view', 'explore-artist-details', ); }); test('drops the forward list when the user navigates from the middle', async ({ app, }) => { await app.getByTestId('nav-albums').click(); await app.getByTestId('nav-tracks').click(); await backButton(app).click(); await expect(forwardButton(app)).toBeEnabled(); // A browser truncates here, and so does this: what was ahead is no // longer reachable, and a Forward button still offering it would // be pointing at an entry that has been overwritten. await app.getByTestId('nav-genres').click(); await expect(activeView(app)).toHaveAttribute('data-active-view', 'genres'); await expect(forwardButton(app)).toBeDisabled(); await expect(backButton(app)).toBeEnabled(); }); test('is absent below the desktop band, where nothing needs it', async ({ app, }) => { // Alt+Left/Right survive at every width, the detail views keep // their own back buttons and the phone has the platform's gesture // — so this is a control standing down, not an action becoming // unreachable. It is hidden at 899 because the top bar is what // runs out of room first below 900 (#143). await app.setViewportSize({ width: 899, height: 600 }); await expect(app.locator('nav-history')).toBeHidden(); await app.setViewportSize({ width: 390, height: 844 }); await expect(app.locator('nav-history')).toBeHidden(); }); }); test.describe('the back gesture', () => { test('leaves a detail view for the view it was opened from', async ({ app, }) => { await openAnArtist(app); await app.goBack(); await expect(activeView(app)).toHaveAttribute('data-active-view', 'artists'); }); test('walks back through primary views, one press per navigation', async ({ app, }) => { await app.getByTestId('nav-tracks').click(); await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks'); await app.getByTestId('nav-albums').click(); await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums'); await app.goBack(); await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks'); // Forward is free once back works, and it is what proves the entry // was restored rather than the view merely re-rendered. await app.goForward(); await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums'); }); test('leaves the nav highlighting the view it landed on, not the one it left', async ({ app, }) => { await app.getByTestId('nav-albums').click(); await expectHighlighted(app, 'Albums'); await app.getByTestId('nav-tracks').click(); await expectHighlighted(app, 'Tracks'); await app.goBack(); // #72, and the half of it the report did not describe: this is // desktop, and before the shell published the active view *both* // navs stayed on Tracks. An absent highlight reads as a glitch; a // confident wrong one is worse, and any back across two primary // views produced it. await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums'); await expectHighlighted(app, 'Albums'); await expectNotHighlighted(app, 'Tracks'); }); test('keeps the parent destination lit while a detail view is open', async ({ app, }) => { await app.getByTestId('nav-artists').click(); await expectHighlighted(app, 'Artists'); await openAnArtist(app); // A detail view is not a destination in either nav, and the user is // still inside Artists. `app-sidebar` did this by accident -- it // guarded on its own item list, so an unmatched name left the // highlight alone -- and that accident is why the sidebar looked // right on a detail view while the tab bar lit nothing. This test // therefore passed before the fix and is here to keep the rule from // being lost while the others are made to pass; the *tab bar's* // half of it is the phone test below, which did not. await expectHighlighted(app, 'Artists'); await app.goBack(); await expectHighlighted(app, 'Artists'); }); test('the tab bar survives the same journey on a phone', async ({ app }) => { await app.setViewportSize(PHONE); // The reported shape: Albums, open an album, press back. The tab // bar had a highlight, then no highlight at all, and never got it // back — `bottom-nav` took the detail view's name, matched it // against no tab, and lit nothing. await navItem(app, 'Albums').click(); await expectHighlighted(app, 'Albums'); await app.locator('cover-grid').getByText('Glass Harbour').first().click(); await expect(activeView(app)).toHaveAttribute( 'data-active-view', 'explore-album-details', ); await expectHighlighted(app, 'Albums'); await app.goBack(); await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums'); await expectHighlighted(app, 'Albums'); }); test('the drawer sidebar opens on the page you are standing on', async ({ app, }) => { await app.setViewportSize(PHONE); await navItem(app, 'Tracks').click(); await expectHighlighted(app, 'Tracks'); // A third symptom of the same cause, found while measuring #72 and // not in the report: `bottom-nav` mounts its `` when // the drawer opens, so that copy had heard no `navigate` at all and // showed its own default — Home, from any page in the app. An event // has no answer for a listener that was not there; a store does. await navItem(app, 'More').click(); // The element carrying the testid is the `wa-drawer` host, which // always reports hidden -- what is visible is the `` in its // shadow root -- so the drawer being open is asserted of the // sidebar it holds rather than of itself. const drawer = app.getByTestId('nav-drawer'); await expect(drawer.locator('app-sidebar')).toBeVisible(); await expect(drawer.getByTestId('nav-tracks')).toHaveAttribute( 'aria-current', 'page', ); await expect(drawer.getByTestId('nav-home')).toHaveAttribute( 'aria-current', 'false', ); }); test('an in-app back button consumes exactly one entry', async ({ app }) => { await app.getByTestId('nav-tracks').click(); await openAnArtist(app); // The detail view's own back button and the phone's gesture are the // same press: if each popped its own stack, this would land two // navigations back instead of one. await app .locator('explore-artist-details') .getByRole('button', { name: 'Back to explore' }) .click(); await expect(activeView(app)).toHaveAttribute('data-active-view', 'artists'); await app.goBack(); await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks'); }); });