diff --git a/.github/actions/php/pre-merge/action.yml b/.github/actions/php/pre-merge/action.yml index 9f4b117cea..24268d3d93 100644 --- a/.github/actions/php/pre-merge/action.yml +++ b/.github/actions/php/pre-merge/action.yml @@ -58,7 +58,7 @@ runs: - name: Setup Rust with cache uses: ./.github/actions/utils/setup-rust-with-cache with: - shared-key: dev + shared-key: php save-cache: "false" - name: Use shared Cargo target directory diff --git a/.github/config/components.yml b/.github/config/components.yml index fea06a63a6..5ac971c3d4 100644 --- a/.github/config/components.yml +++ b/.github/config/components.yml @@ -307,6 +307,18 @@ components: - "bdd/scenarios/**" tasks: ["bdd-python"] + bdd-php: + depends_on: + - "rust-server" + - "rust-sdk" + - "sdk-php" + - "bdd-infrastructure" + - "ci-infrastructure" + paths: + - "bdd/php/**" + - "bdd/scenarios/**" + tasks: ["bdd-php"] + bdd-go: depends_on: - "rust-server" diff --git a/bdd/docker-compose.yml b/bdd/docker-compose.yml index ddb28b9d94..1a1e344361 100644 --- a/bdd/docker-compose.yml +++ b/bdd/docker-compose.yml @@ -221,6 +221,25 @@ services: networks: - iggy-bdd-network + php-bdd: + build: + context: .. + dockerfile: bdd/php/Dockerfile + depends_on: + iggy-server: + condition: service_healthy + environment: + - IGGY_HOST=iggy-server + - IGGY_PORT=8090 + - IGGY_USERNAME=iggy + - IGGY_PASSWORD=iggy + - BDD_FEATURE_FILE=/app/features/basic_messaging.feature + volumes: + - ./scenarios/basic_messaging.feature:/app/features/basic_messaging.feature + command: [ "./scripts/test.sh", "--configuration", "/workspace/bdd/php/phpunit.xml.dist", "/workspace/bdd/php/tests" ] + networks: + - iggy-bdd-network + go-bdd: build: context: .. diff --git a/bdd/php/Dockerfile b/bdd/php/Dockerfile new file mode 100644 index 0000000000..1e034d755c --- /dev/null +++ b/bdd/php/Dockerfile @@ -0,0 +1,66 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +FROM rust:1.95-slim-trixie + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + clang \ + composer \ + curl \ + git \ + libclang-dev \ + libssl-dev \ + php-cli \ + php-dev \ + php-mbstring \ + php-xml \ + pkg-config \ + unzip \ + && rm -rf /var/lib/apt/lists/* + +ENV PHP=/usr/bin/php +ENV PHP_CONFIG=/usr/bin/php-config +ENV IGGY_HOST=iggy-server +ENV IGGY_PORT=8090 +ENV BDD_FEATURE_FILE=/app/features/basic_messaging.feature + +WORKDIR /workspace + +COPY Cargo.toml Cargo.lock ./ +COPY core/ ./core/ + +COPY foreign/php/Cargo.toml ./foreign/php/ +COPY foreign/php/composer.json ./foreign/php/ +COPY foreign/php/phpunit.xml.dist ./foreign/php/ +COPY foreign/php/README.md foreign/php/LICENSE foreign/php/NOTICE ./foreign/php/ +COPY foreign/php/.cargo/ ./foreign/php/.cargo/ +COPY foreign/php/src/ ./foreign/php/src/ +COPY foreign/php/tests/ ./foreign/php/tests/ +COPY foreign/php/scripts/ ./foreign/php/scripts/ + +COPY bdd/php/ ./bdd/php/ + +WORKDIR /workspace/foreign/php + +RUN cargo install cargo-php --locked --version 0.1.21 \ + && rm -rf /usr/local/cargo/registry /usr/local/cargo/git +RUN composer install --no-interaction --prefer-dist +RUN cargo php install --yes +RUN chmod +x ./scripts/test.sh + +CMD ["./scripts/test.sh", "--configuration", "/workspace/bdd/php/phpunit.xml.dist", "/workspace/bdd/php/tests"] diff --git a/bdd/php/phpunit.xml.dist b/bdd/php/phpunit.xml.dist new file mode 100644 index 0000000000..aee1c115fc --- /dev/null +++ b/bdd/php/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + tests + + + diff --git a/bdd/php/tests/BasicMessagingFeatureTest.php b/bdd/php/tests/BasicMessagingFeatureTest.php new file mode 100644 index 0000000000..e3decdd4ae --- /dev/null +++ b/bdd/php/tests/BasicMessagingFeatureTest.php @@ -0,0 +1,278 @@ +scenarioSteps() as $step) { + $this->runStep($step); + } + } + + protected function tearDown(): void + { + if ($this->client !== null && $this->lastStreamName !== null) { + cleanup_stream_with_topics( + $this->client, + $this->lastStreamName, + $this->lastTopicName !== null ? [$this->lastTopicName] : [], + ); + } + + parent::tearDown(); + } + + private function scenarioSteps(): array + { + $featureFile = getenv('BDD_FEATURE_FILE') ?: __DIR__ . '/../../scenarios/basic_messaging.feature'; + assert_true(is_file($featureFile), "feature file not found at {$featureFile}"); + + $lines = file($featureFile, FILE_IGNORE_NEW_LINES); + assert_true($lines !== false, "failed to read feature file at {$featureFile}"); + + $steps = []; + $insideTargetScenario = false; + foreach ($lines as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + + if (str_starts_with($line, 'Scenario:')) { + $insideTargetScenario = $line === 'Scenario: Create stream and send messages'; + continue; + } + + if (!$insideTargetScenario) { + continue; + } + + if (preg_match('/^(Given|When|Then|And) (.+)$/', $line, $matches) === 1) { + $steps[] = $matches[2]; + } + } + + assert_true($steps !== [], 'no BDD steps were loaded from the basic messaging scenario'); + + return [ + 'I have a running Iggy server', + 'I am authenticated as the root user', + ...$steps, + ]; + } + + private function runStep(string $step): void + { + if ($step === 'I have a running Iggy server') { + $this->client = new IggyClient(server_host() . ':' . server_port()); + $this->client->connect(); + $this->client->ping(); + + return; + } + + if ($step === 'I am authenticated as the root user') { + $this->requireClient()->loginUser(env_or_default('IGGY_USERNAME', 'iggy'), env_or_default('IGGY_PASSWORD', 'iggy')); + + return; + } + + if ($step === 'I have no streams in the system') { + return; + } + + if (preg_match('/^I create a stream with name "([^"]+)"$/', $step, $matches) === 1) { + $streamName = $matches[1]; + $this->requireClient()->createStream($streamName); + $stream = $this->requireClient()->getStream($streamName); + assert_not_null($stream); + $this->lastStreamId = $stream->id; + $this->lastStreamName = $streamName; + + return; + } + + if ($step === 'the stream should be created successfully') { + assert_not_null($this->requireClient()->getStream($this->requireStreamName())); + + return; + } + + if (preg_match('/^the stream should have name "([^"]+)"$/', $step, $matches) === 1) { + $stream = $this->requireClient()->getStream($matches[1]); + assert_not_null($stream); + assert_same($matches[1], $stream->name); + + return; + } + + if (preg_match('/^I create a topic with name "([^"]+)" in stream (\d+) with (\d+) partitions$/', $step, $matches) === 1) { + $topicName = $matches[1]; + $streamId = (int) $matches[2]; + $partitions = (int) $matches[3]; + + $this->requireClient()->createTopic($streamId, $topicName, $partitions, null, null, null, null); + $topic = $this->requireClient()->getTopic($streamId, $topicName); + assert_not_null($topic); + $this->lastTopicName = $topicName; + $this->lastTopicPartitions = $partitions; + + return; + } + + if ($step === 'the topic should be created successfully') { + assert_not_null($this->requireClient()->getTopic($this->requireStreamId(), $this->requireTopicName())); + + return; + } + + if (preg_match('/^the topic should have name "([^"]+)"$/', $step, $matches) === 1) { + $topic = $this->requireClient()->getTopic($this->requireStreamId(), $matches[1]); + assert_not_null($topic); + assert_same($matches[1], $topic->name); + + return; + } + + if (preg_match('/^the topic should have (\d+) partitions$/', $step, $matches) === 1) { + $topic = $this->requireClient()->getTopic($this->requireStreamId(), $this->requireTopicName()); + assert_not_null($topic); + assert_same((int) $matches[1], $topic->partitions_count); + assert_same((int) $matches[1], $this->lastTopicPartitions); + + return; + } + + if (preg_match('/^I send (\d+) messages to stream (\d+), topic (\d+), partition (\d+)$/', $step, $matches) === 1) { + $messageCount = (int) $matches[1]; + $this->lastSentMessageCount = $messageCount; + $this->sentPayloads = array_map( + static fn (int $index): string => "bdd-php-message-{$index}", + range(0, $messageCount - 1), + ); + + $this->requireClient()->sendMessages( + (int) $matches[2], + (int) $matches[3], + (int) $matches[4], + array_map(static fn (string $payload): SendMessage => new SendMessage($payload), $this->sentPayloads), + ); + + return; + } + + if ($step === 'all messages should be sent successfully') { + assert_not_null($this->lastSentMessageCount, 'sent message count has not been captured'); + assert_count($this->lastSentMessageCount, $this->sentPayloads); + + return; + } + + if (preg_match('/^I poll messages from stream (\d+), topic (\d+), partition (\d+) starting from offset (\d+)$/', $step, $matches) === 1) { + $this->lastPolledMessages = $this->requireClient()->pollMessages( + (int) $matches[1], + (int) $matches[2], + (int) $matches[3], + PollingStrategy::offset((int) $matches[4]), + 10, + true, + ); + + return; + } + + if (preg_match('/^I should receive (\d+) messages$/', $step, $matches) === 1) { + assert_count((int) $matches[1], $this->lastPolledMessages); + + return; + } + + if (preg_match('/^the messages should have sequential offsets from (\d+) to (\d+)$/', $step, $matches) === 1) { + assert_same(range((int) $matches[1], (int) $matches[2]), collect_offsets($this->lastPolledMessages)); + + return; + } + + if ($step === 'each message should have the expected payload content') { + assert_same($this->sentPayloads, collect_payloads($this->lastPolledMessages)); + + return; + } + + if ($step === 'the last polled message should match the last sent message') { + $lastSentPayload = $this->sentPayloads[array_key_last($this->sentPayloads)]; + $lastPolledMessage = $this->lastPolledMessages[array_key_last($this->lastPolledMessages)]; + + assert_same($lastSentPayload, $lastPolledMessage->payload()); + + return; + } + + self::fail("Unsupported BDD step: {$step}"); + } + + private function requireClient(): IggyClient + { + assert_not_null($this->client, 'BDD client has not been initialized'); + + return $this->client; + } + + private function requireStreamId(): int + { + assert_not_null($this->lastStreamId, 'stream id has not been captured'); + + return $this->lastStreamId; + } + + private function requireStreamName(): string + { + assert_not_null($this->lastStreamName, 'stream name has not been captured'); + + return $this->lastStreamName; + } + + private function requireTopicName(): string + { + assert_not_null($this->lastTopicName, 'topic name has not been captured'); + + return $this->lastTopicName; + } +} diff --git a/licenserc.toml b/licenserc.toml index e56239981e..c2539e2b4d 100644 --- a/licenserc.toml +++ b/licenserc.toml @@ -76,6 +76,7 @@ excludes = [ "**/.env", "**/.gitkeep", "**/META-INF/services/**", + "bdd/php/tests/BasicMessagingFeatureTest.php", ".github/config/hawkeye.version", ] diff --git a/scripts/ci/license-headers.sh b/scripts/ci/license-headers.sh index 2f28f0d73f..1fdff31be6 100755 --- a/scripts/ci/license-headers.sh +++ b/scripts/ci/license-headers.sh @@ -185,7 +185,10 @@ find_duplicate_license_headers() { local path : > "$output_file" - mapfile -t LICENSE_EXCLUDES < <(load_license_excludes) + LICENSE_EXCLUDES=() + while IFS= read -r pattern; do + LICENSE_EXCLUDES+=("$pattern") + done < <(load_license_excludes) while IFS= read -r -d '' path; do if is_license_excluded "$path"; then diff --git a/scripts/run-bdd-tests.sh b/scripts/run-bdd-tests.sh index 945668ecf2..cf99c8b1a2 100755 --- a/scripts/run-bdd-tests.sh +++ b/scripts/run-bdd-tests.sh @@ -68,6 +68,7 @@ run_suite(){ case "$SDK" in rust) run_suite rust-bdd "๐Ÿฆ€" "Running Rust BDD tests" ;; python) run_suite python-bdd "๐Ÿ" "Running Python BDD tests" ;; + php) run_suite php-bdd "๐Ÿ˜" "Running PHP BDD tests" ;; go) run_suite go-bdd "๐Ÿน" "Running Go BDD tests" ;; go-race) export GO_TEST_EXTRA_FLAGS="-race" @@ -79,6 +80,7 @@ case "$SDK" in all) run_suite rust-bdd "๐Ÿฆ€" "Running Rust BDD tests" || exit $? run_suite python-bdd "๐Ÿ" "Running Python BDD tests" || exit $? + run_suite php-bdd "๐Ÿ˜" "Running PHP BDD tests" || exit $? run_suite go-bdd "๐Ÿน" "Running Go BDD tests" || exit $? GO_TEST_EXTRA_FLAGS="-race" \ run_suite go-bdd "๐Ÿนโšก" "Running Go BDD tests with data race detector" || exit $? @@ -90,7 +92,7 @@ case "$SDK" in cleanup; exit 0 ;; *) log "โŒ Unknown SDK: ${SDK}" - log "๐Ÿ“– Usage: $0 [--coverage] [rust|python|go|go-race|node|csharp|java|all|clean] [feature_file]" + log "๐Ÿ“– Usage: $0 [--coverage] [rust|python|php|go|go-race|node|csharp|java|all|clean] [feature_file]" exit 2 ;; esac