@liflig/cdk-snapshot 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,27 +2,43 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/@liflig/cdk-snapshot.svg)](https://www.npmjs.com/package/@liflig/cdk-snapshot)
4
4
  [![ci](https://github.com/capralifecycle/cdk-snapshot/actions/workflows/ci.yml/badge.svg)](https://github.com/capralifecycle/cdk-snapshot/actions/workflows/ci.yml)
5
+ [![node](https://img.shields.io/node/v/@liflig/cdk-snapshot.svg)](https://nodejs.org)
5
6
  [![license](https://img.shields.io/npm/l/@liflig/cdk-snapshot.svg)](LICENSE)
6
7
 
7
- Normalizes synthesized AWS CDK stacks for snapshot testing by stripping values such as asset hashes, bootstrap parameters, Lambda version suffixes and more, in order to produce more stable and usable snapshot files.
8
+ Snapshot testing for AWS CDK stacks. A stack is synthesized to CloudFormation and
9
+ normalized before it is snapshotted. The CDK bootstrap version is always dropped.
10
+ Asset hashes, Lambda version suffixes and CDK Pipelines asset IDs change whenever
11
+ an asset's content does; the [options](#options) mask them, so a snapshot fails only
12
+ when the infrastructure itself changed.
8
13
 
9
- The library provides a generic implementation with a thin adapter per test runner: `node:test`, Bun, Vitest and Jest. Everything is built around one pure function, `cdkTemplate`, which turns a stack into a normalized template object; each adapter wraps that function in whatever the runner's own snapshot assertion looks like. All four write the same snapshot bodies — only the file header differs — so the same `.snap` files stay valid if a project switches runner.
14
+ - One normalization for `node:test`, Bun, Vitest and Jest.
15
+ - All four record the same template. Switching runner means regenerating the snapshots
16
+ once, see [Switching runner](#switching-runner).
17
+ - Replaces `jest-cdk-snapshot` with the same options, defaults and serialization,
18
+ see [Migrating](#migrating-from-jest-cdk-snapshot).
10
19
 
11
20
  ## Install
12
21
 
13
22
  ```sh
14
23
  bun add -d @liflig/cdk-snapshot
24
+ npm install --save-dev @liflig/cdk-snapshot
25
+ pnpm add -D @liflig/cdk-snapshot
15
26
  ```
16
27
 
17
- `aws-cdk-lib` and `constructs` are peer dependencies. The package is ESM; CommonJS test files can `require()` it on Node 22.12 or later.
28
+ The package is ESM, with a CommonJS build of the root and Jest entry points for
29
+ CommonJS Jest projects.
18
30
 
19
31
  ## Usage
20
32
 
21
- Import the entry point for your runner. Jest, Vitest and Bun get a `toMatchCdkSnapshot` matcher; `node:test` has no `expect` at the time of writing, so it calls `cdkTemplate` directly.
33
+ Import the entry point for your runner. Jest, Vitest and Bun get a `toMatchCdkSnapshot`
34
+ matcher; `node:test` has no `expect`, so it calls `cdkTemplate` directly.
22
35
 
23
36
  ### node:test
24
37
 
25
- `configureCdkSnapshots()` points `node:test` at `__snapshots__/*.snap` and the shared serializer. Call it once, before any test runs. It replaces the default serializer for _every_ snapshot in the run, not just CDK ones, so snapshots taken elsewhere in the same project will be reformatted.
38
+ `configureCdkSnapshots()` points `node:test` at `__snapshots__/*.snap` and the shared
39
+ serializer. Call it once, before any test runs. It replaces the default serializer for
40
+ _every_ snapshot in the run, not just CDK ones, so snapshots taken elsewhere in the same
41
+ project will be reformatted.
26
42
 
27
43
  ```js
28
44
  import test from "node:test";
@@ -59,6 +75,18 @@ test("my stack", () => {
59
75
  });
60
76
  ```
61
77
 
78
+ `toMatchCdkSnapshot` does not work in concurrent tests: it snapshots through the global
79
+ `expect`, which Vitest cannot attribute to a test running concurrently. There, snapshot
80
+ the template with the test's own `expect` instead, which records the same entry:
81
+
82
+ ```js
83
+ import { cdkTemplate } from "@liflig/cdk-snapshot/vitest";
84
+
85
+ test.concurrent("my stack", ({ expect }) => {
86
+ expect(cdkTemplate(stack, { ignoreAssets: true })).toMatchSnapshot();
87
+ });
88
+ ```
89
+
62
90
  ### Jest
63
91
 
64
92
  ```js
@@ -69,9 +97,24 @@ test("my stack", () => {
69
97
  });
70
98
  ```
71
99
 
72
- The Jest entry point uses the global `expect`, so it throws on import if Jest is configured with `injectGlobals: false`.
100
+ Instead of importing it in each test file, the entry point can be listed once in
101
+ `setupFilesAfterEnv`.
102
+
103
+ A CommonJS test file, including one ts-jest or babel-jest compiles to CommonJS, loads the
104
+ CommonJS build and needs no configuration. An ESM test file loads the ESM build, and
105
+ needs Jest's ESM support enabled as any ESM test file does:
106
+
107
+ ```sh
108
+ NODE_OPTIONS=--experimental-vm-modules jest
109
+ ```
110
+
111
+ The Jest entry point uses the global `expect`, so it throws on import if Jest is
112
+ configured with `injectGlobals: false`.
73
113
 
74
- The matcher also accepts `propertyMatchers`, forwarded to the runner's own snapshot assertion for values the normalizations do not cover:
114
+ ### Matcher notes
115
+
116
+ `toMatchCdkSnapshot` also accepts `propertyMatchers`, forwarded to the runner's own
117
+ snapshot assertion for values the normalizations do not cover:
75
118
 
76
119
  ```js
77
120
  expect(stack).toMatchCdkSnapshot({
@@ -79,30 +122,82 @@ expect(stack).toMatchCdkSnapshot({
79
122
  });
80
123
  ```
81
124
 
82
- Every entry point also exports `cdkTemplate(stack, options)`. Reach for it to assert on the template without a snapshot. It leaves the stack untouched, so one stack can be synthesized repeatedly with different options.
125
+ Every entry point also exports `cdkTemplate(stack, options)`. Reach for it to assert on
126
+ the template without a snapshot. It leaves the stack untouched, so one stack can be
127
+ synthesized repeatedly with different options.
83
128
 
84
129
  `toMatchCdkSnapshot` cannot be negated; `.not` throws rather than silently passing.
85
130
 
86
- ## Options
87
-
88
- | Option | Default | Effect |
89
- | ------------------------ | ------------- | ----------------------------------------------------------------------------------- |
90
- | `ignoreAssets` | `false` | Replaces Lambda `Code`, container `Image` and the whole `Parameters` block with `Any<Object>` |
91
- | `ignoreBootstrapVersion` | `true` | Drops the `BootstrapVersion` parameter and its check rule |
92
- | `ignoreCurrentVersion` | `false` | Masks the content hash on Lambda `CurrentVersion` logical IDs |
93
- | `ignoreMetadata` | `false` | Drops template and resource `Metadata` |
94
- | `ignoreTags` | `false` | Drops `Tags` from resource properties |
95
- | `ignorePipelineAssets` | `false` | Masks asset paths and IDs in CDK Pipelines `cdk-assets` commands |
96
- | `subsetResourceTypes` | — | Keeps only resources of these CloudFormation types |
97
- | `subsetResourceKeys` | — | Keeps only resources with these logical IDs |
98
- | `assetPlaceholder` | `anyObject` | Token substituted for asset-derived values |
99
- | `propertyMatchers` | — | Matchers forwarded to the runner's snapshot assertion (matcher only) |
131
+ Under Jest and Vitest, `toMatchCdkSnapshot` counts as one assertion towards
132
+ `expect.assertions()`. Bun counts it as two, since its `expect` exposes no way to
133
+ correct the count.
100
134
 
101
- `subsetResourceTypes` and `subsetResourceKeys` intersect: given both, a resource is kept only if it matches both.
102
-
103
- `ignoreAssets` replaces the entire `Parameters` block rather than the individual asset parameters, matching what jest-cdk-snapshot does.
135
+ ## Options
104
136
 
105
- `anyObject` is exported from the package root. It is an asymmetric matcher that serializes as `Any<Object>` and matches any non-null object. The Bun entry point substitutes `expect.any(Object)` instead, because Bun's serializer only recognizes matchers built by its own `expect`.
137
+ | Option | Type | Default | Effect |
138
+ | --- | --- | --- | --- |
139
+ | `ignoreAssets` | `boolean` | `false` | Replaces every `Code` property, every container definition's `Image` and the whole `Parameters` block with `Any<Object>` |
140
+ | `ignoreBootstrapVersion` | `boolean` | `true` | Drops the `BootstrapVersion` parameter and its check rule |
141
+ | `ignoreCurrentVersion` | `boolean` | `false` | Masks the content hash on Lambda `CurrentVersion` logical IDs and every reference to them |
142
+ | `ignoreMetadata` | `boolean` | `false` | Drops template and resource `Metadata` |
143
+ | `ignoreTags` | `boolean` | `false` | Drops `Tags` from resource properties |
144
+ | `ignorePipelineAssets` | `boolean` | `false` | Masks asset paths, IDs and destination suffixes in CDK Pipelines `cdk-assets` commands |
145
+ | `subsetResourceTypes` | `string[]` | keep all | Keeps only resources of these CloudFormation types |
146
+ | `subsetResourceKeys` | `string[]` | keep all | Keeps only resources with these logical IDs |
147
+ | `assetPlaceholder` | `unknown` | `anyObject` | Token substituted for asset-derived values |
148
+ | `propertyMatchers` | `Record<string, unknown>` | none | Matchers forwarded to the runner's snapshot assertion; matcher only, `cdkTemplate` does not take it |
149
+
150
+ `subsetResourceTypes` and `subsetResourceKeys` intersect: given both, a resource is kept
151
+ only if it matches both.
152
+
153
+ `ignoreAssets` is coarse, matching what jest-cdk-snapshot does:
154
+
155
+ - It replaces the entire `Parameters` block. Under CDK's default synthesizer no parameter
156
+ carries an asset hash, so what disappears is the parameters the stack declares itself.
157
+ - It replaces values that are not assets as well, so a change to inline Lambda code, to a
158
+ `Code.fromBucket` key or to a registry image tag such as `nginx:1.27` does not show.
159
+ - Assets outside Lambda `Code` and container images keep their hash: Lambda layers,
160
+ `BucketDeployment` sources, Step Functions and API Gateway definitions read from files,
161
+ and nested stack templates.
162
+ - A function's `currentVersion` logical ID is a hash over its configuration, code
163
+ included, so a stack that uses it also needs `ignoreCurrentVersion` to stay stable.
164
+ - It does nothing to a template with no `Resources`.
165
+
166
+ `ignoreTags` drops the `Tags` property of each resource. Tags nested deeper stay, such as
167
+ those `Tags.of()` propagates into a launch template's `TagSpecifications`.
168
+
169
+ `ignorePipelineAssets` also drops the 8-character suffix CDK appends to each asset
170
+ destination, since that suffix changes with the asset's content too.
171
+
172
+ `anyObject` is exported from the package root. It is an asymmetric matcher that serializes
173
+ as `Any<Object>` and matches any non-null object. The Bun entry point substitutes
174
+ `expect.any(Object)` instead, because Bun's serializer only recognizes matchers built by
175
+ its own `expect`.
176
+
177
+ ## How it works
178
+
179
+ Everything is built around one pure function, `cdkTemplate`, which turns a stack into a
180
+ normalized template object. Each runner gets a thin adapter that wraps that function in
181
+ whatever the runner's own snapshot assertion looks like, so snapshots keep the naming and
182
+ format that runner already produces.
183
+
184
+ ## Switching runner
185
+
186
+ Regenerate the snapshots with the new runner. The templates they record stay the same; the
187
+ diff is limited to how each runner lays out the file around them:
188
+
189
+ | | Jest | Vitest | node:test | Bun |
190
+ | --- | --- | --- | --- | --- |
191
+ | Header | Jest's, and files without it are rejected | Vitest's | none | Bun's |
192
+ | Name of a test inside `describe` | `suite test 1` | `suite > test 1` | `suite > test 1` | `suite test 1` |
193
+ | Entry order | sorted | sorted | sorted | test order |
194
+ | Single-line snapshot, such as `{}` | inline | inline | on a line of its own | inline |
195
+ | Multi-line string inside a template | starts on its key's line | starts on its key's line | starts on its key's line | string and the comma after it on lines of their own |
196
+
197
+ Entry order does not affect matching, but it makes a regeneration diff look larger than it
198
+ is: two similar snapshots trading places reads as values changing.
199
+
200
+ `test/compat.test.ts` pins every row, so a runner that changes its format fails the build.
106
201
 
107
202
  ## Development
108
203
 
@@ -111,20 +206,47 @@ make build # install, format, typecheck, refresh snapshots, test
111
206
  make ci # what the CI workflow runs: refuses a stale lockfile, fails on an uncommitted snapshot change
112
207
  ```
113
208
 
114
- `make snapshots` regenerates the unit snapshots plus the shared fixture under all four runners, which `test/compat.test.ts` then compares against each other.
209
+ `make snapshots` regenerates the unit snapshots plus the shared fixture under all four
210
+ runners, Jest once as ESM and once as CommonJS, which `test/compat.test.ts` then compares
211
+ against each other.
212
+
213
+ `make compat-check` runs only the four runners and fails if their snapshots changed. CI
214
+ runs it on the oldest Node that `engines` in `package.json` allows.
115
215
 
116
216
  ## Migrating from jest-cdk-snapshot
117
217
 
118
- Change the import. Call sites and `.snap` files stay as they are, since the options, their defaults and the serialization all match.
218
+ Change the import, or the `setupFilesAfterEnv` entry. Call sites and `.snap` files stay
219
+ as they are, since the options, their defaults and the serialization all match, and the
220
+ Jest configuration stays as it is, CommonJS or ESM.
119
221
 
120
222
  ```diff
121
223
  -import "jest-cdk-snapshot"
122
224
  +import "@liflig/cdk-snapshot/jest"
123
225
  ```
124
226
 
125
- Verified against liflig-cdk (64 snapshots) and cdk-cloudfront-auth: every snapshot passes under `jest --ci`, and a forced `--updateSnapshot` rewrites nothing.
227
+ Verified against two public CDK libraries, liflig-cdk and cdk-cloudfront-auth: every
228
+ existing snapshot passes under `jest --ci`, and a forced `--updateSnapshot` rewrites
229
+ nothing.
230
+
231
+ One option is gone. `yaml` is not supported, so a project snapshotting YAML has to
232
+ regenerate as JSON.
233
+
234
+ One option masks more. `ignorePipelineAssets` also drops the content-derived suffix that
235
+ recent CDK versions append to asset destinations, which jest-cdk-snapshot keeps. A
236
+ pipeline snapshot taken with it changes once, from `publish "111111111111-eu-west-1-2d2574cc"`
237
+ to `publish "111111111111-eu-west-1"`, and then stays put when asset content changes.
238
+
239
+ jest-cdk-snapshot's option type also extended `StageSynthesisOptions`, so it accepted
240
+ `skipValidation`, `validateOnSynthesis`, `force`, `errorOnDuplicateSynth` and
241
+ `aspectStabilization` while warning at runtime that they did nothing. Here the type
242
+ checker rejects them; delete them.
243
+
244
+ ## Releases
126
245
 
127
- Two of its options are gone. `yaml` is not supported, so a project snapshotting YAML has to regenerate as JSON. The no-op synthesis options it inherited from `StageSynthesisOptions` — `skipValidation`, `validateOnSynthesis`, `force` — are rejected by the type checker instead of warned about at runtime; delete them.
246
+ Released from `master` by [semantic-release](https://semantic-release.gitbook.io/) on
247
+ every merge, so commit messages follow
248
+ [Conventional Commits](https://www.conventionalcommits.org/). The changelog is the
249
+ [GitHub releases page](https://github.com/capralifecycle/cdk-snapshot/releases).
128
250
 
129
251
  ## License
130
252
 
@@ -0,0 +1,14 @@
1
+ import type { Stack } from "aws-cdk-lib";
2
+ import type { CdkTemplateOptions } from "./options.js";
3
+ export type { CdkSnapshotOptions, CdkTemplateOptions } from "./options.js";
4
+ export { anyObject } from "./placeholder.js";
5
+ /**
6
+ * Synthesizes `stack` to a CloudFormation template with deployment noise
7
+ * removed, ready to hand to a snapshot assertion.
8
+ *
9
+ * The stack is left untouched, so it can be synthesized again with different
10
+ * options.
11
+ *
12
+ * Bun users should import this from `@liflig/cdk-snapshot/bun` instead.
13
+ */
14
+ export declare function cdkTemplate(stack: Stack, options?: CdkTemplateOptions): Record<string, unknown>;
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.anyObject = void 0;
4
+ exports.cdkTemplate = cdkTemplate;
5
+ const assertions_1 = require("aws-cdk-lib/assertions");
6
+ const normalize_js_1 = require("./normalize.js");
7
+ var placeholder_js_1 = require("./placeholder.js");
8
+ Object.defineProperty(exports, "anyObject", { enumerable: true, get: function () { return placeholder_js_1.anyObject; } });
9
+ /**
10
+ * Synthesizes `stack` to a CloudFormation template with deployment noise
11
+ * removed, ready to hand to a snapshot assertion.
12
+ *
13
+ * The stack is left untouched, so it can be synthesized again with different
14
+ * options.
15
+ *
16
+ * Bun users should import this from `@liflig/cdk-snapshot/bun` instead.
17
+ */
18
+ function cdkTemplate(stack, options = {}) {
19
+ return (0, normalize_js_1.normalize)(assertions_1.Template.fromStack(stack).toJSON(), options);
20
+ }
@@ -0,0 +1,10 @@
1
+ import type { CdkSnapshotOptions } from "./options.js";
2
+ export { cdkTemplate } from "./index.js";
3
+ export type { CdkSnapshotOptions, CdkTemplateOptions } from "./options.js";
4
+ declare global {
5
+ namespace jest {
6
+ interface Matchers<R, T = {}> {
7
+ toMatchCdkSnapshot(options?: CdkSnapshotOptions): R;
8
+ }
9
+ }
10
+ }
@@ -0,0 +1,8 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.cdkTemplate = void 0;
4
+ const index_js_1 = require("./index.js");
5
+ const matcher_js_1 = require("./matcher.js");
6
+ var index_js_2 = require("./index.js");
7
+ Object.defineProperty(exports, "cdkTemplate", { enumerable: true, get: function () { return index_js_2.cdkTemplate; } });
8
+ (0, matcher_js_1.registerCdkMatcher)((0, matcher_js_1.requireExpect)("Jest", globalThis.expect), index_js_1.cdkTemplate);
@@ -0,0 +1,29 @@
1
+ import type { Stack } from "aws-cdk-lib";
2
+ import type { CdkTemplateOptions } from "./options.js";
3
+ /** The part of a runner's `expect` this matcher relies on. */
4
+ export interface ExpectLike {
5
+ (actual: unknown): {
6
+ toMatchSnapshot(propertyMatchers?: Record<string, unknown>): void;
7
+ };
8
+ extend(matchers: Record<string, unknown>): void;
9
+ /** Jest and Vitest track assertion calls here; Bun has neither method. */
10
+ getState?(): {
11
+ assertionCalls: number;
12
+ };
13
+ setState?(state: {
14
+ assertionCalls: number;
15
+ }): void;
16
+ }
17
+ export type TemplateFn = (stack: Stack, options?: CdkTemplateOptions) => Record<string, unknown>;
18
+ /**
19
+ * Registers `toMatchCdkSnapshot` on the runner's `expect`.
20
+ *
21
+ * The matcher delegates to the runner's own snapshot assertion, so snapshots
22
+ * keep the naming and format that runner already produces.
23
+ */
24
+ export declare function registerCdkMatcher(expect: ExpectLike, cdkTemplate: TemplateFn): void;
25
+ /**
26
+ * Narrows the `expect` a runner injected to the shape the matcher needs, or
27
+ * explains what to do when the runner injected nothing.
28
+ */
29
+ export declare function requireExpect(runner: string, injected: unknown): ExpectLike;
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerCdkMatcher = registerCdkMatcher;
4
+ exports.requireExpect = requireExpect;
5
+ /**
6
+ * Registers `toMatchCdkSnapshot` on the runner's `expect`.
7
+ *
8
+ * The matcher delegates to the runner's own snapshot assertion, so snapshots
9
+ * keep the naming and format that runner already produces.
10
+ */
11
+ function registerCdkMatcher(expect, cdkTemplate) {
12
+ expect.extend({
13
+ toMatchCdkSnapshot(received, options = {}) {
14
+ if (this?.isNot) {
15
+ throw new Error("toMatchCdkSnapshot cannot be negated with `.not`.");
16
+ }
17
+ const { propertyMatchers, ...templateOptions } = options;
18
+ const assertionCalls = expect.getState?.().assertionCalls;
19
+ const assertion = expect(cdkTemplate(received, templateOptions));
20
+ if (propertyMatchers) {
21
+ assertion.toMatchSnapshot(propertyMatchers);
22
+ }
23
+ else {
24
+ assertion.toMatchSnapshot();
25
+ }
26
+ // The nested snapshot assertion must not count towards `expect.assertions()`.
27
+ if (assertionCalls !== undefined)
28
+ expect.setState?.({ assertionCalls });
29
+ return { pass: true, message: () => "" };
30
+ },
31
+ });
32
+ }
33
+ /**
34
+ * Narrows the `expect` a runner injected to the shape the matcher needs, or
35
+ * explains what to do when the runner injected nothing.
36
+ */
37
+ function requireExpect(runner, injected) {
38
+ const candidate = injected;
39
+ if (typeof candidate?.extend !== "function") {
40
+ throw new Error(`@liflig/cdk-snapshot: ${runner} did not inject a global \`expect\`. Enable global injection, or use cdkTemplate() directly.`);
41
+ }
42
+ return candidate;
43
+ }
@@ -0,0 +1,13 @@
1
+ import type { CdkTemplateOptions } from "./options.js";
2
+ /** A synthesized CloudFormation template. */
3
+ export type Template = Record<string, any>;
4
+ /**
5
+ * Returns a copy of `template` with the configured normalizations applied. The
6
+ * argument is left untouched: `Template.fromStack` hands out the assembly's
7
+ * cached template object, so mutating it would corrupt every later assertion
8
+ * on the same stack.
9
+ *
10
+ * Step order is significant: earlier steps can remove structures that later
11
+ * ones inspect.
12
+ */
13
+ export declare function normalize(template: Template, options?: CdkTemplateOptions): Template;
@@ -0,0 +1,141 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalize = normalize;
4
+ const placeholder_js_1 = require("./placeholder.js");
5
+ const currentVersionRegex = /^(.+CurrentVersion[0-9A-F]{8})[0-9a-f]{32}$/;
6
+ const pipelineCdkAssetsRegex = /cdk-assets\s+--path\s+\\"([^\\/]+)\/.+?assets\.json\\"\s+--verbose\s+publish\s+\\"(.+?)\\"/g;
7
+ const assetDestinationRegex = /:(.*?)(?:-[0-9a-f]{8})?$/;
8
+ const maskedVersionSuffix = "x".repeat(32);
9
+ /**
10
+ * Returns a copy of `template` with the configured normalizations applied. The
11
+ * argument is left untouched: `Template.fromStack` hands out the assembly's
12
+ * cached template object, so mutating it would corrupt every later assertion
13
+ * on the same stack.
14
+ *
15
+ * Step order is significant: earlier steps can remove structures that later
16
+ * ones inspect.
17
+ */
18
+ function normalize(template, options = {}) {
19
+ const { ignoreAssets = false, ignoreBootstrapVersion = true, ignoreCurrentVersion = false, ignoreMetadata = false, ignoreTags = false, ignorePipelineAssets = false, subsetResourceTypes, subsetResourceKeys, assetPlaceholder = placeholder_js_1.anyObject, } = options;
20
+ const result = structuredClone(template);
21
+ if (ignoreBootstrapVersion)
22
+ stripBootstrapVersion(result);
23
+ if (ignoreAssets)
24
+ stripAssets(result, assetPlaceholder);
25
+ if (ignoreCurrentVersion)
26
+ maskCurrentVersions(result);
27
+ if (ignorePipelineAssets)
28
+ maskPipelineAssets(result);
29
+ if (subsetResourceTypes) {
30
+ keepResources(result, (_key, resource) => subsetResourceTypes.includes(resource?.Type));
31
+ }
32
+ if (subsetResourceKeys) {
33
+ keepResources(result, (key) => subsetResourceKeys.includes(key));
34
+ }
35
+ if (ignoreMetadata)
36
+ stripMetadata(result);
37
+ if (ignoreTags)
38
+ stripTags(result);
39
+ return result;
40
+ }
41
+ function stripBootstrapVersion(template) {
42
+ const { Parameters, Rules } = template;
43
+ if (Parameters) {
44
+ delete Parameters.BootstrapVersion;
45
+ if (Object.keys(Parameters).length === 0)
46
+ delete template.Parameters;
47
+ }
48
+ if (Rules) {
49
+ delete Rules.CheckBootstrapVersion;
50
+ if (Object.keys(Rules).length === 0)
51
+ delete template.Rules;
52
+ }
53
+ }
54
+ function stripAssets(template, placeholder) {
55
+ if (!template.Resources)
56
+ return;
57
+ if (template.Parameters) {
58
+ template.Parameters = placeholder;
59
+ }
60
+ for (const resource of Object.values(template.Resources)) {
61
+ const properties = resource?.Properties;
62
+ if (!properties)
63
+ continue;
64
+ if (properties.Code) {
65
+ properties.Code = placeholder;
66
+ }
67
+ for (const definition of properties.ContainerDefinitions ?? []) {
68
+ definition.Image = placeholder;
69
+ }
70
+ }
71
+ }
72
+ function maskCurrentVersions(tree) {
73
+ transformStrings(tree, (value) => {
74
+ const match = currentVersionRegex.exec(value);
75
+ return match ? `${match[1]}${maskedVersionSuffix}` : value;
76
+ });
77
+ }
78
+ /**
79
+ * `cdk-assets ... publish "<hash>:<account>-<region>-<suffix>"` — the hash and
80
+ * the 8-hex suffix follow the asset's content, the account and region do not.
81
+ * CDK versions before the suffix emit `<hash>:<account>-<region>`.
82
+ */
83
+ function maskPipelineAssets(tree) {
84
+ transformStrings(tree, (value) => value.replace(pipelineCdkAssetsRegex, (_match, assemblyDir, asset) => {
85
+ const destination = assetDestinationRegex.exec(asset)?.[1] || "<ASSET_ID>";
86
+ return `cdk-assets --path "<${assemblyDir}>" --verbose publish "${destination}"`;
87
+ }));
88
+ }
89
+ /** Rewrites every string in `tree`, object keys included, in place. */
90
+ function transformStrings(tree, transform) {
91
+ if (tree == null || typeof tree !== "object")
92
+ return;
93
+ if (Array.isArray(tree)) {
94
+ for (let i = 0; i < tree.length; i++) {
95
+ const value = tree[i];
96
+ if (typeof value === "string") {
97
+ tree[i] = transform(value);
98
+ }
99
+ else {
100
+ transformStrings(value, transform);
101
+ }
102
+ }
103
+ return;
104
+ }
105
+ const record = tree;
106
+ for (const [key, value] of Object.entries(record)) {
107
+ const newKey = transform(key);
108
+ if (newKey !== key) {
109
+ record[newKey] = value;
110
+ delete record[key];
111
+ }
112
+ if (typeof value === "string") {
113
+ record[newKey] = transform(value);
114
+ }
115
+ else {
116
+ transformStrings(value, transform);
117
+ }
118
+ }
119
+ }
120
+ function keepResources(template, keep) {
121
+ if (!template.Resources)
122
+ return;
123
+ for (const [key, resource] of Object.entries(template.Resources)) {
124
+ if (!keep(key, resource)) {
125
+ delete template.Resources[key];
126
+ }
127
+ }
128
+ }
129
+ function stripMetadata(template) {
130
+ delete template.Metadata;
131
+ for (const resource of Object.values(template.Resources ?? {})) {
132
+ delete resource?.Metadata;
133
+ }
134
+ }
135
+ function stripTags(template) {
136
+ for (const resource of Object.values(template.Resources ?? {})) {
137
+ const properties = resource?.Properties;
138
+ if (properties?.Tags)
139
+ delete properties.Tags;
140
+ }
141
+ }
@@ -0,0 +1,49 @@
1
+ export interface CdkTemplateOptions {
2
+ /**
3
+ * Replace every resource's `Code` property, every container definition's
4
+ * `Image`, and the whole `Parameters` block with
5
+ * {@link CdkTemplateOptions.assetPlaceholder}.
6
+ *
7
+ * Assets elsewhere, such as Lambda layers, keep their hash. A function using
8
+ * `currentVersion` also needs
9
+ * {@link CdkTemplateOptions.ignoreCurrentVersion}, since the version's
10
+ * logical ID hashes the code.
11
+ */
12
+ ignoreAssets?: boolean;
13
+ /**
14
+ * Drop the CDK-managed `BootstrapVersion` parameter and its check rule.
15
+ * Defaults to `true`.
16
+ */
17
+ ignoreBootstrapVersion?: boolean;
18
+ /** Mask the content hash suffix on Lambda `CurrentVersion` logical IDs. */
19
+ ignoreCurrentVersion?: boolean;
20
+ /** Drop template and resource `Metadata`. */
21
+ ignoreMetadata?: boolean;
22
+ /**
23
+ * Drop each resource's `Tags` property. Tags nested deeper, such as a launch
24
+ * template's `TagSpecifications`, are kept.
25
+ */
26
+ ignoreTags?: boolean;
27
+ /**
28
+ * Mask asset paths, IDs and destination suffixes inside CDK Pipelines
29
+ * `cdk-assets` commands.
30
+ */
31
+ ignorePipelineAssets?: boolean;
32
+ /** Keep only resources of these CloudFormation types. */
33
+ subsetResourceTypes?: string[];
34
+ /** Keep only resources with these logical IDs. */
35
+ subsetResourceKeys?: string[];
36
+ /**
37
+ * Token substituted for asset-derived values. Defaults to a matcher
38
+ * serializing as `Any<Object>`; the Bun entry point overrides it.
39
+ */
40
+ assetPlaceholder?: unknown;
41
+ }
42
+ /** {@link CdkTemplateOptions} plus what only the snapshot matcher can apply. */
43
+ export interface CdkSnapshotOptions extends CdkTemplateOptions {
44
+ /**
45
+ * Property matchers handed to the runner's snapshot assertion, for values
46
+ * the normalizations do not cover.
47
+ */
48
+ propertyMatchers?: Record<string, unknown>;
49
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1 @@
1
+ {"type":"commonjs"}
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Stand-in for asset-derived values, which change whenever an asset's content
3
+ * does.
4
+ * Serializes as `Any<Object>` so snapshots match across test runners.
5
+ *
6
+ * Bun accepts only matchers built by its own `expect`; the Bun entry point
7
+ * substitutes one.
8
+ */
9
+ export declare const anyObject: unknown;
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.anyObject = void 0;
4
+ /**
5
+ * Stand-in for asset-derived values, which change whenever an asset's content
6
+ * does.
7
+ * Serializes as `Any<Object>` so snapshots match across test runners.
8
+ *
9
+ * Bun accepts only matchers built by its own `expect`; the Bun entry point
10
+ * substitutes one.
11
+ */
12
+ exports.anyObject = {
13
+ $$typeof: Symbol.for("jest.asymmetricMatcher"),
14
+ asymmetricMatch: (actual) => typeof actual === "object" && actual !== null,
15
+ toString: () => "Any",
16
+ getExpectedType: () => "Object",
17
+ toAsymmetricMatcher: () => "Any<Object>",
18
+ };
package/lib/matcher.d.ts CHANGED
@@ -6,6 +6,13 @@ export interface ExpectLike {
6
6
  toMatchSnapshot(propertyMatchers?: Record<string, unknown>): void;
7
7
  };
8
8
  extend(matchers: Record<string, unknown>): void;
9
+ /** Jest and Vitest track assertion calls here; Bun has neither method. */
10
+ getState?(): {
11
+ assertionCalls: number;
12
+ };
13
+ setState?(state: {
14
+ assertionCalls: number;
15
+ }): void;
9
16
  }
10
17
  export type TemplateFn = (stack: Stack, options?: CdkTemplateOptions) => Record<string, unknown>;
11
18
  /**
package/lib/matcher.js CHANGED
@@ -11,6 +11,7 @@ export function registerCdkMatcher(expect, cdkTemplate) {
11
11
  throw new Error("toMatchCdkSnapshot cannot be negated with `.not`.");
12
12
  }
13
13
  const { propertyMatchers, ...templateOptions } = options;
14
+ const assertionCalls = expect.getState?.().assertionCalls;
14
15
  const assertion = expect(cdkTemplate(received, templateOptions));
15
16
  if (propertyMatchers) {
16
17
  assertion.toMatchSnapshot(propertyMatchers);
@@ -18,6 +19,9 @@ export function registerCdkMatcher(expect, cdkTemplate) {
18
19
  else {
19
20
  assertion.toMatchSnapshot();
20
21
  }
22
+ // The nested snapshot assertion must not count towards `expect.assertions()`.
23
+ if (assertionCalls !== undefined)
24
+ expect.setState?.({ assertionCalls });
21
25
  return { pass: true, message: () => "" };
22
26
  },
23
27
  });
package/lib/node.d.ts CHANGED
@@ -2,7 +2,7 @@ export { cdkTemplate } from "./index.js";
2
2
  export type { CdkSnapshotOptions, CdkTemplateOptions } from "./options.js";
3
3
  /**
4
4
  * Aligns `node:test` with the snapshot location and serialization the other
5
- * runners use, so one set of snapshot files serves all of them.
5
+ * runners use, so its snapshots record templates the same way theirs do.
6
6
  *
7
7
  * Call once, before any test runs.
8
8
  */
package/lib/node.js CHANGED
@@ -4,7 +4,7 @@ import { serialize } from "./serialize.js";
4
4
  export { cdkTemplate } from "./index.js";
5
5
  /**
6
6
  * Aligns `node:test` with the snapshot location and serialization the other
7
- * runners use, so one set of snapshot files serves all of them.
7
+ * runners use, so its snapshots record templates the same way theirs do.
8
8
  *
9
9
  * Call once, before any test runs.
10
10
  */
package/lib/normalize.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { anyObject } from "./placeholder.js";
2
2
  const currentVersionRegex = /^(.+CurrentVersion[0-9A-F]{8})[0-9a-f]{32}$/;
3
3
  const pipelineCdkAssetsRegex = /cdk-assets\s+--path\s+\\"([^\\/]+)\/.+?assets\.json\\"\s+--verbose\s+publish\s+\\"(.+?)\\"/g;
4
- const assetDestinationRegex = /:(.*)$/;
4
+ const assetDestinationRegex = /:(.*?)(?:-[0-9a-f]{8})?$/;
5
5
  const maskedVersionSuffix = "x".repeat(32);
6
6
  /**
7
7
  * Returns a copy of `template` with the configured normalizations applied. The
@@ -73,8 +73,9 @@ function maskCurrentVersions(tree) {
73
73
  });
74
74
  }
75
75
  /**
76
- * `cdk-assets ... publish "<hash>:<destination>"` — the hash changes on every
77
- * synth, the destination does not.
76
+ * `cdk-assets ... publish "<hash>:<account>-<region>-<suffix>"` — the hash and
77
+ * the 8-hex suffix follow the asset's content, the account and region do not.
78
+ * CDK versions before the suffix emit `<hash>:<account>-<region>`.
78
79
  */
79
80
  function maskPipelineAssets(tree) {
80
81
  transformStrings(tree, (value) => value.replace(pipelineCdkAssetsRegex, (_match, assemblyDir, asset) => {
package/lib/options.d.ts CHANGED
@@ -1,7 +1,13 @@
1
1
  export interface CdkTemplateOptions {
2
2
  /**
3
- * Replace asset-derived values — Lambda `Code`, container `Image`, and the
4
- * template parameters carrying asset hashes with {@link anyObject}.
3
+ * Replace every resource's `Code` property, every container definition's
4
+ * `Image`, and the whole `Parameters` block with
5
+ * {@link CdkTemplateOptions.assetPlaceholder}.
6
+ *
7
+ * Assets elsewhere, such as Lambda layers, keep their hash. A function using
8
+ * `currentVersion` also needs
9
+ * {@link CdkTemplateOptions.ignoreCurrentVersion}, since the version's
10
+ * logical ID hashes the code.
5
11
  */
6
12
  ignoreAssets?: boolean;
7
13
  /**
@@ -13,9 +19,15 @@ export interface CdkTemplateOptions {
13
19
  ignoreCurrentVersion?: boolean;
14
20
  /** Drop template and resource `Metadata`. */
15
21
  ignoreMetadata?: boolean;
16
- /** Drop `Tags` from resource properties. */
22
+ /**
23
+ * Drop each resource's `Tags` property. Tags nested deeper, such as a launch
24
+ * template's `TagSpecifications`, are kept.
25
+ */
17
26
  ignoreTags?: boolean;
18
- /** Mask asset paths and IDs inside CDK Pipelines `cdk-assets` commands. */
27
+ /**
28
+ * Mask asset paths, IDs and destination suffixes inside CDK Pipelines
29
+ * `cdk-assets` commands.
30
+ */
19
31
  ignorePipelineAssets?: boolean;
20
32
  /** Keep only resources of these CloudFormation types. */
21
33
  subsetResourceTypes?: string[];
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Stand-in for values that change on every synth, such as asset hashes.
2
+ * Stand-in for asset-derived values, which change whenever an asset's content
3
+ * does.
3
4
  * Serializes as `Any<Object>` so snapshots match across test runners.
4
5
  *
5
6
  * Bun accepts only matchers built by its own `expect`; the Bun entry point
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Stand-in for values that change on every synth, such as asset hashes.
2
+ * Stand-in for asset-derived values, which change whenever an asset's content
3
+ * does.
3
4
  * Serializes as `Any<Object>` so snapshots match across test runners.
4
5
  *
5
6
  * Bun accepts only matchers built by its own `expect`; the Bun entry point
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Serializes a value the way Jest, Vitest and Bun serialize snapshots.
2
+ * Serializes a value the way Jest and Vitest serialize snapshots. Bun matches
3
+ * too, except for multi-line strings nested inside the value.
3
4
  *
4
5
  * `node:test` formats with `JSON.stringify` by default, which would make its
5
6
  * snapshots incompatible with the other runners.
package/lib/serialize.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { format, plugins } from "pretty-format";
2
2
  /**
3
- * Serializes a value the way Jest, Vitest and Bun serialize snapshots.
3
+ * Serializes a value the way Jest and Vitest serialize snapshots. Bun matches
4
+ * too, except for multi-line strings nested inside the value.
4
5
  *
5
6
  * `node:test` formats with `JSON.stringify` by default, which would make its
6
7
  * snapshots incompatible with the other runners.
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@liflig/cdk-snapshot",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Normalizes synthesized AWS CDK stacks for snapshot testing",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Liflig",
8
8
  "engines": {
9
- "node": ">=22.12.0"
9
+ "node": ">=22.13.0"
10
10
  },
11
11
  "repository": {
12
12
  "type": "git",
@@ -28,12 +28,18 @@
28
28
  "bun",
29
29
  "node-test"
30
30
  ],
31
- "main": "./lib/index.js",
32
- "types": "./lib/index.d.ts",
31
+ "main": "./lib/cjs/index.js",
32
+ "types": "./lib/cjs/index.d.ts",
33
33
  "exports": {
34
34
  ".": {
35
- "types": "./lib/index.d.ts",
36
- "import": "./lib/index.js",
35
+ "import": {
36
+ "types": "./lib/index.d.ts",
37
+ "default": "./lib/index.js"
38
+ },
39
+ "require": {
40
+ "types": "./lib/cjs/index.d.ts",
41
+ "default": "./lib/cjs/index.js"
42
+ },
37
43
  "default": "./lib/index.js"
38
44
  },
39
45
  "./bun": {
@@ -42,8 +48,14 @@
42
48
  "default": "./lib/bun.js"
43
49
  },
44
50
  "./jest": {
45
- "types": "./lib/jest.d.ts",
46
- "import": "./lib/jest.js",
51
+ "import": {
52
+ "types": "./lib/jest.d.ts",
53
+ "default": "./lib/jest.js"
54
+ },
55
+ "require": {
56
+ "types": "./lib/cjs/jest.d.ts",
57
+ "default": "./lib/cjs/jest.js"
58
+ },
47
59
  "default": "./lib/jest.js"
48
60
  },
49
61
  "./node": {
@@ -62,7 +74,7 @@
62
74
  "lib/**/*"
63
75
  ],
64
76
  "scripts": {
65
- "build": "tsc",
77
+ "build": "tsc && tsc -p tsconfig.cjs.json && echo '{\"type\":\"commonjs\"}' > lib/cjs/package.json",
66
78
  "test": "bun test ./test",
67
79
  "snapshots": "bun test ./test --update-snapshots",
68
80
  "typecheck": "tsc -p tsconfig.check.json",