Skip to content

Commit 67bbd92

Browse files
authored
[#673] Added automatic Drupal BigPipe placeholder wait for JavaScript scenarios. (#681)
1 parent 0de21d0 commit 67bbd92

8 files changed

Lines changed: 216 additions & 2 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ This ensures that the documentation remains in sync with the actual code impleme
143143
- Use descriptive tags (e.g., `@datetime`) to allow selective test execution
144144
- Negative tests using `@trait:FieldTrait` should use simple navigation (e.g., `I go to "node/add/page"`)
145145
- Avoid using custom steps in negative tests that may not be available in BehatCLI context
146+
- Test-only tags - ones consumed by the test harness (`FeatureContext` or the bootstrap traits) to configure a scenario, as opposed to the library's public tags registered in `docs.php`'s `tag_registry()` - must be prefixed with `test-` (e.g., `@test-bigpipe-timeout`) so they are clearly distinguishable from real library tags.
146147

147148
### Step Definition Constraints
148149
- Documentation tool (`docs.php`) does not support multiple `@When` annotations per method

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ from the community.
7474

7575
| Class | Description |
7676
| --- | --- |
77+
| [Drupal\BigPipeTrait](STEPS.md#drupalbigpipetrait) | Wait for Drupal BigPipe placeholders to be replaced on JavaScript scenarios. |
7778
| [Drupal\BlockTrait](STEPS.md#drupalblocktrait) | Manage Drupal blocks. |
7879
| [Drupal\CacheTrait](STEPS.md#drupalcachetrait) | Invalidate specific Drupal caches from within a scenario. |
7980
| [Drupal\ConfigOverrideTrait](STEPS.md#drupalconfigoverridetrait) | Disable Drupal config overrides from settings.php during a scenario. |

STEPS.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030

3131
| Class | Description |
3232
| --- | --- |
33+
| [Drupal\BigPipeTrait](#drupalbigpipetrait) | Wait for Drupal BigPipe placeholders to be replaced on JavaScript scenarios. |
3334
| [Drupal\BlockTrait](#drupalblocktrait) | Manage Drupal blocks. |
3435
| [Drupal\CacheTrait](#drupalcachetrait) | Invalidate specific Drupal caches from within a scenario. |
3536
| [Drupal\ConfigOverrideTrait](#drupalconfigoverridetrait) | Disable Drupal config overrides from settings.php during a scenario. |
@@ -3072,6 +3073,31 @@ Then the response should be a valid Atom feed
30723073

30733074

30743075

3076+
## Drupal\BigPipeTrait
3077+
3078+
[Source](src/Drupal/BigPipeTrait.php), [Example](tests/behat/features/drupal_big_pipe.feature)
3079+
3080+
> Wait for Drupal BigPipe placeholders to be replaced on JavaScript scenarios.
3081+
> <br/><br/>
3082+
> Drupal BigPipe streams parts of a page in after the initial response and
3083+
> replaces its `<span data-big-pipe-placeholder-id="...">` markers with the
3084+
> real markup using JavaScript. Assertions that run before those replacements
3085+
> land intermittently fail with "element not found". When this trait is
3086+
> included, every `@javascript` scenario waits - before each step - until no
3087+
> BigPipe placeholder markers remain in the DOM, removing that race without an
3088+
> explicit step.
3089+
> <br/><br/>
3090+
> The wait is best-effort: on timeout the step still runs, so a genuinely stuck
3091+
> placeholder surfaces as the real assertion failure rather than being masked
3092+
> here. Non-JavaScript scenarios are left untouched, where BigPipe renders
3093+
> server-side (see the drupal-extension `@bigpipe` cookie handling).
3094+
> <br/><br/>
3095+
> Skip processing with tag: `@behat-steps-skip:BigPipeTrait`.
3096+
> <br/><br/>
3097+
> Override `bigPipeGetWaitTimeout()` (or set `$bigPipeWaitTimeout`) in your
3098+
> `FeatureContext` to change the maximum wait.
3099+
3100+
30753101
## Drupal\BlockTrait
30763102

30773103
[Source](src/Drupal/BlockTrait.php), [Example](tests/behat/features/drupal_block.feature)

src/Drupal/BigPipeTrait.php

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace DrevOps\BehatSteps\Drupal;
6+
7+
use Behat\Behat\Hook\Scope\BeforeScenarioScope;
8+
use Behat\Hook\BeforeScenario;
9+
use Behat\Hook\BeforeStep;
10+
use Behat\Mink\Exception\DriverException;
11+
12+
/**
13+
* Wait for Drupal BigPipe placeholders to be replaced on JavaScript scenarios.
14+
*
15+
* Drupal BigPipe streams parts of a page in after the initial response and
16+
* replaces its `<span data-big-pipe-placeholder-id="...">` markers with the
17+
* real markup using JavaScript. Assertions that run before those replacements
18+
* land intermittently fail with "element not found". When this trait is
19+
* included, every `@javascript` scenario waits - before each step - until no
20+
* BigPipe placeholder markers remain in the DOM, removing that race without an
21+
* explicit step.
22+
*
23+
* The wait is best-effort: on timeout the step still runs, so a genuinely stuck
24+
* placeholder surfaces as the real assertion failure rather than being masked
25+
* here. Non-JavaScript scenarios are left untouched, where BigPipe renders
26+
* server-side (see the drupal-extension `@bigpipe` cookie handling).
27+
*
28+
* Skip processing with tag: `@behat-steps-skip:BigPipeTrait`.
29+
*
30+
* Override `bigPipeGetWaitTimeout()` (or set `$bigPipeWaitTimeout`) in your
31+
* `FeatureContext` to change the maximum wait.
32+
*/
33+
trait BigPipeTrait {
34+
35+
/**
36+
* Default maximum time to wait for BigPipe placeholders, in milliseconds.
37+
*/
38+
protected const DEFAULT_WAIT_TIMEOUT = 10000;
39+
40+
/**
41+
* Whether the automatic BigPipe wait is active for the current scenario.
42+
*/
43+
protected bool $bigPipeAutoWaitEnabled = FALSE;
44+
45+
/**
46+
* Maximum time to wait for BigPipe placeholders to be replaced, in milliseconds.
47+
*/
48+
protected int $bigPipeWaitTimeout = self::DEFAULT_WAIT_TIMEOUT;
49+
50+
/**
51+
* Resolve whether the automatic BigPipe wait applies to this scenario.
52+
*/
53+
#[BeforeScenario]
54+
public function bigPipeBeforeScenario(BeforeScenarioScope $scope): void {
55+
$scenario = $scope->getScenario();
56+
$feature = $scope->getFeature();
57+
58+
// Resolved here, not in the BeforeStep hook, because a BeforeStep scope
59+
// cannot read scenario-level tags.
60+
$is_javascript = $feature->hasTag('javascript') || $scenario->hasTag('javascript');
61+
$is_skipped = $feature->hasTag('behat-steps-skip:BigPipeTrait') || $scenario->hasTag('behat-steps-skip:BigPipeTrait');
62+
63+
$this->bigPipeAutoWaitEnabled = $is_javascript && !$is_skipped;
64+
}
65+
66+
/**
67+
* Wait for BigPipe placeholders to settle before each step runs.
68+
*
69+
* Named to avoid overriding the drupal-extension BigPipeTrait's own
70+
* `bigPipeBeforeStep()` hook, inherited through `DrupalContext`.
71+
*/
72+
#[BeforeStep]
73+
public function bigPipeWaitBeforeStep(): void {
74+
if (!$this->bigPipeAutoWaitEnabled) {
75+
return;
76+
}
77+
78+
$this->bigPipeWaitForPlaceholders($this->bigPipeGetWaitTimeout());
79+
}
80+
81+
/**
82+
* Wait until no BigPipe placeholder markers remain in the DOM.
83+
*
84+
* @param int $timeout_ms
85+
* Maximum time to wait, in milliseconds.
86+
*/
87+
protected function bigPipeWaitForPlaceholders(int $timeout_ms): void {
88+
try {
89+
$this->getSession()->wait($timeout_ms, "document.querySelectorAll('[data-big-pipe-placeholder-id]').length === 0");
90+
}
91+
// @codeCoverageIgnoreStart
92+
catch (DriverException) {
93+
// The driver session is not ready (e.g. no page has been visited yet),
94+
// so there is nothing to synchronise.
95+
}
96+
// @codeCoverageIgnoreEnd
97+
}
98+
99+
/**
100+
* Maximum time to wait for BigPipe placeholders, in milliseconds.
101+
*
102+
* @return int
103+
* The timeout in milliseconds.
104+
*/
105+
protected function bigPipeGetWaitTimeout(): int {
106+
return $this->bigPipeWaitTimeout;
107+
}
108+
109+
}

tests/behat/bootstrap/FeatureContext.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@
77

88
declare(strict_types=1);
99

10+
use Behat\Behat\Hook\Scope\BeforeScenarioScope;
11+
use Behat\Hook\BeforeScenario;
1012
use DrevOps\BehatSteps\AccessibilityTrait;
1113
use DrevOps\BehatSteps\CookieTrait;
1214
use DrevOps\BehatSteps\DateTrait;
15+
use DrevOps\BehatSteps\Drupal\BigPipeTrait;
1316
use DrevOps\BehatSteps\Drupal\BlockTrait;
1417
use DrevOps\BehatSteps\Drupal\CacheTrait;
1518
use DrevOps\BehatSteps\Drupal\ConfigOverrideTrait;
@@ -61,6 +64,7 @@
6164
class FeatureContext extends DrupalContext {
6265

6366
use AccessibilityTrait;
67+
use BigPipeTrait;
6468
use BlockTrait;
6569
use CacheTrait;
6670
use ConfigOverrideTrait;
@@ -144,4 +148,16 @@ protected function accessibilityGetReportDir(): string {
144148
return dirname((string) $this->getMinkParameter('files_path'), 3) . '/.logs/test_results/accessibility';
145149
}
146150

151+
/**
152+
* Shorten the BigPipe wait timeout for the timeout coverage scenario.
153+
*
154+
* Scenarios tagged '@test-bigpipe-timeout' use a short timeout so they can
155+
* exercise the wait timing out quickly; every other scenario keeps the trait's
156+
* default.
157+
*/
158+
#[BeforeScenario]
159+
public function bigPipeSetWaitTimeout(BeforeScenarioScope $scope): void {
160+
$this->bigPipeWaitTimeout = $scope->getScenario()->hasTag('test-bigpipe-timeout') ? 2000 : self::DEFAULT_WAIT_TIMEOUT;
161+
}
162+
147163
}

tests/behat/features/drupal_big_pipe.feature

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
Feature: Check that BigPipeTrait works
22
As Behat Steps library developer
3-
I want to provide tools to manage BigPipe cookies
4-
So that users can test progressive rendering with and without JavaScript
3+
I want BigPipe handled for both non-JavaScript and JavaScript drivers
4+
So that non-JS drivers bypass streaming via the nojs cookie and JS drivers wait for placeholders to be replaced before assertions
55

66
@api @skipped
77
Scenario: Assert that Big Pipe cookie is set
@@ -45,3 +45,20 @@ Feature: Check that BigPipeTrait works
4545
Given I install a "big_pipe" module
4646
When I visit "/"
4747
Then cookie "big_pipe_nojs" does not exist
48+
49+
@javascript
50+
Scenario: BigPipe placeholders are awaited automatically before assertions
51+
When I visit "/sites/default/files/bigpipe_wait.html"
52+
Then I should see "BigPipe streamed content"
53+
54+
@javascript @behat-steps-skip:BigPipeTrait
55+
Scenario: The automatic BigPipe wait can be skipped with a tag
56+
When I visit "/sites/default/files/bigpipe_wait.html"
57+
Then I should see an "span[data-big-pipe-placeholder-id]" element
58+
And I should not see "BigPipe streamed content"
59+
60+
@javascript @test-bigpipe-timeout
61+
Scenario: The automatic BigPipe wait times out without failing when placeholders never resolve
62+
When I visit "/sites/default/files/bigpipe_stuck.html"
63+
Then I should see an "span[data-big-pipe-placeholder-id]" element
64+
And I should not see "BigPipe streamed content"
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>BigPipe Stuck Test</title>
6+
</head>
7+
<body>
8+
<h1>BigPipe Stuck Test Page</h1>
9+
<p>This page keeps its BigPipe placeholder in the DOM and never replaces it,
10+
so the automatic wait runs until its timeout.</p>
11+
12+
<span data-big-pipe-placeholder-id="placeholder-1">Loading...</span>
13+
</body>
14+
</html>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>BigPipe Wait Test</title>
6+
</head>
7+
<body>
8+
<h1>BigPipe Wait Test Page</h1>
9+
<p>This page simulates Drupal BigPipe replacing a placeholder after load.</p>
10+
11+
<span data-big-pipe-placeholder-id="placeholder-1"></span>
12+
13+
<script>
14+
// Mimic Drupal BigPipe: swap the placeholder span for its real content
15+
// after a delay, the way streamed BigPipe replacements arrive. The delay
16+
// is wide enough that the skip-tag scenario can observe the
17+
// pre-replacement state without racing it on a slow CI runner, yet well
18+
// under the trait's default wait timeout so the awaited scenario still
19+
// completes.
20+
setTimeout(function () {
21+
var placeholder = document.querySelector('[data-big-pipe-placeholder-id="placeholder-1"]');
22+
if (placeholder) {
23+
var content = document.createElement('div');
24+
content.textContent = 'BigPipe streamed content';
25+
placeholder.replaceWith(content);
26+
}
27+
}, 5000);
28+
</script>
29+
</body>
30+
</html>

0 commit comments

Comments
 (0)