 src/Render/BigPipe.php                             |  11 +-
 src/Tests/BigPipeNoJsDetectionTest.php             | 160 ++++++++++++++++++++-
 tests/modules/big_pipe_test/big_pipe_test.info.yml |   6 +
 tests/modules/big_pipe_test/big_pipe_test.module   |   0
 .../big_pipe_test/big_pipe_test.routing.yml        |   7 +
 .../big_pipe_test/big_pipe_test.services.yml       |   5 +
 .../big_pipe_test/src/BigPipeTestController.php    |  89 ++++++++++++
 .../src/EventSubscriber/BigPipeTestSubscriber.php  |  53 +++++++
 .../big_pipe_test/src/Form/BigPipeTestForm.php     |  45 ++++++
 .../Render/Placeholder/BigPipeStrategyTest.php     |  15 +-
 10 files changed, 377 insertions(+), 14 deletions(-)

diff --git a/src/Render/BigPipe.php b/src/Render/BigPipe.php
index 7748300..1c00f1e 100644
--- a/src/Render/BigPipe.php
+++ b/src/Render/BigPipe.php
@@ -201,13 +201,12 @@ class BigPipe implements BigPipeInterface {
     };
     $preg_placeholder_strings = array_map($prepare_for_preg_split, array_keys($no_js_placeholders));
     $fragments = preg_split('/' . implode('|', $preg_placeholder_strings) . '/', $html, NULL, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE);
-    assert('count($fragments) % 2 === 1');
 
-    foreach ($fragments as $index => $fragment) {
-      // The fragments with an even index (0, 2, 4 …) contain HTML which needs
-      // to be printed and flushed right away. The rest of the logic in the loop
-      // handles the uneven indices (1, 3, 5 …) which contains placeholders.
-      if ($index % 2 === 0) {
+    foreach ($fragments as $fragment) {
+      // If the fragment isn't one of the no-JS placeholders, it is the HTML in
+      // between placeholders and it must be printed & flushed immediately. The
+      // rest of the logic in the loop handles the placeholders.
+      if (!isset($no_js_placeholders[$fragment])) {
         print $fragment;
         flush();
         continue;
diff --git a/src/Tests/BigPipeNoJsDetectionTest.php b/src/Tests/BigPipeNoJsDetectionTest.php
index 45ee6e3..f89d306 100644
--- a/src/Tests/BigPipeNoJsDetectionTest.php
+++ b/src/Tests/BigPipeNoJsDetectionTest.php
@@ -7,6 +7,8 @@
 
 namespace Drupal\big_pipe\Tests;
 
+use Drupal\Component\Serialization\Json;
+use Drupal\Component\Utility\Html;
 use Drupal\Core\Url;
 use Drupal\simpletest\WebTestBase;
 
@@ -19,14 +21,23 @@ use Drupal\simpletest\WebTestBase;
  *
  * @group big_pipe
  */
+// @todo rename to BigPipeTest
 class BigPipeNoJsDetectionTest extends WebTestBase {
 
+  const START_SIGNAL= '<script type="application/json" data-big-pipe-event="start"></script>';
+  const STOP_SIGNAL= '<script type="application/json" data-big-pipe-event="stop"></script>';
+
   /**
    * Modules to enable.
    *
    * @var array
    */
-  public static $modules = ['big_pipe', 'session_exists_cache_context_test'];
+  public static $modules = ['big_pipe'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $dumpHeaders = TRUE;
 
   /**
    * {@inheritdoc}
@@ -45,7 +56,9 @@ class BigPipeNoJsDetectionTest extends WebTestBase {
    * Tests big_pipe_page_attachments() + \Drupal\big_pipe\Controller\BigPipeController.
    */
   public function testNoJsDetection() {
-    $this->dumpHeaders = TRUE;
+    $this->container->get('module_installer')->install(['session_exists_cache_context_test']);
+    $this->rebuildContainer();
+    $this->rebuildAll();
 
     // 1. No session (anonymous).
     $this->drupalGet(Url::fromRoute('<front>'));
@@ -77,6 +90,149 @@ class BigPipeNoJsDetectionTest extends WebTestBase {
   }
 
   /**
+   * Tests BigPipe-delivered HTML responses when JavaScript is enabled.
+   */
+  public function testBigPipe() {
+    $this->container->get('module_installer')->install(['big_pipe_test']);
+    $this->rebuildContainer();
+    $this->rebuildAll();
+
+    $this->drupalLogin($this->rootUser);
+    $this->assertSessionCookieExists(TRUE);
+    $this->assertBigPipeNoJsCookieExists(FALSE);
+
+    // By not calling checkForMetaRefresh() manually here, we simulate
+    // JavaScript being enabled, because as far as the BigPipe module is
+    // concerned, JavaScript is enabled in the browser as long as the BigPipe
+    // no-JS cookie is *not* set.
+    // @see setUp()
+
+    $this->drupalGet(Url::fromRoute('big_pipe_test'));
+
+    $this->pass('Verifying BigPipe response headers…', 'Debug');
+    $this->assertTrue(FALSE !== strpos($this->drupalGetHeader('Cache-Control'), 'private'), 'Cache-Control header set to "private".');
+    $this->assertEqual('no-store, content="BigPipe/1.0"', $this->drupalGetHeader('Surrogate-Control'));
+    $this->assertEqual('no', $this->drupalGetHeader('X-Accel-Buffering'));
+
+    // Keys: BigPipe no-JS placeholder markup. Values: expected replacement markup.
+    $expected_big_pipe_nojs_placeholders = [
+      'big_pipe_nojs_placeholder_attribute_safe:form_action_cc611e1d' => '<form class="big-pipe-test-form" data-drupal-selector="big-pipe-test-form" action="' . base_path() . 'big_pipe_test"',
+    ];
+    // Keys: BigPipe placeholder IDs. Values: expected AJAX response.
+    $expected_big_pipe_placeholders = [
+      'callback=Drupal%5CCore%5CRender%5CElement%5CStatusMessages%3A%3ArenderMessages&amp;args[0]&amp;token=a8c34b5e' => Json::encode([
+        [
+          'command' => 'settings',
+          'settings' => [
+            'ajaxPageState' => [
+              'theme' => 'classy',
+              'libraries' => 'big_pipe/big_pipe,classy/base,classy/messages,core/drupal.active-link,core/html5shiv,core/normalize,system/base',
+            ],
+            'pluralDelimiter' => \Drupal\Core\StringTranslation\PluralTranslatableMarkup::DELIMITER,
+            'user' => [
+              'uid' => '1',
+              'permissionsHash' => $this->container->get('user_permissions_hash_generator')->generate($this->rootUser),
+            ],
+          ],
+          'merge' => TRUE,
+        ],
+        [
+          'command' => 'add_css',
+          'data' => '<link rel="stylesheet" href="' . base_path() . 'core/themes/classy/css/components/messages.css?' . $this->container->get('state')->get('system.css_js_query_string') . '" media="all" />' . "\n"
+        ],
+        [
+          'command' => 'insert',
+          'method' => 'replaceWith',
+          'selector' => '[data-big-pipe-placeholder-id="callback=Drupal%5CCore%5CRender%5CElement%5CStatusMessages%3A%3ArenderMessages&args[0]&token=a8c34b5e"]',
+          'data' => "\n" . '    <div role="contentinfo" aria-label="Status message" class="messages messages--status">' . "\n" . '                  <h2 class="visually-hidden">Status message</h2>' . "\n" . '                    Hello from BigPipe!' . "\n" . '            </div>' . "\n    \n",
+          'settings' => NULL,
+        ],
+      ]),
+      'timecurrent-timetime' => Json::encode([
+        [
+          'command' => 'insert',
+          'method' => 'replaceWith',
+          'selector' => '[data-big-pipe-placeholder-id="timecurrent-timetime"]',
+          'data' => '<time datetime=1991-03-14"></time>',
+          'settings' => NULL,
+        ],
+      ]),
+    ];
+
+    $this->pass('Verifying BigPipe no-JS placeholders & replacements…', 'Debug');
+    $this->assertSetsEqual(array_keys($expected_big_pipe_nojs_placeholders), explode(' ', $this->drupalGetHeader('BigPipe-Test-No-Js-Placeholders')));
+    foreach ($expected_big_pipe_nojs_placeholders as $big_pipe_nojs_placeholder => $expected_replacement) {
+      $this->pass('Checking whether the replacement for the BigPipe no-JS placeholder "' . $big_pipe_nojs_placeholder . '" is present:');
+      $this->assertRaw($expected_replacement);
+    }
+
+    $this->pass('Verifying BigPipe placeholders & replacements…', 'Debug');
+    $this->assertSetsEqual(array_keys($expected_big_pipe_placeholders), explode(' ', $this->drupalGetHeader('BigPipe-Test-Placeholders')));
+    $placeholder_positions = [];
+    $placeholder_replacement_positions = [];
+    foreach ($expected_big_pipe_placeholders as $big_pipe_placeholder_id => $expected_ajax_response) {
+      $this->pass('BigPipe placeholder: ' . $big_pipe_placeholder_id, 'Debug');
+      // Verify expected placeholder.
+      $expected_placeholder_html = '<div data-big-pipe-placeholder-id="' . $big_pipe_placeholder_id . '"></div>';
+      $this->assertRaw($expected_placeholder_html, 'BigPipe placeholder for placeholder ID "' . $big_pipe_placeholder_id . '" found.');
+      $pos = strpos($this->getRawContent(), $expected_placeholder_html);
+      $placeholder_positions[$pos] = $big_pipe_placeholder_id;
+      // Verify expected placeholder replacement.
+      $result = $this->xpath('//script[@data-big-pipe-replacement-for-placeholder-with-id=:id]', [':id' => Html::decodeEntities($big_pipe_placeholder_id)]);
+      $this->assertEqual($expected_ajax_response, trim((string) $result[0]));
+      $expected_placeholder_replacement = '<script type="application/json" data-big-pipe-replacement-for-placeholder-with-id="' . $big_pipe_placeholder_id . '">';
+      $this->assertRaw($expected_placeholder_replacement);
+      $pos = strpos($this->getRawContent(), $expected_placeholder_replacement);
+      $placeholder_replacement_positions[$pos] = $big_pipe_placeholder_id;
+    }
+    ksort($placeholder_positions, SORT_NUMERIC);
+    $this->assertEqual(array_keys($expected_big_pipe_placeholders), array_values($placeholder_positions));
+    $this->assertEqual(count($expected_big_pipe_placeholders), preg_match_all('/' . preg_quote('<div data-big-pipe-placeholder-id="', '/') . '/', $this->getRawContent()));
+    $this->assertEqual(array_keys($expected_big_pipe_placeholders), array_values($placeholder_replacement_positions));
+    $this->assertEqual(count($expected_big_pipe_placeholders), preg_match_all('/' . preg_quote('<script type="application/json" data-big-pipe-replacement-for-placeholder-with-id="', '/') . '/', $this->getRawContent()));
+
+    $this->pass('Verifying BigPipe assets are present…', 'Debug');
+    $this->assertFalse(empty($this->getDrupalSettings()), 'drupalSettings present.');
+    $this->assertTrue(in_array('big_pipe/big_pipe', explode(',', $this->getDrupalSettings()['ajaxPageState']['libraries'])), 'BigPipe asset library is present.');
+
+    $this->pass('Verifying BigPipe start/stop signals…', 'Debug');
+    $this->assertRaw(static::START_SIGNAL, 'BigPipe start signal present.');
+    $this->assertRaw(static::STOP_SIGNAL, 'BigPipe stop signal present.');
+    $start_signal_position = strpos($this->getRawContent(), static::START_SIGNAL);
+    $stop_signal_position = strpos($this->getRawContent(), static::STOP_SIGNAL);
+    $this->assertTrue($start_signal_position < $stop_signal_position, 'BigPipe start signal appears before stop signal.');
+
+    $this->pass('Verifying BigPipe placeholder replacements and start/stop signals were streamed in the correct order…', 'Debug');
+    $expected_stream_order = array_keys($expected_big_pipe_placeholders);
+    array_unshift($expected_stream_order, static::START_SIGNAL);
+    array_push($expected_stream_order, static::STOP_SIGNAL);
+    $actual_stream_order = $placeholder_replacement_positions + [
+      $start_signal_position => static::START_SIGNAL,
+      $stop_signal_position => static::STOP_SIGNAL,
+    ];
+    ksort($actual_stream_order, SORT_NUMERIC);
+    $this->assertEqual($expected_stream_order, array_values($actual_stream_order));
+  }
+
+  protected function assertSetsEqual(array $a, array $b) {
+    $a_set = $a;
+    sort($a_set);
+    $b_set = $b;
+    sort($b_set);
+    return $this->assertEqual($a_set, $b_set);
+  }
+
+  /**
+   * Tests BigPipe-delivered HTML responses when JavaScript is disabled.
+   */
+  public function atestBigPipeNoJs() {
+    // BigPipe asset library: absent.
+    $this->assertFalse(empty($this->getDrupalSettings()), 'drupalSettings present.');
+    $this->assertFalse(in_array('big_pipe/big_pipe', explode(',', $this->getDrupalSettings()['ajaxPageState']['libraries'])), 'BigPipe asset library is absent.');
+
+  }
+
+  /**
    * Asserts whether a BigPipe no-JS cookie exists or not.
    */
   protected function assertBigPipeNoJsCookieExists($expected) {
diff --git a/tests/modules/big_pipe_test/big_pipe_test.info.yml b/tests/modules/big_pipe_test/big_pipe_test.info.yml
new file mode 100644
index 0000000..46ed28d
--- /dev/null
+++ b/tests/modules/big_pipe_test/big_pipe_test.info.yml
@@ -0,0 +1,6 @@
+name: 'BigPipe test'
+type: module
+description: 'Support module for BigPipe testing.'
+package: Testing
+version: VERSION
+core: 8.x
diff --git a/tests/modules/big_pipe_test/big_pipe_test.module b/tests/modules/big_pipe_test/big_pipe_test.module
new file mode 100644
index 0000000..e69de29
diff --git a/tests/modules/big_pipe_test/big_pipe_test.routing.yml b/tests/modules/big_pipe_test/big_pipe_test.routing.yml
new file mode 100644
index 0000000..d074285
--- /dev/null
+++ b/tests/modules/big_pipe_test/big_pipe_test.routing.yml
@@ -0,0 +1,7 @@
+big_pipe_test:
+  path: '/big_pipe_test'
+  defaults:
+    _controller: '\Drupal\big_pipe_test\BigPipeTestController::test'
+    _title: 'BigPipe test'
+  requirements:
+    _access: 'TRUE'
diff --git a/tests/modules/big_pipe_test/big_pipe_test.services.yml b/tests/modules/big_pipe_test/big_pipe_test.services.yml
new file mode 100644
index 0000000..5ec4895
--- /dev/null
+++ b/tests/modules/big_pipe_test/big_pipe_test.services.yml
@@ -0,0 +1,5 @@
+services:
+  big_pipe_test_subscriber:
+    class: Drupal\big_pipe_test\EventSubscriber\BigPipeTestSubscriber
+    tags:
+      - { name: event_subscriber }
diff --git a/tests/modules/big_pipe_test/src/BigPipeTestController.php b/tests/modules/big_pipe_test/src/BigPipeTestController.php
new file mode 100644
index 0000000..f8fa5a9
--- /dev/null
+++ b/tests/modules/big_pipe_test/src/BigPipeTestController.php
@@ -0,0 +1,89 @@
+<?php
+
+namespace Drupal\big_pipe_test;
+
+use Drupal\Core\Controller\ControllerBase;
+use Drupal\Core\Form\FormBuilderInterface;
+use Drupal\Core\Render\Markup;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+class BigPipeTestController extends ControllerBase {
+
+  /**
+   * The form builder.
+   *
+   * @var \Drupal\Core\Form\FormBuilderInterface
+   */
+  protected $formBuilder;
+
+  /**
+   * Constructs a BigPipeTestController object.
+   *
+   * @param \Drupal\Core\Form\FormBuilderInterface $form_builder
+   *   The form builder.
+   */
+  public function __construct(FormBuilderInterface $form_builder) {
+    $this->formBuilder = $form_builder;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('form_builder')
+    );
+  }
+
+  /**
+   * @see \Drupal\Tests\big_pipe\Unit\Render\Placeholder\BigPipeStrategyTest::placeholdersProvider()
+   */
+  public function test() {
+    $build = [];
+
+    // 1. HTML placeholder: status messages. Drupal renders those automatically,
+    // so all that we need to do in this controller is set a message.
+    drupal_set_message('Hello from BigPipe!');
+
+    // 2. HTML attribute value placeholder: form action.
+    $build['form'] = $this->formBuilder->getForm('Drupal\big_pipe_test\Form\BigPipeTestForm');
+
+    // 3. HTML attribute value subset placeholder: CSRF token in link.
+    // @todo
+
+    // 4. Edge case: custom string to be considered as a placeholder that
+    // happens to not be valid HTML.
+    // @todo
+
+    // 5. Edge case: non-#lazy_builder placeholder.
+    $build['html_non_lazy_builder'] = [
+      '#markup' => Markup::create('<time>CURRENT TIME</time>'),
+      '#attached' => [
+        'placeholders' => [
+          '<time>CURRENT TIME</time>' => [
+            '#pre_render' => [
+              __CLASS__ . '::currentTime',
+            ],
+          ]
+        ]
+      ]
+    ];
+
+    return $build;
+  }
+
+  /**
+   * #lazy_builder callback; builds <time> markup with current time.
+   *
+   * Note: does not actually use current time, that would complicate testing.
+   *
+   * @return array
+   */
+  public static function currentTime() {
+    return [
+      '#markup' => '<time datetime=' . date('Y-m-d', 668948400) . '"></time>',
+      '#cache' => ['max-age' => 0]
+    ];
+  }
+
+}
diff --git a/tests/modules/big_pipe_test/src/EventSubscriber/BigPipeTestSubscriber.php b/tests/modules/big_pipe_test/src/EventSubscriber/BigPipeTestSubscriber.php
new file mode 100644
index 0000000..78b4599
--- /dev/null
+++ b/tests/modules/big_pipe_test/src/EventSubscriber/BigPipeTestSubscriber.php
@@ -0,0 +1,53 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\big_pipe_test\EventSubscriber\BigPipeTestSubscriber.
+ */
+
+namespace Drupal\big_pipe_test\EventSubscriber;
+
+use Drupal\Core\Render\HtmlResponse;
+use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
+use Symfony\Component\HttpKernel\KernelEvents;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+class BigPipeTestSubscriber implements EventSubscriberInterface {
+
+  /**
+   * Exposes all BigPipe placeholders (JS and no-JS) via headers for testing.
+   *
+   * @param \Symfony\Component\HttpKernel\Event\FilterResponseEvent $event
+   *   The event to process.
+   */
+  public function onRespond(FilterResponseEvent $event) {
+    $response = $event->getResponse();
+    if (!$response instanceof HtmlResponse) {
+      return;
+    }
+
+    $attachments = $response->getAttachments();
+
+    $response->headers->set('BigPipe-Test-Placeholders', '<none>');
+    $response->headers->set('BigPipe-Test-No-Js-Placeholders', '<none>');
+
+    if (!empty($attachments['big_pipe_placeholders'])) {
+      $response->headers->set('BigPipe-Test-Placeholders', implode(' ', array_keys($attachments['big_pipe_placeholders'])));
+    }
+
+    if (!empty($attachments['big_pipe_nojs_placeholders'])) {
+      $response->headers->set('BigPipe-Test-No-Js-Placeholders', implode(' ', array_keys($attachments['big_pipe_nojs_placeholders'])));
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    // Run *just* before \Drupal\big_pipe\EventSubscriber\HtmlResponseBigPipeSubscriber::onRespond().
+    $events[KernelEvents::RESPONSE][] = ['onRespond', -99999];
+
+    return $events;
+  }
+
+}
diff --git a/tests/modules/big_pipe_test/src/Form/BigPipeTestForm.php b/tests/modules/big_pipe_test/src/Form/BigPipeTestForm.php
new file mode 100644
index 0000000..14a040e
--- /dev/null
+++ b/tests/modules/big_pipe_test/src/Form/BigPipeTestForm.php
@@ -0,0 +1,45 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\search\Form\SearchBlockForm.
+ */
+
+namespace Drupal\big_pipe_test\Form;
+
+use Drupal\Core\Form\FormBase;
+use Drupal\Core\Form\FormStateInterface;
+
+class BigPipeTestForm extends FormBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return 'big_pipe_test_form';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildForm(array $form, FormStateInterface $form_state) {
+    $form['#token'] = FALSE;
+
+    $form['big_pipe'] = array(
+      '#type' => 'checkboxes',
+      '#title' => $this->t('BigPipe works…'),
+      '#options' => [
+        'js' => $this->t('… with JavaScript'),
+        'nojs' => $this->t('… without JavaScript'),
+      ],
+    );
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) { }
+
+}
diff --git a/tests/src/Unit/Render/Placeholder/BigPipeStrategyTest.php b/tests/src/Unit/Render/Placeholder/BigPipeStrategyTest.php
index 0ff77fd..0698d1b 100644
--- a/tests/src/Unit/Render/Placeholder/BigPipeStrategyTest.php
+++ b/tests/src/Unit/Render/Placeholder/BigPipeStrategyTest.php
@@ -54,6 +54,9 @@ class BigPipeStrategyTest extends UnitTestCase {
     }
   }
 
+  /**
+   * @see \Drupal\big_pipe_test\BigPipeTestController::test()
+   */
   public function placeholdersProvider() {
     // Define the two types of cacheability that we expect to see. These will be
     // used in the expectations.
@@ -66,7 +69,7 @@ class BigPipeStrategyTest extends UnitTestCase {
       'contexts' => ['session.exists', 'cookies:big_pipe_nojs'],
     ];
 
-    // Real-world example of HTML placeholder.
+    // 1. Real-world example of HTML placeholder.
     $messages_placeholder_markup = '<drupal-render-placeholder callback="Drupal\Core\Render\Element\StatusMessages::renderMessages" arguments="0" token="a8c34b5e"></drupal-render-placeholder>';
     $placeholders[$messages_placeholder_markup] = [
       '#lazy_builder' => [
@@ -99,7 +102,7 @@ class BigPipeStrategyTest extends UnitTestCase {
       ],
     ];
 
-    // Real-world example of HTML attribute value placeholder.
+    // 2. Real-world example of HTML attribute value placeholder.
     $placeholders['form_action_cc611e1d'] = [
       '#lazy_builder' => ['form_builder:renderPlaceholderFormAction', []],
     ];
@@ -113,7 +116,7 @@ class BigPipeStrategyTest extends UnitTestCase {
       ],
     ];
 
-    // Real-world example of HTML attribute value subset placeholder.
+    // 3. Real-world example of HTML attribute value subset placeholder.
     $placeholders['22dcc695f4a22e809a62d8eb7addd6fccecfbf31'] = [
       '#lazy_builder' => [
         'route_processor_csrf:renderPlaceholderCsrfToken',
@@ -130,8 +133,8 @@ class BigPipeStrategyTest extends UnitTestCase {
       ],
     ];
 
-    // Edge case: custom string to be considered as a placeholder that happens
-    // to not be valid HTML.
+    // 4. Edge case: custom string to be considered as a placeholder that
+    // happens to not be valid HTML.
     $placeholders['<hello'] = [
       '#lazy_builder' => [
         'hello_or_yarhar',
@@ -148,7 +151,7 @@ class BigPipeStrategyTest extends UnitTestCase {
       ],
     ];
 
-    // Edge case: non-#lazy_builder placeholder.
+    // 5. Edge case: non-#lazy_builder placeholder.
     $placeholders['<time>CURRENT TIME</time>'] = [
       '#pre_render' => ['current_time'],
     ];
