Skip to content
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ from the community.
| [Drupal\ParagraphsTrait](STEPS.md#drupalparagraphstrait) | Manage Drupal paragraphs entities with structured field data. |
| [Drupal\QueueTrait](STEPS.md#drupalqueuetrait) | Manage and assert Drupal queue state. |
| [Drupal\SearchApiTrait](STEPS.md#drupalsearchapitrait) | Assert Drupal Search API with index and query operations. |
| [Drupal\StateTrait](STEPS.md#drupalstatetrait) | Manage and assert Drupal State API values with automatic revert. |
| [Drupal\TaxonomyTrait](STEPS.md#drupaltaxonomytrait) | Manage Drupal taxonomy terms with vocabulary organization. |
| [Drupal\TestmodeTrait](STEPS.md#drupaltestmodetrait) | Configure Drupal Testmode module for controlled testing scenarios. |
| [Drupal\TimeTrait](STEPS.md#drupaltimetrait) | Control system time in tests using Drupal state overrides. |
Expand Down
90 changes: 90 additions & 0 deletions STEPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
| [Drupal\ParagraphsTrait](#drupalparagraphstrait) | Manage Drupal paragraphs entities with structured field data. |
| [Drupal\QueueTrait](#drupalqueuetrait) | Manage and assert Drupal queue state. |
| [Drupal\SearchApiTrait](#drupalsearchapitrait) | Assert Drupal Search API with index and query operations. |
| [Drupal\StateTrait](#drupalstatetrait) | Manage and assert Drupal State API values with automatic revert. |
| [Drupal\TaxonomyTrait](#drupaltaxonomytrait) | Manage Drupal taxonomy terms with vocabulary organization. |
| [Drupal\TestmodeTrait](#drupaltestmodetrait) | Configure Drupal Testmode module for controlled testing scenarios. |
| [Drupal\TimeTrait](#drupaltimetrait) | Control system time in tests using Drupal state overrides. |
Expand Down Expand Up @@ -4161,6 +4162,95 @@ When I run the Search API Solr cron

</details>

## Drupal\StateTrait

[Source](src/Drupal/StateTrait.php), [Example](tests/behat/features/drupal_state.feature)

> Manage and assert Drupal State API values with automatic revert.
> <br/><br/>
> Provides set, delete, and assertion steps for keys stored through
> `\Drupal::state()`. Touched keys are snapshotted on first access and
> reverted after the scenario finishes.
> <br/><br/>
> Skip the revert with `@behat-steps-skip:stateAfterScenario` or with the
> convenience tag `@behat-steps-skip:StateTrait`. The snapshot registry is
> cleared unconditionally before and after the scenario to prevent state
> leaking into subsequent scenarios.


<details>
<summary><code>@Given the state :name has the value :value</code></summary>

<br/>
Set a Drupal state value
<br/><br/>

```gherkin
Given the state "my_module.launched" has the value "1"

```

</details>

<details>
<summary><code>@Given the state :name does not exist</code></summary>

<br/>
Delete a Drupal state value
<br/><br/>

```gherkin
Given the state "my_module.launched" does not exist

```

</details>

<details>
<summary><code>@Given the following state values:</code></summary>

<br/>
Set multiple Drupal state values from a table
<br/><br/>

```gherkin
Given the following state values:
| name | value |
| my_module.launched | 1 |
| my_module.feature_flag | 0 |

```

</details>

<details>
<summary><code>@Then the state :name should have the value :value</code></summary>

<br/>
Assert that a Drupal state value equals an expected value
<br/><br/>

```gherkin
Then the state "my_module.launched" should have the value "1"

```

</details>

<details>
<summary><code>@Then the state :name should not exist</code></summary>

<br/>
Assert that a Drupal state key does not exist
<br/><br/>

```gherkin
Then the state "my_module.launched" should not exist

```

</details>

## Drupal\TaxonomyTrait

[Source](src/Drupal/TaxonomyTrait.php), [Example](tests/behat/features/drupal_taxonomy.feature)
Expand Down
262 changes: 262 additions & 0 deletions src/Drupal/StateTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
<?php

declare(strict_types=1);

namespace DrevOps\BehatSteps\Drupal;

use Behat\Behat\Hook\Scope\AfterScenarioScope;
use Behat\Behat\Hook\Scope\BeforeScenarioScope;
use Behat\Gherkin\Node\TableNode;
use Behat\Hook\AfterScenario;
use Behat\Hook\BeforeScenario;
use Behat\Step\Given;
use Behat\Step\Then;

/**
* Manage and assert Drupal State API values with automatic revert.
*
* Provides set, delete, and assertion steps for keys stored through
* `\Drupal::state()`. Touched keys are snapshotted on first access and
* reverted after the scenario finishes.
*
* Skip the revert with `@behat-steps-skip:stateAfterScenario` or with the
* convenience tag `@behat-steps-skip:StateTrait`. The snapshot registry is
* cleared unconditionally before and after the scenario to prevent state
* leaking into subsequent scenarios.
*/
trait StateTrait {

/**
* Original state values captured before the scenario touched them.
*
* Keys absent from Drupal state are stored with a sentinel marker so they
* can be deleted on revert rather than reset to NULL.
*
* @var array<string, array{exists: bool, value: mixed}>
*/
protected array $stateOriginalValues = [];

/**
* Reset the snapshot registry before each scenario.
*/
#[BeforeScenario]
public function stateBeforeScenario(BeforeScenarioScope $scope): void {
$this->stateOriginalValues = [];
}

/**
* Revert every touched state key after the scenario finishes.
*/
#[AfterScenario]
public function stateAfterScenario(AfterScenarioScope $scope): void {
if (
$scope->getScenario()->hasTag('behat-steps-skip:' . __FUNCTION__)
|| $scope->getScenario()->hasTag('behat-steps-skip:StateTrait')
) {
$this->stateOriginalValues = [];
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

$state = \Drupal::state();
foreach ($this->stateOriginalValues as $name => $snapshot) {
if ($snapshot['exists']) {
$state->set($name, $snapshot['value']);
}
else {
$state->delete($name);
}
}

$this->stateOriginalValues = [];
}

/**
* Set a Drupal state value.
*
* @code
* Given the state "my_module.launched" has the value "1"
* @endcode
*/
#[Given('the state :name has the value :value')]
public function stateSet(string $name, string $value): void {
$this->stateStoreOriginalValue($name);
\Drupal::state()->set($name, $this->stateNormaliseValue($value));
}

/**
* Delete a Drupal state value.
*
* @code
* Given the state "my_module.launched" does not exist
* @endcode
*/
#[Given('the state :name does not exist')]
public function stateDelete(string $name): void {
$this->stateStoreOriginalValue($name);
\Drupal::state()->delete($name);
}

/**
* Set multiple Drupal state values from a table.
*
* @code
* Given the following state values:
* | name | value |
* | my_module.launched | 1 |
* | my_module.feature_flag | 0 |
* @endcode
*/
#[Given('the following state values:')]
public function stateSetMultiple(TableNode $table): void {
$state = \Drupal::state();
foreach ($table->getHash() as $row) {
if (!isset($row['name']) || !array_key_exists('value', $row)) {
throw new \RuntimeException('The state values table must contain "name" and "value" columns.');
}
$name = $row['name'];
$this->stateStoreOriginalValue($name);
$state->set($name, $this->stateNormaliseValue($row['value']));
}
}

/**
* Assert that a Drupal state value equals an expected value.
*
* @code
* Then the state "my_module.launched" should have the value "1"
* @endcode
*/
#[Then('the state :name should have the value :value')]
public function stateAssertHasValue(string $name, string $value): void {
$state_value = $this->stateReadValue($name);
if (!$state_value['exists']) {
throw new \Exception(sprintf('The state "%s" does not exist, but it should have the value "%s".', $name, $value));
}

$expected = $this->stateNormaliseValue($value);
$actual_stringified = $this->stateStringifyValue($state_value['value']);
$expected_stringified = $this->stateStringifyValue($expected);
if ($actual_stringified !== $expected_stringified) {
throw new \Exception(sprintf('The state "%s" has the value "%s", but it should have the value "%s".', $name, $actual_stringified, $expected_stringified));
}
}

/**
* Assert that a Drupal state key does not exist.
*
* @code
* Then the state "my_module.launched" should not exist
* @endcode
*/
#[Then('the state :name should not exist')]
public function stateAssertNotExists(string $name): void {
$state_value = $this->stateReadValue($name);
if ($state_value['exists']) {
throw new \Exception(sprintf('The state "%s" exists with the value "%s", but it should not exist.', $name, $this->stateStringifyValue($state_value['value'])));
}
}

/**
* Read a state value, distinguishing stored NULL from a missing key.
*
* Uses the underlying key/value store's `has()` so that a legitimately
* stored NULL is reported as existing. `\Drupal::state()->get()` cannot
* distinguish the two cases because it applies the `??` operator to
* the loaded value and returns the default for NULL.
*
* @param string $name
* The state key name.
*
* @return array{exists: bool, value: mixed}
* An associative array with `exists` (bool) and `value` (mixed).
*/
protected function stateReadValue(string $name): array {
$key_value = \Drupal::keyValue('state');

if (!$key_value->has($name)) {
return ['exists' => FALSE, 'value' => NULL];
}

return ['exists' => TRUE, 'value' => \Drupal::state()->get($name)];
}

/**
* Store the original state value for a key on first access.
*
* @param string $name
* The state key name.
*/
protected function stateStoreOriginalValue(string $name): void {
if (array_key_exists($name, $this->stateOriginalValues)) {
return;
}

$this->stateOriginalValues[$name] = $this->stateReadValue($name);
}

/**
* Normalise a string value from a step into the shape actually stored.
*
* @param string $value
* The raw value captured from the step or table cell.
*
* @return mixed
* The normalised value: decoded JSON for array/object input, integer or
* float for numeric input, boolean for "true"/"false", NULL for "null",
* or the original string otherwise.
*/
protected function stateNormaliseValue(string $value): mixed {
$trimmed = trim($value);

if ($trimmed === '') {
return $value;
}

$lower = strtolower($trimmed);
if ($lower === 'true') {
return TRUE;
}
if ($lower === 'false') {
return FALSE;
}
if ($lower === 'null') {
return NULL;
}

if ($trimmed[0] === '{' || $trimmed[0] === '[') {
$decoded = json_decode($trimmed);
if (json_last_error() === JSON_ERROR_NONE) {
return $decoded;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

if (is_numeric($trimmed)) {
return str_contains($trimmed, '.') ? (float) $trimmed : (int) $trimmed;
}
Comment on lines +233 to +235
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

In PHP 8.x, what does (int) "1e3"return, and what is the recommended way to parse numeric strings that may include exponent notation (e.g.,1e3)?

💡 Result:

  • (int) "1e3" in PHP 8.x returns: 1 (the cast to int parses only the leading decimal digits and stops at the non-integer part, so the exponent is not applied). [1]

  • Recommended way to parse numeric strings that may include exponent notation (like 1e3):

    1. Validate as a numeric/float string, then parse as float, then convert as needed:
      $f = filter_var($s, FILTER_VALIDATE_FLOAT);
      if ($f === false) { /* invalid */ }
      $i = (int)$f; // "1e3" -> 1000
      FILTER_VALIDATE_FLOAT is intended for safe float validation+conversion. [3]
    2. Alternatively, for a quick check, is_numeric($s) accepts scientific notation, then you can cast to float:
      if (!is_numeric($s)) { /* invalid */ }
      $i = (int)(float)$s;
      [2]

Sources: PHP “Numeric strings” manual (scientific notation rules) [1], is_numeric() manual [2], PHP RFC noting FILTER_VALIDATE_INT/FLOAT for validated casts [3].


🏁 Script executed:

# Examine the actual file around the specified lines
cd /tmp/repo 2>/dev/null || cd . 
if [ -f "src/Drupal/StateTrait.php" ]; then
  echo "=== Lines 225-245 from StateTrait.php ===" 
  sed -n '225,245p' src/Drupal/StateTrait.php
else
  echo "File not found, searching for StateTrait.php..."
  find . -name "StateTrait.php" -type f 2>/dev/null | head -5
fi

Repository: drevops/behat-steps

Length of output: 567


🏁 Script executed:

# Also check the full method signature and context around lines 233-235
if [ -f "src/Drupal/StateTrait.php" ]; then
  echo "=== Full stateNormaliseValue method ===" 
  rg -A 30 "function stateNormaliseValue" src/Drupal/StateTrait.php
fi

Repository: drevops/behat-steps

Length of output: 777


🏁 Script executed:

# Search for any test coverage of numeric normalization
if [ -d "tests" ] || [ -d "test" ]; then
  echo "=== Search for tests covering numeric normalization ===" 
  rg "stateNormaliseValue|1e3|scientific|exponent" tests/ 2>/dev/null || \
  rg "stateNormaliseValue|1e3|scientific|exponent" test/ 2>/dev/null || \
  echo "No tests found with those patterns"
fi

Repository: drevops/behat-steps

Length of output: 372


Handle scientific-notation numbers correctly during normalization.

In stateNormaliseValue(), the expression is_numeric('1e3') returns true, but the check str_contains($trimmed, '.') is false, so the code executes (int) '1e3', which returns 1 instead of 1000. This silently miscasts valid numeric strings with exponent notation.

Use preg_match('/^[+-]?\d+$/', $trimmed) to distinguish pure integers from other numeric formats (floats, exponent notation), routing the latter to (float) conversion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Drupal/StateTrait.php` around lines 233 - 235, The numeric normalization
currently uses str_contains($trimmed, '.') to decide between int and float in
stateNormaliseValue(), which miscasts scientific notation like "1e3"; update the
logic in stateNormaliseValue() so that after is_numeric($trimmed) you use a
regex check (e.g. preg_match('/^[+-]?\d+$/', $trimmed)) to detect pure integers
and cast those to (int), otherwise cast to (float) (this will correctly handle
floats and exponent notation).


return $value;
}

/**
* Stringify a state value for comparison and error messages.
*
* @param mixed $value
* The value to stringify.
*
* @return string
* The stringified value.
*/
protected function stateStringifyValue(mixed $value): string {
if ($value === NULL) {
return 'NULL';
}
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if (is_scalar($value)) {
return (string) $value;
}
return (string) json_encode($value);
}

}
2 changes: 2 additions & 0 deletions tests/behat/bootstrap/FeatureContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
use DrevOps\BehatSteps\Drupal\ParagraphsTrait;
use DrevOps\BehatSteps\Drupal\QueueTrait;
use DrevOps\BehatSteps\Drupal\SearchApiTrait;
use DrevOps\BehatSteps\Drupal\StateTrait;
use DrevOps\BehatSteps\Drupal\TaxonomyTrait;
use DrevOps\BehatSteps\Drupal\TestmodeTrait;
use DrevOps\BehatSteps\Drupal\TimeTrait;
Expand Down Expand Up @@ -86,6 +87,7 @@ class FeatureContext extends DrupalContext {
use RestTrait;
use ResponsiveTrait;
use SearchApiTrait;
use StateTrait;
use TableTrait;
use TaxonomyTrait;
use TestmodeTrait;
Expand Down
Loading