From 5191a9ad551fe4227070cfc9239a8ac464d4cc24 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 02:16:42 +0000 Subject: [PATCH 01/10] docs: Rewrite the plugin documentation for the current plugin framework The plugin framework page predated the current plugin API (it still described the JSii approach, and carried a banner saying it was inaccurate). Replace it with a new top-level Plugins section that documents the framework as it is today, written so that a plugin can be implemented without prior knowledge of ContractCase internals: - The anatomy of a plugin: the ContractCasePlugin object, the description fields and why each name exists, package structure and the -dsl split, and type namespacing rules - Writing matchers: descriptors, the four executor functions and their invariants, context modification, descending into children, and the match context - Writing mock types: descriptors, the write/read setup block, the mock executor lifecycle, mockConfig, and a worked example from the core function plugin - Declaring your DSL: the PluginDslDeclaration data model (noting the generator itself is still work in progress) - Loading and distributing plugins: loadPlugins, module name restrictions, load-time checks, and a pre-publishing checklist The old /docs/reference/plugin-framework URL is kept alive as the contract file format reference, since published API documentation links to it for the matcher format. Also adds maintainer todo notes for the gaps found while writing these docs: plugin resolution from the Java connector's temp directory, loadPlugins missing from the TS DSL, default-export unwrapping in BoundaryPluginLoader, and completing the DSL generator CLI. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014mD6p9EEmftbpBQHZZBWB1 --- docs/maintainers/todo.md | 22 +- .../docs/Alternatives/differences-to-pact.md | 2 +- .../docs/plugins/dsl-generation.md | 227 +++++++++++ .../docs/plugins/loading-plugins.md | 142 +++++++ .../documentation/docs/plugins/plugins.mdx | 125 +++++++ .../docs/plugins/writing-a-plugin.md | 183 +++++++++ .../docs/plugins/writing-matchers.md | 353 ++++++++++++++++++ .../docs/plugins/writing-mocks.md | 351 +++++++++++++++++ .../docs/reference/plugin-framework.md | 220 +++-------- 9 files changed, 1450 insertions(+), 175 deletions(-) create mode 100644 packages/documentation/docs/plugins/dsl-generation.md create mode 100644 packages/documentation/docs/plugins/loading-plugins.md create mode 100644 packages/documentation/docs/plugins/plugins.mdx create mode 100644 packages/documentation/docs/plugins/writing-a-plugin.md create mode 100644 packages/documentation/docs/plugins/writing-matchers.md create mode 100644 packages/documentation/docs/plugins/writing-mocks.md diff --git a/docs/maintainers/todo.md b/docs/maintainers/todo.md index b1bf287d6..a5cc1a797 100644 --- a/docs/maintainers/todo.md +++ b/docs/maintainers/todo.md @@ -36,6 +36,26 @@ versions, as they are listed in source code / generated documentation. ## Todo list +Plugins: + +- [ ] Test that the Java connector resolves plugins from the project-local + `node_modules`. The connector bundle is extracted to a temp directory and + run from there, so bare-specifier `import()` may resolve from the wrong + place - needs an end-to-end test loading a plugin by name from a Java + project. +- [ ] Expose `loadPlugins` in the TS DSL (currently only the Java DSL exposes + it) +- [ ] Unwrap the module namespace when loading plugins in + `BoundaryPluginLoader` - a dynamic `import()` namespace never exposes + `description` etc for plugins built with `export default` or + `module.exports = plugin` (only individual named exports work), so the + documented "default export one `ContractCasePlugin` object" shape needs + `.default` unwrapping, plus an end-to-end test loading a third-party + plugin by name. +- [ ] Complete the DSL generator so plugin authors can run it - hook it into + the CLI and remove the hardcoded paths in + `case-definition-generator/src/index.ts`. + - [ ] Close verifier should return results - [ ] Deprecate and remove flat tests results - [ ] TS Generated Matchers @@ -145,7 +165,7 @@ Good for when I don't want to think - [ ] case-context ? Maybe move this out - [ ] case-contract format ? maybe move this out too -- [ ] Extending case +- [x] Extending case - [x] Vs e2e - [x] Vs schema - [x] Vs pact diff --git a/packages/documentation/docs/Alternatives/differences-to-pact.md b/packages/documentation/docs/Alternatives/differences-to-pact.md index e92541a2e..f7b7b5a79 100644 --- a/packages/documentation/docs/Alternatives/differences-to-pact.md +++ b/packages/documentation/docs/Alternatives/differences-to-pact.md @@ -83,7 +83,7 @@ This means that extending ContractCase is significantly easier - to add new mock types, implement one function and one DSL object. To add new matcher type, there are three functions, and one DSL object to implement. -See the documentation on [extending ContractCase](/docs/reference/plugin-framework) for details. +See the documentation on [extending ContractCase](/docs/plugins/) for details. # Unsupported things diff --git a/packages/documentation/docs/plugins/dsl-generation.md b/packages/documentation/docs/plugins/dsl-generation.md new file mode 100644 index 000000000..4dcdab06f --- /dev/null +++ b/packages/documentation/docs/plugins/dsl-generation.md @@ -0,0 +1,227 @@ +--- +sidebar_position: 4 +sidebar_label: 'Declaring your DSL' +--- + +# Declaring your DSL + +Your plugin's executors run inside the ContractCase core - but your users +write their tests in their own language, against user-facing classes and +functions (the DSL). Somebody has to provide those classes in every language +your users want. + +Rather than asking plugin authors to hand-write and hand-maintain a DSL per +language, ContractCase lets a plugin _declare_ its DSL as data: the `dsl` +property of the plugin object is a `PluginDslDeclaration` describing every +user-facing matcher and interaction, with enough type and documentation +information to generate idiomatic classes in each supported language +(currently TypeScript and Java). + +Declaring the DSL as data has a purpose beyond saving typing: it means there +is **one source of truth**. The language bindings can't drift from each other +in behaviour or documentation, because they're all generated from the same +declaration - and when you add a parameter, every language gets it in the +same release. + +:::caution Work in progress + +The DSL generator itself is work in progress: the generator library exists and +is tested, but it can't yet be invoked outside the ContractCase repository +(there's no CLI hooked up yet). Until that lands, DSL classes for your plugin +need to be written by hand - see +[Hand-writing your DSL](#hand-writing-your-dsl-in-the-meantime) below. + +We recommend writing the `dsl` declaration anyway: it's the documentation of +your user-facing surface, and your plugin will pick up generated DSLs when the +generator is completed. If you're blocked on this, please +[open an issue](https://github.com/case-contract-testing/contract-case/issues/new) +so we can prioritise appropriately. + +::: + +## The declaration + +```ts +import { PluginDslDeclaration } from '@contract-case/case-plugin-base'; + +export const dsl: PluginDslDeclaration = { + namespace: 'yourorg', + category: 'identifiers', + matchers: [ + /* MatcherDslDeclaration[] */ + ], + interactions: [ + /* InteractionDslDeclaration[] */ + ], +}; +``` + +- `namespace` is the prefix for all the type constants in this declaration - + the generator produces type strings of the form `${namespace}:${type}`. It + must be unique to you; we recommend the GitHub organisation or username that + hosts your plugin's repository. (The core plugins share the reserved + namespace `_case`.) +- `category` groups related declarations, and determines where generated + classes land - for example, the package name + `io.contract_testing.contractcase.dsl.matchers.` in Java. +- `matchers`, `interactions` and (rarely) `states` are the declarations + themselves. + +### Declarations don't need to map 1:1 to executors + +More than one DSL declaration may share the same `type` constant. This is +deliberate, and the core function plugin makes heavy use of it: it declares +four matcher DSL classes over two matcher executors, and eight interaction +DSL classes over two mock executors. Use this when you want different names, +different defaults, or different parameter shapes in the DSL for what is +ultimately the same executor - the DSL is for humans, and executors are for +the engine, so there's no reason to force them into the same shape. + +## Declaring an object + +Matchers, interactions and states share a common base: + +```ts +{ + name: 'AnyUlid', // The generated class name, in CamelCase + type: 'AnyUlid', // The type constant, without the namespace + documentation: 'Matches any ULID string.', + params: [ /* ParameterDeclaration[] */ ], +} +``` + +`documentation` is required. Yes, really - it becomes the doc comment on the +generated class in every language, and the users of your plugin won't be sorry +about that. + +### Parameters + +Each parameter is declared with a name, documentation (also required), and a +type: + +```ts +{ + name: 'example', + documentation: 'An optional example ULID to use when writing the contract.', + type: 'string', + optional: true, +} +``` + +- `name` must be alphanumeric camelCase, and unique within the declaration. + Beware of a few names with special behaviour: `type` is reserved, and + `example` / `resolvesTo` are allowed but map to the + [special matcher keys](./writing-matchers#parameters-and-special-keys) of + the same name. (For our ULID matcher, that's exactly what we want.) +- `optional` parameters must come last, since some target languages express + optionality by omitting trailing arguments. +- By default, a parameter is written into the descriptor JSON as + `_case:matcher:` (or `_case:mock:` for interactions). Set + `jsonPropertyName` to override this - for example, the function plugin maps + its `arguments` parameter to the plain `request` key. + +The available types are the scalars (`'string'`, `'number'`, `'integer'`, +`'boolean'`, `'null'`), `'AnyData'` (any JSON value), `'AnyCaseMatcherOrData'` +(any JSON value _or any matcher_ - the workhorse type for parameters that +users will want to nest matchers inside), arrays of any of these +(`{ kind: 'array', type: ... }`), and `PassToMatcher` (below). + +### Composing matchers with `PassToMatcher` + +`PassToMatcher` declares a parameter whose values are passed to _another +matcher's_ constructor, letting you build composite DSL classes whose +generated constructors take flat, friendly arguments. For example, the +function plugin's interactions take a `returnValue` argument, which the +generated code wraps in a `FunctionReturnValue` matcher for you: + +```ts +const returnValue: ParameterDeclaration = { + name: 'returnValue', + jsonPropertyName: 'response', + documentation: 'The return value of this function.', + type: { + kind: 'PassToMatcher', + exposedParams: [ + { + name: 'returnValue', + documentation: 'The return value of this function.', + type: 'AnyCaseMatcherOrData', + }, + ], + matcherReference: { + namespace: '_case', + name: 'FunctionReturnValue', + category: 'functions', + }, + }, +}; +``` + +The `exposedParams` are what the user sees; they're passed positionally to the +constructor of the matcher named by `matcherReference`. The generator does no +checking that the referenced matcher exists or that the parameters line up, so +we recommend only referencing matchers from your own plugin, where you control +both ends. + +## Extra properties on matcher declarations + +Matcher declarations can also carry: + +- `constantParams` - parameters that are always the same for every instance, + written into the descriptor but not exposed in the constructor. The special + key `resolvesTo` sets + [`_case:matcher:resolvesTo`](./writing-matchers#parameters-and-special-keys), + which also makes the generated DSL more precisely typed. +- `contextModifiers` - entries written under `_case:context:*`, for + [context-modifying matchers](./writing-matchers#modifying-the-context) like + `shapedLike`. +- `currentRunModifiers` - entries written under `_case:currentRun:context:*`, + for matchers that change the run configuration below them (like the + log-level changing matcher). Most plugins won't need these. + +## Interaction declarations + +Interactions additionally declare their +[`setup` block](./writing-mocks#one-interaction-two-sides-the-setup-block): + +```ts +{ + name: 'WillReceiveFunctionCall', + type: 'MockFunctionCaller', + documentation: '...', + setup: { + write: { type: '_case:MockFunctionCaller', stateVariables: 'state', triggers: 'generated' }, + read: { type: '_case:MockFunctionExecution', stateVariables: 'default', triggers: 'provided' }, + }, + params: [ /* ... */ ], +} +``` + +The full declaration for the core function plugin is a good worked example of +everything on this page - see +[its source](https://github.com/case-contract-testing/contract-case/blob/main/packages/case-core-plugin-function/src/dsl/functions.ts). + +## What the generator produces + +For TypeScript, each declaration becomes an interface plus a factory function. +For Java, each declaration becomes a builder-style class with Jackson +annotations mapping fields to the descriptor's JSON keys, implementing the +marker interfaces (`DslMatcher`, `DslInteraction`, `DslState`) that the Java +DSL's type signatures require. In both cases, `PassToMatcher` parameters are +collapsed - the generated constructor accepts the exposed parameters and +constructs the inner matcher itself. + +## Hand-writing your DSL (in the meantime) + +Until the generator can be run outside the ContractCase repository: + +- **TypeScript users** can use the plain factory functions from your `-dsl` + package directly (like the `anyUlid` function in + [writing matchers](./writing-matchers#the-dsl-function)) - descriptors are + just JSON, so no generation is strictly necessary. +- **Java users** need hand-written classes: plain objects whose Jackson + `@JsonProperty` annotations produce exactly your descriptor's keys + (including `_case:matcher:type` / `_case:mock:type`), implementing + `DslMatcher` or `DslInteraction` as appropriate. The + [generated classes in the Java DSL](https://github.com/case-contract-testing/contract-case/tree/main/packages/dsl-java/src/main/java/io/contract_testing/contractcase/dsl) + show the expected shape. diff --git a/packages/documentation/docs/plugins/loading-plugins.md b/packages/documentation/docs/plugins/loading-plugins.md new file mode 100644 index 000000000..20eb9a438 --- /dev/null +++ b/packages/documentation/docs/plugins/loading-plugins.md @@ -0,0 +1,142 @@ +--- +sidebar_position: 5 +sidebar_label: 'Loading and distributing' +--- + +# Loading and distributing plugins + +This page covers the user side of plugins: how a plugin gets loaded into a +test run, and what to know when distributing one. + +## Loading a plugin + +A plugin is distributed as an npm package, and loaded by package name. Two +steps: + +1. Install the package locally, alongside your test suite: + + ```bash + npm install --save-dev @yourorg/contract-case-plugin-ulid + ``` + +2. Ask ContractCase to load it, before running any interactions that use it. + From the Java DSL: + + ```java + ContractDefiner definer = new ContractDefiner(config); + definer.loadPlugins("@yourorg/contract-case-plugin-ulid"); + ``` + + and on the verification side: + + ```java + ContractVerifier verifier = new ContractVerifier(config); + verifier.loadPlugins("@yourorg/contract-case-plugin-ulid"); + ``` + +`loadPlugins` accepts multiple names if you're loading more than one plugin. +Loading is idempotent - loading the same plugin (at the same version) twice +is harmless, and the second load is skipped. + +:::caution WARNING + +The JavaScript/TypeScript DSL doesn't yet expose `loadPlugins` - currently +only the Java DSL does. This is an oversight rather than a design decision, +and will be fixed in an upcoming release. + +::: + +### Both sides need the plugin + +The matcher and mock type constants from your plugin are written into the +contract file. That means a contract defined using a plugin can only be +_verified_ by a run that has the same plugin loaded - otherwise the verifier +has no executor for those types, and will fail with a configuration error. + +If you're distributing a contract to another team, their verification suite +needs to install and load your plugin too. Plugin authors should say this +prominently in their installation instructions. + +### Plugin names must be plain package names + +For security reasons, plugin names must be the name of a locally-installed +node package (optionally scoped, optionally with a subpath) - remote URLs, +inline URIs, absolute paths and relative paths are all intentionally +rejected, with the error code `INVALID_PLUGIN_NAME`. + +Why so strict? Loading a plugin executes its code. If plugin specifiers could +be URLs, then anything able to influence the specifier - a malicious contract +file, or a compromised client of a contract server - could load arbitrary +remote code into your test process. Restricting specifiers to +already-installed packages means nothing can be loaded that you didn't +explicitly install. + +### What happens at load time + +When a plugin loads, ContractCase checks: + +- **Version consistency**: if a plugin with the same + [`uniqueMachineName`](./writing-a-plugin#the-description-object) was already + loaded at a _different_ version, loading fails - a single test run can't + reason about two versions of the same plugin. +- **Type registration**: each matcher and mock type the plugin provides is + registered in the engine's registry. Registering a type that another loaded + plugin already claimed is a configuration error (this is why + [namespacing your types](./writing-a-plugin#namespacing-your-types) + matters), and a non-core plugin registering a `_case:`-prefixed mock type is + rejected outright. + +The core plugins (HTTP and function calls) are loaded automatically on every +run - you never need to load them yourself. + +## Configuring a loaded plugin + +Users configure mocks from a plugin via the +[`mockConfig` configuration property](../reference/configuring#mockconfig-object), +keyed by the plugin's `shortName` - see +[plugin configuration](./writing-mocks#plugin-configuration-mockconfig) for +the author's side of this. Plugin authors should document their `shortName` +and supported configuration keys alongside their installation instructions. + +## Distributing a plugin + +Some things to know before publishing: + +- **Export shape.** The package's entry point must export the assembled + [`ContractCasePlugin` object](./writing-a-plugin) as + its default export, and that object is the plugin's whole runtime API. +- **Dependencies.** Depend on `@contract-case/case-plugin-base` and + `@contract-case/case-plugin-dsl-types` only - the rest of the ContractCase + packages are internal. +- **Version compatibility.** ContractCase is + [in beta](../package-versioning), and minor versions of + `@contract-case/case-plugin-base` may contain breaking changes to the + plugin API. Document which ContractCase versions your plugin release + supports, and expect to release in step with ContractCase minors until + 1.0.0. There is currently no automated compatibility check between a + third-party plugin and the core, so a clear compatibility statement in your + README is what your users will rely on. +- **Contract stability.** Your type constants and descriptor shapes are + written into your users' contract files, which may be stored and verified + long after they were defined. Changing them is a breaking change for your + users' _contracts_, not just their code - see the + [notes on namespacing](./writing-a-plugin#namespacing-your-types). +- **DSLs for other languages.** If your users write tests in Java, you'll + also need to ship the Java DSL classes for your matchers and interactions - + see [Declaring your DSL](./dsl-generation) for the current state of DSL + generation. + +### A pre-publishing checklist + +- [ ] All matcher and mock type constants are prefixed with your own + namespace (never `_case:`) +- [ ] `uniqueMachineName` is prefixed with something you control, and doesn't + start with the core plugin prefix (`_CaseCore:`) +- [ ] `version` is a semantic version string, and matches your package + version +- [ ] The plugin object is the package's default export +- [ ] Configuration is validated with helpful `CaseConfigurationError` + messages that name the exact `mockConfig` key to fix +- [ ] Your README documents: the `shortName` and configuration keys, which + ContractCase versions are supported, and that verifiers of contracts + written with your plugin also need it installed diff --git a/packages/documentation/docs/plugins/plugins.mdx b/packages/documentation/docs/plugins/plugins.mdx new file mode 100644 index 000000000..ac570e4ce --- /dev/null +++ b/packages/documentation/docs/plugins/plugins.mdx @@ -0,0 +1,125 @@ +--- +sidebar_label: 'Plugins' +sidebar_position: 8 +--- + +# Extending ContractCase with plugins + +ContractCase is plugin-first: every interaction type and matcher that ships +with ContractCase is implemented using the same plugin API that is available to +you. The HTTP mocks and the function-call mocks are both plugins (you can read +their source [here](https://github.com/case-contract-testing/contract-case/tree/main/packages) - +any package with `core-plugin` in the name). This means the plugin API isn't a +restricted second-class extension point - anything the core interaction types +can do, your plugin can do. + +This section describes how to write, load and distribute your own plugins. It's +written so that you can implement a plugin without any prior knowledge of the +ContractCase internals. + +:::tip note + +If you're planning a plugin, we'd love to hear about it - please say hello by +opening [an issue](https://github.com/case-contract-testing/contract-case/issues/new). +This is doubly useful while the plugin framework is in beta, as we can let you +know about any upcoming changes that might affect you. + +If your extension is likely to be of general use, consider making a pull +request to add it to the core plugins instead. + +::: + +## What can a plugin do? + +A plugin can extend ContractCase with: + +- **New matcher types**: the nodes in the tree that describe the expected data + in an interaction (for example, "any integer", or "an array of at least two + users"). You might add a matcher for a data format that ContractCase doesn't + understand yet - say, matching the timestamp portion of a [ULID](https://github.com/ulid/spec). +- **New mock types**: the executable part of an interaction - the thing that + pretends to be the other side of the communication during a test (for + example, a mock HTTP server). You might add a mock type for a new transport - + say, gRPC or a message queue. +- **DSL declarations**: descriptions of the user-facing classes and functions + for your matchers and mocks, so that they can be generated in each language + that ContractCase supports. + +## How plugins fit into the architecture + +Plugins are always written in TypeScript (or JavaScript), and run inside the +ContractCase core engine - _no matter which language the user's test suite is +written in_. A Java test suite talks to the core engine over a gRPC connector, +and the core engine loads your plugin locally: + +```mermaid +flowchart LR + subgraph host ["User's test suite (any language)"] + TEST["Test code"] --> DSL["ContractCase DSL
(Java, TypeScript, ...)"] + end + subgraph core ["ContractCase core engine (Node.js)"] + ENGINE["Matching engine"] + HTTP["Core HTTP plugin"] + FN["Core function plugin"] + YOURS["Your plugin"] + ENGINE --- HTTP + ENGINE --- FN + ENGINE --- YOURS + end + DSL <-->|"gRPC connector"| ENGINE + ENGINE <--> CONTRACT[("Contract file")] +``` + +This architecture is a deliberate choice, for two reasons: + +1. **One implementation, identical behaviour.** The contract file must mean + exactly the same thing during definition and during verification - even + when the two sides are written in different languages. If matchers were + reimplemented per-language, subtle behaviour differences would creep in, and + a contract that passed on one side could fail on the other for reasons that + have nothing to do with the services under test. +2. **Write once, available everywhere.** Because the behaviour lives in one + place, adding a new language to ContractCase doesn't require porting every + plugin. A plugin author writes the behaviour once, and (with a + [DSL declaration](./dsl-generation)) the user-facing classes can be + generated for each language. + +The trade-off is that the user-facing DSL classes for your plugin do need to +exist in each language your users want. See +[Declaring your DSL](./dsl-generation) for how this works. + +## What's in this section + +1. [The anatomy of a plugin](./writing-a-plugin) - the plugin object, how it's + structured, and the naming rules you'll need to follow +2. [Writing matchers](./writing-matchers) - adding new matcher types +3. [Writing mock types](./writing-mocks) - adding new interaction types +4. [Declaring your DSL](./dsl-generation) - describing your user-facing + classes so they can be generated in each supported language +5. [Loading and distributing plugins](./loading-plugins) - how users load your + plugin, and what to know before publishing it + +You may also want the description of the +[contract file format](../reference/plugin-framework) - useful background, +since your matchers and mock descriptors are written into the contract file. + +## Stability + +ContractCase is in beta, and the plugin framework is the least stable part of +the API surface - see the [versioning policy](../package-versioning). Breaking +changes to the plugin API are indicated by minor version bumps of +[`@contract-case/case-plugin-base`](https://www.npmjs.com/package/@contract-case/case-plugin-base). + +:::caution WARNING + +Some parts of the plugin workflow are still works in progress: + +- The [DSL generator](./dsl-generation) can't yet be run outside the + ContractCase repository, so DSL classes for your plugin currently need to be + written by hand. +- The JavaScript/TypeScript DSL doesn't yet expose `loadPlugins` (the Java DSL + does). This is an oversight and will be fixed in a future release. + +Both of these are described in more detail on their relevant pages. + +::: diff --git a/packages/documentation/docs/plugins/writing-a-plugin.md b/packages/documentation/docs/plugins/writing-a-plugin.md new file mode 100644 index 000000000..1ffbc826d --- /dev/null +++ b/packages/documentation/docs/plugins/writing-a-plugin.md @@ -0,0 +1,183 @@ +--- +sidebar_position: 1 +sidebar_label: 'The anatomy of a plugin' +--- + +# The anatomy of a plugin + +A ContractCase plugin is an npm package whose default export is a single +object of type `ContractCasePlugin`. Everything the plugin provides hangs off +this one object: + +```ts +import { ContractCasePlugin } from '@contract-case/case-plugin-base'; + +const YourPlugin: ContractCasePlugin< + MatcherTypes, // A union of string constants for your matcher types + MockTypes, // A union of string constants for your mock types + MatcherDescriptors, // A union of your matcher descriptor object types + MockDescriptors, // A union of your mock descriptor object types + AllSetupInfo // A union of the setup info objects your mocks provide +> = { + description, // Names and version for this plugin + matcherExecutors, // How your matchers behave, keyed by matcher type + setupMocks, // How your mocks behave, keyed by mock type + dsl, // (Optional) declares your user-facing DSL for generation +}; + +export default YourPlugin; +``` + +For a real example, see the assembly of the +[core function plugin](https://github.com/case-contract-testing/contract-case/blob/main/packages/case-core-plugin-function/src/index.ts). + +The pieces are: + +- `description` - the plugin's names and version, described below. +- `matcherExecutors` - a map from each of your matcher type constants to the + [matcher executor](./writing-matchers) implementing its behaviour. +- `setupMocks` - a map from each of your mock type constants to the + [mock executor](./writing-mocks) implementing its behaviour. +- `dsl` - an optional [DSL declaration](./dsl-generation), so that the + user-facing classes for your matchers and mocks can be generated in each + supported language. + +The rest of this section covers each of these in detail. This page covers the +concepts that apply to the whole plugin: how ContractCase models interactions, +the description object, how to structure your packages, and the naming rules +that keep plugins from colliding with each other. + +## One model: matchers are data, executors are behaviour + +There's only one model in ContractCase - it's used in the contract file, to +run contract tests, and to extend ContractCase with plugins. Understanding it +makes the rest of the plugin API unsurprising: + +- **Matchers are immutable JSON data.** A matcher describes an expectation + ("any integer", "an HTTP request to `/health`"), and is written into the + contract file exactly as the DSL produced it. Matchers are recursive - a + matcher's parameters may themselves contain matchers or literal data. +- **Executors are behaviour.** An executor is the code that interprets a + matcher (or mock descriptor) at test time. Executors live in plugins and are + looked up by the matcher's type constant. + +This split is why a contract file written today can be verified later, on +another machine, in another language: the file contains only data, and any +engine with the right plugins loaded can interpret it. + +Each interaction in the contract file (called an `example` in the file format) +has three parts - the states it needs, the description of the mock (containing +the matcher trees), and the result of the interaction when it was defined. See +the [contract file format](../reference/plugin-framework) for the details of +how these are written down. + +## The description object + +The `description` property identifies your plugin: + +```ts +import { PluginDescription } from '@contract-case/case-plugin-base'; + +export const description: PluginDescription = { + humanReadableName: 'ULID Matcher Plugin', + shortName: 'ulid', + uniqueMachineName: 'yourorg:contract-case-plugin-ulid', + version: '1.0.0', +}; +``` + +Each field exists for a different audience, which is why there are three +different names: + +- `humanReadableName` is for people - it's printed in log and error messages + about your plugin. +- `shortName` is for your users' configuration - it's the key that users + write under the [`mockConfig` configuration property](../reference/configuring#mockconfig-object) + to configure your mocks. It should be reasonably unique, but doesn't have to + be globally unique: if two plugins deliberately share configuration, it's + fine (and useful) for them to share a `shortName`. +- `uniqueMachineName` is for the engine - it's how ContractCase reasons about + which plugins are loaded. It **must** be unique in the whole plugin + ecosystem, because two plugins with the same `uniqueMachineName` can't be + loaded in the same contract. For this reason, we recommend namespacing it + with a prefix you control - for example, the GitHub organisation or username + that hosts your plugin's repository. +- `version` must be a [semantic version](https://semver.org/) string. At load + time, ContractCase uses it to detect conflicts: loading the same + `uniqueMachineName` at the same version twice is fine (the second load is + skipped), but loading it at two _different_ versions is a configuration + error. + +:::caution WARNING + +Don't start your `uniqueMachineName` with the core plugin prefix +(`_CaseCore:`, exported as `CORE_PLUGIN_PREFIX`). It's how ContractCase +recognises its own built-in plugins - core plugins log less debug information, +and failures loading them are treated as crashes in ContractCase rather than +user configuration errors. If your plugin uses this prefix, load failures and +logging won't be handled appropriately. + +::: + +## Namespacing your types + +Every matcher type and mock type constant in the ecosystem shares one global +registry, so the type constants your plugin defines must not collide with +anyone else's: + +- All matcher and mock types provided by ContractCase itself are prefixed with + `_case:` (for example `_case:MatchInteger`, `_case:MockHttpServer`). This + prefix is reserved: a non-core plugin that tries to register a `_case:` mock + type will fail to load with a configuration error. +- Prefix your own type constants with a namespace you control, in the same + style: `yourorg:AnyUlid`, `yourorg:MockMessageQueue`. + +Because type constants are written into the contract file, they're part of +your plugin's public API - renaming one is a breaking change that makes +previously-written contracts unverifiable. Choose them carefully. + +## Package structure + +Two packages from the ContractCase monorepo are relevant to plugin authors: + +- [`@contract-case/case-plugin-base`](https://www.npmjs.com/package/@contract-case/case-plugin-base) + provides the types for the plugin itself (`ContractCasePlugin`, + `MatcherExecutor`, `MockExecutor`, `MatchContext`) plus the helper functions + you'll use to implement executors (error constructors, result combinators, + the describe helpers, and so on). +- [`@contract-case/case-plugin-dsl-types`](https://www.npmjs.com/package/@contract-case/case-plugin-dsl-types) + provides the types that describe data in the contract file + (`AnyCaseMatcherOrData`, `AnyMockDescriptor`, and friends), without dragging + in any engine behaviour. + +Your plugin should depend on these two packages only - in particular, don't +import `@contract-case/case-entities` or `@contract-case/case-core`; they're +internal, and their APIs change without notice. + +### Split your plugin into two packages + +We recommend structuring a plugin as two packages, following the pattern of +the core plugins (eg `case-core-plugin-function` and +`case-core-plugin-function-dsl`): + +- **`your-plugin-dsl`** contains only data definitions: the type constants, + the TypeScript interfaces for your matcher and mock descriptors, and plain + factory functions that build them. It depends only on + `case-plugin-dsl-types`. +- **`your-plugin`** contains the behaviour: the executors and the assembled + plugin object. It depends on `your-plugin-dsl` (for the constants and + descriptor types) and on `case-plugin-base`. + +The reason for the split: packages that provide user-facing DSLs need your +type constants and descriptor shapes so they can construct descriptors that +your executors will understand - but they shouldn't need to pull in the +matching engine (or your executors) to do it. Keeping the descriptor +definitions in a leaf package with almost no dependencies keeps every +downstream DSL light, and guarantees the DSL and the executors agree on the +wire format, because they import the same constants. + +## Where to next + +With the skeleton in place, the next two pages cover the two halves of the +plugin's behaviour: [writing matchers](./writing-matchers) and +[writing mock types](./writing-mocks). diff --git a/packages/documentation/docs/plugins/writing-matchers.md b/packages/documentation/docs/plugins/writing-matchers.md new file mode 100644 index 000000000..d726b4ff8 --- /dev/null +++ b/packages/documentation/docs/plugins/writing-matchers.md @@ -0,0 +1,353 @@ +--- +sidebar_position: 2 +sidebar_label: 'Writing matchers' +--- + +# Writing matchers + +A matcher has two halves, following the +[one model](./writing-a-plugin#one-model-matchers-are-data-executors-are-behaviour) +design: + +1. **The matcher descriptor** - immutable JSON data describing the + expectation. This is what your DSL produces, and what gets written into the + contract file. +2. **The matcher executor** - the behaviour, implemented as four side-effect + free functions that interpret the descriptor at test time. + +This page builds a small worked example: `yourorg:AnyUlid`, a matcher that +accepts any [ULID](https://github.com/ulid/spec) string. + +## Designing the descriptor + +First, define a constant for the type of the matcher. This is how ContractCase +finds the right executor at match time: + +```ts +export const ANY_ULID_TYPE = 'yourorg:AnyUlid' as const; +``` + +Remember that matchers provided by ContractCase are prefixed with `_case:`, and +that this prefix is reserved - prefix your own matcher types with +[a namespace you control](./writing-a-plugin#namespacing-your-types). + +Next, export an interface that describes the matcher JSON. This is exactly +what will be written to the contract file: + +```ts +export interface AnyUlidMatcher { + readonly '_case:matcher:type': typeof ANY_ULID_TYPE; + readonly '_case:matcher:example'?: string; +} +``` + +The `'_case:matcher:type'` key is what makes an object a matcher: during +traversal, any object containing it is dispatched to the matching executor, +and any object without it is treated as literal data. This is also why all +ContractCase metadata is namespaced under `_case:` - it can never collide +with real user data, so users can safely match objects containing any keys of +their own. + +### Parameters and special keys + +Your matcher's parameters can either be namespaced (`'_case:matcher:rule'`, +`'_case:matcher:minLength'`) or plain keys (`arguments`, `functionName`) - +both styles appear in the core plugins. Prefer the namespaced style for +parameters that configure the matcher, and plain keys only where the +descriptor deliberately mirrors user-visible structure (the way the function +plugin's descriptors use `request` and `response`). + +Some `_case:matcher:` keys have meanings that ContractCase itself understands, +so don't reuse them for anything else: + +| Key | Meaning | +| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `_case:matcher:type` | The type constant, used to find the executor | +| `_case:matcher:example` | An example value for this matcher, eg provided by the user with `withExample`. If present, error messages prefer it, and your `strip` implementation should too | +| `_case:matcher:resolvesTo` | Declares the type the matcher resolves to (`'string'`, `'number'`, ...), letting parent matchers and DSL generators reason about the example without executing it | +| `_case:matcher:uniqueName` | Names this matcher so it's saved in the contract's lookup table and can be referenced elsewhere | +| `_case:matcher:child` | Conventionally, the single child of a wrapping matcher | + +### Modifying the context + +Matchers can also carry keys prefixed with `_case:context:`. These aren't +parameters - they're modifications to the [match context](#the-match-context) +that ContractCase automatically folds in before your executor runs. Because +matching is recursive, the modified context flows down to every matcher below +this one in the tree. + +This is how `shapedLike` and `exactlyLike` work - they're a single "cascading +context" matcher whose only job is to set `'_case:context:matchBy'` to +`'type'` or `'exact'` for the subtree below them: + +```ts +export const exactlyLike = (content: AnyCaseMatcherOrData): CoreCascadingMatcher => ({ + '_case:matcher:type': CASCADING_CONTEXT_MATCHER_TYPE, + '_case:matcher:child': content, + '_case:context:matchBy': 'exact', +}); +``` + +If your matcher's behaviour should influence how everything beneath it +matches, prefer setting a context key over threading parameters by hand - it +composes with matchers from other plugins that know nothing about yours. + +## The DSL function + +Users don't write descriptors by hand - provide a factory that builds them: + +```ts +/** + * Matches any ULID string. + * + * @param example - An optional example ULID to use when writing the contract. + */ +export const anyUlid = (example?: string): AnyUlidMatcher => ({ + '_case:matcher:type': ANY_ULID_TYPE, + ...(example != null ? { '_case:matcher:example': example } : {}), +}); +``` + +Following the [recommended package structure](./writing-a-plugin#split-your-plugin-into-two-packages), +the constant, the interface and this factory all belong in your `-dsl` +package. To make your matcher available in other languages, see +[Declaring your DSL](./dsl-generation). + +If your matcher needs additional processing - for example, combining several +matchers into a composite - do it in the DSL layer, producing a tree of plain +descriptors. The descriptors themselves must stay data-only. + +## The matcher executor + +The behaviour is an object with exactly four functions, typed by your constant +and descriptor: + +```ts +export interface MatcherExecutor { + describe: NameMatcherFn; // Describes the matcher in english + check: CheckMatchFn; // Checks the matcher against actual data + strip: StripMatcherFn; // Returns the example data this matcher represents + validate: ValidateMatcherFn; // Validates the matcher's own parameters +} +``` + +There are four functions because a matcher is used at four different moments - +not just during matching. ContractCase strips matchers to produce example +data, describes them to name interactions and build lookup keys, and validates +them before a run starts. Here's the complete executor for our ULID example: + +```ts +import { + CheckMatchFn, + StripMatcherFn, + ValidateMatcherFn, + NameMatcherFn, + MatcherExecutor, + CaseConfigurationError, + matchingError, + errorWhen, + describeMessage, +} from '@contract-case/case-plugin-base'; +import { AnyData } from '@contract-case/case-plugin-dsl-types'; + +const ULID_REGEX = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/; +const DEFAULT_EXAMPLE = '01ARZ3NDEKTSV4RRFFQ69G5FAV'; + +const check: CheckMatchFn = (matcher, matchContext, actual) => + Promise.resolve( + errorWhen( + typeof actual !== 'string' || !ULID_REGEX.test(actual), + matchingError( + matcher, + `'${actual}' is not a ULID string`, + actual, + matchContext, + ), + ), + ); + +const strip: StripMatcherFn = (matcher): AnyData => + matcher['_case:matcher:example'] != null + ? matcher['_case:matcher:example'] + : DEFAULT_EXAMPLE; + +const validate: ValidateMatcherFn = (matcher, matchContext) => + Promise.resolve().then(() => { + const example = matcher['_case:matcher:example']; + if (example != null && !ULID_REGEX.test(example)) { + throw new CaseConfigurationError( + `The example '${example}' given to anyUlid is not itself a valid ULID`, + matchContext, + 'BAD_INTERACTION_DEFINITION', + ); + } + }); + +const describe: NameMatcherFn = () => + describeMessage('any ULID string'); + +export const AnyUlidMatcherExecutor: MatcherExecutor< + typeof ANY_ULID_TYPE, + AnyUlidMatcher +> = { describe, check, strip, validate }; +``` + +Register it on your plugin object, keyed by the type constant: + +```ts +matcherExecutors: { + [ANY_ULID_TYPE]: AnyUlidMatcherExecutor, +}, +``` + +Some important properties of these functions, and the reasons behind them: + +### All four functions must be side-effect free + +ContractCase may call any of them repeatedly on the same data during a run - +for example, `strip` is called both to render examples in error messages and +during pre-verification. Don't modify the matcher descriptor, and don't keep +state between calls. + +### `check` returns errors as values, it doesn't throw + +`check` returns a `MatchResult`, which is an array of `CaseError` objects - +an empty array means the match passed. This is deliberate: a matcher tree +should report _every_ mismatch in a run, not abort at the first one, and +accumulating errors as values is the natural model for that. + +Build results only with the helpers from `case-plugin-base`: + +- `matchingError(matcher, message, actual, context)` creates an error (the + expected value for the error message is computed for you, preferring + `_case:matcher:example` if present) +- `errorWhen(condition, error)` returns an erroring result if the condition + is true, and a passing one otherwise +- `makeNoErrorResult()` / `makeResults(...errors)` build results directly +- `combineResultPromises(...results)` combines the results from several + children + +The rule of thumb for what to do when something goes wrong: + +- The _data didn't match_ → return error values from `check`. +- The _matcher itself is misconfigured_ → throw `CaseConfigurationError` + (usually from `validate`). +- _Neither should be possible_ → throw `CaseCoreError` - this tells the user + it's a bug rather than something they can fix. + +### `strip` and `check` must agree + +Calling `check` on the result of `strip` must always pass: + +```ts +check(descriptor, context, strip(descriptor, context)); // must have no errors +``` + +ContractCase relies on this: before writing a contract, it verifies that every +example matches its own matchers, so a matcher whose stripped example fails +its own check will fail every contract definition it appears in. (Our +`validate` above exists for exactly this reason - it rejects a user-supplied +example that would break the invariant, with an error that explains the +problem rather than a confusing self-mismatch.) + +If your matcher fundamentally can't produce example data - for example, an +auxiliary matcher designed to be combined with others via `and()` - throw a +`StripUnsupportedError` from `strip` instead. + +### `describe` must uniquely describe behaviour + +The string rendered from `describe` is used to name interactions and as the +key when matchers are saved in the contract's lookup table. ContractCase +relies on this property: + +> Any two matchers that produce the same rendered description MUST have +> exactly the same matching behaviour in all cases. + +So include every behaviour-affecting parameter in the description. Build +descriptions with the helpers `describeMessage`, `describeObject`, +`describeArray`, `concatenateDescribe` and `describeJoin` - they return a +structured `DescribeSegment` tree, which ContractCase can render flat (for +lookup keys) or pretty-print with indentation (for humans). + +## Matchers with children + +Matchers are recursive: a parameter of your matcher may itself be a matcher or +literal data (`AnyCaseMatcherOrData`). Your executor must not interpret +children itself, and is not allowed to call other executors directly - instead, +it descends into them through the context, which dispatches to whatever +executor the child needs: + +```mermaid +flowchart TD + A["yourorg:TrimmedString"] -->|"descendAndCheck(child, ctx, actual.trim())"| B["_case:StringPrefix"] + B -->|descendAndCheck| C["_case:MatchString"] +``` + +Each of the four executor functions has a corresponding descend function on +the context: `descendAndCheck`, `descendAndStrip`, `descendAndValidate` and +`descendAndDescribe`. Here's a wrapping matcher that trims whitespace from the +actual data before handing it to its child: + +```ts +import { addLocation } from '@contract-case/case-plugin-base'; + +const check: CheckMatchFn = (matcher, matchContext, actual) => + Promise.resolve().then(() => { + if (typeof actual !== 'string') { + return makeResults( + matchingError(matcher, `'${actual}' is not a string`, actual, matchContext), + ); + } + return matchContext.descendAndCheck( + matcher['_case:matcher:child'], + addLocation(':trimmedString', matchContext), + actual.trim(), + ); + }); + +const strip: StripMatcherFn = (matcher, matchContext) => + matchContext.descendAndStrip( + matcher['_case:matcher:child'], + addLocation(':trimmedString', matchContext), + ); +``` + +Note the `addLocation` call on every descent. The context carries a location +trail describing where in the matcher tree execution currently is, and it's +the backbone of ContractCase's error messages - it's what lets a mismatch deep +in a nested structure say exactly where it happened. Forgetting `addLocation` +won't fail any test; it silently degrades your users' error messages, so treat +it as required. + +If your matcher has several children, descend into each and combine the +results with `combineResultPromises` (for `check`) or the describe helpers +(for `describe`). + +## The match context + +Every executor function receives a `MatchContext`. It combines: + +- **Run configuration**, under keys namespaced `_case:currentRun:context:*` - + for example `'_case:currentRun:context:contractDir'`, or whether this run is + defining (`'write'`) or verifying (`'read'`) a contract + (`'_case:currentRun:context:contractMode'`). The namespacing means plugins + can read configuration without any chance of colliding with user data. +- **Matching configuration**, under `_case:context:*` keys - most importantly + `'_case:context:matchBy'` (`'type'` or `'exact'`), which your matcher should + respect if it matches literal values. These are the keys that + [context-modifying matchers](#modifying-the-context) set for their subtrees. +- **The traversal functions** described above (`descendAndCheck` and + friends). +- **Lookup functions** for saving and retrieving named matchers and state + variables from the contract (`saveLookupableMatcher`, `lookupMatcher`, + `lookupVariable`). These back the `namedMatch` / `stateVariable` + conveniences, and your matchers can use them too. +- **A logger** (`matchContext.logger`) - log through it (rather than + `console`) so your plugin's output respects the user's + [logLevel](../reference/configuring#loglevel-none--error--warn--debug--maintainerdebug--deepmaintainerdebug) + and is formatted consistently with the rest of ContractCase. `debug` is for + your users; `maintainerDebug` is for people debugging your plugin. + +The context is immutable - executors never modify it, they only produce new +contexts for their children (which happens automatically via `addLocation` and +the `_case:context:` keys on descendant matchers). diff --git a/packages/documentation/docs/plugins/writing-mocks.md b/packages/documentation/docs/plugins/writing-mocks.md new file mode 100644 index 000000000..3fad1af33 --- /dev/null +++ b/packages/documentation/docs/plugins/writing-mocks.md @@ -0,0 +1,351 @@ +--- +sidebar_position: 3 +sidebar_label: 'Writing mock types' +--- + +# Writing mock types + +Mocks are the executable part of an interaction - during a test, the mock +pretends to be the other side of the communication boundary. Like matchers, +mocks follow the [one model](./writing-a-plugin#one-model-matchers-are-data-executors-are-behaviour) +split: + +1. **The mock descriptor** - JSON data written to the contract file. It + contains the matcher trees for the data being exchanged (for example + `request` and `response`), plus metadata telling ContractCase how to run + the interaction. +2. **The mock executor** - the behaviour: code that sets the mock up, listens + for the interaction, records what actually happened, and hands the result + back for matching. + +:::tip note + +You'll see the words _mock_, _interaction_ and _example_ used somewhat +interchangeably - an interaction is described by example, and executed against +a mock. In the plugin API, `Mock` generally refers to the executable side. + +::: + +## The mock descriptor + +A mock descriptor is an object with two required metadata keys: + +```ts +export type AnyMockDescriptor = { + '_case:mock:type': string; // Which mock family this is + '_case:run:context:setup': InternalContractCaseCoreSetup; // How to run it from each side + request?: AnyCaseMatcher; // Conventional: the data sent to the mock + response?: AnyCaseMatcher; // Conventional: the data returned by the mock +}; +``` + +`request` and `response` are conventions rather than requirements - but if +your descriptor uses them, you get some helpers (like `defaultNameMock`, +below) for free. + +### One interaction, two sides: the `setup` block + +The defining insight of contract testing is that the same interaction is +tested from both sides: during _definition_ you test one side of the +communication against a mock of the other, and during _verification_ you test +the other side against a mock of the first. That means every interaction needs +_two_ mock behaviours - and which one runs depends on which side of the +contract you're on. + +The `'_case:run:context:setup'` block writes this down: + +```ts +'_case:run:context:setup': { + write: { + // How to run this interaction during contract definition + type: typeof YOUR_MOCK_TYPE, + stateVariables: 'default', + triggers: 'provided', + }, + read: { + // How to run this interaction during contract verification + type: typeof YOUR_OTHER_MOCK_TYPE, + stateVariables: 'state', + triggers: 'generated', + }, +}, +``` + +For each side: + +- `type` names the mock executor to run - ie, what ContractCase should + pretend to be on that side. These are usually different: for example, an + interaction defined by an HTTP client is run against a mock HTTP _server_ + during definition, and replayed by a mock HTTP _client_ during verification. +- `stateVariables` says where [state variables](../defining-contracts/http-client/state-definitions) + get their values on that side: `'state'` means they come from the user's + state handlers, `'default'` means the default values recorded in the + contract are used. +- `triggers` says who initiates the interaction on that side: `'provided'` + means the user supplies a [trigger function](../reference/configuring#triggers--trigger--testresponse--testerrorresponse-various-depending-on-language) + that exercises their own code, `'generated'` means your mock generates the + invocation itself (the way ContractCase generates its own HTTP requests when + verifying an HTTP server). + +Here's how the core function plugin uses this. It defines two mock types - +`_case:MockFunctionExecution` (ContractCase pretends to be a function +implementation) and `_case:MockFunctionCaller` (ContractCase pretends to be +the code that calls a function) - and each descriptor's `setup` block pairs +them, mirrored: + +```mermaid +flowchart LR + subgraph define ["Contract definition (write)"] + T["User's trigger calls their own code"] --> M1["MockFunctionExecution
(ContractCase pretends to be
the function)"] + end + subgraph verify ["Contract verification (read)"] + M2["MockFunctionCaller
(ContractCase generates calls)"] --> F["User's registered
real function"] + end + define -->|"contract file"| verify +``` + +```ts +export interface MockFunctionExecutionDescriptor + extends HasTypeForMockDescriptor, + MockFunctionDescriptor { + '_case:run:context:setup': { + write: { + type: typeof MOCK_FUNCTION_EXECUTION; + stateVariables: 'default'; + triggers: 'provided'; + }; + read: { + type: typeof MOCK_FUNCTION_CALLER; + stateVariables: 'state'; + triggers: 'generated'; + }; + }; +} +``` + +Reading the `write` block: during definition, ContractCase provides a mock +function, and the user's trigger calls it. Reading the `read` block: during +verification, ContractCase generates the calls itself, against the real +function the verifying user registered - and because the verifying side is +the one with real data, that's where state handlers supply the state +variables. + +Your plugin provides an executor for every mock type it names in a `setup` +block - usually a complementary pair like this one. + +## The mock executor + +A mock executor has two functions: + +```ts +export type MockExecutor = { + executor: MockExecutorFn; + ensureMatchersAreNamed: (mock: Descriptor, context: MatchContext) => Descriptor; +}; +``` + +### `ensureMatchersAreNamed` + +Repeated structures in a contract (like a request/response pair that several +interactions share) are stored once, in the contract's lookup table, and +referenced by name. Before writing an interaction, ContractCase asks your +plugin to guarantee that the matcher trees in the descriptor have unique +names - this function returns a descriptor where they do. + +If your descriptor uses the conventional `request` and `response` properties, +delegate to the provided `defaultNameMock` helper, which names both (deriving +a name from each matcher's description if the user didn't supply one). + +### `executor` + +The executor function is where the real work happens: + +```ts +export type MockExecutorFn = ( + mock: Descriptor, + context: MatchContext, +) => Promise>; +``` + +During this function you should: + +1. Validate that the descriptor is correctly formed, and that any + configuration your plugin needs is present (see + [plugin configuration](#plugin-configuration-mockconfig) below) - throwing + `CaseConfigurationError` with a helpful message if not. +2. Start anything that needs to listen (eg a server), or construct whatever + the trigger will interact with (eg a mock function). +3. Return a `MockData` object. + +`MockData` has two halves, corresponding to the two moments of the +interaction's lifecycle: + +```ts +export type MockData = { + config: SetupInfoFor; // Given to the user's trigger, eg { baseUrl } + assertableData: () => Promise; // Called after the trigger, returns what happened +}; +``` + +- `config` is the setup information passed to the user's trigger function - + whatever the trigger needs to exercise the mock. For an HTTP mock this is + the `baseUrl` of the mock server; for the function plugin it's the mock + function itself. +- `assertableData()` is called once the trigger has run. It returns the + `actual` data your mock observed, alongside the `expected` matcher tree and + the context to match it in - ContractCase then runs the matching engine + over the pair. If your mock's `triggers` mode is `'generated'`, generate and + invoke the trigger inside `assertableData()` instead of waiting for one. + +The whole lifecycle, for a `'provided'`-trigger mock: + +```mermaid +sequenceDiagram + participant Core as ContractCase core + participant Exec as Your mock executor + participant Mock as Your mock + participant User as User's trigger + + Core->>Exec: executor(descriptor, context) + Exec->>Mock: set up, start listening + Exec-->>Core: MockData { config, assertableData } + Core->>User: trigger(config) + User->>Mock: exercises the code under test + Mock->>Mock: records actual data + User-->>Core: trigger returns + Core->>Exec: assertableData() + Exec-->>Core: { actual, expected, context } + Core->>Core: match actual against expected +``` + +### A worked example + +Here is the core function plugin's `MockFunctionExecution` executor, +abridged. ContractCase "pretends to be a function" by constructing a real +function that records its arguments (the `actual` data) and derives its +return value from the `response` matcher tree: + +```ts +const setupMockFunctionExecution = ( + { request: expectedArguments, response: expectedResponse, functionName }: MockFunctionDescriptor, + parentContext: MatchContext, +): Promise> => + Promise.resolve( + addLocation( + `mockFunction[${functionName}]`, + providePluginContext(parentContext, { functionName }), + ), + ).then((context) => { + let data: { actualArguments: unknown[] } | null = null; + + // The mock: a real function that records its arguments, and returns + // whatever the response matcher tree describes + const f = (...stringArgs: string[]): string => { + data = { actualArguments: stringArgs.map((s) => JSON.parse(s)) }; + + const functionResponse = validateFunctionResponse( + context.descendAndStrip(expectedResponse, context), + context, + ); + return JSON.stringify(functionResponse); + }; + + return { + config: { + '_case:mock:type': MOCK_FUNCTION_EXECUTION, + stateVariables: context['_case:currentRun:context:variables'], + functions: { [functionName]: f }, + mock: { functionHandle: functionName }, + }, + assertableData: () => + Promise.resolve(data).then((result) => ({ + actual: result ? result.actualArguments : null, + context: addLocation('arguments', context), + expected: expectedArguments, + })), + }; + }); + +export const mockFunctionExecutionExecutor: MockExecutor< + typeof MOCK_FUNCTION_EXECUTION, + MockFunctionExecutionDescriptor, + AllSetup +> = { + executor: setupMockFunctionExecution, + ensureMatchersAreNamed: (descriptor, parentContext) => + defaultNameMock( + descriptor, + providePluginContext(parentContext, { + functionName: descriptor.functionName, + }), + ), +}; +``` + +(See [the full source](https://github.com/case-contract-testing/contract-case/blob/main/packages/case-core-plugin-function/src/mocks/mockFunctionExecution.ts) +for the error handling this abridged version leaves out.) + +A few things worth noticing: + +- The executor doesn't interpret the matcher trees itself - it calls + `context.descendAndStrip(expectedResponse, context)` to turn the response + matcher tree into concrete data, and it hands `expectedArguments` back + untouched from `assertableData()` for the core to match. Mocks orchestrate; + matchers match. +- If the mock was never invoked, `actual` is `null` - the mismatch is then + reported by the matching step, rather than the mock throwing. +- `addLocation` appears here too, for the same reason as in matchers: it's + what makes error messages say _where_ things went wrong. + +## Plugin configuration: `mockConfig` + +Users configure mocks through the +[`mockConfig` configuration property](../reference/configuring#mockconfig-object), +which is keyed by your plugin's +[`shortName`](./writing-a-plugin#the-description-object): + +```ts +mockConfig: { + yourPluginShortName: { + someSetting: 'someValue', + }, +}, +``` + +Inside your executor, read it with the `getPluginConfig` helper: + +```ts +import { getPluginConfig } from '@contract-case/case-plugin-base'; + +const pluginConfig = getPluginConfig(context, description); +``` + +`getPluginConfig` throws a `CaseConfigurationError` if there's no +configuration under your `shortName` at all - but it deliberately doesn't +validate the shape. Validate the individual settings yourself, at the time you +need them, throwing `CaseConfigurationError` with advice that tells the user +exactly which `mockConfig` key to fix. Remember that a helpful error here is +most of your plugin's user experience - it's the first thing a new user of +your plugin will see. + +## Passing information from mocks to matchers + +Sometimes your matchers need information that only the mock executor knows - +the way the function plugin's matchers want to know which function they're +matching arguments for. Use `providePluginContext` (as in the worked example +above) to attach a plugin-provided context object, which your matchers can +read from the context they receive. + +Only use this for information about the _definition_ of the interaction. Don't +use it to smuggle the actual observed data to your matchers - actual data +flows through the `actual` parameter of `assertableData()`, where the core can +see it, report on it, and match it properly. + +## Calling out to user code + +If your mock needs to invoke a function provided by the user's test suite - +which may be running in another language, on the other side of the gRPC +connector - use `context.invokeFunctionByHandle(handle, args)`. Arguments and +return values cross the boundary as JSON-encoded strings. This is how the +function plugin's `MockFunctionCaller` invokes the user's registered +functions; most transport-style plugins won't need it. diff --git a/packages/documentation/docs/reference/plugin-framework.md b/packages/documentation/docs/reference/plugin-framework.md index ea8d0fd7d..48c1de9eb 100644 --- a/packages/documentation/docs/reference/plugin-framework.md +++ b/packages/documentation/docs/reference/plugin-framework.md @@ -1,184 +1,24 @@ --- sidebar_position: 10 +sidebar_label: 'Contract Format' --- -# Plugin Framework +# Contract file format -:::danger Draft ahead +:::tip note -This document is currently inaccurate, as it was written before JSii was found to be unsuitable for writing plugins. - -It's still possible to write extensions - if you are planning an extension, please get in touch by opening [an issue](https://github.com/case-contract-testing/contract-case/issues/new). - -You can also see the core plugin packages [here](https://github.com/case-contract-testing/contract-case/tree/main/packages) - any package with `core-plugin` in the name will provide a good starting point. - -There are [old instructions for adding matchers](https://github.com/case-contract-testing/case/blob/main/docs/maintainers/AddingMatchers.md) in the maintainer documentation. +If you arrived here looking for how to extend ContractCase with your own +matchers or mock types, see the [Plugins](../plugins/) section - this page +describes the format of the contract file itself. ::: -## Caveats for extending ContractCase - -Extensions can be written in any of the languages where ContractCase is available. -However, if you wish to distribute your extension for use by others who don't use the same language, it must be written in TypeScript and transpiled with JSii. - -Additionally, if your matcher is likely to be of general use, consider making a pull request to add it to the core implementation. - -## Anatomy of a ContractCase Interaction - -In the contract file, each interaction (called an `example` in the file format) has three parts: - -- `states`: An array of the state definitions this interaction needs. Each - state has a `_case:state:type` of either `_case:NamedState` or - `_case:StateWithVariables`, a `stateName`, and (for states with variables) a - `variables` object whose values are matchers. -- `mock`: The description of the mock for this interaction. It contains the - matcher tree(s) for the data being exchanged (for example `request` and - `response` for HTTP mocks), a `_case:mock:type` naming the mock executor to - use, and a `_case:run:context:setup` object that tells ContractCase how to - run the interaction from each side: - - `write`: How to run the interaction on the side that defines the contract - - `read`: How to run the interaction on the side that verifies the contract - - Each of these describes which mock type to use (eg an HTTP client interaction - is run with a mock HTTP server during definition, and a mock HTTP client - during verification), whether state variables come from state handlers - (`'state'`) or their default values (`'default'`), and whether triggers are - `'provided'` by the user or `'generated'` by ContractCase. - -- `result`: The result of the interaction when the contract was defined - (successful interactions are recorded as `VERIFIED`). - -## ContractCase Context - -All matcher executors and mock executors receive a context object -(`MatchContext` in the plugin framework types). It combines: - -- **Run configuration**: the resolved configuration for the current run, under - keys namespaced with `_case:currentRun:context:` (for example - `_case:currentRun:context:contractDir`). The namespacing means plugins can - add their own context entries without colliding with user data. -- **Traversal functions**: `descendAndCheck()` and `descendAndStrip()`, which - matcher executors use to recurse into their children. -- **Lookup functions**: used to save and retrieve named matchers and state - variables from the contract's lookup table. -- **A logger and result printer**, so that plugins can log consistently with - the rest of ContractCase. - -Matchers can modify the context for everything below them in the matcher tree -by including fields prefixed with `_case:context:` (see "Designing the -description object" below). For example, `_case:context:matchBy` is how -`shapedLike` and `exactlyLike` switch the default matching mode between -`'type'` and `'exact'` for their children. - -## Extending with a new matcher type - -To extend case with a new Matcher type: - -1. Implement the description object. This is the object that will be produced by the DSL, and the content that will be written to the contract file. -2. Implement the corresponding `MatcherExecutor` - -### Designing the description object - -All matchers much have a constant for the type of the matcher. The -type must have an exported constant for its type. This is used to -determine what type of matcher it is and to run the associated matching -functions. For example: - -```ts -export const YOUR_CUSTOM_MATCHER_TYPE = 'yourName:yourMatcher' as const; -``` - -Note that all matchers that Case provides are prefixed with `case:`. To avoid -clashing with official matcher names, this prefix is not allowed in extensions. - -Export a new interface that describes the actual matcher JSON. This is -what will be written to the contract file, and generated by the matcher DSL. - -It must include `case:matcher:type`, set to the exact type constant string you created in the previous step. -All parameter fields must be prefixed with `case:matcher:`. For example: - -```ts -export interface CoreArrayLengthMatcher { - 'case:matcher:type': typeof CORE_ARRAY_LENGTH_MATCHER; - 'case:matcher:minLength': number; - 'case:matcher:maxLength': number; -} -``` - -If your matcher modifies the context object, add fields prefixed with -`case:context:` - these are automatically picked up by ContractCase and rolled -into the context before this matcher is invoked. Because case matchers are -recursive, this context is passed down to any child matchers. - -### Implementing the description object - -Create a DSL function that creates your matcher type, for example: - -```ts -/** - * Everything inside this matcher will be matched exactly, unless overridden with an `any*` matcher - * - * Use this to switch out of `shapedLike` and back to the default exact matching. - * - * @param content What - */ -export const exactlyLike = ( - content: AnyCaseNodeOrData, -): CoreCascadingMatcher => ({ - 'case:matcher:type': CASCADING_CONTEXT_MATCHER_TYPE, - 'case:matcher:child': content, - 'case:context:matchBy': 'exact', -}); -``` - -### Implementing the MatcherExecutor - -Next, we will add the behaviour of the matcher, both for matching, and for stripping the matchers. - -Implement a type that satisfies `MatcherExecutor`. For example: - -```ts -const strip: StripMatcherFn = ( - matcher: YourCustomMatcherInterface, - matchContext: MatchContext -): AnyData => // implement the strip matcher function here - - -const check: CheckMatchFn = ( - matcher: YourCustomMatcherInterface, - matchContext: MatchContext, - actual: unknown -): Promise | MatchResult => // Implement your check here - -export const ArrayLengthExecutor: MatcherExecutor< - typeof YOUR_CUSTOM_MATCHER_TYPE -> = { check, strip }; -``` - -If you need to recurse further into any children of your matchers, use -`matchContext.descendAndCheck()` or `matchContext.descendAndStrip()` as -appropriate. See the existing [MatcherExecutor implementations](https://github.com/case-contract-testing/case/tree/main/src/diffmatch) for examples. - -If your matcher doesn't have enough context to strip matchers (eg, for -auxiliary matchers designed to be used with `and()`), then throw a `new stripUnsupportedError(matcher, matchContext)` inside your implementation of -`strip()`. - -Note that matcher executors are not allowed to call other matcher executors - -only `descendAndCheck(...)`. If you need to combine matchers, do it at the DSL -layer with `and(...)` - -## Extending with a new mock type - -To extend case with a new Mock type: - -1. Implement a function that matches the `MockSetupFn` interface to give ContractCase the behaviour -1. Implement a function that returns a json-serialisable object that the function you created in the previous step would expect. +Most users do not need to know the contract format - you can treat the +contract file as opaque. This page is for you if you're building tooling on +top of ContractCase, or [writing a plugin](../plugins/) and want to understand +what your descriptors look like once they're written down. -# ContractCase Contract Format - -Most users do not need to know the format - you can treat the contract file as -opaque. If you're building tooling on top of ContractCase, the top level of a -Case File looks like this: +The top level of a contract file looks like this: ```jsonc { @@ -204,17 +44,51 @@ Case File looks like this: "variable:default:userId::test[0]": {}, }, - // The interactions, as described in - // "Anatomy of a ContractCase Interaction" above + // The interactions, as described below "examples": [], } ``` +## Anatomy of an interaction + +Each interaction (called an `example` in the file format) has three parts: + +- `states`: An array of the state definitions this interaction needs. Each + state has a `_case:state:type` of either `_case:NamedState` or + `_case:StateWithVariables`, a `stateName`, and (for states with variables) a + `variables` object whose values are matchers. +- `mock`: The description of the mock for this interaction. It contains the + matcher tree(s) for the data being exchanged (for example `request` and + `response` for HTTP mocks), a `_case:mock:type` naming the mock executor to + use, and a `_case:run:context:setup` object that tells ContractCase how to + run the interaction from each side: + - `write`: How to run the interaction on the side that defines the contract + - `read`: How to run the interaction on the side that verifies the contract + + Each of these describes which mock type to use (eg an HTTP client interaction + is run with a mock HTTP server during definition, and a mock HTTP client + during verification), whether state variables come from state handlers + (`'state'`) or their default values (`'default'`), and whether triggers are + `'provided'` by the user or `'generated'` by ContractCase. See + [writing mock types](../plugins/writing-mocks) for a full description. + +- `result`: The result of the interaction when the contract was defined + (successful interactions are recorded as `VERIFIED`). + +## Metadata namespacing + Within the matcher trees, all ContractCase metadata keys are namespaced with a `_case:` prefix (`_case:matcher:type`, `_case:mock:type`, `_case:state:type` and so on), so they can't collide with user data. Everything without a `_case:` prefix is literal data or the parameters of the enclosing matcher. +Matcher type constants provided by ContractCase itself are also prefixed with +`_case:` (for example `_case:MatchInteger`) - plugins use their own namespace +prefixes instead, as described in +[the plugin documentation](../plugins/writing-a-plugin#namespacing-your-types). + +## Stability + The format is not currently versioned separately from ContractCase itself, and may change between versions - if you're building tooling that reads contract files, please open [an issue](https://github.com/case-contract-testing/contract-case/issues/new) From 8708d0823f7baa1ffdf7e82365760d85a26340e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 03:17:58 +0000 Subject: [PATCH 02/10] fix(plugins): Load plugin modules reliably regardless of how they were packaged Previously, loading a plugin by name failed for plugins built with `export default plugin` or `module.exports = plugin`, because the dynamic import()'s module namespace never exposes the plugin's properties directly. Now the module contents are unwrapped with the new mustResolvePlugin helper (in case-plugin-base), which accepts every packaging style and throws a helpful INVALID_PLUGIN_MODULE configuration error when a module doesn't contain a plugin. Additionally: - Plugin names are now resolved against the working directory (the user's project) first, so plugins are found when the connector runs from a temporary directory (eg when called from the Java DSL) - Falls back to require() in environments that can't do a dynamic import() (eg Jest without --experimental-vm-modules) - PluginLoader now validates the plugin objects it's given (previously a TODO) - Unsafe module specifiers now surface as a BoundaryResult failure rather than a synchronous throw across the never-throws boundary Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014mD6p9EEmftbpBQHZZBWB1 --- package-lock.json | 13 ++ packages/case-connector/eslint.config.mjs | 4 +- packages/case-connector/package.json | 1 + .../internals/BoundaryPluginLoader.spec.ts | 133 +++++++++++++ .../internals/BoundaryPluginLoader.ts | 135 +++++++++---- .../case-boundary/internals/index.ts | 1 + .../default-export.js | 9 + .../contract-case-test-plugin/esm-default.mjs | 7 + .../contract-case-test-plugin/makePlugin.js | 23 +++ .../module-exports.js | 7 + .../named-exports.js | 12 ++ .../contract-case-test-plugin/not-a-plugin.js | 5 + .../contract-case-test-plugin/package.json | 15 ++ .../connectors/loadPlugins/PluginLoader.ts | 12 +- packages/case-core/src/index.ts | 3 + .../api-extractor/case-plugin-base.api.json | 185 ++++++++++++++++++ .../api-extractor/case-plugin-base.api.md | 10 + .../case-plugin-base.anycontractcaseplugin.md | 15 ++ ...urationerrorcodes.invalid_plugin_module.md | 17 ++ ...ase-plugin-base.configurationerrorcodes.md | 23 +++ .../case-plugin-base.iscontractcaseplugin.md | 58 ++++++ .../case-plugin-base/docs/case-plugin-base.md | 37 ++++ .../case-plugin-base.mustresolveplugin.md | 78 ++++++++ .../case-plugin-base/src/errors/ErrorCodes.ts | 16 ++ packages/case-plugin-base/src/index.ts | 1 + .../case-plugin-base/src/plugins/index.ts | 1 + .../src/plugins/resolve.spec.ts | 95 +++++++++ .../case-plugin-base/src/plugins/resolve.ts | 108 ++++++++++ .../temp/case-plugin-base.api.md | 10 + 29 files changed, 990 insertions(+), 44 deletions(-) create mode 100644 packages/case-connector/src/connectors/case-boundary/internals/BoundaryPluginLoader.spec.ts create mode 100644 packages/case-connector/test-fixtures/contract-case-test-plugin/default-export.js create mode 100644 packages/case-connector/test-fixtures/contract-case-test-plugin/esm-default.mjs create mode 100644 packages/case-connector/test-fixtures/contract-case-test-plugin/makePlugin.js create mode 100644 packages/case-connector/test-fixtures/contract-case-test-plugin/module-exports.js create mode 100644 packages/case-connector/test-fixtures/contract-case-test-plugin/named-exports.js create mode 100644 packages/case-connector/test-fixtures/contract-case-test-plugin/not-a-plugin.js create mode 100644 packages/case-connector/test-fixtures/contract-case-test-plugin/package.json create mode 100644 packages/case-plugin-base/docs/case-plugin-base.anycontractcaseplugin.md create mode 100644 packages/case-plugin-base/docs/case-plugin-base.configurationerrorcodes.invalid_plugin_module.md create mode 100644 packages/case-plugin-base/docs/case-plugin-base.iscontractcaseplugin.md create mode 100644 packages/case-plugin-base/docs/case-plugin-base.mustresolveplugin.md create mode 100644 packages/case-plugin-base/src/plugins/index.ts create mode 100644 packages/case-plugin-base/src/plugins/resolve.spec.ts create mode 100644 packages/case-plugin-base/src/plugins/resolve.ts diff --git a/package-lock.json b/package-lock.json index 715a24c98..72b2df157 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3272,6 +3272,10 @@ "tslib": "^2.3.0" } }, + "node_modules/@contract-case/test-plugin-fixture": { + "resolved": "packages/case-connector/test-fixtures/contract-case-test-plugin", + "link": true + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "devOptional": true, @@ -10218,6 +10222,7 @@ "version": "0.11.0", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=14" } @@ -37361,6 +37366,7 @@ "@arethetypeswrong/cli": "^0.18.2", "@contract-case/case-maintainer-config": "0.30.1", "@contract-case/eslint-config-case-maintainer": "0.30.1", + "@contract-case/test-plugin-fixture": "file:test-fixtures/contract-case-test-plugin", "@knighted/duel": "^2.1.6", "@types/google-protobuf": "^3.15.12", "@types/uuid": "^11.0.0", @@ -37408,6 +37414,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/case-connector/test-fixtures/contract-case-test-plugin": { + "name": "@contract-case/test-plugin-fixture", + "version": "1.0.0", + "dev": true, + "license": "BSD-3-Clause" + }, "packages/case-core": { "name": "@contract-case/case-core", "version": "0.30.1", @@ -38032,6 +38044,7 @@ "@contract-case/case-plugin-base": "0.30.1", "@contract-case/cli": "0.30.1", "@contract-case/eslint-config-case-maintainer": "0.30.1", + "@contract-case/test-plugin-fixture": "file:../case-connector/test-fixtures/contract-case-test-plugin", "@grpc/grpc-js": "^1.13.5", "@grpc/proto-loader": "^0.8.0", "@types/body-parser": "^1.19.2", diff --git a/packages/case-connector/eslint.config.mjs b/packages/case-connector/eslint.config.mjs index a73315cf2..7d783a8a5 100644 --- a/packages/case-connector/eslint.config.mjs +++ b/packages/case-connector/eslint.config.mjs @@ -3,8 +3,8 @@ import { globalIgnores } from 'eslint/config'; export default [ globalIgnores( - ['cjs.js', 'rename-inner-webpack-vars-loader.cjs'], - 'Ignore CJS node entry point and webpack loader', + ['cjs.js', 'rename-inner-webpack-vars-loader.cjs', 'test-fixtures/'], + 'Ignore CJS node entry point, webpack loader, and test fixture packages', ), ...lintConfig, { diff --git a/packages/case-connector/package.json b/packages/case-connector/package.json index b5c5ea3a8..b9c75be53 100644 --- a/packages/case-connector/package.json +++ b/packages/case-connector/package.json @@ -51,6 +51,7 @@ "devDependencies": { "@arethetypeswrong/cli": "^0.18.2", "@contract-case/case-maintainer-config": "0.30.1", + "@contract-case/test-plugin-fixture": "file:test-fixtures/contract-case-test-plugin", "@contract-case/eslint-config-case-maintainer": "0.30.1", "@knighted/duel": "^2.1.6", "@types/google-protobuf": "^3.15.12", diff --git a/packages/case-connector/src/connectors/case-boundary/internals/BoundaryPluginLoader.spec.ts b/packages/case-connector/src/connectors/case-boundary/internals/BoundaryPluginLoader.spec.ts new file mode 100644 index 000000000..3487814bb --- /dev/null +++ b/packages/case-connector/src/connectors/case-boundary/internals/BoundaryPluginLoader.spec.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; + +import { BoundaryPluginLoader } from './BoundaryPluginLoader.js'; +import { + BoundaryFailure, + BoundaryResult, + BoundaryResultTypeConstants, + BoundarySuccess, + ContractCaseBoundaryConfig, + ILogPrinter, + IResultPrinter, +} from './boundary/index.js'; + +const logPrinter: ILogPrinter = { + log: () => Promise.resolve(new BoundarySuccess()), +}; + +const resultPrinter: IResultPrinter = { + printMatchError: () => Promise.resolve(new BoundarySuccess()), + printMessageError: () => Promise.resolve(new BoundarySuccess()), + printTestTitle: () => Promise.resolve(new BoundarySuccess()), +}; + +const config: ContractCaseBoundaryConfig = { + testRunId: 'PLUGIN-LOADER-SPEC', + internals: {}, +}; + +const makeLoader = () => + new BoundaryPluginLoader(config, logPrinter, resultPrinter, [ + 'plugin-loader-spec-version', + ]); + +const expectSuccess = (result: BoundaryResult) => { + expect( + result.resultType, + result instanceof BoundaryFailure ? result.message : undefined, + ).toBe(BoundaryResultTypeConstants.RESULT_SUCCESS); +}; + +const expectFailure = (result: BoundaryResult): BoundaryFailure => { + expect(result.resultType).toBe(BoundaryResultTypeConstants.RESULT_FAILURE); + return result as BoundaryFailure; +}; + +describe('BoundaryPluginLoader', () => { + // The fixture package provides the same minimal plugin behind one entry + // point per packaging style a real plugin might be built with. The + // documented shape is "the plugin object is the module's default export" - + // all of these must load. + describe.each([ + ['TypeScript-compiled default export', '@contract-case/test-plugin-fixture'], + [ + 'CommonJS module.exports', + '@contract-case/test-plugin-fixture/module-exports', + ], + ['native ESM default export', '@contract-case/test-plugin-fixture/esm-default'], + [ + 'CommonJS named exports', + '@contract-case/test-plugin-fixture/named-exports', + ], + ])('with a plugin packaged as %s', (_style, moduleName) => { + it('loads successfully', async () => { + expectSuccess(await makeLoader().loadPlugins([moduleName])); + }); + + it('is idempotent when loaded again', async () => { + expectSuccess(await makeLoader().loadPlugins([moduleName])); + }); + }); + + describe('with a module that loads but is not a plugin', () => { + it('fails with a message explaining the expected shape', async () => { + const failure = expectFailure( + await makeLoader().loadPlugins([ + '@contract-case/test-plugin-fixture/not-a-plugin', + ]), + ); + expect(failure.message).toContain( + "doesn't contain a ContractCase plugin", + ); + expect(failure.message).toContain( + '@contract-case/test-plugin-fixture/not-a-plugin', + ); + expect(failure.contractCaseErrorCode).toBe('INVALID_PLUGIN_MODULE'); + }); + }); + + describe('with a package that is not installed', () => { + it('fails and suggests installing the package', async () => { + const failure = expectFailure( + await makeLoader().loadPlugins([ + '@contract-case/definitely-not-a-real-plugin', + ]), + ); + expect(failure.message).toContain( + "Unable to load plugin '@contract-case/definitely-not-a-real-plugin'", + ); + expect(failure.message).toContain('npm install'); + }); + }); + + describe.each([ + ['a remote URI', 'https://example.com/evil.js'], + ['an inline URI', 'data:text/javascript,export default {}'], + ['a relative path', './some/local/path'], + ['a path traversal', '../../../etc/passwd'], + ['an absolute path', '/etc/passwd'], + ['a non-package string', 'not a package name'], + ])('with an unsafe module specifier (%s)', (_kind, moduleName) => { + it('fails without attempting to load it', async () => { + const failure = expectFailure( + await makeLoader().loadPlugins([moduleName]), + ); + expect(failure.message).toContain('Unsafe plugin module specifier'); + expect(failure.contractCaseErrorCode).toBe('INVALID_PLUGIN_NAME'); + }); + }); + + describe('with a mix of loadable and unloadable plugins', () => { + it('fails, reporting the plugin that could not be loaded', async () => { + const failure = expectFailure( + await makeLoader().loadPlugins([ + '@contract-case/test-plugin-fixture/module-exports', + '@contract-case/definitely-not-a-real-plugin', + ]), + ); + expect(failure.message).toContain( + '@contract-case/definitely-not-a-real-plugin', + ); + }); + }); +}); diff --git a/packages/case-connector/src/connectors/case-boundary/internals/BoundaryPluginLoader.ts b/packages/case-connector/src/connectors/case-boundary/internals/BoundaryPluginLoader.ts index bff277c3c..5a3d69061 100644 --- a/packages/case-connector/src/connectors/case-boundary/internals/BoundaryPluginLoader.ts +++ b/packages/case-connector/src/connectors/case-boundary/internals/BoundaryPluginLoader.ts @@ -1,7 +1,12 @@ +import { createRequire } from 'node:module'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + import { CaseConfigurationError, CaseCoreError, PluginLoader, + mustResolvePlugin, } from '@contract-case/case-core'; import { @@ -9,6 +14,7 @@ import { ILogPrinter, IResultPrinter, BoundaryResult, + BoundarySuccess, } from './boundary/index.js'; import { versionString } from '../../../entities/versionString.js'; import { @@ -16,7 +22,87 @@ import { jsErrorToFailure, wrapLogPrinter, } from './mappers/index.js'; -import { BoundarySuccess } from './index.js'; + +/** + * Resolves a plugin module name against the user's project (ie, the current + * working directory), falling back to normal resolution relative to this + * file. + * + * The cwd resolution step exists because this code doesn't always run from + * the user's project: when ContractCase is called from a host language like + * Java, the connector runs from a temporary directory, so resolving relative + * to this file would never find plugins the user installed in their project's + * node_modules. + * + * @param moduleName - a previously validated bare module name + * @returns the resolved path to the module, or null if it couldn't be + * resolved from the working directory (in which case the caller should fall + * back to normal resolution, so that this continues to work if the module is + * resolvable from this file but not from the working directory). + */ +const resolveFromWorkingDirectory = (moduleName: string): string | null => { + try { + return createRequire(join(process.cwd(), 'noop.js')).resolve(moduleName); + } catch { + return null; + } +}; + +const loadOne = ( + moduleName: string, +): Promise> => + Promise.resolve() + .then(() => { + if ( + // Forbid URIs + moduleName.includes(':') || + // Forbid strings that start with '.', as they may be hidden files or path traversal + moduleName.startsWith('.') || + // Forbid strings that start with / , as only local packages are supported + moduleName.startsWith('/') || + // Forbid strings that don't look like node module names + !/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._-]*)*$/i.test( + moduleName, + ) + ) { + throw new CaseConfigurationError( + `Unsafe plugin module specifier. Plugin names must be a valid local nodejs package name, and may not be remote or inline URIs. Provided name was: '${moduleName}'`, + 'DONT_ADD_LOCATION', + 'INVALID_PLUGIN_NAME', + ); + } + const resolvedPath = resolveFromWorkingDirectory(moduleName); + // webpack ignore is needed here so that the final bundle for host + // languages is able to import arbitrary libs, without webpack failing + return import( + /* webpackIgnore: true */ resolvedPath != null + ? pathToFileURL(resolvedPath).href + : moduleName + ) + .catch((e) => { + // Some test environments can't do a dynamic import() at all (eg + // Jest without --experimental-vm-modules). In those environments, + // fall back to loading the plugin with require() instead. + if ( + e != null && + (e.code === 'ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING' || + `${e.message}`.includes('--experimental-vm-modules')) + ) { + return createRequire(join(process.cwd(), 'noop.js'))( + resolvedPath != null ? resolvedPath : moduleName, + ); + } + throw e; + }) + .catch((e) => { + throw new CaseConfigurationError( + `Unable to load plugin '${moduleName}': ${e.message}\n\nPlease check that the plugin's package is installed (for example, with \`npm install --save-dev ${moduleName}\`) in the project the tests are running from.`, + 'DONT_ADD_LOCATION', + 'UNDOCUMENTED', + ); + }); + }) + .then((moduleContents) => mustResolvePlugin(moduleContents, moduleName)); /** * A BoundaryPluginLoader allows loading plugins into the core @@ -77,48 +163,15 @@ export class BoundaryPluginLoader { async loadPlugins(moduleNames: string[]): Promise { this.initialiseLoader(); - return Promise.all( - moduleNames.map((moduleName) => { - if ( - // Forbid URIs - moduleName.includes(':') || - // Forbid strings that start with '.', as they may be hidden files or path traversal - moduleName.startsWith('.') || - // Forbid strings that start with / , as only local packages are supported - moduleName.startsWith('/') || - // Forbid strings that don't look like node module names - !/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._-]*)*$/i.test( - moduleName, - ) - ) { - throw new CaseConfigurationError( - `Unsafe plugin module specifier. Plugin names must be a valid local nodejs package name, and may not be remote or inline URIs. Provided name was: '${moduleName}'`, - 'DONT_ADD_LOCATION', - 'INVALID_PLUGIN_NAME', + return Promise.all(moduleNames.map((moduleName) => loadOne(moduleName))) + .then((plugins) => { + if (this.loader == null) { + throw new CaseCoreError( + 'this.loader was not initialised during loadPlugins. This should never happen, as initialiseLoader() is meant to be called first', ); } - // webpack ignore is needed here so that the final bundle for host - // languages is able to import arbitrary libs, without webpack failing - return import(/* webpackIgnore: true */ moduleName); - }), - ) - .then( - (plugins) => { - if (this.loader == null) { - throw new CaseCoreError( - 'this.loader was not initialised during loadPlugins. This should never happen, as initialiseLoader() is meant to be called first', - ); - } - this.loader.loadPlugins(plugins); - }, - (e) => { - throw new CaseConfigurationError( - `Unable to load plugin: ${e.message}`, - 'DONT_ADD_LOCATION', - 'UNDOCUMENTED', - ); - }, - ) + this.loader.loadPlugins(plugins); + }) .then(() => new BoundarySuccess()) .catch(jsErrorToFailure); } diff --git a/packages/case-connector/src/connectors/case-boundary/internals/index.ts b/packages/case-connector/src/connectors/case-boundary/internals/index.ts index eadc8e96e..5095df953 100644 --- a/packages/case-connector/src/connectors/case-boundary/internals/index.ts +++ b/packages/case-connector/src/connectors/case-boundary/internals/index.ts @@ -8,5 +8,6 @@ export * from './BoundaryContractDefiner.js'; export * from './BoundaryContractVerifier.js'; +export * from './BoundaryPluginLoader.js'; export * from './boundary/index.js'; export * from './types.js'; diff --git a/packages/case-connector/test-fixtures/contract-case-test-plugin/default-export.js b/packages/case-connector/test-fixtures/contract-case-test-plugin/default-export.js new file mode 100644 index 000000000..c9cad8622 --- /dev/null +++ b/packages/case-connector/test-fixtures/contract-case-test-plugin/default-export.js @@ -0,0 +1,9 @@ +'use strict'; + +// Simulates the most common real-world packaging: TypeScript compiling +// `export default plugin` to CommonJS. When this is loaded with a dynamic +// import(), the plugin ends up at `namespace.default.default`. +Object.defineProperty(exports, '__esModule', { value: true }); +const { makePlugin } = require('./makePlugin'); + +exports.default = makePlugin('default-export'); diff --git a/packages/case-connector/test-fixtures/contract-case-test-plugin/esm-default.mjs b/packages/case-connector/test-fixtures/contract-case-test-plugin/esm-default.mjs new file mode 100644 index 000000000..9336bb5ac --- /dev/null +++ b/packages/case-connector/test-fixtures/contract-case-test-plugin/esm-default.mjs @@ -0,0 +1,7 @@ +// Simulates a native ESM plugin: `export default plugin`. When this is +// loaded with a dynamic import(), the plugin ends up at `namespace.default`. +import { createRequire } from 'node:module'; + +const { makePlugin } = createRequire(import.meta.url)('./makePlugin'); + +export default makePlugin('esm-default'); diff --git a/packages/case-connector/test-fixtures/contract-case-test-plugin/makePlugin.js b/packages/case-connector/test-fixtures/contract-case-test-plugin/makePlugin.js new file mode 100644 index 000000000..c1333230b --- /dev/null +++ b/packages/case-connector/test-fixtures/contract-case-test-plugin/makePlugin.js @@ -0,0 +1,23 @@ +'use strict'; + +// Builds a minimal but well-formed ContractCasePlugin. Each entry point of +// this fixture package uses a different name, so that the plugins can all be +// loaded in the same test run without colliding in the (process-global) +// executor registries. +module.exports.makePlugin = (name) => ({ + description: { + humanReadableName: `Test fixture plugin (${name})`, + shortName: `test-fixture-${name}`, + uniqueMachineName: `test-fixture:${name}`, + version: '1.0.0', + }, + matcherExecutors: { + [`test-fixture:${name}:Matcher`]: { + describe: () => ({ kind: 'message', message: `test fixture ${name}` }), + check: () => Promise.resolve([]), + strip: () => `stripped-by-${name}`, + validate: () => Promise.resolve(), + }, + }, + setupMocks: {}, +}); diff --git a/packages/case-connector/test-fixtures/contract-case-test-plugin/module-exports.js b/packages/case-connector/test-fixtures/contract-case-test-plugin/module-exports.js new file mode 100644 index 000000000..ca87b6098 --- /dev/null +++ b/packages/case-connector/test-fixtures/contract-case-test-plugin/module-exports.js @@ -0,0 +1,7 @@ +'use strict'; + +// Simulates hand-written CommonJS: `module.exports = plugin`. When this is +// loaded with a dynamic import(), the plugin ends up at `namespace.default`. +const { makePlugin } = require('./makePlugin'); + +module.exports = makePlugin('module-exports'); diff --git a/packages/case-connector/test-fixtures/contract-case-test-plugin/named-exports.js b/packages/case-connector/test-fixtures/contract-case-test-plugin/named-exports.js new file mode 100644 index 000000000..2d2f8bfa1 --- /dev/null +++ b/packages/case-connector/test-fixtures/contract-case-test-plugin/named-exports.js @@ -0,0 +1,12 @@ +'use strict'; + +// Simulates CommonJS with individual named exports - the only style that +// worked before the module namespace unwrapping was added. Kept to confirm +// it still works. +const { makePlugin } = require('./makePlugin'); + +const plugin = makePlugin('named-exports'); + +exports.description = plugin.description; +exports.matcherExecutors = plugin.matcherExecutors; +exports.setupMocks = plugin.setupMocks; diff --git a/packages/case-connector/test-fixtures/contract-case-test-plugin/not-a-plugin.js b/packages/case-connector/test-fixtures/contract-case-test-plugin/not-a-plugin.js new file mode 100644 index 000000000..ad32ead18 --- /dev/null +++ b/packages/case-connector/test-fixtures/contract-case-test-plugin/not-a-plugin.js @@ -0,0 +1,5 @@ +'use strict'; + +// A module that loads successfully but isn't a ContractCase plugin, for +// testing the error message users get when they load the wrong package. +module.exports = { some: 'data', that: ['is', 'not', 'a', 'plugin'] }; diff --git a/packages/case-connector/test-fixtures/contract-case-test-plugin/package.json b/packages/case-connector/test-fixtures/contract-case-test-plugin/package.json new file mode 100644 index 000000000..7db1ec6dc --- /dev/null +++ b/packages/case-connector/test-fixtures/contract-case-test-plugin/package.json @@ -0,0 +1,15 @@ +{ + "name": "@contract-case/test-plugin-fixture", + "version": "1.0.0", + "private": true, + "description": "Fixture plugin package for BoundaryPluginLoader.spec.ts, providing one entry point per plugin packaging style", + "license": "BSD-3-Clause", + "main": "default-export.js", + "exports": { + ".": "./default-export.js", + "./module-exports": "./module-exports.js", + "./esm-default": "./esm-default.mjs", + "./named-exports": "./named-exports.js", + "./not-a-plugin": "./not-a-plugin.js" + } +} diff --git a/packages/case-core/src/connectors/loadPlugins/PluginLoader.ts b/packages/case-core/src/connectors/loadPlugins/PluginLoader.ts index 3b8811914..bcd7c47cd 100644 --- a/packages/case-core/src/connectors/loadPlugins/PluginLoader.ts +++ b/packages/case-core/src/connectors/loadPlugins/PluginLoader.ts @@ -1,9 +1,11 @@ import { + CaseConfigurationError, ContractCasePlugin, DataContext, IsCaseNodeForType, IsMockDescriptorForType, constructDataContext, + isContractCasePlugin, } from '@contract-case/case-plugin-base'; import type { CaseConfig } from '../../core/types'; @@ -44,7 +46,15 @@ export class PluginLoader { >( plugins: Array>, ): void { - // TODO: Validate plugins here + plugins.forEach((plugin) => { + if (!isContractCasePlugin(plugin)) { + throw new CaseConfigurationError( + `Unable to load plugins, as one of the objects provided wasn't a ContractCase plugin. Plugins must be objects with 'description', 'matcherExecutors' and 'setupMocks' properties. If you are loading a plugin module by name, this is an error in the plugin's packaging - please contact the plugin's authors.`, + 'DONT_ADD_LOCATION', + 'INVALID_PLUGIN_MODULE', + ); + } + }); loadPlugins(this.context, plugins); } } diff --git a/packages/case-core/src/index.ts b/packages/case-core/src/index.ts index 9a3414c21..40be5f450 100644 --- a/packages/case-core/src/index.ts +++ b/packages/case-core/src/index.ts @@ -38,7 +38,10 @@ export { CaseCoreError, CaseTriggerError, VerifyTriggerReturnObjectError, + isContractCasePlugin, + mustResolvePlugin, } from '@contract-case/case-plugin-base'; +export type { AnyContractCasePlugin } from '@contract-case/case-plugin-base'; export type { AnyMockDescriptorType, AnyMockDescriptor, diff --git a/packages/case-plugin-base/api-extractor/case-plugin-base.api.json b/packages/case-plugin-base/api-extractor/case-plugin-base.api.json index 347cfc3cf..73b7460de 100644 --- a/packages/case-plugin-base/api-extractor/case-plugin-base.api.json +++ b/packages/case-plugin-base/api-extractor/case-plugin-base.api.json @@ -318,6 +318,55 @@ ], "name": "addLocation" }, + { + "kind": "TypeAlias", + "canonicalReference": "@contract-case/case-plugin-base!AnyContractCasePlugin:type", + "docComment": "/**\n * Convenience type for a plugin where the specific matcher and mock types aren't known - which is the case when a plugin has been loaded dynamically.\n *\n * @public\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "export type AnyContractCasePlugin = " + }, + { + "kind": "Reference", + "text": "ContractCasePlugin", + "canonicalReference": "@contract-case/case-plugin-base!ContractCasePlugin:type" + }, + { + "kind": "Content", + "text": ", " + }, + { + "kind": "Reference", + "text": "IsMockDescriptorForType", + "canonicalReference": "@contract-case/case-plugin-base!IsMockDescriptorForType:interface" + }, + { + "kind": "Content", + "text": ", unknown>" + }, + { + "kind": "Content", + "text": ";" + } + ], + "fileUrlPath": "src/plugins/resolve.ts", + "releaseTag": "Public", + "name": "AnyContractCasePlugin", + "typeTokenRange": { + "startIndex": 1, + "endIndex": 7 + } + }, { "kind": "Function", "canonicalReference": "@contract-case/case-plugin-base!cantPublish:function(1)", @@ -1623,6 +1672,33 @@ "endIndex": 2 } }, + { + "kind": "PropertySignature", + "canonicalReference": "@contract-case/case-plugin-base!ConfigurationErrorCodes#INVALID_PLUGIN_MODULE:member", + "docComment": "/**\n * Indicates that a plugin module was loaded, but it doesn't contain a ContractCase plugin.\n *\n * Plugin modules must export the assembled plugin object (an object with `description`, `matcherExecutors` and `setupMocks` properties) as the module's default export.\n *\n * If you're a user of the plugin, check that the plugin name is spelled correctly, and that the package really is a ContractCase plugin. If it is, this is an error in the plugin's packaging - please contact the plugin's authors.\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "INVALID_PLUGIN_MODULE: " + }, + { + "kind": "Content", + "text": "'INVALID_PLUGIN_MODULE'" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isReadonly": false, + "isOptional": false, + "releaseTag": "Public", + "name": "INVALID_PLUGIN_MODULE", + "propertyTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + } + }, { "kind": "PropertySignature", "canonicalReference": "@contract-case/case-plugin-base!ConfigurationErrorCodes#INVALID_PLUGIN_NAME:member", @@ -3854,6 +3930,57 @@ ], "extendsTokenRanges": [] }, + { + "kind": "Function", + "canonicalReference": "@contract-case/case-plugin-base!isContractCasePlugin:function(1)", + "docComment": "/**\n * Type guard that determines whether an unknown value is shaped like a {@link ContractCasePlugin}.\n *\n * Note that this only validates the shape of the plugin object - it doesn't confirm that the executors themselves are correctly implemented.\n *\n * @param value - the value to check\n *\n * @returns true if the value has the shape of a ContractCase plugin\n *\n * @public\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "isContractCasePlugin: (value: " + }, + { + "kind": "Content", + "text": "unknown" + }, + { + "kind": "Content", + "text": ") => " + }, + { + "kind": "Reference", + "text": "value", + "canonicalReference": "@contract-case/case-plugin-base!~value" + }, + { + "kind": "Content", + "text": " is " + }, + { + "kind": "Reference", + "text": "AnyContractCasePlugin", + "canonicalReference": "@contract-case/case-plugin-base!AnyContractCasePlugin:type" + } + ], + "fileUrlPath": "src/plugins/resolve.ts", + "returnTypeTokenRange": { + "startIndex": 3, + "endIndex": 6 + }, + "releaseTag": "Public", + "overloadIndex": 1, + "parameters": [ + { + "parameterName": "value", + "parameterTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + }, + "isOptional": false + } + ], + "name": "isContractCasePlugin" + }, { "kind": "Interface", "canonicalReference": "@contract-case/case-plugin-base!IsMockDescriptorForType:interface", @@ -5832,6 +5959,64 @@ "endIndex": 6 } }, + { + "kind": "Function", + "canonicalReference": "@contract-case/case-plugin-base!mustResolvePlugin:function(1)", + "docComment": "/**\n * Extracts the {@link ContractCasePlugin} object from the contents of a dynamically loaded plugin module, or throws a `CaseConfigurationError` if the module doesn't contain one.\n *\n * Plugin packages are documented as exporting the assembled plugin object as their default export - but depending on the module system the plugin was built with (and the module system doing the importing), the plugin object may arrive as the module contents itself, as the module namespace's `default` property, or nested a level deeper (eg TypeScript's CommonJS output of `export default`, imported as ESM). This function accepts all of these shapes, so plugin authors don't need to know the details.\n *\n * @param moduleContents - whatever `import()` (or `require`) returned for the plugin module\n *\n * @param moduleName - the name of the module, for error messages\n *\n * @returns the plugin object\n *\n * @throws\n *\n * CaseConfigurationError if no plugin-shaped object could be found\n *\n * @public\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "mustResolvePlugin: (moduleContents: " + }, + { + "kind": "Content", + "text": "unknown" + }, + { + "kind": "Content", + "text": ", moduleName: " + }, + { + "kind": "Content", + "text": "string" + }, + { + "kind": "Content", + "text": ") => " + }, + { + "kind": "Reference", + "text": "AnyContractCasePlugin", + "canonicalReference": "@contract-case/case-plugin-base!AnyContractCasePlugin:type" + } + ], + "fileUrlPath": "src/plugins/resolve.ts", + "returnTypeTokenRange": { + "startIndex": 5, + "endIndex": 6 + }, + "releaseTag": "Public", + "overloadIndex": 1, + "parameters": [ + { + "parameterName": "moduleContents", + "parameterTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + }, + "isOptional": false + }, + { + "parameterName": "moduleName", + "parameterTypeTokenRange": { + "startIndex": 3, + "endIndex": 4 + }, + "isOptional": false + } + ], + "name": "mustResolvePlugin" + }, { "kind": "Function", "canonicalReference": "@contract-case/case-plugin-base!mustResolveToNumber:function(1)", diff --git a/packages/case-plugin-base/api-extractor/case-plugin-base.api.md b/packages/case-plugin-base/api-extractor/case-plugin-base.api.md index 13131e135..068f59a85 100644 --- a/packages/case-plugin-base/api-extractor/case-plugin-base.api.md +++ b/packages/case-plugin-base/api-extractor/case-plugin-base.api.md @@ -21,6 +21,9 @@ export const actualToString: (actual: T, indent?: number) => string; // @public export const addLocation: (location: string, context: MatchContext) => MatchContext; +// @public +export type AnyContractCasePlugin = ContractCasePlugin, IsMockDescriptorForType, unknown>; + // Warning: (ae-internal-missing-underscore) The name "applyNodeToContext" should be prefixed with an underscore because the declaration is marked as @internal // // @internal @@ -100,6 +103,7 @@ export interface ConfigurationErrorCodes { FAKE_NEVER_CALLED: 'FAKE_NEVER_CALLED'; INVALID_CONFIG: 'INVALID_CONFIG'; INVALID_LIFECYCLE: 'INVALID_LIFECYCLE'; + INVALID_PLUGIN_MODULE: 'INVALID_PLUGIN_MODULE'; INVALID_PLUGIN_NAME: 'INVALID_PLUGIN_NAME'; MISSING_REGISTERED_FUNCTION: 'MISSING_REGISTERED_FUNCTION'; MISSING_STATE_HANDLER: 'MISSING_STATE_HANDLER'; @@ -330,6 +334,9 @@ export interface IsCaseNodeForType { '_case:matcher:type': T; } +// @public +export const isContractCasePlugin: (value: unknown) => value is AnyContractCasePlugin; + // @public export interface IsMockDescriptorForType { // (undocumented) @@ -486,6 +493,9 @@ export type MockOutput = { context: MatchContext; }; +// @public +export const mustResolvePlugin: (moduleContents: unknown, moduleName: string) => AnyContractCasePlugin; + // @public export const mustResolveToNumber: (matcher: AnyCaseMatcherOrData, context: MatchContext) => number; diff --git a/packages/case-plugin-base/docs/case-plugin-base.anycontractcaseplugin.md b/packages/case-plugin-base/docs/case-plugin-base.anycontractcaseplugin.md new file mode 100644 index 000000000..8eca61a34 --- /dev/null +++ b/packages/case-plugin-base/docs/case-plugin-base.anycontractcaseplugin.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [@contract-case/case-plugin-base](./case-plugin-base.md) > [AnyContractCasePlugin](./case-plugin-base.anycontractcaseplugin.md) + +## AnyContractCasePlugin type + +Convenience type for a plugin where the specific matcher and mock types aren't known - which is the case when a plugin has been loaded dynamically. + +**Signature:** + +```typescript +export type AnyContractCasePlugin = ContractCasePlugin, IsMockDescriptorForType, unknown>; +``` +**References:** [ContractCasePlugin](./case-plugin-base.contractcaseplugin.md), [IsCaseNodeForType](./case-plugin-base.iscasenodefortype.md), [IsMockDescriptorForType](./case-plugin-base.ismockdescriptorfortype.md) + diff --git a/packages/case-plugin-base/docs/case-plugin-base.configurationerrorcodes.invalid_plugin_module.md b/packages/case-plugin-base/docs/case-plugin-base.configurationerrorcodes.invalid_plugin_module.md new file mode 100644 index 000000000..95f3d0ea0 --- /dev/null +++ b/packages/case-plugin-base/docs/case-plugin-base.configurationerrorcodes.invalid_plugin_module.md @@ -0,0 +1,17 @@ + + +[Home](./index.md) > [@contract-case/case-plugin-base](./case-plugin-base.md) > [ConfigurationErrorCodes](./case-plugin-base.configurationerrorcodes.md) > [INVALID\_PLUGIN\_MODULE](./case-plugin-base.configurationerrorcodes.invalid_plugin_module.md) + +## ConfigurationErrorCodes.INVALID\_PLUGIN\_MODULE property + +Indicates that a plugin module was loaded, but it doesn't contain a ContractCase plugin. + +Plugin modules must export the assembled plugin object (an object with `description`, `matcherExecutors` and `setupMocks` properties) as the module's default export. + +If you're a user of the plugin, check that the plugin name is spelled correctly, and that the package really is a ContractCase plugin. If it is, this is an error in the plugin's packaging - please contact the plugin's authors. + +**Signature:** + +```typescript +INVALID_PLUGIN_MODULE: 'INVALID_PLUGIN_MODULE'; +``` diff --git a/packages/case-plugin-base/docs/case-plugin-base.configurationerrorcodes.md b/packages/case-plugin-base/docs/case-plugin-base.configurationerrorcodes.md index 9b70b40f4..b1dddfe71 100644 --- a/packages/case-plugin-base/docs/case-plugin-base.configurationerrorcodes.md +++ b/packages/case-plugin-base/docs/case-plugin-base.configurationerrorcodes.md @@ -217,6 +217,29 @@ For contract verification, it should be: 4. Call closePreparedVerification (or close(), depending on your DSL) + + + +[INVALID\_PLUGIN\_MODULE](./case-plugin-base.configurationerrorcodes.invalid_plugin_module.md) + + + + + + + +'INVALID\_PLUGIN\_MODULE' + + + + +Indicates that a plugin module was loaded, but it doesn't contain a ContractCase plugin. + +Plugin modules must export the assembled plugin object (an object with `description`, `matcherExecutors` and `setupMocks` properties) as the module's default export. + +If you're a user of the plugin, check that the plugin name is spelled correctly, and that the package really is a ContractCase plugin. If it is, this is an error in the plugin's packaging - please contact the plugin's authors. + + diff --git a/packages/case-plugin-base/docs/case-plugin-base.iscontractcaseplugin.md b/packages/case-plugin-base/docs/case-plugin-base.iscontractcaseplugin.md new file mode 100644 index 000000000..f5f3dadc6 --- /dev/null +++ b/packages/case-plugin-base/docs/case-plugin-base.iscontractcaseplugin.md @@ -0,0 +1,58 @@ + + +[Home](./index.md) > [@contract-case/case-plugin-base](./case-plugin-base.md) > [isContractCasePlugin](./case-plugin-base.iscontractcaseplugin.md) + +## isContractCasePlugin() function + +Type guard that determines whether an unknown value is shaped like a [ContractCasePlugin](./case-plugin-base.contractcaseplugin.md). + +Note that this only validates the shape of the plugin object - it doesn't confirm that the executors themselves are correctly implemented. + +**Signature:** + +```typescript +isContractCasePlugin: (value: unknown) => value is AnyContractCasePlugin +``` + +## Parameters + + + +
+ +Parameter + + + + +Type + + + + +Description + + +
+ +value + + + + +unknown + + + + +the value to check + + +
+ +**Returns:** + +value is [AnyContractCasePlugin](./case-plugin-base.anycontractcaseplugin.md) + +true if the value has the shape of a ContractCase plugin + diff --git a/packages/case-plugin-base/docs/case-plugin-base.md b/packages/case-plugin-base/docs/case-plugin-base.md index 3964c93c7..7db4dabb7 100644 --- a/packages/case-plugin-base/docs/case-plugin-base.md +++ b/packages/case-plugin-base/docs/case-plugin-base.md @@ -319,6 +319,19 @@ Tests whether a given [MatchResult](./case-plugin-base.matchresult.md) object ha Type guard to determine if an object is a ContractCase matcher descriptor or not + + + +[isContractCasePlugin(value)](./case-plugin-base.iscontractcaseplugin.md) + + + + +Type guard that determines whether an unknown value is shaped like a [ContractCasePlugin](./case-plugin-base.contractcaseplugin.md). + +Note that this only validates the shape of the plugin object - it doesn't confirm that the executors themselves are correctly implemented. + + @@ -383,6 +396,19 @@ Converts a matcher or data into a human friendly string for printing Creates a mismatched matcher expectations error + + + +[mustResolvePlugin(moduleContents, moduleName)](./case-plugin-base.mustresolveplugin.md) + + + + +Extracts the [ContractCasePlugin](./case-plugin-base.contractcaseplugin.md) object from the contents of a dynamically loaded plugin module, or throws a `CaseConfigurationError` if the module doesn't contain one. + +Plugin packages are documented as exporting the assembled plugin object as their default export - but depending on the module system the plugin was built with (and the module system doing the importing), the plugin object may arrive as the module contents itself, as the module namespace's `default` property, or nested a level deeper (eg TypeScript's CommonJS output of `export default`, imported as ESM). This function accepts all of these shapes, so plugin authors don't need to know the details. + + @@ -792,6 +818,17 @@ Description +[AnyContractCasePlugin](./case-plugin-base.anycontractcaseplugin.md) + + + + +Convenience type for a plugin where the specific matcher and mock types aren't known - which is the case when a plugin has been loaded dynamically. + + + + + [CaseError](./case-plugin-base.caseerror.md) diff --git a/packages/case-plugin-base/docs/case-plugin-base.mustresolveplugin.md b/packages/case-plugin-base/docs/case-plugin-base.mustresolveplugin.md new file mode 100644 index 000000000..224e8c18d --- /dev/null +++ b/packages/case-plugin-base/docs/case-plugin-base.mustresolveplugin.md @@ -0,0 +1,78 @@ + + +[Home](./index.md) > [@contract-case/case-plugin-base](./case-plugin-base.md) > [mustResolvePlugin](./case-plugin-base.mustresolveplugin.md) + +## mustResolvePlugin() function + +Extracts the [ContractCasePlugin](./case-plugin-base.contractcaseplugin.md) object from the contents of a dynamically loaded plugin module, or throws a `CaseConfigurationError` if the module doesn't contain one. + +Plugin packages are documented as exporting the assembled plugin object as their default export - but depending on the module system the plugin was built with (and the module system doing the importing), the plugin object may arrive as the module contents itself, as the module namespace's `default` property, or nested a level deeper (eg TypeScript's CommonJS output of `export default`, imported as ESM). This function accepts all of these shapes, so plugin authors don't need to know the details. + +**Signature:** + +```typescript +mustResolvePlugin: (moduleContents: unknown, moduleName: string) => AnyContractCasePlugin +``` + +## Parameters + + + + +
+ +Parameter + + + + +Type + + + + +Description + + +
+ +moduleContents + + + + +unknown + + + + +whatever `import()` (or `require`) returned for the plugin module + + +
+ +moduleName + + + + +string + + + + +the name of the module, for error messages + + +
+ +**Returns:** + +[AnyContractCasePlugin](./case-plugin-base.anycontractcaseplugin.md) + +the plugin object + +## Exceptions + +CaseConfigurationError if no plugin-shaped object could be found + diff --git a/packages/case-plugin-base/src/errors/ErrorCodes.ts b/packages/case-plugin-base/src/errors/ErrorCodes.ts index 53bf8b074..4424328bd 100644 --- a/packages/case-plugin-base/src/errors/ErrorCodes.ts +++ b/packages/case-plugin-base/src/errors/ErrorCodes.ts @@ -106,6 +106,21 @@ export interface ConfigurationErrorCodes { */ INVALID_LIFECYCLE: 'INVALID_LIFECYCLE'; + /** + * Indicates that a plugin module was loaded, but it doesn't contain a + * ContractCase plugin. + * + * Plugin modules must export the assembled plugin object (an object with + * `description`, `matcherExecutors` and `setupMocks` properties) as the + * module's default export. + * + * If you're a user of the plugin, check that the plugin name is spelled + * correctly, and that the package really is a ContractCase plugin. If it + * is, this is an error in the plugin's packaging - please contact the + * plugin's authors. + */ + INVALID_PLUGIN_MODULE: 'INVALID_PLUGIN_MODULE'; + /** * Indicates that the plugin name or path that you provided was invalid. * Most users won't come across this message. @@ -290,6 +305,7 @@ export const ErrorCodes: ErrorCodeDefinitions = { BAD_DSL_DECLARATION: 'BAD_DSL_DECLARATION', INVALID_CONFIG: 'INVALID_CONFIG', INVALID_LIFECYCLE: 'INVALID_LIFECYCLE', + INVALID_PLUGIN_MODULE: 'INVALID_PLUGIN_MODULE', INVALID_PLUGIN_NAME: 'INVALID_PLUGIN_NAME', MISSING_STATE_HANDLER: 'MISSING_STATE_HANDLER', MISSING_TEST_FUNCTION: 'MISSING_TEST_FUNCTION', diff --git a/packages/case-plugin-base/src/index.ts b/packages/case-plugin-base/src/index.ts index 948cfb6fb..126e02a59 100644 --- a/packages/case-plugin-base/src/index.ts +++ b/packages/case-plugin-base/src/index.ts @@ -9,5 +9,6 @@ export * from './errors'; export * from './logger'; export * from './matchers'; export * from './mocks'; +export * from './plugins'; export * from './types'; diff --git a/packages/case-plugin-base/src/plugins/index.ts b/packages/case-plugin-base/src/plugins/index.ts new file mode 100644 index 000000000..98a976137 --- /dev/null +++ b/packages/case-plugin-base/src/plugins/index.ts @@ -0,0 +1 @@ +export * from './resolve'; diff --git a/packages/case-plugin-base/src/plugins/resolve.spec.ts b/packages/case-plugin-base/src/plugins/resolve.spec.ts new file mode 100644 index 000000000..d70c31c93 --- /dev/null +++ b/packages/case-plugin-base/src/plugins/resolve.spec.ts @@ -0,0 +1,95 @@ +import { isContractCasePlugin, mustResolvePlugin } from './resolve'; +import { CaseConfigurationError } from '../errors'; + +const makePlugin = (name: string) => ({ + description: { + humanReadableName: name, + shortName: name, + uniqueMachineName: `test:${name}`, + version: '1.0.0', + }, + matcherExecutors: {}, + setupMocks: {}, +}); + +describe('isContractCasePlugin', () => { + it('accepts a well-formed plugin', () => { + expect(isContractCasePlugin(makePlugin('accepts'))).toBe(true); + }); + + it.each([ + ['null', null], + ['undefined', undefined], + ['a string', 'not a plugin'], + ['an empty object', {}], + ['a description-only object', { description: makePlugin('d').description }], + [ + 'a plugin with a malformed description', + { ...makePlugin('m'), description: { humanReadableName: 42 } }, + ], + [ + 'a plugin missing setupMocks', + { description: makePlugin('s').description, matcherExecutors: {} }, + ], + ])('rejects %s', (_name, value) => { + expect(isContractCasePlugin(value)).toBe(false); + }); +}); + +describe('mustResolvePlugin', () => { + // The shapes below are what a plugin module's contents look like after + // loading, depending on how the plugin was packaged and how it was + // imported. See the comments on each case. + + it('resolves a plugin that is the module contents itself', () => { + // eg `module.exports = plugin` loaded via require() + const plugin = makePlugin('direct'); + expect(mustResolvePlugin(plugin, 'direct-plugin')).toBe(plugin); + }); + + it('resolves a plugin under a default property', () => { + // eg native ESM `export default plugin` loaded via import(), or + // `module.exports = plugin` loaded via import() + const plugin = makePlugin('esm'); + expect(mustResolvePlugin({ default: plugin }, 'esm-plugin')).toBe(plugin); + }); + + it('resolves a plugin under two levels of default', () => { + // eg TypeScript's CommonJS output of `export default plugin` + // (`exports.default = plugin`) loaded via import(), where the namespace's + // default is module.exports + const plugin = makePlugin('ts-cjs'); + expect( + mustResolvePlugin( + { __esModule: true, default: { __esModule: true, default: plugin } }, + 'ts-cjs-plugin', + ), + ).toBe(plugin); + }); + + it('prefers a plugin-shaped module over its default property', () => { + // A module that both is a plugin and has a default property should not + // be unwrapped further + const plugin = { ...makePlugin('outer'), default: makePlugin('inner') }; + expect( + mustResolvePlugin(plugin, 'named-exports-plugin').description + .humanReadableName, + ).toBe('outer'); + }); + + it.each([ + ['an empty module', {}], + ['a module with a non-plugin default', { default: { some: 'data' } }], + ['null module contents', null], + ['a deeply nested plugin beyond the unwrap depth', { + default: { default: { default: { default: makePlugin('too-deep') } } }, + }], + ])('throws a CaseConfigurationError for %s', (_name, moduleContents) => { + expect(() => mustResolvePlugin(moduleContents, 'bad-plugin')).toThrow( + CaseConfigurationError, + ); + expect(() => mustResolvePlugin(moduleContents, 'bad-plugin')).toThrow( + "The module 'bad-plugin' was loaded, but it doesn't contain a ContractCase plugin", + ); + }); +}); diff --git a/packages/case-plugin-base/src/plugins/resolve.ts b/packages/case-plugin-base/src/plugins/resolve.ts new file mode 100644 index 000000000..98af0080c --- /dev/null +++ b/packages/case-plugin-base/src/plugins/resolve.ts @@ -0,0 +1,108 @@ +import { ContractCasePlugin } from '../types'; +import { IsCaseNodeForType } from '../matchers/utility.types'; +import { IsMockDescriptorForType } from '../mocks/executors.types'; +import { CaseConfigurationError } from '../errors'; + +/** + * Convenience type for a plugin where the specific matcher and mock types + * aren't known - which is the case when a plugin has been loaded dynamically. + * + * @public + */ +export type AnyContractCasePlugin = ContractCasePlugin< + string, + string, + IsCaseNodeForType, + IsMockDescriptorForType, + unknown +>; + +/** + * Type guard that determines whether an unknown value is shaped like a + * {@link ContractCasePlugin}. + * + * Note that this only validates the shape of the plugin object - it + * doesn't confirm that the executors themselves are correctly implemented. + * + * @public + * @param value - the value to check + * @returns true if the value has the shape of a ContractCase plugin + */ +export const isContractCasePlugin = ( + value: unknown, +): value is AnyContractCasePlugin => { + if (value == null || typeof value !== 'object') { + return false; + } + const candidate = value as Partial; + return ( + candidate.description != null && + typeof candidate.description === 'object' && + typeof candidate.description.humanReadableName === 'string' && + typeof candidate.description.shortName === 'string' && + typeof candidate.description.uniqueMachineName === 'string' && + typeof candidate.description.version === 'string' && + candidate.matcherExecutors != null && + typeof candidate.matcherExecutors === 'object' && + candidate.setupMocks != null && + typeof candidate.setupMocks === 'object' + ); +}; + +/** + * Depending on how a plugin module was packaged (ESM `export default`, + * CommonJS `module.exports = plugin`, or transpiled variants of either), the + * plugin object may be the module contents itself, or nested under one or + * two levels of `default`. This walks down until it finds something + * plugin-shaped. + */ +const unwrapModuleContents = ( + candidate: unknown, + remainingDepth: number, +): unknown => + isContractCasePlugin(candidate) || + remainingDepth <= 0 || + candidate == null || + typeof candidate !== 'object' || + !('default' in candidate) + ? candidate + : unwrapModuleContents( + (candidate as { default: unknown }).default, + remainingDepth - 1, + ); + +/** + * Extracts the {@link ContractCasePlugin} object from the contents of a + * dynamically loaded plugin module, or throws a `CaseConfigurationError` if + * the module doesn't contain one. + * + * Plugin packages are documented as exporting the assembled plugin object as + * their default export - but depending on the module system the plugin was + * built with (and the module system doing the importing), the plugin object + * may arrive as the module contents itself, as the module namespace's + * `default` property, or nested a level deeper (eg TypeScript's CommonJS + * output of `export default`, imported as ESM). This function accepts all of + * these shapes, so plugin authors don't need to know the details. + * + * @public + * @param moduleContents - whatever `import()` (or `require`) returned for the plugin module + * @param moduleName - the name of the module, for error messages + * @returns the plugin object + * @throws CaseConfigurationError if no plugin-shaped object could be found + */ +export const mustResolvePlugin = ( + moduleContents: unknown, + moduleName: string, +): AnyContractCasePlugin => { + // Two levels of unwrapping is enough for every known packaging style; + // the third level is headroom for a transpiler wrapping a wrapped module. + const candidate = unwrapModuleContents(moduleContents, 3); + if (!isContractCasePlugin(candidate)) { + throw new CaseConfigurationError( + `The module '${moduleName}' was loaded, but it doesn't contain a ContractCase plugin. Plugin modules must export the assembled plugin object (an object with 'description', 'matcherExecutors' and 'setupMocks' properties) as the module's default export. Please check that '${moduleName}' really is a ContractCase plugin, and that its name is spelled correctly. If it is a plugin, this is an error in the plugin's packaging - please contact the plugin's authors.`, + 'DONT_ADD_LOCATION', + 'INVALID_PLUGIN_MODULE', + ); + } + return candidate; +}; diff --git a/packages/case-plugin-base/temp/case-plugin-base.api.md b/packages/case-plugin-base/temp/case-plugin-base.api.md index 13131e135..068f59a85 100644 --- a/packages/case-plugin-base/temp/case-plugin-base.api.md +++ b/packages/case-plugin-base/temp/case-plugin-base.api.md @@ -21,6 +21,9 @@ export const actualToString: (actual: T, indent?: number) => string; // @public export const addLocation: (location: string, context: MatchContext) => MatchContext; +// @public +export type AnyContractCasePlugin = ContractCasePlugin, IsMockDescriptorForType, unknown>; + // Warning: (ae-internal-missing-underscore) The name "applyNodeToContext" should be prefixed with an underscore because the declaration is marked as @internal // // @internal @@ -100,6 +103,7 @@ export interface ConfigurationErrorCodes { FAKE_NEVER_CALLED: 'FAKE_NEVER_CALLED'; INVALID_CONFIG: 'INVALID_CONFIG'; INVALID_LIFECYCLE: 'INVALID_LIFECYCLE'; + INVALID_PLUGIN_MODULE: 'INVALID_PLUGIN_MODULE'; INVALID_PLUGIN_NAME: 'INVALID_PLUGIN_NAME'; MISSING_REGISTERED_FUNCTION: 'MISSING_REGISTERED_FUNCTION'; MISSING_STATE_HANDLER: 'MISSING_STATE_HANDLER'; @@ -330,6 +334,9 @@ export interface IsCaseNodeForType { '_case:matcher:type': T; } +// @public +export const isContractCasePlugin: (value: unknown) => value is AnyContractCasePlugin; + // @public export interface IsMockDescriptorForType { // (undocumented) @@ -486,6 +493,9 @@ export type MockOutput = { context: MatchContext; }; +// @public +export const mustResolvePlugin: (moduleContents: unknown, moduleName: string) => AnyContractCasePlugin; + // @public export const mustResolveToNumber: (matcher: AnyCaseMatcherOrData, context: MatchContext) => number; From 0c9c8c62ae8ad52e36f9ccc7b28e457093103963 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 03:18:17 +0000 Subject: [PATCH 03/10] feat(js-dsl): Add loadPlugins to ContractCaseDefiner and ContractVerifier Mirrors the Java DSL's loadPlugins methods, so that JS/TS users can load plugins too. Includes end-to-end tests that load a fixture plugin by package name and use its matcher during contract definition and verification. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014mD6p9EEmftbpBQHZZBWB1 --- ...386f7b45884498bd22bce90b2075fe44.case.json | 65 +++++++++++++++++++ .../plugin-fixture-consumer-main.case.json | 65 +++++++++++++++++++ .../contract-case-dsl-js-jest/package.json | 1 + .../src/index.plugin.define.spec.ts | 43 ++++++++++++ .../src/index.plugin.spec.verify.ts | 21 ++++++ .../src/connectors/ContractDefiner.ts | 22 +++++++ .../src/connectors/ContractVerifier.ts | 26 ++++++++ 7 files changed, 243 insertions(+) create mode 100644 packages/contract-case-dsl-js-jest/case-contracts/plugin-fixture-provider/plugin-fixture-consumer-20fa3511745cb66b4a8139545586ff77386f7b45884498bd22bce90b2075fe44.case.json create mode 100644 packages/contract-case-dsl-js-jest/case-contracts/plugin-fixture-provider/plugin-fixture-consumer-main.case.json create mode 100644 packages/contract-case-dsl-js-jest/src/index.plugin.define.spec.ts create mode 100644 packages/contract-case-dsl-js-jest/src/index.plugin.spec.verify.ts diff --git a/packages/contract-case-dsl-js-jest/case-contracts/plugin-fixture-provider/plugin-fixture-consumer-20fa3511745cb66b4a8139545586ff77386f7b45884498bd22bce90b2075fe44.case.json b/packages/contract-case-dsl-js-jest/case-contracts/plugin-fixture-provider/plugin-fixture-consumer-20fa3511745cb66b4a8139545586ff77386f7b45884498bd22bce90b2075fe44.case.json new file mode 100644 index 000000000..5e9c47600 --- /dev/null +++ b/packages/contract-case-dsl-js-jest/case-contracts/plugin-fixture-provider/plugin-fixture-consumer-20fa3511745cb66b4a8139545586ff77386f7b45884498bd22bce90b2075fe44.case.json @@ -0,0 +1,65 @@ +{ + "contractType": "case::contract", + "description": { + "consumerName": "plugin fixture consumer", + "providerName": "plugin fixture provider" + }, + "metadata": { + "_case": { + "version": "case-internal-tests", + "hash": "20fa3511745cb66b4a8139545586ff77386f7b45884498bd22bce90b2075fe44" + } + }, + "matcherLookup": { + "matcher:returns test fixture default-export": { + "_case:matcher:type": "_case:FunctionResultMatcher", + "success": { + "_case:matcher:type": "test-fixture:default-export:Matcher" + } + }, + "matcher:An invocation of returnsPluginMatchedValue()": { + "_case:matcher:type": "_case:FunctionArgumentsMatcher", + "arguments": [] + } + }, + "examples": [ + { + "states": [], + "mock": { + "_case:mock:type": "_case:MockFunctionExecution", + "_case:run:context:setup": { + "read": { + "type": "_case:MockFunctionCaller", + "stateVariables": "state", + "triggers": "generated" + }, + "write": { + "type": "_case:MockFunctionExecution", + "stateVariables": "default", + "triggers": "provided" + } + }, + "request": { + "_case:matcher:type": "_case:Lookup", + "_case:matcher:uniqueName": "An invocation of returnsPluginMatchedValue()", + "_case:matcher:child": { + "_case:matcher:type": "_case:FunctionArgumentsMatcher", + "arguments": [] + } + }, + "response": { + "_case:matcher:type": "_case:Lookup", + "_case:matcher:uniqueName": "returns test fixture default-export", + "_case:matcher:child": { + "_case:matcher:type": "_case:FunctionResultMatcher", + "success": { + "_case:matcher:type": "test-fixture:default-export:Matcher" + } + } + }, + "functionName": "returnsPluginMatchedValue" + }, + "result": "VERIFIED" + } + ] +} \ No newline at end of file diff --git a/packages/contract-case-dsl-js-jest/case-contracts/plugin-fixture-provider/plugin-fixture-consumer-main.case.json b/packages/contract-case-dsl-js-jest/case-contracts/plugin-fixture-provider/plugin-fixture-consumer-main.case.json new file mode 100644 index 000000000..5e9c47600 --- /dev/null +++ b/packages/contract-case-dsl-js-jest/case-contracts/plugin-fixture-provider/plugin-fixture-consumer-main.case.json @@ -0,0 +1,65 @@ +{ + "contractType": "case::contract", + "description": { + "consumerName": "plugin fixture consumer", + "providerName": "plugin fixture provider" + }, + "metadata": { + "_case": { + "version": "case-internal-tests", + "hash": "20fa3511745cb66b4a8139545586ff77386f7b45884498bd22bce90b2075fe44" + } + }, + "matcherLookup": { + "matcher:returns test fixture default-export": { + "_case:matcher:type": "_case:FunctionResultMatcher", + "success": { + "_case:matcher:type": "test-fixture:default-export:Matcher" + } + }, + "matcher:An invocation of returnsPluginMatchedValue()": { + "_case:matcher:type": "_case:FunctionArgumentsMatcher", + "arguments": [] + } + }, + "examples": [ + { + "states": [], + "mock": { + "_case:mock:type": "_case:MockFunctionExecution", + "_case:run:context:setup": { + "read": { + "type": "_case:MockFunctionCaller", + "stateVariables": "state", + "triggers": "generated" + }, + "write": { + "type": "_case:MockFunctionExecution", + "stateVariables": "default", + "triggers": "provided" + } + }, + "request": { + "_case:matcher:type": "_case:Lookup", + "_case:matcher:uniqueName": "An invocation of returnsPluginMatchedValue()", + "_case:matcher:child": { + "_case:matcher:type": "_case:FunctionArgumentsMatcher", + "arguments": [] + } + }, + "response": { + "_case:matcher:type": "_case:Lookup", + "_case:matcher:uniqueName": "returns test fixture default-export", + "_case:matcher:child": { + "_case:matcher:type": "_case:FunctionResultMatcher", + "success": { + "_case:matcher:type": "test-fixture:default-export:Matcher" + } + } + }, + "functionName": "returnsPluginMatchedValue" + }, + "result": "VERIFIED" + } + ] +} \ No newline at end of file diff --git a/packages/contract-case-dsl-js-jest/package.json b/packages/contract-case-dsl-js-jest/package.json index 094c1dcfb..8cb5beeba 100644 --- a/packages/contract-case-dsl-js-jest/package.json +++ b/packages/contract-case-dsl-js-jest/package.json @@ -57,6 +57,7 @@ }, "devDependencies": { "@contract-case/case-definition-dsl": "0.30.1", + "@contract-case/test-plugin-fixture": "file:../case-connector/test-fixtures/contract-case-test-plugin", "@contract-case/case-entities-internal": "0.30.1", "@contract-case/case-plugin-base": "0.30.1", "@contract-case/cli": "0.30.1", diff --git a/packages/contract-case-dsl-js-jest/src/index.plugin.define.spec.ts b/packages/contract-case-dsl-js-jest/src/index.plugin.define.spec.ts new file mode 100644 index 000000000..d2653aca0 --- /dev/null +++ b/packages/contract-case-dsl-js-jest/src/index.plugin.define.spec.ts @@ -0,0 +1,43 @@ +import { willCallFunction, FunctionExecutorConfig, defineContract } from './index.js'; + +// This matcher is provided by the fixture plugin (which lives in +// case-connector/test-fixtures). It accepts any actual value, and strips to +// the example 'stripped-by-default-export'. +const fixturePluginMatcher = { + '_case:matcher:type': 'test-fixture:default-export:Matcher', +}; + +describe('definition with a loaded plugin', () => { + defineContract( + { + consumerName: 'plugin fixture consumer', + providerName: 'plugin fixture provider', + changedContracts: 'OVERWRITE', + }, + (contract) => { + beforeAll(() => + contract.loadPlugins('@contract-case/test-plugin-fixture'), + ); + + describe('an interaction that uses a plugin-provided matcher', () => { + it('strips and matches with the plugin matcher', () => + contract.runInteraction( + { + definition: willCallFunction({ + arguments: [], + returnValue: fixturePluginMatcher, + functionName: 'returnsPluginMatchedValue', + }), + }, + { + trigger: async (setup: FunctionExecutorConfig) => + setup.getFunction(setup.mock.functionHandle)(), + testResponse: (returnValue) => { + expect(returnValue).toEqual('stripped-by-default-export'); + }, + }, + )); + }); + }, + ); +}); diff --git a/packages/contract-case-dsl-js-jest/src/index.plugin.spec.verify.ts b/packages/contract-case-dsl-js-jest/src/index.plugin.spec.verify.ts new file mode 100644 index 000000000..59f91e55b --- /dev/null +++ b/packages/contract-case-dsl-js-jest/src/index.plugin.spec.verify.ts @@ -0,0 +1,21 @@ +import { verifyContract } from './boundaries/jest/jest.js'; + +describe('verification with a loaded plugin', () => { + verifyContract( + { + providerName: 'plugin fixture provider', + throwOnFail: true, + }, + (verifier) => { + beforeAll(() => + verifier.loadPlugins('@contract-case/test-plugin-fixture'), + ); + // The plugin's matcher accepts any value, so this function's return + // value doesn't need to match the example recorded in the contract + verifier.registerFunction( + 'returnsPluginMatchedValue', + () => 'a different value to the example', + ); + }, + ); +}); diff --git a/packages/contract-case-dsl-js/src/connectors/ContractDefiner.ts b/packages/contract-case-dsl-js/src/connectors/ContractDefiner.ts index 7373a0b1e..35ecb936e 100644 --- a/packages/contract-case-dsl-js/src/connectors/ContractDefiner.ts +++ b/packages/contract-case-dsl-js/src/connectors/ContractDefiner.ts @@ -2,6 +2,7 @@ import { BoundaryAnyMatcher, BoundaryContractDefiner, BoundaryMockDefinition, + BoundaryPluginLoader, } from '@contract-case/case-connector/cjs'; import { interactions } from '@contract-case/case-definition-dsl'; @@ -83,6 +84,27 @@ export class ContractCaseDefiner { .catch(errorHandler); } + /** + * Loads one or more plugins, which must be the names of plugin packages + * installed in the current project (eg with `npm install --save-dev`). + * + * Call this before running any interactions that need the plugin(s). + * + * @param pluginNames - The names of the plugin packages to load. + * @returns a Promise that resolves once the plugins are loaded. + */ + loadPlugins(...pluginNames: string[]): Promise { + return new BoundaryPluginLoader( + mapConfig({ ...this.config, testRunId: 'DEFINER_LOAD_PLUGIN' }), + defaultPrinter, + defaultPrinter, + [versionString], + ) + .loadPlugins(pluginNames) + .then(mapSuccess) + .catch(errorHandler); + } + endRecord(): Promise { return this.boundaryDefiner .endRecord() diff --git a/packages/contract-case-dsl-js/src/connectors/ContractVerifier.ts b/packages/contract-case-dsl-js/src/connectors/ContractVerifier.ts index 45a897b1f..700bfb956 100644 --- a/packages/contract-case-dsl-js/src/connectors/ContractVerifier.ts +++ b/packages/contract-case-dsl-js/src/connectors/ContractVerifier.ts @@ -1,6 +1,7 @@ import { BoundaryContractVerifier, BoundaryInvokableFunction, + BoundaryPluginLoader, } from '@contract-case/case-connector/cjs'; import { @@ -29,9 +30,12 @@ export class ContractVerifier { private invokeableFunctions: Record; + private printer: typeof defaultPrinter; + constructor(config: ContractCaseVerifierConfig, printer = defaultPrinter) { this.config = config; this.invokeableFunctions = {}; + this.printer = printer; try { this.boundaryVerifier = new BoundaryContractVerifier( @@ -63,6 +67,28 @@ export class ContractVerifier { } } + /** + * Loads one or more plugins, which must be the names of plugin packages + * installed in the current project (eg with `npm install --save-dev`). + * + * Call this before preparing or running any verification tests that need + * the plugin(s). + * + * @param pluginNames - The names of the plugin packages to load. + * @returns a Promise that resolves once the plugins are loaded. + */ + loadPlugins(...pluginNames: string[]): Promise { + return new BoundaryPluginLoader( + mapConfig({ ...this.config, testRunId: 'VERIFICATION_LOAD_PLUGIN' }), + this.printer, + this.printer, + [versionString], + ) + .loadPlugins(pluginNames) + .then(mapSuccess) + .catch(errorHandler); + } + /** * Registers a function that can be invoked by ContractCase during a verification. * From 21297099eb618d1a0896525c7a9ac79d52fce36b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 03:18:34 +0000 Subject: [PATCH 04/10] docs: Update plugin loading documentation now that the JS DSL supports it Adds TypeScript examples to the loading documentation, removes the caveat about the JS DSL not exposing loadPlugins, and ticks off the plugin loading items in the maintainer todo list. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014mD6p9EEmftbpBQHZZBWB1 --- docs/maintainers/todo.md | 12 ++++---- .../docs/plugins/loading-plugins.md | 28 +++++++++++++------ .../documentation/docs/plugins/plugins.mdx | 13 +++------ 3 files changed, 31 insertions(+), 22 deletions(-) diff --git a/docs/maintainers/todo.md b/docs/maintainers/todo.md index a5cc1a797..c2eab7b70 100644 --- a/docs/maintainers/todo.md +++ b/docs/maintainers/todo.md @@ -38,20 +38,22 @@ versions, as they are listed in source code / generated documentation. Plugins: -- [ ] Test that the Java connector resolves plugins from the project-local +- [x] Test that the Java connector resolves plugins from the project-local `node_modules`. The connector bundle is extracted to a temp directory and run from there, so bare-specifier `import()` may resolve from the wrong place - needs an end-to-end test loading a plugin by name from a Java - project. -- [ ] Expose `loadPlugins` in the TS DSL (currently only the Java DSL exposes + project. (Fixed by resolving plugin names against the working directory + in `BoundaryPluginLoader`; tested by `PluginLoadTest` in dsl-java.) +- [x] Expose `loadPlugins` in the TS DSL (currently only the Java DSL exposes it) -- [ ] Unwrap the module namespace when loading plugins in +- [x] Unwrap the module namespace when loading plugins in `BoundaryPluginLoader` - a dynamic `import()` namespace never exposes `description` etc for plugins built with `export default` or `module.exports = plugin` (only individual named exports work), so the documented "default export one `ContractCasePlugin` object" shape needs `.default` unwrapping, plus an end-to-end test loading a third-party - plugin by name. + plugin by name. (Fixed with `mustResolvePlugin` in `case-plugin-base`; + tested by `BoundaryPluginLoader.spec.ts`.) - [ ] Complete the DSL generator so plugin authors can run it - hook it into the CLI and remove the hardcoded paths in `case-definition-generator/src/index.ts`. diff --git a/packages/documentation/docs/plugins/loading-plugins.md b/packages/documentation/docs/plugins/loading-plugins.md index 20eb9a438..0023f4778 100644 --- a/packages/documentation/docs/plugins/loading-plugins.md +++ b/packages/documentation/docs/plugins/loading-plugins.md @@ -20,6 +20,26 @@ steps: ``` 2. Ask ContractCase to load it, before running any interactions that use it. + + From the TypeScript/JavaScript DSL, `loadPlugins` returns a Promise, so + the natural place to call it is a `beforeAll`: + + ```ts + defineContract(config, (contract) => { + beforeAll(() => contract.loadPlugins('@yourorg/contract-case-plugin-ulid')); + + // ... interactions using the plugin's matchers and mocks + }); + ``` + + and on the verification side: + + ```ts + verifyContract(config, (verifier) => { + beforeAll(() => verifier.loadPlugins('@yourorg/contract-case-plugin-ulid')); + }); + ``` + From the Java DSL: ```java @@ -38,14 +58,6 @@ steps: Loading is idempotent - loading the same plugin (at the same version) twice is harmless, and the second load is skipped. -:::caution WARNING - -The JavaScript/TypeScript DSL doesn't yet expose `loadPlugins` - currently -only the Java DSL does. This is an oversight rather than a design decision, -and will be fixed in an upcoming release. - -::: - ### Both sides need the plugin The matcher and mock type constants from your plugin are written into the diff --git a/packages/documentation/docs/plugins/plugins.mdx b/packages/documentation/docs/plugins/plugins.mdx index ac570e4ce..eb16b30bd 100644 --- a/packages/documentation/docs/plugins/plugins.mdx +++ b/packages/documentation/docs/plugins/plugins.mdx @@ -112,14 +112,9 @@ changes to the plugin API are indicated by minor version bumps of :::caution WARNING -Some parts of the plugin workflow are still works in progress: - -- The [DSL generator](./dsl-generation) can't yet be run outside the - ContractCase repository, so DSL classes for your plugin currently need to be - written by hand. -- The JavaScript/TypeScript DSL doesn't yet expose `loadPlugins` (the Java DSL - does). This is an oversight and will be fixed in a future release. - -Both of these are described in more detail on their relevant pages. +One part of the plugin workflow is still work in progress: the +[DSL generator](./dsl-generation) can't yet be run outside the ContractCase +repository, so DSL classes for your plugin currently need to be written by +hand. See [Declaring your DSL](./dsl-generation) for details. ::: From f0baf1c99cb242150e1d96f9935f39c8c245455a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 03:20:12 +0000 Subject: [PATCH 05/10] fix(java-dsl): Send plugin module names when loading plugins from the definer The definer's loadPlugins built its request by calling .addAll() on the protobuf builder's unmodifiable module names list view, so the module names were never sent to the connector. Now uses .addAllModuleNames(), matching the verifier. Also adds an end-to-end test that loads a fixture plugin by package name, proving that plugins are resolved from the project's node_modules even though the connector runs from a temporary directory - and corrects the loadPlugins javadoc, which claimed paths were supported (they aren't, for security reasons). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014mD6p9EEmftbpBQHZZBWB1 --- ...6461e634548edbca9cb3cf877fce0730.case.json | 70 ++++++++++++++ ...ava-plugin-fixture-consumer-main.case.json | 70 ++++++++++++++ packages/dsl-java/package.json | 1 + .../contractcase/ContractDefiner.java | 10 +- .../contractcase/ContractVerifier.java | 9 ++ .../client/InternalDefinerClient.java | 10 +- .../test/plugin/PluginLoadTest.java | 94 +++++++++++++++++++ 7 files changed, 255 insertions(+), 9 deletions(-) create mode 100644 packages/dsl-java/case-contracts/java-plugin-fixture-provider/java-plugin-fixture-consumer-c3f3a15841928272758ddbbaf78dbc7c6461e634548edbca9cb3cf877fce0730.case.json create mode 100644 packages/dsl-java/case-contracts/java-plugin-fixture-provider/java-plugin-fixture-consumer-main.case.json create mode 100644 packages/dsl-java/src/test/java/io/contract_testing/contractcase/test/plugin/PluginLoadTest.java diff --git a/packages/dsl-java/case-contracts/java-plugin-fixture-provider/java-plugin-fixture-consumer-c3f3a15841928272758ddbbaf78dbc7c6461e634548edbca9cb3cf877fce0730.case.json b/packages/dsl-java/case-contracts/java-plugin-fixture-provider/java-plugin-fixture-consumer-c3f3a15841928272758ddbbaf78dbc7c6461e634548edbca9cb3cf877fce0730.case.json new file mode 100644 index 000000000..2424981e4 --- /dev/null +++ b/packages/dsl-java/case-contracts/java-plugin-fixture-provider/java-plugin-fixture-consumer-c3f3a15841928272758ddbbaf78dbc7c6461e634548edbca9cb3cf877fce0730.case.json @@ -0,0 +1,70 @@ +{ + "contractType": "case::contract", + "description": { + "consumerName": "Java Plugin Fixture Consumer", + "providerName": "Java Plugin Fixture Provider" + }, + "metadata": { + "_case": { + "version": "0.30.1", + "hash": "c3f3a15841928272758ddbbaf78dbc7c6461e634548edbca9cb3cf877fce0730" + } + }, + "matcherLookup": { + "matcher:returns test fixture default-export": { + "_case:matcher:type": "_case:FunctionResultMatcher", + "success": { + "_case:matcher:type": "test-fixture:default-export:Matcher" + } + }, + "matcher:An invocation of ReturnsPluginMatchedValue()": { + "_case:matcher:type": "_case:FunctionArgumentsMatcher", + "arguments": [] + } + }, + "examples": [ + { + "states": [ + { + "_case:state:type": "_case:NamedState", + "stateName": "The plugin fixture is loaded" + } + ], + "mock": { + "_case:mock:type": "_case:MockFunctionExecution", + "_case:run:context:setup": { + "read": { + "stateVariables": "state", + "triggers": "generated", + "type": "_case:MockFunctionCaller" + }, + "write": { + "stateVariables": "default", + "triggers": "provided", + "type": "_case:MockFunctionExecution" + } + }, + "functionName": "ReturnsPluginMatchedValue", + "request": { + "_case:matcher:type": "_case:Lookup", + "_case:matcher:uniqueName": "An invocation of ReturnsPluginMatchedValue()", + "_case:matcher:child": { + "_case:matcher:type": "_case:FunctionArgumentsMatcher", + "arguments": [] + } + }, + "response": { + "_case:matcher:type": "_case:Lookup", + "_case:matcher:uniqueName": "returns test fixture default-export", + "_case:matcher:child": { + "_case:matcher:type": "_case:FunctionResultMatcher", + "success": { + "_case:matcher:type": "test-fixture:default-export:Matcher" + } + } + } + }, + "result": "VERIFIED" + } + ] +} \ No newline at end of file diff --git a/packages/dsl-java/case-contracts/java-plugin-fixture-provider/java-plugin-fixture-consumer-main.case.json b/packages/dsl-java/case-contracts/java-plugin-fixture-provider/java-plugin-fixture-consumer-main.case.json new file mode 100644 index 000000000..2424981e4 --- /dev/null +++ b/packages/dsl-java/case-contracts/java-plugin-fixture-provider/java-plugin-fixture-consumer-main.case.json @@ -0,0 +1,70 @@ +{ + "contractType": "case::contract", + "description": { + "consumerName": "Java Plugin Fixture Consumer", + "providerName": "Java Plugin Fixture Provider" + }, + "metadata": { + "_case": { + "version": "0.30.1", + "hash": "c3f3a15841928272758ddbbaf78dbc7c6461e634548edbca9cb3cf877fce0730" + } + }, + "matcherLookup": { + "matcher:returns test fixture default-export": { + "_case:matcher:type": "_case:FunctionResultMatcher", + "success": { + "_case:matcher:type": "test-fixture:default-export:Matcher" + } + }, + "matcher:An invocation of ReturnsPluginMatchedValue()": { + "_case:matcher:type": "_case:FunctionArgumentsMatcher", + "arguments": [] + } + }, + "examples": [ + { + "states": [ + { + "_case:state:type": "_case:NamedState", + "stateName": "The plugin fixture is loaded" + } + ], + "mock": { + "_case:mock:type": "_case:MockFunctionExecution", + "_case:run:context:setup": { + "read": { + "stateVariables": "state", + "triggers": "generated", + "type": "_case:MockFunctionCaller" + }, + "write": { + "stateVariables": "default", + "triggers": "provided", + "type": "_case:MockFunctionExecution" + } + }, + "functionName": "ReturnsPluginMatchedValue", + "request": { + "_case:matcher:type": "_case:Lookup", + "_case:matcher:uniqueName": "An invocation of ReturnsPluginMatchedValue()", + "_case:matcher:child": { + "_case:matcher:type": "_case:FunctionArgumentsMatcher", + "arguments": [] + } + }, + "response": { + "_case:matcher:type": "_case:Lookup", + "_case:matcher:uniqueName": "returns test fixture default-export", + "_case:matcher:child": { + "_case:matcher:type": "_case:FunctionResultMatcher", + "success": { + "_case:matcher:type": "test-fixture:default-export:Matcher" + } + } + } + }, + "result": "VERIFIED" + } + ] +} \ No newline at end of file diff --git a/packages/dsl-java/package.json b/packages/dsl-java/package.json index bce8f309a..c189a20fc 100644 --- a/packages/dsl-java/package.json +++ b/packages/dsl-java/package.json @@ -28,6 +28,7 @@ "url": "https://github.com/case-contract-testing/contract-case/issues" }, "devDependencies": { + "@contract-case/test-plugin-fixture": "file:../case-connector/test-fixtures/contract-case-test-plugin", "rimraf": "^6.1.2" }, "dependencies": { diff --git a/packages/dsl-java/src/main/java/io/contract_testing/contractcase/ContractDefiner.java b/packages/dsl-java/src/main/java/io/contract_testing/contractcase/ContractDefiner.java index 165103998..6d6b2e368 100644 --- a/packages/dsl-java/src/main/java/io/contract_testing/contractcase/ContractDefiner.java +++ b/packages/dsl-java/src/main/java/io/contract_testing/contractcase/ContractDefiner.java @@ -159,11 +159,13 @@ public ContractWriteSuccess endRecord() { } /** - * Loads one or more plugins. + * Loads one or more plugins. Call this before running any interactions that + * need the plugin(s). * - * @param pluginNames The names of the plugins to load. Can be a path to the - * package, or the name - * of a package that has previously been installed with npm. + * @param pluginNames The names of the plugins to load. Each must be the name + * of a node package that has previously been installed in + * the current project (eg with npm). Paths and URIs are + * not supported, for security reasons. */ public void loadPlugins(String... pluginNames) { try { diff --git a/packages/dsl-java/src/main/java/io/contract_testing/contractcase/ContractVerifier.java b/packages/dsl-java/src/main/java/io/contract_testing/contractcase/ContractVerifier.java index 25d4a66fc..572cee573 100644 --- a/packages/dsl-java/src/main/java/io/contract_testing/contractcase/ContractVerifier.java +++ b/packages/dsl-java/src/main/java/io/contract_testing/contractcase/ContractVerifier.java @@ -56,6 +56,15 @@ public ContractVerifier(final ContractCaseConfig config, LogPrinter logPrinter) this.verifier = verification; } + /** + * Loads one or more plugins. Call this before preparing or running any + * verification tests that need the plugin(s). + * + * @param pluginNames The names of the plugins to load. Each must be the name + * of a node package that has previously been installed in + * the current project (eg with npm). Paths and URIs are + * not supported, for security reasons. + */ public void loadPlugins(String... pluginNames) { try { ConnectorResultMapper.mapVoid(this.verifier.loadPlugins(ConnectorConfigMapper.map( diff --git a/packages/dsl-java/src/main/java/io/contract_testing/contractcase/internal/client/InternalDefinerClient.java b/packages/dsl-java/src/main/java/io/contract_testing/contractcase/internal/client/InternalDefinerClient.java index d863507ba..df4d57285 100644 --- a/packages/dsl-java/src/main/java/io/contract_testing/contractcase/internal/client/InternalDefinerClient.java +++ b/packages/dsl-java/src/main/java/io/contract_testing/contractcase/internal/client/InternalDefinerClient.java @@ -89,12 +89,12 @@ private ConnectorResult begin(final ContractCaseConfig wireConfig) { public ConnectorResult loadPlugins(ContractCaseConnectorConfig configOverrides, String[] pluginNames) { - var loadPluginsRequest = LoadPluginRequest.newBuilder() - .setConfig(ConnectorOutgoingMapper.mapConfig(configOverrides)); - loadPluginsRequest.getModuleNamesList() - .addAll(Arrays.stream(pluginNames).map(ConnectorOutgoingMapper::map).toList()); return rpcConnector.executeCallAndWait(DefinitionRequest.newBuilder() - .setLoadPlugin(loadPluginsRequest) + .setLoadPlugin(LoadPluginRequest.newBuilder() + .addAllModuleNames( + Arrays.stream(pluginNames).map(ConnectorOutgoingMapper::map).toList() + ) + .setConfig(ConnectorOutgoingMapper.mapConfig(configOverrides))) , "loadPlugins"); } diff --git a/packages/dsl-java/src/test/java/io/contract_testing/contractcase/test/plugin/PluginLoadTest.java b/packages/dsl-java/src/test/java/io/contract_testing/contractcase/test/plugin/PluginLoadTest.java new file mode 100644 index 000000000..6b292e80c --- /dev/null +++ b/packages/dsl-java/src/test/java/io/contract_testing/contractcase/test/plugin/PluginLoadTest.java @@ -0,0 +1,94 @@ +package io.contract_testing.contractcase.test.plugin; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.contract_testing.contractcase.ContractDefiner; +import io.contract_testing.contractcase.InteractionDefinition; +import io.contract_testing.contractcase.configuration.ContractCaseConfig.ContractCaseConfigBuilder; +import io.contract_testing.contractcase.configuration.IndividualSuccessTestConfig.IndividualSuccessTestConfigBuilder; +import io.contract_testing.contractcase.configuration.PublishType; +import io.contract_testing.contractcase.dsl.interactions.functions.WillCallFunction; +import io.contract_testing.contractcase.dsl.states.InState; +import io.contract_testing.contractcase.exceptions.ContractCaseConfigurationError; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Tests loading a third-party plugin by package name. + * + * The fixture plugin is installed in this project's node_modules (it's a + * devDependency of this package - see + * case-connector/test-fixtures/contract-case-test-plugin). Loading it by name + * proves that the connector resolves plugin packages from the project the + * tests run from, even though the connector itself runs from a temporary + * directory. + */ +public class PluginLoadTest { + + private static ContractDefiner contract; + + @BeforeAll + static void before() { + contract = new ContractDefiner( + ContractCaseConfigBuilder.aContractCaseConfig() + .consumerName("Java Plugin Fixture Consumer") + .providerName("Java Plugin Fixture Provider") + .publish(PublishType.NEVER) + // .changedContracts(ChangedContractsBehaviour.OVERWRITE) + .adviceOverrides(Map.of( + "OVERWRITE_CONTRACTS_NEEDED", + "Please re-run this test, but:\nFirst uncomment the changedContracts line in this unit test")) + .build()); + contract.loadPlugins("@contract-case/test-plugin-fixture"); + } + + @AfterAll + static void after() { + contract.endRecord(); + } + + @Test + public void testInteractionWithPluginProvidedMatcher() { + // The fixture plugin's matcher accepts any actual value, and strips to + // the example 'stripped-by-default-export' + contract.runInteraction( + new InteractionDefinition<>( + List.of(new InState("The plugin fixture is loaded")), + WillCallFunction.builder() + .arguments(List.of()) + .returnValue(Map.of( + "_case:matcher:type", + "test-fixture:default-export:Matcher")) + .functionName("ReturnsPluginMatchedValue") + .build()), + IndividualSuccessTestConfigBuilder.builder() + .withTrigger((setupInfo) -> parse(setupInfo.getFunction(setupInfo.getMockSetup( + "functionHandle")) + .apply(List.of()))) + .withTestResponse((result, setupInfo) -> { + assertThat(result).isEqualTo("stripped-by-default-export"); + })); + } + + @Test + public void testLoadingAPluginThatIsNotInstalledFails() { + var exception = assertThrows( + ContractCaseConfigurationError.class, + () -> contract.loadPlugins("definitely-not-an-installed-plugin")); + assertThat(exception.getMessage()).contains("definitely-not-an-installed-plugin"); + } + + private String parse(String json) { + try { + return new ObjectMapper().readValue(json, String.class); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } +} From 8210914b83db67f269834eee32c820abc244ff8a Mon Sep 17 00:00:00 2001 From: Timothy Jones Date: Fri, 28 Aug 2026 18:23:46 +1000 Subject: [PATCH 06/10] chore: Tidy documentation further --- .../documentation/docs/plugins/loading-plugins.md | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/documentation/docs/plugins/loading-plugins.md b/packages/documentation/docs/plugins/loading-plugins.md index 1d859093b..052f3c639 100644 --- a/packages/documentation/docs/plugins/loading-plugins.md +++ b/packages/documentation/docs/plugins/loading-plugins.md @@ -26,7 +26,9 @@ steps: ```ts defineContract(config, (contract) => { - beforeAll(() => contract.loadPlugins('@yourorg/contract-case-plugin-ulid')); + beforeAll(() => + contract.loadPlugins('@yourorg/contract-case-plugin-ulid'), + ); // ... interactions using the plugin's matchers and mocks }); @@ -36,7 +38,9 @@ steps: ```ts verifyContract(config, (verifier) => { - beforeAll(() => verifier.loadPlugins('@yourorg/contract-case-plugin-ulid')); + beforeAll(() => + verifier.loadPlugins('@yourorg/contract-case-plugin-ulid'), + ); }); ``` @@ -73,13 +77,6 @@ prominently in their installation instructions. Because plugins are intended to be loaded from your package manager, they must be plain package names. -Why so strict? Loading a plugin executes its code. If plugin specifiers could -be URLs, then anything able to influence the specifier - a malicious contract -file, or a compromised client of a contract server - could load arbitrary -remote code into your test process. Restricting specifiers to -already-installed packages means nothing can be loaded that you didn't -explicitly install. - ### What happens at load time When a plugin loads, ContractCase checks: From 92544680e18ab4250375932bc5e8e4a7c2967ebe Mon Sep 17 00:00:00 2001 From: Timothy Jones Date: Fri, 28 Aug 2026 18:32:06 +1000 Subject: [PATCH 07/10] refactor(core): Move plugin module resolution out of case-plugin-base mustResolvePlugin / isContractCasePlugin / AnyContractCasePlugin are loader concerns, only used by case-core and case-connector - plugin authors never need them, so they don't belong in the plugin API surface. Co-Authored-By: Claude Fable 5 --- docs/maintainers/todo.md | 2 +- .../connectors/loadPlugins/PluginLoader.ts | 2 +- .../src/connectors/loadPlugins/index.ts | 1 + .../connectors/loadPlugins}/resolve.spec.ts | 2 +- .../src/connectors/loadPlugins}/resolve.ts | 10 +- packages/case-core/src/index.ts | 3 - .../api-extractor/case-plugin-base.api.json | 158 ------------------ .../api-extractor/case-plugin-base.api.md | 9 - .../case-plugin-base.anycontractcaseplugin.md | 15 -- .../case-plugin-base.iscontractcaseplugin.md | 58 ------- .../case-plugin-base/docs/case-plugin-base.md | 37 ---- .../case-plugin-base.mustresolveplugin.md | 78 --------- packages/case-plugin-base/src/index.ts | 1 - .../case-plugin-base/src/plugins/index.ts | 1 - .../temp/case-plugin-base.api.md | 9 - 15 files changed, 10 insertions(+), 376 deletions(-) rename packages/{case-plugin-base/src/plugins => case-core/src/connectors/loadPlugins}/resolve.spec.ts (97%) rename packages/{case-plugin-base/src/plugins => case-core/src/connectors/loadPlugins}/resolve.ts (94%) delete mode 100644 packages/case-plugin-base/docs/case-plugin-base.anycontractcaseplugin.md delete mode 100644 packages/case-plugin-base/docs/case-plugin-base.iscontractcaseplugin.md delete mode 100644 packages/case-plugin-base/docs/case-plugin-base.mustresolveplugin.md delete mode 100644 packages/case-plugin-base/src/plugins/index.ts diff --git a/docs/maintainers/todo.md b/docs/maintainers/todo.md index c2eab7b70..d18717eba 100644 --- a/docs/maintainers/todo.md +++ b/docs/maintainers/todo.md @@ -52,7 +52,7 @@ Plugins: `module.exports = plugin` (only individual named exports work), so the documented "default export one `ContractCasePlugin` object" shape needs `.default` unwrapping, plus an end-to-end test loading a third-party - plugin by name. (Fixed with `mustResolvePlugin` in `case-plugin-base`; + plugin by name. (Fixed with `mustResolvePlugin` in `case-core`; tested by `BoundaryPluginLoader.spec.ts`.) - [ ] Complete the DSL generator so plugin authors can run it - hook it into the CLI and remove the hardcoded paths in diff --git a/packages/case-core/src/connectors/loadPlugins/PluginLoader.ts b/packages/case-core/src/connectors/loadPlugins/PluginLoader.ts index bcd7c47cd..aec747211 100644 --- a/packages/case-core/src/connectors/loadPlugins/PluginLoader.ts +++ b/packages/case-core/src/connectors/loadPlugins/PluginLoader.ts @@ -5,8 +5,8 @@ import { IsCaseNodeForType, IsMockDescriptorForType, constructDataContext, - isContractCasePlugin, } from '@contract-case/case-plugin-base'; +import { isContractCasePlugin } from './resolve'; import type { CaseConfig } from '../../core/types'; import { configFromEnv, configToRunContext } from '../../core/config'; diff --git a/packages/case-core/src/connectors/loadPlugins/index.ts b/packages/case-core/src/connectors/loadPlugins/index.ts index 66babcc5f..c8a723d7b 100644 --- a/packages/case-core/src/connectors/loadPlugins/index.ts +++ b/packages/case-core/src/connectors/loadPlugins/index.ts @@ -1 +1,2 @@ export * from './PluginLoader'; +export * from './resolve'; diff --git a/packages/case-plugin-base/src/plugins/resolve.spec.ts b/packages/case-core/src/connectors/loadPlugins/resolve.spec.ts similarity index 97% rename from packages/case-plugin-base/src/plugins/resolve.spec.ts rename to packages/case-core/src/connectors/loadPlugins/resolve.spec.ts index d70c31c93..dcf4f188e 100644 --- a/packages/case-plugin-base/src/plugins/resolve.spec.ts +++ b/packages/case-core/src/connectors/loadPlugins/resolve.spec.ts @@ -1,5 +1,5 @@ import { isContractCasePlugin, mustResolvePlugin } from './resolve'; -import { CaseConfigurationError } from '../errors'; +import { CaseConfigurationError } from '@contract-case/case-plugin-base'; const makePlugin = (name: string) => ({ description: { diff --git a/packages/case-plugin-base/src/plugins/resolve.ts b/packages/case-core/src/connectors/loadPlugins/resolve.ts similarity index 94% rename from packages/case-plugin-base/src/plugins/resolve.ts rename to packages/case-core/src/connectors/loadPlugins/resolve.ts index 98af0080c..eb38eedcc 100644 --- a/packages/case-plugin-base/src/plugins/resolve.ts +++ b/packages/case-core/src/connectors/loadPlugins/resolve.ts @@ -1,7 +1,9 @@ -import { ContractCasePlugin } from '../types'; -import { IsCaseNodeForType } from '../matchers/utility.types'; -import { IsMockDescriptorForType } from '../mocks/executors.types'; -import { CaseConfigurationError } from '../errors'; +import { + CaseConfigurationError, + ContractCasePlugin, + IsCaseNodeForType, + IsMockDescriptorForType, +} from '@contract-case/case-plugin-base'; /** * Convenience type for a plugin where the specific matcher and mock types diff --git a/packages/case-core/src/index.ts b/packages/case-core/src/index.ts index 40be5f450..9a3414c21 100644 --- a/packages/case-core/src/index.ts +++ b/packages/case-core/src/index.ts @@ -38,10 +38,7 @@ export { CaseCoreError, CaseTriggerError, VerifyTriggerReturnObjectError, - isContractCasePlugin, - mustResolvePlugin, } from '@contract-case/case-plugin-base'; -export type { AnyContractCasePlugin } from '@contract-case/case-plugin-base'; export type { AnyMockDescriptorType, AnyMockDescriptor, diff --git a/packages/case-plugin-base/api-extractor/case-plugin-base.api.json b/packages/case-plugin-base/api-extractor/case-plugin-base.api.json index 73b7460de..8080c7319 100644 --- a/packages/case-plugin-base/api-extractor/case-plugin-base.api.json +++ b/packages/case-plugin-base/api-extractor/case-plugin-base.api.json @@ -318,55 +318,6 @@ ], "name": "addLocation" }, - { - "kind": "TypeAlias", - "canonicalReference": "@contract-case/case-plugin-base!AnyContractCasePlugin:type", - "docComment": "/**\n * Convenience type for a plugin where the specific matcher and mock types aren't known - which is the case when a plugin has been loaded dynamically.\n *\n * @public\n */\n", - "excerptTokens": [ - { - "kind": "Content", - "text": "export type AnyContractCasePlugin = " - }, - { - "kind": "Reference", - "text": "ContractCasePlugin", - "canonicalReference": "@contract-case/case-plugin-base!ContractCasePlugin:type" - }, - { - "kind": "Content", - "text": ", " - }, - { - "kind": "Reference", - "text": "IsMockDescriptorForType", - "canonicalReference": "@contract-case/case-plugin-base!IsMockDescriptorForType:interface" - }, - { - "kind": "Content", - "text": ", unknown>" - }, - { - "kind": "Content", - "text": ";" - } - ], - "fileUrlPath": "src/plugins/resolve.ts", - "releaseTag": "Public", - "name": "AnyContractCasePlugin", - "typeTokenRange": { - "startIndex": 1, - "endIndex": 7 - } - }, { "kind": "Function", "canonicalReference": "@contract-case/case-plugin-base!cantPublish:function(1)", @@ -3930,57 +3881,6 @@ ], "extendsTokenRanges": [] }, - { - "kind": "Function", - "canonicalReference": "@contract-case/case-plugin-base!isContractCasePlugin:function(1)", - "docComment": "/**\n * Type guard that determines whether an unknown value is shaped like a {@link ContractCasePlugin}.\n *\n * Note that this only validates the shape of the plugin object - it doesn't confirm that the executors themselves are correctly implemented.\n *\n * @param value - the value to check\n *\n * @returns true if the value has the shape of a ContractCase plugin\n *\n * @public\n */\n", - "excerptTokens": [ - { - "kind": "Content", - "text": "isContractCasePlugin: (value: " - }, - { - "kind": "Content", - "text": "unknown" - }, - { - "kind": "Content", - "text": ") => " - }, - { - "kind": "Reference", - "text": "value", - "canonicalReference": "@contract-case/case-plugin-base!~value" - }, - { - "kind": "Content", - "text": " is " - }, - { - "kind": "Reference", - "text": "AnyContractCasePlugin", - "canonicalReference": "@contract-case/case-plugin-base!AnyContractCasePlugin:type" - } - ], - "fileUrlPath": "src/plugins/resolve.ts", - "returnTypeTokenRange": { - "startIndex": 3, - "endIndex": 6 - }, - "releaseTag": "Public", - "overloadIndex": 1, - "parameters": [ - { - "parameterName": "value", - "parameterTypeTokenRange": { - "startIndex": 1, - "endIndex": 2 - }, - "isOptional": false - } - ], - "name": "isContractCasePlugin" - }, { "kind": "Interface", "canonicalReference": "@contract-case/case-plugin-base!IsMockDescriptorForType:interface", @@ -5959,64 +5859,6 @@ "endIndex": 6 } }, - { - "kind": "Function", - "canonicalReference": "@contract-case/case-plugin-base!mustResolvePlugin:function(1)", - "docComment": "/**\n * Extracts the {@link ContractCasePlugin} object from the contents of a dynamically loaded plugin module, or throws a `CaseConfigurationError` if the module doesn't contain one.\n *\n * Plugin packages are documented as exporting the assembled plugin object as their default export - but depending on the module system the plugin was built with (and the module system doing the importing), the plugin object may arrive as the module contents itself, as the module namespace's `default` property, or nested a level deeper (eg TypeScript's CommonJS output of `export default`, imported as ESM). This function accepts all of these shapes, so plugin authors don't need to know the details.\n *\n * @param moduleContents - whatever `import()` (or `require`) returned for the plugin module\n *\n * @param moduleName - the name of the module, for error messages\n *\n * @returns the plugin object\n *\n * @throws\n *\n * CaseConfigurationError if no plugin-shaped object could be found\n *\n * @public\n */\n", - "excerptTokens": [ - { - "kind": "Content", - "text": "mustResolvePlugin: (moduleContents: " - }, - { - "kind": "Content", - "text": "unknown" - }, - { - "kind": "Content", - "text": ", moduleName: " - }, - { - "kind": "Content", - "text": "string" - }, - { - "kind": "Content", - "text": ") => " - }, - { - "kind": "Reference", - "text": "AnyContractCasePlugin", - "canonicalReference": "@contract-case/case-plugin-base!AnyContractCasePlugin:type" - } - ], - "fileUrlPath": "src/plugins/resolve.ts", - "returnTypeTokenRange": { - "startIndex": 5, - "endIndex": 6 - }, - "releaseTag": "Public", - "overloadIndex": 1, - "parameters": [ - { - "parameterName": "moduleContents", - "parameterTypeTokenRange": { - "startIndex": 1, - "endIndex": 2 - }, - "isOptional": false - }, - { - "parameterName": "moduleName", - "parameterTypeTokenRange": { - "startIndex": 3, - "endIndex": 4 - }, - "isOptional": false - } - ], - "name": "mustResolvePlugin" - }, { "kind": "Function", "canonicalReference": "@contract-case/case-plugin-base!mustResolveToNumber:function(1)", diff --git a/packages/case-plugin-base/api-extractor/case-plugin-base.api.md b/packages/case-plugin-base/api-extractor/case-plugin-base.api.md index 068f59a85..484ba8edb 100644 --- a/packages/case-plugin-base/api-extractor/case-plugin-base.api.md +++ b/packages/case-plugin-base/api-extractor/case-plugin-base.api.md @@ -21,9 +21,6 @@ export const actualToString: (actual: T, indent?: number) => string; // @public export const addLocation: (location: string, context: MatchContext) => MatchContext; -// @public -export type AnyContractCasePlugin = ContractCasePlugin, IsMockDescriptorForType, unknown>; - // Warning: (ae-internal-missing-underscore) The name "applyNodeToContext" should be prefixed with an underscore because the declaration is marked as @internal // // @internal @@ -334,9 +331,6 @@ export interface IsCaseNodeForType { '_case:matcher:type': T; } -// @public -export const isContractCasePlugin: (value: unknown) => value is AnyContractCasePlugin; - // @public export interface IsMockDescriptorForType { // (undocumented) @@ -493,9 +487,6 @@ export type MockOutput = { context: MatchContext; }; -// @public -export const mustResolvePlugin: (moduleContents: unknown, moduleName: string) => AnyContractCasePlugin; - // @public export const mustResolveToNumber: (matcher: AnyCaseMatcherOrData, context: MatchContext) => number; diff --git a/packages/case-plugin-base/docs/case-plugin-base.anycontractcaseplugin.md b/packages/case-plugin-base/docs/case-plugin-base.anycontractcaseplugin.md deleted file mode 100644 index 8eca61a34..000000000 --- a/packages/case-plugin-base/docs/case-plugin-base.anycontractcaseplugin.md +++ /dev/null @@ -1,15 +0,0 @@ - - -[Home](./index.md) > [@contract-case/case-plugin-base](./case-plugin-base.md) > [AnyContractCasePlugin](./case-plugin-base.anycontractcaseplugin.md) - -## AnyContractCasePlugin type - -Convenience type for a plugin where the specific matcher and mock types aren't known - which is the case when a plugin has been loaded dynamically. - -**Signature:** - -```typescript -export type AnyContractCasePlugin = ContractCasePlugin, IsMockDescriptorForType, unknown>; -``` -**References:** [ContractCasePlugin](./case-plugin-base.contractcaseplugin.md), [IsCaseNodeForType](./case-plugin-base.iscasenodefortype.md), [IsMockDescriptorForType](./case-plugin-base.ismockdescriptorfortype.md) - diff --git a/packages/case-plugin-base/docs/case-plugin-base.iscontractcaseplugin.md b/packages/case-plugin-base/docs/case-plugin-base.iscontractcaseplugin.md deleted file mode 100644 index f5f3dadc6..000000000 --- a/packages/case-plugin-base/docs/case-plugin-base.iscontractcaseplugin.md +++ /dev/null @@ -1,58 +0,0 @@ - - -[Home](./index.md) > [@contract-case/case-plugin-base](./case-plugin-base.md) > [isContractCasePlugin](./case-plugin-base.iscontractcaseplugin.md) - -## isContractCasePlugin() function - -Type guard that determines whether an unknown value is shaped like a [ContractCasePlugin](./case-plugin-base.contractcaseplugin.md). - -Note that this only validates the shape of the plugin object - it doesn't confirm that the executors themselves are correctly implemented. - -**Signature:** - -```typescript -isContractCasePlugin: (value: unknown) => value is AnyContractCasePlugin -``` - -## Parameters - - - -
- -Parameter - - - - -Type - - - - -Description - - -
- -value - - - - -unknown - - - - -the value to check - - -
- -**Returns:** - -value is [AnyContractCasePlugin](./case-plugin-base.anycontractcaseplugin.md) - -true if the value has the shape of a ContractCase plugin - diff --git a/packages/case-plugin-base/docs/case-plugin-base.md b/packages/case-plugin-base/docs/case-plugin-base.md index 7db4dabb7..3964c93c7 100644 --- a/packages/case-plugin-base/docs/case-plugin-base.md +++ b/packages/case-plugin-base/docs/case-plugin-base.md @@ -319,19 +319,6 @@ Tests whether a given [MatchResult](./case-plugin-base.matchresult.md) object ha Type guard to determine if an object is a ContractCase matcher descriptor or not - - - -[isContractCasePlugin(value)](./case-plugin-base.iscontractcaseplugin.md) - - - - -Type guard that determines whether an unknown value is shaped like a [ContractCasePlugin](./case-plugin-base.contractcaseplugin.md). - -Note that this only validates the shape of the plugin object - it doesn't confirm that the executors themselves are correctly implemented. - - @@ -396,19 +383,6 @@ Converts a matcher or data into a human friendly string for printing Creates a mismatched matcher expectations error - - - -[mustResolvePlugin(moduleContents, moduleName)](./case-plugin-base.mustresolveplugin.md) - - - - -Extracts the [ContractCasePlugin](./case-plugin-base.contractcaseplugin.md) object from the contents of a dynamically loaded plugin module, or throws a `CaseConfigurationError` if the module doesn't contain one. - -Plugin packages are documented as exporting the assembled plugin object as their default export - but depending on the module system the plugin was built with (and the module system doing the importing), the plugin object may arrive as the module contents itself, as the module namespace's `default` property, or nested a level deeper (eg TypeScript's CommonJS output of `export default`, imported as ESM). This function accepts all of these shapes, so plugin authors don't need to know the details. - - @@ -818,17 +792,6 @@ Description -[AnyContractCasePlugin](./case-plugin-base.anycontractcaseplugin.md) - - - - -Convenience type for a plugin where the specific matcher and mock types aren't known - which is the case when a plugin has been loaded dynamically. - - - - - [CaseError](./case-plugin-base.caseerror.md) diff --git a/packages/case-plugin-base/docs/case-plugin-base.mustresolveplugin.md b/packages/case-plugin-base/docs/case-plugin-base.mustresolveplugin.md deleted file mode 100644 index 224e8c18d..000000000 --- a/packages/case-plugin-base/docs/case-plugin-base.mustresolveplugin.md +++ /dev/null @@ -1,78 +0,0 @@ - - -[Home](./index.md) > [@contract-case/case-plugin-base](./case-plugin-base.md) > [mustResolvePlugin](./case-plugin-base.mustresolveplugin.md) - -## mustResolvePlugin() function - -Extracts the [ContractCasePlugin](./case-plugin-base.contractcaseplugin.md) object from the contents of a dynamically loaded plugin module, or throws a `CaseConfigurationError` if the module doesn't contain one. - -Plugin packages are documented as exporting the assembled plugin object as their default export - but depending on the module system the plugin was built with (and the module system doing the importing), the plugin object may arrive as the module contents itself, as the module namespace's `default` property, or nested a level deeper (eg TypeScript's CommonJS output of `export default`, imported as ESM). This function accepts all of these shapes, so plugin authors don't need to know the details. - -**Signature:** - -```typescript -mustResolvePlugin: (moduleContents: unknown, moduleName: string) => AnyContractCasePlugin -``` - -## Parameters - - - - -
- -Parameter - - - - -Type - - - - -Description - - -
- -moduleContents - - - - -unknown - - - - -whatever `import()` (or `require`) returned for the plugin module - - -
- -moduleName - - - - -string - - - - -the name of the module, for error messages - - -
- -**Returns:** - -[AnyContractCasePlugin](./case-plugin-base.anycontractcaseplugin.md) - -the plugin object - -## Exceptions - -CaseConfigurationError if no plugin-shaped object could be found - diff --git a/packages/case-plugin-base/src/index.ts b/packages/case-plugin-base/src/index.ts index 126e02a59..948cfb6fb 100644 --- a/packages/case-plugin-base/src/index.ts +++ b/packages/case-plugin-base/src/index.ts @@ -9,6 +9,5 @@ export * from './errors'; export * from './logger'; export * from './matchers'; export * from './mocks'; -export * from './plugins'; export * from './types'; diff --git a/packages/case-plugin-base/src/plugins/index.ts b/packages/case-plugin-base/src/plugins/index.ts deleted file mode 100644 index 98a976137..000000000 --- a/packages/case-plugin-base/src/plugins/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './resolve'; diff --git a/packages/case-plugin-base/temp/case-plugin-base.api.md b/packages/case-plugin-base/temp/case-plugin-base.api.md index 068f59a85..484ba8edb 100644 --- a/packages/case-plugin-base/temp/case-plugin-base.api.md +++ b/packages/case-plugin-base/temp/case-plugin-base.api.md @@ -21,9 +21,6 @@ export const actualToString: (actual: T, indent?: number) => string; // @public export const addLocation: (location: string, context: MatchContext) => MatchContext; -// @public -export type AnyContractCasePlugin = ContractCasePlugin, IsMockDescriptorForType, unknown>; - // Warning: (ae-internal-missing-underscore) The name "applyNodeToContext" should be prefixed with an underscore because the declaration is marked as @internal // // @internal @@ -334,9 +331,6 @@ export interface IsCaseNodeForType { '_case:matcher:type': T; } -// @public -export const isContractCasePlugin: (value: unknown) => value is AnyContractCasePlugin; - // @public export interface IsMockDescriptorForType { // (undocumented) @@ -493,9 +487,6 @@ export type MockOutput = { context: MatchContext; }; -// @public -export const mustResolvePlugin: (moduleContents: unknown, moduleName: string) => AnyContractCasePlugin; - // @public export const mustResolveToNumber: (matcher: AnyCaseMatcherOrData, context: MatchContext) => number; From 8424b5363cb95503e59e068fa3e8355de6183bbc Mon Sep 17 00:00:00 2001 From: Timothy Jones Date: Fri, 28 Aug 2026 18:33:50 +1000 Subject: [PATCH 08/10] chore: Rename loadOne -> importSinglePlugin --- .../case-boundary/internals/BoundaryPluginLoader.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/case-connector/src/connectors/case-boundary/internals/BoundaryPluginLoader.ts b/packages/case-connector/src/connectors/case-boundary/internals/BoundaryPluginLoader.ts index 5a3d69061..55ce3a872 100644 --- a/packages/case-connector/src/connectors/case-boundary/internals/BoundaryPluginLoader.ts +++ b/packages/case-connector/src/connectors/case-boundary/internals/BoundaryPluginLoader.ts @@ -48,7 +48,7 @@ const resolveFromWorkingDirectory = (moduleName: string): string | null => { } }; -const loadOne = ( +const importSinglePlugin = ( moduleName: string, ): Promise> => Promise.resolve() @@ -163,7 +163,9 @@ export class BoundaryPluginLoader { async loadPlugins(moduleNames: string[]): Promise { this.initialiseLoader(); - return Promise.all(moduleNames.map((moduleName) => loadOne(moduleName))) + return Promise.all( + moduleNames.map((moduleName) => importSinglePlugin(moduleName)), + ) .then((plugins) => { if (this.loader == null) { throw new CaseCoreError( From f5640990cb9c8c94da9c696a472ad5fb054273fa Mon Sep 17 00:00:00 2001 From: Timothy Jones Date: Fri, 28 Aug 2026 18:36:08 +1000 Subject: [PATCH 09/10] chore: Fix formatting --- .../internals/BoundaryPluginLoader.spec.ts | 10 ++++++++-- .../src/connectors/loadPlugins/resolve.spec.ts | 9 ++++++--- .../src/index.plugin.define.spec.ts | 6 +++++- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/case-connector/src/connectors/case-boundary/internals/BoundaryPluginLoader.spec.ts b/packages/case-connector/src/connectors/case-boundary/internals/BoundaryPluginLoader.spec.ts index 3487814bb..db85ae2b1 100644 --- a/packages/case-connector/src/connectors/case-boundary/internals/BoundaryPluginLoader.spec.ts +++ b/packages/case-connector/src/connectors/case-boundary/internals/BoundaryPluginLoader.spec.ts @@ -49,12 +49,18 @@ describe('BoundaryPluginLoader', () => { // documented shape is "the plugin object is the module's default export" - // all of these must load. describe.each([ - ['TypeScript-compiled default export', '@contract-case/test-plugin-fixture'], + [ + 'TypeScript-compiled default export', + '@contract-case/test-plugin-fixture', + ], [ 'CommonJS module.exports', '@contract-case/test-plugin-fixture/module-exports', ], - ['native ESM default export', '@contract-case/test-plugin-fixture/esm-default'], + [ + 'native ESM default export', + '@contract-case/test-plugin-fixture/esm-default', + ], [ 'CommonJS named exports', '@contract-case/test-plugin-fixture/named-exports', diff --git a/packages/case-core/src/connectors/loadPlugins/resolve.spec.ts b/packages/case-core/src/connectors/loadPlugins/resolve.spec.ts index dcf4f188e..521dc5fb4 100644 --- a/packages/case-core/src/connectors/loadPlugins/resolve.spec.ts +++ b/packages/case-core/src/connectors/loadPlugins/resolve.spec.ts @@ -81,9 +81,12 @@ describe('mustResolvePlugin', () => { ['an empty module', {}], ['a module with a non-plugin default', { default: { some: 'data' } }], ['null module contents', null], - ['a deeply nested plugin beyond the unwrap depth', { - default: { default: { default: { default: makePlugin('too-deep') } } }, - }], + [ + 'a deeply nested plugin beyond the unwrap depth', + { + default: { default: { default: { default: makePlugin('too-deep') } } }, + }, + ], ])('throws a CaseConfigurationError for %s', (_name, moduleContents) => { expect(() => mustResolvePlugin(moduleContents, 'bad-plugin')).toThrow( CaseConfigurationError, diff --git a/packages/contract-case-dsl-js-jest/src/index.plugin.define.spec.ts b/packages/contract-case-dsl-js-jest/src/index.plugin.define.spec.ts index d2653aca0..2160d3f51 100644 --- a/packages/contract-case-dsl-js-jest/src/index.plugin.define.spec.ts +++ b/packages/contract-case-dsl-js-jest/src/index.plugin.define.spec.ts @@ -1,4 +1,8 @@ -import { willCallFunction, FunctionExecutorConfig, defineContract } from './index.js'; +import { + willCallFunction, + FunctionExecutorConfig, + defineContract, +} from './index.js'; // This matcher is provided by the fixture plugin (which lives in // case-connector/test-fixtures). It accepts any actual value, and strips to From 7895e38d1ad90641e625361de9420412f0b55d8e Mon Sep 17 00:00:00 2001 From: Timothy Jones Date: Fri, 28 Aug 2026 18:40:35 +1000 Subject: [PATCH 10/10] chore: Fix import order Co-Authored-By: Claude Fable 5 --- packages/case-core/src/connectors/loadPlugins/resolve.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/case-core/src/connectors/loadPlugins/resolve.spec.ts b/packages/case-core/src/connectors/loadPlugins/resolve.spec.ts index 521dc5fb4..b125feca5 100644 --- a/packages/case-core/src/connectors/loadPlugins/resolve.spec.ts +++ b/packages/case-core/src/connectors/loadPlugins/resolve.spec.ts @@ -1,5 +1,5 @@ -import { isContractCasePlugin, mustResolvePlugin } from './resolve'; import { CaseConfigurationError } from '@contract-case/case-plugin-base'; +import { isContractCasePlugin, mustResolvePlugin } from './resolve'; const makePlugin = (name: string) => ({ description: {