diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 90bd5a8..cd0d182 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,7 +12,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "26" - name: Install disposable PostgreSQL engine run: npm install --prefix backend/data/citation-validation --no-audit --no-fund --ignore-scripts @electric-sql/pglite@0.5.14 - name: Verify citation migration and transactions @@ -47,7 +47,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "26" cache: npm cache-dependency-path: "**/package-lock.json" - name: Install frontend deps @@ -58,7 +58,12 @@ jobs: run: npm run build e2e: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + timeout-minutes: 15 + container: + # Keep this version aligned with @playwright/test in frontend/package-lock.json. + image: mcr.microsoft.com/playwright:v1.58.2-noble + options: --ipc=host needs: [backend, frontend] defaults: run: @@ -67,12 +72,14 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "26" cache: npm cache-dependency-path: "**/package-lock.json" - name: Install frontend deps + timeout-minutes: 5 run: npm ci - - name: Install Playwright browsers - run: npx playwright install --with-deps chromium + - name: Verify Playwright image version + run: node -e "if (require('@playwright/test/package.json').version !== '1.58.2') throw new Error('Update the CI Playwright image to match package-lock.json')" - name: Run E2E smoke tests + timeout-minutes: 8 run: npm run test:e2e diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 72482a6..1017add 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -30,7 +30,7 @@ "dompurify": "^3.4.17", "html-to-image": "^1.11.13", "jszip": "^3.10.1", - "katex": "^0.19.1", + "katex": "^0.18.10", "lowlight": "^3.3.0", "papaparse": "^5.5.3", "react": "^18.3.1", @@ -2393,14 +2393,12 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", - "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -2411,7 +2409,6 @@ "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^18.0.0" @@ -3015,7 +3012,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/d3": { @@ -4270,9 +4266,9 @@ } }, "node_modules/katex": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.19.1.tgz", - "integrity": "sha512-jG8uKntjSEcm2EpQOh0XturpPuuC9r3Sq5HLHdsFdWZT27E672SF19QEi+of0/YeOy15lDgRhKvr5614uRrEfA==", + "version": "0.18.10", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.18.10.tgz", + "integrity": "sha512-/B6p9eY9DX7aHBfpkHdpirDTZ5QH9xTwL9y837PWuzuv41O5jFdqNQwVQEQsimY0/iVNkwBY4+4hMfhQ7d4Dbw==", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" diff --git a/frontend/package.json b/frontend/package.json index a8b2aa1..3949c9e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -34,7 +34,7 @@ "dompurify": "^3.4.17", "html-to-image": "^1.11.13", "jszip": "^3.10.1", - "katex": "^0.19.1", + "katex": "^0.18.10", "lowlight": "^3.3.0", "papaparse": "^5.5.3", "react": "^18.3.1", diff --git a/frontend/playwright.config.js b/frontend/playwright.config.js index d6a1308..89a1df4 100644 --- a/frontend/playwright.config.js +++ b/frontend/playwright.config.js @@ -4,6 +4,7 @@ export default defineConfig({ testDir: './e2e', fullyParallel: false, workers: 1, + globalTimeout: process.env.CI ? 6 * 60 * 1000 : undefined, use: { baseURL: 'http://127.0.0.1:4173', trace: 'on-first-retry', diff --git a/frontend/src/components/ItemContextMenu.jsx b/frontend/src/components/ItemContextMenu.jsx index 6f39435..a4d50ad 100644 --- a/frontend/src/components/ItemContextMenu.jsx +++ b/frontend/src/components/ItemContextMenu.jsx @@ -1,4 +1,4 @@ -import { useLayoutEffect, useRef, useState } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' const PANEL_CLASS = 'fixed z-[100] w-60 max-w-[calc(100vw-16px)] max-h-[calc(100vh-16px)] overflow-y-auto rounded-xl border border-slate-200 bg-white py-1 shadow-xl' @@ -16,7 +16,7 @@ function ActionButton({ action, onClose, onMouseEnter }) { } -function Submenu({ branch, onClose, onBack }) { +function Submenu({ branch, onClose, onBack, onMouseEnter }) { const ref = useRef(null) const [position, setPosition] = useState({ left: 0, top: 0 }) useLayoutEffect(() => { @@ -32,6 +32,7 @@ function Submenu({ branch, onClose, onBack }) { }, [branch]) return
{ if (event.key === 'ArrowLeft' || event.key === 'Escape') { event.preventDefault() @@ -48,6 +49,20 @@ export default function ItemContextMenu({ x, y, title, groups, onClose }) { const menuRef = useRef(null) const [branch, setBranch] = useState(null) const [position, setPosition] = useState({ left: x, top: y }) + const branchTimer = useRef(null) + const cancelBranchChange = () => clearTimeout(branchTimer.current) + const openBranch = next => { + cancelBranchChange() + setBranch(next) + } + const hoverBranch = next => { + cancelBranchChange() + if (branch?.group.label === next?.group.label) return + // Allow diagonal pointer travel across other rows into the open submenu. + if (branch) branchTimer.current = setTimeout(() => setBranch(next), 200) + else setBranch(next) + } + useEffect(() => () => clearTimeout(branchTimer.current), []) useLayoutEffect(() => { const menu = menuRef.current @@ -87,7 +102,7 @@ export default function ItemContextMenu({ x, y, title, groups, onClose }) { : event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1 : null if (next !== null) { event.preventDefault() - if (event.target.closest('[role="menu"]') === rootRef.current) setBranch(null) + if (event.target.closest('[role="menu"]') === rootRef.current) openBranch(null) buttons[next]?.focus() } } @@ -98,18 +113,18 @@ export default function ItemContextMenu({ x, y, title, groups, onClose }) {
{title}
{groups.filter(group => group.actions.length).map(group => group.actions.length === 1 ? (
- setBranch(null)} /> + hoverBranch(null)} />
) : ( ))}
- {branch && { + {branch && { branch.trigger.focus() - setBranch(null) + openBranch(null) }} />} , document.body, ) diff --git a/frontend/src/components/ItemContextMenu.test.jsx b/frontend/src/components/ItemContextMenu.test.jsx new file mode 100644 index 0000000..f8d3ce9 --- /dev/null +++ b/frontend/src/components/ItemContextMenu.test.jsx @@ -0,0 +1,30 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import ItemContextMenu from './ItemContextMenu' + +afterEach(() => vi.useRealTimers()) + +it('keeps a submenu open when the pointer crosses another row to reach it', () => { + vi.useFakeTimers() + const copy = vi.fn() + const close = vi.fn() + render() + fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'Copy', exact: true })) + fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'Open paper' })) + const submenu = screen.getByRole('menu', { name: 'Copy' }) + fireEvent.mouseEnter(submenu) + act(() => vi.advanceTimersByTime(300)) + expect(submenu).toBeInTheDocument() + fireEvent.click(screen.getByRole('menuitem', { name: 'Copy title' })) + expect(copy).toHaveBeenCalledOnce() + expect(close).toHaveBeenCalledOnce() + fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'Open paper' })) + act(() => vi.advanceTimersByTime(300)) + expect(screen.queryByRole('menu', { name: 'Copy' })).not.toBeInTheDocument() +}) diff --git a/frontend/src/pages/Library.smoke.test.jsx b/frontend/src/pages/Library.smoke.test.jsx index eeda954..a16b3c5 100644 --- a/frontend/src/pages/Library.smoke.test.jsx +++ b/frontend/src/pages/Library.smoke.test.jsx @@ -329,11 +329,12 @@ describe('Library page smoke', () => { expect(screen.getByRole('menuitem', { name })).toBeInTheDocument() } fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'Process' })) + await screen.findByRole('menuitem', { name: /Generate notes/ }) for (const name of [/Generate notes/, /Auto-tag/, /Fetch PDFs/, /Generate embeddings/]) { expect(screen.getByRole('menuitem', { name })).toBeInTheDocument() } fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'Copy & export' })) - expect(screen.getByRole('menuitem', { name: /Export BibTeX/ })).toBeInTheDocument() + expect(await screen.findByRole('menuitem', { name: /Export BibTeX/ })).toBeInTheDocument() expect(screen.getByRole('menuitem', { name: /Delete item/ })).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Clear selection' })) expect(checkbox).not.toBeChecked()