Skip to content

Abilities API: Add a core/settings-get ability - #12141

Open
jorgefilipecosta wants to merge 46 commits into
WordPress:trunkfrom
jorgefilipecosta:add/core-settings-ability
Open

jorgefilipecosta wants to merge 46 commits into
WordPress:trunkfrom
jorgefilipecosta:add/core-settings-ability

Conversation

@jorgefilipecosta

@jorgefilipecosta jorgefilipecosta commented Jun 9, 2026 •

Copy link
Copy Markdown
Member

What?

Port of WordPress/ai#691 (renamed in WordPress/ai#1087), part of WordPress/ai#40. Adds a read-only core/settings-get ability.

  • register_setting() gets a show_in_abilities argument, false by default. Like show_in_rest, it can be an array with name and schema keys. register_initial_settings() flags 19 settings, such as blogname, posts_per_page, and default_comment_status.
  • core/settings-get returns the exposed settings as a flat map of name to value, optionally filtered by group, fields, or both. It requires manage_options.
  • Values are read as /wp/v2/settings reads them: validated against their schema, left out when the schema rejects them, and sanitized otherwise.
  • The initial settings are also registered on wp_abilities_api_init, so the ability finds them on cron, WP-CLI, and before rest_api_init.
  • The code lives in the private WP_Abilities_Settings class, which the planned core/settings-update ability (Add a core/settings-update ability ai#764) can share.

Testing

Verify unit tests are passing:

npm run test:php -- --group abilities-api

Manual test steps: #12141 (comment)

AI disclosure: issues found via AI-assisted code review; the fixes and this description were drafted with AI assistance (Claude Code) and reviewed by me.

Trac ticket: https://core.trac.wordpress.org/ticket/64605

@github-actions

github-actions Bot commented Jun 9, 2026 •

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props jorgefilipecosta, gziolo, justlevine.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

@jorgefilipecosta jorgefilipecosta changed the title Abilities API: Add a core/settings ability [in progress] Abilities API: Add a core/settings ability Jun 9, 2026
@justlevine

Copy link
Copy Markdown

@jorgefilipecosta can you link this to the trac ticket please? I don't have edit perms in this repo.
https://core.trac.wordpress.org/ticket/64605

@jorgefilipecosta
jorgefilipecosta force-pushed the add/core-settings-ability branch from 073df67 to c948c7a Compare June 12, 2026 15:01
@gziolo
gziolo self-requested a review June 15, 2026 12:31
@jorgefilipecosta

Copy link
Copy Markdown
Member Author

@jorgefilipecosta can you link this to the trac ticket please? I don't have edit perms in this repo. core.trac.wordpress.org/ticket/64605

Nice catch the ticket mention was added.

@jorgefilipecosta
jorgefilipecosta force-pushed the add/core-settings-ability branch from c948c7a to a28c976 Compare June 15, 2026 19:32
@jorgefilipecosta jorgefilipecosta changed the title [in progress] Abilities API: Add a core/settings ability Abilities API: Add a core/settings ability Jun 16, 2026
@gziolo

gziolo commented Jun 17, 2026

Copy link
Copy Markdown
Member

Let's run development, review, and testing through WordPress/ai#691, then sync all agreed refinements here.

@jorgefilipecosta
jorgefilipecosta force-pushed the add/core-settings-ability branch 3 times, most recently from 24bee86 to 6419a95 Compare June 23, 2026 15:23
@jorgefilipecosta jorgefilipecosta changed the title Abilities API: Add a core/settings ability Abilities API: Add a core/read-settings ability Jul 1, 2026

@gziolo gziolo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I left my feedback.

Two questions regarding core/get-site-info:

  • It covers some similar settings but is scoped to the site. Should it also respect the show_in_abilities check wherever applicable?
  • Should it get the default value in the schema aligned to (object) array() as here?

Comment thread src/wp-includes/abilities/class-wp-settings-abilities.php Outdated
Comment thread src/wp-includes/abilities/class-wp-abilities-settings.php Outdated
*/
private function register_get_settings(): void {
// Compute once; execute_get_settings() reuses this exact structure.
$this->exposed_settings = $this->get_exposed_settings();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I noticed that Core settings get registered on the rest_api_init hook:

add_action( 'rest_api_init', 'register_initial_settings', 10 );

It's worth double-checking whether there won't be a race condition with wp_abilities_api_init.

The long-term question is whether get_exposed_settings() could be computed lazily inside the execute/schema callbacks instead of being cached at registration? That would remove the ordering coupling and match the sibling's live‑compute model.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch on the ordering. wp_abilities_api_init fires lazily and isn't ordered relative to rest_api_init, so register() now calls register_initial_settings() before computing the snapshot whenever rest_api_init hasn't fired (or is mid-fire before priority 10). Added the guard in ae862f0 and documented the timing in 59e1819; re-registering later on rest_api_init is harmless.

On computing it lazily: I kept the snapshot cached at registration on purpose. The input schema, output schema, and execute callback all have to derive from the exact same set of exposed settings, so computing it once avoids walking get_registered_settings() three times per request and any risk of the schema and the output drifting apart. With the ordering handled explicitly there's no coupling left to remove, so I dropped the unreachable recompute branch in 13f4d31.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Following your later suggestion, core settings are now registered on wp_abilities_api_init, so the ability does not handle this anymore 👍

Comment thread tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsAbility.php Outdated
@jorgefilipecosta

Copy link
Copy Markdown
Member Author

I left my feedback.

Two questions regarding core/get-site-info:

  • It covers some similar settings but is scoped to the site. Should it also respect the show_in_abilities check wherever applicable?
  • Should it get the default value in the schema aligned to (object) array() as here?

Hi @gziolo I think the answer is yes to both, but I would prefer to do that in a separate PR to avoid this one being too huge.

@jorgefilipecosta

Copy link
Copy Markdown
Member Author

All the reviews comments were applied this is ready for another look.

@gziolo

gziolo commented Jul 13, 2026 •

Copy link
Copy Markdown
Member

One bad setting value fails the whole core/read-settings call

A behavior question to seal with a test.

execute() validates the full output against the schema, so one stored value that does not match its schema makes the entire call return a WP_Error. The client then reads none of the settings.

This is reachable. default_ping_status and default_comment_status use an open|closed enum, and their stored value can drift outside it (direct update_option(), imports, old data). sanitize_option() only maps '0' and '' to 'closed', so a value like 'not-a-valid-status' sticks. admin_email (format: email) is a second, lower chance trigger.

Commit 714d164 handled this by validating and sanitizing each value against its schema (like WP_REST_Settings_Controller::prepare_value()) and dropping only the bad one. The sync commit a6167bf swapped it back to cast_value() to match the AI plugin, which brought the problem back.

A test that pins it down:

public function test_core_read_settings_drops_values_that_fail_their_schema(): void {
	$this->become_admin();

	// sanitize_option() only coerces '0' and '' to 'closed', so this out-of-enum value sticks.
	update_option( 'default_ping_status', 'not-a-valid-status' );

	$result = wp_get_ability( 'core/read-settings' )->execute( array() );

	$this->assertNotWPError( $result, 'One bad value must not fail the whole ability.' );
	$this->assertArrayHasKey( 'blogname', $result );               // others still returned
	$this->assertArrayNotHasKey( 'default_ping_status', $result ); // only the bad one dropped
}

On this branch it fails:

Ability "core/read-settings" has invalid output. Reason: output[default_ping_status] is not one of open and closed.

With the 714d164 approach restored, it passes.

Decision: what should happen for a value that does not match its schema?

  • A (my preference): drop that value and return the rest. The test above encodes this.
  • B: keep the cast and accept the whole call failing. If so, let's add a test that documents this on purpose.

The plugin class mirrors the core class, so the same choice should land there too. Happy to help with the plugin side.


Side question (independent from the above): the sibling abilities core/get-site-info, core/get-user-info, and core/get-environment-info still use 'default' => array() for their input schema, while core/read-settings uses 'default' => (object) array(). So an empty type: object default serializes as [] for them and {} here. Worth aligning them to (object) array() too? It does not depend on the decision above, so maybe it is cleaner as its own small commit.

@gziolo

gziolo commented Jul 13, 2026 •

Copy link
Copy Markdown
Member

Consider wiring register_initial_settings on wp_abilities_api_init too

The ability builds its schema from the registered settings at registration time, so those settings must exist when wp_abilities_api_init fires. Today register_initial_settings is only hooked to rest_api_init, and that action fires lazily inside rest_get_server() when the REST server is first built. On cron, WP-CLI, or any direct ability call that never builds the REST server, the settings are not registered yet. That is why register() calls register_initial_settings() itself with the did_action/doing_action guard.

It works, but it makes the ability responsible for core's bootstrap ordering. A cleaner shape could be to treat the settings as a shared dependency and wire them into both subsystems that use them:

add_action( 'rest_api_init',         'register_initial_settings', 10 );
add_action( 'wp_abilities_api_init', 'register_initial_settings', 1 ); // before core abilities (priority 10)

To make this safe to run from more than one hook, register_initial_settings() could get a small internal check that returns early when the core settings are already registered. register_setting() appends to $new_allowed_options on every call, so without such a guard a second run duplicates those entries and re-fires the register_setting action. The check should look at the actual registration state (for example whether a known core setting like blogname is present) rather than a static flag, so it stays correct when the registry is reset, as the tests do.

With that in place the settings register once and correctly in both cases, REST and Abilities, and the ability no longer needs its own did_action/doing_action logic.

What do you think? If it sounds good I am happy to help wire it up.

// Compute once; execute_get_settings() reuses this exact structure.
$this->exposed_settings = $this->get_exposed_settings();

$settings = $this->exposed_settings;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If there are no settings registered, then there is no need to register the ability as it won't return anything anyway.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The ability is now not registered when no setting is exposed 👍

*
* @access private
*/
final class WP_Settings_Abilities {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nitpick, here and in other open PRs, it might make more sense to follow naming conventions from other places and use WP_Abilities_ prefix and follow with class-wp-abilities- as file name. This would better mirror how it would look when using namespaces: WordPress/Abilities/Settings.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Renamed to WP_Abilities_Settings 👍

@gziolo

gziolo commented Jul 13, 2026

Copy link
Copy Markdown
Member

Thanks @jorgefilipecosta! Nice work on this. The port is faithful to the plugin, the option.php change is clean and well-documented, and CI is green. We are nearly there.

The remaining points are the ones I raised above. The main one is the bad-value handling, since it needs an actual fix here and in the plugin so they stay in sync. The rest are shared conventions and small polish: the WP_Abilities_ class naming (across the abilities PRs) and the empty-object input default (across the sibling abilities). Once the bad-value case is settled, this is good to go from my side.

The AI plugin named the write ability `core/settings-update` (see
WordPress/ai#764), so update the comments that
still called it `core/manage-settings`.
An empty PHP array is encoded as `[]`, so an ability whose output schema
describes an object, such as `core/settings-get` when its filters match no
setting, answered with a JSON array. When the output schema describes an
object, send an empty object instead, so the response matches the schema.
PHP callers still receive the plain array.

Suggested in WordPress/ai#764, where
`core/settings-update` worked around it with an `(object)` cast.
This reverts commit 60c7a8f.

Keep `core/settings-get` the same as in the AI plugin, which answers an
empty result as a plain array. Sending empty object results as `{}` is a
general Abilities API change, which can be proposed on its own.
Abilities can initialize before or without `rest_api_init`, where the
initial settings are registered, for example on cron or WP-CLI. So far
`WP_Abilities_Settings::register()` registered them itself. Register them
from a `wp_abilities_api_init` callback at priority 1 instead, before the
core abilities, so the settings ability no longer handles the settings
bootstrap.

The callback keeps the existing checks: it does nothing once the REST API
has registered the settings, and it restores `$new_allowed_options`, so
saving Settings > General does not try to save `admin_email` (see
WordPress/ai#1080).
@jorgefilipecosta

jorgefilipecosta commented Oct 6, 2026 •

Copy link
Copy Markdown
Member Author

Tested core/settings-get on a 7.2-alpha-64115 nightly with this PR applied, using wp.apiFetch in the browser console in wp-admin.

All exposed settings. Values are typed, and match /wp/v2/settings:

await wp.apiFetch( { path: '/wp-abilities/v1/abilities/core/settings-get/run' } );
{
	"blogname": "My WordPress Website",
	"blogdescription": "",
	"siteurl": "http://127.0.0.1:9412",
	"admin_email": "admin@localhost.com",
	"timezone_string": "",
	"date_format": "F j, Y",
	"time_format": "g:i a",
	"start_of_week": 1,
	"WPLANG": "en_US",
	"use_smilies": true,
	"default_category": 1,
	"default_post_format": "0",
	"posts_per_page": 10,
	"show_on_front": "posts",
	"page_on_front": 0,
	"page_for_posts": 0,
	"default_ping_status": "open",
	"default_comment_status": "open"
}

By group:

await wp.apiFetch( { path: '/wp-abilities/v1/abilities/core/settings-get/run?input[group]=reading' } );
{ "posts_per_page": 10, "show_on_front": "posts", "page_on_front": 0, "page_for_posts": 0 }

By setting name:

await wp.apiFetch( { path: '/wp-abilities/v1/abilities/core/settings-get/run?input[fields][]=blogname&input[fields][]=posts_per_page' } );
{ "blogname": "My WordPress Website", "posts_per_page": 10 }

Both, which returns their intersection:

await wp.apiFetch( { path: '/wp-abilities/v1/abilities/core/settings-get/run?input[group]=reading&input[fields][]=blogname&input[fields][]=posts_per_page' } );
{ "posts_per_page": 10 }

A stored value that fails its schema is left out. After storing a value outside the enum, e.g. with wp option update default_ping_status not-a-valid-status:

await wp.apiFetch( { path: '/wp-abilities/v1/abilities/core/settings-get/run?input[group]=discussion' } );
{ "default_comment_status": "open" }

/wp/v2/settings answers null for that setting.

Invalid input (400):

await wp.apiFetch( { path: '/wp-abilities/v1/abilities/core/settings-get/run?input[group]=nope' } );
// ability_invalid_input: Ability "core/settings-get" has invalid input. Reason: input[group] is not one of general, writing, reading, and discussion.

A user without manage_options (403), here a subscriber:

await wp.apiFetch( { path: '/wp-abilities/v1/abilities/core/settings-get/run' } );
// rest_ability_cannot_execute: Sorry, you are not allowed to execute this ability.

Tested and written with AI assistance (Claude Code).

…ts."

This reverts commit ed2bfee.

`core/get-site-info`, `core/get-user-info`, and `core/get-environment-info`
shipped in 7.1 with an `array()` input default. As an object, the default
reaches `wp_ability_normalize_input` filters as a `stdClass`, so a filter
that reads it as an array breaks, and a filter that changes it changes the
schema default for every later call. Aligning these defaults is a general
Abilities API change, which can be proposed on its own.
`cast_value()` cast each stored value to its type before validating it, so
some values were read differently from `/wp/v2/settings`: a stored `'false'`
came back as `true`, a `stdClass` as `{}`, a list with gaps as a JSON
object, and `'abc'` in an integer setting as `0` instead of being left out.

As `WP_REST_Settings_Controller::prepare_value()` does, validate the stored
value against its schema, leave it out when the schema rejects it, and
sanitize it otherwise. Object values are still cast to objects, so an empty
one is sent as `{}`.

A plain `get_option()` already returns the registered default through
`filter_default_option`, so the exposed settings no longer keep it.
…get.

A setting registered with a type outside the JSON types, such as `foo`,
was added to the output schema and triggered `_doing_it_wrong()` on every
run. As `WP_REST_Settings_Controller::get_registered_options()` does, only
expose settings of a type the settings endpoint supports.
`wp_page_for_privacy_policy` is registered in the `reading` group next to
`page_on_front` and `page_for_posts`, which `core/settings-get` already
exposes. Flag it with `show_in_abilities` too.
- Start the exposed settings as an empty array, which removes the
  unreachable null check in `execute_get_settings()`.
- Collect the groups and the output schema properties with
  `array_column()` and `wp_list_pluck()` instead of a loop.
- Read the setting type and group without the checks that
  `register_setting()` already guarantees, and the exposed name as the
  settings endpoint does.
- Check `$show['schema']` with `isset()` alone.
- Use the `site` category directly, as the other core abilities do,
  instead of a constant used once.
"Settings Get" follows the ability name but does not read as a label. The
other core abilities start with the verb, as in "Get Site Information".
The AI plugin uses the same label (see WordPress/ai#764).
Describe the `register_setting()` argument by its shape and the one rule
integrators need, registering the setting on `init` or earlier. Drop a
comment that would go stale once more settings abilities are added.
jorgefilipecosta added a commit to WordPress/ai that referenced this pull request Oct 6, 2026
- Start the exposed settings as an empty array, which removes the
  unreachable null check and the (array) casts.
- Collect the groups and the output schema properties with
  array_column() and wp_list_pluck() instead of a loop.
- Read the setting type and group without the checks that
  register_setting() already guarantees, and the exposed name as the
  settings endpoint does.
- Check $show['schema'] with isset() alone.
- Use the site category directly instead of a constant.
- Add null to the setting type directly in update_value_schema(), now
  that the type is always one the settings endpoint supports.

Matches the core port in
WordPress/wordpress-develop#12141.
This reverts commit 7f149df.

The AI plugin labels the ability "Settings Get", in line with its other
object-first abilities, since WordPress/ai#1087,
which this PR carries over.
The other core abilities default their object input to `array()`, so
use the same default here, instead of an object.
jorgefilipecosta added a commit to WordPress/ai that referenced this pull request Oct 6, 2026
- Start the exposed settings as an empty array, which removes the
  unreachable null check and the (array) casts.
- Collect the groups and the output schema properties with
  array_column() and wp_list_pluck() instead of a loop.
- Read the setting type and group without the checks that
  register_setting() already guarantees, and the exposed name as the
  settings endpoint does.
- Check $show['schema'] with isset() alone.
- Use the site category directly instead of a constant.
- Add null to the setting type directly in update_value_schema(), now
  that the type is always one the settings endpoint supports.

Matches the core port in
WordPress/wordpress-develop#12141.
@jorgefilipecosta

jorgefilipecosta commented Oct 6, 2026 •

Copy link
Copy Markdown
Member Author

Hi @gziolo, thank you for the reviews, the feedback was applied:

  • A value that does not match its schema is now left out and the other settings are returned (option A).
  • Core settings are now also registered on wp_abilities_api_init with priority 1. A small private function restores $new_allowed_options so fix: prevent error on general settings save ai#1080 does not come back.
  • The class was renamed to WP_Abilities_Settings, the ability is not registered when no setting is exposed, and the isset suggestion was applied.
  • The rename and label from Rename the settings ability to core/settings-get ai#1087 were carried over.
  • wp_page_for_privacy_policy is now also exposed.

Let me know if there is anything else 👍

Restore the default that 656b453 changed to `array()`. An object is serialized as `{}` wherever the schema is read, and it keeps the class identical to the AI plugin's, which supports WordPress 7.0, where the abilities endpoints send an empty array default as `[]`.
@gziolo

gziolo commented Oct 7, 2026

Copy link
Copy Markdown
Member

jorgefilipecosta#91 – we should consider offering a solid strategy around settings registration that is predictable not only for WP core registered settings but also for those coming from plugins. I propsed we enforce that enabling show_in_abilities warns when setting gets registered after init making it clear when to register the setting.

jorgefilipecosta and others added 4 commits October 7, 2026 10:20
WordPress stores `false` as `''`, which `rest_is_boolean()` rejects, so
`core/settings-get` left out every boolean setting whose value is false,
including a Settings API checkbox saved unchecked. The settings endpoint
answers `null` for it.

Read `''` as `false` for a boolean setting before validating it.
`WP_REST_Settings_Controller::get_registered_options()` runs
`rest_default_additional_properties_to_false()` on every setting schema,
so objects reject properties they do not declare. `core/settings-get`
skipped that step, so it returned a stored object with an undeclared
property whole, where the settings endpoint answers `null`.

Close the schema in `value_schema()`, so the ability reads values, and
describes them in its output schema, as the endpoint does.
The input schema accepts an object, but `execute_get_settings()` replaced
anything other than an array with an empty one, so a PHP caller that
passed `(object) array( 'group' => 'reading' )` got every setting back.

Normalize the input with `rest_sanitize_object()`.
… exposure.

Set the `public` meta flag, as the other core abilities do. This
carries over WordPress/ai#1122. In core, `public` also enables
`show_in_rest`.

When `show_in_abilities` is `true`, a setting now uses the same name
and schema as in `show_in_rest`. An array is still used as is. All core
settings use `true`, so they share their names and schemas with the
REST API settings endpoint. For example, `blogname` is exposed as
`title` and `WPLANG` as `language`, and the email and enum schemas are
no longer repeated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@gziolo

gziolo commented Oct 7, 2026

Copy link
Copy Markdown
Member

I pushed 749c2eb with two changes.

1. The ability is now public.

core/settings-get now sets 'public' => true, like the other core abilities. This carries over WordPress/ai#1122. In core, public also enables show_in_rest, so the old flag is no longer needed.

2. show_in_abilities => true reuses the REST API setup.

When show_in_abilities is true, the setting now uses the same name and schema as in show_in_rest. When it is an array, it is used as is.

Because of this, all core settings now use only 'show_in_abilities' => true. We no longer repeat the email format or the open|closed enum. The names now match /wp/v2/settings:

Option Ability name
blogname title
blogdescription description
siteurl url
admin_email email
timezone_string timezone
WPLANG language
wp_page_for_privacy_policy page_for_privacy_policy

The other settings already had the same name in both APIs.

One small side effect: siteurl now also gets the uri format from REST. If the stored value is not a valid URI, the ability leaves it out, like the REST API does.

New tests check that the names match the REST API, and that an array in show_in_abilities replaces the REST arguments.

Two follow-ups:

  • The PR description and the manual test comment still use the old names, like blogname.
  • The AI plugin still uses the old names, so it needs a similar change.

gziolo and others added 2 commits October 7, 2026 12:15
…fault.

Match the other core abilities. wp_prepare_json_schema_for_client()
already sends an empty object default as `{}`, for both the REST API
and the AI client. With an array, the execute callback also gets an
array when no input is given.

Add tests for how wp_prepare_json_schema_for_client() handles an
empty default on the root object schema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a separate PR.

They cover existing behavior of the helper, so they do not belong to this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`set_up_before_class()` swaps the core abilities registration hooks and
clears the `rest_api_init` count, and only `tear_down_after_class()` put
them back. The first test of a run snapshots the hooks, and every test
then restores that snapshot, so whenever this class ran first, its
changes leaked into every later test. Put the hooks and the count back
right after registering the abilities.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants