api-autoupdater-darwin-spec.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586
  1. import { expect } from 'chai';
  2. import * as cp from 'child_process';
  3. import * as http from 'http';
  4. import * as express from 'express';
  5. import * as fs from 'fs-extra';
  6. import * as os from 'os';
  7. import * as path from 'path';
  8. import { AddressInfo } from 'net';
  9. import { ifdescribe, ifit } from './spec-helpers';
  10. import * as uuid from 'uuid';
  11. import { systemPreferences } from 'electron';
  12. const features = process._linkedBinding('electron_common_features');
  13. const fixturesPath = path.resolve(__dirname, 'fixtures');
  14. // We can only test the auto updater on darwin non-component builds
  15. ifdescribe(process.platform === 'darwin' && !(process.env.CI && process.arch === 'arm64') && !process.mas && !features.isComponentBuild())('autoUpdater behavior', function () {
  16. this.timeout(120000);
  17. let identity = '';
  18. beforeEach(function () {
  19. const result = cp.spawnSync(path.resolve(__dirname, '../script/codesign/get-trusted-identity.sh'));
  20. if (result.status !== 0 || result.stdout.toString().trim().length === 0) {
  21. // Per https://circleci.com/docs/2.0/env-vars:
  22. // CIRCLE_PR_NUMBER is only present on forked PRs
  23. if (process.env.CI && !process.env.CIRCLE_PR_NUMBER) {
  24. throw new Error('No valid signing identity available to run autoUpdater specs');
  25. }
  26. this.skip();
  27. } else {
  28. identity = result.stdout.toString().trim();
  29. }
  30. });
  31. it('should have a valid code signing identity', () => {
  32. expect(identity).to.be.a('string').with.lengthOf.at.least(1);
  33. });
  34. const copyApp = async (newDir: string, fixture = 'initial') => {
  35. const appBundlePath = path.resolve(process.execPath, '../../..');
  36. const newPath = path.resolve(newDir, 'Electron.app');
  37. cp.spawnSync('cp', ['-R', appBundlePath, path.dirname(newPath)]);
  38. const appDir = path.resolve(newPath, 'Contents/Resources/app');
  39. await fs.mkdirp(appDir);
  40. await fs.copy(path.resolve(fixturesPath, 'auto-update', fixture), appDir);
  41. const plistPath = path.resolve(newPath, 'Contents', 'Info.plist');
  42. await fs.writeFile(
  43. plistPath,
  44. (await fs.readFile(plistPath, 'utf8')).replace('<key>BuildMachineOSBuild</key>', `<key>NSAppTransportSecurity</key>
  45. <dict>
  46. <key>NSAllowsArbitraryLoads</key>
  47. <true/>
  48. <key>NSExceptionDomains</key>
  49. <dict>
  50. <key>localhost</key>
  51. <dict>
  52. <key>NSExceptionAllowsInsecureHTTPLoads</key>
  53. <true/>
  54. <key>NSIncludesSubdomains</key>
  55. <true/>
  56. </dict>
  57. </dict>
  58. </dict><key>BuildMachineOSBuild</key>`)
  59. );
  60. return newPath;
  61. };
  62. const spawn = (cmd: string, args: string[], opts: any = {}) => {
  63. let out = '';
  64. const child = cp.spawn(cmd, args, opts);
  65. child.stdout.on('data', (chunk: Buffer) => {
  66. out += chunk.toString();
  67. });
  68. child.stderr.on('data', (chunk: Buffer) => {
  69. out += chunk.toString();
  70. });
  71. return new Promise<{ code: number, out: string }>((resolve) => {
  72. child.on('exit', (code, signal) => {
  73. expect(signal).to.equal(null);
  74. resolve({
  75. code: code!,
  76. out
  77. });
  78. });
  79. });
  80. };
  81. const signApp = (appPath: string) => {
  82. return spawn('codesign', ['-s', identity, '--deep', '--force', appPath]);
  83. };
  84. const launchApp = (appPath: string, args: string[] = []) => {
  85. return spawn(path.resolve(appPath, 'Contents/MacOS/Electron'), args);
  86. };
  87. const withTempDirectory = async (fn: (dir: string) => Promise<void>, autoCleanUp = true) => {
  88. const dir = await fs.mkdtemp(path.resolve(os.tmpdir(), 'electron-update-spec-'));
  89. try {
  90. await fn(dir);
  91. } finally {
  92. if (autoCleanUp) {
  93. cp.spawnSync('rm', ['-r', dir]);
  94. }
  95. }
  96. };
  97. const logOnError = (what: any, fn: () => void) => {
  98. try {
  99. fn();
  100. } catch (err) {
  101. console.error(what);
  102. throw err;
  103. }
  104. };
  105. const cachedZips: Record<string, string> = {};
  106. const getOrCreateUpdateZipPath = async (version: string, fixture: string, mutateAppPostSign?: {
  107. mutate: (appPath: string) => Promise<void>,
  108. mutationKey: string,
  109. }) => {
  110. const key = `${version}-${fixture}-${mutateAppPostSign?.mutationKey || 'no-mutation'}`;
  111. if (!cachedZips[key]) {
  112. let updateZipPath: string;
  113. await withTempDirectory(async (dir) => {
  114. const secondAppPath = await copyApp(dir, fixture);
  115. const appPJPath = path.resolve(secondAppPath, 'Contents', 'Resources', 'app', 'package.json');
  116. await fs.writeFile(
  117. appPJPath,
  118. (await fs.readFile(appPJPath, 'utf8')).replace('1.0.0', version)
  119. );
  120. await signApp(secondAppPath);
  121. await mutateAppPostSign?.mutate(secondAppPath);
  122. updateZipPath = path.resolve(dir, 'update.zip');
  123. await spawn('zip', ['-0', '-r', '--symlinks', updateZipPath, './'], {
  124. cwd: dir
  125. });
  126. }, false);
  127. cachedZips[key] = updateZipPath!;
  128. }
  129. return cachedZips[key];
  130. };
  131. after(() => {
  132. for (const version of Object.keys(cachedZips)) {
  133. cp.spawnSync('rm', ['-r', path.dirname(cachedZips[version])]);
  134. }
  135. });
  136. // On arm64 builds the built app is self-signed by default so the setFeedURL call always works
  137. ifit(process.arch !== 'arm64')('should fail to set the feed URL when the app is not signed', async () => {
  138. await withTempDirectory(async (dir) => {
  139. const appPath = await copyApp(dir);
  140. const launchResult = await launchApp(appPath, ['http://myupdate']);
  141. console.log(launchResult);
  142. expect(launchResult.code).to.equal(1);
  143. expect(launchResult.out).to.include('Could not get code signature for running application');
  144. });
  145. });
  146. it('should cleanly set the feed URL when the app is signed', async () => {
  147. await withTempDirectory(async (dir) => {
  148. const appPath = await copyApp(dir);
  149. await signApp(appPath);
  150. const launchResult = await launchApp(appPath, ['http://myupdate']);
  151. expect(launchResult.code).to.equal(0);
  152. expect(launchResult.out).to.include('Feed URL Set: http://myupdate');
  153. });
  154. });
  155. describe('with update server', () => {
  156. let port = 0;
  157. let server: express.Application = null as any;
  158. let httpServer: http.Server = null as any;
  159. let requests: express.Request[] = [];
  160. beforeEach((done) => {
  161. requests = [];
  162. server = express();
  163. server.use((req, res, next) => {
  164. requests.push(req);
  165. next();
  166. });
  167. httpServer = server.listen(0, '127.0.0.1', () => {
  168. port = (httpServer.address() as AddressInfo).port;
  169. done();
  170. });
  171. });
  172. afterEach(async () => {
  173. if (httpServer) {
  174. await new Promise<void>(resolve => {
  175. httpServer.close(() => {
  176. httpServer = null as any;
  177. server = null as any;
  178. resolve();
  179. });
  180. });
  181. }
  182. });
  183. it('should hit the update endpoint when checkForUpdates is called', async () => {
  184. await withTempDirectory(async (dir) => {
  185. const appPath = await copyApp(dir, 'check');
  186. await signApp(appPath);
  187. server.get('/update-check', (req, res) => {
  188. res.status(204).send();
  189. });
  190. const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
  191. logOnError(launchResult, () => {
  192. expect(launchResult.code).to.equal(0);
  193. expect(requests).to.have.lengthOf(1);
  194. expect(requests[0]).to.have.property('url', '/update-check');
  195. expect(requests[0].header('user-agent')).to.include('Electron/');
  196. });
  197. });
  198. });
  199. it('should hit the update endpoint with customer headers when checkForUpdates is called', async () => {
  200. await withTempDirectory(async (dir) => {
  201. const appPath = await copyApp(dir, 'check-with-headers');
  202. await signApp(appPath);
  203. server.get('/update-check', (req, res) => {
  204. res.status(204).send();
  205. });
  206. const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
  207. logOnError(launchResult, () => {
  208. expect(launchResult.code).to.equal(0);
  209. expect(requests).to.have.lengthOf(1);
  210. expect(requests[0]).to.have.property('url', '/update-check');
  211. expect(requests[0].header('x-test')).to.equal('this-is-a-test');
  212. });
  213. });
  214. });
  215. it('should hit the download endpoint when an update is available and error if the file is bad', async () => {
  216. await withTempDirectory(async (dir) => {
  217. const appPath = await copyApp(dir, 'update');
  218. await signApp(appPath);
  219. server.get('/update-file', (req, res) => {
  220. res.status(500).send('This is not a file');
  221. });
  222. server.get('/update-check', (req, res) => {
  223. res.json({
  224. url: `http://localhost:${port}/update-file`,
  225. name: 'My Release Name',
  226. notes: 'Theses are some release notes innit',
  227. pub_date: (new Date()).toString()
  228. });
  229. });
  230. const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
  231. logOnError(launchResult, () => {
  232. expect(launchResult).to.have.property('code', 1);
  233. expect(launchResult.out).to.include('Update download failed. The server sent an invalid response.');
  234. expect(requests).to.have.lengthOf(2);
  235. expect(requests[0]).to.have.property('url', '/update-check');
  236. expect(requests[1]).to.have.property('url', '/update-file');
  237. expect(requests[0].header('user-agent')).to.include('Electron/');
  238. expect(requests[1].header('user-agent')).to.include('Electron/');
  239. });
  240. });
  241. });
  242. const withUpdatableApp = async (opts: {
  243. nextVersion: string;
  244. startFixture: string;
  245. endFixture: string;
  246. mutateAppPostSign?: {
  247. mutate: (appPath: string) => Promise<void>,
  248. mutationKey: string,
  249. }
  250. }, fn: (appPath: string, zipPath: string) => Promise<void>) => {
  251. await withTempDirectory(async (dir) => {
  252. const appPath = await copyApp(dir, opts.startFixture);
  253. await signApp(appPath);
  254. const updateZipPath = await getOrCreateUpdateZipPath(opts.nextVersion, opts.endFixture, opts.mutateAppPostSign);
  255. await fn(appPath, updateZipPath);
  256. });
  257. };
  258. it('should hit the download endpoint when an update is available and update successfully when the zip is provided', async () => {
  259. await withUpdatableApp({
  260. nextVersion: '2.0.0',
  261. startFixture: 'update',
  262. endFixture: 'update'
  263. }, async (appPath, updateZipPath) => {
  264. server.get('/update-file', (req, res) => {
  265. res.download(updateZipPath);
  266. });
  267. server.get('/update-check', (req, res) => {
  268. res.json({
  269. url: `http://localhost:${port}/update-file`,
  270. name: 'My Release Name',
  271. notes: 'Theses are some release notes innit',
  272. pub_date: (new Date()).toString()
  273. });
  274. });
  275. const relaunchPromise = new Promise<void>((resolve) => {
  276. server.get('/update-check/updated/:version', (req, res) => {
  277. res.status(204).send();
  278. resolve();
  279. });
  280. });
  281. const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
  282. logOnError(launchResult, () => {
  283. expect(launchResult).to.have.property('code', 0);
  284. expect(launchResult.out).to.include('Update Downloaded');
  285. expect(requests).to.have.lengthOf(2);
  286. expect(requests[0]).to.have.property('url', '/update-check');
  287. expect(requests[1]).to.have.property('url', '/update-file');
  288. expect(requests[0].header('user-agent')).to.include('Electron/');
  289. expect(requests[1].header('user-agent')).to.include('Electron/');
  290. });
  291. await relaunchPromise;
  292. expect(requests).to.have.lengthOf(3);
  293. expect(requests[2].url).to.equal('/update-check/updated/2.0.0');
  294. expect(requests[2].header('user-agent')).to.include('Electron/');
  295. });
  296. });
  297. describe('with SquirrelMacEnableDirectContentsWrite enabled', () => {
  298. let previousValue: any;
  299. beforeEach(() => {
  300. previousValue = systemPreferences.getUserDefault('SquirrelMacEnableDirectContentsWrite', 'boolean');
  301. systemPreferences.setUserDefault('SquirrelMacEnableDirectContentsWrite', 'boolean', true as any);
  302. });
  303. afterEach(() => {
  304. systemPreferences.setUserDefault('SquirrelMacEnableDirectContentsWrite', 'boolean', previousValue as any);
  305. });
  306. 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 () => {
  307. await withUpdatableApp({
  308. nextVersion: '2.0.0',
  309. startFixture: 'update',
  310. endFixture: 'update'
  311. }, async (appPath, updateZipPath) => {
  312. const randomID = uuid.v4();
  313. cp.spawnSync('xattr', ['-w', 'spec-id', randomID, appPath]);
  314. server.get('/update-file', (req, res) => {
  315. res.download(updateZipPath);
  316. });
  317. server.get('/update-check', (req, res) => {
  318. res.json({
  319. url: `http://localhost:${port}/update-file`,
  320. name: 'My Release Name',
  321. notes: 'Theses are some release notes innit',
  322. pub_date: (new Date()).toString()
  323. });
  324. });
  325. const relaunchPromise = new Promise<void>((resolve) => {
  326. server.get('/update-check/updated/:version', (req, res) => {
  327. res.status(204).send();
  328. resolve();
  329. });
  330. });
  331. const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
  332. logOnError(launchResult, () => {
  333. expect(launchResult).to.have.property('code', 0);
  334. expect(launchResult.out).to.include('Update Downloaded');
  335. expect(requests).to.have.lengthOf(2);
  336. expect(requests[0]).to.have.property('url', '/update-check');
  337. expect(requests[1]).to.have.property('url', '/update-file');
  338. expect(requests[0].header('user-agent')).to.include('Electron/');
  339. expect(requests[1].header('user-agent')).to.include('Electron/');
  340. });
  341. await relaunchPromise;
  342. expect(requests).to.have.lengthOf(3);
  343. expect(requests[2].url).to.equal('/update-check/updated/2.0.0');
  344. expect(requests[2].header('user-agent')).to.include('Electron/');
  345. const result = cp.spawnSync('xattr', ['-l', appPath]);
  346. expect(result.stdout.toString()).to.include(`spec-id: ${randomID}`);
  347. });
  348. });
  349. });
  350. it('should hit the download endpoint when an update is available and fail when the zip signature is invalid', async () => {
  351. await withUpdatableApp({
  352. nextVersion: '2.0.0',
  353. startFixture: 'update',
  354. endFixture: 'update',
  355. mutateAppPostSign: {
  356. mutationKey: 'add-resource',
  357. mutate: async (appPath) => {
  358. const resourcesPath = path.resolve(appPath, 'Contents', 'Resources', 'app', 'injected.txt');
  359. await fs.writeFile(resourcesPath, 'demo');
  360. }
  361. }
  362. }, async (appPath, updateZipPath) => {
  363. server.get('/update-file', (req, res) => {
  364. res.download(updateZipPath);
  365. });
  366. server.get('/update-check', (req, res) => {
  367. res.json({
  368. url: `http://localhost:${port}/update-file`,
  369. name: 'My Release Name',
  370. notes: 'Theses are some release notes innit',
  371. pub_date: (new Date()).toString()
  372. });
  373. });
  374. const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
  375. logOnError(launchResult, () => {
  376. expect(launchResult).to.have.property('code', 1);
  377. expect(launchResult.out).to.include('Code signature at URL');
  378. expect(launchResult.out).to.include('a sealed resource is missing or invalid');
  379. expect(requests).to.have.lengthOf(2);
  380. expect(requests[0]).to.have.property('url', '/update-check');
  381. expect(requests[1]).to.have.property('url', '/update-file');
  382. expect(requests[0].header('user-agent')).to.include('Electron/');
  383. expect(requests[1].header('user-agent')).to.include('Electron/');
  384. });
  385. });
  386. });
  387. it('should hit the download endpoint when an update is available and fail when the ShipIt binary is a symlink', async () => {
  388. await withUpdatableApp({
  389. nextVersion: '2.0.0',
  390. startFixture: 'update',
  391. endFixture: 'update',
  392. mutateAppPostSign: {
  393. mutationKey: 'modify-shipit',
  394. mutate: async (appPath) => {
  395. const shipItPath = path.resolve(appPath, 'Contents', 'Frameworks', 'Squirrel.framework', 'Resources', 'ShipIt');
  396. await fs.remove(shipItPath);
  397. await fs.symlink('/tmp/ShipIt', shipItPath, 'file');
  398. }
  399. }
  400. }, async (appPath, updateZipPath) => {
  401. server.get('/update-file', (req, res) => {
  402. res.download(updateZipPath);
  403. });
  404. server.get('/update-check', (req, res) => {
  405. res.json({
  406. url: `http://localhost:${port}/update-file`,
  407. name: 'My Release Name',
  408. notes: 'Theses are some release notes innit',
  409. pub_date: (new Date()).toString()
  410. });
  411. });
  412. const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
  413. logOnError(launchResult, () => {
  414. expect(launchResult).to.have.property('code', 1);
  415. expect(launchResult.out).to.include('Code signature at URL');
  416. expect(launchResult.out).to.include('a sealed resource is missing or invalid');
  417. expect(requests).to.have.lengthOf(2);
  418. expect(requests[0]).to.have.property('url', '/update-check');
  419. expect(requests[1]).to.have.property('url', '/update-file');
  420. expect(requests[0].header('user-agent')).to.include('Electron/');
  421. expect(requests[1].header('user-agent')).to.include('Electron/');
  422. });
  423. });
  424. });
  425. it('should hit the download endpoint when an update is available and fail when the Electron Framework is modified', async () => {
  426. await withUpdatableApp({
  427. nextVersion: '2.0.0',
  428. startFixture: 'update',
  429. endFixture: 'update',
  430. mutateAppPostSign: {
  431. mutationKey: 'modify-eframework',
  432. mutate: async (appPath) => {
  433. const shipItPath = path.resolve(appPath, 'Contents', 'Frameworks', 'Electron Framework.framework', 'Electron Framework');
  434. await fs.appendFile(shipItPath, Buffer.from('123'));
  435. }
  436. }
  437. }, async (appPath, updateZipPath) => {
  438. server.get('/update-file', (req, res) => {
  439. res.download(updateZipPath);
  440. });
  441. server.get('/update-check', (req, res) => {
  442. res.json({
  443. url: `http://localhost:${port}/update-file`,
  444. name: 'My Release Name',
  445. notes: 'Theses are some release notes innit',
  446. pub_date: (new Date()).toString()
  447. });
  448. });
  449. const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
  450. logOnError(launchResult, () => {
  451. expect(launchResult).to.have.property('code', 1);
  452. expect(launchResult.out).to.include('Code signature at URL');
  453. expect(launchResult.out).to.include(' main executable failed strict validation');
  454. expect(requests).to.have.lengthOf(2);
  455. expect(requests[0]).to.have.property('url', '/update-check');
  456. expect(requests[1]).to.have.property('url', '/update-file');
  457. expect(requests[0].header('user-agent')).to.include('Electron/');
  458. expect(requests[1].header('user-agent')).to.include('Electron/');
  459. });
  460. });
  461. });
  462. it('should hit the download endpoint when an update is available and update successfully when the zip is provided with JSON update mode', async () => {
  463. await withUpdatableApp({
  464. nextVersion: '2.0.0',
  465. startFixture: 'update-json',
  466. endFixture: 'update-json'
  467. }, async (appPath, updateZipPath) => {
  468. server.get('/update-file', (req, res) => {
  469. res.download(updateZipPath);
  470. });
  471. server.get('/update-check', (req, res) => {
  472. res.json({
  473. currentRelease: '2.0.0',
  474. releases: [
  475. {
  476. version: '2.0.0',
  477. updateTo: {
  478. version: '2.0.0',
  479. url: `http://localhost:${port}/update-file`,
  480. name: 'My Release Name',
  481. notes: 'Theses are some release notes innit',
  482. pub_date: (new Date()).toString()
  483. }
  484. }
  485. ]
  486. });
  487. });
  488. const relaunchPromise = new Promise<void>((resolve) => {
  489. server.get('/update-check/updated/:version', (req, res) => {
  490. res.status(204).send();
  491. resolve();
  492. });
  493. });
  494. const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
  495. logOnError(launchResult, () => {
  496. expect(launchResult).to.have.property('code', 0);
  497. expect(launchResult.out).to.include('Update Downloaded');
  498. expect(requests).to.have.lengthOf(2);
  499. expect(requests[0]).to.have.property('url', '/update-check');
  500. expect(requests[1]).to.have.property('url', '/update-file');
  501. expect(requests[0].header('user-agent')).to.include('Electron/');
  502. expect(requests[1].header('user-agent')).to.include('Electron/');
  503. });
  504. await relaunchPromise;
  505. expect(requests).to.have.lengthOf(3);
  506. expect(requests[2]).to.have.property('url', '/update-check/updated/2.0.0');
  507. expect(requests[2].header('user-agent')).to.include('Electron/');
  508. });
  509. });
  510. 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 () => {
  511. await withUpdatableApp({
  512. nextVersion: '0.1.0',
  513. startFixture: 'update-json',
  514. endFixture: 'update-json'
  515. }, async (appPath, updateZipPath) => {
  516. server.get('/update-file', (req, res) => {
  517. res.download(updateZipPath);
  518. });
  519. server.get('/update-check', (req, res) => {
  520. res.json({
  521. currentRelease: '0.1.0',
  522. releases: [
  523. {
  524. version: '0.1.0',
  525. updateTo: {
  526. version: '0.1.0',
  527. url: `http://localhost:${port}/update-file`,
  528. name: 'My Release Name',
  529. notes: 'Theses are some release notes innit',
  530. pub_date: (new Date()).toString()
  531. }
  532. }
  533. ]
  534. });
  535. });
  536. const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
  537. logOnError(launchResult, () => {
  538. expect(launchResult).to.have.property('code', 1);
  539. expect(launchResult.out).to.include('No update available');
  540. expect(requests).to.have.lengthOf(1);
  541. expect(requests[0]).to.have.property('url', '/update-check');
  542. expect(requests[0].header('user-agent')).to.include('Electron/');
  543. });
  544. });
  545. });
  546. });
  547. });