@react-native-harness/cache 1.4.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/LICENSE +20 -0
- package/README.md +26 -0
- package/dist/__tests__/boundary.test.d.ts +2 -0
- package/dist/__tests__/boundary.test.d.ts.map +1 -0
- package/dist/__tests__/boundary.test.js +77 -0
- package/dist/__tests__/domains/metro.test.d.ts +2 -0
- package/dist/__tests__/domains/metro.test.d.ts.map +1 -0
- package/dist/__tests__/domains/metro.test.js +234 -0
- package/dist/__tests__/index.test.d.ts +2 -0
- package/dist/__tests__/index.test.d.ts.map +1 -0
- package/dist/__tests__/index.test.js +40 -0
- package/dist/__tests__/paths.test.d.ts +2 -0
- package/dist/__tests__/paths.test.d.ts.map +1 -0
- package/dist/__tests__/paths.test.js +12 -0
- package/dist/__tests__/plan.test.d.ts +2 -0
- package/dist/__tests__/plan.test.d.ts.map +1 -0
- package/dist/__tests__/plan.test.js +197 -0
- package/dist/__tests__/warmth.test.d.ts +2 -0
- package/dist/__tests__/warmth.test.d.ts.map +1 -0
- package/dist/__tests__/warmth.test.js +36 -0
- package/dist/domains/metro.d.ts +8 -0
- package/dist/domains/metro.d.ts.map +1 -0
- package/dist/domains/metro.js +151 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/paths.d.ts +14 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +26 -0
- package/dist/plan.d.ts +54 -0
- package/dist/plan.d.ts.map +1 -0
- package/dist/plan.js +177 -0
- package/dist/tsconfig.lib.tsbuildinfo +1 -0
- package/dist/warmth.d.ts +8 -0
- package/dist/warmth.d.ts.map +1 -0
- package/dist/warmth.js +40 -0
- package/eslint.config.mjs +20 -0
- package/package.json +22 -0
- package/src/__tests__/boundary.test.ts +95 -0
- package/src/__tests__/domains/metro.test.ts +298 -0
- package/src/__tests__/index.test.ts +54 -0
- package/src/__tests__/paths.test.ts +18 -0
- package/src/__tests__/plan.test.ts +330 -0
- package/src/__tests__/warmth.test.ts +47 -0
- package/src/domains/metro.ts +202 -0
- package/src/index.ts +84 -0
- package/src/paths.ts +43 -0
- package/src/plan.ts +285 -0
- package/src/warmth.ts +59 -0
- package/tsconfig.json +13 -0
- package/tsconfig.lib.json +18 -0
- package/vite.config.ts +18 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { createHarnessCache } from '../index.js';
|
|
6
|
+
|
|
7
|
+
describe('createHarnessCache', () => {
|
|
8
|
+
let projectRoot: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-cache-'));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
fs.rmSync(projectRoot, { recursive: true, force: true });
|
|
16
|
+
vi.restoreAllMocks();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('exposes paths rooted at <projectRoot>/.harness/cache', () => {
|
|
20
|
+
const cache = createHarnessCache({ projectRoot });
|
|
21
|
+
|
|
22
|
+
expect(cache.paths.root).toBe(path.join(projectRoot, '.harness', 'cache'));
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('reports metro as cold before directories exist', () => {
|
|
26
|
+
const cache = createHarnessCache({ projectRoot });
|
|
27
|
+
|
|
28
|
+
expect(cache.isWarm('metro')).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('creates domain directories and reports warm once populated', () => {
|
|
32
|
+
const cache = createHarnessCache({ projectRoot });
|
|
33
|
+
|
|
34
|
+
cache.ensureDomainDirectories('metro');
|
|
35
|
+
|
|
36
|
+
expect(fs.existsSync(cache.paths.metroTransform)).toBe(true);
|
|
37
|
+
expect(fs.existsSync(cache.paths.metroFileMap)).toBe(true);
|
|
38
|
+
expect(cache.isWarm('metro')).toBe(false);
|
|
39
|
+
|
|
40
|
+
fs.writeFileSync(path.join(cache.paths.metroTransform, 'entry'), 'data');
|
|
41
|
+
expect(cache.isWarm('metro')).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('swallows and logs errors instead of throwing when directories cannot be created', () => {
|
|
45
|
+
// Point the project root at a path that cannot be created (its parent is a file).
|
|
46
|
+
const blockedRoot = path.join(projectRoot, 'blocked-file', 'nested');
|
|
47
|
+
fs.writeFileSync(path.join(projectRoot, 'blocked-file'), 'not a directory');
|
|
48
|
+
|
|
49
|
+
const cache = createHarnessCache({ projectRoot: blockedRoot });
|
|
50
|
+
|
|
51
|
+
expect(() => cache.ensureDomainDirectories('metro')).not.toThrow();
|
|
52
|
+
expect(cache.isWarm('metro')).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { createHarnessCachePaths } from '../paths.js';
|
|
4
|
+
|
|
5
|
+
describe('createHarnessCachePaths', () => {
|
|
6
|
+
it('nests cache paths under <projectRoot>/.harness/cache', () => {
|
|
7
|
+
const projectRoot = '/tmp/some-project';
|
|
8
|
+
const paths = createHarnessCachePaths(projectRoot);
|
|
9
|
+
|
|
10
|
+
expect(paths.root).toBe(path.join(projectRoot, '.harness', 'cache'));
|
|
11
|
+
expect(paths.metroTransform).toBe(
|
|
12
|
+
path.join(projectRoot, '.harness', 'cache', 'metro')
|
|
13
|
+
);
|
|
14
|
+
expect(paths.metroFileMap).toBe(
|
|
15
|
+
path.join(projectRoot, '.harness', 'cache', 'metro-file-map')
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { createHarnessCachePaths } from '../paths.js';
|
|
6
|
+
import {
|
|
7
|
+
planRestore,
|
|
8
|
+
planSave,
|
|
9
|
+
snapshot,
|
|
10
|
+
writeKeysFile,
|
|
11
|
+
type CacheKeyInputs,
|
|
12
|
+
type CacheSnapshot,
|
|
13
|
+
} from '../plan.js';
|
|
14
|
+
|
|
15
|
+
describe('planRestore', () => {
|
|
16
|
+
let projectRoot: string;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-cache-'));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
fs.rmSync(projectRoot, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const inputs = (
|
|
27
|
+
staticInputs: Record<string, string>
|
|
28
|
+
): Record<'metro', CacheKeyInputs> => ({
|
|
29
|
+
metro: { os: 'linux', staticInputs },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('produces the same restoreKey across independent calls given the same staticInputs', () => {
|
|
33
|
+
const paths = createHarnessCachePaths(projectRoot);
|
|
34
|
+
|
|
35
|
+
const first = planRestore(paths, inputs({ a: '1', b: '2' }));
|
|
36
|
+
const second = planRestore(paths, inputs({ a: '1', b: '2' }));
|
|
37
|
+
|
|
38
|
+
expect(first.domains.metro?.restoreKey).toBe(
|
|
39
|
+
second.domains.metro?.restoreKey
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('produces the same restoreKey regardless of staticInputs key order', () => {
|
|
44
|
+
const paths = createHarnessCachePaths(projectRoot);
|
|
45
|
+
|
|
46
|
+
const first = planRestore(paths, inputs({ a: '1', b: '2' }));
|
|
47
|
+
const second = planRestore(paths, inputs({ b: '2', a: '1' }));
|
|
48
|
+
|
|
49
|
+
expect(first.domains.metro?.restoreKey).toBe(
|
|
50
|
+
second.domains.metro?.restoreKey
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('produces a different restoreKey for different staticInputs', () => {
|
|
55
|
+
const paths = createHarnessCachePaths(projectRoot);
|
|
56
|
+
|
|
57
|
+
const first = planRestore(paths, inputs({ a: '1' }));
|
|
58
|
+
const second = planRestore(paths, inputs({ a: '2' }));
|
|
59
|
+
|
|
60
|
+
expect(first.domains.metro?.restoreKey).not.toBe(
|
|
61
|
+
second.domains.metro?.restoreKey
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('shapes restorePrefixes for the accretive metro domain as a single trailing-dash prefix', () => {
|
|
66
|
+
const paths = createHarnessCachePaths(projectRoot);
|
|
67
|
+
const plan = planRestore(paths, inputs({ a: '1' }));
|
|
68
|
+
const domain = plan.domains.metro;
|
|
69
|
+
|
|
70
|
+
expect(domain?.accretive).toBe(true);
|
|
71
|
+
expect(domain?.restorePrefixes).toEqual([`${domain?.restoreKey}-`]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('only includes domains present in the inputs', () => {
|
|
75
|
+
const paths = createHarnessCachePaths(projectRoot);
|
|
76
|
+
const plan = planRestore(paths, {});
|
|
77
|
+
|
|
78
|
+
expect(plan.domains).toEqual({});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('reports the domain cache directories as paths', () => {
|
|
82
|
+
const paths = createHarnessCachePaths(projectRoot);
|
|
83
|
+
const plan = planRestore(paths, inputs({ a: '1' }));
|
|
84
|
+
|
|
85
|
+
expect(plan.domains.metro?.paths).toEqual([
|
|
86
|
+
paths.metroTransform,
|
|
87
|
+
paths.metroFileMap,
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('snapshot', () => {
|
|
93
|
+
let projectRoot: string;
|
|
94
|
+
|
|
95
|
+
beforeEach(() => {
|
|
96
|
+
projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-cache-'));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
afterEach(() => {
|
|
100
|
+
fs.rmSync(projectRoot, { recursive: true, force: true });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('is empty for a domain whose directories do not exist', () => {
|
|
104
|
+
const paths = createHarnessCachePaths(projectRoot);
|
|
105
|
+
|
|
106
|
+
expect(() => snapshot(paths)).not.toThrow();
|
|
107
|
+
expect(snapshot(paths).metro).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('collects entries for files under the domain directories', () => {
|
|
111
|
+
const paths = createHarnessCachePaths(projectRoot);
|
|
112
|
+
fs.mkdirSync(path.join(paths.metroTransform, 'nested'), {
|
|
113
|
+
recursive: true,
|
|
114
|
+
});
|
|
115
|
+
fs.writeFileSync(path.join(paths.metroTransform, 'a.txt'), 'aaa');
|
|
116
|
+
fs.writeFileSync(
|
|
117
|
+
path.join(paths.metroTransform, 'nested', 'b.txt'),
|
|
118
|
+
'bb'
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const result = snapshot(paths);
|
|
122
|
+
const entries = result.metro ?? [];
|
|
123
|
+
|
|
124
|
+
expect(entries).toHaveLength(2);
|
|
125
|
+
const aEntry = entries.find((entry) =>
|
|
126
|
+
entry.path.endsWith(path.join('metro', 'a.txt'))
|
|
127
|
+
);
|
|
128
|
+
const bEntry = entries.find((entry) =>
|
|
129
|
+
entry.path.endsWith(path.join('metro', 'nested', 'b.txt'))
|
|
130
|
+
);
|
|
131
|
+
expect(aEntry?.sizeBytes).toBe(3);
|
|
132
|
+
expect(bEntry?.sizeBytes).toBe(2);
|
|
133
|
+
expect(typeof aEntry?.mtimeMs).toBe('number');
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('planSave', () => {
|
|
138
|
+
let projectRoot: string;
|
|
139
|
+
|
|
140
|
+
beforeEach(() => {
|
|
141
|
+
projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-cache-'));
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
afterEach(() => {
|
|
145
|
+
fs.rmSync(projectRoot, { recursive: true, force: true });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const inputs = (
|
|
149
|
+
staticInputs: Record<string, string> = { a: '1' }
|
|
150
|
+
): Record<'metro', CacheKeyInputs> => ({
|
|
151
|
+
metro: { os: 'linux', staticInputs },
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('does not save when there is no change, regardless of policy', () => {
|
|
155
|
+
const paths = createHarnessCachePaths(projectRoot);
|
|
156
|
+
fs.mkdirSync(paths.metroTransform, { recursive: true });
|
|
157
|
+
fs.writeFileSync(path.join(paths.metroTransform, 'a.txt'), 'aaa');
|
|
158
|
+
|
|
159
|
+
const before = snapshot(paths);
|
|
160
|
+
const plan = planSave(
|
|
161
|
+
paths,
|
|
162
|
+
before,
|
|
163
|
+
{ mode: 'always', isDefaultBranch: true },
|
|
164
|
+
inputs()
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
expect(plan.domains.metro?.shouldSave).toBe(false);
|
|
168
|
+
expect(plan.domains.metro?.delta).toEqual({
|
|
169
|
+
addedEntries: 0,
|
|
170
|
+
removedEntries: 0,
|
|
171
|
+
addedBytes: 0,
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('saves an added file only when policy allows it', () => {
|
|
176
|
+
const paths = createHarnessCachePaths(projectRoot);
|
|
177
|
+
fs.mkdirSync(paths.metroTransform, { recursive: true });
|
|
178
|
+
const before = snapshot(paths);
|
|
179
|
+
|
|
180
|
+
fs.writeFileSync(path.join(paths.metroTransform, 'a.txt'), 'aaa');
|
|
181
|
+
|
|
182
|
+
const disallowed = planSave(
|
|
183
|
+
paths,
|
|
184
|
+
before,
|
|
185
|
+
{ mode: 'default-branch', isDefaultBranch: false },
|
|
186
|
+
inputs()
|
|
187
|
+
);
|
|
188
|
+
expect(disallowed.domains.metro?.shouldSave).toBe(false);
|
|
189
|
+
expect(disallowed.domains.metro?.delta.addedEntries).toBe(1);
|
|
190
|
+
expect(disallowed.domains.metro?.delta.addedBytes).toBe(3);
|
|
191
|
+
|
|
192
|
+
const allowed = planSave(
|
|
193
|
+
paths,
|
|
194
|
+
before,
|
|
195
|
+
{ mode: 'default-branch', isDefaultBranch: true },
|
|
196
|
+
inputs()
|
|
197
|
+
);
|
|
198
|
+
expect(allowed.domains.metro?.shouldSave).toBe(true);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('never saves when policy mode is "never"', () => {
|
|
202
|
+
const paths = createHarnessCachePaths(projectRoot);
|
|
203
|
+
fs.mkdirSync(paths.metroTransform, { recursive: true });
|
|
204
|
+
const before = snapshot(paths);
|
|
205
|
+
fs.writeFileSync(path.join(paths.metroTransform, 'a.txt'), 'aaa');
|
|
206
|
+
|
|
207
|
+
const plan = planSave(
|
|
208
|
+
paths,
|
|
209
|
+
before,
|
|
210
|
+
{ mode: 'never', isDefaultBranch: true },
|
|
211
|
+
inputs()
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
expect(plan.domains.metro?.shouldSave).toBe(false);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('counts a removed file in the delta', () => {
|
|
218
|
+
const paths = createHarnessCachePaths(projectRoot);
|
|
219
|
+
fs.mkdirSync(paths.metroTransform, { recursive: true });
|
|
220
|
+
fs.writeFileSync(path.join(paths.metroTransform, 'a.txt'), 'aaa');
|
|
221
|
+
const before = snapshot(paths);
|
|
222
|
+
|
|
223
|
+
fs.rmSync(path.join(paths.metroTransform, 'a.txt'));
|
|
224
|
+
|
|
225
|
+
const plan = planSave(
|
|
226
|
+
paths,
|
|
227
|
+
before,
|
|
228
|
+
{ mode: 'always', isDefaultBranch: true },
|
|
229
|
+
inputs()
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
expect(plan.domains.metro?.delta.removedEntries).toBe(1);
|
|
233
|
+
expect(plan.domains.metro?.shouldSave).toBe(true);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('does not treat an mtime-only change as an added entry or change the content hash', () => {
|
|
237
|
+
const paths = createHarnessCachePaths(projectRoot);
|
|
238
|
+
fs.mkdirSync(paths.metroTransform, { recursive: true });
|
|
239
|
+
const filePath = path.join(paths.metroTransform, 'a.txt');
|
|
240
|
+
fs.writeFileSync(filePath, 'aaa');
|
|
241
|
+
const before = snapshot(paths);
|
|
242
|
+
|
|
243
|
+
const beforeSave = planSave(
|
|
244
|
+
paths,
|
|
245
|
+
before,
|
|
246
|
+
{ mode: 'always', isDefaultBranch: true },
|
|
247
|
+
inputs()
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
// Touch the file's mtime without changing its size/content.
|
|
251
|
+
const future = new Date(Date.now() + 10_000);
|
|
252
|
+
fs.utimesSync(filePath, future, future);
|
|
253
|
+
|
|
254
|
+
const afterSave = planSave(
|
|
255
|
+
paths,
|
|
256
|
+
before,
|
|
257
|
+
{ mode: 'always', isDefaultBranch: true },
|
|
258
|
+
inputs()
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
expect(afterSave.domains.metro?.delta.addedEntries).toBe(0);
|
|
262
|
+
expect(afterSave.domains.metro?.shouldSave).toBe(false);
|
|
263
|
+
expect(afterSave.domains.metro?.saveKey).toBe(
|
|
264
|
+
beforeSave.domains.metro?.saveKey
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('produces a deterministic saveKey across independent planSave calls given the same on-disk state', () => {
|
|
269
|
+
const paths = createHarnessCachePaths(projectRoot);
|
|
270
|
+
fs.mkdirSync(paths.metroTransform, { recursive: true });
|
|
271
|
+
fs.writeFileSync(path.join(paths.metroTransform, 'a.txt'), 'aaa');
|
|
272
|
+
const before: CacheSnapshot = {};
|
|
273
|
+
|
|
274
|
+
const first = planSave(
|
|
275
|
+
paths,
|
|
276
|
+
before,
|
|
277
|
+
{ mode: 'always', isDefaultBranch: true },
|
|
278
|
+
inputs()
|
|
279
|
+
);
|
|
280
|
+
const second = planSave(
|
|
281
|
+
paths,
|
|
282
|
+
before,
|
|
283
|
+
{ mode: 'always', isDefaultBranch: true },
|
|
284
|
+
inputs()
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
expect(first.domains.metro?.saveKey).toBe(second.domains.metro?.saveKey);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
describe('writeKeysFile', () => {
|
|
292
|
+
let projectRoot: string;
|
|
293
|
+
|
|
294
|
+
beforeEach(() => {
|
|
295
|
+
projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-cache-'));
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
afterEach(() => {
|
|
299
|
+
fs.rmSync(projectRoot, { recursive: true, force: true });
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('writes keys.json under the cache root, outside domain subdirectories', () => {
|
|
303
|
+
const paths = createHarnessCachePaths(projectRoot);
|
|
304
|
+
const plan = planRestore(paths, {
|
|
305
|
+
metro: { os: 'linux', staticInputs: { a: '1' } },
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
writeKeysFile(paths, plan);
|
|
309
|
+
|
|
310
|
+
const filePath = path.join(paths.root, 'keys.json');
|
|
311
|
+
expect(fs.existsSync(filePath)).toBe(true);
|
|
312
|
+
expect(filePath.startsWith(paths.metroTransform)).toBe(false);
|
|
313
|
+
expect(filePath.startsWith(paths.metroFileMap)).toBe(false);
|
|
314
|
+
|
|
315
|
+
const written = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
316
|
+
expect(written.version).toBe(1);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('swallows errors instead of throwing when the cache root cannot be created', () => {
|
|
320
|
+
const blockedRoot = path.join(projectRoot, 'blocked-file', 'nested');
|
|
321
|
+
fs.writeFileSync(path.join(projectRoot, 'blocked-file'), 'not a directory');
|
|
322
|
+
const paths = createHarnessCachePaths(blockedRoot);
|
|
323
|
+
const plan = planRestore(paths, {
|
|
324
|
+
metro: { os: 'linux', staticInputs: { a: '1' } },
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
expect(() => writeKeysFile(paths, plan)).not.toThrow();
|
|
328
|
+
expect(fs.existsSync(path.join(paths.root, 'keys.json'))).toBe(false);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { createHarnessCachePaths } from '../paths.js';
|
|
6
|
+
import { isDomainWarm } from '../warmth.js';
|
|
7
|
+
|
|
8
|
+
describe('isDomainWarm', () => {
|
|
9
|
+
let projectRoot: string;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-cache-'));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
fs.rmSync(projectRoot, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('is false when the domain directory does not exist', () => {
|
|
20
|
+
const paths = createHarnessCachePaths(projectRoot);
|
|
21
|
+
|
|
22
|
+
expect(isDomainWarm('metro', paths)).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('is false when the domain directory exists but is empty', () => {
|
|
26
|
+
const paths = createHarnessCachePaths(projectRoot);
|
|
27
|
+
fs.mkdirSync(paths.metroTransform, { recursive: true });
|
|
28
|
+
|
|
29
|
+
expect(isDomainWarm('metro', paths)).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('is true when the domain directory exists and has entries', () => {
|
|
33
|
+
const paths = createHarnessCachePaths(projectRoot);
|
|
34
|
+
fs.mkdirSync(paths.metroTransform, { recursive: true });
|
|
35
|
+
fs.writeFileSync(path.join(paths.metroTransform, 'entry'), 'data');
|
|
36
|
+
|
|
37
|
+
expect(isDomainWarm('metro', paths)).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('is false when the path exists but is a file, not a directory', () => {
|
|
41
|
+
const paths = createHarnessCachePaths(projectRoot);
|
|
42
|
+
fs.mkdirSync(path.dirname(paths.metroTransform), { recursive: true });
|
|
43
|
+
fs.writeFileSync(paths.metroTransform, 'not a directory');
|
|
44
|
+
|
|
45
|
+
expect(isDomainWarm('metro', paths)).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { logger } from '@react-native-harness/tools';
|
|
5
|
+
|
|
6
|
+
const cacheLogger = logger.child('cache');
|
|
7
|
+
|
|
8
|
+
// Metro resolves its own config by walking UP from the project root (see
|
|
9
|
+
// Metro's loadConfig docs), never by scanning the tree downward. Lockfiles in
|
|
10
|
+
// a monorepo live at the repo root, i.e. somewhere on projectRoot's ancestor
|
|
11
|
+
// chain. So instead of a downward `hashFiles('**/…')`-style walk (which also
|
|
12
|
+
// has to dodge node_modules/.git/dist/build to avoid vendored copies), we
|
|
13
|
+
// walk upward from projectRoot to repoRoot (inclusive) and hash every
|
|
14
|
+
// candidate key file that exists along that chain. Sibling workspace
|
|
15
|
+
// packages are never on this chain, so their configs can't spuriously
|
|
16
|
+
// invalidate this project's cache key.
|
|
17
|
+
const LOCKFILE_NAMES: readonly string[] = [
|
|
18
|
+
'bun.lock',
|
|
19
|
+
'bun.lockb',
|
|
20
|
+
'package-lock.json',
|
|
21
|
+
'npm-shrinkwrap.json',
|
|
22
|
+
'pnpm-lock.yaml',
|
|
23
|
+
'yarn.lock',
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
// Metro config candidates: `metro.config.*` at the directory root, plus the
|
|
27
|
+
// `.config/metro.*` directory convention Metro also resolves.
|
|
28
|
+
const METRO_CONFIG_EXTENSIONS: readonly string[] = [
|
|
29
|
+
'js',
|
|
30
|
+
'cjs',
|
|
31
|
+
'mjs',
|
|
32
|
+
'ts',
|
|
33
|
+
'cts',
|
|
34
|
+
'mts',
|
|
35
|
+
'json',
|
|
36
|
+
];
|
|
37
|
+
const METRO_CONFIG_NAMES: readonly string[] = METRO_CONFIG_EXTENSIONS.map(
|
|
38
|
+
(extension) => `metro.config.${extension}`
|
|
39
|
+
);
|
|
40
|
+
const METRO_CONFIG_DIR_NAMES: readonly string[] = METRO_CONFIG_EXTENSIONS.map(
|
|
41
|
+
(extension) => `.config/metro.${extension}`
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// Babel only resolves root configs named `babel.config.*` (note: no `.ts` —
|
|
45
|
+
// Babel does not resolve `babel.config.ts`). File-relative `.babelrc` and the
|
|
46
|
+
// `package.json` `babel`/`metro` fields are intentionally out of scope.
|
|
47
|
+
const BABEL_CONFIG_NAMES: readonly string[] = [
|
|
48
|
+
'babel.config.json',
|
|
49
|
+
'babel.config.js',
|
|
50
|
+
'babel.config.cjs',
|
|
51
|
+
'babel.config.mjs',
|
|
52
|
+
'babel.config.cts',
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
// Relative-to-examined-directory paths checked at every directory on the
|
|
56
|
+
// ancestor chain. All candidates that exist are hashed (rather than
|
|
57
|
+
// emulating Metro/Babel's own first-match-wins resolution), which is
|
|
58
|
+
// deterministic and strictly more conservative.
|
|
59
|
+
export const METRO_KEY_FILENAMES: readonly string[] = [
|
|
60
|
+
...LOCKFILE_NAMES,
|
|
61
|
+
...METRO_CONFIG_NAMES,
|
|
62
|
+
...METRO_CONFIG_DIR_NAMES,
|
|
63
|
+
...BABEL_CONFIG_NAMES,
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
interface KeyFileMatch {
|
|
67
|
+
relativePath: string;
|
|
68
|
+
content: Buffer;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Directories to examine: projectRoot and every ancestor directory up to and
|
|
73
|
+
* including repoRoot. Defensive: if projectRoot isn't inside repoRoot (or
|
|
74
|
+
* repoRoot can't be related to it), fall back to examining projectRoot alone
|
|
75
|
+
* rather than guessing.
|
|
76
|
+
*/
|
|
77
|
+
const collectAncestorDirectories = (
|
|
78
|
+
projectRoot: string,
|
|
79
|
+
repoRoot: string
|
|
80
|
+
): string[] => {
|
|
81
|
+
const resolvedProjectRoot = path.resolve(projectRoot);
|
|
82
|
+
const resolvedRepoRoot = path.resolve(repoRoot);
|
|
83
|
+
|
|
84
|
+
if (resolvedProjectRoot === resolvedRepoRoot) {
|
|
85
|
+
return [resolvedProjectRoot];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const relative = path.relative(resolvedRepoRoot, resolvedProjectRoot);
|
|
89
|
+
const isProjectRootInsideRepoRoot =
|
|
90
|
+
relative !== '' &&
|
|
91
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
92
|
+
relative !== '..' &&
|
|
93
|
+
!path.isAbsolute(relative);
|
|
94
|
+
|
|
95
|
+
if (!isProjectRootInsideRepoRoot) {
|
|
96
|
+
return [resolvedProjectRoot];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const directories: string[] = [];
|
|
100
|
+
let current = resolvedProjectRoot;
|
|
101
|
+
for (;;) {
|
|
102
|
+
directories.push(current);
|
|
103
|
+
if (current === resolvedRepoRoot) {
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
const parent = path.dirname(current);
|
|
107
|
+
if (parent === current) {
|
|
108
|
+
// Reached the filesystem root without hitting repoRoot; defensive
|
|
109
|
+
// guard against an infinite loop.
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
current = parent;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return directories;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const collectKeyFiles = (
|
|
119
|
+
directories: readonly string[],
|
|
120
|
+
repoRoot: string
|
|
121
|
+
): KeyFileMatch[] => {
|
|
122
|
+
const matches: KeyFileMatch[] = [];
|
|
123
|
+
|
|
124
|
+
for (const directory of directories) {
|
|
125
|
+
for (const candidate of METRO_KEY_FILENAMES) {
|
|
126
|
+
const fullPath = path.join(directory, candidate);
|
|
127
|
+
|
|
128
|
+
if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isFile()) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
matches.push({
|
|
133
|
+
relativePath: path
|
|
134
|
+
.relative(repoRoot, fullPath)
|
|
135
|
+
.split(path.sep)
|
|
136
|
+
.join('/'),
|
|
137
|
+
content: fs.readFileSync(fullPath),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return matches;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const hashKeyFiles = (matches: KeyFileMatch[]): string => {
|
|
146
|
+
const hash = createHash('sha256');
|
|
147
|
+
|
|
148
|
+
for (const match of matches
|
|
149
|
+
.slice()
|
|
150
|
+
.sort((a, b) => (a.relativePath < b.relativePath ? -1 : 1))) {
|
|
151
|
+
hash.update(match.relativePath);
|
|
152
|
+
hash.update('\0');
|
|
153
|
+
hash.update(match.content);
|
|
154
|
+
hash.update('\0');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return hash.digest('hex');
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export const computeMetroStaticInputs = (options: {
|
|
161
|
+
projectRoot: string;
|
|
162
|
+
repoRoot: string;
|
|
163
|
+
bundlerMetroVersion: string;
|
|
164
|
+
salt?: string;
|
|
165
|
+
}): Record<string, string> => {
|
|
166
|
+
try {
|
|
167
|
+
const resolvedProjectRoot = path.resolve(options.projectRoot);
|
|
168
|
+
const resolvedRepoRoot = path.resolve(options.repoRoot);
|
|
169
|
+
|
|
170
|
+
// Validate both roots exist so a missing directory degrades to the
|
|
171
|
+
// always-miss fallback below instead of silently hashing zero files.
|
|
172
|
+
if (!fs.statSync(resolvedProjectRoot).isDirectory()) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
`projectRoot "${resolvedProjectRoot}" is not a directory`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
if (!fs.statSync(resolvedRepoRoot).isDirectory()) {
|
|
178
|
+
throw new Error(`repoRoot "${resolvedRepoRoot}" is not a directory`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const directories = collectAncestorDirectories(
|
|
182
|
+
resolvedProjectRoot,
|
|
183
|
+
resolvedRepoRoot
|
|
184
|
+
);
|
|
185
|
+
const matches = collectKeyFiles(directories, resolvedRepoRoot);
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
lockfileHash: hashKeyFiles(matches),
|
|
189
|
+
bundlerMetroVersion: options.bundlerMetroVersion,
|
|
190
|
+
...(options.salt ? { salt: options.salt } : {}),
|
|
191
|
+
};
|
|
192
|
+
} catch (error) {
|
|
193
|
+
cacheLogger.warn(
|
|
194
|
+
`Failed to compute Metro cache key inputs for "${options.projectRoot}". Falling back to an always-miss key.`,
|
|
195
|
+
error
|
|
196
|
+
);
|
|
197
|
+
return {
|
|
198
|
+
lockfileHash: 'unavailable',
|
|
199
|
+
bundlerMetroVersion: options.bundlerMetroVersion,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
};
|