tests.test_plugin module

Conformance of the Claude Code plugin manifest, marketplace and archive.

Claude Code ignores manifest fields it does not recognize and resolves component paths lazily, so a typo (agent for agents) or a path that stopped existing loads a plugin that is simply missing half its content. Nothing surfaces that at runtime, which is what these tests are for.

Caution

The sharpest edge is that claude plugin validate --strict passes a manifest whose agents path loads zero agents (see the plugin module docstring), so validation alone proves nothing about the archive being usable. test_archive_uses_the_default_component_dirs() and test_manifest_declares_no_component_paths() are what keep the layout on the shape that was verified to actually load.

tests.test_plugin.MANIFEST_FIELDS = frozenset({'$schema', 'agents', 'author', 'commands', 'dependencies', 'description', 'displayName', 'homepage', 'hooks', 'keywords', 'license', 'lspServers', 'mcpServers', 'monitors', 'name', 'outputStyles', 'repository', 'settings', 'skills', 'themes', 'userConfig', 'version'})

Top-level fields the plugin manifest schema recognizes.

Mirrors the published schema. Claude Code silently ignores anything else, so an unknown key here is almost always a misspelling of a real one rather than a deliberate addition.

tests.test_plugin.ARCHIVE_VERSION = '1.2.3'

Stand-in version for the packing tests, distinct from the package’s own __version__ so a stamped manifest cannot pass by coincidence.

tests.test_plugin.VERSION_RE = re.compile('^\\d+\\.\\d+\\.\\d+$')

A released version, bare because it names a package rather than a tag.

Both the manifest and the catalog entry carry one, and both are written by the release freeze, so neither may hold a .devN suffix.

tests.test_plugin.manifest() → dict[str, Any][source]

The checked-in plugin manifest, parsed.

Return type:

dict[str, Any]

tests.test_plugin.marketplace() → dict[str, Any][source]

The checked-in marketplace catalog, parsed.

Return type:

dict[str, Any]

tests.test_plugin.marketplace_raw() → str[source]

The checked-in marketplace catalog, as text the freeze can rewrite.

Return type:

str

tests.test_plugin.test_manifest_identity(manifest)[source]

The manifest names the plugin the constants and the docs promise.

Return type:

None

tests.test_plugin.test_manifest_pins_the_last_released_version(manifest)[source]

The manifest names a release, written by the freeze and never by hand.

A git-subdir marketplace source publishes this file as it stands, and claude plugin validate --strict fails a manifest with no version at all. Claude Code treats the string as the update signal, so a hand-maintained one left stale would strand every user on the plugin they already have.

Return type:

None

tests.test_plugin.test_manifest_fields_are_recognized(manifest)[source]

No manifest field is one Claude Code would silently ignore.

Return type:

None

tests.test_plugin.test_manifest_declares_no_component_paths(manifest, field)[source]

The manifest carries metadata only, never a component path.

Every asset already sits at the spec’s default location, precisely so these fields are unnecessary. Re-introducing one is a regression rather than a refactor: an agents path passes claude plugin validate --strict and then loads zero agents.

Return type:

None

tests.test_plugin.test_marketplace_identity(marketplace)[source]

The catalog names the marketplace and plugin the install command uses.

Return type:

None

tests.test_plugin.MARKETPLACE_REFS = ('main', re.compile('^v\\d+\\.\\d+\\.\\d+$'))

The two shapes the entry’s ref is ever allowed to take.

The default branch is the unfrozen state, which is what lets the app’s Sync automatically observe a change at all. A v-prefixed release tag is what PrepareRelease.freeze_marketplace_pin writes for the release commit, and it is the state this test sees when the suite runs from a released tree. Never a vX.Y.Z.devN tag, which was never created and would resolve against nothing.

tests.test_plugin.test_marketplace_installs_from_the_plugin_root(marketplace)[source]

The single entry publishes PLUGIN_ROOT from a pinned tag.

An archive source was the previous shape here, and it syncs only in the CLI: the claude.ai ingester rejects the type outright, which is what made every Desktop and Cowork marketplace sync fail. git-subdir is one of the three types it does accept, and the narrowest of them.

Return type:

None

tests.test_plugin.test_marketplace_entry_carries_a_version(marketplace)[source]

The entry pins a version, which is what update detection reads.

Bumping ref alone leaves the app’s Update button greyed out: detection compares this string, not the manifest the source resolves to. Hand-writing it would strand everyone on the plugin they already had, so the freeze writes it beside the ref it is already moving.

Return type:

None

tests.test_plugin.test_marketplace_pin_moves_on_the_next_freeze(tmp_path, marketplace_raw)[source]

The next release freeze lands both halves of the pin on that release.

Asserted by running the freeze rather than by comparing against the checked-in values, which legitimately name the last published release rather than the version under development.

Return type:

None

tests.test_plugin.test_plugin_root_is_installable_as_it_stands(marketplace)[source]

The directory the entry names really is a plugin root.

A git-subdir source publishes the tree verbatim, with no packing step to rearrange it, so the manifest and both component directories have to be exactly where the spec scans for them. Moving any of the three without moving the marketplace path would ship a catalog entry resolving to a directory Claude Code loads nothing from.

Return type:

None

tests.test_plugin.test_pack_plugin_layout(tmp_path)[source]

The archive holds one top-level folder, with the manifest inside it.

Return type:

None

tests.test_plugin.test_pack_plugin_ships_every_registered_asset(tmp_path)[source]

Exactly the manifest plus every registered agent and skill, nothing else.

Return type:

None

tests.test_plugin.test_archive_uses_the_default_component_dirs(tmp_path)[source]

Assets land at the spec’s default locations, not their .claude/ paths.

This is the invariant that makes the plugin actually load: a manifest pointing at a custom agents path validates cleanly and contributes no agents. Verified against Claude Code 2.1.220 with claude plugin details, which reported Agents (0) for the custom-path layout and Agents (3) for this one.

Return type:

None

tests.test_plugin.test_archive_skill_folders_keep_their_subdirectories(tmp_path)[source]

A skill’s own references/, scripts/ and assets/ travel with it.

No bundled skill has one yet, so this covers the relocation arithmetic with a synthetic tree rather than waiting for the first skill to grow one and finding out in a release.

Return type:

None

tests.test_plugin.test_pack_plugin_stamps_the_version(tmp_path)[source]

The packaged manifest carries the version it was packed with.

Return type:

None

tests.test_plugin.test_pack_plugin_defaults_to_the_running_version(tmp_path)[source]

Omitting the version stamps the one the package reports.

Return type:

None

tests.test_plugin.test_pack_plugin_is_byte_deterministic(tmp_path)[source]

Re-packing an unchanged tree produces an identical archive.

With no sha256 pin in the marketplace entry, the archive digest is what Claude Code falls back to, so a nondeterministic pack would advertise an update on every release whether or not anything changed.

Return type:

None

tests.test_plugin.test_pack_plugin_creates_missing_parents(tmp_path)[source]

A destination in a directory that does not exist yet still packs.

Return type:

None

tests.test_plugin.test_pack_plugin_without_a_manifest(tmp_path)[source]

A tree with no manifest fails loudly instead of packing a broken plugin.

Return type:

None

tests.test_plugin.test_settings_wiring_registers_and_enables()[source]

A fresh document gets both the marketplace and the enablement entry.

Return type:

None

tests.test_plugin.test_settings_wiring_is_format_json_shaped()[source]

Serialized with the tab indent and sorted keys format-json imposes.

Return type:

None

tests.test_plugin.test_biome_json_indent_follows_the_declared_formatter(tmp_path, files, expected)[source]

The indent is read from wherever repomatic run biome would read it.

Return type:

None

tests.test_plugin.test_merge_plugin_settings_converges_under_the_declared_style(tmp_path, declared, indent)[source]

The write agrees with format-json, so the two cannot undo each other.

A repository declaring spaces used to get a tab-indented document back from every run, which format-json reindented right back, leaving sync-repomatic and the autofix lane opening pull requests against each other forever.

Return type:

None

tests.test_plugin.test_merge_plugin_settings_leaves_a_biome_formatted_document_alone(tmp_path)[source]

A collapsed short array is not drift, so the wiring rewrites nothing.

json.dumps expands every array one item per line, while Biome collapses one that fits its line width. Comparing the rendered text reported a difference on a document format-json had already settled, so the two rewrote each other on every run. Matching the indent alone never closed that gap.

Return type:

None

tests.test_plugin.test_settings_wiring_preserves_existing_content(prior, survivor)[source]

Merging never drops what a repository already declared.

Return type:

None

tests.test_plugin.test_settings_wiring_replaces_a_non_object_section()[source]

A malformed section is replaced rather than raising, so init converges.

Return type:

None

tests.test_plugin.test_settings_wiring_rejects_a_non_object_document()[source]

A settings file that is not an object is a mistake worth reporting.

Return type:

None

tests.test_plugin.test_merge_plugin_settings_creates_then_converges(tmp_path)[source]

First call writes the file, the second is a no-op.

Return type:

None