react-native-config-ultimate 0.0.1 → 0.0.5
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 +23 -11
- package/android/build.gradle +13 -0
- package/android/rncu.gradle +24 -4
- package/android/src/main/java/com/reactnativeultimateconfig/UltimateConfigPackage.java +3 -3
- package/android/src/newarch/java/com/reactnativeultimateconfig/UltimateConfigModule.java +102 -0
- package/android/src/{main → oldarch}/java/com/reactnativeultimateconfig/UltimateConfigModule.java +38 -3
- package/index.js +21 -2
- package/index.ts +24 -5
- package/ios/ConfigValues.h +2 -1
- package/ios/UltimateConfig.mm +16 -2
- package/package.json +15 -5
- package/react-native-config-ultimate.podspec +5 -15
- package/src/NativeUltimateConfig.ts +10 -4
- package/src/cli.js +4 -5
- package/src/cli.ts +5 -13
- package/src/load-env.ts +2 -6
- package/src/render-env.js +6 -3
- package/src/render-env.ts +12 -16
- package/src/validate-env.js +8 -2
- package/src/validate-env.ts +15 -12
- package/src/write-env.js +7 -4
- package/src/write-env.ts +11 -4
- package/src/bin.spec.ts +0 -36
- package/src/cli.spec.ts +0 -224
- package/src/flatten.spec.ts +0 -16
- package/src/load-env.spec.ts +0 -163
- package/src/main.spec.ts +0 -171
- package/src/resolve-env.spec.ts +0 -25
- package/src/validate-env.spec.ts +0 -164
- package/src/write-env.spec.ts +0 -105
package/src/load-env.spec.ts
DELETED
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
const mockReadFileSync = jest.fn();
|
|
2
|
-
jest.doMock('fs', () => ({ readFileSync: mockReadFileSync }));
|
|
3
|
-
|
|
4
|
-
const mockParse = jest.fn();
|
|
5
|
-
jest.doMock('dotenv', () => ({ parse: mockParse }));
|
|
6
|
-
|
|
7
|
-
const mockExpand = jest.fn();
|
|
8
|
-
jest.doMock('dotenv-expand', () => ({ expand: mockExpand }));
|
|
9
|
-
|
|
10
|
-
const mockYaml = jest.fn();
|
|
11
|
-
jest.doMock('js-yaml', () => ({ load: mockYaml }));
|
|
12
|
-
|
|
13
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
14
|
-
const load_env: (paths: string | string[]) => Record<string, unknown> =
|
|
15
|
-
require('./load-env').default;
|
|
16
|
-
|
|
17
|
-
describe('load-env', () => {
|
|
18
|
-
beforeEach(() => {
|
|
19
|
-
mockReadFileSync.mockReset();
|
|
20
|
-
mockParse.mockReset();
|
|
21
|
-
mockExpand.mockReset();
|
|
22
|
-
mockYaml.mockReset();
|
|
23
|
-
// Default expand: return parsed as-is (no expansion side effects)
|
|
24
|
-
mockExpand.mockImplementation(
|
|
25
|
-
(input: { parsed: Record<string, string> }) => input
|
|
26
|
-
);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
describe('dotenv format', () => {
|
|
30
|
-
it('reads a single dotenv file (backward-compatible string arg)', () => {
|
|
31
|
-
mockReadFileSync.mockReturnValueOnce('hello=world');
|
|
32
|
-
mockParse.mockReturnValueOnce({ hello: 'world' });
|
|
33
|
-
const result = load_env('hello');
|
|
34
|
-
expect(mockReadFileSync).toHaveBeenCalledWith('hello', 'utf8');
|
|
35
|
-
expect(mockParse).toHaveBeenCalledWith('hello=world');
|
|
36
|
-
expect(result).toEqual({ hello: 'world' });
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it('reads a single dotenv file when passed as an array', () => {
|
|
40
|
-
mockReadFileSync.mockReturnValueOnce('hello=world');
|
|
41
|
-
mockParse.mockReturnValueOnce({ hello: 'world' });
|
|
42
|
-
const result = load_env(['hello']);
|
|
43
|
-
expect(mockReadFileSync).toHaveBeenCalledWith('hello', 'utf8');
|
|
44
|
-
expect(result).toEqual({ hello: 'world' });
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it('merges multiple dotenv files, last file wins for conflicts', () => {
|
|
48
|
-
mockReadFileSync
|
|
49
|
-
.mockReturnValueOnce('A=base\nB=base')
|
|
50
|
-
.mockReturnValueOnce('B=override\nC=new');
|
|
51
|
-
mockParse
|
|
52
|
-
.mockReturnValueOnce({ A: 'base', B: 'base' })
|
|
53
|
-
.mockReturnValueOnce({ B: 'override', C: 'new' });
|
|
54
|
-
const result = load_env(['.env.base', '.env.staging']);
|
|
55
|
-
expect(mockReadFileSync).toHaveBeenCalledTimes(2);
|
|
56
|
-
// expand is called once with the merged raw object
|
|
57
|
-
expect(mockExpand).toHaveBeenCalledWith({
|
|
58
|
-
parsed: { A: 'base', B: 'override', C: 'new' },
|
|
59
|
-
});
|
|
60
|
-
expect(result).toEqual({ A: 'base', B: 'override', C: 'new' });
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('expands $VAR references using dotenv-expand', () => {
|
|
64
|
-
mockReadFileSync.mockReturnValueOnce('BASE=https://api.com\nURL=$BASE/v1');
|
|
65
|
-
mockParse.mockReturnValueOnce({ BASE: 'https://api.com', URL: '$BASE/v1' });
|
|
66
|
-
mockExpand.mockReturnValueOnce({
|
|
67
|
-
parsed: { BASE: 'https://api.com', URL: 'https://api.com/v1' },
|
|
68
|
-
});
|
|
69
|
-
const result = load_env('.env');
|
|
70
|
-
expect(result).toEqual({
|
|
71
|
-
BASE: 'https://api.com',
|
|
72
|
-
URL: 'https://api.com/v1',
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('expands cross-file $VAR references when merging multiple files', () => {
|
|
77
|
-
mockReadFileSync
|
|
78
|
-
.mockReturnValueOnce('BASE_URL=https://api.com')
|
|
79
|
-
.mockReturnValueOnce('API_URL=$BASE_URL/v1');
|
|
80
|
-
mockParse
|
|
81
|
-
.mockReturnValueOnce({ BASE_URL: 'https://api.com' })
|
|
82
|
-
.mockReturnValueOnce({ API_URL: '$BASE_URL/v1' });
|
|
83
|
-
// Expand is called with merged raw — so cross-file reference resolves
|
|
84
|
-
mockExpand.mockReturnValueOnce({
|
|
85
|
-
parsed: {
|
|
86
|
-
BASE_URL: 'https://api.com',
|
|
87
|
-
API_URL: 'https://api.com/v1',
|
|
88
|
-
},
|
|
89
|
-
});
|
|
90
|
-
const result = load_env(['.env.base', '.env.staging']);
|
|
91
|
-
expect(mockExpand).toHaveBeenCalledWith({
|
|
92
|
-
parsed: { BASE_URL: 'https://api.com', API_URL: '$BASE_URL/v1' },
|
|
93
|
-
});
|
|
94
|
-
expect(result).toEqual({
|
|
95
|
-
BASE_URL: 'https://api.com',
|
|
96
|
-
API_URL: 'https://api.com/v1',
|
|
97
|
-
});
|
|
98
|
-
});
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
describe('yaml format', () => {
|
|
102
|
-
it.each`
|
|
103
|
-
extension
|
|
104
|
-
${'yml'}
|
|
105
|
-
${'yaml'}
|
|
106
|
-
`(
|
|
107
|
-
"reads yaml when extension is '.$extension'",
|
|
108
|
-
({ extension }: { extension: string }) => {
|
|
109
|
-
mockReadFileSync.mockReturnValueOnce(Buffer.from('data'));
|
|
110
|
-
mockYaml.mockReturnValueOnce({ hello: 'world' });
|
|
111
|
-
const result = load_env(`hello.${extension}`);
|
|
112
|
-
expect(mockReadFileSync).toHaveBeenCalledWith(`hello.${extension}`);
|
|
113
|
-
expect(mockYaml).toHaveBeenCalledWith('data');
|
|
114
|
-
expect(result).toEqual({ hello: 'world' });
|
|
115
|
-
}
|
|
116
|
-
);
|
|
117
|
-
|
|
118
|
-
it('merges multiple yaml files, last file wins for conflicts', () => {
|
|
119
|
-
mockReadFileSync
|
|
120
|
-
.mockReturnValueOnce(Buffer.from('A: base\nB: base'))
|
|
121
|
-
.mockReturnValueOnce(Buffer.from('B: override\nC: new'));
|
|
122
|
-
mockYaml
|
|
123
|
-
.mockReturnValueOnce({ A: 'base', B: 'base' })
|
|
124
|
-
.mockReturnValueOnce({ B: 'override', C: 'new' });
|
|
125
|
-
const result = load_env(['base.yaml', 'staging.yaml']);
|
|
126
|
-
expect(mockReadFileSync).toHaveBeenCalledTimes(2);
|
|
127
|
-
expect(result).toEqual({ A: 'base', B: 'override', C: 'new' });
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
describe.each`
|
|
131
|
-
extension
|
|
132
|
-
${'yml'}
|
|
133
|
-
${'yaml'}
|
|
134
|
-
`(
|
|
135
|
-
"throws when yaml is not an object with extension '.$extension'",
|
|
136
|
-
({ extension }: { extension: string }) => {
|
|
137
|
-
it.each`
|
|
138
|
-
content
|
|
139
|
-
${'abc:def'}
|
|
140
|
-
${false}
|
|
141
|
-
${true}
|
|
142
|
-
${42}
|
|
143
|
-
${null}
|
|
144
|
-
${undefined}
|
|
145
|
-
`("when content is '$content'", ({ content }: { content: unknown }) => {
|
|
146
|
-
mockReadFileSync.mockReturnValueOnce(Buffer.from('data'));
|
|
147
|
-
mockYaml.mockReturnValueOnce(content);
|
|
148
|
-
expect(() => {
|
|
149
|
-
load_env(`hello.${extension}`);
|
|
150
|
-
}).toThrow();
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
);
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
describe('edge cases', () => {
|
|
157
|
-
it('throws when no files are provided', () => {
|
|
158
|
-
expect(() => load_env([])).toThrow(
|
|
159
|
-
'No env file specified'
|
|
160
|
-
);
|
|
161
|
-
});
|
|
162
|
-
});
|
|
163
|
-
});
|
package/src/main.spec.ts
DELETED
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
const mock_load_env = jest.fn();
|
|
2
|
-
jest.doMock('./load-env', () => ({ __esModule: true, default: mock_load_env }));
|
|
3
|
-
const mock_render_env = jest.fn();
|
|
4
|
-
jest.doMock('./render-env', () => ({ __esModule: true, default: mock_render_env }));
|
|
5
|
-
const mock_write_env = jest.fn();
|
|
6
|
-
jest.doMock('./write-env', () => ({ __esModule: true, default: mock_write_env }));
|
|
7
|
-
const mock_flatten = jest.fn();
|
|
8
|
-
jest.doMock('./flatten', () => ({ __esModule: true, default: mock_flatten }));
|
|
9
|
-
const mock_validate_env = jest.fn();
|
|
10
|
-
jest.doMock('./validate-env', () => ({ validate_env: mock_validate_env }));
|
|
11
|
-
|
|
12
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
13
|
-
const main: (...args: unknown[]) => Promise<void> = require('./main').default;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
export const files_to_assert = [
|
|
17
|
-
'ios/rncu.xcconfig',
|
|
18
|
-
'node_modules/react-native-config-ultimate/ios/ConfigValues.h',
|
|
19
|
-
'node_modules/react-native-config-ultimate/android/rncu.yaml',
|
|
20
|
-
'node_modules/react-native-config-ultimate/index.d.ts',
|
|
21
|
-
'node_modules/react-native-config-ultimate/index.web.js',
|
|
22
|
-
'node_modules/react-native-config-ultimate/override.js',
|
|
23
|
-
];
|
|
24
|
-
|
|
25
|
-
describe('main', () => {
|
|
26
|
-
beforeEach(() => {
|
|
27
|
-
mock_load_env.mockReset();
|
|
28
|
-
mock_render_env.mockReset();
|
|
29
|
-
mock_write_env.mockReset();
|
|
30
|
-
mock_flatten.mockReset();
|
|
31
|
-
mock_validate_env.mockReset();
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it('execute render with paths (string arg — backward-compatible)', async () => {
|
|
35
|
-
mock_load_env.mockReturnValueOnce({ data: true });
|
|
36
|
-
mock_flatten.mockReturnValueOnce({ data: true, ios: true });
|
|
37
|
-
mock_flatten.mockReturnValueOnce({ data: true, android: true });
|
|
38
|
-
mock_flatten.mockReturnValueOnce({ data: true, web: true });
|
|
39
|
-
mock_render_env.mockReturnValueOnce({ hello: 'world' });
|
|
40
|
-
await main(
|
|
41
|
-
'project',
|
|
42
|
-
'project/node_modules/react-native-config-ultimate',
|
|
43
|
-
'file'
|
|
44
|
-
);
|
|
45
|
-
expect(mock_load_env).toHaveBeenCalledWith('file');
|
|
46
|
-
expect(mock_flatten).toHaveBeenCalledWith({ data: true }, 'ios');
|
|
47
|
-
expect(mock_flatten).toHaveBeenCalledWith({ data: true }, 'android');
|
|
48
|
-
expect(mock_flatten).toHaveBeenCalledWith({ data: true }, 'web');
|
|
49
|
-
expect(mock_render_env).toHaveBeenCalledWith(
|
|
50
|
-
'project',
|
|
51
|
-
'project/node_modules/react-native-config-ultimate',
|
|
52
|
-
{
|
|
53
|
-
ios: { data: true, ios: true },
|
|
54
|
-
android: { data: true, android: true },
|
|
55
|
-
web: { data: true, web: true },
|
|
56
|
-
},
|
|
57
|
-
undefined
|
|
58
|
-
);
|
|
59
|
-
expect(mock_write_env).toHaveBeenCalledWith({ hello: 'world' });
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it('passes array of env files to load_env (multi-file merge)', async () => {
|
|
63
|
-
mock_load_env.mockReturnValueOnce({ data: true });
|
|
64
|
-
mock_flatten.mockReturnValue({});
|
|
65
|
-
mock_render_env.mockReturnValueOnce({});
|
|
66
|
-
await main(
|
|
67
|
-
'project',
|
|
68
|
-
'project/node_modules/react-native-config-ultimate',
|
|
69
|
-
['.env.base', '.env.staging']
|
|
70
|
-
);
|
|
71
|
-
expect(mock_load_env).toHaveBeenCalledWith(['.env.base', '.env.staging']);
|
|
72
|
-
});
|
|
73
|
-
describe('rc.on_env', () => {
|
|
74
|
-
it('invoke rc hook with config before flattening', async () => {
|
|
75
|
-
const on_env = jest.fn();
|
|
76
|
-
mock_load_env.mockReturnValueOnce({ data: true });
|
|
77
|
-
await main(
|
|
78
|
-
'project',
|
|
79
|
-
'project/node_modules/react-native-config-ultimate',
|
|
80
|
-
'file',
|
|
81
|
-
{ on_env }
|
|
82
|
-
);
|
|
83
|
-
expect(on_env).toHaveBeenCalledWith({ data: true });
|
|
84
|
-
expect(mock_flatten).toHaveBeenCalledWith({ data: true }, 'ios');
|
|
85
|
-
expect(mock_flatten).toHaveBeenCalledWith({ data: true }, 'android');
|
|
86
|
-
expect(mock_flatten).toHaveBeenCalledWith({ data: true }, 'web');
|
|
87
|
-
});
|
|
88
|
-
it('hook can add or remove values', async () => {
|
|
89
|
-
const on_env = jest.fn();
|
|
90
|
-
on_env.mockImplementation((env: Record<string, unknown>) => {
|
|
91
|
-
const { key1, ...rest } = env;
|
|
92
|
-
void key1;
|
|
93
|
-
return { ...rest, key2: 'hello' };
|
|
94
|
-
});
|
|
95
|
-
mock_load_env.mockReturnValueOnce({ data: true, key1: 'bye' });
|
|
96
|
-
await main(
|
|
97
|
-
'project',
|
|
98
|
-
'project/node_modules/react-native-config-ultimate',
|
|
99
|
-
'file',
|
|
100
|
-
{ on_env }
|
|
101
|
-
);
|
|
102
|
-
expect(on_env).toHaveBeenCalledWith({ data: true, key1: 'bye' });
|
|
103
|
-
expect(mock_flatten).toHaveBeenCalledWith({ data: true, key2: 'hello' }, 'ios');
|
|
104
|
-
expect(mock_flatten).toHaveBeenCalledWith({ data: true, key2: 'hello' }, 'android');
|
|
105
|
-
expect(mock_flatten).toHaveBeenCalledWith({ data: true, key2: 'hello' }, 'web');
|
|
106
|
-
});
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
describe('rc.schema', () => {
|
|
110
|
-
it('calls validate_env when schema is provided', async () => {
|
|
111
|
-
const schema = { API_KEY: { type: 'string' as const, required: true } };
|
|
112
|
-
mock_load_env.mockReturnValueOnce({ API_KEY: 'secret' });
|
|
113
|
-
mock_flatten.mockReturnValue({});
|
|
114
|
-
mock_render_env.mockReturnValueOnce({});
|
|
115
|
-
await main(
|
|
116
|
-
'project',
|
|
117
|
-
'project/node_modules/react-native-config-ultimate',
|
|
118
|
-
'file',
|
|
119
|
-
{ schema }
|
|
120
|
-
);
|
|
121
|
-
expect(mock_validate_env).toHaveBeenCalledWith({ API_KEY: 'secret' }, schema);
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it('does not call validate_env when no schema is provided', async () => {
|
|
125
|
-
mock_load_env.mockReturnValueOnce({ data: true });
|
|
126
|
-
mock_flatten.mockReturnValue({});
|
|
127
|
-
mock_render_env.mockReturnValueOnce({});
|
|
128
|
-
await main(
|
|
129
|
-
'project',
|
|
130
|
-
'project/node_modules/react-native-config-ultimate',
|
|
131
|
-
'file'
|
|
132
|
-
);
|
|
133
|
-
expect(mock_validate_env).not.toHaveBeenCalled();
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it('validates env AFTER on_env hook runs (hook output is validated)', async () => {
|
|
137
|
-
const schema = { INJECTED_KEY: { type: 'string' as const, required: true } };
|
|
138
|
-
const on_env = jest.fn().mockReturnValue({ INJECTED_KEY: 'from-hook' });
|
|
139
|
-
mock_load_env.mockReturnValueOnce({});
|
|
140
|
-
mock_flatten.mockReturnValue({});
|
|
141
|
-
mock_render_env.mockReturnValueOnce({});
|
|
142
|
-
await main(
|
|
143
|
-
'project',
|
|
144
|
-
'project/node_modules/react-native-config-ultimate',
|
|
145
|
-
'file',
|
|
146
|
-
{ on_env, schema }
|
|
147
|
-
);
|
|
148
|
-
// validate_env receives the HOOK output, not the raw env
|
|
149
|
-
expect(mock_validate_env).toHaveBeenCalledWith(
|
|
150
|
-
{ INJECTED_KEY: 'from-hook' },
|
|
151
|
-
schema
|
|
152
|
-
);
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it('propagates validation error thrown by validate_env', async () => {
|
|
156
|
-
const schema = { API_KEY: { type: 'string' as const, required: true } };
|
|
157
|
-
mock_load_env.mockReturnValueOnce({});
|
|
158
|
-
mock_validate_env.mockImplementation(() => {
|
|
159
|
-
throw new Error('❌ validation failed: Missing required env var: API_KEY');
|
|
160
|
-
});
|
|
161
|
-
await expect(
|
|
162
|
-
main(
|
|
163
|
-
'project',
|
|
164
|
-
'project/node_modules/react-native-config-ultimate',
|
|
165
|
-
'file',
|
|
166
|
-
{ schema }
|
|
167
|
-
)
|
|
168
|
-
).rejects.toThrow('Missing required env var: API_KEY');
|
|
169
|
-
});
|
|
170
|
-
});
|
|
171
|
-
});
|
package/src/resolve-env.spec.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import resolve_env from './resolve-env';
|
|
2
|
-
import type { EnvData } from './resolve-env';
|
|
3
|
-
|
|
4
|
-
describe('resolve_env', () => {
|
|
5
|
-
const original: EnvData = { original: 'data' };
|
|
6
|
-
it.each`
|
|
7
|
-
on_env_result | expected
|
|
8
|
-
${1} | ${1}
|
|
9
|
-
${''} | ${''}
|
|
10
|
-
${'hello'} | ${'hello'}
|
|
11
|
-
${0} | ${0}
|
|
12
|
-
${1} | ${1}
|
|
13
|
-
${false} | ${false}
|
|
14
|
-
${true} | ${true}
|
|
15
|
-
${null} | ${null}
|
|
16
|
-
${undefined} | ${original}
|
|
17
|
-
`(
|
|
18
|
-
"when hook returns '$on_env_result' env is resolved to '$expected'",
|
|
19
|
-
async ({ on_env_result, expected }: { on_env_result: unknown; expected: unknown }) => {
|
|
20
|
-
const on_env = jest.fn();
|
|
21
|
-
on_env.mockReturnValueOnce(on_env_result);
|
|
22
|
-
expect(await resolve_env(original, { on_env })).toEqual(expected);
|
|
23
|
-
}
|
|
24
|
-
);
|
|
25
|
-
});
|
package/src/validate-env.spec.ts
DELETED
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
import { validate_env } from './validate-env';
|
|
2
|
-
import type { Schema } from './resolve-env';
|
|
3
|
-
|
|
4
|
-
describe('validate-env', () => {
|
|
5
|
-
describe('required fields', () => {
|
|
6
|
-
it('throws when a required var is missing', () => {
|
|
7
|
-
const schema: Schema = { API_KEY: { type: 'string', required: true } };
|
|
8
|
-
expect(() => validate_env({}, schema)).toThrow('Missing required env var: API_KEY');
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
it('throws when a required var is an empty string', () => {
|
|
12
|
-
const schema: Schema = { API_KEY: { type: 'string', required: true } };
|
|
13
|
-
expect(() => validate_env({ API_KEY: '' }, schema)).toThrow(
|
|
14
|
-
'Missing required env var: API_KEY'
|
|
15
|
-
);
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it('throws when a required var is null', () => {
|
|
19
|
-
const schema: Schema = { API_KEY: { type: 'string', required: true } };
|
|
20
|
-
expect(() => validate_env({ API_KEY: null }, schema)).toThrow(
|
|
21
|
-
'Missing required env var: API_KEY'
|
|
22
|
-
);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it('does not throw when a required var is present', () => {
|
|
26
|
-
const schema: Schema = { API_KEY: { type: 'string', required: true } };
|
|
27
|
-
expect(() => validate_env({ API_KEY: 'my-key' }, schema)).not.toThrow();
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it('does not throw when an optional var is missing', () => {
|
|
31
|
-
const schema: Schema = { DEBUG: { type: 'boolean', required: false } };
|
|
32
|
-
expect(() => validate_env({}, schema)).not.toThrow();
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it('does not throw when required is not specified and var is missing', () => {
|
|
36
|
-
const schema: Schema = { DEBUG: { type: 'boolean' } };
|
|
37
|
-
expect(() => validate_env({}, schema)).not.toThrow();
|
|
38
|
-
});
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
describe('type validation', () => {
|
|
42
|
-
describe('number', () => {
|
|
43
|
-
it('accepts valid number strings', () => {
|
|
44
|
-
const schema: Schema = { TIMEOUT: { type: 'number' } };
|
|
45
|
-
expect(() => validate_env({ TIMEOUT: '3000' }, schema)).not.toThrow();
|
|
46
|
-
expect(() => validate_env({ TIMEOUT: '0' }, schema)).not.toThrow();
|
|
47
|
-
expect(() => validate_env({ TIMEOUT: '-1' }, schema)).not.toThrow();
|
|
48
|
-
expect(() => validate_env({ TIMEOUT: '3.14' }, schema)).not.toThrow();
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it('throws for non-numeric strings', () => {
|
|
52
|
-
const schema: Schema = { TIMEOUT: { type: 'number' } };
|
|
53
|
-
expect(() => validate_env({ TIMEOUT: 'fast' }, schema)).toThrow(
|
|
54
|
-
'TIMEOUT must be a number, got "fast"'
|
|
55
|
-
);
|
|
56
|
-
expect(() => validate_env({ TIMEOUT: 'abc' }, schema)).toThrow(
|
|
57
|
-
'TIMEOUT must be a number'
|
|
58
|
-
);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('accepts actual number values', () => {
|
|
62
|
-
const schema: Schema = { TIMEOUT: { type: 'number' } };
|
|
63
|
-
expect(() => validate_env({ TIMEOUT: 3000 }, schema)).not.toThrow();
|
|
64
|
-
});
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
describe('boolean', () => {
|
|
68
|
-
it('accepts valid boolean strings', () => {
|
|
69
|
-
const schema: Schema = { DEBUG: { type: 'boolean' } };
|
|
70
|
-
for (const v of ['true', 'false', '1', '0', 'TRUE', 'FALSE']) {
|
|
71
|
-
expect(() => validate_env({ DEBUG: v }, schema)).not.toThrow();
|
|
72
|
-
}
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it('throws for invalid boolean strings', () => {
|
|
76
|
-
const schema: Schema = { DEBUG: { type: 'boolean' } };
|
|
77
|
-
expect(() => validate_env({ DEBUG: 'yes' }, schema)).toThrow(
|
|
78
|
-
'DEBUG must be a boolean (true/false/1/0), got "yes"'
|
|
79
|
-
);
|
|
80
|
-
expect(() => validate_env({ DEBUG: 'enabled' }, schema)).toThrow(
|
|
81
|
-
'DEBUG must be a boolean'
|
|
82
|
-
);
|
|
83
|
-
});
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
describe('string', () => {
|
|
87
|
-
it('accepts any non-empty value as string', () => {
|
|
88
|
-
const schema: Schema = { NAME: { type: 'string' } };
|
|
89
|
-
expect(() => validate_env({ NAME: 'hello' }, schema)).not.toThrow();
|
|
90
|
-
expect(() => validate_env({ NAME: '123' }, schema)).not.toThrow();
|
|
91
|
-
expect(() => validate_env({ NAME: 'true' }, schema)).not.toThrow();
|
|
92
|
-
});
|
|
93
|
-
});
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
describe('pattern validation', () => {
|
|
97
|
-
it('accepts values matching the pattern', () => {
|
|
98
|
-
const schema: Schema = {
|
|
99
|
-
ENV: { type: 'string', pattern: '^(dev|staging|prod)$' },
|
|
100
|
-
};
|
|
101
|
-
expect(() => validate_env({ ENV: 'dev' }, schema)).not.toThrow();
|
|
102
|
-
expect(() => validate_env({ ENV: 'staging' }, schema)).not.toThrow();
|
|
103
|
-
expect(() => validate_env({ ENV: 'prod' }, schema)).not.toThrow();
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it('throws for values not matching the pattern', () => {
|
|
107
|
-
const schema: Schema = {
|
|
108
|
-
ENV: { type: 'string', pattern: '^(dev|staging|prod)$' },
|
|
109
|
-
};
|
|
110
|
-
expect(() => validate_env({ ENV: 'production' }, schema)).toThrow(
|
|
111
|
-
'ENV does not match pattern /^(dev|staging|prod)$/, got "production"'
|
|
112
|
-
);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it('pattern validation is skipped for missing optional vars', () => {
|
|
116
|
-
const schema: Schema = {
|
|
117
|
-
ENV: { type: 'string', pattern: '^(dev|staging|prod)$' },
|
|
118
|
-
};
|
|
119
|
-
expect(() => validate_env({}, schema)).not.toThrow();
|
|
120
|
-
});
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
describe('multiple errors', () => {
|
|
124
|
-
it('reports all validation errors at once, not just the first', () => {
|
|
125
|
-
const schema: Schema = {
|
|
126
|
-
API_KEY: { type: 'string', required: true },
|
|
127
|
-
TIMEOUT: { type: 'number', required: true },
|
|
128
|
-
ENV: { type: 'string', pattern: '^(dev|staging|prod)$' },
|
|
129
|
-
};
|
|
130
|
-
let error: Error | undefined;
|
|
131
|
-
try {
|
|
132
|
-
validate_env({ TIMEOUT: 'fast', ENV: 'production' }, schema);
|
|
133
|
-
} catch (e) {
|
|
134
|
-
error = e as Error;
|
|
135
|
-
}
|
|
136
|
-
expect(error).toBeDefined();
|
|
137
|
-
expect(error?.message).toContain('Missing required env var: API_KEY');
|
|
138
|
-
expect(error?.message).toContain('TIMEOUT must be a number');
|
|
139
|
-
expect(error?.message).toContain('ENV does not match pattern');
|
|
140
|
-
});
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
describe('does not throw for valid env', () => {
|
|
144
|
-
it('passes a complete valid env without error', () => {
|
|
145
|
-
const schema: Schema = {
|
|
146
|
-
API_KEY: { type: 'string', required: true },
|
|
147
|
-
TIMEOUT_MS: { type: 'number', required: true },
|
|
148
|
-
DEBUG: { type: 'boolean', required: false },
|
|
149
|
-
ENV_NAME: { type: 'string', required: true, pattern: '^(dev|staging|prod)$' },
|
|
150
|
-
};
|
|
151
|
-
expect(() =>
|
|
152
|
-
validate_env(
|
|
153
|
-
{
|
|
154
|
-
API_KEY: 'secret-key',
|
|
155
|
-
TIMEOUT_MS: '5000',
|
|
156
|
-
ENV_NAME: 'staging',
|
|
157
|
-
// DEBUG intentionally omitted (optional)
|
|
158
|
-
},
|
|
159
|
-
schema
|
|
160
|
-
)
|
|
161
|
-
).not.toThrow();
|
|
162
|
-
});
|
|
163
|
-
});
|
|
164
|
-
});
|
package/src/write-env.spec.ts
DELETED
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
// Mocks must be declared before the module is required.
|
|
2
|
-
const mockWriteFileSync = jest.fn();
|
|
3
|
-
const mockRenameSync = jest.fn();
|
|
4
|
-
const mockUnlinkSync = jest.fn();
|
|
5
|
-
const mockCopyFileSync = jest.fn();
|
|
6
|
-
const mockMkdirSync = jest.fn(); // needed since write-env creates dest dirs
|
|
7
|
-
|
|
8
|
-
jest.mock('fs', () => ({
|
|
9
|
-
writeFileSync: (...args: unknown[]) => mockWriteFileSync(...args),
|
|
10
|
-
renameSync: (...args: unknown[]) => mockRenameSync(...args),
|
|
11
|
-
unlinkSync: (...args: unknown[]) => mockUnlinkSync(...args),
|
|
12
|
-
copyFileSync: (...args: unknown[]) => mockCopyFileSync(...args),
|
|
13
|
-
mkdirSync: (...args: unknown[]) => mockMkdirSync(...args),
|
|
14
|
-
}));
|
|
15
|
-
|
|
16
|
-
jest.mock('os', () => ({ tmpdir: () => '/tmp' }));
|
|
17
|
-
|
|
18
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
19
|
-
const write_env: (env: Record<string, string>) => void = require('./write-env').default;
|
|
20
|
-
|
|
21
|
-
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
22
|
-
|
|
23
|
-
/** Returns the temp file path used for a given destination. */
|
|
24
|
-
function tmp_for(dest: string): string {
|
|
25
|
-
const rename_call = mockRenameSync.mock.calls.find(
|
|
26
|
-
([, d]: [string, string]) => d === dest
|
|
27
|
-
);
|
|
28
|
-
return rename_call?.[0] as string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// ─── tests ───────────────────────────────────────────────────────────────────
|
|
32
|
-
|
|
33
|
-
describe('write-env', () => {
|
|
34
|
-
beforeEach(() => {
|
|
35
|
-
mockWriteFileSync.mockReset();
|
|
36
|
-
mockRenameSync.mockReset();
|
|
37
|
-
mockUnlinkSync.mockReset();
|
|
38
|
-
mockCopyFileSync.mockReset();
|
|
39
|
-
mockMkdirSync.mockReset();
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('writes content to a temp file then renames to destination (atomic write)', () => {
|
|
43
|
-
write_env({ hello: 'world' });
|
|
44
|
-
|
|
45
|
-
// Phase 1: content written to a temp path under /tmp
|
|
46
|
-
expect(mockWriteFileSync).toHaveBeenCalledTimes(1);
|
|
47
|
-
const [tmp_path, content, encoding] = mockWriteFileSync.mock.calls[0] as [string, string, string];
|
|
48
|
-
expect(tmp_path).toMatch(/^\/tmp\/rncu_/);
|
|
49
|
-
expect(content).toBe('world');
|
|
50
|
-
expect(encoding).toBe('utf8');
|
|
51
|
-
|
|
52
|
-
// Phase 2: temp renamed to final destination (atomic)
|
|
53
|
-
expect(mockRenameSync).toHaveBeenCalledWith(tmp_path, 'hello');
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it('writes multiple files atomically', () => {
|
|
57
|
-
write_env({ hello: 'world', hey: 'you' });
|
|
58
|
-
|
|
59
|
-
expect(mockWriteFileSync).toHaveBeenCalledTimes(2);
|
|
60
|
-
expect(mockRenameSync).toHaveBeenCalledTimes(2);
|
|
61
|
-
|
|
62
|
-
// Each dest should have been renamed from a unique temp path
|
|
63
|
-
const tmp_hello = tmp_for('hello');
|
|
64
|
-
const tmp_hey = tmp_for('hey');
|
|
65
|
-
expect(tmp_hello).toBeDefined();
|
|
66
|
-
expect(tmp_hey).toBeDefined();
|
|
67
|
-
expect(tmp_hello).not.toBe(tmp_hey);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it('falls back to copyFileSync + unlinkSync when renameSync fails (cross-device)', () => {
|
|
71
|
-
// Simulate cross-device rename error (e.g. /tmp on different device)
|
|
72
|
-
mockRenameSync.mockImplementation(() => { throw new Error('EXDEV'); });
|
|
73
|
-
|
|
74
|
-
write_env({ hello: 'world' });
|
|
75
|
-
|
|
76
|
-
expect(mockCopyFileSync).toHaveBeenCalledWith(expect.stringMatching(/^\/tmp\/rncu_/), 'hello');
|
|
77
|
-
expect(mockUnlinkSync).toHaveBeenCalled();
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('throws a descriptive error if copyFileSync also fails', () => {
|
|
81
|
-
mockRenameSync.mockImplementation(() => { throw new Error('EXDEV'); });
|
|
82
|
-
mockCopyFileSync.mockImplementation(() => { throw new Error('EACCES: permission denied'); });
|
|
83
|
-
|
|
84
|
-
expect(() => write_env({ hello: 'world' })).toThrow(
|
|
85
|
-
/Failed to write output files/
|
|
86
|
-
);
|
|
87
|
-
expect(() => write_env({ hello: 'world' })).toThrow('hello');
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it('cleans up temp files when Phase 1 write fails', () => {
|
|
91
|
-
// First write succeeds, second fails mid-write
|
|
92
|
-
mockWriteFileSync
|
|
93
|
-
.mockImplementationOnce(() => { /* success */ })
|
|
94
|
-
.mockImplementationOnce(() => { throw new Error('ENOSPC: no space left'); });
|
|
95
|
-
|
|
96
|
-
expect(() => write_env({ hello: 'world', hey: 'you' })).toThrow(
|
|
97
|
-
/Failed to prepare output files/
|
|
98
|
-
);
|
|
99
|
-
|
|
100
|
-
// The first temp file should have been cleaned up
|
|
101
|
-
expect(mockUnlinkSync).toHaveBeenCalledTimes(1);
|
|
102
|
-
// No renames should have happened (no real files touched)
|
|
103
|
-
expect(mockRenameSync).not.toHaveBeenCalled();
|
|
104
|
-
});
|
|
105
|
-
});
|