Files
Mangalord/frontend/e2e/page-context-menu.spec.ts
MechaCat02 92db3ce324
All checks were successful
deploy / test-backend (pull_request) Successful in 28m26s
deploy / test-frontend (pull_request) Successful in 10m22s
deploy / build-and-push (pull_request) Has been skipped
deploy / deploy (pull_request) Has been skipped
feat(ui): present page-action editors as sheets on mobile
The Add-tags and Add-to-collection editors opened as centered Modals on
every form factor, so a mobile user went action-Sheet → centered Modal — an
inconsistent hop, and AddTagsSheet's docstring promised a sheet it never
got. Introduce AdaptiveDialog, which renders a bottom Sheet under 640px and
a centered Modal above it behind one shared contract (open / title / onClose
/ children / testid). Route both editors through it:

- AddToCollectionModal now wraps AdaptiveDialog (both its call sites — manga
  detail and the reader — adapt automatically).
- The reader hosts AddTagsSheet in an AdaptiveDialog instead of a Modal, so
  its "slots into a Sheet (mobile) or Modal (desktop)" contract is now true.

Modal/Sheet already share an API, so the switch is transparent: the dialog
keeps the same role, title, testid, and close affordance. Desktop is
unchanged.

AdaptiveDialog has its own unit test (Sheet on mobile, Modal on desktop);
an e2e proves the mobile long-press → Add-tag flow now opens a Sheet.

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

385 lines
14 KiB
TypeScript

import { test, expect, type Page } from '@playwright/test';
// E2E for the per-page collection + tag flow added in v0.61.0. Mocks
// the entire API so the spec runs without a backend. The same
// fixtures + mockReader scaffolding as `mobile-reader.spec.ts`;
// kept inline so the file stays self-contained.
const MOBILE = { width: 390, height: 844 } as const;
const DESKTOP = { width: 1280, height: 720 } as const;
const mangaId = 'a9999999-9999-9999-9999-999999999999';
const chapterId = 'c9999999-9999-9999-9999-999999999999';
const pageId = 'p11111111-1111-1111-1111-111111111111';
const collectionId = 'cc111111-1111-1111-1111-111111111111';
const mangaFixture = {
id: mangaId,
title: 'Berserk',
status: 'ongoing',
alt_titles: [],
description: null,
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: []
};
const chapterFixture = {
id: chapterId,
manga_id: mangaId,
number: 1,
title: 'The Brand',
page_count: 1,
created_at: '2026-01-01T00:00:00Z'
};
const pagesFixture = [
{
id: pageId,
chapter_id: chapterId,
page_number: 1,
storage_key: `mangas/${mangaId}/chapters/${chapterId}/pages/0001.png`,
content_type: 'image/png'
}
];
const userFixture = {
id: 'u11111111-1111-1111-1111-111111111111',
username: 'tester',
created_at: '2026-01-01T00:00:00Z',
is_admin: false
};
const collectionFixture = {
id: collectionId,
user_id: userFixture.id,
name: 'Favorite panels',
description: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
manga_count: 0,
sample_covers: []
};
/**
* Wire the full mock surface for the reader plus the new
* `pages/:id/my-collections`, `pages/:id/my-tags`, `me/collections`,
* and `collections/:id/pages` endpoints. `myCollectionsState` lets a
* test mutate what /my-collections returns mid-flow so the "re-open
* menu after add → In 1 collection" assertion is exercised.
*/
async function mockReader(
page: Page,
state: {
collectionsContainingPage: string[];
tagsOnPage: string[];
admin?: boolean;
analyzeCalls?: number;
}
) {
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: 200,
contentType: 'application/json',
body: JSON.stringify({
user: { ...userFixture, is_admin: state.admin ?? false }
})
})
);
// Admin force-analyze endpoint — counts invocations for assertions.
await page.route(`**/api/v1/admin/pages/${pageId}/analyze`, (route) => {
state.analyzeCalls = (state.analyzeCalls ?? 0) + 1;
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ enqueued: true })
});
});
await page.route('**/api/v1/auth/me/preferences', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
reader_mode: 'single',
reader_page_gap: 'small'
})
})
);
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)
})
);
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: 1 }
})
})
);
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: 1 }
})
})
);
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 })
})
);
await page.route(`**/api/v1/me/read-progress/${mangaId}`, (route) =>
route.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'not_found', message: 'not found' } })
})
);
// The new endpoints — these are what the context menu hits.
await page.route(`**/api/v1/pages/${pageId}/my-collections`, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ collection_ids: state.collectionsContainingPage })
})
);
await page.route(`**/api/v1/pages/${pageId}/my-tags`, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ tags: state.tagsOnPage })
})
);
await page.route('**/api/v1/me/collections*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [collectionFixture],
page: { limit: 200, offset: 0, total: 1 }
})
})
);
await page.route(
`**/api/v1/collections/${collectionId}/pages`,
async (route) => {
if (route.request().method() === 'POST') {
state.collectionsContainingPage = [collectionId];
return route.fulfill({ status: 201, body: '' });
}
return route.continue();
}
);
const png = Buffer.from(
'89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63000100000005000158a3b62a0000000049454e44ae426082',
'hex'
);
await page.route('**/api/v1/files/**', (route) =>
route.fulfill({ status: 200, contentType: 'image/png', body: png })
);
}
test.describe('page context menu (desktop right-click)', () => {
test('right-click opens the menu with empty-state hints', async ({ page }) => {
const state = { collectionsContainingPage: [], tagsOnPage: [] };
await mockReader(page, state);
await page.setViewportSize(DESKTOP);
await page.goto(`/manga/${mangaId}/chapter/${chapterId}`);
await expect(page.getByTestId('reader-page')).toBeVisible();
await expect(page.getByTestId('page-context-menu')).toBeHidden();
await page.getByTestId('reader-page').click({ button: 'right' });
const menu = page.getByTestId('page-context-menu');
await expect(menu).toBeVisible();
await expect(
page.getByTestId('page-context-collections-line')
).toHaveText('Not in any collection');
await expect(page.getByTestId('page-context-tags-line')).toHaveText(
'No tags yet'
);
});
test('Escape closes the menu', async ({ page }) => {
await mockReader(page, { collectionsContainingPage: [], tagsOnPage: [] });
await page.setViewportSize(DESKTOP);
await page.goto(`/manga/${mangaId}/chapter/${chapterId}`);
await page.getByTestId('reader-page').click({ button: 'right' });
await expect(page.getByTestId('page-context-menu')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByTestId('page-context-menu')).toBeHidden();
});
test('add-to-collection → modal → toggle → re-open menu shows "In 1 collection"', async ({
page
}) => {
const state = { collectionsContainingPage: [], tagsOnPage: [] };
await mockReader(page, state);
await page.setViewportSize(DESKTOP);
await page.goto(`/manga/${mangaId}/chapter/${chapterId}`);
await page.getByTestId('reader-page').click({ button: 'right' });
await page.getByTestId('page-context-add-to-collection').click();
// Modal opens with the user's one collection unchecked.
const modal = page.getByTestId('add-to-collection-modal');
await expect(modal).toBeVisible();
const checkbox = modal.getByTestId(`collection-toggle-${collectionId}`);
await expect(checkbox).not.toBeChecked();
await checkbox.check();
// The mock flips containing-page state on POST. Close + re-open
// the menu to verify the contextual line reflects the new
// server state.
await page.keyboard.press('Escape');
await expect(modal).toBeHidden();
await page.getByTestId('reader-page').click({ button: 'right' });
await expect(page.getByTestId('page-context-collections-line')).toHaveText(
'In 1 collection'
);
});
test('non-admin does not see the Queue for analysis action', async ({ page }) => {
await mockReader(page, { collectionsContainingPage: [], tagsOnPage: [] });
await page.setViewportSize(DESKTOP);
await page.goto(`/manga/${mangaId}/chapter/${chapterId}`);
await page.getByTestId('reader-page').click({ button: 'right' });
await expect(page.getByTestId('page-context-menu')).toBeVisible();
await expect(page.getByTestId('page-context-analyze')).toBeHidden();
});
test('admin can queue the page for analysis from the menu', async ({ page }) => {
const state = {
collectionsContainingPage: [],
tagsOnPage: [],
admin: true,
analyzeCalls: 0
};
await mockReader(page, state);
await page.setViewportSize(DESKTOP);
await page.goto(`/manga/${mangaId}/chapter/${chapterId}`);
await page.getByTestId('reader-page').click({ button: 'right' });
const item = page.getByTestId('page-context-analyze');
await expect(item).toBeVisible();
await expect(item).toContainText('Queue for analysis');
await item.click();
await expect(item).toContainText('Queued for analysis');
expect(state.analyzeCalls).toBe(1);
});
test('Shift+right-click falls through to the browser native menu', async ({
page
}) => {
await mockReader(page, { collectionsContainingPage: [], tagsOnPage: [] });
await page.setViewportSize(DESKTOP);
await page.goto(`/manga/${mangaId}/chapter/${chapterId}`);
// Hold Shift while right-clicking. The reader's
// `oncontextmenu` returns early on shiftKey, so the in-app
// menu must NOT open. (Playwright doesn't surface the
// native browser menu, but we can assert ours stays hidden.)
await page.keyboard.down('Shift');
await page.getByTestId('reader-page').click({ button: 'right' });
await page.keyboard.up('Shift');
await expect(page.getByTestId('page-context-menu')).toBeHidden();
});
});
test.describe('page action sheet (mobile long-press)', () => {
test('long-press on a page image opens the action sheet', async ({ page }) => {
await mockReader(page, { collectionsContainingPage: [], tagsOnPage: [] });
await page.setViewportSize(MOBILE);
await page.goto(`/manga/${mangaId}/chapter/${chapterId}`);
// Switch to continuous mode so the per-image long-press
// handler is wired (single mode goes through TapZone). Both
// codepaths funnel into the same Sheet, but the per-image
// wiring is the riskier of the two — it's the one the audit
// flagged for the multitouch fix.
await page.getByTestId('reader-settings-btn').click();
await page
.getByTestId('reader-settings-sheet')
.getByRole('radio', { name: 'Continuous' })
.click();
// Close the settings sheet so its scrim isn't blocking.
await page.keyboard.press('Escape');
const pageEl = page.getByTestId('reader-page-1');
await expect(pageEl).toBeVisible();
// Synthesize a touch pointerdown, wait past the 450ms timer.
const box = await pageEl.boundingBox();
if (!box) throw new Error('page image has no bounding box');
const cx = box.x + box.width / 2;
const cy = box.y + box.height / 2;
await pageEl.dispatchEvent('pointerdown', {
pointerType: 'touch',
clientX: cx,
clientY: cy,
bubbles: true
});
await page.waitForTimeout(550);
await expect(page.getByTestId('page-action-sheet')).toBeVisible();
await expect(page.getByTestId('page-action-add-to-collection')).toBeVisible();
await expect(page.getByTestId('page-action-add-tag')).toBeVisible();
await expect(page.getByTestId('page-action-save-image')).toBeVisible();
await expect(page.getByTestId('page-action-copy-link')).toBeVisible();
// The editor itself opens as a bottom Sheet on mobile (not a centered
// Modal), so the action-sheet → editor flow stays one consistent
// idiom. Sheet renders a `${testid}-scrim`; Modal a `${testid}-backdrop`.
await page.getByTestId('page-action-add-tag').click();
await expect(page.getByTestId('add-tags-modal-scrim')).toBeVisible();
await expect(page.getByTestId('add-tags-modal-backdrop')).toHaveCount(0);
});
});