@kensio/yulin-aws-simulation 1.14.0 → 1.16.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.14.0",
4
+ "version": "1.16.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
@@ -53,6 +67,12 @@ ambiguous with the suffix Secrets Manager appends to an ARN and advised against
53
67
  already reached production. It caught a wrongly computed Cognito `SECRET_HASH` the stub had happily
54
68
  accepted.
55
69
 
70
+ **Drive requests into the simulation with `SimAwsHttp`.** Reading state back covers the resources.
71
+ `http.fetch("https://www.example.test/docs/x")` covers the path through them, with nothing listening
72
+ and no port to add. One request resolves the hostname through simulated Route 53, finds the
73
+ Distribution its alias records point at, and runs the deployed CloudFront Function at
74
+ viewer-request. A template assertion over the same stack passes with every Route 53 record missing.
75
+
56
76
  **Match service errors by `name`.** The SDK exports its exception classes, which invites the wrong
57
77
  check. `instanceof` holds only while exactly one copy of the SDK is in play. It passes in production
58
78
  and fails against the simulator. `name` is what the wire carries, and is the check that is right in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kensio/yulin-aws-simulation",
3
- "version": "1.14.0",
3
+ "version": "1.16.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,59 +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, driving HTTP requests into the simulation with SimAwsHttp, 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 a CloudFront Distribution, its DNS records or its certificate need 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.14.0"
6
+ version: "1.16.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.
16
- [yulinsim.dev/llms.txt](https://yulinsim.dev/llms.txt) indexes every page as plain markdown, one
17
- link per guide and one per simulated service. Drop the `llms.txt` from a link for the HTML page. The
18
- same pages ship in the repository, as [the README](https://github.com/KensioSoftware/yulin#readme),
19
- then `docs/sdk/` for interception and `docs/services/<name>/` for each simulated service.
20
-
21
- This skill serves the `isolated-testing-style` skill, the general argument for simulation over
22
- stubs.
23
-
24
- Yulin is deliberately flexible, and plenty of shapes work. What follows is the recommended way to
25
- get the most out of it, offered as guidance. Each one says what it buys. A situation that does not
26
- 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.
27
17
 
28
18
  ## Use what Yulin already gives you
29
19
 
30
- The most common way to go wrong with Yulin is to build something on top of it. The failure looks
31
- like a `TestAwsEnvironment` class, a `setupSimulatedAws()` helper returning six things, a factory
32
- per service, or a `beforeEach` that reassembles the world (a private framework wrapped around a tool
33
- that is already the framework).
34
-
35
- It is worth resisting, because Yulin is built to be used directly:
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.
36
23
 
37
- - `new SimAws()` and `new SimSdk()` are plain constructors. No side effects, no network, no cleanup
38
- and no awaiting. Creating a simulation per test is close to free.
39
- - Service accessors take the same Command objects the AWS SDK does, so seeding and asserting need no
40
- translation layer of their own.
41
- - `using simSdk = new SimSdk()` is the teardown.
42
- - `deployTemplateFile` is the environment.
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.
43
28
 
44
- So the recommended shape of a test is to construct, deploy if a template is involved, intercept,
45
- exercise, then assert by reading the simulation back. A wrapper around any of those steps hides the
46
- one thing a reader of the test needs to see, and it has to be maintained forever after.
47
-
48
- If a sequence genuinely repeats, make it a small function in the same test file, and keep it
49
- returning the simulation objects themselves. A bespoke return shape starts hiding them. The point at
50
- which it wants a class, an options interface, or a directory, it has stopped being test setup and
51
- 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.
52
34
 
53
35
  ## One synthesized template, for tests, local dev and production
54
36
 
55
- Describe the infrastructure once, in CDK, and let the same synthesized output drive all three.
56
- 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:
57
38
 
58
39
  ```typescript
59
40
  const stack = await simAws.region("eu-west-2").cloudFormation().deployTemplateFile({
@@ -64,32 +45,22 @@ const stack = await simAws.region("eu-west-2").cloudFormation().deployTemplateFi
64
45
  await stack.waitForDeployComplete();
65
46
  ```
66
47
 
67
- A test written against a template you wrote by hand only tests the template you wrote by hand.
68
- Deploying the synthesized output means a construct change that breaks the system breaks the test.
69
-
70
- The same argument extends past the test suite. A dev environment that creates its buckets and tables
71
- by hand is a third description of the infrastructure, drifting away from the other two at its own
72
- pace, and the drift shows up as a bug that reproduces in exactly one of the three places. Pointing
73
- the dev server at `cdk.out` as well removes the whole category. What runs locally is what CI tested
74
- and what production deploys, and `watch` turns a `cdk synth` into a stack update in place.
75
-
76
- The corollary is that infrastructure belongs in the CDK app even when only a test needs it. A bucket
77
- 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.
78
52
 
79
53
  **Do not hand-roll a wrapper that reads the file and calls `deployTemplate`.** `deployTemplateFile`
80
- already reads it, and it locates the cloud assembly beside the file. The assets manifest and staged
81
- asset directories resolve. A wrapper that reads the JSON itself loses that, and anything needing a
82
- CDK asset, such as a `Custom::CDKBucketDeployment` or a `Code.fromAsset` function, fails with
83
- `No CDK assets manifest is available.`
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
- The two options that make a wrapper unnecessary:
86
-
87
- - **`transform`** is given the parsed template and answers with the one to deploy. It runs on the
88
- deployment and again on every re-read. A wrapper cannot do that. Use it for what a simulation
89
- genuinely cannot resolve, such as an ARN carrying a real account.
90
- - **`watch`** re-applies the file when it changes, updating the stack in place. This is for dev
91
- servers. A `cdk synth` becomes a stack update without restarting the process, and resources the
92
- 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.
93
64
 
94
65
  ```typescript
95
66
  await simAws.cloudFormation().deployTemplateFile({
@@ -99,80 +70,109 @@ await simAws.cloudFormation().deployTemplateFile({
99
70
  });
100
71
  ```
101
72
 
102
- A hosted zone ID that came from `HostedZone.fromLookup` needs no transform of its own. An
103
- `AWS::Route53::RecordSet` naming a hosted zone ID that no zone holds gets one registered under that
104
- ID as the record is created, and its records resolve. The zone takes its name from the records that
105
- name it. Register it yourself with `simAws.route53().registerHostedZone({ id, name })` where a test
106
- depends on the name, such as one listing zones by name.
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.
107
76
 
108
- A failed `cdk synth` leaves the previous template in `cdk.out` where it was, and the tests carry on
109
- running against it. An edit that leaves an unused variable behind fails `tsc` and exits non-zero,
110
- with the error hidden behind a redirect. The test that runs next passes against the template from
111
- before the change. Check that the synthesized JSON changed before concluding anything from a
112
- construct change.
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.
113
81
 
114
- ## Intercept real SDK clients, never hand-roll stubs
82
+ ### Register what the app looks up, deploy what the app creates
115
83
 
116
- `SimSdk` replaces the `send` method of an AWS SDK client class or instance, so real clients answer
117
- from the simulation. The code under test uses the SDK exactly as it does in production and never
118
- learns there is a simulator behind it.
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`.
119
89
 
120
- ```typescript
121
- import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
122
- import { SimSdk } from "@kensio/yulin/sdk";
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.
123
96
 
124
- using simSdk = new SimSdk();
125
- simSdk.intercept(SecretsManagerClient); // Every instance, including ones made later.
126
- ```
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.
127
100
 
128
- A stub asserts that your code called something. The simulator asserts that it called the service
129
- correctly. On a real project, swapping stubs for interception caught two bugs the same afternoon,
130
- both already in production:
101
+ ### Deploy a whole cloud assembly with `deployCdkOut`
131
102
 
132
- - A Secrets Manager secret whose name ended in a hyphen and six characters, exactly the suffix
133
- Secrets Manager appends to an ARN. AWS advises against names of that shape because they are
134
- ambiguous with the ARN form. A stub has no naming rules. It had accepted it happily.
135
- - A Cognito `SECRET_HASH` computed the wrong way. The stub had accepted that too, because a stub is
136
- never going to verify a signature.
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.
137
107
 
138
- Intercept the class in most cases, since the code under test usually constructs its own clients.
139
- Intercept an instance when only one client should reach the simulation.
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
+ ```
140
121
 
141
- Each `SimSdk` owns a `SimAws`, reachable as `simSdk.simAws` for seeding and inspecting state. Pass
142
- 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.
143
128
 
144
- ### Intercept what the code actually sends through
129
+ ## Intercept real SDK clients, never hand-roll stubs
145
130
 
146
- Interception replaces `send` on the thing it is given. It has to be given the client the code under
147
- test actually calls. The wrapper clients are where this bites. A `DynamoDBDocumentClient` built over
148
- a `DynamoDBClient` is what the code sends through. The document client is the one that needs
149
- 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.
150
133
 
151
134
  ```typescript
152
135
  using simSdk = new SimSdk();
153
-
154
- const documents = DynamoDBDocumentClient.from(new DynamoDBClient({ region: "eu-west-2" }));
155
- simSdk.intercept(documents); // Not the DynamoDBClient it was built from.
136
+ simSdk.intercept(SecretsManagerClient); // Every instance, including ones made later.
156
137
  ```
157
138
 
158
- Every Command through an intercepted client is routed to the simulation by default. An allow list of
159
- Command classes narrows that, and is worth reaching for only when something else should genuinely
160
- handle the rest.
161
-
162
- ### Prefer `using` over a teardown step
163
-
164
- `SimSdk` and the interception handles it returns are disposable, so `using simSdk = new SimSdk();`
165
- restores every intercepted client at the end of the scope. `simSdk.restoreAll()` and
166
- `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.
167
142
 
168
- The recommendation is `using`, because it is teardown that cannot be forgotten or skipped by an
169
- early return, and because it leaves nothing for a later test to inherit if the one before it threw.
170
- 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.
171
171
 
172
172
  ## Freeze the clock and advance it deliberately
173
173
 
174
174
  Each `SimAws` carries its own clock, independent of the host and of every other simulation in the
175
- 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:
176
176
 
177
177
  ```typescript
178
178
  const simAws = new SimAws({
@@ -183,38 +183,62 @@ const simAws = new SimAws({
183
183
  await simAws.clock().advanceBy({ minutes: 20 });
184
184
  ```
185
185
 
186
- What this buys is that time-dependent behaviour becomes something a test can assert on in
187
- microseconds rather than something it waits for or gives up on. A good deal of the simulation keys
188
- off that clock: EventBridge rules and Scheduler schedules fire only when time is advanced past them,
189
- DynamoDB items pass their TTL, Secrets Manager deletions come due, `AssumeRole` sessions expire, and
190
- Lambda event source mappings re-poll. Inside a simulated Lambda, `Date.now()` and `new Date()`
191
- 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.
192
193
 
193
- That last point is worth drawing out. `isolated-testing-style` allows a stub for a clock, on the
194
- grounds that a clock has no rules worth modelling. Against Yulin that exception is unnecessary. The
195
- clock is part of the simulation, and advancing it exercises the real expiry rules of the services
196
- around it rather than only the code's own arithmetic.
197
-
198
- `simAws.clock().resume()` switches to tracking the underlying clock, and `simAws.clock().isFrozen`
199
- reports which mode it is in. Running mode suits a local dev server. A test that wants it usually
200
- 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.
201
196
 
202
197
  ## Assert by reading the simulation back
203
198
 
204
- The simulation holds real state. The assertion can read it. After exercising the code, ask the
205
- 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:
206
200
 
207
201
  ```typescript
208
202
  // Then the upload is in the bucket, under the key the handler chose.
209
203
  const object = await simAws.s3().getObject(new GetObjectCommand({ Bucket: bucket, Key: key }));
210
204
  ```
211
205
 
212
- Service accessors take the same Command objects the SDK does. The seeding and assertion code reads
213
- 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.
209
+
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.
215
+
216
+ ## Drive requests into the simulation
214
217
 
215
- This is where simulation pays off over stubs a second time. A call-count assertion holds only for
216
- today's implementation. A state assertion holds however the handler is rewritten, and it fails if
217
- the call was made in a way the real service would have rejected.
218
+ Reading state back covers the resources. `SimAwsHttp` from `@kensio/yulin/serve` covers the path
219
+ through them, by sending a request into the environment with nothing listening. It takes what the
220
+ global `fetch` takes and answers with a `Response`:
221
+
222
+ ```typescript
223
+ const http = new SimAwsHttp({ simAws });
224
+ const response = await http.fetch("https://www.example.test/docs/x?a=1", { redirect: "manual" });
225
+
226
+ // Then the apex redirect the CloudFront Function performs has been applied.
227
+ expect(response.status).toBe(301);
228
+ expect(response.headers.get("location")).toBe("https://example.test/docs/x?a=1");
229
+ ```
230
+
231
+ A hostname simulated Route 53 answers for is requested by its own name, with no port to add and no
232
+ `localUrl(...)` adapting (an `https` URL works with no certificate set up for it). That one request
233
+ resolves the hostname, finds the Distribution its alias records point at, and runs the deployed
234
+ CloudFront Function at viewer-request. The certificate, the Hosted Zone records, the Distribution's
235
+ aliases and the function are covered together. A template assertion over the same stack passes with
236
+ every Route 53 record missing.
237
+
238
+ Reach for `serveSimAws` when the request comes from outside the process, such as a browser, `curl`
239
+ or an SDK client pointed at a local endpoint. Both go through the same routing and service code, and
240
+ `SimAwsHttp` leaves parallel test files no port to collide over. See
241
+ [the serving docs](https://yulinsim.dev/serve/) for the API.
218
242
 
219
243
  ## Match service errors by name
220
244
 
@@ -229,35 +253,18 @@ if (error instanceof Error && error.name === "ResourceNotFoundException") { ...
229
253
  The SDK exports exception classes, which invites the `instanceof` check. It holds only while exactly
230
254
  one copy of the SDK package is in play. Two copies in the module graph, a bundler, or a simulator
231
255
  raising its own classes, and it silently stops matching. Yulin's errors carry the service's real
232
- error names and SDK-shaped `$metadata`, but they are not instances of the SDK classes.
233
-
234
- This is worth fixing in production code, and working around it in tests hides it. `name` is what the
235
- wire carries. The `name` check is the one that is right in both places. A version skew between two
236
- `@aws-sdk/client-*` packages breaks `instanceof` in production too, just less predictably than the
237
- simulator does.
256
+ error names and SDK-shaped `$metadata` without being instances of the SDK classes. Fix it in
257
+ production code, where a version skew between two `@aws-sdk/client-*` packages breaks `instanceof`
258
+ too. `name` is what the wire carries, and is right in both places.
238
259
 
239
260
  ## Expect refusals, and treat them as a feature
240
261
 
241
- Yulin refuses a property it cannot simulate, and never ignores one. That is the right trade.
242
- silently accepting something that changes real behaviour turns a deploy-time failure into a
243
- production one.
244
-
245
- The cost is that one unsupported setting can make a whole stack unsimulatable, and the refusal
246
- arrives one property at a time. When that happens, **enumerate every refusal in one pass**. Strip
247
- properties from the synthesized template until it deploys, keeping a list, then raise them together
248
- upstream.
249
-
250
- ```typescript
251
- // A throwaway transform used to find the floor. Delete it afterwards.
252
- function stripUntilItDeploys(template: CfnTemplateBodyRecord): CfnTemplateBodyRecord {
253
- // Remove one refused property, re-run, record the next refusal, repeat.
254
- // Keep the list. Raise it as one issue.
255
- }
256
- ```
257
-
258
- Doing this one release at a time means one round trip per property, and you never learn how far away
259
- a working simulation actually is. This is the same rule as not discovering service ceilings one
260
- failed deployment at a time.
262
+ Yulin refuses a property it cannot simulate and never ignores one. Silently accepting something that
263
+ changes real behaviour would turn a deploy-time failure into a production one. The cost is that one
264
+ unsupported setting can make a whole stack unsimulatable, one property at a time. **Enumerate every
265
+ refusal in one pass.** Strip properties in a throwaway `transform` until the template deploys,
266
+ keeping the list, then raise them together upstream. Taking them one release at a time is one round
267
+ trip per property, and you never learn how far away a working simulation is.
261
268
 
262
269
  Not every gap is a refusal. Several services record a property they cannot model and carry on,
263
270
  reporting it as an ignored property on the stack and on the resource. Check that report before
@@ -268,37 +275,26 @@ trusting a test that depends on the setting.
268
275
  Fix gaps on [the Yulin repository](https://github.com/KensioSoftware/yulin) at source. A local
269
276
  workaround has to be maintained in every project that hits the same gap.
270
277
 
271
- When reporting, the asymmetry matters more than the volume:
272
-
273
- - A simulator that **stays silent** about something costs you little. The test leaves that behaviour
274
- uncovered, where it was already.
275
- - A simulator that **says 200 where production says 403** converts a deploy-time failure into a
276
- production one. That is the opposite of what it is for.
277
-
278
- So a false pass deserves far more attention than a false refusal. A real example: Yulin authorised a
279
- Lambda function URL invocation against `lambda:InvokeFunctionUrl` alone. CloudFront origin access
280
- control also needs `lambda:InvokeFunction`. The tests passed, the release went out, and the endpoint
281
- 403'd in production. A refusal would have cost an afternoon. The false pass cost an incident.
278
+ The asymmetry matters more than the volume. A simulator staying silent about something costs little,
279
+ leaving that behaviour uncovered where it already was. A simulator saying 200 where production says
280
+ 403 turns a deploy-time failure into a production one, the opposite of what it is for. So report a
281
+ false pass with what production does and what the simulation did, and a false refusal with the
282
+ property and the template that carries it. Raise a gap costing nothing but convenience as well, once
283
+ it is forcing structural duplication.
282
284
 
283
- Report a false pass with what production does and what the simulation did. Report a false refusal
284
- with the property and the template that carries it.
285
-
286
- A workaround kept while the issue is open wants a comment naming that issue, and a revisit when it
287
- closes. One test file carried a comment saying its handler could not reach simulated AWS from inside
288
- a simulated Lambda, citing KensioSoftware/yulin#638. That held when it was written and was fixed in
289
- 1.16.0. The comment went on shaping the design of the test for months after that.
285
+ A workaround kept while the issue is open wants a comment naming that issue and a revisit when it
286
+ closes. Re-read the claims in your own comments on each upgrade. They are the ones nothing tests.
290
287
 
291
288
  ## Deploy expensive context once per test file
292
289
 
293
290
  Vitest gives each test file its own worker, so module-level state is already isolated between files.
294
291
  Deploy a stack once for the file and let the tests share it. Isolation inside the file comes from
295
- randomised names. Rebuilding the environment buys nothing.
292
+ randomised names.
296
293
 
297
294
  ```typescript
298
295
  let simAws: SimAws;
299
296
 
300
297
  beforeAll(async () => {
301
- // Given the real synthesized stack, deployed once for this file.
302
298
  simAws = new SimAws();
303
299
  const stack = await simAws.cloudFormation().deployTemplateFile({
304
300
  templatePath: "cdk.out/SiteStack.template.json",
@@ -309,25 +305,19 @@ beforeAll(async () => {
309
305
  it("stores an upload", async () => {
310
306
  // Given a key no other test in this file is using.
311
307
  const key = `uploads/${faker.string.uuid()}.png`;
312
- // ...
313
308
  });
314
309
  ```
315
310
 
316
- Putting a template deployment in `beforeEach` pays for the whole stack once per test for no
317
- isolation you did not already have.
318
-
319
- A template deployment is the only thing usually worth hoisting. Everything else (the `SimSdk`, a
320
- seeded row, a bucket key) is cheap enough to build inside the test that needs it, and it is also
321
- where it is easiest to read. A `beforeEach` that assembles state for tests that do not all want the
322
- same state is the beginning of the harness this skill opens by arguing against.
311
+ A template deployment is the only thing usually worth hoisting, and in `beforeEach` it pays for the
312
+ whole stack once per test for no isolation you did not already have. The `SimSdk`, a seeded row and
313
+ a bucket key belong inside the test that needs them. A `beforeEach` assembling state for tests that
314
+ do not all want the same state is the beginning of the harness this skill opens by arguing against.
323
315
 
324
316
  ## Run the handler as a real simulated Lambda
325
317
 
326
- Yulin can run an in-process handler as a function inside the simulation, in place of a direct call
327
- from the test. Its SDK calls are routed into the simulation as the execution role. The IAM policies
328
- in the template are exercised too.
329
-
330
- Bind a handler to a template function at deploy time with `bindings`:
318
+ Yulin can run an in-process handler as a function inside the simulation. Bind it to a template
319
+ function at deploy time with `bindings`, targeting the function by `logicalId`, `functionName`,
320
+ `arn`, `cdkPath` or `imageRepository`:
331
321
 
332
322
  ```typescript
333
323
  await simAws.cloudFormation().deployTemplateFile({
@@ -336,65 +326,62 @@ await simAws.cloudFormation().deployTemplateFile({
336
326
  });
337
327
  ```
338
328
 
339
- The handler still runs in process. It can close over test state and be stepped through in a
340
- debugger. The difference from calling it directly is that a missing `s3:PutObject` on the execution
341
- role now fails the test, at the point AWS would have failed it. A binding can target a function by
342
- `logicalId`, `functionName`, `arn`, `cdkPath`, or `imageRepository` for a container image function.
329
+ The handler still runs in process, closing over test state and stopping on a breakpoint. The
330
+ difference from calling it directly is that a missing `s3:PutObject` on the execution role now fails
331
+ the test, at the point AWS would have failed it.
343
332
 
344
333
  ### Invoke through simulated Lambda
345
334
 
346
335
  Binding a handler proves nothing about IAM on its own. The execution role, the function's
347
- environment and its outbound HTTP are all applied by the invocation. The test has to go through
336
+ environment and its outbound HTTP are all applied by the invocation, and the test has to go through
348
337
  `simAws.lambda().invoke(new InvokeCommand({ FunctionName, Payload }))` to get any of them. A test
349
338
  holding the same handler reference and calling it directly runs it in the test's own scope, as the
350
339
  test's own caller, with none of the three.
351
340
 
352
- The difference shows up under mutation. Removing `dynamodb:GetItem` from the role's policy in the
353
- CDK stack and re-synthesizing failed every invoked case:
341
+ To check a suite covers the policy, remove an action such as `dynamodb:GetItem` from the role in the
342
+ CDK stack and re-synthesize. Invoked cases fail with an `AccessDenied` naming the execution role and
343
+ the action. Cases calling the handler directly stay green.
354
344
 
355
- ```
356
- AccessDenied: User: arn:aws:iam::111111111111:role/UserFunctionServiceRole1B2C3D4E
357
- is not authorized to perform: dynamodb:GetItem
358
- ```
345
+ ### Wire the object graph once, in production
346
+
347
+ The costliest shape a Yulin suite grows is a second wiring of the application's own object graph,
348
+ built so that a recorder can be injected into it and asserted on. Nothing needs it. `bindings` plus
349
+ `invoke` run the real handler under its execution role, state reads back through the production
350
+ reader against the deployed table, and accounts and other fixtures come from the simulation's own
351
+ accessors. A recording logger was the last reason standing, and from 1.17.1 a bound handler's output
352
+ is recorded into its log group, read back at `/aws/lambda/<function name>` through
353
+ `FilterLogEvents`.
354
+
355
+ The console and the process standard streams are both bridged for the length of an invocation, in
356
+ the way `process.env` and `Date` are. A logging library building its own `Console` over those
357
+ streams at module scope is recorded too, Powertools' `Logger` included, both its JSON log line and
358
+ its EMF metric document.
359
359
 
360
- The cases calling the handler directly passed. A suite that stays green through that mutation was
361
- never covering the policy.
360
+ So when a test builds the application's own graph, ask what it cannot get through an invocation.
361
+ Expect the answer to be nothing.
362
362
 
363
363
  ### Read the environment inside the handler
364
364
 
365
365
  A bound handler gets the function's declared environment variables with nothing stubbed.
366
366
  `SimProcessEnvironment` holds a run's variables in an `AsyncLocalStorage` store and resolves
367
- `process.env` to that store for the length of the run. Concurrent runs each see their own. Its own
368
- doc comment states the one limit:
367
+ `process.env` to it for the length of the run, with concurrent runs each seeing their own. The one
368
+ thing it cannot reach is a read that already happened. A handler module doing
369
+ `const TABLE = process.env.TABLE_NAME` at module scope is evaluated when the test file imports it,
370
+ long before any run, and captures the host value.
369
371
 
370
- <!-- prose-check:off -->
371
-
372
- > The one thing this cannot reach is a read that already happened. A handler module doing
373
- > `const TABLE = process.env.TABLE_NAME` at module scope is evaluated when the test file imports it,
374
- > long before any run, so it captures the host value.
375
-
376
- <!-- prose-check:on -->
377
-
378
- So read the environment inside the handler body. Memoise there where a warm container should build
379
- its clients once. The substituted `Date` a bound handler reads works the same way, and a time
380
- captured at module scope is captured equally early.
381
-
382
- Yulin ships `SimLambdaEnvironmentConflicts` to warn about this, and it warns only where the host
383
- value and the declared value differ. A suite that stubs the right values stays quiet and never
384
- learns. The rule stays invisible for as long as the stubs agree.
385
-
386
- A `vi.stubEnv` around a bound handler is the sign that the handler reads too early. Moving the reads
387
- into the handler body removed every `vi.stubEnv` from one repository.
372
+ So read the environment inside the handler body, memoising there where a warm container should build
373
+ its clients once. The substituted `Date` works the same way. A `vi.stubEnv` around a bound handler
374
+ is the sign of a handler reading too early. `SimLambdaEnvironmentConflicts` warns about this, but
375
+ only where the host value and the declared value differ, and a suite that stubs the right values
376
+ stays quiet and never learns.
388
377
 
389
378
  ### What a binding buys, and what the zip path buys
390
379
 
391
380
  Deploying without `bindings` runs the bundle `cdk synth` produced. `deployTemplateFile` publishes
392
- the cloud assembly's assets into the staging bucket in simulated S3, and the function's modules are
393
- evaluated as CommonJS in a vm sandbox. That sandbox hands the code its own `process.env`, its own
394
- `Date` and its own HTTP clients, and the module-scope problem above never arises there.
395
-
396
- Both paths authorise through the execution role. The same policy mutation fails a zip-path test
397
- exactly as it fails a bound one. The choice between them is about what else the test needs:
381
+ the cloud assembly's assets into the staging bucket in simulated S3, and the modules are evaluated
382
+ as CommonJS in a vm sandbox with its own `process.env`, `Date` and HTTP clients, where the
383
+ module-scope problem above never arises. Both paths authorise through the execution role, and the
384
+ same policy mutation fails a zip-path test exactly as it fails a bound one.
398
385
 
399
386
  - **A binding** keeps a breakpoint working and lets the handler close over test state.
400
387
  - **The zip path** exercises the artefact that deploys, its imports and its bundling included.
@@ -402,12 +389,10 @@ exactly as it fails a bound one. The choice between them is about what else the
402
389
  ### Outbound HTTP is answered by the simulation
403
390
 
404
391
  From 1.16.2, a simulated Lambda's `fetch` and its `node:http` and `node:https` are answered by the
405
- simulation for every hostname simulated Route 53 resolves. Those requests go through the same
406
- in-process entry point one arriving on localhost uses. A Cognito user pool domain, an HTTP API and a
407
- load balancer are all answered without the test knowing which of them it asked. Everything else
408
- reaches the network as it was addressed.
409
-
410
- This is what makes an OAuth authorization code exchange testable. That exchange lives only at the
411
- hosted `/oauth2/token` endpoint of the user pool domain and has no SDK operation behind it. A
412
- handler that cannot reach the domain leaves the whole exchange uncovered. The same routing lets
413
- `CognitoJwtVerifier` fetch a simulated pool's JWKS from inside a handler with no cache primed.
392
+ simulation for every hostname simulated Route 53 resolves, through the same in-process entry point a
393
+ request arriving on localhost uses. A Cognito user pool domain, an HTTP API and a load balancer are
394
+ all answered without the test knowing which of them it asked, and everything else reaches the
395
+ network as it was addressed. This is what makes an OAuth authorization code exchange testable, since
396
+ that exchange lives only at the pool domain's hosted `/oauth2/token` endpoint with no SDK operation
397
+ behind it. The same routing lets `CognitoJwtVerifier` fetch a simulated pool's JWKS from inside a
398
+ handler with no cache primed.