nativefier/src/playwright-test.ts

402 lines
13 KiB
TypeScript

import { once } from 'events';
import * as fs from 'fs';
import * as path from 'path';
import { Shell } from 'electron';
import {
_electron,
ConsoleMessage,
Dialog,
ElectronApplication,
Page,
} from 'playwright';
import { getTempDir } from './helpers/helpers';
import { NativefierOptions } from '../shared/src/options/model';
const INJECT_DIR = path.join(__dirname, '..', 'app', 'inject');
const log = console;
function sleep(milliseconds: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, milliseconds);
});
}
describe('Application launch', () => {
jest.setTimeout(60000);
let app: ElectronApplication;
let appClosed = true;
const appMainJSPath = path.join(__dirname, '..', 'app', 'lib', 'main.js');
const DEFAULT_CONFIG: NativefierOptions = {
targetUrl: 'https://npmjs.com',
};
const logFileDir = getTempDir('playwright');
const metaOrAlt = process.platform === 'darwin' ? 'Meta' : 'Alt';
const metaOrCtrl = process.platform === 'darwin' ? 'Meta' : 'Control';
const spawnApp = async (
playwrightConfig: NativefierOptions = { ...DEFAULT_CONFIG },
awaitFirstWindow = true,
preventNavigation = false,
): Promise<Page | undefined> => {
const consoleListener = (consoleMessage: ConsoleMessage): void => {
const consoleMethods: Record<string, (...args: unknown[]) => unknown> = {
debug: log.debug.bind(console),
error: log.error.bind(console),
info: log.info.bind(console),
log: log.log.bind(console),
trace: log.trace.bind(console),
warn: log.warn.bind(console),
};
Promise.all(consoleMessage.args().map((x) => x.jsonValue()))
.then((args) => {
if (consoleMessage.type() in consoleMethods) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
consoleMethods[consoleMessage.type()]('window.console', args);
} else {
log.log('window.console', args);
}
})
.catch(() => log.log('window.console', consoleMessage));
};
app = await _electron.launch({
args: [appMainJSPath],
env: {
LOG_FILE_DIR: logFileDir,
PLAYWRIGHT_TEST: '1',
PLAYWRIGHT_CONFIG: JSON.stringify(playwrightConfig),
USE_LOG_FILE: '1',
VERBOSE: '1',
},
});
app.on('window', (page: Page) => {
page.on('console', consoleListener);
if (preventNavigation) {
// Prevent page navigation so we can have a reliable test
page
.route('*', (route): void => {
log.info(`Preventing route: ${route.request().url()}`);
route.abort().catch((error) => {
log.error('ERROR', error);
});
})
.catch((error) => {
log.error('ERROR', error);
});
}
});
app.on('close', () => (appClosed = true));
appClosed = false;
if (!awaitFirstWindow) {
return undefined;
}
const window = await app.firstWindow();
// Wait for our initial page to finish loading, otherwise some tests will break
let waited = 0;
while (
window.url() === 'about:blank' &&
playwrightConfig.targetUrl !== 'about:blank' &&
waited < 2000
) {
waited += 100;
await sleep(100);
}
return window;
};
beforeEach(() => {
nukeInjects();
nukeLogs(logFileDir);
});
afterEach(async () => {
if (app && !appClosed) {
await app.close();
}
if (process.env.DEBUG) {
showLogs(logFileDir);
}
});
test('shows an initial window', async () => {
const mainWindow = (await spawnApp()) as Page;
await mainWindow.waitForLoadState('domcontentloaded');
expect(app.windows()).toHaveLength(1);
expect(await mainWindow.title()).toBe('npm');
});
test('can inject some CSS', async () => {
const fuschia = 'rgb(255, 0, 255)';
createInject(
'inject.css',
`* { background-color: ${fuschia} !important; }`,
);
const mainWindow = (await spawnApp()) as Page;
await mainWindow.waitForLoadState('domcontentloaded');
const headerStyle = await mainWindow.$eval('header', (el) =>
window.getComputedStyle(el),
);
expect(headerStyle.backgroundColor).toBe(fuschia);
await mainWindow.click('#nav-products-link');
await mainWindow.waitForLoadState('domcontentloaded');
const headerStylePostNavigate = await mainWindow.$eval('header', (el) =>
window.getComputedStyle(el),
);
expect(headerStylePostNavigate.backgroundColor).toBe(fuschia);
});
test('can inject some JS', async () => {
const alertMsg = 'hello world from inject';
createInject(
'inject.js',
`setTimeout(() => {alert("${alertMsg}"); }, 5000);`, // Buy ourselves 5 seconds to get the dialog handler setup
);
const mainWindow = (await spawnApp(
{ ...DEFAULT_CONFIG },
true,
true,
)) as Page;
const [dialogPromise] = (await once(
mainWindow,
'dialog',
)) as unknown as Promise<Dialog>[];
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const dialog: Dialog = await dialogPromise;
await dialog.dismiss();
expect(dialog.message()).toBe(alertMsg);
});
test('can open internal links', async () => {
const mainWindow = (await spawnApp()) as Page;
await mainWindow.waitForLoadState('domcontentloaded');
await mainWindow.click('#nav-products-link');
await mainWindow.waitForLoadState('domcontentloaded');
expect(app.windows()).toHaveLength(1);
});
test('tries to open external links', async () => {
const mainWindow = (await spawnApp()) as Page;
await mainWindow.waitForLoadState('domcontentloaded');
// Install the mock first
await app.evaluate(({ shell }: { shell: Shell }) => {
// @ts-expect-error injecting into shell so that this promise
// can be accessed outside of this anonymous function's scope
// Not my favorite thing to do, but I could not find another way
process.openExternalPromise = new Promise((resolve) => {
shell.openExternal = async (url: string): Promise<void> => {
resolve(url);
return Promise.resolve();
};
});
});
// Click, but don't await it - Playwright waits for stuff that does not happen when Electron does openExternal.
mainWindow
.click('#footer > div:nth-child(2) > ul > li:nth-child(2) > a')
.catch((err: unknown) => {
expect(err).toBeUndefined();
});
// Go pull out our value returned by our hacky global promise
const openExternalUrl = await app.evaluate('process.openExternalPromise');
expect(openExternalUrl).not.toBe('https://www.npmjs.com/');
expect(openExternalUrl).not.toBe(DEFAULT_CONFIG.targetUrl);
});
// Currently disabled. Playwright doesn't seem to support app keypress events for menu shortcuts.
// Will enable when https://github.com/microsoft/playwright/issues/8004 is resolved.
test.skip('keyboard shortcuts: zoom', async () => {
const mainWindow = (await spawnApp()) as Page;
await mainWindow.waitForLoadState('domcontentloaded');
const defaultZoom: number | undefined = await app.evaluate(
({ BrowserWindow }) =>
BrowserWindow.getFocusedWindow()?.webContents?.zoomFactor,
);
expect(defaultZoom).toBeDefined();
await mainWindow.keyboard.press(`${metaOrCtrl}+Equal`);
const postZoomIn = await app.evaluate(
({ BrowserWindow }): number | undefined =>
BrowserWindow.getFocusedWindow()?.webContents?.zoomFactor,
);
expect(postZoomIn).toBeGreaterThan(defaultZoom as number);
await mainWindow.keyboard.press(`${metaOrCtrl}+0`);
const postZoomReset = await app.evaluate(
({ BrowserWindow }): number | undefined =>
BrowserWindow.getFocusedWindow()?.webContents?.zoomFactor,
);
expect(postZoomReset).toEqual(defaultZoom);
await mainWindow.keyboard.press(`${metaOrCtrl}+Minus`);
const postZoomOut: number | undefined = await app.evaluate(
({ BrowserWindow }) =>
BrowserWindow.getFocusedWindow()?.webContents?.zoomFactor,
);
expect(postZoomOut).toBeLessThan(defaultZoom as number);
});
// Currently disabled. Playwright doesn't seem to support app keypress events for menu shortcuts.
// Will enable when https://github.com/microsoft/playwright/issues/8004 is resolved.
test.skip('keyboard shortcuts: back and forward', async () => {
const mainWindow = (await spawnApp()) as Page;
await mainWindow.waitForLoadState('domcontentloaded');
await Promise.all([
mainWindow.click('#nav-products-link'),
mainWindow.waitForNavigation({ waitUntil: 'domcontentloaded' }),
]);
// Go back
// console.log(`${metaOrAlt}+ArrowLeft`);
await mainWindow.keyboard.press(`${metaOrAlt}+ArrowLeft`);
await mainWindow.waitForNavigation({ waitUntil: 'domcontentloaded' });
const backUrl = await mainWindow.evaluate(() => window.location.href);
expect(backUrl).toBe(DEFAULT_CONFIG.targetUrl);
// Go forward
// console.log(`${metaOrAlt}+ArrowRight`);
await mainWindow.keyboard.press(`${metaOrAlt}+ArrowRight`);
await mainWindow.waitForNavigation({ waitUntil: 'domcontentloaded' });
const forwardUrl = await mainWindow.evaluate(() => window.location.href);
expect(forwardUrl).not.toBe(DEFAULT_CONFIG.targetUrl);
});
test('no errors thrown in console', async () => {
await spawnApp({ ...DEFAULT_CONFIG }, false);
const mainWindow = await app.firstWindow();
mainWindow.addListener('console', (consoleMessage: ConsoleMessage) => {
try {
expect(consoleMessage.type()).not.toBe('error');
} catch {
// Do it this way so we'll see the whole message, not just
// expect('error').not.toBe('error')
// which isn't particularly useful
expect({
message: 'console.error called unexpectedly with',
consoleMessage,
}).toBeUndefined();
}
});
// Give the app 5 seconds to spin up and ensure no errors happened
await new Promise((resolve) => setTimeout(resolve, 5000));
});
test('basic auth', async () => {
const mainWindow = (await spawnApp({
targetUrl: 'http://httpbin.org/basic-auth/foo/bar',
basicAuthUsername: 'foo',
basicAuthPassword: 'bar',
})) as Page;
await mainWindow.waitForLoadState('networkidle');
const documentText = await mainWindow.evaluate<string>(
'document.documentElement.innerText',
);
const documentJSON = JSON.parse(documentText) as {
authenticated: boolean;
user: string;
};
expect(documentJSON).toEqual({
authenticated: true,
user: 'foo',
});
});
test('basic auth without pre-providing', async () => {
const mainWindow = (await spawnApp({
targetUrl: 'http://httpbin.org/basic-auth/foo/bar',
})) as Page;
await mainWindow.waitForLoadState('load');
// Give the app a few seconds to open the login window
await new Promise((resolve) => setTimeout(resolve, 5000));
const appWindows = app.windows();
expect(appWindows).toHaveLength(2);
const loginWindow = appWindows.filter((x) => x !== mainWindow)[0];
await loginWindow.waitForLoadState('domcontentloaded');
const usernameField = await loginWindow.$('#username-input');
expect(usernameField).not.toBeNull();
const passwordField = await loginWindow.$('#password-input');
expect(passwordField).not.toBeNull();
const submitButton = await loginWindow.$('#submit-form-button');
expect(submitButton).not.toBeNull();
await usernameField?.fill('foo');
await passwordField?.fill('bar');
await submitButton?.click();
await mainWindow.waitForLoadState('networkidle');
const documentText = await mainWindow.evaluate<string>(
'document.documentElement.innerText',
);
const documentJSON = JSON.parse(documentText) as {
authenticated: boolean;
user: string;
};
expect(documentJSON).toEqual({
authenticated: true,
user: 'foo',
});
});
});
function createInject(filename: string, contents: string): void {
fs.writeFileSync(path.join(INJECT_DIR, filename), contents);
}
function nukeInjects(): void {
if (!fs.existsSync(INJECT_DIR)) {
return;
}
const injected = fs
.readdirSync(INJECT_DIR)
.filter((x) => x !== '_placeholder');
injected.forEach((x) => fs.unlinkSync(path.join(INJECT_DIR, x)));
}
function nukeLogs(logFileDir: string): void {
const logs = fs.readdirSync(logFileDir).filter((x) => x.endsWith('.log'));
logs.forEach((x) => fs.unlinkSync(path.join(logFileDir, x)));
}
function showLogs(logFileDir: string): void {
const logs = fs.readdirSync(logFileDir).filter((x) => x.endsWith('.log'));
for (const logFile of logs) {
log.log(fs.readFileSync(path.join(logFileDir, logFile)).toString());
}
}