1 /*
<lambda>null2  * Copyright 2024 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.compose.runtime.tooling
18 
19 import androidx.compose.runtime.Composable
20 import androidx.compose.runtime.Composer
21 import androidx.compose.runtime.DisposableEffect
22 import androidx.compose.runtime.ReusableContent
23 import androidx.compose.runtime.ReusableContentHost
24 import androidx.compose.runtime.getValue
25 import androidx.compose.runtime.key
26 import androidx.compose.runtime.mock.CompositionTestScope
27 import androidx.compose.runtime.mock.compositionTest
28 import androidx.compose.runtime.mutableStateOf
29 import androidx.compose.runtime.remember
30 import androidx.compose.runtime.setValue
31 import androidx.compose.runtime.snapshots.fastForEach
32 import kotlin.test.AfterTest
33 import kotlin.test.BeforeTest
34 import kotlin.test.Test
35 import kotlin.test.assertEquals
36 
37 class ErrorTraceTests {
38     @BeforeTest
39     fun setUp() {
40         Composer.setDiagnosticStackTraceEnabled(true)
41     }
42 
43     @AfterTest
44     fun tearDown() {
45         Composer.setDiagnosticStackTraceEnabled(false)
46     }
47 
48     @Test
49     fun setContent() =
50         exceptionTest("<lambda>(ErrorTraceTests.kt:<unknown line>)") {
51             compose { throwTestException() }
52         }
53 
54     @Test
55     fun recompose() =
56         exceptionTest(
57             "<lambda>(ErrorTraceTests.kt:<unknown line>)",
58         ) {
59             var state by mutableStateOf(false)
60             compose {
61                 if (state) {
62                     throwTestException()
63                 }
64             }
65 
66             state = true
67             advance()
68         }
69 
70     @Test
71     fun setContentLinear() =
72         exceptionTest(
73             "<lambda>(ErrorTraceTests.kt:<unknown line>)",
74             "<lambda>(ErrorTraceComposables.kt:77)",
75             "ReusableComposeNode(Composables.kt:<line number>)",
76             "Linear(ErrorTraceComposables.kt:73)",
77             "<lambda>(ErrorTraceTests.kt:<line number>)"
78         ) {
79             compose { Linear { throwTestException() } }
80         }
81 
82     @Test
83     fun recomposeLinear() =
84         exceptionTest(
85             "<lambda>(ErrorTraceTests.kt:<unknown line>)",
86             "<lambda>(ErrorTraceComposables.kt:77)",
87             "ReusableComposeNode(Composables.kt:<line number>)",
88             "Linear(ErrorTraceComposables.kt:73)",
89             "<lambda>(ErrorTraceTests.kt:<line number>)"
90         ) {
91             var state by mutableStateOf(false)
92             compose {
93                 Linear {
94                     if (state) {
95                         throwTestException()
96                     }
97                 }
98             }
99 
100             state = true
101             advance()
102         }
103 
104     @Test
105     fun setContentInlineLinear() =
106         exceptionTest(
107             "<lambda>(ErrorTraceTests.kt:<unknown line>)",
108             "<lambda>(ErrorTraceComposables.kt:87)",
109             "ReusableComposeNode(Composables.kt:<line number>)",
110             "InlineLinear(ErrorTraceComposables.kt:83)",
111             "<lambda>(ErrorTraceTests.kt:<line number>)"
112         ) {
113             compose { InlineLinear { throwTestException() } }
114         }
115 
116     @Test
117     fun recomposeInlineLinear() =
118         exceptionTest(
119             "<lambda>(ErrorTraceTests.kt:<unknown line>)",
120             "<lambda>(ErrorTraceComposables.kt:87)",
121             "ReusableComposeNode(Composables.kt:<line number>)",
122             "InlineLinear(ErrorTraceComposables.kt:83)",
123             "<lambda>(ErrorTraceTests.kt:<line number>)"
124         ) {
125             var state by mutableStateOf(false)
126 
127             compose {
128                 InlineLinear {
129                     if (state) {
130                         throwTestException()
131                     }
132                 }
133             }
134 
135             state = true
136             advance()
137         }
138 
139     @Test
140     fun setContentAfterTextInLoopInlineWrapper() =
141         exceptionTest(
142             "<lambda>(ErrorTraceTests.kt:<unknown line>)",
143             "InlineWrapper(ErrorTraceComposables.kt:57)",
144             "<lambda>(ErrorTraceTests.kt:<line number>)"
145         ) {
146             compose {
147                 InlineWrapper {
148                     repeat(5) { it ->
149                         Text("test")
150                         if (it > 3) {
151                             throwTestException()
152                         }
153                     }
154                 }
155             }
156         }
157 
158     @Test
159     fun recomposeAfterTextInLoopInlineWrapper() =
160         exceptionTest(
161             "<lambda>(ErrorTraceTests.kt:<unknown line>)",
162             "InlineWrapper(ErrorTraceComposables.kt:57)",
163             "<lambda>(ErrorTraceTests.kt:<line number>)"
164         ) {
165             var state by mutableStateOf(false)
166 
167             compose {
168                 InlineWrapper {
169                     repeat(5) { it ->
170                         Text("test")
171                         if (it > 3 && state) {
172                             throwTestException()
173                         }
174                     }
175                 }
176             }
177 
178             state = true
179             advance()
180         }
181 
182     @Test
183     fun setContentAfterTextInLoop() =
184         exceptionTest(
185             "<lambda>(ErrorTraceTests.kt:<unknown line>)",
186             "Repeated(ErrorTraceComposables.kt:94)",
187             "<lambda>(ErrorTraceTests.kt:<line number>)"
188         ) {
189             compose {
190                 Repeated(List(10) { it }) {
191                     Text("test")
192                     throwTestException()
193                 }
194             }
195         }
196 
197     @Test
198     fun recomposeAfterTextInLoop() =
199         exceptionTest(
200             "<lambda>(ErrorTraceTests.kt:<unknown line>)",
201             "Repeated(ErrorTraceComposables.kt:94)",
202             "<lambda>(ErrorTraceTests.kt:<line number>)"
203         ) {
204             var state by mutableStateOf(false)
205 
206             compose {
207                 Repeated(List(10) { it }) {
208                     Text("test")
209                     if (state) {
210                         throwTestException()
211                     }
212                 }
213             }
214 
215             state = true
216             advance()
217         }
218 
219     @Test
220     fun setContentSubcomposition() =
221         exceptionTest(
222             "<lambda>(ErrorTraceTests.kt:<unknown line>)",
223             "<lambda>(ErrorTraceComposables.kt:66)",
224             "Subcompose(ErrorTraceComposables.kt:62)",
225             "<lambda>(ErrorTraceTests.kt:<line number>)"
226         ) {
227             compose { Subcompose { throwTestException() } }
228         }
229 
230     @Test
231     fun recomposeSubcomposition() =
232         exceptionTest(
233             "<lambda>(ErrorTraceTests.kt:<unknown line>)",
234             "<lambda>(ErrorTraceComposables.kt:66)",
235             "Subcompose(ErrorTraceComposables.kt:62)",
236             "<lambda>(ErrorTraceTests.kt:<line number>)"
237         ) {
238             var state by mutableStateOf(false)
239 
240             compose {
241                 Subcompose {
242                     if (state) {
243                         throwTestException()
244                     }
245                 }
246             }
247 
248             state = true
249             advance()
250         }
251 
252     @Test
253     fun setContentDefaults() =
254         exceptionTest(
255             "<lambda>(ErrorTraceTests.kt:<unknown line>)",
256             "ComposableWithDefaults(ErrorTraceComposables.kt:109)",
257             "<lambda>(ErrorTraceTests.kt:<line number>)"
258         ) {
259             compose { ComposableWithDefaults { throwTestException() } }
260         }
261 
262     @Test
263     fun recomposeDefaults() =
264         exceptionTest(
265             "<lambda>(ErrorTraceTests.kt:<unknown line>)",
266             "ComposableWithDefaults(ErrorTraceComposables.kt:109)",
267             "<lambda>(ErrorTraceTests.kt:<line number>)"
268         ) {
269             var state by mutableStateOf(false)
270 
271             compose {
272                 ComposableWithDefaults {
273                     if (state) {
274                         throwTestException()
275                     }
276                 }
277             }
278 
279             state = true
280             advance()
281         }
282 
283     @Test
284     fun setContentRemember() =
285         exceptionTest(
286             "remember(ErrorTraceTests.kt:<unknown line>)",
287             "<lambda>(ErrorTraceTests.kt:<line number>)"
288         ) {
289             compose { remember { throwTestException() } }
290         }
291 
292     @Test
293     fun setContentRememberObserver() =
294         exceptionTest(
295             "remember(Effects.kt:<unknown line>)",
296             "DisposableEffect(Effects.kt:<line number>)",
297             "<lambda>(ErrorTraceTests.kt:<line number>)"
298         ) {
299             compose { DisposableEffect(Unit) { throwTestException() } }
300         }
301 
302     @Test
303     fun recomposeRememberObserver() =
304         exceptionTest(
305             "remember(Effects.kt:<unknown line>)",
306             "DisposableEffect(Effects.kt:<line number>)",
307             "<lambda>(ErrorTraceTests.kt:<line number>)"
308         ) {
309             var state by mutableStateOf(false)
310             compose {
311                 DisposableEffect(state) {
312                     if (state) {
313                         throwTestException()
314                     }
315                     onDispose {}
316                 }
317             }
318 
319             state = true
320             advance()
321         }
322 
323     @Test
324     fun nodeReuse() =
325         exceptionTest(
326             // todo(b/409033128): Investigate why NodeWithCallbacks has unknown line
327             // missing ReusableComposeNode because writer is not set directly to the node group
328             "NodeWithCallbacks(ErrorTraceComposables.kt:<unknown line>)",
329             "<lambda>(ErrorTraceTests.kt:<line number>)",
330             "ReusableContent(Composables.kt:<line number>)",
331             "<lambda>(ErrorTraceTests.kt:<line number>)"
332         ) {
333             var state by mutableStateOf(false)
334             compose {
335                 ReusableContent(state) { NodeWithCallbacks(onReuse = { throwTestException() }) }
336             }
337 
338             state = true
339             advance()
340         }
341 
342     @Test
343     fun nodeDeactivate() =
344         exceptionTest(
345             "ReusableComposeNode(Composables.kt:<unknown line>)",
346             "NodeWithCallbacks(ErrorTraceComposables.kt:122)",
347             "<lambda>(ErrorTraceTests.kt:<line number>)",
348             "ReusableContentHost(Composables.kt:<line number>)",
349             "<lambda>(ErrorTraceTests.kt:<line number>)"
350         ) {
351             var active by mutableStateOf(true)
352             compose {
353                 ReusableContentHost(active) {
354                     NodeWithCallbacks(onDeactivate = { throwTestException() })
355                 }
356             }
357 
358             active = false
359             advance()
360         }
361 
362     @Test
363     fun setContentNodeAttach() =
364         exceptionTest(
365             "ReusableComposeNode(Composables.kt:<unknown line>)",
366             "NodeWithCallbacks(ErrorTraceComposables.kt:122)",
367             "<lambda>(ErrorTraceTests.kt:<line number>)",
368             "InlineWrapper(ErrorTraceComposables.kt:57)",
369             "<lambda>(ErrorTraceTests.kt:<line number>)"
370         ) {
371             compose { InlineWrapper { NodeWithCallbacks(onAttach = { throwTestException() }) } }
372         }
373 
374     @Test
375     fun recomposeNodeAttach() =
376         exceptionTest(
377             "ReusableComposeNode(Composables.kt:<unknown line>)",
378             "NodeWithCallbacks(ErrorTraceComposables.kt:122)",
379             "<lambda>(ErrorTraceTests.kt:<line number>)",
380             "Wrapper(ErrorTraceComposables.kt:149)",
381             "<lambda>(ErrorTraceTests.kt:<line number>)"
382         ) {
383             var state by mutableStateOf(false)
384             compose {
385                 Wrapper {
386                     if (state) {
387                         NodeWithCallbacks(onAttach = { throwTestException() })
388                     }
389                 }
390             }
391 
392             state = true
393             advance()
394         }
395 
396     @Test
397     // todo(b/409033128): Investigate why NodeWithCallbacks has an incorrect line
398     fun recomposeNodeAttachInlineWrapper() =
399         exceptionTest(
400             "ReusableComposeNode(Composables.kt:<unknown line>)",
401             "NodeWithCallbacks(ErrorTraceComposables.kt:122)",
402             // (b/380272059): groupless source information is missing here after recomposition
403             //                "<lambda>(ErrorTraceTests.kt:<line number>)",
404             //                "InlineWrapper(ErrorTraceComposables.kt:148)",
405             "<lambda>(ErrorTraceTests.kt:<line number>)"
406         ) {
407             var state by mutableStateOf(false)
408             compose {
409                 InlineWrapper {
410                     if (state) {
411                         NodeWithCallbacks(onAttach = { throwTestException() })
412                     }
413                 }
414             }
415 
416             state = true
417             advance()
418         }
419 
420     @Test
421     fun emptySourceInformation() =
422         exceptionTest(
423             "<lambda>(ErrorTraceTests.kt:<unknown line>)",
424             "<lambda>(ErrorTraceTests.kt:<line number>)",
425             "InlineWrapper(ErrorTraceComposables.kt:57)",
426             "<lambda>(ErrorTraceTests.kt:<line number>)"
427         ) {
428             val list = listOf(1, 2, 3)
429             var content: (@Composable () -> Unit)? = null
430             // some gymnastics to ensure that Kotlin generates a null check
431             if (3 in list) {
432                 content = { throwTestException() }
433             }
434 
435             compose { InlineWrapper { list.fastForEach { key(it) { content?.invoke() } } } }
436         }
437 
438     @Test
439     fun setContentNodeUpdate() =
440         exceptionTest(
441             "ReusableComposeNode(Composables.kt:<unknown line>)",
442             "NodeWithCallbacks(ErrorTraceComposables.kt:122)",
443             "<lambda>(ErrorTraceTests.kt:<line number>)",
444             "Wrapper(ErrorTraceComposables.kt:149)",
445             "<lambda>(ErrorTraceTests.kt:<line number>)"
446         ) {
447             compose { Wrapper { NodeWithCallbacks(onUpdate = { throwTestException() }) } }
448         }
449 
450     @Test
451     fun recomposeUpdate() =
452         exceptionTest(
453             "ReusableComposeNode(Composables.kt:<unknown line>)",
454             "NodeWithCallbacks(ErrorTraceComposables.kt:122)",
455             "<lambda>(ErrorTraceTests.kt:<line number>)",
456             "Wrapper(ErrorTraceComposables.kt:149)",
457             "<lambda>(ErrorTraceTests.kt:<line number>)"
458         ) {
459             var state by mutableStateOf(false)
460             compose {
461                 Wrapper {
462                     if (state) {
463                         NodeWithCallbacks(
464                             onUpdate =
465                                 if (state) {
466                                     { throwTestException() }
467                                 } else {
468                                     {}
469                                 }
470                         )
471                     }
472                 }
473             }
474 
475             state = true
476             advance()
477         }
478 }
479 
throwTestExceptionnull480 private fun throwTestException(): Nothing = throw TestComposeException()
481 
482 private class TestComposeException : Exception("Test exception")
483 
484 private const val TestFile = "ErrorTraceComposables.kt"
485 private const val DebugKeepLineNumbers = false
486 
487 private fun exceptionTest(vararg trace: String, block: suspend CompositionTestScope.() -> Unit) {
488     assertTrace(trace.toList()) { compositionTest(block) }
489 }
490 
assertTracenull491 private fun assertTrace(expected: List<String>, block: () -> Unit) {
492     var exception: TestComposeException? = null
493     try {
494         block()
495     } catch (e: TestComposeException) {
496         exception = e
497     }
498     exception = exception ?: error("Composition exception was not caught or not thrown")
499 
500     val composeTrace =
501         exception.suppressedExceptions.firstOrNull { it is DiagnosticComposeException }
502     if (composeTrace == null) {
503         throw exception
504     }
505     val message = composeTrace.message.orEmpty()
506     val frameString =
507         message
508             .substringAfter("Composition stack when thrown:\n")
509             .lines()
510             .filter { it.isNotEmpty() }
511             .map {
512                 val trace = it.removePrefix("\tat ")
513                 // Only keep the lines in the test file
514                 if (trace.contains(TestFile)) {
515                     trace
516                 } else {
517                     val line = trace.substringAfter(':').substringBefore(')')
518                     if (line == "<unknown line>" || DebugKeepLineNumbers) {
519                         trace
520                     } else {
521                         trace.replace(line, "<line number>")
522                     }
523                 }
524             }
525             .joinToString(",\n") { "\"$it\"" }
526 
527     val expectedString = expected.joinToString(",\n") { "\"$it\"" }
528     assertEquals(expectedString, frameString)
529 }
530