electron/spec/api-service-worker-main-spe...

423 lines
16 KiB
TypeScript

import { ipcMain, session, webContents as webContentsModule, WebContents } from 'electron/main';
import { expect } from 'chai';
import { once, on } from 'node:events';
import * as fs from 'node:fs';
import * as http from 'node:http';
import * as path from 'node:path';
import { listen, waitUntil } from './lib/spec-helpers';
// Toggle to add extra debug output
const DEBUG = !process.env.CI;
describe('ServiceWorkerMain module', () => {
const fixtures = path.resolve(__dirname, 'fixtures');
const preloadRealmFixtures = path.resolve(fixtures, 'api/preload-realm');
const webContentsInternal: typeof ElectronInternal.WebContents = webContentsModule as any;
let ses: Electron.Session;
let serviceWorkers: Electron.ServiceWorkers;
let server: http.Server;
let baseUrl: string;
let wc: WebContents;
beforeEach(async () => {
ses = session.fromPartition(`service-worker-main-spec-${crypto.randomUUID()}`);
serviceWorkers = ses.serviceWorkers;
if (DEBUG) {
serviceWorkers.on('console-message', (_e, details) => {
console.log(details.message);
});
}
const uuid = crypto.randomUUID();
server = http.createServer((req, res) => {
const url = new URL(req.url!, `http://${req.headers.host}`);
// /{uuid}/{file}
const file = url.pathname!.split('/')[2]!;
if (file.endsWith('.js')) {
res.setHeader('Content-Type', 'application/javascript');
}
res.end(fs.readFileSync(path.resolve(fixtures, 'api', 'service-workers', file)));
});
const { port } = await listen(server);
baseUrl = `http://localhost:${port}/${uuid}`;
wc = webContentsInternal.create({ session: ses });
if (DEBUG) {
wc.on('console-message', (_e, _l, message) => {
console.log(message);
});
}
});
afterEach(async () => {
if (!wc.isDestroyed()) wc.destroy();
server.close();
ses.getPreloadScripts().map(({ id }) => ses.unregisterPreloadScript(id));
});
function registerPreload (scriptName: string) {
const id = ses.registerPreloadScript({
type: 'service-worker',
filePath: path.resolve(preloadRealmFixtures, scriptName)
});
expect(id).to.be.a('string');
}
async function loadWorkerScript (scriptUrl?: string) {
const scriptParams = scriptUrl ? `?scriptUrl=${scriptUrl}` : '';
return wc.loadURL(`${baseUrl}/index.html${scriptParams}`);
}
async function unregisterAllServiceWorkers () {
await wc.executeJavaScript(`(${async function () {
const registrations = await navigator.serviceWorker.getRegistrations();
for (const registration of registrations) {
registration.unregister();
}
}}())`);
}
async function waitForServiceWorker (expectedRunningStatus: Electron.ServiceWorkersRunningStatusChangedEventParams['runningStatus'] = 'starting') {
const serviceWorkerPromise = new Promise<Electron.ServiceWorkerMain>((resolve) => {
function onRunningStatusChanged ({ versionId, runningStatus }: Electron.ServiceWorkersRunningStatusChangedEventParams) {
if (runningStatus === expectedRunningStatus) {
const serviceWorker = serviceWorkers.getWorkerFromVersionID(versionId)!;
serviceWorkers.off('running-status-changed', onRunningStatusChanged);
resolve(serviceWorker);
}
}
serviceWorkers.on('running-status-changed', onRunningStatusChanged);
});
const serviceWorker = await serviceWorkerPromise;
expect(serviceWorker).to.not.be.undefined();
return serviceWorker!;
}
/** Runs a test using the framework in preload-tests.js */
const runTest = async (serviceWorker: Electron.ServiceWorkerMain, rpc: { name: string, args: any[] }) => {
const uuid = crypto.randomUUID();
serviceWorker.send('test', uuid, rpc.name, ...rpc.args);
return new Promise((resolve, reject) => {
serviceWorker.ipc.once(`test-result-${uuid}`, (_event, { error, result }) => {
if (error) {
reject(result);
} else {
resolve(result);
}
});
});
};
describe('serviceWorkers.getWorkerFromVersionID', () => {
it('returns undefined for non-live service worker', () => {
expect(serviceWorkers.getWorkerFromVersionID(-1)).to.be.undefined();
expect(serviceWorkers._getWorkerFromVersionIDIfExists(-1)).to.be.undefined();
});
it('returns instance for live service worker', async () => {
const runningStatusChanged = once(serviceWorkers, 'running-status-changed');
loadWorkerScript();
const [{ versionId }] = await runningStatusChanged;
const serviceWorker = serviceWorkers.getWorkerFromVersionID(versionId);
expect(serviceWorker).to.not.be.undefined();
const ifExistsServiceWorker = serviceWorkers._getWorkerFromVersionIDIfExists(versionId);
expect(ifExistsServiceWorker).to.not.be.undefined();
expect(serviceWorker).to.equal(ifExistsServiceWorker);
});
it('does not crash on script error', async () => {
wc.loadURL(`${baseUrl}/index.html?scriptUrl=sw-script-error.js`);
let serviceWorker;
const actualStatuses = [];
for await (const [{ versionId, runningStatus }] of on(serviceWorkers, 'running-status-changed')) {
if (!serviceWorker) {
serviceWorker = serviceWorkers.getWorkerFromVersionID(versionId);
}
actualStatuses.push(runningStatus);
if (runningStatus === 'stopping') {
break;
}
}
expect(actualStatuses).to.deep.equal(['starting', 'stopping']);
expect(serviceWorker).to.not.be.undefined();
});
it('does not find unregistered service worker', async () => {
loadWorkerScript();
const runningServiceWorker = await waitForServiceWorker('running');
const { versionId } = runningServiceWorker;
unregisterAllServiceWorkers();
await waitUntil(() => runningServiceWorker.isDestroyed());
const serviceWorker = serviceWorkers.getWorkerFromVersionID(versionId);
expect(serviceWorker).to.be.undefined();
});
});
describe('isDestroyed()', () => {
it('is not destroyed after being created', async () => {
loadWorkerScript();
const serviceWorker = await waitForServiceWorker();
expect(serviceWorker.isDestroyed()).to.be.false();
});
it('is destroyed after being unregistered', async () => {
loadWorkerScript();
const serviceWorker = await waitForServiceWorker();
expect(serviceWorker.isDestroyed()).to.be.false();
await unregisterAllServiceWorkers();
await waitUntil(() => serviceWorker.isDestroyed());
});
});
describe('"running-status-changed" event', () => {
it('handles when content::ServiceWorkerVersion has been destroyed', async () => {
loadWorkerScript('sw-unregister-self.js');
const serviceWorker = await waitForServiceWorker('running');
await waitUntil(() => serviceWorker.isDestroyed());
});
});
describe('startWorkerForScope()', () => {
it('resolves with running workers', async () => {
loadWorkerScript();
const serviceWorker = await waitForServiceWorker('running');
const startWorkerPromise = serviceWorkers.startWorkerForScope(serviceWorker.scope);
await expect(startWorkerPromise).to.eventually.be.fulfilled();
const otherSW = await startWorkerPromise;
expect(otherSW).to.equal(serviceWorker);
});
it('rejects with starting workers', async () => {
loadWorkerScript();
const serviceWorker = await waitForServiceWorker('starting');
const startWorkerPromise = serviceWorkers.startWorkerForScope(serviceWorker.scope);
await expect(startWorkerPromise).to.eventually.be.rejected();
});
it('starts previously stopped worker', async () => {
loadWorkerScript();
const serviceWorker = await waitForServiceWorker('running');
const { scope } = serviceWorker;
const stoppedPromise = waitForServiceWorker('stopped');
await serviceWorkers._stopAllWorkers();
await stoppedPromise;
const startWorkerPromise = serviceWorkers.startWorkerForScope(scope);
await expect(startWorkerPromise).to.eventually.be.fulfilled();
});
it('resolves when called twice', async () => {
loadWorkerScript();
const serviceWorker = await waitForServiceWorker('running');
const { scope } = serviceWorker;
const [swA, swB] = await Promise.all([
serviceWorkers.startWorkerForScope(scope),
serviceWorkers.startWorkerForScope(scope)
]);
expect(swA).to.equal(swB);
expect(swA).to.equal(serviceWorker);
});
});
describe('startTask()', () => {
it('has no tasks in-flight initially', async () => {
loadWorkerScript();
const serviceWorker = await waitForServiceWorker();
expect(serviceWorker._countExternalRequests()).to.equal(0);
});
it('can start and end a task', async () => {
loadWorkerScript();
// Internally, ServiceWorkerVersion buckets tasks into requests made
// during and after startup.
// ServiceWorkerContext::CountExternalRequestsForTest only considers
// requests made while SW is in running status so we need to wait for that
// to read an accurate count.
const serviceWorker = await waitForServiceWorker('running');
const task = serviceWorker.startTask();
expect(task).to.be.an('object');
expect(task).to.have.property('end').that.is.a('function');
expect(serviceWorker._countExternalRequests()).to.equal(1);
task.end();
// Count will decrement after Promise.finally callback
await new Promise<void>(queueMicrotask);
expect(serviceWorker._countExternalRequests()).to.equal(0);
});
it('can have more than one active task', async () => {
loadWorkerScript();
const serviceWorker = await waitForServiceWorker('running');
const taskA = serviceWorker.startTask();
const taskB = serviceWorker.startTask();
expect(serviceWorker._countExternalRequests()).to.equal(2);
taskB.end();
taskA.end();
// Count will decrement after Promise.finally callback
await new Promise<void>(queueMicrotask);
expect(serviceWorker._countExternalRequests()).to.equal(0);
});
it('throws when starting task after destroyed', async () => {
loadWorkerScript();
const serviceWorker = await waitForServiceWorker();
await unregisterAllServiceWorkers();
await waitUntil(() => serviceWorker.isDestroyed());
expect(() => serviceWorker.startTask()).to.throw();
});
it('throws when ending task after destroyed', async () => {
loadWorkerScript();
const serviceWorker = await waitForServiceWorker();
const task = serviceWorker.startTask();
await unregisterAllServiceWorkers();
await waitUntil(() => serviceWorker.isDestroyed());
expect(() => task.end()).to.throw();
});
});
describe("'versionId' property", () => {
it('matches the expected value', async () => {
const runningStatusChanged = once(serviceWorkers, 'running-status-changed');
wc.loadURL(`${baseUrl}/index.html`);
const [{ versionId }] = await runningStatusChanged;
const serviceWorker = serviceWorkers.getWorkerFromVersionID(versionId);
expect(serviceWorker).to.not.be.undefined();
if (!serviceWorker) return;
expect(serviceWorker).to.have.property('versionId').that.is.a('number');
expect(serviceWorker.versionId).to.equal(versionId);
});
});
describe("'scope' property", () => {
it('matches the expected value', async () => {
loadWorkerScript();
const serviceWorker = await waitForServiceWorker();
expect(serviceWorker).to.not.be.undefined();
if (!serviceWorker) return;
expect(serviceWorker).to.have.property('scope').that.is.a('string');
expect(serviceWorker.scope).to.equal(`${baseUrl}/`);
});
});
describe('ipc', () => {
beforeEach(() => {
registerPreload('preload-tests.js');
});
describe('on(channel)', () => {
it('can receive a message during startup', async () => {
registerPreload('preload-send-ping.js');
loadWorkerScript();
const serviceWorker = await waitForServiceWorker();
const pingPromise = once(serviceWorker.ipc, 'ping');
await pingPromise;
});
it('receives a message', async () => {
loadWorkerScript();
const serviceWorker = await waitForServiceWorker('running');
const pingPromise = once(serviceWorker.ipc, 'ping');
runTest(serviceWorker, { name: 'testSend', args: ['ping'] });
await pingPromise;
});
it('does not receive message on ipcMain', async () => {
loadWorkerScript();
const serviceWorker = await waitForServiceWorker('running');
const abortController = new AbortController();
try {
let pingReceived = false;
once(ipcMain, 'ping', { signal: abortController.signal }).then(() => {
pingReceived = true;
});
runTest(serviceWorker, { name: 'testSend', args: ['ping'] });
await once(ses, '-ipc-message');
await new Promise<void>(queueMicrotask);
expect(pingReceived).to.be.false();
} finally {
abortController.abort();
}
});
});
describe('handle(channel)', () => {
it('receives and responds to message', async () => {
loadWorkerScript();
const serviceWorker = await waitForServiceWorker('running');
serviceWorker.ipc.handle('ping', () => 'pong');
const result = await runTest(serviceWorker, { name: 'testInvoke', args: ['ping'] });
expect(result).to.equal('pong');
});
it('works after restarting worker', async () => {
loadWorkerScript();
const serviceWorker = await waitForServiceWorker('running');
const { scope } = serviceWorker;
serviceWorker.ipc.handle('ping', () => 'pong');
await serviceWorkers._stopAllWorkers();
await serviceWorkers.startWorkerForScope(scope);
const result = await runTest(serviceWorker, { name: 'testInvoke', args: ['ping'] });
expect(result).to.equal('pong');
});
});
});
describe('contextBridge', () => {
beforeEach(() => {
registerPreload('preload-tests.js');
});
it('can evaluate func from preload realm', async () => {
loadWorkerScript();
const serviceWorker = await waitForServiceWorker('running');
const result = await runTest(serviceWorker, { name: 'testEvaluate', args: ['evalConstructorName'] });
expect(result).to.equal('ServiceWorkerGlobalScope');
});
});
describe('extensions', () => {
const extensionFixtures = path.join(fixtures, 'extensions');
const testExtensionFixture = path.join(extensionFixtures, 'mv3-service-worker');
beforeEach(async () => {
ses = session.fromPartition(`persist:${crypto.randomUUID()}-service-worker-main-spec`);
serviceWorkers = ses.serviceWorkers;
});
it('can observe extension service workers', async () => {
const serviceWorkerPromise = waitForServiceWorker();
const extension = await ses.loadExtension(testExtensionFixture);
const serviceWorker = await serviceWorkerPromise;
expect(serviceWorker.scope).to.equal(extension.url);
});
it('has extension state available when preload runs', async () => {
registerPreload('preload-send-extension.js');
const serviceWorkerPromise = waitForServiceWorker();
const extensionPromise = ses.loadExtension(testExtensionFixture);
const serviceWorker = await serviceWorkerPromise;
const result = await new Promise<any>((resolve) => {
serviceWorker.ipc.handleOnce('preload-extension-result', (_event, result) => {
resolve(result);
});
});
const extension = await extensionPromise;
expect(result).to.be.an('object');
expect(result.id).to.equal(extension.id);
expect(result.manifest).to.deep.equal(result.manifest);
});
});
});