`.resync-msg` (the admin Force-resync result) was display:none under 640px, so mobile admins — who trigger resync from the overflow sheet — got no success or error feedback. The element is already a sibling of `.action-row` (not a child), so it survives the row being hidden; the only thing hiding it was an explicit mobile rule. Drop that rule. e2e: a mobile admin triggers Force resync from the overflow sheet and now sees the "Metadata updated" result (written test-first against the hidden element, then unhidden). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
333 lines
12 KiB
TypeScript
333 lines
12 KiB
TypeScript
import { test, expect, type Page } from '@playwright/test';
|
|
|
|
// Phase 3: the manga detail page gains a mobile hero (blurred backdrop
|
|
// + transparent app bar), a sticky bottom CTA whose wording reflects
|
|
// read progress, a 3-line description clamp with Read more, and an
|
|
// overflow Sheet that hosts secondary actions (Edit / Upload chapter /
|
|
// Add to collection / Force resync). Desktop layout is preserved.
|
|
|
|
const MOBILE = { width: 390, height: 844 } as const;
|
|
const DESKTOP = { width: 1280, height: 720 } as const;
|
|
|
|
const mangaId = 'a1111111-1111-1111-1111-111111111111';
|
|
const firstChapterId = 'c1111111-1111-1111-1111-111111111111';
|
|
const latestChapterId = 'c2222222-2222-2222-2222-222222222222';
|
|
|
|
// Chapters arrive newest-first from the API (source_index DESC) — the
|
|
// detail page's "Read first chapter" CTA targets the last element.
|
|
const chaptersFixture = [
|
|
{
|
|
id: latestChapterId,
|
|
manga_id: mangaId,
|
|
number: 2,
|
|
title: 'Second',
|
|
page_count: 10,
|
|
created_at: '2026-02-01T00:00:00Z'
|
|
},
|
|
{
|
|
id: firstChapterId,
|
|
manga_id: mangaId,
|
|
number: 1,
|
|
title: 'The Brand',
|
|
page_count: 8,
|
|
created_at: '2026-01-01T00:00:00Z'
|
|
}
|
|
];
|
|
|
|
const shortDescription = 'A brief synopsis.';
|
|
const longDescription =
|
|
'This is an extended description that runs over multiple lines so that ' +
|
|
'the clamp + Read more behavior on mobile has actual content to truncate. ' +
|
|
'Long-form descriptions are common for manga that have been crawled from ' +
|
|
'public sources with editorial blurbs, so the catalog has to handle them ' +
|
|
'gracefully without pushing the chapter list below the fold on phones.';
|
|
|
|
function mangaFixture(description: string = shortDescription) {
|
|
return {
|
|
id: mangaId,
|
|
title: 'Berserk',
|
|
status: 'ongoing',
|
|
alt_titles: [],
|
|
description,
|
|
cover_image_path: `mangas/${mangaId}/cover.png`,
|
|
created_at: '2026-01-01T00:00:00Z',
|
|
updated_at: '2026-01-01T00:00:00Z',
|
|
authors: [{ id: 'au1', name: 'Kentaro Miura' }],
|
|
genres: [],
|
|
tags: []
|
|
};
|
|
}
|
|
|
|
async function mockDetail(
|
|
page: Page,
|
|
opts: {
|
|
description?: string;
|
|
readProgress?: {
|
|
chapter_id: string;
|
|
chapter_number: number;
|
|
page: number;
|
|
} | null;
|
|
authed?: boolean;
|
|
similar?: Array<Record<string, unknown>>;
|
|
} = {}
|
|
) {
|
|
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: 'reader',
|
|
created_at: '2026-01-01T00:00:00Z',
|
|
is_admin: false
|
|
}
|
|
})
|
|
: JSON.stringify({
|
|
error: { code: 'unauthenticated', message: 'unauthenticated' }
|
|
})
|
|
})
|
|
);
|
|
await page.route('**/api/v1/auth/me/preferences', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({})
|
|
})
|
|
);
|
|
await page.route('**/api/v1/me/bookmarks*', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
|
|
})
|
|
);
|
|
await page.route(`**/api/v1/mangas/${mangaId}`, (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mangaFixture(opts.description))
|
|
})
|
|
);
|
|
await page.route(`**/api/v1/mangas/${mangaId}/chapters*`, (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
items: chaptersFixture,
|
|
page: { limit: 50, offset: 0, total: chaptersFixture.length }
|
|
})
|
|
})
|
|
);
|
|
await page.route(`**/api/v1/mangas/${mangaId}/similar`, (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ items: opts.similar ?? [] })
|
|
})
|
|
);
|
|
await page.route(`**/api/v1/me/read-progress/${mangaId}`, (route) =>
|
|
route.fulfill({
|
|
status: opts.readProgress ? 200 : 404,
|
|
contentType: 'application/json',
|
|
body: opts.readProgress
|
|
? JSON.stringify(opts.readProgress)
|
|
: JSON.stringify({ error: { code: 'not_found', message: 'not found' } })
|
|
})
|
|
);
|
|
// 1x1 transparent PNG so cover image requests don't 404 noisily.
|
|
const png = Buffer.from(
|
|
'89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63000100000005000158a3b62a0000000049454e44ae426082',
|
|
'hex'
|
|
);
|
|
await page.route('**/api/v1/files/**', (route) =>
|
|
route.fulfill({ status: 200, contentType: 'image/png', body: png })
|
|
);
|
|
}
|
|
|
|
test.describe('mobile manga detail', () => {
|
|
test('phone viewport: with no progress, CTA reads "Read first chapter" and links to the oldest chapter', async ({
|
|
page
|
|
}) => {
|
|
await mockDetail(page);
|
|
await page.setViewportSize(MOBILE);
|
|
await page.goto(`/manga/${mangaId}`);
|
|
|
|
await expect(page.getByTestId('mobile-hero')).toBeVisible();
|
|
|
|
const cta = page.getByTestId('continue-cta');
|
|
await expect(cta).toHaveText('Read first chapter');
|
|
await expect(cta).toHaveAttribute(
|
|
'href',
|
|
`/manga/${mangaId}/chapter/${firstChapterId}`
|
|
);
|
|
});
|
|
|
|
test('phone viewport: with progress, CTA reads "Continue {chapterLabel}" and links to the in-progress chapter', async ({
|
|
page
|
|
}) => {
|
|
await mockDetail(page, {
|
|
authed: true,
|
|
readProgress: { chapter_id: latestChapterId, chapter_number: 2, page: 5 }
|
|
});
|
|
await page.setViewportSize(MOBILE);
|
|
await page.goto(`/manga/${mangaId}`);
|
|
|
|
const cta = page.getByTestId('continue-cta');
|
|
// chapterLabel returns the title when present, falling back to
|
|
// "Chapter N" only when empty — the fixture has title "Second".
|
|
await expect(cta).toHaveText(/Continue\s+Second/);
|
|
await expect(cta).toHaveAttribute(
|
|
'href',
|
|
`/manga/${mangaId}/chapter/${latestChapterId}`
|
|
);
|
|
});
|
|
|
|
test('phone viewport: short description shows no Read more toggle', async ({ page }) => {
|
|
await mockDetail(page, { description: shortDescription });
|
|
await page.setViewportSize(MOBILE);
|
|
await page.goto(`/manga/${mangaId}`);
|
|
|
|
await expect(page.getByTestId('manga-description')).toBeVisible();
|
|
await expect(page.getByTestId('read-more-toggle')).toHaveCount(0);
|
|
});
|
|
|
|
test('phone viewport: long description shows Read more, expands on tap, collapses on second tap', async ({
|
|
page
|
|
}) => {
|
|
await mockDetail(page, { description: longDescription });
|
|
await page.setViewportSize(MOBILE);
|
|
await page.goto(`/manga/${mangaId}`);
|
|
|
|
const toggle = page.getByTestId('read-more-toggle');
|
|
await expect(toggle).toHaveText('Read more');
|
|
|
|
await toggle.click();
|
|
await expect(toggle).toHaveText('Read less');
|
|
|
|
await toggle.click();
|
|
await expect(toggle).toHaveText('Read more');
|
|
});
|
|
|
|
test('phone viewport: overflow ⋯ opens the actions sheet with Edit / Upload / Add-to-collection rows', async ({
|
|
page
|
|
}) => {
|
|
await mockDetail(page, { authed: true });
|
|
await page.setViewportSize(MOBILE);
|
|
await page.goto(`/manga/${mangaId}`);
|
|
|
|
await expect(page.getByTestId('detail-overflow-sheet')).toBeHidden();
|
|
await page.getByTestId('detail-overflow').click();
|
|
|
|
const sheet = page.getByTestId('detail-overflow-sheet');
|
|
await expect(sheet).toBeVisible();
|
|
await expect(sheet.getByTestId('overflow-edit')).toBeVisible();
|
|
await expect(sheet.getByTestId('overflow-upload-chapter')).toBeVisible();
|
|
await expect(sheet.getByTestId('overflow-add-to-collection')).toBeVisible();
|
|
});
|
|
|
|
test('phone viewport: force resync from the overflow sheet surfaces a result message', async ({
|
|
page
|
|
}) => {
|
|
await mockDetail(page, { authed: true });
|
|
// Upgrade the session to admin so the Force resync action is offered.
|
|
// Registered after mockDetail so this auth/me wins (Playwright routes
|
|
// resolve most-recently-registered first).
|
|
await page.route('**/api/v1/auth/me', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
user: {
|
|
id: 'u1',
|
|
username: 'admin',
|
|
created_at: '2026-01-01T00:00:00Z',
|
|
is_admin: true
|
|
}
|
|
})
|
|
})
|
|
);
|
|
await page.route(`**/api/v1/admin/mangas/${mangaId}/resync`, (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
manga: mangaFixture(),
|
|
cover_fetched: true,
|
|
metadata_status: 'updated'
|
|
})
|
|
})
|
|
);
|
|
await page.setViewportSize(MOBILE);
|
|
await page.goto(`/manga/${mangaId}`);
|
|
|
|
await page.getByTestId('detail-overflow').click();
|
|
await page.getByTestId('overflow-force-resync').click();
|
|
|
|
// The result was previously display:none under 640px, leaving mobile
|
|
// admins with no feedback. It must now be visible.
|
|
const msg = page.getByTestId('force-resync-message');
|
|
await expect(msg).toBeVisible();
|
|
await expect(msg).toContainText(/Metadata updated/i);
|
|
});
|
|
|
|
test('desktop viewport: mobile hero is hidden, existing layout + action-row stays', async ({
|
|
page
|
|
}) => {
|
|
await mockDetail(page, { authed: true });
|
|
await page.setViewportSize(DESKTOP);
|
|
await page.goto(`/manga/${mangaId}`);
|
|
|
|
// Hero block exists in DOM but is hidden by CSS at >640px.
|
|
await expect(page.getByTestId('mobile-hero')).toBeHidden();
|
|
// The desktop title/cover/action surfaces remain visible.
|
|
await expect(page.getByTestId('manga-title')).toBeVisible();
|
|
await expect(page.getByTestId('bookmark-toggle')).toBeVisible();
|
|
});
|
|
|
|
test('renders the Similar section with recommended cards when present', async ({
|
|
page
|
|
}) => {
|
|
await mockDetail(page, {
|
|
similar: [
|
|
{
|
|
id: 'b2222222-2222-2222-2222-222222222222',
|
|
title: 'Vinland Saga',
|
|
status: 'ongoing',
|
|
alt_titles: [],
|
|
description: null,
|
|
cover_image_path: null,
|
|
created_at: '2026-01-01T00:00:00Z',
|
|
updated_at: '2026-01-01T00:00:00Z',
|
|
authors: [{ id: 'au2', name: 'Makoto Yukimura' }],
|
|
genres: []
|
|
}
|
|
]
|
|
});
|
|
await page.goto(`/manga/${mangaId}`);
|
|
|
|
const section = page.getByTestId('similar-section');
|
|
await expect(section).toBeVisible();
|
|
await expect(section.getByText('Vinland Saga')).toBeVisible();
|
|
});
|
|
|
|
test('omits the Similar section when there are no recommendations', async ({ page }) => {
|
|
await mockDetail(page, { similar: [] });
|
|
await page.goto(`/manga/${mangaId}`);
|
|
|
|
// The chapter list is the anchor that the page has rendered.
|
|
await expect(page.getByTestId('manga-title')).toBeVisible();
|
|
await expect(page.getByTestId('similar-section')).toHaveCount(0);
|
|
});
|
|
});
|