test(e2e): cover mobile frontend flows

This commit is contained in:
Estrella Pan
2026-07-15 14:53:55 +02:00
parent 3e11690236
commit 9e4ccd88db
7 changed files with 851 additions and 8 deletions

View File

@@ -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)

View File

@@ -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,
},
)

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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<void> {
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<void> {
await prepareAnonymousPage(page, { draft, needSetup: true });
await page.goto('/#/');
await expect(page).toHaveURL(/#\/setup$/);
}
async function expectTouchTarget(locator: Locator): Promise<void> {
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();
});

View File

@@ -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<void> {
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<string>) => 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<string>('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<void> {
await installSearchEventSource(page);
await page.route('**/api/v1/search/provider', async (route) => {
await route.fulfill({ json: ['mikan', 'nyaa'] });
});
}
async function installOffsetMismatchFixture(page: Page): Promise<void> {
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();
});

View File

@@ -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