diff --git a/src/actions/receive-profile.js b/src/actions/receive-profile.js index 9c0456cf5a..fa6e7e207b 100644 --- a/src/actions/receive-profile.js +++ b/src/actions/receive-profile.js @@ -40,7 +40,7 @@ import { initializeHiddenGlobalTracks, getVisibleThreads, } from '../profile-logic/tracks'; -import { getProfile, getProfileOrNull } from '../selectors/profile'; +import { getProfileOrNull } from '../selectors/profile'; import { getView } from '../selectors/app'; import { setDataSource } from './profile-view'; @@ -997,7 +997,7 @@ export function retrieveProfilesToCompare( export function getProfilesFromRawUrl( location: Location ): ThunkAction< - Promise<{| profile: Profile, shouldSetupInitialUrlState: boolean |}> + Promise<{| profile: Profile | null, shouldSetupInitialUrlState: boolean |}> > { return async (dispatch, getState) => { const pathParts = location.pathname.split('/').filter(d => d); @@ -1048,8 +1048,10 @@ export function getProfilesFromRawUrl( ); } + // Profile may be null only for the `from-addon` dataSource since we do + // not `await` for retrieveProfileFromAddon function. return { - profile: getProfile(getState()), + profile: getProfileOrNull(getState()), shouldSetupInitialUrlState, }; }; diff --git a/src/components/app/UrlManager.js b/src/components/app/UrlManager.js index 6163f5a37e..96caa8a96c 100644 --- a/src/components/app/UrlManager.js +++ b/src/components/app/UrlManager.js @@ -97,15 +97,17 @@ class UrlManager extends React.PureComponent { try { // Process the raw url and fetch the profile. const results: { - profile: Profile, + profile: Profile | null, shouldSetupInitialUrlState: boolean, } = await getProfilesFromRawUrl(window.location); // Manually coerce these into the proper type due to the FlowFixMe above. - const profile: Profile = results.profile; + // Profile may be null only for the `from-addon` dataSource since we do + // not `await` for retrieveProfileFromAddon function. + const profile: Profile | null = results.profile; const shouldSetupInitialUrlState: boolean = results.shouldSetupInitialUrlState; - if (shouldSetupInitialUrlState) { + if (profile !== null && shouldSetupInitialUrlState) { setupInitialUrlState(window.location, profile); } else { urlSetupDone(); diff --git a/src/test/components/UrlManager.test.js b/src/test/components/UrlManager.test.js index 5f29beaa12..910e8685f3 100644 --- a/src/test/components/UrlManager.test.js +++ b/src/test/components/UrlManager.test.js @@ -11,9 +11,16 @@ import { getUrlSetupPhase } from '../../selectors/app'; import UrlManager from '../../components/app/UrlManager'; import { blankStore } from '../fixtures/stores'; import { getDataSource } from '../../selectors/url-state'; +import { waitUntilState } from '../fixtures/utils'; +import { createGeckoProfile } from '../fixtures/profiles/gecko-profile'; +import * as receiveProfile from '../../actions/receive-profile'; + +jest.mock('../../profile-logic/symbol-store'); describe('UrlManager', function() { function setup(urlPath: ?string) { + (receiveProfile: any).doSymbolicateProfile = jest.fn(() => async () => {}); + if (typeof urlPath === 'string') { // jsdom doesn't allow us to rewrite window.location. Instead, use the // History API to properly set the current location. @@ -28,33 +35,83 @@ describe('UrlManager', function() { Contents ); - return { dispatch, getState, createUrlManager }; + + const waitUntilUrlSetupPhase = phase => + waitUntilState(store, state => getUrlSetupPhase(state) === phase); + + return { dispatch, getState, createUrlManager, waitUntilUrlSetupPhase }; } - it('sets up the URL', function() { - const { getState, createUrlManager } = setup(); + beforeEach(function() { + const profileJSON = createGeckoProfile(); + const mockGetProfile = jest.fn().mockResolvedValue(profileJSON); + + const geckoProfiler = { + getProfile: mockGetProfile, + getSymbolTable: jest + .fn() + .mockRejectedValue(new Error('No symbol tables available')), + }; + window.fetch = jest + .fn() + .mockRejectedValue(new Error('No symbolication API in place')); + window.geckoProfilerPromise = Promise.resolve(geckoProfiler); + }); + + afterEach(function() { + delete window.geckoProfilerPromise; + delete window.fetch; + }); + + it('sets up the URL', async function() { + const { getState, createUrlManager, waitUntilUrlSetupPhase } = setup(); expect(getUrlSetupPhase(getState())).toBe('initial-load'); createUrlManager(); + expect(getUrlSetupPhase(getState())).toBe('loading-profile'); + + await waitUntilUrlSetupPhase('done'); + expect(getUrlSetupPhase(getState())).toBe('done'); + expect(getDataSource(getState())).toMatch('none'); }); - it('has no data source by default', function() { - const { getState, createUrlManager } = setup(); + it('has no data source by default', async function() { + const { getState, createUrlManager, waitUntilUrlSetupPhase } = setup(); createUrlManager(); + await waitUntilUrlSetupPhase('done'); expect(getDataSource(getState())).toMatch('none'); }); - it('sets the data source to from-addon', function() { - const { getState, createUrlManager } = setup('/from-addon/'); + it('sets the data source to from-addon', async function() { + const { getState, createUrlManager, waitUntilUrlSetupPhase } = setup( + '/from-addon/' + ); expect(getDataSource(getState())).toMatch('none'); createUrlManager(); + + await waitUntilUrlSetupPhase('done'); expect(getDataSource(getState())).toMatch('from-addon'); }); - it('redirects from-file back to no data source', function() { - const { getState, createUrlManager } = setup('/from-file/'); + it('redirects from-file back to no data source', async function() { + const { getState, createUrlManager, waitUntilUrlSetupPhase } = setup( + '/from-file/' + ); expect(getDataSource(getState())).toMatch('none'); createUrlManager(); + + await waitUntilUrlSetupPhase('done'); expect(getDataSource(getState())).toMatch('none'); }); + + it('sets the data source to public', async function() { + const { getState, createUrlManager, waitUntilUrlSetupPhase } = setup( + '/public/' + ); + expect(getDataSource(getState())).toMatch('none'); + createUrlManager(); + + await waitUntilUrlSetupPhase('done'); + expect(getDataSource(getState())).toMatch('public'); + }); }); diff --git a/src/test/store/receive-profile.test.js b/src/test/store/receive-profile.test.js index 31348a3914..63497ee841 100644 --- a/src/test/store/receive-profile.test.js +++ b/src/test/store/receive-profile.test.js @@ -6,6 +6,7 @@ import type { Profile } from '../../types/profile'; import sinon from 'sinon'; +import { oneLineTrim } from 'common-tags'; import { getEmptyProfile } from '../../profile-logic/data-structures'; import { viewProfileFromPathInZipFile } from '../../actions/zipped-profiles'; @@ -17,23 +18,29 @@ import { getThreadSelectors } from '../../selectors/per-thread'; import { getView } from '../../selectors/app'; import { viewProfile, + finalizeProfileView, retrieveProfileFromAddon, retrieveProfileFromStore, retrieveProfileOrZipFromUrl, retrieveProfileFromFile, retrieveProfilesToCompare, _fetchProfile, + getProfilesFromRawUrl, } from '../../actions/receive-profile'; import { SymbolsNotFoundError } from '../../profile-logic/errors'; import { createGeckoProfile } from '../fixtures/profiles/gecko-profile'; import JSZip from 'jszip'; -import { serializeProfile } from '../../profile-logic/process-profile'; +import { + serializeProfile, + processProfile, +} from '../../profile-logic/process-profile'; import { getProfileFromTextSamples, addMarkersToThreadWithCorrespondingSamples, } from '../fixtures/profiles/processed-profile'; import { getHumanReadableTracks } from '../fixtures/profiles/tracks'; +import { waitUntilState } from '../fixtures/utils'; import { compress } from '../../utils/gz'; @@ -1360,6 +1367,188 @@ describe('actions/receive-profile', function() { expect(nodeData.selfTime).toBe(4); }); }); + + describe('getProfilesFromRawUrl', function() { + function fetch200Response(profile: string) { + return { + ok: true, + status: 200, + headers: { + get: () => 'application/json', + }, + json: () => Promise.resolve(JSON.parse(profile)), + }; + } + + async function setup(location: Object, requiredProfile: number = 1) { + const profile = _getSimpleProfile(); + const geckoProfile = createGeckoProfile(); + + // Add mock fetch response for the required number of times. + // Usually it's 1 but it can be also 2 for `compare` dataSource. + for (let i = 0; i < requiredProfile; i++) { + window.fetch.mockResolvedValueOnce( + fetch200Response(serializeProfile(profile)) + ); + } + + const geckoProfiler = { + getProfile: jest.fn().mockResolvedValue(geckoProfile), + getSymbolTable: jest + .fn() + .mockRejectedValue(new Error('No symbol tables available')), + }; + window.geckoProfilerPromise = Promise.resolve(geckoProfiler); + + simulateSymbolStoreHasNoCache(); + + // Silence the logs coming from the promise rejections above. + jest.spyOn(console, 'log').mockImplementation(() => {}); + + const store = blankStore(); + await store.dispatch(getProfilesFromRawUrl(location)); + + // To find stupid mistakes more easily, check that we didn't get a fatal + // error here. If we got one, let's rethrow the error. + const view = getView(store.getState()); + if (view.phase === 'FATAL_ERROR') { + throw view.error; + } + + const waitUntilPhase = phase => + waitUntilState(store, state => getView(state).phase === phase); + + const waitUntilSymbolication = () => + waitUntilState( + store, + state => ProfileViewSelectors.getSymbolicationStatus(state) === 'DONE' + ); + + return { + profile, + geckoProfile, + waitUntilPhase, + waitUntilSymbolication, + ...store, + }; + } + + beforeEach(function() { + // The stub makes it easy to return different values for different + // arguments. Here we define the default return value because there is no + // argument specified. + window.fetch = jest.fn(); + window.fetch.mockImplementation(() => + Promise.reject(new Error('No more answers have been configured.')) + ); + }); + + afterEach(function() { + delete window.fetch; + delete window.geckoProfilerPromise; + }); + + it('retrieves profile from a `public` data source and loads it', async function() { + const { profile, getState, dispatch } = await setup({ + pathname: '/public/fakehash/', + search: '?thread=0&v=4', + hash: '', + }); + + // Check if we loaded the profile data successfully. + expect(ProfileViewSelectors.getProfile(getState())).toEqual(profile); + expect(getView(getState()).phase).toBe('PROFILE_LOADED'); + + // Check if we can successfully finalize the profile view. + await dispatch(finalizeProfileView()); + expect(getView(getState()).phase).toBe('DATA_LOADED'); + }); + + it('retrieves profile from a `from-url` data source and loads it', async function() { + const { profile, getState, dispatch } = await setup({ + // '/from-url/https://fakeurl.com/fakeprofile.json/' + pathname: '/from-url/https%3A%2F%2Ffakeurl.com%2Ffakeprofile.json/', + search: '', + hash: '', + }); + + // Check if we loaded the profile data successfully. + expect(ProfileViewSelectors.getProfile(getState())).toEqual(profile); + expect(getView(getState()).phase).toBe('PROFILE_LOADED'); + + // Check if we can successfully finalize the profile view. + await dispatch(finalizeProfileView()); + expect(getView(getState()).phase).toBe('DATA_LOADED'); + }); + + it('retrieves profile from a `compare` data source and loads it', async function() { + const url1 = 'http://fake-url.com/public/1?thread=0'; + const url2 = 'http://fake-url.com/public/2?thread=0'; + const { getState, dispatch } = await setup( + { + pathname: '/compare/FAKEURL/', + search: oneLineTrim` + ?profiles[]=${encodeURIComponent(url1)} + &profiles[]=${encodeURIComponent(url2)} + `, + hash: '', + }, + 2 + ); + + // Check if we loaded the profile data successfully. + expect(getView(getState()).phase).toBe('PROFILE_LOADED'); + + // Check if we can successfully finalize the profile view. + await dispatch(finalizeProfileView()); + expect(getView(getState()).phase).toBe('DATA_LOADED'); + }); + + it('retrieves profile from a `from-addon` data source and loads it', async function() { + const { geckoProfile, getState, waitUntilPhase } = await setup( + { + pathname: '/from-addon/', + search: '', + hash: '', + }, + 0 + ); + + // Differently, `from-addon` calls the finalizeProfileView internally, + // we don't need to call it again. + await waitUntilPhase('DATA_LOADED'); + const processedProfile = processProfile(geckoProfile); + expect(ProfileViewSelectors.getProfile(getState())).toEqual( + processedProfile + ); + }); + + it('finishes symbolication for `from-addon` data source', async function() { + const { waitUntilSymbolication } = await setup( + { + pathname: '/from-addon/', + search: '', + hash: '', + }, + 0 + ); + + // It should successfully symbolicate the profiles that are loaded from addon. + await waitUntilSymbolication(); + }); + + it('does not retrieve profile from other data sources', async function() { + ['/none/', '/from-file/', 'local'].forEach(async sourcePath => { + await expect( + setup({ + pathname: sourcePath, + search: '', + hash: '', + }) + ).rejects.toThrow(); + }); + }); + }); }); /**