test(e2e): add a Playwright web e2e lane (reading & annotation flows) (#4214)
* feat(e2e): add Playwright web e2e lane Adds a web-layer end-to-end suite that drives the Next.js web build (`pnpm dev-web`) in a real browser, complementing the existing WebdriverIO suite that drives the Tauri shell. - playwright.config.ts: single Chromium project, auto-starts dev-web - e2e/pages: BasePage/LibraryPage/ReaderPage page objects - e2e/fixtures/base.ts: suppresses demo-book auto-import for a deterministic empty library - e2e/tests: library shell + search, book import, reader open + pagination smoke specs - e2e/fixtures/books: synthetic sample book for import tests - scripts: test:e2e:web, test:e2e:web:ui, test:e2e:web:report Tests run unauthenticated against isolated browser contexts; authenticated/sync flows are out of scope until a test account is provisioned. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(e2e): cover reading and annotation flows Expands the Playwright web e2e lane beyond library/import smoke tests to exercise the major reading and annotation features against the real sample-alice.epub fixture (src/__tests__/fixtures/data/). Reading (reading.spec.ts): open + page turn, TOC chapter navigation, in-book search, font-size change via the settings dialog, bookmark toggle. Annotation (annotation.spec.ts): selection popup, create highlight, change highlight color, add a note, delete an annotation. - ReaderPage POM gains sidebar/TOC, search, settings, bookmark and annotation actions; text selection is driven inside the section iframe (synthetic drags do not produce a selection through nested paginated foliate iframes) - openBook fixture imports and opens a book so specs skip boilerplate - books.ts centralises fixture book paths - replaces the old reader.spec.ts smoke Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(e2e): add headed run script and always write HTML report - test:e2e:web:headed runs the suite in a visible browser, one test at a time, with traces captured - the HTML reporter now runs for local runs too, so every run writes playwright-report/ for test:e2e:web:report to open Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(e2e): fix headed-run flakes in reading and annotation specs The headed run (slower rendering) surfaced two races that the headless run happened to pass: - TOC navigation read reading progress before the section's async progress update landed — now polls with expect.poll. - visibleSectionFrame required a paragraph fully inside the viewport, which intermittently matched nothing — now accepts any paragraph intersecting the viewport and tolerates frames detaching mid-navigation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci: run the Playwright web e2e suite in test_web_app Adds `pnpm test:e2e:web` to the test_web_app job, after the unit/browser tests. The job already installs the Chromium browser, and `.env.web` is committed so the auto-started `pnpm dev-web` server has its config. On failure the HTML report is uploaded as an artifact. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(e2e): exclude e2e specs from the vitest run vitest's default glob matches `*.spec.ts`, so it picked up the new Playwright `e2e/tests/*.spec.ts` files and crashed. Exclude `e2e/` from vitest — those specs run via `pnpm test:e2e:web`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(e2e): run the web e2e suite against a production build `next dev` renders a full-screen error overlay when the app emits its `next-view-transitions` "Transition was aborted" unhandled rejection, and the overlay intercepts pointer events — making the suite flaky on CI. CI now builds the web app (`pnpm build-web`) and the Playwright webServer serves it via `pnpm start-web`; local runs still use `pnpm dev-web`. Verified: 14/14 pass against the production build. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(e2e): run the web e2e suite in the build_web_app job build_web_app already runs `pnpm build-web`, so the e2e suite belongs there — it reuses that build (the CI Playwright webServer serves it via `pnpm start-web`) instead of building a second time in test_web_app. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(e2e): run the web e2e suite with 4 workers Specs are isolated (a fresh browser context per test), so they are safe to parallelize. `test:e2e:web:headed` keeps --workers=1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -89,6 +89,23 @@ jobs:
|
||||
run: |
|
||||
pnpm build-web && pnpm check:all
|
||||
|
||||
- name: install playwright browsers
|
||||
working-directory: apps/readest-app
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: run web e2e tests
|
||||
id: web_e2e
|
||||
working-directory: apps/readest-app
|
||||
run: pnpm test:e2e:web
|
||||
|
||||
- name: upload e2e report
|
||||
if: ${{ failure() && steps.web_e2e.outcome == 'failure' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: apps/readest-app/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
test_web_app:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -71,3 +71,9 @@ src-tauri/gen
|
||||
.claude/settings.local.json
|
||||
.claude/skills
|
||||
|
||||
# Playwright web e2e
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# End-to-end tests
|
||||
|
||||
Readest has two end-to-end lanes. They cover different layers and are run
|
||||
separately.
|
||||
|
||||
## Web lane — Playwright
|
||||
|
||||
Drives the Next.js **web** build (`pnpm dev-web`) in a real browser. Fast, no
|
||||
Rust build required. Tests run unauthenticated against a fresh browser
|
||||
context, so each test starts from an isolated, empty local library.
|
||||
|
||||
```bash
|
||||
pnpm test:e2e:web # run the web e2e suite (auto-starts pnpm dev-web)
|
||||
pnpm test:e2e:web:headed # run headed, one test at a time, with traces
|
||||
pnpm test:e2e:web:ui # run in the Playwright UI mode
|
||||
pnpm test:e2e:web:report # open the last HTML report
|
||||
```
|
||||
|
||||
Every run writes an HTML report to `playwright-report/`; open it with
|
||||
`pnpm test:e2e:web:report`.
|
||||
|
||||
Layout:
|
||||
|
||||
| Path | Purpose |
|
||||
| --------------------------------- | ------------------------------------------------------ |
|
||||
| `playwright.config.ts` (app root) | Runner config, projects, web server. |
|
||||
| `e2e/tests/` | Specs (`*.spec.ts`). |
|
||||
| `e2e/pages/` | Page Object Model — actions/queries, no assertions. |
|
||||
| `e2e/fixtures/` | Shared fixtures; `fixtures/books/` holds sample books. |
|
||||
|
||||
Page objects expose locators and actions; assertions stay in the specs so
|
||||
failures point at test intent. To add coverage, prefer extending a page
|
||||
object over inlining selectors in a spec.
|
||||
|
||||
The demo-book auto-import (`useDemoBooks`) is suppressed by the base fixture
|
||||
so the library is deterministic; authenticated/sync flows are out of scope
|
||||
for this lane until a test account is provisioned.
|
||||
|
||||
## Tauri lane — WebdriverIO
|
||||
|
||||
Drives the actual **Tauri** desktop shell via `tauri-driver`. Use this for
|
||||
coverage that depends on the native build (Rust integration, window
|
||||
management, platform globals).
|
||||
|
||||
```bash
|
||||
pnpm tauri:dev:test # start the Tauri app with the webdriver feature
|
||||
pnpm test:e2e # run wdio against it (specs: e2e/*.e2e.ts)
|
||||
```
|
||||
@@ -0,0 +1,49 @@
|
||||
import { test as base, expect } from '@playwright/test';
|
||||
import { LibraryPage } from '../pages/LibraryPage';
|
||||
import { ReaderPage } from '../pages/ReaderPage';
|
||||
import { SAMPLE_EPUB } from './books';
|
||||
|
||||
type Fixtures = {
|
||||
/**
|
||||
* Imports a book (the sample EPUB by default), opens it, and returns a
|
||||
* {@link ReaderPage} that is ready to interact with.
|
||||
*/
|
||||
openBook: (filePath?: string) => Promise<ReaderPage>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Base test fixture for the web e2e lane.
|
||||
*
|
||||
* - Overrides `page` to suppress the demo-book auto-import that `useDemoBooks`
|
||||
* performs on a fresh web session (see `src/app/library/hooks/useDemoBooks.ts`),
|
||||
* so every test starts from a deterministic empty library.
|
||||
* - Adds the `openBook` action fixture so reading/annotation specs do not
|
||||
* repeat the import-and-open boilerplate.
|
||||
*/
|
||||
export const test = base.extend<Fixtures>({
|
||||
page: async ({ page }, use) => {
|
||||
await page.addInitScript(() => {
|
||||
try {
|
||||
window.localStorage.setItem('demoBooksFetched', 'true');
|
||||
} catch {
|
||||
// localStorage may be unavailable in some contexts; ignore.
|
||||
}
|
||||
});
|
||||
await use(page);
|
||||
},
|
||||
openBook: async ({ page }, use) => {
|
||||
await use(async (filePath = SAMPLE_EPUB) => {
|
||||
const library = new LibraryPage(page);
|
||||
await library.goto();
|
||||
await library.importBook(filePath);
|
||||
await expect(library.bookCards()).toHaveCount(1);
|
||||
await library.openFirstBook();
|
||||
|
||||
const reader = new ReaderPage(page);
|
||||
await reader.waitForReady();
|
||||
return reader;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
@@ -0,0 +1,17 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const fixturesDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** Synthetic plain-text book — fast, used for basic import coverage. */
|
||||
export const SAMPLE_TXT = path.join(fixturesDir, 'books/readest-e2e-sample.txt');
|
||||
|
||||
/**
|
||||
* A real EPUB ("Alice's Adventures in Wonderland") from the unit-test
|
||||
* fixtures. Has multiple chapters and substantial prose, so it exercises
|
||||
* reading and annotation flows realistically.
|
||||
*/
|
||||
export const SAMPLE_EPUB = path.join(
|
||||
fixturesDir,
|
||||
'../../src/__tests__/fixtures/data/sample-alice.epub',
|
||||
);
|
||||
@@ -0,0 +1,29 @@
|
||||
Readest E2E Sample
|
||||
|
||||
Chapter One
|
||||
|
||||
This is a synthetic plain-text book used by the Readest web end-to-end
|
||||
test suite. It exists only so that import and reading flows have a small,
|
||||
deterministic file to exercise. It is not a real publication.
|
||||
|
||||
The text below is intentionally repetitive filler. Its only purpose is to
|
||||
give the paginator enough content to lay out across more than one page so
|
||||
that page-turning can be exercised by the tests.
|
||||
|
||||
Chapter Two
|
||||
|
||||
A reader opens a book, and the words arrange themselves into pages. Each
|
||||
page is a small window onto a longer whole. Turn forward and the window
|
||||
slides ahead; turn back and it returns to where it was.
|
||||
|
||||
Lorem ipsum has long served as placeholder prose, but plain repetition
|
||||
serves a test suite just as well. Sentences accumulate, paragraphs follow,
|
||||
and soon there is enough material for the layout engine to work with.
|
||||
|
||||
Chapter Three
|
||||
|
||||
The end of a sample book is much like its beginning: a few lines of text,
|
||||
arranged for no one in particular, standing in for the real thing. When
|
||||
the test completes, this book is discarded and forgotten.
|
||||
|
||||
The End
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Shared base for page objects.
|
||||
*
|
||||
* Page objects expose actions and queries (locators) only — assertions live
|
||||
* in the specs, so a failing assertion points at test intent rather than at a
|
||||
* helper.
|
||||
*/
|
||||
export abstract class BasePage {
|
||||
constructor(protected readonly page: Page) {}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
import { BasePage } from './BasePage';
|
||||
|
||||
/**
|
||||
* The library page (`/library`, also rendered at `/`).
|
||||
*/
|
||||
export class LibraryPage extends BasePage {
|
||||
readonly container: Locator;
|
||||
readonly header: Locator;
|
||||
readonly bookshelf: Locator;
|
||||
readonly searchInput: Locator;
|
||||
readonly clearSearchButton: Locator;
|
||||
readonly emptyState: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
this.container = page.locator('[aria-label="Your Library"]');
|
||||
this.header = page.locator('[aria-label="Library Header"]');
|
||||
this.bookshelf = page.locator('[aria-label="Bookshelf"]');
|
||||
this.searchInput = page.locator('.search-input');
|
||||
this.clearSearchButton = page.locator('[aria-label="Clear Search"]');
|
||||
this.emptyState = page.getByRole('heading', { name: 'Start your library' });
|
||||
}
|
||||
|
||||
async goto(): Promise<void> {
|
||||
await this.page.goto('/library');
|
||||
await this.container.waitFor({ state: 'visible' });
|
||||
}
|
||||
|
||||
/**
|
||||
* All book cards currently shown in the bookshelf. Book cards are
|
||||
* `div[role="button"]`; the trailing "+" import tile is a `<button>`, so it
|
||||
* is naturally excluded.
|
||||
*/
|
||||
bookCards(): Locator {
|
||||
return this.bookshelf.locator('div[role="button"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a book file via the empty-state "Import Books" button.
|
||||
*
|
||||
* The file `<input>` is created off-DOM (see `useFileSelector.selectFileWeb`),
|
||||
* so a `filechooser` event must be awaited rather than locating an
|
||||
* `<input type="file">`.
|
||||
*/
|
||||
async importBook(filePath: string): Promise<void> {
|
||||
const importButton = this.page.locator('.hero').getByRole('button', { name: 'Import Books' });
|
||||
const chooserPromise = this.page.waitForEvent('filechooser');
|
||||
await importButton.click();
|
||||
const chooser = await chooserPromise;
|
||||
await chooser.setFiles(filePath);
|
||||
}
|
||||
|
||||
async openFirstBook(): Promise<void> {
|
||||
await this.bookCards().first().click();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { type FrameLocator, type Locator, type Page, expect } from '@playwright/test';
|
||||
import { BasePage } from './BasePage';
|
||||
|
||||
/**
|
||||
* The reader page (`/reader/{ids}` on web).
|
||||
*
|
||||
* There is intentionally no `goto()` — callers reach the reader by opening a
|
||||
* book (see the `openBook` fixture), because `/reader` depends on the book
|
||||
* already being present in local storage.
|
||||
*
|
||||
* The header and footer bars are auto-hidden until the book is hovered;
|
||||
* methods that need them call {@link revealHeader} / {@link revealFooter}.
|
||||
*/
|
||||
export class ReaderPage extends BasePage {
|
||||
readonly viewer: Locator;
|
||||
readonly foliateView: Locator;
|
||||
readonly headerBar: Locator;
|
||||
readonly footerBar: Locator;
|
||||
readonly sidebar: Locator;
|
||||
readonly notebook: Locator;
|
||||
readonly tocItems: Locator;
|
||||
readonly searchResults: Locator;
|
||||
readonly annotationPopup: Locator;
|
||||
readonly noteEditor: Locator;
|
||||
readonly annotationItems: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
this.viewer = page.locator('.foliate-viewer').first();
|
||||
this.foliateView = page.locator('foliate-view').first();
|
||||
this.headerBar = page.locator('.header-bar').first();
|
||||
this.footerBar = page.locator('.footer-bar').first();
|
||||
this.sidebar = page.locator('[role="navigation"][aria-label="Sidebar"]');
|
||||
this.notebook = page.locator('[role="group"][aria-label="Notebook"]');
|
||||
this.tocItems = page.locator('.toc-list [role="treeitem"]');
|
||||
this.searchResults = page.locator('.search-results li[role="button"]');
|
||||
this.annotationPopup = page.locator('.selection-popup');
|
||||
this.noteEditor = page.locator('.note-editor-container');
|
||||
this.annotationItems = page.locator('li.booknote-item[role="button"]');
|
||||
}
|
||||
|
||||
/** Wait until the reader route is active and the book viewer has mounted. */
|
||||
async waitForReady(): Promise<void> {
|
||||
await this.page.waitForURL(/\/reader/);
|
||||
await this.viewer.waitFor({ state: 'visible' });
|
||||
await this.foliateView.waitFor({ state: 'attached' });
|
||||
}
|
||||
|
||||
// --- chrome (auto-hidden header / footer bars) ---
|
||||
|
||||
/** Reveal the header bar by clicking its top hover strip. */
|
||||
async revealHeader(): Promise<void> {
|
||||
const box = await this.viewer.boundingBox();
|
||||
if (box) {
|
||||
await this.page.mouse.click(box.x + box.width / 2, box.y + 4);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reveal the footer bar by clicking its bottom hover strip. */
|
||||
async revealFooter(): Promise<void> {
|
||||
const box = await this.viewer.boundingBox();
|
||||
if (box) {
|
||||
await this.page.mouse.click(box.x + box.width / 2, box.y + box.height - 4);
|
||||
}
|
||||
}
|
||||
|
||||
// --- pagination & progress ---
|
||||
|
||||
async nextPage(): Promise<void> {
|
||||
await this.page.keyboard.press('ArrowRight');
|
||||
}
|
||||
|
||||
async prevPage(): Promise<void> {
|
||||
await this.page.keyboard.press('ArrowLeft');
|
||||
}
|
||||
|
||||
/**
|
||||
* Current reading position as a number parsed from the footer's
|
||||
* "Reading Progress" label. The label is in the DOM regardless of whether
|
||||
* the footer is visually revealed, so no reveal is needed.
|
||||
*/
|
||||
async readingProgress(): Promise<number> {
|
||||
const label =
|
||||
(await this.page
|
||||
.locator('span[title="Reading Progress"]')
|
||||
.first()
|
||||
.getAttribute('aria-label')) ?? '';
|
||||
const match = label.match(/(\d+(?:\.\d+)?)/);
|
||||
return match ? Number(match[1]) : Number.NaN;
|
||||
}
|
||||
|
||||
// --- sidebar / table of contents ---
|
||||
|
||||
async openSidebar(): Promise<void> {
|
||||
if (await this.sidebar.isVisible()) return;
|
||||
await this.revealHeader();
|
||||
await this.page.locator('button[aria-label="Toggle Sidebar"]').first().click();
|
||||
await this.sidebar.waitFor({ state: 'visible' });
|
||||
}
|
||||
|
||||
/** Open the sidebar and navigate to the TOC chapter at the given index. */
|
||||
async openTocChapter(index: number): Promise<void> {
|
||||
await this.openSidebar();
|
||||
await this.sidebar.locator('[aria-label="TOC"]').click();
|
||||
await this.tocItems.nth(index).click();
|
||||
}
|
||||
|
||||
// --- in-book search ---
|
||||
|
||||
/** Run an in-book search and return the number of results. */
|
||||
async search(term: string): Promise<number> {
|
||||
await this.openSidebar();
|
||||
await this.page.locator('button[title="Show Search Bar"]').click();
|
||||
await this.sidebar.locator('input.search-input').fill(term);
|
||||
await this.searchResults.first().waitFor({ state: 'visible' });
|
||||
return this.searchResults.count();
|
||||
}
|
||||
|
||||
// --- reader settings ---
|
||||
|
||||
/**
|
||||
* Open the settings dialog, increase the default font size by one step,
|
||||
* and return the value before and after.
|
||||
*/
|
||||
async increaseFontSize(): Promise<{ before: string; after: string }> {
|
||||
await this.revealHeader();
|
||||
await this.headerBar.locator('button[aria-label="Font & Layout"]').click();
|
||||
await this.page.locator('[data-tab="Font"]').click();
|
||||
|
||||
const row = this.page.locator('[data-setting-id="settings.font.defaultFontSize"]');
|
||||
const input = row.locator('input').first();
|
||||
await input.waitFor({ state: 'visible' });
|
||||
const before = await input.inputValue();
|
||||
await row.locator('[aria-label="Increase"]').click();
|
||||
await expect(input).not.toHaveValue(before);
|
||||
const after = await input.inputValue();
|
||||
|
||||
await this.page.keyboard.press('Escape');
|
||||
return { before, after };
|
||||
}
|
||||
|
||||
// --- bookmarks ---
|
||||
|
||||
get addBookmarkButton(): Locator {
|
||||
return this.page.locator('button[aria-label="Add Bookmark"]');
|
||||
}
|
||||
|
||||
get removeBookmarkButton(): Locator {
|
||||
return this.page.locator('button[aria-label="Remove Bookmark"]');
|
||||
}
|
||||
|
||||
// --- text selection & annotations ---
|
||||
|
||||
/**
|
||||
* Find the iframe of the on-screen book section.
|
||||
*
|
||||
* The reader prerenders adjacent sections into separate iframes, so this
|
||||
* scans every `.foliate-viewer iframe` and returns the one holding a `<p>`
|
||||
* whose bounding box actually falls inside the viewport.
|
||||
*/
|
||||
private async visibleSectionFrame(): Promise<FrameLocator> {
|
||||
const viewport = this.page.viewportSize() ?? { width: 1280, height: 720 };
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
const iframes = this.page.locator('.foliate-viewer iframe');
|
||||
const frameCount = await iframes.count();
|
||||
for (let i = 0; i < frameCount; i += 1) {
|
||||
const paragraphs = iframes.nth(i).contentFrame().locator('p');
|
||||
// A frame may be detaching mid-navigation; skip it if so.
|
||||
const paragraphCount = await paragraphs.count().catch(() => 0);
|
||||
for (let j = 0; j < Math.min(paragraphCount, 30); j += 1) {
|
||||
const box = await paragraphs
|
||||
.nth(j)
|
||||
.boundingBox()
|
||||
.catch(() => null);
|
||||
// Accept a paragraph that intersects the viewport — off-screen
|
||||
// prerendered sections sit fully outside it.
|
||||
if (
|
||||
box &&
|
||||
box.width > 120 &&
|
||||
box.height > 16 &&
|
||||
box.x < viewport.width &&
|
||||
box.x + box.width > 0 &&
|
||||
box.y < viewport.height &&
|
||||
box.y + box.height > 0
|
||||
) {
|
||||
return iframes.nth(i).contentFrame();
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.page.waitForTimeout(400);
|
||||
}
|
||||
throw new Error('no visible book section found in the viewer');
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a paragraph of book text and raise the annotation popup.
|
||||
*
|
||||
* Navigates to a chapter first so the page holds prose (the book opens on a
|
||||
* cover page). The selection is made inside the section iframe and a
|
||||
* `pointerup` is dispatched — the exact pair of signals the reader's
|
||||
* annotator listens for — because synthetic mouse drags do not reliably
|
||||
* produce a text selection through nested, paginated foliate iframes.
|
||||
*/
|
||||
async selectText(): Promise<void> {
|
||||
await this.openTocChapter(3);
|
||||
const frame = await this.visibleSectionFrame();
|
||||
|
||||
await frame.locator('body').evaluate(() => {
|
||||
const paragraphs = Array.from(document.querySelectorAll('p'));
|
||||
const target = paragraphs.find((p) => (p.textContent ?? '').trim().length > 60);
|
||||
if (!target) {
|
||||
throw new Error('no selectable paragraph in the visible section');
|
||||
}
|
||||
// Select a span within a text node — the reader's CFI generation
|
||||
// expects text-node range endpoints, not element boundaries.
|
||||
const walker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT);
|
||||
let textNode = walker.nextNode();
|
||||
while (textNode && (textNode.textContent ?? '').trim().length < 20) {
|
||||
textNode = walker.nextNode();
|
||||
}
|
||||
if (!textNode) {
|
||||
throw new Error('no text node found in the target paragraph');
|
||||
}
|
||||
const length = textNode.textContent?.length ?? 0;
|
||||
const range = document.createRange();
|
||||
range.setStart(textNode, 0);
|
||||
range.setEnd(textNode, Math.min(length, 80));
|
||||
const selection = document.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
|
||||
const rect = range.getClientRects()[0] ?? range.getBoundingClientRect();
|
||||
document.dispatchEvent(
|
||||
new PointerEvent('pointerup', {
|
||||
clientX: rect.left + Math.min(20, rect.width / 2),
|
||||
clientY: rect.top + rect.height / 2,
|
||||
bubbles: true,
|
||||
pointerType: 'mouse',
|
||||
}),
|
||||
);
|
||||
});
|
||||
await this.annotationPopup.waitFor({ state: 'visible' });
|
||||
}
|
||||
|
||||
/** A tool button inside the annotation popup, by its accessible name. */
|
||||
popupTool(name: string | RegExp): Locator {
|
||||
return this.annotationPopup.getByRole('button', { name });
|
||||
}
|
||||
|
||||
async highlightSelection(): Promise<void> {
|
||||
await this.popupTool('Highlight').click();
|
||||
}
|
||||
|
||||
async selectHighlightColor(color: string): Promise<void> {
|
||||
await this.page.locator(`[aria-label="Select ${color} color"]`).click();
|
||||
}
|
||||
|
||||
/** Annotate the current selection with a note. */
|
||||
async addNote(text: string): Promise<void> {
|
||||
await this.popupTool('Annotate').click();
|
||||
await this.noteEditor.waitFor({ state: 'visible' });
|
||||
await this.noteEditor.getByRole('textbox').fill(text);
|
||||
await this.notebook.getByRole('button', { name: 'Save' }).click();
|
||||
}
|
||||
|
||||
/** Dismiss the annotation popup if it is open. */
|
||||
async dismissPopup(): Promise<void> {
|
||||
if (await this.annotationPopup.isVisible().catch(() => false)) {
|
||||
await this.page.keyboard.press('Escape');
|
||||
await this.annotationPopup.waitFor({ state: 'hidden' }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the sidebar's "Annotate" tab, which lists the book's annotations
|
||||
* (assert against {@link annotationItems} afterwards).
|
||||
*/
|
||||
async openAnnotationsTab(): Promise<void> {
|
||||
await this.dismissPopup();
|
||||
await this.openSidebar();
|
||||
await this.sidebar.locator('[aria-label="Annotate"]').click();
|
||||
}
|
||||
|
||||
/** Delete the first annotation from the sidebar's "Annotate" tab. */
|
||||
async deleteFirstAnnotation(): Promise<void> {
|
||||
await this.openAnnotationsTab();
|
||||
const item = this.annotationItems.first();
|
||||
await item.hover();
|
||||
await item.getByRole('button', { name: 'Delete' }).click();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { expect, test } from '../fixtures/base';
|
||||
|
||||
test.describe('Annotation', () => {
|
||||
test('shows the annotation popup when text is selected', async ({ openBook }) => {
|
||||
const reader = await openBook();
|
||||
|
||||
await reader.selectText();
|
||||
|
||||
await expect(reader.annotationPopup).toBeVisible();
|
||||
await expect(reader.popupTool('Highlight')).toBeVisible();
|
||||
});
|
||||
|
||||
test('creates a highlight from the selected text', async ({ openBook }) => {
|
||||
const reader = await openBook();
|
||||
|
||||
await reader.selectText();
|
||||
await reader.highlightSelection();
|
||||
|
||||
await reader.openAnnotationsTab();
|
||||
await expect(reader.annotationItems).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('changes the highlight color', async ({ openBook }) => {
|
||||
const reader = await openBook();
|
||||
|
||||
await reader.selectText();
|
||||
await reader.highlightSelection();
|
||||
await reader.selectHighlightColor('green');
|
||||
|
||||
await reader.openAnnotationsTab();
|
||||
await expect(reader.annotationItems).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('adds a note to the selected text', async ({ openBook }) => {
|
||||
const reader = await openBook();
|
||||
const noteText = 'A note added by the e2e suite';
|
||||
|
||||
await reader.selectText();
|
||||
await reader.addNote(noteText);
|
||||
|
||||
await expect(reader.notebook.getByText(noteText)).toBeVisible();
|
||||
});
|
||||
|
||||
test('deletes an annotation', async ({ openBook }) => {
|
||||
const reader = await openBook();
|
||||
|
||||
await reader.selectText();
|
||||
await reader.highlightSelection();
|
||||
await reader.openAnnotationsTab();
|
||||
await expect(reader.annotationItems).toHaveCount(1);
|
||||
|
||||
await reader.deleteFirstAnnotation();
|
||||
|
||||
await expect(reader.annotationItems).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { expect, test } from '../fixtures/base';
|
||||
import { SAMPLE_EPUB, SAMPLE_TXT } from '../fixtures/books';
|
||||
import { LibraryPage } from '../pages/LibraryPage';
|
||||
|
||||
test.describe('Book import', () => {
|
||||
test('imports a plain-text file and surfaces it in the bookshelf', async ({ page }) => {
|
||||
const library = new LibraryPage(page);
|
||||
await library.goto();
|
||||
await expect(library.emptyState).toBeVisible();
|
||||
|
||||
await library.importBook(SAMPLE_TXT);
|
||||
|
||||
await expect(library.bookshelf).toBeVisible();
|
||||
await expect(library.bookCards()).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('imports an EPUB file and surfaces it in the bookshelf', async ({ page }) => {
|
||||
const library = new LibraryPage(page);
|
||||
await library.goto();
|
||||
await expect(library.emptyState).toBeVisible();
|
||||
|
||||
await library.importBook(SAMPLE_EPUB);
|
||||
|
||||
await expect(library.bookshelf).toBeVisible();
|
||||
await expect(library.bookCards()).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { expect, test } from '../fixtures/base';
|
||||
import { LibraryPage } from '../pages/LibraryPage';
|
||||
|
||||
test.describe('Library page', () => {
|
||||
test('renders the library shell and an empty-library state', async ({ page }) => {
|
||||
const library = new LibraryPage(page);
|
||||
await library.goto();
|
||||
|
||||
await expect(library.container).toBeVisible();
|
||||
await expect(library.header).toBeVisible();
|
||||
await expect(library.emptyState).toBeVisible();
|
||||
});
|
||||
|
||||
test('search input accepts text and exposes a clear control', async ({ page }) => {
|
||||
const library = new LibraryPage(page);
|
||||
await library.goto();
|
||||
|
||||
await expect(library.searchInput).toBeVisible();
|
||||
await library.searchInput.fill('a test query');
|
||||
await expect(library.searchInput).toHaveValue('a test query');
|
||||
|
||||
await expect(library.clearSearchButton).toBeVisible();
|
||||
await library.clearSearchButton.click();
|
||||
await expect(library.searchInput).toHaveValue('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { expect, test } from '../fixtures/base';
|
||||
|
||||
test.describe('Reading', () => {
|
||||
test('opens an EPUB and turns pages', async ({ openBook }) => {
|
||||
const reader = await openBook();
|
||||
|
||||
await expect(reader.viewer).toBeVisible();
|
||||
await reader.nextPage();
|
||||
await reader.nextPage();
|
||||
await reader.prevPage();
|
||||
await expect(reader.viewer).toBeVisible();
|
||||
});
|
||||
|
||||
test('navigates chapters via the table of contents', async ({ openBook }) => {
|
||||
const reader = await openBook();
|
||||
const startProgress = await reader.readingProgress();
|
||||
|
||||
await reader.openTocChapter(6);
|
||||
|
||||
// Reading progress updates asynchronously after the section loads.
|
||||
await expect.poll(() => reader.readingProgress()).toBeGreaterThan(startProgress);
|
||||
});
|
||||
|
||||
test('finds matches with in-book search', async ({ openBook }) => {
|
||||
const reader = await openBook();
|
||||
|
||||
const resultCount = await reader.search('Alice');
|
||||
|
||||
expect(resultCount).toBeGreaterThan(0);
|
||||
await reader.searchResults.first().click();
|
||||
await expect(reader.viewer).toBeVisible();
|
||||
});
|
||||
|
||||
test('increases the font size from the settings dialog', async ({ openBook }) => {
|
||||
const reader = await openBook();
|
||||
|
||||
const { before, after } = await reader.increaseFontSize();
|
||||
|
||||
expect(Number(after)).toBeGreaterThan(Number(before));
|
||||
});
|
||||
|
||||
test('adds and removes a bookmark', async ({ openBook }) => {
|
||||
const reader = await openBook();
|
||||
await reader.revealHeader();
|
||||
|
||||
await expect(reader.addBookmarkButton).toBeVisible();
|
||||
await reader.addBookmarkButton.click();
|
||||
await expect(reader.removeBookmarkButton).toBeVisible();
|
||||
|
||||
await reader.removeBookmarkButton.click();
|
||||
await expect(reader.addBookmarkButton).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,10 @@
|
||||
"test:pr:tauri": "bash scripts/test-tauri.sh",
|
||||
"test:all": "pnpm test -- --watch=false && pnpm test:browser && bash scripts/test-tauri.sh",
|
||||
"test:e2e": "wdio run wdio.conf.ts",
|
||||
"test:e2e:web": "playwright test",
|
||||
"test:e2e:web:headed": "playwright test --headed --workers=1 --trace on",
|
||||
"test:e2e:web:ui": "playwright test --ui",
|
||||
"test:e2e:web:report": "playwright show-report",
|
||||
"tauri:dev:test": "dotenv -e .env.tauri -- tauri dev --features webdriver",
|
||||
"tauri:build:test": "dotenv -e .env.tauri -- tauri build --debug --features webdriver",
|
||||
"tauri": "tauri",
|
||||
@@ -189,6 +193,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next/bundle-analyzer": "^15.4.2",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tauri-apps/cli": "2.10.1",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Playwright web e2e lane.
|
||||
*
|
||||
* This suite drives the Next.js *web* build (`pnpm dev-web`) in a real browser.
|
||||
* It is complementary to — not a replacement for — the WebdriverIO suite
|
||||
* (`e2e/app.e2e.ts`, run via `pnpm test:e2e`), which drives the Tauri shell.
|
||||
*
|
||||
* - Specs: e2e/tests/**\/*.spec.ts
|
||||
* - Page objects: e2e/pages
|
||||
* - Fixtures: e2e/fixtures
|
||||
*
|
||||
* Tests run unauthenticated against a fresh browser context, so each test
|
||||
* starts from an isolated, empty local library.
|
||||
*/
|
||||
const PORT = 3000;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e/tests',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
// Tests are isolated (a fresh browser context per test), so they run in
|
||||
// parallel. `test:e2e:web:headed` overrides this to 1 to stay watchable.
|
||||
workers: 4,
|
||||
// Always write the HTML report so `pnpm test:e2e:web:report` can open it.
|
||||
reporter: process.env.CI
|
||||
? [['github'], ['html', { open: 'never' }]]
|
||||
: [['list'], ['html', { open: 'never' }]],
|
||||
timeout: 60_000,
|
||||
expect: { timeout: 15_000 },
|
||||
use: {
|
||||
baseURL: `http://localhost:${PORT}`,
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||
webServer: {
|
||||
// CI runs against a production build (`pnpm build-web` runs first as a
|
||||
// separate CI step) — `next dev` shows an error overlay on the app's
|
||||
// `next-view-transitions` unhandled rejection, which intercepts clicks.
|
||||
command: process.env.CI ? 'pnpm start-web' : 'pnpm dev-web',
|
||||
port: PORT,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
});
|
||||
@@ -27,6 +27,8 @@ export default defineConfig({
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/.claude/**',
|
||||
// Playwright web e2e specs — run via `pnpm test:e2e:web`, not vitest.
|
||||
'**/e2e/**',
|
||||
'**/*.browser.test.ts',
|
||||
'**/*.browser.test.tsx',
|
||||
'**/*.tauri.test.ts',
|
||||
|
||||
Generated
+32
-19
@@ -108,7 +108,7 @@ importers:
|
||||
version: 1.1.4(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)
|
||||
'@opennextjs/cloudflare':
|
||||
specifier: ^1.19.4
|
||||
version: 1.19.9(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(wrangler@4.90.0)
|
||||
version: 1.19.9(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(wrangler@4.90.0)
|
||||
'@radix-ui/react-collapsible':
|
||||
specifier: ^1.1.12
|
||||
version: 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
@@ -150,7 +150,7 @@ importers:
|
||||
version: 0.6.0-pre.28-readest.0
|
||||
'@serwist/next':
|
||||
specifier: ^9.5.6
|
||||
version: 9.5.11(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(typescript@5.9.3)(webpack@5.106.2(postcss@8.5.14))
|
||||
version: 9.5.11(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(typescript@5.9.3)(webpack@5.106.2(postcss@8.5.14))
|
||||
'@serwist/webpack-plugin':
|
||||
specifier: ^9.5.6
|
||||
version: 9.5.11(browserslist@4.28.2)(typescript@5.9.3)(webpack@5.106.2(postcss@8.5.14))
|
||||
@@ -315,10 +315,10 @@ importers:
|
||||
version: 5.1.11
|
||||
next:
|
||||
specifier: 16.2.6
|
||||
version: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
version: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
next-view-transitions:
|
||||
specifier: ^0.3.5
|
||||
version: 0.3.5(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
version: 0.3.5(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
nunjucks:
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.4(chokidar@3.6.0)
|
||||
@@ -395,6 +395,9 @@ importers:
|
||||
'@next/bundle-analyzer':
|
||||
specifier: ^15.4.2
|
||||
version: 15.5.18
|
||||
'@playwright/test':
|
||||
specifier: ^1.60.0
|
||||
version: 1.60.0
|
||||
'@tailwindcss/typography':
|
||||
specifier: ^0.5.16
|
||||
version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.9.0))
|
||||
@@ -544,7 +547,7 @@ importers:
|
||||
version: 5.9.3
|
||||
vinext:
|
||||
specifier: ^0.0.21
|
||||
version: 0.0.21(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.19)(jiti@1.21.7)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))(webpack@5.106.2(postcss@8.5.14))
|
||||
version: 0.0.21(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.19)(jiti@1.21.7)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))(webpack@5.106.2(postcss@8.5.14))
|
||||
vite:
|
||||
specifier: '>=7.3.2 <8'
|
||||
version: 7.3.3(@types/node@22.19.19)(jiti@1.21.7)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)
|
||||
@@ -2396,6 +2399,11 @@ packages:
|
||||
resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@playwright/test@1.60.0':
|
||||
resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
'@polka/url@1.0.0-next.29':
|
||||
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
||||
|
||||
@@ -10660,7 +10668,7 @@ snapshots:
|
||||
'@nodelib/fs.scandir': 2.1.5
|
||||
fastq: 1.20.1
|
||||
|
||||
'@opennextjs/aws@4.0.2(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))':
|
||||
'@opennextjs/aws@4.0.2(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))':
|
||||
dependencies:
|
||||
'@ast-grep/napi': 0.40.5
|
||||
'@aws-sdk/client-cloudfront': 3.984.0
|
||||
@@ -10676,7 +10684,7 @@ snapshots:
|
||||
cookie: 1.1.1
|
||||
esbuild: 0.25.4
|
||||
express: 5.2.1
|
||||
next: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
next: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
path-to-regexp: 8.4.2
|
||||
urlpattern-polyfill: 10.1.0
|
||||
yaml: 2.9.0
|
||||
@@ -10684,17 +10692,17 @@ snapshots:
|
||||
- aws-crt
|
||||
- supports-color
|
||||
|
||||
'@opennextjs/cloudflare@1.19.9(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(wrangler@4.90.0)':
|
||||
'@opennextjs/cloudflare@1.19.9(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(wrangler@4.90.0)':
|
||||
dependencies:
|
||||
'@ast-grep/napi': 0.40.5
|
||||
'@dotenvx/dotenvx': 1.31.0
|
||||
'@opennextjs/aws': 4.0.2(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))
|
||||
'@opennextjs/aws': 4.0.2(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))
|
||||
ci-info: 4.4.0
|
||||
cloudflare: 4.5.0
|
||||
comment-json: 4.6.2
|
||||
enquirer: 2.4.1
|
||||
glob: 12.0.0
|
||||
next: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
next: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
ts-tqdm: 0.8.6
|
||||
wrangler: 4.90.0
|
||||
yargs: 18.0.0
|
||||
@@ -10781,6 +10789,10 @@ snapshots:
|
||||
|
||||
'@opentelemetry/semantic-conventions@1.40.0': {}
|
||||
|
||||
'@playwright/test@1.60.0':
|
||||
dependencies:
|
||||
playwright: 1.60.0
|
||||
|
||||
'@polka/url@1.0.0-next.29': {}
|
||||
|
||||
'@poppinss/colors@4.1.6':
|
||||
@@ -11428,7 +11440,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- browserslist
|
||||
|
||||
'@serwist/next@9.5.11(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(typescript@5.9.3)(webpack@5.106.2(postcss@8.5.14))':
|
||||
'@serwist/next@9.5.11(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(typescript@5.9.3)(webpack@5.106.2(postcss@8.5.14))':
|
||||
dependencies:
|
||||
'@serwist/build': 9.5.11(browserslist@4.28.2)(typescript@5.9.3)
|
||||
'@serwist/utils': 9.5.11(browserslist@4.28.2)
|
||||
@@ -11437,7 +11449,7 @@ snapshots:
|
||||
browserslist: 4.28.2
|
||||
glob: 13.0.6
|
||||
kolorist: 1.8.0
|
||||
next: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
next: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
react: 19.2.5
|
||||
semver: 7.7.4
|
||||
serwist: 9.5.11(browserslist@4.28.2)(typescript@5.9.3)
|
||||
@@ -12293,13 +12305,13 @@ snapshots:
|
||||
dependencies:
|
||||
unpic: 4.2.2
|
||||
|
||||
'@unpic/react@1.0.2(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
|
||||
'@unpic/react@1.0.2(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
|
||||
dependencies:
|
||||
'@unpic/core': 1.0.3
|
||||
react: 19.2.5
|
||||
react-dom: 19.2.5(react@19.2.5)
|
||||
optionalDependencies:
|
||||
next: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
next: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
|
||||
'@upsetjs/venn.js@2.0.0':
|
||||
optionalDependencies:
|
||||
@@ -15682,13 +15694,13 @@ snapshots:
|
||||
|
||||
netmask@2.1.1: {}
|
||||
|
||||
next-view-transitions@0.3.5(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
|
||||
next-view-transitions@0.3.5(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
|
||||
dependencies:
|
||||
next: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
next: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
react: 19.2.5
|
||||
react-dom: 19.2.5(react@19.2.5)
|
||||
|
||||
next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
|
||||
next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
|
||||
dependencies:
|
||||
'@next/env': 16.2.6
|
||||
'@swc/helpers': 0.5.15
|
||||
@@ -15708,6 +15720,7 @@ snapshots:
|
||||
'@next/swc-win32-arm64-msvc': 16.2.6
|
||||
'@next/swc-win32-x64-msvc': 16.2.6
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@playwright/test': 1.60.0
|
||||
sharp: 0.34.5
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
@@ -17425,9 +17438,9 @@ snapshots:
|
||||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.3
|
||||
|
||||
vinext@0.0.21(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.19)(jiti@1.21.7)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))(webpack@5.106.2(postcss@8.5.14)):
|
||||
vinext@0.0.21(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.19)(jiti@1.21.7)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))(webpack@5.106.2(postcss@8.5.14)):
|
||||
dependencies:
|
||||
'@unpic/react': 1.0.2(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
'@unpic/react': 1.0.2(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
'@vercel/og': 0.8.6
|
||||
'@vitejs/plugin-rsc': 0.5.26(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.106.2(postcss@8.5.14)))(react@19.2.5)(vite@7.3.3(@types/node@22.19.19)(jiti@1.21.7)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))
|
||||
magic-string: 0.30.21
|
||||
|
||||
Reference in New Issue
Block a user