From 304abc74fa9c55908217e617977bcdc2ed797138 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 09:54:40 +0000 Subject: [PATCH 1/2] chore: sync PHP client with Apify OpenAPI spec v2-2026-07-08T143931Z - Bump API_SPEC_VERSION to v2-2026-07-08T143931Z and client version to 0.2.0 - Align User-Agent OS token with reference clients via Platform::osToken (WIN* -> win32, CYGWIN* -> cygwin, else lowercase PHP_OS) - Add request-body compression for bodies >= 1024 bytes: brotli (Content-Encoding: br) when the PECL brotli extension is present, gzip (Content-Encoding: gzip) fallback - Add zlib to CI setup-php extensions --- .github/workflows/php-integration-tests.yml | 2 +- .github/workflows/php-publish.yml | 2 +- CHANGELOG.md | 10 +++ src/ApifyClient.php | 3 +- src/Internal/Compression.php | 77 +++++++++++++++++++ src/Internal/HttpClientCore.php | 43 +++++++++++ src/Internal/Platform.php | 45 +++++++++++ src/Version.php | 4 +- tests/Unit/BatchAddRequestsTest.php | 6 +- tests/Unit/CompressionTest.php | 84 +++++++++++++++++++++ tests/Unit/ConfigTest.php | 6 +- tests/Unit/HttpClientTest.php | 29 +++++++ tests/Unit/MockTransport.php | 25 ++++++ tests/Unit/PlatformTest.php | 49 ++++++++++++ 14 files changed, 376 insertions(+), 9 deletions(-) create mode 100644 src/Internal/Compression.php create mode 100644 src/Internal/Platform.php create mode 100644 tests/Unit/CompressionTest.php create mode 100644 tests/Unit/PlatformTest.php diff --git a/.github/workflows/php-integration-tests.yml b/.github/workflows/php-integration-tests.yml index 29b3d1a..301790c 100644 --- a/.github/workflows/php-integration-tests.yml +++ b/.github/workflows/php-integration-tests.yml @@ -35,7 +35,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: '8.1' - extensions: mbstring, json, curl + extensions: mbstring, json, curl, zlib coverage: none tools: composer:v2 diff --git a/.github/workflows/php-publish.yml b/.github/workflows/php-publish.yml index 66f7f3d..d6f16f9 100644 --- a/.github/workflows/php-publish.yml +++ b/.github/workflows/php-publish.yml @@ -45,7 +45,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: '8.1' - extensions: mbstring, json, curl + extensions: mbstring, json, curl, zlib coverage: none tools: composer:v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 558a0ed..0329e23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.2.0 + +- Synced to Apify OpenAPI spec `v2-2026-07-08T143931Z`. No public interface changes. +- Request bodies larger than 1024 bytes are now compressed before being sent, using brotli + (`Content-Encoding: br`) when the PECL `brotli` extension is available and gzip + (`Content-Encoding: gzip`) as a fallback. Matches the reference client's request compression. +- The `User-Agent` OS token now reports the short lowercase platform identifier (e.g. `linux`, + `darwin`, `win32`), matching the reference JS client's `os.platform()` token, instead of the + upper-cased `PHP_OS_FAMILY` value. + ## 0.1.1 - Synced to Apify OpenAPI spec `v2-2026-07-07T132551Z`. No public interface changes. diff --git a/src/ApifyClient.php b/src/ApifyClient.php index a0b468b..ecdead7 100644 --- a/src/ApifyClient.php +++ b/src/ApifyClient.php @@ -7,6 +7,7 @@ use Apify\Client\Http\GuzzleHttpClient; use Apify\Client\Http\HttpClientInterface; use Apify\Client\Internal\HttpClientCore; +use Apify\Client\Internal\Platform; use Apify\Client\Internal\RetryConfig; use Apify\Client\Model\ActorRun; use Apify\Client\Options\RequestQueueClientOptions; @@ -347,7 +348,7 @@ private static function defaultIsAtHome(): bool */ private static function buildUserAgent(?string $suffix, callable $isAtHomeFn): string { - $os = strtolower(PHP_OS_FAMILY); + $os = Platform::osToken(PHP_OS); $atHome = $isAtHomeFn() ? 'true' : 'false'; $ua = sprintf('ApifyClient/%s (%s; PHP/%s); isAtHome/%s', Version::CLIENT_VERSION, $os, PHP_VERSION, $atHome); if ($suffix !== null && $suffix !== '') { diff --git a/src/Internal/Compression.php b/src/Internal/Compression.php new file mode 100644 index 0000000..fbe627c --- /dev/null +++ b/src/Internal/Compression.php @@ -0,0 +1,77 @@ +retry->minDelayMillis; $maxAttempts = $this->retry->maxRetries + 1; $path = self::extractPath($url); @@ -110,6 +113,46 @@ public function call( throw $lastError ?? new TransportException('request failed with no attempts'); } + /** + * Compresses the request body when it is large enough to be worth it, returning the possibly + * replaced body together with the (possibly extended) header map. A caller that already set a + * {@code Content-Encoding} header is left untouched, so an explicitly-encoded body is never + * double-compressed. + * + * @param array $extraHeaders + * @return array{0: string|null, 1: array} + */ + private static function maybeCompressBody(?string $body, array $extraHeaders): array + { + if ($body === null || self::hasHeader($extraHeaders, 'Content-Encoding')) { + return [$body, $extraHeaders]; + } + + $compressed = Compression::maybeCompress($body); + if ($compressed === null) { + return [$body, $extraHeaders]; + } + + [$encoding, $compressedBody] = $compressed; + $extraHeaders['Content-Encoding'] = $encoding; + return [$compressedBody, $extraHeaders]; + } + + /** + * Case-insensitive check for a header key, since HTTP header names are case-insensitive. + * + * @param array $headers + */ + private static function hasHeader(array $headers, string $name): bool + { + foreach (array_keys($headers) as $key) { + if (strcasecmp($key, $name) === 0) { + return true; + } + } + return false; + } + /** Opens a live streaming response (single attempt, no retry). Used by log streaming. */ public function stream(string $url): ResponseInterface { diff --git a/src/Internal/Platform.php b/src/Internal/Platform.php new file mode 100644 index 0000000..b9690f1 --- /dev/null +++ b/src/Internal/Platform.php @@ -0,0 +1,45 @@ +getUnprocessedRequests()); // The retry must send only the still-unprocessed request (r1), not the whole batch again. - $retryBody = Json::decode((string) $transport->received[1]->getBody()); + $retryBody = Json::decode(MockTransport::readBody($transport->received[1])); self::assertIsArray($retryBody); self::assertCount(1, $retryBody); self::assertSame('r1', $retryBody[0]['uniqueKey']); @@ -150,7 +150,7 @@ public function testChunksByCountLimit(): void self::assertSame(2, $transport->callCount()); self::assertCount(30, $result->getProcessedRequests()); // First batch must respect the 25-request count limit. - $firstBody = Json::decode((string) $transport->received[0]->getBody()); + $firstBody = Json::decode(MockTransport::readBody($transport->received[0])); self::assertIsArray($firstBody); self::assertCount(25, $firstBody); } @@ -172,7 +172,7 @@ public function testChunksByPayloadByteSize(): void self::assertSame(2, $transport->callCount()); self::assertCount(3, $result->getProcessedRequests()); - $firstBody = Json::decode((string) $transport->received[0]->getBody()); + $firstBody = Json::decode(MockTransport::readBody($transport->received[0])); self::assertIsArray($firstBody); self::assertCount(2, $firstBody); // byte limit, not the count limit, governed here } diff --git a/tests/Unit/CompressionTest.php b/tests/Unit/CompressionTest.php new file mode 100644 index 0000000..13851b4 --- /dev/null +++ b/tests/Unit/CompressionTest.php @@ -0,0 +1,84 @@ + array_fill(0, 500, ['field' => 'value-with-some-length'])]); + self::assertIsString($original); + + $result = Compression::maybeCompress($original); + self::assertNotNull($result); + [$encoding, $data] = $result; + + // Highly repetitive JSON must shrink. + self::assertLessThan(strlen($original), strlen($data)); + + $decoded = self::decode($encoding, $data); + self::assertSame($original, $decoded); + } + + public function testPrefersBrotliWhenAvailableElseGzip(): void + { + if (!self::hasCodec()) { + self::markTestSkipped('no compression codec available in this PHP build'); + } + $result = Compression::maybeCompress(str_repeat('payload-', 500)); + self::assertNotNull($result); + [$encoding] = $result; + + $expected = function_exists('brotli_compress') ? 'br' : 'gzip'; + self::assertSame($expected, $encoding); + } + + private static function decode(string $encoding, string $data): string + { + if ($encoding === 'br') { + self::assertTrue(function_exists('brotli_uncompress'), 'brotli extension needed to decode'); + // Called indirectly: the symbol only exists when the PECL brotli extension is loaded, + // so a direct call would be an unresolved reference for static analysis. + $brotliUncompress = 'brotli_uncompress'; + $decoded = $brotliUncompress($data); + } else { + $decoded = gzdecode($data); + } + self::assertIsString($decoded); + return $decoded; + } +} diff --git a/tests/Unit/ConfigTest.php b/tests/Unit/ConfigTest.php index 1c62308..298d471 100644 --- a/tests/Unit/ConfigTest.php +++ b/tests/Unit/ConfigTest.php @@ -5,6 +5,7 @@ namespace Apify\Client\Tests\Unit; use Apify\Client\ApifyClient; +use Apify\Client\Internal\Platform; use Apify\Client\Version; use PHPUnit\Framework\TestCase; @@ -18,10 +19,13 @@ public function testUserAgentFormat(): void $expected = sprintf( 'ApifyClient/%s (%s; PHP/%s); isAtHome/false', Version::CLIENT_VERSION, - strtolower(PHP_OS_FAMILY), + Platform::osToken(PHP_OS), PHP_VERSION, ); self::assertSame($expected, $ua); + // The OS token must be the short lowercase platform identifier (aligned with the other + // Apify clients / Node's os.platform()), never an upper-cased uname value. + self::assertMatchesRegularExpression('/^ApifyClient\/\S+ \([a-z0-9]+; PHP\//', $ua); } public function testUserAgentIsAtHomeTrueAndSuffix(): void diff --git a/tests/Unit/HttpClientTest.php b/tests/Unit/HttpClientTest.php index c88a6a6..62581bd 100644 --- a/tests/Unit/HttpClientTest.php +++ b/tests/Unit/HttpClientTest.php @@ -137,6 +137,35 @@ public function testValidateInputParsesBareObject(): void self::assertTrue($this->client($transport)->actor('apify/hello-world')->validateInput(['x' => 1])); } + public function testLargeRequestBodyIsCompressed(): void + { + if (!function_exists('brotli_compress') && !function_exists('gzencode')) { + self::markTestSkipped('no compression codec available in this PHP build'); + } + $transport = (new MockTransport())->queueResponse(200, Json::encode(['data' => ['id' => 'a']])); + // A field well over the 1024-byte threshold forces the request body to be compressed. + $marker = str_repeat('x', 4096); + $this->client($transport)->actors()->create(['name' => 'n', 'title' => $marker]); + + $request = $transport->lastRequest(); + $encoding = $request->getHeaderLine('Content-Encoding'); + self::assertContains($encoding, ['br', 'gzip']); + + // The body is actually compressed on the wire, not merely labelled. + self::assertLessThan(strlen($marker), strlen((string) $request->getBody())); + // ...yet it round-trips back to the original JSON once decoded. + self::assertStringContainsString($marker, MockTransport::readBody($request)); + } + + public function testSmallRequestBodyIsNotCompressed(): void + { + $transport = (new MockTransport())->queueResponse(200, Json::encode(['data' => ['id' => 'a']])); + $this->client($transport)->actors()->create(['name' => 'n']); + + $request = $transport->lastRequest(); + self::assertSame('', $request->getHeaderLine('Content-Encoding')); + } + public function testSafeIdReplacesFirstSlashWithTilde(): void { $transport = (new MockTransport())->queueResponse(200, Json::encode(['data' => ['id' => 'x']])); diff --git a/tests/Unit/MockTransport.php b/tests/Unit/MockTransport.php index c4871ea..359977e 100644 --- a/tests/Unit/MockTransport.php +++ b/tests/Unit/MockTransport.php @@ -50,6 +50,31 @@ public function lastRequest(): RequestInterface return $this->received[count($this->received) - 1]; } + /** + * Reads a recorded request's body as a string, transparently decompressing it when the client + * applied request compression (a {@code Content-Encoding} header). Tests that assert on the body + * shape use this so they stay agnostic to whether the payload went out compressed. + */ + public static function readBody(RequestInterface $request): string + { + $raw = (string) $request->getBody(); + $encoding = $request->getHeaderLine('Content-Encoding'); + if ($encoding === 'gzip') { + $decoded = gzdecode($raw); + } elseif ($encoding === 'br') { + // brotli_uncompress only exists when the PECL brotli extension is loaded; call it + // indirectly so the symbol is not referenced statically when the extension is absent. + $brotliUncompress = 'brotli_uncompress'; + $decoded = $brotliUncompress($raw); + } else { + return $raw; + } + if (!is_string($decoded)) { + throw new RuntimeException('failed to decompress request body (encoding: ' . $encoding . ')'); + } + return $decoded; + } + public function callCount(): int { return count($this->received); diff --git a/tests/Unit/PlatformTest.php b/tests/Unit/PlatformTest.php new file mode 100644 index 0000000..7758533 --- /dev/null +++ b/tests/Unit/PlatformTest.php @@ -0,0 +1,49 @@ + + */ + public static function osCases(): array + { + return [ + 'linux' => ['Linux', 'linux'], + 'macos' => ['Darwin', 'darwin'], + 'windows nt' => ['WINNT', 'win32'], + 'windows word' => ['Windows', 'win32'], + 'win32 literal' => ['WIN32', 'win32'], + 'freebsd' => ['FreeBSD', 'freebsd'], + 'openbsd' => ['OpenBSD', 'openbsd'], + 'netbsd' => ['NetBSD', 'netbsd'], + 'solaris/sunos' => ['SunOS', 'sunos'], + 'cygwin' => ['CYGWIN_NT-10.0-19045', 'cygwin'], + ]; + } + + /** + * @dataProvider osCases + */ + public function testOsTokenMapping(string $phpOs, string $expected): void + { + self::assertSame($expected, Platform::osToken($phpOs)); + } + + public function testTokenIsAlwaysLowercase(): void + { + foreach (['Linux', 'Darwin', 'WINNT', 'FreeBSD', 'SunOS'] as $os) { + $token = Platform::osToken($os); + self::assertSame(strtolower($token), $token); + } + } +} From e5f86f4cc227b8f53ea13e71d569718fa7685758 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 09:54:40 +0000 Subject: [PATCH 2/2] test: cover both brotli and gzip compression paths; verify uniform OS token Split Compression size-gate + codec selection into a testable compressWith() seam (no behavior change) so both the brotli path and the gzip fallback are covered deterministically regardless of whether the PECL brotli extension is loaded. Add a unit-brotli CI job so the real brotli round-trip runs in CI, and guard the gzip tests to skip on zlib-less builds. Add an AIX->aix case asserting the User-Agent OS token matches the reference JS os.platform() token. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019xbC2QoA4QKyvhC1d7gYap --- .github/workflows/php-integration-tests.yml | 31 ++++++++ CHANGELOG.md | 3 + src/Internal/Compression.php | 66 +++++++++++++--- tests/Unit/CompressionTest.php | 84 +++++++++++++++++++++ tests/Unit/PlatformTest.php | 1 + 5 files changed, 176 insertions(+), 9 deletions(-) diff --git a/.github/workflows/php-integration-tests.yml b/.github/workflows/php-integration-tests.yml index 301790c..a9df912 100644 --- a/.github/workflows/php-integration-tests.yml +++ b/.github/workflows/php-integration-tests.yml @@ -25,6 +25,37 @@ concurrency: cancel-in-progress: true jobs: + # Offline unit tests on a build WITH the PECL brotli extension, so the real brotli codec round-trip + # (testRealBrotliRoundTripsWhenExtensionPresent) actually runs instead of self-skipping. The main + # `test` job deliberately omits brotli, so between the two jobs both request-compression codecs + # (brotli and the gzip fallback) get genuine real-codec coverage in CI. This job needs no API token. + unit-brotli: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up PHP (with brotli) + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + extensions: mbstring, json, curl, zlib, brotli + coverage: none + tools: composer:v2 + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist --no-progress + + - name: Fail if brotli extension missing + run: | + if ! php -r "exit(extension_loaded('brotli') ? 0 : 1);"; then + echo "::error::brotli extension failed to load; the real brotli codec test would silently skip." + exit 1 + fi + + - name: Unit tests (brotli path) + run: vendor/bin/phpunit --testsuite unit + test: runs-on: ubuntu-latest steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 0329e23..76c6f81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ - The `User-Agent` OS token now reports the short lowercase platform identifier (e.g. `linux`, `darwin`, `win32`), matching the reference JS client's `os.platform()` token, instead of the upper-cased `PHP_OS_FAMILY` value. +- Both request-compression codecs are now covered by deterministic tests: the brotli path (its + preference over gzip and its output) and the gzip fallback are each exercised regardless of whether + the host PHP build has the PECL `brotli` extension loaded. No behavior change. ## 0.1.1 diff --git a/src/Internal/Compression.php b/src/Internal/Compression.php index fbe627c..e19b728 100644 --- a/src/Internal/Compression.php +++ b/src/Internal/Compression.php @@ -45,25 +45,39 @@ final class Compression * @return array{0: string, 1: string}|null */ public static function maybeCompress(string $body): ?array + { + return self::compressWith($body, self::brotliEncoder(), self::gzipEncoder()); + } + + /** + * Size gate plus codec selection, split out from {@see maybeCompress} so both the brotli and the + * gzip path can be exercised by tests regardless of which extensions the host PHP build loaded. + * + * Brotli ({@code br}) is preferred over gzip when its encoder is available; each encoder returns + * the compressed bytes as a string, or a non-string on failure, in which case the next codec is + * tried. Returns {@code null} when the body is below {@see MIN_COMPRESS_BYTES} or no codec + * succeeds. A {@code null} encoder means that codec is unavailable and is skipped. + * + * @param (callable(string): mixed)|null $brotli brotli encoder, or {@code null} when the PECL brotli extension is absent + * @param (callable(string): mixed)|null $gzip gzip encoder, or {@code null} when zlib is absent + * @return array{0: string, 1: string}|null + */ + public static function compressWith(string $body, ?callable $brotli, ?callable $gzip): ?array { if (strlen($body) < self::MIN_COMPRESS_BYTES) { return null; } - if (function_exists('brotli_compress')) { - // Called indirectly: brotli_compress only exists when the PECL brotli extension is - // loaded, so a direct call would be an unresolved reference for static analysis on the - // (common) PHP builds without the extension. - $brotliCompress = 'brotli_compress'; - $compressed = $brotliCompress($body, self::BROTLI_QUALITY); + if ($brotli !== null) { + $compressed = $brotli($body); if (is_string($compressed)) { return ['br', $compressed]; } } - if (function_exists('gzencode')) { - $compressed = gzencode($body); - if ($compressed !== false) { + if ($gzip !== null) { + $compressed = $gzip($body); + if (is_string($compressed)) { return ['gzip', $compressed]; } } @@ -71,6 +85,40 @@ public static function maybeCompress(string $body): ?array return null; } + /** + * The brotli encoder for this build, or {@code null} when the PECL {@code brotli} extension is not + * loaded. Frequently absent, since brotli is not part of PHP's standard distribution. + * + * @return (callable(string): mixed)|null + */ + private static function brotliEncoder(): ?callable + { + if (!function_exists('brotli_compress')) { + return null; + } + + // Called indirectly: brotli_compress only exists when the PECL brotli extension is loaded, so + // a direct call would be an unresolved reference for static analysis on the (common) PHP + // builds without the extension. + $brotliCompress = 'brotli_compress'; + return static fn (string $body) => $brotliCompress($body, self::BROTLI_QUALITY); + } + + /** + * The gzip encoder for this build, or {@code null} when {@code gzencode} (zlib) is unavailable. + * Ships with PHP's standard {@code zlib} extension, so it is the near-universal fallback. + * + * @return (callable(string): mixed)|null + */ + private static function gzipEncoder(): ?callable + { + if (!function_exists('gzencode')) { + return null; + } + + return static fn (string $body) => gzencode($body); + } + private function __construct() { } diff --git a/tests/Unit/CompressionTest.php b/tests/Unit/CompressionTest.php index 13851b4..dbd9bf8 100644 --- a/tests/Unit/CompressionTest.php +++ b/tests/Unit/CompressionTest.php @@ -67,6 +67,90 @@ public function testPrefersBrotliWhenAvailableElseGzip(): void self::assertSame($expected, $encoding); } + public function testGzipPathWhenBrotliUnavailable(): void + { + if (!function_exists('gzencode')) { + self::markTestSkipped('zlib (gzencode) not available in this PHP build'); + } + // Deterministically exercise the gzip fallback (brotli encoder absent) without depending on + // the host lacking the PECL brotli extension. + $original = str_repeat('payload-', 500); + $result = Compression::compressWith($original, null, static fn (string $b) => gzencode($b)); + self::assertNotNull($result); + [$encoding, $data] = $result; + self::assertSame('gzip', $encoding); + self::assertLessThan(strlen($original), strlen($data)); + self::assertSame($original, gzdecode($data)); + } + + public function testBrotliPathIsPreferredWhenAvailable(): void + { + // Deterministically exercise the brotli path (and its preference over gzip) without depending + // on the host having the PECL brotli extension: inject a stand-in brotli encoder and assert it + // is chosen and its output used, while a real gzip encoder is also available. + $original = str_repeat('payload-', 500); + $marker = 'BR:' . $original; + $result = Compression::compressWith( + $original, + static fn (string $b) => 'BR:' . $b, + static fn (string $b) => gzencode($b), + ); + self::assertNotNull($result); + [$encoding, $data] = $result; + self::assertSame('br', $encoding); + self::assertSame($marker, $data); + } + + public function testRealBrotliRoundTripsWhenExtensionPresent(): void + { + if (!function_exists('brotli_compress')) { + self::markTestSkipped('PECL brotli extension not loaded'); + } + // When the real extension is present, verify the actual brotli codec produces decodable bytes. + $original = str_repeat('payload-', 500); + $result = Compression::maybeCompress($original); + self::assertNotNull($result); + [$encoding, $data] = $result; + self::assertSame('br', $encoding); + self::assertLessThan(strlen($original), strlen($data)); + self::assertSame($original, self::decode('br', $data)); + } + + public function testFallsBackToGzipWhenBrotliEncoderFails(): void + { + if (!function_exists('gzencode')) { + self::markTestSkipped('zlib (gzencode) not available in this PHP build'); + } + // A brotli encoder that fails (returns a non-string) must not abort compression: gzip is used. + $original = str_repeat('payload-', 500); + $result = Compression::compressWith( + $original, + static fn (string $b) => false, + static fn (string $b) => gzencode($b), + ); + self::assertNotNull($result); + [$encoding, $data] = $result; + self::assertSame('gzip', $encoding); + self::assertSame($original, gzdecode($data)); + } + + public function testReturnsNullWhenNoCodecAvailable(): void + { + $original = str_repeat('payload-', 500); + self::assertNull(Compression::compressWith($original, null, null)); + } + + public function testSmallBodyIsNotCompressedEvenWithCodecs(): void + { + // The size gate applies before codec selection, so a below-threshold body is never compressed. + $small = str_repeat('a', Compression::MIN_COMPRESS_BYTES - 1); + self::assertNull(Compression::compressWith( + $small, + static fn (string $b) => 'BR:' . $b, + static fn (string $b) => gzencode($b), + )); + } + private static function decode(string $encoding, string $data): string { if ($encoding === 'br') { diff --git a/tests/Unit/PlatformTest.php b/tests/Unit/PlatformTest.php index 7758533..73138c3 100644 --- a/tests/Unit/PlatformTest.php +++ b/tests/Unit/PlatformTest.php @@ -27,6 +27,7 @@ public static function osCases(): array 'openbsd' => ['OpenBSD', 'openbsd'], 'netbsd' => ['NetBSD', 'netbsd'], 'solaris/sunos' => ['SunOS', 'sunos'], + 'aix' => ['AIX', 'aix'], 'cygwin' => ['CYGWIN_NT-10.0-19045', 'cygwin'], ]; }