mirror of
https://github.com/Foundry376/Mailspring.git
synced 2026-10-23 08:15:18 +02:00
212 lines
8.6 KiB
TypeScript
212 lines
8.6 KiB
TypeScript
import { test, expect, ElectronApplication, Page } from '@playwright/test';
|
|
import fs from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import { pathToFileURL } from 'url';
|
|
import { launchApp, closeApp, findComposer, closeComposerWindows } from '../helpers';
|
|
|
|
// Exercises every clipboard code path the app owns, against the real system
|
|
// clipboard: the main-process `clipboard` module (Electron 44's promise-based,
|
|
// ClipboardItem API), the renderer paste handlers in both composer editors
|
|
// (text, HTML, image blobs, and files copied in Finder/Explorer), and the
|
|
// `write-image-to-clipboard` IPC + `@electron/remote` writes behind the message
|
|
// context menus.
|
|
//
|
|
// Each test seeds the clipboard from the main process and then performs a real
|
|
// Cmd+V in the composer, so a regression anywhere in the chain fails the test.
|
|
|
|
let electronApp: ElectronApplication;
|
|
let mainWindow: Page;
|
|
let configDir: string;
|
|
let fixtureDir: string;
|
|
|
|
// A 1x1 red PNG.
|
|
const PNG_DATA_URL =
|
|
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==';
|
|
|
|
/** Replace the system clipboard with the given MIME → text payloads. */
|
|
async function writeClipboardText(items: Record<string, string>) {
|
|
await electronApp.evaluate(async ({ clipboard, ClipboardItem }, data) => {
|
|
await clipboard.write([new ClipboardItem(data)]);
|
|
}, items);
|
|
}
|
|
|
|
/**
|
|
* Replace the system clipboard with a screenshot-sized PNG. Noise keeps the PNG
|
|
* above the 512-byte floor `Utils.shouldDisplayAsImage` uses for inlining.
|
|
*/
|
|
async function writeClipboardImage() {
|
|
await electronApp.evaluate(async ({ clipboard, ClipboardItem, nativeImage }) => {
|
|
const size = 64;
|
|
const bitmap = Buffer.alloc(size * size * 4);
|
|
for (let i = 0; i < bitmap.length; i++) bitmap[i] = Math.floor(Math.random() * 256);
|
|
const png = nativeImage.createFromBitmap(bitmap, { width: size, height: size }).toPNG();
|
|
await clipboard.write([
|
|
new ClipboardItem({ 'image/png': new Blob([png], { type: 'image/png' }) }),
|
|
]);
|
|
});
|
|
}
|
|
|
|
async function clipboardHas(mimeType: string): Promise<boolean> {
|
|
return electronApp.evaluate(({ clipboard }, type) => clipboard.has(type), mimeType);
|
|
}
|
|
|
|
async function readClipboardText(): Promise<string> {
|
|
return electronApp.evaluate(({ clipboard }) => clipboard.readText());
|
|
}
|
|
|
|
async function openComposer(): Promise<{
|
|
composerPage: Page;
|
|
composer: ReturnType<Page['locator']>;
|
|
}> {
|
|
await mainWindow.locator('#sheet-container').click();
|
|
await mainWindow.keyboard.press('c');
|
|
const composerPage = await findComposer(electronApp);
|
|
expect(composerPage).not.toBeNull();
|
|
const composer = composerPage!.locator('.composer-inner-wrap').last();
|
|
await expect(composer).toBeVisible({ timeout: 5_000 });
|
|
return { composerPage: composerPage!, composer };
|
|
}
|
|
|
|
async function focusRichBody(composerPage: Page, composer: ReturnType<Page['locator']>) {
|
|
const body = composer.locator('.RichEditor-content [contenteditable="true"]').first();
|
|
await body.click();
|
|
await expect(body).toBeFocused();
|
|
return body;
|
|
}
|
|
|
|
async function setComposingHTML(enabled: boolean) {
|
|
await mainWindow.evaluate((value) => {
|
|
(window as any).AppEnv.config.set('core.composing.html', value);
|
|
}, enabled);
|
|
}
|
|
|
|
test.beforeAll(async () => {
|
|
({ electronApp, mainWindow, configDir } = await launchApp());
|
|
fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mailspring-clipboard-'));
|
|
});
|
|
|
|
test.afterAll(async () => {
|
|
await closeApp(electronApp, configDir);
|
|
fs.rmSync(fixtureDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test.afterEach(async () => {
|
|
await closeComposerWindows(electronApp);
|
|
await mainWindow.waitForTimeout(1500);
|
|
});
|
|
|
|
// --- Rich text composer -----------------------------------------------------
|
|
|
|
test('pasting CRLF plain text into the rich composer keeps every line', async () => {
|
|
await writeClipboardText({ 'text/plain': 'First line\r\n\r\nThird line' });
|
|
|
|
const { composerPage, composer } = await openComposer();
|
|
const body = await focusRichBody(composerPage, composer);
|
|
await composerPage.keyboard.press('Meta+v');
|
|
|
|
await expect(body).toContainText('First line');
|
|
await expect(body).toContainText('Third line');
|
|
// Each line lands in its own Slate block, with the blank line preserved between
|
|
// them and no stray `\r` (the app normalizes CRLF before Slate splits on `\n`).
|
|
const texts = await body
|
|
.locator('> div[data-key]')
|
|
.evaluateAll((els) => els.map((el) => el.textContent!.replace(/\uFEFF/g, '')));
|
|
expect(texts.slice(0, 3)).toEqual(['First line', '', 'Third line']);
|
|
expect(texts.join('')).not.toContain('\r');
|
|
});
|
|
|
|
test('pasting HTML into the rich composer preserves formatting', async () => {
|
|
await writeClipboardText({
|
|
'text/html': '<p>Hello <strong>bold</strong> world</p>',
|
|
'text/plain': 'Hello bold world',
|
|
});
|
|
|
|
const { composerPage, composer } = await openComposer();
|
|
const body = await focusRichBody(composerPage, composer);
|
|
await composerPage.keyboard.press('Meta+v');
|
|
|
|
await expect(body).toContainText('Hello bold world');
|
|
await expect(body.locator('strong, b').filter({ hasText: 'bold' })).toHaveCount(1);
|
|
});
|
|
|
|
test('pasting an image into the rich composer attaches it inline', async () => {
|
|
await writeClipboardImage();
|
|
|
|
const { composerPage, composer } = await openComposer();
|
|
await focusRichBody(composerPage, composer);
|
|
await composerPage.keyboard.press('Meta+v');
|
|
|
|
// Image pastes become an inline (cid:) attachment rendered inside the editor,
|
|
// not an entry in the attachments area.
|
|
await expect(composer.locator('.RichEditor-content .image-attachment-item')).toHaveCount(1, {
|
|
timeout: 10_000,
|
|
});
|
|
await expect(composer.locator('.attachments-area .file-upload')).toHaveCount(0);
|
|
});
|
|
|
|
test('pasting a file copied from the OS file manager attaches it by path', async () => {
|
|
const filePath = path.join(fixtureDir, 'Quarterly Report.txt');
|
|
fs.writeFileSync(filePath, 'hello from playwright');
|
|
|
|
// Finder / Explorer / GTK put copied files on the pasteboard as the native
|
|
// file-reference format, which Electron reads and writes as `text/uri-list`.
|
|
await writeClipboardText({ 'text/uri-list': pathToFileURL(filePath).href });
|
|
|
|
const { composerPage, composer } = await openComposer();
|
|
await focusRichBody(composerPage, composer);
|
|
await composerPage.keyboard.press('Meta+v');
|
|
|
|
const attachment = composer.locator('.attachments-area .file-upload');
|
|
await expect(attachment).toHaveCount(1, { timeout: 10_000 });
|
|
await expect(attachment.locator('.file-name')).toHaveText('Quarterly Report.txt');
|
|
});
|
|
|
|
// --- Plain text composer ----------------------------------------------------
|
|
|
|
test('plaintext composer pastes text and attaches copied files', async () => {
|
|
await setComposingHTML(false);
|
|
try {
|
|
const { composerPage, composer } = await openComposer();
|
|
const textarea = composer.locator('.composer-editor-plaintext textarea');
|
|
await expect(textarea).toBeVisible({ timeout: 5_000 });
|
|
|
|
await writeClipboardText({ 'text/plain': 'plain body text' });
|
|
await textarea.click();
|
|
await composerPage.keyboard.press('Meta+v');
|
|
await expect(textarea).toHaveValue(/plain body text/, { timeout: 5_000 });
|
|
|
|
const filePath = path.join(fixtureDir, 'notes.md');
|
|
fs.writeFileSync(filePath, '# notes');
|
|
await writeClipboardText({ 'text/uri-list': pathToFileURL(filePath).href });
|
|
await textarea.click();
|
|
await composerPage.keyboard.press('Meta+v');
|
|
|
|
const attachment = composer.locator('.attachments-area .file-upload');
|
|
await expect(attachment).toHaveCount(1, { timeout: 10_000 });
|
|
await expect(attachment.locator('.file-name')).toHaveText('notes.md');
|
|
// The file path must not have been pasted as text.
|
|
await expect(textarea).toHaveValue(/^plain body text\s*$/);
|
|
} finally {
|
|
await setComposingHTML(true);
|
|
}
|
|
});
|
|
|
|
// --- Context-menu copy paths (renderer → system clipboard) ------------------
|
|
|
|
test('renderer copy actions write the system clipboard', async () => {
|
|
// "Copy Image" in message bodies goes through the main process.
|
|
await writeClipboardText({ 'text/plain': 'placeholder' });
|
|
expect(await clipboardHas('image/png')).toBe(false);
|
|
await mainWindow.evaluate((dataURL) => {
|
|
(window as any).require('electron').ipcRenderer.send('write-image-to-clipboard', dataURL);
|
|
}, PNG_DATA_URL);
|
|
await expect.poll(() => clipboardHas('image/png'), { timeout: 5_000 }).toBe(true);
|
|
|
|
// "Copy Email Address" / "Copy Link" use the main-process clipboard via @electron/remote.
|
|
await mainWindow.evaluate(() => {
|
|
(window as any).require('@electron/remote').clipboard.writeText('copied@example.com');
|
|
});
|
|
await expect.poll(() => readClipboardText(), { timeout: 5_000 }).toBe('copied@example.com');
|
|
});
|