feat: service worker preload scripts for improved extensions support (#44411)

* feat: preload scripts for service workers

* feat: service worker IPC

* test: service worker preload scripts and ipc
This commit is contained in:
Sam Maddock 2025-01-31 09:32:45 -05:00 committed by GitHub
parent bc22ee7897
commit 26da3c5d6e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
67 changed files with 2103 additions and 298 deletions

View file

@ -1,4 +1,4 @@
import { session, webContents as webContentsModule, WebContents } from 'electron/main';
import { ipcMain, session, webContents as webContentsModule, WebContents } from 'electron/main';
import { expect } from 'chai';
@ -14,6 +14,7 @@ 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;
@ -61,8 +62,17 @@ describe('ServiceWorkerMain module', () => {
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}`);
@ -93,6 +103,21 @@ describe('ServiceWorkerMain module', () => {
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();
@ -255,7 +280,7 @@ describe('ServiceWorkerMain module', () => {
expect(() => serviceWorker.startTask()).to.throw();
});
it('throws when ending task after destroyed', async function () {
it('throws when ending task after destroyed', async () => {
loadWorkerScript();
const serviceWorker = await waitForServiceWorker();
const task = serviceWorker.startTask();
@ -288,4 +313,113 @@ describe('ServiceWorkerMain module', () => {
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);
});
});
});

View file

@ -27,8 +27,9 @@ describe('session.serviceWorkers', () => {
const uuid = v4();
server = http.createServer((req, res) => {
const url = new URL(req.url!, `http://${req.headers.host}`);
// /{uuid}/{file}
const file = req.url!.split('/')[2]!;
const file = url.pathname!.split('/')[2]!;
if (file.endsWith('.js')) {
res.setHeader('Content-Type', 'application/javascript');
@ -76,7 +77,7 @@ describe('session.serviceWorkers', () => {
describe('console-message event', () => {
it('should correctly keep the source, message and level', async () => {
const messages: Record<string, Electron.MessageDetails> = {};
w.loadURL(`${baseUrl}/logs.html`);
w.loadURL(`${baseUrl}/index.html?scriptUrl=sw-logs.js`);
for await (const [, details] of on(ses.serviceWorkers, 'console-message')) {
messages[details.message] = details;
expect(details).to.have.property('source', 'console-api');

View file

@ -0,0 +1,16 @@
const { contextBridge, ipcRenderer } = require('electron');
let result;
try {
result = contextBridge.executeInMainWorld({
func: () => ({
chromeType: typeof chrome,
id: globalThis.chrome?.runtime.id,
manifest: globalThis.chrome?.runtime.getManifest()
})
});
} catch (error) {
console.error(error);
}
ipcRenderer.invoke('preload-extension-result', result);

View file

@ -0,0 +1,3 @@
const { ipcRenderer } = require('electron');
ipcRenderer.send('ping');

View file

@ -0,0 +1,34 @@
const { contextBridge, ipcRenderer } = require('electron');
const evalTests = {
evalConstructorName: () => globalThis.constructor.name
};
const tests = {
testSend: (name, ...args) => {
ipcRenderer.send(name, ...args);
},
testInvoke: async (name, ...args) => {
const result = await ipcRenderer.invoke(name, ...args);
return result;
},
testEvaluate: (testName, args) => {
const func = evalTests[testName];
const result = args
? contextBridge.executeInMainWorld({ func, args })
: contextBridge.executeInMainWorld({ func });
return result;
}
};
ipcRenderer.on('test', async (_event, uuid, name, ...args) => {
console.debug(`running test ${name} for ${uuid}`);
try {
const result = await tests[name]?.(...args);
console.debug(`responding test ${name} for ${uuid}`);
ipcRenderer.send(`test-result-${uuid}`, { error: false, result });
} catch (error) {
console.debug(`erroring test ${name} for ${uuid}`);
ipcRenderer.send(`test-result-${uuid}`, { error: true, result: error.message });
}
});

View file

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