diff --git a/packages/app/lib/internal/RNFBNativeEventEmitter.js b/packages/app/lib/internal/RNFBNativeEventEmitter.js index 20e8e18cec..8ef9302b11 100644 --- a/packages/app/lib/internal/RNFBNativeEventEmitter.js +++ b/packages/app/lib/internal/RNFBNativeEventEmitter.js @@ -39,6 +39,18 @@ class RNFBNativeEventEmitter extends NativeEventEmitter { if (global.RNFBDebug) { // eslint-disable-next-line no-console console.debug(`[RNFB<--Event][๐Ÿ“ฃ] ${eventType} <-`, JSON.stringify(args[0])); + // Possible leaking test if events are still being received after the test. + // This is not super accurate but it's better than nothing, e.g. if doing setup/teardown + // logic outside of a test this may cause false positives. + if (global.RNFBTest && !global.RNFBDebugInTestLeakDetection) { + // eslint-disable-next-line no-console + console.debug( + `[TEST--->Leak][๐Ÿ’ก] Possible leaking test detected! An event (โ˜๏ธ) ` + + `was received outside of any running tests which may indicates that some ` + + `listeners/event subscriptions that have not been unsubscribed from in your ` + + `test code. The last test that ran was: "${global.RNFBDebugLastTest}".`, + ); + } } return listener(...args); }; diff --git a/packages/app/lib/internal/web/firebaseStorage.js b/packages/app/lib/internal/web/firebaseStorage.js new file mode 100644 index 0000000000..143a8250cf --- /dev/null +++ b/packages/app/lib/internal/web/firebaseStorage.js @@ -0,0 +1,4 @@ +// We need to share firebase imports between modules, otherwise +// apps and instances of the firebase modules are not shared. +export * from 'firebase/app'; +export * from 'firebase/storage'; diff --git a/packages/app/lib/internal/web/utils.js b/packages/app/lib/internal/web/utils.js new file mode 100644 index 0000000000..7b013891df --- /dev/null +++ b/packages/app/lib/internal/web/utils.js @@ -0,0 +1,27 @@ +import { DeviceEventEmitter } from 'react-native'; + +// A general purpose guard function to catch errors and return a structured error object. +export function guard(fn) { + return fn().catch(e => Promise.reject(getWebError(e))); +} + +// Converts a thrown error to a structured error object +// required by RNFirebase native module internals. +export function getWebError(error) { + const obj = { + code: error.code || 'unknown', + message: error.message, + }; + // Split out prefix, since we internally prefix all error codes already. + if (obj.code.includes('/')) { + obj.code = obj.code.split('/')[1]; + } + return { + ...obj, + userInfo: obj, + }; +} + +export function emitEvent(eventName, event) { + setImmediate(() => DeviceEventEmitter.emit('rnfb_' + eventName, event)); +} diff --git a/packages/storage/e2e/StorageReference.e2e.js b/packages/storage/e2e/StorageReference.e2e.js index 16f75fe887..9e118e8b14 100644 --- a/packages/storage/e2e/StorageReference.e2e.js +++ b/packages/storage/e2e/StorageReference.e2e.js @@ -109,9 +109,11 @@ describe('storage() -> StorageReference', function () { return Promise.reject(new Error('Did not throw')); } catch (error) { error.code.should.equal('storage/object-not-found'); - error.message.should.equal( - '[storage/object-not-found] No object exists at the desired reference.', - ); + if (!Platform.other) { + error.message.should.equal( + '[storage/object-not-found] No object exists at the desired reference.', + ); + } return Promise.resolve(); } }); @@ -124,9 +126,11 @@ describe('storage() -> StorageReference', function () { return Promise.reject(new Error('Did not throw')); } catch (error) { error.code.should.equal('storage/object-not-found'); - error.message.should.equal( - '[storage/object-not-found] No object exists at the desired reference.', - ); + if (!Platform.other) { + error.message.should.equal( + '[storage/object-not-found] No object exists at the desired reference.', + ); + } return Promise.resolve(); } }); @@ -139,9 +143,11 @@ describe('storage() -> StorageReference', function () { return Promise.reject(new Error('Did not throw')); } catch (error) { error.code.should.equal('storage/unauthorized'); - error.message.should.equal( - '[storage/unauthorized] User is not authorized to perform the desired action.', - ); + if (!Platform.other) { + error.message.should.equal( + '[storage/unauthorized] User is not authorized to perform the desired action.', + ); + } return Promise.resolve(); } }); @@ -168,9 +174,11 @@ describe('storage() -> StorageReference', function () { return Promise.reject(new Error('Did not throw')); } catch (error) { error.code.should.equal('storage/object-not-found'); - error.message.should.equal( - '[storage/object-not-found] No object exists at the desired reference.', - ); + if (!Platform.other) { + error.message.should.equal( + '[storage/object-not-found] No object exists at the desired reference.', + ); + } return Promise.resolve(); } }); @@ -182,9 +190,11 @@ describe('storage() -> StorageReference', function () { return Promise.reject(new Error('Did not throw')); } catch (error) { error.code.should.equal('storage/unauthorized'); - error.message.should.equal( - '[storage/unauthorized] User is not authorized to perform the desired action.', - ); + if (!Platform.other) { + error.message.should.equal( + '[storage/unauthorized] User is not authorized to perform the desired action.', + ); + } return Promise.resolve(); } }); @@ -196,7 +206,7 @@ describe('storage() -> StorageReference', function () { const metadata = await storageReference.getMetadata(); metadata.generation.should.be.a.String(); metadata.fullPath.should.equal(`${PATH}/list/file1.txt`); - if (Platform.android) { + if (Platform.android || Platform.other) { metadata.name.should.equal('file1.txt'); } else { // FIXME on ios file comes through as fully-qualified @@ -341,9 +351,11 @@ describe('storage() -> StorageReference', function () { return Promise.reject(new Error('listAll on a forbidden directory succeeded')); } catch (error) { error.code.should.equal('storage/unauthorized'); - error.message.should.equal( - '[storage/unauthorized] User is not authorized to perform the desired action.', - ); + if (!Platform.other) { + error.message.should.equal( + '[storage/unauthorized] User is not authorized to perform the desired action.', + ); + } return Promise.resolve(); } }); @@ -366,7 +378,7 @@ describe('storage() -> StorageReference', function () { // Things that are set automagically for us metadata.generation.should.be.a.String(); metadata.fullPath.should.equal(`${PATH}/list/file1.txt`); - if (Platform.android) { + if (Platform.android || Platform.other) { metadata.name.should.equal('file1.txt'); } else { // FIXME on ios file comes through as fully-qualified @@ -402,7 +414,7 @@ describe('storage() -> StorageReference', function () { // Things that are set automagically for us and are not updatable metadata.generation.should.be.a.String(); metadata.fullPath.should.equal(`${PATH}/list/file1.txt`); - if (Platform.android) { + if (Platform.android || Platform.other) { metadata.name.should.equal('file1.txt'); } else { // FIXME on ios file comes through as fully-qualified @@ -454,6 +466,7 @@ describe('storage() -> StorageReference', function () { contentType: 'application/octet-stream', customMetadata: { keepMe: 'please', + removeMeSecondTime: null, }, }); Object.keys(metadata.customMetadata).length.should.equal(1); @@ -783,9 +796,11 @@ describe('storage() -> StorageReference', function () { return Promise.reject(new Error('Did not throw')); } catch (error) { error.code.should.equal('storage/object-not-found'); - error.message.should.equal( - '[storage/object-not-found] No object exists at the desired reference.', - ); + if (!Platform.other) { + error.message.should.equal( + '[storage/object-not-found] No object exists at the desired reference.', + ); + } return Promise.resolve(); } }); @@ -799,9 +814,11 @@ describe('storage() -> StorageReference', function () { return Promise.reject(new Error('Did not throw')); } catch (error) { error.code.should.equal('storage/object-not-found'); - error.message.should.equal( - '[storage/object-not-found] No object exists at the desired reference.', - ); + if (!Platform.other) { + error.message.should.equal( + '[storage/object-not-found] No object exists at the desired reference.', + ); + } return Promise.resolve(); } }); @@ -815,9 +832,11 @@ describe('storage() -> StorageReference', function () { return Promise.reject(new Error('Did not throw')); } catch (error) { error.code.should.equal('storage/unauthorized'); - error.message.should.equal( - '[storage/unauthorized] User is not authorized to perform the desired action.', - ); + if (!Platform.other) { + error.message.should.equal( + '[storage/unauthorized] User is not authorized to perform the desired action.', + ); + } return Promise.resolve(); } }); @@ -847,9 +866,11 @@ describe('storage() -> StorageReference', function () { return Promise.reject(new Error('Did not throw')); } catch (error) { error.code.should.equal('storage/object-not-found'); - error.message.should.equal( - '[storage/object-not-found] No object exists at the desired reference.', - ); + if (!Platform.other) { + error.message.should.equal( + '[storage/object-not-found] No object exists at the desired reference.', + ); + } return Promise.resolve(); } }); @@ -863,9 +884,11 @@ describe('storage() -> StorageReference', function () { return Promise.reject(new Error('Did not throw')); } catch (error) { error.code.should.equal('storage/unauthorized'); - error.message.should.equal( - '[storage/unauthorized] User is not authorized to perform the desired action.', - ); + if (!Platform.other) { + error.message.should.equal( + '[storage/unauthorized] User is not authorized to perform the desired action.', + ); + } return Promise.resolve(); } }); @@ -878,7 +901,7 @@ describe('storage() -> StorageReference', function () { const metadata = await getMetadata(storageReference); metadata.generation.should.be.a.String(); metadata.fullPath.should.equal(`${PATH}/list/file1.txt`); - if (Platform.android) { + if (Platform.android || Platform.other) { metadata.name.should.equal('file1.txt'); } else { // FIXME on ios file comes through as fully-qualified @@ -1049,9 +1072,11 @@ describe('storage() -> StorageReference', function () { return Promise.reject(new Error('listAll on a forbidden directory succeeded')); } catch (error) { error.code.should.equal('storage/unauthorized'); - error.message.should.equal( - '[storage/unauthorized] User is not authorized to perform the desired action.', - ); + if (!Platform.other) { + error.message.should.equal( + '[storage/unauthorized] User is not authorized to perform the desired action.', + ); + } return Promise.resolve(); } }); @@ -1077,7 +1102,7 @@ describe('storage() -> StorageReference', function () { // Things that are set automagically for us metadata.generation.should.be.a.String(); metadata.fullPath.should.equal(`${PATH}/list/file1.txt`); - if (Platform.android) { + if (Platform.android || Platform.other) { metadata.name.should.equal('file1.txt'); } else { // FIXME on ios file comes through as fully-qualified @@ -1116,7 +1141,7 @@ describe('storage() -> StorageReference', function () { // Things that are set automagically for us and are not updatable metadata.generation.should.be.a.String(); metadata.fullPath.should.equal(`${PATH}/list/file1.txt`); - if (Platform.android) { + if (Platform.android || Platform.other) { metadata.name.should.equal('file1.txt'); } else { // FIXME on ios file comes through as fully-qualified @@ -1175,6 +1200,7 @@ describe('storage() -> StorageReference', function () { contentType: 'application/octet-stream', customMetadata: { keepMe: 'please', + removeMeSecondTime: null, }, }); diff --git a/packages/storage/e2e/StorageTask.e2e.js b/packages/storage/e2e/StorageTask.e2e.js index e21904ae2e..10fc150440 100644 --- a/packages/storage/e2e/StorageTask.e2e.js +++ b/packages/storage/e2e/StorageTask.e2e.js @@ -34,6 +34,7 @@ describe('storage() -> StorageTask', function () { }); describe('writeToFile()', function () { + if (Platform.other) return; // TODO - followup - the storage emulator currently inverts not-found / permission error conditions // this one returns the permission denied against live storage, but object not found against emulator xit('errors if permission denied', async function () { @@ -51,6 +52,7 @@ describe('storage() -> StorageTask', function () { }); it('downloads a file', async function () { + if (Platform.other) return; const meta = await firebase .storage() .ref(`${PATH}/list/file1.txt`) @@ -392,6 +394,7 @@ describe('storage() -> StorageTask', function () { }); it('observer calls error callback', async function () { + if (Platform.other) return; // TODO refactor to use putString instead of writeToFile/putFile const ref = firebase.storage().ref(`${PATH}/uploadOk.jpeg`); const { resolve, promise } = Promise.defer(); const path = `${firebase.utils.FilePath.DOCUMENT_DIRECTORY}/notFoundFooFile.bar`; @@ -411,6 +414,7 @@ describe('storage() -> StorageTask', function () { }); it('observer: calls next callback', async function () { + if (Platform.other) return; // TODO refactor to use putString instead of writeToFile/putFile const ref = firebase.storage().ref(`${PATH}/ok.jpeg`); const { resolve, promise } = Promise.defer(); const path = `${firebase.utils.FilePath.DOCUMENT_DIRECTORY}/onDownload.jpeg`; @@ -427,6 +431,7 @@ describe('storage() -> StorageTask', function () { }); it('observer: calls completion callback', async function () { + if (Platform.other) return; // TODO refactor to use putString instead of writeToFile const ref = firebase.storage().ref(`${PATH}/ok.jpeg`); const { resolve, promise } = Promise.defer(); const path = `${firebase.utils.FilePath.DOCUMENT_DIRECTORY}/onDownload.jpeg`; @@ -442,6 +447,7 @@ describe('storage() -> StorageTask', function () { }); it('calls error callback', async function () { + if (Platform.other) return; // TODO refactor to use putString instead of putFile const ref = firebase.storage().ref(`${PATH}/uploadOk.jpeg`); const { resolve, promise } = Promise.defer(); const path = `${firebase.utils.FilePath.DOCUMENT_DIRECTORY}/notFoundFooFile.bar`; @@ -464,6 +470,7 @@ describe('storage() -> StorageTask', function () { }); it('calls next callback', async function () { + if (Platform.other) return; // TODO refactor to use putString instead of writeToFile const ref = firebase.storage().ref(`${PATH}/ok.jpeg`); const { resolve, promise } = Promise.defer(); const path = `${firebase.utils.FilePath.DOCUMENT_DIRECTORY}/onDownload.jpeg`; @@ -478,6 +485,7 @@ describe('storage() -> StorageTask', function () { }); it('calls completion callback', async function () { + if (Platform.other) return; // TODO refactor to use putString instead of writeToFile const ref = firebase.storage().ref(`${PATH}/ok.jpeg`); const { resolve, promise } = Promise.defer(); const path = `${firebase.utils.FilePath.DOCUMENT_DIRECTORY}/onDownload.jpeg`; @@ -491,6 +499,7 @@ describe('storage() -> StorageTask', function () { }); it('returns a subscribe fn', async function () { + if (Platform.other) return; // TODO refactor to use putString instead of writeToFile const ref = firebase.storage().ref(`${PATH}/ok.jpeg`); const { resolve, promise } = Promise.defer(); const path = `${firebase.utils.FilePath.DOCUMENT_DIRECTORY}/onDownload.jpeg`; @@ -506,6 +515,7 @@ describe('storage() -> StorageTask', function () { }); it('returns a subscribe fn supporting observer usage syntax', async function () { + if (Platform.other) return; // TODO refactor to use putString instead of writeToFile const ref = firebase.storage().ref(`${PATH}/ok.jpeg`); const { resolve, promise } = Promise.defer(); const path = `${firebase.utils.FilePath.DOCUMENT_DIRECTORY}/onDownload.jpeg`; @@ -523,6 +533,7 @@ describe('storage() -> StorageTask', function () { }); it('listens to download state', async function () { + if (Platform.other) return; // TODO refactor to use putString instead of writeToFile const ref = firebase.storage().ref(`${PATH}/ok.jpeg`); const { resolve, reject, promise } = Promise.defer(); const path = `${firebase.utils.FilePath.DOCUMENT_DIRECTORY}/onDownload.gif`; @@ -542,6 +553,7 @@ describe('storage() -> StorageTask', function () { }); it('listens to upload state', async function () { + if (Platform.other) return; // TODO refactor to use putString instead of putFile const { resolve, reject, promise } = Promise.defer(); const path = `${firebase.utils.FilePath.DOCUMENT_DIRECTORY}/onDownload.gif`; const ref = firebase.storage().ref(`${PATH}/uploadOk.jpeg`); @@ -763,6 +775,7 @@ describe('storage() -> StorageTask', function () { }); describe('writeToFile()', function () { + if (Platform.other) return; // TODO - followup - the storage emulator currently inverts not-found / permission error conditions // this one returns the permission denied against live storage, but object not found against emulator xit('errors if permission denied', async function () { @@ -780,6 +793,7 @@ describe('storage() -> StorageTask', function () { }); it('downloads a file', async function () { + if (Platform.other) return; const { getStorage, ref } = storageModular; const meta = await ref(getStorage(), `${PATH}/list/file1.txt`).writeToFile( @@ -1207,6 +1221,7 @@ describe('storage() -> StorageTask', function () { }); it('observer calls error callback', async function () { + if (Platform.other) return; // TODO refactor to use putString instead of writeToFile/putFile const { getStorage, ref, putFile } = storageModular; const storageRef = ref(getStorage(), `${PATH}/uploadOk.jpeg`); const { resolve, promise } = Promise.defer(); @@ -1230,6 +1245,7 @@ describe('storage() -> StorageTask', function () { }); it('observer: calls next callback', async function () { + if (Platform.other) return; // TODO refactor to use putString instead of writeToFile const { getStorage, ref, writeToFile } = storageModular; const storageRef = ref(getStorage(), `${PATH}/ok.jpeg`); @@ -1248,6 +1264,7 @@ describe('storage() -> StorageTask', function () { }); it('observer: calls completion callback', async function () { + if (Platform.other) return; // TODO refactor to use putString instead of writeToFile const { getStorage, ref, writeToFile } = storageModular; const storageRef = ref(getStorage(), `${PATH}/ok.jpeg`); @@ -1267,6 +1284,7 @@ describe('storage() -> StorageTask', function () { }); it('calls error callback', async function () { + if (Platform.other) return; // TODO refactor to use putString instead of writeToFile/putFile const { getStorage, ref, putFile } = storageModular; const storageRef = ref(getStorage(), `${PATH}/uploadOk.jpeg`); const { resolve, promise } = Promise.defer(); @@ -1293,6 +1311,7 @@ describe('storage() -> StorageTask', function () { }); it('calls next callback', async function () { + if (Platform.other) return; // TODO refactor to use putString instead of writeToFile const { getStorage, ref, writeToFile } = storageModular; const storageRef = ref(getStorage(), `${PATH}/ok.jpeg`); @@ -1311,6 +1330,7 @@ describe('storage() -> StorageTask', function () { }); it('calls completion callback', async function () { + if (Platform.other) return; // TODO refactor to use putString instead of writeToFile const { getStorage, ref, writeToFile } = storageModular; const storageRef = ref(getStorage(), `${PATH}/ok.jpeg`); const { resolve, promise } = Promise.defer(); @@ -1327,6 +1347,7 @@ describe('storage() -> StorageTask', function () { }); it('returns a subscribe fn', async function () { + if (Platform.other) return; // TODO refactor to use putString instead of writeToFile const { getStorage, ref, writeToFile } = storageModular; const storageRef = ref(getStorage(), `${PATH}/ok.jpeg`); const { resolve, promise } = Promise.defer(); @@ -1346,6 +1367,7 @@ describe('storage() -> StorageTask', function () { }); it('returns a subscribe fn supporting observer usage syntax', async function () { + if (Platform.other) return; // TODO refactor to use putString instead of writeToFile const { getStorage, ref, writeToFile } = storageModular; const storageRef = ref(getStorage(), `${PATH}/ok.jpeg`); const { resolve, promise } = Promise.defer(); @@ -1367,6 +1389,7 @@ describe('storage() -> StorageTask', function () { }); it('listens to download state', async function () { + if (Platform.other) return; // TODO refactor to use putString instead of writeToFile const { getStorage, ref, writeToFile } = storageModular; const storageRef = ref(getStorage(), `${PATH}/ok.jpeg`); const { resolve, reject, promise } = Promise.defer(); @@ -1389,6 +1412,7 @@ describe('storage() -> StorageTask', function () { }); it('listens to upload state', async function () { + if (Platform.other) return; // TODO refactor to use putString instead of putFile const { getStorage, ref, putFile } = storageModular; const storageRef = ref(getStorage(), `${PATH}/ok.jpeg`); const { resolve, reject, promise } = Promise.defer(); @@ -1482,6 +1506,7 @@ describe('storage() -> StorageTask', function () { }); it('successfully pauses and resumes a download', async function () { + if (Platform.other) return; // TODO refactor to use putString instead of writeToFile const { getStorage, ref, writeToFile } = storageModular; const storageRef = ref(getStorage(), Platform.ios ? '/1mbTestFile.gif' : '/cat.gif'); diff --git a/packages/storage/e2e/helpers.js b/packages/storage/e2e/helpers.js index 06728d23dd..a11211a005 100644 --- a/packages/storage/e2e/helpers.js +++ b/packages/storage/e2e/helpers.js @@ -9,8 +9,9 @@ const PATH = `${PATH_ROOT}/${ID}`; const WRITE_ONLY_NAME = 'writeOnly.jpeg'; exports.seed = async function seed(path) { + let leakDetectCurrent = global.RNFBDebugInTestLeakDetection; + global.RNFBDebugInTestLeakDetection = false; // Force the rules for the storage emulator to be what we expect - await testingUtils.initializeTestEnvironment({ projectId: getE2eTestProject(), storage: { @@ -54,7 +55,11 @@ exports.seed = async function seed(path) { await firebase.storage().ref(`${path}/list/file4.txt`).putString('File 4'); await firebase.storage().ref(`${path}/list/nested/file5.txt`).putString('File 5'); } catch (e) { - throw new Error('unable to seed storage service with test fixture: ' + e); + // eslint-disable-next-line no-console + console.error('unable to seed storage service with test fixtures'); + throw e; + } finally { + global.RNFBDebugInTestLeakDetection = leakDetectCurrent; } }; diff --git a/packages/storage/lib/index.js b/packages/storage/lib/index.js index a8cab689b4..d6453c74ae 100644 --- a/packages/storage/lib/index.js +++ b/packages/storage/lib/index.js @@ -16,6 +16,7 @@ */ import { isAndroid, isNumber, isString } from '@react-native-firebase/app/lib/common'; +import { setReactNativeModule } from '@react-native-firebase/app/lib/internal/nativeModule'; import { createModuleNamespace, FirebaseModule, @@ -25,6 +26,7 @@ import StorageReference from './StorageReference'; import StorageStatics from './StorageStatics'; import { getGsUrlParts, getHttpUrlParts, handleStorageEvent } from './utils'; import version from './version'; +import fallBackModule from './web/RNFBStorageModule'; export { getStorage, @@ -230,3 +232,5 @@ export default createModuleNamespace({ // storage().X(...); // firebase.storage().X(...); export const firebase = getFirebaseRoot(); + +setReactNativeModule(nativeModuleName, fallBackModule); diff --git a/packages/storage/lib/web/RNFBStorageModule.android.js b/packages/storage/lib/web/RNFBStorageModule.android.js new file mode 100644 index 0000000000..af77c859b1 --- /dev/null +++ b/packages/storage/lib/web/RNFBStorageModule.android.js @@ -0,0 +1,2 @@ +// No-op for android. +export default {}; diff --git a/packages/storage/lib/web/RNFBStorageModule.ios.js b/packages/storage/lib/web/RNFBStorageModule.ios.js new file mode 100644 index 0000000000..a3429ada0e --- /dev/null +++ b/packages/storage/lib/web/RNFBStorageModule.ios.js @@ -0,0 +1,2 @@ +// No-op for ios. +export default {}; diff --git a/packages/storage/lib/web/RNFBStorageModule.js b/packages/storage/lib/web/RNFBStorageModule.js new file mode 100644 index 0000000000..4bffd9701f --- /dev/null +++ b/packages/storage/lib/web/RNFBStorageModule.js @@ -0,0 +1,462 @@ +import { + getApps, + connectStorageEmulator, + getApp, + getStorage, + deleteObject, + getDownloadURL, + getMetadata, + list, + listAll, + updateMetadata, + uploadBytesResumable, + ref as firebaseStorageRef, +} from '@react-native-firebase/app/lib/internal/web/firebaseStorage'; +import { guard, getWebError, emitEvent } from '@react-native-firebase/app/lib/internal/web/utils'; +import { Base64 } from '@react-native-firebase/app/lib/common'; + +function rejectWithCodeAndMessage(code, message) { + return Promise.reject( + getWebError({ + code, + message, + }), + ); +} + +function metadataToObject(metadata) { + const out = { + bucket: metadata.bucket, + generation: metadata.generation, + metageneration: metadata.metageneration, + fullPath: metadata.fullPath, + name: metadata.name, + size: metadata.size, + timeCreated: metadata.timeCreated, + updated: metadata.updated, + md5Hash: metadata.md5Hash, + }; + + if ('cacheControl' in metadata) { + out.cacheControl = metadata.cacheControl; + } + + if ('contentLanguage' in metadata) { + out.contentLanguage = metadata.contentLanguage; + } + + if ('contentDisposition' in metadata) { + out.contentDisposition = metadata.contentDisposition; + } + + if ('contentEncoding' in metadata) { + out.contentEncoding = metadata.contentEncoding; + } + + if ('contentType' in metadata) { + out.contentType = metadata.contentType; + } + + if ('customMetadata' in metadata) { + out.customMetadata = metadata.customMetadata; + // To match Android/iOS + out.metadata = metadata.customMetadata; + } + + return out; +} + +function uploadTaskErrorToObject(error, snapshot) { + return { + ...uploadTaskSnapshotToObject(snapshot), + state: 'error', + error: getWebError(error), + }; +} + +function uploadTaskSnapshotToObject(snapshot) { + return { + totalBytes: snapshot ? snapshot.totalBytes : 0, + bytesTransferred: snapshot ? snapshot.bytesTransferred : 0, + state: snapshot ? taskStateToString(snapshot.state) : 'unknown', + metadata: snapshot ? metadataToObject(snapshot.metadata) : {}, + }; +} + +function taskStateToString(state) { + const override = { + canceled: 'cancelled', + }; + + if (state in override) { + return override[state]; + } + + return state; +} + +function makeSettableMetadata(metadata) { + return { + cacheControl: metadata.cacheControl, + contentDisposition: metadata.contentDisposition, + contentEncoding: metadata.contentEncoding, + contentType: metadata.contentType, + contentLanguage: metadata.contentLanguage, + customMetadata: metadata.customMetadata, + }; +} + +function listResultToObject(result) { + return { + nextPageToken: result.nextPageToken, + items: result.items.map(ref => ref.fullPath), + prefixes: result.prefixes.map(ref => ref.fullPath), + }; +} + +const emulatorForApp = {}; +const appInstances = {}; +const storageInstances = {}; +const tasks = {}; + +function getBucketFromUrl(url) { + const pathWithBucketName = url.substring(5); + const bucket = url.substring(0, pathWithBucketName.indexOf('/') + 5); + return bucket; +} + +function getCachedAppInstance(appName) { + return (appInstances[appName] ??= getApp(appName)); +} + +// Returns a cached Storage instance. +function getCachedStorageInstance(appName, url) { + let instance; + if (!url) { + instance = getCachedStorageInstance( + appName, + getCachedAppInstance(appName).options.storageBucket, + ); + } else { + const bucket = getBucketFromUrl(url); + instance = storageInstances[`${appName}|${bucket}`] ??= getStorage( + getCachedAppInstance(appName), + bucket, + ); + } + if (emulatorForApp[appName]) { + connectStorageEmulator(instance, emulatorForApp[appName].host, emulatorForApp[appName].port); + } + return instance; +} + +// Returns a Storage Reference. +function getReferenceFromUrl(appName, url) { + const path = url.substring(url.indexOf('/') + 1); + const instance = getCachedStorageInstance(appName, path); + return firebaseStorageRef(instance, url); +} + +const CONSTANTS = {}; +const defaultAppInstance = getApps()[0]; + +if (defaultAppInstance) { + CONSTANTS.maxDownloadRetryTime = 0; + CONSTANTS.maxOperationRetryTime = 0; + CONSTANTS.maxUploadRetryTime = 0; +} + +export default { + ...CONSTANTS, + + /** + * Delete an object at the path. + * @param {string} appName - The app name. + * @param {string} url - The path to the object. + * @return {Promise} + */ + delete(appName, url) { + return guard(async () => { + const ref = getReferenceFromUrl(appName, url); + await deleteObject(ref); + }); + }, + + /** + * Get the download URL for an object. + * @param {string} appName - The app name. + * @param {string} url - The path to the object. + * @return {Promise} The download URL. + */ + getDownloadURL(appName, url) { + return guard(async () => { + const ref = getReferenceFromUrl(appName, url); + const downloadURL = await getDownloadURL(ref); + return downloadURL; + }); + }, + + /** + * Get the metadata for an object. + * @param {string} appName - The app name. + * @param {string} url - The path to the object. + * @return {Promise} The metadata. + */ + getMetadata(appName, url) { + return guard(async () => { + const ref = getReferenceFromUrl(appName, url); + const metadata = await getMetadata(ref); + return metadataToObject(metadata); + }); + }, + + /** + * List objects at the path. + * @param {string} appName - The app name. + * @param {string} url - The path to the object. + * @param {Object} listOptions - The list options. + * @return {Promise} The list result. + */ + list(appName, url, listOptions) { + return guard(async () => { + const ref = getReferenceFromUrl(appName, url); + const listResult = await list(ref, listOptions); + return listResultToObject(listResult); + }); + }, + + /** + * List all objects at the path. + * @param {string} appName - The app name. + * @param {string} url - The path to the object. + * @return {Promise} The list result. + */ + listAll(appName, url) { + return guard(async () => { + const ref = getReferenceFromUrl(appName, url); + const listResult = await listAll(ref); + return listResultToObject(listResult); + }); + }, + + /** + * Update the metadata for an object. + * @param {string} appName - The app name. + * @param {string} url - The path to the object. + * @param {Object} metadata - The metadata (SettableMetadata). + */ + updateMetadata(appName, url, metadata) { + return guard(async () => { + const ref = getReferenceFromUrl(appName, url); + const updated = await updateMetadata(ref, makeSettableMetadata(metadata)); + return metadataToObject(updated); + }); + }, + + setMaxDownloadRetryTime() { + if (__DEV__) { + // eslint-disable-next-line no-console + console.warn( + 'The Firebase Storage `setMaxDownloadRetryTime` method is not available in the this environment.', + ); + return; + } + }, + + /** + * Set the maximum operation retry time. + * @param {string} appName - The app name. + * @param {number} milliseconds - The maximum operation retry time. + * @return {Promise} + */ + setMaxOperationRetryTime(appName, milliseconds) { + return guard(async () => { + const storage = getCachedStorageInstance(appName); + storage.maxOperationRetryTime = milliseconds; + }); + }, + + /** + * Set the maximum upload retry time. + * @param {string} appName - The app name. + * @param {number} milliseconds - The maximum upload retry time. + * @return {Promise} + */ + setMaxUploadRetryTime(appName, milliseconds) { + return guard(async () => { + const storage = getCachedStorageInstance(appName); + storage.maxUploadRetryTime = milliseconds; + }); + }, + + /** + * Use the Firebase Storage emulator. + * @param {string} appName - The app name. + * @param {string} host - The emulator host. + * @param {number} port - The emulator port. + * @return {Promise} + */ + useEmulator(appName, host, port) { + return guard(async () => { + const instance = getCachedStorageInstance(appName); + connectStorageEmulator(instance, host, port); + emulatorForApp[appName] = { host, port }; + }); + }, + + writeToFile() { + return rejectWithCodeAndMessage( + 'unsupported', + 'This operation is not supported in this environment.', + ); + }, + + /** + * Put a string to the path. + * @param {string} appName - The app name. + * @param {string} url - The path to the object. + * @param {string} string - The string to put. + * @param {string} format - The format of the string. + * @param {Object} metadata - The metadata (SettableMetadata). + * @param {string} taskId - The task ID. + * @return {Promise} The upload snapshot. + */ + putString(appName, url, string, format, metadata = {}, taskId) { + return guard(async () => { + const ref = getReferenceFromUrl(appName, url); + + let base64String = null; + + switch (format) { + case 'base64': + base64String = Base64.atob(string); + break; + case 'base64url': + base64String = Base64.atob(string.replace(/_/g, '/').replace(/-/g, '+')); + break; + } + + const byteArray = new Uint8Array(base64String ? base64String.length : 0); + + if (base64String) { + for (let i = 0; i < base64String.length; i++) { + byteArray[i] = base64String.charCodeAt(i); + } + } + + // Start a resumable upload task. + const task = uploadBytesResumable(ref, byteArray, { + ...makeSettableMetadata(metadata), + md5Hash: metadata.md5Hash, + }); + + // Store the task in the tasks map. + tasks[taskId] = task; + + const snapshot = await new Promise((resolve, reject) => { + task.on( + 'state_changed', + snapshot => { + const event = { + body: uploadTaskSnapshotToObject(snapshot), + appName, + taskId, + eventName: 'state_changed', + }; + emitEvent('storage_event', event); + }, + error => { + const errorSnapshot = uploadTaskErrorToObject(error, task.snapshot); + const event = { + body: { + ...errorSnapshot, + state: 'error', + }, + appName, + taskId, + eventName: 'state_changed', + }; + emitEvent('storage_event', event); + emitEvent('storage_event', { + ...event, + eventName: 'upload_failure', + }); + delete tasks[taskId]; + reject(error); + }, + () => { + delete tasks[taskId]; + const event = { + body: { + ...uploadTaskSnapshotToObject(snapshot), + state: 'success', + }, + appName, + taskId, + eventName: 'state_changed', + }; + emitEvent('storage_event', event); + emitEvent('storage_event', { + ...event, + eventName: 'upload_success', + }); + resolve(task.snapshot); + }, + ); + }); + + return uploadTaskSnapshotToObject(snapshot); + }); + }, + + putFile() { + return rejectWithCodeAndMessage( + 'unsupported', + 'This operation is not supported in this environment.', + ); + }, + + /** + * Set the status of a task. + * @param {string} appName - The app name. + * @param {string} taskId - The task ID. + * @param {number} status - The status. + * @return {Promise} Whether the status was set. + */ + setTaskStatus(appName, taskId, status) { + // TODO this function implementation cannot + // be tested right now since we're unable + // to create a big enough upload to be able to + // pause/resume/cancel it in time. + return guard(async () => { + const task = tasks[taskId]; + + // If the task doesn't exist, return false. + if (!task) { + return false; + } + + let result = false; + + switch (status) { + case 0: + result = await task.pause(); + break; + case 1: + result = await task.resume(); + break; + case 2: + result = await task.cancel(); + break; + } + + emitEvent('storage_event', { + data: buildUploadSnapshotMap(task.snapshot), + appName, + taskId, + }); + + return result; + }); + }, +}; diff --git a/tests/.jetrc.js b/tests/.jetrc.js index 0110bc657d..7b1a350e6a 100644 --- a/tests/.jetrc.js +++ b/tests/.jetrc.js @@ -30,7 +30,7 @@ module.exports = { macos: { async before(config) { try { - execSync(`osascript -e 'quit app "io.invertase.testing.app"'`); + execSync(`killall "io.invertase.testing"`); } catch (e) { // noop } @@ -59,7 +59,7 @@ module.exports = { }, async after(config) { try { - execSync(`osascript -e 'quit app "io.invertase.testing.app"'`); + execSync(`killall "io.invertase.testing"`); } catch (e) { // noop } diff --git a/tests/app.js b/tests/app.js index ed62b12d55..dadcac1914 100644 --- a/tests/app.js +++ b/tests/app.js @@ -25,6 +25,7 @@ const platformSupportedModules = []; if (Platform.other) { platformSupportedModules.push('app'); platformSupportedModules.push('functions'); + platformSupportedModules.push('storage'); // TODO add more modules here once they are supported. } @@ -57,7 +58,11 @@ ErrorUtils.setGlobalHandler((err, isFatal) => { function loadTests(_) { describe('React Native Firebase', function () { - this.retries(4); + if (!global.RNFBDebug) { + // Only retry tests if not debugging locally, + // otherwise it gets annoying to debug. + this.retries(4); + } before(async function () { if (platformSupportedModules.includes('functions')) @@ -76,8 +81,25 @@ function loadTests(_) { firebase.storage().useEmulator('localhost', 9199); }); + afterEach(async function afterEachTest() { + global.RNFBDebugLastTest = this.currentTest.title; + global.RNFBDebugInTestLeakDetection = false; + if (RNFBDebug) { + const emoji = this.currentTest.state === 'passed' ? 'โœ…' : 'โŒ'; + console.debug(`[TEST->Finish][${emoji}] ${this.currentTest.title}`); + console.debug(''); + } + // Allow time for things to settle between tests. + await Utils.sleep(25); + }); + beforeEach(async function beforeEachTest() { const retry = this.currentTest.currentRetry(); + if (RNFBDebug) { + console.debug(''); + console.debug(''); + console.debug(`[TEST-->Start][๐Ÿงช] ${this.currentTest.title}`); + } if (retry > 0) { if (retry === 1) { console.log(''); @@ -92,8 +114,8 @@ function loadTests(_) { } else { // Allow time for things to settle between tests. await Utils.sleep(50); - } + global.RNFBDebugInTestLeakDetection = true; }); // Load tests for each Firebase module. diff --git a/tests/globals.js b/tests/globals.js index 5c1ff383a4..a97175f3db 100644 --- a/tests/globals.js +++ b/tests/globals.js @@ -21,8 +21,32 @@ import 'should-sinon'; import 'should'; import shouldMatchers from 'should'; -// Toggle this to see bridge and event debug logs -// specific to the React Native Firebase packages +// This flag toggles on detailed debugging logging for RNFB internals. +// +// It tracks and logs the following: +// - (๐Ÿ”ต -> ๐ŸŸข/๐Ÿ”ด) Native module method calls, their arguments and results. +// e.g.: +// [RNFB->Native][๐Ÿ”ต] RNFBAppModule.eventsPing -> ["pong",{"foo":"bar"}] +// [RNFB<-Native][๐ŸŸข] RNFBAppModule.eventsPing <- undefined +// [RNFB->Native][๐Ÿ”ต] RNFBFunctionsModule.httpsCallable -> ["[DEFAULT]","us-central1","localhost",5001, ...] +// [RNFB<-Native][๐Ÿ”ด] RNFBFunctionsModule.httpsCallable <- {"code":"functions/deadline-exceeded", ...} +// - (๐Ÿ‘‚ -> ๐Ÿ“ฃ) Subscriptions to native event emitters and receiving of events from native emitters. +// e.g.: +// [RNFB-->Event][๐Ÿ‘‚] storage_event -> listening +// [RNFB<--Event][๐Ÿ“ฃ] storage_event <- {"body":{...},"appName":"[DEFAULT]","taskId":32,"eventName":"state_changed"} +// - (๐Ÿ’ก) Possible leaking tests detection. This is a heuristic based on the assumption that +// tests should not be receiving events when no tests are running. This is not perfect +// but it's better than nothing. +// e.g.: +// [TEST--->Leak][๐Ÿ’ก] Possible leaking test detected! ... The last test that ran was: "...". +// - (๐Ÿงช -> โœ…/โŒ) Start and end of each test, mainly for grouping logs. +// e.g.: +// [TEST-->Start][๐Ÿงช] uploads a base64url string +// [RNFB->Native][๐Ÿ”ต] RNFBStorageModule.putString [...] +// [RNFB<--Event][๐Ÿ“ฃ] storage_event <- {...} +// [RNFB<--Event][๐Ÿ“ฃ] storage_event <- {...} +// [RNFB<-Native][๐ŸŸข] RNFBStorageModule.putString <- {...} +// [TEST->Finish][โœ…] uploads a base64url string global.RNFBDebug = false; // RNFB packages. @@ -310,6 +334,7 @@ Object.defineProperty(global, 'modular', { if (global.Platform.other) { firebase.initializeApp(global.FirebaseHelpers.app.config()); + firebase.initializeApp(global.FirebaseHelpers.app.config(), 'secondaryFromNative'); } Object.defineProperty(global, 'functionsModular', { @@ -417,3 +442,5 @@ global.jet = { // TODO toggle this correct in CI only. global.isCI = true; +// Used to tell our internals that we are running tests. +global.RNFBTest = true; diff --git a/tests/package.json b/tests/package.json index be6e4ef7e1..174c0f9e2d 100644 --- a/tests/package.json +++ b/tests/package.json @@ -43,7 +43,7 @@ "firebase-tools": "^13.11.2", "jest-circus": "^29.7.0", "jest-environment-node": "^29.7.0", - "jet": "0.9.0-dev.11", + "jet": "0.9.0-dev.12", "mocha": "^10.4.0", "nyc": "^15.1.0", "patch-package": "^8.0.0", diff --git a/yarn.lock b/yarn.lock index 7d36cebcc3..20902e5acb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14652,9 +14652,9 @@ __metadata: languageName: node linkType: hard -"jet@npm:0.9.0-dev.11": - version: 0.9.0-dev.11 - resolution: "jet@npm:0.9.0-dev.11" +"jet@npm:0.9.0-dev.12": + version: 0.9.0-dev.12 + resolution: "jet@npm:0.9.0-dev.12" dependencies: "@types/mocha": "npm:^10.0.6" babel-plugin-istanbul: "npm:^6.1.1" @@ -14671,7 +14671,7 @@ __metadata: react-native: "*" bin: jet: jet.js - checksum: 10/b1feb0a4e6c8c817db599279eb9beebb064eb25bd73e4c3fc12c2ebab8b599f7a530eebd6fb75ef740dc7eee8257e7096f64f6ee8fe315612ccbb68785121b04 + checksum: 10/4255dbe287d6cf3696ffb02956122fa6ecc6d107c77ac0e2fa6c4700d255da3fb43ea243f16f92358412a7959803f60f9b8dd90ff9c1d01fafc9d880bd0d6af4 languageName: node linkType: hard @@ -19413,7 +19413,7 @@ __metadata: firebase-tools: "npm:^13.11.2" jest-circus: "npm:^29.7.0" jest-environment-node: "npm:^29.7.0" - jet: "npm:0.9.0-dev.11" + jet: "npm:0.9.0-dev.12" mocha: "npm:^10.4.0" nyc: "npm:^15.1.0" patch-package: "npm:^8.0.0"