diff --git a/features/behat-steps.feature b/features/behat-steps.feature index 3ffa4eb2..431f56e9 100644 --- a/features/behat-steps.feature +++ b/features/behat-steps.feature @@ -695,3 +695,64 @@ Feature: Test that WP-CLI Behat steps work as expected | user_login | user_email | | admin | admin@example.com | | user2 | user2@example.com | + + Scenario: Test line endings normalization for command output and files + Given an empty directory + + # 1. Test STDOUT / STDERR assertions with CRLF + When I run `php -r "echo 'line1' . \"\r\n\" . 'line2' . \"\r\n\";"` + Then STDOUT should be: + """ + line1 + line2 + """ + And STDOUT should contain: + """ + line1 + line2 + """ + And STDOUT should match /line1\nline2/ + + # 2. Test JSON assertions with CRLF + When I run `php -r "echo json_encode(['foo' => \"bar\r\nbaz\r\n\"]) . PHP_EOL;"` + Then STDOUT should be JSON containing: + """ + {"foo":"bar\nbaz\n"} + """ + + # 3. Test JSON array assertions with CRLF + When I run `php -r "echo json_encode([\"foo\r\nbar\", \"baz\r\nqux\"]) . PHP_EOL;"` + Then STDOUT should be a JSON array containing: + """ + ["foo\nbar", "baz\nqux"] + """ + + # 4. Test Table assertions with CRLF + When I run `php -r "echo 'name' . \"\t\" . 'value' . \"\r\n\" . 'foo' . \"\t\" . 'bar' . \"\r\n\";"` + Then STDOUT should be a table containing rows: + | name | value | + | foo | bar | + + # 5. Test CSV assertions with CRLF + When I run `php -r "echo 'name,value' . \"\r\n\" . 'foo,bar' . \"\r\n\";"` + Then STDOUT should be CSV containing: + | name | value | + | foo | bar | + + # 6. Test YAML assertions with CRLF + When I run `php -r "echo 'foo: bar' . \"\r\n\" . 'baz: qux' . \"\r\n\";"` + Then STDOUT should be YAML containing: + """ + foo: bar + baz: qux + """ + + # 7. Test File assertions with CRLF + When I run `php -r "file_put_contents('crlf.txt', 'crlf1' . \"\r\n\" . 'crlf2' . \"\r\n\");"` + Then the crlf.txt file should contain: + """ + crlf1 + crlf2 + """ + And the contents of the crlf.txt file should match /crlf1\ncrlf2/ + diff --git a/features/http-mocking.feature b/features/http-mocking.feature index 0f0ee9a4..35345526 100644 --- a/features/http-mocking.feature +++ b/features/http-mocking.feature @@ -130,7 +130,7 @@ Feature: HTTP request mocking Mocked file contents on disk! """ - When I try `wp eval 'WP_CLI\Utils\http_request("GET", "https://example.com/mocked-file.txt", null, [], ["filename" => "downloaded.txt"]);' --skip-wordpress` + When I try `wp eval "WP_CLI\Utils\http_request('GET', 'https://example.com/mocked-file.txt', null, [], ['filename' => 'downloaded.txt']);" --skip-wordpress` Then the return code should be 0 And the downloaded.txt file should contain: """ diff --git a/src/Context/Support.php b/src/Context/Support.php index 913ca830..959d3c86 100644 --- a/src/Context/Support.php +++ b/src/Context/Support.php @@ -95,6 +95,9 @@ protected function assert_not_numeric( $actual ): void { * @throws Exception */ protected function check_string( $output, $expected, $action, $message = false, $strictly = false ): void { + $output = $this->normalize_line_endings( $output ); + $expected = $this->normalize_line_endings( $expected ); + // Strip ANSI color codes before comparing strings. if ( ! $strictly ) { $output = preg_replace( '/\e[[][A-Za-z0-9];?[0-9]*m?/', '', $output ); @@ -236,8 +239,8 @@ protected function compare_contents( $expected, $actual ) { * the contents of 'array' does not include 3 */ protected function check_that_json_string_contains_json_string( $actual_json, $expected_json ) { - $actual_value = json_decode( $actual_json ); - $expected_value = json_decode( $expected_json ); + $actual_value = $this->normalize_line_endings_in_data( json_decode( $actual_json ) ); + $expected_value = $this->normalize_line_endings_in_data( json_decode( $expected_json ) ); if ( ! $actual_value ) { return false; @@ -311,8 +314,8 @@ static function ( $str ) { * @return bool whether or not $actual_yaml contains $expected_json */ protected function check_that_yaml_string_contains_yaml_string( $actual_yaml, $expected_yaml ) { - $actual_value = Spyc::YAMLLoad( $actual_yaml ); - $expected_value = Spyc::YAMLLoad( $expected_yaml ); + $actual_value = $this->normalize_line_endings_in_data( Spyc::YAMLLoad( $actual_yaml ) ); + $expected_value = $this->normalize_line_endings_in_data( Spyc::YAMLLoad( $expected_yaml ) ); if ( ! $actual_value ) { return false; @@ -336,4 +339,40 @@ protected function generate_diff( string $expected, string $actual ): string { $differ = new Differ( $builder ); return $differ->diff( $expected, $actual ); } + + /** + * Normalize line endings of a string to Unix-style LF (\n). + * + * @param string $str The string to normalize. + * @return string The normalized string. + */ + protected function normalize_line_endings( string $str ): string { + return str_replace( "\r\n", "\n", $str ); + } + + /** + * Recursively normalize line endings in a data structure (array, object, or string). + * + * @param mixed $data The data structure to normalize. + * @return mixed The normalized data structure. + */ + protected function normalize_line_endings_in_data( $data ) { + if ( is_string( $data ) ) { + return $this->normalize_line_endings( $data ); + } + + if ( is_array( $data ) ) { + return array_map( [ $this, 'normalize_line_endings_in_data' ], $data ); + } + + if ( is_object( $data ) ) { + $normalized = new \stdClass(); + foreach ( get_object_vars( $data ) as $key => $value ) { + $normalized->$key = $this->normalize_line_endings_in_data( $value ); + } + return $normalized; + } + + return $data; + } } diff --git a/src/Context/ThenStepDefinitions.php b/src/Context/ThenStepDefinitions.php index bc267235..0f5b4c41 100644 --- a/src/Context/ThenStepDefinitions.php +++ b/src/Context/ThenStepDefinitions.php @@ -95,8 +95,9 @@ public function then_stdout_stderr_should_contain( $stream, $strictly, $action, public function then_stdout_stderr_should_be_a_number( $stream ): void { $stream = strtolower( $stream ); + $output = $this->normalize_line_endings( $this->result->$stream ); - $this->assert_numeric( trim( $this->result->$stream, "\n" ) ); + $this->assert_numeric( trim( $output, "\n" ) ); } /** @@ -118,8 +119,9 @@ public function then_stdout_stderr_should_be_a_number( $stream ): void { public function then_stdout_stderr_should_not_be_a_number( $stream ): void { $stream = strtolower( $stream ); + $output = $this->normalize_line_endings( $this->result->$stream ); - $this->assert_not_numeric( trim( $this->result->$stream, "\n" ) ); + $this->assert_not_numeric( trim( $output, "\n" ) ); } /** @@ -140,7 +142,7 @@ public function then_stdout_stderr_should_not_be_a_number( $stream ): void { * @Then /^STDOUT should be a table containing rows:$/ */ public function then_stdout_should_be_a_table_containing_rows( TableNode $expected ): void { - $output = $this->result->stdout; + $output = $this->normalize_line_endings( $this->result->stdout ); $actual_rows = explode( "\n", rtrim( $output, "\n" ) ); $expected_rows = array(); @@ -176,7 +178,7 @@ public function then_stdout_should_be_a_table_containing_rows( TableNode $expect * @Then /^STDOUT should end with a table containing rows:$/ */ public function then_stdout_should_end_with_a_table_containing_rows( TableNode $expected ): void { - $output = $this->result->stdout; + $output = $this->normalize_line_endings( $this->result->stdout ); $actual_rows = explode( "\n", rtrim( $output, "\n" ) ); $expected_rows = array(); @@ -212,8 +214,8 @@ public function then_stdout_should_end_with_a_table_containing_rows( TableNode $ * @Then /^STDOUT should be JSON containing:$/ */ public function then_stdout_should_be_json_containing( PyStringNode $expected ): void { - $output = $this->result->stdout; - $expected = $this->replace_variables( (string) $expected ); + $output = $this->normalize_line_endings( $this->result->stdout ); + $expected = $this->normalize_line_endings( $this->replace_variables( (string) $expected ) ); if ( ! $this->check_that_json_string_contains_json_string( $output, $expected ) ) { $message = (string) $this->result; @@ -253,11 +255,11 @@ public function then_stdout_should_be_json_containing( PyStringNode $expected ): * @Then /^STDOUT should be a JSON array containing:$/ */ public function then_stdout_should_be_a_json_array_containing( PyStringNode $expected ): void { - $output = $this->result->stdout; - $expected = $this->replace_variables( (string) $expected ); + $output = $this->normalize_line_endings( $this->result->stdout ); + $expected = $this->normalize_line_endings( $this->replace_variables( (string) $expected ) ); - $actual_values = json_decode( $output ); - $expected_values = json_decode( $expected ); + $actual_values = $this->normalize_line_endings_in_data( json_decode( $output ) ); + $expected_values = $this->normalize_line_endings_in_data( json_decode( $expected ) ); $missing = array_diff( $expected_values, $actual_values ); if ( ! empty( $missing ) ) { @@ -293,7 +295,7 @@ public function then_stdout_should_be_a_json_array_containing( PyStringNode $exp * @Then /^STDOUT should be CSV containing:$/ */ public function then_stdout_should_be_csv_containing( TableNode $expected ): void { - $output = $this->result->stdout; + $output = $this->normalize_line_endings( $this->result->stdout ); $expected_rows = $expected->getRows(); foreach ( $expected_rows as &$row ) { @@ -336,8 +338,8 @@ public function then_stdout_should_be_csv_containing( TableNode $expected ): voi * @Then /^STDOUT should be YAML containing:$/ */ public function then_stdout_should_be_yaml_containing( PyStringNode $expected ): void { - $output = $this->result->stdout; - $expected = $this->replace_variables( (string) $expected ); + $output = $this->normalize_line_endings( $this->result->stdout ); + $expected = $this->normalize_line_endings( $this->replace_variables( (string) $expected ) ); if ( ! $this->check_that_yaml_string_contains_yaml_string( $output, $expected ) ) { $message = (string) $this->result; @@ -392,8 +394,9 @@ public function then_stdout_stderr_should_be_empty( $stream ): void { public function then_stdout_stderr_should_not_be_empty( $stream ): void { $stream = strtolower( $stream ); + $output = $this->normalize_line_endings( $this->result->$stream ); - if ( '' === rtrim( $this->result->$stream, "\n" ) ) { + if ( '' === rtrim( $output, "\n" ) ) { throw new Exception( $this->result ); } } @@ -419,7 +422,8 @@ public function then_stdout_stderr_should_not_be_empty( $stream ): void { public function then_stdout_stderr_should_be_a_specific_version_string( $stream, $operator, $goal_ver ): void { $goal_ver = $this->replace_variables( $goal_ver ); $stream = strtolower( $stream ); - if ( false === version_compare( trim( $this->result->$stream, "\n" ), $goal_ver, $operator ) ) { + $output = $this->normalize_line_endings( $this->result->$stream ); + if ( false === version_compare( trim( $output, "\n" ), $goal_ver, $operator ) ) { throw new Exception( $this->result ); } } @@ -530,7 +534,7 @@ public function then_the_contents_of_a_specific_file_should_match( $path, $not, if ( '/' !== $path[0] ) { $path = $this->variables['RUN_DIR'] . "/$path"; } - $contents = file_get_contents( $path ); + $contents = $this->normalize_line_endings( file_get_contents( $path ) ); if ( $not ) { $this->assert_not_regex( $expected, $contents ); } else { @@ -559,10 +563,11 @@ public function then_stdout_stderr_should_match_a_string( $stream, $not, $expect $expected = $this->replace_variables( $expected ); $stream = strtolower( $stream ); + $output = $this->normalize_line_endings( $this->result->$stream ); if ( $not ) { - $this->assert_not_regex( $expected, $this->result->$stream ); + $this->assert_not_regex( $expected, $output ); } else { - $this->assert_regex( $expected, $this->result->$stream ); + $this->assert_regex( $expected, $output ); } }