@liflig/cdk-snapshot 1.0.2 → 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
@@ -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
 
@@ -205,15 +207,17 @@ make ci # what the CI workflow runs: refuses a stale lockfile, fails on an
205
207
  ```
206
208
 
207
209
  `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.
210
+ runners, Jest once as ESM and once as CommonJS, which `test/compat.test.ts` then compares
211
+ against each other.
209
212
 
210
213
  `make compat-check` runs only the four runners and fails if their snapshots changed. CI
211
214
  runs it on the oldest Node that `engines` in `package.json` allows.
212
215
 
213
216
  ## Migrating from jest-cdk-snapshot
214
217
 
215
- Change the import. Call sites and `.snap` files stay as they are, since the options, their
216
- 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.
217
221
 
218
222
  ```diff
219
223
  -import "jest-cdk-snapshot"
@@ -224,9 +228,6 @@ Verified against two public CDK libraries, liflig-cdk and cdk-cloudfront-auth: e
224
228
  existing snapshot passes under `jest --ci`, and a forced `--updateSnapshot` rewrites
225
229
  nothing.
226
230
 
227
- Jest now has to run with ESM support enabled, since this package is ESM — see
228
- [Jest](#jest).
229
-
230
231
  One option is gone. `yaml` is not supported, so a project snapshotting YAML has to
231
232
  regenerate as JSON.
232
233
 
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liflig/cdk-snapshot",
3
- "version": "1.0.2",
3
+ "version": "1.1.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",