@liflig/cdk-snapshot 0.0.1 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/bun.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { Stack } from "aws-cdk-lib";
2
+ import type { CdkSnapshotOptions, CdkTemplateOptions } from "./options.js";
3
+ export type { CdkSnapshotOptions, CdkTemplateOptions } from "./options.js";
4
+ declare module "bun:test" {
5
+ interface Matchers<T> {
6
+ toMatchCdkSnapshot(options?: CdkSnapshotOptions): T;
7
+ }
8
+ }
9
+ /**
10
+ * {@link cdkTemplateCore} with an asset placeholder Bun's serializer accepts.
11
+ *
12
+ * Bun recognizes only matchers created by its own `expect`.
13
+ */
14
+ export declare function cdkTemplate(stack: Stack, options?: CdkTemplateOptions): Record<string, unknown>;
package/lib/bun.js ADDED
@@ -0,0 +1,15 @@
1
+ import { expect } from "bun:test";
2
+ import { cdkTemplate as cdkTemplateCore } from "./index.js";
3
+ import { registerCdkMatcher } from "./matcher.js";
4
+ /**
5
+ * {@link cdkTemplateCore} with an asset placeholder Bun's serializer accepts.
6
+ *
7
+ * Bun recognizes only matchers created by its own `expect`.
8
+ */
9
+ export function cdkTemplate(stack, options = {}) {
10
+ return cdkTemplateCore(stack, {
11
+ assetPlaceholder: expect.any(Object),
12
+ ...options,
13
+ });
14
+ }
15
+ registerCdkMatcher(expect, cdkTemplate);
package/lib/index.d.ts ADDED
@@ -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>;
package/lib/index.js ADDED
@@ -0,0 +1,15 @@
1
+ import { Template } from "aws-cdk-lib/assertions";
2
+ import { normalize } from "./normalize.js";
3
+ export { anyObject } from "./placeholder.js";
4
+ /**
5
+ * Synthesizes `stack` to a CloudFormation template with deployment noise
6
+ * removed, ready to hand to a snapshot assertion.
7
+ *
8
+ * The stack is left untouched, so it can be synthesized again with different
9
+ * options.
10
+ *
11
+ * Bun users should import this from `@liflig/cdk-snapshot/bun` instead.
12
+ */
13
+ export function cdkTemplate(stack, options = {}) {
14
+ return normalize(Template.fromStack(stack).toJSON(), options);
15
+ }
package/lib/jest.d.ts ADDED
@@ -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
+ }
package/lib/jest.js ADDED
@@ -0,0 +1,4 @@
1
+ import { cdkTemplate } from "./index.js";
2
+ import { registerCdkMatcher, requireExpect } from "./matcher.js";
3
+ export { cdkTemplate } from "./index.js";
4
+ registerCdkMatcher(requireExpect("Jest", globalThis.expect), cdkTemplate);
@@ -0,0 +1,22 @@
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
+ }
10
+ export type TemplateFn = (stack: Stack, options?: CdkTemplateOptions) => Record<string, unknown>;
11
+ /**
12
+ * Registers `toMatchCdkSnapshot` on the runner's `expect`.
13
+ *
14
+ * The matcher delegates to the runner's own snapshot assertion, so snapshots
15
+ * keep the naming and format that runner already produces.
16
+ */
17
+ export declare function registerCdkMatcher(expect: ExpectLike, cdkTemplate: TemplateFn): void;
18
+ /**
19
+ * Narrows the `expect` a runner injected to the shape the matcher needs, or
20
+ * explains what to do when the runner injected nothing.
21
+ */
22
+ export declare function requireExpect(runner: string, injected: unknown): ExpectLike;
package/lib/matcher.js ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Registers `toMatchCdkSnapshot` on the runner's `expect`.
3
+ *
4
+ * The matcher delegates to the runner's own snapshot assertion, so snapshots
5
+ * keep the naming and format that runner already produces.
6
+ */
7
+ export function registerCdkMatcher(expect, cdkTemplate) {
8
+ expect.extend({
9
+ toMatchCdkSnapshot(received, options = {}) {
10
+ if (this?.isNot) {
11
+ throw new Error("toMatchCdkSnapshot cannot be negated with `.not`.");
12
+ }
13
+ const { propertyMatchers, ...templateOptions } = options;
14
+ const assertion = expect(cdkTemplate(received, templateOptions));
15
+ if (propertyMatchers) {
16
+ assertion.toMatchSnapshot(propertyMatchers);
17
+ }
18
+ else {
19
+ assertion.toMatchSnapshot();
20
+ }
21
+ return { pass: true, message: () => "" };
22
+ },
23
+ });
24
+ }
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 function requireExpect(runner, injected) {
30
+ const candidate = injected;
31
+ if (typeof candidate?.extend !== "function") {
32
+ throw new Error(`@liflig/cdk-snapshot: ${runner} did not inject a global \`expect\`. Enable global injection, or use cdkTemplate() directly.`);
33
+ }
34
+ return candidate;
35
+ }
package/lib/node.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ export { cdkTemplate } from "./index.js";
2
+ export type { CdkSnapshotOptions, CdkTemplateOptions } from "./options.js";
3
+ /**
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.
6
+ *
7
+ * Call once, before any test runs.
8
+ */
9
+ export declare function configureCdkSnapshots(): void;
package/lib/node.js ADDED
@@ -0,0 +1,19 @@
1
+ import path from "node:path";
2
+ import { snapshot } from "node:test";
3
+ import { serialize } from "./serialize.js";
4
+ export { cdkTemplate } from "./index.js";
5
+ /**
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.
8
+ *
9
+ * Call once, before any test runs.
10
+ */
11
+ export function configureCdkSnapshots() {
12
+ snapshot.setResolveSnapshotPath((testPath) => {
13
+ if (!testPath) {
14
+ throw new Error("@liflig/cdk-snapshot: snapshots require tests to be run from a file.");
15
+ }
16
+ return path.join(path.dirname(testPath), "__snapshots__", `${path.basename(testPath)}.snap`);
17
+ });
18
+ snapshot.setDefaultSnapshotSerializers([serialize]);
19
+ }
@@ -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,137 @@
1
+ import { anyObject } from "./placeholder.js";
2
+ const currentVersionRegex = /^(.+CurrentVersion[0-9A-F]{8})[0-9a-f]{32}$/;
3
+ const pipelineCdkAssetsRegex = /cdk-assets\s+--path\s+\\"([^\\/]+)\/.+?assets\.json\\"\s+--verbose\s+publish\s+\\"(.+?)\\"/g;
4
+ const assetDestinationRegex = /:(.*)$/;
5
+ const maskedVersionSuffix = "x".repeat(32);
6
+ /**
7
+ * Returns a copy of `template` with the configured normalizations applied. The
8
+ * argument is left untouched: `Template.fromStack` hands out the assembly's
9
+ * cached template object, so mutating it would corrupt every later assertion
10
+ * on the same stack.
11
+ *
12
+ * Step order is significant: earlier steps can remove structures that later
13
+ * ones inspect.
14
+ */
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;
17
+ const result = structuredClone(template);
18
+ if (ignoreBootstrapVersion)
19
+ stripBootstrapVersion(result);
20
+ if (ignoreAssets)
21
+ stripAssets(result, assetPlaceholder);
22
+ if (ignoreCurrentVersion)
23
+ maskCurrentVersions(result);
24
+ if (ignorePipelineAssets)
25
+ maskPipelineAssets(result);
26
+ if (subsetResourceTypes) {
27
+ keepResources(result, (_key, resource) => subsetResourceTypes.includes(resource?.Type));
28
+ }
29
+ if (subsetResourceKeys) {
30
+ keepResources(result, (key) => subsetResourceKeys.includes(key));
31
+ }
32
+ if (ignoreMetadata)
33
+ stripMetadata(result);
34
+ if (ignoreTags)
35
+ stripTags(result);
36
+ return result;
37
+ }
38
+ function stripBootstrapVersion(template) {
39
+ const { Parameters, Rules } = template;
40
+ if (Parameters) {
41
+ delete Parameters.BootstrapVersion;
42
+ if (Object.keys(Parameters).length === 0)
43
+ delete template.Parameters;
44
+ }
45
+ if (Rules) {
46
+ delete Rules.CheckBootstrapVersion;
47
+ if (Object.keys(Rules).length === 0)
48
+ delete template.Rules;
49
+ }
50
+ }
51
+ function stripAssets(template, placeholder) {
52
+ if (!template.Resources)
53
+ return;
54
+ if (template.Parameters) {
55
+ template.Parameters = placeholder;
56
+ }
57
+ for (const resource of Object.values(template.Resources)) {
58
+ const properties = resource?.Properties;
59
+ if (!properties)
60
+ continue;
61
+ if (properties.Code) {
62
+ properties.Code = placeholder;
63
+ }
64
+ for (const definition of properties.ContainerDefinitions ?? []) {
65
+ definition.Image = placeholder;
66
+ }
67
+ }
68
+ }
69
+ function maskCurrentVersions(tree) {
70
+ transformStrings(tree, (value) => {
71
+ const match = currentVersionRegex.exec(value);
72
+ return match ? `${match[1]}${maskedVersionSuffix}` : value;
73
+ });
74
+ }
75
+ /**
76
+ * `cdk-assets ... publish "<hash>:<destination>"` — the hash changes on every
77
+ * synth, the destination does not.
78
+ */
79
+ function maskPipelineAssets(tree) {
80
+ transformStrings(tree, (value) => value.replace(pipelineCdkAssetsRegex, (_match, assemblyDir, asset) => {
81
+ const destination = assetDestinationRegex.exec(asset)?.[1] || "<ASSET_ID>";
82
+ return `cdk-assets --path "<${assemblyDir}>" --verbose publish "${destination}"`;
83
+ }));
84
+ }
85
+ /** Rewrites every string in `tree`, object keys included, in place. */
86
+ function transformStrings(tree, transform) {
87
+ if (tree == null || typeof tree !== "object")
88
+ return;
89
+ if (Array.isArray(tree)) {
90
+ for (let i = 0; i < tree.length; i++) {
91
+ const value = tree[i];
92
+ if (typeof value === "string") {
93
+ tree[i] = transform(value);
94
+ }
95
+ else {
96
+ transformStrings(value, transform);
97
+ }
98
+ }
99
+ return;
100
+ }
101
+ const record = tree;
102
+ for (const [key, value] of Object.entries(record)) {
103
+ const newKey = transform(key);
104
+ if (newKey !== key) {
105
+ record[newKey] = value;
106
+ delete record[key];
107
+ }
108
+ if (typeof value === "string") {
109
+ record[newKey] = transform(value);
110
+ }
111
+ else {
112
+ transformStrings(value, transform);
113
+ }
114
+ }
115
+ }
116
+ function keepResources(template, keep) {
117
+ if (!template.Resources)
118
+ return;
119
+ for (const [key, resource] of Object.entries(template.Resources)) {
120
+ if (!keep(key, resource)) {
121
+ delete template.Resources[key];
122
+ }
123
+ }
124
+ }
125
+ function stripMetadata(template) {
126
+ delete template.Metadata;
127
+ for (const resource of Object.values(template.Resources ?? {})) {
128
+ delete resource?.Metadata;
129
+ }
130
+ }
131
+ function stripTags(template) {
132
+ for (const resource of Object.values(template.Resources ?? {})) {
133
+ const properties = resource?.Properties;
134
+ if (properties?.Tags)
135
+ delete properties.Tags;
136
+ }
137
+ }
@@ -0,0 +1,37 @@
1
+ export interface CdkTemplateOptions {
2
+ /**
3
+ * Replace asset-derived values — Lambda `Code`, container `Image`, and the
4
+ * template parameters carrying asset hashes — with {@link anyObject}.
5
+ */
6
+ ignoreAssets?: boolean;
7
+ /**
8
+ * Drop the CDK-managed `BootstrapVersion` parameter and its check rule.
9
+ * Defaults to `true`.
10
+ */
11
+ ignoreBootstrapVersion?: boolean;
12
+ /** Mask the content hash suffix on Lambda `CurrentVersion` logical IDs. */
13
+ ignoreCurrentVersion?: boolean;
14
+ /** Drop template and resource `Metadata`. */
15
+ ignoreMetadata?: boolean;
16
+ /** Drop `Tags` from resource properties. */
17
+ ignoreTags?: boolean;
18
+ /** Mask asset paths and IDs inside CDK Pipelines `cdk-assets` commands. */
19
+ ignorePipelineAssets?: boolean;
20
+ /** Keep only resources of these CloudFormation types. */
21
+ subsetResourceTypes?: string[];
22
+ /** Keep only resources with these logical IDs. */
23
+ subsetResourceKeys?: string[];
24
+ /**
25
+ * Token substituted for asset-derived values. Defaults to a matcher
26
+ * serializing as `Any<Object>`; the Bun entry point overrides it.
27
+ */
28
+ assetPlaceholder?: unknown;
29
+ }
30
+ /** {@link CdkTemplateOptions} plus what only the snapshot matcher can apply. */
31
+ export interface CdkSnapshotOptions extends CdkTemplateOptions {
32
+ /**
33
+ * Property matchers handed to the runner's snapshot assertion, for values
34
+ * the normalizations do not cover.
35
+ */
36
+ propertyMatchers?: Record<string, unknown>;
37
+ }
package/lib/options.js ADDED
File without changes
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Stand-in for values that change on every synth, such as asset hashes.
3
+ * Serializes as `Any<Object>` so snapshots match across test runners.
4
+ *
5
+ * Bun accepts only matchers built by its own `expect`; the Bun entry point
6
+ * substitutes one.
7
+ */
8
+ export declare const anyObject: unknown;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Stand-in for values that change on every synth, such as asset hashes.
3
+ * Serializes as `Any<Object>` so snapshots match across test runners.
4
+ *
5
+ * Bun accepts only matchers built by its own `expect`; the Bun entry point
6
+ * substitutes one.
7
+ */
8
+ export const anyObject = {
9
+ $$typeof: Symbol.for("jest.asymmetricMatcher"),
10
+ asymmetricMatch: (actual) => typeof actual === "object" && actual !== null,
11
+ toString: () => "Any",
12
+ getExpectedType: () => "Object",
13
+ toAsymmetricMatcher: () => "Any<Object>",
14
+ };
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Serializes a value the way Jest, Vitest and Bun serialize snapshots.
3
+ *
4
+ * `node:test` formats with `JSON.stringify` by default, which would make its
5
+ * snapshots incompatible with the other runners.
6
+ */
7
+ export declare function serialize(value: unknown): string;
@@ -0,0 +1,17 @@
1
+ import { format, plugins } from "pretty-format";
2
+ /**
3
+ * Serializes a value the way Jest, Vitest and Bun serialize snapshots.
4
+ *
5
+ * `node:test` formats with `JSON.stringify` by default, which would make its
6
+ * snapshots incompatible with the other runners.
7
+ */
8
+ export function serialize(value) {
9
+ return format(value, {
10
+ escapeRegex: true,
11
+ escapeString: false,
12
+ indent: 2,
13
+ printBasicPrototype: false,
14
+ printFunctionName: false,
15
+ plugins: [plugins.AsymmetricMatcher],
16
+ });
17
+ }
@@ -0,0 +1,8 @@
1
+ import type { CdkSnapshotOptions } from "./options.js";
2
+ export { cdkTemplate } from "./index.js";
3
+ export type { CdkSnapshotOptions, CdkTemplateOptions } from "./options.js";
4
+ declare module "vitest" {
5
+ interface Matchers<T = any> {
6
+ toMatchCdkSnapshot(options?: CdkSnapshotOptions): T;
7
+ }
8
+ }
package/lib/vitest.js ADDED
@@ -0,0 +1,5 @@
1
+ import { expect } from "vitest";
2
+ import { cdkTemplate } from "./index.js";
3
+ import { registerCdkMatcher } from "./matcher.js";
4
+ export { cdkTemplate } from "./index.js";
5
+ registerCdkMatcher(expect, cdkTemplate);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liflig/cdk-snapshot",
3
- "version": "0.0.1",
3
+ "version": "1.0.1",
4
4
  "description": "Normalizes synthesized AWS CDK stacks for snapshot testing",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -84,18 +84,19 @@
84
84
  "pretty-format": "^29.7.0"
85
85
  },
86
86
  "devDependencies": {
87
- "@biomejs/biome": "2.5.10",
87
+ "@biomejs/biome": "2.5.11",
88
88
  "@types/bun": "1.4.0",
89
89
  "@types/jest": "30.0.0",
90
90
  "@types/node": "24.13.3",
91
- "aws-cdk-lib": "2.266.0",
91
+ "aws-cdk-lib": "2.267.0",
92
92
  "constructs": "10.8.1",
93
- "jest": "30.4.2",
93
+ "jest": "30.5.0",
94
94
  "semantic-release": "25.0.9",
95
95
  "typescript": "7.0.2",
96
96
  "vitest": "4.1.11"
97
97
  },
98
98
  "publishConfig": {
99
- "access": "public"
99
+ "access": "public",
100
+ "provenance": true
100
101
  }
101
102
  }