From aae9181cfc40f3bc8c1c3cf9d73a683dc53c55d1 Mon Sep 17 00:00:00 2001 From: sgillot Date: Thu, 10 Sep 2026 10:21:32 +0200 Subject: [PATCH 01/14] fix: resolve missing collection during icon-block migration Legacy blocks with an icon name but no collection were skipped entirely. Look up the name in registered collections, then fall back to mediatheque or icon-pack, and bump to 1.1.2. Co-authored-by: Cursor --- .plugin-data | 2 +- CHANGELOG.md | 4 ++ MIGRATION.md | 4 +- blockparty-icons.php | 4 +- includes/Migration/IconBlockMigrator.php | 63 ++++++++++++++++++++++-- package-lock.json | 4 +- package.json | 2 +- readme.txt | 5 +- src/block.json | 2 +- 9 files changed, 75 insertions(+), 15 deletions(-) diff --git a/.plugin-data b/.plugin-data index 91edf44..80a8f24 100644 --- a/.plugin-data +++ b/.plugin-data @@ -1,4 +1,4 @@ { - "version": "1.1.1", + "version": "1.1.2", "slug": "blockparty-icons" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 4756984..f4ba589 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ This plugin **doesn't run any sanitization on SVGs** before using them. Only use ## Changelog +### 1.1.2 - 2026-09-10 + +- Fix `beapi/icon-block` migration skipping icons that have a name but no collection (registry lookup then generic fallback) + ### 1.1.1 - 2026-08-26 - Fix deprecated warning for nullable string parameters in `CollectionItem` diff --git a/MIGRATION.md b/MIGRATION.md index 35b1bad..a96f1d0 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -56,7 +56,7 @@ You can also pass a `Collection` instance built with `Collection::from_sprite()` | Old (`icon-item` / legacy parent) | New (`blockparty/icon`) | |---|---| -| `icon` (`name`, `type`, `label`, optional `collection`) | `icon` (requires `collection` + `name`) | +| `icon` (`name`, `type`, `label`, optional `collection`) | `icon` (`collection` + `name`; collection is looked up / fallen back when missing) | | Parent `collection.name` (legacy object) | `icon.collection` | | `iconColorValue` / legacy `iconColor.color` | `iconColor` (value kept as-is, including `inherit`) | | `size` (int) | `size` (int) | @@ -98,7 +98,7 @@ The command: 4. Logs migrated / skipped counts 5. Lists missing icon assets (`collection/name`) so you can add them manually to theme assets / collections -When `collection` is missing on the icon object (and not on the parent attrs), the block is skipped — there is no default collection. Missing files among converted icons are reported, not skipped. +When `collection` is missing on the icon object (and not on the parent attrs) but `name` is present, the migrator looks up that name in registered collections (preferred order: `icon-pack`, `theme`, `mediatheque`, then the rest). If still unresolved, it falls back to `mediatheque` for `raw` icons and `icon-pack` otherwise (`sprite` is inferred from `` in the saved HTML when `type` is absent). The block is skipped only when `name` (or collection after these steps) is still missing. Missing files among converted icons are reported as CLI warnings, not skipped. ### Revisions diff --git a/blockparty-icons.php b/blockparty-icons.php index c294308..b2dab2c 100644 --- a/blockparty-icons.php +++ b/blockparty-icons.php @@ -4,7 +4,7 @@ * Description: Provides blocks in WordPress editor to add custom SVG icons. * Requires at least: 6.2 * Requires PHP: 8.1 - * Version: 1.1.1 + * Version: 1.1.2 * Author: Be API Technical Team * Author URI: https://beapi.fr * License: GPL-2.0-or-later @@ -30,7 +30,7 @@ include_once __DIR__ . '/vendor/autoload.php'; } -define( 'BLOCKPARTY_ICONS_VERSION', '1.1.1' ); +define( 'BLOCKPARTY_ICONS_VERSION', '1.1.2' ); define( 'BLOCKPARTY_ICONS_URL', plugin_dir_url( __FILE__ ) ); define( 'BLOCKPARTY_ICONS_DIR', plugin_dir_path( __FILE__ ) ); define( 'BLOCKPARTY_ICONS_PLUGIN_BASENAME', plugin_basename( __FILE__ ) ); diff --git a/includes/Migration/IconBlockMigrator.php b/includes/Migration/IconBlockMigrator.php index d0d19e6..9341014 100644 --- a/includes/Migration/IconBlockMigrator.php +++ b/includes/Migration/IconBlockMigrator.php @@ -202,7 +202,7 @@ private function convert_old_block( array $block ): array { * @param array $attrs Source attrs (item or legacy parent). * @param array $parent_attrs Parent attrs (for className / collection). * @param string $html Saved HTML of the old block. - * @return array|null Null when an icon was expected but name or collection is missing. + * @return array|null Null when an icon was expected but name or collection is still missing after lookup/fallback. * @author Jules Fell */ private function build_blockparty_block( array $attrs, array $parent_attrs, string $html ): ?array { @@ -222,14 +222,26 @@ private function build_blockparty_block( array $attrs, array $parent_attrs, stri // empty shells must become empty blockparty/icon blocks. $expects_icon = ( '' !== $name || $old_icon ); - // Collection from attrs only (no registry lookup, no project-specific default). + $type = (string) ( $old_icon['type'] ?? ( false !== strpos( $html, 'resolve_collection_for_icon( $name ); + } + + // Last resort: frequent BeAPI collection names (not project-specific). + if ( '' === $collection && '' !== $name ) { + $collection = 'raw' === $type ? 'mediatheque' : 'icon-pack'; + } + + // Abort only when an icon was expected but name or collection is still missing. if ( $expects_icon && ( '' === $name || '' === $collection ) ) { return null; } @@ -237,8 +249,6 @@ private function build_blockparty_block( array $attrs, array $parent_attrs, stri $new = []; if ( '' !== $name ) { - $type = (string) ( $old_icon['type'] ?? ( false !== strpos( $html, ' $collection, 'name' => $name, @@ -326,6 +336,49 @@ private function build_blockparty_block( array $attrs, array $parent_attrs, stri ]; } + /** + * Find the first registered collection that contains the given icon name. + * + * Preferred order: icon-pack, theme, mediatheque, then remaining collections. + * + * @param string $name Icon name. + * @return string Collection name, or empty string if none match. + */ + private function resolve_collection_for_icon( string $name ): string { + if ( ! function_exists( '\\Blockparty\\Icons\\get_icon_collections' ) ) { + return ''; + } + + $collections = \Blockparty\Icons\get_icon_collections(); + if ( empty( $collections ) ) { + return ''; + } + + $preferred = [ 'icon-pack', 'theme', 'mediatheque' ]; + $ordered = []; + + foreach ( $preferred as $preferred_name ) { + if ( isset( $collections[ $preferred_name ] ) ) { + $ordered[] = $collections[ $preferred_name ]; + } + } + + foreach ( $collections as $collection_name => $collection ) { + if ( in_array( $collection_name, $preferred, true ) ) { + continue; + } + $ordered[] = $collection; + } + + foreach ( $ordered as $collection ) { + if ( $collection->get( $name ) ) { + return $collection->name(); + } + } + + return ''; + } + /** * Record a migrated icon that is not available in registered collections. * diff --git a/package-lock.json b/package-lock.json index aa22b2a..12e4c07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "blockparty-icons", - "version": "1.1.1", + "version": "1.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "blockparty-icons", - "version": "1.1.1", + "version": "1.1.2", "license": "GPL-2.0-or-later", "dependencies": { "@beapi/icons": "^1.2.5", diff --git a/package.json b/package.json index 0c18322..1b47ddb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "blockparty-icons", - "version": "1.1.1", + "version": "1.1.2", "description": "Provides blocks in WordPress editor to add custom SVG icons from your theme.", "author": "Be API Technical team", "license": "GPL-2.0-or-later", diff --git a/readme.txt b/readme.txt index 3c5ef0a..9125819 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: block, icons, svg, gutenberg, editor Requires at least: 6.2 Tested up to: 6.8 Requires PHP: 8.1 -Stable tag: 1.1.1 +Stable tag: 1.1.2 License: GPL-2.0-or-later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -36,6 +36,9 @@ Yes. Register a collection with `type` set to `folder` and `source` pointing to == Changelog == += 1.1.2 = +* Resolve missing `icon.collection` during `beapi/icon-block` migration via registry lookup and a generic fallback. + = 1.1.1 = * Fix deprecated warning for nullable string parameters in `CollectionItem`. * Align GitHub workflows with other blockparty repos and add release version consistency checks. diff --git a/src/block.json b/src/block.json index ffd022b..d447f27 100644 --- a/src/block.json +++ b/src/block.json @@ -2,7 +2,7 @@ "$schema": "https://schemas.wp.org/trunk/block.json", "apiVersion": 3, "name": "blockparty/icon", - "version": "1.1.1", + "version": "1.1.2", "title": "Icon", "category": "widgets", "description": "Easily add custom vector icon to your content.", From 767840e7fb53cf3a9b52d09e6ee913db1b6daa5c Mon Sep 17 00:00:00 2001 From: Amaury Balmer Date: Thu, 10 Sep 2026 16:11:52 +0200 Subject: [PATCH 02/14] 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 03/14] 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 04/14] 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 05/14] 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 06/14] 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, '/\\' ); From 126c66fa37841e045b34ff7a26bfb0bd187bc7eb Mon Sep 17 00:00:00 2001 From: Amaury Balmer Date: Thu, 10 Sep 2026 15:21:29 +0200 Subject: [PATCH 07/14] test: add reproducible performance protocol for large icon sets Stands up an isolated WordPress instance (port 8899, so it runs alongside the normal dev env) holding 500 SVG icons: 200 in a theme folder and 300 contributed through the media library, sized from ~11 KB to ~1.95 MB with 25 above 1 MB. The rig exists to produce comparable before/after numbers rather than assertions. It reproduces the three things that make WordPress VIP hurt: - a persistent object cache that refuses items over 1 MB the way memcached does, silently returning false, which is exactly what no caller checks; - media-library reads routed through a bpifs:// stream wrapper, so they are counted exactly and can be charged VIP Files-like latency; - collections registered on every request, front end included. Metrics land as one JSON record per request and are reduced to medians. `resident` -- the SVG bytes actually held in memory -- is read by reflecting on CollectionItem's private properties rather than through content(), so the measurement stays honest once loading becomes lazy. The object cache is file-backed rather than memcached: it reproduces the semantics exactly but not the latency, so operation counts are the transferable metric and wall time only indicative. That and the other limits are written up in tests/perf/README.md. phpcs now excludes ./tests/: grumphp passes changed files to phpcs explicitly, which overrides the ruleset's file list, and the harness has to break WordPress conventions to do its job -- a cache drop-in must assign $GLOBALS['wp_object_cache'] and declare both a class and the wp_cache_* functions in one file. Co-Authored-By: Claude Opus 5 --- .gitignore | 4 + package.json | 7 +- phpcs.xml.dist | 7 + tests/perf/README.md | 261 ++++++++++ tests/perf/bin/_env.sh | 66 +++ tests/perf/bin/bench.sh | 128 +++++ tests/perf/bin/generate-fixtures.mjs | 221 ++++++++ tests/perf/bin/report.mjs | 224 ++++++++ tests/perf/bin/setup.sh | 171 +++++++ tests/perf/bin/teardown.sh | 20 + tests/perf/drop-ins/object-cache.php | 477 ++++++++++++++++++ tests/perf/env/.wp-env.json | 20 + tests/perf/mu-plugins/bpi-perf-auth.php | 70 +++ tests/perf/mu-plugins/bpi-perf-probe.php | 371 ++++++++++++++ .../perf/theme/blockparty-perf/functions.php | 133 +++++ tests/perf/theme/blockparty-perf/index.php | 24 + tests/perf/theme/blockparty-perf/style.css | 6 + 17 files changed, 2209 insertions(+), 1 deletion(-) create mode 100644 tests/perf/README.md create mode 100644 tests/perf/bin/_env.sh create mode 100755 tests/perf/bin/bench.sh create mode 100755 tests/perf/bin/generate-fixtures.mjs create mode 100755 tests/perf/bin/report.mjs create mode 100755 tests/perf/bin/setup.sh create mode 100755 tests/perf/bin/teardown.sh create mode 100644 tests/perf/drop-ins/object-cache.php create mode 100644 tests/perf/env/.wp-env.json create mode 100644 tests/perf/mu-plugins/bpi-perf-auth.php create mode 100644 tests/perf/mu-plugins/bpi-perf-probe.php create mode 100644 tests/perf/theme/blockparty-perf/functions.php create mode 100644 tests/perf/theme/blockparty-perf/index.php create mode 100644 tests/perf/theme/blockparty-perf/style.css diff --git a/.gitignore b/.gitignore index 6bd3701..5309cd3 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,7 @@ phpcs.xml /wp-tests-config.php /phpunit.xml /.phpunit.result.cache + +# Perf protocol: generated fixtures (~100 MB) and captured results +tests/perf/fixtures/ +tests/perf/results/ diff --git a/package.json b/package.json index bd0052c..4e64d31 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,12 @@ "make-json": "wp i18n make-json languages/blockparty-icons-fr_FR.po languages/ --no-purge", "env:start": "wp-env start --config=./.wp-env.json", "env:stop": "wp-env stop --config=./.wp-env.json", - "test:php": "bash tests/bin/phpunit.sh" + "test:php": "bash tests/bin/phpunit.sh", + "perf:setup": "bash tests/perf/bin/setup.sh", + "perf:bench": "bash tests/perf/bin/bench.sh", + "perf:report": "node tests/perf/bin/report.mjs", + "perf:fixtures": "node tests/perf/bin/generate-fixtures.mjs", + "perf:teardown": "bash tests/perf/bin/teardown.sh" }, "devDependencies": { "@wordpress/env": "^10.39.0", diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 1c41647..607c551 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -10,6 +10,13 @@ ./includes/ ./build/ + + ./tests/ ./node_modules/ ./src/ ./tools/ diff --git a/tests/perf/README.md b/tests/perf/README.md new file mode 100644 index 0000000..e363a84 --- /dev/null +++ b/tests/perf/README.md @@ -0,0 +1,261 @@ +# Blockparty Icons — performance protocol + +A reproducible local rig for the WordPress VIP performance problem: a site with +500+ SVG icons, some larger than 1 MB, where the plugin loads every icon's content +into memory on every request — front end included, whether or not any icon is used. + +The rig exists to produce **comparable before/after numbers**, so that a fix can be +shown to work rather than asserted to. + +--- + +## What it builds + +An isolated WordPress instance on (the normal dev env on +8888 is untouched and can run at the same time): + +| | | +|---|---| +| `perf-theme` | 200 SVG icons in a theme folder, registered with `type => folder` | +| `perf-media` | 300 SVG icons contributed through the back office (media library) | +| icon sizes | ~11 KB → ~1.95 MB, following a fixed ladder; **25 icons exceed 1 MB** | +| total payload | ~102 MB of SVG | +| object cache | persistent, and refuses any item over 1 MB — like VIP's memcached | +| pages | `/perf-empty/` (no icon), `/perf-single/` (1 icon), `/perf-many/` (20 icons) | + +The two collections deliberately mirror the VIP topology: theme files are local, +media-library files are remote. `get_attached_file()` is routed through a `bpifs://` +stream wrapper that counts every open and can charge artificial latency. + +--- + +## Prerequisites + +- Docker running +- Node 22 (`volta` pins it) +- `composer` on the PATH — **the plugin resolves its classes through Composer's + PSR-4 autoloader**, so without `vendor/` nothing loads + +## Usage + +```bash +# One-off: generate fixtures, boot the env, import 300 icons into the media library. +# Takes several minutes, mostly the media import. +npm run perf:setup + +# Capture a run. --label names the result set. +npm run perf:bench -- --label=baseline + +# After changing the plugin, capture again and diff against the baseline. +npm run perf:bench -- --label=after +node tests/perf/bin/report.mjs --label=after --compare=baseline + +# Tear down (removes containers and the database, keeps the fixtures). +npm run perf:teardown +``` + +Useful flags: + +| flag | effect | +|---|---| +| `--scale=small` (setup) | 50 icons instead of 500, for quick iteration on the rig itself | +| `--skip-fixtures` (setup) | reuse the SVGs already generated | +| `--runs=N` (bench) | samples per scenario, default 5; the first is dropped as warm-up | +| `--latency-us=2000` (bench) | charge 2 ms per media-library file open, emulating VIP Files | + +--- + +## Metrics + +The probe (`mu-plugins/bpi-perf-probe.php`) writes one JSON record per request. +`report.mjs` reduces each scenario to a **median** so one container hiccup cannot +move a number. + +| column | meaning | +|---|---| +| `icons` | icons held in registered collections | +| `response KB` | bytes on the wire | +| `resident` | **MB of SVG content held in memory.** The direct measure of the problem | +| `peak mem` | `memory_get_peak_usage(true)` | +| `media reads` | media-library file opens, exact, via the `bpifs://` wrapper | +| `cache get` / `cache miss` | object-cache operations in the `blockparty-icons` group | +| `set >1MB refused` | cache writes memcached would reject. **These never warm up** | +| `init hook` | wall time inside `blockparty_icons_init` | +| `wall` | total request time | + +`resident` is measured by reflecting on `CollectionItem`'s private properties, not +by calling `content()`. Calling the getter would force lazily-loaded icons to +resolve and destroy the very thing being measured — the number stays honest across +the refactor. + +--- + +## Faithfulness, and where it stops + +Deliberate choices worth knowing before trusting a number: + +- **The object cache is file-backed, not memcached.** It reproduces the *semantics* + exactly — persistence across requests, a 1 MB item ceiling, a silent `false` from + `wp_cache_set()` — but not the latency. Local file I/O is far cheaper than a + network round trip. **Treat operation counts as the transferable metric and wall + time as indicative only.** To model VIP, multiply `cache get` by a memcached RTT. +- **Theme-folder file reads are not counted directly.** `glob()` bypasses PHP stream + wrappers, so only media-library reads get exact counts. Folder reads are inferred + from cache misses: a miss on `from_folder:*` means all 200 files were re-read. + `resident` covers what actually ended up in memory either way. +- **Latency emulation is off by default** (`--latency-us=0`) so the baseline states + only what was really measured. Turn it on for a VIP-shaped picture. +- **PHP memory limit is raised to 512 MB**, matching VIP's web limit. At PHP's + common 128 MB default this dataset does not merely run slowly — see below. + +--- + +## Baseline (2026-09-10, 500 icons, latency 0, 4 runs) + +### At a 128 MB PHP memory limit, the plugin does not run at all + +Activating the plugin against this dataset fatals outright: + +``` +PHP Fatal error: Allowed memory size of 134217728 bytes exhausted +(tried to allocate 31887360 bytes) in .../object-cache.php on line 216 +``` + +The allocation that fails is `serialize()` on the 200-icon folder collection. The +collection is built in full (~40 MB), then serialized for the cache (~32 MB more). +Everything below therefore runs at 512 MB. + +### Measured + +| scenario | icons | response KB | resident MB | peak MB | media reads | cache get | cache miss | >1MB refused | init ms | wall ms | +|---|---|---|---|---|---|---|---|---|---|---| +| front-empty-warm | 500 | 20 | 101.7 | 154 | 15 | 301 | 16 | 16 | 602 | 640 | +| front-empty-cold | 500 | 20 | 101.7 | 154 | 300 | 301 | 301 | 16 | 2557 | 2658 | +| front-single-warm | 500 | 39 | 101.7 | 154 | 15 | 301 | 16 | 16 | 679 | 768 | +| front-single-cold | 500 | 39 | 101.7 | 154 | 300 | 301 | 301 | 16 | 860 | 934 | +| front-many-warm | 500 | 3513 | 101.7 | 154 | 15 | 301 | 16 | 16 | 305 | 363 | +| rest-collections | 500 | <1 | 101.7 | 173 | 15 | 301 | 16 | 16 | 539 | 601 | +| rest-theme-p1 | 500 | 12065 | 101.7 | 154 | 15 | 301 | 16 | 16 | 568 | 729 | +| rest-media-p1 | 500 | 11805 | 101.7 | 171 | 15 | 301 | 16 | 16 | 230 | 284 | +| rest-theme-search | 500 | 8818 | 101.7 | 154 | 15 | 301 | 16 | 16 | 459 | 558 | +| editor-new-page | 500 | **24530** | 101.7 | 239 | 15 | 301 | 16 | 16 | 506 | 1201 | + +### What the numbers say + +1. **`resident` is 101.7 MB on every single row**, including `front-empty-*`, a page + with no icon block at all. The entire 102 MB icon corpus is loaded to render a + paragraph. It never varies, because nothing about the request influences it. + +2. **16 cache entries are permanently refused** for exceeding 1 MB: + - `from_folder:.../theme-icons` — the whole 200-icon collection as one entry + - 15 × `from_file:...` — each individual media icon above 1 MB + + These are not slow-to-warm; they *never* warm. `wp_cache_set()` returns `false`, + no caller checks it, and the work is redone on every request forever. The + `from_folder` rejection alone means all 200 theme SVGs are re-read from disk on + every request. + +3. **301 object-cache gets per request**, 300 of them from the media-library + collection's per-file cache keys. On VIP each is a memcached round trip; at a + conservative 0.3 ms that is ~90 ms of pure latency before any rendering. + +4. **A cold cache costs 300 media reads and 61.8 MB** of remote reads. With + `--latency-us=2000` that alone adds ~600 ms. + +5. **The editor bootstraps 24.5 MB of HTML** because + `block_editor_rest_api_preload_paths` inlines the first 50 icons *with content* + for each collection. A single REST page is 12 MB. + +These reproduce the three failure modes in the audit: eager whole-corpus loading +(point 1), cache entries too large to store (point 3), and the media-library +registration pattern (point 5). + +--- + +## After the fix + +Points 1, 3 and 5 applied: lazy payload loading, per-icon caching with a size +guard, and the `attachments` collection type. Same dataset, same 4 runs. + +| scenario | resident MB | peak MB | media reads | cache get | >1MB refused | init ms | wall ms | +|---|---|---|---|---|---|---|---| +| front-empty-warm | 0.015 (−100%) | 12 (−92%) | 0 (−100%) | 2 (−99%) | 0 | 2.3 (−100%) | 58 (−91%) | +| front-empty-cold | 0.015 (−100%) | 16 (−90%) | 0 (−100%) | 2 (−99%) | 0 | 329 (−87%) | 388 (−85%) | +| front-single-warm | 0.032 (−100%) | 10 (−94%) | 0 (−100%) | 2 (−99%) | 0 | 1.3 (−100%) | 32 (−96%) | +| front-many-warm | 3.42 (−97%) | 31 (−80%) | 0 (−100%) | 2 (−99%) | 0 | 1.5 (−99%) | 95 (−74%) | +| rest-theme-p1 | 11.70 (−88%) | 33 (−79%) | 0 (−100%) | 2 (−99%) | 0 | 0.5 (−100%) | 55 (−92%) | +| rest-media-p1 | 9.11 (−91%) | 27 (−84%) | 3 (−80%) | 2 (−99%) | 0 | 0.8 (−100%) | 67 (−77%) | +| editor-new-page | 20.80 (−80%) | 147 (−38%) | 3 (−80%) | 2 (−99%) | 0 | 81 (−84%) | 432 (−64%) | + +Reading the rows: + +- **`resident` collapses from a flat 101.7 MB to what the request actually uses** — + 15 KB for a page with no icon, 32 KB for a page with one, 11.7 MB for an editor + page that genuinely lists 50 icons. +- **`cache get` drops from 301 to 2**: one index per collection, instead of one + lookup per media attachment. +- **No cache entry is refused any more.** Indexes are small enough to store, and + payloads are cached individually — a 1.8 MB icon no longer poisons a collection. +- `response KB` is unchanged where the payload is genuinely wanted: the same icons + are still delivered, byte for byte. + +### With VIP-shaped latency (`--latency-us=2000`) + +Charging 2 ms per media-library file open, which is what a VIP Files fetch costs: + +| scenario | before | after | +|---|---|---| +| front-empty-warm | 618 ms | 25 ms (−96%) | +| front-empty-cold | **4806 ms** | 422 ms (−91%) | +| front-single-warm | 547 ms | 30 ms (−94%) | +| editor-new-page | 1057 ms | 556 ms (−47%) | + +### What is not addressed + +- **The editor still bootstraps ~22 MB.** `block_editor_rest_api_preload_paths` + inlines the first 50 icons of every collection, with content. Cutting that means + changing what the editor asks for — dropping `content` from the `view` context, or + having the picker fetch payloads only for icons it draws. That was point 4 of the + audit and is out of scope here. +- Rendering a page with 20 icons still resolves 20 payloads (3.4 MB). That is the + work the page actually requires. + +--- + +## Files + +``` +tests/perf/ + bin/ + _env.sh shared config; works around three wp-env quirks + generate-fixtures.mjs deterministic SVG generator (seeded, reproducible) + setup.sh boot the env, import media, create pages + bench.sh run the scenarios, capture metrics + report.mjs aggregate to medians, render the table, diff runs + teardown.sh destroy the env + drop-ins/object-cache.php instrumented cache with memcached's 1 MB ceiling + mu-plugins/ + bpi-perf-probe.php metrics + the bpifs:// remote-filesystem wrapper + bpi-perf-auth.php test-only auth shim for REST scenarios + theme/blockparty-perf/ fixture theme; registers both collections + env/.wp-env.json the isolated environment definition + fixtures/ generated SVGs (git-ignored, ~102 MB) + results/ captured runs (git-ignored) +``` + +### wp-env quirks worked around + +`@wordpress/env` 10.39: + +1. has **no `--config` flag** on any subcommand — it reads `.wp-env.json` from the + working directory, which is why the perf env lives in `tests/perf/env/`; +2. ignores the `port` key, templating `${WP_ENV_PORT:-8888}` into docker-compose + instead — so the ports are set through environment variables; +3. gives `wp-env run` no way to target a non-default env — so the harness talks to + the containers with `docker exec`, resolving them by published port. + +### Safety + +`bpi-perf-auth.php` authenticates any request presenting the `BPI_PERF_TOKEN` +constant's value. It is inert unless that constant is defined, and it is defined +only in `tests/perf/env/.wp-env.json`. It must never be copied into a real site. diff --git a/tests/perf/bin/_env.sh b/tests/perf/bin/_env.sh new file mode 100644 index 0000000..3776501 --- /dev/null +++ b/tests/perf/bin/_env.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# +# Shared settings for the perf harness. Sourced by setup.sh and bench.sh. +# +# Three wp-env facts shape this file (@wordpress/env 10.39): +# +# 1. There is no --config flag. wp-env always reads .wp-env.json from the current +# working directory. The perf environment therefore lives in its own directory, +# tests/perf/env/, with its own .wp-env.json. That also gives it its own project +# hash, so its containers and database are fully isolated from the dev env. +# +# 2. Ports come from WP_ENV_PORT / WP_ENV_TESTS_PORT, not from the config file. +# We pin them so the perf instance can run alongside the dev env on 8888/8889. +# +# 3. `wp-env run` takes no config either, so we address the containers directly +# with docker exec, resolving them by the port they publish. + +export WP_ENV_PORT="${WP_ENV_PORT:-8899}" +export WP_ENV_TESTS_PORT="${WP_ENV_TESTS_PORT:-8898}" + +PERF_ROOT="$( cd "$( dirname "${BASH_SOURCE[0]}" )/../../.." && pwd )" +PERF_ENV_DIR="${PERF_ROOT}/tests/perf/env" +PERF_BASE="http://localhost:${WP_ENV_PORT}" +PERF_TOKEN="local-perf-harness-only" + +export PERF_ROOT PERF_ENV_DIR PERF_BASE PERF_TOKEN + +say() { + printf '\n\033[1m==> %s\033[0m\n' "$1" +} + +# Run a wp-env subcommand against the perf environment. +perf_wp_env() { + ( cd "$PERF_ENV_DIR" && npx --prefix "$PERF_ROOT" wp-env "$@" ) +} + +# Resolve the perf environment's container names from the published port. +perf_resolve_containers() { + local wp + wp="$( docker ps \ + --filter "publish=${WP_ENV_PORT}" \ + --filter "name=-wordpress-1" \ + --format '{{.Names}}' | head -n1 )" + + if [ -z "$wp" ]; then + echo "No running wp-env WordPress container publishing port ${WP_ENV_PORT}." >&2 + echo "Start it with: tests/perf/bin/setup.sh" >&2 + return 1 + fi + + PERF_WP_CONTAINER="$wp" + PERF_PROJECT="${wp%-wordpress-1}" + PERF_CLI_CONTAINER="${PERF_PROJECT}-cli-1" + + export PERF_WP_CONTAINER PERF_PROJECT PERF_CLI_CONTAINER +} + +# Run a command inside the CLI container, as the web user, at the WordPress root. +incli() { + docker exec -u 33 -w /var/www/html "$PERF_CLI_CONTAINER" "$@" +} + +# Run WP-CLI inside the perf environment. +wpcli() { + incli wp "$@" +} diff --git a/tests/perf/bin/bench.sh b/tests/perf/bin/bench.sh new file mode 100755 index 0000000..e857a36 --- /dev/null +++ b/tests/perf/bin/bench.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# +# Run the Blockparty Icons perf scenarios and write a comparable result set. +# +# tests/perf/bin/bench.sh --label=baseline [--runs=5] [--latency-us=0] +# +# --label names the result file, so before/after runs can be diffed +# --runs samples per scenario (the first is discarded as a warm-up) +# --latency-us artificial per-file latency for media-library reads, emulating VIP Files +# +set -euo pipefail + +# shellcheck source=tests/perf/bin/_env.sh +source "$( dirname "${BASH_SOURCE[0]}" )/_env.sh" + +BASE="$PERF_BASE" +TOKEN="$PERF_TOKEN" + +LABEL="" +RUNS=5 +LATENCY=0 + +for arg in "$@"; do + case "$arg" in + --label=*) LABEL="${arg#*=}" ;; + --runs=*) RUNS="${arg#*=}" ;; + --latency-us=*) LATENCY="${arg#*=}" ;; + *) echo "unknown argument: $arg" >&2; exit 1 ;; + esac +done + +if [ -z "$LABEL" ]; then + echo "--label is required (e.g. --label=baseline)" >&2 + exit 1 +fi + +cd "$PERF_ROOT" +mkdir -p tests/perf/results + +perf_resolve_containers + +LOG_HOST="tests/perf/results/${LABEL}.log" +SIZES_FILE="tests/perf/results/${LABEL}.sizes" +: > "$SIZES_FILE" + +# wp-admin is gated by auth_redirect(), which validates the *auth*-scheme cookie, +# while REST and the rest of WordPress read the logged_in one. Mint both. +say "Minting an admin session for the editor scenario" +ADMIN_ID="$( wpcli user list --role=administrator --field=ID --number=1 | tr -d '\r' )" +LOGIN_COOKIE_NAME="$( wpcli eval 'echo LOGGED_IN_COOKIE;' | tr -d '\r' )" +AUTH_COOKIE_NAME="$( wpcli eval 'echo AUTH_COOKIE;' | tr -d '\r' )" +LOGIN_COOKIE_VALUE="$( wpcli eval "echo wp_generate_auth_cookie( ${ADMIN_ID}, time() + 7200, 'logged_in' );" | tr -d '\r' )" +AUTH_COOKIE_VALUE="$( wpcli eval "echo wp_generate_auth_cookie( ${ADMIN_ID}, time() + 7200, 'auth' );" | tr -d '\r' )" + +if [ -z "$LOGIN_COOKIE_VALUE" ] || [ -z "$AUTH_COOKIE_VALUE" ]; then + echo "Could not mint auth cookies; the editor scenario will redirect." >&2 +else + echo " admin user ${ADMIN_ID}, cookies minted" +fi + +say "Resetting perf log" +incli rm -f /var/www/html/wp-content/bpi-perf.log >/dev/null 2>&1 || true + +# hit URL, scenario-name, cold|warm, [auth] +hit() { + local url="$1" name="$2" cache="$3" auth="${4:-noauth}" + local sep="?" + case "$url" in *\?*) sep="&" ;; esac + + local full="${BASE}${url}${sep}bpi_perf_label=${name}&bpi_perf_latency_us=${LATENCY}" + local -a curl_args=( -s -o /dev/null -w '%{http_code} %{size_download}' --max-time 600 ) + + if [ "$auth" = "auth" ]; then + curl_args+=( -H "X-BPI-Perf-Token: ${TOKEN}" ) + if [ -n "$LOGIN_COOKIE_VALUE" ]; then + curl_args+=( -b "${LOGIN_COOKIE_NAME}=${LOGIN_COOKIE_VALUE}" ) + curl_args+=( -b "${AUTH_COOKIE_NAME}=${AUTH_COOKIE_VALUE}" ) + fi + fi + + local last_bytes=0 + for i in $( seq 1 "$RUNS" ); do + if [ "$cache" = "cold" ]; then + wpcli cache flush >/dev/null 2>&1 || true + fi + local out code bytes + out="$( curl "${curl_args[@]}" "$full" )" + code="${out%% *}" + bytes="${out##* }" + last_bytes="$bytes" + if [ "$code" != "200" ]; then + printf ' %s run %s -> HTTP %s\n' "$name" "$i" "$code" >&2 + fi + done + + printf '%s\t%s\n' "$name" "$last_bytes" >> "$SIZES_FILE" + printf ' %-28s %s cache, %s runs, %s KB response\n' \ + "$name" "$cache" "$RUNS" "$(( last_bytes / 1024 ))" +} + +say "Running scenarios (latency=${LATENCY}us per media read)" + +# --- front end ------------------------------------------------------------- +hit "/perf-empty/" "front-empty-warm" warm +hit "/perf-empty/" "front-empty-cold" cold +hit "/perf-single/" "front-single-warm" warm +hit "/perf-single/" "front-single-cold" cold +hit "/perf-many/" "front-many-warm" warm + +# --- REST (editor data path) ---------------------------------------------- +hit "/wp-json/icons/v1/collections?context=edit" "rest-collections" warm auth +hit "/wp-json/icons/v1/perf-theme?context=edit&per_page=50" "rest-theme-p1" warm auth +hit "/wp-json/icons/v1/perf-media?context=edit&per_page=50" "rest-media-p1" warm auth +hit "/wp-json/icons/v1/perf-media?context=edit&per_page=50&page=4" "rest-media-p4" warm auth +hit "/wp-json/icons/v1/perf-theme?context=edit&search=icon-1" "rest-theme-search" warm auth + +# --- editor ---------------------------------------------------------------- +hit "/wp-admin/post-new.php?post_type=page" "editor-new-page" warm auth + +say "Collecting results" +incli cat /var/www/html/wp-content/bpi-perf.log > "$LOG_HOST" 2>/dev/null || true + +if [ ! -s "$LOG_HOST" ]; then + echo "Perf log is empty — is the probe mu-plugin loaded?" >&2 + exit 1 +fi + +node tests/perf/bin/report.mjs --label="$LABEL" diff --git a/tests/perf/bin/generate-fixtures.mjs b/tests/perf/bin/generate-fixtures.mjs new file mode 100755 index 0000000..9f4e722 --- /dev/null +++ b/tests/perf/bin/generate-fixtures.mjs @@ -0,0 +1,221 @@ +#!/usr/bin/env node +/** + * Generate deterministic SVG fixtures for the Blockparty Icons perf protocol. + * + * Two sets are produced: + * - theme-icons/ consumed by the fixture theme as a `folder` collection (local FS, like a VIP theme) + * - media-icons/ imported into the media library (remote FS on VIP, see the bpifs:// wrapper) + * + * Sizes follow a fixed ladder from ~10 KB to ~2 MB so that: + * - the aggregate of any collection blows past memcached's 1 MB per-item limit + * - a handful of *individual* icons also blow past it on their own + * + * Usage: + * node tests/perf/bin/generate-fixtures.mjs [--scale=full|small] [--out=DIR] + */ + +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname( fileURLToPath( import.meta.url ) ); +const DEFAULT_OUT = resolve( HERE, '..', 'fixtures' ); + +const KB = 1024; +const MB = 1024 * 1024; + +/** + * Size ladder. Each bucket is [share, minBytes, maxBytes]. + * `share` values are relative weights within a set. + */ +const LADDER = [ + { key: 'a', share: 0.55, min: 10 * KB, max: 50 * KB }, + { key: 'b', share: 0.28, min: 50 * KB, max: 250 * KB }, + { key: 'c', share: 0.12, min: 250 * KB, max: 1 * MB }, + { key: 'd', share: 0.05, min: 1 * MB, max: 2 * MB }, +]; + +const SCALES = { + full: { theme: 200, media: 300 }, + small: { theme: 20, media: 30 }, +}; + +/** Deterministic PRNG (mulberry32) so runs are byte-for-byte reproducible. */ +function prng( seed ) { + let a = seed >>> 0; + return () => { + a = ( a + 0x6d2b79f5 ) >>> 0; + let t = a; + t = Math.imul( t ^ ( t >>> 15 ), t | 1 ); + t ^= t + Math.imul( t ^ ( t >>> 7 ), t | 61 ); + return ( ( t ^ ( t >>> 14 ) ) >>> 0 ) / 4294967296; + }; +} + +/** Assign a target byte size to every icon index, deterministically. */ +function planSizes( count, rand ) { + const plan = []; + let assigned = 0; + + LADDER.forEach( ( bucket, i ) => { + // Last bucket soaks up the rounding remainder. + const n = + i === LADDER.length - 1 + ? count - assigned + : Math.round( count * bucket.share ); + assigned += n; + for ( let k = 0; k < n; k++ ) { + plan.push( { + bucket: bucket.key, + bytes: Math.floor( bucket.min + rand() * ( bucket.max - bucket.min ) ), + } ); + } + } ); + + // Interleave buckets so a paginated REST response (50 per page) sees a mix + // of sizes rather than 50 tiny icons on page 1. + const shuffled = []; + const stride = Math.ceil( Math.sqrt( plan.length ) ) || 1; + for ( let offset = 0; offset < stride; offset++ ) { + for ( let i = offset; i < plan.length; i += stride ) { + shuffled.push( plan[ i ] ); + } + } + + return shuffled; +} + +/** + * Build a syntactically valid SVG padded with real path data up to `targetBytes`. + * + * Padding is genuine `` geometry rather than a comment blob, so that + * strip_tags(), preg_match_all() and WP_HTML_Tag_Processor do representative work. + */ +function buildSvg( name, targetBytes, rand ) { + const head = + `` + + `${ name }` + + ``; + const tail = ``; + + const parts = [ head ]; + let size = Buffer.byteLength( head ) + Buffer.byteLength( tail ); + + // Each filler path is ~600-700 bytes of plausible curve data. + while ( size < targetBytes ) { + const coords = []; + const segments = 24; + let x = ( rand() * 24 ).toFixed( 2 ); + let y = ( rand() * 24 ).toFixed( 2 ); + coords.push( `M${ x } ${ y }` ); + for ( let s = 0; s < segments; s++ ) { + const c1x = ( rand() * 24 ).toFixed( 2 ); + const c1y = ( rand() * 24 ).toFixed( 2 ); + const c2x = ( rand() * 24 ).toFixed( 2 ); + const c2y = ( rand() * 24 ).toFixed( 2 ); + x = ( rand() * 24 ).toFixed( 2 ); + y = ( rand() * 24 ).toFixed( 2 ); + coords.push( `C${ c1x } ${ c1y } ${ c2x } ${ c2y } ${ x } ${ y }` ); + } + coords.push( 'Z' ); + + const opacity = ( 0.1 + rand() * 0.9 ).toFixed( 3 ); + const piece = ``; + + parts.push( piece ); + size += Buffer.byteLength( piece ); + } + + parts.push( tail ); + return parts.join( '' ); +} + +function generateSet( outDir, prefix, count, seed ) { + rmSync( outDir, { recursive: true, force: true } ); + mkdirSync( outDir, { recursive: true } ); + + const rand = prng( seed ); + const plan = planSizes( count, rand ); + const entries = []; + + plan.forEach( ( item, i ) => { + const index = String( i + 1 ).padStart( 3, '0' ); + const name = `${ prefix }-${ index }`; + const svg = buildSvg( name, item.bytes, rand ); + const bytes = Buffer.byteLength( svg ); + + writeFileSync( resolve( outDir, `${ name }.svg` ), svg ); + entries.push( { name, file: `${ name }.svg`, bucket: item.bucket, bytes } ); + } ); + + return entries; +} + +function summarise( entries ) { + const total = entries.reduce( ( acc, e ) => acc + e.bytes, 0 ); + const overLimit = entries.filter( ( e ) => e.bytes > MB ); + const byBucket = {}; + for ( const e of entries ) { + byBucket[ e.bucket ] = byBucket[ e.bucket ] || { count: 0, bytes: 0 }; + byBucket[ e.bucket ].count++; + byBucket[ e.bucket ].bytes += e.bytes; + } + return { + count: entries.length, + totalBytes: total, + totalMB: +( total / MB ).toFixed( 2 ), + minBytes: Math.min( ...entries.map( ( e ) => e.bytes ) ), + maxBytes: Math.max( ...entries.map( ( e ) => e.bytes ) ), + overOneMB: overLimit.length, + byBucket, + }; +} + +function main() { + const args = Object.fromEntries( + process.argv.slice( 2 ).map( ( a ) => { + const [ k, v ] = a.replace( /^--/, '' ).split( '=' ); + return [ k, v ?? true ]; + } ) + ); + + const scaleKey = args.scale === 'small' ? 'small' : 'full'; + const scale = SCALES[ scaleKey ]; + const out = args.out ? resolve( String( args.out ) ) : DEFAULT_OUT; + + mkdirSync( out, { recursive: true } ); + + const theme = generateSet( resolve( out, 'theme-icons' ), 'theme-icon', scale.theme, 1337 ); + const media = generateSet( resolve( out, 'media-icons' ), 'media-icon', scale.media, 4242 ); + + const manifest = { + generatedAt: new Date().toISOString(), + scale: scaleKey, + ladder: LADDER, + sets: { + theme: { dir: 'theme-icons', ...summarise( theme ), entries: theme }, + media: { dir: 'media-icons', ...summarise( media ), entries: media }, + }, + }; + + writeFileSync( + resolve( out, 'manifest.json' ), + JSON.stringify( manifest, null, '\t' ) + ); + + const t = manifest.sets.theme; + const m = manifest.sets.media; + const fmt = ( s ) => + `${ s.count } icons, ${ s.totalMB } MB total, ${ ( s.minBytes / KB ).toFixed( 0 ) } KB → ` + + `${ ( s.maxBytes / MB ).toFixed( 2 ) } MB, ${ s.overOneMB } over 1 MB`; + + process.stdout.write( + `scale : ${ scaleKey }\n` + + `theme set : ${ fmt( t ) }\n` + + `media set : ${ fmt( m ) }\n` + + `total : ${ ( ( t.totalBytes + m.totalBytes ) / MB ).toFixed( 2 ) } MB in ${ out }\n` + ); +} + +main(); diff --git a/tests/perf/bin/report.mjs b/tests/perf/bin/report.mjs new file mode 100755 index 0000000..c99436c --- /dev/null +++ b/tests/perf/bin/report.mjs @@ -0,0 +1,224 @@ +#!/usr/bin/env node +/** + * Aggregate a perf log into a comparable report. + * + * node tests/perf/bin/report.mjs --label=baseline + * node tests/perf/bin/report.mjs --label=after --compare=baseline + * + * Reads tests/perf/results/