feat: Initial clean release of CHM
一些检查失败了
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
一些检查失败了
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
这个提交包含在:
@@ -0,0 +1,23 @@
|
||||
|
||||
import { ElectronApplication, Locator, Page } from '@playwright/test';
|
||||
import MainScreen from './MainScreen';
|
||||
import activateMainMenuItem from '../util/activateMainMenuItem';
|
||||
|
||||
export default class ChangeAppLayoutScreen {
|
||||
public readonly containerLocator: Locator;
|
||||
|
||||
public constructor(page: Page, private readonly mainScreen: MainScreen) {
|
||||
this.containerLocator = page.locator('.change-app-layout-dialog[open]');
|
||||
}
|
||||
|
||||
public async open(electronApp: ElectronApplication) {
|
||||
await this.mainScreen.waitFor();
|
||||
await activateMainMenuItem(electronApp, 'Change application layout');
|
||||
|
||||
return this.waitFor();
|
||||
}
|
||||
|
||||
public async waitFor() {
|
||||
await this.containerLocator.waitFor();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ElectronApplication, Locator, Page } from '@playwright/test';
|
||||
import setSettingValue from '../util/setSettingValue';
|
||||
import MainScreen from './MainScreen';
|
||||
import { expect } from '../util/test';
|
||||
|
||||
export default class ChatPanel {
|
||||
public readonly container: Locator;
|
||||
public readonly textInput: Locator;
|
||||
|
||||
public constructor(private page_: Page, private mainScreen_: MainScreen) {
|
||||
this.container = page_.getByRole('region', { name: 'AI Chat' });
|
||||
this.textInput = page_.getByRole('textbox', { name: 'Chat message' });
|
||||
}
|
||||
|
||||
public async configure(electronApp: ElectronApplication) {
|
||||
await setSettingValue(electronApp, this.page_, 'ai.enabled', true);
|
||||
await setSettingValue(electronApp, this.page_, 'ai.allowRemote', true);
|
||||
await setSettingValue(electronApp, this.page_, 'ai.chat.providerType', 'test-provider');
|
||||
}
|
||||
|
||||
public async open(electronApp: ElectronApplication) {
|
||||
await this.mainScreen_.goToAnything.runCommand(electronApp, 'toggleAiChat');
|
||||
await this.container.waitFor();
|
||||
}
|
||||
|
||||
public async close(electronApp: ElectronApplication) {
|
||||
await this.container.waitFor();
|
||||
await this.mainScreen_.goToAnything.runCommand(electronApp, 'toggleAiChat');
|
||||
await expect(this.container).not.toBeVisible();
|
||||
}
|
||||
|
||||
public async sendMessage(message: string) {
|
||||
await this.textInput.fill(message);
|
||||
await this.textInput.press('Enter');
|
||||
}
|
||||
|
||||
public async waitForMessageCount(count: number) {
|
||||
await expect(this.container.locator('.chat-message.turn')).toHaveCount(count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
|
||||
import { ElectronApplication, expect, Locator, Page } from '@playwright/test';
|
||||
import MainScreen from './MainScreen';
|
||||
import activateMainMenuItem from '../util/activateMainMenuItem';
|
||||
import { msleep } from '@joplin/utils/time';
|
||||
|
||||
export default class GoToAnything {
|
||||
public readonly containerLocator: Locator;
|
||||
public readonly inputLocator: Locator;
|
||||
|
||||
public constructor(page: Page, private readonly mainScreen: MainScreen) {
|
||||
this.containerLocator = page.locator('.go-to-anything-dialog[open]');
|
||||
this.inputLocator = this.containerLocator.getByRole('textbox');
|
||||
}
|
||||
|
||||
public async waitFor(timeout?: number) {
|
||||
await this.containerLocator.waitFor({ timeout });
|
||||
}
|
||||
|
||||
public async open(electronApp: ElectronApplication) {
|
||||
await this.mainScreen.waitFor();
|
||||
|
||||
// The menu item toggles the dialog, so retrying unconditionally would close a dialog
|
||||
// that a previous slow attempt had just opened.
|
||||
await expect.poll(async () => {
|
||||
if (await this.containerLocator.isVisible()) return true;
|
||||
|
||||
await activateMainMenuItem(electronApp, 'Goto Anything...');
|
||||
try {
|
||||
await this.waitFor(5_000);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, {
|
||||
timeout: 30_000,
|
||||
message: 'should open the Goto Anything dialog',
|
||||
}).toBe(true);
|
||||
|
||||
// Filling the controlled input before React attaches onChange silently drops the text.
|
||||
await this.inputLocator.waitFor();
|
||||
}
|
||||
|
||||
public async openLinkToNote(electronApp: ElectronApplication) {
|
||||
await this.mainScreen.waitFor();
|
||||
await activateMainMenuItem(electronApp, 'Link to note...');
|
||||
return this.waitFor();
|
||||
}
|
||||
|
||||
public resultLocator(resultText: string|RegExp) {
|
||||
return this.containerLocator.getByRole('option', { name: resultText });
|
||||
}
|
||||
|
||||
public async searchForWithRetry(query: string, resultLocator: Locator) {
|
||||
// If note indexing hasn't finished, it's sometimes necessary to search multiple times.
|
||||
// This expect.poll retries the search if it initially fails.
|
||||
await expect.poll(async () => {
|
||||
await this.inputLocator.clear();
|
||||
// Pause to help ensure that the change in the search input is detected
|
||||
await msleep(300);
|
||||
await this.inputLocator.fill(query);
|
||||
try {
|
||||
await expect(resultLocator).toBeVisible({ timeout: 1000 });
|
||||
} catch (error) {
|
||||
// Return, rather than throw, the error -- expect.poll doesn't retry
|
||||
// if the callback throws.
|
||||
return error;
|
||||
}
|
||||
return true;
|
||||
}, { timeout: 15_000 }).toBe(true);
|
||||
}
|
||||
|
||||
public async expectToBeClosed() {
|
||||
await expect(this.containerLocator).not.toBeAttached();
|
||||
}
|
||||
|
||||
public async expectToBeOpen() {
|
||||
await expect(this.containerLocator).toBeAttached();
|
||||
}
|
||||
|
||||
public async runCommand(electronApp: ElectronApplication, command: string) {
|
||||
if (!command.startsWith(':')) {
|
||||
command = `:${command}`;
|
||||
}
|
||||
|
||||
await this.open(electronApp);
|
||||
await this.inputLocator.fill(command);
|
||||
await this.containerLocator.locator('.match-highlight').first().waitFor();
|
||||
await this.inputLocator.press('Enter');
|
||||
await this.expectToBeClosed();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Locator, Page } from '@playwright/test';
|
||||
|
||||
export default class JoplinCloudLoginScreen {
|
||||
public readonly copyLinkToWebsiteLocator: Locator;
|
||||
public constructor(private page_: Page) {
|
||||
this.copyLinkToWebsiteLocator = this.page_.getByRole('button', { name: 'Copy link to website' });
|
||||
}
|
||||
|
||||
public async waitFor() {
|
||||
await this.copyLinkToWebsiteLocator.waitFor();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Page, Locator, ElectronApplication } from '@playwright/test';
|
||||
import NoteEditorScreen from './NoteEditor/NoteEditorScreen';
|
||||
import activateMainMenuItem from '../util/activateMainMenuItem';
|
||||
import Sidebar from './Sidebar';
|
||||
import GoToAnything from './GoToAnything';
|
||||
import setFilePickerResponse from '../util/setFilePickerResponse';
|
||||
import NoteList from './NoteList';
|
||||
import { expect } from '../util/test';
|
||||
import ChangeAppLayoutScreen from './ChangeAppLayoutScreen';
|
||||
import waitForNextWindowMatching from '../util/waitForNextWindowMatching';
|
||||
import ChatPanel from './ChatPanel';
|
||||
|
||||
export default class MainScreen {
|
||||
public readonly newNoteButton: Locator;
|
||||
public readonly noteList: NoteList;
|
||||
public readonly sidebar: Sidebar;
|
||||
public readonly dialog: Locator;
|
||||
public readonly noteEditor: NoteEditorScreen;
|
||||
public readonly goToAnything: GoToAnything;
|
||||
public readonly changeLayoutScreen: ChangeAppLayoutScreen;
|
||||
public readonly chatPanel: ChatPanel;
|
||||
|
||||
public constructor(private page: Page) {
|
||||
this.newNoteButton = page.locator('.new-note-button');
|
||||
this.noteList = new NoteList(page);
|
||||
this.sidebar = new Sidebar(page, this);
|
||||
this.dialog = page.locator('.dialog-modal-layer');
|
||||
this.noteEditor = new NoteEditorScreen(page);
|
||||
this.goToAnything = new GoToAnything(page, this);
|
||||
this.changeLayoutScreen = new ChangeAppLayoutScreen(page, this);
|
||||
this.chatPanel = new ChatPanel(page, this);
|
||||
}
|
||||
|
||||
public async setup() {
|
||||
await this.waitFor();
|
||||
const folder = await this.sidebar.createNewFolder('Test');
|
||||
await folder.waitFor();
|
||||
return this;
|
||||
}
|
||||
|
||||
public async waitFor() {
|
||||
await this.noteList.waitFor();
|
||||
}
|
||||
|
||||
// Follows the steps a user would use to create a new note.
|
||||
public async createNewNote(title: string) {
|
||||
await this.waitFor();
|
||||
// The new note button is only visible when a folder is selected -- wait for it explicitly.
|
||||
await this.newNoteButton.waitFor();
|
||||
|
||||
// Create the new note. Retry this -- creating new notes can sometimes fail if done just after
|
||||
// application startup.
|
||||
await expect.poll(async () => {
|
||||
await this.newNoteButton.click();
|
||||
await expect(this.noteEditor.noteTitleInput).toHaveValue('', { timeout: 4_000 });
|
||||
await expect(this.noteEditor.noteTitleInput).toHaveJSProperty('placeholder', 'Creating new note...', { timeout: 4_000 });
|
||||
return true;
|
||||
}, { timeout: 10_000 }).toBe(true);
|
||||
|
||||
// Fill the title
|
||||
await this.noteEditor.noteTitleInput.click();
|
||||
await this.noteEditor.noteTitleInput.fill(title);
|
||||
|
||||
// Wait for the title to reach the note list, otherwise a later sort can run
|
||||
// while the note is still "Untitled" and order the list unexpectedly.
|
||||
if (title !== '') {
|
||||
await this.noteList.getNoteItemByTitle(title).waitFor();
|
||||
}
|
||||
|
||||
return this.noteEditor;
|
||||
}
|
||||
|
||||
public async openSettings(electronApp: ElectronApplication) {
|
||||
// Check both labels so this works on MacOS
|
||||
await activateMainMenuItem(electronApp, /^(Preferences\.\.\.|Options)$/);
|
||||
}
|
||||
|
||||
public async openNewWindow(electronApp: ElectronApplication) {
|
||||
const pagePromise = waitForNextWindowMatching(/^Joplin -/, electronApp);
|
||||
|
||||
await activateMainMenuItem(electronApp, 'Open in new window');
|
||||
return pagePromise;
|
||||
}
|
||||
|
||||
public async search(text: string) {
|
||||
const searchBar = this.page.getByPlaceholder('Search...');
|
||||
await searchBar.fill(text);
|
||||
}
|
||||
|
||||
private async importFromModule_(electronApp: ElectronApplication, moduleName: string, path: string) {
|
||||
await setFilePickerResponse(electronApp, [path]);
|
||||
await activateMainMenuItem(electronApp, moduleName, 'Import');
|
||||
}
|
||||
|
||||
public async importHtmlDirectory(electronApp: ElectronApplication, path: string) {
|
||||
return this.importFromModule_(electronApp, 'HTML - HTML document (Directory)', path);
|
||||
}
|
||||
|
||||
public async importHtmlFile(electronApp: ElectronApplication, path: string) {
|
||||
return this.importFromModule_(electronApp, 'HTML - HTML document (File)', path);
|
||||
}
|
||||
|
||||
public async pluginPanelLocator(pluginId: string) {
|
||||
return this.page.locator(
|
||||
`iframe[id^=${JSON.stringify(`plugin-view-${pluginId}`)}]`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
interface BaseEditor {
|
||||
typeText(text: string): Promise<void>;
|
||||
waitFor(): Promise<void>;
|
||||
}
|
||||
export default BaseEditor;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Locator, Page } from '@playwright/test';
|
||||
|
||||
export default class EditorCodeDialog {
|
||||
private readonly dialog: Locator;
|
||||
public readonly textArea: Locator;
|
||||
public readonly okButton: Locator;
|
||||
|
||||
public constructor(page: Page) {
|
||||
this.dialog = page.getByRole('dialog', { name: 'Edit' });
|
||||
this.textArea = this.dialog.locator('textarea');
|
||||
this.okButton = this.dialog.getByRole('button', { name: 'OK' });
|
||||
}
|
||||
|
||||
public async waitFor() {
|
||||
await this.dialog.waitFor();
|
||||
await this.textArea.waitFor();
|
||||
}
|
||||
|
||||
public async submit() {
|
||||
await this.okButton.click();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Locator } from '@playwright/test';
|
||||
import BaseEditor from './BaseEditor';
|
||||
|
||||
export default class MarkdownEditor implements BaseEditor {
|
||||
public readonly container: Locator;
|
||||
public readonly content: Locator;
|
||||
public constructor(parent: Locator) {
|
||||
this.container = parent.locator('.CodeMirror');
|
||||
this.content = this.container.locator('[contentEditable]');
|
||||
}
|
||||
|
||||
public async waitFor() {
|
||||
await this.container.waitFor();
|
||||
}
|
||||
|
||||
public async pressKey(text: string) {
|
||||
await this.content.press(text);
|
||||
}
|
||||
|
||||
public async typeText(text: string) {
|
||||
await this.focusContent();
|
||||
await this.content.pressSequentially(text);
|
||||
}
|
||||
|
||||
public async focusContent() {
|
||||
// eslint-disable-next-line no-restricted-properties -- The focusHandler wrapper can't be used here, since Playwright's focus needs to be awaited
|
||||
await this.content.focus();
|
||||
}
|
||||
|
||||
public async innerText() {
|
||||
return this.content.innerText();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { ElectronApplication, Locator, Page } from '@playwright/test';
|
||||
import { expect } from '../../util/test';
|
||||
import activateMainMenuItem from '../../util/activateMainMenuItem';
|
||||
import setSettingValue from '../../util/setSettingValue';
|
||||
import NoteViewer from './NoteViewer';
|
||||
import MarkdownEditor from './MarkdownEditor';
|
||||
import RichTextEditor from './RichTextEditor';
|
||||
|
||||
export default class NoteEditorScreen {
|
||||
public readonly editorPluginFrame: Locator;
|
||||
public readonly noteTitleInput: Locator;
|
||||
|
||||
public readonly attachFileButton: Locator;
|
||||
public readonly toggleCodeBlockButton: Locator;
|
||||
public readonly toggleEditorsButton: Locator;
|
||||
public readonly toggleEditorLayoutButton: Locator;
|
||||
private readonly disableTabNavigationButton: Locator;
|
||||
public readonly toggleEditorPluginButton: Locator;
|
||||
|
||||
public readonly editorSearchInput: Locator;
|
||||
public readonly viewerSearchInput: Locator;
|
||||
|
||||
private readonly containerLocator: Locator;
|
||||
|
||||
private readonly noteViewer_: NoteViewer;
|
||||
private readonly markdownEditor_: MarkdownEditor;
|
||||
private readonly richTextEditor_: RichTextEditor;
|
||||
|
||||
public constructor(private page_: Page) {
|
||||
// .rli-editor is used in the main window, .note-editor-wrapper in secondary windows
|
||||
this.containerLocator = page_.locator('.rli-editor, .note-editor-wrapper');
|
||||
this.noteViewer_ = new NoteViewer(this.containerLocator);
|
||||
this.markdownEditor_ = new MarkdownEditor(this.containerLocator);
|
||||
this.richTextEditor_ = new RichTextEditor(this.containerLocator, page_);
|
||||
this.editorPluginFrame = this.containerLocator.locator('iframe[id^="plugin-view-"]');
|
||||
this.noteTitleInput = this.containerLocator.locator('.title-input');
|
||||
this.attachFileButton = this.containerLocator.getByRole('button', { name: 'Attach file' });
|
||||
this.toggleCodeBlockButton = this.containerLocator.getByRole('button', { name: 'Code Block' });
|
||||
this.toggleEditorsButton = this.containerLocator.getByRole('button', { name: 'Toggle editors', exact: true });
|
||||
this.toggleEditorLayoutButton = this.containerLocator.getByRole('button', { name: 'Toggle editor layout' });
|
||||
// The editor and viewer have slightly different search UI
|
||||
this.editorSearchInput = this.containerLocator.getByPlaceholder('Find');
|
||||
this.viewerSearchInput = this.containerLocator.getByPlaceholder('Search...');
|
||||
this.disableTabNavigationButton = this.containerLocator.getByRole('button', { name: 'Tab moves focus' });
|
||||
this.toggleEditorPluginButton = this.containerLocator.getByRole('button', { name: 'Toggle editor plugin' });
|
||||
}
|
||||
|
||||
public async waitFor() {
|
||||
await this.contentLocator_();
|
||||
await this.noteTitleInput.waitFor();
|
||||
await this.toggleEditorsButton.waitFor();
|
||||
}
|
||||
|
||||
public async undo(electronApp: ElectronApplication) {
|
||||
await activateMainMenuItem(electronApp, 'Undo');
|
||||
}
|
||||
|
||||
public toolbarButtonLocator(title: string) {
|
||||
return this.containerLocator.getByRole('button', { name: title });
|
||||
}
|
||||
|
||||
private async markdownEditorActive() {
|
||||
await this.toggleEditorsButton.waitFor();
|
||||
return this.toggleEditorsButton.evaluate(element => element.classList.contains('markdown-active'));
|
||||
}
|
||||
|
||||
private async showMarkdownEditorOrViewer_() {
|
||||
if (!await this.markdownEditorActive()) {
|
||||
await this.toggleEditorsButton.click();
|
||||
}
|
||||
|
||||
await Promise.race([
|
||||
this.markdownEditor_.waitFor().catch(() => {}),
|
||||
this.noteViewer_.waitFor().catch(() => {}),
|
||||
]);
|
||||
}
|
||||
|
||||
public async showNoteViewerAndMarkdownEditor() {
|
||||
await this.showMarkdownEditorOrViewer_();
|
||||
|
||||
const noteViewerVisible = await this.noteViewer_.container.isVisible();
|
||||
const noteEditorVisible = await this.markdownEditor_.container.isVisible();
|
||||
|
||||
if (noteViewerVisible && !noteEditorVisible) {
|
||||
await this.toggleEditorLayout();
|
||||
} else if (!noteViewerVisible && noteEditorVisible) {
|
||||
await this.toggleEditorLayout();
|
||||
await expect(this.noteViewer_.container).toBeVisible();
|
||||
await expect(this.markdownEditor_.content).not.toBeVisible();
|
||||
await this.toggleEditorLayout();
|
||||
}
|
||||
|
||||
await expect(this.noteViewer_.container).toBeVisible();
|
||||
await expect(this.markdownEditor_.content).toBeVisible();
|
||||
|
||||
return { viewer: this.noteViewer_, editor: this.markdownEditor_ };
|
||||
}
|
||||
|
||||
public async showNoteViewer() {
|
||||
await this.showMarkdownEditorOrViewer_();
|
||||
|
||||
if (!await this.noteViewer_.container.isVisible()) {
|
||||
await this.toggleEditorLayout();
|
||||
}
|
||||
|
||||
return this.noteViewer_;
|
||||
}
|
||||
|
||||
public async showMarkdownEditor() {
|
||||
await this.showMarkdownEditorOrViewer_();
|
||||
|
||||
if (!await this.markdownEditor_.container.isVisible()) {
|
||||
await this.toggleEditorLayout();
|
||||
}
|
||||
|
||||
return this.markdownEditor_;
|
||||
}
|
||||
|
||||
public async showRichTextEditor() {
|
||||
if (await this.markdownEditorActive()) {
|
||||
await this.toggleEditorsButton.click();
|
||||
}
|
||||
await this.richTextEditor_.waitFor();
|
||||
return this.richTextEditor_;
|
||||
}
|
||||
|
||||
private async contentLocator_() {
|
||||
const richTextBody = this.richTextEditor_.content.locator('body');
|
||||
const markdownEditor = this.markdownEditor_.container;
|
||||
const noteViewer = this.noteViewer_.container;
|
||||
|
||||
// Work around an issue where .or doesn't work with frameLocators.
|
||||
// See https://github.com/microsoft/playwright/issues/27688#issuecomment-1771403495
|
||||
await Promise.race([
|
||||
richTextBody.waitFor({ state: 'visible' }).catch(()=>{}),
|
||||
markdownEditor.waitFor({ state: 'visible' }).catch(()=>{}),
|
||||
noteViewer.waitFor({ state: 'visible' }).catch(() => {}),
|
||||
]);
|
||||
if (await richTextBody.isVisible()) {
|
||||
return richTextBody;
|
||||
} else if (await markdownEditor.isVisible()) {
|
||||
return markdownEditor;
|
||||
} else {
|
||||
return noteViewer;
|
||||
}
|
||||
}
|
||||
|
||||
public async disableInlineRendering(electronApp: ElectronApplication) {
|
||||
await setSettingValue(electronApp, this.page_, 'editor.inlineRendering', false);
|
||||
}
|
||||
|
||||
public async expectToHaveText(expected: string|RegExp) {
|
||||
// expect(...).toHaveText can fail in the Rich Text Editor (perhaps due to frame locators).
|
||||
// Using expect.poll refreshes the locator on each attempt, which seems to prevent flakiness.
|
||||
const expectResult = expect.poll(
|
||||
// Use .innerText: textContent doesn't handle line breaks correctly in the CodeMirror
|
||||
// editor.
|
||||
async () => (await this.contentLocator_()).innerText(),
|
||||
);
|
||||
// Allow `expected` to be either an exact match (a string) or a pattern
|
||||
if (typeof expected === 'string') {
|
||||
await expectResult.toBe(expected);
|
||||
} else {
|
||||
await expectResult.toMatch(expected);
|
||||
}
|
||||
}
|
||||
|
||||
public async enableTabNavigation(electronApp: ElectronApplication) {
|
||||
await expect(this.disableTabNavigationButton).not.toBeVisible();
|
||||
await activateMainMenuItem(electronApp, 'Tab moves focus');
|
||||
await expect(this.disableTabNavigationButton).toBeVisible();
|
||||
}
|
||||
|
||||
public async disableTabNavigation(electronApp: ElectronApplication) {
|
||||
await expect(this.disableTabNavigationButton).toBeVisible();
|
||||
await activateMainMenuItem(electronApp, 'Tab moves focus');
|
||||
await expect(this.disableTabNavigationButton).not.toBeVisible();
|
||||
}
|
||||
|
||||
public async goBack() {
|
||||
const backButton = this.toolbarButtonLocator('Back');
|
||||
await expect(backButton).not.toBeDisabled();
|
||||
await backButton.click();
|
||||
}
|
||||
|
||||
public async toggleEditorLayout() {
|
||||
await this.toggleEditorLayoutButton.click();
|
||||
}
|
||||
|
||||
public async hideViewer() {
|
||||
const editorVisible = await this.markdownEditor_.container.isVisible();
|
||||
await expect(this.noteViewer_.container).toBeVisible();
|
||||
await this.toggleEditorLayout();
|
||||
|
||||
// An additional toggle was needed if initially in viewer-only mode
|
||||
if (!editorVisible) {
|
||||
await expect(this.markdownEditor_.container).toBeVisible();
|
||||
await this.toggleEditorLayout();
|
||||
}
|
||||
|
||||
await expect(this.noteViewer_.container).not.toBeVisible();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { FrameLocator, Locator } from '@playwright/test';
|
||||
|
||||
export default class NoteViewer {
|
||||
public container: Locator;
|
||||
public content: FrameLocator;
|
||||
public constructor(parent: Locator) {
|
||||
this.container = parent.locator('iframe[src$="note-viewer/index.html"]');
|
||||
this.content = this.container.contentFrame();
|
||||
}
|
||||
|
||||
public async waitFor() {
|
||||
await this.container.waitFor();
|
||||
await this.content.locator('body').waitFor();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { FrameLocator, Locator, Page } from '@playwright/test';
|
||||
import BaseEditor from './BaseEditor';
|
||||
import EditorCodeDialog from './EditorCodeDialog';
|
||||
|
||||
export default class RichTextEditor implements BaseEditor {
|
||||
public readonly container: Locator;
|
||||
public readonly content: FrameLocator;
|
||||
public readonly codeEditor: EditorCodeDialog;
|
||||
public readonly body: Locator;
|
||||
|
||||
public constructor(parent: Locator, page: Page) {
|
||||
this.container = parent.locator('iframe[title="Rich Text Area"]');
|
||||
this.content = this.container.contentFrame();
|
||||
this.codeEditor = new EditorCodeDialog(page);
|
||||
this.body = this.content.locator('body');
|
||||
}
|
||||
|
||||
public async waitFor() {
|
||||
await this.container.waitFor();
|
||||
await this.content.locator('body').waitFor();
|
||||
}
|
||||
|
||||
public async focusContent() {
|
||||
// eslint-disable-next-line no-restricted-properties -- The focusHandler wrapper can't be used here, since Playwright's focus needs to be awaited
|
||||
await this.body.focus();
|
||||
}
|
||||
|
||||
public async typeText(text: string) {
|
||||
await this.body.waitFor();
|
||||
await this.focusContent();
|
||||
await this.body.pressSequentially(text);
|
||||
}
|
||||
|
||||
public async getSearchMatches() {
|
||||
return this.body.evaluate(() => {
|
||||
const highlights = CSS.highlights.get('jop-search-highlight') ?? new Set();
|
||||
const result = [];
|
||||
for (const highlight of highlights) {
|
||||
result.push(highlight.toString());
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import activateMainMenuItem from '../util/activateMainMenuItem';
|
||||
import { ElectronApplication, Locator, Page, expect } from '@playwright/test';
|
||||
|
||||
export default class NoteList {
|
||||
public readonly container: Locator;
|
||||
public readonly sortOrderButton: Locator;
|
||||
|
||||
public constructor(page: Page) {
|
||||
this.container = page.locator('.rli-noteList');
|
||||
this.sortOrderButton = this.container.getByRole('button', { name: 'Toggle sort order' });
|
||||
}
|
||||
|
||||
public waitFor() {
|
||||
return this.container.waitFor();
|
||||
}
|
||||
|
||||
private async sortBy(electronApp: ElectronApplication, sortMethod: string) {
|
||||
await activateMainMenuItem(electronApp, sortMethod, 'Sort notes by');
|
||||
}
|
||||
|
||||
public async sortByTitle(electronApp: ElectronApplication) {
|
||||
await this.sortBy(electronApp, 'Title');
|
||||
await expect(this.sortOrderButton).toHaveAttribute('title', /Toggle sort order field:[\n ]*title ->/);
|
||||
}
|
||||
|
||||
public async focusContent(electronApp: ElectronApplication) {
|
||||
await activateMainMenuItem(electronApp, 'Note list', 'Focus');
|
||||
await expect(this.container.locator(':focus')).toBeAttached();
|
||||
// Wait for the list to have rendered a selected item before the caller
|
||||
// tries to navigate with arrow keys. Without this, the aria-selected
|
||||
// attribute may not be set yet on slow CI runners.
|
||||
await expect(this.container.locator('[aria-selected="true"]')).toBeAttached();
|
||||
}
|
||||
|
||||
// The resultant locator may fail to resolve if the item is not visible
|
||||
public getNoteItemByTitle(title: string|RegExp) {
|
||||
return this.container.getByRole('option', { name: title });
|
||||
}
|
||||
|
||||
public async expectNoteToBeSelected(title: string|RegExp) {
|
||||
await expect(this.getNoteItemByTitle(title)).toHaveAttribute('aria-selected', 'true');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
|
||||
import { Page, Locator } from '@playwright/test';
|
||||
import SyncTab from './SyncTab';
|
||||
|
||||
export default class SettingsScreen {
|
||||
private readonly container: Locator;
|
||||
public readonly okayButton: Locator;
|
||||
public readonly appearanceTabButton: Locator;
|
||||
|
||||
public constructor(page: Page) {
|
||||
this.container = page.locator('.config-screen');
|
||||
this.okayButton = this.container.locator('.button-bar button', { hasText: 'OK' });
|
||||
this.appearanceTabButton = this.getTabLocator('Appearance');
|
||||
}
|
||||
|
||||
public getTabLocator(tabName: string) {
|
||||
return this.container.getByRole('tab', { name: tabName });
|
||||
}
|
||||
|
||||
public getLastTab() {
|
||||
return this.container.getByRole('tablist').getByRole('tab').last();
|
||||
}
|
||||
|
||||
public async waitFor() {
|
||||
await this.okayButton.waitFor();
|
||||
await this.appearanceTabButton.waitFor();
|
||||
}
|
||||
|
||||
public async openSyncTab() {
|
||||
await this.waitFor();
|
||||
const syncTab = this.getTabLocator('Synchronisation');
|
||||
await syncTab.click();
|
||||
|
||||
return new SyncTab(this.container);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Locator } from '@playwright/test';
|
||||
|
||||
export default class SyncTab {
|
||||
public readonly syncWizardButton: Locator;
|
||||
public readonly syncTargetDropdown: Locator;
|
||||
public readonly connectToJoplinCloudButton: Locator;
|
||||
|
||||
public constructor(container: Locator) {
|
||||
this.syncWizardButton = container.getByRole('button', { name: 'Open Sync Wizard' });
|
||||
this.syncTargetDropdown = container.getByRole('combobox', { name: 'Synchronisation target' });
|
||||
this.connectToJoplinCloudButton = container.getByRole('button', { name: 'Connect to Joplin Cloud' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import activateMainMenuItem from '../util/activateMainMenuItem';
|
||||
import type MainScreen from './MainScreen';
|
||||
import { ElectronApplication, Locator, Page } from '@playwright/test';
|
||||
import expect from '../util/extendedExpect';
|
||||
|
||||
export default class Sidebar {
|
||||
public readonly container: Locator;
|
||||
public readonly allNotes: Locator;
|
||||
|
||||
public constructor(page: Page, private mainScreen: MainScreen) {
|
||||
this.container = page.locator('.rli-sideBar');
|
||||
this.allNotes = this.container.getByText('All notes');
|
||||
}
|
||||
|
||||
public async createNewFolder(title: string) {
|
||||
const newFolderButton = this.container.getByRole('button', { name: 'New' });
|
||||
await newFolderButton.click();
|
||||
|
||||
const titleInput = this.mainScreen.dialog.getByLabel('Title');
|
||||
await titleInput.fill(title);
|
||||
|
||||
const submitButton = this.mainScreen.dialog.getByRole('button', { name: 'OK' });
|
||||
await submitButton.click();
|
||||
|
||||
return this.container.getByRole('treeitem', { name: title });
|
||||
}
|
||||
|
||||
private async sortBy(electronApp: ElectronApplication, option: string) {
|
||||
await activateMainMenuItem(electronApp, option, 'Sort notebooks by');
|
||||
}
|
||||
|
||||
public async sortByDate(electronApp: ElectronApplication) {
|
||||
return this.sortBy(electronApp, 'Updated date');
|
||||
}
|
||||
|
||||
public async sortByTitle(electronApp: ElectronApplication) {
|
||||
return this.sortBy(electronApp, 'Title');
|
||||
}
|
||||
|
||||
public async forceUpdateSorting(electronApp: ElectronApplication) {
|
||||
// By default, notebooks will not be in the correct position in the list for about 1 second.
|
||||
// Change the notebook list sort order to force an immediate refresh.
|
||||
await this.sortByDate(electronApp);
|
||||
await this.sortByTitle(electronApp);
|
||||
}
|
||||
|
||||
// Checks the indentation level of each folder. Useful for determining whether folders are subfolders.
|
||||
public async expectToHaveDepths(folderToDepth: [Locator, number][]) {
|
||||
for (let i = 0; i < folderToDepth.length; i++) {
|
||||
const [folder, depth] = folderToDepth[i];
|
||||
await expect(
|
||||
folder, { message: `Folder ${i} should have depth ${depth}.` },
|
||||
).toHaveJSProperty('ariaLevel', String(depth));
|
||||
}
|
||||
}
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户