Repository: guzzle/psr7 (php)

Issue:
Integration Test Suite not actually executed in CI
https://github.com/guzzle/psr7/runs/6256679863?check_suite_focus=true

In step "Run tests" it is reported that:

```
PHPUnit 9.5.20 #StandWithUkraine

Warning:       Your XML configuration validates against a deprecated schema.
Suggestion:    Migrate your XML configuration using "--migrate-configuration"!

No tests executed!
```

Should this:

https://github.com/guzzle/psr7/blob/58a607de30248bdbb28655d5d46661de5efd727c/.github/workflows/integration.yml#L42

be `./vendor/bin/phpunit --testsuite "Guzzle PSR-7 Integration Test Suite"` instead?

Pull request description:
Fix integration tests to actually run
Closes #506.

Patch:
diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
index a4a2ea0..943a708 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -9,7 +9,7 @@ on:
 jobs:
   composer-normalize:
     name: Composer Normalize
-    runs-on: ubuntu-latest
+    runs-on: ubuntu-22.04
 
     steps:
       - name: Checkout code
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 15fed93..639b293 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -9,7 +9,7 @@ on:
 jobs:
   build-lowest-version:
     name: Build lowest version
-    runs-on: ubuntu-latest
+    runs-on: ubuntu-22.04
 
     steps:
       - name: Set up PHP
@@ -26,11 +26,11 @@ jobs:
         run: composer update --no-interaction --prefer-stable --prefer-lowest --no-progress
 
       - name: Run tests
-        run: make test
+        run: vendor/bin/phpunit --testsuite "Guzzle PSR-7 Unit Test Suite"
 
   build:
     name: Build
-    runs-on: ubuntu-latest
+    runs-on: ubuntu-22.04
     strategy:
       max-parallel: 10
       matrix:
@@ -47,12 +47,8 @@ jobs:
       - name: Checkout code
         uses: actions/checkout@v3
 
-      - name: Mimic PHP 8.0
-        run: composer config platform.php 8.0.999
-        if: matrix.php > 8
-
       - name: Install dependencies
         run: composer update --no-interaction --no-progress
 
       - name: Run tests
-        run: make test
+        run: vendor/bin/phpunit --testsuite "Guzzle PSR-7 Unit Test Suite"
diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml
index 6003e89..5e019e4 100644
--- a/.github/workflows/integration.yml
+++ b/.github/workflows/integration.yml
@@ -10,7 +10,7 @@ jobs:
 
   build:
     name: Test
-    runs-on: ubuntu-latest
+    runs-on: ubuntu-22.04
     strategy:
       max-parallel: 10
       matrix:
@@ -26,17 +26,18 @@ jobs:
       - name: Checkout code
         uses: actions/checkout@v3
 
-      - name: Mimic PHP 8.0
-        run: composer config platform.php 8.0.999
-        if: matrix.php > 8
-
       - name: Install dependencies
         run: composer update --no-interaction --no-progress
 
       - name: Start server
         run: php -S 127.0.0.1:10002 tests/Integration/server.php &
 
-      - name: Run tests
+      - name: Run Guzzle tests
+        env:
+          TEST_SERVER: 127.0.0.1:10002
+        run: vendor/bin/phpunit --testsuite "Guzzle PSR-7 Integration Test Suite"
+
+      - name: Run Interop tests
         env:
           TEST_SERVER: 127.0.0.1:10002
-        run: ./vendor/bin/phpunit --testsuite Integration
+        run: vendor/bin/phpunit --testsuite "PSR-17 Integration Test Suite"
diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml
index 8aea52b..109a943 100644
--- a/.github/workflows/static.yml
+++ b/.github/workflows/static.yml
@@ -9,7 +9,7 @@ on:
 jobs:
   phpstan:
     name: PHPStan
-    runs-on: ubuntu-latest
+    runs-on: ubuntu-22.04
 
     steps:
       - name: Checkout code
@@ -34,7 +34,7 @@ jobs:
 
   php-cs-fixer:
     name: PHP-CS-Fixer
-    runs-on: ubuntu-latest
+    runs-on: ubuntu-22.04
 
     steps:
       - name: Checkout code
@@ -59,7 +59,7 @@ jobs:
 
   psalm:
     name: Psalm
-    runs-on: ubuntu-latest
+    runs-on: ubuntu-22.04
 
     steps:
       - name: Checkout code
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index bc2f5a0..19ff34e 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -152,7 +152,7 @@ parameters:
 
 		-
 			message: "#^Argument of an invalid type array\\<int, string\\>\\|false supplied for foreach, only iterables are supported\\.$#"
-			count: 2
+			count: 1
 			path: src/Header.php
 
 		-
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index bbce3f5..7b4d055 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -14,7 +14,7 @@
         <testsuite name="Guzzle PSR-7 Integration Test Suite">
             <directory>tests/Integration</directory>
         </testsuite>
-        <testsuite name="PSR-7 Integration Test Suite">
+        <testsuite name="PSR-17 Integration Test Suite">
             <directory>./vendor/http-interop/http-factory-tests/test</directory>
         </testsuite>
     </testsuites>
diff --git a/src/Header.php b/src/Header.php
index b219b87..10744ed 100644
--- a/src/Header.php
+++ b/src/Header.php
@@ -19,20 +19,22 @@ final class Header
         static $trimmed = "\"'  \n\t\r";
         $params = $matches = [];
 
-        foreach (self::normalize($header) as $val) {
-            $part = [];
-            foreach (preg_split('/;(?=([^"]*"[^"]*")*[^"]*$)/', $val) as $kvp) {
-                if (preg_match_all('/<[^>]+>|[^=]+/', $kvp, $matches)) {
-                    $m = $matches[0];
-                    if (isset($m[1])) {
-                        $part[trim($m[0], $trimmed)] = trim($m[1], $trimmed);
-                    } else {
-                        $part[] = trim($m[0], $trimmed);
+        foreach ((array) $header as $value) {
+            foreach (self::splitList($value) as $val) {
+                $part = [];
+                foreach (preg_split('/;(?=([^"]*"[^"]*")*[^"]*$)/', $val) as $kvp) {
+                    if (preg_match_all('/<[^>]+>|[^=]+/', $kvp, $matches)) {
+                        $m = $matches[0];
+                        if (isset($m[1])) {
+                            $part[trim($m[0], $trimmed)] = trim($m[1], $trimmed);
+                        } else {
+                            $part[] = trim($m[0], $trimmed);
+                        }
                     }
                 }
-            }
-            if ($part) {
-                $params[] = $part;
+                if ($part) {
+                    $params[] = $part;
+                }
             }
         }
 
@@ -44,25 +46,86 @@ final class Header
      * headers into an array of headers with no comma separated values.
      *
      * @param string|array $header Header to normalize.
+     *
+     * @deprecated Use self::splitList() instead.
      */
     public static function normalize($header): array
     {
         $result = [];
         foreach ((array) $header as $value) {
-            foreach ((array) $value as $v) {
-                if (strpos($v, ',') === false) {
-                    $trimmed = trim($v);
-                    if ($trimmed !== '') {
-                        $result[] = $trimmed;
-                    }
+            foreach (self::splitList($value) as $parsed) {
+                $result[] = $parsed;
+            }
+        }
+
+        return $result;
+    }
+
+    /**
+     * Splits a HTTP header defined to contain comma-separated list into
+     * each individual value. Empty values will be removed.
+     *
+     * Example headers include 'accept', 'cache-control' and 'if-none-match'.
+     *
+     * This method must not be used to parse headers that are not defined as
+     * a list, such as 'user-agent' or 'set-cookie'.
+     *
+     * @param string|string[] $values Header value as returned by MessageInterface::getHeader()
+     *
+     * @return string[]
+     */
+    public static function splitList($values): array
+    {
+        if (!\is_array($values)) {
+            $values = [$values];
+        }
+
+        $result = [];
+        foreach ($values as $value) {
+            if (!\is_string($value)) {
+                throw new \TypeError('$header must either be a string or an array containing strings.');
+            }
+
+            $v = '';
+            $isQuoted = false;
+            $isEscaped = false;
+            for ($i = 0, $max = \strlen($value); $i < $max; $i++) {
+                if ($isEscaped) {
+                    $v .= $value[$i];
+                    $isEscaped = false;
+
                     continue;
                 }
-                foreach (preg_split('/,(?=([^"]*"([^"]|\\\\.)*")*[^"]*$)/', $v) as $vv) {
-                    $trimmed = trim($vv);
-                    if ($trimmed !== '') {
-                        $result[] = $trimmed;
+
+                if (!$isQuoted && $value[$i] === ',') {
+                    $v = \trim($v);
+                    if ($v !== '') {
+                        $result[] = $v;
                     }
+
+                    $v = '';
+                    continue;
+                }
+
+                if ($isQuoted && $value[$i] === '\\') {
+                    $isEscaped = true;
+                    $v .= $value[$i];
+
+                    continue;
                 }
+                if ($value[$i] === '"') {
+                    $isQuoted = !$isQuoted;
+                    $v .= $value[$i];
+
+                    continue;
+                }
+
+                $v .= $value[$i];
+            }
+
+            $v = \trim($v);
+            if ($v !== '') {
+                $result[] = $v;
             }
         }
 
diff --git a/src/MimeType.php b/src/MimeType.php
index 3bcb07a..0debbd1 100644
--- a/src/MimeType.php
+++ b/src/MimeType.php
@@ -32,6 +32,7 @@ final class MimeType
         'aep' => 'application/vnd.audiograph',
         'afm' => 'application/x-font-type1',
         'afp' => 'application/vnd.ibm.modcap',
+        'age' => 'application/vnd.age',
         'ahead' => 'application/vnd.ahead.space',
         'ai' => 'application/pdf',
         'aif' => 'audio/x-aiff',
@@ -60,6 +61,8 @@ final class MimeType
         'atomsvc' => 'application/atomsvc+xml',
         'atx' => 'application/vnd.antix.game-component',
         'au' => 'audio/x-au',
+        'avci' => 'image/avci',
+        'avcs' => 'image/avcs',
         'avi' => 'video/x-msvideo',
         'avif' => 'image/avif',
         'aw' => 'application/applixware',
@@ -154,6 +157,7 @@ final class MimeType
         'com' => 'application/x-msdownload',
         'conf' => 'text/plain',
         'cpio' => 'application/x-cpio',
+        'cpl' => 'application/cpl+xml',
         'cpp' => 'text/x-c',
         'cpt' => 'application/mac-compactpro',
         'crd' => 'application/x-mscardfile',
@@ -316,6 +320,7 @@ final class MimeType
         'gca' => 'application/x-gca-compressed',
         'gdl' => 'model/vnd.gdl',
         'gdoc' => 'application/vnd.google-apps.document',
+        'ged' => 'text/vnd.familysearch.gedcom',
         'geo' => 'application/vnd.dynageo',
         'geojson' => 'application/geo+json',
         'gex' => 'application/vnd.geometry-explorer',
@@ -576,6 +581,7 @@ final class MimeType
         'mpd' => 'application/dash+xml',
         'mpe' => 'video/mpeg',
         'mpeg' => 'video/mpeg',
+        'mpf' => 'application/media-policy-dataset+xml',
         'mpg' => 'video/mpeg',
         'mpg4' => 'video/mp4',
         'mpga' => 'audio/mpeg',
@@ -719,6 +725,7 @@ final class MimeType
         'pgm' => 'image/x-portable-graymap',
         'pgn' => 'application/x-chess-pgn',
         'pgp' => 'application/pgp',
+        'phar' => 'application/octet-stream',
         'php' => 'application/x-httpd-php',
         'php3' => 'application/x-httpd-php',
         'php4' => 'application/x-httpd-php',
@@ -753,7 +760,7 @@ final class MimeType
         'pptm' => 'application/vnd.ms-powerpoint.presentation.macroEnabled.12',
         'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
         'pqa' => 'application/vnd.palm',
-        'prc' => 'application/x-pilot',
+        'prc' => 'model/prc',
         'pre' => 'application/vnd.lotus-freelance',
         'prf' => 'application/pics-rules',
         'provx' => 'application/provenance+xml',
@@ -993,6 +1000,7 @@ final class MimeType
         'txd' => 'application/vnd.genomatix.tuxedo',
         'txf' => 'application/vnd.mobius.txf',
         'txt' => 'text/plain',
+        'u3d' => 'model/u3d',
         'u8dsn' => 'message/global-delivery-status',
         'u8hdr' => 'message/global-headers',
         'u8mdn' => 'message/global-disposition-notification',
@@ -1089,6 +1097,7 @@ final class MimeType
         'webp' => 'image/webp',
         'wg' => 'application/vnd.pmi.widget',
         'wgt' => 'application/widget',
+        'wif' => 'application/watcherinfo+xml',
         'wks' => 'application/vnd.ms-works',
         'wm' => 'video/x-ms-wm',
         'wma' => 'audio/x-ms-wma',


Test patch:
diff --git a/tests/HeaderTest.php b/tests/HeaderTest.php
index 5ff5411..cdbb845 100644
--- a/tests/HeaderTest.php
+++ b/tests/HeaderTest.php
@@ -149,4 +149,33 @@ class HeaderTest extends TestCase
         self::assertSame($result, Psr7\Header::normalize([$header]));
         self::assertSame($result, Psr7\Header::normalize($header));
     }
+
+    /**
+     * @dataProvider normalizeProvider
+     */
+    public function testSplitList($header, $result): void
+    {
+        self::assertSame($result, Psr7\Header::splitList($header));
+    }
+
+    public function testSplitListRejectsNestedArrays(): void
+    {
+        $this->expectException(\TypeError::class);
+
+        Psr7\Header::splitList([['foo']]);
+    }
+
+    public function testSplitListArrayContainingNonStrings(): void
+    {
+        $this->expectException(\TypeError::class);
+
+        Psr7\Header::splitList(['foo', 'bar', 1, false]);
+    }
+
+    public function testSplitListRejectsNonStrings(): void
+    {
+        $this->expectException(\TypeError::class);
+
+        Psr7\Header::splitList(false);
+    }
 }