文件
mynode/packages/app-desktop/integration-tests/markdownEditor.spec.ts
chen 301194963b
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
feat: Initial clean release of CHM
2026-11-04 11:29:39 +08:00

513 行
21 KiB
TypeScript

import { test, expect } from './util/test';
import MainScreen from './models/MainScreen';
import { join } from 'path';
import { readFile } from 'fs-extra';
import getImageSourceSize from './util/getImageSourceSize';
import setFilePickerResponse from './util/setFilePickerResponse';
import activateMainMenuItem from './util/activateMainMenuItem';
import setSettingValue from './util/setSettingValue';
import { toForwardSlashes } from '@joplin/utils/path';
import mockClipboard from './util/mockClipboard';
import { ElectronApplication, Page } from '@playwright/test';
import { extractResourceUrls } from '@joplin/lib/urlUtils';
const importAndOpenHtmlExport = async (mainWindow: Page, electronApp: ElectronApplication, noteTitle: string) => {
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.waitFor();
await mainScreen.importHtmlDirectory(electronApp, join(__dirname, 'resources', 'html-import'));
const importedFolder = mainScreen.sidebar.container.getByText('html-import');
await importedFolder.waitFor();
// Retry -- focusing the imported-folder may fail in some cases
await expect(async () => {
await importedFolder.click();
await mainScreen.noteList.focusContent(electronApp);
const importedHtmlFileItem = mainScreen.noteList.getNoteItemByTitle(noteTitle);
await importedHtmlFileItem.click({ timeout: 300 });
}).toPass();
return { mainScreen };
};
test.describe('markdownEditor', () => {
test('editor should render the full content of HTML notes', async ({ mainWindow, electronApp }) => {
const { mainScreen } = await importAndOpenHtmlExport(mainWindow, electronApp, 'test-html-file-with-spans');
await mainScreen.noteEditor.showMarkdownEditor();
// Regression test: The <span> should not be hidden by inline Markdown rendering (since this is an HTML note):
await mainScreen.noteEditor.expectToHaveText('<p><span style="margin-left: 100px;">test</span></p>');
});
test('preview pane should render images in HTML notes', async ({ mainWindow, electronApp }) => {
const { mainScreen } = await importAndOpenHtmlExport(mainWindow, electronApp, 'test-html-file-with-image');
const viewerFrame = (await mainScreen.noteEditor.showNoteViewer()).content;
// Should render headers
await expect(viewerFrame.locator('h1')).toHaveText('Test HTML file!');
// Should render images
const image = viewerFrame.getByAltText('An SVG image.');
await expect(image).toBeAttached();
await expect(await getImageSourceSize(image)).toMatchObject([117, 30]);
});
test('preview pane should render PDFs', async ({ mainWindow, electronApp }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.createNewNote('PDF attachments');
const editor = mainScreen.noteEditor;
await editor.showMarkdownEditor();
await setFilePickerResponse(electronApp, [join(__dirname, 'resources', 'small-pdf.pdf')]);
await editor.attachFileButton.click();
const viewerFrame = (await mainScreen.noteEditor.showNoteViewer()).content;
const pdfLink = viewerFrame.getByText('small-pdf.pdf');
await expect(pdfLink).toBeVisible();
const expectToBeRendered = async () => {
// PDF preview should render
const pdfViewer = viewerFrame.locator('object[data$=".pdf"]');
// Should create the PDF viewer. Note: This is not sufficient to determine that the PDF viewer
// has rendered.
await expect(pdfViewer).toBeAttached();
// Verify that the PDF viewer has rendered. This relies on how Chrome/Electron loads custom PDFs
// in an object.
// If this breaks due to an Electron upgrade,
// 1. manually verify that the PDF viewer has loaded and
// 2. replace this test with a screenshot comparison (https://playwright.dev/docs/test-snapshots)
await expect.poll(
() => pdfViewer.evaluate((handle) => {
const contentDocument = (handle as HTMLObjectElement).contentDocument;
const pdfViewerAsset = contentDocument.querySelector('link[href*=pdf_embedder]');
return !!pdfViewerAsset;
}),
).toBe(true);
};
await expectToBeRendered();
// Should still render after switching editors
await mainScreen.noteEditor.showRichTextEditor();
await mainScreen.noteEditor.showNoteViewer();
await expectToBeRendered();
});
test('preview pane should render video attachments', async ({ mainWindow, electronApp }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.createNewNote('Media attachments');
const editor = mainScreen.noteEditor;
await editor.showMarkdownEditor();
await setFilePickerResponse(electronApp, [join(__dirname, 'resources', 'video.mp4')]);
await editor.attachFileButton.click();
const videoLocator = (await editor.showNoteViewer()).content.locator('video');
const expectVideoToRender = async () => {
await expect(videoLocator).toBeSeekableMediaElement(6.9, 7);
};
await expectVideoToRender();
// Should be able to render again if the editor is closed and re-opened.
await mainScreen.noteEditor.showRichTextEditor();
await mainScreen.noteEditor.showNoteViewer();
await expectVideoToRender();
});
test('arrow keys should navigate the toolbar', async ({ mainWindow }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.waitFor();
await mainScreen.createNewNote('Note 1');
await mainScreen.createNewNote('Note 2');
const noteEditor = mainScreen.noteEditor;
await (await noteEditor.showMarkdownEditor()).focusContent();
// Escape, then Shift+Tab should focus the toolbar
await mainWindow.keyboard.press('Escape');
await mainWindow.keyboard.press('Shift+Tab');
// Should focus the first item by default, the "back" arrow (back to "Note 1")
const firstItemLocator = noteEditor.toolbarButtonLocator('Back');
await expect(firstItemLocator).toBeFocused();
// Left arrow should wrap to the end
await mainWindow.keyboard.press('ArrowLeft');
const lastItemLocator = noteEditor.toolbarButtonLocator('Toggle editors');
await expect(lastItemLocator).toBeFocused();
await mainWindow.keyboard.press('ArrowRight');
await expect(firstItemLocator).toBeFocused();
// ArrowRight should skip disabled items (Forward).
await mainWindow.keyboard.press('ArrowRight');
await expect(noteEditor.toolbarButtonLocator('Toggle external editing')).toBeFocused();
// Home/end should navigate to the first/last items
await mainWindow.keyboard.press('End');
await expect(lastItemLocator).toBeFocused();
await mainWindow.keyboard.press('Home');
await expect(firstItemLocator).toBeFocused();
});
test('should sync local search between the viewer and editor', async ({ mainWindow }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.waitFor();
const noteEditor = mainScreen.noteEditor;
await mainScreen.createNewNote('Note');
const { editor: markdownEditor, viewer } = await noteEditor.showNoteViewerAndMarkdownEditor();
await markdownEditor.typeText('# Testing');
await markdownEditor.pressKey('Enter');
await markdownEditor.pressKey('Enter');
await markdownEditor.typeText('This is a test of search. `Test inline code`');
await expect(viewer.content.locator('h1')).toHaveText('Testing');
const matches = viewer.content.locator('mark-ghost');
await expect(matches).toHaveCount(0);
await markdownEditor.pressKey(process.platform === 'darwin' ? 'Meta+f' : 'Control+f');
await expect(noteEditor.editorSearchInput).toBeVisible();
await noteEditor.editorSearchInput.click();
await noteEditor.editorSearchInput.fill('test');
await mainWindow.keyboard.press('Enter');
// Should show at least one match in the viewer
await expect(matches).toHaveCount(3);
await expect(matches.first()).toBeAttached();
// Should show matches in code regions
await noteEditor.editorSearchInput.fill('inline code');
await mainWindow.keyboard.press('Enter');
await expect(matches).toHaveCount(1);
// Should continue searching after switching to view-only mode
await noteEditor.toggleEditorLayout();
await noteEditor.toggleEditorLayout();
await expect(markdownEditor.container).not.toBeVisible();
await expect(noteEditor.editorSearchInput).not.toBeVisible();
await expect(noteEditor.viewerSearchInput).toBeVisible();
// Should stop searching after closing the search input
await noteEditor.viewerSearchInput.click();
await expect(matches).toHaveCount(1);
await mainWindow.keyboard.press('Escape');
await expect(noteEditor.viewerSearchInput).not.toBeVisible();
await expect(matches).toHaveCount(0);
// After showing the viewer again, search should still be hidden
await noteEditor.toggleEditorLayout();
await expect(markdownEditor.container).toBeVisible();
await expect(noteEditor.editorSearchInput).not.toBeVisible();
});
test('should move focus when the visible editor panes change', async ({ mainWindow, electronApp }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.waitFor();
const noteEditor = mainScreen.noteEditor;
await mainScreen.createNewNote('Note');
const {
viewer: markdownViewer, editor: markdownEditor,
} = await noteEditor.showNoteViewerAndMarkdownEditor();
await markdownEditor.content.click();
await mainWindow.keyboard.type('test');
const focusInMarkdownEditor = markdownEditor.container.locator(':focus');
await expect(focusInMarkdownEditor).toBeAttached();
// Use the "toggle layout" menu item to avoid changing focus
const toggleEditorLayout = () => activateMainMenuItem(electronApp, 'Toggle editor layout');
// Editor only
await toggleEditorLayout();
// Markdown editor should be focused
await expect(focusInMarkdownEditor).toBeAttached();
// Viewer only
await toggleEditorLayout();
await expect(markdownEditor.container).not.toBeVisible();
// Viewer should be focused
await expect(markdownViewer.container).toBeFocused();
// Viewer and editor
await toggleEditorLayout();
await expect(markdownEditor.container).toBeAttached();
await expect(markdownViewer.container).toBeVisible();
// Editor should be focused
await expect(focusInMarkdownEditor).toBeAttached();
});
test('focusElementNoteViewer should move focus to the viewer', async ({ mainWindow, electronApp }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.waitFor();
const noteEditor = mainScreen.noteEditor;
await mainScreen.createNewNote('Note');
const markdownEditor = await noteEditor.showMarkdownEditor();
await markdownEditor.typeText('# Test');
await markdownEditor.pressKey('Enter');
await markdownEditor.pressKey('Enter');
await markdownEditor.typeText('Test paragraph.');
// Wait for rendering
const noteViewer = await noteEditor.showNoteViewer();
await expect(noteViewer.content.getByText('Test paragraph.')).toBeAttached();
// Move focus
await mainScreen.goToAnything.runCommand(electronApp, 'focusElementNoteViewer');
// Note viewer should be focused
await expect(noteViewer.container).toBeFocused();
});
test('local file URLs setting should allow loading images from local file URLs', async ({ mainWindow, electronApp }) => {
await setSettingValue(electronApp, mainWindow, 'renderer.fileUrls', true);
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.waitFor();
await mainScreen.createNewNote('Test local file URLs');
const editor = mainScreen.noteEditor;
const markdownEditor = await editor.showMarkdownEditor();
await markdownEditor.typeText(`![Test image](file://${toForwardSlashes(join(__dirname, 'resources', 'test.png'))})`);
const renderedImage = (await editor.showNoteViewer()).content.getByRole('img', { name: 'Test image' });
await expect(renderedImage).toBeAttached();
const imageSize = await getImageSourceSize(renderedImage);
expect(imageSize[0]).toBeGreaterThan(0);
expect(imageSize[1]).toBeGreaterThan(0);
});
test('ctrl-clicking on note links should open the linked note (when the viewer is hidden)', async ({ mainWindow, electronApp }) => {
const mainScreen = await new MainScreen(mainWindow);
// Workaround: Required for extracting content accurately from the Markdown editor
await mainScreen.noteEditor.disableInlineRendering(electronApp);
await mainScreen.setup();
await mainScreen.createNewNote('Original');
const { editor: markdownEditor } = await mainScreen.noteEditor.showNoteViewerAndMarkdownEditor();
const noteEditor = mainScreen.noteEditor;
await noteEditor.hideViewer();
await markdownEditor.focusContent();
await markdownEditor.typeText('# Test');
await markdownEditor.pressKey('Enter');
await markdownEditor.typeText('## Test 2');
await markdownEditor.pressKey('Enter');
await markdownEditor.typeText('### Test 3');
const editorContent = markdownEditor.content;
// Extract the note ID
const note1Locator = mainScreen.noteList.getNoteItemByTitle('Original');
await note1Locator.dragTo(editorContent);
const linkExpression = /\[[^\]]*\]\(:\/([a-z0-9]{32})\)/;
await noteEditor.expectToHaveText(linkExpression);
const targetNoteId = (await editorContent.textContent()).match(linkExpression)[1];
await mainScreen.createNewNote('Test note links');
// Create a new link to a header
await markdownEditor.focusContent();
await markdownEditor.pressKey('Enter');
await markdownEditor.pressKey('Enter');
await markdownEditor.typeText('[link](:/');
await markdownEditor.typeText(targetNoteId);
await markdownEditor.typeText('#test-2');
await markdownEditor.typeText(')');
await markdownEditor.pressKey('Enter');
// Clicking the link should navigate to note1
const link = editorContent.getByText(/\[?link\]?/);
await link.click({ modifiers: ['ControlOrMeta'] });
await expect(noteEditor.noteTitleInput).toHaveValue('Original');
await noteEditor.expectToHaveText(/^# Test/);
await expect.poll(() => editorContent.evaluate(async editor => {
const selection = getSelection();
return editor.contains(selection.anchorNode);
})).toBe(true);
// The cursor should be positioned on the linked-to header
await expect.poll(async () => {
await mainWindow.keyboard.type('[[cursor]]');
await noteEditor.expectToHaveText(/## Test 2\[\[cursor\]\]/);
return true;
}).toBe(true);
});
test('should still support the legacy Markdown editor', async ({ electronApp, mainWindow }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.waitFor();
await setSettingValue(electronApp, mainWindow, 'editor.legacyMarkdown', true);
await mainScreen.createNewNote('Test');
// Should show the legacy editor
await expect(mainWindow.locator('.rli-editor .CodeMirror5')).toBeVisible();
});
test('should support the textCopy command', async ({ electronApp, mainWindow }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.waitFor();
await mainScreen.createNewNote('Test copy');
const noteEditor = mainScreen.noteEditor;
const markdownEditor = await noteEditor.showMarkdownEditor();
await markdownEditor.typeText('Test content.');
const { expectClipboardToMatch } = await mockClipboard(electronApp, 'original');
await mainScreen.goToAnything.runCommand(electronApp, 'textCopy');
await expectClipboardToMatch('Test content.\n');
});
test('should support the textCut and textPaste commands', async ({ electronApp, mainWindow }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.waitFor();
await mainScreen.createNewNote('Test paste');
await mainScreen.noteEditor.showMarkdownEditor();
const { expectClipboardToMatch } = await mockClipboard(electronApp, 'test!');
await expectClipboardToMatch('test!');
// Should paste text using the textPaste command
const goToAnything = mainScreen.goToAnything;
await goToAnything.runCommand(electronApp, 'textPaste');
const noteEditor = mainScreen.noteEditor;
await noteEditor.expectToHaveText('test!');
// Should cut text using the textCut command
await mainScreen.createNewNote('Test cut');
const markdownEditor = await noteEditor.showMarkdownEditor();
await markdownEditor.typeText('Test (new content!)');
await goToAnything.runCommand(electronApp, 'textCut');
await noteEditor.expectToHaveText('\n');
await expectClipboardToMatch('Test (new content!)\n');
// Should paste the content again with textPaste
await goToAnything.runCommand(electronApp, 'textPaste');
await noteEditor.expectToHaveText(/^Test \(new content!\)[\n]+/);
});
for (const testCase of [
{ name: 'CodeMirror 6', useLegacyMarkdownEditor: false },
{ name: 'CodeMirror 5', useLegacyMarkdownEditor: true },
]) {
test(`should paste Affinity Copy Merged clipboard data as an image resource (${testCase.name})`, async ({ electronApp, mainWindow }) => {
const mainScreen = new MainScreen(mainWindow);
await setSettingValue(electronApp, mainWindow, 'editor.legacyMarkdown', testCase.useLegacyMarkdownEditor);
await mainScreen.noteEditor.disableInlineRendering(electronApp);
await setSettingValue(electronApp, mainWindow, 'imageResizing', 'neverResize');
await mainScreen.setup();
const noteEditor = await mainScreen.createNewNote('Test Affinity image paste');
const markdownEditor = await noteEditor.showMarkdownEditor();
const editorContent = markdownEditor.container;
const noteBody = async () => editorContent.innerText();
await editorContent.click();
const affinityClipboardText = await readFile(join(__dirname, 'resources', 'affinity.txt'), 'utf8');
await mainWindow.evaluate((affinityClipboardText: string) => {
const { clipboard, nativeImage } = require('electron');
const affinityImageData = affinityClipboardText.match(/data:image\/png;base64,[^"]+/)?.[0];
if (!affinityImageData) throw new Error('Affinity clipboard text does not contain PNG image data');
const image = nativeImage.createFromDataURL(affinityImageData);
if (image.isEmpty()) throw new Error('Could not create image from Affinity clipboard data');
clipboard.write({
text: affinityClipboardText,
image,
});
}, affinityClipboardText);
await mainScreen.goToAnything.runCommand(electronApp, 'textPaste');
await expect.poll(async () => {
const currentNoteBody = (await noteBody()).trimEnd();
const resourceUrls = extractResourceUrls(currentNoteBody);
const isImageResource = currentNoteBody.startsWith('![') && resourceUrls.length === 1 && currentNoteBody.endsWith(`](:/${resourceUrls[0].itemId})`);
const doesNotContainSvg = !currentNoteBody.includes('<svg');
const doesNotContainBase64 = !currentNoteBody.includes('data:image/png;base64,');
return isImageResource && doesNotContainSvg && doesNotContainBase64;
}).toBe(true);
const renderedImage = (await noteEditor.showNoteViewer()).content.locator('img');
await expect(renderedImage).toHaveCount(1);
await expect(await getImageSourceSize(renderedImage.first())).toMatchObject([64, 64]);
});
}
test('the undo and redo menu items should work', async ({ mainWindow, electronApp }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.waitFor();
await mainScreen.createNewNote('Test undo/redo');
const noteEditor = mainScreen.noteEditor;
const markdownEditor = await noteEditor.showMarkdownEditor();
await markdownEditor.typeText('A');
await noteEditor.expectToHaveText('A');
await activateMainMenuItem(electronApp, 'Undo');
await noteEditor.expectToHaveText('\n');
await activateMainMenuItem(electronApp, 'Redo');
await noteEditor.expectToHaveText('A');
});
test('copying from the preview pane should not include theme background color and should preserve bold formatting', async ({ mainWindow, electronApp }) => {
// Set dark theme so background-color would be present in clipboard without the fix
await setSettingValue(electronApp, mainWindow, 'theme', 2);
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.waitFor();
await mainScreen.createNewNote('Test copy formatting');
const noteEditor = mainScreen.noteEditor;
const markdownEditor = await noteEditor.showMarkdownEditor();
await markdownEditor.typeText('**hello**');
const viewerFrame = (await mainScreen.noteEditor.showNoteViewer()).content;
await expect(viewerFrame.locator('strong')).toHaveText('hello');
// Double-click selects the text node inside <strong>, not <strong> itself.
// Without the ancestor re-wrapping fix, <strong> would be dropped.
await viewerFrame.locator('strong').dblclick();
const modifier = process.platform === 'darwin' ? 'Meta' : 'Control';
await mainWindow.keyboard.press(`${modifier}+c`);
const clipboardHtml = await mainWindow.evaluate(() => {
const { clipboard } = require('electron');
return clipboard.readHTML();
});
expect(clipboardHtml).toContain('hello');
// Dark theme background (#1D2024) must not leak into clipboard
expect(clipboardHtml).not.toMatch(/1D2024/i);
expect(clipboardHtml).toContain('<strong');
expect(clipboardHtml).toMatch(/font-weight/i);
});
});