From 9e4ccd88dbbfe171e2a60fd9269b6a2c04ad8fee Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Wed, 15 Jul 2026 14:53:55 +0200 Subject: [PATCH] test(e2e): cover mobile frontend flows --- .../test/e2e/runtime/test_fake_qb_contract.py | 23 + e2e/fake-qb/server.py | 18 + webui/e2e/fixtures/auth.ts | 5 +- webui/e2e/specs/bootstrap-auth.spec.ts | 6 +- webui/e2e/specs/mobile-auth-setup.spec.ts | 393 ++++++++++++++++++ webui/e2e/specs/mobile-shell-search.spec.ts | 380 +++++++++++++++++ webui/e2e/specs/session-access.spec.ts | 34 +- 7 files changed, 851 insertions(+), 8 deletions(-) create mode 100644 webui/e2e/specs/mobile-auth-setup.spec.ts create mode 100644 webui/e2e/specs/mobile-shell-search.spec.ts diff --git a/backend/src/test/e2e/runtime/test_fake_qb_contract.py b/backend/src/test/e2e/runtime/test_fake_qb_contract.py index e50e659c..6ae8acd5 100644 --- a/backend/src/test/e2e/runtime/test_fake_qb_contract.py +++ b/backend/src/test/e2e/runtime/test_fake_qb_contract.py @@ -75,6 +75,29 @@ def test_qb_endpoints_and_successful_delete_update_state(fake_qb_url): assert client.get("/api/v2/torrents/info").status_code == 403 +def test_torrent_presets_include_webui_fields(fake_qb_url): + with httpx.Client(base_url=fake_qb_url) as client: + login(client) + + torrent = client.get("/api/v2/torrents/info").json()[0] + + assert { + "hash", + "name", + "size", + "progress", + "dlspeed", + "upspeed", + "num_seeds", + "num_leechs", + "state", + "eta", + "category", + "save_path", + "added_on", + } <= torrent.keys() + + def test_forced_delete_failure_is_atomic(fake_qb_url): with httpx.Client(base_url=fake_qb_url) as client: login(client) diff --git a/e2e/fake-qb/server.py b/e2e/fake-qb/server.py index 9d45bdc6..46c3e80a 100644 --- a/e2e/fake-qb/server.py +++ b/e2e/fake-qb/server.py @@ -38,6 +38,12 @@ _PRESET_TORRENTS = ( "state": "pausedUP", "progress": 1.0, "size": 65_536, + "dlspeed": 0, + "upspeed": 0, + "num_seeds": 0, + "num_leechs": 0, + "eta": 0, + "added_on": 0, }, { "hash": NEIGHBOUR_HASH, @@ -48,6 +54,12 @@ _PRESET_TORRENTS = ( "state": "pausedUP", "progress": 1.0, "size": 65_536, + "dlspeed": 0, + "upspeed": 0, + "num_seeds": 0, + "num_leechs": 0, + "eta": 0, + "added_on": 0, }, { "hash": DELETE_FAILURE_HASH, @@ -58,6 +70,12 @@ _PRESET_TORRENTS = ( "state": "pausedUP", "progress": 1.0, "size": 65_536, + "dlspeed": 0, + "upspeed": 0, + "num_seeds": 0, + "num_leechs": 0, + "eta": 0, + "added_on": 0, }, ) diff --git a/webui/e2e/fixtures/auth.ts b/webui/e2e/fixtures/auth.ts index ba14fbe0..d461d913 100644 --- a/webui/e2e/fixtures/auth.ts +++ b/webui/e2e/fixtures/auth.ts @@ -36,7 +36,10 @@ export async function loginThroughUI( expect(response.status()).toBe(expectedStatus); if (expectedStatus === 200) { expect(await response.json()).toEqual(SESSION_SUCCESS); - await expect(page).toHaveURL(/#\/bangumi$/); + const expectedPath = await page.evaluate(() => + matchMedia('(max-width: 639px)').matches ? 'home' : 'bangumi' + ); + await expect(page).toHaveURL(new RegExp(`#/${expectedPath}$`)); } return response; } diff --git a/webui/e2e/specs/bootstrap-auth.spec.ts b/webui/e2e/specs/bootstrap-auth.spec.ts index a5c2dd11..dc496284 100644 --- a/webui/e2e/specs/bootstrap-auth.spec.ts +++ b/webui/e2e/specs/bootstrap-auth.spec.ts @@ -67,8 +67,10 @@ test('logout and browser back cannot revive a revoked session', async ({ await expect(page).toHaveURL(/#\/login$/); await page.goBack(); await expect(page).not.toHaveURL(/#\/bangumi$/); - await page.goto('/#/bangumi'); - await expect(page).toHaveURL(/#\/login$/); + + const directPage = await authenticated.context.newPage(); + await directPage.goto('/#/bangumi'); + await expect(directPage).toHaveURL(/#\/login$/); } finally { await closeAuthenticatedContexts([authenticated], testInfo); } diff --git a/webui/e2e/specs/mobile-auth-setup.spec.ts b/webui/e2e/specs/mobile-auth-setup.spec.ts new file mode 100644 index 00000000..419e2aa7 --- /dev/null +++ b/webui/e2e/specs/mobile-auth-setup.spec.ts @@ -0,0 +1,393 @@ +import type { Locator, Page } from '@playwright/test'; +import { expect, test } from '../fixtures/test'; + +const MOBILE_VIEWPORT = { width: 390, height: 844 }; +const SHORT_VIEWPORT = { width: 390, height: 360 }; +const SETUP_DRAFT_KEY = 'ab-setup-wizard'; + +interface SetupDraft { + currentStepIndex: number; + accountData: { + username: string; + password: string; + confirmPassword: string; + }; + downloaderData: { + type: 'qbittorrent'; + host: string; + username: string; + password: string; + path: string; + ssl: boolean; + }; + rssData: { url: string; name: string; skipped: boolean }; + notificationData: { + enable: boolean; + type: 'telegram'; + token: string; + chat_id: string; + skipped: boolean; + }; + validation: { + downloaderTested: boolean; + rssTested: boolean; + notificationTested: boolean; + }; +} + +test.use({ + storageState: { cookies: [], origins: [] }, + viewport: MOBILE_VIEWPORT, +}); + +function setupDraft(currentStepIndex: number): SetupDraft { + return { + currentStepIndex, + accountData: { + username: 'mobile-admin', + password: 'mobile-pass-9', + confirmPassword: 'mobile-pass-9', + }, + downloaderData: { + type: 'qbittorrent', + host: 'qb.internal:8080', + username: '', + password: '', + path: '/downloads/Bangumi', + ssl: false, + }, + rssData: { url: '', name: '', skipped: false }, + notificationData: { + enable: false, + type: 'telegram', + token: '', + chat_id: '', + skipped: false, + }, + validation: { + downloaderTested: false, + rssTested: false, + notificationTested: false, + }, + }; +} + +async function prepareAnonymousPage( + page: Page, + options: { + draft?: SetupDraft; + needSetup?: boolean; + supportPasskeys?: boolean; + } = {} +): Promise { + await page.addInitScript( + ({ draft, draftKey, supportPasskeys }) => { + localStorage.setItem('isLoggedIn', 'false'); + localStorage.setItem('lang', 'en'); + localStorage.setItem('hasPasskey', 'false'); + sessionStorage.removeItem(draftKey); + if (draft) sessionStorage.setItem(draftKey, JSON.stringify(draft)); + + if (supportPasskeys) { + Object.defineProperty(window, 'PublicKeyCredential', { + configurable: true, + value: class {}, + }); + Object.defineProperty(navigator, 'credentials', { + configurable: true, + value: { + create: async () => null, + get: async () => { + throw new DOMException('E2E cancellation', 'NotAllowedError'); + }, + }, + }); + } + }, + { + draft: options.draft, + draftKey: SETUP_DRAFT_KEY, + supportPasskeys: options.supportPasskeys ?? false, + } + ); + + await page.route('**/api/v1/setup/status', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + need_setup: options.needSetup ?? false, + version: 'e2e', + }), + }); + }); +} + +async function openFirstRunSetup( + page: Page, + draft?: SetupDraft +): Promise { + await prepareAnonymousPage(page, { draft, needSetup: true }); + await page.goto('/#/'); + await expect(page).toHaveURL(/#\/setup$/); +} + +async function expectTouchTarget(locator: Locator): Promise { + const box = await locator.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.width).toBeGreaterThanOrEqual(44); + expect(box!.height).toBeGreaterThanOrEqual(44); +} + +async function expectWithinViewport(locator: Locator, page: Page) { + const box = await locator.boundingBox(); + const viewport = page.viewportSize(); + expect(box).not.toBeNull(); + expect(viewport).not.toBeNull(); + expect(box!.x).toBeGreaterThanOrEqual(0); + expect(box!.y).toBeGreaterThanOrEqual(0); + expect(box!.x + box!.width).toBeLessThanOrEqual(viewport!.width); + expect(box!.y + box!.height).toBeLessThanOrEqual(viewport!.height); +} + +async function expectHorizontallyWithinViewport(locator: Locator, page: Page) { + const box = await locator.boundingBox(); + const viewport = page.viewportSize(); + expect(box).not.toBeNull(); + expect(viewport).not.toBeNull(); + expect(box!.x).toBeGreaterThanOrEqual(0); + expect(box!.x + box!.width).toBeLessThanOrEqual(viewport!.width); +} + +test('mobile login stays bounded and scrollable after a short-height resize @mobile', async ({ + page, +}) => { + await prepareAnonymousPage(page, { supportPasskeys: true }); + await page.goto('/#/login'); + + expect(page.viewportSize()).toEqual(MOBILE_VIEWPORT); + const card = page.locator('.login-card'); + const username = page.getByLabel('Username'); + const password = page.getByLabel('Password'); + const login = page.getByRole('button', { name: 'Login', exact: true }); + const passkey = page.getByRole('button', { name: 'Passkey', exact: true }); + await expectWithinViewport(card, page); + for (const control of [username, password, login, passkey]) { + await expectTouchTarget(control); + await expectHorizontallyWithinViewport(control, page); + } + + await page.setViewportSize(SHORT_VIEWPORT); + const scrollMetrics = await page + .locator('.page-login') + .evaluate((element) => ({ + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + })); + expect(scrollMetrics.scrollHeight).toBeGreaterThan( + scrollMetrics.clientHeight + ); + await expectHorizontallyWithinViewport(card, page); + await passkey.scrollIntoViewIfNeeded(); + await expectWithinViewport(passkey, page); +}); + +test('password Enter sends exactly one intercepted login request @mobile', async ({ + page, +}) => { + await prepareAnonymousPage(page); + const submissions: string[] = []; + await page.route('**/api/v1/auth/login', async (route) => { + submissions.push(route.request().postData() ?? ''); + await route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ + msg_en: 'Expected E2E rejection', + msg_zh: 'Expected E2E rejection', + }), + }); + }); + await page.goto('/#/login'); + + await page.getByLabel('Username').fill('mobile-admin'); + const password = page.getByLabel('Password'); + await password.fill('mobile-pass-9'); + const response = page.waitForResponse( + (candidate) => + candidate.url().endsWith('/api/v1/auth/login') && + candidate.request().method() === 'POST' + ); + await password.press('Enter'); + await response; + + expect(submissions).toHaveLength(1); + expect(submissions[0]).toContain('username=mobile-admin'); +}); + +test('passkey action never submits the password form @mobile', async ({ + page, +}) => { + await prepareAnonymousPage(page, { supportPasskeys: true }); + let passwordSubmissions = 0; + let passkeyStarts = 0; + await page.route('**/api/v1/auth/login', async (route) => { + passwordSubmissions++; + await route.fulfill({ status: 500, body: '' }); + }); + await page.route('**/api/v1/passkey/auth/options', async (route) => { + passkeyStarts++; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + challenge: 'AQID', + timeout: 1000, + userVerification: 'preferred', + }), + }); + }); + await page.goto('/#/login'); + + await page.getByLabel('Username').fill('mobile-admin'); + await page.getByLabel('Password').fill('mobile-pass-9'); + const passkeyResponse = page.waitForResponse((candidate) => + candidate.url().endsWith('/api/v1/passkey/auth/options') + ); + await page.getByRole('button', { name: 'Passkey', exact: true }).click(); + await passkeyResponse; + + expect(passkeyStarts).toBe(1); + expect(passwordSubmissions).toBe(0); + await expect(page).toHaveURL(/#\/login$/); +}); + +test('mobile setup keeps progress and controls reachable after resize @mobile', async ({ + page, +}) => { + await openFirstRunSetup(page); + + expect(page.viewportSize()).toEqual(MOBILE_VIEWPORT); + const progress = page.getByRole('progressbar'); + await expect(progress).toHaveAttribute('aria-valuenow', '1'); + const progressBox = await progress.boundingBox(); + expect(progressBox).not.toBeNull(); + expect(progressBox!.y).toBeLessThan(80); + const start = page.getByRole('button', { name: 'Get Started' }); + await expectTouchTarget(start); + await start.click(); + + const username = page.getByLabel('Username'); + const next = page.getByRole('button', { name: 'Next', exact: true }); + await expectTouchTarget(username); + await expectTouchTarget(next); + await page.setViewportSize(SHORT_VIEWPORT); + expect( + await page.evaluate( + () => document.documentElement.scrollHeight > window.innerHeight + ) + ).toBe(true); + await expectHorizontallyWithinViewport( + page.locator('.wizard-container'), + page + ); + await next.scrollIntoViewIfNeeded(); + await expectWithinViewport(next, page); + await progress.scrollIntoViewIfNeeded(); + await expectWithinViewport(progress, page); +}); + +test('setup account requires four username characters and matching eight-character passwords @mobile', async ({ + page, +}) => { + await openFirstRunSetup(page, setupDraft(1)); + + const username = page.getByLabel('Username'); + const password = page.getByLabel('Password', { exact: true }); + const confirmPassword = page.getByLabel('Confirm Password'); + const next = page.getByRole('button', { name: 'Next', exact: true }); + + await username.fill('abc'); + await password.fill('12345678'); + await confirmPassword.fill('12345678'); + await expect(next).toBeDisabled(); + await username.fill('abcd'); + await password.fill('1234567'); + await confirmPassword.fill('1234567'); + await expect(next).toBeDisabled(); + await password.fill('12345678'); + await confirmPassword.fill('87654321'); + await expect(next).toBeDisabled(); + await confirmPassword.fill('12345678'); + await expect(next).toBeEnabled(); +}); + +test('setup downloader can continue with only a host @mobile', async ({ + page, +}) => { + const draft = setupDraft(2); + draft.downloaderData.host = ''; + await openFirstRunSetup(page, draft); + + const next = page.getByRole('button', { name: 'Next', exact: true }); + const testConnection = page.getByRole('button', { + name: 'Test Connection', + }); + await expect(next).toBeDisabled(); + await page.getByLabel('Host').fill('qb.internal:8080'); + await expect(testConnection).toBeDisabled(); + await expect(next).toBeEnabled(); + await next.click(); + + await expect(page.getByRole('progressbar')).toHaveAttribute( + 'aria-valuenow', + '4' + ); + await expect(page.getByText('RSS Source', { exact: true })).toBeVisible(); +}); + +test('setup skips optional steps and completes through the explicit login route @mobile', async ({ + page, +}) => { + await openFirstRunSetup(page, setupDraft(3)); + let completeCalls = 0; + let completePayload: unknown; + await page.route('**/api/v1/setup/complete', async (route) => { + completeCalls++; + completePayload = route.request().postDataJSON(); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: true }), + }); + }); + await page.getByRole('button', { name: 'Skip', exact: true }).click(); + await expect(page.getByText('Notifications', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Skip', exact: true }).click(); + await expect( + page.getByText('Review Settings', { exact: true }) + ).toBeVisible(); + const completeResponse = page.waitForResponse( + (candidate) => + candidate.url().endsWith('/api/v1/setup/complete') && + candidate.request().method() === 'POST' + ); + await page + .getByRole('button', { name: 'Complete Setup', exact: true }) + .click(); + await completeResponse; + + expect(completeCalls).toBe(1); + expect(completePayload).toMatchObject({ + username: 'mobile-admin', + downloader_host: 'qb.internal:8080', + downloader_username: '', + rss_url: '', + rss_name: '', + notification_enable: false, + }); + await expect(page).toHaveURL(/#\/login$/); + await expect( + page.getByRole('button', { name: 'Login', exact: true }) + ).toBeVisible(); +}); diff --git a/webui/e2e/specs/mobile-shell-search.spec.ts b/webui/e2e/specs/mobile-shell-search.spec.ts new file mode 100644 index 00000000..8058aa16 --- /dev/null +++ b/webui/e2e/specs/mobile-shell-search.spec.ts @@ -0,0 +1,380 @@ +import type { Page } from '@playwright/test'; + +import type { BangumiAPI } from '../../types/bangumi'; +import { expect, test } from '../fixtures/test'; + +// Mobile shell regression coverage for #1066/#1069. The keyboard/editor +// scenario remains in mobile-rule-editor.spec.ts; this file covers navigation. +const SEARCH_RESULTS = [ + { + added: false, + deleted: false, + archived: false, + dpi: '1920x1080', + eps_collect: false, + filter: '720|480', + group_name: 'Fixture Group A', + id: 0, + official_title: 'Mobile Search Fixture', + episode_offset: 0, + season_offset: 0, + poster_link: null, + rss_link: '/rss/mobile-search-a.xml', + rule_name: 'mobile-search-a', + save_path: '', + season: 1, + season_raw: 'S1', + source: null, + subtitle: 'CHS', + title_raw: 'Mobile Search Fixture A', + year: '2026', + air_weekday: null, + weekday_locked: false, + needs_review: false, + needs_review_reason: null, + preferred_group: null, + preferred_resolution: null, + episode_type: 'episode', + }, + { + added: false, + deleted: false, + archived: false, + dpi: '1280x720', + eps_collect: false, + filter: '1080', + group_name: 'Fixture Group B', + id: 0, + official_title: 'Mobile Search Fixture', + episode_offset: 0, + season_offset: 0, + poster_link: null, + rss_link: '/rss/mobile-search-b.xml', + rule_name: 'mobile-search-b', + save_path: '', + season: 2, + season_raw: 'S2', + source: null, + subtitle: 'CHT', + title_raw: 'Mobile Search Fixture B', + year: '2026', + air_weekday: null, + weekday_locked: false, + needs_review: false, + needs_review_reason: null, + preferred_group: null, + preferred_resolution: null, + episode_type: 'episode', + }, +] satisfies BangumiAPI[]; + +interface SearchEventSourceAudit { + closeCount: number; + keywords: string; + site: string; +} + +async function installSearchEventSource(page: Page): Promise { + await page.addInitScript((results) => { + const auditWindow = window as Window & { + __searchEventSourceAudit?: SearchEventSourceAudit; + }; + const NativeEventSource = window.EventSource; + const audit: SearchEventSourceAudit = { + closeCount: 0, + keywords: '', + site: '', + }; + auditWindow.__searchEventSourceAudit = audit; + + class SearchEventSource extends EventTarget { + readonly CONNECTING = 0; + readonly OPEN = 1; + readonly CLOSED = 2; + readonly url: string; + readonly withCredentials: boolean; + readyState = this.CONNECTING; + onopen: ((event: Event) => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + + constructor(url: string | URL, init?: EventSourceInit) { + super(); + this.url = String(url); + this.withCredentials = init?.withCredentials ?? false; + const parsed = new URL(this.url, window.location.href); + audit.keywords = parsed.searchParams.get('keywords') ?? ''; + audit.site = parsed.searchParams.get('site') ?? ''; + + window.setTimeout(() => { + if (this.readyState === this.CLOSED) return; + this.readyState = this.OPEN; + const openEvent = new Event('open'); + this.onopen?.(openEvent); + this.dispatchEvent(openEvent); + + for (const result of results) { + const message = new MessageEvent('message', { + data: JSON.stringify(result), + }); + this.onmessage?.(message); + this.dispatchEvent(message); + } + }); + } + + close(): void { + if (this.readyState === this.CLOSED) return; + this.readyState = this.CLOSED; + audit.closeCount += 1; + } + } + + const RoutedEventSource = new Proxy(NativeEventSource, { + construct(Target, argumentsList) { + const [url, init] = argumentsList as [ + string | URL, + EventSourceInit | undefined + ]; + if (String(url).includes('api/v1/search/bangumi?')) { + return new SearchEventSource(url, init); + } + return Reflect.construct(Target, argumentsList); + }, + }); + + Object.defineProperty(window, 'EventSource', { + configurable: true, + value: RoutedEventSource, + }); + }, SEARCH_RESULTS); +} + +async function installSearchFixture(page: Page): Promise { + await installSearchEventSource(page); + await page.route('**/api/v1/search/provider', async (route) => { + await route.fulfill({ json: ['mikan', 'nyaa'] }); + }); +} + +async function installOffsetMismatchFixture(page: Page): Promise { + await page.route('**/api/v1/bangumi/detect-offset', async (route) => { + await route.fulfill({ + json: { + has_mismatch: true, + suggestion: { + season_offset: 1, + episode_offset: 0, + reason: 'E2E offset mismatch', + confidence: 'high', + }, + tmdb_info: null, + }, + }); + }); +} + +test('mobile shell routes without breakpoint redirects @mobile', async ({ + page, +}) => { + expect(page.viewportSize()).toEqual({ width: 390, height: 844 }); + await page.goto('/#/'); + await expect(page).toHaveURL(/#\/home$/); + await expect( + page.locator('[data-summary="downloads"] [data-value="secondary"]') + ).toHaveText('0 B/s'); + + const navigation = page.getByRole('navigation', { + name: 'Primary navigation', + }); + await expect(navigation.getByRole('link', { name: 'Home' })).toBeVisible(); + await expect(navigation.getByRole('link', { name: 'Search' })).toBeVisible(); + await expect( + navigation.getByRole('button', { name: 'More actions' }) + ).toContainText('More'); + + await navigation.getByRole('link', { name: 'Search' }).click(); + await expect(page).toHaveURL(/#\/search$/); + await expect( + page.getByRole('region', { name: 'Search anime' }) + ).toBeVisible(); + await expect(page.getByRole('dialog')).toHaveCount(0); + + await page.setViewportSize({ width: 800, height: 844 }); + await expect(navigation).toBeHidden(); + await expect(page).toHaveURL(/#\/search$/); + await page.setViewportSize({ width: 390, height: 844 }); + await expect(navigation).toBeVisible(); + await expect(page).toHaveURL(/#\/search$/); +}); + +test('mobile search preserves route, focus, and local state @mobile', async ({ + page, +}) => { + await installSearchFixture(page); + await page.goto('/#/search'); + + const input = page.getByRole('textbox', { name: 'Search anime' }); + const provider = page.getByRole('button', { + name: 'Select search provider', + }); + await input.fill('Mobile Shell Fixture'); + + await provider.click(); + await expect(page.getByRole('button', { name: 'nyaa' })).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.getByRole('button', { name: 'nyaa' })).toHaveCount(0); + await expect(page).toHaveURL(/#\/search$/); + await expect(input).toHaveValue('Mobile Shell Fixture'); + await expect(provider).toBeFocused(); + + await provider.click(); + await page.getByRole('button', { name: 'nyaa' }).click(); + await expect(provider).toContainText('nyaa'); + await expect(provider).toBeFocused(); + + await input.press('Enter'); + const groupFilter = page.getByRole('button', { + name: 'Fixture Group A', + exact: true, + }); + await expect(groupFilter).toBeVisible(); + const observedSearch = await page.evaluate( + () => + ( + window as Window & { + __searchEventSourceAudit?: SearchEventSourceAudit; + } + ).__searchEventSourceAudit + ); + expect(observedSearch).toMatchObject({ + keywords: 'Mobile Shell Fixture', + site: 'nyaa', + }); + await groupFilter.click(); + await expect( + page.getByRole('button', { name: 'Clear Fixture Group A' }) + ).toBeVisible(); + + const navigation = page.getByRole('navigation', { + name: 'Primary navigation', + }); + const closeCountBeforeNavigation = observedSearch?.closeCount ?? -1; + expect(closeCountBeforeNavigation).toBe(0); + await navigation.getByRole('link', { name: 'Home' }).click(); + await expect(page).toHaveURL(/#\/home$/); + await expect + .poll(() => + page.evaluate( + () => + ( + window as Window & { + __searchEventSourceAudit?: SearchEventSourceAudit; + } + ).__searchEventSourceAudit?.closeCount ?? -1 + ) + ) + .toBe(closeCountBeforeNavigation + 1); + await navigation.getByRole('link', { name: 'Search' }).click(); + await expect(page).toHaveURL(/#\/search$/); + + await expect(input).toHaveValue('Mobile Shell Fixture'); + await expect(provider).toContainText('nyaa'); + await expect( + page.getByRole('button', { name: 'Clear Fixture Group A' }) + ).toBeVisible(); +}); + +test('mobile offset Escape returns to subscription confirmation @mobile', async ({ + page, +}) => { + await installSearchFixture(page); + await installOffsetMismatchFixture(page); + await page.goto('/#/search'); + + const input = page.getByRole('textbox', { name: 'Search anime' }); + await input.fill('Mobile Shell Fixture'); + await input.press('Enter'); + const variant = page.getByRole('button', { + name: /Fixture Group A.*S1/, + }); + await expect(variant).toBeVisible(); + await variant.click(); + + const offsetDialog = page.getByRole('dialog', { + name: 'Season/Episode Mismatch Detected', + }); + await expect( + offsetDialog.getByRole('button', { name: 'Keep Original' }) + ).toBeVisible(); + await page.keyboard.press('Escape'); + + await expect(offsetDialog).toHaveCount(0); + const confirmation = page.getByRole('dialog', { + name: 'Add Subscription', + }); + await expect( + confirmation.getByRole('button', { name: 'Close' }) + ).toBeVisible(); + await expect(page).toHaveURL(/#\/search$/); +}); + +test('More hands off to one dialog and restores focus on cancel @mobile', async ({ + page, +}) => { + await page.goto('/#/home'); + const navigation = page.getByRole('navigation', { + name: 'Primary navigation', + }); + const moreTrigger = navigation.getByRole('button', { + name: 'More actions', + }); + + await moreTrigger.click(); + const moreDialog = page.getByRole('dialog', { name: 'More actions' }); + await expect(moreDialog.getByTestId('bottom-sheet-panel')).toBeVisible(); + await page.evaluate(() => { + const auditWindow = window as Window & { + __mobileDialogAudit?: { + max: number; + observer: MutationObserver; + }; + }; + const countDialogs = () => + document.querySelectorAll('[role="dialog"]').length; + const audit = { + max: countDialogs(), + observer: new MutationObserver(() => { + audit.max = Math.max(audit.max, countDialogs()); + }), + }; + audit.observer.observe(document.body, { childList: true, subtree: true }); + auditWindow.__mobileDialogAudit = audit; + }); + + await moreDialog.getByRole('button', { name: 'Logout' }).click(); + const confirmation = page.getByRole('dialog', { + name: 'Log out of this account?', + }); + await expect(confirmation.getByTestId('bottom-sheet-panel')).toBeVisible(); + await expect(moreDialog).toHaveCount(0); + + const maximumDialogCount = await page.evaluate(() => { + const auditWindow = window as Window & { + __mobileDialogAudit?: { + max: number; + observer: MutationObserver; + }; + }; + const audit = auditWindow.__mobileDialogAudit; + audit?.observer.disconnect(); + return audit?.max ?? 0; + }); + expect(maximumDialogCount).toBe(1); + + await confirmation + .getByRole('button', { name: 'Cancel', exact: true }) + .click(); + await expect(page.getByRole('dialog')).toHaveCount(0); + await expect(moreTrigger).toBeFocused(); +}); diff --git a/webui/e2e/specs/session-access.spec.ts b/webui/e2e/specs/session-access.spec.ts index 4bbed1f0..045b9127 100644 --- a/webui/e2e/specs/session-access.spec.ts +++ b/webui/e2e/specs/session-access.spec.ts @@ -1,4 +1,4 @@ -import { request as playwrightRequest } from '@playwright/test'; +import { type Page, request as playwrightRequest } from '@playwright/test'; import { E2EApi } from '../fixtures/api'; import type { AuthenticatedContext } from '../fixtures/auth'; import { @@ -10,6 +10,33 @@ import type { ApiTokenCreated } from '../fixtures/data'; import { SESSION_SUCCESS, changedPassword } from '../fixtures/data'; import { expect, test } from '../fixtures/test'; +async function openAccountDialog(page: Page) { + const isMobile = await page.evaluate( + () => matchMedia('(max-width: 639px)').matches + ); + + if (isMobile) { + const navigation = page.getByRole('navigation', { + name: 'Primary navigation', + }); + await navigation.getByRole('button', { name: 'More actions' }).click(); + const moreDialog = page.getByRole('dialog', { name: 'More actions' }); + const morePanel = moreDialog.getByTestId('bottom-sheet-panel'); + await expect(morePanel).toBeVisible(); + await morePanel + .getByRole('button', { name: 'Profile', exact: true }) + .click(); + await expect(moreDialog).toHaveCount(0); + } else { + await page.getByRole('button', { name: 'System menu' }).click(); + await page.getByRole('menuitem', { name: 'Profile', exact: true }).click(); + } + + const dialog = page.getByRole('dialog', { name: 'Change Account' }); + await expect(dialog.getByLabel('Username')).toBeVisible(); + return dialog; +} + test('password change rotates both browser contexts and rejects the old password', async ({ api, browser, @@ -39,9 +66,7 @@ test('password change rotates both browser contexts and rejects the old password testInfo ); - await first.page.getByRole('button', { name: 'System menu' }).click(); - await first.page.getByRole('menuitem', { name: 'Profile' }).click(); - const dialog = first.page.getByRole('dialog').last(); + const dialog = await openAccountDialog(first.page); const nextCredentials = { username: credentials.username, password: changedPassword(uniqueName), @@ -67,7 +92,6 @@ test('password change rotates both browser contexts and rejects the old password await expect(second.page).toHaveURL(/#\/login$/); await loginThroughUI(second.page, credentials, 401); await loginThroughUI(second.page, nextCredentials); - await expect(second.page).toHaveURL(/#\/bangumi$/); } finally { const contexts = [first, second].filter( (value): value is AuthenticatedContext => value !== undefined