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,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})`;
|
||||
}
|
||||
}));
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户