@cronn/lib-file-snapshots 0.1.0 → 0.3.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 +112 -7
  2. package/dist/index.js +255 -129
  3. package/package.json +5 -5
package/dist/index.d.ts CHANGED
@@ -1,15 +1,120 @@
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
+ * Directory in which golden masters are stored
68
+ *
69
+ * @default "data/test/validation"
70
+ */
71
+ validationDir?: string;
72
+ /**
73
+ * Directory in which file snapshots from test runs are stored
74
+ *
75
+ * @default "data/test/output"
76
+ */
77
+ outputDir?: string;
78
+ }
79
+ interface MatchValidationFileOptions {
80
+ /**
81
+ * The full path to the current test file
82
+ *
83
+ * @example "src/tests/feature.test.ts"
84
+ */
85
+ testPath: string;
86
+ /**
87
+ * The full path of titles describing the current test, including nested blocks
88
+ *
89
+ * @example ["test A", "when x, then y"]
90
+ */
91
+ titlePath: string[];
92
+ /**
93
+ * Unique name of the file snapshot
94
+ *
95
+ * Used to distinguish multiple file snapshots within the same `test`.
96
+ */
97
+ name?: string;
98
+ /**
99
+ * The serializer to use for the snapshot
100
+ */
101
+ serializer: SnapshotSerializer;
102
+ }
103
+ interface ValidationFileMatcherResult {
104
+ actual: string;
105
+ expected: string;
106
+ actualFile: string;
107
+ validationFile: string;
108
+ message: () => string;
109
+ }
12
110
 
13
- declare function normalizeTestName(testName: string): string;
111
+ declare class ValidationFileMatcher {
112
+ private readonly validationDir;
113
+ private readonly outputDir;
114
+ constructor(config?: ValidationFileMatcherConfig);
115
+ matchFileSnapshot(actual: unknown, options: MatchValidationFileOptions): ValidationFileMatcherResult;
116
+ private buildValidationFilePath;
117
+ private writeFileSnapshots;
118
+ }
14
119
 
15
- export { type SerializerResult, normalizeTestName, serializeAsJson, serializeAsText };
120
+ 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,262 @@ 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
+
208
+ // src/utils/file.ts
209
+ import fs from "fs";
210
+ var NEW_LINE_SEPARATOR = "\n";
211
+ function normalizeFileName(testName) {
212
+ return testName.replaceAll(/\+0/g, "0").replaceAll(/'([\w ]+)'/g, "$1").replaceAll(/[ .:']/g, "_").replaceAll(/,/g, "");
125
213
  }
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
- );
214
+ function mkdirRecursive(path2) {
215
+ fs.mkdirSync(path2, { recursive: true });
135
216
  }
136
- function normalizedValue(type, additionalProps = {}) {
137
- return { $type: type, ...additionalProps };
217
+ function readSnapshotFile(path2) {
218
+ return fs.readFileSync(path2, { encoding: "utf8" });
138
219
  }
139
- function assertKeyType(key) {
140
- if (!(typeof key === "string")) {
141
- throw new Error(`Key of type ${typeof key} cannot be normalized.`);
220
+ function writeSnapshotFile(file, data, markAsMissing = false) {
221
+ let finalizedData = addTrailingNewLine(data);
222
+ if (markAsMissing) {
223
+ finalizedData = addMissingFileMarker(finalizedData);
142
224
  }
225
+ fs.writeFileSync(file, finalizedData, { encoding: "utf8" });
143
226
  }
144
-
145
- // src/text-serializer.ts
146
- function serializeAsText(value) {
147
- return {
148
- value: normalizeValue2(value),
149
- fileExtension: "txt"
150
- };
227
+ function addTrailingNewLine(data) {
228
+ return `${data}${NEW_LINE_SEPARATOR}`;
151
229
  }
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
- );
230
+ function addMissingFileMarker(data) {
231
+ return `===== missing file =====${NEW_LINE_SEPARATOR}${data}`;
159
232
  }
160
233
 
161
- // src/normalizers.ts
162
- function normalizeTestName(testName) {
163
- return testName.replaceAll(/\+0/g, "0").replaceAll(/'([\w ]+)'/g, "$1").replaceAll(/[ .:']/g, "_").replaceAll(/,/g, "");
164
- }
234
+ // src/matcher/validation-file-matcher.ts
235
+ var ValidationFileMatcher = class {
236
+ validationDir;
237
+ outputDir;
238
+ constructor(config = {}) {
239
+ this.validationDir = config.validationDir ?? "data/test/validation";
240
+ this.outputDir = config.outputDir ?? "data/test/output";
241
+ }
242
+ matchFileSnapshot(actual, options) {
243
+ const { testPath, titlePath, name, serializer } = options;
244
+ if (!serializer.canSerialize(actual)) {
245
+ throw new Error(`Cannot serialize value of type ${typeof actual}`);
246
+ }
247
+ const serializerResult = serializer.serialize(actual);
248
+ const validationFilePath = this.buildValidationFilePath({
249
+ titlePath,
250
+ testPath,
251
+ name,
252
+ fileExtension: serializerResult.fileExtension
253
+ });
254
+ return this.writeFileSnapshots(
255
+ serializerResult.serializedValue,
256
+ validationFilePath
257
+ );
258
+ }
259
+ buildValidationFilePath(options) {
260
+ const { testPath, titlePath, name, fileExtension } = options;
261
+ const normalizedTitlePath = titlePath.map(normalizeFileName);
262
+ const normalizedFileName = name !== void 0 ? `_${normalizeFileName(name)}` : "";
263
+ const normalizedTestName = normalizedTitlePath.pop();
264
+ const validationFilePath = path.join(testPath, ...normalizedTitlePath);
265
+ const validationFileName = `${normalizedTestName}${normalizedFileName}.${fileExtension}`;
266
+ return path.join(validationFilePath, validationFileName);
267
+ }
268
+ writeFileSnapshots(actual, validationFilePath) {
269
+ const validationFileDir = path.dirname(validationFilePath);
270
+ const testOutputDir = `${this.outputDir}/${validationFileDir}`;
271
+ const actualFile = `${this.outputDir}/${validationFilePath}`;
272
+ const testValidationDir = `${this.validationDir}/${validationFileDir}`;
273
+ const validationFile = `${this.validationDir}/${validationFilePath}`;
274
+ mkdirRecursive(testOutputDir);
275
+ mkdirRecursive(testValidationDir);
276
+ if (!fs2.existsSync(validationFile)) {
277
+ writeSnapshotFile(validationFile, actual, true);
278
+ }
279
+ writeSnapshotFile(actualFile, actual);
280
+ return {
281
+ actual: readSnapshotFile(actualFile),
282
+ expected: readSnapshotFile(validationFile),
283
+ actualFile,
284
+ validationFile,
285
+ message: () => `Actual file '${actualFile}'
286
+ does not match validation file '${validationFile}'`
287
+ };
288
+ }
289
+ };
165
290
  export {
166
- normalizeTestName,
167
- serializeAsJson,
168
- serializeAsText
291
+ CompositeSerializer,
292
+ JsonSerializer,
293
+ TextSerializer,
294
+ ValidationFileMatcher
169
295
  };
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.3.0",
4
4
  "description": "The library agnostic core for testing file snapshots",
5
5
  "keywords": [
6
6
  "file snapshots"
@@ -33,11 +33,11 @@
33
33
  ],
34
34
  "devDependencies": {
35
35
  "@biomejs/biome": "1.9.4",
36
- "@types/node": "22.15.21",
37
- "@vitest/coverage-istanbul": "3.1.4",
36
+ "@types/node": "22.15.30",
37
+ "@vitest/coverage-istanbul": "3.2.3",
38
38
  "tsup": "8.5.0",
39
39
  "typescript": "5.8.3",
40
- "vitest": "3.1.4",
40
+ "vitest": "3.2.3",
41
41
  "@cronn/shared-configs": "0.0.0"
42
42
  },
43
43
  "scripts": {
@@ -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
  }