Skip to content
97 changes: 97 additions & 0 deletions e2e/pdf-search.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { PDFDocument, rgb, StandardFonts } from 'pdf-lib';
import { expect, test } from './fixtures';

test('Cmd+F opens search bar in PDF viewer', { timeout: 60_000 }, async ({ electronApp, window }) => {
// Create the test PDF in the test's Node.js context (where imports work)
const doc = await PDFDocument.create();
const font = await doc.embedFont(StandardFonts.Helvetica);
const page = doc.addPage([612, 792]);
const lines = [
{ text: 'Attention Is All You Need', size: 18 },
{ text: '', size: 11 },
{ text: 'Abstract', size: 14 },
{ text: '', size: 11 },
{ text: 'The dominant sequence transduction models are based on complex recurrent', size: 11 },
{ text: 'or convolutional neural networks that include an encoder and a decoder.', size: 11 },
{ text: 'We propose a new simple network architecture, the Transformer, based', size: 11 },
{ text: 'solely on attention mechanisms, dispensing with recurrence entirely.', size: 11 },
];
let y = 720;
for (const line of lines) {
page.drawText(line.text, { x: 72, y, size: line.size, font, color: rgb(0, 0, 0) });
y -= line.size + 6;
}
const pdfBytes = await doc.save();

const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'papershelf-pdf-'));
const pdfPath = path.join(tmpDir, 'test-paper.pdf');
fs.writeFileSync(pdfPath, pdfBytes);
Comment on lines +29 to +31
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Temporary directory created with mkdtempSync is not cleaned up after the test completes. Over many test runs, this could accumulate files in the temp directory.

Add cleanup in test teardown or use a try...finally block.


// Seed the paper in the main process DB (only DB access needs evaluate)
// Note: electronApp.evaluate passes the Electron module as the first arg;
// the data argument comes second (see take-screenshots.ts for the pattern)
await electronApp.evaluate(
async (_electron, _params: { pdfPath: string }) => {
const db = (global as Record<string, any>).__papershelf_db;
db.insertPaper({
arxivId: '1706.03762',
title: 'Attention Is All You Need',
authors: ['Ashish Vaswani', 'Noam Shazeer'],
abstract: 'The dominant sequence transduction models are based on complex recurrent neural networks.',
publishedDate: '2017-06-12T00:00:00Z',
updatedDate: '2017-06-12T00:00:00Z',
categories: ['cs.CL', 'cs.AI'],
arxivUrl: 'https://arxiv.org/abs/1706.03762',
pdfUrl: 'https://arxiv.org/pdf/1706.03762',
pdfPath: _params.pdfPath,
fullText: null,
});
},
{ pdfPath },
);

// Navigate to library and click the paper
await window.getByRole('button', { name: 'My Library' }).click();
await expect(window.getByText('Attention Is All You Need').first()).toBeVisible({ timeout: 5000 });
await window.getByText('Attention Is All You Need').first().click();

// Wait for PDF to fully render — the loading indicator is transient and may
// disappear before we can assert on it, so just wait for the text layer
await window.locator('.textLayer span').first().waitFor({ timeout: 30000 });

// Take a screenshot of the PDF viewer before search
await window.screenshot({ path: 'test-results/pdf-viewer-before-search.png' });

// Open search with Cmd+F
await window.keyboard.press('Meta+f');

// Search bar should be visible
const searchInput = window.getByPlaceholder('Find in PDF...');
await expect(searchInput).toBeVisible({ timeout: 3000 });

// Take screenshot showing search bar open
await window.screenshot({ path: 'test-results/pdf-search-bar-open.png' });

// Type a search query
await searchInput.fill('attention');

// Wait for match counter to appear
await expect(window.getByText(/\d+ of \d+/)).toBeVisible({ timeout: 5000 });

// Take screenshot showing search results with highlights
await window.screenshot({ path: 'test-results/pdf-search-results.png' });

// Navigate to next match with Enter
await searchInput.press('Enter');

Check failure on line 88 in e2e/pdf-search.spec.ts

View workflow job for this annotation

GitHub Actions / e2e-tests

e2e/pdf-search.spec.ts:7:5 › Cmd+F opens search bar in PDF viewer

2) e2e/pdf-search.spec.ts:7:5 › Cmd+F opens search bar in PDF viewer ───────────────────────────── Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── TimeoutError: locator.press: Timeout 30000ms exceeded. Call log: - waiting for getByPlaceholder('Find in PDF...') - locator resolved to <input type="text" value="attention" placeholder="Find in PDF..." class="w-48 text-mac-small bg-transparent border-none outline-none placeholder-gray-400"/> - elementHandle.press("Enter") 86 | 87 | // Navigate to next match with Enter > 88 | await searchInput.press('Enter'); | ^ 89 | await window.screenshot({ path: 'test-results/pdf-search-next-match.png' }); 90 | 91 | // Close search with Escape at /home/runner/work/papershelf/papershelf/e2e/pdf-search.spec.ts:88:21

Check failure on line 88 in e2e/pdf-search.spec.ts

View workflow job for this annotation

GitHub Actions / e2e-tests

e2e/pdf-search.spec.ts:7:5 › Cmd+F opens search bar in PDF viewer

2) e2e/pdf-search.spec.ts:7:5 › Cmd+F opens search bar in PDF viewer ───────────────────────────── TimeoutError: locator.press: Timeout 30000ms exceeded. Call log: - waiting for getByPlaceholder('Find in PDF...') - locator resolved to <input type="text" value="attention" placeholder="Find in PDF..." class="w-48 text-mac-small bg-transparent border-none outline-none placeholder-gray-400"/> - elementHandle.press("Enter") 86 | 87 | // Navigate to next match with Enter > 88 | await searchInput.press('Enter'); | ^ 89 | await window.screenshot({ path: 'test-results/pdf-search-next-match.png' }); 90 | 91 | // Close search with Escape at /home/runner/work/papershelf/papershelf/e2e/pdf-search.spec.ts:88:21
await window.screenshot({ path: 'test-results/pdf-search-next-match.png' });

// Close search with Escape
await searchInput.press('Escape');
await expect(searchInput).not.toBeVisible();

// Take screenshot after closing search (highlights should be gone)
await window.screenshot({ path: 'test-results/pdf-search-closed.png' });
});
175 changes: 175 additions & 0 deletions src/renderer/__tests__/shortcut-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

vi.stubGlobal('localStorage', {
getItem: vi.fn(() => null),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
length: 0,
key: vi.fn(() => null),
});

// Mock electronAPI for store methods that call it
vi.stubGlobal('window', {
...globalThis.window,
electronAPI: {
getShortcutOverrides: vi.fn(() => Promise.resolve({})),
saveShortcutOverrides: vi.fn(),
},
});

import { buildKeyString, formatKeys, getDefaultKeys, useShortcutStore } from '../stores/shortcutStore';

function fakeKeyEvent(overrides: Partial<KeyboardEvent>): KeyboardEvent {
return {
key: '',
metaKey: false,
ctrlKey: false,
altKey: false,
shiftKey: false,
...overrides,
} as KeyboardEvent;
}

beforeEach(() => {
useShortcutStore.getState().resetAll();
});

// --- buildKeyString ---

describe('buildKeyString', () => {
it('returns null for lone modifier presses', () => {
expect(buildKeyString(fakeKeyEvent({ key: 'Meta', metaKey: true }))).toBeNull();
expect(buildKeyString(fakeKeyEvent({ key: 'Shift', shiftKey: true }))).toBeNull();
expect(buildKeyString(fakeKeyEvent({ key: 'Alt', altKey: true }))).toBeNull();
expect(buildKeyString(fakeKeyEvent({ key: 'Control', ctrlKey: true }))).toBeNull();
});

it('returns null when no modifier is held', () => {
expect(buildKeyString(fakeKeyEvent({ key: 'f' }))).toBeNull();
expect(buildKeyString(fakeKeyEvent({ key: 'Enter' }))).toBeNull();
});

it('builds Meta+key strings', () => {
expect(buildKeyString(fakeKeyEvent({ key: 'f', metaKey: true }))).toBe('Meta+f');
expect(buildKeyString(fakeKeyEvent({ key: 'b', metaKey: true }))).toBe('Meta+b');
});

it('lowercases single-char keys', () => {
expect(buildKeyString(fakeKeyEvent({ key: 'F', metaKey: true }))).toBe('Meta+f');
});

it('preserves multi-char key names', () => {
expect(buildKeyString(fakeKeyEvent({ key: 'Enter', metaKey: true }))).toBe('Meta+Enter');
expect(buildKeyString(fakeKeyEvent({ key: 'Escape', ctrlKey: true }))).toBe('Control+Escape');
});

it('combines multiple modifiers in order', () => {
expect(buildKeyString(fakeKeyEvent({ key: 'g', metaKey: true, shiftKey: true }))).toBe('Meta+Shift+g');
expect(buildKeyString(fakeKeyEvent({ key: 'z', metaKey: true, altKey: true }))).toBe('Meta+Alt+z');
});
});

// --- formatKeys ---

describe('formatKeys', () => {
it('formats Meta as ⌘', () => {
expect(formatKeys('Meta+f')).toBe('⌘F');
});

it('formats Shift as ⇧', () => {
expect(formatKeys('Meta+Shift+g')).toBe('⌘⇧G');
});

it('formats Alt as ⌥', () => {
expect(formatKeys('Meta+Alt+z')).toBe('⌘⌥Z');
});

it('formats Control as ⌃', () => {
expect(formatKeys('Control+c')).toBe('⌃C');
});
});

// --- Default shortcuts ---

describe('default shortcuts', () => {
it('includes findInPdf shortcut', () => {
const shortcut = useShortcutStore.getState().getShortcut('findInPdf');
expect(shortcut).toBeDefined();
expect(shortcut?.keys).toBe('Meta+f');
expect(shortcut?.label).toBe('Find in PDF');
});

it('includes highlightSelection shortcut', () => {
const shortcut = useShortcutStore.getState().getShortcut('highlightSelection');
expect(shortcut).toBeDefined();
expect(shortcut?.keys).toBe('Meta+e');
});

it('returns undefined for unknown shortcut IDs', () => {
expect(useShortcutStore.getState().getShortcut('nonexistent')).toBeUndefined();
});

it('getDefaultKeys returns the default for known IDs', () => {
expect(getDefaultKeys('findInPdf')).toBe('Meta+f');
expect(getDefaultKeys('toggleSidebar')).toBe('Meta+b');
});

it('getDefaultKeys returns undefined for unknown IDs', () => {
expect(getDefaultKeys('nonexistent')).toBeUndefined();
});
});

// --- Shortcut remapping ---

describe('shortcut remapping', () => {
it('remaps a shortcut and finds it by new keys', () => {
const store = useShortcutStore.getState();
const result = store.setShortcutKeys('findInPdf', 'Meta+Shift+f');
expect(result.success).toBe(true);

const updated = useShortcutStore.getState().getShortcut('findInPdf');
expect(updated?.keys).toBe('Meta+Shift+f');
});

it('detects conflicts when remapping to an existing key', () => {
const store = useShortcutStore.getState();
// Try to remap findInPdf to Meta+b which is toggleSidebar
const result = store.setShortcutKeys('findInPdf', 'Meta+b');
expect(result.success).toBe(false);
expect(result.conflict).toBe('Toggle Sidebar');
});

it('resets a shortcut to default', () => {
const store = useShortcutStore.getState();
store.setShortcutKeys('findInPdf', 'Meta+Shift+f');
expect(useShortcutStore.getState().getShortcut('findInPdf')?.keys).toBe('Meta+Shift+f');

useShortcutStore.getState().resetShortcut('findInPdf');
expect(useShortcutStore.getState().getShortcut('findInPdf')?.keys).toBe('Meta+f');
});

it('resetAll restores all defaults', () => {
const store = useShortcutStore.getState();
store.setShortcutKeys('findInPdf', 'Meta+Shift+f');
store.setShortcutKeys('toggleSidebar', 'Meta+Shift+b');

useShortcutStore.getState().resetAll();

expect(useShortcutStore.getState().getShortcut('findInPdf')?.keys).toBe('Meta+f');
expect(useShortcutStore.getState().getShortcut('toggleSidebar')?.keys).toBe('Meta+b');
});

it('remapped shortcut is found via key lookup (simulating keyboard dispatch)', () => {
useShortcutStore.getState().setShortcutKeys('findInPdf', 'Meta+Shift+f');

const keyString = buildKeyString(fakeKeyEvent({ key: 'f', metaKey: true, shiftKey: true }));
const match = useShortcutStore.getState().shortcuts.find((s) => s.keys === keyString);
expect(match?.id).toBe('findInPdf');

// Old key should no longer match findInPdf
const oldKeyString = buildKeyString(fakeKeyEvent({ key: 'f', metaKey: true }));
const oldMatch = useShortcutStore.getState().shortcuts.find((s) => s.keys === oldKeyString);
expect(oldMatch?.id).not.toBe('findInPdf');
});
});
Loading
Loading