|
10 | 10 |
|
11 | 11 | 'use strict';
|
12 | 12 |
|
| 13 | +const path = require('path'); |
| 14 | + |
13 | 15 | if (typeof Blob === 'undefined') {
|
14 | 16 | global.Blob = require('buffer').Blob;
|
15 | 17 | }
|
@@ -41,6 +43,26 @@ function formatV8Stack(stack) {
|
41 | 43 | return v8StyleStack;
|
42 | 44 | }
|
43 | 45 |
|
| 46 | +const repoRoot = path.resolve(__dirname, '../../../../'); |
| 47 | +function normalizeReactCodeLocInfo(str) { |
| 48 | + const repoRootForRegexp = repoRoot.replace(/\//g, '\\/'); |
| 49 | + const repoFileLocMatch = new RegExp(`${repoRootForRegexp}.+?:\\d+:\\d+`, 'g'); |
| 50 | + return str && str.replace(repoFileLocMatch, '**'); |
| 51 | +} |
| 52 | + |
| 53 | +// If we just use the original Error prototype, Jest will only display the error message if assertions fail. |
| 54 | +// But we usually want to also assert on our expando properties or even the stack. |
| 55 | +// By hiding the fact from Jest that this is an error, it will show all enumerable properties on mismatch. |
| 56 | + |
| 57 | +function getErrorForJestMatcher(error) { |
| 58 | + return { |
| 59 | + ...error, |
| 60 | + // non-enumerable properties that are still relevant for testing |
| 61 | + message: error.message, |
| 62 | + stack: normalizeReactCodeLocInfo(error.stack), |
| 63 | + }; |
| 64 | +} |
| 65 | + |
44 | 66 | function normalizeComponentInfo(debugInfo) {
|
45 | 67 | if (Array.isArray(debugInfo.stack)) {
|
46 | 68 | const {debugTask, debugStack, ...copy} = debugInfo;
|
@@ -1177,6 +1199,135 @@ describe('ReactFlight', () => {
|
1177 | 1199 | });
|
1178 | 1200 | });
|
1179 | 1201 |
|
| 1202 | + it('should handle exotic stack frames', async () => { |
| 1203 | + function ServerComponent() { |
| 1204 | + const error = new Error('This is an error'); |
| 1205 | + const originalStackLines = error.stack.split('\n'); |
| 1206 | + // Fake a stack |
| 1207 | + error.stack = [ |
| 1208 | + originalStackLines[0], |
| 1209 | + // original |
| 1210 | + // ' at ServerComponentError (file://~/react/packages/react-client/src/__tests__/ReactFlight-test.js:1166:19)', |
| 1211 | + // nested eval (https://github.com/ChromeDevTools/devtools-frontend/blob/831be28facb4e85de5ee8c1acc4d98dfeda7a73b/test/unittests/front_end/panels/console/ErrorStackParser_test.ts#L198) |
| 1212 | + ' at eval (eval at testFunction (inspected-page.html:29:11), <anonymous>:1:10)', |
| 1213 | + // parens may be added by Webpack when bundle layers are used. They're also valid in directory names. |
| 1214 | + ' at ServerComponentError (file://~/(some)(really)(exotic-directory)/ReactFlight-test.js:1166:19)', |
| 1215 | + // anon function (https://github.com/ChromeDevTools/devtools-frontend/blob/831be28facb4e85de5ee8c1acc4d98dfeda7a73b/test/unittests/front_end/panels/console/ErrorStackParser_test.ts#L115C9-L115C35) |
| 1216 | + ' at file:///testing.js:42:3', |
| 1217 | + // async anon function (https://github.com/ChromeDevTools/devtools-frontend/blob/831be28facb4e85de5ee8c1acc4d98dfeda7a73b/test/unittests/front_end/panels/console/ErrorStackParser_test.ts#L130C9-L130C41) |
| 1218 | + ' at async file:///testing.js:42:3', |
| 1219 | + ...originalStackLines.slice(2), |
| 1220 | + ].join('\n'); |
| 1221 | + throw error; |
| 1222 | + } |
| 1223 | + |
| 1224 | + const findSourceMapURL = jest.fn(() => null); |
| 1225 | + const errors = []; |
| 1226 | + class MyErrorBoundary extends React.Component { |
| 1227 | + state = {error: null}; |
| 1228 | + static getDerivedStateFromError(error) { |
| 1229 | + return {error}; |
| 1230 | + } |
| 1231 | + componentDidCatch(error, componentInfo) { |
| 1232 | + errors.push(error); |
| 1233 | + } |
| 1234 | + render() { |
| 1235 | + if (this.state.error) { |
| 1236 | + return null; |
| 1237 | + } |
| 1238 | + return this.props.children; |
| 1239 | + } |
| 1240 | + } |
| 1241 | + const ClientErrorBoundary = clientReference(MyErrorBoundary); |
| 1242 | + |
| 1243 | + function App() { |
| 1244 | + return ( |
| 1245 | + <ClientErrorBoundary> |
| 1246 | + <ServerComponent /> |
| 1247 | + </ClientErrorBoundary> |
| 1248 | + ); |
| 1249 | + } |
| 1250 | + |
| 1251 | + const transport = ReactNoopFlightServer.render(<App />, { |
| 1252 | + onError(x) { |
| 1253 | + if (__DEV__) { |
| 1254 | + return 'a dev digest'; |
| 1255 | + } |
| 1256 | + if (x instanceof Error) { |
| 1257 | + return `digest("${x.message}")`; |
| 1258 | + } else if (Array.isArray(x)) { |
| 1259 | + return `digest([])`; |
| 1260 | + } else if (typeof x === 'object' && x !== null) { |
| 1261 | + return `digest({})`; |
| 1262 | + } |
| 1263 | + return `digest(${String(x)})`; |
| 1264 | + }, |
| 1265 | + }); |
| 1266 | + |
| 1267 | + await act(() => { |
| 1268 | + startTransition(() => { |
| 1269 | + ReactNoop.render( |
| 1270 | + ReactNoopFlightClient.read(transport, {findSourceMapURL}), |
| 1271 | + ); |
| 1272 | + }); |
| 1273 | + }); |
| 1274 | + |
| 1275 | + if (__DEV__) { |
| 1276 | + expect({ |
| 1277 | + errors: errors.map(getErrorForJestMatcher), |
| 1278 | + findSourceMapURLCalls: findSourceMapURL.mock.calls, |
| 1279 | + }).toEqual({ |
| 1280 | + errors: [ |
| 1281 | + { |
| 1282 | + message: 'This is an error', |
| 1283 | + stack: gate(flags => flags.enableOwnerStacks) |
| 1284 | + ? expect.stringContaining( |
| 1285 | + 'Error: This is an error\n' + |
| 1286 | + ' at eval (eval at testFunction (eval at createFakeFunction (**), <anonymous>:1:35)\n' + |
| 1287 | + ' at ServerComponentError (file://~/(some)(really)(exotic-directory)/ReactFlight-test.js:1166:19)\n' + |
| 1288 | + ' at (anonymous) (file:///testing.js:42:3)\n' + |
| 1289 | + ' at (anonymous) (file:///testing.js:42:3)\n', |
| 1290 | + ) |
| 1291 | + : expect.stringContaining( |
| 1292 | + 'Error: This is an error\n' + |
| 1293 | + ' at eval (eval at testFunction (inspected-page.html:29:11), <anonymous>:1:10)\n' + |
| 1294 | + ' at ServerComponentError (file://~/(some)(really)(exotic-directory)/ReactFlight-test.js:1166:19)\n' + |
| 1295 | + ' at file:///testing.js:42:3\n' + |
| 1296 | + ' at file:///testing.js:42:3', |
| 1297 | + ), |
| 1298 | + digest: 'a dev digest', |
| 1299 | + environmentName: 'Server', |
| 1300 | + }, |
| 1301 | + ], |
| 1302 | + findSourceMapURLCalls: gate(flags => flags.enableOwnerStacks) |
| 1303 | + ? [ |
| 1304 | + [__filename], |
| 1305 | + [__filename], |
| 1306 | + // TODO: What should we request here? The outer (<anonymous>) or the inner (inspected-page.html)? |
| 1307 | + ['inspected-page.html:29:11), <anonymous>'], |
| 1308 | + ['file://~/(some)(really)(exotic-directory)/ReactFlight-test.js'], |
| 1309 | + ['file:///testing.js'], |
| 1310 | + [__filename], |
| 1311 | + ] |
| 1312 | + : [], |
| 1313 | + }); |
| 1314 | + } else { |
| 1315 | + expect(errors.map(getErrorForJestMatcher)).toEqual([ |
| 1316 | + { |
| 1317 | + message: |
| 1318 | + 'An error occurred in the Server Components render. The specific message is omitted in production' + |
| 1319 | + ' builds to avoid leaking sensitive details. A digest property is included on this error instance which' + |
| 1320 | + ' may provide additional details about the nature of the error.', |
| 1321 | + stack: |
| 1322 | + 'Error: An error occurred in the Server Components render. The specific message is omitted in production' + |
| 1323 | + ' builds to avoid leaking sensitive details. A digest property is included on this error instance which' + |
| 1324 | + ' may provide additional details about the nature of the error.', |
| 1325 | + digest: 'digest("This is an error")', |
| 1326 | + }, |
| 1327 | + ]); |
| 1328 | + } |
| 1329 | + }); |
| 1330 | + |
1180 | 1331 | it('should include server components in warning stacks', async () => {
|
1181 | 1332 | function Component() {
|
1182 | 1333 | // Trigger key warning
|
|
0 commit comments