From 7db3fc4497ff1f57150c5a10b1efb5a83bffcaea Mon Sep 17 00:00:00 2001 From: Dan Essig Date: Fri, 3 Apr 2026 21:46:29 -0700 Subject: [PATCH 1/2] fix context menu issue, add e2e test for context functionality --- e2e/context-menu.spec.ts | 127 ++++++++++++++++++ e2e/image-clipboard.spec.ts | 36 +++-- .../components/clips/clip/ClipContextMenu.tsx | 6 +- 3 files changed, 153 insertions(+), 16 deletions(-) create mode 100644 e2e/context-menu.spec.ts diff --git a/e2e/context-menu.spec.ts b/e2e/context-menu.spec.ts new file mode 100644 index 0000000..4b795fa --- /dev/null +++ b/e2e/context-menu.spec.ts @@ -0,0 +1,127 @@ +import { test, expect, _electron as electron, ElectronApplication, Page } from '@playwright/test'; +import { resolve } from 'path'; + +const appPath = resolve(__dirname, '../out/main/index.js'); +const UNIQUE = Date.now().toString(36); + +async function launchApp(): Promise<{ app: ElectronApplication; window: Page }> { + const app = await electron.launch({ args: [appPath] }); + const window = await app.firstWindow(); + await window.waitForSelector('#root > *'); + return { app, window }; +} + +test.describe('Context Menu', () => { + let app: ElectronApplication; + let window: Page; + + const clipA = `ctx-clip-a-${UNIQUE}`; + const clipB = `ctx-clip-b-${UNIQUE}`; + + test.beforeAll(async () => { + const result = await launchApp(); + app = result.app; + window = result.window; + + // Add two clips: clipA first, then clipB (clipB will be index 0, clipA index 1) + await app.evaluate(async ({ clipboard }, t) => { + clipboard.writeText(t); + }, clipA); + await window.waitForTimeout(1000); + + await app.evaluate(async ({ clipboard }, t) => { + clipboard.writeText(t); + }, clipB); + await window.waitForTimeout(1000); + + // Verify both clips appear + await expect(window.locator(`text=${clipA}`).first()).toBeVisible({ timeout: 5000 }); + await expect(window.locator(`text=${clipB}`).first()).toBeVisible({ timeout: 5000 }); + }); + + test.afterAll(async () => { + await app.close(); + }); + + test('right-click on a non-first clip shows context menu with all options', async () => { + // clipA is at index 1 (non-first) — right-click it + await window.locator(`text=${clipA}`).first().click({ button: 'right' }); + + // Context menu should appear with all items + await expect(window.locator('text=Copy to Clipboard')).toBeVisible({ timeout: 3000 }); + await expect(window.locator('text=Open Tools Launcher')).toBeVisible(); + await expect(window.locator('text=Lock Clip')).toBeVisible(); + await expect(window.locator('text=Delete Clip')).toBeVisible(); + + // Close by pressing Escape + await window.keyboard.press('Escape'); + }); + + test('right-click on a clip further down the list shows context menu fully visible', async () => { + // Add several more clips to push items down the list + for (let i = 0; i < 8; i++) { + await app.evaluate( + async ({ clipboard }, t) => { + clipboard.writeText(t); + }, + `ctx-filler-${i}-${UNIQUE}` + ); + await window.waitForTimeout(500); + } + await window.waitForTimeout(1000); + + // Scroll to clipA (now further down) and right-click + const target = window.locator(`text=${clipA}`).first(); + await target.scrollIntoViewIfNeeded(); + await target.click({ button: 'right' }); + + // Context menu should be fully visible (portalled to body, not clipped by overflow) + await expect(window.locator('text=Copy to Clipboard')).toBeVisible({ timeout: 3000 }); + await expect(window.locator('text=Delete Clip')).toBeVisible(); + + await window.keyboard.press('Escape'); + }); + + test('context menu closes when clicking elsewhere', async () => { + const target = window.locator(`text=${clipA}`).first(); + await target.scrollIntoViewIfNeeded(); + await target.click({ button: 'right' }); + + await expect(window.locator('text=Copy to Clipboard')).toBeVisible({ timeout: 3000 }); + + // Click elsewhere to close + await window.locator('body').click({ position: { x: 5, y: 5 } }); + + await expect(window.locator('text=Copy to Clipboard')).toBeHidden({ timeout: 3000 }); + }); + + test('context menu closes on Escape key', async () => { + const target = window.locator(`text=${clipA}`).first(); + await target.scrollIntoViewIfNeeded(); + await target.click({ button: 'right' }); + + await expect(window.locator('text=Copy to Clipboard')).toBeVisible({ timeout: 3000 }); + + await window.keyboard.press('Escape'); + + await expect(window.locator('text=Copy to Clipboard')).toBeHidden({ timeout: 3000 }); + }); + + test('Copy to Clipboard action works from context menu', async () => { + const target = window.locator(`text=${clipA}`).first(); + await target.scrollIntoViewIfNeeded(); + await target.click({ button: 'right' }); + + await expect(window.locator('text=Copy to Clipboard')).toBeVisible({ timeout: 3000 }); + await window.locator('text=Copy to Clipboard').click(); + + // Menu should close after action + await expect(window.locator('text=Copy to Clipboard')).toBeHidden({ timeout: 3000 }); + + // Verify clipboard contains the expected text + const clipboardText = await app.evaluate(async ({ clipboard }) => { + return clipboard.readText(); + }); + expect(clipboardText).toBe(clipA); + }); +}); diff --git a/e2e/image-clipboard.spec.ts b/e2e/image-clipboard.spec.ts index b66c98a..e06d1ac 100644 --- a/e2e/image-clipboard.spec.ts +++ b/e2e/image-clipboard.spec.ts @@ -39,41 +39,49 @@ test.describe('Image Clipboard', () => { const window = await app.firstWindow(); await window.waitForSelector('#root > *'); + // Clear any persisted clips from previous test runs + await window.evaluate(async () => { + const api = (window as any).api; + await api.storageSaveClips([], {}); + }); + await window.waitForTimeout(500); + + // Reload the window to pick up the cleared state + await window.reload(); + await window.waitForSelector('#root > *'); + await window.waitForTimeout(500); + const testImagePath = resolve(__dirname, 'fixtures/test-image.png'); - // Copy first image + // Copy first image (original dimensions) await app.evaluate(async ({ clipboard, nativeImage }, imgPath) => { const image = nativeImage.createFromPath(imgPath); clipboard.writeImage(image); }, testImagePath); await window.waitForTimeout(2000); - await expect(window.locator('img[alt="Clipboard image preview"]').first()).toBeVisible({ - timeout: 5000, - }); + const imgPreviews = window.locator('img[alt="Clipboard image preview"]'); + await expect(imgPreviews.first()).toBeVisible({ timeout: 5000 }); - // Clear clipboard with text to reset, then copy a different image + // Verify exactly 1 image clip so far + await expect(imgPreviews).toHaveCount(1, { timeout: 3000 }); + + // Clear clipboard with text to reset the detection state await app.evaluate(async ({ clipboard }) => { clipboard.writeText('separator'); }); await window.waitForTimeout(1000); - // Copy image again (will have different fingerprint due to fresh nativeImage instance) + // Copy a different image (resized to different dimensions → stable, distinct fingerprint) await app.evaluate(async ({ clipboard, nativeImage }, imgPath) => { - // Create a slightly different image by modifying pixels const image = nativeImage.createFromPath(imgPath); - const size = image.getSize(); - const buf = image.toBitmap(); - // Flip a pixel to ensure different fingerprint - buf[0] = buf[0] === 0 ? 1 : 0; - const modified = nativeImage.createFromBitmap(buf, { width: size.width, height: size.height }); - clipboard.writeImage(modified); + const resized = image.resize({ width: 16, height: 16 }); + clipboard.writeImage(resized); }, testImagePath); await window.waitForTimeout(2000); // Should now have two image clips - const imgPreviews = window.locator('img[alt="Clipboard image preview"]'); await expect(imgPreviews).toHaveCount(2, { timeout: 5000 }); await app.close(); diff --git a/src/renderer/src/components/clips/clip/ClipContextMenu.tsx b/src/renderer/src/components/clips/clip/ClipContextMenu.tsx index 4cf8411..d3231c5 100644 --- a/src/renderer/src/components/clips/clip/ClipContextMenu.tsx +++ b/src/renderer/src/components/clips/clip/ClipContextMenu.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { useClipsActions } from '../../../providers/clips'; import { useTheme } from '../../../providers/theme'; @@ -106,7 +107,7 @@ export function ClipContextMenu({ index, x, y, onClose, hasPatterns }: ClipConte } }; - return ( + return createPortal(
Delete Clip
- + , + document.body ); } From bfabfef2a23308608a39ca5a10fc2f9a3e877adb Mon Sep 17 00:00:00 2001 From: Dan Essig Date: Fri, 3 Apr 2026 21:46:35 -0700 Subject: [PATCH 2/2] 1.7.3 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index cd9f981..3053a24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "clipless", - "version": "1.7.2", + "version": "1.7.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clipless", - "version": "1.7.2", + "version": "1.7.3", "hasInstallScript": true, "dependencies": { "@electron-toolkit/preload": "^3.0.1", diff --git a/package.json b/package.json index 00e2fc3..c3816d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clipless", - "version": "1.7.2", + "version": "1.7.3", "description": "An Electron application with React and TypeScript", "main": "./out/main/index.js", "author": "Daniel Essig",