@liflig/cdk-snapshot 1.0.2 → 1.2.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
@@ -6,7 +6,7 @@
6
6
  [![license](https://img.shields.io/npm/l/@liflig/cdk-snapshot.svg)](LICENSE)
7
7
 
8
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.
9
+ normalized before it is snapshotted. The CDK bootstrap version is dropped by default.
10
10
  Asset hashes, Lambda version suffixes and CDK Pipelines asset IDs change whenever
11
11
  an asset's content does; the [options](#options) mask them, so a snapshot fails only
12
12
  when the infrastructure itself changed.
@@ -25,7 +25,8 @@ npm install --save-dev @liflig/cdk-snapshot
25
25
  pnpm add -D @liflig/cdk-snapshot
26
26
  ```
27
27
 
28
- The package is ESM. Under Jest that needs configuration, see [Jest](#jest).
28
+ The package is ESM, with a CommonJS build of the root and Jest entry points for
29
+ CommonJS Jest projects.
29
30
 
30
31
  ## Usage
31
32
 
@@ -96,16 +97,17 @@ test("my stack", () => {
96
97
  });
97
98
  ```
98
99
 
99
- Jest loads this package as ESM, which it does only with its ESM support enabled:
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:
100
106
 
101
107
  ```sh
102
108
  NODE_OPTIONS=--experimental-vm-modules jest
103
109
  ```
104
110
 
105
- A CommonJS test file additionally needs Node 24.9 or later, where Jest can `require()` an
106
- ESM package. Below that, the test file has to be ESM or go through a transform that
107
- compiles the package to CommonJS.
108
-
109
111
  The Jest entry point uses the global `expect`, so it throws on import if Jest is
110
112
  configured with `injectGlobals: false`.
111
113
 
@@ -135,6 +137,7 @@ correct the count.
135
137
  | Option | Type | Default | Effect |
136
138
  | --- | --- | --- | --- |
137
139
  | `ignoreAssets` | `boolean` | `false` | Replaces every `Code` property, every container definition's `Image` and the whole `Parameters` block with `Any<Object>` |
140
+ | `ignoreAssetHashes` | `boolean` | `false` | Replaces the hash of every asset in the app with `<ASSET_HASH>`, wherever it appears |
138
141
  | `ignoreBootstrapVersion` | `boolean` | `true` | Drops the `BootstrapVersion` parameter and its check rule |
139
142
  | `ignoreCurrentVersion` | `boolean` | `false` | Masks the content hash on Lambda `CurrentVersion` logical IDs and every reference to them |
140
143
  | `ignoreMetadata` | `boolean` | `false` | Drops template and resource `Metadata` |
@@ -156,11 +159,29 @@ only if it matches both.
156
159
  `Code.fromBucket` key or to a registry image tag such as `nginx:1.27` does not show.
157
160
  - Assets outside Lambda `Code` and container images keep their hash: Lambda layers,
158
161
  `BucketDeployment` sources, Step Functions and API Gateway definitions read from files,
159
- and nested stack templates.
162
+ and nested stack templates. `ignoreAssetHashes` covers them.
160
163
  - A function's `currentVersion` logical ID is a hash over its configuration, code
161
164
  included, so a stack that uses it also needs `ignoreCurrentVersion` to stay stable.
162
165
  - It does nothing to a template with no `Resources`.
163
166
 
167
+ `ignoreAssetHashes` is the precise alternative. It reads the hash of every file and
168
+ container image asset from the asset manifests the app synthesizes, CDK Pipelines stages
169
+ included, and replaces exactly those hashes wherever a string in the template holds one:
170
+
171
+ ```diff
172
+ "Code": {
173
+ "S3Bucket": "cdk-hnb659fds-assets-112233445566-eu-west-1",
174
+ - "S3Key": "9b8fce7ae7f25ef82fdbaf6b72523b99d0875c0c9c826642819fb51f11d9b125.zip",
175
+ + "S3Key": "<ASSET_HASH>.zip",
176
+ },
177
+ ```
178
+
179
+ Everything else stays visible: the stack's parameters, inline code, registry image tags
180
+ and any hash that belongs to no asset. A function using `currentVersion` still needs
181
+ `ignoreCurrentVersion`, and a CDK Pipeline still needs `ignorePipelineAssets` for its
182
+ destination suffixes, which are not asset hashes. The hashes come from the asset
183
+ manifests that CDK's default synthesizer writes.
184
+
164
185
  `ignoreTags` drops the `Tags` property of each resource. Tags nested deeper stay, such as
165
186
  those `Tags.of()` propagates into a launch template's `TagSpecifications`.
166
187
 
@@ -174,8 +195,8 @@ its own `expect`.
174
195
 
175
196
  ## How it works
176
197
 
177
- Everything is built around one pure function, `cdkTemplate`, which turns a stack into a
178
- normalized template object. Each runner gets a thin adapter that wraps that function in
198
+ Everything is built around one function, `cdkTemplate`, which synthesizes a stack and
199
+ hands the template to a pure normalizer. Each runner gets a thin adapter that wraps that function in
179
200
  whatever the runner's own snapshot assertion looks like, so snapshots keep the naming and
180
201
  format that runner already produces.
181
202
 
@@ -205,15 +226,17 @@ make ci # what the CI workflow runs: refuses a stale lockfile, fails on an
205
226
  ```
206
227
 
207
228
  `make snapshots` regenerates the unit snapshots plus the shared fixture under all four
208
- runners, which `test/compat.test.ts` then compares against each other.
229
+ runners, Jest once as ESM and once as CommonJS, which `test/compat.test.ts` then compares
230
+ against each other.
209
231
 
210
232
  `make compat-check` runs only the four runners and fails if their snapshots changed. CI
211
233
  runs it on the oldest Node that `engines` in `package.json` allows.
212
234
 
213
235
  ## Migrating from jest-cdk-snapshot
214
236
 
215
- Change the import. Call sites and `.snap` files stay as they are, since the options, their
216
- defaults and the serialization all match.
237
+ Change the import, or the `setupFilesAfterEnv` entry. Call sites and `.snap` files stay
238
+ as they are, since the options, their defaults and the serialization all match, and the
239
+ Jest configuration stays as it is, CommonJS or ESM.
217
240
 
218
241
  ```diff
219
242
  -import "jest-cdk-snapshot"
@@ -224,9 +247,6 @@ Verified against two public CDK libraries, liflig-cdk and cdk-cloudfront-auth: e
224
247
  existing snapshot passes under `jest --ci`, and a forced `--updateSnapshot` rewrites
225
248
  nothing.
226
249
 
227
- Jest now has to run with ESM support enabled, since this package is ESM — see
228
- [Jest](#jest).
229
-
230
250
  One option is gone. `yaml` is not supported, so a project snapshotting YAML has to
231
251
  regenerate as JSON.
232
252
 
@@ -0,0 +1,11 @@
1
+ import { type Stack } from "aws-cdk-lib";
2
+ /**
3
+ * The IDs of every file and container image asset in the cloud assembly
4
+ * `stack` belongs to, nested assemblies such as CDK Pipelines stages included.
5
+ * Under CDK's default synthesizer an asset's ID is the hash it is published
6
+ * under.
7
+ *
8
+ * Synthesis is cached per stage, so this reads the assembly the stack's
9
+ * template came from.
10
+ */
11
+ export declare function assetHashes(stack: Stack): Set<string>;
package/lib/assets.js ADDED
@@ -0,0 +1,39 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { Stage } from "aws-cdk-lib";
3
+ /**
4
+ * The IDs of every file and container image asset in the cloud assembly
5
+ * `stack` belongs to, nested assemblies such as CDK Pipelines stages included.
6
+ * Under CDK's default synthesizer an asset's ID is the hash it is published
7
+ * under.
8
+ *
9
+ * Synthesis is cached per stage, so this reads the assembly the stack's
10
+ * template came from.
11
+ */
12
+ export function assetHashes(stack) {
13
+ const hashes = new Set();
14
+ const stage = Stage.of(stack);
15
+ if (stage)
16
+ collect(stage.synth(), hashes);
17
+ return hashes;
18
+ }
19
+ // Artifacts are told apart by manifest type rather than instanceof, which
20
+ // fails when the app was built with a different copy of aws-cdk-lib.
21
+ function collect(assembly, hashes) {
22
+ for (const artifact of assembly.artifacts) {
23
+ switch (artifact.manifest.type) {
24
+ case "cdk:asset-manifest": {
25
+ const { file } = artifact;
26
+ const { files = {}, dockerImages = {} } = JSON.parse(readFileSync(file, "utf8"));
27
+ for (const id of Object.keys({ ...files, ...dockerImages })) {
28
+ hashes.add(id);
29
+ }
30
+ break;
31
+ }
32
+ case "cdk:cloud-assembly": {
33
+ const { nestedAssembly } = artifact;
34
+ collect(nestedAssembly, hashes);
35
+ break;
36
+ }
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,11 @@
1
+ import { type Stack } from "aws-cdk-lib";
2
+ /**
3
+ * The IDs of every file and container image asset in the cloud assembly
4
+ * `stack` belongs to, nested assemblies such as CDK Pipelines stages included.
5
+ * Under CDK's default synthesizer an asset's ID is the hash it is published
6
+ * under.
7
+ *
8
+ * Synthesis is cached per stage, so this reads the assembly the stack's
9
+ * template came from.
10
+ */
11
+ export declare function assetHashes(stack: Stack): Set<string>;
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.assetHashes = assetHashes;
4
+ const node_fs_1 = require("node:fs");
5
+ const aws_cdk_lib_1 = require("aws-cdk-lib");
6
+ /**
7
+ * The IDs of every file and container image asset in the cloud assembly
8
+ * `stack` belongs to, nested assemblies such as CDK Pipelines stages included.
9
+ * Under CDK's default synthesizer an asset's ID is the hash it is published
10
+ * under.
11
+ *
12
+ * Synthesis is cached per stage, so this reads the assembly the stack's
13
+ * template came from.
14
+ */
15
+ function assetHashes(stack) {
16
+ const hashes = new Set();
17
+ const stage = aws_cdk_lib_1.Stage.of(stack);
18
+ if (stage)
19
+ collect(stage.synth(), hashes);
20
+ return hashes;
21
+ }
22
+ // Artifacts are told apart by manifest type rather than instanceof, which
23
+ // fails when the app was built with a different copy of aws-cdk-lib.
24
+ function collect(assembly, hashes) {
25
+ for (const artifact of assembly.artifacts) {
26
+ switch (artifact.manifest.type) {
27
+ case "cdk:asset-manifest": {
28
+ const { file } = artifact;
29
+ const { files = {}, dockerImages = {} } = JSON.parse((0, node_fs_1.readFileSync)(file, "utf8"));
30
+ for (const id of Object.keys({ ...files, ...dockerImages })) {
31
+ hashes.add(id);
32
+ }
33
+ break;
34
+ }
35
+ case "cdk:cloud-assembly": {
36
+ const { nestedAssembly } = artifact;
37
+ collect(nestedAssembly, hashes);
38
+ break;
39
+ }
40
+ }
41
+ }
42
+ }
@@ -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,25 @@
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 assets_js_1 = require("./assets.js");
7
+ const normalize_js_1 = require("./normalize.js");
8
+ var placeholder_js_1 = require("./placeholder.js");
9
+ Object.defineProperty(exports, "anyObject", { enumerable: true, get: function () { return placeholder_js_1.anyObject; } });
10
+ /**
11
+ * Synthesizes `stack` to a CloudFormation template with deployment noise
12
+ * removed, ready to hand to a snapshot assertion.
13
+ *
14
+ * The stack is left untouched, so it can be synthesized again with different
15
+ * options.
16
+ *
17
+ * Bun users should import this from `@liflig/cdk-snapshot/bun` instead.
18
+ */
19
+ function cdkTemplate(stack, options = {}) {
20
+ const template = assertions_1.Template.fromStack(stack).toJSON();
21
+ const hashes = options.ignoreAssetHashes
22
+ ? (0, assets_js_1.assetHashes)(stack)
23
+ : new Set();
24
+ return (0, normalize_js_1.normalize)(template, options, hashes);
25
+ }
@@ -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,16 @@
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
+ * `assetHashes` are the hashes {@link CdkTemplateOptions.ignoreAssetHashes}
14
+ * masks, as read from the stack's cloud assembly.
15
+ */
16
+ export declare function normalize(template: Template, options?: CdkTemplateOptions, assetHashes?: ReadonlySet<string>): Template;
@@ -0,0 +1,155 @@
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 assetHashRegex = /(?<![0-9a-f])[0-9a-f]{64}(?![0-9a-f])/g;
9
+ const maskedVersionSuffix = "x".repeat(32);
10
+ const maskedAssetHash = "<ASSET_HASH>";
11
+ /**
12
+ * Returns a copy of `template` with the configured normalizations applied. The
13
+ * argument is left untouched: `Template.fromStack` hands out the assembly's
14
+ * cached template object, so mutating it would corrupt every later assertion
15
+ * on the same stack.
16
+ *
17
+ * Step order is significant: earlier steps can remove structures that later
18
+ * ones inspect.
19
+ *
20
+ * `assetHashes` are the hashes {@link CdkTemplateOptions.ignoreAssetHashes}
21
+ * masks, as read from the stack's cloud assembly.
22
+ */
23
+ function normalize(template, options = {}, assetHashes = new Set()) {
24
+ const { ignoreAssets = false, ignoreAssetHashes = false, ignoreBootstrapVersion = true, ignoreCurrentVersion = false, ignoreMetadata = false, ignoreTags = false, ignorePipelineAssets = false, subsetResourceTypes, subsetResourceKeys, assetPlaceholder = placeholder_js_1.anyObject, } = options;
25
+ const result = structuredClone(template);
26
+ if (ignoreBootstrapVersion)
27
+ stripBootstrapVersion(result);
28
+ if (ignoreAssets)
29
+ stripAssets(result, assetPlaceholder);
30
+ if (ignoreAssetHashes)
31
+ maskAssetHashes(result, assetHashes);
32
+ if (ignoreCurrentVersion)
33
+ maskCurrentVersions(result);
34
+ if (ignorePipelineAssets)
35
+ maskPipelineAssets(result);
36
+ if (subsetResourceTypes) {
37
+ keepResources(result, (_key, resource) => subsetResourceTypes.includes(resource?.Type));
38
+ }
39
+ if (subsetResourceKeys) {
40
+ keepResources(result, (key) => subsetResourceKeys.includes(key));
41
+ }
42
+ if (ignoreMetadata)
43
+ stripMetadata(result);
44
+ if (ignoreTags)
45
+ stripTags(result);
46
+ return result;
47
+ }
48
+ function stripBootstrapVersion(template) {
49
+ const { Parameters, Rules } = template;
50
+ if (Parameters) {
51
+ delete Parameters.BootstrapVersion;
52
+ if (Object.keys(Parameters).length === 0)
53
+ delete template.Parameters;
54
+ }
55
+ if (Rules) {
56
+ delete Rules.CheckBootstrapVersion;
57
+ if (Object.keys(Rules).length === 0)
58
+ delete template.Rules;
59
+ }
60
+ }
61
+ function stripAssets(template, placeholder) {
62
+ if (!template.Resources)
63
+ return;
64
+ if (template.Parameters) {
65
+ template.Parameters = placeholder;
66
+ }
67
+ for (const resource of Object.values(template.Resources)) {
68
+ const properties = resource?.Properties;
69
+ if (!properties)
70
+ continue;
71
+ if (properties.Code) {
72
+ properties.Code = placeholder;
73
+ }
74
+ for (const definition of properties.ContainerDefinitions ?? []) {
75
+ definition.Image = placeholder;
76
+ }
77
+ }
78
+ }
79
+ /**
80
+ * Only a standalone 64-hex run is a candidate, so a longer hex string that
81
+ * happens to contain an asset hash is left intact.
82
+ */
83
+ function maskAssetHashes(tree, hashes) {
84
+ transformStrings(tree, (value) => value.replace(assetHashRegex, (hash) => hashes.has(hash) ? maskedAssetHash : hash));
85
+ }
86
+ function maskCurrentVersions(tree) {
87
+ transformStrings(tree, (value) => {
88
+ const match = currentVersionRegex.exec(value);
89
+ return match ? `${match[1]}${maskedVersionSuffix}` : value;
90
+ });
91
+ }
92
+ /**
93
+ * `cdk-assets ... publish "<hash>:<account>-<region>-<suffix>"` — the hash and
94
+ * the 8-hex suffix follow the asset's content, the account and region do not.
95
+ * CDK versions before the suffix emit `<hash>:<account>-<region>`.
96
+ */
97
+ function maskPipelineAssets(tree) {
98
+ transformStrings(tree, (value) => value.replace(pipelineCdkAssetsRegex, (_match, assemblyDir, asset) => {
99
+ const destination = assetDestinationRegex.exec(asset)?.[1] || "<ASSET_ID>";
100
+ return `cdk-assets --path "<${assemblyDir}>" --verbose publish "${destination}"`;
101
+ }));
102
+ }
103
+ /** Rewrites every string in `tree`, object keys included, in place. */
104
+ function transformStrings(tree, transform) {
105
+ if (tree == null || typeof tree !== "object")
106
+ return;
107
+ if (Array.isArray(tree)) {
108
+ for (let i = 0; i < tree.length; i++) {
109
+ const value = tree[i];
110
+ if (typeof value === "string") {
111
+ tree[i] = transform(value);
112
+ }
113
+ else {
114
+ transformStrings(value, transform);
115
+ }
116
+ }
117
+ return;
118
+ }
119
+ const record = tree;
120
+ for (const [key, value] of Object.entries(record)) {
121
+ const newKey = transform(key);
122
+ if (newKey !== key) {
123
+ record[newKey] = value;
124
+ delete record[key];
125
+ }
126
+ if (typeof value === "string") {
127
+ record[newKey] = transform(value);
128
+ }
129
+ else {
130
+ transformStrings(value, transform);
131
+ }
132
+ }
133
+ }
134
+ function keepResources(template, keep) {
135
+ if (!template.Resources)
136
+ return;
137
+ for (const [key, resource] of Object.entries(template.Resources)) {
138
+ if (!keep(key, resource)) {
139
+ delete template.Resources[key];
140
+ }
141
+ }
142
+ }
143
+ function stripMetadata(template) {
144
+ delete template.Metadata;
145
+ for (const resource of Object.values(template.Resources ?? {})) {
146
+ delete resource?.Metadata;
147
+ }
148
+ }
149
+ function stripTags(template) {
150
+ for (const resource of Object.values(template.Resources ?? {})) {
151
+ const properties = resource?.Properties;
152
+ if (properties?.Tags)
153
+ delete properties.Tags;
154
+ }
155
+ }
@@ -0,0 +1,61 @@
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
+ * Replace the hash of every asset in the app with `<ASSET_HASH>`, wherever
15
+ * a string in the template holds it: Lambda code and layers, container
16
+ * images, `BucketDeployment` sources, nested stack templates, CDK Pipelines
17
+ * commands. The rest of each value stays, and values that are not assets
18
+ * are untouched.
19
+ *
20
+ * The hashes are read from the asset manifests CDK's default synthesizer
21
+ * writes. A function using `currentVersion` also needs
22
+ * {@link CdkTemplateOptions.ignoreCurrentVersion}.
23
+ */
24
+ ignoreAssetHashes?: boolean;
25
+ /**
26
+ * Drop the CDK-managed `BootstrapVersion` parameter and its check rule.
27
+ * Defaults to `true`.
28
+ */
29
+ ignoreBootstrapVersion?: boolean;
30
+ /** Mask the content hash suffix on Lambda `CurrentVersion` logical IDs. */
31
+ ignoreCurrentVersion?: boolean;
32
+ /** Drop template and resource `Metadata`. */
33
+ ignoreMetadata?: boolean;
34
+ /**
35
+ * Drop each resource's `Tags` property. Tags nested deeper, such as a launch
36
+ * template's `TagSpecifications`, are kept.
37
+ */
38
+ ignoreTags?: boolean;
39
+ /**
40
+ * Mask asset paths, IDs and destination suffixes inside CDK Pipelines
41
+ * `cdk-assets` commands.
42
+ */
43
+ ignorePipelineAssets?: boolean;
44
+ /** Keep only resources of these CloudFormation types. */
45
+ subsetResourceTypes?: string[];
46
+ /** Keep only resources with these logical IDs. */
47
+ subsetResourceKeys?: string[];
48
+ /**
49
+ * Token substituted for asset-derived values. Defaults to a matcher
50
+ * serializing as `Any<Object>`; the Bun entry point overrides it.
51
+ */
52
+ assetPlaceholder?: unknown;
53
+ }
54
+ /** {@link CdkTemplateOptions} plus what only the snapshot matcher can apply. */
55
+ export interface CdkSnapshotOptions extends CdkTemplateOptions {
56
+ /**
57
+ * Property matchers handed to the runner's snapshot assertion, for values
58
+ * the normalizations do not cover.
59
+ */
60
+ propertyMatchers?: Record<string, unknown>;
61
+ }
@@ -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/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Template } from "aws-cdk-lib/assertions";
2
+ import { assetHashes } from "./assets.js";
2
3
  import { normalize } from "./normalize.js";
3
4
  export { anyObject } from "./placeholder.js";
4
5
  /**
@@ -11,5 +12,9 @@ export { anyObject } from "./placeholder.js";
11
12
  * Bun users should import this from `@liflig/cdk-snapshot/bun` instead.
12
13
  */
13
14
  export function cdkTemplate(stack, options = {}) {
14
- return normalize(Template.fromStack(stack).toJSON(), options);
15
+ const template = Template.fromStack(stack).toJSON();
16
+ const hashes = options.ignoreAssetHashes
17
+ ? assetHashes(stack)
18
+ : new Set();
19
+ return normalize(template, options, hashes);
15
20
  }
@@ -9,5 +9,8 @@ export type Template = Record<string, any>;
9
9
  *
10
10
  * Step order is significant: earlier steps can remove structures that later
11
11
  * ones inspect.
12
+ *
13
+ * `assetHashes` are the hashes {@link CdkTemplateOptions.ignoreAssetHashes}
14
+ * masks, as read from the stack's cloud assembly.
12
15
  */
13
- export declare function normalize(template: Template, options?: CdkTemplateOptions): Template;
16
+ export declare function normalize(template: Template, options?: CdkTemplateOptions, assetHashes?: ReadonlySet<string>): Template;
package/lib/normalize.js CHANGED
@@ -2,7 +2,9 @@ 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
4
  const assetDestinationRegex = /:(.*?)(?:-[0-9a-f]{8})?$/;
5
+ const assetHashRegex = /(?<![0-9a-f])[0-9a-f]{64}(?![0-9a-f])/g;
5
6
  const maskedVersionSuffix = "x".repeat(32);
7
+ const maskedAssetHash = "<ASSET_HASH>";
6
8
  /**
7
9
  * Returns a copy of `template` with the configured normalizations applied. The
8
10
  * argument is left untouched: `Template.fromStack` hands out the assembly's
@@ -11,14 +13,19 @@ const maskedVersionSuffix = "x".repeat(32);
11
13
  *
12
14
  * Step order is significant: earlier steps can remove structures that later
13
15
  * ones inspect.
16
+ *
17
+ * `assetHashes` are the hashes {@link CdkTemplateOptions.ignoreAssetHashes}
18
+ * masks, as read from the stack's cloud assembly.
14
19
  */
15
- export function normalize(template, options = {}) {
16
- const { ignoreAssets = false, ignoreBootstrapVersion = true, ignoreCurrentVersion = false, ignoreMetadata = false, ignoreTags = false, ignorePipelineAssets = false, subsetResourceTypes, subsetResourceKeys, assetPlaceholder = anyObject, } = options;
20
+ export function normalize(template, options = {}, assetHashes = new Set()) {
21
+ const { ignoreAssets = false, ignoreAssetHashes = false, ignoreBootstrapVersion = true, ignoreCurrentVersion = false, ignoreMetadata = false, ignoreTags = false, ignorePipelineAssets = false, subsetResourceTypes, subsetResourceKeys, assetPlaceholder = anyObject, } = options;
17
22
  const result = structuredClone(template);
18
23
  if (ignoreBootstrapVersion)
19
24
  stripBootstrapVersion(result);
20
25
  if (ignoreAssets)
21
26
  stripAssets(result, assetPlaceholder);
27
+ if (ignoreAssetHashes)
28
+ maskAssetHashes(result, assetHashes);
22
29
  if (ignoreCurrentVersion)
23
30
  maskCurrentVersions(result);
24
31
  if (ignorePipelineAssets)
@@ -66,6 +73,13 @@ function stripAssets(template, placeholder) {
66
73
  }
67
74
  }
68
75
  }
76
+ /**
77
+ * Only a standalone 64-hex run is a candidate, so a longer hex string that
78
+ * happens to contain an asset hash is left intact.
79
+ */
80
+ function maskAssetHashes(tree, hashes) {
81
+ transformStrings(tree, (value) => value.replace(assetHashRegex, (hash) => hashes.has(hash) ? maskedAssetHash : hash));
82
+ }
69
83
  function maskCurrentVersions(tree) {
70
84
  transformStrings(tree, (value) => {
71
85
  const match = currentVersionRegex.exec(value);
package/lib/options.d.ts CHANGED
@@ -10,6 +10,18 @@ export interface CdkTemplateOptions {
10
10
  * logical ID hashes the code.
11
11
  */
12
12
  ignoreAssets?: boolean;
13
+ /**
14
+ * Replace the hash of every asset in the app with `<ASSET_HASH>`, wherever
15
+ * a string in the template holds it: Lambda code and layers, container
16
+ * images, `BucketDeployment` sources, nested stack templates, CDK Pipelines
17
+ * commands. The rest of each value stays, and values that are not assets
18
+ * are untouched.
19
+ *
20
+ * The hashes are read from the asset manifests CDK's default synthesizer
21
+ * writes. A function using `currentVersion` also needs
22
+ * {@link CdkTemplateOptions.ignoreCurrentVersion}.
23
+ */
24
+ ignoreAssetHashes?: boolean;
13
25
  /**
14
26
  * Drop the CDK-managed `BootstrapVersion` parameter and its check rule.
15
27
  * Defaults to `true`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liflig/cdk-snapshot",
3
- "version": "1.0.2",
3
+ "version": "1.2.0",
4
4
  "description": "Normalizes synthesized AWS CDK stacks for snapshot testing",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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",