@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.
Files changed (52) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +26 -0
  3. package/dist/__tests__/boundary.test.d.ts +2 -0
  4. package/dist/__tests__/boundary.test.d.ts.map +1 -0
  5. package/dist/__tests__/boundary.test.js +77 -0
  6. package/dist/__tests__/domains/metro.test.d.ts +2 -0
  7. package/dist/__tests__/domains/metro.test.d.ts.map +1 -0
  8. package/dist/__tests__/domains/metro.test.js +234 -0
  9. package/dist/__tests__/index.test.d.ts +2 -0
  10. package/dist/__tests__/index.test.d.ts.map +1 -0
  11. package/dist/__tests__/index.test.js +40 -0
  12. package/dist/__tests__/paths.test.d.ts +2 -0
  13. package/dist/__tests__/paths.test.d.ts.map +1 -0
  14. package/dist/__tests__/paths.test.js +12 -0
  15. package/dist/__tests__/plan.test.d.ts +2 -0
  16. package/dist/__tests__/plan.test.d.ts.map +1 -0
  17. package/dist/__tests__/plan.test.js +197 -0
  18. package/dist/__tests__/warmth.test.d.ts +2 -0
  19. package/dist/__tests__/warmth.test.d.ts.map +1 -0
  20. package/dist/__tests__/warmth.test.js +36 -0
  21. package/dist/domains/metro.d.ts +8 -0
  22. package/dist/domains/metro.d.ts.map +1 -0
  23. package/dist/domains/metro.js +151 -0
  24. package/dist/index.d.ts +19 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +29 -0
  27. package/dist/paths.d.ts +14 -0
  28. package/dist/paths.d.ts.map +1 -0
  29. package/dist/paths.js +26 -0
  30. package/dist/plan.d.ts +54 -0
  31. package/dist/plan.d.ts.map +1 -0
  32. package/dist/plan.js +177 -0
  33. package/dist/tsconfig.lib.tsbuildinfo +1 -0
  34. package/dist/warmth.d.ts +8 -0
  35. package/dist/warmth.d.ts.map +1 -0
  36. package/dist/warmth.js +40 -0
  37. package/eslint.config.mjs +20 -0
  38. package/package.json +22 -0
  39. package/src/__tests__/boundary.test.ts +95 -0
  40. package/src/__tests__/domains/metro.test.ts +298 -0
  41. package/src/__tests__/index.test.ts +54 -0
  42. package/src/__tests__/paths.test.ts +18 -0
  43. package/src/__tests__/plan.test.ts +330 -0
  44. package/src/__tests__/warmth.test.ts +47 -0
  45. package/src/domains/metro.ts +202 -0
  46. package/src/index.ts +84 -0
  47. package/src/paths.ts +43 -0
  48. package/src/plan.ts +285 -0
  49. package/src/warmth.ts +59 -0
  50. package/tsconfig.json +13 -0
  51. package/tsconfig.lib.json +18 -0
  52. package/vite.config.ts +18 -0
package/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Callstack
4
+ Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ of this software and associated documentation files (the "Software"), to deal
6
+ in the Software without restriction, including without limitation the rights
7
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ copies of the Software, and to permit persons to whom the Software is
9
+ furnished to do so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all
12
+ copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,26 @@
1
+ ![harness-banner](https://react-native-harness.dev/harness-banner.jpg)
2
+
3
+ ### Cache Management for React Native Harness
4
+
5
+ [![mit licence][license-badge]][license]
6
+ [![npm downloads][npm-downloads-badge]][npm-downloads]
7
+ [![Chat][chat-badge]][chat]
8
+ [![PRs Welcome][prs-welcome-badge]][prs-welcome]
9
+
10
+ Owns the layout of `.harness/cache/` and exposes a narrow API for checking and preparing cache directories, so no other package needs to construct cache paths or hold cache-key logic.
11
+
12
+ ## Made with ❤️ at Callstack
13
+
14
+ `@react-native-harness/cache` is an open source project and will always remain free to use. If you think it's cool, please star it 🌟. [Callstack][callstack-readme-with-love] is a group of React and React Native geeks, contact us at [hello@callstack.com](mailto:hello@callstack.com) if you need any help with these or just want to say hi!
15
+
16
+ Like the project? ⚛️ [Join the team](https://callstack.com/careers/?utm_campaign=Senior_RN&utm_source=github&utm_medium=readme) who does amazing stuff for clients and drives React Native Open Source! 🔥
17
+
18
+ [callstack-readme-with-love]: https://callstack.com/?utm_source=github.com&utm_medium=referral&utm_campaign=react-native-harness&utm_term=readme-with-love
19
+ [license-badge]: https://img.shields.io/npm/l/@react-native-harness/cache?style=for-the-badge
20
+ [license]: https://github.com/callstackincubator/react-native-harness/blob/main/LICENSE
21
+ [npm-downloads-badge]: https://img.shields.io/npm/dm/@react-native-harness/cache?style=for-the-badge
22
+ [npm-downloads]: https://www.npmjs.com/package/@react-native-harness/cache
23
+ [prs-welcome-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=for-the-badge
24
+ [prs-welcome]: ../../CONTRIBUTING.md
25
+ [chat-badge]: https://img.shields.io/discord/426714625279524876.svg?style=for-the-badge
26
+ [chat]: https://discord.gg/xgGt7KAjxv
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=boundary.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"boundary.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/boundary.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,77 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { describe, expect, it } from 'vitest';
5
+ // All `.harness/cache` layout knowledge must live in this package; other
6
+ // packages consume `HarnessCache.paths` instead of constructing cache paths
7
+ // themselves. This test scans the other packages' sources for violations.
8
+ //
9
+ // Allowlisted until phase 2 migrates them onto this package:
10
+ // - packages/tools/src/harness-artifacts.ts: getHarnessCacheRootPath, used by
11
+ // platform-ios for the XCTest agent cache.
12
+ const ALLOWLIST = new Set(['tools/src/harness-artifacts.ts']);
13
+ const findRepoRoot = (start) => {
14
+ let dir = start;
15
+ while (!fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) {
16
+ const parent = path.dirname(dir);
17
+ if (parent === dir) {
18
+ throw new Error('Could not find the repository root');
19
+ }
20
+ dir = parent;
21
+ }
22
+ return dir;
23
+ };
24
+ const collectSourceFiles = (dir, files = []) => {
25
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
26
+ if (entry.isDirectory()) {
27
+ if (entry.name === 'node_modules' ||
28
+ entry.name === 'dist' ||
29
+ entry.name === '__tests__') {
30
+ continue;
31
+ }
32
+ collectSourceFiles(path.join(dir, entry.name), files);
33
+ }
34
+ else if (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx')) {
35
+ files.push(path.join(dir, entry.name));
36
+ }
37
+ }
38
+ return files;
39
+ };
40
+ const constructsHarnessCachePath = (content) => {
41
+ if (content.includes('.harness/cache')) {
42
+ return true;
43
+ }
44
+ const hasHarnessDirLiteral = content.includes("'.harness'") || content.includes('".harness"');
45
+ // A quoted 'cache' followed by `,`, `)`, or `]` is a path segment being
46
+ // joined, as opposed to e.g. a property name in a type.
47
+ const hasCacheSegmentLiteral = /['"]cache['"]\s*[,)\]]/.test(content);
48
+ return hasHarnessDirLiteral && hasCacheSegmentLiteral;
49
+ };
50
+ describe('cache path boundary', () => {
51
+ it('no package outside @react-native-harness/cache constructs .harness cache paths', () => {
52
+ const repoRoot = findRepoRoot(path.dirname(fileURLToPath(import.meta.url)));
53
+ const packagesRoot = path.join(repoRoot, 'packages');
54
+ const violations = [];
55
+ for (const entry of fs.readdirSync(packagesRoot, {
56
+ withFileTypes: true,
57
+ })) {
58
+ if (!entry.isDirectory() || entry.name === 'cache') {
59
+ continue;
60
+ }
61
+ const srcDir = path.join(packagesRoot, entry.name, 'src');
62
+ if (!fs.existsSync(srcDir)) {
63
+ continue;
64
+ }
65
+ for (const file of collectSourceFiles(srcDir)) {
66
+ const relativePath = path.relative(packagesRoot, file);
67
+ if (ALLOWLIST.has(relativePath)) {
68
+ continue;
69
+ }
70
+ if (constructsHarnessCachePath(fs.readFileSync(file, 'utf8'))) {
71
+ violations.push(relativePath);
72
+ }
73
+ }
74
+ }
75
+ expect(violations).toEqual([]);
76
+ });
77
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=metro.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"metro.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/domains/metro.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,234 @@
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 { computeMetroStaticInputs } from '../../domains/metro.js';
6
+ describe('computeMetroStaticInputs', () => {
7
+ let repoRoot;
8
+ let projectRoot;
9
+ beforeEach(() => {
10
+ repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-cache-metro-'));
11
+ projectRoot = path.join(repoRoot, 'apps', 'mobile');
12
+ fs.mkdirSync(projectRoot, { recursive: true });
13
+ });
14
+ afterEach(() => {
15
+ fs.rmSync(repoRoot, { recursive: true, force: true });
16
+ });
17
+ it('includes a lockfile at an ancestor directory between projectRoot and repoRoot', () => {
18
+ fs.writeFileSync(path.join(repoRoot, 'pnpm-lock.yaml'), 'lockfile-a');
19
+ const before = computeMetroStaticInputs({
20
+ projectRoot,
21
+ repoRoot,
22
+ bundlerMetroVersion: '1.0.0',
23
+ });
24
+ fs.writeFileSync(path.join(repoRoot, 'pnpm-lock.yaml'), 'lockfile-b');
25
+ const after = computeMetroStaticInputs({
26
+ projectRoot,
27
+ repoRoot,
28
+ bundlerMetroVersion: '1.0.0',
29
+ });
30
+ expect(before.lockfileHash).not.toBe(after.lockfileHash);
31
+ });
32
+ it('ignores a config file in a sibling directory not on the ancestor chain', () => {
33
+ const siblingDir = path.join(repoRoot, 'apps', 'other');
34
+ fs.mkdirSync(siblingDir, { recursive: true });
35
+ fs.writeFileSync(path.join(siblingDir, 'metro.config.js'), 'config-a');
36
+ const before = computeMetroStaticInputs({
37
+ projectRoot,
38
+ repoRoot,
39
+ bundlerMetroVersion: '1.0.0',
40
+ });
41
+ fs.writeFileSync(path.join(siblingDir, 'metro.config.js'), 'config-b');
42
+ const after = computeMetroStaticInputs({
43
+ projectRoot,
44
+ repoRoot,
45
+ bundlerMetroVersion: '1.0.0',
46
+ });
47
+ expect(before.lockfileHash).toBe(after.lockfileHash);
48
+ });
49
+ it('excludes a vendored lockfile under node_modules (regression pin)', () => {
50
+ // Pin for the old bug where a downward `hashFiles('**/pnpm-lock.yaml',
51
+ // ...)`-style glob would match a dependency's own lockfile under
52
+ // node_modules. Trivially true now that node_modules is never on the
53
+ // upward ancestor chain, but kept as an explicit regression test.
54
+ fs.writeFileSync(path.join(repoRoot, 'pnpm-lock.yaml'), 'lockfile-a');
55
+ const vendoredDir = path.join(projectRoot, 'node_modules', 'some-pkg');
56
+ fs.mkdirSync(vendoredDir, { recursive: true });
57
+ fs.writeFileSync(path.join(vendoredDir, 'package-lock.json'), 'vendored-lockfile');
58
+ const withVendored = computeMetroStaticInputs({
59
+ projectRoot,
60
+ repoRoot,
61
+ bundlerMetroVersion: '1.0.0',
62
+ });
63
+ fs.rmSync(path.join(projectRoot, 'node_modules'), {
64
+ recursive: true,
65
+ force: true,
66
+ });
67
+ const withoutVendored = computeMetroStaticInputs({
68
+ projectRoot,
69
+ repoRoot,
70
+ bundlerMetroVersion: '1.0.0',
71
+ });
72
+ expect(withVendored.lockfileHash).toBe(withoutVendored.lockfileHash);
73
+ });
74
+ it('is deterministic across independent calls given the same fixture tree', () => {
75
+ fs.writeFileSync(path.join(repoRoot, 'pnpm-lock.yaml'), 'lockfile-a');
76
+ fs.writeFileSync(path.join(projectRoot, 'metro.config.js'), 'config-a');
77
+ const first = computeMetroStaticInputs({
78
+ projectRoot,
79
+ repoRoot,
80
+ bundlerMetroVersion: '1.0.0',
81
+ });
82
+ const second = computeMetroStaticInputs({
83
+ projectRoot,
84
+ repoRoot,
85
+ bundlerMetroVersion: '1.0.0',
86
+ });
87
+ expect(first.lockfileHash).toBe(second.lockfileHash);
88
+ });
89
+ it('changes the hash when a matched file changes content', () => {
90
+ fs.writeFileSync(path.join(repoRoot, 'pnpm-lock.yaml'), 'lockfile-a');
91
+ const before = computeMetroStaticInputs({
92
+ projectRoot,
93
+ repoRoot,
94
+ bundlerMetroVersion: '1.0.0',
95
+ });
96
+ fs.writeFileSync(path.join(repoRoot, 'pnpm-lock.yaml'), 'lockfile-b');
97
+ const after = computeMetroStaticInputs({
98
+ projectRoot,
99
+ repoRoot,
100
+ bundlerMetroVersion: '1.0.0',
101
+ });
102
+ expect(before.lockfileHash).not.toBe(after.lockfileHash);
103
+ });
104
+ it('does not change the hash when an unrelated file changes content', () => {
105
+ fs.writeFileSync(path.join(repoRoot, 'pnpm-lock.yaml'), 'lockfile-a');
106
+ fs.writeFileSync(path.join(projectRoot, 'notes.txt'), 'v1');
107
+ const before = computeMetroStaticInputs({
108
+ projectRoot,
109
+ repoRoot,
110
+ bundlerMetroVersion: '1.0.0',
111
+ });
112
+ fs.writeFileSync(path.join(projectRoot, 'notes.txt'), 'v2');
113
+ const after = computeMetroStaticInputs({
114
+ projectRoot,
115
+ repoRoot,
116
+ bundlerMetroVersion: '1.0.0',
117
+ });
118
+ expect(before.lockfileHash).toBe(after.lockfileHash);
119
+ });
120
+ it.each([
121
+ ['metro.config.mts', 'metro-mts-a', 'metro-mts-b'],
122
+ ['metro.config.cts', 'metro-cts-a', 'metro-cts-b'],
123
+ ['metro.config.json', 'metro-json-a', 'metro-json-b'],
124
+ ['babel.config.cts', 'babel-cts-a', 'babel-cts-b'],
125
+ ])('picks up %s as a candidate at projectRoot', (filename, before, after) => {
126
+ fs.writeFileSync(path.join(projectRoot, filename), before);
127
+ const beforeInputs = computeMetroStaticInputs({
128
+ projectRoot,
129
+ repoRoot,
130
+ bundlerMetroVersion: '1.0.0',
131
+ });
132
+ fs.writeFileSync(path.join(projectRoot, filename), after);
133
+ const afterInputs = computeMetroStaticInputs({
134
+ projectRoot,
135
+ repoRoot,
136
+ bundlerMetroVersion: '1.0.0',
137
+ });
138
+ expect(beforeInputs.lockfileHash).not.toBe(afterInputs.lockfileHash);
139
+ });
140
+ it('picks up .config/metro.js as a candidate at projectRoot', () => {
141
+ const configDir = path.join(projectRoot, '.config');
142
+ fs.mkdirSync(configDir, { recursive: true });
143
+ fs.writeFileSync(path.join(configDir, 'metro.js'), 'config-a');
144
+ const before = computeMetroStaticInputs({
145
+ projectRoot,
146
+ repoRoot,
147
+ bundlerMetroVersion: '1.0.0',
148
+ });
149
+ fs.writeFileSync(path.join(configDir, 'metro.js'), 'config-b');
150
+ const after = computeMetroStaticInputs({
151
+ projectRoot,
152
+ repoRoot,
153
+ bundlerMetroVersion: '1.0.0',
154
+ });
155
+ expect(before.lockfileHash).not.toBe(after.lockfileHash);
156
+ });
157
+ it('no longer treats babel.config.ts as a candidate', () => {
158
+ const before = computeMetroStaticInputs({
159
+ projectRoot,
160
+ repoRoot,
161
+ bundlerMetroVersion: '1.0.0',
162
+ });
163
+ fs.writeFileSync(path.join(projectRoot, 'babel.config.ts'), 'babel-ts-a');
164
+ const afterCreate = computeMetroStaticInputs({
165
+ projectRoot,
166
+ repoRoot,
167
+ bundlerMetroVersion: '1.0.0',
168
+ });
169
+ fs.writeFileSync(path.join(projectRoot, 'babel.config.ts'), 'babel-ts-b');
170
+ const afterChange = computeMetroStaticInputs({
171
+ projectRoot,
172
+ repoRoot,
173
+ bundlerMetroVersion: '1.0.0',
174
+ });
175
+ expect(afterCreate.lockfileHash).toBe(before.lockfileHash);
176
+ expect(afterChange.lockfileHash).toBe(before.lockfileHash);
177
+ });
178
+ it('passes bundlerMetroVersion and salt through into the returned object', () => {
179
+ fs.writeFileSync(path.join(repoRoot, 'pnpm-lock.yaml'), 'lockfile-a');
180
+ const withoutSalt = computeMetroStaticInputs({
181
+ projectRoot,
182
+ repoRoot,
183
+ bundlerMetroVersion: '2.3.4',
184
+ });
185
+ expect(withoutSalt.bundlerMetroVersion).toBe('2.3.4');
186
+ expect(withoutSalt.salt).toBeUndefined();
187
+ const withSalt = computeMetroStaticInputs({
188
+ projectRoot,
189
+ repoRoot,
190
+ bundlerMetroVersion: '2.3.4',
191
+ salt: 'v2',
192
+ });
193
+ expect(withSalt.salt).toBe('v2');
194
+ });
195
+ it('returns a stable fallback instead of throwing when projectRoot does not exist', () => {
196
+ const missingProjectRoot = path.join(repoRoot, 'does-not-exist');
197
+ const result = computeMetroStaticInputs({
198
+ projectRoot: missingProjectRoot,
199
+ repoRoot,
200
+ bundlerMetroVersion: '1.0.0',
201
+ });
202
+ expect(result).toEqual({
203
+ lockfileHash: 'unavailable',
204
+ bundlerMetroVersion: '1.0.0',
205
+ });
206
+ });
207
+ it('returns a stable fallback instead of throwing when repoRoot does not exist', () => {
208
+ const missingRepoRoot = path.join(repoRoot, 'does-not-exist');
209
+ const result = computeMetroStaticInputs({
210
+ projectRoot,
211
+ repoRoot: missingRepoRoot,
212
+ bundlerMetroVersion: '1.0.0',
213
+ });
214
+ expect(result).toEqual({
215
+ lockfileHash: 'unavailable',
216
+ bundlerMetroVersion: '1.0.0',
217
+ });
218
+ });
219
+ it('works when projectRoot === repoRoot (single directory examined)', () => {
220
+ fs.writeFileSync(path.join(repoRoot, 'pnpm-lock.yaml'), 'lockfile-a');
221
+ const before = computeMetroStaticInputs({
222
+ projectRoot: repoRoot,
223
+ repoRoot,
224
+ bundlerMetroVersion: '1.0.0',
225
+ });
226
+ fs.writeFileSync(path.join(repoRoot, 'pnpm-lock.yaml'), 'lockfile-b');
227
+ const after = computeMetroStaticInputs({
228
+ projectRoot: repoRoot,
229
+ repoRoot,
230
+ bundlerMetroVersion: '1.0.0',
231
+ });
232
+ expect(before.lockfileHash).not.toBe(after.lockfileHash);
233
+ });
234
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=index.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/index.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,40 @@
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
+ describe('createHarnessCache', () => {
7
+ let projectRoot;
8
+ beforeEach(() => {
9
+ projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-cache-'));
10
+ });
11
+ afterEach(() => {
12
+ fs.rmSync(projectRoot, { recursive: true, force: true });
13
+ vi.restoreAllMocks();
14
+ });
15
+ it('exposes paths rooted at <projectRoot>/.harness/cache', () => {
16
+ const cache = createHarnessCache({ projectRoot });
17
+ expect(cache.paths.root).toBe(path.join(projectRoot, '.harness', 'cache'));
18
+ });
19
+ it('reports metro as cold before directories exist', () => {
20
+ const cache = createHarnessCache({ projectRoot });
21
+ expect(cache.isWarm('metro')).toBe(false);
22
+ });
23
+ it('creates domain directories and reports warm once populated', () => {
24
+ const cache = createHarnessCache({ projectRoot });
25
+ cache.ensureDomainDirectories('metro');
26
+ expect(fs.existsSync(cache.paths.metroTransform)).toBe(true);
27
+ expect(fs.existsSync(cache.paths.metroFileMap)).toBe(true);
28
+ expect(cache.isWarm('metro')).toBe(false);
29
+ fs.writeFileSync(path.join(cache.paths.metroTransform, 'entry'), 'data');
30
+ expect(cache.isWarm('metro')).toBe(true);
31
+ });
32
+ it('swallows and logs errors instead of throwing when directories cannot be created', () => {
33
+ // Point the project root at a path that cannot be created (its parent is a file).
34
+ const blockedRoot = path.join(projectRoot, 'blocked-file', 'nested');
35
+ fs.writeFileSync(path.join(projectRoot, 'blocked-file'), 'not a directory');
36
+ const cache = createHarnessCache({ projectRoot: blockedRoot });
37
+ expect(() => cache.ensureDomainDirectories('metro')).not.toThrow();
38
+ expect(cache.isWarm('metro')).toBe(false);
39
+ });
40
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=paths.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paths.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/paths.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,12 @@
1
+ import path from 'node:path';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { createHarnessCachePaths } from '../paths.js';
4
+ describe('createHarnessCachePaths', () => {
5
+ it('nests cache paths under <projectRoot>/.harness/cache', () => {
6
+ const projectRoot = '/tmp/some-project';
7
+ const paths = createHarnessCachePaths(projectRoot);
8
+ expect(paths.root).toBe(path.join(projectRoot, '.harness', 'cache'));
9
+ expect(paths.metroTransform).toBe(path.join(projectRoot, '.harness', 'cache', 'metro'));
10
+ expect(paths.metroFileMap).toBe(path.join(projectRoot, '.harness', 'cache', 'metro-file-map'));
11
+ });
12
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=plan.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plan.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/plan.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,197 @@
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 { planRestore, planSave, snapshot, writeKeysFile, } from '../plan.js';
7
+ describe('planRestore', () => {
8
+ let projectRoot;
9
+ beforeEach(() => {
10
+ projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-cache-'));
11
+ });
12
+ afterEach(() => {
13
+ fs.rmSync(projectRoot, { recursive: true, force: true });
14
+ });
15
+ const inputs = (staticInputs) => ({
16
+ metro: { os: 'linux', staticInputs },
17
+ });
18
+ it('produces the same restoreKey across independent calls given the same staticInputs', () => {
19
+ const paths = createHarnessCachePaths(projectRoot);
20
+ const first = planRestore(paths, inputs({ a: '1', b: '2' }));
21
+ const second = planRestore(paths, inputs({ a: '1', b: '2' }));
22
+ expect(first.domains.metro?.restoreKey).toBe(second.domains.metro?.restoreKey);
23
+ });
24
+ it('produces the same restoreKey regardless of staticInputs key order', () => {
25
+ const paths = createHarnessCachePaths(projectRoot);
26
+ const first = planRestore(paths, inputs({ a: '1', b: '2' }));
27
+ const second = planRestore(paths, inputs({ b: '2', a: '1' }));
28
+ expect(first.domains.metro?.restoreKey).toBe(second.domains.metro?.restoreKey);
29
+ });
30
+ it('produces a different restoreKey for different staticInputs', () => {
31
+ const paths = createHarnessCachePaths(projectRoot);
32
+ const first = planRestore(paths, inputs({ a: '1' }));
33
+ const second = planRestore(paths, inputs({ a: '2' }));
34
+ expect(first.domains.metro?.restoreKey).not.toBe(second.domains.metro?.restoreKey);
35
+ });
36
+ it('shapes restorePrefixes for the accretive metro domain as a single trailing-dash prefix', () => {
37
+ const paths = createHarnessCachePaths(projectRoot);
38
+ const plan = planRestore(paths, inputs({ a: '1' }));
39
+ const domain = plan.domains.metro;
40
+ expect(domain?.accretive).toBe(true);
41
+ expect(domain?.restorePrefixes).toEqual([`${domain?.restoreKey}-`]);
42
+ });
43
+ it('only includes domains present in the inputs', () => {
44
+ const paths = createHarnessCachePaths(projectRoot);
45
+ const plan = planRestore(paths, {});
46
+ expect(plan.domains).toEqual({});
47
+ });
48
+ it('reports the domain cache directories as paths', () => {
49
+ const paths = createHarnessCachePaths(projectRoot);
50
+ const plan = planRestore(paths, inputs({ a: '1' }));
51
+ expect(plan.domains.metro?.paths).toEqual([
52
+ paths.metroTransform,
53
+ paths.metroFileMap,
54
+ ]);
55
+ });
56
+ });
57
+ describe('snapshot', () => {
58
+ let projectRoot;
59
+ beforeEach(() => {
60
+ projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-cache-'));
61
+ });
62
+ afterEach(() => {
63
+ fs.rmSync(projectRoot, { recursive: true, force: true });
64
+ });
65
+ it('is empty for a domain whose directories do not exist', () => {
66
+ const paths = createHarnessCachePaths(projectRoot);
67
+ expect(() => snapshot(paths)).not.toThrow();
68
+ expect(snapshot(paths).metro).toEqual([]);
69
+ });
70
+ it('collects entries for files under the domain directories', () => {
71
+ const paths = createHarnessCachePaths(projectRoot);
72
+ fs.mkdirSync(path.join(paths.metroTransform, 'nested'), {
73
+ recursive: true,
74
+ });
75
+ fs.writeFileSync(path.join(paths.metroTransform, 'a.txt'), 'aaa');
76
+ fs.writeFileSync(path.join(paths.metroTransform, 'nested', 'b.txt'), 'bb');
77
+ const result = snapshot(paths);
78
+ const entries = result.metro ?? [];
79
+ expect(entries).toHaveLength(2);
80
+ const aEntry = entries.find((entry) => entry.path.endsWith(path.join('metro', 'a.txt')));
81
+ const bEntry = entries.find((entry) => entry.path.endsWith(path.join('metro', 'nested', 'b.txt')));
82
+ expect(aEntry?.sizeBytes).toBe(3);
83
+ expect(bEntry?.sizeBytes).toBe(2);
84
+ expect(typeof aEntry?.mtimeMs).toBe('number');
85
+ });
86
+ });
87
+ describe('planSave', () => {
88
+ let projectRoot;
89
+ beforeEach(() => {
90
+ projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-cache-'));
91
+ });
92
+ afterEach(() => {
93
+ fs.rmSync(projectRoot, { recursive: true, force: true });
94
+ });
95
+ const inputs = (staticInputs = { a: '1' }) => ({
96
+ metro: { os: 'linux', staticInputs },
97
+ });
98
+ it('does not save when there is no change, regardless of policy', () => {
99
+ const paths = createHarnessCachePaths(projectRoot);
100
+ fs.mkdirSync(paths.metroTransform, { recursive: true });
101
+ fs.writeFileSync(path.join(paths.metroTransform, 'a.txt'), 'aaa');
102
+ const before = snapshot(paths);
103
+ const plan = planSave(paths, before, { mode: 'always', isDefaultBranch: true }, inputs());
104
+ expect(plan.domains.metro?.shouldSave).toBe(false);
105
+ expect(plan.domains.metro?.delta).toEqual({
106
+ addedEntries: 0,
107
+ removedEntries: 0,
108
+ addedBytes: 0,
109
+ });
110
+ });
111
+ it('saves an added file only when policy allows it', () => {
112
+ const paths = createHarnessCachePaths(projectRoot);
113
+ fs.mkdirSync(paths.metroTransform, { recursive: true });
114
+ const before = snapshot(paths);
115
+ fs.writeFileSync(path.join(paths.metroTransform, 'a.txt'), 'aaa');
116
+ const disallowed = planSave(paths, before, { mode: 'default-branch', isDefaultBranch: false }, inputs());
117
+ expect(disallowed.domains.metro?.shouldSave).toBe(false);
118
+ expect(disallowed.domains.metro?.delta.addedEntries).toBe(1);
119
+ expect(disallowed.domains.metro?.delta.addedBytes).toBe(3);
120
+ const allowed = planSave(paths, before, { mode: 'default-branch', isDefaultBranch: true }, inputs());
121
+ expect(allowed.domains.metro?.shouldSave).toBe(true);
122
+ });
123
+ it('never saves when policy mode is "never"', () => {
124
+ const paths = createHarnessCachePaths(projectRoot);
125
+ fs.mkdirSync(paths.metroTransform, { recursive: true });
126
+ const before = snapshot(paths);
127
+ fs.writeFileSync(path.join(paths.metroTransform, 'a.txt'), 'aaa');
128
+ const plan = planSave(paths, before, { mode: 'never', isDefaultBranch: true }, inputs());
129
+ expect(plan.domains.metro?.shouldSave).toBe(false);
130
+ });
131
+ it('counts a removed file in the delta', () => {
132
+ const paths = createHarnessCachePaths(projectRoot);
133
+ fs.mkdirSync(paths.metroTransform, { recursive: true });
134
+ fs.writeFileSync(path.join(paths.metroTransform, 'a.txt'), 'aaa');
135
+ const before = snapshot(paths);
136
+ fs.rmSync(path.join(paths.metroTransform, 'a.txt'));
137
+ const plan = planSave(paths, before, { mode: 'always', isDefaultBranch: true }, inputs());
138
+ expect(plan.domains.metro?.delta.removedEntries).toBe(1);
139
+ expect(plan.domains.metro?.shouldSave).toBe(true);
140
+ });
141
+ it('does not treat an mtime-only change as an added entry or change the content hash', () => {
142
+ const paths = createHarnessCachePaths(projectRoot);
143
+ fs.mkdirSync(paths.metroTransform, { recursive: true });
144
+ const filePath = path.join(paths.metroTransform, 'a.txt');
145
+ fs.writeFileSync(filePath, 'aaa');
146
+ const before = snapshot(paths);
147
+ const beforeSave = planSave(paths, before, { mode: 'always', isDefaultBranch: true }, inputs());
148
+ // Touch the file's mtime without changing its size/content.
149
+ const future = new Date(Date.now() + 10_000);
150
+ fs.utimesSync(filePath, future, future);
151
+ const afterSave = planSave(paths, before, { mode: 'always', isDefaultBranch: true }, inputs());
152
+ expect(afterSave.domains.metro?.delta.addedEntries).toBe(0);
153
+ expect(afterSave.domains.metro?.shouldSave).toBe(false);
154
+ expect(afterSave.domains.metro?.saveKey).toBe(beforeSave.domains.metro?.saveKey);
155
+ });
156
+ it('produces a deterministic saveKey across independent planSave calls given the same on-disk state', () => {
157
+ const paths = createHarnessCachePaths(projectRoot);
158
+ fs.mkdirSync(paths.metroTransform, { recursive: true });
159
+ fs.writeFileSync(path.join(paths.metroTransform, 'a.txt'), 'aaa');
160
+ const before = {};
161
+ const first = planSave(paths, before, { mode: 'always', isDefaultBranch: true }, inputs());
162
+ const second = planSave(paths, before, { mode: 'always', isDefaultBranch: true }, inputs());
163
+ expect(first.domains.metro?.saveKey).toBe(second.domains.metro?.saveKey);
164
+ });
165
+ });
166
+ describe('writeKeysFile', () => {
167
+ let projectRoot;
168
+ beforeEach(() => {
169
+ projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-cache-'));
170
+ });
171
+ afterEach(() => {
172
+ fs.rmSync(projectRoot, { recursive: true, force: true });
173
+ });
174
+ it('writes keys.json under the cache root, outside domain subdirectories', () => {
175
+ const paths = createHarnessCachePaths(projectRoot);
176
+ const plan = planRestore(paths, {
177
+ metro: { os: 'linux', staticInputs: { a: '1' } },
178
+ });
179
+ writeKeysFile(paths, plan);
180
+ const filePath = path.join(paths.root, 'keys.json');
181
+ expect(fs.existsSync(filePath)).toBe(true);
182
+ expect(filePath.startsWith(paths.metroTransform)).toBe(false);
183
+ expect(filePath.startsWith(paths.metroFileMap)).toBe(false);
184
+ const written = JSON.parse(fs.readFileSync(filePath, 'utf8'));
185
+ expect(written.version).toBe(1);
186
+ });
187
+ it('swallows errors instead of throwing when the cache root cannot be created', () => {
188
+ const blockedRoot = path.join(projectRoot, 'blocked-file', 'nested');
189
+ fs.writeFileSync(path.join(projectRoot, 'blocked-file'), 'not a directory');
190
+ const paths = createHarnessCachePaths(blockedRoot);
191
+ const plan = planRestore(paths, {
192
+ metro: { os: 'linux', staticInputs: { a: '1' } },
193
+ });
194
+ expect(() => writeKeysFile(paths, plan)).not.toThrow();
195
+ expect(fs.existsSync(path.join(paths.root, 'keys.json'))).toBe(false);
196
+ });
197
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=warmth.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"warmth.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/warmth.test.ts"],"names":[],"mappings":""}