Skip to content

Commit d522af9

Browse files
committed
test(callable): pin that resolving inside a for-generated ExUnit test terminates
Deciding whether a `test` generated inside a `for` comprehension is an ExUnit.Case child resolves that call, which walks back up into the same `for` and down into its children again. Two generated tests are needed to close the loop: with one, the only element the walk would revisit is the entrance the resolve state was seeded with. Removing the RecursionManager guard on resolveInScope puts this fixture back on the errors reported in #3405, which nothing else on the path prevents - both hasBeenVisited guards read a ResolveState, and the reference boundary the cycle crosses carries none.
1 parent 3b74242 commit d522af9

3 files changed

Lines changed: 115 additions & 0 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
defmodule ExUnit.Case do
2+
defmacro __using__(_opts) do
3+
quote do
4+
import ExUnit.Case
5+
end
6+
end
7+
8+
defmacro describe(message, do: block) do
9+
quote do
10+
unquote(message)
11+
unquote(block)
12+
end
13+
end
14+
15+
defmacro test(message, do: block) do
16+
quote do
17+
unquote(message)
18+
unquote(block)
19+
end
20+
end
21+
end
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
defmodule PhoneNumberPoolingTest do
2+
use ExUnit.Case
3+
4+
describe "available_or_purchased_number/3" do
5+
for test_case <- [
6+
%{partition_strategy: "area_code", expected_phone_number: "+15550000001"},
7+
%{partition_strategy: "toll_free", expected_phone_number: "+18005550002"}
8+
] do
9+
@partition_strategy test_case.partition_strategy
10+
@expected_phone_number test_case.expected_phone_number
11+
12+
test "sets webhooks on a " <> @partition_strategy <> "-type phone_number_pools record" do
13+
assert available_or_purchased_number(random_pool_name<caret>()) == @expected_phone_number
14+
end
15+
16+
test "informs caller that no numbers are available for " <> @partition_strategy do
17+
assert available_or_purchased_number(random_pool_name()) == @expected_phone_number
18+
end
19+
end
20+
end
21+
22+
defp random_pool_name do
23+
"pool"
24+
end
25+
26+
defp available_or_purchased_number(pool_name) do
27+
pool_name
28+
end
29+
end
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package org.elixir_lang.reference.callable
2+
3+
import com.intellij.openapi.util.RecursionManager
4+
import com.intellij.psi.PsiPolyVariantReference
5+
import org.elixir_lang.PlatformTestCase
6+
7+
/**
8+
* https://github.com/KronicDeth/intellij-elixir/issues/3405
9+
*/
10+
class Issue3405Test : PlatformTestCase() {
11+
override fun setUp() {
12+
super.setUp()
13+
14+
// The cycle below is prevented rather than absent, so `mayCacheNow()` is false and the test
15+
// fixture's default `assertOnMissedCache` would fail before any assertion here ran.
16+
// `RecursionManager` documents this switch for "tests that check that the stack isn't
17+
// overflown on invalid code", which is what this is. Production sets neither flag: there,
18+
// `doPreventingRecursion` returns `null` and the reference simply resolves to nothing.
19+
RecursionManager.disableMissedCacheAssertions(testRootDisposable)
20+
RecursionManager.disableAssertOnRecursionPrevention(testRootDisposable)
21+
}
22+
23+
/**
24+
* A `for` comprehension generating ExUnit `test` calls puts `ex_unit.Case.isChild` and
25+
* `For.treeWalkDown` on the same walk: deciding whether a generated `test` is an `ExUnit.Case`
26+
* child resolves that call, and resolving it walks back up into the `for` and down into its
27+
* children again.
28+
*
29+
* Neither hinge's `hasBeenVisited` guard closes this. Both read a `ResolveState`, and the
30+
* reference boundary between them - `PsiPolyVariantReference.multiResolve` - carries none, so
31+
* the far side re-enters at `call_definition_clause.MultiResolve.resolveResults` and starts from
32+
* `ResolveState.initial()`. Seeding that fresh state with the entrance is enough while the `for`
33+
* body holds one generated `test`; with two, the walk reaches the sibling, which is on no
34+
* visited set, and the cycle closes. The reporter's file had two.
35+
*
36+
* What contains it is the element-keyed `RecursionManager` guard on
37+
* `reference.resolver.Callable.resolveInScope`, which is scoped to the thread's stack rather
38+
* than to a `ResolveState` and so survives that boundary. Removing it puts this fixture back on
39+
* the reported errors.
40+
*
41+
* The plugin catches its own overflow and reports it through
42+
* [org.elixir_lang.errorreport.Logger], so termination is asserted on what was logged rather
43+
* than on a `StackOverflowError` reaching the test.
44+
*/
45+
fun testResolvingInsideForGeneratedExUnitTestTerminates() {
46+
val (resolveResults, loggedErrors) = captureLoggedErrors {
47+
// `ex_unit_case.ex` has to be resolvable, or `Case.isChild` answers `false` off the
48+
// `ExUnit.Case` lookup and the walk this pins is never entered.
49+
val reference = myFixture
50+
.getReferenceAtCaretPosition("for_generated_ex_unit_tests.exs", "ex_unit_case.ex")
51+
assertInstanceOf(reference, PsiPolyVariantReference::class.java)
52+
53+
(reference as PsiPolyVariantReference).multiResolve(false)
54+
}
55+
56+
val overflows = loggedErrors.filter { loggedError ->
57+
"StackOverflow" in loggedError.message || "StackOverflow" in (loggedError.title ?: "")
58+
}
59+
60+
assertEmpty("resolving through a `for`-generated ExUnit `test` overflowed the stack", overflows)
61+
assertNotNull(resolveResults)
62+
}
63+
64+
override fun getTestDataPath(): String = "testData/org/elixir_lang/reference/callable/issue_3405"
65+
}

0 commit comments

Comments
 (0)