@kensio/skills 1.13.1 → 1.15.0
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/package.json +1 -1
- package/skills/dynamodb-single-table/SKILL.md +1 -1
- package/skills/github-issue-drafting/SKILL.md +1 -1
- package/skills/isolated-testing-style/SKILL.md +1 -1
- package/skills/pangram-check/SKILL.md +1 -1
- package/skills/part-factory-test-data/SKILL.md +1 -1
- package/skills/skill-template/SKILL.md +1 -1
- package/skills/technical-prose-style/SKILL.md +1 -1
- package/skills/yulin-aws-simulation/SKILL.md +231 -184
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@ name: dynamodb-single-table
|
|
|
3
3
|
description: Model data in Amazon DynamoDB as one table by default, reading the current AWS guidance before committing to a schema, writing the access patterns down before any keys exist, holding every entity type in one table, overloading generic partition and sort keys across those types, overloading and sparsifying secondary indexes, and splitting items by write rate. Use when designing or reviewing a DynamoDB schema, when a CDK stack is about to gain a second table, when an entity needs a query it has no key for, when application code fetches from two tables to assemble one response, when a Scan or a filter expression appears, when a partition runs hot or a write throttles, and when asked how to model users, orders, events or tenants in DynamoDB.
|
|
4
4
|
license: Apache-2.0
|
|
5
5
|
metadata:
|
|
6
|
-
version: "1.
|
|
6
|
+
version: "1.15.0"
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# Single-table design in DynamoDB
|
|
@@ -3,7 +3,7 @@ name: github-issue-drafting
|
|
|
3
3
|
description: Draft a GitHub issue from a short note or a rough idea, grounding every claim about the code in the repository the issue will be filed against, splitting work that is really two issues, and filing it with `gh` once the user has approved the draft. Use when turning a to-do item, a Slack message, a code TODO, a failing test or a bug report into an issue, when asked to "write up an issue for" something, to "raise", "file" or "open an issue", when asked whether something should be one issue or several, and when tidying the titles, types or labels of issues that already exist.
|
|
4
4
|
license: Apache-2.0
|
|
5
5
|
metadata:
|
|
6
|
-
version: "1.
|
|
6
|
+
version: "1.15.0"
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# GitHub issue drafting
|
|
@@ -3,7 +3,7 @@ name: isolated-testing-style
|
|
|
3
3
|
description: Write tests that start from given/when/then, use real collaborators through simulation rather than stubs and mocks, take their isolation from randomised data rather than shared setup and teardown, and assert behaviour rather than call counts. Use when writing or reviewing tests, when a test needs a collaborator faked, when reaching for a mock, spy, `toHaveBeenCalledWith`, `beforeEach`/`afterEach` fixtures or a hardcoded expected hash, when test setup has grown tangled, and when asked "how should I test this?".
|
|
4
4
|
license: Apache-2.0
|
|
5
5
|
metadata:
|
|
6
|
-
version: "1.
|
|
6
|
+
version: "1.15.0"
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# Isolated testing style
|
|
@@ -4,7 +4,7 @@ description: Send the prose of a finished document to Pangram, a commercial AI-t
|
|
|
4
4
|
license: Apache-2.0
|
|
5
5
|
compatibility: Needs a Pangram API key and network access. Every run is billed.
|
|
6
6
|
metadata:
|
|
7
|
-
version: "1.
|
|
7
|
+
version: "1.15.0"
|
|
8
8
|
disable-model-invocation: true
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -3,7 +3,7 @@ name: part-factory-test-data
|
|
|
3
3
|
description: Build test data with @kensio/part-factory, keeping in the factory everything a test does not care about, passing dependencies at call time so factories stay independent, and choosing between StaticFactory, DynamicFactory, VariantFactory, MappedFactory and AsyncMappedFactory. Use when writing test fixtures or builders, when a test file is full of object literals, when a shared event, message or payload shape is being hand-written, when a required field is about to be made optional to ease test setup, and when tempted to wrap a factory in a helper function that applies overrides.
|
|
4
4
|
license: Apache-2.0
|
|
5
5
|
metadata:
|
|
6
|
-
version: "1.
|
|
6
|
+
version: "1.15.0"
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# Building test data with Part Factory
|
|
@@ -3,7 +3,7 @@ name: skill-template
|
|
|
3
3
|
description: Scaffold a new agent skill, writing the SKILL.md and its frontmatter to the Agent Skills specification, then wrapping it as a plugin in this repo with package.json, plugin.json and a marketplace entry. Use when the user asks to "add a new skill", "create a skill", "write a SKILL.md" or "start a new plugin", and when checking whether an existing skill is portable between agents.
|
|
4
4
|
license: Apache-2.0
|
|
5
5
|
metadata:
|
|
6
|
-
version: "1.
|
|
6
|
+
version: "1.15.0"
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# Skill template
|
|
@@ -3,7 +3,7 @@ name: technical-prose-style
|
|
|
3
3
|
description: Write documentation, READMEs, code comments, commit messages, release notes and PR text as plain technical prose, by removing the constructions that make unedited LLM prose tiring to read. It targets significance tails, contrastive definition, negation framing, appositive tails, colon explainers and a vocabulary that keeps renaming the same thing. Ships a script that scores prose against Django, Go, Rust and Python documentation. Use when writing or editing docs, a README, a changelog, a blog post or any prose for human readers, when asked to improve, tighten or rewrite writing, when prose "sounds like AI" or "sounds like Claude", and when reviewing documentation in a pull request.
|
|
4
4
|
license: Apache-2.0
|
|
5
5
|
metadata:
|
|
6
|
-
version: "1.
|
|
6
|
+
version: "1.15.0"
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# Technical prose style
|
|
@@ -1,57 +1,40 @@
|
|
|
1
1
|
---
|
|
2
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.
|
|
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, deploying a whole cdk.out cloud assembly, 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
4
|
license: Apache-2.0
|
|
5
5
|
metadata:
|
|
6
|
-
version: "1.
|
|
6
|
+
version: "1.15.0"
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# Testing with Yulin
|
|
10
10
|
|
|
11
11
|
[Yulin](https://yulinsim.dev/) (`@kensio/yulin`) simulates AWS in process, in memory, with no
|
|
12
|
-
network and no AWS account.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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.
|
|
12
|
+
network and no AWS account. This skill is how to use it well. For the API read
|
|
13
|
+
[yulinsim.dev/llms.txt](https://yulinsim.dev/llms.txt), one markdown page per guide and per
|
|
14
|
+
simulated service (drop the `llms.txt` for HTML, or read `docs/` in the repository). It serves
|
|
15
|
+
`isolated-testing-style`, the general argument for simulation over stubs. Each rule says what it
|
|
16
|
+
buys, and a case that does not want that trade can go the other way knowingly.
|
|
25
17
|
|
|
26
18
|
## Use what Yulin already gives you
|
|
27
19
|
|
|
28
|
-
The most common way to go wrong
|
|
29
|
-
|
|
30
|
-
|
|
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.
|
|
20
|
+
The most common way to go wrong is to build something on top of it. The failure looks like a
|
|
21
|
+
`TestAwsEnvironment` class, a `setupSimulatedAws()` helper returning six things, a factory per
|
|
22
|
+
service, or a `beforeEach` that reassembles the world.
|
|
41
23
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
24
|
+
Yulin is built to be used directly. `new SimAws()` and `new SimSdk()` are plain constructors with no
|
|
25
|
+
side effects, no network, no cleanup and no awaiting. Service accessors take the same Command
|
|
26
|
+
objects the SDK does. `using simSdk = new SimSdk()` is the teardown, `deployTemplateFile` is the
|
|
27
|
+
environment, and a simulation per test is close to free.
|
|
45
28
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
29
|
+
So a test constructs, deploys if a template is involved, intercepts, exercises, then asserts by
|
|
30
|
+
reading the simulation back. A wrapper around any of those steps hides the one thing the reader
|
|
31
|
+
needs to see. A repeated sequence can be a small function in the same file returning the simulation
|
|
32
|
+
objects themselves. Once it wants a class, an options interface or a directory, it has become a
|
|
33
|
+
second product.
|
|
50
34
|
|
|
51
35
|
## One synthesized template, for tests, local dev and production
|
|
52
36
|
|
|
53
|
-
Describe the infrastructure once
|
|
54
|
-
Production deploys it. The local dev server deploys it. The tests deploy it:
|
|
37
|
+
Describe the infrastructure once in CDK and let the synthesized output drive all three:
|
|
55
38
|
|
|
56
39
|
```typescript
|
|
57
40
|
const stack = await simAws.region("eu-west-2").cloudFormation().deployTemplateFile({
|
|
@@ -62,104 +45,134 @@ const stack = await simAws.region("eu-west-2").cloudFormation().deployTemplateFi
|
|
|
62
45
|
await stack.waitForDeployComplete();
|
|
63
46
|
```
|
|
64
47
|
|
|
65
|
-
A test written against a template
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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.
|
|
48
|
+
A test written against a hand-written template only tests the hand-written template, and a dev
|
|
49
|
+
environment building its own buckets is a third description whose drift shows up as a bug
|
|
50
|
+
reproducing in exactly one of the three places. So infrastructure belongs in the CDK app even when
|
|
51
|
+
only a test needs it.
|
|
76
52
|
|
|
77
53
|
**Do not hand-roll a wrapper that reads the file and calls `deployTemplate`.** `deployTemplateFile`
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
CDK
|
|
81
|
-
`No CDK assets manifest is available.`
|
|
82
|
-
|
|
83
|
-
The two options that make a wrapper unnecessary:
|
|
54
|
+
locates the cloud assembly beside the file, and that is how staged CDK assets resolve. A wrapper
|
|
55
|
+
reading the JSON fails anything needing one (a `Custom::CDKBucketDeployment`, a `Code.fromAsset`
|
|
56
|
+
function) with `No CDK assets manifest is available.` Two options make a wrapper unnecessary.
|
|
84
57
|
|
|
85
|
-
- **`transform`** is given the parsed template and answers with the one to deploy
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
change left alone keep what they hold.
|
|
58
|
+
- **`transform`** is given the parsed template and answers with the one to deploy, on the deployment
|
|
59
|
+
and on every re-read. Use it for what a simulation genuinely cannot resolve, such as an ARN
|
|
60
|
+
carrying a real account.
|
|
61
|
+
- **`watch`** re-applies the file when it changes, updating the stack in place and leaving untouched
|
|
62
|
+
resources holding what they held. For dev servers, where a `cdk synth` becomes a stack update
|
|
63
|
+
without restarting the process.
|
|
92
64
|
|
|
93
65
|
```typescript
|
|
94
66
|
await simAws.cloudFormation().deployTemplateFile({
|
|
95
67
|
templatePath: "cdk.out/SiteStack.template.json",
|
|
96
|
-
transform:
|
|
68
|
+
transform: withRealAccountArnsResolved,
|
|
97
69
|
watch: { onUpdated: () => srv.reload() },
|
|
98
70
|
});
|
|
99
71
|
```
|
|
100
72
|
|
|
101
|
-
|
|
73
|
+
Together they retire the derived template file a dev server used to write into `cdk.out` so that it
|
|
74
|
+
had something to watch. The watched file is the one CDK wrote, and the adaptation re-applies on
|
|
75
|
+
every read.
|
|
102
76
|
|
|
103
|
-
`
|
|
104
|
-
|
|
105
|
-
|
|
77
|
+
`stack.output("SiteBucketName")` answers a resolved Output narrowed to a string, throwing on one the
|
|
78
|
+
template never declared. Do not hand-roll that reader. And note that a failed `cdk synth` leaves the
|
|
79
|
+
previous template in `cdk.out` with the tests still passing against it, so check the synthesized
|
|
80
|
+
JSON changed before concluding anything from a construct change.
|
|
106
81
|
|
|
107
|
-
|
|
108
|
-
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
|
|
109
|
-
import { SimSdk } from "@kensio/yulin/sdk";
|
|
82
|
+
### Register what the app looks up, deploy what the app creates
|
|
110
83
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
84
|
+
A CDK app pinning an identifier as a literal string across stacks raises the question of where the
|
|
85
|
+
simulated resource carrying it comes from. Yulin stands one up at a chosen id with
|
|
86
|
+
`simAws.route53().registerHostedZone({ id, name })`,
|
|
87
|
+
`simAws.acm().registerCertificate({ arn, domainName })`,
|
|
88
|
+
`simAws.cognitoIdentityProvider().registerUserPool({ id, name })` and `registerUserPoolClient`.
|
|
114
89
|
|
|
115
|
-
A
|
|
116
|
-
|
|
117
|
-
|
|
90
|
+
A registration creates a resource in place of the app creating one, and that is what decides when to
|
|
91
|
+
use it. It suits `HostedZone.fromLookup` or a certificate issued by hand outside the app. A resource
|
|
92
|
+
some stack in the same app creates wants deploying, since a registration would mean configuring it
|
|
93
|
+
by hand and taking its configuration from somewhere other than the deployed template. So register
|
|
94
|
+
what the app looks up, deploy what the app creates, and substitute in a `transform` only where a
|
|
95
|
+
deployed resource cannot be given the id its template names.
|
|
118
96
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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.
|
|
97
|
+
Route 53 needs least of this. An `AWS::Route53::RecordSet` naming a hosted zone id no zone holds
|
|
98
|
+
gets one registered under that id as the record is created, taking its name from the records naming
|
|
99
|
+
it. Register it yourself only where a test depends on that name.
|
|
124
100
|
|
|
125
|
-
|
|
126
|
-
|
|
101
|
+
### Deploy a whole cloud assembly with `deployCdkOut`
|
|
102
|
+
|
|
103
|
+
`deployCdkOut` deploys the Stacks a `cdk.out` holds, each into the region its own environment names
|
|
104
|
+
in the assembly manifest, retiring the per-stack region constants. A Stack synthesized with
|
|
105
|
+
`env: { region: "us-east-1" }` lands in simulated us-east-1 wherever the call was made from, and one
|
|
106
|
+
without `env` takes the region of the scope it was asked through.
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
const stacks = await simAws.cloudFormation().deployCdkOut({
|
|
110
|
+
directoryPath: "cdk.out",
|
|
111
|
+
stackNames: ["DnsStack", "SiteStack"], // Stack names or CDK artifact IDs.
|
|
112
|
+
stackOptions: {
|
|
113
|
+
SiteStack: {
|
|
114
|
+
bindings: [{ logicalId: "UploadFunction", handler: uploadHandler }],
|
|
115
|
+
transform: (template, deployed) =>
|
|
116
|
+
withSimulatedCertificate(template, deployed.get("DnsStack")?.output("SiteCertificateArn")),
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
```
|
|
127
121
|
|
|
128
|
-
|
|
129
|
-
|
|
122
|
+
`stackNames` picks part of an assembly, which most apps need, since most also synthesize a
|
|
123
|
+
deployment pipeline. `stackOptions` carries the `bindings`, `parameters` and `transform` that
|
|
124
|
+
`deployTemplateFile` takes for one template, keyed the same way. Its transform is handed the Stacks
|
|
125
|
+
the same call has already deployed. A Stack consuming a sibling's value therefore stays inside one
|
|
126
|
+
call. Two Stacks passing a plain string between them declare no dependency for the manifest to
|
|
127
|
+
carry, and the order they are named in is what puts the value there in time.
|
|
130
128
|
|
|
131
|
-
|
|
129
|
+
## Intercept real SDK clients, never hand-roll stubs
|
|
132
130
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
a `DynamoDBClient` is what the code sends through. The document client is the one that needs
|
|
136
|
-
intercepting.
|
|
131
|
+
`SimSdk` replaces the `send` method of an AWS SDK client class or instance. Real clients then answer
|
|
132
|
+
from the simulation, and the code under test uses the SDK exactly as it does in production.
|
|
137
133
|
|
|
138
134
|
```typescript
|
|
139
135
|
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.
|
|
136
|
+
simSdk.intercept(SecretsManagerClient); // Every instance, including ones made later.
|
|
143
137
|
```
|
|
144
138
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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.
|
|
139
|
+
A stub asserts that your code called something. The simulator asserts that it called the service
|
|
140
|
+
correctly. A stub has no naming rules and verifies no signatures. A malformed Secrets Manager name
|
|
141
|
+
or a wrongly computed Cognito `SECRET_HASH` passes it and fails in production.
|
|
154
142
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
143
|
+
Intercept the class in most cases, since the code under test usually constructs its own clients.
|
|
144
|
+
Intercept an instance when a single client should reach the simulation, and when a file's cases each
|
|
145
|
+
build their own `SimAws`. A class interception is process-wide (it shadows `send` on the class
|
|
146
|
+
prototype) and refuses a second install while the first is live, with
|
|
147
|
+
`SimSdkAlreadyInterceptedError`. An instance interception goes when the instance does.
|
|
148
|
+
|
|
149
|
+
Whichever it is, it has to be the client the code actually calls. A `DynamoDBDocumentClient` built
|
|
150
|
+
over a `DynamoDBClient` is what the code sends through, and the document client is the one to
|
|
151
|
+
intercept. Every Command routes to the simulation by default, and an allow list of Command classes
|
|
152
|
+
narrows that where something else should handle the rest.
|
|
153
|
+
|
|
154
|
+
`SimSdk` and its interception handles are disposable, so `using` restores every intercepted client
|
|
155
|
+
at the end of the scope, leaving nothing for a later test to inherit when the one before it threw.
|
|
156
|
+
`simSdk.restoreAll()` and `interception.restore()` do it by hand. Each `SimSdk` owns a `SimAws`,
|
|
157
|
+
reachable as `simSdk.simAws`, and `new SimSdk({ simAws })` shares an existing one.
|
|
158
|
+
|
|
159
|
+
### A fake accepts any request the simulator would refuse
|
|
160
|
+
|
|
161
|
+
A fake S3 client stubbing `send` with canned `ListObjectsV2` pages, asserted on through the
|
|
162
|
+
continuation tokens it recorded, passes for code that built its command without a `Bucket`.
|
|
163
|
+
Simulated S3 does the pagination for real. `Prefix`, `MaxKeys`, `ContinuationToken` and `StartAfter`
|
|
164
|
+
all apply, `IsTruncated` and `NextContinuationToken` come back as the service sends them, and
|
|
165
|
+
`configureMaxKeysPerPage` lowers the page size so that a bucket of two objects makes a caller walk a
|
|
166
|
+
continuation. Uploading real parts gives the object the real `<md5-of-the-part-md5s>-<count>` ETag.
|
|
167
|
+
|
|
168
|
+
The residue is small. A couple of answers the service never sends (a truncated page naming no
|
|
169
|
+
continuation token) can only come from a fake, and a test reaching for one should say so in a
|
|
170
|
+
comment.
|
|
158
171
|
|
|
159
172
|
## Freeze the clock and advance it deliberately
|
|
160
173
|
|
|
161
174
|
Each `SimAws` carries its own clock, independent of the host and of every other simulation in the
|
|
162
|
-
process.
|
|
175
|
+
process. Start it frozen and move it only on purpose:
|
|
163
176
|
|
|
164
177
|
```typescript
|
|
165
178
|
const simAws = new SimAws({
|
|
@@ -170,38 +183,35 @@ const simAws = new SimAws({
|
|
|
170
183
|
await simAws.clock().advanceBy({ minutes: 20 });
|
|
171
184
|
```
|
|
172
185
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
186
|
+
Time-dependent behaviour then becomes something a test asserts on in microseconds. A good deal of
|
|
187
|
+
the simulation keys off that clock. EventBridge rules and Scheduler schedules fire only when time is
|
|
188
|
+
advanced past them, DynamoDB items pass their TTL, Secrets Manager deletions come due, `AssumeRole`
|
|
189
|
+
sessions expire, Lambda event source mappings re-poll, and inside a simulated Lambda `Date.now()`
|
|
190
|
+
and `new Date()` report simulated time. So the clock stub `isolated-testing-style` allows is
|
|
191
|
+
unnecessary here, since advancing this one exercises the real expiry rules of the services around it
|
|
192
|
+
as well as the code's own arithmetic.
|
|
179
193
|
|
|
180
|
-
|
|
181
|
-
|
|
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.
|
|
194
|
+
`simAws.clock().resume()` tracks the underlying clock and `simAws.clock().isFrozen` reports the
|
|
195
|
+
mode. Running mode suits a local dev server, and a test usually wants an advance.
|
|
188
196
|
|
|
189
197
|
## Assert by reading the simulation back
|
|
190
198
|
|
|
191
|
-
The simulation holds real state.
|
|
192
|
-
service what happened rather than asking the SDK what it was told:
|
|
199
|
+
The simulation holds real state. After exercising the code, ask the service what happened:
|
|
193
200
|
|
|
194
201
|
```typescript
|
|
195
202
|
// Then the upload is in the bucket, under the key the handler chose.
|
|
196
203
|
const object = await simAws.s3().getObject(new GetObjectCommand({ Bucket: bucket, Key: key }));
|
|
197
204
|
```
|
|
198
205
|
|
|
199
|
-
|
|
200
|
-
|
|
206
|
+
A call-count assertion holds only for today's implementation. A state assertion holds however the
|
|
207
|
+
handler is rewritten, and it fails if the call was made in a way the real service would have
|
|
208
|
+
rejected.
|
|
201
209
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
the
|
|
210
|
+
The accessors sit on more than one scope, with `simAws.region(name)` carrying some of the services
|
|
211
|
+
and `simAws.region(name).account()` carrying all of them (`logs()` among the ones only the account
|
|
212
|
+
scope has), so look on the other scope before concluding a service is missing. Each also takes a
|
|
213
|
+
plain `{ input: { ... } }` in place of a Command object. An assertion can therefore read a service
|
|
214
|
+
back without adding an `@aws-sdk/client-*` package the production code has no use for.
|
|
205
215
|
|
|
206
216
|
## Match service errors by name
|
|
207
217
|
|
|
@@ -216,35 +226,18 @@ if (error instanceof Error && error.name === "ResourceNotFoundException") { ...
|
|
|
216
226
|
The SDK exports exception classes, which invites the `instanceof` check. It holds only while exactly
|
|
217
227
|
one copy of the SDK package is in play. Two copies in the module graph, a bundler, or a simulator
|
|
218
228
|
raising its own classes, and it silently stops matching. Yulin's errors carry the service's real
|
|
219
|
-
error names and SDK-shaped `$metadata
|
|
220
|
-
|
|
221
|
-
|
|
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.
|
|
229
|
+
error names and SDK-shaped `$metadata` without being instances of the SDK classes. Fix it in
|
|
230
|
+
production code, where a version skew between two `@aws-sdk/client-*` packages breaks `instanceof`
|
|
231
|
+
too. `name` is what the wire carries, and is right in both places.
|
|
225
232
|
|
|
226
233
|
## Expect refusals, and treat them as a feature
|
|
227
234
|
|
|
228
|
-
Yulin refuses a property it cannot simulate
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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.
|
|
235
|
+
Yulin refuses a property it cannot simulate and never ignores one. Silently accepting something that
|
|
236
|
+
changes real behaviour would turn a deploy-time failure into a production one. The cost is that one
|
|
237
|
+
unsupported setting can make a whole stack unsimulatable, one property at a time. **Enumerate every
|
|
238
|
+
refusal in one pass.** Strip properties in a throwaway `transform` until the template deploys,
|
|
239
|
+
keeping the list, then raise them together upstream. Taking them one release at a time is one round
|
|
240
|
+
trip per property, and you never learn how far away a working simulation is.
|
|
248
241
|
|
|
249
242
|
Not every gap is a refusal. Several services record a property they cannot model and carry on,
|
|
250
243
|
reporting it as an ignored property on the stack and on the resource. Check that report before
|
|
@@ -255,32 +248,26 @@ trusting a test that depends on the setting.
|
|
|
255
248
|
Fix gaps on [the Yulin repository](https://github.com/KensioSoftware/yulin) at source. A local
|
|
256
249
|
workaround has to be maintained in every project that hits the same gap.
|
|
257
250
|
|
|
258
|
-
|
|
251
|
+
The asymmetry matters more than the volume. A simulator staying silent about something costs little,
|
|
252
|
+
leaving that behaviour uncovered where it already was. A simulator saying 200 where production says
|
|
253
|
+
403 turns a deploy-time failure into a production one, the opposite of what it is for. So report a
|
|
254
|
+
false pass with what production does and what the simulation did, and a false refusal with the
|
|
255
|
+
property and the template that carries it. Raise a gap costing nothing but convenience as well, once
|
|
256
|
+
it is forcing structural duplication.
|
|
259
257
|
|
|
260
|
-
|
|
261
|
-
|
|
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.
|
|
258
|
+
A workaround kept while the issue is open wants a comment naming that issue and a revisit when it
|
|
259
|
+
closes. Re-read the claims in your own comments on each upgrade. They are the ones nothing tests.
|
|
272
260
|
|
|
273
261
|
## Deploy expensive context once per test file
|
|
274
262
|
|
|
275
263
|
Vitest gives each test file its own worker, so module-level state is already isolated between files.
|
|
276
264
|
Deploy a stack once for the file and let the tests share it. Isolation inside the file comes from
|
|
277
|
-
randomised names.
|
|
265
|
+
randomised names.
|
|
278
266
|
|
|
279
267
|
```typescript
|
|
280
268
|
let simAws: SimAws;
|
|
281
269
|
|
|
282
270
|
beforeAll(async () => {
|
|
283
|
-
// Given the real synthesized stack, deployed once for this file.
|
|
284
271
|
simAws = new SimAws();
|
|
285
272
|
const stack = await simAws.cloudFormation().deployTemplateFile({
|
|
286
273
|
templatePath: "cdk.out/SiteStack.template.json",
|
|
@@ -291,25 +278,19 @@ beforeAll(async () => {
|
|
|
291
278
|
it("stores an upload", async () => {
|
|
292
279
|
// Given a key no other test in this file is using.
|
|
293
280
|
const key = `uploads/${faker.string.uuid()}.png`;
|
|
294
|
-
// ...
|
|
295
281
|
});
|
|
296
282
|
```
|
|
297
283
|
|
|
298
|
-
|
|
299
|
-
isolation you did not already have.
|
|
300
|
-
|
|
301
|
-
|
|
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.
|
|
284
|
+
A template deployment is the only thing usually worth hoisting, and in `beforeEach` it pays for the
|
|
285
|
+
whole stack once per test for no isolation you did not already have. The `SimSdk`, a seeded row and
|
|
286
|
+
a bucket key belong inside the test that needs them. A `beforeEach` assembling state for tests that
|
|
287
|
+
do not all want the same state is the beginning of the harness this skill opens by arguing against.
|
|
305
288
|
|
|
306
289
|
## Run the handler as a real simulated Lambda
|
|
307
290
|
|
|
308
|
-
Yulin can run an in-process handler as a function inside the simulation
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
Bind a handler to a template function at deploy time with `bindings`:
|
|
291
|
+
Yulin can run an in-process handler as a function inside the simulation. Bind it to a template
|
|
292
|
+
function at deploy time with `bindings`, targeting the function by `logicalId`, `functionName`,
|
|
293
|
+
`arn`, `cdkPath` or `imageRepository`:
|
|
313
294
|
|
|
314
295
|
```typescript
|
|
315
296
|
await simAws.cloudFormation().deployTemplateFile({
|
|
@@ -318,7 +299,73 @@ await simAws.cloudFormation().deployTemplateFile({
|
|
|
318
299
|
});
|
|
319
300
|
```
|
|
320
301
|
|
|
321
|
-
The handler still runs in process
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
302
|
+
The handler still runs in process, closing over test state and stopping on a breakpoint. The
|
|
303
|
+
difference from calling it directly is that a missing `s3:PutObject` on the execution role now fails
|
|
304
|
+
the test, at the point AWS would have failed it.
|
|
305
|
+
|
|
306
|
+
### Invoke through simulated Lambda
|
|
307
|
+
|
|
308
|
+
Binding a handler proves nothing about IAM on its own. The execution role, the function's
|
|
309
|
+
environment and its outbound HTTP are all applied by the invocation, and the test has to go through
|
|
310
|
+
`simAws.lambda().invoke(new InvokeCommand({ FunctionName, Payload }))` to get any of them. A test
|
|
311
|
+
holding the same handler reference and calling it directly runs it in the test's own scope, as the
|
|
312
|
+
test's own caller, with none of the three.
|
|
313
|
+
|
|
314
|
+
To check a suite covers the policy, remove an action such as `dynamodb:GetItem` from the role in the
|
|
315
|
+
CDK stack and re-synthesize. Invoked cases fail with an `AccessDenied` naming the execution role and
|
|
316
|
+
the action. Cases calling the handler directly stay green.
|
|
317
|
+
|
|
318
|
+
### Wire the object graph once, in production
|
|
319
|
+
|
|
320
|
+
The costliest shape a Yulin suite grows is a second wiring of the application's own object graph,
|
|
321
|
+
built so that a recorder can be injected into it and asserted on. Nothing needs it. `bindings` plus
|
|
322
|
+
`invoke` run the real handler under its execution role, state reads back through the production
|
|
323
|
+
reader against the deployed table, and accounts and other fixtures come from the simulation's own
|
|
324
|
+
accessors. A recording logger was the last reason standing, and from 1.17.1 a bound handler's output
|
|
325
|
+
is recorded into its log group, read back at `/aws/lambda/<function name>` through
|
|
326
|
+
`FilterLogEvents`.
|
|
327
|
+
|
|
328
|
+
The console and the process standard streams are both bridged for the length of an invocation, in
|
|
329
|
+
the way `process.env` and `Date` are. A logging library building its own `Console` over those
|
|
330
|
+
streams at module scope is recorded too, Powertools' `Logger` included, both its JSON log line and
|
|
331
|
+
its EMF metric document.
|
|
332
|
+
|
|
333
|
+
So when a test builds the application's own graph, ask what it cannot get through an invocation.
|
|
334
|
+
Expect the answer to be nothing.
|
|
335
|
+
|
|
336
|
+
### Read the environment inside the handler
|
|
337
|
+
|
|
338
|
+
A bound handler gets the function's declared environment variables with nothing stubbed.
|
|
339
|
+
`SimProcessEnvironment` holds a run's variables in an `AsyncLocalStorage` store and resolves
|
|
340
|
+
`process.env` to it for the length of the run, with concurrent runs each seeing their own. The one
|
|
341
|
+
thing it cannot reach is a read that already happened. A handler module doing
|
|
342
|
+
`const TABLE = process.env.TABLE_NAME` at module scope is evaluated when the test file imports it,
|
|
343
|
+
long before any run, and captures the host value.
|
|
344
|
+
|
|
345
|
+
So read the environment inside the handler body, memoising there where a warm container should build
|
|
346
|
+
its clients once. The substituted `Date` works the same way. A `vi.stubEnv` around a bound handler
|
|
347
|
+
is the sign of a handler reading too early. `SimLambdaEnvironmentConflicts` warns about this, but
|
|
348
|
+
only where the host value and the declared value differ, and a suite that stubs the right values
|
|
349
|
+
stays quiet and never learns.
|
|
350
|
+
|
|
351
|
+
### What a binding buys, and what the zip path buys
|
|
352
|
+
|
|
353
|
+
Deploying without `bindings` runs the bundle `cdk synth` produced. `deployTemplateFile` publishes
|
|
354
|
+
the cloud assembly's assets into the staging bucket in simulated S3, and the modules are evaluated
|
|
355
|
+
as CommonJS in a vm sandbox with its own `process.env`, `Date` and HTTP clients, where the
|
|
356
|
+
module-scope problem above never arises. Both paths authorise through the execution role, and the
|
|
357
|
+
same policy mutation fails a zip-path test exactly as it fails a bound one.
|
|
358
|
+
|
|
359
|
+
- **A binding** keeps a breakpoint working and lets the handler close over test state.
|
|
360
|
+
- **The zip path** exercises the artefact that deploys, its imports and its bundling included.
|
|
361
|
+
|
|
362
|
+
### Outbound HTTP is answered by the simulation
|
|
363
|
+
|
|
364
|
+
From 1.16.2, a simulated Lambda's `fetch` and its `node:http` and `node:https` are answered by the
|
|
365
|
+
simulation for every hostname simulated Route 53 resolves, through the same in-process entry point a
|
|
366
|
+
request arriving on localhost uses. A Cognito user pool domain, an HTTP API and a load balancer are
|
|
367
|
+
all answered without the test knowing which of them it asked, and everything else reaches the
|
|
368
|
+
network as it was addressed. This is what makes an OAuth authorization code exchange testable, since
|
|
369
|
+
that exchange lives only at the pool domain's hosted `/oauth2/token` endpoint with no SDK operation
|
|
370
|
+
behind it. The same routing lets `CognitoJwtVerifier` fetch a simulated pool's JWKS from inside a
|
|
371
|
+
handler with no cache primed.
|