Skip to content

Commit 0b6d7a1

Browse files
authored
Merge pull request desktop#21872 from desktop/ui-testing-library
UI testing library
2 parents 99bf323 + c35b3be commit 0b6d7a1

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+4647
-3
lines changed

.eslintrc.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ overrides:
213213
- files: 'app/test/**/*'
214214
rules:
215215
'@typescript-eslint/no-non-null-assertion': off
216+
react/jsx-no-bind: off
216217
- files: 'script/**/*'
217218
rules:
218219
'@typescript-eslint/no-non-null-assertion': off

.prettierignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ yarn-error.log
99
.idea/
1010
.eslintcache
1111
app/coverage
12+
script/coverage
13+
playwright-videos
1214
app/static/common
1315
app/test/fixtures
1416
gemoji

app/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@
7272
"winston": "^3.6.0"
7373
},
7474
"devDependencies": {
75+
"@testing-library/dom": "8.20.1",
76+
"@testing-library/react": "12.1.5",
7577
"electron-devtools-installer": "^4.0.0",
7678
"webpack-hot-middleware": "^2.10.0"
7779
}

app/test/globals.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ Object.assign(globalThis, {
4242

4343
mock.module('electron', {
4444
namedExports: {
45+
clipboard: { writeText: () => {} },
4546
shell: {},
4647
ipcRenderer: { on: mock.fn(x => {}) },
4748
},

app/test/helpers/ui/electron.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { clipboard } from 'electron'
2+
3+
export function captureClipboardWrites() {
4+
const writes = new Array<string>()
5+
const previousWriteText = clipboard.writeText
6+
7+
clipboard.writeText = (text: string) => {
8+
writes.push(text)
9+
}
10+
11+
return {
12+
writes,
13+
restore() {
14+
clipboard.writeText = previousWriteText
15+
},
16+
}
17+
}

app/test/helpers/ui/render.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import * as React from 'react'
2+
3+
// This import MUST stay here — it runs side-effects that every UI test needs:
4+
// polyfilling ResizeObserver, aligning globalThis.Event/CustomEvent with jsdom,
5+
// and registering an afterEach(cleanup) hook. Tests should never import
6+
// '@testing-library/react' directly; they should import from this module
7+
// instead so these side-effects are guaranteed to run first.
8+
import './setup'
9+
10+
import {
11+
fireEvent,
12+
render as rtlRender,
13+
type RenderOptions,
14+
screen,
15+
waitFor,
16+
within,
17+
} from '@testing-library/react'
18+
19+
type UIErrorRenderOptions = Omit<RenderOptions, 'queries'>
20+
21+
/**
22+
* Thin wrapper around Testing Library's render.
23+
*
24+
* This exists so that every test imports from this module rather than directly
25+
* from '@testing-library/react'. Importing from here guarantees the './setup'
26+
* side-effects (ResizeObserver polyfill, Event/CustomEvent alignment, and
27+
* afterEach cleanup) have already executed before any component is rendered.
28+
*/
29+
export function render(
30+
element: React.ReactElement,
31+
options?: UIErrorRenderOptions
32+
) {
33+
return rtlRender(element, options)
34+
}
35+
36+
export { fireEvent, screen, waitFor, within }

app/test/helpers/ui/setup.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { cleanup } from '@testing-library/react'
2+
import { afterEach } from 'node:test'
3+
4+
class TestResizeObserver {
5+
public observe() {}
6+
7+
public unobserve() {}
8+
9+
public disconnect() {}
10+
}
11+
12+
if (globalThis.ResizeObserver === undefined) {
13+
Object.assign(globalThis, {
14+
ResizeObserver: TestResizeObserver,
15+
})
16+
}
17+
18+
if (
19+
typeof window !== 'undefined' &&
20+
globalThis.CustomEvent !== window.CustomEvent
21+
) {
22+
Object.assign(globalThis, {
23+
CustomEvent: window.CustomEvent,
24+
Event: window.Event,
25+
})
26+
}
27+
28+
afterEach(() => cleanup())

app/test/helpers/ui/timers.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { mock } from 'node:test'
2+
import { act } from 'react-dom/test-utils'
3+
4+
type TimerApi = 'Date' | 'setTimeout'
5+
6+
export function enableTestTimers(apis: ReadonlyArray<TimerApi>, now?: number) {
7+
mock.timers.enable({ apis: [...apis] })
8+
9+
if (now !== undefined) {
10+
mock.timers.setTime(now)
11+
}
12+
}
13+
14+
export function advanceTimersBy(ms: number) {
15+
act(() => {
16+
mock.timers.tick(ms)
17+
})
18+
}
19+
20+
export function resetTestTimers() {
21+
mock.timers.reset()
22+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import assert from 'node:assert'
2+
import { afterEach, beforeEach, describe, it } from 'node:test'
3+
import * as React from 'react'
4+
5+
import { AriaLiveContainer } from '../../../src/ui/accessibility/aria-live-container'
6+
import {
7+
advanceTimersBy,
8+
enableTestTimers,
9+
resetTestTimers,
10+
} from '../../helpers/ui/timers'
11+
import { render } from '../../helpers/ui/render'
12+
13+
describe('AriaLiveContainer', () => {
14+
beforeEach(() => {
15+
enableTestTimers(['Date', 'setTimeout'])
16+
})
17+
18+
afterEach(() => {
19+
resetTestTimers()
20+
})
21+
22+
it('renders a polite live region with the provided id and message', () => {
23+
const view = render(
24+
<AriaLiveContainer id="branch-status" message="3 branches found" />
25+
)
26+
27+
const container = view.container.querySelector('#branch-status.sr-only')
28+
29+
assert.notEqual(container, null)
30+
assert.equal(container?.getAttribute('aria-live'), 'polite')
31+
assert.equal(container?.getAttribute('aria-atomic'), 'true')
32+
assert.equal(container?.textContent, '3 branches found')
33+
34+
view.rerender(<AriaLiveContainer id="branch-status" message={null} />)
35+
36+
assert.equal(container?.textContent, '')
37+
})
38+
39+
it('rebuilds the message after tracked user input changes', () => {
40+
const view = render(
41+
<AriaLiveContainer message="1 result" trackedUserInput="m" />
42+
)
43+
44+
const container = view.container.querySelector('.sr-only')
45+
46+
// Initial render toggles suffix from '' → '\u00A0\u00A0'
47+
assert.equal(container?.textContent, '1 result\u00A0\u00A0')
48+
49+
view.rerender(
50+
<AriaLiveContainer message="1 result" trackedUserInput="ma" />
51+
)
52+
53+
// Debounce hasn't fired yet, so the message is unchanged
54+
assert.equal(container?.textContent, '1 result\u00A0\u00A0')
55+
56+
advanceTimersBy(1001)
57+
58+
// After debounce, suffix toggles to '\u00A0'
59+
assert.equal(container?.textContent, '1 result\u00A0')
60+
})
61+
})
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import assert from 'node:assert'
2+
import { afterEach, beforeEach, describe, it } from 'node:test'
3+
import * as React from 'react'
4+
5+
import { BranchAlreadyUpToDate } from '../../../src/ui/banners/branch-already-up-to-date-banner'
6+
import { Banner } from '../../../src/ui/banners/banner'
7+
import { CherryPickUndone } from '../../../src/ui/banners/cherry-pick-undone'
8+
import { SuccessBanner } from '../../../src/ui/banners/success-banner'
9+
import {
10+
advanceTimersBy,
11+
enableTestTimers,
12+
resetTestTimers,
13+
} from '../../helpers/ui/timers'
14+
import { fireEvent, render, screen } from '../../helpers/ui/render'
15+
16+
describe('banner surfaces', () => {
17+
beforeEach(() => {
18+
enableTestTimers(['setTimeout'])
19+
})
20+
21+
afterEach(() => {
22+
resetTestTimers()
23+
})
24+
25+
it('focuses the first suitable banner element and auto-dismisses on focus out', () => {
26+
let dismissed = 0
27+
28+
function onDismissed() {
29+
dismissed++
30+
}
31+
32+
const view = render(
33+
<Banner id="test-banner" timeout={500} onDismissed={onDismissed}>
34+
<a href="https://example.com/help">Learn more</a>
35+
</Banner>
36+
)
37+
38+
const banner = view.container.querySelector('#test-banner.banner')
39+
const link = screen.getByRole('link', { name: 'Learn more' })
40+
const dismissButton = screen.getByRole('button', {
41+
name: 'Dismiss this message',
42+
})
43+
44+
assert.notEqual(banner, null)
45+
assert.ok(dismissButton)
46+
47+
advanceTimersBy(200)
48+
49+
assert.equal(document.activeElement, link)
50+
51+
fireEvent.focusOut(link, { relatedTarget: document.body })
52+
53+
advanceTimersBy(500)
54+
55+
assert.equal(dismissed, 1)
56+
})
57+
58+
it('renders success banner content and triggers dismiss plus undo from the undo link', () => {
59+
let dismissed = 0
60+
let undone = 0
61+
62+
function onDismissed() {
63+
dismissed++
64+
}
65+
66+
function onUndo() {
67+
undone++
68+
}
69+
70+
render(
71+
<SuccessBanner timeout={750} onDismissed={onDismissed} onUndo={onUndo}>
72+
Branch renamed successfully.
73+
</SuccessBanner>
74+
)
75+
76+
const undoButton = screen.getByRole('button', { name: 'Undo' })
77+
78+
assert.ok(screen.getByText('Branch renamed successfully.'))
79+
assert.notEqual(document.querySelector('.success-contents'), null)
80+
assert.notEqual(document.querySelector('.green-circle .check-icon'), null)
81+
82+
fireEvent.click(undoButton)
83+
84+
assert.equal(dismissed, 1)
85+
assert.equal(undone, 1)
86+
})
87+
88+
it('renders branch up-to-date banner messages with and without the compared branch', () => {
89+
function onDismissed() {}
90+
91+
const view = render(
92+
<BranchAlreadyUpToDate
93+
ourBranch="main"
94+
theirBranch="origin/main"
95+
onDismissed={onDismissed}
96+
/>
97+
)
98+
99+
assert.ok(screen.getByText('main'))
100+
assert.ok(screen.getByText('origin/main'))
101+
assert.ok(
102+
view.container.textContent?.includes('is already up to date with')
103+
)
104+
105+
view.rerender(
106+
<BranchAlreadyUpToDate ourBranch="release" onDismissed={onDismissed} />
107+
)
108+
109+
assert.ok(screen.getByText('release'))
110+
assert.ok(view.container.textContent?.includes('is already up to date'))
111+
})
112+
113+
it('renders cherry-pick undone messages with singular and plural commit copy', () => {
114+
function onDismissed() {}
115+
116+
const view = render(
117+
<CherryPickUndone
118+
countCherryPicked={1}
119+
targetBranchName="main"
120+
onDismissed={onDismissed}
121+
/>
122+
)
123+
124+
assert.ok(
125+
view.container.textContent?.includes(
126+
'Cherry-pick undone. Successfully removed the 1 copied commit from'
127+
)
128+
)
129+
assert.ok(screen.getByText('main'))
130+
131+
view.rerender(
132+
<CherryPickUndone
133+
countCherryPicked={3}
134+
targetBranchName="release"
135+
onDismissed={onDismissed}
136+
/>
137+
)
138+
139+
assert.ok(
140+
view.container.textContent?.includes(
141+
'Cherry-pick undone. Successfully removed the 3 copied commits from'
142+
)
143+
)
144+
assert.ok(screen.getByText('release'))
145+
})
146+
})

0 commit comments

Comments
 (0)