mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-05 15:08:54 +08:00
test(e2e): cover mobile frontend flows
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
393
webui/e2e/specs/mobile-auth-setup.spec.ts
Normal file
393
webui/e2e/specs/mobile-auth-setup.spec.ts
Normal 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();
|
||||
});
|
||||
380
webui/e2e/specs/mobile-shell-search.spec.ts
Normal file
380
webui/e2e/specs/mobile-shell-search.spec.ts
Normal 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();
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user