Skip to content

Commit b746417

Browse files
committed
Merge branch 'upstream-development'
2 parents 136b336 + 0b6d7a1 commit b746417

Some content is hidden

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

42 files changed

+4648
-4
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
@@ -74,6 +74,8 @@
7474
"winston": "^3.6.0"
7575
},
7676
"devDependencies": {
77+
"@testing-library/dom": "8.20.1",
78+
"@testing-library/react": "12.1.5",
7779
"electron-devtools-installer": "^4.0.0",
7880
"webpack-hot-middleware": "^2.10.0"
7981
}

app/src/ui/diff/syntax-highlighting/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { DiffHunk, DiffLineType, DiffLine } from '../../../models/diff'
1717
import { getOldPathOrDefault } from '../../../lib/get-old-path'
1818

1919
/** The maximum number of bytes we'll process for highlighting. */
20-
const MaxHighlightContentLength = 256 * 1024
20+
const MaxHighlightContentLength = 1024 * 1024
2121

2222
// There is no good way to get the actual length of the old/new contents,
2323
// since we're directly truncating the git output to up to MaxHighlightContentLength

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+
})

0 commit comments

Comments
 (0)