api-service-worker-main-spec.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. import { ipcMain, session, webContents as webContentsModule, WebContents } from 'electron/main';
  2. import { expect } from 'chai';
  3. import { once, on } from 'node:events';
  4. import * as fs from 'node:fs';
  5. import * as http from 'node:http';
  6. import * as path from 'node:path';
  7. import { listen, waitUntil } from './lib/spec-helpers';
  8. // Toggle to add extra debug output
  9. const DEBUG = !process.env.CI;
  10. describe('ServiceWorkerMain module', () => {
  11. const fixtures = path.resolve(__dirname, 'fixtures');
  12. const preloadRealmFixtures = path.resolve(fixtures, 'api/preload-realm');
  13. const webContentsInternal: typeof ElectronInternal.WebContents = webContentsModule as any;
  14. let ses: Electron.Session;
  15. let serviceWorkers: Electron.ServiceWorkers;
  16. let server: http.Server;
  17. let baseUrl: string;
  18. let wc: WebContents;
  19. beforeEach(async () => {
  20. ses = session.fromPartition(`service-worker-main-spec-${crypto.randomUUID()}`);
  21. serviceWorkers = ses.serviceWorkers;
  22. if (DEBUG) {
  23. serviceWorkers.on('console-message', (_e, details) => {
  24. console.log(details.message);
  25. });
  26. serviceWorkers.on('running-status-changed', ({ versionId, runningStatus }) => {
  27. console.log(`version ${versionId} is ${runningStatus}`);
  28. });
  29. }
  30. const uuid = crypto.randomUUID();
  31. server = http.createServer((req, res) => {
  32. const url = new URL(req.url!, `http://${req.headers.host}`);
  33. // /{uuid}/{file}
  34. const file = url.pathname!.split('/')[2]!;
  35. if (file.endsWith('.js')) {
  36. res.setHeader('Content-Type', 'application/javascript');
  37. }
  38. res.end(fs.readFileSync(path.resolve(fixtures, 'api', 'service-workers', file)));
  39. });
  40. const { port } = await listen(server);
  41. baseUrl = `http://localhost:${port}/${uuid}`;
  42. wc = webContentsInternal.create({ session: ses });
  43. if (DEBUG) {
  44. wc.on('console-message', ({ message }) => {
  45. console.log(message);
  46. });
  47. }
  48. });
  49. afterEach(async () => {
  50. if (!wc.isDestroyed()) wc.destroy();
  51. server.close();
  52. ses.getPreloadScripts().map(({ id }) => ses.unregisterPreloadScript(id));
  53. });
  54. function registerPreload (scriptName: string) {
  55. const id = ses.registerPreloadScript({
  56. type: 'service-worker',
  57. filePath: path.resolve(preloadRealmFixtures, scriptName)
  58. });
  59. expect(id).to.be.a('string');
  60. }
  61. async function loadWorkerScript (scriptUrl?: string) {
  62. const scriptParams = scriptUrl ? `?scriptUrl=${scriptUrl}` : '';
  63. return wc.loadURL(`${baseUrl}/index.html${scriptParams}`);
  64. }
  65. async function unregisterAllServiceWorkers () {
  66. await wc.executeJavaScript(`(${async function () {
  67. const registrations = await navigator.serviceWorker.getRegistrations();
  68. for (const registration of registrations) {
  69. registration.unregister();
  70. }
  71. }}())`);
  72. }
  73. async function waitForServiceWorker (expectedRunningStatus: Electron.ServiceWorkersRunningStatusChangedEventParams['runningStatus'] = 'starting') {
  74. const serviceWorkerPromise = new Promise<Electron.ServiceWorkerMain>((resolve) => {
  75. function onRunningStatusChanged ({ versionId, runningStatus }: Electron.ServiceWorkersRunningStatusChangedEventParams) {
  76. if (runningStatus === expectedRunningStatus) {
  77. const serviceWorker = serviceWorkers.getWorkerFromVersionID(versionId)!;
  78. serviceWorkers.off('running-status-changed', onRunningStatusChanged);
  79. resolve(serviceWorker);
  80. }
  81. }
  82. serviceWorkers.on('running-status-changed', onRunningStatusChanged);
  83. });
  84. const serviceWorker = await serviceWorkerPromise;
  85. expect(serviceWorker).to.not.be.undefined();
  86. return serviceWorker!;
  87. }
  88. /** Runs a test using the framework in preload-tests.js */
  89. const runTest = async (serviceWorker: Electron.ServiceWorkerMain, rpc: { name: string, args: any[] }) => {
  90. const uuid = crypto.randomUUID();
  91. serviceWorker.send('test', uuid, rpc.name, ...rpc.args);
  92. return new Promise((resolve, reject) => {
  93. serviceWorker.ipc.once(`test-result-${uuid}`, (_event, { error, result }) => {
  94. if (error) {
  95. reject(result);
  96. } else {
  97. resolve(result);
  98. }
  99. });
  100. });
  101. };
  102. describe('serviceWorkers.getWorkerFromVersionID', () => {
  103. it('returns undefined for non-live service worker', () => {
  104. expect(serviceWorkers.getWorkerFromVersionID(-1)).to.be.undefined();
  105. expect(serviceWorkers._getWorkerFromVersionIDIfExists(-1)).to.be.undefined();
  106. });
  107. it('returns instance for live service worker', async () => {
  108. const runningStatusChanged = once(serviceWorkers, 'running-status-changed');
  109. loadWorkerScript();
  110. const [{ versionId }] = await runningStatusChanged;
  111. const serviceWorker = serviceWorkers.getWorkerFromVersionID(versionId);
  112. expect(serviceWorker).to.not.be.undefined();
  113. const ifExistsServiceWorker = serviceWorkers._getWorkerFromVersionIDIfExists(versionId);
  114. expect(ifExistsServiceWorker).to.not.be.undefined();
  115. expect(serviceWorker).to.equal(ifExistsServiceWorker);
  116. });
  117. it('does not crash on script error', async () => {
  118. wc.loadURL(`${baseUrl}/index.html?scriptUrl=sw-script-error.js`);
  119. let serviceWorker;
  120. const actualStatuses = [];
  121. for await (const [{ versionId, runningStatus }] of on(serviceWorkers, 'running-status-changed')) {
  122. if (!serviceWorker) {
  123. serviceWorker = serviceWorkers.getWorkerFromVersionID(versionId);
  124. }
  125. actualStatuses.push(runningStatus);
  126. if (runningStatus === 'stopping') {
  127. break;
  128. }
  129. }
  130. expect(actualStatuses).to.deep.equal(['starting', 'stopping']);
  131. expect(serviceWorker).to.not.be.undefined();
  132. });
  133. it('does not find unregistered service worker', async () => {
  134. loadWorkerScript();
  135. const runningServiceWorker = await waitForServiceWorker('running');
  136. const { versionId } = runningServiceWorker;
  137. unregisterAllServiceWorkers();
  138. await waitUntil(() => runningServiceWorker.isDestroyed());
  139. const serviceWorker = serviceWorkers.getWorkerFromVersionID(versionId);
  140. expect(serviceWorker).to.be.undefined();
  141. });
  142. });
  143. describe('isDestroyed()', () => {
  144. it('is not destroyed after being created', async () => {
  145. loadWorkerScript();
  146. const serviceWorker = await waitForServiceWorker();
  147. expect(serviceWorker.isDestroyed()).to.be.false();
  148. });
  149. it('is destroyed after being unregistered', async () => {
  150. loadWorkerScript();
  151. const serviceWorker = await waitForServiceWorker();
  152. expect(serviceWorker.isDestroyed()).to.be.false();
  153. await unregisterAllServiceWorkers();
  154. await waitUntil(() => serviceWorker.isDestroyed());
  155. });
  156. });
  157. describe('"running-status-changed" event', () => {
  158. it('handles when content::ServiceWorkerVersion has been destroyed', async () => {
  159. loadWorkerScript('sw-unregister-self.js');
  160. const serviceWorker = await waitForServiceWorker('running');
  161. await waitUntil(() => serviceWorker.isDestroyed());
  162. });
  163. });
  164. describe('startWorkerForScope()', () => {
  165. it('resolves with running workers', async () => {
  166. loadWorkerScript();
  167. const serviceWorker = await waitForServiceWorker('running');
  168. const startWorkerPromise = serviceWorkers.startWorkerForScope(serviceWorker.scope);
  169. await expect(startWorkerPromise).to.eventually.be.fulfilled();
  170. const otherSW = await startWorkerPromise;
  171. expect(otherSW).to.equal(serviceWorker);
  172. });
  173. it('rejects with starting workers', async () => {
  174. loadWorkerScript();
  175. const serviceWorker = await waitForServiceWorker('starting');
  176. const startWorkerPromise = serviceWorkers.startWorkerForScope(serviceWorker.scope);
  177. await expect(startWorkerPromise).to.eventually.be.rejected();
  178. });
  179. it('starts previously stopped worker', async () => {
  180. loadWorkerScript();
  181. const serviceWorker = await waitForServiceWorker('running');
  182. const { scope } = serviceWorker;
  183. const stoppedPromise = waitForServiceWorker('stopped');
  184. await serviceWorkers._stopAllWorkers();
  185. await stoppedPromise;
  186. const startWorkerPromise = serviceWorkers.startWorkerForScope(scope);
  187. await expect(startWorkerPromise).to.eventually.be.fulfilled();
  188. });
  189. it('resolves when called twice', async () => {
  190. loadWorkerScript();
  191. const serviceWorker = await waitForServiceWorker('running');
  192. const { scope } = serviceWorker;
  193. const [swA, swB] = await Promise.all([
  194. serviceWorkers.startWorkerForScope(scope),
  195. serviceWorkers.startWorkerForScope(scope)
  196. ]);
  197. expect(swA).to.equal(swB);
  198. expect(swA).to.equal(serviceWorker);
  199. });
  200. });
  201. describe('startTask()', () => {
  202. it('has no tasks in-flight initially', async () => {
  203. loadWorkerScript();
  204. const serviceWorker = await waitForServiceWorker();
  205. expect(serviceWorker._countExternalRequests()).to.equal(0);
  206. });
  207. it('can start and end a task', async () => {
  208. loadWorkerScript();
  209. // Internally, ServiceWorkerVersion buckets tasks into requests made
  210. // during and after startup.
  211. // ServiceWorkerContext::CountExternalRequestsForTest only considers
  212. // requests made while SW is in running status so we need to wait for that
  213. // to read an accurate count.
  214. const serviceWorker = await waitForServiceWorker('running');
  215. const task = serviceWorker.startTask();
  216. expect(task).to.be.an('object');
  217. expect(task).to.have.property('end').that.is.a('function');
  218. expect(serviceWorker._countExternalRequests()).to.equal(1);
  219. task.end();
  220. // Count will decrement after Promise.finally callback
  221. await new Promise<void>(queueMicrotask);
  222. expect(serviceWorker._countExternalRequests()).to.equal(0);
  223. });
  224. it('can have more than one active task', async () => {
  225. loadWorkerScript();
  226. const serviceWorker = await waitForServiceWorker('running');
  227. const taskA = serviceWorker.startTask();
  228. const taskB = serviceWorker.startTask();
  229. expect(serviceWorker._countExternalRequests()).to.equal(2);
  230. taskB.end();
  231. taskA.end();
  232. // Count will decrement after Promise.finally callback
  233. await new Promise<void>(queueMicrotask);
  234. expect(serviceWorker._countExternalRequests()).to.equal(0);
  235. });
  236. it('throws when starting task after destroyed', async () => {
  237. loadWorkerScript();
  238. const serviceWorker = await waitForServiceWorker();
  239. await unregisterAllServiceWorkers();
  240. await waitUntil(() => serviceWorker.isDestroyed());
  241. expect(() => serviceWorker.startTask()).to.throw();
  242. });
  243. it('throws when ending task after destroyed', async () => {
  244. loadWorkerScript();
  245. const serviceWorker = await waitForServiceWorker();
  246. const task = serviceWorker.startTask();
  247. await unregisterAllServiceWorkers();
  248. await waitUntil(() => serviceWorker.isDestroyed());
  249. expect(() => task.end()).to.throw();
  250. });
  251. });
  252. describe("'versionId' property", () => {
  253. it('matches the expected value', async () => {
  254. const runningStatusChanged = once(serviceWorkers, 'running-status-changed');
  255. wc.loadURL(`${baseUrl}/index.html`);
  256. const [{ versionId }] = await runningStatusChanged;
  257. const serviceWorker = serviceWorkers.getWorkerFromVersionID(versionId);
  258. expect(serviceWorker).to.not.be.undefined();
  259. if (!serviceWorker) return;
  260. expect(serviceWorker).to.have.property('versionId').that.is.a('number');
  261. expect(serviceWorker.versionId).to.equal(versionId);
  262. });
  263. });
  264. describe("'scope' property", () => {
  265. it('matches the expected value', async () => {
  266. loadWorkerScript();
  267. const serviceWorker = await waitForServiceWorker();
  268. expect(serviceWorker).to.not.be.undefined();
  269. if (!serviceWorker) return;
  270. expect(serviceWorker).to.have.property('scope').that.is.a('string');
  271. expect(serviceWorker.scope).to.equal(`${baseUrl}/`);
  272. });
  273. });
  274. describe('ipc', () => {
  275. beforeEach(() => {
  276. registerPreload('preload-tests.js');
  277. });
  278. describe('on(channel)', () => {
  279. it('can receive a message during startup', async () => {
  280. registerPreload('preload-send-ping.js');
  281. loadWorkerScript();
  282. const serviceWorker = await waitForServiceWorker();
  283. const pingPromise = once(serviceWorker.ipc, 'ping');
  284. await pingPromise;
  285. });
  286. it('receives a message', async () => {
  287. loadWorkerScript();
  288. const serviceWorker = await waitForServiceWorker('running');
  289. const pingPromise = once(serviceWorker.ipc, 'ping');
  290. runTest(serviceWorker, { name: 'testSend', args: ['ping'] });
  291. await pingPromise;
  292. });
  293. it('does not receive message on ipcMain', async () => {
  294. loadWorkerScript();
  295. const serviceWorker = await waitForServiceWorker('running');
  296. const abortController = new AbortController();
  297. try {
  298. let pingReceived = false;
  299. once(ipcMain, 'ping', { signal: abortController.signal }).then(() => {
  300. pingReceived = true;
  301. });
  302. runTest(serviceWorker, { name: 'testSend', args: ['ping'] });
  303. await once(ses, '-ipc-message');
  304. await new Promise<void>(queueMicrotask);
  305. expect(pingReceived).to.be.false();
  306. } finally {
  307. abortController.abort();
  308. }
  309. });
  310. });
  311. describe('handle(channel)', () => {
  312. it('receives and responds to message', async () => {
  313. loadWorkerScript();
  314. const serviceWorker = await waitForServiceWorker('running');
  315. serviceWorker.ipc.handle('ping', () => 'pong');
  316. const result = await runTest(serviceWorker, { name: 'testInvoke', args: ['ping'] });
  317. expect(result).to.equal('pong');
  318. });
  319. it('works after restarting worker', async () => {
  320. loadWorkerScript();
  321. const serviceWorker = await waitForServiceWorker('running');
  322. const { scope } = serviceWorker;
  323. serviceWorker.ipc.handle('ping', () => 'pong');
  324. await serviceWorkers._stopAllWorkers();
  325. await serviceWorkers.startWorkerForScope(scope);
  326. const result = await runTest(serviceWorker, { name: 'testInvoke', args: ['ping'] });
  327. expect(result).to.equal('pong');
  328. });
  329. });
  330. });
  331. describe('contextBridge', () => {
  332. beforeEach(() => {
  333. registerPreload('preload-tests.js');
  334. });
  335. it('can evaluate func from preload realm', async () => {
  336. loadWorkerScript();
  337. const serviceWorker = await waitForServiceWorker('running');
  338. const result = await runTest(serviceWorker, { name: 'testEvaluate', args: ['evalConstructorName'] });
  339. expect(result).to.equal('ServiceWorkerGlobalScope');
  340. });
  341. it('does not leak prototypes', async () => {
  342. loadWorkerScript();
  343. const serviceWorker = await waitForServiceWorker('running');
  344. const result = await runTest(serviceWorker, { name: 'testPrototypeLeak', args: [] });
  345. expect(result).to.be.true();
  346. });
  347. });
  348. describe('extensions', () => {
  349. const extensionFixtures = path.join(fixtures, 'extensions');
  350. const testExtensionFixture = path.join(extensionFixtures, 'mv3-service-worker');
  351. beforeEach(async () => {
  352. ses = session.fromPartition(`persist:${crypto.randomUUID()}-service-worker-main-spec`);
  353. serviceWorkers = ses.serviceWorkers;
  354. });
  355. it('can observe extension service workers', async () => {
  356. const serviceWorkerPromise = waitForServiceWorker();
  357. const extension = await ses.extensions.loadExtension(testExtensionFixture);
  358. const serviceWorker = await serviceWorkerPromise;
  359. expect(serviceWorker.scope).to.equal(extension.url);
  360. });
  361. it('has extension state available when preload runs', async () => {
  362. registerPreload('preload-send-extension.js');
  363. const serviceWorkerPromise = waitForServiceWorker();
  364. const extensionPromise = ses.extensions.loadExtension(testExtensionFixture);
  365. const serviceWorker = await serviceWorkerPromise;
  366. const result = await new Promise<any>((resolve) => {
  367. serviceWorker.ipc.handleOnce('preload-extension-result', (_event, result) => {
  368. resolve(result);
  369. });
  370. });
  371. const extension = await extensionPromise;
  372. expect(result).to.be.an('object');
  373. expect(result.id).to.equal(extension.id);
  374. expect(result.manifest).to.deep.equal(result.manifest);
  375. });
  376. });
  377. });