@kensio/yulin-aws-simulation 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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "yulin-aws-simulation",
4
- "version": "1.13.1",
4
+ "version": "1.15.0",
5
5
  "description": "How to test AWS code well with the @kensio/yulin in-process simulator.",
6
6
  "author": {
7
7
  "name": "Kensio Software",
package/README.md CHANGED
@@ -43,7 +43,21 @@ Unzip it into `.agents/skills/` and it is installed.
43
43
  the test. Do not hand-roll a wrapper that reads the file and calls `deployTemplate`: the file path
44
44
  is how Yulin finds the cloud assembly beside it. A wrapper loses staged CDK assets. `transform`
45
45
  handles what a simulation cannot resolve, such as an ARN carrying a real account or a hosted zone ID
46
- from a CDK lookup, and `watch` re-applies the file on change for dev servers.
46
+ from a CDK lookup, and `watch` re-applies the file on change for dev servers. `deployCdkOut` deploys
47
+ a whole cloud assembly, each Stack into the region its own environment names.
48
+
49
+ **Register what the app looks up, deploy what the app creates.** `registerHostedZone`,
50
+ `registerCertificate` and `registerUserPool` stand a resource up at an id a CDK app pins as a
51
+ literal string across stacks. That suits a resource the app only looks up, such as a zone behind
52
+ `HostedZone.fromLookup`. One that a stack in the same app creates wants deploying, because a
53
+ registration means configuring it by hand and taking its configuration from somewhere other than the
54
+ deployed template.
55
+
56
+ **Wire the object graph once, in production.** A test that builds the application's own object graph
57
+ a second time is the shape to watch for. Ask what it cannot get through an invocation, and expect
58
+ the answer to be nothing. Bindings and `invoke` give the execution role, the environment and the
59
+ outbound HTTP, the production reader gives the state, and a bound handler's output is recorded into
60
+ its log group.
47
61
 
48
62
  **Intercept real SDK clients with `SimSdk`, never hand-roll stubs.**
49
63
  `simSdk.intercept(SecretsManagerClient)` makes real clients answer from the simulation, and the code
@@ -76,8 +90,10 @@ deployed in `beforeAll` is already isolated between files. Isolation inside the
76
90
  randomised names.
77
91
 
78
92
  **Run the handler as a real simulated Lambda.** Bind an in-process handler to a template function
79
- and its SDK calls are routed into the simulation as the execution role. A missing permission on that
80
- role fails the test at the point AWS would have failed it.
93
+ and invoke the function through simulated Lambda. Its SDK calls are then routed into the simulation
94
+ as the execution role, and a missing permission on that role fails the test at the point AWS would
95
+ have failed it. Calling the bound handler directly skips all of that, and so does reading
96
+ `process.env` at module scope.
81
97
 
82
98
  ## Related skills
83
99
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kensio/yulin-aws-simulation",
3
- "version": "1.13.1",
3
+ "version": "1.15.0",
4
4
  "description": "How to test AWS code well with the @kensio/yulin in-process simulator.",
5
5
  "keywords": [
6
6
  "agent-skills",
@@ -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.13.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. 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.
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 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.
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
- 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.
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
- 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.
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, 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:
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 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.
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
- 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:
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. 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.
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: withoutLookedUpHostedZone,
68
+ transform: withRealAccountArnsResolved,
97
69
  watch: { onUpdated: () => srv.reload() },
98
70
  });
99
71
  ```
100
72
 
101
- ## Intercept real SDK clients, never hand-roll stubs
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
- `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.
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
- ```typescript
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
- using simSdk = new SimSdk();
112
- simSdk.intercept(SecretsManagerClient); // Every instance, including ones made later.
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 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:
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
- - 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.
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
- 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.
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
- 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.
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
- ### Intercept what the code actually sends through
129
+ ## Intercept real SDK clients, never hand-roll stubs
132
130
 
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.
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
- 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.
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
- 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.
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. The recommendation is to start it frozen at a fixed instant and move it only on purpose:
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
- 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.
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
- 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.
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. The assertion can read it. After exercising the code, ask the
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
- Service accessors take the same Command objects the SDK does. The seeding and assertion code reads
200
- like the production code between them.
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
- 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.
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`, 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.
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, 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.
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
- When reporting, the asymmetry matters more than the volume:
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
- - 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.
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. Rebuilding the environment buys nothing.
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
- 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.
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, 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`:
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. 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.
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.