@aurabx/jmix-js 0.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/.ai/security.md +184 -0
- package/.eslintrc.cjs +23 -0
- package/.idea/jmix-ts.iml +13 -0
- package/.idea/modules.xml +8 -0
- package/.idea/vcs.xml +6 -0
- package/.prettierrc +9 -0
- package/README.md +335 -0
- package/WARP.md +143 -0
- package/dist/JmixBuilder.d.ts +82 -0
- package/dist/JmixBuilder.d.ts.map +1 -0
- package/dist/JmixBuilder.js +353 -0
- package/dist/JmixBuilder.js.map +1 -0
- package/dist/crypto/PayloadDecryptor.d.ts +12 -0
- package/dist/crypto/PayloadDecryptor.d.ts.map +1 -0
- package/dist/crypto/PayloadDecryptor.js +39 -0
- package/dist/crypto/PayloadDecryptor.js.map +1 -0
- package/dist/crypto/PayloadEncryptor.d.ts +21 -0
- package/dist/crypto/PayloadEncryptor.d.ts.map +1 -0
- package/dist/crypto/PayloadEncryptor.js +79 -0
- package/dist/crypto/PayloadEncryptor.js.map +1 -0
- package/dist/demo-decrypt-existing.js +27 -0
- package/dist/demo-decrypt.js +39 -0
- package/dist/demo-no-validation.js +95 -0
- package/dist/demo-package-encrypted.js +34 -0
- package/dist/demo-package.js +28 -0
- package/dist/demo-verify-hash.js +30 -0
- package/dist/demo.js +73 -0
- package/dist/dicom/DicomProcessor.d.ts +40 -0
- package/dist/dicom/DicomProcessor.d.ts.map +1 -0
- package/dist/dicom/DicomProcessor.js +231 -0
- package/dist/dicom/DicomProcessor.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/types/index.d.ts +165 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +31 -0
- package/dist/types/index.js.map +1 -0
- package/dist/validation/SchemaValidator.d.ts +25 -0
- package/dist/validation/SchemaValidator.d.ts.map +1 -0
- package/dist/validation/SchemaValidator.js +125 -0
- package/dist/validation/SchemaValidator.js.map +1 -0
- package/jest.config.json +20 -0
- package/package.json +74 -0
- package/samples/sample_config.json +52 -0
- package/samples/study_1/series_1/CT.1.1.dcm +0 -0
- package/samples/study_1/series_1/CT.1.2.dcm +0 -0
- package/samples/study_1/series_1/CT.1.3.dcm +0 -0
- package/samples/study_1/series_1/CT.1.4.dcm +0 -0
- package/samples/study_1/series_1/CT.1.5.dcm +0 -0
- package/samples/study_1/series_2/CT.2.1.dcm +0 -0
- package/samples/study_1/series_2/CT.2.2.dcm +0 -0
- package/samples/study_1/series_2/CT.2.3.dcm +0 -0
- package/samples/study_1/series_2/CT.2.4.dcm +0 -0
- package/samples/study_1/series_2/CT.2.5.dcm +0 -0
- package/samples/study_1/series_3/CT.3.1.dcm +0 -0
- package/samples/study_1/series_3/CT.3.2.dcm +0 -0
- package/samples/study_1/series_3/CT.3.3.dcm +0 -0
- package/samples/study_1/series_3/CT.3.4.dcm +0 -0
- package/samples/study_1/series_3/CT.3.5.dcm +0 -0
- package/src/JmixBuilder.ts +572 -0
- package/src/crypto/PayloadDecryptor.ts +45 -0
- package/src/crypto/PayloadEncryptor.ts +97 -0
- package/src/dicom/DicomProcessor.ts +262 -0
- package/src/index.ts +30 -0
- package/src/types/daikon.d.ts +1 -0
- package/src/types/index.ts +201 -0
- package/src/validation/SchemaValidator.ts +156 -0
- package/test-encrypt.js +10 -0
- package/tests/JmixBuilder.test.ts +170 -0
- package/tests/SchemaValidator.test.ts +196 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { JmixBuilder, Config } from '../src/index.js';
|
|
2
|
+
import * as fs from 'fs/promises';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
|
|
5
|
+
describe('JmixBuilder', () => {
|
|
6
|
+
let builder: JmixBuilder;
|
|
7
|
+
const tempDir = './tmp/test';
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
builder = new JmixBuilder({ outputPath: tempDir });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
// Clean up temp files
|
|
15
|
+
try {
|
|
16
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
17
|
+
} catch {
|
|
18
|
+
// Ignore cleanup errors
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('constructor', () => {
|
|
23
|
+
it('should create a JmixBuilder instance', () => {
|
|
24
|
+
expect(builder).toBeInstanceOf(JmixBuilder);
|
|
25
|
+
expect(builder.getValidator()).toBeDefined();
|
|
26
|
+
expect(builder.getDicomProcessor()).toBeDefined();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should use custom options', () => {
|
|
30
|
+
const customBuilder = new JmixBuilder({
|
|
31
|
+
outputPath: './custom-tmp',
|
|
32
|
+
schemaValidatorOptions: {
|
|
33
|
+
schemaPath: './custom-schemas',
|
|
34
|
+
strictMode: false,
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
expect(customBuilder).toBeInstanceOf(JmixBuilder);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
describe('loadConfig', () => {
|
|
43
|
+
it('should load configuration from sample file', async () => {
|
|
44
|
+
const sampleConfigPath = path.resolve('./samples/sample_config.json');
|
|
45
|
+
|
|
46
|
+
// Check if sample config exists
|
|
47
|
+
try {
|
|
48
|
+
await fs.access(sampleConfigPath);
|
|
49
|
+
const config = await JmixBuilder.loadConfig(sampleConfigPath);
|
|
50
|
+
|
|
51
|
+
expect(config.version).toBeDefined();
|
|
52
|
+
expect(config.sender).toBeDefined();
|
|
53
|
+
expect(config.patient).toBeDefined();
|
|
54
|
+
} catch {
|
|
55
|
+
console.warn('Sample config file not found, skipping test');
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should throw error for invalid config path', async () => {
|
|
60
|
+
await expect(
|
|
61
|
+
JmixBuilder.loadConfig('./invalid/path.json')
|
|
62
|
+
).rejects.toThrow('Failed to load configuration');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('buildFromDicom', () => {
|
|
67
|
+
it('should build envelope from empty directory with config fallback', async () => {
|
|
68
|
+
const config = await JmixBuilder.loadConfig(path.resolve('./samples/sample_config.json'));
|
|
69
|
+
const testDir = path.resolve('./samples/study_1');
|
|
70
|
+
|
|
71
|
+
// Ensure directory exists (may be empty in some environments)
|
|
72
|
+
await fs.mkdir(testDir, { recursive: true });
|
|
73
|
+
|
|
74
|
+
const envelope = await builder.buildFromDicom(testDir, config);
|
|
75
|
+
|
|
76
|
+
expect(envelope.manifest).toBeDefined();
|
|
77
|
+
expect(envelope.metadata).toBeDefined();
|
|
78
|
+
expect(envelope.audit).toBeDefined();
|
|
79
|
+
|
|
80
|
+
expect(envelope.manifest.jmix_version).toBe(config.version);
|
|
81
|
+
expect(envelope.manifest.sender.name).toBe(config.sender.name);
|
|
82
|
+
expect(envelope.manifest.patient.name).toBe(config.patient.name);
|
|
83
|
+
|
|
84
|
+
expect(envelope.metadata.patient.name).toBe(config.patient.name);
|
|
85
|
+
expect(Array.isArray(envelope.metadata.dicom.modalities)).toBe(true);
|
|
86
|
+
|
|
87
|
+
expect(Array.isArray(envelope.audit.audit)).toBe(true);
|
|
88
|
+
expect(envelope.audit.audit.length).toBeGreaterThanOrEqual(1);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should handle non-existent directory gracefully', async () => {
|
|
92
|
+
const config = await JmixBuilder.loadConfig(path.resolve('./samples/sample_config.json'));
|
|
93
|
+
|
|
94
|
+
await expect(
|
|
95
|
+
builder.buildFromDicom('./non-existent-dir', config)
|
|
96
|
+
).rejects.toThrow('Failed to build JMIX envelope');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('saveToFiles', () => {
|
|
101
|
+
it('should save envelope to JSON files', async () => {
|
|
102
|
+
const config = await JmixBuilder.loadConfig(path.resolve('./samples/sample_config.json'));
|
|
103
|
+
const testDir = path.resolve('./samples/study_1');
|
|
104
|
+
const outputDir = path.join(tempDir, 'output');
|
|
105
|
+
|
|
106
|
+
// Ensure directory exists (may be empty in some environments)
|
|
107
|
+
await fs.mkdir(testDir, { recursive: true });
|
|
108
|
+
|
|
109
|
+
const envelope = await builder.buildFromDicom(testDir, config);
|
|
110
|
+
await builder.saveToFiles(envelope, outputDir);
|
|
111
|
+
|
|
112
|
+
// Check that files were created
|
|
113
|
+
const manifestPath = path.join(outputDir, 'manifest.json');
|
|
114
|
+
const metadataPath = path.join(outputDir, 'metadata.json');
|
|
115
|
+
const auditPath = path.join(outputDir, 'audit.json');
|
|
116
|
+
|
|
117
|
+
await expect(fs.access(manifestPath)).resolves.not.toThrow();
|
|
118
|
+
await expect(fs.access(metadataPath)).resolves.not.toThrow();
|
|
119
|
+
await expect(fs.access(auditPath)).resolves.not.toThrow();
|
|
120
|
+
|
|
121
|
+
// Verify file contents are valid JSON
|
|
122
|
+
const manifestContent = await fs.readFile(manifestPath, 'utf-8');
|
|
123
|
+
const metadataContent = await fs.readFile(metadataPath, 'utf-8');
|
|
124
|
+
const auditContent = await fs.readFile(auditPath, 'utf-8');
|
|
125
|
+
|
|
126
|
+
expect(() => JSON.parse(manifestContent)).not.toThrow();
|
|
127
|
+
expect(() => JSON.parse(metadataContent)).not.toThrow();
|
|
128
|
+
expect(() => JSON.parse(auditContent)).not.toThrow();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should use default output path when none specified', async () => {
|
|
132
|
+
const config = await JmixBuilder.loadConfig(path.resolve('./samples/sample_config.json'));
|
|
133
|
+
const testDir = path.resolve('./samples/study_1');
|
|
134
|
+
|
|
135
|
+
// Ensure directory exists (may be empty in some environments)
|
|
136
|
+
await fs.mkdir(testDir, { recursive: true });
|
|
137
|
+
|
|
138
|
+
const envelope = await builder.buildFromDicom(testDir, config);
|
|
139
|
+
await builder.saveToFiles(envelope); // No output path specified
|
|
140
|
+
|
|
141
|
+
// Check that files were created in default path
|
|
142
|
+
const defaultOutputPath = tempDir; // From constructor
|
|
143
|
+
const manifestPath = path.join(defaultOutputPath, 'manifest.json');
|
|
144
|
+
|
|
145
|
+
await expect(fs.access(manifestPath)).resolves.not.toThrow();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('integration', () => {
|
|
150
|
+
it('should create a complete JMIX envelope with realistic data', async () => {
|
|
151
|
+
const config: Config = await JmixBuilder.loadConfig(path.resolve('./samples/sample_config.json'));
|
|
152
|
+
|
|
153
|
+
const testDir = path.resolve('./samples/study_1');
|
|
154
|
+
await fs.mkdir(testDir, { recursive: true });
|
|
155
|
+
|
|
156
|
+
const envelope = await builder.buildFromDicom(testDir, config);
|
|
157
|
+
|
|
158
|
+
// Verify envelope structure
|
|
159
|
+
expect(envelope.manifest.custom_tags).toEqual(['radiology', 'urgent']);
|
|
160
|
+
expect(envelope.manifest.consent?.status).toBe('granted');
|
|
161
|
+
expect(envelope.manifest.patient.identifiers).toHaveLength(1);
|
|
162
|
+
|
|
163
|
+
expect(envelope.metadata.patient.sex).toBe('M');
|
|
164
|
+
expect(typeof envelope.metadata.dicom.patient_name).toBe('string');
|
|
165
|
+
|
|
166
|
+
expect(Array.isArray(envelope.audit.audit)).toBe(true);
|
|
167
|
+
expect(envelope.audit.audit.length).toBeGreaterThanOrEqual(1);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { SchemaValidator, ValidationError } from '../src/index.js';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
|
|
4
|
+
describe('SchemaValidator', () => {
|
|
5
|
+
let validator: SchemaValidator;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
validator = new SchemaValidator();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe('constructor', () => {
|
|
12
|
+
it('should create a validator with default options', () => {
|
|
13
|
+
expect(validator).toBeInstanceOf(SchemaValidator);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('should use custom schema path from options', () => {
|
|
17
|
+
const customValidator = new SchemaValidator({
|
|
18
|
+
schemaPath: './custom-schemas',
|
|
19
|
+
strictMode: false,
|
|
20
|
+
});
|
|
21
|
+
expect(customValidator).toBeInstanceOf(SchemaValidator);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should use schema path from environment variable', () => {
|
|
25
|
+
const originalEnv = process.env.JMIX_SCHEMA_PATH;
|
|
26
|
+
process.env.JMIX_SCHEMA_PATH = './env-schemas';
|
|
27
|
+
|
|
28
|
+
const envValidator = new SchemaValidator();
|
|
29
|
+
expect(envValidator).toBeInstanceOf(SchemaValidator);
|
|
30
|
+
|
|
31
|
+
// Restore original environment
|
|
32
|
+
if (originalEnv) {
|
|
33
|
+
process.env.JMIX_SCHEMA_PATH = originalEnv;
|
|
34
|
+
} else {
|
|
35
|
+
delete process.env.JMIX_SCHEMA_PATH;
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('isSchemaAvailable', () => {
|
|
41
|
+
it('should check if schema files are available', async () => {
|
|
42
|
+
const available = await validator.isSchemaAvailable();
|
|
43
|
+
// This may be false if ../jmix/schemas doesn't exist, which is fine
|
|
44
|
+
expect(typeof available).toBe('boolean');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should check specific schema availability', async () => {
|
|
48
|
+
const manifestAvailable = await validator.isSchemaAvailable('manifest');
|
|
49
|
+
expect(typeof manifestAvailable).toBe('boolean');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('validation methods', () => {
|
|
54
|
+
const sampleManifest = {
|
|
55
|
+
jmix_version: '1.0',
|
|
56
|
+
envelope_id: '12345678-1234-5678-9012-123456789012',
|
|
57
|
+
created_at: new Date().toISOString(),
|
|
58
|
+
sender: {
|
|
59
|
+
name: 'Test Sender',
|
|
60
|
+
id: 'org:test.sender',
|
|
61
|
+
contact: 'test@sender.org',
|
|
62
|
+
},
|
|
63
|
+
receivers: [
|
|
64
|
+
{
|
|
65
|
+
name: 'Test Receiver',
|
|
66
|
+
id: 'org:test.receiver',
|
|
67
|
+
contact: 'test@receiver.org',
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
patient: {
|
|
71
|
+
name: 'Test Patient',
|
|
72
|
+
id: 'PAT001',
|
|
73
|
+
},
|
|
74
|
+
security: {
|
|
75
|
+
classification: 'confidential',
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const sampleMetadata = {
|
|
80
|
+
patient: {
|
|
81
|
+
name: 'Test Patient',
|
|
82
|
+
id: 'PAT001',
|
|
83
|
+
},
|
|
84
|
+
study: {
|
|
85
|
+
description: 'Test Study',
|
|
86
|
+
},
|
|
87
|
+
dicom: {
|
|
88
|
+
modalities: ['CT'],
|
|
89
|
+
series_count: 1,
|
|
90
|
+
instance_count: 5,
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const sampleAudit = {
|
|
95
|
+
audit: [
|
|
96
|
+
{
|
|
97
|
+
event: 'envelope_created',
|
|
98
|
+
by: { id: 'org:test.sender', name: 'Test Sender' },
|
|
99
|
+
to: { id: 'org:test.receiver', name: 'Test Receiver' },
|
|
100
|
+
timestamp: new Date().toISOString(),
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
it('should validate manifest without throwing when schemas not available', async () => {
|
|
106
|
+
// This should not throw even if schemas are missing
|
|
107
|
+
await expect(
|
|
108
|
+
validator.validateManifest(sampleManifest)
|
|
109
|
+
).resolves.not.toThrow();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should validate metadata without throwing when schemas not available', async () => {
|
|
113
|
+
await expect(
|
|
114
|
+
validator.validateMetadata(sampleMetadata)
|
|
115
|
+
).resolves.not.toThrow();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should validate audit without throwing when schemas not available', async () => {
|
|
119
|
+
await expect(validator.validateAudit(sampleAudit)).resolves.not.toThrow();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should validate complete envelope', async () => {
|
|
123
|
+
const envelope = {
|
|
124
|
+
manifest: sampleManifest,
|
|
125
|
+
metadata: sampleMetadata,
|
|
126
|
+
audit: sampleAudit,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
await expect(validator.validateEnvelope(envelope)).resolves.not.toThrow();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should handle invalid data gracefully when schemas not available', async () => {
|
|
133
|
+
const invalidManifest = {
|
|
134
|
+
invalid: 'data',
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// Should not throw when schemas are not available
|
|
138
|
+
await expect(
|
|
139
|
+
validator.validateManifest(invalidManifest)
|
|
140
|
+
).resolves.not.toThrow();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('error handling', () => {
|
|
145
|
+
it('should handle ValidationError properly', () => {
|
|
146
|
+
const error = new ValidationError('Test validation error', [
|
|
147
|
+
'Field is required',
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
expect(error).toBeInstanceOf(ValidationError);
|
|
151
|
+
expect(error.message).toBe('Test validation error');
|
|
152
|
+
expect(error.errors).toEqual(['Field is required']);
|
|
153
|
+
expect(error.name).toBe('ValidationError');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should create ValidationError with default empty errors', () => {
|
|
157
|
+
const error = new ValidationError('Test error');
|
|
158
|
+
|
|
159
|
+
expect(error.errors).toEqual([]);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('with custom schema path', () => {
|
|
164
|
+
it('should use custom schema path', () => {
|
|
165
|
+
const customPath = path.resolve('./custom-schemas');
|
|
166
|
+
const customValidator = new SchemaValidator({
|
|
167
|
+
schemaPath: customPath,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(customValidator).toBeInstanceOf(SchemaValidator);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should handle non-existent custom schema path gracefully', async () => {
|
|
174
|
+
const customValidator = new SchemaValidator({
|
|
175
|
+
schemaPath: './non-existent-schemas',
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const sampleData = { test: 'data' };
|
|
179
|
+
|
|
180
|
+
// Should not throw when schemas don't exist
|
|
181
|
+
await expect(
|
|
182
|
+
customValidator.validateManifest(sampleData)
|
|
183
|
+
).resolves.not.toThrow();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('strict mode', () => {
|
|
188
|
+
it('should work in non-strict mode', () => {
|
|
189
|
+
const nonStrictValidator = new SchemaValidator({
|
|
190
|
+
strictMode: false,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
expect(nonStrictValidator).toBeInstanceOf(SchemaValidator);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "node",
|
|
6
|
+
"esModuleInterop": true,
|
|
7
|
+
"allowSyntheticDefaultImports": true,
|
|
8
|
+
"forceConsistentCasingInFileNames": true,
|
|
9
|
+
"strict": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"outDir": "dist",
|
|
12
|
+
"rootDir": "src",
|
|
13
|
+
"declaration": true,
|
|
14
|
+
"declarationMap": true,
|
|
15
|
+
"sourceMap": true,
|
|
16
|
+
"lib": ["ES2022"],
|
|
17
|
+
"types": ["node", "jest"]
|
|
18
|
+
},
|
|
19
|
+
"include": ["src/**/*"],
|
|
20
|
+
"exclude": ["dist", "node_modules", "tests", "__tests__", "**/*.test.ts"]
|
|
21
|
+
}
|