forked from OurHike/OurHike
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathErrorBoundary.test.tsx
More file actions
180 lines (145 loc) · 5.76 KB
/
Copy pathErrorBoundary.test.tsx
File metadata and controls
180 lines (145 loc) · 5.76 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen, cleanup } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { ErrorBoundary, ScreenFailed } from './ErrorBoundary'
// React's default for a thrown render, effect, or effect CLEANUP is to unmount
// the whole root - not the component that threw. #131 did exactly that: a
// stale removeControl threw during cleanup on every tab switch away from the
// map, and the hiker got a white page with no navigation on it. What was
// reported was "the download tab shows nothing".
//
// So what these test is not "does React catch errors" but the two promises
// this boundary makes: something renders, and there is a way out of it.
function Boom({ explode }: { explode: boolean }): React.ReactNode {
if (explode) throw new Error('the map fell over')
return <p>the map</p>
}
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
/** React logs a caught error itself; silenced so the run stays readable. */
function quiet() {
vi.spyOn(console, 'error').mockImplementation(() => {})
}
describe('ErrorBoundary', () => {
it('renders its children when nothing is wrong', () => {
render(
<ErrorBoundary fallback={() => <p>fallback</p>}>
<Boom explode={false} />
</ErrorBoundary>,
)
expect(screen.getByText('the map')).toBeInTheDocument()
})
it('shows the fallback instead of unmounting the tree', () => {
quiet()
render(
<ErrorBoundary fallback={() => <p>fallback</p>}>
<Boom explode={true} />
</ErrorBoundary>,
)
expect(screen.getByText('fallback')).toBeInTheDocument()
})
it('keeps whatever the fallback renders alongside it', () => {
// The load-bearing part. A fallback with no navigation under it is a white
// screen with words on it - the hiker still cannot reach the map.
quiet()
render(
<ErrorBoundary
fallback={() => (
<>
<p>fallback</p>
<nav aria-label="Main">tabs</nav>
</>
)}
>
<Boom explode={true} />
</ErrorBoundary>,
)
expect(screen.getByRole('navigation', { name: 'Main' })).toBeInTheDocument()
})
it('tries again when the reset key changes, so navigating away and back recovers', async () => {
quiet()
const user = userEvent.setup()
function Harness() {
const [tab, setTab] = useState('trail')
return (
<>
<button
onClick={() => setTab((current) => (current === 'trail' ? 'more' : 'trail'))}
>
switch
</button>
<ErrorBoundary resetKey={tab} fallback={() => <p>fallback</p>}>
<Boom explode={tab === 'trail'} />
</ErrorBoundary>
</>
)
}
render(<Harness />)
expect(screen.getByText('fallback')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'switch' }))
expect(screen.getByText('the map')).toBeInTheDocument()
})
it('stays on the fallback while the reset key is unchanged', () => {
// Clearing the error on every update would re-render the subtree that just
// threw, which throws again - a loop, not a recovery.
quiet()
const { rerender } = render(
<ErrorBoundary resetKey="trail" fallback={() => <p>fallback</p>}>
<Boom explode={true} />
</ErrorBoundary>,
)
rerender(
<ErrorBoundary resetKey="trail" fallback={() => <p>fallback</p>}>
<Boom explode={true} />
</ErrorBoundary>,
)
expect(screen.getByText('fallback')).toBeInTheDocument()
})
it('logs the error rather than sending it anywhere', () => {
// A chosen "no telemetry", not an oversight - there is none in this client
// and adding some carries its own privacy weight. console is where a
// developer looks and a hiker never does.
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
render(
<ErrorBoundary fallback={() => <p>fallback</p>}>
<Boom explode={true} />
</ErrorBoundary>,
)
expect(error).toHaveBeenCalled()
})
})
describe('ScreenFailed', () => {
it('names what broke rather than saying something went wrong', () => {
render(<ScreenFailed what="The map" />)
expect(screen.getByRole('heading')).toHaveTextContent(/the map stopped working/i)
})
it('says the rest of the app still works, because it does', () => {
render(<ScreenFailed what="The map" />)
expect(screen.getByText(/rest of the app is fine/i)).toBeInTheDocument()
})
it('offers no reload, which is the action least likely to help offline', () => {
render(<ScreenFailed what="The map" />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
expect(screen.queryByText(/reload|refresh/i)).not.toBeInTheDocument()
})
it('reassures about the two things a hiker would fear losing', () => {
render(<ScreenFailed what="The map" />)
expect(screen.getByText(/downloaded map/i)).toHaveTextContent(/outbox/i)
})
it('announces itself, so it is not a silent swap for a screen reader', () => {
render(<ScreenFailed what="The map" />)
expect(screen.getByRole('alert')).toBeInTheDocument()
})
it('states a recovery that fits where it is standing', () => {
// The default line assumes a tab bar is rendered underneath, which is true
// inside the shell and false at the root - main.tsx renders this with no
// tabs anywhere, and "switch tabs" on a screen without tabs is an
// instruction that cannot be followed, discovered at the worst moment.
render(<ScreenFailed what="OurHike" recovery="Close the app and open it again." />)
expect(screen.getByText(/close the app/i)).toBeInTheDocument()
expect(screen.queryByText(/switching tabs/i)).not.toBeInTheDocument()
})
})