From 2a28a065cc2fdf24d6f9710cde4dfa384f304e49 Mon Sep 17 00:00:00 2001 From: Kaspars Dambis Date: Sat, 16 May 2026 12:07:20 +0300 Subject: [PATCH 1/5] Normalize to arrays of artifact objects since spec supports both formats Signed-off-by: Kaspars Dambis --- inc/packages/class-releasedocument.php | 9 +++ .../tests/Packages/ReleaseDocumentTest.php | 78 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 tests/phpunit/tests/Packages/ReleaseDocumentTest.php diff --git a/inc/packages/class-releasedocument.php b/inc/packages/class-releasedocument.php index f4fd1058..3840de83 100644 --- a/inc/packages/class-releasedocument.php +++ b/inc/packages/class-releasedocument.php @@ -95,6 +95,15 @@ public static function from_data( stdClass $data ) { } } + // Normalize to arrays of artifact objects since spec supports both formats. + if ( $doc->artifacts instanceof stdClass ) { + foreach ( get_object_vars( $doc->artifacts ) as $type => $artifact ) { + if ( is_object( $artifact ) ) { + $doc->artifacts->{$type} = [ $artifact ]; + } + } + } + return $doc; } } diff --git a/tests/phpunit/tests/Packages/ReleaseDocumentTest.php b/tests/phpunit/tests/Packages/ReleaseDocumentTest.php new file mode 100644 index 00000000..85952b5a --- /dev/null +++ b/tests/phpunit/tests/Packages/ReleaseDocumentTest.php @@ -0,0 +1,78 @@ + 'https://example.com/plugin.zip', + ]; + $icon_artifact = (object) [ + 'url' => 'https://example.com/icon.png', + ]; + $custom_artifact = (object) [ + 'url' => 'https://example.com/extra.json', + ]; + + $release = ReleaseDocument::from_data( + (object) [ + 'version' => '1.2.3', + 'artifacts' => (object) [ + 'package' => $package_artifact, + 'icon' => $icon_artifact, + 'x-extra' => $custom_artifact, + ], + ] + ); + + $this->assertNotWPError( $release, 'Expected a valid release document.' ); + $this->assertIsArray( $release->artifacts->package, 'Package artifacts should be normalized to an array.' ); + $this->assertIsArray( $release->artifacts->icon, 'Icon artifacts should be normalized to an array.' ); + $this->assertIsArray( $release->artifacts->{'x-extra'}, 'Custom artifact types should be normalized to an array.' ); + $this->assertSame( $package_artifact, $release->artifacts->package[0], 'The original package artifact should be preserved.' ); + $this->assertSame( $icon_artifact, $release->artifacts->icon[0], 'The original icon artifact should be preserved.' ); + $this->assertSame( $custom_artifact, $release->artifacts->{'x-extra'}[0], 'The original custom artifact should be preserved.' ); + } + + /** + * Test should preserve artifact arrays. + */ + public function test_should_preserve_artifact_arrays() { + $package_artifact = (object) [ + 'url' => 'https://example.com/plugin.zip', + ]; + $banner_artifact = (object) [ + 'url' => 'https://example.com/banner.png', + ]; + + $release = ReleaseDocument::from_data( + (object) [ + 'version' => '1.2.3', + 'artifacts' => (object) [ + 'package' => [ $package_artifact ], + 'banner' => [ $banner_artifact ], + ], + ] + ); + + $this->assertNotWPError( $release, 'Expected a valid release document.' ); + $this->assertCount( 1, $release->artifacts->package, 'Package artifact arrays should be preserved.' ); + $this->assertCount( 1, $release->artifacts->banner, 'Banner artifact arrays should be preserved.' ); + $this->assertSame( $package_artifact, $release->artifacts->package[0], 'Existing package array entries should be preserved.' ); + $this->assertSame( $banner_artifact, $release->artifacts->banner[0], 'Existing banner array entries should be preserved.' ); + } +} From 7bff9131fc1649ed78bba741959f439c2e85fba4 Mon Sep 17 00:00:00 2001 From: Kaspars Dambis Date: Sat, 16 May 2026 12:09:36 +0300 Subject: [PATCH 2/5] Pick the best artifact for locale Signed-off-by: Kaspars Dambis --- inc/packages/namespace.php | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/inc/packages/namespace.php b/inc/packages/namespace.php index 28ee8886..14cc04a0 100644 --- a/inc/packages/namespace.php +++ b/inc/packages/namespace.php @@ -726,6 +726,7 @@ function get_package_data( $did ) { $type = str_replace( 'wp-', '', $metadata->type ); $sections = (array) $metadata->sections; $description = trim( $sections['description'] ?? '' ); + $package_artifact = isset( $release->artifacts->package ) ? pick_artifact_by_lang( $release->artifacts->package ) : null; $response = [ 'name' => $metadata->name, @@ -747,8 +748,8 @@ function get_package_data( $did ) { 'new_version' => $release->version, 'version' => $release->version, 'remote_version' => $release->version, - 'package' => $release->artifacts->package[0]->url, - 'download_link' => $release->artifacts->package[0]->url, + 'package' => $package_artifact->url ?? '', + 'download_link' => $package_artifact->url ?? '', 'tested' => $required_versions['tested_to'] ?? '', 'external' => 'xxx', 'last_updated' => $metadata->last_updated ?? '', @@ -791,10 +792,7 @@ function cache_did_for_install( array $options ): array { $did = array_find_key( $releases, function ( $release ) use ( $options ) { - if ( ! is_array( $release->artifacts->package ) ) { - return false; - } - $artifact = pick_artifact_by_lang( $release->artifacts->package ); + $artifact = isset( $release->artifacts->package ) ? pick_artifact_by_lang( $release->artifacts->package ) : null; return $artifact && $artifact->url === $options['package']; } ); @@ -964,8 +962,12 @@ function maybe_add_accept_header( $args, $url ) : array { } foreach ( $releases as $release ) { - if ( $url === $release->artifacts->package[0]->url ) { - $content_type = $release->artifacts->package[0]->{'content-type'}; + $artifact = array_find( + $release->artifacts->package ?? [], + fn ( $package_artifact ) => $url === ( $package_artifact->url ?? '' ) + ); + if ( $artifact ) { + $content_type = $artifact->{'content-type'} ?? ''; if ( $content_type === 'application/octet-stream' ) { $args = array_merge( $args, [ 'headers' => [ 'Accept' => $content_type ] ] ); break; From 85afc80cdcb58b843822e14f30577fcac1364ea1 Mon Sep 17 00:00:00 2001 From: Kaspars Dambis Date: Sat, 16 May 2026 12:22:51 +0300 Subject: [PATCH 3/5] Account for lang property being optional Signed-off-by: Kaspars Dambis --- inc/packages/namespace.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/inc/packages/namespace.php b/inc/packages/namespace.php index 14cc04a0..c513b9c4 100644 --- a/inc/packages/namespace.php +++ b/inc/packages/namespace.php @@ -460,9 +460,10 @@ function pick_artifact_by_lang( array $artifacts, ?string $locale = null ) { // Score artifacts based on match. $score_artifact = function ( $artifact ) use ( $langs ) { $score = 0; + $lang = strtolower( $artifact->lang ?? '' ); // Check for lang match. - $idx = array_search( strtolower( $artifact->lang ), $langs, true ); + $idx = array_search( $lang, $langs, true ); if ( $idx !== false ) { $score += ( count( $langs ) - $idx ) * 100; } From d5ca3801f00e34a10ae23f1008c0d1b0f1d9eeb8 Mon Sep 17 00:00:00 2001 From: Kaspars Dambis Date: Sat, 16 May 2026 12:26:40 +0300 Subject: [PATCH 4/5] Add unit tests for artifact picker Signed-off-by: Kaspars Dambis --- .../tests/Packages/PickArtifactByLangTest.php | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/phpunit/tests/Packages/PickArtifactByLangTest.php diff --git a/tests/phpunit/tests/Packages/PickArtifactByLangTest.php b/tests/phpunit/tests/Packages/PickArtifactByLangTest.php new file mode 100644 index 00000000..cd44e4ae --- /dev/null +++ b/tests/phpunit/tests/Packages/PickArtifactByLangTest.php @@ -0,0 +1,53 @@ + 'https://example.com/no-lang.zip', + ]; + $matching_artifact = (object) [ + 'url' => 'https://example.com/de-de.zip', + 'lang' => 'de-DE', + ]; + + $selected = pick_artifact_by_lang( [ $fallback_artifact, $matching_artifact ], 'de-DE' ); + + $this->assertSame( $matching_artifact, $selected, 'The exact locale match should be selected.' ); + } + + /** + * Test should not fail when artifacts do not specify lang. + */ + public function test_should_return_an_artifact_when_lang_is_missing() { + $first_artifact = (object) [ + 'url' => 'https://example.com/first.zip', + ]; + $second_artifact = (object) [ + 'url' => 'https://example.com/second.zip', + ]; + + $selected = pick_artifact_by_lang( [ $first_artifact, $second_artifact ], 'de-DE' ); + + $this->assertContains( $selected, [ $first_artifact, $second_artifact ], 'Artifacts without lang should still return a valid artifact.' ); + } +} From 0e3a367b0eb615bfd5b27842195c3ee143f6c624 Mon Sep 17 00:00:00 2001 From: Kaspars Dambis Date: Sat, 16 May 2026 12:26:59 +0300 Subject: [PATCH 5/5] Describe the current state Signed-off-by: Kaspars Dambis --- inc/packages/namespace.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/inc/packages/namespace.php b/inc/packages/namespace.php index c513b9c4..b3a83a53 100644 --- a/inc/packages/namespace.php +++ b/inc/packages/namespace.php @@ -470,6 +470,9 @@ function pick_artifact_by_lang( array $artifacts, ?string $locale = null ) { return $score; }; + + // Minimal behavior only: missing lang is tolerated, but equal-score ordering + // and partial-locale fallback precedence are intentionally unspecified. usort( $artifacts, function ( $a, $b ) use ( $score_artifact ) { $a_score = $score_artifact( $a ); $b_score = $score_artifact( $b );