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>
385 lines
14 KiB
TypeScript
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);
|
|
});
|
|
});
|