@cronn/lib-file-snapshots 0.1.0 → 0.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.
Files changed (3) hide show
  1. package/dist/index.d.ts +117 -7
  2. package/dist/index.js +263 -129
  3. package/package.json +3 -3
package/dist/index.d.ts CHANGED
@@ -1,15 +1,125 @@
1
- interface SerializerResult {
2
- value: string;
1
+ interface SnapshotSerializer {
2
+ /**
3
+ * Returns true when value can be serialized
4
+ *
5
+ * @param value The value to be serialized
6
+ */
7
+ canSerialize(value: unknown): boolean;
8
+ /**
9
+ * Serializes value
10
+ *
11
+ * @param value The value to be serialized
12
+ * @throws {Error} Will throw an error if value cannot be serialized.
13
+ */
14
+ serialize(value: unknown): SnapshotSerializerResult;
15
+ }
16
+ interface SnapshotSerializerResult {
17
+ /**
18
+ * The serialized value
19
+ */
20
+ serializedValue: string;
21
+ /**
22
+ * The file extension associated with the serialized value
23
+ */
3
24
  fileExtension: string;
4
25
  }
5
26
 
27
+ declare class CompositeSerializer implements SnapshotSerializer {
28
+ private readonly serializers;
29
+ constructor(serializers: SnapshotSerializer[]);
30
+ canSerialize(value: unknown): boolean;
31
+ serialize(value: unknown): SnapshotSerializerResult;
32
+ }
33
+
6
34
  interface JsonSerializerOptions {
7
- includeUndefinedObjectProperties: boolean;
35
+ /**
36
+ * Serializes `undefined` properties in objects. By default, they are omitted.
37
+ *
38
+ * @default false
39
+ */
40
+ includeUndefinedObjectProperties?: boolean;
41
+ }
42
+ declare class JsonSerializer implements SnapshotSerializer {
43
+ private readonly includeUndefinedObjectProperties;
44
+ constructor(options?: JsonSerializerOptions);
45
+ canSerialize(value: unknown): boolean;
46
+ serialize(value: unknown): SnapshotSerializerResult;
47
+ private normalizeValueRecursive;
48
+ private normalizeValue;
49
+ private normalizeNumber;
50
+ private normalizeArray;
51
+ private normalizeObject;
52
+ private normalizeDate;
53
+ private normalizePlainObject;
54
+ private normalizeMap;
55
+ private serializedValue;
56
+ private assertKeyType;
57
+ }
58
+
59
+ declare class TextSerializer implements SnapshotSerializer {
60
+ canSerialize(value: unknown): value is string;
61
+ serialize(value: unknown): SnapshotSerializerResult;
62
+ private normalizeValue;
8
63
  }
9
- declare function serializeAsJson(value: unknown, options: JsonSerializerOptions): SerializerResult;
10
64
 
11
- declare function serializeAsText(value: unknown): SerializerResult;
65
+ interface ValidationFileMatcherConfig {
66
+ /**
67
+ * Base directory for tests
68
+ *
69
+ * The paths of snapshot files will be relative to this directory.
70
+ * @default "."
71
+ */
72
+ baseDir?: string;
73
+ /**
74
+ * Directory in which golden masters are stored
75
+ *
76
+ * @default "data/test/validation"
77
+ */
78
+ validationDir?: string;
79
+ /**
80
+ * Directory in which file snapshots from test runs are stored
81
+ *
82
+ * @default "data/test/output"
83
+ */
84
+ outputDir?: string;
85
+ }
86
+ interface MatchValidationFileOptions {
87
+ /**
88
+ * The full name of the test, including the names of nested describe blocks
89
+ *
90
+ * @example ["test feature", "when x, then y"]
91
+ */
92
+ testName: string[];
93
+ /**
94
+ * The directory in which the current test is located
95
+ */
96
+ testDir: string;
97
+ /**
98
+ * Appends `fileSuffix` to the generated snapshot file
99
+ *
100
+ * Should be used whenever having multiple snapshot assertions in a single `test`.
101
+ */
102
+ fileSuffix?: string;
103
+ /**
104
+ * The serializer to use for the snapshot
105
+ */
106
+ serializer: SnapshotSerializer;
107
+ }
108
+ interface ValidationFileMatcherResult {
109
+ actual: string;
110
+ expected: string;
111
+ actualFile: string;
112
+ validationFile: string;
113
+ }
12
114
 
13
- declare function normalizeTestName(testName: string): string;
115
+ declare class ValidationFileMatcher {
116
+ private readonly baseDir;
117
+ private readonly validationDir;
118
+ private readonly outputDir;
119
+ constructor(config?: ValidationFileMatcherConfig);
120
+ matchFileSnapshot(actual: unknown, options: MatchValidationFileOptions): ValidationFileMatcherResult;
121
+ private buildValidationFilePath;
122
+ private writeFileSnapshots;
123
+ }
14
124
 
15
- export { type SerializerResult, normalizeTestName, serializeAsJson, serializeAsText };
125
+ export { CompositeSerializer, JsonSerializer, TextSerializer, ValidationFileMatcher };
package/dist/index.js CHANGED
@@ -1,4 +1,25 @@
1
- // src/guards.ts
1
+ // src/serializers/composite-serializer.ts
2
+ var CompositeSerializer = class {
3
+ serializers;
4
+ constructor(serializers) {
5
+ this.serializers = serializers;
6
+ }
7
+ canSerialize(value) {
8
+ return this.serializers.some(
9
+ (serializer) => serializer.canSerialize(value)
10
+ );
11
+ }
12
+ serialize(value) {
13
+ for (const serializer of this.serializers) {
14
+ if (serializer.canSerialize(value)) {
15
+ return serializer.serialize(value);
16
+ }
17
+ }
18
+ throw new Error(`Missing serializer for value of type ${typeof value}.`);
19
+ }
20
+ };
21
+
22
+ // src/utils/guards.ts
2
23
  function isArray(value) {
3
24
  return Array.isArray(value);
4
25
  }
@@ -13,157 +34,270 @@ function isPlainObject(value) {
13
34
  return proto === null || proto === Object.prototype;
14
35
  }
15
36
 
16
- // src/json-serializer.ts
17
- function serializeAsJson(value, options) {
18
- const jsonValue = isPlainObject(value) || isArray(value) ? normalizeValueRecursive(value, options) : normalizeValue(value, options);
19
- const serializedValue = JSON.stringify(jsonValue, void 0, 2);
20
- return {
21
- value: serializedValue,
22
- fileExtension: "json"
23
- };
24
- }
25
- function normalizeValueRecursive(value, options) {
26
- if (isArray(value)) {
27
- return normalizeArray(value, options);
37
+ // src/serializers/json-serializer.ts
38
+ var JsonSerializer = class {
39
+ includeUndefinedObjectProperties;
40
+ constructor(options = {}) {
41
+ this.includeUndefinedObjectProperties = options.includeUndefinedObjectProperties ?? false;
28
42
  }
29
- if (isPlainObject(value)) {
30
- return normalizePlainObject(value, options);
43
+ canSerialize(value) {
44
+ return true;
31
45
  }
32
- if (typeof value === "string" || typeof value === "boolean" || value === null) {
33
- return value;
46
+ serialize(value) {
47
+ const jsonValue = isPlainObject(value) || isArray(value) ? this.normalizeValueRecursive(value) : this.normalizeValue(value);
48
+ const serializedValue = JSON.stringify(jsonValue, void 0, 2);
49
+ return {
50
+ serializedValue,
51
+ fileExtension: "json"
52
+ };
34
53
  }
35
- return normalizeValue(value, options);
36
- }
37
- function normalizeValue(value, options) {
38
- if (value === void 0) {
39
- return normalizedValue("undefined");
40
- }
41
- if (value === null || typeof value === "string" || typeof value === "boolean") {
42
- return normalizedValue(typeof value, { value });
43
- }
44
- if (typeof value === "number") {
45
- return normalizeNumber(value);
54
+ normalizeValueRecursive(value) {
55
+ if (isArray(value)) {
56
+ return this.normalizeArray(value);
57
+ }
58
+ if (isPlainObject(value)) {
59
+ return this.normalizePlainObject(value);
60
+ }
61
+ if (typeof value === "string" || typeof value === "boolean" || value === null) {
62
+ return value;
63
+ }
64
+ return this.normalizeValue(value);
46
65
  }
47
- if (typeof value === "bigint") {
48
- return normalizedValue("bigint", { value: value.toString() });
66
+ normalizeValue(value) {
67
+ if (value === void 0) {
68
+ return this.serializedValue("undefined");
69
+ }
70
+ if (value === null || typeof value === "string" || typeof value === "boolean") {
71
+ return this.serializedValue(typeof value, { value });
72
+ }
73
+ if (typeof value === "number") {
74
+ return this.normalizeNumber(value);
75
+ }
76
+ if (typeof value === "bigint") {
77
+ return this.serializedValue("bigint", { value: value.toString() });
78
+ }
79
+ if (typeof value === "symbol") {
80
+ return this.serializedValue("symbol", { description: value.description });
81
+ }
82
+ if (typeof value === "function") {
83
+ return this.serializedValue("function", { name: value.name });
84
+ }
85
+ if (typeof value === "object") {
86
+ return this.normalizeObject(value);
87
+ }
88
+ throw new Error(
89
+ `Missing JSON normalization for value of type ${typeof value}.`
90
+ );
49
91
  }
50
- if (typeof value === "symbol") {
51
- return normalizedValue("symbol", { description: value.description });
92
+ normalizeNumber(value) {
93
+ if (Number.isNaN(value)) {
94
+ return this.serializedValue("number", { value: "Number.NaN" });
95
+ }
96
+ switch (value) {
97
+ case Number.MIN_VALUE:
98
+ return this.serializedValue("number", { value: "Number.MIN_VALUE" });
99
+ case Number.MAX_VALUE:
100
+ return this.serializedValue("number", { value: "Number.MAX_VALUE" });
101
+ case Number.MIN_SAFE_INTEGER:
102
+ return this.serializedValue("number", {
103
+ value: "Number.MIN_SAFE_INTEGER"
104
+ });
105
+ case Number.MAX_SAFE_INTEGER:
106
+ return this.serializedValue("number", {
107
+ value: "Number.MAX_SAFE_INTEGER"
108
+ });
109
+ case Number.NEGATIVE_INFINITY:
110
+ return this.serializedValue("number", {
111
+ value: "Number.NEGATIVE_INFINITY"
112
+ });
113
+ case Number.POSITIVE_INFINITY:
114
+ return this.serializedValue("number", {
115
+ value: "Number.POSITIVE_INFINITY"
116
+ });
117
+ default:
118
+ return value;
119
+ }
52
120
  }
53
- if (typeof value === "function") {
54
- return normalizedValue("function", { name: value.name });
121
+ normalizeArray(value) {
122
+ return value.map((item) => this.normalizeValueRecursive(item));
55
123
  }
56
- if (typeof value === "object") {
57
- return normalizeObject(value, options);
124
+ normalizeObject(value) {
125
+ if (value instanceof Date) {
126
+ return this.normalizeDate(value);
127
+ }
128
+ if (value instanceof Promise) {
129
+ return this.serializedValue("Promise");
130
+ }
131
+ if (value instanceof Set) {
132
+ return this.serializedValue("Set", {
133
+ values: this.normalizeArray(Array.from(value.values()))
134
+ });
135
+ }
136
+ if (value instanceof Map) {
137
+ const mapAsObject = this.normalizeMap(value);
138
+ return this.serializedValue("Map", {
139
+ values: this.normalizePlainObject(mapAsObject)
140
+ });
141
+ }
142
+ throw new Error(
143
+ `Missing JSON normalization for object of type ${Object.getPrototypeOf(value)}`
144
+ );
58
145
  }
59
- throw new Error(
60
- `Missing JSON normalization for value of type ${typeof value}.`
61
- );
62
- }
63
- function normalizeNumber(value) {
64
- if (Number.isNaN(value)) {
65
- return normalizedValue("number", { value: "Number.NaN" });
66
- }
67
- switch (value) {
68
- case Number.MIN_VALUE:
69
- return normalizedValue("number", { value: "Number.MIN_VALUE" });
70
- case Number.MAX_VALUE:
71
- return normalizedValue("number", { value: "Number.MAX_VALUE" });
72
- case Number.MIN_SAFE_INTEGER:
73
- return normalizedValue("number", { value: "Number.MIN_SAFE_INTEGER" });
74
- case Number.MAX_SAFE_INTEGER:
75
- return normalizedValue("number", { value: "Number.MAX_SAFE_INTEGER" });
76
- case Number.NEGATIVE_INFINITY:
77
- return normalizedValue("number", { value: "Number.NEGATIVE_INFINITY" });
78
- case Number.POSITIVE_INFINITY:
79
- return normalizedValue("number", { value: "Number.POSITIVE_INFINITY" });
80
- default:
81
- return value;
146
+ normalizeDate(value) {
147
+ if (Number.isNaN(value.getTime())) {
148
+ return this.serializedValue("Date", { value: "Invalid date" });
149
+ }
150
+ return this.serializedValue("Date", { value: value.toISOString() });
82
151
  }
83
- }
84
- function normalizeArray(value, options) {
85
- return value.map((item) => normalizeValueRecursive(item, options));
86
- }
87
- function normalizeObject(value, options) {
88
- if (value instanceof Date) {
89
- return normalizeDate(value);
152
+ normalizePlainObject(value) {
153
+ const normalizedObject = {};
154
+ for (const [key, propertyValue] of Object.entries(value)) {
155
+ if (propertyValue === void 0 && !this.includeUndefinedObjectProperties) {
156
+ continue;
157
+ }
158
+ this.assertKeyType(key);
159
+ normalizedObject[key] = this.normalizeValueRecursive(propertyValue);
160
+ }
161
+ return normalizedObject;
90
162
  }
91
- if (value instanceof Promise) {
92
- return normalizedValue("Promise");
163
+ normalizeMap(value) {
164
+ return Array.from(value.entries()).reduce(
165
+ (object, [key, value2]) => {
166
+ this.assertKeyType(key);
167
+ object[key] = value2;
168
+ return object;
169
+ },
170
+ {}
171
+ );
93
172
  }
94
- if (value instanceof Set) {
95
- return normalizedValue("Set", {
96
- values: normalizeArray(Array.from(value.values()), options)
97
- });
173
+ serializedValue(type, additionalProps = {}) {
174
+ return { $type: type, ...additionalProps };
98
175
  }
99
- if (value instanceof Map) {
100
- const mapAsObject = normalizeMap(value);
101
- return normalizedValue("Map", {
102
- values: normalizePlainObject(mapAsObject, options)
103
- });
176
+ assertKeyType(key) {
177
+ if (!(typeof key === "string")) {
178
+ throw new Error(`Key of type ${typeof key} cannot be normalized.`);
179
+ }
104
180
  }
105
- throw new Error(
106
- `Missing JSON normalization for object of type ${Object.getPrototypeOf(value)}`
107
- );
108
- }
109
- function normalizeDate(value) {
110
- if (Number.isNaN(value.getTime())) {
111
- return normalizedValue("Date", { value: "Invalid date" });
181
+ };
182
+
183
+ // src/serializers/text-serializer.ts
184
+ var TextSerializer = class {
185
+ canSerialize(value) {
186
+ return typeof value === "string";
112
187
  }
113
- return normalizedValue("Date", { value: value.toISOString() });
114
- }
115
- function normalizePlainObject(value, options) {
116
- const normalizedObject = {};
117
- for (const [key, propertyValue] of Object.entries(value)) {
118
- if (propertyValue === void 0 && !options.includeUndefinedObjectProperties) {
119
- continue;
188
+ serialize(value) {
189
+ if (!this.canSerialize(value)) {
190
+ throw new Error(
191
+ `Missing text serialization for value of type ${typeof value}.`
192
+ );
120
193
  }
121
- assertKeyType(key);
122
- normalizedObject[key] = normalizeValueRecursive(propertyValue, options);
194
+ return {
195
+ serializedValue: this.normalizeValue(value),
196
+ fileExtension: "txt"
197
+ };
198
+ }
199
+ normalizeValue(value) {
200
+ return value.trim();
123
201
  }
124
- return normalizedObject;
202
+ };
203
+
204
+ // src/matcher/validation-file-matcher.ts
205
+ import * as fs2 from "fs";
206
+ import * as path from "path";
207
+ import "vitest";
208
+
209
+ // src/utils/file.ts
210
+ import fs from "fs";
211
+ var NEW_LINE_SEPARATOR = "\n";
212
+ function normalizeFileName(testName) {
213
+ return testName.replaceAll(/\+0/g, "0").replaceAll(/'([\w ]+)'/g, "$1").replaceAll(/[ .:']/g, "_").replaceAll(/,/g, "");
125
214
  }
126
- function normalizeMap(value) {
127
- return Array.from(value.entries()).reduce(
128
- (object, [key, value2]) => {
129
- assertKeyType(key);
130
- object[key] = value2;
131
- return object;
132
- },
133
- {}
134
- );
215
+ function mkdirRecursive(path2) {
216
+ fs.mkdirSync(path2, { recursive: true });
135
217
  }
136
- function normalizedValue(type, additionalProps = {}) {
137
- return { $type: type, ...additionalProps };
218
+ function readSnapshotFile(path2) {
219
+ return fs.readFileSync(path2, { encoding: "utf8" });
138
220
  }
139
- function assertKeyType(key) {
140
- if (!(typeof key === "string")) {
141
- throw new Error(`Key of type ${typeof key} cannot be normalized.`);
221
+ function writeSnapshotFile(file, data, markAsMissing = false) {
222
+ let finalizedData = addTrailingNewLine(data);
223
+ if (markAsMissing) {
224
+ finalizedData = addMissingFileMarker(finalizedData);
142
225
  }
226
+ fs.writeFileSync(file, finalizedData, { encoding: "utf8" });
143
227
  }
144
-
145
- // src/text-serializer.ts
146
- function serializeAsText(value) {
147
- return {
148
- value: normalizeValue2(value),
149
- fileExtension: "txt"
150
- };
228
+ function addTrailingNewLine(data) {
229
+ return `${data}${NEW_LINE_SEPARATOR}`;
151
230
  }
152
- function normalizeValue2(value) {
153
- if (typeof value === "string") {
154
- return value.trim();
155
- }
156
- throw new Error(
157
- `Missing text normalization for value of type ${typeof value}.`
158
- );
231
+ function addMissingFileMarker(data) {
232
+ return `===== missing file =====${NEW_LINE_SEPARATOR}${data}`;
159
233
  }
160
234
 
161
- // src/normalizers.ts
162
- function normalizeTestName(testName) {
163
- return testName.replaceAll(/\+0/g, "0").replaceAll(/'([\w ]+)'/g, "$1").replaceAll(/[ .:']/g, "_").replaceAll(/,/g, "");
164
- }
235
+ // src/matcher/validation-file-matcher.ts
236
+ var ValidationFileMatcher = class {
237
+ baseDir;
238
+ validationDir;
239
+ outputDir;
240
+ constructor(config = {}) {
241
+ this.baseDir = config.baseDir ?? ".";
242
+ this.validationDir = config.validationDir ?? "data/test/validation";
243
+ this.outputDir = config.outputDir ?? "data/test/output";
244
+ }
245
+ matchFileSnapshot(actual, options) {
246
+ const { testName, testDir, fileSuffix, serializer } = options;
247
+ if (!serializer.canSerialize(actual)) {
248
+ throw new Error(`Cannot serialize value of type ${typeof actual}`);
249
+ }
250
+ const serializerResult = serializer.serialize(actual);
251
+ const validationFilePath = this.buildValidationFilePath({
252
+ testName,
253
+ testDir,
254
+ fileSuffix,
255
+ fileExtension: serializerResult.fileExtension
256
+ });
257
+ return this.writeFileSnapshots(
258
+ serializerResult.serializedValue,
259
+ validationFilePath
260
+ );
261
+ }
262
+ buildValidationFilePath(options) {
263
+ const { testName, testDir, fileSuffix, fileExtension } = options;
264
+ const normalizedTestNames = testName.map(normalizeFileName);
265
+ const normalizedFileSuffix = fileSuffix !== void 0 ? `_${normalizeFileName(fileSuffix)}` : "";
266
+ const normalizedTestName = normalizedTestNames.pop();
267
+ const absoluteValidationFilePath = path.join(
268
+ testDir,
269
+ ...normalizedTestNames
270
+ );
271
+ const relativeValidationFilePath = path.relative(
272
+ this.baseDir,
273
+ absoluteValidationFilePath
274
+ );
275
+ const validationFileName = `${normalizedTestName}${normalizedFileSuffix}.${fileExtension}`;
276
+ return path.join(relativeValidationFilePath, validationFileName);
277
+ }
278
+ writeFileSnapshots(actual, validationFilePath) {
279
+ const validationFileDir = path.dirname(validationFilePath);
280
+ const testOutputDir = `${this.outputDir}/${validationFileDir}`;
281
+ const actualFile = `${this.outputDir}/${validationFilePath}`;
282
+ const testValidationDir = `${this.validationDir}/${validationFileDir}`;
283
+ const validationFile = `${this.validationDir}/${validationFilePath}`;
284
+ mkdirRecursive(testOutputDir);
285
+ mkdirRecursive(testValidationDir);
286
+ if (!fs2.existsSync(validationFile)) {
287
+ writeSnapshotFile(validationFile, actual, true);
288
+ }
289
+ writeSnapshotFile(actualFile, actual);
290
+ return {
291
+ actual: readSnapshotFile(actualFile),
292
+ expected: readSnapshotFile(validationFile),
293
+ actualFile,
294
+ validationFile
295
+ };
296
+ }
297
+ };
165
298
  export {
166
- normalizeTestName,
167
- serializeAsJson,
168
- serializeAsText
299
+ CompositeSerializer,
300
+ JsonSerializer,
301
+ TextSerializer,
302
+ ValidationFileMatcher
169
303
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cronn/lib-file-snapshots",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "The library agnostic core for testing file snapshots",
5
5
  "keywords": [
6
6
  "file snapshots"
@@ -33,7 +33,7 @@
33
33
  ],
34
34
  "devDependencies": {
35
35
  "@biomejs/biome": "1.9.4",
36
- "@types/node": "22.15.21",
36
+ "@types/node": "22.15.29",
37
37
  "@vitest/coverage-istanbul": "3.1.4",
38
38
  "tsup": "8.5.0",
39
39
  "typescript": "5.8.3",
@@ -47,6 +47,6 @@
47
47
  "test:coverage": "vitest run --coverage",
48
48
  "compile": "tsc",
49
49
  "build": "tsup",
50
- "ci": "biome ci . && pnpm run compile & pnpm run test:coverage && pnpm run build"
50
+ "ci": "biome ci . && pnpm run compile && pnpm run test:coverage && pnpm run build"
51
51
  }
52
52
  }