release-notes-spec.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. import { GitProcess, IGitExecutionOptions, IGitResult } from 'dugite';
  2. import { expect } from 'chai';
  3. import * as notes from '../script/release/notes/notes.js';
  4. import * as path from 'node:path';
  5. import * as sinon from 'sinon';
  6. /* Fake a Dugite GitProcess that only returns the specific
  7. commits that we want to test */
  8. class Commit {
  9. sha1: string;
  10. subject: string;
  11. constructor (sha1: string, subject: string) {
  12. this.sha1 = sha1;
  13. this.subject = subject;
  14. }
  15. }
  16. class GitFake {
  17. branches: {
  18. [key: string]: Commit[],
  19. };
  20. constructor () {
  21. this.branches = {};
  22. }
  23. setBranch (name: string, commits: Array<Commit>): void {
  24. this.branches[name] = commits;
  25. }
  26. // find the newest shared commit between branches a and b
  27. mergeBase (a: string, b:string): string {
  28. for (const commit of [...this.branches[a].reverse()]) {
  29. if (this.branches[b].map((commit: Commit) => commit.sha1).includes(commit.sha1)) {
  30. return commit.sha1;
  31. }
  32. }
  33. console.error('test error: branches not related');
  34. return '';
  35. }
  36. // eslint-disable-next-line @typescript-eslint/no-unused-vars
  37. exec (args: string[], path: string, options?: IGitExecutionOptions | undefined): Promise<IGitResult> {
  38. let stdout = '';
  39. const stderr = '';
  40. const exitCode = 0;
  41. if (args.length === 3 && args[0] === 'merge-base') {
  42. // expected form: `git merge-base branchName1 branchName2`
  43. const a: string = args[1]!;
  44. const b: string = args[2]!;
  45. stdout = this.mergeBase(a, b);
  46. } else if (args.length === 3 && args[0] === 'log' && args[1] === '--format=%H') {
  47. // expected form: `git log --format=%H branchName
  48. const branch: string = args[2]!;
  49. stdout = this.branches[branch].map((commit: Commit) => commit.sha1).join('\n');
  50. } else if (args.length > 1 && args[0] === 'log' && args.includes('--format=%H,%s')) {
  51. // expected form: `git log --format=%H,%s sha1..branchName
  52. const [start, branch] = args[args.length - 1].split('..');
  53. const lines : string[] = [];
  54. let started = false;
  55. for (const commit of this.branches[branch]) {
  56. started = started || commit.sha1 === start;
  57. if (started) {
  58. lines.push(`${commit.sha1},${commit.subject}` /* %H,%s */);
  59. }
  60. }
  61. stdout = lines.join('\n');
  62. } else if (args.length === 6 &&
  63. args[0] === 'branch' &&
  64. args[1] === '--all' &&
  65. args[2] === '--contains' &&
  66. args[3].endsWith('-x-y')) {
  67. // "what branch is this tag in?"
  68. // git branch --all --contains ${ref} --sort version:refname
  69. stdout = args[3];
  70. } else {
  71. console.error('unhandled GitProcess.exec():', args);
  72. }
  73. return Promise.resolve({ exitCode, stdout, stderr });
  74. }
  75. }
  76. describe('release notes', () => {
  77. const sandbox = sinon.createSandbox();
  78. const gitFake = new GitFake();
  79. const oldBranch = '8-x-y';
  80. const newBranch = '9-x-y';
  81. // commits shared by both oldBranch and newBranch
  82. const sharedHistory = [
  83. new Commit('2abea22b4bffa1626a521711bacec7cd51425818', "fix: explicitly cancel redirects when mode is 'error' (#20686)"),
  84. new Commit('467409458e716c68b35fa935d556050ca6bed1c4', 'build: add support for automated minor releases (#20620)') // merge-base
  85. ];
  86. // these commits came after newBranch was created
  87. const newBreaking = new Commit('2fad53e66b1a2cb6f7dad88fe9bb62d7a461fe98', 'refactor: use v8 serialization for ipc (#20214)');
  88. const newFeat = new Commit('89eb309d0b22bd4aec058ffaf983e81e56a5c378', 'feat: allow GUID parameter to avoid systray demotion on Windows (#21891)');
  89. const newFix = new Commit('0600420bac25439fc2067d51c6aaa4ee11770577', "fix: don't allow window to go behind menu bar on mac (#22828)");
  90. const oldFix = new Commit('f77bd19a70ac2d708d17ddbe4dc12745ca3a8577', 'fix: prevent menu gc during popup (#20785)');
  91. // a bug that's fixed in both branches by separate PRs
  92. const newTropFix = new Commit('a6ff42c190cb5caf8f3e217748e49183a951491b', 'fix: workaround for hang when preventDefault-ing nativeWindowOpen (#22750)');
  93. const oldTropFix = new Commit('8751f485c5a6c8c78990bfd55a4350700f81f8cd', 'fix: workaround for hang when preventDefault-ing nativeWindowOpen (#22749)');
  94. // a PR that has unusual note formatting
  95. const sublist = new Commit('61dc1c88fd34a3e8fff80c80ed79d0455970e610', 'fix: client area inset calculation when maximized for frameless windows (#25052) (#25216)');
  96. before(() => {
  97. // location of release-notes' octokit reply cache
  98. const fixtureDir = path.resolve(__dirname, 'fixtures', 'release-notes');
  99. process.env.NOTES_CACHE_PATH = path.resolve(fixtureDir, 'cache');
  100. });
  101. beforeEach(() => {
  102. const wrapper = (args: string[], path: string, options?: IGitExecutionOptions | undefined) => gitFake.exec(args, path, options);
  103. sandbox.replace(GitProcess, 'exec', wrapper);
  104. gitFake.setBranch(oldBranch, [...sharedHistory, oldFix]);
  105. });
  106. afterEach(() => {
  107. sandbox.restore();
  108. });
  109. describe('trop annotations', () => {
  110. it('shows sibling branches', async function () {
  111. const version = 'v9.0.0';
  112. gitFake.setBranch(oldBranch, [...sharedHistory, oldTropFix]);
  113. gitFake.setBranch(newBranch, [...sharedHistory, newTropFix]);
  114. const results: any = await notes.get(oldBranch, newBranch, version);
  115. expect(results.fix).to.have.lengthOf(1);
  116. console.log(results.fix);
  117. expect(results.fix[0].trops).to.have.keys('8-x-y', '9-x-y');
  118. });
  119. });
  120. // use case: A malicious contributor could edit the text of their 'Notes:'
  121. // in the PR body after a PR's been merged and the maintainers have moved on.
  122. // So instead always use the release-clerk PR comment
  123. it('uses the release-clerk text', async function () {
  124. // realText source: ${fixtureDir}/electron-electron-issue-21891-comments
  125. const realText = 'Added GUID parameter to Tray API to avoid system tray icon demotion on Windows';
  126. const testCommit = new Commit('89eb309d0b22bd4aec058ffaf983e81e56a5c378', 'feat: lole u got troled hard (#21891)');
  127. const version = 'v9.0.0';
  128. gitFake.setBranch(newBranch, [...sharedHistory, testCommit]);
  129. const results: any = await notes.get(oldBranch, newBranch, version);
  130. expect(results.feat).to.have.lengthOf(1);
  131. expect(results.feat[0].hash).to.equal(testCommit.sha1);
  132. expect(results.feat[0].note).to.equal(realText);
  133. });
  134. describe('rendering', () => {
  135. it('removes redundant bullet points', async function () {
  136. const testCommit = sublist;
  137. const version = 'v10.1.1';
  138. gitFake.setBranch(newBranch, [...sharedHistory, testCommit]);
  139. const results: any = await notes.get(oldBranch, newBranch, version);
  140. const rendered: any = await notes.render(results);
  141. expect(rendered).to.not.include('* *');
  142. });
  143. it('indents sublists', async function () {
  144. const testCommit = sublist;
  145. const version = 'v10.1.1';
  146. gitFake.setBranch(newBranch, [...sharedHistory, testCommit]);
  147. const results: any = await notes.get(oldBranch, newBranch, version);
  148. const rendered: any = await notes.render(results);
  149. expect(rendered).to.include([
  150. '* Fixed the following issues for frameless when maximized on Windows:',
  151. ' * fix unreachable task bar when auto hidden with position top',
  152. ' * fix 1px extending to secondary monitor',
  153. ' * fix 1px overflowing into taskbar at certain resolutions',
  154. ' * fix white line on top of window under 4k resolutions. [#25216]'].join('\n'));
  155. });
  156. });
  157. // test that when you feed in different semantic commit types,
  158. // the parser returns them in the results' correct category
  159. describe('semantic commit', () => {
  160. const version = 'v9.0.0';
  161. it("honors 'feat' type", async function () {
  162. const testCommit = newFeat;
  163. gitFake.setBranch(newBranch, [...sharedHistory, testCommit]);
  164. const results: any = await notes.get(oldBranch, newBranch, version);
  165. expect(results.feat).to.have.lengthOf(1);
  166. expect(results.feat[0].hash).to.equal(testCommit.sha1);
  167. });
  168. it("honors 'fix' type", async function () {
  169. const testCommit = newFix;
  170. gitFake.setBranch(newBranch, [...sharedHistory, testCommit]);
  171. const results: any = await notes.get(oldBranch, newBranch, version);
  172. expect(results.fix).to.have.lengthOf(1);
  173. expect(results.fix[0].hash).to.equal(testCommit.sha1);
  174. });
  175. it("honors 'BREAKING CHANGE' message", async function () {
  176. const testCommit = newBreaking;
  177. gitFake.setBranch(newBranch, [...sharedHistory, testCommit]);
  178. const results: any = await notes.get(oldBranch, newBranch, version);
  179. expect(results.breaking).to.have.lengthOf(1);
  180. expect(results.breaking[0].hash).to.equal(testCommit.sha1);
  181. });
  182. });
  183. });