@kensio/yulin-aws-simulation 1.17.0 → 1.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.17.0",
4
+ "version": "1.18.1",
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
@@ -38,10 +38,10 @@ Unzip it into `.agents/skills/` and it is installed.
38
38
 
39
39
  ## What it covers
40
40
 
41
- **Deploy your real synthesized template.** Deploy the JSON CDK produced, with
42
- `deployTemplateFile({ templatePath, stackName })`. A construct change that breaks the system breaks
43
- the test. Do not hand-roll a wrapper that reads the file and calls `deployTemplate`: the file path
44
- is how Yulin finds the cloud assembly beside it. A wrapper loses staged CDK assets. `transform`
41
+ **Deploy your real synthesized templates.** Deploy the JSON CDK produced, one template or several,
42
+ with `deployTemplateFile({ templatePath, stackName })`. A construct change that breaks the system
43
+ breaks the test. Do not hand-roll a wrapper that reads the file and calls `deployTemplate`: the file
44
+ path 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
46
  from a CDK lookup, and `watch` re-applies the file on change for dev servers. `deployCdkOut` deploys
47
47
  a whole cloud assembly, each Stack into the region its own environment names.
@@ -91,9 +91,13 @@ authorised a Lambda function URL invocation against `lambda:InvokeFunctionUrl` a
91
91
  CloudFront origin access control also needs `lambda:InvokeFunction`. The tests passed, the release
92
92
  went out, and the endpoint 403'd in production.
93
93
 
94
- **Deploy expensive context once per test file.** Vitest gives each file its own worker. A stack
95
- deployed in `beforeAll` is already isolated between files. Isolation inside the file comes from
96
- randomised names.
94
+ **Share one simulation across the whole test suite.** Create one `SimAws`, deploy the application
95
+ once and install one `SimSdk` interception layer in Vitest setup. Run the suite in one shared worker
96
+ and module context. Tests use random resource names and identifiers while the simulated AWS state
97
+ stays alive, in the same way they would use an AWS account or a LocalStack container. This should be
98
+ the majority of the suite. Put tests that advance or change the shared simulation clock in a smaller
99
+ isolated group, with a fresh Yulin setup for each test. Per-test and per-file simulations are
100
+ supported for these exceptional cases.
97
101
 
98
102
  **Run the handler as a real simulated Lambda.** Bind an in-process handler to a template function
99
103
  and invoke the function through simulated Lambda. Its SDK calls are then routed into the simulation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kensio/yulin-aws-simulation",
3
- "version": "1.17.0",
3
+ "version": "1.18.1",
4
4
  "description": "How to test AWS code well with the @kensio/yulin in-process simulator.",
5
5
  "keywords": [
6
6
  "agent-skills",
@@ -1,9 +1,9 @@
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, 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.
3
+ description: Use the @kensio/yulin in-process AWS simulator well when testing AWS code. Share one simulation and SDK interception layer across tests that leave simulated time alone, and isolate the minority that change the clock. Use Yulin directly, deploy CDK-synthesized templates, bind real handlers, intercept SDK clients, drive HTTP with SimAwsHttp, control time, read generated names, match service errors by name, and handle unsupported properties. Use when writing or reviewing tests that touch AWS, replacing SDK stubs, testing CDK stacks or CloudFront resources, simplifying Yulin test setup, or diagnosing unsupported templates and commands.
4
4
  license: Apache-2.0
5
5
  metadata:
6
- version: "1.17.0"
6
+ version: "1.18.1"
7
7
  ---
8
8
 
9
9
  # Testing with Yulin
@@ -13,6 +13,13 @@ network and no AWS account. This skill is how to use it well. It serves `isolate
13
13
  the general argument for simulation over stubs. Each rule says what it buys, and a case that does
14
14
  not want that trade can go the other way knowingly.
15
15
 
16
+ Treat Yulin as suite infrastructure, in the same way tests treat an AWS account or a LocalStack
17
+ container. Create one simulation, deploy the application once and install SDK interception once in
18
+ the Vitest suite setup. Every test talks to that shared environment. Randomised resource names and
19
+ identifiers keep tests independent while the simulated AWS state remains alive for the whole suite.
20
+ This is the default for tests that leave the simulation clock alone. Put the smaller set of tests
21
+ that change simulated time in an isolated group, with their own Yulin setup.
22
+
16
23
  For the API read `node_modules/@kensio/yulin/llms.txt`. It indexes the 45 markdown pages beside it
17
24
  under `node_modules/@kensio/yulin/docs/`, one per simulated service and per feature guide,
18
25
  documenting the version installed. Open the page it names for the service in hand and grep it for
@@ -29,18 +36,19 @@ service, or a `beforeEach` that reassembles the world.
29
36
 
30
37
  Yulin is built to be used directly. `new SimAws()` and `new SimSdk()` are plain constructors with no
31
38
  side effects, no network, no cleanup and no awaiting. Service accessors take the same Command
32
- objects the SDK does. `using simSdk = new SimSdk()` is the teardown, `deployTemplateFile` is the
33
- environment, and a simulation per test is close to free.
39
+ objects the SDK does. The suite setup creates these objects once. `deployTemplateFile` is the
40
+ environment, and the tests import the shared simulation objects directly.
34
41
 
35
- So a test constructs, deploys if a template is involved, intercepts, exercises, then asserts by
36
- reading the simulation back. A wrapper around any of those steps hides the one thing the reader
37
- needs to see. A repeated sequence can be a small function in the same file returning the simulation
38
- objects themselves. Once it wants a class, an options interface or a directory, it has become a
39
- second product.
42
+ The setup constructs, deploys and intercepts. A test exercises the application and reads the shared
43
+ simulation back. A wrapper around any of those steps hides what the reader needs to see. The setup
44
+ can be a small module returning the simulation objects themselves. Once it wants a class, an options
45
+ interface or a directory, it has become a second product.
40
46
 
41
- ## One synthesized template, for tests, local dev and production
47
+ ## One CDK app behind the tests, local dev and production
42
48
 
43
- Describe the infrastructure once in CDK and let the synthesized output drive all three:
49
+ Describe the infrastructure once in CDK and let the synthesized output drive all three. Deploy the
50
+ template files `cdk synth` wrote, one of them or all of them, and talk to the simulation the way
51
+ production talks to AWS.
44
52
 
45
53
  ```typescript
46
54
  const stack = await simAws.region("eu-west-2").cloudFormation().deployTemplateFile({
@@ -51,10 +59,12 @@ const stack = await simAws.region("eu-west-2").cloudFormation().deployTemplateFi
51
59
  await stack.waitForDeployComplete();
52
60
  ```
53
61
 
54
- A test written against a hand-written template only tests the hand-written template, and a dev
55
- environment building its own buckets is a third description whose drift shows up as a bug
56
- reproducing in exactly one of the three places. So infrastructure belongs in the CDK app even when
57
- only a test needs it.
62
+ The test suite deploys this template once during its shared Yulin setup. Individual tests use the
63
+ deployed resources and do not deploy their own copies.
64
+
65
+ A hand-written template only tests itself, and a dev environment building its own buckets is a third
66
+ description whose drift shows up as a bug reproducing in one place of the three. Infrastructure
67
+ belongs in the CDK app even when only a test needs it.
58
68
 
59
69
  **Do not hand-roll a wrapper that reads the file and calls `deployTemplate`.** `deployTemplateFile`
60
70
  locates the cloud assembly beside the file, and that is how staged CDK assets resolve. A wrapper
@@ -76,14 +86,32 @@ await simAws.cloudFormation().deployTemplateFile({
76
86
  });
77
87
  ```
78
88
 
79
- Together they retire the derived template file a dev server used to write into `cdk.out` so that it
80
- had something to watch. The watched file is the one CDK wrote, and the adaptation re-applies on
81
- every read.
89
+ The watched file is the one CDK wrote, and the adaptation re-applies on every read.
82
90
 
83
91
  `stack.output("SiteBucketName")` answers a resolved Output narrowed to a string, throwing on one the
84
- template never declared. Do not hand-roll that reader. And note that a failed `cdk synth` leaves the
85
- previous template in `cdk.out` with the tests still passing against it, so check the synthesized
86
- JSON changed before concluding anything from a construct change.
92
+ template never declared. Do not hand-roll that reader. A failed `cdk synth` leaves the previous
93
+ template in `cdk.out` with the tests still passing against it, so check the synthesized JSON changed
94
+ before concluding anything from a construct change.
95
+
96
+ ### Read a generated name back, never write one out
97
+
98
+ A Resource whose template leaves its name out is named `<stack name>-<logical ID>-<tail>`, as an
99
+ account names one. The tail is twelve lowercase hex characters derived from the other two parts,
100
+ standing in for the twelve random ones real CloudFormation appends. Where the two parts overrun the
101
+ service's limit, thirteen characters of it go to the tail and its hyphen and the rest is shared
102
+ between them (a 64 character limit leaves 25 each). The trimmed stack name is what an IAM policy
103
+ scoped by resource prefix matches, and a deploy Role allowed an action on `MyVeryLongStackName-*` is
104
+ refused here as an account refuses it.
105
+
106
+ The same template under the same stack name generates the same name every time. That is the one
107
+ property tempting a test to write the name out. Read it back instead, from a `Ref`, from an
108
+ `Fn::GetAtt` attribute or from the accessor of the service holding it.
109
+ [Names CloudFormation generates](https://yulinsim.dev/services/cloudformation/#names-cloudformation-generates)
110
+ covers the tail and the trimming.
111
+
112
+ An ECS container binding has no way round this. The family CloudFormation generates for an unnamed
113
+ task definition ends in a tail only a deployment reveals. Name the task definition Resource with the
114
+ `logicalId` binding form.
87
115
 
88
116
  ### Register what the app looks up, deploy what the app creates
89
117
 
@@ -93,12 +121,11 @@ simulated resource carrying it comes from. Yulin stands one up at a chosen id wi
93
121
  `simAws.acm().registerCertificate({ arn, domainName })`,
94
122
  `simAws.cognitoIdentityProvider().registerUserPool({ id, name })` and `registerUserPoolClient`.
95
123
 
96
- A registration creates a resource in place of the app creating one, and that is what decides when to
97
- use it. It suits `HostedZone.fromLookup` or a certificate issued by hand outside the app. A resource
98
- some stack in the same app creates wants deploying, since a registration would mean configuring it
99
- by hand and taking its configuration from somewhere other than the deployed template. So register
100
- what the app looks up, deploy what the app creates, and substitute in a `transform` only where a
101
- deployed resource cannot be given the id its template names.
124
+ A registration creates a resource in place of the app creating one. It suits `HostedZone.fromLookup`
125
+ or a certificate issued by hand outside the app. A resource some stack in the same app creates wants
126
+ deploying instead, since a registration takes its configuration from somewhere other than the
127
+ deployed template. Substitute in a `transform` only where a deployed resource cannot be given the id
128
+ its template names.
102
129
 
103
130
  Route 53 needs least of this. An `AWS::Route53::RecordSet` naming a hosted zone id no zone holds
104
131
  gets one registered under that id as the record is created, taking its name from the records naming
@@ -128,9 +155,9 @@ const stacks = await simAws.cloudFormation().deployCdkOut({
128
155
  `stackNames` picks part of an assembly, which most apps need, since most also synthesize a
129
156
  deployment pipeline. `stackOptions` carries the `bindings`, `parameters` and `transform` that
130
157
  `deployTemplateFile` takes for one template, keyed the same way. Its transform is handed the Stacks
131
- the same call has already deployed. A Stack consuming a sibling's value therefore stays inside one
132
- call. Two Stacks passing a plain string between them declare no dependency for the manifest to
133
- carry, and the order they are named in is what puts the value there in time.
158
+ the same call already deployed, so a Stack consuming a sibling's value stays inside one call. Two
159
+ Stacks passing a plain string between them declare no dependency for the manifest to carry, and
160
+ their order in `stackNames` is what puts the value there in time.
134
161
 
135
162
  ## Intercept real SDK clients, never hand-roll stubs
136
163
 
@@ -138,7 +165,7 @@ carry, and the order they are named in is what puts the value there in time.
138
165
  from the simulation, and the code under test uses the SDK exactly as it does in production.
139
166
 
140
167
  ```typescript
141
- using simSdk = new SimSdk();
168
+ const simSdk = new SimSdk({ simAws });
142
169
  simSdk.intercept(SecretsManagerClient); // Every instance, including ones made later.
143
170
  ```
144
171
 
@@ -147,33 +174,31 @@ correctly. A stub has no naming rules and verifies no signatures. A malformed Se
147
174
  or a wrongly computed Cognito `SECRET_HASH` passes it and fails in production.
148
175
 
149
176
  Intercept the class in most cases, since the code under test usually constructs its own clients.
150
- Intercept an instance when a single client should reach the simulation, and when a file's cases each
151
- build their own `SimAws`. A class interception is process-wide (it shadows `send` on the class
152
- prototype) and refuses a second install while the first is live, with
153
- `SimSdkAlreadyInterceptedError`. An instance interception goes when the instance does.
177
+ Install that class interception once in the suite setup. A class interception is process-wide (it
178
+ shadows `send` on the class prototype) and refuses a second install while the first is live, with
179
+ `SimSdkAlreadyInterceptedError`. Repeated interception in `beforeEach` or in each test file is both
180
+ unnecessary and an error when those files share a worker. Intercept an instance only when one
181
+ specific client should reach Yulin.
154
182
 
155
183
  Whichever it is, it has to be the client the code actually calls. A `DynamoDBDocumentClient` built
156
184
  over a `DynamoDBClient` is what the code sends through, and the document client is the one to
157
185
  intercept. Every Command routes to the simulation by default, and an allow list of Command classes
158
186
  narrows that where something else should handle the rest.
159
187
 
160
- `SimSdk` and its interception handles are disposable, so `using` restores every intercepted client
161
- at the end of the scope, leaving nothing for a later test to inherit when the one before it threw.
162
- `simSdk.restoreAll()` and `interception.restore()` do it by hand. Each `SimSdk` owns a `SimAws`,
163
- reachable as `simSdk.simAws`, and `new SimSdk({ simAws })` shares an existing one.
188
+ Keep the suite's `SimSdk` alive for as long as the suite. Worker exit normally removes its
189
+ process-wide patches. Call `simSdk.restoreAll()` if the process will continue doing other work.
190
+ `using simSdk = new SimSdk()` and `interception.restore()` remain useful for a deliberately
191
+ short-lived simulation. Each `SimSdk` owns a `SimAws`, reachable as `simSdk.simAws`, and
192
+ `new SimSdk({ simAws })` shares an existing one.
164
193
 
165
194
  ### A fake accepts any request the simulator would refuse
166
195
 
167
196
  A fake S3 client stubbing `send` with canned `ListObjectsV2` pages, asserted on through the
168
197
  continuation tokens it recorded, passes for code that built its command without a `Bucket`.
169
- Simulated S3 does the pagination for real. `Prefix`, `MaxKeys`, `ContinuationToken` and `StartAfter`
170
- all apply, `IsTruncated` and `NextContinuationToken` come back as the service sends them, and
198
+ Simulated S3 paginates for real, down to the ETag a real multipart upload produces, and
171
199
  `configureMaxKeysPerPage` lowers the page size so that a bucket of two objects makes a caller walk a
172
- continuation. Uploading real parts gives the object the real `<md5-of-the-part-md5s>-<count>` ETag.
173
-
174
- The residue is small. A couple of answers the service never sends (a truncated page naming no
175
- continuation token) can only come from a fake, and a test reaching for one should say so in a
176
- comment.
200
+ continuation. The residue is small, and a test reaching for a fake to get an answer the service
201
+ never sends should say so in a comment.
177
202
 
178
203
  ## Freeze the clock and advance it deliberately
179
204
 
@@ -189,17 +214,30 @@ const simAws = new SimAws({
189
214
  await simAws.clock().advanceBy({ minutes: 20 });
190
215
  ```
191
216
 
192
- Time-dependent behaviour then becomes something a test asserts on in microseconds. A good deal of
193
- the simulation keys off that clock. EventBridge rules and Scheduler schedules fire only when time is
194
- advanced past them, DynamoDB items pass their TTL, Secrets Manager deletions come due, `AssumeRole`
195
- sessions expire, Lambda event source mappings re-poll, and inside a simulated Lambda `Date.now()`
196
- and `new Date()` report simulated time. So the clock stub `isolated-testing-style` allows is
197
- unnecessary here, since advancing this one exercises the real expiry rules of the services around it
198
- as well as the code's own arithmetic.
217
+ Time-dependent behaviour then becomes something a test asserts on in microseconds, and a good deal
218
+ of the simulation keys off that clock. EventBridge rules and Scheduler schedules fire only when time
219
+ is advanced past them, DynamoDB items pass their TTL, Secrets Manager deletions come due,
220
+ `AssumeRole` sessions expire, Lambda event source mappings re-poll, and inside a simulated Lambda
221
+ `Date.now()` and `new Date()` report simulated time. The clock stub `isolated-testing-style` allows
222
+ is unnecessary here, since advancing this one exercises the real expiry rules of the services around
223
+ it as well as the code's own arithmetic.
199
224
 
200
225
  `simAws.clock().resume()` tracks the underlying clock and `simAws.clock().isFrozen` reports the
201
226
  mode. Running mode suits a local dev server, and a test usually wants an advance.
202
227
 
228
+ ### Give clock-changing tests their own simulation
229
+
230
+ The clock is part of a `SimAws` instance's state. Tests sharing that instance also share its current
231
+ time and whether it is frozen or running. One test advancing or resuming the clock therefore changes
232
+ the environment underneath every other test in that group. Randomised resource names cannot isolate
233
+ this change.
234
+
235
+ Most tests should use the suite simulation without changing its clock. Put tests that advance the
236
+ clock or change its mode in a separate Vitest project or file group. Give each of those tests a
237
+ fresh `SimAws`, deployment and interception layer. Each test can then control time without affecting
238
+ another test. A small group may share one isolated simulation when its cases deliberately follow the
239
+ same timeline.
240
+
203
241
  ## Assert by reading the simulation back
204
242
 
205
243
  The simulation holds real state. After exercising the code, ask the service what happened:
@@ -211,13 +249,14 @@ const object = await simAws.s3().getObject(new GetObjectCommand({ Bucket: bucket
211
249
 
212
250
  A call-count assertion holds only for today's implementation. A state assertion holds however the
213
251
  handler is rewritten, and it fails if the call was made in a way the real service would have
214
- rejected.
252
+ rejected. In the shared suite environment, read the resource named by the test's random identifier.
253
+ Do not list the whole service and assume no other test has left state there.
215
254
 
216
- The accessors sit on more than one scope, with `simAws.region(name)` carrying some of the services
217
- and `simAws.region(name).account()` carrying all of them (`logs()` among the ones only the account
218
- scope has), so look on the other scope before concluding a service is missing. Each also takes a
219
- plain `{ input: { ... } }` in place of a Command object. An assertion can therefore read a service
220
- back without adding an `@aws-sdk/client-*` package the production code has no use for.
255
+ The accessors sit on more than one scope. `simAws.region(name)` carries some of the services and
256
+ `simAws.region(name).account()` carries all of them (`logs()` among the account-only ones), so look
257
+ on the other scope before concluding a service is missing. Each also takes a plain
258
+ `{ input: { ... } }` in place of a Command object. An assertion can then read a service back without
259
+ adding an `@aws-sdk/client-*` package the production code has no use for.
221
260
 
222
261
  ## Drive requests into the simulation
223
262
 
@@ -238,11 +277,11 @@ A hostname simulated Route 53 answers for is requested by its own name, with no
238
277
  `localUrl(...)` adapting (an `https` URL works with no certificate set up for it). That one request
239
278
  resolves the hostname, finds the Distribution its alias records point at, and runs the deployed
240
279
  CloudFront Function at viewer-request. The certificate, the Hosted Zone records, the Distribution's
241
- aliases and the function are covered together. A template assertion over the same stack passes with
242
- every Route 53 record missing.
280
+ aliases and the function are covered together, where a template assertion over the same stack passes
281
+ with every Route 53 record missing.
243
282
 
244
- Reach for `serveSimAws` when the request comes from outside the process, such as a browser, `curl`
245
- or an SDK client pointed at a local endpoint. Both go through the same routing and service code, and
283
+ Reach for `serveSimAws` when the request comes from outside the process (a browser, `curl`, an SDK
284
+ client pointed at a local endpoint). Both go through the same routing and service code, and
246
285
  `SimAwsHttp` leaves parallel test files no port to collide over. See
247
286
  [the serving docs](https://yulinsim.dev/serve/) for the API.
248
287
 
@@ -257,9 +296,9 @@ if (error instanceof Error && error.name === "ResourceNotFoundException") { ...
257
296
  ```
258
297
 
259
298
  The SDK exports exception classes, which invites the `instanceof` check. It holds only while exactly
260
- one copy of the SDK package is in play. Two copies in the module graph, a bundler, or a simulator
261
- raising its own classes, and it silently stops matching. Yulin's errors carry the service's real
262
- error names and SDK-shaped `$metadata` without being instances of the SDK classes. Fix it in
299
+ one copy of the SDK package is in play, and two copies in the module graph, a bundler, or a
300
+ simulator raising its own classes all stop it matching silently. Yulin's errors carry the service's
301
+ real error names and SDK-shaped `$metadata` without being instances of the SDK classes. Fix it in
263
302
  production code, where a version skew between two `@aws-sdk/client-*` packages breaks `instanceof`
264
303
  too. `name` is what the wire carries, and is right in both places.
265
304
 
@@ -283,47 +322,101 @@ workaround has to be maintained in every project that hits the same gap.
283
322
 
284
323
  The asymmetry matters more than the volume. A simulator staying silent about something costs little,
285
324
  leaving that behaviour uncovered where it already was. A simulator saying 200 where production says
286
- 403 turns a deploy-time failure into a production one, the opposite of what it is for. So report a
287
- false pass with what production does and what the simulation did, and a false refusal with the
288
- property and the template that carries it. Raise a gap costing nothing but convenience as well, once
289
- it is forcing structural duplication.
325
+ 403 is the opposite of what it is for. So report a false pass with what production does and what the
326
+ simulation did, and a false refusal with the property and the template that carries it. Raise a gap
327
+ costing nothing but convenience as well, once it is forcing structural duplication.
290
328
 
291
329
  A workaround kept while the issue is open wants a comment naming that issue and a revisit when it
292
- closes. Re-read the claims in your own comments on each upgrade. They are the ones nothing tests.
330
+ closes. Re-read those claims on each upgrade. They are the ones nothing tests.
331
+
332
+ ## Share one simulation across the whole test suite
293
333
 
294
- ## Deploy expensive context once per test file
334
+ The recommended lifecycle is one `SimAws`, one deployment and one `SimSdk` interception layer for
335
+ the tests that do not change simulated time. This should be most of the test suite. Do this in
336
+ Vitest setup. Tests should treat the result as a long-running AWS account or LocalStack container
337
+ whose state survives every test and test file.
295
338
 
296
- Vitest gives each test file its own worker, so module-level state is already isolated between files.
297
- Deploy a stack once for the file and let the tests share it. Isolation inside the file comes from
298
- randomised names.
339
+ An in-process simulation needs the test files to share a worker and module cache. Set
340
+ `fileParallelism: false` and `isolate: false`, then load one setup module through `setupFiles`:
299
341
 
300
342
  ```typescript
301
- let simAws: SimAws;
343
+ // vitest.config.ts
344
+ export default defineConfig({
345
+ test: {
346
+ fileParallelism: false,
347
+ isolate: false,
348
+ setupFiles: ["./test/yulin.setup.ts"],
349
+ },
350
+ });
351
+ ```
352
+
353
+ Keep the one-time work in an imported module. Vitest executes a setup file before each test file,
354
+ even with isolation disabled, but imported modules stay cached in the shared worker.
355
+
356
+ ```typescript
357
+ // test/yulin-suite.ts
358
+ const ready = (async () => {
359
+ const simAws = new SimAws();
360
+ const simSdk = new SimSdk({ simAws });
361
+
362
+ simSdk.intercept(DynamoDBClient);
363
+ simSdk.intercept(S3Client);
302
364
 
303
- beforeAll(async () => {
304
- simAws = new SimAws();
305
365
  const stack = await simAws.cloudFormation().deployTemplateFile({
306
366
  templatePath: "cdk.out/SiteStack.template.json",
307
367
  });
308
368
  await stack.waitForDeployComplete();
309
- });
310
369
 
311
- it("stores an upload", async () => {
312
- // Given a key no other test in this file is using.
313
- const key = `uploads/${faker.string.uuid()}.png`;
314
- });
370
+ return { simAws, simSdk, stack };
371
+ })();
372
+
373
+ export function yulinSuite() {
374
+ return ready;
375
+ }
376
+ ```
377
+
378
+ ```typescript
379
+ // test/yulin.setup.ts
380
+ import { yulinSuite } from "./yulin-suite";
381
+
382
+ await yulinSuite();
315
383
  ```
316
384
 
317
- A template deployment is the only thing usually worth hoisting, and in `beforeEach` it pays for the
318
- whole stack once per test for no isolation you did not already have. The `SimSdk`, a seeded row and
319
- a bucket key belong inside the test that needs them. A `beforeEach` assembling state for tests that
320
- do not all want the same state is the beginning of the harness this skill opens by arguing against.
385
+ Tests import `yulinSuite()` and work against that same state. Give every test data a random name or
386
+ identifier, query by that identifier and never assume a service starts empty. A missing-resource
387
+ case uses a new identifier that no test created. A count assertion records the relevant count before
388
+ the action when it cannot query by identifier. Tests remain independent without clearing tables,
389
+ buckets or queues between them.
390
+
391
+ ```typescript
392
+ const { simAws } = await yulinSuite();
393
+ const key = `uploads/${faker.string.uuid()}.png`;
394
+
395
+ // Exercise the application, then read this test's object from shared S3 state.
396
+ const object = await simAws.s3().getObject({ input: { Bucket: bucket, Key: key } });
397
+ ```
398
+
399
+ Do not put the simulation in Vitest `globalSetup`. That hook runs outside the test workers and can
400
+ only pass serializable values into them. `setupFiles` runs in the test worker and can share the live
401
+ `SimAws` object through the cached module above.
402
+
403
+ This shared setup also shares the simulation clock. Keep tests that call `advanceBy`, resume the
404
+ clock or otherwise change simulated time out of this group. Put them in a separate Vitest project or
405
+ file pattern, such as `*.clock.iso.test.ts`, and give each test an isolated Yulin setup. The split
406
+ keeps the common suite setup cheap while allowing the smaller clock-testing group to control time.
407
+
408
+ Creating Yulin once per test or once per file is supported. Reserve it for a case that deliberately
409
+ tests an entire fresh account, an independent clock or incompatible interception. Use the shared
410
+ setup by default. A `beforeEach` or file-level `beforeAll` that deploys the application again
411
+ usually turns random test data into repeated infrastructure work.
321
412
 
322
- ## Run the handler as a real simulated Lambda
413
+ ## Bind a handler and run it as a real simulated Lambda
323
414
 
324
- Yulin can run an in-process handler as a function inside the simulation. Bind it to a template
325
- function at deploy time with `bindings`, targeting the function by `logicalId`, `functionName`,
326
- `arn`, `cdkPath` or `imageRepository`:
415
+ `bindings` is how to run your own code inside the simulation, for an `AWS::CloudFront::Function` as
416
+ much as for a Lambda function. Bind an in-process handler at deploy time, targeting a Lambda
417
+ function by `logicalId`, `functionName`, `arn`, `cdkPath` or `imageRepository`, and a CloudFront
418
+ Function by `logicalId`, `functionName` or `arn`. On a CloudFront Function it is what covers source
419
+ `cdk synth` embedded or transformed.
327
420
 
328
421
  ```typescript
329
422
  await simAws.cloudFormation().deployTemplateFile({
@@ -358,10 +451,10 @@ accessors. A recording logger was the last reason standing, and from 1.17.1 a bo
358
451
  is recorded into its log group, read back at `/aws/lambda/<function name>` through
359
452
  `FilterLogEvents`.
360
453
 
361
- The console and the process standard streams are both bridged for the length of an invocation, in
362
- the way `process.env` and `Date` are. A logging library building its own `Console` over those
363
- streams at module scope is recorded too, Powertools' `Logger` included, both its JSON log line and
364
- its EMF metric document.
454
+ The console and the process standard streams are bridged for the length of an invocation, as
455
+ `process.env` and `Date` are. A logging library building its own `Console` over those streams at
456
+ module scope is recorded too, Powertools' `Logger` included, both its JSON log line and its EMF
457
+ metric document.
365
458
 
366
459
  So when a test builds the application's own graph, ask what it cannot get through an invocation.
367
460
  Expect the answer to be nothing.
@@ -370,27 +463,30 @@ Expect the answer to be nothing.
370
463
 
371
464
  A bound handler gets the function's declared environment variables with nothing stubbed.
372
465
  `SimProcessEnvironment` holds a run's variables in an `AsyncLocalStorage` store and resolves
373
- `process.env` to it for the length of the run, with concurrent runs each seeing their own. The one
374
- thing it cannot reach is a read that already happened. A handler module doing
466
+ `process.env` to it for the length of the run, with concurrent runs each seeing their own. What it
467
+ cannot reach is a read that already happened. A handler module doing
375
468
  `const TABLE = process.env.TABLE_NAME` at module scope is evaluated when the test file imports it,
376
469
  long before any run, and captures the host value.
377
470
 
378
471
  So read the environment inside the handler body, memoising there where a warm container should build
379
- its clients once. The substituted `Date` works the same way. A `vi.stubEnv` around a bound handler
380
- is the sign of a handler reading too early. `SimLambdaEnvironmentConflicts` warns about this, but
381
- only where the host value and the declared value differ, and a suite that stubs the right values
382
- stays quiet and never learns.
472
+ its clients once. The substituted `Date` works the same way, and a `vi.stubEnv` around a bound
473
+ handler is the sign of a handler reading too early. `SimLambdaEnvironmentConflicts` warns about
474
+ this, but only where the host value and the declared value differ, and a suite that stubs the right
475
+ values stays quiet and never learns.
383
476
 
384
- ### What a binding buys, and what the zip path buys
477
+ ### Keep the zip path for a case about the artefact
385
478
 
386
- Deploying without `bindings` runs the bundle `cdk synth` produced. `deployTemplateFile` publishes
387
- the cloud assembly's assets into the staging bucket in simulated S3, and the modules are evaluated
388
- as CommonJS in a vm sandbox with its own `process.env`, `Date` and HTTP clients, where the
389
- module-scope problem above never arises. Both paths authorise through the execution role, and the
390
- same policy mutation fails a zip-path test exactly as it fails a bound one.
479
+ Deploying a Lambda function without `bindings` runs the bundle `cdk synth` produced.
480
+ `deployTemplateFile` publishes the cloud assembly's assets into the staging bucket in simulated S3,
481
+ and the modules are evaluated as CommonJS in a vm sandbox with its own `process.env`, `Date` and
482
+ HTTP clients, where the module-scope problem above never arises. Both paths authorise through the
483
+ execution role, and the same policy mutation fails a zip-path test exactly as it fails a bound one.
391
484
 
392
- - **A binding** keeps a breakpoint working and lets the handler close over test state.
393
- - **The zip path** exercises the artefact that deploys, its imports and its bundling included.
485
+ What that buys is the artefact that deploys, its imports and its bundling included, and a case about
486
+ the bundle itself is the case to spend it on. Everywhere else it is the fallback. Every run waits on
487
+ the build, a stale `cdk.out` runs yesterday's handler, and the vm runtime loads CommonJS as the real
488
+ `nodejs` runtimes do, leaving a `NodejsFunction` synthesized with `format: OutputFormat.ESM` refused
489
+ at cold start.
394
490
 
395
491
  ### Outbound HTTP is answered by the simulation
396
492
 
@@ -398,7 +494,7 @@ From 1.16.2, a simulated Lambda's `fetch` and its `node:http` and `node:https` a
398
494
  simulation for every hostname simulated Route 53 resolves, through the same in-process entry point a
399
495
  request arriving on localhost uses. A Cognito user pool domain, an HTTP API and a load balancer are
400
496
  all answered without the test knowing which of them it asked, and everything else reaches the
401
- network as it was addressed. This is what makes an OAuth authorization code exchange testable, since
402
- that exchange lives only at the pool domain's hosted `/oauth2/token` endpoint with no SDK operation
403
- behind it. The same routing lets `CognitoJwtVerifier` fetch a simulated pool's JWKS from inside a
404
- handler with no cache primed.
497
+ network as addressed. That is what makes an OAuth authorization code exchange testable, since it
498
+ lives only at the pool domain's hosted `/oauth2/token` endpoint with no SDK operation behind it, and
499
+ it lets `CognitoJwtVerifier` fetch a simulated pool's JWKS from inside a handler with no cache
500
+ primed.