diff --git a/docs/src/pages/configurations/composition/index.md b/docs/src/pages/configurations/composition/index.md index e17f6436c78..2c51d1aad25 100644 --- a/docs/src/pages/configurations/composition/index.md +++ b/docs/src/pages/configurations/composition/index.md @@ -77,11 +77,11 @@ For an example what this file should look like, see: [here](https://next--storyb If you have some authentication layer on your hosted storybook, the composing the storybook will fail. Storybook will show a message in the sidebar if that happens. -You can assist the user by creating a `metadata.json` file with a `authUrl` field, and ensure this file **is** loadable (even in the user is not authenticated): +You can assist the user by creating a `metadata.json` file with a `loginUrl` field, and ensure this file **is** loadable (even in the user is not authenticated): ```json { - "authUrl": "https://example.com" + "loginUrl": "https://example.com" } ``` diff --git a/lib/api/src/modules/refs.ts b/lib/api/src/modules/refs.ts index cd7138c2f93..00595d930ed 100644 --- a/lib/api/src/modules/refs.ts +++ b/lib/api/src/modules/refs.ts @@ -15,9 +15,11 @@ export interface SubState { type Versions = Record; -export type SetRefData = Omit & { - stories?: StoriesRaw; -}; +export type SetRefData = Partial< + Omit & { + stories?: StoriesRaw; + } +>; export interface SubAPI { findRef: (source: string) => ComposedRef; @@ -36,7 +38,7 @@ export interface ComposedRef { type?: 'auto-inject' | 'unknown' | 'lazy'; stories: StoriesHash; versions?: Versions; - authUrl?: string; + loginUrl?: string; ready?: boolean; error?: any; } @@ -48,12 +50,12 @@ export type RefUrl = string; // eslint-disable-next-line no-useless-escape const findFilename = /(\/((?:[^\/]+?)\.[^\/]+?)|\/)$/; -const allSettled = (promises: Promise[]) => +const allSettled = (promises: Promise[]): Promise<(Response | false)[]> => Promise.all( - promises.map((promise, i) => + promises.map((promise) => promise.then( - (r) => (r.ok ? r : false), - () => false + (r) => (r.ok ? r : (false as const)), + () => false as const ) ) ); @@ -98,7 +100,7 @@ const map = ( return input; }; -export const init: ModuleFn = ({ store, provider, fullAPI }) => { +export const init: ModuleFn = ({ store, provider, fullAPI }, { runCheck = true } = {}) => { const api: SubAPI = { findRef: (source) => { const refs = api.getRefs(); @@ -123,7 +125,7 @@ export const init: ModuleFn = ({ store, provider, fullAPI }) => { checkRef: async (ref) => { const { id, url } = ref; - const loadedData: { error?: Error; stories?: StoriesRaw } = {}; + const loadedData: { error?: Error; stories?: StoriesRaw; loginUrl?: string } = {}; const [included, omitted, iframe] = await allSettled([ fetch(`${url}/stories.json`, { @@ -144,7 +146,7 @@ export const init: ModuleFn = ({ store, provider, fullAPI }) => { }), ]); - const handle = async (request: Promise | false) => { + const handle = async (request: Response | false): Promise => { if (request) { return Promise.resolve(request) .then((response) => (response.ok ? response.json() : {})) @@ -168,10 +170,10 @@ export const init: ModuleFn = ({ store, provider, fullAPI }) => { `, } as Error; } else if (omitted || included) { - const credentials = !omitted ? 'include' : 'omit'; + const credentials = included ? 'include' : 'omit'; const [stories, metadata] = await Promise.all([ - handle(omitted || included), + included ? handle(included) : handle(omitted), handle( fetch(`${url}/metadata.json`, { headers: { @@ -179,14 +181,14 @@ export const init: ModuleFn = ({ store, provider, fullAPI }) => { }, credentials, cache: 'no-cache', - }) + }).catch(() => false) ), ]); Object.assign(loadedData, { ...stories, ...metadata }); } - api.setRef(id, { + await api.setRef(id, { id, url, ...loadedData, @@ -230,9 +232,11 @@ export const init: ModuleFn = ({ store, provider, fullAPI }) => { r.type = 'unknown'; }); - Object.entries(refs).forEach(([k, v]) => { - api.checkRef(v as SetRefData); - }); + if (runCheck) { + Object.entries(refs).forEach(([k, v]) => { + api.checkRef(v as SetRefData); + }); + } return { api, diff --git a/lib/api/src/tests/ref-mockdata.login-required.json b/lib/api/src/tests/ref-mockdata.login-required.json index 784e17e77ba..64c19b486fc 100644 --- a/lib/api/src/tests/ref-mockdata.login-required.json +++ b/lib/api/src/tests/ref-mockdata.login-required.json @@ -1,3 +1,3 @@ { - "authUrl": "https://example.com" + "loginUrl": "https://example.com" } \ No newline at end of file diff --git a/lib/api/src/tests/refs.test.js b/lib/api/src/tests/refs.test.js index 930e65c135b..1c54af75597 100644 --- a/lib/api/src/tests/refs.test.js +++ b/lib/api/src/tests/refs.test.js @@ -1,7 +1,10 @@ -import { getSourceType } from '../modules/refs'; +import { fetch } from 'global'; +import { getSourceType, init as initRefs } from '../modules/refs'; jest.mock('global', () => { - const globalMock = {}; + const globalMock = { + fetch: jest.fn(() => Promise.resolve({})), + }; // Change global.location value to handle edge cases // Add additional variations of global.location mock return values in this array. // NOTE: The order must match the order that global.location is called in the unit tests. @@ -20,7 +23,80 @@ jest.mock('global', () => { return globalMock; }); -describe('refs', () => { +const provider = { + getConfig: () => ({ + refs: { + fake: { + id: 'fake', + url: 'https://example.com', + title: 'Fake', + }, + }, + }), +}; + +const store = { + getState: () => ({ + refs: { + fake: { + id: 'fake', + url: 'https://example.com', + title: 'Fake', + }, + }, + }), + setState: jest.fn(() => {}), +}; + +const emptyResponse = Promise.resolve({ + ok: true, + json: async () => ({}), +}); + +const setupResponses = ( + a = emptyResponse, + b = emptyResponse, + c = emptyResponse, + d = emptyResponse +) => { + fetch.mockClear(); + store.setState.mockClear(); + + fetch.mockImplementation((l, o) => { + if (l.includes('stories') && o.credentials === 'omit') { + return Promise.resolve({ + ok: a.ok, + json: a.response, + }); + } + if (l.includes('stories') && o.credentials === 'include') { + return Promise.resolve({ + ok: b.ok, + json: b.response, + }); + } + if (l.includes('iframe')) { + return Promise.resolve({ + ok: c.ok, + json: c.response, + }); + } + if (l.includes('metadata')) { + return Promise.resolve({ + ok: d.ok, + json: d.response, + }); + } + return Promise.resolve({ + ok: false, + json: () => { + throw new Error('not ok'); + }, + }); + }); +}; + +describe('Refs API', () => { describe('getSourceType(source)', () => { // These tests must be run first and in correct order. // The order matches the "edgecaseLocations" order in the 'global' mock function above. @@ -53,4 +129,490 @@ describe('refs', () => { ]); }); }); + + describe('checkRef', () => { + it('on initialization it checks refs', async () => { + // given + initRefs({ provider, store }); + + expect(fetch.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "https://example.com/stories.json", + Object { + "credentials": "include", + "headers": Object { + "Accept": "application/json", + }, + }, + ], + Array [ + "https://example.com/stories.json", + Object { + "credentials": "omit", + "headers": Object { + "Accept": "application/json", + }, + }, + ], + Array [ + "https://example.com/iframe.html", + Object { + "cors": "no-cors", + "credentials": "omit", + }, + ], + ] + `); + }); + + it('checks refs (all fail)', async () => { + // given + const { api } = initRefs({ provider, store }, { runCheck: false }); + + setupResponses( + { + ok: false, + response: async () => { + throw new Error('Failed to fetch'); + }, + }, + { + ok: false, + response: async () => { + throw new Error('Failed to fetch'); + }, + }, + { + ok: false, + response: async () => { + throw new Error('not ok'); + }, + } + ); + + await api.checkRef({ + id: 'fake', + url: 'https://example.com', + title: 'Fake', + }); + + expect(fetch.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "https://example.com/stories.json", + Object { + "credentials": "include", + "headers": Object { + "Accept": "application/json", + }, + }, + ], + Array [ + "https://example.com/stories.json", + Object { + "credentials": "omit", + "headers": Object { + "Accept": "application/json", + }, + }, + ], + Array [ + "https://example.com/iframe.html", + Object { + "cors": "no-cors", + "credentials": "omit", + }, + ], + ] + `); + + expect(store.setState.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "refs": Object { + "fake": Object { + "error": Object { + "message": "Error: Loading of ref failed + at fetch (lib/api/src/modules/refs.ts) + + URL: https://example.com + + We weren't able to load the above URL, + it's possible a CORS error happened. + + Please check your dev-tools network tab.", + }, + "id": "fake", + "ready": false, + "stories": undefined, + "title": "Fake", + "type": "auto-inject", + "url": "https://example.com", + }, + }, + } + `); + }); + + it('checks refs (success)', async () => { + // given + const { api } = initRefs({ provider, store }, { runCheck: false }); + + setupResponses( + { + ok: true, + response: async () => ({ stories: {} }), + }, + { + ok: true, + response: async () => ({ stories: {} }), + }, + { + ok: true, + response: async () => { + throw new Error('not ok'); + }, + }, + { + ok: true, + response: async () => ({ + versions: {}, + }), + } + ); + + await api.checkRef({ + id: 'fake', + url: 'https://example.com', + title: 'Fake', + }); + + expect(fetch.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "https://example.com/stories.json", + Object { + "credentials": "include", + "headers": Object { + "Accept": "application/json", + }, + }, + ], + Array [ + "https://example.com/stories.json", + Object { + "credentials": "omit", + "headers": Object { + "Accept": "application/json", + }, + }, + ], + Array [ + "https://example.com/iframe.html", + Object { + "cors": "no-cors", + "credentials": "omit", + }, + ], + Array [ + "https://example.com/metadata.json", + Object { + "cache": "no-cache", + "credentials": "include", + "headers": Object { + "Accept": "application/json", + }, + }, + ], + ] + `); + + expect(store.setState.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "refs": Object { + "fake": Object { + "id": "fake", + "ready": false, + "stories": Object {}, + "title": "Fake", + "type": "lazy", + "url": "https://example.com", + "versions": Object {}, + }, + }, + } + `); + }); + + it('checks refs (auth)', async () => { + // given + const { api } = initRefs({ provider, store }, { runCheck: false }); + + setupResponses( + { + ok: true, + response: async () => ({ loginUrl: 'https://example.com/login' }), + }, + { + ok: false, + response: async () => { + throw new Error('not ok'); + }, + }, + { + ok: true, + response: async () => { + throw new Error('not ok'); + }, + }, + { + ok: false, + response: async () => { + throw new Error('not ok'); + }, + } + ); + + await api.checkRef({ + id: 'fake', + url: 'https://example.com', + title: 'Fake', + }); + + expect(fetch.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "https://example.com/stories.json", + Object { + "credentials": "include", + "headers": Object { + "Accept": "application/json", + }, + }, + ], + Array [ + "https://example.com/stories.json", + Object { + "credentials": "omit", + "headers": Object { + "Accept": "application/json", + }, + }, + ], + Array [ + "https://example.com/iframe.html", + Object { + "cors": "no-cors", + "credentials": "omit", + }, + ], + Array [ + "https://example.com/metadata.json", + Object { + "cache": "no-cache", + "credentials": "omit", + "headers": Object { + "Accept": "application/json", + }, + }, + ], + ] + `); + + expect(store.setState.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "refs": Object { + "fake": Object { + "id": "fake", + "loginUrl": "https://example.com/login", + "ready": false, + "stories": undefined, + "title": "Fake", + "type": "auto-inject", + "url": "https://example.com", + }, + }, + } + `); + }); + + it('checks refs (mixed)', async () => { + // given + const { api } = initRefs({ provider, store }, { runCheck: false }); + + fetch.mockClear(); + store.setState.mockClear(); + + setupResponses( + { + ok: true, + response: async () => ({ loginUrl: 'https://example.com/login' }), + }, + { + ok: true, + response: async () => ({ stories: {} }), + }, + { + ok: true, + response: async () => { + throw new Error('not ok'); + }, + }, + { + ok: true, + response: async () => ({ + versions: { '1.0.0': 'https://example.com/v1', '2.0.0': 'https://example.com' }, + }), + } + ); + + await api.checkRef({ + id: 'fake', + url: 'https://example.com', + title: 'Fake', + }); + + expect(fetch.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "https://example.com/stories.json", + Object { + "credentials": "include", + "headers": Object { + "Accept": "application/json", + }, + }, + ], + Array [ + "https://example.com/stories.json", + Object { + "credentials": "omit", + "headers": Object { + "Accept": "application/json", + }, + }, + ], + Array [ + "https://example.com/iframe.html", + Object { + "cors": "no-cors", + "credentials": "omit", + }, + ], + Array [ + "https://example.com/metadata.json", + Object { + "cache": "no-cache", + "credentials": "include", + "headers": Object { + "Accept": "application/json", + }, + }, + ], + ] + `); + + expect(store.setState.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "refs": Object { + "fake": Object { + "id": "fake", + "ready": false, + "stories": Object {}, + "title": "Fake", + "type": "lazy", + "url": "https://example.com", + "versions": Object { + "1.0.0": "https://example.com/v1", + "2.0.0": "https://example.com", + }, + }, + }, + } + `); + }); + + it('checks refs (cors)', async () => { + // given + const { api } = initRefs({ provider, store }, { runCheck: false }); + + setupResponses( + { + ok: false, + response: async () => { + throw new Error('Failed to fetch'); + }, + }, + { + ok: false, + response: async () => { + throw new Error('Failed to fetch'); + }, + }, + { + ok: true, + response: async () => { + throw new Error('not ok'); + }, + }, + { + ok: false, + response: async () => { + throw new Error('Failed to fetch'); + }, + } + ); + + await api.checkRef({ + id: 'fake', + url: 'https://example.com', + title: 'Fake', + }); + + expect(fetch.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "https://example.com/stories.json", + Object { + "credentials": "include", + "headers": Object { + "Accept": "application/json", + }, + }, + ], + Array [ + "https://example.com/stories.json", + Object { + "credentials": "omit", + "headers": Object { + "Accept": "application/json", + }, + }, + ], + Array [ + "https://example.com/iframe.html", + Object { + "cors": "no-cors", + "credentials": "omit", + }, + ], + ] + `); + + expect(store.setState.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "refs": Object { + "fake": Object { + "id": "fake", + "ready": false, + "stories": undefined, + "title": "Fake", + "type": "auto-inject", + "url": "https://example.com", + }, + }, + } + `); + }); + }); }); diff --git a/lib/api/src/version.ts b/lib/api/src/version.ts index 42fcc6ce797..2a55a8b9514 100644 --- a/lib/api/src/version.ts +++ b/lib/api/src/version.ts @@ -1 +1 @@ -export const version = '6.0.0-beta.31'; \ No newline at end of file +export const version = '6.0.0-beta.31'; diff --git a/lib/ui/src/components/sidebar/RefBlocks.tsx b/lib/ui/src/components/sidebar/RefBlocks.tsx index 22bf7bf9dfe..abd36d1f1ac 100644 --- a/lib/ui/src/components/sidebar/RefBlocks.tsx +++ b/lib/ui/src/components/sidebar/RefBlocks.tsx @@ -160,7 +160,10 @@ const ErrorFormatter: FunctionComponent<{ error: Error }> = ({ error }) => { ); }; -export const AuthBlock: FunctionComponent<{ authUrl: string; id: string }> = ({ authUrl, id }) => { +export const AuthBlock: FunctionComponent<{ loginUrl: string; id: string }> = ({ + loginUrl, + id, +}) => { const [isAuthAttempted, setAuthAttempted] = useState(false); const refresh = useCallback(() => { @@ -169,12 +172,12 @@ export const AuthBlock: FunctionComponent<{ authUrl: string; id: string }> = ({ const open = useCallback((e) => { e.preventDefault(); - const childWindow = window.open(authUrl, `storybook_auth_${id}`, 'resizable,scrollbars'); + const childWindow = window.open(loginUrl, `storybook_auth_${id}`, 'resizable,scrollbars'); // poll for window to close const timer = setInterval(() => { if (!childWindow) { - logger.error('unable to access authUrl window'); + logger.error('unable to access loginUrl window'); clearInterval(timer); } else if (childWindow.closed) { clearInterval(timer); @@ -189,8 +192,8 @@ export const AuthBlock: FunctionComponent<{ authUrl: string; id: string }> = ({ {isAuthAttempted ? ( - Authentication on {authUrl} seems to have concluded, refresh the page - to fetch this storybook + Authentication on {loginUrl} seems to have concluded, refresh the + page to fetch this storybook