test: drop now-empty remote runner (#35343)

* test: drop the now-empty remote runner from CI

* move fixtures to spec-main

* remove remote runner

* fix stuff

* remove global-paths hack

* move ts-smoke to spec/

* fix test after merge

* rename spec-main to spec

* no need to ignore spec/node_modules twice

* simplify spec-runner a little

* no need to hash pj/yl twice

* undo lint change to verify-mksnapshot.py

* excessive ..

* update electron_woa_testing.yml

* don't search for test-results-remote.xml

it is never produced now
This commit is contained in:
Jeremy Rose 2022-08-16 12:23:13 -07:00 committed by GitHub
parent e87c4015fe
commit db7c92fd57
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
327 changed files with 950 additions and 1707 deletions

1
spec/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
node_modules

4
spec/ambient.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare let standardScheme: string;
declare let serviceWorkerScheme: string;
declare module 'dbus-native';

1963
spec/api-app-spec.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,89 @@
import { autoUpdater } from 'electron/main';
import { expect } from 'chai';
import { ifit, ifdescribe } from './spec-helpers';
import { emittedOnce } from './events-helpers';
ifdescribe(!process.mas)('autoUpdater module', function () {
describe('checkForUpdates', function () {
ifit(process.platform === 'win32')('emits an error on Windows if the feed URL is not set', async function () {
const errorEvent = emittedOnce(autoUpdater, 'error');
autoUpdater.setFeedURL({ url: '' });
autoUpdater.checkForUpdates();
const [error] = await errorEvent;
expect(error.message).to.equal('Update URL is not set');
});
});
describe('getFeedURL', () => {
it('returns an empty string by default', () => {
expect(autoUpdater.getFeedURL()).to.equal('');
});
ifit(process.platform === 'win32')('correctly fetches the previously set FeedURL', function () {
const updateURL = 'https://fake-update.electron.io';
autoUpdater.setFeedURL({ url: updateURL });
expect(autoUpdater.getFeedURL()).to.equal(updateURL);
});
});
describe('setFeedURL', function () {
ifdescribe(process.platform === 'win32' || process.platform === 'darwin')('on Mac or Windows', () => {
it('sets url successfully using old (url, headers) syntax', () => {
const url = 'http://electronjs.org';
try {
(autoUpdater.setFeedURL as any)(url, { header: 'val' });
} catch (err) { /* ignore */ }
expect(autoUpdater.getFeedURL()).to.equal(url);
});
it('throws if no url is provided when using the old style', () => {
expect(() => (autoUpdater.setFeedURL as any)()).to.throw('Expected an options object with a \'url\' property to be provided');
});
it('sets url successfully using new ({ url }) syntax', () => {
const url = 'http://mymagicurl.local';
try {
autoUpdater.setFeedURL({ url });
} catch (err) { /* ignore */ }
expect(autoUpdater.getFeedURL()).to.equal(url);
});
it('throws if no url is provided when using the new style', () => {
expect(() => autoUpdater.setFeedURL({ noUrl: 'lol' } as any)
).to.throw('Expected options object to contain a \'url\' string property in setFeedUrl call');
});
});
ifdescribe(process.platform === 'darwin' && process.arch !== 'arm64')('on Mac', function () {
it('emits an error when the application is unsigned', async () => {
const errorEvent = emittedOnce(autoUpdater, 'error');
autoUpdater.setFeedURL({ url: '' });
const [error] = await errorEvent;
expect(error.message).equal('Could not get code signature for running application');
});
it('does not throw if default is the serverType', () => {
// "Could not get code signature..." means the function got far enough to validate that serverType was OK.
expect(() => autoUpdater.setFeedURL({ url: '', serverType: 'default' })).to.throw('Could not get code signature for running application');
});
it('does not throw if json is the serverType', () => {
// "Could not get code signature..." means the function got far enough to validate that serverType was OK.
expect(() => autoUpdater.setFeedURL({ url: '', serverType: 'json' })).to.throw('Could not get code signature for running application');
});
it('does throw if an unknown string is the serverType', () => {
expect(() => autoUpdater.setFeedURL({ url: '', serverType: 'weow' as any })).to.throw('Expected serverType to be \'default\' or \'json\'');
});
});
});
describe('quitAndInstall', () => {
ifit(process.platform === 'win32')('emits an error on Windows when no update is available', async function () {
const errorEvent = emittedOnce(autoUpdater, 'error');
autoUpdater.quitAndInstall();
const [error] = await errorEvent;
expect(error.message).to.equal('No update available, can\'t quit and install');
});
});
});

View file

@ -0,0 +1,586 @@
import { expect } from 'chai';
import * as cp from 'child_process';
import * as http from 'http';
import * as express from 'express';
import * as fs from 'fs-extra';
import * as os from 'os';
import * as path from 'path';
import { AddressInfo } from 'net';
import { ifdescribe, ifit } from './spec-helpers';
import * as uuid from 'uuid';
import { systemPreferences } from 'electron';
const features = process._linkedBinding('electron_common_features');
const fixturesPath = path.resolve(__dirname, 'fixtures');
// We can only test the auto updater on darwin non-component builds
ifdescribe(process.platform === 'darwin' && !(process.env.CI && process.arch === 'arm64') && !process.mas && !features.isComponentBuild())('autoUpdater behavior', function () {
this.timeout(120000);
let identity = '';
beforeEach(function () {
const result = cp.spawnSync(path.resolve(__dirname, '../script/codesign/get-trusted-identity.sh'));
if (result.status !== 0 || result.stdout.toString().trim().length === 0) {
// Per https://circleci.com/docs/2.0/env-vars:
// CIRCLE_PR_NUMBER is only present on forked PRs
if (process.env.CI && !process.env.CIRCLE_PR_NUMBER) {
throw new Error('No valid signing identity available to run autoUpdater specs');
}
this.skip();
} else {
identity = result.stdout.toString().trim();
}
});
it('should have a valid code signing identity', () => {
expect(identity).to.be.a('string').with.lengthOf.at.least(1);
});
const copyApp = async (newDir: string, fixture = 'initial') => {
const appBundlePath = path.resolve(process.execPath, '../../..');
const newPath = path.resolve(newDir, 'Electron.app');
cp.spawnSync('cp', ['-R', appBundlePath, path.dirname(newPath)]);
const appDir = path.resolve(newPath, 'Contents/Resources/app');
await fs.mkdirp(appDir);
await fs.copy(path.resolve(fixturesPath, 'auto-update', fixture), appDir);
const plistPath = path.resolve(newPath, 'Contents', 'Info.plist');
await fs.writeFile(
plistPath,
(await fs.readFile(plistPath, 'utf8')).replace('<key>BuildMachineOSBuild</key>', `<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSExceptionDomains</key>
<dict>
<key>localhost</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict>
</dict><key>BuildMachineOSBuild</key>`)
);
return newPath;
};
const spawn = (cmd: string, args: string[], opts: any = {}) => {
let out = '';
const child = cp.spawn(cmd, args, opts);
child.stdout.on('data', (chunk: Buffer) => {
out += chunk.toString();
});
child.stderr.on('data', (chunk: Buffer) => {
out += chunk.toString();
});
return new Promise<{ code: number, out: string }>((resolve) => {
child.on('exit', (code, signal) => {
expect(signal).to.equal(null);
resolve({
code: code!,
out
});
});
});
};
const signApp = (appPath: string) => {
return spawn('codesign', ['-s', identity, '--deep', '--force', appPath]);
};
const launchApp = (appPath: string, args: string[] = []) => {
return spawn(path.resolve(appPath, 'Contents/MacOS/Electron'), args);
};
const withTempDirectory = async (fn: (dir: string) => Promise<void>, autoCleanUp = true) => {
const dir = await fs.mkdtemp(path.resolve(os.tmpdir(), 'electron-update-spec-'));
try {
await fn(dir);
} finally {
if (autoCleanUp) {
cp.spawnSync('rm', ['-r', dir]);
}
}
};
const logOnError = (what: any, fn: () => void) => {
try {
fn();
} catch (err) {
console.error(what);
throw err;
}
};
const cachedZips: Record<string, string> = {};
const getOrCreateUpdateZipPath = async (version: string, fixture: string, mutateAppPostSign?: {
mutate: (appPath: string) => Promise<void>,
mutationKey: string,
}) => {
const key = `${version}-${fixture}-${mutateAppPostSign?.mutationKey || 'no-mutation'}`;
if (!cachedZips[key]) {
let updateZipPath: string;
await withTempDirectory(async (dir) => {
const secondAppPath = await copyApp(dir, fixture);
const appPJPath = path.resolve(secondAppPath, 'Contents', 'Resources', 'app', 'package.json');
await fs.writeFile(
appPJPath,
(await fs.readFile(appPJPath, 'utf8')).replace('1.0.0', version)
);
await signApp(secondAppPath);
await mutateAppPostSign?.mutate(secondAppPath);
updateZipPath = path.resolve(dir, 'update.zip');
await spawn('zip', ['-0', '-r', '--symlinks', updateZipPath, './'], {
cwd: dir
});
}, false);
cachedZips[key] = updateZipPath!;
}
return cachedZips[key];
};
after(() => {
for (const version of Object.keys(cachedZips)) {
cp.spawnSync('rm', ['-r', path.dirname(cachedZips[version])]);
}
});
// On arm64 builds the built app is self-signed by default so the setFeedURL call always works
ifit(process.arch !== 'arm64')('should fail to set the feed URL when the app is not signed', async () => {
await withTempDirectory(async (dir) => {
const appPath = await copyApp(dir);
const launchResult = await launchApp(appPath, ['http://myupdate']);
console.log(launchResult);
expect(launchResult.code).to.equal(1);
expect(launchResult.out).to.include('Could not get code signature for running application');
});
});
it('should cleanly set the feed URL when the app is signed', async () => {
await withTempDirectory(async (dir) => {
const appPath = await copyApp(dir);
await signApp(appPath);
const launchResult = await launchApp(appPath, ['http://myupdate']);
expect(launchResult.code).to.equal(0);
expect(launchResult.out).to.include('Feed URL Set: http://myupdate');
});
});
describe('with update server', () => {
let port = 0;
let server: express.Application = null as any;
let httpServer: http.Server = null as any;
let requests: express.Request[] = [];
beforeEach((done) => {
requests = [];
server = express();
server.use((req, res, next) => {
requests.push(req);
next();
});
httpServer = server.listen(0, '127.0.0.1', () => {
port = (httpServer.address() as AddressInfo).port;
done();
});
});
afterEach(async () => {
if (httpServer) {
await new Promise<void>(resolve => {
httpServer.close(() => {
httpServer = null as any;
server = null as any;
resolve();
});
});
}
});
it('should hit the update endpoint when checkForUpdates is called', async () => {
await withTempDirectory(async (dir) => {
const appPath = await copyApp(dir, 'check');
await signApp(appPath);
server.get('/update-check', (req, res) => {
res.status(204).send();
});
const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
logOnError(launchResult, () => {
expect(launchResult.code).to.equal(0);
expect(requests).to.have.lengthOf(1);
expect(requests[0]).to.have.property('url', '/update-check');
expect(requests[0].header('user-agent')).to.include('Electron/');
});
});
});
it('should hit the update endpoint with customer headers when checkForUpdates is called', async () => {
await withTempDirectory(async (dir) => {
const appPath = await copyApp(dir, 'check-with-headers');
await signApp(appPath);
server.get('/update-check', (req, res) => {
res.status(204).send();
});
const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
logOnError(launchResult, () => {
expect(launchResult.code).to.equal(0);
expect(requests).to.have.lengthOf(1);
expect(requests[0]).to.have.property('url', '/update-check');
expect(requests[0].header('x-test')).to.equal('this-is-a-test');
});
});
});
it('should hit the download endpoint when an update is available and error if the file is bad', async () => {
await withTempDirectory(async (dir) => {
const appPath = await copyApp(dir, 'update');
await signApp(appPath);
server.get('/update-file', (req, res) => {
res.status(500).send('This is not a file');
});
server.get('/update-check', (req, res) => {
res.json({
url: `http://localhost:${port}/update-file`,
name: 'My Release Name',
notes: 'Theses are some release notes innit',
pub_date: (new Date()).toString()
});
});
const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
logOnError(launchResult, () => {
expect(launchResult).to.have.property('code', 1);
expect(launchResult.out).to.include('Update download failed. The server sent an invalid response.');
expect(requests).to.have.lengthOf(2);
expect(requests[0]).to.have.property('url', '/update-check');
expect(requests[1]).to.have.property('url', '/update-file');
expect(requests[0].header('user-agent')).to.include('Electron/');
expect(requests[1].header('user-agent')).to.include('Electron/');
});
});
});
const withUpdatableApp = async (opts: {
nextVersion: string;
startFixture: string;
endFixture: string;
mutateAppPostSign?: {
mutate: (appPath: string) => Promise<void>,
mutationKey: string,
}
}, fn: (appPath: string, zipPath: string) => Promise<void>) => {
await withTempDirectory(async (dir) => {
const appPath = await copyApp(dir, opts.startFixture);
await signApp(appPath);
const updateZipPath = await getOrCreateUpdateZipPath(opts.nextVersion, opts.endFixture, opts.mutateAppPostSign);
await fn(appPath, updateZipPath);
});
};
it('should hit the download endpoint when an update is available and update successfully when the zip is provided', async () => {
await withUpdatableApp({
nextVersion: '2.0.0',
startFixture: 'update',
endFixture: 'update'
}, async (appPath, updateZipPath) => {
server.get('/update-file', (req, res) => {
res.download(updateZipPath);
});
server.get('/update-check', (req, res) => {
res.json({
url: `http://localhost:${port}/update-file`,
name: 'My Release Name',
notes: 'Theses are some release notes innit',
pub_date: (new Date()).toString()
});
});
const relaunchPromise = new Promise<void>((resolve) => {
server.get('/update-check/updated/:version', (req, res) => {
res.status(204).send();
resolve();
});
});
const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
logOnError(launchResult, () => {
expect(launchResult).to.have.property('code', 0);
expect(launchResult.out).to.include('Update Downloaded');
expect(requests).to.have.lengthOf(2);
expect(requests[0]).to.have.property('url', '/update-check');
expect(requests[1]).to.have.property('url', '/update-file');
expect(requests[0].header('user-agent')).to.include('Electron/');
expect(requests[1].header('user-agent')).to.include('Electron/');
});
await relaunchPromise;
expect(requests).to.have.lengthOf(3);
expect(requests[2].url).to.equal('/update-check/updated/2.0.0');
expect(requests[2].header('user-agent')).to.include('Electron/');
});
});
describe('with SquirrelMacEnableDirectContentsWrite enabled', () => {
let previousValue: any;
beforeEach(() => {
previousValue = systemPreferences.getUserDefault('SquirrelMacEnableDirectContentsWrite', 'boolean');
systemPreferences.setUserDefault('SquirrelMacEnableDirectContentsWrite', 'boolean', true as any);
});
afterEach(() => {
systemPreferences.setUserDefault('SquirrelMacEnableDirectContentsWrite', 'boolean', previousValue as any);
});
it('should hit the download endpoint when an update is available and update successfully when the zip is provided leaving the parent directory untouched', async () => {
await withUpdatableApp({
nextVersion: '2.0.0',
startFixture: 'update',
endFixture: 'update'
}, async (appPath, updateZipPath) => {
const randomID = uuid.v4();
cp.spawnSync('xattr', ['-w', 'spec-id', randomID, appPath]);
server.get('/update-file', (req, res) => {
res.download(updateZipPath);
});
server.get('/update-check', (req, res) => {
res.json({
url: `http://localhost:${port}/update-file`,
name: 'My Release Name',
notes: 'Theses are some release notes innit',
pub_date: (new Date()).toString()
});
});
const relaunchPromise = new Promise<void>((resolve) => {
server.get('/update-check/updated/:version', (req, res) => {
res.status(204).send();
resolve();
});
});
const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
logOnError(launchResult, () => {
expect(launchResult).to.have.property('code', 0);
expect(launchResult.out).to.include('Update Downloaded');
expect(requests).to.have.lengthOf(2);
expect(requests[0]).to.have.property('url', '/update-check');
expect(requests[1]).to.have.property('url', '/update-file');
expect(requests[0].header('user-agent')).to.include('Electron/');
expect(requests[1].header('user-agent')).to.include('Electron/');
});
await relaunchPromise;
expect(requests).to.have.lengthOf(3);
expect(requests[2].url).to.equal('/update-check/updated/2.0.0');
expect(requests[2].header('user-agent')).to.include('Electron/');
const result = cp.spawnSync('xattr', ['-l', appPath]);
expect(result.stdout.toString()).to.include(`spec-id: ${randomID}`);
});
});
});
it('should hit the download endpoint when an update is available and fail when the zip signature is invalid', async () => {
await withUpdatableApp({
nextVersion: '2.0.0',
startFixture: 'update',
endFixture: 'update',
mutateAppPostSign: {
mutationKey: 'add-resource',
mutate: async (appPath) => {
const resourcesPath = path.resolve(appPath, 'Contents', 'Resources', 'app', 'injected.txt');
await fs.writeFile(resourcesPath, 'demo');
}
}
}, async (appPath, updateZipPath) => {
server.get('/update-file', (req, res) => {
res.download(updateZipPath);
});
server.get('/update-check', (req, res) => {
res.json({
url: `http://localhost:${port}/update-file`,
name: 'My Release Name',
notes: 'Theses are some release notes innit',
pub_date: (new Date()).toString()
});
});
const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
logOnError(launchResult, () => {
expect(launchResult).to.have.property('code', 1);
expect(launchResult.out).to.include('Code signature at URL');
expect(launchResult.out).to.include('a sealed resource is missing or invalid');
expect(requests).to.have.lengthOf(2);
expect(requests[0]).to.have.property('url', '/update-check');
expect(requests[1]).to.have.property('url', '/update-file');
expect(requests[0].header('user-agent')).to.include('Electron/');
expect(requests[1].header('user-agent')).to.include('Electron/');
});
});
});
it('should hit the download endpoint when an update is available and fail when the ShipIt binary is a symlink', async () => {
await withUpdatableApp({
nextVersion: '2.0.0',
startFixture: 'update',
endFixture: 'update',
mutateAppPostSign: {
mutationKey: 'modify-shipit',
mutate: async (appPath) => {
const shipItPath = path.resolve(appPath, 'Contents', 'Frameworks', 'Squirrel.framework', 'Resources', 'ShipIt');
await fs.remove(shipItPath);
await fs.symlink('/tmp/ShipIt', shipItPath, 'file');
}
}
}, async (appPath, updateZipPath) => {
server.get('/update-file', (req, res) => {
res.download(updateZipPath);
});
server.get('/update-check', (req, res) => {
res.json({
url: `http://localhost:${port}/update-file`,
name: 'My Release Name',
notes: 'Theses are some release notes innit',
pub_date: (new Date()).toString()
});
});
const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
logOnError(launchResult, () => {
expect(launchResult).to.have.property('code', 1);
expect(launchResult.out).to.include('Code signature at URL');
expect(launchResult.out).to.include('a sealed resource is missing or invalid');
expect(requests).to.have.lengthOf(2);
expect(requests[0]).to.have.property('url', '/update-check');
expect(requests[1]).to.have.property('url', '/update-file');
expect(requests[0].header('user-agent')).to.include('Electron/');
expect(requests[1].header('user-agent')).to.include('Electron/');
});
});
});
it('should hit the download endpoint when an update is available and fail when the Electron Framework is modified', async () => {
await withUpdatableApp({
nextVersion: '2.0.0',
startFixture: 'update',
endFixture: 'update',
mutateAppPostSign: {
mutationKey: 'modify-eframework',
mutate: async (appPath) => {
const shipItPath = path.resolve(appPath, 'Contents', 'Frameworks', 'Electron Framework.framework', 'Electron Framework');
await fs.appendFile(shipItPath, Buffer.from('123'));
}
}
}, async (appPath, updateZipPath) => {
server.get('/update-file', (req, res) => {
res.download(updateZipPath);
});
server.get('/update-check', (req, res) => {
res.json({
url: `http://localhost:${port}/update-file`,
name: 'My Release Name',
notes: 'Theses are some release notes innit',
pub_date: (new Date()).toString()
});
});
const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
logOnError(launchResult, () => {
expect(launchResult).to.have.property('code', 1);
expect(launchResult.out).to.include('Code signature at URL');
expect(launchResult.out).to.include(' main executable failed strict validation');
expect(requests).to.have.lengthOf(2);
expect(requests[0]).to.have.property('url', '/update-check');
expect(requests[1]).to.have.property('url', '/update-file');
expect(requests[0].header('user-agent')).to.include('Electron/');
expect(requests[1].header('user-agent')).to.include('Electron/');
});
});
});
it('should hit the download endpoint when an update is available and update successfully when the zip is provided with JSON update mode', async () => {
await withUpdatableApp({
nextVersion: '2.0.0',
startFixture: 'update-json',
endFixture: 'update-json'
}, async (appPath, updateZipPath) => {
server.get('/update-file', (req, res) => {
res.download(updateZipPath);
});
server.get('/update-check', (req, res) => {
res.json({
currentRelease: '2.0.0',
releases: [
{
version: '2.0.0',
updateTo: {
version: '2.0.0',
url: `http://localhost:${port}/update-file`,
name: 'My Release Name',
notes: 'Theses are some release notes innit',
pub_date: (new Date()).toString()
}
}
]
});
});
const relaunchPromise = new Promise<void>((resolve) => {
server.get('/update-check/updated/:version', (req, res) => {
res.status(204).send();
resolve();
});
});
const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
logOnError(launchResult, () => {
expect(launchResult).to.have.property('code', 0);
expect(launchResult.out).to.include('Update Downloaded');
expect(requests).to.have.lengthOf(2);
expect(requests[0]).to.have.property('url', '/update-check');
expect(requests[1]).to.have.property('url', '/update-file');
expect(requests[0].header('user-agent')).to.include('Electron/');
expect(requests[1].header('user-agent')).to.include('Electron/');
});
await relaunchPromise;
expect(requests).to.have.lengthOf(3);
expect(requests[2]).to.have.property('url', '/update-check/updated/2.0.0');
expect(requests[2].header('user-agent')).to.include('Electron/');
});
});
it('should hit the download endpoint when an update is available and not update in JSON update mode when the currentRelease is older than the current version', async () => {
await withUpdatableApp({
nextVersion: '0.1.0',
startFixture: 'update-json',
endFixture: 'update-json'
}, async (appPath, updateZipPath) => {
server.get('/update-file', (req, res) => {
res.download(updateZipPath);
});
server.get('/update-check', (req, res) => {
res.json({
currentRelease: '0.1.0',
releases: [
{
version: '0.1.0',
updateTo: {
version: '0.1.0',
url: `http://localhost:${port}/update-file`,
name: 'My Release Name',
notes: 'Theses are some release notes innit',
pub_date: (new Date()).toString()
}
}
]
});
});
const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
logOnError(launchResult, () => {
expect(launchResult).to.have.property('code', 1);
expect(launchResult.out).to.include('No update available');
expect(requests).to.have.lengthOf(1);
expect(requests[0]).to.have.property('url', '/update-check');
expect(requests[0].header('user-agent')).to.include('Electron/');
});
});
});
});
});

View file

@ -0,0 +1,418 @@
import { expect } from 'chai';
import * as path from 'path';
import { emittedOnce } from './events-helpers';
import { BrowserView, BrowserWindow, screen, webContents } from 'electron/main';
import { closeWindow } from './window-helpers';
import { defer, ifit, startRemoteControlApp } from './spec-helpers';
import { areColorsSimilar, captureScreen, getPixelColor } from './screen-helpers';
describe('BrowserView module', () => {
const fixtures = path.resolve(__dirname, 'fixtures');
let w: BrowserWindow;
let view: BrowserView;
beforeEach(() => {
expect(webContents.getAllWebContents()).to.have.length(0);
w = new BrowserWindow({
show: false,
width: 400,
height: 400,
webPreferences: {
backgroundThrottling: false
}
});
});
afterEach(async () => {
const p = emittedOnce(w.webContents, 'destroyed');
await closeWindow(w);
w = null as any;
await p;
if (view) {
const p = emittedOnce(view.webContents, 'destroyed');
(view.webContents as any).destroy();
view = null as any;
await p;
}
expect(webContents.getAllWebContents()).to.have.length(0);
});
it('can be created with an existing webContents', async () => {
const wc = (webContents as any).create({ sandbox: true });
await wc.loadURL('about:blank');
view = new BrowserView({ webContents: wc } as any);
expect(view.webContents.getURL()).to.equal('about:blank');
});
describe('BrowserView.setBackgroundColor()', () => {
it('does not throw for valid args', () => {
view = new BrowserView();
view.setBackgroundColor('#000');
});
it('throws for invalid args', () => {
view = new BrowserView();
expect(() => {
view.setBackgroundColor(null as any);
}).to.throw(/conversion failure/);
});
// Linux and arm64 platforms (WOA and macOS) do not return any capture sources
ifit(process.platform !== 'linux' && process.arch !== 'arm64')('sets the background color to transparent if none is set', async () => {
const display = screen.getPrimaryDisplay();
const WINDOW_BACKGROUND_COLOR = '#55ccbb';
w.show();
w.setBounds(display.bounds);
w.setBackgroundColor(WINDOW_BACKGROUND_COLOR);
await w.loadURL('about:blank');
view = new BrowserView();
view.setBounds(display.bounds);
w.setBrowserView(view);
await view.webContents.loadURL('data:text/html,hello there');
const screenCapture = await captureScreen();
const centerColor = getPixelColor(screenCapture, {
x: display.size.width / 2,
y: display.size.height / 2
});
expect(areColorsSimilar(centerColor, WINDOW_BACKGROUND_COLOR)).to.be.true();
});
// Linux and arm64 platforms (WOA and macOS) do not return any capture sources
ifit(process.platform !== 'linux' && process.arch !== 'arm64')('successfully applies the background color', async () => {
const WINDOW_BACKGROUND_COLOR = '#55ccbb';
const VIEW_BACKGROUND_COLOR = '#ff00ff';
const display = screen.getPrimaryDisplay();
w.show();
w.setBounds(display.bounds);
w.setBackgroundColor(WINDOW_BACKGROUND_COLOR);
await w.loadURL('about:blank');
view = new BrowserView();
view.setBounds(display.bounds);
w.setBrowserView(view);
w.setBackgroundColor(VIEW_BACKGROUND_COLOR);
await view.webContents.loadURL('data:text/html,hello there');
const screenCapture = await captureScreen();
const centerColor = getPixelColor(screenCapture, {
x: display.size.width / 2,
y: display.size.height / 2
});
expect(areColorsSimilar(centerColor, VIEW_BACKGROUND_COLOR)).to.be.true();
});
});
describe('BrowserView.setAutoResize()', () => {
it('does not throw for valid args', () => {
view = new BrowserView();
view.setAutoResize({});
view.setAutoResize({ width: true, height: false });
});
it('throws for invalid args', () => {
view = new BrowserView();
expect(() => {
view.setAutoResize(null as any);
}).to.throw(/conversion failure/);
});
});
describe('BrowserView.setBounds()', () => {
it('does not throw for valid args', () => {
view = new BrowserView();
view.setBounds({ x: 0, y: 0, width: 1, height: 1 });
});
it('throws for invalid args', () => {
view = new BrowserView();
expect(() => {
view.setBounds(null as any);
}).to.throw(/conversion failure/);
expect(() => {
view.setBounds({} as any);
}).to.throw(/conversion failure/);
});
});
describe('BrowserView.getBounds()', () => {
it('returns the current bounds', () => {
view = new BrowserView();
const bounds = { x: 10, y: 20, width: 30, height: 40 };
view.setBounds(bounds);
expect(view.getBounds()).to.deep.equal(bounds);
});
});
describe('BrowserWindow.setBrowserView()', () => {
it('does not throw for valid args', () => {
view = new BrowserView();
w.setBrowserView(view);
});
it('does not throw if called multiple times with same view', () => {
view = new BrowserView();
w.setBrowserView(view);
w.setBrowserView(view);
w.setBrowserView(view);
});
});
describe('BrowserWindow.getBrowserView()', () => {
it('returns the set view', () => {
view = new BrowserView();
w.setBrowserView(view);
const view2 = w.getBrowserView();
expect(view2!.webContents.id).to.equal(view.webContents.id);
});
it('returns null if none is set', () => {
const view = w.getBrowserView();
expect(view).to.be.null('view');
});
});
describe('BrowserWindow.addBrowserView()', () => {
it('does not throw for valid args', () => {
const view1 = new BrowserView();
defer(() => (view1.webContents as any).destroy());
w.addBrowserView(view1);
defer(() => w.removeBrowserView(view1));
const view2 = new BrowserView();
defer(() => (view2.webContents as any).destroy());
w.addBrowserView(view2);
defer(() => w.removeBrowserView(view2));
});
it('does not throw if called multiple times with same view', () => {
view = new BrowserView();
w.addBrowserView(view);
w.addBrowserView(view);
w.addBrowserView(view);
});
it('does not crash if the BrowserView webContents are destroyed prior to window addition', () => {
expect(() => {
const view1 = new BrowserView();
(view1.webContents as any).destroy();
w.addBrowserView(view1);
}).to.not.throw();
});
it('does not crash if the webContents is destroyed after a URL is loaded', () => {
view = new BrowserView();
expect(async () => {
view.setBounds({ x: 0, y: 0, width: 400, height: 300 });
await view.webContents.loadURL('data:text/html,hello there');
view.webContents.destroy();
}).to.not.throw();
});
it('can handle BrowserView reparenting', async () => {
view = new BrowserView();
w.addBrowserView(view);
view.webContents.loadURL('about:blank');
await emittedOnce(view.webContents, 'did-finish-load');
const w2 = new BrowserWindow({ show: false });
w2.addBrowserView(view);
w.close();
view.webContents.loadURL(`file://${fixtures}/pages/blank.html`);
await emittedOnce(view.webContents, 'did-finish-load');
// Clean up - the afterEach hook assumes the webContents on w is still alive.
w = new BrowserWindow({ show: false });
w2.close();
w2.destroy();
});
});
describe('BrowserWindow.removeBrowserView()', () => {
it('does not throw if called multiple times with same view', () => {
expect(() => {
view = new BrowserView();
w.addBrowserView(view);
w.removeBrowserView(view);
w.removeBrowserView(view);
}).to.not.throw();
});
});
describe('BrowserWindow.getBrowserViews()', () => {
it('returns same views as was added', () => {
const view1 = new BrowserView();
defer(() => (view1.webContents as any).destroy());
w.addBrowserView(view1);
defer(() => w.removeBrowserView(view1));
const view2 = new BrowserView();
defer(() => (view2.webContents as any).destroy());
w.addBrowserView(view2);
defer(() => w.removeBrowserView(view2));
const views = w.getBrowserViews();
expect(views).to.have.lengthOf(2);
expect(views[0].webContents.id).to.equal(view1.webContents.id);
expect(views[1].webContents.id).to.equal(view2.webContents.id);
});
});
describe('BrowserWindow.setTopBrowserView()', () => {
it('should throw an error when a BrowserView is not attached to the window', () => {
view = new BrowserView();
expect(() => {
w.setTopBrowserView(view);
}).to.throw(/is not attached/);
});
it('should throw an error when a BrowserView is attached to some other window', () => {
view = new BrowserView();
const win2 = new BrowserWindow();
w.addBrowserView(view);
view.setBounds({ x: 0, y: 0, width: 100, height: 100 });
win2.addBrowserView(view);
expect(() => {
w.setTopBrowserView(view);
}).to.throw(/is not attached/);
win2.close();
win2.destroy();
});
});
describe('BrowserView.webContents.getOwnerBrowserWindow()', () => {
it('points to owning window', () => {
view = new BrowserView();
expect(view.webContents.getOwnerBrowserWindow()).to.be.null('owner browser window');
w.setBrowserView(view);
expect(view.webContents.getOwnerBrowserWindow()).to.equal(w);
w.setBrowserView(null);
expect(view.webContents.getOwnerBrowserWindow()).to.be.null('owner browser window');
});
});
describe('shutdown behavior', () => {
it('does not crash on exit', async () => {
const rc = await startRemoteControlApp();
await rc.remotely(() => {
const { BrowserView, app } = require('electron');
new BrowserView({}) // eslint-disable-line
setTimeout(() => {
app.quit();
});
});
const [code] = await emittedOnce(rc.process, 'exit');
expect(code).to.equal(0);
});
it('does not crash on exit if added to a browser window', async () => {
const rc = await startRemoteControlApp();
await rc.remotely(() => {
const { app, BrowserView, BrowserWindow } = require('electron');
const bv = new BrowserView();
bv.webContents.loadURL('about:blank');
const bw = new BrowserWindow({ show: false });
bw.addBrowserView(bv);
setTimeout(() => {
app.quit();
});
});
const [code] = await emittedOnce(rc.process, 'exit');
expect(code).to.equal(0);
});
});
describe('window.open()', () => {
it('works in BrowserView', (done) => {
view = new BrowserView();
w.setBrowserView(view);
view.webContents.setWindowOpenHandler(({ url, frameName }) => {
expect(url).to.equal('http://host/');
expect(frameName).to.equal('host');
done();
return { action: 'deny' };
});
view.webContents.loadFile(path.join(fixtures, 'pages', 'window-open.html'));
});
});
describe('BrowserView.capturePage(rect)', () => {
it('returns a Promise with a Buffer', async () => {
view = new BrowserView({
webPreferences: {
backgroundThrottling: false
}
});
w.addBrowserView(view);
view.setBounds({
...w.getBounds(),
x: 0,
y: 0
});
const image = await view.webContents.capturePage({
x: 0,
y: 0,
width: 100,
height: 100
});
expect(image.isEmpty()).to.equal(true);
});
xit('resolves after the window is hidden and capturer count is non-zero', async () => {
view = new BrowserView({
webPreferences: {
backgroundThrottling: false
}
});
w.setBrowserView(view);
view.setBounds({
...w.getBounds(),
x: 0,
y: 0
});
await view.webContents.loadFile(path.join(fixtures, 'pages', 'a.html'));
view.webContents.incrementCapturerCount();
const image = await view.webContents.capturePage();
expect(image.isEmpty()).to.equal(false);
});
it('should increase the capturer count', () => {
view = new BrowserView({
webPreferences: {
backgroundThrottling: false
}
});
w.setBrowserView(view);
view.setBounds({
...w.getBounds(),
x: 0,
y: 0
});
view.webContents.incrementCapturerCount();
expect(view.webContents.isBeingCaptured()).to.be.true();
view.webContents.decrementCapturerCount();
expect(view.webContents.isBeingCaptured()).to.be.false();
});
});
});

File diff suppressed because it is too large Load diff

143
spec/api-clipboard-spec.ts Normal file
View file

@ -0,0 +1,143 @@
import { expect } from 'chai';
import * as path from 'path';
import { Buffer } from 'buffer';
import { ifdescribe, ifit } from './spec-helpers';
import { clipboard, nativeImage } from 'electron/common';
// FIXME(zcbenz): Clipboard tests are failing on WOA.
ifdescribe(process.platform !== 'win32' || process.arch !== 'arm64')('clipboard module', () => {
const fixtures = path.resolve(__dirname, 'fixtures');
describe('clipboard.readImage()', () => {
it('returns NativeImage instance', () => {
const p = path.join(fixtures, 'assets', 'logo.png');
const i = nativeImage.createFromPath(p);
clipboard.writeImage(i);
const readImage = clipboard.readImage();
expect(readImage.toDataURL()).to.equal(i.toDataURL());
});
});
describe('clipboard.readText()', () => {
it('returns unicode string correctly', () => {
const text = '千江有水千江月,万里无云万里天';
clipboard.writeText(text);
expect(clipboard.readText()).to.equal(text);
});
});
describe('clipboard.readHTML()', () => {
it('returns markup correctly', () => {
const text = '<string>Hi</string>';
const markup = process.platform === 'darwin' ? "<meta charset='utf-8'><string>Hi</string>" : '<string>Hi</string>';
clipboard.writeHTML(text);
expect(clipboard.readHTML()).to.equal(markup);
});
});
describe('clipboard.readRTF', () => {
it('returns rtf text correctly', () => {
const rtf = '{\\rtf1\\ansi{\\fonttbl\\f0\\fswiss Helvetica;}\\f0\\pard\nThis is some {\\b bold} text.\\par\n}';
clipboard.writeRTF(rtf);
expect(clipboard.readRTF()).to.equal(rtf);
});
});
ifdescribe(process.platform !== 'linux')('clipboard.readBookmark', () => {
it('returns title and url', () => {
clipboard.writeBookmark('a title', 'https://electronjs.org');
const readBookmark = clipboard.readBookmark();
if (process.platform !== 'win32') {
expect(readBookmark.title).to.equal('a title');
}
expect(clipboard.readBookmark().url).to.equal('https://electronjs.org');
clipboard.writeText('no bookmark');
expect(clipboard.readBookmark()).to.deep.equal({
title: '',
url: ''
});
});
});
describe('clipboard.read()', () => {
ifit(process.platform !== 'linux')('does not crash when reading various custom clipboard types', () => {
const type = process.platform === 'darwin' ? 'NSFilenamesPboardType' : 'FileNameW';
expect(() => {
clipboard.read(type);
}).to.not.throw();
});
it('can read data written with writeBuffer', () => {
const testText = 'Testing read';
const buffer = Buffer.from(testText, 'utf8');
clipboard.writeBuffer('public/utf8-plain-text', buffer);
expect(clipboard.read('public/utf8-plain-text')).to.equal(testText);
});
});
describe('clipboard.write()', () => {
it('returns data correctly', () => {
const text = 'test';
const rtf = '{\\rtf1\\utf8 text}';
const p = path.join(fixtures, 'assets', 'logo.png');
const i = nativeImage.createFromPath(p);
const markup = process.platform === 'darwin' ? "<meta charset='utf-8'><b>Hi</b>" : '<b>Hi</b>';
const bookmark = { title: 'a title', url: 'test' };
clipboard.write({
text: 'test',
html: '<b>Hi</b>',
rtf: '{\\rtf1\\utf8 text}',
bookmark: 'a title',
image: i
});
expect(clipboard.readText()).to.equal(text);
expect(clipboard.readHTML()).to.equal(markup);
expect(clipboard.readRTF()).to.equal(rtf);
const readImage = clipboard.readImage();
expect(readImage.toDataURL()).to.equal(i.toDataURL());
if (process.platform !== 'linux') {
if (process.platform !== 'win32') {
expect(clipboard.readBookmark()).to.deep.equal(bookmark);
} else {
expect(clipboard.readBookmark().url).to.equal(bookmark.url);
}
}
});
});
ifdescribe(process.platform === 'darwin')('clipboard.read/writeFindText(text)', () => {
it('reads and write text to the find pasteboard', () => {
clipboard.writeFindText('find this');
expect(clipboard.readFindText()).to.equal('find this');
});
});
describe('clipboard.readBuffer(format)', () => {
it('writes a Buffer for the specified format', function () {
const buffer = Buffer.from('writeBuffer', 'utf8');
clipboard.writeBuffer('public/utf8-plain-text', buffer);
expect(buffer.equals(clipboard.readBuffer('public/utf8-plain-text'))).to.equal(true);
});
it('throws an error when a non-Buffer is specified', () => {
expect(() => {
clipboard.writeBuffer('public/utf8-plain-text', 'hello' as any);
}).to.throw(/buffer must be a node Buffer/);
});
ifit(process.platform !== 'win32')('writes a Buffer using a raw format that is used by native apps', function () {
const message = 'Hello from Electron!';
const buffer = Buffer.from(message);
let rawFormat = 'text/plain';
if (process.platform === 'darwin') {
rawFormat = 'public.utf8-plain-text';
}
clipboard.writeBuffer(rawFormat, buffer);
expect(clipboard.readText()).to.equal(message);
});
});
});

View file

@ -0,0 +1,145 @@
import { expect } from 'chai';
import { app, contentTracing, TraceConfig, TraceCategoriesAndOptions } from 'electron/main';
import * as fs from 'fs';
import * as path from 'path';
import { ifdescribe, delay } from './spec-helpers';
// FIXME: The tests are skipped on arm/arm64 and ia32.
ifdescribe(!(['arm', 'arm64', 'ia32'].includes(process.arch)))('contentTracing', () => {
const record = async (options: TraceConfig | TraceCategoriesAndOptions, outputFilePath: string | undefined, recordTimeInMilliseconds = 1e1) => {
await app.whenReady();
await contentTracing.startRecording(options);
await delay(recordTimeInMilliseconds);
const resultFilePath = await contentTracing.stopRecording(outputFilePath);
return resultFilePath;
};
const outputFilePath = path.join(app.getPath('temp'), 'trace.json');
beforeEach(() => {
if (fs.existsSync(outputFilePath)) {
fs.unlinkSync(outputFilePath);
}
});
describe('startRecording', function () {
this.timeout(5e3);
const getFileSizeInKiloBytes = (filePath: string) => {
const stats = fs.statSync(filePath);
const fileSizeInBytes = stats.size;
const fileSizeInKiloBytes = fileSizeInBytes / 1024;
return fileSizeInKiloBytes;
};
it('accepts an empty config', async () => {
const config = {};
await record(config, outputFilePath);
expect(fs.existsSync(outputFilePath)).to.be.true('output exists');
const fileSizeInKiloBytes = getFileSizeInKiloBytes(outputFilePath);
expect(fileSizeInKiloBytes).to.be.above(0,
`the trace output file is empty, check "${outputFilePath}"`);
});
it('accepts a trace config', async () => {
// (alexeykuzmin): All categories are excluded on purpose,
// so only metadata gets into the output file.
const config = {
excluded_categories: ['*']
};
await record(config, outputFilePath);
// If the `excluded_categories` param above is not respected, categories
// like `node,node.environment` will be included in the output.
const content = fs.readFileSync(outputFilePath).toString();
expect(content.includes('"cat":"node,node.environment"')).to.be.false();
});
it('accepts "categoryFilter" and "traceOptions" as a config', async () => {
// (alexeykuzmin): All categories are excluded on purpose,
// so only metadata gets into the output file.
const config = {
categoryFilter: '__ThisIsANonexistentCategory__',
traceOptions: ''
};
await record(config, outputFilePath);
expect(fs.existsSync(outputFilePath)).to.be.true('output exists');
// If the `categoryFilter` param above is not respected
// the file size will be above 50KB.
const fileSizeInKiloBytes = getFileSizeInKiloBytes(outputFilePath);
const expectedMaximumFileSize = 10; // Depends on a platform.
expect(fileSizeInKiloBytes).to.be.above(0,
`the trace output file is empty, check "${outputFilePath}"`);
expect(fileSizeInKiloBytes).to.be.below(expectedMaximumFileSize,
`the trace output file is suspiciously large (${fileSizeInKiloBytes}KB),
check "${outputFilePath}"`);
});
});
describe('stopRecording', function () {
this.timeout(5e3);
it('does not crash on empty string', async () => {
const options = {
categoryFilter: '*',
traceOptions: 'record-until-full,enable-sampling'
};
await contentTracing.startRecording(options);
const path = await contentTracing.stopRecording('');
expect(path).to.be.a('string').that.is.not.empty('result path');
expect(fs.statSync(path).isFile()).to.be.true('output exists');
});
it('calls its callback with a result file path', async () => {
const resultFilePath = await record(/* options */ {}, outputFilePath);
expect(resultFilePath).to.be.a('string').and.be.equal(outputFilePath);
});
it('creates a temporary file when an empty string is passed', async function () {
const resultFilePath = await record(/* options */ {}, /* outputFilePath */ '');
expect(resultFilePath).to.be.a('string').that.is.not.empty('result path');
});
it('creates a temporary file when no path is passed', async function () {
const resultFilePath = await record(/* options */ {}, /* outputFilePath */ undefined);
expect(resultFilePath).to.be.a('string').that.is.not.empty('result path');
});
it('rejects if no trace is happening', async () => {
await expect(contentTracing.stopRecording()).to.be.rejected();
});
});
describe('captured events', () => {
it('include V8 samples from the main process', async function () {
// This test is flaky on macOS CI.
this.retries(3);
await contentTracing.startRecording({
categoryFilter: 'disabled-by-default-v8.cpu_profiler',
traceOptions: 'record-until-full'
});
{
const start = +new Date();
let n = 0;
const f = () => {};
while (+new Date() - start < 200 || n < 500) {
await delay(0);
f();
n++;
}
}
const path = await contentTracing.stopRecording();
const data = fs.readFileSync(path, 'utf8');
const parsed = JSON.parse(data);
expect(parsed.traceEvents.some((x: any) => x.cat === 'disabled-by-default-v8.cpu_profiler' && x.name === 'ProfileChunk')).to.be.true();
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,617 @@
import { expect } from 'chai';
import * as childProcess from 'child_process';
import * as http from 'http';
import * as Busboy from 'busboy';
import * as path from 'path';
import { ifdescribe, ifit, defer, startRemoteControlApp, delay, repeatedly } from './spec-helpers';
import { app } from 'electron/main';
import { crashReporter } from 'electron/common';
import { AddressInfo } from 'net';
import { EventEmitter } from 'events';
import * as fs from 'fs';
import * as uuid from 'uuid';
const isWindowsOnArm = process.platform === 'win32' && process.arch === 'arm64';
const isLinuxOnArm = process.platform === 'linux' && process.arch.includes('arm');
type CrashInfo = {
prod: string
ver: string
process_type: string // eslint-disable-line camelcase
ptype: string
platform: string
_productName: string
_version: string
upload_file_minidump: Buffer // eslint-disable-line camelcase
guid: string
mainProcessSpecific: 'mps' | undefined
rendererSpecific: 'rs' | undefined
globalParam: 'globalValue' | undefined
addedThenRemoved: 'to-be-removed' | undefined
longParam: string | undefined
'electron.v8-fatal.location': string | undefined
'electron.v8-fatal.message': string | undefined
}
function checkCrash (expectedProcessType: string, fields: CrashInfo) {
expect(String(fields.prod)).to.equal('Electron', 'prod');
expect(String(fields.ver)).to.equal(process.versions.electron, 'ver');
expect(String(fields.ptype)).to.equal(expectedProcessType, 'ptype');
expect(String(fields.process_type)).to.equal(expectedProcessType, 'process_type');
expect(String(fields.platform)).to.equal(process.platform, 'platform');
expect(String(fields._productName)).to.equal('Zombies', '_productName');
expect(String(fields._version)).to.equal(app.getVersion(), '_version');
expect(fields.upload_file_minidump).to.be.an.instanceOf(Buffer);
// TODO(nornagon): minidumps are sometimes (not always) turning up empty on
// 32-bit Linux. Figure out why.
if (!(process.platform === 'linux' && process.arch === 'ia32')) {
expect(fields.upload_file_minidump.length).to.be.greaterThan(0);
}
}
const startServer = async () => {
const crashes: CrashInfo[] = [];
function getCrashes () { return crashes; }
const emitter = new EventEmitter();
function waitForCrash (): Promise<CrashInfo> {
return new Promise(resolve => {
emitter.once('crash', (crash) => {
resolve(crash);
});
});
}
const server = http.createServer((req, res) => {
const busboy = new Busboy({ headers: req.headers });
const fields = {} as Record<string, any>;
const files = {} as Record<string, Buffer>;
busboy.on('file', (fieldname, file) => {
const chunks = [] as Array<Buffer>;
file.on('data', (chunk) => {
chunks.push(chunk);
});
file.on('end', () => {
files[fieldname] = Buffer.concat(chunks);
});
});
busboy.on('field', (fieldname, val) => {
fields[fieldname] = val;
});
busboy.on('finish', () => {
// breakpad id must be 16 hex digits.
const reportId = Math.random().toString(16).split('.')[1].padStart(16, '0');
res.end(reportId, async () => {
req.socket.destroy();
emitter.emit('crash', { ...fields, ...files });
});
});
req.pipe(busboy);
});
await new Promise<void>(resolve => {
server.listen(0, '127.0.0.1', () => { resolve(); });
});
const port = (server.address() as AddressInfo).port;
defer(() => { server.close(); });
return { getCrashes, port, waitForCrash };
};
function runApp (appPath: string, args: Array<string> = []) {
const appProcess = childProcess.spawn(process.execPath, [appPath, ...args]);
return new Promise(resolve => {
appProcess.once('exit', resolve);
});
}
function runCrashApp (crashType: string, port: number, extraArgs: Array<string> = []) {
const appPath = path.join(__dirname, 'fixtures', 'apps', 'crash');
return runApp(appPath, [
`--crash-type=${crashType}`,
`--crash-reporter-url=http://127.0.0.1:${port}`,
...extraArgs
]);
}
function waitForNewFileInDir (dir: string): Promise<string[]> {
function readdirIfPresent (dir: string): string[] {
try {
return fs.readdirSync(dir);
} catch (e) {
return [];
}
}
const initialFiles = readdirIfPresent(dir);
return new Promise(resolve => {
const ivl = setInterval(() => {
const newCrashFiles = readdirIfPresent(dir).filter(f => !initialFiles.includes(f));
if (newCrashFiles.length) {
clearInterval(ivl);
resolve(newCrashFiles);
}
}, 1000);
});
}
// TODO(nornagon): Fix tests on linux/arm.
ifdescribe(!isLinuxOnArm && !process.mas && !process.env.DISABLE_CRASH_REPORTER_TESTS)('crashReporter module', function () {
describe('should send minidump', () => {
it('when renderer crashes', async () => {
const { port, waitForCrash } = await startServer();
runCrashApp('renderer', port);
const crash = await waitForCrash();
checkCrash('renderer', crash);
expect(crash.mainProcessSpecific).to.be.undefined();
});
it('when sandboxed renderer crashes', async () => {
const { port, waitForCrash } = await startServer();
runCrashApp('sandboxed-renderer', port);
const crash = await waitForCrash();
checkCrash('renderer', crash);
expect(crash.mainProcessSpecific).to.be.undefined();
});
// TODO(nornagon): Minidump generation in main/node process on Linux/Arm is
// broken (//components/crash prints "Failed to generate minidump"). Figure
// out why.
ifit(!isLinuxOnArm)('when main process crashes', async () => {
const { port, waitForCrash } = await startServer();
runCrashApp('main', port);
const crash = await waitForCrash();
checkCrash('browser', crash);
expect(crash.mainProcessSpecific).to.equal('mps');
});
ifit(!isLinuxOnArm)('when a node process crashes', async () => {
const { port, waitForCrash } = await startServer();
runCrashApp('node', port);
const crash = await waitForCrash();
checkCrash('node', crash);
expect(crash.mainProcessSpecific).to.be.undefined();
expect(crash.rendererSpecific).to.be.undefined();
});
describe('with guid', () => {
for (const processType of ['main', 'renderer', 'sandboxed-renderer']) {
it(`when ${processType} crashes`, async () => {
const { port, waitForCrash } = await startServer();
runCrashApp(processType, port);
const crash = await waitForCrash();
expect(crash.guid).to.be.a('string');
});
}
it('is a consistent id', async () => {
let crash1Guid;
let crash2Guid;
{
const { port, waitForCrash } = await startServer();
runCrashApp('main', port);
const crash = await waitForCrash();
crash1Guid = crash.guid;
}
{
const { port, waitForCrash } = await startServer();
runCrashApp('main', port);
const crash = await waitForCrash();
crash2Guid = crash.guid;
}
expect(crash2Guid).to.equal(crash1Guid);
});
});
describe('with extra parameters', () => {
it('when renderer crashes', async () => {
const { port, waitForCrash } = await startServer();
runCrashApp('renderer', port, ['--set-extra-parameters-in-renderer']);
const crash = await waitForCrash();
checkCrash('renderer', crash);
expect(crash.mainProcessSpecific).to.be.undefined();
expect(crash.rendererSpecific).to.equal('rs');
expect(crash.addedThenRemoved).to.be.undefined();
});
it('when sandboxed renderer crashes', async () => {
const { port, waitForCrash } = await startServer();
runCrashApp('sandboxed-renderer', port, ['--set-extra-parameters-in-renderer']);
const crash = await waitForCrash();
checkCrash('renderer', crash);
expect(crash.mainProcessSpecific).to.be.undefined();
expect(crash.rendererSpecific).to.equal('rs');
expect(crash.addedThenRemoved).to.be.undefined();
});
it('contains v8 crash keys when a v8 crash occurs', async () => {
const { remotely } = await startRemoteControlApp();
const { port, waitForCrash } = await startServer();
await remotely((port: number) => {
require('electron').crashReporter.start({
submitURL: `http://127.0.0.1:${port}`,
compress: false,
ignoreSystemCrashHandler: true
});
}, [port]);
remotely(() => {
const { BrowserWindow } = require('electron');
const bw = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
bw.loadURL('about:blank');
bw.webContents.executeJavaScript('process._linkedBinding(\'electron_common_v8_util\').triggerFatalErrorForTesting()');
});
const crash = await waitForCrash();
expect(crash.prod).to.equal('Electron');
expect(crash._productName).to.equal('electron-test-remote-control');
expect(crash.process_type).to.equal('renderer');
expect(crash['electron.v8-fatal.location']).to.equal('v8::Context::New()');
expect(crash['electron.v8-fatal.message']).to.equal('Circular extension dependency');
});
});
});
ifdescribe(!isLinuxOnArm)('extra parameter limits', () => {
function stitchLongCrashParam (crash: any, paramKey: string) {
if (crash[paramKey]) return crash[paramKey];
let chunk = 1;
let stitched = '';
while (crash[`${paramKey}__${chunk}`]) {
stitched += crash[`${paramKey}__${chunk}`];
chunk++;
}
return stitched;
}
it('should truncate extra values longer than 5 * 4096 characters', async () => {
const { port, waitForCrash } = await startServer();
const { remotely } = await startRemoteControlApp();
remotely((port: number) => {
require('electron').crashReporter.start({
submitURL: `http://127.0.0.1:${port}`,
compress: false,
ignoreSystemCrashHandler: true,
extra: { longParam: 'a'.repeat(100000) }
});
setTimeout(() => process.crash());
}, port);
const crash = await waitForCrash();
expect(stitchLongCrashParam(crash, 'longParam')).to.have.lengthOf(160 * 127 + (process.platform === 'linux' ? 159 : 0), 'crash should have truncated longParam');
});
it('should omit extra keys with names longer than the maximum', async () => {
const kKeyLengthMax = 39;
const { port, waitForCrash } = await startServer();
const { remotely } = await startRemoteControlApp();
remotely((port: number, kKeyLengthMax: number) => {
require('electron').crashReporter.start({
submitURL: `http://127.0.0.1:${port}`,
compress: false,
ignoreSystemCrashHandler: true,
extra: {
['a'.repeat(kKeyLengthMax + 10)]: 'value',
['b'.repeat(kKeyLengthMax)]: 'value',
'not-long': 'not-long-value'
}
});
require('electron').crashReporter.addExtraParameter('c'.repeat(kKeyLengthMax + 10), 'value');
setTimeout(() => process.crash());
}, port, kKeyLengthMax);
const crash = await waitForCrash();
expect(crash).not.to.have.property('a'.repeat(kKeyLengthMax + 10));
expect(crash).not.to.have.property('a'.repeat(kKeyLengthMax));
expect(crash).to.have.property('b'.repeat(kKeyLengthMax), 'value');
expect(crash).to.have.property('not-long', 'not-long-value');
expect(crash).not.to.have.property('c'.repeat(kKeyLengthMax + 10));
expect(crash).not.to.have.property('c'.repeat(kKeyLengthMax));
});
});
describe('globalExtra', () => {
ifit(!isLinuxOnArm)('should be sent with main process dumps', async () => {
const { port, waitForCrash } = await startServer();
runCrashApp('main', port, ['--add-global-param=globalParam:globalValue']);
const crash = await waitForCrash();
expect(crash.globalParam).to.equal('globalValue');
});
it('should be sent with renderer process dumps', async () => {
const { port, waitForCrash } = await startServer();
runCrashApp('renderer', port, ['--add-global-param=globalParam:globalValue']);
const crash = await waitForCrash();
expect(crash.globalParam).to.equal('globalValue');
});
it('should be sent with sandboxed renderer process dumps', async () => {
const { port, waitForCrash } = await startServer();
runCrashApp('sandboxed-renderer', port, ['--add-global-param=globalParam:globalValue']);
const crash = await waitForCrash();
expect(crash.globalParam).to.equal('globalValue');
});
ifit(!isLinuxOnArm)('should not be overridden by extra in main process', async () => {
const { port, waitForCrash } = await startServer();
runCrashApp('main', port, ['--add-global-param=mainProcessSpecific:global']);
const crash = await waitForCrash();
expect(crash.mainProcessSpecific).to.equal('global');
});
ifit(!isLinuxOnArm)('should not be overridden by extra in renderer process', async () => {
const { port, waitForCrash } = await startServer();
runCrashApp('main', port, ['--add-global-param=rendererSpecific:global']);
const crash = await waitForCrash();
expect(crash.rendererSpecific).to.equal('global');
});
});
// TODO(nornagon): also test crashing main / sandboxed renderers.
ifit(!isWindowsOnArm)('should not send a minidump when uploadToServer is false', async () => {
const { port, waitForCrash, getCrashes } = await startServer();
waitForCrash().then(() => expect.fail('expected not to receive a dump'));
await runCrashApp('renderer', port, ['--no-upload']);
// wait a sec in case the crash reporter is about to upload a crash
await delay(1000);
expect(getCrashes()).to.have.length(0);
});
describe('getUploadedReports', () => {
it('returns an array of reports', async () => {
const { remotely } = await startRemoteControlApp();
await remotely(() => {
require('electron').crashReporter.start({ submitURL: 'http://127.0.0.1' });
});
const reports = await remotely(() => require('electron').crashReporter.getUploadedReports());
expect(reports).to.be.an('array');
});
});
// TODO(nornagon): re-enable on woa
ifdescribe(!isWindowsOnArm)('getLastCrashReport', () => {
it('returns the last uploaded report', async () => {
const { remotely } = await startRemoteControlApp();
const { port, waitForCrash } = await startServer();
// 0. clear the crash reports directory.
const dir = await remotely(() => require('electron').app.getPath('crashDumps'));
try {
fs.rmdirSync(dir, { recursive: true });
fs.mkdirSync(dir);
} catch (e) { /* ignore */ }
// 1. start the crash reporter.
await remotely((port: number) => {
require('electron').crashReporter.start({
submitURL: `http://127.0.0.1:${port}`,
compress: false,
ignoreSystemCrashHandler: true
});
}, [port]);
// 2. generate a crash in the renderer.
remotely(() => {
const { BrowserWindow } = require('electron');
const bw = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
bw.loadURL('about:blank');
bw.webContents.executeJavaScript('process.crash()');
});
await waitForCrash();
// 3. get the crash from getLastCrashReport.
const firstReport = await repeatedly(
() => remotely(() => require('electron').crashReporter.getLastCrashReport())
);
expect(firstReport).to.not.be.null();
expect(firstReport.date).to.be.an.instanceOf(Date);
expect((+new Date()) - (+firstReport.date)).to.be.lessThan(30000);
});
});
describe('getParameters', () => {
it('returns all of the current parameters', async () => {
const { remotely } = await startRemoteControlApp();
await remotely(() => {
require('electron').crashReporter.start({
submitURL: 'http://127.0.0.1',
extra: { extra1: 'hi' }
});
});
const parameters = await remotely(() => require('electron').crashReporter.getParameters());
expect(parameters).to.have.property('extra1', 'hi');
});
it('reflects added and removed parameters', async () => {
const { remotely } = await startRemoteControlApp();
await remotely(() => {
require('electron').crashReporter.start({ submitURL: 'http://127.0.0.1' });
require('electron').crashReporter.addExtraParameter('hello', 'world');
});
{
const parameters = await remotely(() => require('electron').crashReporter.getParameters());
expect(parameters).to.have.property('hello', 'world');
}
await remotely(() => { require('electron').crashReporter.removeExtraParameter('hello'); });
{
const parameters = await remotely(() => require('electron').crashReporter.getParameters());
expect(parameters).not.to.have.property('hello');
}
});
it('can be called in the renderer', async () => {
const { remotely } = await startRemoteControlApp();
const rendererParameters = await remotely(async () => {
const { crashReporter, BrowserWindow } = require('electron');
crashReporter.start({ submitURL: 'http://' });
const bw = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
bw.loadURL('about:blank');
await bw.webContents.executeJavaScript('require(\'electron\').crashReporter.addExtraParameter(\'hello\', \'world\')');
return bw.webContents.executeJavaScript('require(\'electron\').crashReporter.getParameters()');
});
expect(rendererParameters).to.deep.equal({ hello: 'world' });
});
it('can be called in a node child process', async () => {
function slurp (stream: NodeJS.ReadableStream): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on('data', chunk => { chunks.push(chunk); });
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
stream.on('error', e => reject(e));
});
}
// TODO(nornagon): how to enable crashpad in a node child process...?
const child = childProcess.fork(path.join(__dirname, 'fixtures', 'module', 'print-crash-parameters.js'), [], { silent: true });
const output = await slurp(child.stdout!);
expect(JSON.parse(output)).to.deep.equal({ hello: 'world' });
});
});
describe('crash dumps directory', () => {
it('is set by default', () => {
expect(app.getPath('crashDumps')).to.be.a('string');
});
it('is inside the user data dir', () => {
expect(app.getPath('crashDumps')).to.include(app.getPath('userData'));
});
function crash (processType: string, remotely: Function) {
if (processType === 'main') {
return remotely(() => {
setTimeout(() => { process.crash(); });
});
} else if (processType === 'renderer') {
return remotely(() => {
const { BrowserWindow } = require('electron');
const bw = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
bw.loadURL('about:blank');
bw.webContents.executeJavaScript('process.crash()');
});
} else if (processType === 'sandboxed-renderer') {
const preloadPath = path.join(__dirname, 'fixtures', 'apps', 'crash', 'sandbox-preload.js');
return remotely((preload: string) => {
const { BrowserWindow } = require('electron');
const bw = new BrowserWindow({ show: false, webPreferences: { sandbox: true, preload, contextIsolation: false } });
bw.loadURL('about:blank');
}, preloadPath);
} else if (processType === 'node') {
const crashScriptPath = path.join(__dirname, 'fixtures', 'apps', 'crash', 'node-crash.js');
return remotely((crashScriptPath: string) => {
const { app } = require('electron');
const childProcess = require('child_process');
const version = app.getVersion();
const url = 'http://127.0.0.1';
childProcess.fork(crashScriptPath, [url, version], { silent: true });
}, crashScriptPath);
}
}
const processList = process.platform === 'linux' ? ['main', 'renderer', 'sandboxed-renderer']
: ['main', 'renderer', 'sandboxed-renderer', 'node'];
for (const crashingProcess of processList) {
describe(`when ${crashingProcess} crashes`, () => {
it('stores crashes in the crash dump directory when uploadToServer: false', async () => {
const { remotely } = await startRemoteControlApp();
const crashesDir = await remotely(() => {
const { crashReporter, app } = require('electron');
crashReporter.start({ submitURL: 'http://127.0.0.1', uploadToServer: false, ignoreSystemCrashHandler: true });
return app.getPath('crashDumps');
});
let reportsDir = crashesDir;
if (process.platform === 'darwin' || process.platform === 'linux') {
reportsDir = path.join(crashesDir, 'completed');
} else if (process.platform === 'win32') {
reportsDir = path.join(crashesDir, 'reports');
}
const newFileAppeared = waitForNewFileInDir(reportsDir);
crash(crashingProcess, remotely);
const newFiles = await newFileAppeared;
expect(newFiles.length).to.be.greaterThan(0);
expect(newFiles[0]).to.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.dmp$/);
});
it('respects an overridden crash dump directory', async () => {
const { remotely } = await startRemoteControlApp();
const crashesDir = path.join(app.getPath('temp'), uuid.v4());
const remoteCrashesDir = await remotely((crashesDir: string) => {
const { crashReporter, app } = require('electron');
app.setPath('crashDumps', crashesDir);
crashReporter.start({ submitURL: 'http://127.0.0.1', uploadToServer: false, ignoreSystemCrashHandler: true });
return app.getPath('crashDumps');
}, crashesDir);
expect(remoteCrashesDir).to.equal(crashesDir);
let reportsDir = crashesDir;
if (process.platform === 'darwin' || process.platform === 'linux') {
reportsDir = path.join(crashesDir, 'completed');
} else if (process.platform === 'win32') {
reportsDir = path.join(crashesDir, 'reports');
}
const newFileAppeared = waitForNewFileInDir(reportsDir);
crash(crashingProcess, remotely);
const newFiles = await newFileAppeared;
expect(newFiles.length).to.be.greaterThan(0);
expect(newFiles[0]).to.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.dmp$/);
});
});
}
});
describe('start() option validation', () => {
it('requires that the submitURL option be specified', () => {
expect(() => {
crashReporter.start({} as any);
}).to.throw('submitURL must be specified when uploadToServer is true');
});
it('allows the submitURL option to be omitted when uploadToServer is false', () => {
expect(() => {
crashReporter.start({ uploadToServer: false } as any);
}).not.to.throw();
});
it('can be called twice', async () => {
const { remotely } = await startRemoteControlApp();
await expect(remotely(() => {
const { crashReporter } = require('electron');
crashReporter.start({ submitURL: 'http://127.0.0.1' });
crashReporter.start({ submitURL: 'http://127.0.0.1' });
})).to.be.fulfilled();
});
});
describe('getUploadToServer()', () => {
it('returns true when uploadToServer is set to true (by default)', async () => {
const { remotely } = await startRemoteControlApp();
await remotely(() => { require('electron').crashReporter.start({ submitURL: 'http://127.0.0.1' }); });
const uploadToServer = await remotely(() => require('electron').crashReporter.getUploadToServer());
expect(uploadToServer).to.be.true();
});
it('returns false when uploadToServer is set to false in init', async () => {
const { remotely } = await startRemoteControlApp();
await remotely(() => { require('electron').crashReporter.start({ submitURL: 'http://127.0.0.1', uploadToServer: false }); });
const uploadToServer = await remotely(() => require('electron').crashReporter.getUploadToServer());
expect(uploadToServer).to.be.false();
});
it('is updated by setUploadToServer', async () => {
const { remotely } = await startRemoteControlApp();
await remotely(() => { require('electron').crashReporter.start({ submitURL: 'http://127.0.0.1' }); });
await remotely(() => { require('electron').crashReporter.setUploadToServer(false); });
expect(await remotely(() => require('electron').crashReporter.getUploadToServer())).to.be.false();
await remotely(() => { require('electron').crashReporter.setUploadToServer(true); });
expect(await remotely(() => require('electron').crashReporter.getUploadToServer())).to.be.true();
});
});
describe('when not started', () => {
it('does not prevent process from crashing', async () => {
const appPath = path.join(__dirname, 'fixtures', 'api', 'cookie-app');
await runApp(appPath);
});
});
});

218
spec/api-debugger-spec.ts Normal file
View file

@ -0,0 +1,218 @@
import { expect } from 'chai';
import * as http from 'http';
import * as path from 'path';
import { AddressInfo } from 'net';
import { BrowserWindow } from 'electron/main';
import { closeAllWindows } from './window-helpers';
import { emittedOnce, emittedUntil } from './events-helpers';
describe('debugger module', () => {
const fixtures = path.resolve(__dirname, 'fixtures');
let w: BrowserWindow;
beforeEach(() => {
w = new BrowserWindow({
show: false,
width: 400,
height: 400
});
});
afterEach(closeAllWindows);
describe('debugger.attach', () => {
it('succeeds when devtools is already open', async () => {
await w.webContents.loadURL('about:blank');
w.webContents.openDevTools();
w.webContents.debugger.attach();
expect(w.webContents.debugger.isAttached()).to.be.true();
});
it('fails when protocol version is not supported', done => {
try {
w.webContents.debugger.attach('2.0');
} catch (err) {
expect(w.webContents.debugger.isAttached()).to.be.false();
done();
}
});
it('attaches when no protocol version is specified', async () => {
w.webContents.debugger.attach();
expect(w.webContents.debugger.isAttached()).to.be.true();
});
});
describe('debugger.detach', () => {
it('fires detach event', async () => {
const detach = emittedOnce(w.webContents.debugger, 'detach');
w.webContents.debugger.attach();
w.webContents.debugger.detach();
const [, reason] = await detach;
expect(reason).to.equal('target closed');
expect(w.webContents.debugger.isAttached()).to.be.false();
});
it('doesn\'t disconnect an active devtools session', async () => {
w.webContents.loadURL('about:blank');
const detach = emittedOnce(w.webContents.debugger, 'detach');
w.webContents.debugger.attach();
w.webContents.openDevTools();
w.webContents.once('devtools-opened', () => {
w.webContents.debugger.detach();
});
await detach;
expect(w.webContents.debugger.isAttached()).to.be.false();
expect((w as any).devToolsWebContents.isDestroyed()).to.be.false();
});
});
describe('debugger.sendCommand', () => {
let server: http.Server;
afterEach(() => {
if (server != null) {
server.close();
server = null as any;
}
});
it('returns response', async () => {
w.webContents.loadURL('about:blank');
w.webContents.debugger.attach();
const params = { expression: '4+2' };
const res = await w.webContents.debugger.sendCommand('Runtime.evaluate', params);
expect(res.wasThrown).to.be.undefined();
expect(res.result.value).to.equal(6);
w.webContents.debugger.detach();
});
it('returns response when devtools is opened', async () => {
w.webContents.loadURL('about:blank');
w.webContents.debugger.attach();
const opened = emittedOnce(w.webContents, 'devtools-opened');
w.webContents.openDevTools();
await opened;
const params = { expression: '4+2' };
const res = await w.webContents.debugger.sendCommand('Runtime.evaluate', params);
expect(res.wasThrown).to.be.undefined();
expect(res.result.value).to.equal(6);
w.webContents.debugger.detach();
});
it('fires message event', async () => {
const url = process.platform !== 'win32'
? `file://${path.join(fixtures, 'pages', 'a.html')}`
: `file:///${path.join(fixtures, 'pages', 'a.html').replace(/\\/g, '/')}`;
w.webContents.loadURL(url);
w.webContents.debugger.attach();
const message = emittedUntil(w.webContents.debugger, 'message',
(event: Electron.Event, method: string) => method === 'Console.messageAdded');
w.webContents.debugger.sendCommand('Console.enable');
const [,, params] = await message;
w.webContents.debugger.detach();
expect((params as any).message.level).to.equal('log');
expect((params as any).message.url).to.equal(url);
expect((params as any).message.text).to.equal('a');
});
it('returns error message when command fails', async () => {
w.webContents.loadURL('about:blank');
w.webContents.debugger.attach();
const promise = w.webContents.debugger.sendCommand('Test');
await expect(promise).to.be.eventually.rejectedWith(Error, "'Test' wasn't found");
w.webContents.debugger.detach();
});
it('handles valid unicode characters in message', async () => {
server = http.createServer((req, res) => {
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end('\u0024');
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
w.loadURL(`http://127.0.0.1:${(server.address() as AddressInfo).port}`);
// If we do this synchronously, it's fast enough to attach and enable
// network capture before the load. If we do it before the loadURL, for
// some reason network capture doesn't get enabled soon enough and we get
// an error when calling `Network.getResponseBody`.
w.webContents.debugger.attach();
w.webContents.debugger.sendCommand('Network.enable');
const [,, { requestId }] = await emittedUntil(w.webContents.debugger, 'message', (_event: any, method: string, params: any) =>
method === 'Network.responseReceived' && params.response.url.startsWith('http://127.0.0.1'));
await emittedUntil(w.webContents.debugger, 'message', (_event: any, method: string, params: any) =>
method === 'Network.loadingFinished' && params.requestId === requestId);
const { body } = await w.webContents.debugger.sendCommand('Network.getResponseBody', { requestId });
expect(body).to.equal('\u0024');
});
it('does not crash for invalid unicode characters in message', (done) => {
try {
w.webContents.debugger.attach();
} catch (err) {
done(`unexpected error : ${err}`);
}
w.webContents.debugger.on('message', (event, method) => {
// loadingFinished indicates that page has been loaded and it did not
// crash because of invalid UTF-8 data
if (method === 'Network.loadingFinished') {
done();
}
});
server = http.createServer((req, res) => {
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end('\uFFFF');
});
server.listen(0, '127.0.0.1', () => {
w.webContents.debugger.sendCommand('Network.enable');
w.loadURL(`http://127.0.0.1:${(server.address() as AddressInfo).port}`);
});
});
it('uses empty sessionId by default', async () => {
w.webContents.loadURL('about:blank');
w.webContents.debugger.attach();
const onMessage = emittedOnce(w.webContents.debugger, 'message');
await w.webContents.debugger.sendCommand('Target.setDiscoverTargets', { discover: true });
const [, method, params, sessionId] = await onMessage;
expect(method).to.equal('Target.targetCreated');
expect(params.targetInfo.targetId).to.not.be.empty();
expect(sessionId).to.be.empty();
w.webContents.debugger.detach();
});
it('creates unique session id for each target', (done) => {
w.webContents.loadFile(path.join(__dirname, 'fixtures', 'sub-frames', 'debug-frames.html'));
w.webContents.debugger.attach();
let session: String;
w.webContents.debugger.on('message', (event, ...args) => {
const [method, params, sessionId] = args;
if (method === 'Target.targetCreated') {
w.webContents.debugger.sendCommand('Target.attachToTarget', { targetId: params.targetInfo.targetId, flatten: true }).then(result => {
session = result.sessionId;
w.webContents.debugger.sendCommand('Debugger.enable', {}, result.sessionId);
});
}
if (method === 'Debugger.scriptParsed') {
expect(sessionId).to.equal(session);
w.webContents.debugger.detach();
done();
}
});
w.webContents.debugger.sendCommand('Target.setDiscoverTargets', { discover: true });
});
});
});

View file

@ -0,0 +1,252 @@
import { expect } from 'chai';
import { screen, desktopCapturer, BrowserWindow } from 'electron/main';
import { delay, ifdescribe, ifit } from './spec-helpers';
import { emittedOnce } from './events-helpers';
import { closeAllWindows } from './window-helpers';
const features = process._linkedBinding('electron_common_features');
ifdescribe(!process.arch.includes('arm') && process.platform !== 'win32')('desktopCapturer', () => {
if (!features.isDesktopCapturerEnabled()) {
// This condition can't go the `ifdescribe` call because its inner code
// it still executed, and if the feature is disabled some function calls here fail.
return;
}
let w: BrowserWindow;
before(async () => {
w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
await w.loadURL('about:blank');
});
after(closeAllWindows);
it('should return a non-empty array of sources', async () => {
const sources = await desktopCapturer.getSources({ types: ['window', 'screen'] });
expect(sources).to.be.an('array').that.is.not.empty();
});
it('throws an error for invalid options', async () => {
const promise = desktopCapturer.getSources(['window', 'screen'] as any);
await expect(promise).to.be.eventually.rejectedWith(Error, 'Invalid options');
});
it('does not throw an error when called more than once (regression)', async () => {
const sources1 = await desktopCapturer.getSources({ types: ['window', 'screen'] });
expect(sources1).to.be.an('array').that.is.not.empty();
const sources2 = await desktopCapturer.getSources({ types: ['window', 'screen'] });
expect(sources2).to.be.an('array').that.is.not.empty();
});
it('responds to subsequent calls of different options', async () => {
const promise1 = desktopCapturer.getSources({ types: ['window'] });
await expect(promise1).to.eventually.be.fulfilled();
const promise2 = desktopCapturer.getSources({ types: ['screen'] });
await expect(promise2).to.eventually.be.fulfilled();
});
// Linux doesn't return any window sources.
ifit(process.platform !== 'linux')('returns an empty display_id for window sources', async () => {
const w = new BrowserWindow({ width: 200, height: 200 });
await w.loadURL('about:blank');
const sources = await desktopCapturer.getSources({ types: ['window'] });
w.destroy();
expect(sources).to.be.an('array').that.is.not.empty();
for (const { display_id: displayId } of sources) {
expect(displayId).to.be.a('string').and.be.empty();
}
});
ifit(process.platform !== 'linux')('returns display_ids matching the Screen API', async () => {
const displays = screen.getAllDisplays();
const sources = await desktopCapturer.getSources({ types: ['screen'] });
expect(sources).to.be.an('array').of.length(displays.length);
for (let i = 0; i < sources.length; i++) {
expect(sources[i].display_id).to.equal(displays[i].id.toString());
}
});
it('disabling thumbnail should return empty images', async () => {
const w2 = new BrowserWindow({ show: false, width: 200, height: 200, webPreferences: { contextIsolation: false } });
const wShown = emittedOnce(w2, 'show');
w2.show();
await wShown;
const isEmpties: boolean[] = (await desktopCapturer.getSources({
types: ['window', 'screen'],
thumbnailSize: { width: 0, height: 0 }
})).map(s => s.thumbnail.constructor.name === 'NativeImage' && s.thumbnail.isEmpty());
w2.destroy();
expect(isEmpties).to.be.an('array').that.is.not.empty();
expect(isEmpties.every(e => e === true)).to.be.true();
});
it('getMediaSourceId should match DesktopCapturerSource.id', async () => {
const w = new BrowserWindow({ show: false, width: 100, height: 100, webPreferences: { contextIsolation: false } });
const wShown = emittedOnce(w, 'show');
const wFocused = emittedOnce(w, 'focus');
w.show();
w.focus();
await wShown;
await wFocused;
const mediaSourceId = w.getMediaSourceId();
const sources = await desktopCapturer.getSources({
types: ['window'],
thumbnailSize: { width: 0, height: 0 }
});
w.destroy();
// TODO(julien.isorce): investigate why |sources| is empty on the linux
// bots while it is not on my workstation, as expected, with and without
// the --ci parameter.
if (process.platform === 'linux' && sources.length === 0) {
it.skip('desktopCapturer.getSources returned an empty source list');
return;
}
expect(sources).to.be.an('array').that.is.not.empty();
const foundSource = sources.find((source) => {
return source.id === mediaSourceId;
});
expect(mediaSourceId).to.equal(foundSource!.id);
});
it('getSources should not incorrectly duplicate window_id', async () => {
const w = new BrowserWindow({ show: false, width: 100, height: 100, webPreferences: { contextIsolation: false } });
const wShown = emittedOnce(w, 'show');
const wFocused = emittedOnce(w, 'focus');
w.show();
w.focus();
await wShown;
await wFocused;
// ensure window_id isn't duplicated in getMediaSourceId,
// which uses a different method than getSources
const mediaSourceId = w.getMediaSourceId();
const ids = mediaSourceId.split(':');
expect(ids[1]).to.not.equal(ids[2]);
const sources = await desktopCapturer.getSources({
types: ['window'],
thumbnailSize: { width: 0, height: 0 }
});
w.destroy();
// TODO(julien.isorce): investigate why |sources| is empty on the linux
// bots while it is not on my workstation, as expected, with and without
// the --ci parameter.
if (process.platform === 'linux' && sources.length === 0) {
it.skip('desktopCapturer.getSources returned an empty source list');
return;
}
expect(sources).to.be.an('array').that.is.not.empty();
for (const source of sources) {
const sourceIds = source.id.split(':');
expect(sourceIds[1]).to.not.equal(sourceIds[2]);
}
});
it('moveAbove should move the window at the requested place', async () => {
// DesktopCapturer.getSources() is guaranteed to return in the correct
// z-order from foreground to background.
const MAX_WIN = 4;
const wList: BrowserWindow[] = [];
const destroyWindows = () => {
for (const w of wList) {
w.destroy();
}
};
try {
for (let i = 0; i < MAX_WIN; i++) {
const w = new BrowserWindow({ show: false, width: 100, height: 100 });
wList.push(w);
}
expect(wList.length).to.equal(MAX_WIN);
// Show and focus all the windows.
for (const w of wList) {
const wShown = emittedOnce(w, 'show');
const wFocused = emittedOnce(w, 'focus');
w.show();
w.focus();
await wShown;
await wFocused;
}
// At this point our windows should be showing from bottom to top.
// DesktopCapturer.getSources() returns sources sorted from foreground to
// background, i.e. top to bottom.
let sources = await desktopCapturer.getSources({
types: ['window'],
thumbnailSize: { width: 0, height: 0 }
});
// TODO(julien.isorce): investigate why |sources| is empty on the linux
// bots while it is not on my workstation, as expected, with and without
// the --ci parameter.
if (process.platform === 'linux' && sources.length === 0) {
destroyWindows();
it.skip('desktopCapturer.getSources returned an empty source list');
return;
}
expect(sources).to.be.an('array').that.is.not.empty();
expect(sources.length).to.gte(MAX_WIN);
// Only keep our windows, they must be in the MAX_WIN first windows.
sources.splice(MAX_WIN, sources.length - MAX_WIN);
expect(sources.length).to.equal(MAX_WIN);
expect(sources.length).to.equal(wList.length);
// Check that the sources and wList are sorted in the reverse order.
// If they're not, skip remaining checks because either focus or
// window placement are not reliable in the running test environment.
const wListReversed = wList.slice().reverse();
const proceed = sources.every(
(source, index) => source.id === wListReversed[index].getMediaSourceId());
if (!proceed) return;
// Move windows so wList is sorted from foreground to background.
for (const [i, w] of wList.entries()) {
if (i < wList.length - 1) {
const next = wList[wList.length - 1];
w.focus();
w.moveAbove(next.getMediaSourceId());
// Ensure the window has time to move.
await delay(2000);
}
}
sources = await desktopCapturer.getSources({
types: ['window'],
thumbnailSize: { width: 0, height: 0 }
});
sources.splice(MAX_WIN, sources.length);
expect(sources.length).to.equal(MAX_WIN);
expect(sources.length).to.equal(wList.length);
// Check that the sources and wList are sorted in the same order.
for (const [index, source] of sources.entries()) {
const wID = wList[index].getMediaSourceId();
expect(source.id).to.equal(wID);
}
} finally {
destroyWindows();
}
});
});

211
spec/api-dialog-spec.ts Normal file
View file

@ -0,0 +1,211 @@
import { expect } from 'chai';
import { dialog, BrowserWindow } from 'electron/main';
import { closeAllWindows } from './window-helpers';
import { ifit, delay } from './spec-helpers';
describe('dialog module', () => {
describe('showOpenDialog', () => {
afterEach(closeAllWindows);
ifit(process.platform !== 'win32')('should not throw for valid cases', () => {
expect(() => {
dialog.showOpenDialog({ title: 'i am title' });
}).to.not.throw();
expect(() => {
const w = new BrowserWindow();
dialog.showOpenDialog(w, { title: 'i am title' });
}).to.not.throw();
});
it('throws errors when the options are invalid', () => {
expect(() => {
dialog.showOpenDialog({ properties: false as any });
}).to.throw(/Properties must be an array/);
expect(() => {
dialog.showOpenDialog({ title: 300 as any });
}).to.throw(/Title must be a string/);
expect(() => {
dialog.showOpenDialog({ buttonLabel: [] as any });
}).to.throw(/Button label must be a string/);
expect(() => {
dialog.showOpenDialog({ defaultPath: {} as any });
}).to.throw(/Default path must be a string/);
expect(() => {
dialog.showOpenDialog({ message: {} as any });
}).to.throw(/Message must be a string/);
});
});
describe('showSaveDialog', () => {
afterEach(closeAllWindows);
ifit(process.platform !== 'win32')('should not throw for valid cases', () => {
expect(() => {
dialog.showSaveDialog({ title: 'i am title' });
}).to.not.throw();
expect(() => {
const w = new BrowserWindow();
dialog.showSaveDialog(w, { title: 'i am title' });
}).to.not.throw();
});
it('throws errors when the options are invalid', () => {
expect(() => {
dialog.showSaveDialog({ title: 300 as any });
}).to.throw(/Title must be a string/);
expect(() => {
dialog.showSaveDialog({ buttonLabel: [] as any });
}).to.throw(/Button label must be a string/);
expect(() => {
dialog.showSaveDialog({ defaultPath: {} as any });
}).to.throw(/Default path must be a string/);
expect(() => {
dialog.showSaveDialog({ message: {} as any });
}).to.throw(/Message must be a string/);
expect(() => {
dialog.showSaveDialog({ nameFieldLabel: {} as any });
}).to.throw(/Name field label must be a string/);
});
});
describe('showMessageBox', () => {
afterEach(closeAllWindows);
// parentless message boxes are synchronous on macOS
// dangling message boxes on windows cause a DCHECK: https://cs.chromium.org/chromium/src/base/win/message_window.cc?l=68&rcl=7faa4bf236a866d007dc5672c9ce42660e67a6a6
ifit(process.platform !== 'darwin' && process.platform !== 'win32')('should not throw for a parentless message box', () => {
expect(() => {
dialog.showMessageBox({ message: 'i am message' });
}).to.not.throw();
});
ifit(process.platform !== 'win32')('should not throw for valid cases', () => {
expect(() => {
const w = new BrowserWindow();
dialog.showMessageBox(w, { message: 'i am message' });
}).to.not.throw();
});
it('throws errors when the options are invalid', () => {
expect(() => {
dialog.showMessageBox(undefined as any, { type: 'not-a-valid-type', message: '' });
}).to.throw(/Invalid message box type/);
expect(() => {
dialog.showMessageBox(null as any, { buttons: false as any, message: '' });
}).to.throw(/Buttons must be an array/);
expect(() => {
dialog.showMessageBox({ title: 300 as any, message: '' });
}).to.throw(/Title must be a string/);
expect(() => {
dialog.showMessageBox({ message: [] as any });
}).to.throw(/Message must be a string/);
expect(() => {
dialog.showMessageBox({ detail: 3.14 as any, message: '' });
}).to.throw(/Detail must be a string/);
expect(() => {
dialog.showMessageBox({ checkboxLabel: false as any, message: '' });
}).to.throw(/checkboxLabel must be a string/);
});
});
describe('showMessageBox with signal', () => {
afterEach(closeAllWindows);
it('closes message box immediately', async () => {
const controller = new AbortController();
const signal = controller.signal;
const w = new BrowserWindow();
const p = dialog.showMessageBox(w, { signal, message: 'i am message' });
controller.abort();
const result = await p;
expect(result.response).to.equal(0);
});
it('closes message box after a while', async () => {
const controller = new AbortController();
const signal = controller.signal;
const w = new BrowserWindow();
const p = dialog.showMessageBox(w, { signal, message: 'i am message' });
await delay(500);
controller.abort();
const result = await p;
expect(result.response).to.equal(0);
});
it('cancels message box', async () => {
const controller = new AbortController();
const signal = controller.signal;
const w = new BrowserWindow();
const p = dialog.showMessageBox(w, {
signal,
message: 'i am message',
buttons: ['OK', 'Cancel'],
cancelId: 1
});
controller.abort();
const result = await p;
expect(result.response).to.equal(1);
});
it('cancels message box after a while', async () => {
const controller = new AbortController();
const signal = controller.signal;
const w = new BrowserWindow();
const p = dialog.showMessageBox(w, {
signal,
message: 'i am message',
buttons: ['OK', 'Cancel'],
cancelId: 1
});
await delay(500);
controller.abort();
const result = await p;
expect(result.response).to.equal(1);
});
});
describe('showErrorBox', () => {
it('throws errors when the options are invalid', () => {
expect(() => {
(dialog.showErrorBox as any)();
}).to.throw(/Insufficient number of arguments/);
expect(() => {
dialog.showErrorBox(3 as any, 'four');
}).to.throw(/Error processing argument at index 0/);
expect(() => {
dialog.showErrorBox('three', 4 as any);
}).to.throw(/Error processing argument at index 1/);
});
});
describe('showCertificateTrustDialog', () => {
it('throws errors when the options are invalid', () => {
expect(() => {
(dialog.showCertificateTrustDialog as any)();
}).to.throw(/options must be an object/);
expect(() => {
dialog.showCertificateTrustDialog({} as any);
}).to.throw(/certificate must be an object/);
expect(() => {
dialog.showCertificateTrustDialog({ certificate: {} as any, message: false as any });
}).to.throw(/message must be a string/);
});
});
});

View file

@ -0,0 +1,58 @@
import { expect } from 'chai';
import { globalShortcut } from 'electron/main';
import { ifdescribe } from './spec-helpers';
ifdescribe(process.platform !== 'win32')('globalShortcut module', () => {
beforeEach(() => {
globalShortcut.unregisterAll();
});
it('can register and unregister single accelerators', () => {
const accelerator = 'CmdOrCtrl+A+B+C';
expect(globalShortcut.isRegistered(accelerator)).to.be.false('initially registered');
globalShortcut.register(accelerator, () => {});
expect(globalShortcut.isRegistered(accelerator)).to.be.true('registration worked');
globalShortcut.unregister(accelerator);
expect(globalShortcut.isRegistered(accelerator)).to.be.false('unregistration worked');
globalShortcut.register(accelerator, () => {});
expect(globalShortcut.isRegistered(accelerator)).to.be.true('reregistration worked');
globalShortcut.unregisterAll();
expect(globalShortcut.isRegistered(accelerator)).to.be.false('re-unregistration worked');
});
it('can register and unregister multiple accelerators', () => {
const accelerators = ['CmdOrCtrl+X', 'CmdOrCtrl+Y'];
expect(globalShortcut.isRegistered(accelerators[0])).to.be.false('first initially unregistered');
expect(globalShortcut.isRegistered(accelerators[1])).to.be.false('second initially unregistered');
globalShortcut.registerAll(accelerators, () => {});
expect(globalShortcut.isRegistered(accelerators[0])).to.be.true('first registration worked');
expect(globalShortcut.isRegistered(accelerators[1])).to.be.true('second registration worked');
globalShortcut.unregisterAll();
expect(globalShortcut.isRegistered(accelerators[0])).to.be.false('first unregistered');
expect(globalShortcut.isRegistered(accelerators[1])).to.be.false('second unregistered');
});
it('does not crash when registering media keys as global shortcuts', () => {
const accelerators = [
'VolumeUp',
'VolumeDown',
'VolumeMute',
'MediaNextTrack',
'MediaPreviousTrack',
'MediaStop', 'MediaPlayPause'
];
expect(() => {
globalShortcut.registerAll(accelerators, () => {});
}).to.not.throw();
globalShortcut.unregisterAll();
});
});

View file

@ -0,0 +1,56 @@
import { expect } from 'chai';
import { inAppPurchase } from 'electron/main';
describe('inAppPurchase module', function () {
if (process.platform !== 'darwin') return;
this.timeout(3 * 60 * 1000);
it('canMakePayments() returns a boolean', () => {
const canMakePayments = inAppPurchase.canMakePayments();
expect(canMakePayments).to.be.a('boolean');
});
it('restoreCompletedTransactions() does not throw', () => {
expect(() => {
inAppPurchase.restoreCompletedTransactions();
}).to.not.throw();
});
it('finishAllTransactions() does not throw', () => {
expect(() => {
inAppPurchase.finishAllTransactions();
}).to.not.throw();
});
it('finishTransactionByDate() does not throw', () => {
expect(() => {
inAppPurchase.finishTransactionByDate(new Date().toISOString());
}).to.not.throw();
});
it('getReceiptURL() returns receipt URL', () => {
expect(inAppPurchase.getReceiptURL()).to.match(/_MASReceipt\/receipt$/);
});
// The following three tests are disabled because they hit Apple servers, and
// Apple started blocking requests from AWS IPs (we think), so they fail on CI.
// TODO: find a way to mock out the server requests so we can test these APIs
// without relying on a remote service.
xdescribe('handles product purchases', () => {
it('purchaseProduct() fails when buying invalid product', async () => {
const success = await inAppPurchase.purchaseProduct('non-exist', 1);
expect(success).to.be.false('failed to purchase non-existent product');
});
it('purchaseProduct() accepts optional arguments', async () => {
const success = await inAppPurchase.purchaseProduct('non-exist');
expect(success).to.be.false('failed to purchase non-existent product');
});
it('getProducts() returns an empty list when getting invalid product', async () => {
const products = await inAppPurchase.getProducts(['non-exist']);
expect(products).to.be.an('array').of.length(0);
});
});
});

92
spec/api-ipc-main-spec.ts Normal file
View file

@ -0,0 +1,92 @@
import { expect } from 'chai';
import * as path from 'path';
import * as cp from 'child_process';
import { closeAllWindows } from './window-helpers';
import { emittedOnce } from './events-helpers';
import { defer } from './spec-helpers';
import { ipcMain, BrowserWindow } from 'electron/main';
describe('ipc main module', () => {
const fixtures = path.join(__dirname, 'fixtures');
afterEach(closeAllWindows);
describe('ipc.sendSync', () => {
afterEach(() => { ipcMain.removeAllListeners('send-sync-message'); });
it('does not crash when reply is not sent and browser is destroyed', (done) => {
const w = new BrowserWindow({
show: false,
webPreferences: {
nodeIntegration: true,
contextIsolation: false
}
});
ipcMain.once('send-sync-message', (event) => {
event.returnValue = null;
done();
});
w.loadFile(path.join(fixtures, 'api', 'send-sync-message.html'));
});
it('does not crash when reply is sent by multiple listeners', (done) => {
const w = new BrowserWindow({
show: false,
webPreferences: {
nodeIntegration: true,
contextIsolation: false
}
});
ipcMain.on('send-sync-message', (event) => {
event.returnValue = null;
});
ipcMain.on('send-sync-message', (event) => {
event.returnValue = null;
done();
});
w.loadFile(path.join(fixtures, 'api', 'send-sync-message.html'));
});
});
describe('ipcMain.on', () => {
it('is not used for internals', async () => {
const appPath = path.join(fixtures, 'api', 'ipc-main-listeners');
const electronPath = process.execPath;
const appProcess = cp.spawn(electronPath, [appPath]);
let output = '';
appProcess.stdout.on('data', (data) => { output += data; });
await emittedOnce(appProcess.stdout, 'end');
output = JSON.parse(output);
expect(output).to.deep.equal(['error']);
});
it('can be replied to', async () => {
ipcMain.on('test-echo', (e, arg) => {
e.reply('test-echo', arg);
});
defer(() => {
ipcMain.removeAllListeners('test-echo');
});
const w = new BrowserWindow({
show: false,
webPreferences: {
nodeIntegration: true,
contextIsolation: false
}
});
w.loadURL('about:blank');
const v = await w.webContents.executeJavaScript(`new Promise((resolve, reject) => {
const { ipcRenderer } = require('electron')
ipcRenderer.send('test-echo', 'hello')
ipcRenderer.on('test-echo', (e, v) => {
resolve(v)
})
})`);
expect(v).to.equal('hello');
});
});
});

View file

@ -0,0 +1,205 @@
import { expect } from 'chai';
import * as path from 'path';
import { ipcMain, BrowserWindow, WebContents, WebPreferences, webContents } from 'electron/main';
import { emittedOnce } from './events-helpers';
import { closeWindow } from './window-helpers';
describe('ipcRenderer module', () => {
const fixtures = path.join(__dirname, 'fixtures');
let w: BrowserWindow;
before(async () => {
w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
await w.loadURL('about:blank');
});
after(async () => {
await closeWindow(w);
w = null as unknown as BrowserWindow;
});
describe('send()', () => {
it('should work when sending an object containing id property', async () => {
const obj = {
id: 1,
name: 'ly'
};
w.webContents.executeJavaScript(`{
const { ipcRenderer } = require('electron')
ipcRenderer.send('message', ${JSON.stringify(obj)})
}`);
const [, received] = await emittedOnce(ipcMain, 'message');
expect(received).to.deep.equal(obj);
});
it('can send instances of Date as Dates', async () => {
const isoDate = new Date().toISOString();
w.webContents.executeJavaScript(`{
const { ipcRenderer } = require('electron')
ipcRenderer.send('message', new Date(${JSON.stringify(isoDate)}))
}`);
const [, received] = await emittedOnce(ipcMain, 'message');
expect(received.toISOString()).to.equal(isoDate);
});
it('can send instances of Buffer', async () => {
const data = 'hello';
w.webContents.executeJavaScript(`{
const { ipcRenderer } = require('electron')
ipcRenderer.send('message', Buffer.from(${JSON.stringify(data)}))
}`);
const [, received] = await emittedOnce(ipcMain, 'message');
expect(received).to.be.an.instanceOf(Uint8Array);
expect(Buffer.from(data).equals(received)).to.be.true();
});
it('throws when sending objects with DOM class prototypes', async () => {
await expect(w.webContents.executeJavaScript(`{
const { ipcRenderer } = require('electron')
ipcRenderer.send('message', document.location)
}`)).to.eventually.be.rejected();
});
it('does not crash when sending external objects', async () => {
await expect(w.webContents.executeJavaScript(`{
const { ipcRenderer } = require('electron')
const http = require('http')
const request = http.request({ port: 5000, hostname: '127.0.0.1', method: 'GET', path: '/' })
const stream = request.agent.sockets['127.0.0.1:5000:'][0]._handle._externalStream
ipcRenderer.send('message', stream)
}`)).to.eventually.be.rejected();
});
it('can send objects that both reference the same object', async () => {
w.webContents.executeJavaScript(`{
const { ipcRenderer } = require('electron')
const child = { hello: 'world' }
const foo = { name: 'foo', child: child }
const bar = { name: 'bar', child: child }
const array = [foo, bar]
ipcRenderer.send('message', array, foo, bar, child)
}`);
const child = { hello: 'world' };
const foo = { name: 'foo', child: child };
const bar = { name: 'bar', child: child };
const array = [foo, bar];
const [, arrayValue, fooValue, barValue, childValue] = await emittedOnce(ipcMain, 'message');
expect(arrayValue).to.deep.equal(array);
expect(fooValue).to.deep.equal(foo);
expect(barValue).to.deep.equal(bar);
expect(childValue).to.deep.equal(child);
});
it('can handle cyclic references', async () => {
w.webContents.executeJavaScript(`{
const { ipcRenderer } = require('electron')
const array = [5]
array.push(array)
const child = { hello: 'world' }
child.child = child
ipcRenderer.send('message', array, child)
}`);
const [, arrayValue, childValue] = await emittedOnce(ipcMain, 'message');
expect(arrayValue[0]).to.equal(5);
expect(arrayValue[1]).to.equal(arrayValue);
expect(childValue.hello).to.equal('world');
expect(childValue.child).to.equal(childValue);
});
});
describe('sendSync()', () => {
it('can be replied to by setting event.returnValue', async () => {
ipcMain.once('echo', (event, msg) => {
event.returnValue = msg;
});
const msg = await w.webContents.executeJavaScript(`new Promise(resolve => {
const { ipcRenderer } = require('electron')
resolve(ipcRenderer.sendSync('echo', 'test'))
})`);
expect(msg).to.equal('test');
});
});
describe('sendTo()', () => {
const generateSpecs = (description: string, webPreferences: WebPreferences) => {
describe(description, () => {
let contents: WebContents;
const payload = 'Hello World!';
before(async () => {
contents = (webContents as any).create({
preload: path.join(fixtures, 'module', 'preload-ipc-ping-pong.js'),
...webPreferences
});
await contents.loadURL('about:blank');
});
after(() => {
(contents as any).destroy();
contents = null as unknown as WebContents;
});
it('sends message to WebContents', async () => {
const data = await w.webContents.executeJavaScript(`new Promise(resolve => {
const { ipcRenderer } = require('electron')
ipcRenderer.sendTo(${contents.id}, 'ping', ${JSON.stringify(payload)})
ipcRenderer.once('pong', (event, data) => resolve(data))
})`);
expect(data).to.equal(payload);
});
it('sends message on channel with non-ASCII characters to WebContents', async () => {
const data = await w.webContents.executeJavaScript(`new Promise(resolve => {
const { ipcRenderer } = require('electron')
ipcRenderer.sendTo(${contents.id}, 'ping-æøåü', ${JSON.stringify(payload)})
ipcRenderer.once('pong-æøåü', (event, data) => resolve(data))
})`);
expect(data).to.equal(payload);
});
});
};
generateSpecs('without sandbox', {});
generateSpecs('with sandbox', { sandbox: true });
generateSpecs('with contextIsolation', { contextIsolation: true });
generateSpecs('with contextIsolation + sandbox', { contextIsolation: true, sandbox: true });
});
describe('ipcRenderer.on', () => {
it('is not used for internals', async () => {
const result = await w.webContents.executeJavaScript(`
require('electron').ipcRenderer.eventNames()
`);
expect(result).to.deep.equal([]);
});
});
describe('after context is released', () => {
it('throws an exception', async () => {
const error = await w.webContents.executeJavaScript(`(${() => {
const child = window.open('', 'child', 'show=no,nodeIntegration=yes')! as any;
const childIpc = child.require('electron').ipcRenderer;
child.close();
return new Promise(resolve => {
setInterval(() => {
try {
childIpc.send('hello');
} catch (e) {
resolve(e);
}
}, 16);
});
}})()`);
expect(error).to.have.property('message', 'IPC method called after context was released');
});
});
});

762
spec/api-ipc-spec.ts Normal file
View file

@ -0,0 +1,762 @@
import { EventEmitter } from 'events';
import { expect } from 'chai';
import { BrowserWindow, ipcMain, IpcMainInvokeEvent, MessageChannelMain, WebContents } from 'electron/main';
import { closeAllWindows } from './window-helpers';
import { emittedOnce } from './events-helpers';
import { defer } from './spec-helpers';
import * as path from 'path';
import * as http from 'http';
import { AddressInfo } from 'net';
const v8Util = process._linkedBinding('electron_common_v8_util');
const fixturesPath = path.resolve(__dirname, 'fixtures');
describe('ipc module', () => {
describe('invoke', () => {
let w = (null as unknown as BrowserWindow);
before(async () => {
w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
await w.loadURL('about:blank');
});
after(async () => {
w.destroy();
});
async function rendererInvoke (...args: any[]) {
const { ipcRenderer } = require('electron');
try {
const result = await ipcRenderer.invoke('test', ...args);
ipcRenderer.send('result', { result });
} catch (e) {
ipcRenderer.send('result', { error: (e as Error).message });
}
}
it('receives a response from a synchronous handler', async () => {
ipcMain.handleOnce('test', (e: IpcMainInvokeEvent, arg: number) => {
expect(arg).to.equal(123);
return 3;
});
const done = new Promise<void>(resolve => ipcMain.once('result', (e, arg) => {
expect(arg).to.deep.equal({ result: 3 });
resolve();
}));
await w.webContents.executeJavaScript(`(${rendererInvoke})(123)`);
await done;
});
it('receives a response from an asynchronous handler', async () => {
ipcMain.handleOnce('test', async (e: IpcMainInvokeEvent, arg: number) => {
expect(arg).to.equal(123);
await new Promise(setImmediate);
return 3;
});
const done = new Promise<void>(resolve => ipcMain.once('result', (e, arg) => {
expect(arg).to.deep.equal({ result: 3 });
resolve();
}));
await w.webContents.executeJavaScript(`(${rendererInvoke})(123)`);
await done;
});
it('receives an error from a synchronous handler', async () => {
ipcMain.handleOnce('test', () => {
throw new Error('some error');
});
const done = new Promise<void>(resolve => ipcMain.once('result', (e, arg) => {
expect(arg.error).to.match(/some error/);
resolve();
}));
await w.webContents.executeJavaScript(`(${rendererInvoke})()`);
await done;
});
it('receives an error from an asynchronous handler', async () => {
ipcMain.handleOnce('test', async () => {
await new Promise(setImmediate);
throw new Error('some error');
});
const done = new Promise<void>(resolve => ipcMain.once('result', (e, arg) => {
expect(arg.error).to.match(/some error/);
resolve();
}));
await w.webContents.executeJavaScript(`(${rendererInvoke})()`);
await done;
});
it('throws an error if no handler is registered', async () => {
const done = new Promise<void>(resolve => ipcMain.once('result', (e, arg) => {
expect(arg.error).to.match(/No handler registered/);
resolve();
}));
await w.webContents.executeJavaScript(`(${rendererInvoke})()`);
await done;
});
it('throws an error when invoking a handler that was removed', async () => {
ipcMain.handle('test', () => { });
ipcMain.removeHandler('test');
const done = new Promise<void>(resolve => ipcMain.once('result', (e, arg) => {
expect(arg.error).to.match(/No handler registered/);
resolve();
}));
await w.webContents.executeJavaScript(`(${rendererInvoke})()`);
await done;
});
it('forbids multiple handlers', async () => {
ipcMain.handle('test', () => { });
try {
expect(() => { ipcMain.handle('test', () => { }); }).to.throw(/second handler/);
} finally {
ipcMain.removeHandler('test');
}
});
it('throws an error in the renderer if the reply callback is dropped', async () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
ipcMain.handleOnce('test', () => new Promise(resolve => {
setTimeout(() => v8Util.requestGarbageCollectionForTesting());
/* never resolve */
}));
w.webContents.executeJavaScript(`(${rendererInvoke})()`);
const [, { error }] = await emittedOnce(ipcMain, 'result');
expect(error).to.match(/reply was never sent/);
});
});
describe('ordering', () => {
let w = (null as unknown as BrowserWindow);
before(async () => {
w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
await w.loadURL('about:blank');
});
after(async () => {
w.destroy();
});
it('between send and sendSync is consistent', async () => {
const received: number[] = [];
ipcMain.on('test-async', (e, i) => { received.push(i); });
ipcMain.on('test-sync', (e, i) => { received.push(i); e.returnValue = null; });
const done = new Promise<void>(resolve => ipcMain.once('done', () => { resolve(); }));
function rendererStressTest () {
const { ipcRenderer } = require('electron');
for (let i = 0; i < 1000; i++) {
switch ((Math.random() * 2) | 0) {
case 0:
ipcRenderer.send('test-async', i);
break;
case 1:
ipcRenderer.sendSync('test-sync', i);
break;
}
}
ipcRenderer.send('done');
}
try {
w.webContents.executeJavaScript(`(${rendererStressTest})()`);
await done;
} finally {
ipcMain.removeAllListeners('test-async');
ipcMain.removeAllListeners('test-sync');
}
expect(received).to.have.lengthOf(1000);
expect(received).to.deep.equal([...received].sort((a, b) => a - b));
});
it('between send, sendSync, and invoke is consistent', async () => {
const received: number[] = [];
ipcMain.handle('test-invoke', (e, i) => { received.push(i); });
ipcMain.on('test-async', (e, i) => { received.push(i); });
ipcMain.on('test-sync', (e, i) => { received.push(i); e.returnValue = null; });
const done = new Promise<void>(resolve => ipcMain.once('done', () => { resolve(); }));
function rendererStressTest () {
const { ipcRenderer } = require('electron');
for (let i = 0; i < 1000; i++) {
switch ((Math.random() * 3) | 0) {
case 0:
ipcRenderer.send('test-async', i);
break;
case 1:
ipcRenderer.sendSync('test-sync', i);
break;
case 2:
ipcRenderer.invoke('test-invoke', i);
break;
}
}
ipcRenderer.send('done');
}
try {
w.webContents.executeJavaScript(`(${rendererStressTest})()`);
await done;
} finally {
ipcMain.removeHandler('test-invoke');
ipcMain.removeAllListeners('test-async');
ipcMain.removeAllListeners('test-sync');
}
expect(received).to.have.lengthOf(1000);
expect(received).to.deep.equal([...received].sort((a, b) => a - b));
});
});
describe('MessagePort', () => {
afterEach(closeAllWindows);
it('can send a port to the main process', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
const p = emittedOnce(ipcMain, 'port');
await w.webContents.executeJavaScript(`(${function () {
const channel = new MessageChannel();
require('electron').ipcRenderer.postMessage('port', 'hi', [channel.port1]);
}})()`);
const [ev, msg] = await p;
expect(msg).to.equal('hi');
expect(ev.ports).to.have.length(1);
expect(ev.senderFrame.parent).to.be.null();
expect(ev.senderFrame.routingId).to.equal(w.webContents.mainFrame.routingId);
const [port] = ev.ports;
expect(port).to.be.an.instanceOf(EventEmitter);
});
it('can sent a message without a transfer', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
const p = emittedOnce(ipcMain, 'port');
await w.webContents.executeJavaScript(`(${function () {
require('electron').ipcRenderer.postMessage('port', 'hi');
}})()`);
const [ev, msg] = await p;
expect(msg).to.equal('hi');
expect(ev.ports).to.deep.equal([]);
expect(ev.senderFrame.routingId).to.equal(w.webContents.mainFrame.routingId);
});
it('can communicate between main and renderer', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
const p = emittedOnce(ipcMain, 'port');
await w.webContents.executeJavaScript(`(${function () {
const channel = new MessageChannel();
(channel.port2 as any).onmessage = (ev: any) => {
channel.port2.postMessage(ev.data * 2);
};
require('electron').ipcRenderer.postMessage('port', '', [channel.port1]);
}})()`);
const [ev] = await p;
expect(ev.ports).to.have.length(1);
expect(ev.senderFrame.routingId).to.equal(w.webContents.mainFrame.routingId);
const [port] = ev.ports;
port.start();
port.postMessage(42);
const [ev2] = await emittedOnce(port, 'message');
expect(ev2.data).to.equal(84);
});
it('can receive a port from a renderer over a MessagePort connection', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
function fn () {
const channel1 = new MessageChannel();
const channel2 = new MessageChannel();
channel1.port2.postMessage('', [channel2.port1]);
channel2.port2.postMessage('matryoshka');
require('electron').ipcRenderer.postMessage('port', '', [channel1.port1]);
}
w.webContents.executeJavaScript(`(${fn})()`);
const [{ ports: [port1] }] = await emittedOnce(ipcMain, 'port');
port1.start();
const [{ ports: [port2] }] = await emittedOnce(port1, 'message');
port2.start();
const [{ data }] = await emittedOnce(port2, 'message');
expect(data).to.equal('matryoshka');
});
it('can forward a port from one renderer to another renderer', async () => {
const w1 = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
const w2 = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w1.loadURL('about:blank');
w2.loadURL('about:blank');
w1.webContents.executeJavaScript(`(${function () {
const channel = new MessageChannel();
(channel.port2 as any).onmessage = (ev: any) => {
require('electron').ipcRenderer.send('message received', ev.data);
};
require('electron').ipcRenderer.postMessage('port', '', [channel.port1]);
}})()`);
const [{ ports: [port] }] = await emittedOnce(ipcMain, 'port');
await w2.webContents.executeJavaScript(`(${function () {
require('electron').ipcRenderer.on('port', ({ ports: [port] }: any) => {
port.postMessage('a message');
});
}})()`);
w2.webContents.postMessage('port', '', [port]);
const [, data] = await emittedOnce(ipcMain, 'message received');
expect(data).to.equal('a message');
});
describe('close event', () => {
describe('in renderer', () => {
it('is emitted when the main process closes its end of the port', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
await w.webContents.executeJavaScript(`(${function () {
const { ipcRenderer } = require('electron');
ipcRenderer.on('port', e => {
const [port] = e.ports;
port.start();
(port as any).onclose = () => {
ipcRenderer.send('closed');
};
});
}})()`);
const { port1, port2 } = new MessageChannelMain();
w.webContents.postMessage('port', null, [port2]);
port1.close();
await emittedOnce(ipcMain, 'closed');
});
it('is emitted when the other end of a port is garbage-collected', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
await w.webContents.executeJavaScript(`(${async function () {
const { port2 } = new MessageChannel();
await new Promise(resolve => {
port2.start();
(port2 as any).onclose = resolve;
process._linkedBinding('electron_common_v8_util').requestGarbageCollectionForTesting();
});
}})()`);
});
it('is emitted when the other end of a port is sent to nowhere', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
ipcMain.once('do-a-gc', () => v8Util.requestGarbageCollectionForTesting());
await w.webContents.executeJavaScript(`(${async function () {
const { port1, port2 } = new MessageChannel();
await new Promise(resolve => {
port2.start();
(port2 as any).onclose = resolve;
require('electron').ipcRenderer.postMessage('nobody-listening', null, [port1]);
require('electron').ipcRenderer.send('do-a-gc');
});
}})()`);
});
});
});
describe('MessageChannelMain', () => {
it('can be created', () => {
const { port1, port2 } = new MessageChannelMain();
expect(port1).not.to.be.null();
expect(port2).not.to.be.null();
});
it('can send messages within the process', async () => {
const { port1, port2 } = new MessageChannelMain();
port2.postMessage('hello');
port1.start();
const [ev] = await emittedOnce(port1, 'message');
expect(ev.data).to.equal('hello');
});
it('can pass one end to a WebContents', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
await w.webContents.executeJavaScript(`(${function () {
const { ipcRenderer } = require('electron');
ipcRenderer.on('port', ev => {
const [port] = ev.ports;
port.onmessage = () => {
ipcRenderer.send('done');
};
});
}})()`);
const { port1, port2 } = new MessageChannelMain();
port1.postMessage('hello');
w.webContents.postMessage('port', null, [port2]);
await emittedOnce(ipcMain, 'done');
});
it('can be passed over another channel', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
await w.webContents.executeJavaScript(`(${function () {
const { ipcRenderer } = require('electron');
ipcRenderer.on('port', e1 => {
e1.ports[0].onmessage = e2 => {
e2.ports[0].onmessage = e3 => {
ipcRenderer.send('done', e3.data);
};
};
});
}})()`);
const { port1, port2 } = new MessageChannelMain();
const { port1: port3, port2: port4 } = new MessageChannelMain();
port1.postMessage(null, [port4]);
port3.postMessage('hello');
w.webContents.postMessage('port', null, [port2]);
const [, message] = await emittedOnce(ipcMain, 'done');
expect(message).to.equal('hello');
});
it('can send messages to a closed port', () => {
const { port1, port2 } = new MessageChannelMain();
port2.start();
port2.on('message', () => { throw new Error('unexpected message received'); });
port1.close();
port1.postMessage('hello');
});
it('can send messages to a port whose remote end is closed', () => {
const { port1, port2 } = new MessageChannelMain();
port2.start();
port2.on('message', () => { throw new Error('unexpected message received'); });
port2.close();
port1.postMessage('hello');
});
it('throws when passing null ports', () => {
const { port1 } = new MessageChannelMain();
expect(() => {
port1.postMessage(null, [null] as any);
}).to.throw(/conversion failure/);
});
it('throws when passing duplicate ports', () => {
const { port1 } = new MessageChannelMain();
const { port1: port3 } = new MessageChannelMain();
expect(() => {
port1.postMessage(null, [port3, port3]);
}).to.throw(/duplicate/);
});
it('throws when passing ports that have already been neutered', () => {
const { port1 } = new MessageChannelMain();
const { port1: port3 } = new MessageChannelMain();
port1.postMessage(null, [port3]);
expect(() => {
port1.postMessage(null, [port3]);
}).to.throw(/already neutered/);
});
it('throws when passing itself', () => {
const { port1 } = new MessageChannelMain();
expect(() => {
port1.postMessage(null, [port1]);
}).to.throw(/contains the source port/);
});
describe('GC behavior', () => {
it('is not collected while it could still receive messages', async () => {
let trigger: Function;
const promise = new Promise(resolve => { trigger = resolve; });
const port1 = (() => {
const { port1, port2 } = new MessageChannelMain();
port2.on('message', (e) => { trigger(e.data); });
port2.start();
return port1;
})();
v8Util.requestGarbageCollectionForTesting();
port1.postMessage('hello');
expect(await promise).to.equal('hello');
});
});
});
const generateTests = (title: string, postMessage: (contents: WebContents) => WebContents['postMessage']) => {
describe(title, () => {
it('sends a message', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
await w.webContents.executeJavaScript(`(${function () {
const { ipcRenderer } = require('electron');
ipcRenderer.on('foo', (_e, msg) => {
ipcRenderer.send('bar', msg);
});
}})()`);
postMessage(w.webContents)('foo', { some: 'message' });
const [, msg] = await emittedOnce(ipcMain, 'bar');
expect(msg).to.deep.equal({ some: 'message' });
});
describe('error handling', () => {
it('throws on missing channel', async () => {
const w = new BrowserWindow({ show: false });
await w.loadURL('about:blank');
expect(() => {
(postMessage(w.webContents) as any)();
}).to.throw(/Insufficient number of arguments/);
});
it('throws on invalid channel', async () => {
const w = new BrowserWindow({ show: false });
await w.loadURL('about:blank');
expect(() => {
postMessage(w.webContents)(null as any, '', []);
}).to.throw(/Error processing argument at index 0/);
});
it('throws on missing message', async () => {
const w = new BrowserWindow({ show: false });
await w.loadURL('about:blank');
expect(() => {
(postMessage(w.webContents) as any)('channel');
}).to.throw(/Insufficient number of arguments/);
});
it('throws on non-serializable message', async () => {
const w = new BrowserWindow({ show: false });
await w.loadURL('about:blank');
expect(() => {
postMessage(w.webContents)('channel', w);
}).to.throw(/An object could not be cloned/);
});
it('throws on invalid transferable list', async () => {
const w = new BrowserWindow({ show: false });
await w.loadURL('about:blank');
expect(() => {
postMessage(w.webContents)('', '', null as any);
}).to.throw(/Invalid value for transfer/);
});
it('throws on transferring non-transferable', async () => {
const w = new BrowserWindow({ show: false });
await w.loadURL('about:blank');
expect(() => {
(postMessage(w.webContents) as any)('channel', '', [123]);
}).to.throw(/Invalid value for transfer/);
});
it('throws when passing null ports', async () => {
const w = new BrowserWindow({ show: false });
await w.loadURL('about:blank');
expect(() => {
postMessage(w.webContents)('foo', null, [null] as any);
}).to.throw(/Invalid value for transfer/);
});
it('throws when passing duplicate ports', async () => {
const w = new BrowserWindow({ show: false });
await w.loadURL('about:blank');
const { port1 } = new MessageChannelMain();
expect(() => {
postMessage(w.webContents)('foo', null, [port1, port1]);
}).to.throw(/duplicate/);
});
it('throws when passing ports that have already been neutered', async () => {
const w = new BrowserWindow({ show: false });
await w.loadURL('about:blank');
const { port1 } = new MessageChannelMain();
postMessage(w.webContents)('foo', null, [port1]);
expect(() => {
postMessage(w.webContents)('foo', null, [port1]);
}).to.throw(/already neutered/);
});
});
});
};
generateTests('WebContents.postMessage', contents => contents.postMessage.bind(contents));
generateTests('WebFrameMain.postMessage', contents => contents.mainFrame.postMessage.bind(contents.mainFrame));
});
describe('WebContents.ipc', () => {
afterEach(closeAllWindows);
it('receives ipc messages sent from the WebContents', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.send(\'test\', 42)');
const [, num] = await emittedOnce(w.webContents.ipc, 'test');
expect(num).to.equal(42);
});
it('receives sync-ipc messages sent from the WebContents', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
w.webContents.ipc.on('test', (event, arg) => {
event.returnValue = arg * 2;
});
const result = await w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.sendSync(\'test\', 42)');
expect(result).to.equal(42 * 2);
});
it('receives postMessage messages sent from the WebContents, w/ MessagePorts', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.postMessage(\'test\', null, [(new MessageChannel).port1])');
const [event] = await emittedOnce(w.webContents.ipc, 'test');
expect(event.ports.length).to.equal(1);
});
it('handles invoke messages sent from the WebContents', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
w.webContents.ipc.handle('test', (_event, arg) => arg * 2);
const result = await w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.invoke(\'test\', 42)');
expect(result).to.equal(42 * 2);
});
it('cascades to ipcMain', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
let gotFromIpcMain = false;
const ipcMainReceived = new Promise<void>(resolve => ipcMain.on('test', () => { gotFromIpcMain = true; resolve(); }));
const ipcReceived = new Promise<boolean>(resolve => w.webContents.ipc.on('test', () => { resolve(gotFromIpcMain); }));
defer(() => ipcMain.removeAllListeners('test'));
w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.send(\'test\', 42)');
// assert that they are delivered in the correct order
expect(await ipcReceived).to.be.false();
await ipcMainReceived;
});
it('overrides ipcMain handlers', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
w.webContents.ipc.handle('test', (_event, arg) => arg * 2);
ipcMain.handle('test', () => { throw new Error('should not be called'); });
defer(() => ipcMain.removeHandler('test'));
const result = await w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.invoke(\'test\', 42)');
expect(result).to.equal(42 * 2);
});
it('falls back to ipcMain handlers', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
ipcMain.handle('test', (_event, arg) => { return arg * 2; });
defer(() => ipcMain.removeHandler('test'));
const result = await w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.invoke(\'test\', 42)');
expect(result).to.equal(42 * 2);
});
it('receives ipcs from child frames', async () => {
const server = http.createServer((req, res) => {
res.setHeader('content-type', 'text/html');
res.end('');
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const port = (server.address() as AddressInfo).port;
defer(() => {
server.close();
});
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegrationInSubFrames: true, preload: path.resolve(fixturesPath, 'preload-expose-ipc.js') } });
// Preloads don't run in about:blank windows, and file:// urls can't be loaded in iframes, so use a blank http page.
await w.loadURL(`data:text/html,<iframe src="http://localhost:${port}"></iframe>`);
w.webContents.mainFrame.frames[0].executeJavaScript('ipc.send(\'test\', 42)');
const [, arg] = await emittedOnce(w.webContents.ipc, 'test');
expect(arg).to.equal(42);
});
});
describe('WebFrameMain.ipc', () => {
afterEach(closeAllWindows);
it('responds to ipc messages in the main frame', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.send(\'test\', 42)');
const [, arg] = await emittedOnce(w.webContents.mainFrame.ipc, 'test');
expect(arg).to.equal(42);
});
it('responds to sync ipc messages in the main frame', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
w.webContents.mainFrame.ipc.on('test', (event, arg) => {
event.returnValue = arg * 2;
});
const result = await w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.sendSync(\'test\', 42)');
expect(result).to.equal(42 * 2);
});
it('receives postMessage messages sent from the WebContents, w/ MessagePorts', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.postMessage(\'test\', null, [(new MessageChannel).port1])');
const [event] = await emittedOnce(w.webContents.mainFrame.ipc, 'test');
expect(event.ports.length).to.equal(1);
});
it('handles invoke messages sent from the WebContents', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
w.webContents.mainFrame.ipc.handle('test', (_event, arg) => arg * 2);
const result = await w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.invoke(\'test\', 42)');
expect(result).to.equal(42 * 2);
});
it('cascades to WebContents and ipcMain', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
let gotFromIpcMain = false;
let gotFromWebContents = false;
const ipcMainReceived = new Promise<void>(resolve => ipcMain.on('test', () => { gotFromIpcMain = true; resolve(); }));
const ipcWebContentsReceived = new Promise<boolean>(resolve => w.webContents.ipc.on('test', () => { gotFromWebContents = true; resolve(gotFromIpcMain); }));
const ipcReceived = new Promise<boolean>(resolve => w.webContents.mainFrame.ipc.on('test', () => { resolve(gotFromWebContents); }));
defer(() => ipcMain.removeAllListeners('test'));
w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.send(\'test\', 42)');
// assert that they are delivered in the correct order
expect(await ipcReceived).to.be.false();
expect(await ipcWebContentsReceived).to.be.false();
await ipcMainReceived;
});
it('overrides ipcMain handlers', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
w.webContents.mainFrame.ipc.handle('test', (_event, arg) => arg * 2);
ipcMain.handle('test', () => { throw new Error('should not be called'); });
defer(() => ipcMain.removeHandler('test'));
const result = await w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.invoke(\'test\', 42)');
expect(result).to.equal(42 * 2);
});
it('overrides WebContents handlers', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
w.webContents.ipc.handle('test', () => { throw new Error('should not be called'); });
w.webContents.mainFrame.ipc.handle('test', (_event, arg) => arg * 2);
ipcMain.handle('test', () => { throw new Error('should not be called'); });
defer(() => ipcMain.removeHandler('test'));
const result = await w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.invoke(\'test\', 42)');
expect(result).to.equal(42 * 2);
});
it('falls back to WebContents handlers', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
w.webContents.ipc.handle('test', (_event, arg) => { return arg * 2; });
const result = await w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.invoke(\'test\', 42)');
expect(result).to.equal(42 * 2);
});
it('receives ipcs from child frames', async () => {
const server = http.createServer((req, res) => {
res.setHeader('content-type', 'text/html');
res.end('');
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const port = (server.address() as AddressInfo).port;
defer(() => {
server.close();
});
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegrationInSubFrames: true, preload: path.resolve(fixturesPath, 'preload-expose-ipc.js') } });
// Preloads don't run in about:blank windows, and file:// urls can't be loaded in iframes, so use a blank http page.
await w.loadURL(`data:text/html,<iframe src="http://localhost:${port}"></iframe>`);
w.webContents.mainFrame.frames[0].executeJavaScript('ipc.send(\'test\', 42)');
w.webContents.mainFrame.ipc.on('test', () => { throw new Error('should not be called'); });
const [, arg] = await emittedOnce(w.webContents.mainFrame.frames[0].ipc, 'test');
expect(arg).to.equal(42);
});
});
});

516
spec/api-menu-item-spec.ts Normal file
View file

@ -0,0 +1,516 @@
import { BrowserWindow, app, Menu, MenuItem, MenuItemConstructorOptions } from 'electron/main';
import { expect } from 'chai';
import { closeAllWindows } from './window-helpers';
const { roleList, execute } = require('../lib/browser/api/menu-item-roles');
describe('MenuItems', () => {
describe('MenuItem instance properties', () => {
it('should have default MenuItem properties', () => {
const item = new MenuItem({
id: '1',
label: 'hello',
role: 'close',
sublabel: 'goodbye',
accelerator: 'CmdOrControl+Q',
click: () => { },
enabled: true,
visible: true,
checked: false,
type: 'normal',
registerAccelerator: true,
submenu: [{ role: 'about' }]
});
expect(item).to.have.property('id').that.is.a('string');
expect(item).to.have.property('label').that.is.a('string').equal('hello');
expect(item).to.have.property('sublabel').that.is.a('string').equal('goodbye');
expect(item).to.have.property('accelerator').that.is.a('string').equal('CmdOrControl+Q');
expect(item).to.have.property('click').that.is.a('function');
expect(item).to.have.property('enabled').that.is.a('boolean').and.is.true('item is enabled');
expect(item).to.have.property('visible').that.is.a('boolean').and.is.true('item is visible');
expect(item).to.have.property('checked').that.is.a('boolean').and.is.false('item is not checked');
expect(item).to.have.property('registerAccelerator').that.is.a('boolean').and.is.true('item can register accelerator');
expect(item).to.have.property('type').that.is.a('string').equal('normal');
expect(item).to.have.property('commandId').that.is.a('number');
expect(item).to.have.property('toolTip').that.is.a('string');
expect(item).to.have.property('role').that.is.a('string');
expect(item).to.have.property('icon');
});
});
describe('MenuItem.click', () => {
it('should be called with the item object passed', done => {
const menu = Menu.buildFromTemplate([{
label: 'text',
click: (item) => {
try {
expect(item.constructor.name).to.equal('MenuItem');
expect(item.label).to.equal('text');
done();
} catch (e) {
done(e);
}
}
}]);
menu._executeCommand({}, menu.items[0].commandId);
});
});
describe('MenuItem with checked/radio property', () => {
it('clicking an checkbox item should flip the checked property', () => {
const menu = Menu.buildFromTemplate([{
label: 'text',
type: 'checkbox'
}]);
expect(menu.items[0].checked).to.be.false('menu item checked');
menu._executeCommand({}, menu.items[0].commandId);
expect(menu.items[0].checked).to.be.true('menu item checked');
});
it('clicking an radio item should always make checked property true', () => {
const menu = Menu.buildFromTemplate([{
label: 'text',
type: 'radio'
}]);
menu._executeCommand({}, menu.items[0].commandId);
expect(menu.items[0].checked).to.be.true('menu item checked');
menu._executeCommand({}, menu.items[0].commandId);
expect(menu.items[0].checked).to.be.true('menu item checked');
});
describe('MenuItem group properties', () => {
const template: MenuItemConstructorOptions[] = [];
const findRadioGroups = (template: MenuItemConstructorOptions[]) => {
const groups = [];
let cur: { begin?: number, end?: number } | null = null;
for (let i = 0; i <= template.length; i++) {
if (cur && ((i === template.length) || (template[i].type !== 'radio'))) {
cur.end = i;
groups.push(cur);
cur = null;
} else if (!cur && i < template.length && template[i].type === 'radio') {
cur = { begin: i };
}
}
return groups;
};
// returns array of checked menuitems in [begin,end)
const findChecked = (menuItems: MenuItem[], begin: number, end: number) => {
const checked = [];
for (let i = begin; i < end; i++) {
if (menuItems[i].checked) checked.push(i);
}
return checked;
};
beforeEach(() => {
for (let i = 0; i <= 10; i++) {
template.push({
label: `${i}`,
type: 'radio'
});
}
template.push({ type: 'separator' });
for (let i = 12; i <= 20; i++) {
template.push({
label: `${i}`,
type: 'radio'
});
}
});
it('at least have one item checked in each group', () => {
const menu = Menu.buildFromTemplate(template);
menu._menuWillShow();
const groups = findRadioGroups(template);
groups.forEach(g => {
expect(findChecked(menu.items, g.begin!, g.end!)).to.deep.equal([g.begin]);
});
});
it('should assign groupId automatically', () => {
const menu = Menu.buildFromTemplate(template);
const usedGroupIds = new Set();
const groups = findRadioGroups(template);
groups.forEach(g => {
const groupId = (menu.items[g.begin!] as any).groupId;
// groupId should be previously unused
// expect(usedGroupIds.has(groupId)).to.be.false('group id present')
expect(usedGroupIds).not.to.contain(groupId);
usedGroupIds.add(groupId);
// everything in the group should have the same id
for (let i = g.begin!; i < g.end!; ++i) {
expect((menu.items[i] as any).groupId).to.equal(groupId);
}
});
});
it("setting 'checked' should flip other items' 'checked' property", () => {
const menu = Menu.buildFromTemplate(template);
const groups = findRadioGroups(template);
groups.forEach(g => {
expect(findChecked(menu.items, g.begin!, g.end!)).to.deep.equal([]);
menu.items[g.begin!].checked = true;
expect(findChecked(menu.items, g.begin!, g.end!)).to.deep.equal([g.begin!]);
menu.items[g.end! - 1].checked = true;
expect(findChecked(menu.items, g.begin!, g.end!)).to.deep.equal([g.end! - 1]);
});
});
});
});
describe('MenuItem role execution', () => {
afterEach(closeAllWindows);
it('does not try to execute roles without a valid role property', () => {
const win = new BrowserWindow({ show: false, width: 200, height: 200 });
const item = new MenuItem({ role: 'asdfghjkl' as any });
const canExecute = execute(item.role, win, win.webContents);
expect(canExecute).to.be.false('can execute');
});
it('executes roles with native role functions', () => {
const win = new BrowserWindow({ show: false, width: 200, height: 200 });
const item = new MenuItem({ role: 'reload' });
const canExecute = execute(item.role, win, win.webContents);
expect(canExecute).to.be.true('can execute');
});
it('execute roles with non-native role functions', () => {
const win = new BrowserWindow({ show: false, width: 200, height: 200 });
const item = new MenuItem({ role: 'resetZoom' });
const canExecute = execute(item.role, win, win.webContents);
expect(canExecute).to.be.true('can execute');
});
});
describe('MenuItem command id', () => {
it('cannot be overwritten', () => {
const item = new MenuItem({ label: 'item' });
const commandId = item.commandId;
expect(commandId).to.not.be.undefined('command id');
expect(() => {
item.commandId = `${commandId}-modified` as any;
}).to.throw(/Cannot assign to read only property/);
expect(item.commandId).to.equal(commandId);
});
});
describe('MenuItem with invalid type', () => {
it('throws an exception', () => {
expect(() => {
Menu.buildFromTemplate([{
label: 'text',
type: 'not-a-type' as any
}]);
}).to.throw(/Unknown menu item type: not-a-type/);
});
});
describe('MenuItem with submenu type and missing submenu', () => {
it('throws an exception', () => {
expect(() => {
Menu.buildFromTemplate([{
label: 'text',
type: 'submenu'
}]);
}).to.throw(/Invalid submenu/);
});
});
describe('MenuItem role', () => {
it('returns undefined for items without default accelerator', () => {
const list = Object.keys(roleList).filter(key => !roleList[key].accelerator);
for (const role of list) {
const item = new MenuItem({ role: role as any });
expect(item.getDefaultRoleAccelerator()).to.be.undefined('default accelerator');
}
});
it('returns the correct default label', () => {
for (const role of Object.keys(roleList)) {
const item = new MenuItem({ role: role as any });
const label: string = roleList[role].label;
expect(item.label).to.equal(label);
}
});
it('returns the correct default accelerator', () => {
const list = Object.keys(roleList).filter(key => roleList[key].accelerator);
for (const role of list) {
const item = new MenuItem({ role: role as any });
const accelerator: string = roleList[role].accelerator;
expect(item.getDefaultRoleAccelerator()).to.equal(accelerator);
}
});
it('allows a custom accelerator and label to be set', () => {
const item = new MenuItem({
role: 'close',
label: 'Custom Close!',
accelerator: 'D'
});
expect(item.label).to.equal('Custom Close!');
expect(item.accelerator).to.equal('D');
expect(item.getDefaultRoleAccelerator()).to.equal('CommandOrControl+W');
});
});
describe('MenuItem appMenu', () => {
before(function () {
if (process.platform !== 'darwin') {
this.skip();
}
});
it('includes a default submenu layout when submenu is empty', () => {
const item = new MenuItem({ role: 'appMenu' });
expect(item.label).to.equal(app.name);
expect(item.submenu!.items[0].role).to.equal('about');
expect(item.submenu!.items[1].type).to.equal('separator');
expect(item.submenu!.items[2].role).to.equal('services');
expect(item.submenu!.items[3].type).to.equal('separator');
expect(item.submenu!.items[4].role).to.equal('hide');
expect(item.submenu!.items[5].role).to.equal('hideothers');
expect(item.submenu!.items[6].role).to.equal('unhide');
expect(item.submenu!.items[7].type).to.equal('separator');
expect(item.submenu!.items[8].role).to.equal('quit');
});
it('overrides default layout when submenu is specified', () => {
const item = new MenuItem({
role: 'appMenu',
submenu: [{
role: 'close'
}]
});
expect(item.label).to.equal(app.name);
expect(item.submenu!.items[0].role).to.equal('close');
});
});
describe('MenuItem fileMenu', () => {
it('includes a default submenu layout when submenu is empty', () => {
const item = new MenuItem({ role: 'fileMenu' });
expect(item.label).to.equal('File');
if (process.platform === 'darwin') {
expect(item.submenu!.items[0].role).to.equal('close');
} else {
expect(item.submenu!.items[0].role).to.equal('quit');
}
});
it('overrides default layout when submenu is specified', () => {
const item = new MenuItem({
role: 'fileMenu',
submenu: [{
role: 'about'
}]
});
expect(item.label).to.equal('File');
expect(item.submenu!.items[0].role).to.equal('about');
});
});
describe('MenuItem editMenu', () => {
it('includes a default submenu layout when submenu is empty', () => {
const item = new MenuItem({ role: 'editMenu' });
expect(item.label).to.equal('Edit');
expect(item.submenu!.items[0].role).to.equal('undo');
expect(item.submenu!.items[1].role).to.equal('redo');
expect(item.submenu!.items[2].type).to.equal('separator');
expect(item.submenu!.items[3].role).to.equal('cut');
expect(item.submenu!.items[4].role).to.equal('copy');
expect(item.submenu!.items[5].role).to.equal('paste');
if (process.platform === 'darwin') {
expect(item.submenu!.items[6].role).to.equal('pasteandmatchstyle');
expect(item.submenu!.items[7].role).to.equal('delete');
expect(item.submenu!.items[8].role).to.equal('selectall');
expect(item.submenu!.items[9].type).to.equal('separator');
expect(item.submenu!.items[10].label).to.equal('Substitutions');
expect(item.submenu!.items[10].submenu!.items[0].role).to.equal('showsubstitutions');
expect(item.submenu!.items[10].submenu!.items[1].type).to.equal('separator');
expect(item.submenu!.items[10].submenu!.items[2].role).to.equal('togglesmartquotes');
expect(item.submenu!.items[10].submenu!.items[3].role).to.equal('togglesmartdashes');
expect(item.submenu!.items[10].submenu!.items[4].role).to.equal('toggletextreplacement');
expect(item.submenu!.items[11].label).to.equal('Speech');
expect(item.submenu!.items[11].submenu!.items[0].role).to.equal('startspeaking');
expect(item.submenu!.items[11].submenu!.items[1].role).to.equal('stopspeaking');
} else {
expect(item.submenu!.items[6].role).to.equal('delete');
expect(item.submenu!.items[7].type).to.equal('separator');
expect(item.submenu!.items[8].role).to.equal('selectall');
}
});
it('overrides default layout when submenu is specified', () => {
const item = new MenuItem({
role: 'editMenu',
submenu: [{
role: 'close'
}]
});
expect(item.label).to.equal('Edit');
expect(item.submenu!.items[0].role).to.equal('close');
});
});
describe('MenuItem viewMenu', () => {
it('includes a default submenu layout when submenu is empty', () => {
const item = new MenuItem({ role: 'viewMenu' });
expect(item.label).to.equal('View');
expect(item.submenu!.items[0].role).to.equal('reload');
expect(item.submenu!.items[1].role).to.equal('forcereload');
expect(item.submenu!.items[2].role).to.equal('toggledevtools');
expect(item.submenu!.items[3].type).to.equal('separator');
expect(item.submenu!.items[4].role).to.equal('resetzoom');
expect(item.submenu!.items[5].role).to.equal('zoomin');
expect(item.submenu!.items[6].role).to.equal('zoomout');
expect(item.submenu!.items[7].type).to.equal('separator');
expect(item.submenu!.items[8].role).to.equal('togglefullscreen');
});
it('overrides default layout when submenu is specified', () => {
const item = new MenuItem({
role: 'viewMenu',
submenu: [{
role: 'close'
}]
});
expect(item.label).to.equal('View');
expect(item.submenu!.items[0].role).to.equal('close');
});
});
describe('MenuItem windowMenu', () => {
it('includes a default submenu layout when submenu is empty', () => {
const item = new MenuItem({ role: 'windowMenu' });
expect(item.label).to.equal('Window');
expect(item.submenu!.items[0].role).to.equal('minimize');
expect(item.submenu!.items[1].role).to.equal('zoom');
if (process.platform === 'darwin') {
expect(item.submenu!.items[2].type).to.equal('separator');
expect(item.submenu!.items[3].role).to.equal('front');
} else {
expect(item.submenu!.items[2].role).to.equal('close');
}
});
it('overrides default layout when submenu is specified', () => {
const item = new MenuItem({
role: 'windowMenu',
submenu: [{ role: 'copy' }]
});
expect(item.label).to.equal('Window');
expect(item.submenu!.items[0].role).to.equal('copy');
});
});
describe('MenuItem with custom properties in constructor', () => {
it('preserves the custom properties', () => {
const template = [{
label: 'menu 1',
customProp: 'foo',
submenu: []
}];
const menu = Menu.buildFromTemplate(template);
menu.items[0].submenu!.append(new MenuItem({
label: 'item 1',
customProp: 'bar',
overrideProperty: 'oops not allowed'
} as any));
expect((menu.items[0] as any).customProp).to.equal('foo');
expect(menu.items[0].submenu!.items[0].label).to.equal('item 1');
expect((menu.items[0].submenu!.items[0] as any).customProp).to.equal('bar');
expect((menu.items[0].submenu!.items[0] as any).overrideProperty).to.be.a('function');
});
});
describe('MenuItem accelerators', () => {
const isDarwin = () => {
return (process.platform === 'darwin');
};
it('should display modifiers correctly for simple keys', () => {
const menu = Menu.buildFromTemplate([
{ label: 'text', accelerator: 'CmdOrCtrl+A' },
{ label: 'text', accelerator: 'Shift+A' },
{ label: 'text', accelerator: 'Alt+A' }
]);
expect(menu._getAcceleratorTextAt(0)).to.equal(isDarwin() ? 'Command+A' : 'Ctrl+A');
expect(menu._getAcceleratorTextAt(1)).to.equal('Shift+A');
expect(menu._getAcceleratorTextAt(2)).to.equal('Alt+A');
});
it('should display modifiers correctly for special keys', () => {
const menu = Menu.buildFromTemplate([
{ label: 'text', accelerator: 'CmdOrCtrl+Tab' },
{ label: 'text', accelerator: 'Shift+Tab' },
{ label: 'text', accelerator: 'Alt+Tab' }
]);
expect(menu._getAcceleratorTextAt(0)).to.equal(isDarwin() ? 'Command+Tab' : 'Ctrl+Tab');
expect(menu._getAcceleratorTextAt(1)).to.equal('Shift+Tab');
expect(menu._getAcceleratorTextAt(2)).to.equal('Alt+Tab');
});
it('should not display modifiers twice', () => {
const menu = Menu.buildFromTemplate([
{ label: 'text', accelerator: 'Shift+Shift+A' },
{ label: 'text', accelerator: 'Shift+Shift+Tab' }
]);
expect(menu._getAcceleratorTextAt(0)).to.equal('Shift+A');
expect(menu._getAcceleratorTextAt(1)).to.equal('Shift+Tab');
});
it('should display correctly for shifted keys', () => {
const menu = Menu.buildFromTemplate([
{ label: 'text', accelerator: 'Control+Shift+=' },
{ label: 'text', accelerator: 'Control+Plus' },
{ label: 'text', accelerator: 'Control+Shift+3' },
{ label: 'text', accelerator: 'Control+#' },
{ label: 'text', accelerator: 'Control+Shift+/' },
{ label: 'text', accelerator: 'Control+?' }
]);
expect(menu._getAcceleratorTextAt(0)).to.equal('Ctrl+Shift+=');
expect(menu._getAcceleratorTextAt(1)).to.equal('Ctrl++');
expect(menu._getAcceleratorTextAt(2)).to.equal('Ctrl+Shift+3');
expect(menu._getAcceleratorTextAt(3)).to.equal('Ctrl+#');
expect(menu._getAcceleratorTextAt(4)).to.equal('Ctrl+Shift+/');
expect(menu._getAcceleratorTextAt(5)).to.equal('Ctrl+?');
});
});
});

939
spec/api-menu-spec.ts Normal file
View file

@ -0,0 +1,939 @@
import * as cp from 'child_process';
import * as path from 'path';
import { expect } from 'chai';
import { BrowserWindow, Menu, MenuItem } from 'electron/main';
import { sortMenuItems } from '../lib/browser/api/menu-utils';
import { emittedOnce } from './events-helpers';
import { ifit, delay } from './spec-helpers';
import { closeWindow } from './window-helpers';
const fixturesPath = path.resolve(__dirname, 'fixtures');
describe('Menu module', function () {
describe('Menu.buildFromTemplate', () => {
it('should be able to attach extra fields', () => {
const menu = Menu.buildFromTemplate([
{
label: 'text',
extra: 'field'
} as MenuItem | Record<string, any>
]);
expect((menu.items[0] as any).extra).to.equal('field');
});
it('should be able to accept only MenuItems', () => {
const menu = Menu.buildFromTemplate([
new MenuItem({ label: 'one' }),
new MenuItem({ label: 'two' })
]);
expect(menu.items[0].label).to.equal('one');
expect(menu.items[1].label).to.equal('two');
});
it('should be able to accept only MenuItems in a submenu', () => {
const menu = Menu.buildFromTemplate([
{
label: 'one',
submenu: [
new MenuItem({ label: 'two' }) as any
]
}
]);
expect(menu.items[0].label).to.equal('one');
expect(menu.items[0].submenu!.items[0].label).to.equal('two');
});
it('should be able to accept MenuItems and plain objects', () => {
const menu = Menu.buildFromTemplate([
new MenuItem({ label: 'one' }),
{ label: 'two' }
]);
expect(menu.items[0].label).to.equal('one');
expect(menu.items[1].label).to.equal('two');
});
it('does not modify the specified template', () => {
const template = [{ label: 'text', submenu: [{ label: 'sub' }] }];
const templateCopy = JSON.parse(JSON.stringify(template));
Menu.buildFromTemplate(template);
expect(template).to.deep.equal(templateCopy);
});
it('does not throw exceptions for undefined/null values', () => {
expect(() => {
Menu.buildFromTemplate([
{
label: 'text',
accelerator: undefined
},
{
label: 'text again',
accelerator: null as any
}
]);
}).to.not.throw();
});
it('does throw exceptions for empty objects and null values', () => {
expect(() => {
Menu.buildFromTemplate([{}, null as any]);
}).to.throw(/Invalid template for MenuItem: must have at least one of label, role or type/);
});
it('does throw exception for object without role, label, or type attribute', () => {
expect(() => {
Menu.buildFromTemplate([{ visible: true }]);
}).to.throw(/Invalid template for MenuItem: must have at least one of label, role or type/);
});
it('does throw exception for undefined', () => {
expect(() => {
Menu.buildFromTemplate([undefined as any]);
}).to.throw(/Invalid template for MenuItem: must have at least one of label, role or type/);
});
it('throws when an non-array is passed as a template', () => {
expect(() => {
Menu.buildFromTemplate('hello' as any);
}).to.throw(/Invalid template for Menu: Menu template must be an array/);
});
describe('Menu sorting and building', () => {
describe('sorts groups', () => {
it('does a simple sort', () => {
const items: Electron.MenuItemConstructorOptions[] = [
{
label: 'two',
id: '2',
afterGroupContaining: ['1']
},
{ type: 'separator' },
{
id: '1',
label: 'one'
}
];
const expected = [
{
id: '1',
label: 'one'
},
{ type: 'separator' },
{
id: '2',
label: 'two',
afterGroupContaining: ['1']
}
];
expect(sortMenuItems(items)).to.deep.equal(expected);
});
it('does a simple sort with MenuItems', () => {
const firstItem = new MenuItem({ id: '1', label: 'one' });
const secondItem = new MenuItem({
label: 'two',
id: '2',
afterGroupContaining: ['1']
});
const sep = new MenuItem({ type: 'separator' });
const items = [secondItem, sep, firstItem];
const expected = [firstItem, sep, secondItem];
expect(sortMenuItems(items)).to.deep.equal(expected);
});
it('resolves cycles by ignoring things that conflict', () => {
const items: Electron.MenuItemConstructorOptions[] = [
{
id: '2',
label: 'two',
afterGroupContaining: ['1']
},
{ type: 'separator' },
{
id: '1',
label: 'one',
afterGroupContaining: ['2']
}
];
const expected = [
{
id: '1',
label: 'one',
afterGroupContaining: ['2']
},
{ type: 'separator' },
{
id: '2',
label: 'two',
afterGroupContaining: ['1']
}
];
expect(sortMenuItems(items)).to.deep.equal(expected);
});
it('ignores references to commands that do not exist', () => {
const items: Electron.MenuItemConstructorOptions[] = [
{
id: '1',
label: 'one'
},
{ type: 'separator' },
{
id: '2',
label: 'two',
afterGroupContaining: ['does-not-exist']
}
];
const expected = [
{
id: '1',
label: 'one'
},
{ type: 'separator' },
{
id: '2',
label: 'two',
afterGroupContaining: ['does-not-exist']
}
];
expect(sortMenuItems(items)).to.deep.equal(expected);
});
it('only respects the first matching [before|after]GroupContaining rule in a given group', () => {
const items: Electron.MenuItemConstructorOptions[] = [
{
id: '1',
label: 'one'
},
{ type: 'separator' },
{
id: '3',
label: 'three',
beforeGroupContaining: ['1']
},
{
id: '4',
label: 'four',
afterGroupContaining: ['2']
},
{ type: 'separator' },
{
id: '2',
label: 'two'
}
];
const expected = [
{
id: '3',
label: 'three',
beforeGroupContaining: ['1']
},
{
id: '4',
label: 'four',
afterGroupContaining: ['2']
},
{ type: 'separator' },
{
id: '1',
label: 'one'
},
{ type: 'separator' },
{
id: '2',
label: 'two'
}
];
expect(sortMenuItems(items)).to.deep.equal(expected);
});
});
describe('moves an item to a different group by merging groups', () => {
it('can move a group of one item', () => {
const items: Electron.MenuItemConstructorOptions[] = [
{
id: '1',
label: 'one'
},
{ type: 'separator' },
{
id: '2',
label: 'two'
},
{ type: 'separator' },
{
id: '3',
label: 'three',
after: ['1']
},
{ type: 'separator' }
];
const expected = [
{
id: '1',
label: 'one'
},
{
id: '3',
label: 'three',
after: ['1']
},
{ type: 'separator' },
{
id: '2',
label: 'two'
}
];
expect(sortMenuItems(items)).to.deep.equal(expected);
});
it("moves all items in the moving item's group", () => {
const items: Electron.MenuItemConstructorOptions[] = [
{
id: '1',
label: 'one'
},
{ type: 'separator' },
{
id: '2',
label: 'two'
},
{ type: 'separator' },
{
id: '3',
label: 'three',
after: ['1']
},
{
id: '4',
label: 'four'
},
{ type: 'separator' }
];
const expected = [
{
id: '1',
label: 'one'
},
{
id: '3',
label: 'three',
after: ['1']
},
{
id: '4',
label: 'four'
},
{ type: 'separator' },
{
id: '2',
label: 'two'
}
];
expect(sortMenuItems(items)).to.deep.equal(expected);
});
it("ignores positions relative to commands that don't exist", () => {
const items: Electron.MenuItemConstructorOptions[] = [
{
id: '1',
label: 'one'
},
{ type: 'separator' },
{
id: '2',
label: 'two'
},
{ type: 'separator' },
{
id: '3',
label: 'three',
after: ['does-not-exist']
},
{
id: '4',
label: 'four',
after: ['1']
},
{ type: 'separator' }
];
const expected = [
{
id: '1',
label: 'one'
},
{
id: '3',
label: 'three',
after: ['does-not-exist']
},
{
id: '4',
label: 'four',
after: ['1']
},
{ type: 'separator' },
{
id: '2',
label: 'two'
}
];
expect(sortMenuItems(items)).to.deep.equal(expected);
});
it('can handle recursive group merging', () => {
const items = [
{
id: '1',
label: 'one',
after: ['3']
},
{
id: '2',
label: 'two',
before: ['1']
},
{
id: '3',
label: 'three'
}
];
const expected = [
{
id: '3',
label: 'three'
},
{
id: '2',
label: 'two',
before: ['1']
},
{
id: '1',
label: 'one',
after: ['3']
}
];
expect(sortMenuItems(items)).to.deep.equal(expected);
});
it('can merge multiple groups when given a list of before/after commands', () => {
const items: Electron.MenuItemConstructorOptions[] = [
{
id: '1',
label: 'one'
},
{ type: 'separator' },
{
id: '2',
label: 'two'
},
{ type: 'separator' },
{
id: '3',
label: 'three',
after: ['1', '2']
}
];
const expected = [
{
id: '2',
label: 'two'
},
{
id: '1',
label: 'one'
},
{
id: '3',
label: 'three',
after: ['1', '2']
}
];
expect(sortMenuItems(items)).to.deep.equal(expected);
});
it('can merge multiple groups based on both before/after commands', () => {
const items: Electron.MenuItemConstructorOptions[] = [
{
id: '1',
label: 'one'
},
{ type: 'separator' },
{
id: '2',
label: 'two'
},
{ type: 'separator' },
{
id: '3',
label: 'three',
after: ['1'],
before: ['2']
}
];
const expected = [
{
id: '1',
label: 'one'
},
{
id: '3',
label: 'three',
after: ['1'],
before: ['2']
},
{
id: '2',
label: 'two'
}
];
expect(sortMenuItems(items)).to.deep.equal(expected);
});
});
it('should position before existing item', () => {
const menu = Menu.buildFromTemplate([
{
id: '2',
label: 'two'
}, {
id: '3',
label: 'three'
}, {
id: '1',
label: 'one',
before: ['2']
}
]);
expect(menu.items[0].label).to.equal('one');
expect(menu.items[1].label).to.equal('two');
expect(menu.items[2].label).to.equal('three');
});
it('should position after existing item', () => {
const menu = Menu.buildFromTemplate([
{
id: '2',
label: 'two',
after: ['1']
},
{
id: '1',
label: 'one'
}, {
id: '3',
label: 'three'
}
]);
expect(menu.items[0].label).to.equal('one');
expect(menu.items[1].label).to.equal('two');
expect(menu.items[2].label).to.equal('three');
});
it('should filter excess menu separators', () => {
const menuOne = Menu.buildFromTemplate([
{
type: 'separator'
}, {
label: 'a'
}, {
label: 'b'
}, {
label: 'c'
}, {
type: 'separator'
}
]);
expect(menuOne.items).to.have.length(3);
expect(menuOne.items[0].label).to.equal('a');
expect(menuOne.items[1].label).to.equal('b');
expect(menuOne.items[2].label).to.equal('c');
const menuTwo = Menu.buildFromTemplate([
{
type: 'separator'
}, {
type: 'separator'
}, {
label: 'a'
}, {
label: 'b'
}, {
label: 'c'
}, {
type: 'separator'
}, {
type: 'separator'
}
]);
expect(menuTwo.items).to.have.length(3);
expect(menuTwo.items[0].label).to.equal('a');
expect(menuTwo.items[1].label).to.equal('b');
expect(menuTwo.items[2].label).to.equal('c');
});
it('should only filter excess menu separators AFTER the re-ordering for before/after is done', () => {
const menuOne = Menu.buildFromTemplate([
{
type: 'separator'
},
{
type: 'normal',
label: 'Foo',
id: 'foo'
},
{
type: 'normal',
label: 'Bar',
id: 'bar'
},
{
type: 'separator',
before: ['bar']
}]);
expect(menuOne.items).to.have.length(3);
expect(menuOne.items[0].label).to.equal('Foo');
expect(menuOne.items[1].type).to.equal('separator');
expect(menuOne.items[2].label).to.equal('Bar');
});
it('should continue inserting items at next index when no specifier is present', () => {
const menu = Menu.buildFromTemplate([
{
id: '2',
label: 'two'
}, {
id: '3',
label: 'three'
}, {
id: '4',
label: 'four'
}, {
id: '5',
label: 'five'
}, {
id: '1',
label: 'one',
before: ['2']
}
]);
expect(menu.items[0].label).to.equal('one');
expect(menu.items[1].label).to.equal('two');
expect(menu.items[2].label).to.equal('three');
expect(menu.items[3].label).to.equal('four');
expect(menu.items[4].label).to.equal('five');
});
it('should continue inserting MenuItems at next index when no specifier is present', () => {
const menu = Menu.buildFromTemplate([
new MenuItem({
id: '2',
label: 'two'
}), new MenuItem({
id: '3',
label: 'three'
}), new MenuItem({
id: '4',
label: 'four'
}), new MenuItem({
id: '5',
label: 'five'
}), new MenuItem({
id: '1',
label: 'one',
before: ['2']
})
]);
expect(menu.items[0].label).to.equal('one');
expect(menu.items[1].label).to.equal('two');
expect(menu.items[2].label).to.equal('three');
expect(menu.items[3].label).to.equal('four');
expect(menu.items[4].label).to.equal('five');
});
});
});
describe('Menu.getMenuItemById', () => {
it('should return the item with the given id', () => {
const menu = Menu.buildFromTemplate([
{
label: 'View',
submenu: [
{
label: 'Enter Fullscreen',
accelerator: 'ControlCommandF',
id: 'fullScreen'
}
]
}
]);
const fsc = menu.getMenuItemById('fullScreen');
expect(menu.items[0].submenu!.items[0]).to.equal(fsc);
});
it('should return the separator with the given id', () => {
const menu = Menu.buildFromTemplate([
{
label: 'Item 1',
id: 'item_1'
},
{
id: 'separator',
type: 'separator'
},
{
label: 'Item 2',
id: 'item_2'
}
]);
const separator = menu.getMenuItemById('separator');
expect(separator).to.be.an('object');
expect(separator).to.equal(menu.items[1]);
});
});
describe('Menu.insert', () => {
it('should throw when attempting to insert at out-of-range indices', () => {
const menu = Menu.buildFromTemplate([
{ label: '1' },
{ label: '2' },
{ label: '3' }
]);
const item = new MenuItem({ label: 'badInsert' });
expect(() => {
menu.insert(9999, item);
}).to.throw(/Position 9999 cannot be greater than the total MenuItem count/);
expect(() => {
menu.insert(-9999, item);
}).to.throw(/Position -9999 cannot be less than 0/);
});
it('should store item in @items by its index', () => {
const menu = Menu.buildFromTemplate([
{ label: '1' },
{ label: '2' },
{ label: '3' }
]);
const item = new MenuItem({ label: 'inserted' });
menu.insert(1, item);
expect(menu.items[0].label).to.equal('1');
expect(menu.items[1].label).to.equal('inserted');
expect(menu.items[2].label).to.equal('2');
expect(menu.items[3].label).to.equal('3');
});
});
describe('Menu.append', () => {
it('should add the item to the end of the menu', () => {
const menu = Menu.buildFromTemplate([
{ label: '1' },
{ label: '2' },
{ label: '3' }
]);
const item = new MenuItem({ label: 'inserted' });
menu.append(item);
expect(menu.items[0].label).to.equal('1');
expect(menu.items[1].label).to.equal('2');
expect(menu.items[2].label).to.equal('3');
expect(menu.items[3].label).to.equal('inserted');
});
});
describe('Menu.popup', () => {
let w: BrowserWindow;
let menu: Menu;
beforeEach(() => {
w = new BrowserWindow({ show: false, width: 200, height: 200 });
menu = Menu.buildFromTemplate([
{ label: '1' },
{ label: '2' },
{ label: '3' }
]);
});
afterEach(async () => {
menu.closePopup();
menu.closePopup(w);
await closeWindow(w);
w = null as unknown as BrowserWindow;
});
it('throws an error if options is not an object', () => {
expect(() => {
menu.popup('this is a string, not an object' as any);
}).to.throw(/Options must be an object/);
});
it('allows for options to be optional', () => {
expect(() => {
menu.popup({});
}).to.not.throw();
});
it('should emit menu-will-show event', (done) => {
menu.on('menu-will-show', () => { done(); });
menu.popup({ window: w });
});
it('should emit menu-will-close event', (done) => {
menu.on('menu-will-close', () => { done(); });
menu.popup({ window: w });
// https://github.com/electron/electron/issues/19411
setTimeout(() => {
menu.closePopup();
});
});
it('returns immediately', () => {
const input = { window: w, x: 100, y: 101 };
const output = menu.popup(input) as unknown as {x: number, y: number, browserWindow: BrowserWindow};
expect(output.x).to.equal(input.x);
expect(output.y).to.equal(input.y);
expect(output.browserWindow).to.equal(input.window);
});
it('works without a given BrowserWindow and options', () => {
const { browserWindow, x, y } = menu.popup({ x: 100, y: 101 }) as unknown as {x: number, y: number, browserWindow: BrowserWindow};
expect(browserWindow.constructor.name).to.equal('BrowserWindow');
expect(x).to.equal(100);
expect(y).to.equal(101);
});
it('works with a given BrowserWindow, options and callback', (done) => {
const { x, y } = menu.popup({
window: w,
x: 100,
y: 101,
callback: () => done()
}) as unknown as {x: number, y: number};
expect(x).to.equal(100);
expect(y).to.equal(101);
// https://github.com/electron/electron/issues/19411
setTimeout(() => {
menu.closePopup();
});
});
it('works with a given BrowserWindow, no options, and a callback', (done) => {
menu.popup({ window: w, callback: () => done() });
// https://github.com/electron/electron/issues/19411
setTimeout(() => {
menu.closePopup();
});
});
it('prevents menu from getting garbage-collected when popuping', async () => {
const menu = Menu.buildFromTemplate([{ role: 'paste' }]);
menu.popup({ window: w });
// Keep a weak reference to the menu.
// eslint-disable-next-line no-undef
const wr = new WeakRef(menu);
await delay();
// Do garbage collection, since |menu| is not referenced in this closure
// it would be gone after next call.
const v8Util = process._linkedBinding('electron_common_v8_util');
v8Util.requestGarbageCollectionForTesting();
await delay();
// Try to receive menu from weak reference.
if (wr.deref()) {
wr.deref()!.closePopup();
} else {
throw new Error('Menu is garbage-collected while popuping');
}
});
});
describe('Menu.setApplicationMenu', () => {
it('sets a menu', () => {
const menu = Menu.buildFromTemplate([
{ label: '1' },
{ label: '2' }
]);
Menu.setApplicationMenu(menu);
expect(Menu.getApplicationMenu()).to.not.be.null('application menu');
});
// TODO(nornagon): this causes the focus handling tests to fail
it.skip('unsets a menu with null', () => {
Menu.setApplicationMenu(null);
expect(Menu.getApplicationMenu()).to.be.null('application menu');
});
ifit(process.platform !== 'darwin')('does not override menu visibility on startup', async () => {
const appPath = path.join(fixturesPath, 'api', 'test-menu-visibility');
const appProcess = cp.spawn(process.execPath, [appPath]);
let output = '';
await new Promise<void>((resolve) => {
appProcess.stdout.on('data', data => {
output += data;
if (data.indexOf('Window has') > -1) {
resolve();
}
});
});
expect(output).to.include('Window has no menu');
});
ifit(process.platform !== 'darwin')('does not override null menu on startup', async () => {
const appPath = path.join(fixturesPath, 'api', 'test-menu-null');
const appProcess = cp.spawn(process.execPath, [appPath]);
let output = '';
appProcess.stdout.on('data', data => { output += data; });
appProcess.stderr.on('data', data => { output += data; });
const [code] = await emittedOnce(appProcess, 'exit');
if (!output.includes('Window has no menu')) {
console.log(code, output);
}
expect(output).to.include('Window has no menu');
});
});
});

View file

@ -0,0 +1,564 @@
import { expect } from 'chai';
import { nativeImage } from 'electron/common';
import { ifdescribe, ifit } from './spec-helpers';
import * as path from 'path';
describe('nativeImage module', () => {
const fixturesPath = path.join(__dirname, 'fixtures');
const imageLogo = {
path: path.join(fixturesPath, 'assets', 'logo.png'),
width: 538,
height: 190
};
const image1x1 = {
dataUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQYlWNgAAIAAAUAAdafFs0AAAAASUVORK5CYII=',
path: path.join(fixturesPath, 'assets', '1x1.png'),
height: 1,
width: 1
};
const image2x2 = {
dataUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAFklEQVQYlWP8//8/AwMDEwMDAwMDAwAkBgMBBMzldwAAAABJRU5ErkJggg==',
path: path.join(fixturesPath, 'assets', '2x2.jpg'),
height: 2,
width: 2
};
const image3x3 = {
dataUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAMAAAADCAYAAABWKLW/AAAADElEQVQYlWNgIAoAAAAnAAGZWEMnAAAAAElFTkSuQmCC',
path: path.join(fixturesPath, 'assets', '3x3.png'),
height: 3,
width: 3
};
const dataUrlImages = [
image1x1,
image2x2,
image3x3
];
ifdescribe(process.platform === 'darwin')('isMacTemplateImage state', () => {
describe('with properties', () => {
it('correctly recognizes a template image', () => {
const image = nativeImage.createFromPath(path.join(fixturesPath, 'assets', 'logo.png'));
expect(image.isMacTemplateImage).to.be.false();
const templateImage = nativeImage.createFromPath(path.join(fixturesPath, 'assets', 'logo_Template.png'));
expect(templateImage.isMacTemplateImage).to.be.true();
});
it('sets a template image', function () {
const image = nativeImage.createFromPath(path.join(fixturesPath, 'assets', 'logo.png'));
expect(image.isMacTemplateImage).to.be.false();
image.isMacTemplateImage = true;
expect(image.isMacTemplateImage).to.be.true();
});
});
describe('with functions', () => {
it('correctly recognizes a template image', () => {
const image = nativeImage.createFromPath(path.join(fixturesPath, 'assets', 'logo.png'));
expect(image.isTemplateImage()).to.be.false();
const templateImage = nativeImage.createFromPath(path.join(fixturesPath, 'assets', 'logo_Template.png'));
expect(templateImage.isTemplateImage()).to.be.true();
});
it('sets a template image', function () {
const image = nativeImage.createFromPath(path.join(fixturesPath, 'assets', 'logo.png'));
expect(image.isTemplateImage()).to.be.false();
image.setTemplateImage(true);
expect(image.isTemplateImage()).to.be.true();
});
});
});
describe('createEmpty()', () => {
it('returns an empty image', () => {
const empty = nativeImage.createEmpty();
expect(empty.isEmpty()).to.be.true();
expect(empty.getAspectRatio()).to.equal(1);
expect(empty.toDataURL()).to.equal('data:image/png;base64,');
expect(empty.toDataURL({ scaleFactor: 2.0 })).to.equal('data:image/png;base64,');
expect(empty.getSize()).to.deep.equal({ width: 0, height: 0 });
expect(empty.getBitmap()).to.be.empty();
expect(empty.getBitmap({ scaleFactor: 2.0 })).to.be.empty();
expect(empty.toBitmap()).to.be.empty();
expect(empty.toBitmap({ scaleFactor: 2.0 })).to.be.empty();
expect(empty.toJPEG(100)).to.be.empty();
expect(empty.toPNG()).to.be.empty();
expect(empty.toPNG({ scaleFactor: 2.0 })).to.be.empty();
if (process.platform === 'darwin') {
expect(empty.getNativeHandle()).to.be.empty();
}
});
});
describe('createFromBitmap(buffer, options)', () => {
it('returns an empty image when the buffer is empty', () => {
expect(nativeImage.createFromBitmap(Buffer.from([]), { width: 0, height: 0 }).isEmpty()).to.be.true();
});
it('returns an image created from the given buffer', () => {
const imageA = nativeImage.createFromPath(path.join(fixturesPath, 'assets', 'logo.png'));
const imageB = nativeImage.createFromBitmap(imageA.toBitmap(), imageA.getSize());
expect(imageB.getSize()).to.deep.equal({ width: 538, height: 190 });
const imageC = nativeImage.createFromBuffer(imageA.toBitmap(), { ...imageA.getSize(), scaleFactor: 2.0 });
expect(imageC.getSize()).to.deep.equal({ width: 269, height: 95 });
});
it('throws on invalid arguments', () => {
expect(() => nativeImage.createFromBitmap(null as any, {} as any)).to.throw('buffer must be a node Buffer');
expect(() => nativeImage.createFromBitmap([12, 14, 124, 12] as any, {} as any)).to.throw('buffer must be a node Buffer');
expect(() => nativeImage.createFromBitmap(Buffer.from([]), {} as any)).to.throw('width is required');
expect(() => nativeImage.createFromBitmap(Buffer.from([]), { width: 1 } as any)).to.throw('height is required');
expect(() => nativeImage.createFromBitmap(Buffer.from([]), { width: 1, height: 1 })).to.throw('invalid buffer size');
});
});
describe('createFromBuffer(buffer, options)', () => {
it('returns an empty image when the buffer is empty', () => {
expect(nativeImage.createFromBuffer(Buffer.from([])).isEmpty()).to.be.true();
});
it('returns an empty image when the buffer is too small', () => {
const image = nativeImage.createFromBuffer(Buffer.from([1, 2, 3, 4]), { width: 100, height: 100 });
expect(image.isEmpty()).to.be.true();
});
it('returns an image created from the given buffer', () => {
const imageA = nativeImage.createFromPath(path.join(fixturesPath, 'assets', 'logo.png'));
const imageB = nativeImage.createFromBuffer(imageA.toPNG());
expect(imageB.getSize()).to.deep.equal({ width: 538, height: 190 });
expect(imageA.toBitmap().equals(imageB.toBitmap())).to.be.true();
const imageC = nativeImage.createFromBuffer(imageA.toJPEG(100));
expect(imageC.getSize()).to.deep.equal({ width: 538, height: 190 });
const imageD = nativeImage.createFromBuffer(imageA.toBitmap(),
{ width: 538, height: 190 });
expect(imageD.getSize()).to.deep.equal({ width: 538, height: 190 });
const imageE = nativeImage.createFromBuffer(imageA.toBitmap(),
{ width: 100, height: 200 });
expect(imageE.getSize()).to.deep.equal({ width: 100, height: 200 });
const imageF = nativeImage.createFromBuffer(imageA.toBitmap());
expect(imageF.isEmpty()).to.be.true();
const imageG = nativeImage.createFromBuffer(imageA.toPNG(),
{ width: 100, height: 200 });
expect(imageG.getSize()).to.deep.equal({ width: 538, height: 190 });
const imageH = nativeImage.createFromBuffer(imageA.toJPEG(100),
{ width: 100, height: 200 });
expect(imageH.getSize()).to.deep.equal({ width: 538, height: 190 });
const imageI = nativeImage.createFromBuffer(imageA.toBitmap(),
{ width: 538, height: 190, scaleFactor: 2.0 });
expect(imageI.getSize()).to.deep.equal({ width: 269, height: 95 });
});
it('throws on invalid arguments', () => {
expect(() => nativeImage.createFromBuffer(null as any)).to.throw('buffer must be a node Buffer');
expect(() => nativeImage.createFromBuffer([12, 14, 124, 12] as any)).to.throw('buffer must be a node Buffer');
});
});
describe('createFromDataURL(dataURL)', () => {
it('returns an empty image from the empty string', () => {
expect(nativeImage.createFromDataURL('').isEmpty()).to.be.true();
});
it('returns an image created from the given string', () => {
for (const imageData of dataUrlImages) {
const imageFromPath = nativeImage.createFromPath(imageData.path);
const imageFromDataUrl = nativeImage.createFromDataURL(imageData.dataUrl!);
expect(imageFromDataUrl.isEmpty()).to.be.false();
expect(imageFromDataUrl.getSize()).to.deep.equal(imageFromPath.getSize());
expect(imageFromDataUrl.toBitmap()).to.satisfy(
(bitmap: any) => imageFromPath.toBitmap().equals(bitmap));
expect(imageFromDataUrl.toDataURL()).to.equal(imageFromPath.toDataURL());
}
});
});
describe('toDataURL()', () => {
it('returns a PNG data URL', () => {
for (const imageData of dataUrlImages) {
const imageFromPath = nativeImage.createFromPath(imageData.path!);
const scaleFactors = [1.0, 2.0];
for (const scaleFactor of scaleFactors) {
expect(imageFromPath.toDataURL({ scaleFactor })).to.equal(imageData.dataUrl);
}
}
});
it('returns a data URL at 1x scale factor by default', () => {
const imageData = imageLogo;
const image = nativeImage.createFromPath(imageData.path);
const imageOne = nativeImage.createFromBuffer(image.toPNG(), {
width: image.getSize().width,
height: image.getSize().height,
scaleFactor: 2.0
});
expect(imageOne.getSize()).to.deep.equal(
{ width: imageData.width / 2, height: imageData.height / 2 });
const imageTwo = nativeImage.createFromDataURL(imageOne.toDataURL());
expect(imageTwo.getSize()).to.deep.equal(
{ width: imageData.width, height: imageData.height });
expect(imageOne.toBitmap().equals(imageTwo.toBitmap())).to.be.true();
});
it('supports a scale factor', () => {
const imageData = imageLogo;
const image = nativeImage.createFromPath(imageData.path);
const expectedSize = { width: imageData.width, height: imageData.height };
const imageFromDataUrlOne = nativeImage.createFromDataURL(
image.toDataURL({ scaleFactor: 1.0 }));
expect(imageFromDataUrlOne.getSize()).to.deep.equal(expectedSize);
const imageFromDataUrlTwo = nativeImage.createFromDataURL(
image.toDataURL({ scaleFactor: 2.0 }));
expect(imageFromDataUrlTwo.getSize()).to.deep.equal(expectedSize);
});
});
describe('toPNG()', () => {
it('returns a buffer at 1x scale factor by default', () => {
const imageData = imageLogo;
const imageA = nativeImage.createFromPath(imageData.path);
const imageB = nativeImage.createFromBuffer(imageA.toPNG(), {
width: imageA.getSize().width,
height: imageA.getSize().height,
scaleFactor: 2.0
});
expect(imageB.getSize()).to.deep.equal(
{ width: imageData.width / 2, height: imageData.height / 2 });
const imageC = nativeImage.createFromBuffer(imageB.toPNG());
expect(imageC.getSize()).to.deep.equal(
{ width: imageData.width, height: imageData.height });
expect(imageB.toBitmap().equals(imageC.toBitmap())).to.be.true();
});
it('supports a scale factor', () => {
const imageData = imageLogo;
const image = nativeImage.createFromPath(imageData.path);
const imageFromBufferOne = nativeImage.createFromBuffer(
image.toPNG({ scaleFactor: 1.0 }));
expect(imageFromBufferOne.getSize()).to.deep.equal(
{ width: imageData.width, height: imageData.height });
const imageFromBufferTwo = nativeImage.createFromBuffer(
image.toPNG({ scaleFactor: 2.0 }), { scaleFactor: 2.0 });
expect(imageFromBufferTwo.getSize()).to.deep.equal(
{ width: imageData.width / 2, height: imageData.height / 2 });
});
});
describe('createFromPath(path)', () => {
it('returns an empty image for invalid paths', () => {
expect(nativeImage.createFromPath('').isEmpty()).to.be.true();
expect(nativeImage.createFromPath('does-not-exist.png').isEmpty()).to.be.true();
expect(nativeImage.createFromPath('does-not-exist.ico').isEmpty()).to.be.true();
expect(nativeImage.createFromPath(__dirname).isEmpty()).to.be.true();
expect(nativeImage.createFromPath(__filename).isEmpty()).to.be.true();
});
it('loads images from paths relative to the current working directory', () => {
const imagePath = path.relative('.', path.join(fixturesPath, 'assets', 'logo.png'));
const image = nativeImage.createFromPath(imagePath);
expect(image.isEmpty()).to.be.false();
expect(image.getSize()).to.deep.equal({ width: 538, height: 190 });
});
it('loads images from paths with `.` segments', () => {
const imagePath = `${path.join(fixturesPath)}${path.sep}.${path.sep}${path.join('assets', 'logo.png')}`;
const image = nativeImage.createFromPath(imagePath);
expect(image.isEmpty()).to.be.false();
expect(image.getSize()).to.deep.equal({ width: 538, height: 190 });
});
it('loads images from paths with `..` segments', () => {
const imagePath = `${path.join(fixturesPath, 'api')}${path.sep}..${path.sep}${path.join('assets', 'logo.png')}`;
const image = nativeImage.createFromPath(imagePath);
expect(image.isEmpty()).to.be.false();
expect(image.getSize()).to.deep.equal({ width: 538, height: 190 });
});
ifit(process.platform === 'darwin')('Gets an NSImage pointer on macOS', function () {
const imagePath = `${path.join(fixturesPath, 'api')}${path.sep}..${path.sep}${path.join('assets', 'logo.png')}`;
const image = nativeImage.createFromPath(imagePath);
const nsimage = image.getNativeHandle();
expect(nsimage).to.have.lengthOf(8);
// If all bytes are null, that's Bad
const allBytesAreNotNull = nsimage.reduce((acc, x) => acc || (x !== 0), false);
expect(allBytesAreNotNull);
});
ifit(process.platform === 'win32')('loads images from .ico files on Windows', function () {
const imagePath = path.join(fixturesPath, 'assets', 'icon.ico');
const image = nativeImage.createFromPath(imagePath);
expect(image.isEmpty()).to.be.false();
expect(image.getSize()).to.deep.equal({ width: 256, height: 256 });
});
});
describe('createFromNamedImage(name)', () => {
it('returns empty for invalid options', () => {
const image = nativeImage.createFromNamedImage('totally_not_real');
expect(image.isEmpty()).to.be.true();
});
ifit(process.platform !== 'darwin')('returns empty on non-darwin platforms', function () {
const image = nativeImage.createFromNamedImage('NSActionTemplate');
expect(image.isEmpty()).to.be.true();
});
ifit(process.platform === 'darwin')('returns a valid image on darwin', function () {
const image = nativeImage.createFromNamedImage('NSActionTemplate');
expect(image.isEmpty()).to.be.false();
});
ifit(process.platform === 'darwin')('returns allows an HSL shift for a valid image on darwin', function () {
const image = nativeImage.createFromNamedImage('NSActionTemplate', [0.5, 0.2, 0.8]);
expect(image.isEmpty()).to.be.false();
});
});
describe('resize(options)', () => {
it('returns a resized image', () => {
const image = nativeImage.createFromPath(path.join(fixturesPath, 'assets', 'logo.png'));
for (const [resizeTo, expectedSize] of new Map([
[{}, { width: 538, height: 190 }],
[{ width: 269 }, { width: 269, height: 95 }],
[{ width: 600 }, { width: 600, height: 212 }],
[{ height: 95 }, { width: 269, height: 95 }],
[{ height: 200 }, { width: 566, height: 200 }],
[{ width: 80, height: 65 }, { width: 80, height: 65 }],
[{ width: 600, height: 200 }, { width: 600, height: 200 }],
[{ width: 0, height: 0 }, { width: 0, height: 0 }],
[{ width: -1, height: -1 }, { width: 0, height: 0 }]
])) {
const actualSize = image.resize(resizeTo).getSize();
expect(actualSize).to.deep.equal(expectedSize);
}
});
it('returns an empty image when called on an empty image', () => {
expect(nativeImage.createEmpty().resize({ width: 1, height: 1 }).isEmpty()).to.be.true();
expect(nativeImage.createEmpty().resize({ width: 0, height: 0 }).isEmpty()).to.be.true();
});
it('supports a quality option', () => {
const image = nativeImage.createFromPath(path.join(fixturesPath, 'assets', 'logo.png'));
const good = image.resize({ width: 100, height: 100, quality: 'good' });
const better = image.resize({ width: 100, height: 100, quality: 'better' });
const best = image.resize({ width: 100, height: 100, quality: 'best' });
expect(good.toPNG()).to.have.lengthOf.at.most(better.toPNG().length);
expect(better.toPNG()).to.have.lengthOf.below(best.toPNG().length);
});
});
describe('crop(bounds)', () => {
it('returns an empty image when called on an empty image', () => {
expect(nativeImage.createEmpty().crop({ width: 1, height: 2, x: 0, y: 0 }).isEmpty()).to.be.true();
expect(nativeImage.createEmpty().crop({ width: 0, height: 0, x: 0, y: 0 }).isEmpty()).to.be.true();
});
it('returns an empty image when the bounds are invalid', () => {
const image = nativeImage.createFromPath(path.join(fixturesPath, 'assets', 'logo.png'));
expect(image.crop({ width: 0, height: 0, x: 0, y: 0 }).isEmpty()).to.be.true();
expect(image.crop({ width: -1, height: 10, x: 0, y: 0 }).isEmpty()).to.be.true();
expect(image.crop({ width: 10, height: -35, x: 0, y: 0 }).isEmpty()).to.be.true();
expect(image.crop({ width: 100, height: 100, x: 1000, y: 1000 }).isEmpty()).to.be.true();
});
it('returns a cropped image', () => {
const image = nativeImage.createFromPath(path.join(fixturesPath, 'assets', 'logo.png'));
const cropA = image.crop({ width: 25, height: 64, x: 0, y: 0 });
const cropB = image.crop({ width: 25, height: 64, x: 30, y: 40 });
expect(cropA.getSize()).to.deep.equal({ width: 25, height: 64 });
expect(cropB.getSize()).to.deep.equal({ width: 25, height: 64 });
expect(cropA.toPNG().equals(cropB.toPNG())).to.be.false();
});
it('toBitmap() returns a buffer of the right size', () => {
const image = nativeImage.createFromPath(path.join(fixturesPath, 'assets', 'logo.png'));
const crop = image.crop({ width: 25, height: 64, x: 0, y: 0 });
expect(crop.toBitmap().length).to.equal(25 * 64 * 4);
});
});
describe('getAspectRatio()', () => {
it('returns an aspect ratio of an empty image', () => {
expect(nativeImage.createEmpty().getAspectRatio()).to.equal(1.0);
});
it('returns an aspect ratio of an image', () => {
const imageData = imageLogo;
// imageData.width / imageData.height = 2.831578947368421
const expectedAspectRatio = 2.8315789699554443;
const image = nativeImage.createFromPath(imageData.path);
expect(image.getAspectRatio()).to.equal(expectedAspectRatio);
});
});
ifdescribe(process.platform !== 'linux')('createThumbnailFromPath(path, size)', () => {
it('throws when invalid size is passed', async () => {
const badSize = { width: -1, height: -1 };
await expect(
nativeImage.createThumbnailFromPath('path', badSize)
).to.eventually.be.rejectedWith('size must not be empty');
});
it('throws when a bad path is passed', async () => {
const badPath = process.platform === 'win32' ? '\\hey\\hi\\hello' : '/hey/hi/hello';
const goodSize = { width: 100, height: 100 };
await expect(
nativeImage.createThumbnailFromPath(badPath, goodSize)
).to.eventually.be.rejected();
});
it('returns native image given valid params', async () => {
const goodPath = path.join(fixturesPath, 'assets', 'logo.png');
const goodSize = { width: 100, height: 100 };
const result = await nativeImage.createThumbnailFromPath(goodPath, goodSize);
expect(result.isEmpty()).to.equal(false);
});
});
describe('addRepresentation()', () => {
it('does not add representation when the buffer is too small', () => {
const image = nativeImage.createEmpty();
image.addRepresentation({
buffer: Buffer.from([1, 2, 3, 4]),
width: 100,
height: 100
});
expect(image.isEmpty()).to.be.true();
});
it('supports adding a buffer representation for a scale factor', () => {
const image = nativeImage.createEmpty();
const imageDataOne = image1x1;
image.addRepresentation({
scaleFactor: 1.0,
buffer: nativeImage.createFromPath(imageDataOne.path).toPNG()
});
expect(image.getScaleFactors()).to.deep.equal([1]);
const imageDataTwo = image2x2;
image.addRepresentation({
scaleFactor: 2.0,
buffer: nativeImage.createFromPath(imageDataTwo.path).toPNG()
});
expect(image.getScaleFactors()).to.deep.equal([1, 2]);
const imageDataThree = image3x3;
image.addRepresentation({
scaleFactor: 3.0,
buffer: nativeImage.createFromPath(imageDataThree.path).toPNG()
});
expect(image.getScaleFactors()).to.deep.equal([1, 2, 3]);
image.addRepresentation({
scaleFactor: 4.0,
buffer: 'invalid' as any
});
// this one failed, so it shouldn't show up in the scale factors
expect(image.getScaleFactors()).to.deep.equal([1, 2, 3]);
expect(image.isEmpty()).to.be.false();
expect(image.getSize()).to.deep.equal({ width: 1, height: 1 });
expect(image.toDataURL({ scaleFactor: 1.0 })).to.equal(imageDataOne.dataUrl);
expect(image.toDataURL({ scaleFactor: 2.0 })).to.equal(imageDataTwo.dataUrl);
expect(image.toDataURL({ scaleFactor: 3.0 })).to.equal(imageDataThree.dataUrl);
expect(image.toDataURL({ scaleFactor: 4.0 })).to.equal(imageDataThree.dataUrl);
});
it('supports adding a data URL representation for a scale factor', () => {
const image = nativeImage.createEmpty();
const imageDataOne = image1x1;
image.addRepresentation({
scaleFactor: 1.0,
dataURL: imageDataOne.dataUrl
});
const imageDataTwo = image2x2;
image.addRepresentation({
scaleFactor: 2.0,
dataURL: imageDataTwo.dataUrl
});
const imageDataThree = image3x3;
image.addRepresentation({
scaleFactor: 3.0,
dataURL: imageDataThree.dataUrl
});
image.addRepresentation({
scaleFactor: 4.0,
dataURL: 'invalid'
});
expect(image.isEmpty()).to.be.false();
expect(image.getSize()).to.deep.equal({ width: 1, height: 1 });
expect(image.toDataURL({ scaleFactor: 1.0 })).to.equal(imageDataOne.dataUrl);
expect(image.toDataURL({ scaleFactor: 2.0 })).to.equal(imageDataTwo.dataUrl);
expect(image.toDataURL({ scaleFactor: 3.0 })).to.equal(imageDataThree.dataUrl);
expect(image.toDataURL({ scaleFactor: 4.0 })).to.equal(imageDataThree.dataUrl);
});
it('supports adding a representation to an existing image', () => {
const imageDataOne = image1x1;
const image = nativeImage.createFromPath(imageDataOne.path);
const imageDataTwo = image2x2;
image.addRepresentation({
scaleFactor: 2.0,
dataURL: imageDataTwo.dataUrl
});
const imageDataThree = image3x3;
image.addRepresentation({
scaleFactor: 2.0,
dataURL: imageDataThree.dataUrl
});
expect(image.toDataURL({ scaleFactor: 1.0 })).to.equal(imageDataOne.dataUrl);
expect(image.toDataURL({ scaleFactor: 2.0 })).to.equal(imageDataTwo.dataUrl);
});
});
});

View file

@ -0,0 +1,118 @@
import { expect } from 'chai';
import { nativeTheme, systemPreferences, BrowserWindow, ipcMain } from 'electron/main';
import * as os from 'os';
import * as path from 'path';
import * as semver from 'semver';
import { delay, ifdescribe } from './spec-helpers';
import { emittedOnce } from './events-helpers';
import { closeAllWindows } from './window-helpers';
describe('nativeTheme module', () => {
describe('nativeTheme.shouldUseDarkColors', () => {
it('returns a boolean', () => {
expect(nativeTheme.shouldUseDarkColors).to.be.a('boolean');
});
});
describe('nativeTheme.themeSource', () => {
afterEach(async () => {
nativeTheme.themeSource = 'system';
// Wait for any pending events to emit
await delay(20);
closeAllWindows();
});
it('is system by default', () => {
expect(nativeTheme.themeSource).to.equal('system');
});
it('should override the value of shouldUseDarkColors', () => {
nativeTheme.themeSource = 'dark';
expect(nativeTheme.shouldUseDarkColors).to.equal(true);
nativeTheme.themeSource = 'light';
expect(nativeTheme.shouldUseDarkColors).to.equal(false);
});
it('should emit the "updated" event when it is set and the resulting "shouldUseDarkColors" value changes', async () => {
let updatedEmitted = emittedOnce(nativeTheme, 'updated');
nativeTheme.themeSource = 'dark';
await updatedEmitted;
updatedEmitted = emittedOnce(nativeTheme, 'updated');
nativeTheme.themeSource = 'light';
await updatedEmitted;
});
it('should not emit the "updated" event when it is set and the resulting "shouldUseDarkColors" value is the same', async () => {
nativeTheme.themeSource = 'dark';
// Wait a few ticks to allow an async events to flush
await delay(20);
let called = false;
nativeTheme.once('updated', () => {
called = true;
});
nativeTheme.themeSource = 'dark';
// Wait a few ticks to allow an async events to flush
await delay(20);
expect(called).to.equal(false);
});
ifdescribe(process.platform === 'darwin' && semver.gte(os.release(), '18.0.0'))('on macOS 10.14', () => {
it('should update appLevelAppearance when set', () => {
nativeTheme.themeSource = 'dark';
expect(systemPreferences.appLevelAppearance).to.equal('dark');
nativeTheme.themeSource = 'light';
expect(systemPreferences.appLevelAppearance).to.equal('light');
});
});
const getPrefersColorSchemeIsDark = async (w: Electron.BrowserWindow) => {
const isDark: boolean = await w.webContents.executeJavaScript(
'matchMedia("(prefers-color-scheme: dark)").matches'
);
return isDark;
};
it('should override the result of prefers-color-scheme CSS media query', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { contextIsolation: false, nodeIntegration: true } });
await w.loadFile(path.resolve(__dirname, 'fixtures', 'blank.html'));
await w.webContents.executeJavaScript(`
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', () => require('electron').ipcRenderer.send('theme-change'))
`);
const originalSystemIsDark = await getPrefersColorSchemeIsDark(w);
let changePromise: Promise<any[]> = emittedOnce(ipcMain, 'theme-change');
nativeTheme.themeSource = 'dark';
if (!originalSystemIsDark) await changePromise;
expect(await getPrefersColorSchemeIsDark(w)).to.equal(true);
changePromise = emittedOnce(ipcMain, 'theme-change');
nativeTheme.themeSource = 'light';
await changePromise;
expect(await getPrefersColorSchemeIsDark(w)).to.equal(false);
changePromise = emittedOnce(ipcMain, 'theme-change');
nativeTheme.themeSource = 'system';
if (originalSystemIsDark) await changePromise;
expect(await getPrefersColorSchemeIsDark(w)).to.equal(originalSystemIsDark);
w.close();
});
});
describe('nativeTheme.shouldUseInvertedColorScheme', () => {
it('returns a boolean', () => {
expect(nativeTheme.shouldUseInvertedColorScheme).to.be.a('boolean');
});
});
describe('nativeTheme.shouldUseHighContrastColors', () => {
it('returns a boolean', () => {
expect(nativeTheme.shouldUseHighContrastColors).to.be.a('boolean');
});
});
describe('nativeTheme.inForcedColorsMode', () => {
it('returns a boolean', () => {
expect(nativeTheme.inForcedColorsMode).to.be.a('boolean');
});
});
});

165
spec/api-net-log-spec.ts Normal file
View file

@ -0,0 +1,165 @@
import { expect } from 'chai';
import * as http from 'http';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as ChildProcess from 'child_process';
import { session, net } from 'electron/main';
import { Socket, AddressInfo } from 'net';
import { ifit } from './spec-helpers';
import { emittedOnce } from './events-helpers';
const appPath = path.join(__dirname, 'fixtures', 'api', 'net-log');
const dumpFile = path.join(os.tmpdir(), 'net_log.json');
const dumpFileDynamic = path.join(os.tmpdir(), 'net_log_dynamic.json');
const testNetLog = () => session.fromPartition('net-log').netLog;
describe('netLog module', () => {
let server: http.Server;
let serverUrl: string;
const connections: Set<Socket> = new Set();
before(done => {
server = http.createServer();
server.listen(0, '127.0.0.1', () => {
serverUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
done();
});
server.on('connection', (connection) => {
connections.add(connection);
connection.once('close', () => {
connections.delete(connection);
});
});
server.on('request', (request, response) => {
response.end();
});
});
after(done => {
for (const connection of connections) {
connection.destroy();
}
server.close(() => {
server = null as any;
done();
});
});
beforeEach(() => {
expect(testNetLog().currentlyLogging).to.be.false('currently logging');
});
afterEach(() => {
try {
if (fs.existsSync(dumpFile)) {
fs.unlinkSync(dumpFile);
}
if (fs.existsSync(dumpFileDynamic)) {
fs.unlinkSync(dumpFileDynamic);
}
} catch (e) {
// Ignore error
}
expect(testNetLog().currentlyLogging).to.be.false('currently logging');
});
it('should begin and end logging to file when .startLogging() and .stopLogging() is called', async () => {
await testNetLog().startLogging(dumpFileDynamic);
expect(testNetLog().currentlyLogging).to.be.true('currently logging');
await testNetLog().stopLogging();
expect(fs.existsSync(dumpFileDynamic)).to.be.true('currently logging');
});
it('should throw an error when .stopLogging() is called without calling .startLogging()', async () => {
await expect(testNetLog().stopLogging()).to.be.rejectedWith('No net log in progress');
});
it('should throw an error when .startLogging() is called with an invalid argument', () => {
expect(() => testNetLog().startLogging('')).to.throw();
expect(() => testNetLog().startLogging(null as any)).to.throw();
expect(() => testNetLog().startLogging([] as any)).to.throw();
expect(() => testNetLog().startLogging('aoeu', { captureMode: 'aoeu' as any })).to.throw();
expect(() => testNetLog().startLogging('aoeu', { maxFileSize: null as any })).to.throw();
});
it('should include cookies when requested', async () => {
await testNetLog().startLogging(dumpFileDynamic, { captureMode: 'includeSensitive' });
const unique = require('uuid').v4();
await new Promise<void>((resolve) => {
const req = net.request(serverUrl);
req.setHeader('Cookie', `foo=${unique}`);
req.on('response', (response) => {
response.on('data', () => {}); // https://github.com/electron/electron/issues/19214
response.on('end', () => resolve());
});
req.end();
});
await testNetLog().stopLogging();
expect(fs.existsSync(dumpFileDynamic)).to.be.true('dump file exists');
const dump = fs.readFileSync(dumpFileDynamic, 'utf8');
expect(dump).to.contain(`foo=${unique}`);
});
it('should include socket bytes when requested', async () => {
await testNetLog().startLogging(dumpFileDynamic, { captureMode: 'everything' });
const unique = require('uuid').v4();
await new Promise<void>((resolve) => {
const req = net.request({ method: 'POST', url: serverUrl });
req.on('response', (response) => {
response.on('data', () => {}); // https://github.com/electron/electron/issues/19214
response.on('end', () => resolve());
});
req.end(Buffer.from(unique));
});
await testNetLog().stopLogging();
expect(fs.existsSync(dumpFileDynamic)).to.be.true('dump file exists');
const dump = fs.readFileSync(dumpFileDynamic, 'utf8');
expect(JSON.parse(dump).events.some((x: any) => x.params && x.params.bytes && Buffer.from(x.params.bytes, 'base64').includes(unique))).to.be.true('uuid present in dump');
});
ifit(process.platform !== 'linux')('should begin and end logging automatically when --log-net-log is passed', async () => {
const appProcess = ChildProcess.spawn(process.execPath,
[appPath], {
env: {
TEST_REQUEST_URL: serverUrl,
TEST_DUMP_FILE: dumpFile
}
});
await emittedOnce(appProcess, 'exit');
expect(fs.existsSync(dumpFile)).to.be.true('dump file exists');
});
ifit(process.platform !== 'linux')('should begin and end logging automatically when --log-net-log is passed, and behave correctly when .startLogging() and .stopLogging() is called', async () => {
const appProcess = ChildProcess.spawn(process.execPath,
[appPath], {
env: {
TEST_REQUEST_URL: serverUrl,
TEST_DUMP_FILE: dumpFile,
TEST_DUMP_FILE_DYNAMIC: dumpFileDynamic,
TEST_MANUAL_STOP: 'true'
}
});
await emittedOnce(appProcess, 'exit');
expect(fs.existsSync(dumpFile)).to.be.true('dump file exists');
expect(fs.existsSync(dumpFileDynamic)).to.be.true('dynamic dump file exists');
});
ifit(process.platform !== 'linux')('should end logging automatically when only .startLogging() is called', async () => {
const appProcess = ChildProcess.spawn(process.execPath,
[appPath], {
env: {
TEST_REQUEST_URL: serverUrl,
TEST_DUMP_FILE_DYNAMIC: dumpFileDynamic
}
});
await emittedOnce(appProcess, 'exit');
expect(fs.existsSync(dumpFileDynamic)).to.be.true('dynamic dump file exists');
});
});

1960
spec/api-net-spec.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,126 @@
// For these tests we use a fake DBus daemon to verify libnotify interaction
// with the session bus. This requires python-dbusmock to be installed and
// running at $DBUS_SESSION_BUS_ADDRESS.
//
// script/spec-runner.js spawns dbusmock, which sets DBUS_SESSION_BUS_ADDRESS.
//
// See https://pypi.python.org/pypi/python-dbusmock to read about dbusmock.
import { expect } from 'chai';
import * as dbus from 'dbus-native';
import { app } from 'electron/main';
import { ifdescribe } from './spec-helpers';
import { promisify } from 'util';
const skip = process.platform !== 'linux' ||
process.arch === 'ia32' ||
process.arch.indexOf('arm') === 0 ||
!process.env.DBUS_SESSION_BUS_ADDRESS;
ifdescribe(!skip)('Notification module (dbus)', () => {
let mock: any, Notification, getCalls: any, reset: any;
const realAppName = app.name;
const realAppVersion = app.getVersion();
const appName = 'api-notification-dbus-spec';
const serviceName = 'org.freedesktop.Notifications';
before(async () => {
// init app
app.name = appName;
app.setDesktopName(`${appName}.desktop`);
// init DBus
const path = '/org/freedesktop/Notifications';
const iface = 'org.freedesktop.DBus.Mock';
console.log(`session bus: ${process.env.DBUS_SESSION_BUS_ADDRESS}`);
const bus = dbus.sessionBus();
const service = bus.getService(serviceName);
const getInterface = promisify(service.getInterface.bind(service));
mock = await getInterface(path, iface);
getCalls = promisify(mock.GetCalls.bind(mock));
reset = promisify(mock.Reset.bind(mock));
});
after(async () => {
// cleanup dbus
if (reset) await reset();
// cleanup app
app.setName(realAppName);
app.setVersion(realAppVersion);
});
describe(`Notification module using ${serviceName}`, () => {
function onMethodCalled (done: () => void) {
function cb (name: string) {
console.log(`onMethodCalled: ${name}`);
if (name === 'Notify') {
mock.removeListener('MethodCalled', cb);
console.log('done');
done();
}
}
return cb;
}
function unmarshalDBusNotifyHints (dbusHints: any) {
const o: Record<string, any> = {};
for (const hint of dbusHints) {
const key = hint[0];
const value = hint[1][1][0];
o[key] = value;
}
return o;
}
function unmarshalDBusNotifyArgs (dbusArgs: any) {
return {
app_name: dbusArgs[0][1][0],
replaces_id: dbusArgs[1][1][0],
app_icon: dbusArgs[2][1][0],
title: dbusArgs[3][1][0],
body: dbusArgs[4][1][0],
actions: dbusArgs[5][1][0],
hints: unmarshalDBusNotifyHints(dbusArgs[6][1][0])
};
}
before(done => {
mock.on('MethodCalled', onMethodCalled(done));
// lazy load Notification after we listen to MethodCalled mock signal
Notification = require('electron').Notification;
const n = new Notification({
title: 'title',
subtitle: 'subtitle',
body: 'body',
replyPlaceholder: 'replyPlaceholder',
sound: 'sound',
closeButtonText: 'closeButtonText'
});
n.show();
});
it(`should call ${serviceName} to show notifications`, async () => {
const calls = await getCalls();
expect(calls).to.be.an('array').of.lengthOf.at.least(1);
const lastCall = calls[calls.length - 1];
const methodName = lastCall[1];
expect(methodName).to.equal('Notify');
const args = unmarshalDBusNotifyArgs(lastCall[2]);
expect(args).to.deep.equal({
app_name: appName,
replaces_id: 0,
app_icon: '',
title: 'title',
body: 'body',
actions: [],
hints: {
append: 'true',
'desktop-entry': appName,
urgency: 1
}
});
});
});
});

View file

@ -0,0 +1,149 @@
import { expect } from 'chai';
import { Notification } from 'electron/main';
import { emittedOnce } from './events-helpers';
import { ifit } from './spec-helpers';
describe('Notification module', () => {
it('is supported', () => {
expect(Notification.isSupported()).to.be.a('boolean');
});
it('inits, gets and sets basic string properties correctly', () => {
const n = new Notification({
title: 'title',
subtitle: 'subtitle',
body: 'body',
replyPlaceholder: 'replyPlaceholder',
sound: 'sound',
closeButtonText: 'closeButtonText'
});
expect(n.title).to.equal('title');
n.title = 'title1';
expect(n.title).to.equal('title1');
expect(n.subtitle).equal('subtitle');
n.subtitle = 'subtitle1';
expect(n.subtitle).equal('subtitle1');
expect(n.body).to.equal('body');
n.body = 'body1';
expect(n.body).to.equal('body1');
expect(n.replyPlaceholder).to.equal('replyPlaceholder');
n.replyPlaceholder = 'replyPlaceholder1';
expect(n.replyPlaceholder).to.equal('replyPlaceholder1');
expect(n.sound).to.equal('sound');
n.sound = 'sound1';
expect(n.sound).to.equal('sound1');
expect(n.closeButtonText).to.equal('closeButtonText');
n.closeButtonText = 'closeButtonText1';
expect(n.closeButtonText).to.equal('closeButtonText1');
});
it('inits, gets and sets basic boolean properties correctly', () => {
const n = new Notification({
title: 'title',
body: 'body',
silent: true,
hasReply: true
});
expect(n.silent).to.be.true('silent');
n.silent = false;
expect(n.silent).to.be.false('silent');
expect(n.hasReply).to.be.true('has reply');
n.hasReply = false;
expect(n.hasReply).to.be.false('has reply');
});
it('inits, gets and sets actions correctly', () => {
const n = new Notification({
title: 'title',
body: 'body',
actions: [
{
type: 'button',
text: '1'
}, {
type: 'button',
text: '2'
}
]
});
expect(n.actions.length).to.equal(2);
expect(n.actions[0].type).to.equal('button');
expect(n.actions[0].text).to.equal('1');
expect(n.actions[1].type).to.equal('button');
expect(n.actions[1].text).to.equal('2');
n.actions = [
{
type: 'button',
text: '3'
}, {
type: 'button',
text: '4'
}
];
expect(n.actions.length).to.equal(2);
expect(n.actions[0].type).to.equal('button');
expect(n.actions[0].text).to.equal('3');
expect(n.actions[1].type).to.equal('button');
expect(n.actions[1].text).to.equal('4');
});
it('can be shown and closed', () => {
const n = new Notification({
title: 'test notification',
body: 'test body',
silent: true
});
n.show();
n.close();
});
ifit(process.platform === 'win32')('inits, gets and sets custom xml', () => {
const n = new Notification({
toastXml: '<xml/>'
});
expect(n.toastXml).to.equal('<xml/>');
});
ifit(process.platform === 'darwin')('emits show and close events', async () => {
const n = new Notification({
title: 'test notification',
body: 'test body',
silent: true
});
{
const e = emittedOnce(n, 'show');
n.show();
await e;
}
{
const e = emittedOnce(n, 'close');
n.close();
await e;
}
});
ifit(process.platform === 'win32')('emits failed event', async () => {
const n = new Notification({
toastXml: 'not xml'
});
{
const e = emittedOnce(n, 'failed');
n.show();
await e;
}
});
// TODO(sethlu): Find way to test init with notification icon?
});

View file

@ -0,0 +1,183 @@
// For these tests we use a fake DBus daemon to verify powerMonitor module
// interaction with the system bus. This requires python-dbusmock installed and
// running (with the DBUS_SYSTEM_BUS_ADDRESS environment variable set).
// script/spec-runner.js will take care of spawning the fake DBus daemon and setting
// DBUS_SYSTEM_BUS_ADDRESS when python-dbusmock is installed.
//
// See https://pypi.python.org/pypi/python-dbusmock for more information about
// python-dbusmock.
import { expect } from 'chai';
import * as dbus from 'dbus-native';
import { ifdescribe, delay } from './spec-helpers';
import { promisify } from 'util';
describe('powerMonitor', () => {
let logindMock: any, dbusMockPowerMonitor: any, getCalls: any, emitSignal: any, reset: any;
// TODO(deepak1556): Enable on arm64 after upgrade, it crashes at the moment.
ifdescribe(process.platform === 'linux' && process.arch !== 'arm64' && process.env.DBUS_SYSTEM_BUS_ADDRESS != null)('when powerMonitor module is loaded with dbus mock', () => {
before(async () => {
const systemBus = dbus.systemBus();
const loginService = systemBus.getService('org.freedesktop.login1');
const getInterface = promisify(loginService.getInterface.bind(loginService));
logindMock = await getInterface('/org/freedesktop/login1', 'org.freedesktop.DBus.Mock');
getCalls = promisify(logindMock.GetCalls.bind(logindMock));
emitSignal = promisify(logindMock.EmitSignal.bind(logindMock));
reset = promisify(logindMock.Reset.bind(logindMock));
});
after(async () => {
await reset();
});
function onceMethodCalled (done: () => void) {
function cb () {
logindMock.removeListener('MethodCalled', cb);
}
done();
return cb;
}
before(done => {
logindMock.on('MethodCalled', onceMethodCalled(done));
// lazy load powerMonitor after we listen to MethodCalled mock signal
dbusMockPowerMonitor = require('electron').powerMonitor;
});
it('should call Inhibit to delay suspend once a listener is added', async () => {
// No calls to dbus until a listener is added
{
const calls = await getCalls();
expect(calls).to.be.an('array').that.has.lengthOf(0);
}
// Add a dummy listener to engage the monitors
dbusMockPowerMonitor.on('dummy-event', () => {});
try {
let retriesRemaining = 3;
// There doesn't seem to be a way to get a notification when a call
// happens, so poll `getCalls` a few times to reduce flake.
let calls: any[] = [];
while (retriesRemaining-- > 0) {
calls = await getCalls();
if (calls.length > 0) break;
await delay(1000);
}
expect(calls).to.be.an('array').that.has.lengthOf(1);
expect(calls[0].slice(1)).to.deep.equal([
'Inhibit', [
[[{ type: 's', child: [] }], ['sleep']],
[[{ type: 's', child: [] }], ['electron']],
[[{ type: 's', child: [] }], ['Application cleanup before suspend']],
[[{ type: 's', child: [] }], ['delay']]
]
]);
} finally {
dbusMockPowerMonitor.removeAllListeners('dummy-event');
}
});
describe('when PrepareForSleep(true) signal is sent by logind', () => {
it('should emit "suspend" event', (done) => {
dbusMockPowerMonitor.once('suspend', () => done());
emitSignal('org.freedesktop.login1.Manager', 'PrepareForSleep',
'b', [['b', true]]);
});
describe('when PrepareForSleep(false) signal is sent by logind', () => {
it('should emit "resume" event', done => {
dbusMockPowerMonitor.once('resume', () => done());
emitSignal('org.freedesktop.login1.Manager', 'PrepareForSleep',
'b', [['b', false]]);
});
it('should have called Inhibit again', async () => {
const calls = await getCalls();
expect(calls).to.be.an('array').that.has.lengthOf(2);
expect(calls[1].slice(1)).to.deep.equal([
'Inhibit', [
[[{ type: 's', child: [] }], ['sleep']],
[[{ type: 's', child: [] }], ['electron']],
[[{ type: 's', child: [] }], ['Application cleanup before suspend']],
[[{ type: 's', child: [] }], ['delay']]
]
]);
});
});
});
describe('when a listener is added to shutdown event', () => {
before(async () => {
const calls = await getCalls();
expect(calls).to.be.an('array').that.has.lengthOf(2);
dbusMockPowerMonitor.once('shutdown', () => { });
});
it('should call Inhibit to delay shutdown', async () => {
const calls = await getCalls();
expect(calls).to.be.an('array').that.has.lengthOf(3);
expect(calls[2].slice(1)).to.deep.equal([
'Inhibit', [
[[{ type: 's', child: [] }], ['shutdown']],
[[{ type: 's', child: [] }], ['electron']],
[[{ type: 's', child: [] }], ['Ensure a clean shutdown']],
[[{ type: 's', child: [] }], ['delay']]
]
]);
});
describe('when PrepareForShutdown(true) signal is sent by logind', () => {
it('should emit "shutdown" event', done => {
dbusMockPowerMonitor.once('shutdown', () => { done(); });
emitSignal('org.freedesktop.login1.Manager', 'PrepareForShutdown',
'b', [['b', true]]);
});
});
});
});
describe('when powerMonitor module is loaded', () => {
// eslint-disable-next-line no-undef
let powerMonitor: typeof Electron.powerMonitor;
before(() => {
powerMonitor = require('electron').powerMonitor;
});
describe('powerMonitor.getSystemIdleState', () => {
it('gets current system idle state', () => {
// this function is not mocked out, so we can test the result's
// form and type but not its value.
const idleState = powerMonitor.getSystemIdleState(1);
expect(idleState).to.be.a('string');
const validIdleStates = ['active', 'idle', 'locked', 'unknown'];
expect(validIdleStates).to.include(idleState);
});
it('does not accept non positive integer threshold', () => {
expect(() => {
powerMonitor.getSystemIdleState(-1);
}).to.throw(/must be greater than 0/);
expect(() => {
powerMonitor.getSystemIdleState(NaN);
}).to.throw(/conversion failure/);
expect(() => {
powerMonitor.getSystemIdleState('a' as any);
}).to.throw(/conversion failure/);
});
});
describe('powerMonitor.getSystemIdleTime', () => {
it('returns current system idle time', () => {
const idleTime = powerMonitor.getSystemIdleTime();
expect(idleTime).to.be.at.least(0);
});
});
describe('powerMonitor.onBatteryPower', () => {
it('returns a boolean', () => {
expect(powerMonitor.onBatteryPower).to.be.a('boolean');
expect(powerMonitor.isOnBatteryPower()).to.be.a('boolean');
});
});
});
});

View file

@ -0,0 +1,13 @@
import { expect } from 'chai';
import { powerSaveBlocker } from 'electron/main';
describe('powerSaveBlocker module', () => {
it('can be started and stopped', () => {
expect(powerSaveBlocker.isStarted(-1)).to.be.false('is started');
const id = powerSaveBlocker.start('prevent-app-suspension');
expect(id).to.to.be.a('number');
expect(powerSaveBlocker.isStarted(id)).to.be.true('is started');
powerSaveBlocker.stop(id);
expect(powerSaveBlocker.isStarted(id)).to.be.false('is started');
});
});

231
spec/api-process-spec.ts Normal file
View file

@ -0,0 +1,231 @@
import * as fs from 'fs';
import * as path from 'path';
import { expect } from 'chai';
import { BrowserWindow } from 'electron';
import { defer, ifdescribe } from './spec-helpers';
import { app } from 'electron/main';
import { closeAllWindows } from './window-helpers';
describe('process module', () => {
describe('renderer process', () => {
let w: BrowserWindow;
before(async () => {
w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
await w.loadURL('about:blank');
});
after(closeAllWindows);
describe('process.getCreationTime()', () => {
it('returns a creation time', async () => {
const creationTime = await w.webContents.executeJavaScript('process.getCreationTime()');
expect(creationTime).to.be.a('number').and.be.at.least(0);
});
});
describe('process.getCPUUsage()', () => {
it('returns a cpu usage object', async () => {
const cpuUsage = await w.webContents.executeJavaScript('process.getCPUUsage()');
expect(cpuUsage.percentCPUUsage).to.be.a('number');
expect(cpuUsage.idleWakeupsPerSecond).to.be.a('number');
});
});
ifdescribe(process.platform !== 'darwin')('process.getIOCounters()', () => {
it('returns an io counters object', async () => {
const ioCounters = await w.webContents.executeJavaScript('process.getIOCounters()');
expect(ioCounters.readOperationCount).to.be.a('number');
expect(ioCounters.writeOperationCount).to.be.a('number');
expect(ioCounters.otherOperationCount).to.be.a('number');
expect(ioCounters.readTransferCount).to.be.a('number');
expect(ioCounters.writeTransferCount).to.be.a('number');
expect(ioCounters.otherTransferCount).to.be.a('number');
});
});
describe('process.getBlinkMemoryInfo()', () => {
it('returns blink memory information object', async () => {
const heapStats = await w.webContents.executeJavaScript('process.getBlinkMemoryInfo()');
expect(heapStats.allocated).to.be.a('number');
expect(heapStats.total).to.be.a('number');
});
});
describe('process.getProcessMemoryInfo()', () => {
it('resolves promise successfully with valid data', async () => {
const memoryInfo = await w.webContents.executeJavaScript('process.getProcessMemoryInfo()');
expect(memoryInfo).to.be.an('object');
if (process.platform === 'linux' || process.platform === 'win32') {
expect(memoryInfo.residentSet).to.be.a('number').greaterThan(0);
}
expect(memoryInfo.private).to.be.a('number').greaterThan(0);
// Shared bytes can be zero
expect(memoryInfo.shared).to.be.a('number').greaterThan(-1);
});
});
describe('process.getSystemMemoryInfo()', () => {
it('returns system memory info object', async () => {
const systemMemoryInfo = await w.webContents.executeJavaScript('process.getSystemMemoryInfo()');
expect(systemMemoryInfo.free).to.be.a('number');
expect(systemMemoryInfo.total).to.be.a('number');
});
});
describe('process.getSystemVersion()', () => {
it('returns a string', async () => {
const systemVersion = await w.webContents.executeJavaScript('process.getSystemVersion()');
expect(systemVersion).to.be.a('string');
});
});
describe('process.getHeapStatistics()', () => {
it('returns heap statistics object', async () => {
const heapStats = await w.webContents.executeJavaScript('process.getHeapStatistics()');
expect(heapStats.totalHeapSize).to.be.a('number');
expect(heapStats.totalHeapSizeExecutable).to.be.a('number');
expect(heapStats.totalPhysicalSize).to.be.a('number');
expect(heapStats.totalAvailableSize).to.be.a('number');
expect(heapStats.usedHeapSize).to.be.a('number');
expect(heapStats.heapSizeLimit).to.be.a('number');
expect(heapStats.mallocedMemory).to.be.a('number');
expect(heapStats.peakMallocedMemory).to.be.a('number');
expect(heapStats.doesZapGarbage).to.be.a('boolean');
});
});
describe('process.takeHeapSnapshot()', () => {
it('returns true on success', async () => {
const filePath = path.join(app.getPath('temp'), 'test.heapsnapshot');
defer(() => {
try {
fs.unlinkSync(filePath);
} catch (e) {
// ignore error
}
});
const success = await w.webContents.executeJavaScript(`process.takeHeapSnapshot(${JSON.stringify(filePath)})`);
expect(success).to.be.true();
const stats = fs.statSync(filePath);
expect(stats.size).not.to.be.equal(0);
});
it('returns false on failure', async () => {
const success = await w.webContents.executeJavaScript('process.takeHeapSnapshot("")');
expect(success).to.be.false();
});
});
describe('process.contextId', () => {
it('is a string', async () => {
const contextId = await w.webContents.executeJavaScript('process.contextId');
expect(contextId).to.be.a('string');
});
});
});
describe('main process', () => {
describe('process.getCreationTime()', () => {
it('returns a creation time', () => {
const creationTime = process.getCreationTime();
expect(creationTime).to.be.a('number').and.be.at.least(0);
});
});
describe('process.getCPUUsage()', () => {
it('returns a cpu usage object', () => {
const cpuUsage = process.getCPUUsage();
expect(cpuUsage.percentCPUUsage).to.be.a('number');
expect(cpuUsage.idleWakeupsPerSecond).to.be.a('number');
});
});
ifdescribe(process.platform !== 'darwin')('process.getIOCounters()', () => {
it('returns an io counters object', () => {
const ioCounters = process.getIOCounters();
expect(ioCounters.readOperationCount).to.be.a('number');
expect(ioCounters.writeOperationCount).to.be.a('number');
expect(ioCounters.otherOperationCount).to.be.a('number');
expect(ioCounters.readTransferCount).to.be.a('number');
expect(ioCounters.writeTransferCount).to.be.a('number');
expect(ioCounters.otherTransferCount).to.be.a('number');
});
});
describe('process.getBlinkMemoryInfo()', () => {
it('returns blink memory information object', () => {
const heapStats = process.getBlinkMemoryInfo();
expect(heapStats.allocated).to.be.a('number');
expect(heapStats.total).to.be.a('number');
});
});
describe('process.getProcessMemoryInfo()', () => {
it('resolves promise successfully with valid data', async () => {
const memoryInfo = await process.getProcessMemoryInfo();
expect(memoryInfo).to.be.an('object');
if (process.platform === 'linux' || process.platform === 'win32') {
expect(memoryInfo.residentSet).to.be.a('number').greaterThan(0);
}
expect(memoryInfo.private).to.be.a('number').greaterThan(0);
// Shared bytes can be zero
expect(memoryInfo.shared).to.be.a('number').greaterThan(-1);
});
});
describe('process.getSystemMemoryInfo()', () => {
it('returns system memory info object', () => {
const systemMemoryInfo = process.getSystemMemoryInfo();
expect(systemMemoryInfo.free).to.be.a('number');
expect(systemMemoryInfo.total).to.be.a('number');
});
});
describe('process.getSystemVersion()', () => {
it('returns a string', () => {
const systemVersion = process.getSystemVersion();
expect(systemVersion).to.be.a('string');
});
});
describe('process.getHeapStatistics()', () => {
it('returns heap statistics object', async () => {
const heapStats = process.getHeapStatistics();
expect(heapStats.totalHeapSize).to.be.a('number');
expect(heapStats.totalHeapSizeExecutable).to.be.a('number');
expect(heapStats.totalPhysicalSize).to.be.a('number');
expect(heapStats.totalAvailableSize).to.be.a('number');
expect(heapStats.usedHeapSize).to.be.a('number');
expect(heapStats.heapSizeLimit).to.be.a('number');
expect(heapStats.mallocedMemory).to.be.a('number');
expect(heapStats.peakMallocedMemory).to.be.a('number');
expect(heapStats.doesZapGarbage).to.be.a('boolean');
});
});
describe('process.takeHeapSnapshot()', () => {
// TODO(nornagon): this seems to take a really long time when run in the
// main process, for unknown reasons.
it.skip('returns true on success', () => {
const filePath = path.join(app.getPath('temp'), 'test.heapsnapshot');
defer(() => {
try {
fs.unlinkSync(filePath);
} catch (e) {
// ignore error
}
});
const success = process.takeHeapSnapshot(filePath);
expect(success).to.be.true();
const stats = fs.statSync(filePath);
expect(stats.size).not.to.be.equal(0);
});
it('returns false on failure', async () => {
const success = process.takeHeapSnapshot('');
expect(success).to.be.false();
});
});
});
});

1049
spec/api-protocol-spec.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,122 @@
import * as cp from 'child_process';
import * as path from 'path';
import { safeStorage } from 'electron/main';
import { expect } from 'chai';
import { emittedOnce } from './events-helpers';
import { ifdescribe } from './spec-helpers';
import * as fs from 'fs';
/* isEncryptionAvailable returns false in Linux when running CI due to a mocked dbus. This stops
* Chrome from reaching the system's keyring or libsecret. When running the tests with config.store
* set to basic-text, a nullptr is returned from chromium, defaulting the available encryption to false.
*
* Because all encryption methods are gated by isEncryptionAvailable, the methods will never return the correct values
* when run on CI and linux.
* Refs: https://github.com/electron/electron/issues/30424.
*/
describe('safeStorage module', () => {
it('safeStorage before and after app is ready', async () => {
const appPath = path.join(__dirname, 'fixtures', 'crash-cases', 'safe-storage');
const appProcess = cp.spawn(process.execPath, [appPath]);
let output = '';
appProcess.stdout.on('data', data => { output += data; });
appProcess.stderr.on('data', data => { output += data; });
const code = (await emittedOnce(appProcess, 'exit'))[0] ?? 1;
if (code !== 0 && output) {
console.log(output);
}
expect(code).to.equal(0);
});
});
ifdescribe(process.platform !== 'linux')('safeStorage module', () => {
after(async () => {
const pathToEncryptedString = path.resolve(__dirname, 'fixtures', 'api', 'safe-storage', 'encrypted.txt');
if (fs.existsSync(pathToEncryptedString)) {
await fs.unlinkSync(pathToEncryptedString);
}
});
describe('SafeStorage.isEncryptionAvailable()', () => {
it('should return true when encryption key is available (macOS, Windows)', () => {
expect(safeStorage.isEncryptionAvailable()).to.equal(true);
});
});
describe('SafeStorage.encryptString()', () => {
it('valid input should correctly encrypt string', () => {
const plaintext = 'plaintext';
const encrypted = safeStorage.encryptString(plaintext);
expect(Buffer.isBuffer(encrypted)).to.equal(true);
});
it('UTF-16 characters can be encrypted', () => {
const plaintext = '€ - utf symbol';
const encrypted = safeStorage.encryptString(plaintext);
expect(Buffer.isBuffer(encrypted)).to.equal(true);
});
});
describe('SafeStorage.decryptString()', () => {
it('valid input should correctly decrypt string', () => {
const encrypted = safeStorage.encryptString('plaintext');
expect(safeStorage.decryptString(encrypted)).to.equal('plaintext');
});
it('UTF-16 characters can be decrypted', () => {
const plaintext = '€ - utf symbol';
const encrypted = safeStorage.encryptString(plaintext);
expect(safeStorage.decryptString(encrypted)).to.equal(plaintext);
});
it('unencrypted input should throw', () => {
const plaintextBuffer = Buffer.from('I am unencoded!', 'utf-8');
expect(() => {
safeStorage.decryptString(plaintextBuffer);
}).to.throw(Error);
});
it('non-buffer input should throw', () => {
const notABuffer = {} as any;
expect(() => {
safeStorage.decryptString(notABuffer);
}).to.throw(Error);
});
});
describe('safeStorage persists encryption key across app relaunch', () => {
it('can decrypt after closing and reopening app', async () => {
const fixturesPath = path.resolve(__dirname, 'fixtures');
const encryptAppPath = path.join(fixturesPath, 'api', 'safe-storage', 'encrypt-app');
const encryptAppProcess = cp.spawn(process.execPath, [encryptAppPath]);
let stdout: string = '';
encryptAppProcess.stderr.on('data', data => { stdout += data; });
encryptAppProcess.stderr.on('data', data => { stdout += data; });
try {
await emittedOnce(encryptAppProcess, 'exit');
const appPath = path.join(fixturesPath, 'api', 'safe-storage', 'decrypt-app');
const relaunchedAppProcess = cp.spawn(process.execPath, [appPath]);
let output = '';
relaunchedAppProcess.stdout.on('data', data => { output += data; });
relaunchedAppProcess.stderr.on('data', data => { output += data; });
const [code] = await emittedOnce(relaunchedAppProcess, 'exit');
if (!output.includes('plaintext')) {
console.log(code, output);
}
expect(output).to.include('plaintext');
} catch (e) {
console.log(stdout);
throw e;
}
});
});
});

91
spec/api-screen-spec.ts Normal file
View file

@ -0,0 +1,91 @@
import { expect } from 'chai';
import { screen } from 'electron/main';
describe('screen module', () => {
describe('methods reassignment', () => {
it('works for a selected method', () => {
const originalFunction = screen.getPrimaryDisplay;
try {
(screen as any).getPrimaryDisplay = () => null;
expect(screen.getPrimaryDisplay()).to.be.null();
} finally {
screen.getPrimaryDisplay = originalFunction;
}
});
});
describe('screen.getCursorScreenPoint()', () => {
it('returns a point object', () => {
const point = screen.getCursorScreenPoint();
expect(point.x).to.be.a('number');
expect(point.y).to.be.a('number');
});
});
describe('screen.getPrimaryDisplay()', () => {
it('returns a display object', () => {
const display = screen.getPrimaryDisplay();
expect(display).to.be.an('object');
});
it('has the correct non-object properties', function () {
if (process.platform === 'linux') this.skip();
const display = screen.getPrimaryDisplay();
expect(display).to.have.property('scaleFactor').that.is.a('number');
expect(display).to.have.property('id').that.is.a('number');
expect(display).to.have.property('rotation').that.is.a('number');
expect(display).to.have.property('touchSupport').that.is.a('string');
expect(display).to.have.property('accelerometerSupport').that.is.a('string');
expect(display).to.have.property('internal').that.is.a('boolean');
expect(display).to.have.property('monochrome').that.is.a('boolean');
expect(display).to.have.property('depthPerComponent').that.is.a('number');
expect(display).to.have.property('colorDepth').that.is.a('number');
expect(display).to.have.property('colorSpace').that.is.a('string');
expect(display).to.have.property('displayFrequency').that.is.a('number');
});
it('has a size object property', function () {
if (process.platform === 'linux') this.skip();
const display = screen.getPrimaryDisplay();
expect(display).to.have.property('size').that.is.an('object');
const size = display.size;
expect(size).to.have.property('width').that.is.greaterThan(0);
expect(size).to.have.property('height').that.is.greaterThan(0);
});
it('has a workAreaSize object property', function () {
if (process.platform === 'linux') this.skip();
const display = screen.getPrimaryDisplay();
expect(display).to.have.property('workAreaSize').that.is.an('object');
const workAreaSize = display.workAreaSize;
expect(workAreaSize).to.have.property('width').that.is.greaterThan(0);
expect(workAreaSize).to.have.property('height').that.is.greaterThan(0);
});
it('has a bounds object property', function () {
if (process.platform === 'linux') this.skip();
const display = screen.getPrimaryDisplay();
expect(display).to.have.property('bounds').that.is.an('object');
const bounds = display.bounds;
expect(bounds).to.have.property('x').that.is.a('number');
expect(bounds).to.have.property('y').that.is.a('number');
expect(bounds).to.have.property('width').that.is.greaterThan(0);
expect(bounds).to.have.property('height').that.is.greaterThan(0);
});
it('has a workArea object property', function () {
const display = screen.getPrimaryDisplay();
expect(display).to.have.property('workArea').that.is.an('object');
const workArea = display.workArea;
expect(workArea).to.have.property('x').that.is.a('number');
expect(workArea).to.have.property('y').that.is.a('number');
expect(workArea).to.have.property('width').that.is.greaterThan(0);
expect(workArea).to.have.property('height').that.is.greaterThan(0);
});
});
});

View file

@ -0,0 +1,95 @@
import * as fs from 'fs';
import * as http from 'http';
import * as path from 'path';
import { session, webContents, WebContents } from 'electron/main';
import { expect } from 'chai';
import { v4 } from 'uuid';
import { AddressInfo } from 'net';
import { emittedOnce, emittedNTimes } from './events-helpers';
const partition = 'service-workers-spec';
describe('session.serviceWorkers', () => {
let ses: Electron.Session;
let server: http.Server;
let baseUrl: string;
let w: WebContents = null as unknown as WebContents;
before(async () => {
ses = session.fromPartition(partition);
await ses.clearStorageData();
});
beforeEach(async () => {
const uuid = v4();
server = http.createServer((req, res) => {
// /{uuid}/{file}
const file = req.url!.split('/')[2]!;
if (file.endsWith('.js')) {
res.setHeader('Content-Type', 'application/javascript');
}
res.end(fs.readFileSync(path.resolve(__dirname, 'fixtures', 'api', 'service-workers', file)));
});
await new Promise<void>(resolve => {
server.listen(0, '127.0.0.1', () => {
baseUrl = `http://localhost:${(server.address() as AddressInfo).port}/${uuid}`;
resolve();
});
});
w = (webContents as any).create({ session: ses });
});
afterEach(async () => {
(w as any).destroy();
server.close();
await ses.clearStorageData();
});
describe('getAllRunning()', () => {
it('should initially report none are running', () => {
expect(ses.serviceWorkers.getAllRunning()).to.deep.equal({});
});
it('should report one as running once you load a page with a service worker', async () => {
await emittedOnce(ses.serviceWorkers, 'console-message', () => w.loadURL(`${baseUrl}/index.html`));
const workers = ses.serviceWorkers.getAllRunning();
const ids = Object.keys(workers) as any[] as number[];
expect(ids).to.have.lengthOf(1, 'should have one worker running');
});
});
describe('getFromVersionID()', () => {
it('should report the correct script url and scope', async () => {
const eventInfo = await emittedOnce(ses.serviceWorkers, 'console-message', () => w.loadURL(`${baseUrl}/index.html`));
const details: Electron.MessageDetails = eventInfo[1];
const worker = ses.serviceWorkers.getFromVersionID(details.versionId);
expect(worker).to.not.equal(null);
expect(worker).to.have.property('scope', baseUrl + '/');
expect(worker).to.have.property('scriptUrl', baseUrl + '/sw.js');
});
});
describe('console-message event', () => {
it('should correctly keep the source, message and level', async () => {
const messages: Record<string, Electron.MessageDetails> = {};
const events = await emittedNTimes(ses.serviceWorkers, 'console-message', 4, () => w.loadURL(`${baseUrl}/logs.html`));
for (const event of events) {
messages[event[1].message] = event[1];
expect(event[1]).to.have.property('source', 'console-api');
}
expect(messages).to.have.property('log log');
expect(messages).to.have.property('info log');
expect(messages).to.have.property('warn log');
expect(messages).to.have.property('error log');
expect(messages['log log']).to.have.property('level', 1);
expect(messages['info log']).to.have.property('level', 1);
expect(messages['warn log']).to.have.property('level', 2);
expect(messages['error log']).to.have.property('level', 3);
});
});
});

1190
spec/api-session-spec.ts Normal file

File diff suppressed because it is too large Load diff

157
spec/api-shell-spec.ts Normal file
View file

@ -0,0 +1,157 @@
import { BrowserWindow, app } from 'electron/main';
import { shell } from 'electron/common';
import { closeAllWindows } from './window-helpers';
import { emittedOnce } from './events-helpers';
import { ifdescribe, ifit } from './spec-helpers';
import * as http from 'http';
import * as fs from 'fs-extra';
import * as os from 'os';
import * as path from 'path';
import { AddressInfo } from 'net';
import { expect } from 'chai';
describe('shell module', () => {
describe('shell.openExternal()', () => {
let envVars: Record<string, string | undefined> = {};
beforeEach(function () {
envVars = {
display: process.env.DISPLAY,
de: process.env.DE,
browser: process.env.BROWSER
};
});
afterEach(async () => {
// reset env vars to prevent side effects
if (process.platform === 'linux') {
process.env.DE = envVars.de;
process.env.BROWSER = envVars.browser;
process.env.DISPLAY = envVars.display;
}
});
afterEach(closeAllWindows);
it('opens an external link', async () => {
let url = 'http://127.0.0.1';
let requestReceived: Promise<any>;
if (process.platform === 'linux') {
process.env.BROWSER = '/bin/true';
process.env.DE = 'generic';
process.env.DISPLAY = '';
requestReceived = Promise.resolve();
} else if (process.platform === 'darwin') {
// On the Mac CI machines, Safari tries to ask for a password to the
// code signing keychain we set up to test code signing (see
// https://github.com/electron/electron/pull/19969#issuecomment-526278890),
// so use a blur event as a crude proxy.
const w = new BrowserWindow({ show: true });
requestReceived = emittedOnce(w, 'blur');
} else {
const server = http.createServer((req, res) => {
res.end();
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
requestReceived = new Promise<void>(resolve => server.on('connection', () => resolve()));
url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
}
await Promise.all<void>([
shell.openExternal(url),
requestReceived
]);
});
});
describe('shell.trashItem()', () => {
afterEach(closeAllWindows);
it('moves an item to the trash', async () => {
const dir = await fs.mkdtemp(path.resolve(app.getPath('temp'), 'electron-shell-spec-'));
const filename = path.join(dir, 'temp-to-be-deleted');
await fs.writeFile(filename, 'dummy-contents');
await shell.trashItem(filename);
expect(fs.existsSync(filename)).to.be.false();
});
it('throws when called with a nonexistent path', async () => {
const filename = path.join(app.getPath('temp'), 'does-not-exist');
await expect(shell.trashItem(filename)).to.eventually.be.rejected();
});
ifit(!(process.platform === 'win32' && process.arch === 'ia32'))('works in the renderer process', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
await expect(w.webContents.executeJavaScript('require(\'electron\').shell.trashItem(\'does-not-exist\')')).to.be.rejectedWith(/does-not-exist|Failed to move item|Failed to create FileOperation/);
});
});
const shortcutOptions = {
target: 'C:\\target',
description: 'description',
cwd: 'cwd',
args: 'args',
appUserModelId: 'appUserModelId',
icon: 'icon',
iconIndex: 1,
toastActivatorClsid: '{0E3CFA27-6FEA-410B-824F-A174B6E865E5}'
};
ifdescribe(process.platform === 'win32')('shell.readShortcutLink(shortcutPath)', () => {
it('throws when failed', () => {
expect(() => {
shell.readShortcutLink('not-exist');
}).to.throw('Failed to read shortcut link');
});
const fixtures = path.resolve(__dirname, 'fixtures');
it('reads all properties of a shortcut', () => {
const shortcut = shell.readShortcutLink(path.join(fixtures, 'assets', 'shortcut.lnk'));
expect(shortcut).to.deep.equal(shortcutOptions);
});
});
ifdescribe(process.platform === 'win32')('shell.writeShortcutLink(shortcutPath[, operation], options)', () => {
const tmpShortcut = path.join(os.tmpdir(), `${Date.now()}.lnk`);
afterEach(() => {
fs.unlinkSync(tmpShortcut);
});
it('writes the shortcut', () => {
expect(shell.writeShortcutLink(tmpShortcut, { target: 'C:\\' })).to.be.true();
expect(fs.existsSync(tmpShortcut)).to.be.true();
});
it('correctly sets the fields', () => {
expect(shell.writeShortcutLink(tmpShortcut, shortcutOptions)).to.be.true();
expect(shell.readShortcutLink(tmpShortcut)).to.deep.equal(shortcutOptions);
});
it('updates the shortcut', () => {
expect(shell.writeShortcutLink(tmpShortcut, 'update', shortcutOptions)).to.be.false();
expect(shell.writeShortcutLink(tmpShortcut, 'create', shortcutOptions)).to.be.true();
expect(shell.readShortcutLink(tmpShortcut)).to.deep.equal(shortcutOptions);
const change = { target: 'D:\\' };
expect(shell.writeShortcutLink(tmpShortcut, 'update', change)).to.be.true();
expect(shell.readShortcutLink(tmpShortcut)).to.deep.equal({ ...shortcutOptions, ...change });
});
it('replaces the shortcut', () => {
expect(shell.writeShortcutLink(tmpShortcut, 'replace', shortcutOptions)).to.be.false();
expect(shell.writeShortcutLink(tmpShortcut, 'create', shortcutOptions)).to.be.true();
expect(shell.readShortcutLink(tmpShortcut)).to.deep.equal(shortcutOptions);
const change = {
target: 'D:\\',
description: 'description2',
cwd: 'cwd2',
args: 'args2',
appUserModelId: 'appUserModelId2',
icon: 'icon2',
iconIndex: 2,
toastActivatorClsid: '{C51A3996-CAD9-4934-848B-16285D4A1496}'
};
expect(shell.writeShortcutLink(tmpShortcut, 'replace', change)).to.be.true();
expect(shell.readShortcutLink(tmpShortcut)).to.deep.equal(change);
});
});
});

291
spec/api-subframe-spec.ts Normal file
View file

@ -0,0 +1,291 @@
import { expect } from 'chai';
import * as path from 'path';
import * as http from 'http';
import { emittedNTimes, emittedOnce } from './events-helpers';
import { closeWindow } from './window-helpers';
import { app, BrowserWindow, ipcMain } from 'electron/main';
import { AddressInfo } from 'net';
import { ifdescribe } from './spec-helpers';
describe('renderer nodeIntegrationInSubFrames', () => {
const generateTests = (description: string, webPreferences: any) => {
describe(description, () => {
const fixtureSuffix = webPreferences.webviewTag ? '-webview' : '';
let w: BrowserWindow;
beforeEach(async () => {
await closeWindow(w);
w = new BrowserWindow({
show: false,
width: 400,
height: 400,
webPreferences
});
});
afterEach(async () => {
await closeWindow(w);
w = null as unknown as BrowserWindow;
});
it('should load preload scripts in top level iframes', async () => {
const detailsPromise = emittedNTimes(ipcMain, 'preload-ran', 2);
w.loadFile(path.resolve(__dirname, `fixtures/sub-frames/frame-container${fixtureSuffix}.html`));
const [event1, event2] = await detailsPromise;
expect(event1[0].frameId).to.not.equal(event2[0].frameId);
expect(event1[0].frameId).to.equal(event1[2]);
expect(event2[0].frameId).to.equal(event2[2]);
expect(event1[0].senderFrame.routingId).to.equal(event1[2]);
expect(event2[0].senderFrame.routingId).to.equal(event2[2]);
});
it('should load preload scripts in nested iframes', async () => {
const detailsPromise = emittedNTimes(ipcMain, 'preload-ran', 3);
w.loadFile(path.resolve(__dirname, `fixtures/sub-frames/frame-with-frame-container${fixtureSuffix}.html`));
const [event1, event2, event3] = await detailsPromise;
expect(event1[0].frameId).to.not.equal(event2[0].frameId);
expect(event1[0].frameId).to.not.equal(event3[0].frameId);
expect(event2[0].frameId).to.not.equal(event3[0].frameId);
expect(event1[0].frameId).to.equal(event1[2]);
expect(event2[0].frameId).to.equal(event2[2]);
expect(event3[0].frameId).to.equal(event3[2]);
expect(event1[0].senderFrame.routingId).to.equal(event1[2]);
expect(event2[0].senderFrame.routingId).to.equal(event2[2]);
expect(event3[0].senderFrame.routingId).to.equal(event3[2]);
});
it('should correctly reply to the main frame with using event.reply', async () => {
const detailsPromise = emittedNTimes(ipcMain, 'preload-ran', 2);
w.loadFile(path.resolve(__dirname, `fixtures/sub-frames/frame-container${fixtureSuffix}.html`));
const [event1] = await detailsPromise;
const pongPromise = emittedOnce(ipcMain, 'preload-pong');
event1[0].reply('preload-ping');
const [, frameId] = await pongPromise;
expect(frameId).to.equal(event1[0].frameId);
});
it('should correctly reply to the main frame with using event.senderFrame.send', async () => {
const detailsPromise = emittedNTimes(ipcMain, 'preload-ran', 2);
w.loadFile(path.resolve(__dirname, `fixtures/sub-frames/frame-container${fixtureSuffix}.html`));
const [event1] = await detailsPromise;
const pongPromise = emittedOnce(ipcMain, 'preload-pong');
event1[0].senderFrame.send('preload-ping');
const [, frameId] = await pongPromise;
expect(frameId).to.equal(event1[0].frameId);
});
it('should correctly reply to the sub-frames with using event.reply', async () => {
const detailsPromise = emittedNTimes(ipcMain, 'preload-ran', 2);
w.loadFile(path.resolve(__dirname, `fixtures/sub-frames/frame-container${fixtureSuffix}.html`));
const [, event2] = await detailsPromise;
const pongPromise = emittedOnce(ipcMain, 'preload-pong');
event2[0].reply('preload-ping');
const [, frameId] = await pongPromise;
expect(frameId).to.equal(event2[0].frameId);
});
it('should correctly reply to the sub-frames with using event.senderFrame.send', async () => {
const detailsPromise = emittedNTimes(ipcMain, 'preload-ran', 2);
w.loadFile(path.resolve(__dirname, `fixtures/sub-frames/frame-container${fixtureSuffix}.html`));
const [, event2] = await detailsPromise;
const pongPromise = emittedOnce(ipcMain, 'preload-pong');
event2[0].senderFrame.send('preload-ping');
const [, frameId] = await pongPromise;
expect(frameId).to.equal(event2[0].frameId);
});
it('should correctly reply to the nested sub-frames with using event.reply', async () => {
const detailsPromise = emittedNTimes(ipcMain, 'preload-ran', 3);
w.loadFile(path.resolve(__dirname, `fixtures/sub-frames/frame-with-frame-container${fixtureSuffix}.html`));
const [, , event3] = await detailsPromise;
const pongPromise = emittedOnce(ipcMain, 'preload-pong');
event3[0].reply('preload-ping');
const [, frameId] = await pongPromise;
expect(frameId).to.equal(event3[0].frameId);
});
it('should correctly reply to the nested sub-frames with using event.senderFrame.send', async () => {
const detailsPromise = emittedNTimes(ipcMain, 'preload-ran', 3);
w.loadFile(path.resolve(__dirname, `fixtures/sub-frames/frame-with-frame-container${fixtureSuffix}.html`));
const [, , event3] = await detailsPromise;
const pongPromise = emittedOnce(ipcMain, 'preload-pong');
event3[0].senderFrame.send('preload-ping');
const [, frameId] = await pongPromise;
expect(frameId).to.equal(event3[0].frameId);
});
it('should not expose globals in main world', async () => {
const detailsPromise = emittedNTimes(ipcMain, 'preload-ran', 2);
w.loadFile(path.resolve(__dirname, `fixtures/sub-frames/frame-container${fixtureSuffix}.html`));
const details = await detailsPromise;
const senders = details.map(event => event[0].sender);
const isolatedGlobals = await Promise.all(senders.map(sender => sender.executeJavaScript('window.isolatedGlobal')));
for (const result of isolatedGlobals) {
if (webPreferences.contextIsolation === undefined || webPreferences.contextIsolation) {
expect(result).to.be.undefined();
} else {
expect(result).to.equal(true);
}
}
});
});
};
const generateConfigs = (webPreferences: any, ...permutations: {name: string, webPreferences: any}[]) => {
const configs = [{ webPreferences, names: [] as string[] }];
for (let i = 0; i < permutations.length; i++) {
const length = configs.length;
for (let j = 0; j < length; j++) {
const newConfig = Object.assign({}, configs[j]);
newConfig.webPreferences = Object.assign({},
newConfig.webPreferences, permutations[i].webPreferences);
newConfig.names = newConfig.names.slice(0);
newConfig.names.push(permutations[i].name);
configs.push(newConfig);
}
}
return configs.map((config: any) => {
if (config.names.length > 0) {
config.title = `with ${config.names.join(', ')} on`;
} else {
config.title = 'without anything special turned on';
}
delete config.names;
return config as {title: string, webPreferences: any};
});
};
generateConfigs(
{
preload: path.resolve(__dirname, 'fixtures/sub-frames/preload.js'),
nodeIntegrationInSubFrames: true
},
{
name: 'sandbox',
webPreferences: { sandbox: true }
},
{
name: 'context isolation disabled',
webPreferences: { contextIsolation: false }
},
{
name: 'webview',
webPreferences: { webviewTag: true, preload: false }
}
).forEach(config => {
generateTests(config.title, config.webPreferences);
});
describe('internal <iframe> inside of <webview>', () => {
let w: BrowserWindow;
beforeEach(async () => {
await closeWindow(w);
w = new BrowserWindow({
show: false,
width: 400,
height: 400,
webPreferences: {
preload: path.resolve(__dirname, 'fixtures/sub-frames/webview-iframe-preload.js'),
nodeIntegrationInSubFrames: true,
webviewTag: true,
contextIsolation: false
}
});
});
afterEach(async () => {
await closeWindow(w);
w = null as unknown as BrowserWindow;
});
it('should not load preload scripts', async () => {
const promisePass = emittedOnce(ipcMain, 'webview-loaded');
const promiseFail = emittedOnce(ipcMain, 'preload-in-frame').then(() => {
throw new Error('preload loaded in internal frame');
});
await w.loadURL('about:blank');
return Promise.race([promisePass, promiseFail]);
});
});
});
// app.getAppMetrics() does not return sandbox information on Linux.
ifdescribe(process.platform !== 'linux')('cross-site frame sandboxing', () => {
let server: http.Server;
let crossSiteUrl: string;
let serverUrl: string;
before(function (done) {
server = http.createServer((req, res) => {
res.end(`<iframe name="frame" src="${crossSiteUrl}" />`);
});
server.listen(0, '127.0.0.1', () => {
serverUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/`;
crossSiteUrl = `http://localhost:${(server.address() as AddressInfo).port}/`;
done();
});
});
after(() => {
server.close();
server = null as unknown as http.Server;
});
let w: BrowserWindow;
afterEach(async () => {
await closeWindow(w);
w = null as unknown as BrowserWindow;
});
const generateSpecs = (description: string, webPreferences: any) => {
describe(description, () => {
it('iframe process is sandboxed if possible', async () => {
w = new BrowserWindow({
show: false,
webPreferences
});
await w.loadURL(serverUrl);
const pidMain = w.webContents.getOSProcessId();
const pidFrame = w.webContents.mainFrame.frames.find(f => f.name === 'frame')!.osProcessId;
const metrics = app.getAppMetrics();
const isProcessSandboxed = function (pid: number) {
const entry = metrics.filter(metric => metric.pid === pid)[0];
return entry && entry.sandboxed;
};
const sandboxMain = !!(webPreferences.sandbox || process.mas);
const sandboxFrame = sandboxMain || !webPreferences.nodeIntegrationInSubFrames;
expect(isProcessSandboxed(pidMain)).to.equal(sandboxMain);
expect(isProcessSandboxed(pidFrame)).to.equal(sandboxFrame);
});
});
};
generateSpecs('nodeIntegrationInSubFrames = false, sandbox = false', {
nodeIntegrationInSubFrames: false,
sandbox: false
});
generateSpecs('nodeIntegrationInSubFrames = false, sandbox = true', {
nodeIntegrationInSubFrames: false,
sandbox: true
});
generateSpecs('nodeIntegrationInSubFrames = true, sandbox = false', {
nodeIntegrationInSubFrames: true,
sandbox: false
});
generateSpecs('nodeIntegrationInSubFrames = true, sandbox = true', {
nodeIntegrationInSubFrames: true,
sandbox: true
});
});

View file

@ -0,0 +1,320 @@
import { expect } from 'chai';
import { systemPreferences } from 'electron/main';
import { ifdescribe } from './spec-helpers';
describe('systemPreferences module', () => {
ifdescribe(process.platform === 'win32')('systemPreferences.getAccentColor', () => {
it('should return a non-empty string', () => {
const accentColor = systemPreferences.getAccentColor();
expect(accentColor).to.be.a('string').that.is.not.empty('accent color');
});
});
ifdescribe(process.platform === 'win32')('systemPreferences.getColor(id)', () => {
it('throws an error when the id is invalid', () => {
expect(() => {
systemPreferences.getColor('not-a-color' as any);
}).to.throw('Unknown color: not-a-color');
});
it('returns a hex RGB color string', () => {
expect(systemPreferences.getColor('window')).to.match(/^#[0-9A-F]{6}$/i);
});
});
ifdescribe(process.platform === 'darwin')('systemPreferences.registerDefaults(defaults)', () => {
it('registers defaults', () => {
const defaultsMap = [
{ key: 'one', type: 'string', value: 'ONE' },
{ key: 'two', value: 2, type: 'integer' },
{ key: 'three', value: [1, 2, 3], type: 'array' }
];
const defaultsDict: Record<string, any> = {};
defaultsMap.forEach(row => { defaultsDict[row.key] = row.value; });
systemPreferences.registerDefaults(defaultsDict);
for (const userDefault of defaultsMap) {
const { key, value: expectedValue, type } = userDefault;
const actualValue = systemPreferences.getUserDefault(key, type as any);
expect(actualValue).to.deep.equal(expectedValue);
}
});
it('throws when bad defaults are passed', () => {
const badDefaults = [
1,
null,
{ one: null }
];
for (const badDefault of badDefaults) {
expect(() => {
systemPreferences.registerDefaults(badDefault as any);
}).to.throw('Error processing argument at index 0, conversion failure from ');
}
});
});
ifdescribe(process.platform === 'darwin')('systemPreferences.getUserDefault(key, type)', () => {
it('returns values for known user defaults', () => {
const locale = systemPreferences.getUserDefault('AppleLocale', 'string');
expect(locale).to.be.a('string').that.is.not.empty('locale');
const languages = systemPreferences.getUserDefault('AppleLanguages', 'array');
expect(languages).to.be.an('array').that.is.not.empty('languages');
});
it('returns values for unknown user defaults', () => {
expect(systemPreferences.getUserDefault('UserDefaultDoesNotExist', 'boolean')).to.equal(false);
expect(systemPreferences.getUserDefault('UserDefaultDoesNotExist', 'integer')).to.equal(0);
expect(systemPreferences.getUserDefault('UserDefaultDoesNotExist', 'float')).to.equal(0);
expect(systemPreferences.getUserDefault('UserDefaultDoesNotExist', 'double')).to.equal(0);
expect(systemPreferences.getUserDefault('UserDefaultDoesNotExist', 'string')).to.equal('');
expect(systemPreferences.getUserDefault('UserDefaultDoesNotExist', 'url')).to.equal('');
expect(systemPreferences.getUserDefault('UserDefaultDoesNotExist', 'badtype' as any)).to.be.undefined('user default');
expect(systemPreferences.getUserDefault('UserDefaultDoesNotExist', 'array')).to.deep.equal([]);
expect(systemPreferences.getUserDefault('UserDefaultDoesNotExist', 'dictionary')).to.deep.equal({});
});
});
ifdescribe(process.platform === 'darwin')('systemPreferences.setUserDefault(key, type, value)', () => {
const KEY = 'SystemPreferencesTest';
const TEST_CASES = [
['string', 'abc'],
['boolean', true],
['float', 2.5],
['double', 10.1],
['integer', 11],
['url', 'https://github.com/electron'],
['array', [1, 2, 3]],
['dictionary', { a: 1, b: 2 }]
];
it('sets values', () => {
for (const [type, value] of TEST_CASES) {
systemPreferences.setUserDefault(KEY, type as any, value as any);
const retrievedValue = systemPreferences.getUserDefault(KEY, type as any);
expect(retrievedValue).to.deep.equal(value);
}
});
it('throws when type and value conflict', () => {
for (const [type, value] of TEST_CASES) {
expect(() => {
systemPreferences.setUserDefault(KEY, type as any, typeof value === 'string' ? {} : 'foo' as any);
}).to.throw(`Unable to convert value to: ${type}`);
}
});
it('throws when type is not valid', () => {
expect(() => {
systemPreferences.setUserDefault(KEY, 'abc' as any, 'foo');
}).to.throw('Invalid type: abc');
});
});
ifdescribe(process.platform === 'darwin')('systemPreferences.subscribeNotification(event, callback)', () => {
it('throws an error if invalid arguments are passed', () => {
const badArgs = [123, {}, ['hi', 'bye'], new Date()];
for (const bad of badArgs) {
expect(() => {
systemPreferences.subscribeNotification(bad as any, () => {});
}).to.throw('Must pass null or a string');
}
});
});
ifdescribe(process.platform === 'darwin')('systemPreferences.subscribeLocalNotification(event, callback)', () => {
it('throws an error if invalid arguments are passed', () => {
const badArgs = [123, {}, ['hi', 'bye'], new Date()];
for (const bad of badArgs) {
expect(() => {
systemPreferences.subscribeNotification(bad as any, () => {});
}).to.throw('Must pass null or a string');
}
});
});
ifdescribe(process.platform === 'darwin')('systemPreferences.subscribeWorkspaceNotification(event, callback)', () => {
it('throws an error if invalid arguments are passed', () => {
const badArgs = [123, {}, ['hi', 'bye'], new Date()];
for (const bad of badArgs) {
expect(() => {
systemPreferences.subscribeWorkspaceNotification(bad as any, () => {});
}).to.throw('Must pass null or a string');
}
});
});
ifdescribe(process.platform === 'darwin')('systemPreferences.getSystemColor(color)', () => {
it('throws on invalid system colors', () => {
const color = 'bad-color';
expect(() => {
systemPreferences.getSystemColor(color as any);
}).to.throw(`Unknown system color: ${color}`);
});
it('returns a valid system color', () => {
const colors = ['blue', 'brown', 'gray', 'green', 'orange', 'pink', 'purple', 'red', 'yellow'];
colors.forEach(color => {
const sysColor = systemPreferences.getSystemColor(color as any);
expect(sysColor).to.be.a('string');
});
});
});
ifdescribe(process.platform === 'darwin')('systemPreferences.getColor(color)', () => {
it('throws on invalid colors', () => {
const color = 'bad-color';
expect(() => {
systemPreferences.getColor(color as any);
}).to.throw(`Unknown color: ${color}`);
});
it('returns a valid color', () => {
const colors = [
'alternate-selected-control-text',
'control-background',
'control',
'control-text',
'disabled-control-text',
'find-highlight',
'grid',
'header-text',
'highlight',
'keyboard-focus-indicator',
'label',
'link',
'placeholder-text',
'quaternary-label',
'scrubber-textured-background',
'secondary-label',
'selected-content-background',
'selected-control',
'selected-control-text',
'selected-menu-item-text',
'selected-text-background',
'selected-text',
'separator',
'shadow',
'tertiary-label',
'text-background',
'text',
'under-page-background',
'unemphasized-selected-content-background',
'unemphasized-selected-text-background',
'unemphasized-selected-text',
'window-background',
'window-frame-text'
];
colors.forEach(color => {
const sysColor = systemPreferences.getColor(color as any);
expect(sysColor).to.be.a('string');
});
});
});
ifdescribe(process.platform === 'darwin')('systemPreferences.appLevelAppearance', () => {
const options = ['dark', 'light', 'unknown', null];
describe('with properties', () => {
it('returns a valid appearance', () => {
const appearance = systemPreferences.appLevelAppearance;
expect(options).to.include(appearance);
});
it('can be changed', () => {
systemPreferences.appLevelAppearance = 'dark';
expect(systemPreferences.appLevelAppearance).to.eql('dark');
});
});
describe('with functions', () => {
it('returns a valid appearance', () => {
const appearance = systemPreferences.getAppLevelAppearance();
expect(options).to.include(appearance);
});
it('can be changed', () => {
systemPreferences.setAppLevelAppearance('dark');
const appearance = systemPreferences.getAppLevelAppearance();
expect(appearance).to.eql('dark');
});
});
});
ifdescribe(process.platform === 'darwin')('systemPreferences.effectiveAppearance', () => {
const options = ['dark', 'light', 'unknown'];
describe('with properties', () => {
it('returns a valid appearance', () => {
const appearance = systemPreferences.effectiveAppearance;
expect(options).to.include(appearance);
});
});
describe('with functions', () => {
it('returns a valid appearance', () => {
const appearance = systemPreferences.getEffectiveAppearance();
expect(options).to.include(appearance);
});
});
});
ifdescribe(process.platform === 'darwin')('systemPreferences.setUserDefault(key, type, value)', () => {
it('removes keys', () => {
const KEY = 'SystemPreferencesTest';
systemPreferences.setUserDefault(KEY, 'string', 'foo');
systemPreferences.removeUserDefault(KEY);
expect(systemPreferences.getUserDefault(KEY, 'string')).to.equal('');
});
it('does not throw for missing keys', () => {
systemPreferences.removeUserDefault('some-missing-key');
});
});
ifdescribe(process.platform === 'darwin')('systemPreferences.canPromptTouchID()', () => {
it('returns a boolean', () => {
expect(systemPreferences.canPromptTouchID()).to.be.a('boolean');
});
});
ifdescribe(process.platform === 'darwin')('systemPreferences.isTrustedAccessibilityClient(prompt)', () => {
it('returns a boolean', () => {
const trusted = systemPreferences.isTrustedAccessibilityClient(false);
expect(trusted).to.be.a('boolean');
});
});
ifdescribe(['win32', 'darwin'].includes(process.platform))('systemPreferences.getMediaAccessStatus(mediaType)', () => {
const statuses = ['not-determined', 'granted', 'denied', 'restricted', 'unknown'];
it('returns an access status for a camera access request', () => {
const cameraStatus = systemPreferences.getMediaAccessStatus('camera');
expect(statuses).to.include(cameraStatus);
});
it('returns an access status for a microphone access request', () => {
const microphoneStatus = systemPreferences.getMediaAccessStatus('microphone');
expect(statuses).to.include(microphoneStatus);
});
it('returns an access status for a screen access request', () => {
const screenStatus = systemPreferences.getMediaAccessStatus('screen');
expect(statuses).to.include(screenStatus);
});
});
describe('systemPreferences.getAnimationSettings()', () => {
it('returns an object with all properties', () => {
const settings = systemPreferences.getAnimationSettings();
expect(settings).to.be.an('object');
expect(settings).to.have.property('shouldRenderRichAnimation').that.is.a('boolean');
expect(settings).to.have.property('scrollAnimationsEnabledBySystem').that.is.a('boolean');
expect(settings).to.have.property('prefersReducedMotion').that.is.a('boolean');
});
});
});

128
spec/api-touch-bar-spec.ts Normal file
View file

@ -0,0 +1,128 @@
import * as path from 'path';
import { BrowserWindow, TouchBar } from 'electron/main';
import { closeWindow } from './window-helpers';
import { expect } from 'chai';
const { TouchBarButton, TouchBarColorPicker, TouchBarGroup, TouchBarLabel, TouchBarOtherItemsProxy, TouchBarPopover, TouchBarScrubber, TouchBarSegmentedControl, TouchBarSlider, TouchBarSpacer } = TouchBar;
describe('TouchBar module', () => {
it('throws an error when created without an options object', () => {
expect(() => {
const touchBar = new (TouchBar as any)();
touchBar.toString();
}).to.throw('Must specify options object as first argument');
});
it('throws an error when created with invalid items', () => {
expect(() => {
const touchBar = new TouchBar({ items: [1, true, {}, []] as any });
touchBar.toString();
}).to.throw('Each item must be an instance of TouchBarItem');
});
it('throws an error when an invalid escape item is set', () => {
expect(() => {
const touchBar = new TouchBar({ items: [], escapeItem: 'esc' as any });
touchBar.toString();
}).to.throw('Escape item must be an instance of TouchBarItem');
expect(() => {
const touchBar = new TouchBar({ items: [] });
touchBar.escapeItem = 'esc' as any;
}).to.throw('Escape item must be an instance of TouchBarItem');
});
it('throws an error if multiple OtherItemProxy items are added', () => {
expect(() => {
const touchBar = new TouchBar({ items: [new TouchBarOtherItemsProxy(), new TouchBarOtherItemsProxy()] });
touchBar.toString();
}).to.throw('Must only have one OtherItemsProxy per TouchBar');
});
it('throws an error if the same TouchBarItem is added multiple times', () => {
expect(() => {
const item = new TouchBarLabel({ label: 'Label' });
const touchBar = new TouchBar({ items: [item, item] });
touchBar.toString();
}).to.throw('Cannot add a single instance of TouchBarItem multiple times in a TouchBar');
});
describe('BrowserWindow behavior', () => {
let window: BrowserWindow;
beforeEach(() => {
window = new BrowserWindow({ show: false });
});
afterEach(async () => {
window.setTouchBar(null);
await closeWindow(window);
window = null as unknown as BrowserWindow;
});
it('can be added to and removed from a window', () => {
const label = new TouchBarLabel({ label: 'bar' });
const touchBar = new TouchBar({
items: [
new TouchBarButton({ label: 'foo', backgroundColor: '#F00', click: () => { } }),
new TouchBarButton({
icon: path.join(__dirname, 'fixtures', 'assets', 'logo.png'),
iconPosition: 'right',
click: () => { }
}),
new TouchBarColorPicker({ selectedColor: '#F00', change: () => { } }),
new TouchBarGroup({ items: new TouchBar({ items: [new TouchBarLabel({ label: 'hello' })] }) }),
label,
new TouchBarOtherItemsProxy(),
new TouchBarPopover({ items: new TouchBar({ items: [new TouchBarButton({ label: 'pop' })] }) }),
new TouchBarSlider({ label: 'slide', value: 5, minValue: 2, maxValue: 75, change: () => { } }),
new TouchBarSpacer({ size: 'large' }),
new TouchBarSegmentedControl({
segmentStyle: 'capsule',
segments: [{ label: 'baz', enabled: false }],
selectedIndex: 5
}),
new TouchBarSegmentedControl({ segments: [] }),
new TouchBarScrubber({
items: [{ label: 'foo' }, { label: 'bar' }, { label: 'baz' }],
selectedStyle: 'outline',
mode: 'fixed',
showArrowButtons: true
})
]
});
const escapeButton = new TouchBarButton({ label: 'foo' });
window.setTouchBar(touchBar);
touchBar.escapeItem = escapeButton;
label.label = 'baz';
escapeButton.label = 'hello';
window.setTouchBar(null);
window.setTouchBar(new TouchBar({ items: [new TouchBarLabel({ label: 'two' })] }));
touchBar.escapeItem = null;
});
it('calls the callback on the items when a window interaction event fires', (done) => {
const button = new TouchBarButton({
label: 'bar',
click: () => {
done();
}
});
const touchBar = new TouchBar({ items: [button] });
window.setTouchBar(touchBar);
window.emit('-touch-bar-interaction', {}, (button as any).id);
});
it('calls the callback on the escape item when a window interaction event fires', (done) => {
const button = new TouchBarButton({
label: 'bar',
click: () => {
done();
}
});
const touchBar = new TouchBar({ escapeItem: button });
window.setTouchBar(touchBar);
window.emit('-touch-bar-interaction', {}, (button as any).id);
});
});
});

229
spec/api-tray-spec.ts Normal file
View file

@ -0,0 +1,229 @@
import { expect } from 'chai';
import { Menu, Tray } from 'electron/main';
import { nativeImage } from 'electron/common';
import { ifdescribe, ifit } from './spec-helpers';
import * as path from 'path';
describe('tray module', () => {
let tray: Tray;
beforeEach(() => { tray = new Tray(nativeImage.createEmpty()); });
afterEach(() => {
tray.destroy();
tray = null as any;
});
describe('new Tray', () => {
it('throws a descriptive error for a missing file', () => {
const badPath = path.resolve('I', 'Do', 'Not', 'Exist');
expect(() => {
tray = new Tray(badPath);
}).to.throw(/Failed to load image from path (.+)/);
});
ifit(process.platform === 'win32')('throws a descriptive error if an invalid guid is given', () => {
expect(() => {
tray = new Tray(nativeImage.createEmpty(), 'I am not a guid');
}).to.throw('Invalid GUID format');
});
ifit(process.platform === 'win32')('accepts a valid guid', () => {
expect(() => {
tray = new Tray(nativeImage.createEmpty(), '0019A433-3526-48BA-A66C-676742C0FEFB');
}).to.not.throw();
});
it('is an instance of Tray', () => {
expect(tray).to.be.an.instanceOf(Tray);
});
});
ifdescribe(process.platform === 'darwin')('tray get/set ignoreDoubleClickEvents', () => {
it('returns false by default', () => {
const ignored = tray.getIgnoreDoubleClickEvents();
expect(ignored).to.be.false('ignored');
});
it('can be set to true', () => {
tray.setIgnoreDoubleClickEvents(true);
const ignored = tray.getIgnoreDoubleClickEvents();
expect(ignored).to.be.true('not ignored');
});
});
describe('tray.setContextMenu(menu)', () => {
it('accepts both null and Menu as parameters', () => {
expect(() => { tray.setContextMenu(new Menu()); }).to.not.throw();
expect(() => { tray.setContextMenu(null); }).to.not.throw();
});
});
describe('tray.destroy()', () => {
it('destroys a tray', () => {
expect(tray.isDestroyed()).to.be.false('tray should not be destroyed');
tray.destroy();
expect(tray.isDestroyed()).to.be.true('tray should be destroyed');
});
});
describe('tray.popUpContextMenu()', () => {
ifit(process.platform === 'win32')('can be called when menu is showing', function (done) {
tray.setContextMenu(Menu.buildFromTemplate([{ label: 'Test' }]));
setTimeout(() => {
tray.popUpContextMenu();
done();
});
tray.popUpContextMenu();
});
it('can be called with a menu', () => {
const menu = Menu.buildFromTemplate([{ label: 'Test' }]);
expect(() => {
tray.popUpContextMenu(menu);
}).to.not.throw();
});
it('can be called with a position', () => {
expect(() => {
tray.popUpContextMenu({ x: 0, y: 0 } as any);
}).to.not.throw();
});
it('can be called with a menu and a position', () => {
const menu = Menu.buildFromTemplate([{ label: 'Test' }]);
expect(() => {
tray.popUpContextMenu(menu, { x: 0, y: 0 });
}).to.not.throw();
});
it('throws an error on invalid arguments', () => {
expect(() => {
tray.popUpContextMenu({} as any);
}).to.throw(/index 0/);
const menu = Menu.buildFromTemplate([{ label: 'Test' }]);
expect(() => {
tray.popUpContextMenu(menu, {} as any);
}).to.throw(/index 1/);
});
});
describe('tray.closeContextMenu()', () => {
ifit(process.platform === 'win32')('does not crash when called more than once', function (done) {
tray.setContextMenu(Menu.buildFromTemplate([{ label: 'Test' }]));
setTimeout(() => {
tray.closeContextMenu();
tray.closeContextMenu();
done();
});
tray.popUpContextMenu();
});
});
describe('tray.getBounds()', () => {
afterEach(() => { tray.destroy(); });
ifit(process.platform !== 'linux')('returns a bounds object', function () {
const bounds = tray.getBounds();
expect(bounds).to.be.an('object').and.to.have.all.keys('x', 'y', 'width', 'height');
});
});
describe('tray.setImage(image)', () => {
it('throws a descriptive error for a missing file', () => {
const badPath = path.resolve('I', 'Do', 'Not', 'Exist');
expect(() => {
tray.setImage(badPath);
}).to.throw(/Failed to load image from path (.+)/);
});
it('accepts empty image', () => {
tray.setImage(nativeImage.createEmpty());
});
});
describe('tray.setPressedImage(image)', () => {
it('throws a descriptive error for a missing file', () => {
const badPath = path.resolve('I', 'Do', 'Not', 'Exist');
expect(() => {
tray.setPressedImage(badPath);
}).to.throw(/Failed to load image from path (.+)/);
});
it('accepts empty image', () => {
tray.setPressedImage(nativeImage.createEmpty());
});
});
ifdescribe(process.platform === 'win32')('tray.displayBalloon(image)', () => {
it('throws a descriptive error for a missing file', () => {
const badPath = path.resolve('I', 'Do', 'Not', 'Exist');
expect(() => {
tray.displayBalloon({
title: 'title',
content: 'wow content',
icon: badPath
});
}).to.throw(/Failed to load image from path (.+)/);
});
it('accepts an empty image', () => {
tray.displayBalloon({
title: 'title',
content: 'wow content',
icon: nativeImage.createEmpty()
});
});
});
ifdescribe(process.platform === 'darwin')('tray get/set title', () => {
it('sets/gets non-empty title', () => {
const title = 'Hello World!';
tray.setTitle(title);
const newTitle = tray.getTitle();
expect(newTitle).to.equal(title);
});
it('sets/gets empty title', () => {
const title = '';
tray.setTitle(title);
const newTitle = tray.getTitle();
expect(newTitle).to.equal(title);
});
it('can have an options object passed in', () => {
expect(() => {
tray.setTitle('Hello World!', {});
}).to.not.throw();
});
it('throws when the options parameter is not an object', () => {
expect(() => {
tray.setTitle('Hello World!', 'test' as any);
}).to.throw(/setTitle options must be an object/);
});
it('can have a font type option set', () => {
expect(() => {
tray.setTitle('Hello World!', { fontType: 'monospaced' });
tray.setTitle('Hello World!', { fontType: 'monospacedDigit' });
}).to.not.throw();
});
it('throws when the font type is specified but is not a string', () => {
expect(() => {
tray.setTitle('Hello World!', { fontType: 5.4 as any });
}).to.throw(/fontType must be one of 'monospaced' or 'monospacedDigit'/);
});
it('throws on invalid font types', () => {
expect(() => {
tray.setTitle('Hello World!', { fontType: 'blep' as any });
}).to.throw(/fontType must be one of 'monospaced' or 'monospacedDigit'/);
});
});
});

15
spec/api-view-spec.ts Normal file
View file

@ -0,0 +1,15 @@
import { closeWindow } from './window-helpers';
import { BaseWindow, View } from 'electron/main';
describe('View', () => {
let w: BaseWindow;
afterEach(async () => {
await closeWindow(w as any);
w = null as unknown as BaseWindow;
});
it('can be used as content view', () => {
w = new BaseWindow({ show: false });
w.setContentView(new View());
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,37 @@
import { closeWindow } from './window-helpers';
import { BaseWindow, WebContentsView } from 'electron/main';
describe('WebContentsView', () => {
let w: BaseWindow;
afterEach(() => closeWindow(w as any).then(() => { w = null as unknown as BaseWindow; }));
it('can be used as content view', () => {
w = new BaseWindow({ show: false });
w.setContentView(new WebContentsView({}));
});
function triggerGCByAllocation () {
const arr = [];
for (let i = 0; i < 1000000; i++) {
arr.push([]);
}
return arr;
}
it('doesn\'t crash when GCed during allocation', (done) => {
// eslint-disable-next-line no-new
new WebContentsView({});
setTimeout(() => {
// NB. the crash we're testing for is the lack of a current `v8::Context`
// when emitting an event in WebContents's destructor. V8 is inconsistent
// about whether or not there's a current context during garbage
// collection, and it seems that `v8Util.requestGarbageCollectionForTesting`
// causes a GC in which there _is_ a current context, so the crash isn't
// triggered. Thus, we force a GC by other means: namely, by allocating a
// bunch of stuff.
triggerGCByAllocation();
done();
});
});
});

View file

@ -0,0 +1,345 @@
import { expect } from 'chai';
import * as http from 'http';
import * as path from 'path';
import * as url from 'url';
import { BrowserWindow, WebFrameMain, webFrameMain, ipcMain } from 'electron/main';
import { closeAllWindows } from './window-helpers';
import { emittedOnce, emittedNTimes } from './events-helpers';
import { AddressInfo } from 'net';
import { ifit, waitUntil } from './spec-helpers';
describe('webFrameMain module', () => {
const fixtures = path.resolve(__dirname, 'fixtures');
const subframesPath = path.join(fixtures, 'sub-frames');
const fileUrl = (filename: string) => url.pathToFileURL(path.join(subframesPath, filename)).href;
type Server = { server: http.Server, url: string }
/** Creates an HTTP server whose handler embeds the given iframe src. */
const createServer = () => new Promise<Server>(resolve => {
const server = http.createServer((req, res) => {
const params = new URLSearchParams(url.parse(req.url || '').search || '');
if (params.has('frameSrc')) {
res.end(`<iframe src="${params.get('frameSrc')}"></iframe>`);
} else {
res.end('');
}
});
server.listen(0, '127.0.0.1', () => {
const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/`;
resolve({ server, url });
});
});
afterEach(closeAllWindows);
describe('WebFrame traversal APIs', () => {
let w: BrowserWindow;
let webFrame: WebFrameMain;
beforeEach(async () => {
w = new BrowserWindow({ show: false, webPreferences: { contextIsolation: true } });
await w.loadFile(path.join(subframesPath, 'frame-with-frame-container.html'));
webFrame = w.webContents.mainFrame;
});
it('can access top frame', () => {
expect(webFrame.top).to.equal(webFrame);
});
it('has no parent on top frame', () => {
expect(webFrame.parent).to.be.null();
});
it('can access immediate frame descendents', () => {
const { frames } = webFrame;
expect(frames).to.have.lengthOf(1);
const subframe = frames[0];
expect(subframe).not.to.equal(webFrame);
expect(subframe.parent).to.equal(webFrame);
});
it('can access deeply nested frames', () => {
const subframe = webFrame.frames[0];
expect(subframe).not.to.equal(webFrame);
expect(subframe.parent).to.equal(webFrame);
const nestedSubframe = subframe.frames[0];
expect(nestedSubframe).not.to.equal(webFrame);
expect(nestedSubframe).not.to.equal(subframe);
expect(nestedSubframe.parent).to.equal(subframe);
});
it('can traverse all frames in root', () => {
const urls = webFrame.framesInSubtree.map(frame => frame.url);
expect(urls).to.deep.equal([
fileUrl('frame-with-frame-container.html'),
fileUrl('frame-with-frame.html'),
fileUrl('frame.html')
]);
});
it('can traverse all frames in subtree', () => {
const urls = webFrame.frames[0].framesInSubtree.map(frame => frame.url);
expect(urls).to.deep.equal([
fileUrl('frame-with-frame.html'),
fileUrl('frame.html')
]);
});
describe('cross-origin', () => {
let serverA = null as unknown as Server;
let serverB = null as unknown as Server;
before(async () => {
serverA = await createServer();
serverB = await createServer();
});
after(() => {
serverA.server.close();
serverB.server.close();
});
it('can access cross-origin frames', async () => {
await w.loadURL(`${serverA.url}?frameSrc=${serverB.url}`);
webFrame = w.webContents.mainFrame;
expect(webFrame.url.startsWith(serverA.url)).to.be.true();
expect(webFrame.frames[0].url).to.equal(serverB.url);
});
});
});
describe('WebFrame.url', () => {
it('should report correct address for each subframe', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { contextIsolation: true } });
await w.loadFile(path.join(subframesPath, 'frame-with-frame-container.html'));
const webFrame = w.webContents.mainFrame;
expect(webFrame.url).to.equal(fileUrl('frame-with-frame-container.html'));
expect(webFrame.frames[0].url).to.equal(fileUrl('frame-with-frame.html'));
expect(webFrame.frames[0].frames[0].url).to.equal(fileUrl('frame.html'));
});
});
describe('WebFrame IDs', () => {
it('has properties for various identifiers', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { contextIsolation: true } });
await w.loadFile(path.join(subframesPath, 'frame.html'));
const webFrame = w.webContents.mainFrame;
expect(webFrame).to.have.ownProperty('url').that.is.a('string');
expect(webFrame).to.have.ownProperty('frameTreeNodeId').that.is.a('number');
expect(webFrame).to.have.ownProperty('name').that.is.a('string');
expect(webFrame).to.have.ownProperty('osProcessId').that.is.a('number');
expect(webFrame).to.have.ownProperty('processId').that.is.a('number');
expect(webFrame).to.have.ownProperty('routingId').that.is.a('number');
});
});
describe('WebFrame.visibilityState', () => {
// TODO(MarshallOfSound): Fix flaky test
// @flaky-test
it.skip('should match window state', async () => {
const w = new BrowserWindow({ show: true });
await w.loadURL('about:blank');
const webFrame = w.webContents.mainFrame;
expect(webFrame.visibilityState).to.equal('visible');
w.hide();
await expect(
waitUntil(() => webFrame.visibilityState === 'hidden')
).to.eventually.be.fulfilled();
});
});
describe('WebFrame.executeJavaScript', () => {
it('can inject code into any subframe', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { contextIsolation: true } });
await w.loadFile(path.join(subframesPath, 'frame-with-frame-container.html'));
const webFrame = w.webContents.mainFrame;
const getUrl = (frame: WebFrameMain) => frame.executeJavaScript('location.href');
expect(await getUrl(webFrame)).to.equal(fileUrl('frame-with-frame-container.html'));
expect(await getUrl(webFrame.frames[0])).to.equal(fileUrl('frame-with-frame.html'));
expect(await getUrl(webFrame.frames[0].frames[0])).to.equal(fileUrl('frame.html'));
});
});
describe('WebFrame.reload', () => {
it('reloads a frame', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { contextIsolation: true } });
await w.loadFile(path.join(subframesPath, 'frame.html'));
const webFrame = w.webContents.mainFrame;
await webFrame.executeJavaScript('window.TEMP = 1', false);
expect(webFrame.reload()).to.be.true();
await emittedOnce(w.webContents, 'dom-ready');
expect(await webFrame.executeJavaScript('window.TEMP', false)).to.be.null();
});
});
describe('WebFrame.send', () => {
it('works', async () => {
const w = new BrowserWindow({
show: false,
webPreferences: {
preload: path.join(subframesPath, 'preload.js'),
nodeIntegrationInSubFrames: true
}
});
await w.loadURL('about:blank');
const webFrame = w.webContents.mainFrame;
const pongPromise = emittedOnce(ipcMain, 'preload-pong');
webFrame.send('preload-ping');
const [, routingId] = await pongPromise;
expect(routingId).to.equal(webFrame.routingId);
});
});
describe('RenderFrame lifespan', () => {
let w: BrowserWindow;
beforeEach(async () => {
w = new BrowserWindow({ show: false, webPreferences: { contextIsolation: true } });
});
// TODO(jkleinsc) fix this flaky test on linux
ifit(process.platform !== 'linux')('throws upon accessing properties when disposed', async () => {
await w.loadFile(path.join(subframesPath, 'frame-with-frame-container.html'));
const { mainFrame } = w.webContents;
w.destroy();
// Wait for WebContents, and thus RenderFrameHost, to be destroyed.
await new Promise(resolve => setTimeout(resolve, 0));
expect(() => mainFrame.url).to.throw();
});
it('persists through cross-origin navigation', async () => {
const server = await createServer();
// 'localhost' is treated as a separate origin.
const crossOriginUrl = server.url.replace('127.0.0.1', 'localhost');
await w.loadURL(server.url);
const { mainFrame } = w.webContents;
expect(mainFrame.url).to.equal(server.url);
await w.loadURL(crossOriginUrl);
expect(w.webContents.mainFrame).to.equal(mainFrame);
expect(mainFrame.url).to.equal(crossOriginUrl);
});
it('recovers from renderer crash on same-origin', async () => {
const server = await createServer();
// Keep reference to mainFrame alive throughout crash and recovery.
const { mainFrame } = w.webContents;
await w.webContents.loadURL(server.url);
const crashEvent = emittedOnce(w.webContents, 'render-process-gone');
w.webContents.forcefullyCrashRenderer();
await crashEvent;
await w.webContents.loadURL(server.url);
// Log just to keep mainFrame in scope.
console.log('mainFrame.url', mainFrame.url);
});
// Fixed by #34411
it('recovers from renderer crash on cross-origin', async () => {
const server = await createServer();
// 'localhost' is treated as a separate origin.
const crossOriginUrl = server.url.replace('127.0.0.1', 'localhost');
// Keep reference to mainFrame alive throughout crash and recovery.
const { mainFrame } = w.webContents;
await w.webContents.loadURL(server.url);
const crashEvent = emittedOnce(w.webContents, 'render-process-gone');
w.webContents.forcefullyCrashRenderer();
await crashEvent;
// A short wait seems to be required to reproduce the crash.
await new Promise(resolve => setTimeout(resolve, 100));
await w.webContents.loadURL(crossOriginUrl);
// Log just to keep mainFrame in scope.
console.log('mainFrame.url', mainFrame.url);
});
});
describe('webFrameMain.fromId', () => {
it('returns undefined for unknown IDs', () => {
expect(webFrameMain.fromId(0, 0)).to.be.undefined();
});
it('can find each frame from navigation events', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { contextIsolation: true } });
// frame-with-frame-container.html, frame-with-frame.html, frame.html
const didFrameFinishLoad = emittedNTimes(w.webContents, 'did-frame-finish-load', 3);
w.loadFile(path.join(subframesPath, 'frame-with-frame-container.html'));
for (const [, isMainFrame, frameProcessId, frameRoutingId] of await didFrameFinishLoad) {
const frame = webFrameMain.fromId(frameProcessId, frameRoutingId);
expect(frame).not.to.be.null();
expect(frame?.processId).to.be.equal(frameProcessId);
expect(frame?.routingId).to.be.equal(frameRoutingId);
expect(frame?.top === frame).to.be.equal(isMainFrame);
}
});
});
describe('"frame-created" event', () => {
it('emits when the main frame is created', async () => {
const w = new BrowserWindow({ show: false });
const promise = emittedOnce(w.webContents, 'frame-created');
w.webContents.loadFile(path.join(subframesPath, 'frame.html'));
const [, details] = await promise;
expect(details.frame).to.equal(w.webContents.mainFrame);
});
it('emits when nested frames are created', async () => {
const w = new BrowserWindow({ show: false });
const promise = emittedNTimes(w.webContents, 'frame-created', 2);
w.webContents.loadFile(path.join(subframesPath, 'frame-container.html'));
const [[, mainDetails], [, nestedDetails]] = await promise;
expect(mainDetails.frame).to.equal(w.webContents.mainFrame);
expect(nestedDetails.frame).to.equal(w.webContents.mainFrame.frames[0]);
});
it('is not emitted upon cross-origin navigation', async () => {
const server = await createServer();
// HACK: Use 'localhost' instead of '127.0.0.1' so Chromium treats it as
// a separate origin because differing ports aren't enough 🤔
const secondUrl = `http://localhost:${new URL(server.url).port}`;
const w = new BrowserWindow({ show: false });
await w.webContents.loadURL(server.url);
let frameCreatedEmitted = false;
w.webContents.once('frame-created', () => {
frameCreatedEmitted = true;
});
await w.webContents.loadURL(secondUrl);
expect(frameCreatedEmitted).to.be.false();
});
});
describe('"dom-ready" event', () => {
it('emits for top-level frame', async () => {
const w = new BrowserWindow({ show: false });
const promise = emittedOnce(w.webContents.mainFrame, 'dom-ready');
w.webContents.loadURL('about:blank');
await promise;
});
it('emits for sub frame', async () => {
const w = new BrowserWindow({ show: false });
const promise = new Promise<void>(resolve => {
w.webContents.on('frame-created', (e, { frame }) => {
frame.on('dom-ready', () => {
if (frame.name === 'frameA') {
resolve();
}
});
});
});
w.webContents.loadFile(path.join(subframesPath, 'frame-with-frame.html'));
await promise;
});
});
});

196
spec/api-web-frame-spec.ts Normal file
View file

@ -0,0 +1,196 @@
import { expect } from 'chai';
import * as path from 'path';
import { BrowserWindow, ipcMain, WebContents } from 'electron/main';
import { emittedOnce } from './events-helpers';
import { defer } from './spec-helpers';
describe('webFrame module', () => {
const fixtures = path.resolve(__dirname, 'fixtures');
it('can use executeJavaScript', async () => {
const w = new BrowserWindow({
show: false,
webPreferences: {
nodeIntegration: true,
contextIsolation: true,
preload: path.join(fixtures, 'pages', 'world-safe-preload.js')
}
});
defer(() => w.close());
const isSafe = emittedOnce(ipcMain, 'executejs-safe');
w.loadURL('about:blank');
const [, wasSafe] = await isSafe;
expect(wasSafe).to.equal(true);
});
it('can use executeJavaScript and catch conversion errors', async () => {
const w = new BrowserWindow({
show: false,
webPreferences: {
nodeIntegration: true,
contextIsolation: true,
preload: path.join(fixtures, 'pages', 'world-safe-preload-error.js')
}
});
defer(() => w.close());
const execError = emittedOnce(ipcMain, 'executejs-safe');
w.loadURL('about:blank');
const [, error] = await execError;
expect(error).to.not.equal(null, 'Error should not be null');
expect(error).to.have.property('message', 'Uncaught Error: An object could not be cloned.');
});
it('calls a spellcheck provider', async () => {
const w = new BrowserWindow({
show: false,
webPreferences: {
nodeIntegration: true,
contextIsolation: false
}
});
defer(() => w.close());
await w.loadFile(path.join(fixtures, 'pages', 'webframe-spell-check.html'));
w.focus();
await w.webContents.executeJavaScript('document.querySelector("input").focus()', true);
const spellCheckerFeedback =
new Promise<[string[], boolean]>(resolve => {
ipcMain.on('spec-spell-check', (e, words, callbackDefined) => {
if (words.length === 5) {
// The API calls the provider after every completed word.
// The promise is resolved only after this event is received with all words.
resolve([words, callbackDefined]);
}
});
});
const inputText = 'spleling test you\'re ';
for (const keyCode of inputText) {
w.webContents.sendInputEvent({ type: 'char', keyCode });
}
const [words, callbackDefined] = await spellCheckerFeedback;
expect(words.sort()).to.deep.equal(['spleling', 'test', 'you\'re', 'you', 're'].sort());
expect(callbackDefined).to.be.true();
});
describe('api', () => {
let w: WebContents;
before(async () => {
const win = new BrowserWindow({ show: false, webPreferences: { contextIsolation: false, nodeIntegration: true } });
await win.loadURL('about:blank');
w = win.webContents;
await w.executeJavaScript('webFrame = require(\'electron\').webFrame; null');
});
it('top is self for top frame', async () => {
const equal = await w.executeJavaScript('webFrame.top.context === webFrame.context');
expect(equal).to.be.true();
});
it('opener is null for top frame', async () => {
const equal = await w.executeJavaScript('webFrame.opener === null');
expect(equal).to.be.true();
});
it('firstChild is null for top frame', async () => {
const equal = await w.executeJavaScript('webFrame.firstChild === null');
expect(equal).to.be.true();
});
it('getFrameForSelector() does not crash when not found', async () => {
const equal = await w.executeJavaScript('webFrame.getFrameForSelector(\'unexist-selector\') === null');
expect(equal).to.be.true();
});
it('findFrameByName() does not crash when not found', async () => {
const equal = await w.executeJavaScript('webFrame.findFrameByName(\'unexist-name\') === null');
expect(equal).to.be.true();
});
it('findFrameByRoutingId() does not crash when not found', async () => {
const equal = await w.executeJavaScript('webFrame.findFrameByRoutingId(-1) === null');
expect(equal).to.be.true();
});
describe('executeJavaScript', () => {
before(() => {
w.executeJavaScript(`
childFrameElement = document.createElement('iframe');
document.body.appendChild(childFrameElement);
childFrame = webFrame.firstChild;
null
`);
});
after(() => {
w.executeJavaScript(`
childFrameElement.remove();
null
`);
});
it('executeJavaScript() yields results via a promise and a sync callback', async () => {
const { callbackResult, callbackError, result } = await w.executeJavaScript(`new Promise(resolve => {
let callbackResult, callbackError;
childFrame
.executeJavaScript('1 + 1', (result, error) => {
callbackResult = result;
callbackError = error;
}).then(result => resolve({callbackResult, callbackError, result}))
})`);
expect(callbackResult).to.equal(2);
expect(callbackError).to.be.undefined();
expect(result).to.equal(2);
});
it('executeJavaScriptInIsolatedWorld() yields results via a promise and a sync callback', async () => {
const { callbackResult, callbackError, result } = await w.executeJavaScript(`new Promise(resolve => {
let callbackResult, callbackError;
childFrame
.executeJavaScriptInIsolatedWorld(999, [{code: '1 + 1'}], (result, error) => {
callbackResult = result;
callbackError = error;
}).then(result => resolve({callbackResult, callbackError, result}))
})`);
expect(callbackResult).to.equal(2);
expect(callbackError).to.be.undefined();
expect(result).to.equal(2);
});
it('executeJavaScript() yields errors via a promise and a sync callback', async () => {
const { callbackResult, callbackError, error } = await w.executeJavaScript(`new Promise(resolve => {
let callbackResult, callbackError;
childFrame
.executeJavaScript('thisShouldProduceAnError()', (result, error) => {
callbackResult = result;
callbackError = error;
}).then(result => {throw new Error}, error => resolve({callbackResult, callbackError, error}))
})`);
expect(callbackResult).to.be.undefined();
expect(callbackError).to.be.an('error');
expect(error).to.be.an('error');
});
it('executeJavaScriptInIsolatedWorld() yields errors via a promise and a sync callback', async () => {
const { callbackResult, callbackError, error } = await w.executeJavaScript(`new Promise(resolve => {
let callbackResult, callbackError;
childFrame
.executeJavaScriptInIsolatedWorld(999, [{code: 'thisShouldProduceAnError()'}], (result, error) => {
callbackResult = result;
callbackError = error;
}).then(result => {throw new Error}, error => resolve({callbackResult, callbackError, error}))
})`);
expect(callbackResult).to.be.undefined();
expect(callbackError).to.be.an('error');
expect(error).to.be.an('error');
});
it('executeJavaScript(InIsolatedWorld) can be used without a callback', async () => {
expect(await w.executeJavaScript('webFrame.executeJavaScript(\'1 + 1\')')).to.equal(2);
expect(await w.executeJavaScript('webFrame.executeJavaScriptInIsolatedWorld(999, [{code: \'1 + 1\'}])')).to.equal(2);
});
});
});
});

View file

@ -0,0 +1,560 @@
import { expect } from 'chai';
import * as http from 'http';
import * as qs from 'querystring';
import * as path from 'path';
import * as url from 'url';
import * as WebSocket from 'ws';
import { ipcMain, protocol, session, WebContents, webContents } from 'electron/main';
import { AddressInfo, Socket } from 'net';
import { emittedOnce } from './events-helpers';
const fixturesPath = path.resolve(__dirname, 'fixtures');
describe('webRequest module', () => {
const ses = session.defaultSession;
const server = http.createServer((req, res) => {
if (req.url === '/serverRedirect') {
res.statusCode = 301;
res.setHeader('Location', 'http://' + req.rawHeaders[1]);
res.end();
} else if (req.url === '/contentDisposition') {
res.setHeader('content-disposition', [' attachment; filename=aa%E4%B8%ADaa.txt']);
const content = req.url;
res.end(content);
} else {
res.setHeader('Custom', ['Header']);
let content = req.url;
if (req.headers.accept === '*/*;test/header') {
content += 'header/received';
}
if (req.headers.origin === 'http://new-origin') {
content += 'new/origin';
}
res.end(content);
}
});
let defaultURL: string;
before((done) => {
protocol.registerStringProtocol('cors', (req, cb) => cb(''));
server.listen(0, '127.0.0.1', () => {
const port = (server.address() as AddressInfo).port;
defaultURL = `http://127.0.0.1:${port}/`;
done();
});
});
after(() => {
server.close();
protocol.unregisterProtocol('cors');
});
let contents: WebContents = null as unknown as WebContents;
// NB. sandbox: true is used because it makes navigations much (~8x) faster.
before(async () => {
contents = (webContents as any).create({ sandbox: true });
await contents.loadFile(path.join(fixturesPath, 'pages', 'fetch.html'));
});
after(() => (contents as any).destroy());
async function ajax (url: string, options = {}) {
return contents.executeJavaScript(`ajax("${url}", ${JSON.stringify(options)})`);
}
describe('webRequest.onBeforeRequest', () => {
afterEach(() => {
ses.webRequest.onBeforeRequest(null);
});
it('can cancel the request', async () => {
ses.webRequest.onBeforeRequest((details, callback) => {
callback({
cancel: true
});
});
await expect(ajax(defaultURL)).to.eventually.be.rejected();
});
it('can filter URLs', async () => {
const filter = { urls: [defaultURL + 'filter/*'] };
ses.webRequest.onBeforeRequest(filter, (details, callback) => {
callback({ cancel: true });
});
const { data } = await ajax(`${defaultURL}nofilter/test`);
expect(data).to.equal('/nofilter/test');
await expect(ajax(`${defaultURL}filter/test`)).to.eventually.be.rejected();
});
it('receives details object', async () => {
ses.webRequest.onBeforeRequest((details, callback) => {
expect(details.id).to.be.a('number');
expect(details.timestamp).to.be.a('number');
expect(details.webContentsId).to.be.a('number');
expect(details.webContents).to.be.an('object');
expect(details.webContents!.id).to.equal(details.webContentsId);
expect(details.frame).to.be.an('object');
expect(details.url).to.be.a('string').that.is.equal(defaultURL);
expect(details.method).to.be.a('string').that.is.equal('GET');
expect(details.resourceType).to.be.a('string').that.is.equal('xhr');
expect(details.uploadData).to.be.undefined();
callback({});
});
const { data } = await ajax(defaultURL);
expect(data).to.equal('/');
});
it('receives post data in details object', async () => {
const postData = {
name: 'post test',
type: 'string'
};
ses.webRequest.onBeforeRequest((details, callback) => {
expect(details.url).to.equal(defaultURL);
expect(details.method).to.equal('POST');
expect(details.uploadData).to.have.lengthOf(1);
const data = qs.parse(details.uploadData[0].bytes.toString());
expect(data).to.deep.equal(postData);
callback({ cancel: true });
});
await expect(ajax(defaultURL, {
method: 'POST',
body: qs.stringify(postData)
})).to.eventually.be.rejected();
});
it('can redirect the request', async () => {
ses.webRequest.onBeforeRequest((details, callback) => {
if (details.url === defaultURL) {
callback({ redirectURL: `${defaultURL}redirect` });
} else {
callback({});
}
});
const { data } = await ajax(defaultURL);
expect(data).to.equal('/redirect');
});
it('does not crash for redirects', async () => {
ses.webRequest.onBeforeRequest((details, callback) => {
callback({ cancel: false });
});
await ajax(defaultURL + 'serverRedirect');
await ajax(defaultURL + 'serverRedirect');
});
it('works with file:// protocol', async () => {
ses.webRequest.onBeforeRequest((details, callback) => {
callback({ cancel: true });
});
const fileURL = url.format({
pathname: path.join(fixturesPath, 'blank.html').replace(/\\/g, '/'),
protocol: 'file',
slashes: true
});
await expect(ajax(fileURL)).to.eventually.be.rejected();
});
});
describe('webRequest.onBeforeSendHeaders', () => {
afterEach(() => {
ses.webRequest.onBeforeSendHeaders(null);
ses.webRequest.onSendHeaders(null);
});
it('receives details object', async () => {
ses.webRequest.onBeforeSendHeaders((details, callback) => {
expect(details.requestHeaders).to.be.an('object');
expect(details.requestHeaders['Foo.Bar']).to.equal('baz');
callback({});
});
const { data } = await ajax(defaultURL, { headers: { 'Foo.Bar': 'baz' } });
expect(data).to.equal('/');
});
it('can change the request headers', async () => {
ses.webRequest.onBeforeSendHeaders((details, callback) => {
const requestHeaders = details.requestHeaders;
requestHeaders.Accept = '*/*;test/header';
callback({ requestHeaders: requestHeaders });
});
const { data } = await ajax(defaultURL);
expect(data).to.equal('/header/received');
});
it('can change the request headers on a custom protocol redirect', async () => {
protocol.registerStringProtocol('no-cors', (req, callback) => {
if (req.url === 'no-cors://fake-host/redirect') {
callback({
statusCode: 302,
headers: {
Location: 'no-cors://fake-host'
}
});
} else {
let content = '';
if (req.headers.Accept === '*/*;test/header') {
content = 'header-received';
}
callback(content);
}
});
// Note that we need to do navigation every time after a protocol is
// registered or unregistered, otherwise the new protocol won't be
// recognized by current page when NetworkService is used.
await contents.loadFile(path.join(__dirname, 'fixtures', 'pages', 'fetch.html'));
try {
ses.webRequest.onBeforeSendHeaders((details, callback) => {
const requestHeaders = details.requestHeaders;
requestHeaders.Accept = '*/*;test/header';
callback({ requestHeaders: requestHeaders });
});
const { data } = await ajax('no-cors://fake-host/redirect');
expect(data).to.equal('header-received');
} finally {
protocol.unregisterProtocol('no-cors');
}
});
it('can change request origin', async () => {
ses.webRequest.onBeforeSendHeaders((details, callback) => {
const requestHeaders = details.requestHeaders;
requestHeaders.Origin = 'http://new-origin';
callback({ requestHeaders: requestHeaders });
});
const { data } = await ajax(defaultURL);
expect(data).to.equal('/new/origin');
});
it('can capture CORS requests', async () => {
let called = false;
ses.webRequest.onBeforeSendHeaders((details, callback) => {
called = true;
callback({ requestHeaders: details.requestHeaders });
});
await ajax('cors://host');
expect(called).to.be.true();
});
it('resets the whole headers', async () => {
const requestHeaders = {
Test: 'header'
};
ses.webRequest.onBeforeSendHeaders((details, callback) => {
callback({ requestHeaders: requestHeaders });
});
ses.webRequest.onSendHeaders((details) => {
expect(details.requestHeaders).to.deep.equal(requestHeaders);
});
await ajax(defaultURL);
});
it('leaves headers unchanged when no requestHeaders in callback', async () => {
let originalRequestHeaders: Record<string, string>;
ses.webRequest.onBeforeSendHeaders((details, callback) => {
originalRequestHeaders = details.requestHeaders;
callback({});
});
ses.webRequest.onSendHeaders((details) => {
expect(details.requestHeaders).to.deep.equal(originalRequestHeaders);
});
await ajax(defaultURL);
});
it('works with file:// protocol', async () => {
const requestHeaders = {
Test: 'header'
};
let onSendHeadersCalled = false;
ses.webRequest.onBeforeSendHeaders((details, callback) => {
callback({ requestHeaders: requestHeaders });
});
ses.webRequest.onSendHeaders((details) => {
expect(details.requestHeaders).to.deep.equal(requestHeaders);
onSendHeadersCalled = true;
});
await ajax(url.format({
pathname: path.join(fixturesPath, 'blank.html').replace(/\\/g, '/'),
protocol: 'file',
slashes: true
}));
expect(onSendHeadersCalled).to.be.true();
});
});
describe('webRequest.onSendHeaders', () => {
afterEach(() => {
ses.webRequest.onSendHeaders(null);
});
it('receives details object', async () => {
ses.webRequest.onSendHeaders((details) => {
expect(details.requestHeaders).to.be.an('object');
});
const { data } = await ajax(defaultURL);
expect(data).to.equal('/');
});
});
describe('webRequest.onHeadersReceived', () => {
afterEach(() => {
ses.webRequest.onHeadersReceived(null);
});
it('receives details object', async () => {
ses.webRequest.onHeadersReceived((details, callback) => {
expect(details.statusLine).to.equal('HTTP/1.1 200 OK');
expect(details.statusCode).to.equal(200);
expect(details.responseHeaders!.Custom).to.deep.equal(['Header']);
callback({});
});
const { data } = await ajax(defaultURL);
expect(data).to.equal('/');
});
it('can change the response header', async () => {
ses.webRequest.onHeadersReceived((details, callback) => {
const responseHeaders = details.responseHeaders!;
responseHeaders.Custom = ['Changed'] as any;
callback({ responseHeaders: responseHeaders });
});
const { headers } = await ajax(defaultURL);
expect(headers).to.to.have.property('custom', 'Changed');
});
it('can change response origin', async () => {
ses.webRequest.onHeadersReceived((details, callback) => {
const responseHeaders = details.responseHeaders!;
responseHeaders['access-control-allow-origin'] = ['http://new-origin'] as any;
callback({ responseHeaders: responseHeaders });
});
const { headers } = await ajax(defaultURL);
expect(headers).to.to.have.property('access-control-allow-origin', 'http://new-origin');
});
it('can change headers of CORS responses', async () => {
ses.webRequest.onHeadersReceived((details, callback) => {
const responseHeaders = details.responseHeaders!;
responseHeaders.Custom = ['Changed'] as any;
callback({ responseHeaders: responseHeaders });
});
const { headers } = await ajax('cors://host');
expect(headers).to.to.have.property('custom', 'Changed');
});
it('does not change header by default', async () => {
ses.webRequest.onHeadersReceived((details, callback) => {
callback({});
});
const { data, headers } = await ajax(defaultURL);
expect(headers).to.to.have.property('custom', 'Header');
expect(data).to.equal('/');
});
it('does not change content-disposition header by default', async () => {
ses.webRequest.onHeadersReceived((details, callback) => {
expect(details.responseHeaders!['content-disposition']).to.deep.equal([' attachment; filename="aa中aa.txt"']);
callback({});
});
const { data, headers } = await ajax(defaultURL + 'contentDisposition');
expect(headers).to.to.have.property('content-disposition', 'attachment; filename=aa%E4%B8%ADaa.txt');
expect(data).to.equal('/contentDisposition');
});
it('follows server redirect', async () => {
ses.webRequest.onHeadersReceived((details, callback) => {
const responseHeaders = details.responseHeaders;
callback({ responseHeaders: responseHeaders });
});
const { headers } = await ajax(defaultURL + 'serverRedirect');
expect(headers).to.to.have.property('custom', 'Header');
});
it('can change the header status', async () => {
ses.webRequest.onHeadersReceived((details, callback) => {
const responseHeaders = details.responseHeaders;
callback({
responseHeaders: responseHeaders,
statusLine: 'HTTP/1.1 404 Not Found'
});
});
const { headers } = await ajax(defaultURL);
expect(headers).to.to.have.property('custom', 'Header');
});
});
describe('webRequest.onResponseStarted', () => {
afterEach(() => {
ses.webRequest.onResponseStarted(null);
});
it('receives details object', async () => {
ses.webRequest.onResponseStarted((details) => {
expect(details.fromCache).to.be.a('boolean');
expect(details.statusLine).to.equal('HTTP/1.1 200 OK');
expect(details.statusCode).to.equal(200);
expect(details.responseHeaders!.Custom).to.deep.equal(['Header']);
});
const { data, headers } = await ajax(defaultURL);
expect(headers).to.to.have.property('custom', 'Header');
expect(data).to.equal('/');
});
});
describe('webRequest.onBeforeRedirect', () => {
afterEach(() => {
ses.webRequest.onBeforeRedirect(null);
ses.webRequest.onBeforeRequest(null);
});
it('receives details object', async () => {
const redirectURL = defaultURL + 'redirect';
ses.webRequest.onBeforeRequest((details, callback) => {
if (details.url === defaultURL) {
callback({ redirectURL: redirectURL });
} else {
callback({});
}
});
ses.webRequest.onBeforeRedirect((details) => {
expect(details.fromCache).to.be.a('boolean');
expect(details.statusLine).to.equal('HTTP/1.1 307 Internal Redirect');
expect(details.statusCode).to.equal(307);
expect(details.redirectURL).to.equal(redirectURL);
});
const { data } = await ajax(defaultURL);
expect(data).to.equal('/redirect');
});
});
describe('webRequest.onCompleted', () => {
afterEach(() => {
ses.webRequest.onCompleted(null);
});
it('receives details object', async () => {
ses.webRequest.onCompleted((details) => {
expect(details.fromCache).to.be.a('boolean');
expect(details.statusLine).to.equal('HTTP/1.1 200 OK');
expect(details.statusCode).to.equal(200);
});
const { data } = await ajax(defaultURL);
expect(data).to.equal('/');
});
});
describe('webRequest.onErrorOccurred', () => {
afterEach(() => {
ses.webRequest.onErrorOccurred(null);
ses.webRequest.onBeforeRequest(null);
});
it('receives details object', async () => {
ses.webRequest.onBeforeRequest((details, callback) => {
callback({ cancel: true });
});
ses.webRequest.onErrorOccurred((details) => {
expect(details.error).to.equal('net::ERR_BLOCKED_BY_CLIENT');
});
await expect(ajax(defaultURL)).to.eventually.be.rejected();
});
});
describe('WebSocket connections', () => {
it('can be proxyed', async () => {
// Setup server.
const reqHeaders : { [key: string] : any } = {};
const server = http.createServer((req, res) => {
reqHeaders[req.url!] = req.headers;
res.setHeader('foo1', 'bar1');
res.end('ok');
});
const wss = new WebSocket.Server({ noServer: true });
wss.on('connection', function connection (ws) {
ws.on('message', function incoming (message) {
if (message === 'foo') {
ws.send('bar');
}
});
});
server.on('upgrade', function upgrade (request, socket, head) {
const pathname = require('url').parse(request.url).pathname;
if (pathname === '/websocket') {
reqHeaders[request.url!] = request.headers;
wss.handleUpgrade(request, socket as Socket, head, function done (ws) {
wss.emit('connection', ws, request);
});
}
});
// Start server.
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const port = String((server.address() as AddressInfo).port);
// Use a separate session for testing.
const ses = session.fromPartition('WebRequestWebSocket');
// Setup listeners.
const receivedHeaders : { [key: string] : any } = {};
ses.webRequest.onBeforeSendHeaders((details, callback) => {
details.requestHeaders.foo = 'bar';
callback({ requestHeaders: details.requestHeaders });
});
ses.webRequest.onHeadersReceived((details, callback) => {
const pathname = require('url').parse(details.url).pathname;
receivedHeaders[pathname] = details.responseHeaders;
callback({ cancel: false });
});
ses.webRequest.onResponseStarted((details) => {
if (details.url.startsWith('ws://')) {
expect(details.responseHeaders!.Connection[0]).be.equal('Upgrade');
} else if (details.url.startsWith('http')) {
expect(details.responseHeaders!.foo1[0]).be.equal('bar1');
}
});
ses.webRequest.onSendHeaders((details) => {
if (details.url.startsWith('ws://')) {
expect(details.requestHeaders.foo).be.equal('bar');
expect(details.requestHeaders.Upgrade).be.equal('websocket');
} else if (details.url.startsWith('http')) {
expect(details.requestHeaders.foo).be.equal('bar');
}
});
ses.webRequest.onCompleted((details) => {
if (details.url.startsWith('ws://')) {
expect(details.error).be.equal('net::ERR_WS_UPGRADE');
} else if (details.url.startsWith('http')) {
expect(details.error).be.equal('net::OK');
}
});
const contents = (webContents as any).create({
session: ses,
nodeIntegration: true,
webSecurity: false,
contextIsolation: false
});
// Cleanup.
after(() => {
contents.destroy();
server.close();
ses.webRequest.onBeforeRequest(null);
ses.webRequest.onBeforeSendHeaders(null);
ses.webRequest.onHeadersReceived(null);
ses.webRequest.onResponseStarted(null);
ses.webRequest.onSendHeaders(null);
ses.webRequest.onCompleted(null);
});
contents.loadFile(path.join(fixturesPath, 'api', 'webrequest.html'), { query: { port } });
await emittedOnce(ipcMain, 'websocket-success');
expect(receivedHeaders['/websocket'].Upgrade[0]).to.equal('websocket');
expect(receivedHeaders['/'].foo1[0]).to.equal('bar1');
expect(reqHeaders['/websocket'].foo).to.equal('bar');
expect(reqHeaders['/'].foo).to.equal('bar');
});
});
});

1562
spec/asar-spec.ts Normal file

File diff suppressed because it is too large Load diff

28
spec/autofill-spec.ts Normal file
View file

@ -0,0 +1,28 @@
import { BrowserWindow } from 'electron';
import * as path from 'path';
import { delay } from './spec-helpers';
import { expect } from 'chai';
import { closeAllWindows } from './window-helpers';
const fixturesPath = path.resolve(__dirname, 'fixtures');
describe('autofill', () => {
afterEach(closeAllWindows);
it('can be selected via keyboard', async () => {
const w = new BrowserWindow({ show: true });
await w.loadFile(path.join(fixturesPath, 'pages', 'datalist.html'));
w.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Tab' });
const inputText = 'clap';
for (const keyCode of inputText) {
w.webContents.sendInputEvent({ type: 'char', keyCode });
await delay(100);
}
w.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Down' });
w.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Enter' });
const value = await w.webContents.executeJavaScript("document.querySelector('input').value");
expect(value).to.equal('Eric Clapton');
});
});

2775
spec/chromium-spec.ts Normal file

File diff suppressed because it is too large Load diff

56
spec/crash-spec.ts Normal file
View file

@ -0,0 +1,56 @@
import { expect } from 'chai';
import * as cp from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { ifit } from './spec-helpers';
const fixturePath = path.resolve(__dirname, 'fixtures', 'crash-cases');
let children: cp.ChildProcessWithoutNullStreams[] = [];
const runFixtureAndEnsureCleanExit = (args: string[]) => {
let out = '';
const child = cp.spawn(process.execPath, args);
children.push(child);
child.stdout.on('data', (chunk: Buffer) => {
out += chunk.toString();
});
child.stderr.on('data', (chunk: Buffer) => {
out += chunk.toString();
});
return new Promise<void>((resolve) => {
child.on('exit', (code, signal) => {
if (code !== 0 || signal !== null) {
console.error(out);
}
expect(signal).to.equal(null, 'exit signal should be null');
expect(code).to.equal(0, 'should have exited with code 0');
children = children.filter(c => c !== child);
resolve();
});
});
};
describe('crash cases', () => {
afterEach(() => {
for (const child of children) {
child.kill();
}
expect(children).to.have.lengthOf(0, 'all child processes should have exited cleanly');
children.length = 0;
});
const cases = fs.readdirSync(fixturePath);
for (const crashCase of cases) {
// TODO(jkleinsc) fix this flaky test on Windows 32-bit
ifit(process.platform !== 'win32' || process.arch !== 'ia32' || crashCase !== 'quit-on-crashed-event')(`the "${crashCase}" case should not crash`, () => {
const fixture = path.resolve(fixturePath, crashCase);
const argsFile = path.resolve(fixture, 'electron.args');
const args = [fixture];
if (fs.existsSync(argsFile)) {
args.push(...fs.readFileSync(argsFile, 'utf8').trim().split('\n'));
}
return runFixtureAndEnsureCleanExit(args);
});
}
});

201
spec/deprecate-spec.ts Normal file
View file

@ -0,0 +1,201 @@
import { expect } from 'chai';
import * as deprecate from '../lib/common/deprecate';
describe('deprecate', () => {
let throwing: boolean;
beforeEach(() => {
throwing = process.throwDeprecation;
deprecate.setHandler(null);
process.throwDeprecation = true;
});
afterEach(() => {
process.throwDeprecation = throwing;
});
it('allows a deprecation handler function to be specified', () => {
const messages: string[] = [];
deprecate.setHandler(message => {
messages.push(message);
});
deprecate.log('this is deprecated');
expect(messages).to.deep.equal(['this is deprecated']);
});
it('returns a deprecation handler after one is set', () => {
const messages = [];
deprecate.setHandler(message => {
messages.push(message);
});
deprecate.log('this is deprecated');
expect(deprecate.getHandler()).to.be.a('function');
});
it('renames a property', () => {
let msg;
deprecate.setHandler(m => { msg = m; });
const oldProp = 'dingyOldName';
const newProp = 'shinyNewName';
let value = 0;
const o: Record<string, number> = { [newProp]: value };
expect(o).to.not.have.property(oldProp);
expect(o).to.have.property(newProp).that.is.a('number');
deprecate.renameProperty(o, oldProp, newProp);
o[oldProp] = ++value;
expect(msg).to.be.a('string');
expect(msg).to.include(oldProp);
expect(msg).to.include(newProp);
expect(o).to.have.property(newProp).that.is.equal(value);
expect(o).to.have.property(oldProp).that.is.equal(value);
});
it('doesn\'t deprecate a property not on an object', () => {
const o: any = {};
expect(() => {
deprecate.removeProperty(o, 'iDoNotExist');
}).to.throw(/iDoNotExist/);
});
it('deprecates a property of an object', () => {
let msg;
deprecate.setHandler(m => { msg = m; });
const prop = 'itMustGo';
const o = { [prop]: 0 };
deprecate.removeProperty(o, prop);
const temp = o[prop];
expect(temp).to.equal(0);
expect(msg).to.be.a('string');
expect(msg).to.include(prop);
});
it('deprecates a property of an but retains the existing accessors and setters', () => {
let msg;
deprecate.setHandler(m => { msg = m; });
const prop = 'itMustGo';
let i = 1;
const o = {
get itMustGo () {
return i;
},
set itMustGo (thing) {
i = thing + 1;
}
};
deprecate.removeProperty(o, prop);
expect(o[prop]).to.equal(1);
expect(msg).to.be.a('string');
expect(msg).to.include(prop);
o[prop] = 2;
expect(o[prop]).to.equal(3);
});
it('warns exactly once when a function is deprecated with no replacement', () => {
let msg;
deprecate.setHandler(m => { msg = m; });
function oldFn () { return 'hello'; }
const deprecatedFn = deprecate.removeFunction(oldFn, 'oldFn');
deprecatedFn();
expect(msg).to.be.a('string');
expect(msg).to.include('oldFn');
});
it('warns exactly once when a function is deprecated with a replacement', () => {
let msg;
deprecate.setHandler(m => { msg = m; });
function oldFn () { return 'hello'; }
const deprecatedFn = deprecate.renameFunction(oldFn, 'newFn');
deprecatedFn();
expect(msg).to.be.a('string');
expect(msg).to.include('oldFn');
expect(msg).to.include('newFn');
});
it('warns only once per item', () => {
const messages: string[] = [];
deprecate.setHandler(message => messages.push(message));
const key = 'foo';
const val = 'bar';
const o = { [key]: val };
deprecate.removeProperty(o, key);
for (let i = 0; i < 3; ++i) {
expect(o[key]).to.equal(val);
expect(messages).to.have.length(1);
}
});
it('warns if deprecated property is already set', () => {
let msg;
deprecate.setHandler(m => { msg = m; });
const oldProp = 'dingyOldName';
const newProp = 'shinyNewName';
const o: Record<string, number> = { [oldProp]: 0 };
deprecate.renameProperty(o, oldProp, newProp);
expect(msg).to.be.a('string');
expect(msg).to.include(oldProp);
expect(msg).to.include(newProp);
});
it('throws an exception if no deprecation handler is specified', () => {
expect(() => {
deprecate.log('this is deprecated');
}).to.throw(/this is deprecated/);
});
describe('moveAPI', () => {
beforeEach(() => {
deprecate.setHandler(null);
});
it('should call the original method', () => {
const warnings = [];
deprecate.setHandler(warning => warnings.push(warning));
let called = false;
const fn = () => {
called = true;
};
const deprecated = deprecate.moveAPI(fn, 'old', 'new');
deprecated();
expect(called).to.equal(true);
});
it('should log the deprecation warning once', () => {
const warnings: string[] = [];
deprecate.setHandler(warning => warnings.push(warning));
const deprecated = deprecate.moveAPI(() => null, 'old', 'new');
deprecated();
expect(warnings).to.have.lengthOf(1);
deprecated();
expect(warnings).to.have.lengthOf(1);
expect(warnings[0]).to.equal('\'old\' is deprecated and will be removed. Please use \'new\' instead.');
});
});
});

View file

@ -1,42 +0,0 @@
/**
* @fileoverview A set of helper functions to make it easier to work
* with events in async/await manner.
*/
/**
* @param {!EventTarget} target
* @param {string} eventName
* @return {!Promise<!Event>}
*/
const waitForEvent = (target, eventName) => {
return new Promise(resolve => {
target.addEventListener(eventName, resolve, { once: true });
});
};
/**
* @param {!EventEmitter} emitter
* @param {string} eventName
* @return {!Promise<!Array>} With Event as the first item.
*/
const emittedOnce = (emitter, eventName) => {
return emittedNTimes(emitter, eventName, 1).then(([result]) => result);
};
const emittedNTimes = (emitter, eventName, times) => {
const events = [];
return new Promise(resolve => {
const handler = (...args) => {
events.push(args);
if (events.length === times) {
emitter.removeListener(eventName, handler);
resolve(events);
}
};
emitter.on(eventName, handler);
});
};
exports.emittedOnce = emittedOnce;
exports.emittedNTimes = emittedNTimes;
exports.waitForEvent = waitForEvent;

55
spec/events-helpers.ts Normal file
View file

@ -0,0 +1,55 @@
/**
* @fileoverview A set of helper functions to make it easier to work
* with events in async/await manner.
*/
/**
* @param {!EventTarget} target
* @param {string} eventName
* @return {!Promise<!Event>}
*/
export const waitForEvent = (target: EventTarget, eventName: string) => {
return new Promise(resolve => {
target.addEventListener(eventName, resolve, { once: true });
});
};
/**
* @param {!EventEmitter} emitter
* @param {string} eventName
* @return {!Promise<!Array>} With Event as the first item.
*/
export const emittedOnce = (emitter: NodeJS.EventEmitter, eventName: string, trigger?: () => void) => {
return emittedNTimes(emitter, eventName, 1, trigger).then(([result]) => result);
};
export const emittedNTimes = async (emitter: NodeJS.EventEmitter, eventName: string, times: number, trigger?: () => void) => {
const events: any[][] = [];
const p = new Promise<any[][]>(resolve => {
const handler = (...args: any[]) => {
events.push(args);
if (events.length === times) {
emitter.removeListener(eventName, handler);
resolve(events);
}
};
emitter.on(eventName, handler);
});
if (trigger) {
await Promise.resolve(trigger());
}
return p;
};
export const emittedUntil = async (emitter: NodeJS.EventEmitter, eventName: string, untilFn: Function) => {
const p = new Promise<any[]>(resolve => {
const handler = (...args: any[]) => {
if (untilFn(...args)) {
emitter.removeListener(eventName, handler);
resolve(args);
}
};
emitter.on(eventName, handler);
});
return p;
};

704
spec/extensions-spec.ts Normal file
View file

@ -0,0 +1,704 @@
import { expect } from 'chai';
import { app, session, BrowserWindow, ipcMain, WebContents, Extension, Session } from 'electron/main';
import { closeAllWindows, closeWindow } from './window-helpers';
import * as http from 'http';
import { AddressInfo } from 'net';
import * as path from 'path';
import * as fs from 'fs';
import * as WebSocket from 'ws';
import { emittedOnce, emittedNTimes, emittedUntil } from './events-helpers';
import { ifit } from './spec-helpers';
const uuid = require('uuid');
const fixtures = path.join(__dirname, 'fixtures');
describe('chrome extensions', () => {
const emptyPage = '<script>console.log("loaded")</script>';
// NB. extensions are only allowed on http://, https:// and ftp:// (!) urls by default.
let server: http.Server;
let url: string;
let port: string;
before(async () => {
server = http.createServer((req, res) => {
if (req.url === '/cors') {
res.setHeader('Access-Control-Allow-Origin', 'http://example.com');
}
res.end(emptyPage);
});
const wss = new WebSocket.Server({ noServer: true });
wss.on('connection', function connection (ws) {
ws.on('message', function incoming (message) {
if (message === 'foo') {
ws.send('bar');
}
});
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', () => {
port = String((server.address() as AddressInfo).port);
url = `http://127.0.0.1:${port}`;
resolve();
}));
});
after(() => {
server.close();
});
afterEach(closeAllWindows);
afterEach(() => {
session.defaultSession.getAllExtensions().forEach((e: any) => {
session.defaultSession.removeExtension(e.id);
});
});
it('does not crash when using chrome.management', async () => {
const customSession = session.fromPartition(`persist:${require('uuid').v4()}`);
const w = new BrowserWindow({ show: false, webPreferences: { session: customSession, sandbox: true } });
await w.loadURL('about:blank');
const promise = emittedOnce(app, 'web-contents-created');
await customSession.loadExtension(path.join(fixtures, 'extensions', 'persistent-background-page'));
const args: any = await promise;
const wc: Electron.WebContents = args[1];
await expect(wc.executeJavaScript(`
(() => {
return new Promise((resolve) => {
chrome.management.getSelf((info) => {
resolve(info);
});
})
})();
`)).to.eventually.have.property('id');
});
it('can open WebSQLDatabase in a background page', async () => {
const customSession = session.fromPartition(`persist:${require('uuid').v4()}`);
const w = new BrowserWindow({ show: false, webPreferences: { session: customSession, sandbox: true } });
await w.loadURL('about:blank');
const promise = emittedOnce(app, 'web-contents-created');
await customSession.loadExtension(path.join(fixtures, 'extensions', 'persistent-background-page'));
const args: any = await promise;
const wc: Electron.WebContents = args[1];
await expect(wc.executeJavaScript('(()=>{try{openDatabase("t", "1.0", "test", 2e5);return true;}catch(e){throw e}})()')).to.not.be.rejected();
});
function fetch (contents: WebContents, url: string) {
return contents.executeJavaScript(`fetch(${JSON.stringify(url)})`);
}
it('bypasses CORS in requests made from extensions', async () => {
const customSession = session.fromPartition(`persist:${require('uuid').v4()}`);
const w = new BrowserWindow({ show: false, webPreferences: { session: customSession, sandbox: true } });
const extension = await customSession.loadExtension(path.join(fixtures, 'extensions', 'ui-page'));
await w.loadURL(`${extension.url}bare-page.html`);
await expect(fetch(w.webContents, `${url}/cors`)).to.not.be.rejectedWith(TypeError);
});
it('loads an extension', async () => {
// NB. we have to use a persist: session (i.e. non-OTR) because the
// extension registry is redirected to the main session. so installing an
// extension in an in-memory session results in it being installed in the
// default session.
const customSession = session.fromPartition(`persist:${uuid.v4()}`);
await customSession.loadExtension(path.join(fixtures, 'extensions', 'red-bg'));
const w = new BrowserWindow({ show: false, webPreferences: { session: customSession } });
await w.loadURL(url);
const bg = await w.webContents.executeJavaScript('document.documentElement.style.backgroundColor');
expect(bg).to.equal('red');
});
it('does not crash when loading an extension with missing manifest', async () => {
const customSession = session.fromPartition(`persist:${uuid.v4()}`);
const promise = customSession.loadExtension(path.join(fixtures, 'extensions', 'missing-manifest'));
await expect(promise).to.eventually.be.rejectedWith(/Manifest file is missing or unreadable/);
});
it('does not crash when failing to load an extension', async () => {
const customSession = session.fromPartition(`persist:${uuid.v4()}`);
const promise = customSession.loadExtension(path.join(fixtures, 'extensions', 'load-error'));
await expect(promise).to.eventually.be.rejected();
});
it('serializes a loaded extension', async () => {
const extensionPath = path.join(fixtures, 'extensions', 'red-bg');
const manifest = JSON.parse(fs.readFileSync(path.join(extensionPath, 'manifest.json'), 'utf-8'));
const customSession = session.fromPartition(`persist:${uuid.v4()}`);
const extension = await customSession.loadExtension(extensionPath);
expect(extension.id).to.be.a('string');
expect(extension.name).to.be.a('string');
expect(extension.path).to.be.a('string');
expect(extension.version).to.be.a('string');
expect(extension.url).to.be.a('string');
expect(extension.manifest).to.deep.equal(manifest);
});
it('removes an extension', async () => {
const customSession = session.fromPartition(`persist:${uuid.v4()}`);
const { id } = await customSession.loadExtension(path.join(fixtures, 'extensions', 'red-bg'));
{
const w = new BrowserWindow({ show: false, webPreferences: { session: customSession } });
await w.loadURL(url);
const bg = await w.webContents.executeJavaScript('document.documentElement.style.backgroundColor');
expect(bg).to.equal('red');
}
customSession.removeExtension(id);
{
const w = new BrowserWindow({ show: false, webPreferences: { session: customSession } });
await w.loadURL(url);
const bg = await w.webContents.executeJavaScript('document.documentElement.style.backgroundColor');
expect(bg).to.equal('');
}
});
it('emits extension lifecycle events', async () => {
const customSession = session.fromPartition(`persist:${require('uuid').v4()}`);
const loadedPromise = emittedOnce(customSession, 'extension-loaded');
const extension = await customSession.loadExtension(path.join(fixtures, 'extensions', 'red-bg'));
const [, loadedExtension] = await loadedPromise;
const [, readyExtension] = await emittedUntil(customSession, 'extension-ready', (event: Event, extension: Extension) => {
return extension.name !== 'Chromium PDF Viewer' && extension.name !== 'CryptoTokenExtension';
});
expect(loadedExtension).to.deep.equal(extension);
expect(readyExtension).to.deep.equal(extension);
const unloadedPromise = emittedOnce(customSession, 'extension-unloaded');
await customSession.removeExtension(extension.id);
const [, unloadedExtension] = await unloadedPromise;
expect(unloadedExtension).to.deep.equal(extension);
});
it('lists loaded extensions in getAllExtensions', async () => {
const customSession = session.fromPartition(`persist:${uuid.v4()}`);
const e = await customSession.loadExtension(path.join(fixtures, 'extensions', 'red-bg'));
expect(customSession.getAllExtensions()).to.deep.equal([e]);
customSession.removeExtension(e.id);
expect(customSession.getAllExtensions()).to.deep.equal([]);
});
it('gets an extension by id', async () => {
const customSession = session.fromPartition(`persist:${uuid.v4()}`);
const e = await customSession.loadExtension(path.join(fixtures, 'extensions', 'red-bg'));
expect(customSession.getExtension(e.id)).to.deep.equal(e);
});
it('confines an extension to the session it was loaded in', async () => {
const customSession = session.fromPartition(`persist:${uuid.v4()}`);
await customSession.loadExtension(path.join(fixtures, 'extensions', 'red-bg'));
const w = new BrowserWindow({ show: false }); // not in the session
await w.loadURL(url);
const bg = await w.webContents.executeJavaScript('document.documentElement.style.backgroundColor');
expect(bg).to.equal('');
});
it('loading an extension in a temporary session throws an error', async () => {
const customSession = session.fromPartition(uuid.v4());
await expect(customSession.loadExtension(path.join(fixtures, 'extensions', 'red-bg'))).to.eventually.be.rejectedWith('Extensions cannot be loaded in a temporary session');
});
describe('chrome.i18n', () => {
let w: BrowserWindow;
let extension: Extension;
const exec = async (name: string) => {
const p = emittedOnce(ipcMain, 'success');
await w.webContents.executeJavaScript(`exec('${name}')`);
const [, result] = await p;
return result;
};
beforeEach(async () => {
const customSession = session.fromPartition(`persist:${uuid.v4()}`);
extension = await customSession.loadExtension(path.join(fixtures, 'extensions', 'chrome-i18n'));
w = new BrowserWindow({ show: false, webPreferences: { session: customSession, nodeIntegration: true, contextIsolation: false } });
await w.loadURL(url);
});
it('getAcceptLanguages()', async () => {
const result = await exec('getAcceptLanguages');
expect(result).to.be.an('array').and.deep.equal(['en-US', 'en']);
});
it('getMessage()', async () => {
const result = await exec('getMessage');
expect(result.id).to.be.a('string').and.equal(extension.id);
expect(result.name).to.be.a('string').and.equal('chrome-i18n');
});
});
describe('chrome.runtime', () => {
let w: BrowserWindow;
const exec = async (name: string) => {
const p = emittedOnce(ipcMain, 'success');
await w.webContents.executeJavaScript(`exec('${name}')`);
const [, result] = await p;
return result;
};
beforeEach(async () => {
const customSession = session.fromPartition(`persist:${uuid.v4()}`);
await customSession.loadExtension(path.join(fixtures, 'extensions', 'chrome-runtime'));
w = new BrowserWindow({ show: false, webPreferences: { session: customSession, nodeIntegration: true, contextIsolation: false } });
await w.loadURL(url);
});
it('getManifest()', async () => {
const result = await exec('getManifest');
expect(result).to.be.an('object').with.property('name', 'chrome-runtime');
});
it('id', async () => {
const result = await exec('id');
expect(result).to.be.a('string').with.lengthOf(32);
});
it('getURL()', async () => {
const result = await exec('getURL');
expect(result).to.be.a('string').and.match(/^chrome-extension:\/\/.*main.js$/);
});
it('getPlatformInfo()', async () => {
const result = await exec('getPlatformInfo');
expect(result).to.be.an('object');
expect(result.os).to.be.a('string');
expect(result.arch).to.be.a('string');
expect(result.nacl_arch).to.be.a('string');
});
});
describe('chrome.storage', () => {
it('stores and retrieves a key', async () => {
const customSession = session.fromPartition(`persist:${uuid.v4()}`);
await customSession.loadExtension(path.join(fixtures, 'extensions', 'chrome-storage'));
const w = new BrowserWindow({ show: false, webPreferences: { session: customSession, nodeIntegration: true, contextIsolation: false } });
try {
const p = emittedOnce(ipcMain, 'storage-success');
await w.loadURL(url);
const [, v] = await p;
expect(v).to.equal('value');
} finally {
w.destroy();
}
});
});
describe('chrome.webRequest', () => {
function fetch (contents: WebContents, url: string) {
return contents.executeJavaScript(`fetch(${JSON.stringify(url)})`);
}
let customSession: Session;
let w: BrowserWindow;
beforeEach(() => {
customSession = session.fromPartition(`persist:${uuid.v4()}`);
w = new BrowserWindow({ show: false, webPreferences: { session: customSession, sandbox: true, contextIsolation: true } });
});
describe('onBeforeRequest', () => {
it('can cancel http requests', async () => {
await w.loadURL(url);
await customSession.loadExtension(path.join(fixtures, 'extensions', 'chrome-webRequest'));
await expect(fetch(w.webContents, url)).to.eventually.be.rejectedWith('Failed to fetch');
});
it('does not cancel http requests when no extension loaded', async () => {
await w.loadURL(url);
await expect(fetch(w.webContents, url)).to.not.be.rejectedWith('Failed to fetch');
});
});
it('does not take precedence over Electron webRequest - http', async () => {
return new Promise<void>((resolve) => {
(async () => {
customSession.webRequest.onBeforeRequest((details, callback) => {
resolve();
callback({ cancel: true });
});
await w.loadURL(url);
await customSession.loadExtension(path.join(fixtures, 'extensions', 'chrome-webRequest'));
fetch(w.webContents, url);
})();
});
});
it('does not take precedence over Electron webRequest - WebSocket', () => {
return new Promise<void>((resolve) => {
(async () => {
customSession.webRequest.onBeforeSendHeaders(() => {
resolve();
});
await w.loadFile(path.join(fixtures, 'api', 'webrequest.html'), { query: { port } });
await customSession.loadExtension(path.join(fixtures, 'extensions', 'chrome-webRequest-wss'));
})();
});
});
describe('WebSocket', () => {
it('can be proxied', async () => {
await w.loadFile(path.join(fixtures, 'api', 'webrequest.html'), { query: { port } });
await customSession.loadExtension(path.join(fixtures, 'extensions', 'chrome-webRequest-wss'));
customSession.webRequest.onSendHeaders((details) => {
if (details.url.startsWith('ws://')) {
expect(details.requestHeaders.foo).be.equal('bar');
}
});
});
});
});
describe('chrome.tabs', () => {
let customSession: Session;
before(async () => {
customSession = session.fromPartition(`persist:${uuid.v4()}`);
await customSession.loadExtension(path.join(fixtures, 'extensions', 'chrome-api'));
});
it('executeScript', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { session: customSession, nodeIntegration: true } });
await w.loadURL(url);
const message = { method: 'executeScript', args: ['1 + 2'] };
w.webContents.executeJavaScript(`window.postMessage('${JSON.stringify(message)}', '*')`);
const [,, responseString] = await emittedOnce(w.webContents, 'console-message');
const response = JSON.parse(responseString);
expect(response).to.equal(3);
});
it('connect', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { session: customSession, nodeIntegration: true } });
await w.loadURL(url);
const portName = uuid.v4();
const message = { method: 'connectTab', args: [portName] };
w.webContents.executeJavaScript(`window.postMessage('${JSON.stringify(message)}', '*')`);
const [,, responseString] = await emittedOnce(w.webContents, 'console-message');
const response = responseString.split(',');
expect(response[0]).to.equal(portName);
expect(response[1]).to.equal('howdy');
});
it('sendMessage receives the response', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { session: customSession, nodeIntegration: true } });
await w.loadURL(url);
const message = { method: 'sendMessage', args: ['Hello World!'] };
w.webContents.executeJavaScript(`window.postMessage('${JSON.stringify(message)}', '*')`);
const [,, responseString] = await emittedOnce(w.webContents, 'console-message');
const response = JSON.parse(responseString);
expect(response.message).to.equal('Hello World!');
expect(response.tabId).to.equal(w.webContents.id);
});
it('update', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { session: customSession, nodeIntegration: true } });
await w.loadURL(url);
const w2 = new BrowserWindow({ show: false, webPreferences: { session: customSession } });
await w2.loadURL('about:blank');
const w2Navigated = emittedOnce(w2.webContents, 'did-navigate');
const message = { method: 'update', args: [w2.webContents.id, { url }] };
w.webContents.executeJavaScript(`window.postMessage('${JSON.stringify(message)}', '*')`);
const [,, responseString] = await emittedOnce(w.webContents, 'console-message');
const response = JSON.parse(responseString);
await w2Navigated;
expect(new URL(w2.getURL()).toString()).to.equal(new URL(url).toString());
expect(response.id).to.equal(w2.webContents.id);
});
});
describe('background pages', () => {
it('loads a lazy background page when sending a message', async () => {
const customSession = session.fromPartition(`persist:${uuid.v4()}`);
await customSession.loadExtension(path.join(fixtures, 'extensions', 'lazy-background-page'));
const w = new BrowserWindow({ show: false, webPreferences: { session: customSession, nodeIntegration: true, contextIsolation: false } });
try {
w.loadURL(url);
const [, resp] = await emittedOnce(ipcMain, 'bg-page-message-response');
expect(resp.message).to.deep.equal({ some: 'message' });
expect(resp.sender.id).to.be.a('string');
expect(resp.sender.origin).to.equal(url);
expect(resp.sender.url).to.equal(url + '/');
} finally {
w.destroy();
}
});
it('can use extension.getBackgroundPage from a ui page', async () => {
const customSession = session.fromPartition(`persist:${uuid.v4()}`);
const { id } = await customSession.loadExtension(path.join(fixtures, 'extensions', 'lazy-background-page'));
const w = new BrowserWindow({ show: false, webPreferences: { session: customSession } });
await w.loadURL(`chrome-extension://${id}/page-get-background.html`);
const receivedMessage = await w.webContents.executeJavaScript('window.completionPromise');
expect(receivedMessage).to.deep.equal({ some: 'message' });
});
it('can use extension.getBackgroundPage from a ui page', async () => {
const customSession = session.fromPartition(`persist:${uuid.v4()}`);
const { id } = await customSession.loadExtension(path.join(fixtures, 'extensions', 'lazy-background-page'));
const w = new BrowserWindow({ show: false, webPreferences: { session: customSession } });
await w.loadURL(`chrome-extension://${id}/page-get-background.html`);
const receivedMessage = await w.webContents.executeJavaScript('window.completionPromise');
expect(receivedMessage).to.deep.equal({ some: 'message' });
});
it('can use runtime.getBackgroundPage from a ui page', async () => {
const customSession = session.fromPartition(`persist:${uuid.v4()}`);
const { id } = await customSession.loadExtension(path.join(fixtures, 'extensions', 'lazy-background-page'));
const w = new BrowserWindow({ show: false, webPreferences: { session: customSession } });
await w.loadURL(`chrome-extension://${id}/page-runtime-get-background.html`);
const receivedMessage = await w.webContents.executeJavaScript('window.completionPromise');
expect(receivedMessage).to.deep.equal({ some: 'message' });
});
it('has session in background page', async () => {
const customSession = session.fromPartition(`persist:${require('uuid').v4()}`);
const promise = emittedOnce(app, 'web-contents-created');
const { id } = await customSession.loadExtension(path.join(fixtures, 'extensions', 'persistent-background-page'));
const [, bgPageContents] = await promise;
expect(bgPageContents.getType()).to.equal('backgroundPage');
await emittedOnce(bgPageContents, 'did-finish-load');
expect(bgPageContents.getURL()).to.equal(`chrome-extension://${id}/_generated_background_page.html`);
expect(bgPageContents.session).to.not.equal(undefined);
});
it('can open devtools of background page', async () => {
const customSession = session.fromPartition(`persist:${require('uuid').v4()}`);
const promise = emittedOnce(app, 'web-contents-created');
await customSession.loadExtension(path.join(fixtures, 'extensions', 'persistent-background-page'));
const [, bgPageContents] = await promise;
expect(bgPageContents.getType()).to.equal('backgroundPage');
bgPageContents.openDevTools();
bgPageContents.closeDevTools();
});
});
describe('devtools extensions', () => {
let showPanelTimeoutId: any = null;
afterEach(() => {
if (showPanelTimeoutId) clearTimeout(showPanelTimeoutId);
});
const showLastDevToolsPanel = (w: BrowserWindow) => {
w.webContents.once('devtools-opened', () => {
const show = () => {
if (w == null || w.isDestroyed()) return;
const { devToolsWebContents } = w as unknown as { devToolsWebContents: WebContents | undefined };
if (devToolsWebContents == null || devToolsWebContents.isDestroyed()) {
return;
}
const showLastPanel = () => {
// this is executed in the devtools context, where UI is a global
const { UI } = (window as any);
const tabs = UI.inspectorView.tabbedPane.tabs;
const lastPanelId = tabs[tabs.length - 1].id;
UI.inspectorView.showPanel(lastPanelId);
};
devToolsWebContents.executeJavaScript(`(${showLastPanel})()`, false).then(() => {
showPanelTimeoutId = setTimeout(show, 100);
});
};
showPanelTimeoutId = setTimeout(show, 100);
});
};
// TODO(jkleinsc) fix this flaky test on WOA
ifit(process.platform !== 'win32' || process.arch !== 'arm64')('loads a devtools extension', async () => {
const customSession = session.fromPartition(`persist:${uuid.v4()}`);
customSession.loadExtension(path.join(fixtures, 'extensions', 'devtools-extension'));
const winningMessage = emittedOnce(ipcMain, 'winning');
const w = new BrowserWindow({ show: true, webPreferences: { session: customSession, nodeIntegration: true, contextIsolation: false } });
await w.loadURL(url);
w.webContents.openDevTools();
showLastDevToolsPanel(w);
await winningMessage;
});
});
describe('chrome extension content scripts', () => {
const fixtures = path.resolve(__dirname, 'fixtures');
const extensionPath = path.resolve(fixtures, 'extensions');
const addExtension = (name: string) => session.defaultSession.loadExtension(path.resolve(extensionPath, name));
const removeAllExtensions = () => {
Object.keys(session.defaultSession.getAllExtensions()).map(extName => {
session.defaultSession.removeExtension(extName);
});
};
let responseIdCounter = 0;
const executeJavaScriptInFrame = (webContents: WebContents, frameRoutingId: number, code: string) => {
return new Promise(resolve => {
const responseId = responseIdCounter++;
ipcMain.once(`executeJavaScriptInFrame_${responseId}`, (event, result) => {
resolve(result);
});
webContents.send('executeJavaScriptInFrame', frameRoutingId, code, responseId);
});
};
const generateTests = (sandboxEnabled: boolean, contextIsolationEnabled: boolean) => {
describe(`with sandbox ${sandboxEnabled ? 'enabled' : 'disabled'} and context isolation ${contextIsolationEnabled ? 'enabled' : 'disabled'}`, () => {
let w: BrowserWindow;
describe('supports "run_at" option', () => {
beforeEach(async () => {
await closeWindow(w);
w = new BrowserWindow({
show: false,
width: 400,
height: 400,
webPreferences: {
contextIsolation: contextIsolationEnabled,
sandbox: sandboxEnabled
}
});
});
afterEach(() => {
removeAllExtensions();
return closeWindow(w).then(() => { w = null as unknown as BrowserWindow; });
});
it('should run content script at document_start', async () => {
await addExtension('content-script-document-start');
w.webContents.once('dom-ready', async () => {
const result = await w.webContents.executeJavaScript('document.documentElement.style.backgroundColor');
expect(result).to.equal('red');
});
w.loadURL(url);
});
it('should run content script at document_idle', async () => {
await addExtension('content-script-document-idle');
w.loadURL(url);
const result = await w.webContents.executeJavaScript('document.body.style.backgroundColor');
expect(result).to.equal('red');
});
it('should run content script at document_end', async () => {
await addExtension('content-script-document-end');
w.webContents.once('did-finish-load', async () => {
const result = await w.webContents.executeJavaScript('document.documentElement.style.backgroundColor');
expect(result).to.equal('red');
});
w.loadURL(url);
});
});
// TODO(nornagon): real extensions don't load on file: urls, so this
// test needs to be updated to serve its content over http.
describe.skip('supports "all_frames" option', () => {
const contentScript = path.resolve(fixtures, 'extensions/content-script');
// Computed style values
const COLOR_RED = 'rgb(255, 0, 0)';
const COLOR_BLUE = 'rgb(0, 0, 255)';
const COLOR_TRANSPARENT = 'rgba(0, 0, 0, 0)';
before(() => {
session.defaultSession.loadExtension(contentScript);
});
after(() => {
session.defaultSession.removeExtension('content-script-test');
});
beforeEach(() => {
w = new BrowserWindow({
show: false,
webPreferences: {
// enable content script injection in subframes
nodeIntegrationInSubFrames: true,
preload: path.join(contentScript, 'all_frames-preload.js')
}
});
});
afterEach(() =>
closeWindow(w).then(() => {
w = null as unknown as BrowserWindow;
})
);
it('applies matching rules in subframes', async () => {
const detailsPromise = emittedNTimes(w.webContents, 'did-frame-finish-load', 2);
w.loadFile(path.join(contentScript, 'frame-with-frame.html'));
const frameEvents = await detailsPromise;
await Promise.all(
frameEvents.map(async frameEvent => {
const [, isMainFrame, , frameRoutingId] = frameEvent;
const result: any = await executeJavaScriptInFrame(
w.webContents,
frameRoutingId,
`(() => {
const a = document.getElementById('all_frames_enabled')
const b = document.getElementById('all_frames_disabled')
return {
enabledColor: getComputedStyle(a).backgroundColor,
disabledColor: getComputedStyle(b).backgroundColor
}
})()`
);
expect(result.enabledColor).to.equal(COLOR_RED);
if (isMainFrame) {
expect(result.disabledColor).to.equal(COLOR_BLUE);
} else {
expect(result.disabledColor).to.equal(COLOR_TRANSPARENT); // null color
}
})
);
});
});
});
};
generateTests(false, false);
generateTests(false, true);
generateTests(true, false);
generateTests(true, true);
});
describe('extension ui pages', () => {
afterEach(() => {
session.defaultSession.getAllExtensions().forEach(e => {
session.defaultSession.removeExtension(e.id);
});
});
it('loads a ui page of an extension', async () => {
const { id } = await session.defaultSession.loadExtension(path.join(fixtures, 'extensions', 'ui-page'));
const w = new BrowserWindow({ show: false });
await w.loadURL(`chrome-extension://${id}/bare-page.html`);
const textContent = await w.webContents.executeJavaScript('document.body.textContent');
expect(textContent).to.equal('ui page loaded ok\n');
});
it('can load resources', async () => {
const { id } = await session.defaultSession.loadExtension(path.join(fixtures, 'extensions', 'ui-page'));
const w = new BrowserWindow({ show: false });
await w.loadURL(`chrome-extension://${id}/page-script-load.html`);
const textContent = await w.webContents.executeJavaScript('document.body.textContent');
expect(textContent).to.equal('script loaded ok\n');
});
});
describe('manifest v3', () => {
it('registers background service worker', async () => {
const customSession = session.fromPartition(`persist:${uuid.v4()}`);
const registrationPromise = new Promise<string>(resolve => {
customSession.serviceWorkers.once('registration-completed', (event, { scope }) => resolve(scope));
});
const extension = await customSession.loadExtension(path.join(fixtures, 'extensions', 'mv3-service-worker'));
const scope = await registrationPromise;
expect(scope).equals(extension.url);
});
});
});

View file

@ -0,0 +1,14 @@
<html>
<body>
<script type="text/javascript" charset="utf-8">
// Only prevent unload on the first window close
var unloadPrevented = false;
window.onbeforeunload = function() {
if (!unloadPrevented) {
unloadPrevented = true;
return '';
}
}
</script>
</body>
</html>

View file

@ -0,0 +1,17 @@
<html>
<body>
<script type="text/javascript" charset="utf-8">
function installBeforeUnload(removeAfterNTimes) {
let count = 0
window.addEventListener('beforeunload', function handler(e) {
setTimeout(() => console.log('beforeunload'))
if (++count <= removeAfterNTimes) {
e.preventDefault();
e.returnValue = '';
}
})
console.log('installed')
}
</script>
</body>
</html>

View file

@ -0,0 +1,14 @@
<html>
<body>
<script type="text/javascript" charset="utf-8">
// Only prevent unload on the first window close
var unloadPrevented = false;
window.onbeforeunload = function() {
if (!unloadPrevented) {
unloadPrevented = true;
return false;
}
}
</script>
</body>
</html>

View file

@ -0,0 +1,9 @@
<html>
<body>
<script type="text/javascript" charset="utf-8">
window.onbeforeunload = function() {
}
</script>
</body>
</html>

View file

@ -0,0 +1,13 @@
const { contextBridge, ipcRenderer } = require('electron');
console.info(contextBridge);
let bound = false;
try {
contextBridge.exposeInMainWorld('test', {});
bound = true;
} catch {
// Ignore
}
ipcRenderer.send('context-bridge-bound', bound);

View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<body>
<script>
try {
window.str = 'some-modified-text';
window.obj.prop = 'obj-modified-prop';
window.arr.splice(2, 0, 5);
} catch (e) { }
console.log(window.str);
console.log(window.obj.prop);
console.log(window.arr);
</script>
</body>
</html>

View file

@ -0,0 +1,20 @@
const { app, BrowserWindow } = require('electron');
const path = require('path');
let win;
app.whenReady().then(function () {
win = new BrowserWindow({
webPreferences: {
contextIsolation: true,
preload: path.join(__dirname, 'preload.js')
}
});
win.loadFile('index.html');
win.webContents.on('console-message', (event, level, message) => {
console.log(message);
});
win.webContents.on('did-finish-load', () => app.quit());
});

View file

@ -0,0 +1,4 @@
{
"name": "electron-test-context-bridge-mutability",
"main": "main.js"
}

View file

@ -0,0 +1,5 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('str', 'some-text');
contextBridge.exposeInMainWorld('obj', { prop: 'obj-prop' });
contextBridge.exposeInMainWorld('arr', [1, 2, 3, 4]);

View file

@ -0,0 +1 @@
<html></html>

View file

@ -0,0 +1,18 @@
const { app, webContents, protocol, session } = require('electron');
protocol.registerSchemesAsPrivileged([
{ scheme: 'test', privileges: { standard: true, secure: true } }
]);
app.whenReady().then(function () {
const ses = session.fromPartition('persist:test-standard-shutdown');
const web = webContents.create({ session: ses });
ses.protocol.registerStringProtocol('test', (request, callback) => {
callback('Hello World!');
});
web.loadURL('test://abc/hello.txt');
web.on('did-finish-load', () => app.quit());
});

View file

@ -1 +0,0 @@
// Nothing to do here

View file

@ -1,4 +0,0 @@
{
"name": "some-module",
"main": "./main2.js"
}

View file

@ -0,0 +1,8 @@
const { app, ipcMain } = require('electron');
app.whenReady().then(() => {
process.stdout.write(JSON.stringify(ipcMain.eventNames()));
process.stdout.end();
app.quit();
});

View file

@ -0,0 +1,4 @@
{
"name": "electron-test-ipc-main-listeners",
"main": "main.js"
}

View file

@ -0,0 +1,22 @@
<html>
<body>
<script type="text/javascript" charset="utf-8">
const {ipcRenderer} = require('electron')
let echo
let requireError
try {
echo = require('@electron-ci/echo')
} catch (error) {
requireError = error
}
if (requireError != null) {
ipcRenderer.send('answer', `Require echo failed: ${requireError.message}`)
} else {
ipcRenderer.send('answer', typeof echo)
}
</script>
</body>
</html>

31
spec/fixtures/api/net-log/main.js vendored Normal file
View file

@ -0,0 +1,31 @@
const { app, net, session } = require('electron');
if (process.env.TEST_DUMP_FILE) {
app.commandLine.appendSwitch('log-net-log', process.env.TEST_DUMP_FILE);
}
function request () {
return new Promise((resolve) => {
const req = net.request(process.env.TEST_REQUEST_URL);
req.on('response', () => {
resolve();
});
req.end();
});
}
app.whenReady().then(async () => {
const netLog = session.defaultSession.netLog;
if (process.env.TEST_DUMP_FILE_DYNAMIC) {
await netLog.startLogging(process.env.TEST_DUMP_FILE_DYNAMIC);
}
await request();
if (process.env.TEST_MANUAL_STOP) {
await netLog.stopLogging();
}
app.quit();
});

View file

@ -0,0 +1,4 @@
{
"name": "electron-test-net-log",
"main": "main.js"
}

View file

@ -0,0 +1,6 @@
const { ipcRenderer, webFrame } = require('electron');
ipcRenderer.send('answer', {
argv: process.argv
});
window.close();

2382
spec/fixtures/api/print-to-pdf.html vendored Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
const { app, safeStorage, ipcMain } = require('electron');
const { promises: fs } = require('fs');
const path = require('path');
const pathToEncryptedString = path.resolve(__dirname, '..', 'encrypted.txt');
const readFile = fs.readFile;
app.whenReady().then(async () => {
const encryptedString = await readFile(pathToEncryptedString);
const decrypted = safeStorage.decryptString(encryptedString);
console.log(decrypted);
app.quit();
});

View file

@ -0,0 +1,4 @@
{
"name": "electron-test-safe-storage",
"main": "main.js"
}

View file

@ -0,0 +1,12 @@
const { app, safeStorage, ipcMain } = require('electron');
const { promises: fs } = require('fs');
const path = require('path');
const pathToEncryptedString = path.resolve(__dirname, '..', 'encrypted.txt');
const writeFile = fs.writeFile;
app.whenReady().then(async () => {
const encrypted = safeStorage.encryptString('plaintext');
const encryptedString = await writeFile(pathToEncryptedString, encrypted);
app.quit();
});

View file

@ -0,0 +1,4 @@
{
"name": "electron-test-safe-storage",
"main": "main.js"
}

107
spec/fixtures/api/sandbox.html vendored Normal file
View file

@ -0,0 +1,107 @@
<html>
<script type="text/javascript" charset="utf-8">
function timeout(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
}
async function invokeGc () {
// it seems calling window.gc once does not guarantee garbage will be
// collected, so we repeat 10 times with interval of 100 ms
for (let i = 0; i < 10; i++) {
window.gc()
await timeout(100)
}
}
const [,test] = window.location.href.split('?')
if (window.opener && test !== 'reload-remote') {
window.callback = () => {
opener.require('electron').ipcRenderer.send('answer', document.body.innerHTML)
}
} else {
const tests = {
'reload-remote-child': () => {
open(`${location.protocol}//${location.pathname}?reload-remote`)
},
'reload-remote': async () => {
const {ipcRenderer, remote} = require('electron')
const p = ipcRenderer.sendSync('get-remote-module-path')
const Hello = remote.require(p)
if (!ipcRenderer.sendSync('reloaded')) {
ipcRenderer.send('reload')
return
}
await invokeGc()
ipcRenderer.send('answer', new Hello().say())
},
'webcontents-stop': () => {
stop()
},
'webcontents-events': () => {
addEventListener('load', () => {
location.hash = 'in-page-navigate'
setTimeout(() => {
location.reload()
}, 50)
})
},
'exit-event': () => {
const {ipcRenderer} = require('electron')
const currentLocation = location.href.slice();
process.on('exit', () => {
ipcRenderer.send('answer', currentLocation)
})
location.assign('http://www.google.com')
},
'window-open': () => {
addEventListener('load', () => {
const popup = open(window.location.href, 'popup!', 'top=60,left=50,width=500,height=600')
popup.addEventListener('DOMContentLoaded', () => {
popup.document.write('<h1>scripting from opener</h1>')
popup.callback()
}, false)
})
},
'window-open-external': () => {
const {ipcRenderer} = require('electron')
addEventListener('load', () => {
ipcRenderer.once('open-the-popup', (event, url) => {
popup = open(url, '', 'top=65,left=55,width=505,height=605')
})
ipcRenderer.once('touch-the-popup', () => {
let errorMessage = null
try {
const childDoc = popup.document
} catch (error) {
errorMessage = error.message
}
ipcRenderer.send('answer', errorMessage)
})
ipcRenderer.send('opener-loaded')
})
},
'verify-ipc-sender': () => {
const {ipcRenderer} = require('electron')
const popup = open()
ipcRenderer.once('verified', () => {
ipcRenderer.send('parent-answer')
})
popup.ipcRenderer.once('verified', () => {
popup.ipcRenderer.send('child-answer')
})
ipcRenderer.send('parent-ready')
popup.ipcRenderer.send('child-ready')
}
}
addEventListener('unload', () => {
if (window.popup)
popup.close()
}, false)
if (tests.hasOwnProperty(test))
tests[test]()
}
</script>
</html>

View file

@ -0,0 +1,9 @@
<html>
<body>
<script type="text/javascript" charset="utf-8">
var ipcRenderer = require('electron').ipcRenderer;
ipcRenderer.sendSync('send-sync-message', 'message');
</script>
</body>
</html>

View file

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<body>
<script>
navigator.serviceWorker.register('sw.js', {
scope: location.pathname.split('/').slice(0, 2).join('/') + '/'
})
</script>
</body>
</html>

View file

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<body>
<script>
navigator.serviceWorker.register('sw-logs.js', {
scope: location.pathname.split('/').slice(0, 2).join('/') + '/'
})
</script>
</body>
</html>

View file

@ -0,0 +1,6 @@
self.addEventListener('install', function (event) {
console.log('log log');
console.info('info log');
console.warn('warn log');
console.error('error log');
});

View file

@ -0,0 +1,3 @@
self.addEventListener('install', function (event) {
console.log('Installed');
});

View file

@ -0,0 +1,16 @@
const { app, BrowserWindow } = require('electron');
let win;
app.whenReady().then(function () {
win = new BrowserWindow({});
win.setMenu(null);
setTimeout(() => {
if (win.isMenuBarVisible()) {
console.log('Window has a menu');
} else {
console.log('Window has no menu');
}
app.quit();
});
});

View file

@ -0,0 +1,4 @@
{
"name": "electron-test-menu",
"main": "main.js"
}

View file

@ -0,0 +1,19 @@
const { app, BrowserWindow } = require('electron');
let win;
// This test uses "app.once('ready')" while the |test-menu-null| test uses
// "app.whenReady()", the 2 APIs have slight difference on timing to cover
// more cases.
app.once('ready', function () {
win = new BrowserWindow({});
win.setMenuBarVisibility(false);
setTimeout(() => {
if (win.isMenuBarVisible()) {
console.log('Window has a menu');
} else {
console.log('Window has no menu');
}
app.quit();
});
});

View file

@ -0,0 +1,4 @@
{
"name": "electron-test-menu",
"main": "main.js"
}

27
spec/fixtures/api/webrequest.html vendored Normal file
View file

@ -0,0 +1,27 @@
<script>
var url = new URL(location.href)
const port = new URLSearchParams(url.search).get("port")
const ipcRenderer = require('electron').ipcRenderer
let count = 0
function checkFinish() {
count++
if (count === 2) {
ipcRenderer.send('websocket-success')
}
}
var conn = new WebSocket(`ws://127.0.0.1:${port}/websocket`)
conn.onopen = data => conn.send('foo')
conn.onmessage = wsMsg
function wsMsg(msg) {
if (msg.data === 'bar') {
checkFinish()
} else {
ipcRenderer.send('fail')
}
}
fetch(`http://127.0.0.1:${port}/`).then(() => {
checkFinish()
})
</script>

View file

@ -0,0 +1,13 @@
const { ipcRenderer, webFrame } = require('electron');
setImmediate(function () {
if (window.location.toString() === 'bar://page/') {
const windowOpenerIsNull = window.opener == null;
ipcRenderer.send('answer', {
nodeIntegration: webFrame.getWebPreference('nodeIntegration'),
typeofProcess: typeof global.process,
windowOpenerIsNull
});
window.close();
}
});

View file

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>test-color-window</title>
<style>
body {
background: green;
}
</style>
</head>
<body id="body">
<script src="./renderer.js"></script>
</body>
</html>

View file

@ -0,0 +1,61 @@
const { app, BrowserWindow, desktopCapturer, ipcMain } = require('electron');
const getColors = require('get-image-colors');
const colors = {};
// Fetch the test window.
const getWindow = async () => {
const sources = await desktopCapturer.getSources({ types: ['window'] });
const filtered = sources.filter(s => s.name === 'test-color-window');
if (filtered.length === 0) {
throw new Error('Could not find test window');
}
return filtered[0];
};
async function createWindow () {
const mainWindow = new BrowserWindow({
frame: false,
transparent: true,
vibrancy: 'under-window',
webPreferences: {
backgroundThrottling: false,
contextIsolation: false,
nodeIntegration: true
}
});
await mainWindow.loadFile('index.html');
// Get initial green background color.
const window = await getWindow();
const buf = window.thumbnail.toPNG();
const result = await getColors(buf, { count: 1, type: 'image/png' });
colors.green = result[0].hex();
}
ipcMain.on('set-transparent', async () => {
// Get updated background color.
const window = await getWindow();
const buf = window.thumbnail.toPNG();
const result = await getColors(buf, { count: 1, type: 'image/png' });
colors.transparent = result[0].hex();
const { green, transparent } = colors;
console.log({ green, transparent });
process.exit(green === transparent ? 1 : 0);
});
app.whenReady().then(() => {
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});

View file

@ -0,0 +1,4 @@
{
"name": "electron-test-background-color-transparent",
"main": "main.js"
}

View file

@ -0,0 +1,9 @@
const { ipcRenderer } = require('electron');
window.setTimeout(async (_) => {
document.body.style.background = 'transparent';
window.setTimeout(async (_) => {
ipcRenderer.send('set-transparent');
}, 2000);
}, 3000);

65
spec/fixtures/apps/crash/main.js vendored Normal file
View file

@ -0,0 +1,65 @@
const { app, BrowserWindow, crashReporter } = require('electron');
const path = require('path');
const childProcess = require('child_process');
app.setVersion('0.1.0');
const url = app.commandLine.getSwitchValue('crash-reporter-url');
const uploadToServer = !app.commandLine.hasSwitch('no-upload');
const setExtraParameters = app.commandLine.hasSwitch('set-extra-parameters-in-renderer');
const addGlobalParam = app.commandLine.getSwitchValue('add-global-param')?.split(':');
crashReporter.start({
productName: 'Zombies',
companyName: 'Umbrella Corporation',
compress: false,
uploadToServer,
submitURL: url,
ignoreSystemCrashHandler: true,
extra: {
mainProcessSpecific: 'mps'
},
globalExtra: addGlobalParam[0] ? { [addGlobalParam[0]]: addGlobalParam[1] } : {}
});
app.whenReady().then(() => {
const crashType = app.commandLine.getSwitchValue('crash-type');
if (crashType === 'main') {
process.crash();
} else if (crashType === 'renderer') {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
if (setExtraParameters) {
w.webContents.executeJavaScript(`
require('electron').crashReporter.addExtraParameter('rendererSpecific', 'rs');
require('electron').crashReporter.addExtraParameter('addedThenRemoved', 'to-be-removed');
require('electron').crashReporter.removeExtraParameter('addedThenRemoved');
`);
}
w.webContents.executeJavaScript('process.crash()');
w.webContents.on('render-process-gone', () => process.exit(0));
} else if (crashType === 'sandboxed-renderer') {
const w = new BrowserWindow({
show: false,
webPreferences: {
sandbox: true,
preload: path.resolve(__dirname, 'sandbox-preload.js'),
contextIsolation: false
}
});
w.loadURL(`about:blank?set_extra=${setExtraParameters ? 1 : 0}`);
w.webContents.on('render-process-gone', () => process.exit(0));
} else if (crashType === 'node') {
const crashesDir = path.join(app.getPath('temp'), `${app.name} Crashes`);
const version = app.getVersion();
const crashPath = path.join(__dirname, 'node-crash.js');
const child = childProcess.fork(crashPath, [url, version, crashesDir], { silent: true });
child.on('exit', () => process.exit(0));
} else {
console.error(`Unrecognized crash type: '${crashType}'`);
process.exit(1);
}
});
setTimeout(() => app.exit(), 30000);

11
spec/fixtures/apps/crash/node-crash.js vendored Normal file
View file

@ -0,0 +1,11 @@
if (process.platform === 'linux') {
process.crashReporter.start({
submitURL: process.argv[2],
productName: 'Zombies',
compress: false,
globalExtra: {
_version: process.argv[3]
}
});
}
process.nextTick(() => process.crash());

4
spec/fixtures/apps/crash/package.json vendored Normal file
View file

@ -0,0 +1,4 @@
{
"name": "electron-test-crash",
"main": "main.js"
}

View file

@ -0,0 +1,10 @@
const { crashReporter } = require('electron');
const params = new URLSearchParams(location.search);
if (params.get('set_extra') === '1') {
crashReporter.addExtraParameter('rendererSpecific', 'rs');
crashReporter.addExtraParameter('addedThenRemoved', 'to-be-removed');
crashReporter.removeExtraParameter('addedThenRemoved');
}
process.crash();

View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">
<meta http-equiv="X-Content-Security-Policy" content="default-src 'self'; script-src 'self'">
<title>Hello World!</title>
</head>
<body>
<h1>Hello World!</h1>
<script src="./renderer.js"></script>
</body>
</html>

37
spec/fixtures/apps/libuv-hang/main.js vendored Normal file
View file

@ -0,0 +1,37 @@
const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path');
async function createWindow () {
const mainWindow = new BrowserWindow({
show: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
sandbox: false
}
});
await mainWindow.loadFile('index.html');
}
app.whenReady().then(() => {
createWindow();
app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
let count = 0;
ipcMain.handle('reload-successful', () => {
if (count === 2) {
app.quit();
} else {
count++;
return count;
}
});
app.on('window-all-closed', function () {
if (process.platform !== 'darwin') app.quit();
});

Some files were not shown because too many files have changed in this diff Show more