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

这个提交包含在:
chen
2026-11-04 11:29:39 +08:00
当前提交 301194963b
共有 8663 个文件被更改,包括 1962691 次插入 和 0 次删除

查看文件

@@ -0,0 +1,51 @@
import { expect, type ElectronApplication } from '@playwright/test';
import type { MenuItem } from 'electron';
// Roughly based on
// https://github.com/spaceagetv/electron-playwright-helpers/blob/main/src/menu_helpers.ts
// If given, `parentMenuLabel` should be the label of the menu containing the target item.
const activateMainMenuItem = async (
electronApp: ElectronApplication,
targetItemLabel: string|RegExp,
parentMenuLabel?: string,
) => {
await expect.poll(() => {
return electronApp.evaluate(async ({ Menu }, [targetItemLabel, parentMenuLabel]) => {
const activateItemInSubmenu = (submenu: MenuItem[], parentLabel: string) => {
for (const item of submenu) {
const matchesParent = !parentMenuLabel || parentLabel === parentMenuLabel;
const matchesLabel = typeof targetItemLabel === 'string' ? (
targetItemLabel === item.label
) : (
item.label.match(targetItemLabel)
);
if (matchesLabel && matchesParent && item.visible) {
// Found!
item.click();
return true;
} else if (item.submenu) {
const foundItem = activateItemInSubmenu(item.submenu.items, item.label);
if (foundItem) {
return true;
}
}
}
// No item found
return false;
};
const appMenu = Menu.getApplicationMenu();
return !!appMenu && activateItemInSubmenu(appMenu.items, '');
}, [targetItemLabel, parentMenuLabel]);
}, {
message: `should find and activate menu item with label ${JSON.stringify(targetItemLabel)}`,
}).toBe(true);
};
export default activateMainMenuItem;

查看文件

@@ -0,0 +1,14 @@
import { dirname, resolve } from 'path';
const createStartupArgs = (profileDirectory: string) => {
// Input paths need to be absolute when running from VSCode
const baseDirectory = dirname(dirname(__dirname));
const mainPath = resolve(baseDirectory, 'main.bundle.js');
// We need to run with --env dev to disable the single instance check.
return [
mainPath, '--env', 'dev', '--lang=en-GB', '--log-level', 'debug', '--no-welcome', '--running-tests', '--profile', resolve(profileDirectory),
];
};
export default createStartupArgs;

查看文件

@@ -0,0 +1,17 @@
import { ElectronApplication } from '@playwright/test';
import type { PageFunctionOn } from 'playwright-core/types/structs';
import type * as ElectronType from 'electron';
import retryOnFailure from './retryOnFailure';
const evaluateWithRetry = async <ReturnType, Arg> (
app: ElectronApplication,
pageFunction: PageFunctionOn<typeof ElectronType, Arg, ReturnType>,
arg: Arg,
) => {
await retryOnFailure(async () => {
return await app.evaluate(pageFunction, arg);
}, { maxRetries: 3 });
};
export default evaluateWithRetry;

查看文件

@@ -0,0 +1,79 @@
import { expect, Locator } from '@playwright/test';
const extendedExpect = expect.extend({
async toBeSeekableMediaElement(videoLocator: Locator, seeksTo: number, playsUntil: number) {
let pass = true;
let nextAssertionStep = '';
const assertionName = 'toBeSeekableMediaElement';
let resultMessage = () => `${assertionName}: Passed`;
try {
await extendedExpect(videoLocator).toBeVisible();
const getDuration = () => {
return videoLocator.evaluate((video) => {
if (!(video instanceof HTMLMediaElement)) {
throw new Error('Not a media element');
}
return video.duration;
});
};
nextAssertionStep = 'Media should be long enough to seek and play';
await extendedExpect.poll(getDuration).toBeGreaterThanOrEqual(Math.max(seeksTo, playsUntil));
nextAssertionStep = 'Should not have a loading error';
await extendedExpect(videoLocator).toHaveJSProperty('error', null);
nextAssertionStep = `Should seek to ${this.utils.printExpected(seeksTo)}`;
await videoLocator.evaluate((video: HTMLMediaElement, playsFrom: number) => {
video.pause();
video.currentTime = playsFrom;
}, seeksTo);
const getCurrentTime = () => {
return videoLocator.evaluate((video: HTMLVideoElement) => {
return video.currentTime;
});
};
await extendedExpect.poll(getCurrentTime).toBeCloseTo(seeksTo);
nextAssertionStep = `Should play until ${this.utils.printExpected(playsUntil)}`;
await videoLocator.evaluate((video: HTMLMediaElement) => video.play());
await extendedExpect.poll(getCurrentTime).toBeGreaterThan(playsUntil);
} catch (error) {
pass = false;
resultMessage = () => error.toString();
}
return {
pass,
message: () => `${assertionName}: ${nextAssertionStep}:\n ${resultMessage()}`,
name: assertionName,
};
},
async toBeSelected(locator: Locator) {
let pass = true;
const assertionName = 'toBeSelected';
let resultMessage = () => `${assertionName}: Passed`;
try {
await extendedExpect(locator).toHaveJSProperty('ariaSelected', 'true');
} catch (error) {
pass = false;
resultMessage = () => error.toString();
}
return {
pass,
message: () => `${assertionName}: ${resultMessage()}`,
name: assertionName,
};
},
});
export default extendedExpect;

查看文件

@@ -0,0 +1,21 @@
import { Locator } from '@playwright/test';
const getImageSourceSize = async (imageLocator: Locator) => {
// Use state: 'attached' -- we don't need the image to be on the screen (just present
// in the DOM).
await imageLocator.waitFor({ state: 'attached' });
// We load a copy of the image to avoid returning an overriden width set with
// .width = some_number
return await imageLocator.evaluate((originalImage: HTMLImageElement) => {
return new Promise<[number, number]>(resolve => {
const testImage = new Image();
testImage.onload = () => {
resolve([testImage.width, testImage.height]);
};
testImage.src = originalImage.src;
});
});
};
export default getImageSourceSize;

查看文件

@@ -0,0 +1,7 @@
import { ElectronApplication } from '@playwright/test';
const getMainWindow = async (electronApp: ElectronApplication) => {
return await electronApp.firstWindow();
};
export default getMainWindow;

查看文件

@@ -0,0 +1,27 @@
import type { Reporter, FullResult, TestCase } from '@playwright/test/reporter';
// Treats a run as successful when the only "failures" are flaky tests
// (tests that failed initially but passed on retry).
class IgnoreFlakyReporter implements Reporter {
private hasRealFailure = false;
public onTestEnd(test: TestCase) {
const status = test.outcome();
if (status === 'unexpected') {
this.hasRealFailure = true;
}
}
public async onEnd(result: FullResult) {
if (result.status === 'failed' && !this.hasRealFailure) {
return { status: 'passed' as const };
}
return undefined;
}
public printsToStdio() {
return false;
}
}
export default IgnoreFlakyReporter;

查看文件

@@ -0,0 +1,29 @@
import { ElectronApplication } from '@playwright/test';
import { expect } from './test';
import getMainWindow from './getMainWindow';
// Currently only supports mocking reading/writing text
const mockClipboard = async (electronApp: ElectronApplication, clipboardText: string) => {
const mainWindow = await getMainWindow(electronApp);
await mainWindow.evaluate(async (clipboardText) => {
const { clipboard } = require('electron');
clipboard.writeText = (text: string) => {
clipboardText = text;
};
clipboard.readText = () => {
return clipboardText;
};
}, clipboardText);
return {
expectClipboardToMatch: async (text: string) => {
await expect.poll(async () => {
return await mainWindow.evaluate(() => {
return require('electron').clipboard.readText();
});
}).toBe(text);
},
};
};
export default mockClipboard;

查看文件

@@ -0,0 +1,20 @@
interface Options {
maxRetries: number;
}
const retryOnFailure = async <T> (callback: ()=> Promise<T>, { maxRetries }: Options): Promise<T> => {
let lastError: Error|null = null;
for (let i = 0; i < maxRetries; i++) {
try {
return await callback();
} catch (error) {
console.error('retry failed:', error, `Retrying... ${i + 1}/${maxRetries}`);
lastError = error;
}
}
throw lastError;
};
export default retryOnFailure;

查看文件

@@ -0,0 +1,10 @@
import { ElectronApplication } from '@playwright/test';
import evaluateWithRetry from './evaluateWithRetry';
const setDarkMode = (app: ElectronApplication, darkMode: boolean) => {
return evaluateWithRetry(app, ({ nativeTheme }, darkMode) => {
nativeTheme.themeSource = darkMode ? 'dark' : 'light';
}, darkMode);
};
export default setDarkMode;

查看文件

@@ -0,0 +1,13 @@
import { ElectronApplication } from '@playwright/test';
const setFilePickerResponse = (electronApp: ElectronApplication, response: string[]) => {
return electronApp.evaluate(async ({ dialog }, response) => {
dialog.showOpenDialog = async () => ({
canceled: false,
filePaths: response,
});
dialog.showOpenDialogSync = () => response;
}, response);
};
export default setFilePickerResponse;

查看文件

@@ -0,0 +1,36 @@
import { ElectronApplication } from '@playwright/test';
import { BaseWindow, MessageBoxOptions } from 'electron';
const setMessageBoxResponse = (electronApp: ElectronApplication, responseMatch: RegExp) => {
return electronApp.evaluate(async ({ dialog }, responseMatch) => {
type DialogArgsType = [ BaseWindow, MessageBoxOptions ]|[MessageBoxOptions];
const getMatchingButton = (dialogArgs: DialogArgsType) => {
const matchingButton = (options: MessageBoxOptions) => {
const buttons = options.buttons ?? ['OK'];
for (let i = 0; i < buttons.length; i++) {
if (buttons[i].match(responseMatch)) {
return i;
}
}
throw new Error('No matching button found');
};
if (dialogArgs.length === 1) {
return matchingButton(dialogArgs[0]);
} else {
return matchingButton(dialogArgs[1]);
}
};
dialog.showMessageBoxSync = (...args: DialogArgsType) => getMatchingButton(args);
dialog.showMessageBox = async (...args: DialogArgsType) => ({
response: getMatchingButton(args),
checkboxChecked: false,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Mock return; cast prevents breakage when Electron's MessageBoxReturnValue shape evolves
} as any);
}, responseMatch);
};
export default setMessageBoxResponse;

查看文件

@@ -0,0 +1,14 @@
import { SettingValueType } from '@joplin/lib/models/Setting';
import { ElectronApplication, Page } from '@playwright/test';
import { BrowserWindow } from 'electron';
const setSettingValue = async <Key extends string> (
app: ElectronApplication, mainWindow: Page, key: Key, value: SettingValueType<Key>,
) => {
const browserWindow = await app.browserWindow(mainWindow);
await browserWindow.evaluateHandle((browserWindow: BrowserWindow, { key, value }) => {
browserWindow.webContents.send('testing--setSetting', key, value);
}, { key, value });
};
export default setSettingValue;

查看文件

@@ -0,0 +1,140 @@
import { resolve, join, dirname } from 'path';
import { remove, mkdirp, readFile, pathExists } from 'fs-extra';
import { _electron as electron, Page, ElectronApplication, test as base, TestInfo } from '@playwright/test';
import uuid from '@joplin/lib/uuid';
import createStartupArgs from './createStartupArgs';
import getMainWindow from './getMainWindow';
import setDarkMode from './setDarkMode';
import evaluateWithRetry from './evaluateWithRetry';
type StartWithPluginsResult = { app: ElectronApplication; mainWindow: Page };
type JoplinFixtures = {
profileDirectory: string;
electronApp: ElectronApplication;
startAppWithPlugins: (pluginPaths: string[])=> Promise<StartWithPluginsResult>;
startupPluginsLoaded: Promise<void>;
mainWindow: Page;
};
// A custom fixture that loads an electron app. See
// https://playwright.dev/docs/test-fixtures
const initializeMainWindow = async (electronApp: ElectronApplication) => {
const mainWindow = await getMainWindow(electronApp);
// Setting the viewport size helps keep test environments consistent.
await mainWindow.setViewportSize({
width: 1300,
height: 800,
});
return mainWindow;
};
const waitForMainMessage = async (electronApp: ElectronApplication, messageId: string) => {
return evaluateWithRetry(electronApp, ({ ipcMain }, messageId) => {
return new Promise<void>(resolve => {
ipcMain.once(messageId, () => resolve());
});
}, messageId);
};
const waitForAppLoaded = async (electronApp: ElectronApplication) => {
await waitForMainMessage(electronApp, 'startup-finished');
};
const waitForStartupPlugins = async (electronApp: ElectronApplication) => {
await waitForMainMessage(electronApp, 'startup-plugins-loaded');
};
const attachJoplinLog = async (profileDirectory: string, testInfo: TestInfo) => {
const logFile = join(profileDirectory, 'log.txt');
if (await pathExists(logFile)) {
await testInfo.attach('log.txt', {
body: await readFile(logFile, 'utf8'),
contentType: 'text/plain',
});
} else {
console.warn('Missing log file');
}
};
const testDir = dirname(__dirname);
export const test = base.extend<JoplinFixtures>({
// Playwright fails if we don't use the object destructuring
// pattern in the first argument.
//
// See https://github.com/microsoft/playwright/issues/8798
//
// eslint-disable-next-line no-empty-pattern
profileDirectory: async ({ }, use, testInfo) => {
const profilePath = resolve(join(testDir, 'test-profile'));
const profileSubdir = join(profilePath, uuid.createNano());
await mkdirp(profileSubdir);
await use(profileSubdir);
// For debugging purposes, attach the Joplin log file to the test:
await attachJoplinLog(profileSubdir, testInfo);
await remove(profileSubdir);
},
electronApp: async ({ profileDirectory }, use) => {
const startupArgs = createStartupArgs(profileDirectory);
const electronApp = await electron.launch({ args: startupArgs });
const startupPromise = waitForAppLoaded(electronApp);
await setDarkMode(electronApp, false);
await startupPromise;
await use(electronApp);
await electronApp.firstWindow();
await electronApp.close();
},
startAppWithPlugins: async ({ profileDirectory }, use) => {
const startupArgs = createStartupArgs(profileDirectory);
let electronApp: ElectronApplication;
await use(async (pluginPaths: string[]) => {
if (electronApp) {
throw new Error('Electron app already created');
}
electronApp = await electron.launch({
args: [
...startupArgs,
'--dev-plugins',
pluginPaths.map(path => resolve(testDir, path)).join(','),
],
});
const startupPromise = waitForAppLoaded(electronApp);
const mainWindowPromise = initializeMainWindow(electronApp);
await waitForStartupPlugins(electronApp);
await startupPromise;
return {
app: electronApp,
mainWindow: await mainWindowPromise,
};
});
if (electronApp) {
await electronApp.firstWindow();
await electronApp.close();
}
},
startupPluginsLoaded: async ({ electronApp }, use) => {
await use(waitForStartupPlugins(electronApp));
},
mainWindow: async ({ electronApp }, use) => {
await use(await initializeMainWindow(electronApp));
},
});
export { default as expect } from './extendedExpect';

查看文件

@@ -0,0 +1,16 @@
import { ElectronApplication } from '@playwright/test';
const waitForNextOpenPath = (electronApp: ElectronApplication) => {
return electronApp.evaluate(async ({ shell }) => {
return new Promise<string>(resolve => {
const originalOpenPath = shell.openPath;
shell.openPath = async (path: string) => {
shell.openPath = originalOpenPath;
resolve(path);
return '';
};
});
});
};
export default waitForNextOpenPath;

查看文件

@@ -0,0 +1,52 @@
import { Second } from '@joplin/utils/time';
import { ElectronApplication, Page } from '@playwright/test';
const waitForNextWindowMatching = (titlePattern: RegExp, electronApp: ElectronApplication) => {
return new Promise<Page>((resolve, reject) => {
let timeout: NodeJS.Timeout|null = null;
const clearListenersAndTimeouts = () => {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
electronApp.off('window', onWindowAdded);
electronApp.off('close', onClose);
};
const onWindowAdded = async (page: Page) => {
const title = await page.title();
if (title.match(titlePattern)) {
clearListenersAndTimeouts();
resolve(page);
}
};
const onClose = () => {
clearListenersAndTimeouts();
reject(new Error('Target application closed.'));
};
electronApp.on('window', onWindowAdded);
electronApp.on('close', onClose);
timeout = setTimeout(async () => {
timeout = null;
clearListenersAndTimeouts();
const windowTitles = await getOpenWindowTitles(electronApp);
reject(new Error(`Opening a window timed out. Open window titles: ${JSON.stringify(windowTitles)}.`));
}, 30 * Second);
});
};
export default waitForNextWindowMatching;
const getOpenWindowTitles = (electronApp: ElectronApplication) => {
const windows = electronApp.windows();
return Promise.all(windows.map(async w => {
try {
return await w.title();
} catch (error) {
return `(Error: ${error})`;
}
}));
};