@@ -14,25 +14,55 @@ const {
1414 exists,
1515 extractXCFramework,
1616 findFirst,
17+ firstExistingUrl,
1718 formatBytes,
1819 formatSpeed,
19- hermesReleaseUrl,
20+ hermesReleaseUrls,
21+ mavenRepositoryUrls,
22+ reactNativeMavenMirrorEnabled,
2023 resolveCacheSlotVersion,
2124 resolveHermesArtifact,
2225 resolveLatestV1Version,
2326 resolveNightlyVersion,
2427 resolveRNCoreArtifact,
2528 resolveRNDepsArtifact,
2629 resolveSnapshotUrl,
27- rnCoreReleaseUrl ,
28- rnDepsReleaseUrl ,
30+ rnCoreReleaseUrls ,
31+ rnDepsReleaseUrls ,
2932 validateArtifactsCache,
3033} = require ( '../download-spm-artifacts' ) ;
3134const { execSync} = require ( 'node:child_process' ) ;
3235const fs = require ( 'node:fs' ) ;
3336const os = require ( 'node:os' ) ;
3437const path = require ( 'node:path' ) ;
3538
39+ // Repository-selection env vars are read at call time; clear them per test so
40+ // results don't depend on the host machine, and restore afterwards.
41+ const REPO_ENV_KEYS = [
42+ 'ENTERPRISE_REPOSITORY' ,
43+ 'RCT_REACT_NATIVE_MAVEN_MIRROR_ENABLED' ,
44+ ] ;
45+ let savedRepoEnv = { } ;
46+ beforeEach ( ( ) => {
47+ savedRepoEnv = { } ;
48+ for ( const key of REPO_ENV_KEYS ) {
49+ savedRepoEnv [ key ] = process . env [ key ] ;
50+ delete process . env [ key ] ;
51+ }
52+ } ) ;
53+ afterEach ( ( ) => {
54+ for ( const key of REPO_ENV_KEYS ) {
55+ if ( savedRepoEnv [ key ] !== undefined ) {
56+ process . env [ key ] = savedRepoEnv [ key ] ;
57+ } else {
58+ delete process . env [ key ] ;
59+ }
60+ }
61+ } ) ;
62+
63+ const MIRROR = 'https://repo.reactnative.dev/maven2' ;
64+ const CENTRAL = 'https://repo1.maven.org/maven2' ;
65+
3666// Shared fetch router used by the URL-resolution tests below. Each key is a
3767// URL substring; the matched value describes the response. Anything unmatched
3868// returns 404 (the "release not found, fall back to snapshot" path).
@@ -193,49 +223,138 @@ describe('resolveHermesArtifact', () => {
193223} ) ;
194224
195225// ---------------------------------------------------------------------------
196- // Maven URL builders — pure string composition.
226+ // Repository selection — RN Maven mirror first, Maven Central as fallback;
227+ // ENTERPRISE_REPOSITORY replaces both.
228+ // ---------------------------------------------------------------------------
229+
230+ describe ( 'mavenRepositoryUrls' , ( ) => {
231+ it ( 'prefers the RN Maven mirror and keeps Maven Central as fallback by default' , ( ) => {
232+ expect ( reactNativeMavenMirrorEnabled ( ) ) . toBe ( true ) ;
233+ expect ( mavenRepositoryUrls ( ) ) . toEqual ( [ MIRROR , CENTRAL ] ) ;
234+ } ) ;
235+
236+ it ( 'stays enabled when RCT_REACT_NATIVE_MAVEN_MIRROR_ENABLED=true' , ( ) => {
237+ process . env . RCT_REACT_NATIVE_MAVEN_MIRROR_ENABLED = 'true' ;
238+ expect ( reactNativeMavenMirrorEnabled ( ) ) . toBe ( true ) ;
239+ expect ( mavenRepositoryUrls ( ) ) . toEqual ( [ MIRROR , CENTRAL ] ) ;
240+ } ) ;
241+
242+ it . each ( [ 'false' , 'FALSE' , '0' ] ) (
243+ 'drops the mirror when RCT_REACT_NATIVE_MAVEN_MIRROR_ENABLED=%s' ,
244+ value => {
245+ process . env . RCT_REACT_NATIVE_MAVEN_MIRROR_ENABLED = value ;
246+ expect ( reactNativeMavenMirrorEnabled ( ) ) . toBe ( false ) ;
247+ expect ( mavenRepositoryUrls ( ) ) . toEqual ( [ CENTRAL ] ) ;
248+ } ,
249+ ) ;
250+
251+ it ( 'ENTERPRISE_REPOSITORY replaces mirror AND Central, stripping trailing slashes' , ( ) => {
252+ process . env . ENTERPRISE_REPOSITORY = 'https://maven.internal.example//' ;
253+ expect ( mavenRepositoryUrls ( ) ) . toEqual ( [ 'https://maven.internal.example' ] ) ;
254+ } ) ;
255+
256+ it . each ( [ '' , ' ' , '\t\n' ] ) (
257+ 'ignores a blank ENTERPRISE_REPOSITORY (%j) and keeps the defaults' ,
258+ value => {
259+ process . env . ENTERPRISE_REPOSITORY = value ;
260+ expect ( mavenRepositoryUrls ( ) ) . toEqual ( [ MIRROR , CENTRAL ] ) ;
261+ } ,
262+ ) ;
263+
264+ it ( 'trims surrounding whitespace from ENTERPRISE_REPOSITORY' , ( ) => {
265+ process . env . ENTERPRISE_REPOSITORY = ' https://maven.internal.example/ ' ;
266+ expect ( mavenRepositoryUrls ( ) ) . toEqual ( [ 'https://maven.internal.example' ] ) ;
267+ } ) ;
268+ } ) ;
269+
270+ // ---------------------------------------------------------------------------
271+ // Maven URL builders — one candidate per repository, mirror first.
197272// ---------------------------------------------------------------------------
198273
199274describe ( 'release URL builders' , ( ) => {
200- it ( 'rnCoreReleaseUrl points at the reactnative-core classifier on Maven Central' , ( ) => {
201- const url = rnCoreReleaseUrl ( '0.85.0' , 'debug' ) ;
202- expect ( url ) . toBe (
203- 'https://repo1.maven.org/maven2/com/facebook/react/react-native-artifacts/0.85.0/' +
204- 'react-native-artifacts-0.85.0-reactnative-core-debug.tar.gz' ,
275+ it ( 'rnCoreReleaseUrls builds a candidate per repository for the reactnative-core classifier' , ( ) => {
276+ const suffix =
277+ '/com/facebook/react/react-native-artifacts/0.85.0/' +
278+ 'react-native-artifacts-0.85.0-reactnative-core-debug.tar.gz' ;
279+ expect ( rnCoreReleaseUrls ( '0.85.0' , 'debug' ) ) . toEqual ( [
280+ MIRROR + suffix ,
281+ CENTRAL + suffix ,
282+ ] ) ;
283+ } ) ;
284+
285+ it ( 'rnDepsReleaseUrls points at the reactnative-dependencies classifier' , ( ) => {
286+ const urls = rnDepsReleaseUrls ( '0.85.0' , 'release' ) ;
287+ expect ( urls ) . toHaveLength ( 2 ) ;
288+ for ( const url of urls ) {
289+ expect ( url ) . toContain ( 'react-native-artifacts/0.85.0/' ) ;
290+ expect ( url ) . toContain ( 'reactnative-dependencies-release.tar.gz' ) ;
291+ }
292+ } ) ;
293+
294+ it ( 'hermesReleaseUrls points at the hermes-ios coordinate' , ( ) => {
295+ const suffix =
296+ '/com/facebook/hermes/hermes-ios/0.13.0/' +
297+ 'hermes-ios-0.13.0-hermes-ios-debug.tar.gz' ;
298+ expect ( hermesReleaseUrls ( '0.13.0' , 'debug' ) ) . toEqual ( [
299+ MIRROR + suffix ,
300+ CENTRAL + suffix ,
301+ ] ) ;
302+ } ) ;
303+
304+ it ( 'honors ENTERPRISE_REPOSITORY as the only release base URL' , ( ) => {
305+ process . env . ENTERPRISE_REPOSITORY = 'https://maven.internal.example' ;
306+ const urls = rnCoreReleaseUrls ( '0.85.0' , 'debug' ) ;
307+ expect ( urls ) . toHaveLength ( 1 ) ;
308+ expect ( urls [ 0 ] ) . toContain (
309+ 'https://maven.internal.example/com/facebook/react/' ,
205310 ) ;
206311 } ) ;
312+ } ) ;
313+
314+ // ---------------------------------------------------------------------------
315+ // firstExistingUrl — ordered candidate probing.
316+ // ---------------------------------------------------------------------------
317+
318+ describe ( 'firstExistingUrl' , ( ) => {
319+ let origFetch ;
320+ beforeEach ( ( ) => {
321+ origFetch = globalThis . fetch ;
322+ } ) ;
323+ afterEach ( ( ) => {
324+ globalThis . fetch = origFetch ;
325+ } ) ;
207326
208- it ( 'rnDepsReleaseUrl points at the reactnative-dependencies classifier' , ( ) => {
209- const url = rnDepsReleaseUrl ( '0.85.0' , 'release' ) ;
210- expect ( url ) . toContain ( 'react-native-artifacts/0.85.0/' ) ;
211- expect ( url ) . toContain ( 'reactnative-dependencies-release.tar.gz' ) ;
327+ it ( 'returns the first candidate when it exists, without probing the rest' , async ( ) => {
328+ globalThis . fetch = jest . fn ( async ( ) => ( { status : 200 } ) ) ;
329+ const url = await firstExistingUrl ( [ 'https://a/x' , 'https://b/x' ] ) ;
330+ expect ( url ) . toBe ( 'https://a/x' ) ;
331+ expect ( globalThis . fetch ) . toHaveBeenCalledTimes ( 1 ) ;
212332 } ) ;
213333
214- it ( 'hermesReleaseUrl points at the hermes-ios coordinate' , ( ) => {
215- const url = hermesReleaseUrl ( '0.13.0' , 'debug' ) ;
216- expect ( url ) . toBe (
217- 'https://repo1.maven.org/maven2/com/facebook/hermes/hermes-ios/0.13.0/' +
218- 'hermes-ios-0.13.0-hermes-ios-debug.tar.gz' ,
334+ it ( 'falls through to the next candidate on a miss' , async ( ) => {
335+ globalThis . fetch = jest . fn ( async u =>
336+ String ( u ) . startsWith ( 'https://b/' ) ? { status : 200 } : { status : 404 } ,
337+ ) ;
338+ expect ( await firstExistingUrl ( [ 'https://a/x' , 'https://b/x' ] ) ) . toBe (
339+ 'https://b/x' ,
219340 ) ;
220341 } ) ;
221342
222- it ( 'honors ENTERPRISE_REPOSITORY for the release base URL' , ( ) => {
223- jest . isolateModules ( ( ) => {
224- const prev = process . env . ENTERPRISE_REPOSITORY ;
225- process . env . ENTERPRISE_REPOSITORY = 'https://maven.internal.example' ;
226- try {
227- const mod = require ( '../download-spm-artifacts' ) ;
228- expect ( mod . rnCoreReleaseUrl ( '0.85.0' , 'debug' ) ) . toContain (
229- 'https://maven.internal.example/com/facebook/react/' ,
230- ) ;
231- } finally {
232- if ( prev !== undefined ) {
233- process . env . ENTERPRISE_REPOSITORY = prev ;
234- } else {
235- delete process . env . ENTERPRISE_REPOSITORY ;
236- }
343+ it ( 'falls through to the next candidate when the probe errors (outage)' , async ( ) => {
344+ globalThis . fetch = jest . fn ( async u => {
345+ if ( String ( u ) . startsWith ( 'https://a/' ) ) {
346+ throw new Error ( 'mirror down' ) ;
237347 }
348+ return { status : 200 } ;
238349 } ) ;
350+ expect ( await firstExistingUrl ( [ 'https://a/x' , 'https://b/x' ] ) ) . toBe (
351+ 'https://b/x' ,
352+ ) ;
353+ } ) ;
354+
355+ it ( 'returns null when no candidate exists' , async ( ) => {
356+ globalThis . fetch = jest . fn ( async ( ) => ( { status : 404 } ) ) ;
357+ expect ( await firstExistingUrl ( [ 'https://a/x' , 'https://b/x' ] ) ) . toBeNull ( ) ;
239358 } ) ;
240359} ) ;
241360
@@ -425,16 +544,61 @@ describe('resolveRNCoreArtifact', () => {
425544 fs . rmSync ( tempDir , { recursive : true , force : true } ) ;
426545 } ) ;
427546
428- it ( 'uses the stable release URL when it exists' , async ( ) => {
547+ it ( 'prefers the RN Maven mirror release when it exists' , async ( ) => {
429548 globalThis . fetch = routerFetch ( {
430549 'reactnative-core-debug.tar.gz' : { status : 200 } ,
431550 } ) ;
432551 const result = await resolveRNCoreArtifact ( '0.85.0' , 'debug' , null ) ;
433552 expect ( result . version ) . toBe ( '0.85.0' ) ;
553+ expect ( result . url ) . toContain ( 'repo.reactnative.dev' ) ;
554+ expect ( result . url ) . toContain ( 'reactnative-core-debug.tar.gz' ) ;
555+ // Maven Central is never probed when the mirror has the artifact.
556+ expect ( globalThis . fetch ) . toHaveBeenCalledTimes ( 1 ) ;
557+ } ) ;
558+
559+ it ( 'falls back to Maven Central when the mirror is missing the artifact' , async ( ) => {
560+ globalThis . fetch = routerFetch ( {
561+ // Only Central serves the artifact; the mirror probe hits the 404
562+ // default.
563+ 'repo1.maven.org' : { status : 200 } ,
564+ } ) ;
565+ const result = await resolveRNCoreArtifact ( '0.85.0' , 'debug' , null ) ;
566+ expect ( result . version ) . toBe ( '0.85.0' ) ;
434567 expect ( result . url ) . toContain ( 'repo1.maven.org' ) ;
435568 expect ( result . url ) . toContain ( 'reactnative-core-debug.tar.gz' ) ;
436569 } ) ;
437570
571+ it ( 'falls back to Maven Central when the mirror errors (outage)' , async ( ) => {
572+ globalThis . fetch = jest . fn ( async url => {
573+ if ( String ( url ) . includes ( 'repo.reactnative.dev' ) ) {
574+ throw new Error ( 'mirror unreachable' ) ;
575+ }
576+ return { status : 200 } ;
577+ } ) ;
578+ const result = await resolveRNCoreArtifact ( '0.85.0' , 'debug' , null ) ;
579+ expect ( result . url ) . toContain ( 'repo1.maven.org' ) ;
580+ } ) ;
581+
582+ it ( 'goes straight to Maven Central when the mirror is disabled' , async ( ) => {
583+ process . env . RCT_REACT_NATIVE_MAVEN_MIRROR_ENABLED = 'false' ;
584+ globalThis . fetch = routerFetch ( {
585+ 'reactnative-core-debug.tar.gz' : { status : 200 } ,
586+ } ) ;
587+ const result = await resolveRNCoreArtifact ( '0.85.0' , 'debug' , null ) ;
588+ expect ( result . url ) . toContain ( 'repo1.maven.org' ) ;
589+ expect ( globalThis . fetch ) . toHaveBeenCalledTimes ( 1 ) ;
590+ } ) ;
591+
592+ it ( 'only consults ENTERPRISE_REPOSITORY when it is set' , async ( ) => {
593+ process . env . ENTERPRISE_REPOSITORY = 'https://maven.internal.example' ;
594+ globalThis . fetch = routerFetch ( {
595+ 'reactnative-core-debug.tar.gz' : { status : 200 } ,
596+ } ) ;
597+ const result = await resolveRNCoreArtifact ( '0.85.0' , 'debug' , null ) ;
598+ expect ( result . url ) . toContain ( 'maven.internal.example' ) ;
599+ expect ( globalThis . fetch ) . toHaveBeenCalledTimes ( 1 ) ;
600+ } ) ;
601+
438602 it ( 'falls back to the snapshot URL when the release is missing' , async ( ) => {
439603 globalThis . fetch = jest . fn ( async ( url , opts ) => {
440604 // HEAD probe of the release URL → 404.
0 commit comments