diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-each.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-each.php
new file mode 100644
index 0000000000000..eea7f3f9fe8e7
--- /dev/null
+++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-each.php
@@ -0,0 +1,681 @@
+interactivity = new WP_Interactivity_API();
+ $this->interactivity->state( 'myPlugin', array( 'list' => array( 1, 2 ) ) );
+ $this->interactivity->state( 'myPlugin', array( 'after' => 'after-wp-each' ) );
+ }
+
+ /**
+ * Tests that the `data-wp-each` directive doesn't do anything if it's not on
+ * a template tag.
+ *
+ * @ticket 60356
+ *
+ * @covers ::process_directives
+ */
+ public function test_wp_each_doesnt_do_anything_on_non_template_tags() {
+ $original = '
+
+
+
';
+ $new = $this->interactivity->process_directives( $original );
+ $this->assertEquals( $original, $new );
+ }
+
+ /**
+ * Tests that the `data-wp-each` directive doesn't do anything if the array
+ * is associative instead of indexed.
+ *
+ * @ticket 60356
+ *
+ * @covers ::process_directives
+ */
+ public function test_wp_each_doesnt_do_anything_on_associative_arrays() {
+ $this->interactivity->state(
+ 'myPlugin',
+ array(
+ 'assoc' => array(
+ 'one' => 1,
+ 'two' => 2,
+ ),
+ )
+ );
+ $original = '
+
+
+ ';
+ $new = $this->interactivity->process_directives( $original );
+ $this->assertEquals( $original, $new );
+ }
+
+ /**
+ * Tests that the `data-wp-each` directive works with simple tags.
+ *
+ * @ticket 60356
+ *
+ * @covers ::process_directives
+ */
+ public function test_wp_each_simple_tags() {
+ $original = '' .
+ '' .
+ '' .
+ '' .
+ 'Text
';
+ $expected = '' .
+ '' .
+ '' .
+ '' .
+ '1' .
+ '2' .
+ 'Text
';
+ $new = $this->interactivity->process_directives( $original );
+ $this->assertEquals( $expected, $new );
+ }
+
+ /**
+ * Tests that the `data-wp-each` directive doesn't do anything if the array is
+ * empty.
+ *
+ * @ticket 60356
+ *
+ * @covers ::process_directives
+ */
+ public function test_wp_each_empty_array() {
+ $this->interactivity->state( 'myPlugin', array( 'empty' => array() ) );
+ $original = '' .
+ '' .
+ '' .
+ '' .
+ 'Text
';
+ $expected = '' .
+ '' .
+ '' .
+ '' .
+ 'Text
';
+ $new = $this->interactivity->process_directives( $original );
+ $this->assertEquals( $expected, $new );
+ }
+
+ /**
+ * Tests that the `data-wp-each` directive merges the item with the previous
+ * context correctly.
+ *
+ * @ticket 60356
+ *
+ * @covers ::process_directives
+ */
+ public function test_wp_each_merges_context_correctly() {
+ $original = '' .
+ '' .
+ '
' .
+ '' .
+ '' .
+ '
Text
' .
+ '
';
+ $expected = '' .
+ '' .
+ '
' .
+ '' .
+ '' .
+ '
1' .
+ '
2' .
+ '
New text
' .
+ '
';
+ $new = $this->interactivity->process_directives( $original );
+ $this->assertEquals( $expected, $new );
+ }
+
+ /**
+ * Tests that the `data-wp-each` directive works with arrays from the context.
+ *
+ * @ticket 60356
+ *
+ * @covers ::process_directives
+ */
+ public function test_wp_each_gets_arrays_from_context() {
+ $original = '' .
+ '' .
+ '
' .
+ '' .
+ '' .
+ '
Text
' .
+ '
';
+ $expected = '' .
+ '' .
+ '
' .
+ '' .
+ '' .
+ '
1' .
+ '
2' .
+ '
Text
' .
+ '
';
+ $new = $this->interactivity->process_directives( $original );
+ $this->assertEquals( $expected, $new );
+ }
+
+ /**
+ * Tests that the `data-wp-each` directive works with the default namespace.
+ *
+ * @ticket 60356
+ *
+ * @covers ::process_directives
+ */
+ public function test_wp_each_default_namespace() {
+ $original = '' .
+ '' .
+ '
' .
+ '' .
+ '' .
+ '
Text
' .
+ '
';
+ $expected = '' .
+ '' .
+ '
' .
+ '' .
+ '' .
+ '
1' .
+ '
2' .
+ '
Text
' .
+ '
';
+ $new = $this->interactivity->process_directives( $original );
+ $this->assertEquals( $expected, $new );
+ }
+
+ /**
+ * Tests that the `data-wp-each` directive works with multiple tags per item.
+ *
+ * @ticket 60356
+ *
+ * @covers ::process_directives
+ */
+ public function test_wp_each_multiple_tags_per_item() {
+ $original = '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ 'Text
';
+ $expected = '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '1' .
+ '1' .
+ '2' .
+ '2' .
+ 'Text
';
+ $new = $this->interactivity->process_directives( $original );
+ $this->assertEquals( $expected, $new );
+ }
+
+ /**
+ * Tests that the `data-wp-each` directive works with void tags.
+ *
+ * @ticket 60356
+ *
+ * @covers ::process_directives
+ */
+ public function test_wp_each_void_tags() {
+ $original = '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ 'Text
';
+ $expected = '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ 'Text
';
+ $new = $this->interactivity->process_directives( $original );
+ $this->assertEquals( $expected, $new );
+ }
+
+ /**
+ * Tests that the `data-wp-each` directive works with combinations of void and
+ * non-void tags.
+ *
+ * @ticket 60356
+ *
+ * @covers ::process_directives
+ */
+ public function test_wp_each_void_and_non_void_tags() {
+ $original = '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ 'Text
';
+ $expected = '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '1' .
+ '' .
+ '2' .
+ 'Text
';
+ $new = $this->interactivity->process_directives( $original );
+ $this->assertEquals( $expected, $new );
+ }
+
+ /**
+ * Tests that the `data-wp-each` directive works with nested tags.
+ *
+ * @ticket 60356
+ *
+ * @covers ::process_directives
+ */
+ public function test_wp_each_nested_tags() {
+ $original = '' .
+ '' .
+ '' .
+ 'id: ' .
+ '
' .
+ '' .
+ 'Text
';
+ $expected = '' .
+ '' .
+ '' .
+ 'id: ' .
+ '
' .
+ '' .
+ '' .
+ 'id: 1' .
+ '
' .
+ '' .
+ 'id: 2' .
+ '
' .
+ 'Text
';
+ $new = $this->interactivity->process_directives( $original );
+ $this->assertEquals( $expected, $new );
+ }
+
+ /**
+ * Tests that the `data-wp-each` directive works with nested item properties.
+ *
+ * @ticket 60356
+ *
+ * @covers ::process_directives
+ */
+ public function test_wp_each_nested_item_properties() {
+ $this->interactivity->state(
+ 'myPlugin',
+ array(
+ 'list' => array(
+ array(
+ 'id' => 1,
+ 'name' => 'one',
+ ),
+ array(
+ 'id' => 2,
+ 'name' => 'two',
+ ),
+ ),
+ )
+ );
+ $original = '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ 'Text
';
+ $expected = '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '1' .
+ 'one' .
+ '2' .
+ 'two' .
+ 'Text
';
+ $new = $this->interactivity->process_directives( $original );
+ $this->assertEquals( $expected, $new );
+ }
+
+ /**
+ * Tests that the `data-wp-each` directive works with different item names.
+ *
+ * @ticket 60356
+ *
+ * @covers ::process_directives
+ */
+ public function test_wp_each_different_item_names() {
+ $original = '' .
+ '' .
+ '' .
+ '' .
+ 'Text
';
+ $expected = '' .
+ '' .
+ '' .
+ '' .
+ '1' .
+ '2' .
+ 'Text
';
+ $new = $this->interactivity->process_directives( $original );
+ $this->assertEquals( $expected, $new );
+ }
+
+ /**
+ * Tests that the `data-wp-each` directive transforms kebab-case into
+ * camelCase.
+ *
+ * @ticket 60356
+ *
+ * @covers ::process_directives
+ */
+ public function test_wp_each_different_item_names_transforms_camelcase() {
+ $original = '' .
+ '' .
+ '' .
+ '' .
+ 'Text
';
+ $expected = '' .
+ '' .
+ '' .
+ '' .
+ '1' .
+ '2' .
+ 'Text
';
+ $new = $this->interactivity->process_directives( $original );
+ $this->assertEquals( $expected, $new );
+ }
+
+ /**
+ * Tests that the `data-wp-each` directive doesn't work with top-level texts.
+ *
+ * @ticket 60356
+ *
+ * @covers ::process_directives
+ */
+ public function test_wp_each_doesnt_work_with_top_level_text() {
+ $original = '' .
+ '' .
+ 'id: ' .
+ '';
+ $new = $this->interactivity->process_directives( $original );
+ $this->assertEquals( $original, $new );
+
+ $original = '' .
+ '' .
+ '!' .
+ '';
+ $new = $this->interactivity->process_directives( $original );
+ $this->assertEquals( $original, $new );
+
+ // But it should work fine with spaces and linebreaks.
+ $original = '
+
+
+ ';
+ $new = $this->interactivity->process_directives( $original );
+ $p = new WP_HTML_Tag_Processor( $new );
+ $p->next_tag( array( 'class_name' => 'test' ) );
+ $p->next_tag( array( 'class_name' => 'test' ) );
+ $this->assertEquals( '1', $p->get_attribute( 'id' ) );
+ $p->next_tag( array( 'class_name' => 'test' ) );
+ $this->assertEquals( '2', $p->get_attribute( 'id' ) );
+ }
+
+ /**
+ * Tests that the `data-wp-each` directive works with nested template tags.
+ *
+ * @ticket 60356
+ *
+ * @covers ::process_directives
+ */
+ public function test_wp_each_nested_template_tags() {
+ $this->interactivity->state( 'myPlugin', array( 'list2' => array( 3, 4 ) ) );
+ $original = '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ 'Text
';
+ $expected = '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '1' .
+ '' .
+ '' .
+ '' .
+ '3' .
+ '4' .
+ '2' .
+ '' .
+ '' .
+ '' .
+ '3' .
+ '4' .
+ 'Text
';
+ $new = $this->interactivity->process_directives( $original );
+ $this->assertEquals( $expected, $new );
+ }
+
+ /**
+ * Tests that the `data-wp-each` directive works with directly nested template
+ * tags.
+ *
+ * @ticket 60356
+ *
+ * @covers ::process_directives
+ */
+ public function test_wp_each_directly_nested_template_tags() {
+ $this->interactivity->state( 'myPlugin', array( 'list2' => array( 3, 4 ) ) );
+ $original = '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ 'Text
';
+ $expected = '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '1' .
+ '3' .
+ '1' .
+ '4' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '2' .
+ '3' .
+ '2' .
+ '4' .
+ 'Text
';
+ $new = $this->interactivity->process_directives( $original );
+ $this->assertEquals( $expected, $new );
+ }
+
+ /**
+ * Tests that the `data-wp-each` directive works with nestded template tags
+ * that use a previous item as a list.
+ *
+ * @ticket 60356
+ *
+ * @covers ::process_directives
+ */
+ public function test_wp_each_nested_template_tags_using_previous_item_as_list() {
+ $this->interactivity->state( 'myPlugin', array( 'list2' => array( array( 1, 2 ), array( 3, 4 ) ) ) );
+ $original = '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ 'Text
';
+ $expected = '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '1' .
+ '2' .
+ '' .
+ '' .
+ '' .
+ '3' .
+ '4' .
+ 'Text
';
+ $new = $this->interactivity->process_directives( $original );
+ $this->assertEquals( $expected, $new );
+ }
+
+ /**
+ * Tests that the `data-wp-each` directive doesn't process unbalanced tags.
+ *
+ * @ticket 60356
+ *
+ * @covers ::process_directives
+ */
+ public function test_wp_each_unbalanced_tags() {
+ $original = '' .
+ '' .
+ '' .
+ '' .
+ 'Text
';
+ $new = $this->interactivity->process_directives( $original );
+ $this->assertEquals( $original, $new );
+ }
+
+ /**
+ * Tests that the `data-wp-each` directive doesn't process unbalanced tags in
+ * nested templates.
+ *
+ * @ticket 60356
+ *
+ * @covers ::process_directives
+ */
+ public function test_wp_each_unbalanced_tags_in_nested_template_tags() {
+ $this->interactivity->state( 'myPlugin', array( 'list2' => array( 3, 4 ) ) );
+ $original = '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ 'Text
';
+ $new = $this->interactivity->process_directives( $original );
+ $this->assertEquals( $original, $new );
+ }
+
+ /**
+ * Tests that the `data-wp-each` directive doesn't process if it doesn't get
+ * an array.
+ *
+ * @ticket 60356
+ *
+ * @covers ::process_directives
+ */
+ public function test_wp_each_doesnt_process_if_not_array() {
+ $original = '' .
+ '' .
+ '' .
+ '' .
+ 'Text
';
+ $expected = '' .
+ '' .
+ '' .
+ '' .
+ 'Text
';
+
+ $this->interactivity->state( 'myPlugin', array( 'list' => null ) );
+ $new = $this->interactivity->process_directives( $original );
+ $this->assertEquals( $expected, $new );
+
+ $this->interactivity->state( 'myPlugin', array( 'list' => 'Text' ) );
+ $new = $this->interactivity->process_directives( $original );
+ $this->assertEquals( $expected, $new );
+
+ $this->interactivity->state( 'myPlugin', array( 'list' => 100 ) );
+ $new = $this->interactivity->process_directives( $original );
+ $this->assertEquals( $expected, $new );
+
+ $this->interactivity->state( 'myPlugin', array( 'list' => false ) );
+ $new = $this->interactivity->process_directives( $original );
+ $this->assertEquals( $expected, $new );
+
+ $this->interactivity->state( 'myPlugin', array( 'list' => true ) );
+ $new = $this->interactivity->process_directives( $original );
+ $this->assertEquals( $expected, $new );
+ }
+
+ /**
+ * Tests that the `data-wp-each` directive doesn't process anything if it
+ * detects manual server-side processing.
+ *
+ * @ticket 60356
+ *
+ * @covers ::process_directives
+ */
+ public function test_wp_each_doesnt_process_with_manual_server_directive_processing() {
+ $original = '' .
+ '' .
+ '' .
+ '' .
+ '1' .
+ '2' .
+ 'Text
';
+ $expected = '' .
+ '' .
+ '' .
+ '' .
+ '1' .
+ '2' .
+ 'Text
';
+ $new = $this->interactivity->process_directives( $original );
+ $this->assertEquals( $expected, $new );
+ }
+}
diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-router-region.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-router-region.php
new file mode 100644
index 0000000000000..9387f6e0f209a
--- /dev/null
+++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-router-region.php
@@ -0,0 +1,159 @@
+interactivity = new WP_Interactivity_API();
+
+ // Removes all hooks set for `wp_footer`.
+ global $wp_filter;
+ $this->original_wp_footer = $wp_filter['wp_footer'];
+ $wp_filter['wp_footer'] = new WP_Hook();
+ }
+
+ /**
+ * Tear down.
+ */
+ public function tear_down() {
+ // Restores all previous hooks set for `wp_footer`.
+ global $wp_filter;
+ $wp_filter['wp_footer'] = $this->original_wp_footer;
+
+ parent::tear_down();
+ }
+
+ /**
+ * Executes the hooks associated to `wp_footer`.
+ */
+ protected function render_wp_footer() {
+ ob_start();
+ do_action( 'wp_footer' );
+ return ob_get_clean();
+ }
+
+ /**
+ * Tests that no elements are added if the `data-wp-router-region` is
+ * missing.
+ *
+ * @ticket 60356
+ *
+ * @covers ::process_directives
+ */
+ public function test_wp_router_region_missing() {
+ $html = 'Nothing here
';
+ $new_html = $this->interactivity->process_directives( $html );
+ $footer = $this->render_wp_footer();
+ $this->assertEquals( $html, $new_html );
+ $this->assertEquals( '', $footer );
+ }
+
+ /**
+ * Tests that the `data-wp-router-region` directive adds a loading bar and a
+ * region for screen reader announcements in the footer.
+ *
+ * @ticket 60356
+ *
+ * @covers ::process_directives
+ */
+ public function test_wp_router_region_adds_loading_bar_aria_live_region() {
+ $html = 'Interactive region
';
+ $new_html = $this->interactivity->process_directives( $html );
+ $footer = $this->render_wp_footer();
+
+ $this->assertEquals( $html, $new_html );
+
+ $query = array( 'tag_name' => 'style' );
+
+ $p = new WP_HTML_Tag_Processor( $footer );
+ $this->assertTrue( $p->next_tag( $query ) );
+ $this->assertEquals( 'wp-interactivity-router_animations', $p->get_attribute( 'id' ) );
+ $this->assertFalse( $p->next_tag( $query ) );
+
+ $query = array(
+ 'tag_name' => 'div',
+ 'class_name' => 'wp-interactivity-router_loading-bar',
+ );
+
+ $p = new WP_HTML_Tag_Processor( $footer );
+ $this->assertTrue( $p->next_tag( $query ) );
+ $this->assertFalse( $p->next_tag( $query ) );
+
+ $query = array(
+ 'tag_name' => 'div',
+ 'class_name' => 'screen-reader-text',
+ );
+
+ $p = new WP_HTML_Tag_Processor( $footer );
+ $this->assertTrue( $p->next_tag( $query ) );
+ $this->assertFalse( $p->next_tag( $query ) );
+ }
+
+ /**
+ * Tests that the `data-wp-router-region` directive only adds the elements
+ * once, independently of the number of directives processed.
+ *
+ * @ticket 60356
+ *
+ * @covers ::process_directives
+ */
+ public function test_wp_router_region_adds_loading_bar_aria_live_region_only_once() {
+ $html = '
+ Interactive region
+ Another interactive region
+ ';
+ $new_html = $this->interactivity->process_directives( $html );
+ $footer = $this->render_wp_footer();
+
+ $this->assertEquals( $html, $new_html );
+
+ $query = array( 'tag_name' => 'style' );
+
+ $p = new WP_HTML_Tag_Processor( $footer );
+ $this->assertTrue( $p->next_tag( $query ) );
+ $this->assertEquals( 'wp-interactivity-router_animations', $p->get_attribute( 'id' ) );
+ $this->assertFalse( $p->next_tag( $query ) );
+
+ $query = array(
+ 'tag_name' => 'div',
+ 'class_name' => 'wp-interactivity-router_loading-bar',
+ );
+
+ $p = new WP_HTML_Tag_Processor( $footer );
+ $this->assertTrue( $p->next_tag( $query ) );
+ $this->assertFalse( $p->next_tag( $query ) );
+
+ $query = array(
+ 'tag_name' => 'div',
+ 'class_name' => 'screen-reader-text',
+ );
+
+ $p = new WP_HTML_Tag_Processor( $footer );
+ $this->assertTrue( $p->next_tag( $query ) );
+ $this->assertFalse( $p->next_tag( $query ) );
+ }
+}