From 767840e7fb53cf3a9b52d09e6ee913db1b6daa5c Mon Sep 17 00:00:00 2001 From: Amaury Balmer Date: Thu, 10 Sep 2026 16:11:52 +0200 Subject: [PATCH 1/5] test: add a PHPUnit suite covering every feature and SVG source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin had no tests. This adds 176 of them, describing what the plugin does today so that a later refactor of how icons load and cache can be shown not to change it. Covered: - every SVG source — folder, sprite, single file — including icon_map labels, sprite version cache-busting, the public URL a sprite icon resolves to, empty and unreadable inputs, and the blockparty_icons_svg_parse_tags filter - the collection container: lookup by name, the dotted-name key, replacement, count, search across both name and label - the registration API a theme calls, duplicate rejection, unknown types, and the editor's REST preload paths - front-end markup: raw and sprite icons, size, border radius, link and aria-label, all accepted and rejected colour formats, and URL/label escaping - both REST controllers: payload shape, pagination headers, search, single item, 404s, and the permission checks at every role - the object-cache wrapper, including that an empty result is a hit and not a miss - the KSES allowances that keep saved block markup valid, and that '; + + $filtered = wp_kses_post( $html ); + + $this->assertStringNotContainsString( 'assertStringContainsString( 'd="M0 0"', $filtered ); + } + + /* ------------------------------------------------------- safe_style_css */ + + public function test_display_is_an_allowed_style_property(): void { + $this->assertContains( 'display', apply_filters( 'safe_style_css', [] ) ); + } + + public function test_a_display_declaration_survives_kses(): void { + $filtered = wp_kses_post( '' ); + + $this->assertStringContainsString( 'display:block', $filtered ); + } + + /* ------------------------------------------------------------- SvgKses */ + + public function test_svg_kses_lists_the_root_and_basic_shapes(): void { + $allowed = SvgKses::get_allowed_svg_kses(); + + foreach ( [ 'svg', 'path', 'circle', 'ellipse', 'line', 'rect', 'polygon', 'polyline' ] as $tag ) { + $this->assertArrayHasKey( $tag, $allowed ); + } + } + + public function test_svg_kses_lists_structure_gradients_and_masks(): void { + $allowed = SvgKses::get_allowed_svg_kses(); + + foreach ( [ 'g', 'defs', 'symbol', 'use', 'linearGradient', 'radialGradient', 'stop', 'clipPath', 'mask' ] as $tag ) { + $this->assertArrayHasKey( $tag, $allowed ); + } + } + + /** + * @dataProvider provide_dangerous_svg_elements + */ + public function test_svg_kses_excludes_dangerous_elements( string $tag ): void { + $this->assertArrayNotHasKey( $tag, SvgKses::get_allowed_svg_kses() ); + } + + public function provide_dangerous_svg_elements(): array { + return array_map( + static fn( $t ) => [ $t ], + [ 'script', 'foreignObject', 'iframe', 'image', 'animate', 'set', 'handler' ] + ); + } + + public function test_svg_kses_allows_no_event_handlers(): void { + foreach ( SvgKses::get_allowed_svg_kses() as $tag => $attributes ) { + foreach ( array_keys( $attributes ) as $attribute ) { + $this->assertStringStartsNotWith( + 'on', + strtolower( (string) $attribute ), + "Tag <{$tag}> must not allow the event attribute {$attribute}." + ); + } + } + } + + public function test_svg_kses_is_filterable(): void { + add_filter( + 'blockparty_icons_allowed_svg_kses', + static function ( array $allowed ) { + $allowed['marker'] = [ 'id' => true ]; + + return $allowed; + } + ); + + $this->assertArrayHasKey( 'marker', SvgKses::get_allowed_svg_kses() ); + } +} diff --git a/tests/phpunit/Icon/CollectionItemTest.php b/tests/phpunit/Icon/CollectionItemTest.php new file mode 100644 index 0000000..f34c0a6 --- /dev/null +++ b/tests/phpunit/Icon/CollectionItemTest.php @@ -0,0 +1,88 @@ +', 'A star', '1.2.3' ); + + $this->assertSame( 'star', $item->name() ); + $this->assertSame( 'raw', $item->type() ); + $this->assertSame( '', $item->content() ); + $this->assertSame( 'A star', $item->label() ); + $this->assertSame( '1.2.3', $item->version() ); + } + + public function test_the_label_defaults_to_the_name(): void { + $item = new CollectionItem( 'star', 'raw', '' ); + + $this->assertSame( 'star', $item->label() ); + } + + public function test_the_version_is_null_by_default(): void { + $item = new CollectionItem( 'star', 'raw', '' ); + + $this->assertNull( $item->version() ); + } + + public function test_a_null_label_falls_back_to_the_name(): void { + $item = new CollectionItem( 'star', 'raw', '', null ); + + $this->assertSame( 'star', $item->label() ); + } + + public function test_empty_content_is_allowed(): void { + $item = new CollectionItem( 'star', 'raw', '' ); + + $this->assertSame( '', $item->content() ); + } + + public function test_content_is_returned_verbatim(): void { + $svg = ''; + + $item = new CollectionItem( 'star', 'raw', $svg ); + + $this->assertSame( + $svg, + $item->content(), + 'The plugin does not sanitize SVGs; content must pass through untouched.' + ); + } + + public function test_it_survives_a_serialization_round_trip(): void { + $item = new CollectionItem( 'star', 'sprite', 'https://example.org/s.svg#star', 'A star', '2.0' ); + + // Serializing is the point of the test: this is how an item reaches the + // object cache and comes back. + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize,WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize + $restored = unserialize( serialize( $item ) ); + + $this->assertInstanceOf( CollectionItem::class, $restored ); + $this->assertSame( 'star', $restored->name() ); + $this->assertSame( 'sprite', $restored->type() ); + $this->assertSame( 'https://example.org/s.svg#star', $restored->content() ); + $this->assertSame( 'A star', $restored->label() ); + $this->assertSame( '2.0', $restored->version() ); + } + + public function test_it_survives_a_trip_through_the_object_cache(): void { + $item = new CollectionItem( 'star', 'raw', $this->svg( 'MARKER' ), 'A star' ); + + wp_cache_set( 'item', [ $item ], 'blockparty-icons-test' ); + $restored = wp_cache_get( 'item', 'blockparty-icons-test' ); + + $this->assertSame( 'star', $restored[0]->name() ); + $this->assertSame( $this->svg( 'MARKER' ), $restored[0]->content() ); + } +} diff --git a/tests/phpunit/Icon/CollectionItemsFactoryTest.php b/tests/phpunit/Icon/CollectionItemsFactoryTest.php new file mode 100644 index 0000000..07ce069 --- /dev/null +++ b/tests/phpunit/Icon/CollectionItemsFactoryTest.php @@ -0,0 +1,352 @@ +make_icon_folder( + [ + 'alpha.svg' => $this->svg( 'M1 1' ), + 'beta.svg' => $this->svg( 'M2 2' ), + 'gamma.svg' => $this->svg( 'M3 3' ), + ] + ); + + $items = CollectionItemsFactory::from_folder( $folder ); + + $this->assertCount( 3, $items ); + $this->assertContainsOnlyInstancesOf( CollectionItem::class, $items ); + $this->assertSame( + [ 'alpha', 'beta', 'gamma' ], + $this->names( $items ), + 'Item names come from the file name without its extension.' + ); + } + + public function test_from_folder_exposes_each_file_contents(): void { + $folder = $this->make_icon_folder( + [ + 'alpha.svg' => $this->svg( 'M1 1' ), + 'beta.svg' => $this->svg( 'M2 2' ), + ] + ); + + $items = $this->by_name( CollectionItemsFactory::from_folder( $folder ) ); + + $this->assertSame( $this->svg( 'M1 1' ), $items['alpha']->content() ); + $this->assertSame( $this->svg( 'M2 2' ), $items['beta']->content() ); + } + + public function test_from_folder_items_are_raw_type(): void { + $folder = $this->make_icon_folder( [ 'alpha.svg' => $this->svg() ] ); + + $items = CollectionItemsFactory::from_folder( $folder ); + + $this->assertSame( 'raw', $items[0]->type() ); + $this->assertNull( $items[0]->version() ); + } + + public function test_from_folder_labels_default_to_the_file_name(): void { + $folder = $this->make_icon_folder( [ 'arrow-left.svg' => $this->svg() ] ); + + $items = CollectionItemsFactory::from_folder( $folder ); + + $this->assertSame( 'arrow-left', $items[0]->label() ); + } + + public function test_from_folder_icon_map_overrides_labels(): void { + $folder = $this->make_icon_folder( + [ + 'alpha.svg' => $this->svg(), + 'beta.svg' => $this->svg(), + ] + ); + + $items = $this->by_name( + CollectionItemsFactory::from_folder( $folder, [ 'alpha' => 'First letter' ] ) + ); + + $this->assertSame( 'First letter', $items['alpha']->label() ); + $this->assertSame( 'beta', $items['beta']->label(), 'Unmapped icons keep their file name.' ); + } + + public function test_from_folder_ignores_non_svg_files(): void { + $folder = $this->make_icon_folder( + [ + 'alpha.svg' => $this->svg(), + 'notes.txt' => 'not an icon', + 'sprite.png' => 'not an icon either', + ] + ); + + $items = CollectionItemsFactory::from_folder( $folder ); + + $this->assertSame( [ 'alpha' ], $this->names( $items ) ); + } + + public function test_from_folder_skips_empty_files(): void { + $folder = $this->make_icon_folder( + [ + 'alpha.svg' => $this->svg(), + 'empty.svg' => '', + ] + ); + + $items = CollectionItemsFactory::from_folder( $folder ); + + $this->assertSame( [ 'alpha' ], $this->names( $items ) ); + } + + public function test_from_folder_on_empty_directory_returns_nothing(): void { + $folder = $this->make_temp_dir( 'empty' ); + + $this->assertSame( [], CollectionItemsFactory::from_folder( $folder ) ); + } + + public function test_from_folder_throws_on_unreadable_path(): void { + $this->expectException( CollectionCreationException::class ); + + CollectionItemsFactory::from_folder( '/definitely/not/a/folder' ); + } + + public function test_from_folder_is_stable_across_calls(): void { + $folder = $this->make_icon_folder( + [ + 'alpha.svg' => $this->svg( 'M1 1' ), + 'beta.svg' => $this->svg( 'M2 2' ), + ] + ); + + $first = $this->by_name( CollectionItemsFactory::from_folder( $folder ) ); + $second = $this->by_name( CollectionItemsFactory::from_folder( $folder ) ); + + $this->assertSame( array_keys( $first ), array_keys( $second ) ); + $this->assertSame( + $first['alpha']->content(), + $second['alpha']->content(), + 'A second call — cached or not — must describe the same icons.' + ); + } + + public function test_from_folder_caches_per_icon_map(): void { + $folder = $this->make_icon_folder( [ 'alpha.svg' => $this->svg() ] ); + + $plain = CollectionItemsFactory::from_folder( $folder ); + $labeled = CollectionItemsFactory::from_folder( $folder, [ 'alpha' => 'Renamed' ] ); + + $this->assertSame( 'alpha', $plain[0]->label() ); + $this->assertSame( + 'Renamed', + $labeled[0]->label(), + 'A different icon_map must not be served the previous cache entry.' + ); + } + + /* ------------------------------------------------------------- sprites */ + + public function test_from_sprite_returns_one_item_per_symbol(): void { + $path = $this->make_file( 'sprite.svg', $this->sprite( [ 'ico-alpha', 'ico-beta' ] ) ); + + $items = CollectionItemsFactory::from_sprite( $path ); + + $this->assertCount( 2, $items ); + $this->assertSame( [ 'ico-alpha', 'ico-beta' ], $this->names( $items ) ); + } + + public function test_from_sprite_items_are_sprite_type(): void { + $path = $this->make_file( 'sprite.svg', $this->sprite( [ 'ico-alpha' ] ) ); + + $items = CollectionItemsFactory::from_sprite( $path ); + + $this->assertSame( 'sprite', $items[0]->type() ); + } + + public function test_from_sprite_content_is_a_public_url_fragment(): void { + $path = $this->make_file( 'sprite.svg', $this->sprite( [ 'ico-alpha' ] ) ); + + $items = CollectionItemsFactory::from_sprite( $path ); + + $expected = str_replace( WP_CONTENT_DIR, WP_CONTENT_URL, $path ) . '#ico-alpha'; + + $this->assertSame( + $expected, + $items[0]->content(), + 'A sprite icon is referenced by URL, not inlined.' + ); + $this->assertStringStartsWith( WP_CONTENT_URL, $items[0]->content() ); + } + + public function test_from_sprite_version_is_added_to_the_url_and_the_item(): void { + $path = $this->make_file( 'sprite.svg', $this->sprite( [ 'ico-alpha' ] ) ); + + $items = CollectionItemsFactory::from_sprite( $path, [], '2.4.1' ); + + $this->assertStringContainsString( 'v=2.4.1', $items[0]->content() ); + $this->assertStringContainsString( '#ico-alpha', $items[0]->content() ); + $this->assertSame( '2.4.1', $items[0]->version() ); + } + + public function test_from_sprite_version_busts_the_cache(): void { + $path = $this->make_file( 'sprite.svg', $this->sprite( [ 'ico-alpha' ] ) ); + + $v1 = CollectionItemsFactory::from_sprite( $path, [], '1.0.0' ); + $v2 = CollectionItemsFactory::from_sprite( $path, [], '2.0.0' ); + + $this->assertStringContainsString( 'v=1.0.0', $v1[0]->content() ); + $this->assertStringContainsString( 'v=2.0.0', $v2[0]->content() ); + } + + public function test_from_sprite_derives_a_readable_label_from_the_id(): void { + $path = $this->make_file( 'sprite.svg', $this->sprite( [ 'ico-arrow-left' ] ) ); + + $items = CollectionItemsFactory::from_sprite( $path ); + + $this->assertSame( + 'Arrow left', + $items[0]->label(), + 'The first hyphen-separated segment is a prefix and is dropped.' + ); + } + + public function test_from_sprite_label_falls_back_to_the_id_when_nothing_remains(): void { + $path = $this->make_file( 'sprite.svg', $this->sprite( [ 'solo' ] ) ); + + $items = CollectionItemsFactory::from_sprite( $path ); + + $this->assertSame( 'solo', $items[0]->label() ); + } + + public function test_from_sprite_icon_map_overrides_labels(): void { + $path = $this->make_file( 'sprite.svg', $this->sprite( [ 'ico-alpha' ] ) ); + + $items = CollectionItemsFactory::from_sprite( $path, [ 'ico-alpha' => 'Custom' ] ); + + $this->assertSame( 'Custom', $items[0]->label() ); + } + + public function test_from_sprite_without_symbols_returns_nothing(): void { + $path = $this->make_file( 'sprite.svg', '' ); + + $this->assertSame( [], CollectionItemsFactory::from_sprite( $path ) ); + } + + public function test_from_sprite_ignores_ids_outside_the_parsed_tags(): void { + // Only and survive the strip_tags() pass, so a is + // not an icon. + $path = $this->make_file( + 'sprite.svg', + '' . + '' . + '' + ); + + $items = CollectionItemsFactory::from_sprite( $path ); + + $this->assertSame( [ 'ico-real' ], $this->names( $items ) ); + } + + public function test_from_sprite_parsed_tags_are_filterable(): void { + $path = $this->make_file( + 'sprite.svg', + '' + ); + + add_filter( 'blockparty_icons_svg_parse_tags', static fn() => '' ); + + $this->assertSame( + [], + CollectionItemsFactory::from_sprite( $path ), + 'Restricting the parsed tags to must hide a id.' + ); + } + + public function test_from_sprite_throws_on_unreadable_path(): void { + $this->expectException( CollectionCreationException::class ); + + CollectionItemsFactory::from_sprite( '/definitely/not/a/sprite.svg' ); + } + + /* -------------------------------------------------------- single files */ + + public function test_from_file_returns_one_item(): void { + $path = $this->make_file( 'star.svg', $this->svg( 'M9 9' ) ); + + $items = CollectionItemsFactory::from_file( $path, [ 'name' => 'star' ] ); + + $this->assertCount( 1, $items ); + $this->assertSame( 'star', $items[0]->name() ); + $this->assertSame( 'raw', $items[0]->type() ); + $this->assertSame( $this->svg( 'M9 9' ), $items[0]->content() ); + } + + public function test_from_file_uses_the_provided_label(): void { + $path = $this->make_file( 'star.svg', $this->svg() ); + + $items = CollectionItemsFactory::from_file( + $path, + [ + 'name' => 'star', + 'label' => 'A shining star', + ] + ); + + $this->assertSame( 'A shining star', $items[0]->label() ); + } + + public function test_from_file_on_empty_file_returns_nothing(): void { + $path = $this->make_file( 'empty.svg', '' ); + + $this->assertSame( [], CollectionItemsFactory::from_file( $path, [ 'name' => 'empty' ] ) ); + } + + public function test_from_file_throws_on_unreadable_path(): void { + $this->expectException( CollectionCreationException::class ); + + CollectionItemsFactory::from_file( '/definitely/not/a/file.svg' ); + } + + /* ------------------------------------------------------------- helpers */ + + /** + * @param CollectionItem[] $items + * + * @return string[] + */ + private function names( array $items ): array { + return array_map( static fn( CollectionItem $item ) => $item->name(), $items ); + } + + /** + * @param CollectionItem[] $items + * + * @return array + */ + private function by_name( array $items ): array { + $out = []; + foreach ( $items as $item ) { + $out[ $item->name() ] = $item; + } + + return $out; + } +} diff --git a/tests/phpunit/Icon/CollectionTest.php b/tests/phpunit/Icon/CollectionTest.php new file mode 100644 index 0000000..305ca85 --- /dev/null +++ b/tests/phpunit/Icon/CollectionTest.php @@ -0,0 +1,254 @@ +' ): CollectionItem { + return new CollectionItem( $name, 'raw', $content, $label ); + } + + /* --------------------------------------------------------- constructor */ + + public function test_label_defaults_to_the_name(): void { + $collection = new Collection( 'my-icons' ); + + $this->assertSame( 'my-icons', $collection->name() ); + $this->assertSame( 'my-icons', $collection->label() ); + } + + public function test_label_can_be_given(): void { + $collection = new Collection( 'my-icons', 'My icons' ); + + $this->assertSame( 'My icons', $collection->label() ); + } + + /* ---------------------------------------------------------- membership */ + + public function test_a_new_collection_is_empty(): void { + $collection = new Collection( 'empty' ); + + $this->assertSame( 0, $collection->count() ); + $this->assertSame( [], $collection->all() ); + $this->assertNull( $collection->get( 'anything' ) ); + } + + public function test_items_are_retrievable_by_name(): void { + $collection = new Collection( 'icons' ); + $collection->add( $this->item( 'alpha' ) ); + $collection->add( $this->item( 'beta' ) ); + + $this->assertSame( 2, $collection->count() ); + $this->assertSame( 'alpha', $collection->get( 'alpha' )->name() ); + $this->assertSame( 'beta', $collection->get( 'beta' )->name() ); + } + + public function test_get_returns_null_for_an_unknown_name(): void { + $collection = new Collection( 'icons' ); + $collection->add( $this->item( 'alpha' ) ); + + $this->assertNull( $collection->get( 'nope' ) ); + } + + public function test_adding_the_same_name_twice_replaces_the_first(): void { + $collection = new Collection( 'icons' ); + $collection->add( $this->item( 'alpha', 'First' ) ); + $collection->add( $this->item( 'alpha', 'Second' ) ); + + $this->assertSame( 1, $collection->count() ); + $this->assertSame( 'Second', $collection->get( 'alpha' )->label() ); + } + + public function test_a_dotted_name_is_keyed_on_the_part_before_the_dot(): void { + $collection = new Collection( 'icons' ); + $collection->add( $this->item( 'alpha.svg' ) ); + + $this->assertSame( + 'alpha.svg', + $collection->get( 'alpha' )->name(), + 'Lookup uses the truncated key while the item keeps its full name.' + ); + } + + public function test_all_is_keyed_by_lookup_name(): void { + $collection = new Collection( 'icons' ); + $collection->add( $this->item( 'alpha' ) ); + $collection->add( $this->item( 'beta' ) ); + + $this->assertSame( [ 'alpha', 'beta' ], array_keys( $collection->all() ) ); + } + + /* -------------------------------------------------------------- search */ + + public function test_search_matches_on_name(): void { + $collection = new Collection( 'icons' ); + $collection->add( $this->item( 'arrow-left' ) ); + $collection->add( $this->item( 'arrow-right' ) ); + $collection->add( $this->item( 'star' ) ); + + $found = $collection->search( 'arrow' ); + + $this->assertSame( 2, $found->count() ); + $this->assertSame( [ 'arrow-left', 'arrow-right' ], array_keys( $found->all() ) ); + } + + public function test_search_matches_on_label(): void { + $collection = new Collection( 'icons' ); + $collection->add( $this->item( 'ico-1', 'Shopping cart' ) ); + $collection->add( $this->item( 'ico-2', 'Star' ) ); + + $found = $collection->search( 'cart' ); + + $this->assertSame( [ 'ico-1' ], array_keys( $found->all() ) ); + } + + public function test_search_is_case_insensitive(): void { + $collection = new Collection( 'icons' ); + $collection->add( $this->item( 'Arrow-Left' ) ); + + $this->assertSame( 1, $collection->search( 'arrow' )->count() ); + $this->assertSame( 1, $collection->search( 'ARROW' )->count() ); + } + + public function test_search_returns_an_empty_collection_when_nothing_matches(): void { + $collection = new Collection( 'icons', 'Icons' ); + $collection->add( $this->item( 'alpha' ) ); + + $found = $collection->search( 'zzz' ); + + $this->assertSame( 0, $found->count() ); + $this->assertSame( 'icons', $found->name(), 'The filtered collection keeps its identity.' ); + $this->assertSame( 'Icons', $found->label() ); + } + + public function test_search_does_not_modify_the_original(): void { + $collection = new Collection( 'icons' ); + $collection->add( $this->item( 'alpha' ) ); + $collection->add( $this->item( 'beta' ) ); + + $collection->search( 'alpha' ); + + $this->assertSame( 2, $collection->count() ); + } + + /* -------------------------------------------------------- named ctors */ + + public function test_from_folder_builds_a_populated_collection(): void { + $folder = $this->make_icon_folder( + [ + 'alpha.svg' => $this->svg( 'M1 1' ), + 'beta.svg' => $this->svg( 'M2 2' ), + ] + ); + + $collection = Collection::from_folder( 'folder-icons', $folder, [ 'label' => 'Folder icons' ] ); + + $this->assertSame( 'folder-icons', $collection->name() ); + $this->assertSame( 'Folder icons', $collection->label() ); + $this->assertSame( 2, $collection->count() ); + $this->assertSame( $this->svg( 'M1 1' ), $collection->get( 'alpha' )->content() ); + } + + public function test_from_folder_applies_the_icon_map(): void { + $folder = $this->make_icon_folder( [ 'alpha.svg' => $this->svg() ] ); + + $collection = Collection::from_folder( + 'folder-icons', + $folder, + [ 'icon_map' => [ 'alpha' => 'The first one' ] ] + ); + + $this->assertSame( 'The first one', $collection->get( 'alpha' )->label() ); + } + + public function test_from_folder_throws_on_unreadable_path(): void { + $this->expectException( CollectionCreationException::class ); + + Collection::from_folder( 'nope', '/definitely/not/a/folder' ); + } + + public function test_from_sprite_builds_a_populated_collection(): void { + $path = $this->make_file( 'sprite.svg', $this->sprite( [ 'ico-alpha', 'ico-beta' ] ) ); + + $collection = Collection::from_sprite( 'sprite-icons', $path, [ 'label' => 'Sprite icons' ] ); + + $this->assertSame( 'Sprite icons', $collection->label() ); + $this->assertSame( 2, $collection->count() ); + $this->assertSame( 'sprite', $collection->get( 'ico-alpha' )->type() ); + } + + public function test_from_sprite_passes_the_version_through(): void { + $path = $this->make_file( 'sprite.svg', $this->sprite( [ 'ico-alpha' ] ) ); + + $collection = Collection::from_sprite( 'sprite-icons', $path, [ 'version' => '3.1' ] ); + + $this->assertSame( '3.1', $collection->get( 'ico-alpha' )->version() ); + $this->assertStringContainsString( 'v=3.1', $collection->get( 'ico-alpha' )->content() ); + } + + public function test_from_sprite_throws_on_unreadable_path(): void { + $this->expectException( CollectionCreationException::class ); + + Collection::from_sprite( 'nope', '/definitely/not/a/sprite.svg' ); + } + + /* ------------------------------------------------------------ iterator */ + + public function test_collection_implements_iterator(): void { + $this->assertInstanceOf( \Iterator::class, new Collection( 'icons' ) ); + } + + public function test_iteration_over_an_empty_collection_yields_nothing(): void { + $seen = []; + foreach ( new Collection( 'icons' ) as $item ) { + $seen[] = $item; + } + + $this->assertSame( [], $seen ); + } + + /** + * Records a defect, not a specification. + * + * The Iterator implementation walks an integer $position, while add() keys items + * by name. valid() therefore asks for $items[0], which never exists, and foreach + * over a populated collection yields nothing at all. + * + * Nothing in the plugin iterates a Collection — every caller uses all() — so this + * is latent rather than broken in production. The test is here so that whoever + * fixes it sees this expectation fail and knows to update it deliberately. + * + * @see Collection::current() + * @see Collection::valid() + */ + public function test_known_defect_iteration_yields_nothing_even_when_populated(): void { + $collection = new Collection( 'icons' ); + $collection->add( $this->item( 'alpha' ) ); + $collection->add( $this->item( 'beta' ) ); + + $seen = []; + foreach ( $collection as $item ) { + $seen[] = $item->name(); + } + + $this->assertSame( 2, $collection->count(), 'The items really are in there.' ); + $this->assertSame( + [], + $seen, + 'Iteration is broken: positions are integers, keys are names. ' . + 'If this assertion starts failing, the Iterator was fixed — update this test.' + ); + } +} diff --git a/tests/phpunit/RegistrationTest.php b/tests/phpunit/RegistrationTest.php new file mode 100644 index 0000000..9e57b10 --- /dev/null +++ b/tests/phpunit/RegistrationTest.php @@ -0,0 +1,269 @@ +assertInstanceOf( Collection::class, $collection ); + $this->assertSame( 'plain', $collection->name() ); + $this->assertSame( 0, $collection->count() ); + } + + public function test_registering_uses_the_given_label(): void { + $collection = register_icon_collection( 'plain', [ 'label' => 'Plain icons' ] ); + + $this->assertSame( 'Plain icons', $collection->label() ); + } + + public function test_registering_a_folder_loads_its_icons(): void { + $folder = $this->make_icon_folder( + [ + 'alpha.svg' => $this->svg( 'M1 1' ), + 'beta.svg' => $this->svg( 'M2 2' ), + ] + ); + + $collection = register_icon_collection( + 'folder-icons', + [ + 'type' => 'folder', + 'source' => $folder, + ] + ); + + $this->assertSame( 2, $collection->count() ); + $this->assertSame( $this->svg( 'M1 1' ), $collection->get( 'alpha' )->content() ); + } + + public function test_registering_a_sprite_loads_its_symbols(): void { + $path = $this->make_file( 'sprite.svg', $this->sprite( [ 'ico-alpha', 'ico-beta' ] ) ); + + $collection = register_icon_collection( + 'sprite-icons', + [ + 'type' => 'sprite', + 'source' => $path, + ] + ); + + $this->assertSame( 2, $collection->count() ); + $this->assertSame( 'sprite', $collection->get( 'ico-alpha' )->type() ); + } + + public function test_registering_an_unknown_type_yields_an_empty_collection(): void { + $collection = register_icon_collection( 'weird', [ 'type' => 'carrier-pigeon' ] ); + + $this->assertInstanceOf( Collection::class, $collection ); + $this->assertSame( 0, $collection->count() ); + } + + public function test_registering_a_folder_that_does_not_exist_stores_false(): void { + $result = register_icon_collection( + 'broken', + [ + 'type' => 'folder', + 'source' => '/definitely/not/a/folder', + ] + ); + + $this->assertFalse( $result, 'A source that cannot be read must not throw out of the API.' ); + } + + public function test_registering_a_prebuilt_collection_instance(): void { + $collection = new Collection( 'prebuilt', 'Prebuilt' ); + $collection->add( new CollectionItem( 'alpha', 'raw', '' ) ); + + $returned = register_icon_collection( $collection ); + + $this->assertSame( $collection, $returned ); + $this->assertSame( $collection, get_icon_collection( 'prebuilt' ) ); + $this->assertSame( 1, get_icon_collection( 'prebuilt' )->count() ); + } + + public function test_registering_a_duplicate_name_is_refused(): void { + register_icon_collection( 'once', [ 'label' => 'First' ] ); + + $second = register_icon_collection( 'once', [ 'label' => 'Second' ] ); + + $this->assertFalse( $second ); + $this->assertSame( 'First', get_icon_collection( 'once' )->label(), 'The first registration wins.' ); + } + + public function test_registering_a_duplicate_instance_is_refused(): void { + register_icon_collection( new Collection( 'once', 'First' ) ); + + $this->assertFalse( register_icon_collection( new Collection( 'once', 'Second' ) ) ); + $this->assertSame( 'First', get_icon_collection( 'once' )->label() ); + } + + /* ------------------------------------------------------------ add_icons */ + + public function test_add_icons_from_a_folder(): void { + register_icon_collection( 'target' ); + $folder = $this->make_icon_folder( + [ + 'alpha.svg' => $this->svg(), + 'beta.svg' => $this->svg(), + ] + ); + + $this->assertTrue( add_icons( 'target', 'folder', $folder ) ); + $this->assertSame( 2, get_icon_collection( 'target' )->count() ); + } + + public function test_add_icons_from_a_sprite(): void { + register_icon_collection( 'target' ); + $path = $this->make_file( 'sprite.svg', $this->sprite( [ 'ico-alpha' ] ) ); + + $this->assertTrue( add_icons( 'target', 'sprite', $path ) ); + $this->assertSame( 'sprite', get_icon_collection( 'target' )->get( 'ico-alpha' )->type() ); + } + + public function test_add_icons_from_a_single_file(): void { + register_icon_collection( 'target' ); + $path = $this->make_file( 'star.svg', $this->svg( 'M9 9' ) ); + + $added = add_icons( + 'target', + 'file', + $path, + [ + 'name' => 'star', + 'label' => 'A star', + ] + ); + + $this->assertTrue( $added ); + $this->assertSame( 'A star', get_icon_collection( 'target' )->get( 'star' )->label() ); + $this->assertSame( $this->svg( 'M9 9' ), get_icon_collection( 'target' )->get( 'star' )->content() ); + } + + public function test_add_icons_accumulates_across_sources(): void { + register_icon_collection( 'target' ); + $folder = $this->make_icon_folder( [ 'alpha.svg' => $this->svg() ] ); + $sprite = $this->make_file( 'sprite.svg', $this->sprite( [ 'ico-beta' ] ) ); + + add_icons( 'target', 'folder', $folder ); + add_icons( 'target', 'sprite', $sprite ); + + $collection = get_icon_collection( 'target' ); + + $this->assertSame( 2, $collection->count() ); + $this->assertSame( 'raw', $collection->get( 'alpha' )->type() ); + $this->assertSame( 'sprite', $collection->get( 'ico-beta' )->type() ); + } + + public function test_add_icons_to_an_unknown_collection_fails(): void { + $folder = $this->make_icon_folder( [ 'alpha.svg' => $this->svg() ] ); + + $this->assertFalse( add_icons( 'nope', 'folder', $folder ) ); + } + + public function test_add_icons_with_an_unknown_type_fails(): void { + register_icon_collection( 'target' ); + + $this->assertFalse( add_icons( 'target', 'carrier-pigeon', '/tmp' ) ); + $this->assertSame( 0, get_icon_collection( 'target' )->count() ); + } + + public function test_add_icons_from_an_unreadable_source_fails(): void { + register_icon_collection( 'target' ); + + $this->assertFalse( add_icons( 'target', 'folder', '/definitely/not/a/folder' ) ); + $this->assertSame( 0, get_icon_collection( 'target' )->count() ); + } + + /* -------------------------------------------------------------- lookups */ + + public function test_icon_collection_exists(): void { + $this->assertFalse( icon_collection_exists( 'ghost' ) ); + + register_icon_collection( 'ghost' ); + + $this->assertTrue( icon_collection_exists( 'ghost' ) ); + } + + public function test_get_icon_collection_returns_null_when_unknown(): void { + $this->assertNull( get_icon_collection( 'ghost' ) ); + } + + public function test_get_icon_collections_lists_everything_by_name(): void { + register_icon_collection( 'one' ); + register_icon_collection( 'two' ); + + $all = get_icon_collections(); + + $this->assertSame( [ 'one', 'two' ], array_keys( $all ) ); + $this->assertContainsOnlyInstancesOf( Collection::class, $all ); + } + + public function test_get_icon_collections_is_empty_by_default(): void { + $this->assertSame( [], get_icon_collections() ); + } + + /* ------------------------------------------------------------- editor */ + + public function test_preload_paths_cover_the_collections_endpoint(): void { + $paths = apply_filters( 'block_editor_rest_api_preload_paths', [], null ); + + $this->assertContains( '/icons/v1/collections?context=edit', $paths ); + } + + public function test_preload_paths_include_one_entry_per_collection(): void { + register_icon_collection( 'one' ); + register_icon_collection( 'two' ); + + $paths = apply_filters( 'block_editor_rest_api_preload_paths', [], null ); + + $this->assertContains( '/icons/v1/one?context=edit', $paths ); + $this->assertContains( '/icons/v1/two?context=edit', $paths ); + } + + public function test_preload_paths_keep_existing_entries(): void { + $paths = apply_filters( 'block_editor_rest_api_preload_paths', [ '/wp/v2/types' ], null ); + + $this->assertContains( '/wp/v2/types', $paths ); + } + + public function test_init_action_fires_for_third_parties(): void { + $fired = false; + add_action( + 'blockparty_icons_init', + static function () use ( &$fired ) { + $fired = true; + } + ); + + do_action( 'blockparty_icons_init' ); + + $this->assertTrue( $fired ); + } + + public function test_the_block_type_is_registered(): void { + $this->assertTrue( + \WP_Block_Type_Registry::get_instance()->is_registered( 'blockparty/icon' ), + 'The plugin registers its block on init.' + ); + } +} diff --git a/tests/phpunit/Rest/CollectionsControllerTest.php b/tests/phpunit/Rest/CollectionsControllerTest.php new file mode 100644 index 0000000..8f9214c --- /dev/null +++ b/tests/phpunit/Rest/CollectionsControllerTest.php @@ -0,0 +1,134 @@ +editor_id = self::factory()->user->create( [ 'role' => 'editor' ] ); + $this->subscriber_id = self::factory()->user->create( [ 'role' => 'subscriber' ] ); + } + + public function tear_down() { + global $wp_rest_server; + $wp_rest_server = null; + + parent::tear_down(); + } + + private function boot_rest(): void { + do_action( 'rest_api_init' ); + } + + private function get_collections() { + return rest_get_server()->dispatch( new WP_REST_Request( 'GET', '/icons/v1/collections' ) ); + } + + /* --------------------------------------------------------- permissions */ + + public function test_the_endpoint_requires_edit_posts(): void { + $this->boot_rest(); + wp_set_current_user( 0 ); + + $this->assertSame( 401, $this->get_collections()->get_status() ); + } + + public function test_a_subscriber_is_refused(): void { + $this->boot_rest(); + wp_set_current_user( $this->subscriber_id ); + + $this->assertSame( 403, $this->get_collections()->get_status() ); + } + + /* --------------------------------------------------------------- shape */ + + public function test_no_collections_yields_an_empty_response(): void { + $this->boot_rest(); + wp_set_current_user( $this->editor_id ); + + $response = $this->get_collections(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( [], $response->get_data() ); + } + + public function test_collections_are_keyed_by_name(): void { + register_icon_collection( 'one', [ 'label' => 'First' ] ); + register_icon_collection( 'two', [ 'label' => 'Second' ] ); + $this->boot_rest(); + wp_set_current_user( $this->editor_id ); + + $data = $this->get_collections()->get_data(); + + $this->assertSame( [ 'one', 'two' ], array_keys( $data ) ); + } + + public function test_each_collection_reports_name_label_and_count(): void { + $folder = $this->make_icon_folder( + [ + 'alpha.svg' => $this->svg(), + 'beta.svg' => $this->svg(), + ] + ); + register_icon_collection( + 'folder-icons', + [ + 'label' => 'Folder icons', + 'type' => 'folder', + 'source' => $folder, + ] + ); + $this->boot_rest(); + wp_set_current_user( $this->editor_id ); + + $entry = $this->get_collections()->get_data()['folder-icons']; + + $this->assertSame( 'folder-icons', $entry['name'] ); + $this->assertSame( 'Folder icons', $entry['label'] ); + $this->assertSame( 2, $entry['count'] ); + } + + public function test_the_listing_carries_no_icon_payloads(): void { + $folder = $this->make_icon_folder( [ 'alpha.svg' => $this->svg( 'MARKER' ) ] ); + register_icon_collection( + 'folder-icons', + [ + 'type' => 'folder', + 'source' => $folder, + ] + ); + $this->boot_rest(); + wp_set_current_user( $this->editor_id ); + + $encoded = wp_json_encode( $this->get_collections()->get_data() ); + + $this->assertStringNotContainsString( + 'MARKER', + $encoded, + 'Listing collections must stay a directory, never ship the SVGs.' + ); + } + + public function test_the_route_is_registered(): void { + $this->boot_rest(); + + $this->assertArrayHasKey( '/icons/v1/collections', rest_get_server()->get_routes() ); + } +} diff --git a/tests/phpunit/Rest/IconsControllerTest.php b/tests/phpunit/Rest/IconsControllerTest.php new file mode 100644 index 0000000..28ea2bb --- /dev/null +++ b/tests/phpunit/Rest/IconsControllerTest.php @@ -0,0 +1,234 @@ +editor_id = self::factory()->user->create( [ 'role' => 'editor' ] ); + $this->subscriber_id = self::factory()->user->create( [ 'role' => 'subscriber' ] ); + + $folder = $this->make_icon_folder( + [ + 'arrow-left.svg' => $this->svg( 'M1 1' ), + 'arrow-right.svg' => $this->svg( 'M2 2' ), + 'star.svg' => $this->svg( 'M3 3' ), + ] + ); + + register_icon_collection( + 'test-icons', + [ + 'label' => 'Test icons', + 'type' => 'folder', + 'source' => $folder, + ] + ); + + // Routes are registered from the collections that exist at rest_api_init. + do_action( 'rest_api_init' ); + } + + public function tear_down() { + global $wp_rest_server; + $wp_rest_server = null; + + parent::tear_down(); + } + + /** + * @param string $route + * @param array $params + * + * @return \WP_REST_Response + */ + private function get( string $route, array $params = [] ) { + $request = new WP_REST_Request( 'GET', $route ); + foreach ( $params as $key => $value ) { + $request->set_param( $key, $value ); + } + + return rest_get_server()->dispatch( $request ); + } + + /* --------------------------------------------------------- permissions */ + + public function test_listing_requires_edit_posts(): void { + wp_set_current_user( 0 ); + + $this->assertSame( 401, $this->get( '/icons/v1/test-icons' )->get_status() ); + } + + public function test_a_subscriber_is_refused(): void { + wp_set_current_user( $this->subscriber_id ); + + $this->assertSame( 403, $this->get( '/icons/v1/test-icons' )->get_status() ); + } + + public function test_an_editor_is_allowed(): void { + wp_set_current_user( $this->editor_id ); + + $this->assertSame( 200, $this->get( '/icons/v1/test-icons' )->get_status() ); + } + + /* --------------------------------------------------------------- items */ + + public function test_listing_returns_every_icon_keyed_by_name(): void { + wp_set_current_user( $this->editor_id ); + + $data = $this->get( '/icons/v1/test-icons' )->get_data(); + + $this->assertSame( [ 'arrow-left', 'arrow-right', 'star' ], array_keys( $data ) ); + } + + public function test_each_icon_exposes_its_fields(): void { + wp_set_current_user( $this->editor_id ); + + $data = $this->get( '/icons/v1/test-icons' )->get_data(); + $icon = $data['arrow-left']; + + $this->assertSame( 'arrow-left', $icon['name'] ); + $this->assertSame( 'arrow-left', $icon['label'] ); + $this->assertSame( 'raw', $icon['type'] ); + $this->assertSame( $this->svg( 'M1 1' ), $icon['content'] ); + $this->assertArrayHasKey( 'version', $icon ); + } + + public function test_totals_are_reported_in_headers(): void { + wp_set_current_user( $this->editor_id ); + + $response = $this->get( '/icons/v1/test-icons' ); + $headers = $response->get_headers(); + + $this->assertSame( 3, $headers['X-WP-Total'] ); + $this->assertSame( 1.0, (float) $headers['X-WP-TotalPages'] ); + } + + /* ---------------------------------------------------------- pagination */ + + public function test_per_page_limits_the_result(): void { + wp_set_current_user( $this->editor_id ); + + $data = $this->get( '/icons/v1/test-icons', [ 'per_page' => 2 ] )->get_data(); + + $this->assertCount( 2, $data ); + $this->assertSame( [ 'arrow-left', 'arrow-right' ], array_keys( $data ) ); + } + + public function test_the_second_page_continues_where_the_first_stopped(): void { + wp_set_current_user( $this->editor_id ); + + $data = $this->get( + '/icons/v1/test-icons', + [ + 'per_page' => 2, + 'page' => 2, + ] + )->get_data(); + + $this->assertSame( [ 'star' ], array_keys( $data ) ); + } + + public function test_page_count_reflects_per_page(): void { + wp_set_current_user( $this->editor_id ); + + $headers = $this->get( '/icons/v1/test-icons', [ 'per_page' => 2 ] )->get_headers(); + + $this->assertSame( 3, $headers['X-WP-Total'] ); + $this->assertSame( 2.0, (float) $headers['X-WP-TotalPages'] ); + } + + public function test_a_page_past_the_end_is_empty(): void { + wp_set_current_user( $this->editor_id ); + + $data = $this->get( + '/icons/v1/test-icons', + [ + 'per_page' => 2, + 'page' => 99, + ] + )->get_data(); + + $this->assertSame( [], $data ); + } + + /* -------------------------------------------------------------- search */ + + public function test_search_narrows_the_list(): void { + wp_set_current_user( $this->editor_id ); + + $data = $this->get( '/icons/v1/test-icons', [ 'search' => 'arrow' ] )->get_data(); + + $this->assertSame( [ 'arrow-left', 'arrow-right' ], array_keys( $data ) ); + } + + public function test_search_totals_describe_the_filtered_set(): void { + wp_set_current_user( $this->editor_id ); + + $headers = $this->get( '/icons/v1/test-icons', [ 'search' => 'arrow' ] )->get_headers(); + + $this->assertSame( 2, $headers['X-WP-Total'] ); + } + + public function test_search_with_no_match_is_empty(): void { + wp_set_current_user( $this->editor_id ); + + $response = $this->get( '/icons/v1/test-icons', [ 'search' => 'zzzz' ] ); + + $this->assertSame( [], $response->get_data() ); + $this->assertSame( 0, $response->get_headers()['X-WP-Total'] ); + } + + /* ---------------------------------------------------------- single item */ + + public function test_a_single_icon_can_be_fetched(): void { + wp_set_current_user( $this->editor_id ); + + $response = $this->get( '/icons/v1/test-icons/star' ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'star', $response->get_data()['name'] ); + $this->assertSame( $this->svg( 'M3 3' ), $response->get_data()['content'] ); + } + + public function test_an_unknown_icon_is_a_404(): void { + wp_set_current_user( $this->editor_id ); + + $response = $this->get( '/icons/v1/test-icons/nope' ); + + $this->assertSame( 404, $response->get_status() ); + $this->assertSame( 'rest_icon_invalid_name', $response->get_data()['code'] ); + } + + /* --------------------------------------------------------------- routes */ + + public function test_a_route_exists_for_each_registered_collection(): void { + $routes = rest_get_server()->get_routes(); + + $this->assertArrayHasKey( '/icons/v1/test-icons', $routes ); + $this->assertArrayHasKey( '/icons/v1/test-icons/(?P[\w\-]+)', $routes ); + } + + public function test_no_route_exists_for_an_unregistered_collection(): void { + wp_set_current_user( $this->editor_id ); + + $this->assertSame( 404, $this->get( '/icons/v1/never-registered' )->get_status() ); + } +} diff --git a/tests/phpunit/TestCase.php b/tests/phpunit/TestCase.php new file mode 100644 index 0000000..55c46cb --- /dev/null +++ b/tests/phpunit/TestCase.php @@ -0,0 +1,157 @@ +temp_dirs as $dir ) { + $this->delete_tree( $dir ); + } + $this->temp_dirs = []; + + $GLOBALS['blockparty_icon_collections'] = []; + + parent::tear_down(); + } + + /** + * Create a directory for fixtures, removed automatically after the test. + * + * Fixtures live under WP_CONTENT_DIR because CollectionItemsFactory derives a + * sprite's public URL by swapping WP_CONTENT_DIR for WP_CONTENT_URL. Putting + * them anywhere else would make that path untestable. + * + * @param string $prefix + * + * @return string Absolute path, without a trailing slash. + */ + protected function make_temp_dir( string $prefix = 'icons' ): string { + $dir = WP_CONTENT_DIR . '/bpi-tests/' . $prefix . '-' . wp_generate_password( 8, false ); + + if ( ! wp_mkdir_p( $dir ) ) { + $this->fail( "Could not create fixture directory {$dir}" ); + } + + $this->temp_dirs[] = $dir; + + return $dir; + } + + /** + * Write a folder of SVG files. + * + * @param array $files Map of file name => file contents. + * + * @return string Absolute path to the folder. + */ + protected function make_icon_folder( array $files ): string { + $dir = $this->make_temp_dir( 'folder' ); + + foreach ( $files as $name => $contents ) { + file_put_contents( $dir . '/' . $name, $contents ); // phpcs:ignore WordPress.WP.AlternativeFunctions + } + + return $dir; + } + + /** + * Write a single file and return its path. + * + * @param string $name + * @param string $contents + * + * @return string + */ + protected function make_file( string $name, string $contents ): string { + $dir = $this->make_temp_dir( 'file' ); + $path = $dir . '/' . $name; + + file_put_contents( $path, $contents ); // phpcs:ignore WordPress.WP.AlternativeFunctions + + return $path; + } + + /** + * Build a minimal but valid single-icon SVG. + * + * @param string $marker Text placed in the path data so a test can tell icons apart. + * + * @return string + */ + protected function svg( string $marker = 'M0 0h24v24H0z' ): string { + return sprintf( + '', + $marker + ); + } + + /** + * Build a sprite containing the given symbol ids. + * + * @param string[] $ids + * + * @return string + */ + protected function sprite( array $ids ): string { + $symbols = ''; + foreach ( $ids as $id ) { + $symbols .= sprintf( + '', + $id + ); + } + + return '' . $symbols . ''; + } + + /** + * Recursively remove a directory. + * + * @param string $dir + */ + private function delete_tree( string $dir ): void { + if ( ! is_dir( $dir ) ) { + return; + } + + foreach ( (array) glob( $dir . '/*' ) as $entry ) { + if ( is_dir( $entry ) ) { + $this->delete_tree( $entry ); + continue; + } + unlink( $entry ); // phpcs:ignore WordPress.WP.AlternativeFunctions + } + + rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions + } +} diff --git a/tests/wp-tests-config-sample.php b/tests/wp-tests-config-sample.php new file mode 100644 index 0000000..1d6b262 --- /dev/null +++ b/tests/wp-tests-config-sample.php @@ -0,0 +1,33 @@ + Date: Thu, 10 Sep 2026 16:22:11 +0200 Subject: [PATCH 2/5] ci: compile the block before running the PHP tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four PHPUnit jobs failed on their first run: 23 failures, every one a rendering test receiving an empty string, plus the assertion that the block type is registered. The cause was not the tests. The plugin calls register_block_type() on its build/ directory, build/ is git-ignored, and the workflow never compiled it — so the block never registered and render_block() had nothing to return. Confirmed locally by moving build/ aside: the same 23 failures over the same 408 assertions as CI. The workflow now builds the assets once in its own job and hands them to the matrix through an artifact, rather than running npm four times over, and checks build/block.json exists before handing over to PHPUnit. The bootstrap gained the same check with a pointed message, because a fresh clone lands in exactly this state and two dozen unexplained failures are a poor welcome. Co-Authored-By: Claude Opus 5 --- .github/workflows/test-php.yml | 47 ++++++++++++++++++++++++++++++++-- tests/README.md | 11 ++++++++ tests/bootstrap.php | 16 ++++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-php.yml b/.github/workflows/test-php.yml index 6063863..f2eceed 100644 --- a/.github/workflows/test-php.yml +++ b/.github/workflows/test-php.yml @@ -13,15 +13,47 @@ concurrency: cancel-in-progress: true jobs: + # The plugin calls register_block_type() on its build/ directory, so the block is + # not registered — and nothing renders — until the assets are compiled. build/ is + # git-ignored, so CI has to produce it. Built once and shared, rather than in each + # matrix job, to keep npm out of the critical path four times over. + build: + name: Build block assets + runs-on: ubuntu-latest + + steps: + - name: Checkout project + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install npm dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Upload build + uses: actions/upload-artifact@v4 + with: + name: block-build + path: build/ + retention-days: 1 + if-no-files-found: error + phpunit: name: PHPUnit (PHP ${{ matrix.php }}) runs-on: ubuntu-latest + needs: build strategy: fail-fast: false matrix: - # The full range the plugin declares support for. Verified locally on 8.3 - # (through wp-env) and 8.4 (against the Composer copy of WordPress). + # The full range the plugin declares support for. php: [ '8.1', '8.2', '8.3', '8.4' ] services: @@ -42,6 +74,12 @@ jobs: - name: Checkout project uses: actions/checkout@v4 + - name: Download block assets + uses: actions/download-artifact@v4 + with: + name: block-build + path: build/ + - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -69,5 +107,10 @@ jobs: tests/wp-tests-config-sample.php > wp-tests-config.php php -l wp-tests-config.php + # Guards against the failure this workflow was fixed for: without build/, + # the block never registers and 23 tests fail with an empty render. + - name: Verify the block manifest is present + run: test -f build/block.json + - name: Run PHPUnit run: composer test diff --git a/tests/README.md b/tests/README.md index 72a5c90..c122314 100644 --- a/tests/README.md +++ b/tests/README.md @@ -7,6 +7,17 @@ mocks rather than the plugin. ## Running +The block assets must be compiled first, whichever way you run the suite: the +plugin registers its block from `build/block.json`, and without it nothing renders. +`build/` is git-ignored, so a fresh clone needs: + +```bash +npm ci && npm run build +``` + +The bootstrap checks for it and says so rather than letting two dozen rendering +tests fail for the wrong reason. + Through wp-env, which supplies WordPress, the test library and a database: ```bash diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 99eb3c9..7fb38b6 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -111,6 +111,22 @@ function bpi_locate_wp_tests_dir(): array { } } +/* + * The plugin registers its block from build/block.json. Without it the block type + * never registers, render_block() returns an empty string, and a couple of dozen + * tests fail for a reason that has nothing to do with what they are testing. + * build/ is git-ignored, so a fresh clone lands exactly there. + */ +if ( ! file_exists( $_bpi_root . '/build/block.json' ) ) { + fwrite( + STDERR, + "build/block.json is missing, so the block cannot register and rendering\n" . + "tests would fail for the wrong reason.\n\n" . + "Compile the assets first:\n npm ci && npm run build\n" + ); + exit( 1 ); +} + require_once $_bpi_tests_dir . '/includes/functions.php'; /** From eb5a296c4340a2e546a6f983441306dd9d280e15 Mon Sep 17 00:00:00 2001 From: Amaury Balmer Date: Thu, 10 Sep 2026 16:39:53 +0200 Subject: [PATCH 3/5] ci: run the PHP tests through wp-env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workflow was standing up its own MariaDB service and generating a wp-tests-config.php, duplicating what wp-env already provides — and it is where the first CI failure came from. wp-env supplies WordPress, the WordPress PHPUnit library and the database, so the workflow now runs `npm run test:php`, the same command as locally. A green run on a laptop and a green run on the pull request now mean the same thing. The PHP matrix is kept: wp-env takes the version from WP_ENV_PHP_VERSION and pulls the matching upstream wordpress:php8.x image. Documented in tests/README.md, since it is also how you reproduce a single matrix job locally. The hand-rolled path stays supported for anyone without Docker — the bootstrap still falls back to the Composer copy of WordPress and tests/wp-tests-config-sample.php is still there — it just is not what CI uses any more. On failure the job prints the wp-env test-environment logs, which is the first thing anyone would ask for. Co-Authored-By: Claude Opus 5 --- .github/workflows/test-php.yml | 107 ++++++++++----------------------- tests/README.md | 15 ++++- 2 files changed, 46 insertions(+), 76 deletions(-) diff --git a/.github/workflows/test-php.yml b/.github/workflows/test-php.yml index f2eceed..973337f 100644 --- a/.github/workflows/test-php.yml +++ b/.github/workflows/test-php.yml @@ -13,104 +13,63 @@ concurrency: cancel-in-progress: true jobs: - # The plugin calls register_block_type() on its build/ directory, so the block is - # not registered — and nothing renders — until the assets are compiled. build/ is - # git-ignored, so CI has to produce it. Built once and shared, rather than in each - # matrix job, to keep npm out of the critical path four times over. - build: - name: Build block assets - runs-on: ubuntu-latest - - steps: - - name: Checkout project - uses: actions/checkout@v4 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - - name: Install npm dependencies - run: npm ci - - - name: Build - run: npm run build - - - name: Upload build - uses: actions/upload-artifact@v4 - with: - name: block-build - path: build/ - retention-days: 1 - if-no-files-found: error - phpunit: name: PHPUnit (PHP ${{ matrix.php }}) runs-on: ubuntu-latest - needs: build strategy: fail-fast: false matrix: - # The full range the plugin declares support for. + # The full range the plugin declares support for. wp-env selects the PHP + # version through WP_ENV_PHP_VERSION, which picks the matching upstream + # `wordpress:php8.x` image. php: [ '8.1', '8.2', '8.3', '8.4' ] - services: - mysql: - image: mariadb:11.4 - env: - MARIADB_ROOT_PASSWORD: root - MARIADB_DATABASE: wordpress_test - ports: - - 3306:3306 - options: >- - --health-cmd="healthcheck.sh --connect --innodb_initialized" - --health-interval=10s - --health-timeout=5s - --health-retries=10 + env: + # Keep off 8888/8889 for no reason other than matching the documented local setup. + WP_ENV_PORT: 8890 + WP_ENV_TESTS_PORT: 8891 + WP_ENV_PHP_VERSION: ${{ matrix.php }} steps: - name: Checkout project uses: actions/checkout@v4 - - name: Download block assets - uses: actions/download-artifact@v4 + - name: Setup Node + uses: actions/setup-node@v4 with: - name: block-build - path: build/ + node-version: 22 + cache: npm - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - extensions: mbstring, intl, mysqli - coverage: none - tools: composer + - name: Install npm dependencies + run: npm ci + + # The plugin calls register_block_type() on its build/ directory. build/ is + # git-ignored, so without this the block never registers and every rendering + # test receives an empty string. + - name: Build block assets + run: npm run build - name: Setup Composer run: | composer config --global http-basic.composer.beapi.fr ${{ secrets.COMPOSER_USER }} ${{ secrets.COMPOSER_PASS }} composer config --global http-basic.packages.beapi.fr ${{ secrets.COMPOSER_USER }} ${{ secrets.COMPOSER_PASS }} - # Brings in WordPress core (roots/wordpress-no-content) and the WordPress - # PHPUnit library (wp-phpunit), so the suite needs only a database. + # Installed on the host: the plugin directory is mounted into the wp-env + # containers, so vendor/ is visible from inside them. The platform pin in + # composer.json keeps resolution identical across the matrix. - name: Install composer dependencies run: composer install --no-interaction --no-progress - - name: Write the test configuration - run: | - sed \ - -e "s/define( 'DB_PASSWORD', '' );/define( 'DB_PASSWORD', 'root' );/" \ - -e "s/define( 'DB_HOST', '127.0.0.1' );/define( 'DB_HOST', '127.0.0.1:3306' );/" \ - -e "s#dirname( __DIR__ )#'${{ github.workspace }}'#" \ - tests/wp-tests-config-sample.php > wp-tests-config.php - php -l wp-tests-config.php - - # Guards against the failure this workflow was fixed for: without build/, - # the block never registers and 23 tests fail with an empty render. - - name: Verify the block manifest is present - run: test -f build/block.json + # Supplies WordPress, the WordPress PHPUnit library and a database, so there + # is no wp-tests-config.php to hand-roll and the suite runs exactly the way + # it does locally. + - name: Start wp-env + run: npx wp-env start - name: Run PHPUnit - run: composer test + run: npm run test:php + + - name: Show wp-env logs on failure + if: failure() + run: npx wp-env logs tests --no-watch diff --git a/tests/README.md b/tests/README.md index c122314..8f0464c 100644 --- a/tests/README.md +++ b/tests/README.md @@ -25,8 +25,11 @@ WP_ENV_PORT=8890 WP_ENV_TESTS_PORT=8891 npx wp-env start npm run test:php ``` -Anywhere else — CI, or a local run against the Composer copy of WordPress — you -supply the database: +This is also what CI runs, so a green run locally means the same thing as a green +run on the pull request. + +Without Docker, you can run against the Composer copy of WordPress instead, and +supply the database yourself: ```bash cp tests/wp-tests-config-sample.php wp-tests-config.php @@ -41,6 +44,14 @@ npm run test:php -- --filter BlockRendererTest composer test -- --filter test_search ``` +To exercise a specific PHP version, set `WP_ENV_PHP_VERSION` — this is how the CI +matrix covers 8.1 through 8.4: + +```bash +WP_ENV_PHP_VERSION=8.1 npx wp-env start --update +npm run test:php +``` + > The suite **drops and recreates** the tables in the configured database. Never > point it at anything you want to keep. From 49de396534d0b27cff96d09104f00ff27f4d9a5d Mon Sep 17 00:00:00 2001 From: Amaury Balmer Date: Thu, 10 Sep 2026 21:55:14 +0200 Subject: [PATCH 4/5] fix(tests): run the suite as the host user, not a hard-coded www-data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 86 of 176 tests failed in CI on every PHP version, all with the same message: the fixture directory under wp-content could not be created. The runner script was doing `docker exec -u 33`. wp-env builds its containers around the *host* user — wp-content is owned by that UID and Apache runs as it — so 33 is only right by accident. On macOS it works because Docker Desktop's file sharing ignores ownership; on a Linux runner, where the host UID is 1001 and ownership is real, www-data cannot write there. The same mismatch was behind the PHPUnit result-cache permission warning. Resolving the UID at run time matches what wp-env actually configures, in both places. Verified locally on PHP 8.1, 8.2, 8.3 and 8.4 through wp-env: 176 tests, 427 assertions, green on each. Co-Authored-By: Claude Opus 5 --- tests/bin/phpunit.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/bin/phpunit.sh b/tests/bin/phpunit.sh index 703c870..fb0284c 100755 --- a/tests/bin/phpunit.sh +++ b/tests/bin/phpunit.sh @@ -36,6 +36,10 @@ fi CLI="${CONTAINER%-tests-wordpress-1}-tests-cli-1" -exec docker exec -u 33 \ +# wp-env builds its containers around the *host* user so that files created inside +# them stay writable outside, and it points Apache at that UID. Run as the same +# user rather than a hard-coded one: www-data (33) happens to work on a Mac but +# cannot write to wp-content on a CI runner, where the host UID is different. +exec docker exec -u "$( id -u )" \ -w /var/www/html/wp-content/plugins/blockparty-icons \ "$CLI" vendor/bin/phpunit "$@" From fc07015d41d0cebf075794508e8e9599467f8355 Mon Sep 17 00:00:00 2001 From: Amaury Balmer Date: Thu, 10 Sep 2026 22:08:49 +0200 Subject: [PATCH 5/5] docs(tests): say where WP_TESTS_DIR comes from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review flagged the runner as never setting WP_TESTS_DIR, and concluded the suite must be falling back to the Composer copy of WordPress and a root wp-tests-config.php. It is not: wp-env sets the variable on its containers and docker exec inherits it, so the bootstrap takes its managed-environment branch and uses wp-env's WordPress, test library and database. The reading was wrong but the confusion was fair — the script leaned on a container-level variable it never mentioned. Both ends of that coupling now say so. Co-Authored-By: Claude Opus 5 --- tests/bin/phpunit.sh | 8 ++++++++ tests/bootstrap.php | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/tests/bin/phpunit.sh b/tests/bin/phpunit.sh index fb0284c..8accc03 100755 --- a/tests/bin/phpunit.sh +++ b/tests/bin/phpunit.sh @@ -10,6 +10,14 @@ # wp-env's own `run` subcommand cannot be pointed at a specific environment, so we # resolve the container from the port it publishes and talk to it with docker exec. # +# Nothing below exports WP_TESTS_DIR, and it does not need to: wp-env sets it to +# /wordpress-phpunit on the service itself, and `docker exec` inherits the +# container's environment. That variable is what makes tests/bootstrap.php take its +# "managed environment" branch and use wp-env's WordPress, test library and +# database — rather than falling back to the Composer copy and a wp-tests-config.php +# in the project root. If you ever see the suite reaching for a root config while +# running through this script, that variable is what went missing. +# set -euo pipefail ROOT="$( cd "$( dirname "${BASH_SOURCE[0]}" )/../.." && pwd )" diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 7fb38b6..9013ab0 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -39,6 +39,13 @@ * wp-env, which brings its own configuration). */ function bpi_locate_wp_tests_dir(): array { + /* + * wp-env sets WP_TESTS_DIR=/wordpress-phpunit on its containers, so this is + * populated whenever the suite runs inside one — including through + * `docker exec`, which inherits the container's environment. Finding it here is + * what keeps wp-env's WordPress, test library and database in play instead of + * the Composer fallback below. + */ $from_env = getenv( 'WP_TESTS_DIR' ); if ( ! empty( $from_env ) ) { $explicit = rtrim( $from_env, '/\\' );