A full parallel run intermittently failed the first few tests: unmocked /api/v1 calls fell through to the dev proxy, whose backend isn't running under E2E, so each incurred a slow ECONNREFUSED round-trip + Vite error logging that piled up across 8 workers at cold start. - Add e2e/fixtures.ts: a shared `test` whose auto-use fixture installs an `**/api/v1/**` fallback (fast 503, logged) for any endpoint a spec didn't mock. Registered before the test body, so per-test routes still win; only genuine gaps land here. Turns silent mock-gap hangs into instant, attributable failures — and cuts the full run ~96s → ~25s. - Point all 22 specs at ./fixtures instead of @playwright/test. - retries: 1 in the config as a safety net for the residual Vite cold-compile timing (re-runs on the now-warm server). Two consecutive full runs: 110 passed, 0 flaky, no retries consumed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
279 lines
10 KiB
TypeScript
279 lines
10 KiB
TypeScript
import { test, expect, type Page } from './fixtures';
|
|
|
|
// Phase 5: Account becomes an inset-grouped hub on mobile (Profile /
|
|
// Preferences / Change password + red Log out at the bottom) and
|
|
// /library hosts a SegmentedControl over Bookmarks / Collections /
|
|
// History. Profile-layout horizontal tabs are hidden on mobile. Desktop
|
|
// is unchanged.
|
|
|
|
const MOBILE = { width: 390, height: 844 } as const;
|
|
const DESKTOP = { width: 1280, height: 720 } as const;
|
|
|
|
async function mockSession(
|
|
page: Page,
|
|
opts: { authed?: boolean; admin?: boolean; logoutCalls?: { count: number } } = {}
|
|
) {
|
|
const authed = opts.authed ?? false;
|
|
await page.route('**/api/v1/auth/config', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
|
})
|
|
);
|
|
await page.route('**/api/v1/auth/me', (route) =>
|
|
route.fulfill({
|
|
status: authed ? 200 : 401,
|
|
contentType: 'application/json',
|
|
body: authed
|
|
? JSON.stringify({
|
|
user: {
|
|
id: 'u1',
|
|
username: 'fabian',
|
|
created_at: '2026-01-05T00:00:00Z',
|
|
is_admin: opts.admin ?? false
|
|
}
|
|
})
|
|
: JSON.stringify({
|
|
error: { code: 'unauthenticated', message: 'unauthenticated' }
|
|
})
|
|
})
|
|
);
|
|
await page.route('**/api/v1/auth/me/preferences', (route) =>
|
|
route.fulfill({
|
|
status: authed ? 200 : 401,
|
|
contentType: 'application/json',
|
|
body: authed
|
|
? JSON.stringify({
|
|
reader_mode: 'single',
|
|
reader_page_gap: 'none',
|
|
updated_at: '2026-01-05T00:00:00Z'
|
|
})
|
|
: JSON.stringify({ error: { code: 'x', message: 'x' } })
|
|
})
|
|
);
|
|
await page.route('**/api/v1/auth/logout', (route) => {
|
|
if (opts.logoutCalls) opts.logoutCalls.count += 1;
|
|
return route.fulfill({ status: 204, body: '' });
|
|
});
|
|
}
|
|
|
|
async function mockLibraryData(page: Page, opts: { authed?: boolean } = {}) {
|
|
const status = opts.authed ? 200 : 401;
|
|
const emptyPage = JSON.stringify({
|
|
items: [],
|
|
page: { limit: 50, offset: 0, total: 0 }
|
|
});
|
|
const unauth = JSON.stringify({
|
|
error: { code: 'unauthenticated', message: 'unauthenticated' }
|
|
});
|
|
await page.route('**/api/v1/me/bookmarks*', (route) =>
|
|
route.fulfill({
|
|
status,
|
|
contentType: 'application/json',
|
|
body: opts.authed ? emptyPage : unauth
|
|
})
|
|
);
|
|
await page.route('**/api/v1/me/collections*', (route) =>
|
|
route.fulfill({
|
|
status,
|
|
contentType: 'application/json',
|
|
body: opts.authed ? emptyPage : unauth
|
|
})
|
|
);
|
|
await page.route('**/api/v1/me/read-progress*', (route) =>
|
|
route.fulfill({
|
|
status,
|
|
contentType: 'application/json',
|
|
body: opts.authed ? emptyPage : unauth
|
|
})
|
|
);
|
|
// The /library load also pulls page-tags + distinct page-tags in the same
|
|
// Promise.all; leaving them unmocked 500s the whole load (caught as an
|
|
// error state, not the empty sub-tabs). General route first, the more
|
|
// specific `/distinct` after so it takes precedence for that path.
|
|
await page.route('**/api/v1/me/page-tags?*', (route) =>
|
|
route.fulfill({
|
|
status,
|
|
contentType: 'application/json',
|
|
body: opts.authed ? emptyPage : unauth
|
|
})
|
|
);
|
|
await page.route('**/api/v1/me/page-tags/distinct*', (route) =>
|
|
route.fulfill({
|
|
status,
|
|
contentType: 'application/json',
|
|
body: opts.authed ? JSON.stringify({ items: [] }) : unauth
|
|
})
|
|
);
|
|
}
|
|
|
|
test.describe('mobile library', () => {
|
|
test('phone viewport: BottomNav Library tab navigates to /library', async ({
|
|
page
|
|
}) => {
|
|
await mockSession(page);
|
|
await mockLibraryData(page);
|
|
await page.route('**/api/v1/mangas*', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
items: [],
|
|
page: { limit: 50, offset: 0, total: 0 }
|
|
})
|
|
})
|
|
);
|
|
await page.setViewportSize(MOBILE);
|
|
await page.goto('/');
|
|
|
|
await page.getByTestId('bottom-nav-library').click();
|
|
await expect(page).toHaveURL(/\/library$/);
|
|
await expect(page.getByTestId('library-tabs')).toBeVisible();
|
|
});
|
|
|
|
test('phone viewport: SegmentedControl swaps Library sub-tabs and updates ?tab=', async ({
|
|
page
|
|
}) => {
|
|
await mockSession(page, { authed: true });
|
|
await mockLibraryData(page, { authed: true });
|
|
await page.setViewportSize(MOBILE);
|
|
await page.goto('/library');
|
|
|
|
// Default sub-tab is bookmarks — URL has no `tab=` param.
|
|
await expect(page).toHaveURL(/\/library$/);
|
|
await expect(page.getByTestId('library-bookmarks-empty')).toBeVisible();
|
|
|
|
await page
|
|
.getByTestId('library-tabs')
|
|
.getByRole('radio', { name: 'Collections' })
|
|
.click();
|
|
await expect(page).toHaveURL(/\?tab=collections$/);
|
|
await expect(page.getByTestId('library-collections-empty')).toBeVisible();
|
|
|
|
await page
|
|
.getByTestId('library-tabs')
|
|
.getByRole('radio', { name: 'History' })
|
|
.click();
|
|
await expect(page).toHaveURL(/\?tab=history$/);
|
|
await expect(page.getByTestId('library-history-empty')).toBeVisible();
|
|
|
|
// Returning to Bookmarks clears the param entirely.
|
|
await page
|
|
.getByTestId('library-tabs')
|
|
.getByRole('radio', { name: 'Bookmarks' })
|
|
.click();
|
|
await expect(page).toHaveURL(/\/library$/);
|
|
});
|
|
|
|
test('phone viewport on /library unauth: sign-in prompt, no list', async ({
|
|
page
|
|
}) => {
|
|
await mockSession(page);
|
|
await mockLibraryData(page);
|
|
await page.setViewportSize(MOBILE);
|
|
await page.goto('/library');
|
|
|
|
await expect(page.getByTestId('library-signin')).toBeVisible();
|
|
await expect(page.getByTestId('library-bookmarks-empty')).toHaveCount(0);
|
|
});
|
|
});
|
|
|
|
test.describe('mobile account hub', () => {
|
|
test('phone viewport authed: inset-grouped hub renders with profile / preferences / change-password / logout rows', async ({
|
|
page
|
|
}) => {
|
|
await mockSession(page, { authed: true });
|
|
await page.setViewportSize(MOBILE);
|
|
await page.goto('/profile/account');
|
|
|
|
await expect(page.getByTestId('account-hub')).toBeVisible();
|
|
await expect(page.getByTestId('account-username')).toHaveText('fabian');
|
|
await expect(page.getByTestId('account-row-profile')).toBeVisible();
|
|
await expect(page.getByTestId('account-row-preferences')).toBeVisible();
|
|
await expect(page.getByTestId('account-row-change-password')).toBeVisible();
|
|
await expect(page.getByTestId('account-row-logout')).toBeVisible();
|
|
// A non-admin never sees the Admin entry.
|
|
await expect(page.getByTestId('account-row-admin')).toHaveCount(0);
|
|
// Desktop card MUST not render — only the hub view is active
|
|
// on mobile so we don't double-up the password form testids.
|
|
await expect(page.getByTestId('account-desktop-card')).toHaveCount(0);
|
|
});
|
|
|
|
test('phone viewport admin: the account hub offers an Admin row linking to /admin', async ({
|
|
page
|
|
}) => {
|
|
// Admin is reachable from the desktop header but had no mobile entry
|
|
// point — admins had to type the URL. The Account hub now carries it.
|
|
await mockSession(page, { authed: true, admin: true });
|
|
await page.setViewportSize(MOBILE);
|
|
await page.goto('/profile/account');
|
|
|
|
const adminRow = page.getByTestId('account-row-admin');
|
|
await expect(adminRow).toBeVisible();
|
|
await expect(adminRow).toHaveAttribute('href', '/admin');
|
|
});
|
|
|
|
test('phone viewport authed: Change password row opens the bottom sheet', async ({
|
|
page
|
|
}) => {
|
|
await mockSession(page, { authed: true });
|
|
await page.setViewportSize(MOBILE);
|
|
await page.goto('/profile/account');
|
|
|
|
await expect(page.getByTestId('password-sheet')).toBeHidden();
|
|
await page.getByTestId('account-row-change-password').click();
|
|
await expect(page.getByTestId('password-sheet')).toBeVisible();
|
|
await expect(
|
|
page.getByTestId('password-sheet').getByTestId('current-password')
|
|
).toBeVisible();
|
|
});
|
|
|
|
test('phone viewport authed: Log out row fires /auth/logout and routes to /login', async ({
|
|
page
|
|
}) => {
|
|
const logoutCalls = { count: 0 };
|
|
await mockSession(page, { authed: true, logoutCalls });
|
|
await page.setViewportSize(MOBILE);
|
|
await page.goto('/profile/account');
|
|
|
|
await page.getByTestId('account-row-logout').click();
|
|
|
|
await expect(page).toHaveURL(/\/login$/);
|
|
expect(logoutCalls.count).toBe(1);
|
|
});
|
|
|
|
test('phone viewport unauth: sign-in CTA, no hub', async ({ page }) => {
|
|
await mockSession(page);
|
|
await page.setViewportSize(MOBILE);
|
|
await page.goto('/profile/account');
|
|
|
|
await expect(page.getByTestId('account-signin')).toBeVisible();
|
|
await expect(page.getByTestId('account-hub')).toHaveCount(0);
|
|
});
|
|
|
|
test('phone viewport authed: profile horizontal tabs are hidden', async ({
|
|
page
|
|
}) => {
|
|
await mockSession(page, { authed: true });
|
|
await page.setViewportSize(MOBILE);
|
|
await page.goto('/profile/account');
|
|
|
|
await expect(page.getByTestId('tab-account')).toBeHidden();
|
|
});
|
|
|
|
test('desktop viewport authed: existing password card visible, hub is not', async ({
|
|
page
|
|
}) => {
|
|
await mockSession(page, { authed: true });
|
|
await page.setViewportSize(DESKTOP);
|
|
await page.goto('/profile/account');
|
|
|
|
await expect(page.getByTestId('account-desktop-card')).toBeVisible();
|
|
await expect(page.getByTestId('password-form')).toBeVisible();
|
|
await expect(page.getByTestId('account-hub')).toHaveCount(0);
|
|
// Desktop profile tabs remain.
|
|
await expect(page.getByTestId('tab-account')).toBeVisible();
|
|
});
|
|
});
|