@cratis/pi 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -0
- package/package.json +24 -0
- package/skills/cratis-application-react-specifications/LICENSE +2 -0
- package/skills/cratis-application-react-specifications/SKILL.md +192 -0
- package/skills/cratis-application-slice-specifications/LICENSE +2 -0
- package/skills/cratis-application-slice-specifications/SKILL.md +222 -0
- package/skills/cratis-arc-authentication-authorization-and-identity/LICENSE +2 -0
- package/skills/cratis-arc-authentication-authorization-and-identity/SKILL.md +233 -0
- package/skills/cratis-arc-authentication-authorization-and-identity/references/authentication.md +140 -0
- package/skills/cratis-arc-authentication-authorization-and-identity/references/authorization.md +112 -0
- package/skills/cratis-arc-authentication-authorization-and-identity/references/frontend.md +102 -0
- package/skills/cratis-arc-authentication-authorization-and-identity/references/local-development.md +85 -0
- package/skills/cratis-arc-authentication-authorization-and-identity/references/tenancy.md +87 -0
- package/skills/cratis-arc-command/LICENSE +2 -0
- package/skills/cratis-arc-command/SKILL.md +286 -0
- package/skills/cratis-arc-command/references/command-result.md +111 -0
- package/skills/cratis-arc-command/references/handler-shapes.md +135 -0
- package/skills/cratis-arc-command/references/proxy-generation.md +87 -0
- package/skills/cratis-arc-command/references/read-model-injection.md +97 -0
- package/skills/cratis-arc-command-execution/LICENSE +2 -0
- package/skills/cratis-arc-command-execution/SKILL.md +148 -0
- package/skills/cratis-arc-command-validation/LICENSE +2 -0
- package/skills/cratis-arc-command-validation/SKILL.md +230 -0
- package/skills/cratis-arc-ef-core-migration/LICENSE +2 -0
- package/skills/cratis-arc-ef-core-migration/SKILL.md +231 -0
- package/skills/cratis-arc-observable-query-http/LICENSE +2 -0
- package/skills/cratis-arc-observable-query-http/SKILL.md +157 -0
- package/skills/cratis-arc-query-paging/LICENSE +2 -0
- package/skills/cratis-arc-query-paging/SKILL.md +173 -0
- package/skills/cratis-arc-react-page/LICENSE +2 -0
- package/skills/cratis-arc-react-page/SKILL.md +291 -0
- package/skills/cratis-arc-react-page/references/data-page.md +125 -0
- package/skills/cratis-arc-react-page/references/data-tables.md +136 -0
- package/skills/cratis-arc-react-page/references/dialogs.md +238 -0
- package/skills/cratis-arc-react-page/references/mvvm.md +150 -0
- package/skills/cratis-arc-react-page/references/queries-and-commands.md +200 -0
- package/skills/cratis-chronicle-cli-operations/LICENSE +2 -0
- package/skills/cratis-chronicle-cli-operations/SKILL.md +199 -0
- package/skills/cratis-chronicle-client-dotnet/LICENSE +2 -0
- package/skills/cratis-chronicle-client-dotnet/SKILL.md +433 -0
- package/skills/cratis-chronicle-client-elixir/LICENSE +2 -0
- package/skills/cratis-chronicle-client-elixir/SKILL.md +429 -0
- package/skills/cratis-chronicle-client-kotlin/LICENSE +2 -0
- package/skills/cratis-chronicle-client-kotlin/SKILL.md +498 -0
- package/skills/cratis-chronicle-client-typescript/LICENSE +2 -0
- package/skills/cratis-chronicle-client-typescript/SKILL.md +396 -0
- package/skills/cratis-chronicle-compliance/LICENSE +2 -0
- package/skills/cratis-chronicle-compliance/SKILL.md +246 -0
- package/skills/cratis-chronicle-event-constraints/LICENSE +2 -0
- package/skills/cratis-chronicle-event-constraints/SKILL.md +227 -0
- package/skills/cratis-chronicle-event-modeling/LICENSE +2 -0
- package/skills/cratis-chronicle-event-modeling/SKILL.md +157 -0
- package/skills/cratis-chronicle-event-specifications/LICENSE +2 -0
- package/skills/cratis-chronicle-event-specifications/SKILL.md +200 -0
- package/skills/cratis-chronicle-event-type-migration/LICENSE +2 -0
- package/skills/cratis-chronicle-event-type-migration/SKILL.md +235 -0
- package/skills/cratis-chronicle-mcp-inspection/LICENSE +2 -0
- package/skills/cratis-chronicle-mcp-inspection/SKILL.md +79 -0
- package/skills/cratis-chronicle-mcp-inspection/references/blocked-tools.md +12 -0
- package/skills/cratis-chronicle-mcp-inspection/references/observational-tools.md +8 -0
- package/skills/cratis-chronicle-multi-tenancy/LICENSE +2 -0
- package/skills/cratis-chronicle-multi-tenancy/SKILL.md +198 -0
- package/skills/cratis-chronicle-projection/LICENSE +2 -0
- package/skills/cratis-chronicle-projection/SKILL.md +162 -0
- package/skills/cratis-chronicle-projection/references/fluent-builder.md +126 -0
- package/skills/cratis-chronicle-projection/references/model-bound-attributes.md +118 -0
- package/skills/cratis-chronicle-reactor/LICENSE +2 -0
- package/skills/cratis-chronicle-reactor/SKILL.md +344 -0
- package/skills/cratis-chronicle-read-model/LICENSE +2 -0
- package/skills/cratis-chronicle-read-model/SKILL.md +193 -0
- package/skills/cratis-chronicle-read-model/references/queries.md +142 -0
- package/skills/cratis-chronicle-read-model-specifications/LICENSE +2 -0
- package/skills/cratis-chronicle-read-model-specifications/SKILL.md +176 -0
- package/skills/cratis-chronicle-reducer/LICENSE +2 -0
- package/skills/cratis-chronicle-reducer/SKILL.md +173 -0
- package/skills/cratis-chronicle-web-workbench/LICENSE +2 -0
- package/skills/cratis-chronicle-web-workbench/SKILL.md +177 -0
- package/skills/cratis-cli-terminal-workbench/LICENSE +2 -0
- package/skills/cratis-cli-terminal-workbench/SKILL.md +156 -0
- package/skills/cratis-code-review/LICENSE +2 -0
- package/skills/cratis-code-review/SKILL.md +177 -0
- package/skills/cratis-components-accessibility/LICENSE +2 -0
- package/skills/cratis-components-accessibility/SKILL.md +176 -0
- package/skills/cratis-components-schema-editor/LICENSE +2 -0
- package/skills/cratis-components-schema-editor/SKILL.md +238 -0
- package/skills/cratis-components-stepper-command-dialog/LICENSE +2 -0
- package/skills/cratis-components-stepper-command-dialog/SKILL.md +209 -0
- package/skills/cratis-components-styling/LICENSE +2 -0
- package/skills/cratis-components-styling/SKILL.md +221 -0
- package/skills/cratis-components-toolbar/LICENSE +2 -0
- package/skills/cratis-components-toolbar/SKILL.md +259 -0
- package/skills/cratis-documentation-writing/LICENSE +2 -0
- package/skills/cratis-documentation-writing/SKILL.md +121 -0
- package/skills/cratis-event-model-diagram/LICENSE +2 -0
- package/skills/cratis-event-model-diagram/SKILL.md +142 -0
- package/skills/cratis-fundamentals-concept/LICENSE +2 -0
- package/skills/cratis-fundamentals-concept/SKILL.md +198 -0
- package/skills/cratis-fundamentals-type-discovery/LICENSE +2 -0
- package/skills/cratis-fundamentals-type-discovery/SKILL.md +152 -0
- package/skills/cratis-governed-release-methodology/LICENSE +2 -0
- package/skills/cratis-governed-release-methodology/SKILL.md +297 -0
- package/skills/cratis-lens-browser-extension/LICENSE +2 -0
- package/skills/cratis-lens-browser-extension/SKILL.md +239 -0
- package/skills/cratis-performance-review/LICENSE +2 -0
- package/skills/cratis-performance-review/SKILL.md +139 -0
- package/skills/cratis-screenplay-model-authoring/LICENSE +2 -0
- package/skills/cratis-screenplay-model-authoring/SKILL.md +231 -0
- package/skills/cratis-security-review/LICENSE +2 -0
- package/skills/cratis-security-review/SKILL.md +141 -0
- package/skills/cratis-specification-by-example/LICENSE +2 -0
- package/skills/cratis-specification-by-example/SKILL.md +188 -0
- package/skills/cratis-specifications-csharp/LICENSE +2 -0
- package/skills/cratis-specifications-csharp/SKILL.md +230 -0
- package/skills/cratis-specifications-csharp/references/application-scenarios.md +191 -0
- package/skills/cratis-specifications-csharp/references/csharp-patterns.md +142 -0
- package/skills/cratis-specifications-csharp/references/integration-specs.md +126 -0
- package/skills/cratis-specifications-typescript/LICENSE +2 -0
- package/skills/cratis-specifications-typescript/SKILL.md +202 -0
- package/skills/cratis-specifications-typescript/references/typescript-patterns.md +168 -0
- package/skills/cratis-stage-rendering-and-sandbox/LICENSE +2 -0
- package/skills/cratis-stage-rendering-and-sandbox/SKILL.md +230 -0
- package/skills/cratis-studio-mcp-safety-guidance/LICENSE +2 -0
- package/skills/cratis-studio-mcp-safety-guidance/SKILL.md +69 -0
- package/skills/cratis-studio-mcp-safety-guidance/references/blocked-tools.md +12 -0
- package/skills/cratis-studio-mcp-safety-guidance/references/observational-tools.md +8 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# In-process scenario specifications
|
|
2
|
+
|
|
3
|
+
The four scenario helpers exercise the real Arc and Chronicle pipelines
|
|
4
|
+
in-process. They are the default for event-sourced *application* behavior. A
|
|
5
|
+
library or framework repository reaches for one only to test the very engine it
|
|
6
|
+
provides.
|
|
7
|
+
|
|
8
|
+
Verified against `Cratis.Arc.Testing` `22.10.4` and `Cratis.Chronicle.Testing`
|
|
9
|
+
`17.0.0`.
|
|
10
|
+
|
|
11
|
+
| Helper | Exercises | Use for |
|
|
12
|
+
| --- | --- | --- |
|
|
13
|
+
| `CommandScenario<TCommand>` | Authorization, validators, `Provide()`, `Handle()`, appended events | State Change behavior |
|
|
14
|
+
| `EventScenario` | Chronicle append semantics without the command pipeline | Constraints, raw sequencing, concurrency |
|
|
15
|
+
| `ReadModelScenario<TReadModel>` | Projection or reducer state from a sequence of events | State View behavior |
|
|
16
|
+
| `ReactorScenario<TReactor>` | Reactor invocation and its side effects | Automation and Translation behavior |
|
|
17
|
+
|
|
18
|
+
Wrap every scenario specification file in `#if DEBUG … #endif` so specification
|
|
19
|
+
code compiles only in Debug.
|
|
20
|
+
|
|
21
|
+
## `CommandScenario<TCommand>`
|
|
22
|
+
|
|
23
|
+
```csharp
|
|
24
|
+
#if DEBUG
|
|
25
|
+
namespace <RootNamespace>.<Feature>.when_<behavior>;
|
|
26
|
+
|
|
27
|
+
public class and_<condition> : Specification
|
|
28
|
+
{
|
|
29
|
+
readonly CommandScenario<<CommandType>> _scenario = new();
|
|
30
|
+
readonly <IdentityType> _id = <IdentityType>.New();
|
|
31
|
+
CommandResult _result;
|
|
32
|
+
|
|
33
|
+
async Task Because() =>
|
|
34
|
+
_result = await _scenario.Execute(new <CommandType>(_id, <arguments>));
|
|
35
|
+
|
|
36
|
+
[Fact] void should_succeed() => _result.ShouldBeSuccessful();
|
|
37
|
+
|
|
38
|
+
[Fact] async Task should_append_<event>() =>
|
|
39
|
+
await _scenario.ShouldHaveAppendedEvent<<CommandType>, <EventType>>(
|
|
40
|
+
_id, appended => appended.<Property> == <expected>);
|
|
41
|
+
}
|
|
42
|
+
#endif
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`CommandScenario<TCommand>` itself exposes exactly `Services`, `Context`,
|
|
46
|
+
`Execute`, and `Validate`. Everything else — seeding, the event log, and the
|
|
47
|
+
assertions — arrives as extension members from `Cratis.Arc.Chronicle.Testing`.
|
|
48
|
+
|
|
49
|
+
- **Event assertions** are extension methods keyed by command *and* event type:
|
|
50
|
+
`ShouldHaveAppendedEvent<TCommand, TEvent>(eventSourceId)`, its
|
|
51
|
+
`(eventSourceId, Func<TEvent, bool> predicate)` overload, and
|
|
52
|
+
`ShouldHaveTailSequenceNumber<TCommand>(…)`. They return `Task`, so the fact is
|
|
53
|
+
`async Task`.
|
|
54
|
+
- **`CommandResult` assertions** come from `Cratis.Arc.Testing.Commands` and
|
|
55
|
+
throw `CommandResultAssertionException` on failure: `ShouldBeSuccessful()`
|
|
56
|
+
(authorized, valid, no exceptions), `ShouldNotBeSuccessful()`,
|
|
57
|
+
`ShouldBeValid()` (validation only — it does not check authorization or
|
|
58
|
+
exceptions), `ShouldHaveValidationErrors()`,
|
|
59
|
+
`ShouldHaveValidationErrorFor(message)`, `ShouldBeAuthorized()`,
|
|
60
|
+
`ShouldNotBeAuthorized()`, `ShouldHaveExceptions()`,
|
|
61
|
+
`ShouldNotHaveExceptions()`.
|
|
62
|
+
- **Seed prior state through `Given`.** With `Cratis.Arc.Chronicle.Testing`
|
|
63
|
+
referenced, a command scenario carries a Chronicle-backed `Given` builder as an
|
|
64
|
+
extension member, alongside `EventScenario`, `EventLog`, `EventSequence` and
|
|
65
|
+
`AppendedEvents`. `Given.ForEventSource(id).Events(…)` seeds the events an
|
|
66
|
+
injected read model is then materialized from by its own projection or
|
|
67
|
+
reducer; `Given.ForEventSource(id).ReadModel(instance)` pins a specific
|
|
68
|
+
read-model value instead. Both return `void`. Prefer seeding events — it
|
|
69
|
+
exercises the projection production depends on, so the specification cannot
|
|
70
|
+
pass against a read-model shape the projection never produces.
|
|
71
|
+
- **Validator and `Provide()` dependencies** are registered in
|
|
72
|
+
`_scenario.Services`; the concrete validator is discovered automatically.
|
|
73
|
+
|
|
74
|
+
### Unhappy paths assert twice
|
|
75
|
+
|
|
76
|
+
```csharp
|
|
77
|
+
[Fact] void should_not_succeed() => _result.ShouldNotBeSuccessful();
|
|
78
|
+
[Fact] void should_have_validation_errors() => _result.ShouldHaveValidationErrors();
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`ShouldNotBeSuccessful()` alone cannot distinguish a validation rejection from
|
|
82
|
+
an unhandled exception, so both facts are required. Authorization failures are
|
|
83
|
+
different: an unauthorized result carries **no** validation errors, so assert
|
|
84
|
+
`ShouldNotBeAuthorized()` instead. Adding an authorization attribute to an
|
|
85
|
+
existing command therefore breaks both its happy-path and its validation-failure
|
|
86
|
+
specifications — switch those assertions rather than patching around them.
|
|
87
|
+
|
|
88
|
+
Never assert on a message string.
|
|
89
|
+
|
|
90
|
+
> **Validator state can be order-sensitive.** The command-scenario pipeline can
|
|
91
|
+
> cache enough state that injected-validator branches depend on execution order
|
|
92
|
+
> when xUnit runs classes in parallel. Use `CommandScenario` for the valid path,
|
|
93
|
+
> test rejected state variants by instantiating the validator directly, and only
|
|
94
|
+
> for that case put the command's specifications in a small xUnit `[Collection]`.
|
|
95
|
+
|
|
96
|
+
## `EventScenario`
|
|
97
|
+
|
|
98
|
+
```csharp
|
|
99
|
+
readonly EventScenario _scenario = new();
|
|
100
|
+
IAppendResult _result;
|
|
101
|
+
|
|
102
|
+
async Task Establish() =>
|
|
103
|
+
await _scenario.Given.ForEventSource(<IdentityType>.New())
|
|
104
|
+
.Events(new <EventType>(<arguments>));
|
|
105
|
+
|
|
106
|
+
async Task Because() =>
|
|
107
|
+
_result = await _scenario.EventLog.Append(
|
|
108
|
+
<IdentityType>.New(), new <EventType>(<arguments>));
|
|
109
|
+
|
|
110
|
+
[Fact] void should_fail() => _result.ShouldBeFailed();
|
|
111
|
+
|
|
112
|
+
[Fact] void should_violate_<constraint>() =>
|
|
113
|
+
_result.ShouldHaveConstraintViolationFor(<ConstraintNames>.<Name>);
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`IAppendResult` assertions throw `AppendResultAssertionException`:
|
|
117
|
+
`ShouldBeSuccessful()`, `ShouldBeFailed()`, `ShouldHaveConstraintViolations()`
|
|
118
|
+
and `ShouldNotHaveConstraintViolations()`,
|
|
119
|
+
`ShouldHaveConstraintViolationFor(name)`,
|
|
120
|
+
`ShouldHaveConcurrencyViolations()` and its negation, `ShouldHaveErrors()` and
|
|
121
|
+
its negation. Assert the constraint **name**, never the message.
|
|
122
|
+
|
|
123
|
+
⚠️ `EventScenario` wires a no-op concurrency-scope strategy, so a concurrency
|
|
124
|
+
violation can never occur inside it. `ShouldHaveConcurrencyViolations()` cannot
|
|
125
|
+
pass there and its negation passes vacuously — specify concurrency against the
|
|
126
|
+
real kernel with an out-of-process integration specification instead.
|
|
127
|
+
|
|
128
|
+
## `ReadModelScenario<TReadModel>`
|
|
129
|
+
|
|
130
|
+
```csharp
|
|
131
|
+
ReadModelScenario<<ReadModelType>> _scenario = null!;
|
|
132
|
+
|
|
133
|
+
void Establish() => _scenario = new();
|
|
134
|
+
|
|
135
|
+
async Task Because() =>
|
|
136
|
+
await _scenario.Given.ForEventSource(_id)
|
|
137
|
+
.Events(new <EventType>(<arguments>), new <OtherEventType>());
|
|
138
|
+
|
|
139
|
+
[Fact] void should_<expected_state>() =>
|
|
140
|
+
Assert.True(_scenario.Instance!.<Property>);
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The helper auto-detects a model-bound projection, a fluent projection, or a
|
|
144
|
+
reducer. Use the named-parameter constructor when services or initial state are
|
|
145
|
+
needed: `new ReadModelScenario<<ReadModelType>>(initialState: null,
|
|
146
|
+
serviceProvider: services)`. Pre-seed a keyed read model with
|
|
147
|
+
`Given.ForEventSourceId(id).ReadModel(instance)` for code that calls
|
|
148
|
+
`IReadModels.GetInstanceById`. Cross-stream projections are supported — seed each
|
|
149
|
+
contributing stream with its own `Given.ForEventSource(…)`.
|
|
150
|
+
|
|
151
|
+
To bridge read models into a command scenario, build a focused
|
|
152
|
+
`ReadModelScenario<T>`, seed it, then register its read models into the command
|
|
153
|
+
scenario with `_scenario.Services.AddSingleton(readModelScenario.ReadModels)`.
|
|
154
|
+
For non-key searches, register a lookup double in the scenario services instead.
|
|
155
|
+
|
|
156
|
+
Do not pre-emptively skip a read-model assertion. Assume scalar concept, enum,
|
|
157
|
+
and identifier properties populate; if one does not, investigate the projection.
|
|
158
|
+
Skip only on a reproduced harness gap, and put the specific reason in the skip
|
|
159
|
+
message.
|
|
160
|
+
|
|
161
|
+
## `ReactorScenario<TReactor>`
|
|
162
|
+
|
|
163
|
+
```csharp
|
|
164
|
+
void Establish()
|
|
165
|
+
{
|
|
166
|
+
_<collaborator> = Substitute.For<<CollaboratorType>>();
|
|
167
|
+
_scenario = new(new ServiceCollection()
|
|
168
|
+
.AddSingleton(_<collaborator>)
|
|
169
|
+
.BuildServiceProvider());
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async Task Because() =>
|
|
173
|
+
await _scenario.Given.ForEventSource(_id).Events(new <EventType>(<arguments>));
|
|
174
|
+
|
|
175
|
+
[Fact] async Task should_<side_effect>() =>
|
|
176
|
+
await _<collaborator>.Received(1).<Method>(<expected>);
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Construct with an `IServiceProvider` of substituted collaborators and assert on
|
|
180
|
+
those substitutes after `Given` fires the events.
|
|
181
|
+
|
|
182
|
+
## Application conventions
|
|
183
|
+
|
|
184
|
+
- **Use a per-specification value for anything under a uniqueness check** — a
|
|
185
|
+
fresh `Guid`, or a truncated `Guid.NewGuid().ToString("N")[..9]`. Hardcoded
|
|
186
|
+
values cause order-dependent flakes. Do not add `[Collection(…)]` to work
|
|
187
|
+
around a collision; that is the wrong fix here.
|
|
188
|
+
- **Sequence numbers are zero-based.** The first event is `0`, the tail of two
|
|
189
|
+
events is `1`. The tail after a single append is `0`, never `1`.
|
|
190
|
+
- A behavior covering several subjects groups its specifications under
|
|
191
|
+
`<Slice>/for_<Subject>/when_<behavior>/`.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# C# specification patterns
|
|
2
|
+
|
|
3
|
+
Detail for the plain `Cratis.Specifications` surface: phases, substitution,
|
|
4
|
+
assertions, and exception capture. Verified against `Cratis.Specifications`
|
|
5
|
+
`4.1.0` and NSubstitute as consumed by that package.
|
|
6
|
+
|
|
7
|
+
## Phases
|
|
8
|
+
|
|
9
|
+
| Method | Purpose | Notes |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| `void Establish()` | Setup, before `Because()` | One per class in the chain, run base-first. Never call `base.Establish()` |
|
|
12
|
+
| `void Because()` | The single action under test | Only in a concrete specification, never in a `given/` context |
|
|
13
|
+
| `[Fact] void should_*()` | One assertion per fact | Expression-body form, no blank lines between facts |
|
|
14
|
+
| `void Destroy()` | Teardown after each specification | Optional |
|
|
15
|
+
|
|
16
|
+
Every phase may be `async Task`.
|
|
17
|
+
|
|
18
|
+
## Layered contexts
|
|
19
|
+
|
|
20
|
+
```csharp
|
|
21
|
+
// given/all_dependencies.cs — substitute every collaborator
|
|
22
|
+
public class all_dependencies : Specification
|
|
23
|
+
{
|
|
24
|
+
protected <CollaboratorType> _<collaborator>;
|
|
25
|
+
protected <OtherCollaboratorType> _<otherCollaborator>;
|
|
26
|
+
|
|
27
|
+
void Establish()
|
|
28
|
+
{
|
|
29
|
+
_<collaborator> = Substitute.For<<CollaboratorType>>();
|
|
30
|
+
_<otherCollaborator> = Substitute.For<<OtherCollaboratorType>>();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// given/a_<system_under_test>.cs — build the system under test
|
|
35
|
+
public class a_<system_under_test> : all_dependencies
|
|
36
|
+
{
|
|
37
|
+
protected <ClassName> _<sut>;
|
|
38
|
+
|
|
39
|
+
void Establish() => _<sut> = new(_<collaborator>, _<otherCollaborator>);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// when_<behavior>/and_<condition>.cs — the concrete specification
|
|
43
|
+
public class and_<condition> : given.a_<system_under_test>
|
|
44
|
+
{
|
|
45
|
+
void Because() => _<sut>.<Action>(<input>);
|
|
46
|
+
|
|
47
|
+
[Fact] void should_<expected_outcome>() =>
|
|
48
|
+
_<collaborator>.Received(1).<Method>(Arg.Any<<ArgumentType>>());
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
A context captures the world as it exists *before* the action. It never
|
|
53
|
+
contains the action itself.
|
|
54
|
+
|
|
55
|
+
## Substitution patterns
|
|
56
|
+
|
|
57
|
+
```csharp
|
|
58
|
+
// Create
|
|
59
|
+
_<collaborator> = Substitute.For<<CollaboratorType>>();
|
|
60
|
+
|
|
61
|
+
// Return values
|
|
62
|
+
_<collaborator>.<Method>(Arg.Any<string>()).Returns(<value>);
|
|
63
|
+
_<collaborator>.<AsyncMethod>(Arg.Any<int>()).Returns(Task.FromResult(<value>));
|
|
64
|
+
|
|
65
|
+
// Argument matchers
|
|
66
|
+
Arg.Is<<RequestType>>(request => request.Id == _expectedId)
|
|
67
|
+
|
|
68
|
+
// Verify
|
|
69
|
+
_<collaborator>.Received(1).<Method>(Arg.Any<string>());
|
|
70
|
+
_<collaborator>.DidNotReceive().<OtherMethod>(Arg.Any<int>());
|
|
71
|
+
|
|
72
|
+
// Capture an argument
|
|
73
|
+
_<collaborator>
|
|
74
|
+
.When(collaborator => collaborator.<Method>(Arg.Any<<ArgumentType>>()))
|
|
75
|
+
.Do(call => _captured = call.Arg<<ArgumentType>>());
|
|
76
|
+
|
|
77
|
+
// Throw from a substitute
|
|
78
|
+
_<collaborator>.<Method>(Arg.Any<<ArgumentType>>())
|
|
79
|
+
.Throws(new <ExceptionType>());
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Assertion extension methods
|
|
83
|
+
|
|
84
|
+
From `Cratis.Specifications`:
|
|
85
|
+
|
|
86
|
+
| Method | Example |
|
|
87
|
+
| --- | --- |
|
|
88
|
+
| `.ShouldEqual(expected)` | `_result.ShouldEqual(<expected>)` |
|
|
89
|
+
| `.ShouldBeTrue()` / `.ShouldBeFalse()` | `_flag.ShouldBeTrue()` |
|
|
90
|
+
| `.ShouldBeNull()` / `.ShouldNotBeNull()` | `_error.ShouldBeNull()` |
|
|
91
|
+
| `.ShouldBeEmpty()` / `.ShouldNotBeEmpty()` | `_items.ShouldBeEmpty()` |
|
|
92
|
+
| `.ShouldContain(item)` / `.ShouldNotContain(item)` | `_items.ShouldContain(<expected>)` |
|
|
93
|
+
| `.ShouldContainOnly(items)` | `_items.ShouldContainOnly(<expectedItems>)` |
|
|
94
|
+
| `.ShouldBeOfExactType<T>()` | `_event.ShouldBeOfExactType<<EventType>>()` |
|
|
95
|
+
| `.ShouldBeGreaterThan(n)` / `.ShouldBeLessThan(n)` | `_count.ShouldBeGreaterThan(0)` |
|
|
96
|
+
|
|
97
|
+
Assert on values and types. Never assert on a presentation message string — it
|
|
98
|
+
is text, not behavior.
|
|
99
|
+
|
|
100
|
+
## Catching exceptions
|
|
101
|
+
|
|
102
|
+
```csharp
|
|
103
|
+
Exception? _error;
|
|
104
|
+
|
|
105
|
+
async Task Because() => _error = await Catch.Exception(_<sut>.<Action>);
|
|
106
|
+
|
|
107
|
+
[Fact] void should_fail() => _error.ShouldNotBeNull();
|
|
108
|
+
[Fact] void should_fail_with_<reason>() =>
|
|
109
|
+
_error.ShouldBeOfExactType<<ExceptionType>>();
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Use `Catch.Exception` rather than a `try`/`catch` in `Because()`; the captured
|
|
113
|
+
exception is the outcome the facts assert on.
|
|
114
|
+
|
|
115
|
+
## Usings
|
|
116
|
+
|
|
117
|
+
`GlobalUsings.Specs.cs` supplies `Xunit`, `NSubstitute`, and
|
|
118
|
+
`Cratis.Specifications`. Do not repeat them per file, and do not add a using for
|
|
119
|
+
the namespace of the system under test.
|
|
120
|
+
|
|
121
|
+
Order non-aliased namespaces first, then a blank line, then
|
|
122
|
+
`using <alias> = …` sorted by alias name. Alias a type whose short name collides
|
|
123
|
+
with a namespace segment, choosing a domain-meaningful alias rather than a
|
|
124
|
+
technical `Command`, `Event`, or `Component` suffix.
|
|
125
|
+
|
|
126
|
+
## One outcome per file
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
# Single outcome — one file
|
|
130
|
+
for_<ClassName>/when_<behavior>.cs
|
|
131
|
+
|
|
132
|
+
# Multiple outcomes — a folder
|
|
133
|
+
for_<ClassName>/when_<behavior>/
|
|
134
|
+
and_<condition>.cs
|
|
135
|
+
and_<other_condition>.cs
|
|
136
|
+
with_<state>.cs
|
|
137
|
+
without_<requirement>.cs
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Allowed prefixes: `and_`, `with_`, `without_`, `having_`, `given_`. Each
|
|
141
|
+
distinct outcome is its own file, so a failure names the outcome that broke
|
|
142
|
+
without reading a multi-assertion file.
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Out-of-process Chronicle integration specifications
|
|
2
|
+
|
|
3
|
+
Reserve these for the host, transport, serialization, or real-infrastructure
|
|
4
|
+
boundary the in-process scenario helpers cannot reach. They exercise a complete
|
|
5
|
+
behavior — HTTP request through command handling, append, constraint checking,
|
|
6
|
+
and projection — against a real Chronicle event store.
|
|
7
|
+
|
|
8
|
+
They live under `when_<behavior>/` **inside the behavior's own folder**, not in a
|
|
9
|
+
`for_<Type>/` unit folder: there is no isolated unit, the whole slice is under
|
|
10
|
+
test. Never mix unit and integration specifications in one folder.
|
|
11
|
+
|
|
12
|
+
Verified against `Cratis.Chronicle.Testing` `17.0.0`.
|
|
13
|
+
|
|
14
|
+
## Structure
|
|
15
|
+
|
|
16
|
+
```csharp
|
|
17
|
+
using context = <RootNamespace>.<Feature>.when_<behavior>.and_<condition>.context;
|
|
18
|
+
|
|
19
|
+
namespace <RootNamespace>.<Feature>.when_<behavior>;
|
|
20
|
+
|
|
21
|
+
[Collection(ChronicleCollection.Name)]
|
|
22
|
+
public class and_<condition>(context context) : Given<context>(context)
|
|
23
|
+
{
|
|
24
|
+
public class context(ChronicleOutOfProcessFixture fixture)
|
|
25
|
+
: given.an_http_client(fixture)
|
|
26
|
+
{
|
|
27
|
+
public CommandResult<<ResponseType>>? Result;
|
|
28
|
+
|
|
29
|
+
async Task Because() =>
|
|
30
|
+
Result = await Client.ExecuteCommand<<CommandType>, <ResponseType>>(
|
|
31
|
+
"<route>", new <CommandType>(<arguments>));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
[Fact] void should_succeed() => Context.Result!.IsSuccess.ShouldBeTrue();
|
|
35
|
+
|
|
36
|
+
[Fact] void should_append_one_event() =>
|
|
37
|
+
Context.ShouldHaveTailSequenceNumber(EventSequenceNumber.First);
|
|
38
|
+
|
|
39
|
+
[Fact] void should_append_<event>() =>
|
|
40
|
+
Context.ShouldHaveAppendedEvent<<EventType>>(
|
|
41
|
+
EventSequenceNumber.First,
|
|
42
|
+
Context.Result!.Response,
|
|
43
|
+
appended => appended.<Property>.Value.ShouldEqual(<expected>));
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Seeding preconditions
|
|
48
|
+
|
|
49
|
+
Append events in `async Task Establish()` before `Because()` runs:
|
|
50
|
+
|
|
51
|
+
```csharp
|
|
52
|
+
public class context(ChronicleOutOfProcessFixture fixture)
|
|
53
|
+
: given.an_http_client(fixture)
|
|
54
|
+
{
|
|
55
|
+
public const string <ExistingValue> = "<value>";
|
|
56
|
+
public CommandResult<object>? Result;
|
|
57
|
+
|
|
58
|
+
async Task Establish() =>
|
|
59
|
+
await EventStore.EventLog.Append(
|
|
60
|
+
<IdentityType>.New(), new <EventType>(<ExistingValue>));
|
|
61
|
+
|
|
62
|
+
async Task Because() =>
|
|
63
|
+
Result = await Client.ExecuteCommand<<CommandType>>(
|
|
64
|
+
"<route>", new <CommandType>(<ExistingValue>));
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
```csharp
|
|
69
|
+
[Fact] void should_not_succeed() => Context.Result!.IsSuccess.ShouldBeFalse();
|
|
70
|
+
|
|
71
|
+
[Fact] void should_not_append_further_events() =>
|
|
72
|
+
Context.ShouldHaveTailSequenceNumber(EventSequenceNumber.First);
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Command execution overloads
|
|
76
|
+
|
|
77
|
+
```csharp
|
|
78
|
+
// No typed response — returns CommandResult<object>?
|
|
79
|
+
Result = await Client.ExecuteCommand<<CommandType>>(route, command);
|
|
80
|
+
|
|
81
|
+
// Typed response — returns CommandResult<TResult>?
|
|
82
|
+
Result = await Client.ExecuteCommand<<CommandType>, <ResponseType>>(route, command);
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Assertion helpers
|
|
86
|
+
|
|
87
|
+
| Helper | Verifies |
|
|
88
|
+
| --- | --- |
|
|
89
|
+
| `Context.Result!.IsSuccess.ShouldBeTrue()` | The command succeeded |
|
|
90
|
+
| `Context.Result!.IsSuccess.ShouldBeFalse()` | The command failed |
|
|
91
|
+
| `Context.ShouldHaveTailSequenceNumber(EventSequenceNumber.First)` | The log holds exactly one event |
|
|
92
|
+
| `Context.ShouldHaveTailSequenceNumber(n)` | The log tail is at sequence `n` |
|
|
93
|
+
| `Context.ShouldHaveAppendedEvent<TEvent>(seq, eventSourceId, validator)` | A specific event was appended with the expected values |
|
|
94
|
+
|
|
95
|
+
Sequence numbers are zero-based: `EventSequenceNumber.First` is `0`, so the tail
|
|
96
|
+
after a single append is `0`.
|
|
97
|
+
|
|
98
|
+
## Asynchronous follow-ups
|
|
99
|
+
|
|
100
|
+
When a reactor fires after the command and appends further events, collect them
|
|
101
|
+
rather than sleeping. Start the collector **before** the triggering action:
|
|
102
|
+
|
|
103
|
+
```csharp
|
|
104
|
+
_collector = StartCollectingAppends();
|
|
105
|
+
// … perform the action …
|
|
106
|
+
await _collector.WaitForCount(2, TimeSpan.FromSeconds(10));
|
|
107
|
+
|
|
108
|
+
[Fact] void should_append_<event>() =>
|
|
109
|
+
_collector.ShouldHaveEvent<<EventType>>(appended => appended.<Property> == <expected>);
|
|
110
|
+
|
|
111
|
+
void Destroy() => _collector.Dispose();
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`IEventAppendCollection` is part of the Chronicle testing API. The timeout is a
|
|
115
|
+
deadline, not a sleep — it turns a hang into a named failure.
|
|
116
|
+
|
|
117
|
+
## Rules
|
|
118
|
+
|
|
119
|
+
- `context` is an inner `public class` inheriting `given.an_http_client(fixture)`.
|
|
120
|
+
- Add the `using context = <full.namespace>.context;` alias at the top of the
|
|
121
|
+
file.
|
|
122
|
+
- `[Collection(ChronicleCollection.Name)]` goes on the outer class and is
|
|
123
|
+
required for isolation.
|
|
124
|
+
- `Establish` seeds preconditions; `Because` performs the action under test.
|
|
125
|
+
- Declare `Result` nullable.
|
|
126
|
+
- The outer class receives `context` through xUnit constructor injection.
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cratis-specifications-typescript
|
|
3
|
+
description: Write TypeScript specifications in the Cratis BDD style using the given() helper, reusable context classes, Sinon stubbing, and the Chai .should fluent interface. Use when adding or restructuring TypeScript specifications, building a given/ context class, or laying out a for_/when_ specification folder. Do not use for C# specifications, and do not use it to decide what the code under specification should do.
|
|
4
|
+
license: MIT
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Cratis TypeScript specifications
|
|
8
|
+
|
|
9
|
+
Specifications describe behavior, not implementation. The `given()` helper and
|
|
10
|
+
its context class are the TypeScript counterpart of the C# `Specification`
|
|
11
|
+
base: setup is separated from the action, and each `it()` verifies one outcome.
|
|
12
|
+
|
|
13
|
+
## Verified product sources
|
|
14
|
+
|
|
15
|
+
This skill is verified against the frameworks these specifications run on:
|
|
16
|
+
|
|
17
|
+
| Framework | Role |
|
|
18
|
+
| --- | --- |
|
|
19
|
+
| Vitest | Test runner (`yarn test` from the package root) |
|
|
20
|
+
| Mocha-style structure | `describe`, `it`, `beforeEach`, `afterEach` |
|
|
21
|
+
| Chai | Assertions, always through the `.should` fluent interface |
|
|
22
|
+
| Sinon | Stubbing and call verification |
|
|
23
|
+
|
|
24
|
+
The `given()` helper is supplied by the Cratis TypeScript package under
|
|
25
|
+
specification and is imported from that package root. Reverify the helper's
|
|
26
|
+
location and signature against the owning repository before relying on a
|
|
27
|
+
different import path.
|
|
28
|
+
|
|
29
|
+
## Route near misses
|
|
30
|
+
|
|
31
|
+
- The specification is C#: use `cratis-specifications-csharp` instead.
|
|
32
|
+
- The subject is an application view model or React component rather than a
|
|
33
|
+
Cratis TypeScript package: the plain `describe`/`beforeEach` frontend style
|
|
34
|
+
applies, not the `given()` helper in this skill.
|
|
35
|
+
- The question is what the code under specification *should do*: resolve the
|
|
36
|
+
behavior first; this skill only specifies already decided behavior.
|
|
37
|
+
|
|
38
|
+
## Step 1 — Create the folder structure
|
|
39
|
+
|
|
40
|
+
Specifications live alongside the source they describe:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
for_<ClassName>/
|
|
44
|
+
├── given/
|
|
45
|
+
│ └── a_<system_under_test>.ts ← reusable context class
|
|
46
|
+
├── when_<behavior>/ ← a behavior with multiple outcomes
|
|
47
|
+
│ ├── with_<condition>.ts
|
|
48
|
+
│ ├── without_<condition>.ts
|
|
49
|
+
│ └── and_<condition>.ts
|
|
50
|
+
└── when_<simple_behavior>.ts ← a single outcome is a single file
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Paths read as English sentences. Allowed outcome prefixes are `and_`, `with_`,
|
|
54
|
+
`without_`, `having_`, and `given_`.
|
|
55
|
+
|
|
56
|
+
**`when` belongs only in a `when_<behavior>` folder name.** A specification file
|
|
57
|
+
or non-`when_` folder must never contain the word `when`. Fold the context into
|
|
58
|
+
the `when_` folder name and use preposition files for the outcomes. Add a
|
|
59
|
+
sub-folder under `when_` only when that condition has its own multiple outcomes.
|
|
60
|
+
|
|
61
|
+
## Step 2 — Write a reusable context class
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
// for_<ClassName>/given/a_<system_under_test>.ts
|
|
65
|
+
import sinon from 'sinon';
|
|
66
|
+
import { <ClassName> } from '../../../<ClassName>';
|
|
67
|
+
|
|
68
|
+
export class a_<system_under_test> {
|
|
69
|
+
<collaborator>: sinon.StubbedInstance<<CollaboratorType>>;
|
|
70
|
+
<sut>: <ClassName>;
|
|
71
|
+
|
|
72
|
+
constructor() {
|
|
73
|
+
this.<collaborator> = sinon.createStubInstance(<CollaboratorClass>);
|
|
74
|
+
this.<collaborator>.<method>.returns(<default>);
|
|
75
|
+
this.<sut> = new <ClassName>(this.<collaborator>);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Context properties are **public** — the specification reads them through the
|
|
81
|
+
`context` parameter. This differs from C#, where `given/` fields are
|
|
82
|
+
`protected`. Name the class `a_` or `an_` so it reads as "given a service, when
|
|
83
|
+
registering".
|
|
84
|
+
|
|
85
|
+
A context captures the world as it exists *before* the action. Never put the
|
|
86
|
+
action under specification in a context.
|
|
87
|
+
|
|
88
|
+
## Step 3 — Write the specification
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
// for_<ClassName>/when_<behavior>/with_<condition>.ts
|
|
92
|
+
import { a_<system_under_test> } from '../given/a_<system_under_test>';
|
|
93
|
+
import { given } from '../../given';
|
|
94
|
+
|
|
95
|
+
describe('when <behavior> with <condition>', given(a_<system_under_test>, context => {
|
|
96
|
+
let result: <ResultType>;
|
|
97
|
+
|
|
98
|
+
beforeEach(async () => {
|
|
99
|
+
result = await context.<sut>.<action>(<input>);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should <expected outcome>', () => {
|
|
103
|
+
result.should.equal(<expected>);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should <other expected outcome>', () => {
|
|
107
|
+
context.<collaborator>.<method>.calledOnce.should.be.true;
|
|
108
|
+
});
|
|
109
|
+
}));
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`given()` instantiates the context class per specification so setup stays
|
|
113
|
+
isolated. `beforeEach` performs the single action under specification; each
|
|
114
|
+
`it()` asserts one outcome.
|
|
115
|
+
|
|
116
|
+
## Step 4 — Skip the context when nothing is shared
|
|
117
|
+
|
|
118
|
+
A behavior with no shared setup needs no context class:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
describe('when <behavior>', () => {
|
|
122
|
+
let result: <ResultType>;
|
|
123
|
+
|
|
124
|
+
beforeEach(() => {
|
|
125
|
+
result = <TypeUnderSpecification>.<action>(<input>);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should <expected outcome>', () => {
|
|
129
|
+
result.<property>.should.equal(<expected>);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Step 5 — Assert with the `.should` fluent interface
|
|
135
|
+
|
|
136
|
+
**Always use `.should`. Never use `expect()`.** The fluent style reads as a
|
|
137
|
+
sentence and matches the rest of the corpus.
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
value.should.equal(<expected>);
|
|
141
|
+
value.should.deep.equal(<expectedObject>);
|
|
142
|
+
flag.should.be.true;
|
|
143
|
+
value.should.be.null;
|
|
144
|
+
value.should.not.be.undefined;
|
|
145
|
+
items.should.contain(<expected>);
|
|
146
|
+
items.should.have.lengthOf(<count>);
|
|
147
|
+
items.should.be.empty;
|
|
148
|
+
value.should.be.instanceOf(<Type>);
|
|
149
|
+
(() => <throwingCall>()).should.throw(<ErrorType>);
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Full Sinon and Chai detail is in
|
|
153
|
+
[typescript-patterns.md](references/typescript-patterns.md).
|
|
154
|
+
|
|
155
|
+
## Step 6 — Apply the naming conventions
|
|
156
|
+
|
|
157
|
+
| Element | Convention | Example |
|
|
158
|
+
| --- | --- | --- |
|
|
159
|
+
| `describe()` text | Natural-language sentence | `'when registering with a valid name'` |
|
|
160
|
+
| `it()` text | Starts with "should", **uses spaces** | `'should append an event'` |
|
|
161
|
+
| Context class | `a_` or `an_` prefix | `an_author_service` |
|
|
162
|
+
| Specification file | Preposition prefix | `with_valid_name.ts` |
|
|
163
|
+
|
|
164
|
+
Use spaces in `it()` descriptions, never underscores — they appear in runner
|
|
165
|
+
output as human-readable sentences. This is the deliberate difference from the C#
|
|
166
|
+
`should_` method naming.
|
|
167
|
+
|
|
168
|
+
## Step 7 — Keep one behavior per file
|
|
169
|
+
|
|
170
|
+
Do not mix orthogonal behaviors in one specification. Separate folders per
|
|
171
|
+
behavior keep failures precise:
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
when_items_are_added_as_delta/and_item_is_identified_by_a_guid.ts
|
|
175
|
+
when_items_are_removed_as_delta/and_item_is_identified_by_a_guid.ts
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
A single file validating both add-delta and remove-delta behavior tangles
|
|
179
|
+
unrelated outcomes and hides which one broke.
|
|
180
|
+
|
|
181
|
+
## What not to specify
|
|
182
|
+
|
|
183
|
+
- Simple property getters and setters.
|
|
184
|
+
- Properties that return a constructor parameter directly.
|
|
185
|
+
- Trivial delegation.
|
|
186
|
+
- Anything a `describe` beginning "when getting" or "when returning" would
|
|
187
|
+
describe — that is a getter, not a behavior.
|
|
188
|
+
|
|
189
|
+
## Verify
|
|
190
|
+
|
|
191
|
+
- Each specification file states one `describe`, one action in `beforeEach`, and
|
|
192
|
+
one or more single-outcome `it()` assertions.
|
|
193
|
+
- No path contains `when` outside a `when_<behavior>` folder name.
|
|
194
|
+
- Outcome files use an allowed preposition prefix.
|
|
195
|
+
- Context classes carry public properties, are named `a_`/`an_`, and contain no
|
|
196
|
+
action under specification.
|
|
197
|
+
- Every assertion uses `.should`; no `expect()` appears.
|
|
198
|
+
- `it()` descriptions start with "should" and use spaces.
|
|
199
|
+
- Stubs are restored between specifications where Sinon state would otherwise
|
|
200
|
+
leak.
|
|
201
|
+
- Nothing trivial is specified.
|
|
202
|
+
- `yarn test` passes from the package root.
|