@kensio/skills 1.13.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 +48 -0
- package/bin/kensio-skills.mjs +213 -0
- package/package.json +39 -0
- package/skills/dynamodb-single-table/SKILL.md +329 -0
- package/skills/dynamodb-single-table/references/aws-guidance.md +183 -0
- package/skills/github-issue-drafting/SKILL.md +260 -0
- package/skills/isolated-testing-style/SKILL.md +293 -0
- package/skills/pangram-check/SKILL.md +125 -0
- package/skills/pangram-check/references/configuration.md +120 -0
- package/skills/pangram-check/references/reading-results.md +69 -0
- package/skills/pangram-check/scripts/pangram-check.mjs +807 -0
- package/skills/part-factory-test-data/SKILL.md +238 -0
- package/skills/skill-template/SKILL.md +126 -0
- package/skills/technical-prose-style/SKILL.md +356 -0
- package/skills/technical-prose-style/references/measurements.md +356 -0
- package/skills/technical-prose-style/scripts/prose-check.mjs +381 -0
- package/skills/yulin-aws-simulation/SKILL.md +324 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: yulin-aws-simulation
|
|
3
|
+
description: Use the @kensio/yulin in-process AWS simulator well when testing AWS code, using it directly rather than building a harness around it, driving tests, local dev and production from one synthesized CDK template, intercepting SDK clients with SimSdk, controlling simulated time, matching service errors by name, and handling properties Yulin refuses to simulate. Use when writing or reviewing tests that touch AWS, when replacing aws-sdk-client-mock or hand-rolled AWS stubs, when a CDK stack needs testing, when test setup around Yulin is growing helper classes or wrapper functions, and when Yulin refuses a template property or an SDK command.
|
|
4
|
+
license: Apache-2.0
|
|
5
|
+
metadata:
|
|
6
|
+
version: "1.13.1"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Testing with Yulin
|
|
10
|
+
|
|
11
|
+
[Yulin](https://yulinsim.dev/) (`@kensio/yulin`) simulates AWS in process, in memory, with no
|
|
12
|
+
network and no AWS account. Its own docs are the authority on the API. This skill covers how to use
|
|
13
|
+
it well, and that lives mostly outside the API.
|
|
14
|
+
|
|
15
|
+
Read the package docs for anything API-shaped. Start with
|
|
16
|
+
[the README](https://github.com/KensioSoftware/yulin#readme), then `docs/sdk/` for interception and
|
|
17
|
+
`docs/services/<name>/` for each simulated service.
|
|
18
|
+
|
|
19
|
+
This skill serves the `isolated-testing-style` skill, the general argument for simulation over
|
|
20
|
+
stubs.
|
|
21
|
+
|
|
22
|
+
Yulin is deliberately flexible, and plenty of shapes work. What follows is the recommended way to
|
|
23
|
+
get the most out of it, offered as guidance. Each one says what it buys. A situation that does not
|
|
24
|
+
want that trade can go the other way knowingly.
|
|
25
|
+
|
|
26
|
+
## Use what Yulin already gives you
|
|
27
|
+
|
|
28
|
+
The most common way to go wrong with Yulin is to build something on top of it. The failure looks
|
|
29
|
+
like a `TestAwsEnvironment` class, a `setupSimulatedAws()` helper returning six things, a factory
|
|
30
|
+
per service, or a `beforeEach` that reassembles the world (a private framework wrapped around a tool
|
|
31
|
+
that is already the framework).
|
|
32
|
+
|
|
33
|
+
It is worth resisting, because Yulin is built to be used directly:
|
|
34
|
+
|
|
35
|
+
- `new SimAws()` and `new SimSdk()` are plain constructors. No side effects, no network, no cleanup
|
|
36
|
+
and no awaiting. Creating a simulation per test is close to free.
|
|
37
|
+
- Service accessors take the same Command objects the AWS SDK does, so seeding and asserting need no
|
|
38
|
+
translation layer of their own.
|
|
39
|
+
- `using simSdk = new SimSdk()` is the teardown.
|
|
40
|
+
- `deployTemplateFile` is the environment.
|
|
41
|
+
|
|
42
|
+
So the recommended shape of a test is to construct, deploy if a template is involved, intercept,
|
|
43
|
+
exercise, then assert by reading the simulation back. A wrapper around any of those steps hides the
|
|
44
|
+
one thing a reader of the test needs to see, and it has to be maintained forever after.
|
|
45
|
+
|
|
46
|
+
If a sequence genuinely repeats, make it a small function in the same test file, and keep it
|
|
47
|
+
returning the simulation objects themselves. A bespoke return shape starts hiding them. The point at
|
|
48
|
+
which it wants a class, an options interface, or a directory, it has stopped being test setup and
|
|
49
|
+
become a second product.
|
|
50
|
+
|
|
51
|
+
## One synthesized template, for tests, local dev and production
|
|
52
|
+
|
|
53
|
+
Describe the infrastructure once, in CDK, and let the same synthesized output drive all three.
|
|
54
|
+
Production deploys it. The local dev server deploys it. The tests deploy it:
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
const stack = await simAws.region("eu-west-2").cloudFormation().deployTemplateFile({
|
|
58
|
+
templatePath: "cdk.out/SiteStack.template.json",
|
|
59
|
+
stackName: "site-stack",
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
await stack.waitForDeployComplete();
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A test written against a template you wrote by hand only tests the template you wrote by hand.
|
|
66
|
+
Deploying the synthesized output means a construct change that breaks the system breaks the test.
|
|
67
|
+
|
|
68
|
+
The same argument extends past the test suite. A dev environment that creates its buckets and tables
|
|
69
|
+
by hand is a third description of the infrastructure, drifting away from the other two at its own
|
|
70
|
+
pace, and the drift shows up as a bug that reproduces in exactly one of the three places. Pointing
|
|
71
|
+
the dev server at `cdk.out` as well removes the whole category. What runs locally is what CI tested
|
|
72
|
+
and what production deploys, and `watch` turns a `cdk synth` into a stack update in place.
|
|
73
|
+
|
|
74
|
+
The corollary is that infrastructure belongs in the CDK app even when only a test needs it. A bucket
|
|
75
|
+
conjured in test setup is infrastructure that production does not have.
|
|
76
|
+
|
|
77
|
+
**Do not hand-roll a wrapper that reads the file and calls `deployTemplate`.** `deployTemplateFile`
|
|
78
|
+
already reads it, and it locates the cloud assembly beside the file. The assets manifest and staged
|
|
79
|
+
asset directories resolve. A wrapper that reads the JSON itself loses that, and anything needing a
|
|
80
|
+
CDK asset, such as a `Custom::CDKBucketDeployment` or a `Code.fromAsset` function, fails with
|
|
81
|
+
`No CDK assets manifest is available.`
|
|
82
|
+
|
|
83
|
+
The two options that make a wrapper unnecessary:
|
|
84
|
+
|
|
85
|
+
- **`transform`** is given the parsed template and answers with the one to deploy. It runs on the
|
|
86
|
+
deployment and again on every re-read. A wrapper cannot do that. Use it for what a simulation
|
|
87
|
+
genuinely cannot resolve, such as an ARN carrying a real account, or a hosted zone ID that came
|
|
88
|
+
from `HostedZone.fromLookup`.
|
|
89
|
+
- **`watch`** re-applies the file when it changes, updating the stack in place. This is for dev
|
|
90
|
+
servers. A `cdk synth` becomes a stack update without restarting the process, and resources the
|
|
91
|
+
change left alone keep what they hold.
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
await simAws.cloudFormation().deployTemplateFile({
|
|
95
|
+
templatePath: "cdk.out/SiteStack.template.json",
|
|
96
|
+
transform: withoutLookedUpHostedZone,
|
|
97
|
+
watch: { onUpdated: () => srv.reload() },
|
|
98
|
+
});
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Intercept real SDK clients, never hand-roll stubs
|
|
102
|
+
|
|
103
|
+
`SimSdk` replaces the `send` method of an AWS SDK client class or instance, so real clients answer
|
|
104
|
+
from the simulation. The code under test uses the SDK exactly as it does in production and never
|
|
105
|
+
learns there is a simulator behind it.
|
|
106
|
+
|
|
107
|
+
```typescript
|
|
108
|
+
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
|
|
109
|
+
import { SimSdk } from "@kensio/yulin/sdk";
|
|
110
|
+
|
|
111
|
+
using simSdk = new SimSdk();
|
|
112
|
+
simSdk.intercept(SecretsManagerClient); // Every instance, including ones made later.
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
A stub asserts that your code called something. The simulator asserts that it called the service
|
|
116
|
+
correctly. On a real project, swapping stubs for interception caught two bugs the same afternoon,
|
|
117
|
+
both already in production:
|
|
118
|
+
|
|
119
|
+
- A Secrets Manager secret whose name ended in a hyphen and six characters, exactly the suffix
|
|
120
|
+
Secrets Manager appends to an ARN. AWS advises against names of that shape because they are
|
|
121
|
+
ambiguous with the ARN form. A stub has no naming rules. It had accepted it happily.
|
|
122
|
+
- A Cognito `SECRET_HASH` computed the wrong way. The stub had accepted that too, because a stub is
|
|
123
|
+
never going to verify a signature.
|
|
124
|
+
|
|
125
|
+
Intercept the class in most cases, since the code under test usually constructs its own clients.
|
|
126
|
+
Intercept an instance when only one client should reach the simulation.
|
|
127
|
+
|
|
128
|
+
Each `SimSdk` owns a `SimAws`, reachable as `simSdk.simAws` for seeding and inspecting state. Pass
|
|
129
|
+
an existing one with `new SimSdk({ simAws })` to share.
|
|
130
|
+
|
|
131
|
+
### Intercept what the code actually sends through
|
|
132
|
+
|
|
133
|
+
Interception replaces `send` on the thing it is given. It has to be given the client the code under
|
|
134
|
+
test actually calls. The wrapper clients are where this bites. A `DynamoDBDocumentClient` built over
|
|
135
|
+
a `DynamoDBClient` is what the code sends through. The document client is the one that needs
|
|
136
|
+
intercepting.
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
using simSdk = new SimSdk();
|
|
140
|
+
|
|
141
|
+
const documents = DynamoDBDocumentClient.from(new DynamoDBClient({ region: "eu-west-2" }));
|
|
142
|
+
simSdk.intercept(documents); // Not the DynamoDBClient it was built from.
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Every Command through an intercepted client is routed to the simulation by default. An allow list of
|
|
146
|
+
Command classes narrows that, and is worth reaching for only when something else should genuinely
|
|
147
|
+
handle the rest.
|
|
148
|
+
|
|
149
|
+
### Prefer `using` over a teardown step
|
|
150
|
+
|
|
151
|
+
`SimSdk` and the interception handles it returns are disposable, so `using simSdk = new SimSdk();`
|
|
152
|
+
restores every intercepted client at the end of the scope. `simSdk.restoreAll()` and
|
|
153
|
+
`interception.restore()` do the same thing by hand.
|
|
154
|
+
|
|
155
|
+
The recommendation is `using`, because it is teardown that cannot be forgotten or skipped by an
|
|
156
|
+
early return, and because it leaves nothing for a later test to inherit if the one before it threw.
|
|
157
|
+
Reach for the explicit calls when interception has to stop somewhere other than the end of a scope.
|
|
158
|
+
|
|
159
|
+
## Freeze the clock and advance it deliberately
|
|
160
|
+
|
|
161
|
+
Each `SimAws` carries its own clock, independent of the host and of every other simulation in the
|
|
162
|
+
process. The recommendation is to start it frozen at a fixed instant and move it only on purpose:
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
const simAws = new SimAws({
|
|
166
|
+
clock: new SimFixedClock(new Date("2026-07-26T09:00:00.000Z")),
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Given a session that has run out while the caller was idle.
|
|
170
|
+
await simAws.clock().advanceBy({ minutes: 20 });
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
What this buys is that time-dependent behaviour becomes something a test can assert on in
|
|
174
|
+
microseconds rather than something it waits for or gives up on. A good deal of the simulation keys
|
|
175
|
+
off that clock: EventBridge rules and Scheduler schedules fire only when time is advanced past them,
|
|
176
|
+
DynamoDB items pass their TTL, Secrets Manager deletions come due, `AssumeRole` sessions expire, and
|
|
177
|
+
Lambda event source mappings re-poll. Inside a simulated Lambda, `Date.now()` and `new Date()`
|
|
178
|
+
report simulated time. A handler's own expiry logic is exercised without a stub in sight.
|
|
179
|
+
|
|
180
|
+
That last point is worth drawing out. `isolated-testing-style` allows a stub for a clock, on the
|
|
181
|
+
grounds that a clock has no rules worth modelling. Against Yulin that exception is unnecessary. The
|
|
182
|
+
clock is part of the simulation, and advancing it exercises the real expiry rules of the services
|
|
183
|
+
around it rather than only the code's own arithmetic.
|
|
184
|
+
|
|
185
|
+
`simAws.clock().resume()` switches to tracking the underlying clock, and `simAws.clock().isFrozen`
|
|
186
|
+
reports which mode it is in. Running mode suits a local dev server. A test that wants it usually
|
|
187
|
+
wants an advance instead.
|
|
188
|
+
|
|
189
|
+
## Assert by reading the simulation back
|
|
190
|
+
|
|
191
|
+
The simulation holds real state. The assertion can read it. After exercising the code, ask the
|
|
192
|
+
service what happened rather than asking the SDK what it was told:
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
// Then the upload is in the bucket, under the key the handler chose.
|
|
196
|
+
const object = await simAws.s3().getObject(new GetObjectCommand({ Bucket: bucket, Key: key }));
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Service accessors take the same Command objects the SDK does. The seeding and assertion code reads
|
|
200
|
+
like the production code between them.
|
|
201
|
+
|
|
202
|
+
This is where simulation pays off over stubs a second time. A call-count assertion holds only for
|
|
203
|
+
today's implementation. A state assertion holds however the handler is rewritten, and it fails if
|
|
204
|
+
the call was made in a way the real service would have rejected.
|
|
205
|
+
|
|
206
|
+
## Match service errors by name
|
|
207
|
+
|
|
208
|
+
```typescript
|
|
209
|
+
// Wrong. Passes in production, fails against the simulator.
|
|
210
|
+
if (error instanceof ResourceNotFoundException) { ... }
|
|
211
|
+
|
|
212
|
+
// Right.
|
|
213
|
+
if (error instanceof Error && error.name === "ResourceNotFoundException") { ... }
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
The SDK exports exception classes, which invites the `instanceof` check. It holds only while exactly
|
|
217
|
+
one copy of the SDK package is in play. Two copies in the module graph, a bundler, or a simulator
|
|
218
|
+
raising its own classes, and it silently stops matching. Yulin's errors carry the service's real
|
|
219
|
+
error names and SDK-shaped `$metadata`, but they are not instances of the SDK classes.
|
|
220
|
+
|
|
221
|
+
This is worth fixing in production code, and working around it in tests hides it. `name` is what the
|
|
222
|
+
wire carries. The `name` check is the one that is right in both places. A version skew between two
|
|
223
|
+
`@aws-sdk/client-*` packages breaks `instanceof` in production too, just less predictably than the
|
|
224
|
+
simulator does.
|
|
225
|
+
|
|
226
|
+
## Expect refusals, and treat them as a feature
|
|
227
|
+
|
|
228
|
+
Yulin refuses a property it cannot simulate, and never ignores one. That is the right trade.
|
|
229
|
+
silently accepting something that changes real behaviour turns a deploy-time failure into a
|
|
230
|
+
production one.
|
|
231
|
+
|
|
232
|
+
The cost is that one unsupported setting can make a whole stack unsimulatable, and the refusal
|
|
233
|
+
arrives one property at a time. When that happens, **enumerate every refusal in one pass**. Strip
|
|
234
|
+
properties from the synthesized template until it deploys, keeping a list, then raise them together
|
|
235
|
+
upstream.
|
|
236
|
+
|
|
237
|
+
```typescript
|
|
238
|
+
// A throwaway transform used to find the floor. Delete it afterwards.
|
|
239
|
+
function stripUntilItDeploys(template: CfnTemplateBodyRecord): CfnTemplateBodyRecord {
|
|
240
|
+
// Remove one refused property, re-run, record the next refusal, repeat.
|
|
241
|
+
// Keep the list. Raise it as one issue.
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Doing this one release at a time means one round trip per property, and you never learn how far away
|
|
246
|
+
a working simulation actually is. This is the same rule as not discovering service ceilings one
|
|
247
|
+
failed deployment at a time.
|
|
248
|
+
|
|
249
|
+
Not every gap is a refusal. Several services record a property they cannot model and carry on,
|
|
250
|
+
reporting it as an ignored property on the stack and on the resource. Check that report before
|
|
251
|
+
trusting a test that depends on the setting.
|
|
252
|
+
|
|
253
|
+
## Raise gaps upstream, and weight false passes far above false refusals
|
|
254
|
+
|
|
255
|
+
Fix gaps on [the Yulin repository](https://github.com/KensioSoftware/yulin) at source. A local
|
|
256
|
+
workaround has to be maintained in every project that hits the same gap.
|
|
257
|
+
|
|
258
|
+
When reporting, the asymmetry matters more than the volume:
|
|
259
|
+
|
|
260
|
+
- A simulator that **stays silent** about something costs you little. The test leaves that behaviour
|
|
261
|
+
uncovered, where it was already.
|
|
262
|
+
- A simulator that **says 200 where production says 403** converts a deploy-time failure into a
|
|
263
|
+
production one. That is the opposite of what it is for.
|
|
264
|
+
|
|
265
|
+
So a false pass deserves far more attention than a false refusal. A real example: Yulin authorised a
|
|
266
|
+
Lambda function URL invocation against `lambda:InvokeFunctionUrl` alone. CloudFront origin access
|
|
267
|
+
control also needs `lambda:InvokeFunction`. The tests passed, the release went out, and the endpoint
|
|
268
|
+
403'd in production. A refusal would have cost an afternoon. The false pass cost an incident.
|
|
269
|
+
|
|
270
|
+
Report a false pass with what production does and what the simulation did. Report a false refusal
|
|
271
|
+
with the property and the template that carries it.
|
|
272
|
+
|
|
273
|
+
## Deploy expensive context once per test file
|
|
274
|
+
|
|
275
|
+
Vitest gives each test file its own worker, so module-level state is already isolated between files.
|
|
276
|
+
Deploy a stack once for the file and let the tests share it. Isolation inside the file comes from
|
|
277
|
+
randomised names. Rebuilding the environment buys nothing.
|
|
278
|
+
|
|
279
|
+
```typescript
|
|
280
|
+
let simAws: SimAws;
|
|
281
|
+
|
|
282
|
+
beforeAll(async () => {
|
|
283
|
+
// Given the real synthesized stack, deployed once for this file.
|
|
284
|
+
simAws = new SimAws();
|
|
285
|
+
const stack = await simAws.cloudFormation().deployTemplateFile({
|
|
286
|
+
templatePath: "cdk.out/SiteStack.template.json",
|
|
287
|
+
});
|
|
288
|
+
await stack.waitForDeployComplete();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("stores an upload", async () => {
|
|
292
|
+
// Given a key no other test in this file is using.
|
|
293
|
+
const key = `uploads/${faker.string.uuid()}.png`;
|
|
294
|
+
// ...
|
|
295
|
+
});
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Putting a template deployment in `beforeEach` pays for the whole stack once per test for no
|
|
299
|
+
isolation you did not already have.
|
|
300
|
+
|
|
301
|
+
A template deployment is the only thing usually worth hoisting. Everything else (the `SimSdk`, a
|
|
302
|
+
seeded row, a bucket key) is cheap enough to build inside the test that needs it, and it is also
|
|
303
|
+
where it is easiest to read. A `beforeEach` that assembles state for tests that do not all want the
|
|
304
|
+
same state is the beginning of the harness this skill opens by arguing against.
|
|
305
|
+
|
|
306
|
+
## Run the handler as a real simulated Lambda
|
|
307
|
+
|
|
308
|
+
Yulin can run an in-process handler as a function inside the simulation, in place of a direct call
|
|
309
|
+
from the test. Its SDK calls are routed into the simulation as the execution role. The IAM policies
|
|
310
|
+
in the template are exercised too.
|
|
311
|
+
|
|
312
|
+
Bind a handler to a template function at deploy time with `bindings`:
|
|
313
|
+
|
|
314
|
+
```typescript
|
|
315
|
+
await simAws.cloudFormation().deployTemplateFile({
|
|
316
|
+
templatePath: "cdk.out/ApiStack.template.json",
|
|
317
|
+
bindings: [{ logicalId: "UploadFunction", handler: uploadHandler }],
|
|
318
|
+
});
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
The handler still runs in process. It can close over test state and be stepped through in a
|
|
322
|
+
debugger. The difference from calling it directly is that a missing `s3:PutObject` on the execution
|
|
323
|
+
role now fails the test, at the point AWS would have failed it. A binding can target a function by
|
|
324
|
+
`logicalId`, `functionName`, `arn`, `cdkPath`, or `imageRepository` for a container image function.
|