Files
Mangalord/frontend/e2e/reader-mode.spec.ts
MechaCat02 f83ac67676
All checks were successful
deploy / test-backend (pull_request) Successful in 27m40s
deploy / test-frontend (pull_request) Successful in 10m19s
deploy / build-and-push (pull_request) Has been skipped
deploy / deploy (pull_request) Has been skipped
feat(reader): surface brightness on desktop, not just the mobile sheet
The brightness slider lived only in the mobile reader settings sheet, so
desktop users couldn't dim — and could be left stuck dimmed by a value a
phone had persisted (brightness is shared via localStorage and the
--reader-dim effect is form-factor agnostic). Add an inline brightness
slider to the desktop reader nav, marked `desktop-control` alongside the
existing mode toggle and gap select. It binds the same `brightness` state,
so a value set on either form factor stays in sync.

Also mocks `/me/read-progress` in the reader-mode e2e setup — without it the
GET/PUT fell through to the (absent) backend and stalled the reader load,
which is why those specs couldn't run headless. The spec is now hermetic and
covers the new desktop control (it dims/undims, and a phone-persisted value
is recoverable).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:01:05 +02:00

287 lines
10 KiB
TypeScript

import { test, expect, type Page } from '@playwright/test';
const mangaId = '22222222-2222-2222-2222-222222222222';
const chapterId = 'c2222222-2222-2222-2222-222222222222';
const mangaFixture = {
id: mangaId,
title: 'Vagabond',
author: 'Takehiko Inoue',
description: null,
cover_image_path: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z'
};
const chapterFixture = {
id: chapterId,
manga_id: mangaId,
number: 1,
title: null,
page_count: 3,
created_at: '2026-01-01T00:00:00Z'
};
const pagesFixture = [
{
id: 'p1111111-2222-2222-2222-222222222222',
chapter_id: chapterId,
page_number: 1,
storage_key: `mangas/${mangaId}/chapters/${chapterId}/pages/0001.png`,
content_type: 'image/png'
},
{
id: 'p2222222-2222-2222-2222-222222222222',
chapter_id: chapterId,
page_number: 2,
storage_key: `mangas/${mangaId}/chapters/${chapterId}/pages/0002.png`,
content_type: 'image/png'
},
{
id: 'p3333333-2222-2222-2222-222222222222',
chapter_id: chapterId,
page_number: 3,
storage_key: `mangas/${mangaId}/chapters/${chapterId}/pages/0003.png`,
content_type: 'image/png'
}
];
async function mockReaderApis(page: Page) {
// Anonymous user — both `me` and preferences fall back to localStorage.
await page.route('**/api/v1/auth/me', (route) =>
route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'unauthenticated', message: '' } })
})
);
await page.route('**/api/v1/auth/me/preferences', (route) =>
route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'unauthenticated', message: '' } })
})
);
await page.route('**/api/v1/me/bookmarks*', (route) =>
route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'unauthenticated', message: '' } })
})
);
// Anonymous reader: read-progress reads/writes are unauthenticated. Without
// this the GET/PUT fall through to the (absent) backend and stall the load.
await page.route('**/api/v1/me/read-progress/**', (route) =>
route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'unauthenticated', message: '' } })
})
);
await page.route(`**/api/v1/mangas/${mangaId}`, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mangaFixture)
})
);
await page.route(`**/api/v1/mangas/${mangaId}/chapters?*`, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [chapterFixture],
page: { limit: 50, offset: 0, total: null }
})
})
);
await page.route(`**/api/v1/mangas/${mangaId}/chapters`, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [chapterFixture],
page: { limit: 50, offset: 0, total: null }
})
})
);
await page.route(`**/api/v1/mangas/${mangaId}/chapters/${chapterId}`, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(chapterFixture)
})
);
await page.route(
`**/api/v1/mangas/${mangaId}/chapters/${chapterId}/pages`,
(route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ pages: pagesFixture })
})
);
const png = Buffer.from(
'89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63000100000005000158a3b62a0000000049454e44ae426082',
'hex'
);
await page.route('**/api/v1/files/**', (route) =>
route.fulfill({ status: 200, contentType: 'image/png', body: png })
);
}
test.beforeEach(async ({ context }) => {
// Clear the localStorage shadow so each test starts in the default
// single-page mode regardless of order.
await context.clearCookies();
await context.addInitScript(() => {
try {
localStorage.removeItem('mangalord-reader-mode');
localStorage.removeItem('mangalord-reader-gap');
localStorage.removeItem('mangalord-reader-brightness');
} catch {
// ignore — about:blank doesn't expose localStorage yet.
}
});
});
test('switching to continuous mode stacks all pages and hides chevrons', async ({ page }) => {
await mockReaderApis(page);
await page.goto(`/manga/${mangaId}/chapter/${chapterId}`);
// Default single-page mode is active.
await expect(page.getByTestId('reader-page')).toBeVisible();
await expect(page.getByTestId('page-indicator')).toHaveText('Page 1 / 3');
await page.getByTestId('reader-mode-continuous').click();
await expect(page.getByTestId('reader-continuous')).toBeVisible();
await expect(page.getByTestId('reader-page-1')).toBeVisible();
await expect(page.getByTestId('reader-page-3')).toBeVisible();
await expect(page.getByTestId('reader-prev')).toHaveCount(0);
await expect(page.getByTestId('reader-next')).toHaveCount(0);
await expect(page.getByTestId('page-indicator')).toHaveText('3 pages');
});
test('arrow keys do not paginate while in continuous mode', async ({ page }) => {
await mockReaderApis(page);
await page.goto(`/manga/${mangaId}/chapter/${chapterId}`);
await page.getByTestId('reader-mode-continuous').click();
await expect(page.getByTestId('reader-continuous')).toBeVisible();
await page.keyboard.press('ArrowRight');
await page.keyboard.press('j');
// Still in continuous mode, still showing every page.
await expect(page.getByTestId('reader-continuous')).toBeVisible();
await expect(page.getByTestId('reader-page-1')).toBeVisible();
await expect(page.getByTestId('reader-page-3')).toBeVisible();
});
test('gap select updates the inline gap on the continuous container', async ({ page }) => {
await mockReaderApis(page);
await page.goto(`/manga/${mangaId}/chapter/${chapterId}`);
await page.getByTestId('reader-mode-continuous').click();
const container = page.getByTestId('reader-continuous');
// Default gap is "none" → 0px.
await expect(container).toHaveAttribute('style', /gap:\s*0px/);
await page.getByTestId('reader-gap').selectOption('medium');
await expect(container).toHaveAttribute('style', /gap:\s*32px/);
await page.getByTestId('reader-gap').selectOption('large');
await expect(container).toHaveAttribute('style', /gap:\s*64px/);
});
test('desktop reader nav exposes a brightness control that dims and undims the reader', async ({
page
}) => {
// The brightness slider used to live only in the mobile settings sheet,
// so desktop users couldn't dim — and could be stuck dimmed by a value a
// phone had persisted. The desktop nav now carries its own slider.
await mockReaderApis(page);
await page.goto(`/manga/${mangaId}/chapter/${chapterId}`);
const slider = page.getByTestId('reader-brightness-desktop');
await expect(slider).toBeVisible();
const dim = () =>
page.evaluate(() =>
document.documentElement.style.getPropertyValue('--reader-dim')
);
// Default brightness is full → no dim overlay.
await expect.poll(async () => Number(await dim())).toBe(0);
// Dimming raises the overlay opacity…
await slider.fill('0.3');
await expect.poll(async () => Number(await dim())).toBeGreaterThan(0);
// …and restoring it clears the dim, so a desktop user is never stuck.
await slider.fill('1');
await expect.poll(async () => Number(await dim())).toBe(0);
});
test('a brightness value persisted by a phone is recoverable on desktop', async ({
page,
context
}) => {
// Seed a dimmed value as if set on mobile, then open the reader on a
// desktop viewport: the dim applies and the desktop control reflects it.
await context.addInitScript(() => {
try {
localStorage.setItem('mangalord-reader-brightness', '0.3');
} catch {
// about:blank — ignore
}
});
await mockReaderApis(page);
await page.goto(`/manga/${mangaId}/chapter/${chapterId}`);
const slider = page.getByTestId('reader-brightness-desktop');
await expect(slider).toHaveValue('0.3');
await expect
.poll(async () =>
Number(
await page.evaluate(() =>
document.documentElement.style.getPropertyValue('--reader-dim')
)
)
)
.toBeGreaterThan(0);
});
test('reader-mode preference set on one page is honored when the reader opens', async ({
page,
context
}) => {
// Simulate a user who has already chosen continuous + medium on the
// settings page (which writes through the preferences store to
// localStorage). The reader should pick up that choice on first
// render without any in-tab navigation.
await context.addInitScript(() => {
localStorage.setItem('mangalord-reader-mode', 'continuous');
localStorage.setItem('mangalord-reader-gap', 'medium');
});
await mockReaderApis(page);
await page.goto(`/manga/${mangaId}/chapter/${chapterId}`);
await expect(page.getByTestId('reader-continuous')).toBeVisible();
await expect(page.getByTestId('page-indicator')).toHaveText('3 pages');
await expect(page.getByTestId('reader-continuous')).toHaveAttribute(
'style',
/gap:\s*32px/
);
});
test('preferences page hides the gap picker while in single-page mode', async ({ page }) => {
// Visually verifies the conditional render. The radio-click semantics
// are exercised in src/lib/preferences.svelte.test.ts; the visible
// mode toggle in the reader top bar covers the cross-route propagation
// path in the test above.
await mockReaderApis(page);
await page.goto('/profile/preferences');
await expect(page.getByTestId('reader-mode-radio-single')).toBeAttached();
await expect(page.getByTestId('reader-mode-radio-continuous')).toBeAttached();
await expect(page.getByTestId('reader-gap-radio-medium')).toHaveCount(0);
});